diff --git a/sql/migrate_rearch3.sql b/sql/migrate_rearch3.sql new file mode 100644 index 0000000..3b7246a --- /dev/null +++ b/sql/migrate_rearch3.sql @@ -0,0 +1,7 @@ +-- rearch3 部署迁移(对**已有** ai_op_queue 的库执行;新库直接跑 ai_op_queue.sql 即可)。 +-- 与部署同批执行的完整清单: +-- 1) 本文件(ai_op_queue 加 sMakePerson 双写列——ERP 执行器仍读 sUserId,切换后可删旧列) +-- 2) sql/ai_chat_event.sql (会话事件账本,新表) +-- 3) sql/ai_skill.sql (DROP 旧草稿表重建;⚠️ 建表瞬间即切断 war 包技能读取) +-- 4) sql/ai_skill_seed.sql (默认技能种子,必须与 3 同批执行) +ALTER TABLE ai_op_queue ADD COLUMN sMakePerson varchar(64) NULL AFTER sUserId; diff --git a/src/main/java/com/xly/config/TracingChatModelListener.java b/src/main/java/com/xly/config/TracingChatModelListener.java index 4138bb1..3966c06 100644 --- a/src/main/java/com/xly/config/TracingChatModelListener.java +++ b/src/main/java/com/xly/config/TracingChatModelListener.java @@ -88,17 +88,21 @@ public class TracingChatModelListener implements ChatModelListener { } /** - * token 估算校准回路:prompt_eval_count(响应免费自带)vs 本地保守估算。 - * 实际 > 估算 = 估算器不够保守(截头风险);实际+输出逼近 num_ctx = 很可能已被 Ollama 静默截头,都告警。 + * token 估算校准回路:prompt_eval_count(响应免费自带)vs 本地保守估算(messages 估算 + 工具 schema + * 定额 {@code TOOLS_OVERHEAD}——实际 prompt 含工具 schema 与模板,messages 估算必然偏小)。 + * 实际 > 估算+定额 = 保守系数不足(截头风险);实际+输出逼近 num_ctx = 很可能已被 Ollama 静默截头,都告警。 */ private void calibrate(Object est, Integer actualIn, Integer actualOut) { if (!(est instanceof Integer e) || actualIn == null || actualIn <= 0) { return; } - double ratio = actualIn / (double) e; - log.info("LLM token calib est={} actual={} ratio={}", e, actualIn, String.format("%.2f", ratio)); - if (actualIn > e) { - log.warn("LLM token 估算偏低(est={} < actual={})——保守系数不足,存在被 num_ctx 截头的风险", e, actualIn); + int budgeted = e + com.xly.service.TokenEstimator.TOOLS_OVERHEAD; + log.info("LLM token calib est={}+{} actual={} ratio={}", e, + com.xly.service.TokenEstimator.TOOLS_OVERHEAD, actualIn, + String.format("%.2f", actualIn / (double) budgeted)); + if (actualIn > budgeted) { + log.warn("LLM token 估算偏低(est+overhead={} < actual={})——保守系数不足,存在被 num_ctx 截头的风险", + budgeted, actualIn); } int total = actualIn + (actualOut == null ? 0 : actualOut); if (total > contextLength * 0.95) { diff --git a/src/main/java/com/xly/service/ConversationService.java b/src/main/java/com/xly/service/ConversationService.java index be758ad..d6b7c2f 100644 --- a/src/main/java/com/xly/service/ConversationService.java +++ b/src/main/java/com/xly/service/ConversationService.java @@ -55,8 +55,10 @@ public class ConversationService { return raw; } String local = raw.isEmpty() ? "default" : raw.replace(':', '_'); - if (local.length() > 64) { - local = local.substring(0, 64); + // 总长必须装进 ai_chat_event/ai_op_queue 的 sConversationId varchar(96)(uid 可能是 36 位 uuid) + int max = Math.min(64, Math.max(1, 96 - uid.length() - 1)); + if (local.length() > max) { + local = local.substring(0, max); } return uid + ":" + local; } diff --git a/src/main/java/com/xly/service/EventProjectionService.java b/src/main/java/com/xly/service/EventProjectionService.java index c875abb..cf2a3f5 100644 --- a/src/main/java/com/xly/service/EventProjectionService.java +++ b/src/main/java/com/xly/service/EventProjectionService.java @@ -49,7 +49,7 @@ public class EventProjectionService { /** 模型输出预留(num_ctx 覆盖 prompt+生成)。 */ private static final int OUTPUT_RESERVE = 1024; /** 工具 schema(随每次请求发送、不在 messages 里)的估算开销。 */ - private static final int TOOLS_OVERHEAD = 2000; + private static final int TOOLS_OVERHEAD = TokenEstimator.TOOLS_OVERHEAD; /** 当前轮增长的乐观小额预留(每次工具往返都会重投影,超出时反应式收缩⑤)。 */ private static final int TURN_RESERVE = 512; /** ④ 摘要区 token 上限。 */ @@ -65,6 +65,7 @@ public class EventProjectionService { /** opId → 人类可读状态行(Phase C:流程卡只读展示 ai_op_queue.sStatus)。可空(测试/未装配)。 */ private volatile UnaryOperator opStatusLookup; + @org.springframework.beans.factory.annotation.Autowired public EventProjectionService(ObjectMapper mapper, @Value("${llm.context-length:16384}") int contextLength) { this.mapper = mapper; this.contextLength = contextLength; @@ -88,10 +89,8 @@ public class EventProjectionService { /** 四段式 LLM 上下文(token 记账)。systemText 为空时不出 system 消息(如测试)。 */ public List project(String systemText, List> events) { int budget = promptBudget(); - List>> turns = groupTurns(events); - int current = turns.size() - 1; - // ①② system + 流程卡(永不让位) + // ①② system + 流程卡(永不让位)——流程卡用**未裁剪**事件流推导(skill_active 可能在窗口头部) SystemMessage sys = null; int sysEst = 0; if (systemText != null && !systemText.isBlank()) { @@ -101,6 +100,22 @@ public class EventProjectionService { sysEst = TokenEstimator.estimate(sys); } + // LLM 事件窗口可能从轮中间切断:丢弃首个用户事件之前的残留, + // 避免渲染出无配对 tool_call 的孤儿 tool_result(OpenAI 兼容端的非法消息序列) + int firstUser = -1; + for (int i = 0; i < events.size(); i++) { + String t = str(events.get(i).get("type")); + if ("user".equals(t) || "form_submit".equals(t)) { + firstUser = i; + break; + } + } + if (firstUser > 0) { + events = events.subList(firstUser, events.size()); + } + List>> turns = groupTurns(events); + int current = turns.size() - 1; + // 当前轮:原样保真;自身超限时轮内让位 List currentMsgs = new ArrayList<>(); int curEst = 0; @@ -125,14 +140,23 @@ public class EventProjectionService { firstVerbatim = fillRecent(oldEst, current, remaining, DIGEST_BUDGET); } - // ④ 摘要行 + // ④ 摘要行:从最新旧轮往回装,按 token 预算与行数双上限收拢(超出的更早轮记入 omitted) List digestLines = new ArrayList<>(); - int digestFrom = Math.max(0, firstVerbatim - DIGEST_TURNS_MAX); - for (int i = digestFrom; i < firstVerbatim; i++) { + int digestFrom = firstVerbatim; + int digestTokens = 0; + for (int i = firstVerbatim - 1; i >= 0 && firstVerbatim - i <= DIGEST_TURNS_MAX; i--) { String line = digestLine(turns.get(i)); - if (!line.isBlank()) { - digestLines.add(line); + if (line.isBlank()) { + digestFrom = i; + continue; + } + int t = TokenEstimator.estimate(line) + 2; + if (digestTokens + t > DIGEST_BUDGET) { + break; } + digestTokens += t; + digestLines.add(0, line); + digestFrom = i; } int omitted = digestFrom; diff --git a/src/main/java/com/xly/service/FormResolverService.java b/src/main/java/com/xly/service/FormResolverService.java index 84aa5fb..ecd29c3 100644 --- a/src/main/java/com/xly/service/FormResolverService.java +++ b/src/main/java/com/xly/service/FormResolverService.java @@ -56,6 +56,41 @@ public class FormResolverService { } } + /** + * formId → 其权威 moduleId(表单目录)。鉴权必须用它而不是调用方(模型)自报的 moduleId—— + * 否则「无权表单的 formId + 有权模块的 moduleId」混搭即可绕过表单级白名单。找不到返回 null。 + */ + public String moduleIdOfForm(String formId) { + if (isBlank(formId)) { + return null; + } + return queryOne("SELECT sModuleId FROM viw_ai_useful_forms WHERE sFormId=? LIMIT 1", formId.trim()); + } + + /** + * 该 FK 目标表是否被「当前用户有权的某张表单」引用(供 /form/options 鉴权): + * 仅登录不够——否则无任何表单权限的用户也能分页翻完客户名录。管理员(白名单 null)恒可。 + */ + public boolean fkTableAccessible(String fkTable, com.xly.agent.AgentIdentity identity) { + if (isBlank(fkTable) || identity == null) { + return false; + } + try { + List> rows = jdbc.queryForList( + "SELECT DISTINCT af.sModuleId FROM viw_kg_field_dict fd " + + "JOIN viw_ai_useful_forms af ON af.sDataSource = fd.sTable " + + "WHERE fd.sFkTable=?", fkTable.trim()); + for (Map r : rows) { + Object m = r.get("sModuleId"); + if (m != null && identity.canAccessModule(m.toString())) { + return true; + } + } + } catch (Exception ignore) { + } + return false; + } + /** 表单数据源表 -> 字段中文名(字段字典),用于把技术列名渲染为中文表头。 */ public Map fieldLabels(String formId) { Map m = new HashMap<>(); diff --git a/src/main/java/com/xly/service/LedgerService.java b/src/main/java/com/xly/service/LedgerService.java index f607c6c..4b5ff73 100644 --- a/src/main/java/com/xly/service/LedgerService.java +++ b/src/main/java/com/xly/service/LedgerService.java @@ -76,14 +76,14 @@ public class LedgerService { log.warn("ledger serialize failed (conv={}, type={}): {}", convId, type, e.getMessage()); return; } - persist(convId, type, json, who); - cache(convId, json); + boolean persisted = persist(convId, type, json, who); + cache(convId, json, persisted); } - /** MySQL 权威落库:服务端赋 iSeq/iTurn,唯一索引 + 重试防并发撞号。失败仅告警(Redis 兜底)。 */ - private void persist(String convId, String type, String json, AgentIdentity who) { + /** MySQL 权威落库:服务端赋 iSeq/iTurn,唯一索引 + 重试防并发撞号。失败仅告警(Redis 兜底),返回是否成功。 */ + private boolean persist(String convId, String type, String json, AgentIdentity who) { if (jdbc == null) { - return; + return false; } String maker = who != null && who.userId() != null ? who.userId() : userIdOf(convId); String brands = who == null ? null : who.brandsId(); @@ -108,20 +108,29 @@ public class LedgerService { "INSERT INTO ai_chat_event(sConversationId,iSeq,iTurn,sMakePerson,sBrandsId,sSubsidiaryId,sType,sPayload,tCreateDate) " + "VALUES(?,?,?,?,?,?,?,?,NOW())", convId, seq, turn, maker, brands, sub, type, json); - return; + return true; } catch (org.springframework.dao.DuplicateKeyException dup) { // 并发撞号:重读末行再试 } catch (Exception e) { log.warn("ledger mysql append failed (conv={}, type={}): {}", convId, type, e.getMessage()); - return; + return false; } } log.warn("ledger mysql append gave up after retries (conv={}, type={})", convId, type); + return false; } - private void cache(String convId, String json) { + /** + * 热缓存追加。**缓存键已失效时不 push**(persisted=true 时)——单条新事件重建的会是 + * 残缺列表且此后永不回源;留给下次读取从 MySQL 冷读回填完整历史(含本条)。 + * MySQL 落库失败时(persisted=false)无论如何都 push,宁可残缺不可丢事件。 + */ + private void cache(String convId, String json, boolean persisted) { try { String key = PREFIX + convId; + if (persisted && !Boolean.TRUE.equals(redis.hasKey(key))) { + return; + } redis.opsForList().rightPush(key, json); redis.expire(key, TTL); } catch (Exception e) { diff --git a/src/main/java/com/xly/service/OpService.java b/src/main/java/com/xly/service/OpService.java index ab8b199..641e951 100644 --- a/src/main/java/com/xly/service/OpService.java +++ b/src/main/java/com/xly/service/OpService.java @@ -27,6 +27,9 @@ public class OpService { this.jdbc = jdbc; } + /** 一次保存里的一个字段改动(批量入队用)。 */ + public record FieldChange(String col, String label, String oldValue, String newValue, String description) { } + /** 单字段更新入队(update)。 */ public String queueUpdate(AgentIdentity who, String convId, String formId, String moduleId, String table, String billId, String field, String fieldLabel, String oldValue, String newValue, @@ -35,6 +38,21 @@ public class OpService { field, fieldLabel, oldValue, newValue, null, description); } + /** + * 多字段更新批量入队(单事务:全部成功或全部回滚,绝不"写一半")。 + * ERP 执行器为单字段 update,故一次保存多字段 = 多行待办。 + */ + @org.springframework.transaction.annotation.Transactional + public List queueUpdates(AgentIdentity who, String convId, String formId, String moduleId, + String table, String billId, List changes) { + List opIds = new java.util.ArrayList<>(changes.size()); + for (FieldChange c : changes) { + opIds.add(insert(who, convId, "update", formId, moduleId, table, billId, + c.col(), c.label(), c.oldValue(), c.newValue(), null, c.description())); + } + return opIds; + } + /** 状态操作入队:invalid(sNewValue=toVoid|cancel)/ examine(sNewValue=1|0)/ delete。 */ public String queueStateOp(AgentIdentity who, String convId, String opType, String formId, String moduleId, String table, String billId, String sNewValue, String description) { @@ -59,11 +77,15 @@ public class OpService { "sOldValue,sNewValue,sPayload,sDescription,sStatus,tCreateDate,tConfirmDate) " + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'confirmed',NOW(),NOW())", sId, who.userId(), who.userId(), who.brandsId(), who.subsidiaryId(), convId, opType, - formId, moduleId, table, billId, field, fieldLabel, - oldValue, newValue, payload, description); + formId, moduleId, table, billId, field, trunc(fieldLabel, 128), + trunc(oldValue, 500), trunc(newValue, 500), payload, trunc(description, 500)); return sId; } + private static String trunc(String s, int max) { + return s != null && s.length() > max ? s.substring(0, max) : s; + } + public Map get(String sId) { List> r = jdbc.queryForList("SELECT * FROM ai_op_queue WHERE sId=?", sId); return r.isEmpty() ? null : r.get(0); diff --git a/src/main/java/com/xly/service/PreviewService.java b/src/main/java/com/xly/service/PreviewService.java index ab00b6f..1dafab5 100644 --- a/src/main/java/com/xly/service/PreviewService.java +++ b/src/main/java/com/xly/service/PreviewService.java @@ -280,9 +280,10 @@ public class PreviewService { // ---------------------------------------------------------------- 保存 /** - * 用户点卡上按钮 → 确定性保存:previewId 取回 → 归属/权限 → 重读重校验 → 写 ai_op_queue。 - * editedFields = 卡上(可编辑预览)用户最终确认的 label→值;状态类传 null。 - * 返回 {queued,opIds,description,message} 或 {error}。 + * 用户点卡上按钮 → 确定性保存:previewId 取回 → 归属/权限 → 重读重校验 → **claim 抢占**(删 previewId + * 的原子 DEL,防并发/重复提交入队两次)→ 写 ai_op_queue。 + * editedFields = 卡上(可编辑预览)用户最终确认的 字段技术名→值;状态类传 null。 + * 返回 {queued,opIds,description,message} 或 {error}(校验失败时预览保留,可修正后重试)。 */ public Map save(AgentIdentity who, String previewId, Map editedFields) { Map out = new LinkedHashMap<>(); @@ -308,43 +309,45 @@ public class PreviewService { out.put("error", cur.error + " 请重新预览。"); return out; } - Map result = "update".equals(action) - ? saveUpdate(who, draft, editedFields, cur, convId) - : saveStateOp(who, draft, cur, convId, action); - if (!result.containsKey("error")) { - discard(previewId); // 一次性:保存成功后预览失效 - } - return result; + return "update".equals(action) + ? saveUpdate(who, draft, editedFields, cur, convId, previewId) + : saveStateOp(who, draft, cur, convId, action, previewId); } - @SuppressWarnings("unchecked") + /** 入队多字段更新的一次改动。 */ + private record Change(String col, String label, String wasShown, Object stored, String shown) { } + private Map saveUpdate(AgentIdentity who, JsonNode draft, Map editedFields, - FormRenderService.Located cur, String convId) { + FormRenderService.Located cur, String convId, String previewId) { Map out = new LinkedHashMap<>(); String table = draft.path("table").asText(""); String recordName = draft.path("recordName").asText(""); JsonNode snapshotRaw = draft.path("snapshotRaw"); JsonNode snapshotShown = draft.path("snapshotShown"); String changedCol = draft.path("changedCol").asText(""); - String changedLabel = draft.path("changedLabel").asText(""); String changedNewShown = draft.path("changedNewShown").asText(""); - // 期望的最终值:预览高亮的改动 + 用户在卡上继续修正的字段 + // 期望的最终值:预览高亮的改动 + 用户在卡上继续修正的字段。 + // 键 = 字段**技术名**(同表两列同中文名时 label 键会串写出"幻影更新")。 + // 值为空 = 放弃该字段的改动(含模型提议的那个改动)——卡上无法把字段改成空值,属已知取舍。 Map desired = new LinkedHashMap<>(); desired.put(changedCol, changedNewShown); if (editedFields != null && !editedFields.isEmpty()) { for (JsonNode meta : draft.path("fieldsMeta")) { String name = meta.path("name").asText(""); - String label = meta.path("label").asText(""); - String v = editedFields.get(label); - if (v != null) { + if (!editedFields.containsKey(name)) { + continue; + } + String v = editedFields.get(name); + if (v == null || v.isBlank()) { + desired.remove(name); + } else { desired.put(name, v); } } } // 第一遍:全部改动先过校验与冲突检查(全通过才入队,避免写一半) - record Change(String col, String label, String wasShown, Object stored, String shown) { } List changes = new ArrayList<>(); for (JsonNode meta : draft.path("fieldsMeta")) { String col = meta.path("name").asText(""); @@ -382,22 +385,34 @@ public class PreviewService { return out; } - String dryErr = dryRunUpdate(who, draft, desired, cur); + String dryErr = dryRunUpdate(who, draft, changes); if (dryErr != null) { out.put("error", dryErr); return out; } - // 第二遍:逐字段入队(ERP 执行器为单字段 update;一次保存多字段 = 多行待办) - List opIds = new ArrayList<>(); + // claim(原子 DEL):并发/重复点保存只有一个能入队 + if (!claim(previewId)) { + out.put("error", "该预览已提交过或已失效,请勿重复提交。"); + return out; + } + // 第二遍:批量入队(单事务;ERP 执行器为单字段 update,一次保存多字段 = 多行待办) + List fcs = new ArrayList<>(); List descs = new ArrayList<>(); for (Change c : changes) { String desc = "将【" + recordName + "】的【" + c.label() + "】" + (c.wasShown().isBlank() ? "" : ("由「" + c.wasShown() + "」")) + "改为「" + c.shown() + "」"; descs.add(desc); - opIds.add(ops.queueUpdate(who, convId, draft.path("formId").asText(""), - draft.path("moduleId").asText(""), table, draft.path("billId").asText(""), - c.col(), c.label(), c.wasShown(), String.valueOf(c.stored()), desc)); + fcs.add(new OpService.FieldChange(c.col(), c.label(), c.wasShown(), String.valueOf(c.stored()), desc)); + } + List opIds; + try { + opIds = ops.queueUpdates(who, convId, draft.path("formId").asText(""), + draft.path("moduleId").asText(""), table, draft.path("billId").asText(""), fcs); + } catch (Exception e) { + log.warn("queue updates failed (conv={}): {}", convId, e.getMessage()); + out.put("error", "提交待办失败:" + e.getMessage() + ",请重新预览。"); + return out; } String description = String.join(";", descs); recordQueued(convId, who, opIds, description); @@ -409,7 +424,7 @@ public class PreviewService { } private Map saveStateOp(AgentIdentity who, JsonNode draft, FormRenderService.Located cur, - String convId, String action) { + String convId, String action, String previewId) { Map out = new LinkedHashMap<>(); String table = draft.path("table").asText(""); Map types = resolver.columnTypes(table); @@ -443,6 +458,11 @@ public class PreviewService { return out; } } + // claim(原子 DEL):并发/重复点按钮只有一个能入队——重复审核/作废的副作用正是状态检查要防的 + if (!claim(previewId)) { + out.put("error", "该预览已提交过或已失效,请勿重复提交。"); + return out; + } String description = draft.path("description").asText(""); String opId = ops.queueStateOp(who, convId, opType, draft.path("formId").asText(""), draft.path("moduleId").asText(""), table, draft.path("billId").asText(""), sNewValue, description); @@ -498,15 +518,17 @@ public class PreviewService { return null; } - private String dryRunUpdate(AgentIdentity who, JsonNode draft, Map desired, - FormRenderService.Located cur) { + private String dryRunUpdate(AgentIdentity who, JsonNode draft, List changes) { if (!dryRunEnabled) { return null; } try { + // 与真实入队完全同口径:只发改动列,值用规范化后的入库值(FK=id、数字已 coerce) Map col = new LinkedHashMap<>(); col.put("sId", draft.path("billId").asText("")); - desired.forEach(col::put); + for (Change c : changes) { + col.put(c.col(), c.stored()); + } JsonNode r = erp.checkBusinessData(who.token(), draft.path("moduleId").asText(""), draft.path("table").asText(""), mapper.writeValueAsString(col), "update"); if (r != null && r.path("code").asInt(1) != 1) { @@ -665,10 +687,12 @@ public class PreviewService { } } - private void discard(String previewId) { + /** 原子 DEL 抢占:只有真正删掉 key 的那个请求返回 true——并发/重复保存只有一个能入队。 */ + private boolean claim(String previewId) { try { - redis.delete(KEY_PREFIX + previewId); - } catch (Exception ignore) { + return Boolean.TRUE.equals(redis.delete(KEY_PREFIX + previewId)); + } catch (Exception e) { + return false; } } diff --git a/src/main/java/com/xly/service/TokenEstimator.java b/src/main/java/com/xly/service/TokenEstimator.java index ca6a074..cc447fe 100644 --- a/src/main/java/com/xly/service/TokenEstimator.java +++ b/src/main/java/com/xly/service/TokenEstimator.java @@ -21,6 +21,8 @@ public final class TokenEstimator { /** 每条消息的结构开销(role/分隔符等)。 */ public static final int MSG_OVERHEAD = 8; + /** 工具 schema + 对话模板的请求级开销(不在 messages 里;实测 7 工具中文描述约 2200-2400t)。 */ + public static final int TOOLS_OVERHEAD = 2600; private TokenEstimator() { } diff --git a/src/main/java/com/xly/tool/ErpReadTool.java b/src/main/java/com/xly/tool/ErpReadTool.java index f17aa56..142d4eb 100644 --- a/src/main/java/com/xly/tool/ErpReadTool.java +++ b/src/main/java/com/xly/tool/ErpReadTool.java @@ -123,7 +123,13 @@ public class ErpReadTool { if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) { return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。"; } - if (!identity.canAccessModule(moduleId.trim())) { + // 鉴权与请求都用表单目录反查出的**权威** moduleId——模型自报的 moduleId 只做形参, + // 否则「无权表单 formId + 有权模块 moduleId」混搭即可绕过表单级白名单。 + String authModule = resolver.moduleIdOfForm(formId.trim()); + if (authModule == null) { + return "该 formId 不在业务表单目录里,请先用 findForms 检索。"; + } + if (!identity.canAccessModule(authModule)) { return "你没有访问该表单的权限。"; } String kw = (keyword == null) ? "" : keyword.trim(); @@ -132,7 +138,7 @@ public class ErpReadTool { JsonNode root; try { - root = erp.readForm(identity.token(), formId.trim(), moduleId.trim(), pageNo, MAX_ROWS, nameField, kw.isEmpty() ? null : kw); + root = erp.readForm(identity.token(), formId.trim(), authModule, pageNo, MAX_ROWS, nameField, kw.isEmpty() ? null : kw); } catch (Exception e) { return "读取失败:" + e.getMessage(); } diff --git a/src/main/java/com/xly/web/AgentChatController.java b/src/main/java/com/xly/web/AgentChatController.java index db6f26b..27d7698 100644 --- a/src/main/java/com/xly/web/AgentChatController.java +++ b/src/main/java/com/xly/web/AgentChatController.java @@ -53,9 +53,10 @@ public class AgentChatController { private static final Logger log = LoggerFactory.getLogger(AgentChatController.class); private static final Set CARD_TOOLS = Set.of("previewChange", "askUser", "collectForm"); - /** 反编造护栏:agent 声称写操作已发生的说法(本轮没出过预览卡/表单时要纠正)。 */ + /** 反编造护栏:agent 声称写操作已发生的说法(本轮没出过预览卡/表单时要纠正)。 + * 负向前瞻排除**状态陈述**(「已审核状态」「已作废的单据」是转述记录现状,不是声称动作已完成)。 */ private static final Pattern WRITE_CLAIM = - Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核|保存|录入)"); + Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核|保存|录入)(?!状态|的|单据|过)"); private final AgentFactory agentFactory; private final AuthzService authz; diff --git a/src/main/java/com/xly/web/FormController.java b/src/main/java/com/xly/web/FormController.java index 81743e3..a375f97 100644 --- a/src/main/java/com/xly/web/FormController.java +++ b/src/main/java/com/xly/web/FormController.java @@ -45,6 +45,10 @@ public class FormController { if (id == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录"); } + // 仅登录不够:该 FK 表必须被用户有权的某张表单引用,否则无表单权限的用户也能翻完客户名录 + if (!resolver.fkTableAccessible(table, id)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该数据"); + } return resolver.fkOptionPage(table, id.brandsId(), q, page, pageSize); } } diff --git a/src/main/java/com/xly/web/PreviewController.java b/src/main/java/com/xly/web/PreviewController.java index b32cf91..ca5bc33 100644 --- a/src/main/java/com/xly/web/PreviewController.java +++ b/src/main/java/com/xly/web/PreviewController.java @@ -37,7 +37,7 @@ public class PreviewController { } public static class SaveReq { - /** 可编辑预览(update)上用户最终确认的 字段中文名->值;状态类操作可空。 */ + /** 可编辑预览(update)上用户最终确认的 字段技术名->值(空值=放弃该字段的改动);状态类操作可空。 */ public Map fields; } diff --git a/src/main/resources/templates/chat.html b/src/main/resources/templates/chat.html index be8d815..2666e1a 100644 --- a/src/main/resources/templates/chat.html +++ b/src/main/resources/templates/chat.html @@ -617,7 +617,8 @@ function addPreviewCard(ev) { } if (f.changed && f.value) wrap.appendChild(el("div", "oldval", "原值:" + f.value)); grid.appendChild(wrap); - controls.push({ label: f.label || f.name, get: getter }); + // 键 = 技术列名(同表两列同中文名时 label 键会串写);空值 = 放弃该字段的改动 + controls.push({ name: f.name, get: getter }); }); card.appendChild(grid); } else { @@ -654,7 +655,7 @@ function addPreviewCard(ev) { result.textContent = "提交中…"; try { const fields = {}; - controls.forEach(c => { const v = c.get(); if (v) fields[c.label] = v; }); + controls.forEach(c => { fields[c.name] = c.get(); }); const r = await fetch(BASE + "/api/agent/preview/" + encodeURIComponent(ev.previewId) + "/save", { method: "POST", headers: authHeaders(true), body: JSON.stringify({ fields: fields }) }); const d = await r.json();