# 技术设计文档：租户管理后台（tenant-admin-web）

## 概述

本设计为 NS4 租户管理后台提供完整的技术方案。系统面向租户管理员（Tenant_Admin），提供用户审核与管理、Excel 数据上传（4 种模板）、维客线索管理三大功能模块。

前端为独立 React 应用（`apps/tenant-admin/`），后端复用现有 FastAPI 服务（`apps/backend/`）新增 4 个路由模块，所有端点注册在 `/api/tenant/` 前缀下。认证体系与小程序完全隔离，使用独立的 `auth.tenant_admins` 表、用户名+密码登录、JWT `aud=tenant-admin`。

### 关键设计决策

| 决策 | 选择 | 理由 |
|------|------|------|
| 认证表 | 独立 `auth.tenant_admins` | 与小程序 `auth.users`（微信登录）完全隔离，避免 user_type 判断复杂度 |
| JWT 隔离 | `aud` 字段区分 | 复用现有 `jose` + `bcrypt` 基础设施，仅扩展 payload |
| Excel 写入策略 | staging 表 + ETL 同步（方案 B） | 数据经 ETL 标准流程，一致性有保障；助教奖罚直接写 biz |
| FDW 多店铺查询 | 逐 site_id 查询合并 | RLS 视图要求 `SET LOCAL app.current_site_id`，单次只能设一个 |
| 前端技术栈 | React + Vite + Ant Design | 与 `apps/admin-web/` 一致，降低维护成本 |
| 响应格式 | `{ code: 0, data }` | 复用现有 `ResponseWrapperMiddleware`，前端统一解包 |

---

## 架构

### 高层架构图

```mermaid
graph TB
    subgraph "前端 apps/tenant-admin/"
        FE[React + Vite + Ant Design]
        API_LAYER[services/api.ts<br/>JWT 自动附加/刷新]
    end

    subgraph "后端 apps/backend/"
        subgraph "路由层 /api/tenant/*"
            R_AUTH[tenant_auth.py<br/>登录/刷新]
            R_USERS[tenant_users.py<br/>审核+管理]
            R_EXCEL[tenant_excel.py<br/>上传/校验/冲突]
            R_CLUES[tenant_clues.py<br/>线索管理]
        end

        AUTH_DEP[require_tenant_admin<br/>认证依赖注入]
        MIDDLEWARE[ResponseWrapperMiddleware<br/>全局响应包装]
    end

    subgraph "数据层"
        APP_DB[(zqyy_app<br/>auth + biz + public)]
        ETL_DB[(etl_feiqiu<br/>fdw_etl 视图)]
    end

    FE --> API_LAYER
    API_LAYER -->|HTTP| MIDDLEWARE
    MIDDLEWARE --> R_AUTH
    MIDDLEWARE --> R_USERS
    MIDDLEWARE --> R_EXCEL
    MIDDLEWARE --> R_CLUES
    R_USERS --> AUTH_DEP
    R_EXCEL --> AUTH_DEP
    R_CLUES --> AUTH_DEP
    R_AUTH -->|登录无需认证| APP_DB
    AUTH_DEP --> APP_DB
    R_USERS -->|FDW 人员匹配| ETL_DB
    R_EXCEL -->|FDW 助教匹配| ETL_DB
    R_CLUES -->|FDW 客户搜索| ETL_DB
```

### 认证流程

```mermaid
sequenceDiagram
    participant FE as Tenant Admin Web
    participant BE as Backend API
    participant DB as auth.tenant_admins

    FE->>BE: POST /api/tenant/auth/login {username, password}
    BE->>DB: SELECT password_hash WHERE username=? AND is_active=true
    alt 凭据有效
        BE->>BE: bcrypt.checkpw() 验证
        BE->>BE: 签发 JWT (aud=tenant-admin, sub=admin_id, managed_site_ids)
        BE-->>FE: {access_token, refresh_token}
    else 凭据无效
        BE-->>FE: 401 用户名或密码错误
    else 账号禁用
        BE-->>FE: 403 账号已被禁用
    end

    Note over FE,BE: 后续请求
    FE->>BE: GET /api/tenant/* (Authorization: Bearer <token>)
    BE->>BE: require_tenant_admin() 验证 aud=tenant-admin
    BE->>BE: 提取 managed_site_ids，附加数据隔离条件
```

### Excel 上传流程

```mermaid
flowchart TD
    A[选择模板类型 + 上传 Excel] --> B[后端解析 + 格式校验]
    B --> C{校验结果}
    C -->|格式错误| D[返回错误行号/列名/描述<br/>前端标红展示]
    D --> A
    C -->|校验通过| E[人员匹配校验<br/>仅 salary_adj / recharge_commission]
    E --> F[冲突检测<br/>按主键规则匹配]
    F --> G{有冲突?}
    G -->|是| H[返回 diff 数据<br/>前端展示 diff 表格]
    H --> I[用户逐行选择<br/>替换/保留]
    G -->|否| J[标记为待写入]
    I --> K[确认写入]
    J --> K
    K --> L{写入目标}
    L -->|助教奖罚| M[biz.salary_adjustments]
    L -->|财务支出| N[biz.stg_finance_expense]
    L -->|团购收入| O[biz.stg_platform_income]
    L -->|充值归属| P[biz.stg_recharge_commission]
    K --> Q[更新 excel_upload_log<br/>status=confirmed]
```

---

## 组件与接口

### 后端路由模块

#### 1. `tenant_auth.py` — 认证路由

| 端点 | 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|------|
| 登录 | POST | `/api/tenant/auth/login` | 无 | 用户名+密码 → JWT 令牌对 |
| 刷新 | POST | `/api/tenant/auth/refresh` | 无 | refresh_token → 新令牌对 |


#### 2. `tenant_users.py` — 用户审核 + 管理路由

| 端点 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 申请列表 | GET | `/api/tenant/applications` | status 筛选 + 分页 |
| 关联建议 | GET | `/api/tenant/applications/{id}/match-suggestions` | FDW 助教/员工匹配 |
| 审核通过 | POST | `/api/tenant/applications/{id}/approve` | 分配角色 + 绑定 |
| 审核拒绝 | POST | `/api/tenant/applications/{id}/reject` | 填写拒绝原因 |
| 用户列表 | GET | `/api/tenant/users` | 角色筛选 + 搜索 + 分页 |
| 编辑用户 | PATCH | `/api/tenant/users/{id}` | 修改角色/门店/状态 |
| 修改绑定 | PUT | `/api/tenant/users/{id}/binding` | 更新助教/员工绑定 |

#### 3. `tenant_excel.py` — Excel 上传路由

| 端点 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 上传解析 | POST | `/api/tenant/excel/upload` | multipart/form-data，校验+冲突检测 |
| 确认写入 | POST | `/api/tenant/excel/confirm` | upload_id + resolutions[] |
| 上传记录 | GET | `/api/tenant/excel/logs` | 历史记录列表 + 分页 |
| 模板下载 | GET | `/api/tenant/excel/template/{type}` | 下载空白 Excel 模板 |

#### 4. `tenant_clues.py` — 维客线索路由

| 端点 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 客户搜索 | GET | `/api/tenant/customers/search` | keyword + site_id 筛选 |
| 线索列表 | GET | `/api/tenant/customers/{member_id}/clues` | 该客户全部线索 |
| 修改线索 | PATCH | `/api/tenant/clues/{id}` | category + summary + detail |
| 删除线索 | DELETE | `/api/tenant/clues/{id}` | 物理删除 |
| 隐藏/显示 | PATCH | `/api/tenant/clues/{id}/visibility` | is_hidden 切换 |

### 认证依赖注入

```python
@dataclass(frozen=True)
class CurrentTenantAdmin:
    """从 JWT 解析出的租户管理员上下文。"""
    admin_id: int
    tenant_id: int
    managed_site_ids: list[int]
    display_name: str | None = None

async def require_tenant_admin(
    credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme),
) -> CurrentTenantAdmin:
    """
    验证 JWT aud=tenant-admin，提取管理员信息。
    拒绝小程序 JWT（aud 不匹配）。
    """
    payload = decode_access_token(token)  # 复用现有解码
    if payload.get("aud") != "tenant-admin":
        raise HTTPException(401, "令牌类型不匹配")
    return CurrentTenantAdmin(
        admin_id=int(payload["sub"]),
        tenant_id=payload["tenant_id"],
        managed_site_ids=payload["managed_site_ids"],
    )
```

### 前端组件结构

```
apps/tenant-admin/src/
├── pages/
│   ├── Login/              # 登录页
│   ├── UserApproval/       # 用户审核（申请列表 + 关联建议 + 审核操作）
│   ├── UserManagement/     # 用户管理（列表 + 编辑 + 绑定）
│   ├── ExcelUpload/        # Excel 上传（模板选择 + 上传 + 校验 + diff + 确认）
│   └── RetentionClues/     # 维客线索（客户搜索 + 线索列表 + 编辑/删除/隐藏）
├── components/
│   ├── SiteSelector/       # 门店筛选器（页面顶部）
│   ├── DiffTable/          # 冲突 diff 交互表格
│   └── ClueEditor/         # 线索编辑表单
├── services/
│   └── api.ts              # API 调用封装（JWT 自动附加/刷新）
├── hooks/
│   └── useAuth.ts          # 认证状态管理
├── utils/
│   └── format.ts           # 格式化工具（手机号脱敏等）
└── App.tsx                 # 路由配置 + 侧边栏布局
```

### API 调用层设计

复用 `apps/admin-web/src/api/client.ts` 的模式：
- axios 实例，`baseURL: "/api/tenant"`
- 请求拦截器：从 localStorage 读取 access_token 附加 Authorization header
- 响应拦截器：401 时用 refresh_token 刷新，刷新失败重定向 `/login`
- 并发刷新保护：多个 401 只触发一次 refresh，其余排队
- 响应解包：`{ code: 0, data }` → 提取 `data`

### 响应格式约定

成功响应（由 `ResponseWrapperMiddleware` 自动包装）：
```json
{ "code": 0, "data": { ... } }
```

错误响应（由异常处理器格式化）：
```json
{ "code": 401, "message": "用户名或密码错误" }
```

分页响应：
```json
{
  "code": 0,
  "data": {
    "items": [...],
    "total": 100,
    "page": 1,
    "pageSize": 20
  }
}
```

Pydantic 模型使用 `alias_generator=to_camel` 实现 snake_case → camelCase 转换。

---

## 数据模型

### 新建表

#### 1. `auth.tenant_admins` — 租户管理员表

| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PK | 自增主键 |
| username | VARCHAR(50) | UNIQUE NOT NULL | 登录用户名 |
| password_hash | VARCHAR(255) | NOT NULL | bcrypt 哈希 |
| display_name | VARCHAR(100) | — | 显示名称 |
| tenant_id | BIGINT | NOT NULL | 所属租户 |
| managed_site_ids | BIGINT[] | NOT NULL | 管辖门店 ID 列表 |
| is_active | BOOLEAN | DEFAULT true | 账号状态 |
| created_by | BIGINT | — | 创建者（Operator ID） |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| last_login_at | TIMESTAMPTZ | — | 最后登录时间 |

索引：`idx_tenant_admin_tenant ON (tenant_id)`

#### 2. `biz.excel_upload_log` — Excel 上传记录表

| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PK | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| upload_type | VARCHAR(30) | CHECK IN (expense, platform_income, salary_adj, recharge_commission) | 模板类型 |
| file_name | VARCHAR(255) | NOT NULL | 原始文件名 |
| uploaded_by | BIGINT | NOT NULL | 上传人（管理员 ID） |
| row_count | INTEGER | DEFAULT 0 | 数据行数 |
| conflict_count | INTEGER | DEFAULT 0 | 冲突行数 |
| resolved_count | INTEGER | DEFAULT 0 | 已解决冲突数 |
| status | VARCHAR(20) | CHECK IN (pending, confirmed, failed) | 批次状态 |
| error_detail | JSONB | — | 错误详情 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 上传时间 |
| confirmed_at | TIMESTAMPTZ | — | 确认时间 |

索引：`idx_excel_log_site ON (site_id, created_at DESC)`

#### 3. `biz.salary_adjustments` — 助教奖罚明细表

| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PK | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| assistant_id | BIGINT | — | 匹配到的助教 ID（可空） |
| assistant_name | VARCHAR(100) | NOT NULL | 助教姓名 |
| assistant_number | VARCHAR(50) | NOT NULL | 助教编号 |
| salary_month | VARCHAR(7) | NOT NULL | 月份 YYYY-MM |
| adjustment_type | VARCHAR(20) | CHECK IN (deduction, bonus) | 类型 |
| amount | NUMERIC(12,2) | CHECK > 0 | 金额 |
| reason | VARCHAR(200) | NOT NULL | 原因 |
| upload_batch_id | BIGINT | FK → excel_upload_log | 上传批次 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| created_by | BIGINT | — | 上传人 |

索引：`(site_id, salary_month)`, `(assistant_id, salary_month)`

#### 4. `biz.stg_finance_expense` — 财务支出暂存表

| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PK | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| expense_month | VARCHAR(7) | NOT NULL | 月份 |
| category | VARCHAR(50) | NOT NULL | 支出类别（8 值枚举） |
| amount | NUMERIC(12,2) | NOT NULL | 金额 |
| remark | TEXT | — | 备注 |
| upload_batch_id | BIGINT | FK → excel_upload_log | 上传批次 |
| synced_at | TIMESTAMPTZ | — | ETL 同步时间（NULL=未同步） |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |

#### 5. `biz.stg_platform_income` — 团购收入暂存表

| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PK | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| income_month | VARCHAR(7) | NOT NULL | 月份 |
| platform_name | VARCHAR(100) | NOT NULL | 平台名称 |
| amount | NUMERIC(12,2) | NOT NULL | 收入金额 |
| remark | TEXT | — | 备注 |
| upload_batch_id | BIGINT | FK → excel_upload_log | 上传批次 |
| synced_at | TIMESTAMPTZ | — | ETL 同步时间 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |

#### 6. `biz.stg_recharge_commission` — 充值业绩归属暂存表

| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PK | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| recharge_date | DATE | NOT NULL | 充值日期 |
| member_name | VARCHAR(100) | NOT NULL | 会员名称 |
| recharge_amount | NUMERIC(12,2) | NOT NULL | 充值金额 |
| assigned_assistant | VARCHAR(100) | NOT NULL | 归属助教 |
| reward_amount | NUMERIC(12,2) | NOT NULL | 奖励金额 |
| upload_batch_id | BIGINT | FK → excel_upload_log | 上传批次 |
| synced_at | TIMESTAMPTZ | — | ETL 同步时间 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |

### 表结构变更

#### `public.member_retention_clue` — 新增 `is_hidden` 列

```sql
ALTER TABLE public.member_retention_clue
    ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT false;

COMMENT ON COLUMN public.member_retention_clue.is_hidden
    IS '是否隐藏（true=管理后台保留但小程序不展示）';
```

已有数据通过 `DEFAULT false` 保证兼容。小程序端线索查询需增加 `WHERE is_hidden = false`。

### 复用的现有表（auth Schema）

| 表 | 用途 |
|---|------|
| `auth.users` | 小程序用户主表（审核时更新 status） |
| `auth.user_applications` | 用户入驻申请（审核列表数据源） |
| `auth.site_code_mapping` | 球房编号 → site_id 映射 |
| `auth.roles` | 角色定义（coach/staff/site_admin/tenant_admin） |
| `auth.user_site_roles` | 用户-门店-角色关联（审核通过时写入） |
| `auth.user_assistant_binding` | 用户-助教/员工绑定（审核通过时写入） |

### FDW 数据源（ETL 库只读）

| 视图 | 用途 | 查询场景 |
|------|------|---------|
| `fdw_etl.v_dim_assistant` | 助教维度表 | 用户审核关联建议、Excel 人员匹配 |
| `fdw_etl.v_dim_staff` + `v_dim_staff_ex` | 员工维度表 | 用户审核关联建议、Excel 人员匹配 |
| `fdw_etl.v_dim_member` | 会员维度表 | 维客线索客户搜索（DQ-6 规则） |

多店铺查询策略：逐 `site_id` 调用 `get_etl_readonly_connection(site_id)` 查询后合并结果。

### Pydantic Schema 设计

```python
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

class TenantBaseModel(BaseModel):
    """租户管理后台基础模型，统一驼峰命名。"""
    model_config = ConfigDict(
        alias_generator=to_camel,
        populate_by_name=True,
    )

class PaginatedResponse(TenantBaseModel, Generic[T]):
    items: list[T]
    total: int
    page: int
    page_size: int
```


---

## 正确性属性（Correctness Properties）

*属性（Property）是在系统所有有效执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。*

### Property 1: 认证正确性

*For any* 用户名和密码组合，如果该组合对应一个活跃的 `auth.tenant_admins` 记录且密码哈希匹配，则登录应返回包含 `aud=tenant-admin` 的有效 JWT 令牌对；如果用户名不存在或密码不匹配，则应返回 401 且错误消息不区分是用户名还是密码错误。

**Validates: Requirements 1.1, 1.2**

### Property 2: JWT 隔离与端点保护

*For any* JWT 令牌和任意 `/api/tenant/*` 受保护端点，当且仅当令牌有效且 `aud=tenant-admin` 时请求应被允许；`aud` 不匹配（如小程序 JWT `aud=xcx`）、令牌过期或令牌无效时应返回 401。

**Validates: Requirements 1.4, 1.6, 17.3**

### Property 3: 数据隔离

*For any* 租户管理员和任意数据查询端点，返回结果中每条记录的 `site_id` 必须属于该管理员的 `managed_site_ids` 集合；尝试访问不在 `managed_site_ids` 范围内的数据应返回 403。

**Validates: Requirements 2.1, 2.2, 2.3**

### Property 4: 审核通过多表一致性

*For any* 状态为 `pending` 的用户申请，执行审核通过操作后，以下条件应同时成立：`auth.users.status = 'approved'`、`auth.user_site_roles` 中存在对应角色记录、`auth.user_assistant_binding` 中存在绑定记录（如提供了 assistant_id/staff_id）、`auth.user_applications.status = 'approved'` 且审核人和审核时间已填写。

**Validates: Requirements 3.4**

### Property 5: 审核拒绝状态更新

*For any* 状态为 `pending` 的用户申请和任意非空拒绝原因字符串，执行审核拒绝操作后，`auth.user_applications.status` 应为 `rejected`，`review_note` 应等于提交的拒绝原因，`reviewed_at` 应非空。

**Validates: Requirements 3.5**

### Property 6: 申请关联匹配

*For any* 用户申请中的手机号和球房编号，关联匹配逻辑应：先通过 `site_code_mapping` 解析 `site_id`，然后在 `v_dim_assistant`（`scd2_is_current=1`）和 `v_dim_staff` + `v_dim_staff_ex` 中按 phone 匹配，返回的候选列表应仅包含 phone 匹配的记录。

**Validates: Requirements 3.3**

### Property 7: 用户管理编辑

*For any* 已通过审核的用户和任意有效的编辑参数（角色、site_id 在管辖范围内、状态），编辑操作后用户记录应反映新值；修改绑定关系后 `user_assistant_binding` 应更新为新的 `assistant_id`/`staff_id`。

**Validates: Requirements 4.2, 4.3, 4.4**

### Property 8: Excel 格式校验

*For any* 模板类型和任意数据行集合，校验器应：对符合模板列定义的行标记为通过，对不符合的行返回包含行号、列名和错误描述的错误信息；校验全部通过时应创建 `excel_upload_log` 记录（status=pending）；通过行数 + 警告行数 + 错误行数 = 总行数。

**Validates: Requirements 5.1, 5.2, 5.3, 5.4, 6.5**

### Property 9: 人员匹配校验

*For any* 助教姓名+编号组合和给定的助教/员工数据集，匹配逻辑应：优先在 `v_dim_assistant`（nickname + assistant_number）中匹配，未匹配时再查 `v_dim_staff` + `v_dim_staff_ex`（name + staff_number）；匹配成功时填充 `assistant_id`，匹配失败时标记为 warning 且不阻断流程。

**Validates: Requirements 6.1, 6.2, 6.3, 6.4**

### Property 10: 冲突检测

*For any* 模板类型、上传数据集和已有数据集，冲突检测应：按模板主键规则（如财务支出的"月份+支出类别"）识别主键重复的行，对冲突行返回逐字段 diff（旧值 vs 新值），非冲突行标记为待写入。

**Validates: Requirements 7.1, 7.2**

### Property 11: 数据写入 round-trip

*For any* 确认写入的数据批次和冲突解决方案，写入后从目标表读取的数据应与提交的数据一致（选择"替换"的行使用新值，选择"保留"的行保持旧值）；`excel_upload_log` 状态应更新为 `confirmed`，实际写入行数应正确记录。

**Validates: Requirements 7.4, 7.5, 8.1, 8.2**

### Property 12: 客户搜索

*For any* 搜索关键词和管辖门店范围，搜索结果应满足：每条结果的 `nickname` 包含关键词（模糊匹配）或 `mobile` 等于关键词（精确匹配），且 `site_id` 在管辖范围内；指定门店筛选时结果应仅包含该门店的客户。

**Validates: Requirements 9.1, 9.3**

### Property 13: 线索编辑校验

*For any* 有效的编辑参数（category 在 6 值枚举内、summary 非空且 ≤200 字符），编辑操作后线索记录应反映新值；对于无效的 category 或空/超长 summary，应拒绝操作。

**Validates: Requirements 11.1, 11.2**

### Property 14: 线索删除

*For any* 存在且在管辖门店范围内的线索，执行删除操作后，该线索应不可通过任何查询接口获取（物理删除）。

**Validates: Requirements 12.2**

### Property 15: 线索隐藏/显示 round-trip

*For any* 线索，将 `is_hidden` 设为 `true` 后，小程序端查询（`WHERE is_hidden = false`）不应返回该线索，但管理后台查询应返回；再将 `is_hidden` 设回 `false` 后，小程序端查询应恢复返回该线索。

**Validates: Requirements 13.1, 13.2, 13.3, 15.3**

### Property 16: 响应格式一致性

*For any* `/api/tenant/*` 端点的成功响应，响应体应符合 `{ code: 0, data: ... }` 格式；错误响应应符合 `{ code: <status_code>, message: <string> }` 格式；分页响应的 `data` 应包含 `items`、`total`、`page`、`pageSize` 字段。

**Validates: Requirements 18.4**

### Property 17: 租户管理员 CRUD 一致性

*For any* 创建租户管理员操作（用户名唯一、密码非空、managed_site_ids 非空），创建后应能通过该用户名+密码成功登录租户管理后台；编辑 managed_site_ids 后，该管理员的数据隔离范围应立即反映新值；禁用（is_active=false）后登录应返回 403；重置密码后旧密码应失效、新密码应生效。

**Validates: Requirements 14.2, 14.4, 14.5, 14.6**

---

## 错误处理

### 认证错误

| 场景 | HTTP 状态码 | 错误消息 | 处理方式 |
|------|------------|---------|---------|
| 用户名或密码错误 | 401 | "用户名或密码错误" | 统一消息，不区分 |
| 账号已禁用 | 403 | "账号已被禁用" | 检查 `is_active` |
| JWT 过期 | 401 | "令牌已过期" | 前端自动刷新 |
| JWT 无效/aud 不匹配 | 401 | "无效的令牌" | 重定向登录页 |
| 刷新令牌过期 | 401 | "刷新令牌已过期" | 清除令牌，重定向登录页 |

### 数据隔离错误

| 场景 | HTTP 状态码 | 错误消息 |
|------|------------|---------|
| 访问非管辖门店数据 | 403 | "无权访问该门店数据" |
| 修改 site_id 超出管辖范围 | 403 | "目标门店不在管辖范围内" |

### 业务逻辑错误

| 场景 | HTTP 状态码 | 错误消息 |
|------|------------|---------|
| 审核非 pending 状态的申请 | 409 | "该申请已被处理" |
| 线索 ID 不存在 | 404 | "线索不存在" |
| 线索不在管辖范围 | 404 | "线索不存在" |
| category 枚举值无效 | 422 | "无效的线索大类" |
| summary 为空或超长 | 422 | "摘要不能为空且不超过200字符" |

### Excel 上传错误

| 场景 | HTTP 状态码 | 错误消息 |
|------|------------|---------|
| 文件格式非 .xlsx/.xls | 400 | "请上传有效的 Excel 文件" |
| 格式校验失败 | 200 | 返回错误行详情（不阻断，前端标红） |
| 人员匹配失败 | 200 | 标记为 warning（不阻断，前端黄色高亮） |
| 写入数据库失败 | 500 | 回滚整批，log status=failed，记录 error_detail |

### 管理端租户管理员 CRUD 错误

| 场景 | HTTP 状态码 | 错误消息 |
|------|------------|---------|
| 用户名已存在 | 409 | "用户名已存在" |
| 管理员 ID 不存在 | 404 | "租户管理员不存在" |
| 无管理员权限 | 403 | "需要管理员权限（site_admin 或 tenant_admin）" |

### 数据库事务策略

- Excel 写入：整批次在单个事务中执行，任何行写入失败则回滚全部
- 审核通过：多表写入在单个事务中执行（users + user_site_roles + user_assistant_binding + user_applications）
- 线索操作：单条记录操作，无需跨表事务

---

## 测试策略

### 双轨测试方法

本项目采用单元测试 + 属性测试的双轨策略：

- **单元测试**：验证具体示例、边界条件和错误处理
- **属性测试**：验证跨所有输入的通用属性

两者互补：单元测试捕获具体 bug，属性测试验证通用正确性。

### 属性测试配置

- **库**：Python 后端使用 `hypothesis`（项目已有 `.hypothesis/` 目录）；前端使用 `fast-check`
- **迭代次数**：每个属性测试最少 100 次迭代
- **标签格式**：`Feature: tenant-admin-web, Property {number}: {property_text}`
- **每个正确性属性对应一个属性测试**

### 单元测试覆盖

| 模块 | 测试重点 |
|------|---------|
| `tenant_auth.py` | 登录成功/失败、禁用账号、JWT 签发/验证、刷新令牌 |
| `tenant_users.py` | 申请列表筛选、审核通过/拒绝、非 pending 状态拒绝（409）、用户编辑、绑定修改 |
| `tenant_excel.py` | 4 种模板格式校验、人员匹配、冲突检测、写入回滚、模板下载 |
| `tenant_clues.py` | 客户搜索、线索 CRUD、隐藏/显示、枚举校验、越权访问 |
| `admin_tenant_admins.py` | 创建（用户名唯一）、编辑、重置密码、禁用、权限校验 |
| 前端 `services/api.ts` | JWT 自动附加、401 刷新、并发刷新保护 |

### 属性测试覆盖

| Property | 测试描述 | 生成器 |
|----------|---------|--------|
| P1 | 认证正确性 | 随机用户名+密码 |
| P2 | JWT 隔离 | 随机 JWT payload（不同 aud） |
| P3 | 数据隔离 | 随机 managed_site_ids + 随机数据 |
| P4 | 审核通过一致性 | 随机 pending 申请 + 角色/绑定参数 |
| P5 | 审核拒绝 | 随机 pending 申请 + 随机拒绝原因 |
| P6 | 关联匹配 | 随机手机号 + 随机助教/员工数据集 |
| P7 | 用户编辑 | 随机用户 + 随机编辑参数 |
| P8 | Excel 校验 | 随机模板类型 + 随机数据行（含有效/无效） |
| P9 | 人员匹配 | 随机姓名+编号 + 随机助教/员工数据集 |
| P10 | 冲突检测 | 随机上传数据 + 随机已有数据 |
| P11 | 写入 round-trip | 随机数据 + 随机冲突解决方案 |
| P12 | 客户搜索 | 随机关键词 + 随机客户数据集 |
| P13 | 线索编辑 | 随机 category + 随机 summary |
| P14 | 线索删除 | 随机线索 ID |
| P15 | 隐藏/显示 round-trip | 随机线索 + 隐藏→显示→验证 |
| P16 | 响应格式 | 随机端点调用 |
| P17 | 租户管理员 CRUD | 随机用户名+密码+门店列表 |

### 边界条件测试（单元测试覆盖）

- 禁用账号登录（1.3）→ 403
- 非 pending 申请审核（3.6）→ 409
- 越权修改 site_id（4.5）→ 403
- 无效 Excel 文件格式（5.5）→ 400
- 写入数据库失败回滚（8.3）→ log status=failed
- 线索 ID 不存在/越权（11.3, 12.3, 13.4）→ 404
