Files
Neo 70324d8542 chore: 文档与 IDE 配置整理
- .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>
2026-04-06 00:02:37 +08:00

560 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 设计文档 — NS4.1 + P16Admin-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 扩展、前端变更、边界条件)
## 概述
本设计覆盖两个独立模块:模块 ANS4.1 租户管理员页面重构 + 项目级注册体系)和模块 BP16 调度任务最小运行间隔)。两者改动文件无重叠,共享收尾流程。
### 设计决策与理由
| 决策 | 理由 |
|------|------|
| `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-webTenantAdmins 页面)
├─ 创建管理员 → 选择租户biz.tenants→ 选择门店biz.sites
├─ 管理简写ID → PUT /api/admin/sites/{site_id}/site-code
└─ 删除管理员 → DELETE /api/admin/tenant-admins/{id}(软删除)
↓ APIadmin_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 WebScheduleTab.tsx
├─ 创建/编辑表单 → min_run_interval_value + min_run_interval_unit
└─ 手动执行 → force=true/false
↓ APIschedules.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 → INSERTsite_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 '当前生效的简写ID6位字符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 × 86400value=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 11last_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等价性通过对比新旧查询结果检查
## 涉及文件汇总
### 模块 ANS4.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` | 修改 |
### 模块 BP16
| 模块 | 文件路径 | 操作 |
|------|---------|------|
| 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` | 新建/修改 |