package hub import ( "context" "log/slog" "sync" "time" "commilitia.net/cdrop/internal/db" ) // DeviceLister is the subset of *db.Queries the hub needs. // Declared as interface so tests can inject a fake. type DeviceLister interface { ListDevicesByUser(ctx context.Context, userID string) ([]db.Device, error) } // Event is what the hub fan-outs to SSE clients. type Event struct { Type string `json:"-"` Data any `json:"-"` } // PresenceDevice is the per-device entry in a `presence` event payload. // Field names align with brief §5: { name, type, online }; last_seen 是 // 设置页展示「上次活跃」需要的扩展字段。 type PresenceDevice struct { Name string `json:"name"` Type string `json:"type"` Online bool `json:"online"` LastSeen int64 `json:"last_seen"` } const clientBuffer = 32 // Client is a single live SSE connection. type Client struct { UserID string DeviceID string ch chan Event } func (c *Client) Events() <-chan Event { return c.ch } // Hub is the in-memory presence + signaling fan-out. // // Concurrency: a single sync.RWMutex protects the user→device map. // Sends to client channels are non-blocking — slow consumers drop events // rather than stall the broadcaster. type Hub struct { mu sync.RWMutex users map[string]map[string]*Client devices DeviceLister grace time.Duration closed bool } func New(devices DeviceLister) *Hub { return &Hub{ users: map[string]map[string]*Client{}, devices: devices, grace: 30 * time.Second, } } // Connect registers a new SSE client and announces presence to the user's other devices. // If a client for (userID, deviceID) already exists (e.g., a tab refresh), its channel is closed. func (h *Hub) Connect(ctx context.Context, userID, deviceID string) *Client { c := &Client{ UserID: userID, DeviceID: deviceID, ch: make(chan Event, clientBuffer), } h.mu.Lock() if h.users[userID] == nil { h.users[userID] = map[string]*Client{} } if old, ok := h.users[userID][deviceID]; ok { close(old.ch) } h.users[userID][deviceID] = c h.mu.Unlock() go h.publishPresence(ctx, userID) return c } // Disconnect removes a client and, after a grace period, re-publishes presence // to peers if the client has not reconnected. func (h *Hub) Disconnect(c *Client) { h.mu.Lock() if active, ok := h.users[c.UserID][c.DeviceID]; ok && active == c { delete(h.users[c.UserID], c.DeviceID) if len(h.users[c.UserID]) == 0 { delete(h.users, c.UserID) } } h.mu.Unlock() time.AfterFunc(h.grace, func() { h.mu.RLock() _, stillOnline := h.users[c.UserID][c.DeviceID] h.mu.RUnlock() if stillOnline { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() h.publishPresence(ctx, c.UserID) }) } // SendTo routes an event to a specific (userID, deviceID). Reports whether // the target was online and the event was queued. func (h *Hub) SendTo(userID, deviceID string, ev Event) bool { h.mu.RLock() c, ok := h.users[userID][deviceID] h.mu.RUnlock() if !ok { return false } select { case c.ch <- ev: return true default: slog.Warn("client buffer full; dropping event", "user", userID, "device", deviceID, "type", ev.Type) return false } } // Broadcast fans an event out to every live client of a user. func (h *Hub) Broadcast(userID string, ev Event) { clients := h.snapshotClients(userID) for _, c := range clients { select { case c.ch <- ev: default: slog.Warn("client buffer full; dropping event", "user", c.UserID, "device", c.DeviceID, "type", ev.Type) } } } // Online reports whether a (userID, deviceID) currently has a live SSE. func (h *Hub) Online(userID, deviceID string) bool { h.mu.RLock() defer h.mu.RUnlock() _, ok := h.users[userID][deviceID] return ok } // Kick force-removes a (userID, deviceID) entry from the hub and closes its // event channel; the SSE handler exits on the next iteration. 与 Connect 的 // 旧通道关闭模式一致(与并发 SendTo 之间存在极窄竞态,但 Kick 罕用,可接受)。 func (h *Hub) Kick(userID, deviceID string) { h.mu.Lock() c, ok := h.users[userID][deviceID] if ok { delete(h.users[userID], deviceID) if len(h.users[userID]) == 0 { delete(h.users, userID) } } h.mu.Unlock() if ok { close(c.ch) } } // PublishPresence broadcasts the user's current device list to all live // clients. 用于在变更设备集合后(如 DELETE /api/devices/{name})立刻广播, // 而不是等待 Disconnect 的宽限期。 func (h *Hub) PublishPresence(ctx context.Context, userID string) { h.publishPresence(ctx, userID) } // Close drops every connected client. Used on graceful shutdown. func (h *Hub) Close() { h.mu.Lock() defer h.mu.Unlock() if h.closed { return } h.closed = true for _, devices := range h.users { for _, c := range devices { close(c.ch) } } h.users = map[string]map[string]*Client{} } func (h *Hub) snapshotClients(userID string) []*Client { h.mu.RLock() defer h.mu.RUnlock() src := h.users[userID] out := make([]*Client, 0, len(src)) for _, c := range src { out = append(out, c) } return out } func (h *Hub) publishPresence(ctx context.Context, userID string) { devs, err := h.devices.ListDevicesByUser(ctx, userID) if err != nil { slog.Error("presence: list devices failed", "user", userID, "err", err) return } h.mu.RLock() live := h.users[userID] items := make([]PresenceDevice, 0, len(devs)) for _, d := range devs { _, online := live[d.Name] items = append(items, PresenceDevice{ Name: d.Name, Type: d.Type, Online: online, LastSeen: d.LastSeen, }) } clients := make([]*Client, 0, len(live)) for _, c := range live { clients = append(clients, c) } h.mu.RUnlock() ev := Event{ Type: "presence", Data: map[string]any{"devices": items}, } for _, c := range clients { select { case c.ch <- ev: default: slog.Warn("presence: client buffer full; dropping", "user", c.UserID, "device", c.DeviceID) } } }