From 7dbe98ee025df4268679874149e5090da9bc9508 Mon Sep 17 00:00:00 2001 From: canglan Date: Thu, 28 May 2026 20:48:29 +0800 Subject: [PATCH] =?UTF-8?q?v2.3=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- backend/routes/upgrade.py | 178 ++++++++++++++++++--------- frontend/admin/dashboard.php | 20 ++- frontend/api/check_upgrade.php | 16 ++- frontend/api/execute_upgrade.php | 21 +++- sql/init.sql | 4 +- sql/upgrades/v1.0.sql | 106 ++++++++++++++++ sql/upgrades/v1.1.sql | 97 +++++++++++++++ sql/upgrades/v1.2.sql | 41 +++++++ sql/upgrades/v1.3.sql | 22 ++++ sql/upgrades/v1.4.sql | 19 +++ sql/upgrades/v1.5.sql | 30 +++++ sql/upgrades/v1.6.sql | 63 ++++++++++ sql/upgrades/v2.3.sql | 11 ++ upgrade.php | 205 ++++++++++++++++++++++++++++--- 15 files changed, 749 insertions(+), 86 deletions(-) create mode 100644 sql/upgrades/v1.0.sql create mode 100644 sql/upgrades/v1.1.sql create mode 100644 sql/upgrades/v1.2.sql create mode 100644 sql/upgrades/v1.3.sql create mode 100644 sql/upgrades/v1.4.sql create mode 100644 sql/upgrades/v1.5.sql create mode 100644 sql/upgrades/v1.6.sql create mode 100644 sql/upgrades/v2.3.sql 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 {