From d12ff72425149edc7430fd70b0d1a281d51d2a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 18:14:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E7=AB=AF=EF=BC=9A=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=85=AC=E5=BC=80=E6=B8=B8=E7=8E=A9=E4=B8=8A=E6=8A=A5=E7=AB=AF?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - modules/game_distribution.rs:新增 POST /api/game-distribution/games/{game_id}/plays,公开可带 bearer,非公开 404、限流 429、成功 200 {recorded} - 身份组成:登录用 userId、匿名用 clientId、都缺失回退 IP+UA;无效 bearer 按匿名处理不影响计数 - 请求体读取原始 Bytes,空体或畸形体不阻断计数 --- .../src/modules/game_distribution.rs | 109 +++++++++++++++++- 1 file changed, 106 insertions(+), 3 deletions(-) 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,