# BD 手册：租户管理后台表（NS4 tenant-admin-web）

## 概述

NS4 租户管理后台新增 6 张表，分布在 `auth` 和 `biz` 两个 Schema 中。`auth.tenant_admins` 为租户管理员认证表，与小程序 `auth.users`（微信登录）完全隔离；`biz` Schema 下 5 张表支撑 Excel 数据上传功能（上传日志、助教奖罚、3 张 staging 暂存表）。

所有表位于 `zqyy_app` / `test_zqyy_app` 数据库。

## 变更说明

| 库 | Schema | 表 | 变更类型 | 说明 |
|----|--------|---|---------|------|
| zqyy_app | auth | tenant_admins | 新建 | 租户管理员认证表 |
| zqyy_app | biz | excel_upload_log | 新建 | Excel 上传记录表 |
| zqyy_app | biz | salary_adjustments | 新建 | 助教奖罚明细表 |
| zqyy_app | biz | stg_finance_expense | 新建 | 财务支出暂存表 |
| zqyy_app | biz | stg_platform_income | 新建 | 团购收入暂存表 |
| zqyy_app | biz | stg_recharge_commission | 新建 | 充值业绩归属暂存表 |

---

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

独立于小程序 `auth.users`，使用用户名+密码登录，JWT `aud=tenant-admin` 隔离。

### 表结构

| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 自增主键 |
| username | VARCHAR(50) | UNIQUE NOT NULL | 登录用户名 |
| password_hash | VARCHAR(255) | NOT NULL | bcrypt 哈希密码 |
| display_name | VARCHAR(100) | — | 显示名称 |
| tenant_id | BIGINT | NOT NULL | 所属租户 ID |
| managed_site_ids | BIGINT[] | NOT NULL | 管辖门店 ID 列表（数据隔离依据） |
| admin_type | VARCHAR(20) | NOT NULL DEFAULT 'tenant_admin' | 管理员类型：tenant_admin（租户管理员）/ site_admin（店铺管理员），CHECK 约束 |
| is_active | BOOLEAN | DEFAULT true | 账号状态（false=禁用，登录返回 403，仅控制启用/禁用，与软删除无关） |
| deleted_at | TIMESTAMPTZ | DEFAULT NULL | 软删除时间戳：NULL=正常，非 NULL=已删除（与 is_active 分离） |
| created_by | BIGINT | — | 创建者（管理员 ID） |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| last_login_at | TIMESTAMPTZ | — | 最后登录时间 |

### 约束与索引

| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| tenant_admins_pkey | PRIMARY KEY | id | 主键 |
| tenant_admins_username_key | UNIQUE | username | 用户名唯一 |
| chk_admin_type | CHECK | admin_type | admin_type IN ('tenant_admin', 'site_admin') |
| idx_tenant_admin_tenant | INDEX (btree) | tenant_id | 按租户查询 |
| idx_tenant_admins_active_not_deleted | INDEX (btree, partial) | is_active WHERE deleted_at IS NULL | 加速列表和登录查询（仅索引未删除记录） |
| idx_tenant_admins_username_lower | UNIQUE (partial) | LOWER(username) WHERE deleted_at IS NULL | 大小写不敏感唯一约束（2026-03-23） |

---

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

记录每次 Excel 上传的批次信息，支撑上传→校验→冲突→确认的完整流程。

### 表结构

| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| upload_type | VARCHAR(30) | NOT NULL, CHECK | 模板类型（见枚举） |
| 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) | NOT NULL, CHECK | 批次状态（见枚举） |
| error_detail | JSONB | — | 错误详情 / 临时缓存上传数据 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 上传时间 |
| confirmed_at | TIMESTAMPTZ | — | 确认写入时间 |

### upload_type 枚举值

| 值 | 说明 |
|----|------|
| expense | 财务支出 |
| platform_income | 团购收入 |
| salary_adj | 助教奖罚 |
| recharge_commission | 充值业绩归属 |

### status 枚举值

| 值 | 说明 |
|----|------|
| pending | 待确认（已上传校验通过，等待用户确认写入） |
| confirmed | 已确认（数据已写入目标表） |
| failed | 失败（写入过程出错，已回滚） |

### 约束与索引

| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| excel_upload_log_pkey | PRIMARY KEY | id | 主键 |
| CHECK (upload_type) | CHECK | upload_type | 限制模板类型枚举 |
| CHECK (status) | CHECK | status | 限制状态枚举 |
| idx_excel_log_site | INDEX (btree) | (site_id, created_at DESC) | 按门店+时间查询 |

---

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

直接写入 biz Schema（非 staging），记录助教扣款/奖金明细。

### 表结构

| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| assistant_id | BIGINT | — | 匹配到的助教 ID（可空，人员匹配失败时为 NULL） |
| assistant_name | VARCHAR(100) | NOT NULL | 助教姓名（Excel 原始值） |
| assistant_number | VARCHAR(50) | NOT NULL | 助教编号（Excel 原始值） |
| salary_month | VARCHAR(7) | NOT NULL | 月份（格式 YYYY-MM） |
| adjustment_type | VARCHAR(20) | NOT NULL, CHECK | 类型：deduction（扣款）/ bonus（奖金） |
| amount | NUMERIC(12,2) | NOT NULL, CHECK (> 0) | 金额（正数） |
| reason | VARCHAR(200) | NOT NULL | 原因说明 |
| upload_batch_id | BIGINT | FK → excel_upload_log(id) | 上传批次 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| created_by | BIGINT | — | 上传人（管理员 ID） |

### adjustment_type 枚举值

| 值 | Excel 中文值 | 说明 |
|----|-------------|------|
| deduction | 扣款 | 扣款 |
| bonus | 奖金 | 奖金 |

### 约束与索引

| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| salary_adjustments_pkey | PRIMARY KEY | id | 主键 |
| CHECK (adjustment_type) | CHECK | adjustment_type | 限制类型枚举 |
| CHECK (amount) | CHECK | amount | 金额必须 > 0 |
| salary_adjustments_upload_batch_id_fkey | FOREIGN KEY | upload_batch_id → excel_upload_log(id) | 关联上传批次 |
| idx_salary_adj_site_month | INDEX (btree) | (site_id, salary_month) | 按门店+月份查询 |
| idx_salary_adj_assistant_month | INDEX (btree) | (assistant_id, salary_month) | 按助教+月份查询 |

---

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

通过 Excel 上传写入，等待 ETL 同步到正式表。

### 表结构

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

### 冲突检测主键

`(site_id, expense_month, category)` — 同门店同月份同类别视为冲突。

### 约束与索引

| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| stg_finance_expense_pkey | PRIMARY KEY | id | 主键 |
| stg_finance_expense_upload_batch_id_fkey | FOREIGN KEY | upload_batch_id → excel_upload_log(id) | 关联上传批次 |

---

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

### 表结构

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

### 冲突检测主键

`(site_id, income_month, platform_name)` — 同门店同月份同平台视为冲突。

### 约束与索引

| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| stg_platform_income_pkey | PRIMARY KEY | id | 主键 |
| stg_platform_income_upload_batch_id_fkey | FOREIGN KEY | upload_batch_id → excel_upload_log(id) | 关联上传批次 |

---

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

### 表结构

| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 自增主键 |
| 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(id) | 上传批次 |
| synced_at | TIMESTAMPTZ | — | ETL 同步时间（NULL=未同步） |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |

### 冲突检测主键

`(site_id, recharge_date, member_name, assigned_assistant)` — 同门店同日期同会员同助教视为冲突。

### 约束与索引

| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| stg_recharge_commission_pkey | PRIMARY KEY | id | 主键 |
| stg_recharge_commission_upload_batch_id_fkey | FOREIGN KEY | upload_batch_id → excel_upload_log(id) | 关联上传批次 |

---

## 兼容性

- **后端 API**：5 个新路由模块（tenant_auth / tenant_users / tenant_excel / tenant_clues / admin_tenant_admins）全部依赖上述表
- **ETL**：3 张 staging 表（stg_finance_expense / stg_platform_income / stg_recharge_commission）的 `synced_at` 字段供 ETL 同步标记，ETL 读取 `synced_at IS NULL` 的行进行同步
- **小程序**：无直接影响（租户管理后台独立认证体系）
- **管理后台（admin-web）**：新增租户管理员 CRUD 页面，调用 admin_tenant_admins 路由

## 回滚策略

### 完整回滚（按依赖顺序）

```sql
BEGIN;
-- 先删除有外键依赖的表
DROP TABLE IF EXISTS biz.salary_adjustments CASCADE;
DROP TABLE IF EXISTS biz.stg_finance_expense CASCADE;
DROP TABLE IF EXISTS biz.stg_platform_income CASCADE;
DROP TABLE IF EXISTS biz.stg_recharge_commission CASCADE;
-- 再删除被引用的表
DROP TABLE IF EXISTS biz.excel_upload_log CASCADE;
-- 最后删除认证表
DROP TABLE IF EXISTS auth.tenant_admins CASCADE;
-- 清理序列（CASCADE 已处理，此处为显式确认）
DROP SEQUENCE IF EXISTS biz.excel_upload_log_id_seq;
DROP SEQUENCE IF EXISTS biz.salary_adjustments_id_seq;
DROP SEQUENCE IF EXISTS biz.stg_finance_expense_id_seq;
DROP SEQUENCE IF EXISTS biz.stg_platform_income_id_seq;
DROP SEQUENCE IF EXISTS biz.stg_recharge_commission_id_seq;
DROP SEQUENCE IF EXISTS auth.tenant_admins_id_seq;
COMMIT;
```

## 验证步骤

```sql
-- 1. 确认 auth.tenant_admins 表存在且列完整（12 列，含 admin_type + deleted_at）
SELECT column_name, data_type, is_nullable, column_default
  FROM information_schema.columns
 WHERE table_schema = 'auth' AND table_name = 'tenant_admins'
 ORDER BY ordinal_position;
-- 预期：12 行（id, username, password_hash, display_name, tenant_id,
--        managed_site_ids, admin_type, is_active, deleted_at, created_by, created_at, last_login_at）

-- 2. 确认 tenant_admins 唯一约束
SELECT conname, contype FROM pg_constraint
 WHERE conrelid = 'auth.tenant_admins'::regclass;
-- 预期：包含 tenant_admins_pkey (p) 和 tenant_admins_username_key (u)

-- 3. 确认 biz.excel_upload_log 表存在且 CHECK 约束正确
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
 WHERE conrelid = 'biz.excel_upload_log'::regclass AND contype = 'c';
-- 预期：2 行（upload_type CHECK 和 status CHECK）

-- 4. 确认 biz.salary_adjustments 外键和 CHECK 约束
SELECT conname, contype, pg_get_constraintdef(oid) FROM pg_constraint
 WHERE conrelid = 'biz.salary_adjustments'::regclass AND contype IN ('c', 'f');
-- 预期：3 行（adjustment_type CHECK, amount CHECK, upload_batch_id FK）

-- 5. 确认 3 张 staging 表的外键
SELECT c.conname, c.conrelid::regclass AS table_name
  FROM pg_constraint c
 WHERE c.confrelid = 'biz.excel_upload_log'::regclass AND c.contype = 'f';
-- 预期：4 行（salary_adjustments + 3 张 staging 表各 1 个 FK）

-- 6. 确认索引
SELECT schemaname, tablename, indexname FROM pg_indexes
 WHERE tablename IN ('tenant_admins', 'excel_upload_log', 'salary_adjustments',
                     'stg_finance_expense', 'stg_platform_income', 'stg_recharge_commission')
 ORDER BY tablename, indexname;
-- 预期：tenant_admins 3 个（pkey + idx_tenant_admin_tenant + idx_tenant_admins_active_not_deleted）
--        excel_upload_log 2 个（pkey + idx_excel_log_site）
--        salary_adjustments 3 个（pkey + idx_salary_adj_site_month + idx_salary_adj_assistant_month）
--        stg_* 各 1 个（pkey）

-- 7. 确认 6 张表均可查询
SELECT 'auth.tenant_admins' AS tbl, COUNT(*) FROM auth.tenant_admins
UNION ALL SELECT 'biz.excel_upload_log', COUNT(*) FROM biz.excel_upload_log
UNION ALL SELECT 'biz.salary_adjustments', COUNT(*) FROM biz.salary_adjustments
UNION ALL SELECT 'biz.stg_finance_expense', COUNT(*) FROM biz.stg_finance_expense
UNION ALL SELECT 'biz.stg_platform_income', COUNT(*) FROM biz.stg_platform_income
UNION ALL SELECT 'biz.stg_recharge_commission', COUNT(*) FROM biz.stg_recharge_commission;
-- 预期：6 行，各表行数 ≥ 0
```

## NS4.1 变更补充（2026-03-22）

### auth.tenant_admins 行为变更

NS4.1 对 `auth.tenant_admins` 表做了以下调整：

#### deleted_at 软删除字段（已合并入主 DDL）

`deleted_at` 字段已合并到主迁移脚本 `2026-03-20__ns4_tenant_admin_tables.sql` 中，不再需要独立的 ALTER TABLE 迁移。字段语义：
- `NULL` = 正常记录
- 非 `NULL` = 已删除（时间戳记录删除时间）
- 与 `is_active` 分离：`is_active` 控制启用/禁用，`deleted_at` 控制软删除
- 部分索引 `idx_tenant_admins_active_not_deleted` 仅索引 `deleted_at IS NULL` 的记录

#### 软删除逻辑

`DELETE /api/admin/tenant-admins/{id}` 端点实际执行软删除：将 `is_active` 设置为 `false`，不物理删除行。已禁用的管理员再次删除返回 409。

```sql
-- 软删除
UPDATE auth.tenant_admins SET is_active = false WHERE id = :id AND is_active = true;
```

#### tenant_id 来源变更

创建管理员时，`tenant_id` 不再是自由输入，而是从 `biz.tenants` 表中选择。后端校验 `tenant_id` 在 `biz.tenants` 中存在且 `is_active=true`。

```sql
-- 创建时校验
SELECT id FROM biz.tenants WHERE id = :tenant_id AND is_active = true;
```

#### username 可编辑

`PATCH /api/admin/tenant-admins/{id}` 端点支持修改 `username`。修改时校验全局唯一性（大小写不敏感），冲突返回 409 Conflict。

```sql
-- 唯一性校验（大小写不敏感）
SELECT id FROM auth.tenant_admins WHERE LOWER(username) = LOWER(:new_username) AND id != :current_id AND deleted_at IS NULL;
```

#### 列表过滤

`GET /api/admin/tenant-admins` 端点默认只返回 `is_active=true` 的记录。新增 `include_inactive` 查询参数，设为 `true` 时返回所有记录（含已禁用）。

列表查询 JOIN `biz.tenants` 获取 `tenant_name` 字段。

---

## 关联文件

- DDL 基线（auth）：`docs/database/ddl/zqyy_app__auth.sql`
- DDL 基线（biz）：`docs/database/ddl/zqyy_app__biz.sql`
- 迁移脚本：`db/zqyy_app/migrations/2026-03-20__ns4_tenant_admin_tables.sql`
- 迁移脚本：`db/zqyy_app/migrations/2026-03-23__case_insensitive_username.sql`
- 迁移脚本：`db/zqyy_app/migrations/2026-03-23__cleanup_roles_add_admin_type.sql`
- 后端路由：`apps/backend/app/routers/tenant_auth.py`、`tenant_users.py`、`tenant_excel.py`、`tenant_clues.py`、`admin_tenant_admins.py`、`tenant_site_admins.py`
- 后端 Schema：`apps/backend/app/schemas/tenant_excel.py`、`admin_tenant_admins.py`
- 认证模块：`apps/backend/app/auth/tenant_admins.py`
- Spec：`.kiro/specs/tenant-admin-web/`

---

## NS4.2 变更补充（2026-03-23）

### auth.tenant_admins 用户名大小写不敏感

#### 变更说明

登录、创建、编辑管理员时，用户名统一转小写存储，查询使用 `LOWER()` 比较。避免 `Admin` 和 `admin` 被视为不同账号。

#### 新增索引

| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| idx_tenant_admins_username_lower | UNIQUE (partial) | LOWER(username) WHERE deleted_at IS NULL | 大小写不敏感唯一约束 |

#### 代码变更

| 文件 | 变更 |
|------|------|
| `tenant_auth.py` | 登录查询 `WHERE LOWER(username) = LOWER(%s)` |
| `admin_tenant_admins.py` | 创建 `INSERT ... VALUES (LOWER(%s), ...)`；编辑 `SET username = LOWER(%s)`；唯一性校验 `LOWER()` 比较 |

#### 兼容性

- 后端 API：登录和 CRUD 接口透明兼容，无需前端改动
- ETL：无影响
- 小程序：无影响（独立认证体系）
- 管理后台（admin-web）：无需改动，后端统一处理

#### 回滚策略

```sql
-- 1. 删除函数索引
DROP INDEX IF EXISTS auth.idx_tenant_admins_username_lower;
-- 2. 代码回滚：恢复 WHERE username = %s（不带 LOWER）
-- 注意：已小写化的用户名不可逆，但不影响功能
```

#### 验证 SQL

```sql
-- 1. 确认索引存在
SELECT indexname FROM pg_indexes
 WHERE tablename = 'tenant_admins' AND indexname = 'idx_tenant_admins_username_lower';
-- 预期：1 行

-- 2. 确认无大写用户名残留
SELECT username FROM auth.tenant_admins WHERE username != LOWER(username);
-- 预期：0 行

-- 3. 确认大小写不敏感登录可用
SELECT id FROM auth.tenant_admins WHERE LOWER(username) = LOWER('Admin') AND deleted_at IS NULL;
-- 预期：与 SELECT ... WHERE LOWER(username) = 'admin' 结果一致
```

---

## NS4.3 变更补充（2026-03-23）

### 角色体系隔离 + 店铺管理员支持

#### 变更说明

| 对象 | 变更类型 | 说明 |
|------|---------|------|
| `auth.tenant_admins.admin_type` | 新增字段 | `VARCHAR(20) NOT NULL DEFAULT 'tenant_admin'`，区分租户管理员（`tenant_admin`）和店铺管理员（`site_admin`） |
| `chk_admin_type` | 新增约束 | `CHECK (admin_type IN ('tenant_admin', 'site_admin'))` |
| `auth.roles` — `site_admin` / `tenant_admin` | 删除 | 小程序 RBAC 体系不需要这两个角色，租户/店铺管理员的区分通过 `admin_type` 列实现 |
| `auth.role_permissions` | 删除关联 | 删除 `site_admin` / `tenant_admin` 对应的 10 条权限映射 |
| `auth.roles` — `head_coach` / `manager` | 新增 | 小程序端新增教练和管理员角色 |

#### 业务规则

- 租户管理员（`admin_type='tenant_admin'`）：可管理所有管辖门店，可创建/编辑/删除店铺管理员
- 店铺管理员（`admin_type='site_admin'`）：仅可管理分配的门店，不可创建其他管理员
- 登录后界面一致，仅数据范围不同（由 `managed_site_ids` 控制）
- 用户名格式：`{第一个管辖店铺的 site_code} + 最长 50 字符`
- 只有 `admin_type='tenant_admin'` 的管理员可以访问 `/api/tenant/site-admins/*` 端点

#### 新增后端端点（店铺管理员 CRUD）

| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| GET | `/api/tenant/site-admins` | 列出店铺管理员 | 仅 tenant_admin |
| POST | `/api/tenant/site-admins` | 创建店铺管理员 | 仅 tenant_admin |
| PATCH | `/api/tenant/site-admins/{id}` | 编辑店铺管理员 | 仅 tenant_admin |
| DELETE | `/api/tenant/site-admins/{id}` | 删除店铺管理员（软删除） | 仅 tenant_admin |
| POST | `/api/tenant/site-admins/{id}/reset-password` | 重置密码 | 仅 tenant_admin |

#### 代码变更

| 文件 | 变更 |
|------|------|
| `tenant_auth.py` | 登录查询加入 `admin_type` 列，JWT 签发包含 `admin_type` |
| `tenant_admins.py` | `CurrentTenantAdmin` dataclass 加入 `admin_type` 字段 |
| `tenant_site_admins.py` | 新增路由模块，5 个 CRUD 端点 |
| `main.py` | 注册 `tenant_site_admins` 路由 |
| `admin_tenant_admins.py` | SQL 查询加入 `ta.admin_type` 列 |
| `xcx_auth.py` | `dev-switch-role` 硬编码更新（删除 site_admin/tenant_admin，新增 head_coach/manager） |

#### 兼容性

| 组件 | 影响 |
|------|------|
| 后端 API | 直接依赖。JWT 新增 `admin_type` 字段；新增 5 个店铺管理员端点；admin-web 列表接口返回 `admin_type` |
| 租户管理后台（tenant-admin） | 直接依赖。菜单根据 `adminType` 动态显示；新增店铺管理员管理页面 |
| 管理后台（admin-web） | 间接依赖。租户管理员列表新增"类型"列显示 |
| 小程序 | 间接依赖。`auth.roles` 删除 site_admin/tenant_admin，新增 head_coach/manager |
| ETL | 无影响 |

#### 回滚策略

```sql
BEGIN;
-- 1. 回滚 admin_type 列
ALTER TABLE auth.tenant_admins DROP CONSTRAINT IF EXISTS chk_admin_type;
ALTER TABLE auth.tenant_admins DROP COLUMN IF EXISTS admin_type;

-- 2. 回滚角色变更（恢复 site_admin/tenant_admin，删除 head_coach/manager）
DELETE FROM auth.role_permissions
 WHERE role_id IN (SELECT id FROM auth.roles WHERE code IN ('head_coach', 'manager'));
DELETE FROM auth.roles WHERE code IN ('head_coach', 'manager');

INSERT INTO auth.roles (code, name, description)
VALUES ('site_admin', '店铺管理员', '单店管理员，可查看所有看板和审核用户'),
       ('tenant_admin', '租户管理员', '连锁管理员，可管理多店铺和所有功能');

-- 恢复 site_admin/tenant_admin 的权限映射（各 5 条）
INSERT INTO auth.role_permissions (role_id, permission_id)
SELECT r.id, p.id
  FROM auth.roles r, auth.permissions p
 WHERE r.code IN ('site_admin', 'tenant_admin');

-- 3. 代码回滚：移除 tenant_site_admins 路由；JWT 移除 admin_type；恢复 dev-switch-role 硬编码
COMMIT;
```

#### 验证 SQL

```sql
-- 1. 确认 admin_type 列存在且默认值正确
SELECT column_name, data_type, column_default
  FROM information_schema.columns
 WHERE table_schema = 'auth' AND table_name = 'tenant_admins' AND column_name = 'admin_type';
-- 预期：admin_type, character varying, 'tenant_admin'::character varying

-- 2. 确认 CHECK 约束
SELECT conname, pg_get_constraintdef(oid)
  FROM pg_constraint
 WHERE conrelid = 'auth.tenant_admins'::regclass AND conname = 'chk_admin_type';
-- 预期：chk_admin_type, CHECK ((admin_type)::text = ANY (...))

-- 3. 确认角色体系（4 条，无 site_admin/tenant_admin）
SELECT code, name FROM auth.roles ORDER BY id;
-- 预期：coach, staff, head_coach, manager

-- 4. 确认角色-权限映射（11 条）
SELECT r.code, COUNT(rp.permission_id) AS perm_count
  FROM auth.roles r
  JOIN auth.role_permissions rp ON r.id = rp.role_id
 GROUP BY r.code ORDER BY r.code;
-- 预期：coach=2, head_coach=2, manager=5, staff=2（共 11 条，head_coach 仅 view_tasks+view_board）
```
