21 KiB
技术设计文档 — RNS1.0:基础设施与契约重写
概述
RNS1.0 是 NS1 小程序后端 API 补全项目的基础设施层,阻塞所有后续子 spec(RNS1.1-1.4)。本设计覆盖 6 个任务:
- 全局响应包装中间件(T0-1):ASGI 中间件 + FastAPI 异常处理器,统一
{ code: 0, data }格式 - Pydantic camelCase 统一(T0-2):基类
CamelModel配置alias_generator=to_camel - 路由路径修正(T0-3):
/cancel-abandon→/restore - 前端 request() 解包(T0-4):
.data自动提取 + 错误码处理 - API 契约完全重写(T0-5):8 个接口的响应定义重写为独立文档
- 前端跨页面参数修复(T0-6):8 个页面间的参数传递统一用唯一 ID
设计原则
- 最小侵入:全局中间件对现有 16 个端点透明生效,无需逐个修改路由函数
- 契约驱动:API 契约文档是后续子 spec 实现的唯一基准,前后端共同遵守
- DWD-DOC 强制规则:所有金额字段使用
items_sum口径,助教费用使用assistant_pd_money+assistant_cx_money拆分
架构
整体架构
RNS1.0 的改动集中在 3 个层面:
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
请求-响应流程
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 直接透传 - 性能更优,无额外的请求体/响应体缓冲开销
接口定义:
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 格式化)
...
跳过条件:
content-type为text/event-stream(SSE 端点)content-type不包含application/json(文件下载等)- HTTP 状态码非 2xx(错误响应已由 ExceptionHandler 格式化)
组件 2:ExceptionHandler(FastAPI 异常处理器)
位置:apps/backend/app/middleware/response_wrapper.py(与中间件同文件)
职责:捕获 HTTPException 和未处理异常,统一格式化为 { code, message }。
接口定义:
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):
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 输出。
接口定义:
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 字段。
改动逻辑:
// 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
改动:
# 修改前
@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 |
数据模型
全局响应包装格式
// 成功响应
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 板块嵌套结构)
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 维度专属字段)
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 维度专属字段)
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 分组结构)
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
错误处理
后端错误处理层次
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(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 参数正确 |
测试执行命令
# 属性测试(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 从契约定义生成)
- 前端联调时,逐页面验证数据渲染正确