# 设计文档:小程序用户认证系统(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 逻辑
### 集成测试
集成测试通过验证脚本实现,覆盖:
- 迁移脚本幂等性验证(执行两次无错误)
- 种子数据完整性验证(权限和角色数量正确)
- 完整认证流程:登录 → 申请 → 审核 → 权限验证