Просмотр исходного кода

fix(s8): backfill active exception id on first trigger hit

YY968XX 2 месяцев назад
Родитель
Сommit
dd59da9b33

+ 122 - 0
scripts/s8/active-exception-id-trigger1-regression.sh

@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+# trigger=1 active_exception_id 回归 — BUG-S8-DETECTION-STATE-ACTIVE-EXC-ID-TRIGGER1-001(dev/test only, aidopdev)。
+#
+# 验证 trigger_count_required=1 首 tick 建单后:
+#   - exception 创建成功
+#   - rule_detection_state.active_exception_id IS NOT NULL
+#   - rule_detection_state.active_exception_id == 新建 exception.id
+#   - DetectionLog CREATED.exception_id == 新建 exception.id
+#
+# 修复前:fresh.Id 在 InsertAsync 后未回填,外层 UPDATE WHERE id=state.Id 命中 0 行 → active_exception_id 永远 NULL。
+# 修复后:fresh = await _detectionStateRep.AsInsertable(fresh).ExecuteReturnEntityAsync(),沿用
+#         S8ManualReportService 既定模式回填 Id,UPDATE WHERE id=state.Id 命中 1 行。
+#
+# 严守:
+#   - 不影响 demo rule 10/11/12
+#   - TEMP rule 测后 enabled=0;TEMP exception soft delete
+#   - 不清 detection_log / rule_detection_state / 演示数据
+
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=./s8-regression-common.sh
+source "${SCRIPT_DIR}/s8-regression-common.sh"
+
+auth_load
+
+RULE='TEMP_SCHED_TIMEOUT_TRIGGER1_ACTIVE_ID'
+DEDUP_OBJ='TEMP-TRIGGER1-ACTIVE-ID-001'
+
+PARAMS_JSON='{"dueAtField":"due_at","statusField":"status","completedStates":["CLOSED","DONE","COMPLETED"],"objectCodeField":"related_object_code","objectIdField":"source_object_id","graceMinutes":0,"exceptionTypeCode":"DELIVERY_DELAY"}'
+HIT_EXPR="SELECT '${DEDUP_OBJ}' AS related_object_code, '${DEDUP_OBJ}' AS source_object_id, DATE_SUB(NOW(), INTERVAL 1 HOUR) AS due_at, 'PENDING' AS status"
+
+baseline_before=$(read_baseline)
+echo "==== active-exception-id-trigger1-regression  baseline_before=${baseline_before} ===="
+
+# ---------------------------------------------------------------------------
+# Setup TEMP rule (trigger=1, recover=1, hit expression)
+# ---------------------------------------------------------------------------
+echo "---- Setup TEMP rule ${RULE} (trigger=1, recover=1) ----"
+exists=$(mysql_run "SELECT COUNT(*) FROM ado_s8_watch_rule WHERE rule_code='${RULE}' AND tenant_id=${TENANT_ID} AND factory_id=${FACTORY_ID};")
+if [[ "${exists}" == "0" ]]; then
+  mysql_run_strict "INSERT INTO ado_s8_watch_rule
+    (tenant_id, factory_id, rule_code, scene_code, data_source_id, watch_object_type, expression, severity,
+     poll_interval_seconds, enabled, created_at, rule_type, source_object_type, params_json,
+     consecutive_failure_count, trigger_count_required, recover_count_required, next_run_at)
+    VALUES (${TENANT_ID}, ${FACTORY_ID}, '${RULE}', 'S1S7_DELIVERY', 1, 'ORDER',
+     \"${HIT_EXPR}\", 'HIGH', 60, 1, NOW(), 'TIMEOUT', 'ORDER', '${PARAMS_JSON}',
+     0, 1, 1, NULL);" >/dev/null
+  echo "  inserted ${RULE}"
+else
+  mysql_run_strict "UPDATE ado_s8_watch_rule SET enabled=1, expression=\"${HIT_EXPR}\", params_json='${PARAMS_JSON}',
+                   trigger_count_required=1, recover_count_required=1,
+                   paused_until=NULL, pause_reason=NULL,
+                   lock_token=NULL, locked_by=NULL, lock_until=NULL, running_started_at=NULL,
+                   consecutive_failure_count=0, last_status=NULL, last_error=NULL,
+                   next_run_at=NULL, updated_at=NOW()
+                 WHERE rule_code='${RULE}' AND tenant_id=${TENANT_ID} AND factory_id=${FACTORY_ID};" >/dev/null
+  echo "  re-armed ${RULE}"
+fi
+RULE_ID=$(get_rule_id_by_code "${RULE}")
+DEDUP_KEY="T${TENANT_ID}:F${FACTORY_ID}:R${RULE}:ORDER:${DEDUP_OBJ}"
+echo "  RULE_ID=${RULE_ID}  DEDUP_KEY=${DEDUP_KEY}"
+
+# 清理上次残留 detection_state / TEMP exception,确保从全新状态起跑
+mysql_run_strict "DELETE FROM ado_s8_rule_detection_state WHERE rule_code='${RULE}' AND tenant_id=${TENANT_ID} AND factory_id=${FACTORY_ID};" >/dev/null
+mysql_run_strict "UPDATE ado_s8_exception SET is_deleted=1, updated_at=NOW() WHERE source_rule_code='${RULE}' AND is_deleted=0;" >/dev/null
+
+marker=$(mysql_run "SELECT NOW();")
+sleep 1
+
+# ---------------------------------------------------------------------------
+# Trigger first tick via /run-once(trigger=1:本 tick 即应建单 + 回填 active_exception_id)
+# ---------------------------------------------------------------------------
+echo "---- Trigger first tick via run-once ----"
+resp=$(run_once_endpoint || true)
+[[ -z "${resp}" ]] && record_fail "run-once returned empty"
+sleep 2
+
+# ---------------------------------------------------------------------------
+# 验证
+# ---------------------------------------------------------------------------
+echo "---- Verify ----"
+exc_count=$(mysql_run "SELECT COUNT(*) FROM ado_s8_exception WHERE source_rule_code='${RULE}' AND is_deleted=0 AND status<>'CLOSED';")
+exc_id=$(mysql_run "SELECT IFNULL(MAX(id),0) FROM ado_s8_exception WHERE source_rule_code='${RULE}' AND is_deleted=0 AND status<>'CLOSED';")
+state_active=$(mysql_run "SELECT IFNULL(active_exception_id,0) FROM ado_s8_rule_detection_state WHERE rule_code='${RULE}' AND dedup_key='${DEDUP_KEY}';")
+state_hit=$(mysql_run "SELECT IFNULL(consecutive_hit_count,-1) FROM ado_s8_rule_detection_state WHERE rule_code='${RULE}' AND dedup_key='${DEDUP_KEY}';")
+created_logs=$(mysql_run "SELECT COUNT(*) FROM ado_s8_detection_log WHERE rule_code='${RULE}' AND detect_result='CREATED' AND detected_at >= '${marker}';")
+created_log_excid=$(mysql_run "SELECT IFNULL(exception_id,0) FROM ado_s8_detection_log WHERE rule_code='${RULE}' AND detect_result='CREATED' AND detected_at >= '${marker}' ORDER BY id DESC LIMIT 1;")
+echo "  exc_count=${exc_count} exc_id=${exc_id} state_active=${state_active} state_hit=${state_hit} CREATED_logs=${created_logs} log.exception_id=${created_log_excid}"
+
+[[ "${exc_count}" == "1" ]] && record_pass "1 active exception created" || record_fail "expected 1 active exception, got ${exc_count}"
+[[ "${exc_id}" -gt 0 ]] && record_pass "exception.id=${exc_id} non-zero" || record_fail "exception id missing"
+[[ "${state_hit}" == "1" ]] && record_pass "detection_state.consecutive_hit_count=1" || record_fail "expected state.hit_count=1, got ${state_hit}"
+[[ "${state_active}" -gt 0 ]] && record_pass "state.active_exception_id=${state_active} (NOT NULL)" || record_fail "state.active_exception_id NULL — BUG NOT FIXED"
+[[ "${state_active}" == "${exc_id}" ]] && record_pass "state.active_exception_id == exception.id (${exc_id})" || record_fail "state.active_exception_id=${state_active} != exception.id=${exc_id}"
+[[ "${created_logs}" -ge 1 ]] && record_pass "CREATED detection_log present (${created_logs})" || record_fail "CREATED log missing"
+[[ "${created_log_excid}" == "${exc_id}" ]] && record_pass "CREATED log.exception_id == exception.id (${exc_id})" || record_fail "CREATED log.exception_id=${created_log_excid} != exception.id=${exc_id}"
+
+# ---------------------------------------------------------------------------
+# Cleanup
+# ---------------------------------------------------------------------------
+echo "---- Cleanup ----"
+mysql_run_strict "UPDATE ado_s8_watch_rule SET enabled=0, paused_until=NULL, pause_reason=NULL, lock_token=NULL, locked_by=NULL, lock_until=NULL, running_started_at=NULL, next_run_at=NULL, updated_at=NOW() WHERE id=${RULE_ID};" >/dev/null
+TEMP_EXC_IDS=$(mysql_run "SELECT IFNULL(GROUP_CONCAT(id),'none') FROM ado_s8_exception WHERE source_rule_code='${RULE}' AND is_deleted=0;")
+mysql_run_strict "UPDATE ado_s8_exception SET is_deleted=1, updated_at=NOW() WHERE source_rule_code='${RULE}' AND is_deleted=0;" >/dev/null
+echo "  disabled rule_id=${RULE_ID}; soft-deleted exception ids=[${TEMP_EXC_IDS}]"
+
+final_enabled=$(get_rule_field "${RULE_ID}" enabled)
+[[ "${final_enabled}" == "0" ]] && record_pass "cleanup: rule enabled=0" || record_fail "cleanup: rule still enabled=${final_enabled}"
+temp_visible=$(mysql_run "SELECT COUNT(*) FROM ado_s8_exception WHERE source_rule_code='${RULE}' AND is_deleted=0;")
+[[ "${temp_visible}" == "0" ]] && record_pass "cleanup: TEMP exception not in default list" || record_fail "cleanup: ${temp_visible} TEMP exception(s) still visible"
+
+# demo rule + baseline 守恒
+demo_state=$(mysql_run "SELECT GROUP_CONCAT(CONCAT(id,':',enabled,':',IFNULL(paused_until,'NULL'),':',trigger_count_required,':',recover_count_required) ORDER BY id) FROM ado_s8_watch_rule WHERE id IN (10,11,12);")
+expected="10:1:NULL:1:1,11:1:NULL:1:1,12:1:NULL:1:1"
+[[ "${demo_state}" == "${expected}" ]] && record_pass "demo rule 10/11/12 conserved" || record_fail "demo rule drift: ${demo_state}"
+
+assert_baseline_unchanged "${baseline_before}"
+
+print_summary
+echo "TEMP rule: ${RULE} (id=${RULE_ID}); TEMP exception ids=[${TEMP_EXC_IDS}]; final state.active_exception_id=${state_active}, exc_id=${exc_id}"
+exit_by_summary

+ 2 - 0
scripts/s8/rule-evaluator-regression.sh

@@ -8,6 +8,7 @@
 #   4. r3-out-of-range-regression.sh   — OUT_OF_RANGE 主链(默认复用 demo rule 11)
 #   5. r6-detection-log-edge-regression.sh — NO_HIT/EVALUATE_FAILED edge
 #   6. recovered-edge-regression.sh    — RECOVERED 主链 + recover_count_required 抗抖
+#   7. active-exception-id-trigger1-regression.sh — trigger=1 首 tick state.active_exception_id 回填
 #
 # 退出码:
 #   - 仅 FAIL>0 时 exit 1;
@@ -75,6 +76,7 @@ run_child "r3-shortage"  "${SCRIPT_DIR}/r3-shortage-regression.sh"
 run_child "r3-oor"       "${SCRIPT_DIR}/r3-out-of-range-regression.sh"
 run_child "r6-edge"      "${SCRIPT_DIR}/r6-detection-log-edge-regression.sh"
 run_child "recovered"    "${SCRIPT_DIR}/recovered-edge-regression.sh"
+run_child "trigger1-active" "${SCRIPT_DIR}/active-exception-id-trigger1-regression.sh"
 
 baseline_after=$(read_baseline)
 

+ 4 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -701,7 +701,10 @@ public class S8WatchSchedulerService : ITransient
                 CreatedAt = now,
                 UpdatedAt = now
             };
-            await _detectionStateRep.InsertAsync(fresh);
+            // BUG-S8-DETECTION-STATE-ACTIVE-EXC-ID-TRIGGER1-001:必须回填 fresh.Id,否则
+            // trigger=1 首 tick 建单后 UPDATE state SET active_exception_id WHERE id=state.Id
+            // 命中 0 行(state.Id 为默认 0)。沿用 S8ManualReportService 既定模式。
+            fresh = await _detectionStateRep.AsInsertable(fresh).ExecuteReturnEntityAsync();
             return (fresh, 1);
         }