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