# 设计文档:小程序用户认证系统(miniapp-auth-system) ## 概述 本设计在 P1(miniapp-db-foundation)已建立的 `auth` Schema 基础上,实现完整的小程序用户认证链路: 1. **微信登录**:小程序端发送 `code` → 后端调用微信 `code2Session` → 获取 `openid` → 创建/查找用户 → 签发 JWT 2. **用户申请**:新用户填写球房ID + 手机号 + 申请身份 → 系统自动匹配助教/员工 → 管理员审核 3. **权限控制**:基于 `user_site_roles` + `role_permissions` 的 RBAC 模型,权限中间件拦截无权请求 4. **多店铺支持**:一个用户可关联多个 `site_id`,切换店铺时重新签发 JWT **环境变量依赖**: | 环境变量 | 用途 | 来源 | |---------|------|------| | `APP_DB_DSN` / `DB_HOST` 等 | 业务库连接 | 根 `.env` | | `PG_DSN` / `ETL_DB_HOST` 等 | ETL 库连接(FDW 匹配) | 根 `.env` | | `JWT_SECRET_KEY` | JWT 签名密钥 | `.env.local` | | `WX_APPID` | 微信小程序 AppID | `.env.local` | | `WX_SECRET` | 微信小程序 AppSecret | `.env.local` | **整体认证流程**: ``` 小程序端 FastAPI 后端 微信服务器 │ │ │ │── wx.login() ──► │ │ │ 获取 code │ │ │ │ │ │── POST /api/xcx/login ──► │ │ │ {code} │── GET code2Session ──────────► │ │ │◄── {openid, session_key} ──── │ │ │ │ │ │── 查找/创建 auth.users ──► │ │ │── 签发 JWT ──► │ │◄── {jwt, status} ─────── │ │ │ │ │ │ [status=pending] │ │ │── POST /api/xcx/apply ──► │ │ │ {site_code, phone, ...} │── 创建 user_applications ──► │ │◄── {application_id} ───── │ │ ``` ## 架构 ### 分层架构 ```mermaid graph TB subgraph "小程序端" MP["微信小程序
wx.login() / wx.request()"] end subgraph "FastAPI 后端(apps/backend/)" subgraph "路由层" XCX_AUTH["routers/xcx_auth.py
微信登录 + 申请"] XCX_USER["routers/xcx_user.py
用户状态 + 店铺切换"] ADMIN_APP["routers/admin_applications.py
管理端审核"] end subgraph "中间件层" PERM_MW["middleware/permission.py
权限中间件"] end subgraph "服务层" WX_SVC["services/wechat.py
code2Session 调用"] APP_SVC["services/application.py
申请 CRUD + 审核"] MATCH_SVC["services/matching.py
人员匹配"] ROLE_SVC["services/role.py
角色权限查询"] end subgraph "认证层(已有 + 扩展)" JWT["auth/jwt.py
JWT 签发/验证(扩展)"] DEPS["auth/dependencies.py
依赖注入(扩展)"] end DB["database.py
数据库连接"] end subgraph "数据库" AUTH_SCHEMA["auth Schema
users / applications / roles / ..."] FDW_ETL["fdw_etl Schema
v_dim_assistant / v_dim_staff"] end subgraph "外部服务" WX_API["微信 API
code2Session"] end MP --> XCX_AUTH MP --> XCX_USER XCX_AUTH --> PERM_MW XCX_USER --> PERM_MW ADMIN_APP --> PERM_MW PERM_MW --> JWT PERM_MW --> DEPS XCX_AUTH --> WX_SVC XCX_AUTH --> APP_SVC ADMIN_APP --> APP_SVC ADMIN_APP --> MATCH_SVC XCX_USER --> ROLE_SVC WX_SVC --> WX_API APP_SVC --> DB MATCH_SVC --> DB ROLE_SVC --> DB DB --> AUTH_SCHEMA DB --> FDW_ETL ``` ### 请求处理流程 ```mermaid sequenceDiagram participant MP as 小程序 participant MW as Permission Middleware participant R as Router participant S as Service participant DB as PostgreSQL MP->>R: POST /api/xcx/login {code} R->>S: wechat.code2session(code) S-->>R: openid R->>DB: SELECT FROM auth.users WHERE wx_openid = ? alt 用户不存在 R->>DB: INSERT INTO auth.users end R->>R: jwt.create_token_pair(user_id, site_id) R-->>MP: {access_token, refresh_token, status} Note over MP,DB: 后续请求(需认证) MP->>MW: GET /api/xcx/... (Bearer token) MW->>MW: decode_access_token(token) MW->>DB: SELECT permissions FROM auth.user_site_roles JOIN ... alt 权限不足 MW-->>MP: 403 Forbidden else 权限通过 MW->>R: 放行 R->>S: 业务逻辑 S->>DB: 数据操作 R-->>MP: 200 OK end ``` ## 组件与接口 ### 组件 1:微信认证服务(services/wechat.py) **职责**:封装微信 `code2Session` API 调用。 ```python import httpx from app.config import get WX_APPID: str = get("WX_APPID", "") WX_SECRET: str = get("WX_SECRET", "") CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session" async def code2session(code: str) -> dict: """ 调用微信 code2Session 接口。 返回: {"openid": str, "session_key": str, "unionid": str | None} 异常: WeChatAuthError: 微信接口返回错误码时抛出 """ ... class WeChatAuthError(Exception): """微信认证错误,包含 errcode 和 errmsg。""" def __init__(self, errcode: int, errmsg: str): ... ``` **设计决策**: - 使用 `httpx.AsyncClient` 异步调用微信 API,与 FastAPI 异步模型一致 - `WX_APPID` / `WX_SECRET` 从环境变量读取,缺失时在调用时报错(而非启动时,因为非所有端点都需要微信认证) ### 组件 2:申请服务(services/application.py) **职责**:处理用户申请的创建、查询、审核。 ```python async def create_application( user_id: int, site_code: str, applied_role_text: str, phone: str, employee_number: str | None = None, nickname: str | None = None, ) -> dict: """ 创建用户申请。 1. 查找 site_code → site_id 映射 2. 检查是否有 pending 申请(有则 409) 3. 插入 user_applications 记录 4. 更新 users.nickname(如提供) """ ... async def approve_application( application_id: int, reviewer_id: int, role_id: int, binding: dict | None = None, # {"assistant_id": ..., "staff_id": ..., "binding_type": ...} review_note: str | None = None, ) -> dict: """ 批准申请。 1. 检查申请状态为 pending(否则 409) 2. 更新 user_applications.status = 'approved' 3. 创建 user_site_roles 记录 4. 创建 user_assistant_binding 记录(如有 binding) 5. 更新 users.status = 'approved'(如果是首次通过) 6. 记录 reviewer_id 和 reviewed_at """ ... async def reject_application( application_id: int, reviewer_id: int, review_note: str, ) -> dict: """ 拒绝申请。 1. 检查申请状态为 pending(否则 409) 2. 更新 user_applications.status = 'rejected' 3. 记录 reviewer_id、review_note、reviewed_at """ ... async def get_user_applications(user_id: int) -> list[dict]: """查询用户的所有申请记录。""" ... ``` ### 组件 3:人员匹配服务(services/matching.py) **职责**:根据申请信息在 FDW 外部表中查找候选匹配。 ```python async def find_candidates( site_id: int, phone: str, employee_number: str | None = None, ) -> list[dict]: """ 在助教表和员工表中查找匹配候选。 查询逻辑: 1. fdw_etl.v_dim_assistant: WHERE site_id = ? AND mobile = ? 2. fdw_etl.v_dim_staff + v_dim_staff_ex: WHERE site_id = ? AND (mobile = ? OR job_num = ?) 3. 合并结果,每条包含 source_type / name / mobile / job_num 注意:查询 FDW 外部表前需设置 app.current_site_id(RLS 隔离)。 但 fdw_etl 中的外部表映射的是 app schema 的 RLS 视图, 所以需要在 ETL 库连接上设置 site_id。 实际上,我们直接在业务库通过 fdw_etl 查询, FDW 会透传 session 变量到远端。 返回: [{"source_type": "assistant"|"staff", "id": int, "name": str, "mobile": str, "job_num": str | None}] """ ... ``` **设计决策**: - FDW 查询需要在业务库连接上设置 `app.current_site_id`,因为 FDW 外部表映射的是 ETL 库 `app` Schema 的 RLS 视图 - 匹配查询使用业务库连接(`get_connection()`),通过 `SET LOCAL app.current_site_id` 设置隔离 - 如果 `site_code` 无法映射到 `site_id`,直接返回空列表 ### 组件 4:角色权限服务(services/role.py) **职责**:查询用户在指定店铺下的角色和权限。 ```python async def get_user_permissions(user_id: int, site_id: int) -> list[str]: """ 获取用户在指定 site_id 下的权限 code 列表。 SQL: SELECT DISTINCT p.code FROM auth.user_site_roles usr JOIN auth.role_permissions rp ON usr.role_id = rp.role_id JOIN auth.permissions p ON rp.permission_id = p.id WHERE usr.user_id = ? AND usr.site_id = ? """ ... async def get_user_sites(user_id: int) -> list[dict]: """ 获取用户关联的所有店铺及对应角色。 返回: [{"site_id": int, "site_name": str, "roles": [{"code": str, "name": str}]}] """ ... async def check_user_has_site_role(user_id: int, site_id: int) -> bool: """检查用户在指定 site_id 下是否有任何角色绑定。""" ... ``` ### 组件 5:权限中间件(middleware/permission.py) **职责**:基于 JWT 中的 `user_id` + `site_id` 检查用户权限。 ```python from functools import wraps from fastapi import Depends, HTTPException, status from app.auth.dependencies import get_current_user, CurrentUser def require_permission(*permission_codes: str): """ 权限装饰器/依赖,用于路由端点。 用法: @router.get("/finance") async def get_finance( user: CurrentUser = Depends(require_permission("view_board_finance")) ): ... 逻辑: 1. 从 JWT 提取 user_id + site_id 2. 查询 auth.users.status,非 approved 则 403 3. 查询 user_site_roles + role_permissions 获取权限列表 4. 检查是否包含所需权限,不包含则 403 """ ... def require_approved(): """ 仅检查用户状态为 approved 的依赖(不检查具体权限)。 用于通用的已认证端点。 """ ... ``` **设计决策**: - 使用 FastAPI 依赖注入模式而非全局中间件,更灵活且可按端点配置 - `pending` 用户只能访问申请提交和状态查询端点,其他端点需要 `approved` 状态 - 权限检查结果可考虑短期缓存(当前版本不缓存,每次查库) ### 组件 6:JWT 服务扩展(auth/jwt.py 扩展) **职责**:扩展现有 JWT 服务,支持微信登录场景。 **扩展内容**: ```python # 新增:创建受限令牌(pending 用户) def create_limited_token_pair(user_id: int) -> dict[str, str]: """ 为 pending 用户签发受限令牌。 payload 不含 site_id 和 roles,仅包含 user_id + type + limited=True。 """ ... # 扩展:create_access_token payload 增加 roles 字段 def create_access_token(user_id: int, site_id: int, roles: list[str] | None = None) -> str: """ 生成 access_token。 payload: sub=user_id, site_id, roles, type=access, exp """ ... ``` **设计决策**: - 保持向后兼容:现有 `create_access_token(user_id, site_id)` 调用不受影响(`roles` 默认 `None`) - `pending` 用户的受限令牌通过 `limited=True` 标记区分,权限中间件据此拦截 ### 组件 7:路由端点 #### 7.1 小程序认证路由(routers/xcx_auth.py) | 方法 | 路径 | 说明 | 认证要求 | |------|------|------|---------| | POST | `/api/xcx/login` | 微信登录 | 无(公开) | | POST | `/api/xcx/apply` | 提交申请 | JWT(含 pending) | | GET | `/api/xcx/me` | 查询自身状态 | JWT(含 pending) | | GET | `/api/xcx/me/sites` | 查询关联店铺 | JWT(approved) | | POST | `/api/xcx/switch-site` | 切换店铺 | JWT(approved) | | POST | `/api/xcx/refresh` | 刷新令牌 | refresh_token | #### 7.2 管理端审核路由(routers/admin_applications.py) | 方法 | 路径 | 说明 | 认证要求 | |------|------|------|---------| | GET | `/api/admin/applications` | 查询申请列表 | JWT + site_admin/tenant_admin | | GET | `/api/admin/applications/{id}` | 查询申请详情 + 候选匹配 | JWT + site_admin/tenant_admin | | POST | `/api/admin/applications/{id}/approve` | 批准申请 | JWT + site_admin/tenant_admin | | POST | `/api/admin/applications/{id}/reject` | 拒绝申请 | JWT + site_admin/tenant_admin | ### 组件 8:Pydantic 模型(schemas/xcx_auth.py) ```python from pydantic import BaseModel, Field import re class WxLoginRequest(BaseModel): code: str = Field(..., min_length=1, description="微信临时登录凭证") class WxLoginResponse(BaseModel): access_token: str refresh_token: str token_type: str = "bearer" user_status: str # pending / approved / rejected / disabled user_id: int class ApplicationRequest(BaseModel): site_code: str = Field(..., pattern=r"^[A-Za-z]{2}\d{3}$", description="球房ID") applied_role_text: str = Field(..., min_length=1, max_length=100) phone: str = Field(..., pattern=r"^\d{11}$", description="手机号") employee_number: str | None = Field(None, max_length=50) nickname: str | None = Field(None, max_length=50) class ApplicationResponse(BaseModel): id: int site_code: str applied_role_text: str status: str review_note: str | None = None created_at: str reviewed_at: str | None = None class UserStatusResponse(BaseModel): user_id: int status: str nickname: str | None applications: list[ApplicationResponse] class SiteInfo(BaseModel): site_id: int site_name: str roles: list[dict] class SwitchSiteRequest(BaseModel): site_id: int class MatchCandidate(BaseModel): source_type: str # assistant / staff id: int name: str mobile: str | None job_num: str | None class ApproveRequest(BaseModel): role_id: int binding: dict | None = None # {"assistant_id": ..., "staff_id": ..., "binding_type": ...} review_note: str | None = None class RejectRequest(BaseModel): review_note: str = Field(..., min_length=1) ``` ## 数据模型 ### ER 图 ```mermaid erDiagram users { serial id PK varchar wx_openid UK varchar wx_union_id varchar wx_avatar_url varchar nickname varchar phone varchar status "pending/approved/rejected/disabled" timestamptz created_at timestamptz updated_at } user_applications { serial id PK int user_id FK varchar site_code int site_id "可空,映射后填入" varchar applied_role_text varchar employee_number "可选" varchar phone varchar status "pending/approved/rejected" int reviewer_id text review_note timestamptz created_at timestamptz reviewed_at } site_code_mapping { serial id PK varchar site_code UK "2字母+3数字" bigint site_id UK varchar site_name int tenant_id timestamptz created_at } roles { serial id PK varchar code UK varchar name text description timestamptz created_at } permissions { serial id PK varchar code UK varchar name text description timestamptz created_at } role_permissions { int role_id FK int permission_id FK } user_site_roles { serial id PK int user_id FK bigint site_id int role_id FK timestamptz created_at } user_assistant_binding { serial id PK int user_id FK bigint site_id bigint assistant_id "可空" bigint staff_id "可空" varchar binding_type "assistant/staff/manager" timestamptz created_at } users ||--o{ user_applications : "提交申请" users ||--o{ user_site_roles : "店铺角色" users ||--o{ user_assistant_binding : "人员绑定" roles ||--o{ user_site_roles : "角色分配" roles ||--o{ role_permissions : "角色权限" permissions ||--o{ role_permissions : "权限定义" site_code_mapping ||--o{ user_applications : "球房映射" ``` ### 表 DDL 概要 所有表在 `auth` Schema 下,迁移脚本位于 `db/zqyy_app/migrations/`。 **关键约束**: - `users.wx_openid` UNIQUE — 一个微信用户对应一条记录 - `site_code_mapping.site_code` UNIQUE — 球房ID 唯一 - `site_code_mapping.site_id` UNIQUE — site_id 唯一映射 - `user_site_roles (user_id, site_id, role_id)` UNIQUE — 防止重复分配 - `role_permissions (role_id, permission_id)` 联合主键 **索引**: - `users`: `ix_users_wx_openid` (wx_openid) - `user_applications`: `ix_user_applications_user_id` (user_id), `ix_user_applications_status` (status) - `user_site_roles`: `ix_user_site_roles_user_site` (user_id, site_id) - `site_code_mapping`: `ix_site_code_mapping_site_code` (site_code) ### 迁移脚本清单 | 序号 | 文件名 | 内容 | |------|--------|------| | 1 | `YYYY-MM-DD__p3_create_auth_tables.sql` | 创建 users / user_applications / site_code_mapping / roles / permissions / role_permissions / user_site_roles / user_assistant_binding | | 2 | `YYYY-MM-DD__p3_seed_roles_permissions.sql` | 种子数据:权限列表 + 默认角色 + 角色权限映射 | ## 正确性属性(Correctness Properties) *属性是系统在所有有效执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。* ### Property 1:迁移脚本幂等性 *For any* 本次新增的迁移脚本(DDL + 种子数据),连续执行两次的结果应与执行一次相同——第二次执行不应产生错误,且数据库状态不变。 **Validates: Requirements 1.9, 2.4, 11.5** ### Property 2:登录创建/查找用户正确性 *For any* 有效的微信 `openid`,调用登录逻辑后:若该 `openid` 已存在于 `auth.users` 中,应返回已有用户的 `user_id`;若不存在,应创建新用户(status=`pending`)并返回新 `user_id`。无论哪种情况,返回的 JWT 中 `sub` 应等于该 `user_id`。 **Validates: Requirements 3.2, 3.3** ### Property 3:disabled 用户登录拒绝 *For any* `auth.users` 中 status 为 `disabled` 的用户,通过其 `openid` 登录时应返回 403 错误,不签发 JWT。 **Validates: Requirements 3.5** ### Property 4:申请创建正确性 *For any* 有效的申请数据(合法 `site_code` 格式、11 位手机号、非空 `applied_role_text`),提交申请后 `auth.user_applications` 中应新增一条 status=`pending` 的记录。若 `site_code` 在 `site_code_mapping` 中有映射,记录的 `site_id` 应等于映射值;若无映射,`site_id` 为 NULL 但申请仍成功。若提供了 `nickname`,`auth.users` 中该用户的 `nickname` 应更新。 **Validates: Requirements 4.1, 4.2, 4.3, 4.4** ### Property 5:手机号格式验证 *For any* 非 11 位纯数字的字符串作为 `phone` 提交申请,系统应返回 422 错误,`auth.user_applications` 中不应新增记录。 **Validates: Requirements 4.5** ### Property 6:重复申请拒绝 *For any* 已有一条 status=`pending` 申请的用户,再次提交申请时应返回 409 错误,`auth.user_applications` 中不应新增记录。 **Validates: Requirements 4.6** ### Property 7:人员匹配合并正确性 *For any* 有效的 `site_id` 和 `phone` 组合,匹配服务返回的候选列表应满足:(1) 每条候选的 `source_type` 为 `assistant` 或 `staff`;(2) 助教来源的候选来自 `v_dim_assistant` 表中 `site_id` 和 `mobile` 匹配的记录;(3) 员工来源的候选来自 `v_dim_staff` 表中 `site_id` 和 `mobile`(或 `job_num`)匹配的记录;(4) 列表是两个来源结果的并集,无遗漏。 **Validates: Requirements 5.1, 5.2, 5.3, 5.4** ### Property 8:审核操作正确性 *For any* status=`pending` 的申请:(1) 批准操作后,申请 status 变为 `approved`,`auth.user_site_roles` 中新增角色记录,`auth.users.status` 变为 `approved`,`reviewer_id` 和 `reviewed_at` 非空;(2) 若提供了 binding 信息,`auth.user_assistant_binding` 中新增绑定记录;(3) 拒绝操作后,申请 status 变为 `rejected`,`review_note` 非空,`reviewer_id` 和 `reviewed_at` 非空。 **Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5** ### Property 9:非 pending 申请审核拒绝 *For any* status 不是 `pending` 的申请(`approved` / `rejected`),对其执行批准或拒绝操作应返回 409 错误,申请状态不变。 **Validates: Requirements 6.6** ### Property 10:用户状态查询完整性 *For any* 用户,查询自身状态应返回:(1) 用户的 `status` 字段;(2) 该用户所有申请记录的完整列表。若用户 status 为 `approved`,还应返回已关联的店铺列表和对应角色。 **Validates: Requirements 7.1, 7.2** ### Property 11:多店铺角色独立分配 *For any* 用户和多个不同的 `site_id`,系统应允许为该用户在每个 `site_id` 下独立分配不同的角色,且 `auth.user_site_roles` 中的记录互不干扰。 **Validates: Requirements 8.1** ### Property 12:店铺切换令牌正确性 *For any* 拥有多店铺绑定的 approved 用户,切换到目标 `site_id` 后签发的新 JWT 中 `site_id` 应等于目标值,`roles` 应等于该用户在目标 `site_id` 下的角色列表。若用户在目标 `site_id` 下无角色绑定,切换应失败。 **Validates: Requirements 8.2, 10.4** ### Property 13:权限中间件拦截正确性 *For any* 用户、`site_id` 和所需权限 code 的组合:(1) 若用户 status 非 `approved`,返回 403;(2) 若用户在该 `site_id` 下的权限列表不包含所需权限,返回 403;(3) 若用户在该 `site_id` 下拥有所需权限且 status 为 `approved`,放行。 **Validates: Requirements 8.3, 9.1, 9.2, 9.3** ### Property 14:JWT payload 结构与状态一致性 *For any* 通过登录签发的 JWT:(1) 解码后应包含 `sub`(user_id)、`type`、`exp` 字段;(2) 若用户 status 为 `approved`,payload 应包含 `site_id` 和 `roles`;(3) 若用户 status 为 `pending`,payload 应包含 `limited=True`,不含 `site_id` 和 `roles`。 **Validates: Requirements 10.1, 10.2, 10.3** ### Property 15:JWT 过期/无效令牌拒绝 *For any* 过期的 JWT 或被篡改的 JWT 字符串,权限中间件应返回 401 错误,不放行请求。 **Validates: Requirements 9.4** ## 错误处理 ### API 错误码规范 | HTTP 状态码 | 场景 | 响应体 | |------------|------|--------| | 401 | JWT 无效/过期、微信 code2Session 失败 | `{"detail": "具体错误描述"}` | | 403 | 用户 disabled、权限不足、用户未 approved | `{"detail": "具体错误描述"}` | | 404 | 申请不存在 | `{"detail": "申请不存在"}` | | 409 | 重复提交 pending 申请、审核非 pending 申请 | `{"detail": "具体冲突描述"}` | | 422 | 请求体校验失败(手机号格式、site_code 格式等) | Pydantic 标准错误格式 | | 500 | 数据库连接失败、微信 API 超时 | `{"detail": "服务器内部错误"}` | ### 微信 API 错误处理 | 微信 errcode | 含义 | 处理方式 | |-------------|------|---------| | 0 | 成功 | 正常流程 | | 40029 | code 无效 | 返回 401,提示"登录凭证无效,请重新登录" | | 45011 | 频率限制 | 返回 429,提示"请求过于频繁" | | 40226 | 高风险用户 | 返回 403,提示"账号存在风险" | | 其他 | 未知错误 | 返回 401,记录日志,提示"微信登录失败" | ### 数据库错误处理 | 场景 | 处理方式 | |------|---------| | 连接失败 | 捕获 `psycopg2.OperationalError`,返回 500 | | 唯一约束冲突(wx_openid) | 并发创建时捕获 `UniqueViolation`,改为查询已有记录 | | 外键约束失败 | 返回 422,提示具体的关联数据不存在 | | FDW 查询失败 | 捕获异常,匹配服务返回空列表,记录日志 | ### 环境变量缺失处理 | 变量 | 缺失时行为 | |------|-----------| | `WX_APPID` / `WX_SECRET` | 微信登录端点返回 500,日志记录"微信配置缺失" | | `JWT_SECRET_KEY` | 应用启动时警告(空字符串不安全),JWT 签发/验证使用空密钥(仅开发环境) | | `DB_HOST` 等数据库参数 | 数据库连接失败,返回 500 | ## 测试策略 ### DDL 测试库落库与文档同步 DDL 变更必须经过以下流程: 1. **测试库执行**:在 `test_zqyy_app` 中执行迁移脚本,验证无错误 2. **幂等性验证**:连续执行两次,第二次无错误 3. **数据库手册更新**:创建/更新 `docs/database/BD_Manual_auth_tables.md`,格式参照现有 `BD_Manual_auth_biz_schemas.md` 4. **DDL 基线刷新**:运行 `python scripts/ops/gen_consolidated_ddl.py` 重新生成 `docs/database/ddl/zqyy_app__auth.sql` ### 小程序认证前端页面 #### 页面清单 | 页面 | 路径 | 说明 | H5 原型 | |------|------|------|---------| | login | `pages/login/login` | 微信登录页(自动调用 wx.login) | `docs/h5_ui/pages/login.html` | | apply | `pages/apply/apply` | 申请表单页(球房ID + 手机号 + 身份 + 编号 + 昵称) | `docs/h5_ui/pages/apply.html` | | reviewing | `pages/reviewing/reviewing` | 审核等待页(显示状态 + 申请摘要) | `docs/h5_ui/pages/reviewing.html` | | no-permission | `pages/no-permission/no-permission` | 无权限/已禁用页 | `docs/h5_ui/pages/no-permission.html` | #### 认证路由流程 ``` app.ts onLaunch() │ ├── wx.login() → 获取 code │ ├── POST /api/xcx/login {code} │ │ │ ├── 返回 user_status = "approved" │ │ └── 跳转主页(task-list 或 home) │ │ │ ├── 返回 user_status = "pending" │ │ ├── 查询 /api/xcx/me → 有 pending 申请 │ │ │ └── 跳转 reviewing 页面 │ │ └── 查询 /api/xcx/me → 无 pending 申请 │ │ └── 跳转 apply 页面 │ │ │ ├── 返回 user_status = "rejected" │ │ └── 跳转 reviewing 页面(显示拒绝原因 + 重新申请按钮) │ │ │ └── 返回 403(disabled) │ └── 跳转 no-permission 页面 │ └── 登录失败(网络错误等) └── 显示错误提示,提供重试按钮 ``` #### app.ts 全局状态管理 ```typescript // globalData 扩展 interface IAppOption { globalData: { userInfo?: { userId: number; status: string; // pending / approved / rejected / disabled nickname?: string; }; token?: string; refreshToken?: string; currentSiteId?: number; sites?: Array<{ siteId: number; siteName: string; roles: string[] }>; }; } ``` #### 请求封装(utils/request.ts) ```typescript /** * 统一请求封装: * 1. 自动附加 Authorization: Bearer * 2. 401 时自动尝试 refresh_token 刷新 * 3. 刷新失败时跳转 login 页面 */ function request(options: RequestOptions): Promise { ... } ``` ### 开发模式联调 #### Mock 登录端点 后端在 `WX_DEV_MODE=true` 时注册 `POST /api/xcx/dev-login`: ```python @router.post("/api/xcx/dev-login") async def dev_login(openid: str, status: str = "approved"): """ 开发模式 mock 登录。 直接根据 openid 查找/创建用户,跳过微信 code2Session。 可通过 status 参数模拟不同用户状态。 仅在 WX_DEV_MODE=true 时可用。 """ ... ``` #### 微信开发者工具联调步骤 联调指南文档位于 `apps/miniprogram/doc/auth-integration-guide.md`,包含: 1. 微信开发者工具项目导入配置(appid、不校验合法域名) 2. 后端启动命令(`cd apps/backend && uvicorn app.main:app --reload`) 3. 小程序请求域名配置(开发环境指向 `http://localhost:8000`) 4. 测试流程:登录 → 申请 → 管理端审核 → 重新登录验证 5. Mock 模式使用说明 ### 属性测试(Property-Based Testing) 使用 Python `hypothesis` 框架,测试目录:`tests/`(Monorepo 级属性测试目录)。 每个属性测试至少运行 100 次迭代。每个测试用注释标注对应的设计属性编号。 标注格式:`# Feature: miniapp-auth-system, Property N: <属性标题>` **属性测试清单**: | 属性 | 测试文件 | 测试方法 | 生成器 | |------|---------|---------|--------| | P2 登录创建/查找用户 | `tests/test_auth_system_properties.py` | 生成随机 openid,模拟登录,验证用户创建/查找逻辑 | `hypothesis.strategies.text` 生成 openid | | P4 申请创建正确性 | `tests/test_auth_system_properties.py` | 生成随机合法申请数据,验证申请记录创建 | 自定义 strategy 生成 site_code(2字母+3数字)、phone(11位数字) | | P5 手机号格式验证 | `tests/test_auth_system_properties.py` | 生成随机非法手机号,验证 422 拒绝 | `hypothesis.strategies.text` 过滤非 11 位数字 | | P6 重复申请拒绝 | `tests/test_auth_system_properties.py` | 生成随机用户+申请,提交两次,验证第二次 409 | 复用申请数据生成器 | | P7 人员匹配合并 | `tests/test_auth_system_properties.py` | 生成随机助教/员工数据,验证匹配结果合并 | 自定义 strategy 生成匹配数据 | | P8 审核操作正确性 | `tests/test_auth_system_properties.py` | 生成随机 pending 申请,执行批准/拒绝,验证状态流转 | 自定义 strategy 生成审核数据 | | P9 非 pending 审核拒绝 | `tests/test_auth_system_properties.py` | 生成随机非 pending 申请,验证 409 | `hypothesis.strategies.sampled_from(["approved", "rejected"])` | | P11 多店铺角色独立 | `tests/test_auth_system_properties.py` | 生成随机用户+多个 site_id,验证角色独立分配 | `hypothesis.strategies.lists` 生成 site_id 列表 | | P12 店铺切换令牌 | `tests/test_auth_system_properties.py` | 生成多店铺用户,切换店铺,验证 JWT 内容 | 复用多店铺生成器 | | P13 权限中间件拦截 | `tests/test_auth_system_properties.py` | 生成随机用户+权限组合,验证中间件判断 | 自定义 strategy 生成权限矩阵 | | P14 JWT payload 结构 | `tests/test_auth_system_properties.py` | 生成随机用户(不同 status),签发 JWT,验证 payload | `hypothesis.strategies.sampled_from(["pending", "approved"])` | | P15 JWT 过期/无效拒绝 | `tests/test_auth_system_properties.py` | 生成随机过期/篡改 JWT,验证 401 | 自定义 strategy 生成无效 JWT | **注意**:P1(迁移幂等性)、P3(disabled 登录拒绝)、P10(状态查询完整性)作为集成测试在后端测试目录实现,因为它们需要真实数据库环境或涉及具体的数据库状态验证。 ### 单元测试 单元测试位于 `apps/backend/tests/`,聚焦于: - `test_xcx_auth_router.py`:微信登录路由测试(mock 微信 API) - `test_application_service.py`:申请服务的边界情况 - `test_matching_service.py`:匹配逻辑的边界情况(空结果、FDW 异常) - `test_permission_middleware.py`:权限中间件的各种组合 - `test_jwt_extended.py`:扩展 JWT 的 limited token 逻辑 ### 集成测试 集成测试通过验证脚本实现,覆盖: - 迁移脚本幂等性验证(执行两次无错误) - 种子数据完整性验证(权限和角色数量正确) - 完整认证流程:登录 → 申请 → 审核 → 权限验证