feat: 修复订单查询和物流查询功能

主要修改:

1. 订单数据解析修复 (agent/agents/order.py)
   - 修复 Mall API 返回数据的嵌套结构解析
   - 更新字段映射:orderId→order_id, orderProduct→items, statusText→status_text
   - 支持多种商品图片字段:image, pic, thumb, productImg
   - 添加详细的调试日志

2. 物流查询修复 (mcp_servers/order_mcp/server.py)
   - 修复物流接口返回数据结构解析 (data[].trackingCode→tracking_number)
   - 添加 print() 日志用于调试
   - 支持多种字段名映射

3. Chatwoot 集成优化 (agent/integrations/chatwoot.py)
   - 添加 json 模块导入
   - 完善订单卡片和表单展示功能

4. API 请求头优化 (mcp_servers/shared/mall_client.py)
   - 更新 User-Agent 和 Accept 头
   - 修正 Origin 和 Referer 大小写

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
wangliang
2026-01-20 19:10:21 +08:00
parent 6b6172d8f0
commit e8e89601a5
5 changed files with 1098 additions and 73 deletions

556
CLAUDE.md Normal file
View File

@@ -0,0 +1,556 @@
# B2B Shopping AI Assistant - Claude 开发上下文
> 本文件记录项目架构、技术决策、最近修改历史,便于 Claude Code 继续开发维护
---
## 项目概览
**项目名称**: B2B Shopping AI Assistant
**技术栈**: Python + LangGraph + FastMCP + Chatwoot + Docker
**AI 模型**: 智谱 AI GLM-4-Flash
**主要功能**: 订单查询、产品咨询、售后服务、FAQ 智能问答
---
## 服务架构
```
┌─────────────────────────────────────────────────────────────┐
│ 客户端渠道 │
│ (Website Widget / API / Chatwoot) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Chatwoot (消息平台) │
│ http://localhost:3000 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ LangGraph Agent (AI 代理层) │
│ http://localhost:8000 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Router │ │ Product │ │ Order │ │ Aftersale│ │
│ │ Agent │ │ Agent │ │ Agent │ │ Agent │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ MCP Servers (工具服务) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Strapi │ │ Order │ │ Aftersale│ │ Product │ │
│ │ MCP │ │ MCP │ │ MCP │ │ MCP │ │
│ │ :8001 │ │ :8002 │ │ :8003 │ │ :8004 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 后端服务 (Hyperf PHP) │
│ https://api.qa1.gaia888.com │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Orders │ │ Products │ │ Aftersales│ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## 目录结构
```
ai/
├── agent/ # AI Agent 主服务
│ ├── agents/ # 各业务 Agent 实现
│ │ ├── router.py # 路由 Agent意图识别
│ │ ├── customer_service.py # 客服 AgentFAQ、公司信息
│ │ ├── order.py # 订单 Agent
│ │ ├── product.py # 产品 Agent
│ │ └── aftersale.py # 售后 Agent
│ ├── core/ # 核心框架
│ │ ├── state.py # AgentState 状态管理
│ │ ├── graph.py # LangGraph 工作流
│ │ ├── llm.py # LLM 客户端(智谱 AI
│ │ └── language_detector.py # 多语言检测
│ ├── integrations/ # 第三方集成
│ │ ├── chatwoot.py # Chatwoot API 客户端
│ │ └── hyperf_client.py # Hyperf API 客户端
│ ├── prompts/ # 提示词模板
│ ├── utils/ # 工具函数
│ │ ├── logger.py # 日志工具
│ │ ├── faq_library.py # 本地 FAQ 库(仅社交问候)
│ │ ├── response_cache.py # Redis 响应缓存
│ │ ├── cache.py # Redis 缓存客户端
│ │ └── token_manager.py # Token 管理
│ ├── webhooks/ # Webhook 处理
│ │ └── chatwoot_webhook.py # Chatwoot Webhook 接收
│ ├── config.py # 配置管理
│ └── main.py # FastAPI 应用入口
├── mcp_servers/ # MCP 服务集群
│ ├── strapi_mcp/ # 知识库 MCP (端口 8001)
│ ├── order_mcp/ # 订单 MCP (端口 8002)
│ ├── aftersale_mcp/ # 售后 MCP (端口 8003)
│ ├── product_mcp/ # 产品 MCP (端口 8004)
│ └── shared/ # 共享模块
├── docker-compose.yml # Docker 编排配置
├── nginx.conf # Nginx 配置
├── .env # 环境变量
└── postgres-with-pgvector.Dockerfile # PostgreSQL Dockerfile
```
---
## 重要配置文件
### 环境变量 (.env)
```env
# AI 模型
ZHIPU_API_KEY=87ecd9d88ef549bc817e1feeba226a3b.hfbTWh4Hhdw8Kulh
ZHIPU_MODEL=GLM-4-Flash-250414
ENABLE_REASONING_MODE=false
REASONING_MODE_FOR_COMPLEX=true
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
# Chatwoot
CHATWOOT_API_URL=http://chatwoot:3000
CHATWOOT_API_TOKEN=fnWaEeAyC1gw1FYQq6YJMWSj
CHATWOOT_ACCOUNT_ID=2
CHATWOOT_WEBHOOK_SECRET=b7a12b9c9173718596f02fd912fb59f97891a0e7abb1a5e457b4c8858b2d21b5
# Hyperf API
HYPERF_API_URL=https://api.qa1.gaia888.com
HYPERF_API_TOKEN=
# Mall API
MALL_API_URL=https://apicn.qa1.gaia888.com
MALL_TENANT_ID=2
MALL_CURRENCY_CODE=EUR
MALL_LANGUAGE_ID=1
MALL_SOURCE=us.qa1.gaia888.com
```
### 服务端口映射
| 服务 | 容器名 | 内部端口 | 外部端口 | 说明 |
|------|--------|----------|----------|------|
| Agent | ai_agent | 8000 | 8000 | AI 代理服务 |
| Chatwoot | ai_chatwoot | 3000 | 3000 | 客服平台 |
| Redis | ai_redis | 6379 | - | 缓存/队列 |
| PostgreSQL | ai_postgres | 5432 | - | Chatwoot 数据库 |
| Nginx | ai_nginx | 80 | 8080 | 文档静态服务 |
| Strapi MCP | ai_strapi_mcp | 8001 | 8001 | 知识库服务 |
| Order MCP | ai_order_mcp | 8002 | 8002 | 订单服务 |
| Aftersale MCP | ai_aftersale_mcp | 8003 | 8003 | 售后服务 |
| Product MCP | ai_product_mcp | 8004 | 8004 | 产品服务 |
---
## 关键代码文件
### 1. Agent 核心流程
**agent/core/graph.py:156-188**
- LangGraph 状态图定义
- Agent 路由逻辑
- 条件边和工具调用流程
**agent/core/state.py:16-67**
- AgentState 数据结构
- ConversationState 枚举
- 核心状态字段定义
### 2. Router - 意图识别
**agent/agents/router.py:38-57**
- FAQ 快速路径优化(在 LLM 前检查本地 FAQ
- 多语言意图识别
**agent/agents/router.py:73-147**
- LLM 意图分类 prompt
- 支持意图customer_service, order, product, aftersale
### 3. Customer Service Agent
**agent/agents/customer_service.py:40-64**
- Router 级 FAQ 响应复用
- 本地 FAQ 库备份检查
**agent/agents/customer_service.py:72-164**
- 多语言分类关键词8 种语言en, nl, de, es, fr, it, tr, zh
- 自动查询 FAQ 分类register, order, payment, shipment, return
### 4. Chatwoot 集成
**agent/integrations/chatwoot.py:228-384**
- `send_order_form()` - 使用 content_type="form" 发送订单详情
- `send_order_card()` - 使用 content_type="cards" 发送订单卡片
- `send_rich_message()` - 通用富媒体消息发送
**agent/webhooks/chatwoot_webhook.py**
- Webhook 接收处理
- 消息格式转换
- 并发会话管理
### 5. FAQ 系统
**agent/utils/faq_library.py:15-49**
- 本地 FAQ 库(仅包含社交问候:你好、谢谢、再见等)
- 业务 FAQ 由 Strapi MCP 处理
- 支持精确匹配和模糊匹配
---
## Context7 MCP 集成
### 什么是 Context7
**Context7 MCP** 是一个为 AI 编码助手和 LLM 提供实时、版本特定的代码文档和示例的服务。它解决了 AI 模型训练数据过时的问题,确保 AI 始终使用最新的官方文档。
**核心功能**
- **实时文档访问**: 从 GitHub 仓库获取最新的官方文档
- **版本特定**: 支持查询特定版本(如 React 19、Next.js 15的文档
- **多语言支持**: 支持 100+ 主流库前端、后端、数据库、AI/ML
- **向量搜索**: 使用语义搜索找到最相关的文档片段
### 配置方式
#### 本项目配置命令
```bash
claude mcp add --transport http context7 https://mcp.context7.com/mcp \
--header "CONTEXT7_API_KEY: ctx7sk-9be917f9-9086-4d85-a3d3-23555aaa2da6"
```
#### API 密钥获取
1. 访问 [Upstash Console](https://console.upstash.com/)
2. 创建新项目或选择现有项目
3. 导航至 "Context7" 标签
4. 复制 API 密钥(格式:`ctx7sk-<uuid>`
### 可用工具
#### 1. `get-documentation-context`
获取文档上下文(主要工具)
**参数**:
```json
{
"library_id": "react", // 库 ID (必填)
"query": "如何使用 useEffect", // 查询问题 (必填)
"version": "19.0.0" // 指定版本 (可选)
}
```
#### 2. `get-docs`
获取库的完整文档列表
**参数**:
```json
{
"library_id": "nextjs",
"version": "15.0.0"
}
```
#### 3. `resolve-library-id`
从库名称/URL 获取标准化的 library_id
**参数**:
```json
{
"identifier": "react" // 或 GitHub URL、npm 包名等
}
```
**返回**:
```json
{
"library_id": "react",
"available_versions": ["18.3.1", "19.0.0", "19.1.0"]
}
```
#### 4. `search-code`
在文档中搜索代码示例
**参数**:
```json
{
"query": "useEffect cleanup",
"libraries": ["react", "nextjs"],
"limit": 5
}
```
### 支持的库(部分)
| 类别 | 库 |
|------|-----|
| 前端框架 | React, Vue, Next.js, Nuxt, Svelte |
| 后端框架 | FastAPI, Django, Express, Laravel |
| 数据库 | PostgreSQL, MySQL, MongoDB, Redis |
| 工具库 | Lodash, Axios, Moment.js |
| AI/ML | LangChain, TensorFlow, PyTorch |
### 使用场景
**在开发中使用 Context7**:
- 查询 LangGraph 最新 API 文档
- 获取 FastAPI 特定版本的代码示例
- 了解 Docker Compose 最新配置选项
**示例**:
```
用户: LangGraph 如何定义条件边?
Claude: [调用 get-documentation-context]
根据 LangGraph 官方文档,条件边使用 add_conditional_edges()...
```
### 相关链接
- [Context7 GitHub 仓库](https://github.com/upstash/context7)
- [Context7 API 文档](https://context7.mintlify.app/api-reference/context/get-documentation-context)
- [Upstash Console](https://console.upstash.com/)
---
## 最近修改历史
### 2026-01-20 - Form 格式订单详情
**修改文件**: `agent/integrations/chatwoot.py`
**修改内容**:
- 新增 `send_order_form()` 方法,使用 `content_type="form"` 展示订单详情
- 支持字段类型text, text_area, select
- 订单字段映射:订单号、状态、下单时间、商品列表、总金额、运费、物流信息、备注
**API 参考**:
- [Create New Message - Chatwoot API](https://developers.chatwoot.com/api-reference/messages/create-new-message)
- [Interactive Messages - Chatwoot User Guide](https://www.chatwoot.com/hc/user-guide/articles/1677689344-how-to-use-interactive-messages)
**数据结构**:
```json
{
"content": "订单详情",
"content_type": "form",
"content_attributes": {
"items": [
{
"name": "order_id",
"label": "订单号",
"type": "text",
"default": "123456789"
}
]
}
}
```
### 2026-01-20 - 多语言 FAQ 分类支持
**修改文件**: `agent/agents/customer_service.py:72-164`
**修改内容**:
- 为 5 个业务分类添加 8 种语言关键词en, nl, de, es, fr, it, tr, zh
- 分类register, order, payment, shipment, return
**示例**:
```python
category_keywords = {
"register": [
"register", "sign up", "account", # English
"注册", "账号", "登录", "密码", # Chinese
# ... 其他语言
]
}
```
### 2026-01-20 - FAQ 架构优化
**修改文件**:
- `agent/agents/router.py:38-57`
- `agent/utils/faq_library.py:15-49`
**问题**: 本地 FAQ 库包含业务相关问答,导致返回硬编码数据而非 Strapi CMS 数据
**解决方案**:
1. 本地 FAQ 库仅保留社交问候18 个关键词)
2. Router 层添加 FAQ 快速路径检查
3. 业务查询自动调用 Strapi MCP 工具
**本地 FAQ 覆盖范围**:
- 问候类你好、hi、hello、hey
- 感谢类谢谢、thank you、thanks
- 再见类再见、bye、goodbye
- 时间问候早上好、下午好、晚上好、good morning
### 2026-01-20 - Chatwoot PID 文件修复
**修改文件**: `docker-compose.yml:64-68`
**问题**: Chatwoot 容器因残留 PID 文件陷入重启循环
**解决方案**: 在启动命令中自动清理 PID 文件
```yaml
chatwoot:
command: sh -c "rm -f /app/tmp/pids/server.pid && bundle exec rails s -p 3000 -b 0.0.0.0"
```
### 2026-01-20 - Docker 镜像拉取优化
**修改文件**: `/etc/docker/daemon.json`
**问题**: Docker Hub 超时导致镜像拉取失败
**解决方案**: 添加中国镜像源
```json
{
"registry-mirrors": [
"https://docker.m.daocloud.io",
"https://docker.1panel.live",
"https://dockerhub.icu"
]
}
```
---
## 常见操作
### 启动服务
```bash
# 启动所有服务
docker-compose up -d
# 查看服务状态
docker-compose ps
# 查看日志
docker-compose logs -f agent
# 重启单个服务
docker-compose restart agent
```
### 环境变量生效
```bash
# 修改 .env 后,需要重启相关服务
# Agent 服务依赖的环境变量ZHIPU_*, REDIS_*, CHATWOOT_*, STRAPI_*, HYPERF_*
docker-compose restart agent
# Chatwoot 服务依赖的环境变量CHATWOOT_*, POSTGRES_*, REDIS_*
docker-compose restart chatwoot chatwoot_worker
# MCP 服务依赖的环境变量HYPERF_*, STRAPI_*
docker-compose restart strapi_mcp order_mcp aftersale_mcp product_mcp
```
### Git 提交
```bash
# 查看状态
git status
# 添加文件(排除测试文件)
git add agent/integrations/chatwoot.py
# 提交
git commit -m "feat: 添加订单详情 form 格式展示支持"
# 推送
git push origin main
```
---
## 技术决策记录
### 1. 为什么使用 LangGraph
- **状态图工作流**: 支持 Agent 之间的条件跳转和循环
- **内置检查点**: 支持 conversation history 的持久化
- **可视化调试**: 可以导出 Mermaid 图表查看工作流
### 2. 为什么使用 MCP (Model Context Protocol)
- **解耦**: Agent 与业务逻辑分离
- **复用**: MCP 工具可被多个 Agent 共享
- **标准化**: 统一的工具调用接口
### 3. 为什么 FAQ 分两级?
- **本地 FAQ 库**: 处理社交问候(你好、谢谢等),避免 LLM 调用
- **Strapi MCP**: 处理业务 FAQ注册、订单等确保数据一致性
### 4. 为什么 Router 层检查 FAQ
- **性能优化**: 跳过 LLM 意图识别,直接返回
- **成本降低**: 减少 LLM API 调用
- **用户体验**: 即时响应常见问候
### 5. 为什么集成 Context7 MCP
- **解决文档过时**: AI 模型训练数据截止到 2023 年,无法获取最新 API
- **版本特定**: 支持查询特定版本的文档(如 LangGraph 0.3 vs 0.2
- **代码示例**: 提供实际可运行的代码片段,减少 AI 幻觉
- **开发效率**: 减少查文档时间,专注于业务逻辑
---
## 已知问题
### 1. Form 类型数据展示
**问题**: Chatwoot 的 form 类型主要用于用户输入,用于只读展示时字段可编辑
**当前方案**: 使用 `default` 属性预填值,用户可以看到但技术上可修改
**未来改进**:
- 研究是否支持只读模式
- 或考虑使用 cards + JSON 格式展示结构化数据
### 2. Token 传递
**问题**: 部分 MCP 工具调用可能未正确传递 Chatwoot conversation ID
**当前方案**: 在 AgentState 中维护 conversation_id
---
## 参考文档
### 项目相关
- [LangGraph 文档](https://langchain-ai.github.io/langgraph/)
- [FastMCP 文档](https://github.com/jlowin/fastmcp)
- [Chatwoot API 文档](https://developers.chatwoot.com/)
- [智谱 AI API 文档](https://open.bigmodel.cn/dev/api)
### Context7 相关
- [Context7 GitHub 仓库](https://github.com/upstash/context7)
- [Context7 API 文档](https://context7.mintlify.app/api-reference/context/get-documentation-context)
- [Upstash Console](https://console.upstash.com/)
- [Context7 MCP 教程](https://dev.to/mehmetakar/context7-mcp-tutorial-3he2)
### 外部参考
- [热门 MCP Server 详解 - 火山引擎](https://www.volcengine.com/docs/86677/2165252)
- [Context7 MCP 为 Cursor 提供实时文档上下文 - 知乎](https://zhuanlan.zhihu.com/p/1914959847792804088)
- [Context7 MCP Server 介绍 - 二师兄的博客](https://www.cloudesx.com/article/Context7-mcp-server)
- [AI 编程焕新:用 Context7 - 腾讯云](https://cloud.tencent.com/developer/article/2528436)
---
**最后更新**: 2026-01-20
**维护者**: Claude Code

View File

@@ -7,6 +7,7 @@ from typing import Any
from core.state import AgentState, ConversationState, add_tool_call, set_response, update_context from core.state import AgentState, ConversationState, add_tool_call, set_response, update_context
from core.llm import get_llm_client, Message from core.llm import get_llm_client, Message
from utils.logger import get_logger from utils.logger import get_logger
from integrations.chatwoot import ChatwootClient
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -362,19 +363,267 @@ async def order_agent(state: AgentState) -> AgentState:
async def _generate_order_response(state: AgentState) -> AgentState: async def _generate_order_response(state: AgentState) -> AgentState:
"""Generate response based on order tool results""" """Generate response based on order tool results"""
# 解析订单数据并尝试使用 form 格式发送
order_data = None
user_message = ""
logistics_data = None
for result in state["tool_results"]:
if result["success"]:
data = result["data"]
tool_name = result["tool_name"]
# 提取订单ID到上下文
if isinstance(data, dict):
if data.get("order_id"):
state = update_context(state, {"order_id": data["order_id"]})
elif data.get("orders") and len(data["orders"]) > 0:
state = update_context(state, {"order_id": data["orders"][0].get("order_id")})
# 处理 get_mall_order 返回的订单数据
if tool_name == "get_mall_order" and isinstance(data, dict):
# MCP 返回结构: {"success": true, "result": {...}}
# result 可能包含: {"success": bool, "order": {...}, "order_id": "...", "error": "..."}
mcp_result = data.get("result", {})
# 检查是否有错误(如未登录)
if mcp_result.get("error") or not mcp_result.get("success"):
logger.warning(
"get_mall_order returned error",
error=mcp_result.get("error"),
require_login=mcp_result.get("require_login")
)
# 设置错误消息到状态中
if mcp_result.get("require_login"):
user_message = mcp_result.get("error", "请先登录账户以查询订单信息")
elif mcp_result.get("order"):
# 有订单数据
order_data = _parse_mall_order_data(mcp_result["order"])
# 如果 order_data 中没有 order_id从外层获取
if not order_data.get("order_id") and mcp_result.get("order_id"):
order_data["order_id"] = mcp_result["order_id"]
else:
logger.warning(
"get_mall_order returned success but no order data",
data_keys=list(data.keys()),
result_keys=list(mcp_result.keys()) if isinstance(mcp_result, dict) else None
)
# 处理 query_order 返回的订单数据
elif tool_name == "query_order" and isinstance(data, dict):
if data.get("orders") and len(data["orders"]) > 0:
order_data = _parse_order_data(data["orders"][0])
if len(data["orders"]) > 1:
user_message = f"找到 {len(data['orders'])} 个订单,显示最新的一个:"
# 处理 get_logistics 返回的物流数据
elif tool_name == "get_logistics" and isinstance(data, dict):
logistics_data = _parse_logistics_data(data)
# 如果之前有订单数据,添加物流信息
if order_data:
order_data["logistics"] = logistics_data
# 尝试使用 Chatwoot cards 格式发送
if order_data:
try:
# 检查是否有有效的 order_id
if not order_data.get("order_id"):
logger.warning(
"No valid order_id in order_data, falling back to text response",
order_data=order_data
)
return await _generate_text_response(state)
chatwoot = ChatwootClient()
conversation_id = state.get("conversation_id")
if conversation_id:
# 记录订单数据(用于调试)
logger.info(
"Preparing to send order card",
conversation_id=conversation_id,
order_id=order_data.get("order_id"),
items_count=len(order_data.get("items", []))
)
# 发送订单卡片(使用默认的"查看订单详情"按钮)
await chatwoot.send_order_card(
conversation_id=conversation_id,
order_data=order_data
)
logger.info(
"Order card sent successfully",
conversation_id=conversation_id,
order_id=order_data.get("order_id")
)
# 设置确认消息
response_text = user_message or "订单详情如下"
state = set_response(state, response_text)
state["state"] = ConversationState.GENERATING.value
return state
except Exception as e:
logger.error(
"Failed to send order card, falling back to text response",
error=str(e),
order_id=order_data.get("order_id")
)
# 降级处理:使用原来的 LLM 生成逻辑
return await _generate_text_response(state)
def _parse_mall_order_data(data: dict[str, Any]) -> dict[str, Any]:
"""解析商城 API 返回的订单数据"""
# 记录原始数据结构(用于调试)
logger.info(
"Parsing mall order data",
data_keys=list(data.keys()),
has_order_id=bool(data.get("order_id")),
has_order_sn=bool(data.get("order_sn")),
has_nested_order=bool(data.get("order")),
order_id_preview=data.get("order_id", data.get("order_sn", "")),
# 如果有 order 字段,记录其内容类型和键
nested_order_type=type(data.get("order")).__name__ if data.get("order") else None,
nested_order_keys=list(data.get("order", {}).keys()) if isinstance(data.get("order"), dict) else None
)
# Mall API 返回结构:外层包含 userId, reqContext 等,实际的订单数据在 order 字段中
# 如果有嵌套的 order 字段,提取出来
actual_order_data = data.get("order", data) if data.get("order") else data
# 记录提取的订单数据结构(用于调试)
logger.info(
"Extracted order data structure",
actual_order_keys=list(actual_order_data.keys()) if isinstance(actual_order_data, dict) else type(actual_order_data).__name__,
has_items=bool(actual_order_data.get("items")),
has_order_items=bool(actual_order_data.get("order_items")),
has_products=bool(actual_order_data.get("products")),
has_orderProduct=bool(actual_order_data.get("orderProduct")),
has_orderGoods=bool(actual_order_data.get("orderGoods")),
has_goods=bool(actual_order_data.get("goods"))
)
order_data = {
"order_id": actual_order_data.get("orderId", actual_order_data.get("order_id", actual_order_data.get("order_sn", ""))),
"status": actual_order_data.get("orderStatusId", actual_order_data.get("status", "unknown")),
"status_text": actual_order_data.get("statusText", actual_order_data.get("status_text", actual_order_data.get("status", ""))),
"total_amount": actual_order_data.get("total", actual_order_data.get("total_amount", actual_order_data.get("order_amount", "0.00"))),
"shipping_fee": actual_order_data.get("shipping_fee", actual_order_data.get("freight_amount", "0")),
}
# 下单时间
if actual_order_data.get("created_at"):
order_data["created_at"] = actual_order_data["created_at"]
elif actual_order_data.get("add_time"):
order_data["created_at"] = actual_order_data["add_time"]
elif actual_order_data.get("dateAdded"):
order_data["created_at"] = actual_order_data["dateAdded"]
# 商品列表 - 尝试多种可能的字段名(优先 orderProduct
items = (
actual_order_data.get("orderProduct") or
actual_order_data.get("items") or
actual_order_data.get("order_items") or
actual_order_data.get("products") or
actual_order_data.get("orderGoods") or
actual_order_data.get("goods") or
[]
)
# 记录商品列表数据结构(用于调试)
if items and len(items) > 0:
first_item = items[0]
logger.info(
"First item structure",
first_item_keys=list(first_item.keys()) if isinstance(first_item, dict) else type(first_item).__name__,
has_image_url=bool(first_item.get("image_url")) if isinstance(first_item, dict) else False,
has_image=bool(first_item.get("image")) if isinstance(first_item, dict) else False,
has_pic=bool(first_item.get("pic")) if isinstance(first_item, dict) else False,
sample_item_data=str(first_item)[:500] if isinstance(first_item, dict) else str(first_item)
)
if items:
order_data["items"] = []
for item in items:
item_data = {
"name": item.get("name", item.get("productName", item.get("product_name", "未知商品"))),
"quantity": item.get("quantity", item.get("num", item.get("productNum", 1))),
"price": item.get("price", item.get("total", item.get("productPrice", item.get("product_price", "0.00"))))
}
# 添加商品图片(支持多种可能的字段名)
image_url = (
item.get("image") or
item.get("image_url") or
item.get("pic") or
item.get("thumb") or
item.get("product_image") or
item.get("pic_url") or
item.get("thumb_url") or
item.get("img") or
item.get("productImg") or
item.get("thumb")
)
if image_url:
item_data["image_url"] = image_url
else:
# 记录没有图片的商品(用于调试)
logger.debug(
"No image found for product",
product_name=item_data.get("name"),
available_keys=list(item.keys())
)
order_data["items"].append(item_data)
# 备注
if actual_order_data.get("remark") or actual_order_data.get("user_remark"):
order_data["remark"] = actual_order_data.get("remark", actual_order_data.get("user_remark", ""))
return order_data
def _parse_order_data(data: dict[str, Any]) -> dict[str, Any]:
"""解析历史订单数据"""
return _parse_mall_order_data(data)
def _parse_logistics_data(data: dict[str, Any]) -> dict[str, Any]:
"""解析物流数据"""
# MCP 返回结构: {"success": true, "result": {...物流数据...}}
mcp_result = data.get("result", data) if data.get("result") else data
logger.info(
"Parsing logistics data",
data_keys=list(data.keys()) if isinstance(data, dict) else None,
has_result_in_data=bool(data.get("result")),
mcp_result_keys=list(mcp_result.keys()) if isinstance(mcp_result, dict) else None,
raw_tracking_number_value=repr(mcp_result.get("tracking_number")) if mcp_result.get("tracking_number") is not None else None,
raw_courier_value=repr(mcp_result.get("courier")) if mcp_result.get("courier") is not None else None,
has_tracking_number=bool(mcp_result.get("tracking_number")),
has_courier=bool(mcp_result.get("courier")),
has_timeline=bool(mcp_result.get("timeline"))
)
return {
"carrier": mcp_result.get("courier", mcp_result.get("carrier", mcp_result.get("express_name", "未知"))),
"tracking_number": mcp_result.get("tracking_number") or "",
"status": mcp_result.get("status"),
"estimated_delivery": mcp_result.get("estimatedDelivery"),
"timeline": mcp_result.get("timeline", [])
}
async def _generate_text_response(state: AgentState) -> AgentState:
"""生成纯文本回复(降级方案)"""
# Build context from tool results # Build context from tool results
tool_context = [] tool_context = []
for result in state["tool_results"]: for result in state["tool_results"]:
if result["success"]: if result["success"]:
data = result["data"] data = result["data"]
tool_context.append(f"工具 {result['tool_name']} 返回:\n{json.dumps(data, ensure_ascii=False, indent=2)}") tool_context.append(f"工具 {result['tool_name']} 返回:\n{json.dumps(data, ensure_ascii=False, indent=2)}")
# Extract order_id for context
if isinstance(data, dict):
if data.get("order_id"):
state = update_context(state, {"order_id": data["order_id"]})
elif data.get("orders") and len(data["orders"]) > 0:
state = update_context(state, {"order_id": data["orders"][0].get("order_id")})
else: else:
tool_context.append(f"工具 {result['tool_name']} 执行失败: {result['error']}") tool_context.append(f"工具 {result['tool_name']} 执行失败: {result['error']}")

View File

@@ -1,6 +1,7 @@
""" """
Chatwoot API Client for B2B Shopping AI Assistant Chatwoot API Client for B2B Shopping AI Assistant
""" """
import json
from typing import Any, Optional from typing import Any, Optional
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
@@ -172,9 +173,122 @@ class ChatwootClient:
self, self,
conversation_id: int, conversation_id: int,
order_data: dict[str, Any], order_data: dict[str, Any],
actions: list[dict[str, Any]] actions: Optional[list[dict[str, Any]]] = None
) -> dict[str, Any]: ) -> dict[str, Any]:
"""发送订单卡片消息(Markdown 文本 + 操作按钮 """发送订单卡片消息(使用 Chatwoot cards 格式
卡片结构:
- 图片:第一件商品的图片
- 标题:订单号 + 状态
- 描述:汇总信息
- 按钮:跳转到订单详情页面
Args:
conversation_id: 会话 ID
order_data: 订单数据,包含:
- order_id: 订单号
- status: 订单状态
- status_text: 状态文本
- items: 商品列表
- total_amount: 总金额
actions: 操作按钮配置列表(可选)
Returns:
发送结果
Example:
>>> order_data = {
... "order_id": "202071324",
... "status_text": "已发货",
... "items": [{"name": "商品A", "quantity": 2}],
... "total_amount": "599.00"
... }
>>> await chatwoot.send_order_card(123, order_data)
"""
order_id = order_data.get("order_id", "")
status_text = order_data.get("status_text", order_data.get("status", ""))
# 获取第一件商品的图片
items = order_data.get("items", [])
media_url = None
if items and len(items) > 0:
first_item = items[0]
media_url = first_item.get("image_url")
# 构建标题
title = f"订单 #{order_id} {status_text}"
# 构建描述
if items and len(items) > 0:
if len(items) == 1:
items_desc = items[0].get("name", "商品")
else:
items_desc = f"{items[0].get('name', '商品A')} 等共计 {len(items)} 件商品"
description = f"包含 {items_desc},实付 ¥{order_data.get('total_amount', '0.00')}"
else:
description = f"实付 ¥{order_data.get('total_amount', '0.00')}"
# 构建操作按钮
card_actions = []
if actions:
card_actions = actions
else:
# 默认按钮:跳转到订单详情页面
card_actions = [
{
"type": "link",
"text": "查看订单详情",
"uri": f"https://www.qa1.gaia888.com/customer/order/detail?orderId={order_id}"
}
]
# 构建单个卡片
card = {
"title": title,
"description": description,
"actions": card_actions
}
# 如果有图片,添加 media_url
if media_url:
card["media_url"] = media_url
# 构建 content_attributes
content_attributes = {
"items": [card]
}
# 记录发送的数据(用于调试)
logger.info(
"Sending order card",
conversation_id=conversation_id,
order_id=order_id,
has_media=bool(media_url),
payload_preview=json.dumps({
"content": "订单详情",
"content_type": "cards",
"content_attributes": content_attributes
}, ensure_ascii=False, indent=2)[:1000]
)
# 发送富媒体消息
return await self.send_rich_message(
conversation_id=conversation_id,
content="订单详情",
content_type="cards",
content_attributes=content_attributes
)
async def send_order_form(
self,
conversation_id: int,
order_data: dict[str, Any],
actions: Optional[list[dict[str, Any]]] = None
) -> dict[str, Any]:
"""发送订单详情表单消息(使用 content_type=form
根据 Chatwoot API 文档实现的 form 格式订单详情展示。
form 类型支持的字段类型text, text_area, email, select
Args: Args:
conversation_id: 会话 ID conversation_id: 会话 ID
@@ -188,11 +302,9 @@ class ChatwootClient:
- shipping_fee: 运费(可选) - shipping_fee: 运费(可选)
- logistics: 物流信息(可选) - logistics: 物流信息(可选)
- remark: 备注(可选) - remark: 备注(可选)
actions: 操作按钮配置列表,每个按钮包含: actions: 操作按钮配置列表(可选),每个按钮包含:
- type: "link""postback" - label: 按钮文字(用于 select 选项的显示)
- text: 按钮文字 - value: 按钮值(用于 select 选项的值)
- uri: 链接地址type=link 时必需)
- payload: 回传数据type=postback 时必需)
Returns: Returns:
发送结果 发送结果
@@ -202,27 +314,127 @@ class ChatwootClient:
... "order_id": "123456789", ... "order_id": "123456789",
... "status": "shipped", ... "status": "shipped",
... "status_text": "已发货", ... "status_text": "已发货",
... "created_at": "2023-10-27 14:30",
... "total_amount": "1058.00", ... "total_amount": "1058.00",
... "items": [...] ... "items": [{"name": "商品A", "quantity": 2, "price": "100.00"}]
... } ... }
>>> actions = [ >>> actions = [
... {"type": "link", "text": "查看详情", "uri": "https://..."}, ... {"label": "查看详情", "value": "VIEW_DETAILS"},
... {"type": "postback", "text": "联系客服", "payload": "CONTACT_SUPPORT"} ... {"label": "联系客服", "value": "CONTACT_SUPPORT"}
... ] ... ]
>>> await chatwoot.send_order_card(123, order_data, actions) >>> await chatwoot.send_order_form(123, order_data, actions)
""" """
# 生成 Markdown 内容 # 构建表单字段
markdown_content = format_order_card_markdown(order_data) form_items = []
# 生成按钮卡片 # 订单号(只读文本)
buttons = create_action_buttons(actions) form_items.append({
"name": "order_id",
"label": "订单号",
"type": "text",
"placeholder": "订单号",
"default": order_data.get("order_id", "")
})
# 发送富媒体消息 # 订单状态(只读文本)
status_text = order_data.get("status_text", order_data.get("status", "unknown"))
form_items.append({
"name": "status",
"label": "订单状态",
"type": "text",
"placeholder": "订单状态",
"default": status_text
})
# 下单时间(只读文本)
if order_data.get("created_at"):
form_items.append({
"name": "created_at",
"label": "下单时间",
"type": "text",
"placeholder": "下单时间",
"default": order_data["created_at"]
})
# 商品列表(多行文本)
items = order_data.get("items", [])
if items:
items_text = "\n".join([
f"▫️ {item.get('name', '未知商品')} × {item.get('quantity', 1)} - ¥{item.get('price', '0.00')}"
for item in items
])
form_items.append({
"name": "items",
"label": "商品详情",
"type": "text_area",
"placeholder": "商品列表",
"default": items_text
})
# 总金额(只读文本)
form_items.append({
"name": "total_amount",
"label": "总金额",
"type": "text",
"placeholder": "总金额",
"default": f"¥{order_data.get('total_amount', '0.00')}"
})
# 运费(只读文本)
if order_data.get("shipping_fee") is not None:
form_items.append({
"name": "shipping_fee",
"label": "运费",
"type": "text",
"placeholder": "运费",
"default": f"¥{order_data['shipping_fee']}"
})
# 物流信息(多行文本)
logistics = order_data.get("logistics")
if logistics:
logistics_text = (
f"承运商: {logistics.get('carrier', '未知')}\n"
f"单号: {logistics.get('tracking_number', '未知')}"
)
form_items.append({
"name": "logistics",
"label": "物流信息",
"type": "text_area",
"placeholder": "物流信息",
"default": logistics_text
})
# 备注(多行文本)
if order_data.get("remark"):
form_items.append({
"name": "remark",
"label": "备注",
"type": "text_area",
"placeholder": "备注",
"default": order_data["remark"]
})
# 操作选项(下拉选择,如果提供了 actions
if actions:
form_items.append({
"name": "action_select",
"label": "操作",
"type": "select",
"options": actions
})
# 构建 content_attributes
content_attributes = {
"items": form_items
}
# 发送 form 类型消息
return await self.send_rich_message( return await self.send_rich_message(
conversation_id=conversation_id, conversation_id=conversation_id,
content=markdown_content, content="订单详情",
content_type="cards", content_type="form",
content_attributes=buttons content_attributes=content_attributes
) )
# ============ Conversations ============ # ============ Conversations ============

View File

@@ -317,10 +317,8 @@ async def get_mall_order(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info( logger.info(
"get_mall_order called", f"get_mall_order called: order_id={order_id}, has_user_token={bool(user_token)}, "
order_id=order_id, f"token_prefix={user_token[:20] if user_token else None}"
has_user_token=bool(user_token),
user_token_prefix=user_token[:20] if user_token else None
) )
try: try:
@@ -347,9 +345,8 @@ async def get_mall_order(
result = await client.get_order_by_id(order_id) result = await client.get_order_by_id(order_id)
logger.info( logger.info(
"Mall API request successful", f"Mall API request successful: order_id={order_id}, "
order_id=order_id, f"result_keys={list(result.keys()) if isinstance(result, dict) else None}"
result_keys=list(result.keys()) if isinstance(result, dict) else None
) )
return { return {
@@ -359,9 +356,7 @@ async def get_mall_order(
} }
except Exception as e: except Exception as e:
logger.error( logger.error(
"Mall API request failed", f"Mall API request failed: order_id={order_id}, error={str(e)}"
order_id=order_id,
error=str(e)
) )
return { return {
"success": False, "success": False,
@@ -395,18 +390,11 @@ async def get_logistics(
Returns: Returns:
物流信息,包含快递公司、状态、预计送达时间、物流轨迹等 物流信息,包含快递公司、状态、预计送达时间、物流轨迹等
""" """
import logging print(f"[get_logistics] Called with order_id={order_id}, has_user_token={bool(user_token)}")
logger = logging.getLogger(__name__)
logger.info(
"get_logistics called",
order_id=order_id,
has_user_token=bool(user_token)
)
# 必须提供 user_token # 必须提供 user_token
if not user_token: if not user_token:
logger.error("No user_token provided for logistics query") print("[get_logistics] ERROR: No user_token provided")
return { return {
"success": False, "success": False,
"error": "用户未登录,请先登录账户以查询物流信息", "error": "用户未登录,请先登录账户以查询物流信息",
@@ -424,32 +412,50 @@ async def get_logistics(
source=settings.mall_source source=settings.mall_source
) )
print(f"[get_logistics] Calling Mall API: /mall/api/order/parcel?orderId={order_id}")
result = await client.get( result = await client.get(
"/mall/api/order/parcel", "/mall/api/order/parcel",
params={"orderId": order_id} params={"orderId": order_id}
) )
logger.info( print(f"[get_logistics] SUCCESS: result_keys={list(result.keys()) if isinstance(result, dict) else type(result).__name__}")
"Logistics query successful", print(f"[get_logistics] Sample data: {str(result)[:500]}")
order_id=order_id,
has_tracking=bool(result.get("trackingNumber")) # Mall API 返回结构:{ "total": 1, "data": [{ "trackingCode": "...", "carrier": "...", ... }] }
) logistics_list = result.get("data", [])
if logistics_list and len(logistics_list) > 0:
first_logistics = logistics_list[0]
tracking_number = first_logistics.get("trackingCode", "")
carrier = first_logistics.get("carrier", "未知")
print(f"[get_logistics] Extracted: tracking_number={tracking_number}, carrier={carrier}")
return { return {
"success": True, "success": True,
"order_id": order_id, "order_id": order_id,
"tracking_number": result.get("trackingNumber"), "tracking_number": tracking_number,
"courier": result.get("courier"), "courier": carrier,
"status": result.get("status"), "tracking_url": first_logistics.get("trackingUrl", ""),
"estimated_delivery": result.get("estimatedDelivery"), "status": first_logistics.get("status", ""),
"timeline": result.get("timeline", []) "timeline": [] # 如果 API 返回轨迹信息,可以在这里添加
}
else:
print(f"[get_logistics] WARNING: No logistics data found in response")
return {
"success": True,
"order_id": order_id,
"tracking_number": "",
"courier": "暂无物流信息",
"status": "",
"timeline": []
} }
except Exception as e: except Exception as e:
logger.error( import traceback
"Logistics query failed", print(f"[get_logistics] ERROR: {type(e).__name__}: {str(e)}")
order_id=order_id, print(f"[get_logistics] TRACEBACK:\n{traceback.format_exc()}")
error=str(e)
)
return { return {
"success": False, "success": False,
"error": str(e), "error": str(e),

View File

@@ -55,14 +55,16 @@ class MallClient:
headers={ headers={
"Authorization": f"Bearer {self.api_token}", "Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json", "Accept": "application/json, text/plain, */*",
"Device-Type": "pc", "Device-Type": "pc",
"tenant-Id": self.tenant_id, "tenant-Id": self.tenant_id,
"currency-code": self.currency_code, "currency-code": self.currency_code,
"language-id": self.language_id, "language-id": self.language_id,
"source": self.source, "source": self.source,
"origin": "https://www.qa1.gaia888.com", "Origin": "https://www.qa1.gaia888.com",
"referer": "https://www.qa1.gaia888.com/", "Referer": "https://www.qa1.gaia888.com/",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
"DNT": "1",
}, },
timeout=30.0 timeout=30.0
) )