From 42a40f61c517318c2fac159389b82ae8233e19bb Mon Sep 17 00:00:00 2001 From: zichun <26684461+reporkey@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:30:41 +0800 Subject: [PATCH] feat: authz layer (form allowlist) + delete write + security findings report --- docs/security-findings.md | 42 ++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/service/AuthzService.java | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/service/ErpClient.java | 34 ++++++++++++++++++++++++++++++++++ src/main/java/com/xly/service/SystemPromptService.java | 6 +++--- src/main/java/com/xly/tool/ErpReadTool.java | 11 ++++++++++- src/main/java/com/xly/tool/ProposeWriteTool.java | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- src/main/java/com/xly/web/AgentChatController.java | 3 ++- src/main/java/com/xly/web/OpController.java | 14 ++++++++++---- 8 files changed, 307 insertions(+), 10 deletions(-) create mode 100644 docs/security-findings.md create mode 100644 src/main/java/com/xly/service/AuthzService.java diff --git a/docs/security-findings.md b/docs/security-findings.md new file mode 100644 index 0000000..4562dc1 --- /dev/null +++ b/docs/security-findings.md @@ -0,0 +1,42 @@ +# 现网安全发现报告(xlyAi / saas ERP) + +> 调查时间 2026-07-21。对象:saas-8s+ ERP 后端(`xlyEntry`)+ xlyAi 现有 NL2SQL。 +> 结论:**后端不是权限权威**,且现有 AI 取数路径存在越权与"读接口可写"风险。下述为供业务方决策的整改清单。 + +## 摘要(按严重度) + +| # | 严重度 | 发现 | 影响 | +|---|--------|------|------| +| 1 | 高 | 后端逐用户**表单/菜单权限被故意关闭** | 任何登录用户经 API 可碰到 UI 里看不到的表单、可审核/删除全公司数据 | +| 2 | 高 | 通用读接口**可被当写接口用** | `bUpdate=1` 等参数经参数绑定触发存储过程改库存 | +| 3 | 高 | 现有 NL2SQL **无租户过滤、可跨品牌** | A 品牌用户"查所有客户"能拿到全部品牌数据 | +| 4 | 中 | NL2SQL **执行期无表白名单**、SQL 安全仅部分 | 构造问题可 SELECT 任意视图;`INTO OUTFILE`/`LOAD_FILE` 未挡 | +| 5 | 中 | NL2SQL **流式缓存命中跳过校验**、缓存无租户隔离 | 缓存投毒 / 越权复用 | + +## 明细 + +### 1. 后端不是权限权威(表单级权限被关) +- `AuthorizationInterceptor` 里逐用户表单权限校验 `checkByUser`(约 143–164 行)**被注释**,注释写着「朱总说不用放 20230626」。 +- `getBusinessDataByFormcustomId` 及**所有写/动作端点**(add/update/delete/审核 `doExamine`)只挂 `@Authorization`=**仅验登录**。 +- 后端**只强制**:公司级租户隔离(`sBrandsId/sSubsidiaryId`)+ 行级 `jurisdiction` 数据范围。 +- 逐用户表单/菜单权限只在**前端 UI** 按 `sAuthsId` 过滤菜单。**agent/脚本直接打 API 即绕过 UI** → 越权放大。 +- 说明:`sAuthsId` 的数据仍在(`sysjurisdiction`,由 `getsAuthsIdNew` 运行时算出,前端 `/getMenuList` 用),所以**可在 agent 侧补回表单级白名单**(xlyAi 已按此实现授权层,见 `docs/agent-architecture.md §7`)。 + +### 2. 读接口可写 +- 通用表单接口把请求体任意 key 按名绑定到存储过程 IN 参、**无白名单**。 +- 例:`材料库存台账`(`SP_Inventory_InOutWarehouse`,AI 已暴露)在 `bUpdate=1` 时**真改** `MitMaterialsStore/EleMaterialsStock`(重算并持久化库存)。 +- 缓解:xlyAi 的 Read 工具只传分页/过滤参数,**绝不透传 `bUpdate/bUpdateAll`**(已实现)。根因需后端加**参数白名单**。 + +### 3–5. 现有 NL2SQL(`XlyErpService.getDynamicTableSqlExec` 链路) +- **无租户过滤**:prompt 从不提品牌,代码不追加 WHERE,视图把品牌当普通列但不过滤;规则默认「返回全部数据」→ 跨品牌泄漏。 +- **越权双轴**:跨品牌(同上);跨表——工具权限只筛「喂给模型的表」,**执行期无 allowlist**,构造问题可查任意视图;系统管理员拿到全部工具。 +- **SQL 安全仅部分**:jsqlparser 挡了 Insert/Update/Delete/Drop + 关键字黑名单,但**无正向 SELECT-only**、`INTO OUTFILE` 未挡、`LOAD_FILE` 未挡(`\bLOAD\b` ≠ `LOAD_FILE`)、无 LIMIT。本地账号 root 全权含 FILE。 +- **流式缓存路径命中即跳过校验**(`if isEmpty(cleanSql)` 兜底)+ 缓存无租户维度。 + +## 建议整改(优先级) +1. **后端**:重开逐用户表单权限校验(`checkByUser`),或至少对 AI 服务账号强制表单白名单。 +2. **后端**:通用读接口加**参数白名单**,禁止读路径接受写类参数(`bUpdate` 等)。 +3. **NL2SQL**(新 `queryData` 已按此实现):强制单条 SELECT、挡 OUTFILE/LOAD_FILE/系统库、强制 LIMIT、专用只读账号、AST 注入租户谓词、缓存过校验且按品牌隔离。 +4. 金融类数据:写操作/SQL **不可变审计**(xlyAi 已建 `ai_audit_log`)。 + +> 注:xlyAi 侧已实现的缓解(授权层白名单、Read 参数白名单、`queryData` 安全栈、审计)是**纵深防御**,不替代后端整改(第 1、2 项根因在后端)。 diff --git a/src/main/java/com/xly/service/AuthzService.java b/src/main/java/com/xly/service/AuthzService.java new file mode 100644 index 0000000..2086d9b --- /dev/null +++ b/src/main/java/com/xly/service/AuthzService.java @@ -0,0 +1,137 @@ +package com.xly.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * xlyAi 侧授权层 —— 表单级白名单(架构 §7)。 + * + *

后端逐用户表单权限被关(只验登录 + 租户 + 行级),所以在 agent 侧按用户实际授权把可碰的 + * 表单/菜单限制住。数据源与后端一致(`sysjurisdiction`,即 `getsAuthsIdNew` 的逻辑)——不新造。 + * + *

管理员(sysadmin/admin) = 全部权限(返回 null);否则 = 该用户有权的 id 集合(菜单/表单/按钮, + * 由 `sysjurisdiction.sKey` 按 '-' 拆出)。当前本地 dev-login 以 admin 身份运行,因此实际全通; + * 生产透传用户 token 时即按各用户真实授权收紧。Read / Query / Write 共用此边界。 + */ +@Service +public class AuthzService { + + private final JdbcTemplate jdbc; + + @Value("${erp.dev-login.username:admin}") + private String devUserNo; + @Value("${erp.dev-login.brand:1111111111}") + private String devBrand; + @Value("${erp.dev-login.subsidiary:1111111111}") + private String devSub; + @Value("${erp.dev-login.usertype:sysadmin}") + private String devUserType; + + public AuthzService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** 当前有效用户(本地=dev-login)能否访问该菜单/表单 id。 */ + public boolean canAccessModule(String moduleId) { + Set granted = grantedIds(resolveDevUserId(), devUserType, devBrand, devSub); + return isAllowed(granted, moduleId); + } + + /** null = 全部(管理员);否则 = 有权的 id 集合。 */ + public Set grantedIds(String userId, String userType, String brandsId, String subsidiaryId) { + if (isAdmin(userType)) { + return null; // 超管全部权限 + } + Set ids = new HashSet<>(); + if (userId == null) { + return ids; + } + List> rows; + Integer direct = safeCount( + "SELECT COUNT(*) FROM sysjurisdiction WHERE sBrandsId=? AND sSubsidiaryId=? AND sUserId=?", + brandsId, subsidiaryId, userId); + if (direct != null && direct > 0) { + rows = jdbc.queryForList( + "SELECT J.sKey AS sKey FROM sysjurisdiction J WHERE J.sBrandsId=? AND J.sSubsidiaryId=? AND J.sUserId=?", + brandsId, subsidiaryId, userId); + } else { + String groups = queryStr( + "SELECT GROUP_CONCAT(sJurisdictionClassifyId) FROM sftlogininfojurisdictiongroup " + + "WHERE sBrandsId=? AND sSubsidiaryId=? AND sParentId=? " + + "AND sJurisdictionClassifyId IN (SELECT sId FROM sisjurisdictionclassify WHERE bLogininfoShow=0)", + brandsId, subsidiaryId, userId); + if (groups == null || groups.isBlank()) { + return ids; + } + List gl = Arrays.asList(groups.split(",")); + String in = gl.stream().map(g -> "?").collect(Collectors.joining(",")); + List args = new ArrayList<>(); + args.add(brandsId); + args.add(subsidiaryId); + args.addAll(gl); + rows = jdbc.queryForList( + "SELECT J.sKey AS sKey FROM sysjurisdiction J WHERE J.sBrandsId=? AND J.sSubsidiaryId=? " + + "AND J.sJurisdictionClassifyId IN (" + in + ")", args.toArray()); + } + for (Map r : rows) { + Object k = r.get("sKey"); + if (k != null) { + for (String part : k.toString().split("-")) { + if (!part.isBlank()) { + ids.add(part); + } + } + } + } + return ids; + } + + public boolean isAllowed(Set granted, String... ids) { + if (granted == null) { + return true; // 管理员 + } + for (String id : ids) { + if (id != null && granted.contains(id)) { + return true; + } + } + return false; + } + + private String resolveDevUserId() { + return queryStr("SELECT sId FROM gdslogininfo WHERE sUserNo=? AND sBrandsId=? LIMIT 1", devUserNo, devBrand); + } + + private boolean isAdmin(String userType) { + return userType != null && (userType.equalsIgnoreCase("sysadmin") || userType.equalsIgnoreCase("admin")); + } + + private Integer safeCount(String sql, Object... args) { + try { + return jdbc.queryForObject(sql, Integer.class, args); + } catch (Exception e) { + return 0; + } + } + + private String queryStr(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; + } +} diff --git a/src/main/java/com/xly/service/ErpClient.java b/src/main/java/com/xly/service/ErpClient.java index a9aed80..97c0281 100644 --- a/src/main/java/com/xly/service/ErpClient.java +++ b/src/main/java/com/xly/service/ErpClient.java @@ -140,6 +140,40 @@ public class ErpClient { return root; } + /** 删除一条记录(addUpdateDelBusinessData, handleType=del)。会话过期自动重登重试。 */ + public JsonNode deleteForm(String moduleId, String table, String billId) { + JsonNode root = doDelete(moduleId, table, billId, token()); + if (root.path("code").asInt() == -2) { + login(); + root = doDelete(moduleId, table, billId, token()); + } + return root; + } + + private JsonNode doDelete(String moduleId, String table, String billId, String tok) { + try { + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; + Map col = new LinkedHashMap<>(); + col.put("handleType", "del"); + col.put("sId", billId); + Map dataItem = new LinkedHashMap<>(); + dataItem.put("sTable", table); + dataItem.put("name", "master"); + dataItem.put("column", List.of(col)); + String body = mapper.writeValueAsString(Map.of("data", List.of(dataItem))); + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/json;charset=UTF-8") + .header("Authorization", tok) + .timeout(Duration.ofSeconds(30)) + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + return mapper.readTree(resp.body()); + } catch (Exception e) { + throw new RuntimeException("ERP 删除异常: " + e.getMessage(), e); + } + } + private JsonNode doUpdate(String moduleId, String table, String billId, String field, String value, String tok) { try { String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; diff --git a/src/main/java/com/xly/service/SystemPromptService.java b/src/main/java/com/xly/service/SystemPromptService.java index 4ee623d..5b74de8 100644 --- a/src/main/java/com/xly/service/SystemPromptService.java +++ b/src/main/java/com/xly/service/SystemPromptService.java @@ -42,6 +42,7 @@ public class SystemPromptService { 仅当 readFormData / lookupRecord 都答不了时才用。 - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\ (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。 + - proposeDelete(entityKeyword, recordKeyword):**提议删除**某条记录(写操作,只提议、暂不执行;用户确认后才删)。删除不可恢复,慎用。 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\ (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。 @@ -50,9 +51,8 @@ public class SystemPromptService { 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 - 4. **改现有记录的某个字段**:直接用 proposeUpdate(给出实体类型如「客户」、记录名、字段中文名、新值)生成\ - 一条【待确认】的修改提议——它**不会立即执行**;真正修改要用户在对话内点【确认】才发生,你**绝不能声称已修改完成**。\ - 新增单据 / 删除 / 审核等其它写操作仍在开发中,如实告知。 + 4. 写操作:改字段用 proposeUpdate、删除记录用 proposeDelete。它们都**只生成待确认提议、不立即执行**;\ + 真正的修改/删除要用户在对话内点【确认】才发生,你**绝不能声称已经改好/删好**。新增单据 / 审核等其它写操作仍在开发中,如实告知。 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\ 只有确实缺少关键参数(如不知道要查哪张单据)时才提问。 """.formatted(renderDomainMap()); diff --git a/src/main/java/com/xly/tool/ErpReadTool.java b/src/main/java/com/xly/tool/ErpReadTool.java index 035c96a..892365a 100644 --- a/src/main/java/com/xly/tool/ErpReadTool.java +++ b/src/main/java/com/xly/tool/ErpReadTool.java @@ -1,6 +1,7 @@ package com.xly.tool; import com.fasterxml.jackson.databind.JsonNode; +import com.xly.service.AuthzService; import com.xly.service.ErpClient; import com.xly.service.FormResolverService; import dev.langchain4j.agent.tool.P; @@ -33,11 +34,13 @@ public class ErpReadTool { private final ErpClient erp; private final JdbcTemplate jdbc; private final FormResolverService resolver; + private final AuthzService authz; - public ErpReadTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver) { + public ErpReadTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, AuthzService authz) { this.erp = erp; this.jdbc = jdbc; this.resolver = resolver; + this.authz = authz; } @Tool("查询某个实体下某条命名记录的**完整信息**(返回该记录的所有可读字段)。" @@ -55,6 +58,9 @@ public class ErpReadTool { String formId = String.valueOf(form.get("sFormId")); String moduleId = String.valueOf(form.get("sModuleId")); String table = String.valueOf(form.get("sDataSource")); + if (!authz.canAccessModule(moduleId)) { + return "你没有访问「" + entityKeyword + "」的权限。"; + } String nameField = resolver.resolveNameField(table); JsonNode root; @@ -119,6 +125,9 @@ public class ErpReadTool { if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) { return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。"; } + if (!authz.canAccessModule(moduleId.trim())) { + return "你没有访问该表单的权限。"; + } String kw = (keyword == null) ? "" : keyword.trim(); String nameField = kw.isEmpty() ? null : resolveNameField(formId.trim()); diff --git a/src/main/java/com/xly/tool/ProposeWriteTool.java b/src/main/java/com/xly/tool/ProposeWriteTool.java index 420bee9..4db8a4b 100644 --- a/src/main/java/com/xly/tool/ProposeWriteTool.java +++ b/src/main/java/com/xly/tool/ProposeWriteTool.java @@ -2,6 +2,7 @@ package com.xly.tool; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.service.AuthzService; import com.xly.service.ErpClient; import com.xly.service.OpService; import dev.langchain4j.agent.tool.P; @@ -27,12 +28,14 @@ public class ProposeWriteTool { private final JdbcTemplate jdbc; private final OpService ops; private final ObjectMapper mapper; + private final AuthzService authz; - public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper) { + public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper, AuthzService authz) { this.erp = erp; this.jdbc = jdbc; this.ops = ops; this.mapper = mapper; + this.authz = authz; } @Tool("提议修改某条现有记录的某个字段(写操作)。本工具**只提议并暂存、绝不立即执行**——" @@ -56,6 +59,9 @@ public class ProposeWriteTool { String formId = str(form.get("sFormId")); String moduleId = str(form.get("sModuleId")); String table = str(form.get("sDataSource")); + if (!authz.canAccessModule(moduleId)) { + return err("你没有修改「" + entityKeyword + "」的权限。"); + } // 2) 字段中文名 -> 技术列名 String field = queryOne( @@ -119,6 +125,68 @@ public class ProposeWriteTool { return toJson(out); } + @Tool("提议**删除**某条现有记录(写操作)。本工具只提议并暂存、绝不立即执行——必须等用户点【确认】后才真正删除。" + + "删除不可恢复,请慎用。直接给出实体类型与记录名即可。") + public String proposeDelete( + @P("实体类型,如 客户 / 物料 / 供应商") String entityKeyword, + @P("要删除的记录的名称关键词") String recordKeyword) { + + if (isBlank(entityKeyword) || isBlank(recordKeyword)) { + return err("缺少实体类型或记录名称。"); + } + Map form = resolveForm(entityKeyword.trim()); + if (form == null) { + return err("找不到「" + entityKeyword + "」对应的可操作主表。"); + } + String formId = str(form.get("sFormId")); + String moduleId = str(form.get("sModuleId")); + String table = str(form.get("sDataSource")); + if (!authz.canAccessModule(moduleId)) { + return err("你没有操作「" + entityKeyword + "」的权限。"); + } + String nameField = 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); + JsonNode root; + try { + root = erp.readForm(formId, moduleId, 1, 5, nameField, recordKeyword.trim()); + } catch (Exception e) { + return err("定位记录失败:" + e.getMessage()); + } + if (root.path("code").asInt(0) < 0) { + return err("定位记录失败:" + 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 err("没有找到名称含「" + 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 err("匹配到多条记录(" + names + "),请提供更精确的名称,只删其中一条。"); + } + JsonNode rec = data.get(0); + String billId = rec.path("sId").asText(null); + if (isBlank(billId)) { + return err("定位到的记录缺少主键 sId,无法安全删除。"); + } + String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); + String description = "删除【" + recordName + "】(" + entityKeyword + ")"; + String opId = ops.createDraft("agent", "delete", formId, moduleId, table, billId, + null, null, recordName, null, description); + + Map out = new LinkedHashMap<>(); + out.put("opId", opId); + out.put("summary", description); + out.put("message", "已为你生成一条待确认的删除,请在下方点【确认】执行、或【取消】。删除不可恢复,请谨慎。"); + return toJson(out); + } + /** 定位实体的可修改主表:该实体名下 table 类型、最常用(AI工具/连接度)的一张。 */ private Map resolveForm(String entityKeyword) { try { diff --git a/src/main/java/com/xly/web/AgentChatController.java b/src/main/java/com/xly/web/AgentChatController.java index c96d25f..9573902 100644 --- a/src/main/java/com/xly/web/AgentChatController.java +++ b/src/main/java/com/xly/web/AgentChatController.java @@ -93,7 +93,8 @@ public class AgentChatController { private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) { send(emitter, "reset", ""); try { - if (te.request() != null && "proposeUpdate".equals(te.request().name()) && te.result() != null) { + String toolName = te.request() == null ? "" : te.request().name(); + if (("proposeUpdate".equals(toolName) || "proposeDelete".equals(toolName)) && te.result() != null) { JsonNode r = mapper.readTree(te.result()); String opId = r.path("opId").asText(null); if (opId != null && !opId.isBlank()) { diff --git a/src/main/java/com/xly/web/OpController.java b/src/main/java/com/xly/web/OpController.java index d089238..90f9bd5 100644 --- a/src/main/java/com/xly/web/OpController.java +++ b/src/main/java/com/xly/web/OpController.java @@ -60,14 +60,20 @@ public class OpController { 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")), - str(op.get("sField")), str(op.get("sNewValue"))); + String opType = str(op.get("sOpType")); + JsonNode r; + if ("delete".equals(opType)) { + r = erp.deleteForm(str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); + } else { + r = erp.updateForm( + str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), + str(op.get("sField")), str(op.get("sNewValue"))); + } 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); + return result("executed", "已执行:" + op.get("sDescription"), op); } String msg = r.path("msg").asText("执行失败"); ops.setStatus(id, "failed", msg); -- libgit2 0.22.2