# 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-` + `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//*`,注入 `Authorization: Bearer CF_APP_SECRET`,浏览器只做 `tracks/new` SDP 交换 **前端媒体链路**: - 推流:`RTCPeerConnection → createOffer → POST /rtc/v1/whip/?app=live&stream=live-&token=... (信令经网关)` → `setRemoteDescription(answer)`,成功后展示 `hlsUrl=/live/.m3u8 + flvUrl` - 观看: - `WHEP`:`POST /rtc/v1/whep/?app=live&stream=...` 同流程,`ontrack →