Files
Genarrative/server-rs/crates/module-game-distribution/src/application.rs
T
kdletters 328ac31844 恢复游戏分发完整实现(特性分支)
- 主站:游戏广场、详情、在线游玩、网页发布与作者中心,以及共享契约与客户端服务
- 后端:module-game-distribution 领域层、SpacetimeDB 表/迁移/绑定、spacetime-client facade、api-server 路由与发行网关
- 后台:游戏审核页(待审列表、通过/拒绝、安全下架)
- AGC:发布面板、本地导出包读取命令与发布服务,含默认跳过的真实链路测试
- 运维:发行来源 nginx 模板与门禁、game-distribution:publish 灰度发布开关、OSS PutObject 受控重试
- 文档:主规范、里程碑与实施计划、决策日志与踩坑记录
2026-09-20 20:49:42 +08:00

1114 lines
37 KiB
Rust

use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use crate::{
commands::{CreateGameInput, CreateVersionInput, IdempotencyRequest, ReviewDecision},
domain::{
GameDistributionAction, GameSnapshot, GameVersionSnapshot, GameVersionStatus,
GameVisibility, normalize_id,
},
errors::{GameDistributionError, GameDistributionFieldError},
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GameOperationResult<T> {
pub snapshot: T,
pub replayed: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicationOperationResult {
pub game: GameSnapshot,
pub version: GameVersionSnapshot,
pub replayed: bool,
}
#[derive(Clone, Debug, Default)]
pub struct InMemoryGameDistributionStore {
inner: Arc<Mutex<GameDistributionState>>,
}
#[derive(Debug, Default)]
struct GameDistributionState {
games: HashMap<String, GameSnapshot>,
versions: HashMap<String, GameVersionSnapshot>,
idempotency: HashMap<IdempotencyScope, IdempotencyRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct IdempotencyScope {
principal_id: String,
action: GameDistributionAction,
key: String,
}
#[derive(Clone, Debug)]
struct IdempotencyRecord {
request_digest: String,
outcome: StoredOutcome,
}
#[derive(Clone, Debug)]
enum StoredOutcome {
Game(GameSnapshot),
Version(GameVersionSnapshot),
Publication {
game: GameSnapshot,
version: GameVersionSnapshot,
},
}
#[derive(Clone, Debug)]
pub struct GameDistributionService {
store: InMemoryGameDistributionStore,
}
impl GameDistributionService {
pub fn new(store: InMemoryGameDistributionStore) -> Self {
Self { store }
}
pub fn store(&self) -> InMemoryGameDistributionStore {
self.store.clone()
}
pub fn create_game(
&self,
input: CreateGameInput,
) -> Result<GameOperationResult<GameSnapshot>, GameDistributionError> {
let game_id = required(input.game_id, GameDistributionFieldError::MissingGameId)?;
let owner_user_id = required(
input.owner_user_id,
GameDistributionFieldError::MissingOwnerUserId,
)?;
let idem = normalize_idempotency(input.idempotency)?;
let scope = scope(&owner_user_id, GameDistributionAction::CreateGame, &idem);
let mut state = lock(&self.store)?;
if let Some(outcome) = replay_outcome(&state, &scope, &idem.request_digest)? {
return game_result(outcome, true);
}
if state.games.contains_key(&game_id) {
return Err(GameDistributionError::GameAlreadyExists);
}
let snapshot = GameSnapshot {
game_id: game_id.clone(),
owner_user_id,
publication_revision: 0,
active_version_id: None,
visibility: GameVisibility::Unpublished,
created_at_micros: input.created_at_micros,
updated_at_micros: input.created_at_micros,
};
state.games.insert(game_id, snapshot.clone());
remember(
&mut state,
scope,
idem.request_digest,
StoredOutcome::Game(snapshot.clone()),
);
Ok(GameOperationResult {
snapshot,
replayed: false,
})
}
pub fn create_version(
&self,
input: CreateVersionInput,
) -> Result<GameOperationResult<GameVersionSnapshot>, GameDistributionError> {
let game_id = required(input.game_id, GameDistributionFieldError::MissingGameId)?;
let owner_user_id = required(
input.owner_user_id,
GameDistributionFieldError::MissingOwnerUserId,
)?;
let version_id = required(
input.version_id,
GameDistributionFieldError::MissingVersionId,
)?;
let package_sha256 = normalize_package_digest(&input.package_sha256)?;
validate_package_bytes(input.package_bytes)?;
let idem = normalize_idempotency(input.idempotency)?;
let scope = scope(&owner_user_id, GameDistributionAction::CreateVersion, &idem);
let mut state = lock(&self.store)?;
if let Some(outcome) = replay_outcome(&state, &scope, &idem.request_digest)? {
return version_result(outcome, true);
}
let game = state
.games
.get(&game_id)
.ok_or(GameDistributionError::GameNotFound)?;
ensure_owner(game, &owner_user_id)?;
if game.visibility == GameVisibility::Suspended {
return Err(GameDistributionError::GameSuspended);
}
if state.versions.contains_key(&version_id) {
return Err(GameDistributionError::VersionAlreadyExists);
}
let version_number = state
.versions
.values()
.filter(|version| version.game_id == game_id)
.map(|version| version.version_number)
.max()
.unwrap_or(0)
.saturating_add(1);
let snapshot = GameVersionSnapshot {
version_id: version_id.clone(),
game_id,
version_number,
package_sha256,
package_bytes: input.package_bytes,
status: GameVersionStatus::AwaitingUpload,
review_reason: None,
created_at_micros: input.created_at_micros,
updated_at_micros: input.created_at_micros,
};
state.versions.insert(version_id, snapshot.clone());
remember(
&mut state,
scope,
idem.request_digest,
StoredOutcome::Version(snapshot.clone()),
);
Ok(GameOperationResult {
snapshot,
replayed: false,
})
}
pub fn confirm_package(
&self,
owner_user_id: &str,
version_id: &str,
package_sha256: &str,
package_bytes: u64,
idempotency: IdempotencyRequest,
at_micros: i64,
) -> Result<GameOperationResult<GameVersionSnapshot>, GameDistributionError> {
let owner_user_id = required(
owner_user_id.to_string(),
GameDistributionFieldError::MissingOwnerUserId,
)?;
let version_id = required(
version_id.to_string(),
GameDistributionFieldError::MissingVersionId,
)?;
let package_sha256 = normalize_package_digest(package_sha256)?;
validate_package_bytes(package_bytes)?;
let idem = normalize_idempotency(idempotency)?;
let scope = scope(
&owner_user_id,
GameDistributionAction::ConfirmPackage,
&idem,
);
let mut state = lock(&self.store)?;
if let Some(outcome) = replay_outcome(&state, &scope, &idem.request_digest)? {
return version_result(outcome, true);
}
let game_id = state
.versions
.get(&version_id)
.ok_or(GameDistributionError::VersionNotFound)?
.game_id
.clone();
let game = state
.games
.get(&game_id)
.ok_or(GameDistributionError::GameNotFound)?;
ensure_owner(game, &owner_user_id)?;
let version = state
.versions
.get_mut(&version_id)
.ok_or(GameDistributionError::VersionNotFound)?;
if version.package_sha256 != package_sha256 || version.package_bytes != package_bytes {
return Err(GameDistributionError::PackageMismatch);
}
if version.status != GameVersionStatus::AwaitingUpload
&& version.status != GameVersionStatus::UploadFailed
{
if version.status == GameVersionStatus::Uploaded {
let snapshot = version.clone();
remember(
&mut state,
scope,
idem.request_digest,
StoredOutcome::Version(snapshot.clone()),
);
return Ok(GameOperationResult {
snapshot,
replayed: false,
});
}
return Err(invalid_transition(
version.status,
GameVersionStatus::Uploaded,
));
}
version.status = GameVersionStatus::Uploaded;
version.updated_at_micros = at_micros;
let snapshot = version.clone();
remember(
&mut state,
scope,
idem.request_digest,
StoredOutcome::Version(snapshot.clone()),
);
Ok(GameOperationResult {
snapshot,
replayed: false,
})
}
pub fn fail_upload(
&self,
owner_user_id: &str,
version_id: &str,
idempotency: IdempotencyRequest,
at_micros: i64,
) -> Result<GameOperationResult<GameVersionSnapshot>, GameDistributionError> {
self.transition_owned_version(
owner_user_id,
version_id,
idempotency,
GameDistributionAction::FailUpload,
&[GameVersionStatus::AwaitingUpload],
GameVersionStatus::UploadFailed,
at_micros,
None,
)
}
pub fn submit_version(
&self,
owner_user_id: &str,
version_id: &str,
idempotency: IdempotencyRequest,
at_micros: i64,
) -> Result<GameOperationResult<GameVersionSnapshot>, GameDistributionError> {
self.transition_owned_version(
owner_user_id,
version_id,
idempotency,
GameDistributionAction::SubmitVersion,
&[
GameVersionStatus::Uploaded,
GameVersionStatus::ValidationFailed,
],
GameVersionStatus::Validating,
at_micros,
None,
)
}
pub fn pass_validation(
&self,
owner_user_id: &str,
version_id: &str,
idempotency: IdempotencyRequest,
at_micros: i64,
) -> Result<GameOperationResult<GameVersionSnapshot>, GameDistributionError> {
self.transition_owned_version(
owner_user_id,
version_id,
idempotency,
GameDistributionAction::PassValidation,
&[GameVersionStatus::Validating],
GameVersionStatus::PendingReview,
at_micros,
None,
)
}
pub fn fail_validation(
&self,
owner_user_id: &str,
version_id: &str,
reason: String,
idempotency: IdempotencyRequest,
at_micros: i64,
) -> Result<GameOperationResult<GameVersionSnapshot>, GameDistributionError> {
let reason = required(reason, GameDistributionFieldError::MissingReviewReason)?;
self.transition_owned_version(
owner_user_id,
version_id,
idempotency,
GameDistributionAction::FailValidation,
&[GameVersionStatus::Validating],
GameVersionStatus::ValidationFailed,
at_micros,
Some(reason),
)
}
pub fn cancel_version(
&self,
owner_user_id: &str,
version_id: &str,
idempotency: IdempotencyRequest,
at_micros: i64,
) -> Result<GameOperationResult<GameVersionSnapshot>, GameDistributionError> {
let owner_user_id = required(
owner_user_id.to_string(),
GameDistributionFieldError::MissingOwnerUserId,
)?;
let version_id = required(
version_id.to_string(),
GameDistributionFieldError::MissingVersionId,
)?;
let idem = normalize_idempotency(idempotency)?;
let scope = scope(&owner_user_id, GameDistributionAction::CancelVersion, &idem);
let mut state = lock(&self.store)?;
if let Some(outcome) = replay_outcome(&state, &scope, &idem.request_digest)? {
return version_result(outcome, true);
}
let game_id = state
.versions
.get(&version_id)
.ok_or(GameDistributionError::VersionNotFound)?
.game_id
.clone();
let game = state
.games
.get(&game_id)
.ok_or(GameDistributionError::GameNotFound)?;
ensure_owner(game, &owner_user_id)?;
let version = state
.versions
.get_mut(&version_id)
.ok_or(GameDistributionError::VersionNotFound)?;
if !version.status.can_cancel() {
return Err(invalid_transition(
version.status,
GameVersionStatus::Cancelled,
));
}
version.status = GameVersionStatus::Cancelled;
version.updated_at_micros = at_micros;
let snapshot = version.clone();
remember(
&mut state,
scope,
idem.request_digest,
StoredOutcome::Version(snapshot.clone()),
);
Ok(GameOperationResult {
snapshot,
replayed: false,
})
}
pub fn review_version(
&self,
reviewer_user_id: &str,
version_id: &str,
decision: ReviewDecision,
idempotency: IdempotencyRequest,
at_micros: i64,
) -> Result<PublicationOperationResult, GameDistributionError> {
let reviewer_user_id = required(
reviewer_user_id.to_string(),
GameDistributionFieldError::MissingActorUserId,
)?;
let version_id = required(
version_id.to_string(),
GameDistributionFieldError::MissingVersionId,
)?;
let idem = normalize_idempotency(idempotency)?;
let action = match decision {
ReviewDecision::Approve { .. } => GameDistributionAction::ApproveVersion,
ReviewDecision::Reject { .. } => GameDistributionAction::RejectVersion,
};
let scope = scope(&reviewer_user_id, action, &idem);
let mut state = lock(&self.store)?;
if let Some(outcome) = replay_outcome(&state, &scope, &idem.request_digest)? {
return publication_result(outcome, true);
}
let (expected_revision, reject_reason) = match decision {
ReviewDecision::Approve {
expected_publication_revision,
} => (expected_publication_revision, None),
ReviewDecision::Reject {
expected_publication_revision,
reason,
} => (
expected_publication_revision,
Some(required(
reason,
GameDistributionFieldError::MissingReviewReason,
)?),
),
};
let version = state
.versions
.get(&version_id)
.ok_or(GameDistributionError::VersionNotFound)?
.clone();
let game = state
.games
.get(&version.game_id)
.ok_or(GameDistributionError::GameNotFound)?
.clone();
if game.publication_revision != expected_revision {
return Err(GameDistributionError::PublicationConflict {
expected: expected_revision,
actual: game.publication_revision,
});
}
if version.status != GameVersionStatus::PendingReview {
return Err(GameDistributionError::ReviewActionNotAllowed);
}
if game.visibility == GameVisibility::Suspended {
return Err(GameDistributionError::GameSuspended);
}
let mut next_game = game;
let mut next_version = version;
next_game.publication_revision = next_game.publication_revision.saturating_add(1);
next_game.updated_at_micros = at_micros;
next_version.updated_at_micros = at_micros;
match reject_reason {
Some(reason) => {
next_version.status = GameVersionStatus::Rejected;
next_version.review_reason = Some(reason);
}
None => {
next_version.status = GameVersionStatus::Published;
next_version.review_reason = None;
next_game.visibility = GameVisibility::Published;
next_game.active_version_id = Some(next_version.version_id.clone());
}
}
state
.games
.insert(next_game.game_id.clone(), next_game.clone());
state
.versions
.insert(next_version.version_id.clone(), next_version.clone());
remember(
&mut state,
scope,
idem.request_digest,
StoredOutcome::Publication {
game: next_game.clone(),
version: next_version.clone(),
},
);
Ok(PublicationOperationResult {
game: next_game,
version: next_version,
replayed: false,
})
}
pub fn unpublish_game(
&self,
owner_user_id: &str,
game_id: &str,
expected_publication_revision: u64,
idempotency: IdempotencyRequest,
at_micros: i64,
) -> Result<GameOperationResult<GameSnapshot>, GameDistributionError> {
self.mutate_game_visibility(
owner_user_id,
game_id,
expected_publication_revision,
idempotency,
GameDistributionAction::UnpublishGame,
GameVisibility::Unpublished,
at_micros,
)
}
pub fn suspend_game(
&self,
actor_user_id: &str,
game_id: &str,
expected_publication_revision: u64,
idempotency: IdempotencyRequest,
at_micros: i64,
) -> Result<GameOperationResult<GameSnapshot>, GameDistributionError> {
self.mutate_game_visibility(
actor_user_id,
game_id,
expected_publication_revision,
idempotency,
GameDistributionAction::SuspendGame,
GameVisibility::Suspended,
at_micros,
)
}
pub fn get_game(&self, game_id: &str) -> Result<GameSnapshot, GameDistributionError> {
let game_id = required(
game_id.to_string(),
GameDistributionFieldError::MissingGameId,
)?;
let state = lock(&self.store)?;
state
.games
.get(&game_id)
.cloned()
.ok_or(GameDistributionError::GameNotFound)
}
pub fn get_version(
&self,
version_id: &str,
) -> Result<GameVersionSnapshot, GameDistributionError> {
let version_id = required(
version_id.to_string(),
GameDistributionFieldError::MissingVersionId,
)?;
let state = lock(&self.store)?;
state
.versions
.get(&version_id)
.cloned()
.ok_or(GameDistributionError::VersionNotFound)
}
fn transition_owned_version(
&self,
owner_user_id: &str,
version_id: &str,
idempotency: IdempotencyRequest,
action: GameDistributionAction,
from: &[GameVersionStatus],
to: GameVersionStatus,
at_micros: i64,
reason: Option<String>,
) -> Result<GameOperationResult<GameVersionSnapshot>, GameDistributionError> {
let owner_user_id = required(
owner_user_id.to_string(),
GameDistributionFieldError::MissingOwnerUserId,
)?;
let version_id = required(
version_id.to_string(),
GameDistributionFieldError::MissingVersionId,
)?;
let idem = normalize_idempotency(idempotency)?;
let scope = scope(&owner_user_id, action, &idem);
let mut state = lock(&self.store)?;
if let Some(outcome) = replay_outcome(&state, &scope, &idem.request_digest)? {
return version_result(outcome, true);
}
let game_id = state
.versions
.get(&version_id)
.ok_or(GameDistributionError::VersionNotFound)?
.game_id
.clone();
let game = state
.games
.get(&game_id)
.ok_or(GameDistributionError::GameNotFound)?;
ensure_owner(game, &owner_user_id)?;
let version = state
.versions
.get_mut(&version_id)
.ok_or(GameDistributionError::VersionNotFound)?;
if !from.contains(&version.status) {
return Err(invalid_transition(version.status, to));
}
version.status = to;
version.review_reason = reason;
version.updated_at_micros = at_micros;
let snapshot = version.clone();
remember(
&mut state,
scope,
idem.request_digest,
StoredOutcome::Version(snapshot.clone()),
);
Ok(GameOperationResult {
snapshot,
replayed: false,
})
}
fn mutate_game_visibility(
&self,
actor_user_id: &str,
game_id: &str,
expected_publication_revision: u64,
idempotency: IdempotencyRequest,
action: GameDistributionAction,
visibility: GameVisibility,
at_micros: i64,
) -> Result<GameOperationResult<GameSnapshot>, GameDistributionError> {
let actor_user_id = required(
actor_user_id.to_string(),
GameDistributionFieldError::MissingActorUserId,
)?;
let game_id = required(
game_id.to_string(),
GameDistributionFieldError::MissingGameId,
)?;
let idem = normalize_idempotency(idempotency)?;
let scope = scope(&actor_user_id, action, &idem);
let mut state = lock(&self.store)?;
if let Some(outcome) = replay_outcome(&state, &scope, &idem.request_digest)? {
return game_result(outcome, true);
}
let game = state
.games
.get(&game_id)
.ok_or(GameDistributionError::GameNotFound)?
.clone();
if action == GameDistributionAction::UnpublishGame {
ensure_owner(&game, &actor_user_id)?;
if game.visibility != GameVisibility::Published {
return Err(GameDistributionError::GameNotPublished);
}
}
if game.publication_revision != expected_publication_revision {
return Err(GameDistributionError::PublicationConflict {
expected: expected_publication_revision,
actual: game.publication_revision,
});
}
if action == GameDistributionAction::SuspendGame
&& game.visibility == GameVisibility::Suspended
{
return Err(GameDistributionError::GameSuspended);
}
let active_version_id = game.active_version_id.clone();
let mut next_game = game;
next_game.visibility = visibility;
next_game.publication_revision = next_game.publication_revision.saturating_add(1);
next_game.updated_at_micros = at_micros;
if visibility == GameVisibility::Unpublished {
next_game.active_version_id = None;
if let Some(version_id) = active_version_id {
if let Some(version) = state.versions.get_mut(&version_id) {
if version.status == GameVersionStatus::Published {
version.status = GameVersionStatus::Revoked;
version.updated_at_micros = at_micros;
}
}
}
}
state
.games
.insert(next_game.game_id.clone(), next_game.clone());
remember(
&mut state,
scope,
idem.request_digest,
StoredOutcome::Game(next_game.clone()),
);
Ok(GameOperationResult {
snapshot: next_game,
replayed: false,
})
}
}
fn required(
value: String,
error: GameDistributionFieldError,
) -> Result<String, GameDistributionError> {
normalize_id(value).ok_or(GameDistributionError::Field(error))
}
fn normalize_idempotency(
value: IdempotencyRequest,
) -> Result<IdempotencyRequest, GameDistributionError> {
value.validate().map_err(GameDistributionError::Field)?;
Ok(IdempotencyRequest {
key: normalize_id(value.key).ok_or(GameDistributionError::Field(
GameDistributionFieldError::MissingIdempotencyKey,
))?,
request_digest: normalize_id(value.request_digest).ok_or(GameDistributionError::Field(
GameDistributionFieldError::MissingRequestDigest,
))?,
})
}
fn normalize_package_digest(value: &str) -> Result<String, GameDistributionError> {
let normalized = value.trim().to_ascii_lowercase();
if normalized.len() != 64 || !normalized.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(GameDistributionError::Field(
GameDistributionFieldError::InvalidPackageDigest,
));
}
Ok(normalized)
}
fn validate_package_bytes(value: u64) -> Result<(), GameDistributionError> {
if value == 0 {
Err(GameDistributionError::Field(
GameDistributionFieldError::InvalidPackageBytes,
))
} else {
Ok(())
}
}
fn scope(
principal_id: &str,
action: GameDistributionAction,
idem: &IdempotencyRequest,
) -> IdempotencyScope {
IdempotencyScope {
principal_id: principal_id.to_string(),
action,
key: idem.key.clone(),
}
}
fn lock(
store: &InMemoryGameDistributionStore,
) -> Result<std::sync::MutexGuard<'_, GameDistributionState>, GameDistributionError> {
store
.inner
.lock()
.map_err(|_| GameDistributionError::IdempotencyOutcomeMismatch)
}
fn replay_outcome(
state: &GameDistributionState,
scope: &IdempotencyScope,
request_digest: &str,
) -> Result<Option<StoredOutcome>, GameDistributionError> {
let Some(record) = state.idempotency.get(scope) else {
return Ok(None);
};
if record.request_digest != request_digest {
return Err(GameDistributionError::IdempotencyConflict);
}
Ok(Some(record.outcome.clone()))
}
fn remember(
state: &mut GameDistributionState,
scope: IdempotencyScope,
request_digest: String,
outcome: StoredOutcome,
) {
state.idempotency.insert(
scope,
IdempotencyRecord {
request_digest,
outcome,
},
);
}
fn game_result(
outcome: StoredOutcome,
replayed: bool,
) -> Result<GameOperationResult<GameSnapshot>, GameDistributionError> {
match outcome {
StoredOutcome::Game(snapshot) => Ok(GameOperationResult { snapshot, replayed }),
_ => Err(GameDistributionError::IdempotencyOutcomeMismatch),
}
}
fn version_result(
outcome: StoredOutcome,
replayed: bool,
) -> Result<GameOperationResult<GameVersionSnapshot>, GameDistributionError> {
match outcome {
StoredOutcome::Version(snapshot) => Ok(GameOperationResult { snapshot, replayed }),
_ => Err(GameDistributionError::IdempotencyOutcomeMismatch),
}
}
fn publication_result(
outcome: StoredOutcome,
replayed: bool,
) -> Result<PublicationOperationResult, GameDistributionError> {
match outcome {
StoredOutcome::Publication { game, version } => Ok(PublicationOperationResult {
game,
version,
replayed,
}),
_ => Err(GameDistributionError::IdempotencyOutcomeMismatch),
}
}
fn ensure_owner(game: &GameSnapshot, owner_user_id: &str) -> Result<(), GameDistributionError> {
if game.owner_user_id == owner_user_id {
Ok(())
} else {
Err(GameDistributionError::Forbidden)
}
}
fn invalid_transition(from: GameVersionStatus, to: GameVersionStatus) -> GameDistributionError {
GameDistributionError::InvalidVersionTransition { from, to }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
CreateGameInput, CreateVersionInput, IdempotencyRequest, ReviewDecision,
compute_request_digest,
};
fn idem(key: &str, body: &str) -> IdempotencyRequest {
IdempotencyRequest {
key: key.to_string(),
request_digest: compute_request_digest(body),
}
}
fn service() -> GameDistributionService {
GameDistributionService::new(InMemoryGameDistributionStore::default())
}
fn create_game(service: &GameDistributionService) -> GameSnapshot {
service
.create_game(CreateGameInput {
game_id: "game-1".to_string(),
owner_user_id: "owner-1".to_string(),
idempotency: idem("create-game-1", "game-1"),
created_at_micros: 1,
})
.expect("game should be created")
.snapshot
}
fn create_version(service: &GameDistributionService) -> GameVersionSnapshot {
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(),
package_sha256: "a".repeat(64),
package_bytes: 42,
idempotency: idem("create-version-1", "version-1"),
created_at_micros: 2,
})
.expect("version should be created")
.snapshot
}
#[test]
fn idempotency_replays_same_snapshot_and_rejects_digest_conflict() {
let service = service();
let first = service
.create_game(CreateGameInput {
game_id: "game-1".to_string(),
owner_user_id: "owner-1".to_string(),
idempotency: idem("same-key", "first"),
created_at_micros: 1,
})
.expect("first create should succeed");
let replay = service
.create_game(CreateGameInput {
game_id: "game-2".to_string(),
owner_user_id: "owner-1".to_string(),
idempotency: idem("same-key", "first"),
created_at_micros: 99,
})
.expect("same request should replay");
assert!(replay.replayed);
assert_eq!(replay.snapshot, first.snapshot);
let conflict = service
.create_game(CreateGameInput {
game_id: "game-3".to_string(),
owner_user_id: "owner-1".to_string(),
idempotency: idem("same-key", "different"),
created_at_micros: 3,
})
.expect_err("same key with another digest must fail");
assert_eq!(conflict, GameDistributionError::IdempotencyConflict);
}
#[test]
fn owner_is_required_for_version_and_package_mutations() {
let service = service();
create_version(&service);
let error = service
.confirm_package(
"other-owner",
"version-1",
&"a".repeat(64),
42,
idem("upload-1", "upload"),
3,
)
.expect_err("other owner must be denied");
assert_eq!(error, GameDistributionError::Forbidden);
}
#[test]
fn version_state_machine_reaches_pending_review_only_after_package_validation() {
let service = service();
create_version(&service);
let invalid = service
.pass_validation(
"owner-1",
"version-1",
idem("pass-before-submit", "pass"),
3,
)
.expect_err("validation cannot start before upload");
assert_eq!(
invalid,
GameDistributionError::InvalidVersionTransition {
from: GameVersionStatus::AwaitingUpload,
to: GameVersionStatus::PendingReview
}
);
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");
let pending = service
.pass_validation("owner-1", "version-1", idem("pass-1", "pass"), 5)
.expect("validation should pass");
assert_eq!(pending.snapshot.status, GameVersionStatus::PendingReview);
}
#[test]
fn validation_failure_can_retry_same_confirmed_package() {
let service = service();
create_version(&service);
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
.fail_validation(
"owner-1",
"version-1",
"校验器暂时失败".to_string(),
idem("fail-validation-1", "fail"),
5,
)
.expect("validation failure should be recorded");
let retried = service
.submit_version("owner-1", "version-1", idem("submit-2", "retry"), 6)
.expect("same confirmed package should be retryable");
assert_eq!(retried.snapshot.status, GameVersionStatus::Validating);
}
#[test]
fn publication_cas_rejects_stale_review_and_unpublish() {
let service = service();
create_version(&service);
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");
let published = service
.review_version(
"reviewer-1",
"version-1",
ReviewDecision::Approve {
expected_publication_revision: 0,
},
idem("review-1", "approve"),
6,
)
.expect("review should publish");
assert_eq!(published.game.publication_revision, 1);
assert_eq!(published.game.visibility, GameVisibility::Published);
let stale = service
.unpublish_game(
"owner-1",
"game-1",
0,
idem("unpublish-stale", "unpublish"),
7,
)
.expect_err("stale publication revision must fail");
assert_eq!(
stale,
GameDistributionError::PublicationConflict {
expected: 0,
actual: 1
}
);
let unpublished = service
.unpublish_game("owner-1", "game-1", 1, idem("unpublish-1", "unpublish"), 8)
.expect("current revision should unpublish");
assert_eq!(unpublished.snapshot.visibility, GameVisibility::Unpublished);
assert_eq!(
service
.get_version("version-1")
.expect("version should exist")
.status,
GameVersionStatus::Revoked
);
}
#[test]
fn rejected_review_preserves_reason_and_advances_publication_revision() {
let service = service();
create_version(&service);
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");
let rejected = service
.review_version(
"reviewer-1",
"version-1",
ReviewDecision::Reject {
expected_publication_revision: 0,
reason: "需要修复资源".to_string(),
},
idem("review-1", "reject"),
6,
)
.expect("review rejection should succeed");
assert_eq!(rejected.version.status, GameVersionStatus::Rejected);
assert_eq!(
rejected.version.review_reason.as_deref(),
Some("需要修复资源")
);
assert_eq!(rejected.game.publication_revision, 1);
assert_eq!(rejected.game.visibility, GameVisibility::Unpublished);
}
}