后端:游玩计数去重与限流改用元组键

- game_play_counter.rs:seen/rate 的键由 U+001F 拼接字符串改为 (identity, game_id)/(ip, game_id) 元组,clientId 携带分隔符时不再发生键碰撞
- 补分隔符碰撞回归测试
This commit is contained in:
2026-10-03 19:23:37 +08:00
parent 03f1828561
commit 7c3398029e
@@ -81,10 +81,10 @@ pub struct GamePlayCounter {
struct GamePlayCounterState {
/// `game_id -> 待落库增量`;只包含通过校验的上报,键空间由公开游戏目录界定。
pending: HashMap<String, u64>,
/// `identity + game_id -> 最近一次计数时间`。
seen: HashMap<String, Instant>,
/// `IP + game_id -> 当前固定窗口`。
rate: HashMap<String, RateWindow>,
/// `(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);
}
}