# 设计文档 — 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` | 新建/修改 |
