# 技术设计文档:租户管理后台(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
JWT 自动附加/刷新] end subgraph "后端 apps/backend/" subgraph "路由层 /api/tenant/*" R_AUTH[tenant_auth.py
登录/刷新] R_USERS[tenant_users.py
审核+管理] R_EXCEL[tenant_excel.py
上传/校验/冲突] R_CLUES[tenant_clues.py
线索管理] end AUTH_DEP[require_tenant_admin
认证依赖注入] MIDDLEWARE[ResponseWrapperMiddleware
全局响应包装] end subgraph "数据层" APP_DB[(zqyy_app
auth + biz + public)] ETL_DB[(etl_feiqiu
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 ) BE->>BE: require_tenant_admin() 验证 aud=tenant-admin BE->>BE: 提取 managed_site_ids,附加数据隔离条件 ``` ### Excel 上传流程 ```mermaid flowchart TD A[选择模板类型 + 上传 Excel] --> B[后端解析 + 格式校验] B --> C{校验结果} C -->|格式错误| D[返回错误行号/列名/描述
前端标红展示] D --> A C -->|校验通过| E[人员匹配校验
仅 salary_adj / recharge_commission] E --> F[冲突检测
按主键规则匹配] F --> G{有冲突?} G -->|是| H[返回 diff 数据
前端展示 diff 表格] H --> I[用户逐行选择
替换/保留] 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
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"], ) ``` 数据隔离通过 `managed_site_ids` 实现: ```python 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` 自动包装): ```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: , message: }` 格式;分页响应的 `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