30 KiB
技术设计文档 — RNS1.4:CHAT 对齐与联调收尾
概述
RNS1.4 是 RNS1 系列的收尾 spec,覆盖三大块工作:
- CHAT 模块路径迁移与功能补全(T4-1 ~ T4-3):将现有
/api/ai/*路由迁移到/api/xcx/chat/*,实现 CHAT-1/2/3/4 四个端点,支持 referenceCard 和多入口参数路由 - FDW 端到端验证(T4-4):验证
test_zqyy_app→test_etl_feiqiu链路上所有 FDW 视图可访问、性能达标、索引完备 - 全量前后端联调(T4-5):13 个页面移除 mock 数据,连接真实后端,修复 notes 触底加载和 customer-service-records 按月请求
设计原则
- 复用优先:现有
biz.ai_conversations/biz.ai_messages表结构通过 DDL 迁移扩展,新增 CHAT 所需字段(customer_id、title、last_message、reference_card),不新建表 - 契约驱动:所有端点严格遵循
API-contract.md中 CHAT-1/2/3/4 的定义 - 权限一致:所有 CHAT 端点使用
require_approved()依赖,与 RNS1.1-1.3 保持一致 - DWD-DOC 强制规则:referenceCard 中涉及金额使用
items_sum口径,会员信息通过member_idJOINdim_member
依赖关系
graph LR
RNS10[RNS1.0<br/>基础设施] --> RNS14[RNS1.4<br/>CHAT + 联调]
RNS11[RNS1.1<br/>任务/绩效] --> RNS14
RNS12[RNS1.2<br/>客户/助教] --> RNS14
RNS13[RNS1.3<br/>三看板] --> RNS14
style RNS14 fill:#f9f,stroke:#333
架构
整体架构
graph TB
subgraph "微信小程序 (apps/miniprogram/)"
FE_CHAT[pages/chat/chat.ts]
FE_HIST[pages/chat-history/chat-history.ts]
FE_API[services/api.ts]
FE_REQ[utils/request.ts]
FE_CHAT --> FE_API
FE_HIST --> FE_API
FE_API --> FE_REQ
end
subgraph "FastAPI 后端 (apps/backend/app/)"
MW[ResponseWrapperMiddleware<br/>SSE 自动跳过]
ROUTER[routers/xcx_chat.py<br/>CHAT-1/2/3/4]
SCHEMA[schemas/xcx_chat.py<br/>Pydantic CamelModel]
SVC[services/chat_service.py<br/>对话业务逻辑]
AI_SVC[ai/conversation_service.py<br/>AI 调用 + 持久化]
BAILIAN[ai/bailian_client.py<br/>百炼 API]
FDW_Q[services/fdw_queries.py<br/>FDW 查询]
DB[database.py]
MW --> ROUTER
ROUTER --> SCHEMA
ROUTER --> SVC
SVC --> AI_SVC
SVC --> FDW_Q
AI_SVC --> BAILIAN
SVC --> DB
end
subgraph "数据库"
APP_DB[(zqyy_app<br/>biz.ai_conversations<br/>biz.ai_messages)]
ETL_DB[(etl_feiqiu via FDW<br/>fdw_etl.v_dim_member<br/>fdw_etl.v_dws_member_*)]
end
FE_REQ -->|HTTP JSON / SSE| MW
DB --> APP_DB
FDW_Q --> ETL_DB
style ROUTER fill:#f9f,stroke:#333
style SVC fill:#f9f,stroke:#333
请求-响应流程(SSE 流式)
sequenceDiagram
participant MP as 小程序 chat.ts
participant MW as ResponseWrapper
participant R as xcx_chat.py
participant S as chat_service.py
participant AI as BailianClient
participant DB as zqyy_app
MP->>MW: POST /api/xcx/chat/stream<br/>{chatId, content}
MW->>R: 透传(SSE 跳过包装)
R->>R: require_approved() + 验证 chatId 归属
R->>S: stream_chat(chatId, content, user)
S->>DB: INSERT user message → ai_messages
S->>AI: 流式调用百炼 API
loop 逐 token
AI-->>S: token 片段
S-->>R: SSEEvent(type=message, token=...)
R-->>MW: data: {"token": "..."}
MW-->>MP: 透传 SSE 事件
end
S->>DB: INSERT AI reply → ai_messages
S->>DB: UPDATE ai_conversations.last_message
S-->>R: SSEEvent(type=done, messageId, createdAt)
R-->>MW: data: {"messageId": "...", "createdAt": "..."}
MW-->>MP: 透传 done 事件
组件与接口
组件 1:xcx_chat 路由模块(路径迁移 + CHAT-1/2/3/4)
位置:apps/backend/app/routers/xcx_chat.py(新文件,替代 xcx_ai_chat.py)
职责:将现有 /api/ai/* 路由迁移到 /api/xcx/chat/*,并实现 CHAT-1/2/3/4 四个端点。
迁移策略:
- 新建
xcx_chat.py,prefix 为/api/xcx/chat - 从
xcx_ai_chat.py迁移 SSE 流式对话、历史列表、消息查询三个端点 - 在
main.py中替换路由注册:移除xcx_ai_chat.router,注册xcx_chat.router - 删除
xcx_ai_chat.py(不保留旧路径兼容)
端点定义:
router = APIRouter(prefix="/api/xcx/chat", tags=["小程序 CHAT"])
# CHAT-1: 对话历史列表
@router.get("/history")
async def list_chat_history(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
) -> ChatHistoryResponse: ...
# CHAT-2a: 通过 chatId 查询消息
@router.get("/{chat_id}/messages")
async def get_chat_messages(
chat_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
) -> ChatMessagesResponse: ...
# CHAT-2b: 通过上下文查询消息(自动查找/创建对话)
@router.get("/messages")
async def get_chat_messages_by_context(
context_type: str = Query(..., alias="contextType"), # task / customer / coach
context_id: str = Query(..., alias="contextId"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
) -> ChatMessagesResponse: ...
# CHAT-3: 发送消息(同步回复)
@router.post("/{chat_id}/messages")
async def send_message(
chat_id: int,
body: SendMessageRequest,
user: CurrentUser = Depends(require_approved()),
) -> SendMessageResponse: ...
# CHAT-4: SSE 流式端点
@router.post("/stream")
async def chat_stream(
body: ChatStreamRequest,
user: CurrentUser = Depends(require_approved()),
) -> StreamingResponse: ...
组件 2:chat_service 业务逻辑层
位置:apps/backend/app/services/chat_service.py(新文件)
职责:封装 CHAT 模块的核心业务逻辑,包括对话管理、消息持久化、referenceCard 组装。
关键方法:
class ChatService:
"""CHAT 模块业务逻辑。"""
def get_chat_history(
self, user_id: int, site_id: int, page: int, page_size: int
) -> tuple[list[dict], int]:
"""CHAT-1: 查询对话历史列表,返回 (items, total)。"""
def get_or_create_session(
self, user_id: int, site_id: int,
context_type: str, context_id: str | None
) -> int:
"""按入口上下文查找或创建对话,返回 chat_id。
复用规则:
- context_type='task': 同一 taskId 始终复用(无时限)
- context_type='customer'/'coach': 最后消息 ≤ 3 天复用,> 3 天新建
- context_type='general': 始终新建
"""
def get_messages(
self, chat_id: int, user_id: int, site_id: int,
page: int, page_size: int
) -> tuple[list[dict], int, int]:
"""CHAT-2: 查询消息列表,返回 (messages, total, chat_id)。
验证 chat_id 归属当前用户。"""
def send_message_sync(
self, chat_id: int, content: str, user_id: int, site_id: int
) -> dict:
"""CHAT-3: 发送消息并获取同步 AI 回复。
1. 验证 chatId 归属
2. 存入用户消息
3. 调用 AI 获取回复
4. 存入 AI 回复
5. 更新 session 的 last_message / last_message_at
6. AI 失败时返回错误提示消息(HTTP 200)"""
def build_reference_card(
self, customer_id: int, site_id: int
) -> dict | None:
"""组装 referenceCard:从 FDW 查询客户关键指标。
遵循 DWD-DOC 规则:金额用 items_sum,会员信息通过 member_id JOIN dim_member。"""
def generate_title(self, session: dict) -> str:
"""生成对话标题:自定义标题 > 上下文名称 > 首条消息前20字。"""
referenceCard 组装逻辑:
def build_reference_card(self, customer_id: int, site_id: int) -> dict | None:
"""从 FDW 查询客户指标,组装为 referenceCard 结构。"""
# 1. 通过 member_id JOIN fdw_etl.v_dim_member 获取客户姓名
# 2. 通过 fdw_etl.v_dws_member_consumption_summary 获取:
# - 余额(balance)
# - 近30天消费(items_sum 口径,非 consume_money)
# - 到店次数
# 3. 组装为 referenceCard 结构
return {
"type": "customer",
"title": f"{member_name} — 消费概览",
"summary": f"余额 ¥{balance},近30天消费 ¥{consume_30d}",
"data": {
"余额": f"¥{balance}",
"近30天消费": f"¥{consume_30d}",
"到店次数": f"{visit_count}次",
}
}
组件 3:Pydantic Schema(CamelModel)
位置:apps/backend/app/schemas/xcx_chat.py(新文件)
职责:定义 CHAT 模块所有请求/响应的 Pydantic schema,继承 CamelModel 统一 camelCase 输出。
from app.schemas.base import CamelModel
class ChatHistoryItem(CamelModel):
id: int
title: str
customer_name: str | None = None
last_message: str | None = None
timestamp: str # ISO 8601,最后消息时间
unread_count: int = 0
class ChatHistoryResponse(CamelModel):
items: list[ChatHistoryItem]
total: int
page: int
page_size: int
class ReferenceCard(CamelModel):
type: str # 'customer' | 'record'
title: str
summary: str
data: dict[str, str] # 键值对详情
class ChatMessageItem(CamelModel):
id: int
role: str # 'user' | 'assistant'
content: str
created_at: str # ISO 8601(统一字段名,替代 timestamp / created_at)
reference_card: ReferenceCard | None = None
class ChatMessagesResponse(CamelModel):
chat_id: int
items: list[ChatMessageItem]
total: int
page: int
page_size: int
class SendMessageRequest(CamelModel):
content: str
class SendMessageResponse(CamelModel):
user_message: MessageBrief
ai_reply: MessageBrief
class MessageBrief(CamelModel):
id: int
content: str
created_at: str
class ChatStreamRequest(CamelModel):
chat_id: int
content: str
组件 4:前端 chat 页面改造
位置:apps/miniprogram/miniprogram/pages/chat/chat.ts
改造要点:
-
多入口参数路由(GAP-49/50):
onLoad(options) { if (options.historyId) { // 从 chat-history 跳转:直接用 historyId 作为 chatId this.chatId = options.historyId this.loadMessages(this.chatId) } else if (options.taskId) { // 从 task-detail 跳转:同一 taskId 始终复用同一对话 this.loadMessagesByContext('task', options.taskId) } else if (options.customerId) { // 从 customer-detail 跳转:3 天内复用,超过 3 天新建 this.loadMessagesByContext('customer', options.customerId) } else if (options.coachId) { // 从 coach-detail 跳转:3 天内复用,超过 3 天新建 this.loadMessagesByContext('coach', options.coachId) } else { // 无参数:始终新建通用对话 this.loadMessagesByContext('general', '') } } -
SSE 替换 mock 流式输出:
- 移除
simulateStreamOutput()调用和mockAIReplies - 使用
wx.request+enableChunked: true(微信基础库 2.20.2+)接收 SSE - 备选方案:轮询 CHAT-3 同步端点(不支持 chunked 的低版本基础库)
- 移除
-
referenceCard 渲染:
- 消息列表中检测
referenceCard字段,已有toDataList()和 WXML 模板,无需大改 - 确保从真实 API 返回的
referenceCard结构与 mock 一致
- 消息列表中检测
组件 5:前端 chat-history 页面改造
位置:apps/miniprogram/miniprogram/pages/chat-history/chat-history.ts
改造要点:
- 移除
mockChatHistory导入 - 调用
fetchChatHistory()获取真实数据 - 响应字段映射:后端返回
timestamp(ISO 8601)→ 前端formatRelativeTime()处理
组件 6:前端 services/api.ts CHAT 模块对接
位置:apps/miniprogram/miniprogram/services/api.ts
改造要点:
fetchChatHistory():调用GET /api/xcx/chat/historyfetchChatMessages():调用GET /api/xcx/chat/{chatId}/messagesfetchChatMessagesByContext(contextType, contextId):新增,调用GET /api/xcx/chat/messages?contextType={type}&contextId={id}sendChatMessage():调用POST /api/xcx/chat/{chatId}/messages- 移除所有 CHAT 相关 mock 数据导入
USE_REAL_API开关对 CHAT 模块设为true
组件 7:FDW 验证脚本
位置:scripts/ops/verify_fdw_e2e.py(新文件)
职责:一次性验证脚本,检查 test_zqyy_app → test_etl_feiqiu FDW 链路。
验证项:
- 所有
fdw_etl.*视图可访问(SELECT 1 FROM ... LIMIT 1) - 带典型过滤条件的查询响应时间 < 3s
- 关键索引存在检查(
chat_sessions的(assistant_id, customer_id)等) - 结果输出为结构化报告(JSON),失败项标注需 DBA 介入
组件 8:联调修复 — notes 触底加载 & customer-service-records 按月请求
notes 页面(pages/notes/notes.ts):
- 实现
onReachBottom()生命周期函数 - 维护
page状态,触底时page++调用fetchNotes({ page, pageSize }) - 追加数据到已有列表,
hasMore === false时停止加载
customer-service-records 页面(pages/customer-service-records/customer-service-records.ts):
- 月份切换时调用
fetchCustomerRecords({ customerId, year, month }) - 清空已有列表 → 显示 loading → 渲染新数据
- 首次加载默认当前月份
数据模型
表结构变更:扩展 biz.ai_conversations
现有 biz.ai_conversations 表需新增字段以支持 CHAT API 契约:
-- 迁移脚本:扩展 ai_conversations 支持 CHAT 模块
ALTER TABLE biz.ai_conversations
ADD COLUMN IF NOT EXISTS context_type varchar(20), -- 关联上下文类型:task / customer / coach / general
ADD COLUMN IF NOT EXISTS context_id varchar(50), -- 关联上下文 ID(taskId / customerId / coachId)
ADD COLUMN IF NOT EXISTS title varchar(200), -- 对话标题
ADD COLUMN IF NOT EXISTS last_message text, -- 最后一条消息摘要
ADD COLUMN IF NOT EXISTS last_message_at timestamptz; -- 最后消息时间
COMMENT ON COLUMN biz.ai_conversations.context_type IS '对话关联上下文类型:task(任务)/ customer(客户)/ coach(助教)/ general(通用)';
COMMENT ON COLUMN biz.ai_conversations.context_id IS '关联上下文 ID:task 入口为 taskId,customer 入口为 customerId,coach 入口为 coachId,general 为 NULL';
COMMENT ON COLUMN biz.ai_conversations.title IS '对话标题:自定义 > 上下文名称 > 首条消息前20字';
COMMENT ON COLUMN biz.ai_conversations.last_message IS '最后一条消息内容摘要(截断至100字)';
COMMENT ON COLUMN biz.ai_conversations.last_message_at IS '最后消息时间,用于历史列表排序和对话复用时限判断';
新增索引:
-- 上下文对话查找(按 context_type + context_id 查找可复用对话)
CREATE INDEX idx_ai_conv_context
ON biz.ai_conversations (user_id, site_id, context_type, context_id, last_message_at DESC NULLS LAST)
WHERE context_type IS NOT NULL;
-- 历史列表排序优化(CHAT-1: 按 last_message_at 倒序)
CREATE INDEX idx_ai_conv_last_msg
ON biz.ai_conversations (user_id, site_id, last_message_at DESC NULLS LAST);
对话复用规则
不同入口的对话创建/复用策略:
| 入口 | context_type | context_id | 复用规则 |
|---|---|---|---|
| task-detail | task |
taskId | 始终复用:同一 taskId 归为同一个对话,无时限 |
| customer-detail | customer |
customerId | 3 天时限:最后一条消息 ≤ 3 天则复用,> 3 天则新建 |
| coach-detail | coach |
coachId | 3 天时限:最后一条消息 ≤ 3 天则复用,> 3 天则新建 |
| chat-history | — | — | 直接打开:用 historyId 作为 chatId 加载已有对话 |
| 无参数(AI 按钮等) | general |
NULL | 始终新建:每次创建新对话 |
复用查找 SQL 模式:
-- task 入口:始终复用(无时限)
SELECT id FROM biz.ai_conversations
WHERE user_id = :user_id AND site_id = :site_id
AND context_type = 'task' AND context_id = :task_id
ORDER BY created_at DESC LIMIT 1;
-- customer / coach 入口:3 天时限复用
SELECT id FROM biz.ai_conversations
WHERE user_id = :user_id AND site_id = :site_id
AND context_type = :type AND context_id = :context_id
AND last_message_at > NOW() - INTERVAL '3 days'
ORDER BY last_message_at DESC LIMIT 1;
表结构变更:扩展 biz.ai_messages
-- 迁移脚本:扩展 ai_messages 支持 referenceCard
ALTER TABLE biz.ai_messages
ADD COLUMN IF NOT EXISTS reference_card jsonb; -- 引用卡片 JSON
COMMENT ON COLUMN biz.ai_messages.reference_card IS 'referenceCard JSON:{type, title, summary, data}';
变更后完整表结构
biz.ai_conversations(扩展后)
| 字段 | 类型 | 说明 |
|---|---|---|
id |
bigint PK | 对话 ID(即 chatId) |
user_id |
varchar(50) | 用户 ID(助教) |
nickname |
varchar(100) | 用户昵称 |
app_id |
varchar(30) | AI 应用 ID(CHAT 模块固定为 app1_chat) |
site_id |
bigint | 门店 ID |
source_page |
varchar(100) | 来源页面 |
source_context |
jsonb | 来源上下文 |
created_at |
timestamptz | 创建时间 |
customer_id |
— | 已移除 — 改用 context_type + context_id 通用方案 |
context_type |
varchar(20) | 新增 — 对话关联上下文类型:task/customer/coach/general |
context_id |
varchar(50) | 新增 — 关联上下文 ID(taskId/customerId/coachId) |
title |
varchar(200) | 新增 — 对话标题 |
last_message |
text | 新增 — 最后消息摘要 |
last_message_at |
timestamptz | 新增 — 最后消息时间 |
biz.ai_messages(扩展后)
| 字段 | 类型 | 说明 |
|---|---|---|
id |
bigint PK | 消息 ID |
conversation_id |
bigint FK | 对话 ID |
role |
varchar(10) | 角色:user / assistant |
content |
text | 消息内容 |
tokens_used |
integer | token 消耗量 |
created_at |
timestamptz | 创建时间 |
reference_card |
jsonb | 新增 — 引用卡片 |
字段映射:数据库 → API 响应
| 数据库字段 | API 响应字段(camelCase) | 说明 |
|---|---|---|
ai_conversations.id |
id / chatId |
对话 ID |
ai_conversations.title |
title |
对话标题 |
ai_conversations.last_message |
lastMessage |
最后消息摘要 |
ai_conversations.last_message_at |
timestamp |
CHAT-1 历史列表时间 |
ai_conversations.context_id → 当 context_type=customer 时 JOIN v_dim_member |
customerName |
客户姓名(仅 context_type=customer 时有值) |
ai_messages.id |
id |
消息 ID |
ai_messages.role |
role |
消息角色 |
ai_messages.content |
content |
消息内容 |
ai_messages.created_at |
createdAt |
统一时间字段名 |
ai_messages.reference_card |
referenceCard |
引用卡片 JSON |
referenceCard JSON 结构
{
"type": "customer",
"title": "张伟 — 消费概览",
"summary": "余额 ¥5,200,近30天消费 ¥2,380",
"data": {
"余额": "¥5,200",
"近30天消费": "¥2,380",
"到店次数": "8次",
"最近到店": "3天前"
}
}
DWD-DOC 强制规则在数据模型中的体现
| 规则 | 影响范围 | 实施方式 |
|---|---|---|
items_sum 口径 |
referenceCard 中"近30天消费" | SQL 使用 items_sum 字段,禁用 consume_money |
| 助教费用拆分 | referenceCard 中如涉及助教费用 | 使用 assistant_pd_money + assistant_cx_money |
| 会员信息 JOIN | referenceCard 中客户姓名 | 通过 member_id JOIN fdw_etl.v_dim_member(scd2_is_current=1),禁用结算单冗余字段 |
正确性属性
属性(Property)是一个在系统所有合法执行路径上都应成立的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。
Property 1: 路由迁移完整性
For any CHAT 端点路径(/history、/{chatId}/messages、/messages、/{chatId}/messages POST、/stream),以 /api/xcx/chat 为前缀的请求应返回非 404 响应(需认证),而以 /api/ai 为前缀的对应旧路径应返回 404。
Validates: Requirements 1.1, 1.2
Property 2: CHAT API 响应结构完整性
For any CHAT-1 历史列表项,响应必须包含 id、title、lastMessage、timestamp 字段;For any CHAT-2 消息项,响应必须包含 id、role、content、createdAt 字段;For any CHAT-3 发送消息响应,必须包含 userMessage 和 aiReply,各含 id、content、createdAt 字段。
Validates: Requirements 2.2, 3.3, 5.3
Property 3: 列表排序不变量
For any CHAT-1 返回的对话历史列表,相邻两项的 timestamp 应满足前项 ≥ 后项(按时间倒序);For any CHAT-2 返回的消息列表,相邻两项的 createdAt 应满足前项 ≤ 后项(按时间正序)。
Validates: Requirements 2.3, 3.5
Property 4: 对话标题生成优先级
For any 对话记录,标题生成应遵循优先级链:若 title 字段非空则使用 title;否则若 customer_id 关联的客户姓名非空则使用客户姓名;否则使用首条消息内容的前 20 个字符。生成结果应始终为非空字符串。
Validates: Requirements 2.4
Property 5: 权限控制与数据隔离
For any CHAT 端点(CHAT-1/2/3/4),未通过审核的用户(status ≠ approved)应收到 HTTP 403 响应;For any 已认证用户请求的对话数据,返回的所有对话记录的 user_id 应等于当前用户 ID;For any 不属于当前用户的 chatId,CHAT-3 和 CHAT-4 应返回 HTTP 403。
Validates: Requirements 2.6, 5.6, 11.1, 11.2, 11.3
Property 6: 对话复用规则正确性
For any context_type='task' 的入口,同一 (user_id, site_id, context_id) 多次调用 get_or_create_session 应始终返回同一个 chatId;For any context_type='customer' 或 context_type='coach' 的入口,若最后消息时间 ≤ 3 天则返回已有 chatId,若 > 3 天则返回新的 chatId;For any context_type='general' 的入口,每次调用应返回不同的 chatId。
Validates: Requirements 3.8, 3.9, 3.10
Property 7: referenceCard 持久化 Round Trip
For any 合法的 referenceCard JSON 对象(包含 type、title、summary、data 字段),存入 ai_messages.reference_card 后再读取,应得到与原始对象结构等价的 JSON。
Validates: Requirements 4.1, 4.3
Property 8: 消息持久化与会话元数据更新
For any 通过 CHAT-3 或 CHAT-4 发送的消息,用户消息和 AI 回复均应被持久化到 ai_messages 表;发送后对应 ai_conversations 记录的 last_message 应更新为最新消息内容,last_message_at 应更新为最新消息时间。
Validates: Requirements 5.2, 5.4, 6.3, 6.4
Property 9: SSE 事件类型有效性
For any CHAT-4 SSE 流中的事件,其 event 字段应为 message、done、error 三者之一;message 事件的 data 应包含 token 字段;done 事件的 data 应包含 messageId 和 createdAt 字段;error 事件的 data 应包含 message 字段。
Validates: Requirements 6.2
错误处理
后端错误处理
所有 CHAT 端点的错误响应遵循 RNS1.0 全局异常处理器格式:{ code: <HTTP状态码>, message: <错误详情> }。
| 场景 | HTTP 状态码 | 响应 | 处理方式 |
|---|---|---|---|
| 未认证(无 token / token 过期) | 401 | { code: 401, message: "无效的令牌" } |
get_current_user 依赖抛出 |
| 未通过审核(status ≠ approved) | 403 | { code: 403, message: "用户未通过审核,无法访问此资源" } |
require_approved() 依赖抛出 |
| chatId 不属于当前用户 | 403 | { code: 403, message: "无权访问此对话" } |
chat_service 验证后抛出 |
| chatId 不存在 | 404 | { code: 404, message: "对话不存在" } |
chat_service 查询后抛出 |
| 消息内容为空 | 422 | { code: 422, message: "消息内容不能为空" } |
路由层 Pydantic 验证 |
| AI 服务调用失败(CHAT-3) | 200 | aiReply.content = "抱歉,AI 助手暂时无法回复,请稍后重试" |
用户消息仍保存,AI 回复为错误提示 |
| AI 服务调用失败(CHAT-4 SSE) | SSE error 事件 | event: error\ndata: {"message": "AI 服务暂时不可用"} |
流中发送 error 事件后关闭 |
| 数据库连接失败 | 500 | { code: 500, message: "Internal Server Error" } |
全局 unhandled_exception_handler |
| FDW 查询失败(referenceCard) | 静默降级 | referenceCard 返回 null |
不影响消息本身,仅 referenceCard 缺失 |
CHAT-3 AI 失败降级策略
async def send_message_sync(self, chat_id, content, user_id, site_id):
# 1. 存入用户消息(无论 AI 是否成功)
user_msg_id = self._save_message(chat_id, "user", content)
# 2. 调用 AI
try:
ai_reply = await self._call_ai(content, chat_id)
except Exception as e:
logger.error("AI 服务调用失败: %s", e)
ai_reply = "抱歉,AI 助手暂时无法回复,请稍后重试"
# 3. 存入 AI 回复(包括错误提示)
ai_msg_id = self._save_message(chat_id, "assistant", ai_reply)
# 4. 更新 session 元数据
self._update_session_metadata(chat_id, ai_reply)
# 5. HTTP 200 返回(不抛异常)
return { "userMessage": {...}, "aiReply": {...} }
前端错误处理
| 场景 | 处理方式 |
|---|---|
| CHAT API 返回 401 | 跳转登录页(request() 全局拦截) |
| CHAT API 返回 403 | Toast 提示"权限不足" |
| CHAT API 返回 404 | Toast 提示"对话不存在" |
| CHAT API 返回 500 | Toast 提示"服务暂时不可用" |
| SSE 连接中断 | 停止流式显示,显示"连接中断"提示,允许重试 |
| 网络超时 | wx.request fail 回调,显示网络错误提示 |
测试策略
双轨测试方法
RNS1.4 采用属性测试(Property-Based Testing)+ 单元测试(Unit Testing)双轨并行:
- 属性测试:验证对话管理、消息持久化、权限控制、referenceCard round trip 等通用规则
- 单元测试:验证具体端点行为、边界条件、AI 失败降级等
属性测试配置
- 测试库:Hypothesis(Python,项目已使用)
- 测试位置:
tests/目录(Monorepo 级属性测试) - 最小迭代次数:每个属性测试 100 次(
@settings(max_examples=100)) - 标签格式:每个测试函数的 docstring 中标注
Feature: rns1-chat-integration, Property {N}: {property_text}
属性测试清单
| Property | 测试函数 | 生成器 | 验证逻辑 |
|---|---|---|---|
| P3: 列表排序不变量 | test_chat_list_ordering |
st.lists(st.datetimes()) 生成随机时间戳列表 |
对话列表按时间倒序,消息列表按时间正序 |
| P4: 标题生成优先级 | test_title_generation_priority |
st.fixed_dictionaries({"title": st.one_of(st.none(), st.text(min_size=1)), "customer_name": st.one_of(st.none(), st.text(min_size=1)), "first_message": st.text(min_size=1)}) |
标题遵循优先级链,结果非空 |
| P6: customerId 幂等性 | test_customer_id_get_or_create_idempotent |
st.integers(min_value=1) 生成随机 user_id 和 customer_id |
多次调用返回同一 chatId |
| P7: referenceCard Round Trip | test_reference_card_roundtrip |
st.fixed_dictionaries({"type": st.sampled_from(["customer", "record"]), "title": st.text(min_size=1), "summary": st.text(), "data": st.dictionaries(st.text(min_size=1), st.text())}) |
JSON 序列化→存储→读取→反序列化等于原始对象 |
| P8: 消息持久化 | test_message_persistence_after_send |
st.text(min_size=1, max_size=500) 生成随机消息内容 |
发送后 ai_messages 包含用户消息和 AI 回复,session 元数据已更新 |
| P9: SSE 事件类型 | test_sse_event_type_validity |
st.sampled_from(["message", "done", "error"]) + 对应 data 结构 |
事件类型为三者之一,data 结构符合定义 |
单元测试清单
| 测试目标 | 测试文件 | 关键用例 |
|---|---|---|
| 路由迁移(P1) | apps/backend/tests/unit/test_xcx_chat_routes.py |
/api/xcx/chat/history 返回 200;/api/ai/conversations 返回 404 |
| 响应结构(P2) | apps/backend/tests/unit/test_xcx_chat_schema.py |
ChatHistoryItem / ChatMessageItem / SendMessageResponse 序列化验证 |
| 权限控制(P5) | apps/backend/tests/unit/test_xcx_chat_auth.py |
未审核用户 403;chatId 不属于当前用户 403 |
| AI 失败降级(edge case) | apps/backend/tests/unit/test_xcx_chat_ai_fallback.py |
AI 超时时返回错误提示消息,HTTP 200 |
| SSE 跳过包装 | 已由 RNS1.0 测试覆盖 | text/event-stream 不经过 ResponseWrapper |
| FDW 验证 | scripts/ops/verify_fdw_e2e.py |
一次性运行,输出验证报告 |
| 联调验证 | 手动测试 | 13 页面逐一验证真实数据渲染 |
测试执行命令
# 属性测试(Hypothesis)
cd C:\NeoZQYY && pytest tests/ -v -k "rns1_chat"
# 单元测试
cd apps/backend && pytest tests/unit/ -v -k "xcx_chat"
# FDW 验证脚本
cd C:\NeoZQYY && uv run python scripts/ops/verify_fdw_e2e.py