# 技术设计文档 — 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:\Project\NeoZQYY && pytest tests/ -v -k "rns1_chat"

# 单元测试
cd apps/backend && pytest tests/unit/ -v -k "xcx_chat"

# FDW 验证脚本
cd C:\Project\NeoZQYY && uv run python scripts/ops/verify_fdw_e2e.py
```

