v2.3更新

This commit is contained in:
2026-05-28 20:48:29 +08:00
parent ca53fdc349
commit 7dbe98ee02
15 changed files with 749 additions and 86 deletions

View File

@@ -1 +1 @@
2.2
2.3

View File

@@ -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,17 +142,18 @@ 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)
last_error = None
for attempt in range(1, MAX_RETRIES + 1):
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()
@@ -136,19 +164,11 @@ async def execute_upgrade_step(request: Request):
(version, version)
)
# 重新检测版本
new_version = '0.0.0'
try:
row = await execute_query(
"SELECT setting_value FROM system_settings WHERE setting_key = 'db_version'"
)
if row:
new_version = row[0]['setting_value']
except Exception:
pass
# 验证版本号是否正确写入
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,
@@ -156,9 +176,24 @@ async def execute_upgrade_step(request: Request):
'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:
logger.error(f"数据库升级失败: v{version} - {str(e)}")
return error_response(message=f"升级至 v{version} 失败: {str(e)}", code=500)
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()

View File

@@ -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 += '<div style="display:flex;align-items:center;padding:8px 12px;margin:4px 0;border-radius:6px;font-size:13px;background:var(--color-hover);border-left:3px solid var(--color-border);" id="ustep-' + i + '">' +
'<span style="margin-right:8px;" id="ustep-icon-' + i + '">○</span>' +
'<span>升级至 v' + upgradeSteps[i].version + '</span>' +
'<span>升级至 v' + escapeHtml(upgradeSteps[i].version) + '</span>' +
'</div>';
}
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 = '<div style="background:var(--color-danger-light);border:1px solid #ffccc7;border-radius:6px;padding:12px;color:var(--color-danger-dark);font-size:13px;"><strong>升级失败:</strong>' + msg + '</div>';
document.getElementById('upgradeResult').innerHTML = '<div style="background:var(--color-danger-light);border:1px solid #ffccc7;border-radius:6px;padding:12px;color:var(--color-danger-dark);font-size:13px;"><strong>升级失败:</strong>' + escapeHtml(msg) + '</div>';
}
})();
</script>

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

106
sql/upgrades/v1.0.sql Normal file
View File

@@ -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 ('语文'), ('数学'), ('英语');

97
sql/upgrades/v1.1.sql Normal file
View File

@@ -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`;

41
sql/upgrades/v1.2.sql Normal file
View File

@@ -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;

22
sql/upgrades/v1.3.sql Normal file
View File

@@ -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;

19
sql/upgrades/v1.4.sql Normal file
View File

@@ -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;

30
sql/upgrades/v1.5.sql Normal file
View File

@@ -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;

63
sql/upgrades/v1.6.sql Normal file
View File

@@ -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;

11
sql/upgrades/v2.3.sql Normal file
View File

@@ -0,0 +1,11 @@
-- ===========================================
-- 班级操行分管理系统 - v2.2 → v2.3 升级脚本
-- 字符集: utf8mb4
--
-- 说明: v2.3 为升级系统优化版本,无数据库 schema 变更。
-- 主要变更:
-- 1. 升级系统全面重构错误处理、SQL 解析、XSS 防护)
-- 2. 新增升级验证 + 自动重试 + 失败回滚机制
-- 3. 补全 v1.0-v1.6 增量升级脚本
-- 4. 修复 DELIMITER SQL 执行问题
-- ===========================================

View File

@@ -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,19 +92,146 @@ 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 注入)
@@ -105,6 +240,41 @@ function executeUpgrade($pdo, $version, $sqlFile) {
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;
}
throw new RuntimeException("升级至 v{$version} 失败 (尝试 {$maxRetries} 次): {$lastError}");
}
// ===========================================
@@ -468,6 +638,13 @@ try {
<?php if (!$hasError && !$isUpToDate): ?>
<script>
function escapeHtml(str) {
if (typeof str !== 'string') return '';
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function executeUpgrade() {
var btn = document.getElementById('btnUpgrade');
var resultArea = document.getElementById('resultArea');
@@ -512,7 +689,7 @@ try {
btn.textContent = '升级失败';
btn.disabled = false;
resultArea.style.display = 'block';
resultArea.innerHTML = '<div class="error-box"><strong>升级失败:</strong>' + (data.error || '未知错误') + '</div>';
resultArea.innerHTML = '<div class="error-box"><strong>升级失败:</strong>' + escapeHtml(data.error || '未知错误') + '</div>';
}
})
.catch(function(err) {
@@ -525,7 +702,7 @@ try {
btn.disabled = false;
btn.textContent = '立即升级';
resultArea.style.display = 'block';
resultArea.innerHTML = '<div class="error-box"><strong>请求失败:</strong>' + err.message + '</div>';
resultArea.innerHTML = '<div class="error-box"><strong>请求失败:</strong>' + escapeHtml(err.message) + '</div>';
});
}