补齐游戏分发封面与截图的冻结、上传与展示
- 服务端:游戏表追加可空 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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user