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 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 95 private void calibrate(Object est, Integer actualIn, Integer actualOut) {
95 96 if (!(est instanceof Integer e) || actualIn == null || actualIn <= 0) {
96 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 107 int total = actualIn + (actualOut == null ? 0 : actualOut);
104 108 if (total > contextLength * 0.95) {
... ...
src/main/java/com/xly/service/ConversationService.java
... ... @@ -55,8 +55,10 @@ public class ConversationService {
55 55 return raw;
56 56 }
57 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 63 return uid + ":" + local;
62 64 }
... ...
src/main/java/com/xly/service/EventProjectionService.java
... ... @@ -49,7 +49,7 @@ public class EventProjectionService {
49 49 /** 模型输出预留(num_ctx 覆盖 prompt+生成)。 */
50 50 private static final int OUTPUT_RESERVE = 1024;
51 51 /** 工具 schema(随每次请求发送、不在 messages 里)的估算开销。 */
52   - private static final int TOOLS_OVERHEAD = 2000;
  52 + private static final int TOOLS_OVERHEAD = TokenEstimator.TOOLS_OVERHEAD;
53 53 /** 当前轮增长的乐观小额预留(每次工具往返都会重投影,超出时反应式收缩⑤)。 */
54 54 private static final int TURN_RESERVE = 512;
55 55 /** ④ 摘要区 token 上限。 */
... ... @@ -65,6 +65,7 @@ public class EventProjectionService {
65 65 /** opId → 人类可读状态行(Phase C:流程卡只读展示 ai_op_queue.sStatus)。可空(测试/未装配)。 */
66 66 private volatile UnaryOperator<String> opStatusLookup;
67 67  
  68 + @org.springframework.beans.factory.annotation.Autowired
68 69 public EventProjectionService(ObjectMapper mapper, @Value("${llm.context-length:16384}") int contextLength) {
69 70 this.mapper = mapper;
70 71 this.contextLength = contextLength;
... ... @@ -88,10 +89,8 @@ public class EventProjectionService {
88 89 /** 四段式 LLM 上下文(token 记账)。systemText 为空时不出 system 消息(如测试)。 */
89 90 public List<ChatMessage> project(String systemText, List<Map<String, Object>> events) {
90 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 94 SystemMessage sys = null;
96 95 int sysEst = 0;
97 96 if (systemText != null && !systemText.isBlank()) {
... ... @@ -101,6 +100,22 @@ public class EventProjectionService {
101 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 120 List<ChatMessage> currentMsgs = new ArrayList<>();
106 121 int curEst = 0;
... ... @@ -125,14 +140,23 @@ public class EventProjectionService {
125 140 firstVerbatim = fillRecent(oldEst, current, remaining, DIGEST_BUDGET);
126 141 }
127 142  
128   - // ④ 摘要行
  143 + // ④ 摘要行:从最新旧轮往回装,按 token 预算与行数双上限收拢(超出的更早轮记入 omitted)
129 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 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 161 int omitted = digestFrom;
138 162  
... ...
src/main/java/com/xly/service/FormResolverService.java
... ... @@ -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 95 public Map<String, String> fieldLabels(String formId) {
61 96 Map<String, String> m = new HashMap<>();
... ...
src/main/java/com/xly/service/LedgerService.java
... ... @@ -76,14 +76,14 @@ public class LedgerService {
76 76 log.warn("ledger serialize failed (conv={}, type={}): {}", convId, type, e.getMessage());
77 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 85 if (jdbc == null) {
86   - return;
  86 + return false;
87 87 }
88 88 String maker = who != null && who.userId() != null ? who.userId() : userIdOf(convId);
89 89 String brands = who == null ? null : who.brandsId();
... ... @@ -108,20 +108,29 @@ public class LedgerService {
108 108 "INSERT INTO ai_chat_event(sConversationId,iSeq,iTurn,sMakePerson,sBrandsId,sSubsidiaryId,sType,sPayload,tCreateDate) "
109 109 + "VALUES(?,?,?,?,?,?,?,?,NOW())",
110 110 convId, seq, turn, maker, brands, sub, type, json);
111   - return;
  111 + return true;
112 112 } catch (org.springframework.dao.DuplicateKeyException dup) {
113 113 // 并发撞号:重读末行再试
114 114 } catch (Exception e) {
115 115 log.warn("ledger mysql append failed (conv={}, type={}): {}", convId, type, e.getMessage());
116   - return;
  116 + return false;
117 117 }
118 118 }
119 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 129 try {
124 130 String key = PREFIX + convId;
  131 + if (persisted && !Boolean.TRUE.equals(redis.hasKey(key))) {
  132 + return;
  133 + }
125 134 redis.opsForList().rightPush(key, json);
126 135 redis.expire(key, TTL);
127 136 } catch (Exception e) {
... ...
src/main/java/com/xly/service/OpService.java
... ... @@ -27,6 +27,9 @@ public class OpService {
27 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 33 /** 单字段更新入队(update)。 */
31 34 public String queueUpdate(AgentIdentity who, String convId, String formId, String moduleId, String table,
32 35 String billId, String field, String fieldLabel, String oldValue, String newValue,
... ... @@ -35,6 +38,21 @@ public class OpService {
35 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 56 /** 状态操作入队:invalid(sNewValue=toVoid|cancel)/ examine(sNewValue=1|0)/ delete。 */
39 57 public String queueStateOp(AgentIdentity who, String convId, String opType, String formId, String moduleId,
40 58 String table, String billId, String sNewValue, String description) {
... ... @@ -59,11 +77,15 @@ public class OpService {
59 77 "sOldValue,sNewValue,sPayload,sDescription,sStatus,tCreateDate,tConfirmDate) " +
60 78 "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'confirmed',NOW(),NOW())",
61 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 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 89 public Map<String, Object> get(String sId) {
68 90 List<Map<String, Object>> r = jdbc.queryForList("SELECT * FROM ai_op_queue WHERE sId=?", sId);
69 91 return r.isEmpty() ? null : r.get(0);
... ...
src/main/java/com/xly/service/PreviewService.java
... ... @@ -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 288 public Map<String, Object> save(AgentIdentity who, String previewId, Map<String, String> editedFields) {
288 289 Map<String, Object> out = new LinkedHashMap<>();
... ... @@ -308,43 +309,45 @@ public class PreviewService {
308 309 out.put("error", cur.error + " 请重新预览。");
309 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 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 322 Map<String, Object> out = new LinkedHashMap<>();
324 323 String table = draft.path("table").asText("");
325 324 String recordName = draft.path("recordName").asText("");
326 325 JsonNode snapshotRaw = draft.path("snapshotRaw");
327 326 JsonNode snapshotShown = draft.path("snapshotShown");
328 327 String changedCol = draft.path("changedCol").asText("");
329   - String changedLabel = draft.path("changedLabel").asText("");
330 328 String changedNewShown = draft.path("changedNewShown").asText("");
331 329  
332   - // 期望的最终值:预览高亮的改动 + 用户在卡上继续修正的字段
  330 + // 期望的最终值:预览高亮的改动 + 用户在卡上继续修正的字段。
  331 + // 键 = 字段**技术名**(同表两列同中文名时 label 键会串写出"幻影更新")。
  332 + // 值为空 = 放弃该字段的改动(含模型提议的那个改动)——卡上无法把字段改成空值,属已知取舍。
333 333 Map<String, String> desired = new LinkedHashMap<>();
334 334 desired.put(changedCol, changedNewShown);
335 335 if (editedFields != null && !editedFields.isEmpty()) {
336 336 for (JsonNode meta : draft.path("fieldsMeta")) {
337 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 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 351 List<Change> changes = new ArrayList<>();
349 352 for (JsonNode meta : draft.path("fieldsMeta")) {
350 353 String col = meta.path("name").asText("");
... ... @@ -382,22 +385,34 @@ public class PreviewService {
382 385 return out;
383 386 }
384 387  
385   - String dryErr = dryRunUpdate(who, draft, desired, cur);
  388 + String dryErr = dryRunUpdate(who, draft, changes);
386 389 if (dryErr != null) {
387 390 out.put("error", dryErr);
388 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 401 List<String> descs = new ArrayList<>();
394 402 for (Change c : changes) {
395 403 String desc = "将【" + recordName + "】的【" + c.label() + "】"
396 404 + (c.wasShown().isBlank() ? "" : ("由「" + c.wasShown() + "」")) + "改为「" + c.shown() + "」";
397 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 417 String description = String.join(";", descs);
403 418 recordQueued(convId, who, opIds, description);
... ... @@ -409,7 +424,7 @@ public class PreviewService {
409 424 }
410 425  
411 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 428 Map<String, Object> out = new LinkedHashMap<>();
414 429 String table = draft.path("table").asText("");
415 430 Map<String, String> types = resolver.columnTypes(table);
... ... @@ -443,6 +458,11 @@ public class PreviewService {
443 458 return out;
444 459 }
445 460 }
  461 + // claim(原子 DEL):并发/重复点按钮只有一个能入队——重复审核/作废的副作用正是状态检查要防的
  462 + if (!claim(previewId)) {
  463 + out.put("error", "该预览已提交过或已失效,请勿重复提交。");
  464 + return out;
  465 + }
446 466 String description = draft.path("description").asText("");
447 467 String opId = ops.queueStateOp(who, convId, opType, draft.path("formId").asText(""),
448 468 draft.path("moduleId").asText(""), table, draft.path("billId").asText(""), sNewValue, description);
... ... @@ -498,15 +518,17 @@ public class PreviewService {
498 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 522 if (!dryRunEnabled) {
504 523 return null;
505 524 }
506 525 try {
  526 + // 与真实入队完全同口径:只发改动列,值用规范化后的入库值(FK=id、数字已 coerce)
507 527 Map<String, Object> col = new LinkedHashMap<>();
508 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 532 JsonNode r = erp.checkBusinessData(who.token(), draft.path("moduleId").asText(""),
511 533 draft.path("table").asText(""), mapper.writeValueAsString(col), "update");
512 534 if (r != null && r.path("code").asInt(1) != 1) {
... ... @@ -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 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 21  
22 22 /** 每条消息的结构开销(role/分隔符等)。 */
23 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 27 private TokenEstimator() {
26 28 }
... ...
src/main/java/com/xly/tool/ErpReadTool.java
... ... @@ -123,7 +123,13 @@ public class ErpReadTool {
123 123 if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) {
124 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 133 return "你没有访问该表单的权限。";
128 134 }
129 135 String kw = (keyword == null) ? "" : keyword.trim();
... ... @@ -132,7 +138,7 @@ public class ErpReadTool {
132 138  
133 139 JsonNode root;
134 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 142 } catch (Exception e) {
137 143 return "读取失败:" + e.getMessage();
138 144 }
... ...
src/main/java/com/xly/web/AgentChatController.java
... ... @@ -53,9 +53,10 @@ public class AgentChatController {
53 53  
54 54 private static final Logger log = LoggerFactory.getLogger(AgentChatController.class);
55 55 private static final Set<String> CARD_TOOLS = Set.of("previewChange", "askUser", "collectForm");
56   - /** 反编造护栏:agent 声称写操作已发生的说法(本轮没出过预览卡/表单时要纠正)。 */
  56 + /** 反编造护栏:agent 声称写操作已发生的说法(本轮没出过预览卡/表单时要纠正)。
  57 + * 负向前瞻排除**状态陈述**(「已审核状态」「已作废的单据」是转述记录现状,不是声称动作已完成)。 */
57 58 private static final Pattern WRITE_CLAIM =
58   - Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核|保存|录入)");
  59 + Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核|保存|录入)(?!状态|的|单据|过)");
59 60  
60 61 private final AgentFactory agentFactory;
61 62 private final AuthzService authz;
... ...
src/main/java/com/xly/web/FormController.java
... ... @@ -45,6 +45,10 @@ public class FormController {
45 45 if (id == null) {
46 46 throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录");
47 47 }
  48 + // 仅登录不够:该 FK 表必须被用户有权的某张表单引用,否则无表单权限的用户也能翻完客户名录
  49 + if (!resolver.fkTableAccessible(table, id)) {
  50 + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该数据");
  51 + }
48 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 37 }
38 38  
39 39 public static class SaveReq {
40   - /** 可编辑预览(update)上用户最终确认的 字段中文名->值;状态类操作可空。 */
  40 + /** 可编辑预览(update)上用户最终确认的 字段技术名->值(空值=放弃该字段的改动);状态类操作可空。 */
41 41 public Map<String, String> fields;
42 42 }
43 43  
... ...
src/main/resources/templates/chat.html
... ... @@ -617,7 +617,8 @@ function addPreviewCard(ev) {
617 617 }
618 618 if (f.changed && f.value) wrap.appendChild(el("div", "oldval", "原值:" + f.value));
619 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 623 card.appendChild(grid);
623 624 } else {
... ... @@ -654,7 +655,7 @@ function addPreviewCard(ev) {
654 655 result.textContent = "提交中…";
655 656 try {
656 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 659 const r = await fetch(BASE + "/api/agent/preview/" + encodeURIComponent(ev.previewId) + "/save",
659 660 { method: "POST", headers: authHeaders(true), body: JSON.stringify({ fields: fields }) });
660 661 const d = await r.json();
... ...