更新点歌和弹幕微调
This commit is contained in:
@@ -21,8 +21,9 @@ crate 导出。
|
||||
| `rate_limit` | 匿名登录和 enrollment 滥用限制 |
|
||||
| `realtime` | account event routing 与 component-scoped fanout |
|
||||
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
|
||||
| `song_request` | 点歌命令、事务队列、评分、快照和管理服务 |
|
||||
| `song_request` | 点歌命令、事务队列、快照和管理服务 |
|
||||
| `gift_effect` | 全屏礼物流星设置、分档边界与事件订阅 |
|
||||
| `guard_effect` | 独立大航海主题、感谢文案与 `live.guard.buy` 事件订阅 |
|
||||
| `gift_menu` | 礼物菜单设置、触发匹配与 OBS 高亮投影 |
|
||||
|
||||
## 重要不变量
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::{
|
||||
db::{Db, DbError},
|
||||
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
|
||||
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
|
||||
guard_effect::{GUARD_EFFECT_KIND, GUARD_EFFECT_NAME, GuardEffectSettings},
|
||||
i18n,
|
||||
overlay::OverlaySettings,
|
||||
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
||||
@@ -669,6 +670,23 @@ impl AuthService {
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let guard_component_id = Uuid::new_v4();
|
||||
let guard_settings = serde_json::to_value(GuardEffectSettings::default())
|
||||
.expect("GuardEffectSettings 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)",
|
||||
&[
|
||||
&guard_component_id,
|
||||
&user_id,
|
||||
&GUARD_EFFECT_KIND,
|
||||
&GUARD_EFFECT_NAME,
|
||||
&guard_settings,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let menu_component_id = Uuid::new_v4();
|
||||
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
|
||||
.expect("GiftMenuSettings is always JSON serializable");
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::{
|
||||
domain::{ComponentMessage, LiveEvent, LiveEventKind},
|
||||
gift_effect::GiftEffectDefinition,
|
||||
gift_menu::{GiftMenuDefinition, GiftMenuProjection},
|
||||
guard_effect::GuardEffectDefinition,
|
||||
overlay::OverlaySettings,
|
||||
song_request::{SongRequestDefinition, SongRequestProjection},
|
||||
};
|
||||
@@ -389,6 +390,12 @@ impl ComponentRegistry {
|
||||
Arc::new(PassthroughProjection),
|
||||
)
|
||||
.expect("built-in component kinds are unique");
|
||||
registry
|
||||
.register(
|
||||
Arc::new(GuardEffectDefinition),
|
||||
Arc::new(PassthroughProjection),
|
||||
)
|
||||
.expect("built-in component kinds are unique");
|
||||
registry
|
||||
.register(Arc::new(GiftMenuDefinition), Arc::new(GiftMenuProjection))
|
||||
.expect("built-in component kinds are unique");
|
||||
@@ -560,7 +567,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_gift_effect_is_registered_with_guard_and_gift_subscriptions() {
|
||||
fn builtin_gift_effect_only_subscribes_to_gifts() {
|
||||
let registry = ComponentRegistry::default();
|
||||
assert!(registry.kinds().contains(&"gift_effect".to_owned()));
|
||||
let runtime = registry.runtime("gift_effect").unwrap();
|
||||
@@ -574,7 +581,26 @@ mod tests {
|
||||
);
|
||||
let subscriptions = runtime.subscriptions(&instance).unwrap();
|
||||
assert!(subscriptions.contains(LiveEventKind::Gift));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_guard_effect_only_subscribes_to_guard_purchases() {
|
||||
let registry = ComponentRegistry::default();
|
||||
assert!(registry.kinds().contains(&"guard_effect".to_owned()));
|
||||
let runtime = registry.runtime("guard_effect").unwrap();
|
||||
let instance = ComponentInstance::new(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
"guard_effect",
|
||||
"大航海特效",
|
||||
1,
|
||||
runtime.definition().default_settings(),
|
||||
);
|
||||
let subscriptions = runtime.subscriptions(&instance).unwrap();
|
||||
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||
assert!(!subscriptions.contains(LiveEventKind::Gift));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ struct OverlayFileConfig {
|
||||
font_scale: Option<u16>,
|
||||
decoration_line_weight: Option<u16>,
|
||||
max_visible: Option<u8>,
|
||||
expand_new_danmaku: Option<bool>,
|
||||
collapse_after_seconds: Option<u16>,
|
||||
unfold_duration_ms: Option<u16>,
|
||||
motion_intensity: Option<u8>,
|
||||
@@ -345,6 +346,9 @@ fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
|
||||
show_like: file.events.like.unwrap_or(default.show_like),
|
||||
show_share: file.events.share.unwrap_or(default.show_share),
|
||||
max_visible: file.max_visible.unwrap_or(default.max_visible),
|
||||
expand_new_danmaku: file
|
||||
.expand_new_danmaku
|
||||
.unwrap_or(default.expand_new_danmaku),
|
||||
collapse_after_seconds: file
|
||||
.collapse_after_seconds
|
||||
.unwrap_or(default.collapse_after_seconds),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Full-screen gift and membership effect component.
|
||||
//! Full-screen gift effect component.
|
||||
//!
|
||||
//! The component is deliberately passive: it subscribes to canonical gift and
|
||||
//! guard-purchase events and projects them to its own authenticated OBS stream.
|
||||
//! The component is deliberately passive: it subscribes only to canonical gift
|
||||
//! events and projects them to its own authenticated OBS stream.
|
||||
//! All visual differentiation is settings-driven in the browser, so receiving
|
||||
//! an effect never creates database writes or depends on an OBS connection.
|
||||
|
||||
@@ -57,9 +57,8 @@ pub struct GiftEffectSettings {
|
||||
pub high: MeteorTierSettings,
|
||||
pub featured: MeteorTierSettings,
|
||||
pub trail_intensity: u8,
|
||||
pub guard_star_count: u8,
|
||||
pub guard_effect_duration_ms: u16,
|
||||
pub max_concurrent_effects: u8,
|
||||
#[serde(default = "default_queue_capacity")]
|
||||
pub queue_capacity: u16,
|
||||
pub low_performance_mode: bool,
|
||||
}
|
||||
|
||||
@@ -87,9 +86,7 @@ impl Default for GiftEffectSettings {
|
||||
speed: 880,
|
||||
},
|
||||
trail_intensity: 78,
|
||||
guard_star_count: 48,
|
||||
guard_effect_duration_ms: 5_200,
|
||||
max_concurrent_effects: 8,
|
||||
queue_capacity: default_queue_capacity(),
|
||||
low_performance_mode: false,
|
||||
}
|
||||
}
|
||||
@@ -105,13 +102,15 @@ impl GiftEffectSettings {
|
||||
self.high = self.high.sanitize();
|
||||
self.featured = self.featured.sanitize();
|
||||
self.trail_intensity = self.trail_intensity.min(100);
|
||||
self.guard_star_count = self.guard_star_count.clamp(8, 96);
|
||||
self.guard_effect_duration_ms = self.guard_effect_duration_ms.clamp(1_000, 15_000);
|
||||
self.max_concurrent_effects = self.max_concurrent_effects.clamp(1, 12);
|
||||
self.queue_capacity = self.queue_capacity.clamp(1, 1_000);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_queue_capacity() -> u16 {
|
||||
256
|
||||
}
|
||||
|
||||
pub struct GiftEffectDefinition;
|
||||
|
||||
impl GiftEffectDefinition {
|
||||
@@ -148,10 +147,7 @@ impl ComponentDefinition for GiftEffectDefinition {
|
||||
|
||||
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
|
||||
self.parse(settings.clone())?;
|
||||
Ok(EventSubscription::new([
|
||||
LiveEventKind::Gift,
|
||||
LiveEventKind::GuardPurchase,
|
||||
]))
|
||||
Ok(EventSubscription::new([LiveEventKind::Gift]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,25 +164,38 @@ mod tests {
|
||||
settings["featured"]["size"] = Value::from(9_999);
|
||||
settings["highValueThreshold"] = Value::from(50_000);
|
||||
settings["featuredValueThreshold"] = Value::from(10_000);
|
||||
settings["queueCapacity"] = Value::from(9_999);
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
assert_eq!(sanitized["normal"]["count"], 1);
|
||||
assert_eq!(sanitized["fontBrightness"], 180);
|
||||
assert_eq!(sanitized["featured"]["size"], 480);
|
||||
assert_eq!(sanitized["featuredValueThreshold"], 50_000);
|
||||
assert_eq!(sanitized["queueCapacity"], 1_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_only_subscribes_to_durable_gifts_and_guards() {
|
||||
fn component_only_subscribes_to_durable_gifts() {
|
||||
let definition = GiftEffectDefinition;
|
||||
let subscriptions = definition
|
||||
.subscriptions(&definition.default_settings())
|
||||
.unwrap();
|
||||
assert!(subscriptions.contains(LiveEventKind::Gift));
|
||||
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
assert!(!subscriptions.contains(LiveEventKind::Danmaku));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_settings_receive_queue_capacity_default() {
|
||||
let definition = GiftEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings.as_object_mut().unwrap().remove("queueCapacity");
|
||||
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
|
||||
assert_eq!(sanitized["queueCapacity"], 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moonlit_water_theme_is_accepted_and_serialized() {
|
||||
let definition = GiftEffectDefinition;
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Full-screen membership-purchase effect component.
|
||||
//!
|
||||
//! This component is deliberately separate from gift effects: every instance
|
||||
//! owns its settings, read-only OBS token, event subscription, and WebSocket
|
||||
//! channel, and consumes only canonical guard-purchase events.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
components::{ComponentDefinition, ComponentError, EventSubscription},
|
||||
domain::LiveEventKind,
|
||||
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
|
||||
};
|
||||
|
||||
pub const GUARD_EFFECT_KIND: &str = "guard_effect";
|
||||
pub const GUARD_EFFECT_NAME: &str = "大航海特效";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum GuardEffectThemeId {
|
||||
#[default]
|
||||
JadeStarfall,
|
||||
MoonlitWater,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GuardEffectSettings {
|
||||
#[serde(default)]
|
||||
pub theme_id: GuardEffectThemeId,
|
||||
#[serde(default)]
|
||||
pub font_family: FontFamilyId,
|
||||
#[serde(default = "default_font_brightness")]
|
||||
pub font_brightness: u16,
|
||||
pub star_count: u8,
|
||||
pub effect_duration_ms: u16,
|
||||
#[serde(default = "default_title_template")]
|
||||
pub title_template: String,
|
||||
#[serde(default = "default_closing_text")]
|
||||
pub closing_text: String,
|
||||
#[serde(default = "default_queue_capacity")]
|
||||
pub queue_capacity: u16,
|
||||
pub low_performance_mode: bool,
|
||||
}
|
||||
|
||||
impl Default for GuardEffectSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme_id: GuardEffectThemeId::default(),
|
||||
font_family: FontFamilyId::default(),
|
||||
font_brightness: default_font_brightness(),
|
||||
star_count: 48,
|
||||
effect_duration_ms: 5_200,
|
||||
title_template: default_title_template(),
|
||||
closing_text: default_closing_text(),
|
||||
queue_capacity: default_queue_capacity(),
|
||||
low_performance_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GuardEffectSettings {
|
||||
pub fn sanitize(mut self) -> Self {
|
||||
self.font_brightness = sanitize_font_brightness(self.font_brightness);
|
||||
self.star_count = self.star_count.clamp(8, 96);
|
||||
self.effect_duration_ms = self.effect_duration_ms.clamp(1_000, 15_000);
|
||||
self.title_template = sanitize_copy(self.title_template, &default_title_template());
|
||||
self.closing_text = sanitize_copy(self.closing_text, &default_closing_text());
|
||||
self.queue_capacity = self.queue_capacity.clamp(1, 1_000);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_queue_capacity() -> u16 {
|
||||
256
|
||||
}
|
||||
|
||||
/// Preserve the membership-related portion of the first legacy gift-effect
|
||||
/// instance when an existing account receives its initial guard component.
|
||||
pub fn settings_from_legacy_gift(value: &Value) -> GuardEffectSettings {
|
||||
let mut migrated = serde_json::to_value(GuardEffectSettings::default())
|
||||
.expect("GuardEffectSettings is always JSON serializable");
|
||||
for (legacy, current) in [
|
||||
("themeId", "themeId"),
|
||||
("fontFamily", "fontFamily"),
|
||||
("fontBrightness", "fontBrightness"),
|
||||
("guardStarCount", "starCount"),
|
||||
("guardEffectDurationMs", "effectDurationMs"),
|
||||
("guardTitleTemplate", "titleTemplate"),
|
||||
("guardClosingText", "closingText"),
|
||||
("lowPerformanceMode", "lowPerformanceMode"),
|
||||
] {
|
||||
if let Some(candidate) = value.get(legacy) {
|
||||
migrated[current] = candidate.clone();
|
||||
}
|
||||
}
|
||||
serde_json::from_value::<GuardEffectSettings>(migrated)
|
||||
.unwrap_or_default()
|
||||
.sanitize()
|
||||
}
|
||||
|
||||
fn default_title_template() -> String {
|
||||
"{guard}启航".into()
|
||||
}
|
||||
|
||||
fn default_closing_text() -> String {
|
||||
"相伴前行".into()
|
||||
}
|
||||
|
||||
fn sanitize_copy(value: String, fallback: &str) -> String {
|
||||
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.is_empty() {
|
||||
fallback.to_owned()
|
||||
} else {
|
||||
normalized.chars().take(80).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GuardEffectDefinition;
|
||||
|
||||
impl GuardEffectDefinition {
|
||||
fn parse(&self, settings: Value) -> Result<GuardEffectSettings, ComponentError> {
|
||||
serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
|
||||
kind: GUARD_EFFECT_KIND.to_owned(),
|
||||
detail: error.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ComponentDefinition for GuardEffectDefinition {
|
||||
fn kind(&self) -> &'static str {
|
||||
GUARD_EFFECT_KIND
|
||||
}
|
||||
|
||||
fn settings_version(&self) -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_settings(&self) -> Value {
|
||||
serde_json::to_value(GuardEffectSettings::default())
|
||||
.expect("GuardEffectSettings is always JSON serializable")
|
||||
}
|
||||
|
||||
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError> {
|
||||
serde_json::to_value(self.parse(settings)?.sanitize()).map_err(|error| {
|
||||
ComponentError::InvalidSettings {
|
||||
kind: GUARD_EFFECT_KIND.to_owned(),
|
||||
detail: error.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
|
||||
self.parse(settings.clone())?;
|
||||
Ok(EventSubscription::new([LiveEventKind::GuardPurchase]))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_are_bounded_and_copy_is_sanitized() {
|
||||
let definition = GuardEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings["fontBrightness"] = Value::from(999);
|
||||
settings["starCount"] = Value::from(255);
|
||||
settings["effectDurationMs"] = Value::from(200);
|
||||
settings["queueCapacity"] = Value::from(9_999);
|
||||
settings["titleTemplate"] = Value::from(format!(" {{guard}}{} ", "启航".repeat(50)));
|
||||
settings["closingText"] = Value::from(" ");
|
||||
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
|
||||
assert_eq!(sanitized["fontBrightness"], 180);
|
||||
assert_eq!(sanitized["starCount"], 96);
|
||||
assert_eq!(sanitized["effectDurationMs"], 1_000);
|
||||
assert_eq!(sanitized["queueCapacity"], 1_000);
|
||||
assert_eq!(
|
||||
sanitized["titleTemplate"].as_str().unwrap().chars().count(),
|
||||
80
|
||||
);
|
||||
assert_eq!(sanitized["closingText"], "相伴前行");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_settings_receive_copy_defaults() {
|
||||
let definition = GuardEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings.as_object_mut().unwrap().remove("titleTemplate");
|
||||
settings.as_object_mut().unwrap().remove("closingText");
|
||||
settings.as_object_mut().unwrap().remove("queueCapacity");
|
||||
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
|
||||
assert_eq!(sanitized["titleTemplate"], "{guard}启航");
|
||||
assert_eq!(sanitized["closingText"], "相伴前行");
|
||||
assert_eq!(sanitized["queueCapacity"], 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_gift_settings_preserve_membership_configuration() {
|
||||
let legacy = serde_json::json!({
|
||||
"themeId": "moonlit-water",
|
||||
"fontFamily": "kai",
|
||||
"fontBrightness": 145,
|
||||
"guardStarCount": 72,
|
||||
"guardEffectDurationMs": 8_500,
|
||||
"guardTitleTemplate": "{guard}出发",
|
||||
"guardClosingText": "一路顺风",
|
||||
"lowPerformanceMode": true,
|
||||
"normal": { "count": 20 }
|
||||
});
|
||||
|
||||
let migrated = settings_from_legacy_gift(&legacy);
|
||||
|
||||
assert_eq!(migrated.theme_id, GuardEffectThemeId::MoonlitWater);
|
||||
assert_eq!(migrated.star_count, 72);
|
||||
assert_eq!(migrated.effect_duration_ms, 8_500);
|
||||
assert_eq!(migrated.title_template, "{guard}出发");
|
||||
assert_eq!(migrated.closing_text, "一路顺风");
|
||||
assert!(migrated.low_performance_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_only_subscribes_to_guard_purchases() {
|
||||
let definition = GuardEffectDefinition;
|
||||
let subscriptions = definition
|
||||
.subscriptions(&definition.default_settings())
|
||||
.unwrap();
|
||||
|
||||
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||
assert!(!subscriptions.contains(LiveEventKind::Gift));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moonlit_water_theme_is_accepted_and_serialized() {
|
||||
let definition = GuardEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings["themeId"] = Value::from("moonlit-water");
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
assert_eq!(sanitized["themeId"], "moonlit-water");
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub mod db;
|
||||
pub mod domain;
|
||||
pub mod gift_effect;
|
||||
pub mod gift_menu;
|
||||
pub mod guard_effect;
|
||||
pub mod http_api;
|
||||
pub mod i18n;
|
||||
pub mod live;
|
||||
|
||||
@@ -57,6 +57,8 @@ pub struct OverlaySettings {
|
||||
pub show_like: bool,
|
||||
pub show_share: bool,
|
||||
pub max_visible: u8,
|
||||
#[serde(default = "default_expand_new_danmaku")]
|
||||
pub expand_new_danmaku: bool,
|
||||
pub collapse_after_seconds: u16,
|
||||
#[serde(default = "default_unfold_duration_ms")]
|
||||
pub unfold_duration_ms: u16,
|
||||
@@ -89,6 +91,7 @@ impl Default for OverlaySettings {
|
||||
show_like: false,
|
||||
show_share: false,
|
||||
max_visible: 5,
|
||||
expand_new_danmaku: default_expand_new_danmaku(),
|
||||
collapse_after_seconds: 12,
|
||||
unfold_duration_ms: default_unfold_duration_ms(),
|
||||
motion_intensity: 70,
|
||||
@@ -141,6 +144,10 @@ fn default_unfold_duration_ms() -> u16 {
|
||||
1_000
|
||||
}
|
||||
|
||||
fn default_expand_new_danmaku() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_particle_count() -> u8 {
|
||||
8
|
||||
}
|
||||
@@ -588,6 +595,7 @@ mod tests {
|
||||
danmaku_color: Some("not-css".into()),
|
||||
decoration_line_weight: 999,
|
||||
max_visible: 99,
|
||||
expand_new_danmaku: false,
|
||||
collapse_after_seconds: 1,
|
||||
unfold_duration_ms: 9_000,
|
||||
motion_intensity: 200,
|
||||
@@ -604,6 +612,7 @@ mod tests {
|
||||
assert_eq!(settings.danmaku_color, None);
|
||||
assert_eq!(settings.decoration_line_weight, 300);
|
||||
assert_eq!(settings.max_visible, 12);
|
||||
assert!(!settings.expand_new_danmaku);
|
||||
assert_eq!(settings.collapse_after_seconds, 2);
|
||||
assert_eq!(settings.unfold_duration_ms, 5_000);
|
||||
assert_eq!(settings.motion_intensity, 100);
|
||||
@@ -630,6 +639,7 @@ mod tests {
|
||||
object.remove("viewerColor");
|
||||
object.remove("danmakuColor");
|
||||
object.remove("decorationLineWeight");
|
||||
object.remove("expandNewDanmaku");
|
||||
object.remove("unfoldDurationMs");
|
||||
object.remove("particleCount");
|
||||
object.remove("particleSpeed");
|
||||
@@ -642,6 +652,7 @@ mod tests {
|
||||
assert_eq!(settings.danmaku_color, None);
|
||||
assert_eq!(settings.font_scale, 140);
|
||||
assert_eq!(settings.decoration_line_weight, 160);
|
||||
assert!(settings.expand_new_danmaku);
|
||||
assert_eq!(settings.unfold_duration_ms, 1_000);
|
||||
assert_eq!(settings.particle_count, 8);
|
||||
assert_eq!(settings.particle_speed, 100);
|
||||
|
||||
@@ -17,6 +17,9 @@ use crate::{
|
||||
db::{ComponentRecord, Db, DbError},
|
||||
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
|
||||
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
|
||||
guard_effect::{
|
||||
GUARD_EFFECT_KIND, GUARD_EFFECT_NAME, GuardEffectSettings, settings_from_legacy_gift,
|
||||
},
|
||||
i18n,
|
||||
realtime::InMemoryComponentStore,
|
||||
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
||||
@@ -140,6 +143,44 @@ impl TenantRepository {
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let has_guard_effect = transaction
|
||||
.query_one(
|
||||
"SELECT EXISTS(SELECT 1 FROM component_instances \
|
||||
WHERE owner_user_id=$1 AND kind=$2)",
|
||||
&[&tenant.user_id, &GUARD_EFFECT_KIND],
|
||||
)
|
||||
.await?
|
||||
.get::<_, bool>(0);
|
||||
if !has_guard_effect {
|
||||
let legacy_gift_settings = transaction
|
||||
.query_opt(
|
||||
"SELECT settings FROM component_instances \
|
||||
WHERE owner_user_id=$1 AND kind=$2 ORDER BY created_at,id LIMIT 1",
|
||||
&[&tenant.user_id, &GIFT_EFFECT_KIND],
|
||||
)
|
||||
.await?
|
||||
.map(|row| row.get::<_, Value>(0));
|
||||
let guard_settings = legacy_gift_settings
|
||||
.as_ref()
|
||||
.map(settings_from_legacy_gift)
|
||||
.unwrap_or_else(GuardEffectSettings::default);
|
||||
let guard_settings = serde_json::to_value(guard_settings)
|
||||
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO component_instances \
|
||||
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||
VALUES($1,$2,$3,$4,$5,1,true)",
|
||||
&[
|
||||
&Uuid::new_v4(),
|
||||
&tenant.user_id,
|
||||
&GUARD_EFFECT_KIND,
|
||||
&GUARD_EFFECT_NAME,
|
||||
&guard_settings,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let has_gift_menu = transaction
|
||||
.query_one(
|
||||
"SELECT EXISTS(SELECT 1 FROM component_instances \
|
||||
|
||||
@@ -128,34 +128,20 @@ pub enum SongCommand {
|
||||
title: String,
|
||||
normalized_title: String,
|
||||
},
|
||||
Rate(u8),
|
||||
}
|
||||
|
||||
/// Parse only explicit commands separated from their argument by whitespace.
|
||||
/// This avoids treating ordinary words such as “点歌姬” as queue mutations.
|
||||
/// Parse a request prefix followed immediately by a title or by whitespace and
|
||||
/// a title. Whitespace inside the title is normalized before deduplication.
|
||||
pub fn parse_command(text: &str) -> Option<SongCommand> {
|
||||
let canonical = collapse_whitespace(text);
|
||||
let (command, argument) = canonical.split_once(' ')?;
|
||||
match command {
|
||||
"点歌" => {
|
||||
if argument.is_empty() || argument.chars().count() > 80 {
|
||||
return None;
|
||||
}
|
||||
Some(SongCommand::Request {
|
||||
title: argument.to_owned(),
|
||||
normalized_title: argument.to_lowercase(),
|
||||
})
|
||||
}
|
||||
"打分" => match argument {
|
||||
"1" => Some(SongCommand::Rate(1)),
|
||||
"2" => Some(SongCommand::Rate(2)),
|
||||
"3" => Some(SongCommand::Rate(3)),
|
||||
"4" => Some(SongCommand::Rate(4)),
|
||||
"5" => Some(SongCommand::Rate(5)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
let argument = canonical.strip_prefix("点歌")?.trim_start();
|
||||
if argument.is_empty() || argument.chars().count() > 80 {
|
||||
return None;
|
||||
}
|
||||
Some(SongCommand::Request {
|
||||
title: argument.to_owned(),
|
||||
normalized_title: argument.to_lowercase(),
|
||||
})
|
||||
}
|
||||
|
||||
fn collapse_whitespace(value: &str) -> String {
|
||||
@@ -180,8 +166,6 @@ pub struct SongRequestItem {
|
||||
pub requested_at: DateTime<Utc>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub finished_at: Option<DateTime<Utc>>,
|
||||
pub average_score: Option<f64>,
|
||||
pub rating_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -191,7 +175,6 @@ pub struct SongQueueSummary {
|
||||
pub queued_count: i64,
|
||||
pub completed_count: i64,
|
||||
pub cancelled_count: i64,
|
||||
pub rating_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -241,25 +224,20 @@ impl SongRequestService {
|
||||
};
|
||||
let settings: SongRequestSettings = serde_json::from_value(component.settings.clone())
|
||||
.map_err(|error| SongRequestError::Invalid(error.to_string()))?;
|
||||
let result = match command {
|
||||
SongCommand::Request {
|
||||
title,
|
||||
normalized_title,
|
||||
} => {
|
||||
self.request_song(
|
||||
component,
|
||||
&danmaku.viewer,
|
||||
&title,
|
||||
&normalized_title,
|
||||
event.id,
|
||||
&settings,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
SongCommand::Rate(score) => {
|
||||
self.rate_current(component, &danmaku.viewer, score).await?
|
||||
}
|
||||
};
|
||||
let SongCommand::Request {
|
||||
title,
|
||||
normalized_title,
|
||||
} = command;
|
||||
let result = self
|
||||
.request_song(
|
||||
component,
|
||||
&danmaku.viewer,
|
||||
&title,
|
||||
&normalized_title,
|
||||
event.id,
|
||||
&settings,
|
||||
)
|
||||
.await?;
|
||||
if let Some(change) = result {
|
||||
self.publish_change(component, &event.room_id, change)?;
|
||||
}
|
||||
@@ -299,7 +277,7 @@ impl SongRequestService {
|
||||
};
|
||||
let sql = format!(
|
||||
"{} WHERE r.component_instance_id=$1 AND {status_filter} \
|
||||
GROUP BY r.id ORDER BY {order} OFFSET $2 LIMIT $3",
|
||||
ORDER BY {order} OFFSET $2 LIMIT $3",
|
||||
item_select()
|
||||
);
|
||||
let rows = transaction
|
||||
@@ -342,7 +320,7 @@ impl SongRequestService {
|
||||
.query(
|
||||
&format!(
|
||||
"{} WHERE r.component_instance_id=$1 AND r.status='queued' \
|
||||
GROUP BY r.id ORDER BY r.queue_position ASC",
|
||||
ORDER BY r.queue_position ASC",
|
||||
item_select()
|
||||
),
|
||||
&[&component.id],
|
||||
@@ -671,59 +649,6 @@ impl SongRequestService {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn rate_current(
|
||||
&self,
|
||||
component: &ComponentInstance,
|
||||
viewer: &PlatformViewer,
|
||||
score: u8,
|
||||
) -> Result<Option<SongQueueChange>, SongRequestError> {
|
||||
let viewer_uid = bounded_identity(&viewer.uid, 64)?;
|
||||
let viewer_name = bounded_identity(&viewer.name, 80)?;
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Db::set_tenant(&transaction, component.owner_id).await?;
|
||||
lock_state(&transaction, component).await?;
|
||||
let Some(row) = transaction
|
||||
.query_opt(
|
||||
"SELECT id FROM song_requests WHERE component_instance_id=$1 AND status='current' FOR UPDATE",
|
||||
&[&component.id],
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let request_id: Uuid = row.get(0);
|
||||
let score = i16::from(score);
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO song_ratings \
|
||||
(id,owner_user_id,component_instance_id,song_request_id,viewer_uid,viewer_name,score) \
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7) \
|
||||
ON CONFLICT(song_request_id,viewer_uid) DO UPDATE \
|
||||
SET score=EXCLUDED.score,viewer_name=EXCLUDED.viewer_name,updated_at=now()",
|
||||
&[
|
||||
&Uuid::new_v4(),
|
||||
&component.owner_id,
|
||||
&component.id,
|
||||
&request_id,
|
||||
&viewer_uid,
|
||||
&viewer_name,
|
||||
&score,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let revision = bump_revision(&transaction, component.id).await?;
|
||||
let current = current_item(&transaction, component.id).await?;
|
||||
transaction.commit().await?;
|
||||
Ok(Some(SongQueueChange {
|
||||
revision,
|
||||
operation: "rating-updated",
|
||||
item_id: request_id,
|
||||
item: current.clone(),
|
||||
current,
|
||||
}))
|
||||
}
|
||||
|
||||
fn publish_change(
|
||||
&self,
|
||||
component: &ComponentInstance,
|
||||
@@ -846,8 +771,7 @@ async fn promote_next(
|
||||
|
||||
fn item_select() -> &'static str {
|
||||
"SELECT r.id,r.song_title,r.requester_uid,r.requester_name,r.status,r.queue_position,\
|
||||
r.requested_at,r.started_at,r.finished_at,avg(v.score)::double precision,count(v.id)::BIGINT \
|
||||
FROM song_requests r LEFT JOIN song_ratings v ON v.song_request_id=r.id"
|
||||
r.requested_at,r.started_at,r.finished_at FROM song_requests r"
|
||||
}
|
||||
|
||||
fn item_from_row(row: &Row) -> SongRequestItem {
|
||||
@@ -863,8 +787,6 @@ fn item_from_row(row: &Row) -> SongRequestItem {
|
||||
requested_at: row.get(6),
|
||||
started_at: row.get(7),
|
||||
finished_at: row.get(8),
|
||||
average_score: row.get(9),
|
||||
rating_count: row.get(10),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -873,10 +795,7 @@ async fn request_item(
|
||||
request_id: Uuid,
|
||||
) -> Result<Option<SongRequestItem>, SongRequestError> {
|
||||
Ok(transaction
|
||||
.query_opt(
|
||||
&format!("{} WHERE r.id=$1 GROUP BY r.id", item_select()),
|
||||
&[&request_id],
|
||||
)
|
||||
.query_opt(&format!("{} WHERE r.id=$1", item_select()), &[&request_id])
|
||||
.await?
|
||||
.map(|row| item_from_row(&row)))
|
||||
}
|
||||
@@ -888,7 +807,7 @@ async fn current_item(
|
||||
Ok(transaction
|
||||
.query_opt(
|
||||
&format!(
|
||||
"{} WHERE r.component_instance_id=$1 AND r.status='current' GROUP BY r.id",
|
||||
"{} WHERE r.component_instance_id=$1 AND r.status='current'",
|
||||
item_select()
|
||||
),
|
||||
&[&component_id],
|
||||
@@ -906,8 +825,7 @@ async fn queue_summary(
|
||||
"SELECT count(*) FILTER (WHERE status IN ('current','queued'))::BIGINT,\
|
||||
count(*) FILTER (WHERE status='queued')::BIGINT,\
|
||||
count(*) FILTER (WHERE status='completed')::BIGINT,\
|
||||
count(*) FILTER (WHERE status='cancelled')::BIGINT,\
|
||||
(SELECT count(*) FROM song_ratings WHERE component_instance_id=$1)::BIGINT \
|
||||
count(*) FILTER (WHERE status='cancelled')::BIGINT \
|
||||
FROM song_requests WHERE component_instance_id=$1",
|
||||
&[&component_id],
|
||||
)
|
||||
@@ -917,7 +835,6 @@ async fn queue_summary(
|
||||
queued_count: row.get(1),
|
||||
completed_count: row.get(2),
|
||||
cancelled_count: row.get(3),
|
||||
rating_count: row.get(4),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1079,7 +996,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_requests_and_scores_with_normalized_whitespace() {
|
||||
fn parses_only_requests_with_normalized_whitespace() {
|
||||
assert_eq!(
|
||||
parse_command(" 点歌 My Song "),
|
||||
Some(SongCommand::Request {
|
||||
@@ -1087,11 +1004,18 @@ mod tests {
|
||||
normalized_title: "my song".into(),
|
||||
})
|
||||
);
|
||||
assert_eq!(parse_command("打分 5"), Some(SongCommand::Rate(5)));
|
||||
assert_eq!(
|
||||
parse_command("点歌夜曲"),
|
||||
Some(SongCommand::Request {
|
||||
title: "夜曲".into(),
|
||||
normalized_title: "夜曲".into(),
|
||||
})
|
||||
);
|
||||
assert_eq!(parse_command("打分 5"), None);
|
||||
assert_eq!(parse_command("打分 0"), None);
|
||||
assert_eq!(parse_command("点歌姬"), None);
|
||||
assert_eq!(parse_command("点歌"), None);
|
||||
assert_eq!(parse_command(&format!("点歌 {}", "歌".repeat(81))), None);
|
||||
assert_eq!(parse_command(&format!("点歌{}", "歌".repeat(81))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user