Files
Neo-ZQYY/.kiro/specs/03-miniapp-auth-system/design.md
Neo b25308c3f4 feat: P1-P3 全栈集成 — 数据库基础 + DWS 扩展 + 小程序鉴权 + 工程化体系
## P1 数据库基础
- zqyy_app: 创建 auth/biz schema、FDW 连接 etl_feiqiu
- etl_feiqiu: 创建 app schema RLS 视图、商品库存预警表
- 清理 assistant_abolish 残留数据

## P2 ETL/DWS 扩展
- 新增 DWS 助教订单贡献度表 (dws.assistant_order_contribution)
- 新增 assistant_order_contribution_task 任务及 RLS 视图
- member_consumption 增加充值字段、assistant_daily 增加处罚字段
- 更新 ODS/DWD/DWS 任务文档及业务规则文档
- 更新 consistency_checker、flow_runner、task_registry 等核心模块

## P3 小程序鉴权系统
- 新增 xcx_auth 路由/schema(微信登录 + JWT)
- 新增 wechat/role/matching/application 服务层
- zqyy_app 鉴权表迁移 + 角色权限种子数据
- auth/dependencies.py 支持小程序 JWT 鉴权

## 文档与审计
- 新增 DOCUMENTATION-MAP 文档导航
- 新增 7 份 BD_Manual 数据库变更文档
- 更新 DDL 基线快照(etl_feiqiu 6 schema + zqyy_app auth)
- 新增全栈集成审计记录、部署检查清单更新
- 新增 BACKLOG 路线图、FDW→Core 迁移计划

## Kiro 工程化
- 新增 5 个 Spec(P1/P2/P3/全栈集成/核心业务)
- 新增审计自动化脚本(agent_on_stop/build_audit_context/compliance_prescan)
- 新增 6 个 Hook(合规检查/会话日志/提交审计等)
- 新增 doc-map steering 文件

## 运维与测试
- 新增 ops 脚本:迁移验证/API 健康检查/ETL 监控/集成报告
- 新增属性测试:test_dws_contribution / test_auth_system
- 清理过期 export 报告文件
- 更新 .gitignore 排除规则
2026-02-26 08:03:53 +08:00

860 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 设计文档小程序用户认证系统miniapp-auth-system
## 概述
本设计在 P1miniapp-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["微信小程序<br/>wx.login() / wx.request()"]
end
subgraph "FastAPI 后端apps/backend/"
subgraph "路由层"
XCX_AUTH["routers/xcx_auth.py<br/>微信登录 + 申请"]
XCX_USER["routers/xcx_user.py<br/>用户状态 + 店铺切换"]
ADMIN_APP["routers/admin_applications.py<br/>管理端审核"]
end
subgraph "中间件层"
PERM_MW["middleware/permission.py<br/>权限中间件"]
end
subgraph "服务层"
WX_SVC["services/wechat.py<br/>code2Session 调用"]
APP_SVC["services/application.py<br/>申请 CRUD + 审核"]
MATCH_SVC["services/matching.py<br/>人员匹配"]
ROLE_SVC["services/role.py<br/>角色权限查询"]
end
subgraph "认证层(已有 + 扩展)"
JWT["auth/jwt.py<br/>JWT 签发/验证(扩展)"]
DEPS["auth/dependencies.py<br/>依赖注入(扩展)"]
end
DB["database.py<br/>数据库连接"]
end
subgraph "数据库"
AUTH_SCHEMA["auth Schema<br/>users / applications / roles / ..."]
FDW_ETL["fdw_etl Schema<br/>v_dim_assistant / v_dim_staff"]
end
subgraph "外部服务"
WX_API["微信 API<br/>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_idRLS 隔离)。
但 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` 状态
- 权限检查结果可考虑短期缓存(当前版本不缓存,每次查库)
### 组件 6JWT 服务扩展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` | 查询关联店铺 | JWTapproved |
| POST | `/api/xcx/switch-site` | 切换店铺 | JWTapproved |
| 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 |
### 组件 8Pydantic 模型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 3disabled 用户登录拒绝
*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 14JWT 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 15JWT 过期/无效令牌拒绝
*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 页面(显示拒绝原因 + 重新申请按钮)
│ │
│ └── 返回 403disabled
│ └── 跳转 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 <token>
* 2. 401 时自动尝试 refresh_token 刷新
* 3. 刷新失败时跳转 login 页面
*/
function request(options: RequestOptions): Promise<any> { ... }
```
### 开发模式联调
#### 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_code2字母+3数字、phone11位数字 |
| 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迁移幂等性、P3disabled 登录拒绝、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 逻辑
### 集成测试
集成测试通过验证脚本实现,覆盖:
- 迁移脚本幂等性验证(执行两次无错误)
- 种子数据完整性验证(权限和角色数量正确)
- 完整认证流程:登录 → 申请 → 审核 → 权限验证