仰望星辰工作室

feat(migrations): 引入版本化迁移链,跨版本更新按游标顺序执行

Y yang1145 提交于 2026-09-30 21:22 · 51f91e5 ·父提交 d878119
feat(migrations): 引入版本化迁移链,跨版本更新按游标顺序执行

- 新增 MPHX/migrations.php:以独立游标表 MN_dbver 记录每版本应用状态,
  DELIMITER 感知的语句切分支持 v183/v184 存储过程守护写法,
  容忍 1050/1060/1061/1826/1091 类幂等错误码。
- 在线更新走迁移链:只跑「游标 < 版本 <= 目标」的 update_v*_*.sql,
  失败保留 update/ 目录,游标停在最后成功版本,修复后可续跑;
  旧式单文件 update/update.sql 保持兼容路径。
- 安装向导播种基线:全新安装/覆盖升级完成后把游标抬到 $WEBQB,
  避免在线更新重放历史迁移;skip 模式因库版本未知不播种。
- install.sql / repair_tables.sql 建表,后台更新提示与 changelog 同步说明。
9 个文件变更 +351 -15 13172193298@163.com
•MPHX/github_updater.php +3 -1
•MPHX/migrations.php +285 -0
•admin/api/gg.php +20 -9
•docs/guide/changelog.md +11 -1
•install/install.api.php +13 -2
•install/install.sql +9 -0
•install/repair_tables.sql +8 -0
•templates/default/admin/update.php +1 -1
•templates/tdesign/spa/src/admin/views/UpdateView.vue +1 -1
变更内容
diff --git a/MPHX/github_updater.php b/MPHX/github_updater.php
index ec45b98..7f57b95 100644
--- a/MPHX/github_updater.php
+++ b/MPHX/github_updater.php
@@ -944,7 +944,9 @@ function mnbt_updater_guard_shutdown()
 }
 
 /**
- * 执行升级 SQL(只执行 update/update.sql,逻辑与旧版一致:按分号逐句跑)
+ * V1.85 迁移链(MPHX/migrations.php / MN_dbver 游标表)的旧式兼容路径:
+ * 仅用于执行发布包里遗留的单文件 update/update.sql,按分号逐句跑,不做 DELIMITER 切分。
+ * 新版本请改放 update/update_v<3位或4位>_<slug>.sql,走 mnbt_migrations_run。
  * 注意参数顺序是 mysqli(host, user, pwd, dbname[, port]),旧代码把 dbname 和 pwd 传反了,
  * 导致连接总是失败,这里按正确顺序传
  * @return string 空串表示成功,否则为错误说明
diff --git a/MPHX/migrations.php b/MPHX/migrations.php
new file mode 100644
index 0000000..6735b1f
--- /dev/null
+++ b/MPHX/migrations.php
@@ -0,0 +1,285 @@
+<?php
+if (!defined('IN_CRONLITE')) exit();
+/**
+ * 版本化迁移链引擎(V1.85 起)
+ *
+ * 目标:更新包里 update/ 目录按版本各放一个小增量 SQL(update_v183_xxx.sql、
+ * update_1860_xxx.sql…),执行器读库里的"已应用版本"游标,只把游标之后、
+ * 目标版本之前的迁移按升序依次跑一遍,做到任意老版本一步跳到目标版本。
+ *
+ * 为什么这么设计:
+ * - 游标用独立表 MN_dbver(自举建表),不塞 MN_config,避免和业务配置耦合、
+ *   也留下每个版本何时应用的审计。
+ * - 语句切分支持 DELIMITER,因为幂等的"存储过程守护列存在"写法(v183/v184)
+ *   必须整段(含过程体内的分号)作为一个语句执行,朴素按 ; 切会崩。
+ * - 容忍"对象已存在"类错误码,配合迁移文件本身的幂等写法,存量老站从头重跑也安全。
+ * 只定义函数,不产生任何输出;入参用 $dbconfig 自建连接,调用方无需传 DB 层。
+ */
+
+/** 迁移重跑时"对象/列/索引已存在"或"已删除"类错误码,视为幂等跳过;其余按致命错误停止 */
+function mnbt_migrations_tolerant_errno()
+{
+	// 1050 表已存在 1060 列重复 1061 键名重复(MySQL) 1826 索引重复(MariaDB) 1091 无法删除不存在列/索引
+	return array(1050, 1060, 1061, 1826, 1091);
+}
+
+/**
+ * 迁移文件名 → 版本整数(与 $WEBQB / tag_to_version 同一套:主版本*1000+次版本*10+补丁)
+ * 支持两种写法:3 位 update_v183_xxx.sql(=>1830)、4 位 update_1860_xxx.sql / update_v1860_xxx.sql(=>1860)
+ * 不符合命名(如旧的单文件 update.sql)返回 0,交由链忽略
+ */
+function mnbt_migration_version_from_name($path)
+{
+	$name = basename((string)$path);
+	if (!preg_match('/^update_v?(\d{3,4})_.*\.sql$/i', $name, $m)) return 0;
+	$ds = $m[1];
+	if (strlen($ds) >= 4) {
+		$major = (int)$ds[0];
+		$minor = (int)substr($ds, 1, 2);
+		$patch = (int)substr($ds, 3, 1);
+	} else { // 3 位:形如 183 => 主1 次83
+		$major = (int)$ds[0];
+		$minor = (int)substr($ds, 1, 2);
+		$patch = 0;
+	}
+	return $major * 1000 + $minor * 10 + min(9, $patch);
+}
+
+/**
+ * DELIMITER-aware 语句切分:
+ * - 识别 `DELIMITER xxx` 指令行切换结束符
+ * - 跳过 -- / # 行注释与 &#47;* *&#47; 块注释(注释后的换行保留,避免相邻行粘连)
+ * - 单引号/双引号/反引号内的分隔符与注释不参与切分,反斜杠转义(反引号除外)
+ * @return array 去掉空白与纯注释后的完整语句列表
+ */
+function mnbt_sql_statements($sql)
+{
+	$sql = (string)$sql;
+	$stmts = array();
+	$delim = ';';
+	$buf = '';
+	$len = strlen($sql);
+	$i = 0;
+	$q = '';            // 当前引号:'' 无,或 ' " `
+	$line_start = true; // 是否处于行首(允许出现 DELIMITER 指令)
+	while ($i < $len) {
+		$c = $sql[$i];
+		if ($q !== '') { // 引号内:只关心转义与闭合,其余原样进 buf
+			if ($c === '\\' && $q !== '`') {
+				$buf .= $c;
+				if ($i + 1 < $len) $buf .= $sql[$i + 1];
+				$i += 2;
+				continue;
+			}
+			if ($c === $q) { $buf .= $c; $q = ''; $i++; $line_start = false; continue; }
+			$buf .= $c;
+			if ($c === "\n" || $c === "\r") $line_start = true;
+			$i++;
+			continue;
+		}
+		// 引号外:先处理注释
+		if ($c === '-' && $i + 1 < $len && $sql[$i + 1] === '-') { // -- 行注释
+			$j = $i + 2;
+			while ($j < $len && $sql[$j] !== "\n") $j++;
+			$i = $j; // 停在换行上,交给下面的普通分支保留换行
+			continue;
+		}
+		if ($c === '#') { // # 行注释
+			$j = $i + 1;
+			while ($j < $len && $sql[$j] !== "\n") $j++;
+			$i = $j;
+			continue;
+		}
+		if ($c === '/' && $i + 1 < $len && $sql[$i + 1] === '*') { // 块注释
+			$j = $i + 2;
+			while ($j < $len && !($sql[$j] === '*' && $j + 1 < $len && $sql[$j + 1] === '/')) $j++;
+			$i = ($j < $len) ? $j + 2 : $j;
+			continue;
+		}
+		// 引号开始
+		if ($c === "'" || $c === '"' || $c === '`') {
+			$q = $c; $buf .= $c; $i++; $line_start = false;
+			continue;
+		}
+		// DELIMITER 指令(必须位于行首且当前语句缓冲为空);结束符可能是 ';',故用 \S+ 捕获
+		if ($line_start && trim($buf) === '' && preg_match('/^[ \t]*DELIMITER[ \t]+(\S+)/i', substr($sql, $i), $md)) {
+			$delim = $md[1];
+			$j = $i;
+			while ($j < $len && $sql[$j] !== "\n") $j++;
+			$i = $j; $buf = '';
+			continue;
+		}
+		// 结束符匹配
+		$dl = strlen($delim);
+		if ($dl > 0 && substr($sql, $i, $dl) === $delim) {
+			$s = trim($buf);
+			if ($s !== '') $stmts[] = $s;
+			$buf = ''; $i += $dl; $line_start = false;
+			continue;
+		}
+		// 普通字符入缓冲
+		if ($c === "\n" || $c === "\r") { $line_start = true; }
+		elseif ($c === ' ' || $c === "\t") { /* 维持 line_start */ }
+		else { $line_start = false; }
+		$buf .= $c;
+		$i++;
+	}
+	$s = trim($buf);
+	if ($s !== '') $stmts[] = $s;
+	return $stmts;
+}
+
+/** 用 $dbconfig 建一个 mysqli(关闭异常模式,保持 query 返回 false 的旧语义);失败写 $err 返回 null */
+function mnbt_migrations_db($dbconfig, &$err)
+{
+	$err = '';
+	if (!class_exists('mysqli')) {
+		$err = '服务器未启用 mysqli 扩展,无法执行升级 SQL';
+		return null;
+	}
+	if (function_exists('mysqli_report')) @mysqli_report(MYSQLI_REPORT_OFF);
+	$port = !empty($dbconfig['port']) ? (int)$dbconfig['port'] : 3306;
+	try {
+		$db = @new mysqli($dbconfig['host'], $dbconfig['user'], $dbconfig['pwd'], $dbconfig['dbname'], $port);
+	} catch (Throwable $e) {
+		$err = '连接数据库出错:' . $e->getMessage();
+		return null;
+	}
+	if (mysqli_connect_errno()) {
+		$err = '连接数据库出错:' . mysqli_connect_error();
+		return null;
+	}
+	$db->set_charset('utf8');
+	$db->query("set sql_mode = ''");
+	return $db;
+}
+
+/** 建游标表(自举,幂等) */
+function mnbt_migrations_ensure($db, &$err)
+{
+	$sql = "CREATE TABLE IF NOT EXISTS `MN_dbver` ("
+		. "`version` int(11) NOT NULL,"
+		. "`file` varchar(191) NOT NULL DEFAULT '',"
+		. "`applied_at` datetime NOT NULL,"
+		. "PRIMARY KEY (`version`)) ENGINE=MyISAM DEFAULT CHARSET=utf8";
+	if ($db->query($sql) === false) {
+		$err = '创建迁移记录表 MN_dbver 失败:' . $db->error;
+		return false;
+	}
+	return true;
+}
+
+/** 当前库已应用到的版本(高水位游标);无记录返回 0 */
+function mnbt_migrations_cursor($db)
+{
+	$r = $db->query("SELECT MAX(`version`) AS v FROM `MN_dbver`");
+	if (!$r) return 0;
+	$row = $r->fetch_assoc();
+	if (is_object($r)) $r->free();
+	return isset($row['v']) ? (int)$row['v'] : 0;
+}
+
+/** 记录某版本已应用(REPLACE 防主键冲突) */
+function mnbt_migrations_record($db, $version, $file)
+{
+	$version = (int)$version;
+	$now = date('Y-m-d H:i:s');
+	$stmt = $db->prepare("REPLACE INTO `MN_dbver`(`version`,`file`,`applied_at`) VALUES(?,?,?)");
+	if ($stmt) {
+		$stmt->bind_param('iss', $version, $file, $now);
+		$stmt->execute();
+		$stmt->close();
+		return;
+	}
+	// 兜底:预处理不可用时走转义拼接
+	$safe_file = $db->real_escape_string($file);
+	$db->query("REPLACE INTO `MN_dbver`(`version`,`file`,`applied_at`) VALUES($version,'$safe_file','$now')");
+}
+
+/** 顺序执行一组语句;容忍"已存在"类错误,其余即视为致命并停止 */
+function mnbt_migrations_exec($db, $stmts, &$err)
+{
+	$tol = mnbt_migrations_tolerant_errno();
+	foreach ($stmts as $st) {
+		if (trim($st) === '') continue;
+		$res = $db->query($st);
+		if ($res === false) {
+			if (in_array($db->errno, $tol, true)) { continue; }
+			$preview = function_exists('mb_substr') ? mb_substr($st, 0, 140) : substr($st, 0, 420);
+			$err = 'SQL 错误 ' . $db->errno . ':' . $db->error . '(语句:' . $preview . '…)';
+			return false;
+		}
+		if (is_object($res)) $res->free();
+	}
+	return true;
+}
+
+/** 扫描迁移目录,返回按版本升序的迁移项 [{version,name,file}] */
+function mnbt_migrations_find($dir)
+{
+	$out = array();
+	$paths = glob(rtrim($dir, '/') . '/update_*.sql');
+	if (!is_array($paths)) return $out;
+	foreach ($paths as $p) {
+		$v = mnbt_migration_version_from_name($p);
+		if ($v <= 0) continue; // 不符合命名规范(如遗留单文件 update.sql)忽略
+		$out[] = array('version' => $v, 'name' => basename($p), 'file' => $p);
+	}
+	usort($out, function ($a, $b) {
+		if ($a['version'] === $b['version']) return strcmp($a['name'], $b['name']);
+		return $a['version'] - $b['version'];
+	});
+	return $out;
+}
+
+/**
+ * 执行迁移链:只跑「游标 < 版本 <= 目标」的迁移,按升序,成功一个记一个。
+ * 中途失败即停止并保留后续文件(游标停在最后成功版本,修复后重跑可续)。
+ * @return array 已成功应用的迁移文件名列表
+ */
+function mnbt_migrations_run($dbconfig, $dir, $target, &$err)
+{
+	$applied = array();
+	$err = '';
+	$files = mnbt_migrations_find($dir);
+	if (!$files) return $applied; // 无迁移文件视为无需变更
+	$db = mnbt_migrations_db($dbconfig, $conn_err);
+	if ($db === null) { $err = $conn_err; return $applied; }
+	if (!mnbt_migrations_ensure($db, $err)) { $db->close(); return $applied; }
+	$cursor = mnbt_migrations_cursor($db);
+	foreach ($files as $f) {
+		if ($f['version'] > $target) break;     // 已按版本升序,后续都超目标
+		if ($f['version'] <= $cursor) continue;  // 游标之前的高水位已应用
+		$sql = @file_get_contents($f['file']);
+		if ($sql === false) { $err = '无法读取迁移文件 ' . $f['name']; break; }
+		$stmts = mnbt_sql_statements($sql);
+		if (!$stmts && trim($sql) !== '') { $err = '迁移文件未解析出任何语句:' . $f['name']; break; }
+		if (!mnbt_migrations_exec($db, $stmts, $err)) { $err = '迁移 ' . $f['name'] . '(版本 ' . $f['version'] . ')失败:' . $err; break; }
+		mnbt_migrations_record($db, $f['version'], $f['name']);
+		$applied[] = $f['name'];
+	}
+	$db->close();
+	if ($err !== '') return array(); // 有错:整链视为失败,交由上层保留文件、提示手工处理
+	return $applied;
+}
+
+/**
+ * 基线播种:全新安装 / 安装向导升级后调用,把游标抬到目标版本,
+ * 之后在线更新只跑更高版本的增量,不会重放已到位的历史迁移。
+ * 已到位的表/列由安装流程本身保证,这里只是记账。
+ */
+function mnbt_migrations_seed($dbconfig, $version, &$err)
+{
+	$version = (int)$version;
+	$err = '';
+	if ($version <= 0) { $err = '基线版本号无效'; return false; }
+	$db = mnbt_migrations_db($dbconfig, $conn_err);
+	if ($db === null) { $err = $conn_err; return false; }
+	if (!mnbt_migrations_ensure($db, $err)) { $db->close(); return false; }
+	if (mnbt_migrations_cursor($db) < $version) {
+		mnbt_migrations_record($db, $version, '__baseline__');
+	}
+	$db->close();
+	return true;
+}
+?>
diff --git a/admin/api/gg.php b/admin/api/gg.php
index 2f97c7d..8988c50 100644
--- a/admin/api/gg.php
+++ b/admin/api/gg.php
@@ -11,6 +11,7 @@ if($egn=='update') {
 	@ignore_user_abort(true);
 	include("../MPHX/BL.php");
 	include_once("../MPHX/github_updater.php");
+	include_once("../MPHX/migrations.php");
 	include("../cf_up.php");
 
 	mnbt_updater_guard_start();
@@ -103,16 +104,26 @@ if($egn=='update') {
 		json_exit_error('解压覆盖失败:'.$ex_err);
 	}
 
-	// 8. 升级 SQL:只执行包里带的 update/update.sql,位置在剥顶层目录后不变
+	// 8. 升级 SQL:把包里 update/ 的版本化迁移文件按游标顺序跑一遍(支持跨版本一步跳)
 	$sql_msg = '';
-	$sql_file = $root.'/update/update.sql';
-	if(is_file($sql_file)) {
-		$sql_msg = mnbt_updater_run_sql($sql_file,$dbconfig);
-		if($sql_msg==='') {
-			// 执行成功才删,出错时保留文件让管理员可以手工再跑一次
-			@unlink($sql_file);
-			@rmdir($root.'/update/');
+	$upd_dir = $root.'/update/';
+	$applied_files = array();
+	if(is_dir($upd_dir)) {
+		$applied_files = mnbt_migrations_run($dbconfig,$upd_dir,(int)$chk['version'],$mig_err);
+		if($mig_err!=='') $sql_msg = $mig_err;
+		// 遗留单文件 update.sql(旧式发布)继续兼容:链成功后一次性执行并删除
+		if($sql_msg==='' && is_file($upd_dir.'update.sql')) {
+			$sql_msg = mnbt_updater_run_sql($upd_dir.'update.sql',$dbconfig);
+			if($sql_msg==='') {
+				@unlink($upd_dir.'update.sql');
+				@rmdir($upd_dir);
+			}
+		} elseif($sql_msg==='') {
+			// 版本链全部成功:删掉已应用的迁移文件,保持 update/ 目录整洁(游标表已记账,重跑无副作用)
+			foreach($applied_files as $fn) @unlink($upd_dir.$fn);
+			@rmdir($upd_dir);
 		}
+		// 失败:保留 update/ 目录所有文件,游标停在最后成功版本,修复后可再次点击继续
 	}
 
 	// 9. 还原本地配置与安装锁、把后台目录改回原名、清临时文件
@@ -124,7 +135,7 @@ if($egn=='update') {
 		logjl($user ?? '', '系统更新', '更新到 '.$chk['latest'].'('.$used['label'].',来源 '.$via_label.')', $sql_msg===''?'更新成功':'更新成功但升级SQL有误', $DB);
 	}
 	if($sql_msg!=='') {
-		json_exit_error('文件已更新,但升级 SQL 执行失败:'.$sql_msg.'(update/update.sql 已保留,可手工执行)', [
+		json_exit_error('文件已更新,但升级 SQL 执行失败:'.$sql_msg.'(update/ 迁移文件已保留,游标停在最后成功的版本,可修复后重跑或手工执行)', [
 			'ver' => $chk['latest'], 'tag' => $chk['tag'],
 			'source' => $used['label'], 'via' => $via_label, 'strip' => $za['strip'], 'files' => $za['files'],
 		]);
diff --git a/docs/guide/changelog.md b/docs/guide/changelog.md
index 66a8223..5882ca0 100644
--- a/docs/guide/changelog.md
+++ b/docs/guide/changelog.md
@@ -41,7 +41,7 @@ description: MNBT 版本更新记录(V1.85 ~ V1.60)
 - 解压前扫描 ZipArchive 条目名,拒绝绝对路径、盘符、`..` 段
 - 覆盖前备份并在完成后还原 `config.php`、`cf_up.php`、`MPHX/SQ.php`、`install/install.lock`、`api/cookie/`;包自带的 `install.lock` 若站点原本没有会被删除,避免被判定为未安装
 - `set_time_limit(600)` + `ignore_user_abort` + `register_shutdown_function` 兜底:任何中断路径都会还原改名后的后台目录、还原本地配置并清理临时包
-- 升级 SQL 仅执行包内 `update/update.sql`(通过 `$dbconfig` 连接),执行成功后删除该文件与 `update/` 目录
+- 升级 SQL 走版本化迁移链:`update/update_v<3 位或 4 位>_<slug>.sql` 按版本号升序执行,仅跑游标之后、目标版本以内的增量(详见下节)
 
 **发版运维约定**
 
@@ -53,6 +53,16 @@ description: MNBT 版本更新记录(V1.85 ~ V1.60)
 - 安装向导(`install/index.php` 的徽章/升级文案、`install/install.api.php` 的接口 `vs` 字段与升级完成提示)、后台版本显示(`admin/api/bt.php`)全部改为从 `$WEBQB` 计算,不再写死字面量;发版改产品内版本只需动 `BL.php` 一处
 - README 与 docs 首页的 shields.io 徽章是静态 Markdown(PHP 渲染不到),由 `tools/sync-version.sh` 从 `$WEBQB` 同步;发版流程:改 `BL.php` → `bash tools/sync-version.sh` → 追加 changelog 段落
 
+**版本化迁移链**
+
+- 新增 `MPHX/migrations.php`:按文件版本号排序、以游标表驱动依次执行的迁移引擎;任意老版本一键跳到目标版本,无需人工拼 SQL
+- 游标表 `MN_dbver`(`version` PK / `file` / `applied_at`)独立记录每个版本何时应用,不塞进 `MN_config`,也留下审计;安装向导与 `install.sql`、`repair_tables.sql` 一并建表
+- 迁移文件命名:`update/update_v<3位或4位>_<slug>.sql`;3 位取 `183`→`1830`,4 位取 `1860`→`1860`;不符合此命名的旧式单文件 `update.sql` 走一次性兼容路径执行后删除
+- 语句切分支持 `DELIMITER $$` 与存储过程守护写法(v183/v184 的幂等「列存在则跳过」),引号/注释内的分隔符不参与切分
+- 幂等容忍错误码:1050 表已存在、1060 列重复、1061/1826 索引重复(MySQL/MariaDB)、1091 无法删除不存在列/索引;其它错误码致命并停链,`update/` 目录保留、游标停在最后成功的版本,修复后可再次点击继续
+- 基线播种:全新安装/升级完成后调用 `mnbt_migrations_seed($dbconfig, $WEBQB)` 把游标抬到当前版本,之后在线更新只跑更高版本的增量,不会重放历史迁移
+- 发版约定:每个版本的增量放独立小 SQL 追加到 `update/`,每条迁移保持幂等;跨版本升级由客户端自动顺序执行游标之后的所有文件
+
 ## V1.83
 
 **Docker 容器托管**
diff --git a/install/install.api.php b/install/install.api.php
index 0a94c27..216c158 100644
--- a/install/install.api.php
+++ b/install/install.api.php
@@ -57,14 +57,14 @@ function mnbt_table_columns($table) {
     return $cols;
 }
 
-// 标准表清单(含 V1.83 Docker 集成四表)
+// 标准表清单(含 V1.83 Docker 集成四表、V1.85 迁移游标表 MN_dbver)
 function mnbt_standard_tables() {
     return array('MN_config','MN_log','MN_bt','MN_zj','MN_bs','MN_ym','MN_dd',
         'MN_monitor_task','MN_monitor_log','MN_notice_log',
         'MN_node','MN_node_task','MN_node_nonce',
         'MN_forbidden_scan','MN_forbidden_match',
         'MN_plugin','MN_plugin_option',
-        'MN_docker_node','MN_docker_user','MN_docker_plan','MN_docker_order');
+        'MN_docker_node','MN_docker_user','MN_docker_plan','MN_docker_order','MN_dbver');
 }
 
 // 各版本增量字段定义(随发版追加,勿在此写死当前版本号):表 => (列名 => ALTER 语句)
@@ -529,6 +529,17 @@ switch ($action) {
             exit(Res(0, '站点配置写入失败:' . DB::error()));
         }
 
+        // V1.85 迁移链基线播种:全新安装 / 覆盖升级已把库结构带到当前版本,
+        // 据此把游标 MN_dbver 抬到 $WEBQB,之后在线更新只跑更高版本的增量、不重放历史迁移。
+        // skip 模式(只改配置不动表结构)库版本未知,故不播种;播种失败也不阻断安装。
+        if ($install_mode !== 'skip') {
+            include_once __DIR__ . '/../MPHX/migrations.php';
+            if (function_exists('mnbt_migrations_seed')) {
+                $seed_err = '';
+                mnbt_migrations_seed($dbconfig, (int)$WEBQB, $seed_err);
+            }
+        }
+
         @file_put_contents("install.lock", '安装锁');
         if ($install_mode === 'upgrade') {
             exit(Res(1, '升级完成!已保留原有数据,成功更新至 ' . mnbt_version()));
diff --git a/install/install.sql b/install/install.sql
index 6a6f33f..d68c326 100644
--- a/install/install.sql
+++ b/install/install.sql
@@ -423,3 +423,12 @@ CREATE TABLE `MN_plugin_option` (
   PRIMARY KEY (`id`),
   UNIQUE KEY `uk_plugin_k` (`plugin_slug`,`k`)
 ) ENGINE=MyISAM DEFAULT CHARSET=utf8;
+
+-- 迁移链版本游标(V1.85 起,在线更新按此表记录已应用的版本决定后续增量)
+DROP TABLE IF EXISTS `MN_dbver`;
+CREATE TABLE `MN_dbver` (
+  `version` int(11) NOT NULL,                                   -- 已应用的版本整数(同 $WEBQB 体系,如 1850)
+  `file` varchar(191) NOT NULL DEFAULT '',                      -- 应用的迁移文件名
+  `applied_at` datetime NOT NULL,                               -- 应用时间
+  PRIMARY KEY (`version`)
+) ENGINE=MyISAM DEFAULT CHARSET=utf8;
diff --git a/install/repair_tables.sql b/install/repair_tables.sql
index 4bdf6ff..08e14b0 100644
--- a/install/repair_tables.sql
+++ b/install/repair_tables.sql
@@ -327,3 +327,11 @@ CREATE TABLE IF NOT EXISTS `MN_docker_order` (
   `date` varchar(50) NOT NULL,
   PRIMARY KEY (`id`)
 ) ENGINE=MyISAM DEFAULT CHARSET=utf8;
+
+-- V1.85 迁移链版本游标表(在线更新据此判断已应用版本)
+CREATE TABLE IF NOT EXISTS `MN_dbver` (
+  `version` int(11) NOT NULL,
+  `file` varchar(191) NOT NULL DEFAULT '',
+  `applied_at` datetime NOT NULL,
+  PRIMARY KEY (`version`)
+) ENGINE=MyISAM DEFAULT CHARSET=utf8;
diff --git a/templates/default/admin/update.php b/templates/default/admin/update.php
index df20f1a..3244ba9 100644
--- a/templates/default/admin/update.php
+++ b/templates/default/admin/update.php
@@ -146,7 +146,7 @@ $upd_token_set = !empty($mnbt_update['has_token']);
       <div class="mn-upd-note" style="margin-top:16px;">
         <b>更新说明:</b>更新会直接用 GitHub Release 的包覆盖站点文件,覆盖前会备份并还原
         <code>config.php</code>、<code>cf_up.php</code>、<code>MPHX/SQ.php</code>、<code>install/install.lock</code>、<code>api/cookie/</code>;
-        包里若带 <code>update/update.sql</code> 会自动执行。请在维护时段操作,更新过程中不要关闭页面。
+        包里 <code>update/update_v*_*.sql</code> 的版本化迁移会按版本号依次执行(仅跑游标 <code>MN_dbver</code> 之后的增量),旧式单文件 <code>update/update.sql</code> 仍兼容。请在维护时段操作,更新过程中不要关闭页面。
       </div>
     </div>
   </div>
diff --git a/templates/tdesign/spa/src/admin/views/UpdateView.vue b/templates/tdesign/spa/src/admin/views/UpdateView.vue
index 5f1f0dd..625e0a6 100644
--- a/templates/tdesign/spa/src/admin/views/UpdateView.vue
+++ b/templates/tdesign/spa/src/admin/views/UpdateView.vue
@@ -163,7 +163,7 @@
           <p class="upd-note">
             更新会直接用 GitHub Release 的包覆盖站点文件,覆盖前会备份并还原
             <code>config.php</code>、<code>cf_up.php</code>、<code>MPHX/SQ.php</code>、<code>install/install.lock</code>、<code>api/cookie/</code>;
-            包里若带 <code>update/update.sql</code> 会自动执行。请在维护时段操作。
+            包里 <code>update/update_v*_*.sql</code> 的版本化迁移会按版本号依次执行(仅跑游标 <code>MN_dbver</code> 之后的增量),旧式单文件 <code>update/update.sql</code> 仍兼容。请在维护时段操作。
           </p>
         </div>
       </div>