# 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 表结构 |