# 设计文档：小程序用户认证系统（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 逻辑

### 集成测试

集成测试通过验证脚本实现，覆盖：

- 迁移脚本幂等性验证（执行两次无错误）
- 种子数据完整性验证（权限和角色数量正确）
- 完整认证流程：登录 → 申请 → 审核 → 权限验证
