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 9605ed7e9..3eecc60c2 100644 --- a/server-rs/crates/api-server/src/game_play_counter.rs +++ b/server-rs/crates/api-server/src/game_play_counter.rs @@ -148,6 +148,19 @@ impl GamePlayCounter { GamePlayOutcome::Counted } + /// 限流预检:只读,不消耗额度、不改任何状态。 + /// + /// 公开上报接口在昂贵的公开可见性查询之前先用它挡掉明显超限的请求;真正计数时 + /// `record` 仍会重新判定一次,所以这里只用于省一次远端查询,不承担正确性。 + pub fn is_rate_limited(&self, game_id: &str, client_ip: &str, now: Instant) -> bool { + let key = (client_ip.to_string(), game_id.to_string()); + let state = self.lock(); + state.rate.get(&key).is_some_and(|window| { + now.saturating_duration_since(window.started_at) < self.settings.rate_window + && window.count >= self.settings.rate_limit + }) + } + /// 到达 flush 间隔或待落库游戏数达到上限时取走全部增量;否则返回 `None`。 pub fn take_pending_if_due(&self, now: Instant) -> Option> { let mut state = self.lock(); @@ -322,6 +335,26 @@ mod tests { ); } + #[test] + fn rate_limit_precheck_is_read_only_and_window_scoped() { + let start = Instant::now(); + let counter = GamePlayCounter::new(settings(), start); + + assert!(!counter.is_rate_limited("g1", "1.1.1.1", start)); + for index in 0..3 { + counter.record(report("g1", &format!("u{index}"), "1.1.1.1"), start); + } + + // 预检只读:连续调用不消耗额度,也不改变判定。 + assert!(counter.is_rate_limited("g1", "1.1.1.1", start)); + assert!(counter.is_rate_limited("g1", "1.1.1.1", start)); + // 另一个 IP、另一个游戏都不受影响。 + assert!(!counter.is_rate_limited("g1", "2.2.2.2", start)); + assert!(!counter.is_rate_limited("g2", "1.1.1.1", start)); + // 固定窗口结束后恢复。 + assert!(!counter.is_rate_limited("g1", "1.1.1.1", start + Duration::from_secs(60))); + } + #[test] fn take_pending_if_due_waits_for_interval_then_drains() { let start = Instant::now(); diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index 008a047e8..184afb343 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -961,6 +961,15 @@ async fn record_game_play( return Err(AppError::from_status(StatusCode::NOT_FOUND)); } + let client_ip = client_ip_from_headers(&headers); + // 先在内存里挡掉明显超限的请求,避免它们也去打一次 SpacetimeDB;真正计数时 record 会再判一次。 + if state + .game_play_counter() + .is_rate_limited(&game_id, &client_ip, Instant::now()) + { + return Err(AppError::from_status(StatusCode::TOO_MANY_REQUESTS)); + } + // 非公开 / 已下架 / 已暂停的游戏不计数,按不存在返回。 let is_public = state .spacetime_client() @@ -972,7 +981,6 @@ async fn record_game_play( return Err(AppError::from_status(StatusCode::NOT_FOUND)); } - let client_ip = client_ip_from_headers(&headers); let user_agent = user_agent_tag(&headers); let authenticated = optional_access_token_from_headers( &state,