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 271 n.error = "「" + value + "」不是系统里已有的" + ent + ",请从已有记录里选一个。";
272 272 return n;
273 273 }
274   - Map<String, Object> pick = null;
275 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 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 290 if (pick == null && cands.size() == 1) {
282 291 pick = cands.get(0);
283 292 }
... ... @@ -494,6 +503,10 @@ public class FormRenderService {
494 503 custId = String.valueOf(n.stored);
495 504 }
496 505 } else {
  506 + if (!tt.containsKey(colName)) { // 与 normalize 同款幻影列硬闸(保护交给检查,不靠策展清单)
  507 + out.error = "「" + zh + "」不是可写字段,请从表单里选有效字段。";
  508 + return out;
  509 + }
497 510 n = new Normalized();
498 511 try {
499 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 111 return m;
112 112 }
113 113  
114   - /** 猜该表单/表的"名称"字段(以 Name 结尾、使用最广),用于按关键词过滤 / 展示记录名。 */
  114 + /**
  115 + * 猜该表单/表的"名称"字段(以 Name 结尾、使用最广),用于按关键词过滤 / 展示记录名。
  116 + * 字段字典含幻影列(108 个 FK 目标表里 55 个的首选名称列不存在于物理表)——只返回物理存在的,
  117 + * 否则下游按不存在的列过滤/查询会把元数据问题报成「找不到记录」这类用户错误。
  118 + */
115 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 136 /** 按关键词形态选检索字段:形如单号(字母前缀+数字)且表有 sBillNo 列 → 用单号列;否则名称列。 */
... ... @@ -127,13 +142,10 @@ public class FormResolverService {
127 142 return resolveNameField(table);
128 143 }
129 144  
130   - /** 同 {@link #resolveNameField},但从 formId 出发(经表单目录找到数据源表)。 */
  145 + /** 同 {@link #resolveNameField}(含幻影列过滤),但从 formId 出发(经表单目录找到数据源表)。 */
131 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 151 // ERP 会自动注入 / 系统管理、不该让用户填的列(新增时排除)。
... ... @@ -426,17 +438,18 @@ public class FormResolverService {
426 438 }
427 439 int cap = Math.max(1, Math.min(limit, 10));
428 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 444 try {
430 445 List<Map<String, Object>> rows;
431 446 if (!tenantScoped) {
432 447 // 无租户列 = 全局基础表(如字典类)
433 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 450 } else {
437 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 454 return rows;
442 455 } catch (Exception e) {
... ...
src/main/java/com/xly/service/LedgerService.java
... ... @@ -209,10 +209,13 @@ public class LedgerService {
209 209 return List.of();
210 210 }
211 211 Collections.reverse(out);
212   - try { // 回填热缓存(尽力而为,恒为全量窗口)
  212 + try { // 回填热缓存(尽力而为,恒为全量窗口);warm 锁防并发双回填把整段会话推两遍
213 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 219 } catch (Exception ignore) {
217 220 }
218 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 50 }
51 51  
52 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 65 void exactNameWinsOverShorterFuzzyMatches() {
54 66 FormResolverService r = resolverWith(Map.of("sCustomerId", "varchar"), List.of(
55 67 Map.of("sId", "idShort", "name", "印刷"),
... ...