MigrationCollisionPolicy.cs 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. namespace Admin.NET.Core.Update;
  2. /// <summary>已知迁移记录冲突的重整裁决。</summary>
  3. public enum MigrationCollisionDecision
  4. {
  5. /// <summary>不做任何事(绝大多数情况,含全新环境与已升级完成的环境)。</summary>
  6. None = 0,
  7. /// <summary>把误记的那一行状态重整掉,让原脚本按正常流程重跑并原地覆写该行。</summary>
  8. ResetCollidedRecord = 1,
  9. /// <summary>拒绝并停机:命中了登记的事故签名,但安全前提不成立。</summary>
  10. Reject = 2
  11. }
  12. /// <summary>一条已登记的迁移记录冲突,及其重整所需的全部证据常量。</summary>
  13. public sealed record MigrationCollisionPlan(
  14. string Version,
  15. string CollidedFileHash,
  16. string ExpectedDiskHash,
  17. string DisplacedVersion,
  18. string DisplacedFileHash,
  19. string Strategy);
  20. /// <summary>裁决结果。</summary>
  21. public sealed record MigrationCollisionOutcome(
  22. MigrationCollisionDecision Decision,
  23. MigrationCollisionPlan? Plan,
  24. string? RejectReason);
  25. /// <summary>
  26. /// 已知「迁移记录被误记」的重整策略。<b>纯函数,不碰数据库</b>,effect 由调用方执行。
  27. ///
  28. /// <para><b>与 <see cref="MigrationRecoveryPolicy"/> 的区别</b>:那个处理「脚本 Failed、重跑前清状态」,
  29. /// 在执行循环内按 pending 脚本调用;本策略处理「某版本被记成 Success,但记录的内容其实是另一个版本的脚本」,
  30. /// 必须在 pending <b>计算之前</b>调用 —— 因为 <c>ShouldSkipScript</c> 正是在计算 pending 时就抛 hash 不符。</para>
  31. ///
  32. /// <para><b>1.0.536 的具体情况</b>:2026-09-15 22:58 一次重启从<b>尚未更新到目标 revision 的工作区</b>启动,
  33. /// 其 <c>UpdateScripts/1.0.536.sql</c> 当时仍是旧 Stage-3 内容(与 1.0.539 同一份 SQL、仅注释里版本号不同),
  34. /// 于是该内容被执行并记成了 <c>version=1.0.536 / hash=970E5A8F… / Success</c>。
  35. /// 而同一份 Stage-3 内容早已在 2026-09-12 以 <c>version=1.0.539 / hash=9B627E41…</c> 合法记录。
  36. /// 结果:真正的 1.0.536(API_INBOUND)永远无法执行 —— 它在目标 revision 上的 hash 是 5A18A15A…,
  37. /// 与记录不符,<c>ShouldSkipScript</c> 直接抛错,任何带目标 revision 的实例都起不来。</para>
  38. ///
  39. /// <para><b>为什么是「重整状态」而不是「删除记录」</b>:<c>sys_db_migration_log</c> 对 version 有唯一键,
  40. /// 且 <c>UpsertMigrationLog</c> 按 version 查到已有行后<b>原地 UPDATE</b>。
  41. /// 因此只要把误记行的状态改掉,正式的 1.0.536 就会被执行,并由<b>原有正常流程</b>把同一行
  42. /// 覆写成真实的 <c>hash=5A18A15A… / Success</c>。历史不被删除,而是被真实执行结果取代——
  43. /// 「这条迁移到底成没成功」始终是脚本自己跑出来的结论。</para>
  44. ///
  45. /// <para><b>安全边界</b>:本策略<b>不能</b>感知同一数据库上的其它实例。若重整之后、正式脚本执行之前,
  46. /// 某个仍持旧脚本的实例启动,它会重跑旧 Stage-3 并把 hash 改回去。
  47. /// 因此部署时必须先停掉所有连接该库的旧 backend —— 这是流程前提,代码无法兜底。</para>
  48. /// </summary>
  49. public static class MigrationCollisionPolicy
  50. {
  51. /// <summary>重整后写入的中间状态。刻意不使用 Running/Success/Failed/Skipped 任一既有值,避免与正常流程语义混淆。</summary>
  52. public const string SupersededStatus = "Superseded";
  53. /// <summary>
  54. /// 冲突登记表。<b>只按版本号精确登记,并钉死全部相关 SHA256,不做任何模式匹配、不按脚本内容推断。</b>
  55. /// 新增条目等于授权一次对迁移历史的修改,必须逐条评审。
  56. /// <b>严禁</b>据此演化出任何「hash 不符就改历史」的通用逻辑。
  57. /// </summary>
  58. public static readonly IReadOnlyList<MigrationCollisionPlan> KnownCollisions = new[]
  59. {
  60. new MigrationCollisionPlan(
  61. Version: "1.0.536",
  62. CollidedFileHash: "970E5A8F0133944CB04AC95E67112A2EC6E2E45F06AE61C3F2049163F7DC9A61",
  63. ExpectedDiskHash: "5A18A15AB379167A151C317A18617E2841EC4E808C8FD727216360D81EA76776",
  64. DisplacedVersion: "1.0.539",
  65. DisplacedFileHash: "9B627E41C15EBB5195C035592870E56A9EDE5704FA423DDEC5A4A8FF94A6CE42",
  66. Strategy: "COLLISION_STATUS_RESET")
  67. };
  68. /// <summary>
  69. /// 裁决是否要在计算 pending 之前重整某条误记的迁移记录。
  70. ///
  71. /// <para>闸门 1–4 用于<b>识别事故签名</b>,任一不成立一律返回 <see cref="MigrationCollisionDecision.None"/>
  72. /// —— 这些情形(全新环境、已升级完成的环境、失败重试中的环境)都属正常,必须放行给既有流程处理。</para>
  73. ///
  74. /// <para>签名确认之后,闸门 5–7 是<b>安全前提</b>,任一不成立一律
  75. /// <see cref="MigrationCollisionDecision.Reject"/> 并停机,不允许降级为「跳过重整继续启动」。</para>
  76. /// </summary>
  77. /// <param name="version">待判定的脚本版本号。</param>
  78. /// <param name="matchedRowCount">迁移日志中该 version 的行数(正常为 0 或 1)。</param>
  79. /// <param name="loggedStatus">该 version 日志行的状态;无行时传 null。</param>
  80. /// <param name="loggedFileHash">该 version 日志行记录的 SHA256。</param>
  81. /// <param name="targetDiskHashMatches">磁盘脚本内容是否等于 <c>ExpectedDiskHash</c>(由调用方用 MigrationScriptHash 判定)。</param>
  82. /// <param name="displacedLoggedStatus">被顶替版本(如 1.0.539)日志行的状态。</param>
  83. /// <param name="displacedLoggedFileHash">被顶替版本日志行记录的 SHA256。</param>
  84. /// <param name="displacedDiskHashMatches">磁盘上被顶替版本的脚本内容是否等于 <c>DisplacedFileHash</c>。</param>
  85. public static MigrationCollisionOutcome Decide(
  86. string version,
  87. int matchedRowCount,
  88. string? loggedStatus,
  89. string? loggedFileHash,
  90. bool targetDiskHashMatches,
  91. string? displacedLoggedStatus,
  92. string? displacedLoggedFileHash,
  93. bool displacedDiskHashMatches)
  94. {
  95. // 闸门 1:版本必须精确登记。
  96. var plan = KnownCollisions.FirstOrDefault(x =>
  97. string.Equals(x.Version, version, StringComparison.OrdinalIgnoreCase));
  98. if (plan == null)
  99. return new MigrationCollisionOutcome(MigrationCollisionDecision.None, null, null);
  100. // 闸门 2:该版本必须恰好有一行日志。
  101. // 0 行 = 全新环境的正常首次执行,不是事故场景。
  102. if (matchedRowCount != 1)
  103. return new MigrationCollisionOutcome(MigrationCollisionDecision.None, plan, null);
  104. // 闸门 3:必须处于 Success。
  105. // Failed / Running 是别的问题,交给既有路径(ShouldSkipScript 会让它重跑)。
  106. if (!string.Equals(loggedStatus, "Success", StringComparison.OrdinalIgnoreCase))
  107. return new MigrationCollisionOutcome(MigrationCollisionDecision.None, plan, null);
  108. // 闸门 4:记录的 hash 必须正是那次事故写入的值。
  109. // 不符则一律 None —— 关键:**已经升级完成的环境**这里是 ExpectedDiskHash,
  110. // 若在此 Reject 会把所有正常环境打死。交给 ShouldSkipScript 按常规判定即可。
  111. if (!string.Equals(loggedFileHash?.Trim(), plan.CollidedFileHash, StringComparison.OrdinalIgnoreCase))
  112. return new MigrationCollisionOutcome(MigrationCollisionDecision.None, plan, null);
  113. // —— 至此确认命中登记的事故签名,以下任一不成立即停机 ——
  114. // 闸门 5:当前部署必须就是目标 revision。
  115. // 否则说明这是个带着别的代码版本的实例,重整后它会跑出我们没预期的脚本。
  116. if (!targetDiskHashMatches)
  117. return new MigrationCollisionOutcome(
  118. MigrationCollisionDecision.Reject, plan,
  119. $"命中 {plan.Version} 事故签名,但磁盘脚本与登记的目标内容({plan.ExpectedDiskHash})不一致,"
  120. + "当前部署不是该重整所针对的 revision");
  121. // 闸门 6:被顶替的内容必须已被合法记录在案。
  122. // 这是最关键的一道:证明重整不会抹掉任何真实发生过的执行史。
  123. if (!string.Equals(displacedLoggedStatus, "Success", StringComparison.OrdinalIgnoreCase) ||
  124. !string.Equals(displacedLoggedFileHash?.Trim(), plan.DisplacedFileHash, StringComparison.OrdinalIgnoreCase))
  125. return new MigrationCollisionOutcome(
  126. MigrationCollisionDecision.Reject, plan,
  127. $"命中 {plan.Version} 事故签名,但未能证明被顶替的内容已由 {plan.DisplacedVersion} "
  128. + $"(hash={plan.DisplacedFileHash}、status=Success)合法记录:"
  129. + $"实际 status={displacedLoggedStatus}、hash={displacedLoggedFileHash}");
  130. // 闸门 7:磁盘上被顶替版本的脚本也必须仍是登记的那一份,
  131. // 确保「已记录的那条」与「本 revision 携带的那条」是同一个东西。
  132. if (!displacedDiskHashMatches)
  133. return new MigrationCollisionOutcome(
  134. MigrationCollisionDecision.Reject, plan,
  135. $"命中 {plan.Version} 事故签名,但磁盘上的 {plan.DisplacedVersion} 脚本内容与登记的 "
  136. + $"{plan.DisplacedFileHash} 不一致");
  137. return new MigrationCollisionOutcome(MigrationCollisionDecision.ResetCollidedRecord, plan, null);
  138. }
  139. }