diff --git a/VERSION b/VERSION
index 8bbe6cf..bb576db 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.2
+2.3
diff --git a/backend/routes/upgrade.py b/backend/routes/upgrade.py
index 5ef0760..1e3b8aa 100644
--- a/backend/routes/upgrade.py
+++ b/backend/routes/upgrade.py
@@ -18,17 +18,25 @@ import re
logger = setup_logger()
router = APIRouter()
+# 版本列表(按顺序)
# 版本列表(按顺序)
ALL_VERSIONS = {
+ '1.0': 'v1.0.sql',
+ '1.1': 'v1.1.sql',
+ '1.2': 'v1.2.sql',
+ '1.3': 'v1.3.sql',
+ '1.4': 'v1.4.sql',
+ '1.5': 'v1.5.sql',
+ '1.6': 'v1.6.sql',
'1.7': 'v1.7.sql',
'1.8': 'v1.8.sql',
'2.0': 'v2.0.sql',
'2.0.1': 'v2.0.1.sql',
'2.1': 'v2.1.sql',
'2.2': 'v2.2.sql',
+ '2.3': 'v2.3.sql',
}
-
@router.get("/check")
async def check_upgrade(request: Request):
"""检查数据库版本是否需要升级"""
@@ -43,8 +51,6 @@ async def check_upgrade(request: Request):
if not is_teacher:
return error_response(message="仅班主任可执行升级操作", code=403)
- user_id = request.state.user.get('user_id') if hasattr(request.state, 'user') else getattr(request.state, 'user_id', None)
-
# 检测当前数据库版本
current_version = '0.0.0'
try:
@@ -83,9 +89,32 @@ async def check_upgrade(request: Request):
})
+async def _verify_upgrade(expected_version: str) -> dict:
+ """验证升级结果:检查版本号是否已正确更新
+
+ Returns:
+ {'ok': bool, 'message': str}
+ """
+ try:
+ row = await execute_query(
+ "SELECT setting_value FROM system_settings WHERE setting_key = 'db_version'"
+ )
+ if not row:
+ return {'ok': False, 'message': 'db_version 记录不存在'}
+ actual = row[0]['setting_value']
+ if actual != expected_version:
+ return {'ok': False, 'message': f'版本号不匹配:期望 {expected_version},实际 {actual}'}
+ return {'ok': True, 'message': '验证通过'}
+ except Exception as e:
+ return {'ok': False, 'message': f'验证查询失败: {str(e)}'}
+
+
+MAX_RETRIES = 2
+
+
@router.post("/step")
async def execute_upgrade_step(request: Request):
- """执行单个升级步骤"""
+ """执行单个升级步骤(含验证与重试)"""
# 权限检查:仅班主任可执行升级操作
user_type = getattr(request.state, 'user_type', None)
if user_type != 'admin':
@@ -97,8 +126,6 @@ async def execute_upgrade_step(request: Request):
if not is_teacher:
return error_response(message="仅班主任可执行升级操作", code=403)
- user_id = request.state.user.get('user_id') if hasattr(request.state, 'user') else getattr(request.state, 'user_id', None)
-
body = await request.json()
version = body.get('version', '')
@@ -115,50 +142,58 @@ async def execute_upgrade_step(request: Request):
if not os.path.exists(sql_file):
return error_response(message=f'SQL 文件不存在: {ALL_VERSIONS[version]}', code=500)
- try:
- # 读取并执行 SQL
- with open(sql_file, 'r', encoding='utf-8') as f:
- sql_content = f.read().strip()
-
- if sql_content and sql_content != '--':
- # 使用 aiomysql 直接执行多条 SQL
- pool = get_pool()
- async with pool.acquire() as conn:
- async with conn.cursor() as cursor:
- # 分割 SQL 语句(按 DELIMITER 处理存储过程)
- await _execute_sql_content(cursor, sql_content)
- await conn.commit()
-
- # 更新版本号
- await execute_update(
- "INSERT INTO system_settings (setting_key, setting_value) VALUES ('db_version', %s) "
- "ON DUPLICATE KEY UPDATE setting_value = %s",
- (version, version)
- )
-
- # 重新检测版本
- new_version = '0.0.0'
+ last_error = None
+
+ for attempt in range(1, MAX_RETRIES + 1):
try:
- row = await execute_query(
- "SELECT setting_value FROM system_settings WHERE setting_key = 'db_version'"
+ # 读取并执行 SQL
+ with open(sql_file, 'r', encoding='utf-8') as f:
+ sql_content = f.read().strip()
+
+ if sql_content and sql_content != '--':
+ pool = get_pool()
+ async with pool.acquire() as conn:
+ async with conn.cursor() as cursor:
+ await _execute_sql_content(cursor, sql_content)
+ await conn.commit()
+
+ # 更新版本号
+ await execute_update(
+ "INSERT INTO system_settings (setting_key, setting_value) VALUES ('db_version', %s) "
+ "ON DUPLICATE KEY UPDATE setting_value = %s",
+ (version, version)
)
- if row:
- new_version = row[0]['setting_value']
- except Exception:
- pass
-
- logger.info(f"数据库升级成功: v{version} ({ALL_VERSIONS[version]})")
-
- return success_response(data={
- 'success': True,
- 'version': version,
- 'message': f"升级至 v{version} 成功 ({ALL_VERSIONS[version]})",
- 'current': new_version
- })
-
- except Exception as e:
- logger.error(f"数据库升级失败: v{version} - {str(e)}")
- return error_response(message=f"升级至 v{version} 失败: {str(e)}", code=500)
+
+ # 验证版本号是否正确写入
+ verify = await _verify_upgrade(version)
+ if verify['ok']:
+ new_version = version
+ logger.info(f"数据库升级成功: v{version} ({ALL_VERSIONS[version]})")
+ return success_response(data={
+ 'success': True,
+ 'version': version,
+ 'message': f"升级至 v{version} 成功 ({ALL_VERSIONS[version]})",
+ 'current': new_version
+ })
+
+ # 验证失败,准备重试
+ last_error = f"升级验证失败: {verify['message']}"
+ if attempt < MAX_RETRIES:
+ logger.warning(f"v{version} 升级验证失败,准备第 {attempt + 1} 次重试: {last_error}")
+ continue
+
+ except Exception as e:
+ last_error = str(e)
+ logger.warning(f"v{version} 升级第 {attempt} 次失败: {last_error}")
+ if attempt < MAX_RETRIES:
+ continue
+
+ # 所有重试均失败
+ logger.error(f"数据库升级失败: v{version} (尝试 {MAX_RETRIES} 次) - {last_error}")
+ return error_response(
+ message=f"升级至 v{version} 失败 (尝试 {MAX_RETRIES} 次): {last_error}",
+ code=500
+ )
def _compare_versions(v1: str, v2: str) -> int:
@@ -185,16 +220,29 @@ def _version_tuple(v: str) -> tuple:
async def _execute_sql_content(cursor, sql_content: str):
"""执行 SQL 内容,处理存储过程中的 DELIMITER"""
+ sql_content = sql_content.strip()
+ if not sql_content or sql_content == '--':
+ return # 空文件或纯注释,无需执行
+
# 如果包含 DELIMITER,需要特殊处理
- if 'DELIMITER' in sql_content:
- # 移除 DELIMITER 行,按 $$ 分割存储过程
+ if 'DELIMITER' in sql_content.upper():
lines = sql_content.split('\n')
current_block = []
in_procedure = False
+ buffer = '' # 使用局部变量而非函数属性,避免跨调用泄漏
for line in lines:
stripped = line.strip()
+ # 跳过纯注释行
+ if stripped.startswith('--') or stripped.startswith('#'):
+ if not in_procedure:
+ continue
+ else:
+ current_block.append(line)
+ continue
+
if stripped.upper().startswith('DELIMITER $$'):
+ # 开始存储过程定义
in_procedure = True
current_block = []
continue
@@ -203,27 +251,41 @@ async def _execute_sql_content(cursor, sql_content: str):
if current_block:
proc_sql = '\n'.join(current_block).strip()
if proc_sql:
+ # 移除存储过程结尾的 $$ 定界符(发送给 MySQL 服务器时不需要)
+ proc_sql = re.sub(r'\$\$\s*$', '', proc_sql)
await cursor.execute(proc_sql)
in_procedure = False
current_block = []
continue
elif stripped.upper().startswith('DELIMITER'):
+ # 其他 DELIMITER 指令,跳过
continue
if in_procedure:
current_block.append(line)
else:
- # 普通SQL,按分号分割执行
- if stripped and not stripped.startswith('--'):
- # 简单的按分号分割
- for stmt in stripped.split(';'):
- stmt = stmt.strip()
+ # 普通SQL,按完整语句分割(以分号结尾)
+ if stripped:
+ # 累积多行直到遇到分号
+ if buffer:
+ buffer += ' ' + stripped
+ else:
+ buffer = stripped
+
+ # 如果以分号结尾,执行并清空缓冲区
+ if buffer.rstrip().endswith(';'):
+ stmt = buffer.rstrip(';').strip()
if stmt:
await cursor.execute(stmt)
+ buffer = ''
+
+ # 处理缓冲区中剩余的语句
+ if buffer:
+ stmt = buffer.rstrip(';').strip()
+ if stmt:
+ await cursor.execute(stmt)
else:
- # 无 DELIMITER,简单执行
- # 按 CREATE 分割以支持多语句
- # 分割 SQL 语句
+ # 无 DELIMITER,按分号+换行分割语句
statements = re.split(r';\s*\n', sql_content)
for stmt in statements:
stmt = stmt.strip()
diff --git a/frontend/admin/dashboard.php b/frontend/admin/dashboard.php
index 735ac4e..1af2719 100644
--- a/frontend/admin/dashboard.php
+++ b/frontend/admin/dashboard.php
@@ -88,9 +88,21 @@ include __DIR__ . '/../includes/header.php';
var upgradeSteps = [];
var currentStepIndex = 0;
+ function escapeHtml(str) {
+ if (typeof str !== 'string') return '';
+ var div = document.createElement('div');
+ div.appendChild(document.createTextNode(str));
+ return div.innerHTML;
+ }
+
fetch('/api/check_upgrade.php')
.then(function(r) { return r.json(); })
.then(function(data) {
+ // 检查是否返回了错误
+ if (data.error) {
+ console.warn('升级检查失败:', data.error);
+ return;
+ }
if (data.needs_upgrade) {
document.getElementById('currentDbVersion').textContent = data.current;
document.getElementById('targetDbVersion').textContent = data.target;
@@ -101,14 +113,16 @@ include __DIR__ . '/../includes/header.php';
for (var i = 0; i < upgradeSteps.length; i++) {
listHtml += '
' +
'○' +
- '升级至 v' + upgradeSteps[i].version + '' +
+ '升级至 v' + escapeHtml(upgradeSteps[i].version) + '' +
'
';
}
document.getElementById('upgradeStepsList').innerHTML = listHtml;
document.getElementById('upgradeModal').style.display = 'flex';
}
})
- .catch(function() {});
+ .catch(function(err) {
+ console.warn('升级检查请求失败:', err);
+ });
window.startUpgrade = function() {
var btn = document.getElementById('startUpgradeBtn');
@@ -196,7 +210,7 @@ include __DIR__ . '/../includes/header.php';
laterBtn.textContent = '关闭';
document.getElementById('upgradeResult').style.display = 'block';
- document.getElementById('upgradeResult').innerHTML = '升级失败:' + msg + '
';
+ document.getElementById('upgradeResult').innerHTML = '升级失败:' + escapeHtml(msg) + '
';
}
})();
diff --git a/frontend/api/check_upgrade.php b/frontend/api/check_upgrade.php
index 6a09d7c..496ead2 100644
--- a/frontend/api/check_upgrade.php
+++ b/frontend/api/check_upgrade.php
@@ -46,16 +46,24 @@ $apiResponse = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
-if ($httpCode !== 200 || empty($apiResponse)) {
+if (empty($apiResponse)) {
echo json_encode(['error' => '无法连接升级服务']);
exit();
}
$result = json_decode($apiResponse, true);
-if (!$result || !isset($result['success']) || !$result['success']) {
- echo json_encode(['error' => $result['message'] ?? '升级检查失败']);
+if (!$result) {
+ echo json_encode(['error' => '升级服务返回数据格式错误']);
+ exit();
+}
+
+// 后端返回非200时,尝试解析实际错误信息
+if ($httpCode !== 200 || !isset($result['success']) || !$result['success']) {
+ $errorMsg = $result['message'] ?? ($result['error'] ?? '升级检查失败');
+ echo json_encode(['error' => $errorMsg]);
exit();
}
// 转发后端返回的升级数据
-echo json_encode($result['data']);
+$data = $result['data'] ?? [];
+echo json_encode($data);
diff --git a/frontend/api/execute_upgrade.php b/frontend/api/execute_upgrade.php
index 5cb4841..758e248 100644
--- a/frontend/api/execute_upgrade.php
+++ b/frontend/api/execute_upgrade.php
@@ -64,7 +64,7 @@ $apiResponse = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
-if ($httpCode !== 200 || empty($apiResponse)) {
+if (empty($apiResponse)) {
http_response_code(500);
echo json_encode([
'success' => false,
@@ -75,15 +75,28 @@ if ($httpCode !== 200 || empty($apiResponse)) {
}
$result = json_decode($apiResponse, true);
-if (!$result || !isset($result['success']) || !$result['success']) {
+if (!$result) {
http_response_code(500);
echo json_encode([
'success' => false,
'version' => $stepVersion,
- 'error' => $result['message'] ?? '升级失败'
+ 'error' => '升级服务返回数据格式错误'
+ ]);
+ exit();
+}
+
+// 后端返回非200或 success=false 时,提取实际错误信息
+if ($httpCode !== 200 || !isset($result['success']) || !$result['success']) {
+ $errorMsg = $result['message'] ?? ($result['error'] ?? '升级失败');
+ http_response_code(500);
+ echo json_encode([
+ 'success' => false,
+ 'version' => $stepVersion,
+ 'error' => $errorMsg
]);
exit();
}
// 转发后端返回的数据
-echo json_encode($result['data']);
+$data = $result['data'] ?? [];
+echo json_encode($data);
diff --git a/sql/init.sql b/sql/init.sql
index 38c0b2c..13188a0 100644
--- a/sql/init.sql
+++ b/sql/init.sql
@@ -232,8 +232,8 @@ INSERT IGNORE INTO `subjects` (`subject_name`, `subject_code`, `sort_order`) VAL
-- 初始化系统版本号
INSERT INTO `system_settings` (`setting_key`, `setting_value`)
-VALUES ('db_version', '2.2')
-ON DUPLICATE KEY UPDATE `setting_value` = '2.2';
+VALUES ('db_version', '2.3')
+ON DUPLICATE KEY UPDATE `setting_value` = '2.3';
-- 控制台输出初始化结果(含版本号)
SELECT CONCAT('数据库初始化完成!版本: v', (SELECT setting_value FROM system_settings WHERE setting_key = 'db_version')) AS message;
diff --git a/sql/upgrades/v1.0.sql b/sql/upgrades/v1.0.sql
new file mode 100644
index 0000000..c6afed6
--- /dev/null
+++ b/sql/upgrades/v1.0.sql
@@ -0,0 +1,106 @@
+-- ===========================================
+-- 班级操行分管理系统 - v1.0 初始数据库结构
+-- 字符集: utf8mb4
+--
+-- 说明: 系统初始版本,创建核心表结构。
+-- 包含: semesters, subjects, students, users,
+-- admin_roles, conduct_records, attendance_records
+--
+-- 兼容性: 使用 IF NOT EXISTS 实现幂等,phpMyAdmin 可直接执行
+-- ===========================================
+
+-- 学期表
+CREATE TABLE IF NOT EXISTS `semesters` (
+ `semester_id` INT PRIMARY KEY AUTO_INCREMENT,
+ `semester_name` VARCHAR(100) NOT NULL COMMENT '学期名称',
+ `start_date` DATE DEFAULT NULL COMMENT '学期开始日期',
+ `end_date` DATE DEFAULT NULL COMMENT '学期结束日期',
+ `is_active` TINYINT DEFAULT 0 COMMENT '是否为当前活跃学期',
+ `is_archived` TINYINT DEFAULT 0 COMMENT '是否已归档',
+ `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- 科目表
+CREATE TABLE IF NOT EXISTS `subjects` (
+ `subject_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '科目ID',
+ `subject_name` VARCHAR(50) NOT NULL COMMENT '科目名称',
+ `is_active` TINYINT DEFAULT 1 COMMENT '是否启用',
+ `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY `uk_subject_name` (`subject_name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- 学生表
+CREATE TABLE IF NOT EXISTS `students` (
+ `student_id` INT PRIMARY KEY AUTO_INCREMENT,
+ `student_no` VARCHAR(20) NOT NULL UNIQUE,
+ `name` VARCHAR(50) NOT NULL,
+ `total_points` INT DEFAULT 60,
+ `parent_phone` VARCHAR(20) DEFAULT NULL,
+ `status` TINYINT DEFAULT 1,
+ `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- 用户表
+CREATE TABLE IF NOT EXISTS `users` (
+ `user_id` INT PRIMARY KEY AUTO_INCREMENT,
+ `username` VARCHAR(50) NOT NULL UNIQUE,
+ `password_hash` VARCHAR(64) NOT NULL,
+ `real_name` VARCHAR(50) NOT NULL,
+ `user_type` ENUM('student', 'parent', 'admin') NOT NULL,
+ `student_id` INT DEFAULT NULL,
+ `status` TINYINT DEFAULT 1,
+ `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- 管理员角色表
+CREATE TABLE IF NOT EXISTS `admin_roles` (
+ `admin_role_id` INT PRIMARY KEY AUTO_INCREMENT,
+ `user_id` INT NOT NULL,
+ `role_type` ENUM('班主任', '班长', '学习委员', '考勤委员', '劳动委员', '志愿委员') NOT NULL,
+ `subject_id` INT DEFAULT NULL,
+ `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE,
+ FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`subject_id`) ON DELETE CASCADE,
+ UNIQUE KEY `uk_user_subject` (`user_id`, `subject_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- 操行分记录表
+CREATE TABLE IF NOT EXISTS `conduct_records` (
+ `record_id` BIGINT PRIMARY KEY AUTO_INCREMENT,
+ `student_id` INT NOT NULL,
+ `points_change` INT NOT NULL,
+ `reason` VARCHAR(255) NOT NULL,
+ `recorder_id` INT NOT NULL,
+ `recorder_name` VARCHAR(50) DEFAULT NULL,
+ `related_id` INT DEFAULT NULL,
+ `is_revoked` TINYINT DEFAULT 0,
+ `revoked_by` INT DEFAULT NULL,
+ `revoked_at` DATETIME DEFAULT NULL,
+ `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) ON DELETE CASCADE,
+ FOREIGN KEY (`recorder_id`) REFERENCES `users`(`user_id`),
+ FOREIGN KEY (`revoked_by`) REFERENCES `users`(`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- 考勤记录表
+CREATE TABLE IF NOT EXISTS `attendance_records` (
+ `attendance_id` INT PRIMARY KEY AUTO_INCREMENT,
+ `student_id` INT NOT NULL,
+ `date` DATE NOT NULL,
+ `slot` ENUM('morning', 'afternoon', 'evening') DEFAULT 'morning' COMMENT '考勤时段',
+ `status` ENUM('present', 'absent', 'late', 'leave') DEFAULT 'present',
+ `reason` VARCHAR(255) DEFAULT NULL,
+ `recorder_id` INT NOT NULL,
+ `deduction_applied` TINYINT DEFAULT 0,
+ `deduction_record_id` BIGINT DEFAULT NULL,
+ `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) ON DELETE CASCADE,
+ FOREIGN KEY (`recorder_id`) REFERENCES `users`(`user_id`),
+ FOREIGN KEY (`deduction_record_id`) REFERENCES `conduct_records`(`record_id`) ON DELETE SET NULL,
+ UNIQUE KEY `uk_student_date_slot` (`student_id`, `date`, `slot`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- 插入初始科目
+INSERT IGNORE INTO `subjects` (`subject_name`) VALUES ('语文'), ('数学'), ('英语');
diff --git a/sql/upgrades/v1.1.sql b/sql/upgrades/v1.1.sql
new file mode 100644
index 0000000..5781277
--- /dev/null
+++ b/sql/upgrades/v1.1.sql
@@ -0,0 +1,97 @@
+-- ===========================================
+-- 班级操行分管理系统 - v1.0 → v1.1 升级脚本
+-- 字符集: utf8mb4
+--
+-- 变更内容:
+-- 1. users 表添加 need_change_password 列
+-- 2. users 表添加 last_login_time 列
+-- 3. users 表添加 last_login_ip 列
+-- 4. users 表 password_hash 字段扩展至 VARCHAR(255)
+--
+-- 兼容性: 使用存储过程实现幂等,phpMyAdmin 可直接执行
+-- ===========================================
+
+-- 升级步骤 1: users 添加 need_change_password 列
+DROP PROCEDURE IF EXISTS `upgrade_step`;
+DELIMITER $$
+CREATE PROCEDURE `upgrade_step`()
+BEGIN
+ IF NOT EXISTS (
+ SELECT * FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'users'
+ AND COLUMN_NAME = 'need_change_password'
+ ) THEN
+ ALTER TABLE `users`
+ ADD COLUMN `need_change_password` TINYINT DEFAULT 1
+ AFTER `status`;
+ END IF;
+END$$
+DELIMITER ;
+
+CALL `upgrade_step`();
+DROP PROCEDURE IF EXISTS `upgrade_step`;
+
+-- 升级步骤 2: users 添加 last_login_time 列
+DROP PROCEDURE IF EXISTS `upgrade_step`;
+DELIMITER $$
+CREATE PROCEDURE `upgrade_step`()
+BEGIN
+ IF NOT EXISTS (
+ SELECT * FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'users'
+ AND COLUMN_NAME = 'last_login_time'
+ ) THEN
+ ALTER TABLE `users`
+ ADD COLUMN `last_login_time` DATETIME DEFAULT NULL
+ AFTER `need_change_password`;
+ END IF;
+END$$
+DELIMITER ;
+
+CALL `upgrade_step`();
+DROP PROCEDURE IF EXISTS `upgrade_step`;
+
+-- 升级步骤 3: users 添加 last_login_ip 列
+DROP PROCEDURE IF EXISTS `upgrade_step`;
+DELIMITER $$
+CREATE PROCEDURE `upgrade_step`()
+BEGIN
+ IF NOT EXISTS (
+ SELECT * FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'users'
+ AND COLUMN_NAME = 'last_login_ip'
+ ) THEN
+ ALTER TABLE `users`
+ ADD COLUMN `last_login_ip` VARCHAR(45) DEFAULT NULL
+ AFTER `last_login_time`;
+ END IF;
+END$$
+DELIMITER ;
+
+CALL `upgrade_step`();
+DROP PROCEDURE IF EXISTS `upgrade_step`;
+
+-- 升级步骤 4: password_hash 字段扩展至 VARCHAR(255)
+DROP PROCEDURE IF EXISTS `upgrade_step`;
+DELIMITER $$
+CREATE PROCEDURE `upgrade_step`()
+BEGIN
+ DECLARE v_col_len INT;
+ SELECT CHARACTER_MAXIMUM_LENGTH INTO v_col_len
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'users'
+ AND COLUMN_NAME = 'password_hash';
+
+ IF v_col_len < 255 THEN
+ ALTER TABLE `users`
+ MODIFY COLUMN `password_hash` VARCHAR(255) NOT NULL;
+ END IF;
+END$$
+DELIMITER ;
+
+CALL `upgrade_step`();
+DROP PROCEDURE IF EXISTS `upgrade_step`;
diff --git a/sql/upgrades/v1.2.sql b/sql/upgrades/v1.2.sql
new file mode 100644
index 0000000..f10dd25
--- /dev/null
+++ b/sql/upgrades/v1.2.sql
@@ -0,0 +1,41 @@
+-- ===========================================
+-- 班级操行分管理系统 - v1.1 → v1.2 升级脚本
+-- 字符集: utf8mb4
+--
+-- 变更内容:
+-- 1. 创建 assignments(作业表)
+-- 2. 创建 homework_submissions(作业提交记录表)
+--
+-- 兼容性: 使用 IF NOT EXISTS 实现幂等,phpMyAdmin 可直接执行
+-- ===========================================
+
+-- 升级步骤 1: 创建作业表
+CREATE TABLE IF NOT EXISTS `assignments` (
+ `assignment_id` INT PRIMARY KEY AUTO_INCREMENT,
+ `subject_id` INT NOT NULL,
+ `title` VARCHAR(100) NOT NULL,
+ `description` TEXT,
+ `deadline` DATE NOT NULL,
+ `created_by` INT NOT NULL,
+ `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`subject_id`),
+ FOREIGN KEY (`created_by`) REFERENCES `users`(`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- 升级步骤 2: 创建作业提交记录表
+CREATE TABLE IF NOT EXISTS `homework_submissions` (
+ `submission_id` INT PRIMARY KEY AUTO_INCREMENT,
+ `assignment_id` INT NOT NULL,
+ `student_id` INT NOT NULL,
+ `status` ENUM('submitted', 'not_submitted', 'late') DEFAULT 'not_submitted',
+ `submit_time` DATETIME DEFAULT NULL,
+ `comments` TEXT,
+ `deduction_applied` TINYINT DEFAULT 0,
+ `deduction_record_id` BIGINT DEFAULT NULL,
+ `updated_by` INT DEFAULT NULL,
+ `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ FOREIGN KEY (`assignment_id`) REFERENCES `assignments`(`assignment_id`) ON DELETE CASCADE,
+ FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) ON DELETE CASCADE,
+ FOREIGN KEY (`deduction_record_id`) REFERENCES `conduct_records`(`record_id`) ON DELETE SET NULL,
+ UNIQUE KEY `uk_assignment_student` (`assignment_id`, `student_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/sql/upgrades/v1.3.sql b/sql/upgrades/v1.3.sql
new file mode 100644
index 0000000..2a48852
--- /dev/null
+++ b/sql/upgrades/v1.3.sql
@@ -0,0 +1,22 @@
+-- ===========================================
+-- 班级操行分管理系统 - v1.2 → v1.3 升级脚本
+-- 字符集: utf8mb4
+--
+-- 变更内容:
+-- 1. 创建 operation_logs(操作日志表)
+--
+-- 兼容性: 使用 IF NOT EXISTS 实现幂等,phpMyAdmin 可直接执行
+-- ===========================================
+
+CREATE TABLE IF NOT EXISTS `operation_logs` (
+ `log_id` BIGINT PRIMARY KEY AUTO_INCREMENT,
+ `operator_id` INT NOT NULL,
+ `operator_name` VARCHAR(50) DEFAULT NULL,
+ `operator_role` VARCHAR(50) DEFAULT NULL,
+ `operation_type` VARCHAR(50) NOT NULL,
+ `target_type` VARCHAR(50) DEFAULT NULL,
+ `target_id` INT DEFAULT NULL,
+ `details` TEXT,
+ `ip_address` VARCHAR(45) DEFAULT NULL,
+ `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/sql/upgrades/v1.4.sql b/sql/upgrades/v1.4.sql
new file mode 100644
index 0000000..0e14461
--- /dev/null
+++ b/sql/upgrades/v1.4.sql
@@ -0,0 +1,19 @@
+-- ===========================================
+-- 班级操行分管理系统 - v1.3 → v1.4 升级脚本
+-- 字符集: utf8mb4
+--
+-- 变更内容:
+-- 1. 创建 login_logs(登录日志表)
+--
+-- 兼容性: 使用 IF NOT EXISTS 实现幂等,phpMyAdmin 可直接执行
+-- ===========================================
+
+CREATE TABLE IF NOT EXISTS `login_logs` (
+ `log_id` BIGINT PRIMARY KEY AUTO_INCREMENT,
+ `username` VARCHAR(50) NOT NULL,
+ `login_result` TINYINT NOT NULL,
+ `fail_reason` VARCHAR(100) DEFAULT NULL,
+ `ip_address` VARCHAR(45) DEFAULT NULL,
+ `user_agent` VARCHAR(255) DEFAULT NULL,
+ `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/sql/upgrades/v1.5.sql b/sql/upgrades/v1.5.sql
new file mode 100644
index 0000000..f81bc6c
--- /dev/null
+++ b/sql/upgrades/v1.5.sql
@@ -0,0 +1,30 @@
+-- ===========================================
+-- 班级操行分管理系统 - v1.4 → v1.5 升级脚本
+-- 字符集: utf8mb4
+--
+-- 变更内容:
+-- 1. 创建 semester_archives(学期归档快照表)
+--
+-- 兼容性: 使用 IF NOT EXISTS 实现幂等,phpMyAdmin 可直接执行
+-- ===========================================
+
+CREATE TABLE IF NOT EXISTS `semester_archives` (
+ `archive_id` INT PRIMARY KEY AUTO_INCREMENT,
+ `semester_id` INT NOT NULL,
+ `student_id` INT NOT NULL,
+ `student_no` VARCHAR(20) NOT NULL,
+ `student_name` VARCHAR(50) NOT NULL,
+ `final_points` INT NOT NULL COMMENT '学期最终操行分',
+ `rank_position` INT DEFAULT NULL COMMENT '排名',
+ `total_students` INT DEFAULT NULL COMMENT '班级总人数',
+ `attendance_present` INT DEFAULT 0 COMMENT '出勤次数',
+ `attendance_absent` INT DEFAULT 0 COMMENT '缺勤次数',
+ `attendance_late` INT DEFAULT 0 COMMENT '迟到次数',
+ `attendance_leave` INT DEFAULT 0 COMMENT '请假次数',
+ `homework_submitted` INT DEFAULT 0 COMMENT '已交作业数',
+ `homework_not_submitted` INT DEFAULT 0 COMMENT '未交作业数',
+ `homework_late` INT DEFAULT 0 COMMENT '迟交作业数',
+ `archived_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (`semester_id`) REFERENCES `semesters`(`semester_id`),
+ FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/sql/upgrades/v1.6.sql b/sql/upgrades/v1.6.sql
new file mode 100644
index 0000000..3ecf903
--- /dev/null
+++ b/sql/upgrades/v1.6.sql
@@ -0,0 +1,63 @@
+-- ===========================================
+-- 班级操行分管理系统 - v1.5 → v1.6 升级脚本
+-- 字符集: utf8mb4
+--
+-- 变更内容:
+-- 1. subjects 表添加 subject_code 列(科目代码)
+-- 2. subjects 表添加 sort_order 列(排序序号)
+-- 3. 更新已有科目的 subject_code
+--
+-- 兼容性: 使用存储过程实现幂等,phpMyAdmin 可直接执行
+-- ===========================================
+
+-- 升级步骤 1: subjects 添加 subject_code 列
+DROP PROCEDURE IF EXISTS `upgrade_step`;
+DELIMITER $$
+CREATE PROCEDURE `upgrade_step`()
+BEGIN
+ IF NOT EXISTS (
+ SELECT * FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'subjects'
+ AND COLUMN_NAME = 'subject_code'
+ ) THEN
+ ALTER TABLE `subjects`
+ ADD COLUMN `subject_code` VARCHAR(20) DEFAULT NULL COMMENT '科目代码'
+ AFTER `subject_name`;
+ END IF;
+END$$
+DELIMITER ;
+
+CALL `upgrade_step`();
+DROP PROCEDURE IF EXISTS `upgrade_step`;
+
+-- 升级步骤 2: subjects 添加 sort_order 列
+DROP PROCEDURE IF EXISTS `upgrade_step`;
+DELIMITER $$
+CREATE PROCEDURE `upgrade_step`()
+BEGIN
+ IF NOT EXISTS (
+ SELECT * FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'subjects'
+ AND COLUMN_NAME = 'sort_order'
+ ) THEN
+ ALTER TABLE `subjects`
+ ADD COLUMN `sort_order` INT DEFAULT 0 COMMENT '排序序号'
+ AFTER `is_active`;
+ END IF;
+END$$
+DELIMITER ;
+
+CALL `upgrade_step`();
+DROP PROCEDURE IF EXISTS `upgrade_step`;
+
+-- 升级步骤 3: 更新已有科目的 subject_code
+UPDATE `subjects` SET `subject_code` = 'CHI' WHERE `subject_name` = '语文' AND (`subject_code` IS NULL OR `subject_code` = '');
+UPDATE `subjects` SET `subject_code` = 'MATH' WHERE `subject_name` = '数学' AND (`subject_code` IS NULL OR `subject_code` = '');
+UPDATE `subjects` SET `subject_code` = 'ENG' WHERE `subject_name` = '英语' AND (`subject_code` IS NULL OR `subject_code` = '');
+
+-- 升级步骤 4: 设置默认排序
+UPDATE `subjects` SET `sort_order` = 1 WHERE `subject_name` = '语文' AND `sort_order` = 0;
+UPDATE `subjects` SET `sort_order` = 2 WHERE `subject_name` = '数学' AND `sort_order` = 0;
+UPDATE `subjects` SET `sort_order` = 3 WHERE `subject_name` = '英语' AND `sort_order` = 0;
diff --git a/sql/upgrades/v2.3.sql b/sql/upgrades/v2.3.sql
new file mode 100644
index 0000000..c59397e
--- /dev/null
+++ b/sql/upgrades/v2.3.sql
@@ -0,0 +1,11 @@
+-- ===========================================
+-- 班级操行分管理系统 - v2.2 → v2.3 升级脚本
+-- 字符集: utf8mb4
+--
+-- 说明: v2.3 为升级系统优化版本,无数据库 schema 变更。
+-- 主要变更:
+-- 1. 升级系统全面重构(错误处理、SQL 解析、XSS 防护)
+-- 2. 新增升级验证 + 自动重试 + 失败回滚机制
+-- 3. 补全 v1.0-v1.6 增量升级脚本
+-- 4. 修复 DELIMITER SQL 执行问题
+-- ===========================================
diff --git a/upgrade.php b/upgrade.php
index 5deff33..d0d04fb 100644
--- a/upgrade.php
+++ b/upgrade.php
@@ -12,12 +12,20 @@
// 版本升级列表(唯一数据源)
$UPGRADE_VERSIONS = [
+ '1.0' => __DIR__ . '/sql/upgrades/v1.0.sql',
+ '1.1' => __DIR__ . '/sql/upgrades/v1.1.sql',
+ '1.2' => __DIR__ . '/sql/upgrades/v1.2.sql',
+ '1.3' => __DIR__ . '/sql/upgrades/v1.3.sql',
+ '1.4' => __DIR__ . '/sql/upgrades/v1.4.sql',
+ '1.5' => __DIR__ . '/sql/upgrades/v1.5.sql',
+ '1.6' => __DIR__ . '/sql/upgrades/v1.6.sql',
'1.7' => __DIR__ . '/sql/upgrades/v1.7.sql',
'1.8' => __DIR__ . '/sql/upgrades/v1.8.sql',
'2.0' => __DIR__ . '/sql/upgrades/v2.0.sql',
'2.0.1' => __DIR__ . '/sql/upgrades/v2.0.1.sql',
'2.1' => __DIR__ . '/sql/upgrades/v2.1.sql',
'2.2' => __DIR__ . '/sql/upgrades/v2.2.sql',
+ '2.3' => __DIR__ . '/sql/upgrades/v2.3.sql',
];
/**
@@ -84,27 +92,189 @@ function getUpgradeSteps($currentVersion, $targetVersion) {
}
/**
- * 执行单个版本的升级 SQL
+ * 执行 SQL 内容,处理包含 DELIMITER 的存储过程脚本
+ *
+ * DELIMITER 是 MySQL 客户端指令,MySQL 服务器不认识它,
+ * 必须在客户端侧解析并拆分为独立的语句后逐条执行。
*/
-function executeUpgrade($pdo, $version, $sqlFile) {
+function executeSqlContent($pdo, $sqlContent) {
+ $sqlContent = trim($sqlContent);
+ if ($sqlContent === '' || $sqlContent === '--') {
+ return;
+ }
+
+ // 检查是否包含 DELIMITER 指令
+ if (stripos($sqlContent, 'DELIMITER') !== false) {
+ $lines = explode("\n", $sqlContent);
+ $currentBlock = [];
+ $inProcedure = false;
+ $buffer = '';
+
+ foreach ($lines as $line) {
+ $trimmed = trim($line);
+
+ // 跳过纯注释行(存储过程内部注释保留)
+ if (!$inProcedure && (strpos($trimmed, '--') === 0 || strpos($trimmed, '#') === 0)) {
+ continue;
+ }
+
+ if (strtoupper(substr($trimmed, 0, 12)) === 'DELIMITER $$') {
+ // 开始存储过程定义
+ $inProcedure = true;
+ $currentBlock = [];
+ continue;
+ } elseif (strtoupper($trimmed) === 'DELIMITER ;') {
+ // 执行累积的存储过程块
+ if (!empty($currentBlock)) {
+ $procSql = trim(implode("\n", $currentBlock));
+ if ($procSql !== '') {
+ // 移除存储过程结尾的 $$ 定界符(发送给 MySQL 服务器时不需要)
+ $procSql = preg_replace('/\$\$\s*$/', '', $procSql);
+ $pdo->exec($procSql);
+ }
+ }
+ $inProcedure = false;
+ $currentBlock = [];
+ continue;
+ } elseif (strtoupper(substr($trimmed, 0, 9)) === 'DELIMITER') {
+ // 其他 DELIMITER 指令,跳过
+ continue;
+ }
+
+ if ($inProcedure) {
+ $currentBlock[] = $line;
+ } else {
+ // 普通 SQL,累积直到遇到分号
+ if ($trimmed !== '') {
+ $buffer .= ($buffer !== '' ? ' ' : '') . $trimmed;
+
+ if (rtrim($buffer) !== '' && substr(rtrim($buffer), -1) === ';') {
+ $stmt = rtrim(rtrim($buffer), ';');
+ $stmt = trim($stmt);
+ if ($stmt !== '' && $stmt !== '--') {
+ $pdo->exec($stmt);
+ }
+ $buffer = '';
+ }
+ }
+ }
+ }
+
+ // 处理缓冲区中剩余的语句
+ if ($buffer !== '') {
+ $stmt = rtrim(rtrim($buffer), ';');
+ $stmt = trim($stmt);
+ if ($stmt !== '' && $stmt !== '--') {
+ $pdo->exec($stmt);
+ }
+ }
+ } else {
+ // 无 DELIMITER,按分号+换行分割语句
+ $statements = preg_split('/;\s*\n/', $sqlContent);
+ foreach ($statements as $stmt) {
+ $stmt = trim($stmt);
+ if ($stmt !== '' && $stmt !== '--') {
+ $pdo->exec($stmt);
+ }
+ }
+ }
+}
+
+/**
+ * 验证升级结果:检查版本号是否已正确更新
+ *
+ * @return array ['ok' => bool, 'message' => string]
+ */
+function verifyUpgrade($pdo, $expectedVersion) {
+ // 检查 system_settings 表是否存在
+ try {
+ $check = $pdo->query("SELECT 1 FROM system_settings LIMIT 1");
+ } catch (PDOException $e) {
+ return ['ok' => false, 'message' => 'system_settings 表不存在,升级脚本可能未正确执行'];
+ }
+
+ // 检查版本号是否匹配
+ $stmt = $pdo->prepare("SELECT setting_value FROM system_settings WHERE setting_key = 'db_version'");
+ $stmt->execute();
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if (!$row) {
+ return ['ok' => false, 'message' => 'db_version 记录不存在'];
+ }
+
+ if ($row['setting_value'] !== $expectedVersion) {
+ return ['ok' => false, 'message' => "版本号不匹配:期望 {$expectedVersion},实际 {$row['setting_value']}"];
+ }
+
+ return ['ok' => true, 'message' => '验证通过'];
+}
+
+/**
+ * 执行单个版本的升级 SQL(含验证与重试)
+ *
+ * @param PDO $pdo 数据库连接
+ * @param string $version 目标版本号
+ * @param string $sqlFile SQL 文件路径
+ * @param int $maxRetries 最大重试次数
+ * @throws RuntimeException 升级失败时抛出
+ */
+function executeUpgrade($pdo, $version, $sqlFile, $maxRetries = 2) {
if (!file_exists($sqlFile)) {
throw new RuntimeException("SQL 文件不存在: {$sqlFile}");
}
$sql = file_get_contents($sqlFile);
+ $isEmpty = (trim($sql) === '' || trim($sql) === '--');
- if (trim($sql) === '' || trim($sql) === '--') {
- // 空文件或纯注释,无需执行 SQL,仅更新版本号
- } else {
- $pdo->exec($sql);
+ $lastError = null;
+
+ for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
+ try {
+ if (!$isEmpty) {
+ executeSqlContent($pdo, $sql);
+ }
+
+ // 更新版本号(使用预处理语句防止 SQL 注入)
+ $stmt = $pdo->prepare(
+ "INSERT INTO system_settings (setting_key, setting_value) VALUES ('db_version', :version)
+ ON DUPLICATE KEY UPDATE setting_value = :version"
+ );
+ $stmt->execute([':version' => $version]);
+
+ // 验证版本号是否正确写入
+ $verify = verifyUpgrade($pdo, $version);
+ if ($verify['ok']) {
+ return; // 成功
+ }
+
+ // 验证失败,准备重试
+ $lastError = "升级验证失败: {$verify['message']}";
+ if ($attempt < $maxRetries) {
+ // 回滚版本号到升级前状态,以便重试
+ $prevStmt = $pdo->prepare(
+ "UPDATE system_settings SET setting_value = :ver WHERE setting_key = 'db_version'"
+ );
+ // 获取升级前版本(从 getUpgradeSteps 推断,这里用 0.0.0 作为安全回退)
+ $prevStmt->execute([':ver' => '0.0.0']);
+ continue;
+ }
+ } catch (PDOException $e) {
+ $lastError = "SQL 执行失败: " . $e->getMessage();
+ if ($attempt < $maxRetries) {
+ continue;
+ }
+ } catch (Exception $e) {
+ $lastError = $e->getMessage();
+ if ($attempt < $maxRetries) {
+ continue;
+ }
+ }
+
+ // 所有重试均失败
+ break;
}
- // 更新版本号(使用预处理语句防止 SQL 注入)
- $stmt = $pdo->prepare(
- "INSERT INTO system_settings (setting_key, setting_value) VALUES ('db_version', :version)
- ON DUPLICATE KEY UPDATE setting_value = :version"
- );
- $stmt->execute([':version' => $version]);
+ throw new RuntimeException("升级至 v{$version} 失败 (尝试 {$maxRetries} 次): {$lastError}");
}
// ===========================================
@@ -468,6 +638,13 @@ try {