705 lines
30 KiB
Markdown
705 lines
30 KiB
Markdown
# 技术设计文档 — 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<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 事件
|
||
```
|
||
|
||
|
||
## 组件与接口
|
||
|
||
### 组件 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: <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` | 未审核用户 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
|
||
```
|
||
|