Explorar o código

fix(s8): split scheduler and params saves

S8-SCHED-SAVE-SPLIT-1:解耦监控规则配置页保存流程,避免无意义 params_json 重序列化。

变更:
- openEdit 时记录 originalScheduleSnapshot / originalParamsSnapshot / originalEnabled 三快照
- hasScheduleChanged:数值归一化后比较 poll/trigger/recover 三字段
- hasParamsChanged:buildPayloadParamsJson 稳定字符串与快照比较;enabled 切换计入
- save 改为变更标志分支调用:
  · both false → toast 未检测到变更,不发请求
  · 仅 schedule → 只 PUT /schedule
  · 仅 params → 只 PUT /params
  · both true → 先 PUT /schedule 再 PUT /params
- 保存成功后刷新本地三快照,避免下次保存误判

验证(rule 10,Chrome MCP):
- 场景 A 仅 schedule:Network 仅 /schedule,params_json 字节级不变 ✅
- 场景 B 仅 params:Network 仅 /params,poll/trigger/recover 不变 ✅
- 场景 C 同时改:Network /schedule + /params 双 200 ✅
- 场景 D 无变化:Network 无 PUT,toast 未检测到变更 ✅

数据守恒:
- demo rule 10/11/12 enabled=1 ✅
- baseline=3 ✅
- rule 10 验证后已恢复演示安全态(trigger=1, params graceMinutes=0)

不改 backend / DB schema / scheduler / evaluator / params 模板化保存语义。
YY968XX hai 2 meses
pai
achega
807b91f92c

+ 61 - 8
Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue

@@ -104,6 +104,17 @@ const POLL_INTERVAL_PRESETS = [
 	{ label: '4 小时', value: 14400 },
 ];
 
+// S8-SCHED-SAVE-SPLIT-1:打开抽屉时记录的 schedule / params / enabled 快照,
+// 用于在 save() 中判断真实变更,避免无意义触发 PUT /params 重序列化。
+interface ScheduleSnapshot {
+	pollIntervalSeconds: number;
+	triggerCountRequired: number;
+	recoverCountRequired: number;
+}
+const originalScheduleSnapshot = ref<ScheduleSnapshot | null>(null);
+const originalParamsSnapshot = ref<string | null>(null);
+const originalEnabled = ref<boolean>(true);
+
 // S8-SCHED-FRONTEND-1:30s 自动刷新;onMounted/onActivated 启动;onUnmounted/onDeactivated 清理。
 const REFRESH_INTERVAL_MS = 30000;
 let refreshTimer: ReturnType<typeof setInterval> | null = null;
@@ -250,9 +261,35 @@ function loadFormFromRow(row: S8WatchRuleConfigRow) {
 function openEdit(row: S8WatchRuleConfigRow) {
 	editingRow.value = row;
 	loadFormFromRow(row);
+	// S8-SCHED-SAVE-SPLIT-1:写入快照。schedule 走数值归一化;params 走 buildPayloadParamsJson
+	// 自身的稳定 key 顺序,因此打开时与保存时两次调用即可字节级比较。
+	originalScheduleSnapshot.value = {
+		pollIntervalSeconds: clampInt(scheduleForm.pollIntervalSeconds, 60, 86400, 300),
+		triggerCountRequired: clampInt(scheduleForm.triggerCountRequired, 1, 10, 1),
+		recoverCountRequired: clampInt(scheduleForm.recoverCountRequired, 1, 10, 1),
+	};
+	originalParamsSnapshot.value = buildPayloadParamsJson();
+	originalEnabled.value = enabledForm.value;
 	drawerOpen.value = true;
 }
 
+function hasScheduleChanged(): boolean {
+	const o = originalScheduleSnapshot.value;
+	if (!o) return false;
+	return (
+		clampInt(scheduleForm.pollIntervalSeconds, 60, 86400, 300) !== o.pollIntervalSeconds
+		|| clampInt(scheduleForm.triggerCountRequired, 1, 10, 1) !== o.triggerCountRequired
+		|| clampInt(scheduleForm.recoverCountRequired, 1, 10, 1) !== o.recoverCountRequired
+	);
+}
+
+function hasParamsChanged(): boolean {
+	// enabled 在后端走 /params 端点,因此 enabled 切换视为 params 变更。
+	if (enabledForm.value !== originalEnabled.value) return true;
+	const next = buildPayloadParamsJson();
+	return next !== originalParamsSnapshot.value;
+}
+
 function closeDrawer() {
 	drawerOpen.value = false;
 	editingRow.value = null;
@@ -305,19 +342,35 @@ function buildPayloadParamsJson(): string | null {
 
 async function save() {
 	if (!editingRow.value) return;
+	const scheduleChanged = hasScheduleChanged();
+	const paramsChanged = hasParamsChanged();
+	if (!scheduleChanged && !paramsChanged) {
+		ElMessage.info('未检测到变更');
+		return;
+	}
 	saving.value = true;
 	try {
-		// 调度参数走专用 schedule 端点;params_json / rule_type / expression 不会被触碰。
-		await s8ConfigApi.watchRules.updateSchedule(editingRow.value.id, {
+		if (scheduleChanged) {
+			await s8ConfigApi.watchRules.updateSchedule(editingRow.value.id, {
+				pollIntervalSeconds: clampInt(scheduleForm.pollIntervalSeconds, 60, 86400, 300),
+				triggerCountRequired: clampInt(scheduleForm.triggerCountRequired, 1, 10, 1),
+				recoverCountRequired: clampInt(scheduleForm.recoverCountRequired, 1, 10, 1),
+			});
+		}
+		if (paramsChanged) {
+			await s8ConfigApi.watchRules.updateParams(editingRow.value.id, {
+				paramsJson: buildPayloadParamsJson(),
+				enabled: enabledForm.value,
+			});
+		}
+		// S8-SCHED-SAVE-SPLIT-1:保存成功后刷新快照,避免下一次保存被误判为仍有变更。
+		originalScheduleSnapshot.value = {
 			pollIntervalSeconds: clampInt(scheduleForm.pollIntervalSeconds, 60, 86400, 300),
 			triggerCountRequired: clampInt(scheduleForm.triggerCountRequired, 1, 10, 1),
 			recoverCountRequired: clampInt(scheduleForm.recoverCountRequired, 1, 10, 1),
-		});
-		// params_json + enabled 仍走原 params 端点,保持模板化保存语义。
-		await s8ConfigApi.watchRules.updateParams(editingRow.value.id, {
-			paramsJson: buildPayloadParamsJson(),
-			enabled: enabledForm.value,
-		});
+		};
+		originalParamsSnapshot.value = buildPayloadParamsJson();
+		originalEnabled.value = enabledForm.value;
 		ElMessage.success('保存成功');
 		closeDrawer();
 		await loadRows();

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

@@ -0,0 +1,120 @@
+# 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 参数化(沿用前序)