# NS4.1：租户管理员页面重构 — 项目级注册体系 + 简写ID管理

> 优先级：高（NS4 后续迭代，依赖 NS4 基础设施已就绪）
> 预估工作量：中
> 前置条件：NS4（租户管理后台基础设施）、P3（用户认证体系）
> 关联页面：`http://localhost:5173/tenant-admins`（admin-web 系统管理后台）

---

## 一、背景与目标

### 1.1 现状问题

当前 `admin-web` 的租户管理员页面（NS4 需求 14）仅支持基础 CRUD：
- 创建时手动输入 `tenant_id` 和 `managed_site_ids`（无下拉选项，无名称参考）
- 无法删除管理员记录
- 无法管理简写ID（site_code），简写ID 的创建和修改散落在数据库手动操作中
- 缺少项目级的「连接器 → 租户 → 店铺」注册体系，租户名称无处存储

### 1.2 目标

1. 建立项目级注册体系：`biz.connectors` → `biz.tenants` → `biz.sites`，统一管理连接器、租户、店铺三级关系
2. 将 `auth.site_code_mapping` 合并迁移至 `biz.sites`，简写ID 成为店铺属性
3. 简写ID 变更增量记录（`biz.site_code_history`），保护已提交但未审核的用户申请
4. 重构租户管理员页面：支持删除（软删除）、2 步创建流程、简写ID 管理
5. 新增 ETL 增量同步任务：从 `dwd.dim_site` 同步店铺信息到业务库

---

## 二、数据模型设计

### 2.1 新建表

#### 表 1：`biz.connectors` — 连接器注册表

记录本项目接入的上游 SaaS 系统。当前仅「飞球」一个连接器，预留多连接器扩展。

```sql
CREATE TABLE biz.connectors (
    id            SERIAL        PRIMARY KEY,
    connector_key VARCHAR(50)   NOT NULL UNIQUE,  -- 连接器标识（如 'feiqiu'）
    display_name  VARCHAR(100)  NOT NULL,          -- 显示名称（如 '飞球'）
    is_active     BOOLEAN       NOT NULL DEFAULT true,
    created_at    TIMESTAMPTZ   NOT NULL DEFAULT NOW()
);

COMMENT ON TABLE biz.connectors IS '连接器注册表：记录本项目接入的上游 SaaS 系统';
```

初始数据：

```sql
INSERT INTO biz.connectors (connector_key, display_name)
VALUES ('feiqiu', '飞球');
```

#### 表 2：`biz.tenants` — 租户注册表

记录每个连接器下的租户信息。`tenant_id` 来自上游系统（飞球的 `tenant_id`）。

```sql
CREATE TABLE biz.tenants (
    id            SERIAL        PRIMARY KEY,
    connector_id  INTEGER       NOT NULL REFERENCES biz.connectors(id),
    tenant_id     BIGINT        NOT NULL,          -- 上游系统的租户 ID
    tenant_name   VARCHAR(200),                    -- 租户名称（可从上游同步或手动填写）
    is_active     BOOLEAN       NOT NULL DEFAULT true,
    created_at    TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
    updated_at    TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
    UNIQUE (connector_id, tenant_id)               -- 同一连接器下 tenant_id 唯一
);

COMMENT ON TABLE biz.tenants IS '租户注册表：连接器下的租户，tenant_id 来自上游系统';
```

初始数据（从 ETL 库 `dwd.dim_site` 提取当前唯一租户）：

```sql
INSERT INTO biz.tenants (connector_id, tenant_id, tenant_name)
VALUES (1, 2790683160709957, '朗朗桌球');
-- tenant_name 暂用店铺名，后续可由管理员修改或从上游同步
```

#### 表 3：`biz.sites` — 店铺注册表（合并 `auth.site_code_mapping`）

将 `auth.site_code_mapping` 的功能合并到此表，增加 `tenant_id` 外键关联。
`site_code` 为当前生效的简写ID（6 位，3+3 格式）。

```sql
CREATE TABLE biz.sites (
    id            SERIAL        PRIMARY KEY,
    tenant_id     INTEGER       NOT NULL REFERENCES biz.tenants(id),
    site_id       BIGINT        NOT NULL UNIQUE,   -- 上游系统的店铺 ID
    site_name     VARCHAR(200),                    -- 店铺名称（从 dwd.dim_site 同步）
    site_code     VARCHAR(6)    UNIQUE,            -- 当前生效的简写ID（如 'LLQ001'）
    site_label    VARCHAR(50),                     -- 店铺标签（从 dwd.dim_site 同步）
    is_active     BOOLEAN       NOT NULL DEFAULT true,
    created_at    TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
    updated_at    TIMESTAMPTZ   NOT NULL DEFAULT NOW()
);

COMMENT ON TABLE biz.sites IS '店铺注册表：合并原 auth.site_code_mapping，增加租户关联和简写ID管理';
COMMENT ON COLUMN biz.sites.site_code IS '当前生效的简写ID，6位字符（3+3格式），全局唯一';
```

初始数据（从 `auth.site_code_mapping` 迁移）：

```sql
-- 仅迁移真实数据（排除测试数据 tenant_id IS NULL）
INSERT INTO biz.sites (tenant_id, site_id, site_name, site_code)
SELECT t.id, scm.site_id, scm.site_name, scm.site_code
FROM auth.site_code_mapping scm
JOIN biz.tenants t ON t.tenant_id = scm.tenant_id
WHERE scm.tenant_id IS NOT NULL;
```

#### 表 4：`biz.site_code_history` — 简写ID 变更历史表

增量记录所有使用过的简写ID，用于保护已提交但未审核的用户申请。

```sql
CREATE TABLE biz.site_code_history (
    id            SERIAL        PRIMARY KEY,
    site_id       BIGINT        NOT NULL,          -- 关联 biz.sites.site_id
    site_code     VARCHAR(6)    NOT NULL,           -- 历史简写ID
    is_current    BOOLEAN       NOT NULL DEFAULT false, -- 是否为当前生效
    created_at    TIMESTAMPTZ   NOT NULL DEFAULT NOW(), -- 该 code 生效时间
    retired_at    TIMESTAMPTZ,                      -- 该 code 失效时间（NULL=当前生效）
    UNIQUE (site_code)                              -- 简写ID 全局唯一（含历史）
);

COMMENT ON TABLE biz.site_code_history IS '简写ID变更历史：增量记录所有使用过的简写ID';
COMMENT ON COLUMN biz.site_code_history.is_current IS 'true=当前生效的简写ID，每个 site_id 最多一条 is_current=true';
```

### 2.2 表关系

```
biz.connectors (1)
  └── biz.tenants (N)        -- 一个连接器下多个租户
        └── biz.sites (N)    -- 一个租户下多个店铺
              └── biz.site_code_history (N)  -- 一个店铺的简写ID变更历史

auth.tenant_admins.tenant_id → biz.tenants.tenant_id（逻辑关联，不加 FK）
auth.user_applications.site_code → biz.site_code_history.site_code（逻辑关联）
```

### 2.3 废弃表处理

`auth.site_code_mapping` 在数据迁移完成并验证后标记为废弃：
1. 迁移期间保留原表，新旧并行
2. 后端代码切换到 `biz.sites` 读取
3. 验证无误后，原表重命名为 `auth._archived_site_code_mapping`

### 2.4 `dwd.dim_site` 提升为项目级

当前 `dwd.dim_site` 位于 ETL 连接器级别（`apps/etl/connectors/feiqiu/`）。
本次不做物理迁移（表仍在 `dwd` schema），但在语义上将其视为项目级维度表。
后续如有多连接器场景，再考虑拆分为项目级 `dim` schema。

---

## 三、功能详细设计

### 3.1 租户管理员列表页

#### 现有功能保留
- 分页 + 关键词搜索
- 编辑（显示名称、管辖门店、账号状态）
- 重置密码

#### 新增功能

##### 3.1.1 删除管理员（软删除）

- 操作：点击「删除」按钮 → 二次确认弹窗 → 确认后设置 `is_active = false`
- 列表默认只显示 `is_active = true` 的记录
- 可选：增加「显示已禁用」开关，查看所有记录
- 已禁用的记录不可再次删除，但可以重新启用

##### 3.1.2 创建管理员（2 步流程）

**第 1 步：创建账号**
- 选择租户：下拉选择 `biz.tenants` 中的租户（显示 `tenant_name`，值为 `tenant_id`）
- 输入用户名
- 输入初始密码
- 输入显示名称
- 选择管辖门店：根据所选租户，加载该租户下所有店铺（`biz.sites`），多选

**第 2 步：设置简写ID**
- 展示所选租户下所有店铺列表
- 每个店铺显示：店铺名称、当前简写ID（如有）
- 可为每个店铺设置/修改简写ID
- 简写ID 格式：6 位字符数字（3+3 模式，如 `LLQ001`）
- 校验：全局唯一（含历史记录中的 code）

> 第 2 步可跳过（简写ID 后续可在编辑时设置）

##### 3.1.3 编辑管理员

- 不可修改所属租户（`tenant_id` 只读）
- 可修改：用户名、密码（重置）、显示名称、管辖门店、简写ID
- 简写ID 编辑入口：在编辑弹窗中增加「管理简写ID」区域，展示该租户下所有店铺及其当前 code

### 3.2 简写ID 管理逻辑

#### 3.2.1 设置/修改简写ID

```
用户在管理后台修改某店铺的简写ID（old_code → new_code）
  → 校验 new_code 格式（6位，3+3）
  → 校验 new_code 全局唯一（biz.sites.site_code + biz.site_code_history.site_code）
  → 事务内执行：
     1. 将 old_code 在 site_code_history 中标记 is_current=false, retired_at=NOW()
     2. 插入 new_code 到 site_code_history（is_current=true）
     3. 更新 biz.sites.site_code = new_code
     4. 清理无引用的历史记录（见 3.2.2）
```

#### 3.2.2 历史记录清理

每次修改简写ID 时，检查被替换的旧 code 是否有关联的用户申请：

```sql
-- 检查旧 code 是否有未审核的申请引用
SELECT COUNT(*) FROM auth.user_applications
WHERE site_code = :old_code AND status = 'pending';
```

- 如果有未审核申请引用 → 保留历史记录（`is_current=false`，但不删除）
- 如果无任何申请引用 → 从 `biz.site_code_history` 中删除该条记录

> 目的：防止用户已用旧 code 提交申请但尚未审核时，映射关系丢失

#### 3.2.3 简写ID 格式规范

- 总长度：6 位
- 格式：3 位字母/数字 + 3 位数字（如 `LLQ001`、`ABC123`）
- 大小写：统一存储为大写
- 全局唯一：同一时刻不允许两个店铺使用相同 code（含历史未清理的 code）

### 3.3 租户/店铺信息展示

#### 3.3.1 租户下拉选项

创建管理员时，租户下拉数据来源：

```
GET /api/admin/tenants → biz.tenants (is_active=true)
返回：[{ id, tenantId, tenantName, connectorName }]
```

#### 3.3.2 店铺列表

选择租户后，加载该租户下所有店铺：

```
GET /api/admin/tenants/{tenant_id}/sites → biz.sites (tenant_id=?, is_active=true)
返回：[{ id, siteId, siteName, siteCode, siteLabel }]
```

---

## 四、接口设计

### 4.1 新增接口

| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 租户列表 | GET | `/api/admin/tenants` | 所有活跃租户（含连接器名称） |
| 租户下店铺列表 | GET | `/api/admin/tenants/{tenant_id}/sites` | 指定租户下所有店铺（含当前 site_code） |
| 删除管理员 | DELETE | `/api/admin/tenant-admins/{id}` | 软删除（is_active=false） |
| 设置简写ID | PUT | `/api/admin/sites/{site_id}/site-code` | 设置/修改店铺简写ID |
| 简写ID历史 | GET | `/api/admin/sites/{site_id}/site-code-history` | 查看某店铺的简写ID变更历史 |

### 4.2 修改接口

| 接口 | 变更内容 |
|------|----------|
| `POST /api/admin/tenant-admins` | 创建时 `tenant_id` 改为从 `biz.tenants` 选择；`managed_site_ids` 从 `biz.sites` 选择 |
| `PATCH /api/admin/tenant-admins/{id}` | 增加 `username` 可修改（需校验唯一性） |
| `GET /api/admin/tenant-admins` | 默认只返回 `is_active=true`；增加 `include_inactive` 参数 |

---

## 五、ETL 同步任务

### 5.1 店铺信息增量同步

新增 ETL 任务：从 `dwd.dim_site`（ETL 库）增量同步到 `biz.sites`（业务库）。

#### 同步逻辑

```
1. 读取 dwd.dim_site WHERE scd2_is_current = 1
2. 对比 biz.sites 中已有记录：
   - 新增店铺（site_id 不存在）→ INSERT（site_code 留空，待管理员设置）
   - 店铺名称变更 → UPDATE site_name
   - 店铺标签变更 → UPDATE site_label
3. 不删除已有记录（即使上游标记为关闭）
```

#### 触发方式

- 手动触发：管理后台按钮或 CLI 命令
- 定时触发：随 ETL 日常调度（DWD 层完成后）

#### 租户信息

- `biz.tenants` 中的 `tenant_name` 暂不自动同步（上游无 tenant_name 字段）
- 首次由迁移脚本写入，后续由管理员在管理后台手动修改

---

## 六、数据迁移计划

### 6.1 迁移步骤

```
1. 创建新表：biz.connectors → biz.tenants → biz.sites → biz.site_code_history
2. 写入种子数据：connectors（飞球）、tenants（从 dim_site 提取）
3. 迁移 auth.site_code_mapping → biz.sites（仅真实数据，排除 tenant_id IS NULL 的测试数据）
4. 为已有 site_code 创建 site_code_history 记录（is_current=true）
5. 运行 ETL 同步任务，补充 biz.sites 中缺失的店铺（dim_site 中有但 site_code_mapping 中没有的）
6. 后端代码切换：所有读取 auth.site_code_mapping 的地方改为读取 biz.sites
7. 验证：对比新旧表数据一致性
8. 废弃原表：重命名为 auth._archived_site_code_mapping
```

### 6.2 回滚策略

- 迁移期间保留原表不动
- 如需回滚：删除 biz.sites/tenants/connectors/site_code_history，恢复后端代码指向 auth.site_code_mapping

---

## 七、前端页面设计

### 7.1 页面布局

```
┌─────────────────────────────────────────────────────┐
│  租户管理员                                          │
│  [搜索框]  [显示已禁用 ☐]  [+ 创建管理员]            │
├─────────────────────────────────────────────────────┤
│  用户名 │ 显示名称 │ 租户 │ 管辖门店 │ 状态 │ 操作   │
│  admin1 │ 张三     │ 朗朗 │ 朗朗桌球 │ 启用 │ 编辑   │
│         │          │      │          │      │ 重置   │
│         │          │      │          │      │ 简写ID │
│         │          │      │          │      │ 删除   │
└─────────────────────────────────────────────────────┘
```

### 7.2 创建弹窗（2 步）

```
步骤 1/2：创建账号
┌──────────────────────────────┐
│  租户：    [▼ 朗朗桌球     ] │
│  用户名：  [              ] │
│  初始密码：[              ] │
│  显示名称：[              ] │
│  管辖门店：[☑ 朗朗桌球    ] │
│                              │
│        [下一步]  [取消]      │
└──────────────────────────────┘

步骤 2/2：设置简写ID
┌──────────────────────────────┐
│  店铺          当前简写ID     │
│  朗朗桌球      [LLQ001    ]  │
│                              │
│  格式：6位（3字母+3数字）     │
│                              │
│   [跳过]  [完成创建]  [上一步]│
└──────────────────────────────┘
```

### 7.3 简写ID 管理弹窗

```
管理简写ID — 朗朗桌球（租户）
┌──────────────────────────────────────┐
│  店铺          当前ID    操作         │
│  朗朗桌球      LLQ001   [修改]       │
│                                      │
│  修改简写ID：                         │
│  新ID：[      ]  [保存]  [取消]      │
│                                      │
│  变更历史：                           │
│  LLQ001  当前生效  2026-03-22        │
│  LL001   已失效    2026-02-25 (保留)  │
│                                      │
│                          [关闭]      │
└──────────────────────────────────────┘
```

---

## 八、影响范围

### 8.1 后端

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `app/routers/admin_tenant_admins.py` | 修改 | 增加 DELETE 端点、修改 CREATE/EDIT 逻辑 |
| `app/schemas/admin_tenant_admins.py` | 修改 | 新增/修改请求响应 Schema |
| `app/routers/admin_registry.py` | 新建 | 租户/店铺/简写ID 管理接口 |
| `app/schemas/admin_registry.py` | 新建 | 注册体系 Schema |

### 8.2 前端（admin-web）

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `src/pages/TenantAdmins/index.tsx` | 重构 | 2 步创建、删除、简写ID 管理 |
| `src/api/tenantAdmins.ts` | 修改 | 新增 API 调用 |
| `src/api/registry.ts` | 新建 | 租户/店铺列表 API |

### 8.3 数据库

| 操作 | 对象 | 说明 |
|------|------|------|
| 新建 | `biz.connectors` | 连接器注册表 |
| 新建 | `biz.tenants` | 租户注册表 |
| 新建 | `biz.sites` | 店铺注册表（合并 site_code_mapping） |
| 新建 | `biz.site_code_history` | 简写ID 变更历史 |
| 废弃 | `auth.site_code_mapping` | 迁移完成后废弃 |

### 8.4 ETL

| 变更 | 说明 |
|------|------|
| 新增同步任务 | `dwd.dim_site` → `biz.sites` 增量同步 |

### 8.5 小程序端

| 变更 | 说明 |
|------|------|
| 用户申请时的 site_code 查询 | 从 `auth.site_code_mapping` 切换到 `biz.sites` + `biz.site_code_history` |

---

## 九、约束与边界条件

1. 一个租户只有一个管理员（软限制，不加 DB 约束）
2. 简写ID 全局唯一（含历史记录），通过 UNIQUE 约束保证
3. 删除管理员为软删除（`is_active = false`），不物理删除
4. 编辑时不可修改所属租户
5. 简写ID 格式：6 位，3+3 模式，统一大写存储
6. 历史简写ID 仅在无未审核申请引用时才可清理
7. ETL 同步不删除已有店铺记录（即使上游关闭）
8. `biz.tenants.tenant_name` 暂不自动同步，由管理员手动维护

---

## 十、不做的事情（明确排除）

1. 不做多连接器支持的完整实现（仅预留 `biz.connectors` 表结构）
2. 不做 `dwd.dim_site` 的物理迁移（保留在 `dwd` schema）
3. 不做租户管理员的自助注册功能
4. 不做店铺管理员的管理（本 PRD 仅涉及租户管理员）
5. 不做 `auth.site_code_mapping` 的立即删除（迁移后保留为 `_archived`）
6. 不做简写ID 的自动生成（由管理员手动设置）

---

## 十一、数据库现状参考

### ETL 库 `dwd.dim_site`（当前数据）

| site_id | tenant_id | shop_name | site_label |
|---------|-----------|-----------|------------|
| 2790685415443269 | 2790683160709957 | 朗朗桌球 | A |

### 业务库 `auth.site_code_mapping`（当前数据）

| id | site_code | site_id | site_name | tenant_id |
|----|-----------|---------|-----------|-----------|
| 1 | LL001 | 2790685415443269 | 朗朗桌球 | 2790683160709957 |
| 1448 | PT952 | 857189 | 测试球房_PT952 | NULL |
| 1470 | PT118 | 819193 | 测试球房_PT118 | NULL |
| 1471 | PT607 | 899675 | 测试球房_PT607 | NULL |

> 仅 id=1 为真实数据，其余为属性测试生成的测试数据（`tenant_id IS NULL`）

### 业务库 `auth.tenant_admins`（现有结构）

| 字段 | 类型 | 说明 |
|------|------|------|
| id | BIGSERIAL | 主键 |
| username | VARCHAR(50) | 登录用户名，UNIQUE |
| password_hash | VARCHAR(255) | bcrypt 哈希 |
| display_name | VARCHAR(100) | 显示名称 |
| tenant_id | BIGINT | 所属租户 |
| managed_site_ids | BIGINT[] | 管辖门店 ID 列表 |
| is_active | BOOLEAN | 账号状态 |
| created_by | BIGINT | 创建者 |
| created_at | TIMESTAMPTZ | 创建时间 |
| last_login_at | TIMESTAMPTZ | 最后登录时间 |

> `tenant_admins` 表结构不变，仅后端逻辑调整（创建时从 `biz.tenants` 选择租户）

---

## 十二、参考文档

| 文档 | 路径 | 用途 |
|------|------|------|
| NS4 原始 PRD | `docs/prd/Neo_Specs/NS4-tenant-admin-web.md` | 租户管理后台完整需求 |
| P3 认证体系 | `docs/prd/specs/P3-miniapp-auth-system.md` | site_code / user_applications 设计 |
| BD 手册-认证表 | `docs/database/BD_Manual_auth_tables.md` | auth schema 表结构 |
| BD 手册-业务表 | `docs/database/BD_Manual_biz_tables.md` | biz schema 表结构 |
