update UI details for new stream

This commit is contained in:
2026-08-14 12:20:46 -07:00
parent acc4c117f7
commit a36d511d39
18 changed files with 424 additions and 146 deletions
+1
View File
@@ -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.';
+4
View File
@@ -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(
+115 -46
View File
@@ -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