update UI details for new stream
This commit is contained in:
@@ -5,8 +5,8 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域
|
|||||||
## 路由
|
## 路由
|
||||||
|
|
||||||
| 路由 | 权限 | 作用 |
|
| 路由 | 权限 | 作用 |
|
||||||
| --------------------------------------- | --------------- | ------------------------------------ |
|
| --------------------------------------- | --------------- | -------------------------------- |
|
||||||
| `/control/` | 登录用户 | 直播源、组件、测试、设置和 OBS token |
|
| `/control/` | 登录用户 | 组件实例、测试、设置和 OBS token |
|
||||||
| `/control/invitations` | system admin | 创建/撤销绑定房间的邀请码 |
|
| `/control/invitations` | system admin | 创建/撤销绑定房间的邀请码 |
|
||||||
| `/control/components/:id/song-requests` | 登录用户 | 点歌队列、统计与管理操作 |
|
| `/control/components/:id/song-requests` | 登录用户 | 点歌队列、统计与管理操作 |
|
||||||
| `/control/login` | 匿名 | 用户名 + TOTP/恢复码登录 |
|
| `/control/login` | 匿名 | 用户名 + TOTP/恢复码登录 |
|
||||||
@@ -14,7 +14,8 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域
|
|||||||
| `/control/setup` | 首次部署 | 创建唯一 system admin |
|
| `/control/setup` | 首次部署 | 创建唯一 system admin |
|
||||||
| `/obs/:publicId` | component token | 透明 OBS 浏览器源 |
|
| `/obs/:publicId` | component token | 透明 OBS 浏览器源 |
|
||||||
|
|
||||||
`main.tsx` 在初始化控制台前先识别 OBS 路由,因此 OBS 不会注册 PWA 或请求账户 session。
|
`main.tsx`
|
||||||
|
在初始化控制台前先识别 OBS 路由,因此 OBS 不会注册 PWA 或请求账户 session。控制台允许同一组件类型创建多个命名实例;列表必须显示实例名称而不是只显示类型,以便不同 OBS 场景的样式和地址可被区分。
|
||||||
|
|
||||||
## 文件职责
|
## 文件职责
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,13 @@ export function normalizeRecoveryCodes(value: unknown): string[] {
|
|||||||
|
|
||||||
export function normalizeComponents(value: unknown): ComponentSummary[] {
|
export function normalizeComponents(value: unknown): ComponentSummary[] {
|
||||||
const root = object(value)
|
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
|
return list
|
||||||
.map(entry => {
|
.map(entry => {
|
||||||
const item = object(entry)
|
const item = object(entry)
|
||||||
|
|||||||
@@ -1994,13 +1994,37 @@ select {
|
|||||||
box-shadow: 0 0 9px rgba(86, 232, 197, 0.7);
|
box-shadow: 0 0 9px rgba(86, 232, 197, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.future-components {
|
.component-create {
|
||||||
margin: 18px 8px 0;
|
margin: 18px 8px 0;
|
||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
border-top: 1px solid rgba(94, 187, 173, 0.12);
|
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 {
|
.dashboard-content {
|
||||||
@@ -2017,6 +2041,14 @@ select {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-heading-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.page-heading h1 {
|
.page-heading h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #e2fff9;
|
color: #e2fff9;
|
||||||
@@ -2352,10 +2384,6 @@ td:last-child {
|
|||||||
.component-list {
|
.component-list {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.future-components {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
@media (max-width: 680px) {
|
||||||
|
|||||||
+172
-59
@@ -205,6 +205,32 @@ function isGiftMenuKind(kind: string): boolean {
|
|||||||
return kind === 'gift_menu'
|
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
|
type Flash = { kind: 'success' | 'error'; text: string } | undefined
|
||||||
|
|
||||||
function Panel({
|
function Panel({
|
||||||
@@ -2048,11 +2074,28 @@ function ComponentList({
|
|||||||
components,
|
components,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onCreate,
|
||||||
|
creating,
|
||||||
}: {
|
}: {
|
||||||
components: ComponentSummary[]
|
components: ComponentSummary[]
|
||||||
selectedId?: string
|
selectedId?: string
|
||||||
onSelect: (component: ComponentSummary) => void
|
onSelect: (component: ComponentSummary) => void
|
||||||
|
onCreate: (kind: string, name: string) => Promise<boolean>
|
||||||
|
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 (
|
return (
|
||||||
<aside className="component-sidebar jade-panel">
|
<aside className="component-sidebar jade-panel">
|
||||||
<div className="component-sidebar-heading">
|
<div className="component-sidebar-heading">
|
||||||
@@ -2074,49 +2117,43 @@ function ComponentList({
|
|||||||
key={component.id}
|
key={component.id}
|
||||||
>
|
>
|
||||||
<span className="component-icon" aria-hidden="true">
|
<span className="component-icon" aria-hidden="true">
|
||||||
{isDanmakuKind(component.kind)
|
{componentKindMark(component.kind)}
|
||||||
? translate('components.danmaku_mark')
|
|
||||||
: isSongRequestKind(component.kind)
|
|
||||||
? translate('components.song_mark')
|
|
||||||
: isGiftEffectKind(component.kind)
|
|
||||||
? translate('components.gift_mark')
|
|
||||||
: isGiftMenuKind(component.kind)
|
|
||||||
? translate('components.gift_menu_mark')
|
|
||||||
: translate('components.generic_mark')}
|
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<b>
|
<b>{component.name}</b>
|
||||||
{isDanmakuKind(component.kind)
|
<small>{componentKindLabel(component.kind)}</small>
|
||||||
? translate('components.danmaku_type')
|
|
||||||
: isSongRequestKind(component.kind)
|
|
||||||
? translate('components.song_type')
|
|
||||||
: isGiftEffectKind(component.kind)
|
|
||||||
? translate('components.gift_type')
|
|
||||||
: isGiftMenuKind(component.kind)
|
|
||||||
? translate('components.gift_menu_type')
|
|
||||||
: component.name}
|
|
||||||
</b>
|
|
||||||
<small>
|
|
||||||
{isDanmakuKind(component.kind)
|
|
||||||
? translate('components.danmaku_type')
|
|
||||||
: isSongRequestKind(component.kind)
|
|
||||||
? translate('components.song_type')
|
|
||||||
: isGiftEffectKind(component.kind)
|
|
||||||
? translate('components.gift_type')
|
|
||||||
: isGiftMenuKind(component.kind)
|
|
||||||
? translate('components.gift_menu_type')
|
|
||||||
: component.kind}
|
|
||||||
</small>
|
|
||||||
</span>
|
</span>
|
||||||
<i className={component.enabled === false ? 'disabled' : 'enabled'} />
|
<i className={component.enabled === false ? 'disabled' : 'enabled'} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="future-components">
|
<form className="component-create" onSubmit={event => void submit(event)}>
|
||||||
<span>{translate('components.coming_soon')}</span>
|
<b>{translate('components.add_instance')}</b>
|
||||||
<small>{translate('components.future')}</small>
|
<small>{translate('components.add_instance_description')}</small>
|
||||||
</div>
|
<select
|
||||||
|
aria-label={translate('components.instance_type')}
|
||||||
|
value={kind}
|
||||||
|
onChange={event => setKind(event.target.value as typeof kind)}
|
||||||
|
>
|
||||||
|
{componentKinds.map(componentKind => (
|
||||||
|
<option value={componentKind} key={componentKind}>
|
||||||
|
{componentKindLabel(componentKind)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
maxLength={80}
|
||||||
|
aria-label={translate('components.instance_name')}
|
||||||
|
placeholder={translate('components.instance_name_placeholder')}
|
||||||
|
value={name}
|
||||||
|
onChange={event => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button disabled={creating || !name.trim()}>
|
||||||
|
{translate(creating ? 'components.creating' : 'components.create_instance')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2134,6 +2171,8 @@ export function ComponentsPage({
|
|||||||
const [settings, setSettings] = useState<ComponentSettings>()
|
const [settings, setSettings] = useState<ComponentSettings>()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [flash, setFlash] = useState<Flash>()
|
const [flash, setFlash] = useState<Flash>()
|
||||||
const selectedIdRef = useRef<string | undefined>(undefined)
|
const selectedIdRef = useRef<string | undefined>(undefined)
|
||||||
const settingsRequestRef = useRef(0)
|
const settingsRequestRef = useRef(0)
|
||||||
@@ -2147,7 +2186,7 @@ export function ComponentsPage({
|
|||||||
usePwaUpdateBlocker(
|
usePwaUpdateBlocker(
|
||||||
'component-settings',
|
'component-settings',
|
||||||
translate('components.settings_blocker'),
|
translate('components.settings_blocker'),
|
||||||
saving || settingsDirty,
|
saving || deleting || settingsDirty,
|
||||||
)
|
)
|
||||||
|
|
||||||
const loadComponentSettings = useCallback(async (component: ComponentSummary) => {
|
const loadComponentSettings = useCallback(async (component: ComponentSummary) => {
|
||||||
@@ -2225,6 +2264,12 @@ export function ComponentsPage({
|
|||||||
}, [loadComponentSettings])
|
}, [loadComponentSettings])
|
||||||
|
|
||||||
const choose = (component: ComponentSummary) => {
|
const choose = (component: ComponentSummary) => {
|
||||||
|
if (
|
||||||
|
component.id !== selectedId &&
|
||||||
|
settingsDirty &&
|
||||||
|
!window.confirm(translate('components.discard_changes_confirm'))
|
||||||
|
)
|
||||||
|
return
|
||||||
selectedIdRef.current = component.id
|
selectedIdRef.current = component.id
|
||||||
setSelectedId(component.id)
|
setSelectedId(component.id)
|
||||||
const url = new URL(location.href)
|
const url = new URL(location.href)
|
||||||
@@ -2233,6 +2278,76 @@ export function ComponentsPage({
|
|||||||
void loadComponentSettings(component)
|
void loadComponentSettings(component)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createInstance = async (kind: string, name: string): Promise<boolean> => {
|
||||||
|
if (settingsDirty && !window.confirm(translate('components.discard_changes_confirm')))
|
||||||
|
return false
|
||||||
|
setCreating(true)
|
||||||
|
setFlash(undefined)
|
||||||
|
try {
|
||||||
|
const payload = await api<unknown>(
|
||||||
|
'/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 () => {
|
const saveSettings = async () => {
|
||||||
if (!selected || !settings) return
|
if (!selected || !settings) return
|
||||||
const componentId = selected.id
|
const componentId = selected.id
|
||||||
@@ -2282,7 +2397,13 @@ export function ComponentsPage({
|
|||||||
return (
|
return (
|
||||||
<ControlLayout user={user} active="components" onLogout={onLogout}>
|
<ControlLayout user={user} active="components" onLogout={onLogout}>
|
||||||
<div className="dashboard-grid">
|
<div className="dashboard-grid">
|
||||||
<ComponentList components={components} selectedId={selectedId} onSelect={choose} />
|
<ComponentList
|
||||||
|
components={components}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={choose}
|
||||||
|
onCreate={createInstance}
|
||||||
|
creating={creating}
|
||||||
|
/>
|
||||||
<div className="dashboard-content">
|
<div className="dashboard-content">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="loading-panel jade-panel">{translate('components.loading')}</div>
|
<div className="loading-panel jade-panel">{translate('components.loading')}</div>
|
||||||
@@ -2292,29 +2413,10 @@ export function ComponentsPage({
|
|||||||
<>
|
<>
|
||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">
|
<p className="eyebrow">{componentKindLabel(selected.kind)}</p>
|
||||||
{isDanmakuKind(selected.kind)
|
<h1>{selected.name}</h1>
|
||||||
? 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}
|
|
||||||
</p>
|
|
||||||
<h1>
|
|
||||||
{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}
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="page-heading-actions">
|
||||||
<span
|
<span
|
||||||
className={`status-chip ${selected.enabled === false ? 'offline' : 'online'}`}
|
className={`status-chip ${selected.enabled === false ? 'offline' : 'online'}`}
|
||||||
>
|
>
|
||||||
@@ -2322,6 +2424,17 @@ export function ComponentsPage({
|
|||||||
? translate('common.disabled')
|
? translate('common.disabled')
|
||||||
: translate('common.enabled')}
|
: translate('common.enabled')}
|
||||||
</span>
|
</span>
|
||||||
|
{components.filter(component => component.kind === selected.kind).length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={() => void deleteSelected()}
|
||||||
|
>
|
||||||
|
{translate(deleting ? 'components.deleting' : 'components.delete_instance')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isDanmakuKind(selected.kind) && settings ? (
|
{isDanmakuKind(selected.kind) && settings ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ crate 导出。
|
|||||||
|
|
||||||
- handler 不能信任请求体中的 owner;owner 必须来自 session 或账户 source context。
|
- handler 不能信任请求体中的 owner;owner 必须来自 session 或账户 source context。
|
||||||
- `component_instances` 不保存 source 绑定;账户唯一的监听事件会按 owner 提供给其全部启用组件。
|
- `component_instances` 不保存 source 绑定;账户唯一的监听事件会按 owner 提供给其全部启用组件。
|
||||||
|
- 同一组件 kind 可以有多个实例;settings、token、持久状态和广播通道必须继续按 component ID 隔离。
|
||||||
- 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,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.';
|
||||||
@@ -302,6 +302,10 @@ async fn migrate(db: &Db) -> Result<(), String> {
|
|||||||
(9_i32, include_str!("../migrations/009_gift_effect.sql")),
|
(9_i32, include_str!("../migrations/009_gift_effect.sql")),
|
||||||
(10_i32, include_str!("../migrations/010_gift_menu.sql")),
|
(10_i32, include_str!("../migrations/010_gift_menu.sql")),
|
||||||
(11_i32, include_str!("../migrations/011_totp_reset.sql")),
|
(11_i32, include_str!("../migrations/011_totp_reset.sql")),
|
||||||
|
(
|
||||||
|
12_i32,
|
||||||
|
include_str!("../migrations/012_component_instances.sql"),
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let applied = transaction
|
let applied = transaction
|
||||||
.query_one(
|
.query_one(
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ use crate::{
|
|||||||
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_COMPONENT_INSTANCES_PER_KIND: i64 = 16;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct TenantRepository {
|
pub struct TenantRepository {
|
||||||
db: Db,
|
db: Db,
|
||||||
@@ -49,17 +51,25 @@ impl TenantRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure every active tenant has every required singleton component.
|
/// Ensure every active tenant has an initial instance of each built-in
|
||||||
/// Partial unique indexes make concurrent starts idempotent. The song
|
/// component. Registration normally creates these rows; this startup
|
||||||
/// component additionally owns a relational revision state row.
|
/// 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> {
|
pub async fn ensure_builtin_components(&self) -> Result<(), RepositoryError> {
|
||||||
for tenant in self.db.list_active_tenants().await? {
|
for tenant in self.db.list_active_tenants().await? {
|
||||||
let mut client = self.db.get().await?;
|
let mut client = self.db.get().await?;
|
||||||
let transaction = client.transaction().await?;
|
let transaction = client.transaction().await?;
|
||||||
Db::set_tenant(&transaction, tenant.user_id).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
|
let existing = transaction
|
||||||
.query_opt(
|
.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],
|
&[&tenant.user_id, &SONG_REQUEST_KIND],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -84,11 +94,17 @@ impl TenantRepository {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
transaction
|
transaction
|
||||||
.query_one(
|
.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],
|
&[&tenant.user_id, &SONG_REQUEST_KIND],
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
RepositoryError::Invalid(
|
||||||
|
"failed to create the initial song request component".into(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
.get(0)
|
.get(0)
|
||||||
};
|
};
|
||||||
transaction
|
transaction
|
||||||
@@ -98,13 +114,22 @@ impl TenantRepository {
|
|||||||
&[&tenant.user_id, &component_id],
|
&[&tenant.user_id, &component_id],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
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?
|
||||||
|
.get::<_, bool>(0);
|
||||||
|
if !has_gift_effect {
|
||||||
let gift_settings = serde_json::to_value(GiftEffectSettings::default())
|
let gift_settings = serde_json::to_value(GiftEffectSettings::default())
|
||||||
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
||||||
transaction
|
transaction
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO component_instances \
|
"INSERT INTO component_instances \
|
||||||
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||||
VALUES($1,$2,$3,$4,$5,1,true) ON CONFLICT DO NOTHING",
|
VALUES($1,$2,$3,$4,$5,1,true)",
|
||||||
&[
|
&[
|
||||||
&Uuid::new_v4(),
|
&Uuid::new_v4(),
|
||||||
&tenant.user_id,
|
&tenant.user_id,
|
||||||
@@ -114,13 +139,23 @@ impl TenantRepository {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
.await?;
|
.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?
|
||||||
|
.get::<_, bool>(0);
|
||||||
|
if !has_gift_menu {
|
||||||
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
|
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
|
||||||
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
||||||
transaction
|
transaction
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO component_instances \
|
"INSERT INTO component_instances \
|
||||||
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||||
VALUES($1,$2,$3,$4,$5,1,true) ON CONFLICT DO NOTHING",
|
VALUES($1,$2,$3,$4,$5,1,true)",
|
||||||
&[
|
&[
|
||||||
&Uuid::new_v4(),
|
&Uuid::new_v4(),
|
||||||
&tenant.user_id,
|
&tenant.user_id,
|
||||||
@@ -130,6 +165,7 @@ impl TenantRepository {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
}
|
||||||
transaction.commit().await?;
|
transaction.commit().await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -162,12 +198,6 @@ impl TenantRepository {
|
|||||||
kind: &str,
|
kind: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<ComponentInstance, RepositoryError> {
|
) -> Result<ComponentInstance, RepositoryError> {
|
||||||
// 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
|
let runtime = self
|
||||||
.registry
|
.registry
|
||||||
.runtime(kind)
|
.runtime(kind)
|
||||||
@@ -190,6 +220,26 @@ impl TenantRepository {
|
|||||||
let mut client = self.db.get().await?;
|
let mut client = self.db.get().await?;
|
||||||
let transaction = client.transaction().await?;
|
let transaction = client.transaction().await?;
|
||||||
Db::set_tenant(&transaction, owner_id).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
|
transaction
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO component_instances \
|
"INSERT INTO component_instances \
|
||||||
@@ -205,6 +255,15 @@ impl TenantRepository {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
.await?;
|
.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?;
|
transaction.commit().await?;
|
||||||
self.cache.upsert(component.clone());
|
self.cache.upsert(component.clone());
|
||||||
Ok(component)
|
Ok(component)
|
||||||
@@ -218,6 +277,12 @@ impl TenantRepository {
|
|||||||
let mut client = self.db.get().await?;
|
let mut client = self.db.get().await?;
|
||||||
let transaction = client.transaction().await?;
|
let transaction = client.transaction().await?;
|
||||||
Db::set_tenant(&transaction, owner_id).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
|
let kind: String = transaction
|
||||||
.query_opt(
|
.query_opt(
|
||||||
"SELECT kind FROM component_instances WHERE owner_user_id=$1 AND id=$2",
|
"SELECT kind FROM component_instances WHERE owner_user_id=$1 AND id=$2",
|
||||||
@@ -226,10 +291,14 @@ impl TenantRepository {
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(RepositoryError::NotFound)?
|
.ok_or(RepositoryError::NotFound)?
|
||||||
.get(0);
|
.get(0);
|
||||||
if matches!(
|
let instance_count = transaction
|
||||||
kind.as_str(),
|
.query_one(
|
||||||
SONG_REQUEST_KIND | GIFT_EFFECT_KIND | GIFT_MENU_KIND
|
"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);
|
return Err(RepositoryError::Forbidden);
|
||||||
}
|
}
|
||||||
let changed = transaction
|
let changed = transaction
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
## 核心目标
|
## 核心目标
|
||||||
|
|
||||||
- 每个账户拥有一个可切换的 Bilibili 直播间、一个 CookieCloud 来源和一条独立监听连接。
|
- 每个账户拥有一个可切换的 Bilibili 直播间、一个 CookieCloud 来源和一条独立监听连接。
|
||||||
- 组件不绑定或选择直播源;账户事件流会提供给该账户所有启用的组件实例。
|
- 组件不绑定或选择直播源;账户事件流会提供给该账户所有启用的组件实例。同一 kind 可以拥有多个实例,每个实例独立保存名称、设置、OBS
|
||||||
|
token 和实时通道。
|
||||||
- 平台原始命令先转换成稳定的领域事件,组件不直接依赖 Bilibili `CMD`。
|
- 平台原始命令先转换成稳定的领域事件,组件不直接依赖 Bilibili `CMD`。
|
||||||
- HTTP 会话、直播源、组件、OBS token 和实时通道均以租户为边界。
|
- HTTP 会话、直播源、组件、OBS token 和实时通道均以租户为边界。
|
||||||
- 新组件可以增加设置、投影和持久化副作用,而不修改直播连接核心。
|
- 新组件可以增加设置、投影和持久化副作用,而不修改直播连接核心。
|
||||||
@@ -37,7 +38,8 @@ flowchart LR
|
|||||||
只标识账户级监听,不存储在组件行中。
|
只标识账户级监听,不存储在组件行中。
|
||||||
4. 匹配订阅后,路由器先执行可持久化的 `EventHandler`,再执行无副作用的 `EventProjection`。
|
4. 匹配订阅后,路由器先执行可持久化的 `EventHandler`,再执行无副作用的 `EventProjection`。
|
||||||
5. 投影结果只发布到该 `component_id` 的广播通道。没有全局 WebSocket 事件总线。
|
5. 投影结果只发布到该 `component_id` 的广播通道。没有全局 WebSocket 事件总线。
|
||||||
6. OBS 使用组件级只读 token 订阅一个组件,不能读取控制台 API。
|
6. OBS 使用实例级只读 token 订阅一个组件实例,不能读取控制台 API;同类型的其他实例拥有不同
|
||||||
|
`component_id`,可在不同 OBS 场景中使用不同样式。
|
||||||
|
|
||||||
## 状态所有权
|
## 状态所有权
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
组件是“消费所属账户事件流的独立功能实例”。当前内建 `danmaku_overlay`、`song_request`、 `gift_effect`
|
组件是“消费所属账户事件流的独立功能实例”。当前内建 `danmaku_overlay`、`song_request`、 `gift_effect`
|
||||||
与 `gift_menu`,未来礼物墙或统计组件也应使用同一套契约。
|
与 `gift_menu`,未来礼物墙或统计组件也应使用同一套契约。
|
||||||
|
|
||||||
|
账户注册时会为每种内建 kind 创建一个初始实例。控制台可以为同一 kind 再创建多个命名实例;每个实例都有独立 settings、OBS
|
||||||
|
token、`publicId` 与 `EventHub`
|
||||||
|
channel。当前每账户每 kind 最多 16 个实例,且至少保留一个,避免误删后由启动回填产生一个意外的新地址。删除实例会级联删除它的 token 与组件专属关系数据,并立即关闭该实例的 WebSocket
|
||||||
|
channel。
|
||||||
|
|
||||||
## 一个组件由什么组成
|
## 一个组件由什么组成
|
||||||
|
|
||||||
| 部分 | Rust 契约 | 职责 |
|
| 部分 | Rust 契约 | 职责 |
|
||||||
@@ -28,7 +33,8 @@
|
|||||||
- 数据库操作必须包含 owner/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。
|
||||||
8. 在控制台增加设置编辑器,在 OBS 前端增加对应事件渲染器。
|
8. 在控制台增加设置编辑器,在 OBS 前端增加对应事件渲染器。
|
||||||
9. 增加以下测试:设置边界、版本迁移、订阅、跨租户拒绝、handler 幂等、投影 wire shape 和 OBS 渲染。
|
9. 增加以下测试:设置边界、版本迁移、订阅、跨租户拒绝、handler 幂等、投影 wire shape 和 OBS 渲染。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# `danmaku_overlay` 弹幕姬
|
# `danmaku_overlay` 弹幕姬
|
||||||
|
|
||||||
弹幕姬把一个租户直播源的互动事件投影为透明 OBS 消息墙。它是被动展示组件:不记账、不回复弹幕,也不把 WebSocket 当作持久化业务通道。
|
弹幕姬把一个租户直播源的互动事件投影为透明 OBS 消息墙。它是被动展示组件:不记账、不回复弹幕,也不把 WebSocket 当作持久化业务通道。账户注册时会创建一个初始实例;同一账户可以继续添加多个弹幕姬实例,为横屏、竖屏或其他 OBS 场景分别保存样式和 OBS 地址。所有实例共享账户直播监听,但事件投影和 WebSocket 广播仍按实例 ID 隔离。
|
||||||
|
|
||||||
## 订阅事件
|
## 订阅事件
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# 全屏礼物特效组件
|
# 全屏礼物特效组件
|
||||||
|
|
||||||
`gift_effect` 是每个账户自动拥有且不可删除的单例组件。它消费账户级直播源中的 `live.gift` 和
|
`gift_effect`
|
||||||
`live.guard.buy`,使用独立只读 token 作为透明 OBS 浏览器源;不订阅
|
在账户注册时创建一个初始实例,并允许为不同 OBS 场景添加多个独立样式实例。每个实例消费账户级直播源中的
|
||||||
|
`live.gift` 和 `live.guard.buy`,使用自己的只读 token 作为透明 OBS 浏览器源;不订阅
|
||||||
`live.gift.combo`,避免一次连击重复触发完整特效。
|
`live.gift.combo`,避免一次连击重复触发完整特效。
|
||||||
|
|
||||||
## 展示行为
|
## 展示行为
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 礼物菜单组件
|
# 礼物菜单组件
|
||||||
|
|
||||||
`gift_menu`
|
`gift_menu`
|
||||||
是每个账户自动拥有且不可删除的单例组件。它把直播间礼物或大航海投喂映射为主播提供的内容说明,并以独立只读 token 输出透明 OBS 浏览器源。
|
在账户注册时创建一个初始实例,也可以为不同 OBS 场景添加多个实例。每个实例独立保存菜单内容、样式和只读 token;它把直播间礼物或大航海投喂映射为主播提供的内容说明,并输出透明 OBS 浏览器源。
|
||||||
|
|
||||||
## 目录与触发器
|
## 目录与触发器
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# `song_request` 点歌姬
|
# `song_request` 点歌姬
|
||||||
|
|
||||||
每个账户自动拥有一个不可删除、不可重复创建的 `song_request`
|
每个账户自动拥有一个初始 `song_request`
|
||||||
实例,并与该账户的固定直播源绑定。组件只订阅规范化的
|
实例,也可以再创建同类型的命名实例。每个实例拥有独立设置、OBS 地址、队列、评分和 revision,但共享账户的固定直播监听;删除实例会同时删除该实例的队列历史。组件只订阅规范化的
|
||||||
`live.danmaku`;业务状态由 PostgreSQL 保存,不依赖 OBS 是否在线。
|
`live.danmaku`;业务状态由 PostgreSQL 保存,不依赖 OBS 是否在线。
|
||||||
|
|
||||||
## 弹幕命令
|
## 弹幕命令
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ log。WebSocket 建立后,客户端必须在 8 秒内发送第一帧:
|
|||||||
`overlay.settings.snapshot` 兼容帧。token 无效、被轮换或属于其他组件时,服务端以 policy
|
`overlay.settings.snapshot` 兼容帧。token 无效、被轮换或属于其他组件时,服务端以 policy
|
||||||
close 结束连接。
|
close 结束连接。
|
||||||
|
|
||||||
|
`componentId`/`publicId` 标识组件实例,而不是组件类型。同一账户可以创建多个相同 `componentKind`
|
||||||
|
的实例;它们使用各自的设置、token 和 WebSocket 地址,客户端不能把同 kind 视为同一个订阅通道。
|
||||||
|
|
||||||
`language` 是组件所属账户的 locale。用户在控制台修改语言后,所有已连接组件会立即收到
|
`language` 是组件所属账户的 locale。用户在控制台修改语言后,所有已连接组件会立即收到
|
||||||
`component.language.updated`,payload 为 `{ "language": "en-US" }`;新连接以认证帧为准。
|
`component.language.updated`,payload 为 `{ "language": "en-US" }`;新连接以认证帧为准。
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -14,6 +14,7 @@ token。以下规则是实现约束,而不是可选部署建议。
|
|||||||
- tenant 查询必须在事务中执行 `SET LOCAL app.user_id`,不能使用会泄漏到连接池的 session-level
|
- tenant 查询必须在事务中执行 `SET LOCAL app.user_id`,不能使用会泄漏到连接池的 session-level
|
||||||
`SET`。
|
`SET`。
|
||||||
- 实时广播按 `component_id` 建立独立 channel,不提供全局订阅。
|
- 实时广播按 `component_id` 建立独立 channel,不提供全局订阅。
|
||||||
|
- 同一 kind 的多个实例仍逐行执行 owner 归属校验;实例 token 不能订阅同账户的另一个实例。
|
||||||
- 路由器按事件的可信 `owner_id` 扇出,并在投影发布前再次验证 owner、账户 source 和 component ID。
|
- 路由器按事件的可信 `owner_id` 扇出,并在投影发布前再次验证 owner、账户 source 和 component ID。
|
||||||
|
|
||||||
## Secret 生命周期
|
## Secret 生命周期
|
||||||
@@ -25,7 +26,7 @@ token。以下规则是实现约束,而不是可选部署建议。
|
|||||||
| 登录 session | HttpOnly Cookie | SHA-256 摘要 | 到期、登出或撤销 |
|
| 登录 session | HttpOnly Cookie | SHA-256 摘要 | 到期、登出或撤销 |
|
||||||
| CookieCloud Key/密码 | 用户提交时 | XChaCha20-Poly1305 密文 | 覆盖更新 |
|
| CookieCloud Key/密码 | 用户提交时 | XChaCha20-Poly1305 密文 | 覆盖更新 |
|
||||||
| 邀请码 | 创建时显示一次 | SHA-256 摘要和前缀 | 单次消费或撤销 |
|
| 邀请码 | 创建时显示一次 | SHA-256 摘要和前缀 | 单次消费或撤销 |
|
||||||
| OBS token | 创建/轮换时显示一次 | SHA-256 摘要 | 组件级轮换 |
|
| OBS token | 创建/轮换时显示一次 | SHA-256 摘要 | 组件实例级轮换 |
|
||||||
|
|
||||||
`security.data_encryption_key`
|
`security.data_encryption_key`
|
||||||
是恢复密文所必需的主密钥。它必须独立备份,但不能提交到 Git 或写入镜像。
|
是恢复密文所必需的主密钥。它必须独立备份,但不能提交到 Git 或写入镜像。
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 经理瓷给我的要求
|
||||||
|
|
||||||
|
1. 右下角的点歌界面应该从上往下滚动 ok
|
||||||
|
2. 通过woff2直接从前端serve字体 ok
|
||||||
|
3. 小礼物用扇子,大礼物用跳跃的鱼
|
||||||
|
|||||||
+34
-6
@@ -252,7 +252,7 @@ name = "简体中文"
|
|||||||
"components.title" = "我的组件"
|
"components.title" = "我的组件"
|
||||||
"components.eyebrow" = "直播组件"
|
"components.eyebrow" = "直播组件"
|
||||||
"components.empty_title" = "还没有组件"
|
"components.empty_title" = "还没有组件"
|
||||||
"components.empty_description" = "账户初始化完成后,服务会为你创建默认弹幕姬。"
|
"components.empty_description" = "账户初始化完成后,服务会为每种内置组件创建一个初始实例。"
|
||||||
"components.danmaku_mark" = "弹"
|
"components.danmaku_mark" = "弹"
|
||||||
"components.song_mark" = "歌"
|
"components.song_mark" = "歌"
|
||||||
"components.gift_mark" = "礼"
|
"components.gift_mark" = "礼"
|
||||||
@@ -262,8 +262,22 @@ name = "简体中文"
|
|||||||
"components.song_type" = "直播点歌姬"
|
"components.song_type" = "直播点歌姬"
|
||||||
"components.gift_type" = "全屏礼物星雨"
|
"components.gift_type" = "全屏礼物星雨"
|
||||||
"components.gift_menu_type" = "直播礼物菜单"
|
"components.gift_menu_type" = "直播礼物菜单"
|
||||||
"components.coming_soon" = "即将支持"
|
"components.add_instance" = "添加组件实例"
|
||||||
"components.future" = "更多主题 · 互动组件"
|
"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_blocker" = "保存或还原当前组件设置"
|
||||||
"components.settings_load_failed" = "无法读取组件设置"
|
"components.settings_load_failed" = "无法读取组件设置"
|
||||||
"components.load_failed" = "控制台数据加载失败"
|
"components.load_failed" = "控制台数据加载失败"
|
||||||
@@ -753,7 +767,7 @@ name = "English"
|
|||||||
"components.title" = "My components"
|
"components.title" = "My components"
|
||||||
"components.eyebrow" = "LIVE COMPONENTS"
|
"components.eyebrow" = "LIVE COMPONENTS"
|
||||||
"components.empty_title" = "No components yet"
|
"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.danmaku_mark" = "Chat"
|
||||||
"components.song_mark" = "Song"
|
"components.song_mark" = "Song"
|
||||||
"components.gift_mark" = "Gift"
|
"components.gift_mark" = "Gift"
|
||||||
@@ -763,8 +777,22 @@ name = "English"
|
|||||||
"components.song_type" = "Song request overlay"
|
"components.song_type" = "Song request overlay"
|
||||||
"components.gift_type" = "Full-screen gift starfall"
|
"components.gift_type" = "Full-screen gift starfall"
|
||||||
"components.gift_menu_type" = "Live gift menu"
|
"components.gift_menu_type" = "Live gift menu"
|
||||||
"components.coming_soon" = "Coming soon"
|
"components.add_instance" = "Add component instance"
|
||||||
"components.future" = "More themes · Interactive components"
|
"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_blocker" = "save or revert the current component settings"
|
||||||
"components.settings_load_failed" = "Could not load component settings"
|
"components.settings_load_failed" = "Could not load component settings"
|
||||||
"components.load_failed" = "Could not load console data"
|
"components.load_failed" = "Could not load console data"
|
||||||
|
|||||||
Reference in New Issue
Block a user