namespace Admin.NET.Core.Update; /// 已知迁移记录冲突的重整裁决。 public enum MigrationCollisionDecision { /// 不做任何事(绝大多数情况,含全新环境与已升级完成的环境)。 None = 0, /// 把误记的那一行状态重整掉,让原脚本按正常流程重跑并原地覆写该行。 ResetCollidedRecord = 1, /// 拒绝并停机:命中了登记的事故签名,但安全前提不成立。 Reject = 2 } /// 一条已登记的迁移记录冲突,及其重整所需的全部证据常量。 public sealed record MigrationCollisionPlan( string Version, string CollidedFileHash, string ExpectedDiskHash, string DisplacedVersion, string DisplacedFileHash, string Strategy); /// 裁决结果。 public sealed record MigrationCollisionOutcome( MigrationCollisionDecision Decision, MigrationCollisionPlan? Plan, string? RejectReason); /// /// 已知「迁移记录被误记」的重整策略。纯函数,不碰数据库,effect 由调用方执行。 /// /// 的区别:那个处理「脚本 Failed、重跑前清状态」, /// 在执行循环内按 pending 脚本调用;本策略处理「某版本被记成 Success,但记录的内容其实是另一个版本的脚本」, /// 必须在 pending 计算之前调用 —— 因为 ShouldSkipScript 正是在计算 pending 时就抛 hash 不符。 /// /// 1.0.536 的具体情况:2026-09-15 22:58 一次重启从尚未更新到目标 revision 的工作区启动, /// 其 UpdateScripts/1.0.536.sql 当时仍是旧 Stage-3 内容(与 1.0.539 同一份 SQL、仅注释里版本号不同), /// 于是该内容被执行并记成了 version=1.0.536 / hash=970E5A8F… / Success。 /// 而同一份 Stage-3 内容早已在 2026-09-12 以 version=1.0.539 / hash=9B627E41… 合法记录。 /// 结果:真正的 1.0.536(API_INBOUND)永远无法执行 —— 它在目标 revision 上的 hash 是 5A18A15A…, /// 与记录不符,ShouldSkipScript 直接抛错,任何带目标 revision 的实例都起不来。 /// /// 为什么是「重整状态」而不是「删除记录」sys_db_migration_log 对 version 有唯一键, /// 且 UpsertMigrationLog 按 version 查到已有行后原地 UPDATE。 /// 因此只要把误记行的状态改掉,正式的 1.0.536 就会被执行,并由原有正常流程把同一行 /// 覆写成真实的 hash=5A18A15A… / Success。历史不被删除,而是被真实执行结果取代—— /// 「这条迁移到底成没成功」始终是脚本自己跑出来的结论。 /// /// 安全边界:本策略不能感知同一数据库上的其它实例。若重整之后、正式脚本执行之前, /// 某个仍持旧脚本的实例启动,它会重跑旧 Stage-3 并把 hash 改回去。 /// 因此部署时必须先停掉所有连接该库的旧 backend —— 这是流程前提,代码无法兜底。 /// public static class MigrationCollisionPolicy { /// 重整后写入的中间状态。刻意不使用 Running/Success/Failed/Skipped 任一既有值,避免与正常流程语义混淆。 public const string SupersededStatus = "Superseded"; /// /// 冲突登记表。只按版本号精确登记,并钉死全部相关 SHA256,不做任何模式匹配、不按脚本内容推断。 /// 新增条目等于授权一次对迁移历史的修改,必须逐条评审。 /// 严禁据此演化出任何「hash 不符就改历史」的通用逻辑。 /// public static readonly IReadOnlyList KnownCollisions = new[] { new MigrationCollisionPlan( Version: "1.0.536", CollidedFileHash: "970E5A8F0133944CB04AC95E67112A2EC6E2E45F06AE61C3F2049163F7DC9A61", ExpectedDiskHash: "5A18A15AB379167A151C317A18617E2841EC4E808C8FD727216360D81EA76776", DisplacedVersion: "1.0.539", DisplacedFileHash: "9B627E41C15EBB5195C035592870E56A9EDE5704FA423DDEC5A4A8FF94A6CE42", Strategy: "COLLISION_STATUS_RESET") }; /// /// 裁决是否要在计算 pending 之前重整某条误记的迁移记录。 /// /// 闸门 1–4 用于识别事故签名,任一不成立一律返回 /// —— 这些情形(全新环境、已升级完成的环境、失败重试中的环境)都属正常,必须放行给既有流程处理。 /// /// 签名确认之后,闸门 5–7 是安全前提,任一不成立一律 /// 并停机,不允许降级为「跳过重整继续启动」。 /// /// 待判定的脚本版本号。 /// 迁移日志中该 version 的行数(正常为 0 或 1)。 /// 该 version 日志行的状态;无行时传 null。 /// 该 version 日志行记录的 SHA256。 /// 磁盘脚本内容是否等于 ExpectedDiskHash(由调用方用 MigrationScriptHash 判定)。 /// 被顶替版本(如 1.0.539)日志行的状态。 /// 被顶替版本日志行记录的 SHA256。 /// 磁盘上被顶替版本的脚本内容是否等于 DisplacedFileHash。 public static MigrationCollisionOutcome Decide( string version, int matchedRowCount, string? loggedStatus, string? loggedFileHash, bool targetDiskHashMatches, string? displacedLoggedStatus, string? displacedLoggedFileHash, bool displacedDiskHashMatches) { // 闸门 1:版本必须精确登记。 var plan = KnownCollisions.FirstOrDefault(x => string.Equals(x.Version, version, StringComparison.OrdinalIgnoreCase)); if (plan == null) return new MigrationCollisionOutcome(MigrationCollisionDecision.None, null, null); // 闸门 2:该版本必须恰好有一行日志。 // 0 行 = 全新环境的正常首次执行,不是事故场景。 if (matchedRowCount != 1) return new MigrationCollisionOutcome(MigrationCollisionDecision.None, plan, null); // 闸门 3:必须处于 Success。 // Failed / Running 是别的问题,交给既有路径(ShouldSkipScript 会让它重跑)。 if (!string.Equals(loggedStatus, "Success", StringComparison.OrdinalIgnoreCase)) return new MigrationCollisionOutcome(MigrationCollisionDecision.None, plan, null); // 闸门 4:记录的 hash 必须正是那次事故写入的值。 // 不符则一律 None —— 关键:**已经升级完成的环境**这里是 ExpectedDiskHash, // 若在此 Reject 会把所有正常环境打死。交给 ShouldSkipScript 按常规判定即可。 if (!string.Equals(loggedFileHash?.Trim(), plan.CollidedFileHash, StringComparison.OrdinalIgnoreCase)) return new MigrationCollisionOutcome(MigrationCollisionDecision.None, plan, null); // —— 至此确认命中登记的事故签名,以下任一不成立即停机 —— // 闸门 5:当前部署必须就是目标 revision。 // 否则说明这是个带着别的代码版本的实例,重整后它会跑出我们没预期的脚本。 if (!targetDiskHashMatches) return new MigrationCollisionOutcome( MigrationCollisionDecision.Reject, plan, $"命中 {plan.Version} 事故签名,但磁盘脚本与登记的目标内容({plan.ExpectedDiskHash})不一致," + "当前部署不是该重整所针对的 revision"); // 闸门 6:被顶替的内容必须已被合法记录在案。 // 这是最关键的一道:证明重整不会抹掉任何真实发生过的执行史。 if (!string.Equals(displacedLoggedStatus, "Success", StringComparison.OrdinalIgnoreCase) || !string.Equals(displacedLoggedFileHash?.Trim(), plan.DisplacedFileHash, StringComparison.OrdinalIgnoreCase)) return new MigrationCollisionOutcome( MigrationCollisionDecision.Reject, plan, $"命中 {plan.Version} 事故签名,但未能证明被顶替的内容已由 {plan.DisplacedVersion} " + $"(hash={plan.DisplacedFileHash}、status=Success)合法记录:" + $"实际 status={displacedLoggedStatus}、hash={displacedLoggedFileHash}"); // 闸门 7:磁盘上被顶替版本的脚本也必须仍是登记的那一份, // 确保「已记录的那条」与「本 revision 携带的那条」是同一个东西。 if (!displacedDiskHashMatches) return new MigrationCollisionOutcome( MigrationCollisionDecision.Reject, plan, $"命中 {plan.Version} 事故签名,但磁盘上的 {plan.DisplacedVersion} 脚本内容与登记的 " + $"{plan.DisplacedFileHash} 不一致"); return new MigrationCollisionOutcome(MigrationCollisionDecision.ResetCollidedRecord, plan, null); } }