26 KiB
技术设计文档:租户管理后台(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,前端统一解包 |
架构
高层架构图
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
认证流程
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 上传流程
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 切换 |
认证依赖注入
@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"],
)
数据隔离通过 managed_site_ids 实现:
def site_filter_clause(admin: CurrentTenantAdmin) -> tuple[str, tuple]:
"""生成 site_id IN (...) SQL 片段。"""
placeholders = ",".join(["%s"] * len(admin.managed_site_ids))
return f"site_id IN ({placeholders})", tuple(admin.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 自动包装):
{ "code": 0, "data": { ... } }
错误响应(由异常处理器格式化):
{ "code": 401, "message": "用户名或密码错误" }
分页响应:
{
"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 列
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 设计
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_id(4.5)→ 403
- 无效 Excel 文件格式(5.5)→ 400
- 写入数据库失败回滚(8.3)→ log status=failed
- 线索 ID 不存在/越权(11.3, 12.3, 13.4)→ 404