Ver código fonte

feat(agent-chat): 添加Agent聊天消息和意图日志支持

- 新增chat_messages表用于存储Agent前端聊天消息
- 新增agent_intent_log表用于存储Agent意图识别日志
- 实现AgentChatBusinessService业务服务,支持消息和意图日志写入
- 控制消息写入时检查消息幂等性并校验调用用户身份和Trace一致性
- 支持分页查询和用户身份绑定,确保数据安全和调用规范
wzq 1 mês atrás
pai
commit
ac78bbd12e
61 arquivos alterados com 1310 adições e 3440 exclusões
  1. 0 930
      doc/s.md
  2. 0 164
      sql/mysql/agent_aftersales_m1.sql
  3. 42 0
      sql/mysql/agent_chat_m1.sql
  4. 0 55
      sql/postgresql/aftersales_policy_pgvector.sql
  5. 9 0
      src/main/java/com/zsElectric/boot/common/constant/JwtClaimConstants.java
  6. 15 0
      src/main/java/com/zsElectric/boot/platform/agent/config/AgentInternalProperties.java
  7. 0 51
      src/main/java/com/zsElectric/boot/platform/agent/config/AgentToolConfig.java
  8. 20 0
      src/main/java/com/zsElectric/boot/platform/agent/config/AgentWebMvcConfig.java
  9. 0 107
      src/main/java/com/zsElectric/boot/platform/agent/controller/AgentApprovalController.java
  10. 37 0
      src/main/java/com/zsElectric/boot/platform/agent/controller/AgentChatHistoryController.java
  11. 77 0
      src/main/java/com/zsElectric/boot/platform/agent/controller/AgentInternalController.java
  12. 0 68
      src/main/java/com/zsElectric/boot/platform/agent/controller/AgentMcpController.java
  13. 0 12
      src/main/java/com/zsElectric/boot/platform/agent/mapper/AftersalesWorkOrderMapper.java
  14. 0 12
      src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentApprovalMapper.java
  15. 0 12
      src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentAuditLogMapper.java
  16. 9 0
      src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentChatMessageMapper.java
  17. 9 0
      src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentIntentLogMapper.java
  18. 0 12
      src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentPendingActionMapper.java
  19. 0 15
      src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentToolRegistryMapper.java
  20. 0 28
      src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentApprovalDecisionRequest.java
  21. 15 0
      src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentDeductibleRechargeOrderQueryRequest.java
  22. 43 0
      src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentIntentLogWriteRequest.java
  23. 10 0
      src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentIntentLogWriteResponse.java
  24. 34 0
      src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentMessageWriteRequest.java
  25. 13 0
      src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentMessageWriteResponse.java
  26. 13 0
      src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentOrderQueryRequest.java
  27. 0 46
      src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentToolCallRequest.java
  28. 0 107
      src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentToolCallResponse.java
  29. 0 99
      src/main/java/com/zsElectric/boot/platform/agent/model/entity/AftersalesWorkOrder.java
  30. 0 107
      src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentApproval.java
  31. 0 104
      src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentAuditLog.java
  32. 27 0
      src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentChatMessage.java
  33. 31 0
      src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentIntentLog.java
  34. 0 94
      src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentPendingAction.java
  35. 0 90
      src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentToolRegistry.java
  36. 17 0
      src/main/java/com/zsElectric/boot/platform/agent/model/vo/AgentChatMessageVO.java
  37. 21 0
      src/main/java/com/zsElectric/boot/platform/agent/model/vo/AgentDeductibleRechargeOrderVO.java
  38. 29 0
      src/main/java/com/zsElectric/boot/platform/agent/model/vo/AgentOrderQueryVO.java
  39. 0 45
      src/main/java/com/zsElectric/boot/platform/agent/service/AgentApprovalStore.java
  40. 0 17
      src/main/java/com/zsElectric/boot/platform/agent/service/AgentAuditWriter.java
  41. 0 24
      src/main/java/com/zsElectric/boot/platform/agent/service/AgentBusinessToolExecutor.java
  42. 255 0
      src/main/java/com/zsElectric/boot/platform/agent/service/AgentChatBusinessService.java
  43. 0 491
      src/main/java/com/zsElectric/boot/platform/agent/service/AgentToolOrchestrator.java
  44. 0 285
      src/main/java/com/zsElectric/boot/platform/agent/service/DefaultAgentBusinessToolExecutor.java
  45. 0 83
      src/main/java/com/zsElectric/boot/platform/agent/service/MyBatisAgentApprovalStore.java
  46. 0 30
      src/main/java/com/zsElectric/boot/platform/agent/service/MyBatisAgentAuditWriter.java
  47. 0 23
      src/main/java/com/zsElectric/boot/platform/agent/tool/AgentRiskLevel.java
  48. 0 89
      src/main/java/com/zsElectric/boot/platform/agent/tool/AgentToolCatalog.java
  49. 0 25
      src/main/java/com/zsElectric/boot/platform/agent/tool/AgentToolDefinition.java
  50. 11 0
      src/main/java/com/zsElectric/boot/platform/agent/web/AgentHeaders.java
  51. 54 0
      src/main/java/com/zsElectric/boot/platform/agent/web/AgentInternalAuthenticationInterceptor.java
  52. 53 0
      src/main/java/com/zsElectric/boot/platform/agent/web/TraceIdFilter.java
  53. 21 6
      src/main/java/com/zsElectric/boot/security/token/JwtTokenManager.java
  54. 6 2
      src/main/resources/application-dev.yml
  55. 0 2
      src/main/resources/application-test.yml
  56. 5 0
      src/main/resources/application.yml
  57. 228 0
      src/test/java/com/zsElectric/boot/platform/agent/service/AgentChatBusinessServiceTest.java
  58. 0 205
      src/test/java/com/zsElectric/boot/platform/agent/service/AgentToolOrchestratorTest.java
  59. 88 0
      src/test/java/com/zsElectric/boot/platform/agent/web/AgentInternalAuthenticationInterceptorTest.java
  60. 32 0
      src/test/java/com/zsElectric/boot/platform/agent/web/TraceIdFilterTest.java
  61. 86 0
      src/test/java/com/zsElectric/boot/security/token/JwtTokenManagerTest.java

+ 0 - 930
doc/s.md

@@ -1,930 +0,0 @@
-# 企业售后 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、权限、审批、状态恢复、审计和测试案例。

+ 0 - 164
sql/mysql/agent_aftersales_m1.sql

@@ -1,164 +0,0 @@
-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;

+ 42 - 0
sql/mysql/agent_chat_m1.sql

@@ -0,0 +1,42 @@
+CREATE TABLE IF NOT EXISTS chat_messages (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+  message_id CHAR(36) NOT NULL COMMENT '消息UUID,幂等键',
+  thread_id CHAR(36) NOT NULL COMMENT '前端会话UUID',
+  trace_id CHAR(36) NOT NULL COMMENT '本轮Agent链路UUID',
+  parent_message_id CHAR(36) NULL COMMENT '关联的用户消息UUID',
+  user_id BIGINT NOT NULL COMMENT '后台用户ID(sys_user.id)',
+  role VARCHAR(16) NOT NULL COMMENT 'user或assistant',
+  content LONGTEXT NOT NULL COMMENT '前端渲染内容',
+  status VARCHAR(16) NOT NULL DEFAULT 'completed' COMMENT 'completed/failed/interrupted',
+  occurred_at DATETIME(3) NOT NULL COMMENT '消息在Python侧产生时间',
+  create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '入库时间',
+  UNIQUE KEY uk_chat_message_id (message_id),
+  KEY idx_chat_user_thread_time (user_id, thread_id, occurred_at, id),
+  KEY idx_chat_trace (trace_id),
+  CONSTRAINT chk_chat_role CHECK (role IN ('user', 'assistant')),
+  CONSTRAINT chk_chat_status CHECK (status IN ('completed', 'failed', 'interrupted'))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Agent前端聊天消息';
+
+CREATE TABLE IF NOT EXISTS agent_intent_log (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+  intent_log_id CHAR(36) NOT NULL COMMENT '对外意图日志UUID',
+  trace_id CHAR(36) NOT NULL COMMENT '本轮Agent链路UUID',
+  thread_id CHAR(36) NOT NULL COMMENT '前端会话UUID',
+  message_id CHAR(36) NOT NULL COMMENT '关联用户消息UUID',
+  user_id BIGINT NOT NULL COMMENT '后台用户ID(sys_user.id)',
+  intent VARCHAR(32) NOT NULL COMMENT 'chitchat/order_query',
+  confidence DECIMAL(5,4) NOT NULL COMMENT '置信度0到1',
+  classifier_source VARCHAR(32) NOT NULL COMMENT 'LLM/RULE_FALLBACK',
+  model_name VARCHAR(128) NULL COMMENT '分类模型名称',
+  duration_ms BIGINT NULL COMMENT '分类耗时毫秒',
+  status VARCHAR(16) NOT NULL DEFAULT 'success' COMMENT 'success/failed',
+  error_message VARCHAR(1000) NULL COMMENT '失败摘要,不保存用户原文',
+  create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  UNIQUE KEY uk_agent_intent_log_id (intent_log_id),
+  UNIQUE KEY uk_agent_intent_message_user (message_id, user_id),
+  KEY idx_agent_intent_trace (trace_id),
+  KEY idx_agent_intent_thread_time (user_id, thread_id, create_time),
+  CONSTRAINT chk_agent_intent CHECK (intent IN ('chitchat', 'order_query')),
+  CONSTRAINT chk_agent_intent_source CHECK (classifier_source IN ('LLM', 'RULE_FALLBACK')),
+  CONSTRAINT chk_agent_intent_confidence CHECK (confidence >= 0 AND confidence <= 1)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Agent意图识别日志';

+ 0 - 55
sql/postgresql/aftersales_policy_pgvector.sql

@@ -1,55 +0,0 @@
-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);

+ 9 - 0
src/main/java/com/zsElectric/boot/common/constant/JwtClaimConstants.java

@@ -30,6 +30,15 @@ public interface JwtClaimConstants {
      */
     String DATA_SCOPE = "dataScope";
 
+    /**
+     * 登录主体类型,用于解析 Token 时恢复正确的 UserDetails 实现。
+     */
+    String PRINCIPAL_TYPE = "principalType";
+
+    String PRINCIPAL_TYPE_APPLET = "applet";
+
+    String PRINCIPAL_TYPE_SYSTEM = "system";
+
     /**
      * 权限(角色Code)集合
      */

+ 15 - 0
src/main/java/com/zsElectric/boot/platform/agent/config/AgentInternalProperties.java

@@ -0,0 +1,15 @@
+package com.zsElectric.boot.platform.agent.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/** Internal authentication settings shared with the Python Agent service. */
+@Data
+@Component
+@ConfigurationProperties(prefix = "agent.internal")
+public class AgentInternalProperties {
+
+    /** Secret passed in X-Agent-Service-Key. Empty means internal APIs are disabled. */
+    private String serviceKey = "zselectric-local-dev";
+}

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

@@ -1,51 +0,0 @@
-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);
-    }
-}

+ 20 - 0
src/main/java/com/zsElectric/boot/platform/agent/config/AgentWebMvcConfig.java

@@ -0,0 +1,20 @@
+package com.zsElectric.boot.platform.agent.config;
+
+import com.zsElectric.boot.platform.agent.web.AgentInternalAuthenticationInterceptor;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/** Registers authentication checks only for Python-to-Java internal Agent APIs. */
+@Configuration
+@RequiredArgsConstructor
+public class AgentWebMvcConfig implements WebMvcConfigurer {
+
+    private final AgentInternalAuthenticationInterceptor interceptor;
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(interceptor).addPathPatterns("/api/v1/agent/internal/**");
+    }
+}

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

@@ -1,107 +0,0 @@
-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();
-    }
-}

+ 37 - 0
src/main/java/com/zsElectric/boot/platform/agent/controller/AgentChatHistoryController.java

@@ -0,0 +1,37 @@
+package com.zsElectric.boot.platform.agent.controller;
+
+import com.zsElectric.boot.core.web.PageResult;
+import com.zsElectric.boot.platform.agent.model.vo.AgentChatMessageVO;
+import com.zsElectric.boot.platform.agent.service.AgentChatBusinessService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Pattern;
+import lombok.RequiredArgsConstructor;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "Agent聊天历史")
+@Validated
+@RestController
+@RequestMapping("/api/v1/agent/chat")
+@RequiredArgsConstructor
+public class AgentChatHistoryController {
+
+    private final AgentChatBusinessService service;
+
+    @Operation(summary = "分页查询当前后台用户的会话消息")
+    @GetMapping("/threads/{threadId}/messages")
+    public PageResult<AgentChatMessageVO> getHistory(
+            @PathVariable @Pattern(regexp = "[0-9a-fA-F-]{36}") String threadId,
+            @RequestParam(defaultValue = "1") @Min(1) int pageNum,
+            @RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize
+    ) {
+        return service.getHistory(threadId, pageNum, pageSize);
+    }
+}

+ 77 - 0
src/main/java/com/zsElectric/boot/platform/agent/controller/AgentInternalController.java

@@ -0,0 +1,77 @@
+package com.zsElectric.boot.platform.agent.controller;
+
+import com.zsElectric.boot.core.web.Result;
+import com.zsElectric.boot.platform.agent.model.dto.AgentIntentLogWriteRequest;
+import com.zsElectric.boot.platform.agent.model.dto.AgentIntentLogWriteResponse;
+import com.zsElectric.boot.platform.agent.model.dto.AgentMessageWriteRequest;
+import com.zsElectric.boot.platform.agent.model.dto.AgentMessageWriteResponse;
+import com.zsElectric.boot.platform.agent.model.dto.AgentOrderQueryRequest;
+import com.zsElectric.boot.platform.agent.model.dto.AgentDeductibleRechargeOrderQueryRequest;
+import com.zsElectric.boot.platform.agent.model.vo.AgentDeductibleRechargeOrderVO;
+import com.zsElectric.boot.platform.agent.model.vo.AgentOrderQueryVO;
+import com.zsElectric.boot.platform.agent.service.AgentChatBusinessService;
+import com.zsElectric.boot.platform.agent.web.AgentHeaders;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@Tag(name = "Agent内部接口")
+@RestController
+@RequestMapping("/api/v1/agent/internal")
+@RequiredArgsConstructor
+public class AgentInternalController {
+
+    private final AgentChatBusinessService service;
+
+    @Operation(summary = "幂等写入前端聊天消息")
+    @PostMapping("/messages")
+    public Result<AgentMessageWriteResponse> writeMessage(
+            @RequestHeader(AgentHeaders.TRACE_ID) String traceId,
+            @Valid @RequestBody AgentMessageWriteRequest request
+    ) {
+        return Result.success(service.writeMessage(request, traceId));
+    }
+
+    @Operation(summary = "记录Agent意图识别结果")
+    @PostMapping("/intent-logs")
+    public Result<AgentIntentLogWriteResponse> writeIntentLog(
+            @RequestHeader(AgentHeaders.TRACE_ID) String traceId,
+            @Valid @RequestBody AgentIntentLogWriteRequest request
+    ) {
+        return Result.success(service.writeIntentLog(request, traceId));
+    }
+
+    @Operation(summary = "根据用户提供的订单号查询充电订单")
+    @PostMapping("/tools/order-query")
+    public Result<AgentOrderQueryVO> queryOrder(@Valid @RequestBody AgentOrderQueryRequest request) {
+        return Result.success(service.queryOrder(request.getChargeOrderNo()));
+    }
+
+    @Operation(summary = "查询近期10条充电订单")
+    @PostMapping("/tools/charge-orders/recent")
+    public Result<List<AgentOrderQueryVO>> queryRecentChargeOrders() {
+        return Result.success(service.queryRecentChargeOrders());
+    }
+
+    @Operation(summary = "查询近期10条可抵扣余量充值订单")
+    @PostMapping("/tools/deductible-recharge-orders/recent")
+    public Result<List<AgentDeductibleRechargeOrderVO>> queryRecentDeductibleRechargeOrders() {
+        return Result.success(service.queryRecentDeductibleRechargeOrders());
+    }
+
+    @Operation(summary = "根据订单号查询可抵扣余量充值订单详情")
+    @PostMapping("/tools/deductible-recharge-orders/detail")
+    public Result<AgentDeductibleRechargeOrderVO> queryDeductibleRechargeOrder(
+            @Valid @RequestBody AgentDeductibleRechargeOrderQueryRequest request
+    ) {
+        return Result.success(service.queryDeductibleRechargeOrder(request.getOrderNo()));
+    }
+}

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

@@ -1,68 +0,0 @@
-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));
-    }
-}

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

@@ -1,12 +0,0 @@
-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> {
-}

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

@@ -1,12 +0,0 @@
-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> {
-}

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

@@ -1,12 +0,0 @@
-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> {
-}

+ 9 - 0
src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentChatMessageMapper.java

@@ -0,0 +1,9 @@
+package com.zsElectric.boot.platform.agent.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsElectric.boot.platform.agent.model.entity.AgentChatMessage;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AgentChatMessageMapper extends BaseMapper<AgentChatMessage> {
+}

+ 9 - 0
src/main/java/com/zsElectric/boot/platform/agent/mapper/AgentIntentLogMapper.java

@@ -0,0 +1,9 @@
+package com.zsElectric.boot.platform.agent.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsElectric.boot.platform.agent.model.entity.AgentIntentLog;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AgentIntentLogMapper extends BaseMapper<AgentIntentLog> {
+}

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

@@ -1,12 +0,0 @@
-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> {
-}

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

@@ -1,15 +0,0 @@
-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> {
-}

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

@@ -1,28 +0,0 @@
-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;
-}

+ 15 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentDeductibleRechargeOrderQueryRequest.java

@@ -0,0 +1,15 @@
+package com.zsElectric.boot.platform.agent.model.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class AgentDeductibleRechargeOrderQueryRequest {
+
+    @NotBlank
+    @Size(max = 64)
+    @Pattern(regexp = "[A-Za-z0-9_\\-|*]+")
+    private String orderNo;
+}

+ 43 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentIntentLogWriteRequest.java

@@ -0,0 +1,43 @@
+package com.zsElectric.boot.platform.agent.model.dto;
+
+import jakarta.validation.constraints.DecimalMax;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class AgentIntentLogWriteRequest {
+
+    @NotBlank
+    @Size(max = 64)
+    private String traceId;
+    @NotBlank
+    @Size(max = 64)
+    private String threadId;
+    @NotBlank
+    @Size(max = 64)
+    private String messageId;
+    @NotBlank
+    @Pattern(regexp = "chitchat|order_query")
+    private String intent;
+    @NotNull
+    @DecimalMin("0.0")
+    @DecimalMax("1.0")
+    private BigDecimal confidence;
+    @NotBlank
+    @Pattern(regexp = "LLM|RULE_FALLBACK")
+    private String classifierSource;
+    @Size(max = 128)
+    private String modelName;
+    private Long durationMs;
+    @NotBlank
+    @Pattern(regexp = "success|failed")
+    private String status;
+    @Size(max = 1000)
+    private String errorMessage;
+}

+ 10 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentIntentLogWriteResponse.java

@@ -0,0 +1,10 @@
+package com.zsElectric.boot.platform.agent.model.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+@Data
+@AllArgsConstructor
+public class AgentIntentLogWriteResponse {
+    private String intentLogId;
+}

+ 34 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentMessageWriteRequest.java

@@ -0,0 +1,34 @@
+package com.zsElectric.boot.platform.agent.model.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class AgentMessageWriteRequest {
+
+    @NotBlank
+    @Size(max = 64)
+    private String messageId;
+    @NotBlank
+    @Size(max = 64)
+    private String threadId;
+    @NotBlank
+    @Size(max = 64)
+    private String traceId;
+    @Size(max = 64)
+    private String parentMessageId;
+    @NotBlank
+    @Pattern(regexp = "user|assistant")
+    private String role;
+    @NotBlank
+    @Size(max = 16000)
+    private String content;
+    @NotBlank
+    @Pattern(regexp = "completed|failed|interrupted")
+    private String status;
+    private LocalDateTime occurredAt;
+}

+ 13 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentMessageWriteResponse.java

@@ -0,0 +1,13 @@
+package com.zsElectric.boot.platform.agent.model.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+@Data
+@AllArgsConstructor
+public class AgentMessageWriteResponse {
+    private String messageId;
+    private String threadId;
+    private Long userId;
+    private boolean duplicate;
+}

+ 13 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/dto/AgentOrderQueryRequest.java

@@ -0,0 +1,13 @@
+package com.zsElectric.boot.platform.agent.model.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+@Data
+public class AgentOrderQueryRequest {
+
+    @NotBlank
+    @Pattern(regexp = "(?i)CD\\d{30}")
+    private String chargeOrderNo;
+}

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

@@ -1,46 +0,0 @@
-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<>();
-}

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

@@ -1,107 +0,0 @@
-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;
-    }
-}

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

@@ -1,99 +0,0 @@
-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;
-}

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

@@ -1,107 +0,0 @@
-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;
-}

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

@@ -1,104 +0,0 @@
-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;
-}

+ 27 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentChatMessage.java

@@ -0,0 +1,27 @@
+package com.zsElectric.boot.platform.agent.model.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/** Frontend-facing chat message stored independently from LangGraph checkpoints. */
+@Data
+@TableName("chat_messages")
+public class AgentChatMessage {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String messageId;
+    private String threadId;
+    private String traceId;
+    private String parentMessageId;
+    private Long userId;
+    private String role;
+    private String content;
+    private String status;
+    private LocalDateTime occurredAt;
+    private LocalDateTime createTime;
+}

+ 31 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/entity/AgentIntentLog.java

@@ -0,0 +1,31 @@
+package com.zsElectric.boot.platform.agent.model.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/** Auditable intent-classification result for one user turn. */
+@Data
+@TableName("agent_intent_log")
+public class AgentIntentLog {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String intentLogId;
+    private String traceId;
+    private String threadId;
+    private String messageId;
+    private Long userId;
+    private String intent;
+    private BigDecimal confidence;
+    private String classifierSource;
+    private String modelName;
+    private Long durationMs;
+    private String status;
+    private String errorMessage;
+    private LocalDateTime createTime;
+}

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

@@ -1,94 +0,0 @@
-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;
-}

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

@@ -1,90 +0,0 @@
-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;
-}

+ 17 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/vo/AgentChatMessageVO.java

@@ -0,0 +1,17 @@
+package com.zsElectric.boot.platform.agent.model.vo;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class AgentChatMessageVO {
+    private String messageId;
+    private String threadId;
+    private String traceId;
+    private String parentMessageId;
+    private String role;
+    private String content;
+    private String status;
+    private LocalDateTime occurredAt;
+}

+ 21 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/vo/AgentDeductibleRechargeOrderVO.java

@@ -0,0 +1,21 @@
+package com.zsElectric.boot.platform.agent.model.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/** Safe projection of an order that purchases deductible charging balance. */
+@Data
+public class AgentDeductibleRechargeOrderVO {
+    private String orderNo;
+    private BigDecimal orderMoney;
+    private BigDecimal remainingMoney;
+    private LocalDateTime payTime;
+    private Integer orderStatus;
+    private Integer orderType;
+    private BigDecimal refundMoney;
+    private LocalDateTime refundTime;
+    private String remark;
+    private LocalDateTime createTime;
+}

+ 29 - 0
src/main/java/com/zsElectric/boot/platform/agent/model/vo/AgentOrderQueryVO.java

@@ -0,0 +1,29 @@
+package com.zsElectric.boot.platform.agent.model.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/** Deliberately reduced charging-order view for the backend Agent. */
+@Data
+public class AgentOrderQueryVO {
+    private String chargeOrderNo;
+    private String equipmentId;
+    private String connectorId;
+    private String connectorName;
+    private String stationName;
+    private String startTime;
+    private String endTime;
+    private Integer chargeTime;
+    private Integer status;
+    private BigDecimal totalCharge;
+    private BigDecimal realCost;
+    private BigDecimal realServiceCost;
+    private Integer stopType;
+    private String stopReason;
+    private String soc;
+    private BigDecimal discountMoney;
+    private BigDecimal couponPrice;
+    private LocalDateTime createTime;
+}

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

@@ -1,45 +0,0 @@
-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);
-}

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

@@ -1,17 +0,0 @@
-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);
-}

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

@@ -1,24 +0,0 @@
-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);
-}

+ 255 - 0
src/main/java/com/zsElectric/boot/platform/agent/service/AgentChatBusinessService.java

@@ -0,0 +1,255 @@
+package com.zsElectric.boot.platform.agent.service;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zsElectric.boot.business.model.query.ChargeOrderInfoQuery;
+import com.zsElectric.boot.business.model.query.UserOrderInfoQuery;
+import com.zsElectric.boot.business.model.vo.ChargeOrderInfoVO;
+import com.zsElectric.boot.business.model.vo.UserOrderInfoVO;
+import com.zsElectric.boot.business.service.ChargeOrderInfoService;
+import com.zsElectric.boot.business.service.UserOrderInfoService;
+import com.zsElectric.boot.core.exception.BusinessException;
+import com.zsElectric.boot.core.web.PageResult;
+import com.zsElectric.boot.core.web.ResultCode;
+import com.zsElectric.boot.platform.agent.mapper.AgentChatMessageMapper;
+import com.zsElectric.boot.platform.agent.mapper.AgentIntentLogMapper;
+import com.zsElectric.boot.platform.agent.model.dto.AgentIntentLogWriteRequest;
+import com.zsElectric.boot.platform.agent.model.dto.AgentIntentLogWriteResponse;
+import com.zsElectric.boot.platform.agent.model.dto.AgentMessageWriteRequest;
+import com.zsElectric.boot.platform.agent.model.dto.AgentMessageWriteResponse;
+import com.zsElectric.boot.platform.agent.model.entity.AgentChatMessage;
+import com.zsElectric.boot.platform.agent.model.entity.AgentIntentLog;
+import com.zsElectric.boot.platform.agent.model.vo.AgentChatMessageVO;
+import com.zsElectric.boot.platform.agent.model.vo.AgentDeductibleRechargeOrderVO;
+import com.zsElectric.boot.platform.agent.model.vo.AgentOrderQueryVO;
+import com.zsElectric.boot.security.model.SysUserDetails;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+
+/** Business boundary used by Python Agent endpoints and frontend history. */
+@Service
+@RequiredArgsConstructor
+public class AgentChatBusinessService {
+
+    private final AgentChatMessageMapper chatMessageMapper;
+    private final AgentIntentLogMapper intentLogMapper;
+    private final ChargeOrderInfoService chargeOrderInfoService;
+    private final UserOrderInfoService userOrderInfoService;
+
+    @Transactional(rollbackFor = Exception.class)
+    public AgentMessageWriteResponse writeMessage(AgentMessageWriteRequest request, String headerTraceId) {
+        Long userId = currentBackendUserId();
+        requireTraceMatch(request.getTraceId(), headerTraceId);
+        AgentChatMessage existing = chatMessageMapper.selectOne(Wrappers.<AgentChatMessage>lambdaQuery()
+                .eq(AgentChatMessage::getMessageId, request.getMessageId())
+                .last("limit 1"));
+        if (existing != null) {
+            boolean sameRequest = Objects.equals(existing.getUserId(), userId)
+                    && Objects.equals(existing.getThreadId(), request.getThreadId())
+                    && Objects.equals(existing.getRole(), request.getRole())
+                    && Objects.equals(existing.getContent(), request.getContent())
+                    && Objects.equals(existing.getParentMessageId(), request.getParentMessageId())
+                    && Objects.equals(existing.getStatus(), request.getStatus());
+            if (!sameRequest) {
+                throw new BusinessException(ResultCode.USER_DUPLICATE_REQUEST, "message_id已被其他消息使用");
+            }
+            return new AgentMessageWriteResponse(existing.getMessageId(), existing.getThreadId(), userId, true);
+        }
+
+        AgentChatMessage entity = new AgentChatMessage();
+        entity.setMessageId(request.getMessageId());
+        entity.setThreadId(request.getThreadId());
+        entity.setTraceId(request.getTraceId());
+        entity.setParentMessageId(request.getParentMessageId());
+        entity.setUserId(userId);
+        entity.setRole(request.getRole());
+        entity.setContent(request.getContent());
+        entity.setStatus(request.getStatus());
+        entity.setOccurredAt(request.getOccurredAt() == null ? LocalDateTime.now() : request.getOccurredAt());
+        entity.setCreateTime(LocalDateTime.now());
+        chatMessageMapper.insert(entity);
+        return new AgentMessageWriteResponse(entity.getMessageId(), entity.getThreadId(), userId, false);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public AgentIntentLogWriteResponse writeIntentLog(AgentIntentLogWriteRequest request, String headerTraceId) {
+        Long userId = currentBackendUserId();
+        requireTraceMatch(request.getTraceId(), headerTraceId);
+        AgentChatMessage message = chatMessageMapper.selectOne(Wrappers.<AgentChatMessage>lambdaQuery()
+                .eq(AgentChatMessage::getMessageId, request.getMessageId())
+                .eq(AgentChatMessage::getUserId, userId)
+                .eq(AgentChatMessage::getThreadId, request.getThreadId())
+                .last("limit 1"));
+        if (message == null) {
+            throw new BusinessException(ResultCode.USER_RESOURCE_NOT_FOUND, "意图日志关联的用户消息不存在");
+        }
+        AgentIntentLog existing = intentLogMapper.selectOne(Wrappers.<AgentIntentLog>lambdaQuery()
+                .eq(AgentIntentLog::getMessageId, request.getMessageId())
+                .eq(AgentIntentLog::getUserId, userId)
+                .last("limit 1"));
+        if (existing != null) {
+            return new AgentIntentLogWriteResponse(existing.getIntentLogId());
+        }
+
+        AgentIntentLog entity = new AgentIntentLog();
+        entity.setIntentLogId(UUID.randomUUID().toString());
+        entity.setTraceId(request.getTraceId());
+        entity.setThreadId(request.getThreadId());
+        entity.setMessageId(request.getMessageId());
+        entity.setUserId(userId);
+        entity.setIntent(request.getIntent());
+        entity.setConfidence(request.getConfidence() == null ? BigDecimal.ZERO : request.getConfidence());
+        entity.setClassifierSource(request.getClassifierSource());
+        entity.setModelName(request.getModelName());
+        entity.setDurationMs(request.getDurationMs());
+        entity.setStatus(request.getStatus());
+        entity.setErrorMessage(request.getErrorMessage());
+        entity.setCreateTime(LocalDateTime.now());
+        intentLogMapper.insert(entity);
+        return new AgentIntentLogWriteResponse(entity.getIntentLogId());
+    }
+
+    public AgentOrderQueryVO queryOrder(String chargeOrderNo) {
+        currentBackendUserId();
+        String normalizedOrderNo = chargeOrderNo.toUpperCase();
+        ChargeOrderInfoVO source = chargeOrderInfoService.queryOrder(normalizedOrderNo);
+        if (source == null) {
+            throw new BusinessException(ResultCode.USER_RESOURCE_NOT_FOUND, "未查询到该订单");
+        }
+        return toSafeOrder(source);
+    }
+
+    public List<AgentOrderQueryVO> queryRecentChargeOrders() {
+        currentBackendUserId();
+        ChargeOrderInfoQuery query = new ChargeOrderInfoQuery();
+        query.setPageNum(1);
+        query.setPageSize(10);
+        IPage<ChargeOrderInfoVO> page = chargeOrderInfoService.getChargeOrderInfoPage(query);
+        return page.getRecords().stream().map(this::toSafeOrder).toList();
+    }
+
+    public List<AgentDeductibleRechargeOrderVO> queryRecentDeductibleRechargeOrders() {
+        currentBackendUserId();
+        UserOrderInfoQuery query = new UserOrderInfoQuery();
+        query.setPageNum(1);
+        query.setPageSize(10);
+        IPage<UserOrderInfoVO> page = userOrderInfoService.getUserOrderInfoPage(query);
+        return page.getRecords().stream().map(this::toSafeRechargeOrder).toList();
+    }
+
+    public AgentDeductibleRechargeOrderVO queryDeductibleRechargeOrder(String orderNo) {
+        currentBackendUserId();
+        String normalizedOrderNo = orderNo.matches("(?i)SP\\d{30}")
+                ? orderNo.toUpperCase()
+                : orderNo;
+        UserOrderInfoQuery query = new UserOrderInfoQuery();
+        query.setOrderNo(normalizedOrderNo);
+        query.setPageNum(1);
+        query.setPageSize(1);
+        IPage<UserOrderInfoVO> page = userOrderInfoService.getUserOrderInfoPage(query);
+        if (page.getRecords().isEmpty()) {
+            throw new BusinessException(ResultCode.USER_RESOURCE_NOT_FOUND, "未查询到该可抵扣余量充值订单");
+        }
+        return toSafeRechargeOrder(page.getRecords().get(0));
+    }
+
+    public PageResult<AgentChatMessageVO> getHistory(String threadId, int pageNum, int pageSize) {
+        Long userId = currentBackendUserId();
+        int safePageNum = Math.max(1, pageNum);
+        int safePageSize = Math.min(100, Math.max(1, pageSize));
+        Page<AgentChatMessage> page = chatMessageMapper.selectPage(
+                new Page<>(safePageNum, safePageSize),
+                Wrappers.<AgentChatMessage>lambdaQuery()
+                        .eq(AgentChatMessage::getUserId, userId)
+                        .eq(AgentChatMessage::getThreadId, threadId)
+                        .orderByDesc(AgentChatMessage::getOccurredAt)
+                        .orderByDesc(AgentChatMessage::getId)
+        );
+        List<AgentChatMessage> entities = new ArrayList<>(page.getRecords());
+        Collections.reverse(entities);
+        List<AgentChatMessageVO> records = entities.stream().map(this::toMessageVO).toList();
+        Page<AgentChatMessageVO> voPage = new Page<>(safePageNum, safePageSize, page.getTotal());
+        voPage.setRecords(records);
+        return PageResult.success(voPage);
+    }
+
+    private AgentOrderQueryVO toSafeOrder(ChargeOrderInfoVO source) {
+        AgentOrderQueryVO target = new AgentOrderQueryVO();
+        target.setChargeOrderNo(source.getChargeOrderNo());
+        target.setEquipmentId(source.getEquipmentId());
+        target.setConnectorId(source.getConnectorId());
+        target.setConnectorName(source.getConnectorName());
+        target.setStationName(source.getStationName());
+        target.setStartTime(source.getStartTime());
+        target.setEndTime(source.getEndTime());
+        target.setChargeTime(source.getChargeTime());
+        target.setStatus(source.getStatus());
+        target.setTotalCharge(source.getTotalCharge());
+        target.setRealCost(source.getRealCost());
+        target.setRealServiceCost(source.getRealServiceCost());
+        target.setStopType(source.getStopType());
+        target.setStopReason(source.getStopReason());
+        target.setSoc(source.getSoc());
+        target.setDiscountMoney(source.getDiscountMoney());
+        target.setCouponPrice(source.getCouponPrice());
+        target.setCreateTime(source.getCreateTime());
+        return target;
+    }
+
+    private AgentDeductibleRechargeOrderVO toSafeRechargeOrder(UserOrderInfoVO source) {
+        AgentDeductibleRechargeOrderVO target = new AgentDeductibleRechargeOrderVO();
+        target.setOrderNo(source.getOrderNo());
+        target.setOrderMoney(source.getOrderMoney());
+        BigDecimal refundMoney = source.getRefundMoney() == null ? BigDecimal.ZERO : source.getRefundMoney();
+        target.setRemainingMoney(
+                source.getOrderMoney() == null ? null : source.getOrderMoney().subtract(refundMoney)
+        );
+        target.setPayTime(source.getPayTime());
+        target.setOrderStatus(source.getOrderStatus());
+        target.setOrderType(source.getOrderType());
+        target.setRefundMoney(source.getRefundMoney());
+        target.setRefundTime(source.getRefundTime());
+        target.setRemark(source.getRemark());
+        target.setCreateTime(source.getCreateTime());
+        return target;
+    }
+
+    private AgentChatMessageVO toMessageVO(AgentChatMessage source) {
+        AgentChatMessageVO target = new AgentChatMessageVO();
+        target.setMessageId(source.getMessageId());
+        target.setThreadId(source.getThreadId());
+        target.setTraceId(source.getTraceId());
+        target.setParentMessageId(source.getParentMessageId());
+        target.setRole(source.getRole());
+        target.setContent(source.getContent());
+        target.setStatus(source.getStatus());
+        target.setOccurredAt(source.getOccurredAt());
+        return target;
+    }
+
+    private void requireTraceMatch(String bodyTraceId, String headerTraceId) {
+        if (!Objects.equals(bodyTraceId, headerTraceId)) {
+            throw new BusinessException(ResultCode.PARAMETER_FORMAT_MISMATCH, "请求体traceId与X-Trace-Id不一致");
+        }
+    }
+
+    private Long currentBackendUserId() {
+        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+        if (authentication == null || !(authentication.getPrincipal() instanceof SysUserDetails user)) {
+            throw new BusinessException(ResultCode.ACCESS_UNAUTHORIZED, "仅允许已登录的后台用户访问");
+        }
+        return user.getUserId();
+    }
+}

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

@@ -1,491 +0,0 @@
-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);
-    }
-}

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

@@ -1,285 +0,0 @@
-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);
-        }
-    }
-}

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

@@ -1,83 +0,0 @@
-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);
-    }
-}

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

@@ -1,30 +0,0 @@
-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);
-    }
-}

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

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

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

@@ -1,89 +0,0 @@
-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;
-    }
-}

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

@@ -1,25 +0,0 @@
-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
-) {
-}

+ 11 - 0
src/main/java/com/zsElectric/boot/platform/agent/web/AgentHeaders.java

@@ -0,0 +1,11 @@
+package com.zsElectric.boot.platform.agent.web;
+
+/** HTTP header names used between the Python Agent and Java business service. */
+public final class AgentHeaders {
+
+    public static final String TRACE_ID = "X-Trace-Id";
+    public static final String SERVICE_KEY = "X-Agent-Service-Key";
+
+    private AgentHeaders() {
+    }
+}

+ 54 - 0
src/main/java/com/zsElectric/boot/platform/agent/web/AgentInternalAuthenticationInterceptor.java

@@ -0,0 +1,54 @@
+package com.zsElectric.boot.platform.agent.web;
+
+import com.zsElectric.boot.core.web.ResultCode;
+import com.zsElectric.boot.core.web.WebResponseHelper;
+import com.zsElectric.boot.platform.agent.config.AgentInternalProperties;
+import com.zsElectric.boot.security.model.SysUserDetails;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+
+/** Requires a backend-user JWT, service key, and caller-provided trace id. */
+@Component
+@RequiredArgsConstructor
+public class AgentInternalAuthenticationInterceptor implements HandlerInterceptor {
+
+    private final AgentInternalProperties properties;
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+        if (authentication == null || !(authentication.getPrincipal() instanceof SysUserDetails)) {
+            WebResponseHelper.writeError(response, ResultCode.ACCESS_UNAUTHORIZED, "仅允许已登录的后台用户访问");
+            return false;
+        }
+        String configured = properties.getServiceKey();
+        String provided = request.getHeader(AgentHeaders.SERVICE_KEY);
+        if (configured == null || configured.isBlank() || !constantTimeEquals(configured, provided)) {
+            WebResponseHelper.writeError(response, ResultCode.ACCESS_UNAUTHORIZED, "Agent服务凭证无效");
+            return false;
+        }
+        if (TraceIdFilter.validUuid(request.getHeader(AgentHeaders.TRACE_ID)) == null) {
+            WebResponseHelper.writeError(response, ResultCode.PARAMETER_FORMAT_MISMATCH, "X-Trace-Id必须是UUID");
+            return false;
+        }
+        return true;
+    }
+
+    private boolean constantTimeEquals(String expected, String actual) {
+        if (actual == null) {
+            return false;
+        }
+        return MessageDigest.isEqual(
+                expected.getBytes(StandardCharsets.UTF_8),
+                actual.getBytes(StandardCharsets.UTF_8)
+        );
+    }
+}

+ 53 - 0
src/main/java/com/zsElectric/boot/platform/agent/web/TraceIdFilter.java

@@ -0,0 +1,53 @@
+package com.zsElectric.boot.platform.agent.web;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.MDC;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.UUID;
+
+/** Binds one validated trace id to the request, response, and logging MDC. */
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE)
+public class TraceIdFilter extends OncePerRequestFilter {
+
+    public static final String REQUEST_ATTRIBUTE = TraceIdFilter.class.getName() + ".traceId";
+
+    @Override
+    protected void doFilterInternal(
+            HttpServletRequest request,
+            HttpServletResponse response,
+            FilterChain filterChain
+    ) throws ServletException, IOException {
+        String traceId = validUuid(request.getHeader(AgentHeaders.TRACE_ID));
+        if (traceId == null) {
+            traceId = UUID.randomUUID().toString();
+        }
+        request.setAttribute(REQUEST_ATTRIBUTE, traceId);
+        response.setHeader(AgentHeaders.TRACE_ID, traceId);
+        MDC.put("trace_id", traceId);
+        try {
+            filterChain.doFilter(request, response);
+        } finally {
+            MDC.remove("trace_id");
+        }
+    }
+
+    public static String validUuid(String value) {
+        if (value == null || value.isBlank()) {
+            return null;
+        }
+        try {
+            return UUID.fromString(value).toString();
+        } catch (IllegalArgumentException ignored) {
+            return null;
+        }
+    }
+}

+ 21 - 6
src/main/java/com/zsElectric/boot/security/token/JwtTokenManager.java

@@ -90,18 +90,31 @@ public class JwtTokenManager implements TokenManager {
 
         JWT jwt = JWTUtil.parseToken(token);
         JSONObject payloads = jwt.getPayloads();
-        SysUserDetails userDetails = new SysUserDetails();
-        userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); // 用户ID
-        userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); // 部门ID
-        userDetails.setDataScope(payloads.getInt(JwtClaimConstants.DATA_SCOPE)); // 数据权限范围
-
-        userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); // 用户名
         // 角色集合
         Set<SimpleGrantedAuthority> authorities = payloads.getJSONArray(JwtClaimConstants.AUTHORITIES)
                 .stream()
                 .map(authority -> new SimpleGrantedAuthority(Convert.toStr(authority)))
                 .collect(Collectors.toSet());
+        String principalType = payloads.getStr(JwtClaimConstants.PRINCIPAL_TYPE);
+        boolean isAppletUser = JwtClaimConstants.PRINCIPAL_TYPE_APPLET.equals(principalType)
+                // 兼容修复前签发、尚未过期的小程序 Token。
+                || (StrUtil.isBlank(principalType) && authorities.stream()
+                .anyMatch(authority -> "ROLE_APPLET_USER".equals(authority.getAuthority())));
 
+        if (isAppletUser) {
+            AppletUserDetails userDetails = new AppletUserDetails();
+            userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID));
+            userDetails.setPhone(payloads.getStr(JWTPayload.SUBJECT));
+            userDetails.setEnabled(true);
+            return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
+        }
+
+        SysUserDetails userDetails = new SysUserDetails();
+        userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID));
+        userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID));
+        userDetails.setDataScope(payloads.getInt(JwtClaimConstants.DATA_SCOPE));
+        userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT));
+        userDetails.setAuthorities(authorities);
         return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
     }
 
@@ -263,6 +276,7 @@ public class JwtTokenManager implements TokenManager {
         if (principal instanceof AppletUserDetails) {
             // 小程序用户
             AppletUserDetails appletUser = (AppletUserDetails) principal;
+            payload.put(JwtClaimConstants.PRINCIPAL_TYPE, JwtClaimConstants.PRINCIPAL_TYPE_APPLET);
             payload.put(JwtClaimConstants.USER_ID, appletUser.getUserId()); // 用户ID (c_user_info表)
             payload.put(JwtClaimConstants.DEPT_ID, null); // 小程序用户无部门
             payload.put(JwtClaimConstants.DATA_SCOPE, null); // 小程序用户无数据权限范围
@@ -271,6 +285,7 @@ public class JwtTokenManager implements TokenManager {
         } else if (principal instanceof SysUserDetails) {
             // 系统用户
             SysUserDetails sysUser = (SysUserDetails) principal;
+            payload.put(JwtClaimConstants.PRINCIPAL_TYPE, JwtClaimConstants.PRINCIPAL_TYPE_SYSTEM);
             payload.put(JwtClaimConstants.USER_ID, sysUser.getUserId()); // 用户ID (sys_user表)
             payload.put(JwtClaimConstants.DEPT_ID, sysUser.getDeptId()); // 部门ID
             payload.put(JwtClaimConstants.DATA_SCOPE, sysUser.getDataScope()); // 数据权限范围

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

@@ -129,8 +129,6 @@ 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
@@ -364,3 +362,9 @@ ai:
   rate-limit:
     max-executions-per-minute: 10
     max-executions-per-day: 100
+
+# 本地 Python Agent 与 Java 内部接口共享的服务密钥。
+# 生产环境仍必须通过 AGENT_SERVICE_KEY 显式配置。
+agent:
+  internal:
+    service-key: ${AGENT_SERVICE_KEY:zselectric-local-dev}

+ 0 - 2
src/main/resources/application-test.yml

@@ -129,8 +129,6 @@ 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

+ 5 - 0
src/main/resources/application.yml

@@ -12,3 +12,8 @@ spring:
 # 在 banner.txt 中显示项目版本,使用 @project.version@ 从 pom.xml 获取
 project:
   version: @project.version@
+
+# Python Agent internal API authentication. Production must set AGENT_SERVICE_KEY.
+agent:
+  internal:
+    service-key: ${AGENT_SERVICE_KEY:}

+ 228 - 0
src/test/java/com/zsElectric/boot/platform/agent/service/AgentChatBusinessServiceTest.java

@@ -0,0 +1,228 @@
+package com.zsElectric.boot.platform.agent.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zsElectric.boot.business.model.vo.ChargeOrderInfoVO;
+import com.zsElectric.boot.business.model.vo.UserOrderInfoVO;
+import com.zsElectric.boot.business.service.ChargeOrderInfoService;
+import com.zsElectric.boot.business.service.UserOrderInfoService;
+import com.zsElectric.boot.core.exception.BusinessException;
+import com.zsElectric.boot.platform.agent.mapper.AgentChatMessageMapper;
+import com.zsElectric.boot.platform.agent.mapper.AgentIntentLogMapper;
+import com.zsElectric.boot.platform.agent.model.dto.AgentMessageWriteRequest;
+import com.zsElectric.boot.platform.agent.model.dto.AgentMessageWriteResponse;
+import com.zsElectric.boot.platform.agent.model.dto.AgentIntentLogWriteRequest;
+import com.zsElectric.boot.platform.agent.model.dto.AgentIntentLogWriteResponse;
+import com.zsElectric.boot.platform.agent.model.entity.AgentChatMessage;
+import com.zsElectric.boot.platform.agent.model.entity.AgentIntentLog;
+import com.zsElectric.boot.platform.agent.model.vo.AgentOrderQueryVO;
+import com.zsElectric.boot.security.model.SysUserDetails;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.ArgumentCaptor;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.util.Set;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AgentChatBusinessServiceTest {
+
+    @Mock
+    private AgentChatMessageMapper chatMessageMapper;
+    @Mock
+    private AgentIntentLogMapper intentLogMapper;
+    @Mock
+    private ChargeOrderInfoService chargeOrderInfoService;
+    @Mock
+    private UserOrderInfoService userOrderInfoService;
+
+    private AgentChatBusinessService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new AgentChatBusinessService(
+                chatMessageMapper,
+                intentLogMapper,
+                chargeOrderInfoService,
+                userOrderInfoService
+        );
+        SysUserDetails user = new SysUserDetails();
+        user.setUserId(42L);
+        user.setUsername("admin");
+        user.setEnabled(true);
+        user.setAuthorities(Set.of(new SimpleGrantedAuthority("ROLE_ADMIN")));
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities())
+        );
+    }
+
+    @AfterEach
+    void tearDown() {
+        SecurityContextHolder.clearContext();
+    }
+
+    @Test
+    void duplicateMessageReturnsExistingRecordWithoutInsert() {
+        AgentMessageWriteRequest request = messageRequest();
+        AgentChatMessage existing = new AgentChatMessage();
+        existing.setMessageId(request.getMessageId());
+        existing.setThreadId(request.getThreadId());
+        existing.setTraceId(request.getTraceId());
+        existing.setUserId(42L);
+        existing.setRole(request.getRole());
+        existing.setContent(request.getContent());
+        existing.setStatus(request.getStatus());
+        request.setTraceId("e31031a3-a5ea-44e7-89f8-c52664ddce35");
+        when(chatMessageMapper.selectOne(any())).thenReturn(existing);
+
+        AgentMessageWriteResponse response = service.writeMessage(request, request.getTraceId());
+
+        assertTrue(response.isDuplicate());
+        assertEquals(42L, response.getUserId());
+        verify(chatMessageMapper, never()).insert(any());
+    }
+
+    @Test
+    void orderLookupReturnsSafeProjection() {
+        String orderNo = "CD" + "1".repeat(30);
+        ChargeOrderInfoVO source = new ChargeOrderInfoVO();
+        source.setUserId(1001L);
+        source.setChargeOrderNo(orderNo);
+        source.setStationName("测试站");
+        when(chargeOrderInfoService.queryOrder(orderNo)).thenReturn(source);
+
+        AgentOrderQueryVO result = service.queryOrder(orderNo.toLowerCase());
+
+        assertEquals(orderNo, result.getChargeOrderNo());
+        assertEquals("测试站", result.getStationName());
+    }
+
+    @Test
+    void missingOrderReturnsNotFoundFailure() {
+        String orderNo = "CD" + "2".repeat(30);
+        when(chargeOrderInfoService.queryOrder(orderNo)).thenReturn(null);
+
+        assertThrows(BusinessException.class,
+                () -> service.queryOrder(orderNo));
+    }
+
+    @Test
+    void orderBelongingToAnyCustomerCanBeQueriedByBackendUser() {
+        String orderNo = "CD" + "3".repeat(30);
+        ChargeOrderInfoVO source = new ChargeOrderInfoVO();
+        source.setUserId(99L);
+        source.setChargeOrderNo(orderNo);
+        when(chargeOrderInfoService.queryOrder(orderNo)).thenReturn(source);
+
+        AgentOrderQueryVO result = service.queryOrder(orderNo);
+
+        assertEquals(orderNo, result.getChargeOrderNo());
+    }
+
+    @Test
+    void recentChargeOrdersReturnsAtMostServicePageRecords() {
+        ChargeOrderInfoVO source = new ChargeOrderInfoVO();
+        source.setChargeOrderNo("CD" + "4".repeat(30));
+        source.setStationName("近期充电站");
+        Page<ChargeOrderInfoVO> page = new Page<>(1, 10);
+        page.setRecords(List.of(source));
+        when(chargeOrderInfoService.getChargeOrderInfoPage(any())).thenReturn(page);
+
+        List<AgentOrderQueryVO> result = service.queryRecentChargeOrders();
+
+        assertEquals(1, result.size());
+        assertEquals("近期充电站", result.get(0).getStationName());
+    }
+
+    @Test
+    void recentDeductibleRechargeOrdersReturnsSafeProjection() {
+        UserOrderInfoVO source = new UserOrderInfoVO();
+        source.setOrderNo("SP" + "5".repeat(30));
+        source.setOrderMoney(new java.math.BigDecimal("100.00"));
+        Page<UserOrderInfoVO> page = new Page<>(1, 10);
+        page.setRecords(List.of(source));
+        when(userOrderInfoService.getUserOrderInfoPage(any())).thenReturn(page);
+
+        var result = service.queryRecentDeductibleRechargeOrders();
+
+        assertEquals(1, result.size());
+        assertEquals(new java.math.BigDecimal("100.00"), result.get(0).getOrderMoney());
+        assertEquals(new java.math.BigDecimal("100.00"), result.get(0).getRemainingMoney());
+    }
+
+    @Test
+    void deductibleRechargeOrderDetailUsesProvidedOrderNumber() {
+        String orderNo = "SP" + "6".repeat(30);
+        UserOrderInfoVO source = new UserOrderInfoVO();
+        source.setOrderNo(orderNo);
+        Page<UserOrderInfoVO> page = new Page<>(1, 1);
+        page.setRecords(List.of(source));
+        when(userOrderInfoService.getUserOrderInfoPage(any())).thenReturn(page);
+
+        var result = service.queryDeductibleRechargeOrder(orderNo.toLowerCase());
+
+        assertEquals(orderNo, result.getOrderNo());
+    }
+
+    @Test
+    void missingDeductibleRechargeOrderReturnsNotFoundFailure() {
+        Page<UserOrderInfoVO> page = new Page<>(1, 1);
+        page.setRecords(List.of());
+        when(userOrderInfoService.getUserOrderInfoPage(any())).thenReturn(page);
+
+        assertThrows(BusinessException.class,
+                () -> service.queryDeductibleRechargeOrder("SP" + "7".repeat(30)));
+    }
+
+    @Test
+    void intentLogGetsJavaGeneratedIdAndLinksPersistedUserMessage() {
+        AgentIntentLogWriteRequest request = new AgentIntentLogWriteRequest();
+        request.setTraceId("b49e6de6-6914-4cd2-abd9-8648e8cf9e38");
+        request.setThreadId("729d8cf1-9170-426d-87a4-d013fdb2c780");
+        request.setMessageId("a4ff2748-c1c5-4e04-9060-4cbe7346f12b");
+        request.setIntent("chitchat");
+        request.setConfidence(new java.math.BigDecimal("0.9500"));
+        request.setClassifierSource("LLM");
+        request.setModelName("test-model");
+        request.setStatus("success");
+        AgentChatMessage userMessage = new AgentChatMessage();
+        userMessage.setMessageId(request.getMessageId());
+        userMessage.setUserId(42L);
+        when(chatMessageMapper.selectOne(any())).thenReturn(userMessage);
+        when(intentLogMapper.selectOne(any())).thenReturn(null);
+
+        AgentIntentLogWriteResponse response = service.writeIntentLog(request, request.getTraceId());
+
+        assertNotNull(response.getIntentLogId());
+        ArgumentCaptor<AgentIntentLog> captor = ArgumentCaptor.forClass(AgentIntentLog.class);
+        verify(intentLogMapper).insert(captor.capture());
+        assertEquals(request.getMessageId(), captor.getValue().getMessageId());
+        assertEquals(42L, captor.getValue().getUserId());
+    }
+
+    private AgentMessageWriteRequest messageRequest() {
+        AgentMessageWriteRequest request = new AgentMessageWriteRequest();
+        request.setMessageId("a4ff2748-c1c5-4e04-9060-4cbe7346f12b");
+        request.setThreadId("729d8cf1-9170-426d-87a4-d013fdb2c780");
+        request.setTraceId("b49e6de6-6914-4cd2-abd9-8648e8cf9e38");
+        request.setRole("user");
+        request.setContent("查询订单");
+        request.setStatus("completed");
+        return request;
+    }
+}

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

@@ -1,205 +0,0 @@
-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;
-        }
-    }
-}

+ 88 - 0
src/test/java/com/zsElectric/boot/platform/agent/web/AgentInternalAuthenticationInterceptorTest.java

@@ -0,0 +1,88 @@
+package com.zsElectric.boot.platform.agent.web;
+
+import com.zsElectric.boot.platform.agent.config.AgentInternalProperties;
+import com.zsElectric.boot.security.model.AppletUserDetails;
+import com.zsElectric.boot.security.model.SysUserDetails;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.util.Set;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AgentInternalAuthenticationInterceptorTest {
+
+    private AgentInternalAuthenticationInterceptor interceptor;
+
+    @BeforeEach
+    void setUp() {
+        AgentInternalProperties properties = new AgentInternalProperties();
+        properties.setServiceKey("test-service-key");
+        interceptor = new AgentInternalAuthenticationInterceptor(properties);
+        SysUserDetails user = new SysUserDetails();
+        user.setUserId(7L);
+        user.setUsername("admin");
+        user.setEnabled(true);
+        user.setAuthorities(Set.of(new SimpleGrantedAuthority("ROLE_ADMIN")));
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities())
+        );
+    }
+
+    @AfterEach
+    void tearDown() {
+        SecurityContextHolder.clearContext();
+    }
+
+    @Test
+    void missingServiceKeyIsRejected() {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.addHeader(AgentHeaders.TRACE_ID, UUID.randomUUID().toString());
+
+        assertFalse(interceptor.preHandle(request, new MockHttpServletResponse(), new Object()));
+    }
+
+    @Test
+    void missingBackendJwtPrincipalIsRejected() {
+        SecurityContextHolder.clearContext();
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.addHeader(AgentHeaders.SERVICE_KEY, "test-service-key");
+        request.addHeader(AgentHeaders.TRACE_ID, UUID.randomUUID().toString());
+
+        assertFalse(interceptor.preHandle(request, new MockHttpServletResponse(), new Object()));
+    }
+
+    @Test
+    void appletJwtPrincipalIsRejected() {
+        AppletUserDetails appletUser = new AppletUserDetails(
+                8L, "13800000000", "openid", "tester", true
+        );
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(
+                        appletUser, null, appletUser.getAuthorities()
+                )
+        );
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.addHeader(AgentHeaders.SERVICE_KEY, "test-service-key");
+        request.addHeader(AgentHeaders.TRACE_ID, UUID.randomUUID().toString());
+
+        assertFalse(interceptor.preHandle(request, new MockHttpServletResponse(), new Object()));
+    }
+
+    @Test
+    void jwtPrincipalServiceKeyAndTraceIdAreAllRequiredAndAccepted() {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.addHeader(AgentHeaders.SERVICE_KEY, "test-service-key");
+        request.addHeader(AgentHeaders.TRACE_ID, UUID.randomUUID().toString());
+
+        assertTrue(interceptor.preHandle(request, new MockHttpServletResponse(), new Object()));
+    }
+}

+ 32 - 0
src/test/java/com/zsElectric/boot/platform/agent/web/TraceIdFilterTest.java

@@ -0,0 +1,32 @@
+package com.zsElectric.boot.platform.agent.web;
+
+import org.junit.jupiter.api.Test;
+import org.slf4j.MDC;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class TraceIdFilterTest {
+
+    @Test
+    void validTraceIdIsBoundToMdcAndResponseThenCleared() throws Exception {
+        String traceId = UUID.randomUUID().toString();
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.addHeader(AgentHeaders.TRACE_ID, traceId);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        AtomicReference<String> traceInsideChain = new AtomicReference<>();
+
+        new TraceIdFilter().doFilter(request, response,
+                (servletRequest, servletResponse) -> traceInsideChain.set(MDC.get("trace_id")));
+
+        assertEquals(traceId, traceInsideChain.get());
+        assertEquals(traceId, response.getHeader(AgentHeaders.TRACE_ID));
+        assertEquals(traceId, request.getAttribute(TraceIdFilter.REQUEST_ATTRIBUTE));
+        assertNull(MDC.get("trace_id"));
+    }
+}

+ 86 - 0
src/test/java/com/zsElectric/boot/security/token/JwtTokenManagerTest.java

@@ -0,0 +1,86 @@
+package com.zsElectric.boot.security.token;
+
+import cn.hutool.jwt.JWTUtil;
+import com.zsElectric.boot.common.constant.JwtClaimConstants;
+import com.zsElectric.boot.config.property.SecurityProperties;
+import com.zsElectric.boot.security.model.AppletUserDetails;
+import com.zsElectric.boot.security.model.AuthenticationToken;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.mockito.Mockito.mock;
+
+class JwtTokenManagerTest {
+
+    private static final String SECRET = "01234567890123456789012345678901";
+
+    private JwtTokenManager tokenManager;
+
+    @BeforeEach
+    void setUp() {
+        SecurityProperties.JwtConfig jwt = new SecurityProperties.JwtConfig();
+        jwt.setSecretKey(SECRET);
+        SecurityProperties.SessionConfig session = new SecurityProperties.SessionConfig();
+        session.setType("jwt");
+        session.setJwt(jwt);
+        session.setAccessTokenTimeToLive(3600);
+        session.setRefreshTokenTimeToLive(7200);
+        SecurityProperties properties = new SecurityProperties();
+        properties.setSession(session);
+
+        @SuppressWarnings("unchecked")
+        RedisTemplate<String, Object> redisTemplate = mock(RedisTemplate.class);
+        tokenManager = new JwtTokenManager(properties, redisTemplate);
+    }
+
+    @Test
+    void generatedAppletTokenParsesBackToAppletPrincipal() {
+        AppletUserDetails appletUser = new AppletUserDetails();
+        appletUser.setUserId(20128L);
+        appletUser.setPhone("15285063223");
+        appletUser.setEnabled(true);
+        Authentication authentication = new UsernamePasswordAuthenticationToken(
+                appletUser,
+                "",
+                appletUser.getAuthorities()
+        );
+
+        AuthenticationToken token = tokenManager.generateToken(authentication);
+        Authentication parsed = tokenManager.parseToken(token.getAccessToken());
+
+        AppletUserDetails principal = assertInstanceOf(AppletUserDetails.class, parsed.getPrincipal());
+        assertEquals(20128L, principal.getUserId());
+        assertEquals("15285063223", principal.getPhone());
+    }
+
+    @Test
+    void legacyAppletTokenWithoutPrincipalTypeRemainsUsable() {
+        Map<String, Object> payload = new HashMap<>();
+        payload.put(JwtClaimConstants.USER_ID, 20128L);
+        payload.put(JwtClaimConstants.AUTHORITIES, Set.of("ROLE_APPLET_USER"));
+        payload.put("sub", "15285063223");
+        payload.put("jti", "legacy-applet-token");
+        payload.put("iat", new Date());
+        payload.put("exp", new Date(System.currentTimeMillis() + 60_000));
+        String token = JWTUtil.createToken(
+                payload,
+                SECRET.getBytes(StandardCharsets.UTF_8)
+        );
+
+        Authentication parsed = tokenManager.parseToken(token);
+
+        AppletUserDetails principal = assertInstanceOf(AppletUserDetails.class, parsed.getPrincipal());
+        assertEquals(20128L, principal.getUserId());
+    }
+}