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
+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":[]}}}});