add configurable gift menu overlays

Add tenant-scoped gift menu settings, catalog-backed triggers, infinite OBS rendering, and guard assets. Normalize legacy and protobuf gift values for blind-box, battery-tier, and transaction-aware matching.
This commit is contained in:
2026-07-21 19:41:49 -07:00
parent c4eca7b8bf
commit 716c6f3f2f
29 changed files with 2764 additions and 91 deletions
+1
View File
@@ -23,6 +23,7 @@ crate 导出。
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
| `song_request` | 点歌命令、事务队列、评分、快照和管理服务 |
| `gift_effect` | 全屏礼物流星设置、分档边界与事件订阅 |
| `gift_menu` | 礼物菜单设置、触发匹配与 OBS 高亮投影 |
## 重要不变量
@@ -0,0 +1,6 @@
-- Every account owns exactly one built-in gift-menu component. Its ordered
-- entries remain in validated component settings and inherit component RLS.
CREATE UNIQUE INDEX IF NOT EXISTS component_instances_single_gift_menu
ON component_instances(owner_user_id, kind)
WHERE kind = 'gift_menu';
+10 -1
View File
@@ -24,6 +24,7 @@ use crate::{
bilibili::BilibiliProvider,
supervisor::{ProviderFactory, SourceSupervisor},
},
overlay::GiftCatalogRegistry,
rate_limit::AuthRateLimiter,
realtime::{EventHub, InMemoryComponentStore, SourceEventRouter},
repository::TenantRepository,
@@ -46,6 +47,7 @@ pub struct AppState {
pub component_socket_slots: Arc<Semaphore>,
pub http: reqwest::Client,
pub song_requests: SongRequestService,
pub gift_catalogs: GiftCatalogRegistry,
}
impl AppState {
@@ -65,6 +67,7 @@ impl AppState {
let component_cache = Arc::new(InMemoryComponentStore::default());
let hub = EventHub::new(512);
let song_requests = SongRequestService::new(db.clone(), hub.clone());
let gift_catalogs = GiftCatalogRegistry::default();
registry
.register_handler(
SONG_REQUEST_KIND,
@@ -98,6 +101,7 @@ impl AppState {
auth: auth.clone(),
config: config.clone(),
http: http.clone(),
gift_catalogs: gift_catalogs.clone(),
});
let (source_events, mut source_event_rx) = mpsc::channel::<Arc<LiveEvent>>(512);
let supervisor = SourceSupervisor::new(provider_factory, source_events);
@@ -135,6 +139,7 @@ impl AppState {
component_socket_slots: Arc::new(Semaphore::new(128)),
http,
song_requests,
gift_catalogs,
};
state.start_all_sources().await?;
Ok(state)
@@ -229,6 +234,7 @@ struct BilibiliProviderFactory {
auth: AuthService,
config: Arc<Config>,
http: reqwest::Client,
gift_catalogs: GiftCatalogRegistry,
}
#[async_trait]
@@ -242,12 +248,14 @@ impl ProviderFactory for BilibiliProviderFactory {
.ok_or_else(|| "CookieCloud credentials have not been configured".to_string())?;
self.config.allowed_cookiecloud_host(&stored.host)?;
let cookie = fetch_bilibili_cookie(&self.http, &stored).await?;
Ok(Arc::new(BilibiliProvider::new(
let gift_catalog = self.gift_catalogs.catalog(source.owner_id).await;
Ok(Arc::new(BilibiliProvider::with_gift_catalog(
cookie,
self.config.gift_refresh_seconds,
self.config.gift_request_timeout_seconds,
self.config.emoticon_refresh_seconds,
self.config.emoticon_request_timeout_seconds,
gift_catalog,
)))
}
}
@@ -292,6 +300,7 @@ async fn migrate(db: &Db) -> Result<(), String> {
include_str!("../migrations/008_account_language.sql"),
),
(9_i32, include_str!("../migrations/009_gift_effect.sql")),
(10_i32, include_str!("../migrations/010_gift_menu.sql")),
] {
let applied = transaction
.query_one(
+18
View File
@@ -27,6 +27,7 @@ use crate::{
credentials::{CookieCloudCredentials, CookieCloudSecrets, normalize_cookiecloud_host},
db::{Db, DbError},
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
i18n,
overlay::OverlaySettings,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
@@ -668,6 +669,23 @@ impl AuthService {
],
)
.await?;
let menu_component_id = Uuid::new_v4();
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
.expect("GiftMenuSettings is always JSON serializable");
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true)",
&[
&menu_component_id,
&user_id,
&GIFT_MENU_KIND,
&GIFT_MENU_NAME,
&menu_settings,
],
)
.await?;
transaction
.execute(
"DELETE FROM pending_registrations WHERE id=$1",
+22
View File
@@ -21,6 +21,7 @@ use uuid::Uuid;
use crate::{
domain::{ComponentMessage, LiveEvent, LiveEventKind},
gift_effect::GiftEffectDefinition,
gift_menu::{GiftMenuDefinition, GiftMenuProjection},
overlay::OverlaySettings,
song_request::{SongRequestDefinition, SongRequestProjection},
};
@@ -389,6 +390,9 @@ impl ComponentRegistry {
)
.expect("built-in component kinds are unique");
registry
.register(Arc::new(GiftMenuDefinition), Arc::new(GiftMenuProjection))
.expect("built-in component kinds are unique");
registry
}
pub fn register(
@@ -574,6 +578,24 @@ mod tests {
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
}
#[test]
fn builtin_gift_menu_is_registered_as_a_gift_and_guard_projection() {
let registry = ComponentRegistry::default();
let runtime = registry.runtime("gift_menu").unwrap();
let instance = ComponentInstance::new(
Uuid::new_v4(),
Uuid::new_v4(),
"gift_menu",
"礼物菜单",
1,
runtime.definition().default_settings(),
);
let subscriptions = runtime.subscriptions(&instance).unwrap();
assert!(subscriptions.contains(LiveEventKind::Gift));
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
}
#[test]
fn duplicate_component_kinds_are_rejected() {
let registry = ComponentRegistry::default();
+3
View File
@@ -95,6 +95,9 @@ pub struct GiftDetails {
pub id: Option<i64>,
pub name: String,
pub coin_type: String,
/// Display value in Bilibili batteries. Upstream gift prices are gold
/// coin values, where 100 gold coins equal one battery.
pub battery_value: i64,
pub unit_price: i64,
pub total_price: i64,
pub price_cny: f64,
+510
View File
@@ -0,0 +1,510 @@
//! Configurable gift-to-content menu component.
//!
//! Menu entries live in the component's versioned JSON settings because they
//! are a small, ordered presentation configuration rather than an event log.
//! The projection is authoritative for trigger matching and emits only matched
//! item IDs; the browser never has to reinterpret Bilibili guard names.
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use uuid::Uuid;
use crate::{
components::{
ComponentDefinition, ComponentError, ComponentInstance, EventProjection, EventSubscription,
},
domain::{ComponentMessage, LiveEvent, LiveEventKind, LiveEventPayload},
overlay::normalize_image_url,
};
pub const GIFT_MENU_KIND: &str = "gift_menu";
pub const GIFT_MENU_NAME: &str = "礼物菜单";
const MAX_MENU_ITEMS: usize = 100;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GiftMenuThemeId {
#[default]
JadeBanquet,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum GuardLevel {
Captain,
Admiral,
Governor,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(
tag = "kind",
rename_all = "lowercase",
rename_all_fields = "camelCase"
)]
pub enum GiftMenuTrigger {
Gift {
gift_id: i64,
gift_name: String,
image_url: Option<String>,
unit_price: i64,
},
Guard {
level: GuardLevel,
},
Battery {
amount: i64,
},
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GiftMenuItem {
pub id: Uuid,
pub trigger: GiftMenuTrigger,
pub description: String,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GiftMenuSettings {
#[serde(default)]
pub theme_id: GiftMenuThemeId,
#[serde(default)]
pub items: Vec<GiftMenuItem>,
pub visible_rows: u8,
pub row_height: u16,
pub scroll_speed_pixels_per_second: u16,
pub highlight_duration_ms: u16,
pub font_scale: u16,
pub motion_intensity: u8,
pub low_performance_mode: bool,
}
impl Default for GiftMenuSettings {
fn default() -> Self {
Self {
theme_id: GiftMenuThemeId::default(),
items: Vec::new(),
visible_rows: 4,
row_height: 88,
scroll_speed_pixels_per_second: 28,
highlight_duration_ms: 3_800,
font_scale: 100,
motion_intensity: 78,
low_performance_mode: false,
}
}
}
impl GiftMenuSettings {
fn sanitize(mut self) -> Result<Self, String> {
if self.items.len() > MAX_MENU_ITEMS {
return Err(format!("gift menu accepts at most {MAX_MENU_ITEMS} items"));
}
let mut ids = HashSet::with_capacity(self.items.len());
let mut triggers = HashSet::with_capacity(self.items.len());
for item in &mut self.items {
if item.id.is_nil() || !ids.insert(item.id) {
return Err("gift menu item IDs must be unique and non-zero".into());
}
item.description = normalize_text(&item.description, 200)?;
let trigger_key = sanitize_trigger(&mut item.trigger)?;
if !triggers.insert(trigger_key) {
return Err("gift menu triggers must be unique".into());
}
}
self.visible_rows = self.visible_rows.clamp(1, 20);
self.row_height = self.row_height.clamp(44, 240);
self.scroll_speed_pixels_per_second = self.scroll_speed_pixels_per_second.min(240);
self.highlight_duration_ms = self.highlight_duration_ms.clamp(600, 12_000);
self.font_scale = self.font_scale.clamp(50, 220);
self.motion_intensity = self.motion_intensity.min(100);
Ok(self)
}
}
fn normalize_text(value: &str, max_chars: usize) -> Result<String, String> {
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
let count = normalized.chars().count();
if count == 0 || count > max_chars {
return Err(format!("text must contain 1-{max_chars} characters"));
}
Ok(normalized)
}
fn sanitize_trigger(trigger: &mut GiftMenuTrigger) -> Result<String, String> {
match trigger {
GiftMenuTrigger::Gift {
gift_id,
gift_name,
image_url,
unit_price,
} => {
if *gift_id <= 0 {
return Err("gift ID must be positive".into());
}
*gift_name = normalize_text(gift_name, 80)?;
*unit_price = (*unit_price).clamp(0, 1_000_000_000);
*image_url = image_url.as_deref().and_then(normalize_image_url);
Ok(format!("gift:{gift_id}"))
}
GiftMenuTrigger::Guard { level } => Ok(format!("guard:{level:?}")),
GiftMenuTrigger::Battery { amount } => {
if *amount <= 0 || *amount > 1_000_000_000 {
return Err("battery amount must be between 1 and 1000000000".into());
}
Ok(format!("battery:{amount}"))
}
}
}
pub struct GiftMenuDefinition;
impl GiftMenuDefinition {
fn parse(&self, settings: Value) -> Result<GiftMenuSettings, ComponentError> {
serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
kind: GIFT_MENU_KIND.to_owned(),
detail: error.to_string(),
})
}
}
impl ComponentDefinition for GiftMenuDefinition {
fn kind(&self) -> &'static str {
GIFT_MENU_KIND
}
fn settings_version(&self) -> u32 {
1
}
fn default_settings(&self) -> Value {
serde_json::to_value(GiftMenuSettings::default())
.expect("GiftMenuSettings is always JSON serializable")
}
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError> {
let settings =
self.parse(settings)?
.sanitize()
.map_err(|detail| ComponentError::InvalidSettings {
kind: GIFT_MENU_KIND.to_owned(),
detail,
})?;
serde_json::to_value(settings).map_err(|error| ComponentError::InvalidSettings {
kind: GIFT_MENU_KIND.to_owned(),
detail: error.to_string(),
})
}
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
self.parse(settings.clone())?;
Ok(EventSubscription::new([
LiveEventKind::Gift,
LiveEventKind::GuardPurchase,
]))
}
}
#[derive(Default)]
pub struct GiftMenuProjection;
impl EventProjection for GiftMenuProjection {
fn project(
&self,
component: &ComponentInstance,
event: &LiveEvent,
) -> Result<Option<ComponentMessage>, ComponentError> {
let settings: GiftMenuSettings = serde_json::from_value(component.settings.clone())
.map_err(|error| ComponentError::Projection(error.to_string()))?;
let (matched, viewer) = match &event.payload {
LiveEventPayload::Gift(gift) => {
// Concrete gift identities take precedence over price tiers.
// Only fall back to battery matching when no configured gift
// ID (or name, when the event has no ID) matched.
let concrete = settings
.items
.iter()
.filter(|item| match &item.trigger {
GiftMenuTrigger::Gift {
gift_id, gift_name, ..
} => gift
.gift
.id
.map_or(gift.gift.name == *gift_name, |id| id == *gift_id),
_ => false,
})
.map(|item| item.id)
.collect::<Vec<_>>();
let matched = if concrete.is_empty() {
settings
.items
.iter()
.filter(|item| {
matches!(
&item.trigger,
GiftMenuTrigger::Battery { amount }
if gift.gift.battery_value == *amount
)
})
.map(|item| item.id)
.collect::<Vec<_>>()
} else {
concrete
};
(matched, &gift.viewer)
}
LiveEventPayload::GuardPurchase(guard) => {
let Some(level) = guard_level(&guard.guard_name) else {
return Ok(None);
};
(
settings
.items
.iter()
.filter(|item| {
matches!(&item.trigger, GiftMenuTrigger::Guard { level: candidate } if *candidate == level)
})
.map(|item| item.id)
.collect::<Vec<_>>(),
&guard.viewer,
)
}
_ => return Ok(None),
};
if matched.is_empty() {
return Ok(None);
}
let mut message = ComponentMessage::from_live_event(component.id, event)
.map_err(|error| ComponentError::Projection(error.to_string()))?;
message.event_type = "gift-menu.triggered".into();
message.payload = json!({
"itemIds": matched,
"viewer": viewer,
"sourceEventId": event.id,
});
Ok(Some(message))
}
}
fn guard_level(name: &str) -> Option<GuardLevel> {
let name = name.trim().to_lowercase();
if name.contains("总督") || name.contains("governor") {
Some(GuardLevel::Governor)
} else if name.contains("提督") || name.contains("admiral") {
Some(GuardLevel::Admiral)
} else if name.contains("舰长") || name.contains("captain") {
Some(GuardLevel::Captain)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{
GiftDetails, GiftEvent, GuardPurchaseEvent, LiveEventPayload, PlatformViewer,
};
fn viewer() -> PlatformViewer {
PlatformViewer {
uid: "42".into(),
name: "观众".into(),
}
}
fn component(items: Vec<GiftMenuItem>) -> ComponentInstance {
let settings = GiftMenuSettings {
items,
..GiftMenuSettings::default()
};
ComponentInstance::new(
Uuid::new_v4(),
Uuid::new_v4(),
GIFT_MENU_KIND,
GIFT_MENU_NAME,
1,
serde_json::to_value(settings).unwrap(),
)
}
#[test]
fn settings_reject_duplicate_triggers_and_bound_renderer_values() {
let trigger = GiftMenuTrigger::Battery { amount: 150 };
let items = vec![
GiftMenuItem {
id: Uuid::new_v4(),
trigger: trigger.clone(),
description: "点歌".into(),
},
GiftMenuItem {
id: Uuid::new_v4(),
trigger,
description: "学歌".into(),
},
];
let definition = GiftMenuDefinition;
let settings = GiftMenuSettings {
items,
..GiftMenuSettings::default()
};
assert!(
definition
.validate_settings(serde_json::to_value(settings).unwrap())
.is_err()
);
let settings = GiftMenuSettings {
visible_rows: 255,
row_height: 1,
scroll_speed_pixels_per_second: u16::MAX,
font_scale: 1,
..GiftMenuSettings::default()
};
let sanitized = definition
.validate_settings(serde_json::to_value(settings).unwrap())
.unwrap();
assert_eq!(sanitized["visibleRows"], 20);
assert_eq!(sanitized["rowHeight"], 44);
assert_eq!(sanitized["scrollSpeedPixelsPerSecond"], 240);
assert_eq!(sanitized["fontScale"], 50);
}
#[test]
fn gift_projection_prefers_a_specific_gift_over_its_battery_tier() {
let specific = Uuid::new_v4();
let battery = Uuid::new_v4();
let component = component(vec![
GiftMenuItem {
id: specific,
trigger: GiftMenuTrigger::Gift {
gift_id: 31039,
gift_name: "心动盲盒".into(),
image_url: None,
unit_price: 15_000,
},
description: "点歌".into(),
},
GiftMenuItem {
id: battery,
trigger: GiftMenuTrigger::Battery { amount: 150 },
description: "任选挑战".into(),
},
]);
let event = LiveEvent::new(
component.owner_id,
component.account_source_id,
"bilibili",
"123",
LiveEventPayload::Gift(GiftEvent {
viewer: viewer(),
gift: GiftDetails {
id: Some(31039),
name: "心动盲盒".into(),
coin_type: "gold".into(),
battery_value: crate::overlay::gift_price_to_batteries(15_000),
unit_price: 15_000,
total_price: 30_000,
price_cny: 30.0,
image_url: None,
animation_url: None,
effect_type: None,
stay_time: None,
},
quantity: 2,
source_event_id: "event".into(),
}),
);
let message = GiftMenuProjection
.project(&component, &event)
.unwrap()
.unwrap();
assert_eq!(message.event_type, "gift-menu.triggered");
assert_eq!(message.payload["itemIds"], json!([specific]));
}
#[test]
fn gift_projection_falls_back_to_the_unit_battery_value() {
let battery = Uuid::new_v4();
let component = component(vec![
GiftMenuItem {
id: Uuid::new_v4(),
trigger: GiftMenuTrigger::Gift {
gift_id: 999,
gift_name: "其他礼物".into(),
image_url: None,
unit_price: 15_000,
},
description: "其他内容".into(),
},
GiftMenuItem {
id: battery,
trigger: GiftMenuTrigger::Battery { amount: 150 },
description: "任选挑战".into(),
},
]);
let event = LiveEvent::new(
component.owner_id,
component.account_source_id,
"bilibili",
"123",
LiveEventPayload::Gift(GiftEvent {
viewer: viewer(),
gift: GiftDetails {
id: Some(31039),
name: "心动盲盒".into(),
coin_type: "gold".into(),
battery_value: 150,
unit_price: 15_000,
total_price: 15_000,
price_cny: 15.0,
image_url: None,
animation_url: None,
effect_type: None,
stay_time: None,
},
quantity: 1,
source_event_id: "event".into(),
}),
);
let message = GiftMenuProjection
.project(&component, &event)
.unwrap()
.unwrap();
assert_eq!(message.payload["itemIds"], json!([battery]));
}
#[test]
fn guard_projection_normalizes_all_membership_levels() {
let id = Uuid::new_v4();
let component = component(vec![GiftMenuItem {
id,
trigger: GiftMenuTrigger::Guard {
level: GuardLevel::Admiral,
},
description: "专属节目".into(),
}]);
let event = LiveEvent::new(
component.owner_id,
component.account_source_id,
"bilibili",
"123",
LiveEventPayload::GuardPurchase(GuardPurchaseEvent {
viewer: viewer(),
guard_name: "提督".into(),
quantity: 1,
price: 199_800,
}),
);
assert!(
GiftMenuProjection
.project(&component, &event)
.unwrap()
.is_some()
);
}
}
+71 -2
View File
@@ -36,6 +36,7 @@ use crate::{
COMPONENT_PROTOCOL_VERSION, ComponentMessage, DanmakuEvent, DanmakuSegment, EnterEvent,
GiftDetails, GiftEvent, GuardPurchaseEvent, LiveEvent, LiveEventPayload, PlatformViewer,
},
gift_menu::GIFT_MENU_KIND,
repository::{ComponentView, RepositoryError},
song_request::{SONG_REQUEST_KIND, SongListScope, SongRequestError},
};
@@ -89,6 +90,14 @@ pub fn router(state: AppState) -> Router {
"/api/v1/components/{id}/test-events",
post(component_test_event),
)
.route(
"/api/v1/components/{id}/gift-catalog",
get(component_gift_catalog),
)
.route(
"/api/v1/components/{id}/gift-catalog/refresh",
post(refresh_component_gift_catalog),
)
.route(
"/api/v1/components/{public_id}/stream",
get(component_stream),
@@ -741,6 +750,59 @@ async fn put_component_settings(
Ok(Json(json!({"settings":component.settings})))
}
async fn component_gift_catalog(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
let session = require_session(&state, &headers).await?;
let component = state.repository.get_component(session.user.id, id).await?;
if component.kind != GIFT_MENU_KIND {
return Err(ApiError::new(
StatusCode::NOT_FOUND,
"not_found",
"Gift catalog is unavailable for this component",
));
}
let catalog = state.gift_catalogs.catalog(session.user.id).await;
if catalog.is_empty().await {
catalog
.refresh(
&session.user.room_id,
state.config.gift_request_timeout_seconds,
)
.await
.map_err(internal)?;
}
Ok(Json(json!({"gifts":catalog.list().await})))
}
async fn refresh_component_gift_catalog(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
same_origin(&state, &headers)?;
let session = require_session(&state, &headers).await?;
let component = state.repository.get_component(session.user.id, id).await?;
if component.kind != GIFT_MENU_KIND {
return Err(ApiError::new(
StatusCode::NOT_FOUND,
"not_found",
"Gift catalog is unavailable for this component",
));
}
let catalog = state.gift_catalogs.catalog(session.user.id).await;
catalog
.refresh(
&session.user.room_id,
state.config.gift_request_timeout_seconds,
)
.await
.map_err(internal)?;
Ok(Json(json!({"gifts":catalog.list().await})))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SongRequestsQuery {
@@ -907,6 +969,8 @@ enum TestEventRequest {
name: String,
#[serde(rename = "giftName")]
gift_name: String,
#[serde(rename = "giftId")]
gift_id: Option<i64>,
battery: i32,
quantity: i32,
},
@@ -943,18 +1007,23 @@ async fn component_test_event(
uid,
name,
gift_name,
gift_id,
battery,
quantity,
} => {
let quantity = quantity.max(1);
let unit_price = i64::from(battery.max(0));
// The test API accepts the user-facing battery value while the
// canonical event retains Bilibili's raw gold-coin price.
let battery_value = i64::from(battery.max(0));
let unit_price = battery_value.saturating_mul(100);
let total_price = unit_price.saturating_mul(i64::from(quantity));
LiveEventPayload::Gift(GiftEvent {
viewer: viewer(uid, name),
gift: GiftDetails {
id: None,
id: gift_id,
name: gift_name,
coin_type: "gold".into(),
battery_value,
unit_price,
total_price,
price_cny: total_price as f64 / 1000.0,
+1
View File
@@ -13,6 +13,7 @@ pub mod credentials;
pub mod db;
pub mod domain;
pub mod gift_effect;
pub mod gift_menu;
pub mod http_api;
pub mod i18n;
pub mod live;
+219 -32
View File
@@ -29,7 +29,9 @@ use crate::{
UnknownLiveEvent, ViewerInteractionEvent,
},
live::{LiveProvider, SourceContext, SourceStatus},
overlay::{EmoticonCatalog, EmoticonMeta, GiftCatalog, normalize_image_url},
overlay::{
EmoticonCatalog, EmoticonMeta, GiftCatalog, gift_price_to_batteries, normalize_image_url,
},
};
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(20);
@@ -53,10 +55,28 @@ impl BilibiliProvider {
gift_timeout_seconds: u64,
emoticon_refresh_seconds: u64,
emoticon_timeout_seconds: u64,
) -> Self {
Self::with_gift_catalog(
cookie,
gift_refresh_seconds,
gift_timeout_seconds,
emoticon_refresh_seconds,
emoticon_timeout_seconds,
GiftCatalog::default(),
)
}
pub fn with_gift_catalog(
cookie: String,
gift_refresh_seconds: u64,
gift_timeout_seconds: u64,
emoticon_refresh_seconds: u64,
emoticon_timeout_seconds: u64,
gift_catalog: GiftCatalog,
) -> Self {
Self {
cookie: cookie.into(),
gift_catalog: GiftCatalog::default(),
gift_catalog,
emoticon_catalog: EmoticonCatalog::default(),
gift_refresh_seconds,
gift_timeout_seconds,
@@ -149,7 +169,7 @@ impl BilibiliProvider {
name,
gift_id,
coin_type,
battery,
unit_price,
quantity,
event_id,
} => LiveEventPayload::Gift(GiftEvent {
@@ -159,7 +179,7 @@ impl BilibiliProvider {
name,
gift_id,
coin_type,
battery,
unit_price,
quantity,
)
.await,
@@ -171,7 +191,7 @@ impl BilibiliProvider {
name,
gift_id,
coin_type,
battery,
unit_price,
quantity,
combo_id,
} => LiveEventPayload::GiftCombo(GiftComboEvent {
@@ -181,7 +201,7 @@ impl BilibiliProvider {
name,
gift_id,
coin_type,
battery,
unit_price,
quantity,
)
.await,
@@ -416,7 +436,7 @@ enum ProviderEvent {
name: String,
gift_id: Option<i64>,
coin_type: Option<String>,
battery: i32,
unit_price: i64,
quantity: i32,
event_id: String,
},
@@ -425,7 +445,7 @@ enum ProviderEvent {
name: String,
gift_id: Option<i64>,
coin_type: Option<String>,
battery: i32,
unit_price: i64,
quantity: i32,
combo_id: String,
},
@@ -523,15 +543,64 @@ fn normalize_danmaku(message: &DanmuMessage) -> Option<ProviderEvent> {
fn normalize_gift(message: &GiftMessage) -> Option<ProviderEvent> {
let uid = message.data.uid?;
let quantity = bounded_i32(message.data.num.unwrap_or(1)).max(1);
let unit_price = message.data.price.or_else(|| {
let blind_gift = message.data.extra.get("blind_gift");
let original_gift_id = blind_gift
.and_then(|value| value.get("original_gift_id"))
.and_then(value_i64);
let original_gift_name = blind_gift
.and_then(|value| value.get("original_gift_name"))
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let original_price = blind_gift
.and_then(|value| value.get("original_price"))
.and_then(value_i64);
// SEND_GIFT_V2 exposes the actual paid unit value as discount_price.
// Legacy events also carry this field for discounted gifts. Prefer it to
// the catalog/list price, while an explicit blind-box original price stays
// authoritative when present.
let discount_price = message
.data
.extra
.get("discount_price")
.and_then(value_i64)
.filter(|price| *price > 0);
let unit_price = original_price
.or(discount_price)
.or_else(|| message.data.price.and_then(bounded_i64))
.or_else(|| {
message
.data
.total_coin
.and_then(bounded_i64)
.map(|total| total / i64::from(quantity))
});
let gift_id = original_gift_id.or_else(|| message.data.gift_id.and_then(bounded_i64));
let gift_name = original_gift_name.unwrap_or_else(|| {
message
.data
.total_coin
.map(|total| total / u64::try_from(quantity).unwrap_or(1))
.gift_name
.clone()
.unwrap_or_else(|| "礼物".into())
});
info!(
gift_id = gift_id.unwrap_or_default(),
gift_name = %gift_name,
blind_box = blind_gift.is_some(),
raw_unit_price = unit_price.unwrap_or_default(),
battery_value = gift_price_to_batteries(unit_price.unwrap_or_default()),
quantity,
"normalized gift event"
);
let event_id = message
.message_id
.clone()
.or_else(|| {
message
.data
.extra
.get("transaction_id")
.and_then(value_lossless_string)
})
.or_else(|| {
message
.data
@@ -561,14 +630,14 @@ fn normalize_gift(message: &GiftMessage) -> Option<ProviderEvent> {
.clone()
.unwrap_or_else(|| format!("UID {uid}")),
},
name: message
.data
.gift_name
.clone()
.unwrap_or_else(|| "礼物".into()),
gift_id: message.data.gift_id.and_then(bounded_i64),
// Blind-box SEND_GIFT events describe the revealed prize in the
// ordinary gift fields. The `blind_gift.original_*` fields identify
// what the viewer actually bought, which is the correct menu trigger
// and value source.
name: gift_name,
gift_id,
coin_type: message.data.coin_type.clone(),
battery: bounded_i32(unit_price.unwrap_or_default()),
unit_price: unit_price.unwrap_or_default().max(0),
quantity,
event_id,
})
@@ -613,7 +682,7 @@ fn normalize_combo(message: &ComboSendMessage) -> Option<ProviderEvent> {
.unwrap_or_else(|| "礼物".into()),
gift_id,
coin_type: message.data.coin_type.clone(),
battery: bounded_i32(unit_price.unwrap_or_default()),
unit_price: unit_price.and_then(bounded_i64).unwrap_or_default().max(0),
quantity,
combo_id,
})
@@ -717,23 +786,26 @@ fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
"SEND_GIFT" => Some(ProviderEvent::Gift {
viewer: data_viewer(data)?,
name: data
.get("giftName")
.pointer("/blind_gift/original_gift_name")
.or_else(|| data.get("giftName"))
.or_else(|| data.get("gift_name"))?
.as_str()?
.to_owned(),
gift_id: data
.get("giftId")
.pointer("/blind_gift/original_gift_id")
.or_else(|| data.get("giftId"))
.or_else(|| data.get("gift_id"))
.and_then(Value::as_i64),
coin_type: data
.get("coin_type")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
battery: data
.get("price")
unit_price: data
.pointer("/blind_gift/original_price")
.or_else(|| data.get("price"))
.and_then(value_i64)
.map(bounded_signed_i32)
.unwrap_or(0),
.unwrap_or(0)
.max(0),
quantity: data
.get("num")
.and_then(value_i64)
@@ -1065,14 +1137,18 @@ async fn gift_details(
name: String,
gift_id: Option<i64>,
coin_type: Option<String>,
battery: i32,
event_unit_price: i64,
quantity: i32,
) -> GiftDetails {
let metadata = catalog.get(gift_id, &name).await;
let unit_price = metadata
.as_ref()
.map(|gift| gift.unit_price)
.unwrap_or_else(|| i64::from(battery.max(0)));
let unit_price = if event_unit_price > 0 {
event_unit_price
} else {
metadata
.as_ref()
.map(|gift| gift.unit_price)
.unwrap_or_default()
};
let total_price = unit_price.saturating_mul(i64::from(quantity.max(1)));
GiftDetails {
id: metadata.as_ref().and_then(|gift| gift.id).or(gift_id),
@@ -1083,6 +1159,7 @@ async fn gift_details(
coin_type: coin_type
.or_else(|| metadata.as_ref().map(|gift| gift.coin_type.clone()))
.unwrap_or_else(|| "gold".into()),
battery_value: gift_price_to_batteries(unit_price),
unit_price,
total_price,
price_cny: total_price as f64 / 1000.0,
@@ -1098,8 +1175,54 @@ async fn gift_details(
#[cfg(test)]
mod tests {
use super::*;
use base64::{Engine, engine::general_purpose::STANDARD};
use libilibili::websocket::parse_command;
fn push_varint(bytes: &mut Vec<u8>, mut value: u64) {
loop {
let mut byte = (value & 0x7f) as u8;
value >>= 7;
if value != 0 {
byte |= 0x80;
}
bytes.push(byte);
if value == 0 {
break;
}
}
}
fn push_field_varint(bytes: &mut Vec<u8>, field: u64, value: u64) {
push_varint(bytes, field << 3);
push_varint(bytes, value);
}
fn push_field_bytes(bytes: &mut Vec<u8>, field: u64, value: &[u8]) {
push_varint(bytes, (field << 3) | 2);
push_varint(bytes, value.len() as u64);
bytes.extend_from_slice(value);
}
fn gift_v2_payload() -> String {
let mut gift = Vec::new();
push_field_varint(&mut gift, 1, 20_036);
push_field_bytes(&mut gift, 2, "盲盒奖品".as_bytes());
push_field_varint(&mut gift, 3, 1);
push_field_varint(&mut gift, 5, 50_000);
push_field_varint(&mut gift, 6, 50_000);
push_field_varint(&mut gift, 7, 15_000);
push_field_bytes(&mut gift, 8, b"gold");
push_field_bytes(&mut gift, 9, b"gift-v2-transaction");
push_field_varint(&mut gift, 10, 1_753_984_699);
push_field_bytes(&mut gift, 18, "投喂".as_bytes());
let mut message = Vec::new();
push_field_varint(&mut message, 1, 123);
push_field_bytes(&mut message, 2, "V2观众".as_bytes());
push_field_bytes(&mut message, 10, &gift);
STANDARD.encode(message)
}
#[test]
fn normalizes_libilibili_danmaku_to_provider_event() {
let message = normalize_command(parse_command(
@@ -1151,7 +1274,7 @@ mod tests {
viewer,
gift_id,
coin_type,
battery,
unit_price,
quantity,
event_id,
..
@@ -1159,7 +1282,7 @@ mod tests {
assert_eq!(viewer.uid, "123");
assert_eq!(gift_id, Some(42));
assert_eq!(coin_type.as_deref(), Some("gold"));
assert_eq!(battery, 100);
assert_eq!(unit_price, 100);
assert_eq!(quantity, 3);
assert_eq!(event_id, "gift-event-1");
}
@@ -1167,6 +1290,70 @@ mod tests {
}
}
#[test]
fn blind_box_uses_the_purchased_gift_identity_and_price() {
let event = normalize_command(parse_command(json!({
"cmd":"SEND_GIFT",
"data":{
"uid":123,
"uname":"盲盒观众",
"giftId":20036,
"giftName":"盲盒奖品",
"num":1,
"price":50000,
"coin_type":"gold",
"blind_gift":{
"original_gift_id":20002,
"original_gift_name":"心动盲盒",
"original_price":15000
}
}
})))
.unwrap();
match event {
ProviderEvent::Gift {
gift_id,
name,
unit_price,
..
} => {
assert_eq!(gift_id, Some(20002));
assert_eq!(name, "心动盲盒");
assert_eq!(unit_price, 15_000);
}
_ => panic!("expected gift"),
}
}
#[test]
fn gift_v2_uses_discount_value_and_transaction_identity() {
let event = normalize_command(parse_command(json!({
"cmd":"SEND_GIFT_V2",
"data":{"pb":gift_v2_payload()}
})))
.unwrap();
match event {
ProviderEvent::Gift {
viewer,
gift_id,
name,
unit_price,
quantity,
event_id,
..
} => {
assert_eq!(viewer.uid, "123");
assert_eq!(viewer.name, "V2观众");
assert_eq!(gift_id, Some(20_036));
assert_eq!(name, "盲盒奖品");
assert_eq!(unit_price, 15_000);
assert_eq!(quantity, 1);
assert_eq!(event_id, "gift-v2-transaction");
}
_ => panic!("expected gift"),
}
}
#[test]
fn normalizes_typed_combo_commands() {
let event = normalize_command(parse_command(json!({
+75 -1
View File
@@ -10,6 +10,7 @@ use std::{collections::HashMap, sync::Arc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::RwLock;
use uuid::Uuid;
/// Stable identifier for a renderer theme.
///
@@ -117,6 +118,7 @@ pub struct GiftMeta {
pub id: Option<i64>,
pub name: String,
pub coin_type: String,
pub battery_value: i64,
pub unit_price: i64,
pub image_url: Option<String>,
pub animation_url: Option<String>,
@@ -130,6 +132,24 @@ pub struct GiftCatalog {
by_name: Arc<RwLock<HashMap<String, GiftMeta>>>,
}
/// Account-keyed catalog handles shared by the live provider and control API.
/// Each account receives a distinct [`GiftCatalog`], so switching or refreshing
/// one room can never replace another tenant's gift metadata.
#[derive(Clone, Default)]
pub struct GiftCatalogRegistry {
catalogs: Arc<RwLock<HashMap<Uuid, GiftCatalog>>>,
}
impl GiftCatalogRegistry {
pub async fn catalog(&self, owner_id: Uuid) -> GiftCatalog {
if let Some(catalog) = self.catalogs.read().await.get(&owner_id).cloned() {
return catalog;
}
let mut catalogs = self.catalogs.write().await;
catalogs.entry(owner_id).or_default().clone()
}
}
#[derive(Clone, Debug)]
pub struct EmoticonMeta {
pub emoji: String,
@@ -239,6 +259,21 @@ impl GiftCatalog {
.cloned()
}
/// Return a stable, deduplicated control-console view ordered by price and
/// name. ID-backed entries are preferred because names are not unique.
pub async fn list(&self) -> Vec<GiftMeta> {
let mut gifts: Vec<_> = self.by_id.read().await.values().cloned().collect();
if gifts.is_empty() {
gifts.extend(self.by_name.read().await.values().cloned());
}
gifts.sort_by(|left, right| {
left.unit_price
.cmp(&right.unit_price)
.then_with(|| left.name.cmp(&right.name))
});
gifts
}
pub async fn refresh(&self, room_id: &str, timeout_seconds: u64) -> Result<usize, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_seconds))
@@ -285,6 +320,7 @@ fn parse_catalog(payload: &Value) -> Result<GiftCatalogSnapshot, String> {
continue;
};
let id = item.get("id").and_then(Value::as_i64);
let unit_price = item.get("price").and_then(Value::as_i64).unwrap_or(0);
let gift = GiftMeta {
id,
name: name.to_owned(),
@@ -293,7 +329,8 @@ fn parse_catalog(payload: &Value) -> Result<GiftCatalogSnapshot, String> {
.and_then(Value::as_str)
.unwrap_or("gold")
.to_owned(),
unit_price: item.get("price").and_then(Value::as_i64).unwrap_or(0),
battery_value: gift_price_to_batteries(unit_price),
unit_price,
image_url: string_field(item, "img_basic"),
animation_url: string_field(item, "gif"),
effect_type: item.get("effect").and_then(|value| match value {
@@ -314,6 +351,12 @@ fn parse_catalog(payload: &Value) -> Result<GiftCatalogSnapshot, String> {
Ok((ids, names, list.len()))
}
/// Bilibili's gift panel and live messages express paid gift prices in gold
/// coins rather than batteries. One battery is 100 gold coins (and ¥0.1).
pub fn gift_price_to_batteries(raw_price: i64) -> i64 {
raw_price.max(0) / 100
}
fn string_field(value: &Value, name: &str) -> Option<String> {
value
.get(name)
@@ -414,6 +457,29 @@ fn normalize_name(name: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn gift_catalog_registry_isolates_account_snapshots() {
let registry = GiftCatalogRegistry::default();
let first = registry.catalog(Uuid::new_v4()).await;
let second = registry.catalog(Uuid::new_v4()).await;
first.by_id.write().await.insert(
1,
GiftMeta {
id: Some(1),
name: "小花花".into(),
coin_type: "gold".into(),
battery_value: 1,
unit_price: 100,
image_url: None,
animation_url: None,
effect_type: None,
stay_time: None,
},
);
assert_eq!(first.list().await.len(), 1);
assert!(second.list().await.is_empty());
}
use serde_json::json;
#[test]
@@ -425,6 +491,7 @@ mod tests {
let (ids, names, count) = parse_catalog(&payload).expect("catalog");
assert_eq!(count, 1);
assert_eq!(ids.get(&42).expect("id index").unit_price, 30_000);
assert_eq!(ids.get(&42).expect("id index").battery_value, 300);
assert_eq!(
names
.get("青玉灯")
@@ -435,6 +502,13 @@ mod tests {
);
}
#[test]
fn converts_raw_gold_coin_prices_to_batteries() {
assert_eq!(gift_price_to_batteries(100), 1);
assert_eq!(gift_price_to_batteries(15_000), 150);
assert_eq!(gift_price_to_batteries(-1), 0);
}
#[test]
fn rejects_an_empty_catalog_without_replacing_the_cache() {
let payload = json!({"data":{"gift_config":{"base_config":{"list":[]}}}});
+22 -2
View File
@@ -16,6 +16,7 @@ use crate::{
components::{ComponentInstance, ComponentRegistry},
db::{ComponentRecord, Db, DbError},
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
i18n,
realtime::InMemoryComponentStore,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
@@ -113,6 +114,22 @@ impl TenantRepository {
],
)
.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?;
transaction.commit().await?;
}
Ok(())
@@ -148,7 +165,7 @@ impl TenantRepository {
// 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) {
if matches!(kind, SONG_REQUEST_KIND | GIFT_EFFECT_KIND | GIFT_MENU_KIND) {
return Err(RepositoryError::Forbidden);
}
let runtime = self
@@ -209,7 +226,10 @@ impl TenantRepository {
.await?
.ok_or(RepositoryError::NotFound)?
.get(0);
if matches!(kind.as_str(), SONG_REQUEST_KIND | GIFT_EFFECT_KIND) {
if matches!(
kind.as_str(),
SONG_REQUEST_KIND | GIFT_EFFECT_KIND | GIFT_MENU_KIND
) {
return Err(RepositoryError::Forbidden);
}
let changed = transaction