# DESIGN.md — 君弘研究·直播控台 重写工程

> 文档定位：**先于代码的设计规范**。技术栈已拍板（见 §1），本文件锁定架构、模块边界、接口契约、数据模型、UI 规范、部署形态。代码必须服从本文件；改本文件需同步改代码。
>
> 状态：v1.0 · 2026-09-19 · 待隽侬过审后开写业务代码

---

## 1. 选型决策（已拍板）

**决策原则**：用户量级 ≤100 + 出错概率最小 = 不引入任何不必要的技术复杂度。

| # | 维度 | 选型 | 理由 |
|---|---|---|---|
| 1 | 后端 | **Flask 3.x + Python 3.13** | 同步、简单、debug 容易、RAG 已对齐、无异步心智负担 |
| 2 | 前端 | **Jinja2 + HTMX + Tailwind (CDN)** | 无构建链、无版本地狱、单体部署、交互够用 |
| 3 | 数据库 | **SQLite (WAL 模式)** | ≤100 用户零压力、零运维、单文件备份 |
| 4 | ORM | **SQLAlchemy 2.x (Core/ORM)** | 类型安全、迁移工具成熟 |
| 5 | 部署 | **systemd user service + cloudflared** | 与现有 netf.top 架构一致 |
| 6 | 前后端 | **单体（不分离）** | 出错概率最低，SPA 在此量级是过度设计 |
| 7 | 鉴权 | **先 mock 接口 → 后接适当性/VIP** | 预留接口位，走通流程优先 |
| 8 | 定时任务 | **APScheduler (进程内)** | 无需额外组件，与 Flask 同进程 |

**显式排除**：FastAPI（异步在此量级无收益）、React/Vue SPA（构建链+版本地狱）、Postgres（运维成本）、Docker（单进程无需容器化）。

---

## 2. 架构总览

单体 Flask 应用，分层清晰。**一个进程**跑完所有事（Web + 定时任务 + PolyV 回调）。

```mermaid
flowchart TB
    subgraph Client["客户端"]
        OP[运营/编辑 控制台]
        VIEWER[观众 观看页]
    end

    subgraph CF["Cloudflare Tunnel"]
        ING[live.netf.top ingress]
    end

    subgraph App["Flask 单体 :8100"]
        R[routes/ 路由层]
        S[services/ 业务逻辑]
        M[models/ 数据模型]
        PV[polyv/ PolyV SDK 封装]
        T[tasks/ 定时任务 APScheduler]
        AUTH[auth/ 鉴权链路 mock→真实]
    end

    subgraph DB["SQLite WAL"]
        SQL[(live_console.db)]
    end

    subgraph PolyV["保利威云"]
        API[api.polyv.net REST]
        CB[回调: 自定义授权/状态变更]
        PLAYER[Web 播放器 JS]
    end

    OP --> ING
    VIEWER --> ING
    VIEWER --> PLAYER
    ING --> R
    R --> S
    S --> M
    S --> PV
    S --> AUTH
    T --> S
    M --> SQL
    PV <--> API
    API --> CB
    CB --> R
    PLAYER -.直接加载.-> PolyV
```

**模块边界**（不可越界）：
- `routes/` 只管 HTTP 进出 + 参数校验，不写业务逻辑
- `services/` 业务逻辑，可调 `models/` `polyv/` `auth/`，不碰 HTTP
- `models/` 纯数据访问，不调外部服务
- `polyv/` 只封装保利威 API（签名/频道/授权/回放），不碰业务规则
- `auth/` 鉴权策略，先 mock 后真实，对 services 透明
- `tasks/` 定时任务编排，调 services，不直接调 routes

---

## 3. 目录结构

```
live-console/
├── app.py                    # Flask 工厂 + APScheduler 初始化
├── config.py                 # 配置加载（env → Config 对象）
├── .env.example              # 配置模板（密钥占位，600 权限）
├── requirements.txt
├── gunicorn_config.py        # 生产 WSGI
├── live_console.db           # SQLite（运行态，gitignore）
├── routes/
│   ├── __init__.py
│   ├── preview.py            # 预告 CRUD
│   ├── live.py               # 直播间 CRUD + PolyV 频道
│   ├── playback.py           # 回放管理
│   ├── console.py            # 控制台页面（Jinja2 渲染）
│   ├── viewer.py             # 观众观看页（嵌入 PolyV 播放器）
│   └── webhook.py            # PolyV 回调（自定义授权/状态）
├── services/
│   ├── __init__.py
│   ├── preview_service.py    # 预告创建/状态机
│   ├── injection_service.py  # T-30 注入 + 备份 + 恢复
│   ├── hit_service.py        # 命中检测 T±20min
│   ├── playback_service.py   # 回放生成 + 字段继承
│   └── live_service.py       # 直播间 + 频道创建编排
├── models/
│   ├── __init__.py
│   ├── db.py                 # SQLAlchemy db 实例
│   ├── preview.py            # Preview 表
│   ├── live_room.py          # LiveRoom 表
│   ├── playback.py           # Playback 表
│   ├── channel.py            # PolyVChannel 表（频道缓存）
│   ├── audience.py           # Audience 表（观众记录）
│   └── audit_log.py          # AuditLog 表（操作审计）
├── polyv/
│   ├── __init__.py
│   ├── sign.py               # 签名算法（MD5/SHA256，RAG 已验正确）
│   ├── client.py             # HTTP 客户端 + 公共参数
│   ├── channel.py            # 频道: create / basic_get / update
│   ├── auth.py               # 自定义观看授权: 设置 + 回调验签
│   └── vod.py                # 回放(VOD): 列表 + PlaySafe 授权
├── auth/
│   ├── __init__.py
│   ├── base.py               # 鉴权抽象接口
│   ├── mock.py               # mock 实现（走通流程用）
│   └── real.py               # 真实适当性+VIP（预留，数据源待定）
├── tasks/
│   ├── __init__.py
│   └── scheduler.py          # APScheduler 注册: T-30/T+30/命中/回放
├── templates/
│   ├── base.html             # 布局（Tailwind CDN + HTMX + Mermaid）
│   ├── console/              # 控制台页面
│   │   ├── dashboard.html
│   │   ├── preview_edit.html
│   │   ├── live_room.html
│   │   └── playback_list.html
│   └── viewer/               # 观众观看页
│       └── watch.html        # 嵌入 PolyV 播放器
└── static/
    ├── css/
    │   └── tokens.css        # 设计 token（见 §9）
    └── js/
        └── app.js            # HTMX 交互增强
```

---

## 4. 数据模型（6 张表）

沿用旧设计 6 张表，修正字段类型 + 索引 + 状态枚举一致性。

```sql
-- 1. 预告表
CREATE TABLE preview (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    title         TEXT NOT NULL,
    presenter     TEXT NOT NULL,
    planned_at    DATETIME NOT NULL,           -- 计划开播时间 T
    sector        TEXT,                        -- 板块
    variety       TEXT,                        -- 品种
    intro         TEXT,                        -- 简介
    cover_url     TEXT,
    status        TEXT NOT NULL DEFAULT 'pending'
                  CHECK(status IN ('pending','live','ended','cancelled')),
    hit_status    TEXT NOT NULL DEFAULT 'pending'
                  CHECK(hit_status IN ('pending','hit','missed')),
    live_room_id  INTEGER,                     -- 注入目标直播间
    injected_at   DATETIME,                    -- T-30 注入时间
    restored_at   DATETIME,                    -- T+30 恢复时间
    created_at    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY(live_room_id) REFERENCES live_room(id)
);
CREATE INDEX idx_preview_planned ON preview(planned_at);
CREATE INDEX idx_preview_status ON preview(status, hit_status);

-- 2. 直播间表
CREATE TABLE live_room (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    name          TEXT NOT NULL,               -- 直播间名（备份前原值）
    name_backup   TEXT,                        -- 注入后备份的原名
    channel_id    TEXT,                        -- PolyV 频道号
    watch_url     TEXT,                        -- 观看地址（basic/get 回填）
    push_url      TEXT,                        -- 推流地址（basic/get 回填）
    watch_condition TEXT DEFAULT 'custom',     -- none/code/custom
    is_active     BOOLEAN NOT NULL DEFAULT 1,
    created_at    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_room_channel ON live_room(channel_id);

-- 3. 回放表
CREATE TABLE playback (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    vid           TEXT NOT NULL,               -- PolyV 回放 vid
    live_room_id  INTEGER NOT NULL,
    preview_id    INTEGER,                     -- 来源预告（可空）
    title         TEXT NOT NULL,
    sector        TEXT,                        -- 继承自预告
    variety       TEXT,                        -- 继承自预告
    cover_url     TEXT,
    duration      INTEGER,                     -- 秒
    playsafe_token TEXT,                       -- PlaySafe 授权 token（如启用）
    created_at    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY(live_room_id) REFERENCES live_room(id),
    FOREIGN KEY(preview_id) REFERENCES preview(id)
);
CREATE INDEX idx_playback_room ON playback(live_room_id);

-- 4. PolyV 频道缓存表
CREATE TABLE polyv_channel (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    channel_id    TEXT UNIQUE NOT NULL,
    name          TEXT,
    watch_url     TEXT,
    push_url      TEXT,
    auth_type     TEXT,                        -- none/code/custom
    raw_basic     TEXT,                        -- basic/get 原始 JSON（调试用）
    synced_at     DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 5. 观众记录表
CREATE TABLE audience (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    channel_id    TEXT NOT NULL,
    viewer_id     TEXT,                        -- PolyV 观众标识（自定义授权传入）
    user_code     TEXT,                        -- 业务用户号
    appropriateness TEXT,                      -- 适当性 C1-C5
    vip_level     INTEGER DEFAULT 0,           -- VIP 三档 0/1/2/3
    auth_result   TEXT,                        -- allow/deny
    auth_reason   TEXT,                        -- 拒绝原因
    first_seen    DATETIME,
    last_seen     DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(channel_id, viewer_id)
);
CREATE INDEX idx_audience_channel ON audience(channel_id);

-- 6. 审计日志表
CREATE TABLE audit_log (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    actor         TEXT NOT NULL,               -- 操作人/系统
    action        TEXT NOT NULL,               -- preview.create/live.inject/...
    target_type   TEXT,
    target_id     INTEGER,
    detail        TEXT,                        -- JSON
    created_at    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_audit_target ON audit_log(target_type, target_id);
CREATE INDEX idx_audit_time ON audit_log(created_at);
```

**关键修正（vs 旧工程）**：
- 状态枚举用 `CHECK` 约束强制一致（旧工程代码与文档枚举不一致）
- `live_room` 增加 `watch_url`/`push_url` 字段，承接 `basic/get` 回填（旧工程漏调 basic/get 导致地址全空）
- `polyv_channel` 缓存表独立，避免直播间表臃肿
- `audience` 表记录真实观众（旧工程取"首个用户"放行，闸门形同虚设）
- `audit_log` 新增，金融场景必备

---

## 5. 接口契约（路由表）

精简自旧工程 39 API，按模块分组。**所有写操作记 audit_log**。

### 5.1 控制台页面（HTML）
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/console` | 控制台首页（看板 + 待办） |
| GET | `/console/previews` | 预告列表 |
| GET | `/console/previews/new` | 新建预告表单 |
| GET | `/console/previews/{id}/edit` | 编辑预告 |
| GET | `/console/rooms` | 直播间列表 |
| GET | `/console/rooms/{id}` | 直播间详情（含频道信息） |
| GET | `/console/playbacks` | 回放列表 |

### 5.2 预告 API
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/previews` | 创建预告 → 状态 pending |
| PUT | `/api/previews/{id}` | 编辑预告（pending 态可改） |
| DELETE | `/api/previews/{id}` | 删除预告（仅 pending） |
| POST | `/api/previews/{id}/cancel` | 取消预告 → cancelled |

### 5.3 直播间 + 频道 API
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/rooms` | 直播间列表 |
| POST | `/api/rooms` | 创建直播间 → 同步建 PolyV 频道 |
| PUT | `/api/rooms/{id}` | 编辑直播间 |
| POST | `/api/rooms/{id}/sync-channel` | 重新拉 basic/get 回填地址 |

### 5.4 回放 API
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/playbacks` | 回放列表（支持 sector/variety 筛选） |
| GET | `/api/playbacks/{id}` | 回放详情 |

### 5.5 观众观看页
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/play/{channel_id}` | 观看页（嵌入 PolyV 播放器 + 触发 custom auth） |

### 5.6 PolyV 回调（关键）
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/webhook/polyv/auth` | **自定义观看授权回调**（验签→查观众→适当性/VIP 判定→回签） |
| POST | `/webhook/polyv/status` | 直播状态变更（开播/结束） |

---

## 6. PolyV 集成层

**签名算法**（RAG 已验正确，照搬）：
```
sign = MD5(appSecret + 按 key 升序拼接 "key+value"（无=无&、跳过空值） + appSecret).upper()
公共参数: appId / timestamp(ms) / sign
表单 POST
```

**频道创建流程**（修正旧工程 P1：漏 basic/get）：
```mermaid
sequenceDiagram
    participant S as live_service
    participant P as polyv/channel
    participant API as api.polyv.net
    S->>P: create_channel(name)
    P->>API: POST /channel/create (v4)
    API-->>P: {channelId}  ⚠️ 不含地址
    P->>API: POST /channel/basic/get
    API-->>P: {channelId, watchUrl, pushUrl, ...}
    P-->>S: {channel_id, watch_url, push_url}
    S->>P: set_watch_condition(channel_id, "custom")
    P->>API: POST /channel/update (masterAuthSetting.authType=custom)
    API-->>P: ok
```

**自定义观看授权回调**（修正旧工程 P0×2）：
```mermaid
flowchart LR
    A[PolyV 回调 POST] --> B{验签通过?}
    B -- 否 --> X[回签 denyAll]
    B -- 是 --> C[解析 viewerId/userCode]
    C --> D[auth/ 鉴权: 查适当性+VIP]
    D --> E{满足?}
    E -- 是 --> F[回签 allow + 观众信息]
    E -- 否 --> G[回签 deny + 原因]
    F --> H[(写 audience 表)]
    G --> H
```

**回放(VOD)授权**（修正旧工程 P0：缺失）：
- 回放页带 `vid` → 后端 `/api/playbacks/{id}` 下发 PlaySafe token
- 播放器加载 token 后才能播放
- 旧工程完全没实现 VOD 授权，重写必须补

---

## 7. 鉴权链路（mock → 真实）

**抽象接口**（对 services 透明）：
```python
class AuthProvider(Protocol):
    def check(self, viewer_id: str, channel_id: str) -> AuthResult:
        """返回 allow/deny + appropriateness + vip_level + reason"""
```

**阶段 1（mock，走通流程）**：
- `mock.MockAuthProvider`：固定返回 allow + 适当性 C4 + VIP 1
- 目的：联调 PolyV 全链路，确认签名/创建/basic/get/回调/播放都通

**阶段 2（真实，数据源待定）**：
- `real.RealAuthProvider`：调公司内部系统查适当性 + VIP
- **卡点**：数据源未定（SSO / 中台 API / 自维护白名单）
- 接口位预留，实现等隽侬确认数据源后填

---

## 8. 定时任务（APScheduler 进程内）

| 任务 | 触发 | 动作 |
|---|---|---|
| 注入扫描 | 每 5min | 扫 `planned_at` 在未来 30min 内的 pending 预告 → 调 injection_service 注入直播间（备份原名+写入预告信息） |
| 命中检测 | 每 1min | 对 live 态预告，判断 `planned_at` 是否在 T±20min 窗口内命中实际开播 → hit/missed + 通知 |
| 恢复扫描 | 每 5min | 扫 `planned_at` 已过 30min 的 live 预告 → 调 injection_service 恢复直播间原名 |
| 回放生成 | 事件触发（状态变更回调） | 直播结束 → 调 playback_service 生成回放，继承板块/品种 |

---

## 9. UI 设计规范（Stripe 风，与逻辑看板一致）

**设计 token**（`static/css/tokens.css` + Tailwind config）：

```css
:root {
  /* 中性 */
  --ink: #1A1F36;          /* 主文字 深蓝墨 */
  --ink-sub: #697386;      /* 次要文字 */
  --bg: #FFFFFF;            /* 主背景 */
  --bg-subtle: #F6F9FC;    /* 次背景 */
  --border: #E3E8EE;        /* 边框 */
  --border-strong: #CDD3DC;

  /* 品牌 */
  --primary: #635BFF;       /* 紫蓝（Stripe 品牌色） */
  --primary-hover: #5851DB;
  --primary-bg: #EFEAFF;

  /* 语义（金融场景） */
  --success: #00875A;       /* 正常/通过 */
  --success-bg: #E3F5EC;
  --danger: #DE3618;        /* 警示/拒绝 */
  --danger-bg: #FCEAE6;
  --warning: #C28100;
  --warning-bg: #FCF2D6;
  --info: #1A73E8;
  --info-bg: #E3EFFC;

  /* 圆角/阴影 */
  --radius: 8px;
  --radius-sm: 4px;
  --shadow-card: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
  --shadow-pop: 0 4px 12px rgba(0,0,0,0.10);
}
```

**排版**：系统字体栈（-apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei"），正文 14px/1.6，标题 16-24px。

**组件取向**：
- 卡片化布局（白底 + border + shadow-card）
- 表格密集型（控台核心是列表/表格，非花哨视觉）
- HTMX 局部刷新（不整页 reload）
- 状态用彩色标签（pending 灰 / live 紫 / ended 绿 / cancelled 红）

**遵循**：light 主题，深色文字，不反转。所有可交互元素有 hover/focus 态。

---

## 10. 部署形态

```mermaid
flowchart LR
    USER[用户] -->|https://live.netf.top| CF[Cloudflare]
    CF -->|tunnel| CFD[cloudflared user service]
    CFD -->|ingress :8100| FLASK[gunicorn: Flask app]
    FLASK -->|读写| DB[(live_console.db\n/mnt/data/live-console/)]
    FLASK -->|API| POLYV[api.polyv.net]
    FLASK -->|回调| POLYV
```

**部署清单**：
- 工程源码：`/home/xiaoan0120/WorkBuddy/2026-09-19-11-29-19/live-console/`（开发）
- 运行目录：`/mnt/data/live-console/`（生产，含 db + venv）
- WSGI：gunicorn（`gunicorn_config.py`，4 worker）
- 服务：`~/.config/systemd/user/web-live-console.service`（监听 127.0.0.1:8100）
- cloudflared ingress：`live.netf.top` 从 `:8095`（占位 http.server）改为 `:8100`（Flask）
- 静态资源：Flask 自管（`/static/`），不另起 http.server
- 路径：`/console` 控制台 / `/play/{channel_id}` 观看页 / `/api/*` 接口 / `/webhook/*` 回调

**环境变量**（`.env`，600 权限，gitignore）：
```
FLASK_ENV=production
SECRET_KEY=<random>
DATABASE_URL=sqlite:////mnt/data/live-console/live_console.db
POLYV_USER_ID=<待隽侬发>
POLYV_APP_ID=<待隽侬发>
POLYV_APP_SECRET=<待隽侬发>
AUTH_PROVIDER=mock   # mock | real
```

---

## 11. 重写优先级（P0 先修，对照 RAG 对查报告）

| 优先级 | 修复项 | 阶段 |
|---|---|---|
| **P0-1** | 创建频道后补 `basic/get` 回填地址 | 阶段1 骨架 |
| **P0-2** | 频道创建时设 `masterAuthSetting.authType=custom`（否则回调不触发） | 阶段1 骨架 |
| **P0-3** | 自定义授权识别真实观众（写 audience 表，非"首个用户"放行） | 阶段1 骨架（mock 鉴权） |
| **P0-4** | 回放(VOD) PlaySafe 授权补全 | 阶段2 |
| P1-1 | 密钥全外部化到 `.env` | 阶段1 骨架 |
| P1-2 | CORS 白名单对齐真实前端域 | 阶段1 骨架 |
| P1-3 | 状态枚举 DB CHECK 约束 + 代码常量统一 | 阶段1 骨架 |
| P2 | 接口取舍、Mock 状态说明、文档措辞 | 阶段2+ |

**实施阶段**：
1. **阶段1（骨架 + 走通）**：目录 + 数据模型 + PolyV 集成层（签名/创建/basic/get/custom 回调）+ mock 鉴权 + 控制台基础页。目标：mock 鉴权下能跑完 创建频道→观看→授权→播放。
2. **阶段2（业务完整）**：预告状态机 + 注入/恢复 + 命中检测 + 回放 + 完整控制台。
3. **阶段3（真实鉴权）**：接适当性/VIP 数据源，替换 mock。

---

## 12. 待解项（不阻断阶段1）

- [ ] PolyV 真实凭证（userId/appId/appSecret）— 隽侬待发
- [ ] 适当性 + VIP 数据源 — 阶段3 前必须明确
- [ ] 通知渠道（命中后通知谁、怎么通知）— 阶段2
- [ ] 回放封面来源 — 阶段2

---

_本文件由拾光基于 RAG 对查结论 + 逻辑看板设计语言起草。改本文件需同步改代码，反之亦然。_
