# 技术设计文档 — 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
ASGI 中间件] --> E[ExceptionHandler
FastAPI 异常处理器] E --> F[routers/xcx_*.py
路由层] F --> G[services/*.py
业务逻辑层] G --> H[database.py
数据库连接] end subgraph "数据库" I[(zqyy_app
业务库)] J[(etl_feiqiu
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: , message: }""" 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 { 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 从契约定义生成) - 前端联调时,逐页面验证数据渲染正确