修复跨引擎定长字符校验并补齐双SQL实机验收

This commit is contained in:
smail 2026-09-21 07:35:41 +08:00
parent c0ce75597c
commit 3cc47aada1
5 changed files with 224 additions and 2 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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`。失败目标、离线源库和诊断证据未删除。

View File

@ -9,7 +9,8 @@
- [✓] HTTP/HTTPS 活动响应排空修复与本地、Linux 云端专项复核。
- [✓] 最新候选 SQLite/MySQL/PostgreSQL 空数据基础回归,配置历史与业务同库。
- [✓] MySQL/PostgreSQL 同引擎切库后的真实进程重启和故障恢复,见后续补充记录。
- [ ] 跨引擎应用级迁移与重启矩阵。
- [✓] MySQL ↔ PostgreSQL 跨引擎应用级迁移、两类重启及账号故障恢复,见 `SQL跨引擎迁移补充验收.md`。
- [ ] 涉及 SQLite 的四个跨引擎应用级迁移与重启方向。
- [ ] 迁移复制、提交途中进程中断后的恢复。
- [!] 浏览器、外部收件、自动续期、安全告警、凭据轮换及其他生产门禁仍未关闭。

View File

@ -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 的其余四个跨引擎应用级方向、中断临界点和此前其他生产门禁仍未关闭,整体生产验收尚未通过。