diff --git a/src/main/java/com/xly/service/FormRenderService.java b/src/main/java/com/xly/service/FormRenderService.java index a6d6543..0de024b 100644 --- a/src/main/java/com/xly/service/FormRenderService.java +++ b/src/main/java/com/xly/service/FormRenderService.java @@ -271,13 +271,22 @@ public class FormRenderService { n.error = "「" + value + "」不是系统里已有的" + ent + ",请从已有记录里选一个。"; return n; } - Map pick = null; String want = value.trim(); - for (Map c : cands) { // 唯一精确同名直接命中 + Map pick = null; + int exact = 0; + for (Map c : cands) { if (want.equals(String.valueOf(c.get("name")))) { - pick = pick == null ? c : pick; + exact++; + if (pick == null) { + pick = c; + } } } + if (exact > 1) { // 完全同名的多条记录:名称无从区分,绝不静默绑其中一条 + n.error = "系统里存在多条同名的「" + want + "」记录,无法按名称区分," + + "请在 ERP 里处理这条数据、或先把重名记录改名后再试。"; + return n; + } if (pick == null && cands.size() == 1) { pick = cands.get(0); } @@ -494,6 +503,10 @@ public class FormRenderService { custId = String.valueOf(n.stored); } } else { + if (!tt.containsKey(colName)) { // 与 normalize 同款幻影列硬闸(保护交给检查,不靠策展清单) + out.error = "「" + zh + "」不是可写字段,请从表单里选有效字段。"; + return out; + } n = new Normalized(); try { n.stored = resolver.coerce(tt.get(colName), v); diff --git a/src/main/java/com/xly/service/FormResolverService.java b/src/main/java/com/xly/service/FormResolverService.java index 1b96105..3e56c4a 100644 --- a/src/main/java/com/xly/service/FormResolverService.java +++ b/src/main/java/com/xly/service/FormResolverService.java @@ -111,11 +111,26 @@ public class FormResolverService { return m; } - /** 猜该表单/表的"名称"字段(以 Name 结尾、使用最广),用于按关键词过滤 / 展示记录名。 */ + /** + * 猜该表单/表的"名称"字段(以 Name 结尾、使用最广),用于按关键词过滤 / 展示记录名。 + * 字段字典含幻影列(108 个 FK 目标表里 55 个的首选名称列不存在于物理表)——只返回物理存在的, + * 否则下游按不存在的列过滤/查询会把元数据问题报成「找不到记录」这类用户错误。 + */ public String resolveNameField(String table) { - return queryOne( - "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + - "ORDER BY iFormUses DESC LIMIT 1", table); + try { + Map physical = columnTypes(table); + List> rows = jdbc.queryForList( + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + + "GROUP BY sField ORDER BY SUM(iFormUses) DESC LIMIT 5", table); + for (Map r : rows) { + String f = String.valueOf(r.get("sField")); + if (physical.containsKey(f)) { + return f; + } + } + } catch (Exception ignore) { + } + return null; } /** 按关键词形态选检索字段:形如单号(字母前缀+数字)且表有 sBillNo 列 → 用单号列;否则名称列。 */ @@ -127,13 +142,10 @@ public class FormResolverService { return resolveNameField(table); } - /** 同 {@link #resolveNameField},但从 formId 出发(经表单目录找到数据源表)。 */ + /** 同 {@link #resolveNameField}(含幻影列过滤),但从 formId 出发(经表单目录找到数据源表)。 */ public String resolveNameFieldByFormId(String formId) { - return queryOne( - "SELECT fd.sField FROM viw_ai_useful_forms af " + - "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " + - "WHERE af.sFormId = ? AND fd.sField LIKE '%Name' AND fd.sField NOT LIKE '%NameId' " + - "ORDER BY fd.iFormUses DESC LIMIT 1", formId); + String table = queryOne("SELECT sDataSource FROM viw_ai_useful_forms WHERE sFormId=? LIMIT 1", formId); + return table == null ? null : resolveNameField(table); } // ERP 会自动注入 / 系统管理、不该让用户填的列(新增时排除)。 @@ -426,17 +438,18 @@ public class FormResolverService { } int cap = Math.max(1, Math.min(limit, 10)); boolean tenantScoped = columnTypes(fkTable).containsKey("sBrandsId"); + // LIKE 通配转义(用户原话里的 %/_ 不是通配符);sId 兜底排序保证预览与保存两次解析确定同序 + String pattern = "%" + name.trim().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"; + String orderBy = " ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC, sId ASC LIMIT " + cap; 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() + "%"); + "` WHERE `" + nameField + "` LIKE ?" + orderBy, pattern); } 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() + "%"); + "` WHERE sBrandsId=? AND `" + nameField + "` LIKE ?" + orderBy, brand, pattern); } return rows; } catch (Exception e) { diff --git a/src/main/java/com/xly/service/LedgerService.java b/src/main/java/com/xly/service/LedgerService.java index 3cbce05..cd38222 100644 --- a/src/main/java/com/xly/service/LedgerService.java +++ b/src/main/java/com/xly/service/LedgerService.java @@ -209,10 +209,13 @@ public class LedgerService { return List.of(); } Collections.reverse(out); - try { // 回填热缓存(尽力而为,恒为全量窗口) + try { // 回填热缓存(尽力而为,恒为全量窗口);warm 锁防并发双回填把整段会话推两遍 String key = PREFIX + convId; - redis.opsForList().rightPushAll(key, out); - redis.expire(key, TTL); + if (Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(key + ":warm", "1", Duration.ofSeconds(15))) + && !Boolean.TRUE.equals(redis.hasKey(key))) { + redis.opsForList().rightPushAll(key, out); + redis.expire(key, TTL); + } } catch (Exception ignore) { } return lastN > 0 && out.size() > lastN ? out.subList(out.size() - lastN, out.size()) : out; diff --git a/src/test/java/com/xly/service/FormRenderNormalizeTest.java b/src/test/java/com/xly/service/FormRenderNormalizeTest.java index 7004a68..a1b4178 100644 --- a/src/test/java/com/xly/service/FormRenderNormalizeTest.java +++ b/src/test/java/com/xly/service/FormRenderNormalizeTest.java @@ -50,6 +50,18 @@ class FormRenderNormalizeTest { } @Test + void duplicateExactNamesAreRejectedNotFirstPicked() { + // 本地库实证:同品牌下「普通彩盒」有 10 条——完全同名时名称无从区分,绝不静默绑第一条 + FormResolverService r = resolverWith(Map.of("sProductId", "varchar"), List.of( + Map.of("sId", "prodA", "name", "普通彩盒"), + Map.of("sId", "prodB", "name", "普通彩盒"))); + FormRenderService.Normalized n = svc(r).normalize("t", "sProductId", "eleproduct", "产品名称", "普通彩盒", WHO); + assertNotNull(n.error, "同名多条必须报错——shown 恰好等于原话,卡片看不出绑错了谁"); + assertTrue(n.error.contains("同名")); + assertNull(n.stored); + } + + @Test void exactNameWinsOverShorterFuzzyMatches() { FormResolverService r = resolverWith(Map.of("sCustomerId", "varchar"), List.of( Map.of("sId", "idShort", "name", "印刷"),