1
0

5 کامیت‌ها 373672b5c3 ... 23802f1183

نویسنده SHA1 پیام تاریخ
  wzq 23802f1183 feat(architecture): 删除企业售后Agent项目设计文档 1 ماه پیش
  wzq 0cb5bad243 docs: switch aftersales agent design to langgraph 1 ماه پیش
  wzq 4db93ee08e docs: add aftersales agent llm configuration 1 ماه پیش
  wzq 84f2a2e406 docs: expand aftersales agent table design 1 ماه پیش
  wzq 14ea3381ae docs: design enterprise aftersales agent 1 ماه پیش
47فایلهای تغییر یافته به همراه3532 افزوده شده و 49 حذف شده
  1. 0 3
      .vscode/settings.json
  2. 930 0
      doc/s.md
  3. 10 10
      doc/第三方接入API文档.md
  4. 164 0
      sql/mysql/agent_aftersales_m1.sql
  5. 55 0
      sql/postgresql/aftersales_policy_pgvector.sql
  6. 12 0
      src/main/java/com/zsElectric/boot/business/controller/ThirdPartyInfoController.java
  7. 3 0
      src/main/java/com/zsElectric/boot/business/model/query/ChargeOrderInfoQuery.java
  8. 2 0
      src/main/java/com/zsElectric/boot/business/model/vo/ChargeOrderInfoVO.java
  9. 1 1
      src/main/java/com/zsElectric/boot/business/quartz/CompensateOrderJob.java
  10. 1 1
      src/main/java/com/zsElectric/boot/business/quartz/FailOrderDisposeJob.java
  11. 3 0
      src/main/java/com/zsElectric/boot/business/service/ThirdPartyInfoService.java
  12. 26 26
      src/main/java/com/zsElectric/boot/business/service/impl/ChargeOrderInfoServiceImpl.java
  13. 13 0
      src/main/java/com/zsElectric/boot/business/service/impl/ThirdPartyInfoServiceImpl.java
  14. 2 0
      src/main/java/com/zsElectric/boot/business/service/impl/UserInfoServiceImpl.java
  15. 2 2
      src/main/java/com/zsElectric/boot/charging/quartz/ChargingJob.java
  16. 51 0
      src/main/java/com/zsElectric/boot/platform/agent/config/AgentToolConfig.java
  17. 107 0
      src/main/java/com/zsElectric/boot/platform/agent/controller/AgentApprovalController.java
  18. 68 0
      src/main/java/com/zsElectric/boot/platform/agent/controller/AgentMcpController.java
  19. 12 0
      src/main/java/com/zsElectric/boot/platform/agent/mapper/AftersalesWorkOrderMapper.java
  20. 12 0
      src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentApprovalMapper.java
  21. 12 0
      src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentAuditLogMapper.java
  22. 12 0
      src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentPendingActionMapper.java
  23. 15 0
      src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentToolRegistryMapper.java
  24. 28 0
      src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentApprovalDecisionRequest.java
  25. 46 0
      src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentToolCallRequest.java
  26. 107 0
      src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentToolCallResponse.java
  27. 99 0
      src/main/java/com/zsElectric/boot/platform/agent/model/entity/AftersalesWorkOrder.java
  28. 107 0
      src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentApproval.java
  29. 104 0
      src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentAuditLog.java
  30. 94 0
      src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentPendingAction.java
  31. 90 0
      src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentToolRegistry.java
  32. 45 0
      src/main/java/com/zsElectric/boot/platform/agent/service/AgentApprovalStore.java
  33. 17 0
      src/main/java/com/zsElectric/boot/platform/agent/service/AgentAuditWriter.java
  34. 24 0
      src/main/java/com/zsElectric/boot/platform/agent/service/AgentBusinessToolExecutor.java
  35. 491 0
      src/main/java/com/zsElectric/boot/platform/agent/service/AgentToolOrchestrator.java
  36. 285 0
      src/main/java/com/zsElectric/boot/platform/agent/service/DefaultAgentBusinessToolExecutor.java
  37. 83 0
      src/main/java/com/zsElectric/boot/platform/agent/service/MyBatisAgentApprovalStore.java
  38. 30 0
      src/main/java/com/zsElectric/boot/platform/agent/service/MyBatisAgentAuditWriter.java
  39. 23 0
      src/main/java/com/zsElectric/boot/platform/agent/tool/AgentRiskLevel.java
  40. 89 0
      src/main/java/com/zsElectric/boot/platform/agent/tool/AgentToolCatalog.java
  41. 25 0
      src/main/java/com/zsElectric/boot/platform/agent/tool/AgentToolDefinition.java
  42. 6 0
      src/main/resources/application-dev.yml
  43. 4 0
      src/main/resources/application-prod.yml
  44. 10 4
      src/main/resources/application-test.yml
  45. 1 1
      src/main/resources/application.yml
  46. 6 1
      src/main/resources/mapper/business/ChargeOrderInfoMapper.xml
  47. 205 0
      src/test/java/com/zsElectric/boot/platform/agent/service/AgentToolOrchestratorTest.java

+ 0 - 3
.vscode/settings.json

@@ -1,3 +0,0 @@
-{
-  "java.compile.nullAnalysis.mode": "automatic"
-}

+ 930 - 0
doc/s.md

@@ -0,0 +1,930 @@
+# 企业售后 Agent 实战项目设计
+
+## 目标
+
+构建一个面向充电业务售后场景的企业 Agent 项目。用户描述问题后,Agent 能判断售后意图,检索售后政策,查询充电订单和充电记录,生成处理建议。低风险动作自动创建售后工单,高风险退款或补偿进入审批,审批后按冻结动作恢复执行,并记录全链路审计。
+
+首版目标不是做普通聊天机器人,而是做一个受控的企业执行者:能查、能建议、能调用工具,但高风险动作必须可审批、可恢复、可追责。
+
+## 范围
+
+首版采用双服务垂直切片:
+
+- Java `zsElectric-boot`:企业业务系统和 MCP Server,负责业务工具、审批、冻结动作、审计、售后政策和数据库。
+- Python `zsElectric-agent-service`:独立兄弟服务,使用 FastAPI 作为服务框架,使用 LangGraph 作为 Agent 状态图编排层,作为 MCP Client 调用 Java MCP Server。
+- PostgreSQL + pgvector:作为售后政策 RAG 知识库。
+- Swagger 或 Postman:用于审批演示,不开发审批管理页面。
+
+不在首版范围内:
+
+- 不把所有 Java REST Controller 自动暴露为工具。
+- Python 不直连 Java 业务数据库。
+- 不做完整前端审批后台。
+- 不接入 MCP 之外的泛化工具网关。
+
+## 架构
+
+```text
+用户
+  -> Python zsElectric-agent-service (FastAPI)
+       -> LangGraph StateGraph
+       -> LLM Adapter
+       -> RAG Retriever (PostgreSQL + pgvector)
+       -> MCP Client
+       -> Graph Checkpointer
+  -> Java zsElectric-boot MCP Server
+       -> tool registry / whitelist / risk policy
+       -> approval / pending action / audit log
+       -> existing service / mapper / DB
+```
+
+Python 只负责 Agent 编排:LangGraph 状态流转、意图判断、对话状态、工具选择、RAG 上下文组装、最终建议生成。
+
+Java 负责企业能力暴露:通过 MCP tools 暴露受控业务动作,内部仍然走现有 service 层,保留鉴权、参数校验、风险分级、审批、审计和幂等保护。
+
+HTTP 可以作为 MCP 的传输方式存在,但对 Python 来说,调用边界是 MCP tool,不是普通业务 REST API。
+
+## Java 模块
+
+在 `zsElectric-boot` 中新增 `platform.agent` 模块:
+
+```text
+platform.agent
+├─ mcp              # MCP endpoint 和 tool 暴露层
+├─ tool             # 工具注册、白名单、风险等级、schema
+├─ approval         # 审批和 pending_action
+├─ audit            # Agent 全链路审计
+├─ policy           # 售后政策文档和 RAG 元数据
+└─ aftersales       # 售后工单、退款、补偿业务适配
+```
+
+Java MCP Server 暴露的工具必须来自白名单,并带明确参数 schema、风险等级、trace_id、operator/user 上下文。模型不能直接访问 Mapper、数据库或内部通用 Controller。
+
+## Python 模块
+
+在兄弟目录创建 `E:\wzq\workCode\zswl\zsElectric-agent-service`:
+
+```text
+app
+├─ main.py          # FastAPI app
+├─ graph            # LangGraph StateGraph、state、nodes、edges
+├─ checkpoint       # LangGraph checkpoint 持久化适配
+├─ llm              # OpenAI兼容LLM客户端、Prompt、输出解析
+├─ mcp_client       # 连接 Java MCP Server
+├─ rag              # pgvector 检索和上下文组装
+├─ schemas          # 请求、响应、工具结果结构
+├─ api              # chat、resume、trace、health 路由
+└─ evals            # 测试问题集和期望结果
+```
+
+FastAPI 接口:
+
+```text
+POST /api/v1/agent/chat
+POST /api/v1/agent/resume/{approval_id}
+GET  /api/v1/agent/runs/{trace_id}
+GET  /health
+```
+
+## LangGraph 编排
+
+首版不再使用 OpenAI Agents SDK。Python `zsElectric-agent-service` 使用 LangGraph 显式建模售后处理状态图,LLM 只作为图中节点的模型能力,MCP 工具调用、审批暂停、恢复执行和错误降级都由图节点和边控制。
+
+状态图节点:
+
+```text
+receive_input
+  -> detect_intent
+  -> retrieve_policy
+  -> query_order
+  -> plan_action
+  -> route_by_risk
+      -> create_work_order
+      -> request_approval
+  -> final_answer
+
+resume_after_approval
+  -> load_run_state
+  -> call_java_resume_tool
+  -> final_answer
+```
+
+核心状态 `AfterSalesAgentState`:
+
+```text
+trace_id
+thread_id
+user_input
+messages
+intent
+issue_type
+risk_level
+policy_evidence
+order_context
+tool_plan
+tool_results
+approval_id
+pending_action_id
+resume_result
+final_answer
+error
+```
+
+`thread_id` 默认等于 `trace_id`。LangGraph checkpointer 负责保存 Python 图状态,用于 `/resume/{approval_id}` 找回审批前的对话、证据和工具结果;Java `agent_approval` 与 `agent_pending_action` 仍是审批和冻结动作的事实来源。审批通过后,Python 从 checkpointer 读取状态,再通过 MCP 调 Java `resume_after_approval(approval_id)`,不能重新让模型生成退款或补偿参数。
+
+生产环境使用 PostgreSQL checkpointer,测试环境可使用内存 checkpointer。所有高风险中断点必须在进入 `request_approval` 后保存 checkpoint,并把 `approval_id`、`pending_action_id`、`trace_id` 写入状态。
+
+## LLM 与 LangGraph 接入方案
+
+主链路采用 LangGraph 编排:Python `zsElectric-agent-service` 接入真实 LLM,Java `zsElectric-boot` 继续只做 MCP 工具控制面。Python 的 LangGraph 节点负责模型调用、意图识别、工具计划、MCP 调用路由、审批暂停和最终回复;Java 负责工具白名单、风险分级、审批、冻结动作、幂等、审计和真实业务落库。
+
+该方案的核心边界是:LLM 可以“建议调用哪个工具、用什么参数”,但不能直接访问数据库,也不能绕过 Java 的 MCP 工具治理。退款、补偿等高风险动作即使由 LLM 识别出来,也只能通过 Java `request_refund`、`request_compensation` 创建审批和冻结动作,审批通过后再由 `resume_after_approval` 恢复执行。
+
+### 配置归属
+
+LLM 与 LangGraph 配置归属 Python `zsElectric-agent-service`,不复用 Java 现有 `platform.ai` 的 `ai:` 配置作为售后 Agent 主链路配置。原因是售后 Agent 的模型、Prompt、状态图节点、工具计划、RAG 参数、最大步数、checkpoint 和降级策略都属于 Agent 编排层;Java `platform.ai` 可以继续服务后台 AI 命令系统,二者避免共享配置造成运行边界不清。
+
+Python 首版使用 OpenAI 兼容协议,支持 OpenAI、DashScope/Qwen、DeepSeek 等兼容 `/chat/completions` 的模型服务。模型名称不写死在代码里,由环境变量或配置文件决定。
+
+Python `.env` 示例:
+
+```dotenv
+AGENT_LLM_ENABLED=true
+AGENT_LLM_PROVIDER=openai
+AGENT_LLM_API_KEY=
+AGENT_LLM_BASE_URL=https://api.openai.com/v1
+AGENT_LLM_MODEL=
+AGENT_LLM_FAST_MODEL=
+AGENT_LLM_TEMPERATURE=0.2
+AGENT_LLM_TIMEOUT_SECONDS=60
+AGENT_LLM_TOOL_TIMEOUT_SECONDS=15
+
+LANGGRAPH_CHECKPOINT_ENABLED=true
+LANGGRAPH_CHECKPOINT_DSN=postgresql+psycopg://agent:agent@127.0.0.1:5432/zselectric_agent
+LANGGRAPH_THREAD_ID_STRATEGY=trace_id
+LANGGRAPH_MAX_STEPS=8
+
+AGENT_PROMPT_VERSION=aftersales-agent-v1
+AGENT_TOOL_PLAN_MODE=structured_json
+AGENT_RULE_FALLBACK_ENABLED=true
+
+EMBEDDING_PROVIDER=openai
+EMBEDDING_API_KEY=
+EMBEDDING_BASE_URL=https://api.openai.com/v1
+EMBEDDING_MODEL=
+EMBEDDING_DIMENSIONS=1536
+
+RAG_TOP_K=5
+RAG_MIN_SCORE=0.75
+RAG_MAX_CONTEXT_TOKENS=3000
+
+ZSELECTRIC_BOOT_URL=http://127.0.0.1:8080
+JAVA_MCP_BASE_PATH=/api/v1/agent/mcp
+```
+
+生产环境要求:
+
+- `AGENT_LLM_API_KEY`、`EMBEDDING_API_KEY` 只允许来自环境变量或密钥管理系统,不写入仓库。
+- `AGENT_LLM_MODEL` 必须显式配置;服务启动时不依赖 SDK 默认模型。
+- `AGENT_LLM_FAST_MODEL` 可选;如果为空,快速分类步骤复用 `AGENT_LLM_MODEL`。
+- `LANGGRAPH_CHECKPOINT_ENABLED=true` 时必须配置持久化 checkpointer;生产环境不使用内存 checkpoint。
+- `LANGGRAPH_MAX_STEPS` 必须限制在 3 到 12 之间,防止图循环失控。
+- `EMBEDDING_DIMENSIONS` 必须与 `aftersales_policy_chunk.embedding VECTOR(...)` 一致,不一致时服务启动失败。
+- 启动日志只打印脱敏配置摘要,例如 provider、base_url host、model、prompt_version、RAG 参数,不打印 API Key。
+
+### Python 新增组件
+
+`zsElectric-agent-service` 增加以下组件:
+
+```text
+app
+├─ core
+│  └─ settings.py              # 读取 LLM、LangGraph、RAG、Java MCP 配置
+├─ llm
+│  ├─ client.py                # OpenAI 兼容 chat/completions 客户端
+│  ├─ prompts.py               # Prompt 模板和版本号
+│  └─ planner.py               # plan_action 节点的结构化输出解析
+├─ graph
+│  ├─ state.py                 # AfterSalesAgentState
+│  ├─ nodes.py                 # detect_intent、retrieve_policy、query_order 等节点
+│  ├─ edges.py                 # 风险路由和错误路由
+│  └─ aftersales_graph.py      # StateGraph 编译入口
+├─ checkpoint
+│  └─ store.py                 # PostgreSQL checkpointer 配置
+└─ evals
+   └─ aftersales_cases.jsonl   # 模型回归用例
+```
+
+`settings.py` 负责配置校验,启动时检查:
+
+- LLM 启用时 `AGENT_LLM_API_KEY`、`AGENT_LLM_BASE_URL`、`AGENT_LLM_MODEL` 必填。
+- `LANGGRAPH_MAX_STEPS` 必须大于 0 且不超过 12,防止状态图无限循环。
+- 生产环境必须启用持久化 checkpointer;只有单元测试允许内存 checkpointer。
+- `ZSELECTRIC_BOOT_URL` 必填且不能指向外部未知域名。
+- RAG 参数必须在安全范围内,例如 `RAG_TOP_K` 在 1 到 10 之间。
+
+`client.py` 只处理模型 HTTP 调用,不包含业务规则。它向 `{AGENT_LLM_BASE_URL}/chat/completions` 发送请求,统一返回:
+
+```json
+{
+  "content": "...",
+  "model": "actual-model-name",
+  "provider": "openai",
+  "usage": {
+    "prompt_tokens": 123,
+    "completion_tokens": 45,
+    "total_tokens": 168
+  }
+}
+```
+
+`planner.py` 负责把 `plan_action` 节点的模型输出解析成固定 JSON。如果模型返回非法 JSON、工具名不在 Python 本地白名单、参数缺少必填字段,LangGraph 进入规则降级或人工处理节点,不把非法工具请求传给 Java。
+
+### 工具计划结构
+
+LangGraph 的 `plan_action` 节点只生成工具计划,不直接生成最终客服回复。工具计划必须是结构化 JSON:
+
+```json
+{
+  "intent": "refund_request",
+  "risk_level": "HIGH",
+  "need_order_lookup": true,
+  "tool_calls": [
+    {
+      "tool_name": "retrieve_aftersales_policy",
+      "params": {
+        "issue_type": "refund_request",
+        "keywords": "用户要求退款"
+      }
+    },
+    {
+      "tool_name": "query_charge_order",
+      "params": {
+        "order_no": "CD001"
+      }
+    },
+    {
+      "tool_name": "request_refund",
+      "params": {
+        "order_no": "CD001",
+        "reason": "用户反馈充电失败要求退款"
+      }
+    }
+  ],
+  "customer_message_draft": "该问题涉及退款,需要先进入审批。"
+}
+```
+
+Python 本地白名单必须与 Java `AgentToolCatalog` 保持一致,首版允许的工具名为:
+
+```text
+query_charge_order
+query_charge_session
+retrieve_aftersales_policy
+create_aftersales_work_order
+request_refund
+request_compensation
+get_approval_status
+resume_after_approval
+write_agent_audit
+```
+
+LangGraph 只执行白名单中的工具,并且执行顺序受状态图控制:
+
+1. 总是先执行 `retrieve_aftersales_policy`。
+2. 请求中存在 `order_no`、`user_id` 或 `phone` 时,才允许执行订单查询。
+3. 高风险工具最多执行一个,且必须位于政策检索和必要订单查询之后。
+4. 模型不得直接调用 `resume_after_approval`;该工具只允许 `/api/v1/agent/resume/{approval_id}` 路由进入 `resume_after_approval` 子图后触发。
+
+### 主调用流程
+
+LangGraph 接入后的低风险流程:
+
+```text
+1. FastAPI /chat 接收 ChatRequest,生成 trace_id。
+2. FastAPI 调用 aftersales_graph,thread_id=trace_id。
+3. detect_intent 节点识别意图和问题类型。
+4. retrieve_policy 节点检索售后政策。
+5. query_order 节点按需调用 Java MCP 查询订单或充电记录。
+6. plan_action 节点调用 LLM 生成结构化工具计划。
+7. route_by_risk 节点校验 JSON schema、工具白名单、顺序、参数和最大工具次数。
+8. create_work_order 节点调用 Java MCP 创建售后工单。
+9. final_answer 节点把工具结果、政策证据、工单号交给 LLM 生成最终客服回复。
+10. Python 返回 AgentResponse,并把模型、checkpoint 和工具调用摘要写审计。
+```
+
+LangGraph 接入后的高风险流程:
+
+```text
+1. FastAPI /chat 接收退款或补偿诉求,生成 trace_id。
+2. LangGraph 执行 detect_intent、retrieve_policy、query_order、plan_action。
+3. route_by_risk 节点发现 request_refund 或 request_compensation。
+4. request_approval 节点调用 Java MCP。
+5. Java AgentToolOrchestrator 判断 HIGH 风险,创建 agent_approval 和 agent_pending_action。
+6. Java 冻结 tool_name、tool_version、params_json、params_hash、idempotency_key。
+7. Java 返回 pending_approval、approval_id、pending_action_id。
+8. LangGraph 将 approval_id 和 pending_action_id 写入 checkpoint。
+9. final_answer 节点不让 LLM 编造执行结果,只生成“已进入审批”的客服回复。
+10. 审批人通过 Java 审批接口 approve 或 reject。
+11. 前端或运营后台调用 Python /resume/{approval_id}。
+12. Python 从 checkpoint 找回 trace_id/thread_id 和审批前状态。
+13. resume_after_approval 子图只透传 approval_id 调 Java resume_after_approval。
+14. Java 校验审批状态、冻结参数 hash、幂等状态后执行冻结动作。
+15. final_answer 节点根据 Java 结果生成审批后的最终回复。
+```
+
+### Prompt 约束
+
+系统 Prompt 首版固定为 `aftersales-agent-v1`,核心规则:
+
+```text
+你是充电业务售后 Agent。
+你只能根据工具返回结果回答,不得编造订单、退款、补偿或审批状态。
+你必须先检索售后政策,再决定是否需要查询订单或创建工单。
+退款、补偿、账户权益变更属于高风险动作,只能调用 request_refund 或 request_compensation 发起审批。
+当 Java 工具返回 pending_approval 时,你必须明确告知需要人工审批,不能声称退款或补偿已经完成。
+当工具失败或证据不足时,你必须建议人工核查。
+你必须输出符合 JSON schema 的工具计划,不得输出额外解释。
+```
+
+最终回复 Prompt 与工具计划 Prompt 分开。最终回复只接收已执行的工具结果,不允许再产生新工具调用,避免模型在回复阶段绕过工具治理。
+
+### 降级策略
+
+LangGraph 节点或 LLM 调用失败时:
+
+- 如果 `AGENT_RULE_FALLBACK_ENABLED=true`,回退到当前规则编排:关键词判断退款/补偿,低风险建工单,高风险进审批。
+- 如果规则也无法判断,返回“当前智能判断不可用,请人工核查”,并写失败审计。
+- 任何节点失败都必须写入 `state.error` 和 `agent_audit_log`,不能跳过审计直接结束。
+
+LLM 输出非法工具计划时:
+
+- 记录 `tool_plan_parse_failed` 审计。
+- 不调用 Java MCP。
+- 尝试一次低温重试;重试仍失败则进入规则降级。
+
+Java MCP 工具失败时:
+
+- Python 不让 LLM 猜测结果。
+- 将错误、tool_name、trace_id 写审计。
+- 最终回复固定为需要人工核查。
+
+RAG 无命中时:
+
+- 允许查询订单和创建普通工单。
+- 不允许发起退款或补偿审批,除非用户明确表达退款/补偿诉求且订单证据完整。
+
+### 审计与可观测性
+
+每次 Agent run 必须写入以下审计信息:
+
+```text
+trace_id
+thread_id
+checkpoint_id
+graph_version
+graph_node
+prompt_version
+llm_provider
+llm_model
+fast_model
+embedding_model
+rag_top_k
+rag_min_score
+tool_plan_json
+tool_call_count
+token_usage_json
+fallback_used
+final_status
+```
+
+审计写入分两层:
+
+- Python 侧记录 LLM 输入输出摘要、工具计划、token usage、fallback 状态。
+- LangGraph checkpointer 保存图状态,但不替代审计日志。
+- Java 侧继续记录工具调用、审批创建、审批决策、冻结动作恢复和业务执行结果。
+
+两侧用同一个 `trace_id` 串联。高风险动作以 Java 审计为准,Python 审计不能替代 Java 审批和冻结动作记录。
+
+### 安全约束
+
+- LLM 不接触数据库连接串,不直连 Java 业务库。
+- LLM 不接触完整手机号、身份证、支付密钥等敏感字段;进入 prompt 前必须脱敏。
+- Python 本地工具白名单和 Java 工具白名单双重校验。
+- 高风险工具必须由 Java 判断风险并创建审批,不能只靠 Prompt 文案限制。
+- `resume_after_approval` 不允许从 LLM 工具计划触发,只能由 `/resume/{approval_id}` 入口触发。
+- 模型输出的金额、原因、证据只能作为申请参数,真实执行仍以 Java 冻结参数和审批状态为准。
+- 每次模型或 Prompt 版本变更,必须跑 eval 数据集后再进入生产。
+
+### 测试策略
+
+Python 新增测试:
+
+- LLM 配置缺失时启动失败。
+- LLM 工具计划 JSON 解析成功。
+- 非白名单工具被拒绝,且不调用 Java MCP。
+- 模型非法 JSON 时重试一次,然后规则降级。
+- 低风险意图生成 `create_aftersales_work_order`。
+- 退款意图生成 `request_refund`,Java 返回 `pending_approval` 后不生成“已退款”回复。
+- `/resume/{approval_id}` 只能调用 `resume_after_approval`,不走 LLM 工具计划。
+- 高风险审批返回后写入 LangGraph checkpoint。
+- `/resume/{approval_id}` 能从 checkpoint 找回审批前状态。
+- checkpoint 丢失时返回可解释错误,并提示人工核查。
+- token usage、model、prompt_version 写入审计。
+
+Java 现有测试保持:
+
+- 高风险工具创建审批和冻结动作。
+- 审批通过后按冻结参数恢复执行。
+- `params_hash` 篡改保护。
+- 重复恢复返回历史结果,避免重复执行。
+
+集成 eval 用例至少覆盖:
+
+```text
+充电失败咨询 -> 检索政策 + 查询订单 + 创建工单
+充电失败要求退款 -> 检索政策 + 查询订单 + 创建审批
+用户要求补偿余额 -> 检索政策 + 查询订单 + 创建审批
+只有手机号没有订单号 -> 查询最近订单后建单或进入人工核查
+政策无命中 -> 不自动退款/补偿
+Java MCP 超时 -> 返回人工核查
+审批通过后 resume -> 执行冻结动作
+审批拒绝后 resume -> 返回 rejected
+```
+
+## MCP 工具
+
+首版工具清单:
+
+```text
+query_charge_order(order_no | user_id | phone)
+query_charge_session(order_no)
+retrieve_aftersales_policy(issue_type, keywords)
+create_aftersales_work_order(order_no, issue_type, suggestion, evidence)
+request_refund(order_no, amount, reason, evidence)
+request_compensation(order_no, compensation_type, amount, reason)
+get_approval_status(approval_id)
+resume_after_approval(approval_id)
+write_agent_audit(trace_id, step, input, output, status)
+```
+
+风险等级:
+
+```text
+LOW:
+  query_charge_order
+  query_charge_session
+  retrieve_aftersales_policy
+  create_aftersales_work_order
+  write_agent_audit
+
+HIGH:
+  request_refund
+  request_compensation
+  resume_after_approval
+```
+
+低风险工具可以直接执行。高风险工具不直接退款或补偿,而是由 Java 创建审批单和冻结动作,返回 `approval_required`。
+
+## 数据表
+
+表按职责拆成两组:
+
+- Java 业务库:`agent_tool_registry`、`agent_approval`、`agent_pending_action`、`agent_audit_log`、`aftersales_work_order`。首版按 MySQL 设计,跟 `zsElectric-boot` 当前业务库一致。
+- RAG 知识库:`aftersales_policy_doc`、`aftersales_policy_chunk`。首版按 PostgreSQL + pgvector 设计。Python FastAPI Agent 可以直接检索该知识库;Java MCP Server 也可以通过 `retrieve_aftersales_policy` 工具封装同一套检索能力。
+
+核心关系:
+
+```text
+agent_approval 1 -> 1 agent_pending_action
+agent_approval 1 -> n agent_audit_log
+agent_pending_action 1 -> n agent_audit_log
+aftersales_policy_doc 1 -> n aftersales_policy_chunk
+trace_id 贯穿 agent_approval、agent_pending_action、agent_audit_log、aftersales_work_order
+```
+
+### agent_tool_registry
+
+记录 Java MCP Server 可暴露工具的治理信息。工具必须先注册到该表,再进入白名单判断。
+
+```sql
+CREATE TABLE agent_tool_registry (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+  tool_name VARCHAR(128) NOT NULL COMMENT 'MCP工具名',
+  tool_version VARCHAR(32) NOT NULL DEFAULT 'v1' COMMENT '工具版本',
+  description VARCHAR(512) NOT NULL COMMENT '工具说明',
+  transport VARCHAR(32) NOT NULL DEFAULT 'mcp' COMMENT '工具传输协议',
+  handler_bean VARCHAR(192) NOT NULL COMMENT 'Java处理器Bean或路由标识',
+  risk_level VARCHAR(16) NOT NULL COMMENT '风险等级: LOW, MEDIUM, HIGH',
+  enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
+  require_approval TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要审批',
+  idempotent TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否支持幂等',
+  input_schema_json JSON NOT NULL COMMENT '输入参数JSON Schema',
+  output_schema_json JSON NULL COMMENT '输出结果JSON Schema',
+  allowed_roles JSON NULL COMMENT '允许调用的角色列表',
+  sensitive_fields JSON NULL COMMENT '需要脱敏的字段列表',
+  rate_limit_per_minute INT NOT NULL DEFAULT 60 COMMENT '单工具分钟限流',
+  timeout_ms INT NOT NULL DEFAULT 10000 COMMENT '工具超时时间',
+  remark VARCHAR(512) NULL COMMENT '备注',
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
+  UNIQUE KEY uk_agent_tool_name_version (tool_name, tool_version),
+  KEY idx_agent_tool_enabled_risk (enabled, risk_level),
+  KEY idx_agent_tool_handler (handler_bean)
+) COMMENT='Agent MCP工具注册表';
+```
+
+首版工具注册示例:
+
+```text
+query_charge_order: LOW, require_approval=false
+query_charge_session: LOW, require_approval=false
+retrieve_aftersales_policy: LOW, require_approval=false
+create_aftersales_work_order: LOW, require_approval=false
+request_refund: HIGH, require_approval=true
+request_compensation: HIGH, require_approval=true
+resume_after_approval: HIGH, require_approval=false
+write_agent_audit: LOW, require_approval=false
+```
+
+### agent_approval
+
+记录高风险动作审批单。审批单只表达“是否允许执行”,不直接保存完整执行参数;完整执行参数保存在 `agent_pending_action`。
+
+```sql
+CREATE TABLE agent_approval (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+  approval_id VARCHAR(64) NOT NULL COMMENT '审批ID,外部展示和恢复执行使用',
+  trace_id VARCHAR(64) NOT NULL COMMENT 'Agent运行链路ID',
+  pending_action_id BIGINT NULL COMMENT '关联冻结动作ID',
+  biz_type VARCHAR(64) NOT NULL COMMENT '业务类型: refund, compensation',
+  biz_no VARCHAR(128) NOT NULL COMMENT '业务编号,通常为订单号',
+  risk_level VARCHAR(16) NOT NULL COMMENT '风险等级: HIGH',
+  status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '状态: pending, approved, rejected, expired, executed',
+  request_user_id BIGINT NULL COMMENT '发起用户ID',
+  request_username VARCHAR(128) NULL COMMENT '发起用户名',
+  request_reason VARCHAR(1024) NOT NULL COMMENT '申请原因',
+  risk_reason VARCHAR(1024) NULL COMMENT '命中高风险的原因',
+  policy_evidence_json JSON NULL COMMENT '政策依据和RAG命中证据',
+  requested_amount DECIMAL(10,2) NULL COMMENT '申请退款或补偿金额',
+  approver_id BIGINT NULL COMMENT '审批人ID',
+  approver_username VARCHAR(128) NULL COMMENT '审批人名称',
+  decision_comment VARCHAR(1024) NULL COMMENT '审批意见',
+  decision_time DATETIME NULL COMMENT '审批时间',
+  expires_at DATETIME NOT NULL COMMENT '审批过期时间',
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
+  deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
+  UNIQUE KEY uk_agent_approval_id (approval_id),
+  KEY idx_agent_approval_trace (trace_id),
+  KEY idx_agent_approval_status_time (status, create_time),
+  KEY idx_agent_approval_request_user (request_user_id, status),
+  KEY idx_agent_approval_biz (biz_type, biz_no)
+) COMMENT='Agent高风险动作审批表';
+```
+
+状态流转:
+
+```text
+pending -> approved -> executed
+pending -> rejected
+pending -> expired
+```
+
+`approved` 只表示审批通过。真实退款或补偿必须由 `resume_after_approval(approval_id)` 根据冻结动作执行,执行成功后再把审批单标记为 `executed`。
+
+### agent_pending_action
+
+记录审批通过后要恢复执行的冻结动作。审批前后的执行参数必须保持一致,不能让模型在审批后重新生成参数。
+
+```sql
+CREATE TABLE agent_pending_action (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+  action_id VARCHAR(64) NOT NULL COMMENT '冻结动作ID',
+  approval_id VARCHAR(64) NOT NULL COMMENT '关联审批ID',
+  trace_id VARCHAR(64) NOT NULL COMMENT 'Agent运行链路ID',
+  tool_name VARCHAR(128) NOT NULL COMMENT '待执行MCP工具名',
+  tool_version VARCHAR(32) NOT NULL DEFAULT 'v1' COMMENT '工具版本',
+  risk_level VARCHAR(16) NOT NULL COMMENT '风险等级',
+  params_json JSON NOT NULL COMMENT '冻结后的工具参数',
+  params_hash CHAR(64) NOT NULL COMMENT '参数SHA-256摘要,防篡改',
+  idempotency_key VARCHAR(128) NOT NULL COMMENT '幂等键',
+  execute_status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '执行状态: pending, executing, executed, failed, expired, rejected',
+  execute_result_json JSON NULL COMMENT '执行结果',
+  error_message TEXT NULL COMMENT '失败原因',
+  retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
+  executed_at DATETIME NULL COMMENT '执行完成时间',
+  expires_at DATETIME NOT NULL COMMENT '冻结动作过期时间',
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
+  deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
+  UNIQUE KEY uk_agent_action_id (action_id),
+  UNIQUE KEY uk_agent_action_idempotency (idempotency_key),
+  KEY idx_agent_action_approval (approval_id),
+  KEY idx_agent_action_trace (trace_id),
+  KEY idx_agent_action_status_time (execute_status, create_time),
+  KEY idx_agent_action_tool (tool_name, tool_version)
+) COMMENT='Agent审批后待恢复执行动作表';
+```
+
+恢复执行校验顺序:
+
+```text
+approval_id 存在
+-> approval.status = approved
+-> pending_action.execute_status = pending
+-> 当前时间未超过 expires_at
+-> tool_name 仍在 agent_tool_registry 白名单且启用
+-> params_hash 与 params_json 重算结果一致
+-> idempotency_key 未执行过
+-> 执行业务工具
+-> 更新 execute_status、execute_result_json、agent_approval.status
+-> 写 agent_audit_log
+```
+
+### agent_audit_log
+
+记录 Agent 全链路审计。审计表只追加,不更新业务含义,用于追踪、排障和责任回溯。
+
+```sql
+CREATE TABLE agent_audit_log (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+  trace_id VARCHAR(64) NOT NULL COMMENT 'Agent运行链路ID',
+  thread_id VARCHAR(64) NULL COMMENT 'LangGraph线程ID,默认等于trace_id',
+  checkpoint_id VARCHAR(128) NULL COMMENT 'LangGraph checkpoint ID',
+  span_id VARCHAR(64) NULL COMMENT '步骤Span ID',
+  parent_span_id VARCHAR(64) NULL COMMENT '父级Span ID',
+  approval_id VARCHAR(64) NULL COMMENT '关联审批ID',
+  pending_action_id VARCHAR(64) NULL COMMENT '关联冻结动作ID',
+  actor_type VARCHAR(32) NOT NULL COMMENT '操作者类型: user, agent, system, approver',
+  actor_id VARCHAR(64) NULL COMMENT '操作者ID',
+  actor_name VARCHAR(128) NULL COMMENT '操作者名称',
+  step VARCHAR(64) NOT NULL COMMENT '步骤: user_input, intent_detected, rag_retrieved, tool_called, approval_created, approval_decided, action_resumed, final_answer',
+  event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
+  graph_version VARCHAR(64) NULL COMMENT 'LangGraph图版本',
+  graph_node VARCHAR(128) NULL COMMENT '当前图节点',
+  tool_name VARCHAR(128) NULL COMMENT '工具名',
+  risk_level VARCHAR(16) NULL COMMENT '风险等级',
+  model_provider VARCHAR(64) NULL COMMENT '模型供应商,如openai',
+  model_name VARCHAR(128) NULL COMMENT '实际调用模型名',
+  prompt_version VARCHAR(64) NULL COMMENT 'Prompt版本',
+  token_usage_json JSON NULL COMMENT 'Token用量',
+  input_json JSON NULL COMMENT '输入快照,敏感字段脱敏后保存',
+  output_json JSON NULL COMMENT '输出快照,敏感字段脱敏后保存',
+  status VARCHAR(32) NOT NULL COMMENT '状态: success, failed, pending, skipped',
+  error_message TEXT NULL COMMENT '错误信息',
+  duration_ms BIGINT NULL COMMENT '步骤耗时毫秒',
+  ip_address VARCHAR(64) NULL COMMENT '客户端IP',
+  user_agent VARCHAR(512) NULL COMMENT '客户端User-Agent',
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  KEY idx_agent_audit_trace_time (trace_id, create_time),
+  KEY idx_agent_audit_thread_time (thread_id, create_time),
+  KEY idx_agent_audit_checkpoint (checkpoint_id),
+  KEY idx_agent_audit_approval (approval_id),
+  KEY idx_agent_audit_action (pending_action_id),
+  KEY idx_agent_audit_graph_node (graph_node, create_time),
+  KEY idx_agent_audit_tool_time (tool_name, create_time),
+  KEY idx_agent_audit_model_time (model_name, create_time),
+  KEY idx_agent_audit_status_time (status, create_time)
+) COMMENT='Agent全链路审计日志表';
+```
+
+审计步骤包括用户输入、LLM 配置快照、意图判断、RAG 命中文档、工具调用、审批创建、审批结果、恢复执行和最终回复。
+
+### aftersales_work_order
+
+记录低风险售后自动建单结果,也可以承接高风险审批后的售后处理记录。
+
+```sql
+CREATE TABLE aftersales_work_order (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+  work_order_no VARCHAR(64) NOT NULL COMMENT '售后工单号',
+  trace_id VARCHAR(64) NOT NULL COMMENT 'Agent运行链路ID',
+  order_no VARCHAR(128) NOT NULL COMMENT '充电订单号',
+  charge_record_id BIGINT NULL COMMENT '充电记录ID',
+  user_id BIGINT NULL COMMENT '用户ID',
+  phone_masked VARCHAR(64) NULL COMMENT '脱敏手机号',
+  issue_type VARCHAR(64) NOT NULL COMMENT '问题类型: charge_failed, payment_issue, refund_request, compensation_request',
+  issue_level VARCHAR(16) NOT NULL DEFAULT 'normal' COMMENT '问题等级: normal, urgent',
+  source VARCHAR(32) NOT NULL DEFAULT 'agent' COMMENT '来源: agent, manual, third_party',
+  suggestion TEXT NOT NULL COMMENT 'Agent处理建议',
+  policy_evidence_json JSON NULL COMMENT '政策依据和RAG命中证据',
+  order_snapshot_json JSON NULL COMMENT '订单和充电记录快照',
+  status VARCHAR(32) NOT NULL DEFAULT 'created' COMMENT '状态: created, processing, resolved, closed, canceled',
+  created_by_agent TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否Agent创建',
+  created_by VARCHAR(64) NULL COMMENT '创建人',
+  assigned_to VARCHAR(64) NULL COMMENT '处理人',
+  resolve_result TEXT NULL COMMENT '处理结果',
+  closed_at DATETIME NULL COMMENT '关闭时间',
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
+  UNIQUE KEY uk_aftersales_work_order_no (work_order_no),
+  KEY idx_aftersales_order_no (order_no),
+  KEY idx_aftersales_trace (trace_id),
+  KEY idx_aftersales_status_time (status, create_time),
+  KEY idx_aftersales_issue_type (issue_type, create_time)
+) COMMENT='售后工单表';
+```
+
+### aftersales_policy_doc / aftersales_policy_chunk
+
+`aftersales_policy_doc` 保存政策原文和版本,`aftersales_policy_chunk` 保存分片文本、embedding 和元数据,用于 pgvector 检索。
+
+```sql
+CREATE TABLE aftersales_policy_doc (
+  id BIGSERIAL PRIMARY KEY,
+  doc_code VARCHAR(64) NOT NULL,
+  title VARCHAR(256) NOT NULL,
+  category VARCHAR(64) NOT NULL,
+  issue_type VARCHAR(64) NOT NULL,
+  version VARCHAR(32) NOT NULL,
+  content TEXT NOT NULL,
+  content_hash CHAR(64) NOT NULL,
+  source_type VARCHAR(32) NOT NULL DEFAULT 'manual',
+  source_uri VARCHAR(512),
+  effective_at TIMESTAMPTZ,
+  expired_at TIMESTAMPTZ,
+  enabled BOOLEAN NOT NULL DEFAULT TRUE,
+  metadata JSONB,
+  create_time TIMESTAMPTZ NOT NULL DEFAULT now(),
+  update_time TIMESTAMPTZ NOT NULL DEFAULT now(),
+  UNIQUE (doc_code, version)
+);
+
+CREATE INDEX idx_policy_doc_issue_enabled
+  ON aftersales_policy_doc (issue_type, enabled);
+
+CREATE INDEX idx_policy_doc_category
+  ON aftersales_policy_doc (category);
+```
+
+```sql
+CREATE EXTENSION IF NOT EXISTS vector;
+
+CREATE TABLE aftersales_policy_chunk (
+  id BIGSERIAL PRIMARY KEY,
+  doc_id BIGINT NOT NULL REFERENCES aftersales_policy_doc(id),
+  chunk_no INT NOT NULL,
+  chunk_text TEXT NOT NULL,
+  token_count INT,
+  embedding VECTOR(1536) NOT NULL,
+  content_hash CHAR(64) NOT NULL,
+  issue_type VARCHAR(64) NOT NULL,
+  metadata JSONB,
+  enabled BOOLEAN NOT NULL DEFAULT TRUE,
+  create_time TIMESTAMPTZ NOT NULL DEFAULT now(),
+  update_time TIMESTAMPTZ NOT NULL DEFAULT now(),
+  UNIQUE (doc_id, chunk_no)
+);
+
+CREATE INDEX idx_policy_chunk_doc
+  ON aftersales_policy_chunk (doc_id);
+
+CREATE INDEX idx_policy_chunk_issue_enabled
+  ON aftersales_policy_chunk (issue_type, enabled);
+
+CREATE INDEX idx_policy_chunk_metadata
+  ON aftersales_policy_chunk USING GIN (metadata);
+
+CREATE INDEX idx_policy_chunk_embedding
+  ON aftersales_policy_chunk USING hnsw (embedding vector_cosine_ops);
+```
+
+`VECTOR(1536)` 是首版 embedding 维度约定,后续如果更换 embedding 模型,必须同步迁移分片表维度并重建向量索引。
+
+## 主流程
+
+低风险流程:
+
+```text
+1. FastAPI /chat 接收用户问题,生成 trace_id
+2. Agent 判断意图,例如“充电失败咨询”
+3. Agent 通过 RAG 检索售后政策
+4. Agent 通过 MCP 调 query_charge_order 和 query_charge_session
+5. Agent 生成处理建议
+6. Agent 通过 MCP 调 create_aftersales_work_order
+7. Java 创建工单并写 audit_log
+8. FastAPI 返回建议、工单号、trace_id
+```
+
+高风险流程:
+
+```text
+1. FastAPI /chat 接收用户问题,生成 trace_id
+2. Agent 判断意图,例如“充电失败要求退款”
+3. Agent 检索政策并查询订单、充电记录
+4. Agent 判断需要退款或补偿
+5. Agent 通过 MCP 调 request_refund 或 request_compensation
+6. Java 判断为 HIGH 风险,创建 agent_approval
+7. Java 创建 agent_pending_action,冻结 tool_name 和 params_json
+8. Java 返回 approval_required、approval_id、建议说明
+9. 人在 Swagger/Postman 调 Java 审批接口 approve 或 reject
+10. FastAPI /resume/{approval_id} 调 MCP resume_after_approval
+11. Java 校验审批和 pending_action,执行真实退款或补偿
+12. Java 写 audit_log 并返回工具结果
+13. Agent 生成最终客服回复
+```
+
+## Java 审批接口
+
+审批演示接口放在 Java:
+
+```text
+GET  /api/v1/agent/approvals
+GET  /api/v1/agent/approvals/{approval_id}
+POST /api/v1/agent/approvals/{approval_id}/approve
+POST /api/v1/agent/approvals/{approval_id}/reject
+```
+
+审批通过只改变审批状态,不直接执行退款或补偿。真实执行由 `resume_after_approval(approval_id)` 触发,保证“审批”和“执行恢复”边界清楚。
+
+## 错误处理
+
+```text
+RAG 无命中:
+  返回“政策依据不足”,只允许生成建议,不允许自动退款或补偿。
+
+订单不存在:
+  停止工具链路,写 audit_log,返回需要人工核查。
+
+MCP 工具失败:
+  记录 trace_id、tool_name、input、error,不让 Agent 猜测执行结果。
+
+审批过期:
+  pending_action 标记 expired,需要重新发起申请。
+
+审批拒绝:
+  resume_after_approval 返回 rejected,Agent 生成拒绝后的客服回复。
+
+参数被篡改:
+  params_hash 校验失败,拒绝执行并写高危审计。
+
+重复恢复:
+  pending_action 已执行则返回历史结果,保证幂等。
+```
+
+## 测试
+
+Java 测试:
+
+- 工具白名单测试。
+- 高低风险分级测试。
+- approval 和 pending_action 落库测试。
+- approve/reject 状态流转测试。
+- resume_after_approval 幂等测试。
+- params_hash 篡改保护测试。
+- audit_log 写入测试。
+
+Python FastAPI 测试:
+
+- `/chat` 低风险自动建工单。
+- `/chat` 高风险返回 `approval_required`。
+- `/resume/{approval_id}` 审批后恢复执行。
+- MCP client 工具调用失败。
+- RAG 无命中降级。
+- LLM 配置加载、默认模型、快速模型、embedding 维度一致性测试。
+- 模型变更后的 eval 数据集回归测试。
+
+集成演示:
+
+- 充电失败但只需普通售后处理,自动建工单。
+- 充电失败要求退款,进入审批。
+- 审批通过,resume 后执行退款或补偿。
+- 审批拒绝,Agent 生成拒绝说明。
+- 通过 trace_id 查询完整审计链路。
+
+## 里程碑
+
+### M1 Java MCP Server 与治理表
+
+交付 MCP endpoint、tool registry、approval、pending_action、audit_log,以及基础售后政策表。
+
+### M2 Python FastAPI Agent
+
+交付 FastAPI 服务、LangGraph 状态图、持久化 checkpoint、LLM 配置管理、MCP client、基础 RAG 检索、`/chat` 和 `/resume`。
+
+### M3 售后业务闭环
+
+交付订单查询、充电记录查询、政策检索、工单创建、退款/补偿审批和审批恢复。
+
+### M4 验证与演示
+
+交付种子数据、Swagger/Postman 脚本、eval case、README 跑通文档。
+
+## 验收标准
+
+- Python 服务不直连 Java 业务数据库。
+- Java 只暴露白名单 MCP tools,不把全部业务接口自动暴露给模型。
+- 低风险售后问题可以自动创建工单。
+- 高风险退款或补偿必须产生审批单和冻结动作。
+- 审批通过后可以按 `approval_id` 恢复执行。
+- 审批拒绝、审批过期、重复恢复、参数篡改都有确定结果。
+- 每次 Agent 运行都能通过 `trace_id` 查到完整审计链路。
+- 每次 Agent 运行都能在审计日志中看到实际模型、prompt 版本、RAG 参数和 token 用量。
+- 高风险审批暂停后,LangGraph checkpoint 能保存审批前状态,`/resume/{approval_id}` 能从 checkpoint 恢复上下文。
+- Embedding 模型维度与 pgvector 表结构不一致时,Python 服务启动失败。
+- 项目文档能解释 LangGraph 状态图、RAG、MCP Tool Calling、权限、审批、状态恢复、审计和测试案例。

+ 10 - 10
doc/第三方接入API文档.md

@@ -10,7 +10,7 @@
 - [2. 获取Token](#2-获取token)
 - [3. 充值档位信息分页列表](#3-充值档位信息分页列表)
 - [4. 获取用户信息](#4-获取用户信息)
-- [5. 充点券购买](#5-充点券购买)
+- [5. 充电券购买](#5-充电券购买)
 - [6. 获取充电站列表](#6-获取充电站列表)
 - [7. 获取充电站详情与充电终端列表](#7-获取充电站详情与充电终端列表)
 - [8. 获取充电终端详情](#8-获取充电终端详情)
@@ -659,15 +659,15 @@
 
 ### 9.2 输入参数(data解密后)
 
-| 参数名称             | 参数定义 | 参数类型 | 描述 | 是否必填 |
-|------------------|----------|----------|------|------|
-| equipmentId      | 充电桩编号 | String | 充电桩编号 | 是    |
-| stationId        | 第三方充电站ID | String | 充电站ID | 是    |
-| connectorId      | 充电设备接口编码 | String | 充电设备接口编码 | 是    |
-| channelOrderNo   | 渠道方订单编号 | String | 第三方平台订单编号 | 是    |
-| channelUserPhone | 渠道方用户手机号 | String | 用户手机号 | 是    |
-| channelPreAmt    | 渠道方预支付金额 | BigDecimal | 预支付金额 | 是    |
-| plateNum         | 车牌号 | String | 车牌号 | 否    |
+| 参数名称             | 参数定义 | 参数类型 | 描述           | 是否必填 |
+|------------------|----------|----------|--------------|------|
+| equipmentId      | 充电桩编号 | String | 充电桩编号        | 是    |
+| stationId        | 第三方充电站ID | String | 充电站ID        | 是    |
+| connectorId      | 充电设备接口编码 | String | 充电设备接口编码     | 是    |
+| channelOrderNo   | 渠道方订单编号 | String | 第三方平台订单编号    | 是    |
+| channelUserPhone | 渠道方用户手机号 | String | 用户手机号        | 是    |
+| channelPreAmt    | 渠道方预支付金额 | BigDecimal | 预支付金额        | 是    |
+| plateNum         | 车牌号 | String | 车牌号(用于停车减免)) | 否    |
 
 ### 9.3 请求示例
 

+ 164 - 0
sql/mysql/agent_aftersales_m1.sql

@@ -0,0 +1,164 @@
+CREATE TABLE IF NOT EXISTS agent_tool_registry (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+  tool_name VARCHAR(128) NOT NULL COMMENT 'MCP工具名',
+  tool_version VARCHAR(32) NOT NULL DEFAULT 'v1' COMMENT '工具版本',
+  description VARCHAR(512) NOT NULL COMMENT '工具说明',
+  transport VARCHAR(32) NOT NULL DEFAULT 'mcp' COMMENT '工具传输协议',
+  handler_bean VARCHAR(192) NOT NULL COMMENT 'Java处理器Bean或路由标识',
+  risk_level VARCHAR(16) NOT NULL COMMENT '风险等级: LOW, MEDIUM, HIGH',
+  enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
+  require_approval TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要审批',
+  idempotent TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否支持幂等',
+  input_schema_json JSON NOT NULL COMMENT '输入参数JSON Schema',
+  output_schema_json JSON NULL COMMENT '输出结果JSON Schema',
+  allowed_roles JSON NULL COMMENT '允许调用的角色列表',
+  sensitive_fields JSON NULL COMMENT '需要脱敏的字段列表',
+  rate_limit_per_minute INT NOT NULL DEFAULT 60 COMMENT '单工具分钟限流',
+  timeout_ms INT NOT NULL DEFAULT 10000 COMMENT '工具超时时间',
+  remark VARCHAR(512) NULL COMMENT '备注',
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
+  UNIQUE KEY uk_agent_tool_name_version (tool_name, tool_version),
+  KEY idx_agent_tool_enabled_risk (enabled, risk_level),
+  KEY idx_agent_tool_handler (handler_bean)
+) COMMENT='Agent MCP工具注册表';
+
+CREATE TABLE IF NOT EXISTS agent_approval (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+  approval_id VARCHAR(64) NOT NULL COMMENT '审批ID',
+  trace_id VARCHAR(64) NOT NULL COMMENT 'Agent运行链路ID',
+  pending_action_id BIGINT NULL COMMENT '关联冻结动作ID',
+  biz_type VARCHAR(64) NOT NULL COMMENT '业务类型',
+  biz_no VARCHAR(128) NULL COMMENT '业务编号',
+  risk_level VARCHAR(16) NOT NULL COMMENT '风险等级',
+  status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '状态',
+  request_user_id BIGINT NULL COMMENT '发起用户ID',
+  request_username VARCHAR(128) NULL COMMENT '发起用户名',
+  request_reason VARCHAR(1024) NULL COMMENT '申请原因',
+  risk_reason VARCHAR(1024) NULL COMMENT '命中高风险的原因',
+  policy_evidence_json JSON NULL COMMENT '政策依据和RAG命中证据',
+  requested_amount DECIMAL(10,2) NULL COMMENT '申请金额',
+  approver_id BIGINT NULL COMMENT '审批人ID',
+  approver_username VARCHAR(128) NULL COMMENT '审批人名称',
+  decision_comment VARCHAR(1024) NULL COMMENT '审批意见',
+  decision_time DATETIME NULL COMMENT '审批时间',
+  expires_at DATETIME NOT NULL COMMENT '审批过期时间',
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
+  deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
+  UNIQUE KEY uk_agent_approval_id (approval_id),
+  KEY idx_agent_approval_trace (trace_id),
+  KEY idx_agent_approval_status_time (status, create_time),
+  KEY idx_agent_approval_request_user (request_user_id, status),
+  KEY idx_agent_approval_biz (biz_type, biz_no)
+) COMMENT='Agent高风险动作审批表';
+
+CREATE TABLE IF NOT EXISTS agent_pending_action (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+  action_id VARCHAR(64) NOT NULL COMMENT '冻结动作ID',
+  approval_id VARCHAR(64) NOT NULL COMMENT '关联审批ID',
+  trace_id VARCHAR(64) NOT NULL COMMENT 'Agent运行链路ID',
+  tool_name VARCHAR(128) NOT NULL COMMENT '待执行MCP工具名',
+  tool_version VARCHAR(32) NOT NULL DEFAULT 'v1' COMMENT '工具版本',
+  risk_level VARCHAR(16) NOT NULL COMMENT '风险等级',
+  params_json JSON NOT NULL COMMENT '冻结后的工具参数',
+  params_hash CHAR(64) NOT NULL COMMENT '参数SHA-256摘要',
+  idempotency_key VARCHAR(128) NOT NULL COMMENT '幂等键',
+  execute_status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '执行状态',
+  execute_result_json JSON NULL COMMENT '执行结果',
+  error_message TEXT NULL COMMENT '失败原因',
+  retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
+  executed_at DATETIME NULL COMMENT '执行完成时间',
+  expires_at DATETIME NOT NULL COMMENT '冻结动作过期时间',
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
+  deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
+  UNIQUE KEY uk_agent_action_id (action_id),
+  UNIQUE KEY uk_agent_action_idempotency (idempotency_key),
+  KEY idx_agent_action_approval (approval_id),
+  KEY idx_agent_action_trace (trace_id),
+  KEY idx_agent_action_status_time (execute_status, create_time),
+  KEY idx_agent_action_tool (tool_name, tool_version)
+) COMMENT='Agent审批后待恢复执行动作表';
+
+CREATE TABLE IF NOT EXISTS agent_audit_log (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+  trace_id VARCHAR(64) NOT NULL COMMENT 'Agent运行链路ID',
+  span_id VARCHAR(64) NULL COMMENT '步骤Span ID',
+  parent_span_id VARCHAR(64) NULL COMMENT '父级Span ID',
+  approval_id VARCHAR(64) NULL COMMENT '关联审批ID',
+  pending_action_id VARCHAR(64) NULL COMMENT '关联冻结动作ID',
+  actor_type VARCHAR(32) NOT NULL COMMENT '操作者类型',
+  actor_id VARCHAR(64) NULL COMMENT '操作者ID',
+  actor_name VARCHAR(128) NULL COMMENT '操作者名称',
+  step VARCHAR(64) NOT NULL COMMENT '步骤',
+  event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
+  tool_name VARCHAR(128) NULL COMMENT '工具名',
+  risk_level VARCHAR(16) NULL COMMENT '风险等级',
+  input_json JSON NULL COMMENT '输入快照',
+  output_json JSON NULL COMMENT '输出快照',
+  status VARCHAR(32) NOT NULL COMMENT '状态',
+  error_message TEXT NULL COMMENT '错误信息',
+  duration_ms BIGINT NULL COMMENT '步骤耗时毫秒',
+  ip_address VARCHAR(64) NULL COMMENT '客户端IP',
+  user_agent VARCHAR(512) NULL COMMENT '客户端User-Agent',
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  KEY idx_agent_audit_trace_time (trace_id, create_time),
+  KEY idx_agent_audit_approval (approval_id),
+  KEY idx_agent_audit_action (pending_action_id),
+  KEY idx_agent_audit_tool_time (tool_name, create_time),
+  KEY idx_agent_audit_status_time (status, create_time)
+) COMMENT='Agent全链路审计日志表';
+
+CREATE TABLE IF NOT EXISTS aftersales_work_order (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+  work_order_no VARCHAR(64) NOT NULL COMMENT '售后工单号',
+  trace_id VARCHAR(64) NOT NULL COMMENT 'Agent运行链路ID',
+  order_no VARCHAR(128) NOT NULL COMMENT '充电订单号',
+  charge_record_id BIGINT NULL COMMENT '充电记录ID',
+  user_id BIGINT NULL COMMENT '用户ID',
+  phone_masked VARCHAR(64) NULL COMMENT '脱敏手机号',
+  issue_type VARCHAR(64) NOT NULL COMMENT '问题类型',
+  issue_level VARCHAR(16) NOT NULL DEFAULT 'normal' COMMENT '问题等级',
+  source VARCHAR(32) NOT NULL DEFAULT 'agent' COMMENT '来源',
+  suggestion TEXT NOT NULL COMMENT 'Agent处理建议',
+  policy_evidence_json JSON NULL COMMENT '政策依据和RAG命中证据',
+  order_snapshot_json JSON NULL COMMENT '订单和充电记录快照',
+  status VARCHAR(32) NOT NULL DEFAULT 'created' COMMENT '状态',
+  created_by_agent TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否Agent创建',
+  created_by VARCHAR(64) NULL COMMENT '创建人',
+  assigned_to VARCHAR(64) NULL COMMENT '处理人',
+  resolve_result TEXT NULL COMMENT '处理结果',
+  closed_at DATETIME NULL COMMENT '关闭时间',
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
+  UNIQUE KEY uk_aftersales_work_order_no (work_order_no),
+  KEY idx_aftersales_order_no (order_no),
+  KEY idx_aftersales_trace (trace_id),
+  KEY idx_aftersales_status_time (status, create_time),
+  KEY idx_aftersales_issue_type (issue_type, create_time)
+) COMMENT='售后工单表';
+
+INSERT INTO agent_tool_registry
+  (tool_name, tool_version, description, transport, handler_bean, risk_level, enabled, require_approval, idempotent, input_schema_json, output_schema_json)
+VALUES
+  ('query_charge_order', 'v1', '查询充电订单', 'mcp', 'defaultAgentBusinessToolExecutor', 'LOW', 1, 0, 1, JSON_OBJECT(), JSON_OBJECT()),
+  ('query_charge_session', 'v1', '查询充电记录', 'mcp', 'defaultAgentBusinessToolExecutor', 'LOW', 1, 0, 1, JSON_OBJECT(), JSON_OBJECT()),
+  ('retrieve_aftersales_policy', 'v1', '检索售后政策', 'mcp', 'defaultAgentBusinessToolExecutor', 'LOW', 1, 0, 1, JSON_OBJECT(), JSON_OBJECT()),
+  ('create_aftersales_work_order', 'v1', '创建售后工单', 'mcp', 'defaultAgentBusinessToolExecutor', 'LOW', 1, 0, 1, JSON_OBJECT(), JSON_OBJECT()),
+  ('request_refund', 'v1', '发起退款审批', 'mcp', 'defaultAgentBusinessToolExecutor', 'HIGH', 1, 1, 1, JSON_OBJECT(), JSON_OBJECT()),
+  ('request_compensation', 'v1', '发起补偿审批', 'mcp', 'defaultAgentBusinessToolExecutor', 'HIGH', 1, 1, 1, JSON_OBJECT(), JSON_OBJECT()),
+  ('resume_after_approval', 'v1', '审批后恢复冻结动作', 'mcp', 'agentToolOrchestrator', 'HIGH', 1, 0, 1, JSON_OBJECT(), JSON_OBJECT()),
+  ('get_approval_status', 'v1', '查询审批状态', 'mcp', 'agentToolOrchestrator', 'LOW', 1, 0, 1, JSON_OBJECT(), JSON_OBJECT()),
+  ('write_agent_audit', 'v1', '写入Agent审计日志', 'mcp', 'defaultAgentBusinessToolExecutor', 'LOW', 1, 0, 1, JSON_OBJECT(), JSON_OBJECT())
+ON DUPLICATE KEY UPDATE
+  description = VALUES(description),
+  handler_bean = VALUES(handler_bean),
+  risk_level = VALUES(risk_level),
+  enabled = VALUES(enabled),
+  require_approval = VALUES(require_approval),
+  update_time = CURRENT_TIMESTAMP;

+ 55 - 0
sql/postgresql/aftersales_policy_pgvector.sql

@@ -0,0 +1,55 @@
+CREATE EXTENSION IF NOT EXISTS vector;
+
+CREATE TABLE IF NOT EXISTS aftersales_policy_doc (
+  id BIGSERIAL PRIMARY KEY,
+  doc_code VARCHAR(64) NOT NULL,
+  title VARCHAR(256) NOT NULL,
+  category VARCHAR(64) NOT NULL,
+  issue_type VARCHAR(64) NOT NULL,
+  version VARCHAR(32) NOT NULL,
+  content TEXT NOT NULL,
+  content_hash CHAR(64) NOT NULL,
+  source_type VARCHAR(32) NOT NULL DEFAULT 'manual',
+  source_uri VARCHAR(512),
+  effective_at TIMESTAMPTZ,
+  expired_at TIMESTAMPTZ,
+  enabled BOOLEAN NOT NULL DEFAULT TRUE,
+  metadata JSONB,
+  create_time TIMESTAMPTZ NOT NULL DEFAULT now(),
+  update_time TIMESTAMPTZ NOT NULL DEFAULT now(),
+  UNIQUE (doc_code, version)
+);
+
+CREATE INDEX IF NOT EXISTS idx_policy_doc_issue_enabled
+  ON aftersales_policy_doc (issue_type, enabled);
+
+CREATE INDEX IF NOT EXISTS idx_policy_doc_category
+  ON aftersales_policy_doc (category);
+
+CREATE TABLE IF NOT EXISTS aftersales_policy_chunk (
+  id BIGSERIAL PRIMARY KEY,
+  doc_id BIGINT NOT NULL REFERENCES aftersales_policy_doc(id),
+  chunk_no INT NOT NULL,
+  chunk_text TEXT NOT NULL,
+  token_count INT,
+  embedding VECTOR(1536) NOT NULL,
+  content_hash CHAR(64) NOT NULL,
+  issue_type VARCHAR(64) NOT NULL,
+  metadata JSONB,
+  enabled BOOLEAN NOT NULL DEFAULT TRUE,
+  create_time TIMESTAMPTZ NOT NULL DEFAULT now(),
+  update_time TIMESTAMPTZ NOT NULL DEFAULT now(),
+  UNIQUE (doc_id, chunk_no)
+);
+
+CREATE INDEX IF NOT EXISTS idx_policy_chunk_doc
+  ON aftersales_policy_chunk (doc_id);
+
+CREATE INDEX IF NOT EXISTS idx_policy_chunk_issue_enabled
+  ON aftersales_policy_chunk (issue_type, enabled);
+
+CREATE INDEX IF NOT EXISTS idx_policy_chunk_metadata
+  ON aftersales_policy_chunk USING GIN (metadata);
+
+CREATE INDEX IF NOT EXISTS idx_policy_chunk_embedding
+  ON aftersales_policy_chunk USING hnsw (embedding vector_cosine_ops);

+ 12 - 0
src/main/java/com/zsElectric/boot/business/controller/ThirdPartyInfoController.java

@@ -1,8 +1,10 @@
 package com.zsElectric.boot.business.controller;
 
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zsElectric.boot.business.model.entity.ThirdPartyInfo;
 import com.zsElectric.boot.business.model.form.ThirdPartyInfoForm;
 import com.zsElectric.boot.business.model.query.ThirdPartyInfoQuery;
+import com.zsElectric.boot.business.model.vo.PartyStationInfoVO;
 import com.zsElectric.boot.business.model.vo.ThirdPartyInfoVO;
 import com.zsElectric.boot.business.service.ThirdPartyInfoService;
 import com.zsElectric.boot.common.annotation.Log;
@@ -16,6 +18,8 @@ import lombok.RequiredArgsConstructor;
 import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.List;
+
 /**
  * 第三方对接信息控制器
  *
@@ -38,6 +42,14 @@ public class ThirdPartyInfoController {
         return PageResult.success(result);
     }
 
+    @Operation(summary = "获取第三方对接信息列表")
+    @GetMapping("/list")
+    @Log(value = "获取第三方对接信息列表", module = LogModuleEnum.OTHER)
+    public Result<List<ThirdPartyInfoVO>> getThirdPartyInfoList() {
+        List<ThirdPartyInfoVO> result = thirdPartyInfoService.getThirdPartyInfoList();
+        return Result.success(result);
+    }
+
     @Operation(summary = "根据ID获取渠道方对接信息详情")
     @GetMapping("/{id}")
     @Log(value = "获取渠道方对接信息详情", module = LogModuleEnum.OTHER)

+ 3 - 0
src/main/java/com/zsElectric/boot/business/model/query/ChargeOrderInfoQuery.java

@@ -56,4 +56,7 @@ public class ChargeOrderInfoQuery extends BasePageQuery {
     @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
     private LocalDateTime endTime;
+
+    @Schema(description = "运营商ID")
+    private String operatorId;
 }

+ 2 - 0
src/main/java/com/zsElectric/boot/business/model/vo/ChargeOrderInfoVO.java

@@ -124,4 +124,6 @@ public class ChargeOrderInfoVO implements Serializable {
     private String phone;
     @Schema(description = "电池soc%")
     private String soc;
+    @Schema(description = "运营商")
+    private String ecName;
 }

+ 1 - 1
src/main/java/com/zsElectric/boot/business/quartz/CompensateOrderJob.java

@@ -29,7 +29,7 @@ public class CompensateOrderJob {
      * 查找状态为3(已完成)或5(未成功充电)且充电数据为0的订单
      * 优先通过third_party_api_log表获取推送数据,备选通过third_party_charge_status表查询
      */
-    @Scheduled(cron = "${job.compensate-order.cron:0 0/10 * * * ?}")
+//    @Scheduled(cron = "${job.compensate-order.cron:0 0/10 * * * ?}")
     public void compensateChargeOrder() {
         if (!running.compareAndSet(false, true)) {
             log.warn("充电订单补偿定时任务正在执行中,跳过本次调度");

+ 1 - 1
src/main/java/com/zsElectric/boot/business/quartz/FailOrderDisposeJob.java

@@ -18,7 +18,7 @@ public class FailOrderDisposeJob {
     /**
      * 每3分钟执行一次
      */
-    @Scheduled(cron = "0 */3 * * * ?")
+//    @Scheduled(cron = "0 */3 * * * ?")
     public void failOrderDispose(){
         log.info("开始执行失败订单处理定时任务");
 

+ 3 - 0
src/main/java/com/zsElectric/boot/business/service/ThirdPartyInfoService.java

@@ -60,4 +60,7 @@ public interface ThirdPartyInfoService {
      * 获取渠道方集合
      * */
     List<PartyStationInfoVO> getPartyStationInfoList();
+
+    List<ThirdPartyInfoVO> getThirdPartyInfoList();
+
 }

+ 26 - 26
src/main/java/com/zsElectric/boot/business/service/impl/ChargeOrderInfoServiceImpl.java

@@ -386,9 +386,7 @@ public class ChargeOrderInfoServiceImpl extends ServiceImpl<ChargeOrderInfoMappe
      * @return
      */
     public AppChargeVO channelInvokeCharge(AppInvokeChargeForm formData) throws JsonProcessingException {
-        if (StrUtil.isBlank(formData.getOperatorId())) {
-            throw new BusinessException("运营商ID不能为空");
-        }
+
         if (StrUtil.isBlank(formData.getChannelOrderNo())) {
             throw new BusinessException("渠道订单号不能为空");
         }
@@ -399,28 +397,6 @@ public class ChargeOrderInfoServiceImpl extends ServiceImpl<ChargeOrderInfoMappe
             throw new BusinessException("渠道预支付金额必须大于0");
         }
 
-        ThirdPartyInfo thirdPartyInfo = thirdPartyInfoMapper.selectOne(
-                Wrappers.lambdaQuery(ThirdPartyInfo.class)
-                        .eq(ThirdPartyInfo::getOperatorId, formData.getOperatorId())
-                        .last("limit 1"));
-        if (thirdPartyInfo == null) {
-            throw new BusinessException("渠道运营商不存在");
-        }
-
-        UserInfo channelUser = userInfoService.getUserInfoByPhoneAndOperatorId(
-                formData.getChannelUserPhone(),
-                thirdPartyInfo.getId()
-        );
-        if (channelUser == null) {
-            channelUser = userInfoService.registerThirdPartyUserByPhone(
-                    formData.getChannelUserPhone(),
-                    thirdPartyInfo.getId()
-            );
-        }
-        if (channelUser == null) {
-            throw new BusinessException("渠道用户不存在且自动注册失败");
-        }
-
         String seq = ConnectivityConstants.OPERATOR_ID + formData.getChannelOrderNo();
 
         //请求设备认证
@@ -436,13 +412,37 @@ public class ChargeOrderInfoServiceImpl extends ServiceImpl<ChargeOrderInfoMappe
         //创建订单
         ChargeOrderInfo chargeOrderInfo = new ChargeOrderInfo();
         Long currentUserId = SecurityUtils.getUserId();
-        chargeOrderInfo.setUserId(channelUser.getId());
         User user = currentUserId == null ? null : userMapper.selectById(currentUserId);
         if(ObjectUtil.isNotEmpty(user)){
             FirmInfo firmInfo = firmInfoMapper.selectOne(Wrappers.lambdaQuery(FirmInfo.class).eq(FirmInfo::getDeptId, user.getDeptId()).last("limit 1"));
             if(firmInfo != null) {
                 chargeOrderInfo.setFirmId(firmInfo.getId());
             }
+            chargeOrderInfo.setUserId(user.getId());
+        }
+
+        if (StrUtil.isNotBlank(formData.getOperatorId())) {
+            ThirdPartyInfo thirdPartyInfo = thirdPartyInfoMapper.selectOne(
+                    Wrappers.lambdaQuery(ThirdPartyInfo.class)
+                            .eq(ThirdPartyInfo::getOperatorId, formData.getOperatorId())
+                            .last("limit 1"));
+            if (thirdPartyInfo == null) {
+                throw new BusinessException("渠道运营商不存在");
+            }
+            UserInfo channelUser = userInfoService.getUserInfoByPhoneAndOperatorId(
+                    formData.getChannelUserPhone(),
+                    thirdPartyInfo.getId()
+            );
+            if (channelUser == null) {
+                channelUser = userInfoService.registerThirdPartyUserByPhone(
+                        formData.getChannelUserPhone(),
+                        thirdPartyInfo.getId()
+                );
+            }
+            if (channelUser == null) {
+                throw new BusinessException("渠道用户不存在且自动注册失败");
+            }
+            chargeOrderInfo.setUserId(channelUser.getId());
         }
 
         chargeOrderInfo.setOrderType(SystemConstants.CHARGE_ORDER_TYPE_CHANNEL);

+ 13 - 0
src/main/java/com/zsElectric/boot/business/service/impl/ThirdPartyInfoServiceImpl.java

@@ -89,4 +89,17 @@ public class ThirdPartyInfoServiceImpl implements ThirdPartyInfoService {
                 })
                 .collect(Collectors.toList());
     }
+
+    @Override
+    public List<ThirdPartyInfoVO> getThirdPartyInfoList() {
+        List<ThirdPartyInfo> thirdPartyInfos = thirdPartyInfoMapper.selectList(null);
+        // 转换为VO列表
+        return thirdPartyInfos.stream()
+                .map(info -> {
+                    ThirdPartyInfoVO vo = new ThirdPartyInfoVO();
+                    BeanUtils.copyProperties(info, vo);
+                    return vo;
+                })
+                .collect(Collectors.toList());
+    }
 }

+ 2 - 0
src/main/java/com/zsElectric/boot/business/service/impl/UserInfoServiceImpl.java

@@ -175,6 +175,7 @@ public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> i
         return this.getOne(
                 new LambdaQueryWrapper<UserInfo>()
                         .eq(UserInfo::getOpenid, openid)
+                        .isNull(UserInfo::getThirdPartId)
         );
     }
 
@@ -192,6 +193,7 @@ public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> i
         return this.getOne(
                 new LambdaQueryWrapper<UserInfo>()
                         .eq(UserInfo::getPhone, phone)
+                        .isNull(UserInfo::getThirdPartId)
         );
     }
 

+ 2 - 2
src/main/java/com/zsElectric/boot/charging/quartz/ChargingJob.java

@@ -39,7 +39,7 @@ public class ChargingJob {
      * 同步充电站信息
      * 每5分钟执行一次,从第三方接口获取充电站信息并存储到数据库
      */
-    @Scheduled(cron = "0 0/15 * * * ?")
+//    @Scheduled(cron = "0 0/15 * * * ?")
     public void syncStationsInfo() {
         log.info("开始执行充电站信息同步定时任务");
 
@@ -76,7 +76,7 @@ public class ChargingJob {
      * 每10分钟执行一次,查询所有充电桩的价格策略并存储到数据库
      * cron表达式: 0 10 * * * ? 表示每10分钟执行
      */
-    @Scheduled(cron = "0 */10 * * * ?")
+//    @Scheduled(cron = "0 */10 * * * ?")
     public void syncEquipmentPricePolicy() {
         // 检查任务是否正在执行,防止并发
         if (isPricePolicySyncRunning) {

+ 51 - 0
src/main/java/com/zsElectric/boot/platform/agent/config/AgentToolConfig.java

@@ -0,0 +1,51 @@
+package com.zsElectric.boot.platform.agent.config;
+
+import com.zsElectric.boot.platform.agent.service.AgentApprovalStore;
+import com.zsElectric.boot.platform.agent.service.AgentAuditWriter;
+import com.zsElectric.boot.platform.agent.service.AgentBusinessToolExecutor;
+import com.zsElectric.boot.platform.agent.service.AgentToolOrchestrator;
+import com.zsElectric.boot.platform.agent.tool.AgentToolCatalog;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Clock;
+
+/**
+ * Agent 工具控制面配置。
+ *
+ * <p>将白名单目录、编排器和时钟注册为 Spring Bean。这样测试可以直接 new 编排器,
+ * 生产环境则由 Spring 注入 MyBatis 持久化端口和默认业务工具执行器。</p>
+ */
+@Configuration
+public class AgentToolConfig {
+
+    /**
+     * 注册默认工具白名单。
+     */
+    @Bean
+    public AgentToolCatalog agentToolCatalog() {
+        return AgentToolCatalog.defaultCatalog();
+    }
+
+    /**
+     * 注册统一时钟。时间相关逻辑集中从 Clock 获取,便于后续测试和排查。
+     */
+    @Bean
+    public Clock agentClock() {
+        return Clock.systemDefaultZone();
+    }
+
+    /**
+     * 注册 Agent 工具编排器。
+     */
+    @Bean
+    public AgentToolOrchestrator agentToolOrchestrator(
+            AgentToolCatalog agentToolCatalog,
+            AgentApprovalStore approvalStore,
+            AgentBusinessToolExecutor toolExecutor,
+            AgentAuditWriter auditWriter,
+            Clock agentClock
+    ) {
+        return new AgentToolOrchestrator(agentToolCatalog, approvalStore, toolExecutor, auditWriter, agentClock);
+    }
+}

+ 107 - 0
src/main/java/com/zsElectric/boot/platform/agent/controller/AgentApprovalController.java

@@ -0,0 +1,107 @@
+package com.zsElectric.boot.platform.agent.controller;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.zsElectric.boot.core.web.Result;
+import com.zsElectric.boot.platform.agent.mapper.AgentApprovalMapper;
+import com.zsElectric.boot.platform.agent.model.dto.AgentApprovalDecisionRequest;
+import com.zsElectric.boot.platform.agent.model.entity.AgentApproval;
+import com.zsElectric.boot.platform.agent.service.AgentToolOrchestrator;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * Agent 高风险动作审批演示接口。
+ *
+ * <p>首版不做完整审批后台,使用 Swagger/Postman 调这些接口完成审批演示。
+ * 审批通过或拒绝只改变审批状态;真正的退款/补偿恢复执行仍由 Python 调用
+ * resume_after_approval 工具触发。</p>
+ */
+@Tag(name = "Agent审批接口")
+@RestController
+@RequestMapping("/api/v1/agent/approvals")
+@RequiredArgsConstructor
+public class AgentApprovalController {
+
+    /**
+     * 审批单 Mapper,用于列表和详情查询。
+     */
+    private final AgentApprovalMapper agentApprovalMapper;
+    /**
+     * 审批决策委托给编排器,保证状态流转和审计逻辑集中。
+     */
+    private final AgentToolOrchestrator agentToolOrchestrator;
+
+    /**
+     * 查询审批列表。
+     *
+     * <p>limit 被限制在 1 到 100,避免演示接口一次拉取过多数据。</p>
+     */
+    @Operation(summary = "获取Agent审批列表")
+    @GetMapping
+    public Result<List<AgentApproval>> listApprovals(
+            @RequestParam(required = false) String status,
+            @RequestParam(defaultValue = "20") Integer limit
+    ) {
+        List<AgentApproval> approvals = agentApprovalMapper.selectList(Wrappers.lambdaQuery(AgentApproval.class)
+                .eq(status != null && !status.isBlank(), AgentApproval::getStatus, status)
+                .orderByDesc(AgentApproval::getCreateTime)
+                .last("limit " + Math.max(1, Math.min(limit, 100))));
+        return Result.success(approvals);
+    }
+
+    /**
+     * 查询单个审批详情。
+     */
+    @Operation(summary = "获取Agent审批详情")
+    @GetMapping("/{approvalId}")
+    public Result<AgentApproval> getApproval(@PathVariable String approvalId) {
+        AgentApproval approval = agentApprovalMapper.selectOne(Wrappers.lambdaQuery(AgentApproval.class)
+                .eq(AgentApproval::getApprovalId, approvalId)
+                .last("limit 1"));
+        return Result.success(approval);
+    }
+
+    /**
+     * 审批通过。
+     *
+     * <p>该接口不会直接执行业务动作,只把审批状态置为 approved。</p>
+     */
+    @Operation(summary = "通过Agent审批")
+    @PostMapping("/{approvalId}/approve")
+    public Result<Void> approve(
+            @PathVariable String approvalId,
+            @RequestBody AgentApprovalDecisionRequest request
+    ) {
+        agentToolOrchestrator.approve(
+                approvalId,
+                request.getApproverId(),
+                request.getApproverUsername(),
+                request.getDecisionComment()
+        );
+        return Result.success();
+    }
+
+    /**
+     * 审批拒绝。
+     *
+     * <p>拒绝后冻结动作会同步标记 rejected,resume 时返回确定结果。</p>
+     */
+    @Operation(summary = "拒绝Agent审批")
+    @PostMapping("/{approvalId}/reject")
+    public Result<Void> reject(
+            @PathVariable String approvalId,
+            @RequestBody AgentApprovalDecisionRequest request
+    ) {
+        agentToolOrchestrator.reject(
+                approvalId,
+                request.getApproverId(),
+                request.getApproverUsername(),
+                request.getDecisionComment()
+        );
+        return Result.success();
+    }
+}

+ 68 - 0
src/main/java/com/zsElectric/boot/platform/agent/controller/AgentMcpController.java

@@ -0,0 +1,68 @@
+package com.zsElectric.boot.platform.agent.controller;
+
+import com.zsElectric.boot.core.web.Result;
+import com.zsElectric.boot.platform.agent.model.dto.AgentToolCallRequest;
+import com.zsElectric.boot.platform.agent.model.dto.AgentToolCallResponse;
+import com.zsElectric.boot.platform.agent.service.AgentToolOrchestrator;
+import com.zsElectric.boot.platform.agent.tool.AgentToolCatalog;
+import com.zsElectric.boot.platform.agent.tool.AgentToolDefinition;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * Agent MCP 工具入口。
+ *
+ * <p>Python Agent 只通过该控制器调用 Java 业务能力。控制器不会把所有 REST 接口
+ * 自动暴露给模型,而是把请求交给 AgentToolOrchestrator,由编排器执行白名单、
+ * 风险分级、审批、冻结动作和审计。</p>
+ */
+@Tag(name = "Agent MCP工具接口")
+@RestController
+@RequestMapping("/api/v1/agent/mcp")
+@RequiredArgsConstructor
+public class AgentMcpController {
+
+    /**
+     * 当前可暴露给模型的工具目录。
+     */
+    private final AgentToolCatalog agentToolCatalog;
+    /**
+     * 工具调用控制面,负责安全治理和业务执行分流。
+     */
+    private final AgentToolOrchestrator agentToolOrchestrator;
+
+    /**
+     * 获取白名单工具列表。
+     *
+     * <p>该接口给 Python Agent、Swagger 或 Postman 演示使用,便于确认当前哪些工具
+     * 可被调用以及对应风险等级。</p>
+     */
+    @Operation(summary = "获取Agent白名单工具")
+    @GetMapping("/tools")
+    public Result<List<AgentToolDefinition>> listTools() {
+        return Result.success(agentToolCatalog.list());
+    }
+
+    /**
+     * 调用指定 MCP 工具。
+     *
+     * <p>toolName 以路径参数为准,避免请求体伪造不同工具名。工具版本缺省为 v1。
+     * 后续如果需要按角色校验、IP 限流、签名校验,可以在进入编排器前统一补充。</p>
+     */
+    @Operation(summary = "调用Agent工具")
+    @PostMapping("/tools/{toolName}/call")
+    public Result<AgentToolCallResponse> callTool(
+            @PathVariable String toolName,
+            @RequestBody AgentToolCallRequest request
+    ) {
+        request.setToolName(toolName);
+        if (request.getToolVersion() == null || request.getToolVersion().isBlank()) {
+            request.setToolVersion("v1");
+        }
+        return Result.success(agentToolOrchestrator.call(request));
+    }
+}

+ 12 - 0
src/main/java/com/zsElectric/boot/platform/agent/mapper/AftersalesWorkOrderMapper.java

@@ -0,0 +1,12 @@
+package com.zsElectric.boot.platform.agent.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsElectric.boot.platform.agent.model.entity.AftersalesWorkOrder;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 售后工单 Mapper。
+ */
+@Mapper
+public interface AftersalesWorkOrderMapper extends BaseMapper<AftersalesWorkOrder> {
+}

+ 12 - 0
src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentApprovalMapper.java

@@ -0,0 +1,12 @@
+package com.zsElectric.boot.platform.agent.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsElectric.boot.platform.agent.model.entity.AgentApproval;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * Agent 审批单 Mapper。
+ */
+@Mapper
+public interface AgentApprovalMapper extends BaseMapper<AgentApproval> {
+}

+ 12 - 0
src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentAuditLogMapper.java

@@ -0,0 +1,12 @@
+package com.zsElectric.boot.platform.agent.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsElectric.boot.platform.agent.model.entity.AgentAuditLog;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * Agent 审计日志 Mapper。
+ */
+@Mapper
+public interface AgentAuditLogMapper extends BaseMapper<AgentAuditLog> {
+}

+ 12 - 0
src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentPendingActionMapper.java

@@ -0,0 +1,12 @@
+package com.zsElectric.boot.platform.agent.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsElectric.boot.platform.agent.model.entity.AgentPendingAction;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * Agent 冻结动作 Mapper。
+ */
+@Mapper
+public interface AgentPendingActionMapper extends BaseMapper<AgentPendingAction> {
+}

+ 15 - 0
src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentToolRegistryMapper.java

@@ -0,0 +1,15 @@
+package com.zsElectric.boot.platform.agent.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsElectric.boot.platform.agent.model.entity.AgentToolRegistry;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * Agent 工具注册表 Mapper。
+ *
+ * <p>当前运行时白名单由 AgentToolCatalog 提供,该 Mapper 用于后续动态工具治理、
+ * 管理后台展示和 SQL 初始化验证。</p>
+ */
+@Mapper
+public interface AgentToolRegistryMapper extends BaseMapper<AgentToolRegistry> {
+}

+ 28 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentApprovalDecisionRequest.java

@@ -0,0 +1,28 @@
+package com.zsElectric.boot.platform.agent.model.dto;
+
+import lombok.Data;
+
+/**
+ * Agent 审批决策请求。
+ *
+ * <p>审批接口只修改审批状态,不直接执行业务动作。审批通过后仍需要调用
+ * resume_after_approval,按冻结动作恢复执行。</p>
+ */
+@Data
+public class AgentApprovalDecisionRequest {
+
+    /**
+     * 审批人 ID,用于审计和审批记录展示。
+     */
+    private String approverId;
+
+    /**
+     * 审批人名称。
+     */
+    private String approverUsername;
+
+    /**
+     * 审批意见,例如“同意退款”或“证据不足,拒绝补偿”。
+     */
+    private String decisionComment;
+}

+ 46 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentToolCallRequest.java

@@ -0,0 +1,46 @@
+package com.zsElectric.boot.platform.agent.model.dto;
+
+import lombok.Data;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Agent 工具调用请求。
+ *
+ * <p>Python Agent 调 Java MCP 工具时使用该结构。它不承载具体业务 DTO,
+ * 而是使用 params 保存工具参数,方便不同工具共享同一个入口。</p>
+ */
+@Data
+public class AgentToolCallRequest {
+
+    /**
+     * Agent 单次运行链路 ID。所有审批、冻结动作、审计、工单都应携带同一个 traceId。
+     */
+    private String traceId;
+
+    /**
+     * 工具名。Controller 会用路径中的 toolName 覆盖该字段,避免请求体和 URL 不一致。
+     */
+    private String toolName;
+
+    /**
+     * 工具版本。默认 v1,后续参数 schema 变化时可并存多个版本。
+     */
+    private String toolVersion = "v1";
+
+    /**
+     * 调用操作者 ID,通常来自 Python Agent 传入的后台用户或系统账号。
+     */
+    private String operatorId;
+
+    /**
+     * 调用操作者名称,用于审批和审计展示。
+     */
+    private String operatorName;
+
+    /**
+     * 工具参数。高风险工具会把该 Map 稳定序列化后写入 agent_pending_action.params_json。
+     */
+    private Map<String, Object> params = new LinkedHashMap<>();
+}

+ 107 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentToolCallResponse.java

@@ -0,0 +1,107 @@
+package com.zsElectric.boot.platform.agent.model.dto;
+
+import lombok.Data;
+
+import java.util.Map;
+
+/**
+ * Agent 工具调用响应。
+ *
+ * <p>该结构统一承载低风险工具直接执行结果、高风险工具审批等待结果,以及失败原因。
+ * Python Agent 只需要根据 status 和 approvalRequired 判断下一步是回复用户、等待审批,
+ * 还是提示人工介入。</p>
+ */
+@Data
+public class AgentToolCallResponse {
+
+    /**
+     * 工具调用在控制面是否成功。审批等待也算成功,因为系统已经正确创建审批单。
+     */
+    private boolean success;
+
+    /**
+     * 是否需要人工审批。true 时 approvalId 必须有值。
+     */
+    private boolean approvalRequired;
+
+    /**
+     * 状态:executed、pending_approval、rejected、expired、failed 等。
+     */
+    private String status;
+
+    /**
+     * 本次 Agent 运行链路 ID。
+     */
+    private String traceId;
+
+    /**
+     * 实际调用的工具名。
+     */
+    private String toolName;
+
+    /**
+     * 审批 ID,高风险工具创建审批或恢复执行时返回。
+     */
+    private String approvalId;
+
+    /**
+     * 冻结动作 ID,用于定位 agent_pending_action。
+     */
+    private String pendingActionId;
+
+    /**
+     * 工具业务输出。低风险工具是业务结果;审批等待时包含 approval_id 等提示信息。
+     */
+    private Map<String, Object> output;
+
+    /**
+     * 失败原因。MCP 工具失败时不要让 Agent 猜测结果,应把明确错误返回给调用方。
+     */
+    private String errorMessage;
+
+    /**
+     * 构造已执行成功的工具响应。
+     */
+    public static AgentToolCallResponse executed(AgentToolCallRequest request, Map<String, Object> output) {
+        AgentToolCallResponse response = new AgentToolCallResponse();
+        response.setSuccess(true);
+        response.setStatus("executed");
+        response.setTraceId(request.getTraceId());
+        response.setToolName(request.getToolName());
+        response.setOutput(output);
+        return response;
+    }
+
+    /**
+     * 构造需要人工审批的工具响应。
+     */
+    public static AgentToolCallResponse pendingApproval(AgentToolCallRequest request, String approvalId, String actionId) {
+        AgentToolCallResponse response = new AgentToolCallResponse();
+        response.setSuccess(true);
+        response.setApprovalRequired(true);
+        response.setStatus("pending_approval");
+        response.setTraceId(request.getTraceId());
+        response.setToolName(request.getToolName());
+        response.setApprovalId(approvalId);
+        response.setPendingActionId(actionId);
+        response.setOutput(Map.of(
+                "approval_required", true,
+                "approval_id", approvalId,
+                "pending_action_id", actionId
+        ));
+        return response;
+    }
+
+    /**
+     * 构造工具失败响应。
+     */
+    public static AgentToolCallResponse failed(AgentToolCallRequest request, String message) {
+        AgentToolCallResponse response = new AgentToolCallResponse();
+        response.setSuccess(false);
+        response.setStatus("failed");
+        response.setTraceId(request.getTraceId());
+        response.setToolName(request.getToolName());
+        response.setErrorMessage(message);
+        return response;
+    }
+}

+ 99 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/entity/AftersalesWorkOrder.java

@@ -0,0 +1,99 @@
+package com.zsElectric.boot.platform.agent.model.entity;
+
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.zsElectric.boot.common.base.BaseEntity;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * 售后工单实体。
+ *
+ * <p>低风险售后问题由 Agent 自动创建工单;高风险退款/补偿在审批恢复后也可以
+ * 写入工单,作为客服处理记录和审计入口。该表面向售后处理,不替代真实退款单或账户流水。</p>
+ */
+@Getter
+@Setter
+@TableName("aftersales_work_order")
+public class AftersalesWorkOrder extends BaseEntity {
+
+    /**
+     * 售后工单号,对外展示和客服检索使用。
+     */
+    private String workOrderNo;
+    /**
+     * Agent 运行链路 ID。
+     */
+    private String traceId;
+    /**
+     * 充电订单号。
+     */
+    private String orderNo;
+    /**
+     * 充电记录 ID,首版可为空,后续接入更细的充电会话表时回填。
+     */
+    private Long chargeRecordId;
+    /**
+     * 用户 ID。
+     */
+    private Long userId;
+    /**
+     * 脱敏手机号,避免工具结果和工单直接保存完整手机号。
+     */
+    private String phoneMasked;
+    /**
+     * 问题类型:charge_failed、payment_issue、refund_request、compensation_request。
+     */
+    private String issueType;
+    /**
+     * 问题等级:normal、urgent。
+     */
+    private String issueLevel;
+    /**
+     * 工单来源:agent、manual、third_party。
+     */
+    private String source;
+    /**
+     * Agent 生成的处理建议。
+     */
+    private String suggestion;
+    /**
+     * 政策依据和 RAG 命中证据 JSON。
+     */
+    private String policyEvidenceJson;
+    /**
+     * 订单和充电记录快照 JSON,方便后续复盘,不依赖实时订单状态。
+     */
+    private String orderSnapshotJson;
+    /**
+     * 工单状态:created、processing、resolved、closed、canceled 等。
+     */
+    private String status;
+    /**
+     * 是否由 Agent 创建。
+     */
+    private Integer createdByAgent;
+    /**
+     * 创建人,通常是 Agent 调用上下文中的 operatorName。
+     */
+    private String createdBy;
+    /**
+     * 分配处理人。
+     */
+    private String assignedTo;
+    /**
+     * 售后处理结果。
+     */
+    private String resolveResult;
+    /**
+     * 工单关闭时间。
+     */
+    private LocalDateTime closedAt;
+    /**
+     * 逻辑删除标记。
+     */
+    @TableLogic
+    private Integer deleted;
+}

+ 107 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentApproval.java

@@ -0,0 +1,107 @@
+package com.zsElectric.boot.platform.agent.model.entity;
+
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.Version;
+import com.zsElectric.boot.common.base.BaseEntity;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * Agent 高风险动作审批单。
+ *
+ * <p>审批单只表达“是否允许执行某个高风险动作”,完整执行参数不放在这里,
+ * 而是放在 agent_pending_action。这样审批列表可以展示业务摘要和决策信息,
+ * 但真正恢复执行时仍以冻结动作为准。</p>
+ */
+@Getter
+@Setter
+@TableName("agent_approval")
+public class AgentApproval extends BaseEntity {
+
+    /**
+     * 外部展示和恢复执行使用的审批 ID。
+     */
+    private String approvalId;
+    /**
+     * Agent 运行链路 ID,贯穿审批、冻结动作、审计和工单。
+     */
+    private String traceId;
+    /**
+     * 关联冻结动作数据库主键。保存后回填,用于后台详情页快速定位。
+     */
+    private Long pendingActionId;
+    /**
+     * 业务类型,例如 refund、compensation。
+     */
+    private String bizType;
+    /**
+     * 业务编号,通常是充电订单号。
+     */
+    private String bizNo;
+    /**
+     * 风险等级,首版高风险审批固定为 HIGH。
+     */
+    private String riskLevel;
+    /**
+     * 审批状态:pending、approved、rejected、expired、executed。
+     */
+    private String status;
+    /**
+     * 发起审批的用户 ID,可能来自后台登录用户或 Agent 系统账号。
+     */
+    private Long requestUserId;
+    /**
+     * 发起审批的用户名。
+     */
+    private String requestUsername;
+    /**
+     * 申请原因,通常来自用户售后诉求或 Agent 生成的处理理由。
+     */
+    private String requestReason;
+    /**
+     * 命中高风险的原因,用于审批人理解为什么不能自动执行。
+     */
+    private String riskReason;
+    /**
+     * 政策依据和 RAG 命中证据 JSON,供审批人判断。
+     */
+    private String policyEvidenceJson;
+    /**
+     * 申请退款或补偿金额。
+     */
+    private BigDecimal requestedAmount;
+    /**
+     * 审批人 ID。
+     */
+    private Long approverId;
+    /**
+     * 审批人名称。
+     */
+    private String approverUsername;
+    /**
+     * 审批意见。
+     */
+    private String decisionComment;
+    /**
+     * 审批决策时间。
+     */
+    private LocalDateTime decisionTime;
+    /**
+     * 审批过期时间。过期后需要重新发起高风险申请。
+     */
+    private LocalDateTime expiresAt;
+    /**
+     * 乐观锁版本,防止多人同时审批覆盖状态。
+     */
+    @Version
+    private Integer version;
+    /**
+     * 逻辑删除标记。
+     */
+    @TableLogic
+    private Integer deleted;
+}

+ 104 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentAuditLog.java

@@ -0,0 +1,104 @@
+package com.zsElectric.boot.platform.agent.model.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * Agent 全链路审计日志。
+ *
+ * <p>审计表用于还原一次 Agent 运行的关键步骤,包括用户输入、意图判断、RAG 命中、
+ * 工具调用、审批创建、审批决策、恢复执行和最终回复。该表只追加,不用于驱动业务状态。</p>
+ */
+@Getter
+@Setter
+@TableName("agent_audit_log")
+public class AgentAuditLog {
+
+    /**
+     * 主键 ID。
+     */
+    private Long id;
+    /**
+     * Agent 运行链路 ID。
+     */
+    private String traceId;
+    /**
+     * 当前步骤 span ID,预留给更细粒度链路追踪。
+     */
+    private String spanId;
+    /**
+     * 父级 span ID,预留给树形调用链。
+     */
+    private String parentSpanId;
+    /**
+     * 关联审批 ID。
+     */
+    private String approvalId;
+    /**
+     * 关联冻结动作 ID。
+     */
+    private String pendingActionId;
+    /**
+     * 操作者类型:user、agent、system、approver。
+     */
+    private String actorType;
+    /**
+     * 操作者 ID。
+     */
+    private String actorId;
+    /**
+     * 操作者名称。
+     */
+    private String actorName;
+    /**
+     * 步骤名,例如 tool_called、approval_created、action_resumed。
+     */
+    private String step;
+    /**
+     * 事件类型,首版通常与 step 保持一致,后续可进一步细分。
+     */
+    private String eventType;
+    /**
+     * 关联工具名。
+     */
+    private String toolName;
+    /**
+     * 工具风险等级。
+     */
+    private String riskLevel;
+    /**
+     * 输入快照 JSON。后续可在写入前统一脱敏。
+     */
+    private String inputJson;
+    /**
+     * 输出快照 JSON。用于排查工具执行和 Agent 回复依据。
+     */
+    private String outputJson;
+    /**
+     * 步骤状态:success、failed、pending、skipped。
+     */
+    private String status;
+    /**
+     * 失败原因。
+     */
+    private String errorMessage;
+    /**
+     * 步骤耗时毫秒。
+     */
+    private Long durationMs;
+    /**
+     * 客户端 IP。
+     */
+    private String ipAddress;
+    /**
+     * 客户端 User-Agent。
+     */
+    private String userAgent;
+    /**
+     * 审计创建时间。
+     */
+    private LocalDateTime createTime;
+}

+ 94 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentPendingAction.java

@@ -0,0 +1,94 @@
+package com.zsElectric.boot.platform.agent.model.entity;
+
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.Version;
+import com.zsElectric.boot.common.base.BaseEntity;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * Agent 待恢复执行的冻结动作。
+ *
+ * <p>当模型请求退款、补偿等高风险工具时,系统不会立即执行业务动作,而是把工具名、
+ * 工具版本、参数 JSON、参数哈希和幂等键冻结到这张表。审批通过后,
+ * resume_after_approval 只能按这张表保存的参数恢复执行,不能让模型重新生成参数。</p>
+ */
+@Getter
+@Setter
+@TableName("agent_pending_action")
+public class AgentPendingAction extends BaseEntity {
+
+    /**
+     * 外部展示用冻结动作 ID。
+     */
+    private String actionId;
+    /**
+     * 关联审批 ID。
+     */
+    private String approvalId;
+    /**
+     * Agent 运行链路 ID。
+     */
+    private String traceId;
+    /**
+     * 审批通过后要执行的工具名。
+     */
+    private String toolName;
+    /**
+     * 工具版本。
+     */
+    private String toolVersion;
+    /**
+     * 风险等级。
+     */
+    private String riskLevel;
+    /**
+     * 冻结后的工具参数 JSON。
+     */
+    private String paramsJson;
+    /**
+     * params_json 的 SHA-256 摘要,用于恢复执行前防篡改校验。
+     */
+    private String paramsHash;
+    /**
+     * 幂等键,用于防止重复恢复导致重复退款或重复补偿。
+     */
+    private String idempotencyKey;
+    /**
+     * 执行状态:pending、executing、executed、failed、expired、rejected。
+     */
+    private String executeStatus;
+    /**
+     * 执行成功后的结果 JSON。重复 resume 时直接返回该历史结果。
+     */
+    private String executeResultJson;
+    /**
+     * 执行失败或校验失败原因。
+     */
+    private String errorMessage;
+    /**
+     * 重试次数,预留给后续失败重试策略。
+     */
+    private Integer retryCount;
+    /**
+     * 实际执行完成时间。
+     */
+    private LocalDateTime executedAt;
+    /**
+     * 冻结动作过期时间。
+     */
+    private LocalDateTime expiresAt;
+    /**
+     * 乐观锁版本。
+     */
+    @Version
+    private Integer version;
+    /**
+     * 逻辑删除标记。
+     */
+    @TableLogic
+    private Integer deleted;
+}

+ 90 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentToolRegistry.java

@@ -0,0 +1,90 @@
+package com.zsElectric.boot.platform.agent.model.entity;
+
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.zsElectric.boot.common.base.BaseEntity;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * Agent 工具注册表实体。
+ *
+ * <p>该表用于治理 MCP 工具:记录工具名、版本、处理器、风险等级、审批要求、输入输出 schema
+ * 和限流策略。当前运行时代码先使用 AgentToolCatalog 内置白名单,数据库表用于后续动态治理
+ * 和运营审计。</p>
+ */
+@Getter
+@Setter
+@TableName("agent_tool_registry")
+public class AgentToolRegistry extends BaseEntity {
+
+    /**
+     * MCP 工具名。
+     */
+    private String toolName;
+    /**
+     * 工具版本。
+     */
+    private String toolVersion;
+    /**
+     * 工具说明。
+     */
+    private String description;
+    /**
+     * 工具传输协议,首版固定 mcp。
+     */
+    private String transport;
+    /**
+     * Java 处理器 Bean 或路由标识。
+     */
+    private String handlerBean;
+    /**
+     * 风险等级:LOW、MEDIUM、HIGH。
+     */
+    private String riskLevel;
+    /**
+     * 是否启用。
+     */
+    private Integer enabled;
+    /**
+     * 是否需要审批。
+     */
+    private Integer requireApproval;
+    /**
+     * 是否支持幂等。
+     */
+    private Integer idempotent;
+    /**
+     * 输入参数 JSON Schema。
+     */
+    private String inputSchemaJson;
+    /**
+     * 输出结果 JSON Schema。
+     */
+    private String outputSchemaJson;
+    /**
+     * 允许调用的角色列表 JSON。
+     */
+    private String allowedRoles;
+    /**
+     * 需要脱敏的敏感字段列表 JSON。
+     */
+    private String sensitiveFields;
+    /**
+     * 单工具每分钟限流阈值。
+     */
+    private Integer rateLimitPerMinute;
+    /**
+     * 工具超时时间,单位毫秒。
+     */
+    private Integer timeoutMs;
+    /**
+     * 备注。
+     */
+    private String remark;
+    /**
+     * 逻辑删除标记。
+     */
+    @TableLogic
+    private Integer deleted;
+}

+ 45 - 0
src/main/java/com/zsElectric/boot/platform/agent/service/AgentApprovalStore.java

@@ -0,0 +1,45 @@
+package com.zsElectric.boot.platform.agent.service;
+
+import com.zsElectric.boot.platform.agent.model.entity.AgentApproval;
+import com.zsElectric.boot.platform.agent.model.entity.AgentPendingAction;
+
+import java.util.Optional;
+
+/**
+ * Agent 审批和冻结动作存储端口。
+ *
+ * <p>编排器依赖该接口而不是直接依赖 MyBatis,便于单元测试使用内存实现。
+ * 生产环境由 MyBatisAgentApprovalStore 负责落库。</p>
+ */
+public interface AgentApprovalStore {
+
+    /**
+     * 保存审批单。
+     */
+    void saveApproval(AgentApproval approval);
+
+    /**
+     * 保存冻结动作。
+     */
+    void savePendingAction(AgentPendingAction pendingAction);
+
+    /**
+     * 按审批 ID 查询审批单。
+     */
+    Optional<AgentApproval> findApproval(String approvalId);
+
+    /**
+     * 按审批 ID 查询关联冻结动作。
+     */
+    Optional<AgentPendingAction> findPendingAction(String approvalId);
+
+    /**
+     * 更新审批单状态或决策信息。
+     */
+    void updateApproval(AgentApproval approval);
+
+    /**
+     * 更新冻结动作执行状态或结果。
+     */
+    void updatePendingAction(AgentPendingAction pendingAction);
+}

+ 17 - 0
src/main/java/com/zsElectric/boot/platform/agent/service/AgentAuditWriter.java

@@ -0,0 +1,17 @@
+package com.zsElectric.boot.platform.agent.service;
+
+import com.zsElectric.boot.platform.agent.model.entity.AgentAuditLog;
+
+/**
+ * Agent 审计写入端口。
+ *
+ * <p>所有关键控制面事件都通过该接口写入 agent_audit_log。后续如需统一敏感字段脱敏、
+ * 异步写入或日志降级,可以替换实现而不改编排器。</p>
+ */
+public interface AgentAuditWriter {
+
+    /**
+     * 追加一条审计日志。
+     */
+    void write(AgentAuditLog auditLog);
+}

+ 24 - 0
src/main/java/com/zsElectric/boot/platform/agent/service/AgentBusinessToolExecutor.java

@@ -0,0 +1,24 @@
+package com.zsElectric.boot.platform.agent.service;
+
+import com.zsElectric.boot.platform.agent.model.dto.AgentToolCallRequest;
+
+import java.util.Map;
+
+/**
+ * Agent 业务工具执行端口。
+ *
+ * <p>编排器只负责安全治理,真正的订单查询、政策检索、售后建单、审批后业务动作
+ * 都通过该接口执行。这样可以保持“控制面”和“业务能力”边界清楚。</p>
+ */
+public interface AgentBusinessToolExecutor {
+
+    /**
+     * 执行指定白名单工具。
+     *
+     * @param toolName 工具名
+     * @param params 工具参数
+     * @param request 原始调用上下文
+     * @return 工具输出,返回给 Python Agent
+     */
+    Map<String, Object> execute(String toolName, Map<String, Object> params, AgentToolCallRequest request);
+}

+ 491 - 0
src/main/java/com/zsElectric/boot/platform/agent/service/AgentToolOrchestrator.java

@@ -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);
+    }
+}

+ 285 - 0
src/main/java/com/zsElectric/boot/platform/agent/service/DefaultAgentBusinessToolExecutor.java

@@ -0,0 +1,285 @@
+package com.zsElectric.boot.platform.agent.service;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.zsElectric.boot.business.model.entity.ChargeOrderInfo;
+import com.zsElectric.boot.business.model.vo.ChargeOrderInfoVO;
+import com.zsElectric.boot.business.service.ChargeOrderInfoService;
+import com.zsElectric.boot.platform.agent.mapper.AftersalesWorkOrderMapper;
+import com.zsElectric.boot.platform.agent.model.dto.AgentToolCallRequest;
+import com.zsElectric.boot.platform.agent.model.entity.AftersalesWorkOrder;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Agent 默认业务工具执行器。
+ *
+ * <p>该类只处理已经被 AgentToolOrchestrator 判定为“允许执行”的工具调用。
+ * 也就是说,高风险退款/补偿在到达这里之前已经完成审批和冻结参数校验。
+ * 这里不暴露通用 Controller,也不让模型直接访问 Mapper,而是把白名单工具映射到
+ * 明确的 Service/Mapper 调用。</p>
+ *
+ * <p>首版重点是跑通售后闭环,所以政策检索使用默认政策文本,退款/补偿审批通过后
+ * 先落售后工单记录,后续可以在对应分支接入真实支付退款或补偿账户变更逻辑。</p>
+ */
+@Component
+@RequiredArgsConstructor
+public class DefaultAgentBusinessToolExecutor implements AgentBusinessToolExecutor {
+
+    /**
+     * 复用现有充电订单 Service,保证订单查询仍走业务层而不是直接让 Agent 查库。
+     */
+    private final ChargeOrderInfoService chargeOrderInfoService;
+    /**
+     * 售后工单 Mapper,用于低风险自动建单和高风险审批后的处理记录。
+     */
+    private final AftersalesWorkOrderMapper aftersalesWorkOrderMapper;
+    /**
+     * 允许 write_agent_audit 工具追加审计日志。
+     */
+    private final AgentAuditWriter auditWriter;
+    /**
+     * 用于把 VO 转成 Map、把 evidence/order_snapshot 等结构化参数写入 JSON 字符串。
+     */
+    private final ObjectMapper objectMapper;
+
+    /**
+     * 根据白名单工具名分发到具体业务实现。
+     *
+     * <p>这里故意使用显式 switch,不做反射调用 Bean 方法,避免未来把未治理的业务方法
+     * 意外暴露给模型。</p>
+     */
+    @Override
+    public Map<String, Object> execute(String toolName, Map<String, Object> params, AgentToolCallRequest request) {
+        return switch (toolName) {
+            case "query_charge_order" -> queryChargeOrder(params);
+            case "query_charge_session" -> queryChargeSession(params);
+            case "retrieve_aftersales_policy" -> retrieveAftersalesPolicy(params);
+            case "create_aftersales_work_order" -> createWorkOrder(params, request, "created");
+            case "request_refund" -> createApprovedRiskWorkOrder(params, request, "refund_submitted");
+            case "request_compensation" -> createApprovedRiskWorkOrder(params, request, "compensation_submitted");
+            case "write_agent_audit" -> writeAgentAudit(params, request);
+            default -> throw new IllegalArgumentException("unsupported agent tool: " + toolName);
+        };
+    }
+
+    /**
+     * 查询充电订单。
+     *
+     * <p>优先使用 order_no 精确查询;如果没有订单号,再按 user_id 或 phone 找最近一条
+     * 充电订单。找不到时抛出明确异常,让 Agent 停止工具链路并返回人工核查。</p>
+     */
+    private Map<String, Object> queryChargeOrder(Map<String, Object> params) {
+        ChargeOrderInfoVO vo = null;
+        String orderNo = string(params, "order_no");
+        if (notBlank(orderNo)) {
+            vo = chargeOrderInfoService.queryOrder(orderNo);
+        } else {
+            ChargeOrderInfo entity = findChargeOrderEntity(params);
+            if (entity != null) {
+                vo = chargeOrderInfoService.queryOrder(entity.getChargeOrderNo());
+            }
+        }
+        if (vo == null) {
+            throw new IllegalArgumentException("charge order not found");
+        }
+        return objectMapper.convertValue(vo, Map.class);
+    }
+
+    /**
+     * 查询充电会话摘要。
+     *
+     * <p>首版没有单独的“充电会话表”抽象,因此从充电订单详情中提取开始时间、结束时间、
+     * 充电时长、费用、电量和停止原因,作为 Agent 生成售后建议的上下文。</p>
+     */
+    private Map<String, Object> queryChargeSession(Map<String, Object> params) {
+        Map<String, Object> order = queryChargeOrder(params);
+        Map<String, Object> session = new LinkedHashMap<>();
+        session.put("order_no", order.get("chargeOrderNo"));
+        session.put("status", order.get("status"));
+        session.put("start_time", order.get("startTime"));
+        session.put("end_time", order.get("endTime"));
+        session.put("charge_time", order.get("chargeTime"));
+        session.put("total_charge", order.get("totalCharge"));
+        session.put("real_cost", order.get("realCost"));
+        session.put("stop_reason", order.get("stopReason"));
+        session.put("charge_details", order.get("chargeDetails"));
+        return session;
+    }
+
+    /**
+     * 在缺少订单号时,按用户或手机号定位最近订单。
+     */
+    private ChargeOrderInfo findChargeOrderEntity(Map<String, Object> params) {
+        String phone = string(params, "phone");
+        Long userId = longValue(params, "user_id");
+        return chargeOrderInfoService.getOne(Wrappers.lambdaQuery(ChargeOrderInfo.class)
+                .eq(userId != null, ChargeOrderInfo::getUserId, userId)
+                .eq(notBlank(phone), ChargeOrderInfo::getPhoneNum, phone)
+                .orderByDesc(ChargeOrderInfo::getCreateTime)
+                .last("limit 1"));
+    }
+
+    /**
+     * 检索售后政策。
+     *
+     * <p>当前 Java 侧先提供兜底政策能力,保证 Agent 闭环可以运行。真正的 pgvector RAG
+     * 接入后,可以把该方法替换为调用政策检索服务,接口返回结构保持不变。</p>
+     */
+    private Map<String, Object> retrieveAftersalesPolicy(Map<String, Object> params) {
+        String issueType = string(params, "issue_type");
+        String keywords = string(params, "keywords");
+        return Map.of(
+                "issue_type", issueType == null ? "unknown" : issueType,
+                "keywords", keywords == null ? "" : keywords,
+                "policy_hit", true,
+                "source", "java-default-policy",
+                "content", "首版默认政策:普通售后可建工单;退款或补偿必须进入审批并按冻结动作恢复执行。"
+        );
+    }
+
+    /**
+     * 审批通过后的高风险售后记录。
+     *
+     * <p>该方法只会在 resume_after_approval 完成审批状态和参数哈希校验后被调用。
+     * 首版用售后工单承接“已审批的退款/补偿动作”,为后续接真实退款能力保留审计依据。</p>
+     */
+    private Map<String, Object> createApprovedRiskWorkOrder(Map<String, Object> params, AgentToolCallRequest request, String status) {
+        String issueType = "refund_submitted".equals(status) ? "refund_request" : "compensation_request";
+        Map<String, Object> enriched = new LinkedHashMap<>(params);
+        enriched.putIfAbsent("issue_type", issueType);
+        enriched.putIfAbsent("suggestion", "审批已通过,记录高风险售后处理动作。");
+        Map<String, Object> result = createWorkOrder(enriched, request, status);
+        result.put("execution_mode", "approved_after_sales_record");
+        return result;
+    }
+
+    /**
+     * 创建售后工单。
+     *
+     * <p>低风险场景直接创建状态为 created 的工单;高风险审批恢复场景会传入
+     * refund_submitted 或 compensation_submitted,用来区分工单来源。</p>
+     */
+    private Map<String, Object> createWorkOrder(Map<String, Object> params, AgentToolCallRequest request, String status) {
+        AftersalesWorkOrder workOrder = new AftersalesWorkOrder();
+        workOrder.setWorkOrderNo("WO" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase());
+        workOrder.setTraceId(request.getTraceId());
+        workOrder.setOrderNo(required(params, "order_no"));
+        workOrder.setUserId(longValue(params, "user_id"));
+        workOrder.setPhoneMasked(maskPhone(string(params, "phone")));
+        workOrder.setIssueType(defaultString(string(params, "issue_type"), "charge_failed"));
+        workOrder.setIssueLevel(defaultString(string(params, "issue_level"), "normal"));
+        workOrder.setSource("agent");
+        workOrder.setSuggestion(defaultString(string(params, "suggestion"), string(params, "reason")));
+        workOrder.setPolicyEvidenceJson(toJson(params.get("evidence")));
+        workOrder.setOrderSnapshotJson(toJson(params.get("order_snapshot")));
+        workOrder.setStatus(defaultString(status, "created"));
+        workOrder.setCreatedByAgent(1);
+        workOrder.setCreatedBy(request.getOperatorName());
+        workOrder.setDeleted(0);
+        aftersalesWorkOrderMapper.insert(workOrder);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("work_order_no", workOrder.getWorkOrderNo());
+        result.put("status", workOrder.getStatus());
+        result.put("order_no", workOrder.getOrderNo());
+        result.put("issue_type", workOrder.getIssueType());
+        return result;
+    }
+
+    /**
+     * 外部显式写审计的工具实现。
+     *
+     * <p>通常编排器会自动写关键审计;该工具用于 Python Agent 想补充记录意图判断、
+     * RAG 命中、最终回复等 Java 无法感知的步骤。</p>
+     */
+    private Map<String, Object> writeAgentAudit(Map<String, Object> params, AgentToolCallRequest request) {
+        com.zsElectric.boot.platform.agent.model.entity.AgentAuditLog auditLog =
+                new com.zsElectric.boot.platform.agent.model.entity.AgentAuditLog();
+        auditLog.setTraceId(defaultString(string(params, "trace_id"), request.getTraceId()));
+        auditLog.setActorType("agent");
+        auditLog.setStep(defaultString(string(params, "step"), "tool_called"));
+        auditLog.setEventType(defaultString(string(params, "event_type"), auditLog.getStep()));
+        auditLog.setToolName(string(params, "tool_name"));
+        auditLog.setStatus(defaultString(string(params, "status"), "success"));
+        auditLog.setInputJson(toJson(params.get("input")));
+        auditLog.setOutputJson(toJson(params.get("output")));
+        auditLog.setCreateTime(LocalDateTime.now());
+        auditWriter.write(auditLog);
+        return Map.of("audit_written", true, "trace_id", auditLog.getTraceId());
+    }
+
+    /**
+     * 必填参数读取,不满足时直接终止工具执行。
+     */
+    private String required(Map<String, Object> params, String name) {
+        String value = string(params, name);
+        if (!notBlank(value)) {
+            throw new IllegalArgumentException(name + " is required");
+        }
+        return value;
+    }
+
+    /**
+     * 从通用参数 Map 中读取字符串。
+     */
+    private String string(Map<String, Object> params, String name) {
+        Object value = params == null ? null : params.get(name);
+        return value == null ? null : String.valueOf(value);
+    }
+
+    /**
+     * 从通用参数 Map 中读取 Long 类型参数。
+     */
+    private Long longValue(Map<String, Object> params, String name) {
+        String value = string(params, name);
+        if (!notBlank(value)) {
+            return null;
+        }
+        return Long.parseLong(value);
+    }
+
+    /**
+     * 判断字符串是否有实际内容。
+     */
+    private boolean notBlank(String value) {
+        return value != null && !value.isBlank();
+    }
+
+    /**
+     * 字符串默认值处理。
+     */
+    private String defaultString(String value, String defaultValue) {
+        return notBlank(value) ? value : defaultValue;
+    }
+
+    /**
+     * 手机号脱敏后再写入售后工单,避免 Agent 工具结果泄露完整手机号。
+     */
+    private String maskPhone(String phone) {
+        if (!notBlank(phone) || phone.length() < 7) {
+            return phone;
+        }
+        return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
+    }
+
+    /**
+     * 将结构化参数转成 JSON 字符串用于落库。
+     */
+    private String toJson(Object value) {
+        if (value == null) {
+            return null;
+        }
+        try {
+            return objectMapper.writeValueAsString(value);
+        } catch (JsonProcessingException e) {
+            throw new IllegalArgumentException("json serialization failed", e);
+        }
+    }
+}

+ 83 - 0
src/main/java/com/zsElectric/boot/platform/agent/service/MyBatisAgentApprovalStore.java

@@ -0,0 +1,83 @@
+package com.zsElectric.boot.platform.agent.service;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.zsElectric.boot.platform.agent.mapper.AgentApprovalMapper;
+import com.zsElectric.boot.platform.agent.mapper.AgentPendingActionMapper;
+import com.zsElectric.boot.platform.agent.model.entity.AgentApproval;
+import com.zsElectric.boot.platform.agent.model.entity.AgentPendingAction;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+/**
+ * 基于 MyBatis-Plus 的审批和冻结动作存储实现。
+ *
+ * <p>这里保持简单的按 approval_id 查询和按主键更新。复杂并发控制依赖实体上的
+ * @Version 乐观锁字段,后续如果审批并发更复杂,可以在该实现中补充状态条件更新。</p>
+ */
+@Component
+@RequiredArgsConstructor
+public class MyBatisAgentApprovalStore implements AgentApprovalStore {
+
+    /**
+     * 审批单 Mapper。
+     */
+    private final AgentApprovalMapper approvalMapper;
+    /**
+     * 冻结动作 Mapper。
+     */
+    private final AgentPendingActionMapper pendingActionMapper;
+
+    /**
+     * 新增审批单。
+     */
+    @Override
+    public void saveApproval(AgentApproval approval) {
+        approvalMapper.insert(approval);
+    }
+
+    /**
+     * 新增冻结动作。
+     */
+    @Override
+    public void savePendingAction(AgentPendingAction pendingAction) {
+        pendingActionMapper.insert(pendingAction);
+    }
+
+    /**
+     * 按 approval_id 查询审批单,limit 1 防止异常重复数据影响调用方。
+     */
+    @Override
+    public Optional<AgentApproval> findApproval(String approvalId) {
+        return Optional.ofNullable(approvalMapper.selectOne(Wrappers.lambdaQuery(AgentApproval.class)
+                .eq(AgentApproval::getApprovalId, approvalId)
+                .last("limit 1")));
+    }
+
+    /**
+     * 按 approval_id 查询冻结动作。
+     */
+    @Override
+    public Optional<AgentPendingAction> findPendingAction(String approvalId) {
+        return Optional.ofNullable(pendingActionMapper.selectOne(Wrappers.lambdaQuery(AgentPendingAction.class)
+                .eq(AgentPendingAction::getApprovalId, approvalId)
+                .last("limit 1")));
+    }
+
+    /**
+     * 更新审批单。
+     */
+    @Override
+    public void updateApproval(AgentApproval approval) {
+        approvalMapper.updateById(approval);
+    }
+
+    /**
+     * 更新冻结动作。
+     */
+    @Override
+    public void updatePendingAction(AgentPendingAction pendingAction) {
+        pendingActionMapper.updateById(pendingAction);
+    }
+}

+ 30 - 0
src/main/java/com/zsElectric/boot/platform/agent/service/MyBatisAgentAuditWriter.java

@@ -0,0 +1,30 @@
+package com.zsElectric.boot.platform.agent.service;
+
+import com.zsElectric.boot.platform.agent.mapper.AgentAuditLogMapper;
+import com.zsElectric.boot.platform.agent.model.entity.AgentAuditLog;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+/**
+ * MyBatis-Plus 审计写入实现。
+ *
+ * <p>审计日志采用追加写,不在这里更新业务状态。即便未来改成异步队列,
+ * 也应保持“业务状态由审批/冻结动作表承载,审计表只记录过程”的边界。</p>
+ */
+@Component
+@RequiredArgsConstructor
+public class MyBatisAgentAuditWriter implements AgentAuditWriter {
+
+    /**
+     * 审计日志 Mapper。
+     */
+    private final AgentAuditLogMapper agentAuditLogMapper;
+
+    /**
+     * 追加审计日志。
+     */
+    @Override
+    public void write(AgentAuditLog auditLog) {
+        agentAuditLogMapper.insert(auditLog);
+    }
+}

+ 23 - 0
src/main/java/com/zsElectric/boot/platform/agent/tool/AgentRiskLevel.java

@@ -0,0 +1,23 @@
+package com.zsElectric.boot.platform.agent.tool;
+
+/**
+ * Agent 工具风险等级。
+ *
+ * <p>风险等级用于把“模型建议调用工具”和“系统真正执行业务动作”隔开。
+ * LOW 可以直接执行并审计;MEDIUM 预留给后续需要二次确认但不一定审批的动作;
+ * HIGH 必须经过审批或冻结恢复流程。</p>
+ */
+public enum AgentRiskLevel {
+    /**
+     * 低风险:只读查询、政策检索、普通售后建单、审计写入。
+     */
+    LOW,
+    /**
+     * 中风险:首版暂未使用,预留给修改非资金类状态等场景。
+     */
+    MEDIUM,
+    /**
+     * 高风险:退款、补偿、审批后恢复执行等有资金或强业务副作用的动作。
+     */
+    HIGH
+}

+ 89 - 0
src/main/java/com/zsElectric/boot/platform/agent/tool/AgentToolCatalog.java

@@ -0,0 +1,89 @@
+package com.zsElectric.boot.platform.agent.tool;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Agent 工具白名单目录。
+ *
+ * <p>这里定义“模型可以看到并调用哪些工具”。首版先用代码内置目录,避免把现有
+ * Controller 或 Service 自动暴露给模型;后续如果要改成数据库动态目录,也应该保持
+ * 同样的约束:工具必须有固定名称、版本、风险等级、审批策略和参数 schema。</p>
+ */
+public class AgentToolCatalog {
+
+    /**
+     * key 格式为 toolName:version,用版本号避免后续参数 schema 变更时出现兼容问题。
+     */
+    private final Map<String, AgentToolDefinition> tools;
+
+    /**
+     * 构造白名单索引。重复工具名和版本会被后写入定义覆盖,调用方应保证定义唯一。
+     */
+    public AgentToolCatalog(List<AgentToolDefinition> definitions) {
+        Map<String, AgentToolDefinition> indexed = new LinkedHashMap<>();
+        for (AgentToolDefinition definition : definitions) {
+            indexed.put(key(definition.name(), definition.version()), definition);
+        }
+        this.tools = Map.copyOf(indexed);
+    }
+
+    /**
+     * 首版售后 Agent 工具清单。
+     *
+     * <p>LOW 工具可以直接执行;HIGH 工具要么创建审批和冻结动作,要么是恢复执行工具。
+     * 这里刻意没有收录启动充电、停止充电、清余额等高副作用接口,避免模型直接操作。</p>
+     */
+    public static AgentToolCatalog defaultCatalog() {
+        return new AgentToolCatalog(List.of(
+                low("query_charge_order", "查询充电订单"),
+                low("query_charge_session", "查询充电记录"),
+                low("retrieve_aftersales_policy", "检索售后政策"),
+                low("create_aftersales_work_order", "创建售后工单"),
+                high("request_refund", "发起退款审批", true),
+                high("request_compensation", "发起补偿审批", true),
+                high("resume_after_approval", "审批后恢复冻结动作", false),
+                low("get_approval_status", "查询审批状态"),
+                low("write_agent_audit", "写入Agent审计日志")
+        ));
+    }
+
+    /**
+     * 按工具名和版本查找白名单定义。
+     */
+    public Optional<AgentToolDefinition> find(String name, String version) {
+        return Optional.ofNullable(tools.get(key(name, version == null ? "v1" : version)));
+    }
+
+    /**
+     * 返回给 Python Agent 或 Swagger 演示查看的工具列表。
+     */
+    public List<AgentToolDefinition> list() {
+        return tools.values().stream()
+                .sorted((left, right) -> left.name().compareTo(right.name()))
+                .toList();
+    }
+
+    /**
+     * 创建低风险工具定义。
+     */
+    private static AgentToolDefinition low(String name, String description) {
+        return new AgentToolDefinition(name, "v1", description, AgentRiskLevel.LOW, false, Map.of(), Map.of());
+    }
+
+    /**
+     * 创建高风险工具定义。
+     */
+    private static AgentToolDefinition high(String name, String description, boolean approvalRequired) {
+        return new AgentToolDefinition(name, "v1", description, AgentRiskLevel.HIGH, approvalRequired, Map.of(), Map.of());
+    }
+
+    /**
+     * 工具索引键,名称和版本共同确定一个稳定工具契约。
+     */
+    private static String key(String name, String version) {
+        return name + ":" + version;
+    }
+}

+ 25 - 0
src/main/java/com/zsElectric/boot/platform/agent/tool/AgentToolDefinition.java

@@ -0,0 +1,25 @@
+package com.zsElectric.boot.platform.agent.tool;
+
+import java.util.Map;
+
+/**
+ * Agent 工具定义。
+ *
+ * @param name 工具名,作为 Python Agent 调用 Java MCP 工具的稳定标识
+ * @param version 工具版本,参数结构变化时通过版本隔离
+ * @param description 工具说明,用于工具列表展示和后续模型提示词组装
+ * @param riskLevel 风险等级,决定是否需要进入审批和冻结动作
+ * @param approvalRequired 是否需要人工审批;高风险恢复工具本身可以是 HIGH 但不再创建新审批
+ * @param inputSchema 输入参数 JSON Schema,首版预留,后续可用于运行时校验和提示词生成
+ * @param outputSchema 输出结果 JSON Schema,首版预留,后续可用于工具结果标准化
+ */
+public record AgentToolDefinition(
+        String name,
+        String version,
+        String description,
+        AgentRiskLevel riskLevel,
+        boolean approvalRequired,
+        Map<String, Object> inputSchema,
+        Map<String, Object> outputSchema
+) {
+}

+ 6 - 0
src/main/resources/application-dev.yml

@@ -51,6 +51,10 @@ spring:
     # 邮件发送者
     from: youlaitech@163.com
 
+# 充电站经纬度同步黑名单(黑名单中的站点不同步经纬度,保留数据库原有值)
+black-charging-stations:
+  station-list: "202510211552,202511201459,202512181925"
+
 # 第三方认证配置
 third-party:
   jwt:
@@ -125,6 +129,8 @@ security:
     - /charge-business/v1/linkData/**
     - /applet/v1/homePage/** # 用户端分页查询站点信息
     - /third_party/v1/** # 第三方接入接口
+    - /api/v1/agent/mcp/**
+    - /api/v1/agent/approvals/**
   # 只走第三方过滤器、不走其他安全链
   third-party-urls:
     - /charge-business/v1/linkData/notification_start_charge_result

+ 4 - 0
src/main/resources/application-prod.yml

@@ -51,6 +51,10 @@ spring:
     # 邮件发送者
     from: youlaitech@163.com
 
+# 充电站经纬度同步黑名单(黑名单中的站点不同步经纬度,保留数据库原有值)
+black-charging-stations:
+  station-list: "202510211552,202511201459,202512181925"
+
 # 第三方认证配置
 third-party:
   jwt:

+ 10 - 4
src/main/resources/application-test.yml

@@ -51,6 +51,10 @@ spring:
     # 邮件发送者
     from: youlaitech@163.com
 
+# 充电站经纬度同步黑名单(黑名单中的站点不同步经纬度,保留数据库原有值)
+black-charging-stations:
+  station-list: ""
+
 # 第三方认证配置
 third-party:
   jwt:
@@ -125,6 +129,8 @@ security:
     - /charge-business/v1/linkData/**
     - /applet/v1/homePage/** # 用户端分页查询站点信息
     - /third_party/v1/** # 第三方接入接口
+    - /api/v1/agent/mcp/**
+    - /api/v1/agent/approvals/**
   # 只走第三方过滤器、不走其他安全链
   third-party-urls:
     - /charge-business/v1/linkData/notification_start_charge_result
@@ -162,13 +168,13 @@ oss:
   # 阿里云OSS对象存储服务
   aliyun:
     # 服务Endpoint
-    endpoint: oss-cn-beijing.aliyuncs.com
+    endpoint: oss-cn-chengdu.aliyuncs.com
     # 访问凭据`
-    access-key-id: LTAI5tJscqbev7wSugGCrEtt
+    access-key-id: LTAI5tBd18obT6zfRBZqGWiC
     # 凭据密钥
-    access-key-secret: xJkoJR1ILpXNSF2ERnxNq71UZTQNcB
+    access-key-secret: LHsruEf7Y9lLFfdLRTOkQrd2nzZTWx
     # 存储桶名称
-    bucket-name: national-motion
+    bucket-name: zsdd-electric
   # 本地存储
   local:
     # 文件存储路径 请注意下,mac用户请使用 /Users/your-username/your-path/,否则会有权限问题,windows用户请使用 D:/your-path/

+ 1 - 1
src/main/resources/application.yml

@@ -2,7 +2,7 @@ spring:
   application:
     name: zsElectric-boot
   profiles:
-    active: prod
+    active: dev
   config:
     import: classpath:codegen.yml
   servlet:

+ 6 - 1
src/main/resources/mapper/business/ChargeOrderInfoMapper.xml

@@ -54,13 +54,15 @@
         coi.is_deleted,
         pci.connector_id,
         pci.connector_name,
-        CONCAT(tcs.soc,'%') AS soc
+        CONCAT(tcs.soc,'%') AS soc,
+        ctp.ec_name
         FROM
         c_charge_order_info coi
         LEFT JOIN c_user_info ui ON coi.user_id = ui.id
         left JOIN third_party_connector_info pci ON coi.connector_id = pci.connector_id
         LEFT JOIN third_party_station_info psi ON coi.third_party_station_id = psi.id
         LEFT JOIN third_party_charge_status tcs ON coi.start_charge_seq = tcs.start_charge_seq
+        LEFT JOIN c_third_party_info ctp ON coi.operator_id = ctp.operator_id
         <where>
             coi.is_deleted = 0
             <if test="queryParams.equipmentId != null">
@@ -100,6 +102,9 @@
             <if test="queryParams.connectorId != null and queryParams.connectorId != ''">
                 AND coi.connector_id = #{queryParams.connectorId}
             </if>
+            <if test="queryParams.operatorId != null">
+                AND coi.operator_id = #{queryParams.operatorId}
+            </if>
         </where>
         ORDER BY coi.create_time DESC
     </select>

+ 205 - 0
src/test/java/com/zsElectric/boot/platform/agent/service/AgentToolOrchestratorTest.java

@@ -0,0 +1,205 @@
+package com.zsElectric.boot.platform.agent.service;
+
+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.AgentPendingAction;
+import com.zsElectric.boot.platform.agent.tool.AgentToolCatalog;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AgentToolOrchestratorTest {
+
+    private InMemoryAgentApprovalStore store;
+    private RecordingBusinessToolExecutor executor;
+    private AgentToolOrchestrator orchestrator;
+
+    @BeforeEach
+    void setUp() {
+        store = new InMemoryAgentApprovalStore();
+        executor = new RecordingBusinessToolExecutor();
+        orchestrator = new AgentToolOrchestrator(
+                AgentToolCatalog.defaultCatalog(),
+                store,
+                executor,
+                audit -> {
+                },
+                Clock.fixed(Instant.parse("2026-07-08T10:15:30Z"), ZoneId.of("UTC"))
+        );
+    }
+
+    @Test
+    void highRiskRefundCreatesApprovalInsteadOfExecutingImmediately() {
+        AgentToolCallRequest request = request(
+                "trace-001",
+                "request_refund",
+                Map.of("order_no", "CD001", "amount", "12.50", "reason", "充电失败")
+        );
+
+        AgentToolCallResponse response = orchestrator.call(request);
+
+        assertThat(response.isApprovalRequired()).isTrue();
+        assertThat(response.getApprovalId()).startsWith("APR");
+        assertThat(response.getStatus()).isEqualTo("pending_approval");
+        assertThat(executor.calls).isEmpty();
+        assertThat(store.approvals).hasSize(1);
+        assertThat(store.pendingActions).hasSize(1);
+        assertThat(store.pendingActions.get(0).getToolName()).isEqualTo("request_refund");
+        assertThat(store.pendingActions.get(0).getParamsHash()).hasSize(64);
+    }
+
+    @Test
+    void lowRiskWorkOrderExecutesImmediately() {
+        executor.nextResult = Map.of("work_order_no", "WO001", "status", "created");
+        AgentToolCallRequest request = request(
+                "trace-002",
+                "create_aftersales_work_order",
+                Map.of("order_no", "CD002", "issue_type", "charge_failed", "suggestion", "安排客服核查")
+        );
+
+        AgentToolCallResponse response = orchestrator.call(request);
+
+        assertThat(response.isApprovalRequired()).isFalse();
+        assertThat(response.getStatus()).isEqualTo("executed");
+        assertThat(response.getOutput()).containsEntry("work_order_no", "WO001");
+        assertThat(executor.calls).containsExactly("create_aftersales_work_order");
+        assertThat(store.approvals).isEmpty();
+    }
+
+    @Test
+    void approvedActionResumesTheFrozenToolAndMarksRecordsExecuted() {
+        AgentToolCallResponse approval = orchestrator.call(request(
+                "trace-003",
+                "request_refund",
+                Map.of("order_no", "CD003", "amount", "8.00", "reason", "重复扣费")
+        ));
+        orchestrator.approve(approval.getApprovalId(), "1001", "张三", "同意退款");
+        executor.nextResult = Map.of("refund_status", "submitted", "refund_no", "RF001");
+
+        AgentToolCallResponse response = orchestrator.call(request(
+                "trace-003",
+                "resume_after_approval",
+                Map.of("approval_id", approval.getApprovalId())
+        ));
+
+        assertThat(response.getStatus()).isEqualTo("executed");
+        assertThat(response.getOutput()).containsEntry("refund_no", "RF001");
+        assertThat(executor.calls).containsExactly("request_refund");
+        assertThat(store.approvals.get(0).getStatus()).isEqualTo("executed");
+        assertThat(store.pendingActions.get(0).getExecuteStatus()).isEqualTo("executed");
+    }
+
+    @Test
+    void repeatedResumeReturnsHistoricalResultWithoutExecutingAgain() {
+        AgentToolCallResponse approval = orchestrator.call(request(
+                "trace-004",
+                "request_compensation",
+                Map.of("order_no", "CD004", "amount", "5.00", "reason", "服务异常")
+        ));
+        orchestrator.approve(approval.getApprovalId(), "1001", "张三", "同意补偿");
+        executor.nextResult = Map.of("compensation_status", "submitted", "compensation_no", "CP001");
+
+        orchestrator.call(request("trace-004", "resume_after_approval", Map.of("approval_id", approval.getApprovalId())));
+        AgentToolCallResponse repeated = orchestrator.call(request(
+                "trace-004",
+                "resume_after_approval",
+                Map.of("approval_id", approval.getApprovalId())
+        ));
+
+        assertThat(repeated.getStatus()).isEqualTo("executed");
+        assertThat(repeated.getOutput()).containsEntry("compensation_no", "CP001");
+        assertThat(executor.calls).containsExactly("request_compensation");
+    }
+
+    @Test
+    void resumeRejectsTamperedFrozenParams() {
+        AgentToolCallResponse approval = orchestrator.call(request(
+                "trace-005",
+                "request_refund",
+                new LinkedHashMap<>(Map.of("order_no", "CD005", "amount", "3.00", "reason", "异常扣费"))
+        ));
+        orchestrator.approve(approval.getApprovalId(), "1001", "张三", "同意退款");
+        store.pendingActions.get(0).setParamsJson("{\"order_no\":\"CD005\",\"amount\":\"300.00\",\"reason\":\"异常扣费\"}");
+
+        AgentToolCallResponse response = orchestrator.call(request(
+                "trace-005",
+                "resume_after_approval",
+                Map.of("approval_id", approval.getApprovalId())
+        ));
+
+        assertThat(response.getStatus()).isEqualTo("failed");
+        assertThat(response.getErrorMessage()).contains("params_hash");
+        assertThat(executor.calls).isEmpty();
+        assertThat(store.pendingActions.get(0).getExecuteStatus()).isEqualTo("failed");
+    }
+
+    private static AgentToolCallRequest request(String traceId, String toolName, Map<String, Object> params) {
+        AgentToolCallRequest request = new AgentToolCallRequest();
+        request.setTraceId(traceId);
+        request.setToolName(toolName);
+        request.setToolVersion("v1");
+        request.setOperatorId("agent-test");
+        request.setOperatorName("Agent Test");
+        request.setParams(params);
+        return request;
+    }
+
+    private static class InMemoryAgentApprovalStore implements AgentApprovalStore {
+        private final List<AgentApproval> approvals = new ArrayList<>();
+        private final List<AgentPendingAction> pendingActions = new ArrayList<>();
+
+        @Override
+        public void saveApproval(AgentApproval approval) {
+            approvals.add(approval);
+        }
+
+        @Override
+        public void savePendingAction(AgentPendingAction pendingAction) {
+            pendingActions.add(pendingAction);
+        }
+
+        @Override
+        public Optional<AgentApproval> findApproval(String approvalId) {
+            return approvals.stream()
+                    .filter(approval -> approvalId.equals(approval.getApprovalId()))
+                    .findFirst();
+        }
+
+        @Override
+        public Optional<AgentPendingAction> findPendingAction(String approvalId) {
+            return pendingActions.stream()
+                    .filter(action -> approvalId.equals(action.getApprovalId()))
+                    .findFirst();
+        }
+
+        @Override
+        public void updateApproval(AgentApproval approval) {
+        }
+
+        @Override
+        public void updatePendingAction(AgentPendingAction pendingAction) {
+        }
+    }
+
+    private static class RecordingBusinessToolExecutor implements AgentBusinessToolExecutor {
+        private final List<String> calls = new ArrayList<>();
+        private Map<String, Object> nextResult = Map.of("status", "ok");
+
+        @Override
+        public Map<String, Object> execute(String toolName, Map<String, Object> params, AgentToolCallRequest request) {
+            calls.add(toolName);
+            return nextResult;
+        }
+    }
+}