Commit e6886ee977d687692c09a467c5538cacb5d8891e

Authored by zichun
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
docs/agent-architecture.md
... ... @@ -516,7 +516,8 @@ ai_op_queue(
516 516 LLM 窗口截断的孤儿 tool_result 丢弃。校准回路:`prompt_eval_count` vs 估算+定额,超出即告警
517 517 (TracingChatModelListener)。
518 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 522 ### 21.3 技能:ai_skill 表存在开关
522 523  
... ...
src/main/java/com/xly/config/CorsConfig.java
1 1 package com.xly.config;
2 2  
  3 +import org.springframework.beans.factory.annotation.Value;
3 4 import org.springframework.context.annotation.Bean;
4 5 import org.springframework.context.annotation.Configuration;
5 6 import org.springframework.web.cors.CorsConfiguration;
6 7 import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
7 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 17 @Configuration
19 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 24 @Bean
25 25 public CorsFilter corsFilter() {
26 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 33 config.addAllowedMethod("*");
33   -
34   - // 允许所有请求头
35 34 config.addAllowedHeader("*");
36   -
37   - // 允许携带凭证(如cookies)
38 35 config.setAllowCredentials(true);
39   -
40   - // 暴露所有响应头
41 36 config.addExposedHeader("*");
42   -
43   - // 预检请求缓存时间
44 37 config.setMaxAge(3600L);
45 38  
46 39 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
47 40 source.registerCorsConfiguration("/**", config);
48   -
49 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 43 \ No newline at end of file
  44 +}
... ...
src/main/java/com/xly/service/FormRenderService.java
... ... @@ -93,7 +93,8 @@ public class FormRenderService {
93 93 "AND IFNULL(bReadonly,0)=0 ORDER BY iOrder LIMIT " + MAX_FIELDS, formId);
94 94 for (Map<String, Object> c : cols) {
95 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 98 continue;
98 99 }
99 100 Map<String, Object> f = new LinkedHashMap<>();
... ... @@ -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 222 public Map<String, Object> resolveColumn(String table, String zh) {
219 223 try {
  224 + Map<String, String> types = resolver.columnTypes(table);
220 225 List<Map<String, Object>> r = jdbc.queryForList(
221 226 "SELECT sField col, MAX(sFkTable) fk FROM viw_kg_field_dict " +
222 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 242 } catch (Exception e) {
235 243 return null;
236 244 }
... ... @@ -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 260 public Normalized normalize(String table, String col, String fk, String label, String value, AgentIdentity identity) {
251 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 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 270 String ent = label == null ? "" : label.replace("名称", "");
256 271 n.error = "「" + value + "」不是系统里已有的" + ent + ",请从已有记录里选一个。";
257 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 295 return n;
262 296 }
263 297 try {
264   - n.stored = resolver.coerce(resolver.columnTypes(table).get(col), value);
  298 + n.stored = resolver.coerce(types.get(col), value);
265 299 } catch (IllegalArgumentException ex) {
266 300 n.error = "「" + label + "」" + ex.getMessage() + ",请给一个有效值。";
267 301 return n;
... ... @@ -448,7 +482,6 @@ public class FormRenderService {
448 482 descParts.add(zh + "=" + String.join(",", manyQtys));
449 483 continue;
450 484 }
451   - String targetTable = "slave".equals(tgt) ? "quoquotationslave" : out.table;
452 485 Map<String, String> tt = "slave".equals(tgt) ? slaveTypes : masterTypes;
453 486 Normalized n;
454 487 if (fk != null && !fk.isBlank()) {
... ... @@ -479,7 +512,8 @@ public class FormRenderService {
479 512 } else {
480 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 517 descParts.add(zh + "=" + shown + (shown.equals(v) ? "" : "(原话:" + v + ")"));
484 518 }
485 519 if (descParts.isEmpty()) {
... ...
src/main/java/com/xly/service/FormResolverService.java
... ... @@ -368,6 +368,8 @@ public class FormResolverService {
368 368 }
369 369 List<Map<String, Object>> out = new ArrayList<>();
370 370 try {
  371 + // 字段字典由表单控件名构建,含大量幻影列——只呈现物理表真实存在的列
  372 + Map<String, String> physical = columnTypes(table);
371 373 List<Map<String, Object>> rows = jdbc.queryForList(
372 374 "SELECT sField, " +
373 375 // 标签取非Id、更短的中文名(客户 优先于 客户Id)
... ... @@ -376,7 +378,7 @@ public class FormResolverService {
376 378 "WHERE sTable=? AND CHAR_LENGTH(sChinese)>=2 GROUP BY sField ORDER BY u DESC", table);
377 379 for (Map<String, Object> r : rows) {
378 380 String col = String.valueOf(r.get("sField"));
379   - if (isSystemColumn(col)) {
  381 + if (isSystemColumn(col) || !physical.containsKey(col)) {
380 382 continue;
381 383 }
382 384 Map<String, Object> f = new LinkedHashMap<>();
... ... @@ -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 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 419 if (fkTable == null || fkTable.isBlank() || name == null || name.isBlank()
407 420 || brand == null || brand.isBlank()) {
408   - return null;
  421 + return List.of();
409 422 }
410 423 String nameField = resolveNameField(fkTable);
411 424 if (nameField == null) {
412   - return null;
  425 + return List.of();
413 426 }
  427 + int cap = Math.max(1, Math.min(limit, 10));
414 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 179 return out;
180 180 }
181 181  
182   - /** Redis 缓存失效 → 回源 MySQL(有界窗口)并回填缓存。 */
  182 + /**
  183 + * Redis 缓存失效 → 回源 MySQL 并回填缓存。**无论调用方要多小的窗口,回填一律用全量窗口**——
  184 + * 若按调用方窗口(如去重探针的 6 条)回填,key 一旦置为存在,后续读取永不再回源,
  185 + * 会话历史会被永久截断成那几条。回填后按 lastN 切尾返回。
  186 + */
183 187 private List<String> coldRead(String convId, int lastN) {
184 188 if (jdbc == null) {
185 189 return List.of();
186 190 }
187   - int window = lastN <= 0 ? COLD_READ_MAX : Math.min(lastN, COLD_READ_MAX);
188 191 try {
189 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 194 convId);
192 195 if (rows.isEmpty()) {
193 196 return List.of();
... ... @@ -206,13 +209,13 @@ public class LedgerService {
206 209 return List.of();
207 210 }
208 211 Collections.reverse(out);
209   - try { // 回填热缓存(尽力而为
  212 + try { // 回填热缓存(尽力而为,恒为全量窗口
210 213 String key = PREFIX + convId;
211 214 redis.opsForList().rightPushAll(key, out);
212 215 redis.expire(key, TTL);
213 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 219 } catch (Exception e) {
217 220 log.warn("ledger mysql read failed (conv={}): {}", convId, e.getMessage());
218 221 return List.of();
... ...
src/main/java/com/xly/web/AgentChatController.java
... ... @@ -121,7 +121,7 @@ public class AgentChatController {
121 121  
122 122 /**
123 123 * 确定性表单保存:collectForm 表单的结构化字段直达 create 校验(FK/类型/系统列)并写 ai_op_queue
124   - * (用户点【保存】= 已当面授权 → sStatus=confirmed)。**xlyAi 到此为止**,执行由 ERP 侧负责。
  124 + * (用户点【保存】= 已当面授权 → 入队 pending+100)。**xlyAi 到此为止**,执行由 ERP 侧负责。
125 125 * 同步返回 {queued,opId,summary,message} 或 {error}。
126 126 */
127 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 +}
... ...