后端:新增公开游玩上报端点
- 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:
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user