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

21 KiB
Raw Blame History

技术设计文档 — 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 个层面:

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

组件与接口

组件 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 直接透传
  • 性能更优,无额外的请求体/响应体缓冲开销

接口定义

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-typetext/event-streamSSE 端点)
  2. content-type 不包含 application/json(文件下载等)
  3. HTTP 状态码非 2xx错误响应已由 ExceptionHandler 格式化)

组件 2ExceptionHandlerFastAPI 异常处理器)

位置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)

组件 3CamelModelPydantic 基类)

位置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_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,
    )

迁移策略

  • 所有现有 schemaxcx_tasks.pyxcx_auth.pyxcx_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()
  • 不保留旧路径兼容映射

组件 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 时传 customerIdloadDetail()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_typetaskTypepriority_scorepriorityScoreis_pinnedisPinnedexpires_atexpiresAtcreated_atcreatedAtmember_idmemberIdmember_namememberNamemember_phonememberPhoners_scorersScoreheart_iconheartIconabandon_reasonabandonReason
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 状态码 status400-599 范围)和任意非空字符串 detail,当路由抛出 HTTPException(status_code=status, detail=detail) 时,ExceptionHandler 的输出 JSON 应满足 output.code == statusoutput.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-streamapplication/octet-streamtext/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=nmessage=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 转换、前端解包等通用规则在所有输入上的正确性
  • 单元测试:验证具体的路由路径修改、参数传递修复、边界条件等

属性测试配置

  • 测试库HypothesisPython已在项目中使用.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=0data;解包 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 从契约定义生成)
  • 前端联调时,逐页面验证数据渲染正确