From 3cc47aada1fc82f4b919fb4807c78437dafb176d Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 21 Sep 2026 07:35:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B7=A8=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E5=AE=9A=E9=95=BF=E5=AD=97=E7=AC=A6=E6=A0=A1=E9=AA=8C=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=8F=8CSQL=E5=AE=9E=E6=9C=BA=E9=AA=8C?= =?UTF-8?q?=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dbmigration/portable_character_test.go | 103 ++++++++++++++++++ backend/services/dbmigration/portable_data.go | 19 ++++ docs/SQL跨引擎迁移补充验收.md | 90 +++++++++++++++ docs/数据库迁移重启补充验收.md | 3 +- docs/生产补充验收记录.md | 11 +- 5 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 backend/services/dbmigration/portable_character_test.go create mode 100644 docs/SQL跨引擎迁移补充验收.md diff --git a/backend/services/dbmigration/portable_character_test.go b/backend/services/dbmigration/portable_character_test.go new file mode 100644 index 00000000..39c16cac --- /dev/null +++ b/backend/services/dbmigration/portable_character_test.go @@ -0,0 +1,103 @@ +package dbmigration + +import ( + "context" + "database/sql" + "reflect" + "testing" + + "git.pcux.cn/xuans/Smail/config" +) + +// 用真实行扫描接口重放云端驱动类型;这些用例不能代替双 SQL 实机验收。 +func TestPortableFixedCharacterDriverValues(t *testing.T) { + for _, item := range []struct { + name string + kind config.DatabaseType + columnType string + input, want any + }{ + {"postgres_empty", config.DatabaseTypePostgreSQL, "BPCHAR", []byte(" "), []byte("")}, + {"postgres_token", config.DatabaseTypePostgreSQL, "BPCHAR", []byte("abc "), []byte("abc")}, + {"postgres_string", config.DatabaseTypePostgreSQL, "BPCHAR", "abc ", "abc"}, + {"postgres_null", config.DatabaseTypePostgreSQL, "BPCHAR", nil, nil}, + {"mysql_padding", config.DatabaseTypeMySQL, "CHAR", []byte("abc "), []byte("abc")}, + {"postgres_leading_space", config.DatabaseTypePostgreSQL, "BPCHAR", []byte(" abc "), []byte(" abc")}, + {"postgres_other_whitespace", config.DatabaseTypePostgreSQL, "BPCHAR", []byte("abc\t\n\u00a0 "), []byte("abc\t\n\u00a0")}, + {"postgres_varchar", config.DatabaseTypePostgreSQL, "VARCHAR", "abc ", "abc "}, + {"mysql_varchar", config.DatabaseTypeMySQL, "VARCHAR", []byte("abc "), []byte("abc ")}, + {"postgres_text", config.DatabaseTypePostgreSQL, "TEXT", "abc ", "abc "}, + {"postgres_binary", config.DatabaseTypePostgreSQL, "BYTEA", []byte{0, 255, 32, 32}, []byte{0, 255, 32, 32}}, + {"mysql_binary", config.DatabaseTypeMySQL, "BLOB", []byte{0, 255, 32}, []byte{0, 255, 32}}, + {"sqlite_char", config.DatabaseTypeSQLite, "CHAR", "abc ", "abc "}, + {"sqlite_bpchar", config.DatabaseTypeSQLite, "BPCHAR", []byte("abc "), []byte("abc ")}, + } { + t.Run(item.name, func(t *testing.T) { + database := portableCharacterFixture(t, item.columnType, item.input) + count := 0 + err := visitPortableRows(context.Background(), database, item.kind, "sample", []string{"value"}, func(values []any) error { + count++ + if !reflect.DeepEqual(values[0], item.want) { + t.Fatal("定长字符填充或非定长数据处理错误") + } + return nil + }) + if err != nil || count != 1 { + t.Fatal("驱动类型重放未完成") + } + }) + } +} + +func TestPortableFixedCharacterDigest(t *testing.T) { + for _, item := range []struct { + name string + sourceType, targetType string + source, target any + equal bool + }{ + {"empty_padding", "CHAR", "BPCHAR", []byte(""), []byte(" "), true}, + {"token_padding", "CHAR", "BPCHAR", []byte("abc"), []byte("abc "), true}, + {"changed_token", "CHAR", "BPCHAR", []byte("abc"), []byte("abd "), false}, + {"leading_space", "CHAR", "BPCHAR", []byte("abc"), []byte(" abc "), false}, + {"null_not_empty", "CHAR", "BPCHAR", nil, []byte(" "), false}, + {"varchar_space", "VARCHAR", "VARCHAR", []byte("abc"), "abc ", false}, + {"text_space", "TEXT", "TEXT", []byte("abc"), "abc ", false}, + {"binary_space", "BLOB", "BYTEA", []byte{0, 255}, []byte{0, 255, 32}, false}, + } { + t.Run(item.name, func(t *testing.T) { + source := portableCharacterFixture(t, item.sourceType, item.source) + target := portableCharacterFixture(t, item.targetType, item.target) + types := map[string]string{"value": item.targetType} + left, err := digestPortableTable(context.Background(), source, config.DatabaseTypeMySQL, "sample", []string{"value"}, types, config.DatabaseTypePostgreSQL) + if err != nil { + t.Fatal("源摘要计算失败") + } + right, err := digestPortableTable(context.Background(), target, config.DatabaseTypePostgreSQL, "sample", []string{"value"}, types, config.DatabaseTypePostgreSQL) + if err != nil { + t.Fatal("目标摘要计算失败") + } + if (left == right) != item.equal { + t.Fatal("摘要未区分填充与真实数据差异") + } + }) + } +} + +func portableCharacterFixture(t *testing.T, columnType string, value any) *sql.DB { + t.Helper() + database, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal("创建重放数据库失败") + } + database.SetMaxOpenConns(1) + t.Cleanup(func() { _ = database.Close() }) + // 类型来自本文件固定用例,不接受外部输入。 + if _, err := database.Exec("CREATE TABLE sample (value " + columnType + ")"); err != nil { + t.Fatal("创建重放表失败") + } + if _, err := database.Exec("INSERT INTO sample(value) VALUES (?)", value); err != nil { + t.Fatal("写入重放值失败") + } + return database +} diff --git a/backend/services/dbmigration/portable_data.go b/backend/services/dbmigration/portable_data.go index 4b497b84..eb4c3e52 100644 --- a/backend/services/dbmigration/portable_data.go +++ b/backend/services/dbmigration/portable_data.go @@ -1,6 +1,7 @@ package dbmigration import ( + "bytes" "context" "crypto/sha256" "database/sql" @@ -257,6 +258,10 @@ func visitPortableRows(ctx context.Context, database portableQuery, kind config. return ErrSQLConnectionUnavailable } defer rows.Close() + types, err := rows.ColumnTypes() + if err != nil || len(types) != len(columns) { + return ErrSQLDataVerification + } for rows.Next() { values, pointers := make([]any, len(columns)), make([]any, len(columns)) for i := range values { @@ -265,6 +270,20 @@ func visitPortableRows(ctx context.Context, database portableQuery, kind config. if err := rows.Scan(pointers...); err != nil { return ErrSQLDataVerification } + for i, column := range types { + // 只移除源 SQL 定长字符的空格填充;SQLite 声明和变长、二进制值保持原样。 + fixed := kind == config.DatabaseTypePostgreSQL && column.DatabaseTypeName() == "BPCHAR" || + kind == config.DatabaseTypeMySQL && column.DatabaseTypeName() == "CHAR" + if !fixed { + continue + } + switch value := values[i].(type) { + case []byte: + values[i] = bytes.TrimRight(value, " ") + case string: + values[i] = strings.TrimRight(value, " ") + } + } if err := visit(values); err != nil { return err } diff --git a/docs/SQL跨引擎迁移补充验收.md b/docs/SQL跨引擎迁移补充验收.md new file mode 100644 index 00000000..28af2861 --- /dev/null +++ b/docs/SQL跨引擎迁移补充验收.md @@ -0,0 +1,90 @@ +# SQL 跨引擎迁移补充验收 + +## 结论与范围 + +北京时间 2026 年 9 月 21 日,在云服务器新空数据隔离实例中,完成 MySQL → PostgreSQL、PostgreSQL → MySQL 的应用级迁移、内部重启、系统级进程重启及目标账号故障恢复。 + +- [✓] 保留迁移失败反例,以只读诊断定位定长字符填充差异。 +- [✓] 确定性回归先失败后通过,不跳过表或降低完整性校验。 +- [✓] 双 SQL 跨引擎切换、两类重启、目标不可用时拒绝启动及恢复。 +- [✓] 两个方向均验证旧会话、历史邮件、新邮件、中文正文及附件。 +- [✓] 源库无应用连接、源库逻辑内容不变;新增邮件仅进入目标库。 +- [✓] 配置历史完整保留在目标业务库,没有生成 SQLite 文件。 +- [✓] 云端新增专项零跳过;最新 Python 部署套件 136 项零跳过通过。 +- [✓] 证据、源码、工具摘要、账号锁定及正式服务保护状态复核。 +- [ ] 涉及 SQLite 的四个跨引擎应用级重启方向。 +- [ ] SQL 复制中断、SQLite 提交及配置切换临界点恢复。 +- [!] 浏览器、外部依赖、安全告警、凭据轮换及其他生产门禁尚未关闭。 + +**本记录只关闭明确列出的双 SQL 跨引擎项,不代表整个项目生产验收完成,也不是正式实例升级。** + +## 缺陷定位与最小修复 + +旧候选在 `/var/tmp/smail-review-runtime-20260921-063341/` 的 MySQL → PostgreSQL 迁移中返回 HTTP 409,提示数据库数据校验未通过。目标数据复制已提交,但配置切换被拒绝;失败源库、目标库和日志均保留,没有通过重试覆盖既有目标。 + +只读原生 SQL 初步比较只能看到失败响应后可能变化的操作日志与会话时间,不能据此认定并发写入是原因。随后使用新建的最小 SELECT 权限账号、只读事务和生产摘要函数进行 Go 驱动级诊断,定位到 `verification_cleanup_state.lease_token`:MySQL 的定长字符读取结果与 PostgreSQL `BPCHAR` 填充后的驱动值不一致。 + +最终只读诊断位于 `/var/tmp/smail-review-diagnostic-20260921-070712/`。只输出表名、列名、类型、数量和差异标记,不输出行内容、凭据或摘要原文。诊断有意以非零退出报告差异,不算通过的验收。最初两类启动失败也保留:`PrivateTmp` 隐藏执行路径,以及隐藏正式家目录后驱动默认客户端证书路径不可访问。修正仅以本轮目录只读覆盖可见路径,正式 `/opt/smail` 仍被遮蔽;没有关闭 TLS、解锁原测试账号或改变正式服务。各次诊断账号均已锁定。 + +`backend/services/dbmigration/portable_data.go` 在行扫描后按**实际源引擎和列元数据**处理填充: + +- 仅 MySQL `CHAR`、PostgreSQL `BPCHAR` 删除右侧 ASCII 空格填充。 +- 复制与校验共用同一读取路径,支持 PostgreSQL 作为源库的反向迁移。 +- 不裁剪前导空格、制表符、换行或非断行空格。 +- 不改变 VARCHAR、TEXT、BLOB、BYTEA 或 SQLite 的值,即使 SQLite 列声明为 CHAR。 +- NULL 与空字符串、实际令牌内容变更、变长文本或二进制尾部差异仍须区分。 +- 全表复制、行数与摘要校验、失败不切配置等保护全部保留。 + +## 回归与构建 + +| 项目 | 结果与证据 | +| --- | --- | +| 修复前确定性反例 | `character-before.log`,六个扫描用例、两个摘要用例失败 | +| 修复后专项 | `character-after.log`,两个顶层测试、22 个子用例通过 | +| 本地迁移包 | `character-package.log`,退出零;可选 SQL 集成未配置,不能称零跳过 | +| 三轮专项竞态 | `character-race.log`,退出零;同样不扩大为可选 SQL 集成通过 | +| 云端新增专项 | `smail-review-ssl-apex-20260921/20260921-072942-character-fixed`,两个顶层测试、零跳过、退出零 | +| 本地全量后端及 overlay | `run-2026-09-20T23-31-05-653Z-37152`,66 个测试包、1136 个顶层测试、904 个子测试通过;35 个顶层与 12 个子测试跳过,另有 18 个无测试包 | +| 云端完整 Python 部署回归 | `/var/tmp/smail-review-python-20260921-072946/`,136 项、零跳过、退出零;运行 6.603 秒,内存峰值 46.7 MiB、交换零 | + +全量后端的跳过包含缺少专用 SQL 连接、Windows 符号链接权限、本地管理员命名管道和浏览器夹具等条件;没有人为添加跳过或将它们计为通过。双 SQL 实机证据单独列在下节,不以这些可选测试的空运行代替。 + +候选重新编译后端,复用未改动的前端与 Linux 插件产物;不是本轮重新构建全部资产。构建基线为 `6f63a7e` 加本轮两个 Go 文件改动,611 个后端 Go 文件摘要保存于 `character-source-manifest.json`。 + +- 主程序 `smail-character`:`97ad202b67e57e6284d7707928cf4591a5d2d676344d7b67f003630242e28924`。 +- Linux 测试二进制:`28e424f0a5b3663f43db55bcfd022bb3721dbb438d108184aede8b6af6d7ce96`。 +- Python 部署回归日志:`c6efcb86c945ebf221652edc395133f2d454e3ef5942218591534353c61caf8d`。 + +## 双向实机结果 + +实例完整路径前缀为 `/var/tmp/smail-review-runtime-`。 + +| 项目 | MySQL → PostgreSQL | PostgreSQL → MySQL | +| --- | --- | --- | +| 实例目录后缀 | `20260921-072157` | `20260921-072510` | +| 内部重启前后 PID | `786894`,不变 | `787599`,不变 | +| 系统重启后 PID | `787300` | `787953` | +| 故障恢复后 PID | `787432` | `788077` | +| 三阶段源/目标连接数 | 均为 `0 / 3` | 均为 `0 / 3` | +| 目标账号锁定后启动 | failed,PID 为零,退出 1 | failed,PID 为零,退出 1 | +| 源/目标配置历史条数 | `1 / 1`,原始载荷和摘要一致 | `1 / 1`,原始载荷和摘要一致 | +| SQLite 文件数 | 零 | 零 | +| OOM、资源采集及清理错误 | 全部为零 | 全部为零 | + +两轮均由正式 API 预览、迁移、提交配置并触发内部重启。待重启期间拒绝业务写入,内部重启保留原会话。系统级重启后 PID 与 InvocationID 改变。锁定目标账号模拟故障时,不回退源库、不改写目标配置;恢复账号后再验登录、SMTP 收信及 IMAP/POP3 读取历史和新增邮件。 + +迁移历史比较使用规范化的原始字节编码,同时检查载荷 SHA256;不是只比较条数或解析后的 JSON。源库逻辑指纹在后续业务及故障注入期间保持不变。每个实例只有目标库承载活动业务和配置历史,源库只是保留的离线证据。 + +## 证据与收尾复核 + +本地证据均位于 `test-log/production-remediation-20260921/`: + +- 双向汇总:`cross-sql-results-20260921-072145.json`。 +- 原始证据:`cross-sql-runtime-evidence/` 下对应两个实例,共 26 份有效文件。 +- 独立复核:`cross-sql-recheck-20260921-073040.json`,重新核对本地与云端摘要、六个工具及 611 个后端源码摘要。 +- 全量后端统计:`character-full-backend-result.json`,保留全部跳过名称。 +- 云端部署回归:`python-linux-result-20260921-072946.json` 及对应完整日志。 + +独立查询数据库系统表确认四个源/目标账号均锁定;两个 PostgreSQL 账号均不可登录、没有建库权限、不是超级用户。两轮桥接、应用、网络锚点及诊断单元均已停止。正式 Smail、Nginx、MySQL、PostgreSQL、Scert、FDM 的 PID、InvocationID、重启次数保持一致,正式程序摘要仍为 `c14ef1c5ba1dc2096c312c8357039a7330c985cbeac90765aed01d8151e05e1f`。 + +用户已有的 `docs/云端全项目测试报告.md` 未编辑、未暂存,摘要仍为 `A142FAA660487656124A8BD4A3EF4F58ED80B3DEC0EDA4AAB0F20B8C3297FF08`。失败目标、离线源库和诊断证据未删除。 diff --git a/docs/数据库迁移重启补充验收.md b/docs/数据库迁移重启补充验收.md index d28092b6..b4019479 100644 --- a/docs/数据库迁移重启补充验收.md +++ b/docs/数据库迁移重启补充验收.md @@ -9,7 +9,8 @@ - [✓] HTTP/HTTPS 活动响应排空修复与本地、Linux 云端专项复核。 - [✓] 最新候选 SQLite/MySQL/PostgreSQL 空数据基础回归,配置历史与业务同库。 - [✓] MySQL/PostgreSQL 同引擎切库后的真实进程重启和故障恢复,见后续补充记录。 -- [ ] 跨引擎应用级迁移与重启矩阵。 +- [✓] MySQL ↔ PostgreSQL 跨引擎应用级迁移、两类重启及账号故障恢复,见 `SQL跨引擎迁移补充验收.md`。 +- [ ] 涉及 SQLite 的四个跨引擎应用级迁移与重启方向。 - [ ] 迁移复制、提交途中进程中断后的恢复。 - [!] 浏览器、外部收件、自动续期、安全告警、凭据轮换及其他生产门禁仍未关闭。 diff --git a/docs/生产补充验收记录.md b/docs/生产补充验收记录.md index ce3efbf4..ebbbb821 100644 --- a/docs/生产补充验收记录.md +++ b/docs/生产补充验收记录.md @@ -30,7 +30,8 @@ - [✓] HTTP 退出后原 Cookie 重放、账号禁用及修改密码后的旧会话拒绝;浏览器操作另行验收。 - [✓] 管理员恢复整数边界及系统程序路径整改,云端 Linux 包 41 个顶层测试零跳过通过;新候选三库空数据实机复验通过。 - [✓] 修复自动 DNS 证书加载与导出遗漏根域名校验,保留整改前云端失败反例;新候选的证书专项与三库回归通过。 -- [ ] 真正应用进程切库重启后的业务验收、故障注入和中断恢复。 +- [✓] SQLite 同引擎及双 SQL 同引擎、跨引擎切库后的真实进程重启与目标不可用恢复;分阶段证据见后文。 +- [ ] 涉及 SQLite 的跨引擎应用级重启矩阵、SQL 中断及 SQLite 提交/配置切换临界点恢复。 - [ ] 最新候选浏览器初始化、账号恢复及完整用户操作闭环。 - [!] Windows 提权本地通道验收需要管理员会话,当前终端未提权。 - [!] 生产 DNS 自动续期需要安全注入有效且已轮换的最小权限凭据,当前为手动托管证书。 @@ -322,3 +323,11 @@ SQLite 已完成迁移失败不覆盖目标、成功迁移、内部重启、系 详见 `SQLite复制中断恢复补充验收.md`。未提交复制事务中的真实 SIGKILL 中断、源库和配置保持不变、恢复后的登录与真实邮件读写、失败目标拒绝覆盖已通过。随后正常迁移及两类重启、目标权限故障恢复通过,最终活动业务库只有 `migrated.db`,配置历史同库;离线源库和失败目标仅保留为证据。 扩充云端部署套件实际执行 131 项、零跳过、退出零;20 份中断实机证据、四个工具及 610 个后端源码摘要已复核。正式服务与其他项目保持不变,未替换正式程序。SQLite 提交及配置切换临界点、SQL 中断、跨引擎矩阵和前述其他生产门禁仍未关闭,不能宣称整体验收通过。 + +### 9 月 21 日双 SQL 跨引擎补充 + +详见 `SQL跨引擎迁移补充验收.md`。MySQL → PostgreSQL 首轮实机被完整性校验拒绝,经过只读 Go 驱动诊断,定位并修复定长字符填充表示差异。修复仅按源引擎和列类型处理填充,不忽略任何业务表,不裁剪普通文本、二进制值或 SQLite 数据;保留失败反例和 22 个子用例。 + +新候选完成 MySQL ↔ PostgreSQL 双向迁移、内部重启、系统级重启、目标账号故障及恢复后的真实三协议读写,源库零活动连接、源内容不变、配置历史同库、没有 SQLite 文件。26 份实机证据、六个工具及 611 个后端源码摘要再次核对一致,测试账号锁定且临时建库权限撤销,正式六服务和程序未变。 + +云端完整部署回归实际为 136 项、零跳过;新增 Go 专项两个顶层测试零跳过。本地全量后端通过,但明确保留 35 个顶层和 12 个子测试跳过,不能称全平台零跳过。涉及 SQLite 的其余四个跨引擎应用级方向、中断临界点和此前其他生产门禁仍未关闭,整体生产验收尚未通过。