// Package clipboard 实现单条覆写式的剪贴板同步:DB 立即写入(GET 永远反映 // 最新真相),SSE 广播按 generation 计数节流——同窗口内的连续 PUT 只让最后 // 一次广播出去,避免下游设备被中间值抖动。 package clipboard import ( "context" "database/sql" "errors" "fmt" "log/slog" "sync" "time" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/hub" ) // originFutureToleranceMs caps how far ahead of server time a client's origin_ts // may be. Beyond it we assume a runaway clock and clamp to server-now, so one // mis-set device can't set an origin_ts no honest device could beat for a while. const originFutureToleranceMs = 5 * 60 * 1000 const ( // ContentTypeText 是 MVP 阶段唯一允许写入的 content_type。前端 HTML→text // fallback 在客户端完成,后端只接 text/plain。 ContentTypeText = "text/plain" // EventType 是 SSE 推送给在线设备的事件名(brief §5 风格的 namespace:verb)。 EventType = "clipboard:update" ) // Errors that the HTTP layer needs to distinguish. var ( ErrUnsupportedType = errors.New("unsupported content_type") ErrTooLarge = errors.New("content exceeds limit") ) // Hub is the subset of *hub.Hub that the service needs. Declared as interface // so unit tests can swap in a fake. type Hub interface { Broadcast(userID string, ev hub.Event) } // Querier 是 sqlc 生成的 *db.Queries 的子集,便于测试桩替换。 type Querier interface { UpsertClipboard(ctx context.Context, arg db.UpsertClipboardParams) (int64, error) GetClipboard(ctx context.Context, userID string) (db.ClipboardState, error) ClearExpiredClipboards(ctx context.Context, updatedAt int64) (int64, error) } // Service 持有内存里的 pending 表,每个 userID 对应一个最新待广播的 generation。 type Service struct { q Querier hub Hub max int window time.Duration ttl time.Duration // 0 = 内容不过期 mu sync.Mutex pending map[string]uint64 // userID → 当前最新 generation nextGen uint64 } // New constructs a Service. window 是稳定窗口(PUT 收到后等待无新写入的时长, // 到期才广播);maxBytes 是单次写入上限(UTF-8 字节);ttl 是云端内容存活时长 // (0 = 不过期)——见 Get 的惰性过期与 RunSweeper 的主动清除。 func New(q Querier, h Hub, maxBytes int, window, ttl time.Duration) *Service { if maxBytes <= 0 { maxBytes = 65536 } if window <= 0 { window = 3 * time.Second } return &Service{ q: q, hub: h, max: maxBytes, window: window, ttl: ttl, pending: map[string]uint64{}, } } // MaxBytes 暴露上限给 HTTP 层做请求体校验。 func (s *Service) MaxBytes() int { return s.max } // State 是 GET 返回 / SSE 广播 payload 的统一形状。Version 是变更探测的核心—— // 每用户严格单调递增;OriginTs 是冲突收敛的 LWW 键(源设备复制时刻 ms);客户端 // 据 Version 判变更、据 OriginTs 决定回写与否。 type State struct { Content string `json:"content"` ContentType string `json:"content_type"` SourceDevice string `json:"source_device"` UpdatedAt int64 `json:"updated_at"` Version int64 `json:"version"` OriginTs int64 `json:"origin_ts"` } // Get 读取当前持久化的剪贴板状态;err == sql.ErrNoRows 时表示用户尚未写入。 // 内容超过 TTL 即视为已过期,返回空状态(与「尚未写入」等价、ETag 0),绝不 // 回吐过期内容;实际的 DB 清除交给 RunSweeper。 func (s *Service) Get(ctx context.Context, userID string) (*State, error) { row, err := s.q.GetClipboard(ctx, userID) if err != nil { return nil, err } st := rowToState(row) if s.expired(st.Content, st.UpdatedAt) { return &State{ContentType: ContentTypeText, UpdatedAt: 0}, nil } return st, nil } // expired 判断一条内容是否超过 TTL。ttl<=0 或内容为空时永不过期。 func (s *Service) expired(content string, updatedAt int64) bool { if s.ttl <= 0 || content == "" { return false } return time.Now().Unix()-updatedAt >= int64(s.ttl.Seconds()) } // SweepExpired 清除所有超过 TTL 的剪贴板内容(content / source_device 置空), // 返回清除的行数。ttl<=0 时为 no-op。 func (s *Service) SweepExpired(ctx context.Context) (int64, error) { if s.ttl <= 0 { return 0, nil } cutoff := time.Now().Unix() - int64(s.ttl.Seconds()) return s.q.ClearExpiredClipboards(ctx, cutoff) } // SweepInterval 是 sweeper 唤醒周期。 const SweepInterval = 30 * time.Second // RunSweeper 阻塞直到 ctx 取消,周期性清除过期剪贴板内容。ttl<=0 时 // SweepExpired 为 no-op;调用方应据 cfg.ClipboardTTLSec>0 决定是否启动。 func RunSweeper(ctx context.Context, svc *Service) { t := time.NewTicker(SweepInterval) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: n, err := svc.SweepExpired(ctx) if err != nil { slog.Error("clipboard sweeper: clear expired failed", "err", err) continue } if n > 0 { slog.Info("clipboard sweeper: cleared expired content", "count", n) } } } } // Put 接收一次写入:校验 → 立即 UPSERT → bump generation → 定时器到期才广播。 // 同 userID 在窗口期内的多次 Put 只让最后一次广播。 // Put writes a clipboard update tagged with originTs (the source device's copy // time, ms). It is accepted only when originTs strictly exceeds the stored one // (LWW); a delayed / out-of-order older copy is rejected with accepted=false and // the current winning State returned (so the caller can reconcile). Only an // accepted write bumps the version and schedules a broadcast. func (s *Service) Put( ctx context.Context, userID, contentType, content, sourceDevice string, originTs int64, ) (st *State, accepted bool, err error) { if contentType != ContentTypeText { return nil, false, fmt.Errorf("%w: %q", ErrUnsupportedType, contentType) } if len(content) > s.max { return nil, false, fmt.Errorf("%w: %d > %d", ErrTooLarge, len(content), s.max) } now := time.Now() if nowMs := now.UnixMilli(); originTs > nowMs+originFutureToleranceMs { originTs = nowMs // clamp a runaway-future clock to server-now } version, err := s.q.UpsertClipboard(ctx, db.UpsertClipboardParams{ UserID: userID, ContentType: contentType, Content: &content, SourceDevice: &sourceDevice, UpdatedAt: now.Unix(), OriginTs: originTs, }) if errors.Is(err, sql.ErrNoRows) { // LWW rejected: a newer origin_ts already won. Return the current winner // (no broadcast) so the caller pulls / reconciles instead of clobbering. cur, gerr := s.Get(ctx, userID) if gerr != nil { return nil, false, gerr } return cur, false, nil } if err != nil { return nil, false, fmt.Errorf("upsert clipboard: %w", err) } st = &State{ Content: content, ContentType: contentType, SourceDevice: sourceDevice, UpdatedAt: now.Unix(), Version: version, OriginTs: originTs, } s.mu.Lock() s.nextGen++ gen := s.nextGen s.pending[userID] = gen s.mu.Unlock() time.AfterFunc(s.window, func() { s.maybeCommit(userID, gen) }) return st, true, nil } // maybeCommit 由定时器回调进入。只有当 pending[userID] 仍然是入参 gen 时(即 // 期间没有更新写入)才真正广播;否则视为被新写入覆盖,直接丢弃。 func (s *Service) maybeCommit(userID string, gen uint64) { s.mu.Lock() cur, ok := s.pending[userID] if !ok || cur != gen { s.mu.Unlock() return } delete(s.pending, userID) s.mu.Unlock() // 重新读 DB 拿权威 state——窗口结束时 DB 已是 gen 对应的内容(中间被 // 后续覆盖的可能性已被前面的 generation 检查排除)。 row, err := s.q.GetClipboard(context.Background(), userID) if err != nil { slog.Error("clipboard commit: get state failed", "err", err, "user", userID) return } s.hub.Broadcast(userID, hub.Event{ Type: EventType, Data: rowToState(row), }) } func rowToState(row db.ClipboardState) *State { st := &State{ ContentType: row.ContentType, UpdatedAt: row.UpdatedAt, Version: row.Version, OriginTs: row.OriginTs, } if row.Content != nil { st.Content = *row.Content } if row.SourceDevice != nil { st.SourceDevice = *row.SourceDevice } return st }