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