# 设计文档 — NS4.1 + P16:Admin-Web 管理后台增强 > 权威参考:实施过程中如遇细节不明确,应优先查阅 PRD 原文: > - `docs/prd/Neo_Specs/NS4.1-tenant-admin-redesign.md` — NS4.1 完整设计(数据模型 DDL、页面布局、接口设计、迁移步骤、边界条件) > - `docs/prd/specs/P16-task-min-run-interval.md` — P16 完整设计(调度器逻辑、API 扩展、前端变更、边界条件) ## 概述 本设计覆盖两个独立模块:模块 A(NS4.1 租户管理员页面重构 + 项目级注册体系)和模块 B(P16 调度任务最小运行间隔)。两者改动文件无重叠,共享收尾流程。 ### 设计决策与理由 | 决策 | 理由 | |------|------| | `biz.connectors/tenants/sites` 三级表而非扁平表 | 预留多连接器扩展,当前仅飞球一个连接器但表结构已就绪 | | `site_code_history` 全局 UNIQUE 含历史 | 保护已提交但未审核的用户申请,旧 code 映射不丢失 | | `auth.site_code_mapping` 迁移后重命名而非删除 | 安全回滚,迁移期间新旧并行 | | `min_run_interval` 放表级列而非 `schedule_config` JSONB | 便于 SQL 查询和索引,避免 JSONB 解析开销 | | 间隔检查基于 `last_run_at`(开始时间)而非 `last_success_at` | 防止失败后立即重试导致资源浪费,失败后仍需等待间隔 | | 并发检查基于 `last_status = 'running'` | 简单有效,无需引入分布式锁 | | `force=true` 绕过所有检查 | 应急场景需要管理员完全控制 | ## 架构 ### 模块 A:注册体系数据流 ``` admin-web(TenantAdmins 页面) ├─ 创建管理员 → 选择租户(biz.tenants)→ 选择门店(biz.sites) ├─ 管理简写ID → PUT /api/admin/sites/{site_id}/site-code └─ 删除管理员 → DELETE /api/admin/tenant-admins/{id}(软删除) ↓ API(admin_registry.py + admin_tenant_admins.py) 数据库:biz schema biz.connectors (1) → biz.tenants (N) → biz.sites (N) → biz.site_code_history (N) ↑ ETL 同步(dim_site → biz.sites) 小程序端:用户申请时 site_code 查询 auth.site_code_mapping → biz.sites + biz.site_code_history ``` ### 模块 B:调度器间隔检查流程 ``` Admin Web(ScheduleTab.tsx) ├─ 创建/编辑表单 → min_run_interval_value + min_run_interval_unit └─ 手动执行 → force=true/false ↓ API(schedules.py) 数据库:scheduled_tasks 表 ├─ min_run_interval_value (INTEGER) ├─ min_run_interval_unit (VARCHAR) └─ last_success_at (TIMESTAMPTZ) ↓ 调度器轮询(scheduler.py,每 30 秒) 判断逻辑: 1. 并发检查 → last_status == 'running' → 跳过 2. 间隔检查 → now - last_run_at < min_interval → 跳过 3. 正常入队 → task_queue.enqueue() ↓ 任务执行完成回调 更新 scheduled_tasks: - 成功 → last_status='completed', last_success_at=NOW() - 失败 → last_status='failed'(last_success_at 不变) ``` ## 组件与接口 ### 模块 A 组件 #### 1. admin_registry.py — 注册体系路由(新建) 文件:`apps/backend/app/routers/admin_registry.py` ```python router = APIRouter(prefix="/api/admin", tags=["admin-registry"]) @router.get("/tenants") async def list_tenants() -> list[TenantItem]: """所有活跃租户(含连接器名称)。""" @router.get("/tenants/{tenant_id}/sites") async def list_tenant_sites(tenant_id: int) -> list[SiteItem]: """指定租户下所有活跃店铺。""" @router.put("/sites/{site_id}/site-code") async def update_site_code(site_id: int, body: UpdateSiteCodeRequest) -> SiteCodeResult: """设置/修改店铺简写ID,事务内执行历史记录管理。""" @router.get("/sites/{site_id}/site-code-history") async def get_site_code_history(site_id: int) -> list[SiteCodeHistoryItem]: """查看简写ID 变更历史。""" ``` #### 2. admin_registry.py — Schema(新建) 文件:`apps/backend/app/schemas/admin_registry.py` ```python class TenantItem(BaseModel): id: int tenant_id: int tenant_name: str | None connector_name: str is_active: bool class SiteItem(BaseModel): id: int site_id: int site_name: str | None site_code: str | None site_label: str | None is_active: bool class UpdateSiteCodeRequest(BaseModel): new_code: str # 6 位,3+3 格式,统一大写 class SiteCodeResult(BaseModel): site_id: int old_code: str | None new_code: str history_cleaned: bool # 旧 code 是否被清理 class SiteCodeHistoryItem(BaseModel): id: int site_code: str is_current: bool created_at: datetime retired_at: datetime | None class SiteSyncResult(BaseModel): inserted: int # 新增店铺数 updated: int # 更新店铺数 ``` #### 3. admin_tenant_admins.py — 扩展(修改) 文件:`apps/backend/app/routers/admin_tenant_admins.py` 变更要点: - 新增 `DELETE /api/admin/tenant-admins/{id}` 端点(软删除) - 修改 `POST /api/admin/tenant-admins` 创建逻辑:`tenant_id` 从 `biz.tenants` 校验存在性 - 修改 `GET /api/admin/tenant-admins` 列表:默认 `is_active=true`,新增 `include_inactive` 参数 - 修改 `PATCH /api/admin/tenant-admins/{id}` 编辑逻辑:支持修改 `username`(校验全局唯一性,冲突返回 409) - Schema 扩展:`TenantAdminListItem` 新增 `tenant_name` 字段(JOIN `biz.tenants`) #### 4. site_code 查询切换 涉及文件: - `apps/backend/app/routers/tenant_users.py` — `match-suggestions` 中的 site_code 查询 - `apps/backend/app/auth/tenant_admins.py` — 如有 site_code 相关逻辑 - `apps/miniprogram/` — 用户申请时的 site_code 验证 切换策略: ```sql -- 旧:SELECT * FROM auth.site_code_mapping WHERE site_code = :code -- 新:优先查 biz.sites,再查 biz.site_code_history SELECT s.site_id, s.site_name, s.tenant_id FROM biz.sites s WHERE s.site_code = :code AND s.is_active = true UNION ALL SELECT s.site_id, s.site_name, s.tenant_id FROM biz.site_code_history h JOIN biz.sites s ON s.site_id = h.site_id WHERE h.site_code = :code AND h.is_current = false AND s.is_active = true LIMIT 1; ``` #### 5. admin-web 前端组件 文件:`apps/admin-web/src/pages/TenantAdmins/index.tsx`(重构) 文件:`apps/admin-web/src/api/registry.ts`(新建) 文件:`apps/admin-web/src/api/tenantAdmins.ts`(修改) 前端变更要点: - 列表页新增「删除」按钮 + 「显示已禁用」开关 - 创建弹窗改为 2 步 Steps 组件 - 新增简写ID 管理弹窗(Modal 内嵌 Table + 编辑行) - `registry.ts` 封装 `GET /api/admin/tenants`、`GET /api/admin/tenants/{id}/sites`、`POST /api/admin/sites/sync` #### 6. ETL 店铺同步逻辑(新建) 文件:`apps/backend/app/routers/admin_registry.py`(同注册体系路由)或独立 service ```python @router.post("/sites/sync") async def sync_sites() -> SiteSyncResult: """手动触发店铺同步:FDW 读取 dwd.dim_site → 对比 biz.sites → INSERT/UPDATE。""" class SiteSyncResult(BaseModel): inserted: int # 新增店铺数 updated: int # 更新店铺数 ``` 同步逻辑: - 通过 FDW 读取 ETL 库 `dwd.dim_site`(`scd2_is_current=1`) - 对比 `biz.sites`:新 site_id → INSERT(site_code 留空),名称/标签变更 → UPDATE - 不删除已有记录 - 预留定时触发入口(ETL DWD 完成后通过内部 API 调用) - 数据迁移(任务 1)完成后需执行一次初始同步,补充 `auth.site_code_mapping` 中没有但 `dwd.dim_site` 中有的店铺 ### 模块 B 组件 #### 1. scheduler.py — 核心逻辑扩展(修改) 文件:`apps/backend/app/services/scheduler.py` ```python def _convert_interval_to_seconds(value: int, unit: str) -> int: """将间隔值转换为秒数。""" multipliers = {"minutes": 60, "hours": 3600, "days": 86400} return value * multipliers.get(unit, 60) async def check_and_enqueue(self): """扩展后的轮询逻辑。""" # SQL 查询扩展:新增读取 min_run_interval_value, min_run_interval_unit, last_run_at, last_status for task in due_tasks: # 1. 并发检查 if task.last_status == "running": logger.info(f"Task {task.id} skipped: concurrent execution") continue # 2. 间隔检查 if task.min_run_interval_value > 0 and task.last_run_at: min_seconds = _convert_interval_to_seconds(task.min_run_interval_value, task.min_run_interval_unit) if (now - task.last_run_at).total_seconds() < min_seconds: # 推进 next_run_at logger.info(f"Task {task.id} skipped: interval not reached") continue # 3. 正常入队 await self.enqueue(task) ``` 任务完成回调扩展: ```python async def on_task_completed(self, task_id: int, success: bool): if success: await db.execute( "UPDATE scheduled_tasks SET last_status='completed', last_success_at=NOW() WHERE id=:id", {"id": task_id} ) else: await db.execute( "UPDATE scheduled_tasks SET last_status='failed' WHERE id=:id", {"id": task_id} ) ``` #### 2. schedules.py — API 路由扩展(修改) 文件:`apps/backend/app/routers/schedules.py` 文件:`apps/backend/app/schemas/schedules.py` Schema 扩展: ```python class CreateScheduleRequest(BaseModel): # ... 现有字段 ... min_run_interval_value: int = 0 min_run_interval_unit: str = "minutes" class UpdateScheduleRequest(BaseModel): # ... 现有字段 ... min_run_interval_value: int | None = None min_run_interval_unit: str | None = None class ScheduleResponse(BaseModel): # ... 现有字段 ... min_run_interval_value: int min_run_interval_unit: str last_success_at: datetime | None ``` 手动执行扩展: ```python @router.post("/{schedule_id}/run") async def run_schedule(schedule_id: int, force: bool = False): if not force: task = await get_task(schedule_id) if task.last_status == "running": raise HTTPException(409, "任务正在执行中") if task.min_run_interval_value > 0 and task.last_run_at: min_seconds = _convert_interval_to_seconds(...) remaining = min_seconds - (now - task.last_run_at).total_seconds() if remaining > 0: raise HTTPException(409, f"最小运行间隔未到,距下次可执行还有 {int(remaining // 60)} 分钟") await enqueue_task(schedule_id, force=force) ``` #### 3. ScheduleTab.tsx — 前端扩展(修改) 文件:`apps/admin-web/src/components/ScheduleTab.tsx` 变更要点: - 创建/编辑表单新增「最小运行间隔」行:`InputNumber` + `Select`(分钟/小时/天) - 列表表格新增「最小间隔」列和「上次成功」列 - 手动执行确认框新增「强制执行」Checkbox ## 数据模型 ### 模块 A:新建表 #### biz.connectors(连接器注册表) ```sql CREATE TABLE biz.connectors ( id SERIAL PRIMARY KEY, connector_key VARCHAR(50) NOT NULL UNIQUE, 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 系统'; ``` #### biz.tenants(租户注册表) ```sql CREATE TABLE biz.tenants ( id SERIAL PRIMARY KEY, connector_id INTEGER NOT NULL REFERENCES biz.connectors(id), tenant_id BIGINT NOT NULL, 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) ); COMMENT ON TABLE biz.tenants IS '租户注册表:连接器下的租户,tenant_id 来自上游系统'; ``` #### biz.sites(店铺注册表) ```sql CREATE TABLE biz.sites ( id SERIAL PRIMARY KEY, tenant_id INTEGER NOT NULL REFERENCES biz.tenants(id), site_id BIGINT NOT NULL UNIQUE, site_name VARCHAR(200), site_code VARCHAR(6) UNIQUE, site_label VARCHAR(50), 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格式),全局唯一'; ``` #### biz.site_code_history(简写ID 变更历史) ```sql CREATE TABLE biz.site_code_history ( id SERIAL PRIMARY KEY, site_id BIGINT NOT NULL, site_code VARCHAR(6) NOT NULL, is_current BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), retired_at TIMESTAMPTZ, UNIQUE (site_code) ); 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'; ``` ### 模块 A:种子数据 ```sql -- 连接器 INSERT INTO biz.connectors (connector_key, display_name) VALUES ('feiqiu', '飞球'); -- 租户(从 dwd.dim_site 提取) INSERT INTO biz.tenants (connector_id, tenant_id, tenant_name) VALUES (1, 2790683160709957, '朗朗桌球'); -- 店铺(从 auth.site_code_mapping 迁移真实数据) 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; -- 简写ID 历史(为已有 code 创建记录) INSERT INTO biz.site_code_history (site_id, site_code, is_current) SELECT site_id, site_code, true FROM biz.sites WHERE site_code IS NOT NULL; ``` ### 模块 B:已有表变更 #### public.scheduled_tasks — 新增字段 ```sql ALTER TABLE scheduled_tasks ADD COLUMN min_run_interval_value INTEGER NOT NULL DEFAULT 0; ALTER TABLE scheduled_tasks ADD COLUMN min_run_interval_unit VARCHAR(20) NOT NULL DEFAULT 'minutes'; ALTER TABLE scheduled_tasks ADD COLUMN last_success_at TIMESTAMPTZ; COMMENT ON COLUMN scheduled_tasks.min_run_interval_value IS '最小间隔数值(0=无限制)'; COMMENT ON COLUMN scheduled_tasks.min_run_interval_unit IS '间隔单位:minutes/hours/days'; COMMENT ON COLUMN scheduled_tasks.last_success_at IS '最后一次成功执行的时间'; ``` ### 模块 A:废弃表处理 迁移完成并验证后: ```sql ALTER TABLE auth.site_code_mapping RENAME TO auth._archived_site_code_mapping; COMMENT ON TABLE auth._archived_site_code_mapping IS '已废弃(2026-03-22):数据已迁移至 biz.sites,保留供回滚'; ``` ## 正确性属性 ### Property 1: 简写ID 全局唯一性 *对于任意* 简写ID 设置/修改操作,新 code 在 `biz.sites.site_code` 和 `biz.site_code_history.site_code` 中均不存在时才允许写入;已存在时应拒绝并返回错误。 **验证: 需求 A3.2** ### Property 2: 简写ID 变更事务完整性 *对于任意* 简写ID 修改操作(old_code → new_code),事务完成后应满足:`biz.sites.site_code = new_code`,`site_code_history` 中 old_code 的 `is_current=false` 且 `retired_at IS NOT NULL`,new_code 的 `is_current=true`。事务失败时所有变更回滚。 **验证: 需求 A3.1** ### Property 3: 简写ID 格式校验 *对于任意* 输入字符串,简写ID 校验应仅接受 6 位字符(3 位字母/数字 + 3 位数字),统一转为大写存储;不符合格式的输入应被拒绝。 **验证: 需求 A3.2** ### Property 4: 租户管理员软删除一致性 *对于任意* 管理员删除操作,删除后该管理员 `is_active=false`;默认列表查询不返回该记录;`include_inactive=true` 时返回。 **验证: 需求 A2.3, A2.7** ### Property 5: 数据迁移完整性 *对于* `auth.site_code_mapping` 中所有 `tenant_id IS NOT NULL` 的记录,迁移后 `biz.sites` 中应存在对应的 `site_id` 记录,且 `site_code` 值一致。 **验证: 需求 A1.5** ### Property 6: site_code 查询切换等价性 *对于任意* site_code 查询,切换到 `biz.sites` + `biz.site_code_history` 后的查询结果应与原 `auth.site_code_mapping` 查询结果等价(相同 site_code 映射到相同 site_id)。 **验证: 需求 A6.1, A6.2** ### Property 7: 间隔转换正确性 *对于任意* `(value, unit)` 组合,`_convert_interval_to_seconds()` 应返回正确的秒数:minutes × 60、hours × 3600、days × 86400;value=0 时返回 0。 **验证: 需求 B2.4** ### Property 8: 调度器间隔跳过正确性 *对于任意* 到期任务,当 `min_run_interval_value > 0` 且 `now() - last_run_at < min_interval_seconds` 时应跳过执行;当 `min_run_interval_value = 0` 或 `last_run_at IS NULL` 时应正常执行。 **验证: 需求 B2.1, B2.2** ### Property 9: 调度器并发跳过正确性 *对于任意* 到期任务,当 `last_status = 'running'` 时应跳过入队;其他状态正常处理。 **验证: 需求 B2.1** ### Property 10: 强制执行绕过所有检查 *对于任意* 手动执行请求,当 `force=true` 时应绕过间隔检查和并发检查,直接入队执行。 **验证: 需求 B3.4** ### Property 11: last_success_at 仅成功时更新 *对于任意* 任务执行结果,成功时 `last_success_at` 更新为当前时间;失败时 `last_success_at` 保持不变。 **验证: 需求 B2.3** ## 错误处理 ### 模块 A 错误处理 | 场景 | HTTP 状态码 | 响应 | |------|------------|------| | 简写ID 格式不合法 | 422 | "简写ID 格式错误,需 6 位(3+3 模式)" | | 简写ID 已被占用 | 409 | "简写ID '{code}' 已被使用" | | 租户不存在 | 404 | "租户不存在" | | 店铺不存在 | 404 | "店铺不存在" | | 管理员不存在 | 404 | "管理员不存在" | | 管理员已禁用再次删除 | 409 | "管理员已处于禁用状态" | ### 模块 B 错误处理 | 场景 | HTTP 状态码 | 响应 | |------|------------|------| | 手动执行 + 间隔未到 + force=false | 409 | "最小运行间隔未到,距下次可执行还有 X 分钟" | | 手动执行 + 任务正在运行 + force=false | 409 | "任务正在执行中" | | 无效间隔单位 | 422 | "间隔单位必须为 minutes/hours/days" | ## 测试策略 ### 属性测试(Property-Based Testing) 使用 `hypothesis` 库,测试文件位于 `tests/` 目录。 | 测试文件 | 覆盖属性 | |---------|---------| | `tests/test_site_code_props.py` | Property 1(唯一性)、Property 2(事务完整性)、Property 3(格式校验) | | `tests/test_tenant_admin_props.py` | Property 4(软删除一致性) | | `tests/test_scheduler_interval_props.py` | Property 7(间隔转换)、Property 8(间隔跳过)、Property 9(并发跳过)、Property 10(强制执行)、Property 11(last_success_at) | ### 单元测试 | 测试文件 | 覆盖内容 | |---------|---------| | `apps/backend/tests/unit/test_admin_registry.py` | 注册体系 API 边界条件 | | `apps/backend/tests/unit/test_scheduler_interval.py` | 调度器间隔逻辑边界条件 | ### 集成验证 - 数据迁移验证:Property 5(迁移完整性)通过一次性验证脚本检查 - site_code 查询切换验证:Property 6(等价性)通过对比新旧查询结果检查 ## 涉及文件汇总 ### 模块 A(NS4.1) | 模块 | 文件路径 | 操作 | |------|---------|------| | DDL 迁移 | `db/zqyy_app/migrations/2026-03-22__ns41_registry_tables.sql` | 新增 | | DDL 基线 | `docs/database/ddl/zqyy_app__biz.sql` | 修改 | | Schema | `apps/backend/app/schemas/admin_registry.py` | 新建 | | Schema | `apps/backend/app/schemas/admin_tenant_admins.py` | 修改 | | 路由 | `apps/backend/app/routers/admin_registry.py` | 新建(含同步端点 `POST /api/admin/sites/sync`) | | 路由 | `apps/backend/app/routers/admin_tenant_admins.py` | 修改 | | 路由 | `apps/backend/app/routers/tenant_users.py` | 修改(site_code 查询切换) | | 主入口 | `apps/backend/app/main.py` | 修改(注册新路由) | | 前端 API | `apps/admin-web/src/api/registry.ts` | 新建(含 sync API 封装) | | 前端 API | `apps/admin-web/src/api/tenantAdmins.ts` | 修改 | | 前端页面 | `apps/admin-web/src/pages/TenantAdmins/index.tsx` | 重构 | | BD 手册 | `docs/database/BD_Manual_biz_registry_tables.md` | 新建 | | BD 手册 | `docs/database/BD_Manual_tenant_admin_tables.md` | 修改 | ### 模块 B(P16) | 模块 | 文件路径 | 操作 | |------|---------|------| | DDL 迁移 | `db/zqyy_app/migrations/2026-03-22__p16_min_run_interval.sql` | 新增 | | DDL 基线 | `docs/database/ddl/zqyy_app__public.sql` | 修改 | | Schema | `apps/backend/app/schemas/schedules.py` | 修改 | | 调度器 | `apps/backend/app/services/scheduler.py` | 修改 | | 路由 | `apps/backend/app/routers/schedules.py` | 修改 | | 任务队列 | `apps/backend/app/services/task_queue.py` | 可能修改(回调) | | 前端 | `apps/admin-web/src/components/ScheduleTab.tsx` | 修改 | | BD 手册 | `docs/database/BD_Manual_scheduled_tasks.md` | 新建/修改 |