Files
Neo-ZQYY/.kiro/specs/rns1-chat-integration/design.md

705 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 技术设计文档 — RNS1.4CHAT 对齐与联调收尾
## 概述
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-513 个页面移除 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<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
```
## 架构
### 整体架构
```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<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 流式)
```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<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 事件
```
## 组件与接口
### 组件 1xcx_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: ...
```
### 组件 2chat_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}",
}
}
```
### 组件 3Pydantic SchemaCamelModel
**位置**`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`
### 组件 7FDW 验证脚本
**位置**`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), -- 关联上下文 IDtaskId / 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 '关联上下文 IDtask 入口为 taskIdcustomer 入口为 customerIdcoach 入口为 coachIdgeneral 为 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 应用 IDCHAT 模块固定为 `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) | **新增** — 关联上下文 IDtaskId/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: <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 失败降级策略
```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` | 未审核用户 403chatId 不属于当前用户 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
```