Files
SharedClassManager/upgrade.php
2026-05-29 17:35:29 +08:00

717 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 班级操行分管理系统 - 自动升级脚本
*
* 读取 VERSION 文件确定目标版本,自动检测数据库当前版本,
* 依次执行增量 SQL 升级脚本。
*/
// ===========================================
// 辅助函数
// ===========================================
// 版本升级列表(唯一数据源)
$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',
'2.4' => __DIR__ . '/sql/upgrades/v2.4.sql',
'2.5' => __DIR__ . '/sql/upgrades/v2.5.sql',
];
/**
* 读取 backend/.env 文件并解析数据库配置
*/
function readEnvConfig($envPath) {
if (!file_exists($envPath)) {
throw new RuntimeException('配置文件不存在: ' . $envPath);
}
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$config = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || strpos($line, '#') === 0) {
continue;
}
if (strpos($line, '=') !== false) {
list($key, $value) = explode('=', $line, 2);
$config[trim($key)] = trim($value);
}
}
$required = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'];
foreach ($required as $key) {
if (!isset($config[$key]) || $config[$key] === '') {
throw new RuntimeException("缺少必要的数据库配置: {$key}");
}
}
return $config;
}
/**
* 检测数据库当前版本
*/
function detectCurrentVersion($pdo) {
try {
$stmt = $pdo->query("SELECT setting_value FROM system_settings WHERE setting_key = 'db_version'");
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? $row['setting_value'] : '0.0.0';
} catch (PDOException $e) {
return '0.0.0';
}
}
/**
* 获取需要执行的升级步骤
*/
function getUpgradeSteps($currentVersion, $targetVersion) {
global $UPGRADE_VERSIONS;
$steps = [];
foreach ($UPGRADE_VERSIONS as $version => $sqlFile) {
if (version_compare($version, $currentVersion, '>') &&
version_compare($version, $targetVersion, '<=')) {
$steps[$version] = $sqlFile;
}
}
uksort($steps, 'version_compare');
return $steps;
}
/**
* 执行 SQL 内容,处理包含 DELIMITER 的存储过程脚本
*
* DELIMITER 是 MySQL 客户端指令MySQL 服务器不认识它,
* 必须在客户端侧解析并拆分为独立的语句后逐条执行。
*/
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) === '--');
$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;
}
throw new RuntimeException("升级至 v{$version} 失败 (尝试 {$maxRetries} 次): {$lastError}");
}
// ===========================================
// 主逻辑
// ===========================================
// POST 模式:执行单个升级步骤(依次执行)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_GET['action'] ?? '') === 'step') {
header('Content-Type: application/json; charset=utf-8');
$stepVersion = $_GET['version'] ?? '';
if (empty($stepVersion)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => '缺少版本号参数']);
exit();
}
try {
$envPath = __DIR__ . '/backend/.env';
$config = readEnvConfig($envPath);
$dsn = "mysql:host={$config['DB_HOST']};port={$config['DB_PORT']};dbname={$config['DB_NAME']};charset=utf8mb4";
$pdo = new PDO($dsn, $config['DB_USER'], $config['DB_PASSWORD'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
// 获取该版本对应的 SQL 文件
if (!isset($UPGRADE_VERSIONS[$stepVersion])) {
throw new RuntimeException("未知版本: {$stepVersion}");
}
$sqlFile = $UPGRADE_VERSIONS[$stepVersion];
$shortFile = basename($sqlFile);
executeUpgrade($pdo, $stepVersion, $sqlFile);
// 重新检测当前版本
$newVersion = detectCurrentVersion($pdo);
echo json_encode([
'success' => true,
'version' => $stepVersion,
'message' => "升级至 v{$stepVersion} 成功 ({$shortFile})",
'current' => $newVersion
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'version' => $stepVersion,
'error' => "升级至 v{$stepVersion} 失败: " . $e->getMessage()
]);
}
exit();
}
// POST 模式执行升级AJAX 请求)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_GET['action'] ?? '') === 'execute') {
header('Content-Type: application/json; charset=utf-8');
$upgradeLog = [];
$currentVersion = '未知';
$targetVersion = '未知';
try {
$envPath = __DIR__ . '/backend/.env';
$config = readEnvConfig($envPath);
$dsn = "mysql:host={$config['DB_HOST']};port={$config['DB_PORT']};dbname={$config['DB_NAME']};charset=utf8mb4";
$pdo = new PDO($dsn, $config['DB_USER'], $config['DB_PASSWORD'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
$currentVersion = detectCurrentVersion($pdo);
$versionFile = __DIR__ . '/VERSION';
if (!file_exists($versionFile)) {
throw new RuntimeException('VERSION 文件不存在');
}
$targetVersion = trim(file_get_contents($versionFile));
$upgradeSteps = getUpgradeSteps($currentVersion, $targetVersion);
if (empty($upgradeSteps)) {
echo json_encode([
'success' => true,
'current' => $currentVersion,
'target' => $targetVersion,
'steps' => [['version' => '', 'status' => 'uptodate', 'message' => '数据库已是最新版本,无需升级。']]
]);
exit();
}
$pdo->beginTransaction();
try {
foreach ($upgradeSteps as $version => $sqlFile) {
$shortFile = basename($sqlFile);
try {
executeUpgrade($pdo, $version, $sqlFile);
$upgradeLog[] = [
'version' => $version,
'status' => 'success',
'message' => "升级至 v{$version} 成功 ({$shortFile})"
];
} catch (Exception $e) {
$upgradeLog[] = [
'version' => $version,
'status' => 'error',
'message' => "升级至 v{$version} 失败 ({$shortFile}): " . $e->getMessage()
];
throw $e;
}
}
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
echo json_encode([
'success' => true,
'current' => $currentVersion,
'target' => $targetVersion,
'steps' => $upgradeLog
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'current' => $currentVersion,
'target' => $targetVersion,
'steps' => $upgradeLog,
'error' => $e->getMessage()
]);
}
exit();
}
// GET 模式:显示升级信息页面
$currentVersion = '未知';
$targetVersion = '未知';
$upgradeSteps = [];
$hasError = false;
$errorMessage = '';
$isUpToDate = false;
try {
$envPath = __DIR__ . '/backend/.env';
$config = readEnvConfig($envPath);
$dsn = "mysql:host={$config['DB_HOST']};port={$config['DB_PORT']};dbname={$config['DB_NAME']};charset=utf8mb4";
$pdo = new PDO($dsn, $config['DB_USER'], $config['DB_PASSWORD'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
$currentVersion = detectCurrentVersion($pdo);
$versionFile = __DIR__ . '/VERSION';
if (!file_exists($versionFile)) {
throw new RuntimeException('VERSION 文件不存在: ' . $versionFile);
}
$targetVersion = trim(file_get_contents($versionFile));
$upgradeSteps = getUpgradeSteps($currentVersion, $targetVersion);
$isUpToDate = empty($upgradeSteps);
} catch (Exception $e) {
$hasError = true;
$errorMessage = $e->getMessage();
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统升级 - 班级操行分管理系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f7fa;
color: #333;
line-height: 1.6;
padding: 20px;
}
.container {
max-width: 720px;
margin: 40px auto;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
padding: 24px 32px;
}
.header h1 { font-size: 20px; font-weight: 600; }
.header p { font-size: 13px; opacity: 0.85; margin-top: 4px; }
.content { padding: 24px 32px; }
.info-row {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.info-row:last-child { border-bottom: none; }
.info-label { color: #888; }
.info-value { font-weight: 600; }
.section-title {
font-size: 15px;
font-weight: 600;
margin: 20px 0 12px;
color: #444;
}
.step {
display: flex;
align-items: flex-start;
padding: 10px 12px;
margin-bottom: 8px;
border-radius: 6px;
font-size: 14px;
background: #fafafa;
border-left: 3px solid #ddd;
}
.step.success { border-left-color: #52c41a; background: #f6ffed; }
.step.error { border-left-color: #ff4d4f; background: #fff2f0; }
.step.pending { border-left-color: #faad14; background: #fffbe6; }
.step-icon { margin-right: 10px; font-size: 16px; flex-shrink: 0; }
.step.success .step-icon { color: #52c41a; }
.step.error .step-icon { color: #ff4d4f; }
.step.pending .step-icon { color: #faad14; }
.step-message { word-break: break-all; }
.error-box {
background: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 6px;
padding: 12px 16px;
margin-top: 16px;
color: #cf1322;
font-size: 13px;
word-break: break-all;
}
.warning-box {
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 6px;
padding: 12px 16px;
margin-top: 16px;
color: #ad6800;
font-size: 13px;
}
.btn-upgrade {
display: inline-block;
padding: 10px 32px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
border-radius: 6px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
margin-top: 20px;
transition: opacity 0.2s;
}
.btn-upgrade:hover { opacity: 0.9; }
.btn-upgrade:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-upgrade.loading {
opacity: 0.7;
cursor: wait;
}
.action-area {
text-align: center;
margin-top: 16px;
}
.result-area {
margin-top: 16px;
}
.footer {
text-align: center;
padding: 16px 32px;
border-top: 1px solid #f0f0f0;
color: #aaa;
font-size: 12px;
}
.success-box {
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 6px;
padding: 16px;
margin-top: 16px;
text-align: center;
color: #389e0d;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>班级操行分管理系统 - 数据库升级</h1>
<p>自动检测版本并执行增量升级</p>
</div>
<div class="content">
<div class="info-row">
<span class="info-label">当前数据库版本</span>
<span class="info-value" id="currentVersion"><?php echo htmlspecialchars($currentVersion); ?></span>
</div>
<div class="info-row">
<span class="info-label">目标版本</span>
<span class="info-value" id="targetVersion"><?php echo htmlspecialchars($targetVersion); ?></span>
</div>
<?php if ($hasError): ?>
<div class="error-box">
<strong>错误:</strong><?php echo htmlspecialchars($errorMessage); ?>
</div>
<?php if ($hasError && strpos($errorMessage, '配置文件不存在') !== false): ?>
<div class="warning-box">
<strong>💡 解决方法:</strong><br>
1. 进入 <code>backend/</code> 目录<br>
2. 复制配置模板:<code>cp .env.example .env</code><br>
3. 编辑 <code>.env</code> 文件,填入实际的数据库连接信息<br>
4. 刷新此页面
</div>
<?php endif; ?>
<?php elseif ($isUpToDate): ?>
<div class="success-box">
✓ 数据库已是最新版本,无需升级。
</div>
<?php else: ?>
<div class="section-title">待执行升级步骤</div>
<?php foreach ($upgradeSteps as $version => $sqlFile): ?>
<div class="step pending" id="step-<?php echo htmlspecialchars($version); ?>">
<span class="step-icon">○</span>
<span class="step-message">升级至 v<?php echo htmlspecialchars($version); ?> (<?php echo htmlspecialchars(basename($sqlFile)); ?>)</span>
</div>
<?php endforeach; ?>
<div class="warning-box">
⚠️ 升级前请确保已备份数据库,升级过程中请勿关闭页面。
</div>
<div class="action-area">
<button class="btn-upgrade" id="btnUpgrade" onclick="executeUpgrade()">立即升级</button>
</div>
<div class="result-area" id="resultArea" style="display:none;"></div>
<?php endif; ?>
</div>
<div class="footer">
班级操行分管理系统 v<?php echo htmlspecialchars($targetVersion); ?>
</div>
</div>
<?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');
btn.disabled = true;
btn.classList.add('loading');
btn.textContent = '升级中...';
resultArea.style.display = 'none';
// 收集所有待执行步骤
var steps = [];
<?php foreach ($upgradeSteps as $version => $sqlFile): ?>
steps.push('<?php echo htmlspecialchars($version); ?>');
<?php endforeach; ?>
var currentIndex = 0;
function executeNextStep() {
if (currentIndex >= steps.length) {
btn.classList.remove('loading');
btn.textContent = '升级完成';
resultArea.style.display = 'block';
resultArea.innerHTML = '<div class="success-box">✓ 升级成功!数据库已更新至最新版本<br><br><small style="color:#888">建议升级完成后删除 upgrade.php 文件</small></div>';
return;
}
var version = steps[currentIndex];
fetch('?action=step&version=' + encodeURIComponent(version), { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(data) {
var el = document.getElementById('step-' + version);
if (el) {
el.className = 'step ' + (data.success ? 'success' : 'error');
el.querySelector('.step-icon').textContent = data.success ? '✓' : '✗';
}
if (data.success) {
currentIndex++;
executeNextStep();
} else {
btn.classList.remove('loading');
btn.textContent = '升级失败';
btn.disabled = false;
resultArea.style.display = 'block';
resultArea.innerHTML = '<div class="error-box"><strong>升级失败:</strong>' + escapeHtml(data.error || '未知错误') + '</div>';
}
})
.catch(function(err) {
var el = document.getElementById('step-' + version);
if (el) {
el.className = 'step error';
el.querySelector('.step-icon').textContent = '✗';
}
btn.classList.remove('loading');
btn.disabled = false;
btn.textContent = '立即升级';
resultArea.style.display = 'block';
resultArea.innerHTML = '<div class="error-box"><strong>请求失败:</strong>' + escapeHtml(err.message) + '</div>';
});
}
executeNextStep();
}
</script>
<?php endif; ?>
</body>
</html>