Commit 42a40f61c517318c2fac159389b82ae8233e19bb
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.
Showing
8 changed files
with
307 additions
and
10 deletions
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); | ... | ... |