Commit 9f1f7fc014f4df7aa8f65590135ffd1fe4471b6d

Authored by zichun
1 parent 2c0afde9

op-queue status protocol: 3-state sStatus (pending/success/fail) + iStatusCode (…

…100/101, 200/201, 4xx/5xx error taxonomy); xlyAi writes pending+100 only; legacy values read-compat; migration maps existing rows
docs/agent-architecture.md
@@ -485,8 +485,12 @@ ai_op_queue( @@ -485,8 +485,12 @@ ai_op_queue(
485 - **确定性保存端点**(不经 LLM):`POST /api/agent/preview/{id}/save`(fields=技术名→值, 485 - **确定性保存端点**(不经 LLM):`POST /api/agent/preview/{id}/save`(fields=技术名→值,
486 空值=放弃该字段的改动)与 `POST /api/agent/form/submit`(create)。流程:归属校验 → 权限重查 → 486 空值=放弃该字段的改动)与 `POST /api/agent/form/submit`(create)。流程:归属校验 → 权限重查 →
487 **重读记录重校验**(值/状态被他人改过 → 拒绝,"所见即所写")→ **原子 claim previewId**(防并发/ 487 **重读记录重校验**(值/状态被他人改过 → 拒绝,"所见即所写")→ **原子 claim previewId**(防并发/
488 - 重复提交)→ 写 `ai_op_queue` 一行 `sStatus='confirmed'`(多字段=多行、单事务)→ 落 `queued` 事件。 488 + 重复提交)→ 写 `ai_op_queue` 一行 `pending+100`(多字段=多行、单事务)→ 落 `queued` 事件。
489 **xlyAi 到此为止**:执行/审计/权属全部移交 ERP 侧(执行器读本表;现状=报价自动执行、其余进待办)。 489 **xlyAi 到此为止**:执行/审计/权属全部移交 ERP 侧(执行器读本表;现状=报价自动执行、其余进待办)。
  490 + **状态协议 = sStatus 三态 + iStatusCode**(2026-07-29 定,码表见 `sql/ai_op_queue.sql` 头注):
  491 + pending(100 已提交/101 ERP 处理中)、success(200/201 幂等命中)、fail(400 载荷/401 权限/404 不存在/
  492 + 409 状态冲突/422 业务校验/423 锁定/450 已取消/500 异常/504 超时);xlyAi 只写 pending+100,
  493 + 其余全由 ERP 回写;旧值 draft/confirmed/executed/failed/cancelled 兼容读不再写。
490 - 退役:OpController(confirm/cancel/pending)、AuditService、ErpClient 全部写方法 + execStaging。 494 - 退役:OpController(confirm/cancel/pending)、AuditService、ErpClient 全部写方法 + execStaging。
491 ErpClient 只剩 whoami / readForm / getUuid / checkBusinessData(dry-run 接口位)。 495 ErpClient 只剩 whoami / readForm / getUuid / checkBusinessData(dry-run 接口位)。
492 - **状态合法性硬检查**(预览+保存两次,防 TOCTOU;依据已核实的存储过程行为):审核要求未审核、 496 - **状态合法性硬检查**(预览+保存两次,防 TOCTOU;依据已核实的存储过程行为):审核要求未审核、
sql/ai_op_queue.sql
1 -- ai_op_queue:AI 写操作队列(rearch3 §3:**AI 侧唯一的写入口**)。 1 -- ai_op_queue:AI 写操作队列(rearch3 §3:**AI 侧唯一的写入口**)。
2 --- 用户在预览卡/表单上点按钮(保存/审核/作废/…)→ xlyAi 校验后落一行 sStatus='confirmed' 2 +-- 用户在预览卡/表单上点按钮(保存/审核/作废/…)→ xlyAi 校验后落一行 pending+100
3 -- xlyAi 工作到此为止:执行/审计由 ERP 侧负责(暂存执行器 /ai/execStaging 读本表; 3 -- xlyAi 工作到此为止:执行/审计由 ERP 侧负责(暂存执行器 /ai/execStaging 读本表;
4 -- 现状=报价自动执行、其余进待办)。覆盖 update / create / delete / examine / invalid(sOpType 区分)。 4 -- 现状=报价自动执行、其余进待办)。覆盖 update / create / delete / examine / invalid(sOpType 区分)。
  5 +--
  6 +-- 状态协议(2026-07-29 定)= sStatus 三态 + iStatusCode 状态码:
  7 +-- pending:100=已提交等待 ERP 处理(xlyAi 写入的唯一状态)|101=ERP 处理中(已领取,防并发重复执行)
  8 +-- success:200=执行成功|201=幂等命中(此前已执行,本次返回既有结果)
  9 +-- fail :400=载荷/参数非法|401=身份/权限不足|404=目标记录不存在|409=状态冲突(重复审核/
  10 +-- 重复作废/未审核销审/记录被他人改过)|422=业务校验未通过|423=记录被锁定(SysLocking)|
  11 +-- 450=已取消(ERP 端取消待办)|500=执行异常|504=执行超时
  12 +-- pending 由 xlyAi 写入(100)与 ERP 领取时更新(101);success/fail 只由 ERP 回写。
  13 +-- 旧值 draft/confirmed/executing/executed/failed/cancelled 已废弃(xlyAi 兼容读,不再写)。
5 -- update:sTargetBillId + sField/sNewValue(单字段;一次保存多字段 = 多行)。 14 -- update:sTargetBillId + sField/sNewValue(单字段;一次保存多字段 = 多行)。
6 -- invalid:sNewValue 存 handleType(toVoid=作废 / cancel=复原);examine:sNewValue 存 iFlag(1/0)。 15 -- invalid:sNewValue 存 handleType(toVoid=作废 / cancel=复原);examine:sNewValue 存 iFlag(1/0)。
7 -- create:整表 payload 存 sPayload(列->值 JSON,多表用 __tables__)。 16 -- create:整表 payload 存 sPayload(列->值 JSON,多表用 __tables__)。
@@ -26,7 +35,8 @@ CREATE TABLE IF NOT EXISTS ai_op_queue ( @@ -26,7 +35,8 @@ CREATE TABLE IF NOT EXISTS ai_op_queue (
26 sSourceRef text, -- generate:源表单 + 选中明细 sId 集合 JSON 35 sSourceRef text, -- generate:源表单 + 选中明细 sId 集合 JSON
27 bAutoExecute tinyint NOT NULL DEFAULT 0, -- 1=ERP 确认后自动执行;0=预填表单等用户提交 36 bAutoExecute tinyint NOT NULL DEFAULT 0, -- 1=ERP 确认后自动执行;0=预填表单等用户提交
28 sDescription varchar(500), -- 人类可读的改动描述 37 sDescription varchar(500), -- 人类可读的改动描述
29 - sStatus varchar(16), -- draft | confirmed | executing | executed | failed | cancelled | expired 38 + sStatus varchar(16), -- pending | success | fail(状态码见头部注释)
  39 + iStatusCode int NULL, -- 状态码(100/101/200/201/4xx/5xx)
30 sResultBillId varchar(64), -- 执行后生成/影响的单据 id 40 sResultBillId varchar(64), -- 执行后生成/影响的单据 id
31 sResultMsg varchar(500), 41 sResultMsg varchar(500),
32 sErrorMsg varchar(1000), 42 sErrorMsg varchar(1000),
sql/migrate_rearch3.sql
1 -- rearch3 部署迁移(对**已有** ai_op_queue 的库执行;新库直接跑 ai_op_queue.sql 即可)。 1 -- rearch3 部署迁移(对**已有** ai_op_queue 的库执行;新库直接跑 ai_op_queue.sql 即可)。
2 -- 与部署同批执行的完整清单: 2 -- 与部署同批执行的完整清单:
3 --- 1) 本文件(ai_op_queue 加 sMakePerson 双写列——ERP 执行器仍读 sUserId,切换后可删旧列 3 +-- 1) 本文件(ai_op_queue 加 sMakePerson 双写列 + 状态协议改 状态三态+状态码
4 -- 2) sql/ai_chat_event.sql (会话事件账本,新表) 4 -- 2) sql/ai_chat_event.sql (会话事件账本,新表)
5 -- 3) sql/ai_skill.sql (DROP 旧草稿表重建;⚠️ 建表瞬间即切断 war 包技能读取) 5 -- 3) sql/ai_skill.sql (DROP 旧草稿表重建;⚠️ 建表瞬间即切断 war 包技能读取)
6 -- 4) sql/ai_skill_seed.sql (默认技能种子,必须与 3 同批执行) 6 -- 4) sql/ai_skill_seed.sql (默认技能种子,必须与 3 同批执行)
7 ALTER TABLE ai_op_queue ADD COLUMN sMakePerson varchar(64) NULL AFTER sUserId; 7 ALTER TABLE ai_op_queue ADD COLUMN sMakePerson varchar(64) NULL AFTER sUserId;
  8 +
  9 +-- 状态协议(状态码表见 ai_op_queue.sql 头部注释):sStatus 三态 pending|success|fail + iStatusCode
  10 +ALTER TABLE ai_op_queue ADD COLUMN iStatusCode int NULL AFTER sStatus;
  11 +-- 存量行按旧→新映射(cancelled→fail+450;draft/confirmed→pending+100)
  12 +UPDATE ai_op_queue SET sStatus='pending', iStatusCode=100 WHERE sStatus IN ('draft','confirmed');
  13 +UPDATE ai_op_queue SET sStatus='pending', iStatusCode=101 WHERE sStatus='executing';
  14 +UPDATE ai_op_queue SET sStatus='success', iStatusCode=200 WHERE sStatus='executed';
  15 +UPDATE ai_op_queue SET sStatus='fail', iStatusCode=500 WHERE sStatus='failed';
  16 +UPDATE ai_op_queue SET sStatus='fail', iStatusCode=450 WHERE sStatus='cancelled';
src/main/java/com/xly/service/OpService.java
@@ -10,11 +10,20 @@ import java.util.Map; @@ -10,11 +10,20 @@ import java.util.Map;
10 /** 10 /**
11 * ai_op_queue 队列写入 —— **AI 侧唯一的写入口**(rearch3 §3 写路径收权)。 11 * ai_op_queue 队列写入 —— **AI 侧唯一的写入口**(rearch3 §3 写路径收权)。
12 * 12 *
13 - * <p>用户在预览卡/表单上点按钮(保存/审核/作废…)→ 确定性端点校验后经本类落一行  
14 - * {@code sStatus='confirmed'}(用户已当面授权)。**xlyAi 工作到此为止**:是否/何时执行、  
15 - * 执行权属、审计留痕全部由 ERP 侧负责(暂存执行器读本表;现状=报价自动执行、其余进待办)。 13 + * <p>用户在预览卡/表单上点按钮(保存/审核/作废…)→ 确定性端点校验后经本类落一行队列。
  14 + * **xlyAi 工作到此为止**:是否/何时执行、执行权属、审计留痕全部由 ERP 侧负责
  15 + * (暂存执行器读本表;现状=报价自动执行、其余进待办)。
16 * xlyAi 侧只保留 {@link #statusLabel} 只读展示处理进度(流程卡)。 16 * xlyAi 侧只保留 {@link #statusLabel} 只读展示处理进度(流程卡)。
17 * 17 *
  18 + * <p><b>状态协议 = sStatus 三态 + iStatusCode 状态码</b>(2026-07-29 定):
  19 + * <ul>
  20 + * <li>{@code pending}:100=已提交等待 ERP 处理(xlyAi 写入的唯一状态)、101=ERP 处理中(已领取);</li>
  21 + * <li>{@code success}:200=执行成功、201=幂等命中(此前已执行);</li>
  22 + * <li>{@code fail}:400 载荷非法 / 401 身份权限 / 404 记录不存在 / 409 状态冲突 /
  23 + * 422 业务校验未通过 / 423 记录被锁 / 450 已取消 / 500 执行异常 / 504 超时。</li>
  24 + * </ul>
  25 + * 旧值(draft/confirmed/executing/executed/failed/cancelled)兼容读(见 statusLabel),不再写。
  26 + *
18 * <p>列名兼容:ERP 执行器仍读 {@code sUserId} → 新列 {@code sMakePerson}(ERP 惯例)双写, 27 * <p>列名兼容:ERP 执行器仍读 {@code sUserId} → 新列 {@code sMakePerson}(ERP 惯例)双写,
19 * ERP 侧切换后可删 sUserId。 28 * ERP 侧切换后可删 sUserId。
20 */ 29 */
@@ -67,6 +76,7 @@ public class OpService { @@ -67,6 +76,7 @@ public class OpService {
67 null, null, null, null, payload, description); 76 null, null, null, null, payload, description);
68 } 77 }
69 78
  79 + /** xlyAi 写入的唯一状态:pending+100(已提交,等待 ERP 处理)。 */
70 private String insert(AgentIdentity who, String convId, String opType, String formId, String moduleId, 80 private String insert(AgentIdentity who, String convId, String opType, String formId, String moduleId,
71 String table, String billId, String field, String fieldLabel, String oldValue, 81 String table, String billId, String field, String fieldLabel, String oldValue,
72 String newValue, String payload, String description) { 82 String newValue, String payload, String description) {
@@ -74,8 +84,8 @@ public class OpService { @@ -74,8 +84,8 @@ public class OpService {
74 jdbc.update( 84 jdbc.update(
75 "INSERT INTO ai_op_queue(sId,sUserId,sMakePerson,sBrandsId,sSubsidiaryId,sConversationId,sOpType," + 85 "INSERT INTO ai_op_queue(sId,sUserId,sMakePerson,sBrandsId,sSubsidiaryId,sConversationId,sOpType," +
76 "sTargetFormId,sTargetModuleId,sTargetTable,sTargetBillId,sField,sFieldLabel," + 86 "sTargetFormId,sTargetModuleId,sTargetTable,sTargetBillId,sField,sFieldLabel," +
77 - "sOldValue,sNewValue,sPayload,sDescription,sStatus,tCreateDate,tConfirmDate) " +  
78 - "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'confirmed',NOW(),NOW())", 87 + "sOldValue,sNewValue,sPayload,sDescription,sStatus,iStatusCode,tCreateDate,tConfirmDate) " +
  88 + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'pending',100,NOW(),NOW())",
79 sId, who.userId(), who.userId(), who.brandsId(), who.subsidiaryId(), convId, opType, 89 sId, who.userId(), who.userId(), who.brandsId(), who.subsidiaryId(), convId, opType,
80 formId, moduleId, table, billId, field, trunc(fieldLabel, 128), 90 formId, moduleId, table, billId, field, trunc(fieldLabel, 128),
81 trunc(oldValue, 500), trunc(newValue, 500), payload, trunc(description, 500)); 91 trunc(oldValue, 500), trunc(newValue, 500), payload, trunc(description, 500));
@@ -91,19 +101,27 @@ public class OpService { @@ -91,19 +101,27 @@ public class OpService {
91 return r.isEmpty() ? null : r.get(0); 101 return r.isEmpty() ? null : r.get(0);
92 } 102 }
93 103
94 - /** sStatus → 人类可读进度(流程卡只读展示;不做任何处理动作)。查不到返回 null。 */ 104 + /** 状态+状态码 → 人类可读进度(流程卡只读展示;不做任何处理动作)。查不到返回 null。 */
95 public String statusLabel(String sId) { 105 public String statusLabel(String sId) {
96 try { 106 try {
97 List<Map<String, Object>> r = jdbc.queryForList( 107 List<Map<String, Object>> r = jdbc.queryForList(
98 - "SELECT sStatus, sResultMsg, sErrorMsg FROM ai_op_queue WHERE sId=?", sId); 108 + "SELECT sStatus, iStatusCode, sResultMsg, sErrorMsg FROM ai_op_queue WHERE sId=?", sId);
99 if (r.isEmpty()) { 109 if (r.isEmpty()) {
100 return null; 110 return null;
101 } 111 }
102 String st = String.valueOf(r.get(0).get("sStatus")); 112 String st = String.valueOf(r.get(0).get("sStatus"));
  113 + Object codeObj = r.get(0).get("iStatusCode");
  114 + int code = codeObj instanceof Number n ? n.intValue() : 0;
103 String msg = str(r.get(0).get("sResultMsg")); 115 String msg = str(r.get(0).get("sResultMsg"));
104 String err = str(r.get(0).get("sErrorMsg")); 116 String err = str(r.get(0).get("sErrorMsg"));
105 return switch (st) { 117 return switch (st) {
106 - case "confirmed" -> "已提交,等待 ERP 处理"; 118 + case "pending" -> code == 101 ? "ERP 处理中" : "已提交,等待 ERP 处理";
  119 + case "success" -> (code == 201 ? "ERP 已执行成功(此前已执行)" : "ERP 已执行成功")
  120 + + (msg.isBlank() ? "" : ":" + msg);
  121 + case "fail" -> "ERP 执行失败(" + failReason(code) + ")"
  122 + + (err.isBlank() ? (msg.isBlank() ? "" : ":" + msg) : ":" + err);
  123 + // 旧协议兼容读(历史行)
  124 + case "draft", "confirmed" -> "已提交,等待 ERP 处理";
107 case "executing" -> "ERP 处理中"; 125 case "executing" -> "ERP 处理中";
108 case "executed" -> "ERP 已执行成功" + (msg.isBlank() ? "" : ":" + msg); 126 case "executed" -> "ERP 已执行成功" + (msg.isBlank() ? "" : ":" + msg);
109 case "failed" -> "ERP 执行失败" + (err.isBlank() ? (msg.isBlank() ? "" : ":" + msg) : ":" + err); 127 case "failed" -> "ERP 执行失败" + (err.isBlank() ? (msg.isBlank() ? "" : ":" + msg) : ":" + err);
@@ -115,6 +133,22 @@ public class OpService { @@ -115,6 +133,22 @@ public class OpService {
115 } 133 }
116 } 134 }
117 135
  136 + /** fail 状态码 → 错误类型(与 ERP 侧约定的状态码表同源)。 */
  137 + private static String failReason(int code) {
  138 + return switch (code) {
  139 + case 400 -> "载荷非法";
  140 + case 401 -> "身份或权限不足";
  141 + case 404 -> "目标记录不存在";
  142 + case 409 -> "状态冲突";
  143 + case 422 -> "业务校验未通过";
  144 + case 423 -> "记录被锁定";
  145 + case 450 -> "已取消";
  146 + case 504 -> "执行超时";
  147 + case 500 -> "执行异常";
  148 + default -> "code=" + code;
  149 + };
  150 + }
  151 +
118 private static String str(Object o) { 152 private static String str(Object o) {
119 return o == null ? "" : o.toString(); 153 return o == null ? "" : o.toString();
120 } 154 }