diff --git a/script/migrate-db-files-to-oss/.gitignore b/script/migrate-db-files-to-oss/.gitignore
new file mode 100644
index 0000000..ef2f8f7
--- /dev/null
+++ b/script/migrate-db-files-to-oss/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+config.local.json
+*.local.json
+reports/
diff --git a/script/migrate-db-files-to-oss/README.md b/script/migrate-db-files-to-oss/README.md
new file mode 100644
index 0000000..59fda16
--- /dev/null
+++ b/script/migrate-db-files-to-oss/README.md
@@ -0,0 +1,155 @@
+# 数据库图片迁移到阿里云 OSS
+
+纯 JavaScript / Node.js 工具。默认自动读取项目当前环境的主数据库连接,从数据库识别旧数据库存储和默认阿里云 OSS,直接读取原图上传并校验,然后更新文件表和业务字段中的旧地址。
+
+**通常不需要填写配置,不需要设置数据库密码环境变量,也不需要启动本地后端。** 需要 Node.js 20.19+、MySQL 5.7/8.x 和项目已设置好的默认阿里云 OSS。
+
+## 直接使用
+
+在本目录执行:
+
+```sh
+npm ci --ignore-scripts
+node migrate.mjs init
+```
+
+这会读取代码里的数据库连接,查询存储配置和已有迁移批次,自动生成 `config.local.json`。不上传图片、不更新数据库、不创建迁移表。数据库密码仍从项目 YAML 读取,OSS 密钥仍从存储配置表读取,不重复写入生成文件。
+
+接着只读预检,或开始迁移:
+
+```sh
+node migrate.mjs
+node migrate.mjs apply --limit 50
+node migrate.mjs apply
+```
+
+第一条没有参数,默认只读预检;第二条实际迁移少量图片;第三条继续完成剩余迁移。可跳过 init,预检时也会自动生成配置。中断或失败后重跑相同 apply 命令即可。
+
+预检只读文件元数据,不读取图片二进制内容或计算 SHA-256。图片分组、文件记录和路径拼写由三次批量查询完成,不再逐张反复访问远程数据库。输出包含当前阶段、扫描表名和行数;等待较长查询时每 5 秒显示耗时。`estimatedBytes` 是文件表记录的容量估算,`sizePending` 是缺少容量信息的文件数,实际长度和哈希在 apply 时校验。
+
+上传时的源文件复核也分两步:先只查最新记录编号,再按主键读取该图片的长度和哈希,避免在没有路径索引的排序查询中读取图片大字段。每张图片完成后会显示源读取/复核、OSS 上传/校验、最终地址校验及总耗时,便于区分数据库等待与网络传输。
+
+“更新已校验文件记录”是数据库事务阶段,不是再次上传。脚本每批最多处理 50 张:按主键范围加锁,批量复核源文件最新版本和哈希,再批量更新文件记录、写入逐条回滚日志及迁移状态,一起提交。保留原值比较和事务保护,不需要新增配置或给业务表加索引。该阶段重新计时,并显示每批源校验、记录及日志、提交的耗时。已知源变化或原值冲突会回滚整批,再逐项定位;连接或提交异常则停止,避免重复提交。
+
+按 Ctrl+C 中断后,保留 `config.local.json` 和同一个 runId,重新执行 `node migrate.mjs apply` 即可续跑。已提交记录保留,未提交事务由数据库回滚。处于 `verified` 的已上传文件采用最多 4 张并发,重新读取 OSS 内容和最终访问地址核对哈希,不重新下载数据库图片,也不重新上传;切换时仍在事务里复核源内容。已切换文件也会批量核对文件记录,并并发检查访问地址。校验失败的图片不会用于更新业务引用。更新脚本后,需要停止旧进程再执行,新代码才会生效。
+
+文件总大小不等于网络总传输量:脚本在运行电脑上从数据库读取、上传 OSS,并读取 OSS 及最终访问地址进行校验。macOS 系统代理开关也不代表 Node 默认会使用代理;需要结合进程代理变量、实际连接及系统路由判断。此脚本不修改系统代理设置。
+
+如需回滚:
+
+```sh
+node migrate.mjs rollback
+```
+
+也可以使用 `npm run init`、`npm run plan`、`npm run apply`、`npm run rollback`。生成的配置和报告已忽略,不进入 Git。
+
+## 自动识别的内容
+
+- 环境:显式 `--profile` 优先,其次 `SPRING_PROFILES_ACTIVE`,否则按项目 `application.yaml`。当前源码默认是 dev;生成文件会固定已选环境,避免下一次切换环境后误续跑。
+- 数据库:合并基础 YAML 和对应环境 YAML,读取 dynamic.primary 指定的主数据源,包括地址、端口、数据库、用户名、密码。按需要解析环境变量占位符。
+- 源配置:从仍有未删除 BLOB 的数据库存储配置中识别,不依赖迁移过程中会变化的文件表 config_id。
+- 目标:唯一的默认存储配置,且必须是阿里云 OSS。不能确定时停止,不自动猜选。
+- 历史域名:从旧文件地址提取;OSS Bucket、访问域名和密钥直接使用已有配置。
+- 批次:已有批次优先续跑;首次生成稳定批次名称。保留服务器身份、存储编号和扫描范围,避免换库或换目标后误续跑。
+- 下载接口:根据项目端口和 API 前缀推导。当前源码端口是 48083;默认直接读数据库,不依赖这个接口。
+
+项目 dev/local 的数据库可能是远程地址,不能把环境名 local 当成本机数据库。每次执行会显示实际连接地址和库名。工具读取仓库配置及支持的环境变量,无法知道部署服务器额外的 Docker/命令行覆盖。
+
+多个未完成批次、默认 OSS 改变、数据库服务器改变等情况会明确提示。若固定批次建立后又出现新 BLOB,可显式新建批次:
+
+```sh
+node migrate.mjs apply --run-id db-images-next
+```
+
+新批次名称会保存到自动配置。完成回滚的批次不会自动重新迁移。
+
+## 仅在需要时覆盖
+
+正常不需要复制示例或逐项编辑。`config.example.json` 为 `{}`,表示全部自动识别;`config.manual.example.json` 保留完全手工配置方式,兼容旧用法。
+
+例如切换项目环境:
+
+```sh
+node migrate.mjs init --profile local
+```
+
+同一生成配置绑定原数据库;如果切换后指向不同数据库,会停止,不能把原批次用于另一台服务器。
+
+其他可选设置仍可写入配置文件:`referenceSchemas` 添加同一 MySQL 实例的 IM 库,`imagesOnly: false` 包含非图片,`sourceMode: "http"` 使用原下载接口。手动连接另一数据库时可用 `--config config.manual.local.json`,其中 mysql.passwordEnv 指定的环境变量需要自行提供。
+
+迁移前备份数据库,保持原图片和目标配置稳定。回滚只恢复本批次写入且没有被后来修改的值,不删除原 BLOB 或 OSS 对象。最终清理仍是独立步骤。
+
+## 实际处理流程
+
+1. 按数据库自身的路径比较规则,选取每个 `config_id + path` 最大的未删除内容 ID,与 `DBFileClient.getContent` 一致。大整数 ID 全程使用字符串。
+2. 默认处理 MIME 为 `image/*` 或常见图片扩展名的内容;没有文件表记录但可以识别为图片的 BLOB 也会处理。其他文件计入跳过数量;如确需迁移全部数据库文件,设置 `imagesOnly: false`。无法识别的无扩展名内容需要单独核查。
+3. 建立固定迁移清单,保存原文件记录和旧地址。默认直接读取数据库原始字节;HTTP 模式通过旧下载接口读取。两种方式均与数据库计算的长度、SHA-256 比较。
+4. 使用独立目录 `objectPrefix/旧配置编号/路径哈希/内容哈希.扩展名` 上传;保留原始文件字节,不压缩图片。上传前后均检查对象,不覆盖不同内容。
+5. 从 OSS 重新读取校验,并匿名访问最终将写入数据库的 URL 再次校验。SDK 上传成功但浏览器地址不可访问时,不切换数据库记录。
+6. 本次选中文件全部通过上传校验后,每批最多 50 张用一个事务更新 `infra_file.config_id/path/url/type/size`,保留文件记录 ID 和自动更新时间。逐条回滚日志与文件记录、迁移状态同时提交。
+7. 扫描指定数据库的字符、文本、JSON 字段,按精确地址映射替换单 URL、逗号列表、JSON 值及可识别的富文本地址。每行更新都检查原值,避免覆盖用户刚修改的数据。原生 JSON 的日志保存数据库实际持久化结果,便于正确回滚。
+
+上传图片是逐个进行的;正式迁移按主键分批计算内容哈希,不会一次加载整个 BLOB 表。文件元数据和 URL 映射保存在内存;引用读取每页至少 1000 行,更新仍逐行执行。MySQL 当前会话的单条 SELECT 默认最多执行 60 秒,沿用 `timeoutMs` 配置,不修改服务器全局设置。
+
+## OSS 访问方式
+
+- 默认 `targetUrlMode: "direct"`,业务字段保存 OSS/CDN 稳定地址。地址优先使用 `targetPublicDomain`,其次使用存储配置的 `domain`,最后根据 Bucket 和 OSS 节点生成公网域名。Bucket/CDN 应允许这些图片按该地址读取;脚本不会修改 Bucket 权限。
+- 私有 Bucket 可使用 `targetUrlMode: "backend"`,业务字段保存现有后端下载入口加目标配置编号的地址,后端通过自己的 OSS 凭据读取。`downloadBaseUrl` 此时必须是用户设备能访问的稳定地址,不能填仅运行脚本机器可访问的 localhost。现有接口是 `@PermitAll`,此模式沿用项目已有访问规则,不新增鉴权。
+- 默认 `sourceMode: "database"`,无需启动旧后端。也可设置 `sourceMode: "http"` 使用原下载接口。使用 backend 目标模式时,后端仍须在线。
+
+## 旧地址与迁移范围
+
+修改文件表不会自动改变旧接口的行为:它仍按 URL 中的配置编号直接读取存储。因此脚本始终保留 `infra_file_content` 和原数据库配置,让旧客户端缓存及未替换引用仍有机会继续读取。
+
+过渡期通过文件管理删除 OSS 文件,不会自动删除旧数据库副本。历史副本的删除和最终清理需要关联处理;本脚本不承担清理工作。续跑会重新检查已迁移对象和文件记录,目标已被删除或改动时停止复用对应映射。
+
+旧接口会解码路径一次,并将裸 `+` 当空格。脚本下载时逐段编码;旧 URL 只有在其实际解码结果对应当前文件时才加入映射。裸 `+`、已有 `%HH`、`?/#` 等存在歧义的旧地址不会被静默指向另一张图片,未解决的引用会进入复核统计。
+
+未知查询参数、片段、相对地址和无法识别的文本组合不做猜测替换。JSON 属性名不改。历史域名别名通过 `oldDomains` 补充。默认扫描主数据库,额外数据库只扫描同一个 MySQL 实例且当前账号有权限的 `referenceSchemas`;不同实例的 IM 库需要单独处理。
+
+IM 自己上传到 MinIO 的文件不属于本次 BLOB 搬迁,但 IM 中保存的主库旧图片 URL 可以随引用扫描替换。Redis、客户端本地缓存、其他数据库实例和外部系统不在扫描范围,旧 BLOB 不能在脚本结束后立即清理。
+
+业务引用表需要单列主键;无主键、复合主键表会列入待复核。更新要求 InnoDB。视图、生成列、文件基础表和迁移日志表不参与业务引用替换。`excludeReferenceTables` 可以填写明确不需要扫描的表名或 `库名.表名`;这些主动排除项会在报告中列出。
+
+接口访问日志、接口错误日志、定时任务日志、系统操作日志和登录日志也默认排除:它们不是页面图片引用,迁移不应改写历史记录。具体为 `infra_api_access_log`、`infra_api_error_log`、`infra_job_log`、`system_operate_log`、`system_login_log`,不会泛化排除其他名字包含 log 的业务表。
+
+## 结果、恢复与权限
+
+每次命令在配置文件旁的 `reports/` 写 JSON 报告:
+
+| 状态 | 含义 |
+| --- | --- |
+| `planned` | 数据库预检通过,尚未校验 HTTP/OSS 连通性 |
+| `complete` | 本批文件已切换,已扫描范围未检测到剩余旧路由引用 |
+| `partial` | 限量试迁移、上传失败、文件记录冲突或业务更新冲突,需要续跑/处理 |
+| `needs-review` | 预检/迁移扫描仍发现旧地址或不支持的表;可能包含有意跳过的非图片、未知 URL 变体,不能据此清理源数据 |
+| `rolled_back` / `rollback_partial` | 回滚完成 / 仍有并发修改冲突 |
+
+退出码:0 为本次命令成功;2 为部分完成或需要复核;1 为执行错误。
+失败时只输出简短错误,不输出配置密钥;详细原值、映射及逐项状态保存在主库的三个表:
+
+```text
+infra_file_oss_migration_runs
+infra_file_oss_migration_items
+infra_file_oss_migration_changes
+```
+
+`plan` 不创建这些表、不上传、不更新业务数据;`apply` 首次执行会创建日志表。更新与日志在同一事务中提交,回滚不依赖本地报告。保留这些表及数据库备份直到验收结束。
+
+同一主库的 apply/rollback 使用 MySQL 命名锁避免重复运行。同一 runId 绑定原数据库服务器、存储目标和扫描范围,不能换库或换 Bucket 后继续执行。需要 SELECT/UPDATE、主库创建和写入日志表的权限,以及 OSS 对指定对象的读取/写入权限;不需要 OSS 删除权限。
+
+脚本会开启当前数据库连接的严格模式,并在 SQL 出现截断等警告时回滚,防止较短的业务字段保存损坏 URL。apply 将当前连接的隔离级别设为 REPEATABLE READ,要求源内容表、文件表和日志表为 InnoDB;批次源校验的主键范围锁覆盖可能新增或恢复的较新版本,防止校验期间源内容变化。不修改数据库全局隔离级别。配置文件、依赖目录和报告已通过此目录的 `.gitignore` 排除;回滚日志包含原业务值,应按业务数据库的权限保护。
+
+## 验证
+
+```sh
+npm test
+```
+
+默认运行本地 HTTP、模拟 OSS 和事务接口的测试。真实 MySQL 集成测试默认跳过;仅可指向专用临时 MySQL 容器的端口:
+
+```sh
+MIGRATION_TEST_DB_PORT=临时容器端口 node --test test/integration.test.mjs
+```
+
+集成测试会创建并删除固定的 `codex_oss_migration_test` 测试库,不可指向业务数据库服务器。其 OSS 是内存模拟,HTTP 下载接口由本地测试服务模拟;真实阿里云连接和实际业务页面仍需通过少量试迁移验收。
diff --git a/script/migrate-db-files-to-oss/config.example.json b/script/migrate-db-files-to-oss/config.example.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/script/migrate-db-files-to-oss/config.example.json
@@ -0,0 +1 @@
+{}
diff --git a/script/migrate-db-files-to-oss/config.manual.example.json b/script/migrate-db-files-to-oss/config.manual.example.json
new file mode 100644
index 0000000..598a237
--- /dev/null
+++ b/script/migrate-db-files-to-oss/config.manual.example.json
@@ -0,0 +1,25 @@
+{
+ "runId": "db-images-20261105",
+ "mysql": {
+ "host": "127.0.0.1",
+ "port": 3306,
+ "user": "root",
+ "passwordEnv": "MIGRATION_DB_PASSWORD",
+ "database": "YOUR_DATABASE"
+ },
+ "sourceConfigIds": [],
+ "targetConfigId": null,
+ "sourceMode": "http",
+ "downloadBaseUrl": "http://127.0.0.1:48083/admin-api/infra/file",
+ "oldDomains": [],
+ "targetUrlMode": "direct",
+ "targetPublicDomain": "",
+ "objectPrefix": "migration/db-images",
+ "referenceSchemas": [],
+ "excludeReferenceTables": [],
+ "imagesOnly": true,
+ "batchSize": 100,
+ "timeoutMs": 60000,
+ "maxFileBytes": 67108864,
+ "retries": 3
+}
diff --git a/script/migrate-db-files-to-oss/lib/auto-storage.mjs b/script/migrate-db-files-to-oss/lib/auto-storage.mjs
new file mode 100644
index 0000000..9ecf903
--- /dev/null
+++ b/script/migrate-db-files-to-oss/lib/auto-storage.mjs
@@ -0,0 +1,173 @@
+import { baseUrl, bit, id, normalizeConfig, ossSettings, qualified, sha256 } from './core.mjs';
+
+const RUNS_TABLE = 'infra_file_oss_migration_runs';
+const RESTORED_FIELDS = [
+ 'sourceConfigIds', 'targetConfigId', 'sourceMode', 'downloadBaseUrl', 'oldDomains',
+ 'objectPrefix', 'imagesOnly', 'referenceSchemas', 'excludeReferenceTables', 'targetUrlMode'
+];
+const hasValue = (value, key) => Object.prototype.hasOwnProperty.call(value, key) && value[key] !== undefined;
+const sortedIds = values => [...new Set(values.map(id))].sort((left, right) => BigInt(left) < BigInt(right) ? -1 : BigInt(left) > BigInt(right) ? 1 : 0);
+
+function assertIdentity(expected, current) {
+ if (!expected || expected.server !== current.server || expected.schema !== current.schema) {
+ throw new Error('数据库身份与原迁移配置不一致,请检查数据库连接和批次编号');
+ }
+}
+
+function parseRunSettings(value) {
+ let settings;
+ try {
+ settings = typeof value === 'string' ? JSON.parse(value) : value;
+ } catch {
+ throw new Error('迁移批次的配置记录损坏,不能自动恢复');
+ }
+ if (!settings || typeof settings !== 'object' || !settings.binding || typeof settings.binding !== 'object') {
+ throw new Error('迁移批次缺少原始配置绑定,不能自动恢复');
+ }
+ return settings;
+}
+
+function selectRun(rows, requestedRunId) {
+ if (requestedRunId !== undefined) return rows.find(row => row.run_id === requestedRunId);
+ const unfinished = rows.filter(row => !['complete', 'rolled_back'].includes(row.status));
+ if (unfinished.length > 1) throw new Error('存在多个未完成迁移批次,请使用 --run-id 指定要继续的批次');
+ if (unfinished.length === 1) return unfinished[0];
+ if (rows.length > 1) throw new Error('存在多个历史迁移批次,请使用 --run-id 指定批次,或指定新的批次编号');
+ return rows[0];
+}
+
+function contentId(value) {
+ if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^\d+$/.test(String(value))) {
+ throw new Error('迁移批次的内容上界无效,不能安全判断增量数据');
+ }
+ return BigInt(value);
+}
+
+/**
+ * Read-only discovery of storage configuration and a stable migration run.
+ * This function never emits logs, persists configuration, or returns the OSS
+ * credentials read from infra_file_config. Explicit input fields take priority
+ * over a restored binding; loadSettings/runApply validate the final fingerprint.
+ */
+export async function discoverStorageConfig(db, input, { command = 'plan' } = {}) {
+ if (!input?.mysql?.database) throw new Error('缺少本地数据库名称,无法自动识别文件存储');
+ const schema = input.mysql.database;
+ const [[serverRow]] = await db.execute('SELECT @@server_uuid AS server_uuid');
+ if (!serverRow?.server_uuid) throw new Error('无法读取数据库服务器身份');
+ const identity = { server: serverRow.server_uuid, schema };
+ if (hasValue(input, '_databaseIdentity')) assertIdentity(input._databaseIdentity, identity);
+
+ const [runTable] = await db.execute(
+ "SELECT 1 AS present FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND TABLE_TYPE='BASE TABLE' LIMIT 1",
+ [schema, RUNS_TABLE]
+ );
+ let history = [];
+ if (runTable.length) {
+ [history] = await db.execute(`SELECT run_id,settings,status FROM ${qualified(schema, RUNS_TABLE)}`);
+ }
+ const selected = selectRun(history, input.runId);
+ const settings = selected ? parseRunSettings(selected.settings) : undefined;
+ const config = { ...input, mysql: { ...input.mysql } };
+ if (selected) {
+ assertIdentity(settings.binding, identity);
+ for (const key of RESTORED_FIELDS) {
+ if (!hasValue(input, key) && hasValue(settings.binding, key)) {
+ config[key] = structuredClone(settings.binding[key]);
+ }
+ }
+ if (!hasValue(input, 'targetPublicDomain') && hasValue(settings.binding, 'publicDomain')) {
+ config.targetPublicDomain = settings.binding.publicDomain;
+ }
+ config.runId = selected.run_id;
+ if (command === 'apply' && (selected.status.startsWith('rollback') || selected.status === 'rolled_back')) {
+ throw new Error('该批次已开始或完成回滚,不能继续迁移;请继续 rollback,或使用 --run-id 指定新批次');
+ }
+ } else if (command === 'rollback') {
+ throw new Error('未找到可回滚的历史迁移批次,请使用 --run-id 指定已有批次');
+ }
+
+ config._databaseIdentity = identity;
+ config._autoDiscovery = true;
+ delete config._projectDownloadBaseUrl;
+
+ // Rollback only restores journalled database values. Current storage rows,
+ // default targets and OSS credentials are deliberately irrelevant here.
+ if (command === 'rollback') return normalizeConfig(config);
+
+ if (!hasValue(config, 'sourceMode')) config.sourceMode = 'database';
+ if (!hasValue(config, 'downloadBaseUrl') && input._projectDownloadBaseUrl) {
+ config.downloadBaseUrl = input._projectDownloadBaseUrl;
+ }
+ const [storageRows] = await db.execute(`SELECT id,storage,master,config FROM ${qualified(schema, 'infra_file_config')} WHERE deleted=0`);
+ const byId = new Map(storageRows.map(row => [id(row.id), row]));
+ const masters = storageRows.filter(row => bit(row.master));
+ const requireDefault = !hasValue(config, 'targetConfigId') || input._autoDiscovery === true || Boolean(selected);
+ if (requireDefault && masters.length !== 1) {
+ throw new Error('默认文件存储配置必须唯一,请先在文件配置中设置唯一的阿里云 OSS 默认存储');
+ }
+ if (!hasValue(config, 'targetConfigId')) config.targetConfigId = id(masters[0].id);
+ config.targetConfigId = id(config.targetConfigId);
+ if (requireDefault && (id(masters[0].id) !== config.targetConfigId
+ || (selected && id(masters[0].id) !== id(settings.binding.targetConfigId)))) {
+ throw new Error('当前默认 OSS 与原迁移目标不同,不能自动续跑;请恢复原默认配置或使用 --run-id 建立新批次');
+ }
+ const target = byId.get(config.targetConfigId);
+ if (!target) throw new Error('目标文件存储配置不存在或已删除');
+ try {
+ ossSettings(target, config);
+ } catch {
+ // JSON/URL parsing errors must not include raw configuration or secrets.
+ throw new Error('目标默认存储不是有效的阿里云 OSS 配置,请检查类型、endpoint、Bucket 和访问凭据');
+ }
+
+ if (!hasValue(config, 'sourceConfigIds')) {
+ const databaseIds = sortedIds(storageRows.filter(row => Number(row.storage) === 1).map(row => row.id));
+ let contents = [];
+ if (databaseIds.length) {
+ [contents] = await db.execute(
+ `SELECT DISTINCT config_id FROM ${qualified(schema, 'infra_file_content')} WHERE deleted=0 AND config_id IN (${databaseIds.map(() => '?').join(',')})`,
+ databaseIds
+ );
+ }
+ config.sourceConfigIds = sortedIds(contents.map(row => row.config_id));
+ }
+ if (!Array.isArray(config.sourceConfigIds) || !config.sourceConfigIds.length) {
+ throw new Error('未发现包含未删除内容的数据库存储配置,无需迁移');
+ }
+ config.sourceConfigIds = [...new Set(config.sourceConfigIds.map(id))];
+ for (const sourceId of config.sourceConfigIds) {
+ const source = byId.get(sourceId);
+ if (!source || Number(source.storage) !== 1) throw new Error('原数据库存储配置不存在、已删除或类型发生变化,请检查原迁移绑定');
+ if (bit(source.master)) throw new Error('源配置仍是默认上传配置,请先切换默认配置到阿里云 OSS');
+ }
+
+ if (selected && selected.status !== 'inventory' && command === 'apply') {
+ const sourceIds = settings.binding.sourceConfigIds.map(id);
+ if (!sourceIds.length) throw new Error('历史迁移批次的源配置列表为空,不能安全续跑');
+ const [[maximum]] = await db.execute(
+ `SELECT COALESCE(MAX(id),0) AS max_id FROM ${qualified(schema, 'infra_file_content')} WHERE deleted=0 AND config_id IN (${sourceIds.map(() => '?').join(',')})`,
+ sourceIds
+ );
+ if (contentId(maximum.max_id) > contentId(settings.maxId)) {
+ throw new Error('原批次建立后出现新的数据库文件,请使用 --run-id 指定新批次,避免遗漏新增内容');
+ }
+ }
+
+ if (!hasValue(config, 'oldDomains')) {
+ const [domains] = await db.execute(
+ `SELECT DISTINCT SUBSTRING_INDEX(url,'/admin-api/infra/file/',1) AS domain FROM ${qualified(schema, 'infra_file')} WHERE deleted=0 AND config_id IN (${config.sourceConfigIds.map(() => '?').join(',')}) AND url LIKE '%/admin-api/infra/file/%'`,
+ config.sourceConfigIds
+ );
+ const validDomains = new Set();
+ for (const row of domains) {
+ try { validDomains.add(baseUrl(row.domain)); } catch { /* Ignore malformed and credential-bearing historical URLs. */ }
+ }
+ config.oldDomains = [...validDomains].sort();
+ }
+
+ if (!hasValue(config, 'runId')) {
+ const fingerprint = { ...identity, sourceConfigIds: sortedIds(config.sourceConfigIds), targetConfigId: config.targetConfigId };
+ config.runId = `db-images-${sha256(JSON.stringify(fingerprint)).slice(0, 16)}`;
+ }
+ return normalizeConfig(config);
+}
diff --git a/script/migrate-db-files-to-oss/lib/concurrency.mjs b/script/migrate-db-files-to-oss/lib/concurrency.mjs
new file mode 100644
index 0000000..9e07fa7
--- /dev/null
+++ b/script/migrate-db-files-to-oss/lib/concurrency.mjs
@@ -0,0 +1,30 @@
+/**
+ * Map in input order with bounded parallelism. After the first failure, stop
+ * scheduling work and drain every in-flight worker before rejecting.
+ */
+export async function mapBounded(items, limit, worker) {
+ if (!Number.isSafeInteger(limit) || limit < 1) throw new Error('并发数量必须为正整数');
+ const source = Array.from(items);
+ const results = new Array(source.length);
+ let next = 0;
+ let failed = false;
+ let firstError;
+
+ async function run() {
+ while (!failed && next < source.length) {
+ const index = next++;
+ try {
+ results[index] = await worker(source[index], index);
+ } catch (error) {
+ if (!failed) {
+ failed = true;
+ firstError = error;
+ }
+ }
+ }
+ }
+
+ await Promise.all(Array.from({ length: Math.min(limit, source.length) }, () => run()));
+ if (failed) throw firstError;
+ return results;
+}
diff --git a/script/migrate-db-files-to-oss/lib/core.mjs b/script/migrate-db-files-to-oss/lib/core.mjs
new file mode 100644
index 0000000..96d5f83
--- /dev/null
+++ b/script/migrate-db-files-to-oss/lib/core.mjs
@@ -0,0 +1,174 @@
+import { createHash } from 'node:crypto';
+
+export const sha256 = value => createHash('sha256').update(value).digest('hex');
+export const quote = name => '`' + String(name).replaceAll('`', '``') + '`';
+export const qualified = (schema, table) => `${quote(schema)}.${quote(table)}`;
+export const bit = value => Buffer.isBuffer(value) ? value.some(byte => byte !== 0) : value === true || value === 1 || value === '1';
+export const errorText = error => String(error.code || error.message || error.name || '未知错误').replace(/https?:\/\/\S+/g, '[URL]').slice(0, 300);
+
+export function id(value) {
+ if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('配置编号超出安全整数范围,请使用字符串');
+ if (!/^[1-9]\d*$/.test(String(value))) throw new Error('配置编号必须为正整数,不能使用存储类型代替配置编号');
+ return String(value);
+}
+
+export function baseUrl(value) {
+ const parsed = new URL(value);
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) {
+ throw new Error('访问基础地址必须是无账号、查询参数和片段的 HTTP(S) URL');
+ }
+ return parsed.href.replace(/\/+$/, '');
+}
+
+export function encodePath(path) {
+ if (!path || path.startsWith('/') || path.includes('\\') || path.split('/').some(part => ['.', '..', ''].includes(part))) {
+ throw new Error('文件路径为空或含不支持的路径段,需单独处理');
+ }
+ return path.split('/').map(encodeURIComponent).join('/');
+}
+
+export const downloadUrl = (base, configId, path) => `${baseUrl(base)}/${id(configId)}/get/${encodePath(path)}`;
+
+export function normalizeConfig(input) {
+ const config = {
+ sourceMode: 'http', targetUrlMode: 'direct', objectPrefix: 'migration/db-images',
+ imagesOnly: true, batchSize: 100, timeoutMs: 60000, maxFileBytes: 64 * 1024 * 1024,
+ retries: 3, oldDomains: [], excludeReferenceTables: [], ...input
+ };
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(config.runId || '')) throw new Error('runId 必须是 1~64 位字母、数字、横线或下划线');
+ if (!config.mysql?.database || !config.mysql?.user) throw new Error('请填写 mysql.database 和 mysql.user');
+ if (!Array.isArray(config.sourceConfigIds) || !config.sourceConfigIds.length) throw new Error('请填写 sourceConfigIds(原数据库存储配置编号)');
+ config.sourceConfigIds = [...new Set(config.sourceConfigIds.map(id))];
+ config.targetConfigId = id(config.targetConfigId);
+ if (config.sourceConfigIds.includes(config.targetConfigId)) throw new Error('源配置和 OSS 目标配置不能相同');
+ if (!['http', 'database'].includes(config.sourceMode)) throw new Error('sourceMode 只能为 http 或 database');
+ if (!['direct', 'backend'].includes(config.targetUrlMode)) throw new Error('targetUrlMode 只能为 direct 或 backend');
+ if (config.sourceMode === 'http' || config.targetUrlMode === 'backend') config.downloadBaseUrl = baseUrl(config.downloadBaseUrl);
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9/_-]{0,119}$/.test(config.objectPrefix) || config.objectPrefix.endsWith('/')) throw new Error('objectPrefix 只能包含字母、数字、横线、下划线和目录分隔符');
+ for (const key of ['batchSize', 'timeoutMs', 'maxFileBytes', 'retries']) {
+ if (!Number.isSafeInteger(config[key]) || config[key] < 1) throw new Error(`${key} 必须为正整数`);
+ }
+ if (config.batchSize > 1000 || config.retries > 10) throw new Error('batchSize 上限为 1000,retries 上限为 10');
+ config.oldDomains = config.oldDomains.map(baseUrl);
+ config.referenceSchemas = [...new Set([config.mysql.database, ...(config.referenceSchemas || [])])];
+ return config;
+}
+
+export function parseStorage(row) {
+ return typeof row.config === 'string' ? JSON.parse(row.config) : row.config;
+}
+
+export function ossSettings(row, config) {
+ if (Number(row.storage) !== 20) throw new Error('目标配置不是 S3/OSS 存储(storage=20)');
+ const value = parseStorage(row);
+ const endpoint = new URL(/^https?:\/\//.test(value.endpoint) ? value.endpoint : `https://${value.endpoint}`);
+ if (!/^oss-[a-z0-9-]+\.aliyuncs\.com$/.test(endpoint.hostname) || endpoint.pathname !== '/' || endpoint.search || endpoint.hash || endpoint.username || endpoint.password) {
+ throw new Error('目标 endpoint 必须为阿里云 OSS 区域/加速节点,不能是其他 S3 服务或 Bucket 域名');
+ }
+ if (!value.bucket || !value.accessKey || !value.accessSecret) throw new Error('OSS 配置缺少 bucket/accessKey/accessSecret');
+ const publicDomain = baseUrl(config.targetPublicDomain || value.domain || `https://${value.bucket}.${endpoint.hostname.replace('-internal.', '.')}`);
+ return { endpoint: endpoint.origin, bucket: value.bucket, accessKeyId: value.accessKey, accessKeySecret: value.accessSecret, publicDomain };
+}
+
+export function objectKey(config, item) {
+ const extension = /\.([a-zA-Z0-9]{1,10})$/.exec(item.path)?.[1]?.toLowerCase();
+ return `${config.objectPrefix}/${item.configId}/${sha256(item.path)}/${item.sha256}${extension ? '.' + extension : ''}`;
+}
+
+export function aliases(item, rows, sourceDomain, config) {
+ const result = new Set(rows.map(row => row.url).filter(Boolean));
+ const domains = [...new Set([sourceDomain, ...config.oldDomains].filter(Boolean).map(baseUrl))];
+ const paths = [...new Set([item.path, ...rows.map(row => row.path)])];
+ const sourcePaths = new Set(paths);
+ const addPathAliases = (prefix, path) => {
+ const encoded = encodePath(path);
+ result.add(prefix + path);
+ result.add(prefix + encoded);
+ // FileController uses form-style decoding: a literal '+' means a space.
+ result.add(prefix + encoded.replaceAll('%20', '+'));
+ };
+ for (const domain of domains) for (const path of paths) {
+ const prefix = `${domain}/admin-api/infra/file/${item.configId}/get/`;
+ addPathAliases(prefix, path);
+ }
+ if (config.downloadBaseUrl) {
+ const prefix = baseUrl(config.downloadBaseUrl) + '/' + id(item.configId) + '/get/';
+ for (const path of paths) addPathAliases(prefix, path);
+ }
+ return [...result].filter(value => {
+ try {
+ // Parse the actual request path: raw '?' / '#' end the path, and the
+ // browser encodes spaces/Unicode before FileController sees requestURI.
+ const parsed = new URL(value, 'https://migration-alias.invalid');
+ const route = parsed.pathname.match(/\/infra\/file\/([^/]+)\/get(?:\/|$)/);
+ if (!route) return true; // Keep pre-existing URLs from other file routes.
+ if (BigInt(decodeURIComponent(route[1])) !== BigInt(item.configId)) return false;
+ const marker = parsed.pathname.indexOf('/get/');
+ if (marker < 0) return false;
+ const path = decodeURIComponent(parsed.pathname.slice(marker + 5).replaceAll('+', ' '));
+ return sourcePaths.has(path);
+ } catch {
+ // Unverifiable aliases remain untouched and are reported by the caller.
+ return false;
+ }
+ });
+}
+
+export function mimeType(path, records) {
+ const stored = records.map(row => row.type).find(type => /^image\//i.test(type || ''));
+ if (stored) return stored;
+ const ext = path.split('.').pop().toLowerCase();
+ const types = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon', tif: 'image/tiff', tiff: 'image/tiff', avif: 'image/avif', heic: 'image/heic', heif: 'image/heif' };
+ return types[ext] || records.find(row => row.type)?.type || 'application/octet-stream';
+}
+
+export async function readHttp(url, { timeoutMs, maxFileBytes }, expectedSize) {
+ const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'error' });
+ if (!response.ok) { await response.body?.cancel(); throw new Error(`文件访问返回 HTTP ${response.status}`); }
+ const declared = Number(response.headers.get('content-length'));
+ if (declared > maxFileBytes || (expectedSize !== undefined && declared > expectedSize)) {
+ await response.body?.cancel(); throw new Error('HTTP 文件长度超过限制');
+ }
+ let size = 0;
+ const chunks = [];
+ for await (const chunk of response.body) {
+ size += chunk.length;
+ if (size > maxFileBytes || (expectedSize !== undefined && size > expectedSize)) throw new Error('HTTP 文件长度超过限制');
+ chunks.push(chunk);
+ }
+ return Buffer.concat(chunks);
+}
+
+export function verifyContent(bytes, expected) {
+ if (bytes.length !== Number(expected.size) || sha256(bytes) !== expected.sha256) throw new Error('文件长度或 SHA-256 不一致');
+}
+
+export async function ensureObject(oss, key, bytes, item) {
+ let exists = true;
+ try {
+ const result = await oss.head(key);
+ if (Number(result.res.headers['content-length']) !== Number(item.size)) throw new Error('OSS 同名对象长度不同,禁止覆盖');
+ } catch (error) {
+ if (error.code === 'NoSuchKey' || error.status === 404) exists = false;
+ else throw error;
+ }
+ if (!exists) {
+ try {
+ await oss.put(key, bytes, { headers: { 'Content-Type': item.type, 'x-oss-forbid-overwrite': 'true' } });
+ } catch (error) {
+ if (error.code !== 'FileAlreadyExists' && error.status !== 409) throw error;
+ }
+ }
+ const result = await oss.head(key);
+ if (Number(result.res.headers['content-length']) !== Number(item.size)) throw new Error('OSS 文件长度不一致');
+ verifyContent((await oss.get(key)).content, item);
+}
+
+export async function retry(action, attempts, wait = ms => new Promise(resolve => setTimeout(resolve, ms))) {
+ for (let i = 0; ; i++) {
+ try { return await action(); } catch (error) {
+ if (i + 1 >= attempts) throw error;
+ await wait(Math.min(1000 * 2 ** i, 5000));
+ }
+ }
+}
diff --git a/script/migrate-db-files-to-oss/lib/journal.mjs b/script/migrate-db-files-to-oss/lib/journal.mjs
new file mode 100644
index 0000000..e73f978
--- /dev/null
+++ b/script/migrate-db-files-to-oss/lib/journal.mjs
@@ -0,0 +1,318 @@
+import { qualified, quote } from './core.mjs';
+
+const RUNS = 'infra_file_oss_migration_runs';
+const ITEMS = 'infra_file_oss_migration_items';
+const CHANGES = 'infra_file_oss_migration_changes';
+const metadataCache = new WeakMap();
+const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
+
+// Snapshots must preserve BIT/Buffer and 64-bit keys without Number conversion.
+function pack(value) {
+ if (Buffer.isBuffer(value)) return { type: 'buffer', value: value.toString('base64') };
+ if (typeof value === 'bigint') return { type: 'bigint', value: value.toString() };
+ if (value instanceof Date) return { type: 'date', value: value.toISOString() };
+ if (value === undefined) throw new Error('变更快照不能包含 undefined');
+ return { type: 'json', value };
+}
+
+function unpack(value) {
+ if (value.type === 'buffer') return Buffer.from(value.value, 'base64');
+ if (value.type === 'bigint') return BigInt(value.value);
+ if (value.type === 'date') return new Date(value.value);
+ if (value.type === 'json') return value.value;
+ throw new Error('变更日志值类型不受支持');
+}
+
+function snapshot(record) {
+ return JSON.stringify(Object.fromEntries(Object.entries(record).map(([key, value]) => [key, pack(value)])));
+}
+
+function restoreSnapshot(text) {
+ return Object.fromEntries(Object.entries(JSON.parse(text)).map(([key, value]) => [key, unpack(value)]));
+}
+
+function parameter(value) {
+ if (typeof value === 'bigint') return value.toString();
+ if (value !== null && typeof value === 'object' && !Buffer.isBuffer(value) && !(value instanceof Date)) return JSON.stringify(value);
+ return value;
+}
+
+const equal = (left, right) => JSON.stringify(pack(left)) === JSON.stringify(pack(right));
+
+function condition(values) {
+ const entries = Object.entries(values);
+ return {
+ sql: entries.map(([key]) => `CAST(${quote(key)} AS BINARY) <=> CAST(? AS BINARY)`).join(' AND '),
+ params: entries.map(([, value]) => parameter(value))
+ };
+}
+
+async function tableMetadata(connection, schema, table) {
+ let cache = metadataCache.get(connection);
+ if (!cache) { cache = new Map(); metadataCache.set(connection, cache); }
+ const key = JSON.stringify([schema, table]);
+ if (!cache.has(key)) {
+ const [rows] = await connection.execute(
+ `SELECT c.COLUMN_NAME AS name, c.EXTRA AS extra, t.ENGINE AS engine
+ FROM information_schema.COLUMNS c JOIN information_schema.TABLES t
+ ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME
+ WHERE c.TABLE_SCHEMA = ? AND c.TABLE_NAME = ?`, [schema, table]);
+ if (!rows.length || rows.some(row => String(row.engine).toLowerCase() !== 'innodb')) {
+ throw new Error(`表 ${schema}.${table} 必须为 InnoDB,才能保证更新和日志原子提交`);
+ }
+ cache.set(key, {
+ names: new Set(rows.map(row => row.name)),
+ automatic: rows.filter(row => /on update/i.test(row.extra || '')).map(row => row.name)
+ });
+ }
+ return cache.get(key);
+}
+
+function assertFields(metadata, primaryKey, before, after) {
+ if (own(after, primaryKey)) throw new Error('迁移不能修改主键');
+ for (const name of [primaryKey, ...Object.keys(before), ...Object.keys(after)]) {
+ if (!metadata.names.has(name)) throw new Error(`变更列 ${name} 不存在`);
+ }
+ for (const name of Object.keys(after)) {
+ if (!own(before, name)) throw new Error(`变更列 ${name} 缺少原值快照`);
+ }
+}
+
+function assignments(values, metadata) {
+ const entries = Object.entries(values);
+ return {
+ sql: [
+ ...entries.map(([name]) => `${quote(name)} = ?`),
+ ...metadata.automatic.filter(name => !own(values, name)).map(name => `${quote(name)} = ${quote(name)}`)
+ ].join(', '),
+ params: entries.map(([, value]) => parameter(value))
+ };
+}
+
+export async function ensureJournal(connection, schema) {
+ await connection.execute(`CREATE TABLE IF NOT EXISTS ${qualified(schema, RUNS)} (
+ run_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
+ settings LONGTEXT NOT NULL,
+ status VARCHAR(32) NOT NULL DEFAULT 'active',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`);
+ await connection.execute(`CREATE TABLE IF NOT EXISTS ${qualified(schema, ITEMS)} (
+ run_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ item_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ data LONGTEXT NOT NULL,
+ status VARCHAR(32) NOT NULL,
+ error TEXT NULL,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (run_id, item_key)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`);
+ await connection.execute(`CREATE TABLE IF NOT EXISTS ${qualified(schema, CHANGES)} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ run_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ schema_name VARCHAR(64) NOT NULL,
+ table_name VARCHAR(64) NOT NULL,
+ pk_name VARCHAR(64) NOT NULL,
+ pk_value TEXT NOT NULL,
+ before_json LONGTEXT NOT NULL,
+ after_json LONGTEXT NOT NULL,
+ rolled_back TINYINT NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ KEY ix_run_id_id (run_id, id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`);
+ for (const table of [RUNS, ITEMS, CHANGES]) await tableMetadata(connection, schema, table);
+}
+
+/** The caller owns BEGIN/COMMIT/ROLLBACK, including the change-journal INSERT. */
+export async function applyChange(connection, journalSchema, runId, change) {
+ const { schema, table, primaryKey, pkValue, before, after } = change;
+ const metadata = await tableMetadata(connection, schema, table);
+ assertFields(metadata, primaryKey, before, after);
+ const changed = Object.fromEntries(Object.entries(after).filter(([name, value]) => !equal(before[name], value)));
+ if (!Object.keys(changed).length) return { changed: false };
+ const update = assignments(changed, metadata);
+ const guard = condition(before);
+ const [result] = await connection.execute(
+ `UPDATE ${qualified(schema, table)} SET ${update.sql}
+ WHERE ${quote(primaryKey)} = ?${guard.sql ? ` AND ${guard.sql}` : ''}`,
+ [...update.params, parameter(pkValue), ...guard.params]);
+ if (result.affectedRows !== 1) {
+ const error = new Error(`数据已变化或不存在:${schema}.${table},本次更新已停止`);
+ error.code = 'MIGRATION_CAS_CONFLICT';
+ throw error;
+ }
+ if (result.warningStatus > 0) {
+ throw new Error('数据库更新产生警告,可能发生字段截断或类型转换;事务必须回滚');
+ }
+ // Native JSON columns normalize whitespace/key order, and MySQL may convert
+ // numeric request values. Rollback must compare against the bytes/type that
+ // the database actually persisted, not the JavaScript UPDATE parameters.
+ const [storedRows] = await connection.execute(
+ `SELECT ${Object.keys(changed).map(quote).join(', ')} FROM ${qualified(schema, table)}
+ WHERE ${quote(primaryKey)} = ? FOR UPDATE`, [parameter(pkValue)]);
+ if (storedRows.length !== 1 || Object.keys(changed).some(name => !own(storedRows[0], name))) {
+ throw new Error('无法读取更新后的记录,事务必须回滚');
+ }
+ const persisted = Object.fromEntries(Object.keys(changed).map(name => [name, storedRows[0][name]]));
+ const [journal] = await connection.execute(
+ `INSERT INTO ${qualified(journalSchema, CHANGES)}
+ (run_id, schema_name, table_name, pk_name, pk_value, before_json, after_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ [runId, schema, table, primaryKey, JSON.stringify(pack(pkValue)), snapshot(before), snapshot(persisted)]);
+ return { changed: true, journalId: String(journal.insertId) };
+}
+
+function primaryKeyIdentity(value) {
+ if (Buffer.isBuffer(value)) return `buffer:${value.toString('base64')}`;
+ if (!['string', 'number', 'bigint'].includes(typeof value)
+ || (typeof value === 'number' && !Number.isSafeInteger(value))) {
+ throw new Error('批量变更主键必须是字符串、安全整数、BigInt 或 Buffer');
+ }
+ // mysql2 may return integer primary keys as either strings or numbers.
+ return `scalar:${String(value)}`;
+}
+
+/**
+ * Apply one homogeneous batch with one CASE UPDATE, one persisted-value read,
+ * and one journal INSERT. The caller must roll back the entire transaction on
+ * any error: the UPDATE can have matched only part of a concurrently edited
+ * batch before affectedRows reports the conflict.
+ */
+export async function applyChanges(connection, journalSchema, runId, changes) {
+ if (!Array.isArray(changes)) throw new Error('批量变更必须为数组');
+ if (!changes.length) return { changed: 0, skipped: 0 };
+ const { schema, table, primaryKey } = changes[0];
+ const afterFields = Object.keys(changes[0].after).sort();
+ const identities = new Set();
+ for (const change of changes) {
+ if (change.schema !== schema || change.table !== table || change.primaryKey !== primaryKey) {
+ throw new Error('批量变更必须使用相同的数据库、表和主键列');
+ }
+ if (JSON.stringify(Object.keys(change.after).sort()) !== JSON.stringify(afterFields)) {
+ throw new Error('批量变更的目标字段集合必须一致');
+ }
+ const identity = primaryKeyIdentity(change.pkValue);
+ if (identities.has(identity)) throw new Error('批量变更包含重复主键');
+ identities.add(identity);
+ }
+ const metadata = await tableMetadata(connection, schema, table);
+ const active = [];
+ for (const change of changes) {
+ assertFields(metadata, primaryKey, change.before, change.after);
+ const changed = Object.fromEntries(Object.entries(change.after).filter(([name, value]) => !equal(change.before[name], value)));
+ if (Object.keys(changed).length) active.push({ ...change, changed });
+ }
+ const skipped = changes.length - active.length;
+ if (!active.length) return { changed: 0, skipped };
+
+ const fields = afterFields.filter(name => active.some(change => own(change.changed, name)));
+ const updateParams = [];
+ const set = fields.map(name => {
+ const branches = active.filter(change => own(change.changed, name)).map(change => {
+ updateParams.push(parameter(change.pkValue), parameter(change.changed[name]));
+ return 'WHEN ? THEN ?';
+ });
+ return `${quote(name)} = CASE ${quote(primaryKey)} ${branches.join(' ')} ELSE ${quote(name)} END`;
+ });
+ set.push(...metadata.automatic.filter(name => !fields.includes(name)).map(name => `${quote(name)} = ${quote(name)}`));
+ const where = active.map(change => {
+ const guard = condition(change.before);
+ updateParams.push(parameter(change.pkValue), ...guard.params);
+ return `(${quote(primaryKey)} = ?${guard.sql ? ` AND ${guard.sql}` : ''})`;
+ });
+ const [updated] = await connection.execute(
+ `UPDATE ${qualified(schema, table)} SET ${set.join(', ')} WHERE ${where.join(' OR ')}`, updateParams);
+ if (updated.affectedRows !== active.length) {
+ const error = new Error(`批量更新发现数据已变化或不存在:${schema}.${table},必须回滚整批事务`);
+ error.code = 'MIGRATION_CAS_CONFLICT';
+ throw error;
+ }
+ if (updated.warningStatus > 0) throw new Error('数据库批量更新产生警告,可能发生字段截断或类型转换;事务必须回滚');
+
+ const [storedRows] = await connection.execute(
+ `SELECT ${[primaryKey, ...fields].map(quote).join(', ')} FROM ${qualified(schema, table)}
+ WHERE ${quote(primaryKey)} IN (${active.map(() => '?').join(', ')}) FOR UPDATE`,
+ active.map(change => parameter(change.pkValue)));
+ if (storedRows.length !== active.length) throw new Error('无法读取批量更新后的全部记录,事务必须回滚');
+ const storedByKey = new Map();
+ for (const row of storedRows) {
+ if (![primaryKey, ...fields].every(name => own(row, name))) throw new Error('批量更新读回记录缺少字段,事务必须回滚');
+ const identity = primaryKeyIdentity(row[primaryKey]);
+ if (storedByKey.has(identity)) throw new Error('批量更新读回记录包含重复主键,事务必须回滚');
+ storedByKey.set(identity, row);
+ }
+ const journalParams = [];
+ for (const change of active) {
+ const row = storedByKey.get(primaryKeyIdentity(change.pkValue));
+ if (!row) throw new Error('批量更新读回主键与请求不一致,事务必须回滚');
+ const persisted = Object.fromEntries(Object.keys(change.changed).map(name => [name, row[name]]));
+ journalParams.push(runId, schema, table, primaryKey, JSON.stringify(pack(change.pkValue)), snapshot(change.before), snapshot(persisted));
+ }
+ const [journal] = await connection.execute(
+ `INSERT INTO ${qualified(journalSchema, CHANGES)}
+ (run_id, schema_name, table_name, pk_name, pk_value, before_json, after_json)
+ VALUES ${active.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')}`, journalParams);
+ if (journal.affectedRows !== active.length || journal.warningStatus > 0) {
+ throw new Error('批量回滚日志未完整保存,事务必须回滚');
+ }
+ return { changed: active.length, skipped };
+}
+
+export async function rollbackChanges(connection, journalSchema, runId) {
+ const result = { restored: 0, alreadyRestored: 0, conflicts: [] };
+ let cursor = null;
+ const journal = qualified(journalSchema, CHANGES);
+ while (true) {
+ const [batch] = await connection.execute(
+ `SELECT CAST(id AS CHAR) AS id FROM ${journal}
+ WHERE run_id = ? AND rolled_back = 0${cursor === null ? '' : ' AND id < ?'} ORDER BY id DESC LIMIT 100`,
+ cursor === null ? [runId] : [runId, cursor]);
+ if (!batch.length) break;
+ for (const entry of batch) {
+ cursor = String(entry.id);
+ await connection.beginTransaction();
+ try {
+ const [[row]] = await connection.execute(`SELECT * FROM ${journal} WHERE id = ? AND run_id = ? FOR UPDATE`, [cursor, runId]);
+ if (!row || Number(row.rolled_back) === 1) { await connection.commit(); continue; }
+ const before = restoreSnapshot(row.before_json);
+ const after = restoreSnapshot(row.after_json);
+ const pkValue = unpack(JSON.parse(row.pk_value));
+ const metadata = await tableMetadata(connection, row.schema_name, row.table_name);
+ assertFields(metadata, row.pk_name, before, after);
+ const target = qualified(row.schema_name, row.table_name);
+ const expectedAfter = condition({ ...before, ...after });
+ const [matchesAfter] = await connection.execute(
+ `SELECT 1 AS matched FROM ${target} WHERE ${quote(row.pk_name)} = ? AND ${expectedAfter.sql} FOR UPDATE`,
+ [parameter(pkValue), ...expectedAfter.params]);
+ let alreadyRestored = false;
+ if (!matchesAfter.length) {
+ const expectedBefore = condition(before);
+ const [matchesBefore] = await connection.execute(
+ `SELECT 1 AS matched FROM ${target} WHERE ${quote(row.pk_name)} = ? AND ${expectedBefore.sql} FOR UPDATE`,
+ [parameter(pkValue), ...expectedBefore.params]);
+ if (!matchesBefore.length) {
+ await connection.rollback();
+ result.conflicts.push({ id: cursor, schema: row.schema_name, table: row.table_name, pkValue: typeof pkValue === 'bigint' ? pkValue.toString() : pkValue });
+ continue;
+ }
+ alreadyRestored = true;
+ } else {
+ const values = Object.fromEntries(Object.keys(after).map(name => [name, before[name]]));
+ const update = assignments(values, metadata);
+ const [updated] = await connection.execute(
+ `UPDATE ${target} SET ${update.sql} WHERE ${quote(row.pk_name)} = ? AND ${expectedAfter.sql}`,
+ [...update.params, parameter(pkValue), ...expectedAfter.params]);
+ if (updated.affectedRows !== 1) throw new Error('已锁定的回滚记录发生变化,事务已终止');
+ if (updated.warningStatus > 0) throw new Error('数据库回滚产生警告,事务已终止');
+ }
+ await connection.execute(`UPDATE ${journal} SET rolled_back = 1 WHERE id = ? AND run_id = ?`, [cursor, runId]);
+ await connection.commit();
+ if (alreadyRestored) result.alreadyRestored++;
+ else result.restored++;
+ } catch (error) {
+ await connection.rollback();
+ throw error;
+ }
+ }
+ }
+ return result;
+}
diff --git a/script/migrate-db-files-to-oss/lib/progress.mjs b/script/migrate-db-files-to-oss/lib/progress.mjs
new file mode 100644
index 0000000..7d47cce
--- /dev/null
+++ b/script/migrate-db-files-to-oss/lib/progress.mjs
@@ -0,0 +1,43 @@
+/**
+ * Keep database waits and long scans visible without logging every record.
+ * update() also checks elapsed time when a synchronous loop delays timers.
+ */
+export function createProgress({ log = console.log, intervalMs = 5000, now = Date.now } = {}) {
+ if (!Number.isSafeInteger(intervalMs) || intervalMs < 1) throw new Error('进度输出间隔必须为正整数毫秒');
+ let message = '';
+ let startedAt;
+ let lastOutput;
+ let closed = false;
+
+ function output(force = false) {
+ if (closed || !message) return;
+ const current = now();
+ if (!force && lastOutput !== undefined && current - lastOutput < intervalMs) return;
+ lastOutput = current;
+ const seconds = Math.max(0, Math.floor((current - startedAt) / 1000));
+ log(message + '(已用时 ' + seconds + ' 秒)');
+ }
+
+ const timer = setInterval(() => output(), intervalMs);
+ timer.unref?.();
+
+ return {
+ stage(value) {
+ if (closed) return;
+ message = String(value ?? '').trim();
+ startedAt = now();
+ output(true);
+ },
+ update(value) {
+ if (closed) return;
+ message = String(value ?? '').trim();
+ if (startedAt === undefined) startedAt = now();
+ output();
+ },
+ close() {
+ if (closed) return;
+ closed = true;
+ clearInterval(timer);
+ },
+ };
+}
diff --git a/script/migrate-db-files-to-oss/lib/project-config.mjs b/script/migrate-db-files-to-oss/lib/project-config.mjs
new file mode 100644
index 0000000..5c57a6f
--- /dev/null
+++ b/script/migrate-db-files-to-oss/lib/project-config.mjs
@@ -0,0 +1,223 @@
+import { readFile } from 'node:fs/promises';
+import { resolve } from 'node:path';
+import { isIP } from 'node:net';
+import { parseAllDocuments } from 'yaml';
+
+const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
+const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
+const canonical = key => key.replace(/[-_]/g, '').toLowerCase();
+const environmentName = path => path.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[.-]/g, '_').toUpperCase();
+
+function merge(target, source) {
+ const result = Object.assign(Object.create(null), target);
+ for (const [key, value] of Object.entries(source)) {
+ result[key] = record(value) && record(result[key]) ? merge(result[key], value) : value;
+ }
+ return result;
+}
+
+function property(root, path) {
+ const parts = path.split('.');
+ let current = root;
+ for (let index = 0; index < parts.length; index++) {
+ if (!record(current)) return undefined;
+ const remainder = parts.slice(index).join('.');
+ if (own(current, remainder)) return current[remainder];
+ const part = parts[index];
+ let key = part;
+ if (!own(current, key)) {
+ const matches = Object.keys(current).filter(candidate => canonical(candidate) === canonical(part));
+ if (matches.length > 1) throw new Error('配置存在多个等价属性名,无法自动选择;请使用明确的迁移配置');
+ if (!matches.length) return undefined;
+ key = matches[0];
+ }
+ current = current[key];
+ }
+ return current;
+}
+
+function resolver(configuration, env) {
+ const raw = name => {
+ if (own(env, name) && env[name] !== undefined) return env[name];
+ const key = environmentName(name);
+ if (own(env, key) && env[key] !== undefined) return env[key];
+ return property(configuration, name);
+ };
+ const scalar = value => {
+ if (!['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
+ throw new Error('自动配置所需字段必须为单个值,请检查环境配置');
+ }
+ return String(value);
+ };
+ function expand(value, trail = []) {
+ const text = scalar(value);
+ let output = '', cursor = 0;
+ while (cursor < text.length) {
+ const start = text.indexOf('${', cursor);
+ if (start < 0) return output + text.slice(cursor);
+ output += text.slice(cursor, start);
+ let depth = 1, end = start + 2;
+ for (; end < text.length && depth; end++) {
+ if (text.startsWith('${', end)) { depth++; end++; }
+ else if (text[end] === '}') depth--;
+ }
+ if (depth) throw new Error('自动配置所需字段包含未闭合的占位符,请检查环境配置');
+ const expression = text.slice(start + 2, end - 1);
+ let separator = -1, nested = 0;
+ for (let index = 0; index < expression.length; index++) {
+ if (expression.startsWith('${', index)) { nested++; index++; }
+ else if (expression[index] === '}') nested--;
+ else if (expression[index] === ':' && nested === 0) { separator = index; break; }
+ }
+ const name = (separator < 0 ? expression : expression.slice(0, separator)).trim();
+ if (!name || name.includes('${')) throw new Error('自动配置不支持动态占位符名称,请使用明确的迁移配置');
+ if (trail.includes(name) || trail.length >= 32) throw new Error('自动配置所需字段存在循环占位符引用,请检查环境配置');
+ let replacement = raw(name);
+ if (replacement === undefined || replacement === null) {
+ if (separator < 0) throw new Error('自动配置所需占位符未提供,请设置对应环境变量或使用明确的迁移配置');
+ replacement = expression.slice(separator + 1);
+ }
+ output += expand(replacement, [...trail, name]);
+ cursor = end;
+ }
+ return output;
+ }
+ return {
+ raw,
+ field(name, fallback) {
+ const value = raw(name);
+ if (value === undefined || value === null) {
+ if (fallback !== undefined) return expand(fallback);
+ throw new Error('项目配置缺少迁移所需字段,请检查主数据源配置或使用明确的迁移配置');
+ }
+ return expand(value, [name]);
+ },
+ expand,
+ };
+}
+
+async function yamlFile(path) {
+ let text;
+ try { text = await readFile(path, 'utf8'); }
+ catch { throw new Error('无法读取基础或所选环境 YAML,请检查项目路径和 --profile'); }
+ try {
+ const documents = parseAllDocuments(text, { uniqueKeys: true, intAsBigInt: true });
+ let result = Object.create(null);
+ for (const document of documents) {
+ if (document.errors.length || document.warnings.length) throw new Error();
+ const value = document.toJS({ maxAliasCount: 100 });
+ if (value === null) continue;
+ if (!record(value)) throw new Error();
+ if (property(value, 'spring.config.activate.on-profile') !== undefined
+ || typeof property(value, 'spring.profiles') === 'string'
+ || Array.isArray(property(value, 'spring.profiles'))) {
+ throw Object.assign(new Error('自动配置不支持带 profile 条件的 YAML 文档,请使用明确的迁移配置'), { unsupported: true });
+ }
+ result = merge(result, value);
+ }
+ return result;
+ } catch (error) {
+ if (error.unsupported) throw error;
+ throw new Error('YAML 无法安全解析,请检查缩进、重复键和文档结构');
+ }
+}
+
+function singleProfile(value) {
+ if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value.trim())) {
+ throw new Error('自动配置仅支持单个 profile,请使用 --profile dev、local 或 prod 明确选择');
+ }
+ return value.trim();
+}
+
+function assertSelectionSupported(configuration, profile, env) {
+ const resolved = resolver(configuration, env);
+ const imports = resolved.raw('spring.config.import');
+ const includes = resolved.raw('spring.profiles.include');
+ if (imports !== undefined && imports !== null && imports !== '' && (!Array.isArray(imports) || imports.length)) {
+ throw new Error('自动配置不支持 spring.config.import,请使用明确的迁移配置');
+ }
+ if (includes !== undefined && includes !== null && includes !== '' && (!Array.isArray(includes) || includes.length)) {
+ throw new Error('自动配置不支持附加 profiles.include,请使用明确的迁移配置');
+ }
+ const groups = property(configuration, 'spring.profiles.group');
+ if ((record(groups) && own(groups, profile))
+ || resolved.raw('spring.profiles.group.' + profile) !== undefined) {
+ throw new Error('所选 profile 是配置组,无法唯一确定数据源;请使用具体环境或明确的迁移配置');
+ }
+}
+
+function port(value, fallbackMessage) {
+ if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535) throw new Error(fallbackMessage);
+ return Number(value);
+}
+
+function mysqlAddress(value) {
+ if (!value.startsWith('jdbc:mysql://')) throw new Error('自动配置仅支持单主机 jdbc:mysql:// URL,请使用明确的迁移配置');
+ try {
+ const url = new URL(value.slice(5));
+ const host = url.hostname.replace(/^\[|\]$/g, '');
+ if (!host || (!isIP(host) && !/^[a-zA-Z0-9.-]+$/.test(host)) || url.username || url.password || url.hash) throw new Error();
+ const parts = url.pathname.split('/');
+ if (parts.length !== 2 || !parts[1]) throw new Error();
+ const database = decodeURIComponent(parts[1]);
+ if (!database || /[/\\\u0000]/.test(database)) throw new Error();
+ return { host, port: url.port ? port(url.port, '') : 3306, database };
+ } catch {
+ throw new Error('MySQL URL 必须指定单个主机、有效端口和数据库,不能含账号或多主机地址');
+ }
+}
+
+function route(value, allowEmpty) {
+ if (value === '' && allowEmpty) return '';
+ if (!value.startsWith('/') || /[?#\\]/.test(value)
+ || value.split('/').some(part => part === '.' || part === '..')) {
+ throw new Error('HTTP context-path 或 API 前缀无效,请使用明确的迁移下载地址');
+ }
+ return value.replace(/\/+$/, '');
+}
+
+/**
+ * Read only the selected project's YAML. This deliberately supports a narrow
+ * Spring configuration subset and never logs credentials or parser source text.
+ */
+export async function loadProjectConfig({ projectRoot, profile, env = process.env }) {
+ if (!projectRoot || typeof projectRoot !== 'string') throw new Error('请指定后端项目根目录');
+ const resourceRoot = resolve(projectRoot, 'yudao-server/src/main/resources');
+ const baseFile = resolve(resourceRoot, 'application.yaml');
+ const base = await yamlFile(baseFile);
+ const baseResolver = resolver(base, env);
+ let selected;
+ if (profile !== undefined) selected = profile;
+ else if (own(env, 'SPRING_PROFILES_ACTIVE') && env.SPRING_PROFILES_ACTIVE !== undefined) selected = env.SPRING_PROFILES_ACTIVE;
+ else selected = baseResolver.raw('spring.profiles.active');
+ if (typeof selected === 'string') selected = baseResolver.expand(selected);
+ const selectedProfile = singleProfile(selected);
+ assertSelectionSupported(base, selectedProfile, env);
+ const selectedFile = resolve(resourceRoot, 'application-' + selectedProfile + '.yaml');
+ const selectedConfiguration = await yamlFile(selectedFile);
+ if (property(selectedConfiguration, 'spring.profiles.active') !== undefined) {
+ throw new Error('所选环境文件再次声明 active profile,自动选择存在歧义;请使用明确的迁移配置');
+ }
+ const configuration = merge(base, selectedConfiguration);
+ assertSelectionSupported(configuration, selectedProfile, env);
+ const selectedResolver = resolver(configuration, env);
+ const primary = selectedResolver.field('spring.datasource.dynamic.primary', 'master');
+ if (!/^[a-zA-Z0-9_-]+$/.test(primary)) throw new Error('主数据源名称无效,请检查 dynamic.primary');
+ const datasource = 'spring.datasource.dynamic.datasource.' + primary + '.';
+ const address = mysqlAddress(selectedResolver.field(datasource + 'url'));
+ const user = selectedResolver.field(datasource + 'username');
+ const password = selectedResolver.field(datasource + 'password');
+ if (!user.trim() || !password.trim()) throw new Error('主数据库用户名或密码缺失,请配置凭据后重试');
+ if (/^\s*ENC\s*\(/i.test(password)) throw new Error('数据库密码仍为 ENC 加密内容,请通过环境变量提供已解密密码');
+ const serverPort = port(selectedResolver.field('server.port', '8080'), 'HTTP 端口无效,无法推导下载地址');
+ const context = route(selectedResolver.field('server.servlet.context-path', ''), true);
+ const prefix = route(selectedResolver.field('yudao.web.admin-api.prefix', '/admin-api'), false);
+ const ssl = selectedResolver.field('server.ssl.enabled', 'false');
+ if (!['true', 'false'].includes(ssl.toLowerCase())) throw new Error('HTTP SSL 配置无效,无法推导下载地址');
+ return {
+ profile: selectedProfile,
+ mysql: { ...address, user, password },
+ downloadBaseUrl: (ssl.toLowerCase() === 'true' ? 'https' : 'http') + '://127.0.0.1:' + serverPort + context + prefix + '/infra/file',
+ sourceFiles: [baseFile, selectedFile],
+ };
+}
diff --git a/script/migrate-db-files-to-oss/lib/references.mjs b/script/migrate-db-files-to-oss/lib/references.mjs
new file mode 100644
index 0000000..88637f3
--- /dev/null
+++ b/script/migrate-db-files-to-oss/lib/references.mjs
@@ -0,0 +1,207 @@
+const DEFAULT_EXCLUDED_TABLES = new Set([
+ 'infra_file', 'infra_file_content', 'infra_file_config',
+ 'infra_api_access_log', 'infra_api_error_log', 'infra_job_log',
+ 'system_operate_log', 'system_login_log',
+]);
+const MIGRATION_TABLE_PREFIX = /^(?:_+)?(?:migration(?:_|$)|migrate(?:_|$)|(?:infra_file_oss|infra_file|file|oss)_migration(?:_|$))/i;
+const URL_RUN = /[a-z][a-z0-9+.-]*:\/\/[^\s"'`<>]+/gi;
+const NEXT_URL = /,(?=[a-z][a-z0-9+.-]*:\/\/)/gi;
+const TRAILING_DELIMITERS = /[,\])}]+$/;
+
+function lookup(value, replacements) {
+ const replacement = replacements.get(value);
+ return typeof replacement === 'string' && replacement !== value
+ ? { value: replacement, changed: true, replacements: 1 }
+ : { value, changed: false, replacements: 0 };
+}
+
+function rewriteUrlPart(part, replacements) {
+ const exact = lookup(part, replacements);
+ if (exact.changed || replacements.has(part)) return exact;
+ // Closing prose/Markdown punctuation is separate from a URL. A dictionary
+ // entry that includes that punctuation always takes precedence.
+ const delimiters = part.match(TRAILING_DELIMITERS)?.[0] ?? '';
+ for (let count = 1; count <= delimiters.length; count += 1) {
+ const prefix = part.slice(0, -count);
+ const result = lookup(prefix, replacements);
+ if (result.changed) return { ...result, value: result.value + part.slice(-count) };
+ }
+ return exact;
+}
+
+function rewriteUrlRun(run, replacements) {
+ const exact = rewriteUrlPart(run, replacements);
+ if (exact.changed || replacements.has(run)) return exact;
+ const parts = run.split(NEXT_URL);
+ if (parts.length === 1) return exact;
+ let count = 0;
+ const rewritten = [];
+ for (let index = 0; index < parts.length; index += 1) {
+ const result = rewriteUrlPart(parts[index], replacements);
+ rewritten.push(result.value);
+ count += result.replacements;
+ // An unknown query/fragment can contain a literal URL. Do not treat its
+ // comma-delimited contents as independent business references.
+ if (!result.changed && !replacements.has(parts[index]) && /[?#]/.test(parts[index])) {
+ rewritten.push(...parts.slice(index + 1));
+ break;
+ }
+ }
+ return { value: rewritten.join(','), changed: count > 0, replacements: count };
+}
+
+function rewriteText(value, replacements) {
+ const exact = lookup(value, replacements);
+ if (exact.changed || replacements.has(value)) return exact;
+
+ // Validate JSON first, then edit only string-value tokens. This preserves
+ // whitespace, key spelling and untouched escapes in serialized JSON.
+ if (/^[\s]*[\[{\"]/.test(value)) {
+ let isJson = false;
+ try {
+ const parsed = JSON.parse(value);
+ isJson = parsed !== null && (typeof parsed === 'object' || typeof parsed === 'string');
+ } catch {
+ // A regular text or HTML field may also begin with a JSON-like character.
+ }
+ if (isJson) {
+ let count = 0;
+ const rewritten = value.replace(/"(?:\\[\s\S]|[^"\\])*"/g, (token, offset) => {
+ if (/^\s*:/.test(value.slice(offset + token.length))) return token;
+ const result = rewriteText(JSON.parse(token), replacements);
+ count += result.replacements;
+ return result.changed ? JSON.stringify(result.value) : token;
+ });
+ return { value: rewritten, changed: count > 0, replacements: count };
+ }
+ }
+
+ let count = 0;
+ const rewritten = value.replace(URL_RUN, (run) => {
+ const result = rewriteUrlRun(run, replacements);
+ count += result.replacements;
+ return result.value;
+ });
+ return { value: rewritten, changed: count > 0, replacements: count };
+}
+
+/**
+ * Replace complete absolute URL references, without URL decoding, prefix
+ * substitution, or query/fragment normalization. JSON values returned as
+ * objects by mysql2 are supported; object keys are never rewritten.
+ * The input is not mutated and replacement values are not processed again.
+ */
+export function rewriteReferences(value, replacements) {
+ if (!(replacements instanceof Map)) throw new TypeError('replacements must be a Map');
+ const active = new WeakSet();
+ function visit(input) {
+ if (typeof input === 'string') return rewriteText(input, replacements);
+ if (input === null || typeof input !== 'object' || input instanceof Date || ArrayBuffer.isView(input)) {
+ return { value: input, changed: false, replacements: 0 };
+ }
+ if (active.has(input)) throw new TypeError('Reference values must not contain cycles');
+ active.add(input);
+ const output = Array.isArray(input) ? [] : {};
+ let count = 0;
+ for (const [key, item] of Object.entries(input)) {
+ const result = visit(item);
+ // defineProperty also handles JSON keys such as "__proto__" safely.
+ Object.defineProperty(output, key, { value: result.value, enumerable: true, writable: true, configurable: true });
+ count += result.replacements;
+ }
+ active.delete(input);
+ return { value: count > 0 ? output : input, changed: count > 0, replacements: count };
+ }
+ return visit(value);
+}
+
+/** Detect remaining legacy URLs in text/JSON values, including nested JSON. */
+export function containsLegacyReference(value, sourceConfigIds) {
+ const markers = [...sourceConfigIds].map((id) => `/infra/file/${String(id)}/get/`);
+ if (!markers.length) return false;
+ const visited = new WeakSet();
+ function visit(input) {
+ if (typeof input === 'string') {
+ if (/^[\s]*[\[{\"]/.test(input)) {
+ try {
+ const parsed = JSON.parse(input);
+ // Parse JSON before examining its text so keys do not count as
+ // references, and \\u002f / nested JSON escapes are decoded properly.
+ return visit(parsed);
+ } catch {
+ // Rich text or malformed JSON still receives the literal text check.
+ }
+ }
+ const normalized = input.replaceAll('\\/', '/');
+ return markers.some((marker) => normalized.includes(marker));
+ }
+ if (input === null || typeof input !== 'object' || input instanceof Date || ArrayBuffer.isView(input)) return false;
+ if (visited.has(input)) return false;
+ visited.add(input);
+ return Object.values(input).some(visit);
+ }
+ return visit(value);
+}
+
+/**
+ * Discover writable text/JSON columns with one primary-key column. All schema
+ * names are bound values, never SQL identifiers. excludedTables accepts table
+ * names or schema.table names in an Array/Set (case-insensitive exclusion).
+ */
+export async function discoverReferenceColumns(connection, schemas, excludedTables = []) {
+ const schemaNames = [...new Set(schemas)];
+ if (schemaNames.some((name) => typeof name !== 'string' || !name.trim())) {
+ throw new TypeError('schemas must contain non-empty schema names');
+ }
+ if (schemaNames.length === 0) return { columns: [], skipped: [] };
+ const excluded = new Set([...excludedTables].map((name) => String(name).toLowerCase()));
+ const placeholders = schemaNames.map(() => '?').join(', ');
+ const [rows] = await connection.execute(`
+ SELECT c.TABLE_SCHEMA AS schemaName, c.TABLE_NAME AS tableName,
+ c.COLUMN_NAME AS columnName, c.DATA_TYPE AS dataType,
+ c.EXTRA AS extra, c.GENERATION_EXPRESSION AS generationExpression,
+ pk.primaryKey, pk.primaryKeyCount
+ FROM information_schema.COLUMNS c
+ JOIN information_schema.TABLES t
+ ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME
+ LEFT JOIN (
+ SELECT TABLE_SCHEMA, TABLE_NAME, MIN(COLUMN_NAME) AS primaryKey,
+ COUNT(*) AS primaryKeyCount
+ FROM information_schema.KEY_COLUMN_USAGE
+ WHERE CONSTRAINT_NAME = 'PRIMARY' AND TABLE_SCHEMA IN (${placeholders})
+ GROUP BY TABLE_SCHEMA, TABLE_NAME
+ ) pk ON pk.TABLE_SCHEMA = c.TABLE_SCHEMA AND pk.TABLE_NAME = c.TABLE_NAME
+ WHERE c.TABLE_SCHEMA IN (${placeholders}) AND t.TABLE_TYPE = 'BASE TABLE'
+ AND c.DATA_TYPE IN ('char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'json')
+ ORDER BY c.TABLE_SCHEMA, c.TABLE_NAME, c.ORDINAL_POSITION
+ `, [...schemaNames, ...schemaNames]);
+
+ const columns = [];
+ const skipped = [];
+ const skippedTables = new Set();
+ for (const row of rows) {
+ const schema = row.schemaName;
+ const table = row.tableName;
+ const lowerTable = table.toLowerCase();
+ const fullName = `${schema}.${table}`;
+ let reason;
+ if (DEFAULT_EXCLUDED_TABLES.has(lowerTable) || MIGRATION_TABLE_PREFIX.test(table)
+ || excluded.has(lowerTable) || excluded.has(fullName.toLowerCase())) {
+ reason = 'excluded table';
+ } else if (Number(row.primaryKeyCount) !== 1) {
+ reason = Number(row.primaryKeyCount) > 1 ? 'composite primary key' : 'no primary key';
+ }
+ if (reason) {
+ if (!skippedTables.has(fullName)) {
+ skipped.push({ schema, table, reason });
+ skippedTables.add(fullName);
+ }
+ continue;
+ }
+ const extra = row.extra ?? '';
+ if (row.columnName === row.primaryKey || /\bauto_increment\b/i.test(extra)
+ || row.generationExpression || /\b(?:VIRTUAL|STORED)\s+GENERATED\b/i.test(extra)) continue;
+ columns.push({ schema, table, column: row.columnName, primaryKey: row.primaryKey, dataType: row.dataType.toLowerCase() });
+ }
+ return { columns, skipped };
+}
diff --git a/script/migrate-db-files-to-oss/lib/source-batch.mjs b/script/migrate-db-files-to-oss/lib/source-batch.mjs
new file mode 100644
index 0000000..8cd67ca
--- /dev/null
+++ b/script/migrate-db-files-to-oss/lib/source-batch.mjs
@@ -0,0 +1,87 @@
+import { id, qualified } from './core.mjs';
+
+function sourceChanged() {
+ return Object.assign(new Error('源图片在任务建立后发生变化或已删除,请检查该批文件'), {
+ code: 'MIGRATION_SOURCE_CHANGED'
+ });
+}
+
+/**
+ * Validate a bounded batch immediately after the caller begins an InnoDB
+ * REPEATABLE READ transaction, BEFORE any consistent read or explicitly created
+ * consistent snapshot. The plain metadata read must establish its snapshot
+ * only after the primary-key range has been locked.
+ *
+ * The first locking read scans from the minimum expected ID through the upper
+ * key range, including deleted rows and potential newer versions. This retains
+ * range/gap protection until commit/rollback, including against undeleting or
+ * changing another row into a newer matching version. Do not restrict this
+ * range lock to only expected IDs, active rows, or matching paths.
+ *
+ * The caller owns transaction boundaries, rollback on failure and batch size.
+ */
+export async function verifySourceBatch(db, config, items) {
+ if (!Array.isArray(items) || items.length === 0) throw new TypeError('源文件校验批次不能为空');
+ if (!config?.mysql?.database) throw new TypeError('源文件校验需要数据库名称');
+ const expected = new Map();
+ for (const item of items) {
+ const contentId = id(item?.contentId);
+ const configId = id(item?.configId);
+ if (expected.has(contentId)) throw new TypeError('源文件校验批次不能包含重复的内容编号');
+ if (typeof item.path !== 'string' || !item.path.length) throw new TypeError('源文件路径必须为非空字符串');
+ if (!Number.isSafeInteger(item.size) || item.size < 0 || typeof item.sha256 !== 'string' || !item.sha256.length) {
+ throw new TypeError('源文件校验需要有效的预期大小和 SHA-256');
+ }
+ expected.set(contentId, { ...item, contentId, configId });
+ }
+ const entries = [...expected.values()];
+ const minimumId = entries.reduce((minimum, item) => BigInt(item.contentId) < BigInt(minimum) ? item.contentId : minimum, entries[0].contentId);
+ const sourceTable = qualified(config.mysql.database, 'infra_file_content');
+ const cases = entries.map(() => 'WHEN config_id=? AND path=? THEN ?').join(' ');
+ const predicates = entries.map(() => '(config_id=? AND path=? AND id>=?)').join(' OR ');
+ const caseParams = entries.flatMap(item => [item.configId, item.path, item.contentId]);
+ const predicateParams = entries.flatMap(item => [item.configId, item.path, item.contentId]);
+
+ // A primary-key-only locking scan avoids the large-row access path triggered
+ // by locking reads that evaluate config/path predicates. Do not filter out
+ // deleted rows: their undelete could otherwise create a new active version.
+ await db.execute(
+ `SELECT id FROM ${sourceTable} WHERE id>=? ORDER BY id FOR UPDATE`,
+ [minimumId]
+ );
+
+ // CASE is evaluated by MySQL using the path column's collation. The returned
+ // expected ID is the only grouping key used in JavaScript. This consistent
+ // read establishes a fresh snapshot while the whole relevant range is held.
+ const [latestRows] = await db.execute(
+ `SELECT id,CASE ${cases} END AS expected_content_id FROM ${sourceTable}
+ WHERE deleted=0 AND id>=? AND (${predicates}) ORDER BY id DESC`,
+ [...caseParams, minimumId, ...predicateParams]
+ );
+ const latest = new Map();
+ for (const row of latestRows) {
+ const expectedId = String(row.expected_content_id);
+ if (!expected.has(expectedId)) throw sourceChanged();
+ const actualId = id(row.id);
+ if (!latest.has(expectedId) || BigInt(actualId) > BigInt(latest.get(expectedId))) latest.set(expectedId, actualId);
+ }
+ for (const contentId of expected.keys()) {
+ if (latest.get(contentId) !== contentId) throw sourceChanged();
+ }
+
+ // The range is already locked and each config/path version is validated.
+ // Digest work now touches only the selected primary keys, not a sorted scan.
+ const [contentRows] = await db.execute(
+ `SELECT id,config_id,path,OCTET_LENGTH(content) AS size,LOWER(SHA2(content,256)) AS sha256
+ FROM ${sourceTable} WHERE deleted=0 AND id IN (${entries.map(() => '?').join(',')}) FOR UPDATE`,
+ entries.map(item => item.contentId)
+ );
+ const verified = new Set();
+ for (const row of contentRows) {
+ const contentId = id(row.id);
+ const item = expected.get(contentId);
+ if (!item || verified.has(contentId) || Number(row.size) !== item.size || row.sha256 !== item.sha256) throw sourceChanged();
+ verified.add(contentId);
+ }
+ if (verified.size !== expected.size) throw sourceChanged();
+}
diff --git a/script/migrate-db-files-to-oss/lib/source-index.mjs b/script/migrate-db-files-to-oss/lib/source-index.mjs
new file mode 100644
index 0000000..1d92d25
--- /dev/null
+++ b/script/migrate-db-files-to-oss/lib/source-index.mjs
@@ -0,0 +1,107 @@
+import { id, qualified } from './core.mjs';
+
+const compareIds = (left, right) => BigInt(left) < BigInt(right) ? -1 : BigInt(left) > BigInt(right) ? 1 : 0;
+
+function boundary(value) {
+ if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^\d+$/.test(String(value))) {
+ throw new Error('文件清单上界必须是非负整数;大整数请使用字符串');
+ }
+ return BigInt(value).toString();
+}
+
+function pathText(value) {
+ if (Buffer.isBuffer(value)) return value.toString('utf8');
+ if (typeof value !== 'string') throw new Error('文件路径不是有效的文本值');
+ return value;
+}
+
+function fileMetadata(row) {
+ return {
+ id: id(row.id), config_id: id(row.config_id), path: pathText(row.path),
+ url: row.url, type: row.type, size: row.size
+ };
+}
+
+/**
+ * Build a metadata-only index in three SELECTs. MySQL performs all path
+ * grouping/comparisons with the columns' collation; JavaScript only associates
+ * records by their latest numeric ID. No file bytes or digests are selected.
+ */
+export async function loadSourceIndex(db, config, { maxId, progress } = {}) {
+ if (!config?.mysql?.database || !Array.isArray(config.sourceConfigIds) || !config.sourceConfigIds.length) {
+ throw new Error('文件清单需要数据库名称和至少一个源配置编号');
+ }
+ const sourceIds = [...new Set(config.sourceConfigIds.map(id))].sort(compareIds);
+ const upperBound = maxId === undefined ? undefined : boundary(maxId);
+ const contentTable = qualified(config.mysql.database, 'infra_file_content');
+ const fileTable = qualified(config.mysql.database, 'infra_file');
+ const markers = sourceIds.map(() => '?').join(',');
+ // Keep the bound outside this grouping: a newer active row above the bound
+ // must suppress the whole group, not make an older duplicate current again.
+ const latest = `SELECT config_id,path,MAX(id) AS id FROM ${contentTable}
+ WHERE deleted=0 AND config_id IN (${markers}) GROUP BY config_id,path`;
+ const boundClause = upperBound === undefined ? '' : ' WHERE g.id<=?';
+ const boundParams = upperBound === undefined ? [] : [upperBound];
+
+ progress?.stage?.('读取数据库文件分组(只读取元数据,不计算图片哈希)');
+ const [latestRows] = await db.execute(
+ `SELECT c.id AS content_id,c.config_id,c.path FROM ${contentTable} c
+ JOIN (${latest}) g ON g.id=c.id${boundClause} ORDER BY c.id`,
+ [...sourceIds, ...boundParams]
+ );
+ const byContentId = new Map();
+ let observedMaximum = '0';
+ for (const row of latestRows) {
+ const contentId = id(row.content_id);
+ if (upperBound !== undefined && compareIds(contentId, upperBound) > 0) continue;
+ const group = {
+ contentId, configId: id(row.config_id), path: pathText(row.path), files: [], spellings: []
+ };
+ byContentId.set(contentId, group);
+ if (compareIds(contentId, observedMaximum) > 0) observedMaximum = contentId;
+ }
+
+ progress?.stage?.('批量匹配文件记录并检查缺失内容');
+ const [fileRows] = await db.execute(
+ `SELECT f.id,f.config_id,f.path,f.url,f.type,f.size,g.id AS content_id
+ FROM ${fileTable} f LEFT JOIN (${latest}) g
+ ON g.config_id=f.config_id AND g.path=f.path
+ WHERE f.deleted=0 AND f.config_id IN (${markers}) ORDER BY f.id`,
+ [...sourceIds, ...sourceIds]
+ );
+ const missingFiles = [];
+ for (const row of fileRows) {
+ const file = fileMetadata(row);
+ if (row.content_id === null || row.content_id === undefined) {
+ missingFiles.push(file);
+ continue;
+ }
+ // The join deliberately has no upper bound: a file whose latest content
+ // lies beyond this batch is not a missing-content file.
+ byContentId.get(id(row.content_id))?.files.push(file);
+ }
+
+ progress?.stage?.('批量收集原路径拼写,保留历史图片地址兼容性');
+ const [spellingRows] = await db.execute(
+ `SELECT DISTINCT g.id AS content_id,BINARY c.path AS path
+ FROM ${contentTable} c JOIN (${latest}) g
+ ON g.config_id=c.config_id AND g.path=c.path
+ WHERE c.deleted=0${upperBound === undefined ? '' : ' AND g.id<=?'} ORDER BY content_id`,
+ [...sourceIds, ...boundParams]
+ );
+ const spellingSets = new Map();
+ for (const row of spellingRows) {
+ const contentId = id(row.content_id);
+ if (!byContentId.has(contentId)) continue;
+ if (!spellingSets.has(contentId)) spellingSets.set(contentId, new Set());
+ spellingSets.get(contentId).add(pathText(row.path));
+ }
+ const groups = [...byContentId.values()].sort((left, right) => compareIds(left.contentId, right.contentId));
+ for (const group of groups) {
+ group.files.sort((left, right) => compareIds(left.id, right.id));
+ group.spellings = [...(spellingSets.get(group.contentId) || [])]
+ .sort((left, right) => Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')));
+ }
+ missingFiles.sort((left, right) => compareIds(left.id, right.id));
+ return { maxId: upperBound ?? observedMaximum, groups, missingFiles };
+}
diff --git a/script/migrate-db-files-to-oss/lib/transition.mjs b/script/migrate-db-files-to-oss/lib/transition.mjs
new file mode 100644
index 0000000..0cf4bd4
--- /dev/null
+++ b/script/migrate-db-files-to-oss/lib/transition.mjs
@@ -0,0 +1,88 @@
+import { performance } from 'node:perf_hooks';
+import { qualified, sha256 } from './core.mjs';
+import { applyChanges } from './journal.mjs';
+import { verifySourceBatch } from './source-batch.mjs';
+
+/**
+ * Switch a verified batch in one caller-session REPEATABLE READ transaction.
+ * Source validation must be the first operation after BEGIN: it establishes
+ * the source range lock before the transaction's consistent-read snapshot.
+ */
+export async function switchFiles(db, config, items) {
+ if (!Array.isArray(items)) throw new TypeError('文件切换批次必须为数组');
+ const timings = { sourceMs: 0, writeMs: 0, statusMs: 0, commitMs: 0 };
+ if (!items.length) return timings;
+ const changes = items.flatMap(item => {
+ if (!Array.isArray(item?.files)) throw new TypeError('文件切换项必须包含 files 数组(无文件记录时使用空数组)');
+ return item.files.map(file => ({
+ schema: config.mysql.database, table: 'infra_file', primaryKey: 'id', pkValue: file.id,
+ before: {
+ config_id: file.config_id, path: file.path, url: file.url,
+ type: file.type, size: file.size, deleted: Buffer.from([0])
+ },
+ after: {
+ config_id: config.targetConfigId, path: item.key, url: item.url,
+ type: item.type, size: item.size
+ }
+ }));
+ });
+ const keys = items.map(item => sha256(`${item.configId}\0${item.path}`));
+ if (new Set(keys).size !== keys.length) throw new TypeError('文件切换批次不能包含重复源路径');
+ if (new Set(changes.map(change => String(change.pkValue))).size !== changes.length) {
+ throw new TypeError('文件切换批次不能包含重复文件记录');
+ }
+ const measure = async (name, action) => {
+ const started = performance.now();
+ try { return await action(); }
+ finally { timings[name] += performance.now() - started; }
+ };
+
+ await db.beginTransaction();
+ let committing = false;
+ try {
+ // Do not insert metadata lookups or other consistent reads before this.
+ await measure('sourceMs', () => verifySourceBatch(db, config, items));
+ await measure('writeMs', async () => {
+ // One source path may have many infra_file rows. Bound prepared-statement
+ // size while retaining the same source locks and transaction for all rows.
+ for (let offset = 0; offset < changes.length; offset += 100) {
+ await applyChanges(db, config.mysql.database, config.runId, changes.slice(offset, offset + 100));
+ }
+ });
+ await measure('statusMs', async () => {
+ const [result] = await db.execute(
+ `UPDATE ${qualified(config.mysql.database, 'infra_file_oss_migration_items')}
+ SET status='switched',error=NULL,updated_at=CURRENT_TIMESTAMP
+ WHERE run_id=? AND status='verified' AND item_key IN (${keys.map(() => '?').join(',')})`,
+ [config.runId, ...keys]);
+ if (result.affectedRows !== items.length) {
+ throw Object.assign(new Error('部分迁移项已变化或不再处于 verified 状态,必须回滚整批事务'), {
+ code: 'MIGRATION_CAS_CONFLICT'
+ });
+ }
+ if (result.warningStatus > 0) {
+ throw Object.assign(new Error('批量迁移状态更新产生数据库警告,必须回滚整批事务'), {
+ code: 'MIGRATION_SQL_WARNING'
+ });
+ }
+ });
+ committing = true;
+ await measure('commitMs', () => db.commit());
+ return timings;
+ } catch (error) {
+ try {
+ await db.rollback();
+ } catch (rollbackError) {
+ throw Object.assign(new Error('事务回滚失败,连接状态不确定,禁止降级重试', { cause: error }), {
+ code: 'MIGRATION_ROLLBACK_FAILED', fatal: true, noFallback: true, rollbackError
+ });
+ }
+ if (committing) {
+ // A failed COMMIT acknowledgement does not prove the server rolled back.
+ throw Object.assign(new Error('事务提交结果不确定,禁止降级重试,请重新核对迁移状态', { cause: error }), {
+ code: error.code || 'MIGRATION_COMMIT_FAILED', fatal: true, noFallback: true
+ });
+ }
+ throw error;
+ }
+}
diff --git a/script/migrate-db-files-to-oss/migrate.mjs b/script/migrate-db-files-to-oss/migrate.mjs
new file mode 100644
index 0000000..8301a01
--- /dev/null
+++ b/script/migrate-db-files-to-oss/migrate.mjs
@@ -0,0 +1,582 @@
+#!/usr/bin/env node
+import { readFile, mkdir, writeFile } from 'node:fs/promises';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { isDeepStrictEqual } from 'node:util';
+import { aliases, bit, downloadUrl, encodePath, ensureObject, errorText, mimeType, normalizeConfig, objectKey, ossSettings, parseStorage, qualified, quote, readHttp, retry, sha256, verifyContent } from './lib/core.mjs';
+import { containsLegacyReference, discoverReferenceColumns, rewriteReferences } from './lib/references.mjs';
+import { ensureJournal, applyChange, rollbackChanges } from './lib/journal.mjs';
+import { loadProjectConfig } from './lib/project-config.mjs';
+import { discoverStorageConfig } from './lib/auto-storage.mjs';
+import { loadSourceIndex } from './lib/source-index.mjs';
+import { createProgress } from './lib/progress.mjs';
+import { switchFiles } from './lib/transition.mjs';
+import { mapBounded } from './lib/concurrency.mjs';
+
+const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
+const PROJECT_ROOT = resolve(SCRIPT_DIR, '../..');
+const TRANSITION_BATCH_SIZE = 50;
+const VALIDATION_CONCURRENCY = 4;
+const seconds = milliseconds => (milliseconds / 1000).toFixed(2);
+
+const HELP = `数据库图片迁移到阿里云 OSS(Node.js 20.19+)
+ node migrate.mjs 自动读取项目配置,只读预检
+ node migrate.mjs init 自动生成本机配置,只查询存储配置及已有批次
+ node migrate.mjs apply 执行迁移;可加 --limit 50 先迁移少量
+ node migrate.mjs rollback 回滚当前批次的数据库修改
+可选:--profile dev | --config 文件路径 | --run-id 新批次名称
+默认复用项目当前环境的主数据库和默认 OSS,不需要填写数据库密码或存储编号。
+保留原 BLOB、旧配置和 OSS 对象;rollback 只撤销本批次数据库修改。`;
+
+export function parseArgs(argv) {
+ if (argv.includes('--help') || argv.includes('-h')) return { help: true };
+ const hasCommand = argv.length && !argv[0].startsWith('--');
+ const command = hasCommand ? argv[0] : 'plan';
+ const rest = hasCommand ? argv.slice(1) : argv;
+ if (!['init', 'plan', 'apply', 'rollback'].includes(command)) throw new Error('命令只能为 init、plan、apply 或 rollback');
+ const result = { command, limit: Infinity, configPath: resolve(SCRIPT_DIR, 'config.local.json') };
+ for (let i = 0; i < rest.length; i += 2) {
+ if (!['--config', '--limit', '--profile', '--run-id'].includes(rest[i]) || !rest[i + 1]) throw new Error('未知参数或缺少参数值');
+ if (rest[i] === '--config') { result.configPath = resolve(rest[i + 1]); result.explicitConfig = true; }
+ else if (rest[i] === '--profile') result.profile = rest[i + 1];
+ else if (rest[i] === '--run-id') result.runId = rest[i + 1];
+ else result.limit = Number(rest[i + 1]);
+ }
+ if (result.limit !== Infinity && (!Number.isSafeInteger(result.limit) || result.limit < 1 || command !== 'apply')) throw new Error('--limit 仅用于 apply,且必须为正整数');
+ return result;
+}
+
+export async function prepareInput(args, projectRoot = PROJECT_ROOT) {
+ let raw = {}, exists = false;
+ try { raw = JSON.parse(await readFile(args.configPath, 'utf8')); exists = true; }
+ catch (error) { if (error.code !== 'ENOENT' || args.explicitConfig) throw error; }
+ const placeholder = raw.mysql?.database === 'YOUR_DATABASE';
+ const projectManaged = !raw.mysql || placeholder || raw._autoDiscovery === true;
+ let input;
+ if (projectManaged) {
+ const project = await loadProjectConfig({ projectRoot, profile: args.profile || raw.projectProfile });
+ input = { ...raw, projectProfile: project.profile, mysql: project.mysql,
+ _projectDownloadBaseUrl: project.downloadBaseUrl, _autoDiscovery: true };
+ } else {
+ if (args.profile) throw new Error('手动 mysql 配置不能同时使用 --profile;删除 mysql 项即可复用项目配置');
+ input = { ...raw, mysql: { ...raw.mysql } };
+ }
+ if (args.runId) input.runId = args.runId;
+ if (input.targetConfigId === null) delete input.targetConfigId;
+ if (Array.isArray(input.sourceConfigIds) && input.sourceConfigIds.length === 0) delete input.sourceConfigIds;
+ const password = input.mysql.passwordEnv ? process.env[input.mysql.passwordEnv] : input.mysql.password;
+ if (password === undefined) throw new Error('数据库密码未提供;自动模式无需填写 mysql,手动模式请设置指定环境变量');
+ input.mysql.password = password;
+ return { input, exists, projectManaged, raw };
+}
+
+export async function persistGeneratedConfig(config, args, prepared) {
+ if (!prepared.projectManaged || (prepared.exists && prepared.raw._autoDiscovery !== true)) return;
+ const saved = {
+ projectProfile: config.projectProfile, runId: config.runId,
+ sourceConfigIds: config.sourceConfigIds, targetConfigId: config.targetConfigId,
+ sourceMode: config.sourceMode, oldDomains: config.oldDomains,
+ _autoDiscovery: true, _databaseIdentity: config._databaseIdentity
+ };
+ // Persist discovery/batch choices, but read credentials from the project's
+ // YAML on each invocation. No database or OSS secret is copied here.
+ const defaults = normalizeConfig({ runId: config.runId, mysql: config.mysql, sourceConfigIds: config.sourceConfigIds, targetConfigId: config.targetConfigId, sourceMode: 'database' });
+ for (const key of ['targetUrlMode', 'targetPublicDomain', 'objectPrefix', 'referenceSchemas', 'excludeReferenceTables', 'imagesOnly', 'batchSize', 'timeoutMs', 'maxFileBytes', 'retries']) {
+ if (config[key] !== undefined && JSON.stringify(config[key]) !== JSON.stringify(defaults[key])) saved[key] = config[key];
+ }
+ if (config.sourceMode === 'http' || config.targetUrlMode === 'backend') saved.downloadBaseUrl = config.downloadBaseUrl;
+ const serialized = JSON.stringify(saved, null, 2) + '\n';
+ if (prepared.exists && JSON.stringify(prepared.raw) === JSON.stringify(saved)) return;
+ // Never overwrite a pre-existing manual file; generated files may be
+ // refreshed to pin an explicitly requested new batch.
+ await writeFile(args.configPath, serialized, { mode: 0o600, flag: prepared.exists ? 'w' : 'wx' });
+}
+
+const table = (config, name) => qualified(config.mysql.database, name);
+const runs = config => table(config, 'infra_file_oss_migration_runs');
+const items = config => table(config, 'infra_file_oss_migration_items');
+const parseJson = value => typeof value === 'string' ? JSON.parse(value) : value;
+const itemKey = item => sha256(`${item.configId}\0${item.path}`);
+const samePlannedItem = (left, right) => {
+ const canonical = item => ({ ...item, aliases: [...item.aliases].sort(), files: [...item.files].sort((a, b) => BigInt(a.id) < BigInt(b.id) ? -1 : BigInt(a.id) > BigInt(b.id) ? 1 : 0) });
+ return isDeepStrictEqual(canonical(left), canonical(right));
+};
+
+export async function loadSettings(db, config) {
+ const ids = [...config.sourceConfigIds, config.targetConfigId];
+ const [rows] = await db.execute(`SELECT id, storage, master, config FROM ${table(config, 'infra_file_config')} WHERE deleted=0 AND id IN (${ids.map(() => '?').join(',')})`, ids);
+ const byId = new Map(rows.map(row => [String(row.id), row]));
+ for (const sourceId of config.sourceConfigIds) {
+ const row = byId.get(sourceId);
+ if (!row || Number(row.storage) !== 1) throw new Error(`源配置 ${sourceId} 不存在或不是数据库存储`);
+ if (bit(row.master)) throw new Error(`源配置 ${sourceId} 仍是默认上传配置,请先切换默认配置到 OSS`);
+ }
+ const targetRow = byId.get(config.targetConfigId);
+ if (!targetRow) throw new Error('目标存储配置不存在');
+ const oss = ossSettings(targetRow, config);
+ const [identity] = await db.query('SELECT @@server_uuid AS server_uuid');
+ const safe = {
+ version: 1, server: identity[0].server_uuid, schema: config.mysql.database,
+ sourceConfigIds: config.sourceConfigIds, targetConfigId: config.targetConfigId,
+ sourceDomains: config.sourceConfigIds.map(sourceId => parseStorage(byId.get(sourceId)).domain || ''),
+ sourceMode: config.sourceMode, downloadBaseUrl: config.downloadBaseUrl || '',
+ oldDomains: config.oldDomains, targetUrlMode: config.targetUrlMode,
+ endpoint: oss.endpoint, bucket: oss.bucket, publicDomain: oss.publicDomain,
+ objectPrefix: config.objectPrefix, imagesOnly: config.imagesOnly,
+ referenceSchemas: config.referenceSchemas, excludeReferenceTables: config.excludeReferenceTables
+ };
+ return { byId, oss, safe };
+}
+
+const progressOf = config => config._progress || { stage: message => console.log(message), update() {} };
+
+function sourceItem(group, config, settings) {
+ const type = mimeType(group.path, group.files);
+ if (config.imagesOnly && !type.toLowerCase().startsWith('image/')) return null;
+ encodePath(group.path);
+ const item = { contentId: String(group.contentId), configId: String(group.configId), path: group.path, type, files: group.files };
+ const aliasRows = [...group.files, ...group.spellings.map(path => ({ path }))];
+ item.aliases = aliases(item, aliasRows, parseStorage(settings.byId.get(item.configId)).domain, config);
+ return item;
+}
+
+const missingCount = (index, config) => index.missingFiles.filter(file => !config.imagesOnly || mimeType(file.path, [file]).toLowerCase().startsWith('image/')).length;
+
+export async function sourceCurrent(db, config, item, lock = false) {
+ // Keep large content out of the unindexed path/order lookup. On the actual
+ // database the combined lookup + BLOB projection took seconds per image;
+ // a metadata lookup followed by a primary-key hash read takes milliseconds.
+ // With lock=true the first SELECT retains the original range-lock semantics.
+ const [[latest]] = await db.execute(`SELECT id FROM ${table(config, 'infra_file_content')} WHERE deleted=0 AND config_id=? AND path=? ORDER BY id DESC LIMIT 1${lock ? ' FOR UPDATE' : ''}`, [item.configId, item.path]);
+ if (!latest || String(latest.id) !== String(item.contentId)) throw new Error('源图片在任务建立后发生变化或已删除,请检查该文件');
+ const [[current]] = await db.execute(`SELECT id,OCTET_LENGTH(content) AS size,LOWER(SHA2(content,256)) AS sha256 FROM ${table(config, 'infra_file_content')} WHERE id=? AND config_id=? AND path=? AND deleted=0${lock ? ' FOR UPDATE' : ''}`, [item.contentId, item.configId, item.path]);
+ if (!current || String(current.id) !== String(item.contentId) || current.sha256 !== item.sha256 || Number(current.size) !== item.size) throw new Error('源图片在任务建立后发生变化或已删除,请检查该文件');
+}
+
+export async function* inventory(db, config, settings, maxId, summary, sourceIndex) {
+ const progress = progressOf(config);
+ const index = sourceIndex || await loadSourceIndex(db, config, { maxId, progress });
+ const candidates = [];
+ for (const group of index.groups) {
+ summary.sourceGroups++;
+ const item = sourceItem(group, config, settings);
+ if (item) candidates.push(item); else summary.skippedNonImages++;
+ }
+ progress.stage(`建立迁移清单:按主键读取并校验 ${candidates.length} 个文件的内容`);
+ for (let start = 0; start < candidates.length; start += config.batchSize) {
+ const batch = candidates.slice(start, start + config.batchSize);
+ progress.update(`建立迁移清单:已校验 ${start}/${candidates.length} 个文件,正在读取下一批`);
+ const [rows] = await db.execute(`SELECT id,config_id,path,OCTET_LENGTH(content) AS size,LOWER(SHA2(content,256)) AS sha256 FROM ${table(config, 'infra_file_content')} WHERE deleted=0 AND id IN (${batch.map(() => '?').join(',')})`, batch.map(item => item.contentId));
+ const byId = new Map(rows.map(row => [String(row.id), row]));
+ for (const item of batch) {
+ const row = byId.get(item.contentId);
+ if (!row || String(row.config_id) !== item.configId || row.path !== item.path) throw new Error(`内容记录 ${item.contentId} 在建立清单期间发生变化,请检查后重试`);
+ item.size = Number(row.size);
+ item.sha256 = row.sha256;
+ if (!item.size || item.size > config.maxFileBytes) throw new Error(`内容记录 ${item.contentId} 为空或超过 maxFileBytes`);
+ item.key = objectKey(config, item);
+ item.url = config.targetUrlMode === 'backend' ? downloadUrl(config.downloadBaseUrl, config.targetConfigId, item.key) : `${settings.oss.publicDomain}/${encodePath(item.key)}`;
+ if (item.key.length > 512 || item.url.length > 1024) throw new Error('目标路径或访问地址超出 infra_file 字段长度');
+ summary.candidates++;
+ summary.bytes += item.size;
+ if (!item.files.length) summary.withoutFileRecord++;
+ yield item;
+ }
+ }
+ progress.stage(`迁移清单已建立:${summary.candidates} 个文件,内容校验完成`);
+}
+
+function addMappings(map, item) {
+ for (const oldUrl of item.aliases) {
+ if (map.has(oldUrl) && map.get(oldUrl) !== item.url) throw new Error('同一旧地址对应多个目标,必须先解决数据冲突');
+ map.set(oldUrl, item.url);
+ }
+}
+
+const emptyInventory = () => ({ sourceGroups: 0, candidates: 0, bytes: 0, skippedNonImages: 0, withoutFileRecord: 0 });
+
+async function scanReferences(db, config, map, { apply = false } = {}) {
+ const progress = progressOf(config);
+ progress.stage('检查需要扫描的业务表和图片地址字段');
+ const discovery = await discoverReferenceColumns(db, config.referenceSchemas, config.excludeReferenceTables);
+ const groups = new Map();
+ for (const column of discovery.columns) {
+ const key = JSON.stringify([column.schema, column.table]);
+ if (!groups.has(key)) groups.set(key, { ...column, columns: [] });
+ groups.get(key).columns.push(column.column);
+ }
+ const result = { scannedRows: 0, changedRows: 0, replacements: 0, unresolvedRows: 0, conflicts: 0, skippedTables: discovery.skipped.filter(entry => entry.reason !== 'excluded table'), excludedTables: discovery.skipped.filter(entry => entry.reason === 'excluded table'), tables: [] };
+ let tableNumber = 0;
+ const readBatchSize = Math.max(config.batchSize, 1000);
+ for (const group of groups.values()) {
+ tableNumber++;
+ let scanned = 0;
+ progress.stage(`引用扫描 ${tableNumber}/${groups.size}:${group.schema}.${group.table}`);
+ const stat = { schema: group.schema, table: group.table, changedRows: 0, unresolvedRows: 0 };
+ let cursor;
+ for (;;) {
+ const columns = [group.primaryKey, ...group.columns];
+ const [rows] = await db.query(`SELECT ${columns.map(quote).join(',')} FROM ${qualified(group.schema, group.table)} ${cursor === undefined ? '' : `WHERE ${quote(group.primaryKey)}>?`} ORDER BY ${quote(group.primaryKey)} LIMIT ${readBatchSize}`, cursor === undefined ? [] : [cursor]);
+ if (!rows.length) break;
+ for (const row of rows) {
+ cursor = row[group.primaryKey];
+ result.scannedRows++;
+ scanned++;
+ progress.update(`引用扫描 ${tableNumber}/${groups.size}:${group.schema}.${group.table},已检查 ${scanned} 行,匹配 ${stat.changedRows} 行`);
+ const before = {}, after = {};
+ let unresolved = false, replacements = 0;
+ for (const column of group.columns) {
+ const rewritten = rewriteReferences(row[column], map);
+ if (rewritten.changed) { before[column] = row[column]; after[column] = rewritten.value; replacements += rewritten.replacements; }
+ if (containsLegacyReference(rewritten.value, config.sourceConfigIds)) unresolved = true;
+ }
+ if (unresolved) { result.unresolvedRows++; stat.unresolvedRows++; }
+ if (!Object.keys(after).length) continue;
+ if (apply) {
+ await db.beginTransaction();
+ try {
+ await applyChange(db, config.mysql.database, config.runId, { schema: group.schema, table: group.table, primaryKey: group.primaryKey, pkValue: cursor, before, after });
+ await db.commit();
+ } catch (error) {
+ await db.rollback();
+ result.conflicts++;
+ console.error(`引用更新未完成 ${group.schema}.${group.table}:${errorText(error)}`);
+ continue;
+ }
+ }
+ result.changedRows++; stat.changedRows++; result.replacements += replacements;
+ }
+ }
+ if (stat.changedRows || stat.unresolvedRows) result.tables.push(stat);
+ console.log(`引用扫描 ${group.schema}.${group.table}:匹配 ${stat.changedRows} 行,仍含旧地址 ${stat.unresolvedRows} 行`);
+ }
+ return result;
+}
+
+async function setItem(db, config, item, status, error = null) {
+ await db.execute(`UPDATE ${items(config)} SET status=?,error=?,updated_at=CURRENT_TIMESTAMP WHERE run_id=? AND item_key=?`, [status, error, config.runId, itemKey(item)]);
+}
+
+async function* savedItems(db, config, statuses = null) {
+ let cursor = '';
+ for (;;) {
+ const [rows] = await db.execute(`SELECT item_key,data,status FROM ${items(config)} WHERE run_id=? AND item_key>? ${statuses ? `AND status IN (${statuses.map(() => '?').join(',')})` : ''} ORDER BY item_key LIMIT ${config.batchSize}`, [config.runId, cursor, ...(statuses || [])]);
+ if (!rows.length) return;
+ for (const row of rows) { cursor = row.item_key; yield { ...parseJson(row.data), status: row.status }; }
+ }
+}
+
+async function migrateItem(db, config, oss, item) {
+ const timings = { sourceMs: 0, ossMs: 0, publicMs: 0, recordMs: 0 };
+ const measure = async (key, label, action) => {
+ progressOf(config).update(`文件 ${item.contentId}:${label}`);
+ const started = performance.now();
+ try { return await action(); } finally { timings[key] += performance.now() - started; }
+ };
+ await measure('sourceMs', '复核源文件版本', () => sourceCurrent(db, config, item));
+ let bytes;
+ if (config.sourceMode === 'http') {
+ bytes = await measure('sourceMs', '读取原图片', () => readHttp(downloadUrl(config.downloadBaseUrl, item.configId, item.path), config, item.size));
+ } else {
+ const [[row]] = await measure('sourceMs', '读取数据库原图片', () => db.execute(`SELECT content FROM ${table(config, 'infra_file_content')} WHERE id=? AND deleted=0`, [item.contentId]));
+ if (!row) throw new Error('源内容已删除');
+ bytes = row.content;
+ }
+ verifyContent(bytes, item);
+ await measure('ossMs', '上传 OSS 并读取校验', () => ensureObject(oss, item.key, bytes, item));
+ // SDK 可读不代表页面可读:按最终将写入业务表的地址匿名访问并再次校验。
+ verifyContent(await measure('publicMs', '校验最终图片访问地址', () => readHttp(item.url, config, item.size)), item);
+ await measure('sourceMs', '复核源图片未发生变化', () => sourceCurrent(db, config, item));
+ await measure('recordMs', '保存迁移进度', () => setItem(db, config, item, 'verified'));
+ return timings;
+}
+
+export async function verifyCheckpoint(oss, config, item) {
+ // A verified checkpoint needs no source download or PUT. Still check the
+ // authenticated object and its final public URL before trusting it again.
+ const head = await oss.head(item.key);
+ if (Number(head.res.headers['content-length']) !== item.size || item.size > config.maxFileBytes) {
+ throw new Error('OSS 文件长度不一致或超过限制');
+ }
+ verifyContent((await oss.get(item.key)).content, item);
+ verifyContent(await readHttp(item.url, config, item.size), item);
+}
+
+export async function verifySwitchedBatch(db, config, batch) {
+ // A resumed run must not reuse a mapping whose object/metadata was deleted
+ // or changed after the earlier successful batch. Read metadata once; only
+ // HTTP validation is concurrent, never transactions on the shared connection.
+ const ids = [...new Set(batch.flatMap(item => item.files.map(file => String(file.id))))];
+ const byId = new Map();
+ for (let start = 0; start < ids.length; start += 1000) {
+ const chunk = ids.slice(start, start + 1000);
+ const [rows] = await db.execute(`SELECT id,config_id,path,url,deleted FROM ${table(config, 'infra_file')} WHERE id IN (${chunk.map(() => '?').join(',')})`, chunk);
+ for (const row of rows) byId.set(String(row.id), row);
+ }
+ return mapBounded(batch, VALIDATION_CONCURRENCY, async item => {
+ try {
+ for (const original of item.files) {
+ const current = byId.get(String(original.id));
+ if (!current || bit(current.deleted) || String(current.config_id) !== config.targetConfigId || current.path !== item.key || current.url !== item.url) {
+ throw new Error('已迁移文件记录后来发生变化或已删除,停止复用该地址映射');
+ }
+ }
+ await retry(async () => verifyContent(await readHttp(item.url, config, item.size), item), config.retries);
+ return { item };
+ } catch (error) {
+ return { item, error };
+ }
+ });
+}
+
+async function* itemBatches(iterable, size) {
+ let batch = [];
+ for await (const item of iterable) {
+ batch.push(item);
+ if (batch.length === size) { yield batch; batch = []; }
+ }
+ if (batch.length) yield batch;
+}
+
+export async function runPlan(db, config, settings) {
+ const progress = progressOf(config);
+ progress.stage('开始只读预检:只读取文件信息,不读取图片内容或计算 SHA-256');
+ const index = await loadSourceIndex(db, config, { progress });
+ const summary = { command: 'plan', ...emptyInventory(), bytes: null, estimatedBytes: 0, sizePending: 0, contentVerified: false, missingContent: missingCount(index, config) };
+ const mappings = new Map();
+ for (const group of index.groups) {
+ summary.sourceGroups++;
+ const item = sourceItem(group, config, settings);
+ if (!item) { summary.skippedNonImages++; continue; }
+ summary.candidates++;
+ const sizes = item.files.map(file => Number(file.size)).filter(size => Number.isSafeInteger(size) && size >= 0);
+ if (sizes.length) summary.estimatedBytes += Math.max(...sizes); else summary.sizePending++;
+ if (!item.files.length) summary.withoutFileRecord++;
+ // Preview markers exist only in memory. Actual object keys require the
+ // verified content hash and are created exclusively during apply.
+ item.url = `migration-preview://${item.configId}/${item.contentId}`;
+ addMappings(mappings, item);
+ }
+ progress.stage(`已识别 ${summary.candidates} 个图片文件;开始检查业务引用(实际内容在迁移时校验)`);
+ summary.references = await scanReferences(db, config, mappings);
+ summary.status = summary.missingContent || summary.references.unresolvedRows || summary.references.skippedTables.length ? 'needs-review' : 'planned';
+ progress.stage('只读预检完成');
+ console.log('预检未读取图片二进制内容、未计算 SHA-256;容量仅为文件表估算。未验证 HTTP/OSS 连通性,也未创建迁移表、上传或修改数据。');
+ return summary;
+}
+
+export async function runApply(db, config, settings, limit = Infinity, createOss = null) {
+ const progress = progressOf(config);
+ progress.stage('读取迁移批次和断点记录');
+ // Batch source checks depend on InnoDB next-key locks and a snapshot first
+ // established AFTER their range lock, even if the server defaults to RC.
+ await db.query('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
+ const [engines] = await db.execute(`SELECT TABLE_NAME AS name,ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME IN ('infra_file_content','infra_file')`, [config.mysql.database]);
+ if (engines.length !== 2 || engines.some(row => String(row.engine).toLowerCase() !== 'innodb')) {
+ throw new Error('infra_file_content 和 infra_file 必须为 InnoDB,才能保证源校验与记录更新的事务保护');
+ }
+ await ensureJournal(db, config.mysql.database);
+ const [[saved]] = await db.execute(`SELECT settings,status FROM ${runs(config)} WHERE run_id=?`, [config.runId]);
+ let runSettings, sourceIndex;
+ if (saved) {
+ runSettings = parseJson(saved.settings);
+ if (JSON.stringify(runSettings.binding) !== JSON.stringify(settings.safe)) throw new Error('同一 runId 的数据库、源/目标或引用范围发生变化,请恢复原配置或使用新 runId');
+ if (saved.status.startsWith('rollback') || saved.status === 'rolled_back') throw new Error('本批次已开始回滚,不能继续迁移,请继续回滚或使用新 runId');
+ } else {
+ sourceIndex = await loadSourceIndex(db, config, { progress });
+ const missing = missingCount(sourceIndex, config);
+ if (missing) throw new Error(`发现 ${missing} 条图片记录没有数据库内容,请先执行 plan 并处理`);
+ runSettings = { binding: settings.safe, maxId: sourceIndex.maxId };
+ await db.execute(`INSERT INTO ${runs(config)} (run_id,settings,status) VALUES (?,?,'inventory')`, [config.runId, JSON.stringify(runSettings)]);
+ }
+ const summary = { command: 'apply', ...emptyInventory(), ...runSettings.inventory, uploaded: 0, revalidated: 0, switched: 0, failed: 0, limited: false };
+ if (!saved || saved.status === 'inventory') {
+ for await (const item of inventory(db, config, settings, runSettings.maxId, summary, sourceIndex)) {
+ const [[existing]] = await db.execute(`SELECT data FROM ${items(config)} WHERE run_id=? AND item_key=?`, [config.runId, itemKey(item)]);
+ if (existing) {
+ if (!samePlannedItem(parseJson(existing.data), item)) throw new Error('建立清单期间源数据发生变化,请检查后建立新批次');
+ } else {
+ await db.execute(`INSERT INTO ${items(config)} (run_id,item_key,data,status) VALUES (?,?,?,'planned')`, [config.runId, itemKey(item), JSON.stringify(item)]);
+ }
+ }
+ runSettings.inventory = Object.fromEntries(Object.keys(emptyInventory()).map(key => [key, summary[key]]));
+ await db.execute(`UPDATE ${runs(config)} SET settings=?,status='active',updated_at=CURRENT_TIMESTAMP WHERE run_id=?`, [JSON.stringify(runSettings), config.runId]);
+ }
+ // 先验证所有映射没有歧义,再发生 OSS 写入。
+ const allMappings = new Map();
+ let initialSwitched = 0, totalItems = 0;
+ progress.stage('检查迁移地址映射是否存在冲突');
+ for await (const item of savedItems(db, config)) {
+ addMappings(allMappings, item);
+ totalItems++;
+ if (item.status === 'switched') initialSwitched++;
+ }
+ const { publicDomain, ...ossOptions } = settings.oss;
+ const oss = createOss ? createOss() : new (await import('ali-oss')).default({ ...ossOptions, timeout: config.timeoutMs });
+ let processed = 0;
+ const selected = [], checkpoints = [];
+ progress.stage('开始上传并校验 OSS 文件');
+ for await (const item of savedItems(db, config, ['planned', 'failed', 'verified'])) {
+ if (processed >= limit) { summary.limited = true; break; }
+ processed++;
+ if (item.status === 'verified') { checkpoints.push(item); continue; }
+ try {
+ const started = performance.now();
+ const timings = await retry(() => migrateItem(db, config, oss, item), config.retries);
+ summary.uploaded++;
+ selected.push(item);
+ console.log(`上传校验通过:内容记录 ${item.contentId}(本次 ${processed},总计 ${seconds(performance.now() - started)} 秒;源读取/复核 ${seconds(timings.sourceMs)} 秒,OSS 上传/校验 ${seconds(timings.ossMs)} 秒,访问校验 ${seconds(timings.publicMs)} 秒)`);
+ } catch (error) {
+ await setItem(db, config, item, 'failed', errorText(error));
+ summary.failed++;
+ console.error(`上传未完成:内容记录 ${item.contentId},${errorText(error)}`);
+ }
+ }
+ if (checkpoints.length) {
+ let checked = 0;
+ progress.stage(`复核已上传断点:0/${checkpoints.length},最多 ${VALIDATION_CONCURRENCY} 张同时校验`);
+ const results = await mapBounded(checkpoints, VALIDATION_CONCURRENCY, async item => {
+ try {
+ await retry(() => verifyCheckpoint(oss, config, item), config.retries);
+ return { item };
+ } catch (error) {
+ return { item, error };
+ } finally {
+ progress.update(`复核已上传断点:${++checked}/${checkpoints.length}`);
+ }
+ });
+ // All network workers have settled before using the database again.
+ for (const { item, error } of results) {
+ if (error) {
+ await setItem(db, config, item, 'failed', errorText(error));
+ summary.failed++;
+ console.error(`已上传断点校验失败:内容记录 ${item.contentId},${errorText(error)}`);
+ } else { selected.push(item); summary.revalidated++; }
+ }
+ }
+ if (summary.failed) {
+ summary.status = 'partial';
+ const [counts] = await db.execute(`SELECT status,COUNT(*) AS count FROM ${items(config)} WHERE run_id=? GROUP BY status`, [config.runId]);
+ summary.items = Object.fromEntries(counts.map(row => [row.status, Number(row.count)]));
+ await db.execute(`UPDATE ${runs(config)} SET status='partial',updated_at=CURRENT_TIMESTAMP WHERE run_id=?`, [config.runId]);
+ return summary;
+ }
+ const switchProgress = () => `更新已校验文件记录:本次完成 ${summary.switched}/${selected.length},累计 ${initialSwitched + summary.switched}/${totalItems}`;
+ progress.stage(switchProgress());
+ for (let start = 0; start < selected.length; start += TRANSITION_BATCH_SIZE) {
+ const batch = selected.slice(start, start + TRANSITION_BATCH_SIZE);
+ const started = performance.now();
+ try {
+ const timing = await switchFiles(db, config, batch);
+ summary.switched += batch.length;
+ console.log(`文件记录批次完成:${batch.length} 张,${seconds(performance.now() - started)} 秒(源校验 ${seconds(timing.sourceMs)} 秒,文件及回滚日志 ${seconds(timing.writeMs)} 秒,进度 ${seconds(timing.statusMs)} 秒,提交 ${seconds(timing.commitMs)} 秒);累计 ${initialSwitched + summary.switched}/${totalItems}`);
+ } catch (error) {
+ // Isolate known data conflicts after the whole batch rolled back. Never
+ // replay a transaction with an ambiguous commit or broken connection.
+ if (error.fatal || !['MIGRATION_CAS_CONFLICT', 'MIGRATION_SOURCE_CHANGED'].includes(error.code)) throw error;
+ console.error(`该批文件存在修改冲突,事务已回滚;逐项定位:${errorText(error)}`);
+ for (const item of batch) {
+ try { await switchFiles(db, config, [item]); summary.switched++; }
+ catch (itemError) {
+ if (itemError.fatal || !['MIGRATION_CAS_CONFLICT', 'MIGRATION_SOURCE_CHANGED'].includes(itemError.code)) throw itemError;
+ summary.failed++;
+ console.error(`文件记录未切换:内容记录 ${item.contentId},${errorText(itemError)}`);
+ }
+ }
+ }
+ progress.update(switchProgress());
+ }
+ const mappings = new Map();
+ let checkedSwitched = 0;
+ progress.stage(`复核已迁移文件的访问地址:0/${initialSwitched + summary.switched}`);
+ for await (const batch of itemBatches(savedItems(db, config, ['switched']), TRANSITION_BATCH_SIZE)) {
+ for (const { item, error } of await verifySwitchedBatch(db, config, batch)) {
+ if (error) {
+ summary.failed++;
+ console.error(`目标地址不可复用:内容记录 ${item.contentId},${errorText(error)}`);
+ } else addMappings(mappings, item);
+ }
+ checkedSwitched += batch.length;
+ progress.update(`复核已迁移文件的访问地址:${checkedSwitched}/${initialSwitched + summary.switched}`);
+ }
+ summary.references = await scanReferences(db, config, mappings, { apply: true });
+ const [counts] = await db.execute(`SELECT status,COUNT(*) AS count FROM ${items(config)} WHERE run_id=? GROUP BY status`, [config.runId]);
+ summary.items = Object.fromEntries(counts.map(row => [row.status, Number(row.count)]));
+ const unfinished = counts.some(row => row.status !== 'switched');
+ summary.status = unfinished || summary.failed || summary.references.conflicts ? 'partial'
+ : summary.references.unresolvedRows || summary.references.skippedTables.length ? 'needs-review' : 'complete';
+ await db.execute(`UPDATE ${runs(config)} SET status=?,updated_at=CURRENT_TIMESTAMP WHERE run_id=?`, [summary.status, config.runId]);
+ return summary;
+}
+
+export async function runRollback(db, config) {
+ const [[run]] = await db.execute(`SELECT settings,status FROM ${runs(config)} WHERE run_id=?`, [config.runId]);
+ if (!run) throw new Error('找不到此 runId 的迁移日志');
+ const [[identity]] = await db.query('SELECT @@server_uuid AS server_uuid');
+ const binding = parseJson(run.settings).binding;
+ if (binding.schema !== config.mysql.database || binding.server !== identity.server_uuid) throw new Error('回滚数据库与迁移时不一致');
+ await db.execute(`UPDATE ${runs(config)} SET status='rollback',updated_at=CURRENT_TIMESTAMP WHERE run_id=?`, [config.runId]);
+ const result = await rollbackChanges(db, config.mysql.database, config.runId);
+ const status = result.conflicts.length ? 'rollback_partial' : 'rolled_back';
+ await db.execute(`UPDATE ${runs(config)} SET status=?,updated_at=CURRENT_TIMESTAMP WHERE run_id=?`, [status, config.runId]);
+ return { command: 'rollback', status, ...result };
+}
+
+export async function main(argv = process.argv.slice(2)) {
+ const args = parseArgs(argv);
+ if (args.help) { console.log(HELP); return; }
+ const prepared = await prepareInput(args);
+ const { default: mysql } = await import('mysql2/promise');
+ const { passwordEnv, ...mysqlOptions } = prepared.input.mysql;
+ console.log(`读取${prepared.input.projectProfile ? `项目 ${prepared.input.projectProfile} 环境` : '指定配置'}:MySQL ${mysqlOptions.host || 'localhost'}:${mysqlOptions.port || 3306}/${mysqlOptions.database}`);
+ const db = await mysql.createConnection({ connectTimeout: 10000, ...mysqlOptions, multipleStatements: false, supportBigNumbers: true, bigNumberStrings: true, jsonStrings: true, dateStrings: true });
+ const progress = createProgress();
+ let locked = false;
+ const lockName = `oss-file-migration:${sha256(mysqlOptions.database).slice(0, 40)}`;
+ try {
+ progress.stage('识别旧存储、默认 OSS 和迁移批次');
+ await db.query("SET SESSION sql_mode = CONCAT_WS(',', NULLIF(@@SESSION.sql_mode,''), 'STRICT_ALL_TABLES')");
+ if (['apply', 'rollback'].includes(args.command)) {
+ const [[lock]] = await db.execute('SELECT GET_LOCK(?,0) AS acquired', [lockName]);
+ if (Number(lock.acquired) !== 1) throw new Error('已有文件迁移或回滚任务运行,请等待其结束');
+ locked = true;
+ }
+ const auto = prepared.projectManaged || !prepared.input.runId || !prepared.input.sourceConfigIds?.length || !prepared.input.targetConfigId;
+ const config = normalizeConfig(auto ? await discoverStorageConfig(db, prepared.input, { command: args.command }) : prepared.input);
+ config._progress = progress;
+ await db.query(`SET SESSION max_execution_time=${config.timeoutMs}`);
+ await persistGeneratedConfig(config, args, prepared);
+ if (args.command === 'init') {
+ await loadSettings(db, config);
+ progress.close();
+ console.log(`配置已就绪:旧数据库存储 ${config.sourceConfigIds.join(',')} → 默认 OSS ${config.targetConfigId};批次 ${config.runId}`);
+ console.log(`配置文件:${args.configPath};数据库密码和 OSS 密钥未复制到此文件。`);
+ console.log('只读取了项目和数据库配置,未上传图片或修改数据库。');
+ return { status: 'configured', configPath: args.configPath };
+ }
+ let summary;
+ if (args.command === 'rollback') summary = await runRollback(db, config);
+ else {
+ const settings = await loadSettings(db, config);
+ console.log(`源配置 ${config.sourceConfigIds.join(',')} → OSS 配置 ${config.targetConfigId};批次 ${config.runId}`);
+ summary = args.command === 'plan' ? await runPlan(db, config, settings) : await runApply(db, config, settings, args.limit);
+ }
+ const reportDir = resolve(dirname(args.configPath), 'reports');
+ await mkdir(reportDir, { recursive: true, mode: 0o700 });
+ const reportPath = resolve(reportDir, `${config.runId}-${args.command}-${Date.now()}.json`);
+ await writeFile(reportPath, JSON.stringify(summary, null, 2), { mode: 0o600 });
+ progress.close();
+ console.log(JSON.stringify(summary, null, 2));
+ console.log(`报告:${reportPath}`);
+ if (['partial', 'needs-review', 'rollback_partial'].includes(summary.status)) process.exitCode = 2;
+ return summary;
+ } finally {
+ progress.close();
+ try { if (locked) await db.execute('SELECT RELEASE_LOCK(?)', [lockName]); }
+ finally { await db.end(); }
+ }
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ main().catch(error => { console.error(`执行停止:${errorText(error)}`); process.exitCode = 1; });
+}
diff --git a/script/migrate-db-files-to-oss/package-lock.json b/script/migrate-db-files-to-oss/package-lock.json
new file mode 100644
index 0000000..e299726
--- /dev/null
+++ b/script/migrate-db-files-to-oss/package-lock.json
@@ -0,0 +1,1101 @@
+{
+ "name": "friendship-db-files-to-oss",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "friendship-db-files-to-oss",
+ "dependencies": {
+ "ali-oss": "6.23.0",
+ "mysql2": "3.15.3",
+ "yaml": "2.8.2"
+ },
+ "engines": {
+ "node": ">=20.19"
+ }
+ },
+ "node_modules/address": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmmirror.com/address/-/address-1.2.2.tgz",
+ "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/agentkeepalive": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmmirror.com/agentkeepalive/-/agentkeepalive-3.5.3.tgz",
+ "integrity": "sha512-yqXL+k5rr8+ZRpOAntkaaRgWgE5o8ESAj5DyRmVTCSoZxXmqemb9Dd7T4i5UzwuERdLAJUy6XzR9zFVuf0kzkw==",
+ "license": "MIT",
+ "dependencies": {
+ "humanize-ms": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/ali-oss": {
+ "version": "6.23.0",
+ "resolved": "https://registry.npmmirror.com/ali-oss/-/ali-oss-6.23.0.tgz",
+ "integrity": "sha512-FipRmyd16Pr/tEey/YaaQ/24Pc3HEpLM9S1DRakEuXlSLXNIJnu1oJtHM53eVYpvW3dXapSjrip3xylZUTIZVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "address": "^1.2.2",
+ "agentkeepalive": "^3.4.1",
+ "bowser": "^1.6.0",
+ "copy-to": "^2.0.1",
+ "dateformat": "^2.0.0",
+ "debug": "^4.3.4",
+ "destroy": "^1.0.4",
+ "end-or-error": "^1.0.1",
+ "get-ready": "^1.0.0",
+ "humanize-ms": "^1.2.0",
+ "is-type-of": "^1.4.0",
+ "js-base64": "^2.5.2",
+ "jstoxml": "^2.0.0",
+ "lodash": "^4.17.21",
+ "merge-descriptors": "^1.0.1",
+ "mime": "^2.4.5",
+ "platform": "^1.3.1",
+ "pump": "^3.0.0",
+ "qs": "^6.4.0",
+ "sdk-base": "^2.0.1",
+ "stream-http": "2.8.2",
+ "stream-wormhole": "^1.0.4",
+ "urllib": "^2.44.0",
+ "utility": "^1.18.0",
+ "xml2js": "^0.6.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "license": "MIT"
+ },
+ "node_modules/aws-ssl-profiles": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
+ "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/bowser": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmmirror.com/bowser/-/bowser-1.9.4.tgz",
+ "integrity": "sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==",
+ "license": "MIT"
+ },
+ "node_modules/builtin-status-codes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz",
+ "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==",
+ "license": "MIT"
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/copy-to": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/copy-to/-/copy-to-2.0.1.tgz",
+ "integrity": "sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==",
+ "license": "MIT"
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
+ "node_modules/dateformat": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmmirror.com/dateformat/-/dateformat-2.2.0.tgz",
+ "integrity": "sha512-GODcnWq3YGoTnygPfi02ygEiRxqUxpJwuRHjdhJYuxpcZmDq4rjBiXYmbCCzStxo176ixfLT6i4NPwQooRySnw==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/default-user-agent": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/default-user-agent/-/default-user-agent-1.0.0.tgz",
+ "integrity": "sha512-bDF7bg6OSNcSwFWPu4zYKpVkJZQYVrAANMYB8bc9Szem1D0yKdm4sa/rOCs2aC9+2GMqQ7KnwtZRvDhmLF0dXw==",
+ "license": "MIT",
+ "dependencies": {
+ "os-name": "~1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ }
+ },
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/digest-header": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/digest-header/-/digest-header-1.1.0.tgz",
+ "integrity": "sha512-glXVh42vz40yZb9Cq2oMOt70FIoWiv+vxNvdKdU8CwjLad25qHM3trLxhl9bVjdr6WaslIXhWpn0NO8T/67Qjg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/end-or-error": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/end-or-error/-/end-or-error-1.0.1.tgz",
+ "integrity": "sha512-OclLMSug+k2A0JKuf494im25ANRBVW8qsjmwbgX7lQ8P82H21PQ1PWkoYwb9y5yMBS69BPlwtzdIFClo3+7kOQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.11.14"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "license": "MIT",
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/formstream": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmmirror.com/formstream/-/formstream-1.5.2.tgz",
+ "integrity": "sha512-NASf0lgxC1AyKNXQIrXTEYkiX99LhCEXTkiGObXAkpBui86a4u8FjH1o2bGb3PpqI3kafC+yw4zWeK6l6VHTgg==",
+ "license": "MIT",
+ "dependencies": {
+ "destroy": "^1.0.4",
+ "mime": "^2.5.2",
+ "node-hex": "^1.0.1",
+ "pause-stream": "~0.0.11"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/generate-function": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz",
+ "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-property": "^1.0.2"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-ready": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/get-ready/-/get-ready-1.0.0.tgz",
+ "integrity": "sha512-mFXCZPJIlcYcth+N8267+mghfYN9h3EhsDa6JSnbA3Wrhh/XFpuowviFcsDeYZtKspQyWyJqfs4O6P8CHeTwzw==",
+ "license": "MIT"
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/humanize-ms": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/humanize-ms/-/humanize-ms-1.2.1.tgz",
+ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.0.0"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/is-class-hotfix": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmmirror.com/is-class-hotfix/-/is-class-hotfix-0.0.6.tgz",
+ "integrity": "sha512-0n+pzCC6ICtVr/WXnN2f03TK/3BfXY7me4cjCAqT8TYXEl0+JBRoqBo94JJHXcyDSLUeWbNX8Fvy5g5RJdAstQ==",
+ "license": "MIT"
+ },
+ "node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmmirror.com/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-property": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/is-property/-/is-property-1.0.2.tgz",
+ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
+ "license": "MIT"
+ },
+ "node_modules/is-type-of": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmmirror.com/is-type-of/-/is-type-of-1.4.0.tgz",
+ "integrity": "sha512-EddYllaovi5ysMLMEN7yzHEKh8A850cZ7pykrY1aNRQGn/CDjRDE9qEWbIdt7xGEVJmjBXzU/fNnC4ABTm8tEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "^1.0.2",
+ "is-class-hotfix": "~0.0.6",
+ "isstream": "~0.1.2"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/isstream": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmmirror.com/isstream/-/isstream-0.1.2.tgz",
+ "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==",
+ "license": "MIT"
+ },
+ "node_modules/js-base64": {
+ "version": "2.6.4",
+ "resolved": "https://registry.npmmirror.com/js-base64/-/js-base64-2.6.4.tgz",
+ "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/jstoxml": {
+ "version": "2.2.9",
+ "resolved": "https://registry.npmmirror.com/jstoxml/-/jstoxml-2.2.9.tgz",
+ "integrity": "sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmmirror.com/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/lru.min": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmmirror.com/lru.min/-/lru.min-1.1.5.tgz",
+ "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==",
+ "license": "MIT",
+ "engines": {
+ "bun": ">=1.0.0",
+ "deno": ">=1.30.0",
+ "node": ">=8.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wellwelwel"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz",
+ "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/mysql2": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/mysql2/-/mysql2-3.15.3.tgz",
+ "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
+ "license": "MIT",
+ "dependencies": {
+ "aws-ssl-profiles": "^1.1.1",
+ "denque": "^2.1.0",
+ "generate-function": "^2.3.1",
+ "iconv-lite": "^0.7.0",
+ "long": "^5.2.1",
+ "lru.min": "^1.0.0",
+ "named-placeholders": "^1.1.3",
+ "seq-queue": "^0.0.5",
+ "sqlstring": "^2.3.2"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ }
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/named-placeholders": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmmirror.com/named-placeholders/-/named-placeholders-1.1.6.tgz",
+ "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
+ "license": "MIT",
+ "dependencies": {
+ "lru.min": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/node-hex": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/node-hex/-/node-hex-1.0.1.tgz",
+ "integrity": "sha512-iwpZdvW6Umz12ICmu9IYPRxg0tOLGmU3Tq2tKetejCj3oZd7b2nUXwP3a7QA5M9glWy8wlPS1G3RwM/CdsUbdQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/os-name": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/os-name/-/os-name-1.0.3.tgz",
+ "integrity": "sha512-f5estLO2KN8vgtTRaILIgEGBoBrMnZ3JQ7W9TMZCnOIGwHe8TRGSpcagnWDo+Dfhd/z08k9Xe75hvciJJ8Qaew==",
+ "license": "MIT",
+ "dependencies": {
+ "osx-release": "^1.0.0",
+ "win-release": "^1.0.0"
+ },
+ "bin": {
+ "os-name": "cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/osx-release": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/osx-release/-/osx-release-1.1.0.tgz",
+ "integrity": "sha512-ixCMMwnVxyHFQLQnINhmIpWqXIfS2YOXchwQrk+OFzmo6nDjQ0E4KXAyyUh0T0MZgV4bUhkRrAbVqlE4yLVq4A==",
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.1.0"
+ },
+ "bin": {
+ "osx-release": "cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pause-stream": {
+ "version": "0.0.11",
+ "resolved": "https://registry.npmmirror.com/pause-stream/-/pause-stream-0.0.11.tgz",
+ "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==",
+ "license": [
+ "MIT",
+ "Apache2"
+ ],
+ "dependencies": {
+ "through": "~2.3"
+ }
+ },
+ "node_modules/platform": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmmirror.com/platform/-/platform-1.3.6.tgz",
+ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
+ "license": "MIT"
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.17.1",
+ "resolved": "https://registry.npmmirror.com/qs/-/qs-6.17.1.tgz",
+ "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/sax": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.1.tgz",
+ "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
+ "node_modules/sdk-base": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/sdk-base/-/sdk-base-2.0.1.tgz",
+ "integrity": "sha512-eeG26wRwhtwYuKGCDM3LixCaxY27Pa/5lK4rLKhQa7HBjJ3U3Y+f81MMZQRsDw/8SC2Dao/83yJTXJ8aULuN8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "get-ready": "~1.0.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "5.7.3",
+ "resolved": "https://registry.npmmirror.com/semver/-/semver-5.7.3.tgz",
+ "integrity": "sha512-kM18BPOqTi0+TB68703/GJRpAML5Me+IDQsR1J26YB3EFKKNEyHIVZuWDX7+WKQB3tHwDwgk24BfhdpWUbytvQ==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/seq-queue": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmmirror.com/seq-queue/-/seq-queue-0.0.5.tgz",
+ "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/sqlstring": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmmirror.com/sqlstring/-/sqlstring-2.3.3.tgz",
+ "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmmirror.com/statuses/-/statuses-1.5.0.tgz",
+ "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/stream-http": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmmirror.com/stream-http/-/stream-http-2.8.2.tgz",
+ "integrity": "sha512-QllfrBhqF1DPcz46WxKTs6Mz1Bpc+8Qm6vbqOpVav5odAXwbyzwnEczoWqtxrsmlO+cJqtPrp/8gWKWjaKLLlA==",
+ "license": "MIT",
+ "dependencies": {
+ "builtin-status-codes": "^3.0.0",
+ "inherits": "^2.0.1",
+ "readable-stream": "^2.3.6",
+ "to-arraybuffer": "^1.0.0",
+ "xtend": "^4.0.0"
+ }
+ },
+ "node_modules/stream-wormhole": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/stream-wormhole/-/stream-wormhole-1.1.0.tgz",
+ "integrity": "sha512-gHFfL3px0Kctd6Po0M8TzEvt3De/xu6cnRrjlfYNhwbhLPLwigI2t1nc6jrzNuaYg5C4YF78PPFuQPzRiqn9ew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/through": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmmirror.com/through/-/through-2.3.8.tgz",
+ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
+ "license": "MIT"
+ },
+ "node_modules/to-arraybuffer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",
+ "integrity": "sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==",
+ "license": "MIT"
+ },
+ "node_modules/unescape": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/unescape/-/unescape-1.0.1.tgz",
+ "integrity": "sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "extend-shallow": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/urllib": {
+ "version": "2.44.1",
+ "resolved": "https://registry.npmmirror.com/urllib/-/urllib-2.44.1.tgz",
+ "integrity": "sha512-vreOVvFizoiIz5NK9IYMgUknkriHHBVccn2VFfJhgKz6O2qwm0SgjFk4OpXFRDXpdrTx8EzM1DB0/pejrqXwPA==",
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.3.0",
+ "content-type": "^1.0.2",
+ "default-user-agent": "^1.0.0",
+ "digest-header": "^1.0.0",
+ "ee-first": "~1.1.1",
+ "formstream": "^1.1.0",
+ "humanize-ms": "^1.2.0",
+ "iconv-lite": "^0.6.3",
+ "pump": "^3.0.0",
+ "qs": "^6.4.0",
+ "statuses": "^1.3.1",
+ "utility": "^1.16.1"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "peerDependencies": {
+ "proxy-agent": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "proxy-agent": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/urllib/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/utility": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmmirror.com/utility/-/utility-1.18.0.tgz",
+ "integrity": "sha512-PYxZDA+6QtvRvm//++aGdmKG/cI07jNwbROz0Ql+VzFV1+Z0Dy55NI4zZ7RHc9KKpBePNFwoErqIuqQv/cjiTA==",
+ "license": "MIT",
+ "dependencies": {
+ "copy-to": "^2.0.1",
+ "escape-html": "^1.0.3",
+ "mkdirp": "^0.5.1",
+ "mz": "^2.7.0",
+ "unescape": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.12.0"
+ }
+ },
+ "node_modules/win-release": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/win-release/-/win-release-1.1.1.tgz",
+ "integrity": "sha512-iCRnKVvGxOQdsKhcQId2PXV1vV3J/sDPXKA4Oe9+Eti2nb2ESEsYHRYls/UjoUW3bIc5ZDO8dTH50A/5iVN+bw==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/xml2js": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.6.2.tgz",
+ "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
+ "license": "MIT",
+ "dependencies": {
+ "sax": ">=0.6.0",
+ "xmlbuilder": "~11.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/xmlbuilder": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/yaml": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.2.tgz",
+ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ }
+ }
+}
diff --git a/script/migrate-db-files-to-oss/package.json b/script/migrate-db-files-to-oss/package.json
new file mode 100644
index 0000000..cf5c70b
--- /dev/null
+++ b/script/migrate-db-files-to-oss/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "friendship-db-files-to-oss",
+ "private": true,
+ "type": "module",
+ "engines": {
+ "node": ">=20.19"
+ },
+ "scripts": {
+ "test": "node --test test/*.test.mjs",
+ "init": "node migrate.mjs init",
+ "plan": "node migrate.mjs",
+ "apply": "node migrate.mjs apply",
+ "rollback": "node migrate.mjs rollback"
+ },
+ "dependencies": {
+ "ali-oss": "6.23.0",
+ "mysql2": "3.15.3",
+ "yaml": "2.8.2"
+ }
+}
diff --git a/script/migrate-db-files-to-oss/test/auto-storage.test.mjs b/script/migrate-db-files-to-oss/test/auto-storage.test.mjs
new file mode 100644
index 0000000..2e42e1a
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/auto-storage.test.mjs
@@ -0,0 +1,172 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { discoverStorageConfig } from '../lib/auto-storage.mjs';
+
+const database = 'local_app';
+const server = 'test-server-uuid';
+const input = () => ({ mysql: { database, user: 'local-test', password: 'db-test-only' }, projectProfile: 'local', _projectDownloadBaseUrl: 'http://localhost:48080/admin-api/infra/file' });
+const source = value => ({ id: value, storage: 1, master: Buffer.from([0]), config: JSON.stringify({ domain: 'https://old.example' }) });
+const target = (value = '30', master = 1) => ({ id: value, storage: 20, master: Buffer.from([master]), config: JSON.stringify({ endpoint: 'oss-cn-hangzhou.aliyuncs.com', bucket: 'migration-test', accessKey: 'oss-test-access', accessSecret: 'oss-secret-must-not-escape', domain: 'https://media.example' }) });
+function binding(overrides = {}) {
+ return { server, schema: database, sourceConfigIds: ['5'], targetConfigId: '30', sourceMode: 'database', downloadBaseUrl: 'https://old.example/admin-api/infra/file', oldDomains: ['https://historic.example'], objectPrefix: 'migration/original', imagesOnly: true, referenceSchemas: [database, 'chat'], excludeReferenceTables: ['audit'], targetUrlMode: 'direct', publicDomain: 'https://original-media.example', ...overrides };
+}
+const run = (runId = 'original-run', status = 'partial', overrides = {}) => ({ run_id: runId, status, settings: JSON.stringify({ binding: binding(overrides), maxId: '9007199254740993' }) });
+
+class FakeDb {
+ constructor(options = {}) {
+ this.server = server;
+ this.configs = [source('5'), target()];
+ this.sourceIds = ['5'];
+ this.runs = [];
+ this.domains = ['https://old.example'];
+ this.maximum = '9007199254740993';
+ this.calls = [];
+ Object.assign(this, options);
+ }
+ async execute(sql, params = []) {
+ assert.match(sql.trim(), /^SELECT\b/i, 'discovery must only issue SELECT');
+ this.calls.push({ sql, params });
+ if (sql.includes('@@server_uuid')) return [[{ server_uuid: this.server }]];
+ if (sql.includes('information_schema.TABLES')) {
+ assert.deepEqual(params, [database, 'infra_file_oss_migration_runs']);
+ return [this.runs.length ? [{ present: 1 }] : []];
+ }
+ if (sql.includes('infra_file_oss_migration_runs')) return [this.runs];
+ if (sql.includes('infra_file_config')) return [this.configs];
+ if (sql.includes('MAX(id)')) return [[{ max_id: this.maximum }]];
+ if (sql.includes('infra_file_content')) {
+ assert.match(sql, /deleted=0/);
+ return [this.sourceIds.filter(value => params.includes(value)).map(config_id => ({ config_id }))];
+ }
+ if (sql.includes('SUBSTRING_INDEX')) return [this.domains.map(domain => ({ domain }))];
+ throw new Error(`Unexpected test SQL: ${sql}`);
+ }
+}
+
+test('discovers source BLOB configurations and the unique OSS default using SELECT only', async () => {
+ const db = new FakeDb({ configs: [source('9007199254740995'), target(), source('12'), source('2'), source('999')], sourceIds: ['12', '9007199254740995', '2', '2'] });
+ const result = await discoverStorageConfig(db, input());
+ assert.deepEqual(result.sourceConfigIds, ['2', '12', '9007199254740995']);
+ assert.equal(result.targetConfigId, '30');
+ assert.equal(result.sourceMode, 'database');
+ assert.equal(result.downloadBaseUrl, input()._projectDownloadBaseUrl);
+ assert.equal(result.projectProfile, 'local');
+ assert.equal(result._autoDiscovery, true);
+ assert.deepEqual(result._databaseIdentity, { server, schema: database });
+ assert.match(result.runId, /^db-images-[a-f0-9]{16}$/);
+ assert.equal(JSON.stringify(result).includes('oss-secret-must-not-escape'), false);
+ assert.ok(db.calls.some(call => call.sql.includes('SELECT DISTINCT config_id FROM')));
+});
+
+test('generated runId is independent of source ordering, credentials, domains and remaining file metadata', async () => {
+ const first = await discoverStorageConfig(new FakeDb({ configs: [source('7'), target(), source('5')], sourceIds: ['7', '5'] }), input());
+ const secondInput = input();
+ secondInput.mysql.password = 'rotated-password';
+ const second = await discoverStorageConfig(new FakeDb({ configs: [source('5'), source('7'), target()], sourceIds: ['5', '7'], domains: [] }), secondInput);
+ assert.equal(second.runId, first.runId);
+ assert.deepEqual(second.sourceConfigIds, first.sourceConfigIds);
+});
+
+test('only valid HTTP base domains without credentials are inferred and sorted', async () => {
+ const result = await discoverStorageConfig(new FakeDb({ domains: ['https://z.example/', 'https://a.example/prefix', 'https://z.example', 'https://user:secret@example.com', 'ftp://file.example', 'https://x.example?secret=1', 'not a URL'] }), input());
+ assert.deepEqual(result.oldDomains, ['https://a.example/prefix', 'https://z.example']);
+});
+
+test('requires a unique default and refuses non-Aliyun defaults instead of selecting a different OSS', async () => {
+ await assert.rejects(discoverStorageConfig(new FakeDb({ configs: [source('5'), target('30', 0)] }), input()), /默认.*唯一/);
+ await assert.rejects(discoverStorageConfig(new FakeDb({ configs: [source('5'), target(), target('31')] }), input()), /默认.*唯一/);
+ const invalidDefault = target();
+ invalidDefault.config = JSON.stringify({ endpoint: 'minio.example', accessSecret: 'dont-leak' });
+ await assert.rejects(discoverStorageConfig(new FakeDb({ configs: [source('5'), invalidDefault, target('31', 0)] }), input()), error => /阿里云/.test(error.message) && !error.message.includes('dont-leak'));
+ const malformedDefault = target();
+ malformedDefault.config = '{"secret":"dont-leak", broken';
+ await assert.rejects(discoverStorageConfig(new FakeDb({ configs: [source('5'), malformedDefault] }), input()), error => !error.message.includes('dont-leak'));
+});
+
+test('restores an unfinished run before current metadata can shrink its original scope', async () => {
+ const original = run('original-run', 'partial', { sourceConfigIds: ['7', '5'] });
+ const db = new FakeDb({ runs: [original], configs: [source('5'), source('7'), target()], sourceIds: [], domains: [] });
+ const result = await discoverStorageConfig(db, input(), { command: 'apply' });
+ assert.equal(result.runId, 'original-run');
+ assert.deepEqual(result.sourceConfigIds, ['7', '5'], 'historical order is part of the existing fingerprint');
+ assert.deepEqual(result.oldDomains, ['https://historic.example']);
+ assert.equal(result.objectPrefix, 'migration/original');
+ assert.equal(result.downloadBaseUrl, 'https://old.example/admin-api/infra/file');
+ assert.equal(result.targetPublicDomain, 'https://original-media.example');
+ assert.deepEqual(result.referenceSchemas, [database, 'chat']);
+ assert.ok(!db.calls.some(call => call.sql.includes('SELECT DISTINCT config_id')));
+ assert.ok(!db.calls.some(call => call.sql.includes('SUBSTRING_INDEX')));
+});
+
+test('explicit input fields override restored fields for downstream fingerprint validation', async () => {
+ const result = await discoverStorageConfig(new FakeDb({ runs: [run()] }), {
+ ...input(), oldDomains: [], objectPrefix: 'migration/explicit', imagesOnly: false,
+ targetPublicDomain: 'https://explicit.example', sourceMode: 'http', downloadBaseUrl: 'https://explicit.example/admin-api/infra/file'
+ });
+ assert.deepEqual(result.oldDomains, []);
+ assert.equal(result.objectPrefix, 'migration/explicit');
+ assert.equal(result.imagesOnly, false);
+ assert.equal(result.targetPublicDomain, 'https://explicit.example');
+ assert.equal(result.sourceMode, 'http');
+});
+
+test('automatic resumed inputs and historical runs reject a changed default target', async () => {
+ const options = { configs: [source('5'), target('30', 0), target('31')] };
+ await assert.rejects(discoverStorageConfig(new FakeDb(options), { ...input(), targetConfigId: '30', _autoDiscovery: true }), /原迁移目标不同/);
+ await assert.rejects(discoverStorageConfig(new FakeDb({ ...options, runs: [run()] }), input()), /原迁移目标不同/);
+});
+
+test('rejects cross-server/schema inputs and restored history before accessing storage credentials', async () => {
+ for (const wrong of [{ server: 'other', schema: database }, { server, schema: 'other' }]) {
+ const db = new FakeDb();
+ await assert.rejects(discoverStorageConfig(db, { ...input(), _databaseIdentity: wrong }), /身份.*不一致/);
+ assert.equal(db.calls.length, 1);
+ }
+ const db = new FakeDb({ runs: [run('foreign', 'partial', { server: 'other' })] });
+ await assert.rejects(discoverStorageConfig(db, input()), /身份.*不一致/);
+ assert.ok(!db.calls.some(call => call.sql.includes('infra_file_config')));
+});
+
+test('historical selection refuses ambiguity but honors an explicit existing or new runId', async () => {
+ const unfinished = [run('one'), run('two')];
+ await assert.rejects(discoverStorageConfig(new FakeDb({ runs: unfinished }), input()), /多个未完成.*--run-id/);
+ const chosen = await discoverStorageConfig(new FakeDb({ runs: unfinished }), { ...input(), runId: 'two' });
+ assert.equal(chosen.runId, 'two');
+ const fresh = await discoverStorageConfig(new FakeDb({ runs: unfinished }), { ...input(), runId: 'new-run' });
+ assert.equal(fresh.runId, 'new-run');
+ assert.equal(fresh.objectPrefix, 'migration/db-images');
+ await assert.rejects(discoverStorageConfig(new FakeDb({ runs: [run('one', 'complete'), run('two', 'rolled_back')] }), input()), /多个历史.*--run-id/);
+ const single = await discoverStorageConfig(new FakeDb({ runs: [run('complete-run', 'complete')] }), input());
+ assert.equal(single.runId, 'complete-run');
+ const active = await discoverStorageConfig(new FakeDb({ runs: [run('old', 'complete'), run('active', 'needs-review')] }), input());
+ assert.equal(active.runId, 'active');
+});
+
+test('apply cannot resume a rollback while rollback does not require current storage configs or secrets', async () => {
+ for (const status of ['rollback', 'rollback_partial', 'rolled_back']) {
+ await assert.rejects(discoverStorageConfig(new FakeDb({ runs: [run('old', status)] }), input(), { command: 'apply' }), /回滚.*--run-id/);
+ }
+ const db = new FakeDb({ runs: [run('old', 'rollback_partial')], configs: [] });
+ const result = await discoverStorageConfig(db, input(), { command: 'rollback' });
+ assert.equal(result.runId, 'old');
+ assert.equal(result.targetConfigId, '30');
+ assert.ok(!db.calls.some(call => call.sql.includes('infra_file_config')));
+ await assert.rejects(discoverStorageConfig(new FakeDb(), input(), { command: 'rollback' }), /未找到可回滚/);
+});
+
+test('new content beyond a non-inventory run boundary blocks apply, including bigint boundaries', async () => {
+ const options = { runs: [run()], maximum: '9007199254740994' };
+ await assert.rejects(discoverStorageConfig(new FakeDb(options), input(), { command: 'apply' }), /新增内容/);
+ const planned = await discoverStorageConfig(new FakeDb(options), input(), { command: 'plan' });
+ assert.equal(planned.runId, 'original-run');
+ const inventoryDb = new FakeDb({ ...options, runs: [run('still-building', 'inventory')] });
+ const inventory = await discoverStorageConfig(inventoryDb, input(), { command: 'apply' });
+ assert.equal(inventory.runId, 'still-building');
+ assert.ok(!inventoryDb.calls.some(call => call.sql.includes('MAX(id)')));
+});
+
+test('missing source contents produce a clear no-migration result without creating a run', async () => {
+ const db = new FakeDb({ sourceIds: [] });
+ await assert.rejects(discoverStorageConfig(db, input()), /无需迁移/);
+ assert.ok(db.calls.every(call => /^SELECT\b/i.test(call.sql.trim())));
+});
diff --git a/script/migrate-db-files-to-oss/test/concurrency.test.mjs b/script/migrate-db-files-to-oss/test/concurrency.test.mjs
new file mode 100644
index 0000000..22ff72d
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/concurrency.test.mjs
@@ -0,0 +1,146 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { setImmediate } from 'node:timers/promises';
+import { mapBounded } from '../lib/concurrency.mjs';
+
+function deferred() {
+ let resolve, reject;
+ const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
+ return { promise, resolve, reject };
+}
+
+test('bounded workers keep input order when tasks finish out of order', { timeout: 2000 }, async () => {
+ const gates = Array.from({ length: 5 }, deferred);
+ const started = [];
+ let active = 0, maximum = 0;
+ const pending = mapBounded(['a', 'b', 'c', 'd', 'e'], 2, async (value, index) => {
+ started.push(index);
+ maximum = Math.max(maximum, ++active);
+ try { await gates[index].promise; return value.toUpperCase(); }
+ finally { active--; }
+ });
+ assert.deepEqual(started, [0, 1]);
+ gates[1].resolve();
+ await setImmediate();
+ assert.deepEqual(started, [0, 1, 2]);
+ gates[2].resolve();
+ await setImmediate();
+ assert.deepEqual(started, [0, 1, 2, 3]);
+ gates[0].resolve();
+ await setImmediate();
+ assert.deepEqual(started, [0, 1, 2, 3, 4]);
+ gates[4].resolve();
+ gates[3].resolve();
+ assert.deepEqual(await pending, ['A', 'B', 'C', 'D', 'E']);
+ assert.equal(maximum, 2);
+ assert.equal(active, 0);
+});
+
+test('failure stops new work and waits for all running workers before rejecting', { timeout: 2000 }, async () => {
+ const gates = Array.from({ length: 5 }, deferred);
+ const started = [];
+ const finished = [];
+ const failure = new Error('first verification failed');
+ let settled = false;
+ const pending = mapBounded([0, 1, 2, 3, 4], 3, async index => {
+ started.push(index);
+ try { await gates[index].promise; return index; }
+ finally { finished.push(index); }
+ });
+ const outcome = pending.then(
+ value => { settled = true; return { value }; },
+ error => { settled = true; return { error }; },
+ );
+ assert.deepEqual(started, [0, 1, 2]);
+ gates[1].reject(failure);
+ await setImmediate();
+ assert.deepEqual(started, [0, 1, 2]);
+ assert.equal(settled, false);
+ gates[0].resolve();
+ await setImmediate();
+ assert.deepEqual(started, [0, 1, 2]);
+ assert.equal(settled, false);
+ gates[2].resolve();
+ assert.equal((await outcome).error, failure);
+ assert.equal(settled, true);
+ assert.deepEqual(finished.sort(), [0, 1, 2]);
+});
+
+test('additional in-flight rejections are drained while preserving the first failure', { timeout: 2000 }, async () => {
+ const gates = Array.from({ length: 3 }, deferred);
+ const first = new Error('first');
+ const second = new Error('second');
+ let calls = 0, settled = false;
+ const outcome = mapBounded([0, 1, 2, 3], 3, async index => {
+ calls++;
+ await gates[index].promise;
+ return index;
+ }).then(
+ () => { settled = true; assert.fail('must reject'); },
+ error => { settled = true; return error; },
+ );
+ gates[0].reject(first);
+ await setImmediate();
+ gates[1].reject(second);
+ await setImmediate();
+ assert.equal(settled, false);
+ assert.equal(calls, 3);
+ gates[2].resolve();
+ assert.equal(await outcome, first);
+});
+
+test('a synchronous failure stops scheduling immediately', async () => {
+ const failure = new Error('synchronous failure');
+ const started = [];
+ await assert.rejects(mapBounded([0, 1, 2], 3, value => {
+ started.push(value);
+ throw failure;
+ }), error => error === failure);
+ assert.deepEqual(started, [0]);
+});
+
+test('falsy rejection values still reject and never get treated as success', async () => {
+ for (const failure of [undefined, null, false, 0, '']) {
+ let rejected = false;
+ await mapBounded([1], 1, () => Promise.reject(failure)).then(
+ () => assert.fail('must reject'),
+ error => { rejected = true; assert.equal(error, failure); },
+ );
+ assert.equal(rejected, true);
+ }
+});
+
+test('limit one executes serially and accepts synchronous return values', async () => {
+ const order = [];
+ assert.deepEqual(await mapBounded([2, 4, 6], 1, (value, index) => {
+ order.push(index);
+ return value * 2;
+ }), [4, 8, 12]);
+ assert.deepEqual(order, [0, 1, 2]);
+});
+
+test('limits larger than the item count only start existing items', async () => {
+ let calls = 0;
+ const result = await mapBounded(['x', 'y'], 1000, async value => { calls++; return value; });
+ assert.deepEqual(result, ['x', 'y']);
+ assert.equal(calls, 2);
+});
+
+test('empty input returns an empty array without invoking the worker', async () => {
+ assert.deepEqual(await mapBounded([], 4, () => assert.fail('must not run')), []);
+});
+
+test('invalid limits reject even for an empty input', async () => {
+ for (const limit of [0, -1, 1.5, Infinity, NaN, '4', null, undefined, Number.MAX_SAFE_INTEGER + 1]) {
+ await assert.rejects(mapBounded([], limit, () => {}), /正整数/);
+ }
+});
+
+test('input values are snapshotted before workers mutate the original collection', async () => {
+ const input = ['first', 'second', 'third'];
+ const result = await mapBounded(input, 1, async (value, index) => {
+ if (index === 0) input.splice(1, 2, 'changed');
+ return value;
+ });
+ assert.deepEqual(result, ['first', 'second', 'third']);
+});
diff --git a/script/migrate-db-files-to-oss/test/core.test.mjs b/script/migrate-db-files-to-oss/test/core.test.mjs
new file mode 100644
index 0000000..21fa80b
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/core.test.mjs
@@ -0,0 +1,428 @@
+import test, { mock } from 'node:test';
+import assert from 'node:assert/strict';
+import { createServer } from 'node:http';
+import { once } from 'node:events';
+import {
+ aliases, baseUrl, bit, downloadUrl, encodePath, ensureObject, errorText, id,
+ mimeType, normalizeConfig, objectKey, ossSettings, parseStorage, qualified,
+ quote, readHttp, retry, sha256, verifyContent,
+} from '../lib/core.mjs';
+
+const IMAGE = Buffer.from('89504e470d0a1a0a0001020304050607', 'hex');
+const ITEM = {
+ configId: '9007199254740993', path: '头像/张 三+100%.PNG',
+ size: IMAGE.length, sha256: sha256(IMAGE), type: 'image/png',
+};
+const HTTP_OPTIONS = { timeoutMs: 1000, maxFileBytes: 1024 };
+const sourceBase = 'http://127.0.0.1:48080/admin-api/infra/file';
+const baseConfig = overrides => ({
+ runId: 'unit-test', mysql: { database: 'unit_test', user: 'test-only' },
+ sourceConfigIds: ['1'], targetConfigId: '20', downloadBaseUrl: sourceBase,
+ ...overrides,
+});
+const storageRow = overrides => ({
+ storage: 20,
+ config: {
+ endpoint: 'oss-cn-hangzhou.aliyuncs.com', bucket: 'test-only-bucket',
+ accessKey: 'test-only-access-key', accessSecret: 'test-only-access-secret',
+ ...overrides,
+ },
+});
+const noSuchKey = () => Object.assign(new Error('missing'), { code: 'NoSuchKey', status: 404 });
+const head = bytes => ({ res: { headers: { 'content-length': String(bytes.length) } } });
+const storedObject = bytes => ({
+ head: mock.fn(async () => head(bytes)),
+ get: mock.fn(async () => ({ content: bytes })),
+ put: mock.fn(async () => { throw new Error('unexpected write'); }),
+});
+
+async function localServer(t, handler) {
+ const server = createServer(handler);
+ server.listen(0, '127.0.0.1');
+ await once(server, 'listening');
+ t.after(async () => {
+ const closed = new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
+ server.closeAllConnections();
+ await closed;
+ });
+ return 'http://127.0.0.1:' + server.address().port;
+}
+
+test('download URL preserves Chinese, space, plus, percent, question mark and fragment characters', () => {
+ const path = '头像/张 三+100%?#.png';
+ const encoded = '%E5%A4%B4%E5%83%8F/%E5%BC%A0%20%E4%B8%89%2B100%25%3F%23.png';
+ assert.equal(encodePath(path), encoded);
+ const url = downloadUrl(sourceBase + '///', ITEM.configId, path);
+ assert.equal(url, sourceBase + '/' + ITEM.configId + '/get/' + encoded);
+ assert.equal(decodeURIComponent(new URL(url).pathname.split('/get/')[1]), path);
+ assert.equal(new URL(url).search, '');
+ assert.equal(new URL(url).hash, '');
+});
+
+test('encoded-looking path text is encoded exactly once without decoding the source path', () => {
+ assert.equal(encodePath('%2F/%20+%.png'), '%252F/%2520%2B%25.png');
+ assert.equal(decodeURIComponent(encodePath('%2F/%20+%.png')), '%2F/%20+%.png');
+});
+
+test('ambiguous or normalized path segments are rejected', () => {
+ for (const path of ['', '/image.png', 'a//b.png', 'a/', './a.png', 'a/../b.png', 'a/./b.png', 'a\\b.png']) {
+ assert.throws(() => encodePath(path), /路径/, path);
+ }
+});
+
+test('URL base validates scheme and rejects credentials, queries and fragments', () => {
+ assert.equal(baseUrl('https://images.example.test/prefix///'), 'https://images.example.test/prefix');
+ for (const url of ['ftp://example.test', 'file:///tmp/image', 'https://user:pass@example.test', 'https://example.test/?key=value', 'https://example.test/#part']) {
+ assert.throws(() => baseUrl(url), /HTTP/);
+ }
+});
+
+test('bigint IDs remain exact strings and unsafe numeric IDs are rejected', () => {
+ assert.equal(id('9007199254740993'), '9007199254740993');
+ assert.equal(id(9007199254740993n), '9007199254740993');
+ assert.equal(id(42), '42');
+ assert.throws(() => id(Number('9007199254740993')), /安全整数/);
+ for (const value of [0, -1, 1.5, '01', '1e3', ' 1', '', null, undefined, true]) {
+ assert.throws(() => id(value), /正整数|安全整数/, String(value));
+ }
+});
+
+test('MySQL BIT values distinguish zero buffers from true values', () => {
+ for (const value of [Buffer.from([0]), Buffer.alloc(0), false, 0, '0', null, undefined]) assert.equal(bit(value), false);
+ for (const value of [Buffer.from([1]), true, 1, '1']) assert.equal(bit(value), true);
+});
+
+test('configuration normalizes IDs and schemas without changing bigint precision', () => {
+ const result = normalizeConfig(baseConfig({
+ sourceConfigIds: [1, '1', '9007199254740993'],
+ referenceSchemas: ['chat', 'unit_test', 'chat'],
+ oldDomains: ['https://old.example.test///'],
+ }));
+ assert.deepEqual(result.sourceConfigIds, ['1', '9007199254740993']);
+ assert.deepEqual(result.referenceSchemas, ['unit_test', 'chat']);
+ assert.deepEqual(result.oldDomains, ['https://old.example.test']);
+ assert.equal(result.downloadBaseUrl, sourceBase);
+});
+
+test('configuration rejects same source and target, invalid modes and unsafe batch limits', () => {
+ for (const overrides of [
+ { targetConfigId: '1' }, { sourceMode: 'unknown' }, { targetUrlMode: 'unknown' },
+ { batchSize: 0 }, { batchSize: 1001 }, { retries: 0 }, { retries: 11 },
+ { maxFileBytes: 1.5 }, { timeoutMs: 0 }, { objectPrefix: '../unsafe' },
+ ]) assert.throws(() => normalizeConfig(baseConfig(overrides)));
+});
+
+test('database source and direct target do not require a download address', () => {
+ const result = normalizeConfig(baseConfig({ sourceMode: 'database', downloadBaseUrl: undefined }));
+ assert.equal(result.sourceMode, 'database');
+});
+
+test('OSS settings use the actual Java configuration field names and support JSON storage', () => {
+ const row = storageRow();
+ row.config = JSON.stringify(row.config);
+ assert.equal(parseStorage(row).accessKey, 'test-only-access-key');
+ assert.deepEqual(ossSettings(row, {}), {
+ endpoint: 'https://oss-cn-hangzhou.aliyuncs.com', bucket: 'test-only-bucket',
+ accessKeyId: 'test-only-access-key', accessKeySecret: 'test-only-access-secret',
+ publicDomain: 'https://test-only-bucket.oss-cn-hangzhou.aliyuncs.com',
+ });
+});
+
+test('OSS internal endpoint gets a public fallback domain and explicit domain takes precedence', () => {
+ const row = storageRow({ endpoint: 'https://oss-cn-hangzhou-internal.aliyuncs.com' });
+ const result = ossSettings(row, {});
+ assert.equal(result.endpoint, 'https://oss-cn-hangzhou-internal.aliyuncs.com');
+ assert.equal(result.publicDomain, 'https://test-only-bucket.oss-cn-hangzhou.aliyuncs.com');
+ assert.equal(ossSettings(storageRow({ domain: 'https://cdn.example.test/' }), {}).publicDomain, 'https://cdn.example.test');
+ assert.equal(ossSettings(row, { targetPublicDomain: 'https://override.example.test/assets/' }).publicDomain, 'https://override.example.test/assets');
+});
+
+test('OSS settings reject wrong storage type, non-OSS services, bucket endpoints and missing fields', () => {
+ assert.throws(() => ossSettings({ ...storageRow(), storage: 1 }, {}), /storage=20/);
+ for (const endpoint of [
+ 'http://127.0.0.1:9000', 's3.amazonaws.com', 'bucket.oss-cn-hangzhou.aliyuncs.com',
+ 'oss-cn-hangzhou.aliyuncs.com.evil.test', 'https://oss-cn-hangzhou.aliyuncs.com/path',
+ 'https://user:pass@oss-cn-hangzhou.aliyuncs.com', 'https://oss-cn-hangzhou.aliyuncs.com/?x=1',
+ ]) assert.throws(() => ossSettings(storageRow({ endpoint }), {}), /endpoint/);
+ for (const field of ['bucket', 'accessKey', 'accessSecret']) {
+ assert.throws(() => ossSettings(storageRow({ [field]: '' }), {}), /缺少/);
+ }
+});
+
+test('object keys separate config, source path and content while keeping a safe extension', () => {
+ const config = { objectPrefix: 'migration/test' };
+ const key = objectKey(config, ITEM);
+ assert.equal(key, 'migration/test/' + ITEM.configId + '/' + sha256(ITEM.path) + '/' + ITEM.sha256 + '.png');
+ assert.notEqual(key, objectKey(config, { ...ITEM, configId: '2' }));
+ assert.notEqual(key, objectKey(config, { ...ITEM, path: 'other.PNG' }));
+ assert.notEqual(key, objectKey(config, { ...ITEM, sha256: 'b'.repeat(64) }));
+});
+
+test('aliases retain unrelated stored URLs and only route variants resolving to the source path', () => {
+ const row = { path: ITEM.path, url: 'https://special.example.test/persisted-image' };
+ const result = aliases(ITEM, [row, row], 'https://old.example.test', {
+ oldDomains: ['https://older.example.test'], downloadBaseUrl: sourceBase,
+ });
+ assert.equal(result.filter(value => value === row.url).length, 1);
+ assert.ok(!result.includes('https://old.example.test/admin-api/infra/file/' + ITEM.configId + '/get/' + ITEM.path));
+ assert.ok(result.includes('https://older.example.test/admin-api/infra/file/' + ITEM.configId + '/get/' + encodePath(ITEM.path)));
+ assert.ok(result.includes(downloadUrl(sourceBase, ITEM.configId, ITEM.path)));
+});
+
+test('plus and space source files cannot claim each other through raw plus aliases', () => {
+ const domain = 'https://old.example.test';
+ const prefix = domain + '/admin-api/infra/file/1/get/';
+ const config = { oldDomains: [], downloadBaseUrl: sourceBase };
+ const plus = aliases({ configId: '1', path: 'a+b.png' }, [
+ { path: 'a+b.png', url: prefix + 'a+b.png' },
+ { path: 'a+b.png', url: prefix + 'a%2Bb.png' },
+ ], domain, config);
+ const space = aliases({ configId: '1', path: 'a b.png' }, [
+ { path: 'a b.png', url: prefix + 'a+b.png' },
+ ], domain, config);
+ assert.ok(!plus.includes(prefix + 'a+b.png'));
+ assert.ok(plus.includes(prefix + 'a%2Bb.png'));
+ assert.ok(space.includes(prefix + 'a+b.png'));
+ assert.ok(space.includes(prefix + 'a b.png'));
+ assert.ok(space.includes(prefix + 'a%20b.png'));
+ assert.ok(!space.includes(prefix + 'a%2Bb.png'));
+ assert.ok(space.includes(sourceBase + '/1/get/a+b.png'));
+ assert.deepEqual(plus.filter(url => space.includes(url)), []);
+});
+
+test('percent-encoded-looking filenames keep their meaning after exactly one route decode', () => {
+ const domain = 'https://old.example.test';
+ const prefix = domain + '/admin-api/infra/file/1/get/';
+ const config = { oldDomains: [] };
+ const literal = aliases({ configId: '1', path: 'a%2Fb.png' }, [
+ { path: 'a%2Fb.png', url: prefix + 'a%2Fb.png' },
+ ], domain, config);
+ const directory = aliases({ configId: '1', path: 'a/b.png' }, [
+ { path: 'a/b.png', url: prefix + 'a%2Fb.png' },
+ ], domain, config);
+ assert.ok(!literal.includes(prefix + 'a%2Fb.png'));
+ assert.ok(literal.includes(prefix + 'a%252Fb.png'));
+ assert.ok(directory.includes(prefix + 'a%2Fb.png'));
+ assert.ok(directory.includes(prefix + 'a/b.png'));
+ assert.deepEqual(literal.filter(url => directory.includes(url)), []);
+});
+
+test('raw query, fragment and unverifiable percent paths are excluded from source aliases', () => {
+ const domain = 'https://old.example.test';
+ const prefix = domain + '/admin-api/infra/file/1/get/';
+ for (const path of ['question?.png', 'fragment#.png', 'literal%.png', 'a%20b.png']) {
+ const result = aliases({ configId: '1', path }, [{ path, url: prefix + path }], domain, { oldDomains: [] });
+ assert.ok(!result.includes(prefix + path), path);
+ assert.ok(result.includes(prefix + encodePath(path)), path);
+ }
+});
+
+test('route validation accepts SQL-equivalent spellings and ignores genuine query parameters', () => {
+ const domain = 'https://old.example.test';
+ const prefix = domain + '/admin-api/infra/file/1/get/';
+ const alternate = 'photo a.JPG';
+ const result = aliases({ configId: '1', path: 'Photo A.jpg' }, [
+ { path: alternate, url: prefix + 'photo+a.JPG?download=1#preview' },
+ { path: alternate, url: domain + '/admin-api/infra/file/2/get/photo+a.JPG' },
+ { path: alternate, url: prefix + 'unrelated.png' },
+ ], domain, { oldDomains: [] });
+ assert.ok(result.includes(prefix + 'photo+a.JPG?download=1#preview'));
+ assert.ok(result.includes(prefix + 'photo+a.JPG'));
+ assert.ok(result.includes(prefix + 'Photo+A.jpg'));
+ assert.ok(!result.includes(domain + '/admin-api/infra/file/2/get/photo+a.JPG'));
+ assert.ok(!result.includes(prefix + 'unrelated.png'));
+});
+
+test('MIME detection prefers stored image type, then extension, then other stored type', () => {
+ assert.equal(mimeType('renamed.jpg', [{ type: 'image/png' }]), 'image/png');
+ assert.equal(mimeType('image.WEBP', []), 'image/webp');
+ assert.equal(mimeType('no-extension', [{ type: 'application/pdf' }]), 'application/pdf');
+ assert.equal(mimeType('no-extension', []), 'application/octet-stream');
+});
+
+test('SQL identifiers are quoted and error URLs are redacted', () => {
+ assert.equal(quote('strange\u0060name'), '\u0060strange\u0060\u0060name\u0060');
+ assert.equal(qualified('schema', 'table'), '\u0060schema\u0060.\u0060table\u0060');
+ const redacted = errorText(new Error('failed https://example.test/?secret=private afterward'));
+ assert.equal(redacted, 'failed [URL] afterward');
+ assert.ok(errorText(new Error('x'.repeat(500))).length <= 300);
+});
+
+test('HTTP download sends the intended encoded route and preserves binary bytes', async t => {
+ let observedUrl;
+ const base = await localServer(t, (request, response) => {
+ observedUrl = request.url;
+ response.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': IMAGE.length });
+ response.end(IMAGE);
+ });
+ const route = downloadUrl(base + '/admin-api/infra/file', ITEM.configId, ITEM.path);
+ const bytes = await readHttp(route, HTTP_OPTIONS, IMAGE.length);
+ assert.deepEqual(bytes, IMAGE);
+ assert.equal(observedUrl, '/admin-api/infra/file/' + ITEM.configId + '/get/' + encodePath(ITEM.path));
+ assert.doesNotThrow(() => verifyContent(bytes, ITEM));
+});
+
+test('HTTP errors and redirects are rejected rather than uploaded', async t => {
+ const base = await localServer(t, (request, response) => {
+ if (request.url === '/redirect') response.writeHead(302, { Location: '/image' });
+ else response.writeHead(404);
+ response.end();
+ });
+ await assert.rejects(readHttp(base + '/missing', HTTP_OPTIONS), /HTTP 404/);
+ await assert.rejects(readHttp(base + '/redirect', HTTP_OPTIONS));
+});
+
+test('HTTP 200 HTML or JSON with the same byte count fails hash verification', async t => {
+ const base = await localServer(t, (request, response) => {
+ const fake = Buffer.alloc(IMAGE.length, ' ');
+ fake.write(request.url === '/html' ? 'error' : '{"error":true}');
+ response.writeHead(200, { 'Content-Type': request.url === '/html' ? 'text/html' : 'application/json' });
+ response.end(fake);
+ });
+ for (const path of ['/html', '/json']) {
+ const bytes = await readHttp(base + path, HTTP_OPTIONS, IMAGE.length);
+ assert.equal(bytes.length, IMAGE.length);
+ assert.throws(() => verifyContent(bytes, ITEM), /SHA-256/);
+ }
+});
+
+test('short downloads fail expected length validation', async t => {
+ const base = await localServer(t, (_request, response) => response.end(IMAGE.subarray(0, 4)));
+ const bytes = await readHttp(base, HTTP_OPTIONS, IMAGE.length);
+ assert.throws(() => verifyContent(bytes, ITEM), /长度/);
+});
+
+test('HTTP declared length exceeding either cap is rejected', async t => {
+ const base = await localServer(t, (_request, response) => {
+ response.writeHead(200, { 'Content-Length': IMAGE.length });
+ response.end(IMAGE);
+ });
+ await assert.rejects(readHttp(base, { ...HTTP_OPTIONS, maxFileBytes: 4 }), /长度超过限制/);
+ await assert.rejects(readHttp(base, HTTP_OPTIONS, 4), /长度超过限制/);
+});
+
+test('chunked HTTP bodies cannot bypass actual byte limits', async t => {
+ const base = await localServer(t, (_request, response) => {
+ response.writeHead(200, { 'Transfer-Encoding': 'chunked' });
+ response.write(IMAGE.subarray(0, 8));
+ response.end(IMAGE.subarray(8));
+ });
+ await assert.rejects(readHttp(base, { ...HTTP_OPTIONS, maxFileBytes: 4 }), /长度超过限制/);
+ await assert.rejects(readHttp(base, HTTP_OPTIONS, 4), /长度超过限制/);
+});
+
+test('HTTP requests time out when the source never responds', async t => {
+ const base = await localServer(t, () => {});
+ await assert.rejects(readHttp(base, { ...HTTP_OPTIONS, timeoutMs: 30 }));
+});
+
+test('content verification checks both length and hash', () => {
+ assert.doesNotThrow(() => verifyContent(IMAGE, { ...ITEM, size: String(IMAGE.length) }));
+ assert.throws(() => verifyContent(IMAGE, { ...ITEM, size: IMAGE.length + 1 }), /长度/);
+ assert.throws(() => verifyContent(IMAGE, { ...ITEM, sha256: '0'.repeat(64) }), /SHA-256/);
+});
+
+test('existing identical OSS objects are read and verified without upload', async () => {
+ const oss = storedObject(IMAGE);
+ await ensureObject(oss, 'target/key.png', IMAGE, ITEM);
+ assert.equal(oss.put.mock.callCount(), 0);
+ assert.equal(oss.get.mock.callCount(), 1);
+ assert.equal(oss.get.mock.calls[0].arguments[0], 'target/key.png');
+});
+
+test('existing OSS objects with a different length are not overwritten', async () => {
+ const oss = storedObject(Buffer.from('wrong length'));
+ await assert.rejects(ensureObject(oss, 'target/key.png', IMAGE, ITEM), /禁止覆盖/);
+ assert.equal(oss.put.mock.callCount(), 0);
+ assert.equal(oss.get.mock.callCount(), 0);
+});
+
+test('existing same-length OSS objects with different bytes are not overwritten', async () => {
+ const oss = storedObject(Buffer.alloc(IMAGE.length, 1));
+ await assert.rejects(ensureObject(oss, 'target/key.png', IMAGE, ITEM), /SHA-256/);
+ assert.equal(oss.put.mock.callCount(), 0);
+});
+
+test('missing OSS objects upload exact bytes with MIME and overwrite prevention', async () => {
+ let saved;
+ const oss = {
+ head: mock.fn(async () => { if (!saved) throw noSuchKey(); return head(saved); }),
+ put: mock.fn(async (_key, bytes) => { saved = Buffer.from(bytes); }),
+ get: mock.fn(async () => ({ content: saved })),
+ };
+ await ensureObject(oss, 'target/key.png', IMAGE, ITEM);
+ assert.equal(oss.put.mock.callCount(), 1);
+ assert.deepEqual(oss.put.mock.calls[0].arguments, [
+ 'target/key.png', IMAGE, { headers: { 'Content-Type': 'image/png', 'x-oss-forbid-overwrite': 'true' } },
+ ]);
+ assert.deepEqual(saved, IMAGE);
+});
+
+test('OSS HEAD authorization failures do not cause a speculative upload', async () => {
+ const denied = Object.assign(new Error('denied'), { code: 'AccessDenied', status: 403 });
+ const oss = { head: mock.fn(async () => { throw denied; }), put: mock.fn(), get: mock.fn() };
+ await assert.rejects(ensureObject(oss, 'key', IMAGE, ITEM), error => error === denied);
+ assert.equal(oss.put.mock.callCount(), 0);
+});
+
+test('upload competition is accepted only after verifying the winner content', async () => {
+ for (const sameContent of [true, false]) {
+ const winner = sameContent ? IMAGE : Buffer.alloc(IMAGE.length, 2);
+ let appeared = false;
+ const oss = {
+ head: mock.fn(async () => { if (!appeared) throw noSuchKey(); return head(winner); }),
+ put: mock.fn(async () => {
+ appeared = true;
+ throw Object.assign(new Error('another upload won'), { code: 'FileAlreadyExists', status: 409 });
+ }),
+ get: mock.fn(async () => ({ content: winner })),
+ };
+ if (sameContent) await ensureObject(oss, 'key', IMAGE, ITEM);
+ else await assert.rejects(ensureObject(oss, 'key', IMAGE, ITEM), /SHA-256/);
+ assert.equal(oss.put.mock.callCount(), 1);
+ assert.equal(oss.get.mock.callCount(), 1);
+ }
+});
+
+test('failed or corrupted OSS uploads never report success', async () => {
+ const offline = Object.assign(new Error('offline'), { code: 'ConnectionTimeout' });
+ const failed = {
+ head: mock.fn(async () => { throw noSuchKey(); }),
+ put: mock.fn(async () => { throw offline; }), get: mock.fn(),
+ };
+ await assert.rejects(ensureObject(failed, 'key', IMAGE, ITEM), error => error === offline);
+ assert.equal(failed.get.mock.callCount(), 0);
+ let uploaded = false;
+ const corrupt = {
+ head: mock.fn(async () => { if (!uploaded) throw noSuchKey(); return head(IMAGE); }),
+ put: mock.fn(async () => { uploaded = true; }),
+ get: mock.fn(async () => ({ content: Buffer.alloc(IMAGE.length) })),
+ };
+ await assert.rejects(ensureObject(corrupt, 'key', IMAGE, ITEM), /SHA-256/);
+});
+
+test('retry stops on success and uses capped exponential waits', async () => {
+ let calls = 0;
+ const waits = [];
+ const value = await retry(async () => {
+ if (++calls < 7) throw new Error('transient');
+ return 'done';
+ }, 7, async ms => { waits.push(ms); });
+ assert.equal(value, 'done');
+ assert.equal(calls, 7);
+ assert.deepEqual(waits, [1000, 2000, 4000, 5000, 5000, 5000]);
+});
+
+test('retry preserves the last failure and never exceeds the configured attempt count', async () => {
+ let calls = 0;
+ const waits = [];
+ const failure = new Error('permanent');
+ await assert.rejects(retry(async () => { calls++; throw failure; }, 3, async ms => waits.push(ms)), error => error === failure);
+ assert.equal(calls, 3);
+ assert.deepEqual(waits, [1000, 2000]);
+ const first = mock.fn(async () => 'first');
+ const wait = mock.fn();
+ assert.equal(await retry(first, 3, wait), 'first');
+ assert.equal(first.mock.callCount(), 1);
+ assert.equal(wait.mock.callCount(), 0);
+});
diff --git a/script/migrate-db-files-to-oss/test/integration.test.mjs b/script/migrate-db-files-to-oss/test/integration.test.mjs
new file mode 100644
index 0000000..fbe8147
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/integration.test.mjs
@@ -0,0 +1,241 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createServer } from 'node:http';
+import { once } from 'node:events';
+import { encodePath, normalizeConfig } from '../lib/core.mjs';
+import { applyChange } from '../lib/journal.mjs';
+import { discoverStorageConfig } from '../lib/auto-storage.mjs';
+import { loadSourceIndex } from '../lib/source-index.mjs';
+import { loadSettings, runApply, runPlan, runRollback } from '../migrate.mjs';
+
+// Only point this at the disposable test MySQL container: this suite recreates
+// its dedicated codex_oss_migration_test schema, never the application database.
+test('MySQL migration, resume, exact reference replacement and rollback', {
+ skip: !process.env.MIGRATION_TEST_DB_PORT, timeout: 60000
+}, async t => {
+ const { default: mysql } = await import('mysql2/promise');
+ const db = await mysql.createConnection({ host: '127.0.0.1', port: Number(process.env.MIGRATION_TEST_DB_PORT), user: 'root', password: '', supportBigNumbers: true, bigNumberStrings: true, jsonStrings: true, dateStrings: true });
+ const objects = new Map();
+ let putCount = 0, failPut = false, sourceDownloads = 0;
+ const oss = {
+ async head(key) { if (!objects.has(key)) throw Object.assign(new Error('missing'), { code: 'NoSuchKey', status: 404 }); return { res: { headers: { 'content-length': String(objects.get(key).length) } } }; },
+ async get(key) { return { content: Buffer.from(objects.get(key)) }; },
+ async put(key, bytes, options) {
+ assert.equal(options.headers['x-oss-forbid-overwrite'], 'true');
+ if (failPut) throw Object.assign(new Error('test failure'), { code: 'TestOssUnavailable' });
+ assert.ok(!objects.has(key)); objects.set(key, Buffer.from(bytes)); putCount++;
+ }
+ };
+ const server = createServer(async (request, response) => {
+ try {
+ const url = new URL(request.url, 'http://localhost');
+ if (url.pathname.startsWith('/objects/')) {
+ const bytes = objects.get(decodeURIComponent(url.pathname.slice('/objects/'.length)));
+ if (!bytes) { response.writeHead(404).end(); return; }
+ response.end(bytes); return;
+ }
+ const prefix = '/admin-api/infra/file/5/get/';
+ if (url.pathname.startsWith(prefix)) {
+ sourceDownloads++;
+ const path = decodeURIComponent(url.pathname.slice(prefix.length).replaceAll('+', ' '));
+ const [[row]] = await db.execute('SELECT content FROM infra_file_content WHERE config_id=5 AND path=? AND deleted=0 ORDER BY id DESC LIMIT 1', [path]);
+ if (row) response.end(row.content); else response.writeHead(404).end();
+ return;
+ }
+ response.writeHead(404).end();
+ } catch { response.writeHead(500).end(); }
+ });
+ server.listen(0, '127.0.0.1'); await once(server, 'listening');
+ const origin = `http://127.0.0.1:${server.address().port}`;
+ const schema = 'codex_oss_migration_test';
+ let config;
+ const firstPath = '头像/张 三+100%.png';
+ const firstUrl = `${origin}/admin-api/infra/file/5/get/${encodePath(firstPath)}`;
+ const orphanUrl = `${origin}/admin-api/infra/file/5/get/orphan.png`;
+ const caseUrl = `${origin}/admin-api/infra/file/5/get/Case.png`;
+ const originals = [Buffer.from('first current image'), Buffer.from('second orphan image'), Buffer.from('case latest image')];
+ try {
+ await db.query(`DROP DATABASE IF EXISTS ${schema}`);
+ await db.query(`CREATE DATABASE ${schema} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
+ await db.changeUser({ database: schema });
+ await db.query('CREATE TABLE infra_file_config (id BIGINT PRIMARY KEY,storage INT,master BIT(1),config TEXT,deleted BIT(1) DEFAULT 0) ENGINE=InnoDB');
+ await db.query('CREATE TABLE infra_file_content (id BIGINT PRIMARY KEY,config_id BIGINT,path VARCHAR(512),content MEDIUMBLOB,deleted BIT(1) DEFAULT 0) ENGINE=InnoDB');
+ await db.query('CREATE TABLE infra_file (id BIGINT PRIMARY KEY,config_id BIGINT,path VARCHAR(512),url VARCHAR(1024),type VARCHAR(128),size INT,deleted BIT(1) DEFAULT 0,update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB');
+ await db.query('CREATE TABLE member_user (id BIGINT PRIMARY KEY,avatar VARCHAR(1024),wechat_qr_code VARCHAR(255),extra_json JSON,update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB');
+ await db.query('CREATE TABLE notes (id INT PRIMARY KEY,body TEXT) ENGINE=InnoDB');
+ await db.execute('INSERT INTO infra_file_config VALUES (5,1,0,?,0),(30,20,1,?,0)', [JSON.stringify({ domain: origin }), JSON.stringify({ endpoint: 'oss-cn-hangzhou.aliyuncs.com', bucket: 'test', accessKey: 'test-key', accessSecret: 'test-secret', domain: `${origin}/objects` })]);
+ const records = [
+ ['9007199254740992', 'Case.png', Buffer.from('case old image'), 0],
+ ['9007199254740993', firstPath, Buffer.from('first old image'), 0],
+ ['9007199254740994', firstPath, originals[0], 0],
+ ['9007199254740995', firstPath, Buffer.from('deleted newest'), 1],
+ ['9007199254740996', 'orphan.png', originals[1], 0],
+ ['9007199254740997', 'case.PNG', originals[2], 0]
+ ];
+ for (const [contentId, path, content, deleted] of records) await db.execute('INSERT INTO infra_file_content VALUES (?,5,?,?,?)', [contentId, path, content, deleted]);
+ for (const [fileId, path, url] of [['9007199254741001', firstPath, firstUrl], ['9007199254741002', 'Case.png', caseUrl], ['9007199254741003', 'case.PNG', caseUrl.replace('Case.png', 'case.PNG')]]) {
+ await db.execute("INSERT INTO infra_file (id,config_id,path,url,type,size,update_time) VALUES (?,5,?,?,'image/png',1,'2020-01-01 00:00:00')", [fileId, path, url]);
+ }
+ const payload = JSON.stringify({ a: [firstUrl, orphanUrl], nested: { image: caseUrl }, outside: 'https://outside.example/image.png' });
+ await db.execute("INSERT INTO member_user VALUES (9007199254741101,?,?,?,'2020-01-01 00:00:00')", [firstUrl, orphanUrl, payload]);
+ await db.execute('INSERT INTO notes VALUES (1,?)', [`
${orphanUrl},${caseUrl}`]);
+ config = normalizeConfig({ runId: 'integration', mysql: { database: schema, user: 'root' }, sourceConfigIds: ['5'], targetConfigId: '30', downloadBaseUrl: `${origin}/admin-api/infra/file`, batchSize: 1, retries: 1 });
+ const settings = await loadSettings(db, config);
+
+ await t.test('automatic discovery uses the existing database and unique OSS without configuration writes', async () => {
+ const automatic = await discoverStorageConfig(db, { mysql: config.mysql, _autoDiscovery: true, _projectDownloadBaseUrl: config.downloadBaseUrl });
+ assert.deepEqual(automatic.sourceConfigIds, ['5']); assert.equal(automatic.targetConfigId, '30');
+ assert.equal(automatic.sourceMode, 'database'); assert.ok(automatic.runId.startsWith('db-images-'));
+ assert.ok(automatic.oldDomains.includes(origin)); assert.equal(objects.size, 0);
+ const [tables] = await db.query("SHOW TABLES LIKE 'infra_file_oss_migration_%'"); assert.equal(tables.length, 0);
+ });
+
+ await t.test('plan is read-only and uses latest active content with bigint/collation semantics', async () => {
+ const statements = [];
+ const readOnlyDb = {
+ execute(sql, params) { statements.push(sql); return db.execute(sql, params); },
+ query(sql, params) { statements.push(sql); return db.query(sql, params); }
+ };
+ const plan = await runPlan(readOnlyDb, config, settings);
+ assert.equal(plan.status, 'planned'); assert.equal(plan.candidates, 3); assert.equal(plan.withoutFileRecord, 1);
+ assert.equal(plan.bytes, null); assert.equal(plan.estimatedBytes, 2); assert.equal(plan.sizePending, 1); assert.equal(plan.contentVerified, false);
+ assert.ok(statements.every(sql => /^\s*SELECT/i.test(sql)));
+ assert.ok(statements.every(sql => !/\b(?:SHA2|OCTET_LENGTH|content)\b|NOT EXISTS/i.test(sql)), 'plan must never scan image bytes or use per-file correlated subqueries');
+ assert.equal(plan.references.changedRows, 2); assert.equal(objects.size, 0);
+ const [rows] = await db.query("SHOW TABLES LIKE 'infra_file_oss_migration_%'"); assert.equal(rows.length, 0);
+ });
+
+ await t.test('metadata upper bound excludes newer versions without misclassifying their files as missing', async () => {
+ const index = await loadSourceIndex(db, config, { maxId: '9007199254740996' });
+ assert.equal(index.groups.length, 2); assert.equal(index.missingFiles.length, 0);
+ assert.equal(index.groups[0].path, firstPath); assert.equal(index.groups[1].path, 'orphan.png');
+ const full = await loadSourceIndex(db, config);
+ assert.deepEqual(full.groups[2].spellings.slice().sort(), ['Case.png', 'case.PNG'].sort());
+ assert.equal(full.groups[2].files.length, 2);
+ });
+
+ await t.test('limited trial, resume and repeated apply preserve originals and journals', async () => {
+ const trial = await runApply(db, config, settings, 1, () => oss);
+ assert.equal(trial.status, 'partial'); assert.equal(trial.items.switched, 1); assert.equal(objects.size, 1);
+ const resumedConfig = await discoverStorageConfig(db, { mysql: config.mysql, _autoDiscovery: true }, { command: 'apply' });
+ assert.equal(resumedConfig.runId, 'integration'); assert.deepEqual(resumedConfig.sourceConfigIds, ['5']);
+ assert.equal(resumedConfig.sourceMode, 'http'); assert.equal(resumedConfig.downloadBaseUrl, config.downloadBaseUrl);
+ assert.deepEqual((await loadSettings(db, resumedConfig)).safe, settings.safe);
+ // Simulate interruption after all remaining uploads reached verified but
+ // before their first file transaction. Resume must reuse the checkpoint.
+ const interruptedDb = {
+ execute: (...args) => db.execute(...args), query: (...args) => db.query(...args),
+ beginTransaction: async () => { throw new Error('simulated interruption before transition'); }
+ };
+ await assert.rejects(runApply(interruptedDb, config, settings, Infinity, () => oss), /simulated interruption/);
+ const [[checkpoints]] = await db.query("SELECT COUNT(*) AS n FROM infra_file_oss_migration_items WHERE run_id='integration' AND status='verified'");
+ assert.equal(Number(checkpoints.n), 2);
+ const downloadsBeforeResume = sourceDownloads;
+ const putsBeforeResume = putCount;
+ const done = await runApply(db, config, settings, Infinity, () => oss);
+ assert.equal(done.status, 'complete'); assert.equal(done.items.switched, 3); assert.equal(objects.size, 3); assert.equal(putCount, 3);
+ assert.equal(done.revalidated, 2); assert.equal(done.uploaded, 0);
+ assert.equal(sourceDownloads, downloadsBeforeResume); assert.equal(putCount, putsBeforeResume);
+ const [[member]] = await db.query('SELECT * FROM member_user');
+ assert.ok(member.avatar.startsWith(`${origin}/objects/`)); assert.ok(member.wechat_qr_code.startsWith(`${origin}/objects/`));
+ assert.deepEqual(Object.values(JSON.parse(member.extra_json).nested).map(url => url.startsWith(`${origin}/objects/`)), [true]);
+ assert.equal(JSON.parse(member.extra_json).outside, 'https://outside.example/image.png');
+ const [[counts]] = await db.query('SELECT COUNT(*) AS n FROM infra_file_content'); assert.equal(Number(counts.n), 6);
+ const [files] = await db.query('SELECT * FROM infra_file ORDER BY id');
+ assert.ok(files.every(file => file.config_id === '30'));
+ assert.equal(files[1].path, files[2].path); // case/accent-insensitive source lookup remains equivalent
+ assert.equal(files[0].size, originals[0].length); // stale old metadata is repaired
+ assert.ok(files.every(file => file.update_time === '2020-01-01 00:00:00'));
+ const [[before]] = await db.query('SELECT COUNT(*) AS n FROM infra_file_oss_migration_changes');
+ const repeated = await runApply(db, config, settings, Infinity, () => oss); assert.equal(repeated.status, 'complete');
+ const [[after]] = await db.query('SELECT COUNT(*) AS n FROM infra_file_oss_migration_changes'); assert.equal(before.n, after.n); assert.equal(putCount, 3);
+ const [[entry]] = await db.query("SELECT data FROM infra_file_oss_migration_items WHERE JSON_UNQUOTE(JSON_EXTRACT(data,'$.path')) LIKE '%100%.png'");
+ assert.ok(JSON.parse(entry.data).aliases.includes(firstUrl)); // logs must never rewrite themselves
+ });
+
+ await t.test('resume does not rewrite new references using a deleted OSS object', async () => {
+ const [[entry]] = await db.query("SELECT data FROM infra_file_oss_migration_items WHERE JSON_UNQUOTE(JSON_EXTRACT(data,'$.path')) LIKE '%100%.png'");
+ const item = JSON.parse(entry.data), bytes = objects.get(item.key);
+ objects.delete(item.key);
+ await db.execute('INSERT INTO notes VALUES (2,?)', [firstUrl]);
+ const result = await runApply(db, config, settings, Infinity, () => oss);
+ assert.equal(result.status, 'partial'); assert.equal(result.failed, 1);
+ const [[note]] = await db.query('SELECT body FROM notes WHERE id=2'); assert.equal(note.body, firstUrl);
+ objects.set(item.key, bytes); await db.query('DELETE FROM notes WHERE id=2');
+ });
+
+ await t.test('rollback preserves later user edits and can resume after conflict resolution', async () => {
+ const [[migrated]] = await db.query('SELECT avatar FROM member_user');
+ await db.execute('UPDATE member_user SET avatar=?', ['https://user-new.example/avatar.png']);
+ const rollback = await runRollback(db, config); assert.equal(rollback.status, 'rollback_partial'); assert.ok(rollback.conflicts.length > 0);
+ const [[current]] = await db.query('SELECT avatar FROM member_user'); assert.equal(current.avatar, 'https://user-new.example/avatar.png');
+ await db.execute('UPDATE member_user SET avatar=?', [migrated.avatar]);
+ const resumed = await runRollback(db, config); assert.equal(resumed.status, 'rolled_back');
+ const [[member]] = await db.query('SELECT * FROM member_user'); assert.equal(member.avatar, firstUrl); assert.equal(member.wechat_qr_code, orphanUrl); assert.deepEqual(JSON.parse(member.extra_json), JSON.parse(payload));
+ const [files] = await db.query('SELECT * FROM infra_file'); assert.ok(files.every(file => file.config_id === '5')); assert.equal(objects.size, 3);
+ await assert.rejects(runApply(db, config, settings, Infinity, () => oss), /回滚/);
+ });
+
+ await t.test('failed uploads persist a partial state and never switch database addresses', async () => {
+ const failureConfig = { ...config, runId: 'upload-failure', objectPrefix: 'migration/failure' };
+ const failureSettings = await loadSettings(db, failureConfig); failPut = true;
+ const result = await runApply(db, failureConfig, failureSettings, Infinity, () => oss); failPut = false;
+ assert.equal(result.status, 'partial'); assert.equal(result.items.failed, 3);
+ const [[run]] = await db.query("SELECT status FROM infra_file_oss_migration_runs WHERE run_id='upload-failure'"); assert.equal(run.status, 'partial');
+ const [[member]] = await db.query('SELECT avatar FROM member_user'); assert.equal(member.avatar, firstUrl);
+ });
+
+ await t.test('non-strict MySQL truncation warnings roll back both data and journal', async () => {
+ await db.query('CREATE TABLE short_url (id INT PRIMARY KEY,url VARCHAR(8)) ENGINE=InnoDB');
+ await db.query("INSERT INTO short_url VALUES (1,'old')");
+ const [[mode]] = await db.query('SELECT @@SESSION.sql_mode AS value');
+ await db.query("SET SESSION sql_mode=''"); await db.beginTransaction();
+ await assert.rejects(applyChange(db, schema, 'warning', { schema, table: 'short_url', primaryKey: 'id', pkValue: 1, before: { url: 'old' }, after: { url: 'this-would-be-truncated' } }), /警告/);
+ await db.rollback(); await db.execute('SET SESSION sql_mode=?', [mode.value]);
+ const [[row]] = await db.query('SELECT url FROM short_url'); assert.equal(row.url, 'old');
+ const [[count]] = await db.query("SELECT COUNT(*) AS n FROM infra_file_oss_migration_changes WHERE run_id='warning'"); assert.equal(Number(count.n), 0);
+ });
+
+ await t.test('batch CAS conflict isolates the edited file and commits unrelated verified items', async () => {
+ const conflictConfig = { ...config, runId: 'batch-conflict', objectPrefix: 'migration/conflict' };
+ const conflictSettings = await loadSettings(db, conflictConfig);
+ let changed = false, rollbacks = 0;
+ const concurrentEditDb = {
+ query: (...args) => db.query(...args), execute: (...args) => db.execute(...args),
+ async beginTransaction() {
+ if (!changed) {
+ changed = true;
+ await db.execute('UPDATE infra_file SET url=? WHERE id=?', ['https://user-change.example/new.png', '9007199254741001']);
+ }
+ return db.beginTransaction();
+ },
+ commit: () => db.commit(),
+ rollback() { rollbacks++; return db.rollback(); }
+ };
+ const result = await runApply(concurrentEditDb, conflictConfig, conflictSettings, Infinity, () => oss);
+ assert.equal(result.status, 'partial'); assert.equal(result.failed, 1);
+ assert.equal(result.items.switched, 2); assert.equal(result.items.verified, 1);
+ assert.ok(rollbacks >= 2, 'the batch and the conflicted single item are rolled back');
+ const [[edited]] = await db.query('SELECT config_id,url FROM infra_file WHERE id=9007199254741001');
+ assert.equal(edited.config_id, '5'); assert.equal(edited.url, 'https://user-change.example/new.png');
+ const [[member]] = await db.query('SELECT avatar FROM member_user'); assert.equal(member.avatar, firstUrl);
+ const rollback = await runRollback(db, conflictConfig); assert.equal(rollback.status, 'rolled_back');
+ await db.execute('UPDATE infra_file SET url=? WHERE id=9007199254741001', [firstUrl]);
+ });
+
+ await t.test('database-source mode works and ambiguous raw-plus links require review', async () => {
+ const rawUrl = `${origin}/admin-api/infra/file/5/get/${firstPath}`;
+ await db.execute('INSERT INTO notes VALUES (3,?)', [rawUrl]);
+ const directConfig = { ...config, runId: 'database-mode', sourceMode: 'database' };
+ const directSettings = await loadSettings(db, directConfig);
+ const result = await runApply(db, directConfig, directSettings, Infinity, () => oss);
+ assert.equal(result.status, 'needs-review'); assert.equal(result.items.switched, 3);
+ const [[note]] = await db.query('SELECT body FROM notes WHERE id=3'); assert.equal(note.body, rawUrl);
+ assert.ok(result.references.unresolvedRows > 0);
+ });
+ } finally {
+ server.close(); server.closeAllConnections();
+ await db.query(`DROP DATABASE IF EXISTS ${schema}`);
+ await db.end();
+ }
+});
diff --git a/script/migrate-db-files-to-oss/test/journal-batch.test.mjs b/script/migrate-db-files-to-oss/test/journal-batch.test.mjs
new file mode 100644
index 0000000..316a75d
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/journal-batch.test.mjs
@@ -0,0 +1,311 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyChanges } from '../lib/journal.mjs';
+
+const clone = value => Buffer.isBuffer(value) ? Buffer.from(value)
+ : Array.isArray(value) ? value.map(clone)
+ : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)])) : value;
+const key = value => Buffer.isBuffer(value) ? `buffer:${value.toString('hex')}` : String(value);
+const same = (left, right) => Buffer.isBuffer(left) || Buffer.isBuffer(right)
+ ? Buffer.isBuffer(left) && Buffer.isBuffer(right) && left.equals(right)
+ : left === null || right === null ? left === right : String(left) === String(right);
+
+const makeRow = (id, suffix) => ({
+ id, config_id: '4', path: `${suffix}.png`, url: `https://old/${suffix}.png`, type: 'image/png', size: 5,
+ deleted: Buffer.from([0]), update_time: 'unchanged', touched: 'unchanged'
+});
+
+// This small SQL test double evaluates the CASE dispatch and every WHERE guard
+// independently, so mixed per-row values and partially matched batches surface.
+class BatchConnection {
+ constructor(rows = [makeRow('9007199254740993', 'a'), makeRow('9007199254740994', 'b')]) {
+ this.rows = Object.fromEntries(rows.map(row => [key(row.id), clone(row)]));
+ this.calls = [];
+ this.journals = [];
+ this.warningStatus = 0;
+ this.failJournal = false;
+ this.journalWarningStatus = 0;
+ this.journalAffectedRows = null;
+ this.engine = 'InnoDB';
+ this.normalize = null;
+ this.omitReadback = false;
+ this.reverseReadback = false;
+ this.nextId = 9007199254741993n;
+ }
+
+ async beginTransaction() {
+ this.calls.push({ transaction: 'begin' });
+ this.saved = { rows: clone(this.rows), journals: clone(this.journals) };
+ }
+ async commit() { this.calls.push({ transaction: 'commit' }); this.saved = undefined; }
+ async rollback() {
+ this.calls.push({ transaction: 'rollback' });
+ if (this.saved) { this.rows = this.saved.rows; this.journals = this.saved.journals; this.saved = undefined; }
+ }
+
+ async execute(sql, params = []) {
+ this.calls.push({ sql, params });
+ if (sql.includes('FROM information_schema.COLUMNS')) {
+ return [[...['id', 'config_id', 'path', 'url', 'type', 'size', 'deleted', 'json_data', 'counter', 'update_time', 'touched']
+ .map(name => ({ name, engine: this.engine, extra: ['update_time', 'touched'].includes(name) ? 'on update CURRENT_TIMESTAMP' : '' }))]];
+ }
+ if (sql.startsWith('UPDATE')) {
+ const [setSql, whereSql] = sql.split(/\bWHERE\b/);
+ const cases = [...setSql.matchAll(/`([^`]+)`\s*=\s*CASE\s+`id`([\s\S]*?)\bEND/g)];
+ assert.ok(cases.length > 0, 'the batch must use a CASE expression for changed columns');
+ let offset = 0;
+ const values = new Map();
+ for (const [, column, branches] of cases) {
+ const count = [...branches.matchAll(/\bWHEN\s+\?\s+THEN\s+\?/g)].length;
+ assert.ok(count > 0);
+ assert.match(branches, new RegExp('ELSE\\s+`' + column + '`'));
+ const entries = new Map();
+ for (let index = 0; index < count; index++) entries.set(key(params[offset++]), params[offset++]);
+ values.set(column, entries);
+ }
+ let affectedRows = 0;
+ const groups = whereSql.trim().replace(/^\(/, '').replace(/\)$/, '').split(/\)\s+OR\s+\(/);
+ for (const group of groups) {
+ assert.match(group, /`id`\s*=\s*\?/);
+ const pk = key(params[offset++]);
+ const guards = [...group.matchAll(/CAST\(`([^`]+)` AS BINARY\)\s*<=>\s*CAST\(\? AS BINARY\)/g)].map(match => match[1]);
+ const expected = guards.map(() => params[offset++]);
+ const row = this.rows[pk];
+ if (!row || !guards.every((name, index) => same(row[name], expected[index]))) continue;
+ for (const [column, entries] of values) if (entries.has(pk)) row[column] = entries.get(pk);
+ if (this.normalize) this.normalize(row, [...values.keys()]);
+ if (!setSql.includes('`update_time` = `update_time`')) row.update_time = 'changed';
+ if (!setSql.includes('`touched` = `touched`')) row.touched = 'changed';
+ affectedRows++;
+ }
+ assert.equal(offset, params.length, 'all prepared UPDATE values must have a matching SQL placeholder');
+ return [{ affectedRows, warningStatus: this.warningStatus }];
+ }
+ if (sql.startsWith('SELECT `')) {
+ assert.match(sql, /\bIN\s*\(/);
+ assert.match(sql, /FOR UPDATE/);
+ const selected = [...sql.split(/\bFROM\b/)[0].matchAll(/`([^`]+)`/g)].map(match => match[1]);
+ const rows = params.map(pk => this.rows[key(pk)]).filter(Boolean)
+ .map(row => Object.fromEntries(selected.map(column => [column, row[column]])));
+ if (this.reverseReadback) rows.reverse();
+ return [this.omitReadback ? rows.slice(1) : rows];
+ }
+ if (sql.startsWith('INSERT INTO')) {
+ if (this.failJournal) throw Object.assign(new Error('injected journal INSERT failure'), { code: 'TEST_JOURNAL_FAILURE' });
+ assert.equal(params.length % 7, 0);
+ const firstId = this.nextId.toString();
+ for (let offset = 0; offset < params.length; offset += 7) {
+ const [run_id, schema_name, table_name, pk_name, pk_value, before_json, after_json] = params.slice(offset, offset + 7);
+ this.journals.push({ id: (this.nextId++).toString(), run_id, schema_name, table_name, pk_name, pk_value, before_json, after_json, rolled_back: 0 });
+ }
+ return [{ affectedRows: this.journalAffectedRows ?? params.length / 7, warningStatus: this.journalWarningStatus, insertId: firstId }];
+ }
+ throw new Error(`Unexpected test SQL: ${sql}`);
+ }
+}
+
+function change(row, suffix = 'migrated') {
+ const { config_id, path, url, type, size, deleted } = row;
+ return {
+ schema: 'app', table: 'infra_file', primaryKey: 'id', pkValue: row.id,
+ before: { config_id, path, url, type, size, deleted },
+ after: { config_id: '20', path: `${suffix}/${path}`, url: `https://oss/${suffix}/${path}`, type, size }
+ };
+}
+
+const changesFor = connection => Object.values(connection.rows).map(row => change(row));
+const dml = connection => connection.calls.filter(call => call.sql && !call.sql.includes('information_schema'));
+
+async function commitBatch(connection, changes) {
+ await connection.beginTransaction();
+ try {
+ const result = await applyChanges(connection, 'app', 'run', changes);
+ await connection.commit();
+ return result;
+ } catch (error) { await connection.rollback(); throw error; }
+}
+
+test('batch uses one CASE update, one readback and one journal insert for distinct bigint rows', async () => {
+ const connection = new BatchConnection();
+ const changes = changesFor(connection);
+ assert.deepEqual(await commitBatch(connection, changes), { changed: 2, skipped: 0 });
+ assert.deepEqual(dml(connection).map(call => call.sql.split(/\s/)[0]), ['UPDATE', 'SELECT', 'INSERT']);
+ assert.equal(connection.rows['9007199254740993'].url, 'https://oss/migrated/a.png');
+ assert.equal(connection.rows['9007199254740994'].url, 'https://oss/migrated/b.png');
+ assert.equal(connection.rows['9007199254740993'].update_time, 'unchanged');
+ assert.equal(connection.rows['9007199254740994'].touched, 'unchanged');
+ assert.equal(connection.journals.length, 2);
+ assert.deepEqual(connection.journals.map(row => JSON.parse(row.pk_value).value), ['9007199254740993', '9007199254740994']);
+ const [update] = dml(connection);
+ assert.match(update.sql, /CAST\(`deleted` AS BINARY\) <=> CAST\(\? AS BINARY\)/);
+ assert.ok(update.params.some(value => Buffer.isBuffer(value) && value.equals(Buffer.from([0]))));
+ assert.ok(!update.sql.includes('https://oss/'), 'URLs must be bound, not interpolated');
+ assert.ok(!update.sql.includes('`deleted` = CASE'), 'before-only deletion guards must not become assignments');
+});
+
+test('table metadata is cached across batches on the same connection', async () => {
+ const connection = new BatchConnection();
+ await commitBatch(connection, changesFor(connection));
+ await commitBatch(connection, Object.values(connection.rows).map(row => change(row, 'second')));
+ assert.equal(connection.calls.filter(call => call.sql?.includes('information_schema')).length, 1);
+ assert.equal(dml(connection).length, 6);
+});
+
+test('empty batches and unchanged rows create no updates or journal entries', async () => {
+ const connection = new BatchConnection();
+ assert.deepEqual(await commitBatch(connection, []), { changed: 0, skipped: 0 });
+ const changes = changesFor(connection);
+ changes.forEach(item => { item.after = Object.fromEntries(Object.keys(item.after).map(name => [name, item.before[name]])); });
+ assert.deepEqual(await commitBatch(connection, changes), { changed: 0, skipped: 2 });
+ assert.equal(dml(connection).length, 0);
+ assert.equal(connection.journals.length, 0);
+});
+
+test('a no-op row is skipped while a different row receives its own values', async () => {
+ const connection = new BatchConnection();
+ const changes = changesFor(connection);
+ changes[1].after = Object.fromEntries(Object.keys(changes[1].after).map(name => [name, changes[1].before[name]]));
+ assert.deepEqual(await commitBatch(connection, changes), { changed: 1, skipped: 1 });
+ assert.equal(connection.rows['9007199254740993'].url, 'https://oss/migrated/a.png');
+ assert.equal(connection.rows['9007199254740994'].url, 'https://old/b.png');
+ assert.equal(connection.journals.length, 1);
+});
+
+test('after field order may differ and row-specific unchanged fields stay unchanged', async () => {
+ const connection = new BatchConnection();
+ const changes = changesFor(connection);
+ changes[0].after.type = 'image/webp';
+ changes[1].after = Object.fromEntries(Object.entries(changes[1].after).reverse());
+ await commitBatch(connection, changes);
+ assert.equal(connection.rows['9007199254740993'].type, 'image/webp');
+ assert.equal(connection.rows['9007199254740994'].type, 'image/png');
+});
+
+test('different schema, table, primary key, or after-field groups are rejected before updates', async () => {
+ for (const mutate of [
+ item => { item.schema = 'other'; },
+ item => { item.table = 'member_user'; },
+ item => { item.primaryKey = 'other_id'; },
+ item => { delete item.after.size; }
+ ]) {
+ const connection = new BatchConnection();
+ const changes = changesFor(connection);
+ mutate(changes[1]);
+ await assert.rejects(commitBatch(connection, changes));
+ assert.equal(dml(connection).length, 0);
+ }
+});
+
+test('duplicate primary keys are rejected even across string and bigint representations', async () => {
+ const connection = new BatchConnection();
+ const changes = changesFor(connection);
+ changes[1].pkValue = BigInt(changes[0].pkValue);
+ await assert.rejects(commitBatch(connection, changes), /主键|重复/);
+ assert.equal(dml(connection).length, 0);
+});
+
+test('primary key updates and missing before snapshots are rejected', async () => {
+ for (const mutate of [
+ item => { item.after.id = '1'; },
+ item => { delete item.before.url; }
+ ]) {
+ const connection = new BatchConnection();
+ const changes = changesFor(connection);
+ changes.forEach(mutate);
+ await assert.rejects(commitBatch(connection, changes));
+ assert.equal(dml(connection).length, 0);
+ }
+});
+
+test('a concurrent case-only edit leaves the entire batch unchanged after caller rollback', async () => {
+ const connection = new BatchConnection();
+ const changes = changesFor(connection);
+ connection.rows['9007199254740994'].url = 'https://old/B.png';
+ const before = clone(connection.rows);
+ await assert.rejects(commitBatch(connection, changes), { code: 'MIGRATION_CAS_CONFLICT' });
+ assert.deepEqual(connection.rows, before);
+ assert.equal(connection.journals.length, 0);
+ assert.equal(dml(connection).length, 1);
+ assert.equal(connection.calls.at(-1).transaction, 'rollback');
+});
+
+test('a deletion guard conflict rolls back otherwise matching rows', async () => {
+ const connection = new BatchConnection();
+ const changes = changesFor(connection);
+ connection.rows['9007199254740994'].deleted = Buffer.from([1]);
+ const before = clone(connection.rows);
+ await assert.rejects(commitBatch(connection, changes), { code: 'MIGRATION_CAS_CONFLICT' });
+ assert.deepEqual(connection.rows, before);
+});
+
+test('journal INSERT failure rolls back all successfully updated rows', async () => {
+ const connection = new BatchConnection();
+ const changes = changesFor(connection);
+ const before = clone(connection.rows);
+ connection.failJournal = true;
+ await assert.rejects(commitBatch(connection, changes), { code: 'TEST_JOURNAL_FAILURE' });
+ assert.deepEqual(connection.rows, before);
+ assert.equal(connection.journals.length, 0);
+ assert.equal(dml(connection).length, 3);
+});
+
+test('MySQL JSON canonicalization and numeric conversion are read back into each journal entry', async () => {
+ const connection = new BatchConnection();
+ connection.reverseReadback = true;
+ for (const row of Object.values(connection.rows)) {
+ row.json_data = JSON.stringify({ url: row.url }).replaceAll('\":', '\": ');
+ row.counter = 1;
+ }
+ const changes = Object.values(connection.rows).map(row => ({
+ schema: 'app', table: 'infra_file', primaryKey: 'id', pkValue: row.id,
+ before: { json_data: row.json_data, counter: 1 },
+ after: { json_data: JSON.stringify({ url: `https://oss/${row.path}` }), counter: '2' }
+ }));
+ connection.normalize = (row, fields) => {
+ if (fields.includes('json_data')) row.json_data = JSON.stringify(JSON.parse(row.json_data)).replaceAll('\":', '\": ');
+ if (fields.includes('counter')) row.counter = Number(row.counter);
+ };
+ await commitBatch(connection, changes);
+ const snapshots = connection.journals.map(row => JSON.parse(row.after_json));
+ assert.equal(snapshots[0].json_data.value, '{"url": "https://oss/a.png"}');
+ assert.equal(snapshots[1].json_data.value, '{"url": "https://oss/b.png"}');
+ assert.equal(snapshots[0].counter.value, 2);
+ assert.equal(snapshots[1].counter.value, 2);
+});
+
+test('UPDATE warnings and incomplete readback each abort the entire batch', async () => {
+ for (const prepare of [
+ connection => { connection.warningStatus = 1; },
+ connection => { connection.omitReadback = true; }
+ ]) {
+ const connection = new BatchConnection();
+ const changes = changesFor(connection);
+ const before = clone(connection.rows);
+ prepare(connection);
+ await assert.rejects(commitBatch(connection, changes));
+ assert.deepEqual(connection.rows, before);
+ assert.equal(connection.journals.length, 0);
+ }
+});
+
+test('incomplete or warned batch journal inserts roll back both updated rows and inserted logs', async () => {
+ for (const prepare of [
+ connection => { connection.journalAffectedRows = 1; },
+ connection => { connection.journalWarningStatus = 1; }
+ ]) {
+ const connection = new BatchConnection();
+ const changes = changesFor(connection);
+ const before = clone(connection.rows);
+ prepare(connection);
+ await assert.rejects(commitBatch(connection, changes), /回滚日志/);
+ assert.deepEqual(connection.rows, before);
+ assert.equal(connection.journals.length, 0);
+ }
+});
+
+test('non-InnoDB targets are rejected before mutation', async () => {
+ const connection = new BatchConnection();
+ connection.engine = 'MyISAM';
+ await assert.rejects(commitBatch(connection, changesFor(connection)), /InnoDB/);
+ assert.equal(dml(connection).length, 0);
+});
diff --git a/script/migrate-db-files-to-oss/test/journal.test.mjs b/script/migrate-db-files-to-oss/test/journal.test.mjs
new file mode 100644
index 0000000..87bf350
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/journal.test.mjs
@@ -0,0 +1,238 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { ensureJournal, applyChange, rollbackChanges } from '../lib/journal.mjs';
+
+const clone = value => Buffer.isBuffer(value) ? Buffer.from(value)
+ : Array.isArray(value) ? value.map(clone)
+ : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)])) : value;
+const same = (a, b) => Buffer.isBuffer(a) && Buffer.isBuffer(b) ? a.equals(b) : a === b || (a !== null && b !== null && String(a) === String(b));
+const names = sql => [...sql.matchAll(/CAST\(`((?:``|[^`])+)` AS BINARY\)/g)].map(match => match[1].replaceAll('``', '`'));
+
+class MemoryConnection {
+ constructor() {
+ this.calls = [];
+ this.rows = { '9007199254740993': { id: '9007199254740993', avatar: 'https://old/a', nullable: null, deleted: Buffer.from([0]), update_time: 'unchanged', touched: 'unchanged' } };
+ this.journals = [];
+ this.nextId = 9007199254740993n;
+ this.engine = 'InnoDB';
+ this.failJournal = false;
+ this.normalizeUpdate = null;
+ this.warningStatus = 0;
+ }
+
+ async beginTransaction() { this.calls.push({ transaction: 'begin' }); this.saved = { rows: clone(this.rows), journals: clone(this.journals) }; }
+ async commit() { this.calls.push({ transaction: 'commit' }); this.saved = undefined; }
+ async rollback() { this.calls.push({ transaction: 'rollback' }); if (this.saved) { this.rows = this.saved.rows; this.journals = this.saved.journals; this.saved = undefined; } }
+
+ async execute(sql, params = []) {
+ this.calls.push({ sql, params });
+ if (sql.startsWith('CREATE TABLE')) return [{ affectedRows: 0 }];
+ if (sql.includes('FROM information_schema.COLUMNS')) {
+ return [[...['id', 'avatar', 'nullable', 'deleted', 'update_time', 'touched', 'json_data', 'counter'].map(name => ({ name, engine: this.engine, extra: ['update_time', 'touched'].includes(name) ? 'on update CURRENT_TIMESTAMP' : '' }))]];
+ }
+ if (sql.startsWith('INSERT INTO')) {
+ if (this.failJournal) throw Object.assign(new Error('journal unavailable'), { code: 'TEST_JOURNAL_FAILURE' });
+ const [run_id, schema_name, table_name, pk_name, pk_value, before_json, after_json] = params;
+ const id = (this.nextId++).toString();
+ this.journals.push({ id, run_id, schema_name, table_name, pk_name, pk_value, before_json, after_json, rolled_back: 0 });
+ return [{ insertId: id }];
+ }
+ if (sql.startsWith('SELECT CAST(id AS CHAR)')) {
+ const [runId, cursor] = params;
+ return [this.journals.filter(row => row.run_id === runId && row.rolled_back === 0 && (cursor === undefined || BigInt(row.id) < BigInt(cursor)))
+ .sort((a, b) => BigInt(a.id) > BigInt(b.id) ? -1 : 1).slice(0, 100).map(row => ({ id: row.id }))];
+ }
+ if (sql.startsWith('SELECT *')) return [[this.journals.find(row => row.id === params[0] && row.run_id === params[1])].filter(Boolean)];
+ if (sql.startsWith('SELECT 1 AS matched')) {
+ const row = this.rows[String(params[0])];
+ return [row && names(sql).every((name, index) => same(row[name], params[index + 1])) ? [{ matched: 1 }] : []];
+ }
+ if (sql.startsWith('SELECT `')) {
+ const row = this.rows[String(params[0])];
+ const selected = [...sql.split(' FROM ')[0].matchAll(/`((?:``|[^`])+)`/g)].map(match => match[1]);
+ return [row ? [Object.fromEntries(selected.map(name => [name, row[name]]))] : []];
+ }
+ if (sql.startsWith('UPDATE') && sql.includes('SET rolled_back = 1')) {
+ const row = this.journals.find(row => row.id === params[0] && row.run_id === params[1]);
+ if (row) row.rolled_back = 1;
+ return [{ affectedRows: row ? 1 : 0 }];
+ }
+ if (sql.startsWith('UPDATE')) {
+ const set = sql.split(' SET ')[1].split('WHERE')[0];
+ const changed = [...set.matchAll(/`((?:``|[^`])+)` = \?/g)].map(match => match[1]);
+ const row = this.rows[String(params[changed.length])];
+ if (!row || !names(sql).every((name, index) => same(row[name], params[changed.length + 1 + index]))) return [{ affectedRows: 0 }];
+ changed.forEach((name, index) => { row[name] = params[index]; });
+ if (this.normalizeUpdate) this.normalizeUpdate(row, changed);
+ if (!set.includes('`update_time` = `update_time`')) row.update_time = 'changed';
+ if (!set.includes('`touched` = `touched`')) row.touched = 'changed';
+ return [{ affectedRows: 1, warningStatus: this.warningStatus }];
+ }
+ throw new Error(`Unexpected test query: ${sql}`);
+ }
+}
+
+const change = (before = 'https://old/a', after = 'https://oss/a') => ({
+ schema: 'app', table: 'member_user', primaryKey: 'id', pkValue: '9007199254740993',
+ before: { avatar: before, deleted: Buffer.from([0]), nullable: null }, after: { avatar: after }
+});
+
+async function commitChange(connection, payload = change(), runId = 'run') {
+ await connection.beginTransaction();
+ try {
+ const result = await applyChange(connection, 'app', runId, payload);
+ await connection.commit();
+ return result;
+ } catch (error) { await connection.rollback(); throw error; }
+}
+
+test('journal schema uses InnoDB and independent run/item/change records', async () => {
+ const connection = new MemoryConnection();
+ await ensureJournal(connection, 'app');
+ const creates = connection.calls.filter(call => call.sql?.startsWith('CREATE TABLE'));
+ assert.equal(creates.length, 3);
+ assert.ok(creates.every(call => call.sql.includes('ENGINE=InnoDB')));
+ assert.match(creates[1].sql, /PRIMARY KEY \(run_id, item_key\)/);
+ assert.match(creates[2].sql, /KEY ix_run_id_id \(run_id, id\)/);
+});
+
+test('apply logs original values with binary null-safe guards and lossless bigint IDs', async () => {
+ const connection = new MemoryConnection();
+ const result = await commitChange(connection);
+ assert.equal(result.journalId, '9007199254740993');
+ assert.equal(connection.rows['9007199254740993'].avatar, 'https://oss/a');
+ assert.equal(connection.rows['9007199254740993'].update_time, 'unchanged');
+ assert.equal(connection.rows['9007199254740993'].touched, 'unchanged');
+ const update = connection.calls.find(call => call.sql?.startsWith('UPDATE'));
+ assert.match(update.sql, /CAST\(`avatar` AS BINARY\) <=> CAST\(\? AS BINARY\)/);
+ assert.match(update.sql, /CAST\(`nullable` AS BINARY\) <=> CAST\(\? AS BINARY\)/);
+ assert.equal(update.params[1], '9007199254740993');
+ assert.deepEqual(update.params[3], Buffer.from([0]));
+ assert.equal(update.params[4], null);
+ assert.equal(connection.journals.length, 1);
+});
+
+test('no-op changes do not update rows or create logs', async () => {
+ const connection = new MemoryConnection();
+ assert.deepEqual(await commitChange(connection, change('https://old/a', 'https://old/a')), { changed: false });
+ assert.equal(connection.journals.length, 0);
+ assert.equal(connection.calls.filter(call => call.sql?.startsWith('UPDATE')).length, 0);
+});
+
+test('primary-key changes and missing snapshots are rejected before mutation', async () => {
+ const connection = new MemoryConnection();
+ await assert.rejects(commitChange(connection, { ...change(), after: { id: '1' } }), /不能修改主键/);
+ await assert.rejects(commitChange(connection, { ...change(), before: { deleted: Buffer.from([0]) } }), /缺少原值快照/);
+ assert.equal(connection.journals.length, 0);
+});
+
+test('concurrent edits including case-only changes fail CAS and preserve the row', async () => {
+ const connection = new MemoryConnection();
+ connection.rows['9007199254740993'].avatar = 'https://old/A';
+ await assert.rejects(commitChange(connection), { code: 'MIGRATION_CAS_CONFLICT' });
+ assert.equal(connection.rows['9007199254740993'].avatar, 'https://old/A');
+ assert.equal(connection.journals.length, 0);
+});
+
+test('a journal INSERT failure is rolled back with the row by the caller transaction', async () => {
+ const connection = new MemoryConnection();
+ connection.failJournal = true;
+ await assert.rejects(commitChange(connection), { code: 'TEST_JOURNAL_FAILURE' });
+ assert.equal(connection.rows['9007199254740993'].avatar, 'https://old/a');
+ assert.equal(connection.journals.length, 0);
+ assert.equal(connection.calls.at(-1).transaction, 'rollback');
+});
+
+test('journal reads back native JSON canonicalization and numeric conversions before recording rollback values', async () => {
+ const connection = new MemoryConnection();
+ connection.rows['9007199254740993'].json_data = '{"avatar": "https://old/a"}';
+ connection.rows['9007199254740993'].counter = 1;
+ connection.normalizeUpdate = (row, changed) => {
+ if (changed.includes('json_data')) row.json_data = JSON.stringify(JSON.parse(row.json_data)).replaceAll('\":', '\": ');
+ if (changed.includes('counter')) row.counter = Number(row.counter);
+ };
+ await commitChange(connection, {
+ ...change(),
+ before: { json_data: '{"avatar": "https://old/a"}', counter: 1 },
+ after: { json_data: '{"avatar":"https://oss/a"}', counter: '2' }
+ });
+ const after = JSON.parse(connection.journals[0].after_json);
+ assert.equal(after.json_data.value, '{"avatar": "https://oss/a"}');
+ assert.equal(after.counter.value, 2);
+ const result = await rollbackChanges(connection, 'app', 'run');
+ assert.deepEqual(result, { restored: 1, alreadyRestored: 0, conflicts: [] });
+ assert.equal(connection.rows['9007199254740993'].json_data, '{"avatar": "https://old/a"}');
+ assert.equal(connection.rows['9007199254740993'].counter, 1);
+});
+
+test('silent SQL truncation warnings abort the transaction before recording success', async () => {
+ const connection = new MemoryConnection();
+ connection.warningStatus = 1;
+ await assert.rejects(commitChange(connection), /数据库更新产生警告/);
+ assert.equal(connection.rows['9007199254740993'].avatar, 'https://old/a');
+ assert.equal(connection.journals.length, 0);
+});
+
+test('rollback restores consecutive edits in reverse journal order without losing bigint precision', async () => {
+ const connection = new MemoryConnection();
+ await commitChange(connection);
+ await commitChange(connection, change('https://oss/a', 'https://oss/b'));
+ const result = await rollbackChanges(connection, 'app', 'run');
+ assert.deepEqual(result, { restored: 2, alreadyRestored: 0, conflicts: [] });
+ assert.equal(connection.rows['9007199254740993'].avatar, 'https://old/a');
+ assert.equal(connection.rows['9007199254740993'].update_time, 'unchanged');
+ const reads = connection.calls.filter(call => call.sql?.startsWith('SELECT *'));
+ assert.deepEqual(reads.map(call => call.params[0]), ['9007199254740994', '9007199254740993']);
+ assert.ok(connection.journals.every(row => row.rolled_back === 1));
+ assert.deepEqual(await rollbackChanges(connection, 'app', 'run'), { restored: 0, alreadyRestored: 0, conflicts: [] });
+});
+
+test('rollback detects an already-restored row and only marks the journal', async () => {
+ const connection = new MemoryConnection();
+ await commitChange(connection);
+ connection.rows['9007199254740993'].avatar = 'https://old/a';
+ const result = await rollbackChanges(connection, 'app', 'run');
+ assert.deepEqual(result, { restored: 0, alreadyRestored: 1, conflicts: [] });
+ assert.equal(connection.journals[0].rolled_back, 1);
+});
+
+test('rollback SQL warnings do not mark a truncated restoration as complete', async () => {
+ const connection = new MemoryConnection();
+ await commitChange(connection);
+ connection.warningStatus = 1;
+ await assert.rejects(rollbackChanges(connection, 'app', 'run'), /数据库回滚产生警告/);
+ assert.equal(connection.rows['9007199254740993'].avatar, 'https://oss/a');
+ assert.equal(connection.journals[0].rolled_back, 0);
+});
+
+test('rollback leaves later user edits and deletion guards unchanged', async () => {
+ for (const update of [{ avatar: 'https://user/new' }, { deleted: Buffer.from([1]) }]) {
+ const connection = new MemoryConnection();
+ await commitChange(connection);
+ Object.assign(connection.rows['9007199254740993'], update);
+ const original = clone(connection.rows['9007199254740993']);
+ const result = await rollbackChanges(connection, 'app', 'run');
+ assert.equal(result.restored, 0);
+ assert.equal(result.conflicts.length, 1);
+ assert.equal(result.conflicts[0].pkValue, '9007199254740993');
+ assert.deepEqual(connection.rows['9007199254740993'], original);
+ assert.equal(connection.journals[0].rolled_back, 0);
+ }
+});
+
+test('rollback does not resurrect missing rows or touch another run', async () => {
+ const connection = new MemoryConnection();
+ await commitChange(connection);
+ delete connection.rows['9007199254740993'];
+ assert.deepEqual(await rollbackChanges(connection, 'app', 'another-run'), { restored: 0, alreadyRestored: 0, conflicts: [] });
+ const result = await rollbackChanges(connection, 'app', 'run');
+ assert.equal(result.conflicts.length, 1);
+ assert.equal(Object.keys(connection.rows).length, 0);
+});
+
+test('non-transactional tables are rejected before any update', async () => {
+ const connection = new MemoryConnection();
+ connection.engine = 'MyISAM';
+ await assert.rejects(commitChange(connection), /必须为 InnoDB/);
+ assert.equal(connection.calls.filter(call => call.sql?.startsWith('UPDATE')).length, 0);
+});
diff --git a/script/migrate-db-files-to-oss/test/progress.test.mjs b/script/migrate-db-files-to-oss/test/progress.test.mjs
new file mode 100644
index 0000000..b75c561
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/progress.test.mjs
@@ -0,0 +1,142 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createProgress } from '../lib/progress.mjs';
+
+function fixture(t, intervalMs = 5000) {
+ t.mock.timers.enable({ apis: ['setInterval'] });
+ let time = 0;
+ const lines = [];
+ const progress = createProgress({ log: line => lines.push(line), intervalMs, now: () => time });
+ t.after(() => progress.close());
+ return {
+ progress, lines,
+ setTime(value) { time = value; },
+ tick(value) { time += value; t.mock.timers.tick(value); },
+ };
+}
+
+test('construction stays silent until a stage is available', t => {
+ const { progress, lines, tick } = fixture(t);
+ tick(15000);
+ assert.deepEqual(lines, []);
+ progress.stage('正在等待数据库连接');
+ assert.deepEqual(lines, ['正在等待数据库连接(已用时 0 秒)']);
+});
+
+test('the timer reports a waiting stage at the configured interval', t => {
+ const { progress, lines, tick } = fixture(t);
+ progress.stage('正在统计历史图片');
+ tick(5000);
+ tick(5000);
+ assert.deepEqual(lines, [
+ '正在统计历史图片(已用时 0 秒)',
+ '正在统计历史图片(已用时 5 秒)',
+ '正在统计历史图片(已用时 10 秒)',
+ ]);
+});
+
+test('frequent updates preserve the newest message without printing every record', t => {
+ const { progress, lines, setTime } = fixture(t);
+ progress.stage('正在扫描引用');
+ for (let count = 1; count <= 1000; count++) {
+ setTime(count * 4);
+ progress.update('正在扫描引用,已检查 ' + count + ' 行');
+ }
+ assert.equal(lines.length, 1);
+ setTime(5000);
+ progress.update('正在扫描引用,已检查 1200 行');
+ assert.equal(lines.at(-1), '正在扫描引用,已检查 1200 行(已用时 5 秒)');
+ assert.equal(lines.length, 2);
+});
+
+test('timer output uses the last update message without resetting stage elapsed time', t => {
+ const { progress, lines, tick } = fixture(t);
+ progress.stage('正在扫描引用');
+ tick(2000);
+ progress.update('正在扫描引用,已检查 100 行');
+ tick(3000);
+ assert.deepEqual(lines, [
+ '正在扫描引用(已用时 0 秒)',
+ '正在扫描引用,已检查 100 行(已用时 5 秒)',
+ ]);
+});
+
+test('synchronous updates emit progress even when interval callbacks cannot run', t => {
+ const { progress, lines, setTime } = fixture(t);
+ progress.stage('正在处理清单');
+ setTime(4999);
+ progress.update('正在处理清单 1');
+ assert.equal(lines.length, 1);
+ setTime(5000);
+ progress.update('正在处理清单 2');
+ setTime(10000);
+ progress.update('正在处理清单 3');
+ assert.deepEqual(lines, [
+ '正在处理清单(已用时 0 秒)',
+ '正在处理清单 2(已用时 5 秒)',
+ '正在处理清单 3(已用时 10 秒)',
+ ]);
+});
+
+test('a timer callback immediately after an update does not duplicate output', t => {
+ const { progress, lines, setTime } = fixture(t);
+ progress.stage('正在上传');
+ setTime(5000);
+ progress.update('正在上传,已处理 50 张');
+ t.mock.timers.tick(5000);
+ assert.equal(lines.length, 2);
+});
+
+test('changing stage prints immediately and restarts its elapsed time', t => {
+ const { progress, lines, tick } = fixture(t);
+ progress.stage('正在统计');
+ tick(5000);
+ progress.stage('正在上传');
+ assert.equal(lines.at(-1), '正在上传(已用时 0 秒)');
+ tick(5000);
+ assert.equal(lines.at(-1), '正在上传(已用时 5 秒)');
+});
+
+test('close is idempotent and prevents timer, stage and update output', t => {
+ const { progress, lines, tick } = fixture(t);
+ progress.stage('正在扫描');
+ progress.close();
+ progress.close();
+ tick(15000);
+ progress.stage('已关闭');
+ progress.update('不应输出');
+ assert.deepEqual(lines, ['正在扫描(已用时 0 秒)']);
+});
+
+test('an update can initialize progress when no explicit stage was set', t => {
+ const { progress, lines, tick } = fixture(t);
+ progress.update('正在处理');
+ tick(5000);
+ assert.deepEqual(lines, ['正在处理(已用时 0 秒)', '正在处理(已用时 5 秒)']);
+});
+
+test('the interval is unrefed and its exact handle is cleared', t => {
+ let callback;
+ const handle = { unref: t.mock.fn() };
+ const interval = t.mock.method(globalThis, 'setInterval', (fn, ms) => {
+ callback = fn;
+ assert.equal(ms, 5000);
+ return handle;
+ });
+ const clear = t.mock.method(globalThis, 'clearInterval', () => {});
+ const log = t.mock.fn();
+ const progress = createProgress({ log, now: () => 0 });
+ assert.equal(interval.mock.callCount(), 1);
+ assert.equal(handle.unref.mock.callCount(), 1);
+ assert.equal(log.mock.callCount(), 0);
+ progress.close();
+ assert.deepEqual(clear.mock.calls[0].arguments, [handle]);
+ callback();
+ assert.equal(log.mock.callCount(), 0);
+});
+
+test('invalid intervals fail before starting a timer', () => {
+ for (const intervalMs of [0, -1, 1.5, NaN, Infinity]) {
+ assert.throws(() => createProgress({ intervalMs }), /正整数/);
+ }
+});
diff --git a/script/migrate-db-files-to-oss/test/project-config.test.mjs b/script/migrate-db-files-to-oss/test/project-config.test.mjs
new file mode 100644
index 0000000..760edf7
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/project-config.test.mjs
@@ -0,0 +1,309 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { stringify } from 'yaml';
+import { loadProjectConfig } from '../lib/project-config.mjs';
+
+const defaultBase = () => ({ spring: { profiles: { active: 'dev' } } });
+const datasource = overrides => ({
+ url: 'jdbc:mysql://db.example.test:3307/fixture_db?useSSL=false&serverTimezone=Asia%2FShanghai',
+ username: 'fixture-user', password: 'fixture-password', ...overrides,
+});
+const profileDocument = (overrides = {}) => ({
+ spring: { datasource: { dynamic: { datasource: { master: datasource(overrides) } } } },
+ server: { port: 48083 },
+});
+const documents = value => (Array.isArray(value) ? value : [value]).map(document => stringify(document)).join('---\n');
+
+async function fixture(t, { base = defaultBase(), profiles = { dev: profileDocument() } } = {}) {
+ const root = await mkdtemp(join(tmpdir(), 'oss-project-config-test-'));
+ t.after(() => rm(root, { recursive: true, force: true }));
+ const resources = join(root, 'yudao-server/src/main/resources');
+ await mkdir(resources, { recursive: true });
+ await writeFile(join(resources, 'application.yaml'), typeof base === 'string' ? base : documents(base));
+ for (const [name, content] of Object.entries(profiles)) {
+ await writeFile(join(resources, 'application-' + name + '.yaml'), typeof content === 'string' ? content : documents(content));
+ }
+ return root;
+}
+
+test('default profile, master credentials and actual server port come from the project YAML', async t => {
+ const projectRoot = await fixture(t);
+ const result = await loadProjectConfig({ projectRoot, env: {} });
+ assert.equal(result.profile, 'dev');
+ assert.deepEqual(result.mysql, {
+ host: 'db.example.test', port: 3307, database: 'fixture_db',
+ user: 'fixture-user', password: 'fixture-password',
+ });
+ assert.equal(result.downloadBaseUrl, 'http://127.0.0.1:48083/admin-api/infra/file');
+ assert.deepEqual(result.sourceFiles, [
+ resolve(projectRoot, 'yudao-server/src/main/resources/application.yaml'),
+ resolve(projectRoot, 'yudao-server/src/main/resources/application-dev.yaml'),
+ ]);
+});
+
+test('explicit profile overrides environment, which overrides the base active profile', async t => {
+ const projectRoot = await fixture(t, { profiles: {
+ dev: profileDocument({ url: 'jdbc:mysql://dev.example.test/dev_db' }),
+ local: profileDocument({ url: 'jdbc:mysql://local.example.test/local_db' }),
+ prod: profileDocument({ url: 'jdbc:mysql://prod.example.test/prod_db' }),
+ } });
+ const explicit = await loadProjectConfig({ projectRoot, profile: 'local', env: { SPRING_PROFILES_ACTIVE: 'prod' } });
+ const environment = await loadProjectConfig({ projectRoot, env: { SPRING_PROFILES_ACTIVE: 'prod' } });
+ const base = await loadProjectConfig({ projectRoot, env: {} });
+ assert.equal(explicit.mysql.database, 'local_db');
+ assert.equal(environment.mysql.database, 'prod_db');
+ assert.equal(base.mysql.database, 'dev_db');
+});
+
+test('single-profile selection resolves a base property or environment placeholder with a default', async t => {
+ const projectRoot = await fixture(t, {
+ base: { selection: { profile: '${APP_PROFILE:dev}' }, spring: { profiles: { active: '${selection.profile}' } } },
+ profiles: { dev: profileDocument(), local: profileDocument() },
+ });
+ assert.equal((await loadProjectConfig({ projectRoot, env: {} })).profile, 'dev');
+ assert.equal((await loadProjectConfig({ projectRoot, env: { APP_PROFILE: 'local' } })).profile, 'local');
+});
+
+test('explicit profile works even when the base active profile is absent or unusable', async t => {
+ const projectRoot = await fixture(t, { base: { spring: { profiles: { active: ['unused', 'group'] } } } });
+ const result = await loadProjectConfig({ projectRoot, profile: 'dev', env: {} });
+ assert.equal(result.profile, 'dev');
+});
+
+test('multiple, missing and path-traversal profiles fail with actionable selection errors', async t => {
+ for (const active of [['dev', 'prod'], 'dev,prod', 'dev prod', '', '../prod', null, undefined]) {
+ const projectRoot = await fixture(t, { base: { spring: { profiles: { active } } } });
+ await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), /单个 profile.*--profile/);
+ }
+ const projectRoot = await fixture(t);
+ await assert.rejects(loadProjectConfig({ projectRoot, env: { SPRING_PROFILES_ACTIVE: 'dev,prod' } }), /单个 profile/);
+ await assert.rejects(loadProjectConfig({ projectRoot, profile: '../dev', env: {} }), /单个 profile/);
+});
+
+test('multiple unconditional documents merge recursively across base and selected profile', async t => {
+ const projectRoot = await fixture(t, {
+ base: [
+ { spring: { profiles: { active: 'dev' }, datasource: { dynamic: { primary: 'main', datasource: { main: { username: 'base-user', password: 'base-password' } } } } } },
+ { spring: { datasource: { dynamic: { datasource: { main: { url: 'jdbc:mysql://base.example.test/base_db' } } } } }, server: { port: 8081 } },
+ ],
+ profiles: { dev: [
+ { spring: { datasource: { dynamic: { datasource: { main: { url: 'jdbc:mysql://selected.example.test:3308/selected_db' } } } } } },
+ { spring: { datasource: { dynamic: { datasource: { main: { password: 'selected-password' } } } } }, server: { port: 48083 } },
+ ] },
+ });
+ const result = await loadProjectConfig({ projectRoot, env: {} });
+ assert.deepEqual(result.mysql, { host: 'selected.example.test', port: 3308, database: 'selected_db', user: 'base-user', password: 'selected-password' });
+ assert.equal(result.downloadBaseUrl, 'http://127.0.0.1:48083/admin-api/infra/file');
+});
+
+test('environment directly overrides master URL, username and password', async t => {
+ const projectRoot = await fixture(t);
+ const result = await loadProjectConfig({ projectRoot, env: {
+ SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_URL: 'jdbc:mysql://override.example.test:3310/env_db',
+ SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_USERNAME: 'env-user',
+ SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_PASSWORD: ' env:pass#word ',
+ } });
+ assert.deepEqual(result.mysql, { host: 'override.example.test', port: 3310, database: 'env_db', user: 'env-user', password: ' env:pass#word ' });
+});
+
+test('dynamic primary environment override selects the named source instead of master or slave', async t => {
+ const document = profileDocument();
+ document.spring.datasource.dynamic.datasource.reporting = datasource({ url: 'jdbc:mysql://reporting.example.test/reporting_db' });
+ document.spring.datasource.dynamic.datasource.slave = datasource({ url: 'jdbc:mysql://slave.example.test/slave_db' });
+ const projectRoot = await fixture(t, { profiles: { dev: document } });
+ const result = await loadProjectConfig({ projectRoot, env: {
+ SPRING_DATASOURCE_DYNAMIC_PRIMARY: 'reporting',
+ SPRING_DATASOURCE_DYNAMIC_DATASOURCE_REPORTING_PASSWORD: 'reporting-env-password',
+ } });
+ assert.equal(result.mysql.database, 'reporting_db');
+ assert.equal(result.mysql.password, 'reporting-env-password');
+});
+
+test('required fields support property references and nested environment defaults', async t => {
+ const document = profileDocument({
+ url: 'jdbc:mysql://${DB_HOST:${database.host}}:${DB_PORT:${database.port:3306}}/${database.name}',
+ username: '${DB_USER:${database.user}}',
+ password: '${DB_PASSWORD:${database.password}}',
+ });
+ document.database = { host: 'property.example.test', name: 'property_db', user: 'property-user', password: 'property-password' };
+ const projectRoot = await fixture(t, { profiles: { dev: document } });
+ const base = await loadProjectConfig({ projectRoot, env: {} });
+ assert.deepEqual(base.mysql, { host: 'property.example.test', port: 3306, database: 'property_db', user: 'property-user', password: 'property-password' });
+ const overridden = await loadProjectConfig({ projectRoot, env: { DB_HOST: 'env.example.test', DB_PORT: '3309', DB_PASSWORD: 'env-password' } });
+ assert.equal(overridden.mysql.host, 'env.example.test');
+ assert.equal(overridden.mysql.port, 3309);
+ assert.equal(overridden.mysql.password, 'env-password');
+});
+
+test('unrelated unresolved placeholders, service credentials and cycles are never resolved', async t => {
+ const document = profileDocument();
+ document.unrelated = { secret: '${MISSING_SERVICE_SECRET}', a: '${unrelated.b}', b: '${unrelated.a}', encrypted: 'ENC(unrelated-service-secret)' };
+ const projectRoot = await fixture(t, { profiles: { dev: document } });
+ assert.equal((await loadProjectConfig({ projectRoot, env: {} })).mysql.database, 'fixture_db');
+});
+
+test('HTTP context path, admin prefix and SSL settings determine the download URL', async t => {
+ const document = profileDocument();
+ document.server = { port: 8443, servlet: { contextPath: '/backend/' }, ssl: { enabled: true } };
+ document.yudao = { web: { adminApi: { prefix: '/management/' } } };
+ const projectRoot = await fixture(t, { profiles: { dev: document } });
+ const result = await loadProjectConfig({ projectRoot, env: {} });
+ assert.equal(result.downloadBaseUrl, 'https://127.0.0.1:8443/backend/management/infra/file');
+ const overridden = await loadProjectConfig({ projectRoot, env: {
+ SERVER_PORT: '9080', SERVER_SERVLET_CONTEXT_PATH: '/overridden',
+ YUDAO_WEB_ADMIN_API_PREFIX: '/admin', SERVER_SSL_ENABLED: 'false',
+ } });
+ assert.equal(overridden.downloadBaseUrl, 'http://127.0.0.1:9080/overridden/admin/infra/file');
+});
+
+test('Spring defaults apply to missing primary, HTTP port and API prefix', async t => {
+ const document = profileDocument();
+ delete document.server;
+ const projectRoot = await fixture(t, { profiles: { dev: document } });
+ assert.equal((await loadProjectConfig({ projectRoot, env: {} })).downloadBaseUrl, 'http://127.0.0.1:8080/admin-api/infra/file');
+});
+
+test('an explicit root context path does not produce duplicate slashes', async t => {
+ const document = profileDocument();
+ document.server.servlet = { 'context-path': '/' };
+ document.yudao = { web: { 'admin-api': { prefix: '/' } } };
+ const projectRoot = await fixture(t, { profiles: { dev: document } });
+ assert.equal((await loadProjectConfig({ projectRoot, env: {} })).downloadBaseUrl, 'http://127.0.0.1:48083/infra/file');
+});
+
+test('single-host MySQL URL supports omitted ports and IPv6 without changing database names', async t => {
+ for (const [url, expected] of [
+ ['jdbc:mysql://localhost/test_db', { host: 'localhost', port: 3306, database: 'test_db' }],
+ ['jdbc:mysql://[::1]:3309/test_db', { host: '::1', port: 3309, database: 'test_db' }],
+ ['jdbc:mysql://db.example.test/test%20db', { host: 'db.example.test', port: 3306, database: 'test db' }],
+ ]) {
+ const projectRoot = await fixture(t, { profiles: { dev: profileDocument({ url }) } });
+ const result = await loadProjectConfig({ projectRoot, env: {} });
+ assert.deepEqual({ host: result.mysql.host, port: result.mysql.port, database: result.mysql.database }, expected);
+ }
+});
+
+test('ambiguous, unsupported and incomplete JDBC URLs are rejected without echoing their values', async t => {
+ for (const url of [
+ 'jdbc:mysql:loadbalance://first,second/db',
+ 'jdbc:mysql://first,second/db',
+ 'jdbc:mysql://first:3306,second:3306/db',
+ 'jdbc:postgresql://secret-host/secret-database',
+ 'jdbc:mysql://db.example.test',
+ 'jdbc:mysql://db.example.test/a/b',
+ 'jdbc:mysql://db.example.test:65536/db',
+ 'jdbc:mysql://db.example.test:0/db',
+ 'jdbc:mysql://user:sensitive-url-password@db.example.test/db',
+ 'jdbc:mysql://db.example.test/a%2Fb',
+ ]) {
+ const projectRoot = await fixture(t, { profiles: { dev: profileDocument({ url }) } });
+ await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => {
+ assert.match(error.message, /MySQL|jdbc:mysql/);
+ assert.ok(!error.message.includes(url));
+ assert.ok(!error.message.includes('sensitive-url-password'));
+ return true;
+ });
+ }
+});
+
+test('missing or still-encrypted passwords are rejected, with direct environment override available', async t => {
+ for (const password of [null, undefined, '', 'ENC(sensitive-encrypted-password)']) {
+ const projectRoot = await fixture(t, { profiles: { dev: profileDocument({ password }) } });
+ await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => {
+ assert.match(error.message, /缺少|缺失|ENC/);
+ assert.ok(!error.message.includes('sensitive-encrypted-password'));
+ return true;
+ });
+ assert.equal((await loadProjectConfig({ projectRoot, env: { SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_PASSWORD: 'provided-password' } })).mysql.password, 'provided-password');
+ }
+});
+
+test('required unresolved, malformed or cyclic placeholders stop safely without revealing secrets', async t => {
+ for (const password of [
+ 'sensitive-prefix-${MISSING_PASSWORD}',
+ 'sensitive-prefix-${MISSING_PASSWORD',
+ '${spring.datasource.dynamic.datasource.master.password}',
+ ]) {
+ const projectRoot = await fixture(t, { profiles: { dev: profileDocument({ password }) } });
+ await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => {
+ assert.match(error.message, /占位符/);
+ assert.ok(!error.message.includes('sensitive-prefix'));
+ return true;
+ });
+ }
+});
+
+test('numeric YAML scalar credentials retain bigint precision', async t => {
+ const document = profileDocument({ password: 900719925474099312345n });
+ const projectRoot = await fixture(t, { profiles: { dev: document } });
+ assert.equal((await loadProjectConfig({ projectRoot, env: {} })).mysql.password, '900719925474099312345');
+});
+
+test('config imports, included profiles and a selected profile group require explicit configuration', async t => {
+ for (const base of [
+ { spring: { profiles: { active: 'dev' }, config: { import: 'optional:file:sensitive-location' } } },
+ { spring: { profiles: { active: 'dev', include: ['common'] } } },
+ { spring: { profiles: { active: 'dev', group: { dev: ['common', 'database'] } } } },
+ ]) {
+ const projectRoot = await fixture(t, { base });
+ await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => {
+ assert.match(error.message, /import|include|配置组/);
+ assert.ok(!error.message.includes('sensitive-location'));
+ return true;
+ });
+ }
+ const projectRoot = await fixture(t);
+ await assert.rejects(loadProjectConfig({ projectRoot, env: { SPRING_CONFIG_IMPORT: 'optional:file:sensitive-location' } }), /import/);
+ await assert.rejects(loadProjectConfig({ projectRoot, env: { SPRING_PROFILES_GROUP_DEV: 'common,database' } }), /配置组/);
+});
+
+test('an unrelated profile group does not affect an explicitly selected concrete profile', async t => {
+ const projectRoot = await fixture(t, { base: { spring: { profiles: { active: 'dev', group: { production: ['prod', 'shared'] } } } } });
+ assert.equal((await loadProjectConfig({ projectRoot, env: {} })).profile, 'dev');
+});
+
+test('conditional YAML documents and active-profile redefinition are rejected', async t => {
+ for (const content of [
+ [profileDocument(), { spring: { config: { activate: { 'on-profile': 'dev' } } }, server: { port: 8080 } }],
+ [profileDocument(), { spring: { profiles: 'dev' }, server: { port: 8080 } }],
+ [profileDocument(), { spring: { profiles: { active: 'prod' } } }],
+ ]) {
+ const projectRoot = await fixture(t, { profiles: { dev: content } });
+ await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), /条件|歧义/);
+ }
+});
+
+test('missing files and malformed YAML errors do not expose parser source or secrets', async t => {
+ const projectRoot = await fixture(t, { base: 'spring: [sensitive-password' });
+ await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => {
+ assert.match(error.message, /YAML/);
+ assert.ok(!error.message.includes('sensitive-password'));
+ return true;
+ });
+ const validRoot = await fixture(t);
+ await assert.rejects(loadProjectConfig({ projectRoot: validRoot, profile: 'missing', env: {} }), /--profile/);
+ await assert.rejects(loadProjectConfig({ projectRoot: '', env: {} }), /项目根目录/);
+});
+
+test('duplicate keys inside one YAML document cannot silently choose credentials', async t => {
+ const projectRoot = await fixture(t, { base: 'spring:\n profiles:\n active: dev\nspring:\n password: sensitive-password\n' });
+ await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => {
+ assert.match(error.message, /YAML/);
+ assert.ok(!error.message.includes('sensitive-password'));
+ return true;
+ });
+});
+
+test('invalid HTTP ports and route prefixes fail instead of generating unusable URLs', async t => {
+ const projectRoot = await fixture(t);
+ for (const env of [
+ { SERVER_PORT: '0' }, { SERVER_PORT: '65536' }, { SERVER_PORT: 'not-a-port' },
+ { SERVER_SERVLET_CONTEXT_PATH: 'missing-leading-slash' },
+ { YUDAO_WEB_ADMIN_API_PREFIX: '/admin?query' },
+ { SERVER_SERVLET_CONTEXT_PATH: '/a/../b' },
+ { SERVER_SSL_ENABLED: 'unknown' },
+ ]) await assert.rejects(loadProjectConfig({ projectRoot, env }), /HTTP/);
+});
diff --git a/script/migrate-db-files-to-oss/test/references.test.mjs b/script/migrate-db-files-to-oss/test/references.test.mjs
new file mode 100644
index 0000000..15975f4
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/references.test.mjs
@@ -0,0 +1,171 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { containsLegacyReference, discoverReferenceColumns, rewriteReferences } from '../lib/references.mjs';
+
+const oldUrl = 'https://old.example/admin-api/infra/file/1/get/a.png';
+const newUrl = 'https://bucket.example/migrated/a.png';
+const otherUrl = 'https://elsewhere.example/a.png';
+const replacements = new Map([[oldUrl, newUrl]]);
+
+test('replaces an exact URL once and never cascades a destination mapping', () => {
+ assert.deepEqual(rewriteReferences(oldUrl, new Map([[oldUrl, newUrl], [newUrl, otherUrl]])), {
+ value: newUrl, changed: true, replacements: 1
+ });
+ assert.deepEqual(rewriteReferences(null, replacements), { value: null, changed: false, replacements: 0 });
+});
+
+test('replaces comma lists, repeated values and rich text while preserving surrounding text', () => {
+ const input = `${oldUrl},${otherUrl}, ${oldUrl}\n
`;
+ const expected = `${newUrl},${otherUrl}, ${newUrl}\n
`;
+ assert.deepEqual(rewriteReferences(input, replacements), { value: expected, changed: true, replacements: 4 });
+ assert.equal(rewriteReferences(`[image](${oldUrl}),`, replacements).value, `[image](${newUrl}),`);
+});
+
+test('never matches URL prefixes, query extensions, fragments or external embedded URLs', () => {
+ const unchanged = [
+ `${oldUrl}.bak`, `${oldUrl}/thumbnail`, `${oldUrl}?width=200`, `${oldUrl}#preview`,
+ `${oldUrl}%20other`, `${oldUrl}&x=1`, `${oldUrl};v=1`, otherUrl,
+ `https://external.example/?redirect=${oldUrl}`,
+ `https://external.example/?redirect=x,${oldUrl}`,
+ `${oldUrl}?url=x,${oldUrl}`
+ ];
+ for (const value of unchanged) {
+ assert.deepEqual(rewriteReferences(value, replacements), { value, changed: false, replacements: 0 }, value);
+ }
+});
+
+test('only rewrites queries and fragments explicitly present in the dictionary', () => {
+ const url = `${oldUrl}?v=2#original`;
+ assert.equal(rewriteReferences(`
`, new Map([[url, newUrl]])).value, `
`);
+ assert.equal(rewriteReferences(url, replacements).value, url);
+});
+
+test('keeps URL encoding literal and supports Unicode paths', () => {
+ const encoded = oldUrl.replace('a.png', '%E5%A4%B4%E5%83%8F%20a.png');
+ const unicode = oldUrl.replace('a.png', '头像.png');
+ const mapping = new Map([[encoded, newUrl], [unicode, `${newUrl}?kind=unicode`]]);
+ assert.equal(rewriteReferences(`
${unicode}`, mapping).replacements, 2);
+ assert.equal(rewriteReferences(encoded.replace('%20', ' '), mapping).changed, false);
+ assert.equal(rewriteReferences(encoded.toLowerCase(), mapping).changed, false);
+});
+
+test('JSON text retains formatting and untouched escapes; keys remain unchanged', () => {
+ const escapedUrl = oldUrl.replaceAll('/', '\\/');
+ const input = `{\n "${oldUrl}": "key stays", "avatar" : "${escapedUrl}",\n "nested": [{"pic":"${oldUrl}"}], "untouched":"\\u4e2d"\n}`;
+ const expected = `{\n "${oldUrl}": "key stays", "avatar" : "${newUrl}",\n "nested": [{"pic":"${newUrl}"}], "untouched":"\\u4e2d"\n}`;
+ assert.deepEqual(rewriteReferences(input, replacements), { value: expected, changed: true, replacements: 2 });
+ assert.equal(rewriteReferences(JSON.stringify(JSON.stringify({ avatar: oldUrl })), replacements).value,
+ JSON.stringify(JSON.stringify({ avatar: newUrl })));
+});
+
+test('parsed JSON is recursively rewritten without changing input or prototype', () => {
+ const input = JSON.parse(`{"__proto__":{"url":"${oldUrl}"},"photos":["${oldUrl}",null,4],"url":"${otherUrl}"}`);
+ const result = rewriteReferences(input, replacements);
+ assert.equal(result.replacements, 2);
+ assert.equal(input.photos[0], oldUrl);
+ assert.equal(result.value.photos[0], newUrl);
+ assert.equal(result.value.__proto__.url, newUrl);
+ assert.equal(Object.getPrototypeOf(result.value), Object.prototype);
+ assert.equal(rewriteReferences({ url: otherUrl }, replacements).changed, false);
+});
+
+test('malformed JSON safely uses literal URL replacement without changing escapes', () => {
+ const input = `{"avatar":"${oldUrl}",}`;
+ assert.equal(rewriteReferences(input, replacements).value, `{"avatar":"${newUrl}",}`);
+});
+
+test('remaining-reference detection finds Unicode-escaped JSON URLs with unknown queries', () => {
+ const withQuery = `${oldUrl}?unmapped=1#preview`;
+ const unicodeJson = JSON.stringify({ avatar: withQuery }).replaceAll('/', '\\u002f');
+ const rewritten = rewriteReferences(unicodeJson, replacements);
+ assert.equal(rewritten.changed, false);
+ assert.equal(containsLegacyReference(rewritten.value, ['1']), true);
+ assert.equal(containsLegacyReference(rewritten.value, ['2']), false);
+});
+
+test('remaining-reference detection recursively handles nested JSON strings and parsed values', () => {
+ const nested = JSON.stringify({ data: JSON.stringify({ photos: [`${oldUrl}?v=2`] }).replaceAll('/', '\\u002f') });
+ assert.equal(containsLegacyReference(nested, new Set([1])), true);
+ assert.equal(containsLegacyReference({ data: [null, { image: oldUrl }] }, [1]), true);
+ assert.equal(containsLegacyReference(JSON.stringify(JSON.stringify(`${oldUrl}#unknown`)), ['1']), true);
+ assert.equal(containsLegacyReference(rewriteReferences(JSON.stringify({ image: oldUrl }), replacements).value, [1]), false);
+});
+
+test('remaining-reference detection handles escaped plain text, exact config IDs, and excludes JSON keys', () => {
+ assert.equal(containsLegacyReference(`
`, [1]), true);
+ assert.equal(containsLegacyReference(`${oldUrl}?query=2`, [11]), false);
+ assert.equal(containsLegacyReference(oldUrl.replace('/file/1/', '/file/11/'), [1]), false);
+ assert.equal(containsLegacyReference(JSON.stringify({ [oldUrl]: 'not a URL value' }), [1]), false);
+ assert.equal(containsLegacyReference({ [oldUrl]: 'not a URL value' }, [1]), false);
+ assert.equal(containsLegacyReference(null, [1]), false);
+ assert.equal(containsLegacyReference(oldUrl, []), false);
+ const cycle = {};
+ cycle.self = cycle;
+ assert.equal(containsLegacyReference(cycle, [1]), false);
+});
+
+test('discovery binds schema names and excludes unsafe or non-content columns', async () => {
+ const row = (tableName, columnName, extra = {}) => ({
+ schemaName: 'app', tableName, columnName, dataType: 'varchar', extra: '',
+ generationExpression: '', primaryKey: 'id', primaryKeyCount: 1, ...extra
+ });
+ const rows = [
+ row('member_user', 'avatar'), row('member_user', 'id'),
+ row('member_user', 'generated', { generationExpression: 'avatar', extra: 'VIRTUAL GENERATED' }),
+ row('member_user', 'stored', { extra: 'STORED GENERATED' }),
+ row('member_user', 'seq', { extra: 'auto_increment' }),
+ row('member_user', 'defaulted', { extra: 'DEFAULT_GENERATED' }),
+ row('member_com_auth', 'auth_extra_json', { dataType: 'json' }),
+ row('infra_file', 'url'), row('infra_file_content', 'path'), row('infra_file_config', 'config'),
+ row('migration_files', 'old_url'), row('infra_file_migration_journal', 'old_url'),
+ row('infra_file_oss_migration_runs', 'settings'), row('infra_file_oss_migration_items', 'data'),
+ row('infra_file_oss_migration_changes', 'before_json'), row('INFRA_FILE_OSS_MIGRATION_CHANGES', 'after_json'),
+ row('no_key', 'a', { primaryKeyCount: null, primaryKey: null }),
+ row('no_key', 'b', { primaryKeyCount: null, primaryKey: null }),
+ row('composite', 'a', { primaryKeyCount: '2' }),
+ row('skip_me', 'a'), row('skip_qualified', 'a'),
+ ];
+ const schemas = ['app', 'odd` schema; DROP TABLE x'];
+ const connection = { async execute(sql, params) {
+ assert.deepEqual(params, [...schemas, ...schemas]);
+ assert.ok(!sql.includes(schemas[1]));
+ assert.match(sql, /t\.TABLE_TYPE = 'BASE TABLE'/);
+ assert.match(sql, /c\.DATA_TYPE IN \('char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'json'\)/);
+ return [rows];
+ } };
+ const result = await discoverReferenceColumns(connection, schemas, new Set(['skip_me', 'APP.skip_qualified']));
+ assert.deepEqual(result.columns, [
+ { schema: 'app', table: 'member_user', column: 'avatar', primaryKey: 'id', dataType: 'varchar' },
+ { schema: 'app', table: 'member_user', column: 'defaulted', primaryKey: 'id', dataType: 'varchar' },
+ { schema: 'app', table: 'member_com_auth', column: 'auth_extra_json', primaryKey: 'id', dataType: 'json' }
+ ]);
+ assert.equal(result.skipped.length, 13);
+ for (const table of ['infra_file_oss_migration_runs', 'infra_file_oss_migration_items', 'infra_file_oss_migration_changes', 'INFRA_FILE_OSS_MIGRATION_CHANGES']) {
+ assert.equal(result.skipped.find((item) => item.table === table).reason, 'excluded table');
+ assert.ok(!result.columns.some((item) => item.table === table));
+ }
+ assert.equal(result.skipped.find((item) => item.table === 'no_key').reason, 'no primary key');
+ assert.equal(result.skipped.find((item) => item.table === 'composite').reason, 'composite primary key');
+});
+
+test('discovery makes no connection for an empty schema list and validates names', async () => {
+ assert.deepEqual(await discoverReferenceColumns({}, []), { columns: [], skipped: [] });
+ await assert.rejects(discoverReferenceColumns({}, ['']), /schema names/);
+});
+
+test('discovery explicitly excludes known technical and audit logs while retaining business log tables', async () => {
+ const excluded = [
+ 'infra_api_access_log', 'infra_api_error_log', 'infra_job_log',
+ 'system_operate_log', 'system_login_log', 'SYSTEM_LOGIN_LOG',
+ ];
+ const retained = ['member_activity_log', 'chat_message_log', 'custom_audit_log', 'infra_api_access_log_archive'];
+ const rows = [...excluded, ...retained].flatMap(tableName => ['content', 'details'].map(columnName => ({
+ schemaName: 'app', tableName, columnName, dataType: 'text', extra: '',
+ generationExpression: '', primaryKey: 'id', primaryKeyCount: 1,
+ })));
+ const connection = { async execute() { return [rows]; } };
+ const result = await discoverReferenceColumns(connection, ['app']);
+ assert.deepEqual(result.skipped, excluded.map(table => ({ schema: 'app', table, reason: 'excluded table' })));
+ assert.deepEqual([...new Set(result.columns.map(column => column.table))], retained);
+ assert.equal(result.columns.length, retained.length * 2);
+});
diff --git a/script/migrate-db-files-to-oss/test/runtime-config.test.mjs b/script/migrate-db-files-to-oss/test/runtime-config.test.mjs
new file mode 100644
index 0000000..bd12a1a
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/runtime-config.test.mjs
@@ -0,0 +1,56 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { normalizeConfig } from '../lib/core.mjs';
+import { parseArgs, prepareInput, persistGeneratedConfig } from '../migrate.mjs';
+
+test('zero arguments default to read-only plan, with optional profile or explicit actions', () => {
+ const args = parseArgs([]);
+ assert.equal(args.command, 'plan'); assert.ok(args.configPath.endsWith('config.local.json'));
+ assert.equal(parseArgs(['--profile', 'dev']).command, 'plan');
+ assert.equal(parseArgs(['init']).command, 'init');
+ assert.equal(parseArgs(['apply', '--limit', '50']).limit, 50);
+ assert.equal(parseArgs(['apply', '--run-id', 'new-batch']).runId, 'new-batch');
+ assert.equal(parseArgs(['--help']).help, true);
+ assert.throws(() => parseArgs(['plan', '--limit', '50']));
+ assert.throws(() => parseArgs(['delete']));
+});
+
+test('automatic configuration inherits project credentials without exporting a password variable', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'oss-runtime-'));
+ try {
+ const resources = join(root, 'yudao-server/src/main/resources'); await mkdir(resources, { recursive: true });
+ await writeFile(join(resources, 'application.yaml'), 'spring:\n profiles:\n active: dev\n');
+ await writeFile(join(resources, 'application-dev.yaml'), `server:\n port: 48083\nspring:\n datasource:\n dynamic:\n primary: master\n datasource:\n master:\n url: jdbc:mysql://127.0.0.1:3306/fixture_db\n username: fixture_user\n password: fixture-secret\n`);
+ const args = { command: 'init', configPath: join(root, 'config.local.json') };
+ const prepared = await prepareInput(args, root);
+ assert.equal(prepared.projectManaged, true); assert.equal(prepared.exists, false);
+ assert.equal(prepared.input.mysql.database, 'fixture_db'); assert.equal(prepared.input.mysql.password, 'fixture-secret');
+ assert.equal(prepared.input.sourceMode, undefined); // defaults must not overwrite a resumed run
+ assert.equal(prepared.input._projectDownloadBaseUrl, 'http://127.0.0.1:48083/admin-api/infra/file');
+ const config = normalizeConfig({ ...prepared.input, sourceMode: 'database', sourceConfigIds: ['5'], targetConfigId: '30', runId: 'auto-test', _databaseIdentity: { server: 'test-uuid', schema: 'fixture_db' } });
+ await persistGeneratedConfig(config, args, prepared);
+ const text = await readFile(args.configPath, 'utf8');
+ assert.ok(!text.includes('fixture-secret')); assert.ok(!text.includes('"mysql"'));
+ const saved = JSON.parse(text); assert.equal(saved.runId, 'auto-test'); assert.equal(saved.projectProfile, 'dev'); assert.equal(saved.targetConfigId, '30');
+ const reloaded = await prepareInput(args, root);
+ assert.equal(reloaded.input.mysql.password, 'fixture-secret'); assert.equal(reloaded.input.runId, 'auto-test');
+ } finally { await rm(root, { recursive: true, force: true }); }
+});
+
+test('manual connection remains supported and is never overwritten with project settings', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'oss-manual-'));
+ try {
+ const path = join(root, 'manual.json');
+ const raw = { mysql: { host: 'manual.example', database: 'manual_db', user: 'manual', password: 'manual-secret' }, runId: 'manual-run', sourceConfigIds: ['5'], targetConfigId: '30', sourceMode: 'database' };
+ await writeFile(path, JSON.stringify(raw));
+ const args = { command: 'init', configPath: path, explicitConfig: true };
+ const prepared = await prepareInput(args, root);
+ assert.equal(prepared.projectManaged, false); assert.equal(prepared.input.mysql.host, 'manual.example');
+ await persistGeneratedConfig(normalizeConfig(prepared.input), args, prepared);
+ assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), raw);
+ await assert.rejects(prepareInput({ ...args, profile: 'prod' }, root), /不能同时使用/);
+ } finally { await rm(root, { recursive: true, force: true }); }
+});
diff --git a/script/migrate-db-files-to-oss/test/source-batch.test.mjs b/script/migrate-db-files-to-oss/test/source-batch.test.mjs
new file mode 100644
index 0000000..d2ec92f
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/source-batch.test.mjs
@@ -0,0 +1,139 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { verifySourceBatch } from '../lib/source-batch.mjs';
+
+const config = { mysql: { database: 'source_batch_test' } };
+const first = { contentId: '9007199254740993', configId: '5', path: '相册/Résumé.PNG', size: 1234, sha256: 'a'.repeat(64) };
+const second = { contentId: '9007199254740995', configId: '9007199254740999', path: '头像.png', size: 4321, sha256: 'b'.repeat(64) };
+const latest = item => ({ id: item.contentId, expected_content_id: item.contentId });
+const locked = (...items) => items.map(item => ({ id: item.contentId }));
+const content = (item, overrides = {}) => ({ id: item.contentId, config_id: item.configId, path: item.path, size: String(item.size), sha256: item.sha256, ...overrides });
+const changed = error => error.code === 'MIGRATION_SOURCE_CHANGED';
+
+class FakeDb {
+ constructor(responses) {
+ this.responses = responses;
+ this.calls = [];
+ }
+ async execute(sql, params) {
+ assert.match(sql.trim(), /^SELECT\b/);
+ if (this.calls.length === 1) assert.doesNotMatch(sql, /FOR UPDATE|LOCK IN SHARE MODE/i);
+ else assert.match(sql, /FOR UPDATE$/);
+ assert.doesNotMatch(sql, /\b(?:START TRANSACTION|BEGIN|COMMIT|ROLLBACK)\b/i);
+ this.calls.push({ sql, params });
+ if (this.calls.length > this.responses.length) throw new Error('Unexpected database query');
+ const result = this.responses[this.calls.length - 1];
+ if (result instanceof Error) throw result;
+ return [result];
+ }
+}
+
+test('validates in three SELECTs with a primary-key-only range lock and a separate digest read', async () => {
+ const db = new FakeDb([locked(first, second), [latest(second), latest(first)], [content(first), content(second)]]);
+ await verifySourceBatch(db, config, [second, first]);
+ assert.equal(db.calls.length, 3);
+ const [range, metadata, hashes] = db.calls;
+ assert.match(range.sql, /^SELECT id FROM /);
+ assert.match(range.sql, /WHERE id>=\? ORDER BY id FOR UPDATE$/);
+ assert.doesNotMatch(range.sql, /\b(?:content|deleted|config_id|path|CASE|LIMIT)\b|SHA2|OCTET_LENGTH|id\s+IN\s*\(/i,
+ 'lock every row and gap above the minimum ID, including deleted and unrelated-path rows');
+ assert.deepEqual(range.params, [first.contentId]);
+ assert.match(metadata.sql, /^SELECT id,CASE WHEN config_id=\? AND path=\? THEN \?/);
+ assert.match(metadata.sql, /END AS expected_content_id/);
+ assert.match(metadata.sql, /WHERE deleted=0 AND id>=\? AND \(\(config_id=\? AND path=\? AND id>=\?\) OR/);
+ assert.match(metadata.sql, /ORDER BY id DESC$/);
+ assert.doesNotMatch(metadata.sql, /\bcontent\b|OCTET_LENGTH|SHA2|\bLIMIT\b|id\s+IN\s*\(/i,
+ 'metadata lookup must examine newer IDs without projecting BLOB expressions');
+ assert.deepEqual(metadata.params, [
+ second.configId, second.path, second.contentId,
+ first.configId, first.path, first.contentId,
+ first.contentId,
+ second.configId, second.path, second.contentId,
+ first.configId, first.path, first.contentId
+ ]);
+ assert.match(hashes.sql, /OCTET_LENGTH\(content\) AS size,LOWER\(SHA2\(content,256\)\) AS sha256/);
+ assert.match(hashes.sql, /WHERE deleted=0 AND id IN \(\?,\?\) FOR UPDATE$/);
+ assert.doesNotMatch(hashes.sql, /ORDER BY|\bCASE\b|\bJOIN\b|\bLIMIT\b/i);
+ assert.deepEqual(hashes.params, [second.contentId, first.contentId]);
+});
+
+test('uses server-provided grouping IDs and accepts collation-equivalent case/accent spellings', async () => {
+ const db = new FakeDb([locked(first), [latest(first)], [content(first, { path: '相册/RESUME.png' })]]);
+ await verifySourceBatch(db, config, [first]);
+ assert.deepEqual(db.calls[1].params, [first.configId, first.path, first.contentId, first.contentId, first.configId, first.path, first.contentId]);
+ assert.doesNotMatch(db.calls[1].sql, /BINARY\s+path|LOWER\s*\(path\)/i);
+});
+
+test('rejects a newer active version before performing the batch digest read', async () => {
+ const db = new FakeDb([locked(first, second), [
+ latest(second), latest(first), { id: '9007199254740994', expected_content_id: first.contentId }
+ ]]);
+ await assert.rejects(verifySourceBatch(db, config, [first, second]), changed);
+ assert.equal(db.calls.length, 2);
+});
+
+test('rejects a deleted/missing group or an unexpected server grouping ID before reading hashes', async t => {
+ for (const [label, rows] of [
+ ['missing group', [latest(first)]],
+ ['all deleted', []],
+ ['unexpected group', [latest(first), { id: second.contentId, expected_content_id: '88' }]],
+ ['null CASE result', [latest(first), { id: second.contentId, expected_content_id: null }]]
+ ]) {
+ await t.test(label, async () => {
+ const db = new FakeDb([locked(first, second), rows]);
+ await assert.rejects(verifySourceBatch(db, config, [first, second]), changed);
+ assert.equal(db.calls.length, 2);
+ });
+ }
+});
+
+test('rejects disappearance, size changes, hash changes and wrong IDs in the digest result', async t => {
+ for (const [label, rows] of [
+ ['missing content', [content(first)]],
+ ['wrong size', [content(first), content(second, { size: second.size + 1 })]],
+ ['same size different hash', [content(first), content(second, { sha256: 'c'.repeat(64) })]],
+ ['wrong ID', [content(first), content(second, { id: '9007199254740996' })]],
+ ['duplicate ID', [content(first), content(first)]]
+ ]) {
+ await t.test(label, async () => {
+ const db = new FakeDb([locked(first, second), [latest(first), latest(second)], rows]);
+ await assert.rejects(verifySourceBatch(db, config, [first, second]), changed);
+ assert.equal(db.calls.length, 3);
+ });
+ }
+});
+
+test('preserves unsigned bigint IDs and binds all paths instead of interpolating them', async () => {
+ const large = { ...first, contentId: '18446744073709551615', path: "x.png' OR '1'='1" };
+ const db = new FakeDb([locked(large), [latest(large)], [content(large)]]);
+ await verifySourceBatch(db, { mysql: { database: 'app`db' } }, [large]);
+ for (const call of db.calls) {
+ assert.match(call.sql, /`app``db`\.`infra_file_content`/);
+ assert.ok(!call.sql.includes(large.path));
+ assert.ok(!call.sql.includes(large.contentId));
+ }
+ assert.deepEqual(db.calls[2].params, [large.contentId]);
+});
+
+test('rejects empty batches, duplicate IDs and unsafe numeric IDs before any SQL', async t => {
+ for (const [label, batch, expectedError] of [
+ ['empty batch', [], /不能为空/],
+ ['duplicate content ID', [first, { ...first }], /重复/],
+ ['unsafe content ID', [{ ...first, contentId: Number.MAX_SAFE_INTEGER + 1 }], /安全整数/],
+ ['unsafe config ID', [{ ...first, configId: Number.MAX_SAFE_INTEGER + 1 }], /安全整数/],
+ ['invalid expected size', [{ ...first, size: Number.MAX_SAFE_INTEGER + 1 }], /预期大小/]
+ ]) {
+ await t.test(label, async () => {
+ const db = new FakeDb([]);
+ await assert.rejects(verifySourceBatch(db, config, batch), expectedError);
+ assert.equal(db.calls.length, 0);
+ });
+ }
+});
+
+test('propagates database lock/read errors and leaves rollback to the transaction owner', async () => {
+ const lockFailure = Object.assign(new Error('lock wait timed out'), { code: 'ER_LOCK_WAIT_TIMEOUT' });
+ const db = new FakeDb([lockFailure]);
+ await assert.rejects(verifySourceBatch(db, config, [first]), error => error === lockFailure);
+ assert.equal(db.calls.length, 1);
+});
diff --git a/script/migrate-db-files-to-oss/test/source-current.test.mjs b/script/migrate-db-files-to-oss/test/source-current.test.mjs
new file mode 100644
index 0000000..a9c2654
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/source-current.test.mjs
@@ -0,0 +1,131 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { sourceCurrent } from '../migrate.mjs';
+
+const config = { mysql: { database: 'source_current_test' } };
+const item = {
+ contentId: '9007199254740993', configId: '9007199254740995',
+ path: '头像/Résumé + 100%.png', size: 1234, sha256: 'a'.repeat(64)
+};
+const current = overrides => ({ id: item.contentId, size: String(item.size), sha256: item.sha256, ...overrides });
+
+class FakeDb {
+ constructor(responses) {
+ this.responses = responses;
+ this.calls = [];
+ }
+ async execute(sql, params) {
+ assert.match(sql, /^SELECT\b/, 'source validation must not write or manage the caller transaction');
+ this.calls.push({ sql, params });
+ if (this.calls.length > this.responses.length) throw new Error('Unexpected extra database query');
+ const response = this.responses[this.calls.length - 1];
+ if (response instanceof Error) throw response;
+ return [response];
+ }
+}
+
+function assertMetadataLookup(call, expectedItem, locked = false) {
+ assert.match(call.sql, /^SELECT id FROM /);
+ assert.doesNotMatch(call.sql, /\bcontent\b|SHA2\s*\(|OCTET_LENGTH\s*\(/i,
+ 'the unindexed path/order lookup must not project file bytes or content expressions');
+ assert.match(call.sql, /WHERE deleted=0 AND config_id=\? AND path=\? ORDER BY id DESC LIMIT 1/);
+ assert.doesNotMatch(call.sql, /BINARY\s+path|LOWER\s*\(path\)/i, 'preserve native MySQL path collation');
+ assert.deepEqual(call.params, [expectedItem.configId, expectedItem.path]);
+ if (locked) assert.match(call.sql, / FOR UPDATE$/);
+ else assert.doesNotMatch(call.sql, /FOR UPDATE/i);
+}
+
+function assertPrimaryKeyHashRead(call, expectedItem, locked = false) {
+ assert.match(call.sql, /OCTET_LENGTH\(content\) AS size/);
+ assert.match(call.sql, /LOWER\(SHA2\(content,256\)\) AS sha256/);
+ assert.match(call.sql, /WHERE id=\? AND config_id=\? AND path=\? AND deleted=0/);
+ assert.doesNotMatch(call.sql, /ORDER BY|LIMIT|JOIN/i, 'read the digest through a single primary-key lookup');
+ assert.deepEqual(call.params, [expectedItem.contentId, expectedItem.configId, expectedItem.path]);
+ if (locked) assert.match(call.sql, / FOR UPDATE$/);
+ else assert.doesNotMatch(call.sql, /FOR UPDATE/i);
+}
+
+test('looks up the latest ID without BLOB expressions, then validates size and SHA by primary key', async () => {
+ const db = new FakeDb([[{ id: item.contentId }], [current()]]);
+ await sourceCurrent(db, config, item);
+ assert.equal(db.calls.length, 2);
+ assertMetadataLookup(db.calls[0], item);
+ assertPrimaryKeyHashRead(db.calls[1], item);
+});
+
+test('rejects a newer active version before any content/hash query, without rounding bigint IDs', async () => {
+ const db = new FakeDb([[{ id: '9007199254740994' }]]);
+ await assert.rejects(sourceCurrent(db, config, item), /源图片.*变化或已删除/);
+ assert.equal(db.calls.length, 1);
+ assertMetadataLookup(db.calls[0], item);
+});
+
+test('rejects a missing latest record without issuing a content/hash query', async () => {
+ const db = new FakeDb([[]]);
+ await assert.rejects(sourceCurrent(db, config, item), /源图片.*变化或已删除/);
+ assert.equal(db.calls.length, 1);
+ assertMetadataLookup(db.calls[0], item);
+});
+
+test('rejects content disappearing between the metadata and primary-key reads', async () => {
+ const db = new FakeDb([[{ id: item.contentId }], []]);
+ await assert.rejects(sourceCurrent(db, config, item), /源图片.*变化或已删除/);
+ assert.equal(db.calls.length, 2);
+ assertPrimaryKeyHashRead(db.calls[1], item);
+});
+
+test('rejects changed length, changed hash, or an unexpected primary-key row', async t => {
+ for (const [name, row] of [
+ ['changed size', current({ size: item.size + 1 })],
+ ['changed hash with the same size', current({ sha256: 'b'.repeat(64) })],
+ ['wrong content ID', current({ id: '9007199254740994' })],
+ ['missing hash', current({ sha256: null })]
+ ]) {
+ await t.test(name, async () => {
+ const db = new FakeDb([[{ id: item.contentId }], [row]]);
+ await assert.rejects(sourceCurrent(db, config, item), /源图片.*变化或已删除/);
+ assert.equal(db.calls.length, 2);
+ assertPrimaryKeyHashRead(db.calls[1], item);
+ });
+ }
+});
+
+test('retains FOR UPDATE on both validation queries when the caller owns a transaction', async () => {
+ const db = new FakeDb([[{ id: item.contentId }], [current()]]);
+ await sourceCurrent(db, config, item, true);
+ assert.equal(db.calls.length, 2);
+ assertMetadataLookup(db.calls[0], item, true);
+ assertPrimaryKeyHashRead(db.calls[1], item, true);
+});
+
+test('locked validation still stops at the metadata lookup if the latest version changed', async () => {
+ const db = new FakeDb([[{ id: '9007199254740994' }]]);
+ await assert.rejects(sourceCurrent(db, config, item, true), /源图片.*变化或已删除/);
+ assert.equal(db.calls.length, 1);
+ assertMetadataLookup(db.calls[0], item, true);
+});
+
+test('supports unsigned-bigint-sized IDs and binds paths without interpolation or normalization', async () => {
+ const largeItem = { ...item, contentId: '18446744073709551615', path: "case/IMAGE.png' OR 'x'='x" };
+ const db = new FakeDb([[{ id: largeItem.contentId }], [current({ id: largeItem.contentId })]]);
+ await sourceCurrent(db, { mysql: { database: 'app`db' } }, largeItem, false);
+ assertMetadataLookup(db.calls[0], largeItem);
+ assertPrimaryKeyHashRead(db.calls[1], largeItem);
+ for (const call of db.calls) {
+ assert.match(call.sql, /`app``db`\.`infra_file_content`/);
+ assert.ok(!call.sql.includes(largeItem.contentId));
+ assert.ok(!call.sql.includes(largeItem.path));
+ }
+});
+
+test('propagates database failures instead of accepting an unverified source', async () => {
+ for (const responses of [
+ [Object.assign(new Error('metadata lookup timed out'), { code: 'TEST_TIMEOUT' })],
+ [[{ id: item.contentId }], Object.assign(new Error('hash read failed'), { code: 'TEST_READ_FAILURE' })]
+ ]) {
+ const db = new FakeDb(responses);
+ const expected = responses[responses.length - 1];
+ await assert.rejects(sourceCurrent(db, config, item), error => error === expected);
+ assert.equal(db.calls.length, responses.length);
+ }
+});
diff --git a/script/migrate-db-files-to-oss/test/source-index.test.mjs b/script/migrate-db-files-to-oss/test/source-index.test.mjs
new file mode 100644
index 0000000..699e0d3
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/source-index.test.mjs
@@ -0,0 +1,127 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { loadSourceIndex } from '../lib/source-index.mjs';
+
+const config = { mysql: { database: 'source_index_test' }, sourceConfigIds: ['9007199254740999', '5'] };
+const makeFile = (value, contentId, overrides = {}) => ({
+ id: value, config_id: '5', path: 'image.png', url: 'https://old.example/image.png',
+ type: 'image/png', size: 3, content_id: contentId, ...overrides
+});
+
+class FakeDb {
+ constructor(batches, stageEvents) {
+ this.batches = batches;
+ this.stageEvents = stageEvents;
+ this.calls = [];
+ }
+ async execute(sql, params) {
+ assert.match(sql.trim(), /^SELECT\b/);
+ assert.doesNotMatch(sql, /\b(?:OCTET_LENGTH|SHA2)\s*\(/i);
+ assert.doesNotMatch(sql, /\bcontent\b|SELECT\s+\*/i, 'only metadata columns may be selected');
+ assert.doesNotMatch(sql, /\b(?:NOT EXISTS|CREATE|INSERT|UPDATE|DELETE|ALTER)\b/i);
+ assert.match(sql, /GROUP BY config_id,path/);
+ assert.match(sql, /MAX\(id\) AS id/);
+ assert.match(sql, /WHERE deleted=0 AND config_id IN \(\?,\?\)/);
+ if (this.stageEvents) assert.equal(this.stageEvents.length, this.calls.length + 1, 'announce each phase before executing it');
+ this.calls.push({ sql, params });
+ if (this.calls.length > 3) throw new Error('Unexpected per-file query');
+ return [this.batches[this.calls.length - 1]];
+ }
+}
+
+test('loads all metadata in exactly three SELECTs and preserves bigint IDs and raw spellings', async () => {
+ const stages = [];
+ const db = new FakeDb([
+ [
+ { content_id: '9007199254740995', config_id: '9007199254740999', path: '头像.png' },
+ { content_id: '9', config_id: '5', path: 'IMAGE.png' }
+ ],
+ [makeFile('9007199254740996', '9'), makeFile('2', '9', { path: 'IMAGE.png' }), makeFile('10', null)],
+ [
+ { content_id: '9', path: Buffer.from('image.png') },
+ { content_id: '9', path: Buffer.from('IMAGE.png') },
+ { content_id: '9', path: Buffer.from('image.png ') },
+ { content_id: '9', path: Buffer.from('image.png') },
+ { content_id: '9007199254740995', path: Buffer.from('头像.png') }
+ ]
+ ], stages);
+ const result = await loadSourceIndex(db, config, { progress: { stage: message => stages.push(message) } });
+ assert.equal(db.calls.length, 3);
+ assert.equal(stages.length, 3);
+ assert.equal(result.maxId, '9007199254740995');
+ assert.deepEqual(result.groups.map(group => group.contentId), ['9', '9007199254740995']);
+ assert.equal(result.groups[0].path, 'IMAGE.png', 'the latest row supplies the canonical raw path');
+ assert.deepEqual(result.groups[0].files.map(file => file.id), ['2', '9007199254740996']);
+ assert.deepEqual(result.groups[0].spellings, ['IMAGE.png', 'image.png', 'image.png ']);
+ assert.equal(result.groups[1].configId, '9007199254740999');
+ assert.deepEqual(result.groups[1].files, [], 'orphan BLOBs remain candidates');
+ assert.deepEqual(result.groups[1].spellings, ['头像.png']);
+ assert.deepEqual(result.missingFiles.map(file => file.id), ['10']);
+ assert.equal(Object.hasOwn(result.missingFiles[0], 'content_id'), false);
+ assert.deepEqual(db.calls[0].params, ['5', '9007199254740999']);
+ assert.deepEqual(db.calls[1].params, ['5', '9007199254740999', '5', '9007199254740999']);
+});
+
+test('upper bound applies after grouping and files with newer content are never marked missing', async () => {
+ const bound = '9007199254740993';
+ const db = new FakeDb([
+ [{ content_id: '8', config_id: '5', path: 'image.png' }],
+ [makeFile('1', '8'), makeFile('2', '9007199254740994'), makeFile('3', null)],
+ [{ content_id: '8', path: 'image.png' }, { content_id: '9007199254740994', path: 'newer.png' }]
+ ]);
+ const result = await loadSourceIndex(db, config, { maxId: bound });
+ assert.equal(result.maxId, bound);
+ assert.deepEqual(result.groups.map(group => group.contentId), ['8']);
+ assert.deepEqual(result.groups[0].files.map(file => file.id), ['1']);
+ assert.deepEqual(result.missingFiles.map(file => file.id), ['3']);
+ assert.match(db.calls[0].sql, /GROUP BY config_id,path\) g ON g.id=c.id WHERE g.id<=\?/);
+ assert.doesNotMatch(db.calls[0].sql, /WHERE deleted=0[^)]*id\s*[<>=]/i);
+ assert.doesNotMatch(db.calls[1].sql, /g\.id<=|id<=/);
+ assert.match(db.calls[2].sql, /WHERE c.deleted=0 AND g.id<=\?/);
+ assert.deepEqual(db.calls[0].params, ['5', '9007199254740999', bound]);
+ assert.deepEqual(db.calls[2].params, ['5', '9007199254740999', bound]);
+});
+
+test('all path associations use server-generated content IDs, never JavaScript case folding', async () => {
+ const db = new FakeDb([
+ [{ content_id: '7', config_id: '5', path: 'Résumé.png' }, { content_id: '8', config_id: '5', path: 'resume.png' }],
+ [makeFile('1', '7', { path: 'RESUME.png' }), makeFile('2', '8', { path: 'resume.png' })],
+ [{ content_id: '7', path: Buffer.from('Résumé.png') }, { content_id: '7', path: Buffer.from('RESUME.png') }, { content_id: '8', path: Buffer.from('resume.png') }]
+ ]);
+ const result = await loadSourceIndex(db, config);
+ assert.equal(result.groups.length, 2);
+ assert.deepEqual(result.groups[0].files.map(file => file.id), ['1']);
+ assert.deepEqual(result.groups[1].files.map(file => file.id), ['2']);
+ assert.match(db.calls[1].sql, /g\.config_id=f\.config_id AND g\.path=f\.path/);
+ assert.match(db.calls[2].sql, /SELECT DISTINCT g.id AS content_id,BINARY c.path AS path/);
+ assert.match(db.calls[2].sql, /g\.config_id=c\.config_id AND g\.path=c\.path/);
+ assert.match(db.calls[1].sql, /WHERE f.deleted=0/);
+ assert.match(db.calls[2].sql, /WHERE c.deleted=0/);
+});
+
+test('empty content results still report missing files and use a zero upper bound', async () => {
+ const db = new FakeDb([[], [makeFile('4', null)], []]);
+ const result = await loadSourceIndex(db, config);
+ assert.equal(db.calls.length, 3);
+ assert.equal(result.maxId, '0');
+ assert.deepEqual(result.groups, []);
+ assert.deepEqual(result.missingFiles.map(file => file.id), ['4']);
+ const bounded = await loadSourceIndex(new FakeDb([[], [], []]), config, { maxId: '0' });
+ assert.equal(bounded.maxId, '0');
+});
+
+test('source IDs are deduplicated and database identifiers are safely quoted', async () => {
+ const db = new FakeDb([[], [], []]);
+ const unusual = { mysql: { database: 'app`db' }, sourceConfigIds: ['9007199254740999', '5', '5'] };
+ await loadSourceIndex(db, unusual);
+ assert.match(db.calls[0].sql, /`app``db`\.`infra_file_content`/);
+ assert.deepEqual(db.calls[0].params, ['5', '9007199254740999']);
+});
+
+test('rejects unsafe numeric boundaries and invalid source configurations without SQL', async () => {
+ const db = new FakeDb([[], [], []]);
+ await assert.rejects(loadSourceIndex(db, config, { maxId: Number.MAX_SAFE_INTEGER + 1 }), /大整数/);
+ await assert.rejects(loadSourceIndex(db, config, { maxId: '-1' }), /非负整数/);
+ await assert.rejects(loadSourceIndex(db, { ...config, sourceConfigIds: [] }), /至少一个/);
+ assert.equal(db.calls.length, 0);
+});
diff --git a/script/migrate-db-files-to-oss/test/target-validation.test.mjs b/script/migrate-db-files-to-oss/test/target-validation.test.mjs
new file mode 100644
index 0000000..2d5ca88
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/target-validation.test.mjs
@@ -0,0 +1,245 @@
+import test, { mock } from 'node:test';
+import assert from 'node:assert/strict';
+import { createServer } from 'node:http';
+import { once } from 'node:events';
+import { setImmediate } from 'node:timers/promises';
+import { sha256 } from '../lib/core.mjs';
+import { verifyCheckpoint, verifySwitchedBatch } from '../migrate.mjs';
+
+const CONTENT = Buffer.from('89504e470d0a1a0a0011223344556677', 'hex');
+const CORRUPT = Buffer.alloc(CONTENT.length, 1);
+const CONFIG = { mysql: { database: 'target_test' }, targetConfigId: '20', timeoutMs: 2000, maxFileBytes: 1024, retries: 1 };
+const item = (url, files = [{ id: '9007199254740993' }]) => ({
+ contentId: '9007199254740995', configId: '1', path: 'source.png',
+ key: 'migration/object.png', url, size: CONTENT.length, sha256: sha256(CONTENT), files,
+});
+const stored = entry => ({
+ id: entry.files[0].id, config_id: '20', path: entry.key,
+ url: entry.url, deleted: Buffer.from([0]),
+});
+const oss = (bytes = CONTENT) => ({
+ head: mock.fn(async () => ({ res: { headers: { 'content-length': String(bytes.length) } } })),
+ get: mock.fn(async () => ({ content: bytes })),
+ put: mock.fn(async () => { throw new Error('validation must never upload'); }),
+});
+
+async function server(t, handler) {
+ const instance = createServer(handler);
+ instance.listen(0, '127.0.0.1');
+ await once(instance, 'listening');
+ t.after(async () => {
+ const closing = new Promise((resolve, reject) => instance.close(error => error ? reject(error) : resolve()));
+ instance.closeAllConnections();
+ await closing;
+ });
+ return 'http://127.0.0.1:' + instance.address().port;
+}
+
+test('verified checkpoint validates authenticated and public bytes without PUT or source access', async t => {
+ let publicReads = 0;
+ const url = await server(t, (_request, response) => { publicReads++; response.end(CONTENT); });
+ const entry = item(url);
+ const client = oss();
+ await verifyCheckpoint(client, CONFIG, entry);
+ assert.equal(client.head.mock.callCount(), 1);
+ assert.equal(client.get.mock.callCount(), 1);
+ assert.equal(client.put.mock.callCount(), 0);
+ assert.deepEqual(client.head.mock.calls[0].arguments, [entry.key]);
+ assert.deepEqual(client.get.mock.calls[0].arguments, [entry.key]);
+ assert.equal(publicReads, 1);
+});
+
+test('missing checkpoint object is rejected without downloading or creating another object', async () => {
+ const client = oss();
+ const missing = Object.assign(new Error('missing object'), { code: 'NoSuchKey', status: 404 });
+ client.head = mock.fn(async () => { throw missing; });
+ await assert.rejects(verifyCheckpoint(client, CONFIG, item('http://127.0.0.1:1/unused')), error => error === missing);
+ assert.equal(client.get.mock.callCount(), 0);
+ assert.equal(client.put.mock.callCount(), 0);
+});
+
+test('checkpoint HEAD rejects mismatched sizes or configured size limits before GET', async () => {
+ for (const tooLarge of [false, true]) {
+ const client = oss();
+ if (!tooLarge) client.head = mock.fn(async () => ({ res: { headers: { 'content-length': String(CONTENT.length + 1) } } }));
+ await assert.rejects(verifyCheckpoint(client, { ...CONFIG, maxFileBytes: tooLarge ? 1 : CONFIG.maxFileBytes }, item('http://127.0.0.1:1/unused')), /长度/);
+ assert.equal(client.get.mock.callCount(), 0);
+ assert.equal(client.put.mock.callCount(), 0);
+ }
+});
+
+test('same-length authenticated corruption rejects before public validation', async t => {
+ let publicReads = 0;
+ const url = await server(t, (_request, response) => { publicReads++; response.end(CONTENT); });
+ const client = oss(CORRUPT);
+ await assert.rejects(verifyCheckpoint(client, CONFIG, item(url)), /SHA-256/);
+ assert.equal(publicReads, 0);
+ assert.equal(client.put.mock.callCount(), 0);
+});
+
+test('public corruption or access denial prevents reusing an otherwise valid checkpoint', async t => {
+ const url = await server(t, (request, response) => {
+ if (request.url === '/denied') { response.writeHead(403); response.end(); }
+ else response.end(CORRUPT);
+ });
+ for (const [path, message] of [['/corrupt', /SHA-256/], ['/denied', /HTTP 403/]]) {
+ const client = oss();
+ await assert.rejects(verifyCheckpoint(client, CONFIG, item(url + path)), message);
+ assert.equal(client.put.mock.callCount(), 0);
+ }
+});
+
+test('switched batch reads all metadata once with exact bigint IDs and deduplicates repeated records', async t => {
+ const url = await server(t, (_request, response) => response.end(CONTENT));
+ const first = item(url + '/one');
+ const second = { ...item(url + '/two', [{ id: '9007199254740997' }, { id: '9007199254740997' }]), key: 'migration/two.png' };
+ const db = { execute: mock.fn(async (sql, params) => {
+ assert.match(sql, /SELECT id,config_id,path,url,deleted/);
+ assert.match(sql, /WHERE id IN \(\?,\?\)/);
+ assert.deepEqual(params, ['9007199254740993', '9007199254740997']);
+ return [[stored(first), stored(second)]];
+ }) };
+ const results = await verifySwitchedBatch(db, CONFIG, [first, second]);
+ assert.equal(db.execute.mock.callCount(), 1);
+ assert.deepEqual(results, [{ item: first }, { item: second }]);
+});
+
+test('orphaned source content with no file metadata still validates its public target', async t => {
+ let publicReads = 0;
+ const url = await server(t, (_request, response) => { publicReads++; response.end(CONTENT); });
+ const entry = item(url, []);
+ const db = { execute: mock.fn(async () => { throw new Error('orphan must not query file metadata'); }) };
+ assert.deepEqual(await verifySwitchedBatch(db, CONFIG, [entry]), [{ item: entry }]);
+ assert.equal(db.execute.mock.callCount(), 0);
+ assert.equal(publicReads, 1);
+});
+
+test('mutated, deleted and missing file records are returned as errors before public access', async t => {
+ let publicReads = 0;
+ const url = await server(t, (_request, response) => { publicReads++; response.end(CONTENT); });
+ const entry = item(url);
+ for (const changed of [
+ null,
+ { ...stored(entry), config_id: '21' },
+ { ...stored(entry), path: 'some/other/object.png' },
+ { ...stored(entry), url: url + '/edited' },
+ { ...stored(entry), deleted: Buffer.from([1]) },
+ ]) {
+ const db = { execute: mock.fn(async () => [changed ? [changed] : []]) };
+ const results = await verifySwitchedBatch(db, CONFIG, [entry]);
+ assert.equal(results.length, 1);
+ assert.equal(results[0].item, entry);
+ assert.match(results[0].error.message, /发生变化|已删除/);
+ assert.deepEqual(results.filter(result => !result.error), []);
+ }
+ assert.equal(publicReads, 0);
+});
+
+test('a single missing metadata record rejects an item that has multiple original records', async t => {
+ let publicReads = 0;
+ const url = await server(t, (_request, response) => { publicReads++; response.end(CONTENT); });
+ const entry = item(url, [{ id: '9007199254740993' }, { id: '9007199254740997' }]);
+ const db = { execute: mock.fn(async () => [[stored(entry)]]) };
+ const [result] = await verifySwitchedBatch(db, CONFIG, [entry]);
+ assert.ok(result.error);
+ assert.equal(publicReads, 0);
+});
+
+test('switched public corruption excludes only its own mapping and keeps input order', async t => {
+ const url = await server(t, (request, response) => response.end(request.url === '/bad' ? CORRUPT : CONTENT));
+ const bad = item(url + '/bad', []);
+ const good = item(url + '/good', []);
+ const results = await verifySwitchedBatch({}, CONFIG, [bad, good]);
+ assert.equal(results[0].item, bad);
+ assert.match(results[0].error.message, /SHA-256/);
+ assert.deepEqual(results[1], { item: good });
+ assert.deepEqual(results.filter(result => !result.error).map(result => result.item.url), [good.url]);
+});
+
+test('metadata query errors reject before network validation starts', async t => {
+ let publicReads = 0;
+ const url = await server(t, (_request, response) => { publicReads++; response.end(CONTENT); });
+ const failure = new Error('metadata unavailable');
+ const db = { execute: mock.fn(async () => { throw failure; }) };
+ await assert.rejects(verifySwitchedBatch(db, CONFIG, [item(url)]), error => error === failure);
+ assert.equal(publicReads, 0);
+});
+
+test('final validation is capped at four active requests and settles only after every started request', { timeout: 4000 }, async t => {
+ const responses = new Map();
+ let active = 0, maximum = 0, settled = false;
+ const url = await server(t, (request, response) => {
+ active++;
+ maximum = Math.max(maximum, active);
+ response.once('finish', () => { active--; });
+ responses.set(Number(request.url.slice(1)), response);
+ });
+ const batch = Array.from({ length: 6 }, (_, index) => item(url + '/' + index, []));
+ const pending = verifySwitchedBatch({}, CONFIG, batch).then(results => { settled = true; return results; });
+ const until = async predicate => {
+ const deadline = Date.now() + 1500;
+ while (!predicate()) {
+ if (Date.now() > deadline) assert.fail('validation did not reach the expected request state');
+ await setImmediate();
+ }
+ };
+ await until(() => responses.size === 4);
+ assert.equal(active, 4);
+ assert.equal(settled, false);
+ // Per-item failures are collected, so remaining items must still be checked.
+ responses.get(0).end(CORRUPT);
+ responses.get(1).end(CONTENT);
+ await until(() => responses.size === 6);
+ assert.equal(active, 4);
+ assert.equal(settled, false);
+ responses.get(2).end(CONTENT);
+ responses.get(3).end(CONTENT);
+ responses.get(4).end(CONTENT);
+ await setImmediate();
+ assert.equal(settled, false);
+ responses.get(5).end(CONTENT);
+ const results = await pending;
+ assert.equal(maximum, 4);
+ assert.equal(active, 0);
+ assert.equal(settled, true);
+ assert.equal(results.length, 6);
+ assert.match(results[0].error.message, /SHA-256/);
+ assert.deepEqual(results.slice(1), batch.slice(1).map(entry => ({ item: entry })));
+});
+
+test('empty switched batches perform no database or network work', async () => {
+ const db = { execute: mock.fn(async () => { throw new Error('must not query'); }) };
+ assert.deepEqual(await verifySwitchedBatch(db, CONFIG, []), []);
+ assert.equal(db.execute.mock.callCount(), 0);
+});
+
+test('more than 1000 file records are loaded in bounded sequential queries before public validation', async t => {
+ let completedQueries = 0, activeQueries = 0, maximumQueries = 0, publicReads = 0;
+ const url = await server(t, (_request, response) => {
+ publicReads++;
+ assert.equal(completedQueries, 2);
+ assert.equal(activeQueries, 0);
+ response.end(CONTENT);
+ });
+ const files = Array.from({ length: 1001 }, (_, index) => ({ id: String(9007199254740993n + BigInt(index)) }));
+ const entry = item(url, files);
+ const seenIds = [];
+ const querySizes = [];
+ const db = { execute: mock.fn(async (_sql, params) => {
+ maximumQueries = Math.max(maximumQueries, ++activeQueries);
+ assert.ok(params.length <= 1000);
+ assert.equal(publicReads, 0);
+ querySizes.push(params.length);
+ seenIds.push(...params);
+ await setImmediate();
+ completedQueries++;
+ activeQueries--;
+ return [params.map(id => ({ ...stored(entry), id })).reverse()];
+ }) };
+ assert.deepEqual(await verifySwitchedBatch(db, CONFIG, [entry]), [{ item: entry }]);
+ assert.deepEqual(querySizes, [1000, 1]);
+ assert.deepEqual(seenIds, files.map(file => file.id));
+ assert.equal(maximumQueries, 1);
+ assert.equal(db.execute.mock.callCount(), 2);
+ assert.equal(publicReads, 1);
+});
diff --git a/script/migrate-db-files-to-oss/test/transition-integration.test.mjs b/script/migrate-db-files-to-oss/test/transition-integration.test.mjs
new file mode 100644
index 0000000..8ec9a8c
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/transition-integration.test.mjs
@@ -0,0 +1,213 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { setTimeout as delay } from 'node:timers/promises';
+import { encodePath, sha256 } from '../lib/core.mjs';
+import { ensureJournal, rollbackChanges } from '../lib/journal.mjs';
+import { verifySourceBatch } from '../lib/source-batch.mjs';
+import { switchFiles } from '../lib/transition.mjs';
+
+// Opt in only against the disposable localhost test server. No host, database,
+// credentials or application configuration is read from the project.
+const SCHEMA = 'codex_oss_transition_test';
+const PORT = process.env.MIGRATION_TEST_DB_PORT;
+const IDS = {
+ older: '9007199254741001', first: '9007199254741003', second: '9007199254741005',
+ orphan: '9007199254741007', deletedNewer: '9007199254741009', newVersion: '9007199254741011'
+};
+const FIRST_PATH = '相册/Résumé.PNG';
+const EQUIVALENT_PATH = '相册/RESUME.png';
+const imageBytes = [Buffer.from([1, 2, 3, 4, 5]), Buffer.from([6, 7, 8, 9]), Buffer.from([10, 11, 12])];
+const changedSource = error => error.code === 'MIGRATION_SOURCE_CHANGED';
+const changedRow = error => error.code === 'MIGRATION_CAS_CONFLICT';
+
+test('MySQL batch transitions preserve source locks, atomic journals and rollback', {
+ skip: !PORT, timeout: 45000
+}, async t => {
+ const port = Number(PORT);
+ assert.ok(Number.isInteger(port) && port > 0 && port <= 65535, 'MIGRATION_TEST_DB_PORT must be a valid disposable localhost MySQL port');
+ const { default: mysql } = await import('mysql2/promise');
+ const connectionOptions = {
+ host: '127.0.0.1', port, user: 'root', password: '', connectTimeout: 3000,
+ supportBigNumbers: true, bigNumberStrings: true, jsonStrings: true, dateStrings: true
+ };
+ const db = await mysql.createConnection(connectionOptions);
+ let contender, observer;
+ try {
+ await db.query(`DROP DATABASE IF EXISTS ${SCHEMA}`);
+ await db.query(`CREATE DATABASE ${SCHEMA} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
+ await db.changeUser({ database: SCHEMA });
+ await db.query('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
+ await db.query("SET SESSION sql_mode=CONCAT_WS(',',NULLIF(@@SESSION.sql_mode,''),'STRICT_ALL_TABLES')");
+ await db.query('SET SESSION innodb_lock_wait_timeout=5');
+ await db.query(`CREATE TABLE infra_file_content (
+ id BIGINT UNSIGNED NOT NULL PRIMARY KEY,config_id BIGINT UNSIGNED NOT NULL,
+ path VARCHAR(512) NOT NULL,content MEDIUMBLOB NOT NULL,deleted BIT(1) NOT NULL DEFAULT 0
+ ) ENGINE=InnoDB`);
+ await db.query(`CREATE TABLE infra_file (
+ id BIGINT UNSIGNED NOT NULL PRIMARY KEY,config_id BIGINT UNSIGNED NOT NULL,
+ path VARCHAR(512) NOT NULL,url VARCHAR(1024) NOT NULL,type VARCHAR(128),size INT,
+ deleted BIT(1) NOT NULL DEFAULT 0,
+ update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB`);
+ await ensureJournal(db, SCHEMA);
+
+ contender = await mysql.createConnection({ ...connectionOptions, database: SCHEMA });
+ observer = await mysql.createConnection({ ...connectionOptions, database: SCHEMA });
+ await contender.query('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
+ await contender.query('SET SESSION innodb_lock_wait_timeout=5');
+ const [[thread]] = await contender.query('SELECT CONNECTION_ID() AS id');
+
+ const readFiles = async () => {
+ const [rows] = await db.query('SELECT id,config_id,path,url,type,size,deleted,update_time FROM infra_file ORDER BY id');
+ return rows.map(row => ({ ...row }));
+ };
+ const readStatuses = async runId => {
+ const [rows] = await db.execute('SELECT status FROM infra_file_oss_migration_items WHERE run_id=? ORDER BY item_key', [runId]);
+ return rows.map(row => row.status);
+ };
+ const journalCount = async runId => {
+ const [[row]] = await db.execute('SELECT COUNT(*) AS n FROM infra_file_oss_migration_changes WHERE run_id=?', [runId]);
+ return Number(row.n);
+ };
+
+ async function seed(runId) {
+ await db.rollback();
+ for (const table of ['infra_file_oss_migration_changes', 'infra_file_oss_migration_items', 'infra_file_oss_migration_runs', 'infra_file', 'infra_file_content']) {
+ await db.query(`DELETE FROM ${table}`);
+ }
+ const sources = [
+ [IDS.older, EQUIVALENT_PATH, Buffer.from('older version'), 0],
+ [IDS.first, FIRST_PATH, imageBytes[0], 0],
+ [IDS.second, 'second.png', imageBytes[1], 0],
+ [IDS.orphan, 'orphan.png', imageBytes[2], 0],
+ [IDS.deletedNewer, EQUIVALENT_PATH, Buffer.from('deleted newer version'), 1]
+ ];
+ await db.execute(`INSERT INTO infra_file_content(id,config_id,path,content,deleted) VALUES ${sources.map(() => '(?,5,?,?,?)').join(',')}`, sources.flat());
+ const files = [
+ ['9007199254741101', FIRST_PATH], ['9007199254741102', EQUIVALENT_PATH], ['9007199254741103', 'second.png']
+ ];
+ await db.execute(`INSERT INTO infra_file(id,config_id,path,url,type,size,update_time) VALUES ${files.map(() => "(?,5,?,?,'image/png',1,'2020-01-01 00:00:00')").join(',')}`,
+ files.flatMap(([fileId, path]) => [fileId, path, `https://legacy.test/admin-api/infra/file/5/get/${encodePath(path)}`]));
+ const items = [];
+ for (const [index, [contentId, path]] of [[IDS.first, FIRST_PATH], [IDS.second, 'second.png'], [IDS.orphan, 'orphan.png']].entries()) {
+ const [records] = await db.execute('SELECT id,config_id,path,url,type,size FROM infra_file WHERE config_id=5 AND path=? AND deleted=0 ORDER BY id', [path]);
+ const key = `migration/test/${contentId}.png`;
+ items.push({ contentId, configId: '5', path, size: imageBytes[index].length, sha256: sha256(imageBytes[index]), key, url: `https://oss.test/${key}`, type: 'image/png', files: records.map(row => ({ ...row })) });
+ }
+ await db.execute("INSERT INTO infra_file_oss_migration_runs(run_id,settings,status) VALUES (?,?,'active')", [runId, JSON.stringify({ test: true })]);
+ for (const item of items) {
+ await db.execute("INSERT INTO infra_file_oss_migration_items(run_id,item_key,data,status) VALUES (?,?,?,'verified')", [runId, sha256(`${item.configId}\0${item.path}`), JSON.stringify(item)]);
+ }
+ return { config: { mysql: { database: SCHEMA }, runId, targetConfigId: '30' }, items, originals: await readFiles() };
+ }
+
+ await t.test('batch success switches duplicate metadata and orphan progress; journals restore original values', async () => {
+ const fixture = await seed('batch-success');
+ assert.equal(fixture.items[0].files.length, 2, 'native collation includes the case/accent-equivalent metadata');
+ assert.equal(fixture.items[2].files.length, 0, 'orphan content remains a migration item');
+ await switchFiles(db, fixture.config, fixture.items);
+ const files = await readFiles();
+ for (const item of fixture.items) for (const original of item.files) {
+ const stored = files.find(file => file.id === original.id);
+ assert.equal(stored.config_id, '30');
+ assert.equal(stored.path, item.key);
+ assert.equal(stored.url, item.url);
+ assert.equal(stored.size, item.size);
+ assert.equal(stored.update_time, '2020-01-01 00:00:00');
+ }
+ assert.deepEqual(await readStatuses(fixture.config.runId), ['switched', 'switched', 'switched']);
+ assert.equal(await journalCount(fixture.config.runId), 3);
+ const result = await rollbackChanges(db, SCHEMA, fixture.config.runId);
+ assert.equal(result.restored, 3);
+ assert.deepEqual(result.conflicts, []);
+ assert.deepEqual(await readFiles(), fixture.originals);
+ const [[source]] = await db.execute('SELECT content FROM infra_file_content WHERE id=?', [IDS.first]);
+ assert.deepEqual(source.content, imageBytes[0]);
+ });
+
+ await t.test('a business-row CAS conflict rolls back the entire batch and preserves concurrent edits', async () => {
+ const fixture = await seed('cas-conflict');
+ await db.execute('UPDATE infra_file SET url=? WHERE id=?', ['https://user-edit.test/new.png', fixture.items[1].files[0].id]);
+ const beforeAttempt = await readFiles();
+ await assert.rejects(switchFiles(db, fixture.config, fixture.items), changedRow);
+ assert.deepEqual(await readFiles(), beforeAttempt);
+ assert.deepEqual(await readStatuses(fixture.config.runId), ['verified', 'verified', 'verified']);
+ assert.equal(await journalCount(fixture.config.runId), 0);
+ });
+
+ await t.test('a final progress-state CAS conflict rolls back already-written files and journals', async () => {
+ const fixture = await seed('status-conflict');
+ const orphan = fixture.items[2];
+ await db.execute("UPDATE infra_file_oss_migration_items SET status='failed' WHERE run_id=? AND item_key=?", [fixture.config.runId, sha256(`${orphan.configId}\0${orphan.path}`)]);
+ await assert.rejects(switchFiles(db, fixture.config, fixture.items), changedRow);
+ assert.deepEqual(await readFiles(), fixture.originals);
+ assert.deepEqual((await readStatuses(fixture.config.runId)).sort(), ['failed', 'verified', 'verified']);
+ assert.equal(await journalCount(fixture.config.runId), 0);
+ });
+
+ await t.test('source mutations and a newer collation-equivalent version prevent every database switch', async t2 => {
+ for (const kind of ['same-size-hash-change', 'deleted-source', 'newer-equivalent-path']) {
+ await t2.test(kind, async () => {
+ const fixture = await seed(kind);
+ if (kind === 'same-size-hash-change') await db.execute('UPDATE infra_file_content SET content=? WHERE id=?', [Buffer.from([9, 9, 9, 9, 9]), IDS.first]);
+ else if (kind === 'deleted-source') await db.execute('UPDATE infra_file_content SET deleted=1 WHERE id=?', [IDS.first]);
+ else await db.execute('INSERT INTO infra_file_content(id,config_id,path,content) VALUES (?,5,?,?)', [IDS.newVersion, EQUIVALENT_PATH, Buffer.from('new active version')]);
+ await assert.rejects(switchFiles(db, fixture.config, fixture.items), changedSource);
+ assert.deepEqual(await readFiles(), fixture.originals);
+ assert.deepEqual(await readStatuses(fixture.config.runId), ['verified', 'verified', 'verified']);
+ assert.equal(await journalCount(fixture.config.runId), 0);
+ });
+ }
+ });
+
+ async function proveRangeLock(kind, statement, parameters) {
+ const fixture = await seed(`range-${kind}`);
+ let outcome, pending;
+ await db.beginTransaction();
+ try {
+ // Must be the first consistent read after BEGIN; the locking read
+ // inside verification establishes protection before the RR snapshot.
+ await verifySourceBatch(db, fixture.config, [fixture.items[0]]);
+ pending = contender.execute(statement, parameters).then(
+ value => { outcome = { value }; }, error => { outcome = { error }; }
+ );
+ const deadline = Date.now() + 3000;
+ let observedWait = false;
+ while (Date.now() < deadline) {
+ assert.equal(outcome, undefined, 'concurrent source mutation must not complete before commit');
+ const [transactions] = await observer.execute('SELECT trx_state FROM information_schema.innodb_trx WHERE trx_mysql_thread_id=?', [thread.id]);
+ if (transactions.some(transaction => transaction.trx_state === 'LOCK WAIT')) { observedWait = true; break; }
+ await delay(20);
+ }
+ assert.ok(observedWait, 'observe an actual InnoDB lock wait, not merely a delayed promise');
+ assert.equal(outcome, undefined);
+ await db.commit();
+ await pending;
+ if (outcome.error) throw outcome.error;
+ assert.equal(outcome.value[0].affectedRows, 1, 'mutation completes after the source lock is released');
+ } finally {
+ await db.rollback();
+ if (pending) await pending;
+ }
+ // Once the waiting writer commits, the next batch must detect the new
+ // active version rather than continuing with its old snapshot.
+ await db.beginTransaction();
+ try {
+ await assert.rejects(verifySourceBatch(db, fixture.config, [fixture.items[0]]), changedSource);
+ } finally { await db.rollback(); }
+ }
+
+ await t.test('RR range locks block insertion of a new matching version until commit', async () => {
+ await proveRangeLock('insert', 'INSERT INTO infra_file_content(id,config_id,path,content) VALUES (?,5,?,?)', [IDS.newVersion, EQUIVALENT_PATH, Buffer.from('concurrent version')]);
+ });
+ await t.test('RR range locks include deleted rows and block undeleting a newer version until commit', async () => {
+ await proveRangeLock('undelete', 'UPDATE infra_file_content SET deleted=0 WHERE id=?', [IDS.deletedNewer]);
+ });
+ } finally {
+ await db.rollback().catch(() => {});
+ if (contender) await contender.end();
+ if (observer) await observer.end();
+ try { await db.query(`DROP DATABASE IF EXISTS ${SCHEMA}`); }
+ finally { await db.end(); }
+ }
+});
diff --git a/script/migrate-db-files-to-oss/test/transition.test.mjs b/script/migrate-db-files-to-oss/test/transition.test.mjs
new file mode 100644
index 0000000..d76d959
--- /dev/null
+++ b/script/migrate-db-files-to-oss/test/transition.test.mjs
@@ -0,0 +1,251 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { switchFiles } from '../lib/transition.mjs';
+import { sha256 } from '../lib/core.mjs';
+
+const config = { mysql: { database: 'transition_test' }, runId: 'run', targetConfigId: '20' };
+const itemKey = item => sha256(`${item.configId}\0${item.path}`);
+const clone = value => Buffer.isBuffer(value) ? Buffer.from(value)
+ : Array.isArray(value) ? value.map(clone)
+ : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)])) : value;
+
+function fixture({ orphan = false, count = 1 } = {}) {
+ const item = {
+ contentId: '9007199254740993', configId: '5', path: '头像/原图.png',
+ size: 12, sha256: 'a'.repeat(64), key: 'migration/new.png',
+ url: 'https://oss/new.png', type: 'image/png', files: []
+ };
+ if (!orphan) for (let index = 0; index < count; index++) item.files.push({
+ id: String(9007199254741093n + BigInt(index)), config_id: '5', path: item.path,
+ url: 'https://old/old.png', type: 'image/png', size: 12
+ });
+ return [item];
+}
+
+class TransitionDb {
+ constructor(items) {
+ this.items = items;
+ this.calls = [];
+ this.files = Object.fromEntries(items.flatMap(item => item.files.map(file => [String(file.id), clone(file)])));
+ this.states = Object.fromEntries(items.map(item => [itemKey(item), 'verified']));
+ this.logs = [];
+ this.statusWarning = 0;
+ this.statusAffected = null;
+ this.fileAffected = null;
+ }
+ async beginTransaction() {
+ this.calls.push({ transaction: 'begin' });
+ if (this.beginFailure) throw this.beginFailure;
+ this.saved = clone({ files: this.files, states: this.states, logs: this.logs });
+ }
+ async commit() {
+ this.calls.push({ transaction: 'commit' });
+ if (this.commitFailure) throw this.commitFailure;
+ this.saved = undefined;
+ }
+ async rollback() {
+ this.calls.push({ transaction: 'rollback' });
+ if (this.rollbackFailure) throw this.rollbackFailure;
+ if (this.saved) Object.assign(this, this.saved);
+ this.saved = undefined;
+ }
+ async execute(sql, params = []) {
+ this.calls.push({ sql, params });
+ if (sql.startsWith('SELECT id FROM')) {
+ if (this.sourceFailure) throw this.sourceFailure;
+ return [this.items.map(item => ({ id: item.contentId }))];
+ }
+ if (sql.startsWith('SELECT id,CASE')) {
+ return [this.items.map(item => ({ id: this.newerSource ? String(BigInt(item.contentId) + 1n) : item.contentId, expected_content_id: item.contentId }))];
+ }
+ if (sql.startsWith('SELECT id,config_id,path,OCTET_LENGTH')) {
+ return [this.items.map(item => ({ id: item.contentId, config_id: item.configId, path: item.path, size: item.size, sha256: item.sha256 }))];
+ }
+ if (sql.includes('information_schema.COLUMNS')) {
+ return [[...['id', 'config_id', 'path', 'url', 'type', 'size', 'deleted', 'update_time'].map(name => ({
+ name, extra: name === 'update_time' ? 'on update CURRENT_TIMESTAMP' : '', engine: 'InnoDB'
+ }))]];
+ }
+ if (sql.startsWith('UPDATE') && sql.includes('`infra_file`')) {
+ const [set, where] = sql.split(' WHERE ');
+ let cursor = (set.match(/\?/g) || []).length;
+ const groups = where.split(' OR ');
+ for (const group of groups) {
+ const pk = String(params[cursor]);
+ cursor += (group.match(/\?/g) || []).length;
+ const item = this.items.find(value => value.files.some(file => String(file.id) === pk));
+ assert.ok(item, 'file updates must target the selected batch');
+ this.files[pk] = { ...this.files[pk], config_id: config.targetConfigId, path: item.key, url: item.url, type: item.type, size: item.size };
+ }
+ return [{ affectedRows: this.fileAffected ?? groups.length, warningStatus: 0 }];
+ }
+ if (sql.startsWith('SELECT `')) {
+ return [params.map(pk => this.files[String(pk)])];
+ }
+ if (sql.startsWith('INSERT INTO')) {
+ if (this.journalFailure) throw this.journalFailure;
+ for (let index = 0; index < params.length; index += 7) this.logs.push(params.slice(index, index + 7));
+ return [{ affectedRows: params.length / 7, warningStatus: 0 }];
+ }
+ if (sql.startsWith('UPDATE') && sql.includes('`infra_file_oss_migration_items`')) {
+ assert.match(sql, /WHERE run_id=\? AND status='verified' AND item_key IN \(/);
+ assert.match(sql, /SET status='switched',error=NULL,updated_at=CURRENT_TIMESTAMP/);
+ assert.equal(params[0], config.runId);
+ let affected = 0;
+ for (const key of params.slice(1)) if (this.states[key] === 'verified') { this.states[key] = 'switched'; affected++; }
+ return [{ affectedRows: this.statusAffected ?? affected, warningStatus: this.statusWarning }];
+ }
+ throw new Error(`Unexpected query: ${sql}`);
+ }
+}
+
+const state = db => clone({ files: db.files, states: db.states, logs: db.logs });
+
+test('source range verification is the first operation after BEGIN, then files and guarded item status commit together', async () => {
+ const items = fixture();
+ const db = new TransitionDb(items);
+ const result = await switchFiles(db, config, items);
+ assert.deepEqual(Object.keys(result), ['sourceMs', 'writeMs', 'statusMs', 'commitMs']);
+ assert.ok(Object.values(result).every(value => Number.isFinite(value) && value >= 0));
+ assert.equal(db.calls[0].transaction, 'begin');
+ assert.match(db.calls[1].sql, /^SELECT id FROM .* WHERE id>=\? ORDER BY id FOR UPDATE$/);
+ assert.match(db.calls[2].sql, /^SELECT id,CASE/);
+ assert.match(db.calls[3].sql, /^SELECT id,config_id,path,OCTET_LENGTH/);
+ assert.ok(db.calls[4].sql.includes('information_schema.COLUMNS'));
+ assert.equal(db.calls.at(-1).transaction, 'commit');
+ assert.equal(db.files[items[0].files[0].id].url, items[0].url);
+ assert.equal(db.states[itemKey(items[0])], 'switched');
+ assert.equal(db.logs.length, 1);
+ const statusCall = db.calls.find(call => call.sql?.includes("SET status='switched'"));
+ assert.deepEqual(statusCall.params, [config.runId, itemKey(items[0])]);
+});
+
+test('more than 100 file records use bounded write chunks in one source-locked transaction', async () => {
+ const items = fixture({ count: 101 });
+ const db = new TransitionDb(items);
+ await switchFiles(db, config, items);
+ assert.deepEqual(db.calls.filter(call => call.transaction).map(call => call.transaction), ['begin', 'commit']);
+ assert.equal(db.calls.filter(call => call.sql?.startsWith('SELECT id FROM')).length, 1);
+ const writes = db.calls.filter(call => call.sql?.startsWith('UPDATE') && call.sql.includes('`infra_file`'));
+ assert.equal(writes.length, 2);
+ assert.equal(writes[0].sql.split(' WHERE ')[1].split(' OR ').length, 100);
+ assert.equal(writes[1].sql.split(' WHERE ')[1].split(' OR ').length, 1);
+ assert.equal(db.logs.length, 101);
+ assert.equal(Object.values(db.files).filter(file => file.url === items[0].url).length, 101);
+});
+
+test('orphan content is source-verified and status-switched even without any infra_file rows', async () => {
+ const items = fixture({ orphan: true });
+ const db = new TransitionDb(items);
+ await switchFiles(db, config, items);
+ assert.equal(db.states[itemKey(items[0])], 'switched');
+ assert.equal(db.logs.length, 0);
+ assert.equal(db.calls.filter(call => call.sql).length, 4);
+ assert.equal(db.calls.some(call => call.sql?.includes('information_schema')), false);
+});
+
+test('mixed regular and orphan items both participate in one guarded status update', async () => {
+ const items = fixture();
+ items.push({ ...fixture({ orphan: true })[0], contentId: '9007199254740994', path: 'orphan.png' });
+ const db = new TransitionDb(items);
+ await switchFiles(db, config, items);
+ assert.equal(db.logs.length, 1);
+ assert.deepEqual(Object.values(db.states), ['switched', 'switched']);
+});
+
+test('source errors are propagated unchanged after a successful rollback', async () => {
+ const items = fixture();
+ const db = new TransitionDb(items);
+ const failure = Object.assign(new Error('source changed'), { code: 'MIGRATION_SOURCE_CHANGED' });
+ db.sourceFailure = failure;
+ await assert.rejects(switchFiles(db, config, items), error => error === failure);
+ assert.deepEqual(db.calls.filter(call => call.transaction).map(call => call.transaction), ['begin', 'rollback']);
+ assert.equal(db.calls.some(call => call.sql?.startsWith('UPDATE')), false);
+});
+
+test('a newer source version stops the transaction before any file updates', async () => {
+ const items = fixture();
+ const db = new TransitionDb(items);
+ db.newerSource = true;
+ await assert.rejects(switchFiles(db, config, items), { code: 'MIGRATION_SOURCE_CHANGED' });
+ assert.equal(db.calls.some(call => call.sql?.startsWith('UPDATE')), false);
+ assert.equal(db.calls.at(-1).transaction, 'rollback');
+});
+
+test('file CAS mismatches roll back all file edits and preserve the conflict code', async () => {
+ const items = fixture();
+ const db = new TransitionDb(items);
+ const before = state(db);
+ db.fileAffected = 0;
+ await assert.rejects(switchFiles(db, config, items), { code: 'MIGRATION_CAS_CONFLICT' });
+ assert.deepEqual(state(db), before);
+ assert.equal(db.calls.some(call => call.sql?.includes("SET status='switched'")), false);
+});
+
+test('non-verified item status rolls back previously updated files and journal inserts', async () => {
+ const items = fixture();
+ const db = new TransitionDb(items);
+ db.states[itemKey(items[0])] = 'planned';
+ const before = state(db);
+ await assert.rejects(switchFiles(db, config, items), { code: 'MIGRATION_CAS_CONFLICT' });
+ assert.deepEqual(state(db), before);
+ assert.ok(db.calls.some(call => call.sql?.startsWith('INSERT INTO')));
+});
+
+test('item status SQL warnings roll back files, logs and item statuses', async () => {
+ const items = fixture();
+ const db = new TransitionDb(items);
+ const before = state(db);
+ db.statusWarning = 1;
+ await assert.rejects(switchFiles(db, config, items), { code: 'MIGRATION_SQL_WARNING' });
+ assert.deepEqual(state(db), before);
+});
+
+test('rollback failure is fatal and prohibits fallback while preserving both causes', async () => {
+ const items = fixture();
+ const db = new TransitionDb(items);
+ db.sourceFailure = Object.assign(new Error('source failed'), { code: 'MIGRATION_SOURCE_CHANGED' });
+ db.rollbackFailure = new Error('connection lost');
+ await assert.rejects(switchFiles(db, config, items), error => {
+ assert.equal(error.code, 'MIGRATION_ROLLBACK_FAILED');
+ assert.equal(error.fatal, true);
+ assert.equal(error.noFallback, true);
+ assert.equal(error.cause, db.sourceFailure);
+ assert.equal(error.rollbackError, db.rollbackFailure);
+ return true;
+ });
+});
+
+test('commit acknowledgement failure is fatal even if a rollback call subsequently succeeds', async () => {
+ const items = fixture();
+ const db = new TransitionDb(items);
+ db.commitFailure = Object.assign(new Error('commit transport failure'), { code: 'PROTOCOL_CONNECTION_LOST' });
+ await assert.rejects(switchFiles(db, config, items), error => {
+ assert.equal(error.code, 'PROTOCOL_CONNECTION_LOST');
+ assert.equal(error.fatal, true);
+ assert.equal(error.noFallback, true);
+ assert.equal(error.cause, db.commitFailure);
+ return true;
+ });
+ assert.deepEqual(db.calls.filter(call => call.transaction).map(call => call.transaction), ['begin', 'commit', 'rollback']);
+});
+
+test('BEGIN failure is propagated without performing any reads, writes or rollback', async () => {
+ const items = fixture();
+ const db = new TransitionDb(items);
+ db.beginFailure = new Error('cannot begin');
+ await assert.rejects(switchFiles(db, config, items), error => error === db.beginFailure);
+ assert.deepEqual(db.calls, [{ transaction: 'begin' }]);
+});
+
+test('empty batches are no-ops and malformed or duplicate input is rejected before BEGIN', async () => {
+ const db = new TransitionDb([]);
+ assert.deepEqual(await switchFiles(db, config, []), { sourceMs: 0, writeMs: 0, statusMs: 0, commitMs: 0 });
+ await assert.rejects(switchFiles(db, config, null), /数组/);
+ await assert.rejects(switchFiles(db, config, [{ ...fixture()[0], files: null }]), /files/);
+ await assert.rejects(switchFiles(db, config, [fixture()[0], fixture()[0]]), /重复源路径/);
+ const duplicateFiles = fixture();
+ duplicateFiles[0].files.push({ ...duplicateFiles[0].files[0] });
+ await assert.rejects(switchFiles(db, config, duplicateFiles), /重复文件记录/);
+ assert.equal(db.calls.length, 0);
+});
diff --git a/sql/mysql/recent/20261105_member_recent_cfg_unique.sql b/sql/mysql/recent/20261105_member_recent_cfg_unique.sql
new file mode 100644
index 0000000..83bc010
--- /dev/null
+++ b/sql/mysql/recent/20261105_member_recent_cfg_unique.sql
@@ -0,0 +1,70 @@
+-- 修复重复朋友圈配置,并保证同一用户最多只有一条有效配置。
+-- 适用 MySQL 5.7 / 8.0。执行前选择业务库,并备份 member_recent_cfg。
+-- 先部署兼容重复数据/唯一键冲突的代码;执行期间暂停配置写入(登录初始化、隐私设置、后台编辑)。
+-- DDL 会隐式提交;如中途失败,修复原因后可重跑,不能依靠 ROLLBACK 撤销整个脚本。
+-- 只逻辑删除较旧行,不重建默认配置,不修改保留行的隐私/通知选项。
+-- member_id 引用 member_user 的全局主键,唯一键不额外拼接可能为 NULL 的 tenant_id。
+
+-- 预检:完整展示重复配置,便于核对 home_hide/global_hide/privacy_secure/short_note/is_push 等实际字段。
+-- 保留顺序与服务查询一致:update_time 最新优先,时间相同取 id 最大;NULL 更新时间排在最后。
+SELECT cfg.*
+FROM `member_recent_cfg` cfg
+INNER JOIN (
+ SELECT `member_id`, COUNT(*) AS active_count
+ FROM `member_recent_cfg`
+ WHERE `deleted` = b'0'
+ GROUP BY `member_id`
+ HAVING COUNT(*) > 1
+) duplicated ON duplicated.`member_id` = cfg.`member_id`
+WHERE cfg.`deleted` = b'0'
+ORDER BY cfg.`member_id`, cfg.`update_time` DESC, cfg.`id` DESC;
+
+-- 先物化待删除 id,再更新,避免修改 update_time 影响同一批次的保留顺序。
+-- GROUP BY 阻止 MySQL 5.7/8 合并派生表,避免 1093 自查询更新错误。
+UPDATE `member_recent_cfg` obsolete
+INNER JOIN (
+ SELECT older.`id`
+ FROM `member_recent_cfg` older
+ INNER JOIN `member_recent_cfg` newer
+ ON newer.`member_id` = older.`member_id`
+ AND newer.`deleted` = b'0'
+ AND (
+ newer.`update_time` > older.`update_time`
+ OR (older.`update_time` IS NULL AND newer.`update_time` IS NOT NULL)
+ OR (newer.`update_time` <=> older.`update_time` AND newer.`id` > older.`id`)
+ )
+ WHERE older.`deleted` = b'0'
+ GROUP BY older.`id`
+) duplicate_ids ON duplicate_ids.`id` = obsolete.`id`
+SET obsolete.`deleted` = b'1', obsolete.`update_time` = CURRENT_TIMESTAMP
+WHERE obsolete.`deleted` = b'0';
+SELECT ROW_COUNT() AS logically_deleted_duplicate_configs;
+
+-- 已删除行生成 NULL,保留所有删除历史,同时允许以后重新创建配置。
+SET @cfg_add_column_sql = IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'member_recent_cfg' AND COLUMN_NAME = 'active_unique_flag') = 0,
+ 'ALTER TABLE `member_recent_cfg` ADD COLUMN `active_unique_flag` tinyint GENERATED ALWAYS AS (CASE WHEN `deleted` = 0 THEN 1 ELSE NULL END) STORED COMMENT ''有效配置唯一标记,删除记录为NULL''',
+ 'SELECT ''active_unique_flag already exists'' AS migration_step'
+);
+PREPARE cfg_stmt FROM @cfg_add_column_sql;
+EXECUTE cfg_stmt;
+DEALLOCATE PREPARE cfg_stmt;
+
+SET @cfg_add_index_sql = IF(
+ (SELECT COUNT(*) FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'member_recent_cfg' AND INDEX_NAME = 'uk_member_recent_cfg_active') = 0,
+ 'ALTER TABLE `member_recent_cfg` ADD UNIQUE KEY `uk_member_recent_cfg_active` (`member_id`, `active_unique_flag`)',
+ 'SELECT ''uk_member_recent_cfg_active already exists'' AS migration_step'
+);
+PREPARE cfg_stmt FROM @cfg_add_index_sql;
+EXECUTE cfg_stmt;
+DEALLOCATE PREPARE cfg_stmt;
+
+-- 验收:应无重复分组;索引 NON_UNIQUE 应为 0。
+SELECT `member_id`, COUNT(*) AS active_count
+FROM `member_recent_cfg`
+WHERE `deleted` = b'0'
+GROUP BY `member_id`
+HAVING COUNT(*) > 1;
+SHOW INDEX FROM `member_recent_cfg` WHERE Key_name = 'uk_member_recent_cfg_active';
diff --git a/sql/mysql/recent/20261105_member_recent_tag_unique.sql b/sql/mysql/recent/20261105_member_recent_tag_unique.sql
new file mode 100644
index 0000000..3928d31
--- /dev/null
+++ b/sql/mysql/recent/20261105_member_recent_tag_unique.sql
@@ -0,0 +1,63 @@
+-- 修复重复朋友圈标签,并保证同一用户、同一类型最多只有一条有效标签。
+-- 适用 MySQL 5.7 / 8.0。执行前选择业务库,并备份 member_recent_tag。
+-- 先部署兼容重复数据/唯一键冲突的代码;执行期间暂停标签写入(登录、后台编辑、认证、VIP及清理任务)。
+-- DDL 会隐式提交;如中途失败,修复原因后可重跑,不能依靠 ROLLBACK 撤销整个脚本。
+-- 仅逻辑删除重复项,保留最小 id;不修改保留行,不物理删除任何记录。
+
+-- member_id 引用 member_user 的全局主键,无需额外拼接租户维度。
+-- 历史标签的 tenant_id 可能为 NULL,将其直接纳入唯一键会让重复有效标签漏过约束。
+SET @tag_group_columns = '`member_id`, `tag_biz_type`';
+
+-- 预检:正常业务类型应为 1~5。NULL 类型不参与本次去重/约束,保留原始异常数据供单独核查。
+SELECT `id`, `member_id`, `tag_biz_type`
+FROM `member_recent_tag`
+WHERE `deleted` = b'0' AND (`tag_biz_type` IS NULL OR `tag_biz_type` NOT IN ('1', '2', '3', '4', '5'));
+
+SET @tag_duplicate_sql = CONCAT(
+ 'SELECT ', @tag_group_columns, ', MIN(`id`) AS keep_id, COUNT(*) AS active_count ',
+ 'FROM `member_recent_tag` WHERE `deleted` = b''0'' AND `tag_biz_type` IS NOT NULL ',
+ 'GROUP BY ', @tag_group_columns, ' HAVING COUNT(*) > 1'
+);
+PREPARE tag_stmt FROM @tag_duplicate_sql;
+EXECUTE tag_stmt;
+DEALLOCATE PREPARE tag_stmt;
+
+-- 逻辑删除同组的其余有效行。GROUP BY 派生表在 MySQL 5.7/8 中物化,避免 1093 自查询更新错误。
+SET @tag_cleanup_sql = CONCAT(
+ 'UPDATE `member_recent_tag` tag INNER JOIN (', @tag_duplicate_sql, ') kept ',
+ 'ON tag.`member_id` = kept.`member_id` AND tag.`tag_biz_type` = kept.`tag_biz_type` ',
+ 'SET tag.`deleted` = b''1'', tag.`update_time` = CURRENT_TIMESTAMP ',
+ 'WHERE tag.`deleted` = b''0'' AND tag.`id` <> kept.keep_id'
+);
+PREPARE tag_stmt FROM @tag_cleanup_sql;
+EXECUTE tag_stmt;
+SELECT ROW_COUNT() AS logically_deleted_duplicate_tags;
+DEALLOCATE PREPARE tag_stmt;
+
+-- 已删除行生成 NULL,利用 MySQL 唯一索引允许多个 NULL 的语义保留全部删除历史。
+-- 不能直接使用 (member_id, tag_biz_type, deleted) 唯一键,否则第二次删除同类型标签会冲突。
+SET @tag_add_column_sql = IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'member_recent_tag' AND COLUMN_NAME = 'active_unique_flag') = 0,
+ 'ALTER TABLE `member_recent_tag` ADD COLUMN `active_unique_flag` tinyint GENERATED ALWAYS AS (CASE WHEN `deleted` = 0 THEN 1 ELSE NULL END) STORED COMMENT ''有效标签唯一标记,删除记录为NULL''',
+ 'SELECT ''active_unique_flag already exists'' AS migration_step'
+);
+PREPARE tag_stmt FROM @tag_add_column_sql;
+EXECUTE tag_stmt;
+DEALLOCATE PREPARE tag_stmt;
+
+SET @tag_add_index_sql = IF(
+ (SELECT COUNT(*) FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'member_recent_tag' AND INDEX_NAME = 'uk_member_recent_tag_active') = 0,
+ CONCAT('ALTER TABLE `member_recent_tag` ADD UNIQUE KEY `uk_member_recent_tag_active` (', @tag_group_columns, ', `active_unique_flag`)'),
+ 'SELECT ''uk_member_recent_tag_active already exists'' AS migration_step'
+);
+PREPARE tag_stmt FROM @tag_add_index_sql;
+EXECUTE tag_stmt;
+DEALLOCATE PREPARE tag_stmt;
+
+-- 验收:应无重复分组;索引 NON_UNIQUE 应为 0;正常新增标签无需写入生成列。
+PREPARE tag_stmt FROM @tag_duplicate_sql;
+EXECUTE tag_stmt;
+DEALLOCATE PREPARE tag_stmt;
+SHOW INDEX FROM `member_recent_tag` WHERE Key_name = 'uk_member_recent_tag_active';
diff --git a/sql/mysql/recent/member_recent.sql b/sql/mysql/recent/member_recent.sql
index a1731f3..a47ac02 100644
--- a/sql/mysql/recent/member_recent.sql
+++ b/sql/mysql/recent/member_recent.sql
@@ -169,7 +169,9 @@ CREATE TABLE `member_recent_tag`
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
- PRIMARY KEY (`id`) USING BTREE
+ `active_unique_flag` tinyint GENERATED ALWAYS AS (CASE WHEN `deleted` = 0 THEN 1 ELSE NULL END) STORED COMMENT '有效标签唯一标记,删除记录为NULL',
+ PRIMARY KEY (`id`) USING BTREE,
+ UNIQUE KEY `uk_member_recent_tag_active` (`member_id`, `tag_biz_type`, `active_unique_flag`)
) ENGINE = InnoDB AUTO_INCREMENT = 32 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '朋友圈用户tag';
-- ----------------------------
@@ -189,7 +191,9 @@ CREATE TABLE `member_recent_cfg`
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
- PRIMARY KEY (`id`) USING BTREE
+ `active_unique_flag` tinyint GENERATED ALWAYS AS (CASE WHEN `deleted` = 0 THEN 1 ELSE NULL END) STORED COMMENT '有效配置唯一标记,删除记录为NULL',
+ PRIMARY KEY (`id`) USING BTREE,
+ UNIQUE KEY `uk_member_recent_cfg_active` (`member_id`, `active_unique_flag`)
) ENGINE = InnoDB AUTO_INCREMENT = 32 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '用户朋友圈相关配置';
-- ----------------------------
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImpl.java
index 95a71b3..89d8f70 100644
--- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImpl.java
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImpl.java
@@ -11,10 +11,12 @@ import cn.iocoder.yudao.module.member.dal.redis.recent.MemberRecentRedisDAO;
import cn.iocoder.yudao.module.member.enums.MemberErrorCode;
import cn.iocoder.yudao.module.member.service.opentype.OpenTypeService;
import lombok.SneakyThrows;
+import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
+import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -61,17 +63,23 @@ public class MemberRecentCfgServiceImpl implements MemberRecentCfgService {
* 创建或新增配置
*/
@Override
+ @SneakyThrows
+ @Transactional(propagation = Propagation.NOT_SUPPORTED)
public MemberRecentCfgDO createOrUpdate(MemberRecentCfgReqVo dto) {
- MemberRecentCfgDO detail = null;
- if (ObjectUtil.isNotEmpty(dto.getMemberId())) {
- detail = recentCfgMapper.selectOne(new LambdaQueryWrapperX()
- .eq(MemberRecentCfgDO::getMemberId, dto.getMemberId())
- );
- }
- MemberRecentCfgDO entity = BeanUtil.toBean(dto, MemberRecentCfgDO.class);
- if (ObjectUtil.isEmpty(detail)) {
- recentCfgMapper.insert(entity);
- } else {
+ return memberRecentRedisDAO.lock(dto.getMemberId(), UPDATE_TIMEOUT_MILLIS, () -> {
+ MemberRecentCfgDO detail = selectCurrentConfig(dto.getMemberId(), true);
+ MemberRecentCfgDO entity = BeanUtil.toBean(dto, MemberRecentCfgDO.class);
+ if (detail == null) {
+ try {
+ recentCfgMapper.insert(entity);
+ return entity;
+ } catch (DuplicateKeyException ex) {
+ detail = selectCurrentConfig(dto.getMemberId(), true);
+ if (detail == null) {
+ throw ex;
+ }
+ }
+ }
// 判断是否修改了`全局隐身`和`首页隐身`
if (ObjectUtil.isNotEmpty(dto.getHomeHide())) {
if (dto.getHomeHide()) {
@@ -83,8 +91,8 @@ public class MemberRecentCfgServiceImpl implements MemberRecentCfgService {
}
entity.setId(detail.getId());
recentCfgMapper.updateById(entity);
- }
- return entity;
+ return entity;
+ });
}
/**
@@ -92,26 +100,48 @@ public class MemberRecentCfgServiceImpl implements MemberRecentCfgService {
*/
@Override
@SneakyThrows
- @Transactional(rollbackFor = Exception.class)
+ @Transactional(propagation = Propagation.NOT_SUPPORTED)
public MemberRecentCfgDO getOrCreate(Long memberId) {
- MemberRecentCfgDO detail = recentCfgMapper.selectOne(new LambdaQueryWrapperX()
- .eq(MemberRecentCfgDO::getMemberId, memberId)
- );
+ MemberRecentCfgDO detail = selectCurrentConfig(memberId, false);
if (ObjectUtil.isNotEmpty(detail)) {
return detail;
}
+ // 单条写入自动提交后才解锁;不能让外层事务保留旧快照或在解锁后提交。
return memberRecentRedisDAO.lock(memberId, UPDATE_TIMEOUT_MILLIS, () -> {
+ MemberRecentCfgDO existing = selectCurrentConfig(memberId, true);
+ if (existing != null) {
+ return existing;
+ }
MemberRecentCfgDO insertObj = MemberRecentCfgDO.builder()
.memberId(memberId)
.homeHide(false)
.privacySecure(false)
.shortNote(false)
.build();
- recentCfgMapper.insert(insertObj);
- return insertObj;
+ try {
+ recentCfgMapper.insert(insertObj);
+ return insertObj;
+ } catch (DuplicateKeyException ex) {
+ MemberRecentCfgDO concurrent = selectCurrentConfig(memberId, true);
+ if (concurrent == null) {
+ throw ex;
+ }
+ return concurrent;
+ }
});
}
+ private MemberRecentCfgDO selectCurrentConfig(Long memberId, boolean currentRead) {
+ // 历史重复配置优先保留最近修改的隐私设置,不用默认值覆盖用户选择。
+ // NOT_SUPPORTED 仍可能绑定 MyBatis SqlSession。锁内使用不同 SQL 做当前读,
+ // 避免复用锁外查询缓存的“无记录”;写入仍在当前无事务连接上自动提交。
+ return recentCfgMapper.selectOne(new LambdaQueryWrapperX()
+ .eq(MemberRecentCfgDO::getMemberId, memberId)
+ .orderByDesc(MemberRecentCfgDO::getUpdateTime)
+ .orderByDesc(MemberRecentCfgDO::getId)
+ .last(currentRead ? "LIMIT 1 FOR UPDATE" : "LIMIT 1"));
+ }
+
/**
* 查询首页隐身的用户ids
*/
@@ -134,4 +164,4 @@ public class MemberRecentCfgServiceImpl implements MemberRecentCfgService {
public int del(Long menberId) {
return recentCfgMapper.deleteById(menberId);
}
-}
\ No newline at end of file
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImp.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImp.java
index 2a3c08d..276e5ef 100644
--- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImp.java
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImp.java
@@ -3,27 +3,22 @@ package cn.iocoder.yudao.module.member.service.user;
import cn.hutool.core.util.ObjectUtil;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
-import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.member.dal.dataobject.config.MemberConfigDO;
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberRecentTagDO;
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
-import cn.iocoder.yudao.module.member.dal.mysql.opentype.OpenTypeMapper;
-import cn.iocoder.yudao.module.member.dal.mysql.opentype.OpenTypeUserMapper;
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberRecentTagMapper;
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
import cn.iocoder.yudao.module.member.enums.tag.MemberRecentTagEnum;
import cn.iocoder.yudao.module.member.service.config.MemberConfigService;
-import cn.iocoder.yudao.module.member.service.opentype.BO.OpenTypeExtBO;
import cn.iocoder.yudao.module.member.service.opentype.OpenTypeService;
import cn.iocoder.yudao.module.member.service.user.BO.MemberTagExtBO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
-import org.springframework.security.core.parameters.P;
+import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import javax.validation.Valid;
-import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
@@ -54,24 +49,7 @@ public class MemberRecentTagServiceImp implements MemberRecentTagService {
*/
@Override
public void setPersonTag(Long authMemberId, boolean isAddTag) {
- MemberRecentTagDO detail = memberRecentTagMapper.selectOne(new LambdaQueryWrapperX()
- .eq(MemberRecentTagDO::getMemberId, authMemberId)
- .eq(MemberRecentTagDO::getTagBizType, MemberRecentTagEnum.TRUE_PERSON_AUTH.getType())
- );
- MemberRecentTagDO obj = new MemberRecentTagDO();
- obj.setTagBizId(null);
- obj.setMemberId(authMemberId);
- obj.setTagBizType(MemberRecentTagEnum.TRUE_PERSON_AUTH.getType());
- obj.setTagTitle(MemberRecentTagEnum.TRUE_PERSON_AUTH.getDescription());
- if (ObjectUtil.isEmpty(detail)) {
- if (isAddTag) {
- memberRecentTagMapper.insert(obj);
- }
- } else {
- if (!isAddTag) {
- memberRecentTagMapper.deleteById(detail.getId());
- }
- }
+ synchronizeTag(authMemberId, MemberRecentTagEnum.TRUE_PERSON_AUTH, isAddTag);
}
/**
@@ -124,32 +102,7 @@ public class MemberRecentTagServiceImp implements MemberRecentTagService {
// 是否是新人
boolean newPerson = memberUser.isNewPerson(newPersonDays);
- MemberRecentTagDO tag = memberRecentTagMapper.selectOne(new LambdaQueryWrapperX()
- .eq(MemberRecentTagDO::getMemberId, memberId)
- .eq(MemberRecentTagDO::getTagBizType, MemberRecentTagEnum.NEW_PERSON.getType())
- );
-
- // 是新人
- if (newPerson) {
- if (ObjectUtil.isEmpty(tag)) {
- MemberRecentTagDO obj = new MemberRecentTagDO();
- obj.setTagBizId(null);
- obj.setMemberId(memberId);
- obj.setTagBizType(MemberRecentTagEnum.NEW_PERSON.getType());
- obj.setTagTitle(MemberRecentTagEnum.NEW_PERSON.getDescription());
- memberRecentTagMapper.insert(obj);
- return true;
- }
- return false;
- }
- // 不是新人
- else {
- if (ObjectUtil.isNotEmpty(tag)) {
- memberRecentTagMapper.deleteById(tag.getId());
- return true;
- }
- return false;
- }
+ return synchronizeTag(memberId, MemberRecentTagEnum.NEW_PERSON, newPerson);
}
/**
@@ -162,31 +115,40 @@ public class MemberRecentTagServiceImp implements MemberRecentTagService {
if (ObjectUtil.isEmpty(memberUser)) return false;
// 现在是否还是vip
boolean isVip = openTypeService.isUserOpenType(memberId);
- MemberRecentTagDO tag = memberRecentTagMapper.selectOne(new LambdaQueryWrapperX()
+ return synchronizeTag(memberId, MemberRecentTagEnum.VIP, isVip);
+ }
+
+ /**
+ * 同步动态标签。历史重复数据按存在处理,失效时逻辑删除全部匹配记录。
+ */
+ private boolean synchronizeTag(Long memberId, MemberRecentTagEnum tagType, boolean enabled) {
+ if (!enabled) {
+ return memberRecentTagMapper.delete(tagQuery(memberId, tagType)) > 0;
+ }
+ if (memberRecentTagMapper.selectCount(tagQuery(memberId, tagType)) > 0) {
+ return false;
+ }
+ MemberRecentTagDO tag = MemberRecentTagDO.builder()
+ .memberId(memberId)
+ .tagBizType(tagType.getType())
+ .tagTitle(tagType.getDescription())
+ .build();
+ try {
+ return memberRecentTagMapper.insert(tag) > 0;
+ } catch (DuplicateKeyException ex) {
+ // 当前读确认并发插入结果,避免外层 REPEATABLE READ 事务仍读到旧快照。
+ if (ObjectUtil.isNotEmpty(memberRecentTagMapper.selectList(tagQuery(memberId, tagType)
+ .select(MemberRecentTagDO::getId).last("LIMIT 1 FOR UPDATE")))) {
+ return false;
+ }
+ throw ex;
+ }
+ }
+
+ private LambdaQueryWrapperX tagQuery(Long memberId, MemberRecentTagEnum tagType) {
+ return new LambdaQueryWrapperX()
.eq(MemberRecentTagDO::getMemberId, memberId)
- .eq(MemberRecentTagDO::getTagBizType, MemberRecentTagEnum.VIP.getType())
- );
- // 是vip
- if (isVip) {
- if (ObjectUtil.isEmpty(tag)) {
- MemberRecentTagDO obj = new MemberRecentTagDO();
- obj.setTagBizId(null);
- obj.setMemberId(memberId);
- obj.setTagBizType(MemberRecentTagEnum.VIP.getType());
- obj.setTagTitle(MemberRecentTagEnum.VIP.getDescription());
- memberRecentTagMapper.insert(obj);
- return true;
- }
- return false;
- }
- // 不是vip
- else {
- if (ObjectUtil.isNotEmpty(tag)) {
- memberRecentTagMapper.deleteById(tag.getId());
- return true;
- }
- return false;
- }
+ .eq(MemberRecentTagDO::getTagBizType, tagType.getType());
}
/**
diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java
index d30f830..2a82233 100644
--- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java
+++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java
@@ -442,7 +442,8 @@ public class MemberUserServiceImpl implements MemberUserService {
);
// 添加标签
if (ObjectUtil.isNotEmpty(updateReqVO.getMemberRecentTagList())) {
- List tags = updateReqVO.getMemberRecentTagList().stream().map(v -> {
+ // 历史重复标签或前端重试都可能带来重复类型,保存时按业务类型去重。
+ List tags = updateReqVO.getMemberRecentTagList().stream().distinct().map(v -> {
MemberRecentTagEnum tagEnum = MemberRecentTagEnum.valueOfType(v);
if (ObjectUtil.isEmpty(tagEnum)) return null;
MemberRecentTagDO entity = new MemberRecentTagDO();
diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImplSmsRegisterDbTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImplSmsRegisterDbTest.java
new file mode 100644
index 0000000..a00f260
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImplSmsRegisterDbTest.java
@@ -0,0 +1,144 @@
+package cn.iocoder.yudao.module.member.service.auth;
+
+import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
+import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
+import cn.iocoder.yudao.module.member.client.service.ChatApi;
+import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatLoginReqVO;
+import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatLoginRespVO;
+import cn.iocoder.yudao.module.member.controller.app.auth.vo.AppAuthLoginRespVO;
+import cn.iocoder.yudao.module.member.controller.app.auth.vo.AppAuthRegisterRespVO;
+import cn.iocoder.yudao.module.member.dal.dataobject.config.MemberConfigDO;
+import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
+import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
+import cn.iocoder.yudao.module.member.dal.redis.recent.MemberRecentRedisDAO;
+import cn.iocoder.yudao.module.member.service.config.MemberConfigService;
+import cn.iocoder.yudao.module.member.service.opentype.OpenTypeService;
+import cn.iocoder.yudao.module.member.service.user.MemberRecentCfgService;
+import cn.iocoder.yudao.module.member.service.user.MemberRecentCfgServiceImpl;
+import cn.iocoder.yudao.module.member.service.user.MemberRecentTagService;
+import cn.iocoder.yudao.module.member.service.user.MemberRecentTagServiceImp;
+import cn.iocoder.yudao.module.member.service.user.MemberUserService;
+import cn.iocoder.yudao.module.system.api.logger.LoginLogApi;
+import cn.iocoder.yudao.module.system.api.oauth2.OAuth2TokenApi;
+import cn.iocoder.yudao.module.system.api.oauth2.dto.OAuth2AccessTokenCreateReqDTO;
+import cn.iocoder.yudao.module.system.api.oauth2.dto.OAuth2AccessTokenRespDTO;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import javax.annotation.Resource;
+import javax.sql.DataSource;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@Import({MemberRecentTagServiceImp.class, MemberRecentCfgServiceImpl.class})
+@TestPropertySource(properties = {
+ "spring.datasource.url=jdbc:h2:mem:member_sms_register_regression;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value;",
+ "spring.sql.init.schema-locations=classpath:/sql/create_tables.sql,classpath:/sql/member_recent_tag_login_schema.sql,classpath:/sql/member_recent_cfg_schema.sql"
+})
+class MemberAuthServiceImplSmsRegisterDbTest extends BaseDbUnitTest {
+
+ @Resource
+ private MemberRecentTagService memberRecentTagService;
+ @Resource
+ private MemberRecentCfgService memberRecentCfgService;
+ @Resource
+ private DataSource dataSource;
+ @MockBean
+ private MemberUserMapper memberUserMapper;
+ @MockBean
+ private MemberConfigService memberConfigService;
+ @MockBean
+ private OpenTypeService openTypeService;
+ @MockBean
+ private MemberRecentRedisDAO memberRecentRedisDAO;
+
+ private JdbcTemplate jdbcTemplate;
+
+ @BeforeEach
+ void setUp() {
+ jdbcTemplate = new JdbcTemplate(dataSource);
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.setRemoteAddr("127.0.0.1");
+ request.addHeader("terminal", "1");
+ RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+ }
+
+ @AfterEach
+ void tearDown() {
+ RequestContextHolder.resetRequestAttributes();
+ jdbcTemplate.update("DELETE FROM member_recent_tag");
+ jdbcTemplate.update("DELETE FROM member_recent_cfg");
+ }
+
+ @Test
+ void smsRegisterReturnsTokensWhenBothNewPersonTagsAndConfigsContainDuplicates() {
+ Long userId = 740L;
+ String mobile = "18900520049";
+ MemberUserDO user = new MemberUserDO().setId(userId).setMobile(mobile)
+ .setStatus(CommonStatusEnum.ENABLE.getStatus());
+ user.setCreateTime(LocalDateTime.now().minusDays(1));
+ when(memberUserMapper.selectById(userId)).thenReturn(user);
+ MemberConfigDO config = new MemberConfigDO();
+ config.setNewPersonDays(15);
+ when(memberConfigService.getConfig()).thenReturn(config);
+ jdbcTemplate.update("INSERT INTO member_recent_tag (member_id, tag_biz_type, tag_title)"
+ + " VALUES (740, '2', '新人'), (740, '2', '新人')");
+ jdbcTemplate.update("INSERT INTO member_recent_cfg (member_id, home_hide, privacy_secure, update_time)"
+ + " VALUES (740, FALSE, FALSE, '2026-11-01 10:00:00'),"
+ + " (740, TRUE, TRUE, '2026-11-02 10:00:00')");
+
+ MemberUserService userService = mock(MemberUserService.class);
+ OAuth2TokenApi oauth2TokenApi = mock(OAuth2TokenApi.class);
+ ChatApi chatApi = mock(ChatApi.class);
+ when(userService.getUserByMobile(mobile)).thenReturn(user);
+ when(userService.createUserIfAbsent(eq(mobile), anyString(), anyInt())).thenReturn(user);
+ when(userService.syncChatUser(userId)).thenReturn(true);
+ when(oauth2TokenApi.createAccessToken(any(OAuth2AccessTokenCreateReqDTO.class)))
+ .thenReturn(new OAuth2AccessTokenRespDTO().setUserId(userId)
+ .setAccessToken("access-token").setRefreshToken("refresh-token")
+ .setExpiresTime(LocalDateTime.now().plusDays(1)));
+ ChatLoginRespVO chatToken = new ChatLoginRespVO();
+ chatToken.setAccessToken("chat-access-token");
+ chatToken.setRefreshToken("chat-refresh-token");
+ when(chatApi.login(any(ChatLoginReqVO.class))).thenReturn(chatToken);
+ MemberAuthServiceImpl authService = new MemberAuthServiceImpl();
+ ReflectionTestUtils.setField(authService, "userService", userService);
+ ReflectionTestUtils.setField(authService, "memberRecentTagService", memberRecentTagService);
+ ReflectionTestUtils.setField(authService, "memberRecentCfgService", memberRecentCfgService);
+ ReflectionTestUtils.setField(authService, "oauth2TokenApi", oauth2TokenApi);
+ ReflectionTestUtils.setField(authService, "loginLogApi", mock(LoginLogApi.class));
+ ReflectionTestUtils.setField(authService, "chatApi", chatApi);
+ AppAuthRegisterRespVO request = AppAuthRegisterRespVO.builder().mobile(mobile).code("8855").build();
+
+ AppAuthLoginRespVO result = authService.smsRegister(request);
+ AppAuthLoginRespVO retried = authService.smsRegister(request);
+
+ assertThat(result.getUserId()).isEqualTo(userId);
+ assertThat(result.getAccessToken()).isEqualTo("access-token");
+ assertThat(result.getRefreshToken()).isEqualTo("refresh-token");
+ assertThat(result.getChatToken().getAccessToken()).isEqualTo("chat-access-token");
+ assertThat(retried.getAccessToken()).isEqualTo("access-token");
+ assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM member_recent_tag", Integer.class)).isEqualTo(2);
+ assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM member_recent_cfg", Integer.class)).isEqualTo(2);
+ assertThat(memberRecentCfgService.getOrCreate(userId).getPrivacySecure()).isTrue();
+ verify(userService, times(2)).updateUserLogin(userId, "127.0.0.1");
+ }
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImplDbTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImplDbTest.java
new file mode 100644
index 0000000..c1e349d
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImplDbTest.java
@@ -0,0 +1,183 @@
+package cn.iocoder.yudao.module.member.service.user;
+
+import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
+import cn.iocoder.yudao.module.member.controller.app.user.vo.recentcfg.MemberRecentCfgReqVo;
+import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberRecentCfgDO;
+import cn.iocoder.yudao.module.member.dal.mysql.user.RecentCfgMapper;
+import cn.iocoder.yudao.module.member.dal.redis.recent.MemberRecentRedisDAO;
+import cn.iocoder.yudao.module.member.service.opentype.OpenTypeService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import javax.annotation.Resource;
+import javax.sql.DataSource;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+
+import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
+import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.USER_UPDATE_CONFIG_ERROR;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.when;
+
+@Import(MemberRecentCfgServiceImpl.class)
+@TestPropertySource(properties = {
+ "spring.datasource.url=jdbc:h2:mem:member_recent_cfg_regression;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value;",
+ "spring.sql.init.schema-locations=classpath:/sql/create_tables.sql,classpath:/sql/member_recent_cfg_schema.sql"
+})
+class MemberRecentCfgServiceImplDbTest extends BaseDbUnitTest {
+
+ @Resource
+ private MemberRecentCfgService memberRecentCfgService;
+ @Resource
+ private RecentCfgMapper recentCfgMapper;
+ @Resource
+ private DataSource dataSource;
+ @Resource
+ private PlatformTransactionManager transactionManager;
+ @MockBean
+ private MemberRecentRedisDAO memberRecentRedisDAO;
+ @MockBean
+ private OpenTypeService openTypeService;
+
+ private JdbcTemplate jdbc;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ jdbc = new JdbcTemplate(dataSource);
+ ReentrantLock lock = new ReentrantLock();
+ when(memberRecentRedisDAO.lock(anyLong(), anyLong(), any())).thenAnswer(invocation -> {
+ lock.lock();
+ try {
+ assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse();
+ return ((Callable>) invocation.getArgument(2)).call();
+ } finally {
+ lock.unlock();
+ }
+ });
+ }
+
+ @AfterEach
+ void cleanConfigs() {
+ jdbc.update("DELETE FROM member_recent_cfg");
+ }
+
+ @Test
+ void duplicateConfigsUseLatestPrivacySettingsAndIgnoreDeletedRows() {
+ insertConfig(812L, "2026-11-05 01:21:20", false, false);
+ insertConfig(813L, "2026-11-05 01:21:20", true, false);
+ insertConfig(814L, "2026-11-05 02:00:00", false, true);
+
+ MemberRecentCfgDO config = memberRecentCfgService.getOrCreate(740L);
+
+ assertThat(config.getId()).isEqualTo(813L);
+ assertThat(config.getHomeHide()).isTrue();
+ assertThat(config.getPrivacySecure()).isTrue();
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM member_recent_cfg", Integer.class)).isEqualTo(3);
+ }
+
+ @Test
+ void duplicateConfigsPreferUpdateTimeBeforeId() {
+ insertConfig(812L, "2026-11-05 02:00:00", true, false);
+ insertConfig(813L, "2026-11-05 01:21:20", false, false);
+
+ assertThat(memberRecentCfgService.getOrCreate(740L).getId()).isEqualTo(812L);
+ }
+
+ @Test
+ void updateUsesCurrentDuplicateConfigAndRetainsVipCheck() {
+ insertConfig(812L, "2026-11-05 01:21:20", false, false);
+ insertConfig(813L, "2026-11-05 01:21:20", false, false);
+ MemberRecentCfgReqVo request = request(true);
+
+ assertServiceException(() -> memberRecentCfgService.createOrUpdate(request),
+ USER_UPDATE_CONFIG_ERROR, "全局隐身或首页隐身");
+ assertThat(recentCfgMapper.selectById(813L).getHomeHide()).isFalse();
+
+ when(openTypeService.isUserOpenType()).thenReturn(true);
+ assertThat(memberRecentCfgService.createOrUpdate(request).getId()).isEqualTo(813L);
+ assertThat(recentCfgMapper.selectById(813L).getHomeHide()).isTrue();
+ assertThat(recentCfgMapper.selectById(812L).getHomeHide()).isFalse();
+ }
+
+ @Test
+ void concurrentFirstLoginsCreateOneConfig() throws Exception {
+ // 强制所有请求都完成锁外的“无记录”查询,再允许任一请求进入锁,
+ // 覆盖 MyBatis 会话缓存空结果导致锁内复查失效的实际竞态。
+ CountDownLatch allInitialReadsMissed = new CountDownLatch(8);
+ ReentrantLock lock = new ReentrantLock();
+ doAnswer(invocation -> {
+ allInitialReadsMissed.countDown();
+ assertThat(allInitialReadsMissed.await(10, TimeUnit.SECONDS)).isTrue();
+ lock.lock();
+ try {
+ assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse();
+ return ((Callable>) invocation.getArgument(2)).call();
+ } finally {
+ lock.unlock();
+ }
+ }).when(memberRecentRedisDAO).lock(anyLong(), anyLong(), any());
+ ExecutorService executor = Executors.newFixedThreadPool(8);
+ CountDownLatch start = new CountDownLatch(1);
+ List> calls = new ArrayList<>();
+ try {
+ for (int index = 0; index < 8; index++) {
+ calls.add(executor.submit(() -> {
+ start.await();
+ return memberRecentCfgService.getOrCreate(740L);
+ }));
+ }
+ start.countDown();
+ Long configId = calls.get(0).get(10, TimeUnit.SECONDS).getId();
+ for (Future call : calls) {
+ MemberRecentCfgDO config = call.get(10, TimeUnit.SECONDS);
+ assertThat(config.getId()).isEqualTo(configId);
+ assertThat(config.getHomeHide()).isFalse();
+ }
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM member_recent_cfg WHERE member_id=740", Integer.class))
+ .isEqualTo(1);
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ void initializationCommitsBeforeReturningToAnOuterTransaction() {
+ new TransactionTemplate(transactionManager).execute(status -> {
+ assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isTrue();
+ memberRecentCfgService.getOrCreate(740L);
+ assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isTrue();
+ status.setRollbackOnly();
+ return null;
+ });
+
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM member_recent_cfg WHERE member_id=740", Integer.class))
+ .isEqualTo(1);
+ }
+
+ private void insertConfig(Long id, String updateTime, boolean hidden, boolean deleted) {
+ jdbc.update("INSERT INTO member_recent_cfg (id,member_id,home_hide,privacy_secure,update_time,deleted)"
+ + " VALUES (?,740,?,?,?,?)", id, hidden, hidden, updateTime, deleted);
+ }
+
+ private MemberRecentCfgReqVo request(boolean homeHide) {
+ return new MemberRecentCfgReqVo(740L, true, homeHide, false, true, false);
+ }
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImplTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImplTest.java
new file mode 100644
index 0000000..dfd0741
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentCfgServiceImplTest.java
@@ -0,0 +1,96 @@
+package cn.iocoder.yudao.module.member.service.user;
+
+import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
+import cn.iocoder.yudao.module.member.controller.app.user.vo.recentcfg.MemberRecentCfgReqVo;
+import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberRecentCfgDO;
+import cn.iocoder.yudao.module.member.dal.mysql.user.RecentCfgMapper;
+import cn.iocoder.yudao.module.member.dal.redis.recent.MemberRecentRedisDAO;
+import cn.iocoder.yudao.module.member.service.opentype.OpenTypeService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.springframework.dao.DuplicateKeyException;
+
+import java.util.concurrent.Callable;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MemberRecentCfgServiceImplTest extends BaseMockitoUnitTest {
+
+ @InjectMocks
+ private MemberRecentCfgServiceImpl service;
+ @Mock
+ private RecentCfgMapper recentCfgMapper;
+ @Mock
+ private MemberRecentRedisDAO memberRecentRedisDAO;
+ @Mock
+ private OpenTypeService openTypeService;
+
+ @BeforeEach
+ void runLockedCallback() throws Exception {
+ when(memberRecentRedisDAO.lock(anyLong(), anyLong(), any())).thenAnswer(invocation ->
+ ((Callable>) invocation.getArgument(2)).call());
+ }
+
+ @Test
+ void rechecksAfterAcquiringInitializationLock() {
+ MemberRecentCfgDO existing = existing();
+ when(recentCfgMapper.selectOne(any())).thenReturn(null, existing);
+
+ assertThat(service.getOrCreate(740L)).isSameAs(existing);
+ verify(recentCfgMapper, never()).insert(any(MemberRecentCfgDO.class));
+ }
+
+ @Test
+ void returnsConfigCreatedByAConcurrentWriterOnUniqueConflict() {
+ MemberRecentCfgDO existing = existing();
+ when(recentCfgMapper.selectOne(any())).thenReturn(null, null, existing);
+ when(recentCfgMapper.insert(any(MemberRecentCfgDO.class)))
+ .thenThrow(new DuplicateKeyException("uk_member_recent_cfg_active"));
+
+ assertThat(service.getOrCreate(740L)).isSameAs(existing);
+ }
+
+ @Test
+ void initializationDoesNotHideUnrelatedDuplicateKeyFailures() {
+ DuplicateKeyException failure = new DuplicateKeyException("another constraint");
+ when(recentCfgMapper.insert(any(MemberRecentCfgDO.class))).thenThrow(failure);
+
+ assertThatThrownBy(() -> service.getOrCreate(740L)).isSameAs(failure);
+ }
+
+ @Test
+ void updateAppliesRequestedSettingsAfterConcurrentInitialization() {
+ MemberRecentCfgDO existing = existing();
+ when(recentCfgMapper.selectOne(any())).thenReturn(null, existing);
+ when(recentCfgMapper.insert(any(MemberRecentCfgDO.class)))
+ .thenThrow(new DuplicateKeyException("uk_member_recent_cfg_active"));
+ MemberRecentCfgReqVo request = new MemberRecentCfgReqVo(740L, true, false, false, true, false);
+
+ MemberRecentCfgDO updated = service.createOrUpdate(request);
+
+ assertThat(updated.getId()).isEqualTo(existing.getId());
+ assertThat(updated.getPrivacySecure()).isTrue();
+ verify(recentCfgMapper).updateById(updated);
+ }
+
+ @Test
+ void updateDoesNotHideUnrelatedDuplicateKeyFailures() {
+ DuplicateKeyException failure = new DuplicateKeyException("another constraint");
+ when(recentCfgMapper.insert(any(MemberRecentCfgDO.class))).thenThrow(failure);
+
+ assertThatThrownBy(() -> service.createOrUpdate(
+ new MemberRecentCfgReqVo(740L, true, false, false, true, false))).isSameAs(failure);
+ }
+
+ private MemberRecentCfgDO existing() {
+ return MemberRecentCfgDO.builder().id(813L).memberId(740L).privacySecure(true).build();
+ }
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImpDbTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImpDbTest.java
new file mode 100644
index 0000000..2a1a133
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImpDbTest.java
@@ -0,0 +1,208 @@
+package cn.iocoder.yudao.module.member.service.user;
+
+import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
+import cn.iocoder.yudao.module.member.dal.dataobject.config.MemberConfigDO;
+import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
+import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
+import cn.iocoder.yudao.module.member.enums.tag.MemberRecentTagEnum;
+import cn.iocoder.yudao.module.member.service.config.MemberConfigService;
+import cn.iocoder.yudao.module.member.service.opentype.OpenTypeService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.TestPropertySource;
+
+import javax.annotation.Resource;
+import javax.sql.DataSource;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+@Import(MemberRecentTagServiceImp.class)
+@TestPropertySource(properties = {
+ "spring.datasource.url=jdbc:h2:mem:member_recent_tag_login;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value;",
+ "spring.sql.init.schema-locations=classpath:/sql/create_tables.sql,classpath:/sql/member_recent_tag_login_schema.sql"
+})
+class MemberRecentTagServiceImpDbTest extends BaseDbUnitTest {
+
+ private static final Long MEMBER_ID = 740L;
+
+ @Resource
+ private MemberRecentTagServiceImp service;
+ @Resource
+ private DataSource dataSource;
+ @MockBean
+ private MemberUserMapper memberUserMapper;
+ @MockBean
+ private MemberConfigService memberConfigService;
+ @MockBean
+ private OpenTypeService openTypeService;
+
+ private JdbcTemplate jdbcTemplate;
+
+ @BeforeEach
+ void setUp() {
+ jdbcTemplate = new JdbcTemplate(dataSource);
+ }
+
+ @AfterEach
+ void cleanTags() {
+ jdbcTemplate.update("DELETE FROM member_recent_tag");
+ }
+
+ @Test
+ void duplicateNewPersonTagsDoNotBlockAnUnexpiredUser() {
+ mockNewPersonAge(1);
+ insertTag(MEMBER_ID, MemberRecentTagEnum.NEW_PERSON, false);
+ insertTag(MEMBER_ID, MemberRecentTagEnum.NEW_PERSON, false);
+
+ assertThat(service.createOrUpdateNewPersonTag(MEMBER_ID)).isFalse();
+
+ assertThat(activeCount(MEMBER_ID, MemberRecentTagEnum.NEW_PERSON)).isEqualTo(2);
+ assertThat(totalCount()).isEqualTo(2);
+ }
+
+ @Test
+ void expiredNewPersonLogicallyDeletesAllDuplicatesAndPreservesOtherTags() {
+ mockNewPersonAge(30);
+ insertTag(MEMBER_ID, MemberRecentTagEnum.NEW_PERSON, false);
+ insertTag(MEMBER_ID, MemberRecentTagEnum.NEW_PERSON, false);
+ insertTag(MEMBER_ID, MemberRecentTagEnum.NEW_PERSON, true);
+ insertTag(MEMBER_ID, MemberRecentTagEnum.VIP, false);
+ insertTag(741L, MemberRecentTagEnum.NEW_PERSON, false);
+
+ assertThat(service.createOrUpdateNewPersonTag(MEMBER_ID)).isTrue();
+ assertThat(service.createOrUpdateNewPersonTag(MEMBER_ID)).isFalse();
+
+ assertThat(activeCount(MEMBER_ID, MemberRecentTagEnum.NEW_PERSON)).isZero();
+ assertThat(activeCount(MEMBER_ID, MemberRecentTagEnum.VIP)).isEqualTo(1);
+ assertThat(activeCount(741L, MemberRecentTagEnum.NEW_PERSON)).isEqualTo(1);
+ assertThat(totalCount()).isEqualTo(5);
+ assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM member_recent_tag WHERE deleted = TRUE",
+ Integer.class)).isEqualTo(3);
+ }
+
+ @Test
+ void newPersonTagIsCreatedOnceAndAnExistingTagIsReused() {
+ mockNewPersonAge(1);
+
+ assertThat(service.createOrUpdateNewPersonTag(MEMBER_ID)).isTrue();
+ assertThat(service.createOrUpdateNewPersonTag(MEMBER_ID)).isFalse();
+
+ assertThat(activeCount(MEMBER_ID, MemberRecentTagEnum.NEW_PERSON)).isEqualTo(1);
+ assertThat(jdbcTemplate.queryForObject("SELECT tag_title FROM member_recent_tag", String.class))
+ .isEqualTo(MemberRecentTagEnum.NEW_PERSON.getDescription());
+ }
+
+ @Test
+ void expiredUserWithoutANewPersonTagDoesNotCreateOne() {
+ mockNewPersonAge(30);
+
+ assertThat(service.createOrUpdateNewPersonTag(MEMBER_ID)).isFalse();
+
+ assertThat(totalCount()).isZero();
+ }
+
+ @Test
+ void duplicateVipTagsAreIdempotentAndAllExpireTogether() {
+ mockUser(1);
+ when(openTypeService.isUserOpenType(MEMBER_ID)).thenReturn(true, false);
+ insertTag(MEMBER_ID, MemberRecentTagEnum.VIP, false);
+ insertTag(MEMBER_ID, MemberRecentTagEnum.VIP, false);
+
+ assertThat(service.createOrUpdateVipTag(MEMBER_ID)).isFalse();
+ assertThat(activeCount(MEMBER_ID, MemberRecentTagEnum.VIP)).isEqualTo(2);
+ assertThat(service.createOrUpdateVipTag(MEMBER_ID)).isTrue();
+
+ assertThat(activeCount(MEMBER_ID, MemberRecentTagEnum.VIP)).isZero();
+ assertThat(totalCount()).isEqualTo(2);
+ }
+
+ @Test
+ void vipTagIsCreatedOnce() {
+ mockUser(1);
+ when(openTypeService.isUserOpenType(MEMBER_ID)).thenReturn(true);
+
+ assertThat(service.createOrUpdateVipTag(MEMBER_ID)).isTrue();
+ assertThat(service.createOrUpdateVipTag(MEMBER_ID)).isFalse();
+
+ assertThat(activeCount(MEMBER_ID, MemberRecentTagEnum.VIP)).isEqualTo(1);
+ }
+
+ @Test
+ void personTagCanRevokeAllDuplicatesAndBeGrantedAgainWithoutDeletingHistory() {
+ insertTag(MEMBER_ID, MemberRecentTagEnum.TRUE_PERSON_AUTH, false);
+ insertTag(MEMBER_ID, MemberRecentTagEnum.TRUE_PERSON_AUTH, false);
+ insertTag(MEMBER_ID, MemberRecentTagEnum.NEW_PERSON, false);
+
+ service.setPersonTag(MEMBER_ID, true);
+ assertThat(activeCount(MEMBER_ID, MemberRecentTagEnum.TRUE_PERSON_AUTH)).isEqualTo(2);
+ service.setPersonTag(MEMBER_ID, false);
+ assertThat(activeCount(MEMBER_ID, MemberRecentTagEnum.TRUE_PERSON_AUTH)).isZero();
+ assertThat(totalCount()).isEqualTo(3);
+ service.setPersonTag(MEMBER_ID, true);
+ service.setPersonTag(MEMBER_ID, true);
+
+ assertThat(activeCount(MEMBER_ID, MemberRecentTagEnum.TRUE_PERSON_AUTH)).isEqualTo(1);
+ assertThat(activeCount(MEMBER_ID, MemberRecentTagEnum.NEW_PERSON)).isEqualTo(1);
+ assertThat(totalCount()).isEqualTo(4);
+ }
+
+ @Test
+ void scheduledNewPersonCleanupHandlesDuplicateRowsForTheSameMember() {
+ mockNewPersonAge(30);
+ insertTag(MEMBER_ID, MemberRecentTagEnum.NEW_PERSON, false);
+ insertTag(MEMBER_ID, MemberRecentTagEnum.NEW_PERSON, false);
+
+ assertThat(service.checkNewPersonTagJob()).isEqualTo(1);
+
+ assertThat(activeCount(MEMBER_ID, MemberRecentTagEnum.NEW_PERSON)).isZero();
+ assertThat(totalCount()).isEqualTo(2);
+ }
+
+ @Test
+ void scheduledVipCleanupHandlesDuplicateRowsForTheSameMember() {
+ mockUser(1);
+ when(openTypeService.isUserOpenType(MEMBER_ID)).thenReturn(false);
+ insertTag(MEMBER_ID, MemberRecentTagEnum.VIP, false);
+ insertTag(MEMBER_ID, MemberRecentTagEnum.VIP, false);
+
+ assertThat(service.checkVipTagJob()).isEqualTo(1);
+
+ assertThat(activeCount(MEMBER_ID, MemberRecentTagEnum.VIP)).isZero();
+ assertThat(totalCount()).isEqualTo(2);
+ }
+
+ private void mockNewPersonAge(int days) {
+ mockUser(days);
+ MemberConfigDO config = new MemberConfigDO();
+ config.setNewPersonDays(15);
+ when(memberConfigService.getConfig()).thenReturn(config);
+ }
+
+ private void mockUser(int days) {
+ MemberUserDO user = new MemberUserDO();
+ user.setId(MEMBER_ID);
+ user.setCreateTime(LocalDateTime.now().minusDays(days));
+ when(memberUserMapper.selectById(MEMBER_ID)).thenReturn(user);
+ }
+
+ private void insertTag(Long memberId, MemberRecentTagEnum type, boolean deleted) {
+ jdbcTemplate.update("INSERT INTO member_recent_tag (member_id, tag_biz_type, tag_title, deleted)"
+ + " VALUES (?, ?, ?, ?)", memberId, type.getType(), type.getDescription(), deleted);
+ }
+
+ private int activeCount(Long memberId, MemberRecentTagEnum type) {
+ return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM member_recent_tag"
+ + " WHERE member_id = ? AND tag_biz_type = ? AND deleted = FALSE",
+ Integer.class, memberId, type.getType());
+ }
+
+ private int totalCount() {
+ return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM member_recent_tag", Integer.class);
+ }
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImpTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImpTest.java
index 651f2b2..39042d7 100644
--- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImpTest.java
+++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberRecentTagServiceImpTest.java
@@ -5,17 +5,27 @@ import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberRecentTagDO;
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberRecentTagMapper;
import cn.iocoder.yudao.module.member.enums.tag.MemberRecentTagEnum;
import cn.iocoder.yudao.module.member.service.user.BO.MemberTagExtBO;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.dao.DuplicateKeyException;
import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.groups.Tuple.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
class MemberRecentTagServiceImpTest extends BaseMockitoUnitTest {
@@ -25,6 +35,11 @@ class MemberRecentTagServiceImpTest extends BaseMockitoUnitTest {
@Mock
private MemberRecentTagMapper memberRecentTagMapper;
+ @BeforeAll
+ static void initTableInfo() {
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), MemberRecentTagDO.class);
+ }
+
@Test
void getUserTagExtListDeduplicatesTagsByMemberAndBizType() {
when(memberRecentTagMapper.selectList(any())).thenReturn(Arrays.asList(
@@ -46,6 +61,44 @@ class MemberRecentTagServiceImpTest extends BaseMockitoUnitTest {
assertThat(result.get(0).getTagTitle()).isEqualTo("VIP用户");
}
+ @Test
+ void concurrentInsertIsIdempotentWhenTheActiveTagNowExists() {
+ when(memberRecentTagMapper.selectCount(any())).thenReturn(0L);
+ when(memberRecentTagMapper.insert(any(MemberRecentTagDO.class)))
+ .thenThrow(new DuplicateKeyException("active member tag already exists"));
+ when(memberRecentTagMapper.selectList(any())).thenReturn(Collections.singletonList(
+ createTag(1L, 740L, MemberRecentTagEnum.TRUE_PERSON_AUTH, "真人认证")));
+
+ memberRecentTagService.setPersonTag(740L, true);
+
+ verify(memberRecentTagMapper).selectCount(any());
+ verify(memberRecentTagMapper).selectList(any());
+ verify(memberRecentTagMapper).insert(any(MemberRecentTagDO.class));
+ }
+
+ @Test
+ void duplicateKeyWithoutTheExpectedActiveTagIsPropagated() {
+ when(memberRecentTagMapper.selectCount(any())).thenReturn(0L);
+ DuplicateKeyException failure = new DuplicateKeyException("unrelated unique constraint");
+ when(memberRecentTagMapper.insert(any(MemberRecentTagDO.class))).thenThrow(failure);
+ when(memberRecentTagMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+ assertThatThrownBy(() -> memberRecentTagService.setPersonTag(740L, true))
+ .isSameAs(failure);
+ }
+
+ @Test
+ void otherDatabaseFailuresAreNotTreatedAsConcurrentSuccess() {
+ when(memberRecentTagMapper.selectCount(any())).thenReturn(0L);
+ DataIntegrityViolationException failure = new DataIntegrityViolationException("invalid tag data");
+ when(memberRecentTagMapper.insert(any(MemberRecentTagDO.class))).thenThrow(failure);
+
+ assertThatThrownBy(() -> memberRecentTagService.setPersonTag(740L, true))
+ .isSameAs(failure);
+ verify(memberRecentTagMapper).selectCount(any());
+ verify(memberRecentTagMapper, never()).selectList(any());
+ }
+
private MemberRecentTagDO createTag(Long id, Long memberId, MemberRecentTagEnum tag, String title) {
return MemberRecentTagDO.builder()
.id(id)
diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImplRecentTagTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImplRecentTagTest.java
new file mode 100644
index 0000000..60a4546
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImplRecentTagTest.java
@@ -0,0 +1,77 @@
+package cn.iocoder.yudao.module.member.service.user;
+
+import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
+import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserUpdateReqVO;
+import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberRecentTagDO;
+import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
+import cn.iocoder.yudao.module.member.dal.mysql.user.MemberRecentTagMapper;
+import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyCollection;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+class MemberUserServiceImplRecentTagTest extends BaseMockitoUnitTest {
+
+ @InjectMocks
+ private MemberUserServiceImpl userService;
+ @Mock
+ private MemberUserMapper memberUserMapper;
+ @Mock
+ private MemberRecentTagMapper memberRecentTagMapper;
+ @Mock
+ private MemberRecentTagService memberRecentTagService;
+
+ @Test
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ void updateUserStoresEachTagTypeOnceWhenRetryPayloadContainsDuplicates() {
+ MemberUserUpdateReqVO request = request();
+ request.setMemberRecentTagList(Arrays.asList("1", "2", "4", "2", "4", "1"));
+
+ userService.updateUser(request);
+
+ ArgumentCaptor> tags = ArgumentCaptor.forClass((Class) Collection.class);
+ verify(memberRecentTagMapper).insert(tags.capture());
+ assertThat(tags.getValue()).extracting(MemberRecentTagDO::getTagBizType)
+ .containsExactly("1", "2", "4");
+ assertThat(tags.getValue()).allSatisfy(tag -> assertThat(tag.getMemberId()).isEqualTo(740L));
+ verify(memberRecentTagService).createOrUpdateNewPersonTag(740L);
+ }
+
+ @Test
+ void updateUserWithoutTagFieldLeavesExistingTagsUntouched() {
+ userService.updateUser(request());
+
+ verifyNoInteractions(memberRecentTagMapper);
+ }
+
+ @Test
+ void updateUserWithEmptyTagListClearsTagsWithoutInserting() {
+ MemberUserUpdateReqVO request = request();
+ request.setMemberRecentTagList(Collections.emptyList());
+
+ userService.updateUser(request);
+
+ verify(memberRecentTagMapper).delete(any());
+ verify(memberRecentTagMapper, never()).insert(anyCollection());
+ }
+
+ private MemberUserUpdateReqVO request() {
+ when(memberUserMapper.selectById(740L)).thenReturn(new MemberUserDO().setId(740L).setStatus(0));
+ MemberUserUpdateReqVO request = new MemberUserUpdateReqVO();
+ request.setId(740L);
+ return request;
+ }
+}
diff --git a/yudao-module-member/yudao-module-member-biz/src/test/resources/sql/member_recent_cfg_schema.sql b/yudao-module-member/yudao-module-member-biz/src/test/resources/sql/member_recent_cfg_schema.sql
new file mode 100644
index 0000000..c8b6aac
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/test/resources/sql/member_recent_cfg_schema.sql
@@ -0,0 +1,14 @@
+CREATE TABLE IF NOT EXISTS member_recent_cfg (
+ id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ member_id bigint NOT NULL,
+ is_push bit,
+ home_hide bit,
+ global_hide bit,
+ privacy_secure bit,
+ short_note bit,
+ creator varchar(64) DEFAULT '',
+ create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater varchar(64) DEFAULT '',
+ update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ deleted bit NOT NULL DEFAULT FALSE
+);
diff --git a/yudao-module-member/yudao-module-member-biz/src/test/resources/sql/member_recent_tag_login_schema.sql b/yudao-module-member/yudao-module-member-biz/src/test/resources/sql/member_recent_tag_login_schema.sql
new file mode 100644
index 0000000..aadd323
--- /dev/null
+++ b/yudao-module-member/yudao-module-member-biz/src/test/resources/sql/member_recent_tag_login_schema.sql
@@ -0,0 +1,13 @@
+CREATE TABLE IF NOT EXISTS member_recent_tag (
+ id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ member_id bigint NOT NULL,
+ tag_biz_type varchar(32),
+ tag_title varchar(64) NOT NULL,
+ tag_biz_id bigint,
+ creator varchar(64) DEFAULT '',
+ create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater varchar(64) DEFAULT '',
+ update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted bit NOT NULL DEFAULT FALSE,
+ tenant_id bigint NOT NULL DEFAULT 0
+);