Files
Neo-ZQYY/.kiro/specs/tenant-admin-web/design.md
2026-03-20 09:03:11 +08:00

615 lines
26 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.
# 技术设计文档租户管理后台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提取管理员信息。
拒绝小程序 JWTaud 不匹配)。
"""
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 17.4**
---
## 错误处理
### 认证错误
| 场景 | 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 |
### 数据库事务策略
- 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、隐藏/显示、枚举校验、越权访问 |
| 前端 `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 | 响应格式 | 随机端点调用 |
### 边界条件测试(单元测试覆盖)
- 禁用账号登录1.3)→ 403
- 非 pending 申请审核3.6)→ 409
- 越权修改 site_id4.5)→ 403
- 无效 Excel 文件格式5.5)→ 400
- 写入数据库失败回滚8.3)→ log status=failed
- 线索 ID 不存在/越权11.3, 12.3, 13.4)→ 404