实现游戏买断制泥点付费的 api-server HTTP 层、公开投影与播放鉴权
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- server-rs/crates/api-server/src/runtime_profile.rs:钱包流水来源类型补 GamePurchase 分支并导入 game_purchase 常量,修复 non-exhaustive match 编译错误。
- server-rs/crates/api-server/src/modules/game_distribution.rs:创建版本接受并校验 priceMudPoints(复用 normalize_game_price_mud_points,越界 400),随版本冻结资料一起落库。
- server-rs/crates/api-server/src/modules/game_distribution.rs:新增 POST /api/game-distribution/games/{gameId}/purchase(require_bearer_auth + 必填 Idempotency-Key,请求体 { expectedPriceMudPoints }),经 facade 原子扣费并返回 GameDistributionPurchaseResponse。
- server-rs/crates/api-server/src/modules/game_distribution.rs:购买错误映射——余额不足 400 INSUFFICIENT_MUD_POINTS、价格已变化 409、免费游戏 400、游戏未公开或不存在 404、缺幂等键 400。
- server-rs/crates/api-server/src/modules/game_distribution.rs:新增 POST /api/game-distribution/games/{gameId}/play-session;免费作品直接回既有公开入口,付费作品接受管理员令牌(沿用现有 admin 鉴权,免购买)与用户令牌,校验已购买 / 作者本人,未登录 401、未购买 403,签发绑定 gameId + userId 的 2 小时会话。
- server-rs/crates/api-server/src/modules/game_distribution.rs:新增 GET /api/game-distribution/play-sessions/{token}[/{assetPath}] 网关,无登录中间件、凭令牌读取,能解析平台刷新会话 Cookie 时 403,令牌过期 / 不存在、游戏下架或封禁、缺少有效公开版本一律 404,复用发行包读取与 CSP/MIME/nosniff/CORP 并全程 no-store。
- server-rs/crates/api-server/src/modules/game_distribution.rs:公开发行网关 releases/{gameId} 在当前公开版本 price_mud_points > 0 时返回 404,付费作品只能走播放会话路径。
- server-rs/crates/api-server/src/modules/game_distribution.rs:game_payload 发出 priceMudPoints 与 purchased 默认值;public_game_payload 按查看者解析 purchased,并对未购买且非作者 / 非管理员的付费作品把 currentVersion.entryUrl 置 null;公开详情接口可选鉴权读取当前用户,坏令牌按匿名、购买态读取失败按未购买。
- server-rs/crates/api-server/src/modules/game_distribution.rs:private_version_payload 发出该版本冻结价(历史缺字段为 0);作者 / 管理员版本详情的 game payload 按「冻结价优先、否则游戏行价」。
- server-rs/crates/api-server/src/state.rs:新增付费游戏播放会话的进程内存储与读写方法,读取时清理过期会话。
- server-rs/crates/api-server/src/admin.rs:抽出 resolve_admin_session,新增 try_authenticate_admin_from_headers 供同时接受管理员 / 用户令牌的端点复用,require_admin_auth 的校验与鉴权行为不变。
- server-rs/crates/spacetime-client/src/active.rs:补出 GameDistributionPurchaseRecordInput 的 re-export。
- docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md:登记购买、播放会话、公开详情入口隐藏与公开发行网关的 HTTP 契约。
- 补齐 api-server game_distribution 定向测试:价格校验 400、公开投影入口门禁(未购买 / 已购买 / 作者 / 管理员 / 免费)、冻结价回读、购买错误映射、购买路由鉴权与幂等键、播放会话网关 Cookie / 未知与过期令牌 / 路由挂载。
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -2039,16 +2039,18 @@ fn map_admin_editor_generation_pricing_error(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn require_admin_auth(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
/// 解析管理员 bearer 令牌:校验签名、管理员角色与账号当前状态,返回管理员会话。
|
||||
///
|
||||
/// 后台未启用返回 503;令牌缺失 / 无效 / 角色不符或账号停用返回 401(角色不符为 403)。
|
||||
async fn resolve_admin_session(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<crate::state::AdminSession, AppError> {
|
||||
// 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<AuthenticatedAdmin> {
|
||||
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<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppStateInner>);
|
||||
|
||||
@@ -315,6 +323,7 @@ pub struct AppStateInner {
|
||||
external_api_auth_state: ExternalApiAuthState,
|
||||
game_distribution_preview_sessions:
|
||||
Arc<AsyncMutex<HashMap<String, GameDistributionPreviewSession>>>,
|
||||
game_distribution_play_sessions: Arc<AsyncMutex<HashMap<String, GameDistributionPlaySession>>>,
|
||||
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<GameDistributionPlaySession> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user