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 a892127b7..4035b596e 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -1,7 +1,7 @@ use std::{ collections::{BTreeMap, HashMap, VecDeque}, sync::{Arc, Mutex, OnceLock}, - time::{SystemTime, UNIX_EPOCH}, + time::{Instant, SystemTime, UNIX_EPOCH}, }; use axum::{ @@ -62,10 +62,11 @@ use uuid::Uuid; use crate::{ admin::{AuthenticatedAdmin, require_admin_auth}, api_response::json_success_body, - auth::{AuthenticatedAccessToken, require_bearer_auth}, + auth::{AuthenticatedAccessToken, optional_access_token_from_headers, require_bearer_auth}, + game_play_counter::{GamePlayOutcome, GamePlayReport}, http_error::AppError, platform_errors::{map_llm_error, map_oss_error}, - request_context::RequestContext, + request_context::{RequestContext, client_ip_from_headers}, state::AppState, }; @@ -344,6 +345,10 @@ pub fn router(state: AppState) -> Router { let public_games = Router::new() .route("/api/game-distribution/games", get(list_games)) .route("/api/game-distribution/games/{game_id}", get(get_game)) + .route( + "/api/game-distribution/games/{game_id}/plays", + post(record_game_play), + ) .route_layer(middleware::from_fn(add_no_store_response_headers)); Router::new() @@ -931,6 +936,104 @@ async fn get_game( Ok(json_success_body(Some(&ctx), public_game_payload(game))) } +/// 一次游玩上报的请求体;只有匿名身份需要 `clientId`,登录身份由 bearer 决定。 +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RecordGamePlayRequest { + #[serde(default)] + client_id: Option, +} + +/// 记录一次「开始游戏」。 +/// +/// 公开端点:登录用户按 `userId` 去重,匿名按 `clientId`(缺失时回退 `IP + UA`)去重; +/// 命中 30 分钟去重窗口或超过 `IP + game` 限流时不增加计数。计数只进内存缓冲, +/// 立即返回 `recorded`,任何失败都不影响游玩本身。 +async fn record_game_play( + State(state): State, + Extension(ctx): Extension, + Path(game_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Result, AppError> { + let game_id = game_id.trim().to_string(); + if game_id.is_empty() { + return Err(AppError::from_status(StatusCode::NOT_FOUND)); + } + + // 非公开 / 已下架 / 已暂停的游戏不计数,按不存在返回。 + let is_public = state + .spacetime_client() + .get_public_game_distribution_game(game_id.clone()) + .await + .map_err(map_spacetime_error)? + .is_some(); + if !is_public { + 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, + format!("/api/game-distribution/games/{game_id}/plays"), + headers, + ctx.request_id().to_string(), + ) + .await + .unwrap_or_else(|error| { + // 可选 bearer:无效 token 按匿名处理,绝不能因为它挡掉一次真实游玩。 + debug!(error = %error, "游戏游玩计数忽略无效 bearer,按匿名计数"); + None + }); + let identity = authenticated + .as_ref() + .map(|token| format!("user:{}", token.claims().user_id())) + .or_else(|| request_client_id(&body).map(|client_id| format!("client:{client_id}"))) + .unwrap_or_else(|| format!("ip:{client_ip}|ua:{user_agent}")); + + let outcome = state.game_play_counter().record( + GamePlayReport { + game_id: &game_id, + identity: &identity, + client_ip: &client_ip, + }, + Instant::now(), + ); + if outcome == GamePlayOutcome::RateLimited { + return Err(AppError::from_status(StatusCode::TOO_MANY_REQUESTS)); + } + Ok(json_success_body( + Some(&ctx), + json!({ "recorded": outcome == GamePlayOutcome::Counted }), + )) +} + +fn request_client_id(body: &Bytes) -> Option { + if body.is_empty() { + return None; + } + let request = serde_json::from_slice::(body).ok()?; + request + .client_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.chars().take(128).collect()) +} + +fn user_agent_tag(headers: &HeaderMap) -> String { + headers + .get(header::USER_AGENT) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown") + .chars() + .take(64) + .collect() +} + /// 作者自有游戏列表:只返回当前认证主体名下的游戏与最近版本状态。 async fn list_my_games( State(state): State,