# 预告 × 直播间 — 全功能关系文档

> 来源：历史工程 `trae_polyv_export/live-console-backend/app.py`（51KB）+ `instance/liveconsole.db`（实测表结构）+ `需求点清单.md` + `arch.json`（原版功能架构图）+ `prd_md.md` + `polyv-integration-guide.md`。
> 用途：供重写工程时决策"基于历史保留什么、改什么、补什么"。

---

## 0. 一句话关系

**预告是直播前的"信息载体 + 预约入口"，本身不承载直播流；直播间是承载实际推拉流的实体。** 预告在开播前 30 分钟把自身信息（标题/封面/板块/品种/适当性/VIP）"注入"到关联直播间的"当前生效值"层，开播后 30 分钟"恢复"回直播间的默认值。一个直播间可挂多个预告，但同时只有一个被注入。

---

## 1. 两个实体的定位

| 维度 | 预告 Preview | 直播间 Room |
|------|-------------|-----------|
| 本质 | 直播前的内容卡片 + 预约入口 | 承载推拉流的保利威频道映射 |
| 是否承载直播流 | 否 | 是（push_url/pull_url/watch_url） |
| 创建时机 | 运营手动创建，必须关联一个直播间 | 运营手动创建，创建时同步建保利威频道 |
| 字段层次 | 单层（自身属性） | **三层**（保利威原生 / 管理台默认 / 当前生效） |
| 状态 | 自动计算 pending/live/ended | 自管 idle/live/ended/pending |
| 生命周期 | 创建→待开播→(注入)→命中/失效→结束 | 创建(建频道)→idle→(注入)→live→ended→(恢复)→idle |

---

## 2. 实体间关系（ER）

```mermaid
erDiagram
    TEAM ||--o{ RESEARCHER : "1团队N研究员"
    RESEARCHER }o--o{ ROOM : "多对多 room_researchers"
    PREVIEW }o--|| ROOM : "room_id 关联（可空）"
    ROOM }o--o| PREVIEW : "preview_id 反向关联（双向外键）"
    ROOM }o--o| PREVIEW : "injected_preview_id 当前注入"
    USER ||--o{ ROOM : "观看鉴权（无外键，逻辑关系）"

    PREVIEW {
        int id PK
        int room_id FK
        string title
        string cover_url
        text content
        datetime scheduled_at
        string suit_level
        string vip_tier
        string sector
        string variety
        string status
        string hit_status
        datetime hit_at
        int subscriber_count
    }
    ROOM {
        int id PK
        int preview_id FK
        int injected_preview_id FK
        string polyv_channel_id UK
        string channel_passwd
        string default_title
        string title
        string status
        datetime started_at
        datetime ended_at
    }
```

### 关系说明

| 关系 | 方向 | 字段 | 说明 |
|------|------|------|------|
| 预告 → 直播间 | 多对一 | `Preview.room_id` | 预告创建时必填（代码里实际 nullable=True，注释写"命中恢复后可为空"） |
| 直播间 → 预告 | 一对多 | `Room.preview_id` | 直播间反向指向"当前关联"的预告 |
| 直播间 → 注入预告 | 一对一 | `Room.injected_preview_id` | 当前正在注入的预告 ID，无注入时为 NULL |
| 直播间 ↔ 研究员 | 多对多 | `room_researchers` 关联表 | 一个直播间多个主播/研究员 |

### ⚠️ 设计问题：双向外键

历史代码里 `Preview.room_id` 和 `Room.preview_id` **互为外键**，形成循环引用。实际数据里 `Room.preview_id` 几乎不用（注入走 `injected_preview_id`）。**重写建议：删掉 `Room.preview_id`，只保留 `Preview.room_id` 单向关联 + `Room.injected_preview_id` 注入指针。**

---

## 3. 预告（Preview）全字段

实测数据库 `previews` 表，22 个字段：

| 字段 | 类型 | 说明 | 备注 |
|------|------|------|------|
| id | INT PK | 预告ID | |
| room_id | INT FK | 关联直播间 | nullable，"命中恢复后可为空" |
| title | VARCHAR(200) | 预告标题 | 必填，注入时覆盖 room.title |
| cover_url | VARCHAR(500) | 封面图 | 注入时覆盖 room.cover_url |
| intro | TEXT | 简要描述 | 列表/卡片展示 |
| content | TEXT | 富文本内容 | HTML，Quill 编辑器产出 |
| scheduled_at | DATETIME | 计划开播时间 | 注入窗口和命中检测的基准 |
| suit_level | VARCHAR(5) | 适当性等级 | C1-C5 |
| vip_tier | VARCHAR(20) | VIP等级 | junyan/junxing/junzunxiang |
| min_suit_level | VARCHAR(5) | 最低观看适当性 | 鉴权用 |
| min_vip_tier | VARCHAR(20) | 最低VIP等级 | 鉴权用 |
| sector | VARCHAR(100) | 板块 | 新能源/半导体等，注入时覆盖 |
| variety | VARCHAR(100) | 品种 | 光伏/芯片等，注入时覆盖 |
| status | VARCHAR(20) | 预告状态 | **自动计算**：pending/live/ended |
| hit_status | VARCHAR(20) | 命中状态 | pending/hit/missed，**⚠️ 从不更新** |
| hit_at | DATETIME | 命中时间 | **⚠️ 从不写入** |
| applied_at | DATETIME | 同步到保利威时间 | 注入时写入 |
| subscriber_count | INT | 预约人数 | **仅计数器，无预约关系表** |
| polyv_channel_id | VARCHAR(100) | 保利威频道ID | 冗余存一份 |
| share_url | VARCHAR(500) | 分享链接 | |
| created_at | DATETIME | 创建时间 | |
| updated_at | DATETIME | 更新时间 | 自动 |

### 预告状态自动计算逻辑（`to_dict` 里实时算）

```
now < scheduled_at           → pending（待开播）
scheduled_at ≤ now ≤ +2h     → live（直播中）
now > scheduled_at + 2h      → ended（已结束）
```

---

## 4. 直播间（Room）全字段 — 三层架构

历史项目最值得保留的设计：**Room 字段分三层**，让"默认配置"和"预告注入后的临时值"互不污染。

### 第一层：保利威原生字段（创建频道时由 API 返回）

| 字段 | 类型 | 说明 |
|------|------|------|
| polyv_channel_id | VARCHAR(100) UK | 频道ID，唯一 |
| channel_passwd | VARCHAR(20) | 讲师登录密码 |
| polyv_scene | VARCHAR(30) | 直播场景 topclass/double/train/seminar/alone |
| polyv_template | VARCHAR(30) | 直播模板 ppt/alone/topclass/seminar |
| push_url | VARCHAR(500) | 推流地址（RTMP） |
| pull_url | VARCHAR(500) | 拉流地址（m3u8） |
| watch_url | VARCHAR(500) | 观众观看地址 |
| playback_url | VARCHAR(500) | 回放地址 |
| playback_vid | VARCHAR(100) | 回放视频ID |

### 第二层：管理台默认信息（保利威没有，运营配置的"原始态"）

| 字段 | 类型 | 说明 |
|------|------|------|
| internal_name | VARCHAR(200) | 对内名称（管理台查询用） |
| default_title | VARCHAR(200) | 默认标题（传给保利威 + 恢复时用） |
| default_cover_url | VARCHAR(500) | 默认封面 |
| default_description | TEXT | 默认简介 |
| default_sector | VARCHAR(100) | 默认板块 |
| default_variety | VARCHAR(100) | 默认品种 |
| default_suit_level | VARCHAR(5) | 默认适当性 |
| default_vip_tier | VARCHAR(20) | 默认VIP |
| default_guests | VARCHAR(500) | 默认嘉宾（JSON数组） |

### 第三层：当前生效值（可能是默认值，也可能是注入后的预告值）

| 字段 | 类型 | 说明 |
|------|------|------|
| title | VARCHAR(200) | 当前生效标题 |
| cover_url | VARCHAR(500) | 当前生效封面 |
| sector | VARCHAR(100) | 当前生效板块 |
| variety | VARCHAR(100) | 当前生效品种 |
| gif_url | VARCHAR(500) | GIF动图（业务自增） |
| suit_level | VARCHAR(5) | 当前生效适当性 |
| vip_tier | VARCHAR(20) | 当前生效VIP |
| min_suit_level | VARCHAR(5) | 最低观看适当性 |
| min_vip_tier | VARCHAR(20) | 最低VIP |

### 其他字段

| 字段 | 类型 | 说明 |
|------|------|------|
| preview_id | INT FK | 反向关联预告（**建议删除**） |
| injected_preview_id | INT FK | 当前注入的预告ID |
| view_limit_type | VARCHAR(20) | 观看限制 none/code/phone/external/direct |
| view_limit_value | VARCHAR(200) | 限制值（密码/白名单） |
| is_enabled | BOOL | 是否启用 |
| is_web_enabled | BOOL | 机构WEB是否启用 |
| tags | VARCHAR(200) | 标签逗号分隔 |
| scheduled_start_at | DATETIME | 计划开播时间 |
| last_operator | VARCHAR(100) | 最后操作者 |
| last_operated_at | DATETIME | 最后操作时间 |
| status | VARCHAR(20) | idle/live/ended/pending |
| viewer_count | INT | 观看人数 |
| started_at | DATETIME | 实际开播时间 |
| ended_at | DATETIME | 实际结束时间 |

---

## 5. 注入与恢复机制（核心流转）

这是预告↔直播间关系的核心。历史代码已实现，逻辑完整。

### 时间窗口

```
scheduled_at - 30min ──── 注入开始
scheduled_at           ─── 计划开播
scheduled_at + 30min ──── 注入结束（开始恢复）
```

### 注入逻辑 `inject_preview_to_room(preview, room)`

预告字段覆盖直播间"当前生效值"层，**不动第二层默认值**：

| 预告字段 | → | 直播间当前生效字段 | 空值处理 |
|---------|---|------------------|---------|
| preview.title | → | room.title | 空则用 room.default_title |
| preview.cover_url | → | room.cover_url | 空则用 room.default_cover_url |
| preview.sector | → | room.sector | 空则用 room.default_sector |
| preview.variety | → | room.variety | 空则用 room.default_variety |
| preview.suit_level | → | room.suit_level | 空则用 room.default_suit_level |
| preview.vip_tier | → | room.vip_tier | 空则用 room.default_vip_tier |

同时：`room.injected_preview_id = preview.id`，`preview.applied_at = now`。

### 恢复逻辑 `restore_room_defaults(room)`

把"当前生效值"回退到"第二层默认值"：

```
room.title = room.default_title
room.cover_url = room.default_cover_url
room.sector = room.default_sector
room.variety = room.default_variety
room.suit_level = room.default_suit_level
room.vip_tier = room.default_vip_tier
room.injected_preview_id = None
```

### 同步触发 `sync_preview_injection()`

遍历所有预告，按时间窗口判断该注入还是该恢复。**触发时机**：
- 手动调用 `/api/sync-preview-injection`
- 每次查询 `/api/room-with-preview/<rid>` 时自动同步

⚠️ **没有定时任务**自动跑。历史代码里没有 APScheduler/Celery，注入同步全靠请求触发。如果没人访问，注入/恢复不会自动发生。

---

## 6. 命中检测（设计了但没接通）

### 设计意图（来自 PRD + 需求清单）

- 命中窗口：预告 `scheduled_at` ± 20 分钟
- 实际开播时间在窗口内 → 命中（hit），预约用户收通知
- 超出窗口 → 未命中（missed），预告失效
- 命中后记录 `hit_at`

### 代码现状

```python
def check_preview_hit(preview, room_started_at, now=None):
    # 函数存在，逻辑正确
    delta = abs((room_started_at - preview.scheduled_at).total_seconds())
    return delta <= 20 * 60
```

**但全代码库没有任何地方调用这个函数。** `hit_status` 永远是默认值 `pending`，`hit_at` 永远是 NULL。命中检测是个**空壳**。

### 缺失的链路

| 环节 | 现状 |
|------|------|
| 检测触发 | 无（应该配 PolyV 状态回调或主动轮询） |
| 命中判定 | 函数有，没调用 |
| hit_status 更新 | 无 |
| 通知预约用户 | 无（连预约关系表都没有） |

---

## 7. 状态流转全景

```mermaid
stateDiagram-v2
    direction LR

    state "预告" as P {
        [*] --> P_pending : 创建
        P_pending --> P_injected : T-30min 注入
        P_injected --> P_hit : 开播命中±20min
        P_injected --> P_missed : 超出窗口
        P_hit --> P_ended : 直播结束
        P_missed --> P_ended : 失效
    }

    state "直播间" as R {
        [*] --> R_idle : 创建(建频道)
        R_idle --> R_injected : 预告注入
        R_injected --> R_live : 开播
        R_live --> R_ended : 结束
        R_ended --> R_idle : T+30min 恢复
    }

    P_injected --> R_injected : 覆盖生效值
    P_ended --> R_idle : 恢复默认值
```

### 预告状态（两套，别混淆）

| 状态字段 | 取值 | 计算方式 | 是否落库 |
|---------|------|---------|---------|
| status | pending/live/ended | `to_dict` 实时算（基于 scheduled_at ± 2h） | 否（每次查询重算） |
| hit_status | pending/hit/missed | 应由命中检测写入 | **从不更新** |

### 直播间状态

| status | 含义 | 触发 |
|--------|------|------|
| idle | 未开播 | 创建后默认 / 恢复后 |
| live | 直播中 | 手动 PUT status=live 时写 started_at |
| ended | 已结束 | 手动 PUT status=ended 时写 ended_at |
| pending | 等待中 | 代码支持，无业务触发 |

⚠️ 直播间状态变更**全靠手动 API 调用**，没有接保利威状态回调，没有主动轮询。

---

## 8. 鉴权链（保利威观看准入）

```mermaid
flowchart TD
    A[用户进观看页] --> B[加载直播间信息]
    B --> C[输入 userid]
    C --> D[/api/auth/check]
    D --> E{用户存在?}
    E -->|否| F[404 用户不存在]
    E -->|是| G{适当性+VIP 校验}
    G -->|不足| H[403 适当性/VIP不足]
    G -->|通过| I[准入通过]

    I --> J[保利威播放器加载]
    J --> K[保利威回调 /api/auth/custom-auth]
    K --> L{签名验证<br/>MD5(secret+cid+secret+ts)}
    L -->|失败| M[403]
    L -->|通过| N{找频道对应房间}
    N -->|无| O[404]
    N -->|有| P[⚠️ 取第一个active用户 mock]
    P --> Q{权限校验}
    Q -->|不足| R[403]
    Q -->|通过| S[返回回调签名<br/>userid/nickname/avatar/ts/sign]
    S --> T[保利威放行播放]
```

### 三个鉴权接口

| 接口 | 方法 | 用途 | 现状 |
|------|------|------|------|
| /api/auth/login | POST | userid 登录，返回 base64 token | mock，无密码校验 |
| /api/auth/check | POST | 前端准入验证 | 校验 user + room 权限 |
| /api/auth/custom-auth | POST | 保利威自定义授权回调 | **⚠️ mock：取第一个 active 用户，不是真实观众** |

### 权限校验规则 `check_view_permission`

```
适当性：C1 < C2 < C3 < C4 < C5（用户 ≥ 最低要求）
VIP：   空 < junyan < junxing < junzunxiang（用户 ≥ 最低要求）
优先级：适当性优先于 VIP
回退：  min_suit_level 空则用 suit_level
```

---

## 9. 历史项目的缺口与设计问题（决策重点）

### 9.1 完全没实现的功能（需求/架构图有，代码无）

| 功能 | 需求来源 | 现状 | 影响 |
|------|---------|------|------|
| **Playback 回放表** | 需求清单 §1.6 | 无表，回放只是 Room 的字段 | 回放无法独立管理、无法分段、无法按板块筛选 |
| **命中检测触发** | 需求清单 §2.2 | 函数有，没调用 | hit_status 永远 pending，预约通知链路断 |
| **预约关系** | 需求清单 subscriber_count | 只有计数器，无 subscription 表 | 无法知道谁预约了、无法发通知 |
| **通知机制** | PRD 规则三 | 完全无 | 命中后无法通知预约用户 |
| **话题管理** | arch.json 直播控制台 | 无 | 抑制/置顶/原场回顧功能缺失 |
| **推流控制** | arch.json 直播控制台 | 无 | 开始/结束推流、切图功能缺失 |
| **内容分发复用** | arch.json 内容管理 | 无 | 推送到其他平台功能缺失 |
| **回放分段** | arch.json 内容列表 | 无 | 回放无法分段管理 |
| **定时任务** | 注入恢复需要 | 无 APScheduler | 注入/恢复靠请求触发，不访问不执行 |
| **保利威状态回调** | 命中检测需要 | 无 | 无法被动感知开播/结束 |

### 9.2 实现了但有设计问题

| 问题 | 详情 | 重写建议 |
|------|------|---------|
| **双向外键** | Preview.room_id ↔ Room.preview_id 循环引用 | 删 Room.preview_id，保留单向 Preview.room_id |
| **自定义授权 mock** | custom-auth 取"第一个 active 用户"，不识别真实观众 | 接 PolyV 自定义授权真实观众识别（需 authType=custom + 后端授权接口） |
| **PolyV API 版本** | 用 v3 `/channel/create` | 升 v4 `/channel/create-init` + `basic/get` 回填 pushUrl/watchUrl |
| **注入无定时** | sync_preview_injection 靠请求触发 | 加 APScheduler 定时跑（每 1min） |
| **状态无回调** | 直播间 status 全靠手动 | 接 PolyV 状态回调 + v4 批量查询主动轮询 |
| **预告 status 不落库** | 每次查询重算 | 可接受（无状态污染），或落库 + 定时更新 |
| **PolyV 频道创建后未补 basic/get** | push_url/pull_url 可能拿不到 | 创建后立即调 basic/get 回填 |

### 9.3 设计亮点（建议保留）

| 设计 | 价值 |
|------|------|
| **Room 三层字段** | 默认值/生效值分离，注入恢复零污染，这是历史项目最值得保留的设计 |
| **注入只覆盖生效层** | 不动默认值，恢复就是回退默认值，逻辑干净 |
| **预告 status 实时算** | 不落库避免状态污染，查询时根据 scheduled_at 算 |
| **适当性+VIP 双闸门** | 适当性优先于 VIP，符合期货合规 |
| **min_xxx 回退 xxx** | 灵活，可只设门槛也可设当前值 |

---

## 10. 基于历史调整的建议

### 必改（P0）

1. **删双向外键** — 删 `Room.preview_id`，只留 `Preview.room_id` + `Room.injected_preview_id`
2. **补 Playback 表** — 回放独立成表，关联 room_id + preview_id，继承 sector/variety
3. **补 Subscription 表** — 预约关系落库（user_id + preview_id + status），subscriber_count 改为查询计数
4. **接通命中检测** — APScheduler 每 1min 跑，调 PolyV v4 批量查 live-status，命中则更新 hit_status + 发通知
5. **接保利威状态回调** — 开播/结束被动感知，配主动轮询兜底（PolyV 主动 set_status 不触发回调）
6. **自定义授权识别真实观众** — authType=custom，后端授权接口识别真实 userid 而非取第一个 active
7. **PolyV API 升 v4** — create-init + basic/get 回填

### 建议改（P1）

8. **注入加定时** — APScheduler 每 1min 跑 sync_preview_injection，不靠请求触发
9. **直播间状态机** — 接 PolyV 6 状态（unStart/live/end/waiting/playback/banpush），不只 idle/live/ended
10. **回放 PlaySafe 授权** — 回放观看走 PlaySafe token，不是简单 playback_url

### 可选（P2，看业务需要）

11. 话题管理（抑制/置顶）— 如果要做互动
12. 推流控制（开始/结束推流）— 如果运营要在控台控推流
13. 内容分发复用 — 如果要推到其他平台
14. 回放分段 — 如果回放要按主题切片

### 阶段策略（对齐 DESIGN v2 三阶段）

| 阶段 | 预告/直播间相关 |
|------|----------------|
| 阶段1 骨架 | 保留三层 Room + 单层 Preview + 注入恢复 + mock 鉴权 + 控制台基础页 |
| 阶段2 业务完整 | 补 Playback 表 + Subscription 表 + 命中检测 + PolyV 状态回调 + 定时任务 |
| 阶段3 真实鉴权 | 自定义授权识别真实观众 + 适当性/VIP 数据源接入 + PlaySafe 回放授权 |

---

## 附：历史项目页面与接口清单

### 后台页面（templates/）

| 页面 | 路由 | 模板 |
|------|------|------|
| 预告列表 | /previews | preview_list.html |
| 直播间列表 | /rooms | room_list.html |
| 团队管理 | /teams | teams.html |
| 节目单（前端） | /schedule | schedule.html |
| 直播大厅（前端） | /live | live.html |
| 观看页 | /watch/<room_id> | watch.html |
| 回放中心 | /playback | playback.html |

### API 接口（40 个）

| 域 | 接口数 | 列表 |
|----|-------|------|
| 鉴权 | 3 | login / custom-auth / check |
| 用户 | 4 | list / create / update / delete |
| 团队 | 4 | list / create / update / delete |
| 研究员 | 4 | list / create / update / delete |
| 预告 | 5 | list / create / get / update / delete |
| 直播间 | 5 | list / create / get / update / delete |
| 业务流 | 3 | sync-preview-injection / room-with-preview / upload |
| 前端 | 3 | schedule / live-rooms / polyv-status |
| 其他 | 2 | seed / polyv-status |

### 数据库实测（liveconsole.db）

| 表 | 行数 | 说明 |
|----|------|------|
| teams | 3 | 量化研究/宏观策略/行业研究 |
| researchers | 8 | 分布于3团队+1未分配 |
| rooms | 8 | 覆盖不同等级/状态 |
| previews | 8 | 不同时间偏移/板块/品种 |
| users | 8 | C1-C5 + 三档VIP |
| room_researchers | 12 | 直播间-研究员多对多 |
| **playbacks** | **无** | 需求有，未实现 |
| **subscriptions** | **无** | 需求有，未实现 |

---

_文档生成于 2026-09-19，基于历史工程源码实测。重写工程时以此为"保留/改/补"的决策依据。_
