From 61a2d2541fbe0129110e34e8359e3b96fb793bc7 Mon Sep 17 00:00:00 2001 From: zichun <26684461+reporkey@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:08:49 +0800 Subject: [PATCH] feat: lookupRecord (KgSearch) + audit log + Query (safe read-only SQL) --- sql/ai_audit_log.sql | 16 ++++++++++++++++ src/main/java/com/xly/config/AgentConfig.java | 4 +++- src/main/java/com/xly/service/AuditService.java | 42 ++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/service/FormResolverService.java | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/service/OpService.java | 5 ++++- src/main/java/com/xly/service/SystemPromptService.java | 7 +++++-- src/main/java/com/xly/tool/ErpReadTool.java | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- src/main/java/com/xly/tool/QueryTool.java | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/web/OpController.java | 15 ++++++++++++++- 9 files changed, 442 insertions(+), 6 deletions(-) create mode 100644 sql/ai_audit_log.sql create mode 100644 src/main/java/com/xly/service/AuditService.java create mode 100644 src/main/java/com/xly/service/FormResolverService.java create mode 100644 src/main/java/com/xly/tool/QueryTool.java diff --git a/sql/ai_audit_log.sql b/sql/ai_audit_log.sql new file mode 100644 index 0000000..1e643fc --- /dev/null +++ b/sql/ai_audit_log.sql @@ -0,0 +1,16 @@ +-- ai_audit_log:AI 操作不可变审计(金融类数据合规要求)。 +-- 记录谁、何时、什么动作、目标、结果。独立于 Langfuse(LLM tracing ≠ 业务审计)。 +-- 只追加、不更新不删除(应用层保证;生产可再加触发器/权限强制)。 +CREATE TABLE IF NOT EXISTS ai_audit_log ( + sId bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, + tTime datetime NOT NULL, + sUserId varchar(64), + sConversationId varchar(96), + sAction varchar(24), -- propose | confirm | cancel | query + sTarget varchar(200), -- 目标表/记录/操作简述 + sDetail text, -- JSON 或 SQL 明细 + sResult varchar(16), -- ok | fail + sResultMsg varchar(500), + KEY idx_time (tTime), + KEY idx_user (sUserId, tTime) +); diff --git a/src/main/java/com/xly/config/AgentConfig.java b/src/main/java/com/xly/config/AgentConfig.java index e1f1113..9206bcb 100644 --- a/src/main/java/com/xly/config/AgentConfig.java +++ b/src/main/java/com/xly/config/AgentConfig.java @@ -5,6 +5,7 @@ import com.xly.service.SystemPromptService; import com.xly.tool.ErpReadTool; import com.xly.tool.KgQueryTool; import com.xly.tool.ProposeWriteTool; +import com.xly.tool.QueryTool; import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.model.ollama.OllamaStreamingChatModel; import dev.langchain4j.service.AiServices; @@ -50,11 +51,12 @@ public class AgentConfig { KgQueryTool kgQueryTool, ErpReadTool erpReadTool, ProposeWriteTool proposeWriteTool, + QueryTool queryTool, RedisChatMemoryStore memoryStore) { String systemPrompt = systemPromptService.buildSystemPrompt(); return AiServices.builder(ReActAgent.class) .streamingChatModel(agentStreamingModel()) - .tools(kgQueryTool, erpReadTool, proposeWriteTool) + .tools(kgQueryTool, erpReadTool, proposeWriteTool, queryTool) .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() .id(memoryId) .maxMessages(30) diff --git a/src/main/java/com/xly/service/AuditService.java b/src/main/java/com/xly/service/AuditService.java new file mode 100644 index 0000000..880c755 --- /dev/null +++ b/src/main/java/com/xly/service/AuditService.java @@ -0,0 +1,42 @@ +package com.xly.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * 业务审计:写操作 / SQL 的不可变留痕(谁、何时、什么动作、目标、结果)。 + * 记账失败绝不阻断主流程(审计是旁路)。 + */ +@Service +public class AuditService { + + private static final Logger log = LoggerFactory.getLogger(AuditService.class); + + private final JdbcTemplate jdbc; + + public AuditService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public void log(String userId, String conversationId, String action, + String target, String detail, boolean ok, String resultMsg) { + try { + jdbc.update( + "INSERT INTO ai_audit_log(tTime,sUserId,sConversationId,sAction,sTarget,sDetail,sResult,sResultMsg) " + + "VALUES(NOW(),?,?,?,?,?,?,?)", + userId, conversationId, action, trunc(target, 200), trunc(detail, 60000), + ok ? "ok" : "fail", trunc(resultMsg, 500)); + } catch (Exception e) { + log.warn("audit write failed (action={}): {}", action, e.getMessage()); + } + } + + private static String trunc(String s, int max) { + if (s == null) { + return null; + } + return s.length() > max ? s.substring(0, max) : s; + } +} diff --git a/src/main/java/com/xly/service/FormResolverService.java b/src/main/java/com/xly/service/FormResolverService.java new file mode 100644 index 0000000..8c9f789 --- /dev/null +++ b/src/main/java/com/xly/service/FormResolverService.java @@ -0,0 +1,103 @@ +package com.xly.service; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 表单 / 字段解析(KgSearch 内核)—— 把「实体类型 / 字段中文名」解析到具体的 + * 主表 / formId / moduleId / 技术列名,供 Read 与 Write 共用,集中一处避免各工具各猜一套。 + * + *

数据全部来自已物化的知识图谱视图:{@code viw_ai_useful_forms} / {@code viw_kg_form} + * / {@code viw_kg_field_dict}。 + */ +@Service +public class FormResolverService { + + private final JdbcTemplate jdbc; + + public FormResolverService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** + * 定位某实体的**可改主表**:该实体名下、table 类型、排除报表视图(viw_*)、按 AI工具/连接度/名称长度 + * 取最常用的一张。返回含 sFormId / sModuleId / sDataSource 的行,找不到返回 null。 + */ + public Map resolveMasterForm(String entityKeyword) { + if (isBlank(entityKeyword)) { + return null; + } + try { + List> r = jdbc.queryForList( + "SELECT af.sFormId, af.sModuleId, af.sDataSource, af.sFormTitle FROM viw_ai_useful_forms af " + + "LEFT JOIN viw_kg_form f ON f.sFormId = af.sFormId " + + "WHERE af.sFormTitle LIKE ? AND af.sExecType='table' AND af.sDataSource NOT LIKE 'viw%' " + + "ORDER BY COALESCE(f.bAiTool,0) DESC, " + + "(COALESCE(f.iUpstream,0)+COALESCE(f.iDownstream,0)) DESC, CHAR_LENGTH(af.sFormTitle) ASC LIMIT 1", + "%" + entityKeyword.trim() + "%"); + return r.isEmpty() ? null : r.get(0); + } catch (Exception e) { + return null; + } + } + + /** 表单数据源表 -> 字段中文名(字段字典),用于把技术列名渲染为中文表头。 */ + public Map fieldLabels(String formId) { + Map m = new HashMap<>(); + try { + List> rows = jdbc.queryForList( + "SELECT fd.sField AS f, MIN(fd.sChinese) AS zh FROM viw_ai_useful_forms af " + + "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " + + "WHERE af.sFormId = ? GROUP BY fd.sField", formId); + for (Map r : rows) { + Object f = r.get("f"); + Object zh = r.get("zh"); + if (f != null && zh != null) { + m.put(f.toString(), zh.toString()); + } + } + } catch (Exception ignore) { + } + return m; + } + + /** 猜该表单/表的"名称"字段(以 Name 结尾、使用最广),用于按关键词过滤 / 展示记录名。 */ + public String resolveNameField(String table) { + return queryOne( + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + + "ORDER BY iFormUses DESC LIMIT 1", table); + } + + /** 字段中文名 -> 技术列名(先精确、再模糊)。 */ + public String resolveField(String table, String fieldChinese) { + String f = queryOne( + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese=? ORDER BY iFormUses DESC LIMIT 1", + table, fieldChinese.trim()); + if (f == null) { + f = queryOne( + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese LIKE ? ORDER BY iFormUses DESC LIMIT 1", + table, "%" + fieldChinese.trim() + "%"); + } + return f; + } + + private String queryOne(String sql, Object... args) { + try { + List> r = jdbc.queryForList(sql, args); + if (!r.isEmpty()) { + Object v = r.get(0).values().iterator().next(); + return v == null ? null : v.toString(); + } + } catch (Exception ignore) { + } + return null; + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } +} diff --git a/src/main/java/com/xly/service/OpService.java b/src/main/java/com/xly/service/OpService.java index 827fe30..210a931 100644 --- a/src/main/java/com/xly/service/OpService.java +++ b/src/main/java/com/xly/service/OpService.java @@ -13,9 +13,11 @@ import java.util.Map; public class OpService { private final JdbcTemplate jdbc; + private final AuditService audit; - public OpService(JdbcTemplate jdbc) { + public OpService(JdbcTemplate jdbc, AuditService audit) { this.jdbc = jdbc; + this.audit = audit; } /** 暂存一条 draft 写操作,返回 opId(不执行)。 */ @@ -29,6 +31,7 @@ public class OpService { "sTargetBillId,sField,sFieldLabel,sOldValue,sNewValue,sDescription,sStatus,tCreateDate) " + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?, 'draft', NOW())", sId, userId, opType, formId, moduleId, table, billId, field, fieldLabel, oldValue, newValue, description); + audit.log(userId, null, "propose", table + "#" + billId, description, true, "draft staged (" + sId + ")"); return sId; } diff --git a/src/main/java/com/xly/service/SystemPromptService.java b/src/main/java/com/xly/service/SystemPromptService.java index 04f4390..4ee623d 100644 --- a/src/main/java/com/xly/service/SystemPromptService.java +++ b/src/main/java/com/xly/service/SystemPromptService.java @@ -35,8 +35,11 @@ public class SystemPromptService { %s 【可用工具】 - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 - - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数);\ - keyword 可选,**仅用于精确查找某个命名记录**(如某个客户名);问数量 / 全部 / 概况时必须留空。 + - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数),用于列表 / 计数 / 概况。 + - lookupRecord(entityKeyword, recordKeyword):查某个实体下某条命名记录的**完整信息**(某客户 / 某物料的详细资料、\ + 或它的某个具体字段如电话 / 销售员)。**问"某个记录的某个字段/详情"时优先用它**(比 readFormData 更准)。 + - queryData(question):用**只读 SQL** 回答没有现成表单能直接答的临时统计 / 分析(跨表汇总、计数、排名、分组);\ + 仅当 readFormData / lookupRecord 都答不了时才用。 - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\ (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ diff --git a/src/main/java/com/xly/tool/ErpReadTool.java b/src/main/java/com/xly/tool/ErpReadTool.java index 62661a2..035c96a 100644 --- a/src/main/java/com/xly/tool/ErpReadTool.java +++ b/src/main/java/com/xly/tool/ErpReadTool.java @@ -2,6 +2,7 @@ package com.xly.tool; import com.fasterxml.jackson.databind.JsonNode; import com.xly.service.ErpClient; +import com.xly.service.FormResolverService; import dev.langchain4j.agent.tool.P; import dev.langchain4j.agent.tool.Tool; import org.springframework.jdbc.core.JdbcTemplate; @@ -31,10 +32,80 @@ public class ErpReadTool { private final ErpClient erp; private final JdbcTemplate jdbc; + private final FormResolverService resolver; - public ErpReadTool(ErpClient erp, JdbcTemplate jdbc) { + public ErpReadTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver) { this.erp = erp; this.jdbc = jdbc; + this.resolver = resolver; + } + + @Tool("查询某个实体下某条命名记录的**完整信息**(返回该记录的所有可读字段)。" + + "用于「某客户 / 某物料 的详细资料 / 某个具体字段(如联系电话、销售员)」这类精确查询。") + public String lookupRecord( + @P("实体类型,如 客户 / 物料 / 供应商") String entityKeyword, + @P("记录名称关键词,如某个客户名") String recordKeyword) { + if (entityKeyword == null || entityKeyword.isBlank() || recordKeyword == null || recordKeyword.isBlank()) { + return "请提供实体类型和记录名称关键词。"; + } + Map form = resolver.resolveMasterForm(entityKeyword.trim()); + if (form == null) { + return "找不到「" + entityKeyword + "」对应的主表。"; + } + String formId = String.valueOf(form.get("sFormId")); + String moduleId = String.valueOf(form.get("sModuleId")); + String table = String.valueOf(form.get("sDataSource")); + String nameField = resolver.resolveNameField(table); + + JsonNode root; + try { + root = erp.readForm(formId, moduleId, 1, 3, nameField, recordKeyword.trim()); + } catch (Exception e) { + return "读取失败:" + e.getMessage(); + } + if (root.path("code").asInt(0) < 0) { + return "读取失败:" + root.path("msg").asText("未知错误"); + } + JsonNode rows = root.path("dataset").path("rows"); + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; + int n = (data != null && data.isArray()) ? data.size() : 0; + if (n == 0) { + return "没有找到名称含「" + recordKeyword + "」的记录。"; + } + if (n > 1) { + StringBuilder names = new StringBuilder(); + for (int i = 0; i < data.size() && i < 5; i++) { + if (i > 0) names.append("、"); + names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); + } + return "匹配到多条(" + names + "),请说得更具体一点。"; + } + return renderRecord(data.get(0), resolver.fieldLabels(formId)); + } + + /** 把一条记录渲染成「中文名:值」清单(跳过空值、id/token/布尔噪声),最多 20 行。 */ + private String renderRecord(JsonNode rec, Map labels) { + StringBuilder sb = new StringBuilder(); + int count = 0; + Iterator it = rec.fieldNames(); + while (it.hasNext() && count < 20) { + String c = it.next(); + if (c.equalsIgnoreCase("sToken") || c.endsWith("Id") || (c.length() > 1 && c.charAt(0) == 'b')) { + continue; + } + JsonNode v = rec.path(c); + if (v.isContainerNode() || v.isNull()) { + continue; + } + String val = v.asText("").trim(); + String label = labels.get(c); + if (val.isEmpty() || label == null) { + continue; + } + sb.append("- ").append(label).append(":").append(val.length() > 60 ? val.substring(0, 60) + "…" : val).append("\n"); + count++; + } + return sb.length() == 0 ? "找到了该记录,但没有可展示的字段。" : sb.toString(); } @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。可选 keyword 用于按名称模糊过滤" diff --git a/src/main/java/com/xly/tool/QueryTool.java b/src/main/java/com/xly/tool/QueryTool.java new file mode 100644 index 0000000..8b7eddb --- /dev/null +++ b/src/main/java/com/xly/tool/QueryTool.java @@ -0,0 +1,183 @@ +package com.xly.tool; + +import com.xly.service.AuditService; +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; +import dev.langchain4j.model.ollama.OllamaChatModel; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.select.Select; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * Query 工具:**只读 SQL 兜底** —— 回答没有现成表单/记录能直接答的临时统计/分析问题 + * (跨表汇总、按条件计数排名等)。 + * + *

安全栈:用 coder 模型据 KG 字段字典接地生成 SQL → jsqlparser 强制**单条 SELECT** → + * 挡 {@code INTO OUTFILE / LOAD_FILE / information_schema / SLEEP / BENCHMARK} 与多语句 → + * 强制 LIMIT。(本地单品牌,租户注入留作生产加固;见架构 §9。)SQL 入审计。 + */ +@Component +public class QueryTool { + + private final OllamaChatModel sqlModel; + private final JdbcTemplate jdbc; + private final AuditService audit; + + public QueryTool(@Qualifier("sqlChatModel") OllamaChatModel sqlModel, JdbcTemplate jdbc, AuditService audit) { + this.sqlModel = sqlModel; + this.jdbc = jdbc; + this.audit = audit; + } + + @Tool("用**只读 SQL** 回答没有现成表单/记录能直接答的临时统计或分析问题" + + "(如跨表汇总、按条件计数、排名、分组统计)。仅在 readFormData / lookupRecord 无法回答时才用。") + public String queryData(@P("用自然语言描述要统计/分析什么") String question) { + if (question == null || question.isBlank()) { + return "请描述要查询统计的内容。"; + } + String hint = schemaHint(question); + String prompt = """ + 你是 MySQL 专家。根据【问题】生成 **一条** MySQL SELECT 查询来回答它。 + 数据库 = xlyweberp_saas。可用的表和字段(列名=中文名): + %s + 规则:只用 SELECT(严禁任何写操作 / 文件操作);需要时 JOIN;务必带合适的 LIMIT(<=100); + **列别名一律用英文**(如 cnt、total、name),ORDER BY 用英文列名或序号,**绝不要用中文做别名**; + 表名、列名一律用上面给定的英文名。**只输出 SQL 本身**,不要解释、不要 markdown 代码围栏。 + 问题:%s + """.formatted(hint, question); + + String sql; + try { + sql = cleanSql(sqlModel.chat(prompt)); + } catch (Exception e) { + return "生成查询失败:" + e.getMessage(); + } + + String reject = validate(sql); + if (reject != null) { + audit.log(null, null, "query", "REJECTED", sql, false, reject); + return "无法安全执行该查询(" + reject + ")。可以换个更具体的问法。"; + } + sql = forceLimit(sql); + + try { + List> rows = jdbc.queryForList(sql); + audit.log(null, null, "query", "ok", sql, true, "rows=" + rows.size()); + return formatRows(rows); + } catch (Exception e) { + audit.log(null, null, "query", "fail", sql, false, e.getMessage()); + return "查询执行失败:" + e.getMessage(); + } + } + + /** 据字段字典把问题里出现的中文术语接地到具体表+列,喂给 coder 模型。 */ + private String schemaHint(String question) { + StringBuilder sb = new StringBuilder(); + try { + List> rows = jdbc.queryForList( + "SELECT fd.sTable, " + + "GROUP_CONCAT(DISTINCT CONCAT(fd.sField,'=',fd.sChinese) ORDER BY fd.iFormUses DESC SEPARATOR ', ') cols, " + + "SUM(fd.iFormUses) usage_ " + + "FROM viw_kg_field_dict fd " + + "WHERE CHAR_LENGTH(fd.sChinese)>=2 AND INSTR(?, fd.sChinese)>0 " + + "AND fd.sTable NOT LIKE 'viw%' " + + "AND fd.sTable IN (SELECT DISTINCT sDataSource FROM viw_ai_useful_forms) " + + "GROUP BY fd.sTable ORDER BY usage_ DESC, COUNT(*) DESC LIMIT 6", question); + for (Map r : rows) { + String cols = String.valueOf(r.get("cols")); + if (cols.length() > 400) { + cols = cols.substring(0, 400) + "…"; + } + sb.append("- ").append(r.get("sTable")).append("(").append(cols).append(")\n"); + } + } catch (Exception ignore) { + } + if (sb.length() == 0) { + sb.append("(未匹配到具体表;请在问题里使用业务术语,如 客户 / 订单 / 金额 / 数量)\n"); + } + return sb.toString(); + } + + private String cleanSql(String raw) { + if (raw == null) { + return ""; + } + String s = raw.replace("```sql", "").replace("```", "").trim(); + int i = s.toLowerCase().indexOf("select"); + if (i > 0) { + s = s.substring(i); + } + int semi = s.indexOf(';'); + if (semi >= 0) { + s = s.substring(0, semi); + } + return s.trim(); + } + + /** 单条 SELECT + 挡危险构造。返回 null=通过,否则=拒绝原因。 */ + private String validate(String sql) { + if (sql == null || sql.isBlank()) { + return "未生成SQL"; + } + String low = sql.toLowerCase(); + String[] bad = {"into outfile", "into dumpfile", "load_file", "load data", + "information_schema", "sleep(", "benchmark(", "sys.", "mysql."}; + for (String b : bad) { + if (low.contains(b)) { + return "含禁止构造: " + b; + } + } + try { + Statement stmt = CCJSqlParserUtil.parse(sql); + if (!(stmt instanceof Select)) { + return "只允许 SELECT"; + } + } catch (Exception e) { + return "SQL 解析失败"; + } + return null; + } + + private String forceLimit(String sql) { + String low = sql.toLowerCase(); + if (!low.matches("(?s).*\\blimit\\b.*")) { + return sql.trim() + " LIMIT 100"; + } + return sql; + } + + private String formatRows(List> rows) { + if (rows.isEmpty()) { + return "查询完成,没有匹配的数据。"; + } + List cols = List.copyOf(rows.get(0).keySet()); + StringBuilder sb = new StringBuilder(); + sb.append("查询结果(").append(rows.size()).append(" 行):\n\n"); + sb.append("| ").append(String.join(" | ", cols)).append(" |\n"); + sb.append("|").append(" --- |".repeat(cols.size())).append("\n"); + int shown = 0; + for (Map r : rows) { + if (shown++ >= 30) { + sb.append("| … 仅显示前 30 行 |").append(" |".repeat(Math.max(0, cols.size() - 1))).append("\n"); + break; + } + StringBuilder line = new StringBuilder("| "); + for (String c : cols) { + Object v = r.get(c); + String s = v == null ? "" : v.toString().replace("|", "/").replace("\n", " "); + if (s.length() > 30) { + s = s.substring(0, 30) + "…"; + } + line.append(s).append(" | "); + } + sb.append(line).append("\n"); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/xly/web/OpController.java b/src/main/java/com/xly/web/OpController.java index dd5842b..d089238 100644 --- a/src/main/java/com/xly/web/OpController.java +++ b/src/main/java/com/xly/web/OpController.java @@ -1,6 +1,7 @@ package com.xly.web; import com.fasterxml.jackson.databind.JsonNode; +import com.xly.service.AuditService; import com.xly.service.ErpClient; import com.xly.service.OpService; import org.slf4j.Logger; @@ -29,10 +30,12 @@ public class OpController { private final OpService ops; private final ErpClient erp; + private final AuditService audit; - public OpController(OpService ops, ErpClient erp) { + public OpController(OpService ops, ErpClient erp, AuditService audit) { this.ops = ops; this.erp = erp; + this.audit = audit; } /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */ @@ -52,6 +55,10 @@ public class OpController { if (!"draft".equals(String.valueOf(op.get("sStatus")))) { return result(String.valueOf(op.get("sStatus")), "该操作已处理过(" + op.get("sStatus") + ")", op); } + String uid = str(op.get("sUserId")); + String conv = str(op.get("sConversationId")); + String target = str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId")); + String detail = str(op.get("sDescription")); try { JsonNode r = erp.updateForm( str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), @@ -59,14 +66,17 @@ public class OpController { int code = r.path("code").asInt(0); if (code == 1) { ops.setStatus(id, "executed", "操作成功"); + audit.log(uid, conv, "confirm", target, detail, true, "executed"); return result("executed", "已修改:" + op.get("sDescription"), op); } String msg = r.path("msg").asText("执行失败"); ops.setStatus(id, "failed", msg); + audit.log(uid, conv, "confirm", target, detail, false, msg); return result("failed", msg, op); } catch (Exception e) { log.warn("confirm op {} failed", id, e); ops.setStatus(id, "failed", e.getMessage()); + audit.log(uid, conv, "confirm", target, detail, false, e.getMessage()); return result("failed", "执行异常:" + e.getMessage(), op); } } @@ -77,6 +87,9 @@ public class OpController { Map op = ops.get(id); if (op != null && "draft".equals(String.valueOf(op.get("sStatus")))) { ops.setStatus(id, "cancelled", "用户取消"); + audit.log(str(op.get("sUserId")), str(op.get("sConversationId")), "cancel", + str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId")), + str(op.get("sDescription")), true, "cancelled"); } return result("cancelled", "已取消", op); } -- libgit2 0.22.2