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
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+8
-8
@@ -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>(
|
||||
|
||||
+8
-8
@@ -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>(
|
||||
|
||||
+8
-8
@@ -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>(
|
||||
|
||||
+8
-8
@@ -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>(
|
||||
|
||||
+8
-8
@@ -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>(
|
||||
|
||||
+8
-8
@@ -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
Reference in New Issue
Block a user