diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 6a5afc4f1..cd634f6cc 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -488,6 +488,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 资料编辑:`update_game_distribution_game_metadata_and_return` 覆盖游戏行上的展示字段(标题/简介/详介/分类/标签/封面/截图/设备/输入模式/方向)并立即生效,要求 `expected_publication_revision` CAS;版本行与冻结资料不变,下一次审核通过仍会用新版本的冻结资料覆盖游戏行。**资料编辑不得直接改公开价格**:调价必须走新版本审核。 - 买断制定价(2026-10-05):游戏行末尾追加 `price_mud_points: u64` 并设置 `#[default(0u64)]`;`0` 表示免费,上限 `1_000_000`(复用 `module-game-distribution::normalize_game_price_mud_points` 校验)。价格是版本冻结资料的一部分:作者在 `GameDistributionCreateVersionRequest.priceMudPoints` 提交,写入版本冻结 `metadata_json.priceMudPoints`,只有 `approve_game_distribution_version_and_return` 通过审核时才随资料整体生效到本行;未通过审核或资料编辑都不会改变当前公开价格。公开投影(`get_public_game_distribution_game_and_return` 等)在游戏快照上带出 `priceMudPoints`。 - 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published`、`deleted_at` 为空且存在有效 `active_version_id` 的投影。 +- 购买与播放鉴权 HTTP(2026-10-05):`POST /api/game-distribution/games/{gameId}/purchase`(`require_bearer_auth` + 必填 `Idempotency-Key`,请求体 `{ expectedPriceMudPoints }`)经 facade 调 `purchase_game_distribution_game_and_return`,返回 `{ purchase, walletBalance, replayed }`;余额不足 400 `INSUFFICIENT_MUD_POINTS`、价格已变化 409、免费游戏 400、游戏不可见 404、缺幂等键 400、未登录 401。`POST /api/game-distribution/games/{gameId}/play-session` 对免费作品直接回既有公开入口 `/games/{gameId}/`;付费作品同时接受管理员令牌(按现有 admin 鉴权)与用户令牌,已购买 / 作者本人 / 管理员才签发绑定 `gameId + userId`、2 小时有效期的进程内会话,令牌为内存态,进程重启即失效。网关 `GET /api/game-distribution/play-sessions/{token}[/{assetPath}]` 不挂登录中间件、凭令牌读取当前公开版本包,能解析出平台刷新会话 Cookie 时 403,令牌过期 / 不存在、游戏下架 / 封禁或没有有效公开版本一律 404,全部 `no-store`。公开详情 `GET /api/game-distribution/games/{gameId}` 可选鉴权读取查看者:`purchased` 只反映真实购买记录,付费作品对未购买且非作者 / 非管理员把 `currentVersion.entryUrl` 置 `null`(资料与价格仍可见);`GET /api/game-distribution/releases/{gameId}[/{assetPath}]` 在当前公开版本 `price_mud_points > 0` 时同样 404,付费作品只能经播放会话路径播放。 - 游玩计数写入:`play_count` 只由批量 procedure `increment_game_distribution_game_play_counts_and_return`(输入 `GameDistributionPlayCountIncrementInput { increments: Vec<{ gameId, delta }> }`)累加。`api-server` 在内存里按 `identity + gameId` 做 30 分钟去重、按 `IP + gameId` 做固定窗口限流后,按 `GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS`(默认 5 秒)批量落库;事务内只对 `published` 且存在有效 `active_version_id` 的游戏 `saturating_add`,非公开静默跳过,且**不更新** `updated_at`。公开 HTTP 入口为 `POST /api/game-distribution/games/{gameId}/plays`,完整行为见玩法链路的「游玩计数(已实现)」。 ### `game_distribution_review` diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 33140ee88..a87108933 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -2039,16 +2039,18 @@ fn map_admin_editor_generation_pricing_error( })) } -pub async fn require_admin_auth( - State(state): State, - mut request: Request, - next: Next, -) -> Result { +/// 解析管理员 bearer 令牌:校验签名、管理员角色与账号当前状态,返回管理员会话。 +/// +/// 后台未启用返回 503;令牌缺失 / 无效 / 角色不符或账号停用返回 401(角色不符为 403)。 +async fn resolve_admin_session( + state: &AppState, + headers: &HeaderMap, +) -> Result { // member 的启停、版本和权限以当前数据库记录为准,JWT 不承载授权真相。 let runtime = state.admin_runtime().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用") })?; - let bearer_token = extract_bearer_token(request.headers())?; + let bearer_token = extract_bearer_token(headers)?; let claims = runtime .verify_token(&bearer_token) .map_err(|error| AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error))?; @@ -2090,6 +2092,29 @@ pub async fn require_admin_auth( ) } .map_err(|error| AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error))?; + Ok(admin_session) +} + +/// 尝试把 bearer 令牌当作管理员令牌解析;同时接受管理员令牌与用户令牌的端点使用它。 +/// +/// 后台未启用、缺少令牌或令牌不是有效管理员令牌时一律返回 `None`,调用方按普通用户身份继续, +/// 不能因为管理身份校验失败而放行,也不能因此误拒普通用户。 +pub(crate) async fn try_authenticate_admin_from_headers( + state: &AppState, + headers: &HeaderMap, +) -> Option { + resolve_admin_session(state, headers) + .await + .ok() + .map(|session| AuthenticatedAdmin::new(build_admin_session_payload(session))) +} + +pub async fn require_admin_auth( + State(state): State, + mut request: Request, + next: Next, +) -> Result { + let admin_session = resolve_admin_session(&state, request.headers()).await?; enforce_admin_request_permission( &admin_session.account_role, &admin_session.tab_permissions, @@ -2097,7 +2122,6 @@ pub async fn require_admin_auth( request.method(), request.uri().path(), )?; - request .extensions_mut() .insert(AuthenticatedAdmin::new(build_admin_session_payload( 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 bc7905ed6..3bd5eb795 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -17,10 +17,10 @@ use axum::{ routing::{get, post, put}, }; use module_game_distribution::{ - MAX_PACKAGE_BYTES, ReleaseAssetError, ReleasePackageError, ReleasePackageManifest, - compute_request_digest, extract_release_asset, normalize_review_comment, - normalize_review_moderation_reason, release_asset_content_type, validate_release_zip, - validate_review_list_status, + MAX_GAME_PRICE_MUD_POINTS, MAX_PACKAGE_BYTES, ReleaseAssetError, ReleasePackageError, + ReleasePackageManifest, compute_request_digest, extract_release_asset, + normalize_game_price_mud_points, normalize_review_comment, normalize_review_moderation_reason, + release_asset_content_type, validate_release_zip, validate_review_list_status, }; use platform_auth::read_refresh_session_token; use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest}; @@ -41,7 +41,9 @@ use shared_contracts::game_distribution::{ GameDistributionAuthor, GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest, GameDistributionInputMode, GameDistributionMyReviewResponse, GameDistributionOrientation, - GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest, + GameDistributionPlaySessionResponse, GameDistributionPublishMetadataSuggestion, + GameDistributionPublishMetadataSuggestionRequest, GameDistributionPurchase, + GameDistributionPurchaseRequest, GameDistributionPurchaseResponse, GameDistributionRatingSummary, GameDistributionReview, GameDistributionReviewsResponse, GameDistributionSaveReviewRequest, GameDistributionSaveReviewResponse, GameDistributionUpdateGameMetadataRequest, @@ -53,19 +55,19 @@ use spacetime_client::{ GameDistributionCancelVersionRecordInput, GameDistributionDeleteGameRecordInput, GameDistributionGameRecord, GameDistributionGetGameRecordInput, GameDistributionOwnerGameRecord, GameDistributionPublicGameListRecordInput, - GameDistributionPublicGameRecord, GameDistributionRatingSummaryRecord, - GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput, - GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationOperationRecord, - GameDistributionReviewModerationRecordInput, GameDistributionSubmitReviewRecordInput, - GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput, - GameDistributionUpdateMetadataRecordInput, GameDistributionUserReviewRecord, - GameDistributionVersionRecord, SpacetimeClientError, + GameDistributionPublicGameRecord, GameDistributionPurchaseRecordInput, + GameDistributionRatingSummaryRecord, GameDistributionRejectRecordInput, + GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, + GameDistributionReviewModerationOperationRecord, GameDistributionReviewModerationRecordInput, + GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, + GameDistributionUnpublishRecordInput, GameDistributionUpdateMetadataRecordInput, + GameDistributionUserReviewRecord, GameDistributionVersionRecord, SpacetimeClientError, }; use tracing::{debug, info, warn}; use uuid::Uuid; use crate::{ - admin::{AuthenticatedAdmin, require_admin_auth}, + admin::{AuthenticatedAdmin, require_admin_auth, try_authenticate_admin_from_headers}, api_response::json_success_body, auth::{AuthenticatedAccessToken, optional_access_token_from_headers, require_bearer_auth}, game_play_counter::{GamePlayOutcome, GamePlayReport}, @@ -347,6 +349,10 @@ pub fn router(state: AppState) -> Router { "/api/game-distribution/games/{game_id}/unpublish", post(unpublish_game), ) + .route( + "/api/game-distribution/games/{game_id}/purchase", + post(purchase_game), + ) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, @@ -392,6 +398,27 @@ pub fn router(state: AppState) -> Router { ) .route_layer(middleware::from_fn(add_no_store_response_headers)); + // 付费作品的播放会话:签发接口按管理员 / 用户令牌分别判定,网关凭令牌读取包内资源。 + // 两者都不挂 `require_bearer_auth`(免费作品要能直接回既有公开入口),统一 `no-store`。 + let play_sessions = Router::new() + .route( + "/api/game-distribution/games/{game_id}/play-session", + post(create_game_play_session), + ) + .route( + "/api/game-distribution/play-sessions/{token}/{*asset_path}", + get(serve_play_session_asset), + ) + .route( + "/api/game-distribution/play-sessions/{token}", + get(serve_play_session_entry), + ) + .route( + "/api/game-distribution/play-sessions/{token}/", + get(serve_play_session_entry), + ) + .route_layer(middleware::from_fn(add_no_store_response_headers)); + Router::new() .route( "/api/game-distribution/releases/{game_id}/{*asset_path}", @@ -421,6 +448,7 @@ pub fn router(state: AppState) -> Router { ) .merge(public_games) .merge(protected) + .merge(play_sessions) .merge(user_reviews) .merge(admin_user_reviews) .merge(admin) @@ -862,6 +890,18 @@ async fn serve_release_asset( ); return Err(AppError::from_status(StatusCode::NOT_FOUND)); } + // 付费作品只经播放会话路径开放;公开同源发行路径对所有人关闭,避免猜测 gameId 绕过购买。 + if public_game.game.price_mud_points > 0 { + debug!( + operation = "release_rejected", + game_id = %game_id, + version_id = %version.version_id, + asset_path = %asset_path, + reason = "paid_game_requires_play_session", + "付费作品的公开发行路径已关闭" + ); + return Err(AppError::from_status(StatusCode::NOT_FOUND)); + } let package = release_package_bytes(&state, &game_id, &version.version_id).await?; release_package_asset_response( &package, @@ -1020,7 +1060,7 @@ async fn list_games( .map_err(map_spacetime_error)?; let games = games .into_iter() - .map(public_game_payload) + .map(|game| public_game_payload(game, &GameViewerContext::default())) .collect::>(); Ok(json_success_body( Some(&ctx), @@ -1028,10 +1068,69 @@ async fn list_games( )) } +/// 解析公开投影的查看者:可选鉴权读取当前用户,决定 `purchased` 与付费入口可见性。 +/// +/// 无令牌、坏令牌、管理令牌与读取购买记录失败都不阻断公开详情:坏令牌按匿名、读失败按未购买 +/// (内容访问失败关闭),只有游戏本身不可见才 404。 +async fn game_viewer_context( + state: &AppState, + ctx: &RequestContext, + headers: &HeaderMap, + game: &GameDistributionGameRecord, +) -> GameViewerContext { + if try_authenticate_admin_from_headers(state, headers) + .await + .is_some() + { + return GameViewerContext { + is_admin: true, + ..GameViewerContext::default() + }; + } + let authenticated = match optional_access_token_from_headers( + state, + format!("/api/game-distribution/games/{}", game.game_id), + headers.clone(), + ctx.request_id().to_string(), + ) + .await + { + Ok(authenticated) => authenticated, + Err(error) => { + debug!(error = %error, "公开游戏详情忽略无效 bearer,按匿名读取"); + return GameViewerContext::default(); + } + }; + let Some(authenticated) = authenticated else { + return GameViewerContext::default(); + }; + let user_id = authenticated.claims().user_id().to_string(); + let is_author = game.owner_user_id == user_id; + let mut purchased = false; + if game.price_mud_points > 0 && !is_author { + match state + .spacetime_client() + .get_game_distribution_purchase(game.game_id.clone(), user_id.clone()) + .await + { + Ok((purchase, _)) => purchased = purchase.is_some(), + Err(error) => { + warn!(error = %error, game_id = %game.game_id, "读取公开详情购买态失败,按未购买读取"); + } + } + } + GameViewerContext { + user_id: Some(user_id), + purchased, + is_admin: false, + } +} + async fn get_game( State(state): State, Extension(ctx): Extension, Path(game_id): Path, + headers: HeaderMap, ) -> Result, AppError> { let game = state .spacetime_client() @@ -1039,7 +1138,11 @@ async fn get_game( .await .map_err(map_spacetime_error)? .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; - Ok(json_success_body(Some(&ctx), public_game_payload(game))) + let viewer = game_viewer_context(&state, &ctx, &headers, &game.game).await; + Ok(json_success_body( + Some(&ctx), + public_game_payload(game, &viewer), + )) } /// 一次游玩上报的请求体;只有匿名身份需要 `clientId`,登录身份由 bearer 决定。 @@ -1488,9 +1591,13 @@ async fn create_version( validate_version_declaration(&payload)?; let now = now_micros(); let version_id = format!("gamever_{}", Uuid::new_v4().simple()); - let metadata_json = - resolve_version_metadata_json(&state, auth.claims().user_id(), &payload.game_metadata) - .await?; + let metadata_json = resolve_version_metadata_json( + &state, + auth.claims().user_id(), + &payload.game_metadata, + payload.price_mud_points, + ) + .await?; let request_digest = compute_request_digest( &serde_json::to_vec(&(game_id.as_str(), &payload, metadata_json.as_str())) .map_err(|error| internal(error.to_string()))?, @@ -1522,6 +1629,65 @@ async fn create_version( )) } +/// 买断制购买:扣泥点与写购买记录在同一事务内完成,`Idempotency-Key` 保证双击与重试只扣一次。 +/// +/// 错误口径:余额不足 400 `INSUFFICIENT_MUD_POINTS`;价格已由新版本改变 409;免费游戏 400; +/// 游戏不可见 / 不存在 404。免费游戏与已购买账号都不产生新扣费。 +async fn purchase_game( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + Path(game_id): Path, + headers: HeaderMap, + Json(payload): Json, +) -> Result, AppError> { + let idempotency_key = idempotency_key(&headers)?; + let game_id = game_id.trim().to_string(); + if game_id.is_empty() { + return Err(AppError::from_status(StatusCode::NOT_FOUND)); + } + let user_id = auth.claims().user_id().to_string(); + let request_digest = compute_request_digest( + &serde_json::to_vec(&(game_id.as_str(), &payload, user_id.as_str())) + .map_err(|error| internal(error.to_string()))?, + ); + let (purchase, wallet_balance, replayed) = state + .spacetime_client() + .purchase_game_distribution_game(GameDistributionPurchaseRecordInput { + game_id, + user_id, + expected_price_mud_points: payload.expected_price_mud_points, + idempotency_key, + request_digest, + now_micros: now_micros(), + }) + .await + .map_err(map_purchase_error)?; + info!( + request_id = ctx.request_id(), + operation = "game_purchased", + game_id = %purchase.game_id, + user_id = %purchase.user_id, + price_mud_points = purchase.price_mud_points, + replayed, + elapsed_ms = ctx.elapsed(), + "买断制作品购买完成" + ); + Ok(json_success_body( + Some(&ctx), + GameDistributionPurchaseResponse { + purchase: GameDistributionPurchase { + purchase_id: purchase.purchase_id, + game_id: purchase.game_id, + price_mud_points: purchase.price_mud_points, + created_at: purchase.created_at, + }, + wallet_balance, + replayed, + }, + )) +} + async fn upload_package( State(state): State, Extension(ctx): Extension, @@ -2590,6 +2756,165 @@ async fn serve_admin_version_preview_asset_inner( release_package_asset_response(&package, &asset_path, content_type, "no-store") } +/// 播放会话短时效:付费作品每次进入游戏都重新签发,过期后必须重新鉴权,不能长期留一个令牌。 +const GAME_PLAY_SESSION_TTL_SECONDS: i64 = 2 * 60 * 60; + +/// 为付费作品签发播放会话;免费作品直接回现有公开入口,不引入新的游玩路径。 +/// +/// 鉴权顺序:管理员令牌(按现有后台鉴权判定,免购买)→ 用户令牌(作者本人或已购买)。 +/// 未登录 401、游戏不可见 404、未购买且非作者 403;拒绝一律不签发令牌。 +async fn create_game_play_session( + State(state): State, + Extension(ctx): Extension, + Path(game_id): Path, + headers: HeaderMap, +) -> Result, AppError> { + let game_id = game_id.trim().to_string(); + if game_id.is_empty() { + return Err(AppError::from_status(StatusCode::NOT_FOUND)); + } + let public_game = state + .spacetime_client() + .get_public_game_distribution_game(game_id.clone()) + .await + .map_err(map_spacetime_error)? + .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; + let game = public_game.game; + let expires_at = + time::OffsetDateTime::now_utc() + time::Duration::seconds(GAME_PLAY_SESSION_TTL_SECONDS); + let expires_at_wire = shared_kernel::format_rfc3339(expires_at) + .map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))?; + + // 免费作品保持既有公开入口:不校验登录,也不签发会话令牌。 + if game.price_mud_points == 0 { + return Ok(json_success_body( + Some(&ctx), + GameDistributionPlaySessionResponse { + play_url: build_release_entry_url(&game_id)?, + expires_at: expires_at_wire, + }, + )); + } + + let admin = try_authenticate_admin_from_headers(&state, &headers).await; + let identity = match admin { + // 管理员免购买试玩:会话仍绑定具体身份,避免把管理身份折叠成匿名令牌。 + Some(admin) => admin.session().subject.clone(), + None => { + let authenticated = optional_access_token_from_headers( + &state, + format!("/api/game-distribution/games/{game_id}/play-session"), + headers.clone(), + ctx.request_id().to_string(), + ) + .await? + .ok_or_else(|| { + AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("播放付费作品需要先登录") + })?; + let user_id = authenticated.claims().user_id().to_string(); + let is_author = game.owner_user_id == user_id; + if !is_author { + let (purchase, _) = state + .spacetime_client() + .get_game_distribution_purchase(game_id.clone(), user_id.clone()) + .await + .map_err(map_spacetime_error)?; + if purchase.is_none() { + return Err(AppError::from_status(StatusCode::FORBIDDEN) + .with_message("这款游戏需要先购买才能游玩")); + } + } + user_id + } + }; + + let token = state + .create_game_distribution_play_session(game_id.clone(), identity, expires_at) + .await; + info!( + request_id = ctx.request_id(), + operation = "game_play_session_created", + game_id = %game_id, + expires_at = %expires_at_wire, + "签发付费作品播放会话" + ); + Ok(json_success_body( + Some(&ctx), + GameDistributionPlaySessionResponse { + play_url: format!("/api/game-distribution/play-sessions/{token}/"), + expires_at: expires_at_wire, + }, + )) +} + +async fn serve_play_session_entry( + State(state): State, + headers: HeaderMap, + Path(token): Path, +) -> Result { + serve_play_session_asset_inner(state, headers, token, "index.html".to_string()).await +} + +async fn serve_play_session_asset( + State(state): State, + headers: HeaderMap, + Path((token, asset_path)): Path<(String, String)>, +) -> Result { + serve_play_session_asset_inner(state, headers, token, asset_path).await +} + +/// 播放会话资源网关:无登录中间件,凭令牌读取当前公开版本;包内相对资源沿同一令牌前缀解析。 +/// +/// 令牌过期 / 不存在、游戏下架或封禁、没有有效公开版本一律按 404 关闭,不区分失效原因。 +async fn serve_play_session_asset_inner( + state: AppState, + headers: HeaderMap, + token: String, + asset_path: String, +) -> Result { + // 会话资源必须在独立来源上读取:能解析出平台刷新会话 Cookie 说明请求落在主站来源上。 + if headers + .get(header::COOKIE) + .and_then(|value| value.to_str().ok()) + .and_then(|cookie_header| { + read_refresh_session_token(cookie_header, state.refresh_cookie_config()) + }) + .is_some() + { + return Err(AppError::from_status(StatusCode::FORBIDDEN) + .with_message("播放会话资源不能携带平台会话 Cookie")); + } + let session = state + .get_game_distribution_play_session(&token) + .await + .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; + let asset_path = asset_path.trim_start_matches('/').to_string(); + let content_type = release_asset_content_type(&asset_path) + .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; + let public_game = state + .spacetime_client() + .get_public_game_distribution_game(session.game_id.clone()) + .await + .map_err(map_spacetime_error)? + .ok_or_else(|| { + debug!( + operation = "play_session_rejected", + game_id = %session.game_id, + user_id = %session.user_id, + reason = "game_not_playable", + "播放会话对应的游戏已下架、封禁或不可见" + ); + AppError::from_status(StatusCode::NOT_FOUND) + })?; + let version = public_game + .current_version + .filter(|version| version.status == GAME_DISTRIBUTION_PUBLISHED_STATUS) + .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; + let package = release_package_bytes(&state, &session.game_id, &version.version_id).await?; + release_package_asset_response(&package, &asset_path, content_type, "no-store") +} + /// 审核通过时派生的发行入口:平台同源路径 `/games/{gameId}/`。 /// /// 存相对路径而不是绝对 URL,部署侧就不需要提供发行域名;dev / release / 预览环境 @@ -2828,6 +3153,9 @@ fn validate_game_metadata(payload: &GameDistributionCreateGameRequest) -> Result fn validate_version_declaration( payload: &GameDistributionCreateVersionRequest, ) -> Result<(), AppError> { + // 价格随版本冻结,非法价格必须在建版本前拒绝,而不是等到审核通过才失败。 + normalize_game_price_mud_points(payload.price_mud_points) + .map_err(|error| bad_request(error.to_string()))?; if payload.package_entry_path != "index.html" { return Err(bad_request("发行包入口必须是 index.html")); } @@ -2886,13 +3214,50 @@ fn package_manifest_json(manifest: &ReleasePackageManifest) -> Result Value { +/// 公开投影的查看者上下文:决定 `purchased`,以及付费作品是否直接下发发行入口。 +/// +/// 匿名 / 未登录恒为「未购买、非作者、非管理员」,也就是付费作品拿不到入口。 +#[derive(Clone, Default)] +struct GameViewerContext { + user_id: Option, + purchased: bool, + is_admin: bool, +} + +impl GameViewerContext { + /// 付费作品:已购买、作者本人或管理员才允许直接拿到入口。 + fn can_play_paid(&self, game: &GameDistributionGameRecord) -> bool { + self.purchased + || self.is_admin + || self.user_id.as_deref() == Some(game.owner_user_id.as_str()) + } +} + +fn public_game_payload( + game: GameDistributionPublicGameRecord, + viewer: &GameViewerContext, +) -> Value { + let price_mud_points = game.game.price_mud_points; + let can_play_paid = viewer.can_play_paid(&game.game); let mut payload = game_payload(&game.game); - if let Value::Object(ref mut object) = payload { + if let Value::Object(object) = &mut payload { + object.insert( + "purchased".to_string(), + Value::Bool(price_mud_points > 0 && viewer.purchased), + ); object.insert( "currentVersion".to_string(), game.current_version - .map(|version| version_summary_payload(&version)) + .map(|version| { + let mut summary = version_summary_payload(&version); + // 付费作品对未购买且非作者 / 非管理员不下发入口;资料与价格仍然可见。 + if price_mud_points > 0 && !can_play_paid { + if let Value::Object(version_fields) = &mut summary { + version_fields.insert("entryUrl".to_string(), Value::Null); + } + } + summary + }) .unwrap_or(Value::Null), ); object.insert( @@ -2950,6 +3315,8 @@ fn admin_game_version_payload( } fn game_payload(game: &GameDistributionGameRecord) -> Value { + // `priceMudPoints` 是游戏行当前生效价;`purchased` 只是基础默认值, + // 公开投影会按查看者覆盖成真实购买态。 let tags = serde_json::from_str::>(&game.tags_json).unwrap_or_default(); let screenshots = game .screenshots_json @@ -2981,6 +3348,8 @@ fn game_payload(game: &GameDistributionGameRecord) -> Value { "publicationRevision": game.publication_revision, "playCount": game.play_count, "createdAt": game.created_at, + "priceMudPoints": game.price_mud_points, + "purchased": false, }) } @@ -3026,6 +3395,16 @@ fn version_summary_payload(version: &GameDistributionVersionRecord) -> Value { }) } +/// 读取版本冻结资料里的买断价;快照缺失、无该字段或取值越界时返回 `None`(历史版本)。 +fn frozen_version_price_mud_points(version: &GameDistributionVersionRecord) -> Option { + let metadata = version.metadata_json.as_deref()?; + let value: Value = serde_json::from_str(metadata).ok()?; + value + .get("priceMudPoints") + .and_then(Value::as_u64) + .filter(|price| *price <= MAX_GAME_PRICE_MUD_POINTS) +} + fn private_version_payload(version: &GameDistributionVersionRecord) -> Value { json!({ "versionId": version.version_id, @@ -3036,6 +3415,8 @@ fn private_version_payload(version: &GameDistributionVersionRecord) -> Value { "packageFileCount": version.package_file_count, "status": version.status, "publicationRevision": version.publication_revision, + // 该版本冻结的买断价;历史无价格版本按免费(0)展示。 + "priceMudPoints": frozen_version_price_mud_points(version).unwrap_or(0), "reviewReason": version.review_reason, "entryUrl": version.entry_url, "createdAt": version.created_at, @@ -3130,6 +3511,7 @@ async fn resolve_version_metadata_json( state: &AppState, owner_user_id: &str, metadata: &GameDistributionCreateGameRequest, + price_mud_points: u64, ) -> Result { let (cover_asset_id, cover_object_key, screenshots) = resolve_owned_game_media(state, owner_user_id, metadata).await?; @@ -3158,6 +3540,8 @@ async fn resolve_version_metadata_json( }, "inputModes": metadata.input_modes, "orientation": metadata.orientation, + // 买断价与展示资料同源冻结;审核通过时一起生效到游戏行。 + "priceMudPoints": price_mud_points, }); serde_json::to_string(&snapshot).map_err(|error| internal(error.to_string())) } @@ -3220,14 +3604,22 @@ fn version_detail_payload( .filter(|value| !value.is_empty()) .and_then(|value| serde_json::from_str::(value).ok()) .unwrap_or(Value::Null); - if let Value::Object(ref mut object) = payload { + if let Value::Object(object) = &mut payload { object.insert( "recoveryAction".to_string(), Value::String(recovery_action_for_status(version.status.as_str()).to_string()), ); object.insert("frozenMetadata".to_string(), frozen_metadata); } - json!({ "game": game_payload(game), "version": payload }) + let mut game_payload = game_payload(game); + // 版本详情里的价格口径:待审 / 该版本冻结价优先,历史无价格版本回退游戏行当前价。 + if let Value::Object(object) = &mut game_payload { + object.insert( + "priceMudPoints".to_string(), + json!(frozen_version_price_mud_points(version).unwrap_or(game.price_mud_points)), + ); + } + json!({ "game": game_payload, "version": payload }) } /// 客户端可执行的下一步;状态是唯一事实源,前端不自行推断。 @@ -3283,6 +3675,30 @@ fn map_package_error(error: ReleasePackageError) -> AppError { .with_details(json!({ "provider": "game-distribution", "reason": format!("{error:?}") })) } +/// 购买失败的错误映射。 +/// +/// 余额不足 400 `INSUFFICIENT_MUD_POINTS`(前端据此引导充值);价格已被新版本改变 409; +/// 游戏未公开 / 不存在 404;免费游戏与其它业务拒绝走通用 400。 +fn map_purchase_error(error: SpacetimeClientError) -> AppError { + if let SpacetimeClientError::Procedure(message) = &error { + if message.contains("泥点余额不足") || message.contains("可消费泥点不足") { + return AppError::from_status(StatusCode::BAD_REQUEST) + .with_code("INSUFFICIENT_MUD_POINTS") + .with_message("泥点余额不足") + .with_details(json!({ "provider": "game-distribution", "message": message })); + } + if message.contains("价格已变化") { + return AppError::from_status(StatusCode::CONFLICT) + .with_details(json!({ "provider": "game-distribution", "message": message })); + } + if message.contains("未公开") { + return AppError::from_status(StatusCode::NOT_FOUND) + .with_details(json!({ "provider": "game-distribution", "message": message })); + } + } + map_spacetime_error(error) +} + fn map_spacetime_error(error: SpacetimeClientError) -> AppError { match error { SpacetimeClientError::Procedure(message) @@ -4149,6 +4565,7 @@ mod tests { screenshots_json: Some( r#"[{"assetId":"asset_1","objectKey":"generated/shot-1.png"}]"#.to_string(), ), + price_mud_points: 0, }; let payload = game_payload(&game); assert_eq!( @@ -4163,14 +4580,17 @@ mod tests { serde_json::from_value(payload).expect("旧游戏响应应可解析"); assert!(legacy.rating_summary.is_none()); for (average_score, rating_count) in [(None, 0), (Some(8.2), 26)] { - let payload = public_game_payload(GameDistributionPublicGameRecord { - game: game.clone(), - current_version: None, - rating_summary: GameDistributionRatingSummaryRecord { - average_score, - rating_count, + let payload = public_game_payload( + GameDistributionPublicGameRecord { + game: game.clone(), + current_version: None, + rating_summary: GameDistributionRatingSummaryRecord { + average_score, + rating_count, + }, }, - }); + &GameViewerContext::default(), + ); let summary: shared_contracts::game_distribution::GameDistributionGameSummary = serde_json::from_value(payload).expect("公开游戏响应应可解析"); assert_eq!( @@ -4210,6 +4630,7 @@ mod tests { local_project_id: None, cover_object_key: Some("generated/game-cover.png".to_string()), screenshots_json: None, + price_mud_points: 0, }; let mut version = GameDistributionVersionRecord { version_id: "version_2".to_string(), @@ -4276,6 +4697,7 @@ mod tests { local_project_id: None, cover_object_key: None, screenshots_json: None, + price_mud_points: 0, }; let version = GameDistributionVersionRecord { version_id: "version_1".to_string(), @@ -5036,4 +5458,465 @@ mod tests { headers.insert(header::USER_AGENT, " test-agent ".parse().unwrap()); assert_eq!(user_agent_tag(&headers), "test-agent"); } + + fn paid_game_record(owner_user_id: &str, price_mud_points: u64) -> GameDistributionGameRecord { + GameDistributionGameRecord { + game_id: "game_1".to_string(), + owner_user_id: owner_user_id.to_string(), + title: "付费游戏".to_string(), + summary: "摘要".to_string(), + description: "描述".to_string(), + category: "益智".to_string(), + tags_json: "[]".to_string(), + cover_asset_id: Some("asset_cover".to_string()), + author_name: None, + author_avatar_url: None, + device_support_desktop: true, + device_support_mobile: true, + device_support_touch: false, + input_modes_json: "[]".to_string(), + orientation: "responsive".to_string(), + publication_revision: 1, + active_version_id: Some("version_1".to_string()), + visibility: "published".to_string(), + play_count: 0, + created_at: "2026-09-20T00:00:00Z".to_string(), + updated_at: "2026-09-20T00:00:00Z".to_string(), + local_project_id: None, + cover_object_key: None, + screenshots_json: None, + price_mud_points, + } + } + + fn public_version_record( + entry_url: Option<&str>, + metadata_json: Option<&str>, + ) -> GameDistributionVersionRecord { + GameDistributionVersionRecord { + version_id: "version_1".to_string(), + game_id: "game_1".to_string(), + owner_user_id: "user_author".to_string(), + version_number: 1, + package_sha256: "c".repeat(64), + package_bytes: 2048, + package_file_count: 3, + package_entry_path: "index.html".to_string(), + status: "published".to_string(), + review_reason: None, + entry_url: entry_url.map(str::to_string), + publication_revision: 1, + created_at: "2026-09-20T00:00:00Z".to_string(), + updated_at: "2026-09-20T00:00:00Z".to_string(), + metadata_json: metadata_json.map(str::to_string), + } + } + + fn public_game_record( + game: GameDistributionGameRecord, + version: GameDistributionVersionRecord, + ) -> GameDistributionPublicGameRecord { + GameDistributionPublicGameRecord { + game, + current_version: Some(version), + rating_summary: GameDistributionRatingSummaryRecord { + average_score: None, + rating_count: 0, + }, + } + } + + fn priced_version_request(price_mud_points: u64) -> GameDistributionCreateVersionRequest { + GameDistributionCreateVersionRequest { + local_project_id: None, + version_number: None, + price_mud_points, + package_sha256: "d".repeat(64), + package_bytes: 1024, + package_file_count: 1, + package_entry_path: "index.html".to_string(), + game_metadata: GameDistributionCreateGameRequest { + local_project_id: None, + title: "付费游戏".to_string(), + summary: "摘要".to_string(), + description: None, + category: "益智".to_string(), + tags: vec![], + cover_asset_id: Some("asset_cover".to_string()), + screenshots: vec![], + device_support: GameDistributionDeviceSupport { + desktop: true, + mobile: true, + touch: true, + }, + input_modes: vec![], + orientation: GameDistributionOrientation::Responsive, + }, + } + } + + /// 价格是版本冻结资料的一部分:越界价格必须在建版本前 400,而不是等审核通过才失败。 + #[test] + fn version_declaration_validates_game_price() { + for price in [0, 1, MAX_GAME_PRICE_MUD_POINTS] { + validate_version_declaration(&priced_version_request(price)) + .unwrap_or_else(|error| panic!("价格 {price} 应通过校验:{}", error.message())); + } + let error = + validate_version_declaration(&priced_version_request(MAX_GAME_PRICE_MUD_POINTS + 1)) + .expect_err("超过上限的价格必须被拒"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!( + error.message().contains("priceMudPoints"), + "错误信息应指向 priceMudPoints,实际:{}", + error.message() + ); + } + + /// 付费作品的入口只在已购买 / 作者本人 / 管理员三种身份下下发;免费作品行为不变。 + #[test] + fn public_payload_gates_paid_entry_by_purchase_author_and_admin() { + let locked = public_game_payload( + public_game_record( + paid_game_record("user_author", 200), + public_version_record(Some("/games/game_1/"), None), + ), + &GameViewerContext::default(), + ); + assert_eq!(locked["priceMudPoints"], json!(200)); + assert_eq!(locked["purchased"], json!(false)); + // 资料与版本信息仍然可见,只有入口被隐藏。 + assert_eq!(locked["currentVersion"]["id"], json!("version_1")); + assert!(locked["currentVersion"]["entryUrl"].is_null()); + + let purchased = GameViewerContext { + user_id: Some("user_player".to_string()), + purchased: true, + is_admin: false, + }; + let unlocked = public_game_payload( + public_game_record( + paid_game_record("user_author", 200), + public_version_record(Some("/games/game_1/"), None), + ), + &purchased, + ); + assert_eq!(unlocked["purchased"], json!(true)); + assert_eq!( + unlocked["currentVersion"]["entryUrl"], + json!("/games/game_1/") + ); + + for viewer in [ + GameViewerContext { + user_id: Some("user_author".to_string()), + purchased: false, + is_admin: false, + }, + GameViewerContext { + user_id: None, + purchased: false, + is_admin: true, + }, + ] { + let payload = public_game_payload( + public_game_record( + paid_game_record("user_author", 200), + public_version_record(Some("/games/game_1/"), None), + ), + &viewer, + ); + assert_eq!( + payload["currentVersion"]["entryUrl"], + json!("/games/game_1/"), + "作者与管理员不应被购买限制挡住" + ); + // 免购买游玩不等于已拥有,购买态只反映真实购买记录。 + assert_eq!(payload["purchased"], json!(false)); + } + + // 免费作品对匿名查看者保持既有公开入口,且不出现购买态。 + let free = public_game_payload( + public_game_record( + paid_game_record("user_author", 0), + public_version_record(Some("/games/game_1/"), None), + ), + &GameViewerContext::default(), + ); + assert_eq!(free["priceMudPoints"], json!(0)); + assert_eq!(free["purchased"], json!(false)); + assert_eq!(free["currentVersion"]["entryUrl"], json!("/games/game_1/")); + } + + /// 审核列表与版本详情都读版本冻结价;历史版本缺字段按免费 / 回退游戏行价。 + #[test] + fn private_version_payload_reports_frozen_price_and_legacy_defaults() { + let game = paid_game_record("user_author", 999); + let mut version = public_version_record(None, Some(r#"{"priceMudPoints":240}"#)); + version.status = "pending_review".to_string(); + + let payload = private_version_payload(&version); + assert_eq!(payload["priceMudPoints"], json!(240)); + let dto: shared_contracts::game_distribution::GameDistributionPrivateVersion = + serde_json::from_value(payload.clone()).expect("审核列表项应可解析为契约类型"); + assert_eq!(dto.price_mud_points, 240); + + // 待审版本冻结价优先于游戏行现价。 + let detail = version_detail_payload(&version, &game); + assert_eq!(detail["game"]["priceMudPoints"], json!(240)); + assert_eq!(detail["version"]["priceMudPoints"], json!(240)); + + // 历史版本缺 priceMudPoints:列表按免费,详情回退游戏行当前价。 + version.metadata_json = Some(r#"{"coverAssetId":"asset_cover"}"#.to_string()); + assert_eq!( + private_version_payload(&version)["priceMudPoints"], + json!(0) + ); + assert_eq!( + version_detail_payload(&version, &game)["game"]["priceMudPoints"], + json!(999) + ); + + // 完全没有冻结资料的旧版本同样按免费展示,不 panic。 + version.metadata_json = None; + assert_eq!( + private_version_payload(&version)["priceMudPoints"], + json!(0) + ); + assert_eq!( + version_detail_payload(&version, &game)["game"]["priceMudPoints"], + json!(999) + ); + } + + #[test] + fn purchase_errors_map_to_explicit_status_and_code() { + let insufficient = map_purchase_error(SpacetimeClientError::Procedure( + "可消费泥点不足:需要 200,扣除退款占用后可用 10".to_string(), + )); + assert_eq!(insufficient.status_code(), StatusCode::BAD_REQUEST); + assert_eq!(insufficient.code(), "INSUFFICIENT_MUD_POINTS"); + assert_eq!(insufficient.message(), "泥点余额不足"); + + let price_changed = map_purchase_error(SpacetimeClientError::Procedure( + "价格已变化,请刷新后重试".to_string(), + )); + assert_eq!(price_changed.status_code(), StatusCode::CONFLICT); + + let free = map_purchase_error(SpacetimeClientError::Procedure( + "免费游戏不需要购买".to_string(), + )); + assert_eq!(free.status_code(), StatusCode::BAD_REQUEST); + + for message in [ + "游戏不存在", + "游戏未公开或没有可玩版本,不能购买", + "游戏已被删除", + ] { + let error = map_purchase_error(SpacetimeClientError::Procedure(message.to_string())); + assert_eq!(error.status_code(), StatusCode::NOT_FOUND, "{message}"); + } + + let offline = map_purchase_error(SpacetimeClientError::ConnectDropped); + assert_eq!(offline.status_code(), StatusCode::BAD_GATEWAY); + } + + async fn signed_test_user_token(state: &AppState, phone_number: &str) -> String { + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, + sign_access_token, + }; + let user = state + .seed_test_phone_user_with_password(phone_number, "secret123") + .await; + let session_id = state.seed_test_refresh_session_for_user(&user, "sess_game_purchase"); + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: user.id.clone(), + session_id, + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: user.token_version, + phone_verified: false, + binding_status: BindingStatus::Active, + display_name: Some(user.display_name.clone()), + }, + state.auth_jwt_config(), + time::OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } + + async fn read_response_json(response: Response) -> Value { + use http_body_util::BodyExt; + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + serde_json::from_slice(&body).expect("response body should be json") + } + + /// 购买接口:未登录 401、缺幂等键 400、请求体缺期望价格 400;合法请求才会触达 SpacetimeDB。 + #[tokio::test] + async fn purchase_route_requires_bearer_idempotency_key_and_valid_body() { + use axum::http::Request; + use tower::ServiceExt; + + let state = AppState::new(crate::config::AppConfig::default()).unwrap(); + let token = signed_test_user_token(&state, "13800138201").await; + let app = crate::app::build_router(state); + let request = |authorization: Option<&str>, idempotency_key: Option<&str>, body: &str| { + let mut builder = Request::builder() + .method("POST") + .uri("/api/game-distribution/games/game_1/purchase") + .header(header::CONTENT_TYPE, "application/json"); + if let Some(authorization) = authorization { + builder = builder.header(header::AUTHORIZATION, authorization); + } + if let Some(idempotency_key) = idempotency_key { + builder = builder.header("idempotency-key", idempotency_key); + } + builder.body(Body::from(body.to_string())).unwrap() + }; + + let unauthorized = app + .clone() + .oneshot(request( + None, + Some("purchase-key"), + r#"{"expectedPriceMudPoints":200}"#, + )) + .await + .unwrap(); + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + + let missing_key = app + .clone() + .oneshot(request( + Some(&format!("Bearer {token}")), + None, + r#"{"expectedPriceMudPoints":200}"#, + )) + .await + .unwrap(); + assert_eq!(missing_key.status(), StatusCode::BAD_REQUEST); + assert_eq!( + read_response_json(missing_key).await["error"]["message"], + json!("缺少 Idempotency-Key") + ); + + let missing_price = app + .clone() + .oneshot(request( + Some(&format!("Bearer {token}")), + Some("purchase-key"), + r#"{}"#, + )) + .await + .unwrap(); + // 请求体缺 `expectedPriceMudPoints` 属于语义校验失败,框架按兄弟接口口径返回 422。 + assert_eq!(missing_price.status(), StatusCode::UNPROCESSABLE_ENTITY); + + // 校验通过后进入 SpacetimeDB;测试进程未连接数据库,按上游不可用失败关闭。 + let offline = app + .oneshot(request( + Some(&format!("Bearer {token}")), + Some("purchase-key"), + r#"{"expectedPriceMudPoints":200}"#, + )) + .await + .unwrap(); + assert_eq!(offline.status(), StatusCode::BAD_GATEWAY); + } + + /// 播放会话网关:拒绝平台 Cookie、未知 / 过期令牌与非法资源路径一律 404,且全部 no-store。 + #[tokio::test] + async fn play_session_gateway_rejects_platform_cookie_and_invalid_tokens() { + use axum::http::Request; + use tower::ServiceExt; + + let state = AppState::new(crate::config::AppConfig::default()).unwrap(); + let cookie_name = state.refresh_cookie_config().cookie_name().to_string(); + let app = crate::app::build_router(state.clone()); + + let with_cookie = app + .clone() + .oneshot( + Request::builder() + .uri("/api/game-distribution/play-sessions/token_1/") + .header(header::COOKIE, format!("{cookie_name}=refresh-token-value")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(with_cookie.status(), StatusCode::FORBIDDEN); + assert_eq!(with_cookie.headers()[header::CACHE_CONTROL], "no-store"); + + for uri in [ + "/api/game-distribution/play-sessions/unknown_token/", + "/api/game-distribution/play-sessions/unknown_token", + "/api/game-distribution/play-sessions/unknown_token/assets/main.js", + ] { + let response = app + .clone() + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + } + + // 过期会话在读取时被清理,与不存在的令牌同样按 404 关闭。 + let expired = state + .create_game_distribution_play_session( + "game_1".to_string(), + "user_1".to_string(), + time::OffsetDateTime::now_utc() - time::Duration::seconds(1), + ) + .await; + let expired_response = app + .oneshot( + Request::builder() + .uri(format!("/api/game-distribution/play-sessions/{expired}/")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(expired_response.status(), StatusCode::NOT_FOUND); + assert!( + state + .get_game_distribution_play_session(&expired) + .await + .is_none(), + "过期会话必须被清理" + ); + } + + /// 播放会话签发路由可达且不挂 bearer 中间件:无令牌也要走到 handler 的游戏可见性读取 + /// (未连接 SpacetimeDB 时 502),而不是 401 / 404 / 405。 + #[tokio::test] + async fn play_session_route_is_mounted_without_bearer_middleware() { + use axum::http::Request; + use tower::ServiceExt; + + let app = + crate::app::build_router(AppState::new(crate::config::AppConfig::default()).unwrap()); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/game-distribution/games/game_1/play-session") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + } } diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 5f8f96d78..fcd397610 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -51,6 +51,7 @@ use shared_contracts::runtime::{ PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_GRANT, PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_RESET, PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_GAME_PURCHASE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_PERIOD_GRANT, @@ -214,6 +215,10 @@ fn format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_RECHARGE_REFUND_RECOVERY } + // 游戏买断制购买:每账号每游戏一次扣费流水。 + RuntimeProfileWalletLedgerSourceType::GamePurchase => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_GAME_PURCHASE + } } } diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 5de09dde0..b1cc13d14 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -143,6 +143,14 @@ pub(crate) struct GameDistributionPreviewSession { pub(crate) expires_at: OffsetDateTime, } +/// 付费游戏播放会话:绑定 gameId 与请求者身份,短时效;过期后必须重新鉴权签发。 +#[derive(Clone, Debug)] +pub(crate) struct GameDistributionPlaySession { + pub(crate) game_id: String, + pub(crate) user_id: String, + pub(crate) expires_at: OffsetDateTime, +} + #[derive(Clone)] pub struct AppState(Arc); @@ -315,6 +323,7 @@ pub struct AppStateInner { external_api_auth_state: ExternalApiAuthState, game_distribution_preview_sessions: Arc>>, + game_distribution_play_sessions: Arc>>, editor_project_state: EditorProjectState, #[cfg(any())] puzzle_gallery_cache: PuzzleGalleryCache, @@ -707,6 +716,7 @@ impl AppState { #[cfg(test)] test_external_background_removal_enqueue: Arc::new(Mutex::new(None)), game_distribution_preview_sessions: Arc::new(AsyncMutex::new(HashMap::new())), + game_distribution_play_sessions: Arc::new(AsyncMutex::new(HashMap::new())), oss_client, project_snapshot_oss_client, template_library_store, @@ -797,6 +807,39 @@ impl AppState { sessions.get(token).cloned() } + /// 签发一个付费游戏播放会话:`gameId` 与请求者身份一起绑定,令牌本身不携带业务数据。 + pub(crate) async fn create_game_distribution_play_session( + &self, + game_id: String, + user_id: String, + expires_at: OffsetDateTime, + ) -> String { + let token = uuid::Uuid::new_v4().to_string(); + let now = OffsetDateTime::now_utc(); + let mut sessions = self.game_distribution_play_sessions.lock().await; + sessions.retain(|_, session| session.expires_at > now); + sessions.insert( + token.clone(), + GameDistributionPlaySession { + game_id, + user_id, + expires_at, + }, + ); + token + } + + /// 读取播放会话;过期会话在读取时被清理,调用方一律按 404 处理。 + pub(crate) async fn get_game_distribution_play_session( + &self, + token: &str, + ) -> Option { + let now = OffsetDateTime::now_utc(); + let mut sessions = self.game_distribution_play_sessions.lock().await; + sessions.retain(|_, session| session.expires_at > now); + sessions.get(token).cloned() + } + pub fn http_request_permit_pools(&self) -> HttpRequestPermitPools { self.http_request_permit_pools.clone() } diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 4f387f1c6..6ce029f33 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -28,7 +28,8 @@ pub use game_distribution::{ GameDistributionCreateVersionRecordInput, GameDistributionDeleteGameRecordInput, GameDistributionFailUploadRecordInput, GameDistributionGetGameRecordInput, GameDistributionOwnerGameListRecordInput, GameDistributionPlayCountIncrementRecordInput, - GameDistributionPublicGameListRecordInput, GameDistributionRejectRecordInput, + GameDistributionPublicGameListRecordInput, GameDistributionPurchaseRecordInput, + GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationRecordInput, GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput,