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:
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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":[]}}}});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user