Commit 4aacc3ab8d82cd25ad42afb6a7097525680a2a53
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)
Showing
4 changed files
with
61 additions
and
20 deletions
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", "印刷"), | ... | ... |
-
mentioned in commit 576ed951