后端:游玩上报先做内存限流预检再查库

- game_play_counter.rs:新增只读 is_rate_limited,不消耗额度也不改状态
- modules/game_distribution.rs:record_game_play 先做内存限流预检,超限直接 429,不再让这些请求也打一次 SpacetimeDB;真正计数时 record 仍会重新判定
- 补预检只读与固定窗口作用域测试
This commit is contained in:
2026-10-04 11:04:52 +08:00
parent 2122dc9469
commit 3f0a77e65e
2 changed files with 42 additions and 1 deletions
@@ -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<Vec<GamePlayCountDelta>> {
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();
@@ -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,