move live source ownership to accounts
This commit is contained in:
@@ -29,7 +29,7 @@ adapter 只负责把强类型 Bilibili 命令转换成稳定的领域事件。cr
|
|||||||
- `components.rs`
|
- `components.rs`
|
||||||
注册组件定义、设置 schema/迁移、事件订阅、纯投影和独立副作用 handler;新礼物展示或点歌姬不需要修改弹幕姬队列核心。
|
注册组件定义、设置 schema/迁移、事件订阅、纯投影和独立副作用 handler;新礼物展示或点歌姬不需要修改弹幕姬队列核心。
|
||||||
- `realtime.rs` 按 component
|
- `realtime.rs` 按 component
|
||||||
UUID 建立独立广播通道,路由时同时校验 owner 与 source;副作用 handler 不依赖 OBS
|
UUID 建立独立广播通道;账户直播事件先按 owner 提供给全部启用组件,再由组件订阅筛选,副作用 handler 不依赖 OBS
|
||||||
WebSocket 是否在线。
|
WebSocket 是否在线。
|
||||||
- `auth.rs`、`repository.rs` 和 PostgreSQL
|
- `auth.rs`、`repository.rs` 和 PostgreSQL
|
||||||
RLS 共同实现身份、凭据、直播源、组件与 token 的用户隔离;`http_api.rs`
|
RLS 共同实现身份、凭据、直播源、组件与 token 的用户隔离;`http_api.rs`
|
||||||
@@ -116,7 +116,8 @@ https://danmaku.luoxingci.com/control/setup
|
|||||||
创建和撤销邀请码。每个邀请码只能使用一次,并在创建时固定绑定一个尚未占用的 Bilibili
|
创建和撤销邀请码。每个邀请码只能使用一次,并在创建时固定绑定一个尚未占用的 Bilibili
|
||||||
`room_id`;注册者不能修改该房间,账户创建后房间绑定也不可更改。受邀用户打开注册链接,选择用户名、扫描自己的 TOTP 二维码并确认动态码即可完成注册。普通用户不能创建邀请码。
|
`room_id`;注册者不能修改该房间,账户创建后房间绑定也不可更改。受邀用户打开注册链接,选择用户名、扫描自己的 TOTP 二维码并确认动态码即可完成注册。普通用户不能创建邀请码。
|
||||||
|
|
||||||
每个用户在 `/control/` 配置自己的 CookieCloud 同步 UUID/Key 和密码,地址必须位于部署管理员配置的
|
每个用户在 `/control/account`
|
||||||
|
配置账户级直播监听和自己的 CookieCloud 同步 UUID/Key 与密码。该配置只创建一条上游连接,并供账户下全部现有和未来组件使用。地址必须位于部署管理员配置的
|
||||||
`security.cookiecloud_allowed_hosts`
|
`security.cookiecloud_allowed_hosts`
|
||||||
白名单。服务禁止 HTTP 重定向并安全编码 Key 路径,避免用户凭据导致服务端任意请求。服务会先验证凭据,再将敏感字段按用户独立加密保存;Cookie、Key 和明文密码不会返回浏览器,也不会与其他账户共享。CookieCloud 中需要存在 Bilibili
|
白名单。服务禁止 HTTP 重定向并安全编码 Key 路径,避免用户凭据导致服务端任意请求。服务会先验证凭据,再将敏感字段按用户独立加密保存;Cookie、Key 和明文密码不会返回浏览器,也不会与其他账户共享。CookieCloud 中需要存在 Bilibili
|
||||||
`SESSDATA`。
|
`SESSDATA`。
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Authenticated component studio.
|
* Authenticated component studio.
|
||||||
*
|
*
|
||||||
* This module composes tenant-scoped component settings, CookieCloud source
|
* This module composes tenant-scoped component settings, account-level live
|
||||||
* configuration, isolated test events, OBS token rotation and system-admin
|
* source configuration, isolated test events, OBS token rotation and system-admin
|
||||||
* invitations. One-time secrets remain local to the panel that created them;
|
* invitations. One-time secrets remain local to the panel that created them;
|
||||||
* update blockers prevent the PWA from refreshing until they are saved.
|
* update blockers prevent the PWA from refreshing until they are saved.
|
||||||
*/
|
*/
|
||||||
@@ -98,7 +98,7 @@ function ControlLayout({
|
|||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
user: AuthUser
|
user: AuthUser
|
||||||
active: 'components' | 'invitations'
|
active: 'components' | 'account' | 'invitations'
|
||||||
onLogout: () => Promise<void>
|
onLogout: () => Promise<void>
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}) {
|
}) {
|
||||||
@@ -117,6 +117,9 @@ function ControlLayout({
|
|||||||
<a className={active === 'components' ? 'active' : ''} href="/control/">
|
<a className={active === 'components' ? 'active' : ''} href="/control/">
|
||||||
我的组件
|
我的组件
|
||||||
</a>
|
</a>
|
||||||
|
<a className={active === 'account' ? 'active' : ''} href="/control/account">
|
||||||
|
直播账户
|
||||||
|
</a>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<a className={active === 'invitations' ? 'active' : ''} href="/control/invitations">
|
<a className={active === 'invitations' ? 'active' : ''} href="/control/invitations">
|
||||||
邀请码
|
邀请码
|
||||||
@@ -509,21 +512,17 @@ function SourceEditor({
|
|||||||
source: CookieCloudSource
|
source: CookieCloudSource
|
||||||
onSaved: (source: CookieCloudSource) => void
|
onSaved: (source: CookieCloudSource) => void
|
||||||
}) {
|
}) {
|
||||||
const [roomId, setRoomId] = useState(source.roomId)
|
const roomId = source.roomId
|
||||||
const [host, setHost] = useState(source.cookieCloud.host)
|
const [host, setHost] = useState(source.cookieCloud.host)
|
||||||
const [key, setKey] = useState(source.cookieCloud.key)
|
const [key, setKey] = useState(source.cookieCloud.key)
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [flash, setFlash] = useState<Flash>()
|
const [flash, setFlash] = useState<Flash>()
|
||||||
const sourceDirty =
|
const sourceDirty =
|
||||||
roomId !== source.roomId ||
|
host.trim() !== source.cookieCloud.host || key !== source.cookieCloud.key || Boolean(password)
|
||||||
host.trim() !== source.cookieCloud.host ||
|
|
||||||
key !== source.cookieCloud.key ||
|
|
||||||
Boolean(password)
|
|
||||||
usePwaUpdateBlocker('live-source', '保存或还原直播源与 CookieCloud 设置', busy || sourceDirty)
|
usePwaUpdateBlocker('live-source', '保存或还原直播源与 CookieCloud 设置', busy || sourceDirty)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRoomId(source.roomId)
|
|
||||||
setHost(source.cookieCloud.host)
|
setHost(source.cookieCloud.host)
|
||||||
setKey(source.cookieCloud.key)
|
setKey(source.cookieCloud.key)
|
||||||
setPassword('')
|
setPassword('')
|
||||||
@@ -535,7 +534,7 @@ function SourceEditor({
|
|||||||
setFlash(undefined)
|
setFlash(undefined)
|
||||||
try {
|
try {
|
||||||
const payload = await api<unknown>(
|
const payload = await api<unknown>(
|
||||||
'/api/v1/source',
|
'/api/v1/account/live-source',
|
||||||
json('PUT', {
|
json('PUT', {
|
||||||
roomId: roomId.trim(),
|
roomId: roomId.trim(),
|
||||||
cookieCloud: {
|
cookieCloud: {
|
||||||
@@ -548,7 +547,7 @@ function SourceEditor({
|
|||||||
const next = normalizeSource(payload)
|
const next = normalizeSource(payload)
|
||||||
onSaved(next)
|
onSaved(next)
|
||||||
setPassword('')
|
setPassword('')
|
||||||
setFlash({ kind: 'success', text: '直播源已保存,连接会使用新的隔离配置。' })
|
setFlash({ kind: 'success', text: '账户直播源已保存,所有组件会共享这条事件流。' })
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setFlash({ kind: 'error', text: errorMessage(reason, '直播源保存失败') })
|
setFlash({ kind: 'error', text: errorMessage(reason, '直播源保存失败') })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -559,7 +558,7 @@ function SourceEditor({
|
|||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
title="Bilibili 直播源"
|
title="Bilibili 直播源"
|
||||||
description="该 CookieCloud 凭据仅属于当前账户,不会与其他用户共享。"
|
description="每个账户只有一条监听连接和一份 CookieCloud 凭据;监听事件会提供给本账户的所有组件。"
|
||||||
aside={
|
aside={
|
||||||
source.connected === undefined ? undefined : (
|
source.connected === undefined ? undefined : (
|
||||||
<span className={`status-chip ${source.connected ? 'online' : 'offline'}`}>
|
<span className={`status-chip ${source.connected ? 'online' : 'offline'}`}>
|
||||||
@@ -572,13 +571,7 @@ function SourceEditor({
|
|||||||
<form className="field-grid two-columns" onSubmit={submit}>
|
<form className="field-grid two-columns" onSubmit={submit}>
|
||||||
<label>
|
<label>
|
||||||
邀请码绑定的直播间 ID
|
邀请码绑定的直播间 ID
|
||||||
<input
|
<input required readOnly inputMode="numeric" value={roomId} />
|
||||||
required
|
|
||||||
readOnly
|
|
||||||
inputMode="numeric"
|
|
||||||
value={roomId}
|
|
||||||
onChange={event => setRoomId(event.target.value)}
|
|
||||||
/>
|
|
||||||
<small>直播间由系统管理员签发邀请码时固定,用户不能自行切换。</small>
|
<small>直播间由系统管理员签发邀请码时固定,用户不能自行切换。</small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -942,7 +935,6 @@ export function ComponentsPage({
|
|||||||
const [components, setComponents] = useState<ComponentSummary[]>([])
|
const [components, setComponents] = useState<ComponentSummary[]>([])
|
||||||
const [selectedId, setSelectedId] = useState<string>()
|
const [selectedId, setSelectedId] = useState<string>()
|
||||||
const [settings, setSettings] = useState<ComponentSettings>()
|
const [settings, setSettings] = useState<ComponentSettings>()
|
||||||
const [source, setSource] = useState<CookieCloudSource>()
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [flash, setFlash] = useState<Flash>()
|
const [flash, setFlash] = useState<Flash>()
|
||||||
@@ -984,14 +976,10 @@ export function ComponentsPage({
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [componentPayload, sourcePayload] = await Promise.all([
|
const componentPayload = await api<unknown>('/api/v1/components')
|
||||||
api<unknown>('/api/v1/components'),
|
|
||||||
api<unknown>('/api/v1/source'),
|
|
||||||
])
|
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const nextComponents = normalizeComponents(componentPayload)
|
const nextComponents = normalizeComponents(componentPayload)
|
||||||
setComponents(nextComponents)
|
setComponents(nextComponents)
|
||||||
setSource(normalizeSource(sourcePayload))
|
|
||||||
const requested = new URLSearchParams(location.search).get('component')
|
const requested = new URLSearchParams(location.search).get('component')
|
||||||
const first =
|
const first =
|
||||||
nextComponents.find(component => component.id === requested) ??
|
nextComponents.find(component => component.id === requested) ??
|
||||||
@@ -1132,13 +1120,61 @@ export function ComponentsPage({
|
|||||||
<div className="empty-state">当前账户还没有可配置的组件。</div>
|
<div className="empty-state">当前账户还没有可配置的组件。</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
{source && <SourceEditor source={source} onSaved={setSource} />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ControlLayout>
|
</ControlLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Account-owned upstream configuration, intentionally separate from every
|
||||||
|
* component editor so adding future components cannot create another listener. */
|
||||||
|
export function AccountLiveSourcePage({
|
||||||
|
user,
|
||||||
|
onLogout,
|
||||||
|
}: {
|
||||||
|
user: AuthUser
|
||||||
|
onLogout: () => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [source, setSource] = useState<CookieCloudSource>()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [flash, setFlash] = useState<Flash>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
api<unknown>('/api/v1/account/live-source')
|
||||||
|
.then(payload => {
|
||||||
|
if (!cancelled) setSource(normalizeSource(payload))
|
||||||
|
})
|
||||||
|
.catch(reason => {
|
||||||
|
if (!cancelled)
|
||||||
|
setFlash({ kind: 'error', text: errorMessage(reason, '无法读取账户直播源') })
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ControlLayout user={user} active="account" onLogout={onLogout}>
|
||||||
|
<div className="admin-content">
|
||||||
|
<div className="page-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">ACCOUNT LIVE STREAM</p>
|
||||||
|
<h1>直播账户</h1>
|
||||||
|
<p>配置一次直播间与 CookieCloud,供当前账户下所有现有及未来组件使用。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FlashMessage flash={flash} />
|
||||||
|
{loading && <div className="loading-panel jade-panel">正在读取账户直播源…</div>}
|
||||||
|
{source && <SourceEditor source={source} onSaved={setSource} />}
|
||||||
|
</div>
|
||||||
|
</ControlLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCompleteSongQueue(componentId: string): Promise<SongRequestPage> {
|
async function loadCompleteSongQueue(componentId: string): Promise<SongRequestPage> {
|
||||||
// A queue may change between paginated HTTP requests. Restart instead of
|
// A queue may change between paginated HTTP requests. Restart instead of
|
||||||
// presenting pages from different revisions as one ordering.
|
// presenting pages from different revisions as one ordering.
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { ApiError, api, errorMessage, json, normalizeSession } from './api'
|
import { ApiError, api, errorMessage, json, normalizeSession } from './api'
|
||||||
import { EnrollmentPage, LoginPage } from './auth'
|
import { EnrollmentPage, LoginPage } from './auth'
|
||||||
import { ComponentsPage, ForbiddenPage, InvitationsPage, SongRequestsPage } from './control'
|
import {
|
||||||
|
AccountLiveSourcePage,
|
||||||
|
ComponentsPage,
|
||||||
|
ForbiddenPage,
|
||||||
|
InvitationsPage,
|
||||||
|
SongRequestsPage,
|
||||||
|
} from './control'
|
||||||
import { Overlay, tokenFromFragment } from './overlay'
|
import { Overlay, tokenFromFragment } from './overlay'
|
||||||
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
|
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
|
||||||
import { SongRequestOverlay } from './songOverlay'
|
import { SongRequestOverlay } from './songOverlay'
|
||||||
@@ -166,6 +172,10 @@ function App() {
|
|||||||
if (!session.user) return <Redirect to="/control/login" />
|
if (!session.user) return <Redirect to="/control/login" />
|
||||||
return <ComponentsPage user={session.user} onLogout={logout} />
|
return <ComponentsPage user={session.user} onLogout={logout} />
|
||||||
}
|
}
|
||||||
|
if (path === '/control/account') {
|
||||||
|
if (!session.user) return <Redirect to="/control/login" />
|
||||||
|
return <AccountLiveSourcePage user={session.user} onLogout={logout} />
|
||||||
|
}
|
||||||
if (path === '/control/invitations') {
|
if (path === '/control/invitations') {
|
||||||
if (!session.user) return <Redirect to="/control/login" />
|
if (!session.user) return <Redirect to="/control/login" />
|
||||||
if (session.user.role !== 'system_admin')
|
if (session.user.role !== 'system_admin')
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ crate 导出。
|
|||||||
| `live` | provider trait、Bilibili adapter 与 source supervisor |
|
| `live` | provider trait、Bilibili adapter 与 source supervisor |
|
||||||
| `overlay` | 弹幕姬设置及礼物/表情目录 |
|
| `overlay` | 弹幕姬设置及礼物/表情目录 |
|
||||||
| `rate_limit` | 匿名登录和 enrollment 滥用限制 |
|
| `rate_limit` | 匿名登录和 enrollment 滥用限制 |
|
||||||
| `realtime` | source event routing 与 component-scoped fanout |
|
| `realtime` | account event routing 与 component-scoped fanout |
|
||||||
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
|
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
|
||||||
| `song_request` | 点歌命令、事务队列、评分、快照和管理服务 |
|
| `song_request` | 点歌命令、事务队列、评分、快照和管理服务 |
|
||||||
|
|
||||||
## 重要不变量
|
## 重要不变量
|
||||||
|
|
||||||
- handler 不能信任请求体中的 owner;owner 必须来自 session 或 source context。
|
- handler 不能信任请求体中的 owner;owner 必须来自 session 或账户 source context。
|
||||||
|
- `component_instances` 不保存 source 绑定;账户唯一的监听事件会按 owner 提供给其全部启用组件。
|
||||||
- tenant table 查询必须在 `Db::set_tenant` 后的事务中执行。
|
- tenant table 查询必须在 `Db::set_tenant` 后的事务中执行。
|
||||||
- provider 只能输出 canonical、bounded、sanitized `LiveEvent`。
|
- provider 只能输出 canonical、bounded、sanitized `LiveEvent`。
|
||||||
- `libilibili` listener 必须保持 20 秒心跳;断线后由 adapter 重新获取弹幕 host/token 并重建 socket。
|
- `libilibili` listener 必须保持 20 秒心跳;断线后由 adapter 重新获取弹幕 host/token 并重建 socket。
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Move live-listener ownership fully to the account boundary.
|
||||||
|
--
|
||||||
|
-- `live_sources.owner_user_id` and `cookiecloud_credentials.user_id` already
|
||||||
|
-- enforce one listener configuration and one credential source per account.
|
||||||
|
-- The component-level `source_id` foreign key was therefore redundant and,
|
||||||
|
-- more importantly, made new component kinds look as though they selected
|
||||||
|
-- their own upstream listener. Components now subscribe to their owner's
|
||||||
|
-- canonical event stream and retain only their account ownership.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS component_instances_source_idx;
|
||||||
|
|
||||||
|
ALTER TABLE component_instances
|
||||||
|
DROP CONSTRAINT IF EXISTS component_instances_owner_user_id_source_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE component_instances
|
||||||
|
DROP COLUMN IF EXISTS source_id;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS component_instances_owner_enabled_idx
|
||||||
|
ON component_instances(owner_user_id, enabled, created_at);
|
||||||
|
|
||||||
|
COMMENT ON TABLE live_sources IS
|
||||||
|
'One immutable Bilibili live-listener configuration per account.';
|
||||||
|
COMMENT ON TABLE cookiecloud_credentials IS
|
||||||
|
'One encrypted CookieCloud credential source per account.';
|
||||||
|
COMMENT ON TABLE component_instances IS
|
||||||
|
'Account-owned consumers of the owning account live-event stream.';
|
||||||
@@ -279,6 +279,10 @@ async fn migrate(db: &Db) -> Result<(), String> {
|
|||||||
(3_i32, include_str!("../migrations/003_multitenancy.sql")),
|
(3_i32, include_str!("../migrations/003_multitenancy.sql")),
|
||||||
(4_i32, include_str!("../migrations/004_auth_hardening.sql")),
|
(4_i32, include_str!("../migrations/004_auth_hardening.sql")),
|
||||||
(5_i32, include_str!("../migrations/005_song_request.sql")),
|
(5_i32, include_str!("../migrations/005_song_request.sql")),
|
||||||
|
(
|
||||||
|
6_i32,
|
||||||
|
include_str!("../migrations/006_account_live_source.sql"),
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let applied = transaction
|
let applied = transaction
|
||||||
.query_one(
|
.query_one(
|
||||||
|
|||||||
@@ -611,14 +611,9 @@ impl AuthService {
|
|||||||
.expect("OverlaySettings is always JSON serializable");
|
.expect("OverlaySettings is always JSON serializable");
|
||||||
transaction
|
transaction
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO component_instances(id,owner_user_id,source_id,kind,name,settings) \
|
"INSERT INTO component_instances(id,owner_user_id,kind,name,settings) \
|
||||||
VALUES($1,$2,$3,'danmaku_overlay','弹幕姬',$4)",
|
VALUES($1,$2,'danmaku_overlay','弹幕姬',$3)",
|
||||||
&[
|
&[&default_component_id, &user_id, &default_settings],
|
||||||
&default_component_id,
|
|
||||||
&user_id,
|
|
||||||
&default_source_id,
|
|
||||||
&default_settings,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let song_component_id = Uuid::new_v4();
|
let song_component_id = Uuid::new_v4();
|
||||||
@@ -627,12 +622,11 @@ impl AuthService {
|
|||||||
transaction
|
transaction
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO component_instances \
|
"INSERT INTO component_instances \
|
||||||
(id,owner_user_id,source_id,kind,name,settings,settings_version,enabled) \
|
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||||
VALUES($1,$2,$3,$4,$5,$6,1,true)",
|
VALUES($1,$2,$3,$4,$5,1,true)",
|
||||||
&[
|
&[
|
||||||
&song_component_id,
|
&song_component_id,
|
||||||
&user_id,
|
&user_id,
|
||||||
&default_source_id,
|
|
||||||
&SONG_REQUEST_KIND,
|
&SONG_REQUEST_KIND,
|
||||||
&SONG_REQUEST_NAME,
|
&SONG_REQUEST_NAME,
|
||||||
&song_settings,
|
&song_settings,
|
||||||
@@ -1240,13 +1234,6 @@ impl AuthService {
|
|||||||
.ok_or(AuthError::InvalidCredentials)?
|
.ok_or(AuthError::InvalidCredentials)?
|
||||||
.get(0);
|
.get(0);
|
||||||
Db::set_tenant(&transaction, owner_user_id).await?;
|
Db::set_tenant(&transaction, owner_user_id).await?;
|
||||||
let source_id: Uuid = transaction
|
|
||||||
.query_one(
|
|
||||||
"SELECT id FROM live_sources WHERE owner_user_id=$1",
|
|
||||||
&[&owner_user_id],
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.get(0);
|
|
||||||
let component_id = if let Some(row) = transaction
|
let component_id = if let Some(row) = transaction
|
||||||
.query_opt(
|
.query_opt(
|
||||||
"SELECT id FROM component_instances \
|
"SELECT id FROM component_instances \
|
||||||
@@ -1268,9 +1255,9 @@ impl AuthService {
|
|||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
transaction
|
transaction
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO component_instances(id,owner_user_id,source_id,kind,name,settings) \
|
"INSERT INTO component_instances(id,owner_user_id,kind,name,settings) \
|
||||||
VALUES($1,$2,$3,'danmaku_overlay','弹幕姬',$4)",
|
VALUES($1,$2,'danmaku_overlay','弹幕姬',$3)",
|
||||||
&[&id, &owner_user_id, &source_id, &settings],
|
&[&id, &owner_user_id, &settings],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -84,7 +84,10 @@ pub struct ComponentInstance {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing)]
|
||||||
pub owner_id: Uuid,
|
pub owner_id: Uuid,
|
||||||
pub source_id: Uuid,
|
/// Runtime identity of the owner's account-level source. This value is
|
||||||
|
/// derived while loading the component and is not stored on its row.
|
||||||
|
#[serde(rename = "sourceId")]
|
||||||
|
pub account_source_id: Uuid,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -95,7 +98,7 @@ pub struct ComponentInstance {
|
|||||||
impl ComponentInstance {
|
impl ComponentInstance {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
owner_id: Uuid,
|
owner_id: Uuid,
|
||||||
source_id: Uuid,
|
account_source_id: Uuid,
|
||||||
kind: impl Into<String>,
|
kind: impl Into<String>,
|
||||||
name: impl Into<String>,
|
name: impl Into<String>,
|
||||||
settings_version: u32,
|
settings_version: u32,
|
||||||
@@ -104,7 +107,7 @@ impl ComponentInstance {
|
|||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
owner_id,
|
owner_id,
|
||||||
source_id,
|
account_source_id,
|
||||||
kind: kind.into(),
|
kind: kind.into(),
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -108,8 +108,11 @@ impl Db {
|
|||||||
Self::set_tenant(&transaction, user_id).await?;
|
Self::set_tenant(&transaction, user_id).await?;
|
||||||
let rows = transaction
|
let rows = transaction
|
||||||
.query(
|
.query(
|
||||||
"SELECT id,source_id,kind,name,settings,settings_version,enabled \
|
"SELECT component.id,source.id,component.kind,component.name,component.settings,\
|
||||||
FROM component_instances WHERE owner_user_id=$1 ORDER BY created_at",
|
component.settings_version,component.enabled \
|
||||||
|
FROM component_instances AS component \
|
||||||
|
JOIN live_sources AS source ON source.owner_user_id=component.owner_user_id \
|
||||||
|
WHERE component.owner_user_id=$1 ORDER BY component.created_at",
|
||||||
&[&user_id],
|
&[&user_id],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -119,7 +122,7 @@ impl Db {
|
|||||||
.map(|row| ComponentRecord {
|
.map(|row| ComponentRecord {
|
||||||
id: row.get(0),
|
id: row.get(0),
|
||||||
owner_user_id: user_id,
|
owner_user_id: user_id,
|
||||||
source_id: row.get(1),
|
account_source_id: row.get(1),
|
||||||
kind: row.get(2),
|
kind: row.get(2),
|
||||||
name: row.get(3),
|
name: row.get(3),
|
||||||
settings: row.get(4),
|
settings: row.get(4),
|
||||||
@@ -141,7 +144,9 @@ pub struct ActiveTenant {
|
|||||||
pub struct ComponentRecord {
|
pub struct ComponentRecord {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub owner_user_id: Uuid,
|
pub owner_user_id: Uuid,
|
||||||
pub source_id: Uuid,
|
/// Derived from the account's singleton live source; it is not component
|
||||||
|
/// configuration and is never persisted on `component_instances`.
|
||||||
|
pub account_source_id: Uuid,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub settings: Value,
|
pub settings: Value,
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ impl ComponentMessage {
|
|||||||
Self {
|
Self {
|
||||||
owner_id: component.owner_id,
|
owner_id: component.owner_id,
|
||||||
component_id: component.id,
|
component_id: component.id,
|
||||||
source_id: component.source_id,
|
source_id: component.account_source_id,
|
||||||
version: COMPONENT_PROTOCOL_VERSION,
|
version: COMPONENT_PROTOCOL_VERSION,
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/v1/invitations/{id}", delete(revoke_invitation))
|
.route("/api/v1/invitations/{id}", delete(revoke_invitation))
|
||||||
.route("/api/v1/source", get(get_source).put(put_source))
|
.route("/api/v1/source", get(get_source).put(put_source))
|
||||||
.route("/api/v1/source/reconnect", post(reconnect_source))
|
.route("/api/v1/source/reconnect", post(reconnect_source))
|
||||||
|
.route(
|
||||||
|
"/api/v1/account/live-source",
|
||||||
|
get(get_source).put(put_source),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/account/live-source/reconnect",
|
||||||
|
post(reconnect_source),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/components",
|
"/api/v1/components",
|
||||||
get(list_components).post(create_component),
|
get(list_components).post(create_component),
|
||||||
@@ -108,6 +116,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/control/register", get(spa_page))
|
.route("/control/register", get(spa_page))
|
||||||
.route("/control/setup", get(spa_page))
|
.route("/control/setup", get(spa_page))
|
||||||
.route("/control/invitations", get(spa_page))
|
.route("/control/invitations", get(spa_page))
|
||||||
|
.route("/control/account", get(spa_page))
|
||||||
.route("/obs/{public_id}", get(spa_page))
|
.route("/obs/{public_id}", get(spa_page))
|
||||||
.fallback_service(
|
.fallback_service(
|
||||||
ServeDir::new("/app/web").not_found_service(ServeFile::new("/app/web/index.html")),
|
ServeDir::new("/app/web").not_found_service(ServeFile::new("/app/web/index.html")),
|
||||||
@@ -899,7 +908,7 @@ async fn component_test_event(
|
|||||||
};
|
};
|
||||||
let mut event = LiveEvent::new(
|
let mut event = LiveEvent::new(
|
||||||
component.owner_id,
|
component.owner_id,
|
||||||
component.source_id,
|
component.account_source_id,
|
||||||
"test",
|
"test",
|
||||||
session.user.room_id,
|
session.user.room_id,
|
||||||
payload,
|
payload,
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ impl SourceStatus {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SourceContext {
|
pub struct SourceContext {
|
||||||
pub owner_id: Uuid,
|
pub owner_id: Uuid,
|
||||||
/// Stable database identity for the account's single fixed live source.
|
/// Stable identity for the account's single fixed listener. Components do
|
||||||
/// It remains distinct from the owner id so future provider/source models
|
/// not persist or select this ID; it remains in event envelopes so clients
|
||||||
/// do not leak the current one-room product rule into event contracts.
|
/// can identify the shared upstream connection.
|
||||||
pub source_id: Uuid,
|
pub source_id: Uuid,
|
||||||
pub room_id: String,
|
pub room_id: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Per-source task ownership, restart and cancellation.
|
//! Per-account source task ownership, restart and cancellation.
|
||||||
//!
|
//!
|
||||||
//! The supervisor guarantees at most one provider generation for a source ID.
|
//! The supervisor guarantees at most one provider generation for a source ID.
|
||||||
//! Reconfiguration cancels the old task before a replacement starts, preventing
|
//! Reconfiguration cancels the old task before a replacement starts, preventing
|
||||||
@@ -27,7 +27,7 @@ struct RunningSource {
|
|||||||
status: watch::Receiver<SourceStatus>,
|
status: watch::Receiver<SourceStatus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owns exactly one provider task per account/source. Restart always cancels
|
/// Owns exactly one provider task per account. Restart always cancels
|
||||||
/// the prior generation before starting another, which prevents the duplicate
|
/// the prior generation before starting another, which prevents the duplicate
|
||||||
/// listeners produced by the legacy `/reconnect` handler.
|
/// listeners produced by the legacy `/reconnect` handler.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Tenant-aware event routing and component-scoped realtime fanout.
|
//! Tenant-aware event routing and component-scoped realtime fanout.
|
||||||
//!
|
//!
|
||||||
//! The router resolves enabled instances by owner and source, validates their
|
//! The router resolves every enabled instance owned by the account, validates its
|
||||||
//! settings/subscriptions, runs durable handlers, then publishes passive
|
//! settings/subscriptions, runs durable handlers, then publishes passive
|
||||||
//! projections. Each component owns a separate broadcast channel; there is no
|
//! projections. Each component owns a separate broadcast channel; there is no
|
||||||
//! global receiver that could accidentally observe another tenant's events.
|
//! global receiver that could accidentally observe another tenant's events.
|
||||||
@@ -126,15 +126,11 @@ impl Error for ComponentStoreError {}
|
|||||||
pub type ComponentStoreFuture<'a> =
|
pub type ComponentStoreFuture<'a> =
|
||||||
Pin<Box<dyn Future<Output = Result<Vec<ComponentInstance>, ComponentStoreError>> + Send + 'a>>;
|
Pin<Box<dyn Future<Output = Result<Vec<ComponentInstance>, ComponentStoreError>> + Send + 'a>>;
|
||||||
|
|
||||||
/// Persistence port used by source routing. A PostgreSQL implementation should
|
/// Persistence port used by account-stream routing. A provider event is offered
|
||||||
/// always scope its query by both owner and source; the router repeats that
|
/// to every enabled component owned by that account; each component definition
|
||||||
/// check as defense in depth.
|
/// then filters it through its event subscription.
|
||||||
pub trait ComponentInstanceStore: Send + Sync {
|
pub trait ComponentInstanceStore: Send + Sync {
|
||||||
fn list_enabled_for_source<'a>(
|
fn list_enabled_for_owner<'a>(&'a self, owner_id: Uuid) -> ComponentStoreFuture<'a>;
|
||||||
&'a self,
|
|
||||||
owner_id: Uuid,
|
|
||||||
source_id: Uuid,
|
|
||||||
) -> ComponentStoreFuture<'a>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
@@ -173,22 +169,14 @@ impl InMemoryComponentStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ComponentInstanceStore for InMemoryComponentStore {
|
impl ComponentInstanceStore for InMemoryComponentStore {
|
||||||
fn list_enabled_for_source<'a>(
|
fn list_enabled_for_owner<'a>(&'a self, owner_id: Uuid) -> ComponentStoreFuture<'a> {
|
||||||
&'a self,
|
|
||||||
owner_id: Uuid,
|
|
||||||
source_id: Uuid,
|
|
||||||
) -> ComponentStoreFuture<'a> {
|
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
Ok(self
|
Ok(self
|
||||||
.instances
|
.instances
|
||||||
.read()
|
.read()
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|instance| {
|
.filter(|instance| instance.enabled && instance.owner_id == owner_id)
|
||||||
instance.enabled
|
|
||||||
&& instance.owner_id == owner_id
|
|
||||||
&& instance.source_id == source_id
|
|
||||||
})
|
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect())
|
.collect())
|
||||||
})
|
})
|
||||||
@@ -229,7 +217,7 @@ pub enum RouteError {
|
|||||||
impl fmt::Display for RouteError {
|
impl fmt::Display for RouteError {
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::Store(error) => write!(formatter, "cannot resolve source components: {error}"),
|
Self::Store(error) => write!(formatter, "cannot resolve account components: {error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,7 +270,7 @@ impl SourceEventRouter {
|
|||||||
pub async fn route(&self, event: Arc<LiveEvent>) -> Result<RouteReport, RouteError> {
|
pub async fn route(&self, event: Arc<LiveEvent>) -> Result<RouteReport, RouteError> {
|
||||||
let components = self
|
let components = self
|
||||||
.components
|
.components
|
||||||
.list_enabled_for_source(event.owner_id, event.source_id)
|
.list_enabled_for_owner(event.owner_id)
|
||||||
.await?;
|
.await?;
|
||||||
let mut report = RouteReport {
|
let mut report = RouteReport {
|
||||||
considered: components.len(),
|
considered: components.len(),
|
||||||
@@ -290,14 +278,11 @@ impl SourceEventRouter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for component in components {
|
for component in components {
|
||||||
if !component.enabled
|
if !component.enabled || component.owner_id != event.owner_id {
|
||||||
|| component.owner_id != event.owner_id
|
|
||||||
|| component.source_id != event.source_id
|
|
||||||
{
|
|
||||||
report.failures.push(RouteFailure {
|
report.failures.push(RouteFailure {
|
||||||
component_id: component.id,
|
component_id: component.id,
|
||||||
stage: RouteStage::Scope,
|
stage: RouteStage::Scope,
|
||||||
detail: "component owner/source does not match the source event".into(),
|
detail: "component owner does not match the account event stream".into(),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -349,7 +334,7 @@ impl SourceEventRouter {
|
|||||||
match runtime.project(&component, &event) {
|
match runtime.project(&component, &event) {
|
||||||
Ok(Some(message)) => {
|
Ok(Some(message)) => {
|
||||||
if message.owner_id != component.owner_id
|
if message.owner_id != component.owner_id
|
||||||
|| message.source_id != component.source_id
|
|| message.source_id != event.source_id
|
||||||
|| message.component_id != component.id
|
|| message.component_id != component.id
|
||||||
{
|
{
|
||||||
report.failures.push(RouteFailure {
|
report.failures.push(RouteFailure {
|
||||||
@@ -517,16 +502,38 @@ mod tests {
|
|||||||
assert!(report.failures.is_empty());
|
assert!(report.failures.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn one_account_event_is_offered_to_all_owned_components() {
|
||||||
|
let owner_id = Uuid::new_v4();
|
||||||
|
let account_source_id = Uuid::new_v4();
|
||||||
|
let first = overlay(owner_id, account_source_id);
|
||||||
|
let second = overlay(owner_id, account_source_id);
|
||||||
|
let first_id = first.id;
|
||||||
|
let second_id = second.id;
|
||||||
|
let hub = EventHub::new(8);
|
||||||
|
let mut first_rx = hub.subscribe(first_id);
|
||||||
|
let mut second_rx = hub.subscribe(second_id);
|
||||||
|
let store = Arc::new(InMemoryComponentStore::new(vec![first, second]));
|
||||||
|
let router = SourceEventRouter::new(ComponentRegistry::default(), store, hub);
|
||||||
|
|
||||||
|
let report = router
|
||||||
|
.route(danmaku(owner_id, account_source_id))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(report.considered, 2);
|
||||||
|
assert_eq!(report.projected, 2);
|
||||||
|
assert_eq!(first_rx.try_recv().unwrap().component_id, first_id);
|
||||||
|
assert_eq!(second_rx.try_recv().unwrap().component_id, second_id);
|
||||||
|
assert!(report.failures.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
struct LeakyStore {
|
struct LeakyStore {
|
||||||
instances: Vec<ComponentInstance>,
|
instances: Vec<ComponentInstance>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ComponentInstanceStore for LeakyStore {
|
impl ComponentInstanceStore for LeakyStore {
|
||||||
fn list_enabled_for_source<'a>(
|
fn list_enabled_for_owner<'a>(&'a self, _owner_id: Uuid) -> ComponentStoreFuture<'a> {
|
||||||
&'a self,
|
|
||||||
_owner_id: Uuid,
|
|
||||||
_source_id: Uuid,
|
|
||||||
) -> ComponentStoreFuture<'a> {
|
|
||||||
Box::pin(async move { Ok(self.instances.clone()) })
|
Box::pin(async move { Ok(self.instances.clone()) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! PostgreSQL is authoritative. Successful writes are reflected into the
|
//! PostgreSQL is authoritative. Successful writes are reflected into the
|
||||||
//! in-process [`InMemoryComponentStore`] used by the hot event path; startup and
|
//! in-process [`InMemoryComponentStore`] used by the hot event path; startup and
|
||||||
//! source restarts hydrate that cache from tenant-scoped rows before events are
|
//! account-source restarts hydrate that cache from tenant-scoped rows before events are
|
||||||
//! routed.
|
//! routed.
|
||||||
|
|
||||||
use std::{fmt, sync::Arc};
|
use std::{fmt, sync::Arc};
|
||||||
@@ -69,12 +69,11 @@ impl TenantRepository {
|
|||||||
transaction
|
transaction
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO component_instances \
|
"INSERT INTO component_instances \
|
||||||
(id,owner_user_id,source_id,kind,name,settings,settings_version,enabled) \
|
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||||
VALUES($1,$2,$3,$4,$5,$6,1,true) ON CONFLICT DO NOTHING",
|
VALUES($1,$2,$3,$4,$5,1,true) ON CONFLICT DO NOTHING",
|
||||||
&[
|
&[
|
||||||
&id,
|
&id,
|
||||||
&tenant.user_id,
|
&tenant.user_id,
|
||||||
&tenant.source_id,
|
|
||||||
&SONG_REQUEST_KIND,
|
&SONG_REQUEST_KIND,
|
||||||
&SONG_REQUEST_NAME,
|
&SONG_REQUEST_NAME,
|
||||||
&settings,
|
&settings,
|
||||||
@@ -159,12 +158,11 @@ impl TenantRepository {
|
|||||||
transaction
|
transaction
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO component_instances \
|
"INSERT INTO component_instances \
|
||||||
(id,owner_user_id,source_id,kind,name,settings,settings_version,enabled) \
|
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||||
VALUES($1,$2,$3,$4,$5,$6,$7,true)",
|
VALUES($1,$2,$3,$4,$5,$6,true)",
|
||||||
&[
|
&[
|
||||||
&component.id,
|
&component.id,
|
||||||
&owner_id,
|
&owner_id,
|
||||||
&source_id,
|
|
||||||
&component.kind,
|
&component.kind,
|
||||||
&component.name,
|
&component.name,
|
||||||
&component.settings,
|
&component.settings,
|
||||||
@@ -220,8 +218,11 @@ impl TenantRepository {
|
|||||||
Db::set_tenant(&transaction, owner_id).await?;
|
Db::set_tenant(&transaction, owner_id).await?;
|
||||||
let row = transaction
|
let row = transaction
|
||||||
.query_opt(
|
.query_opt(
|
||||||
"SELECT id,source_id,kind,name,settings,settings_version,enabled \
|
"SELECT component.id,source.id,component.kind,component.name,component.settings,\
|
||||||
FROM component_instances WHERE owner_user_id=$1 AND id=$2",
|
component.settings_version,component.enabled \
|
||||||
|
FROM component_instances AS component \
|
||||||
|
JOIN live_sources AS source ON source.owner_user_id=component.owner_user_id \
|
||||||
|
WHERE component.owner_user_id=$1 AND component.id=$2",
|
||||||
&[&owner_id, &component_id],
|
&[&owner_id, &component_id],
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
@@ -230,7 +231,7 @@ impl TenantRepository {
|
|||||||
self.validate_loaded_component(component_from_record(ComponentRecord {
|
self.validate_loaded_component(component_from_record(ComponentRecord {
|
||||||
id: row.get(0),
|
id: row.get(0),
|
||||||
owner_user_id: owner_id,
|
owner_user_id: owner_id,
|
||||||
source_id: row.get(1),
|
account_source_id: row.get(1),
|
||||||
kind: row.get(2),
|
kind: row.get(2),
|
||||||
name: row.get(3),
|
name: row.get(3),
|
||||||
settings: row.get(4),
|
settings: row.get(4),
|
||||||
@@ -423,7 +424,7 @@ fn component_from_record(record: ComponentRecord) -> Result<ComponentInstance, R
|
|||||||
Ok(ComponentInstance {
|
Ok(ComponentInstance {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
owner_id: record.owner_user_id,
|
owner_id: record.owner_user_id,
|
||||||
source_id: record.source_id,
|
account_source_id: record.account_source_id,
|
||||||
kind: record.kind,
|
kind: record.kind,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
enabled: record.enabled,
|
enabled: record.enabled,
|
||||||
|
|||||||
@@ -678,8 +678,8 @@ async fn ensure_song_component(
|
|||||||
let valid: bool = transaction
|
let valid: bool = transaction
|
||||||
.query_one(
|
.query_one(
|
||||||
"SELECT EXISTS(SELECT 1 FROM component_instances WHERE owner_user_id=$1 \
|
"SELECT EXISTS(SELECT 1 FROM component_instances WHERE owner_user_id=$1 \
|
||||||
AND id=$2 AND source_id=$3 AND kind='song_request' AND enabled)",
|
AND id=$2 AND kind='song_request' AND enabled)",
|
||||||
&[&component.owner_id, &component.id, &component.source_id],
|
&[&component.owner_id, &component.id],
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
.get(0);
|
.get(0);
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
## 核心目标
|
## 核心目标
|
||||||
|
|
||||||
- 每个账户固定绑定一个 Bilibili 直播间和一个独立直播源。
|
- 每个账户固定绑定一个 Bilibili 直播间、一个 CookieCloud 来源和一条独立监听连接。
|
||||||
- 一个直播源只建立一条上游连接,但可以把事件投递给多个组件实例。
|
- 组件不绑定或选择直播源;账户事件流会提供给该账户所有启用的组件实例。
|
||||||
- 平台原始命令先转换成稳定的领域事件,组件不直接依赖 Bilibili `CMD`。
|
- 平台原始命令先转换成稳定的领域事件,组件不直接依赖 Bilibili `CMD`。
|
||||||
- HTTP 会话、直播源、组件、OBS token 和实时通道均以租户为边界。
|
- HTTP 会话、直播源、组件、OBS token 和实时通道均以租户为边界。
|
||||||
- 新组件可以增加设置、投影和持久化副作用,而不修改直播连接核心。
|
- 新组件可以增加设置、投影和持久化副作用,而不修改直播连接核心。
|
||||||
@@ -32,7 +32,9 @@ flowchart LR
|
|||||||
1. `SourceSupervisor` 为每个 `source_id` 保持至多一个 provider task。
|
1. `SourceSupervisor` 为每个 `source_id` 保持至多一个 provider task。
|
||||||
2. `BilibiliProvider` 使用该用户加密保存的 CookieCloud 凭据构造 `libilibili`
|
2. `BilibiliProvider` 使用该用户加密保存的 CookieCloud 凭据构造 `libilibili`
|
||||||
客户端;crate 负责 WBI、WebSocket 与压缩包解析,adapter 再把强类型命令转换成 `LiveEvent`。
|
客户端;crate 负责 WBI、WebSocket 与压缩包解析,adapter 再把强类型命令转换成 `LiveEvent`。
|
||||||
3. `SourceEventRouter` 同时使用 `owner_id` 与 `source_id` 查找启用的组件,并再次检查组件归属。
|
3. `SourceEventRouter` 按 `owner_id`
|
||||||
|
取得该账户所有启用组件,再由各组件的订阅声明筛选事件类型;`source_id`
|
||||||
|
只标识账户级监听,不存储在组件行中。
|
||||||
4. 匹配订阅后,路由器先执行可持久化的 `EventHandler`,再执行无副作用的 `EventProjection`。
|
4. 匹配订阅后,路由器先执行可持久化的 `EventHandler`,再执行无副作用的 `EventProjection`。
|
||||||
5. 投影结果只发布到该 `component_id` 的广播通道。没有全局 WebSocket 事件总线。
|
5. 投影结果只发布到该 `component_id` 的广播通道。没有全局 WebSocket 事件总线。
|
||||||
6. OBS 使用组件级只读 token 订阅一个组件,不能读取控制台 API。
|
6. OBS 使用组件级只读 token 订阅一个组件,不能读取控制台 API。
|
||||||
@@ -42,8 +44,8 @@ flowchart LR
|
|||||||
| 状态 | 权威来源 | 内存副本 | 说明 |
|
| 状态 | 权威来源 | 内存副本 | 说明 |
|
||||||
| ---------------- | ------------ | ------------------------ | ----------------------------- |
|
| ---------------- | ------------ | ------------------------ | ----------------------------- |
|
||||||
| 用户、TOTP、会话 | PostgreSQL | 无 | Secret 加密,token 只保存摘要 |
|
| 用户、TOTP、会话 | PostgreSQL | 无 | Secret 加密,token 只保存摘要 |
|
||||||
| CookieCloud 凭据 | PostgreSQL | provider 构建期间解密 | 不返回浏览器 |
|
| CookieCloud 凭据 | PostgreSQL | provider 构建期间解密 | 每账户一份,不返回浏览器 |
|
||||||
| 直播源和房间 | PostgreSQL | `SourceSupervisor` | 每用户固定一个房间 |
|
| 直播源和房间 | PostgreSQL | `SourceSupervisor` | 每账户固定一个房间和连接 |
|
||||||
| 组件实例与设置 | PostgreSQL | `InMemoryComponentStore` | 写入成功后刷新热路径缓存 |
|
| 组件实例与设置 | PostgreSQL | `InMemoryComponentStore` | 写入成功后刷新热路径缓存 |
|
||||||
| 礼物/表情目录 | Bilibili API | provider catalog | 刷新失败保留最近成功快照 |
|
| 礼物/表情目录 | Bilibili API | provider catalog | 刷新失败保留最近成功快照 |
|
||||||
| 实时消息 | provider | `EventHub` 有界广播 | 不作为业务持久化机制 |
|
| 实时消息 | provider | `EventHub` 有界广播 | 不作为业务持久化机制 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 组件开发指南
|
# 组件开发指南
|
||||||
|
|
||||||
组件是“一个直播源上的独立功能实例”。当前内建 `danmaku_overlay` 与
|
组件是“消费所属账户事件流的独立功能实例”。当前内建 `danmaku_overlay` 与
|
||||||
`song_request`,未来礼物墙或统计组件也应使用同一套契约。
|
`song_request`,未来礼物墙或统计组件也应使用同一套契约。
|
||||||
|
|
||||||
## 一个组件由什么组成
|
## 一个组件由什么组成
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
| 定义 | `ComponentDefinition` | kind、设置版本、默认值、校验、迁移、订阅 |
|
| 定义 | `ComponentDefinition` | kind、设置版本、默认值、校验、迁移、订阅 |
|
||||||
| 投影 | `EventProjection` | 把 `LiveEvent` 转成浏览器消息,不执行持久化副作用 |
|
| 投影 | `EventProjection` | 把 `LiveEvent` 转成浏览器消息,不执行持久化副作用 |
|
||||||
| Handler | `EventHandler` | 可选的数据库写入、点歌或外部动作 |
|
| Handler | `EventHandler` | 可选的数据库写入、点歌或外部动作 |
|
||||||
| 实例 | `ComponentInstance` | owner、source、kind、名称、设置和启用状态 |
|
| 实例 | `ComponentInstance` | owner、kind、名称、设置和启用状态 |
|
||||||
| 实时通道 | `EventHub` | 按 component ID 隔离的有界广播 |
|
| 实时通道 | `EventHub` | 按 component ID 隔离的有界广播 |
|
||||||
| 前端 | React renderer/editor | 管理设置、测试与 OBS 展示 |
|
| 前端 | React renderer/editor | 管理设置、测试与 OBS 展示 |
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
4. 实现无副作用的 `EventProjection`。返回 `None` 表示该事件无需发送浏览器。
|
4. 实现无副作用的 `EventProjection`。返回 `None` 表示该事件无需发送浏览器。
|
||||||
5. 若需要可靠业务动作,实现 `EventHandler`:
|
5. 若需要可靠业务动作,实现 `EventHandler`:
|
||||||
- 即使没有 OBS 客户端也会执行。
|
- 即使没有 OBS 客户端也会执行。
|
||||||
- 数据库操作必须包含 owner/source/component 条件。
|
- 数据库操作必须包含 owner/component 条件。
|
||||||
- 上游可能重试或出现组合事件,因此 handler 自己负责幂等。
|
- 上游可能重试或出现组合事件,因此 handler 自己负责幂等。
|
||||||
6. 在 `ComponentRegistry::with_builtin_components` 注册定义与投影,再注册 handler。
|
6. 在 `ComponentRegistry::with_builtin_components` 注册定义与投影,再注册 handler。
|
||||||
7. 增加数据库创建/设置 API;不要把组件专属关系数据无限塞入 JSON settings。
|
7. 增加数据库创建/设置 API;不要把组件专属关系数据无限塞入 JSON settings。
|
||||||
@@ -49,8 +49,8 @@ Handler 面向“业务事实”。例如点歌请求、礼物累计或审计写
|
|||||||
|
|
||||||
## 租户与 token 规则
|
## 租户与 token 规则
|
||||||
|
|
||||||
- 组件必须属于同一个 owner 与 source。
|
- 组件必须属于经过认证的 owner,不能选择或覆盖账户直播源。
|
||||||
- 路由和数据库查询都要重复检查这一关系。
|
- 路由和数据库查询都要重复检查 owner;账户事件可被其所有启用组件订阅。
|
||||||
- 每个组件单独签发 access token,默认只有 `events:subscribe`。
|
- 每个组件单独签发 access token,默认只有 `events:subscribe`。
|
||||||
- 删除组件或轮换 token 时,关闭该组件 channel,使已有 socket 立即失效。
|
- 删除组件或轮换 token 时,关闭该组件 channel,使已有 socket 立即失效。
|
||||||
- 组件 WebSocket 不得暴露其他组件列表或控制 API。
|
- 组件 WebSocket 不得暴露其他组件列表或控制 API。
|
||||||
|
|||||||
+3
-1
@@ -65,7 +65,9 @@ close 结束连接。
|
|||||||
```
|
```
|
||||||
|
|
||||||
`ownerId` 永远不会序列化到浏览器。消费者应按 `version` 和 `type`
|
`ownerId` 永远不会序列化到浏览器。消费者应按 `version` 和 `type`
|
||||||
分派,并忽略不认识的 payload 字段,从而允许兼容地增加元数据。
|
分派,并忽略不认识的 payload 字段,从而允许兼容地增加元数据。 `sourceId`
|
||||||
|
标识所属账户唯一的直播监听连接,不表示组件单独绑定了一个直播源;同一账户不同组件收到的实时事件具有相同的
|
||||||
|
`sourceId`。
|
||||||
|
|
||||||
## 事件类型
|
## 事件类型
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -6,12 +6,13 @@ token。以下规则是实现约束,而不是可选部署建议。
|
|||||||
## 租户隔离
|
## 租户隔离
|
||||||
|
|
||||||
- HTTP handler 只从服务端会话解析 `owner_id`,不接受客户端声明的 owner。
|
- HTTP handler 只从服务端会话解析 `owner_id`,不接受客户端声明的 owner。
|
||||||
- 组件查询同时限定 `owner_user_id` 与 `source_id`。
|
- 组件查询始终限定 `owner_user_id`;组件表不保存直播源外键。
|
||||||
|
- 直播间、直播源和 CookieCloud 凭据分别以账户 ID 建立唯一约束与 RLS 边界。
|
||||||
- 数据库使用 owner 复合外键、RLS 和 `FORCE ROW LEVEL SECURITY`。
|
- 数据库使用 owner 复合外键、RLS 和 `FORCE ROW LEVEL SECURITY`。
|
||||||
- tenant 查询必须在事务中执行 `SET LOCAL app.user_id`,不能使用会泄漏到连接池的 session-level
|
- tenant 查询必须在事务中执行 `SET LOCAL app.user_id`,不能使用会泄漏到连接池的 session-level
|
||||||
`SET`。
|
`SET`。
|
||||||
- 实时广播按 `component_id` 建立独立 channel,不提供全局订阅。
|
- 实时广播按 `component_id` 建立独立 channel,不提供全局订阅。
|
||||||
- 路由器在投影发布前再次验证 owner、source 和 component ID。
|
- 路由器按事件的可信 `owner_id` 扇出,并在投影发布前再次验证 owner、账户 source 和 component ID。
|
||||||
|
|
||||||
## Secret 生命周期
|
## Secret 生命周期
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user