Commit 61a2d2541fbe0129110e34e8359e3b96fb793bc7

Authored by zichun
1 parent 9bd3e8ea

feat: lookupRecord (KgSearch) + audit log + Query (safe read-only SQL)

- FormResolverService: shared KG resolution (entity->master table excl. viw_*, field 中文->column, name field, labels)
- ErpReadTool.lookupRecord(entity, record): reliably resolves the master table + returns a single record's full labelled fields — fixes the 'specific field of a specific record' case (e.g. 必胜客的销售员 -> 马艺祖)
- AuditService + ai_audit_log: immutable audit of propose/confirm/cancel/query (who/when/action/target/result)
- QueryTool.queryData(question): read-only SQL escape hatch — coder-model NL2SQL grounded on 字段字典, jsqlparser single-SELECT guard, blocks OUTFILE/LOAD_FILE/information_schema/SLEEP, forced LIMIT, audited. SAFE by construction; NL2SQL table/join accuracy for complex questions is limited (architecture-flagged, deferred).

Verified: lookupRecord works; audit rows written; Query guards + LIMIT + audit work (NL2SQL grounding still mis-picks entity master on multi-table questions).
sql/ai_audit_log.sql 0 → 100644
  1 +-- ai_audit_log:AI 操作不可变审计(金融类数据合规要求)。
  2 +-- 记录谁、何时、什么动作、目标、结果。独立于 Langfuse(LLM tracing ≠ 业务审计)。
  3 +-- 只追加、不更新不删除(应用层保证;生产可再加触发器/权限强制)。
  4 +CREATE TABLE IF NOT EXISTS ai_audit_log (
  5 + sId bigint NOT NULL AUTO_INCREMENT PRIMARY KEY,
  6 + tTime datetime NOT NULL,
  7 + sUserId varchar(64),
  8 + sConversationId varchar(96),
  9 + sAction varchar(24), -- propose | confirm | cancel | query
  10 + sTarget varchar(200), -- 目标表/记录/操作简述
  11 + sDetail text, -- JSON 或 SQL 明细
  12 + sResult varchar(16), -- ok | fail
  13 + sResultMsg varchar(500),
  14 + KEY idx_time (tTime),
  15 + KEY idx_user (sUserId, tTime)
  16 +);
src/main/java/com/xly/config/AgentConfig.java
@@ -5,6 +5,7 @@ import com.xly.service.SystemPromptService; @@ -5,6 +5,7 @@ import com.xly.service.SystemPromptService;
5 import com.xly.tool.ErpReadTool; 5 import com.xly.tool.ErpReadTool;
6 import com.xly.tool.KgQueryTool; 6 import com.xly.tool.KgQueryTool;
7 import com.xly.tool.ProposeWriteTool; 7 import com.xly.tool.ProposeWriteTool;
  8 +import com.xly.tool.QueryTool;
8 import dev.langchain4j.memory.chat.MessageWindowChatMemory; 9 import dev.langchain4j.memory.chat.MessageWindowChatMemory;
9 import dev.langchain4j.model.ollama.OllamaStreamingChatModel; 10 import dev.langchain4j.model.ollama.OllamaStreamingChatModel;
10 import dev.langchain4j.service.AiServices; 11 import dev.langchain4j.service.AiServices;
@@ -50,11 +51,12 @@ public class AgentConfig { @@ -50,11 +51,12 @@ public class AgentConfig {
50 KgQueryTool kgQueryTool, 51 KgQueryTool kgQueryTool,
51 ErpReadTool erpReadTool, 52 ErpReadTool erpReadTool,
52 ProposeWriteTool proposeWriteTool, 53 ProposeWriteTool proposeWriteTool,
  54 + QueryTool queryTool,
53 RedisChatMemoryStore memoryStore) { 55 RedisChatMemoryStore memoryStore) {
54 String systemPrompt = systemPromptService.buildSystemPrompt(); 56 String systemPrompt = systemPromptService.buildSystemPrompt();
55 return AiServices.builder(ReActAgent.class) 57 return AiServices.builder(ReActAgent.class)
56 .streamingChatModel(agentStreamingModel()) 58 .streamingChatModel(agentStreamingModel())
57 - .tools(kgQueryTool, erpReadTool, proposeWriteTool) 59 + .tools(kgQueryTool, erpReadTool, proposeWriteTool, queryTool)
58 .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() 60 .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder()
59 .id(memoryId) 61 .id(memoryId)
60 .maxMessages(30) 62 .maxMessages(30)
src/main/java/com/xly/service/AuditService.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import org.slf4j.Logger;
  4 +import org.slf4j.LoggerFactory;
  5 +import org.springframework.jdbc.core.JdbcTemplate;
  6 +import org.springframework.stereotype.Service;
  7 +
  8 +/**
  9 + * 业务审计:写操作 / SQL 的不可变留痕(谁、何时、什么动作、目标、结果)。
  10 + * 记账失败绝不阻断主流程(审计是旁路)。
  11 + */
  12 +@Service
  13 +public class AuditService {
  14 +
  15 + private static final Logger log = LoggerFactory.getLogger(AuditService.class);
  16 +
  17 + private final JdbcTemplate jdbc;
  18 +
  19 + public AuditService(JdbcTemplate jdbc) {
  20 + this.jdbc = jdbc;
  21 + }
  22 +
  23 + public void log(String userId, String conversationId, String action,
  24 + String target, String detail, boolean ok, String resultMsg) {
  25 + try {
  26 + jdbc.update(
  27 + "INSERT INTO ai_audit_log(tTime,sUserId,sConversationId,sAction,sTarget,sDetail,sResult,sResultMsg) " +
  28 + "VALUES(NOW(),?,?,?,?,?,?,?)",
  29 + userId, conversationId, action, trunc(target, 200), trunc(detail, 60000),
  30 + ok ? "ok" : "fail", trunc(resultMsg, 500));
  31 + } catch (Exception e) {
  32 + log.warn("audit write failed (action={}): {}", action, e.getMessage());
  33 + }
  34 + }
  35 +
  36 + private static String trunc(String s, int max) {
  37 + if (s == null) {
  38 + return null;
  39 + }
  40 + return s.length() > max ? s.substring(0, max) : s;
  41 + }
  42 +}
src/main/java/com/xly/service/FormResolverService.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import org.springframework.jdbc.core.JdbcTemplate;
  4 +import org.springframework.stereotype.Service;
  5 +
  6 +import java.util.HashMap;
  7 +import java.util.List;
  8 +import java.util.Map;
  9 +
  10 +/**
  11 + * 表单 / 字段解析(KgSearch 内核)—— 把「实体类型 / 字段中文名」解析到具体的
  12 + * 主表 / formId / moduleId / 技术列名,供 Read 与 Write 共用,集中一处避免各工具各猜一套。
  13 + *
  14 + * <p>数据全部来自已物化的知识图谱视图:{@code viw_ai_useful_forms} / {@code viw_kg_form}
  15 + * / {@code viw_kg_field_dict}。
  16 + */
  17 +@Service
  18 +public class FormResolverService {
  19 +
  20 + private final JdbcTemplate jdbc;
  21 +
  22 + public FormResolverService(JdbcTemplate jdbc) {
  23 + this.jdbc = jdbc;
  24 + }
  25 +
  26 + /**
  27 + * 定位某实体的**可改主表**:该实体名下、table 类型、排除报表视图(viw_*)、按 AI工具/连接度/名称长度
  28 + * 取最常用的一张。返回含 sFormId / sModuleId / sDataSource 的行,找不到返回 null。
  29 + */
  30 + public Map<String, Object> resolveMasterForm(String entityKeyword) {
  31 + if (isBlank(entityKeyword)) {
  32 + return null;
  33 + }
  34 + try {
  35 + List<Map<String, Object>> r = jdbc.queryForList(
  36 + "SELECT af.sFormId, af.sModuleId, af.sDataSource, af.sFormTitle FROM viw_ai_useful_forms af " +
  37 + "LEFT JOIN viw_kg_form f ON f.sFormId = af.sFormId " +
  38 + "WHERE af.sFormTitle LIKE ? AND af.sExecType='table' AND af.sDataSource NOT LIKE 'viw%' " +
  39 + "ORDER BY COALESCE(f.bAiTool,0) DESC, " +
  40 + "(COALESCE(f.iUpstream,0)+COALESCE(f.iDownstream,0)) DESC, CHAR_LENGTH(af.sFormTitle) ASC LIMIT 1",
  41 + "%" + entityKeyword.trim() + "%");
  42 + return r.isEmpty() ? null : r.get(0);
  43 + } catch (Exception e) {
  44 + return null;
  45 + }
  46 + }
  47 +
  48 + /** 表单数据源表 -> 字段中文名(字段字典),用于把技术列名渲染为中文表头。 */
  49 + public Map<String, String> fieldLabels(String formId) {
  50 + Map<String, String> m = new HashMap<>();
  51 + try {
  52 + List<Map<String, Object>> rows = jdbc.queryForList(
  53 + "SELECT fd.sField AS f, MIN(fd.sChinese) AS zh FROM viw_ai_useful_forms af " +
  54 + "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " +
  55 + "WHERE af.sFormId = ? GROUP BY fd.sField", formId);
  56 + for (Map<String, Object> r : rows) {
  57 + Object f = r.get("f");
  58 + Object zh = r.get("zh");
  59 + if (f != null && zh != null) {
  60 + m.put(f.toString(), zh.toString());
  61 + }
  62 + }
  63 + } catch (Exception ignore) {
  64 + }
  65 + return m;
  66 + }
  67 +
  68 + /** 猜该表单/表的"名称"字段(以 Name 结尾、使用最广),用于按关键词过滤 / 展示记录名。 */
  69 + public String resolveNameField(String table) {
  70 + return queryOne(
  71 + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " +
  72 + "ORDER BY iFormUses DESC LIMIT 1", table);
  73 + }
  74 +
  75 + /** 字段中文名 -> 技术列名(先精确、再模糊)。 */
  76 + public String resolveField(String table, String fieldChinese) {
  77 + String f = queryOne(
  78 + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese=? ORDER BY iFormUses DESC LIMIT 1",
  79 + table, fieldChinese.trim());
  80 + if (f == null) {
  81 + f = queryOne(
  82 + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese LIKE ? ORDER BY iFormUses DESC LIMIT 1",
  83 + table, "%" + fieldChinese.trim() + "%");
  84 + }
  85 + return f;
  86 + }
  87 +
  88 + private String queryOne(String sql, Object... args) {
  89 + try {
  90 + List<Map<String, Object>> r = jdbc.queryForList(sql, args);
  91 + if (!r.isEmpty()) {
  92 + Object v = r.get(0).values().iterator().next();
  93 + return v == null ? null : v.toString();
  94 + }
  95 + } catch (Exception ignore) {
  96 + }
  97 + return null;
  98 + }
  99 +
  100 + private static boolean isBlank(String s) {
  101 + return s == null || s.isBlank();
  102 + }
  103 +}
src/main/java/com/xly/service/OpService.java
@@ -13,9 +13,11 @@ import java.util.Map; @@ -13,9 +13,11 @@ import java.util.Map;
13 public class OpService { 13 public class OpService {
14 14
15 private final JdbcTemplate jdbc; 15 private final JdbcTemplate jdbc;
  16 + private final AuditService audit;
16 17
17 - public OpService(JdbcTemplate jdbc) { 18 + public OpService(JdbcTemplate jdbc, AuditService audit) {
18 this.jdbc = jdbc; 19 this.jdbc = jdbc;
  20 + this.audit = audit;
19 } 21 }
20 22
21 /** 暂存一条 draft 写操作,返回 opId(不执行)。 */ 23 /** 暂存一条 draft 写操作,返回 opId(不执行)。 */
@@ -29,6 +31,7 @@ public class OpService { @@ -29,6 +31,7 @@ public class OpService {
29 "sTargetBillId,sField,sFieldLabel,sOldValue,sNewValue,sDescription,sStatus,tCreateDate) " + 31 "sTargetBillId,sField,sFieldLabel,sOldValue,sNewValue,sDescription,sStatus,tCreateDate) " +
30 "VALUES(?,?,?,?,?,?,?,?,?,?,?,?, 'draft', NOW())", 32 "VALUES(?,?,?,?,?,?,?,?,?,?,?,?, 'draft', NOW())",
31 sId, userId, opType, formId, moduleId, table, billId, field, fieldLabel, oldValue, newValue, description); 33 sId, userId, opType, formId, moduleId, table, billId, field, fieldLabel, oldValue, newValue, description);
  34 + audit.log(userId, null, "propose", table + "#" + billId, description, true, "draft staged (" + sId + ")");
32 return sId; 35 return sId;
33 } 36 }
34 37
src/main/java/com/xly/service/SystemPromptService.java
@@ -35,8 +35,11 @@ public class SystemPromptService { @@ -35,8 +35,11 @@ public class SystemPromptService {
35 %s 35 %s
36 【可用工具】 36 【可用工具】
37 - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 37 - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。
38 - - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数);\  
39 - keyword 可选,**仅用于精确查找某个命名记录**(如某个客户名);问数量 / 全部 / 概况时必须留空。 38 + - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数),用于列表 / 计数 / 概况。
  39 + - lookupRecord(entityKeyword, recordKeyword):查某个实体下某条命名记录的**完整信息**(某客户 / 某物料的详细资料、\
  40 + 或它的某个具体字段如电话 / 销售员)。**问"某个记录的某个字段/详情"时优先用它**(比 readFormData 更准)。
  41 + - queryData(question):用**只读 SQL** 回答没有现成表单能直接答的临时统计 / 分析(跨表汇总、计数、排名、分组);\
  42 + 仅当 readFormData / lookupRecord 都答不了时才用。
40 - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\ 43 - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\
41 (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。 44 (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。
42 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ 45 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\
src/main/java/com/xly/tool/ErpReadTool.java
@@ -2,6 +2,7 @@ package com.xly.tool; @@ -2,6 +2,7 @@ package com.xly.tool;
2 2
3 import com.fasterxml.jackson.databind.JsonNode; 3 import com.fasterxml.jackson.databind.JsonNode;
4 import com.xly.service.ErpClient; 4 import com.xly.service.ErpClient;
  5 +import com.xly.service.FormResolverService;
5 import dev.langchain4j.agent.tool.P; 6 import dev.langchain4j.agent.tool.P;
6 import dev.langchain4j.agent.tool.Tool; 7 import dev.langchain4j.agent.tool.Tool;
7 import org.springframework.jdbc.core.JdbcTemplate; 8 import org.springframework.jdbc.core.JdbcTemplate;
@@ -31,10 +32,80 @@ public class ErpReadTool { @@ -31,10 +32,80 @@ public class ErpReadTool {
31 32
32 private final ErpClient erp; 33 private final ErpClient erp;
33 private final JdbcTemplate jdbc; 34 private final JdbcTemplate jdbc;
  35 + private final FormResolverService resolver;
34 36
35 - public ErpReadTool(ErpClient erp, JdbcTemplate jdbc) { 37 + public ErpReadTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver) {
36 this.erp = erp; 38 this.erp = erp;
37 this.jdbc = jdbc; 39 this.jdbc = jdbc;
  40 + this.resolver = resolver;
  41 + }
  42 +
  43 + @Tool("查询某个实体下某条命名记录的**完整信息**(返回该记录的所有可读字段)。"
  44 + + "用于「某客户 / 某物料 的详细资料 / 某个具体字段(如联系电话、销售员)」这类精确查询。")
  45 + public String lookupRecord(
  46 + @P("实体类型,如 客户 / 物料 / 供应商") String entityKeyword,
  47 + @P("记录名称关键词,如某个客户名") String recordKeyword) {
  48 + if (entityKeyword == null || entityKeyword.isBlank() || recordKeyword == null || recordKeyword.isBlank()) {
  49 + return "请提供实体类型和记录名称关键词。";
  50 + }
  51 + Map<String, Object> form = resolver.resolveMasterForm(entityKeyword.trim());
  52 + if (form == null) {
  53 + return "找不到「" + entityKeyword + "」对应的主表。";
  54 + }
  55 + String formId = String.valueOf(form.get("sFormId"));
  56 + String moduleId = String.valueOf(form.get("sModuleId"));
  57 + String table = String.valueOf(form.get("sDataSource"));
  58 + String nameField = resolver.resolveNameField(table);
  59 +
  60 + JsonNode root;
  61 + try {
  62 + root = erp.readForm(formId, moduleId, 1, 3, nameField, recordKeyword.trim());
  63 + } catch (Exception e) {
  64 + return "读取失败:" + e.getMessage();
  65 + }
  66 + if (root.path("code").asInt(0) < 0) {
  67 + return "读取失败:" + root.path("msg").asText("未知错误");
  68 + }
  69 + JsonNode rows = root.path("dataset").path("rows");
  70 + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null;
  71 + int n = (data != null && data.isArray()) ? data.size() : 0;
  72 + if (n == 0) {
  73 + return "没有找到名称含「" + recordKeyword + "」的记录。";
  74 + }
  75 + if (n > 1) {
  76 + StringBuilder names = new StringBuilder();
  77 + for (int i = 0; i < data.size() && i < 5; i++) {
  78 + if (i > 0) names.append("、");
  79 + names.append(nameField == null ? "" : data.get(i).path(nameField).asText(""));
  80 + }
  81 + return "匹配到多条(" + names + "),请说得更具体一点。";
  82 + }
  83 + return renderRecord(data.get(0), resolver.fieldLabels(formId));
  84 + }
  85 +
  86 + /** 把一条记录渲染成「中文名:值」清单(跳过空值、id/token/布尔噪声),最多 20 行。 */
  87 + private String renderRecord(JsonNode rec, Map<String, String> labels) {
  88 + StringBuilder sb = new StringBuilder();
  89 + int count = 0;
  90 + Iterator<String> it = rec.fieldNames();
  91 + while (it.hasNext() && count < 20) {
  92 + String c = it.next();
  93 + if (c.equalsIgnoreCase("sToken") || c.endsWith("Id") || (c.length() > 1 && c.charAt(0) == 'b')) {
  94 + continue;
  95 + }
  96 + JsonNode v = rec.path(c);
  97 + if (v.isContainerNode() || v.isNull()) {
  98 + continue;
  99 + }
  100 + String val = v.asText("").trim();
  101 + String label = labels.get(c);
  102 + if (val.isEmpty() || label == null) {
  103 + continue;
  104 + }
  105 + sb.append("- ").append(label).append(":").append(val.length() > 60 ? val.substring(0, 60) + "…" : val).append("\n");
  106 + count++;
  107 + }
  108 + return sb.length() == 0 ? "找到了该记录,但没有可展示的字段。" : sb.toString();
38 } 109 }
39 110
40 @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。可选 keyword 用于按名称模糊过滤" 111 @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。可选 keyword 用于按名称模糊过滤"
src/main/java/com/xly/tool/QueryTool.java 0 → 100644
  1 +package com.xly.tool;
  2 +
  3 +import com.xly.service.AuditService;
  4 +import dev.langchain4j.agent.tool.P;
  5 +import dev.langchain4j.agent.tool.Tool;
  6 +import dev.langchain4j.model.ollama.OllamaChatModel;
  7 +import net.sf.jsqlparser.parser.CCJSqlParserUtil;
  8 +import net.sf.jsqlparser.statement.Statement;
  9 +import net.sf.jsqlparser.statement.select.Select;
  10 +import org.springframework.beans.factory.annotation.Qualifier;
  11 +import org.springframework.jdbc.core.JdbcTemplate;
  12 +import org.springframework.stereotype.Component;
  13 +
  14 +import java.util.List;
  15 +import java.util.Map;
  16 +
  17 +/**
  18 + * Query 工具:**只读 SQL 兜底** —— 回答没有现成表单/记录能直接答的临时统计/分析问题
  19 + * (跨表汇总、按条件计数排名等)。
  20 + *
  21 + * <p>安全栈:用 coder 模型据 KG 字段字典接地生成 SQL → jsqlparser 强制**单条 SELECT** →
  22 + * 挡 {@code INTO OUTFILE / LOAD_FILE / information_schema / SLEEP / BENCHMARK} 与多语句 →
  23 + * 强制 LIMIT。(本地单品牌,租户注入留作生产加固;见架构 §9。)SQL 入审计。
  24 + */
  25 +@Component
  26 +public class QueryTool {
  27 +
  28 + private final OllamaChatModel sqlModel;
  29 + private final JdbcTemplate jdbc;
  30 + private final AuditService audit;
  31 +
  32 + public QueryTool(@Qualifier("sqlChatModel") OllamaChatModel sqlModel, JdbcTemplate jdbc, AuditService audit) {
  33 + this.sqlModel = sqlModel;
  34 + this.jdbc = jdbc;
  35 + this.audit = audit;
  36 + }
  37 +
  38 + @Tool("用**只读 SQL** 回答没有现成表单/记录能直接答的临时统计或分析问题"
  39 + + "(如跨表汇总、按条件计数、排名、分组统计)。仅在 readFormData / lookupRecord 无法回答时才用。")
  40 + public String queryData(@P("用自然语言描述要统计/分析什么") String question) {
  41 + if (question == null || question.isBlank()) {
  42 + return "请描述要查询统计的内容。";
  43 + }
  44 + String hint = schemaHint(question);
  45 + String prompt = """
  46 + 你是 MySQL 专家。根据【问题】生成 **一条** MySQL SELECT 查询来回答它。
  47 + 数据库 = xlyweberp_saas。可用的表和字段(列名=中文名):
  48 + %s
  49 + 规则:只用 SELECT(严禁任何写操作 / 文件操作);需要时 JOIN;务必带合适的 LIMIT(<=100);
  50 + **列别名一律用英文**(如 cnt、total、name),ORDER BY 用英文列名或序号,**绝不要用中文做别名**;
  51 + 表名、列名一律用上面给定的英文名。**只输出 SQL 本身**,不要解释、不要 markdown 代码围栏。
  52 + 问题:%s
  53 + """.formatted(hint, question);
  54 +
  55 + String sql;
  56 + try {
  57 + sql = cleanSql(sqlModel.chat(prompt));
  58 + } catch (Exception e) {
  59 + return "生成查询失败:" + e.getMessage();
  60 + }
  61 +
  62 + String reject = validate(sql);
  63 + if (reject != null) {
  64 + audit.log(null, null, "query", "REJECTED", sql, false, reject);
  65 + return "无法安全执行该查询(" + reject + ")。可以换个更具体的问法。";
  66 + }
  67 + sql = forceLimit(sql);
  68 +
  69 + try {
  70 + List<Map<String, Object>> rows = jdbc.queryForList(sql);
  71 + audit.log(null, null, "query", "ok", sql, true, "rows=" + rows.size());
  72 + return formatRows(rows);
  73 + } catch (Exception e) {
  74 + audit.log(null, null, "query", "fail", sql, false, e.getMessage());
  75 + return "查询执行失败:" + e.getMessage();
  76 + }
  77 + }
  78 +
  79 + /** 据字段字典把问题里出现的中文术语接地到具体表+列,喂给 coder 模型。 */
  80 + private String schemaHint(String question) {
  81 + StringBuilder sb = new StringBuilder();
  82 + try {
  83 + List<Map<String, Object>> rows = jdbc.queryForList(
  84 + "SELECT fd.sTable, " +
  85 + "GROUP_CONCAT(DISTINCT CONCAT(fd.sField,'=',fd.sChinese) ORDER BY fd.iFormUses DESC SEPARATOR ', ') cols, " +
  86 + "SUM(fd.iFormUses) usage_ " +
  87 + "FROM viw_kg_field_dict fd " +
  88 + "WHERE CHAR_LENGTH(fd.sChinese)>=2 AND INSTR(?, fd.sChinese)>0 " +
  89 + "AND fd.sTable NOT LIKE 'viw%' " +
  90 + "AND fd.sTable IN (SELECT DISTINCT sDataSource FROM viw_ai_useful_forms) " +
  91 + "GROUP BY fd.sTable ORDER BY usage_ DESC, COUNT(*) DESC LIMIT 6", question);
  92 + for (Map<String, Object> r : rows) {
  93 + String cols = String.valueOf(r.get("cols"));
  94 + if (cols.length() > 400) {
  95 + cols = cols.substring(0, 400) + "…";
  96 + }
  97 + sb.append("- ").append(r.get("sTable")).append("(").append(cols).append(")\n");
  98 + }
  99 + } catch (Exception ignore) {
  100 + }
  101 + if (sb.length() == 0) {
  102 + sb.append("(未匹配到具体表;请在问题里使用业务术语,如 客户 / 订单 / 金额 / 数量)\n");
  103 + }
  104 + return sb.toString();
  105 + }
  106 +
  107 + private String cleanSql(String raw) {
  108 + if (raw == null) {
  109 + return "";
  110 + }
  111 + String s = raw.replace("```sql", "").replace("```", "").trim();
  112 + int i = s.toLowerCase().indexOf("select");
  113 + if (i > 0) {
  114 + s = s.substring(i);
  115 + }
  116 + int semi = s.indexOf(';');
  117 + if (semi >= 0) {
  118 + s = s.substring(0, semi);
  119 + }
  120 + return s.trim();
  121 + }
  122 +
  123 + /** 单条 SELECT + 挡危险构造。返回 null=通过,否则=拒绝原因。 */
  124 + private String validate(String sql) {
  125 + if (sql == null || sql.isBlank()) {
  126 + return "未生成SQL";
  127 + }
  128 + String low = sql.toLowerCase();
  129 + String[] bad = {"into outfile", "into dumpfile", "load_file", "load data",
  130 + "information_schema", "sleep(", "benchmark(", "sys.", "mysql."};
  131 + for (String b : bad) {
  132 + if (low.contains(b)) {
  133 + return "含禁止构造: " + b;
  134 + }
  135 + }
  136 + try {
  137 + Statement stmt = CCJSqlParserUtil.parse(sql);
  138 + if (!(stmt instanceof Select)) {
  139 + return "只允许 SELECT";
  140 + }
  141 + } catch (Exception e) {
  142 + return "SQL 解析失败";
  143 + }
  144 + return null;
  145 + }
  146 +
  147 + private String forceLimit(String sql) {
  148 + String low = sql.toLowerCase();
  149 + if (!low.matches("(?s).*\\blimit\\b.*")) {
  150 + return sql.trim() + " LIMIT 100";
  151 + }
  152 + return sql;
  153 + }
  154 +
  155 + private String formatRows(List<Map<String, Object>> rows) {
  156 + if (rows.isEmpty()) {
  157 + return "查询完成,没有匹配的数据。";
  158 + }
  159 + List<String> cols = List.copyOf(rows.get(0).keySet());
  160 + StringBuilder sb = new StringBuilder();
  161 + sb.append("查询结果(").append(rows.size()).append(" 行):\n\n");
  162 + sb.append("| ").append(String.join(" | ", cols)).append(" |\n");
  163 + sb.append("|").append(" --- |".repeat(cols.size())).append("\n");
  164 + int shown = 0;
  165 + for (Map<String, Object> r : rows) {
  166 + if (shown++ >= 30) {
  167 + sb.append("| … 仅显示前 30 行 |").append(" |".repeat(Math.max(0, cols.size() - 1))).append("\n");
  168 + break;
  169 + }
  170 + StringBuilder line = new StringBuilder("| ");
  171 + for (String c : cols) {
  172 + Object v = r.get(c);
  173 + String s = v == null ? "" : v.toString().replace("|", "/").replace("\n", " ");
  174 + if (s.length() > 30) {
  175 + s = s.substring(0, 30) + "…";
  176 + }
  177 + line.append(s).append(" | ");
  178 + }
  179 + sb.append(line).append("\n");
  180 + }
  181 + return sb.toString();
  182 + }
  183 +}
src/main/java/com/xly/web/OpController.java
1 package com.xly.web; 1 package com.xly.web;
2 2
3 import com.fasterxml.jackson.databind.JsonNode; 3 import com.fasterxml.jackson.databind.JsonNode;
  4 +import com.xly.service.AuditService;
4 import com.xly.service.ErpClient; 5 import com.xly.service.ErpClient;
5 import com.xly.service.OpService; 6 import com.xly.service.OpService;
6 import org.slf4j.Logger; 7 import org.slf4j.Logger;
@@ -29,10 +30,12 @@ public class OpController { @@ -29,10 +30,12 @@ public class OpController {
29 30
30 private final OpService ops; 31 private final OpService ops;
31 private final ErpClient erp; 32 private final ErpClient erp;
  33 + private final AuditService audit;
32 34
33 - public OpController(OpService ops, ErpClient erp) { 35 + public OpController(OpService ops, ErpClient erp, AuditService audit) {
34 this.ops = ops; 36 this.ops = ops;
35 this.erp = erp; 37 this.erp = erp;
  38 + this.audit = audit;
36 } 39 }
37 40
38 /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */ 41 /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */
@@ -52,6 +55,10 @@ public class OpController { @@ -52,6 +55,10 @@ public class OpController {
52 if (!"draft".equals(String.valueOf(op.get("sStatus")))) { 55 if (!"draft".equals(String.valueOf(op.get("sStatus")))) {
53 return result(String.valueOf(op.get("sStatus")), "该操作已处理过(" + op.get("sStatus") + ")", op); 56 return result(String.valueOf(op.get("sStatus")), "该操作已处理过(" + op.get("sStatus") + ")", op);
54 } 57 }
  58 + String uid = str(op.get("sUserId"));
  59 + String conv = str(op.get("sConversationId"));
  60 + String target = str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId"));
  61 + String detail = str(op.get("sDescription"));
55 try { 62 try {
56 JsonNode r = erp.updateForm( 63 JsonNode r = erp.updateForm(
57 str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), 64 str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")),
@@ -59,14 +66,17 @@ public class OpController { @@ -59,14 +66,17 @@ public class OpController {
59 int code = r.path("code").asInt(0); 66 int code = r.path("code").asInt(0);
60 if (code == 1) { 67 if (code == 1) {
61 ops.setStatus(id, "executed", "操作成功"); 68 ops.setStatus(id, "executed", "操作成功");
  69 + audit.log(uid, conv, "confirm", target, detail, true, "executed");
62 return result("executed", "已修改:" + op.get("sDescription"), op); 70 return result("executed", "已修改:" + op.get("sDescription"), op);
63 } 71 }
64 String msg = r.path("msg").asText("执行失败"); 72 String msg = r.path("msg").asText("执行失败");
65 ops.setStatus(id, "failed", msg); 73 ops.setStatus(id, "failed", msg);
  74 + audit.log(uid, conv, "confirm", target, detail, false, msg);
66 return result("failed", msg, op); 75 return result("failed", msg, op);
67 } catch (Exception e) { 76 } catch (Exception e) {
68 log.warn("confirm op {} failed", id, e); 77 log.warn("confirm op {} failed", id, e);
69 ops.setStatus(id, "failed", e.getMessage()); 78 ops.setStatus(id, "failed", e.getMessage());
  79 + audit.log(uid, conv, "confirm", target, detail, false, e.getMessage());
70 return result("failed", "执行异常:" + e.getMessage(), op); 80 return result("failed", "执行异常:" + e.getMessage(), op);
71 } 81 }
72 } 82 }
@@ -77,6 +87,9 @@ public class OpController { @@ -77,6 +87,9 @@ public class OpController {
77 Map<String, Object> op = ops.get(id); 87 Map<String, Object> op = ops.get(id);
78 if (op != null && "draft".equals(String.valueOf(op.get("sStatus")))) { 88 if (op != null && "draft".equals(String.valueOf(op.get("sStatus")))) {
79 ops.setStatus(id, "cancelled", "用户取消"); 89 ops.setStatus(id, "cancelled", "用户取消");
  90 + audit.log(str(op.get("sUserId")), str(op.get("sConversationId")), "cancel",
  91 + str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId")),
  92 + str(op.get("sDescription")), true, "cancelled");
80 } 93 }
81 return result("cancelled", "已取消", op); 94 return result("cancelled", "已取消", op);
82 } 95 }