修复游戏买断制付费的作者豁免、价格口径与契约可空性
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

- H1:购买事务在扣费前判定 `game.owner_user_id == user_id` 并失败关闭,领域 `resolve_game_purchase_decision` 新增 `is_owner` 标记返回 `OwnerCannotPurchase`(文案「作者本人无需购买」);api-server `map_purchase_error` 映射为 400 `GAME_PURCHASE_OWNER_EXEMPT`;购买路由按角色显式拒绝管理员令牌(403 `GAME_PURCHASE_ADMIN_NOT_ALLOWED`),管理员免购买不扣费。

- M1:`version_detail_payload` 的历史版本价格改为 `frozen_version_price_mud_points(version).unwrap_or(0)`,与待审列表及审核通过后生效的游戏行价格统一;同步把既有断言改为「历史版本缺 `priceMudPoints` 按免费(0)」。

- M2:`shared-contracts::GameDistributionVersionSummary.entry_url` 改为 `#[serde(default)] Option<String>`,与公开投影对未购买者下发的 `entryUrl: null` 一致。

- M3/L5:SpacetimeDB 购买事务改为复用 `module_game_distribution::resolve_game_purchase_decision`(新增 `game_distribution_visibility` 与 `resolve_game_distribution_purchase_decision` 包装),可购买性口径统一为「活动版本存在且 `status = published`」(复用 `public_game_distribution_version`),删除事务内联判定。

- 测试:内存领域作者自购拒绝;SpacetimeDB 购买判定五分支(免费 / 未公开 / 版本未公开 / 已拥有 / 作者自购)、事务源扫描(先判定后扣费、公开投影口径)、审核通过时历史版本价格生效为 0;api-server 管理员令牌 403、公开投影 `entryUrl: null` 可被 Rust DTO 解析、错误映射补作者豁免。

- `scripts/check-game-distribution-dto-parity.mjs` 增加可空性一致性比对(TS `| null` ⟺ Rust `Option`,允许 `?` 表示可省略),已用负例验证能拦住同类漂移。

- 同步更新 docs/【后端架构】server-rs与SpacetimeDB数据契约:登记作者豁免 / 管理员 403 / 可玩性口径 / 历史价格按 0;顺带修复 `module-game-distribution` 与 `spacetime-module` 既有未格式化行。
This commit is contained in:
2026-10-05 18:00:03 +08:00
parent d40df89e2c
commit d1bfe4c08b
9 changed files with 497 additions and 88 deletions
@@ -1631,8 +1631,12 @@ async fn create_version(
/// 买断制购买:扣泥点与写购买记录在同一事务内完成,`Idempotency-Key` 保证双击与重试只扣一次。
///
/// 只接受普通用户 bearer:管理员令牌与用户令牌共用同一 JWT 签名密钥,
/// `require_bearer_auth` 无法区分,这里按角色显式拒绝,管理员免购买且绝不扣费。
///
/// 错误口径:余额不足 400 `INSUFFICIENT_MUD_POINTS`;价格已由新版本改变 409;免费游戏 400;
/// 游戏不可见 / 不存在 404。免费游戏与已购买账号都不产生新扣费。
/// 作者本人自购 400 `GAME_PURCHASE_OWNER_EXEMPT`;管理员令牌 403;游戏不可见 / 不存在 404。
/// 免费游戏与已购买账号都不产生新扣费。
async fn purchase_game(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
@@ -1641,6 +1645,11 @@ async fn purchase_game(
headers: HeaderMap,
Json(payload): Json<GameDistributionPurchaseRequest>,
) -> Result<Json<Value>, AppError> {
if auth.claims().roles.iter().any(|role| role == "admin") {
return Err(AppError::from_status(StatusCode::FORBIDDEN)
.with_code("GAME_PURCHASE_ADMIN_NOT_ALLOWED")
.with_message("管理员无需购买作品"));
}
let idempotency_key = idempotency_key(&headers)?;
let game_id = game_id.trim().to_string();
if game_id.is_empty() {
@@ -3612,11 +3621,12 @@ fn version_detail_payload(
object.insert("frozenMetadata".to_string(), frozen_metadata);
}
let mut game_payload = game_payload(game);
// 版本详情里的价格口径:待审 / 该版本冻结价优先,历史无价格版本回退游戏行当前价。
// 版本详情里的价格口径:待审 / 该版本冻结价优先;历史无价格版本按免费(0)展示,
// 与待审列表和审核通过后的实际生效价统一,不再回退到游戏行旧价。
if let Value::Object(object) = &mut game_payload {
object.insert(
"priceMudPoints".to_string(),
json!(frozen_version_price_mud_points(version).unwrap_or(game.price_mud_points)),
json!(frozen_version_price_mud_points(version).unwrap_or(0)),
);
}
json!({ "game": game_payload, "version": payload })
@@ -3678,7 +3688,8 @@ fn map_package_error(error: ReleasePackageError) -> AppError {
/// 购买失败的错误映射。
///
/// 余额不足 400 `INSUFFICIENT_MUD_POINTS`(前端据此引导充值);价格已被新版本改变 409;
/// 游戏未公开 / 不存在 404;免费游戏与其它业务拒绝走通用 400。
/// 游戏未公开 / 不存在 404;作者本人自购 400 `GAME_PURCHASE_OWNER_EXEMPT`;
/// 免费游戏与其它业务拒绝走通用 400。
fn map_purchase_error(error: SpacetimeClientError) -> AppError {
if let SpacetimeClientError::Procedure(message) = &error {
if message.contains("泥点余额不足") || message.contains("可消费泥点不足") {
@@ -3695,6 +3706,13 @@ fn map_purchase_error(error: SpacetimeClientError) -> AppError {
return AppError::from_status(StatusCode::NOT_FOUND)
.with_details(json!({ "provider": "game-distribution", "message": message }));
}
// 作者本人免购买:这是业务拒绝(不是服务端故障),必须映射成 400 而不是 5xx。
if message.contains("作者本人") {
return AppError::from_status(StatusCode::BAD_REQUEST)
.with_code("GAME_PURCHASE_OWNER_EXEMPT")
.with_message("作者本人无需购买")
.with_details(json!({ "provider": "game-distribution", "message": message }));
}
}
map_spacetime_error(error)
}
@@ -4669,6 +4687,29 @@ mod tests {
assert!(legacy_payload["version"]["frozenMetadata"].is_null());
}
/// M2:付费作品对未购买查看者下发 `entryUrl: null`,Rust 契约必须能解析成 `None`。
#[test]
fn paid_game_hides_entry_url_from_unpurchased_viewer_and_dto_accepts_null() {
let game = paid_game_record("user_author", 240);
let version = public_version_record(
Some("/games/game_1/"),
Some(r#"{"coverAssetId":"asset_cover","coverObjectKey":"generated/cover.png"}"#),
);
let payload = public_game_payload(
public_game_record(game, version),
&GameViewerContext::default(),
);
assert!(payload["currentVersion"]["entryUrl"].is_null());
assert_eq!(payload["purchased"], json!(false));
let summary: shared_contracts::game_distribution::GameDistributionGameSummary =
serde_json::from_value(payload).expect("公开游戏响应应可解析");
assert_eq!(
summary.current_version.expect("应有当前版本").entry_url,
None
);
}
/// 作者管理页依赖这条边界:公开投影不带版本私有状态,作者条目必须带。
#[test]
fn owner_game_entry_payload_carries_private_versions_that_public_payload_omits() {
@@ -5648,7 +5689,8 @@ mod tests {
assert_eq!(free["currentVersion"]["entryUrl"], json!("/games/game_1/"));
}
/// 审核列表与版本详情都读版本冻结价;历史版本缺字段按免费 / 回退游戏行价。
/// 审核列表与版本详情都读版本冻结价;历史版本缺 `priceMudPoints` 一律按免费(0),
/// 不回退游戏行价,与审核通过后实际生效的价格口径一致。
#[test]
fn private_version_payload_reports_frozen_price_and_legacy_defaults() {
let game = paid_game_record("user_author", 999);
@@ -5666,7 +5708,7 @@ mod tests {
assert_eq!(detail["game"]["priceMudPoints"], json!(240));
assert_eq!(detail["version"]["priceMudPoints"], json!(240));
// 历史版本缺 priceMudPoints:列表按免费,详情回退游戏行当前价。
// 历史版本缺 priceMudPoints:列表与详情都按免费(0),不回退游戏行当前价 999。
version.metadata_json = Some(r#"{"coverAssetId":"asset_cover"}"#.to_string());
assert_eq!(
private_version_payload(&version)["priceMudPoints"],
@@ -5674,7 +5716,7 @@ mod tests {
);
assert_eq!(
version_detail_payload(&version, &game)["game"]["priceMudPoints"],
json!(999)
json!(0)
);
// 完全没有冻结资料的旧版本同样按免费展示,不 panic。
@@ -5685,7 +5727,7 @@ mod tests {
);
assert_eq!(
version_detail_payload(&version, &game)["game"]["priceMudPoints"],
json!(999)
json!(0)
);
}
@@ -5708,9 +5750,18 @@ mod tests {
));
assert_eq!(free.status_code(), StatusCode::BAD_REQUEST);
// 作者本人自购是业务拒绝:必须 400 + 明确错误码,绝不能落到 5xx。
let owner_exempt = map_purchase_error(SpacetimeClientError::Procedure(
"作者本人无需购买".to_string(),
));
assert_eq!(owner_exempt.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(owner_exempt.code(), "GAME_PURCHASE_OWNER_EXEMPT");
assert_eq!(owner_exempt.message(), "作者本人无需购买");
for message in [
"游戏不存在",
"游戏未公开或没有可玩版本,不能购买",
"游戏当前未公开",
"游戏版本当前未公开",
"游戏已被删除",
] {
let error = map_purchase_error(SpacetimeClientError::Procedure(message.to_string()));
@@ -5722,6 +5773,16 @@ mod tests {
}
async fn signed_test_user_token(state: &AppState, phone_number: &str) -> String {
signed_test_token_with_roles(state, phone_number, vec!["user".to_string()]).await
}
/// 用同一签名配置签发任意角色的用户令牌:管理员令牌与用户令牌共用密钥,
/// 只有角色不同,因此必须按角色验证购买路由的拒绝行为。
async fn signed_test_token_with_roles(
state: &AppState,
phone_number: &str,
roles: Vec<String>,
) -> String {
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
sign_access_token,
@@ -5735,7 +5796,7 @@ mod tests {
user_id: user.id.clone(),
session_id,
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
roles,
token_version: user.token_version,
phone_verified: false,
binding_status: BindingStatus::Active,
@@ -5832,6 +5893,37 @@ mod tests {
assert_eq!(offline.status(), StatusCode::BAD_GATEWAY);
}
/// H1 边界:管理员令牌与用户令牌共用同一签名密钥,`require_bearer_auth` 无法区分;
/// 购买路由必须按角色显式拒绝管理员令牌,管理员免购买且绝不扣费。
#[tokio::test]
async fn purchase_route_rejects_admin_role_token() {
use axum::http::Request;
use tower::ServiceExt;
let state = AppState::new(crate::config::AppConfig::default()).unwrap();
let token =
signed_test_token_with_roles(&state, "13800138207", vec!["admin".to_string()]).await;
let app = crate::app::build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/game-distribution/games/game_1/purchase")
.header(header::CONTENT_TYPE, "application/json")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header("idempotency-key", "purchase-admin-key")
.body(Body::from(r#"{"expectedPriceMudPoints":200}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
read_response_json(response).await["error"]["code"],
json!("GAME_PURCHASE_ADMIN_NOT_ALLOWED")
);
}
/// 播放会话网关:拒绝平台 Cookie、未知 / 过期令牌与非法资源路径一律 404,且全部 no-store。
#[tokio::test]
async fn play_session_gateway_rejects_platform_cookie_and_invalid_tokens() {
@@ -597,7 +597,10 @@ impl GameDistributionService {
input: PurchaseGameInput,
) -> Result<PurchaseOperationResult, GameDistributionError> {
let game_id = required(input.game_id, GameDistributionFieldError::MissingGameId)?;
let user_id = required(input.user_id, GameDistributionFieldError::MissingActorUserId)?;
let user_id = required(
input.user_id,
GameDistributionFieldError::MissingActorUserId,
)?;
let wallet_ledger_id = required(
input.wallet_ledger_id,
GameDistributionFieldError::MissingWalletLedgerId,
@@ -608,15 +611,23 @@ impl GameDistributionService {
if let Some(outcome) = replay_outcome(&state, &scope, &idem.request_digest)? {
return purchase_result(outcome, true);
}
let (visibility, has_active_version, price_mud_points) = {
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,
game.active_version_id.is_some(),
has_active_version,
game.price_mud_points,
game.owner_user_id.clone(),
)
};
let existing = state
@@ -628,6 +639,7 @@ impl GameDistributionService {
has_active_version,
price_mud_points,
existing.is_some(),
owner_user_id == user_id,
)?;
match decision {
GamePurchaseDecision::Free => {
@@ -659,9 +671,7 @@ impl GameDistributionService {
wallet_ledger_id,
created_at_micros: input.created_at_micros,
};
state
.purchases
.insert((user_id, game_id), purchase.clone());
state.purchases.insert((user_id, game_id), purchase.clone());
remember(
&mut state,
scope,
@@ -679,7 +689,10 @@ impl GameDistributionService {
game_id: &str,
user_id: &str,
) -> Result<GamePurchaseSnapshot, GameDistributionError> {
let game_id = required(game_id.to_string(), GameDistributionFieldError::MissingGameId)?;
let game_id = required(
game_id.to_string(),
GameDistributionFieldError::MissingGameId,
)?;
let user_id = required(
user_id.to_string(),
GameDistributionFieldError::MissingActorUserId,
@@ -1565,30 +1578,36 @@ mod tests {
#[test]
fn purchase_requires_published_game_with_active_version() {
assert_eq!(
resolve_game_purchase_decision(GameVisibility::Unpublished, true, 100, false)
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)
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)
resolve_game_purchase_decision(GameVisibility::Published, true, 0, false, false)
.expect("免费游戏"),
GamePurchaseDecision::Free
);
assert_eq!(
resolve_game_purchase_decision(GameVisibility::Published, true, 100, true)
resolve_game_purchase_decision(GameVisibility::Published, true, 100, true, false)
.expect("已拥有"),
GamePurchaseDecision::AlreadyOwned
);
assert_eq!(
resolve_game_purchase_decision(GameVisibility::Published, true, 100, false)
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);
@@ -1598,6 +1617,26 @@ mod tests {
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();
@@ -17,7 +17,10 @@ pub fn generate_game_id(seed_micros: i64) -> String {
/// 长度前缀使任意游戏和账号组合都只有一个无歧义的主键;每账号每游戏最多一条购买记录。
pub fn game_purchase_id(game_id: &str, user_id: &str) -> String {
format!("{GAME_PURCHASE_ID_PREFIX}{}:{game_id}{user_id}", game_id.len())
format!(
"{GAME_PURCHASE_ID_PREFIX}{}:{game_id}{user_id}",
game_id.len()
)
}
/// 校验买断制价格。类型本身已保证是整数,这里只拒绝超出 `0..=MAX_GAME_PRICE_MUD_POINTS`
@@ -160,12 +163,15 @@ pub enum GamePurchaseDecision {
/// 判断购买资格:只有「公开 + 存在有效公开版本」的游戏才产生购买语义。
///
/// 免费与已拥有都不是错误,由调用方按决策回传对应结果;未公开或缺少公开版本时失败关闭。
/// `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);
@@ -173,6 +179,10 @@ pub fn resolve_game_purchase_decision(
if !has_active_version {
return Err(GameDistributionError::VersionNotPublished);
}
// 作者免购买即可游玩自己的作品,绝不允许对自己的作品产生扣费。
if is_owner {
return Err(GameDistributionError::OwnerCannotPurchase);
}
if price_mud_points == 0 {
return Ok(GamePurchaseDecision::Free);
}
@@ -66,6 +66,8 @@ pub enum GameDistributionError {
ReviewActionNotAllowed,
/// 免费游戏不需要购买;客户端应直接进入游玩。
FreeGameNotPurchasable,
/// 作者本人无需购买:合同约定作者免购买即可游玩自己的付费作品,禁止产生扣费。
OwnerCannotPurchase,
/// `expectedPriceMudPoints` 与服务端当前价格不一致,必须刷新后重试。
PriceChanged,
GamePurchaseNotFound,
@@ -98,6 +100,7 @@ impl fmt::Display for GameDistributionError {
Self::VersionAlreadyPublished => formatter.write_str("游戏版本已经公开"),
Self::ReviewActionNotAllowed => formatter.write_str("当前版本不允许审核操作"),
Self::FreeGameNotPurchasable => formatter.write_str("免费游戏不需要购买"),
Self::OwnerCannotPurchase => formatter.write_str("作者本人无需购买"),
Self::PriceChanged => formatter.write_str("价格已变化,请刷新后重试"),
Self::GamePurchaseNotFound => formatter.write_str("购买记录不存在"),
}
@@ -22,11 +22,11 @@ pub use commands::{
CreateGameInput, CreateVersionInput, IdempotencyRequest, PurchaseGameInput, ReviewDecision,
};
pub use domain::{
GAME_PURCHASE_ID_PREFIX, MAX_GAME_PRICE_MUD_POINTS, GameDistributionAction,
GamePurchaseDecision, GamePurchaseSnapshot, GameSnapshot, GameVersionSnapshot,
GameVersionStatus, GameVisibility, compute_request_digest, game_purchase_id,
generate_game_id, generate_game_version_id, normalize_game_price_mud_points,
resolve_game_purchase_decision, resolve_version_number,
GAME_PURCHASE_ID_PREFIX, GameDistributionAction, GamePurchaseDecision, GamePurchaseSnapshot,
GameSnapshot, GameVersionSnapshot, GameVersionStatus, GameVisibility,
MAX_GAME_PRICE_MUD_POINTS, compute_request_digest, game_purchase_id, generate_game_id,
generate_game_version_id, normalize_game_price_mud_points, resolve_game_purchase_decision,
resolve_version_number,
};
pub use errors::{GameDistributionError, GameDistributionFieldError};
pub use events::GameDistributionEvent;
@@ -157,7 +157,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>,
@@ -1379,9 +1379,7 @@ pub struct GameDistributionPurchaseResult {
pub error_message: Option<String>,
}
fn game_distribution_purchase_result_error(
error: String,
) -> GameDistributionPurchaseResult {
fn game_distribution_purchase_result_error(error: String) -> GameDistributionPurchaseResult {
GameDistributionPurchaseResult {
ok: false,
purchase: None,
@@ -3746,6 +3744,44 @@ fn get_owner_game_distribution_version_tx(
)))
}
/// 把数据行里的可见性文本映射成领域枚举;未知取值失败关闭,不静默当成可购买。
fn game_distribution_visibility(
visibility: &str,
) -> Result<module_game_distribution::GameVisibility, String> {
match visibility {
GAME_DISTRIBUTION_VISIBILITY_UNPUBLISHED => {
Ok(module_game_distribution::GameVisibility::Unpublished)
}
GAME_DISTRIBUTION_VISIBILITY_PUBLISHED => {
Ok(module_game_distribution::GameVisibility::Published)
}
GAME_DISTRIBUTION_VISIBILITY_SUSPENDED => {
Ok(module_game_distribution::GameVisibility::Suspended)
}
other => Err(format!("未知的游戏可见性:{other}")),
}
}
/// 生产购买判定:复用 `module-game-distribution` 的领域规则,事务里不再维护第二份判定。
///
/// `has_published_active_version` 必须是权威判定「活动版本存在且 `status = published`」,
/// 与公开投影同一口径;作者本人拥有免购买权,判定为 `OwnerCannotPurchase` 而不是扣费。
fn resolve_game_distribution_purchase_decision(
game: &GameDistributionGame,
has_published_active_version: bool,
already_owned: bool,
viewer_user_id: &str,
) -> Result<module_game_distribution::GamePurchaseDecision, String> {
module_game_distribution::resolve_game_purchase_decision(
game_distribution_visibility(&game.visibility)?,
has_published_active_version,
game.price_mud_points,
already_owned,
game.owner_user_id == viewer_user_id,
)
.map_err(|error| error.to_string())
}
/// 购买事务:先查幂等收据与现有所有权,再校验可见性/价格,最后扣费 + 落购买行 + 记收据。
fn purchase_game_distribution_game_tx(
ctx: &ReducerContext,
@@ -3767,7 +3803,11 @@ fn purchase_game_distribution_game_tx(
let purchase = find_game_distribution_purchase(ctx, &user_id, &game_id)
.ok_or_else(|| "幂等收据对应的购买记录已不存在".to_string())?;
let balance = profile_wallet_balance(ctx, &user_id);
return Ok((game_distribution_purchase_snapshot(&purchase), balance, true));
return Ok((
game_distribution_purchase_snapshot(&purchase),
balance,
true,
));
}
let game = ctx
@@ -3777,45 +3817,56 @@ fn purchase_game_distribution_game_tx(
.find(&game_id)
.ok_or_else(|| "游戏不存在".to_string())?;
ensure_game_distribution_game_not_deleted(&game)?;
if game.visibility != GAME_DISTRIBUTION_VISIBILITY_PUBLISHED || game.active_version_id.is_none()
{
return Err("游戏未公开或没有可玩版本,不能购买".to_string());
}
// 可玩性口径与公开投影一致:活动版本必须存在且已公开,避免「已扣费却不可玩」。
let has_published_active_version = public_game_distribution_version(ctx, &game).is_some();
let existing_purchase = find_game_distribution_purchase(ctx, &user_id, &game_id);
let decision = resolve_game_distribution_purchase_decision(
&game,
has_published_active_version,
existing_purchase.is_some(),
&user_id,
)?;
let price_mud_points = game.price_mud_points;
if price_mud_points == 0 {
return Err("免费游戏不需要购买".to_string());
match decision {
module_game_distribution::GamePurchaseDecision::Free => {
return Err("免费游戏不需要购买".to_string());
}
// 换幂等键重复购买:已有所有权时不再扣费,只补记收据并回传既有记录。
module_game_distribution::GamePurchaseDecision::AlreadyOwned => {
let purchase = existing_purchase.expect("已拥有的判定必然对应既有购买记录");
insert_game_distribution_receipt(
ctx,
GameDistributionReceiptInput {
receipt_id,
owner_user_id: user_id.clone(),
action: GAME_DISTRIBUTION_ACTION_PURCHASE.to_string(),
idempotency_key,
request_digest,
game_id: Some(game_id),
version_id: None,
outcome_kind: "purchase".to_string(),
outcome_game_id: None,
outcome_version_id: None,
outcome_json: Some(purchase.purchase_id.clone()),
now_micros: input.now_micros,
},
)?;
let balance = profile_wallet_balance(ctx, &user_id);
return Ok((
game_distribution_purchase_snapshot(&purchase),
balance,
true,
));
}
module_game_distribution::GamePurchaseDecision::Purchasable => {}
}
if input.expected_price_mud_points != price_mud_points {
return Err("价格已变化,请刷新后重试".to_string());
}
// 换幂等键重复购买:已有所有权时不再扣费,只补记收据并回传既有记录。
if let Some(purchase) = find_game_distribution_purchase(ctx, &user_id, &game_id) {
insert_game_distribution_receipt(
ctx,
GameDistributionReceiptInput {
receipt_id,
owner_user_id: user_id.clone(),
action: GAME_DISTRIBUTION_ACTION_PURCHASE.to_string(),
idempotency_key,
request_digest,
game_id: Some(game_id),
version_id: None,
outcome_kind: "purchase".to_string(),
outcome_game_id: None,
outcome_version_id: None,
outcome_json: Some(purchase.purchase_id.clone()),
now_micros: input.now_micros,
},
)?;
let balance = profile_wallet_balance(ctx, &user_id);
return Ok((game_distribution_purchase_snapshot(&purchase), balance, true));
}
let now = Timestamp::from_micros_since_unix_epoch(input.now_micros);
let wallet_ledger_id = format!(
"{GAME_DISTRIBUTION_PURCHASE_LEDGER_PREFIX}:{user_id}:{game_id}"
);
let wallet_ledger_id =
format!("{GAME_DISTRIBUTION_PURCHASE_LEDGER_PREFIX}:{user_id}:{game_id}");
let metadata_json = serde_json::json!({
"kind": "game_purchase",
"gameId": game_id,
@@ -3836,9 +3887,7 @@ fn purchase_game_distribution_game_tx(
wallet_ledger_id: wallet_ledger_id.clone(),
created_at: now,
};
ctx.db
.game_distribution_purchase()
.insert(purchase.clone());
ctx.db.game_distribution_purchase().insert(purchase.clone());
insert_game_distribution_receipt(
ctx,
GameDistributionReceiptInput {
@@ -3869,8 +3918,8 @@ fn get_game_distribution_purchase_tx(
) -> Result<(Option<GameDistributionPurchaseSnapshot>, u64), String> {
let user_id = required_game_distribution_text(input.user_id, "user_id")?;
let game_id = required_game_distribution_text(input.game_id, "game_id")?;
let purchase =
find_game_distribution_purchase(ctx, &user_id, &game_id).map(|row| game_distribution_purchase_snapshot(&row));
let purchase = find_game_distribution_purchase(ctx, &user_id, &game_id)
.map(|row| game_distribution_purchase_snapshot(&row));
Ok((purchase, profile_wallet_balance(ctx, &user_id)))
}
@@ -4052,8 +4101,8 @@ pub(crate) struct GameDistributionFrozenDeviceSupport {
fn parse_game_distribution_frozen_metadata(
metadata_json: &str,
) -> Result<GameDistributionFrozenMetadata, String> {
let frozen: GameDistributionFrozenMetadata = serde_json::from_str(metadata_json)
.map_err(|_| "版本冻结资料格式无效".to_string())?;
let frozen: GameDistributionFrozenMetadata =
serde_json::from_str(metadata_json).map_err(|_| "版本冻结资料格式无效".to_string())?;
module_game_distribution::normalize_game_price_mud_points(frozen.price_mud_points)
.map_err(|error| error.to_string())?;
Ok(frozen)
@@ -4241,4 +4290,136 @@ mod tests {
// 溢出不能回绕成 0 或静默改写已有版本。
assert!(!overflow.contains("版本号 0"));
}
fn game_row(
visibility: &str,
owner_user_id: &str,
price_mud_points: u64,
) -> GameDistributionGame {
GameDistributionGame {
game_id: "game-1".to_string(),
owner_user_id: owner_user_id.to_string(),
title: "测试作品".to_string(),
summary: String::new(),
description: String::new(),
category: "其他".to_string(),
tags_json: "[]".to_string(),
cover_asset_id: None,
author_name: None,
author_avatar_url: None,
device_support_desktop: true,
device_support_mobile: false,
device_support_touch: false,
input_modes_json: "[]".to_string(),
orientation: "landscape".to_string(),
publication_revision: 1,
active_version_id: Some("version-1".to_string()),
visibility: visibility.to_string(),
play_count: 0,
created_at: Timestamp::from_micros_since_unix_epoch(0),
updated_at: Timestamp::from_micros_since_unix_epoch(0),
local_project_id: None,
cover_object_key: None,
screenshots_json: None,
deleted_at: None,
price_mud_points,
}
}
/// 生产购买判定必须覆盖免费、未公开、已拥有与作者自购四种分支。
#[test]
fn purchase_decision_covers_free_unpublished_owned_and_owner_cases() {
let published = game_row(GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, "owner-1", 100);
assert_eq!(
resolve_game_distribution_purchase_decision(&published, true, false, "player-1")
.expect("可购买"),
module_game_distribution::GamePurchaseDecision::Purchasable
);
// 已拥有:回传既有所有权,不再扣费。
assert_eq!(
resolve_game_distribution_purchase_decision(&published, true, true, "player-1")
.expect("已拥有"),
module_game_distribution::GamePurchaseDecision::AlreadyOwned
);
// 免费游戏:直接进入游玩。
let free = game_row(GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, "owner-1", 0);
assert_eq!(
resolve_game_distribution_purchase_decision(&free, true, false, "player-1")
.expect("免费游戏"),
module_game_distribution::GamePurchaseDecision::Free
);
// 未公开:失败关闭,避免「已扣费却不可玩」。
let unpublished = game_row(GAME_DISTRIBUTION_VISIBILITY_UNPUBLISHED, "owner-1", 100);
assert_eq!(
resolve_game_distribution_purchase_decision(&unpublished, true, false, "player-1")
.expect_err("未公开不能购买"),
"游戏当前未公开"
);
// 已公开但活动版本不是 published:同样失败关闭。
let version_not_published =
resolve_game_distribution_purchase_decision(&published, false, false, "player-1")
.expect_err("活动版本未公开不能购买");
assert!(
version_not_published.contains("未公开"),
"{version_not_published}"
);
// 作者本人自购:明确业务拒绝,绝不扣费、绝不落购买记录。
assert_eq!(
resolve_game_distribution_purchase_decision(&published, true, false, "owner-1")
.expect_err("作者本人不能购买自己的作品"),
"作者本人无需购买"
);
}
/// `ReducerContext` 无法在单测里构造,事务只能靠源扫描兜底:生产路径必须复用领域判定,
/// 且必须先判定后扣费,不得内联第二份可购买性判断。
#[test]
fn purchase_transaction_reuses_domain_decision_before_charging() {
let source = include_str!("game_distribution.rs");
let body = source
.split("fn purchase_game_distribution_game_tx(")
.nth(1)
.expect("购买事务应存在");
let body = &body[..body.find("\nfn ").unwrap_or(body.len())];
assert!(
body.contains("resolve_game_distribution_purchase_decision("),
"生产事务必须调用领域购买判定"
);
assert!(
body.contains("public_game_distribution_version(ctx, &game).is_some()"),
"可玩性口径必须与公开投影一致(活动版本存在且 published)"
);
assert!(
!body.contains("game.active_version_id.is_none()"),
"不得再用 active_version_id 直接判可购买"
);
let decision = body
.find("resolve_game_distribution_purchase_decision(")
.expect("判定应存在");
let charge = body
.find("consume_profile_wallet_points_for_game_purchase")
.expect("扣费应存在");
assert!(decision < charge, "必须先判定后扣费");
}
/// M1 的事务侧:审核通过时把冻结资料里的价格整体生效到游戏行;历史版本缺 `priceMudPoints`
/// 时必须生效为 `0`(免费),与列表 / 详情口径一致。
#[test]
fn approving_frozen_metadata_applies_frozen_price_or_free_for_legacy_versions() {
let mut game = game_row(GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, "owner-1", 240);
let legacy = parse_game_distribution_frozen_metadata(
r#"{"title":"标题","summary":"摘要","category":"益智","coverAssetId":"asset_cover","coverObjectKey":"generated/cover.png","deviceSupport":{"desktop":true,"mobile":false,"touch":false},"orientation":"responsive"}"#,
)
.expect("历史冻结资料应可解析");
apply_game_distribution_frozen_metadata(&mut game, &legacy);
assert_eq!(game.price_mud_points, 0, "历史无价格版本通过后应为免费");
let priced = parse_game_distribution_frozen_metadata(
r#"{"title":"标题","summary":"摘要","category":"益智","coverAssetId":"asset_cover","coverObjectKey":"generated/cover.png","deviceSupport":{"desktop":true,"mobile":false,"touch":false},"orientation":"responsive","priceMudPoints":100}"#,
)
.expect("带价格冻结资料应可解析");
apply_game_distribution_frozen_metadata(&mut game, &priced);
assert_eq!(game.price_mud_points, 100);
}
}