wrapper = new LambdaQueryWrapper<>();\r\n wrapper.and(w -> w\r\n .apply(\"material_code LIKE {0}\", \"%\" + escaped + \"%\")\r\n .or()\r\n .apply(\"material_name LIKE {0}\", \"%\" + escaped + \"%\")\r\n .or()\r\n .apply(\"spec LIKE {0}\", \"%\" + escaped + \"%\")\r\n );\r\n wrapper.select(Material::getId);\r\n return selectList(wrapper).stream()\r\n .map(Material::getId)\r\n .collect(Collectors.toList());\r\n }\r\n\r\n}\r\n"}],"isError":false,"timestamp":1780017933583}}
{"type":"message","id":"62f396c9","parentId":"376e2bbd","timestamp":"2026-05-29T01:25:33.583Z","message":{"role":"toolResult","toolCallId":"call_4d47ea19647d4e97ac05b221","toolName":"read","content":[{"type":"text","text":"package com.witsoft.erp.common.util;\n\n/**\n * LIKE 查询特殊字符转义工具类\n * \n * SQL LIKE 查询中,% 和 _ 是通配符,需要转义。\n * 本工具转义 \\ % _ 三个字符,配合 ESCAPE '\\' 使用。\n */\npublic class LikeEscapeUtil {\n\n /**\n * 转义 LIKE 查询中的特殊字符\n *
\n * SQL LIKE 通配符:% _ 需要转义\n * 数据中出现的特殊字符:空格、%、x、_、-、/、\\ 均不影响 LIKE 查询(% 和 _ 已被转义)\n * \n * @param keyword 原始关键字\n * @return 转义后的关键字\n */\n public static String escapeLike(String keyword) {\n if (keyword == null) {\n return null;\n }\n return keyword\n .replace(\"\\\\\", \"\\\\\\\\\")\n .replace(\"%\", \"\\\\%\")\n .replace(\"_\", \"\\\\_\");\n }\n}"}],"isError":false,"timestamp":1780017933583}}
{"type":"message","id":"7f6a9bf1","parentId":"62f396c9","timestamp":"2026-05-29T01:25:53.287Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Now I have a full picture of the existing codebase. Let me analyze the situation and provide a comprehensive answer about the \"近似查询\" (approximate/fuzzy query) approach.\n\nThe user is asking about a query approximation strategy for materials, with these requirements:\n1. AI should return name and spec\n2. Exact match by name\n3. Fuzzy match by name\n4. Fuzzy match by spec\n5. When multiple results are returned, analyze priority\n\nLet me now look at what the current code already does and propose a comprehensive solution.\n\nCurrent state:\n- `MaterialServiceImpl.searchByNameLike(String keyword)` - does a combined LIKE on material_code, material_name, spec with `OR` - so all three fields are searched with the same keyword\n- `MaterialMapper.searchMaterialIdsByKeyword` - same combined LIKE\n- `MaterialSelectorServiceImpl` - keyword matching in `matchesKeyword()` method\n- `AiRecognitionServiceImpl.matchMaterialItem()` - complex multi-level fuzzy matching strategy\n\nThe user wants a more structured approach. Let me think about what would be a good design.\n\nThe key architectural question is: when the user/ai provides a query string that might match multiple materials by different criteria (exact name match, fuzzy name match, fuzzy spec match), how should we prioritize results?\n\nA good approach would be something like:\n\n1. **Separate the matching strategies** - don't use a single OR LIKE\n2. **Define priority tiers**:\n - Tier 1: Exact name match\n - Tier 2: Name starts with / prefix match\n - Tier 3: Name contains keyword (fuzzy)\n - Tier 4: Spec contains keyword\n - Tier 5: Code contains keyword\n3. **Return with priority label** so the caller knows which tier matched\n\nLet me propose a clean solution.","thinkingSignature":"reasoning_content"},{"type":"text","text":"现在我对整个查询链路有完整了解了。给你一个综合分析。\n\n---\n\n## 现有查询链路分析\n\n目前项目中有**三处**物料近似查询逻辑,分散在不同地方:\n\n| 位置 | 方式 | 问题 |\n|---|---|---|\n| `MaterialService.searchByNameLike()` | 一个 `keyword` 同时 LIKE 编码/名称/规格(OR) | 三条路混在一起,不知道哪个优先级 |\n| `MaterialSelectorServiceImpl.matchesKeyword()` | 内存中 `contains` 匹配代码/名称/规格 | 无优先级,纯集合过滤 |\n| `AiRecognitionServiceImpl.matchMaterialItem()` | 多层降级搜索(名称→名称+规格→分词→汉字片段) | 每次只取第一个,放弃多候选 |\n\n核心痛点:**没有按匹配类型分优先级排序,多条结果混在一起无法区分。**\n\n---\n\n## 推荐方案:结构化近似查询\n\n核心思路:**一个查询不只返回「有没有匹配到」,还要返回「匹配的场次(match_type)」。**\n\n### 1. 定义匹配优先级枚举\n\n```java\npublic enum MatchPriority {\n EXACT_NAME(1, \"名称精确匹配\"), // material_name = keyword\n PREFIX_NAME(2, \"名称前缀匹配\"), // material_name LIKE 'keyword%'\n FUZZY_NAME(3, \"名称模糊匹配\"), // material_name LIKE '%keyword%'\n FUZZY_SPEC(4, \"规格模糊匹配\"), // spec LIKE '%keyword%'\n FUZZY_CODE(5, \"编码模糊匹配\"); // material_code LIKE '%keyword%'\n\n public final int rank;\n public final String desc;\n\n MatchPriority(int rank, String desc) {\n this.rank = rank;\n this.desc = desc;\n }\n}\n```\n\n### 2. SQL 分步查询(按优先级降级)\n\n```sql\n-- Step 1: 精确匹配名称\nSELECT id, material_code, material_name, spec, unit, 'EXACT_NAME' AS match_type\n FROM md_material\n WHERE material_name = :keyword\n AND status = 0\n\n-- Step 2: 名称前缀匹配(如果有增删查改场景)\nSELECT id, ..., 'PREFIX_NAME' AS match_type\n FROM md_material\n WHERE material_name LIKE :keyword || '%'\n AND status = 0\n\n-- Step 3: 名称模糊匹配\nSELECT id, ..., 'FUZZY_NAME' AS match_type\n FROM md_material\n WHERE material_name LIKE '%' || :keyword || '%'\n AND status = 0\n\n-- Step 4: 规格模糊匹配\nSELECT id, ..., 'FUZZY_SPEC' AS match_type\n FROM md_material\n WHERE spec LIKE '%' || :keyword || '%'\n AND status = 0\n\n-- Step 5: 编码模糊匹配(兜底)\nSELECT id, ..., 'FUZZY_CODE' AS match_type\n FROM md_material\n WHERE material_code LIKE '%' || :keyword || '%'\n AND status = 0\n```\n\n> **为什么不全放一个 OR?** 因为 OR 出来的结果你无法知道是哪条条件命中的,排序时无法区分精确匹配和模糊匹配。\n\n### 3. 返回 DTO\n\n```java\n@Data\npublic class MaterialMatchResult {\n private Long id;\n private String materialCode;\n private String materialName;\n private String spec;\n private String unit;\n \n /** 命中类型(EXACT_NAME / FUZZY_SPEC 等) */\n private MatchPriority matchType;\n \n /** 排序权重(越小越优先,展示时按此排序) */\n private Integer matchRank;\n \n /** 总结果数(方便前端决策是否展示\"搜索到 N 条\") */\n private Integer totalMatches;\n}\n```\n\n### 4. Service 实现架构\n\n```java\n@Override\npublic List searchSimilar(String keyword) {\n String escaped = escapeLike(keyword);\n List results = new ArrayList<>();\n Set addedIds = new HashSet<>(); // 防重复\n\n // 按优先级依次查询,已匹配的 ID 不再出现在更低优先级中\n // 1. 名称精确匹配\n searchAndAdd(results, addedIds, \n wrapper -> wrapper.eq(Material::getMaterialName, keyword),\n MatchPriority.EXACT_NAME);\n\n // 2. 名称前缀匹配\n searchAndAdd(results, addedIds,\n wrapper -> wrapper.apply(\"material_name LIKE {0}\", escaped + \"%\"),\n MatchPriority.PREFIX_NAME);\n\n // 3. 名称模糊匹配\n searchAndAdd(results, addedIds,\n wrapper -> wrapper.apply(\"material_name LIKE {0}\", \"%\" + escaped + \"%\"),\n MatchPriority.FUZZY_NAME);\n\n // 4. 规格模糊匹配\n searchAndAdd(results, addedIds,\n wrapper -> wrapper.apply(\"spec LIKE {0}\", \"%\" + escaped + \"%\"),\n MatchPriority.FUZZY_SPEC);\n\n // 5. 编码模糊匹配(兜底)\n searchAndAdd(results, addedIds,\n wrapper -> wrapper.apply(\"material_code LIKE {0}\", \"%\" + escaped + \"%\"),\n MatchPriority.FUZZY_CODE);\n\n return results;\n}\n\nprivate void searchAndAdd(List results, Set addedIds,\n Consumer> condition, MatchPriority priority) {\n LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();\n condition.accept(wrapper);\n wrapper.eq(Material::getStatus, 0);\n wrapper.orderByDesc(Material::getCreateTime);\n wrapper.last(\"LIMIT 20\");\n\n List list = baseMapper.selectList(wrapper);\n for (Material m : list) {\n if (addedIds.add(m.getId())) {\n MaterialMatchResult r = new MaterialMatchResult();\n // 填充字段\n r.setMatchType(priority);\n r.setMatchRank(priority.rank);\n results.add(r);\n }\n }\n}\n```\n\n### 5. AI 场景的适配(`AiRecognitionServiceImpl`)\n\n现有逻辑每次只取 `candidates.get(0)`,改为:\n\n```java\n// 不再只取第一个,改用结构化搜索\nList candidates = materialService.searchSimilar(normalized);\n\nif (candidates.isEmpty()) {\n // 降级:分词搜索(和现有逻辑一致)\n ...\n}\n\n// 取优先级最高的候选\nMaterialMatchResult best = candidates.get(0);\n```\n\n并且 `RecognitionItem` 中可以新增一个 `matchType` 字段,前端可以看到匹配质量(精确/模糊)。\n\n---\n\n## 优先级总览\n\n| 优先级 | 匹配方式 | SQL 条件 | 场景示例(输入\"8.5mm玻璃棒\") |\n|---|---|---|---|\n| 🥇 1 | 名称精确匹配 | `name = keyword` | 数据库刚好有物料叫\"8.5mm玻璃棒\" |\n| 🥇 2 | 名称前缀匹配 | `name LIKE 'keyword%'` | 前缀匹配(较少用,视业务需求可跳过) |\n| 🥇 3 | 名称模糊匹配 | `name LIKE '%keyword%'` | 名称中包含\"8.5mm玻璃棒\" |\n| 🥇 4 | 规格模糊匹配 | `spec LIKE '%keyword%'` | 规格字段中有\"8.5mm\"或\"玻璃棒\" |\n| 🥇 5 | 编码模糊匹配 | `code LIKE '%keyword%'` | 编码中包含该关键字(兜底) |\n\n**输出自然形成排序:** 精确匹配排最前,越模糊越靠后。\n\n---\n\n## 与现有代码的兼容\n\n- `searchByNameLike()` 保留不动(现有调用者需要原始 OR 行为),新增 `searchSimilar()`\n- `MaterialSelectorServiceImpl.matchesKeyword()` 同理保留,不改\n- 只改 `AiRecognitionServiceImpl` 和物料搜索前端用的地方\n\n---\n\n要我把这个方案落地成代码?还是你先看方案,有地方需要调整再聊?"}],"api":"openai-completions","provider":"new-api","model":"deepseek-v4-flash","usage":{"input":958,"output":2124,"cacheRead":35584,"cacheWrite":0,"reasoningTokens":377,"totalTokens":38666,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1780017933600,"responseId":"chatcmpl-aa6864f7-43cc-4f36-b462-ae56b7f496cd"}}
{"type":"message","id":"6f53d1c0","parentId":"7f6a9bf1","timestamp":"2026-05-29T01:30:33.710Z","message":{"role":"user","content":[{"type":"text","text":"[Fri 2026-05-29 09:30 GMT+8] 保存方案文档到 tiny yangxuan/设计"}],"timestamp":1780018233603}}
{"type":"message","id":"6fef19eb","parentId":"6f53d1c0","timestamp":"2026-05-29T01:30:43.129Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to save the analysis/design document to a specific path. Let me interpret \"tiny yangxuan/设计\" - this likely means the tiny-erp project's design docs, and \"yangxuan/设计\" suggests a path structure. Let me think about where to put this.\n\nLooking at the context, the workspace is `/home/yangxuan/.openclaw/workspace/java`. The user said \"tiny yangxuan/设计\" - I think \"tiny\" refers to the tiny-erp project. Given the path convention, I'll save it under the workspace or a reasonable location. But actually, re-reading: \"保存方案文档到 tiny yangxuan/设计\" - this could mean:\n1. Save to the tiny-erp project's design folder with \"yangxuan/设计\" subpath\n2. Or save relative to workspace\n\nGiven the workspace is for \"java\" and the tiny-erp project is at `/home/yangxuan/Projects/IdeaProjects/tiny-erp`, I think the user wants me to save it to the tiny-erp project. But \"yangxuan/设计\" - maybe a design docs folder?\n\nLet me check if there's a design/docs folder in the tiny-erp project, or I'll create one.\n\nActually, re-reading more carefully: \"保存方案文档到 tiny yangxuan/设计\" - \"tiny\" is the project alias (from TOOLS.md: `tiny` / `tiny-erp` → `/home/yangxuan/Projects/IdeaProjects/tiny-erp`), so \"tiny yangxuan/设计\" means save to the tiny-erp project at path `yangxuan/设计/`. This is likely relative to the tiny-erp project root.\n\nLet me check if that path exists.","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_6e2d01514735431a8a8f8d8d","name":"exec","arguments":{"command":"ls -la /home/yangxuan/Projects/IdeaProjects/tiny-erp/yangxuan/ 2>/dev/null || echo \"NOT_EXISTS\"","workdir":"/home/yangxuan/Projects/IdeaProjects/tiny-erp"},"partialArgs":"{\"command\": \"ls -la /home/yangxuan/Projects/IdeaProjects/tiny-erp/yangxuan/ 2>/dev/null || echo \\\"NOT_EXISTS\\\"\", \"workdir\": \"/home/yangxuan/Projects/IdeaProjects/tiny-erp\"}"}],"api":"openai-completions","provider":"new-api","model":"deepseek-v4-flash","usage":{"input":37647,"output":483,"cacheRead":0,"cacheWrite":0,"reasoningTokens":374,"totalTokens":38130,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1780018233910,"responseId":"chatcmpl-0462ac89-d4ec-4ac0-a7de-f21284e1addd"}}
{"type":"message","id":"dfd63bad","parentId":"6fef19eb","timestamp":"2026-05-29T01:30:43.846Z","message":{"role":"toolResult","toolCallId":"call_6e2d01514735431a8a8f8d8d","toolName":"exec","content":[{"type":"text","text":"total 48\ndrwxrwxr-x 8 yangxuan yangxuan 4096 May 26 14:50 .\ndrwxrwxr-x 10 yangxuan yangxuan 4096 May 28 20:53 ..\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 26 10:42 API\n-rw-rw-r-- 1 yangxuan yangxuan 2763 May 25 18:56 README.md\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 29 09:23 其他\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 25 19:01 数据库\ndrwxr-xr-x 2 yangxuan yangxuan 4096 May 26 19:42 测试\n-rw-rw-r-- 1 yangxuan yangxuan 2771 May 26 14:56 物料信息填充公共服务总结.md\ndrwxrwxr-x 8 yangxuan yangxuan 4096 May 26 10:32 设计\ndrwxr-xr-x 8 yangxuan yangxuan 4096 May 25 18:58 需求\n-rw-rw-r-- 1 yangxuan yangxuan 5162 May 25 18:49 项目规范.md"}],"details":{"status":"completed","exitCode":0,"durationMs":207,"aggregated":"total 48\ndrwxrwxr-x 8 yangxuan yangxuan 4096 May 26 14:50 .\ndrwxrwxr-x 10 yangxuan yangxuan 4096 May 28 20:53 ..\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 26 10:42 API\n-rw-rw-r-- 1 yangxuan yangxuan 2763 May 25 18:56 README.md\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 29 09:23 其他\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 25 19:01 数据库\ndrwxr-xr-x 2 yangxuan yangxuan 4096 May 26 19:42 测试\n-rw-rw-r-- 1 yangxuan yangxuan 2771 May 26 14:56 物料信息填充公共服务总结.md\ndrwxrwxr-x 8 yangxuan yangxuan 4096 May 26 10:32 设计\ndrwxr-xr-x 8 yangxuan yangxuan 4096 May 25 18:58 需求\n-rw-rw-r-- 1 yangxuan yangxuan 5162 May 25 18:49 项目规范.md","cwd":"/home/yangxuan/Projects/IdeaProjects/tiny-erp"},"isError":false,"timestamp":1780018243846}}
{"type":"message","id":"0fcae377","parentId":"dfd63bad","timestamp":"2026-05-29T01:30:46.377Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Good, the directory `设计` already exists under `yangxuan/`. Let me check what's already in it.","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_6b68771c08894e71b545affa","name":"exec","arguments":{"command":"ls -la /home/yangxuan/Projects/IdeaProjects/tiny-erp/yangxuan/设计/","workdir":"/home/yangxuan/Projects/IdeaProjects/tiny-erp"},"partialArgs":"{\"command\": \"ls -la /home/yangxuan/Projects/IdeaProjects/tiny-erp/yangxuan/设计/\", \"workdir\": \"/home/yangxuan/Projects/IdeaProjects/tiny-erp\"}"}],"api":"openai-completions","provider":"new-api","model":"deepseek-v4-flash","usage":{"input":438,"output":124,"cacheRead":37632,"cacheWrite":0,"reasoningTokens":25,"totalTokens":38194,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1780018243848,"responseId":"chatcmpl-75593c6d-7d5f-49d2-bb91-5fdb5dccd207"}}
{"type":"message","id":"6626aabc","parentId":"0fcae377","timestamp":"2026-05-29T01:30:47.122Z","message":{"role":"toolResult","toolCallId":"call_6b68771c08894e71b545affa","toolName":"exec","content":[{"type":"text","text":"total 56\ndrwxrwxr-x 8 yangxuan yangxuan 4096 May 26 10:32 .\ndrwxrwxr-x 8 yangxuan yangxuan 4096 May 26 14:50 ..\n-rw-r--r-- 1 yangxuan yangxuan 21349 May 23 17:17 01-开发方案.md\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 25 18:57 主数据模块\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 26 11:25 库存模块\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 25 18:57 系统模块\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 28 10:40 财务模块\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 25 18:57 采购模块\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 25 18:57 销售模块"}],"details":{"status":"completed","exitCode":0,"durationMs":211,"aggregated":"total 56\ndrwxrwxr-x 8 yangxuan yangxuan 4096 May 26 10:32 .\ndrwxrwxr-x 8 yangxuan yangxuan 4096 May 26 14:50 ..\n-rw-r--r-- 1 yangxuan yangxuan 21349 May 23 17:17 01-开发方案.md\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 25 18:57 主数据模块\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 26 11:25 库存模块\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 25 18:57 系统模块\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 28 10:40 财务模块\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 25 18:57 采购模块\ndrwxrwxr-x 2 yangxuan yangxuan 4096 May 25 18:57 销售模块","cwd":"/home/yangxuan/Projects/IdeaProjects/tiny-erp"},"isError":false,"timestamp":1780018247121}}
{"type":"message","id":"995ed6a7","parentId":"6626aabc","timestamp":"2026-05-29T01:31:05.083Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to save the analysis document about the \"查询近似规则\" (fuzzy/approximate search) to the `tiny-erp/yangxuan/设计/` directory. I'll write a comprehensive design document there.","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_49b53b043c024d128d4ec852","name":"write","arguments":{"path":"/home/yangxuan/Projects/IdeaProjects/tiny-erp/yangxuan/设计/02-物料近似查询方案.md","content":"# 物料近似查询方案\n\n> 2026-05-29 更新\n\n## 一、背景\n\n现有物料查询存在多个入口,但缺乏统一的近似匹配策略:\n\n| 位置 | 方式 | 问题 |\n|------|------|------|\n| `MaterialService.searchByNameLike()` | 一个 `keyword` 同时 LIKE 编码/名称/规格(OR) | 三条路混在一起,无法区分命中类型,排序无优先级 |\n| `MaterialSelectorServiceImpl.matchesKeyword()` | 内存中 `contains` 匹配代码/名称/规格 | 无优先级,纯集合过滤 |\n| `AiRecognitionServiceImpl.matchMaterialItem()` | 多层降级搜索 | 每次只取第一个匹配,放弃多候选分析 |\n\n核心痛点:**无法知道命中哪个字段,无法按匹配质量排序,多条结果混在一起。**\n\n## 二、匹配优先级定义\n\n按精确度从高到低,定义 5 级匹配优先级:\n\n| 优先级 | 匹配方式 | SQL 条件 | 示意场景 |\n|--------|----------|----------|----------|\n| 🥇 1 | 名称精确匹配 | `material_name = keyword` | 输入\"8.5mm玻璃棒\" → 数据库刚好有这个名称 |\n| 🥇 2 | 名称前缀匹配 | `material_name LIKE 'keyword%'` | \"玻璃棒\" → \"玻璃棒 8.5mm\" |\n| 🥇 3 | 名称模糊匹配 | `material_name LIKE '%keyword%'` | \"玻璃棒\" → \"高硼硅玻璃棒\" |\n| 🥇 4 | 规格模糊匹配 | `spec LIKE '%keyword%'` | \"玻璃棒\" → 物料名称为\"石英管\"但规格字段含\"玻璃棒\" |\n| 🥇 5 | 编码模糊匹配 | `material_code LIKE '%keyword%'` | 兜底,编码中包含该关键字 |\n\n名称精确匹配最高,编码模糊匹配最低。\n\n## 三、数据结构\n\n### 3.1 匹配优先级枚举\n\n```java\npublic enum MatchPriority {\n EXACT_NAME(1, \"名称精确匹配\"),\n PREFIX_NAME(2, \"名称前缀匹配\"),\n FUZZY_NAME(3, \"名称模糊匹配\"),\n FUZZY_SPEC(4, \"规格模糊匹配\"),\n FUZZY_CODE(5, \"编码模糊匹配\");\n\n public final int rank; // 排序权重,越小越优先\n public final String desc;\n\n MatchPriority(int rank, String desc) {\n this.rank = rank;\n this.desc = desc;\n }\n}\n```\n\n### 3.2 统一返回 DTO\n\n```java\n@Data\npublic class MaterialMatchResult {\n private Long id;\n private String materialCode;\n private String materialName;\n private String spec;\n private String unit;\n\n /** 命中类型(EXACT_NAME / FUZZY_SPEC 等) */\n private MatchPriority matchType;\n\n /** 排序权重(rank 值,越小越优先) */\n private Integer matchRank;\n\n /** 关联数据(物料类型名称、分类名称等,后续由 MaterialFillService 填充) */\n private MaterialInfoDTO materialInfo;\n}\n```\n\n## 四、核心查询流程\n\n### 4.1 分步查询 + 去重\n\n核心思路:按优先级从高到低依次查询,**每个更低优先级只搜索未被命中过的物料 ID**,最终结果自然按优先级排序。\n\n```\n输入 keyword\n\nStep 1: SELECT ... WHERE material_name = :keyword\n → 加入结果集,记录:匹配类型 = EXACT_NAME\n → 记录已命中的 ID 集合\n\nStep 2: SELECT ... WHERE material_name LIKE :keyword || '%'\n → 过滤掉已命中的 ID,剩余加入结果集\n → 匹配类型 = PREFIX_NAME\n\nStep 3: SELECT ... WHERE material_name LIKE '%' || :keyword || '%'\n → 过滤已命中 ID\n → 匹配类型 = FUZZY_NAME\n\nStep 4: SELECT ... WHERE spec LIKE '%' || :keyword || '%'\n → 过滤已命中 ID\n → 匹配类型 = FUZZY_SPEC\n\nStep 5: SELECT ... WHERE material_code LIKE '%' || :keyword || '%'\n → 过滤已命中 ID\n → 匹配类型 = FUZZY_CODE\n\n返回 List(有序,按 matchRank 升序)\n```\n\n### 4.2 为什么不用单个 OR?\n\n单个 OR 查询无法区分每条结果是命中 name 还是 spec 还是 code,排序时无法给精确匹配更高权重。分步查询天然得到优先级排序的结果。\n\n## 五、接口定义\n\n### 5.1 Service 层新增\n\n```java\npublic interface MaterialService extends IService {\n\n // 保留现有方法(不破坏调用者)\n Page pageWithStock(MaterialPageQueryReqDTO reqDTO);\n List searchMaterialIdsByKeyword(String keyword);\n List searchByNameLike(String keyword);\n\n // 新增:结构化近似查询,结果按匹配优先级排序\n List searchSimilar(String keyword);\n}\n```\n\n### 5.2 限流保护\n\n- 每个步骤 LIMIT 20\n- 最终结果上限:建议 30-50 条\n- 关键字空或过短(< 2 字符)时返回空\n\n## 六、现有调用处的迁移方案\n\n### 6.1 AI 识别场景(`AiRecognitionServiceImpl`)\n\n现有逻辑每次只取 `candidates.get(0)`,改为走结构化搜索:\n\n```java\n// 改前\nList candidates = materialService.searchByNameLike(normalized);\n// ... multi-level fallback ...\nif (!candidates.isEmpty()) {\n Material best = candidates.get(0);\n // ...\n}\n\n// 改后\nList candidates = materialService.searchSimilar(normalized);\nif (candidates.isEmpty()) {\n // 降级:分词搜索(保留现有降级逻辑)\n}\nMaterialMatchResult best = candidates.get(0);\n// 同时可将 matchType 带回前端\n```\n\n在 `RecognitionItem` 中增加 `matchType` 字段,前端可视化展示匹配质量。\n\n### 6.2 物料选择器(`MaterialSelectorServiceImpl`)\n\n入库/出库选择器的 `matchesKeyword()` 内存过滤改为按优先级匹配:\n\n```java\n// 改前:内存中 contains 匹配\n.filter(dto -> matchesKeyword(dto, keyword))\n\n// 改后:按优先级搜索并排序(或者在 SQL 层就按优先级查)\n```\n\n### 6.3 库存查询场景(`StockServiceImpl`)\n\n当前库存分页查询中通过 `searchMaterialIdsByKeyword` 获取物料 ID 再 JOIN 库存表:\n\n```java\n// 改前\nList matchedMaterialIds = materialMapper.searchMaterialIdsByKeyword(dto.getKeyword());\n\n// 改后:加入排序,保持匹配优先级排序\nList matched = materialService.searchSimilar(dto.getKeyword());\nList matchedMaterialIds = matched.stream().map(MaterialMatchResult::getId).toList();\n```\n\n## 七、特殊字符处理\n\n统一使用 `LikeEscapeUtil` 转义 `\\` `%` `_` 三个字符后拼接 `%` 通配符。\n\n```sql\n-- 示例:keyword = \"8.5%玻璃_棒\"\n-- 转义后:material_name LIKE '%8.5\\%玻璃\\_棒%' ESCAPE '\\'\n```\n\n## 八、与现有代码的兼容性\n\n- **`searchByNameLike()` 保留不动** — 现有调用方不需要改\n- **`searchMaterialIdsByKeyword()` 保留不动** — 兼容旧查询路径\n- **`matchesKeyword()` 保留不动** — 选择器内存过滤仍可用\n- 新增 `searchSimilar()` 方法,调用方按需迁移\n\n## 九、PostgreSQL 性能考虑\n\n- 物料表 `md_material` 预计规模不超过 10 万条\n- `material_name`、`spec`、`material_code` 建议建索引(**已建则忽略**)\n- 分步查询每次命中特定索引,不会出现全表扫描\n\n```sql\n-- 建议索引\nCREATE INDEX idx_md_material_name ON md_material(material_name);\nCREATE INDEX idx_md_material_spec ON md_material(spec);\nCREATE INDEX idx_md_material_code ON md_material(material_code);\n```\n\n## 十、前端展示建议\n\n返回结果携带 `matchType`,前端根据匹配类型给出视觉提示:\n\n| 匹配类型 | 展示样式 |\n|----------|----------|\n| EXACT_NAME | 正常高亮(绿底/粗体) |\n| PREFIX_NAME / FUZZY_NAME | 仅高亮匹配部分 |\n| FUZZY_SPEC | 灰色标注\"规格匹配\" |\n| FUZZY_CODE | 灰色标注\"编码匹配\" |\n\n同时展示每条结果的 `score/matchType`,帮助用户判断匹配质量。"},"partialArgs":"{\"path\": \"/home/yangxuan/Projects/IdeaProjects/tiny-erp/yangxuan/设计/02-物料近似查询方案.md\", \"content\": \"# 物料近似查询方案\\n\\n> 2026-05-29 更新\\n\\n## 一、背景\\n\\n现有物料查询存在多个入口,但缺乏统一的近似匹配策略:\\n\\n| 位置 | 方式 | 问题 |\\n|------|------|------|\\n| `MaterialService.searchByNameLike()` | 一个 `keyword` 同时 LIKE 编码/名称/规格(OR) | 三条路混在一起,无法区分命中类型,排序无优先级 |\\n| `MaterialSelectorServiceImpl.matchesKeyword()` | 内存中 `contains` 匹配代码/名称/规格 | 无优先级,纯集合过滤 |\\n| `AiRecognitionServiceImpl.matchMaterialItem()` | 多层降级搜索 | 每次只取第一个匹配,放弃多候选分析 |\\n\\n核心痛点:**无法知道命中哪个字段,无法按匹配质量排序,多条结果混在一起。**\\n\\n## 二、匹配优先级定义\\n\\n按精确度从高到低,定义 5 级匹配优先级:\\n\\n| 优先级 | 匹配方式 | SQL 条件 | 示意场景 |\\n|--------|----------|----------|----------|\\n| 🥇 1 | 名称精确匹配 | `material_name = keyword` | 输入\\\"8.5mm玻璃棒\\\" → 数据库刚好有这个名称 |\\n| 🥇 2 | 名称前缀匹配 | `material_name LIKE 'keyword%'` | \\\"玻璃棒\\\" → \\\"玻璃棒 8.5mm\\\" |\\n| 🥇 3 | 名称模糊匹配 | `material_name LIKE '%keyword%'` | \\\"玻璃棒\\\" → \\\"高硼硅玻璃棒\\\" |\\n| 🥇 4 | 规格模糊匹配 | `spec LIKE '%keyword%'` | \\\"玻璃棒\\\" → 物料名称为\\\"石英管\\\"但规格字段含\\\"玻璃棒\\\" |\\n| 🥇 5 | 编码模糊匹配 | `material_code LIKE '%keyword%'` | 兜底,编码中包含该关键字 |\\n\\n名称精确匹配最高,编码模糊匹配最低。\\n\\n## 三、数据结构\\n\\n### 3.1 匹配优先级枚举\\n\\n```java\\npublic enum MatchPriority {\\n EXACT_NAME(1, \\\"名称精确匹配\\\"),\\n PREFIX_NAME(2, \\\"名称前缀匹配\\\"),\\n FUZZY_NAME(3, \\\"名称模糊匹配\\\"),\\n FUZZY_SPEC(4, \\\"规格模糊匹配\\\"),\\n FUZZY_CODE(5, \\\"编码模糊匹配\\\");\\n\\n public final int rank; // 排序权重,越小越优先\\n public final String desc;\\n\\n MatchPriority(int rank, String desc) {\\n this.rank = rank;\\n this.desc = desc;\\n }\\n}\\n```\\n\\n### 3.2 统一返回 DTO\\n\\n```java\\n@Data\\npublic class MaterialMatchResult {\\n private Long id;\\n private String materialCode;\\n private String materialName;\\n private String spec;\\n private String unit;\\n\\n /** 命中类型(EXACT_NAME / FUZZY_SPEC 等) */\\n private MatchPriority matchType;\\n\\n /** 排序权重(rank 值,越小越优先) */\\n private Integer matchRank;\\n\\n /** 关联数据(物料类型名称、分类名称等,后续由 MaterialFillService 填充) */\\n private MaterialInfoDTO materialInfo;\\n}\\n```\\n\\n## 四、核心查询流程\\n\\n### 4.1 分步查询 + 去重\\n\\n核心思路:按优先级从高到低依次查询,**每个更低优先级只搜索未被命中过的物料 ID**,最终结果自然按优先级排序。\\n\\n```\\n输入 keyword\\n\\nStep 1: SELECT ... WHERE material_name = :keyword\\n → 加入结果集,记录:匹配类型 = EXACT_NAME\\n → 记录已命中的 ID 集合\\n\\nStep 2: SELECT ... WHERE material_name LIKE :keyword || '%'\\n → 过滤掉已命中的 ID,剩余加入结果集\\n → 匹配类型 = PREFIX_NAME\\n\\nStep 3: SELECT ... WHERE material_name LIKE '%' || :keyword || '%'\\n → 过滤已命中 ID\\n → 匹配类型 = FUZZY_NAME\\n\\nStep 4: SELECT ... WHERE spec LIKE '%' || :keyword || '%'\\n → 过滤已命中 ID\\n → 匹配类型 = FUZZY_SPEC\\n\\nStep 5: SELECT ... WHERE material_code LIKE '%' || :keyword || '%'\\n → 过滤已命中 ID\\n → 匹配类型 = FUZZY_CODE\\n\\n返回 List(有序,按 matchRank 升序)\\n```\\n\\n### 4.2 为什么不用单个 OR?\\n\\n单个 OR 查询无法区分每条结果是命中 name 还是 spec 还是 code,排序时无法给精确匹配更高权重。分步查询天然得到优先级排序的结果。\\n\\n## 五、接口定义\\n\\n### 5.1 Service 层新增\\n\\n```java\\npublic interface MaterialService extends IService {\\n\\n // 保留现有方法(不破坏调用者)\\n Page pageWithStock(MaterialPageQueryReqDTO reqDTO);\\n List searchMaterialIdsByKeyword(String keyword);\\n List searchByNameLike(String keyword);\\n\\n // 新增:结构化近似查询,结果按匹配优先级排序\\n List searchSimilar(String keyword);\\n}\\n```\\n\\n### 5.2 限流保护\\n\\n- 每个步骤 LIMIT 20\\n- 最终结果上限:建议 30-50 条\\n- 关键字空或过短(< 2 字符)时返回空\\n\\n## 六、现有调用处的迁移方案\\n\\n### 6.1 AI 识别场景(`AiRecognitionServiceImpl`)\\n\\n现有逻辑每次只取 `candidates.get(0)`,改为走结构化搜索:\\n\\n```java\\n// 改前\\nList candidates = materialService.searchByNameLike(normalized);\\n// ... multi-level fallback ...\\nif (!candidates.isEmpty()) {\\n Material best = candidates.get(0);\\n // ...\\n}\\n\\n// 改后\\nList candidates = materialService.searchSimilar(normalized);\\nif (candidates.isEmpty()) {\\n // 降级:分词搜索(保留现有降级逻辑)\\n}\\nMaterialMatchResult best = candidates.get(0);\\n// 同时可将 matchType 带回前端\\n```\\n\\n在 `RecognitionItem` 中增加 `matchType` 字段,前端可视化展示匹配质量。\\n\\n### 6.2 物料选择器(`MaterialSelectorServiceImpl`)\\n\\n入库/出库选择器的 `matchesKeyword()` 内存过滤改为按优先级匹配:\\n\\n```java\\n// 改前:内存中 contains 匹配\\n.filter(dto -> matchesKeyword(dto, keyword))\\n\\n// 改后:按优先级搜索并排序(或者在 SQL 层就按优先级查)\\n```\\n\\n### 6.3 库存查询场景(`StockServiceImpl`)\\n\\n当前库存分页查询中通过 `searchMaterialIdsByKeyword` 获取物料 ID 再 JOIN 库存表:\\n\\n```java\\n// 改前\\nList matchedMaterialIds = materialMapper.searchMaterialIdsByKeyword(dto.getKeyword());\\n\\n// 改后:加入排序,保持匹配优先级排序\\nList matched = materialService.searchSimilar(dto.getKeyword());\\nList matchedMaterialIds = matched.stream().map(MaterialMatchResult::getId).toList();\\n```\\n\\n## 七、特殊字符处理\\n\\n统一使用 `LikeEscapeUtil` 转义 `\\\\` `%` `_` 三个字符后拼接 `%` 通配符。\\n\\n```sql\\n-- 示例:keyword = \\\"8.5%玻璃_棒\\\"\\n-- 转义后:material_name LIKE '%8.5\\\\%玻璃\\\\_棒%' ESCAPE '\\\\'\\n```\\n\\n## 八、与现有代码的兼容性\\n\\n- **`searchByNameLike()` 保留不动** — 现有调用方不需要改\\n- **`searchMaterialIdsByKeyword()` 保留不动** — 兼容旧查询路径\\n- **`matchesKeyword()` 保留不动** — 选择器内存过滤仍可用\\n- 新增 `searchSimilar()` 方法,调用方按需迁移\\n\\n## 九、PostgreSQL 性能考虑\\n\\n- 物料表 `md_material` 预计规模不超过 10 万条\\n- `material_name`、`spec`、`material_code` 建议建索引(**已建则忽略**)\\n- 分步查询每次命中特定索引,不会出现全表扫描\\n\\n```sql\\n-- 建议索引\\nCREATE INDEX idx_md_material_name ON md_material(material_name);\\nCREATE INDEX idx_md_material_spec ON md_material(spec);\\nCREATE INDEX idx_md_material_code ON md_material(material_code);\\n```\\n\\n## 十、前端展示建议\\n\\n返回结果携带 `matchType`,前端根据匹配类型给出视觉提示:\\n\\n| 匹配类型 | 展示样式 |\\n|----------|----------|\\n| EXACT_NAME | 正常高亮(绿底/粗体) |\\n| PREFIX_NAME / FUZZY_NAME | 仅高亮匹配部分 |\\n| FUZZY_SPEC | 灰色标注\\\"规格匹配\\\" |\\n| FUZZY_CODE | 灰色标注\\\"编码匹配\\\" |\\n\\n同时展示每条结果的 `score/matchType`,帮助用户判断匹配质量。\"}"}],"api":"openai-completions","provider":"new-api","model":"deepseek-v4-flash","usage":{"input":545,"output":2098,"cacheRead":37888,"cacheWrite":0,"reasoningTokens":49,"totalTokens":40531,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1780018247125,"responseId":"chatcmpl-736acbf4-8027-41c9-80de-08cf329f8ce3"}}
{"type":"message","id":"6f846074","parentId":"995ed6a7","timestamp":"2026-05-29T01:31:05.568Z","message":{"role":"toolResult","toolCallId":"call_49b53b043c024d128d4ec852","toolName":"write","content":[{"type":"text","text":"Successfully wrote 5213 bytes to /home/yangxuan/Projects/IdeaProjects/tiny-erp/yangxuan/设计/02-物料近似查询方案.md"}],"isError":false,"timestamp":1780018265567}}
{"type":"message","id":"904c85b3","parentId":"6f846074","timestamp":"2026-05-29T01:31:09.534Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Document saved successfully. Let me confirm the path and summarize.","thinkingSignature":"reasoning_content"},{"type":"text","text":"已保存到 `tiny-erp/yangxuan/设计/02-物料近似查询方案.md` ✅\n\n文档覆盖了:\n1. **现有代码痛点分析** — 三个入口各有各的问题\n2. **5 级匹配优先级定义** — 精确名称 > 前缀 > 模糊名称 > 规格 > 编码\n3. **分步查询 + 去重实现方案** — 避免 OR 混在一起无法排序\n4. **枚举 + DTO 结构**\n5. **三处现有调用点的迁移路径** — AI 识别、物料选择器、库存查询\n6. **兼容性说明** — 旧方法保留不动,新增 `searchSimilar()`\n7. **索引建议** 和 **前端展示建议**"}],"api":"openai-completions","provider":"new-api","model":"deepseek-v4-flash","usage":{"input":2129,"output":175,"cacheRead":38400,"cacheWrite":0,"reasoningTokens":12,"totalTokens":40704,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1780018265570,"responseId":"chatcmpl-b43acb25-e3af-43eb-98df-fb031cc294f6"}}
{"type":"message","id":"1f4c53ef","parentId":"904c85b3","timestamp":"2026-05-29T01:36:39.085Z","message":{"role":"user","content":[{"type":"text","text":"[Fri 2026-05-29 09:36 GMT+8] ## 交互优化\n- 返回前端id 无法填充页面\n - 客户供应商需要查询名称\n - 物料使用com.witsoft.erp.module.md.material.service.MaterialFillService.getMaterialInfoMap填充\n - 单据类型使用 数据字典中类型\n - 使用local 库测试\n - [02-物料近似查询方案.md](yangxuan/设计/02-物料近似查询方案.md)"}],"timestamp":1780018598971}}
{"type":"message","id":"0769cc9f","parentId":"1f4c53ef","timestamp":"2026-05-29T01:36:49.750Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user is asking me to read the current document and then discuss/handle an interaction optimization. Let me first read the document they referenced, and also understand what they're talking about.\n\nLet me re-read the conversation. The user said:\n\n- Return frontend id can't fill page\n- Customer/supplier need to query name\n- Material uses `MaterialFillService.getMaterialInfoMap` to fill\n- Document type uses data dictionary type\n- Test on local database\n\nSo it seems like they're talking about the AI recognition feature, where the current implementation returns IDs but the frontend can't directly use those IDs to fill in names on the page. They want:\n\n1. For counterparties (customer/supplier): instead of just returning ID, also query and return the name\n2. For materials: use the existing `MaterialFillService.getMaterialInfoMap` to fill in material info\n3. For document type: use data dictionary values\n4. Test on the local database\n\nWait, let me re-read the AiRecognitionServiceImpl.java - it already does some of this. Let me look more carefully at what might be missing.\n\nActually, looking again at the code I already read:\n\nThe `AiRecognitionResultDTO` already has:\n- `Counterparty` with `id`, `name`, `recognizedName`, `exactMatch`\n- `RecognitionItem` with `materialId`, `materialName`, `materialCode`, `spec`, `unit`\n- `typeValue`, `typeName`\n\nSo it seems like they already return names. But maybe the issue is that:\n\n1. For counterparty matching - if it's not exact match, it returns the recognized name without an ID, and the frontend can't map it\n2. For material matching - it returns materialId but the frontend can't display materialCode/spec/unit properly\n3. document type needs to be from data dictionary properly\n\nLet me look at the existing code more carefully to understand the actual problem. The user says \"返回前端id 无法填充页面\" - the frontend can't fill the page with just IDs.\n\nLet me re-examine the AiRecognitionServiceImpl code more carefully, especially the material matching part.\n\nFrom what I saw earlier, the `matchMaterialItem` method in `AiRecognitionServiceImpl`:\n1. Searches materials by name\n2. If found, builds a `RecognitionItem` with materialId, materialName etc.\n3. But it uses `MaterialFillService.getMaterialInfoMap` to fill in the material info\n\nWait, I didn't see the full method because it was truncated. Let me read the full file.\n\nActually, let me look at this more carefully. The user is talking about an interaction optimization. Let me re-read:\n\n1. \"返回前端id 无法填充页面\" - Returning just IDs to frontend can't fill the page\n2. \"客户供应商需要查询名称\" - Customer/supplier need to query names\n3. \"物料使用MaterialFillService.getMaterialInfoMap填充\" - Materials use MaterialFillService.getMaterialInfoMap to fill\n4. \"单据类型使用数据字典中类型\" - Document type should use data dictionary types\n5. \"使用local库测试\" - Test on local database\n\nSo the user wants me to analyze the AiRecognitionResultDTO and the flow to see what's wrong, and then optimize it.\n\nLet me read the full matchMaterialItem method to understand the current implementation better.","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_2d536132adca4def9e1ebb45","name":"read","arguments":{"path":"/home/yangxuan/Projects/IdeaProjects/tiny-erp/src/main/java/com/witsoft/erp/module/ai/service/impl/AiRecognitionServiceImpl.java","offset":310},"partialArgs":"{\"path\": \"/home/yangxuan/Projects/IdeaProjects/tiny-erp/src/main/java/com/witsoft/erp/module/ai/service/impl/AiRecognitionServiceImpl.java\", \"offset\": 310}"}],"api":"openai-completions","provider":"new-api","model":"deepseek-v4-flash","usage":{"input":40786,"output":815,"cacheRead":0,"cacheWrite":0,"reasoningTokens":718,"totalTokens":41601,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1780018599297,"responseId":"chatcmpl-03d5605e-4f3a-4c58-a5d4-395fe80ad1de"}}
{"type":"message","id":"1cc62cc2","parentId":"0769cc9f","timestamp":"2026-05-29T01:36:50.301Z","message":{"role":"toolResult","toolCallId":"call_2d536132adca4def9e1ebb45","toolName":"read","content":[{"type":"text","text":" }\n chineseParts.sort((a, b) -> Integer.compare(b.length(), a.length()));\n for (String cp : chineseParts) {\n if (cp.length() >= 2) {\n candidates = materialService.searchByNameLike(cp);\n if (!candidates.isEmpty()) break;\n }\n }\n }\n\n if (candidates.isEmpty()) {\n // 完全无法匹配:返回 AI 识别的原始信息\n return AiRecognitionResultDTO.RecognitionItem.builder()\n .materialName(materialName)\n .spec(spec)\n .recognizedName(materialName)\n .quantity(quantity)\n .price(price)\n .unit(unit)\n .exactMatch(false)\n .build();\n }\n\n // 取最佳匹配,并用 MaterialFillService 补充信息\n Material best = candidates.get(0);\n MaterialInfoDTO info = materialFillService.getMaterialInfo(best.getId());\n\n AiRecognitionResultDTO.RecognitionItem.RecognitionItemBuilder builder = AiRecognitionResultDTO.RecognitionItem.builder()\n .materialId(best.getId())\n .materialName(info != null ? info.getMaterialName() : best.getMaterialName())\n .materialCode(info != null ? info.getMaterialCode() : null)\n .spec(info != null ? info.getSpec() : best.getSpec())\n .unit(info != null ? info.getUnit() : best.getUnit())\n .recognizedName(materialName)\n .quantity(quantity)\n .price(price)\n .exactMatch(best.getMaterialName().replaceAll(\"\\\\s+\", \"\").equals(normalized));\n\n return builder.build();\n }\n\n // ========== 名称处理工具 ==========\n\n /** 归一化名称:去空格、全角转半角 */\n private String normalizeName(String name) {\n if (name == null) return \"\";\n // 去掉所有空白字符\n String result = name.replaceAll(\"\\\\s+\", \"\");\n // 全角转半角\n StringBuilder sb = new StringBuilder();\n for (char c : result.toCharArray()) {\n if (c >= 'A' && c <= 'Z') sb.append((char) (c - 'A' + 'A'));\n else if (c >= 'a' && c <= 'z') sb.append((char) (c - 'a' + 'a'));\n else if (c >= '0' && c <= '9') sb.append((char) (c - '0' + '0'));\n else sb.append(c);\n }\n return sb.toString();\n }\n\n /** 分词:按非中文/非数字/非字母字符分割 */\n private List tokenize(String s) {\n List tokens = new ArrayList<>();\n StringBuilder cur = new StringBuilder();\n for (char c : s.toCharArray()) {\n if (Character.isLetterOrDigit(c)) {\n cur.append(c);\n } else {\n if (cur.length() > 0) {\n tokens.add(cur.toString());\n cur.setLength(0);\n }\n }\n }\n if (cur.length() > 0) {\n tokens.add(cur.toString());\n }\n return tokens;\n }\n\n // ========== JSON 工具方法 ==========\n\n private String extractJson(String text) {\n if (text == null) return null;\n text = text.replaceAll(\"```json\\\\s*\", \"\").replaceAll(\"```\\\\s*\", \"\");\n text = text.trim();\n if (text.startsWith(\"{\") && text.endsWith(\"}\")) {\n return text;\n }\n int start = text.indexOf('{');\n int end = text.lastIndexOf('}');\n if (start >= 0 && end > start) {\n return text.substring(start, end + 1);\n }\n return null;\n }\n\n @SuppressWarnings(\"unchecked\")\n private Map parseSimpleJson(String json) {\n Map result = new LinkedHashMap<>();\n json = json.trim();\n if (json.startsWith(\"{\") && json.endsWith(\"}\")) {\n json = json.substring(1, json.length() - 1).trim();\n }\n List segments = splitTopLevel(json);\n for (String seg : segments) {\n int colonIdx = seg.indexOf(':');\n if (colonIdx < 0) continue;\n String key = seg.substring(0, colonIdx).trim();\n String value = seg.substring(colonIdx + 1).trim();\n key = unquote(key);\n if (value.startsWith(\"\\\"\")) {\n result.put(key, unquote(value));\n } else if (value.startsWith(\"[\")) {\n result.put(key, parseArray(value));\n } else if (value.startsWith(\"{\")) {\n result.put(key, parseSimpleJson(value));\n } else if (value.equals(\"null\")) {\n result.put(key, null);\n } else {\n try {\n if (value.contains(\".\")) {\n result.put(key, new BigDecimal(value));\n } else {\n result.put(key, Long.parseLong(value));\n }\n } catch (NumberFormatException e) {\n result.put(key, value);\n }\n }\n }\n return result;\n }\n\n private List