Files
Neo-ZQYY/.kiro/specs/rns1-infra-contract-rewrite/design.md

553 lines
21 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.0:基础设施与契约重写
## 概述
RNS1.0 是 NS1 小程序后端 API 补全项目的基础设施层,阻塞所有后续子 specRNS1.1-1.4)。本设计覆盖 6 个任务:
1. **全局响应包装中间件**T0-1ASGI 中间件 + 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-58 个接口的响应定义重写为独立文档
6. **前端跨页面参数修复**T0-68 个页面间的参数传递统一用唯一 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
```
## 组件与接口
### 组件 1ResponseWrapperMiddlewareASGI 中间件)
**位置**`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 格式化)
### 组件 2ExceptionHandlerFastAPI 异常处理器)
**位置**`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)
```
### 组件 3CamelModelPydantic 基类)
**位置**`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_camelJSON 输出字段名自动转 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()`
- 不保留旧路径兼容映射
### 组件 6API 契约文档
**位置**`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:\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 从契约定义生成)
- 前端联调时,逐页面验证数据渲染正确