Compare commits

..

No commits in common. "c7f70970acf56c826353350fcf56bc2799f8294b" and "1a54286d6948cf74c7ab54e956f306a2e383b943" have entirely different histories.

41 changed files with 77 additions and 372 deletions

View File

@ -27,7 +27,7 @@ SyncLive(同步直播,对标 SyncTV)的整体架构与模块划分说明
- `internal/db/`:嵌入式 libSQL 持久化(房间分发拓扑),仅 file:/:memory: 模式。
- `internal/auth/`:Casbin RBAC 模型与策略、JWT 签发校验、用户存储。
- `internal/sfu/`:SFU 后端 Provider 抽象(Cloudflare / SRS),统一 `BackendInfo` 与能力查询。
- `internal/server/`:HTTP/gRPC 服务装配、JSON 网关、房间与聊天 SSE(含 `GET /api/rooms/events` 全局房间 SSE 与 `GET /api/room/{room}/events` 单房间拓扑 SSE)、媒体反代、静态 UI 嵌入。
- `internal/server/`:HTTP/gRPC 服务装配、JSON 网关、房间与聊天 SSE、媒体反代、静态 UI 嵌入。
- `cmd/server/`:进程入口,装配配置、服务与优雅关闭。
- `web/`:前端单页应用(SolidJS + TanStack + Ark UI + Tailwind v4),构建产物由 Go embed 内联。

View File

@ -328,7 +328,6 @@ publisher ──WHIP/tracks.new──▶ Cloudflare Realtime ──▶ viewer(s)
| GET | `/api/config` | `config:read` guest可 | 后端能力与 `candidate/token_required` |
| GET | `/api/rooms` | `room:list` | 全量房间 (hub) |
| GET | `/api/rooms/query?...` | `room:list` | 分页查询 (DB 优先) |
| GET | `/api/rooms/events` | `room:list` SSE | 全局房间变更(create/update/delete/sync,`event: rooms`),供 /rooms 实时刷新 |
| POST | `/api/rooms` | `room:list` | 创建占位 |
| POST | `/api/publish` | `room:publish` | 发布 (多后端) |
| POST | `/api/subscribe` | `room:subscribe` | 订阅 |

View File

@ -31,10 +31,9 @@ func TestDistributorListDedup(t *testing.T) {
}
func TestValidateCDNRequiresURLWhenEnabled(t *testing.T) {
// 运行时允许 cdn 占位:未配置 RTMP 仅影响分发就绪,不阻断配置保存
c := &Config{Distributors: "cdn", CDNRTMPURL: ""}
if err := c.ValidateDistributors(); err != nil {
t.Fatalf("cdn占位不应阻断配置保存,got %v", err)
if err := c.ValidateDistributors(); err == nil {
t.Fatal("expected error when cdn enabled without URL")
}
c2 := &Config{Distributors: "cdn", CDNRTMPURL: "rtmp://cdn.example.com/live"}
if err := c2.ValidateDistributors(); err != nil {
@ -44,13 +43,6 @@ func TestValidateCDNRequiresURLWhenEnabled(t *testing.T) {
if err := c3.ValidateDistributors(); err != nil {
t.Fatalf("should not require CDN URL when cdn not in list, got %v", err)
}
// 分发维度的真实就绪由 Provider.Configured / DistributionTarget.status 表达
if c.CDNEnabled() {
t.Fatal("空 CDN_RTMP_URL 不应视为已启用")
}
if !c2.CDNEnabled() {
t.Fatal("非空 CDN_RTMP_URL 应视为已启用")
}
}
func TestCDNEnabled(t *testing.T) {

View File

@ -374,7 +374,6 @@ func (s *Server) handleAuthCheck(w http.ResponseWriter, r *http.Request) {
{"user:manage", "user", "manage"},
{"system:manage", "system", "manage"},
{"config:read", "config", "read"},
{"config:write", "config", "write"},
{"srs:streams", "srs", "streams"},
} {
ok, _ := s.auth.Check(claims.Username, claims.Role, c.obj, c.act)

View File

@ -118,41 +118,6 @@ func (s *Server) handleRoomEvents(w http.ResponseWriter, r *http.Request) {
}
}
// handleRoomsEvents 以 SSE 推送全局房间列表变更(创建/删除/LIVE 状态变化),供 /rooms 实时刷新。
func (s *Server) handleRoomsEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ch, unsub := s.hub.subscribeRooms()
defer unsub()
// initial sync hint
fmt.Fprintf(w, "event: rooms\ndata: {\"type\":\"rooms\",\"event\":\"sync\",\"ts\":%d}\n\n", time.Now().Unix())
flusher.Flush()
ticker := time.NewTicker(25 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case msg := <-ch:
fmt.Fprintf(w, "event: rooms\ndata: %s\n\n", msg)
flusher.Flush()
case <-ticker.C:
fmt.Fprintf(w, ": ping\n\n")
flusher.Flush()
}
}
}
func (s *Server) handleSRSStreams(w http.ResponseWriter, r *http.Request) {
resp, err := http.Get(s.cfg.SRSBaseURL + "/api/v1/streams/")
if err != nil {

View File

@ -111,7 +111,6 @@ func (s *Server) handleManageRooms(w http.ResponseWriter, r *http.Request) {
created.PasswordHash = hash
}
s.hub.createRoom(name)
s.hub.broadcastRooms("create", name)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "room": created})
default:

View File

@ -19,7 +19,6 @@ type roomHub struct {
mu sync.RWMutex
rooms map[string]*roomEntry
db *sql.DB
globalSubs map[chan []byte]struct{}
}
type roomEntry struct {
@ -29,12 +28,12 @@ type roomEntry struct {
}
func newRoomHub() *roomHub {
return &roomHub{rooms: map[string]*roomEntry{}, globalSubs: map[chan []byte]struct{}{}}
return &roomHub{rooms: map[string]*roomEntry{}}
}
// newRoomHubWithDB 创建带嵌入式 libSQL 持久化的 hub,启动时自动从 DB 加载全量房间。
func newRoomHubWithDB(database *sql.DB) *roomHub {
h := &roomHub{rooms: map[string]*roomEntry{}, db: database, globalSubs: map[chan []byte]struct{}{}}
h := &roomHub{rooms: map[string]*roomEntry{}, db: database}
if database != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
@ -74,9 +73,6 @@ func (h *roomHub) createRoom(name string) {
h.mu.Lock()
if _, ok := h.rooms[name]; !ok {
h.rooms[name] = &roomEntry{targets: map[string]*gen.StreamTarget{}, distributions: map[string]*gen.DistributionTarget{}, subs: map[chan []byte]struct{}{}}
h.mu.Unlock()
h.broadcastRooms("create", name)
return
}
h.mu.Unlock()
}
@ -85,7 +81,6 @@ func (h *roomHub) setTarget(room, backend string, t *gen.StreamTarget) {
e := h.get(room)
e.targets[backend] = t
h.broadcast(room, e)
h.broadcastRoomsLocked("update", room)
h.mu.Unlock()
if h.db != nil {
@ -107,7 +102,6 @@ func (h *roomHub) removeTarget(room, backend string) {
delete(e.targets, backend)
if len(e.targets) == 0 && len(e.subs) == 0 {
delete(h.rooms, room)
h.broadcastRoomsLocked("delete", room)
h.mu.Unlock()
if h.db != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
@ -119,7 +113,6 @@ func (h *roomHub) removeTarget(room, backend string) {
return
}
h.broadcast(room, e)
h.broadcastRoomsLocked("update", room)
h.mu.Unlock()
if h.db != nil {
@ -214,47 +207,6 @@ func (h *roomHub) broadcastEvent(room, eventType string, payload interface{}) {
h.mu.RUnlock()
}
// ---- 全局房间列表广播(供 /api/rooms/events 订阅) ----
func (h *roomHub) subscribeRooms() (chan []byte, func()) {
h.mu.Lock()
defer h.mu.Unlock()
if h.globalSubs == nil {
h.globalSubs = map[chan []byte]struct{}{}
}
ch := make(chan []byte, 8)
h.globalSubs[ch] = struct{}{}
return ch, func() {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.globalSubs, ch)
}
}
func (h *roomHub) broadcastRooms(event, room string) {
h.mu.RLock()
defer h.mu.RUnlock()
h.broadcastRoomsLocked(event, room)
}
func (h *roomHub) broadcastRoomsLocked(event, room string) {
if len(h.globalSubs) == 0 {
return
}
payload, _ := json.Marshal(map[string]interface{}{
"type": "rooms",
"event": event,
"room": room,
"ts": time.Now().Unix(),
})
for ch := range h.globalSubs {
select {
case ch <- payload:
default:
}
}
}
func (h *roomHub) distributions(room string) []*gen.DistributionTarget {
h.mu.RLock()
defer h.mu.RUnlock()
@ -278,7 +230,6 @@ func (h *roomHub) setDistribution(room string, d *gen.DistributionTarget) {
key := d.GetKind().String()
e.distributions[key] = d
h.broadcast(room, e)
h.broadcastRoomsLocked("update", room)
h.mu.Unlock()
}
@ -287,7 +238,6 @@ func (h *roomHub) clearDistributions(room string) {
if e, ok := h.rooms[room]; ok {
e.distributions = map[string]*gen.DistributionTarget{}
h.broadcast(room, e)
h.broadcastRoomsLocked("update", room)
}
h.mu.Unlock()
}
@ -296,11 +246,4 @@ func (h *roomHub) removeRoom(name string) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.rooms, name)
payload, _ := json.Marshal(map[string]interface{}{"type": "rooms", "event": "delete", "room": name, "ts": time.Now().Unix()})
for ch := range h.globalSubs {
select {
case ch <- payload:
default:
}
}
}

View File

@ -195,7 +195,6 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/auth/check", s.authWrap(http.HandlerFunc(s.handleAuthCheck), "config", "read", false))
// 对标 SyncTV 的分页查询:GET /api/rooms/query?page=1&page_size=20&search=&status=active
mux.Handle("GET /api/rooms/query", s.authWrap(http.HandlerFunc(s.handleRoomsQuery), "room", "list", true))
mux.Handle("POST /api/rooms/events", s.authWrap(http.HandlerFunc(s.handleRoomsEvents), "room", "list", true))
mux.Handle("GET /api/config", s.authWrap(http.HandlerFunc(s.handleConfig), "config", "read", false))
mux.Handle("GET /api/rooms", s.authWrap(http.HandlerFunc(s.handleRooms), "room", "list", true))
@ -204,13 +203,13 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/subscribe", s.authWrap(http.HandlerFunc(s.handleSubscribe), "room", "subscribe", true))
mux.Handle("POST /api/stop", s.authWrap(http.HandlerFunc(s.handleStop), "room", "stop", true))
mux.Handle("GET /api/srs/streams", s.authWrap(http.HandlerFunc(s.handleSRSStreams), "srs", "streams", true))
mux.Handle("POST /api/room/{room}/events", s.authWrap(http.HandlerFunc(s.handleRoomEvents), "room", "watch", true))
mux.Handle("GET /api/room/{room}/events", s.authWrap(http.HandlerFunc(s.handleRoomEvents), "room", "watch", true))
mux.Handle("GET /api/room/{room}/playlist", s.authWrap(http.HandlerFunc(s.handleRoomPlaylist), "room", "watch", true))
mux.Handle("POST /api/room/{room}/playlist", s.authWrap(http.HandlerFunc(s.handleRoomPlaylist), "room", "watch", true))
mux.Handle("DELETE /api/room/{room}/playlist", s.authWrap(http.HandlerFunc(s.handleRoomPlaylist), "room", "watch", true))
mux.Handle("GET /api/room/{room}/playback", s.authWrap(http.HandlerFunc(s.handleRoomPlayback), "room", "watch", true))
mux.Handle("POST /api/room/{room}/playback", s.authWrap(http.HandlerFunc(s.handleRoomPlayback), "room", "watch", true))
mux.Handle("POST /api/room/{room}/chat/subscribe", s.authWrap(http.HandlerFunc(s.handleRoomChat), "room", "chat", false))
mux.Handle("GET /api/room/{room}/chat", s.authWrap(http.HandlerFunc(s.handleRoomChat), "room", "chat", false))
// 发送与 SSE 订阅一致:guest 亦可发弹幕(RBAC 已授权 room:chat),needAuth=false
mux.Handle("POST /api/room/{room}/chat", s.authWrap(http.HandlerFunc(s.handleRoomChatPost), "room", "chat", false))
// 主播独立外部推送接口:仅 publisher/admin(room:publish),供 OBS/机器人/管理工具以 Bearer token 调用,消息标记 host

View File

@ -1 +1 @@
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]],i=i=>e(n,t(i,{iconNode:r,name:`arrow-right`}));export{i as t};
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]],i=i=>e(n,t(i,{iconNode:r,name:`arrow-right`}));export{i as t};

View File

@ -1 +1 @@
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]],i=i=>e(n,t(i,{iconNode:r,name:`globe`}));export{i as t};
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]],i=i=>e(n,t(i,{iconNode:r,name:`globe`}));export{i as t};

View File

@ -1 +1 @@
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`line`,{x1:`4`,x2:`20`,y1:`9`,y2:`9`,key:`4lhtct`}],[`line`,{x1:`4`,x2:`20`,y1:`15`,y2:`15`,key:`vyu0kd`}],[`line`,{x1:`10`,x2:`8`,y1:`3`,y2:`21`,key:`1ggp8o`}],[`line`,{x1:`16`,x2:`14`,y1:`3`,y2:`21`,key:`weycgp`}]],i=i=>e(n,t(i,{iconNode:r,name:`hash`}));export{i as t};
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`line`,{x1:`4`,x2:`20`,y1:`9`,y2:`9`,key:`4lhtct`}],[`line`,{x1:`4`,x2:`20`,y1:`15`,y2:`15`,key:`vyu0kd`}],[`line`,{x1:`10`,x2:`8`,y1:`3`,y2:`21`,key:`1ggp8o`}],[`line`,{x1:`16`,x2:`14`,y1:`3`,y2:`21`,key:`weycgp`}]],i=i=>e(n,t(i,{iconNode:r,name:`hash`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`path`,{d:`M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49`,key:`ct8e1f`}],[`path`,{d:`M14.084 14.158a3 3 0 0 1-4.242-4.242`,key:`151rxh`}],[`path`,{d:`M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143`,key:`13bj9a`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]],i=i=>e(n,t(i,{iconNode:r,name:`eye-off`})),a=[[`path`,{d:`M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0`,key:`1nclc0`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]],o=r=>e(n,t(r,{iconNode:a,name:`eye`})),s=[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]],c=r=>e(n,t(r,{iconNode:s,name:`message-square`}));export{o as n,i as r,c as t};
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`path`,{d:`M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49`,key:`ct8e1f`}],[`path`,{d:`M14.084 14.158a3 3 0 0 1-4.242-4.242`,key:`151rxh`}],[`path`,{d:`M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143`,key:`13bj9a`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]],i=i=>e(n,t(i,{iconNode:r,name:`eye-off`})),a=[[`path`,{d:`M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0`,key:`1nclc0`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]],o=r=>e(n,t(r,{iconNode:a,name:`eye`})),s=[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]],c=r=>e(n,t(r,{iconNode:s,name:`message-square`}));export{o as n,i as r,c as t};

View File

@ -1 +1 @@
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`path`,{d:`M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`,key:`p7xjir`}]],i=i=>e(n,t(i,{iconNode:r,name:`cloud`})),a=[[`path`,{d:`M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z`,key:`vbtd3f`}],[`path`,{d:`M12 17v4`,key:`1riwvh`}],[`path`,{d:`M8 21h8`,key:`1ev6f3`}],[`rect`,{x:`2`,y:`3`,width:`20`,height:`14`,rx:`2`,key:`x3v2xh`}]],o=r=>e(n,t(r,{iconNode:a,name:`monitor-play`}));export{i as n,o as t};
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`path`,{d:`M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`,key:`p7xjir`}]],i=i=>e(n,t(i,{iconNode:r,name:`cloud`})),a=[[`path`,{d:`M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z`,key:`vbtd3f`}],[`path`,{d:`M12 17v4`,key:`1riwvh`}],[`path`,{d:`M8 21h8`,key:`1ev6f3`}],[`rect`,{x:`2`,y:`3`,width:`20`,height:`14`,rx:`2`,key:`x3v2xh`}]],o=r=>e(n,t(r,{iconNode:a,name:`monitor-play`}));export{i as n,o as t};

View File

@ -1 +1 @@
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`path`,{d:`M4.9 16.1C1 12.2 1 5.8 4.9 1.9`,key:`s0qx1y`}],[`path`,{d:`M7.8 4.7a6.14 6.14 0 0 0-.8 7.5`,key:`1idnkw`}],[`circle`,{cx:`12`,cy:`9`,r:`2`,key:`1092wv`}],[`path`,{d:`M16.2 4.8c2 2 2.26 5.11.8 7.47`,key:`ojru2q`}],[`path`,{d:`M19.1 1.9a9.96 9.96 0 0 1 0 14.1`,key:`rhi7fg`}],[`path`,{d:`M9.5 18h5`,key:`mfy3pd`}],[`path`,{d:`m8 22 4-11 4 11`,key:`25yftu`}]],i=i=>e(n,t(i,{iconNode:r,name:`radio-tower`}));export{i as t};
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`path`,{d:`M4.9 16.1C1 12.2 1 5.8 4.9 1.9`,key:`s0qx1y`}],[`path`,{d:`M7.8 4.7a6.14 6.14 0 0 0-.8 7.5`,key:`1idnkw`}],[`circle`,{cx:`12`,cy:`9`,r:`2`,key:`1092wv`}],[`path`,{d:`M16.2 4.8c2 2 2.26 5.11.8 7.47`,key:`ojru2q`}],[`path`,{d:`M19.1 1.9a9.96 9.96 0 0 1 0 14.1`,key:`rhi7fg`}],[`path`,{d:`M9.5 18h5`,key:`mfy3pd`}],[`path`,{d:`m8 22 4-11 4 11`,key:`25yftu`}]],i=i=>e(n,t(i,{iconNode:r,name:`radio-tower`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]],i=i=>e(n,t(i,{iconNode:r,name:`shield-check`}));export{i as t};
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]],i=i=>e(n,t(i,{iconNode:r,name:`shield-check`}));export{i as t};

View File

@ -1,4 +0,0 @@
function e(){try{return localStorage.getItem(`sync_live_token`)||``}catch{return``}}function t(){let t=e();return t?{Authorization:`Bearer ${t}`}:{}}function n(e){let t=[],n=e;for(;;){let e=n.indexOf(`
`);if(e===-1)break;let r=n.slice(0,e);if(n=n.slice(e+2),!r.trim()||r.startsWith(`:`))continue;let i=`message`,a=``;for(let e of r.split(`
`))e.startsWith(`event:`)?i=e.slice(6).trim():e.startsWith(`data:`)&&(a+=e.slice(5).trim());a&&t.push({event:i,data:a})}return{events:t,rest:n}}async function r(e,r,i,a){i?.(!1);let o=0;for(;!a?.aborted;){try{let s=await fetch(e,{method:`POST`,headers:{Accept:`text/event-stream`,...t()},credentials:`same-origin`,signal:a});if(!s.ok){let e=await s.text().catch(()=>``);throw Error(e||`SSE POST ${s.status}`)}if(!s.body)throw Error(`no body`);i?.(!0),o=0;let c=s.body.getReader(),l=new TextDecoder,u=``;for(;!a?.aborted;){let{value:e,done:t}=await c.read();if(t)break;u+=l.decode(e,{stream:!0});let{events:i,rest:a}=n(u);u=a;for(let e of i)r(e.event,e.data)}i?.(!1)}catch{if(a?.aborted)break;i?.(!1),o+=1;let e=Math.min(15e3,500*2**Math.min(o,5));await new Promise(t=>setTimeout(t,e));continue}if(a?.aborted)break;await new Promise(e=>setTimeout(e,800))}}function i(e,t){let n=new AbortController;return r(`/api/room/${encodeURIComponent(e)}/events`,(e,n)=>{if(e===`room`)try{t(JSON.parse(n))}catch{}},void 0,n.signal),()=>n.abort()}function a(e,t){let n=new AbortController;return r(`/api/rooms/events`,(t,n)=>{if(t===`rooms`)try{e(JSON.parse(n))}catch{}},t,n.signal),()=>n.abort()}function o(e,t,n){let i=new AbortController;return r(`/api/room/${encodeURIComponent(e)}/chat/subscribe`,(e,n)=>{if(e===`chat`)try{t(JSON.parse(n))}catch{}},n,i.signal),()=>i.abort()}export{i as n,a as r,o as t};

View File

@ -1 +1 @@
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]],i=i=>e(n,t(i,{iconNode:r,name:`shield-alert`})),a=[[`circle`,{cx:`12`,cy:`8`,r:`5`,key:`1hypcn`}],[`path`,{d:`M20 21a8 8 0 0 0-16 0`,key:`rfgkzh`}]],o=r=>e(n,t(r,{iconNode:a,name:`user-round`}));export{i as n,o as t};
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]],i=i=>e(n,t(i,{iconNode:r,name:`shield-alert`})),a=[[`circle`,{cx:`12`,cy:`8`,r:`5`,key:`1hypcn`}],[`path`,{d:`M20 21a8 8 0 0 0-16 0`,key:`rfgkzh`}]],o=r=>e(n,t(r,{iconNode:a,name:`user-round`}));export{i as n,o as t};

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]],i=i=>e(n,t(i,{iconNode:r,name:`lock`})),a=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]],o=r=>e(n,t(r,{iconNode:a,name:`plus`})),s=[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]],c=r=>e(n,t(r,{iconNode:s,name:`users`}));export{o as n,i as r,c as t};
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]],i=i=>e(n,t(i,{iconNode:r,name:`lock`})),a=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]],o=r=>e(n,t(r,{iconNode:a,name:`plus`})),s=[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]],c=r=>e(n,t(r,{iconNode:s,name:`users`}));export{o as n,i as r,c as t};

File diff suppressed because one or more lines are too long

View File

@ -19,9 +19,9 @@
}
})()
</script>
<script type="module" crossorigin src="/static/assets/index-LUhOsKYd.js"></script>
<link rel="modulepreload" crossorigin href="/static/assets/ui-CTx2ZrLp.js">
<link rel="stylesheet" crossorigin href="/static/assets/index-DwOmVRa5.css">
<script type="module" crossorigin src="/static/assets/index-Cfr72k3j.js"></script>
<link rel="modulepreload" crossorigin href="/static/assets/ui-CBKBDzcl.js">
<link rel="stylesheet" crossorigin href="/static/assets/index-CRlWlaHg.css">
</head>
<body>
<div id="root"></div>

View File

@ -1,8 +1,7 @@
import { Component, createSignal, createEffect, For, Show, onMount, onCleanup } from 'solid-js'
import { Component, createSignal, createEffect, For, Show, onMount } from 'solid-js'
import { useNavigate } from '@tanstack/solid-router'
import { ArrowRight, DoorOpen, Plus, Search, Lock, Globe, Users, RadioTower, Crown } from 'lucide-solid'
import { api } from '../../lib/api'
import { subscribeRooms } from '../../lib/sse'
import { permissions } from '../../lib/permissions'
import { Button, Corners, LiveDot, Reveal, Alert, Badge } from '../../components/ui'
@ -21,8 +20,6 @@ export const Rooms: Component = () => {
const [desc, setDesc] = createSignal('')
const [isPublic, setIsPublic] = createSignal(true)
const [showAdvanced, setShowAdvanced] = createSignal(false)
const [roomsLive, setRoomsLive] = createSignal(false)
const [lastSync, setLastSync] = createSignal<string>('')
const load = async () => {
setLoading(true)
@ -102,41 +99,12 @@ export const Rooms: Component = () => {
}
createEffect(() => {
if (permissions.can('room:list')) load()
if (permissions.isAuthed()) load()
})
createEffect(() => {
page(); search(); permissions.can('room:list') && load()
page(); search(); permissions.isAuthed() && load()
})
onMount(() => permissions.can('room:list') && load())
let unsubRooms: (() => void) | null = null
let debounce: ReturnType<typeof setTimeout> | null = null
const attach = () => {
if (unsubRooms) return
try {
unsubRooms = subscribeRooms(
() => {
setLastSync(new Date().toLocaleTimeString())
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => load(), 350)
},
(live) => setRoomsLive(live),
)
} catch {
setRoomsLive(false)
}
}
const detach = () => {
if (unsubRooms) { try { unsubRooms() } catch {} ; unsubRooms = null }
if (debounce) { clearTimeout(debounce); debounce = null }
setRoomsLive(false)
}
// attach when authed, detach when not
createEffect(() => {
if (permissions.can('room:list')) attach()
else detach()
})
attach()
onCleanup(() => detach())
onMount(() => permissions.isAuthed() && load())
const doSearch = () => {
setSearch(searchInput().trim())
@ -186,7 +154,7 @@ export const Rooms: Component = () => {
<p class="mono-label text-muted-foreground">rooms / registry</p>
<h1 class="display mt-2 text-4xl sm:text-5xl">房间</h1>
<p class="mt-3 max-w-xl text-sm text-muted-foreground">
创建或进入房间。已接入实时推送(<code class="font-mono">/api/rooms/events</code> SSE)· 新房间 / LIVE 状态变化自动刷新。
创建或进入房间。已接入新分页查询(<code class="font-mono">/api/rooms/query</code>)与管理面创建(<code class="font-mono">/api/manage/rooms</code>),支持搜索、公开度与成员统计。
</p>
</div>
</Reveal>
@ -195,7 +163,6 @@ export const Rooms: Component = () => {
<Alert tone="error">{err()}</Alert>
</Show>
<Show when={permissions.can('room:list')}>
<Reveal delay={1}>
<div class="relative max-w-3xl border border-line bg-card/60 p-3 sm:p-4">
<Corners />
@ -241,7 +208,6 @@ export const Rooms: Component = () => {
</div>
</div>
</Reveal>
</Show>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-1 items-center gap-2">
@ -273,13 +239,6 @@ export const Rooms: Component = () => {
<h2 class="flex items-center gap-2 font-semibold">
<RadioTower class="size-4 text-muted-foreground" />
房间列表
<span class="flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] font-normal" classList={{ 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600': roomsLive(), 'border-line bg-muted text-muted-foreground': !roomsLive() }}>
<span class="size-1.5 rounded-full" classList={{ 'bg-emerald-500 animate-pulse': roomsLive(), 'bg-muted-foreground/40': !roomsLive() }} />
{roomsLive() ? '实时' : '离线'}
</span>
<Show when={lastSync()}>
<span class="font-mono text-[10px] text-muted-foreground">· {lastSync()}</span>
</Show>
</h2>
<Show when={loading()}>
<span class="mono-label animate-pulse text-muted-foreground">loading…</span>

View File

@ -51,33 +51,16 @@ function RoomHub(props: { room: string }) {
let pc: RTCPeerConnection | undefined
let stopHls: (() => void) | undefined
let unsubRoom: (() => void) | undefined
let unsubChat: (() => void) | undefined
createEffect(() => {
// room:watch 订阅房间事件(SSE 需要 room:watch 权限)
if (permissions.can('room:watch') && !unsubRoom) {
unsubRoom = subscribeRoom(room(), (ev) => {
onMount(() => {
const unsubRoom = subscribeRoom(room(), (ev) => {
setTargets(ev.targets || [])
setDists(ev.distributions || [])
})
}
if (!permissions.can('room:watch') && unsubRoom) {
unsubRoom()
unsubRoom = undefined
}
// room:chat 订阅弹幕(guest 也有 room:chat 权限)
if (permissions.can('room:chat') && !unsubChat) {
unsubChat = subscribeChat(room(), (m) => setMsgs((p) => [...p, m].slice(-100)))
}
if (!permissions.can('room:chat') && unsubChat) {
unsubChat()
unsubChat = undefined
}
})
const unsubChat = subscribeChat(room(), (m) => setMsgs((p) => [...p, m].slice(-100)))
onCleanup(() => {
unsubRoom?.()
unsubChat?.()
unsubRoom()
unsubChat()
})
})
function attach(p: RTCPeerConnection) {
@ -207,7 +190,7 @@ function RoomHub(props: { room: string }) {
</div>
<div class="mt-4 flex flex-wrap gap-2">
<Button class="h-10" onClick={start} disabled={running() || !permissions.can('room:subscribe')}>
<Button class="h-10" onClick={start} disabled={running()}>
<PlayIcon />
开始观看
</Button>

View File

@ -12,10 +12,6 @@ import type {
AuthResponse,
} from './types'
export function persistToken(token: string) { try { localStorage.setItem('sync_live_token', token) } catch {} }
export function clearToken() { try { localStorage.removeItem('sync_live_token') } catch {} }
export function currentToken(): string { try { return localStorage.getItem('sync_live_token') || '' } catch { return '' } }
const BASE = ''
let refreshing: Promise<void> | null = null
@ -34,9 +30,7 @@ async function rawReq<T>(method: string, path: string, body?: unknown): Promise<
if (res.status === 204) return undefined as T
const ct = res.headers.get('content-type') || ''
if (ct.includes('application/json')) {
const j = (await res.json()) as any
if (j && typeof j.token === 'string' && j.token) persistToken(j.token)
return j as T
return (await res.json()) as T
}
return (await res.json().catch(() => undefined)) as T
}
@ -94,7 +88,7 @@ export const api = {
password,
role,
}),
logout: async () => { try { const r = await req<{ status: string }>('POST', '/api/auth/logout'); return r } finally { clearToken() } },
logout: () => req<{ status: string }>('POST', '/api/auth/logout'),
me: () =>
req<{ username: string; role: Role; status?: string; expires_at: number; issued_at: number }>('GET', '/api/auth/me'),
srsStreams: () => req<{ code: number; streams: string[] }>('GET', '/api/srs/streams'),

View File

@ -1,5 +1,4 @@
import type { Role } from './types'
import { subscribeChatSSE } from './sse'
export interface ChatMessage {
id: string
@ -12,29 +11,35 @@ export interface ChatMessage {
ts: number
}
// 订阅房间弹幕(POST SSE + JWT,满足前端SSE必须是POST+JWT的要求)
// 订阅房间弹幕(SSE,事件名 chat)
export function subscribeChat(room: string, onMsg: (m: ChatMessage) => void): () => void {
return subscribeChatSSE(room, onMsg as any)
const es = new EventSource(`/api/room/${encodeURIComponent(room)}/chat`, { withCredentials: true })
es.addEventListener('chat', (e) => {
try {
onMsg(JSON.parse((e as MessageEvent).data) as ChatMessage)
} catch {
/* ignore */
}
})
return () => es.close()
}
// 主播独立外部推送:以主播身份推送弹幕(需 room:publish 权限,可带 Bearer token 供外部工具调用)
export async function broadcastChat(room: string, message: string, color?: string): Promise<void> {
const t = (() => { try { return localStorage.getItem('sync_live_token') || '' } catch { return '' } })()
await fetch(`/api/room/${encodeURIComponent(room)}/broadcast`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, color }),
})
}
// 发送一条弹幕(需登录,POST+JWT)
// 发送一条弹幕(需登录)
export async function sendChat(room: string, message: string, color?: string): Promise<void> {
const t = (() => { try { return localStorage.getItem('sync_live_token') || '' } catch { return '' } })()
await fetch(`/api/room/${encodeURIComponent(room)}/chat`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, color }),
})
}

View File

@ -35,7 +35,6 @@ export const permissions = {
isRoot: () => permissions.role() === 'root',
isAdmin: () => { const r = permissions.role(); return r === 'root' || r === 'admin' },
isAdminOrAbove: () => { const r = permissions.role(); return r === 'root' || r === 'admin' },
isPublisher: () => { const r = permissions.role(); return r === 'root' || r === 'admin' || r === 'publisher' },
isBanned: () => permissions.status() === 'banned',
canManage: (targetRole: string) => {
const r = permissions.role()

View File

@ -1,142 +1,16 @@
import type { RoomEvent } from './types'
function getToken(): string {
// Token is stored in HttpOnly cookie (token), but also returned in login response.
// Try to read from localStorage if ever persisted, otherwise rely on cookie via Authorization fallback.
// We store last token in localStorage on login/register for POST SSE header use.
try { return localStorage.getItem('sync_live_token') || '' } catch { return '' }
}
function authHeaders(): Record<string, string> {
const t = getToken()
if (!t) return {}
return { Authorization: `Bearer ${t}` }
}
function parseSSEChunk(buffer: string): { events: Array<{ event: string; data: string }>; rest: string } {
const events: Array<{ event: string; data: string }> = []
let rest = buffer
// SSE frames are separated by \n\n
while (true) {
const idx = rest.indexOf('\n\n')
if (idx === -1) break
const frame = rest.slice(0, idx)
rest = rest.slice(idx + 2)
if (!frame.trim() || frame.startsWith(':')) continue
let event = 'message'
let data = ''
for (const line of frame.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim()
else if (line.startsWith('data:')) data += line.slice(5).trim()
}
if (data) events.push({ event, data })
}
return { events, rest }
}
async function postSSE(
url: string,
onEvent: (event: string, data: string) => void,
onState?: (live: boolean) => void,
signal?: AbortSignal,
): Promise<void> {
onState?.(false)
let retries = 0
while (!signal?.aborted) {
try {
const res = await fetch(url, {
method: 'POST',
headers: { Accept: 'text/event-stream', ...authHeaders() },
credentials: 'same-origin',
signal,
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `SSE POST ${res.status}`)
}
if (!res.body) throw new Error('no body')
onState?.(true)
retries = 0
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
while (!signal?.aborted) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const { events, rest } = parseSSEChunk(buf)
buf = rest
for (const ev of events) onEvent(ev.event, ev.data)
}
onState?.(false)
} catch (e: any) {
if (signal?.aborted) break
onState?.(false)
// backoff retry
retries += 1
const delay = Math.min(15000, 500 * Math.pow(2, Math.min(retries, 5)))
await new Promise((r) => setTimeout(r, delay))
continue
}
// stream ended without abort -> reconnect
if (signal?.aborted) break
await new Promise((r) => setTimeout(r, 800))
}
}
export function subscribeRoom(room: string, onEvent: (ev: RoomEvent) => void): () => void {
const ac = new AbortController()
postSSE(`/api/room/${encodeURIComponent(room)}/events`, (event, data) => {
if (event !== 'room') return
const es = new EventSource(`/api/room/${encodeURIComponent(room)}/events`, {
withCredentials: true,
})
es.addEventListener('room', (e) => {
try {
onEvent(JSON.parse(data) as RoomEvent)
} catch {}
}, undefined, ac.signal)
return () => ac.abort()
}
export interface RoomsEvent {
type: 'rooms'
event: 'create' | 'update' | 'delete' | 'sync'
room: string
ts: number
}
export function subscribeRooms(
onEvent: (ev: RoomsEvent) => void,
onState?: (live: boolean) => void,
): () => void {
const ac = new AbortController()
postSSE(
'/api/rooms/events',
(event, data) => {
if (event !== 'rooms') return
try {
onEvent(JSON.parse(data) as RoomsEvent)
} catch {}
},
onState,
ac.signal,
)
return () => ac.abort()
}
export function subscribeChatSSE(
room: string,
onMsg: (m: any) => void,
onState?: (live: boolean) => void,
): () => void {
const ac = new AbortController()
postSSE(
`/api/room/${encodeURIComponent(room)}/chat/subscribe`,
(event, data) => {
if (event !== 'chat') return
try {
onMsg(JSON.parse(data))
} catch {}
},
onState,
ac.signal,
)
return () => ac.abort()
const data = JSON.parse((e as MessageEvent).data)
onEvent(data as RoomEvent)
} catch {
/* ignore malformed */
}
})
return () => es.close()
}

View File

@ -14,8 +14,7 @@ export default defineConfig(({ mode }) => ({
emptyOutDir: true,
},
server: {
host: "127.0.0.1",
port: 3000,
port: 5173,
// 开发态把控制面 / RTC / 直播流反代到本地 Go 服务(不影响构建产物)
proxy: {
'/api': 'http://localhost:8088',