# 技术设计文档 — RNS1.0：基础设施与契约重写

## 概述

RNS1.0 是 NS1 小程序后端 API 补全项目的基础设施层，阻塞所有后续子 spec（RNS1.1-1.4）。本设计覆盖 6 个任务：

1. **全局响应包装中间件**（T0-1）：ASGI 中间件 + FastAPI 异常处理器，统一 `{ code: 0, data }` 格式
2. **Pydantic camelCase 统一**（T0-2）：基类 `CamelModel` 配置 `alias_generator=to_camel`
3. **路由路径修正**（T0-3）：`/cancel-abandon` → `/restore`
4. **前端 request() 解包**（T0-4）：`.data` 自动提取 + 错误码处理
5. **API 契约完全重写**（T0-5）：8 个接口的响应定义重写为独立文档
6. **前端跨页面参数修复**（T0-6）：8 个页面间的参数传递统一用唯一 ID

### 设计原则

- **最小侵入**：全局中间件对现有 16 个端点透明生效，无需逐个修改路由函数
- **契约驱动**：API 契约文档是后续子 spec 实现的唯一基准，前后端共同遵守
- **DWD-DOC 强制规则**：所有金额字段使用 `items_sum` 口径，助教费用使用 `assistant_pd_money` + `assistant_cx_money` 拆分

## 架构

### 整体架构

RNS1.0 的改动集中在 3 个层面：

```mermaid
graph TB
    subgraph "微信小程序 (apps/miniprogram/)"
        A[services/api.ts] --> B[utils/request.ts]
        C[各页面 .ts] --> A
    end

    subgraph "FastAPI 后端 (apps/backend/app/)"
        D[ResponseWrapperMiddleware<br/>ASGI 中间件] --> E[ExceptionHandler<br/>FastAPI 异常处理器]
        E --> F[routers/xcx_*.py<br/>路由层]
        F --> G[services/*.py<br/>业务逻辑层]
        G --> H[database.py<br/>数据库连接]
    end

    subgraph "数据库"
        I[(zqyy_app<br/>业务库)]
        J[(etl_feiqiu<br/>ETL 库 via FDW)]
    end

    B -->|HTTP JSON| D
    H --> I
    H --> J

    style D fill:#f9f,stroke:#333
    style E fill:#f9f,stroke:#333
    style B fill:#bbf,stroke:#333
```

### 请求-响应流程

```mermaid
sequenceDiagram
    participant MP as 小程序 request()
    participant MW as ResponseWrapperMiddleware
    participant EH as ExceptionHandler
    participant R as Router 端点
    participant S as Service 层

    MP->>MW: HTTP Request
    MW->>R: 透传请求
    R->>S: 调用业务逻辑

    alt 成功
        S-->>R: 返回数据
        R-->>MW: JSONResponse(data)
        MW-->>MP: { code: 0, data: ... }
    end

    alt HTTPException
        S-->>EH: raise HTTPException(status, detail)
        EH-->>MW: JSONResponse({ code, message })
        MW-->>MP: { code: status_code, message: detail }
    end

    alt 未捕获异常
        S-->>EH: raise Exception
        EH-->>MW: JSONResponse({ code: 500, message })
        Note over EH: 完整堆栈写入服务端日志
        MW-->>MP: { code: 500, message: "Internal Server Error" }
    end

    alt SSE 流式
        S-->>R: StreamingResponse(text/event-stream)
        R-->>MW: StreamingResponse
        MW-->>MP: 直接透传，不包装
    end
```


## 组件与接口

### 组件 1：ResponseWrapperMiddleware（ASGI 中间件）

**位置**：`apps/backend/app/middleware/response_wrapper.py`

**职责**：拦截所有 HTTP 响应，对 JSON 成功响应自动包装为 `{ code: 0, data }` 格式。

**选型理由**：选择 ASGI 中间件而非 FastAPI 中间件（`BaseHTTPMiddleware`），原因：
- `BaseHTTPMiddleware` 会将 `StreamingResponse` 缓冲为完整响应体，破坏 SSE 流式传输
- ASGI 中间件可在 `http.response.start` 阶段检查 `content-type`，对 SSE 直接透传
- 性能更优，无额外的请求体/响应体缓冲开销

**接口定义**：

```python
class ResponseWrapperMiddleware:
    """ASGI 中间件：全局响应包装。"""

    def __init__(self, app: ASGIApp):
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send):
        # 仅处理 HTTP 请求
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return

        # 拦截响应头，检查 content-type
        # - text/event-stream → 透传
        # - 非 application/json → 透传
        # - application/json + 2xx → 包装为 { code: 0, data: <原始body> }
        # - application/json + 非 2xx → 透传（已由 ExceptionHandler 格式化）
        ...
```

**跳过条件**：
1. `content-type` 为 `text/event-stream`（SSE 端点）
2. `content-type` 不包含 `application/json`（文件下载等）
3. HTTP 状态码非 2xx（错误响应已由 ExceptionHandler 格式化）

### 组件 2：ExceptionHandler（FastAPI 异常处理器）

**位置**：`apps/backend/app/middleware/response_wrapper.py`（与中间件同文件）

**职责**：捕获 `HTTPException` 和未处理异常，统一格式化为 `{ code, message }`。

**接口定义**：

```python
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
    """HTTPException → { code: <status_code>, message: <detail> }"""
    return JSONResponse(
        status_code=exc.status_code,
        content={"code": exc.status_code, "message": exc.detail},
    )

async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
    """未捕获异常 → { code: 500, message: "Internal Server Error" }
    完整堆栈写入服务端日志。"""
    logger.exception("未捕获异常: %s", exc)
    return JSONResponse(
        status_code=500,
        content={"code": 500, "message": "Internal Server Error"},
    )
```

**注册方式**（`main.py`）：

```python
from app.middleware.response_wrapper import (
    ResponseWrapperMiddleware,
    http_exception_handler,
    unhandled_exception_handler,
)

# 异常处理器（在路由注册之后）
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(Exception, unhandled_exception_handler)

# ASGI 中间件（在 CORS 之后添加，注意顺序：后添加的先执行）
app.add_middleware(ResponseWrapperMiddleware)
```

### 组件 3：CamelModel（Pydantic 基类）

**位置**：`apps/backend/app/schemas/base.py`

**职责**：所有 Pydantic 响应 schema 的基类，统一配置 camelCase 输出。

**接口定义**：

```python
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

class CamelModel(BaseModel):
    """所有小程序 API 响应 schema 的基类。

    - alias_generator=to_camel：JSON 输出字段名自动转 camelCase
    - populate_by_name=True：同时接受 snake_case 和 camelCase 输入
    - from_attributes=True：支持从 ORM 对象/dict 构造
    """
    model_config = ConfigDict(
        alias_generator=to_camel,
        populate_by_name=True,
        from_attributes=True,
    )
```

**迁移策略**：
- 所有现有 schema（`xcx_tasks.py`、`xcx_auth.py`、`xcx_notes.py`）的 `BaseModel` 替换为 `CamelModel`
- 路由函数中 `response_model` 不变，Pydantic 自动使用 alias 序列化
- 新增 schema 统一继承 `CamelModel`

### 组件 4：前端 request() 解包

**位置**：`apps/miniprogram/miniprogram/utils/request.ts`

**职责**：从全局包装中自动提取 `.data` 字段。

**改动逻辑**：

```typescript
// request() 函数返回值处理（伪代码）
const res = await wx.request({ ... })

// 检查是否为标准包装格式
if (res.data && typeof res.data === 'object' && 'code' in res.data) {
  if (res.data.code === 0) {
    return res.data.data  // 成功：返回业务数据
  } else {
    throw { code: res.data.code, message: res.data.message }  // 错误：抛出
  }
}
// 非标准格式（SSE 等）：直接返回
return res.data
```

### 组件 5：路由路径修正

**位置**：`apps/backend/app/routers/xcx_tasks.py`

**改动**：

```python
# 修改前
@router.post("/{task_id}/cancel-abandon")
async def cancel_abandon(task_id: int, ...):

# 修改后
@router.post("/{task_id}/restore")
async def restore_task(task_id: int, ...):
```

- 函数名从 `cancel_abandon` 改为 `restore_task`（语义更清晰）
- 业务逻辑不变，仍调用 `task_manager.cancel_abandon()`
- 不保留旧路径兼容映射

### 组件 6：API 契约文档

**位置**：`docs/miniprogram-dev/API-contract.md`（原地重写）

**组织结构**：按接口分节，每个接口包含：
- 端点路径 + HTTP 方法
- 请求参数（Query / Path / Body）
- 响应结构（TypeScript 类型定义 + 字段说明表）
- 数据源标注（FDW 表名或业务表名）
- DWD-DOC 强制规则标注（涉及金额/会员字段时）

重写范围：BOARD-1/2/3、CUST-1、COACH-1、PERF-1、TASK-1 performance、CHAT-1/2（共 8 个接口）。

### 组件 7：前端跨页面参数修复

**涉及文件**：

| 页面文件 | 修改内容 |
|---------|---------|
| `pages/task-detail/task-detail.ts` | 跳转 chat/customer-service-records 时传 `customerId` 而非 `detail.id` |
| `pages/customer-detail/customer-detail.ts` | 跳转 chat/customer-service-records 时传 `customerId`；`loadDetail()` 从 `onLoad(options)` 获取 ID |
| `pages/coach-detail/coach-detail.ts` | 任务项跳转 customer-detail 时传 `id={customerId}` 而非 `name` |
| `pages/performance/performance.ts` | 跳转 task-detail 时传 `id={taskId}` 而非 `customerName` |
| `pages/chat/chat.ts` | 支持 `customerId`/`historyId`/`coachId` 三种入口参数路由 |
| `app.ts` | 登录后将 `role`/`storeName`/`coachLevel`/`avatar` 存入 `globalData.authUser` |


## 数据模型

### 全局响应包装格式

```typescript
// 成功响应
interface SuccessResponse<T> {
  code: 0
  data: T
}

// 错误响应
interface ErrorResponse {
  code: number    // HTTP 状态码（400/401/403/404/500 等）
  message: string // 错误描述
}
```

### CamelModel 基类影响的现有 Schema

以下现有 schema 需从 `BaseModel` 迁移到 `CamelModel`：

| Schema 文件 | 类名 | 影响字段（snake_case → camelCase） |
|------------|------|----------------------------------|
| `schemas/xcx_tasks.py` | `TaskListItem` | `task_type` → `taskType`、`priority_score` → `priorityScore`、`is_pinned` → `isPinned`、`expires_at` → `expiresAt`、`created_at` → `createdAt`、`member_id` → `memberId`、`member_name` → `memberName`、`member_phone` → `memberPhone`、`rs_score` → `rsScore`、`heart_icon` → `heartIcon`、`abandon_reason` → `abandonReason` |
| `schemas/xcx_tasks.py` | `AbandonRequest` | `reason`（单字段，无变化） |
| `schemas/xcx_auth.py` | 所有 Auth schema | 需逐个检查字段名 |
| `schemas/xcx_notes.py` | 所有 Notes schema | 需逐个检查字段名 |

### 契约重写涉及的新增数据结构（概要）

以下为 T0-5 契约重写中定义的核心数据结构，完整定义在 API 契约文档中：

#### BOARD-3 财务看板（6 板块嵌套结构）

```typescript
interface BoardFinanceResponse {
  overview: OverviewSection       // 经营一览：8 指标 + 8 环比
  recharge: RechargeSection | null // 预收资产：储值卡 + 赠送卡矩阵（area≠all 时为 null）
  revenue: RevenueSection         // 应计收入：结构表 + 明细
  cashflow: CashflowSection       // 现金流入：消费收款 + 充值收款
  expense: ExpenseSection         // 现金流出：4 子分组
  coachAnalysis: CoachAnalysisSection // 助教分析：基础课 + 激励课
}

// 环比字段通用模式
interface CompareField {
  value: number
  compare: string    // 如 "+12.5%"
  isDown: boolean
  isFlat: boolean
}
```

#### BOARD-1 助教看板（基础字段 + 4 维度专属字段）

```typescript
interface CoachBoardItem {
  // 基础字段（所有维度共享）
  id: string
  name: string
  initial: string
  avatarGradient: string
  level: string
  skills: Array<{ text: string; cls: string }>
  topCustomers: string[]

  // perf 维度专属
  perfHours?: number
  perfHoursBefore?: number
  perfGap?: string
  perfReached?: boolean

  // salary 维度专属
  salary?: number
  salaryPerfHours?: number
  salaryPerfBefore?: number

  // sv 维度专属
  svAmount?: number
  svCustomerCount?: number
  svConsume?: number

  // task 维度专属
  taskRecall?: number
  taskCallback?: number
}
```

#### BOARD-2 客户看板（基础字段 + 8 维度专属字段）

```typescript
interface CustomerBoardItem {
  // 基础字段
  id: string
  name: string
  initial: string
  avatarCls: string
  assistants: Array<{
    name: string; cls: string; heartScore: number
    badge?: string; badgeCls?: string
  }>

  // 各维度专属字段按 dimension 参数动态返回
  // recall / potential / balance / recharge / recent / spend60 / freq60 / loyal
  [key: string]: any
}
```

#### PERF-1 绩效概览（DateGroup 分组结构）

```typescript
interface DateGroup {
  date: string          // 日期标签，如 "3月15日 周六"
  totalHours: number    // 当日总工时
  totalIncome: number   // 当日总收入
  records: Array<{
    customerName: string
    timeRange: string
    hours: number
    courseType: string
    courseTypeClass: string  // basic / vip / tip
    location: string
    income: number
  }>
}
```

### DWD-DOC 强制规则在数据模型中的体现

| 规则 | 影响范围 | 实施方式 |
|------|---------|---------|
| `items_sum` 口径 | BOARD-3 所有金额、CUST-1 消费金额、PERF-1 收入 | 契约文档标注，后端 SQL 使用 `items_sum` 字段 |
| 助教费用拆分 | BOARD-3 助教分析、CUST-1 消费记录 coaches | 使用 `assistant_pd_money` + `assistant_cx_money` |
| 会员信息 JOIN | CUST-1、BOARD-2、TASK-1 | 通过 `member_id` JOIN `dim_member`，禁用 `member_phone` |


## 正确性属性

*属性（Property）是一个在系统所有合法执行路径上都应成立的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*

RNS1.0 的核心可测试逻辑集中在全局响应包装中间件（T0-1）、camelCase 转换（T0-2）和前端解包（T0-4）。API 契约重写（T0-5）和前端参数修复（T0-6）主要是文档和具体代码修改，通过示例测试覆盖。

### Property 1: 响应包装-解包 Round Trip

*For any* 合法的 JSON 可序列化 Python 对象 `data`，经 `ResponseWrapperMiddleware` 包装为 `{ "code": 0, "data": data }` 后，再经前端 `request()` 的 `.data` 解包，应该得到与原始 `data` 结构等价的对象。

**Validates: Requirements 1.1, 4.1**

### Property 2: 异常响应保持 code 和 message

*For any* HTTP 状态码 `status`（400-599 范围）和任意非空字符串 `detail`，当路由抛出 `HTTPException(status_code=status, detail=detail)` 时，`ExceptionHandler` 的输出 JSON 应满足 `output.code == status` 且 `output.message == detail`。对于未捕获异常（edge case），输出应固定为 `code=500, message="Internal Server Error"`。

**Validates: Requirements 1.2, 1.3**

### Property 3: 非 JSON 响应透传

*For any* HTTP 响应，若其 `content-type` 不包含 `application/json`（包括 `text/event-stream`、`application/octet-stream`、`text/html` 等），`ResponseWrapperMiddleware` 应不修改响应体，输出与输入完全相同。

**Validates: Requirements 1.5, 1.6**

### Property 4: camelCase 转换 Round Trip

*For any* 继承 `CamelModel` 的 Pydantic schema 实例，将其序列化为 JSON（使用 `model_dump(by_alias=True)`）得到 camelCase 字段名的 dict，再用该 dict 反序列化回同一 schema 类（通过 `populate_by_name=True`），应得到与原始实例等价的对象。

**Validates: Requirements 2.2, 2.4**

### Property 5: 错误码解包抛出

*For any* 响应对象 `{ code: n, message: m }`，其中 `n` 为非零整数，前端 `request()` 解包时应抛出包含 `code=n` 和 `message=m` 的错误对象，不返回任何业务数据。

**Validates: Requirements 4.2**

### Property 6: 非标准格式响应透传

*For any* 响应对象，若其不包含 `code` 字段（即非全局包装格式），前端 `request()` 应直接返回原始响应体，不做任何解包或错误处理。

**Validates: Requirements 4.3**


## 错误处理

### 后端错误处理层次

```mermaid
graph TD
    A[路由层抛出 HTTPException] --> B[http_exception_handler]
    C[Service 层未捕获异常] --> D[unhandled_exception_handler]
    B --> E["{ code: status_code, message: detail }"]
    D --> F["{ code: 500, message: 'Internal Server Error' }"]
    D --> G[logger.exception 写入完整堆栈]
```

| 错误类型 | 处理方式 | 响应格式 | 日志 |
|---------|---------|---------|------|
| `HTTPException(400, "参数错误")` | `http_exception_handler` | `{ code: 400, message: "参数错误" }` | 无（业务预期错误） |
| `HTTPException(401, "未认证")` | `http_exception_handler` | `{ code: 401, message: "未认证" }` | 无 |
| `HTTPException(403, "权限不足")` | `http_exception_handler` | `{ code: 403, message: "权限不足" }` | WARNING（已有，permission.py） |
| `HTTPException(404, "资源不存在")` | `http_exception_handler` | `{ code: 404, message: "资源不存在" }` | 无 |
| `psycopg2.OperationalError` | `unhandled_exception_handler` | `{ code: 500, message: "Internal Server Error" }` | ERROR + 完整堆栈 |
| `ValueError` / `TypeError` 等 | `unhandled_exception_handler` | `{ code: 500, message: "Internal Server Error" }` | ERROR + 完整堆栈 |

### 前端错误处理

| 场景 | request() 行为 | 调用方处理 |
|------|---------------|-----------|
| `code: 0` | 返回 `data` 字段 | 正常渲染 |
| `code: 401` | 抛出 `{ code: 401, message }` | 跳转登录页 |
| `code: 403` | 抛出 `{ code: 403, message }` | 显示"权限不足"提示 |
| `code: 400/404/500` | 抛出 `{ code, message }` | 显示错误提示 toast |
| 无 `code` 字段 | 直接返回原始响应 | SSE 等特殊场景处理 |
| 网络超时/断连 | wx.request 自身 fail 回调 | 显示网络错误提示 |

### 中间件错误处理

`ResponseWrapperMiddleware` 自身的异常处理：
- 如果中间件在包装过程中出错（如 JSON 解析失败），应透传原始响应，不阻塞请求
- 中间件不应吞掉任何异常，仅做格式转换

## 测试策略

### 双轨测试方法

RNS1.0 采用属性测试（Property-Based Testing）+ 单元测试（Unit Testing）双轨并行：

- **属性测试**：验证全局响应包装、camelCase 转换、前端解包等通用规则在所有输入上的正确性
- **单元测试**：验证具体的路由路径修改、参数传递修复、边界条件等

### 属性测试配置

- **测试库**：[Hypothesis](https://hypothesis.readthedocs.io/)（Python，已在项目中使用，见 `.hypothesis/` 目录）
- **测试位置**：`tests/` 目录（Monorepo 级属性测试）
- **最小迭代次数**：每个属性测试 100 次（`@settings(max_examples=100)`）
- **标签格式**：每个测试函数的 docstring 中标注 `Feature: rns1-infra-contract-rewrite, Property {N}: {property_text}`

### 属性测试清单

| Property | 测试函数 | 生成器 | 验证逻辑 |
|----------|---------|--------|---------|
| P1: 响应包装-解包 Round Trip | `test_response_wrap_unwrap_roundtrip` | `st.from_type(dict\|list\|str\|int\|float\|bool\|None)` 生成随机 JSON 可序列化数据 | 包装后 JSON 包含 `code=0` 和 `data`；解包 `data` 等于原始输入 |
| P2: 异常响应保持 code 和 message | `test_exception_handler_preserves_code_message` | `st.integers(min_value=400, max_value=599)` × `st.text(min_size=1)` | 输出 JSON 的 `code` 等于输入状态码，`message` 等于输入 detail |
| P3: 非 JSON 响应透传 | `test_non_json_response_passthrough` | `st.sampled_from(["text/event-stream", "application/octet-stream", "text/html", ...])` × `st.binary()` | 中间件输出的响应体与输入完全相同 |
| P4: camelCase 转换 Round Trip | `test_camel_case_roundtrip` | `st.fixed_dictionaries` 生成随机 snake_case 字段名和值 | `model_dump(by_alias=True)` → `Model(**camel_dict)` → 等于原始实例 |
| P5: 错误码解包抛出 | `test_error_code_unpacker_throws` | `st.integers().filter(lambda x: x != 0)` × `st.text()` | 解包函数对 `{ code: n, message: m }` 抛出异常，异常包含 code 和 message |
| P6: 非标准格式透传 | `test_non_standard_response_passthrough` | `st.dictionaries(st.text(), st.text()).filter(lambda d: "code" not in d)` | 解包函数直接返回原始 dict |

### 单元测试清单

| 测试目标 | 测试文件 | 关键用例 |
|---------|---------|---------|
| 路由路径修正 | `tests/unit/test_xcx_tasks_route.py` | `/restore` 返回 200；`/cancel-abandon` 返回 404 |
| CamelModel 基类 | `tests/unit/test_camel_model.py` | `TaskListItem` 序列化输出 camelCase；反序列化接受两种格式 |
| 中间件跳过逻辑 | `tests/unit/test_response_wrapper.py` | SSE 端点不包装；health 端点包装；非 2xx 不二次包装 |
| 前端参数传递 | 手动联调验证 | 8 个跳转场景逐一验证 URL 参数正确 |

### 测试执行命令

```bash
# 属性测试（Hypothesis）
cd C:\Project\NeoZQYY && pytest tests/ -v -k "rns1"

# 单元测试
cd apps/backend && pytest tests/unit/ -v -k "response_wrapper or camel_model or xcx_tasks_route"
```

### 契约文档验证

API 契约重写（T0-5）的验证方式：
- 人工 review：对照前端 mock 数据结构，逐字段比对
- 后续子 spec 实现时，后端响应必须通过 Pydantic schema 验证（schema 从契约定义生成）
- 前端联调时，逐页面验证数据渲染正确

