feat: 重构订单和物流信息展示格式

主要改动:
- 订单列表:使用 order_list 格式,展示 5 个订单(全部状态)
- 订单详情:使用 order_detail 格式,优化价格和时间显示
- 物流信息:使用 logistics 格式,根据 track id 动态生成步骤
- 商品图片:从 orderProduct.imageUrl 字段获取
- 时间格式:统一为 YYYY-MM-DD HH:MM:SS
- 多语言支持:amountLabel、orderTime 支持中英文
- 配置管理:新增 FRONTEND_URL 环境变量
- API 集成:改进 Mall API tracks 数据解析
- 认证优化:account_id 从 webhook 动态获取

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
wangliang
2026-01-23 18:49:40 +08:00
parent e8e89601a5
commit 0b5d0a8086
11 changed files with 1493 additions and 394 deletions

View File

@@ -69,13 +69,98 @@ async def customer_service_agent(state: AgentState) -> AgentState:
# Auto-detect category and query FAQ
message_lower = state["current_message"].lower()
# 定义分类关键词
# 定义分类关键词支持多语言en, nl, de, es, fr, it, tr, zh
category_keywords = {
"register": ["register", "sign up", "account", "login", "password", "forgot"],
"order": ["order", "place order", "cancel order", "modify order", "change order"],
"payment": ["pay", "payment", "checkout", "voucher", "discount", "promo"],
"shipment": ["ship", "shipping", "delivery", "courier", "transit", "logistics", "tracking"],
"return": ["return", "refund", "exchange", "defective", "damaged"],
"register": [
# English
"register", "sign up", "account", "login", "password", "forgot",
# Dutch (Nederlands)
"registreren", "account", "inloggen", "wachtwoord",
# German (Deutsch)
"registrieren", "konto", "anmelden", "passwort",
# Spanish (Español)
"registrar", "cuenta", "iniciar", "contraseña",
# French (Français)
"enregistrer", "compte", "connecter", "mot de passe",
# Italian (Italiano)
"registrarsi", "account", "accesso", "password",
# Turkish (Türkçe)
"kayıt", "hesap", "giriş", "şifre",
# Chinese (中文)
"注册", "账号", "登录", "密码", "忘记密码"
],
"order": [
# English
"order", "place order", "cancel order", "modify order", "change order",
# Dutch
"bestelling", "bestellen", "annuleren", "wijzigen",
# German
"bestellung", "bestellen", "stornieren", "ändern",
# Spanish
"pedido", "hacer pedido", "cancelar", "modificar",
# French
"commande", "passer commande", "annuler", "modifier",
# Italian
"ordine", "ordinare", "cancellare", "modificare",
# Turkish
"sipariş", "sipariş ver", "iptal", "değiştir",
# Chinese
"订单", "下单", "取消订单", "修改订单", "更改订单"
],
"payment": [
# English
"pay", "payment", "checkout", "voucher", "discount", "promo",
# Dutch
"betalen", "betaling", "korting", "voucher",
# German
"bezahlen", "zahlung", "rabatt", "gutschein",
# Spanish
"pagar", "pago", "descuento", "cupón",
# French
"payer", "paiement", "réduction", "bon",
# Italian
"pagare", "pagamento", "sconto", "voucher",
# Turkish
"ödemek", "ödeme", "indirim", "kupon",
# Chinese
"支付", "付款", "结算", "优惠券", "折扣", "促销"
],
"shipment": [
# English
"ship", "shipping", "delivery", "courier", "transit", "logistics", "tracking",
# Dutch
"verzenden", "levering", "koerier", "logistiek", "volgen",
# German
"versand", "lieferung", "kurier", "logistik", "verfolgung",
# Spanish
"enviar", "envío", "entrega", "mensajería", "logística", "seguimiento",
# French
"expédier", "livraison", "coursier", "logistique", "suivi",
# Italian
"spedire", "spedizione", "consegna", "corriere", "logistica", "tracciamento",
# Turkish
"gönderi", "teslimat", "kurye", "lojistik", "takip",
# Chinese
"发货", "配送", "快递", "物流", "运输", "配送单"
],
"return": [
# English
"return", "refund", "exchange", "defective", "damaged",
# Dutch
"retour", "terugbetaling", "ruilen", "defect",
# German
"rückgabe", "erstattung", "austausch", "defekt",
# Spanish
"devolución", "reembolso", "cambio", "defectuoso",
# French
"retour", "remboursement", "échange", "défectueux",
# Italian
"reso", "rimborso", "cambio", "difettoso",
# Turkish
"iade", "geri ödeme", "değişim", "defekt",
# Chinese
"退货", "退款", "换货", "有缺陷", "损坏"
],
}
# 检测分类

View File

@@ -12,6 +12,40 @@ from integrations.chatwoot import ChatwootClient
logger = get_logger(__name__)
# Order status constants (Mall API)
ORDER_STATUS = {
0: "已取消", # ORDER_STATUS_CANCEL
1: "待支付", # ORDER_STATUS_WAIT_PAY
2: "已支付", # ORDER_STATUS_PAID
3: "已发货", # ORDER_STATUS_SHIPPED
4: "已签收", # ORDER_STATUS_SIGNED
15: "已完成", # ORDER_STATUS_FINISH
100: "超时取消", # ORDER_STATUS_CANCEL_OVER_TIME
110: "已废弃", # ORDER_STATUS_PAY_SUCCESS (deprecated)
10000: "全部" # All
}
def get_status_text(status_value: Any) -> str:
"""获取订单状态文本
Args:
status_value: 状态值(数字或字符串)
Returns:
状态文本
"""
if status_value is None:
return ""
# 转换为整数
try:
status_int = int(status_value)
return ORDER_STATUS.get(status_int, str(status_value))
except (ValueError, TypeError):
return str(status_value)
ORDER_AGENT_PROMPT = """你是一个专业的 B2B 订单服务助手。
你的职责是帮助用户处理订单相关的问题,包括:
- 订单查询
@@ -22,21 +56,21 @@ ORDER_AGENT_PROMPT = """你是一个专业的 B2B 订单服务助手。
## 可用工具
1. **get_mall_order** - 从商城 API 查询单(推荐使用)
1. **get_mall_order** - 从商城 API 查询单个订单详情(推荐使用)
- order_id: 订单号(必需)
- user_token: 用户 token自动注入
- 说明:此工具会自动使用用户的身份 token 查询商城订单详情
2. **get_logistics** - 从商城 API 查询物流信息
- order_id: 订单号(必需
- 说明:查询订单的物流轨迹和配送状态
2. **get_mall_order_list** - 从商城 API 查询订单列表(推荐使用)
- user_token: 用户 token自动注入
- page: 页码(可选,默认 1
- limit: 每页数量(可选,默认 10
- 说明:查询用户的所有订单,按时间倒序排列
3. **query_order** - 查询历史订单
- user_id: 用户 ID自动注入
- account_id: ID(自动注入)
- order_id: 订单号(可选,不填则查询最近订单)
- date_start: 开始日期(可选)
- date_end: 结束日期(可选)
- status: 订单状态(可选)
3. **get_logistics** - 从商城 API 查询物流信息
- order_id: 订单号(必需
- user_token: token(自动注入)
- 说明:查询订单的物流轨迹和配送状态
4. **modify_order** - 修改订单
- order_id: 订单号
@@ -100,6 +134,16 @@ ORDER_AGENT_PROMPT = """你是一个专业的 B2B 订单服务助手。
}
```
用户: "查询我最近的订单""我的订单列表"
回复:
```json
{
"action": "call_tool",
"tool_name": "get_mall_order_list",
"arguments": {}
}
```
用户: "我的订单发货了吗?"
回复:
```json
@@ -125,8 +169,10 @@ ORDER_AGENT_PROMPT = """你是一个专业的 B2B 订单服务助手。
- **必须返回完整的 JSON 对象**,不要只返回部分内容
- **不要添加任何 markdown 代码块标记**(如 \`\`\`json
- **不要添加任何解释性文字**,只返回 JSON
- **每次调用工具必须指定 tool_name**
- user_id 和 account_id 会自动注入到 arguments 中,无需手动添加
- 如果用户提供了订单号,优先使用 get_mall_order 工具
- 如果用户提供了具体订单号,使用 get_mall_order 工具
- 如果用户想查询订单列表或最近的订单,使用 get_mall_order_list 工具
- 如果用户想查询物流状态,使用 get_logistics 工具
- 对于敏感操作(取消、修改),确保有明确的订单号
"""
@@ -216,15 +262,18 @@ async def order_agent(state: AgentState) -> AgentState:
arguments["account_id"] = state["account_id"]
# Inject user_token if available
if state.get("user_token"):
arguments["user_token"] = state["user_token"]
# 优先使用 mall_token用于 Mall API如果没有则使用 user_token
token_to_use = state.get("mall_token") or state.get("user_token")
if token_to_use:
arguments["user_token"] = token_to_use
logger.info(
"Injected user_token into tool call",
token_prefix=state["user_token"][:20] if state["user_token"] else None
"Injected token into tool call",
token_type="mall_token" if state.get("mall_token") else "user_token",
token_prefix=token_to_use[:20] if token_to_use else None
)
else:
logger.warning(
"No user_token available in state, MCP will use default token",
"No token available in state, MCP will use default token",
conversation_id=state["conversation_id"]
)
@@ -310,21 +359,27 @@ async def order_agent(state: AgentState) -> AgentState:
arguments["account_id"] = state["account_id"]
# Inject user_token if available (for Mall API calls)
if state.get("user_token"):
arguments["user_token"] = state["user_token"]
# 优先使用 mall_token用于 Mall API如果没有则使用 user_token
token_to_use = state.get("mall_token") or state.get("user_token")
if token_to_use:
arguments["user_token"] = token_to_use
logger.debug(
"Injected user_token into tool call",
token_prefix=state["user_token"][:20] if state["user_token"] else None
"Injected token into tool call",
token_type="mall_token" if state.get("mall_token") else "user_token",
token_prefix=token_to_use[:20] if token_to_use else None
)
else:
logger.warning(
"No user_token available in state, MCP will use default token",
"No token available in state, MCP will use default token",
conversation_id=state["conversation_id"]
)
# Use entity if available
# Use entity if available (only for single-order queries, not for order list)
tool_name = result["tool_name"]
if "order_id" not in arguments and state["entities"].get("order_id"):
arguments["order_id"] = state["entities"]["order_id"]
# 只在查询单个订单的工具中添加 order_id
if tool_name in ["get_mall_order", "get_logistics", "query_order"]:
arguments["order_id"] = state["entities"]["order_id"]
state = add_tool_call(
state,
@@ -363,8 +418,23 @@ async def order_agent(state: AgentState) -> AgentState:
async def _generate_order_response(state: AgentState) -> AgentState:
"""Generate response based on order tool results"""
# 检查是否是邮件渠道
is_email = state.get("context", {}).get("is_email", False)
# 邮件渠道:使用纯文本回复(不支持富媒体)
if is_email:
logger.info(
"Email channel detected, using text response instead of rich media",
conversation_id=state.get("conversation_id")
)
return await _generate_text_response(state)
# 获取检测到的语言,默认为英文
detected_language = state.get("detected_language", "en")
# 解析订单数据并尝试使用 form 格式发送
order_data = None
order_list = [] # 订单列表
user_message = ""
logistics_data = None
@@ -380,8 +450,18 @@ async def _generate_order_response(state: AgentState) -> AgentState:
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):
# 处理 get_mall_order_list 返回的订单列表
if tool_name == "get_mall_order_list" and isinstance(data, dict):
mcp_result = data.get("result", {})
if mcp_result.get("orders") and isinstance(mcp_result["orders"], list):
# 解析订单列表
for order_item in mcp_result["orders"]:
parsed_order = _parse_mall_order_data(order_item)
if parsed_order.get("order_id"):
order_list.append(parsed_order)
# 处理 get_mall_order 返回的单个订单数据
elif 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", {})
@@ -412,9 +492,16 @@ async def _generate_order_response(state: AgentState) -> AgentState:
# 处理 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'])} 个订单,显示最新的一个:"
for order_item in data["orders"]:
parsed = _parse_order_data(order_item)
if parsed.get("order_id"):
order_list.append(parsed)
user_message = f"找到 {len(data['orders'])} 个订单"
else:
# 只有一个订单,作为单个订单处理
order_data = _parse_order_data(data["orders"][0])
# 处理 get_logistics 返回的物流数据
elif tool_name == "get_logistics" and isinstance(data, dict):
@@ -422,8 +509,88 @@ async def _generate_order_response(state: AgentState) -> AgentState:
# 如果之前有订单数据,添加物流信息
if order_data:
order_data["logistics"] = logistics_data
# 如果只有物流数据,单独发送物流信息
elif logistics_data and logistics_data.get("tracking_number"):
# 添加订单号(如果有)
order_id = state.get("order_id", logistics_data.get("order_id", ""))
if order_id:
logistics_data["order_id"] = order_id
# 尝试使用 Chatwoot cards 格式发送
try:
chatwoot = ChatwootClient(account_id=int(state.get("account_id", 1)))
conversation_id = state.get("conversation_id")
if conversation_id:
logger.info(
"Preparing to send logistics info",
conversation_id=conversation_id,
tracking_number=logistics_data.get("tracking_number"),
carrier=logistics_data.get("carrier"),
language=detected_language
)
await chatwoot.send_logistics_info(
conversation_id=int(conversation_id),
logistics_data=logistics_data,
language=detected_language
)
logger.info(
"Logistics info sent successfully",
conversation_id=conversation_id,
tracking_number=logistics_data.get("tracking_number")
)
state = set_response(state, "")
state["state"] = ConversationState.GENERATING.value
return state
except Exception as e:
logger.error(
"Failed to send logistics info, falling back to text response",
error=str(e),
tracking_number=logistics_data.get("tracking_number")
)
# 如果有订单列表(多个订单),使用订单列表格式
if order_list and len(order_list) > 1:
try:
chatwoot = ChatwootClient(account_id=int(state.get("account_id", 1)))
conversation_id = state.get("conversation_id")
if conversation_id:
logger.info(
"Preparing to send order list",
conversation_id=conversation_id,
orders_count=len(order_list),
language=detected_language
)
await chatwoot.send_order_list(
conversation_id=int(conversation_id),
orders=order_list,
language=detected_language
)
logger.info(
"Order list sent successfully",
conversation_id=conversation_id,
orders_count=len(order_list),
language=detected_language
)
state = set_response(state, "")
state["state"] = ConversationState.GENERATING.value
return state
except Exception as e:
logger.error(
"Failed to send order list, falling back to text response",
error=str(e),
orders_count=len(order_list)
)
# 尝试使用 Chatwoot form 格式发送单个订单
if order_data:
try:
# 检查是否有有效的 order_id
@@ -434,7 +601,7 @@ async def _generate_order_response(state: AgentState) -> AgentState:
)
return await _generate_text_response(state)
chatwoot = ChatwootClient()
chatwoot = ChatwootClient(account_id=int(state.get("account_id", 1)))
conversation_id = state.get("conversation_id")
if conversation_id:
@@ -443,24 +610,24 @@ async def _generate_order_response(state: AgentState) -> AgentState:
"Preparing to send order card",
conversation_id=conversation_id,
order_id=order_data.get("order_id"),
items_count=len(order_data.get("items", []))
items_count=len(order_data.get("items", [])),
language=detected_language
)
# 发送订单卡片(使用默认的"查看订单详情"按钮)
await chatwoot.send_order_card(
conversation_id=conversation_id,
order_data=order_data
await chatwoot.send_order_form(
conversation_id=int(conversation_id),
order_data=order_data,
language=detected_language
)
logger.info(
"Order card sent successfully",
conversation_id=conversation_id,
order_id=order_data.get("order_id")
order_id=order_data.get("order_id"),
language=detected_language
)
# 设置确认消息
response_text = user_message or "订单详情如下"
state = set_response(state, response_text)
state = set_response(state, "")
state["state"] = ConversationState.GENERATING.value
return state
@@ -508,12 +675,19 @@ def _parse_mall_order_data(data: dict[str, Any]) -> dict[str, Any]:
order_data = {
"order_id": actual_order_data.get("orderId", actual_order_data.get("order_id", actual_order_data.get("order_sn", ""))),
"order_type": actual_order_data.get("orderType", actual_order_data.get("order_type", "")),
"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", ""))),
"status_text": actual_order_data.get("statusText", ""),
"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")),
"payment_method": actual_order_data.get("paymentCode", actual_order_data.get("paymentMethod", "")),
}
# 如果 statusText 为空,使用 orderStatusId 映射获取状态文本
if not order_data["status_text"]:
status_id = actual_order_data.get("orderStatusId", actual_order_data.get("status"))
order_data["status_text"] = get_status_text(status_id)
# 下单时间
if actual_order_data.get("created_at"):
order_data["created_at"] = actual_order_data["created_at"]
@@ -522,65 +696,99 @@ def _parse_mall_order_data(data: dict[str, Any]) -> dict[str, Any]:
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
[]
)
# 商品列表 - 直接使用 Mall API 返回的 orderProduct 字段
order_product = actual_order_data.get("orderProduct", [])
# 记录商品列表数据结构(用于调试)
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)
)
logger.info(
"Parsing orderProduct array",
has_order_product=bool(order_product),
order_product_count=len(order_product) if isinstance(order_product, list) else 0,
order_product_type=type(order_product).__name__
)
if items:
if order_product and isinstance(order_product, list) and len(order_product) > 0:
order_data["items"] = []
for item in items:
for product in order_product:
if not isinstance(product, dict):
continue
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"))))
"name": product.get("productName", product.get("name", product.get("product_name", "未知商品"))),
"quantity": product.get("quantity", product.get("num", product.get("productNum", 1))),
"price": product.get("price", product.get("productPrice", product.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")
)
# 商品图片直接从 product 的 imageUrl 字段获取
image_url = product.get("imageUrl")
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", ""))
# 物流信息(如果有)
if actual_order_data.get("parcels") and len(actual_order_data.get("parcels", [])) > 0:
# parcels 是一个数组,包含物流信息
first_parcel = actual_order_data["parcels"][0] if isinstance(actual_order_data["parcels"], list) else actual_order_data["parcels"]
if isinstance(first_parcel, dict):
logistics_info = {
"carrier": first_parcel.get("courier", first_parcel.get("carrier", first_parcel.get("company", ""))),
"tracking_number": first_parcel.get("trackingCode", first_parcel.get("tracking_number", first_parcel.get("trackingNumber", ""))),
}
# 只有在有有效数据时才添加
if logistics_info["carrier"] or logistics_info["tracking_number"]:
order_data["logistics"] = logistics_info
# 收货地址
shipping_firstname = actual_order_data.get("shippingFirstname", "")
shipping_lastname = actual_order_data.get("shippingLastname", "")
shipping_company = actual_order_data.get("shippingCompany", "")
shipping_address_1 = actual_order_data.get("shippingAddress_1", "")
shipping_address_2 = actual_order_data.get("shippingAddress_2", "")
shipping_city = actual_order_data.get("shippingCity", "")
shipping_postcode = actual_order_data.get("shippingPostcode", "")
shipping_country = actual_order_data.get("shippingCountry", "")
shipping_zone = actual_order_data.get("shippingZone", "")
# 如果有任何地址字段存在,构建收货地址
if any([shipping_firstname, shipping_lastname, shipping_address_1, shipping_city]):
order_data["shipping_address"] = {
"name": f"{shipping_firstname} {shipping_lastname}".strip() or "",
"line1": shipping_address_1 or "",
"line2": shipping_address_2 or "",
"city": shipping_city or "",
"state": shipping_zone or "",
"postal_code": shipping_postcode or "",
"country": shipping_country or ""
}
# 发票地址
billing_firstname = actual_order_data.get("paymentFirstname", "")
billing_lastname = actual_order_data.get("paymentLastname", "")
billing_company = actual_order_data.get("paymentCompany", "")
billing_address_1 = actual_order_data.get("paymentAddress_1", "")
billing_address_2 = actual_order_data.get("paymentAddress_2", "")
billing_city = actual_order_data.get("paymentCity", "")
billing_postcode = actual_order_data.get("paymentPostcode", "")
billing_country = actual_order_data.get("paymentCountry", "")
billing_zone = actual_order_data.get("paymentZone", "")
# 如果有任何地址字段存在,构建发票地址
if any([billing_firstname, billing_lastname, billing_address_1, billing_city]):
order_data["billing_address"] = {
"name": f"{billing_firstname} {billing_lastname}".strip() or "",
"line1": billing_address_1 or "",
"line2": billing_address_2 or "",
"city": billing_city or "",
"state": billing_zone or "",
"postal_code": billing_postcode or "",
"country": billing_country or ""
}
return order_data