Commit 4aacc3ab8d82cd25ad42afb6a7097525680a2a53

Authored by zichun
1 parent e6886ee9

review follow-up on FK resolve: duplicate exact-name records rejected (never sil…

…ently bind first — 31 same-name groups in eleproduct alone); deterministic candidate order (sId tiebreak); LIKE wildcard escaping; quote non-FK branch phantom gate; cold-read warm lock vs concurrent double-fill; resolveNameField(+ByFormId) phantom name-column cross-check (55/108 fk tables)
src/main/java/com/xly/service/FormRenderService.java
@@ -271,13 +271,22 @@ public class FormRenderService { @@ -271,13 +271,22 @@ public class FormRenderService {
271 n.error = "「" + value + "」不是系统里已有的" + ent + ",请从已有记录里选一个。"; 271 n.error = "「" + value + "」不是系统里已有的" + ent + ",请从已有记录里选一个。";
272 return n; 272 return n;
273 } 273 }
274 - Map<String, Object> pick = null;  
275 String want = value.trim(); 274 String want = value.trim();
276 - for (Map<String, Object> c : cands) { // 唯一精确同名直接命中 275 + Map<String, Object> pick = null;
  276 + int exact = 0;
  277 + for (Map<String, Object> c : cands) {
277 if (want.equals(String.valueOf(c.get("name")))) { 278 if (want.equals(String.valueOf(c.get("name")))) {
278 - pick = pick == null ? c : pick; 279 + exact++;
  280 + if (pick == null) {
  281 + pick = c;
  282 + }
279 } 283 }
280 } 284 }
  285 + if (exact > 1) { // 完全同名的多条记录:名称无从区分,绝不静默绑其中一条
  286 + n.error = "系统里存在多条同名的「" + want + "」记录,无法按名称区分,"
  287 + + "请在 ERP 里处理这条数据、或先把重名记录改名后再试。";
  288 + return n;
  289 + }
281 if (pick == null && cands.size() == 1) { 290 if (pick == null && cands.size() == 1) {
282 pick = cands.get(0); 291 pick = cands.get(0);
283 } 292 }
@@ -494,6 +503,10 @@ public class FormRenderService { @@ -494,6 +503,10 @@ public class FormRenderService {
494 custId = String.valueOf(n.stored); 503 custId = String.valueOf(n.stored);
495 } 504 }
496 } else { 505 } else {
  506 + if (!tt.containsKey(colName)) { // 与 normalize 同款幻影列硬闸(保护交给检查,不靠策展清单)
  507 + out.error = "「" + zh + "」不是可写字段,请从表单里选有效字段。";
  508 + return out;
  509 + }
497 n = new Normalized(); 510 n = new Normalized();
498 try { 511 try {
499 n.stored = resolver.coerce(tt.get(colName), v); 512 n.stored = resolver.coerce(tt.get(colName), v);
src/main/java/com/xly/service/FormResolverService.java
@@ -111,11 +111,26 @@ public class FormResolverService { @@ -111,11 +111,26 @@ public class FormResolverService {
111 return m; 111 return m;
112 } 112 }
113 113
114 - /** 猜该表单/表的"名称"字段(以 Name 结尾、使用最广),用于按关键词过滤 / 展示记录名。 */ 114 + /**
  115 + * 猜该表单/表的"名称"字段(以 Name 结尾、使用最广),用于按关键词过滤 / 展示记录名。
  116 + * 字段字典含幻影列(108 个 FK 目标表里 55 个的首选名称列不存在于物理表)——只返回物理存在的,
  117 + * 否则下游按不存在的列过滤/查询会把元数据问题报成「找不到记录」这类用户错误。
  118 + */
115 public String resolveNameField(String table) { 119 public String resolveNameField(String table) {
116 - return queryOne(  
117 - "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " +  
118 - "ORDER BY iFormUses DESC LIMIT 1", table); 120 + try {
  121 + Map<String, String> physical = columnTypes(table);
  122 + List<Map<String, Object>> rows = jdbc.queryForList(
  123 + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " +
  124 + "GROUP BY sField ORDER BY SUM(iFormUses) DESC LIMIT 5", table);
  125 + for (Map<String, Object> r : rows) {
  126 + String f = String.valueOf(r.get("sField"));
  127 + if (physical.containsKey(f)) {
  128 + return f;
  129 + }
  130 + }
  131 + } catch (Exception ignore) {
  132 + }
  133 + return null;
119 } 134 }
120 135
121 /** 按关键词形态选检索字段:形如单号(字母前缀+数字)且表有 sBillNo 列 → 用单号列;否则名称列。 */ 136 /** 按关键词形态选检索字段:形如单号(字母前缀+数字)且表有 sBillNo 列 → 用单号列;否则名称列。 */
@@ -127,13 +142,10 @@ public class FormResolverService { @@ -127,13 +142,10 @@ public class FormResolverService {
127 return resolveNameField(table); 142 return resolveNameField(table);
128 } 143 }
129 144
130 - /** 同 {@link #resolveNameField},但从 formId 出发(经表单目录找到数据源表)。 */ 145 + /** 同 {@link #resolveNameField}(含幻影列过滤),但从 formId 出发(经表单目录找到数据源表)。 */
131 public String resolveNameFieldByFormId(String formId) { 146 public String resolveNameFieldByFormId(String formId) {
132 - return queryOne(  
133 - "SELECT fd.sField FROM viw_ai_useful_forms af " +  
134 - "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " +  
135 - "WHERE af.sFormId = ? AND fd.sField LIKE '%Name' AND fd.sField NOT LIKE '%NameId' " +  
136 - "ORDER BY fd.iFormUses DESC LIMIT 1", formId); 147 + String table = queryOne("SELECT sDataSource FROM viw_ai_useful_forms WHERE sFormId=? LIMIT 1", formId);
  148 + return table == null ? null : resolveNameField(table);
137 } 149 }
138 150
139 // ERP 会自动注入 / 系统管理、不该让用户填的列(新增时排除)。 151 // ERP 会自动注入 / 系统管理、不该让用户填的列(新增时排除)。
@@ -426,17 +438,18 @@ public class FormResolverService { @@ -426,17 +438,18 @@ public class FormResolverService {
426 } 438 }
427 int cap = Math.max(1, Math.min(limit, 10)); 439 int cap = Math.max(1, Math.min(limit, 10));
428 boolean tenantScoped = columnTypes(fkTable).containsKey("sBrandsId"); 440 boolean tenantScoped = columnTypes(fkTable).containsKey("sBrandsId");
  441 + // LIKE 通配转义(用户原话里的 %/_ 不是通配符);sId 兜底排序保证预览与保存两次解析确定同序
  442 + String pattern = "%" + name.trim().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%";
  443 + String orderBy = " ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC, sId ASC LIMIT " + cap;
429 try { 444 try {
430 List<Map<String, Object>> rows; 445 List<Map<String, Object>> rows;
431 if (!tenantScoped) { 446 if (!tenantScoped) {
432 // 无租户列 = 全局基础表(如字典类) 447 // 无租户列 = 全局基础表(如字典类)
433 rows = jdbc.queryForList("SELECT sId, `" + nameField + "` AS name FROM `" + fkTable + 448 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() + "%"); 449 + "` WHERE `" + nameField + "` LIKE ?" + orderBy, pattern);
436 } else { 450 } else {
437 rows = jdbc.queryForList("SELECT sId, `" + nameField + "` AS name FROM `" + fkTable + 451 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() + "%"); 452 + "` WHERE sBrandsId=? AND `" + nameField + "` LIKE ?" + orderBy, brand, pattern);
440 } 453 }
441 return rows; 454 return rows;
442 } catch (Exception e) { 455 } catch (Exception e) {
src/main/java/com/xly/service/LedgerService.java
@@ -209,10 +209,13 @@ public class LedgerService { @@ -209,10 +209,13 @@ public class LedgerService {
209 return List.of(); 209 return List.of();
210 } 210 }
211 Collections.reverse(out); 211 Collections.reverse(out);
212 - try { // 回填热缓存(尽力而为,恒为全量窗口) 212 + try { // 回填热缓存(尽力而为,恒为全量窗口);warm 锁防并发双回填把整段会话推两遍
213 String key = PREFIX + convId; 213 String key = PREFIX + convId;
214 - redis.opsForList().rightPushAll(key, out);  
215 - redis.expire(key, TTL); 214 + if (Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(key + ":warm", "1", Duration.ofSeconds(15)))
  215 + && !Boolean.TRUE.equals(redis.hasKey(key))) {
  216 + redis.opsForList().rightPushAll(key, out);
  217 + redis.expire(key, TTL);
  218 + }
216 } catch (Exception ignore) { 219 } catch (Exception ignore) {
217 } 220 }
218 return lastN > 0 && out.size() > lastN ? out.subList(out.size() - lastN, out.size()) : out; 221 return lastN > 0 && out.size() > lastN ? out.subList(out.size() - lastN, out.size()) : out;
src/test/java/com/xly/service/FormRenderNormalizeTest.java
@@ -50,6 +50,18 @@ class FormRenderNormalizeTest { @@ -50,6 +50,18 @@ class FormRenderNormalizeTest {
50 } 50 }
51 51
52 @Test 52 @Test
  53 + void duplicateExactNamesAreRejectedNotFirstPicked() {
  54 + // 本地库实证:同品牌下「普通彩盒」有 10 条——完全同名时名称无从区分,绝不静默绑第一条
  55 + FormResolverService r = resolverWith(Map.of("sProductId", "varchar"), List.of(
  56 + Map.of("sId", "prodA", "name", "普通彩盒"),
  57 + Map.of("sId", "prodB", "name", "普通彩盒")));
  58 + FormRenderService.Normalized n = svc(r).normalize("t", "sProductId", "eleproduct", "产品名称", "普通彩盒", WHO);
  59 + assertNotNull(n.error, "同名多条必须报错——shown 恰好等于原话,卡片看不出绑错了谁");
  60 + assertTrue(n.error.contains("同名"));
  61 + assertNull(n.stored);
  62 + }
  63 +
  64 + @Test
53 void exactNameWinsOverShorterFuzzyMatches() { 65 void exactNameWinsOverShorterFuzzyMatches() {
54 FormResolverService r = resolverWith(Map.of("sCustomerId", "varchar"), List.of( 66 FormResolverService r = resolverWith(Map.of("sCustomerId", "varchar"), List.of(
55 Map.of("sId", "idShort", "name", "印刷"), 67 Map.of("sId", "idShort", "name", "印刷"),