diff --git a/apps/overlay/README.md b/apps/overlay/README.md index 843634a..be3e3bc 100644 --- a/apps/overlay/README.md +++ b/apps/overlay/README.md @@ -4,17 +4,18 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域 ## 路由 -| 路由 | 权限 | 作用 | -| --------------------------------------- | --------------- | ------------------------------------ | -| `/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 浏览器源 | +| 路由 | 权限 | 作用 | +| --------------------------------------- | --------------- | -------------------------------- | +| `/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。 +`main.tsx` +在初始化控制台前先识别 OBS 路由,因此 OBS 不会注册 PWA 或请求账户 session。控制台允许同一组件类型创建多个命名实例;列表必须显示实例名称而不是只显示类型,以便不同 OBS 场景的样式和地址可被区分。 ## 文件职责 diff --git a/apps/overlay/src/api.ts b/apps/overlay/src/api.ts index 9f7732b..67864fe 100644 --- a/apps/overlay/src/api.ts +++ b/apps/overlay/src/api.ts @@ -189,7 +189,13 @@ export function normalizeRecoveryCodes(value: unknown): string[] { export function normalizeComponents(value: unknown): ComponentSummary[] { const root = object(value) - const list = Array.isArray(value) ? value : Array.isArray(root.components) ? root.components : [] + const list = Array.isArray(value) + ? value + : Array.isArray(root.components) + ? root.components + : root.component && typeof root.component === 'object' + ? [root.component] + : [] return list .map(entry => { const item = object(entry) diff --git a/apps/overlay/src/control.css b/apps/overlay/src/control.css index 8efe44e..61ca903 100644 --- a/apps/overlay/src/control.css +++ b/apps/overlay/src/control.css @@ -1994,13 +1994,37 @@ select { box-shadow: 0 0 9px rgba(86, 232, 197, 0.7); } -.future-components { +.component-create { margin: 18px 8px 0; padding-top: 15px; display: grid; - gap: 4px; + gap: 8px; border-top: 1px solid rgba(94, 187, 173, 0.12); - color: #567d78; +} + +.component-create > b { + color: #bcebe2; +} + +.component-create > small { + color: #719e98; + line-height: 1.4; +} + +.component-create select, +.component-create input { + width: 100%; + min-width: 0; + min-height: 40px; + padding: 8px 10px; + border: 1px solid rgba(75, 157, 151, 0.45); + border-radius: 9px; + color: #edfffb; + background: rgba(3, 18, 26, 0.82); +} + +.component-create button { + width: 100%; } .dashboard-content { @@ -2017,6 +2041,14 @@ select { align-items: center; } +.page-heading-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; + align-items: center; +} + .page-heading h1 { margin: 0; color: #e2fff9; @@ -2352,10 +2384,6 @@ td:last-child { .component-list { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); } - - .future-components { - display: none; - } } @media (max-width: 680px) { diff --git a/apps/overlay/src/control.tsx b/apps/overlay/src/control.tsx index 2519dd4..b379988 100644 --- a/apps/overlay/src/control.tsx +++ b/apps/overlay/src/control.tsx @@ -205,6 +205,32 @@ function isGiftMenuKind(kind: string): boolean { return kind === 'gift_menu' } +const componentKinds = ['danmaku_overlay', 'song_request', 'gift_effect', 'gift_menu'] as const + +function componentKindLabel(kind: string): string { + return isDanmakuKind(kind) + ? translate('components.danmaku_type') + : isSongRequestKind(kind) + ? translate('components.song_type') + : isGiftEffectKind(kind) + ? translate('components.gift_type') + : isGiftMenuKind(kind) + ? translate('components.gift_menu_type') + : kind +} + +function componentKindMark(kind: string): string { + return isDanmakuKind(kind) + ? translate('components.danmaku_mark') + : isSongRequestKind(kind) + ? translate('components.song_mark') + : isGiftEffectKind(kind) + ? translate('components.gift_mark') + : isGiftMenuKind(kind) + ? translate('components.gift_menu_mark') + : translate('components.generic_mark') +} + type Flash = { kind: 'success' | 'error'; text: string } | undefined function Panel({ @@ -2048,11 +2074,28 @@ function ComponentList({ components, selectedId, onSelect, + onCreate, + creating, }: { components: ComponentSummary[] selectedId?: string onSelect: (component: ComponentSummary) => void + onCreate: (kind: string, name: string) => Promise + creating: boolean }) { + const [kind, setKind] = useState<(typeof componentKinds)[number]>('danmaku_overlay') + const [name, setName] = useState('') + usePwaUpdateBlocker( + 'component-create', + translate('components.create_blocker'), + creating || Boolean(name.trim()), + ) + + const submit = async (event: FormEvent) => { + event.preventDefault() + if (await onCreate(kind, name)) setName('') + } + return ( ) } @@ -2134,6 +2171,8 @@ export function ComponentsPage({ const [settings, setSettings] = useState() const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) + const [creating, setCreating] = useState(false) + const [deleting, setDeleting] = useState(false) const [flash, setFlash] = useState() const selectedIdRef = useRef(undefined) const settingsRequestRef = useRef(0) @@ -2147,7 +2186,7 @@ export function ComponentsPage({ usePwaUpdateBlocker( 'component-settings', translate('components.settings_blocker'), - saving || settingsDirty, + saving || deleting || settingsDirty, ) const loadComponentSettings = useCallback(async (component: ComponentSummary) => { @@ -2225,6 +2264,12 @@ export function ComponentsPage({ }, [loadComponentSettings]) const choose = (component: ComponentSummary) => { + if ( + component.id !== selectedId && + settingsDirty && + !window.confirm(translate('components.discard_changes_confirm')) + ) + return selectedIdRef.current = component.id setSelectedId(component.id) const url = new URL(location.href) @@ -2233,6 +2278,76 @@ export function ComponentsPage({ void loadComponentSettings(component) } + const createInstance = async (kind: string, name: string): Promise => { + if (settingsDirty && !window.confirm(translate('components.discard_changes_confirm'))) + return false + setCreating(true) + setFlash(undefined) + try { + const payload = await api( + '/api/v1/components', + json('POST', { kind, name: name.trim() }), + ) + const component = normalizeComponents(payload)[0] + if (!component) throw new Error(translate('components.create_failed')) + setComponents(current => [...current, component]) + selectedIdRef.current = component.id + setSelectedId(component.id) + const url = new URL(location.href) + url.searchParams.set('component', component.id) + history.replaceState(null, '', url) + await loadComponentSettings(component) + setFlash({ kind: 'success', text: translate('components.created') }) + return true + } catch (reason) { + setFlash({ + kind: 'error', + text: errorMessage(reason, translate('components.create_failed')), + }) + return false + } finally { + setCreating(false) + } + } + + const deleteSelected = async () => { + if ( + !selected || + !window.confirm(translate('components.delete_confirm', { name: selected.name })) + ) + return + setDeleting(true) + setFlash(undefined) + try { + await api(`/api/v1/components/${encodeURIComponent(selected.id)}`, json('DELETE')) + const remaining = components.filter(component => component.id !== selected.id) + setComponents(remaining) + const next = remaining.find(component => component.kind === selected.kind) ?? remaining[0] + settingsRequestRef.current += 1 + savedSettingsRef.current = undefined + setSettings(undefined) + selectedIdRef.current = next?.id + setSelectedId(next?.id) + const url = new URL(location.href) + if (next) { + url.searchParams.set('component', next.id) + history.replaceState(null, '', url) + await loadComponentSettings(next) + } else { + url.searchParams.delete('component') + history.replaceState(null, '', url) + } + setFlash({ kind: 'success', text: translate('components.deleted') }) + } catch (reason) { + setFlash({ + kind: 'error', + text: errorMessage(reason, translate('components.delete_failed')), + }) + } finally { + setDeleting(false) + } + } + const saveSettings = async () => { if (!selected || !settings) return const componentId = selected.id @@ -2282,7 +2397,13 @@ export function ComponentsPage({ return (
- +
{loading && (
{translate('components.loading')}
@@ -2292,36 +2413,28 @@ export function ComponentsPage({ <>
-

- {isDanmakuKind(selected.kind) - ? translate('components.danmaku_type') - : isSongRequestKind(selected.kind) - ? translate('components.song_type') - : isGiftEffectKind(selected.kind) - ? translate('components.gift_type') - : isGiftMenuKind(selected.kind) - ? translate('components.gift_menu_type') - : selected.kind} -

-

- {isDanmakuKind(selected.kind) - ? translate('components.danmaku_type') - : isSongRequestKind(selected.kind) - ? translate('components.song_type') - : isGiftEffectKind(selected.kind) - ? translate('components.gift_type') - : isGiftMenuKind(selected.kind) - ? translate('components.gift_menu_type') - : selected.name} -

+

{componentKindLabel(selected.kind)}

+

{selected.name}

+
+
+ + {selected.enabled === false + ? translate('common.disabled') + : translate('common.enabled')} + + {components.filter(component => component.kind === selected.kind).length > 1 && ( + + )}
- - {selected.enabled === false - ? translate('common.disabled') - : translate('common.enabled')} -
{isDanmakuKind(selected.kind) && settings ? ( <> diff --git a/apps/server-rust/README.md b/apps/server-rust/README.md index a5dd3ef..aed15ff 100644 --- a/apps/server-rust/README.md +++ b/apps/server-rust/README.md @@ -29,6 +29,7 @@ crate 导出。 - handler 不能信任请求体中的 owner;owner 必须来自 session 或账户 source context。 - `component_instances` 不保存 source 绑定;账户唯一的监听事件会按 owner 提供给其全部启用组件。 +- 同一组件 kind 可以有多个实例;settings、token、持久状态和广播通道必须继续按 component ID 隔离。 - tenant table 查询必须在 `Db::set_tenant` 后的事务中执行。 - provider 只能输出 canonical、bounded、sanitized `LiveEvent`。 - `libilibili` listener 必须保持 20 秒心跳;断线后由 adapter 重新获取弹幕 host/token 并重建 socket。 diff --git a/apps/server-rust/migrations/012_component_instances.sql b/apps/server-rust/migrations/012_component_instances.sql new file mode 100644 index 0000000..d7f1f23 --- /dev/null +++ b/apps/server-rust/migrations/012_component_instances.sql @@ -0,0 +1,10 @@ +-- Built-in component kinds are instance-based. An account may create multiple +-- instances of the same kind, each with independent settings, OBS credentials, +-- realtime fanout, and component-owned durable state. + +DROP INDEX IF EXISTS component_instances_single_song_request; +DROP INDEX IF EXISTS component_instances_single_gift_effect; +DROP INDEX IF EXISTS component_instances_single_gift_menu; + +COMMENT ON TABLE component_instances IS + 'Tenant-owned component instances. Multiple rows of the same kind may belong to one account.'; diff --git a/apps/server-rust/src/app.rs b/apps/server-rust/src/app.rs index 2941911..5a6478b 100644 --- a/apps/server-rust/src/app.rs +++ b/apps/server-rust/src/app.rs @@ -302,6 +302,10 @@ async fn migrate(db: &Db) -> Result<(), String> { (9_i32, include_str!("../migrations/009_gift_effect.sql")), (10_i32, include_str!("../migrations/010_gift_menu.sql")), (11_i32, include_str!("../migrations/011_totp_reset.sql")), + ( + 12_i32, + include_str!("../migrations/012_component_instances.sql"), + ), ] { let applied = transaction .query_one( diff --git a/apps/server-rust/src/repository.rs b/apps/server-rust/src/repository.rs index ac8bc76..e93aa2c 100644 --- a/apps/server-rust/src/repository.rs +++ b/apps/server-rust/src/repository.rs @@ -22,6 +22,8 @@ use crate::{ song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings}, }; +const MAX_COMPONENT_INSTANCES_PER_KIND: i64 = 16; + #[derive(Clone)] pub struct TenantRepository { db: Db, @@ -49,17 +51,25 @@ impl TenantRepository { Ok(()) } - /// Ensure every active tenant has every required singleton component. - /// Partial unique indexes make concurrent starts idempotent. The song - /// component additionally owns a relational revision state row. + /// Ensure every active tenant has an initial instance of each built-in + /// component. Registration normally creates these rows; this startup + /// backfill keeps upgrades from older releases complete. Additional + /// instances are created explicitly through the component API. pub async fn ensure_builtin_components(&self) -> Result<(), RepositoryError> { for tenant in self.db.list_active_tenants().await? { let mut client = self.db.get().await?; let transaction = client.transaction().await?; Db::set_tenant(&transaction, tenant.user_id).await?; + transaction + .query_one( + "SELECT id FROM users WHERE id=$1 AND status='active' FOR UPDATE", + &[&tenant.user_id], + ) + .await?; let existing = transaction .query_opt( - "SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2", + "SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2 \ + ORDER BY created_at,id LIMIT 1", &[&tenant.user_id, &SONG_REQUEST_KIND], ) .await?; @@ -84,11 +94,17 @@ impl TenantRepository { ) .await?; transaction - .query_one( - "SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2", + .query_opt( + "SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2 \ + ORDER BY created_at,id LIMIT 1", &[&tenant.user_id, &SONG_REQUEST_KIND], ) .await? + .ok_or_else(|| { + RepositoryError::Invalid( + "failed to create the initial song request component".into(), + ) + })? .get(0) }; transaction @@ -98,38 +114,58 @@ impl TenantRepository { &[&tenant.user_id, &component_id], ) .await?; - let gift_settings = serde_json::to_value(GiftEffectSettings::default()) - .map_err(|error| RepositoryError::Invalid(error.to_string()))?; - transaction - .execute( - "INSERT INTO component_instances \ - (id,owner_user_id,kind,name,settings,settings_version,enabled) \ - VALUES($1,$2,$3,$4,$5,1,true) ON CONFLICT DO NOTHING", - &[ - &Uuid::new_v4(), - &tenant.user_id, - &GIFT_EFFECT_KIND, - &GIFT_EFFECT_NAME, - &gift_settings, - ], + let has_gift_effect = transaction + .query_one( + "SELECT EXISTS(SELECT 1 FROM component_instances \ + WHERE owner_user_id=$1 AND kind=$2)", + &[&tenant.user_id, &GIFT_EFFECT_KIND], ) - .await?; - let menu_settings = serde_json::to_value(GiftMenuSettings::default()) - .map_err(|error| RepositoryError::Invalid(error.to_string()))?; - transaction - .execute( - "INSERT INTO component_instances \ - (id,owner_user_id,kind,name,settings,settings_version,enabled) \ - VALUES($1,$2,$3,$4,$5,1,true) ON CONFLICT DO NOTHING", - &[ - &Uuid::new_v4(), - &tenant.user_id, - &GIFT_MENU_KIND, - &GIFT_MENU_NAME, - &menu_settings, - ], + .await? + .get::<_, bool>(0); + if !has_gift_effect { + let gift_settings = serde_json::to_value(GiftEffectSettings::default()) + .map_err(|error| RepositoryError::Invalid(error.to_string()))?; + transaction + .execute( + "INSERT INTO component_instances \ + (id,owner_user_id,kind,name,settings,settings_version,enabled) \ + VALUES($1,$2,$3,$4,$5,1,true)", + &[ + &Uuid::new_v4(), + &tenant.user_id, + &GIFT_EFFECT_KIND, + &GIFT_EFFECT_NAME, + &gift_settings, + ], + ) + .await?; + } + let has_gift_menu = transaction + .query_one( + "SELECT EXISTS(SELECT 1 FROM component_instances \ + WHERE owner_user_id=$1 AND kind=$2)", + &[&tenant.user_id, &GIFT_MENU_KIND], ) - .await?; + .await? + .get::<_, bool>(0); + if !has_gift_menu { + let menu_settings = serde_json::to_value(GiftMenuSettings::default()) + .map_err(|error| RepositoryError::Invalid(error.to_string()))?; + transaction + .execute( + "INSERT INTO component_instances \ + (id,owner_user_id,kind,name,settings,settings_version,enabled) \ + VALUES($1,$2,$3,$4,$5,1,true)", + &[ + &Uuid::new_v4(), + &tenant.user_id, + &GIFT_MENU_KIND, + &GIFT_MENU_NAME, + &menu_settings, + ], + ) + .await?; + } transaction.commit().await?; } Ok(()) @@ -162,12 +198,6 @@ impl TenantRepository { kind: &str, name: &str, ) -> Result { - // Every tenant receives this singleton during registration/startup. - // Keeping creation internal prevents a second instance from racing the - // partial unique index and turning a domain conflict into a DB error. - if matches!(kind, SONG_REQUEST_KIND | GIFT_EFFECT_KIND | GIFT_MENU_KIND) { - return Err(RepositoryError::Forbidden); - } let runtime = self .registry .runtime(kind) @@ -190,6 +220,26 @@ impl TenantRepository { let mut client = self.db.get().await?; let transaction = client.transaction().await?; Db::set_tenant(&transaction, owner_id).await?; + // Serialize instance-count checks for this account. Without the owner + // row lock, concurrent requests could both pass the per-kind limit. + transaction + .query_one( + "SELECT id FROM users WHERE id=$1 AND status='active' FOR UPDATE", + &[&owner_id], + ) + .await?; + let instance_count = transaction + .query_one( + "SELECT count(*) FROM component_instances WHERE owner_user_id=$1 AND kind=$2", + &[&owner_id, &component.kind], + ) + .await? + .get::<_, i64>(0); + if instance_count >= MAX_COMPONENT_INSTANCES_PER_KIND { + return Err(RepositoryError::Invalid(format!( + "a component kind accepts at most {MAX_COMPONENT_INSTANCES_PER_KIND} instances" + ))); + } transaction .execute( "INSERT INTO component_instances \ @@ -205,6 +255,15 @@ impl TenantRepository { ], ) .await?; + if component.kind == SONG_REQUEST_KIND { + transaction + .execute( + "INSERT INTO song_request_state(owner_user_id,component_instance_id) \ + VALUES($1,$2)", + &[&owner_id, &component.id], + ) + .await?; + } transaction.commit().await?; self.cache.upsert(component.clone()); Ok(component) @@ -218,6 +277,12 @@ impl TenantRepository { let mut client = self.db.get().await?; let transaction = client.transaction().await?; Db::set_tenant(&transaction, owner_id).await?; + transaction + .query_one( + "SELECT id FROM users WHERE id=$1 AND status='active' FOR UPDATE", + &[&owner_id], + ) + .await?; let kind: String = transaction .query_opt( "SELECT kind FROM component_instances WHERE owner_user_id=$1 AND id=$2", @@ -226,10 +291,14 @@ impl TenantRepository { .await? .ok_or(RepositoryError::NotFound)? .get(0); - if matches!( - kind.as_str(), - SONG_REQUEST_KIND | GIFT_EFFECT_KIND | GIFT_MENU_KIND - ) { + let instance_count = transaction + .query_one( + "SELECT count(*) FROM component_instances WHERE owner_user_id=$1 AND kind=$2", + &[&owner_id, &kind], + ) + .await? + .get::<_, i64>(0); + if instance_count <= 1 { return Err(RepositoryError::Forbidden); } let changed = transaction diff --git a/docs/architecture.md b/docs/architecture.md index ce25274..6d44681 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,8 @@ ## 核心目标 - 每个账户拥有一个可切换的 Bilibili 直播间、一个 CookieCloud 来源和一条独立监听连接。 -- 组件不绑定或选择直播源;账户事件流会提供给该账户所有启用的组件实例。 +- 组件不绑定或选择直播源;账户事件流会提供给该账户所有启用的组件实例。同一 kind 可以拥有多个实例,每个实例独立保存名称、设置、OBS + token 和实时通道。 - 平台原始命令先转换成稳定的领域事件,组件不直接依赖 Bilibili `CMD`。 - HTTP 会话、直播源、组件、OBS token 和实时通道均以租户为边界。 - 新组件可以增加设置、投影和持久化副作用,而不修改直播连接核心。 @@ -37,7 +38,8 @@ flowchart LR 只标识账户级监听,不存储在组件行中。 4. 匹配订阅后,路由器先执行可持久化的 `EventHandler`,再执行无副作用的 `EventProjection`。 5. 投影结果只发布到该 `component_id` 的广播通道。没有全局 WebSocket 事件总线。 -6. OBS 使用组件级只读 token 订阅一个组件,不能读取控制台 API。 +6. OBS 使用实例级只读 token 订阅一个组件实例,不能读取控制台 API;同类型的其他实例拥有不同 + `component_id`,可在不同 OBS 场景中使用不同样式。 ## 状态所有权 diff --git a/docs/components/README.md b/docs/components/README.md index c9727aa..9ec5cad 100644 --- a/docs/components/README.md +++ b/docs/components/README.md @@ -3,6 +3,11 @@ 组件是“消费所属账户事件流的独立功能实例”。当前内建 `danmaku_overlay`、`song_request`、 `gift_effect` 与 `gift_menu`,未来礼物墙或统计组件也应使用同一套契约。 +账户注册时会为每种内建 kind 创建一个初始实例。控制台可以为同一 kind 再创建多个命名实例;每个实例都有独立 settings、OBS +token、`publicId` 与 `EventHub` +channel。当前每账户每 kind 最多 16 个实例,且至少保留一个,避免误删后由启动回填产生一个意外的新地址。删除实例会级联删除它的 token 与组件专属关系数据,并立即关闭该实例的 WebSocket +channel。 + ## 一个组件由什么组成 | 部分 | Rust 契约 | 职责 | @@ -28,7 +33,8 @@ - 数据库操作必须包含 owner/component 条件。 - 上游可能重试或出现组合事件,因此 handler 自己负责幂等。 6. 在 `ComponentRegistry::with_builtin_components` 注册定义与投影,再注册 handler。 -7. 增加数据库创建/设置 API;不要把组件专属关系数据无限塞入 JSON settings。 +7. 增加数据库创建/设置 API;初始化组件专属关系数据,并确认删除实例时可以安全级联。不要把组件专属关系数据无限塞入JSON + settings。 8. 在控制台增加设置编辑器,在 OBS 前端增加对应事件渲染器。 9. 增加以下测试:设置边界、版本迁移、订阅、跨租户拒绝、handler 幂等、投影 wire shape 和 OBS 渲染。 diff --git a/docs/components/danmaku-overlay.md b/docs/components/danmaku-overlay.md index 4ecf843..b4758b6 100644 --- a/docs/components/danmaku-overlay.md +++ b/docs/components/danmaku-overlay.md @@ -1,6 +1,6 @@ # `danmaku_overlay` 弹幕姬 -弹幕姬把一个租户直播源的互动事件投影为透明 OBS 消息墙。它是被动展示组件:不记账、不回复弹幕,也不把 WebSocket 当作持久化业务通道。 +弹幕姬把一个租户直播源的互动事件投影为透明 OBS 消息墙。它是被动展示组件:不记账、不回复弹幕,也不把 WebSocket 当作持久化业务通道。账户注册时会创建一个初始实例;同一账户可以继续添加多个弹幕姬实例,为横屏、竖屏或其他 OBS 场景分别保存样式和 OBS 地址。所有实例共享账户直播监听,但事件投影和 WebSocket 广播仍按实例 ID 隔离。 ## 订阅事件 diff --git a/docs/components/gift-effect.md b/docs/components/gift-effect.md index 786d4d2..2647f3d 100644 --- a/docs/components/gift-effect.md +++ b/docs/components/gift-effect.md @@ -1,7 +1,8 @@ # 全屏礼物特效组件 -`gift_effect` 是每个账户自动拥有且不可删除的单例组件。它消费账户级直播源中的 `live.gift` 和 -`live.guard.buy`,使用独立只读 token 作为透明 OBS 浏览器源;不订阅 +`gift_effect` +在账户注册时创建一个初始实例,并允许为不同 OBS 场景添加多个独立样式实例。每个实例消费账户级直播源中的 +`live.gift` 和 `live.guard.buy`,使用自己的只读 token 作为透明 OBS 浏览器源;不订阅 `live.gift.combo`,避免一次连击重复触发完整特效。 ## 展示行为 diff --git a/docs/components/gift-menu.md b/docs/components/gift-menu.md index e594c4c..9fae3d2 100644 --- a/docs/components/gift-menu.md +++ b/docs/components/gift-menu.md @@ -1,7 +1,7 @@ # 礼物菜单组件 `gift_menu` -是每个账户自动拥有且不可删除的单例组件。它把直播间礼物或大航海投喂映射为主播提供的内容说明,并以独立只读 token 输出透明 OBS 浏览器源。 +在账户注册时创建一个初始实例,也可以为不同 OBS 场景添加多个实例。每个实例独立保存菜单内容、样式和只读 token;它把直播间礼物或大航海投喂映射为主播提供的内容说明,并输出透明 OBS 浏览器源。 ## 目录与触发器 diff --git a/docs/components/song-request.md b/docs/components/song-request.md index 507f574..40a022f 100644 --- a/docs/components/song-request.md +++ b/docs/components/song-request.md @@ -1,7 +1,7 @@ # `song_request` 点歌姬 -每个账户自动拥有一个不可删除、不可重复创建的 `song_request` -实例,并与该账户的固定直播源绑定。组件只订阅规范化的 +每个账户自动拥有一个初始 `song_request` +实例,也可以再创建同类型的命名实例。每个实例拥有独立设置、OBS 地址、队列、评分和 revision,但共享账户的固定直播监听;删除实例会同时删除该实例的队列历史。组件只订阅规范化的 `live.danmaku`;业务状态由 PostgreSQL 保存,不依赖 OBS 是否在线。 ## 弹幕命令 diff --git a/docs/protocol.md b/docs/protocol.md index 24fe848..8455123 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -43,6 +43,9 @@ log。WebSocket 建立后,客户端必须在 8 秒内发送第一帧: `overlay.settings.snapshot` 兼容帧。token 无效、被轮换或属于其他组件时,服务端以 policy close 结束连接。 +`componentId`/`publicId` 标识组件实例,而不是组件类型。同一账户可以创建多个相同 `componentKind` +的实例;它们使用各自的设置、token 和 WebSocket 地址,客户端不能把同 kind 视为同一个订阅通道。 + `language` 是组件所属账户的 locale。用户在控制台修改语言后,所有已连接组件会立即收到 `component.language.updated`,payload 为 `{ "language": "en-US" }`;新连接以认证帧为准。 diff --git a/docs/security.md b/docs/security.md index b429e5e..0294cbd 100644 --- a/docs/security.md +++ b/docs/security.md @@ -14,6 +14,7 @@ token。以下规则是实现约束,而不是可选部署建议。 - tenant 查询必须在事务中执行 `SET LOCAL app.user_id`,不能使用会泄漏到连接池的 session-level `SET`。 - 实时广播按 `component_id` 建立独立 channel,不提供全局订阅。 +- 同一 kind 的多个实例仍逐行执行 owner 归属校验;实例 token 不能订阅同账户的另一个实例。 - 路由器按事件的可信 `owner_id` 扇出,并在投影发布前再次验证 owner、账户 source 和 component ID。 ## Secret 生命周期 @@ -25,7 +26,7 @@ token。以下规则是实现约束,而不是可选部署建议。 | 登录 session | HttpOnly Cookie | SHA-256 摘要 | 到期、登出或撤销 | | CookieCloud Key/密码 | 用户提交时 | XChaCha20-Poly1305 密文 | 覆盖更新 | | 邀请码 | 创建时显示一次 | SHA-256 摘要和前缀 | 单次消费或撤销 | -| OBS token | 创建/轮换时显示一次 | SHA-256 摘要 | 组件级轮换 | +| OBS token | 创建/轮换时显示一次 | SHA-256 摘要 | 组件实例级轮换 | `security.data_encryption_key` 是恢复密文所必需的主密钥。它必须独立备份,但不能提交到 Git 或写入镜像。 diff --git a/proposal.md b/proposal.md index e69de29..7dd98fd 100644 --- a/proposal.md +++ b/proposal.md @@ -0,0 +1,5 @@ +# 经理瓷给我的要求 + +1. 右下角的点歌界面应该从上往下滚动 ok +2. 通过woff2直接从前端serve字体 ok +3. 小礼物用扇子,大礼物用跳跃的鱼 diff --git a/resources/i18n.toml b/resources/i18n.toml index 8cf412d..10ecaaa 100644 --- a/resources/i18n.toml +++ b/resources/i18n.toml @@ -252,7 +252,7 @@ name = "简体中文" "components.title" = "我的组件" "components.eyebrow" = "直播组件" "components.empty_title" = "还没有组件" -"components.empty_description" = "账户初始化完成后,服务会为你创建默认弹幕姬。" +"components.empty_description" = "账户初始化完成后,服务会为每种内置组件创建一个初始实例。" "components.danmaku_mark" = "弹" "components.song_mark" = "歌" "components.gift_mark" = "礼" @@ -262,8 +262,22 @@ name = "简体中文" "components.song_type" = "直播点歌姬" "components.gift_type" = "全屏礼物星雨" "components.gift_menu_type" = "直播礼物菜单" -"components.coming_soon" = "即将支持" -"components.future" = "更多主题 · 互动组件" +"components.add_instance" = "添加组件实例" +"components.add_instance_description" = "同一类型可以添加多个实例,每个实例单独保存样式和 OBS 地址。" +"components.instance_type" = "组件类型" +"components.instance_name" = "实例名称" +"components.instance_name_placeholder" = "例如:竖屏场景" +"components.create_instance" = "添加实例" +"components.creating" = "正在添加…" +"components.create_blocker" = "完成或清空正在填写的组件实例名称" +"components.discard_changes_confirm" = "当前组件有尚未保存的设置。确定放弃这些修改吗?" +"components.created" = "组件实例已添加,可以单独配置样式和 OBS 地址。" +"components.create_failed" = "无法添加组件实例" +"components.delete_instance" = "删除实例" +"components.deleting" = "正在删除…" +"components.delete_confirm" = "确定删除“{name}”吗?它的 OBS 地址、令牌和组件数据将同时失效。" +"components.deleted" = "组件实例已删除。" +"components.delete_failed" = "无法删除组件实例;每种类型至少需要保留一个实例。" "components.settings_blocker" = "保存或还原当前组件设置" "components.settings_load_failed" = "无法读取组件设置" "components.load_failed" = "控制台数据加载失败" @@ -753,7 +767,7 @@ name = "English" "components.title" = "My components" "components.eyebrow" = "LIVE COMPONENTS" "components.empty_title" = "No components yet" -"components.empty_description" = "The service creates a default chat overlay after account initialization." +"components.empty_description" = "The service creates one initial instance of every built-in component after account initialization." "components.danmaku_mark" = "Chat" "components.song_mark" = "Song" "components.gift_mark" = "Gift" @@ -763,8 +777,22 @@ name = "English" "components.song_type" = "Song request overlay" "components.gift_type" = "Full-screen gift starfall" "components.gift_menu_type" = "Live gift menu" -"components.coming_soon" = "Coming soon" -"components.future" = "More themes · Interactive components" +"components.add_instance" = "Add component instance" +"components.add_instance_description" = "Add multiple instances of one type, each with its own style and OBS address." +"components.instance_type" = "Component type" +"components.instance_name" = "Instance name" +"components.instance_name_placeholder" = "For example: Portrait scene" +"components.create_instance" = "Add instance" +"components.creating" = "Adding…" +"components.create_blocker" = "finish or clear the component instance name being entered" +"components.discard_changes_confirm" = "The current component has unsaved settings. Discard those changes?" +"components.created" = "Component instance added. Its style and OBS address can be configured independently." +"components.create_failed" = "Could not add the component instance" +"components.delete_instance" = "Delete instance" +"components.deleting" = "Deleting…" +"components.delete_confirm" = "Delete “{name}”? Its OBS address, token, and component data will stop working." +"components.deleted" = "Component instance deleted." +"components.delete_failed" = "Could not delete the component instance; at least one instance of each type must remain." "components.settings_blocker" = "save or revert the current component settings" "components.settings_load_failed" = "Could not load component settings" "components.load_failed" = "Could not load console data"