namespace Admin.NET.Core.Update; /// 已知失败迁移的恢复裁决。 public enum MigrationRecoveryDecision { /// 不做任何事(绝大多数情况)。 None = 0, /// 重跑前清空目标表,然后让原脚本原样重跑。 Truncate = 1, /// 拒绝并停机:命中了登记版本,但 hash 对不上,不能对未知脚本执行破坏性操作。 Reject = 2 } /// 一条已发布且已知会失败的迁移,及其恢复策略。 public sealed record MigrationRecoveryPlan( string Version, string OriginalFileHash, string Strategy, string TableName); /// 裁决结果。 public sealed record MigrationRecoveryOutcome( MigrationRecoveryDecision Decision, MigrationRecoveryPlan? Plan, string? RejectReason); /// /// 已知失败迁移的「重跑前恢复」策略。纯函数,不碰数据库,effect 由调用方执行。 /// /// 这不是迁移的正常语义,而是针对特定版本的一次性抢救。 /// 适用前提是:脚本已经 push、hash 已被多个环境记录,此时 /// · 不能改脚本 —— 已 Success 的环境会因 hash 不符而拒绝启动; /// · 不能手改迁移日志 —— 那是伪造执行历史。 /// 唯一安全的口子是在重跑之前把「让它跑不完的那点状态」清掉, /// 再让原脚本原样重跑成功,hash 与执行历史都保持真实。 /// /// 1.0.517 的具体情况:脚本末尾的 /// DELETE FROM dwd_requirement_examine_detail WHERE material_role IS NULL /// 谓词正确但跑不完 —— material_role 是同一脚本刚 ADD 的列、无索引, /// 实测共享库命中 1,028,210 行 ≈ 100%,单条语句 elapsed 645,038ms, /// 超过迁移执行器 600s 的 CommandTimeout ⇒ Failed;而 Failed 脚本每次启动都会重跑、 /// StopApplicationOnFailure 默认 true,于是形成「启动→重试→超时→停机」的死循环。 /// 该表是 100% 派生、读者数为 0 的 DWD,下一轮 S1 跑批即完整重建, /// 因此重跑前 TRUNCATE 是安全的:原 DELETE 随后命中 0 行、瞬间完成。 /// public static class MigrationRecoveryPolicy { /// /// 恢复登记表。只按版本号精确登记,不做任何模式匹配、不按脚本内容推断。 /// 新增条目等于授权一次破坏性操作,必须逐条评审。 /// public static readonly IReadOnlyList KnownFailedRecoveries = new[] { new MigrationRecoveryPlan( Version: "1.0.517", OriginalFileHash: "EF2722954B4BB5BEA465F63E764C927F01E9E52109738478E89CE94145ECB50B", Strategy: "DWD_TRUNCATE_PRE_RETRY", TableName: "dwd_requirement_examine_detail") }; /// /// 裁决是否要在重跑前做恢复。四道闸门,全部通过才 。 /// /// 待执行脚本的版本号。 /// 迁移日志里该版本的状态;从未跑过时传 null。 /// 迁移日志里记录的脚本 SHA256。 /// 磁盘上的脚本内容是否仍等于登记的原始 hash。 public static MigrationRecoveryOutcome Decide( string version, string? loggedStatus, string? loggedFileHash, bool diskHashMatchesOriginal) { // 闸门 1:版本必须精确登记。 var plan = KnownFailedRecoveries.FirstOrDefault(x => string.Equals(x.Version, version, StringComparison.OrdinalIgnoreCase)); if (plan == null) return new MigrationRecoveryOutcome(MigrationRecoveryDecision.None, null, null); // 闸门 2:必须已经跑过且失败。 // 从未跑过(无日志行)= 正常首次执行,不是抢救场景; // 已 Success 的根本走不到这里(ShouldSkipScript 已把它排除出 pending), // 这里再判一次是纵深防御,确保本策略永远不会碰成功过的环境。 if (string.IsNullOrWhiteSpace(loggedStatus)) return new MigrationRecoveryOutcome(MigrationRecoveryDecision.None, plan, null); if (!string.Equals(loggedStatus, "Failed", StringComparison.OrdinalIgnoreCase)) return new MigrationRecoveryOutcome(MigrationRecoveryDecision.None, plan, null); // 闸门 3:日志记录的 hash 必须等于已发布的原始脚本 hash。 // 不符 = 「这个 1.0.517 不是我们登记的那一个」, // 此时继续等于拿不可回滚的 TRUNCATE 去赌未知脚本 ⇒ 显式拒绝。 if (!string.Equals(loggedFileHash?.Trim(), plan.OriginalFileHash, StringComparison.OrdinalIgnoreCase)) return new MigrationRecoveryOutcome( MigrationRecoveryDecision.Reject, plan, $"迁移日志记录的 SHA256 与已登记的原始脚本不一致:记录={loggedFileHash},登记={plan.OriginalFileHash}"); // 闸门 4:磁盘脚本也必须仍是原始内容(防止本地改过脚本又恰好处于 Failed)。 if (!diskHashMatchesOriginal) return new MigrationRecoveryOutcome( MigrationRecoveryDecision.Reject, plan, "磁盘脚本内容与已登记的原始脚本不一致,恢复策略只针对已发布的原始脚本"); return new MigrationRecoveryOutcome(MigrationRecoveryDecision.Truncate, plan, null); } }