s8-regression-common.sh 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. #!/usr/bin/env bash
  2. # S8-REGRESSION-FIXTURE-1 通用回归工具(库脚本,被 source;不直接执行)。
  3. #
  4. # 提供:
  5. # - DB 连接 / token 解析
  6. # - mysql_run "$sql" → 返回 STDOUT,错误压制到 stderr
  7. # - record_pass / record_fail / record_skip / print_summary
  8. # - read_baseline / assert_baseline_unchanged
  9. # - require_demo_rule "<rule_code>" → 0=存在且 enabled=1,1=不存在/禁用
  10. # - get_rule_field <id> <column>
  11. # - run_once_endpoint
  12. # - snapshot_demo_rule_state / assert_demo_rule_state_unchanged
  13. # 回归"漂移检测"模型:捕获初态 → 末态比对,不再硬编码 enabled=1。
  14. # - arm_demo_rules_for_regression / restore_demo_rules_disabled_after_regression
  15. # 配合 CTO 决策"demo rule 默认关闭":driver 入场前 arm,收尾恢复 disabled。
  16. # - cleanup_temp_sched_approval_ghost_tasks
  17. # teardown 后清除 TEMP_SCHED_% 软删异常遗留的 Pending ApprovalFlowTask。
  18. #
  19. # 设计约束(CTO 约束 二/四/九):
  20. # - 不硬编码 baseline 数值;当前 dev=3 但脚本由 read_baseline 实测。
  21. # - 不强制 G01_TEST_*;旧 fixture 缺失 → 调用方记 SKIP。
  22. # - 不修改 demo rule 10/11/12 的最终态:driver 自责 arm/restore;child 走 snapshot 漂移检测。
  23. set -uo pipefail
  24. PROJECT_DIR="${PROJECT_DIR:-/home/yy968/work/New9S/AiDOPWarehouse}"
  25. STORAGE_STATE="${STORAGE_STATE:-${PROJECT_DIR}/Web/tests/e2e/.auth/storage-state.json}"
  26. BACKEND_BASE="${BACKEND_BASE:-http://localhost:5005}"
  27. TENANT_ID="${TENANT_ID:-1}"
  28. FACTORY_ID="${FACTORY_ID:-1}"
  29. DB_HOST="${DB_HOST:-123.60.180.165}"
  30. DB_PORT="${DB_PORT:-3306}"
  31. DB_NAME="${DB_NAME:-aidopdev}"
  32. DB_USER="${DB_USER:-aidopremote}"
  33. DB_PASS="${DB_PASS:-1234567890aiDOP#}"
  34. S8REG_PASS_COUNT=0
  35. S8REG_FAIL_COUNT=0
  36. S8REG_SKIP_COUNT=0
  37. S8REG_NOTES=()
  38. s8reg_die() { echo "FATAL: $*" >&2; exit 2; }
  39. [[ "${DB_NAME}" == "aidopdev" ]] || s8reg_die "DB_NAME must be aidopdev, got ${DB_NAME}"
  40. mysql_run() {
  41. MYSQL_PWD="${DB_PASS}" mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" "${DB_NAME}" \
  42. --default-character-set=utf8mb4 --connect-timeout=8 -N -B -e "$1" 2>/dev/null
  43. }
  44. mysql_run_strict() {
  45. # 与 mysql_run 同,但失败立即 exit;用于必须成功的写入。
  46. MYSQL_PWD="${DB_PASS}" mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" "${DB_NAME}" \
  47. --default-character-set=utf8mb4 --connect-timeout=8 -N -B -e "$1" || \
  48. s8reg_die "mysql_run_strict failed: $1"
  49. }
  50. record_pass() { S8REG_PASS_COUNT=$((S8REG_PASS_COUNT + 1)); echo "PASS: $*"; S8REG_NOTES+=("PASS: $*"); }
  51. record_fail() { S8REG_FAIL_COUNT=$((S8REG_FAIL_COUNT + 1)); echo "FAIL: $*" >&2; S8REG_NOTES+=("FAIL: $*"); }
  52. record_skip() { S8REG_SKIP_COUNT=$((S8REG_SKIP_COUNT + 1)); echo "SKIP: $*"; S8REG_NOTES+=("SKIP: $*"); }
  53. print_summary() {
  54. echo "==== summary ===="
  55. echo "passed=${S8REG_PASS_COUNT}"
  56. echo "failed=${S8REG_FAIL_COUNT}"
  57. echo "skipped=${S8REG_SKIP_COUNT}"
  58. }
  59. read_baseline() {
  60. mysql_run "SELECT COUNT(*) FROM ado_s8_exception_type WHERE tenant_id=0 AND factory_id=0 AND enabled=1;"
  61. }
  62. # 用法: assert_baseline_unchanged "$before"
  63. assert_baseline_unchanged() {
  64. local before="$1"
  65. local after
  66. after=$(read_baseline)
  67. if [[ "${before}" == "${after}" ]]; then
  68. record_pass "baseline unchanged (before=after=${before})"
  69. return 0
  70. else
  71. record_fail "baseline drifted: before=${before} after=${after}"
  72. return 1
  73. fi
  74. }
  75. # 0 = 存在且 enabled=1;1 = 不存在或被禁用。
  76. require_demo_rule() {
  77. local code="$1"
  78. local cnt
  79. cnt=$(mysql_run "SELECT COUNT(*) FROM ado_s8_watch_rule WHERE rule_code='${code}' AND tenant_id=${TENANT_ID} AND factory_id=${FACTORY_ID} AND enabled=1;")
  80. [[ "${cnt}" == "1" ]]
  81. }
  82. get_rule_field() {
  83. local id="$1"; local col="$2"
  84. mysql_run "SELECT IFNULL(${col}, 'NULL') FROM ado_s8_watch_rule WHERE id=${id};"
  85. }
  86. get_rule_id_by_code() {
  87. local code="$1"
  88. mysql_run "SELECT id FROM ado_s8_watch_rule WHERE rule_code='${code}' AND tenant_id=${TENANT_ID} AND factory_id=${FACTORY_ID};"
  89. }
  90. # ── demo rule 漂移检测(child 用) ────────────────────────────────────────
  91. # 用法:
  92. # DEMO_SNAPSHOT=$(snapshot_demo_rule_state)
  93. # ...
  94. # assert_demo_rule_state_unchanged "${DEMO_SNAPSHOT}"
  95. # 不依赖 enabled=1 假设;driver arm 后 child 看到 enabled=1,单跑 child 看到 enabled=0,
  96. # 两种场景都只检查"未漂移"。
  97. snapshot_demo_rule_state() {
  98. 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);"
  99. }
  100. assert_demo_rule_state_unchanged() {
  101. local before="$1"
  102. local after
  103. after=$(snapshot_demo_rule_state)
  104. if [[ "${before}" == "${after}" ]]; then
  105. record_pass "demo rule 10/11/12 conserved (drift-free, snapshot=${before})"
  106. return 0
  107. else
  108. record_fail "demo rule 10/11/12 drifted: before=${before} after=${after}"
  109. return 1
  110. fi
  111. }
  112. # ── demo rule arm/restore(driver 用) ───────────────────────────────────
  113. # CTO 决策(S8-DEMO-RULE-DEFAULT-DISABLED-1):业务 demo 规则默认 enabled=0,
  114. # 由业务人员现场启用。回归 driver 入场前临时 arm,收尾必须恢复 disabled。
  115. # 仅触动 enabled / paused_until / pause_reason / lock 三件套 / trigger / recover;
  116. # 不改 rule_code / scene_code / rule_type / expression / params_json。
  117. arm_demo_rules_for_regression() {
  118. mysql_run "UPDATE ado_s8_watch_rule
  119. SET enabled=1,
  120. paused_until=NULL, pause_reason=NULL,
  121. lock_token=NULL, locked_by=NULL, lock_until=NULL,
  122. trigger_count_required=1, recover_count_required=1,
  123. updated_at=NOW()
  124. WHERE id IN (10,11,12);" >/dev/null
  125. echo "[arm-demo] demo rule 10/11/12 enabled=1 (regression-only; will restore on exit)"
  126. }
  127. restore_demo_rules_disabled_after_regression() {
  128. mysql_run "UPDATE ado_s8_watch_rule
  129. SET enabled=0,
  130. paused_until=NULL, pause_reason=NULL,
  131. lock_token=NULL, locked_by=NULL, lock_until=NULL,
  132. trigger_count_required=1, recover_count_required=1,
  133. updated_at=NOW()
  134. WHERE id IN (10,11,12);" >/dev/null
  135. echo "[restore-demo] demo rule 10/11/12 enabled=0 (CTO policy: default off)"
  136. }
  137. # ── TEMP_SCHED_% 审批幽灵待办清理 ─────────────────────────────────────────
  138. # 当 child fixture 关闭 TEMP rule、软删 TEMP exception 后,对应的
  139. # ApprovalFlowInstance / ApprovalFlowTask 不会被业务级联取消;这些 Pending(0) 任务
  140. # 会污染审批中心(Demo01/Demo02 出现幽灵代办)。
  141. #
  142. # 严格范围:
  143. # 1. 仅命中 ado_s8_watch_rule.rule_code LIKE 'TEMP_SCHED_%' 关联的异常;
  144. # 2. 仅命中 ado_s8_exception.is_deleted=1 的异常(已 fixture teardown 软删);
  145. # 3. 仅命中 ApprovalFlowInstance.BizType ∈ {EXCEPTION_REPORT, EXCEPTION_ESCALATION, EXCEPTION_CLOSURE};
  146. # 4. 仅 Status=0(Pending) 任务改为 Status=4(Cancelled),不物理删除;
  147. # 5. 实例仅在该实例下已无 Pending task 时改 Status=4。
  148. # 不取消 DEMO_/真实业务异常的待办;不动 detection_log;不动 rule_detection_state。
  149. #
  150. # 表关联事实(已读核确认):
  151. # ApprovalFlowInstance.BizId = ado_s8_exception.id
  152. # ado_s8_exception.source_rule_id = ado_s8_watch_rule.id
  153. # ApprovalFlowTask.Status: 0=Pending / 4=Cancelled
  154. # ApprovalFlowInstance.Status: 4=Cancelled
  155. cleanup_temp_sched_approval_ghost_tasks() {
  156. local cancelled_tasks
  157. cancelled_tasks=$(mysql_run "
  158. UPDATE ApprovalFlowTask t
  159. JOIN ApprovalFlowInstance i ON i.Id = t.InstanceId
  160. JOIN ado_s8_exception e ON e.id = i.BizId
  161. JOIN ado_s8_watch_rule r ON r.id = e.source_rule_id
  162. SET t.Status = 4,
  163. t.ActionTime = NOW(),
  164. t.UpdateTime = NOW(),
  165. t.Comment = LEFT(CONCAT(IFNULL(t.Comment,''),'[regression-teardown:cancelled-temp-sched-ghost]'), 1024)
  166. WHERE t.Status = 0
  167. AND r.rule_code LIKE 'TEMP_SCHED_%'
  168. AND e.is_deleted = 1
  169. AND i.BizType IN ('EXCEPTION_REPORT','EXCEPTION_ESCALATION','EXCEPTION_CLOSURE');
  170. SELECT ROW_COUNT();" | tail -n1)
  171. local cancelled_instances
  172. cancelled_instances=$(mysql_run "
  173. UPDATE ApprovalFlowInstance i
  174. JOIN ado_s8_exception e ON e.id = i.BizId
  175. JOIN ado_s8_watch_rule r ON r.id = e.source_rule_id
  176. SET i.Status = 4,
  177. i.EndTime = COALESCE(i.EndTime, NOW()),
  178. i.UpdateTime = NOW()
  179. WHERE i.Status <> 4
  180. AND r.rule_code LIKE 'TEMP_SCHED_%'
  181. AND e.is_deleted = 1
  182. AND i.BizType IN ('EXCEPTION_REPORT','EXCEPTION_ESCALATION','EXCEPTION_CLOSURE')
  183. AND NOT EXISTS (
  184. SELECT 1 FROM ApprovalFlowTask t2
  185. WHERE t2.InstanceId = i.Id AND t2.Status = 0
  186. );
  187. SELECT ROW_COUNT();" | tail -n1)
  188. echo "[cleanup-ghost] cancelled_pending_tasks=${cancelled_tasks:-0} cancelled_instances=${cancelled_instances:-0}"
  189. }
  190. auth_load() {
  191. [[ -f "${STORAGE_STATE}" ]] || s8reg_die "storage state not found: ${STORAGE_STATE}"
  192. AT="$(python3 -c "
  193. import json,sys
  194. d=json.load(open(sys.argv[1]))
  195. for kv in d['origins'][0]['localStorage']:
  196. if kv['name']=='admin.net:access-token':
  197. print(kv['value'].strip('\"')); break
  198. " "${STORAGE_STATE}")"
  199. XAT="$(python3 -c "
  200. import json,sys
  201. d=json.load(open(sys.argv[1]))
  202. for kv in d['origins'][0]['localStorage']:
  203. if kv['name']=='admin.net:x-access-token':
  204. print(kv['value'].strip('\"')); break
  205. " "${STORAGE_STATE}")"
  206. [[ -n "${AT}" && "${AT}" != "null" ]] || s8reg_die "access-token missing in storage-state"
  207. [[ -n "${XAT}" && "${XAT}" != "null" ]] || s8reg_die "x-access-token missing in storage-state"
  208. export AT XAT
  209. }
  210. run_once_endpoint() {
  211. curl -fsS --max-time 30 -X POST \
  212. -H "Authorization: Bearer ${AT}" -H "X-Access-Token: ${XAT}" \
  213. "${BACKEND_BASE}/api/aidop/s8/watch-debug/run-once?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}"
  214. }
  215. # 子脚本退出码语义:
  216. # 0 = 至少一项 PASS 或纯 SKIP(FAIL=0)
  217. # 1 = 至少一项 FAIL
  218. # 2 = 致命错误(s8reg_die)
  219. exit_by_summary() {
  220. if (( S8REG_FAIL_COUNT > 0 )); then
  221. return 1
  222. fi
  223. return 0
  224. }