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);
}
}