diff --git a/server-rs/crates/api-server/src/game_play_counter.rs b/server-rs/crates/api-server/src/game_play_counter.rs index d8fb7c598..56a138607 100644 --- a/server-rs/crates/api-server/src/game_play_counter.rs +++ b/server-rs/crates/api-server/src/game_play_counter.rs @@ -81,10 +81,10 @@ pub struct GamePlayCounter { struct GamePlayCounterState { /// `game_id -> 待落库增量`;只包含通过校验的上报,键空间由公开游戏目录界定。 pending: HashMap, - /// `identity + game_id -> 最近一次计数时间`。 - seen: HashMap, - /// `IP + game_id -> 当前固定窗口`。 - rate: HashMap, + /// `(identity, game_id) -> 最近一次计数时间`。 + seen: HashMap<(String, String), Instant>, + /// `(IP, game_id) -> 当前固定窗口`。 + rate: HashMap<(String, String), RateWindow>, /// 最近一次真正取走增量的时间,用于判断是否到达 flush 间隔。 last_flush_at: Instant, } @@ -115,8 +115,10 @@ impl GamePlayCounter { /// /// 被去重命中的上报不消耗限流额度;限流只挡同一 IP 对同一游戏的超额上报。 pub fn record(&self, report: GamePlayReport<'_>, now: Instant) -> GamePlayOutcome { - let dedup_key = format!("{}\u{1f}{}", report.identity, report.game_id); - let rate_key = format!("{}\u{1f}{}", report.client_ip, report.game_id); + // 键用元组而不是拼接字符串:clientId 来自 JSON,可能包含任意字节(含 U+001F), + // 拼接会产生本不存在的键碰撞。 + let dedup_key = (report.identity.to_string(), report.game_id.to_string()); + let rate_key = (report.client_ip.to_string(), report.game_id.to_string()); let mut state = self.lock(); if let Some(seen_at) = state.seen.get(&dedup_key) @@ -413,4 +415,21 @@ mod tests { GamePlayOutcome::Counted ); } + + #[test] + fn separator_like_bytes_in_key_parts_do_not_collide() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + // 旧实现用 U+001F 拼接 identity/game_id,下面两个不同元组会被拼成同一个键。 + assert_eq!( + counter.record(report("b\u{1f}c", "a", "1.1.1.1"), start), + GamePlayOutcome::Counted + ); + assert_eq!( + counter.record(report("c", "a\u{1f}b", "1.1.1.1"), start), + GamePlayOutcome::Counted + ); + assert_eq!(counter.pending_total(), 2); + } }