Commit e6886ee977d687692c09a467c5538cacb5d8891e

Authored by zichun
1 parent 9f1f7fc0

audit HIGH fixes: FK resolve — candidates + exact-name priority + ambiguity erro…

…r + shown=resolved record name (WYSIWYG); phantom-column cross-check vs information_schema (resolveColumn/businessFields/skeleton/normalize hard gate); ledger cold-read always warms full window (partial-window rebuild permanently truncated history); CORS origin allowlist (configurable, default localhost) replacing wildcard+credentials; stale 'confirmed' docs
docs/agent-architecture.md
@@ -516,7 +516,8 @@ ai_op_queue( @@ -516,7 +516,8 @@ ai_op_queue(
516 LLM 窗口截断的孤儿 tool_result 丢弃。校准回路:`prompt_eval_count` vs 估算+定额,超出即告警 516 LLM 窗口截断的孤儿 tool_result 丢弃。校准回路:`prompt_eval_count` vs 估算+定额,超出即告警
517 (TracingChatModelListener)。 517 (TracingChatModelListener)。
518 - 流程卡新语义:previewChange 预览行("尚未写入");`queued` 后技能线摘下,待办行**只读**展示 518 - 流程卡新语义:previewChange 预览行("尚未写入");`queued` 后技能线摘下,待办行**只读**展示
519 - `ai_op_queue.sStatus` 进度(confirmed/executing/executed/failed),直到下一个流程事件替换。 519 + `ai_op_queue.sStatus+iStatusCode` 进度(pending 100/101、success 200/201、fail 4xx/5xx),
  520 + 直到下一个流程事件替换。
520 521
521 ### 21.3 技能:ai_skill 表存在开关 522 ### 21.3 技能:ai_skill 表存在开关
522 523
src/main/java/com/xly/config/CorsConfig.java
1 package com.xly.config; 1 package com.xly.config;
2 2
  3 +import org.springframework.beans.factory.annotation.Value;
3 import org.springframework.context.annotation.Bean; 4 import org.springframework.context.annotation.Bean;
4 import org.springframework.context.annotation.Configuration; 5 import org.springframework.context.annotation.Configuration;
5 import org.springframework.web.cors.CorsConfiguration; 6 import org.springframework.web.cors.CorsConfiguration;
6 import org.springframework.web.cors.UrlBasedCorsConfigurationSource; 7 import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
7 import org.springframework.web.filter.CorsFilter; 8 import org.springframework.web.filter.CorsFilter;
8 -import org.springframework.web.servlet.config.annotation.CorsRegistry;  
9 -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;  
10 9
11 -/***  
12 - * @Author 钱豹  
13 - * @Date 22:40 2026/2/3  
14 - * @Param  
15 - * @return  
16 - * @Description 跨域配置  
17 - **/ 10 +/**
  11 + * CORS:**origin 白名单**(可配置 {@code xly.cors.allowed-origins},逗号分隔,支持通配段)。
  12 + *
  13 + * <p>不再对所有域名放行——通配 + allowCredentials 意味着浏览器里任意网页都能跨域驱动本服务
  14 + * (本地 dev-login 开启时无凭证即可入队 ERP 写操作)。生产走 nginx 同源代理本就不需要 CORS;
  15 + * 出现合法跨域消费方时把它的 origin 加进配置即可。
  16 + */
18 @Configuration 17 @Configuration
19 public class CorsConfig { 18 public class CorsConfig {
20 19
21 - /**  
22 - * 允许所有跨域请求 - CorsFilter方式  
23 - */ 20 + /** 默认只放行本机开发场景的 origin。 */
  21 + @Value("${xly.cors.allowed-origins:http://localhost:*,http://127.0.0.1:*,http://host.docker.internal:*}")
  22 + private String allowedOrigins;
  23 +
24 @Bean 24 @Bean
25 public CorsFilter corsFilter() { 25 public CorsFilter corsFilter() {
26 CorsConfiguration config = new CorsConfiguration(); 26 CorsConfiguration config = new CorsConfiguration();
27 -  
28 - // 允许所有域名  
29 - config.addAllowedOriginPattern("*");  
30 -  
31 - // 允许所有请求方法 27 + for (String o : allowedOrigins.split(",")) {
  28 + String t = o.trim();
  29 + if (!t.isEmpty()) {
  30 + config.addAllowedOriginPattern(t);
  31 + }
  32 + }
32 config.addAllowedMethod("*"); 33 config.addAllowedMethod("*");
33 -  
34 - // 允许所有请求头  
35 config.addAllowedHeader("*"); 34 config.addAllowedHeader("*");
36 -  
37 - // 允许携带凭证(如cookies)  
38 config.setAllowCredentials(true); 35 config.setAllowCredentials(true);
39 -  
40 - // 暴露所有响应头  
41 config.addExposedHeader("*"); 36 config.addExposedHeader("*");
42 -  
43 - // 预检请求缓存时间  
44 config.setMaxAge(3600L); 37 config.setMaxAge(3600L);
45 38
46 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); 39 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
47 source.registerCorsConfiguration("/**", config); 40 source.registerCorsConfiguration("/**", config);
48 -  
49 return new CorsFilter(source); 41 return new CorsFilter(source);
50 } 42 }
51 -  
52 - /**  
53 - * 允许所有跨域请求 - WebMvcConfigurer方式  
54 - */  
55 - @Bean  
56 - public WebMvcConfigurer corsConfigurer() {  
57 - return new WebMvcConfigurer() {  
58 - @Override  
59 - public void addCorsMappings(CorsRegistry registry) {  
60 - registry.addMapping("/**")  
61 - .allowedOriginPatterns("*") // 使用 allowedOriginPatterns 代替 allowedOrigins  
62 - .allowedMethods("*")  
63 - .allowedHeaders("*")  
64 - .exposedHeaders("*")  
65 - .allowCredentials(true)  
66 - .maxAge(3600);  
67 - }  
68 - };  
69 - }  
70 -}  
71 \ No newline at end of file 43 \ No newline at end of file
  44 +}
src/main/java/com/xly/service/FormRenderService.java
@@ -93,7 +93,8 @@ public class FormRenderService { @@ -93,7 +93,8 @@ public class FormRenderService {
93 "AND IFNULL(bReadonly,0)=0 ORDER BY iOrder LIMIT " + MAX_FIELDS, formId); 93 "AND IFNULL(bReadonly,0)=0 ORDER BY iOrder LIMIT " + MAX_FIELDS, formId);
94 for (Map<String, Object> c : cols) { 94 for (Map<String, Object> c : cols) {
95 String name = str(c.get("sName")); 95 String name = str(c.get("sName"));
96 - if (name == null || name.isBlank() || resolver.isSystemColumn(name)) { 96 + if (name == null || name.isBlank() || resolver.isSystemColumn(name)
  97 + || !types.containsKey(name)) { // 界面元数据同样含幻影列
97 continue; 98 continue;
98 } 99 }
99 Map<String, Object> f = new LinkedHashMap<>(); 100 Map<String, Object> f = new LinkedHashMap<>();
@@ -214,23 +215,30 @@ public class FormRenderService { @@ -214,23 +215,30 @@ public class FormRenderService {
214 215
215 // ---------------------------------------------------------------- 字段解析与规范化 216 // ---------------------------------------------------------------- 字段解析与规范化
216 217
217 - /** 字段中文名 → {col, fk}(先精确合并模糊,取使用度最高的一列)。找不到返回 null。 */ 218 + /**
  219 + * 字段中文名 → {col, fk}(先精确合并模糊,按使用度排序)。字段字典由表单控件名构建,
  220 + * 有大量**幻影列**(不存在于物理表)——只返回物理存在的候选,找不到返回 null。
  221 + */
218 public Map<String, Object> resolveColumn(String table, String zh) { 222 public Map<String, Object> resolveColumn(String table, String zh) {
219 try { 223 try {
  224 + Map<String, String> types = resolver.columnTypes(table);
220 List<Map<String, Object>> r = jdbc.queryForList( 225 List<Map<String, Object>> r = jdbc.queryForList(
221 "SELECT sField col, MAX(sFkTable) fk FROM viw_kg_field_dict " + 226 "SELECT sField col, MAX(sFkTable) fk FROM viw_kg_field_dict " +
222 "WHERE sTable=? AND (sChinese=? OR sChinese LIKE ?) " + 227 "WHERE sTable=? AND (sChinese=? OR sChinese LIKE ?) " +
223 - "GROUP BY sField ORDER BY SUM(iFormUses) DESC LIMIT 1",  
224 - table, zh, "%" + zh + "%");  
225 - if (r.isEmpty()) {  
226 - return null;  
227 - }  
228 - Map<String, Object> m = r.get(0);  
229 - Object fk = m.get("fk");  
230 - if (fk != null && ("null".equalsIgnoreCase(String.valueOf(fk)) || String.valueOf(fk).isBlank())) {  
231 - m.put("fk", null); 228 + "GROUP BY sField ORDER BY MAX(sChinese=?) DESC, SUM(iFormUses) DESC LIMIT 5",
  229 + table, zh, "%" + zh + "%", zh);
  230 + for (Map<String, Object> m : r) {
  231 + String col = String.valueOf(m.get("col"));
  232 + if (!types.containsKey(col)) {
  233 + continue; // 幻影列:字典有、物理表没有
  234 + }
  235 + Object fk = m.get("fk");
  236 + if (fk != null && ("null".equalsIgnoreCase(String.valueOf(fk)) || String.valueOf(fk).isBlank())) {
  237 + m.put("fk", null);
  238 + }
  239 + return m;
232 } 240 }
233 - return m; 241 + return null;
234 } catch (Exception e) { 242 } catch (Exception e) {
235 return null; 243 return null;
236 } 244 }
@@ -244,24 +252,50 @@ public class FormRenderService { @@ -244,24 +252,50 @@ public class FormRenderService {
244 } 252 }
245 253
246 /** 254 /**
247 - * 把用户/模型给的字符串按目标列规范化:外键列名称→id(租户内),其余按列类型强转。  
248 - * 预览与保存共用同一份逻辑("所见即所写"的前半)。 255 + * 把用户/模型给的字符串按目标列规范化:外键列名称→id(租户内,多条命中报错列候选、
  256 + * 唯一精确同名优先——绝不静默绑最短匹配),其余按列类型强转。目标列必须物理存在
  257 + * (字段字典由表单控件名构建,含大量幻影列;幻影列会让类型校验静默失效)。
  258 + * 预览与保存共用同一份逻辑;FK 的 {@code shown} = **实际解析到的记录名**("所见即所写")。
249 */ 259 */
250 public Normalized normalize(String table, String col, String fk, String label, String value, AgentIdentity identity) { 260 public Normalized normalize(String table, String col, String fk, String label, String value, AgentIdentity identity) {
251 Normalized n = new Normalized(); 261 Normalized n = new Normalized();
  262 + Map<String, String> types = resolver.columnTypes(table);
  263 + if (!types.containsKey(col)) {
  264 + n.error = "「" + label + "」不是「" + table + "」的可写字段,请换个字段名。";
  265 + return n;
  266 + }
252 if (fk != null && !fk.isBlank()) { 267 if (fk != null && !fk.isBlank()) {
253 - String id = resolver.resolveFk(fk, identity.brandsId(), value);  
254 - if (id == null) { 268 + List<Map<String, Object>> cands = resolver.resolveFkCandidates(fk, identity.brandsId(), value, 5);
  269 + if (cands.isEmpty()) {
255 String ent = label == null ? "" : label.replace("名称", ""); 270 String ent = label == null ? "" : label.replace("名称", "");
256 n.error = "「" + value + "」不是系统里已有的" + ent + ",请从已有记录里选一个。"; 271 n.error = "「" + value + "」不是系统里已有的" + ent + ",请从已有记录里选一个。";
257 return n; 272 return n;
258 } 273 }
259 - n.stored = id;  
260 - n.shown = value; 274 + Map<String, Object> pick = null;
  275 + String want = value.trim();
  276 + for (Map<String, Object> c : cands) { // 唯一精确同名直接命中
  277 + if (want.equals(String.valueOf(c.get("name")))) {
  278 + pick = pick == null ? c : pick;
  279 + }
  280 + }
  281 + if (pick == null && cands.size() == 1) {
  282 + pick = cands.get(0);
  283 + }
  284 + if (pick == null) {
  285 + StringBuilder names = new StringBuilder();
  286 + for (int i = 0; i < cands.size(); i++) {
  287 + if (i > 0) names.append("、");
  288 + names.append(cands.get(i).get("name"));
  289 + }
  290 + n.error = "「" + value + "」匹配到多条记录(" + names + "),请用完整名称指明其中一条。";
  291 + return n;
  292 + }
  293 + n.stored = String.valueOf(pick.get("sId"));
  294 + n.shown = String.valueOf(pick.get("name")); // 卡片/描述显示真正要绑定的记录名,而非用户原话
261 return n; 295 return n;
262 } 296 }
263 try { 297 try {
264 - n.stored = resolver.coerce(resolver.columnTypes(table).get(col), value); 298 + n.stored = resolver.coerce(types.get(col), value);
265 } catch (IllegalArgumentException ex) { 299 } catch (IllegalArgumentException ex) {
266 n.error = "「" + label + "」" + ex.getMessage() + ",请给一个有效值。"; 300 n.error = "「" + label + "」" + ex.getMessage() + ",请给一个有效值。";
267 return n; 301 return n;
@@ -448,7 +482,6 @@ public class FormRenderService { @@ -448,7 +482,6 @@ public class FormRenderService {
448 descParts.add(zh + "=" + String.join(",", manyQtys)); 482 descParts.add(zh + "=" + String.join(",", manyQtys));
449 continue; 483 continue;
450 } 484 }
451 - String targetTable = "slave".equals(tgt) ? "quoquotationslave" : out.table;  
452 Map<String, String> tt = "slave".equals(tgt) ? slaveTypes : masterTypes; 485 Map<String, String> tt = "slave".equals(tgt) ? slaveTypes : masterTypes;
453 Normalized n; 486 Normalized n;
454 if (fk != null && !fk.isBlank()) { 487 if (fk != null && !fk.isBlank()) {
@@ -479,7 +512,8 @@ public class FormRenderService { @@ -479,7 +512,8 @@ public class FormRenderService {
479 } else { 512 } else {
480 masterCol.put(colName, n.stored); 513 masterCol.put(colName, n.stored);
481 } 514 }
482 - String shown = (fk != null && !fk.isBlank()) ? v : String.valueOf(n.stored); 515 + // FK 显示真正绑定的记录名(n.shown),数值显示规范化后的数——与实际入库同源
  516 + String shown = (fk != null && !fk.isBlank()) ? n.shown : String.valueOf(n.stored);
483 descParts.add(zh + "=" + shown + (shown.equals(v) ? "" : "(原话:" + v + ")")); 517 descParts.add(zh + "=" + shown + (shown.equals(v) ? "" : "(原话:" + v + ")"));
484 } 518 }
485 if (descParts.isEmpty()) { 519 if (descParts.isEmpty()) {
src/main/java/com/xly/service/FormResolverService.java
@@ -368,6 +368,8 @@ public class FormResolverService { @@ -368,6 +368,8 @@ public class FormResolverService {
368 } 368 }
369 List<Map<String, Object>> out = new ArrayList<>(); 369 List<Map<String, Object>> out = new ArrayList<>();
370 try { 370 try {
  371 + // 字段字典由表单控件名构建,含大量幻影列——只呈现物理表真实存在的列
  372 + Map<String, String> physical = columnTypes(table);
371 List<Map<String, Object>> rows = jdbc.queryForList( 373 List<Map<String, Object>> rows = jdbc.queryForList(
372 "SELECT sField, " + 374 "SELECT sField, " +
373 // 标签取非Id、更短的中文名(客户 优先于 客户Id) 375 // 标签取非Id、更短的中文名(客户 优先于 客户Id)
@@ -376,7 +378,7 @@ public class FormResolverService { @@ -376,7 +378,7 @@ public class FormResolverService {
376 "WHERE sTable=? AND CHAR_LENGTH(sChinese)>=2 GROUP BY sField ORDER BY u DESC", table); 378 "WHERE sTable=? AND CHAR_LENGTH(sChinese)>=2 GROUP BY sField ORDER BY u DESC", table);
377 for (Map<String, Object> r : rows) { 379 for (Map<String, Object> r : rows) {
378 String col = String.valueOf(r.get("sField")); 380 String col = String.valueOf(r.get("sField"));
379 - if (isSystemColumn(col)) { 381 + if (isSystemColumn(col) || !physical.containsKey(col)) {
380 continue; 382 continue;
381 } 383 }
382 Map<String, Object> f = new LinkedHashMap<>(); 384 Map<String, Object> f = new LinkedHashMap<>();
@@ -397,28 +399,49 @@ public class FormResolverService { @@ -397,28 +399,49 @@ public class FormResolverService {
397 } 399 }
398 400
399 /** 401 /**
400 - * 外键名称 -> id:在外键表按名称字段模糊匹配(元数据来源可信,非用户拼 SQL)。 402 + * 外键名称 -> id(旧接口,取最短匹配)。写入路径请改用 {@link #resolveFkCandidates}——
  403 + * 本方法多条命中时静默取最短,会出现「卡上写 A、实际绑 B」的所见非所写。
  404 + */
  405 + public String resolveFk(String fkTable, String brand, String name) {
  406 + List<Map<String, Object>> c = resolveFkCandidates(fkTable, brand, name, 1);
  407 + return c.isEmpty() ? null : String.valueOf(c.get(0).get("sId"));
  408 + }
  409 +
  410 + /**
  411 + * 外键名称 -> 候选记录列表 [{sId, name}](按名称长度升序,最多 limit 条)。
  412 + * 调用方规则:唯一精确同名 → 直接用;仅 1 条 → 用;多条且无唯一精确 → 报错列候选让用户挑
  413 + * (与 locateRecord 的多条报错同一纪律,杜绝静默绑错记录)。
401 * 414 *
402 * <p><b>必须按租户过滤</b>:没有 sBrandsId 谓词时,新增/修改会把**别家租户**的客户/产品 id 绑进本租户单据, 415 * <p><b>必须按租户过滤</b>:没有 sBrandsId 谓词时,新增/修改会把**别家租户**的客户/产品 id 绑进本租户单据,
403 - * 而且能被当成跨租户的存在性探针。拿不到租户就直接返回 null(写入路径随即报「不是系统里已有的…」)。 416 + * 而且能被当成跨租户的存在性探针。拿不到租户就直接返回(写入路径随即报「不是系统里已有的…」)。
404 */ 417 */
405 - public String resolveFk(String fkTable, String brand, String name) { 418 + public List<Map<String, Object>> resolveFkCandidates(String fkTable, String brand, String name, int limit) {
406 if (fkTable == null || fkTable.isBlank() || name == null || name.isBlank() 419 if (fkTable == null || fkTable.isBlank() || name == null || name.isBlank()
407 || brand == null || brand.isBlank()) { 420 || brand == null || brand.isBlank()) {
408 - return null; 421 + return List.of();
409 } 422 }
410 String nameField = resolveNameField(fkTable); 423 String nameField = resolveNameField(fkTable);
411 if (nameField == null) { 424 if (nameField == null) {
412 - return null; 425 + return List.of();
413 } 426 }
  427 + int cap = Math.max(1, Math.min(limit, 10));
414 boolean tenantScoped = columnTypes(fkTable).containsKey("sBrandsId"); 428 boolean tenantScoped = columnTypes(fkTable).containsKey("sBrandsId");
415 - if (!tenantScoped) {  
416 - // 无租户列 = 全局基础表(如字典类),按名称直接解析  
417 - return queryOne("SELECT sId FROM `" + fkTable + "` WHERE `" + nameField + "` LIKE ? " +  
418 - "ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT 1", "%" + name.trim() + "%"); 429 + try {
  430 + List<Map<String, Object>> rows;
  431 + if (!tenantScoped) {
  432 + // 无租户列 = 全局基础表(如字典类)
  433 + rows = jdbc.queryForList("SELECT sId, `" + nameField + "` AS name FROM `" + fkTable +
  434 + "` WHERE `" + nameField + "` LIKE ? ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT " + cap,
  435 + "%" + name.trim() + "%");
  436 + } else {
  437 + rows = jdbc.queryForList("SELECT sId, `" + nameField + "` AS name FROM `" + fkTable +
  438 + "` WHERE sBrandsId=? AND `" + nameField + "` LIKE ? ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT " + cap,
  439 + brand, "%" + name.trim() + "%");
  440 + }
  441 + return rows;
  442 + } catch (Exception e) {
  443 + return List.of();
419 } 444 }
420 - return queryOne("SELECT sId FROM `" + fkTable + "` WHERE sBrandsId=? AND `" + nameField + "` LIKE ? " +  
421 - "ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT 1", brand, "%" + name.trim() + "%");  
422 } 445 }
423 446
424 /** 447 /**
src/main/java/com/xly/service/LedgerService.java
@@ -179,15 +179,18 @@ public class LedgerService { @@ -179,15 +179,18 @@ public class LedgerService {
179 return out; 179 return out;
180 } 180 }
181 181
182 - /** Redis 缓存失效 → 回源 MySQL(有界窗口)并回填缓存。 */ 182 + /**
  183 + * Redis 缓存失效 → 回源 MySQL 并回填缓存。**无论调用方要多小的窗口,回填一律用全量窗口**——
  184 + * 若按调用方窗口(如去重探针的 6 条)回填,key 一旦置为存在,后续读取永不再回源,
  185 + * 会话历史会被永久截断成那几条。回填后按 lastN 切尾返回。
  186 + */
183 private List<String> coldRead(String convId, int lastN) { 187 private List<String> coldRead(String convId, int lastN) {
184 if (jdbc == null) { 188 if (jdbc == null) {
185 return List.of(); 189 return List.of();
186 } 190 }
187 - int window = lastN <= 0 ? COLD_READ_MAX : Math.min(lastN, COLD_READ_MAX);  
188 try { 191 try {
189 List<Map<String, Object>> rows = jdbc.queryForList( 192 List<Map<String, Object>> rows = jdbc.queryForList(
190 - "SELECT sType, sPayload FROM ai_chat_event WHERE sConversationId=? ORDER BY iId DESC LIMIT " + window, 193 + "SELECT sType, sPayload FROM ai_chat_event WHERE sConversationId=? ORDER BY iId DESC LIMIT " + COLD_READ_MAX,
191 convId); 194 convId);
192 if (rows.isEmpty()) { 195 if (rows.isEmpty()) {
193 return List.of(); 196 return List.of();
@@ -206,13 +209,13 @@ public class LedgerService { @@ -206,13 +209,13 @@ public class LedgerService {
206 return List.of(); 209 return List.of();
207 } 210 }
208 Collections.reverse(out); 211 Collections.reverse(out);
209 - try { // 回填热缓存(尽力而为 212 + try { // 回填热缓存(尽力而为,恒为全量窗口
210 String key = PREFIX + convId; 213 String key = PREFIX + convId;
211 redis.opsForList().rightPushAll(key, out); 214 redis.opsForList().rightPushAll(key, out);
212 redis.expire(key, TTL); 215 redis.expire(key, TTL);
213 } catch (Exception ignore) { 216 } catch (Exception ignore) {
214 } 217 }
215 - return out; 218 + return lastN > 0 && out.size() > lastN ? out.subList(out.size() - lastN, out.size()) : out;
216 } catch (Exception e) { 219 } catch (Exception e) {
217 log.warn("ledger mysql read failed (conv={}): {}", convId, e.getMessage()); 220 log.warn("ledger mysql read failed (conv={}): {}", convId, e.getMessage());
218 return List.of(); 221 return List.of();
src/main/java/com/xly/web/AgentChatController.java
@@ -121,7 +121,7 @@ public class AgentChatController { @@ -121,7 +121,7 @@ public class AgentChatController {
121 121
122 /** 122 /**
123 * 确定性表单保存:collectForm 表单的结构化字段直达 create 校验(FK/类型/系统列)并写 ai_op_queue 123 * 确定性表单保存:collectForm 表单的结构化字段直达 create 校验(FK/类型/系统列)并写 ai_op_queue
124 - * (用户点【保存】= 已当面授权 → sStatus=confirmed)。**xlyAi 到此为止**,执行由 ERP 侧负责。 124 + * (用户点【保存】= 已当面授权 → 入队 pending+100)。**xlyAi 到此为止**,执行由 ERP 侧负责。
125 * 同步返回 {queued,opId,summary,message} 或 {error}。 125 * 同步返回 {queued,opId,summary,message} 或 {error}。
126 */ 126 */
127 @PostMapping("/form/submit") 127 @PostMapping("/form/submit")
src/test/java/com/xly/service/FormRenderNormalizeTest.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.xly.agent.AgentIdentity;
  5 +import org.junit.jupiter.api.Test;
  6 +
  7 +import java.util.List;
  8 +import java.util.Map;
  9 +
  10 +import static org.junit.jupiter.api.Assertions.assertEquals;
  11 +import static org.junit.jupiter.api.Assertions.assertNotNull;
  12 +import static org.junit.jupiter.api.Assertions.assertNull;
  13 +import static org.junit.jupiter.api.Assertions.assertTrue;
  14 +import static org.mockito.ArgumentMatchers.anyInt;
  15 +import static org.mockito.ArgumentMatchers.anyString;
  16 +import static org.mockito.ArgumentMatchers.eq;
  17 +import static org.mockito.Mockito.mock;
  18 +import static org.mockito.Mockito.when;
  19 +
  20 +/**
  21 + * 值规范化的**所见即所写**性质(2026-07-29 复审 HIGH 修复的回归锁):
  22 + * FK 多条命中必须报错列候选(绝不静默绑最短)、唯一精确同名优先、shown=实际绑定的记录名;
  23 + * 幻影列(字段字典有、物理表没有)必须整体拒绝,不得落到 varchar 分支跳过类型校验。
  24 + */
  25 +class FormRenderNormalizeTest {
  26 +
  27 + private static final AgentIdentity WHO = new AgentIdentity("tok", "u1", "brand", "sub", null);
  28 +
  29 + private FormRenderService svc(FormResolverService resolver) {
  30 + return new FormRenderService(null, resolver, null, new ObjectMapper());
  31 + }
  32 +
  33 + private FormResolverService resolverWith(Map<String, String> types, List<Map<String, Object>> fkCands) {
  34 + FormResolverService r = mock(FormResolverService.class);
  35 + when(r.columnTypes(anyString())).thenReturn(types);
  36 + when(r.resolveFkCandidates(anyString(), anyString(), anyString(), anyInt())).thenReturn(fkCands);
  37 + return r;
  38 + }
  39 +
  40 + @Test
  41 + void ambiguousFkNameIsRejectedWithCandidates() {
  42 + FormResolverService r = resolverWith(Map.of("sCustomerId", "varchar"), List.of(
  43 + Map.of("sId", "id1", "name", "常州印刷"),
  44 + Map.of("sId", "id2", "name", "金陵印刷"),
  45 + Map.of("sId", "id3", "name", "连云港印刷")));
  46 + FormRenderService.Normalized n = svc(r).normalize("t", "sCustomerId", "elecustomer", "客户名称", "印刷", WHO);
  47 + assertNotNull(n.error, "多条命中必须报错,绝不静默绑最短匹配");
  48 + assertTrue(n.error.contains("常州印刷") && n.error.contains("金陵印刷"), "报错列出候选供用户挑");
  49 + assertNull(n.stored);
  50 + }
  51 +
  52 + @Test
  53 + void exactNameWinsOverShorterFuzzyMatches() {
  54 + FormResolverService r = resolverWith(Map.of("sCustomerId", "varchar"), List.of(
  55 + Map.of("sId", "idShort", "name", "印刷"),
  56 + Map.of("sId", "idExact", "name", "金陵印刷")));
  57 + FormRenderService.Normalized n = svc(r).normalize("t", "sCustomerId", "elecustomer", "客户名称", "金陵印刷", WHO);
  58 + assertNull(n.error);
  59 + assertEquals("idExact", n.stored, "唯一精确同名直接命中");
  60 + assertEquals("金陵印刷", n.shown);
  61 + }
  62 +
  63 + @Test
  64 + void uniquePartialMatchShowsResolvedRealName() {
  65 + FormResolverService r = resolverWith(Map.of("sCustomerId", "varchar"), List.of(
  66 + Map.of("sId", "id9", "name", "姑苏印务")));
  67 + FormRenderService.Normalized n = svc(r).normalize("t", "sCustomerId", "elecustomer", "客户名称", "姑苏", WHO);
  68 + assertNull(n.error);
  69 + assertEquals("id9", n.stored);
  70 + assertEquals("姑苏印务", n.shown, "卡片显示真正要绑定的记录名,而非用户原话");
  71 + }
  72 +
  73 + @Test
  74 + void phantomColumnIsRejectedNotCoercedAsVarchar() {
  75 + // 字段字典解析出的列不在物理表:不得落 varchar 分支静默接受「五千」这类值
  76 + FormResolverService r = resolverWith(Map.of("dQty", "decimal"), List.of());
  77 + FormRenderService.Normalized n = svc(r).normalize("t", "sGhostCol", null, "数量", "五千", WHO);
  78 + assertNotNull(n.error);
  79 + assertTrue(n.error.contains("不是") && n.error.contains("可写字段"));
  80 + }
  81 +
  82 + @Test
  83 + void numericColumnStillCoerces() {
  84 + FormResolverService real = mock(FormResolverService.class);
  85 + when(real.columnTypes(anyString())).thenReturn(Map.of("dQty", "decimal"));
  86 + when(real.coerce(eq("decimal"), anyString())).thenCallRealMethod();
  87 + FormRenderService.Normalized ok = svc(real).normalize("t", "dQty", null, "数量", "5,000", WHO);
  88 + assertNull(ok.error);
  89 + assertEquals("5000", String.valueOf(ok.stored));
  90 + FormRenderService.Normalized bad = svc(real).normalize("t", "dQty", null, "数量", "五千", WHO);
  91 + assertNotNull(bad.error, "数值列拒绝非数字");
  92 + }
  93 +}