live-sfu-demo/docs/business-logic.md

465 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SyncLive 业务逻辑文档
> 版本:2026-08-22 · 基于 `main` 分支代码整理 · 对标 SyncTV 的直播分流姐妹项目
---
## 1. 项目定位
**SyncLive = Sync + Live(一起播·一起看直播)**,对标 `SyncTV(Sync + TV 一起看)`。
- **一句话**:一路推流,多端同步扇出。控制面 `protobuf/gRPC`,媒体面 `WebRTC + HLS/FLV`。
- **双 SFU 分流**:同一路发布可同时落到 `Cloudflare Realtime(主 SFU,托管扇出)` 与 `SRS(本地对照,WHIP/WHEP/HLS/FLV 三协议同出)`。
- **设计约束**:浏览器只与服务端交换 SDP/信令,SFU 凭证不出服务端;房间拓扑持久化到嵌入式 libSQL,无外部依赖即可开箱。
```
publisher ──WHIP/tracks.new──▶ Cloudflare Realtime ──▶ viewer(s)
└────WHIP──────────▶ SRS ─┬─▶ WHEP (0.2-0.5s 低延时)
├─▶ HLS (5-10s,切片 /live/*.m3u8)
└─▶ FLV (http-flv)
```
---
## 2. 总体架构
```
浏览器 (SolidJS SPA, TanStack Router/Query, Ark UI, Tailwind v4)
│ HTTP / SSE / WebRTC(信令经服务端反代,媒体直连 SFU)
▼
控制面 (Go, :8088 HTTP + :9090 gRPC)
├─ gRPC + JSON 网关 (protojson,见 api/sync_live.proto)
├─ 鉴权 (Casbin RBAC + JWT + UserStore)
├─ 房间拓扑 (roomHub 内存 + libSQL 持久化 stream_targets)
├─ 房间成员/权限/播放列表 (room.Service + room.Store)
├─ 聊天弹幕 (chatHub 内存广播 + 滑动窗口限流)
└─ 媒体面反代 (httputil: /rtc/v1/* → SRS, /api/cf/* → Cloudflare, /live/* → SRS HLS)
▼
媒体面 (SFU Provider 抽象)
├─ Cloudflare Realtime Provider (REST Client + BackendInfo)
└─ SRS Provider (WHIP/WHEP 信令 + HLS 静态资源)
▼
数据层
├─ libSQL 嵌入式 file:./data/sync-live.db (WAL)
└─ data/users.json (用户库,bcrypt 哈希)
```
目录映射:
| 目录 | 职责 |
|------|------|
| `api/sync_live.proto` | 控制面唯一契约,经 `buf generate` 到 `gen/` |
| `internal/config` | 环境变量加载 + `Validate()` 密钥校验 + `ProviderList()` |
| `internal/db` | 嵌入式 libSQL 打开/迁移/CRUD (stream_targets, rooms_v2 等) |
| `internal/auth` | JWT 双令牌、用户文件存储、Casbin Enforcer、中间件 |
| `internal/room` | 房间/成员/角色/权限/播放列表的领域模型与存储 |
| `internal/sfu/cloudflare` | Cloudflare Realtime REST 客户端 |
| `internal/sfu/srs` | SRS Provider 元信息 |
| `internal/server` | HTTP/gRPC 装配、网关、反代、SSE、聊天、Manage 面 |
| `cmd/server` | 进程入口与优雅关闭 |
| `web/src` | SolidJS 前端,构建产物 `internal/server/static` 被 Go embed |
| `deploy` | `docker-compose.yml` + `srs.conf` |
---
## 3. 技术栈
- **后端**:Go 1.25, `google.golang.org/grpc + protobuf`, `casbin/casbin/v2`, `golang-jwt/jwt/v5`, `tursodatabase/go-libsql`, `golang.org/x/crypto/bcrypt`
- **前端**:SolidJS + TanStack Router/Query/Virtual + Ark UI + Tailwind v4 + Vite + `hls.js` + `lucide-solid` + `playwright` (截图)
- **SFU**:Cloudflare Realtime (`rtc.live.cloudflare.com/v1`), SRS 6 (`:1935/:1985/:8080/:8000`)
- **持久化**:libSQL file 模式,`MaxOpenConns=1`,WAL
---
## 4. 核心业务模块
### 4.1 房间 (Room)
**模型** `internal/room/room.go`:
| 字段 | 含义 |
|------|------|
| `name` | 主键,URL `?room=` 指定的房间名 |
| `display_name/description` | 展示用 |
| `creator` | 创建者 username |
| `status` | `active / closed / banned`,状态机 `CanTransitionTo` |
| `is_public` | 是否公开可发现 |
| `password_hash` | 可选房间密码 (bcrypt) |
| `require_approval` | 入房需审批 → 成员 `pending` |
| `max_members` | 上限默认 100 |
| `settings_json` | `RoomSettings` JSON,含细粒度权限覆盖 |
| `auto_play` | `enabled/mode/delay`,`sequential/repeat_one/repeat_all/shuffle` |
| `category_id/cover_file_id/label_ids` | 分类/标签/封面 (兼容 SyncTV) |
**房间服务** `internal/room/service.go`:
- `CreateRoom`:建 `rooms_v2` + 写入 `creator` 为 `RoleCreator` 的成员记录
- `JoinRoom`:校验 `banned/closed`、密码、人数上限、重复加入、封禁;`require_approval=true` 时置 `MemberPending`
- `EnsurePermission/CheckPermission`:按 `RoomSettings` 与成员 `PermissionSet` 判定单项权限;`closed/banned` 房间仅允许 `view_members/view_chat_history`
- `UpdateMemberRole/Kick/Ban/Leave/ApproveJoin/RejectJoin/TransferOwnership`:均校验 `PermManageMembers/PermRemoveMembers` 与 `Role.CanManage` 等级
**成员模型** `internal/room/member.go` + `role.go`:
- 角色 `RoleCreator(1) > RoleAdmin(2) > RoleMember(3) > RoleGuest(4)`,`Rank()` 分 100/80/50/10
- 状态 `MemberActive / Banned / Pending / Kicked`
- 权限位 `PermissionSet uint64`,在 `added/removed/admin_added/admin_removed` 四列上做增量覆盖
### 4.2 鉴权与 RBAC (Auth)
**用户存储** `internal/auth/store.go` + `data/users.json`:
- 文件锁 + 内存 map,`Create` 时 bcrypt 哈希、`Verify` 比对
- `BootstrapAdmin`:用户库为空且 `BOOTSTRAP_ADMIN_USER/PASS` 已设时创建首个 `admin`(可选 `BOOTSTRAP_ADMIN_ROLE=root` 提升为 super admin)
- 字段:`username / hash / role / status(active|banned) / created_at`
**JWT** `internal/auth/jwt.go`:
- `JWTManager{secret, ttl=2h, refreshTTL=7d, issuer="sync-live"}`,HS256
- `Sign(username, role)` → `access token (TokenType=access)`;`SignRefresh` → `refresh token`
- `Verify / VerifyRefresh / IssuePair / Refresh`;`Validate()` 要求 `JWT_SECRET` 非空,否则拒绝启动
**Casbin** `internal/auth/model.conf + policy.csv + enforcer.go`:
- 模型:`r=sub,obj,act | p=sub,obj,act | g=_,_ | e=some(p.eft==allow) | m=g(r.sub,p.sub) && r.obj==p.obj && r.act==p.act`
- 策略表(节选):
- `root/admin/publisher/viewer/guest` 五档,`guest` 仅 `config:read + srs:streams + room:chat`
- `viewer` 可 `room:subscribe/watch/chat`
- `publisher` 可 `room:publish/stop`
- `admin/root` 可 `room:manage + user:list/manage + system:manage`
- `Enforcer.AddUserRole` 在登录/改角色时同步 `g` 关系
**中间件** `internal/auth/middleware.go`:
- 从 `Authorization: Bearer / Cookie(token|access_token|refresh_token) / ?token=` 提取 token
- 注入 `context.Context` 的 `AuthedUser{Username, Role, Token}`
- `AuthorizeMiddleware(obj, act, needAuth)`:`guest` 放行 `config:read + srs:streams + room:chat`,其余走 `Enforce`
### 4.3 房间分发拓扑 (roomHub + stream_targets)
**内存 Hub** `internal/server/rooms.go`:
- `roomHub{rooms map[name]*roomEntry, db *sql.DB}`,`roomEntry{targets map[backend]*StreamTarget, subs map[chan]struct{}}`
- `setTarget(room, backend, target)`:写入内存 → `broadcast(room)` → 异步 `db.SaveTarget`
- `removeTarget`:删 backend,空房间且无订阅者则删 entry + `db.DeleteTarget`
- `subscribe(room)`:每房间 8 缓冲 channel,`broadcast` 非阻塞 fan-out
- `broadcastEvent(room, type, payload)`:复用同一订阅通道,以 `{"type": "...", "payload": ...}` 信封推送 `playlist_update / playback` 等通用事件
- `newRoomHubWithDB` 启动时 `db.LoadAll` 恢复全量房间
**持久化** `internal/db/db.go`:
- `Open(dsn)` 仅允许 `file:` / `:memory:`,自动 `MkdirAll` + `Migrate`
- 表:
- `stream_targets(room, backend PK, session_id, stream, publish_token, url, published_at)` + `idx_room`
- `rooms(name PK, created_at, updated_at)` (轻量索引)
- `rooms_v2 / room_members / room_bans / room_join_requests / room_categories / room_labels / user_bans / user_registration_requests`
- `SaveTarget = INSERT OR REPLACE` + 维护 `rooms` 占位;`DeleteTarget` 空房间时删 `rooms`
**控制面 API** `internal/server/service.go + gateway.go`:
- `GetConfig`:按 `SFU_PROVIDER` 顺序组 `BackendInfo[] + candidate + token_required`
- `Publish(room, backend, identity)`:
- `cloudflare`:`CreateSession(room) → GetSession → target{session_id}`;未配置时直接报错
- `srs`:`stream=live-<room>` + `signStreamToken(secret, stream, identity, "publish", 2h)` → `target{stream, publish_token, url=/rtc/v1/whep/?app=live&stream=...}`
- 统一 `hub.setTarget` 并广播
- `Subscribe(room, backend)`:
- `cloudflare`:新建 viewer session,返回 `session_id + publisher_session_id + ice_servers`
- `srs`:返回 `stream + ice_servers(stun)`,观看侧 WHEP 与 HLS 共用同一 `stream`
- `StopStream(room, backend)`:`cloudflare` 删发布 session + `hub.removeTarget`
- `WatchRoom (gRPC stream) / GET /api/room/{room}/events (SSE)`:首包即时推送 `targets`,后续 fan-out + 20s 心跳全量重推
- `handleRoomsQuery GET /api/rooms/query`:支持 `page/page_size/search/status/creator/is_public/sort_by/sort_direction`,优先走 `room.Store.QueryRooms` (DB),否则回退内存 hub
### 4.4 媒体面反代与 SFU
**SRS** `internal/server/proxy.go + deploy/srs.conf`:
- `srsProxyHandler`:`SRS_API_BASE (默认 :1985)` 的 WHIP/WHEP 信令反代;`SFU_TOKEN_REQUIRED=1` 时校验 `?token=` 为 `role=publish` 且 `room` 一致的 HS256 JWT
- `srsHlsProxyHandler`:`SRS_HTTP_BASE (默认 :8080)` 的 HLS/FLV 静态切片反代,挂 `GET/HEAD /live/`,自动补 CORS `Allow-Origin: *`,`OPTIONS` 204
- SRS 配置:`http_server :8080` 切片落盘 `objs/nginx/html`,`http_api :1985` 信令,`rtc_server :8000` 媒体,`vhost __defaultVhost__ { rtc + http_remux(flv) + hls(fragment 10s, window 60s) }`
**Cloudflare** `internal/sfu/cloudflare/client.go + provider.go`:
- `Provider{client, appID, stun, configured}`,`BackendInfo{kind=CLOUDFLARE, name="Cloudflare Realtime", configured, primary=true}`
- `cfProxyHandler`:将 `/api/cf/*` 转 `CF_BASE_URL/apps/<CF_APP_ID>/*`,注入 `Authorization: Bearer CF_APP_SECRET`,浏览器只做 `tracks/new` SDP 交换
**前端媒体链路**:
- 推流:`RTCPeerConnection → createOffer → POST /rtc/v1/whip/?app=live&stream=live-<room>&token=... (信令经网关)` → `setRemoteDescription(answer)`,成功后展示 `hlsUrl=/live/<stream>.m3u8 + flvUrl`
- 观看:
- `WHEP`:`POST /rtc/v1/whep/?app=live&stream=...` 同流程,`ontrack → <video>`
- `HLS`:`video.canPlayType('application/vnd.apple.mpegurl')` 走原生,否则 `hls.js loadSource/attachMedia`;观看页三档切 `cloudflare / srs(whep) / srs-hls`
### 4.5 聊天弹幕
**hub** `internal/server/chat.go`:
- `chatMessage{id, room, user, role, message, color, host, ts}`;`chatHub{rooms map[room]map[chan]struct{}, recentMsgs map[room][]*chatMessage}`,`recentLimit=50`
- `subscribe(room)` 16 缓冲;`broadcast` 进 `recentMsgs` 并裁剪;`recent(room)` 快照回放
- 空房间无订阅者时清 `recentMsgs`,防内存无界
- `GET /api/room/{room}/chat (SSE)`:进场回放 recent + 25s ping;`POST /api/room/{room}/chat` 与 `POST /api/room/{room}/broadcast` (主播专用,`host=true`) 均经 `chatHub.broadcast`
- 限流 `chatRateLimiter(limit=5, window=3s)`,key=`room/user/ip` (broadcast 另缀 `/broadcast/`),滑动窗口 + 分钟级 `cleanup`
- `clientIP` 取 `X-Forwarded-For[0] / X-Real-IP / RemoteAddr`
**权限**:`POST /chat` 需 `room:chat` (guest 亦可);`POST /broadcast` 需 `room:publish` (publisher/admin),走 `authWrap(..., needAuth=true)`
### 4.6 播放列表与同步播放
`internal/server/playlist.go + internal/room/playlist.go`:
- `PlaylistItem{room, url, title, added_by}`,`PlaybackState{room, position, playing, rate, current_item, updated_by}`
- `GET /api/room/{room}/playlist` 列件;`POST` (需 `PermControlStream`) 增件并 `broadcastEvent("playlist_update", items)`;`DELETE ?id=` 删件同广播
- `GET /api/room/{room}/playback` 读状态(无则 `rate=1`);`POST/PUT` 需 `PermControlStream`,`rate<=0` 归一,落库后 `broadcastEvent("playback", ps)`,观看端经同一 SSE 连接按 `event: playlist_update / playback` 分发
### 4.7 房间内权限位
`internal/room/permission.go`:
- 16 项:`send_chat / view_chat_history / view_members / subscribe / watch_room / publish / stop_stream / broadcast / manage_members / add_members / remove_members / manage_permissions / manage_room / delete_room / delete_chat_messages / control_stream`
- `PermissionSet uint64` 位集,`Grant/Revoke/Has/Names`
- 默认集:
- `PermDefaultCreator = All`
- `PermDefaultAdmin` = 除 `delete_room` 外大部分
- `PermDefaultMember` = `chat/view/subscribe/watch/publish/stop`
- `PermDefaultGuest` = `view_chat/view_members/subscribe/watch`;`PermGuestAssignable = Guest + send_chat`
- `RoomSettings.Admin/Member/GuestPermissions()` 在 `Default*` 上叠加 `added/removed` 增量
### 4.8 管理后台 (Manage)
`internal/server/manage.go` 挂于 `/api/manage/*`,均 `authWrap(needAuth=true)`:
- `GET /api/manage/overview`:汇总计数
- `GET/POST /api/manage/rooms`:列/建房间(含 `is_public/require_approval/max_members/password/display_name/description`)
- `GET/PUT/PATCH/DELETE /api/manage/rooms/{room}`:详情/改设置/删房
- `GET/POST /api/manage/rooms/{room}/members`、`PUT/DELETE .../members/{user}`、`POST .../perms`、`POST .../approve|reject`、`POST .../transfer`:成员增删改、权限覆盖、审批、转让房主 (`RoleCreator` 互转)
- `GET /api/manage/permissions[/{room}]`:权限矩阵
- `GET/PUT /api/manage/rooms/{room}/settings`、`POST .../join|leave`:房间设置与自助进出
---
## 5. 关键流程时序
### 5.1 登录 / 注册 / 鉴权
1. `POST /api/auth/login|register {username,password,role?}` → `UserStore.Verify/Create` → `JWT.Sign` → `Set-Cookie: token=... HttpOnly + {token,user,expires_at}`
2. 后续请求带 `Cookie` 或 `Authorization: Bearer`,`AuthorizeMiddleware` → `JWT.Verify` → 注入 `AuthedUser` → `Enforcer.Enforce(sub,obj,act)`
3. `POST /api/auth/refresh` 用 `refresh_token` 换新 `access`;`GET /api/auth/me` 返回当前用户与 `expires_at/issued_at`
4. 未登录 `guest` 默认仅 `config:read` 与 `srs:streams` 与 `room:chat` 放行,其余 401/403
### 5.2 房间生命周期
1. `POST /api/rooms {name}` 或 `POST /api/manage/rooms` → `hub.createRoom` (内存占位);若走 `room.Service.CreateRoom` 则同时落 `rooms_v2 + room_members(creator)`
2. `GET /api/rooms | GET /api/rooms/query?...` 分页查;`GET /api/manage/rooms/{room}` 看成员与 `targets(live)`;`GET /live/*.m3u8` 经网关拉 HLS 无需订阅
3. 转让:`POST /api/manage/rooms/{room}/transfer {target}` 需当前 `RoleCreator`,事务内改 `rooms_v2.creator` 并互换 `creator↔admin` 角色
4. 关闭/封禁:`RoomStatus` 流转 `active↔closed/banned`,`EnsurePermission` 据此限权
### 5.3 发布 (Publish)
```
前端 → POST /api/publish {room, backend} (需 room:publish)
→ Service.Publish 按 backend 分流
├─ cloudflare: CreateSession(room) → target{session_id}
└─ srs: stream=live-<room>, token=HS256(room,identity,publish,2h) → target{stream,token,url}
→ hub.setTarget(room, backend, target) → SSE 广播 RoomEvent{targets}
→ 返回 {session_id/stream/publish_token/ice_servers/target}
前端 → RTCPeerConnection offer → POST /rtc/v1/whip/?app=live&stream=...&token=... (SRS 反代校验) 或 POST /api/cf/.../tracks/new (CF 反代注入 Bearer)
→ setRemoteDescription(answer) → 推流成功,展示 HLS/FLV 链接
```
### 5.4 订阅 / 观看
```
前端 → POST /api/subscribe {room, backend} (需 room:subscribe)
→ hub.findTarget(room, backend) 取发布侧 target
├─ cloudflare: CreateSession(room) viewer → {session_id, publisher_session_id, ice_servers}
└─ srs: {stream, ice_servers}
前端 → 按 backends[].configured 展示线路,未配置标「未配置」
├─ cloudflare: tracks/new location=remote
├─ srs-whep: POST /rtc/v1/whep/?app=live&stream=live-<room> → ontrack
└─ srs-hls: GET /live/live-<room>.m3u8 → hls.js / 原生
→ 同时订阅 GET /api/room/{room}/events (SSE) + GET /api/room/{room}/chat (SSE),拓扑/弹幕/播放状态同链路按 event type 分发
```
### 5.5 弹幕与主播广播
```
观众 POST /api/room/{room}/chat {message,color} (room:chat)
→ 取身份 user/role/id (guest 可发)
→ chatRL.allow(room/user/ip, 5/3s) 限流
→ chatHub.broadcast → SSE fan-out (event: chat) → 前端 danmaku 叠加
主播 POST /api/room/{room}/broadcast {message,color} (room:publish, 需 Bearer)
→ 同限流 key=room/broadcast/user/ip
→ chatMessage{host:true} → 同一 hub 广播,观众/主播端同可见
```
### 5.6 停止
`POST /api/stop {room, backend} (room:stop)` → `cloudflare` 删 `session_id` + `hub.removeTarget(room, backend)` → 广播空 targets → 前端自动切离或提示下播
---
## 6. 数据模型与契约
### 6.1 Protobuf 契约 `api/sync_live.proto`
- 服务 `SyncLive`:`GetConfig / ListRooms / Publish / Subscribe / StopStream / WatchRoom(stream) / Login / Register / GetMe / ListUsers / UpdateUserRole`
- 枚举 `BackendKind`: `UNSPECIFIED(0) / CLOUDFLARE(1) / SRS(2)`
- 消息:`BackendInfo{kind,name,configured,primary}`, `IceServer{urls,username,credential}`, `StreamTarget{backend,session_id,stream,publish_token,url,published_at}`, `Room{name,targets[]}`, `RoomEvent{room,targets[]}`, `UserInfo{username,role,created_at}`, `Login/Register/GetMe/ListUsers/UpdateUserRole` 族
### 6.2 libSQL 表
- `stream_targets`:房间分发的事实表,`PRIMARY KEY(room,backend)`
- `rooms / rooms_v2`:`rooms` 为轻量拓扑索引,`rooms_v2` 为完整房间档案(含 `settings_json`)
- `room_members(room,user_id PK, username, role, status, added/removed/admin_added/admin_removed, joined_at, version)` + 两索引
- `room_bans / room_join_requests / room_categories / room_labels / user_bans / user_registration_requests`
- 增量兼容:启动时 `ALTER TABLE rooms_v2 ADD COLUMN category_id/cover_file_id/label_ids` 忽略已存在错误
### 6.3 用户库 `data/users.json`
- `map[username]*User{username, hash(bcrypt), role, status, created_at}`,文件锁读写,`BOOTSTRAP_ADMIN_*` 冷启动种子
---
## 7. API 清单
### 7.1 JSON 网关 (同 HTTP 端口,protojson)
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | `/api/config` | `config:read` guest可 | 后端能力与 `candidate/token_required` |
| GET | `/api/rooms` | `room:list` | 全量房间 (hub) |
| GET | `/api/rooms/query?...` | `room:list` | 分页查询 (DB 优先) |
| POST | `/api/rooms` | `room:list` | 创建占位 |
| POST | `/api/publish` | `room:publish` | 发布 (多后端) |
| POST | `/api/subscribe` | `room:subscribe` | 订阅 |
| POST | `/api/stop` | `room:stop` | 下播 |
| GET | `/api/srs/streams` | `srs:streams` guest可 | 代理 `SRS :1985 /api/v1/streams/` |
| GET | `/api/room/{room}/events` | `room:watch` SSE | 房间拓扑 |
| GET | `/api/room/{room}/playlist` | `room:watch` | 列播放列表 |
| POST | `/api/room/{room}/playlist` | `room:watch` + `control_stream` | 增件 |
| DELETE | `/api/room/{room}/playlist?id=` | 同上 | 删件 |
| GET | `/api/room/{room}/playback` | `room:watch` | 读同步状态 |
| POST/PUT | `/api/room/{room}/playback` | 同上 | 写同步状态 |
| GET | `/api/room/{room}/chat` | `room:chat` SSE guest可 | 弹幕订阅 + recent 回放 |
| POST | `/api/room/{room}/chat` | `room:chat` guest可 | 发弹幕 |
| POST | `/api/room/{room}/broadcast` | `room:publish` | 主播广播 (`host:true`) |
### 7.2 认证
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/auth/login` | 登录,签 access+refresh,种 HttpOnly Cookie |
| POST | `/api/auth/register` | 注册,`ALLOW_REGISTER=1` 时放行,`admin/publisher` 需 admin 授权否则降为 viewer |
| POST | `/api/auth/logout` | 清 Cookie |
| POST | `/api/auth/refresh` | 用 refresh 换 access |
| GET | `/api/auth/me` | 当前用户 |
| GET | `/api/auth/users` | 列用户 (admin) |
| POST | `/api/auth/users/role` | 改角色 (`only root can grant root`, admin 不能提权他人为 admin/root) |
| POST | `/api/auth/users/ban|unban` | 封/解封 (admin/root) |
| GET | `/api/auth/check` | 前端权限探针 |
### 7.3 管理面 `/api/manage/*`
见 4.8 节,覆盖 `overview/rooms/rooms/{room}/members/permissions/settings/join/leave/transfer` 全链路。
### 7.4 媒体反代
| 方法 | 路径 | 目标 | 说明 |
|------|------|------|------|
| ANY | `/rtc/v1/*` | `SRS_API_BASE (:1985)` | WHIP/WHEP 信令,可选 `?token` 校验 |
| ANY | `/api/cf/*` | `CF_BASE_URL/apps/<CF_APP_ID>/*` | Cloudflare Realtime,注 Bearer |
| GET/HEAD | `/live/*` | `SRS_HTTP_BASE (:8080)` | HLS/FLV 切片,补 CORS |
### 7.5 gRPC `:9090`
同 `SyncLive` 服务,`UnaryAuthInterceptor + StreamAuthInterceptor` 校验 `room/user/config/srs/system` 等 `obj:act`。
---
## 8. 权限矩阵
### 8.1 全局 Casbin (policy.csv)
| 角色 | 能力 |
|------|------|
| `root` | 全量:`config:read, room:list/publish/subscribe/stop/watch/chat/manage/delete, user:list/manage, srs:streams, system:manage` |
| `admin` | 除 `system:manage/room:delete` 外全量 |
| `publisher` | `config:read + room:list/publish/subscribe/stop/watch/chat + srs:streams` |
| `viewer` | `config:read + room:list/subscribe/watch/chat + srs:streams` |
| `guest` | `config:read + srs:streams + room:chat` (弹幕可发,拉流只看 HLS) |
### 8.2 房间内位权限 (PermissionSet)
见 4.7 表,房间设置可在默认集上按 `admin/member/guest` 三档增删位,且受 `Role.CanManage` 等级约束。关键校验入口 `room.Service.EnsurePermission`。
---
## 9. 配置与部署
### 9.1 环境变量 (env > 默认)
| 变量 | 默认 | 说明 |
|------|------|------|
| `HTTP_PORT/GRPC_PORT` | `8088/9090` | 控制面端口 |
| `JWT_SECRET` | (必填) | 正式环境必须显式配置,否则 `Validate` 拒绝启动 |
| `SFU_TOKEN_SECRET` | 同 JWT | 推流 JWT 密钥,空则复用 JWT |
| `SFU_TOKEN_REQUIRED` | `0` | `1` 时 WHIP 强制 `?token` 校验 |
| `SFU_PROVIDER` | `cloudflare,srs` | 后端顺序 |
| `SRS_API_BASE/SRS_HTTP_BASE/SRS_APP/SRS_CANDIDATE` | `http://localhost:1985 / :8080 / live / 127.0.0.1` | SRS 链路 |
| `CF_APP_ID/CF_APP_SECRET/CF_BASE_URL/CF_STUN_URL` | (空) / `https://rtc.live.cloudflare.com/v1` / `stun:stun.cloudflare.com:3478` | Cloudflare 链路 |
| `TURSO_DATABASE_URL/DATABASE_URL` | `file:./data/sync-live.db?cache=shared&_journal_mode=WAL` | 仅 file/`:memory:`,拒 `libsql://` |
| `AUTH_USER_FILE/CASBIN_MODEL/CASBIN_POLICY` | `data/users.json / internal/auth/model.conf / policy.csv` | 认证文件 |
| `ALLOW_REGISTER` | `1` | 是否开放注册 |
| `BOOTSTRAP_ADMIN_USER/PASS/ROLE` | (空) | 冷启动种子,仅空库生效 |
| `SMTP_*/EMAIL_VERIFY_*/OAUTH_*/AUTHBOSS_ENABLED` | (多为 `0`/空) | 邮件/验证/OAuth 预留开关 |
### 9.2 本地启动
```bash
cp .env.example .env # 填 JWT_SECRET 等
cd deploy && SRS_CANDIDATE=127.0.0.1 docker compose up -d srs
go run ./cmd/server # http://localhost:8088 /publish?room=xxx /watch?room=xxx /login /manage
# 前端热更新
cd web && pnpm i && pnpm dev # :5173
pnpm build # 产到 internal/server/static (Go embed)
```
### 9.3 SRS 部署要点
- `http_server :8080` 切片目录与 `hls_path` 一致;`hls_fragment 10 / window 60`;公网部署 `SRS_CANDIDATE=公网IP` 并重建容器
- 生产可不暴露 `8080`,仅保留 `1985/8000`,前端统一经 `8088 /live/*` 拉流
- HLS 首屏 10-20s,重试 `curl /live/<stream>.m3u8` 与 `docker exec ls objs/nginx/html/live` 排障
---
## 10. 前端
- 路由 `web/src/app/router.tsx`:`TanStack Router + RootRoute(Layout) + lazy(code-split)`,路由 `/ /publish?room= /watch?room= /room?room= /rooms /users /manage /login`,均为 SPA 回退 `index.html`
- 状态:`TanStack Query` 封装 `api.ts` 的 `fetch /api/* (protojson)`;SSE 封装 `sse.ts/chat.ts`,`GET /api/room/{room}/events` 与 `/chat` 并行订阅,按 `event: room/chat/playlist_update/playback` 分发
- 媒体:`webrtc.ts` 封装 WHIP/WHEP 的 `RTCPeerConnection` 流程,`hls.ts` 封装 `hls.js` 回退
- 组件:`components/danmaku.tsx` 叠加层,`components/manage/*` 管理后台分 `Overview/Rooms/Permissions/System/UsersInline`
- 构建:`vite.config.ts` 输出 `../internal/server/static`,`internal/server/server.go:embed static` 内联,`GET / /publish /watch /login /manage` 均回 `static/index.html`
---
## 11. 安全与限流
- 正式环境密钥强校验:缺 `JWT_SECRET` 或 `SFU_TOKEN_REQUIRED` 却无 `SFU_TOKEN_SECRET` 直接退错
- 密码:用户 `bcrypt`,房间密码 `bcrypt` (`HashRoomPassword`)
- 推流 token:`token.go` 自实现 `HS256(b64url header + payload).hmacSHA256(secret)`,`{room,identity,role,iat,exp}`,TTL 2h
- 弹幕限流:每 `room/user/ip` 5 条 / 3s 滑动窗口,超限 `429`,后台分钟级清过期 key;单条 500 字符截断
- 鉴权覆盖:HTTP `authWrap` 与 gRPC `Unary/StreamInterceptor` 双轨,`guest` 白名单最小化
---
## 12. 关联文档与验证
- 推流链路详述:`docs/streaming-pipeline.md`(含 SRS `srs.conf` 全量、反代代码、前端 `publishSRS/watchSRS/watchHLS` 选型表与 `curl/ffplay` 验证步骤)
- 架构分层:`design.md`
- 协议契约:`api/sync_live.proto` + `buf.gen.yaml`
- 快速验证:发布页推流后 `curl -i http://localhost:8088/live/live-demo.m3u8` / `curl -I .../live-demo.flv` / `ffplay ...`,SRS 日志与切片目录联合排障
---
*维护提示:新增后端或房间字段时,同步更新 `api/*.proto → gen/`、`internal/db.Migrate / room.Store.InitSchema`、`policy.csv` 与本文件对应小节;房间拓扑的持久化与 SSE 广播是分流可见性的核心链路,改动前优先补 `grpc_test.go / auth_test.go` 用例。*