diff --git a/docs/agent-architecture.md b/docs/agent-architecture.md index 1dc4e66..cf38098 100644 --- a/docs/agent-architecture.md +++ b/docs/agent-architecture.md @@ -516,7 +516,8 @@ ai_op_queue( LLM 窗口截断的孤儿 tool_result 丢弃。校准回路:`prompt_eval_count` vs 估算+定额,超出即告警 (TracingChatModelListener)。 - 流程卡新语义:previewChange 预览行("尚未写入");`queued` 后技能线摘下,待办行**只读**展示 - `ai_op_queue.sStatus` 进度(confirmed/executing/executed/failed),直到下一个流程事件替换。 + `ai_op_queue.sStatus+iStatusCode` 进度(pending 100/101、success 200/201、fail 4xx/5xx), + 直到下一个流程事件替换。 ### 21.3 技能:ai_skill 表存在开关 diff --git a/src/main/java/com/xly/config/CorsConfig.java b/src/main/java/com/xly/config/CorsConfig.java index cce455e..193465c 100644 --- a/src/main/java/com/xly/config/CorsConfig.java +++ b/src/main/java/com/xly/config/CorsConfig.java @@ -1,70 +1,43 @@ package com.xly.config; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -/*** - * @Author 钱豹 - * @Date 22:40 2026/2/3 - * @Param - * @return - * @Description 跨域配置 - **/ +/** + * CORS:**origin 白名单**(可配置 {@code xly.cors.allowed-origins},逗号分隔,支持通配段)。 + * + *

不再对所有域名放行——通配 + allowCredentials 意味着浏览器里任意网页都能跨域驱动本服务 + * (本地 dev-login 开启时无凭证即可入队 ERP 写操作)。生产走 nginx 同源代理本就不需要 CORS; + * 出现合法跨域消费方时把它的 origin 加进配置即可。 + */ @Configuration public class CorsConfig { - /** - * 允许所有跨域请求 - CorsFilter方式 - */ + /** 默认只放行本机开发场景的 origin。 */ + @Value("${xly.cors.allowed-origins:http://localhost:*,http://127.0.0.1:*,http://host.docker.internal:*}") + private String allowedOrigins; + @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); - - // 允许所有域名 - config.addAllowedOriginPattern("*"); - - // 允许所有请求方法 + for (String o : allowedOrigins.split(",")) { + String t = o.trim(); + if (!t.isEmpty()) { + config.addAllowedOriginPattern(t); + } + } config.addAllowedMethod("*"); - - // 允许所有请求头 config.addAllowedHeader("*"); - - // 允许携带凭证(如cookies) config.setAllowCredentials(true); - - // 暴露所有响应头 config.addExposedHeader("*"); - - // 预检请求缓存时间 config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); - return new CorsFilter(source); } - - /** - * 允许所有跨域请求 - WebMvcConfigurer方式 - */ - @Bean - public WebMvcConfigurer corsConfigurer() { - return new WebMvcConfigurer() { - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") - .allowedOriginPatterns("*") // 使用 allowedOriginPatterns 代替 allowedOrigins - .allowedMethods("*") - .allowedHeaders("*") - .exposedHeaders("*") - .allowCredentials(true) - .maxAge(3600); - } - }; - } -} \ No newline at end of file +} diff --git a/src/main/java/com/xly/service/FormRenderService.java b/src/main/java/com/xly/service/FormRenderService.java index 81d5d17..a6d6543 100644 --- a/src/main/java/com/xly/service/FormRenderService.java +++ b/src/main/java/com/xly/service/FormRenderService.java @@ -93,7 +93,8 @@ public class FormRenderService { "AND IFNULL(bReadonly,0)=0 ORDER BY iOrder LIMIT " + MAX_FIELDS, formId); for (Map c : cols) { String name = str(c.get("sName")); - if (name == null || name.isBlank() || resolver.isSystemColumn(name)) { + if (name == null || name.isBlank() || resolver.isSystemColumn(name) + || !types.containsKey(name)) { // 界面元数据同样含幻影列 continue; } Map f = new LinkedHashMap<>(); @@ -214,23 +215,30 @@ public class FormRenderService { // ---------------------------------------------------------------- 字段解析与规范化 - /** 字段中文名 → {col, fk}(先精确合并模糊,取使用度最高的一列)。找不到返回 null。 */ + /** + * 字段中文名 → {col, fk}(先精确合并模糊,按使用度排序)。字段字典由表单控件名构建, + * 有大量**幻影列**(不存在于物理表)——只返回物理存在的候选,找不到返回 null。 + */ public Map resolveColumn(String table, String zh) { try { + Map types = resolver.columnTypes(table); List> r = jdbc.queryForList( "SELECT sField col, MAX(sFkTable) fk FROM viw_kg_field_dict " + "WHERE sTable=? AND (sChinese=? OR sChinese LIKE ?) " + - "GROUP BY sField ORDER BY SUM(iFormUses) DESC LIMIT 1", - table, zh, "%" + zh + "%"); - if (r.isEmpty()) { - return null; - } - Map m = r.get(0); - Object fk = m.get("fk"); - if (fk != null && ("null".equalsIgnoreCase(String.valueOf(fk)) || String.valueOf(fk).isBlank())) { - m.put("fk", null); + "GROUP BY sField ORDER BY MAX(sChinese=?) DESC, SUM(iFormUses) DESC LIMIT 5", + table, zh, "%" + zh + "%", zh); + for (Map m : r) { + String col = String.valueOf(m.get("col")); + if (!types.containsKey(col)) { + continue; // 幻影列:字典有、物理表没有 + } + Object fk = m.get("fk"); + if (fk != null && ("null".equalsIgnoreCase(String.valueOf(fk)) || String.valueOf(fk).isBlank())) { + m.put("fk", null); + } + return m; } - return m; + return null; } catch (Exception e) { return null; } @@ -244,24 +252,50 @@ public class FormRenderService { } /** - * 把用户/模型给的字符串按目标列规范化:外键列名称→id(租户内),其余按列类型强转。 - * 预览与保存共用同一份逻辑("所见即所写"的前半)。 + * 把用户/模型给的字符串按目标列规范化:外键列名称→id(租户内,多条命中报错列候选、 + * 唯一精确同名优先——绝不静默绑最短匹配),其余按列类型强转。目标列必须物理存在 + * (字段字典由表单控件名构建,含大量幻影列;幻影列会让类型校验静默失效)。 + * 预览与保存共用同一份逻辑;FK 的 {@code shown} = **实际解析到的记录名**("所见即所写")。 */ public Normalized normalize(String table, String col, String fk, String label, String value, AgentIdentity identity) { Normalized n = new Normalized(); + Map types = resolver.columnTypes(table); + if (!types.containsKey(col)) { + n.error = "「" + label + "」不是「" + table + "」的可写字段,请换个字段名。"; + return n; + } if (fk != null && !fk.isBlank()) { - String id = resolver.resolveFk(fk, identity.brandsId(), value); - if (id == null) { + List> cands = resolver.resolveFkCandidates(fk, identity.brandsId(), value, 5); + if (cands.isEmpty()) { String ent = label == null ? "" : label.replace("名称", ""); n.error = "「" + value + "」不是系统里已有的" + ent + ",请从已有记录里选一个。"; return n; } - n.stored = id; - n.shown = value; + Map pick = null; + String want = value.trim(); + for (Map c : cands) { // 唯一精确同名直接命中 + if (want.equals(String.valueOf(c.get("name")))) { + pick = pick == null ? c : pick; + } + } + if (pick == null && cands.size() == 1) { + pick = cands.get(0); + } + if (pick == null) { + StringBuilder names = new StringBuilder(); + for (int i = 0; i < cands.size(); i++) { + if (i > 0) names.append("、"); + names.append(cands.get(i).get("name")); + } + n.error = "「" + value + "」匹配到多条记录(" + names + "),请用完整名称指明其中一条。"; + return n; + } + n.stored = String.valueOf(pick.get("sId")); + n.shown = String.valueOf(pick.get("name")); // 卡片/描述显示真正要绑定的记录名,而非用户原话 return n; } try { - n.stored = resolver.coerce(resolver.columnTypes(table).get(col), value); + n.stored = resolver.coerce(types.get(col), value); } catch (IllegalArgumentException ex) { n.error = "「" + label + "」" + ex.getMessage() + ",请给一个有效值。"; return n; @@ -448,7 +482,6 @@ public class FormRenderService { descParts.add(zh + "=" + String.join(",", manyQtys)); continue; } - String targetTable = "slave".equals(tgt) ? "quoquotationslave" : out.table; Map tt = "slave".equals(tgt) ? slaveTypes : masterTypes; Normalized n; if (fk != null && !fk.isBlank()) { @@ -479,7 +512,8 @@ public class FormRenderService { } else { masterCol.put(colName, n.stored); } - String shown = (fk != null && !fk.isBlank()) ? v : String.valueOf(n.stored); + // FK 显示真正绑定的记录名(n.shown),数值显示规范化后的数——与实际入库同源 + String shown = (fk != null && !fk.isBlank()) ? n.shown : String.valueOf(n.stored); descParts.add(zh + "=" + shown + (shown.equals(v) ? "" : "(原话:" + v + ")")); } if (descParts.isEmpty()) { diff --git a/src/main/java/com/xly/service/FormResolverService.java b/src/main/java/com/xly/service/FormResolverService.java index ecd29c3..1b96105 100644 --- a/src/main/java/com/xly/service/FormResolverService.java +++ b/src/main/java/com/xly/service/FormResolverService.java @@ -368,6 +368,8 @@ public class FormResolverService { } List> out = new ArrayList<>(); try { + // 字段字典由表单控件名构建,含大量幻影列——只呈现物理表真实存在的列 + Map physical = columnTypes(table); List> rows = jdbc.queryForList( "SELECT sField, " + // 标签取非Id、更短的中文名(客户 优先于 客户Id) @@ -376,7 +378,7 @@ public class FormResolverService { "WHERE sTable=? AND CHAR_LENGTH(sChinese)>=2 GROUP BY sField ORDER BY u DESC", table); for (Map r : rows) { String col = String.valueOf(r.get("sField")); - if (isSystemColumn(col)) { + if (isSystemColumn(col) || !physical.containsKey(col)) { continue; } Map f = new LinkedHashMap<>(); @@ -397,28 +399,49 @@ public class FormResolverService { } /** - * 外键名称 -> id:在外键表按名称字段模糊匹配(元数据来源可信,非用户拼 SQL)。 + * 外键名称 -> id(旧接口,取最短匹配)。写入路径请改用 {@link #resolveFkCandidates}—— + * 本方法多条命中时静默取最短,会出现「卡上写 A、实际绑 B」的所见非所写。 + */ + public String resolveFk(String fkTable, String brand, String name) { + List> c = resolveFkCandidates(fkTable, brand, name, 1); + return c.isEmpty() ? null : String.valueOf(c.get(0).get("sId")); + } + + /** + * 外键名称 -> 候选记录列表 [{sId, name}](按名称长度升序,最多 limit 条)。 + * 调用方规则:唯一精确同名 → 直接用;仅 1 条 → 用;多条且无唯一精确 → 报错列候选让用户挑 + * (与 locateRecord 的多条报错同一纪律,杜绝静默绑错记录)。 * *

必须按租户过滤:没有 sBrandsId 谓词时,新增/修改会把**别家租户**的客户/产品 id 绑进本租户单据, - * 而且能被当成跨租户的存在性探针。拿不到租户就直接返回 null(写入路径随即报「不是系统里已有的…」)。 + * 而且能被当成跨租户的存在性探针。拿不到租户就直接返回空(写入路径随即报「不是系统里已有的…」)。 */ - public String resolveFk(String fkTable, String brand, String name) { + public List> resolveFkCandidates(String fkTable, String brand, String name, int limit) { if (fkTable == null || fkTable.isBlank() || name == null || name.isBlank() || brand == null || brand.isBlank()) { - return null; + return List.of(); } String nameField = resolveNameField(fkTable); if (nameField == null) { - return null; + return List.of(); } + int cap = Math.max(1, Math.min(limit, 10)); boolean tenantScoped = columnTypes(fkTable).containsKey("sBrandsId"); - if (!tenantScoped) { - // 无租户列 = 全局基础表(如字典类),按名称直接解析 - return queryOne("SELECT sId FROM `" + fkTable + "` WHERE `" + nameField + "` LIKE ? " + - "ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT 1", "%" + name.trim() + "%"); + try { + List> rows; + if (!tenantScoped) { + // 无租户列 = 全局基础表(如字典类) + rows = jdbc.queryForList("SELECT sId, `" + nameField + "` AS name FROM `" + fkTable + + "` WHERE `" + nameField + "` LIKE ? ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT " + cap, + "%" + name.trim() + "%"); + } else { + rows = jdbc.queryForList("SELECT sId, `" + nameField + "` AS name FROM `" + fkTable + + "` WHERE sBrandsId=? AND `" + nameField + "` LIKE ? ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT " + cap, + brand, "%" + name.trim() + "%"); + } + return rows; + } catch (Exception e) { + return List.of(); } - return queryOne("SELECT sId FROM `" + fkTable + "` WHERE sBrandsId=? AND `" + nameField + "` LIKE ? " + - "ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT 1", brand, "%" + name.trim() + "%"); } /** diff --git a/src/main/java/com/xly/service/LedgerService.java b/src/main/java/com/xly/service/LedgerService.java index 4b5ff73..3cbce05 100644 --- a/src/main/java/com/xly/service/LedgerService.java +++ b/src/main/java/com/xly/service/LedgerService.java @@ -179,15 +179,18 @@ public class LedgerService { return out; } - /** Redis 缓存失效 → 回源 MySQL(有界窗口)并回填缓存。 */ + /** + * Redis 缓存失效 → 回源 MySQL 并回填缓存。**无论调用方要多小的窗口,回填一律用全量窗口**—— + * 若按调用方窗口(如去重探针的 6 条)回填,key 一旦置为存在,后续读取永不再回源, + * 会话历史会被永久截断成那几条。回填后按 lastN 切尾返回。 + */ private List coldRead(String convId, int lastN) { if (jdbc == null) { return List.of(); } - int window = lastN <= 0 ? COLD_READ_MAX : Math.min(lastN, COLD_READ_MAX); try { List> rows = jdbc.queryForList( - "SELECT sType, sPayload FROM ai_chat_event WHERE sConversationId=? ORDER BY iId DESC LIMIT " + window, + "SELECT sType, sPayload FROM ai_chat_event WHERE sConversationId=? ORDER BY iId DESC LIMIT " + COLD_READ_MAX, convId); if (rows.isEmpty()) { return List.of(); @@ -206,13 +209,13 @@ public class LedgerService { return List.of(); } Collections.reverse(out); - try { // 回填热缓存(尽力而为) + try { // 回填热缓存(尽力而为,恒为全量窗口) String key = PREFIX + convId; redis.opsForList().rightPushAll(key, out); redis.expire(key, TTL); } catch (Exception ignore) { } - return out; + return lastN > 0 && out.size() > lastN ? out.subList(out.size() - lastN, out.size()) : out; } catch (Exception e) { log.warn("ledger mysql read failed (conv={}): {}", convId, e.getMessage()); return List.of(); diff --git a/src/main/java/com/xly/web/AgentChatController.java b/src/main/java/com/xly/web/AgentChatController.java index 27d7698..952ec83 100644 --- a/src/main/java/com/xly/web/AgentChatController.java +++ b/src/main/java/com/xly/web/AgentChatController.java @@ -121,7 +121,7 @@ public class AgentChatController { /** * 确定性表单保存:collectForm 表单的结构化字段直达 create 校验(FK/类型/系统列)并写 ai_op_queue - * (用户点【保存】= 已当面授权 → sStatus=confirmed)。**xlyAi 到此为止**,执行由 ERP 侧负责。 + * (用户点【保存】= 已当面授权 → 入队 pending+100)。**xlyAi 到此为止**,执行由 ERP 侧负责。 * 同步返回 {queued,opId,summary,message} 或 {error}。 */ @PostMapping("/form/submit") diff --git a/src/test/java/com/xly/service/FormRenderNormalizeTest.java b/src/test/java/com/xly/service/FormRenderNormalizeTest.java new file mode 100644 index 0000000..7004a68 --- /dev/null +++ b/src/test/java/com/xly/service/FormRenderNormalizeTest.java @@ -0,0 +1,93 @@ +package com.xly.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.agent.AgentIdentity; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 值规范化的**所见即所写**性质(2026-07-29 复审 HIGH 修复的回归锁): + * FK 多条命中必须报错列候选(绝不静默绑最短)、唯一精确同名优先、shown=实际绑定的记录名; + * 幻影列(字段字典有、物理表没有)必须整体拒绝,不得落到 varchar 分支跳过类型校验。 + */ +class FormRenderNormalizeTest { + + private static final AgentIdentity WHO = new AgentIdentity("tok", "u1", "brand", "sub", null); + + private FormRenderService svc(FormResolverService resolver) { + return new FormRenderService(null, resolver, null, new ObjectMapper()); + } + + private FormResolverService resolverWith(Map types, List> fkCands) { + FormResolverService r = mock(FormResolverService.class); + when(r.columnTypes(anyString())).thenReturn(types); + when(r.resolveFkCandidates(anyString(), anyString(), anyString(), anyInt())).thenReturn(fkCands); + return r; + } + + @Test + void ambiguousFkNameIsRejectedWithCandidates() { + FormResolverService r = resolverWith(Map.of("sCustomerId", "varchar"), List.of( + Map.of("sId", "id1", "name", "常州印刷"), + Map.of("sId", "id2", "name", "金陵印刷"), + Map.of("sId", "id3", "name", "连云港印刷"))); + FormRenderService.Normalized n = svc(r).normalize("t", "sCustomerId", "elecustomer", "客户名称", "印刷", WHO); + assertNotNull(n.error, "多条命中必须报错,绝不静默绑最短匹配"); + assertTrue(n.error.contains("常州印刷") && n.error.contains("金陵印刷"), "报错列出候选供用户挑"); + assertNull(n.stored); + } + + @Test + void exactNameWinsOverShorterFuzzyMatches() { + FormResolverService r = resolverWith(Map.of("sCustomerId", "varchar"), List.of( + Map.of("sId", "idShort", "name", "印刷"), + Map.of("sId", "idExact", "name", "金陵印刷"))); + FormRenderService.Normalized n = svc(r).normalize("t", "sCustomerId", "elecustomer", "客户名称", "金陵印刷", WHO); + assertNull(n.error); + assertEquals("idExact", n.stored, "唯一精确同名直接命中"); + assertEquals("金陵印刷", n.shown); + } + + @Test + void uniquePartialMatchShowsResolvedRealName() { + FormResolverService r = resolverWith(Map.of("sCustomerId", "varchar"), List.of( + Map.of("sId", "id9", "name", "姑苏印务"))); + FormRenderService.Normalized n = svc(r).normalize("t", "sCustomerId", "elecustomer", "客户名称", "姑苏", WHO); + assertNull(n.error); + assertEquals("id9", n.stored); + assertEquals("姑苏印务", n.shown, "卡片显示真正要绑定的记录名,而非用户原话"); + } + + @Test + void phantomColumnIsRejectedNotCoercedAsVarchar() { + // 字段字典解析出的列不在物理表:不得落 varchar 分支静默接受「五千」这类值 + FormResolverService r = resolverWith(Map.of("dQty", "decimal"), List.of()); + FormRenderService.Normalized n = svc(r).normalize("t", "sGhostCol", null, "数量", "五千", WHO); + assertNotNull(n.error); + assertTrue(n.error.contains("不是") && n.error.contains("可写字段")); + } + + @Test + void numericColumnStillCoerces() { + FormResolverService real = mock(FormResolverService.class); + when(real.columnTypes(anyString())).thenReturn(Map.of("dQty", "decimal")); + when(real.coerce(eq("decimal"), anyString())).thenCallRealMethod(); + FormRenderService.Normalized ok = svc(real).normalize("t", "dQty", null, "数量", "5,000", WHO); + assertNull(ok.error); + assertEquals("5000", String.valueOf(ok.stored)); + FormRenderService.Normalized bad = svc(real).normalize("t", "dQty", null, "数量", "五千", WHO); + assertNotNull(bad.error, "数值列拒绝非数字"); + } +}