Commit e6886ee977d687692c09a467c5538cacb5d8891e
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
Showing
7 changed files
with
213 additions
and
86 deletions
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 | +} |
-
mentioned in commit 576ed951