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

chore: remove obsolete S8 journal notes

YY968XX 1 месяц назад
Родитель
Сommit
4dbafc3d83

+ 0 - 184
lwb/journals/2026-04-28-S8-sched-exec.md

@@ -1,184 +0,0 @@
-# S8-SCHED-EXEC-1 执行记录
-
-> 日期:2026-04-28
-> 范围:S8 自动监控调度执行层(DB 驱动派发 + lease 抢占 + 抗抖触发/恢复 + 失败治理)
-
-## 任务状态
-
-- 任务名:S8-SCHED-EXEC-1
-- 开始:2026-04-28 12:08
-- 结束:2026-04-28 12:50
-- 耗时:约 42 分钟
-
-## 修改文件
-
-- `server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs`
-  - 新增依赖 `_detectionStateRep`
-  - 新增 lease/dispatch 公共方法:`ResetExpiredLeasesAsync` / `PickReadyRulesAsync` / `RunSingleRuleAsync` / `OnRuleCompletedAsync` / `RunDispatchTickAsync`
-  - 抽出 `ProcessSingleRuleAsync` 单规则处理体(与 legacy `ProcessRulesByTypeAsync` 共享 evaluator → reconcile → hit 循环语义)
-  - `RefreshDetectionAsync` 增加 `consecutive_hit_count += 1` / `consecutive_miss_count = 0` / `recovered_at = null`(复发清空)
-  - `ProcessRulesByTypeAsync` 创建分支注入 `UpsertDetectionStateOnHitAsync` 抗抖累计(trigger_count_required 兜底 [1,10])
-  - `ReconcileRecoveriesForRuleAsync` 增加 miss 累计 + recover_count_required 抗抖(仅累计未达阈值时不写 recovered_at / 不写 RECOVERED)
-  - 新增 DTO:`S8RuleLease` / `S8RuleRunResult` / `S8RuleRunStats` / `S8DispatchTickResult`
-- `server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8WatchSchedulerJob.cs`
-  - `IntervalMs`:300000 → 60000(5min → 1min)
-  - 替换为 `RunDispatchTickAsync` 主流程(删除进程内 `_consecutiveFailureCount` / `TryAutoPause`)
-  - `BuildLockedBy()` = "{MachineName}-{ProcessId}" 作为 lease owner
-  - 留痕字段扩展:tickId / runId / picked / created / refreshed / pending / perRuleFailed / leaseReleased
-
-## 核心变更点
-
-### Lease 模型
-- 乐观 UPDATE 抢锁:候选 SELECT → 逐行 `WHERE id=? AND enabled AND (paused_until<=NOW) AND (next_run_at<=NOW) AND (lock_until<=NOW)` → affectedRows=1 才进 lease 列表
-- LeaseDuration = 5 min
-- `lock_token` GUID,`locked_by = $"{MachineName}-{Pid}"`,`running_started_at = NOW`,`last_run_id = runId`
-- OnRuleCompletedAsync 必须 `WHERE id=? AND lock_token=?`,丢锁记 Warning 不覆盖状态
-
-### 抗抖
-- 建单前:`UpsertDetectionStateOnHitAsync` 命中累加 `consecutive_hit_count`,未达 `trigger_count_required` 写 `antiflap_pending_hit` skip 结果(不建单、不写 CREATED)
-- 已建异常刷新:RefreshDetectionAsync 自增 `consecutive_hit_count`,清 miss & recovered_at
-- 恢复抗抖:每次 miss 异常 `consecutive_miss_count += 1` & 异常 `consecutive_hit_count = 0`;同步 detection_state;未达 `recover_count_required` 写 `antiflap_pending_recovery` 不写 recovered_at / 不写 RECOVERED
-- 兜底:trigger / recover 计数 null / <1 / >10 一律按 1
-
-### 失败治理
-- evaluator 抛 `S8RuleEvaluatorException` → `Result.Success=false` → `consecutive_failure_count += 1`
-- 达到 3 次 → `paused_until = NOW + 1h` / `pause_reason = "AUTO_PAUSED_AFTER_3_FAILURES: {error 摘要}"`(截断 64 字符)
-- 暂停期 PickReadyRulesAsync 自然过滤
-
-### Job 节拍
-- 5min → 1min;单规则节奏由 `poll_interval_seconds` + `next_run_at` 决定,未到期规则在 PickReady 阶段过滤
-
-### 调度入口
-- Job:`RunDispatchTickAsync(tenantId=1, factoryId=1, batchSize=32, lockedBy)`
-- Debug `run-once`:保留旧 `CreateExceptionsAsync`,路径仍走 legacy `ProcessRulesByTypeAsync`(已注入抗抖;trigger=1/recover=1 时行为等价旧版)
-
-## 测试命令与结果
-
-```
-# 编译
-dotnet build server/Plugins/Admin.NET.Plugin.AiDOP/Admin.NET.Plugin.AiDOP.csproj -f net10.0
-  → 0 Error, 64 Warning(pre-existing XML doc)
-
-# 重启
-bash restart_aidop.sh
-  → 后端 5005 + 前端 8888 启动成功;首次激活日志:
-    "S8WatchSchedulerJob 首次激活:IntervalMs=60000 BatchSize=32 ..."
-```
-
-### A. 编译与启动
-- ✅ 0 Error
-- ✅ 后端启动无 ERROR
-
-### B. PickReady + lease(rule 10/11/12,nudge next_run_at 至过去)
-| ruleId | last_run_at | next_run_at | last_status | last_run_id | lock_token | duration_ms |
-|---|---|---|---|---|---|---|
-| 10 | 12:22:40 | 12:23:40 | SUCCESS | 6e329636829e4f2f | NULL | 656 |
-| 11 | 12:22:41 | 12:27:41 | SUCCESS | 6e329636829e4f2f | NULL | 665 |
-| 12 | 12:22:41 | 12:27:41 | SUCCESS | 6e329636829e4f2f | NULL | 675 |
-- ✅ lease 三件套全部 NULL
-- ✅ last_duration_ms 有值
-- ✅ last_run_id 有值
-
-### C. poll_interval_seconds 差异化
-- rule 10 (poll=60):next - last = 60s ✅
-- rule 11 (poll=300):next - last = 300s ✅
-- rule 12 (poll=300):next - last = 300s ✅
-
-### D. lease 防重复
-1. `UPDATE WHERE id=10 SET lock_token='TEST_LOCK_BLOCK', lock_until=NOW+5min` → 等 1 tick
-   - ✅ rule 10 last_run_at 不变(未被拾取)
-2. 改 `lock_until = NOW - 1min` → 等 1 tick
-   - ✅ ResetExpiredLeasesAsync 释放(log: `lease_reset releasedCount=1`)
-   - ✅ 下个 tick 重新拾取 → SUCCESS
-
-### F. 建单前抗抖(trigger_count_required=3)
-准备:`UPDATE exception 64 SET status='CLOSED'`,`DELETE detection_state`,`SET trigger_count_required=3 ON rule 10`
-
-| tick | hit_count | active_exception_id | created |
-|---|---|---|---|
-| 1 | 1 | NULL | ❌ pending |
-| 2 | 2 | NULL | ❌ pending |
-| 3 | 3 | 70 | ✅ CREATED 写 detection_log |
-
-新建异常 70:dedup_key 与原一致;`consecutive_hit_count=3`、`consecutive_miss_count=0`
-
-### G. 恢复抗抖(recover_count_required=3)
-准备:`UPDATE demo_test_order id=1 SET status='COMPLETED'`(rule 不再命中)
-
-| tick | exception 70 miss_count | recovered_at | RECOVERED log |
-|---|---|---|---|
-| 1 | 1 | NULL | 否 |
-| 2 | 2 | NULL | 否 |
-| 3 | 3 | 12:42:39 | ✅ |
-
-### H. 复发清空 recovered_at
-准备:`UPDATE exception 70 SET recovered_at=NOW`,恢复 demo_test_order 让 rule 重新命中
-
-| 项 | 结果 |
-|---|---|
-| recovered_at | NOW → NULL ✅ |
-| last_detected_at | 12:38:39 ✅ |
-| consecutive_hit_count | 3 → 4 ✅ |
-| status | NEW(未自动改)✅ |
-| detection_log | REFRESHED ✅ |
-
-### 失败治理(TEMP_SCHED_BAD_FAILURE,params_json='{}')
-| tick | last_status | consecutive_failure_count | paused_until | pause_reason |
-|---|---|---|---|---|
-| 1 | FAILED | 1 | NULL | NULL |
-| 2 | FAILED | 2 | NULL | NULL |
-| 3 | FAILED | 3 | 13:46:39 (NOW+1h) | AUTO_PAUSED_AFTER_3_FAILURES: TIMEOUT 规则 TEMP_SCHED_BAD_FAILURE |
-| 4 | (未拾取)| 3 | 13:46:39 | 不变 |
-
-其它规则 (10/11/12) 同期 last_run_at 持续推进 → 单条规则失败不影响其他规则。
-
-### I. baseline & demo 守恒
-- baseline = `SELECT COUNT(*) FROM ado_s8_exception_type WHERE tenant_id=0 AND factory_id=0 AND enabled=1` = 3 ✅
-- rule 10/11/12 enabled=1,paused_until=NULL,trigger_count_required=1,recover_count_required=1 ✅
-
-## SQL 写入摘要(仅限 dev/aidopdev)
-
-```
--- F 准备
-UPDATE ado_s8_exception SET status='CLOSED' WHERE id=64;
-DELETE FROM ado_s8_rule_detection_state WHERE rule_code='DEMO_ORDER_DELIVERY_TIMEOUT';
-UPDATE ado_s8_watch_rule SET trigger_count_required=3, recover_count_required=3 WHERE id=10;
-
--- 失败治理 fixture
-INSERT INTO ado_s8_watch_rule (...) VALUES (..., 'TEMP_SCHED_BAD_FAILURE', ..., '{}', ...);
-
--- H 准备
-UPDATE ado_s8_exception SET recovered_at=NOW() WHERE id=70;
-
--- G 准备
-UPDATE demo_test_order SET status='COMPLETED' WHERE id=1;
-
--- 清理
-DELETE FROM ado_s8_watch_rule WHERE rule_code='TEMP_SCHED_BAD_FAILURE';
-UPDATE demo_test_order SET status='PENDING' WHERE id=1;
-UPDATE ado_s8_watch_rule SET trigger_count_required=1, recover_count_required=1 WHERE id=10;
-DELETE FROM ado_s8_rule_detection_state WHERE rule_code='DEMO_ORDER_DELIVERY_TIMEOUT';
-```
-
-注:exception 64 仍 CLOSED;exception 70 保留作为演示数据(复发链路样本)。
-
-## 未解决风险
-
-1. **新调度路径不再扫描"未分类"旧 AlertRule 规则**:legacy `LoadExecutionRulesAsync` 走 `RuleType IS NULL OR ''` 分支,仅在 debug `CreateExceptionsAsync` 调用链可达。Job 现在只走 dispatch tick + RuleType 分派。如生产/预发环境仍存在未分类规则,需要补 RuleType 标记,否则 Job 不再拾取它们。当前 dev 三条 demo 全部 RuleType 已分类,无影响。
-2. **调度 batchSize 硬编码 32**:dev 仅 3 条 demo,规模上来后需要下放到 appsettings 或 watch_rule 配置;本轮按"先可用"处理。
-3. **trigger / recover ≥ 10 的极端值被当作 1**:`NormalizeAntiflapCount` 兜底策略偏保守,如未来需要"≥ 10 次抗抖"必须先调整边界。
-4. **Job 进程多实例**:lease 模型已支持,但本轮仅单实例验证;多实例并发抢锁需要在测试环境复制后再做压测验证。
-5. **detection_state 不软删**:state 行只追加/累加,没有清理机制。如 dedup_key 增量上去后体积不可控;下一轮立 retention 任务(按 last_seen_at 过期)。
-6. **legacy `ProcessRulesByTypeAsync` 与新 `ProcessSingleRuleAsync` 有 ~80% 代码重复**:本轮按"双写不删旧"处理保留 debug run-once 的语义安全。下一轮 cleanup 立 `S8-SCHED-CLEANUP-LEGACY-PATH-1` 把 legacy 路径改成 wrapper。
-7. **回归脚本仍脱节**:rule-evaluator-regression / r6-detection-log-edge-regression baseline=13 + G01_TEST_* 硬编码,不可跑;保持 `S8-REGRESSION-FIXTURE-1` 待办。
-
-## CTO 判断
-
-**通过**。lease + dispatch + 抗抖触发 + 抗抖恢复 + 复发 + 失败治理 + 节拍 1min 全部端到端验证通过;baseline / demo 规则守恒;其它规则不受单条失败影响。
-
-## 下一步建议
-
-- **S8-SCHED-FRONTEND-1**:watch_rule 配置页暴露 poll_interval_seconds / trigger_count_required / recover_count_required;列表加 last_run_at / next_run_at / last_status;run-now / pause / resume controller 端点
-- **S8-SCHED-CLEANUP-LEGACY-PATH-1**:把 `ProcessRulesByTypeAsync` 改为 `ProcessSingleRuleAsync` 的轻 wrapper
-- **S8-DETECTION-STATE-RETENTION-1**:state 表 retention 任务(按 last_seen_at 过期)
-- **S8-REGRESSION-FIXTURE-1**:注入 G01_TEST_* fixture seed;driver baseline 参数化

+ 0 - 133
lwb/journals/2026-04-28-S8-sched-frontend.md

@@ -1,133 +0,0 @@
-# S8-SCHED-FRONTEND-1 执行记录
-
-> 日期:2026-04-28
-> 范围:S8 监控规则前端调度操作 + 运行态展示
-
-## 任务状态
-
-- 任务名:S8-SCHED-FRONTEND-1
-- 开始:2026-04-28 13:55
-- 结束:2026-04-28 14:18
-- 耗时:约 23 分钟
-
-## 修改文件
-
-后端:
-- `server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs`
-  - 新增 `PUT /{id}/schedule` `POST /{id}/run-now` `POST /{id}/pause` `POST /{id}/resume` 4 端点
-- `server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs`
-  - 新增 `UpdateScheduleAsync` / `RunNowAsync` / `PauseAsync` / `ResumeAsync` 4 方法
-  - 复用 SqlSugar SetColumns 局部更新,不触碰 params_json / rule_type / expression / data_source_id
-- `server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleParamsPayload.cs`
-  - 新增 `S8WatchRuleSchedulePayload` 载荷(仅 poll/trigger/recover 三字段)
-
-前端:
-- `Web/src/views/aidop/s8/api/s8ConfigApi.ts`
-  - `S8WatchRuleConfigRow` 扩展 13 个运行态字段(triggerCountRequired/recoverCountRequired/nextRunAt/lastRunAt/lastStatus/lastError/lastDurationMs/lastRunId/lockToken/lockedBy/lockUntil/runningStartedAt/consecutiveFailureCount/pausedUntil/pauseReason)
-  - 新增 `S8WatchRuleSchedulePayload` 类型
-  - `s8ConfigApi.watchRules` 增加 `updateSchedule` / `runNow` / `pause` / `resume` 4 方法
-- `Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue`
-  - 列表新增 9 列(轮询间隔/触发阈值/恢复阈值/上次执行/下次执行/上次状态/失败次数/耗时/错误摘要)
-  - 行操作新增 立即执行 / 暂停 / 恢复 / 重置失败 4 个按钮
-  - 编辑抽屉新增「调度设置」区(poll_interval_seconds 预设下拉 + trigger/recover 数字输入)
-  - 30s 自动刷新(onMounted/onActivated 启动;onUnmounted/onDeactivated 清理;编辑抽屉打开期间或页面不可见时跳过刷新)
-  - save() 双端点:先 PUT /schedule(poll/trigger/recover),再 PUT /params(params_json/enabled)
-
-commit hash:(待提交)
-
-## 后端 build
-
-```
-dotnet build server/Plugins/Admin.NET.Plugin.AiDOP/Admin.NET.Plugin.AiDOP.csproj -f net10.0
-  → 0 Error, 64 Warning(pre-existing XML doc)
-```
-
-## 前端 build
-
-```
-npx vue-tsc --noEmit -p tsconfig.json
-  → 改动文件 0 error;其它 pre-existing 自动生成代码 / dragVerify 等不归本轮
-pnpm run build
-  → ✓ built in 44.41s
-```
-
-## API 验证
-
-`GET /api/aidop/s8/config/watch-rules?tenantId=1&factoryId=1` 返回字段(节选 rule 11):
-
-```json
-{
-  "id":"11", "ruleCode":"DEMO_ORDER_DIMENSION_OOR", "ruleType":"OUT_OF_RANGE",
-  "pollIntervalSeconds":300, "triggerCountRequired":1, "recoverCountRequired":1,
-  "nextRunAt":"2026-04-28 14:12:05", "lastRunAt":"2026-04-28 14:07:05", "lastStatus":"SUCCESS",
-  "lastError":null, "lastDurationMs":600, "consecutiveFailureCount":0,
-  "pausedUntil":null, "pauseReason":null, "lockToken":null, "lockedBy":null,
-  ...
-}
-```
-✅ 13 个运行态字段全部 camelCase 暴露。
-
-## Chrome MCP 验证
-
-| 验证项 | 结果 |
-|---|---|
-| 页面可打开(/aidop/s8/config/watch-rules)| ✅ 3 条 demo 规则全部展示 |
-| 列表运行态列 | ✅ 轮询间隔/触发阈值/恢复阈值/上次执行/下次执行/上次状态/失败次数/耗时/错误摘要 9 列均显示正确 |
-| poll 显示 | ✅ rule 10=1 分钟,rule 11/12=5 分钟 |
-| trigger / recover 显示 | ✅ "连续 1 次命中" / "连续 1 次未命中" |
-| last_run_at / next_run_at / last_status | ✅ 时间戳 + SUCCESS 绿色 tag |
-| 立即执行(rule 10)| ✅ next_run_at 置为 NOW,下个 tick 拾取,last_run_at 更新(14:09:04 → 14:11:04)|
-| 暂停(rule 11)| ✅ paused_until=9999-12-31,pause_reason=MANUAL_PAUSED;下个 tick 不被拾取(last_run_at 14:07:05 不变)|
-| 暂停 UI 反馈 | ✅ 下次执行列显示「已暂停」,按钮变成「恢复」,立即执行 disabled |
-| 执行中 UI 反馈 | ✅ rule 10/12 lease 持有期显示「执行中」,立即执行 disabled |
-| 恢复(rule 11)| ✅ paused_until→NULL,pause_reason→NULL,consecutive_failure_count=0,last_error=NULL,next_run_at=NOW |
-| 编辑调度参数(rule 10 trigger 1→2)| ✅ 保存后 trigger_count_required=2;rule_type/expression/params_json 数据不变(params_json 仅格式化重序列化,语义一致)|
-| Console error/warn | ✅ 0 |
-| Network 5xx | ✅ 0 |
-| 截图 | `/home/yy968/work/MeetingWorkflow/runs/s8-sched-frontend-20260428/01-watch-rules-runtime-cols.png` |
-
-## SQL 保存前后比对(rule 10 trigger 1→2)
-
-保存前:
-```
-rule_type: TIMEOUT
-expression: SELECT order_no AS related_object_code, ... FROM demo_test_order
-params_json: {"dueAtField":"due_at","statusField":"status","graceMinutes":0,...,"completedStates":["CLOSED","DONE","COMPLETED"],...}
-poll_interval_seconds: 60
-trigger_count_required: 1
-recover_count_required: 1
-```
-
-保存后:
-```
-rule_type: TIMEOUT             ✅ 不变
-expression: SELECT order_no... ✅ 不变
-params_json: {"dueAtField":"due_at","statusField":"status","completedStates":["CLOSED","DONE","COMPLETED"],...,"graceMinutes":0,...} ✅ 数据等价(仅字段顺序/空格归一化)
-poll_interval_seconds: 60      ✅ 不变(未编辑)
-trigger_count_required: 2      ✅ 按预期变化
-recover_count_required: 1      ✅ 不变(未编辑)
-```
-
-baseline:
-```
-SELECT COUNT(*) FROM ado_s8_exception_type WHERE tenant_id=0 AND factory_id=0 AND enabled=1 = 3
-```
-✅ 不变。
-
-cleanup 后已 `UPDATE rule 10 SET trigger_count_required=1`。
-
-## 未解决风险
-
-1. **params_json 数据等价但格式化不同**:save() 调用 updateSchedule 后再调用 updateParams,params_json 由前端从模板字段重新序列化(不是原样回写)。两次序列化的 JSON 顺序、空格不同,但内容等价。已验证 dueAtField/statusField/completedStates/objectCodeField/objectIdField/graceMinutes/exceptionTypeCode 全字段保留。如下游系统依赖原文格式(极少见),需要前端在保存调度时 skip params 重序列化;下一轮可考虑分离两个保存按钮。
-2. **运行态字段无独立 GET**:列表 API 返回所有字段;高频自动刷新 + 大量规则时数据传输较大。规模上来后可考虑 `/runtime` 轻量端点;本轮 dev 仅 3 条无需。
-3. **暂停哨兵 9999-12-31**:MySQL DATETIME 上限是 9999-12-31 23:59:59,正好可承载;但后续若迁 PostgreSQL 等需复核。
-4. **Chrome MCP 启动需用户预先开启 9222 端口**:本轮启动延迟一次后用户手动启动,验证恢复正常。流程上可在 reference_chrome_devtools_mcp.md 文档化。
-5. **Demo01 登录密码硬编码于本轮验证流**:仅用于本地 dev 验证,非提交内容。
-
-## 下一步建议
-
-- **S8-SCHED-MULTI-INSTANCE-1**:lease 模型已支持,需要在多实例环境复制并跑并发抢锁压测
-- **S8-DETECTION-STATE-RETENTION-1**:state 表按 last_seen_at 过期清理任务
-- **S8-SCHED-CLEANUP-LEGACY-PATH-1**:把 `ProcessRulesByTypeAsync` 改为 `ProcessSingleRuleAsync` 轻 wrapper
-- **S8-SCHED-RUNTIME-API-1**:考虑分离 `/runtime` 轻量端点,列表自动刷新只拉运行态
-- **S8-REGRESSION-FIXTURE-1**:注入 G01_TEST_* fixture seed;driver baseline 参数化

+ 0 - 120
lwb/journals/2026-04-28-S8-sched-save-split.md

@@ -1,120 +0,0 @@
-# S8-SCHED-SAVE-SPLIT-1 执行记录
-
-> 日期:2026-04-28
-> 范围:监控规则配置页保存流程解耦(schedule / params 各自独立调用)
-
-## 任务状态
-
-- 任务名:S8-SCHED-SAVE-SPLIT-1
-- 开始:2026-04-28 14:21
-- 结束:2026-04-28 14:35
-- 耗时:约 14 分钟
-
-## 修改文件
-
-仅前端:
-- `Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue`
-  - 新增 `originalScheduleSnapshot` / `originalParamsSnapshot` / `originalEnabled` 三快照(openEdit 时记录)
-  - 新增 `hasScheduleChanged()` / `hasParamsChanged()` 判定函数(数值归一化 + buildPayloadParamsJson 稳定字符串比较)
-  - `save()` 改为按 schedule/params 变更标志分支调用:
-    - both false → toast "未检测到变更"
-    - 仅 schedule → 只 PUT /schedule
-    - 仅 params → 只 PUT /params(含 enabled)
-    - both true → 先 schedule 再 params
-  - 保存成功后刷新本地三快照(避免下次保存误判)
-
-无后端 / DB / schema 改动。
-
-commit:(待)
-
-## 三种保存路径验证
-
-dev 库 rule 10 (DEMO_ORDER_DELIVERY_TIMEOUT),全程通过 Chrome MCP。
-
-### 场景 A:只改 schedule(trigger 1→2)
-
-| 项 | 结果 |
-|---|---|
-| Network 请求 | 仅 `PUT /api/aidop/s8/config/watch-rules/10/schedule` 200 |
-| Network 不应有 | 无 `PUT /params` ✅ |
-| trigger_count_required | 1 → 2 ✅ |
-| params_json | **字节级完全不变** ✅ |
-| rule_type | 不变 ✅ |
-| expression | 不变 ✅ |
-
-### 场景 B:只改 params(graceMinutes 0→1)
-
-| 项 | 结果 |
-|---|---|
-| Network 请求 | 仅 `PUT /api/aidop/s8/config/watch-rules/10/params` 200 |
-| Network 不应有 | 无 `PUT /schedule` ✅ |
-| params_json.graceMinutes | 0 → 1 ✅ |
-| poll_interval_seconds | 不变 ✅ |
-| trigger_count_required | 不变 ✅ |
-| recover_count_required | 不变 ✅ |
-
-### 场景 C:同时改(trigger 2→3 + graceMinutes 1→2)
-
-| 项 | 结果 |
-|---|---|
-| Network 请求 | `PUT /schedule` 200 + `PUT /params` 200(按序)✅ |
-| trigger_count_required | 2 → 3 ✅ |
-| graceMinutes | 1 → 2 ✅ |
-
-### 场景 D:无变化保存
-
-| 项 | 结果 |
-|---|---|
-| Network 请求 | **无任何 PUT** ✅ |
-| Toast | "未检测到变更" |
-| Console error/warn | 0 ✅ |
-
-## params_json 字节级不变验证(场景 A)
-
-保存前:
-```
-{"dueAtField":"due_at","statusField":"status","completedStates":["CLOSED","DONE","COMPLETED"],"objectCodeField":"related_object_code","objectIdField":"source_object_id","graceMinutes":0,"exceptionTypeCode":"DELIVERY_DELAY"}
-```
-
-保存后(仅触发 /schedule):
-```
-{"dueAtField":"due_at","statusField":"status","completedStates":["CLOSED","DONE","COMPLETED"],"objectCodeField":"related_object_code","objectIdField":"source_object_id","graceMinutes":0,"exceptionTypeCode":"DELIVERY_DELAY"}
-```
-
-→ 完全相同(字节级一致)。
-
-## 数据守恒
-
-```
-SELECT id, rule_code, enabled FROM ado_s8_watch_rule WHERE id IN (10,11,12);
-  10 DEMO_ORDER_DELIVERY_TIMEOUT enabled=1
-  11 DEMO_ORDER_DIMENSION_OOR    enabled=1
-  12 DEMO_ORDER_YIELD_OOR        enabled=1
-
-SELECT COUNT(*) FROM ado_s8_exception_type WHERE tenant_id=0 AND factory_id=0 AND enabled=1 = 3
-```
-
-✅ 全部守恒。rule 10 验证后已恢复至演示安全态:trigger=1, recover=1, params_json graceMinutes=0。
-
-## 编译验证
-
-- `npx vue-tsc --noEmit` → 改动文件 0 error(其它 pre-existing 不归本轮)
-- `pnpm run build` → ✓ built in 46.58s
-
-## 截图
-
-- `/home/yy968/work/MeetingWorkflow/runs/s8-sched-save-split-20260428/01-watch-rules-page.png`
-
-## 未解决风险
-
-1. **buildPayloadParamsJson 必须保持稳定 key 顺序**:本轮通过两次调用同一函数生成 canonical 字符串作为快照与最新值比较;若未来在该函数内引入条件分支(例如某字段空时不输出)可能导致 false positive 变更检测。建议在该函数附近加注释或单测约束。
-2. **enabled 切换计入 params 变更**:因后端 enabled 走 /params 端点,开关切换时会走 params 路径。若未来 enabled 拆出独立端点,可在 hasParamsChanged 移除 enabled 比较。
-3. **失败兜底吞错**:当前 save() try/catch 仍统一捕获并 toast;如 schedule 成功但 params 失败,UI 仅展示 params 错误,schedule 已生效但用户可能误以为整体失败。建议下一轮把 schedule + params 双调用分别 try/catch 并明确显示哪一步成功 / 哪一步失败。
-4. **数值类型边界**:clampInt 兜底极端值(poll_interval 60–86400 / trigger·recover 1–10),与后端范围校验一致;如运行期前后端范围不一致需同步调整。
-
-## 下一步建议
-
-- **S8-SCHED-SAVE-PARTIAL-FAILURE-1**:拆 schedule / params 各自 try/catch,明确双调用部分成功语义
-- **S8-SCHED-CLEANUP-LEGACY-PATH-1**:把 `ProcessRulesByTypeAsync` 改 `ProcessSingleRuleAsync` 轻 wrapper(与本轮无关,沿用前序)
-- **S8-DETECTION-STATE-RETENTION-1**:state 表按 last_seen_at 过期清理(沿用前序)
-- **S8-REGRESSION-FIXTURE-1**:注入 G01_TEST_* fixture seed;driver baseline 参数化(沿用前序)