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>
This commit is contained in:
559
docs/specs/admin-web-enhancement/design.md
Normal file
559
docs/specs/admin-web-enhancement/design.md
Normal file
@@ -0,0 +1,559 @@
|
||||
# 设计文档 — 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` | 新建/修改 |
|
||||
181
docs/specs/admin-web-enhancement/requirements.md
Normal file
181
docs/specs/admin-web-enhancement/requirements.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# 需求文档 — NS4.1 + P16:Admin-Web 管理后台增强
|
||||
|
||||
## 简介
|
||||
|
||||
本 Spec 合并两个独立需求:NS4.1(租户管理员页面重构 + 项目级注册体系)和 P16(调度任务最小运行间隔机制)。两者均为 admin-web 管理后台的功能增强,改动文件无重叠,合并执行以减少上下文切换。
|
||||
|
||||
### 合并理由
|
||||
|
||||
- 两者都是 admin-web 后台功能迭代,共享同一前端项目(`apps/admin-web/`)和后端项目(`apps/backend/`)
|
||||
- NS4.1 改动集中在租户管理员页面(`TenantAdmins/`)+ 新建注册体系路由,P16 改动集中在调度任务页面(`ScheduleTab.tsx`)+ 调度器逻辑
|
||||
- 改动文件完全不重叠,合并不增加冲突风险
|
||||
- 共享收尾流程(DDL 合并、BD 手册、文档同步)
|
||||
|
||||
### 依赖
|
||||
|
||||
- NS4(租户管理后台基础设施)— `tenant-admin-web` spec 已完成
|
||||
- P3(用户认证体系)— `03-miniapp-auth-system` spec 已完成
|
||||
|
||||
### 来源文档(权威参考)
|
||||
|
||||
实施过程中如遇细节不明确,应优先查阅以下 PRD 原文:
|
||||
|
||||
- `docs/prd/Neo_Specs/NS4.1-tenant-admin-redesign.md` — NS4.1 PRD 主文档(数据模型 DDL、页面布局、接口设计、迁移步骤、边界条件)
|
||||
- `docs/prd/specs/P16-task-min-run-interval.md` — P16 PRD 主文档(调度器逻辑、API 扩展、前端变更、边界条件)
|
||||
|
||||
### 不在本 spec 范围
|
||||
|
||||
- P15(AI 监控后台 + 测试重建 + 回填)— 单独 Spec
|
||||
- 多连接器完整实现(仅预留 `biz.connectors` 表结构)
|
||||
- `dwd.dim_site` 物理迁移(保留在 `dwd` schema)
|
||||
- 租户管理员自助注册
|
||||
- 简写ID 自动生成
|
||||
- ETL 任务注册机制修改(`TaskMeta`/`TaskRegistry`)
|
||||
- 批量 seed SQL 设定初始间隔值
|
||||
|
||||
## 术语表
|
||||
|
||||
- **admin-web**:系统管理后台(`apps/admin-web/`),面向系统管理员
|
||||
- **tenant-admin**:租户管理后台(`apps/tenant-admin/`),面向租户管理员
|
||||
- **biz.connectors**:连接器注册表,记录接入的上游 SaaS 系统
|
||||
- **biz.tenants**:租户注册表,连接器下的租户
|
||||
- **biz.sites**:店铺注册表,合并原 `auth.site_code_mapping`
|
||||
- **biz.site_code_history**:简写ID 变更历史表
|
||||
- **site_code**:店铺简写ID,6 位字符(3+3 格式,如 `LLQ001`)
|
||||
- **min_run_interval**:调度任务最小运行间隔,任务开始执行后的最小等待时间
|
||||
- **scheduled_tasks**:调度任务表(`public.scheduled_tasks`),存储 ETL 调度配置
|
||||
- **scheduler.py**:调度器核心逻辑,每 30 秒轮询到期任务
|
||||
|
||||
## 需求
|
||||
|
||||
### 模块 A:NS4.1 — 租户管理员页面重构 + 项目级注册体系
|
||||
|
||||
#### 需求 A1:项目级注册体系 — 连接器/租户/店铺三级表
|
||||
|
||||
**用户故事:** 作为系统管理员,我希望建立「连接器 → 租户 → 店铺」三级注册体系,以便统一管理上游 SaaS 系统、租户和店铺的关系,并为简写ID 提供归属。
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE Backend SHALL 在 `biz` schema 新建 `connectors` 表(id, connector_key UNIQUE, display_name, is_active, created_at),初始数据插入 `('feiqiu', '飞球')`
|
||||
2. THE Backend SHALL 在 `biz` schema 新建 `tenants` 表(id, connector_id FK, tenant_id BIGINT, tenant_name, is_active, created_at, updated_at),UNIQUE(connector_id, tenant_id)
|
||||
3. THE Backend SHALL 在 `biz` schema 新建 `sites` 表(id, tenant_id FK, site_id BIGINT UNIQUE, site_name, site_code VARCHAR(6) UNIQUE, site_label, is_active, created_at, updated_at)
|
||||
4. THE Backend SHALL 在 `biz` schema 新建 `site_code_history` 表(id, site_id BIGINT, site_code VARCHAR(6) UNIQUE, is_current BOOLEAN, created_at, retired_at)
|
||||
5. THE Backend SHALL 将 `auth.site_code_mapping` 中真实数据(`tenant_id IS NOT NULL`)迁移到 `biz.sites`,并为已有 `site_code` 创建 `site_code_history` 记录(`is_current=true`)
|
||||
6. THE Backend SHALL 在迁移完成并验证后,将 `auth.site_code_mapping` 重命名为 `auth._archived_site_code_mapping`
|
||||
|
||||
#### 需求 A1b:数据迁移后初始同步
|
||||
|
||||
**用户故事:** 作为系统管理员,我希望迁移完成后立即运行一次 ETL 同步,补充 `biz.sites` 中缺失的店铺(`dwd.dim_site` 中有但 `auth.site_code_mapping` 中没有的),确保注册体系数据完整。
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE Backend SHALL 在数据迁移(A1)完成后、代码切换(A6)之前,执行一次店铺同步(复用 A5 的同步逻辑),将 `dwd.dim_site`(`scd2_is_current=1`)中存在但 `biz.sites` 中不存在的店铺补充插入
|
||||
2. THE Backend SHALL 在初始同步完成后输出同步结果(新增数/更新数),供验证使用
|
||||
|
||||
#### 需求 A2:租户/店铺管理 API
|
||||
|
||||
**用户故事:** 作为系统管理员,我希望通过 API 查询租户列表和店铺列表,以便在创建管理员时选择所属租户和管辖门店。
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE Backend SHALL 实现 `GET /api/admin/tenants` 端点,返回所有活跃租户(含连接器名称)
|
||||
2. THE Backend SHALL 实现 `GET /api/admin/tenants/{tenant_id}/sites` 端点,返回指定租户下所有活跃店铺(含当前 site_code)
|
||||
3. THE Backend SHALL 实现 `DELETE /api/admin/tenant-admins/{id}` 端点,软删除管理员(`is_active=false`)
|
||||
4. THE Backend SHALL 实现 `PUT /api/admin/sites/{site_id}/site-code` 端点,设置/修改店铺简写ID
|
||||
5. THE Backend SHALL 实现 `GET /api/admin/sites/{site_id}/site-code-history` 端点,查看简写ID 变更历史
|
||||
6. THE Backend SHALL 修改 `POST /api/admin/tenant-admins` 端点,创建时 `tenant_id` 从 `biz.tenants` 选择,`managed_site_ids` 从 `biz.sites` 选择
|
||||
7. THE Backend SHALL 修改 `GET /api/admin/tenant-admins` 端点,默认只返回 `is_active=true`,增加 `include_inactive` 参数
|
||||
8. THE Backend SHALL 修改 `PATCH /api/admin/tenant-admins/{id}` 端点,支持修改 `username`(需校验全局唯一性,冲突返回 409)
|
||||
|
||||
#### 需求 A3:简写ID 管理逻辑
|
||||
|
||||
**用户故事:** 作为系统管理员,我希望在管理后台设置和修改店铺简写ID,并保留变更历史,以便保护已提交但未审核的用户申请。
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. WHEN 修改简写ID 时,THE Backend SHALL 在事务内执行:旧 code 标记 `is_current=false` + `retired_at=NOW()`,新 code 插入 `site_code_history`(`is_current=true`),更新 `biz.sites.site_code`
|
||||
2. THE Backend SHALL 校验新 code 格式(6 位,3+3 模式,统一大写存储)和全局唯一性(含 `biz.sites.site_code` + `biz.site_code_history.site_code`)
|
||||
3. WHEN 旧 code 有未审核申请引用(`auth.user_applications WHERE site_code = :old_code AND status = 'pending'`)时,THE Backend SHALL 保留历史记录不删除
|
||||
4. WHEN 旧 code 无任何申请引用时,THE Backend SHALL 从 `biz.site_code_history` 中删除该条记录
|
||||
|
||||
#### 需求 A4:租户管理员页面重构(admin-web)
|
||||
|
||||
**用户故事:** 作为系统管理员,我希望在 admin-web 中通过改进的界面管理租户管理员,支持 2 步创建流程、软删除和简写ID 管理。
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE admin-web SHALL 重构租户管理员列表页,新增「删除」操作按钮(二次确认 → 软删除),默认只显示活跃记录,可选「显示已禁用」开关
|
||||
2. THE admin-web SHALL 实现 2 步创建流程:第 1 步选择租户(下拉 `biz.tenants`)+ 输入账号信息 + 选择管辖门店(`biz.sites`);第 2 步可选设置简写ID
|
||||
3. THE admin-web SHALL 在编辑弹窗中增加「管理简写ID」区域,展示该租户下所有店铺及其当前 code,支持修改;编辑时所属租户(`tenant_id`)为只读不可修改;用户名(`username`)可修改(需校验唯一性)
|
||||
4. THE admin-web SHALL 新增简写ID 管理弹窗,展示变更历史,支持修改操作
|
||||
5. THE admin-web SHALL 新增 `src/api/registry.ts` 封装租户/店铺列表 API 调用
|
||||
|
||||
#### 需求 A5:ETL 店铺信息增量同步
|
||||
|
||||
**用户故事:** 作为系统管理员,我希望 ETL 完成后能自动同步店铺信息到业务库,以便 `biz.sites` 中的店铺名称和标签保持最新。
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE Backend SHALL 实现店铺同步逻辑:通过 FDW 读取 ETL 库 `dwd.dim_site`(`scd2_is_current=1`),对比 `biz.sites`,新增店铺 INSERT(`site_code` 留空),名称/标签变更 UPDATE
|
||||
2. THE Backend SHALL 不删除已有店铺记录(即使上游标记为关闭)
|
||||
3. THE Backend SHALL 支持手动触发同步(管理后台按钮或 API 端点)
|
||||
4. THE Backend SHALL 支持定时触发同步(随 ETL 日常调度,DWD 层完成后通过内部 API 触发)
|
||||
|
||||
#### 需求 A6:后端代码切换 — site_code 查询源
|
||||
|
||||
**用户故事:** 作为后端开发者,我希望所有读取 `auth.site_code_mapping` 的代码切换到 `biz.sites` + `biz.site_code_history`,以便完成数据迁移。
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE Backend SHALL 将所有读取 `auth.site_code_mapping` 的查询切换到 `biz.sites`
|
||||
2. THE 小程序端 SHALL 将用户申请时的 `site_code` 查询从 `auth.site_code_mapping` 切换到 `biz.sites` + `biz.site_code_history`
|
||||
3. THE Backend SHALL 确保切换后所有现有功能(用户申请、关联建议匹配等)正常工作
|
||||
|
||||
---
|
||||
|
||||
### 模块 B:P16 — 调度任务最小运行间隔机制
|
||||
|
||||
#### 需求 B1:scheduled_tasks 表扩展
|
||||
|
||||
**用户故事:** 作为管理员,我希望为每个调度任务设置最小运行间隔,使任务即使调度到期也不会在间隔内重复执行。
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE Backend SHALL 在 `scheduled_tasks` 表新增 `min_run_interval_value`(INTEGER DEFAULT 0)、`min_run_interval_unit`(VARCHAR(20) DEFAULT 'minutes')、`last_success_at`(TIMESTAMPTZ NULL)3 个字段
|
||||
2. THE Backend SHALL 确保 `min_run_interval_value = 0` 表示无限制,与现有行为完全一致(向后兼容)
|
||||
|
||||
#### 需求 B2:调度器核心逻辑 — 并发检查 + 间隔检查
|
||||
|
||||
**用户故事:** 作为管理员,我希望调度器在轮询时自动检查最小间隔和并发状态,避免任务重复执行或并发执行。
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE scheduler SHALL 在 `check_and_enqueue()` 中新增并发检查:若 `last_status = 'running'`,跳过本次入队,日志记录 `skipped_concurrent`
|
||||
2. THE scheduler SHALL 在 `check_and_enqueue()` 中新增间隔检查:若 `min_run_interval_value > 0` 且 `now() - last_run_at < min_interval_seconds`,跳过本次执行并推进 `next_run_at`,日志记录 `skipped_interval`
|
||||
3. WHEN `last_run_at IS NULL`(从未执行)时,THE scheduler SHALL 跳过间隔检查,正常执行
|
||||
4. THE scheduler SHALL 在任务成功完成时同时更新 `last_success_at = NOW()`,失败时不更新 `last_success_at`
|
||||
5. THE scheduler SHALL 实现 `_convert_interval_to_seconds(value, unit)` 辅助函数,支持 `minutes`/`hours`/`days` 单位
|
||||
|
||||
#### 需求 B3:API 扩展 — 创建/更新/手动执行
|
||||
|
||||
**用户故事:** 作为管理员,我希望通过 API 配置最小运行间隔,并在必要时强制执行任务。
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE Backend SHALL 在 `POST /api/schedules` 和 `PUT /api/schedules/{id}` 端点的请求体中新增 `min_run_interval_value`(int, default=0)和 `min_run_interval_unit`(str, default='minutes')
|
||||
2. THE Backend SHALL 在 `GET /api/schedules` 响应中新增 `min_run_interval_value`、`min_run_interval_unit`、`last_success_at` 字段
|
||||
3. THE Backend SHALL 在 `POST /api/schedules/{id}/run` 端点新增 `force: bool = False` 查询参数
|
||||
4. WHEN `force=true` 时,THE Backend SHALL 绕过最小间隔和并发检查,直接入队执行
|
||||
5. WHEN `force=false` 且间隔未到时,THE Backend SHALL 返回 409 Conflict,提示"最小运行间隔未到,距下次可执行还有 X 分钟"
|
||||
6. WHEN `force=false` 且任务正在运行时,THE Backend SHALL 返回 409 Conflict,提示"任务正在执行中"
|
||||
|
||||
#### 需求 B4:Admin Web 前端 — ScheduleTab 扩展
|
||||
|
||||
**用户故事:** 作为管理员,我希望在调度任务管理界面中看到和配置最小运行间隔。
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE admin-web SHALL 在创建/编辑调度任务表单中新增「最小运行间隔」行:`InputNumber`(数值)+ `Select`(单位:分钟/小时/天),数值为 0 时显示提示"无限制"
|
||||
2. THE admin-web SHALL 在任务列表表格中新增「最小间隔」列(显示如"10 天"、"无限制")和「上次成功」列(相对时间)
|
||||
3. THE admin-web SHALL 在手动执行确认框中新增「强制执行(忽略最小间隔)」勾选项,默认不勾选
|
||||
4. WHEN 勾选强制执行时,THE admin-web SHALL 调用 `POST /api/schedules/{id}/run?force=true`
|
||||
308
docs/specs/admin-web-enhancement/tasks.md
Normal file
308
docs/specs/admin-web-enhancement/tasks.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# 实施计划: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 调度任务间隔)。两者改动文件无重叠,可交替执行。整体顺序:DDL 迁移 → 后端 API → 前端页面 → 数据迁移/切换 → 收尾。
|
||||
|
||||
后端使用 Python(FastAPI + Pydantic),前端使用 TypeScript(React + Vite + Ant Design)。
|
||||
|
||||
## 任务
|
||||
|
||||
### 阶段一:DDL 迁移(模块 A + B)
|
||||
|
||||
- [x] 1. DDL 迁移 — 模块 A:注册体系四张新表
|
||||
- [x] 1.1 创建迁移脚本 `db/zqyy_app/migrations/2026-03-22__ns41_registry_tables.sql`
|
||||
- CREATE TABLE `biz.connectors`(id SERIAL PK, connector_key VARCHAR(50) UNIQUE, display_name, is_active, created_at)
|
||||
- CREATE TABLE `biz.tenants`(id SERIAL PK, connector_id FK, tenant_id BIGINT, tenant_name, is_active, created_at, updated_at, UNIQUE(connector_id, tenant_id))
|
||||
- CREATE TABLE `biz.sites`(id SERIAL PK, tenant_id FK, site_id BIGINT UNIQUE, site_name, site_code VARCHAR(6) UNIQUE, site_label, is_active, created_at, updated_at)
|
||||
- CREATE TABLE `biz.site_code_history`(id SERIAL PK, site_id BIGINT, site_code VARCHAR(6) UNIQUE, is_current BOOLEAN, created_at, retired_at)
|
||||
- INSERT 种子数据:connectors('feiqiu')、tenants(朗朗桌球)
|
||||
- INSERT 迁移数据:从 `auth.site_code_mapping` 迁移真实数据到 `biz.sites`,创建 `site_code_history` 记录
|
||||
- 编写回滚脚本(逆序 DROP TABLE)
|
||||
- _需求: A1.1, A1.2, A1.3, A1.4, A1.5_
|
||||
|
||||
- [x] 2. DDL 迁移 — 模块 B:scheduled_tasks 新增字段
|
||||
- [x] 2.1 创建迁移脚本 `db/zqyy_app/migrations/2026-03-22__p16_min_run_interval.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 注释
|
||||
- 编写回滚脚本(ALTER TABLE DROP COLUMN)
|
||||
- _需求: B1.1, B1.2_
|
||||
|
||||
### 阶段二:后端 API — 模块 A(注册体系 + 管理员重构)
|
||||
|
||||
- [x] 3. 后端 Schema — 注册体系
|
||||
- [x] 3.1 创建 `apps/backend/app/schemas/admin_registry.py`
|
||||
- 定义 `TenantItem`(id, tenant_id, tenant_name, connector_name, is_active)
|
||||
- 定义 `SiteItem`(id, site_id, site_name, site_code, site_label, is_active)
|
||||
- 定义 `UpdateSiteCodeRequest`(new_code: str)
|
||||
- 定义 `SiteCodeResult`(site_id, old_code, new_code, history_cleaned)
|
||||
- 定义 `SiteCodeHistoryItem`(id, site_code, is_current, created_at, retired_at)
|
||||
- _需求: A2.1, A2.2, A2.4, A2.5_
|
||||
|
||||
- [x] 3.2 修改 `apps/backend/app/schemas/admin_tenant_admins.py`
|
||||
- `TenantAdminListItem` 新增 `tenant_name` 字段
|
||||
- `TenantAdminCreateRequest` 添加字段说明注释(tenant_id 从 biz.tenants 选择)
|
||||
- _需求: A2.6_
|
||||
|
||||
- [x] 4. 后端路由 — 注册体系 API
|
||||
- [x] 4.1 创建 `apps/backend/app/routers/admin_registry.py`
|
||||
- `GET /api/admin/tenants` — 所有活跃租户列表(JOIN biz.connectors 获取 connector_name)
|
||||
- `GET /api/admin/tenants/{tenant_id}/sites` — 指定租户下所有活跃店铺
|
||||
- `PUT /api/admin/sites/{site_id}/site-code` — 设置/修改简写ID(事务内执行)
|
||||
- 校验格式(6 位,3+3,统一大写)
|
||||
- 校验全局唯一(biz.sites + biz.site_code_history)
|
||||
- 事务:旧 code 标记 retired → 新 code 插入 history → 更新 sites.site_code
|
||||
- 检查旧 code 是否有未审核申请引用,决定是否清理历史记录
|
||||
- `GET /api/admin/sites/{site_id}/site-code-history` — 简写ID 变更历史
|
||||
- _需求: A2.1, A2.2, A2.4, A2.5, A3.1, A3.2, A3.3, A3.4_
|
||||
|
||||
- [x] 4.2 在 `apps/backend/app/main.py` 中注册 admin_registry router
|
||||
- _需求: A2.1_
|
||||
|
||||
- [x] 5. 后端路由 — 管理员 CRUD 扩展
|
||||
- [x] 5.1 修改 `apps/backend/app/routers/admin_tenant_admins.py`
|
||||
- 新增 `DELETE /api/admin/tenant-admins/{id}` — 软删除(is_active=false),已禁用返回 409
|
||||
- 修改 `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)
|
||||
- 列表查询 JOIN biz.tenants 获取 tenant_name
|
||||
- _需求: A2.3, A2.6, A2.7, A2.8, A4.1_
|
||||
|
||||
- [x] 6. 编写属性测试 — 模块 A
|
||||
- [x] 6.1 创建 `tests/test_site_code_props.py`
|
||||
- **Property 1: 简写ID 全局唯一性** — 使用 Hypothesis 生成随机 code,验证已存在的 code 被拒绝
|
||||
- **Property 2: 简写ID 变更事务完整性** — 验证事务后 sites.site_code、history.is_current、history.retired_at 状态一致
|
||||
- **Property 3: 简写ID 格式校验** — 生成随机字符串,验证仅 6 位 3+3 格式通过
|
||||
- **验证: 需求 A3.1, A3.2**
|
||||
|
||||
- [x] 6.2 创建 `tests/test_tenant_admin_props.py`(扩展已有文件或新建)
|
||||
- **Property 4: 租户管理员软删除一致性** — 删除后默认列表不返回,include_inactive 返回
|
||||
- **验证: 需求 A2.3, A2.7**
|
||||
|
||||
- [x] 7. 检查点 — 模块 A 后端验证
|
||||
- 确保注册体系 API 和管理员 CRUD 扩展所有测试通过,ask the user if questions arise.
|
||||
|
||||
### 阶段三:后端 API — 模块 B(调度器间隔)
|
||||
|
||||
- [x] 8. 后端 Schema + 路由 — 调度器间隔
|
||||
- [x] 8.1 修改 `apps/backend/app/schemas/schedules.py`
|
||||
- `CreateScheduleRequest` 新增 `min_run_interval_value`(int, default=0)、`min_run_interval_unit`(str, default='minutes')
|
||||
- `UpdateScheduleRequest` 新增同上两个可选字段
|
||||
- `ScheduleResponse` 新增 `min_run_interval_value`、`min_run_interval_unit`、`last_success_at`
|
||||
- _需求: B3.1, B3.2_
|
||||
|
||||
- [x] 8.2 修改 `apps/backend/app/services/scheduler.py`
|
||||
- 新增 `_convert_interval_to_seconds(value, unit)` 辅助函数
|
||||
- 扩展 `check_and_enqueue()` SQL 查询:新增读取 min_run_interval_value, min_run_interval_unit, last_run_at, last_status
|
||||
- 新增并发检查:last_status == 'running' → 跳过,日志记录 skipped_concurrent
|
||||
- 新增间隔检查:min_run_interval_value > 0 且 now - last_run_at < min_interval → 跳过,推进 next_run_at
|
||||
- _需求: B2.1, B2.2, B2.4_
|
||||
|
||||
- [x] 8.3 修改任务完成回调(`scheduler.py` 或 `task_queue.py`)
|
||||
- 成功时:`last_status='completed'`, `last_success_at=NOW()`
|
||||
- 失败时:`last_status='failed'`(last_success_at 不变)
|
||||
- _需求: B2.3_
|
||||
|
||||
- [x] 8.4 修改 `apps/backend/app/routers/schedules.py`
|
||||
- 创建/更新端点支持新字段写入
|
||||
- 列表端点响应包含新字段
|
||||
- `POST /api/schedules/{id}/run` 新增 `force: bool = False` 查询参数
|
||||
- force=false 时检查并发和间隔,不满足返回 409
|
||||
- force=true 时绕过所有检查
|
||||
- _需求: B3.1, B3.2, B3.3, B3.4, B3.5_
|
||||
|
||||
- [x] 9. 编写属性测试 — 模块 B
|
||||
- [x] 9.1 创建 `tests/test_scheduler_interval_props.py`
|
||||
- **Property 7: 间隔转换正确性** — 生成随机 (value, unit),验证秒数计算正确
|
||||
- **Property 8: 调度器间隔跳过正确性** — 生成随机任务状态,验证跳过/执行决策
|
||||
- **Property 9: 调度器并发跳过正确性** — last_status='running' 时跳过
|
||||
- **Property 10: 强制执行绕过所有检查** — force=true 时无论状态都执行
|
||||
- **Property 11: last_success_at 仅成功时更新** — 成功更新,失败不变
|
||||
- **验证: 需求 B2.1, B2.2, B2.3, B2.4, B3.4**
|
||||
|
||||
- [x] 10. 检查点 — 模块 B 后端验证
|
||||
- 确保调度器间隔逻辑和 API 扩展所有测试通过,ask the user if questions arise.
|
||||
|
||||
### 阶段四:ETL 店铺同步(模块 A)
|
||||
|
||||
- [x] 11. 后端 — 店铺信息增量同步
|
||||
- [x] 11.1 实现同步逻辑(新建 service 或在 admin_registry 路由中实现)
|
||||
- 通过 FDW 读取 ETL 库 `dwd.dim_site`(`scd2_is_current=1`)
|
||||
- 对比 `biz.sites`:新增店铺 INSERT(site_code 留空,tenant_id 通过 dim_site.tenant_id 关联 biz.tenants),名称/标签变更 UPDATE
|
||||
- 不删除已有店铺记录
|
||||
- _需求: A5.1, A5.2_
|
||||
|
||||
- [x] 11.2 实现手动触发端点
|
||||
- `POST /api/admin/sites/sync` — 手动触发同步,返回同步结果(新增数/更新数)
|
||||
- _需求: A5.3_
|
||||
|
||||
- [x] 11.3 执行一次初始同步(数据迁移补数据)
|
||||
- 在 DDL 迁移(任务 1)完成后、代码切换(任务 15)之前,调用同步逻辑补充 `auth.site_code_mapping` 中没有但 `dwd.dim_site` 中有的店铺
|
||||
- 输出同步结果(新增数/更新数)供验证使用
|
||||
- _需求: A1b.1, A1b.2_
|
||||
|
||||
- [x] 11.4 预留定时触发入口(随 ETL DWD 完成后通过内部 API 触发)
|
||||
- _需求: A5.4_
|
||||
|
||||
### 阶段五:前端页面
|
||||
|
||||
- [x] 12. 前端 — 模块 A:租户管理员页面重构
|
||||
- [x] 12.1 创建 `apps/admin-web/src/api/registry.ts`
|
||||
- 封装 `GET /api/admin/tenants` 和 `GET /api/admin/tenants/{id}/sites` API 调用
|
||||
- 封装 `PUT /api/admin/sites/{site_id}/site-code` 和 `GET /api/admin/sites/{site_id}/site-code-history`
|
||||
- 封装 `POST /api/admin/sites/sync`(手动同步)
|
||||
- _需求: A4.5_
|
||||
|
||||
- [x] 12.2 修改 `apps/admin-web/src/api/tenantAdmins.ts`
|
||||
- 新增 `deleteTenantAdmin(id)` API 调用
|
||||
- 修改 `listTenantAdmins` 支持 `include_inactive` 参数
|
||||
- _需求: A4.1_
|
||||
|
||||
- [x] 12.3 重构 `apps/admin-web/src/pages/TenantAdmins/index.tsx`
|
||||
- 列表页新增「删除」操作按钮(Popconfirm 二次确认 → 调用 DELETE API)
|
||||
- 列表页新增「显示已禁用」Switch 开关
|
||||
- 列表页新增「简写ID」操作按钮(打开简写ID 管理弹窗)
|
||||
- 列表新增「租户」列(显示 tenant_name)
|
||||
- 编辑弹窗中 `username` 改为可编辑(需校验唯一性,409 时提示"用户名已存在");`tenant_id` 只读
|
||||
- _需求: A4.1, A4.3, A2.8_
|
||||
|
||||
- [x] 12.4 实现 2 步创建流程
|
||||
- 使用 Ant Design Steps 组件
|
||||
- 第 1 步:选择租户(Select,数据源 GET /api/admin/tenants)→ 输入用户名/密码/显示名称 → 选择管辖门店(Select multiple,数据源 GET /api/admin/tenants/{id}/sites)
|
||||
- 第 2 步:展示所选租户下所有店铺,可为每个店铺设置简写ID(可跳过)
|
||||
- _需求: A4.2_
|
||||
|
||||
- [x] 12.5 实现简写ID 管理弹窗
|
||||
- Modal 内嵌 Table:店铺名称、当前 ID、操作(修改)
|
||||
- 修改行:Input + 保存/取消按钮,格式校验(6 位 3+3)
|
||||
- 变更历史区域:展示 site_code_history 列表
|
||||
- _需求: A4.3, A4.4_
|
||||
|
||||
- [x] 13. 前端 — 模块 B:ScheduleTab 扩展
|
||||
- [x] 13.1 修改 `apps/admin-web/src/components/ScheduleTab.tsx`
|
||||
- 创建/编辑表单新增「最小运行间隔」行:InputNumber(数值)+ Select(单位:分钟/小时/天)
|
||||
- 数值为 0 时显示 placeholder "无限制"
|
||||
- 位置:在调度类型配置区域下方
|
||||
- _需求: B4.1_
|
||||
|
||||
- [x] 13.2 修改列表表格
|
||||
- 新增「最小间隔」列:显示格式如"10 天"、"1 小时"、"无限制"(value=0 时)
|
||||
- 新增「上次成功」列:显示 last_success_at 的相对时间(dayjs fromNow)
|
||||
- _需求: B4.2_
|
||||
|
||||
- [x] 13.3 修改手动执行确认框
|
||||
- 新增 Checkbox「强制执行(忽略最小间隔)」,默认不勾选
|
||||
- 勾选后调用 `POST /api/schedules/{id}/run?force=true`
|
||||
- 不勾选时调用 `POST /api/schedules/{id}/run`,409 时展示错误提示
|
||||
- _需求: B4.3, B4.4_
|
||||
|
||||
- [x] 14. 检查点 — 前端页面验证
|
||||
- 确保所有前端组件渲染正常,API 调用层工作正确,ask the user if questions arise.
|
||||
|
||||
### 阶段六:数据迁移与代码切换
|
||||
|
||||
- [x] 15. site_code 查询源切换
|
||||
- [x] 15.1 修改 `apps/backend/app/routers/tenant_users.py`
|
||||
- `match-suggestions` 中的 site_code 查询从 `auth.site_code_mapping` 切换到 `biz.sites` + `biz.site_code_history`
|
||||
- _需求: A6.1_
|
||||
|
||||
- [x] 15.2 搜索并修改所有其他引用 `auth.site_code_mapping` 的代码
|
||||
- 小程序端用户申请时的 site_code 验证
|
||||
- 其他后端路由中的 site_code 查询
|
||||
- _需求: A6.1, A6.2_
|
||||
|
||||
- [x] 15.3 验证切换后功能正常
|
||||
- 用户申请流程中 site_code 查询正确
|
||||
- 关联建议匹配正确
|
||||
- _需求: A6.3_
|
||||
|
||||
- [x] 16. 废弃原表
|
||||
- [x] 16.1 验证 `biz.sites` 数据与 `auth.site_code_mapping` 一致
|
||||
- 编写验证 SQL 对比两表数据
|
||||
- _需求: A1.5_
|
||||
|
||||
- [x] 16.2 重命名原表为 `auth._archived_site_code_mapping`
|
||||
- _需求: A1.6_
|
||||
|
||||
### 阶段七:收尾
|
||||
|
||||
- [x] 17. 数据库变更审计与 DDL 合并
|
||||
- [x] 17.1 审计本次实现中对数据库的所有改动
|
||||
- 检查新建表(biz.connectors/tenants/sites/site_code_history)、新增字段(scheduled_tasks 三字段)、废弃表(auth.site_code_mapping)
|
||||
- [x] 17.2 执行两个迁移脚本到测试库(`test_zqyy_app`)
|
||||
- 验证新表和新字段已正确创建(使用 BD 手册中的验证 SQL)
|
||||
- [x] 17.3 合并到主 DDL 基线文件
|
||||
- 模块 A 新表 → `docs/database/ddl/zqyy_app__biz.sql`
|
||||
- 模块 B 新字段 → `docs/database/ddl/zqyy_app__public.sql`
|
||||
- [x] 17.4 验证回滚脚本可执行(任务 1、2 中已编写)
|
||||
|
||||
- [x] 18. BD 手册更新
|
||||
- [x] 18.1 创建 `docs/database/BD_Manual_biz_registry_tables.md`
|
||||
- 覆盖 biz.connectors、biz.tenants、biz.sites、biz.site_code_history 四张表
|
||||
- 包含:字段明细、约束与索引、验证 SQL(≥3 条)、回滚策略
|
||||
- _规范: db-docs.md_
|
||||
- [x] 18.2 更新 `docs/database/BD_Manual_tenant_admin_tables.md`
|
||||
- 补充软删除逻辑说明、tenant_id 从 biz.tenants 选择的变更
|
||||
- [x] 18.3 创建/更新 `docs/database/BD_Manual_scheduled_tasks.md`
|
||||
- 新增 min_run_interval_value、min_run_interval_unit、last_success_at 字段说明
|
||||
- 包含:字段明细、约束、验证 SQL、回滚策略
|
||||
|
||||
- [x] 19. 前后端联调与集成验证
|
||||
- [x] 19.1 启动后端服务,使用测试库验证各端点完整请求-响应链路
|
||||
- 验证注册体系 API(tenants/sites/site-code)JSON 响应结构与 Schema 定义一致
|
||||
- 验证调度器 API(schedules)新增字段和 force 参数正常工作
|
||||
- 验证权限校验在真实请求中生效
|
||||
- [x] 19.2 前端联调验证
|
||||
- 确认租户管理员页面能正确调用新增 API 并渲染数据(2 步创建、删除、简写ID 管理)
|
||||
- 确认 ScheduleTab 扩展字段正确展示和提交
|
||||
- 验证空数据/降级场景下前端不崩溃
|
||||
|
||||
- [x] 20. 文档同步更新
|
||||
- [x] 20.1 更新后端 API 参考文档
|
||||
- 在 `apps/backend/docs/API-REFERENCE.md` 新增 admin_registry 路由模块文档
|
||||
- 更新 schedules 路由模块文档(新增字段和 force 参数)
|
||||
- 更新 `apps/backend/README.md` 路由模块摘要
|
||||
- [x] 20.2 更新 admin-web README
|
||||
- 在 `apps/admin-web/README.md` 更新页面说明(租户管理员重构、ScheduleTab 扩展)
|
||||
- [x] 20.3 更新文档地图
|
||||
- 在 `docs/DOCUMENTATION-MAP.md` 新增本次模块条目(BD 手册、Spec)
|
||||
- _规范: doc-map.md_
|
||||
|
||||
- [x] 21. 最终检查点 — 全量验证
|
||||
- 运行 Monorepo 属性测试:`cd C:\NeoZQYY && pytest tests/ -v`
|
||||
- 运行后端单元测试:`cd apps/backend && pytest tests/ -v`
|
||||
- 确保所有属性测试(Property 1-11)和单元测试全部通过
|
||||
- 确保 DDL 迁移已合并到主基线
|
||||
- 确保 BD 手册已同步更新
|
||||
- 确保 API 文档、后端 README、admin-web README、文档地图均已更新
|
||||
- 确保前端页面连接真实后端运行正常(租户管理员页面 + ScheduleTab)
|
||||
- 确保 `auth.site_code_mapping` 已废弃重命名
|
||||
- ask the user if questions arise.
|
||||
|
||||
- [x] 22. 服务清理
|
||||
- [x] 22.1 关闭浏览器、停止后端和前端服务、清理资源
|
||||
- 停止 uvicorn 后端进程(controlPwshProcess stop)
|
||||
- 停止前端开发服务器(controlPwshProcess stop)
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选(属性测试),可跳过以加速 MVP
|
||||
- 每个任务引用了具体的需求编号以确保可追溯性(A1-A6 对应 NS4.1,B1-B4 对应 P16)
|
||||
- 属性测试验证 11 个正确性属性(Property 1-11),单元测试验证具体边界条件
|
||||
- 检查点任务确保增量验证,避免问题累积(任务 7、10、14、21)
|
||||
- 模块 A 和模块 B 改动文件无重叠,可交替执行
|
||||
- 后端使用 Python(FastAPI + Pydantic + Hypothesis),前端使用 TypeScript(React + Vite + Ant Design)
|
||||
- 数据迁移采用渐进策略:新建表 → 迁移数据 → 切换代码 → 验证 → 废弃原表
|
||||
- 收尾阶段遵循 `spec-closing-checklist.md`(全栈类 Spec,步骤 1-6 全覆盖):
|
||||
- 步骤 1(最终测试)→ 任务 21
|
||||
- 步骤 2(前后端联调)→ 任务 19
|
||||
- 步骤 3(DDL 合并)→ 任务 17
|
||||
- 步骤 4(BD 手册)→ 任务 18
|
||||
- 步骤 5(文档同步)→ 任务 20
|
||||
- 步骤 6(服务清理)→ 任务 22
|
||||
Reference in New Issue
Block a user