Переглянути джерело

feat(s8): 补齐异常恢复系统时间线

恢复此前已经写 recovered_at、写 RECOVERED detection_log、发恢复通知,
唯独**用户能看见的那条时间线没有**。于是页面上异常"自己不见了",
没人能分辨是系统判定恢复、还是有人做了什么。

保持既有语义不动:恢复**不**自动关闭异常,状态机不变,recovered_at 与
RECOVERED detection_log 原样保留。恢复只表示"规则不再命中",是否闭环仍由人判断。

operator_user_id 写 NULL,绝不伪造。时间线的操作人列刚在 S8-SYSUSER-ONLY-1 里从
"混装 EmployeeMaster.RecID 与 SysUser.Id 两套空间"收拾干净(实测 CLAIM 19 行是
员工 Id、其余是账号 Id),系统动作往里塞 1 或任意账号 Id 等于把刚修好的东西弄脏。

幂等判据用既有列表达:不存在 created_at 晚于本异常 last_detected_at 的
SYSTEM_RECOVERED 行。
- 首次恢复:最近命中之后还没有恢复行 → 写入;
- 调度器重跑:刚写的那行已晚于 last_detected_at → 跳过;
- 复发后再恢复:RefreshDetectionAsync 会把 last_detected_at 推到旧恢复行之后
  → 写入新的一条,那是真实的新恢复周期,本就该留痕。
刻意不用"最后一条时间线是什么"作判据:调度器根本不写其它时间线,
那种写法会把第二次真实恢复也挡掉。

前端把 NULL 操作人显示为「系统」。原实现是 v-if="item.operatorName",
NULL 时整行不渲染,系统恢复那条看起来像"谁都没做过"。

本地验证(经 API 调整规则宽限期让其不再命中,全程不碰业务数据):
未达阈值时 recovered_at 与 SYSTEM_RECOVERED 均为 0(抗抖期间不刷屏);
达到阈值后 28 条异常同时得到 recovered_at / RECOVERED detection_log /
SYSTEM_RECOVERED 三者各 28 条,auto_closed = 0,operator_user_id 全为 NULL;
再次跑批仍是 28 条、每条异常恰好 1 条(幂等);详情页显示
「系统恢复 / 操作人:系统」,无裸 NULL。验证后已把宽限期复原为 0。
YY968XX 2 днів тому
батько
коміт
5593e98a45

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.387",
+	"version": "2.4.388",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 15 - 2
Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue

@@ -72,7 +72,20 @@ const route = useRoute();
 const router = useRouter();
 const loading = ref(false);
 const detail = ref<Record<string, any> | null>(null);
-const timeline = ref<{ id: number; actionCode: string; actionLabel: string; operatorName?: string | null; actionRemark?: string | null; createdAt: string }[]>([]);
+const timeline = ref<{ id: number; actionCode: string; actionLabel: string; operatorUserId?: number | null; operatorName?: string | null; actionRemark?: string | null; createdAt: string }[]>([]);
+
+/**
+ * 时间线操作人展示。
+ *
+ * S8-SYSTEM-RECOVERED-TIMELINE-1:系统动作(如 SYSTEM_RECOVERED)没有真人操作者,
+ * 后端写 NULL 而不是伪造一个账号 Id。这里显示为「系统」——
+ * 原实现是 v-if="item.operatorName",NULL 时整行不渲染,于是系统恢复那条
+ * 看起来像"谁都没做过",用户无从判断是自动恢复还是有人动过。
+ */
+function operatorLabel(item: { operatorUserId?: number | null; operatorName?: string | null }) {
+	if (item.operatorName) return item.operatorName;
+	return item.operatorUserId ? `账号 ${item.operatorUserId}` : '系统';
+}
 const decisions = ref<S8DecisionRow[]>([]);
 const evidences = ref<S8EvidenceRow[]>([]);
 // S8-SYSUSER-ONLY-1:人员选择器的选项就是系统账号本身。
@@ -438,7 +451,7 @@ onMounted(async () => {
 						<el-timeline v-if="timeline.length">
 							<el-timeline-item v-for="item in timeline" :key="item.id" :timestamp="item.createdAt">
 								<div class="timeline-title">{{ cleanAnyTextForDisplay(item.actionLabel) }}</div>
-								<div v-if="item.operatorName" class="muted">操作人:{{ item.operatorName }}</div>
+								<div class="muted">操作人:{{ operatorLabel(item) }}</div>
 								<div v-if="item.actionRemark" class="muted">{{ cleanAnyTextForDisplay(item.actionRemark) }}</div>
 							</el-timeline-item>
 						</el-timeline>

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.500</AssemblyVersion>
-    <FileVersion>1.0.500</FileVersion>
-    <Version>1.0.500</Version>
+    <AssemblyVersion>1.0.501</AssemblyVersion>
+    <FileVersion>1.0.501</FileVersion>
+    <Version>1.0.501</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 192 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8SystemRecoveredTimelineTests.cs

@@ -0,0 +1,192 @@
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-SYSTEM-RECOVERED-TIMELINE-1:恢复要在时间线上留痕。
+///
+/// <para><b>补的是什么</b>:恢复此前已经写 <c>recovered_at</c>、写 RECOVERED detection_log、
+/// 发恢复通知,唯独<b>用户能看见的那条时间线没有</b>。于是页面上异常"自己不见了",
+/// 没人能分辨是系统判定恢复、还是有人做了什么。</para>
+///
+/// <para><b>刻意不动的语义</b>:恢复<b>不</b>自动关闭异常;状态机不变;
+/// recovered_at 与 RECOVERED detection_log 原样保留。</para>
+/// </summary>
+public class S8SystemRecoveredTimelineTests
+{
+    private static readonly string PluginRoot =
+        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private static string CodeOnly(string relativePath)
+    {
+        var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+        return string.Join('\n', File.ReadAllLines(full)
+            .Where(l =>
+            {
+                var t = l.TrimStart();
+                return !t.StartsWith("///", StringComparison.Ordinal)
+                       && !t.StartsWith("//", StringComparison.Ordinal);
+            }));
+    }
+
+    private static string Scheduler() => CodeOnly("Service/S8/S8WatchSchedulerService.cs");
+
+    /// <summary>动作码是稳定常量,不是散落各处的字符串字面量。</summary>
+    [Fact]
+    public void ActionCode_IsAStableConstant()
+    {
+        Assert.Equal("SYSTEM_RECOVERED", S8WatchSchedulerService.SystemRecoveredActionCode);
+    }
+
+    /// <summary>
+    /// 阈值<b>未</b>达到时不得写时间线 —— 抗抖期间每次未命中都留一条,
+    /// 时间线会被刷屏,而且每一条都不代表真的恢复了。
+    /// </summary>
+    [Fact]
+    public void BelowThreshold_WritesNoTimeline()
+    {
+        var code = Scheduler();
+
+        // 未达阈值那一支必须在写时间线之前就 continue
+        var pendingIdx = code.IndexOf("antiflap_pending_recovery", StringComparison.Ordinal);
+        var timelineIdx = code.IndexOf("TryWriteSystemRecoveredTimelineAsync(c.Id", StringComparison.Ordinal);
+        Assert.True(pendingIdx > 0 && timelineIdx > 0);
+        Assert.True(pendingIdx < timelineIdx,
+            "未达恢复阈值的分支必须早于时间线写入,否则抗抖期间每次未命中都会留痕");
+        Assert.Contains("if (newMissCount < recoverRequired)", code);
+    }
+
+    /// <summary>
+    /// 阈值达到时:recovered_at 已写、RECOVERED detection_log 已写,<b>之后</b>才写时间线。
+    /// 顺序重要 —— 时间线是"已经判定恢复"的结果,不能先于判定出现。
+    /// </summary>
+    [Fact]
+    public void AtThreshold_WritesTimelineAfterStateAndDetectionLog()
+    {
+        var code = Scheduler();
+        var recoveredAtIdx = code.IndexOf("RecoveredAt = now,", StringComparison.Ordinal);
+        var detectionLogIdx = code.IndexOf("DetectResult = DetectResultRecovered,", StringComparison.Ordinal);
+        var timelineIdx = code.IndexOf("TryWriteSystemRecoveredTimelineAsync(c.Id", StringComparison.Ordinal);
+
+        Assert.True(recoveredAtIdx > 0 && detectionLogIdx > 0 && timelineIdx > 0);
+        Assert.True(recoveredAtIdx < timelineIdx, "recovered_at 必须先落库");
+        Assert.True(detectionLogIdx < timelineIdx, "RECOVERED detection_log 必须先落库");
+    }
+
+    /// <summary>
+    /// 恢复<b>不自动关闭</b>异常,也不改状态机 ——
+    /// 恢复只表示"规则不再命中",是否闭环仍由人判断。
+    /// </summary>
+    [Fact]
+    public void Recovery_DoesNotAutoCloseOrChangeStatus()
+    {
+        var code = Scheduler();
+
+        // 恢复那一段不得出现状态改写
+        var start = code.IndexOf("RecoveredAt = now,", StringComparison.Ordinal);
+        var end = code.IndexOf("TryDispatchRecoveredLayerNotificationAsync", StringComparison.Ordinal);
+        Assert.True(start > 0 && end > start);
+        var recoverySegment = code[start..end];
+
+        Assert.DoesNotContain("Status = \"CLOSED\"", recoverySegment);
+        Assert.DoesNotContain("Status = \"RECOVERED\"", recoverySegment);
+        Assert.DoesNotContain("ClosedAt", recoverySegment);
+
+        // 时间线本身也不写状态迁移
+        var writer = CodeOnly("Service/S8/S8WatchSchedulerService.cs");
+        Assert.Contains("ToStatus = null,", writer);
+    }
+
+    /// <summary>
+    /// <b>operator_user_id 必须是 NULL</b>,绝不伪造。
+    ///
+    /// <para>时间线的操作人列刚在 S8-SYSUSER-ONLY-1 里从"混装 EmployeeMaster.RecID 与
+    /// SysUser.Id 两套空间"收拾干净(实测 CLAIM 19 行是员工 Id、其余是账号 Id)。
+    /// 系统动作往里塞 1 或任意账号 Id,等于把刚修好的东西重新弄脏。</para>
+    /// </summary>
+    [Fact]
+    public void SystemAction_LeavesOperatorNullNeverFabricated()
+    {
+        var code = Scheduler();
+        var start = code.IndexOf("ActionCode = SystemRecoveredActionCode,", StringComparison.Ordinal);
+        Assert.True(start > 0);
+        var block = code[start..(start + 700)];
+
+        Assert.Contains("OperatorUserId = null,", block);
+        Assert.Contains("OperatorName = null,", block);
+        Assert.DoesNotContain("OperatorUserId = 1", block);
+        Assert.DoesNotContain("EmployeeId", block);
+    }
+
+    /// <summary>
+    /// 幂等:同一恢复周期只写一条;调度器重跑不重复追加;
+    /// 但复发后<b>真实的新恢复周期</b>要能再写一条。
+    ///
+    /// <para>判据用既有列表达:不存在 <c>created_at</c> 晚于 <c>last_detected_at</c> 的
+    /// SYSTEM_RECOVERED 行。复发时 <c>RefreshDetectionAsync</c> 会把 last_detected_at
+    /// 推到旧恢复行之后,于是新周期自然放行 —— 不新增状态字段、不靠"最后一条是什么"
+    /// (调度器根本不写其它时间线,那种判据会把第二次真实恢复也挡掉)。</para>
+    /// </summary>
+    [Fact]
+    public void Idempotent_PerRecoveryCycle_ButAllowsGenuineRerecovery()
+    {
+        var code = Scheduler();
+
+        Assert.Contains("t.ActionCode == SystemRecoveredActionCode", code);
+        Assert.Contains("t.CreatedAt > lastDetectedAt!.Value", code);
+        Assert.Contains("already_recorded_for_current_cycle", code);
+
+        // 复发确实会推进 last_detected_at 并清空 recovered_at —— 幂等判据依赖这一点
+        Assert.Contains("LastDetectedAt = hit.DetectedAt,", code);
+        Assert.Contains("RecoveredAt = null,", code);
+    }
+
+    /// <summary>
+    /// 时间线写失败不得回滚已判定的恢复状态 —— 但必须落日志。
+    /// 静默吞掉会让"恢复了却没有痕迹"变成无从查起的问题。
+    /// </summary>
+    [Fact]
+    public void TimelineFailure_DoesNotRollbackRecovery()
+    {
+        var code = Scheduler();
+        Assert.Contains("system_recovered_timeline_failed", code);
+        Assert.Contains("system_recovered_timeline_written", code);
+    }
+
+    /// <summary>
+    /// 前端把 NULL 操作人显示为「系统」,不是整行不渲染。
+    ///
+    /// <para>原实现 <c>v-if="item.operatorName"</c> 在 NULL 时整行消失,
+    /// 系统恢复那条看起来像"谁都没做过",用户无从判断是自动恢复还是有人动过。</para>
+    /// </summary>
+    [Fact]
+    public void Frontend_ShowsSystemInsteadOfBlankOperator()
+    {
+        var page = File.ReadAllText(Path.GetFullPath(Path.Combine(
+            PluginRoot, "../../../Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue")));
+
+        Assert.Contains("function operatorLabel(", page);
+        Assert.Contains("'系统'", page);
+        Assert.Contains("operatorLabel(item)", page);
+        Assert.DoesNotContain("v-if=\"item.operatorName\" class=\"muted\"", page);
+    }
+
+    /// <summary>
+    /// 租户隔离不受本批影响:恢复处理仍在按租户取候选的循环里,
+    /// 时间线只按 exceptionId 写,不跨租户。
+    /// </summary>
+    [Fact]
+    public void TenantIsolation_Preserved()
+    {
+        var code = Scheduler();
+        Assert.Contains("x.TenantId == tenantId", code);
+        var start = code.IndexOf("TryWriteSystemRecoveredTimelineAsync(long exceptionId", StringComparison.Ordinal);
+        Assert.True(start > 0);
+        var method = code[start..(start + 1600)];
+        // 时间线写入只按主键定位,不引入任何工厂维度
+        Assert.Contains("t.ExceptionId == exceptionId", method);
+        Assert.DoesNotContain("FactoryId", method);
+    }
+}

+ 71 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -42,6 +42,9 @@ public class S8WatchSchedulerService : ITransient
     private const string DetectResultCreated = "CREATED";
     private const string DetectResultRefreshed = "REFRESHED";
     private const string DetectResultRecovered = "RECOVERED";
+
+    /// <summary>系统恢复时间线的动作码。与人工动作码同一命名空间,但永远不带操作人。</summary>
+    internal const string SystemRecoveredActionCode = "SYSTEM_RECOVERED";
     private const string DetectResultNoHit = "NO_HIT";
     private const string DetectResultEvaluateFailed = "EVALUATE_FAILED";
 
@@ -529,6 +532,11 @@ public class S8WatchSchedulerService : ITransient
                 Remark = "Rule no longer hit; recovered_at marked"
             });
 
+            // S8-SYSTEM-RECOVERED-TIMELINE-1:恢复也要在时间线上留痕。
+            // 此前 recovered_at 与 RECOVERED detection_log 都有了,唯独用户能看见的那条时间线没有 ——
+            // 于是页面上异常"自己不见了",没人知道是系统判定恢复还是有人做了什么。
+            await TryWriteSystemRecoveredTimelineAsync(c.Id, now, newMissCount, recoverRequired);
+
             // S8-NOTIFY-WIRE-RECOVERED-1:detection_log 已写入、recovered_at 已落库后挂入恢复通知。
             // 通知失败仅 LogWarning,绝不影响恢复状态/检测日志。
             await TryDispatchRecoveredLayerNotificationAsync(c.Id);
@@ -609,6 +617,69 @@ public class S8WatchSchedulerService : ITransient
         }
     }
 
+    /// <summary>
+    /// S8-SYSTEM-RECOVERED-TIMELINE-1:写一条系统恢复时间线。
+    ///
+    /// <para><b>不自动关闭异常</b>:恢复只表示"规则不再命中",是否闭环仍由人判断。
+    /// 既有语义(recovered_at + RECOVERED detection_log,状态不变)原样保留。</para>
+    ///
+    /// <para><b>operator_user_id 写 NULL</b>:这一步没有真人操作者。
+    /// 绝不伪造 1 / EmployeeId / 某个 SysUserId —— 时间线的操作人列刚在
+    /// S8-SYSUSER-ONLY-1 里从"混装两套 ID 空间"收拾干净,不能又塞进一个假值。</para>
+    ///
+    /// <para><b>幂等判据</b>:不存在 <c>created_at</c> 晚于本异常最近一次命中时间
+    /// (<c>last_detected_at</c>)的 SYSTEM_RECOVERED 行。
+    /// <list type="bullet">
+    ///   <item>首次恢复:最近命中之后还没有恢复行 → 写入;</item>
+    ///   <item>调度器重跑:刚写的那行已经晚于 last_detected_at → 跳过,不重复追加;</item>
+    ///   <item>复发后再恢复:<c>RefreshDetectionAsync</c> 会把 last_detected_at 推到
+    ///         旧恢复行之后 → 写入新的一条,这是<b>真实的新恢复周期</b>,本就该留痕。</item>
+    /// </list>
+    /// 只用既有列表达,不新增状态字段。</para>
+    ///
+    /// <para>失败仅 LogWarning:时间线写不进去不该把已经判定的恢复状态回滚。</para>
+    /// </summary>
+    private async Task TryWriteSystemRecoveredTimelineAsync(long exceptionId, DateTime now, int missCount, int recoverRequired)
+    {
+        try
+        {
+            var lastDetectedAt = await _exceptionRep.AsQueryable().ClearFilter()
+                .Where(x => x.Id == exceptionId)
+                .Select(x => x.LastDetectedAt)
+                .FirstAsync();
+
+            var already = await _exceptionRep.Context.Queryable<AdoS8ExceptionTimeline>()
+                .Where(t => t.ExceptionId == exceptionId && t.ActionCode == SystemRecoveredActionCode)
+                .WhereIF(lastDetectedAt != null, t => t.CreatedAt > lastDetectedAt!.Value)
+                .AnyAsync();
+            if (already)
+            {
+                _logger.LogInformation(
+                    "system_recovered_timeline_skip exceptionId={Id} reason=already_recorded_for_current_cycle", exceptionId);
+                return;
+            }
+
+            await _exceptionRep.Context.Insertable(new AdoS8ExceptionTimeline
+            {
+                ExceptionId = exceptionId,
+                ActionCode = SystemRecoveredActionCode,
+                ActionLabel = "系统恢复",
+                FromStatus = null,
+                ToStatus = null,          // 恢复不改状态机,故不写状态迁移
+                OperatorUserId = null,    // 系统动作无真人操作者,绝不伪造
+                OperatorName = null,
+                ActionRemark = $"系统检测到异常已恢复(连续未命中 {missCount} 次,达到恢复阈值 {recoverRequired})。异常未自动关闭,仍需人工确认闭环。",
+                CreatedAt = now
+            }).ExecuteCommandAsync();
+
+            _logger.LogInformation("system_recovered_timeline_written exceptionId={Id} missCount={Miss}", exceptionId, missCount);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "system_recovered_timeline_failed exceptionId={Id}", exceptionId);
+        }
+    }
+
     private static string Truncate(string? s, int max) =>
         string.IsNullOrEmpty(s) ? string.Empty : (s.Length <= max ? s : s.Substring(0, max));