# 设计文档：Web 管理后台（admin-web-console）

## 概述

将现有 PySide6 桌面 GUI 替换为 BS 架构的 Web 管理后台。系统分为两部分：

- **后端**：在现有 `apps/backend/` FastAPI 骨架上扩展，新增 ETL 管理相关的 RESTful API 和 WebSocket 端点
- **前端**：在 `apps/admin-web/` 下使用 React + Vite + Ant Design 构建 SPA 应用

核心设计原则：
1. 后端通过子进程调用现有 ETL_CLI，不重写 ETL 逻辑
2. 调度任务从本地 JSON 迁移至 PostgreSQL（`zqyy_app` 库）
3. 前后端通过 JSON API 通信，实时日志通过 WebSocket 推送
4. 数据库查询限制为只读，防止误操作
5. **多门店隔离**：通过 `site_id` 贯穿全链路，Operator 登录后绑定门店，所有 API 请求自动携带 site_id
6. **执行流程（Flow）分离**：完整保留现有 7 种 Flow 和 4 种处理模式，前端按 Flow 动态展示可选层和任务

## 架构

```mermaid
graph TB
    subgraph "前端 (apps/admin-web/)"
        FE[React SPA<br/>Vite + Ant Design]
    end

    subgraph "后端 (apps/backend/)"
        API[FastAPI 应用]
        AUTH[JWT 认证中间件]
        WS[WebSocket 端点<br/>实时日志推送]
        EXEC[TaskExecutor<br/>子进程管理]
        QUEUE[TaskQueue<br/>队列管理]
        SCHED[Scheduler<br/>定时调度]
    end

    subgraph "数据层"
        PG_APP[(zqyy_app<br/>用户/队列/调度/历史)]
        PG_ETL[(etl_feiqiu<br/>ODS/DWD/DWS)]
        ENV[.env 文件]
    end

    subgraph "ETL Connector"
        CLI[ETL CLI<br/>子进程]
    end

    FE -->|HTTP/WS| API
    API --> AUTH
    API --> WS
    API --> EXEC
    API --> QUEUE
    API --> SCHED
    EXEC -->|subprocess| CLI
    CLI --> PG_ETL
    QUEUE --> PG_APP
    SCHED --> PG_APP
    SCHED -->|到期触发| QUEUE
    API -->|只读查询| PG_ETL
    API -->|读写| PG_APP
    API -->|读写| ENV
```

### 请求流程

1. 前端发起 HTTP 请求 → JWT 中间件验证令牌（提取 site_id）→ 路由处理
2. 任务执行：API 接收 TaskConfig（含 site_id）→ TaskQueue 入队 → TaskExecutor 取出执行 → 子进程调用 ETL_CLI（`--store-id {site_id}`）→ stdout/stderr 通过 WebSocket 推送
3. 调度触发：Scheduler 定时检查到期任务 → 自动入队 → 同上执行流程

### 多门店隔离设计

现有系统通过 `site_id`（即 `store_id`，CLI 参数名为 `--store-id`）实现多门店数据隔离：
- ETL 数据库层：所有业务表包含 `site_id` 字段，`app` schema 通过 RLS 按 `current_setting('app.current_site_id')` 自动过滤
- ETL CLI 层：通过 `--store-id` 参数指定门店

Web 管理后台的隔离策略：

1. **用户-门店绑定**：`admin_users` 表增加 `site_id` 字段，每个 Operator 绑定一个门店
2. **JWT 令牌携带 site_id**：登录时将 `site_id` 写入 JWT payload，后续请求自动提取
3. **API 层自动注入**：所有涉及 ETL 操作的 API，从 JWT 中提取 `site_id`，自动注入到 TaskConfig 和数据库查询中
4. **数据库查看器隔离**：查询 ETL 数据库时，设置 `SET LOCAL app.current_site_id = '{site_id}'`，利用 RLS 自动过滤
5. **队列和调度隔离**：`task_queue` 和 `scheduled_tasks` 表增加 `site_id` 字段，查询时按 site_id 过滤

### 执行流程（Flow）配置设计

> 术语说明：**Connector**（数据源连接器）指对接的上游 SaaS 平台（如飞球），对应 `apps/etl/pipelines/{connector}/`；**Flow**（执行流程）指 ETL 任务的处理链路，描述数据从哪一层流到哪一层。CLI 参数 `--pipeline` 实际传递的是 Flow ID。

完整保留现有 7 种 Flow，前端根据选择动态展示：

| Flow ID | 显示名称 | 包含层 |
|---------|---------|--------|
| `api_ods` | API → ODS | ODS |
| `api_ods_dwd` | API → ODS → DWD | ODS, DWD |
| `api_full` | API → ODS → DWD → DWS汇总 → DWS指数 | ODS, DWD, DWS, INDEX |
| `ods_dwd` | ODS → DWD | DWD |
| `dwd_dws` | DWD → DWS汇总 | DWS |
| `dwd_dws_index` | DWD → DWS汇总 → DWS指数 | DWS, INDEX |
| `dwd_index` | DWD → DWS指数 | INDEX |

4 种处理模式：
- `increment_only`：仅增量处理
- `verify_only`：校验并修复（可选"校验前从 API 获取"）
- `increment_verify`：增量 + 校验并修复
- `full_window`：用 API 返回数据的实际时间范围处理全部层，无需校验

时间窗口模式：
- `lookback`：回溯 + 冗余（lookback_hours + overlap_seconds）
- `custom`：自定义时间范围（window_start + window_end）
- 窗口切分：不切分 / 按天（1天/10天/30天）

前端交互逻辑：
1. Operator 选择 Flow → 前端根据 Flow 包含的层，动态显示/隐藏 ODS 任务选择、DWD 表选择、DWS 任务选择
2. Operator 选择处理模式 → 前端根据模式显示/隐藏校验相关选项
3. Operator 选择时间窗口模式 → 前端切换回溯配置或自定义日期选择器

## 组件与接口

### 后端 API 路由

```
apps/backend/app/
├── main.py                    # FastAPI 入口（已有，扩展）
├── config.py                  # 配置加载（已有）
├── database.py                # 数据库连接（已有，扩展）
├── auth/
│   ├── __init__.py
│   ├── jwt.py                 # JWT 令牌生成/验证
│   └── dependencies.py        # FastAPI 依赖注入（当前用户）
├── routers/
│   ├── auth.py                # POST /api/auth/login, POST /api/auth/refresh
│   ├── tasks.py               # 任务注册表 & 配置 API
│   ├── execution.py           # 任务执行 & 队列 API
│   ├── schedules.py           # 调度任务 CRUD API
│   ├── env_config.py          # 环境配置 API
│   ├── db_viewer.py           # 数据库查看器 API
│   └── etl_status.py          # ETL 状态 API
├── schemas/
│   ├── auth.py                # 认证相关 Pydantic 模型
│   ├── tasks.py               # 任务配置 Pydantic 模型
│   ├── execution.py           # 执行记录 Pydantic 模型
│   ├── schedules.py           # 调度配置 Pydantic 模型
│   └── db_viewer.py           # 数据库查看器 Pydantic 模型
├── services/
│   ├── task_executor.py       # 子进程管理，执行 ETL_CLI
│   ├── task_queue.py          # 任务队列管理
│   ├── scheduler.py           # 定时调度器
│   └── cli_builder.py         # CLI 命令构建（从 gui/utils/cli_builder.py 迁移）
├── middleware/
│   └── auth.py                # JWT 认证中间件
└── ws/
    └── logs.py                # WebSocket 日志推送端点
```

### 前端结构

```
apps/admin-web/
├── package.json
├── vite.config.ts
├── tsconfig.json
├── index.html
├── src/
│   ├── main.tsx               # 入口
│   ├── App.tsx                # 根组件（Layout + Router）
│   ├── api/                   # API 客户端
│   │   ├── client.ts          # axios 实例（JWT 拦截器）
│   │   ├── auth.ts
│   │   ├── tasks.ts
│   │   ├── execution.ts
│   │   ├── schedules.ts
│   │   ├── envConfig.ts
│   │   ├── dbViewer.ts
│   │   └── etlStatus.ts
│   ├── pages/
│   │   ├── Login.tsx
│   │   ├── TaskConfig.tsx     # 任务配置
│   │   ├── TaskManager.tsx    # 任务管理（队列 + 调度）
│   │   ├── EnvConfig.tsx      # 环境配置
│   │   ├── DBViewer.tsx       # 数据库查看器
│   │   ├── ETLStatus.tsx      # ETL 状态
│   │   └── LogViewer.tsx      # 日志查看器
│   ├── components/            # 可复用组件
│   │   ├── TaskSelector.tsx   # 任务选择器（按业务域分组）
│   │   ├── DwdTableSelector.tsx
│   │   ├── TimeWindowForm.tsx
│   │   └── LogStream.tsx      # WebSocket 日志流组件
│   ├── hooks/
│   │   ├── useAuth.ts
│   │   └── useWebSocket.ts
│   ├── store/                 # 状态管理（React Context 或 Zustand）
│   │   └── authStore.ts
│   └── types/                 # TypeScript 类型定义
│       └── index.ts
```

### 核心 API 端点

| 方法 | 路径 | 说明 | 需求 |
|------|------|------|------|
| POST | `/api/auth/login` | 用户登录，返回 JWT | 1 |
| POST | `/api/auth/refresh` | 刷新访问令牌 | 1 |
| GET | `/api/tasks/registry` | 获取任务注册表（按业务域分组） | 2 |
| GET | `/api/tasks/dwd-tables` | 获取 DWD 表定义（按业务域分组） | 2 |
| POST | `/api/tasks/validate` | 验证 TaskConfig | 2, 11 |
| GET | `/api/tasks/flows` | 获取执行流程列表（7 种 Flow + 4 种处理模式） | 2 |
| POST | `/api/execution/run` | 提交任务执行 | 3 |
| GET | `/api/execution/queue` | 获取当前队列 | 4 |
| POST | `/api/execution/queue` | 添加任务到队列 | 4 |
| PUT | `/api/execution/queue/reorder` | 调整队列顺序 | 4 |
| DELETE | `/api/execution/queue/{id}` | 从队列删除任务 | 4 |
| POST | `/api/execution/{id}/cancel` | 取消执行中的任务 | 3 |
| GET | `/api/execution/history` | 获取执行历史 | 4 |
| GET | `/api/execution/{id}/logs` | 获取历史任务日志 | 9 |
| GET | `/api/schedules` | 获取调度任务列表 | 5 |
| POST | `/api/schedules` | 创建调度任务 | 5 |
| PUT | `/api/schedules/{id}` | 更新调度任务 | 5 |
| DELETE | `/api/schedules/{id}` | 删除调度任务 | 5 |
| PATCH | `/api/schedules/{id}/toggle` | 启用/禁用调度任务 | 5 |
| GET | `/api/env-config` | 获取环境配置 | 6 |
| PUT | `/api/env-config` | 更新环境配置 | 6 |
| GET | `/api/env-config/export` | 导出配置（去敏感值） | 6 |
| GET | `/api/db/schemas` | 获取 Schema 列表 | 7 |
| GET | `/api/db/schemas/{name}/tables` | 获取表列表和行数 | 7 |
| GET | `/api/db/tables/{schema}/{table}/columns` | 获取表列定义 | 7 |
| POST | `/api/db/query` | 执行只读 SQL 查询 | 7 |
| GET | `/api/etl-status/cursors` | 获取 ETL 游标状态 | 8 |
| GET | `/api/etl-status/recent-runs` | 获取最近执行记录 | 8 |
| WS | `/ws/logs/{execution_id}` | 实时日志 WebSocket | 9 |

### 关键服务组件

#### TaskExecutor（任务执行器）

```python
class TaskExecutor:
    """管理 ETL_CLI 子进程的生命周期"""

    async def execute(self, config: TaskConfig, execution_id: str) -> None:
        """
        以子进程方式调用 ETL_CLI。
        - 使用 asyncio.create_subprocess_exec 启动子进程
        - 逐行读取 stdout/stderr，广播到 WebSocket 连接
        - 记录退出码和执行时长到数据库
        """
        ...

    async def cancel(self, execution_id: str) -> bool:
        """向子进程发送 SIGTERM，等待退出后标记为已取消"""
        ...
```

#### TaskQueue（任务队列）

```python
class TaskQueue:
    """基于 PostgreSQL 的任务队列"""

    async def enqueue(self, config: TaskConfig) -> str:
        """入队，返回队列任务 ID"""
        ...

    async def dequeue(self) -> Optional[QueuedTask]:
        """取出队首待执行任务"""
        ...

    async def reorder(self, task_id: str, new_position: int) -> None:
        """调整任务在队列中的位置"""
        ...

    async def process_loop(self) -> None:
        """后台循环：队列非空且无运行中任务时，自动取出执行"""
        ...
```

#### Scheduler（调度器）

```python
class Scheduler:
    """基于 PostgreSQL 的定时调度器"""

    async def check_and_enqueue(self) -> None:
        """检查到期的调度任务，将其 TaskConfig 加入队列"""
        ...

    async def start(self) -> None:
        """启动后台定时检查循环（每 30 秒检查一次）"""
        ...
```

#### CLIBuilder（CLI 命令构建器）

从现有 `gui/utils/cli_builder.py` 迁移，核心逻辑不变：

```python
class CLIBuilder:
    """将 TaskConfig 转换为 ETL_CLI 命令行参数列表"""

    def build_command(self, config: TaskConfig, etl_project_path: str) -> list[str]:
        """
        构建完整命令：
        [python, -m, cli.main, --pipeline, {flow_id}, --tasks, ..., --store-id, {site_id}, ...]
        注意：CLI 参数名 --pipeline 传递的是 Flow ID
        """
        ...
```

## 数据模型

### 数据库表（zqyy_app）

#### admin_users 表

```sql
CREATE TABLE admin_users (
    id          SERIAL PRIMARY KEY,
    username    VARCHAR(64) UNIQUE NOT NULL,
    password_hash VARCHAR(256) NOT NULL,
    display_name VARCHAR(128),
    site_id     BIGINT NOT NULL,          -- 绑定的门店 ID
    is_active   BOOLEAN DEFAULT TRUE,
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    updated_at  TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_admin_users_site ON admin_users(site_id);
```

#### task_queue 表

```sql
CREATE TABLE task_queue (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    site_id     BIGINT NOT NULL,          -- 门店隔离
    config      JSONB NOT NULL,           -- 序列化的 TaskConfig
    status      VARCHAR(20) NOT NULL DEFAULT 'pending',
                -- pending / running / success / failed / cancelled
    position    INTEGER NOT NULL DEFAULT 0,
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    started_at  TIMESTAMPTZ,
    finished_at TIMESTAMPTZ,
    exit_code   INTEGER,
    error_message TEXT
);

CREATE INDEX idx_task_queue_status ON task_queue(status);
CREATE INDEX idx_task_queue_site_position ON task_queue(site_id, position) WHERE status = 'pending';
```

#### task_execution_log 表

```sql
CREATE TABLE task_execution_log (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    queue_id    UUID REFERENCES task_queue(id),
    site_id     BIGINT NOT NULL,          -- 门店隔离
    task_codes  TEXT[] NOT NULL,
    status      VARCHAR(20) NOT NULL,
    started_at  TIMESTAMPTZ NOT NULL,
    finished_at TIMESTAMPTZ,
    exit_code   INTEGER,
    duration_ms INTEGER,
    command     TEXT,                      -- 实际执行的 CLI 命令
    output_log  TEXT,                      -- stdout 完整日志
    error_log   TEXT,                      -- stderr 日志
    summary     JSONB,                     -- 执行摘要
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_execution_log_site_started ON task_execution_log(site_id, started_at DESC);
```

#### scheduled_tasks 表

```sql
CREATE TABLE scheduled_tasks (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    site_id     BIGINT NOT NULL,          -- 门店隔离
    name        VARCHAR(256) NOT NULL,
    task_codes  TEXT[] NOT NULL,
    task_config JSONB NOT NULL,            -- 序列化的 TaskConfig
    schedule_config JSONB NOT NULL,        -- 序列化的 ScheduleConfig
    enabled     BOOLEAN DEFAULT TRUE,
    last_run_at TIMESTAMPTZ,
    next_run_at TIMESTAMPTZ,
    run_count   INTEGER DEFAULT 0,
    last_status VARCHAR(20),
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    updated_at  TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_scheduled_tasks_site ON scheduled_tasks(site_id);
CREATE INDEX idx_scheduled_tasks_next_run ON scheduled_tasks(next_run_at)
    WHERE enabled = TRUE;
```

### Pydantic 模型（后端 schemas）

```python
# schemas/tasks.py
class TaskConfigSchema(BaseModel):
    """任务配置 — 前后端传输格式"""
    tasks: list[str]
    pipeline: str = "api_ods_dwd"         # 执行流程 Flow ID（7 种之一），对应 CLI --pipeline 参数
    processing_mode: str = "increment_only"  # 处理模式（3 种之一）
    pipeline_flow: str = "FULL"           # 传统模式兼容（已弃用，保留向后兼容）
    dry_run: bool = False
    window_mode: str = "lookback"         # lookback / custom
    window_start: str | None = None
    window_end: str | None = None
    window_split: str | None = None       # none / day
    window_split_days: int | None = None  # 1 / 10 / 30
    lookback_hours: int = 24
    overlap_seconds: int = 600
    fetch_before_verify: bool = False
    skip_ods_when_fetch_before_verify: bool = False
    ods_use_local_json: bool = False
    store_id: int | None = None           # 门店 ID（由后端从 JWT 注入，前端不传）
    dwd_only_tables: list[str] | None = None  # DWD 表级选择
    extra_args: dict[str, Any] = {}

    @model_validator(mode="after")
    def validate_window(self) -> "TaskConfigSchema":
        """验证时间窗口：结束日期不早于开始日期"""
        if self.window_start and self.window_end:
            if self.window_end < self.window_start:
                raise ValueError("window_end 不能早于 window_start")
        return self

class PipelineDefinition(BaseModel):
    """执行流程（Flow）定义 — 注意：字段名保留 pipeline 以兼容 CLI 参数"""
    id: str
    name: str
    layers: list[str]

class ProcessingModeDefinition(BaseModel):
    """处理模式定义"""
    id: str
    name: str
    description: str

# schemas/schedules.py
class ScheduleConfigSchema(BaseModel):
    """调度配置"""
    schedule_type: Literal["once", "interval", "daily", "weekly", "cron"]
    interval_value: int = 1
    interval_unit: Literal["minutes", "hours", "days"] = "hours"
    daily_time: str = "04:00"
    weekly_days: list[int] = [1]
    weekly_time: str = "04:00"
    cron_expression: str = "0 4 * * *"
    enabled: bool = True
    start_date: str | None = None
    end_date: str | None = None
```

### TypeScript 类型（前端）

```typescript
// types/index.ts
interface TaskConfig {
  tasks: string[];
  pipeline: string;                    // 执行流程 Flow ID（对应 CLI --pipeline）
  processing_mode: string;             // 处理模式
  pipeline_flow: string;               // 传统模式兼容（已弃用）
  dry_run: boolean;
  window_mode: string;                 // lookback / custom
  window_start: string | null;
  window_end: string | null;
  window_split: string | null;         // none / day
  window_split_days: number | null;    // 1 / 10 / 30
  lookback_hours: number;
  overlap_seconds: number;
  fetch_before_verify: boolean;
  skip_ods_when_fetch_before_verify: boolean;
  ods_use_local_json: boolean;
  store_id: number | null;             // 由后端注入
  dwd_only_tables: string[] | null;    // DWD 表级选择
  extra_args: Record<string, unknown>;
}

interface PipelineDefinition {
  id: string;                          // Flow ID（字段名保留 pipeline 兼容 CLI）
  name: string;
  layers: string[];                    // 包含的层：ODS / DWD / DWS / INDEX
}

interface ProcessingModeDefinition {
  id: string;
  name: string;
  description: string;
}

interface TaskDefinition {
  code: string;
  name: string;
  description: string;
  domain: string;
  requires_window: boolean;
  is_ods: boolean;
  is_dimension: boolean;
  default_enabled: boolean;
}

interface ScheduleConfig {
  schedule_type: "once" | "interval" | "daily" | "weekly" | "cron";
  interval_value: number;
  interval_unit: "minutes" | "hours" | "days";
  daily_time: string;
  weekly_days: number[];
  weekly_time: string;
  cron_expression: string;
  enabled: boolean;
  start_date: string | null;
  end_date: string | null;
}

interface QueuedTask {
  id: string;
  site_id: number;
  config: TaskConfig;
  status: "pending" | "running" | "success" | "failed" | "cancelled";
  position: number;
  created_at: string;
  started_at: string | null;
  finished_at: string | null;
  exit_code: number | null;
  error_message: string | null;
}

interface ExecutionLog {
  id: string;
  site_id: number;
  task_codes: string[];
  status: string;
  started_at: string;
  finished_at: string | null;
  exit_code: number | null;
  duration_ms: number | null;
  command: string;
  summary: Record<string, unknown> | null;
}
```

## 正确性属性

*属性（Property）是指在系统所有有效执行中都应成立的特征或行为——本质上是对系统行为的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。*

### Property 1: TaskConfig 序列化往返一致性

*For any* 有效的 TaskConfigSchema 对象，将其序列化为 JSON 字符串后再反序列化，应产生与原始对象等价的结果。

**Validates: Requirements 11.1, 11.2, 11.3**

### Property 2: 无效凭据始终被拒绝

*For any* 不存在于 admin_users 表中的用户名/密码组合，登录接口应返回 401 状态码。

**Validates: Requirements 1.2**

### Property 3: 有效 JWT 令牌授权访问

*For any* 由系统签发且未过期的 JWT 令牌，携带该令牌访问受保护端点应返回非 401 状态码。

**Validates: Requirements 1.3**

### Property 4: 任务注册表按业务域正确分组

*For any* Task_Registry 中的任务集合，API 返回的分组结果中，每个任务应出现在且仅出现在其所属业务域的分组中。

**Validates: Requirements 2.1**

### Property 5: Flow 层级过滤正确性

*For any* Flow 选择和任务列表，过滤后的结果应只包含与所选 Flow 包含的层兼容的任务，且不遗漏任何兼容任务。

**Validates: Requirements 2.2**

### Property 6: 时间窗口验证

*For any* 两个日期字符串，当 window_end 早于 window_start 时，TaskConfigSchema 验证应失败；当 window_end 不早于 window_start 时，验证应通过。

**Validates: Requirements 2.3**

### Property 7: TaskConfig 到 CLI 命令转换完整性

*For any* 有效的 TaskConfigSchema，CLIBuilder 生成的命令参数列表应包含 TaskConfig 中所有非空字段对应的 CLI 参数。

**Validates: Requirements 2.5, 2.6**

### Property 8: 队列 CRUD 不变量

*For any* 任务队列状态，入队一个任务后队列长度增加 1 且新任务状态为 pending；删除一个 pending 任务后队列长度减少 1 且该任务不再出现在队列中。

**Validates: Requirements 4.1, 4.4**

### Property 9: 队列出队顺序

*For any* 包含多个 pending 任务的队列，dequeue 操作应返回 position 值最小的任务。

**Validates: Requirements 4.2**

### Property 10: 队列重排一致性

*For any* 队列和重排操作（将任务移动到新位置），重排后队列中任务的相对顺序应与请求一致。

**Validates: Requirements 4.3**

### Property 11: 执行历史排序与限制

*For any* 执行历史记录集合，API 返回的结果应按 started_at 降序排列，且结果数量不超过请求的 limit 值。

**Validates: Requirements 4.5, 8.2**

### Property 12: 调度任务 CRUD 往返

*For any* 有效的 ScheduleConfigSchema，创建调度任务后再查询该任务，返回的调度配置应与创建时提交的配置等价。

**Validates: Requirements 5.1, 5.4**

### Property 13: 到期调度任务自动入队

*For any* enabled 为 true 且 next_run_at 早于当前时间的调度任务，check_and_enqueue 执行后该任务的 TaskConfig 应出现在执行队列中。

**Validates: Requirements 5.2**

### Property 14: 调度任务启用/禁用状态

*For any* 调度任务，禁用后 next_run_at 应为 NULL；重新启用后 next_run_at 应被重新计算为非 NULL 值（对于非一次性调度）。

**Validates: Requirements 5.3**

### Property 15: .env 解析与敏感值掩码

*For any* 包含敏感键（PASSWORD、TOKEN、SECRET、DSN）的 .env 文件内容，API 返回的键值对列表中这些键的值应被掩码替换，不包含原始敏感值。

**Validates: Requirements 6.1, 6.3**

### Property 16: .env 写入往返一致性

*For any* 有效的键值对集合（不含注释和空行），写入 .env 文件后再读取解析，应得到与原始集合等价的键值对。

**Validates: Requirements 6.2**

### Property 17: SQL 写操作拦截

*For any* 包含 INSERT、UPDATE、DELETE、DROP 或 TRUNCATE 关键词（不区分大小写）的 SQL 语句，数据库查看器 API 应拒绝执行并返回错误。

**Validates: Requirements 7.5**

### Property 18: SQL 查询结果行数限制

*For any* SQL 查询执行结果，返回的行数应不超过 1000。

**Validates: Requirements 7.4**

### Property 19: 日志过滤正确性

*For any* 日志行集合和过滤关键词，过滤后的结果应只包含含有该关键词的日志行，且不遗漏任何匹配行。

**Validates: Requirements 9.2**

### Property 20: 门店隔离 — 队列和调度数据不跨站泄露

*For any* 两个不同 site_id 的 Operator，一个 Operator 查询队列/调度/执行历史时，结果中不应包含另一个 site_id 的数据。

**Validates: Requirements 1.3（隐含的多门店隔离要求）**

### Property 21: Flow 层级与任务兼容性

*For any* Flow 类型和任务定义，当 Flow 包含的层不包含该任务所属层时，该任务不应出现在可选列表中；当 Flow 包含该任务所属层时，该任务应出现在可选列表中。

**Validates: Requirements 2.2**

## 错误处理

### 认证错误
- 无效凭据：返回 `401 Unauthorized`，响应体包含 `{"detail": "用户名或密码错误"}`
- 令牌过期：返回 `401 Unauthorized`，响应体包含 `{"detail": "令牌已过期"}`
- 令牌无效：返回 `401 Unauthorized`，响应体包含 `{"detail": "无效的令牌"}`

### 任务执行错误
- TaskConfig 验证失败：返回 `422 Unprocessable Entity`，响应体包含字段级错误详情
- ETL_CLI 子进程超时：记录错误日志，任务状态标记为 `failed`，error_message 记录超时信息
- ETL_CLI 子进程异常退出：记录 exit_code 和 stderr 输出，任务状态标记为 `failed`
- 取消不存在或已完成的任务：返回 `404 Not Found` 或 `409 Conflict`

### 队列错误
- 删除非 pending 状态的任务：返回 `409 Conflict`，提示只能删除待执行任务
- 重排包含非 pending 任务：忽略非 pending 任务，只重排 pending 任务

### 数据库查看器错误
- SQL 写操作拦截：返回 `400 Bad Request`，提示只允许只读查询
- SQL 查询超时（30 秒）：终止查询，返回 `408 Request Timeout`
- SQL 语法错误：返回 `400 Bad Request`，包含 PostgreSQL 原始错误信息

### 环境配置错误
- .env 文件不存在：返回 `404 Not Found`
- 配置格式错误：返回 `422 Unprocessable Entity`，包含错误行号和描述
- 文件写入权限不足：返回 `500 Internal Server Error`，记录详细错误日志

### 通用错误
- 所有 API 错误响应统一格式：`{"detail": "错误描述", "code": "ERROR_CODE"}`
- 未捕获异常：返回 `500 Internal Server Error`，日志记录完整堆栈

## 测试策略

### 测试框架

- **后端单元测试 & 属性测试**：pytest + hypothesis
  - 路径：`apps/backend/tests/`
  - 运行：`cd apps/backend && pytest tests/ -v`
- **前端单元测试**：Vitest + React Testing Library
  - 路径：`apps/admin-web/src/__tests__/`
  - 运行：`cd apps/admin-web && pnpm test`

### 属性测试（Property-Based Testing）

使用 hypothesis 库，每个属性测试至少运行 100 次迭代。

每个属性测试必须用注释标注对应的设计文档属性编号：

```python
# Feature: admin-web-console, Property 1: TaskConfig 序列化往返一致性
@given(config=st.builds(TaskConfigSchema, ...))
def test_task_config_round_trip(config):
    ...
```

属性测试重点覆盖：
- Property 1：TaskConfig 序列化往返（核心数据模型）
- Property 6：时间窗口验证（输入验证）
- Property 7：TaskConfig 到 CLI 命令转换（关键业务逻辑）
- Property 8-10：队列 CRUD 不变量（状态管理）
- Property 15-16：.env 解析与写入往返（配置管理）
- Property 17：SQL 写操作拦截（安全关键）
- Property 19：日志过滤（数据过滤逻辑）

### 单元测试

单元测试覆盖具体示例和边界条件：
- JWT 令牌生成/验证/过期
- 调度器 next_run 计算（各种调度类型）
- CLI 命令构建的具体场景
- API 端点的请求/响应格式
- 前端组件渲染和交互

### 集成测试

需要数据库环境的测试：
- 任务队列的完整生命周期
- 调度任务的创建/触发/执行
- 数据库查看器的 Schema 浏览和查询执行
- ETL 状态查询
