|
|
@@ -0,0 +1,491 @@
|
|
|
+package com.zsElectric.boot.platform.agent.service;
|
|
|
+
|
|
|
+import com.fasterxml.jackson.core.JsonProcessingException;
|
|
|
+import com.fasterxml.jackson.core.type.TypeReference;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.fasterxml.jackson.databind.SerializationFeature;
|
|
|
+import com.zsElectric.boot.platform.agent.model.dto.AgentToolCallRequest;
|
|
|
+import com.zsElectric.boot.platform.agent.model.dto.AgentToolCallResponse;
|
|
|
+import com.zsElectric.boot.platform.agent.model.entity.AgentApproval;
|
|
|
+import com.zsElectric.boot.platform.agent.model.entity.AgentAuditLog;
|
|
|
+import com.zsElectric.boot.platform.agent.model.entity.AgentPendingAction;
|
|
|
+import com.zsElectric.boot.platform.agent.tool.AgentToolCatalog;
|
|
|
+import com.zsElectric.boot.platform.agent.tool.AgentToolDefinition;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.security.MessageDigest;
|
|
|
+import java.security.NoSuchAlgorithmException;
|
|
|
+import java.time.Clock;
|
|
|
+import java.time.LocalDateTime;
|
|
|
+import java.util.HexFormat;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.UUID;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Agent 工具编排器。
|
|
|
+ *
|
|
|
+ * <p>这是 Java 侧 Agent 控制面的核心类,不直接承载具体业务逻辑,而是负责把一次
|
|
|
+ * MCP tool 调用放进受控流程中:先查白名单和风险等级,再决定是直接执行低风险工具,
|
|
|
+ * 还是为高风险工具创建审批单和冻结动作。这样可以避免模型绕过审批直接触发退款、
|
|
|
+ * 补偿等有资金或业务副作用的动作。</p>
|
|
|
+ *
|
|
|
+ * <p>高风险动作的关键安全点是“冻结参数”:审批前把 tool_name、params_json、
|
|
|
+ * params_hash 和 idempotency_key 固化到 agent_pending_action。审批通过后恢复执行时,
|
|
|
+ * 只允许执行这份冻结参数,不再让模型重新生成参数。</p>
|
|
|
+ */
|
|
|
+public class AgentToolOrchestrator {
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Jackson 反序列化 Map 的类型引用,专门用于读取冻结动作里的 params_json
|
|
|
+ * 和执行结果 JSON。
|
|
|
+ */
|
|
|
+ private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {
|
|
|
+ };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 工具白名单与风险定义。所有模型可调用工具必须先出现在这里。
|
|
|
+ */
|
|
|
+ private final AgentToolCatalog toolCatalog;
|
|
|
+ /**
|
|
|
+ * 审批单和冻结动作的持久化端口。测试中可以替换成内存实现,生产使用 MyBatis 实现。
|
|
|
+ */
|
|
|
+ private final AgentApprovalStore approvalStore;
|
|
|
+ /**
|
|
|
+ * 真实业务工具执行端口。编排器只关心“是否允许执行”,不关心具体业务怎么落库。
|
|
|
+ */
|
|
|
+ private final AgentBusinessToolExecutor toolExecutor;
|
|
|
+ /**
|
|
|
+ * Agent 全链路审计写入端口。关键节点都会追加审计,便于按 trace_id 回溯。
|
|
|
+ */
|
|
|
+ private final AgentAuditWriter auditWriter;
|
|
|
+ /**
|
|
|
+ * 可注入时钟,便于测试审批过期、冻结动作过期等时间相关逻辑。
|
|
|
+ */
|
|
|
+ private final Clock clock;
|
|
|
+ /**
|
|
|
+ * 用于生成稳定 JSON。参数哈希必须基于稳定序列化结果,否则同一参数 Map 的字段顺序
|
|
|
+ * 变化会导致哈希不一致。
|
|
|
+ */
|
|
|
+ private final ObjectMapper objectMapper;
|
|
|
+
|
|
|
+ public AgentToolOrchestrator(
|
|
|
+ AgentToolCatalog toolCatalog,
|
|
|
+ AgentApprovalStore approvalStore,
|
|
|
+ AgentBusinessToolExecutor toolExecutor,
|
|
|
+ AgentAuditWriter auditWriter,
|
|
|
+ Clock clock
|
|
|
+ ) {
|
|
|
+ this.toolCatalog = toolCatalog;
|
|
|
+ this.approvalStore = approvalStore;
|
|
|
+ this.toolExecutor = toolExecutor;
|
|
|
+ this.auditWriter = auditWriter;
|
|
|
+ this.clock = clock;
|
|
|
+ this.objectMapper = new ObjectMapper()
|
|
|
+ .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * MCP tool 调用入口。
|
|
|
+ *
|
|
|
+ * <p>这里统一执行三类分流:</p>
|
|
|
+ * <ul>
|
|
|
+ * <li>恢复类工具:resume_after_approval 只能走冻结动作恢复流程。</li>
|
|
|
+ * <li>查询类工具:get_approval_status 只读返回审批状态。</li>
|
|
|
+ * <li>普通业务工具:按白名单风险等级决定直接执行或创建审批。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * @param request Python Agent 传来的工具调用请求
|
|
|
+ * @return 工具执行结果,或审批等待结果,或明确失败原因
|
|
|
+ */
|
|
|
+ public AgentToolCallResponse call(AgentToolCallRequest request) {
|
|
|
+ AgentToolDefinition definition = toolCatalog.find(request.getToolName(), request.getToolVersion())
|
|
|
+ .orElse(null);
|
|
|
+ if (definition == null) {
|
|
|
+ return AgentToolCallResponse.failed(request, "tool is not in agent whitelist: " + request.getToolName());
|
|
|
+ }
|
|
|
+
|
|
|
+ if ("resume_after_approval".equals(definition.name())) {
|
|
|
+ return resumeAfterApproval(request);
|
|
|
+ }
|
|
|
+
|
|
|
+ if ("get_approval_status".equals(definition.name())) {
|
|
|
+ return getApprovalStatus(request);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (definition.approvalRequired()) {
|
|
|
+ return createApproval(request, definition);
|
|
|
+ }
|
|
|
+
|
|
|
+ return executeLowRiskTool(request, definition);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 审批通过。
|
|
|
+ *
|
|
|
+ * <p>注意:审批通过只把 agent_approval.status 从 pending 改成 approved,
|
|
|
+ * 不直接执行退款或补偿。真实执行必须由 resume_after_approval 再触发,
|
|
|
+ * 这样审批动作和执行恢复边界清楚,审计也更容易解释。</p>
|
|
|
+ */
|
|
|
+ public void approve(String approvalId, String approverId, String approverName, String decisionComment) {
|
|
|
+ AgentApproval approval = approvalStore.findApproval(approvalId)
|
|
|
+ .orElseThrow(() -> new IllegalArgumentException("approval not found: " + approvalId));
|
|
|
+ if (!"pending".equals(approval.getStatus())) {
|
|
|
+ throw new IllegalStateException("only pending approval can be approved");
|
|
|
+ }
|
|
|
+ approval.setStatus("approved");
|
|
|
+ approval.setApproverId(parseLong(approverId));
|
|
|
+ approval.setApproverUsername(approverName);
|
|
|
+ approval.setDecisionComment(decisionComment);
|
|
|
+ approval.setDecisionTime(now());
|
|
|
+ approvalStore.updateApproval(approval);
|
|
|
+ writeAudit(approval.getTraceId(), approvalId, null, "approval_decided", null, "success", null, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 审批拒绝。
|
|
|
+ *
|
|
|
+ * <p>拒绝后同步把冻结动作标记为 rejected,后续 resume_after_approval 会返回
|
|
|
+ * 确定的 rejected 结果,而不是再次尝试执行业务动作。</p>
|
|
|
+ */
|
|
|
+ public void reject(String approvalId, String approverId, String approverName, String decisionComment) {
|
|
|
+ AgentApproval approval = approvalStore.findApproval(approvalId)
|
|
|
+ .orElseThrow(() -> new IllegalArgumentException("approval not found: " + approvalId));
|
|
|
+ if (!"pending".equals(approval.getStatus())) {
|
|
|
+ throw new IllegalStateException("only pending approval can be rejected");
|
|
|
+ }
|
|
|
+ approval.setStatus("rejected");
|
|
|
+ approval.setApproverId(parseLong(approverId));
|
|
|
+ approval.setApproverUsername(approverName);
|
|
|
+ approval.setDecisionComment(decisionComment);
|
|
|
+ approval.setDecisionTime(now());
|
|
|
+ approvalStore.updateApproval(approval);
|
|
|
+
|
|
|
+ approvalStore.findPendingAction(approvalId).ifPresent(action -> {
|
|
|
+ action.setExecuteStatus("rejected");
|
|
|
+ approvalStore.updatePendingAction(action);
|
|
|
+ });
|
|
|
+ writeAudit(approval.getTraceId(), approvalId, null, "approval_decided", null, "success", null, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 为高风险工具创建审批单和冻结动作。
|
|
|
+ *
|
|
|
+ * <p>冻结动作保存的是审批后真正要执行的工具名和参数快照。params_hash 用来防止
|
|
|
+ * 审批期间参数被人为或程序篡改;idempotency_key 用于后续业务执行幂等保护。</p>
|
|
|
+ */
|
|
|
+ private AgentToolCallResponse createApproval(AgentToolCallRequest request, AgentToolDefinition definition) {
|
|
|
+ String approvalId = "APR" + token();
|
|
|
+ String actionId = "ACT" + token();
|
|
|
+ String paramsJson = canonicalJson(request.getParams());
|
|
|
+ LocalDateTime now = now();
|
|
|
+ LocalDateTime expiresAt = now.plusHours(24);
|
|
|
+
|
|
|
+ AgentApproval approval = new AgentApproval();
|
|
|
+ approval.setApprovalId(approvalId);
|
|
|
+ approval.setTraceId(request.getTraceId());
|
|
|
+ approval.setBizType(bizType(definition.name()));
|
|
|
+ approval.setBizNo(stringParam(request.getParams(), "order_no"));
|
|
|
+ approval.setRiskLevel(definition.riskLevel().name());
|
|
|
+ approval.setStatus("pending");
|
|
|
+ approval.setRequestUsername(request.getOperatorName());
|
|
|
+ approval.setRequestReason(stringParam(request.getParams(), "reason"));
|
|
|
+ approval.setRiskReason("HIGH risk tool requires human approval: " + definition.name());
|
|
|
+ approval.setPolicyEvidenceJson(toJsonOrNull(request.getParams().get("evidence")));
|
|
|
+ approval.setRequestedAmount(decimalParam(request.getParams(), "amount"));
|
|
|
+ approval.setExpiresAt(expiresAt);
|
|
|
+ approval.setVersion(0);
|
|
|
+ approval.setDeleted(0);
|
|
|
+
|
|
|
+ AgentPendingAction pendingAction = new AgentPendingAction();
|
|
|
+ pendingAction.setActionId(actionId);
|
|
|
+ pendingAction.setApprovalId(approvalId);
|
|
|
+ pendingAction.setTraceId(request.getTraceId());
|
|
|
+ pendingAction.setToolName(definition.name());
|
|
|
+ pendingAction.setToolVersion(definition.version());
|
|
|
+ pendingAction.setRiskLevel(definition.riskLevel().name());
|
|
|
+ pendingAction.setParamsJson(paramsJson);
|
|
|
+ pendingAction.setParamsHash(sha256(paramsJson));
|
|
|
+ pendingAction.setIdempotencyKey(definition.name() + ":" + approvalId);
|
|
|
+ pendingAction.setExecuteStatus("pending");
|
|
|
+ pendingAction.setRetryCount(0);
|
|
|
+ pendingAction.setExpiresAt(expiresAt);
|
|
|
+ pendingAction.setVersion(0);
|
|
|
+ pendingAction.setDeleted(0);
|
|
|
+
|
|
|
+ approvalStore.saveApproval(approval);
|
|
|
+ approvalStore.savePendingAction(pendingAction);
|
|
|
+ approval.setPendingActionId(pendingAction.getId());
|
|
|
+ approvalStore.updateApproval(approval);
|
|
|
+ writeAudit(request.getTraceId(), approvalId, actionId, "approval_created", definition.name(), "pending", request.getParams(), null);
|
|
|
+ return AgentToolCallResponse.pendingApproval(request, approvalId, actionId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 执行低风险工具。
|
|
|
+ *
|
|
|
+ * <p>低风险并不代表不审计;每次成功或失败都会写 agent_audit_log,确保
|
|
|
+ * Python Agent 的一次回答能通过 trace_id 还原工具链路。</p>
|
|
|
+ */
|
|
|
+ private AgentToolCallResponse executeLowRiskTool(AgentToolCallRequest request, AgentToolDefinition definition) {
|
|
|
+ try {
|
|
|
+ Map<String, Object> output = toolExecutor.execute(definition.name(), request.getParams(), request);
|
|
|
+ writeAudit(request.getTraceId(), null, null, "tool_called", definition.name(), "success", request.getParams(), output);
|
|
|
+ return AgentToolCallResponse.executed(request, output);
|
|
|
+ } catch (Exception ex) {
|
|
|
+ writeAudit(request.getTraceId(), null, null, "tool_called", definition.name(), "failed", request.getParams(), Map.of("error", ex.getMessage()));
|
|
|
+ return AgentToolCallResponse.failed(request, ex.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 审批通过后的恢复执行入口。
|
|
|
+ *
|
|
|
+ * <p>恢复执行会按固定顺序校验:审批存在、冻结动作存在、未重复执行、审批已通过、
|
|
|
+ * 未过期、工具仍在白名单、params_hash 与 params_json 一致。只有这些条件全部满足,
|
|
|
+ * 才会调用真实业务工具。</p>
|
|
|
+ */
|
|
|
+ private AgentToolCallResponse resumeAfterApproval(AgentToolCallRequest request) {
|
|
|
+ String approvalId = stringParam(request.getParams(), "approval_id");
|
|
|
+ if (approvalId == null || approvalId.isBlank()) {
|
|
|
+ return AgentToolCallResponse.failed(request, "approval_id is required");
|
|
|
+ }
|
|
|
+
|
|
|
+ AgentApproval approval = approvalStore.findApproval(approvalId).orElse(null);
|
|
|
+ AgentPendingAction pendingAction = approvalStore.findPendingAction(approvalId).orElse(null);
|
|
|
+ if (approval == null || pendingAction == null) {
|
|
|
+ return AgentToolCallResponse.failed(request, "approval or pending_action not found");
|
|
|
+ }
|
|
|
+
|
|
|
+ if ("executed".equals(pendingAction.getExecuteStatus())) {
|
|
|
+ return executedFromHistory(request, pendingAction);
|
|
|
+ }
|
|
|
+ if ("rejected".equals(approval.getStatus()) || "rejected".equals(pendingAction.getExecuteStatus())) {
|
|
|
+ return terminalResponse(request, "rejected", Map.of("approval_id", approvalId, "status", "rejected"));
|
|
|
+ }
|
|
|
+ if (!"approved".equals(approval.getStatus())) {
|
|
|
+ return terminalResponse(request, "pending_approval", Map.of("approval_id", approvalId, "status", approval.getStatus()));
|
|
|
+ }
|
|
|
+ if (pendingAction.getExpiresAt() != null && pendingAction.getExpiresAt().isBefore(now())) {
|
|
|
+ approval.setStatus("expired");
|
|
|
+ pendingAction.setExecuteStatus("expired");
|
|
|
+ approvalStore.updateApproval(approval);
|
|
|
+ approvalStore.updatePendingAction(pendingAction);
|
|
|
+ return terminalResponse(request, "expired", Map.of("approval_id", approvalId, "status", "expired"));
|
|
|
+ }
|
|
|
+
|
|
|
+ AgentToolDefinition frozenDefinition = toolCatalog.find(pendingAction.getToolName(), pendingAction.getToolVersion())
|
|
|
+ .orElse(null);
|
|
|
+ if (frozenDefinition == null) {
|
|
|
+ return failPendingAction(request, pendingAction, "frozen tool is no longer enabled");
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Object> frozenParams = parseJsonMap(pendingAction.getParamsJson());
|
|
|
+ String recalculatedHash = sha256(canonicalJson(frozenParams));
|
|
|
+ if (!recalculatedHash.equals(pendingAction.getParamsHash())) {
|
|
|
+ return failPendingAction(request, pendingAction, "params_hash validation failed");
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ pendingAction.setExecuteStatus("executing");
|
|
|
+ approvalStore.updatePendingAction(pendingAction);
|
|
|
+ Map<String, Object> output = toolExecutor.execute(pendingAction.getToolName(), frozenParams, request);
|
|
|
+ pendingAction.setExecuteStatus("executed");
|
|
|
+ pendingAction.setExecuteResultJson(canonicalJson(output));
|
|
|
+ pendingAction.setExecutedAt(now());
|
|
|
+ approval.setStatus("executed");
|
|
|
+ approvalStore.updatePendingAction(pendingAction);
|
|
|
+ approvalStore.updateApproval(approval);
|
|
|
+ writeAudit(request.getTraceId(), approvalId, pendingAction.getActionId(), "action_resumed", pendingAction.getToolName(), "success", frozenParams, output);
|
|
|
+ return AgentToolCallResponse.executed(request, output);
|
|
|
+ } catch (Exception ex) {
|
|
|
+ return failPendingAction(request, pendingAction, ex.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询审批状态,供 Python Agent 或演示脚本轮询审批结果。
|
|
|
+ */
|
|
|
+ private AgentToolCallResponse getApprovalStatus(AgentToolCallRequest request) {
|
|
|
+ String approvalId = stringParam(request.getParams(), "approval_id");
|
|
|
+ return approvalStore.findApproval(approvalId)
|
|
|
+ .map(approval -> terminalResponse(request, "success", Map.of(
|
|
|
+ "approval_id", approval.getApprovalId(),
|
|
|
+ "status", approval.getStatus(),
|
|
|
+ "trace_id", approval.getTraceId()
|
|
|
+ )))
|
|
|
+ .orElseGet(() -> AgentToolCallResponse.failed(request, "approval not found: " + approvalId));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 重复恢复时返回历史执行结果。
|
|
|
+ *
|
|
|
+ * <p>这一步保证 resume_after_approval 具备幂等语义:同一个 approval_id 已经执行成功后,
|
|
|
+ * 再次调用不会重复退款或补偿,只返回第一次执行的结果快照。</p>
|
|
|
+ */
|
|
|
+ private AgentToolCallResponse executedFromHistory(AgentToolCallRequest request, AgentPendingAction pendingAction) {
|
|
|
+ AgentToolCallResponse response = AgentToolCallResponse.executed(request, parseJsonMap(pendingAction.getExecuteResultJson()));
|
|
|
+ response.setApprovalId(pendingAction.getApprovalId());
|
|
|
+ response.setPendingActionId(pendingAction.getActionId());
|
|
|
+ return response;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构造不会继续执行业务动作的终态响应,例如 rejected、expired、pending_approval。
|
|
|
+ */
|
|
|
+ private AgentToolCallResponse terminalResponse(AgentToolCallRequest request, String status, Map<String, Object> output) {
|
|
|
+ AgentToolCallResponse response = new AgentToolCallResponse();
|
|
|
+ response.setSuccess(true);
|
|
|
+ response.setStatus(status);
|
|
|
+ response.setTraceId(request.getTraceId());
|
|
|
+ response.setToolName(request.getToolName());
|
|
|
+ response.setOutput(output);
|
|
|
+ return response;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 冻结动作恢复失败时统一落失败状态并写审计。
|
|
|
+ */
|
|
|
+ private AgentToolCallResponse failPendingAction(AgentToolCallRequest request, AgentPendingAction pendingAction, String message) {
|
|
|
+ pendingAction.setExecuteStatus("failed");
|
|
|
+ pendingAction.setErrorMessage(message);
|
|
|
+ approvalStore.updatePendingAction(pendingAction);
|
|
|
+ writeAudit(request.getTraceId(), pendingAction.getApprovalId(), pendingAction.getActionId(), "action_resumed", pendingAction.getToolName(), "failed", null, Map.of("error", message));
|
|
|
+ return AgentToolCallResponse.failed(request, message);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 写 Agent 审计日志。
|
|
|
+ *
|
|
|
+ * <p>审计日志只追加,不参与业务状态判断。这里保存输入输出快照时仍然走 JSON,
|
|
|
+ * 后续如果有敏感字段脱敏策略,可以在这个入口或 AgentAuditWriter 实现里统一处理。</p>
|
|
|
+ */
|
|
|
+ private void writeAudit(
|
|
|
+ String traceId,
|
|
|
+ String approvalId,
|
|
|
+ String pendingActionId,
|
|
|
+ String step,
|
|
|
+ String toolName,
|
|
|
+ String status,
|
|
|
+ Object input,
|
|
|
+ Object output
|
|
|
+ ) {
|
|
|
+ AgentAuditLog auditLog = new AgentAuditLog();
|
|
|
+ auditLog.setTraceId(traceId);
|
|
|
+ auditLog.setApprovalId(approvalId);
|
|
|
+ auditLog.setPendingActionId(pendingActionId);
|
|
|
+ auditLog.setActorType("agent");
|
|
|
+ auditLog.setStep(step);
|
|
|
+ auditLog.setEventType(step);
|
|
|
+ auditLog.setToolName(toolName);
|
|
|
+ auditLog.setStatus(status);
|
|
|
+ auditLog.setInputJson(toJsonOrNull(input));
|
|
|
+ auditLog.setOutputJson(toJsonOrNull(output));
|
|
|
+ auditLog.setCreateTime(now());
|
|
|
+ auditWriter.write(auditLog);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成稳定 JSON 字符串,用于冻结参数和哈希计算。
|
|
|
+ */
|
|
|
+ private String canonicalJson(Object value) {
|
|
|
+ try {
|
|
|
+ return objectMapper.writeValueAsString(value == null ? Map.of() : value);
|
|
|
+ } catch (JsonProcessingException e) {
|
|
|
+ throw new IllegalArgumentException("failed to serialize json", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 将冻结参数或历史执行结果反序列化为 Map,供恢复执行和历史返回使用。
|
|
|
+ */
|
|
|
+ private Map<String, Object> parseJsonMap(String json) {
|
|
|
+ if (json == null || json.isBlank()) {
|
|
|
+ return new LinkedHashMap<>();
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return objectMapper.readValue(json, MAP_TYPE);
|
|
|
+ } catch (JsonProcessingException e) {
|
|
|
+ throw new IllegalArgumentException("failed to parse json", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算 SHA-256 摘要。params_hash 使用该方法防止冻结参数被篡改。
|
|
|
+ */
|
|
|
+ private String sha256(String value) {
|
|
|
+ try {
|
|
|
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
|
+ return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
|
|
|
+ } catch (NoSuchAlgorithmException e) {
|
|
|
+ throw new IllegalStateException("SHA-256 is unavailable", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 当前时间统一从注入时钟获取,避免测试中依赖真实系统时间。
|
|
|
+ */
|
|
|
+ private LocalDateTime now() {
|
|
|
+ return LocalDateTime.now(clock);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成外部展示用的短 ID 片段,调用方再加 APR/ACT 前缀区分审批和冻结动作。
|
|
|
+ */
|
|
|
+ private String token() {
|
|
|
+ return UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 将工具名转换成审批业务类型,便于审批列表按业务维度筛选。
|
|
|
+ */
|
|
|
+ private String bizType(String toolName) {
|
|
|
+ if ("request_compensation".equals(toolName)) {
|
|
|
+ return "compensation";
|
|
|
+ }
|
|
|
+ if ("request_refund".equals(toolName)) {
|
|
|
+ return "refund";
|
|
|
+ }
|
|
|
+ return toolName;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从通用参数 Map 中读取字符串参数。
|
|
|
+ */
|
|
|
+ private String stringParam(Map<String, Object> params, String name) {
|
|
|
+ Object value = params == null ? null : params.get(name);
|
|
|
+ return value == null ? null : String.valueOf(value);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从通用参数 Map 中读取金额参数。
|
|
|
+ */
|
|
|
+ private BigDecimal decimalParam(Map<String, Object> params, String name) {
|
|
|
+ String value = stringParam(params, name);
|
|
|
+ if (value == null || value.isBlank()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return new BigDecimal(value);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 将审批人 ID 等字符串字段转换成长整型,允许为空。
|
|
|
+ */
|
|
|
+ private Long parseLong(String value) {
|
|
|
+ if (value == null || value.isBlank()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return Long.parseLong(value);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 可空对象转 JSON;空值保持 null,避免审计表里出现无意义的 "{}"。
|
|
|
+ */
|
|
|
+ private String toJsonOrNull(Object value) {
|
|
|
+ if (value == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return canonicalJson(value);
|
|
|
+ }
|
|
|
+}
|