Commit 203353cfc64764f1f8c2e6d03b647405c9caae20

Authored by zichun
1 parent 541e0041

review fixes: migration sql for sMakePerson; ledger cache no partial rebuild on …

…expired key; preview save name-keyed + empty=abandon-change; previewId atomic claim; transactional batch queue + column truncation; digest zone token-budgeted; window-orphan tool_result trim; convId length cap; readFormData authoritative moduleId + /form/options permission gate (security audit); WRITE_CLAIM state-statement lookahead
sql/migrate_rearch3.sql 0 → 100644
  1 +-- rearch3 部署迁移(对**已有** ai_op_queue 的库执行;新库直接跑 ai_op_queue.sql 即可)。
  2 +-- 与部署同批执行的完整清单:
  3 +-- 1) 本文件(ai_op_queue 加 sMakePerson 双写列——ERP 执行器仍读 sUserId,切换后可删旧列)
  4 +-- 2) sql/ai_chat_event.sql (会话事件账本,新表)
  5 +-- 3) sql/ai_skill.sql (DROP 旧草稿表重建;⚠️ 建表瞬间即切断 war 包技能读取)
  6 +-- 4) sql/ai_skill_seed.sql (默认技能种子,必须与 3 同批执行)
  7 +ALTER TABLE ai_op_queue ADD COLUMN sMakePerson varchar(64) NULL AFTER sUserId;
src/main/java/com/xly/config/TracingChatModelListener.java
@@ -88,17 +88,21 @@ public class TracingChatModelListener implements ChatModelListener { @@ -88,17 +88,21 @@ public class TracingChatModelListener implements ChatModelListener {
88 } 88 }
89 89
90 /** 90 /**
91 - * token 估算校准回路:prompt_eval_count(响应免费自带)vs 本地保守估算。  
92 - * 实际 > 估算 = 估算器不够保守(截头风险);实际+输出逼近 num_ctx = 很可能已被 Ollama 静默截头,都告警。 91 + * token 估算校准回路:prompt_eval_count(响应免费自带)vs 本地保守估算(messages 估算 + 工具 schema
  92 + * 定额 {@code TOOLS_OVERHEAD}——实际 prompt 含工具 schema 与模板,messages 估算必然偏小)。
  93 + * 实际 > 估算+定额 = 保守系数不足(截头风险);实际+输出逼近 num_ctx = 很可能已被 Ollama 静默截头,都告警。
93 */ 94 */
94 private void calibrate(Object est, Integer actualIn, Integer actualOut) { 95 private void calibrate(Object est, Integer actualIn, Integer actualOut) {
95 if (!(est instanceof Integer e) || actualIn == null || actualIn <= 0) { 96 if (!(est instanceof Integer e) || actualIn == null || actualIn <= 0) {
96 return; 97 return;
97 } 98 }
98 - double ratio = actualIn / (double) e;  
99 - log.info("LLM token calib est={} actual={} ratio={}", e, actualIn, String.format("%.2f", ratio));  
100 - if (actualIn > e) {  
101 - log.warn("LLM token 估算偏低(est={} < actual={})——保守系数不足,存在被 num_ctx 截头的风险", e, actualIn); 99 + int budgeted = e + com.xly.service.TokenEstimator.TOOLS_OVERHEAD;
  100 + log.info("LLM token calib est={}+{} actual={} ratio={}", e,
  101 + com.xly.service.TokenEstimator.TOOLS_OVERHEAD, actualIn,
  102 + String.format("%.2f", actualIn / (double) budgeted));
  103 + if (actualIn > budgeted) {
  104 + log.warn("LLM token 估算偏低(est+overhead={} < actual={})——保守系数不足,存在被 num_ctx 截头的风险",
  105 + budgeted, actualIn);
102 } 106 }
103 int total = actualIn + (actualOut == null ? 0 : actualOut); 107 int total = actualIn + (actualOut == null ? 0 : actualOut);
104 if (total > contextLength * 0.95) { 108 if (total > contextLength * 0.95) {
src/main/java/com/xly/service/ConversationService.java
@@ -55,8 +55,10 @@ public class ConversationService { @@ -55,8 +55,10 @@ public class ConversationService {
55 return raw; 55 return raw;
56 } 56 }
57 String local = raw.isEmpty() ? "default" : raw.replace(':', '_'); 57 String local = raw.isEmpty() ? "default" : raw.replace(':', '_');
58 - if (local.length() > 64) {  
59 - local = local.substring(0, 64); 58 + // 总长必须装进 ai_chat_event/ai_op_queue 的 sConversationId varchar(96)(uid 可能是 36 位 uuid)
  59 + int max = Math.min(64, Math.max(1, 96 - uid.length() - 1));
  60 + if (local.length() > max) {
  61 + local = local.substring(0, max);
60 } 62 }
61 return uid + ":" + local; 63 return uid + ":" + local;
62 } 64 }
src/main/java/com/xly/service/EventProjectionService.java
@@ -49,7 +49,7 @@ public class EventProjectionService { @@ -49,7 +49,7 @@ public class EventProjectionService {
49 /** 模型输出预留(num_ctx 覆盖 prompt+生成)。 */ 49 /** 模型输出预留(num_ctx 覆盖 prompt+生成)。 */
50 private static final int OUTPUT_RESERVE = 1024; 50 private static final int OUTPUT_RESERVE = 1024;
51 /** 工具 schema(随每次请求发送、不在 messages 里)的估算开销。 */ 51 /** 工具 schema(随每次请求发送、不在 messages 里)的估算开销。 */
52 - private static final int TOOLS_OVERHEAD = 2000; 52 + private static final int TOOLS_OVERHEAD = TokenEstimator.TOOLS_OVERHEAD;
53 /** 当前轮增长的乐观小额预留(每次工具往返都会重投影,超出时反应式收缩⑤)。 */ 53 /** 当前轮增长的乐观小额预留(每次工具往返都会重投影,超出时反应式收缩⑤)。 */
54 private static final int TURN_RESERVE = 512; 54 private static final int TURN_RESERVE = 512;
55 /** ④ 摘要区 token 上限。 */ 55 /** ④ 摘要区 token 上限。 */
@@ -65,6 +65,7 @@ public class EventProjectionService { @@ -65,6 +65,7 @@ public class EventProjectionService {
65 /** opId → 人类可读状态行(Phase C:流程卡只读展示 ai_op_queue.sStatus)。可空(测试/未装配)。 */ 65 /** opId → 人类可读状态行(Phase C:流程卡只读展示 ai_op_queue.sStatus)。可空(测试/未装配)。 */
66 private volatile UnaryOperator<String> opStatusLookup; 66 private volatile UnaryOperator<String> opStatusLookup;
67 67
  68 + @org.springframework.beans.factory.annotation.Autowired
68 public EventProjectionService(ObjectMapper mapper, @Value("${llm.context-length:16384}") int contextLength) { 69 public EventProjectionService(ObjectMapper mapper, @Value("${llm.context-length:16384}") int contextLength) {
69 this.mapper = mapper; 70 this.mapper = mapper;
70 this.contextLength = contextLength; 71 this.contextLength = contextLength;
@@ -88,10 +89,8 @@ public class EventProjectionService { @@ -88,10 +89,8 @@ public class EventProjectionService {
88 /** 四段式 LLM 上下文(token 记账)。systemText 为空时不出 system 消息(如测试)。 */ 89 /** 四段式 LLM 上下文(token 记账)。systemText 为空时不出 system 消息(如测试)。 */
89 public List<ChatMessage> project(String systemText, List<Map<String, Object>> events) { 90 public List<ChatMessage> project(String systemText, List<Map<String, Object>> events) {
90 int budget = promptBudget(); 91 int budget = promptBudget();
91 - List<List<Map<String, Object>>> turns = groupTurns(events);  
92 - int current = turns.size() - 1;  
93 92
94 - // ①② system + 流程卡(永不让位) 93 + // ①② system + 流程卡(永不让位)——流程卡用**未裁剪**事件流推导(skill_active 可能在窗口头部)
95 SystemMessage sys = null; 94 SystemMessage sys = null;
96 int sysEst = 0; 95 int sysEst = 0;
97 if (systemText != null && !systemText.isBlank()) { 96 if (systemText != null && !systemText.isBlank()) {
@@ -101,6 +100,22 @@ public class EventProjectionService { @@ -101,6 +100,22 @@ public class EventProjectionService {
101 sysEst = TokenEstimator.estimate(sys); 100 sysEst = TokenEstimator.estimate(sys);
102 } 101 }
103 102
  103 + // LLM 事件窗口可能从轮中间切断:丢弃首个用户事件之前的残留,
  104 + // 避免渲染出无配对 tool_call 的孤儿 tool_result(OpenAI 兼容端的非法消息序列)
  105 + int firstUser = -1;
  106 + for (int i = 0; i < events.size(); i++) {
  107 + String t = str(events.get(i).get("type"));
  108 + if ("user".equals(t) || "form_submit".equals(t)) {
  109 + firstUser = i;
  110 + break;
  111 + }
  112 + }
  113 + if (firstUser > 0) {
  114 + events = events.subList(firstUser, events.size());
  115 + }
  116 + List<List<Map<String, Object>>> turns = groupTurns(events);
  117 + int current = turns.size() - 1;
  118 +
104 // 当前轮:原样保真;自身超限时轮内让位 119 // 当前轮:原样保真;自身超限时轮内让位
105 List<ChatMessage> currentMsgs = new ArrayList<>(); 120 List<ChatMessage> currentMsgs = new ArrayList<>();
106 int curEst = 0; 121 int curEst = 0;
@@ -125,14 +140,23 @@ public class EventProjectionService { @@ -125,14 +140,23 @@ public class EventProjectionService {
125 firstVerbatim = fillRecent(oldEst, current, remaining, DIGEST_BUDGET); 140 firstVerbatim = fillRecent(oldEst, current, remaining, DIGEST_BUDGET);
126 } 141 }
127 142
128 - // ④ 摘要行 143 + // ④ 摘要行:从最新旧轮往回装,按 token 预算与行数双上限收拢(超出的更早轮记入 omitted)
129 List<String> digestLines = new ArrayList<>(); 144 List<String> digestLines = new ArrayList<>();
130 - int digestFrom = Math.max(0, firstVerbatim - DIGEST_TURNS_MAX);  
131 - for (int i = digestFrom; i < firstVerbatim; i++) { 145 + int digestFrom = firstVerbatim;
  146 + int digestTokens = 0;
  147 + for (int i = firstVerbatim - 1; i >= 0 && firstVerbatim - i <= DIGEST_TURNS_MAX; i--) {
132 String line = digestLine(turns.get(i)); 148 String line = digestLine(turns.get(i));
133 - if (!line.isBlank()) {  
134 - digestLines.add(line); 149 + if (line.isBlank()) {
  150 + digestFrom = i;
  151 + continue;
  152 + }
  153 + int t = TokenEstimator.estimate(line) + 2;
  154 + if (digestTokens + t > DIGEST_BUDGET) {
  155 + break;
135 } 156 }
  157 + digestTokens += t;
  158 + digestLines.add(0, line);
  159 + digestFrom = i;
136 } 160 }
137 int omitted = digestFrom; 161 int omitted = digestFrom;
138 162
src/main/java/com/xly/service/FormResolverService.java
@@ -56,6 +56,41 @@ public class FormResolverService { @@ -56,6 +56,41 @@ public class FormResolverService {
56 } 56 }
57 } 57 }
58 58
  59 + /**
  60 + * formId → 其权威 moduleId(表单目录)。鉴权必须用它而不是调用方(模型)自报的 moduleId——
  61 + * 否则「无权表单的 formId + 有权模块的 moduleId」混搭即可绕过表单级白名单。找不到返回 null。
  62 + */
  63 + public String moduleIdOfForm(String formId) {
  64 + if (isBlank(formId)) {
  65 + return null;
  66 + }
  67 + return queryOne("SELECT sModuleId FROM viw_ai_useful_forms WHERE sFormId=? LIMIT 1", formId.trim());
  68 + }
  69 +
  70 + /**
  71 + * 该 FK 目标表是否被「当前用户有权的某张表单」引用(供 /form/options 鉴权):
  72 + * 仅登录不够——否则无任何表单权限的用户也能分页翻完客户名录。管理员(白名单 null)恒可。
  73 + */
  74 + public boolean fkTableAccessible(String fkTable, com.xly.agent.AgentIdentity identity) {
  75 + if (isBlank(fkTable) || identity == null) {
  76 + return false;
  77 + }
  78 + try {
  79 + List<Map<String, Object>> rows = jdbc.queryForList(
  80 + "SELECT DISTINCT af.sModuleId FROM viw_kg_field_dict fd " +
  81 + "JOIN viw_ai_useful_forms af ON af.sDataSource = fd.sTable " +
  82 + "WHERE fd.sFkTable=?", fkTable.trim());
  83 + for (Map<String, Object> r : rows) {
  84 + Object m = r.get("sModuleId");
  85 + if (m != null && identity.canAccessModule(m.toString())) {
  86 + return true;
  87 + }
  88 + }
  89 + } catch (Exception ignore) {
  90 + }
  91 + return false;
  92 + }
  93 +
59 /** 表单数据源表 -> 字段中文名(字段字典),用于把技术列名渲染为中文表头。 */ 94 /** 表单数据源表 -> 字段中文名(字段字典),用于把技术列名渲染为中文表头。 */
60 public Map<String, String> fieldLabels(String formId) { 95 public Map<String, String> fieldLabels(String formId) {
61 Map<String, String> m = new HashMap<>(); 96 Map<String, String> m = new HashMap<>();
src/main/java/com/xly/service/LedgerService.java
@@ -76,14 +76,14 @@ public class LedgerService { @@ -76,14 +76,14 @@ public class LedgerService {
76 log.warn("ledger serialize failed (conv={}, type={}): {}", convId, type, e.getMessage()); 76 log.warn("ledger serialize failed (conv={}, type={}): {}", convId, type, e.getMessage());
77 return; 77 return;
78 } 78 }
79 - persist(convId, type, json, who);  
80 - cache(convId, json); 79 + boolean persisted = persist(convId, type, json, who);
  80 + cache(convId, json, persisted);
81 } 81 }
82 82
83 - /** MySQL 权威落库:服务端赋 iSeq/iTurn,唯一索引 + 重试防并发撞号。失败仅告警(Redis 兜底)。 */  
84 - private void persist(String convId, String type, String json, AgentIdentity who) { 83 + /** MySQL 权威落库:服务端赋 iSeq/iTurn,唯一索引 + 重试防并发撞号。失败仅告警(Redis 兜底),返回是否成功。 */
  84 + private boolean persist(String convId, String type, String json, AgentIdentity who) {
85 if (jdbc == null) { 85 if (jdbc == null) {
86 - return; 86 + return false;
87 } 87 }
88 String maker = who != null && who.userId() != null ? who.userId() : userIdOf(convId); 88 String maker = who != null && who.userId() != null ? who.userId() : userIdOf(convId);
89 String brands = who == null ? null : who.brandsId(); 89 String brands = who == null ? null : who.brandsId();
@@ -108,20 +108,29 @@ public class LedgerService { @@ -108,20 +108,29 @@ public class LedgerService {
108 "INSERT INTO ai_chat_event(sConversationId,iSeq,iTurn,sMakePerson,sBrandsId,sSubsidiaryId,sType,sPayload,tCreateDate) " 108 "INSERT INTO ai_chat_event(sConversationId,iSeq,iTurn,sMakePerson,sBrandsId,sSubsidiaryId,sType,sPayload,tCreateDate) "
109 + "VALUES(?,?,?,?,?,?,?,?,NOW())", 109 + "VALUES(?,?,?,?,?,?,?,?,NOW())",
110 convId, seq, turn, maker, brands, sub, type, json); 110 convId, seq, turn, maker, brands, sub, type, json);
111 - return; 111 + return true;
112 } catch (org.springframework.dao.DuplicateKeyException dup) { 112 } catch (org.springframework.dao.DuplicateKeyException dup) {
113 // 并发撞号:重读末行再试 113 // 并发撞号:重读末行再试
114 } catch (Exception e) { 114 } catch (Exception e) {
115 log.warn("ledger mysql append failed (conv={}, type={}): {}", convId, type, e.getMessage()); 115 log.warn("ledger mysql append failed (conv={}, type={}): {}", convId, type, e.getMessage());
116 - return; 116 + return false;
117 } 117 }
118 } 118 }
119 log.warn("ledger mysql append gave up after retries (conv={}, type={})", convId, type); 119 log.warn("ledger mysql append gave up after retries (conv={}, type={})", convId, type);
  120 + return false;
120 } 121 }
121 122
122 - private void cache(String convId, String json) { 123 + /**
  124 + * 热缓存追加。**缓存键已失效时不 push**(persisted=true 时)——单条新事件重建的会是
  125 + * 残缺列表且此后永不回源;留给下次读取从 MySQL 冷读回填完整历史(含本条)。
  126 + * MySQL 落库失败时(persisted=false)无论如何都 push,宁可残缺不可丢事件。
  127 + */
  128 + private void cache(String convId, String json, boolean persisted) {
123 try { 129 try {
124 String key = PREFIX + convId; 130 String key = PREFIX + convId;
  131 + if (persisted && !Boolean.TRUE.equals(redis.hasKey(key))) {
  132 + return;
  133 + }
125 redis.opsForList().rightPush(key, json); 134 redis.opsForList().rightPush(key, json);
126 redis.expire(key, TTL); 135 redis.expire(key, TTL);
127 } catch (Exception e) { 136 } catch (Exception e) {
src/main/java/com/xly/service/OpService.java
@@ -27,6 +27,9 @@ public class OpService { @@ -27,6 +27,9 @@ public class OpService {
27 this.jdbc = jdbc; 27 this.jdbc = jdbc;
28 } 28 }
29 29
  30 + /** 一次保存里的一个字段改动(批量入队用)。 */
  31 + public record FieldChange(String col, String label, String oldValue, String newValue, String description) { }
  32 +
30 /** 单字段更新入队(update)。 */ 33 /** 单字段更新入队(update)。 */
31 public String queueUpdate(AgentIdentity who, String convId, String formId, String moduleId, String table, 34 public String queueUpdate(AgentIdentity who, String convId, String formId, String moduleId, String table,
32 String billId, String field, String fieldLabel, String oldValue, String newValue, 35 String billId, String field, String fieldLabel, String oldValue, String newValue,
@@ -35,6 +38,21 @@ public class OpService { @@ -35,6 +38,21 @@ public class OpService {
35 field, fieldLabel, oldValue, newValue, null, description); 38 field, fieldLabel, oldValue, newValue, null, description);
36 } 39 }
37 40
  41 + /**
  42 + * 多字段更新批量入队(单事务:全部成功或全部回滚,绝不"写一半")。
  43 + * ERP 执行器为单字段 update,故一次保存多字段 = 多行待办。
  44 + */
  45 + @org.springframework.transaction.annotation.Transactional
  46 + public List<String> queueUpdates(AgentIdentity who, String convId, String formId, String moduleId,
  47 + String table, String billId, List<FieldChange> changes) {
  48 + List<String> opIds = new java.util.ArrayList<>(changes.size());
  49 + for (FieldChange c : changes) {
  50 + opIds.add(insert(who, convId, "update", formId, moduleId, table, billId,
  51 + c.col(), c.label(), c.oldValue(), c.newValue(), null, c.description()));
  52 + }
  53 + return opIds;
  54 + }
  55 +
38 /** 状态操作入队:invalid(sNewValue=toVoid|cancel)/ examine(sNewValue=1|0)/ delete。 */ 56 /** 状态操作入队:invalid(sNewValue=toVoid|cancel)/ examine(sNewValue=1|0)/ delete。 */
39 public String queueStateOp(AgentIdentity who, String convId, String opType, String formId, String moduleId, 57 public String queueStateOp(AgentIdentity who, String convId, String opType, String formId, String moduleId,
40 String table, String billId, String sNewValue, String description) { 58 String table, String billId, String sNewValue, String description) {
@@ -59,11 +77,15 @@ public class OpService { @@ -59,11 +77,15 @@ public class OpService {
59 "sOldValue,sNewValue,sPayload,sDescription,sStatus,tCreateDate,tConfirmDate) " + 77 "sOldValue,sNewValue,sPayload,sDescription,sStatus,tCreateDate,tConfirmDate) " +
60 "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'confirmed',NOW(),NOW())", 78 "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'confirmed',NOW(),NOW())",
61 sId, who.userId(), who.userId(), who.brandsId(), who.subsidiaryId(), convId, opType, 79 sId, who.userId(), who.userId(), who.brandsId(), who.subsidiaryId(), convId, opType,
62 - formId, moduleId, table, billId, field, fieldLabel,  
63 - oldValue, newValue, payload, description); 80 + formId, moduleId, table, billId, field, trunc(fieldLabel, 128),
  81 + trunc(oldValue, 500), trunc(newValue, 500), payload, trunc(description, 500));
64 return sId; 82 return sId;
65 } 83 }
66 84
  85 + private static String trunc(String s, int max) {
  86 + return s != null && s.length() > max ? s.substring(0, max) : s;
  87 + }
  88 +
67 public Map<String, Object> get(String sId) { 89 public Map<String, Object> get(String sId) {
68 List<Map<String, Object>> r = jdbc.queryForList("SELECT * FROM ai_op_queue WHERE sId=?", sId); 90 List<Map<String, Object>> r = jdbc.queryForList("SELECT * FROM ai_op_queue WHERE sId=?", sId);
69 return r.isEmpty() ? null : r.get(0); 91 return r.isEmpty() ? null : r.get(0);
src/main/java/com/xly/service/PreviewService.java
@@ -280,9 +280,10 @@ public class PreviewService { @@ -280,9 +280,10 @@ public class PreviewService {
280 // ---------------------------------------------------------------- 保存 280 // ---------------------------------------------------------------- 保存
281 281
282 /** 282 /**
283 - * 用户点卡上按钮 → 确定性保存:previewId 取回 → 归属/权限 → 重读重校验 → 写 ai_op_queue。  
284 - * editedFields = 卡上(可编辑预览)用户最终确认的 label→值;状态类传 null。  
285 - * 返回 {queued,opIds,description,message} 或 {error}。 283 + * 用户点卡上按钮 → 确定性保存:previewId 取回 → 归属/权限 → 重读重校验 → **claim 抢占**(删 previewId
  284 + * 的原子 DEL,防并发/重复提交入队两次)→ 写 ai_op_queue。
  285 + * editedFields = 卡上(可编辑预览)用户最终确认的 字段技术名→值;状态类传 null。
  286 + * 返回 {queued,opIds,description,message} 或 {error}(校验失败时预览保留,可修正后重试)。
286 */ 287 */
287 public Map<String, Object> save(AgentIdentity who, String previewId, Map<String, String> editedFields) { 288 public Map<String, Object> save(AgentIdentity who, String previewId, Map<String, String> editedFields) {
288 Map<String, Object> out = new LinkedHashMap<>(); 289 Map<String, Object> out = new LinkedHashMap<>();
@@ -308,43 +309,45 @@ public class PreviewService { @@ -308,43 +309,45 @@ public class PreviewService {
308 out.put("error", cur.error + " 请重新预览。"); 309 out.put("error", cur.error + " 请重新预览。");
309 return out; 310 return out;
310 } 311 }
311 - Map<String, Object> result = "update".equals(action)  
312 - ? saveUpdate(who, draft, editedFields, cur, convId)  
313 - : saveStateOp(who, draft, cur, convId, action);  
314 - if (!result.containsKey("error")) {  
315 - discard(previewId); // 一次性:保存成功后预览失效  
316 - }  
317 - return result; 312 + return "update".equals(action)
  313 + ? saveUpdate(who, draft, editedFields, cur, convId, previewId)
  314 + : saveStateOp(who, draft, cur, convId, action, previewId);
318 } 315 }
319 316
320 - @SuppressWarnings("unchecked") 317 + /** 入队多字段更新的一次改动。 */
  318 + private record Change(String col, String label, String wasShown, Object stored, String shown) { }
  319 +
321 private Map<String, Object> saveUpdate(AgentIdentity who, JsonNode draft, Map<String, String> editedFields, 320 private Map<String, Object> saveUpdate(AgentIdentity who, JsonNode draft, Map<String, String> editedFields,
322 - FormRenderService.Located cur, String convId) { 321 + FormRenderService.Located cur, String convId, String previewId) {
323 Map<String, Object> out = new LinkedHashMap<>(); 322 Map<String, Object> out = new LinkedHashMap<>();
324 String table = draft.path("table").asText(""); 323 String table = draft.path("table").asText("");
325 String recordName = draft.path("recordName").asText(""); 324 String recordName = draft.path("recordName").asText("");
326 JsonNode snapshotRaw = draft.path("snapshotRaw"); 325 JsonNode snapshotRaw = draft.path("snapshotRaw");
327 JsonNode snapshotShown = draft.path("snapshotShown"); 326 JsonNode snapshotShown = draft.path("snapshotShown");
328 String changedCol = draft.path("changedCol").asText(""); 327 String changedCol = draft.path("changedCol").asText("");
329 - String changedLabel = draft.path("changedLabel").asText("");  
330 String changedNewShown = draft.path("changedNewShown").asText(""); 328 String changedNewShown = draft.path("changedNewShown").asText("");
331 329
332 - // 期望的最终值:预览高亮的改动 + 用户在卡上继续修正的字段 330 + // 期望的最终值:预览高亮的改动 + 用户在卡上继续修正的字段。
  331 + // 键 = 字段**技术名**(同表两列同中文名时 label 键会串写出"幻影更新")。
  332 + // 值为空 = 放弃该字段的改动(含模型提议的那个改动)——卡上无法把字段改成空值,属已知取舍。
333 Map<String, String> desired = new LinkedHashMap<>(); 333 Map<String, String> desired = new LinkedHashMap<>();
334 desired.put(changedCol, changedNewShown); 334 desired.put(changedCol, changedNewShown);
335 if (editedFields != null && !editedFields.isEmpty()) { 335 if (editedFields != null && !editedFields.isEmpty()) {
336 for (JsonNode meta : draft.path("fieldsMeta")) { 336 for (JsonNode meta : draft.path("fieldsMeta")) {
337 String name = meta.path("name").asText(""); 337 String name = meta.path("name").asText("");
338 - String label = meta.path("label").asText("");  
339 - String v = editedFields.get(label);  
340 - if (v != null) { 338 + if (!editedFields.containsKey(name)) {
  339 + continue;
  340 + }
  341 + String v = editedFields.get(name);
  342 + if (v == null || v.isBlank()) {
  343 + desired.remove(name);
  344 + } else {
341 desired.put(name, v); 345 desired.put(name, v);
342 } 346 }
343 } 347 }
344 } 348 }
345 349
346 // 第一遍:全部改动先过校验与冲突检查(全通过才入队,避免写一半) 350 // 第一遍:全部改动先过校验与冲突检查(全通过才入队,避免写一半)
347 - record Change(String col, String label, String wasShown, Object stored, String shown) { }  
348 List<Change> changes = new ArrayList<>(); 351 List<Change> changes = new ArrayList<>();
349 for (JsonNode meta : draft.path("fieldsMeta")) { 352 for (JsonNode meta : draft.path("fieldsMeta")) {
350 String col = meta.path("name").asText(""); 353 String col = meta.path("name").asText("");
@@ -382,22 +385,34 @@ public class PreviewService { @@ -382,22 +385,34 @@ public class PreviewService {
382 return out; 385 return out;
383 } 386 }
384 387
385 - String dryErr = dryRunUpdate(who, draft, desired, cur); 388 + String dryErr = dryRunUpdate(who, draft, changes);
386 if (dryErr != null) { 389 if (dryErr != null) {
387 out.put("error", dryErr); 390 out.put("error", dryErr);
388 return out; 391 return out;
389 } 392 }
390 393
391 - // 第二遍:逐字段入队(ERP 执行器为单字段 update;一次保存多字段 = 多行待办)  
392 - List<String> opIds = new ArrayList<>(); 394 + // claim(原子 DEL):并发/重复点保存只有一个能入队
  395 + if (!claim(previewId)) {
  396 + out.put("error", "该预览已提交过或已失效,请勿重复提交。");
  397 + return out;
  398 + }
  399 + // 第二遍:批量入队(单事务;ERP 执行器为单字段 update,一次保存多字段 = 多行待办)
  400 + List<OpService.FieldChange> fcs = new ArrayList<>();
393 List<String> descs = new ArrayList<>(); 401 List<String> descs = new ArrayList<>();
394 for (Change c : changes) { 402 for (Change c : changes) {
395 String desc = "将【" + recordName + "】的【" + c.label() + "】" 403 String desc = "将【" + recordName + "】的【" + c.label() + "】"
396 + (c.wasShown().isBlank() ? "" : ("由「" + c.wasShown() + "」")) + "改为「" + c.shown() + "」"; 404 + (c.wasShown().isBlank() ? "" : ("由「" + c.wasShown() + "」")) + "改为「" + c.shown() + "」";
397 descs.add(desc); 405 descs.add(desc);
398 - opIds.add(ops.queueUpdate(who, convId, draft.path("formId").asText(""),  
399 - draft.path("moduleId").asText(""), table, draft.path("billId").asText(""),  
400 - c.col(), c.label(), c.wasShown(), String.valueOf(c.stored()), desc)); 406 + fcs.add(new OpService.FieldChange(c.col(), c.label(), c.wasShown(), String.valueOf(c.stored()), desc));
  407 + }
  408 + List<String> opIds;
  409 + try {
  410 + opIds = ops.queueUpdates(who, convId, draft.path("formId").asText(""),
  411 + draft.path("moduleId").asText(""), table, draft.path("billId").asText(""), fcs);
  412 + } catch (Exception e) {
  413 + log.warn("queue updates failed (conv={}): {}", convId, e.getMessage());
  414 + out.put("error", "提交待办失败:" + e.getMessage() + ",请重新预览。");
  415 + return out;
401 } 416 }
402 String description = String.join(";", descs); 417 String description = String.join(";", descs);
403 recordQueued(convId, who, opIds, description); 418 recordQueued(convId, who, opIds, description);
@@ -409,7 +424,7 @@ public class PreviewService { @@ -409,7 +424,7 @@ public class PreviewService {
409 } 424 }
410 425
411 private Map<String, Object> saveStateOp(AgentIdentity who, JsonNode draft, FormRenderService.Located cur, 426 private Map<String, Object> saveStateOp(AgentIdentity who, JsonNode draft, FormRenderService.Located cur,
412 - String convId, String action) { 427 + String convId, String action, String previewId) {
413 Map<String, Object> out = new LinkedHashMap<>(); 428 Map<String, Object> out = new LinkedHashMap<>();
414 String table = draft.path("table").asText(""); 429 String table = draft.path("table").asText("");
415 Map<String, String> types = resolver.columnTypes(table); 430 Map<String, String> types = resolver.columnTypes(table);
@@ -443,6 +458,11 @@ public class PreviewService { @@ -443,6 +458,11 @@ public class PreviewService {
443 return out; 458 return out;
444 } 459 }
445 } 460 }
  461 + // claim(原子 DEL):并发/重复点按钮只有一个能入队——重复审核/作废的副作用正是状态检查要防的
  462 + if (!claim(previewId)) {
  463 + out.put("error", "该预览已提交过或已失效,请勿重复提交。");
  464 + return out;
  465 + }
446 String description = draft.path("description").asText(""); 466 String description = draft.path("description").asText("");
447 String opId = ops.queueStateOp(who, convId, opType, draft.path("formId").asText(""), 467 String opId = ops.queueStateOp(who, convId, opType, draft.path("formId").asText(""),
448 draft.path("moduleId").asText(""), table, draft.path("billId").asText(""), sNewValue, description); 468 draft.path("moduleId").asText(""), table, draft.path("billId").asText(""), sNewValue, description);
@@ -498,15 +518,17 @@ public class PreviewService { @@ -498,15 +518,17 @@ public class PreviewService {
498 return null; 518 return null;
499 } 519 }
500 520
501 - private String dryRunUpdate(AgentIdentity who, JsonNode draft, Map<String, String> desired,  
502 - FormRenderService.Located cur) { 521 + private String dryRunUpdate(AgentIdentity who, JsonNode draft, List<Change> changes) {
503 if (!dryRunEnabled) { 522 if (!dryRunEnabled) {
504 return null; 523 return null;
505 } 524 }
506 try { 525 try {
  526 + // 与真实入队完全同口径:只发改动列,值用规范化后的入库值(FK=id、数字已 coerce)
507 Map<String, Object> col = new LinkedHashMap<>(); 527 Map<String, Object> col = new LinkedHashMap<>();
508 col.put("sId", draft.path("billId").asText("")); 528 col.put("sId", draft.path("billId").asText(""));
509 - desired.forEach(col::put); 529 + for (Change c : changes) {
  530 + col.put(c.col(), c.stored());
  531 + }
510 JsonNode r = erp.checkBusinessData(who.token(), draft.path("moduleId").asText(""), 532 JsonNode r = erp.checkBusinessData(who.token(), draft.path("moduleId").asText(""),
511 draft.path("table").asText(""), mapper.writeValueAsString(col), "update"); 533 draft.path("table").asText(""), mapper.writeValueAsString(col), "update");
512 if (r != null && r.path("code").asInt(1) != 1) { 534 if (r != null && r.path("code").asInt(1) != 1) {
@@ -665,10 +687,12 @@ public class PreviewService { @@ -665,10 +687,12 @@ public class PreviewService {
665 } 687 }
666 } 688 }
667 689
668 - private void discard(String previewId) { 690 + /** 原子 DEL 抢占:只有真正删掉 key 的那个请求返回 true——并发/重复保存只有一个能入队。 */
  691 + private boolean claim(String previewId) {
669 try { 692 try {
670 - redis.delete(KEY_PREFIX + previewId);  
671 - } catch (Exception ignore) { 693 + return Boolean.TRUE.equals(redis.delete(KEY_PREFIX + previewId));
  694 + } catch (Exception e) {
  695 + return false;
672 } 696 }
673 } 697 }
674 698
src/main/java/com/xly/service/TokenEstimator.java
@@ -21,6 +21,8 @@ public final class TokenEstimator { @@ -21,6 +21,8 @@ public final class TokenEstimator {
21 21
22 /** 每条消息的结构开销(role/分隔符等)。 */ 22 /** 每条消息的结构开销(role/分隔符等)。 */
23 public static final int MSG_OVERHEAD = 8; 23 public static final int MSG_OVERHEAD = 8;
  24 + /** 工具 schema + 对话模板的请求级开销(不在 messages 里;实测 7 工具中文描述约 2200-2400t)。 */
  25 + public static final int TOOLS_OVERHEAD = 2600;
24 26
25 private TokenEstimator() { 27 private TokenEstimator() {
26 } 28 }
src/main/java/com/xly/tool/ErpReadTool.java
@@ -123,7 +123,13 @@ public class ErpReadTool { @@ -123,7 +123,13 @@ public class ErpReadTool {
123 if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) { 123 if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) {
124 return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。"; 124 return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。";
125 } 125 }
126 - if (!identity.canAccessModule(moduleId.trim())) { 126 + // 鉴权与请求都用表单目录反查出的**权威** moduleId——模型自报的 moduleId 只做形参,
  127 + // 否则「无权表单 formId + 有权模块 moduleId」混搭即可绕过表单级白名单。
  128 + String authModule = resolver.moduleIdOfForm(formId.trim());
  129 + if (authModule == null) {
  130 + return "该 formId 不在业务表单目录里,请先用 findForms 检索。";
  131 + }
  132 + if (!identity.canAccessModule(authModule)) {
127 return "你没有访问该表单的权限。"; 133 return "你没有访问该表单的权限。";
128 } 134 }
129 String kw = (keyword == null) ? "" : keyword.trim(); 135 String kw = (keyword == null) ? "" : keyword.trim();
@@ -132,7 +138,7 @@ public class ErpReadTool { @@ -132,7 +138,7 @@ public class ErpReadTool {
132 138
133 JsonNode root; 139 JsonNode root;
134 try { 140 try {
135 - root = erp.readForm(identity.token(), formId.trim(), moduleId.trim(), pageNo, MAX_ROWS, nameField, kw.isEmpty() ? null : kw); 141 + root = erp.readForm(identity.token(), formId.trim(), authModule, pageNo, MAX_ROWS, nameField, kw.isEmpty() ? null : kw);
136 } catch (Exception e) { 142 } catch (Exception e) {
137 return "读取失败:" + e.getMessage(); 143 return "读取失败:" + e.getMessage();
138 } 144 }
src/main/java/com/xly/web/AgentChatController.java
@@ -53,9 +53,10 @@ public class AgentChatController { @@ -53,9 +53,10 @@ public class AgentChatController {
53 53
54 private static final Logger log = LoggerFactory.getLogger(AgentChatController.class); 54 private static final Logger log = LoggerFactory.getLogger(AgentChatController.class);
55 private static final Set<String> CARD_TOOLS = Set.of("previewChange", "askUser", "collectForm"); 55 private static final Set<String> CARD_TOOLS = Set.of("previewChange", "askUser", "collectForm");
56 - /** 反编造护栏:agent 声称写操作已发生的说法(本轮没出过预览卡/表单时要纠正)。 */ 56 + /** 反编造护栏:agent 声称写操作已发生的说法(本轮没出过预览卡/表单时要纠正)。
  57 + * 负向前瞻排除**状态陈述**(「已审核状态」「已作废的单据」是转述记录现状,不是声称动作已完成)。 */
57 private static final Pattern WRITE_CLAIM = 58 private static final Pattern WRITE_CLAIM =
58 - Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核|保存|录入)"); 59 + Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核|保存|录入)(?!状态|的|单据|过)");
59 60
60 private final AgentFactory agentFactory; 61 private final AgentFactory agentFactory;
61 private final AuthzService authz; 62 private final AuthzService authz;
src/main/java/com/xly/web/FormController.java
@@ -45,6 +45,10 @@ public class FormController { @@ -45,6 +45,10 @@ public class FormController {
45 if (id == null) { 45 if (id == null) {
46 throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录"); 46 throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录");
47 } 47 }
  48 + // 仅登录不够:该 FK 表必须被用户有权的某张表单引用,否则无表单权限的用户也能翻完客户名录
  49 + if (!resolver.fkTableAccessible(table, id)) {
  50 + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该数据");
  51 + }
48 return resolver.fkOptionPage(table, id.brandsId(), q, page, pageSize); 52 return resolver.fkOptionPage(table, id.brandsId(), q, page, pageSize);
49 } 53 }
50 } 54 }
src/main/java/com/xly/web/PreviewController.java
@@ -37,7 +37,7 @@ public class PreviewController { @@ -37,7 +37,7 @@ public class PreviewController {
37 } 37 }
38 38
39 public static class SaveReq { 39 public static class SaveReq {
40 - /** 可编辑预览(update)上用户最终确认的 字段中文名->值;状态类操作可空。 */ 40 + /** 可编辑预览(update)上用户最终确认的 字段技术名->值(空值=放弃该字段的改动);状态类操作可空。 */
41 public Map<String, String> fields; 41 public Map<String, String> fields;
42 } 42 }
43 43
src/main/resources/templates/chat.html
@@ -617,7 +617,8 @@ function addPreviewCard(ev) { @@ -617,7 +617,8 @@ function addPreviewCard(ev) {
617 } 617 }
618 if (f.changed && f.value) wrap.appendChild(el("div", "oldval", "原值:" + f.value)); 618 if (f.changed && f.value) wrap.appendChild(el("div", "oldval", "原值:" + f.value));
619 grid.appendChild(wrap); 619 grid.appendChild(wrap);
620 - controls.push({ label: f.label || f.name, get: getter }); 620 + // 键 = 技术列名(同表两列同中文名时 label 键会串写);空值 = 放弃该字段的改动
  621 + controls.push({ name: f.name, get: getter });
621 }); 622 });
622 card.appendChild(grid); 623 card.appendChild(grid);
623 } else { 624 } else {
@@ -654,7 +655,7 @@ function addPreviewCard(ev) { @@ -654,7 +655,7 @@ function addPreviewCard(ev) {
654 result.textContent = "提交中…"; 655 result.textContent = "提交中…";
655 try { 656 try {
656 const fields = {}; 657 const fields = {};
657 - controls.forEach(c => { const v = c.get(); if (v) fields[c.label] = v; }); 658 + controls.forEach(c => { fields[c.name] = c.get(); });
658 const r = await fetch(BASE + "/api/agent/preview/" + encodeURIComponent(ev.previewId) + "/save", 659 const r = await fetch(BASE + "/api/agent/preview/" + encodeURIComponent(ev.previewId) + "/save",
659 { method: "POST", headers: authHeaders(true), body: JSON.stringify({ fields: fields }) }); 660 { method: "POST", headers: authHeaders(true), body: JSON.stringify({ fields: fields }) });
660 const d = await r.json(); 661 const d = await r.json();