补齐游戏分发封面与截图的冻结、上传与展示

- 服务端:游戏表追加可空 cover_object_key / screenshots_json,版本表追加可空 metadata_json,创建版本时校验必需封面、最多 6 张截图与图片素材归属,并从素材记录派生对象键生成冻结资料。
- 服务端:审核通过时把冻结资料整体生效到游戏行;只有已公开且存在有效活动版本的游戏,其封面/截图素材才在 /api/assets/read-url 获得匿名读授权。
- 服务端:作者与管理员回读版本时追加 frozenMetadata,带回封面与截图素材 ID 供续发复用,公开投影仍只暴露对象键。
- 网页:/games/publish 新增「封面与截图」区,复用平台图片直传与 confirm 通道,缺封面或截图超量在发请求前拦截,更新版本时按冻结资料回填且不要求重新上传。
- 网页:游戏广场卡片与详情页 hero 换签展示真实封面,详情页新增可点击的截图缩略图条,无素材或换签失败时静默回退原有渐变占位。
- AGC:发布面板支持封面必选与截图最多 6 张,新增素材直传服务并在创建游戏前拦截缺封面,同一文件重复提交复用素材 ID。
- AGC:http 能力作用域新增阿里云 OSS 直传地址,客户端同时校验直传目标必须是平台素材存储。
- 契约与文档:同步 shared-contracts、packages/shared 契约,更新 SpacetimeDB 迁移注释、后端架构、玩法链路与实施计划文档,并补齐网页端与 AGC 定向测试。
This commit is contained in:
2026-09-20 22:19:50 +08:00
parent 789aef4ce5
commit 4a2b270714
34 changed files with 2439 additions and 13 deletions
@@ -50,6 +50,8 @@ pub(crate) const MAX_PACKAGE_REQUEST_BODY_BYTES: usize = MAX_PACKAGE_BYTES as us
const MAX_LIST_LIMIT: u32 = 48;
const MAX_IDEMPOTENCY_KEY_CHARS: usize = 128;
const MAX_PACKAGE_MANIFEST_JSON_BYTES: usize = 2 * 1024 * 1024;
/// 首版截图上限,与主规范冻结口径一致。
const MAX_GAME_SCREENSHOTS: usize = 6;
const GAME_DISTRIBUTION_OBJECT_PREFIX: &str = "agc/project-snapshots/v1/game-distribution/";
const GAME_DISTRIBUTION_PUBLISHED_STATUS: &str = "published";
/// 发行包 PUT 的尝试次数与退避,口径与 `platform-oss` 的可重试分类一致。
@@ -521,8 +523,11 @@ async fn create_version(
validate_version_declaration(&payload)?;
let now = now_micros();
let version_id = format!("gamever_{}", Uuid::new_v4().simple());
let metadata_json =
resolve_version_metadata_json(&state, auth.claims().user_id(), &payload.game_metadata)
.await?;
let request_digest = compute_request_digest(
&serde_json::to_vec(&(game_id.as_str(), &payload))
&serde_json::to_vec(&(game_id.as_str(), &payload, metadata_json.as_str()))
.map_err(|error| internal(error.to_string()))?,
);
let version = state
@@ -532,6 +537,7 @@ async fn create_version(
game_id,
owner_user_id: auth.claims().user_id().to_string(),
version_id,
metadata_json,
package_sha256: payload.package_sha256,
package_bytes: payload.package_bytes,
package_file_count: payload.package_file_count,
@@ -1252,6 +1258,25 @@ fn validate_game_metadata(payload: &GameDistributionCreateGameRequest) -> Result
if payload.device_support.mobile && !payload.device_support.touch {
return Err(bad_request("声明支持移动端时必须支持触控"));
}
if payload
.cover_asset_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
{
return Err(bad_request("发布游戏必须提供封面"));
}
if payload.screenshots.len() > MAX_GAME_SCREENSHOTS {
return Err(bad_request("游戏截图最多 6 张"));
}
if payload
.screenshots
.iter()
.any(|screenshot| screenshot.trim().is_empty())
{
return Err(bad_request("游戏截图素材 ID 不能为空"));
}
Ok(())
}
@@ -1331,6 +1356,14 @@ fn public_game_payload(game: GameDistributionPublicGameRecord) -> Value {
fn game_payload(game: &GameDistributionGameRecord) -> Value {
let tags = serde_json::from_str::<Vec<String>>(&game.tags_json).unwrap_or_default();
let screenshots = game
.screenshots_json
.as_deref()
.and_then(|json| serde_json::from_str::<Vec<FrozenGameScreenshot>>(json).ok())
.unwrap_or_default()
.into_iter()
.map(|screenshot| screenshot.object_key)
.collect::<Vec<_>>();
let input_modes =
serde_json::from_str::<Vec<GameDistributionInputMode>>(&game.input_modes_json)
.unwrap_or_default();
@@ -1343,6 +1376,8 @@ fn game_payload(game: &GameDistributionGameRecord) -> Value {
"tags": tags,
"coverColor": "#F3E4D0",
"icon": "🎮",
"coverObjectKey": game.cover_object_key,
"screenshots": screenshots,
"author": { "id": game.owner_user_id, "name": game.author_name.as_deref().unwrap_or("创作者"), "avatarUrl": game.author_avatar_url },
"deviceSupport": { "desktop": game.device_support_desktop, "mobile": game.device_support_mobile, "touch": game.device_support_touch },
"inputModes": input_modes,
@@ -1416,6 +1451,94 @@ async fn ensure_publish_enabled(state: &AppState, user_id: Option<&str>) -> Resu
}
}
/// 冻结资料快照里的截图素材。
#[derive(Debug, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct FrozenGameScreenshot {
asset_id: String,
object_key: String,
}
/// 校验封面/截图素材归属并生成版本冻结资料 JSON。
///
/// 对象键由服务端从素材记录派生,客户端只能提供素材 ID;素材必须属于当前作者且是图片。
async fn resolve_version_metadata_json(
state: &AppState,
owner_user_id: &str,
metadata: &GameDistributionCreateGameRequest,
) -> Result<String, AppError> {
let cover_asset_id = metadata
.cover_asset_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| bad_request("发布游戏必须提供封面"))?
.to_string();
let cover_object_key =
resolve_owned_image_object_key(state, owner_user_id, cover_asset_id.as_str()).await?;
let mut screenshots = Vec::with_capacity(metadata.screenshots.len());
for asset_id in &metadata.screenshots {
let asset_id = asset_id.trim();
if asset_id.is_empty() {
return Err(bad_request("游戏截图素材 ID 不能为空"));
}
let object_key = resolve_owned_image_object_key(state, owner_user_id, asset_id).await?;
screenshots.push(FrozenGameScreenshot {
asset_id: asset_id.to_string(),
object_key,
});
}
let tags = metadata
.tags
.iter()
.map(|tag| tag.trim())
.filter(|tag| !tag.is_empty())
.collect::<Vec<_>>();
let snapshot = json!({
"title": metadata.title.trim(),
"summary": metadata.summary.trim(),
"description": metadata
.description
.clone()
.unwrap_or_else(|| metadata.summary.trim().to_string()),
"category": metadata.category,
"tags": tags,
"coverAssetId": cover_asset_id,
"coverObjectKey": cover_object_key,
"screenshots": screenshots,
"deviceSupport": {
"desktop": metadata.device_support.desktop,
"mobile": metadata.device_support.mobile,
"touch": metadata.device_support.touch,
},
"inputModes": metadata.input_modes,
"orientation": metadata.orientation,
});
serde_json::to_string(&snapshot).map_err(|error| internal(error.to_string()))
}
async fn resolve_owned_image_object_key(
state: &AppState,
owner_user_id: &str,
asset_object_id: &str,
) -> Result<String, AppError> {
let asset = state
.spacetime_client()
.get_asset_object(asset_object_id.to_string())
.await
.map_err(map_spacetime_error)?
.ok_or_else(|| bad_request("封面或截图素材不存在"))?;
if asset.owner_user_id.as_deref() != Some(owner_user_id) {
return Err(AppError::from_status(StatusCode::FORBIDDEN)
.with_message("封面或截图素材不属于当前账号"));
}
let content_type = asset.content_type.as_deref().unwrap_or("");
if !content_type.starts_with("image/") {
return Err(bad_request("封面和截图必须是图片素材"));
}
Ok(asset.object_key)
}
/// 读取当前主体名下的版本;未知版本和别人的版本都按不可见处理(404)。
async fn load_owner_version_or_404(
state: &AppState,
@@ -1436,17 +1559,28 @@ async fn load_owner_version_or_404(
}
}
/// 版本私有投影:在通用私有字段上追加客户端恢复动作。
/// 版本私有投影:在通用私有字段上追加客户端恢复动作与随版本冻结的资料快照。
///
/// 该投影只用于作者本人与管理员回读,因此可以带上冻结资料里的素材 ID:作者更新游戏时
/// 复用同一批素材,不需要为了沿用封面重新上传一次;快照缺失(历史版本)时按空值返回。
fn version_detail_payload(
version: &GameDistributionVersionRecord,
game: &GameDistributionGameRecord,
) -> Value {
let mut payload = private_version_payload(version);
let frozen_metadata = version
.metadata_json
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| serde_json::from_str::<Value>(value).ok())
.unwrap_or(Value::Null);
if let Value::Object(ref mut object) = payload {
object.insert(
"recoveryAction".to_string(),
Value::String(recovery_action_for_status(version.status.as_str()).to_string()),
);
object.insert("frozenMetadata".to_string(), frozen_metadata);
}
json!({ "game": game_payload(game), "version": payload })
}
@@ -1548,6 +1682,7 @@ mod tests {
GameDistributionCreateGameRequest {
local_project_id: None,
title: "测试游戏".to_string(),
screenshots: Vec::new(),
summary: "用于验证发行合同".to_string(),
description: Some("描述".to_string()),
category: "益智".to_string(),
@@ -1575,6 +1710,137 @@ mod tests {
);
}
#[test]
fn metadata_requires_cover_and_limits_screenshots() {
let mut payload = metadata();
payload.cover_asset_id = None;
assert_eq!(
validate_game_metadata(&payload)
.expect_err("缺少封面必须被拒")
.status_code(),
StatusCode::BAD_REQUEST
);
let mut payload = metadata();
payload.cover_asset_id = Some("asset_cover".to_string());
payload.screenshots = (0..7).map(|index| format!("asset_{index}")).collect();
assert_eq!(
validate_game_metadata(&payload)
.expect_err("超过 6 张截图必须被拒")
.status_code(),
StatusCode::BAD_REQUEST
);
let mut payload = metadata();
payload.cover_asset_id = Some("asset_cover".to_string());
payload.screenshots = (0..6).map(|index| format!("asset_{index}")).collect();
validate_game_metadata(&payload).expect("封面 + 6 张截图应通过校验");
}
#[test]
fn public_payload_exposes_cover_and_screenshot_object_keys() {
let game = GameDistributionGameRecord {
game_id: "game_1".to_string(),
owner_user_id: "user_1".to_string(),
title: "封面游戏".to_string(),
summary: "摘要".to_string(),
description: "描述".to_string(),
category: "益智".to_string(),
tags_json: "[]".to_string(),
cover_asset_id: Some("asset_cover".to_string()),
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: "responsive".to_string(),
publication_revision: 1,
active_version_id: Some("version_1".to_string()),
visibility: "published".to_string(),
play_count: 0,
created_at: "2026-09-20T00:00:00Z".to_string(),
updated_at: "2026-09-20T00:00:00Z".to_string(),
cover_object_key: Some("generated/game-cover.png".to_string()),
screenshots_json: Some(
r#"[{"assetId":"asset_1","objectKey":"generated/shot-1.png"}]"#.to_string(),
),
};
let payload = game_payload(&game);
assert_eq!(
payload["coverObjectKey"],
Value::String("generated/game-cover.png".to_string())
);
assert_eq!(
payload["screenshots"][0],
Value::String("generated/shot-1.png".to_string())
);
}
#[test]
fn version_detail_payload_exposes_frozen_metadata_to_owner() {
let game = GameDistributionGameRecord {
game_id: "game_1".to_string(),
owner_user_id: "user_1".to_string(),
title: "封面游戏".to_string(),
summary: "摘要".to_string(),
description: "描述".to_string(),
category: "益智".to_string(),
tags_json: "[]".to_string(),
cover_asset_id: Some("asset_cover".to_string()),
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: "responsive".to_string(),
publication_revision: 1,
active_version_id: Some("version_1".to_string()),
visibility: "published".to_string(),
play_count: 0,
created_at: "2026-09-20T00:00:00Z".to_string(),
updated_at: "2026-09-20T00:00:00Z".to_string(),
cover_object_key: Some("generated/game-cover.png".to_string()),
screenshots_json: None,
};
let mut version = GameDistributionVersionRecord {
version_id: "version_2".to_string(),
game_id: "game_1".to_string(),
owner_user_id: "user_1".to_string(),
version_number: 2,
package_sha256: "a".repeat(64),
package_bytes: 1024,
package_file_count: 1,
package_entry_path: "index.html".to_string(),
status: "pending_review".to_string(),
review_reason: None,
entry_url: None,
publication_revision: 1,
created_at: "2026-09-20T00:00:00Z".to_string(),
updated_at: "2026-09-20T00:00:00Z".to_string(),
metadata_json: Some(
r#"{"coverAssetId":"asset_cover","coverObjectKey":"generated/game-cover.png","screenshots":[{"assetId":"asset_shot","objectKey":"generated/shot.png"}]}"#
.to_string(),
),
};
let payload = version_detail_payload(&version, &game);
assert_eq!(
payload["version"]["frozenMetadata"]["coverAssetId"],
Value::String("asset_cover".to_string())
);
assert_eq!(
payload["version"]["frozenMetadata"]["screenshots"][0]["assetId"],
Value::String("asset_shot".to_string())
);
// 历史版本没有冻结资料时按 null 返回,客户端必须按空值处理。
version.metadata_json = None;
let legacy_payload = version_detail_payload(&version, &game);
assert!(legacy_payload["version"]["frozenMetadata"].is_null());
}
#[test]
fn package_validation_errors_are_unprocessable() {
let error = map_package_error(ReleasePackageError::MissingEntry);