仰望星辰工作室

better-staridc-MNBT

better-staridc-MNBT/ MPHX/migrations.php 10.4 KB · 285 行 原始文件
Z zfhsh first commit 2 天前
1<?php
2if (!defined('IN_CRONLITE')) exit();
3/**
4 * 版本化迁移链引擎(V1.85 起)
5 *
6 * 目标:更新包里 update/ 目录按版本各放一个小增量 SQL(update_v183_xxx.sql、
7 * update_1860_xxx.sql…),执行器读库里的"已应用版本"游标,只把游标之后、
8 * 目标版本之前的迁移按升序依次跑一遍,做到任意老版本一步跳到目标版本。
9 *
10 * 为什么这么设计:
11 * - 游标用独立表 MN_dbver(自举建表),不塞 MN_config,避免和业务配置耦合、
12 * 也留下每个版本何时应用的审计。
13 * - 语句切分支持 DELIMITER,因为幂等的"存储过程守护列存在"写法(v183/v184)
14 * 必须整段(含过程体内的分号)作为一个语句执行,朴素按 ; 切会崩。
15 * - 容忍"对象已存在"类错误码,配合迁移文件本身的幂等写法,存量老站从头重跑也安全。
16 * 只定义函数,不产生任何输出;入参用 $dbconfig 自建连接,调用方无需传 DB 层。
17 */
18
19/** 迁移重跑时"对象/列/索引已存在"或"已删除"类错误码,视为幂等跳过;其余按致命错误停止 */
20function mnbt_migrations_tolerant_errno()
21{
22 // 1050 表已存在 1060 列重复 1061 键名重复(MySQL) 1826 索引重复(MariaDB) 1091 无法删除不存在列/索引
23 return array(1050, 1060, 1061, 1826, 1091);
24}
25
26/**
27 * 迁移文件名 → 版本整数(与 $WEBQB / tag_to_version 同一套:主版本*1000+次版本*10+补丁)
28 * 支持两种写法:3 位 update_v183_xxx.sql(=>1830)、4 位 update_1860_xxx.sql / update_v1860_xxx.sql(=>1860)
29 * 不符合命名(如旧的单文件 update.sql)返回 0,交由链忽略
30 */
31function mnbt_migration_version_from_name($path)
32{
33 $name = basename((string)$path);
34 if (!preg_match('/^update_v?(\d{3,4})_.*\.sql$/i', $name, $m)) return 0;
35 $ds = $m[1];
36 if (strlen($ds) >= 4) {
37 $major = (int)$ds[0];
38 $minor = (int)substr($ds, 1, 2);
39 $patch = (int)substr($ds, 3, 1);
40 } else { // 3 位:形如 183 => 主1 次83
41 $major = (int)$ds[0];
42 $minor = (int)substr($ds, 1, 2);
43 $patch = 0;
44 }
45 return $major * 1000 + $minor * 10 + min(9, $patch);
46}
47
48/**
49 * DELIMITER-aware 语句切分:
50 * - 识别 `DELIMITER xxx` 指令行切换结束符
51 * - 跳过 -- / # 行注释与 &#47;* *&#47; 块注释(注释后的换行保留,避免相邻行粘连)
52 * - 单引号/双引号/反引号内的分隔符与注释不参与切分,反斜杠转义(反引号除外)
53 * @return array 去掉空白与纯注释后的完整语句列表
54 */
55function mnbt_sql_statements($sql)
56{
57 $sql = (string)$sql;
58 $stmts = array();
59 $delim = ';';
60 $buf = '';
61 $len = strlen($sql);
62 $i = 0;
63 $q = ''; // 当前引号:'' 无,或 ' " `
64 $line_start = true; // 是否处于行首(允许出现 DELIMITER 指令)
65 while ($i < $len) {
66 $c = $sql[$i];
67 if ($q !== '') { // 引号内:只关心转义与闭合,其余原样进 buf
68 if ($c === '\\' && $q !== '`') {
69 $buf .= $c;
70 if ($i + 1 < $len) $buf .= $sql[$i + 1];
71 $i += 2;
72 continue;
73 }
74 if ($c === $q) { $buf .= $c; $q = ''; $i++; $line_start = false; continue; }
75 $buf .= $c;
76 if ($c === "\n" || $c === "\r") $line_start = true;
77 $i++;
78 continue;
79 }
80 // 引号外:先处理注释
81 if ($c === '-' && $i + 1 < $len && $sql[$i + 1] === '-') { // -- 行注释
82 $j = $i + 2;
83 while ($j < $len && $sql[$j] !== "\n") $j++;
84 $i = $j; // 停在换行上,交给下面的普通分支保留换行
85 continue;
86 }
87 if ($c === '#') { // # 行注释
88 $j = $i + 1;
89 while ($j < $len && $sql[$j] !== "\n") $j++;
90 $i = $j;
91 continue;
92 }
93 if ($c === '/' && $i + 1 < $len && $sql[$i + 1] === '*') { // 块注释
94 $j = $i + 2;
95 while ($j < $len && !($sql[$j] === '*' && $j + 1 < $len && $sql[$j + 1] === '/')) $j++;
96 $i = ($j < $len) ? $j + 2 : $j;
97 continue;
98 }
99 // 引号开始
100 if ($c === "'" || $c === '"' || $c === '`') {
101 $q = $c; $buf .= $c; $i++; $line_start = false;
102 continue;
103 }
104 // DELIMITER 指令(必须位于行首且当前语句缓冲为空);结束符可能是 ';',故用 \S+ 捕获
105 if ($line_start && trim($buf) === '' && preg_match('/^[ \t]*DELIMITER[ \t]+(\S+)/i', substr($sql, $i), $md)) {
106 $delim = $md[1];
107 $j = $i;
108 while ($j < $len && $sql[$j] !== "\n") $j++;
109 $i = $j; $buf = '';
110 continue;
111 }
112 // 结束符匹配
113 $dl = strlen($delim);
114 if ($dl > 0 && substr($sql, $i, $dl) === $delim) {
115 $s = trim($buf);
116 if ($s !== '') $stmts[] = $s;
117 $buf = ''; $i += $dl; $line_start = false;
118 continue;
119 }
120 // 普通字符入缓冲
121 if ($c === "\n" || $c === "\r") { $line_start = true; }
122 elseif ($c === ' ' || $c === "\t") { /* 维持 line_start */ }
123 else { $line_start = false; }
124 $buf .= $c;
125 $i++;
126 }
127 $s = trim($buf);
128 if ($s !== '') $stmts[] = $s;
129 return $stmts;
130}
131
132/** 用 $dbconfig 建一个 mysqli(关闭异常模式,保持 query 返回 false 的旧语义);失败写 $err 返回 null */
133function mnbt_migrations_db($dbconfig, &$err)
134{
135 $err = '';
136 if (!class_exists('mysqli')) {
137 $err = '服务器未启用 mysqli 扩展,无法执行升级 SQL';
138 return null;
139 }
140 if (function_exists('mysqli_report')) @mysqli_report(MYSQLI_REPORT_OFF);
141 $port = !empty($dbconfig['port']) ? (int)$dbconfig['port'] : 3306;
142 try {
143 $db = @new mysqli($dbconfig['host'], $dbconfig['user'], $dbconfig['pwd'], $dbconfig['dbname'], $port);
144 } catch (Throwable $e) {
145 $err = '连接数据库出错:' . $e->getMessage();
146 return null;
147 }
148 if (mysqli_connect_errno()) {
149 $err = '连接数据库出错:' . mysqli_connect_error();
150 return null;
151 }
152 $db->set_charset('utf8');
153 $db->query("set sql_mode = ''");
154 return $db;
155}
156
157/** 建游标表(自举,幂等) */
158function mnbt_migrations_ensure($db, &$err)
159{
160 $sql = "CREATE TABLE IF NOT EXISTS `MN_dbver` ("
161 . "`version` int(11) NOT NULL,"
162 . "`file` varchar(191) NOT NULL DEFAULT '',"
163 . "`applied_at` datetime NOT NULL,"
164 . "PRIMARY KEY (`version`)) ENGINE=MyISAM DEFAULT CHARSET=utf8";
165 if ($db->query($sql) === false) {
166 $err = '创建迁移记录表 MN_dbver 失败:' . $db->error;
167 return false;
168 }
169 return true;
170}
171
172/** 当前库已应用到的版本(高水位游标);无记录返回 0 */
173function mnbt_migrations_cursor($db)
174{
175 $r = $db->query("SELECT MAX(`version`) AS v FROM `MN_dbver`");
176 if (!$r) return 0;
177 $row = $r->fetch_assoc();
178 if (is_object($r)) $r->free();
179 return isset($row['v']) ? (int)$row['v'] : 0;
180}
181
182/** 记录某版本已应用(REPLACE 防主键冲突) */
183function mnbt_migrations_record($db, $version, $file)
184{
185 $version = (int)$version;
186 $now = date('Y-m-d H:i:s');
187 $stmt = $db->prepare("REPLACE INTO `MN_dbver`(`version`,`file`,`applied_at`) VALUES(?,?,?)");
188 if ($stmt) {
189 $stmt->bind_param('iss', $version, $file, $now);
190 $stmt->execute();
191 $stmt->close();
192 return;
193 }
194 // 兜底:预处理不可用时走转义拼接
195 $safe_file = $db->real_escape_string($file);
196 $db->query("REPLACE INTO `MN_dbver`(`version`,`file`,`applied_at`) VALUES($version,'$safe_file','$now')");
197}
198
199/** 顺序执行一组语句;容忍"已存在"类错误,其余即视为致命并停止 */
200function mnbt_migrations_exec($db, $stmts, &$err)
201{
202 $tol = mnbt_migrations_tolerant_errno();
203 foreach ($stmts as $st) {
204 if (trim($st) === '') continue;
205 $res = $db->query($st);
206 if ($res === false) {
207 if (in_array($db->errno, $tol, true)) { continue; }
208 $preview = function_exists('mb_substr') ? mb_substr($st, 0, 140) : substr($st, 0, 420);
209 $err = 'SQL 错误 ' . $db->errno . ':' . $db->error . '(语句:' . $preview . '…)';
210 return false;
211 }
212 if (is_object($res)) $res->free();
213 }
214 return true;
215}
216
217/** 扫描迁移目录,返回按版本升序的迁移项 [{version,name,file}] */
218function mnbt_migrations_find($dir)
219{
220 $out = array();
221 $paths = glob(rtrim($dir, '/') . '/update_*.sql');
222 if (!is_array($paths)) return $out;
223 foreach ($paths as $p) {
224 $v = mnbt_migration_version_from_name($p);
225 if ($v <= 0) continue; // 不符合命名规范(如遗留单文件 update.sql)忽略
226 $out[] = array('version' => $v, 'name' => basename($p), 'file' => $p);
227 }
228 usort($out, function ($a, $b) {
229 if ($a['version'] === $b['version']) return strcmp($a['name'], $b['name']);
230 return $a['version'] - $b['version'];
231 });
232 return $out;
233}
234
235/**
236 * 执行迁移链:只跑「游标 < 版本 <= 目标」的迁移,按升序,成功一个记一个。
237 * 中途失败即停止并保留后续文件(游标停在最后成功版本,修复后重跑可续)。
238 * @return array 已成功应用的迁移文件名列表
239 */
240function mnbt_migrations_run($dbconfig, $dir, $target, &$err)
241{
242 $applied = array();
243 $err = '';
244 $files = mnbt_migrations_find($dir);
245 if (!$files) return $applied; // 无迁移文件视为无需变更
246 $db = mnbt_migrations_db($dbconfig, $conn_err);
247 if ($db === null) { $err = $conn_err; return $applied; }
248 if (!mnbt_migrations_ensure($db, $err)) { $db->close(); return $applied; }
249 $cursor = mnbt_migrations_cursor($db);
250 foreach ($files as $f) {
251 if ($f['version'] > $target) break; // 已按版本升序,后续都超目标
252 if ($f['version'] <= $cursor) continue; // 游标之前的高水位已应用
253 $sql = @file_get_contents($f['file']);
254 if ($sql === false) { $err = '无法读取迁移文件 ' . $f['name']; break; }
255 $stmts = mnbt_sql_statements($sql);
256 if (!$stmts && trim($sql) !== '') { $err = '迁移文件未解析出任何语句:' . $f['name']; break; }
257 if (!mnbt_migrations_exec($db, $stmts, $err)) { $err = '迁移 ' . $f['name'] . '(版本 ' . $f['version'] . ')失败:' . $err; break; }
258 mnbt_migrations_record($db, $f['version'], $f['name']);
259 $applied[] = $f['name'];
260 }
261 $db->close();
262 if ($err !== '') return array(); // 有错:整链视为失败,交由上层保留文件、提示手工处理
263 return $applied;
264}
265
266/**
267 * 基线播种:全新安装 / 安装向导升级后调用,把游标抬到目标版本,
268 * 之后在线更新只跑更高版本的增量,不会重放已到位的历史迁移。
269 * 已到位的表/列由安装流程本身保证,这里只是记账。
270 */
271function mnbt_migrations_seed($dbconfig, $version, &$err)
272{
273 $version = (int)$version;
274 $err = '';
275 if ($version <= 0) { $err = '基线版本号无效'; return false; }
276 $db = mnbt_migrations_db($dbconfig, $conn_err);
277 if ($db === null) { $err = $conn_err; return false; }
278 if (!mnbt_migrations_ensure($db, $err)) { $db->close(); return false; }
279 if (mnbt_migrations_cursor($db) < $version) {
280 mnbt_migrations_record($db, $version, '__baseline__');
281 }
282 $db->close();
283 return true;
284}
285?>