后端:新增公开游玩上报端点

- modules/game_distribution.rs:新增 POST /api/game-distribution/games/{game_id}/plays,公开可带 bearer,非公开 404、限流 429、成功 200 {recorded}
- 身份组成:登录用 userId、匿名用 clientId、都缺失回退 IP+UA;无效 bearer 按匿名处理不影响计数
- 请求体读取原始 Bytes,空体或畸形体不阻断计数
This commit is contained in:
2026-10-03 18:14:50 +08:00
parent dbf3adeb13
commit d12ff72425
@@ -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<AppState> {
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<String>,
}
/// 记录一次「开始游戏」。
///
/// 公开端点:登录用户按 `userId` 去重,匿名按 `clientId`(缺失时回退 `IP + UA`)去重;
/// 命中 30 分钟去重窗口或超过 `IP + game` 限流时不增加计数。计数只进内存缓冲,
/// 立即返回 `recorded`,任何失败都不影响游玩本身。
async fn record_game_play(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Path(game_id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<Value>, 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<String> {
if body.is_empty() {
return None;
}
let request = serde_json::from_slice::<RecordGamePlayRequest>(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<AppState>,