328ac31844
- 主站:游戏广场、详情、在线游玩、网页发布与作者中心,以及共享契约与客户端服务 - 后端:module-game-distribution 领域层、SpacetimeDB 表/迁移/绑定、spacetime-client facade、api-server 路由与发行网关 - 后台:游戏审核页(待审列表、通过/拒绝、安全下架) - AGC:发布面板、本地导出包读取命令与发布服务,含默认跳过的真实链路测试 - 运维:发行来源 nginx 模板与门禁、game-distribution:publish 灰度发布开关、OSS PutObject 受控重试 - 文档:主规范、里程碑与实施计划、决策日志与踩坑记录
1114 lines
37 KiB
Rust
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);
|
|
}
|
|
}
|