Commit 42a40f61c517318c2fac159389b82ae8233e19bb

Authored by zichun
1 parent 902b7648

feat: authz layer (form allowlist) + delete write + security findings report

- AuthzService: form-level allowlist ported from backend getsAuthsIdNew (sysjurisdiction sKey set); sysadmin=all; wired into Read/lookupRecord/proposeUpdate/proposeDelete (admin dev-login => all-access; enforces per-user once prod passes through user token)
- proposeDelete + ErpClient.deleteForm (handleType=del) + OpController branches op type; HITL-gated like update
- docs/security-findings.md: report of the 5 current-system vulns (backend form-perm off, read-API-can-write, NL2SQL no-tenant/over-privilege/partial-SQL-safety/cache-skips-validation) for the business
- Verified end-to-end: read after authz = 93; propose-delete card -> confirm -> row actually deleted; audit rows written.
docs/security-findings.md 0 → 100644
  1 +# 现网安全发现报告(xlyAi / saas ERP)
  2 +
  3 +> 调查时间 2026-07-21。对象:saas-8s+ ERP 后端(`xlyEntry`)+ xlyAi 现有 NL2SQL。
  4 +> 结论:**后端不是权限权威**,且现有 AI 取数路径存在越权与"读接口可写"风险。下述为供业务方决策的整改清单。
  5 +
  6 +## 摘要(按严重度)
  7 +
  8 +| # | 严重度 | 发现 | 影响 |
  9 +|---|--------|------|------|
  10 +| 1 | 高 | 后端逐用户**表单/菜单权限被故意关闭** | 任何登录用户经 API 可碰到 UI 里看不到的表单、可审核/删除全公司数据 |
  11 +| 2 | 高 | 通用读接口**可被当写接口用** | `bUpdate=1` 等参数经参数绑定触发存储过程改库存 |
  12 +| 3 | 高 | 现有 NL2SQL **无租户过滤、可跨品牌** | A 品牌用户"查所有客户"能拿到全部品牌数据 |
  13 +| 4 | 中 | NL2SQL **执行期无表白名单**、SQL 安全仅部分 | 构造问题可 SELECT 任意视图;`INTO OUTFILE`/`LOAD_FILE` 未挡 |
  14 +| 5 | 中 | NL2SQL **流式缓存命中跳过校验**、缓存无租户隔离 | 缓存投毒 / 越权复用 |
  15 +
  16 +## 明细
  17 +
  18 +### 1. 后端不是权限权威(表单级权限被关)
  19 +- `AuthorizationInterceptor` 里逐用户表单权限校验 `checkByUser`(约 143–164 行)**被注释**,注释写着「朱总说不用放 20230626」。
  20 +- `getBusinessDataByFormcustomId` 及**所有写/动作端点**(add/update/delete/审核 `doExamine`)只挂 `@Authorization`=**仅验登录**。
  21 +- 后端**只强制**:公司级租户隔离(`sBrandsId/sSubsidiaryId`)+ 行级 `jurisdiction` 数据范围。
  22 +- 逐用户表单/菜单权限只在**前端 UI** 按 `sAuthsId` 过滤菜单。**agent/脚本直接打 API 即绕过 UI** → 越权放大。
  23 +- 说明:`sAuthsId` 的数据仍在(`sysjurisdiction`,由 `getsAuthsIdNew` 运行时算出,前端 `/getMenuList` 用),所以**可在 agent 侧补回表单级白名单**(xlyAi 已按此实现授权层,见 `docs/agent-architecture.md §7`)。
  24 +
  25 +### 2. 读接口可写
  26 +- 通用表单接口把请求体任意 key 按名绑定到存储过程 IN 参、**无白名单**。
  27 +- 例:`材料库存台账`(`SP_Inventory_InOutWarehouse`,AI 已暴露)在 `bUpdate=1` 时**真改** `MitMaterialsStore/EleMaterialsStock`(重算并持久化库存)。
  28 +- 缓解:xlyAi 的 Read 工具只传分页/过滤参数,**绝不透传 `bUpdate/bUpdateAll`**(已实现)。根因需后端加**参数白名单**。
  29 +
  30 +### 3–5. 现有 NL2SQL(`XlyErpService.getDynamicTableSqlExec` 链路)
  31 +- **无租户过滤**:prompt 从不提品牌,代码不追加 WHERE,视图把品牌当普通列但不过滤;规则默认「返回全部数据」→ 跨品牌泄漏。
  32 +- **越权双轴**:跨品牌(同上);跨表——工具权限只筛「喂给模型的表」,**执行期无 allowlist**,构造问题可查任意视图;系统管理员拿到全部工具。
  33 +- **SQL 安全仅部分**:jsqlparser 挡了 Insert/Update/Delete/Drop + 关键字黑名单,但**无正向 SELECT-only**、`INTO OUTFILE` 未挡、`LOAD_FILE` 未挡(`\bLOAD\b` ≠ `LOAD_FILE`)、无 LIMIT。本地账号 root 全权含 FILE。
  34 +- **流式缓存路径命中即跳过校验**(`if isEmpty(cleanSql)` 兜底)+ 缓存无租户维度。
  35 +
  36 +## 建议整改(优先级)
  37 +1. **后端**:重开逐用户表单权限校验(`checkByUser`),或至少对 AI 服务账号强制表单白名单。
  38 +2. **后端**:通用读接口加**参数白名单**,禁止读路径接受写类参数(`bUpdate` 等)。
  39 +3. **NL2SQL**(新 `queryData` 已按此实现):强制单条 SELECT、挡 OUTFILE/LOAD_FILE/系统库、强制 LIMIT、专用只读账号、AST 注入租户谓词、缓存过校验且按品牌隔离。
  40 +4. 金融类数据:写操作/SQL **不可变审计**(xlyAi 已建 `ai_audit_log`)。
  41 +
  42 +> 注:xlyAi 侧已实现的缓解(授权层白名单、Read 参数白名单、`queryData` 安全栈、审计)是**纵深防御**,不替代后端整改(第 1、2 项根因在后端)。
... ...
src/main/java/com/xly/service/AuthzService.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import org.springframework.beans.factory.annotation.Value;
  4 +import org.springframework.jdbc.core.JdbcTemplate;
  5 +import org.springframework.stereotype.Service;
  6 +
  7 +import java.util.ArrayList;
  8 +import java.util.Arrays;
  9 +import java.util.HashSet;
  10 +import java.util.List;
  11 +import java.util.Map;
  12 +import java.util.Set;
  13 +import java.util.stream.Collectors;
  14 +
  15 +/**
  16 + * xlyAi 侧授权层 —— 表单级白名单(架构 §7)。
  17 + *
  18 + * <p>后端逐用户表单权限被关(只验登录 + 租户 + 行级),所以在 agent 侧按用户实际授权把可碰的
  19 + * 表单/菜单限制住。数据源与后端一致(`sysjurisdiction`,即 `getsAuthsIdNew` 的逻辑)——不新造。
  20 + *
  21 + * <p>管理员(sysadmin/admin) = 全部权限(返回 null);否则 = 该用户有权的 id 集合(菜单/表单/按钮,
  22 + * 由 `sysjurisdiction.sKey` 按 '-' 拆出)。当前本地 dev-login 以 admin 身份运行,因此实际全通;
  23 + * 生产透传用户 token 时即按各用户真实授权收紧。Read / Query / Write 共用此边界。
  24 + */
  25 +@Service
  26 +public class AuthzService {
  27 +
  28 + private final JdbcTemplate jdbc;
  29 +
  30 + @Value("${erp.dev-login.username:admin}")
  31 + private String devUserNo;
  32 + @Value("${erp.dev-login.brand:1111111111}")
  33 + private String devBrand;
  34 + @Value("${erp.dev-login.subsidiary:1111111111}")
  35 + private String devSub;
  36 + @Value("${erp.dev-login.usertype:sysadmin}")
  37 + private String devUserType;
  38 +
  39 + public AuthzService(JdbcTemplate jdbc) {
  40 + this.jdbc = jdbc;
  41 + }
  42 +
  43 + /** 当前有效用户(本地=dev-login)能否访问该菜单/表单 id。 */
  44 + public boolean canAccessModule(String moduleId) {
  45 + Set<String> granted = grantedIds(resolveDevUserId(), devUserType, devBrand, devSub);
  46 + return isAllowed(granted, moduleId);
  47 + }
  48 +
  49 + /** null = 全部(管理员);否则 = 有权的 id 集合。 */
  50 + public Set<String> grantedIds(String userId, String userType, String brandsId, String subsidiaryId) {
  51 + if (isAdmin(userType)) {
  52 + return null; // 超管全部权限
  53 + }
  54 + Set<String> ids = new HashSet<>();
  55 + if (userId == null) {
  56 + return ids;
  57 + }
  58 + List<Map<String, Object>> rows;
  59 + Integer direct = safeCount(
  60 + "SELECT COUNT(*) FROM sysjurisdiction WHERE sBrandsId=? AND sSubsidiaryId=? AND sUserId=?",
  61 + brandsId, subsidiaryId, userId);
  62 + if (direct != null && direct > 0) {
  63 + rows = jdbc.queryForList(
  64 + "SELECT J.sKey AS sKey FROM sysjurisdiction J WHERE J.sBrandsId=? AND J.sSubsidiaryId=? AND J.sUserId=?",
  65 + brandsId, subsidiaryId, userId);
  66 + } else {
  67 + String groups = queryStr(
  68 + "SELECT GROUP_CONCAT(sJurisdictionClassifyId) FROM sftlogininfojurisdictiongroup " +
  69 + "WHERE sBrandsId=? AND sSubsidiaryId=? AND sParentId=? " +
  70 + "AND sJurisdictionClassifyId IN (SELECT sId FROM sisjurisdictionclassify WHERE bLogininfoShow=0)",
  71 + brandsId, subsidiaryId, userId);
  72 + if (groups == null || groups.isBlank()) {
  73 + return ids;
  74 + }
  75 + List<String> gl = Arrays.asList(groups.split(","));
  76 + String in = gl.stream().map(g -> "?").collect(Collectors.joining(","));
  77 + List<Object> args = new ArrayList<>();
  78 + args.add(brandsId);
  79 + args.add(subsidiaryId);
  80 + args.addAll(gl);
  81 + rows = jdbc.queryForList(
  82 + "SELECT J.sKey AS sKey FROM sysjurisdiction J WHERE J.sBrandsId=? AND J.sSubsidiaryId=? " +
  83 + "AND J.sJurisdictionClassifyId IN (" + in + ")", args.toArray());
  84 + }
  85 + for (Map<String, Object> r : rows) {
  86 + Object k = r.get("sKey");
  87 + if (k != null) {
  88 + for (String part : k.toString().split("-")) {
  89 + if (!part.isBlank()) {
  90 + ids.add(part);
  91 + }
  92 + }
  93 + }
  94 + }
  95 + return ids;
  96 + }
  97 +
  98 + public boolean isAllowed(Set<String> granted, String... ids) {
  99 + if (granted == null) {
  100 + return true; // 管理员
  101 + }
  102 + for (String id : ids) {
  103 + if (id != null && granted.contains(id)) {
  104 + return true;
  105 + }
  106 + }
  107 + return false;
  108 + }
  109 +
  110 + private String resolveDevUserId() {
  111 + return queryStr("SELECT sId FROM gdslogininfo WHERE sUserNo=? AND sBrandsId=? LIMIT 1", devUserNo, devBrand);
  112 + }
  113 +
  114 + private boolean isAdmin(String userType) {
  115 + return userType != null && (userType.equalsIgnoreCase("sysadmin") || userType.equalsIgnoreCase("admin"));
  116 + }
  117 +
  118 + private Integer safeCount(String sql, Object... args) {
  119 + try {
  120 + return jdbc.queryForObject(sql, Integer.class, args);
  121 + } catch (Exception e) {
  122 + return 0;
  123 + }
  124 + }
  125 +
  126 + private String queryStr(String sql, Object... args) {
  127 + try {
  128 + List<Map<String, Object>> r = jdbc.queryForList(sql, args);
  129 + if (!r.isEmpty()) {
  130 + Object v = r.get(0).values().iterator().next();
  131 + return v == null ? null : v.toString();
  132 + }
  133 + } catch (Exception ignore) {
  134 + }
  135 + return null;
  136 + }
  137 +}
... ...
src/main/java/com/xly/service/ErpClient.java
... ... @@ -140,6 +140,40 @@ public class ErpClient {
140 140 return root;
141 141 }
142 142  
  143 + /** 删除一条记录(addUpdateDelBusinessData, handleType=del)。会话过期自动重登重试。 */
  144 + public JsonNode deleteForm(String moduleId, String table, String billId) {
  145 + JsonNode root = doDelete(moduleId, table, billId, token());
  146 + if (root.path("code").asInt() == -2) {
  147 + login();
  148 + root = doDelete(moduleId, table, billId, token());
  149 + }
  150 + return root;
  151 + }
  152 +
  153 + private JsonNode doDelete(String moduleId, String table, String billId, String tok) {
  154 + try {
  155 + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId;
  156 + Map<String, Object> col = new LinkedHashMap<>();
  157 + col.put("handleType", "del");
  158 + col.put("sId", billId);
  159 + Map<String, Object> dataItem = new LinkedHashMap<>();
  160 + dataItem.put("sTable", table);
  161 + dataItem.put("name", "master");
  162 + dataItem.put("column", List.of(col));
  163 + String body = mapper.writeValueAsString(Map.of("data", List.of(dataItem)));
  164 + HttpRequest req = HttpRequest.newBuilder(URI.create(url))
  165 + .header("Content-Type", "application/json;charset=UTF-8")
  166 + .header("Authorization", tok)
  167 + .timeout(Duration.ofSeconds(30))
  168 + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
  169 + .build();
  170 + HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
  171 + return mapper.readTree(resp.body());
  172 + } catch (Exception e) {
  173 + throw new RuntimeException("ERP 删除异常: " + e.getMessage(), e);
  174 + }
  175 + }
  176 +
143 177 private JsonNode doUpdate(String moduleId, String table, String billId, String field, String value, String tok) {
144 178 try {
145 179 String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId;
... ...
src/main/java/com/xly/service/SystemPromptService.java
... ... @@ -42,6 +42,7 @@ public class SystemPromptService {
42 42 仅当 readFormData / lookupRecord 都答不了时才用。
43 43 - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\
44 44 (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。
  45 + - proposeDelete(entityKeyword, recordKeyword):**提议删除**某条记录(写操作,只提议、暂不执行;用户确认后才删)。删除不可恢复,慎用。
45 46 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\
46 47 (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\
47 48 (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。
... ... @@ -50,9 +51,8 @@ public class SystemPromptService {
50 51 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。
51 52 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。
52 53 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。
53   - 4. **改现有记录的某个字段**:直接用 proposeUpdate(给出实体类型如「客户」、记录名、字段中文名、新值)生成\
54   - 一条【待确认】的修改提议——它**不会立即执行**;真正修改要用户在对话内点【确认】才发生,你**绝不能声称已修改完成**。\
55   - 新增单据 / 删除 / 审核等其它写操作仍在开发中,如实告知。
  54 + 4. 写操作:改字段用 proposeUpdate、删除记录用 proposeDelete。它们都**只生成待确认提议、不立即执行**;\
  55 + 真正的修改/删除要用户在对话内点【确认】才发生,你**绝不能声称已经改好/删好**。新增单据 / 审核等其它写操作仍在开发中,如实告知。
56 56 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\
57 57 只有确实缺少关键参数(如不知道要查哪张单据)时才提问。
58 58 """.formatted(renderDomainMap());
... ...
src/main/java/com/xly/tool/ErpReadTool.java
1 1 package com.xly.tool;
2 2  
3 3 import com.fasterxml.jackson.databind.JsonNode;
  4 +import com.xly.service.AuthzService;
4 5 import com.xly.service.ErpClient;
5 6 import com.xly.service.FormResolverService;
6 7 import dev.langchain4j.agent.tool.P;
... ... @@ -33,11 +34,13 @@ public class ErpReadTool {
33 34 private final ErpClient erp;
34 35 private final JdbcTemplate jdbc;
35 36 private final FormResolverService resolver;
  37 + private final AuthzService authz;
36 38  
37   - public ErpReadTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver) {
  39 + public ErpReadTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, AuthzService authz) {
38 40 this.erp = erp;
39 41 this.jdbc = jdbc;
40 42 this.resolver = resolver;
  43 + this.authz = authz;
41 44 }
42 45  
43 46 @Tool("查询某个实体下某条命名记录的**完整信息**(返回该记录的所有可读字段)。"
... ... @@ -55,6 +58,9 @@ public class ErpReadTool {
55 58 String formId = String.valueOf(form.get("sFormId"));
56 59 String moduleId = String.valueOf(form.get("sModuleId"));
57 60 String table = String.valueOf(form.get("sDataSource"));
  61 + if (!authz.canAccessModule(moduleId)) {
  62 + return "你没有访问「" + entityKeyword + "」的权限。";
  63 + }
58 64 String nameField = resolver.resolveNameField(table);
59 65  
60 66 JsonNode root;
... ... @@ -119,6 +125,9 @@ public class ErpReadTool {
119 125 if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) {
120 126 return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。";
121 127 }
  128 + if (!authz.canAccessModule(moduleId.trim())) {
  129 + return "你没有访问该表单的权限。";
  130 + }
122 131 String kw = (keyword == null) ? "" : keyword.trim();
123 132 String nameField = kw.isEmpty() ? null : resolveNameField(formId.trim());
124 133  
... ...
src/main/java/com/xly/tool/ProposeWriteTool.java
... ... @@ -2,6 +2,7 @@ package com.xly.tool;
2 2  
3 3 import com.fasterxml.jackson.databind.JsonNode;
4 4 import com.fasterxml.jackson.databind.ObjectMapper;
  5 +import com.xly.service.AuthzService;
5 6 import com.xly.service.ErpClient;
6 7 import com.xly.service.OpService;
7 8 import dev.langchain4j.agent.tool.P;
... ... @@ -27,12 +28,14 @@ public class ProposeWriteTool {
27 28 private final JdbcTemplate jdbc;
28 29 private final OpService ops;
29 30 private final ObjectMapper mapper;
  31 + private final AuthzService authz;
30 32  
31   - public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper) {
  33 + public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper, AuthzService authz) {
32 34 this.erp = erp;
33 35 this.jdbc = jdbc;
34 36 this.ops = ops;
35 37 this.mapper = mapper;
  38 + this.authz = authz;
36 39 }
37 40  
38 41 @Tool("提议修改某条现有记录的某个字段(写操作)。本工具**只提议并暂存、绝不立即执行**——"
... ... @@ -56,6 +59,9 @@ public class ProposeWriteTool {
56 59 String formId = str(form.get("sFormId"));
57 60 String moduleId = str(form.get("sModuleId"));
58 61 String table = str(form.get("sDataSource"));
  62 + if (!authz.canAccessModule(moduleId)) {
  63 + return err("你没有修改「" + entityKeyword + "」的权限。");
  64 + }
59 65  
60 66 // 2) 字段中文名 -> 技术列名
61 67 String field = queryOne(
... ... @@ -119,6 +125,68 @@ public class ProposeWriteTool {
119 125 return toJson(out);
120 126 }
121 127  
  128 + @Tool("提议**删除**某条现有记录(写操作)。本工具只提议并暂存、绝不立即执行——必须等用户点【确认】后才真正删除。"
  129 + + "删除不可恢复,请慎用。直接给出实体类型与记录名即可。")
  130 + public String proposeDelete(
  131 + @P("实体类型,如 客户 / 物料 / 供应商") String entityKeyword,
  132 + @P("要删除的记录的名称关键词") String recordKeyword) {
  133 +
  134 + if (isBlank(entityKeyword) || isBlank(recordKeyword)) {
  135 + return err("缺少实体类型或记录名称。");
  136 + }
  137 + Map<String, Object> form = resolveForm(entityKeyword.trim());
  138 + if (form == null) {
  139 + return err("找不到「" + entityKeyword + "」对应的可操作主表。");
  140 + }
  141 + String formId = str(form.get("sFormId"));
  142 + String moduleId = str(form.get("sModuleId"));
  143 + String table = str(form.get("sDataSource"));
  144 + if (!authz.canAccessModule(moduleId)) {
  145 + return err("你没有操作「" + entityKeyword + "」的权限。");
  146 + }
  147 + String nameField = queryOne(
  148 + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " +
  149 + "ORDER BY iFormUses DESC LIMIT 1", table);
  150 + JsonNode root;
  151 + try {
  152 + root = erp.readForm(formId, moduleId, 1, 5, nameField, recordKeyword.trim());
  153 + } catch (Exception e) {
  154 + return err("定位记录失败:" + e.getMessage());
  155 + }
  156 + if (root.path("code").asInt(0) < 0) {
  157 + return err("定位记录失败:" + root.path("msg").asText("未知错误"));
  158 + }
  159 + JsonNode rows = root.path("dataset").path("rows");
  160 + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null;
  161 + int n = (data != null && data.isArray()) ? data.size() : 0;
  162 + if (n == 0) {
  163 + return err("没有找到名称含「" + recordKeyword + "」的记录。");
  164 + }
  165 + if (n > 1) {
  166 + StringBuilder names = new StringBuilder();
  167 + for (int i = 0; i < data.size() && i < 5; i++) {
  168 + if (i > 0) names.append("、");
  169 + names.append(nameField == null ? "" : data.get(i).path(nameField).asText(""));
  170 + }
  171 + return err("匹配到多条记录(" + names + "),请提供更精确的名称,只删其中一条。");
  172 + }
  173 + JsonNode rec = data.get(0);
  174 + String billId = rec.path("sId").asText(null);
  175 + if (isBlank(billId)) {
  176 + return err("定位到的记录缺少主键 sId,无法安全删除。");
  177 + }
  178 + String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword);
  179 + String description = "删除【" + recordName + "】(" + entityKeyword + ")";
  180 + String opId = ops.createDraft("agent", "delete", formId, moduleId, table, billId,
  181 + null, null, recordName, null, description);
  182 +
  183 + Map<String, Object> out = new LinkedHashMap<>();
  184 + out.put("opId", opId);
  185 + out.put("summary", description);
  186 + out.put("message", "已为你生成一条待确认的删除,请在下方点【确认】执行、或【取消】。删除不可恢复,请谨慎。");
  187 + return toJson(out);
  188 + }
  189 +
122 190 /** 定位实体的可修改主表:该实体名下 table 类型、最常用(AI工具/连接度)的一张。 */
123 191 private Map<String, Object> resolveForm(String entityKeyword) {
124 192 try {
... ...
src/main/java/com/xly/web/AgentChatController.java
... ... @@ -93,7 +93,8 @@ public class AgentChatController {
93 93 private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) {
94 94 send(emitter, "reset", "");
95 95 try {
96   - if (te.request() != null && "proposeUpdate".equals(te.request().name()) && te.result() != null) {
  96 + String toolName = te.request() == null ? "" : te.request().name();
  97 + if (("proposeUpdate".equals(toolName) || "proposeDelete".equals(toolName)) && te.result() != null) {
97 98 JsonNode r = mapper.readTree(te.result());
98 99 String opId = r.path("opId").asText(null);
99 100 if (opId != null && !opId.isBlank()) {
... ...
src/main/java/com/xly/web/OpController.java
... ... @@ -60,14 +60,20 @@ public class OpController {
60 60 String target = str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId"));
61 61 String detail = str(op.get("sDescription"));
62 62 try {
63   - JsonNode r = erp.updateForm(
64   - str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")),
65   - str(op.get("sField")), str(op.get("sNewValue")));
  63 + String opType = str(op.get("sOpType"));
  64 + JsonNode r;
  65 + if ("delete".equals(opType)) {
  66 + r = erp.deleteForm(str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")));
  67 + } else {
  68 + r = erp.updateForm(
  69 + str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")),
  70 + str(op.get("sField")), str(op.get("sNewValue")));
  71 + }
66 72 int code = r.path("code").asInt(0);
67 73 if (code == 1) {
68 74 ops.setStatus(id, "executed", "操作成功");
69 75 audit.log(uid, conv, "confirm", target, detail, true, "executed");
70   - return result("executed", "已修改:" + op.get("sDescription"), op);
  76 + return result("executed", "已执行:" + op.get("sDescription"), op);
71 77 }
72 78 String msg = r.path("msg").asText("执行失败");
73 79 ops.setStatus(id, "failed", msg);
... ...