diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..11a5779 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +`apps/server-rust/` contains the Axum backend, live-event adapters, authentication, persistence, and WebSocket delivery. Database migrations live in `apps/server-rust/migrations/`; Rust unit and async tests are normally kept beside the modules they exercise. `apps/overlay/src/` contains the React/Vite control and OBS interfaces, with PWA code in `apps/overlay/pwa/` and static assets in `apps/overlay/public/`. Architecture, protocol, security, and component notes belong in `docs/`; deployment support lives in `deploy/`, `Dockerfile`, and `compose.yaml`. `vendor/blivedm/` is an intentionally patched dependency—edit it only for a targeted upstream integration change. + +## Build, Test, and Development Commands + +- `cargo fmt --manifest-path apps/server-rust/Cargo.toml` formats Rust code. +- `cargo clippy --manifest-path apps/server-rust/Cargo.toml --all-targets --no-deps -- -D warnings` runs strict Rust linting. +- `cargo test --manifest-path apps/server-rust/Cargo.toml --all-targets` runs backend tests. +- `npm --prefix apps/overlay run dev` starts the Vite development server. +- `npm --prefix apps/overlay run format:check` checks Prettier; use `run format` to apply it. +- `npm --prefix apps/overlay run build` type-checks and builds the frontend; `run docs:check` validates documentation links. +- `docker compose config --quiet` validates deployment configuration; `docker compose up --build -d` builds and starts the stack. + +Run `git diff --check` before submitting changes. + +## Coding Style & Naming Conventions + +Use four spaces in Rust and TOML, and two spaces in TypeScript, CSS, Markdown, JSON, and YAML. Rust uses `snake_case` for modules/functions and `PascalCase` for types. TypeScript uses `camelCase` for functions/values and `PascalCase` for React components and types. Let rustfmt and the pinned Prettier version decide layout. Do not bulk-format vendored code. + +## Testing Guidelines + +Add Rust tests near the changed behavior using `#[test]` or `#[tokio::test]`. There is no fixed coverage threshold, but regressions and tenant-boundary behavior require tests. Frontend changes must pass formatting and production builds. For visual overlay work, manually verify representative OBS sizes and attach before/after screenshots to the pull request. + +## Commits, Pull Requests & Security + +Follow existing history: use a short, lowercase, imperative or descriptive commit subject and keep each commit to one logical change. Pull requests should summarize behavior, list verification commands, note migrations or configuration changes, link relevant issues, and include screenshots for UI work. + +Never commit `config.toml`, `.env`, CookieCloud credentials, TOTP secrets, recovery codes, session secrets, or OBS tokens; update examples with placeholders instead. Preserve tenant isolation, PostgreSQL RLS policies, and authenticated ownership checks in every query and API boundary. diff --git a/README.md b/README.md index 11a8fa6..566f01f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # 洛星瓷直播组件服务 -这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` -弹幕姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示、点歌姬等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。 +这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬与 `song_request` +点歌姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。 直播连接使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 的 `blivedm` crate。项目在 -`vendor/blivedm` 固定了保留原始 JSON 的小补丁,避免 UID、礼物价格和上游事件 ID 被简化消息结构丢弃。 +`vendor/blivedm` +固定了保留原始 JSON、可观测发送失败和安全重连所需的小补丁,避免 UID、礼物价格和上游事件 ID 被简化消息结构丢弃,也让宿主在 socket 恢复失败后重建客户端。 ## 文档索引 @@ -13,6 +14,7 @@ - [React 控制台、OBS 与 PWA](apps/overlay/README.md) - [组件开发指南](docs/components/README.md) - [`danmaku_overlay` 弹幕姬](docs/components/danmaku-overlay.md) +- [`song_request` 点歌姬](docs/components/song-request.md) - [WebSocket 实时协议](docs/protocol.md) - [租户、Secret 与部署安全](docs/security.md) - [完整配置注释](config.toml.example) @@ -54,7 +56,8 @@ Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--c `[database].url` 指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL -RLS 约束,HTTP API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。 +RLS 约束,HTTP +API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。点歌队列、评分和完整历史同样保存在租户隔离表中。 `security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP Secret 与每个用户的 CookieCloud @@ -190,6 +193,9 @@ fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通 `/api/v1/components//stream` 完成认证。令牌只带 `events:subscribe` 权限,不能调用管理或写入接口;轮换后旧令牌立即失效。 +每个账户会自动拥有一个不可删除的点歌姬。观众发送 `点歌 歌名` 加入队列,发送 `打分 1-5` +为当前歌曲评分;主播可从组件设置打开独立统计窗口,置顶、完成或取消队列项。点歌状态和评分持久化在 PostgreSQL,即使 OBS 未连接也不会丢失。 + 旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。 diff --git a/apps/overlay/README.md b/apps/overlay/README.md index 2de215e..5be6a7a 100644 --- a/apps/overlay/README.md +++ b/apps/overlay/README.md @@ -4,28 +4,31 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域 ## 路由 -| 路由 | 权限 | 作用 | -| ---------------------- | --------------- | ------------------------------------ | -| `/control/` | 登录用户 | 直播源、组件、测试、设置和 OBS token | -| `/control/invitations` | system admin | 创建/撤销绑定房间的邀请码 | -| `/control/login` | 匿名 | 用户名 + TOTP/恢复码登录 | -| `/control/register` | 匿名受邀用户 | 邀请码注册与 TOTP enrollment | -| `/control/setup` | 首次部署 | 创建唯一 system admin | -| `/obs/:publicId` | component token | 透明 OBS 浏览器源 | +| 路由 | 权限 | 作用 | +| --------------------------------------- | --------------- | ------------------------------------ | +| `/control/` | 登录用户 | 直播源、组件、测试、设置和 OBS token | +| `/control/invitations` | system admin | 创建/撤销绑定房间的邀请码 | +| `/control/components/:id/song-requests` | 登录用户 | 点歌队列、统计与管理操作 | +| `/control/login` | 匿名 | 用户名 + TOTP/恢复码登录 | +| `/control/register` | 匿名受邀用户 | 邀请码注册与 TOTP enrollment | +| `/control/setup` | 首次部署 | 创建唯一 system admin | +| `/obs/:publicId` | component token | 透明 OBS 浏览器源 | `main.tsx` 在初始化控制台前先识别 OBS 路由,因此 OBS 不会注册 PWA 或请求账户 session。 ## 文件职责 -| 文件 | 职责 | -| ------------------- | ---------------------------------------------------- | -| `src/api.ts` | same-origin fetch、错误模型和兼容性 normalizer | -| `src/auth.tsx` | passwordless login、TOTP QR 与恢复码 | -| `src/control.tsx` | tenant component studio 和 system-admin 邀请码页面 | -| `src/overlay.tsx` | WebSocket、消息队列、礼物/表情和 OBS 自适应渲染 | -| `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker | -| `src/types.ts` | sanitized API view model 与 overlay settings | -| `pwa/control-sw.js` | `/control/` 静态壳层的缓存策略 | +| 文件 | 职责 | +| --------------------- | ---------------------------------------------------- | +| `src/api.ts` | same-origin fetch、错误模型和兼容性 normalizer | +| `src/auth.tsx` | passwordless login、TOTP QR 与恢复码 | +| `src/control.tsx` | tenant component studio 和 system-admin 邀请码页面 | +| `src/stream.ts` | 通用组件 WebSocket 鉴权、重连和 renderer 分流 | +| `src/overlay.tsx` | 弹幕、礼物/表情和 OBS 自适应渲染 | +| `src/songOverlay.tsx` | 点歌快照 reducer、revision 校验与往返滚动 | +| `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker | +| `src/types.ts` | sanitized API view model 与 overlay settings | +| `pwa/control-sw.js` | `/control/` 静态壳层的缓存策略 | ## Secret 与状态 @@ -59,3 +62,4 @@ YAML 和项目文档。 - [实时协议](../../docs/protocol.md) - [弹幕姬组件](../../docs/components/danmaku-overlay.md) +- [点歌姬组件](../../docs/components/song-request.md) diff --git a/apps/overlay/src/api.ts b/apps/overlay/src/api.ts index b396b9a..8253f8a 100644 --- a/apps/overlay/src/api.ts +++ b/apps/overlay/src/api.ts @@ -14,8 +14,13 @@ import type { Invitation, OverlaySettings, Session, + SongRequestItem, + SongRequestPage, + SongRequestSettings, TotpEnrollment, } from './types' +import { normalizeThemeId } from './themes' +import { defaultOverlaySettings, defaultSongRequestSettings } from './types' export class ApiError extends Error { readonly status: number @@ -172,7 +177,68 @@ export function normalizeComponents(value: unknown): ComponentSummary[] { export function normalizeSettings(value: unknown): OverlaySettings { const root = object(value) - return (root.settings ?? value) as OverlaySettings + const settings = object(root.settings ?? value) + return { + ...defaultOverlaySettings, + ...(settings as Partial), + themeId: normalizeThemeId(settings.themeId), + } +} + +export function normalizeSongRequestSettings(value: unknown): SongRequestSettings { + const root = object(value) + const settings = object(root.settings ?? value) + return { + ...defaultSongRequestSettings, + ...(settings as Partial), + themeId: normalizeThemeId(settings.themeId), + } +} + +export function normalizeSongRequestItem(value: unknown): SongRequestItem | undefined { + const item = object(value) + const requester = object(item.requester) + if (typeof item.id !== 'string' || typeof item.title !== 'string') return undefined + const status = String(item.status) + if (!['current', 'queued', 'completed', 'cancelled'].includes(status)) return undefined + return { + id: item.id, + title: item.title, + requester: { uid: String(requester.uid ?? ''), name: String(requester.name ?? '直播间观众') }, + status: status as SongRequestItem['status'], + queuePosition: Number(item.queuePosition ?? 0), + requestedAt: String(item.requestedAt ?? ''), + startedAt: + typeof item.startedAt === 'string' || item.startedAt === null ? item.startedAt : undefined, + finishedAt: + typeof item.finishedAt === 'string' || item.finishedAt === null ? item.finishedAt : undefined, + averageScore: + typeof item.averageScore === 'number' || item.averageScore === null + ? item.averageScore + : undefined, + ratingCount: Number(item.ratingCount ?? 0), + } +} + +export function normalizeSongRequestPage(value: unknown): SongRequestPage { + const root = object(value) + const current = normalizeSongRequestItem(root.current) + const summary = object(root.summary) + return { + revision: Number(root.revision ?? 0), + current: current ?? null, + items: (Array.isArray(root.items) ? root.items : []) + .map(normalizeSongRequestItem) + .filter((item): item is SongRequestItem => Boolean(item)), + nextCursor: typeof root.nextCursor === 'number' ? root.nextCursor : null, + summary: { + activeCount: Number(summary.activeCount ?? 0), + queuedCount: Number(summary.queuedCount ?? 0), + completedCount: Number(summary.completedCount ?? 0), + cancelledCount: Number(summary.cancelledCount ?? 0), + ratingCount: Number(summary.ratingCount ?? 0), + }, + } } export function normalizeSource(value: unknown): CookieCloudSource { diff --git a/apps/overlay/src/control.css b/apps/overlay/src/control.css index fab3205..ea4181f 100644 --- a/apps/overlay/src/control.css +++ b/apps/overlay/src/control.css @@ -3,6 +3,137 @@ background: transparent; } +.theme-jade-scroll { + color: var(--theme-text); +} + +.limit-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + margin: 22px 0; + padding: 18px; + border: 1px solid rgba(111, 228, 211, 0.18); + border-radius: 16px; +} + +.limit-grid legend { + padding: 0 8px; + color: var(--muted, #9ccbc5); +} + +.limit-grid label { + display: grid; + gap: 7px; +} + +.song-stat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} + +.stat-card { + display: grid; + gap: 8px; + padding: 18px 20px; +} + +.stat-card small, +.current-song-admin small, +.song-admin-list small { + color: #98c9c3; +} + +.stat-card b { + color: #dffff7; + font-size: 2rem; +} + +.current-song-admin { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.current-song-admin h2 { + margin: 6px 0; + color: #eafff9; + font-size: clamp(1.5rem, 4vw, 2.4rem); +} + +.song-admin-list { + display: grid; + gap: 10px; +} + +.song-admin-list article { + display: grid; + grid-template-columns: 44px minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + padding: 12px 14px; + border: 1px solid rgba(119, 232, 214, 0.14); + border-radius: 13px; + background: rgba(5, 39, 49, 0.45); +} + +.song-admin-list article > div:not(.form-actions) { + display: grid; + gap: 5px; + min-width: 0; +} + +.queue-number { + color: #7df4df; + font-variant-numeric: tabular-nums; + text-align: center; +} + +.obs-status { + box-sizing: border-box; + display: grid; + width: 100vw; + height: 100vh; + place-items: center; + padding: 20px; + color: #e7fff9; + background: transparent; + text-align: center; +} + +.obs-status.pending { + opacity: 0; +} + +@media (max-width: 760px) { + .limit-grid, + .song-stat-grid { + grid-template-columns: 1fr 1fr; + } + + .current-song-admin { + align-items: stretch; + flex-direction: column; + } + + .song-admin-list article { + grid-template-columns: 36px minmax(0, 1fr); + } + + .song-admin-list article .form-actions { + grid-column: 2; + } +} + +@media (max-width: 480px) { + .limit-grid, + .song-stat-grid { + grid-template-columns: 1fr; + } +} + html, body, #root, @@ -21,7 +152,7 @@ body, } .card-decor { - --decor-primary-image: url('/assets/floral-divider.svg'); + --decor-primary-image: var(--theme-pattern-divider); --decor-primary-position: center 44%; --decor-primary-size: 92% auto; --decor-primary-transform: none; @@ -34,7 +165,7 @@ body, #6adcc6 68%, #f4bfd4 96% ); - --decor-secondary-image: url('/assets/floral-cluster.svg'); + --decor-secondary-image: var(--theme-pattern-cluster); --decor-secondary-position: right -10px bottom -22px; --decor-secondary-size: 34% auto; --decor-secondary-transform: none; @@ -123,12 +254,12 @@ body, } .decor-v2 { - --decor-primary-image: url('/assets/floral-vine.svg'); + --decor-primary-image: var(--theme-pattern-vine); --decor-primary-position: left -12px bottom -13px; --decor-primary-size: 73% auto; --decor-primary-opacity: 0.105; --decor-primary-color: linear-gradient(110deg, #83ead5, #d8fff3 54%, #cbb9e9); - --decor-secondary-image: url('/assets/floral-divider.svg'); + --decor-secondary-image: var(--theme-pattern-divider); --decor-secondary-position: right -22px top -18px; --decor-secondary-size: 62% auto; --decor-secondary-opacity: 0.055; @@ -139,7 +270,7 @@ body, } .decor-v3 { - --decor-primary-image: url('/assets/floral-vine.svg'); + --decor-primary-image: var(--theme-pattern-vine); --decor-primary-position: left -15px bottom -15px; --decor-primary-size: 77% auto; --decor-primary-transform: scaleX(-1); @@ -155,7 +286,7 @@ body, } .decor-v4 { - --decor-primary-image: url('/assets/floral-cluster.svg'); + --decor-primary-image: var(--theme-pattern-cluster); --decor-primary-position: left -18px bottom -28px; --decor-primary-size: 45% auto; --decor-primary-transform: rotate(-5deg); @@ -172,13 +303,13 @@ body, } .decor-v5 { - --decor-primary-image: url('/assets/floral-vine.svg'); + --decor-primary-image: var(--theme-pattern-vine); --decor-primary-position: center top -17px; --decor-primary-size: 90% auto; --decor-primary-transform: scaleY(-1); --decor-primary-opacity: 0.095; --decor-primary-color: linear-gradient(90deg, #d9c4ed, #82e5d2 44%, #f8e7b5 78%, #efc2d8); - --decor-secondary-image: url('/assets/floral-divider.svg'); + --decor-secondary-image: var(--theme-pattern-divider); --decor-secondary-position: left 18% top -15px; --decor-secondary-size: 55% auto; --decor-secondary-opacity: 0.06; @@ -233,7 +364,8 @@ body, height: var(--particle-size); opacity: 0; will-change: transform, opacity; - animation: card-sparkle var(--particle-duration, 4000ms) ease-in-out infinite; + animation: var(--theme-motion-sparkle, card-sparkle) var(--particle-duration, 4000ms) ease-in-out + infinite; } .card-particle.star { @@ -362,7 +494,7 @@ body, } .copy span { - color: #e7fff9; + color: var(--theme-text, #e7fff9); } .danmaku-content { @@ -434,7 +566,8 @@ body, inset 12px 0 18px -15px rgba(142, 255, 230, 0.95), inset -12px 0 18px -15px rgba(142, 255, 230, 0.95), 0 10px 28px rgba(0, 15, 25, 0.32); - animation: scroll-unfurl var(--unfold-duration, 1000ms) cubic-bezier(0.25, 0.45, 0.45, 0.95) both; + animation: var(--theme-motion-unfurl, scroll-unfurl) var(--unfold-duration, 1000ms) + cubic-bezier(0.25, 0.45, 0.45, 0.95) both; } .card.danmaku.expanded::after { @@ -460,8 +593,8 @@ body, inset -6px 0 7px -7px rgba(216, 255, 246, 0.72), -2px 0 0 rgba(7, 46, 53, 0.72), 2px 0 0 rgba(7, 46, 53, 0.72); - animation: scroll-rails-open var(--unfold-duration, 1000ms) cubic-bezier(0.25, 0.45, 0.45, 0.95) - both; + animation: var(--theme-motion-rails-open, scroll-rails-open) var(--unfold-duration, 1000ms) + cubic-bezier(0.25, 0.45, 0.45, 0.95) both; } .card.danmaku.expanded .copy { @@ -475,7 +608,7 @@ body, } .card.danmaku.expanded .copy b { - color: #f0fffb; + color: var(--theme-user, #f0fffb); font-size: 0.78em; letter-spacing: 0.06em; } @@ -507,7 +640,7 @@ body, .card.danmaku.compact .copy b { max-width: none; flex: 0 0 auto; - color: #aeece1; + color: var(--theme-compact-user, #aeece1); } .card.danmaku.compact .copy span { @@ -1354,6 +1487,11 @@ select { gap: 19px 30px; } +.theme-selector { + max-width: 520px; + margin-bottom: 24px; +} + .slider-grid label { display: grid; gap: 9px; diff --git a/apps/overlay/src/control.tsx b/apps/overlay/src/control.tsx index 44ba961..132641f 100644 --- a/apps/overlay/src/control.tsx +++ b/apps/overlay/src/control.tsx @@ -17,17 +17,25 @@ import { normalizeComponents, normalizeInvitations, normalizeSettings, + normalizeSongRequestPage, + normalizeSongRequestSettings, normalizeSource, } from './api' import { Overlay } from './overlay' import { PwaControls, usePwaUpdateBlocker } from './pwa' -import { defaultOverlaySettings } from './types' +import { SongRequestOverlay } from './songOverlay' +import { getOverlayTheme, overlayThemes } from './themes' +import { defaultOverlaySettings, defaultSongRequestSettings } from './types' import type { AuthUser, + ComponentSettings, ComponentSummary, CookieCloudSource, Invitation, OverlaySettings, + SongRequestItem, + SongRequestPage, + SongRequestSettings, } from './types' const previewPresets = [ @@ -41,6 +49,10 @@ function isDanmakuKind(kind: string): boolean { return kind === 'danmaku_overlay' || kind === 'danmaku' } +function isSongRequestKind(kind: string): boolean { + return kind === 'song_request' +} + type Flash = { kind: 'success' | 'error'; text: string } | undefined function Panel({ @@ -150,9 +162,26 @@ function SettingsEditor({ ['showLike', '点赞'], ['showShare', '分享'], ] + const selectedTheme = getOverlayTheme(settings.themeId) return (
+
+ +