feat: chat integration, tenant admin spec, backend chat service, miniprogram updates, DEMO moved to tmp, XCX-TEST removed, migrations & docs
This commit is contained in:
623
.kiro/specs/tenant-admin-web/design.md
Normal file
623
.kiro/specs/tenant-admin-web/design.md
Normal file
@@ -0,0 +1,623 @@
|
||||
# 技术设计文档:租户管理后台(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"],
|
||||
)
|
||||
```
|
||||
|
||||
数据隔离通过 `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: <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
|
||||
Reference in New Issue
Block a user