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