diff --git a/docs/business-logic.md b/docs/business-logic.md new file mode 100644 index 0000000..b0e45bf --- /dev/null +++ b/docs/business-logic.md @@ -0,0 +1,464 @@ +# 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 →