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