update UI details for new stream
This commit is contained in:
+11
-10
@@ -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 场景的样式和地址可被区分。
|
||||
|
||||
## 文件职责
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+179
-66
@@ -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<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 (
|
||||
<aside className="component-sidebar jade-panel">
|
||||
<div className="component-sidebar-heading">
|
||||
@@ -2074,49 +2117,43 @@ function ComponentList({
|
||||
key={component.id}
|
||||
>
|
||||
<span className="component-icon" aria-hidden="true">
|
||||
{isDanmakuKind(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')}
|
||||
{componentKindMark(component.kind)}
|
||||
</span>
|
||||
<span>
|
||||
<b>
|
||||
{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.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>
|
||||
<b>{component.name}</b>
|
||||
<small>{componentKindLabel(component.kind)}</small>
|
||||
</span>
|
||||
<i className={component.enabled === false ? 'disabled' : 'enabled'} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="future-components">
|
||||
<span>{translate('components.coming_soon')}</span>
|
||||
<small>{translate('components.future')}</small>
|
||||
</div>
|
||||
<form className="component-create" onSubmit={event => void submit(event)}>
|
||||
<b>{translate('components.add_instance')}</b>
|
||||
<small>{translate('components.add_instance_description')}</small>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -2134,6 +2171,8 @@ export function ComponentsPage({
|
||||
const [settings, setSettings] = useState<ComponentSettings>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [flash, setFlash] = useState<Flash>()
|
||||
const selectedIdRef = useRef<string | undefined>(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<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 () => {
|
||||
if (!selected || !settings) return
|
||||
const componentId = selected.id
|
||||
@@ -2282,7 +2397,13 @@ export function ComponentsPage({
|
||||
return (
|
||||
<ControlLayout user={user} active="components" onLogout={onLogout}>
|
||||
<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">
|
||||
{loading && (
|
||||
<div className="loading-panel jade-panel">{translate('components.loading')}</div>
|
||||
@@ -2292,36 +2413,28 @@ export function ComponentsPage({
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">
|
||||
{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}
|
||||
</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>
|
||||
<p className="eyebrow">{componentKindLabel(selected.kind)}</p>
|
||||
<h1>{selected.name}</h1>
|
||||
</div>
|
||||
<div className="page-heading-actions">
|
||||
<span
|
||||
className={`status-chip ${selected.enabled === false ? 'offline' : 'online'}`}
|
||||
>
|
||||
{selected.enabled === false
|
||||
? translate('common.disabled')
|
||||
: translate('common.enabled')}
|
||||
</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>
|
||||
<span
|
||||
className={`status-chip ${selected.enabled === false ? 'offline' : 'online'}`}
|
||||
>
|
||||
{selected.enabled === false
|
||||
? translate('common.disabled')
|
||||
: translate('common.enabled')}
|
||||
</span>
|
||||
</div>
|
||||
{isDanmakuKind(selected.kind) && settings ? (
|
||||
<>
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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")),
|
||||
(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(
|
||||
|
||||
@@ -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<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
|
||||
.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
|
||||
|
||||
Reference in New Issue
Block a user