Jelajahi Sumber

test(s8): cover timeout evaluator regression

R2-TIMEOUT-EVALUATOR-2 follow-on fixes + minimal regression coverage.

Bug fixes:
- BuildPayload now writes the correct __sourceObjectType / __sourceObjectId
  audit metadata (previously __sourceObjectType was incorrectly populated
  from ObjectIdField). Adds __exceptionTypeCode for completeness.
- Class-summary `&&` removed from XML doc (clears 4 CS1570 warnings).

Refactor:
- Promote nested TimeoutParams to file-level S8TimeoutParams (internal).
- Surface BuildDedupKey as internal static for direct unit testing.
- Add [InternalsVisibleTo("Admin.NET.Test")] on the plugin assembly.

Regression coverage:
- Admin.NET.Test now references the plugin and adds
  S8/S8TimeoutEvaluatorTests.cs covering params parser tolerance,
  invalid JSON contract, dedup_key stability, and column-length budget.
- scripts/s8/r2-timeout-regression.sh drives the run-once debug endpoint
  twice and asserts: G01 OUT_OF_RANGE compat duplicate-skip against id=34,
  exactly one active TIMEOUT exception per dedup_key, last_detected_at
  refresh, and __sourceObjectType/__sourceObjectId metadata presence.

Verified: regression script passes locally; service write path / dedup_key
generation / scheduler dispatch unchanged.
YY968XX 1 bulan lalu
induk
melakukan
479deeb3af

+ 108 - 0
scripts/s8/r2-timeout-regression.sh

@@ -0,0 +1,108 @@
+#!/usr/bin/env bash
+# R2-TIMEOUT-EVALUATOR-1/2 regression script (dev/test only, aidopdev).
+#
+# Drives the safe debug endpoint /api/aidop/s8/watch-debug/run-once twice and
+# verifies:
+#   1. only ONE active (status != CLOSED) exception per dedup_key;
+#   2. last_detected_at refreshed between runs;
+#   3. source_payload contains corrected __sourceObjectId metadata;
+#   4. G01_TEST_WATCH OUT_OF_RANGE compat path keeps producing duplicate skip
+#      against id=34 (existing IN_PROGRESS exception).
+#
+# Requires:
+#   - mysql client on PATH;
+#   - python3 for JSON parsing;
+#   - aidopdev backend running on http://localhost:5005;
+#   - WatchScheduler:DebugEndpointEnabled=true (dev/test default);
+#   - Web/tests/e2e/.auth/storage-state.json populated (run Playwright once).
+
+set -euo pipefail
+
+PROJECT_DIR="${PROJECT_DIR:-/home/yy968/work/New9S/AiDOPWarehouse}"
+STORAGE_STATE="${PROJECT_DIR}/Web/tests/e2e/.auth/storage-state.json"
+BACKEND_BASE="${BACKEND_BASE:-http://localhost:5005}"
+TENANT_ID="${TENANT_ID:-1}"
+FACTORY_ID="${FACTORY_ID:-1}"
+DB_HOST="${DB_HOST:-123.60.180.165}"
+DB_PORT="${DB_PORT:-3306}"
+DB_NAME="${DB_NAME:-aidopdev}"
+DB_USER="${DB_USER:-aidopremote}"
+DB_PASS="${DB_PASS:-1234567890aiDOP#}"
+RULE_CODE="${RULE_CODE:-G01_TEST_TIMEOUT}"
+
+fail() { echo "FAIL: $*" >&2; exit 1; }
+ok()   { echo "OK: $*"; }
+
+[[ "${DB_NAME}" == "aidopdev" ]] || fail "DB_NAME must be aidopdev, got ${DB_NAME}"
+[[ -f "${STORAGE_STATE}" ]] || fail "storage state not found: ${STORAGE_STATE}"
+
+AT="$(python3 -c "
+import json,sys
+d=json.load(open(sys.argv[1]))
+for kv in d['origins'][0]['localStorage']:
+    if kv['name']=='admin.net:access-token':
+        print(kv['value'].strip('\"')); break
+" "${STORAGE_STATE}")"
+XAT="$(python3 -c "
+import json,sys
+d=json.load(open(sys.argv[1]))
+for kv in d['origins'][0]['localStorage']:
+    if kv['name']=='admin.net:x-access-token':
+        print(kv['value'].strip('\"')); break
+" "${STORAGE_STATE}")"
+[[ -n "${AT}" && "${AT}" != "null" ]] || fail "access-token missing in storage-state"
+[[ -n "${XAT}" && "${XAT}" != "null" ]] || fail "x-access-token missing in storage-state"
+
+mysql_query() {
+  MYSQL_PWD="${DB_PASS}" mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" "${DB_NAME}" \
+    --default-character-set=utf8mb4 --connect-timeout=8 -N -B -e "$1" 2>/dev/null
+}
+
+run_once() {
+  curl -fsS --max-time 15 -X POST \
+    -H "Authorization: Bearer ${AT}" -H "X-Access-Token: ${XAT}" \
+    "${BACKEND_BASE}/api/aidop/s8/watch-debug/run-once?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}"
+}
+
+precheck=$(mysql_query "SELECT COUNT(*) FROM ado_s8_watch_rule WHERE rule_code='${RULE_CODE}' AND enabled=1;")
+[[ "${precheck}" == "1" ]] || fail "${RULE_CODE} not present or disabled (got '${precheck}')"
+ok "${RULE_CODE} enabled"
+
+resp1="$(run_once)"
+count1=$(printf '%s' "${resp1}" | python3 -c "import json,sys;print(json.load(sys.stdin).get('count',0))")
+[[ "${count1}" -ge 1 ]] || fail "first run-once did not return any results"
+ok "first run-once HTTP 200, count=${count1}"
+
+# OUT_OF_RANGE compat: G01_TEST_WATCH (sourceRuleId=1) must remain duplicate-skip against id=34.
+g01ok=$(printf '%s' "${resp1}" | python3 -c "
+import json,sys
+d=json.load(sys.stdin)
+hit=any(r for r in d.get('results',[]) if str(r.get('sourceRuleId'))=='1' and r.get('skipped') and str(r.get('matchedExceptionId'))=='34')
+print('YES' if hit else 'NO')
+")
+[[ "${g01ok}" == "YES" ]] || fail "OUT_OF_RANGE G01 regression: expected skipped duplicate for sourceRuleId=1 against id=34"
+ok "OUT_OF_RANGE G01 compat path: skipped duplicate id=34"
+
+before_ts=$(mysql_query "SELECT IFNULL(MAX(last_detected_at), '1970-01-01') FROM ado_s8_exception WHERE source_rule_code='${RULE_CODE}' AND status<>'CLOSED' AND is_deleted=0;")
+ok "pre-second-run last_detected_at=${before_ts}"
+
+sleep 1
+resp2="$(run_once)"
+count2=$(printf '%s' "${resp2}" | python3 -c "import json,sys;print(json.load(sys.stdin).get('count',0))")
+ok "second run-once HTTP 200, count=${count2}"
+
+active_count=$(mysql_query "SELECT COUNT(*) FROM ado_s8_exception WHERE source_rule_code='${RULE_CODE}' AND status<>'CLOSED' AND is_deleted=0;")
+[[ "${active_count}" == "1" ]] || fail "expected exactly 1 active TIMEOUT exception, got ${active_count}"
+ok "active TIMEOUT exception count = 1 (no duplicate creation)"
+
+after_ts=$(mysql_query "SELECT MAX(last_detected_at) FROM ado_s8_exception WHERE source_rule_code='${RULE_CODE}' AND status<>'CLOSED' AND is_deleted=0;")
+[[ "${after_ts}" > "${before_ts}" ]] || fail "last_detected_at did not refresh (before=${before_ts}, after=${after_ts})"
+ok "last_detected_at refreshed: ${before_ts} -> ${after_ts}"
+
+payload=$(mysql_query "SELECT source_payload FROM ado_s8_exception WHERE source_rule_code='${RULE_CODE}' AND status<>'CLOSED' AND is_deleted=0 ORDER BY id DESC LIMIT 1;")
+echo "${payload}" | grep -q '__sourceObjectId' || fail "source_payload missing __sourceObjectId metadata (R2-2 fix)"
+echo "${payload}" | grep -q '__sourceObjectType' || fail "source_payload missing __sourceObjectType metadata (R2-2 fix)"
+echo "${payload}" | grep -q '__ruleType' || fail "source_payload missing __ruleType metadata"
+ok "source_payload metadata fields present (__ruleType / __sourceObjectType / __sourceObjectId)"
+
+ok "R2-TIMEOUT regression PASSED"

+ 1 - 0
server/Admin.NET.Test/Admin.NET.Test.csproj

@@ -22,5 +22,6 @@
 
   <ItemGroup>
     <ProjectReference Include="..\Admin.NET.Core\Admin.NET.Core.csproj" />
+    <ProjectReference Include="..\Plugins\Admin.NET.Plugin.AiDOP\Admin.NET.Plugin.AiDOP.csproj" />
   </ItemGroup>
 </Project>

+ 93 - 0
server/Admin.NET.Test/S8/S8TimeoutEvaluatorTests.cs

@@ -0,0 +1,93 @@
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using System.Text.Json;
+using Xunit;
+
+namespace Admin.NET.Test.S8;
+
+/// <summary>
+/// R2-TIMEOUT-EVALUATOR 纯逻辑回归。覆盖 params_json 解析 + dedup_key 生成稳定性。
+/// 不接 DB / 不接 DI;evaluator 的 SQL 与 SqlSugarRepository 路径走 run-once 端到端验证。
+/// </summary>
+public class S8TimeoutEvaluatorTests
+{
+    [Fact]
+    public void TimeoutParams_Parses_FullSpec()
+    {
+        var json = """
+        {
+          "dueAtField": "due_at",
+          "statusField": "status",
+          "completedStates": ["CLOSED", "DONE", "COMPLETED"],
+          "objectCodeField": "related_object_code",
+          "objectIdField": "source_object_id",
+          "graceMinutes": 5,
+          "exceptionTypeCode": "DELIVERY_DELAY"
+        }
+        """;
+        var p = S8TimeoutParams.Parse(json);
+        Assert.Equal("due_at", p.DueAtField);
+        Assert.Equal("status", p.StatusField);
+        Assert.Equal(new[] { "CLOSED", "DONE", "COMPLETED" }, p.CompletedStates);
+        Assert.Equal("related_object_code", p.ObjectCodeField);
+        Assert.Equal("source_object_id", p.ObjectIdField);
+        Assert.Equal(5, p.GraceMinutes);
+        Assert.Equal("DELIVERY_DELAY", p.ExceptionTypeCode);
+    }
+
+    [Fact]
+    public void TimeoutParams_Tolerates_MissingOptionalFields()
+    {
+        var json = """{ "dueAtField": "due_at", "statusField": "status", "exceptionTypeCode": "PENDING_SHIPMENT" }""";
+        var p = S8TimeoutParams.Parse(json);
+        Assert.Equal("due_at", p.DueAtField);
+        Assert.Equal("status", p.StatusField);
+        Assert.Empty(p.CompletedStates);
+        Assert.Null(p.ObjectCodeField);
+        Assert.Null(p.ObjectIdField);
+        Assert.Equal(0, p.GraceMinutes);
+        Assert.Equal("PENDING_SHIPMENT", p.ExceptionTypeCode);
+    }
+
+    [Fact]
+    public void TimeoutParams_Skips_BlankCompletedStates()
+    {
+        var json = """{ "completedStates": ["CLOSED", "  ", "", "DONE"] }""";
+        var p = S8TimeoutParams.Parse(json);
+        Assert.Equal(new[] { "CLOSED", "DONE" }, p.CompletedStates);
+    }
+
+    [Fact]
+    public void TimeoutParams_Throws_OnInvalidJson()
+    {
+        // evaluator 在 EvaluateAsync 内 try/catch 该异常并降级为空命中;本断言锁定异常类型契约。
+        Assert.Throws<JsonException>(() => S8TimeoutParams.Parse("not-a-json"));
+    }
+
+    [Fact]
+    public void BuildDedupKey_IsStable_SameInputsSameOutput()
+    {
+        var k1 = S8TimeoutRuleEvaluator.BuildDedupKey(1, 1, "G01_TEST_TIMEOUT", "ORDER", "ORDER-TIMEOUT-01");
+        var k2 = S8TimeoutRuleEvaluator.BuildDedupKey(1, 1, "G01_TEST_TIMEOUT", "ORDER", "ORDER-TIMEOUT-01");
+        Assert.Equal(k1, k2);
+        Assert.Equal("T1:F1:RG01_TEST_TIMEOUT:ORDER:ORDER-TIMEOUT-01", k1);
+    }
+
+    [Fact]
+    public void BuildDedupKey_Differentiates_OnTenantFactoryRuleObject()
+    {
+        var baseKey = S8TimeoutRuleEvaluator.BuildDedupKey(1, 1, "R", "ORDER", "ID");
+        Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(2, 1, "R", "ORDER", "ID"));
+        Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(1, 2, "R", "ORDER", "ID"));
+        Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(1, 1, "R2", "ORDER", "ID"));
+        Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(1, 1, "R", "TASK", "ID"));
+        Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(1, 1, "R", "ORDER", "ID2"));
+    }
+
+    [Fact]
+    public void BuildDedupKey_Length_FitsVarchar128()
+    {
+        // 实体定义 dedup_key VARCHAR(128)。本断言锁定常规标识符不溢出列长度。
+        var k = S8TimeoutRuleEvaluator.BuildDedupKey(9999999, 9999999, "VERY_LONG_RULE_CODE_FOR_PADDING_64____", "EXTREMELY_LONG_OBJECT_TYPE______", "EXTREMELY_LONG_OBJECT_ID________");
+        Assert.True(k.Length <= 128, $"dedup_key length {k.Length} exceeds 128: {k}");
+    }
+}

+ 3 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/InternalsVisible.cs

@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Admin.NET.Test")]

+ 41 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutParams.cs

@@ -0,0 +1,41 @@
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+
+/// <summary>
+/// R2 TIMEOUT 类规则 params_json 解析结果。internal 可见,供测试覆盖。
+/// 字段缺失或非法 JSON:调用方负责降级(evaluator 静默返空命中),不在此抛出业务异常。
+/// </summary>
+internal sealed class S8TimeoutParams
+{
+    public string? DueAtField { get; set; }
+    public string? StatusField { get; set; }
+    public List<string> CompletedStates { get; } = new();
+    public string? ObjectCodeField { get; set; }
+    public string? ObjectIdField { get; set; }
+    public int GraceMinutes { get; set; }
+    public string? ExceptionTypeCode { get; set; }
+
+    /// <summary>解析 params_json;非法 JSON 抛 <see cref="JsonException"/>,由上游捕获。</summary>
+    public static S8TimeoutParams Parse(string json)
+    {
+        using var doc = JsonDocument.Parse(json);
+        var root = doc.RootElement;
+        var p = new S8TimeoutParams();
+        if (root.TryGetProperty("dueAtField", out var v1)) p.DueAtField = v1.GetString();
+        if (root.TryGetProperty("statusField", out var v2)) p.StatusField = v2.GetString();
+        if (root.TryGetProperty("objectCodeField", out var v3)) p.ObjectCodeField = v3.GetString();
+        if (root.TryGetProperty("objectIdField", out var v4)) p.ObjectIdField = v4.GetString();
+        if (root.TryGetProperty("graceMinutes", out var v5) && v5.ValueKind == JsonValueKind.Number) p.GraceMinutes = v5.GetInt32();
+        if (root.TryGetProperty("exceptionTypeCode", out var v6)) p.ExceptionTypeCode = v6.GetString();
+        if (root.TryGetProperty("completedStates", out var v7) && v7.ValueKind == JsonValueKind.Array)
+        {
+            foreach (var s in v7.EnumerateArray())
+            {
+                var str = s.GetString();
+                if (!string.IsNullOrWhiteSpace(str)) p.CompletedStates.Add(str!);
+            }
+        }
+        return p;
+    }
+}

+ 10 - 39
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutRuleEvaluator.cs

@@ -10,7 +10,7 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
 /// R2 TIMEOUT 类规则 evaluator MVP。
 /// params_json 约定(首版最小集合):
 ///   { dueAtField, statusField, completedStates[], objectCodeField, objectIdField, graceMinutes, exceptionTypeCode }
-/// 判定:dueAt &lt;= now - graceMinutes && status NOT IN completedStates → HIT。
+/// 判定:dueAt &lt;= now - graceMinutes 且 status 不在 completedStates 内 → HIT。
 /// 不做严重度阶梯、不做 SLA 升级、不做事件触发。
 /// </summary>
 public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
@@ -39,8 +39,8 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         if (string.IsNullOrWhiteSpace(rule.Expression) || string.IsNullOrWhiteSpace(rule.ParamsJson))
             return hits;
 
-        TimeoutParams parameters;
-        try { parameters = TimeoutParams.Parse(rule.ParamsJson!); }
+        S8TimeoutParams parameters;
+        try { parameters = S8TimeoutParams.Parse(rule.ParamsJson!); }
         catch { return hits; }
 
         if (string.IsNullOrWhiteSpace(parameters.DueAtField)
@@ -108,7 +108,7 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
                 SceneCode = rule.SceneCode,
                 Severity = string.IsNullOrWhiteSpace(rule.Severity) ? "MEDIUM" : rule.Severity,
                 DedupKey = dedupKey,
-                SourcePayload = BuildPayload(row, due.Value, status, parameters),
+                SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, due.Value, status, parameters),
                 DetectedAt = detectedAt,
                 Title = $"[超时] {sourceObjectType} {sourceObjectId} 已超期至 {due.Value:yyyy-MM-dd HH:mm:ss}(状态 {status})",
                 DataSourceId = dataSource.Id,
@@ -133,10 +133,11 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         });
     }
 
-    private static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
+    /// <summary>构造 R2 dedup_key 稳定字符串:T{tenant}:F{factory}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。internal 暴露供测试。</summary>
+    internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
         $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
 
-    private static string BuildPayload(DataRow row, DateTime dueAt, string status, TimeoutParams parameters)
+    private static string BuildPayload(DataRow row, string sourceObjectType, string sourceObjectId, DateTime dueAt, string status, S8TimeoutParams parameters)
     {
         var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
         foreach (DataColumn column in row.Table.Columns)
@@ -145,10 +146,12 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
             payload[column.ColumnName] = value == DBNull.Value ? null : value;
         }
         payload["__ruleType"] = RuleTypeCode;
+        payload["__sourceObjectType"] = sourceObjectType;
+        payload["__sourceObjectId"] = sourceObjectId;
         payload["__dueAt"] = dueAt;
         payload["__status"] = status;
         payload["__graceMinutes"] = parameters.GraceMinutes;
-        payload["__sourceObjectType"] = parameters.ObjectIdField;
+        payload["__exceptionTypeCode"] = parameters.ExceptionTypeCode;
         return JsonSerializer.Serialize(payload);
     }
 
@@ -177,36 +180,4 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         return long.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), out var r) ? r : null;
     }
 
-    private sealed class TimeoutParams
-    {
-        public string? DueAtField { get; set; }
-        public string? StatusField { get; set; }
-        public List<string> CompletedStates { get; set; } = new();
-        public string? ObjectCodeField { get; set; }
-        public string? ObjectIdField { get; set; }
-        public int GraceMinutes { get; set; }
-        public string? ExceptionTypeCode { get; set; }
-
-        public static TimeoutParams Parse(string json)
-        {
-            using var doc = JsonDocument.Parse(json);
-            var root = doc.RootElement;
-            var p = new TimeoutParams();
-            if (root.TryGetProperty("dueAtField", out var v1)) p.DueAtField = v1.GetString();
-            if (root.TryGetProperty("statusField", out var v2)) p.StatusField = v2.GetString();
-            if (root.TryGetProperty("objectCodeField", out var v3)) p.ObjectCodeField = v3.GetString();
-            if (root.TryGetProperty("objectIdField", out var v4)) p.ObjectIdField = v4.GetString();
-            if (root.TryGetProperty("graceMinutes", out var v5) && v5.ValueKind == JsonValueKind.Number) p.GraceMinutes = v5.GetInt32();
-            if (root.TryGetProperty("exceptionTypeCode", out var v6)) p.ExceptionTypeCode = v6.GetString();
-            if (root.TryGetProperty("completedStates", out var v7) && v7.ValueKind == JsonValueKind.Array)
-            {
-                foreach (var s in v7.EnumerateArray())
-                {
-                    var str = s.GetString();
-                    if (!string.IsNullOrWhiteSpace(str)) p.CompletedStates.Add(str!);
-                }
-            }
-            return p;
-        }
-    }
 }