update UI details for new stream
This commit is contained in:
@@ -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