Commit 9f1f7fc014f4df7aa8f65590135ffd1fe4471b6d
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
Showing
4 changed files
with
69 additions
and
12 deletions
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 | } |