# 技术设计文档 — RNS1.4:CHAT 对齐与联调收尾 ## 概述 RNS1.4 是 RNS1 系列的收尾 spec,覆盖三大块工作: 1. **CHAT 模块路径迁移与功能补全**(T4-1 ~ T4-3):将现有 `/api/ai/*` 路由迁移到 `/api/xcx/chat/*`,实现 CHAT-1/2/3/4 四个端点,支持 referenceCard 和多入口参数路由 2. **FDW 端到端验证**(T4-4):验证 `test_zqyy_app` → `test_etl_feiqiu` 链路上所有 FDW 视图可访问、性能达标、索引完备 3. **全量前后端联调**(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_id` JOIN `dim_member` ### 依赖关系 ```mermaid graph LR RNS10[RNS1.0
基础设施] --> RNS14[RNS1.4
CHAT + 联调] RNS11[RNS1.1
任务/绩效] --> RNS14 RNS12[RNS1.2
客户/助教] --> RNS14 RNS13[RNS1.3
三看板] --> RNS14 style RNS14 fill:#f9f,stroke:#333 ``` ## 架构 ### 整体架构 ```mermaid 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
SSE 自动跳过] ROUTER[routers/xcx_chat.py
CHAT-1/2/3/4] SCHEMA[schemas/xcx_chat.py
Pydantic CamelModel] SVC[services/chat_service.py
对话业务逻辑] AI_SVC[ai/conversation_service.py
AI 调用 + 持久化] BAILIAN[ai/bailian_client.py
百炼 API] FDW_Q[services/fdw_queries.py
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
biz.ai_conversations
biz.ai_messages)] ETL_DB[(etl_feiqiu via FDW
fdw_etl.v_dim_member
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 流式) ```mermaid 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
{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`(不保留旧路径兼容) **端点定义**: ```python 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 组装。 **关键方法**: ```python 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 组装逻辑**: ```python 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 输出。 ```python 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` **改造要点**: 1. **多入口参数路由**(GAP-49/50): ```typescript 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', '') } } ``` 2. **SSE 替换 mock 流式输出**: - 移除 `simulateStreamOutput()` 调用和 `mockAIReplies` - 使用 `wx.request` + `enableChunked: true`(微信基础库 2.20.2+)接收 SSE - 备选方案:轮询 CHAT-3 同步端点(不支持 chunked 的低版本基础库) 3. **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/history` - `fetchChatMessages()`:调用 `GET /api/xcx/chat/{chatId}/messages` - `fetchChatMessagesByContext(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 链路。 **验证项**: 1. 所有 `fdw_etl.*` 视图可访问(SELECT 1 FROM ... LIMIT 1) 2. 带典型过滤条件的查询响应时间 < 3s 3. 关键索引存在检查(`chat_sessions` 的 `(assistant_id, customer_id)` 等) 4. 结果输出为结构化报告(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 契约: ```sql -- 迁移脚本:扩展 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 '最后消息时间,用于历史列表排序和对话复用时限判断'; ``` **新增索引**: ```sql -- 上下文对话查找(按 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 模式: ```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` ```sql -- 迁移脚本:扩展 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 结构 ```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: , 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 失败降级策略 ```python 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](https://hypothesis.readthedocs.io/)(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 页面逐一验证真实数据渲染 | ### 测试执行命令 ```bash # 属性测试(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 ```