Merge remote-tracking branch 'origin/master' into feat/game-fork
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m27s
Project CI / Native shell tests (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Successful in 2m24s
Project CI / Repository checks (pull_request) Failing after 1m1s

# Conflicts:
#	apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs
#	apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx
#	apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx
#	docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
#	package.json
#	packages/shared/src/contracts/gameDistribution.ts
#	scripts/check-game-distribution-dto-parity.mjs
#	server-rs/crates/api-server/src/modules/game_distribution.rs
#	server-rs/crates/module-game-distribution/src/errors.rs
#	server-rs/crates/module-game-distribution/src/lib.rs
#	server-rs/crates/shared-contracts/src/game_distribution.rs
#	server-rs/crates/spacetime-client/src/active.rs
#	server-rs/crates/spacetime-client/src/active/mapper.rs
#	server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs
#	server-rs/crates/spacetime-client/src/game_distribution.rs
#	server-rs/crates/spacetime-client/src/module_bindings.rs
#	server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs
#	server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs
#	server-rs/crates/spacetime-module/src/game_distribution.rs
#	server-rs/crates/spacetime-module/src/migration.rs
#	src/components/game-distribution/GameDetailPage.tsx
#	src/components/game-distribution/GameDistributionPages.test.tsx
#	src/components/game-distribution/GamePlayPage.tsx
#	src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
#	src/services/gameDistributionClient.test.ts
#	src/services/gameDistributionClient.ts
This commit is contained in:
2026-10-06 16:10:52 +08:00
314 changed files with 13094 additions and 2427 deletions
+188 -37
View File
@@ -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(
@@ -2758,27 +2782,72 @@ async fn fetch_admin_dashboard_wallet_stats(
if !range.contains_day_key(day_key) {
continue;
}
match source_type.as_str() {
"asset_operation_consume" | "llm_router_consume" if amount_delta < 0 => {
stats
.consumed_mud_points
.add(day_key, amount_delta.unsigned_abs());
}
// 生成失败 / 精选审核返还,以及 LLM Router 的正向冲正流水,都按退还对冲消耗。
"asset_operation_refund" | "llm_router_consume" if amount_delta > 0 => {
stats.refunded_mud_points.add(day_key, amount_delta as u64);
}
"points_recharge" if amount_delta > 0 => {
stats.recharged_mud_points = stats
.recharged_mud_points
.saturating_add(amount_delta as u64);
}
_ => {}
}
accumulate_admin_dashboard_wallet_ledger_row(
&mut stats,
&source_type,
amount_delta,
day_key,
);
}
stats
}
/// 单条钱包流水对后台面板的贡献。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AdminDashboardWalletLedgerEffect {
/// 真实扣费:资产操作、LLM Router 与买断制购买。
Consume,
/// 退还 / 冲正:生成失败与精选审核返还,以及 LLM Router 的正向冲正流水。
Refund,
/// 充值入账。
Recharge,
/// 不计入消耗、退还或充值。
Ignore,
}
/// 后台消耗口径的来源白名单:与运行时消费投影(`GamePurchase` 也计入消耗)保持一致,
/// 新增流水来源时必须同步这里,否则后台统计会静默丢失这一类消耗。
fn resolve_admin_dashboard_wallet_ledger_effect(
source_type: &str,
amount_delta: i64,
) -> AdminDashboardWalletLedgerEffect {
match source_type {
"asset_operation_consume" | "llm_router_consume" | "game_purchase" if amount_delta < 0 => {
AdminDashboardWalletLedgerEffect::Consume
}
"asset_operation_refund" | "llm_router_consume" if amount_delta > 0 => {
AdminDashboardWalletLedgerEffect::Refund
}
"points_recharge" if amount_delta > 0 => AdminDashboardWalletLedgerEffect::Recharge,
_ => AdminDashboardWalletLedgerEffect::Ignore,
}
}
/// 把一条已通过日期筛选的流水累加进后台统计。
fn accumulate_admin_dashboard_wallet_ledger_row(
stats: &mut AdminDashboardWalletStats,
source_type: &str,
amount_delta: i64,
day_key: i64,
) {
match resolve_admin_dashboard_wallet_ledger_effect(source_type, amount_delta) {
AdminDashboardWalletLedgerEffect::Consume => {
stats
.consumed_mud_points
.add(day_key, amount_delta.unsigned_abs());
}
AdminDashboardWalletLedgerEffect::Refund => {
stats.refunded_mud_points.add(day_key, amount_delta as u64);
}
AdminDashboardWalletLedgerEffect::Recharge => {
stats.recharged_mud_points = stats
.recharged_mud_points
.saturating_add(amount_delta as u64);
}
AdminDashboardWalletLedgerEffect::Ignore => {}
}
}
async fn fetch_admin_dashboard_user_stats(
state: &AppState,
range: &AdminDashboardRangeResolved,
@@ -4778,6 +4847,7 @@ fn wallet_ledger_source_type_to_string(value: &Value) -> Option<String> {
14 => "recharge_refund_recovery",
15 => "llm_router_consume",
16 => "membership_upgrade_grant",
17 => "game_purchase",
_ => return Some(Value::Array(items.to_vec()).to_string()),
}
.to_string(),
@@ -5068,14 +5138,15 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
#[cfg(test)]
mod tests {
use super::{
AdminDashboardGranularity, AdminDashboardSeries, AdminDisplayNameDirectory,
EditorShowcaseAssetRecord, SpacetimeSchemaResponse, admin_dashboard_user_stats_from_record,
admin_dashboard_user_stats_from_result, admin_editor_asset_group_payload,
admin_editor_asset_payload_from_record, admin_editor_showcase_asset_payload_from_record,
append_spacetime_sql_response_chunk, apply_admin_database_table_filters,
build_admin_asset_read_url_audit, build_admin_dashboard_chart,
build_admin_database_enum_labels, build_admin_database_table_row,
build_admin_editor_showcase_campaign_image_confirm_request,
AdminDashboardGranularity, AdminDashboardSeries, AdminDashboardWalletLedgerEffect,
AdminDashboardWalletStats, AdminDisplayNameDirectory, EditorShowcaseAssetRecord,
SpacetimeSchemaResponse, accumulate_admin_dashboard_wallet_ledger_row,
admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result,
admin_editor_asset_group_payload, admin_editor_asset_payload_from_record,
admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk,
apply_admin_database_table_filters, build_admin_asset_read_url_audit,
build_admin_dashboard_chart, build_admin_database_enum_labels,
build_admin_database_table_row, build_admin_editor_showcase_campaign_image_confirm_request,
build_admin_external_api_key_sql, build_admin_tracking_event_keys_sql,
build_admin_tracking_events_sql, build_body_preview, build_debug_base_url,
build_spacetime_schema_url, clamp_admin_database_table_limit,
@@ -5088,10 +5159,11 @@ mod tests {
parse_admin_tracking_event_keys_sql_response, parse_admin_tracking_events_sql_response,
parse_spacetime_sql_count_response, parse_timestamp_text_to_micros,
resolve_admin_dashboard_range, resolve_admin_dashboard_range_at,
resolve_admin_database_table_sql_limit, resolve_admin_editor_asset_filters,
sum_admin_user_recharged_cents, timestamp_value_to_micros, trim_preview,
validate_admin_editor_asset_cursor, validate_admin_external_api_key_query,
verify_admin_password, wallet_ledger_source_type_to_string,
resolve_admin_dashboard_wallet_ledger_effect, resolve_admin_database_table_sql_limit,
resolve_admin_editor_asset_filters, sum_admin_user_recharged_cents,
timestamp_value_to_micros, trim_preview, validate_admin_editor_asset_cursor,
validate_admin_external_api_key_query, verify_admin_password,
wallet_ledger_source_type_to_string,
};
use std::collections::BTreeMap;
@@ -6290,14 +6362,93 @@ mod tests {
wallet_ledger_source_type_to_string(&json!([15, []])).as_deref(),
Some("llm_router_consume")
);
// 买断制购买是第 18 个变体(索引 17);漏映射时后台会直接展示原始数组文本。
assert_eq!(
wallet_ledger_source_type_to_string(&json!([16, []])).as_deref(),
Some("membership_upgrade_grant")
);
assert_eq!(
wallet_ledger_source_type_to_string(&json!([17, []])).as_deref(),
Some("[17,[]]")
Some("game_purchase")
);
assert_eq!(
wallet_ledger_source_type_to_string(&json!("GamePurchase")).as_deref(),
Some("game_purchase")
);
}
/// `game_purchase` 必须计入后台「消耗泥点」,否则买断消耗会整类从后台统计里丢失。
#[test]
fn dashboard_wallet_ledger_effect_counts_game_purchase_as_consumption() {
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("asset_operation_consume", -40),
AdminDashboardWalletLedgerEffect::Consume
);
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("llm_router_consume", -7),
AdminDashboardWalletLedgerEffect::Consume
);
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("game_purchase", -30),
AdminDashboardWalletLedgerEffect::Consume
);
// LLM Router 的正向冲正按退还对冲消耗;买断制没有退还来源,正向流水不计入。
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("llm_router_consume", 7),
AdminDashboardWalletLedgerEffect::Refund
);
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("asset_operation_refund", 40),
AdminDashboardWalletLedgerEffect::Refund
);
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("points_recharge", 100),
AdminDashboardWalletLedgerEffect::Recharge
);
// 未列入白名单的来源按不计入处理,避免新增变体被悄悄算进某一类。
for source_type in [
"snapshot_sync",
"membership_period_grant",
"redeem_code_reward",
] {
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect(source_type, 100),
AdminDashboardWalletLedgerEffect::Ignore,
"source_type={source_type}"
);
}
// 买断制购买没有成对的退还来源,正向流水不能算成退还。
assert_eq!(
resolve_admin_dashboard_wallet_ledger_effect("game_purchase", 30),
AdminDashboardWalletLedgerEffect::Ignore
);
}
/// 一条买断流水要真的累加进面板序列,而不只是分类正确。
#[test]
fn dashboard_wallet_stats_accumulate_game_purchase_consumption() {
let mut stats = AdminDashboardWalletStats {
consumed_mud_points: AdminDashboardSeries::default(),
refunded_mud_points: AdminDashboardSeries::default(),
recharged_mud_points: 0,
warnings: vec![],
};
accumulate_admin_dashboard_wallet_ledger_row(&mut stats, "game_purchase", -30, 100);
accumulate_admin_dashboard_wallet_ledger_row(
&mut stats,
"asset_operation_consume",
-40,
100,
);
accumulate_admin_dashboard_wallet_ledger_row(&mut stats, "llm_router_consume", 7, 100);
accumulate_admin_dashboard_wallet_ledger_row(&mut stats, "asset_operation_refund", 40, 100);
accumulate_admin_dashboard_wallet_ledger_row(&mut stats, "points_recharge", 100, 100);
accumulate_admin_dashboard_wallet_ledger_row(&mut stats, "snapshot_sync", 500, 100);
assert_eq!(stats.consumed_mud_points.value(100), 70);
assert_eq!(stats.consumed_mud_points.total(), 70);
assert_eq!(stats.refunded_mud_points.value(100), 47);
assert_eq!(stats.recharged_mud_points, 100);
}
#[test]
File diff suppressed because it is too large Load Diff
@@ -49,6 +49,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,
@@ -218,6 +219,10 @@ fn format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_UPGRADE_GRANT
}
// 游戏买断制购买:每账号每游戏一次扣费流水。
RuntimeProfileWalletLedgerSourceType::GamePurchase => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_GAME_PURCHASE
}
}
}
+43
View File
@@ -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()
}
@@ -4,10 +4,13 @@ use std::{
};
use crate::{
commands::{CreateGameInput, CreateVersionInput, IdempotencyRequest, ReviewDecision},
commands::{
CreateGameInput, CreateVersionInput, IdempotencyRequest, PurchaseGameInput, ReviewDecision,
},
domain::{
GameDistributionAction, GameSnapshot, GameVersionSnapshot, GameVersionStatus,
GameVisibility, normalize_id, resolve_version_number,
GameDistributionAction, GamePurchaseDecision, GamePurchaseSnapshot, GameSnapshot,
GameVersionSnapshot, GameVersionStatus, GameVisibility, normalize_game_price_mud_points,
normalize_id, resolve_game_purchase_decision, resolve_version_number,
},
errors::{GameDistributionError, GameDistributionFieldError},
};
@@ -25,6 +28,13 @@ pub struct PublicationOperationResult {
pub replayed: bool,
}
/// 购买结果:购买快照 + 是否命中幂等/既有所有权重放。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PurchaseOperationResult {
pub purchase: GamePurchaseSnapshot,
pub replayed: bool,
}
#[derive(Clone, Debug, Default)]
pub struct InMemoryGameDistributionStore {
inner: Arc<Mutex<GameDistributionState>>,
@@ -34,6 +44,8 @@ pub struct InMemoryGameDistributionStore {
struct GameDistributionState {
games: HashMap<String, GameSnapshot>,
versions: HashMap<String, GameVersionSnapshot>,
/// 以 `(user_id, game_id)` 为键强制“每账号每游戏最多一条”购买记录。
purchases: HashMap<(String, String), GamePurchaseSnapshot>,
idempotency: HashMap<IdempotencyScope, IdempotencyRecord>,
}
@@ -58,6 +70,7 @@ enum StoredOutcome {
game: GameSnapshot,
version: GameVersionSnapshot,
},
Purchase(GamePurchaseSnapshot),
}
#[derive(Clone, Debug)]
@@ -100,6 +113,7 @@ impl GameDistributionService {
visibility: GameVisibility::Unpublished,
created_at_micros: input.created_at_micros,
updated_at_micros: input.created_at_micros,
price_mud_points: 0,
};
state.games.insert(game_id, snapshot.clone());
remember(
@@ -129,6 +143,7 @@ impl GameDistributionService {
)?;
let package_sha256 = normalize_package_digest(&input.package_sha256)?;
validate_package_bytes(input.package_bytes)?;
let price_mud_points = normalize_game_price_mud_points(input.price_mud_points)?;
let idem = normalize_idempotency(input.idempotency)?;
let scope = scope(&owner_user_id, GameDistributionAction::CreateVersion, &idem);
let mut state = lock(&self.store)?;
@@ -166,6 +181,7 @@ impl GameDistributionService {
review_reason: None,
created_at_micros: input.created_at_micros,
updated_at_micros: input.created_at_micros,
price_mud_points,
};
state.versions.insert(version_id, snapshot.clone());
remember(
@@ -479,6 +495,8 @@ impl GameDistributionService {
next_version.review_reason = None;
next_game.visibility = GameVisibility::Published;
next_game.active_version_id = Some(next_version.version_id.clone());
// 价格随版本冻结:审核通过时与资料一起整体生效到游戏行。
next_game.price_mud_points = next_version.price_mud_points;
}
}
state
@@ -570,6 +588,123 @@ impl GameDistributionService {
.ok_or(GameDistributionError::VersionNotFound)
}
/// 买断制购买:校验可见性/价格/未购买,先写幂等收据再落购买记录。
///
/// 钱包扣费由 runtime profile 域负责;本方法只接收已落账的 `wallet_ledger_id`,
/// 保证购买记录与扣费流水共享同一份成交价快照。
pub fn purchase_game(
&self,
input: PurchaseGameInput,
) -> Result<PurchaseOperationResult, GameDistributionError> {
let game_id = required(input.game_id, GameDistributionFieldError::MissingGameId)?;
let user_id = required(
input.user_id,
GameDistributionFieldError::MissingActorUserId,
)?;
let wallet_ledger_id = required(
input.wallet_ledger_id,
GameDistributionFieldError::MissingWalletLedgerId,
)?;
let idem = normalize_idempotency(input.idempotency)?;
let scope = scope(&user_id, GameDistributionAction::Purchase, &idem);
let mut state = lock(&self.store)?;
if let Some(outcome) = replay_outcome(&state, &scope, &idem.request_digest)? {
return purchase_result(outcome, true);
}
let (visibility, has_active_version, price_mud_points, owner_user_id) = {
let game = state
.games
.get(&game_id)
.ok_or(GameDistributionError::GameNotFound)?;
// 与公开投影同一口径:活动版本必须存在且 `status = published`,否则会出现
// 「已扣费却不可玩」的付费作品。
let has_active_version = game
.active_version_id
.as_ref()
.and_then(|version_id| state.versions.get(version_id))
.is_some_and(|version| version.status == GameVersionStatus::Published);
(
game.visibility,
has_active_version,
game.price_mud_points,
game.owner_user_id.clone(),
)
};
let existing = state
.purchases
.get(&(user_id.clone(), game_id.clone()))
.cloned();
let decision = resolve_game_purchase_decision(
visibility,
has_active_version,
price_mud_points,
existing.is_some(),
owner_user_id == user_id,
)?;
match decision {
GamePurchaseDecision::Free => {
return Err(GameDistributionError::FreeGameNotPurchasable);
}
GamePurchaseDecision::AlreadyOwned => {
let purchase = existing.expect("已拥有的判定必然对应既有购买记录");
remember(
&mut state,
scope,
idem.request_digest,
StoredOutcome::Purchase(purchase.clone()),
);
return Ok(PurchaseOperationResult {
purchase,
replayed: true,
});
}
GamePurchaseDecision::Purchasable => {}
}
if input.expected_price_mud_points != price_mud_points {
return Err(GameDistributionError::PriceChanged);
}
let purchase = GamePurchaseSnapshot {
purchase_id: crate::domain::game_purchase_id(&game_id, &user_id),
game_id: game_id.clone(),
user_id: user_id.clone(),
price_mud_points,
wallet_ledger_id,
created_at_micros: input.created_at_micros,
};
state.purchases.insert((user_id, game_id), purchase.clone());
remember(
&mut state,
scope,
idem.request_digest,
StoredOutcome::Purchase(purchase.clone()),
);
Ok(PurchaseOperationResult {
purchase,
replayed: false,
})
}
pub fn get_game_purchase(
&self,
game_id: &str,
user_id: &str,
) -> Result<GamePurchaseSnapshot, GameDistributionError> {
let game_id = required(
game_id.to_string(),
GameDistributionFieldError::MissingGameId,
)?;
let user_id = required(
user_id.to_string(),
GameDistributionFieldError::MissingActorUserId,
)?;
let state = lock(&self.store)?;
state
.purchases
.get(&(user_id, game_id))
.cloned()
.ok_or(GameDistributionError::GamePurchaseNotFound)
}
fn transition_owned_version(
&self,
owner_user_id: &str,
@@ -832,6 +967,16 @@ fn publication_result(
}
}
fn purchase_result(
outcome: StoredOutcome,
replayed: bool,
) -> Result<PurchaseOperationResult, GameDistributionError> {
match outcome {
StoredOutcome::Purchase(purchase) => Ok(PurchaseOperationResult { purchase, replayed }),
_ => Err(GameDistributionError::IdempotencyOutcomeMismatch),
}
}
fn ensure_owner(game: &GameSnapshot, owner_user_id: &str) -> Result<(), GameDistributionError> {
if game.owner_user_id == owner_user_id {
Ok(())
@@ -848,8 +993,8 @@ fn invalid_transition(from: GameVersionStatus, to: GameVersionStatus) -> GameDis
mod tests {
use super::*;
use crate::{
CreateGameInput, CreateVersionInput, IdempotencyRequest, ReviewDecision,
compute_request_digest,
CreateGameInput, CreateVersionInput, IdempotencyRequest, MAX_GAME_PRICE_MUD_POINTS,
PurchaseGameInput, ReviewDecision, compute_request_digest,
};
fn idem(key: &str, body: &str) -> IdempotencyRequest {
@@ -883,6 +1028,7 @@ mod tests {
owner_user_id: "owner-1".to_string(),
version_id: "version-1".to_string(),
version_number: None,
price_mud_points: 0,
package_sha256: "a".repeat(64),
package_bytes: 42,
idempotency: idem("create-version-1", "version-1"),
@@ -1163,6 +1309,7 @@ mod tests {
owner_user_id: "owner-1".to_string(),
version_id: "version-2".to_string(),
version_number: None,
price_mud_points: 0,
package_sha256: "b".repeat(64),
package_bytes: 7,
idempotency: idem("create-version-2", "version-2"),
@@ -1317,6 +1464,7 @@ mod tests {
owner_user_id: "owner-1".to_string(),
version_id: "version-1-again".to_string(),
version_number: Some(first.version_number),
price_mud_points: 0,
package_sha256: "b".repeat(64),
package_bytes: 9,
idempotency: idem("create-version-again", "version-again"),
@@ -1331,4 +1479,227 @@ mod tests {
first
);
}
/// 创建并发布一个指定价格的版本,返回审核通过后的游戏快照。
fn publish_version_with_price(
service: &GameDistributionService,
price_mud_points: u64,
) -> GameSnapshot {
create_game(service);
service
.create_version(CreateVersionInput {
game_id: "game-1".to_string(),
owner_user_id: "owner-1".to_string(),
version_id: "version-1".to_string(),
version_number: None,
price_mud_points,
package_sha256: "a".repeat(64),
package_bytes: 42,
idempotency: idem("create-version-1", "version-1"),
created_at_micros: 2,
})
.expect("version should be created");
service
.confirm_package(
"owner-1",
"version-1",
&"a".repeat(64),
42,
idem("upload-1", "upload"),
3,
)
.expect("upload should succeed");
service
.submit_version("owner-1", "version-1", idem("submit-1", "submit"), 4)
.expect("submit should start validation");
service
.pass_validation("owner-1", "version-1", idem("pass-1", "pass"), 5)
.expect("validation should pass");
service
.review_version(
"reviewer-1",
"version-1",
ReviewDecision::Approve {
expected_publication_revision: 0,
},
idem("review-1", "approve"),
6,
)
.expect("review should publish")
.game
}
fn purchase_input(
purchase_id_for_ledger: &str,
expected_price_mud_points: u64,
key: &str,
) -> PurchaseGameInput {
PurchaseGameInput {
game_id: "game-1".to_string(),
user_id: "player-1".to_string(),
wallet_ledger_id: format!("game_purchase:{purchase_id_for_ledger}"),
expected_price_mud_points,
idempotency: idem(key, purchase_id_for_ledger),
created_at_micros: 10,
}
}
#[test]
fn price_must_be_within_bounds_and_zero_stays_free() {
assert_eq!(normalize_game_price_mud_points(0).expect("0 是免费"), 0);
assert_eq!(
normalize_game_price_mud_points(MAX_GAME_PRICE_MUD_POINTS).expect("上限合法"),
MAX_GAME_PRICE_MUD_POINTS
);
assert_eq!(
normalize_game_price_mud_points(MAX_GAME_PRICE_MUD_POINTS + 1)
.expect_err("超过上限必须失败"),
GameDistributionFieldError::InvalidGamePrice
);
let err = service()
.create_version(CreateVersionInput {
game_id: "game-1".to_string(),
owner_user_id: "owner-1".to_string(),
version_id: "version-1".to_string(),
version_number: None,
price_mud_points: MAX_GAME_PRICE_MUD_POINTS + 1,
package_sha256: "a".repeat(64),
package_bytes: 42,
idempotency: idem("create-version-1", "version-1"),
created_at_micros: 2,
})
.expect_err("越界价格不能冻结到版本");
assert_eq!(
err,
GameDistributionError::Field(GameDistributionFieldError::InvalidGamePrice)
);
}
#[test]
fn purchase_requires_published_game_with_active_version() {
assert_eq!(
resolve_game_purchase_decision(GameVisibility::Unpublished, true, 100, false, false)
.expect_err("未公开不能购买"),
GameDistributionError::GameNotPublished
);
assert_eq!(
resolve_game_purchase_decision(GameVisibility::Published, false, 100, false, false)
.expect_err("没有公开版本不能购买"),
GameDistributionError::VersionNotPublished
);
assert_eq!(
resolve_game_purchase_decision(GameVisibility::Published, true, 0, false, false)
.expect("免费游戏"),
GamePurchaseDecision::Free
);
assert_eq!(
resolve_game_purchase_decision(GameVisibility::Published, true, 100, true, false)
.expect("已拥有"),
GamePurchaseDecision::AlreadyOwned
);
assert_eq!(
resolve_game_purchase_decision(GameVisibility::Published, true, 100, false, false)
.expect("可购买"),
GamePurchaseDecision::Purchasable
);
// 作者本人拥有免购买权:付费作品不产生任何购买语义。
assert_eq!(
resolve_game_purchase_decision(GameVisibility::Published, true, 100, false, true)
.expect_err("作者本人不能购买自己的作品"),
GameDistributionError::OwnerCannotPurchase
);
let service = service();
create_game(&service);
let unpublished = service
.purchase_game(purchase_input("gdpurchase_1", 100, "buy-1"))
.expect_err("未公开游戏不能购买");
assert_eq!(unpublished, GameDistributionError::GameNotPublished);
}
#[test]
fn owner_cannot_purchase_own_paid_game() {
let service = service();
let game = publish_version_with_price(&service, 100);
assert_eq!(game.owner_user_id, "owner-1");
let mut input = purchase_input("gdpurchase_owner", 100, "buy-owner");
input.user_id = "owner-1".to_string();
let rejected = service
.purchase_game(input)
.expect_err("作者本人自购必须被拒绝");
assert_eq!(rejected, GameDistributionError::OwnerCannotPurchase);
// 被拒绝的购买不留下任何所有权记录。
assert_eq!(
service
.get_game_purchase("game-1", "owner-1")
.expect_err("作者本人不应产生购买记录"),
GameDistributionError::GamePurchaseNotFound
);
}
#[test]
fn free_game_rejects_purchase_and_price_change_is_conflict() {
let free_service = service();
publish_version_with_price(&free_service, 0);
let free = free_service
.purchase_game(purchase_input("gdpurchase_1", 0, "buy-1"))
.expect_err("免费游戏不需要购买");
assert_eq!(free, GameDistributionError::FreeGameNotPurchasable);
let paid_service = service();
publish_version_with_price(&paid_service, 100);
let stale = paid_service
.purchase_game(purchase_input("gdpurchase_1", 99, "buy-1"))
.expect_err("陈旧价格必须冲突");
assert_eq!(stale, GameDistributionError::PriceChanged);
}
#[test]
fn purchase_charges_once_and_keeps_single_ownership() {
let service = service();
let game = publish_version_with_price(&service, 100);
assert_eq!(game.price_mud_points, 100);
let first = service
.purchase_game(purchase_input("gdpurchase_1", 100, "buy-1"))
.expect("首次购买");
assert!(!first.replayed);
assert_eq!(first.purchase.price_mud_points, 100);
assert_eq!(
first.purchase.wallet_ledger_id,
"game_purchase:gdpurchase_1".to_string()
);
// 同 key 同请求重放:返回同一条购买记录。
let replay = service
.purchase_game(purchase_input("gdpurchase_1", 100, "buy-1"))
.expect("同请求重放");
assert!(replay.replayed);
assert_eq!(replay.purchase, first.purchase);
let conflict = service
.purchase_game(purchase_input("gdpurchase_other", 100, "buy-1"))
.expect_err("同 key 不同请求必须冲突");
assert_eq!(conflict, GameDistributionError::IdempotencyConflict);
// 换幂等键再买:命中既有所有权,仍只有一条记录、不再扣费。
let repeated = service
.purchase_game(purchase_input("gdpurchase_2", 100, "buy-2"))
.expect("重复购买幂等返回既有记录");
assert!(repeated.replayed);
assert_eq!(repeated.purchase, first.purchase);
assert_eq!(
service
.get_game_purchase("game-1", "player-1")
.expect("购买记录可回读"),
first.purchase
);
assert_eq!(
service
.get_game_purchase("game-1", "player-2")
.expect_err("未购买账号没有记录"),
GameDistributionError::GamePurchaseNotFound
);
}
}
@@ -38,12 +38,28 @@ pub struct CreateVersionInput {
/// 传入时只要求 `>= 1`:同一 gameId 的同一标签可以反复提交,每次都是一条新版本记录。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_number: Option<u64>,
/// 本版本冻结的买断制价格(泥点);`0` 表示免费,审核通过时整体生效到游戏行。
#[serde(default)]
pub price_mud_points: u64,
pub package_sha256: String,
pub package_bytes: u64,
pub idempotency: IdempotencyRequest,
pub created_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PurchaseGameInput {
pub game_id: String,
/// 购买者;与 `game_id` 一起构成唯一所有权。
pub user_id: String,
/// runtime profile 钱包扣费生成的流水 ID;领域侧只记录,不重复扣费。
pub wallet_ledger_id: String,
/// 与服务端当前价格 CAS:不一致时失败关闭,绝不按陈旧价格扣费。
pub expected_price_mud_points: u64,
pub idempotency: IdempotencyRequest,
pub created_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReviewDecision {
Approve {
@@ -1,16 +1,37 @@
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::errors::GameDistributionFieldError;
use crate::errors::{GameDistributionError, GameDistributionFieldError};
use shared_kernel::{build_prefixed_seed_id, normalize_required_string};
pub const GAME_ID_PREFIX: &str = "game_";
pub const GAME_VERSION_ID_PREFIX: &str = "gamever_";
pub const GAME_PURCHASE_ID_PREFIX: &str = "gdpurchase_";
/// 买断制价格上限(泥点);`0` 表示免费。上限与 0 一起构成唯一定价约束。
pub const MAX_GAME_PRICE_MUD_POINTS: u64 = 1_000_000;
pub fn generate_game_id(seed_micros: i64) -> String {
build_prefixed_seed_id(GAME_ID_PREFIX, seed_micros)
}
/// 长度前缀使任意游戏和账号组合都只有一个无歧义的主键;每账号每游戏最多一条购买记录。
pub fn game_purchase_id(game_id: &str, user_id: &str) -> String {
format!(
"{GAME_PURCHASE_ID_PREFIX}{}:{game_id}{user_id}",
game_id.len()
)
}
/// 校验买断制价格。类型本身已保证是整数,这里只拒绝超出 `0..=MAX_GAME_PRICE_MUD_POINTS`
/// 的取值;`0` 是合法价格,代表免费。
pub fn normalize_game_price_mud_points(value: u64) -> Result<u64, GameDistributionFieldError> {
if value > MAX_GAME_PRICE_MUD_POINTS {
return Err(GameDistributionFieldError::InvalidGamePrice);
}
Ok(value)
}
pub fn generate_game_version_id(seed_micros: i64, version_number: u64) -> String {
format!(
"{}{seed_micros:x}_{version_number:x}",
@@ -99,6 +120,8 @@ pub struct GameSnapshot {
pub visibility: GameVisibility,
pub created_at_micros: i64,
pub updated_at_micros: i64,
/// 审核通过后生效的买断制价格(泥点);`0` 表示免费。
pub price_mud_points: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -112,6 +135,61 @@ pub struct GameVersionSnapshot {
pub review_reason: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
/// 随版本冻结的价格;审核通过时整体生效到游戏行。
pub price_mud_points: u64,
}
/// 买断制购买记录快照:每账号每游戏最多一条,保存成交价与钱包流水 ID。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GamePurchaseSnapshot {
pub purchase_id: String,
pub game_id: String,
pub user_id: String,
pub price_mud_points: u64,
pub wallet_ledger_id: String,
pub created_at_micros: i64,
}
/// 一次购买请求落在哪条路径上。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum GamePurchaseDecision {
/// 免费游戏:任何账号都可直接游玩,不产生购买记录。
Free,
/// 已拥有:重复购买幂等返回既有记录,不再扣费。
AlreadyOwned,
/// 可购买:按当前价格扣费并写入购买记录。
Purchasable,
}
/// 判断购买资格:只有「公开 + 存在有效公开版本」的游戏才产生购买语义。
///
/// `has_active_version` 必须是权威判定「活动版本存在且 `status = published`」,与公开投影同一口径,
/// 否则会出现「已扣费却不可玩」。免费、已拥有与作者本人自购都不是错误,由调用方按决策回传对应结果;
/// 未公开或缺少公开版本时失败关闭。
pub fn resolve_game_purchase_decision(
visibility: GameVisibility,
has_active_version: bool,
price_mud_points: u64,
already_owned: bool,
is_owner: bool,
) -> Result<GamePurchaseDecision, GameDistributionError> {
if visibility != GameVisibility::Published {
return Err(GameDistributionError::GameNotPublished);
}
if !has_active_version {
return Err(GameDistributionError::VersionNotPublished);
}
// 作者免购买即可游玩自己的作品,绝不允许对自己的作品产生扣费。
if is_owner {
return Err(GameDistributionError::OwnerCannotPurchase);
}
if price_mud_points == 0 {
return Ok(GamePurchaseDecision::Free);
}
if already_owned {
return Ok(GamePurchaseDecision::AlreadyOwned);
}
Ok(GamePurchaseDecision::Purchasable)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -128,6 +206,7 @@ pub enum GameDistributionAction {
RejectVersion,
UnpublishGame,
SuspendGame,
Purchase,
}
/// 作品级共创(Fork)授权档位:开放度只允许单向提升,不允许回退。
@@ -1,6 +1,6 @@
use std::{error::Error, fmt};
use crate::{GameVersionStatus, GameVisibility};
use crate::{GameVersionStatus, GameVisibility, domain::MAX_GAME_PRICE_MUD_POINTS};
/// 共创主题错误码的字面量。
///
@@ -36,6 +36,8 @@ pub enum GameDistributionFieldError {
MissingReviewReason,
InvalidVersionNumber,
VersionNumberExhausted,
InvalidGamePrice,
MissingWalletLedgerId,
}
impl fmt::Display for GameDistributionFieldError {
@@ -52,6 +54,14 @@ impl fmt::Display for GameDistributionFieldError {
Self::MissingReviewReason => "拒绝审核必须提供理由",
Self::InvalidVersionNumber => "版本号必须是大于 0 的整数",
Self::VersionNumberExhausted => "版本号已达上限,无法自动递增",
// 上限只从常量取,避免改价格上限时漏改文案、前后端口径分叉。
Self::InvalidGamePrice => {
return write!(
formatter,
"priceMudPoints 必须是 0 到 {MAX_GAME_PRICE_MUD_POINTS} 之间的整数"
);
}
Self::MissingWalletLedgerId => "walletLedgerId 不能为空",
};
formatter.write_str(message)
}
@@ -125,6 +135,13 @@ pub enum GameDistributionError {
ThemeMemberGameNotFound {
game_id: String,
},
/// 免费游戏不需要购买;客户端应直接进入游玩。
FreeGameNotPurchasable,
/// 作者本人无需购买:合同约定作者免购买即可游玩自己的付费作品,禁止产生扣费。
OwnerCannotPurchase,
/// `expectedPriceMudPoints` 与服务端当前价格不一致,必须刷新后重试。
PriceChanged,
GamePurchaseNotFound,
}
impl fmt::Display for GameDistributionError {
@@ -206,6 +223,10 @@ impl fmt::Display for GameDistributionError {
GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE
)
}
Self::FreeGameNotPurchasable => formatter.write_str("免费游戏不需要购买"),
Self::OwnerCannotPurchase => formatter.write_str("作者本人无需购买"),
Self::PriceChanged => formatter.write_str("价格已变化,请刷新后重试"),
Self::GamePurchaseNotFound => formatter.write_str("购买记录不存在"),
}
}
}
@@ -225,3 +246,19 @@ fn _visibility_is_exhaustive(visibility: GameVisibility) -> bool {
GameVisibility::Unpublished | GameVisibility::Published | GameVisibility::Suspended
)
}
#[cfg(test)]
mod tests {
use super::*;
/// 价格上限只在 `domain::MAX_GAME_PRICE_MUD_POINTS` 定义一次;文案必须插值该常量,
/// 否则改上限时前端 / AGC 会继续按旧上限校验,两侧口径分叉。
#[test]
fn invalid_game_price_message_interpolates_the_price_limit_constant() {
let message = GameDistributionFieldError::InvalidGamePrice.to_string();
assert_eq!(
message,
format!("priceMudPoints 必须是 0 到 {MAX_GAME_PRICE_MUD_POINTS} 之间的整数")
);
}
}
@@ -20,7 +20,7 @@ pub use reviews::{
pub use application::{
GameDistributionService, GameOperationResult, InMemoryGameDistributionStore,
PublicationOperationResult,
PublicationOperationResult, PurchaseOperationResult,
};
pub use collection::{
GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_DEFAULT, GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX,
@@ -29,13 +29,17 @@ pub use collection::{
game_distribution_collection_page_limit, game_distribution_collection_visible,
parse_game_distribution_collection_cursor,
};
pub use commands::{CreateGameInput, CreateVersionInput, IdempotencyRequest, ReviewDecision};
pub use commands::{
CreateGameInput, CreateVersionInput, IdempotencyRequest, PurchaseGameInput, ReviewDecision,
};
pub use domain::{
FORK_AUTHORIZATION_FORBIDDEN, FORK_AUTHORIZATION_FULL, FORK_AUTHORIZATION_NON_COMMERCIAL,
ForkAuthorization, GameDistributionAction, GameSnapshot, GameVersionSnapshot,
GameVersionStatus, GameVisibility, can_serve_as_fork_source, compute_request_digest,
counts_as_public_derivative, fork_lineage_visible_identity, generate_game_id,
generate_game_version_id, next_generation, resolve_version_number,
ForkAuthorization, GAME_PURCHASE_ID_PREFIX, GameDistributionAction, GamePurchaseDecision,
GamePurchaseSnapshot, GameSnapshot, GameVersionSnapshot, GameVersionStatus, GameVisibility,
MAX_GAME_PRICE_MUD_POINTS, can_serve_as_fork_source, compute_request_digest,
counts_as_public_derivative, fork_lineage_visible_identity, game_purchase_id, generate_game_id,
generate_game_version_id, next_generation, normalize_game_price_mud_points,
resolve_game_purchase_decision, resolve_version_number,
};
pub use errors::{
GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE, GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE,
@@ -1155,6 +1155,8 @@ pub enum RuntimeProfileWalletLedgerSourceType {
RechargeRefundRecovery,
LlmRouterConsume,
MembershipUpgradeGrant,
/// 游戏买断制购买扣费(每账号每游戏一次)。
GamePurchase,
}
impl RuntimeProfileWalletLedgerSourceType {
@@ -1177,6 +1179,7 @@ impl RuntimeProfileWalletLedgerSourceType {
Self::RechargeRefundRecovery => "recharge_refund_recovery",
Self::LlmRouterConsume => "llm_router_consume",
Self::MembershipUpgradeGrant => "membership_upgrade_grant",
Self::GamePurchase => "game_purchase",
}
}
}
+145 -10
View File
@@ -868,6 +868,12 @@ enum RouteDecision {
ReleaseGateway {
upstream_path: String,
},
/// 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions/<token>/…`。
/// 与 Nginx 的同名前缀 location(`^~ /api/game-distribution/play-sessions/`)同口径:
/// 路径原样走 api 上游,其余路由属性(大小上限、限流、维护判断)与通用 `/api` 路由一致,
/// 唯一差别是在代理阶段清空 Cookie——播放会话不读账号凭证,网关对带平台 refresh Cookie
/// 的请求会返回 403(纵深防御保留)。
PlaySessionGateway,
Local(LocalResponse),
}
@@ -878,10 +884,11 @@ impl RouteDecision {
RouteDecision::Proxy {
target: ProxyTarget::Api | ProxyTarget::Spacetime,
..
} | RouteDecision::Local(LocalResponse::Static {
root: StaticRoot::Web,
..
})
} | RouteDecision::PlaySessionGateway
| RouteDecision::Local(LocalResponse::Static {
root: StaticRoot::Web,
..
})
)
}
@@ -891,15 +898,17 @@ impl RouteDecision {
RouteDecision::Proxy {
target: ProxyTarget::Api,
..
}
} | RouteDecision::PlaySessionGateway
)
}
fn proxy_target(&self) -> Option<ProxyTarget> {
match self {
RouteDecision::Proxy { target, .. } => Some(*target),
// 发行入口同样代理到 api 上游,只有路径与请求头在代理阶段被重写。
RouteDecision::ReleaseGateway { .. } => Some(ProxyTarget::Api),
// 发行入口与播放会话入口同样代理到 api 上游,只有路径与请求头在代理阶段被改写。
RouteDecision::ReleaseGateway { .. } | RouteDecision::PlaySessionGateway => {
Some(ProxyTarget::Api)
}
RouteDecision::Local(_) => None,
}
}
@@ -909,11 +918,24 @@ impl RouteDecision {
RouteDecision::Proxy { body_limit, .. } => *body_limit,
// 发行入口只服务静态资源读取,Nginx 侧也没有请求体上限指令。
RouteDecision::ReleaseGateway { .. } => None,
// 播放会话入口与通用 `/api` 路由同口径(Nginx 侧同样 `client_max_body_size 210m`)。
RouteDecision::PlaySessionGateway => Some(DEFAULT_MAX_API_BODY_BYTES),
RouteDecision::Local(_) => None,
}
}
}
/// 该路由的转发是否必须清空 Cookie。
///
/// 平台内容网关(发行入口 / 播放会话入口)都不使用账号凭证:Nginx 侧对应 location 都写了
/// `proxy_set_header Cookie ""`,api-server 网关也会拒绝带平台 refresh Cookie 的请求。
fn route_clears_cookie(route: &RouteDecision) -> bool {
matches!(
route,
RouteDecision::ReleaseGateway { .. } | RouteDecision::PlaySessionGateway
)
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct ProtectionKey {
class: ProtectionClass,
@@ -1205,7 +1227,8 @@ impl ProxyHttp for GenarrativeGateway {
match &ctx.route {
RouteDecision::Proxy { .. } => Ok(false),
RouteDecision::ReleaseGateway { .. } => Ok(false),
// 发行入口 / 播放会话入口都继续走上游代理,不需要在本阶段就地响应。
RouteDecision::ReleaseGateway { .. } | RouteDecision::PlaySessionGateway => Ok(false),
RouteDecision::Local(LocalResponse::RedirectPermanent { location }) => {
respond_redirect(session, location).await?;
Ok(true)
@@ -1321,10 +1344,14 @@ impl ProxyHttp for GenarrativeGateway {
}
// 中文注释:平台同源发行入口按 Nginx 的 proxy_pass 口径重写路径——换成
// /api/game-distribution/releases/<gameId><asset 路径>,原来的 query 不再拼接;
// 同时清空 Cookie,发行内容不读账号凭证。
// /api/game-distribution/releases/<gameId><asset 路径>,原来的 query 不再拼接。
if let RouteDecision::ReleaseGateway { upstream_path } = &ctx.route {
upstream_request.set_raw_path(upstream_path.as_bytes())?;
}
// 中文注释:平台内容网关(发行入口 / 播放会话入口)一律清空 Cookie,内容不读账号凭证;
// Nginx 侧对应 location 同口径写 `proxy_set_header Cookie ""`。
if route_clears_cookie(&ctx.route) {
upstream_request.remove_header("cookie");
}
@@ -1680,12 +1707,24 @@ fn release_gateway_upstream_path(path: &str) -> Option<String> {
))
}
/// 平台付费游戏播放会话前缀:`/api/game-distribution/play-sessions/<token>/…`。
///
/// 与 Nginx 的 `location ^~ /api/game-distribution/play-sessions/` 同口径:`/play-sessions/<token>`
/// 与 `/play-sessions/<token>/…` 都属于该前缀(前缀含 `<token>` 前的斜杠),只有裸前缀
/// `/api/game-distribution/play-sessions` 继续走通用 `/api`。该前缀下 api-server 只注册 GET 入口
/// 与包内资源;创建会话是 `POST /api/game-distribution/games/{gameId}/play-session`,不在此前缀下。
fn is_play_session_proxy_path(path: &str) -> bool {
path.starts_with("/api/game-distribution/play-sessions/")
}
fn protection_class_for_route(route: &RouteDecision, path: &str) -> Option<ProtectionClass> {
match route {
RouteDecision::Proxy {
target: ProxyTarget::Api,
..
} if path.starts_with("/admin/api/") => Some(ProtectionClass::AdminApi),
// 播放会话入口与通用 `/api` 路由同口径:同样吃 api 限流(Nginx 侧同样声明 limit_conn / limit_req)。
RouteDecision::PlaySessionGateway => Some(ProtectionClass::Api),
RouteDecision::Proxy {
target: ProxyTarget::Api,
..
@@ -1904,6 +1943,12 @@ fn classify_path(path: &str) -> RouteDecision {
};
}
// 播放会话前缀先判:它同样落在 `/api/` 下,但转发时必须清空 Cookie(Nginx 侧同名的
// `^~` 前缀 location 也是排在通用 `/api` 正则 location 之前)。
if is_play_session_proxy_path(path) {
return RouteDecision::PlaySessionGateway;
}
if path == "/api" || path.starts_with("/api/") {
return RouteDecision::Proxy {
target: ProxyTarget::Api,
@@ -3196,6 +3241,14 @@ mod tests {
case.sample_path
);
}
("play_session_gateway", RouteDecision::PlaySessionGateway) => {
assert!(
route_clears_cookie(route),
"route parity play session gateway must clear cookie: {} {}",
case.id,
case.sample_path
);
}
(
"redirect_permanent",
RouteDecision::Local(LocalResponse::RedirectPermanent { location }),
@@ -4038,6 +4091,88 @@ mod tests {
);
}
#[test]
fn classifies_play_session_paths_and_clears_cookie() {
let play_session_route =
classify_path("/api/game-distribution/play-sessions/token_1/index.html");
assert_eq!(play_session_route, RouteDecision::PlaySessionGateway);
// 播放会话入口走 api 上游,但与通用 `/api` 一样受限流、大小上限与维护判断约束,
// 唯独多一条「清空 Cookie」。
assert_eq!(play_session_route.proxy_target(), Some(ProxyTarget::Api));
assert_eq!(
play_session_route.body_limit(),
Some(DEFAULT_MAX_API_BODY_BYTES)
);
assert!(play_session_route.applies_maintenance_gate());
assert!(play_session_route.is_api_like());
assert_eq!(
protection_class_for_route(
&play_session_route,
"/api/game-distribution/play-sessions/token_1/index.html"
),
Some(ProtectionClass::Api)
);
assert!(should_disable_accel_buffering(&play_session_route));
assert!(route_clears_cookie(&play_session_route));
// 包内相对资源沿同一令牌前缀解析,子路径同样命中。
for path in [
"/api/game-distribution/play-sessions/token_1/",
"/api/game-distribution/play-sessions/token_1",
"/api/game-distribution/play-sessions/token_1/assets/main.js",
"/api/game-distribution/play-sessions/token_1/audio/bgm.ogg",
] {
assert!(is_play_session_proxy_path(path), "path: {path}");
assert_eq!(
classify_path(path),
RouteDecision::PlaySessionGateway,
"path: {path}"
);
}
// 裸前缀 `/api/game-distribution/play-sessions`(无 token)仍然走通用 `/api`:
// 它不是播放会话资源,绝不能跟着清空 Cookie。
for path in [
"/api/game-distribution/play-sessions",
"/api/game-distribution/games/game_1/play-session",
"/api/game-distribution/releases/game_1/index.html",
"/api/assets/history",
"/api",
] {
assert!(!is_play_session_proxy_path(path), "path: {path}");
assert!(!route_clears_cookie(&classify_path(path)), "path: {path}");
assert_eq!(
classify_path(path),
RouteDecision::Proxy {
target: ProxyTarget::Api,
body_limit: Some(DEFAULT_MAX_API_BODY_BYTES),
},
"path: {path}"
);
}
}
#[test]
fn only_platform_content_gateways_clear_cookie() {
// `release_gateway` 与 `play_session_gateway` 都要清空 Cookie;通用 API 代理与
// 静态路由都不清(`/api/auth/*` 依赖 refresh Cookie)。
assert!(route_clears_cookie(&classify_path(
"/games/game_0123456789abcdef0123456789abcdef/index.html"
)));
assert!(route_clears_cookie(&classify_path(
"/api/game-distribution/play-sessions/token_1/"
)));
for path in [
"/api/auth/refresh",
"/api/assets/history",
"/api/game-distribution/purchases",
"/games/detail",
"/assets/app.js",
] {
assert!(!route_clears_cookie(&classify_path(path)), "path: {path}");
}
}
#[test]
fn protection_rejects_when_concurrency_is_exhausted() {
let config = ProtectionConfig {
@@ -186,7 +186,10 @@ pub struct GameDistributionVersionSummary {
pub id: String,
pub version: String,
/// 发行入口:平台同源路径 `/games/<gameId>/`,客户端按当前 origin 解析后再交给 iframe。
pub entry_url: String,
///
/// 买断制作品对未购买且非作者 / 非管理员的查看者不下发入口(`null`),游玩入口改由播放会话接口签发。
#[serde(default)]
pub entry_url: Option<String>,
pub sha256: String,
pub published_at: String,
pub controls: Vec<String>,
@@ -248,6 +251,12 @@ pub struct GameDistributionGameSummary {
/// 排序与公开主题列表共用同一份比较器(创建时间倒序 + `theme_id` 升序兜底)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub themes: Option<Vec<GameDistributionThemeReference>>,
/// 买断制价格(泥点);`0` 表示免费。旧响应可省略,按免费处理。
#[serde(default)]
pub price_mud_points: u64,
/// 当前查看者是否已拥有;匿名与未登录恒为 `false`。
#[serde(default)]
pub purchased: bool,
}
/// 作品级共创(Fork)授权档位:只允许单向提升开放度。
@@ -594,6 +603,10 @@ pub struct GameDistributionCreateVersionRequest {
/// 缺省时序列化省略该字段,保证旧客户端请求的幂等摘要与升级前一致。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_number: Option<u64>,
/// 作者提交的买断制价格(泥点);`0` 表示免费,缺省按免费处理。
/// 价格随版本冻结,审核通过时与资料一起生效到游戏行。
#[serde(default)]
pub price_mud_points: u64,
pub package_sha256: String,
pub package_bytes: u64,
pub package_file_count: u32,
@@ -638,6 +651,9 @@ pub struct GameDistributionPrivateVersion {
pub status: GameDistributionVersionStatus,
/// 这一版所属游戏的公开切换版本号(撤回与审核回传用)。
pub publication_revision: u64,
/// 该版本冻结的买断制价格(泥点);历史无价格版本按 `0` 展示为免费。
#[serde(default)]
pub price_mud_points: u64,
#[serde(default)]
pub review_reason: Option<String>,
#[serde(default)]
@@ -652,6 +668,41 @@ pub struct GameDistributionPrivateVersion {
pub updated_at: String,
}
/// 买断制购买请求。`expectedPriceMudPoints` 与服务端当前价格做 CAS:
/// 不一致说明作者已通过新版本调价,客户端必须刷新详情后重试。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionPurchaseRequest {
pub expected_price_mud_points: u64,
}
/// 购买记录快照;成交价随购买冻结,之后调价不影响既有所有权。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionPurchase {
pub purchase_id: String,
pub game_id: String,
pub price_mud_points: u64,
pub created_at: String,
}
/// 购买响应:购买快照 + 支付后钱包余额 + 是否幂等重放。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionPurchaseResponse {
pub purchase: GameDistributionPurchase,
pub wallet_balance: u64,
pub replayed: bool,
}
/// 付费游戏播放会话:`playUrl` 已绑定 gameId、用户与短时效,过期或下架即失效。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionPlaySessionResponse {
pub play_url: String,
pub expires_at: String,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -802,4 +853,80 @@ mod tests {
assert_eq!(serialized["expectedPublicationRevision"], 3);
assert_eq!(serialized["coverAssetId"], "asset-cover");
}
#[test]
fn purchase_and_play_session_dtos_round_trip() {
let request: GameDistributionPurchaseRequest =
serde_json::from_value(serde_json::json!({ "expectedPriceMudPoints": 120 }))
.expect("购买请求应可解析");
assert_eq!(request.expected_price_mud_points, 120);
let response = serde_json::to_value(GameDistributionPurchaseResponse {
purchase: GameDistributionPurchase {
purchase_id: "gdpurchase_1".to_string(),
game_id: "game-1".to_string(),
price_mud_points: 120,
created_at: "2026-10-05T00:00:00Z".to_string(),
},
wallet_balance: 880,
replayed: false,
})
.expect("购买响应应可序列化");
assert_eq!(response["purchase"]["purchaseId"], "gdpurchase_1");
assert_eq!(response["purchase"]["priceMudPoints"], 120);
assert_eq!(response["walletBalance"], 880);
assert_eq!(response["replayed"], false);
let session = serde_json::to_value(GameDistributionPlaySessionResponse {
play_url: "/api/game-distribution/play-sessions/abc/".to_string(),
expires_at: "2026-10-05T00:05:00Z".to_string(),
})
.expect("播放会话应可序列化");
assert_eq!(
session["playUrl"],
"/api/game-distribution/play-sessions/abc/"
);
assert_eq!(session["expiresAt"], "2026-10-05T00:05:00Z");
}
#[test]
fn legacy_game_summary_defaults_to_free_and_not_purchased() {
let payload: GameDistributionGameSummary = serde_json::from_value(serde_json::json!({
"id": "game-1",
"title": "游戏",
"summary": "简介",
"description": "",
"category": "益智",
"tags": [],
"coverColor": "#ffffff",
"icon": "game",
"author": { "id": "user-1", "name": "作者" },
"deviceSupport": { "desktop": true, "mobile": false, "touch": false },
"inputModes": [],
"orientation": "responsive",
"status": "published",
"publicationRevision": 1,
"currentVersion": null,
"playCount": 0,
"createdAt": "2026-10-05T00:00:00Z",
}))
.expect("旧公开响应应继续可解析");
assert_eq!(payload.price_mud_points, 0);
assert!(!payload.purchased);
let version: GameDistributionPrivateVersion = serde_json::from_value(serde_json::json!({
"versionId": "gamever_1",
"gameId": "game-1",
"versionNumber": 1,
"packageSha256": "a".repeat(64),
"packageBytes": 10,
"packageFileCount": 1,
"status": "pending_review",
"publicationRevision": 1,
"createdAt": "2026-10-05T00:00:00Z",
"updatedAt": "2026-10-05T00:00:00Z",
}))
.expect("旧版本 payload 应继续可解析");
assert_eq!(version.price_mud_points, 0);
}
}
@@ -21,6 +21,7 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD: &str = "invit
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME: &str =
"asset_operation_consume";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_LLM_ROUTER_CONSUME: &str = "llm_router_consume";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_GAME_PURCHASE: &str = "game_purchase";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND: &str = "asset_operation_refund";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_code_reward";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM: &str =
@@ -32,13 +32,14 @@ pub use game_distribution::{
GameDistributionCreateVersionRecordInput, GameDistributionDeleteGameRecordInput,
GameDistributionFailUploadRecordInput, GameDistributionGetGameRecordInput,
GameDistributionOwnerGameListRecordInput, GameDistributionPlayCountIncrementRecordInput,
GameDistributionPublicGameListRecordInput, GameDistributionRejectRecordInput,
GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput,
GameDistributionReviewModerationRecordInput, GameDistributionSetForkAuthorizationRecordInput,
GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput,
GameDistributionThemeMemberRemoveRecordInput, GameDistributionThemeMemberUpsertRecordInput,
GameDistributionUncollectGameRecordInput, GameDistributionUnpublishRecordInput,
GameDistributionUpdateMetadataRecordInput, GameDistributionUpdateThemeRecordInput,
GameDistributionPublicGameListRecordInput, GameDistributionPurchaseRecordInput,
GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput,
GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationRecordInput,
GameDistributionSetForkAuthorizationRecordInput, GameDistributionSubmitReviewRecordInput,
GameDistributionSuspendRecordInput, GameDistributionThemeMemberRemoveRecordInput,
GameDistributionThemeMemberUpsertRecordInput, GameDistributionUncollectGameRecordInput,
GameDistributionUnpublishRecordInput, GameDistributionUpdateMetadataRecordInput,
GameDistributionUpdateThemeRecordInput,
};
#[path = "active/external_generation.rs"]
pub mod external_generation;
@@ -105,13 +105,13 @@ pub use self::game_distribution::{
GameDistributionForkSourceRecord, GameDistributionGameRecord,
GameDistributionLineageNodeRecord, GameDistributionLineageRecord,
GameDistributionLineageTreeRecord, GameDistributionOwnerGameRecord,
GameDistributionPublicGameRecord, GameDistributionRatingSummaryRecord,
GameDistributionReviewGameListRecord, GameDistributionReviewGameRecord,
GameDistributionReviewModerationOperationRecord, GameDistributionReviewModerationRecord,
GameDistributionThemeDetailRecord, GameDistributionThemeReferenceRecord,
GameDistributionThemeSummaryRecord, GameDistributionUserReviewListRecord,
GameDistributionUserReviewRecord, GameDistributionUserReviewSaveRecord,
GameDistributionVersionRecord,
GameDistributionPublicGameRecord, GameDistributionPurchaseRecord,
GameDistributionRatingSummaryRecord, GameDistributionReviewGameListRecord,
GameDistributionReviewGameRecord, GameDistributionReviewModerationOperationRecord,
GameDistributionReviewModerationRecord, GameDistributionThemeDetailRecord,
GameDistributionThemeReferenceRecord, GameDistributionThemeSummaryRecord,
GameDistributionUserReviewListRecord, GameDistributionUserReviewRecord,
GameDistributionUserReviewSaveRecord, GameDistributionVersionRecord,
};
pub use self::llm_router_account::{LlmRouterAccountRecord, LlmRouterAccountUpsertRecordInput};
pub use self::payment::{
@@ -186,9 +186,10 @@ pub(crate) use self::game_distribution::{
map_game_distribution_fork_source_result, map_game_distribution_game_result,
map_game_distribution_lineage_result, map_game_distribution_owner_game_list_result,
map_game_distribution_public_game_list_result, map_game_distribution_public_game_result,
map_game_distribution_review_list_result, map_game_distribution_theme_detail_result,
map_game_distribution_theme_list_result, map_game_distribution_theme_refs_result,
map_game_distribution_version_result, map_review_moderation_operation, map_user_review,
map_game_distribution_purchase_result, map_game_distribution_review_list_result,
map_game_distribution_theme_detail_result, map_game_distribution_theme_list_result,
map_game_distribution_theme_refs_result, map_game_distribution_version_result,
map_review_moderation_operation, map_user_review,
};
pub(crate) use self::payment::{
map_payment_api_key_list_result, map_payment_api_key_result, map_payment_app_list_result,
@@ -183,6 +183,19 @@ pub struct GameDistributionGameRecord {
pub screenshots_json: Option<String>,
/// 作品级共创授权档位:`forbidden` / `nonCommercial` / `full`。
pub fork_authorization: String,
/// 买断制价格(泥点);`0` 表示免费。
pub price_mud_points: u64,
}
/// 买断制购买记录投影;成交价随购买冻结。
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GameDistributionPurchaseRecord {
pub purchase_id: String,
pub game_id: String,
pub user_id: String,
pub price_mud_points: u64,
pub wallet_ledger_id: String,
pub created_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -478,6 +491,7 @@ fn map_game(
cover_object_key: value.cover_object_key,
screenshots_json: value.screenshots_json,
fork_authorization: value.fork_authorization,
price_mud_points: value.price_mud_points,
}
}
@@ -1329,3 +1343,26 @@ mod admin_theme_result_tests {
assert_eq!(error.to_string(), "THEME_NOT_FOUND: 主题不存在");
}
}
/// 解析购买/回读统一结果:`(购买记录, 钱包余额, replayed)`。
pub(crate) fn map_game_distribution_purchase_result(
result: crate::module_bindings::GameDistributionPurchaseResult,
) -> Result<(Option<GameDistributionPurchaseRecord>, u64, bool), SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok((
result
.purchase
.map(|purchase| GameDistributionPurchaseRecord {
purchase_id: purchase.purchase_id,
game_id: purchase.game_id,
user_id: purchase.user_id,
price_mud_points: purchase.price_mud_points,
wallet_ledger_id: purchase.wallet_ledger_id,
created_at: shared_kernel::format_timestamp_micros(purchase.created_at_micros),
}),
result.wallet_balance,
result.replayed,
))
}
@@ -95,6 +95,9 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant => {
module_runtime::RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant
}
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::GamePurchase => {
module_runtime::RuntimeProfileWalletLedgerSourceType::GamePurchase
}
}
}
@@ -286,6 +286,17 @@ pub struct GameDistributionCollectGameRecordInput {
pub now_micros: i64,
}
/// 买断制购买;`expected_price_mud_points` 与服务端当前价格 CAS。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GameDistributionPurchaseRecordInput {
pub game_id: String,
pub user_id: String,
pub expected_price_mud_points: u64,
pub idempotency_key: String,
pub request_digest: String,
pub now_micros: i64,
}
/// 取消收藏(收录)。按确定性主键删除,天然幂等,因此不带幂等键。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GameDistributionUncollectGameRecordInput {
@@ -1910,4 +1921,71 @@ impl SpacetimeClient {
)
.await
}
/// 买断制购买;返回 `(购买记录, 支付后钱包余额, 是否重放)`。
pub async fn purchase_game_distribution_game(
&self,
input: GameDistributionPurchaseRecordInput,
) -> Result<(GameDistributionPurchaseRecord, u64, bool), SpacetimeClientError> {
let procedure_input = crate::module_bindings::GameDistributionPurchaseInput {
game_id: input.game_id,
user_id: input.user_id,
expected_price_mud_points: input.expected_price_mud_points,
idempotency_key: input.idempotency_key,
request_digest: input.request_digest,
now_micros: input.now_micros,
};
self.call_after_connect(
"purchase_game_distribution_game",
move |connection, sender| {
connection
.procedures()
.purchase_game_distribution_game_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_game_distribution_purchase_result)
.and_then(|(purchase, wallet_balance, replayed)| {
purchase
.map(|purchase| (purchase, wallet_balance, replayed))
.ok_or_else(|| {
SpacetimeClientError::missing_snapshot("游戏购买结果")
})
});
send_once(&sender, mapped);
},
);
},
)
.await
}
/// 回读购买记录与当前钱包余额;未购买时购买记录为空。
pub async fn get_game_distribution_purchase(
&self,
game_id: String,
user_id: String,
) -> Result<(Option<GameDistributionPurchaseRecord>, u64), SpacetimeClientError> {
let procedure_input =
crate::module_bindings::GameDistributionPurchaseLookupInput { game_id, user_id };
self.call_after_connect(
"get_game_distribution_purchase",
move |connection, sender| {
connection
.procedures()
.get_game_distribution_purchase_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_game_distribution_purchase_result)
.map(|(purchase, wallet_balance, _)| (purchase, wallet_balance));
send_once(&sender, mapped);
},
);
},
)
.await
}
}
@@ -486,6 +486,12 @@ pub mod game_distribution_procedure_result_type;
pub mod game_distribution_public_game_input_type;
pub mod game_distribution_public_game_list_input_type;
pub mod game_distribution_public_game_snapshot_type;
pub mod game_distribution_purchase_input_type;
pub mod game_distribution_purchase_lookup_input_type;
pub mod game_distribution_purchase_result_type;
pub mod game_distribution_purchase_snapshot_type;
pub mod game_distribution_purchase_table;
pub mod game_distribution_purchase_type;
pub mod game_distribution_reject_input_type;
pub mod game_distribution_restore_input_type;
pub mod game_distribution_review_game_list_input_type;
@@ -552,6 +558,7 @@ pub mod get_game_distribution_fork_source_and_return_procedure;
pub mod get_game_distribution_game_and_return_procedure;
pub mod get_game_distribution_lineage_and_return_procedure;
pub mod get_game_distribution_my_review_and_return_procedure;
pub mod get_game_distribution_purchase_and_return_procedure;
pub mod get_game_distribution_theme_detail_and_return_procedure;
pub mod get_game_distribution_version_and_return_procedure;
pub mod get_llm_router_account_and_return_procedure;
@@ -735,6 +742,7 @@ pub mod public_work_like_table;
pub mod public_work_like_type;
pub mod public_work_play_daily_stat_table;
pub mod public_work_play_daily_stat_type;
pub mod purchase_game_distribution_game_and_return_procedure;
pub mod put_database_migration_import_chunk_procedure;
pub mod query_analytics_metric_procedure;
pub mod read_agc_model_catalog_procedure;
@@ -1457,6 +1465,12 @@ pub use game_distribution_procedure_result_type::GameDistributionProcedureResult
pub use game_distribution_public_game_input_type::GameDistributionPublicGameInput;
pub use game_distribution_public_game_list_input_type::GameDistributionPublicGameListInput;
pub use game_distribution_public_game_snapshot_type::GameDistributionPublicGameSnapshot;
pub use game_distribution_purchase_input_type::GameDistributionPurchaseInput;
pub use game_distribution_purchase_lookup_input_type::GameDistributionPurchaseLookupInput;
pub use game_distribution_purchase_result_type::GameDistributionPurchaseResult;
pub use game_distribution_purchase_snapshot_type::GameDistributionPurchaseSnapshot;
pub use game_distribution_purchase_table::*;
pub use game_distribution_purchase_type::GameDistributionPurchase;
pub use game_distribution_reject_input_type::GameDistributionRejectInput;
pub use game_distribution_restore_input_type::GameDistributionRestoreInput;
pub use game_distribution_review_game_list_input_type::GameDistributionReviewGameListInput;
@@ -1523,6 +1537,7 @@ pub use get_game_distribution_fork_source_and_return_procedure::get_game_distrib
pub use get_game_distribution_game_and_return_procedure::get_game_distribution_game_and_return;
pub use get_game_distribution_lineage_and_return_procedure::get_game_distribution_lineage_and_return;
pub use get_game_distribution_my_review_and_return_procedure::get_game_distribution_my_review_and_return;
pub use get_game_distribution_purchase_and_return_procedure::get_game_distribution_purchase_and_return;
pub use get_game_distribution_theme_detail_and_return_procedure::get_game_distribution_theme_detail_and_return;
pub use get_game_distribution_version_and_return_procedure::get_game_distribution_version_and_return;
pub use get_llm_router_account_and_return_procedure::get_llm_router_account_and_return;
@@ -1706,6 +1721,7 @@ pub use public_work_like_table::*;
pub use public_work_like_type::PublicWorkLike;
pub use public_work_play_daily_stat_table::*;
pub use public_work_play_daily_stat_type::PublicWorkPlayDailyStat;
pub use purchase_game_distribution_game_and_return_procedure::purchase_game_distribution_game_and_return;
pub use put_database_migration_import_chunk_procedure::put_database_migration_import_chunk;
pub use query_analytics_metric_procedure::query_analytics_metric;
pub use read_agc_model_catalog_procedure::read_agc_model_catalog;
@@ -2104,6 +2120,7 @@ pub struct DbUpdate {
game_distribution_game: __sdk::TableUpdate<GameDistributionGame>,
game_distribution_idempotency_receipt: __sdk::TableUpdate<GameDistributionIdempotencyReceipt>,
game_distribution_lineage: __sdk::TableUpdate<GameDistributionLineage>,
game_distribution_purchase: __sdk::TableUpdate<GameDistributionPurchase>,
game_distribution_review: __sdk::TableUpdate<GameDistributionReview>,
game_distribution_review_moderation_log:
__sdk::TableUpdate<GameDistributionReviewModerationLog>,
@@ -2336,6 +2353,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
"game_distribution_lineage" => db_update.game_distribution_lineage.append(
game_distribution_lineage_table::parse_table_update(table_update)?,
),
"game_distribution_purchase" => db_update.game_distribution_purchase.append(
game_distribution_purchase_table::parse_table_update(table_update)?,
),
"game_distribution_review" => db_update.game_distribution_review.append(
game_distribution_review_table::parse_table_update(table_update)?,
),
@@ -2777,6 +2797,12 @@ impl __sdk::DbUpdate for DbUpdate {
&self.game_distribution_lineage,
)
.with_updates_by_pk(|row| &row.game_id);
diff.game_distribution_purchase = cache
.apply_diff_to_table::<GameDistributionPurchase>(
"game_distribution_purchase",
&self.game_distribution_purchase,
)
.with_updates_by_pk(|row| &row.purchase_id);
diff.game_distribution_review = cache
.apply_diff_to_table::<GameDistributionReview>(
"game_distribution_review",
@@ -3192,6 +3218,9 @@ impl __sdk::DbUpdate for DbUpdate {
"game_distribution_lineage" => db_update
.game_distribution_lineage
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"game_distribution_purchase" => db_update
.game_distribution_purchase
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"game_distribution_review" => db_update
.game_distribution_review
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
@@ -3496,6 +3525,9 @@ impl __sdk::DbUpdate for DbUpdate {
"game_distribution_lineage" => db_update
.game_distribution_lineage
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"game_distribution_purchase" => db_update
.game_distribution_purchase
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"game_distribution_review" => db_update
.game_distribution_review
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
@@ -3711,6 +3743,7 @@ pub struct AppliedDiff<'r> {
game_distribution_idempotency_receipt:
__sdk::TableAppliedDiff<'r, GameDistributionIdempotencyReceipt>,
game_distribution_lineage: __sdk::TableAppliedDiff<'r, GameDistributionLineage>,
game_distribution_purchase: __sdk::TableAppliedDiff<'r, GameDistributionPurchase>,
game_distribution_review: __sdk::TableAppliedDiff<'r, GameDistributionReview>,
game_distribution_review_moderation_log:
__sdk::TableAppliedDiff<'r, GameDistributionReviewModerationLog>,
@@ -4007,6 +4040,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
&self.game_distribution_lineage,
event,
);
callbacks.invoke_table_row_callbacks::<GameDistributionPurchase>(
"game_distribution_purchase",
&self.game_distribution_purchase,
event,
);
callbacks.invoke_table_row_callbacks::<GameDistributionReview>(
"game_distribution_review",
&self.game_distribution_review,
@@ -4956,6 +4994,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
game_distribution_game_table::register_table(client_cache);
game_distribution_idempotency_receipt_table::register_table(client_cache);
game_distribution_lineage_table::register_table(client_cache);
game_distribution_purchase_table::register_table(client_cache);
game_distribution_review_table::register_table(client_cache);
game_distribution_review_moderation_log_table::register_table(client_cache);
game_distribution_theme_table::register_table(client_cache);
@@ -5055,6 +5094,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
"game_distribution_game",
"game_distribution_idempotency_receipt",
"game_distribution_lineage",
"game_distribution_purchase",
"game_distribution_review",
"game_distribution_review_moderation_log",
"game_distribution_theme",
@@ -34,10 +34,10 @@ pub trait acknowledge_external_generation_job_summaries_and_return {
input: ExternalGenerationJobAcknowledgeInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalGenerationJobSummaryProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<ExternalGenerationJobSummaryProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -47,10 +47,10 @@ impl acknowledge_external_generation_job_summaries_and_return for super::RemoteP
input: ExternalGenerationJobAcknowledgeInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalGenerationJobSummaryProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<ExternalGenerationJobSummaryProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, ExternalGenerationJobSummaryProcedureResult>(
@@ -34,10 +34,10 @@ pub trait acknowledge_external_generation_jobs_and_return {
input: ExternalGenerationJobAcknowledgeInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -47,10 +47,10 @@ impl acknowledge_external_generation_jobs_and_return for super::RemoteProcedures
input: ExternalGenerationJobAcknowledgeInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>(
@@ -31,10 +31,10 @@ pub trait activate_editor_canvas_layout_and_return {
input: EditorCanvasLayoutMigrationInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorCanvasLayoutMigrationProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<EditorCanvasLayoutMigrationProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -44,10 +44,10 @@ impl activate_editor_canvas_layout_and_return for super::RemoteProcedures {
input: EditorCanvasLayoutMigrationInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorCanvasLayoutMigrationProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<EditorCanvasLayoutMigrationProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorCanvasLayoutMigrationProcedureResult>(
@@ -31,10 +31,10 @@ pub trait admin_disable_profile_redeem_code {
input: RuntimeProfileRedeemCodeAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_disable_profile_redeem_code for super::RemoteProcedures {
input: RuntimeProfileRedeemCodeAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
@@ -31,10 +31,10 @@ pub trait admin_disable_profile_task_config {
input: RuntimeProfileTaskConfigAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_disable_profile_task_config for super::RemoteProcedures {
input: RuntimeProfileTaskConfigAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>(
@@ -31,10 +31,10 @@ pub trait admin_get_profile_wallet_and_return {
input: RuntimeProfileAdminWalletGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileAdminWalletProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileAdminWalletProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_get_profile_wallet_and_return for super::RemoteProcedures {
input: RuntimeProfileAdminWalletGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileAdminWalletProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileAdminWalletProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileAdminWalletProcedureResult>(

Some files were not shown because too many files have changed in this diff Show More