Commit 203353cfc64764f1f8c2e6d03b647405c9caae20
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
Showing
14 changed files
with
206 additions
and
65 deletions
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(); |