formatting and comments
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
# 系统架构
|
||||
|
||||
本文描述直播组件服务的运行边界、数据流和扩展点。实现代码分别位于 `apps/server-rust` 与
|
||||
`apps/overlay`。
|
||||
|
||||
## 核心目标
|
||||
|
||||
- 每个账户固定绑定一个 Bilibili 直播间和一个独立直播源。
|
||||
- 一个直播源只建立一条上游连接,但可以把事件投递给多个组件实例。
|
||||
- 平台原始命令先转换成稳定的领域事件,组件不直接依赖 Bilibili `CMD`。
|
||||
- HTTP 会话、直播源、组件、OBS token 和实时通道均以租户为边界。
|
||||
- 新组件可以增加设置、投影和持久化副作用,而不修改直播连接核心。
|
||||
|
||||
## 运行时数据流
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CC[CookieCloud] --> PF[ProviderFactory]
|
||||
BL[Bilibili Live] <--> BP[BilibiliProvider]
|
||||
PF --> BP
|
||||
BP --> LE[LiveEvent]
|
||||
LE --> SR[SourceEventRouter]
|
||||
PG[(PostgreSQL)] --> CR[Component repository/cache]
|
||||
CR --> SR
|
||||
SR --> EH[Durable handlers]
|
||||
SR --> PJ[Passive projection]
|
||||
PJ --> HUB[Component-scoped EventHub]
|
||||
HUB --> WS[Authenticated WebSocket]
|
||||
WS --> OBS[OBS overlay]
|
||||
```
|
||||
|
||||
1. `SourceSupervisor` 为每个 `source_id` 保持至多一个 provider task。
|
||||
2. `BilibiliProvider` 使用该用户加密保存的 CookieCloud 凭据获取 Cookie,并把原始消息转换成
|
||||
`LiveEvent`。
|
||||
3. `SourceEventRouter` 同时使用 `owner_id` 与 `source_id` 查找启用的组件,并再次检查组件归属。
|
||||
4. 匹配订阅后,路由器先执行可持久化的 `EventHandler`,再执行无副作用的 `EventProjection`。
|
||||
5. 投影结果只发布到该 `component_id` 的广播通道。没有全局 WebSocket 事件总线。
|
||||
6. OBS 使用组件级只读 token 订阅一个组件,不能读取控制台 API。
|
||||
|
||||
## 状态所有权
|
||||
|
||||
| 状态 | 权威来源 | 内存副本 | 说明 |
|
||||
| ---------------- | ------------ | ------------------------ | ----------------------------- |
|
||||
| 用户、TOTP、会话 | PostgreSQL | 无 | Secret 加密,token 只保存摘要 |
|
||||
| CookieCloud 凭据 | PostgreSQL | provider 构建期间解密 | 不返回浏览器 |
|
||||
| 直播源和房间 | PostgreSQL | `SourceSupervisor` | 每用户固定一个房间 |
|
||||
| 组件实例与设置 | PostgreSQL | `InMemoryComponentStore` | 写入成功后刷新热路径缓存 |
|
||||
| 礼物/表情目录 | Bilibili API | provider catalog | 刷新失败保留最近成功快照 |
|
||||
| 实时消息 | provider | `EventHub` 有界广播 | 不作为业务持久化机制 |
|
||||
| PWA 静态壳层 | Docker 镜像 | Cache Storage | 不包含 API 或用户数据 |
|
||||
|
||||
## 启动顺序
|
||||
|
||||
`AppState::build` 按以下顺序启动,避免事件进入未完成的运行时:
|
||||
|
||||
1. 创建 PostgreSQL 连接池并执行嵌入式迁移。
|
||||
2. 构造加密和 passwordless 鉴权服务。
|
||||
3. 注册内建组件定义。
|
||||
4. 从 PostgreSQL hydrate 组件热路径缓存。
|
||||
5. 创建有界源事件队列、路由器和每组件广播中心。
|
||||
6. 为数据库中所有启用的直播源启动 provider。
|
||||
7. 最后由 `main.rs` 绑定 HTTP 监听端口。
|
||||
|
||||
关机时先停止接收 HTTP,再取消所有 provider task,防止容器退出期间继续拉取上游事件。
|
||||
|
||||
## 进程与部署边界
|
||||
|
||||
- Node 只存在于 Docker 的前端构建阶段。
|
||||
- 最终镜像仅运行 Rust 可执行文件,并从 `/app/web` 同域托管静态资源。
|
||||
- 容器使用 host network,但默认只监听 `127.0.0.1:9719`。
|
||||
- Nginx 负责公网 TLS、域名和 WebSocket upgrade。
|
||||
- CookieCloud 与 PostgreSQL 是外部服务,不由本项目 Compose 创建。
|
||||
|
||||
## 代码导航
|
||||
|
||||
- 后端模块职责:[`apps/server-rust/README.md`](../apps/server-rust/README.md)
|
||||
- 前端路由和状态:[`apps/overlay/README.md`](../apps/overlay/README.md)
|
||||
- 组件扩展指南:[`components/README.md`](components/README.md)
|
||||
- WebSocket 协议:[`protocol.md`](protocol.md)
|
||||
- 安全边界:[`security.md`](security.md)
|
||||
@@ -0,0 +1,58 @@
|
||||
# 组件开发指南
|
||||
|
||||
组件是“一个直播源上的独立功能实例”。当前内建
|
||||
`danmaku_overlay`,未来礼物墙、点歌姬或统计组件都应使用同一套契约。
|
||||
|
||||
## 一个组件由什么组成
|
||||
|
||||
| 部分 | Rust 契约 | 职责 |
|
||||
| -------- | --------------------- | ------------------------------------------------- |
|
||||
| 定义 | `ComponentDefinition` | kind、设置版本、默认值、校验、迁移、订阅 |
|
||||
| 投影 | `EventProjection` | 把 `LiveEvent` 转成浏览器消息,不执行持久化副作用 |
|
||||
| Handler | `EventHandler` | 可选的数据库写入、点歌或外部动作 |
|
||||
| 实例 | `ComponentInstance` | owner、source、kind、名称、设置和启用状态 |
|
||||
| 实时通道 | `EventHub` | 按 component ID 隔离的有界广播 |
|
||||
| 前端 | React renderer/editor | 管理设置、测试与 OBS 展示 |
|
||||
|
||||
## 新增组件步骤
|
||||
|
||||
1. 选择稳定、全小写的 kind,例如 `gift_wall` 或 `song_request`。
|
||||
2. 为设置定义可序列化结构,提供安全默认值和 `sanitize`/校验逻辑。
|
||||
3. 实现 `ComponentDefinition`:
|
||||
- `settings_version` 从 `1` 开始。
|
||||
- `migrate_settings` 必须能把已保存的旧版本升级到当前版本。
|
||||
- `subscriptions` 只返回当前设置需要的 `LiveEventKind`。
|
||||
4. 实现无副作用的 `EventProjection`。返回 `None` 表示该事件无需发送浏览器。
|
||||
5. 若需要可靠业务动作,实现 `EventHandler`:
|
||||
- 即使没有 OBS 客户端也会执行。
|
||||
- 数据库操作必须包含 owner/source/component 条件。
|
||||
- 上游可能重试或出现组合事件,因此 handler 自己负责幂等。
|
||||
6. 在 `ComponentRegistry::with_builtin_components` 注册定义与投影,再注册 handler。
|
||||
7. 增加数据库创建/设置 API;不要把组件专属关系数据无限塞入 JSON settings。
|
||||
8. 在控制台增加设置编辑器,在 OBS 前端增加对应事件渲染器。
|
||||
9. 增加以下测试:设置边界、版本迁移、订阅、跨租户拒绝、handler 幂等、投影 wire shape 和 OBS 渲染。
|
||||
|
||||
## Projection 与 Handler 的边界
|
||||
|
||||
Projection 面向“现在打开的浏览器”。消息丢失或没有接收者都属于正常情况。它必须快速、确定、无副作用。
|
||||
|
||||
Handler 面向“业务事实”。例如点歌请求、礼物累计或审计写入必须在这里完成,而不是等待前端收到 WebSocket。一个 handler 失败会记录在
|
||||
`RouteReport`,但不会阻止其他 handler 或 OBS 投影。
|
||||
|
||||
## 设置版本规则
|
||||
|
||||
- 数据库同时保存 `settings` 与 `settings_version`。
|
||||
- 每次读取或路由前,通过 registry 迁移并校验设置。
|
||||
- 新字段应提供默认值,删除/改义字段必须增加版本并编写迁移。
|
||||
- 客户端输入只能作为待校验 JSON;后端返回的 sanitized 设置才是权威值。
|
||||
- UI slider 的范围不能代替后端范围检查。
|
||||
|
||||
## 租户与 token 规则
|
||||
|
||||
- 组件必须属于同一个 owner 与 source。
|
||||
- 路由和数据库查询都要重复检查这一关系。
|
||||
- 每个组件单独签发 access token,默认只有 `events:subscribe`。
|
||||
- 删除组件或轮换 token 时,关闭该组件 channel,使已有 socket 立即失效。
|
||||
- 组件 WebSocket 不得暴露其他组件列表或控制 API。
|
||||
|
||||
当前组件的具体行为见 [`danmaku-overlay.md`](danmaku-overlay.md)。
|
||||
@@ -0,0 +1,66 @@
|
||||
# `danmaku_overlay` 弹幕姬
|
||||
|
||||
弹幕姬把一个租户直播源的互动事件投影为透明 OBS 消息墙。它是被动展示组件:不记账、不回复弹幕,也不把 WebSocket 当作持久化业务通道。
|
||||
|
||||
## 订阅事件
|
||||
|
||||
组件根据设置动态订阅:
|
||||
|
||||
- 弹幕、进房、礼物与礼物连击、醒目留言、舰长、点赞、分享。
|
||||
- 关闭类别后,后端不为该组件投影对应事件,前端也会进行一次兼容性过滤。
|
||||
- 未归一化的 `live.unknown` 默认不进入弹幕姬。
|
||||
|
||||
## 设置
|
||||
|
||||
| 字段 | 范围/单位 | 行为 |
|
||||
| ------------------------ | ----------- | -------------------------- |
|
||||
| `fontScale` | 50–300% | 展开与收缩字号的统一比例 |
|
||||
| `maxVisible` | 1–12 | 同时保留的消息卡数量 |
|
||||
| `collapseAfterSeconds` | 2–120 秒 | 最新卡从展开态切换到紧凑态 |
|
||||
| `unfoldDurationMs` | 200–5000 ms | 横向卷轴展开动画时间 |
|
||||
| `motionIntensity` | 0–100% | 卡片、流光与焦点动画强度 |
|
||||
| `particleCount` | 0–12 | 每卡星花粒子数量 |
|
||||
| `particleSpeed` | 25–300% | 粒子动画速度 |
|
||||
| `lowPerformanceMode` | boolean | 关闭高成本动态效果 |
|
||||
| `highValueThreshold` | 千分之一元 | 高价值礼物起点 |
|
||||
| `featuredValueThreshold` | 千分之一元 | 焦点礼物起点 |
|
||||
| `show*` | boolean | 控制各事件类别订阅 |
|
||||
|
||||
后端的 `OverlaySettings::sanitize` 是最终边界。控制台 range input 只改善交互,不能取代服务端校验。
|
||||
|
||||
## 卡片生命周期
|
||||
|
||||
1. 新事件插入队首,以卷轴动画横向展开。
|
||||
2. 用户名与内容在展开态分行显示,长内容完整换行。
|
||||
3. 新事件到达或超时后,旧卡变成紧凑态;内容不会隐藏。
|
||||
4. 紧凑态缩小字号并尽量压缩布局,但仍允许换行避免截断。
|
||||
5. 超过 `maxVisible` 的最旧卡才会离开队列。
|
||||
|
||||
花纹由事件类型和事件 ID 的稳定 hash 选择。相邻卡会避开完全相同的款式;礼物连击更新沿用原卡片 key 和装饰,避免视觉跳动。
|
||||
|
||||
## 礼物展示
|
||||
|
||||
- `live.gift` 创建礼物卡;`live.gift.combo` 使用 `comboId` 更新同一张卡。
|
||||
- 元数据优先使用礼物目录中的静态图、GIF、币种和价格。
|
||||
- GIF 加载失败时降级为静态图片;静态图失败时隐藏图片但保留名称、数量和用户。
|
||||
- 普通、高价值和焦点礼物按 `totalPrice` 与两个阈值分级。
|
||||
- `priceCny` 只用于人类可读展示;整数 `totalPrice` 用于精确分级。
|
||||
|
||||
## 弹幕表情
|
||||
|
||||
文字和表情按 `segments` 顺序混排。表情 URL 来自消息本身或用户级表情目录补全;图片使用
|
||||
`no-referrer`,失败后显示原始表情文字。整条大表情可通过 `standalone` 使用更合适的尺寸。
|
||||
|
||||
## OBS 自适应
|
||||
|
||||
- 根背景完全透明,不存在 1920×1080 固定画布。
|
||||
- `ResizeObserver` 根据浏览器源实际宽高选择 `narrow`、`short` 或 `standard`。
|
||||
- 字号和间距使用 CSS custom properties 与 `clamp()`,OBS 自由缩放时不拉伸素材比例。
|
||||
- 建议从 `360×600`、`440×760`、`600×1080` 或 `720×320` 开始测试。
|
||||
- 低高度会减少视觉密度,但不会把仍在队列中的文字裁成省略号。
|
||||
|
||||
## 测试页面
|
||||
|
||||
控制台的事件测试直接构造 canonical
|
||||
`LiveEvent`,使用当前账户、source 和 component 走同一套订阅、handler、projection 与 EventHub。测试事件标记为
|
||||
`simulated=true`,不会发送到 Bilibili,也不会跨组件广播。
|
||||
@@ -0,0 +1,103 @@
|
||||
# 组件实时协议
|
||||
|
||||
当前协议版本为 `1`。后端的权威定义在 `domain.rs` 的
|
||||
`ComponentMessage`,浏览器渲染器只依赖本文列出的稳定字段。
|
||||
|
||||
## 连接地址与认证
|
||||
|
||||
组件流地址:
|
||||
|
||||
```text
|
||||
wss://danmaku.luoxingci.com/api/v1/components/<publicId>/stream
|
||||
```
|
||||
|
||||
OBS 页面地址中的 token 位于 fragment:
|
||||
|
||||
```text
|
||||
https://danmaku.luoxingci.com/obs/<publicId>#token=<component-token>
|
||||
```
|
||||
|
||||
fragment 不会进入首次 HTTP 请求或 Nginx access
|
||||
log。WebSocket 建立后,客户端必须在 8 秒内发送第一帧:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "authenticate",
|
||||
"token": "component-token"
|
||||
}
|
||||
```
|
||||
|
||||
服务端验证 token 摘要、组件归属和 `events:subscribe` scope。认证成功后返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"type": "authenticated",
|
||||
"componentId": "08d31d19-3e4b-4c11-9cf7-9bd786a39465"
|
||||
}
|
||||
```
|
||||
|
||||
之后立即发送
|
||||
`overlay.settings.snapshot`,再开始发送实时事件。token 无效、被轮换或属于其他组件时,服务端以 policy
|
||||
close 结束连接。
|
||||
|
||||
## 版本化事件信封
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"id": "d56c1a28-a6f1-4ad9-b02f-4c22dc4d3c27",
|
||||
"componentId": "08d31d19-3e4b-4c11-9cf7-9bd786a39465",
|
||||
"sourceId": "c8087bb0-0dde-40eb-a43d-a9a2574c2717",
|
||||
"occurredAt": "2026-07-15T20:10:30.125Z",
|
||||
"roomId": "123456",
|
||||
"type": "live.danmaku",
|
||||
"payload": {
|
||||
"viewer": { "uid": "42", "name": "观众" },
|
||||
"text": "晚上好",
|
||||
"segments": [{ "type": "text", "text": "晚上好" }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ownerId` 永远不会序列化到浏览器。消费者应按 `version` 和 `type`
|
||||
分派,并忽略不认识的 payload 字段,从而允许兼容地增加元数据。
|
||||
|
||||
## 事件类型
|
||||
|
||||
| `type` | 主要 payload | 说明 |
|
||||
| --------------------------- | --------------------------------------------- | ---------------------- |
|
||||
| `overlay.settings.snapshot` | `settings` | 认证后当前设置快照 |
|
||||
| `overlay.settings.updated` | `settings` | 控制台保存后的实时设置 |
|
||||
| `live.danmaku` | `viewer`, `text`, `segments` | 普通文字和表情分段 |
|
||||
| `live.enter` | `viewer` | 进房事件 |
|
||||
| `live.gift` | `viewer`, `gift`, `quantity`, `sourceEventId` | 一次可记账的礼物事件 |
|
||||
| `live.gift.combo` | `viewer`, `gift`, `quantity`, `comboId` | 同一连击的视觉更新 |
|
||||
| `live.superchat` | `viewer`, `message`, `price`, `sourceEventId` | 醒目留言 |
|
||||
| `live.guard.buy` | `viewer`, `guardName`, `quantity`, `price` | 舰长购买 |
|
||||
| `live.like` | `viewer` | 点赞 |
|
||||
| `live.share` | `viewer` | 分享 |
|
||||
| `live.unknown` | `command`, `metadata` | 有界且已清理的未知事件 |
|
||||
|
||||
礼物目录价格的原始单位是人民币的千分之一。`gift.totalPrice` 保留该整数单位,`gift.priceCny`
|
||||
是供展示使用的人民币数值。
|
||||
|
||||
`live.gift` 与 `live.gift.combo` 不是两笔礼物。需要持久化计数的组件通常只消费
|
||||
`live.gift`;连击事件用于更新同一张视觉卡片。
|
||||
|
||||
## 表情分段
|
||||
|
||||
`live.danmaku.payload.segments` 是判别联合:
|
||||
|
||||
- `text`:包含可直接显示的文字。
|
||||
- `emoticon`:包含回退文字、图片 URL、可选尺寸、动态标记和整条大表情标记。
|
||||
|
||||
图片失败时客户端必须回退到 `text`,不能让一张失效图片破坏整条弹幕。
|
||||
|
||||
## 背压与重连
|
||||
|
||||
- 每个组件使用有界 `tokio::broadcast` channel。
|
||||
- 慢客户端发生 `Lagged` 时跳过已经丢失的旧帧,继续接收新事件;实时展示不保证历史重放。
|
||||
- 浏览器对非鉴权关闭使用指数退避重连,上限 12 秒。
|
||||
- 鉴权失败不会自动重试,避免对已撤销 token 形成无限请求。
|
||||
- 需要可靠业务处理的功能必须实现 `EventHandler` 并写入数据库,不能把 WebSocket 当作消息队列。
|
||||
@@ -0,0 +1,75 @@
|
||||
# 安全模型
|
||||
|
||||
本服务同时处理 Bilibili 登录 Cookie、TOTP Secret、一次性恢复码、管理员邀请码和 OBS
|
||||
token。以下规则是实现约束,而不是可选部署建议。
|
||||
|
||||
## 租户隔离
|
||||
|
||||
- HTTP handler 只从服务端会话解析 `owner_id`,不接受客户端声明的 owner。
|
||||
- 组件查询同时限定 `owner_user_id` 与 `source_id`。
|
||||
- 数据库使用 owner 复合外键、RLS 和 `FORCE ROW LEVEL SECURITY`。
|
||||
- tenant 查询必须在事务中执行 `SET LOCAL app.user_id`,不能使用会泄漏到连接池的 session-level
|
||||
`SET`。
|
||||
- 实时广播按 `component_id` 建立独立 channel,不提供全局订阅。
|
||||
- 路由器在投影发布前再次验证 owner、source 和 component ID。
|
||||
|
||||
## Secret 生命周期
|
||||
|
||||
| Secret | 浏览器可见性 | 数据库存储 | 轮换/消费 |
|
||||
| -------------------- | ------------------- | ----------------------- | ---------------- |
|
||||
| TOTP Secret | 注册时显示一次 | XChaCha20-Poly1305 密文 | 账户绑定 |
|
||||
| 恢复码 | 注册完成时显示一次 | SHA-256 摘要 | 单次消费 |
|
||||
| 登录 session | HttpOnly Cookie | SHA-256 摘要 | 到期、登出或撤销 |
|
||||
| CookieCloud Key/密码 | 用户提交时 | XChaCha20-Poly1305 密文 | 覆盖更新 |
|
||||
| 邀请码 | 创建时显示一次 | SHA-256 摘要和前缀 | 单次消费或撤销 |
|
||||
| OBS token | 创建/轮换时显示一次 | SHA-256 摘要 | 组件级轮换 |
|
||||
|
||||
`security.data_encryption_key`
|
||||
是恢复密文所必需的主密钥。它必须独立备份,但不能提交到 Git 或写入镜像。
|
||||
|
||||
## Passwordless 认证
|
||||
|
||||
- 第一个系统管理员只能通过数据库为空时的 bootstrap 流程创建。
|
||||
- bootstrap proof 不成为账户密码,也不能用于日常登录。
|
||||
- TOTP 接受有限时钟偏移,并持久化最近使用的 time step,阻止同一码重放。
|
||||
- 登录与匿名注册同时按账户维度和网络维度限流,错误消息不暴露用户名是否存在。
|
||||
- 注册先写入短期 pending enrollment;只有正确 TOTP 确认后才原子创建账户并消费邀请码。
|
||||
|
||||
## CookieCloud 与 SSRF
|
||||
|
||||
- 部署者通过 `cookiecloud_allowed_hosts` 指定精确的基础地址白名单。
|
||||
- URL 会规范化,并拒绝 embedded credentials、query 和 fragment。
|
||||
- Key 编码成单一路径段,不能注入额外路径。
|
||||
- HTTP 客户端禁止重定向,防止允许的地址跳转到内网目标。
|
||||
- 浏览器只看到 host 和 `keyConfigured`/`passwordConfigured`,不会读回凭据。
|
||||
- 日志不得把 `blivedm` 调到可能打印认证响应的详细级别。
|
||||
|
||||
## HTTP、WebSocket 与 OBS
|
||||
|
||||
- 公开部署必须在受信任反代终止 HTTPS,并保持 `secure_cookies = true`。
|
||||
- 生产 session 使用 `__Host-` Cookie、HttpOnly、Secure 与 SameSite 策略。
|
||||
- 写 API 执行 same-origin 检查并限制 body 大小。
|
||||
- 所有 `/api/*` 响应使用 `Cache-Control: no-store`。
|
||||
- OBS token 放在 URL fragment,并作为 WebSocket 第一帧发送。
|
||||
- token 只具有 `events:subscribe` scope,且只能订阅其绑定的组件。
|
||||
- WebSocket 首帧、frame size、认证时间和全局连接数均有上限。
|
||||
|
||||
## PWA 边界
|
||||
|
||||
- manifest、Service Worker 和 start URL 都限定在 `/control/`。
|
||||
- `/obs/*` 不在 Service Worker scope 内。
|
||||
- worker 只缓存静态应用壳层、图标和构建资源;不缓存 API、WebSocket 或直播事件。
|
||||
- 离线 mutation 直接失败,不使用 Background Sync。
|
||||
- 新 worker 仅在用户确认后激活。
|
||||
- TOTP、恢复码、邀请码、新 OBS 地址或未保存设置可见时,更新会被阻止。
|
||||
|
||||
## 上线检查表
|
||||
|
||||
- [ ] 使用独立随机 `data_encryption_key`,并在安全位置备份。
|
||||
- [ ] `config.toml` 权限为 `0600`,且已被 Git 和 Docker build context 排除。
|
||||
- [ ] PostgreSQL runtime role 可以执行迁移,但 PUBLIC 无权执行 SECURITY DEFINER helper。
|
||||
- [ ] `secure_cookies = true`,Nginx 正确传递 `X-Forwarded-Proto https`。
|
||||
- [ ] 应用仅监听 loopback,9719 未直接暴露公网。
|
||||
- [ ] CookieCloud 白名单只包含管理员批准的实例。
|
||||
- [ ] 日志中没有 Cookie、TOTP、token、邀请码或上游认证响应。
|
||||
- [ ] 轮换 OBS token 后,旧浏览器源立即断开且无法重连。
|
||||
Reference in New Issue
Block a user