S8SystemRecoveredTimelineTests.cs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. using Admin.NET.Plugin.AiDOP.Service.S8;
  2. using Xunit;
  3. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  4. /// <summary>
  5. /// S8-SYSTEM-RECOVERED-TIMELINE-1:恢复要在时间线上留痕。
  6. ///
  7. /// <para><b>补的是什么</b>:恢复此前已经写 <c>recovered_at</c>、写 RECOVERED detection_log、
  8. /// 发恢复通知,唯独<b>用户能看见的那条时间线没有</b>。于是页面上异常"自己不见了",
  9. /// 没人能分辨是系统判定恢复、还是有人做了什么。</para>
  10. ///
  11. /// <para><b>刻意不动的语义</b>:恢复<b>不</b>自动关闭异常;状态机不变;
  12. /// recovered_at 与 RECOVERED detection_log 原样保留。</para>
  13. /// </summary>
  14. public class S8SystemRecoveredTimelineTests
  15. {
  16. private static readonly string PluginRoot =
  17. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  18. private static string CodeOnly(string relativePath)
  19. {
  20. var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
  21. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  22. return string.Join('\n', File.ReadAllLines(full)
  23. .Where(l =>
  24. {
  25. var t = l.TrimStart();
  26. return !t.StartsWith("///", StringComparison.Ordinal)
  27. && !t.StartsWith("//", StringComparison.Ordinal);
  28. }));
  29. }
  30. private static string Scheduler() => CodeOnly("Service/S8/S8WatchSchedulerService.cs");
  31. /// <summary>动作码是稳定常量,不是散落各处的字符串字面量。</summary>
  32. [Fact]
  33. public void ActionCode_IsAStableConstant()
  34. {
  35. Assert.Equal("SYSTEM_RECOVERED", S8WatchSchedulerService.SystemRecoveredActionCode);
  36. }
  37. /// <summary>
  38. /// 阈值<b>未</b>达到时不得写时间线 —— 抗抖期间每次未命中都留一条,
  39. /// 时间线会被刷屏,而且每一条都不代表真的恢复了。
  40. /// </summary>
  41. [Fact]
  42. public void BelowThreshold_WritesNoTimeline()
  43. {
  44. var code = Scheduler();
  45. // 未达阈值那一支必须在写时间线之前就 continue
  46. var pendingIdx = code.IndexOf("antiflap_pending_recovery", StringComparison.Ordinal);
  47. var timelineIdx = code.IndexOf("TryWriteSystemRecoveredTimelineAsync(c.Id", StringComparison.Ordinal);
  48. Assert.True(pendingIdx > 0 && timelineIdx > 0);
  49. Assert.True(pendingIdx < timelineIdx,
  50. "未达恢复阈值的分支必须早于时间线写入,否则抗抖期间每次未命中都会留痕");
  51. Assert.Contains("if (newMissCount < recoverRequired)", code);
  52. }
  53. /// <summary>
  54. /// 阈值达到时:recovered_at 已写、RECOVERED detection_log 已写,<b>之后</b>才写时间线。
  55. /// 顺序重要 —— 时间线是"已经判定恢复"的结果,不能先于判定出现。
  56. /// </summary>
  57. [Fact]
  58. public void AtThreshold_WritesTimelineAfterStateAndDetectionLog()
  59. {
  60. var code = Scheduler();
  61. var recoveredAtIdx = code.IndexOf("RecoveredAt = now,", StringComparison.Ordinal);
  62. var detectionLogIdx = code.IndexOf("DetectResult = DetectResultRecovered,", StringComparison.Ordinal);
  63. var timelineIdx = code.IndexOf("TryWriteSystemRecoveredTimelineAsync(c.Id", StringComparison.Ordinal);
  64. Assert.True(recoveredAtIdx > 0 && detectionLogIdx > 0 && timelineIdx > 0);
  65. Assert.True(recoveredAtIdx < timelineIdx, "recovered_at 必须先落库");
  66. Assert.True(detectionLogIdx < timelineIdx, "RECOVERED detection_log 必须先落库");
  67. }
  68. /// <summary>
  69. /// 恢复<b>不自动关闭</b>异常,也不改状态机 ——
  70. /// 恢复只表示"规则不再命中",是否闭环仍由人判断。
  71. /// </summary>
  72. [Fact]
  73. public void Recovery_DoesNotAutoCloseOrChangeStatus()
  74. {
  75. var code = Scheduler();
  76. // 恢复那一段不得出现状态改写
  77. var start = code.IndexOf("RecoveredAt = now,", StringComparison.Ordinal);
  78. var end = code.IndexOf("TryDispatchRecoveredLayerNotificationAsync", StringComparison.Ordinal);
  79. Assert.True(start > 0 && end > start);
  80. var recoverySegment = code[start..end];
  81. Assert.DoesNotContain("Status = \"CLOSED\"", recoverySegment);
  82. Assert.DoesNotContain("Status = \"RECOVERED\"", recoverySegment);
  83. Assert.DoesNotContain("ClosedAt", recoverySegment);
  84. // 时间线本身也不写状态迁移
  85. var writer = CodeOnly("Service/S8/S8WatchSchedulerService.cs");
  86. Assert.Contains("ToStatus = null,", writer);
  87. }
  88. /// <summary>
  89. /// <b>operator_user_id 必须是 NULL</b>,绝不伪造。
  90. ///
  91. /// <para>时间线的操作人列刚在 S8-SYSUSER-ONLY-1 里从"混装 EmployeeMaster.RecID 与
  92. /// SysUser.Id 两套空间"收拾干净(实测 CLAIM 19 行是员工 Id、其余是账号 Id)。
  93. /// 系统动作往里塞 1 或任意账号 Id,等于把刚修好的东西重新弄脏。</para>
  94. /// </summary>
  95. [Fact]
  96. public void SystemAction_LeavesOperatorNullNeverFabricated()
  97. {
  98. var code = Scheduler();
  99. var start = code.IndexOf("ActionCode = SystemRecoveredActionCode,", StringComparison.Ordinal);
  100. Assert.True(start > 0);
  101. var block = code[start..(start + 700)];
  102. Assert.Contains("OperatorUserId = null,", block);
  103. Assert.Contains("OperatorName = null,", block);
  104. Assert.DoesNotContain("OperatorUserId = 1", block);
  105. Assert.DoesNotContain("EmployeeId", block);
  106. }
  107. /// <summary>
  108. /// 幂等:同一恢复周期只写一条;调度器重跑不重复追加;
  109. /// 但复发后<b>真实的新恢复周期</b>要能再写一条。
  110. ///
  111. /// <para>判据用既有列表达:不存在 <c>created_at</c> 晚于 <c>last_detected_at</c> 的
  112. /// SYSTEM_RECOVERED 行。复发时 <c>RefreshDetectionAsync</c> 会把 last_detected_at
  113. /// 推到旧恢复行之后,于是新周期自然放行 —— 不新增状态字段、不靠"最后一条是什么"
  114. /// (调度器根本不写其它时间线,那种判据会把第二次真实恢复也挡掉)。</para>
  115. /// </summary>
  116. [Fact]
  117. public void Idempotent_PerRecoveryCycle_ButAllowsGenuineRerecovery()
  118. {
  119. var code = Scheduler();
  120. Assert.Contains("t.ActionCode == SystemRecoveredActionCode", code);
  121. Assert.Contains("t.CreatedAt > lastDetectedAt!.Value", code);
  122. Assert.Contains("already_recorded_for_current_cycle", code);
  123. // 复发确实会推进 last_detected_at 并清空 recovered_at —— 幂等判据依赖这一点
  124. Assert.Contains("LastDetectedAt = hit.DetectedAt,", code);
  125. Assert.Contains("RecoveredAt = null,", code);
  126. }
  127. /// <summary>
  128. /// 时间线写失败不得回滚已判定的恢复状态 —— 但必须落日志。
  129. /// 静默吞掉会让"恢复了却没有痕迹"变成无从查起的问题。
  130. /// </summary>
  131. [Fact]
  132. public void TimelineFailure_DoesNotRollbackRecovery()
  133. {
  134. var code = Scheduler();
  135. Assert.Contains("system_recovered_timeline_failed", code);
  136. Assert.Contains("system_recovered_timeline_written", code);
  137. }
  138. /// <summary>
  139. /// 前端把 NULL 操作人显示为「系统」,不是整行不渲染。
  140. ///
  141. /// <para>原实现 <c>v-if="item.operatorName"</c> 在 NULL 时整行消失,
  142. /// 系统恢复那条看起来像"谁都没做过",用户无从判断是自动恢复还是有人动过。</para>
  143. /// </summary>
  144. [Fact]
  145. public void Frontend_ShowsSystemInsteadOfBlankOperator()
  146. {
  147. var page = File.ReadAllText(Path.GetFullPath(Path.Combine(
  148. PluginRoot, "../../../Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue")));
  149. Assert.Contains("function operatorLabel(", page);
  150. Assert.Contains("'系统'", page);
  151. Assert.Contains("operatorLabel(item)", page);
  152. Assert.DoesNotContain("v-if=\"item.operatorName\" class=\"muted\"", page);
  153. }
  154. /// <summary>
  155. /// 租户隔离不受本批影响:恢复处理仍在按租户取候选的循环里,
  156. /// 时间线只按 exceptionId 写,不跨租户。
  157. /// </summary>
  158. [Fact]
  159. public void TenantIsolation_Preserved()
  160. {
  161. var code = Scheduler();
  162. Assert.Contains("x.TenantId == tenantId", code);
  163. var start = code.IndexOf("TryWriteSystemRecoveredTimelineAsync(long exceptionId", StringComparison.Ordinal);
  164. Assert.True(start > 0);
  165. var method = code[start..(start + 1600)];
  166. // 时间线写入只按主键定位,不引入任何工厂维度
  167. Assert.Contains("t.ExceptionId == exceptionId", method);
  168. Assert.DoesNotContain("FactoryId", method);
  169. }
  170. }