- .kiro/specs/ → docs/specs/(41 个历史需求 spec 迁移,移除 .config.kiro) - CLAUDE.md 三层拆分:根文件精简 + apps/backend/CLAUDE.md + .claude/commands/ - 新增 /spec-close、/pre-change 两个工作流命令 - DDL 基线刷新(从测试库重新导出 11 个文件,dws 35→38 表,biz 18→21 表) - BD_Manual → BD_manual 命名统一(48 个文件) - 修复 3 处文档与数据库不一致(auth.users.status 默认值、scheduled_tasks 字段、RLS 视图数) - 新增 BD_manual_public_rbac_tables.md(public schema 8 张 RBAC/工作流表) - 合并 biz.trigger_jobs 文档(10→12 字段,归档独立文档) - docs/database/README.md 索引更新 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
21 KiB
设计文档 — 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
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
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字段(JOINbiz.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 验证
切换策略:
-- 旧: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
@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
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)
任务完成回调扩展:
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 扩展:
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
手动执行扩展:
@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(连接器注册表)
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(租户注册表)
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(店铺注册表)
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 变更历史)
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:种子数据
-- 连接器
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 — 新增字段
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:废弃表处理
迁移完成并验证后:
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 |
新建/修改 |