游戏分发发布媒体改 objectKey 与 multipart 直传

- 契约:创建/编辑/建版请求去掉 `coverAssetId`,改为 `coverObjectKey` 与 `screenshots: (string|null)[]`,Rust 与 TS 同步。
- schema:`game_distribution_game` 删除 `cover_asset_id`;创建输入末尾追加 `cover_object_key`/`screenshots_json`;冻结资料截图改为 objectKey 数组并删除 `GameDistributionFrozenScreenshot`;同步迁移登记与生成绑定。
- 授权:删除按 assetId 的公开读授权,新增按 objectKey 的 `get_game_distribution_media_read_access` procedure;公开可见判定抽成 `game_distribution_game_is_publicly_visible`。
- api-server:三条写路径改收 `multipart/form-data`(`metadata` 文本 + `cover` + 重复 `screenshot`),新图直写项目快照桶、沿用图校验归属与数量;新增匿名 `GET /api/game-distribution/media/read-url` 与 `read-bytes`;幂等摘要覆盖图片字节。
- web:发布/编辑客户端改 FormData;封面与截图读取切到新读路由;移除发布前素材登记与 assetId 状态。
- AGC:发布命令先按 objectKey 做存在性校验以保持编译,multipart 直传改造留待 M3。
- 文档:实施计划与玩法链路合同章节同步新的 multipart 字段口径。
This commit is contained in:
2026-10-07 00:30:26 +08:00
parent 77126e06f9
commit d202c76099
34 changed files with 1406 additions and 547 deletions
@@ -1327,12 +1327,14 @@ pub(crate) async fn publish_local_project_game(
.filter(|value| !value.is_empty())
.ok_or_else(|| "发布需要本地项目标识,请重新打开项目后再试".to_string())?
.to_string();
// 发布媒体合同已切到 objectKey 沿用 + multipart 直传(M1);AGC 面板与直传改造在 M3,
// 当前先用 objectKey 做存在性校验,避免带旧 assetId 发出必然被拒的请求。
if metadata
.cover_asset_id
.cover_object_key
.as_deref()
.map(str::trim)
.unwrap_or_default()
.is_empty()
.filter(|value| !value.is_empty())
.is_none()
{
return Err("请先选择游戏封面(JPG/PNG/WebP),再发布到游戏广场".to_string());
}
@@ -1704,7 +1706,7 @@ mod tests {
description: None,
category: "益智".to_string(),
tags: vec![],
cover_asset_id: Some("asset-cover".to_string()),
cover_object_key: Some("generated/cover.png".to_string()),
screenshots: vec![],
device_support: shared_contracts::game_distribution::GameDistributionDeviceSupport {
desktop: true,
@@ -77,7 +77,7 @@
### 3.3 发布媒体直传
- 端点:`POST /api/game-distribution/games`、`POST /api/game-distribution/games/{game_id}/versions`、`PATCH /api/game-distribution/my-games/{game_id}` 改为 `multipart/form-data`。
- 字段:元数据 JSON 文本 part;`cover` 二进制 part(可选);`coverObjectKey` 文本 part(沿用线上封面时);`screenshots` 文本 JSON 数组(沿用线上的 objectKey 列表);截图二进制 parts(新增/替换,≤6 张)。
- 字段:`metadata` 文本 part(JSON,即原请求体的元数据部分,去掉媒体字段);`cover` 二进制 part(可选,出现时覆盖 `metadata.coverObjectKey`);`screenshot` 二进制 parts(可重复,按出现顺序消费)。`metadata.coverObjectKey` 为沿用线上封面时的 objectKey;`metadata.screenshots` 为 `(string|null)[]`,`string` 沿用该 objectKey、`null` 取下一个 `screenshot` part,数组顺序即最终截图顺序。
- 存储:新图直接落**项目快照桶** `agc/project-snapshots/v1/game-distribution/media/<gameId>/{cover|screenshot}-<uuid>.<ext>`(与发行包同桶、与素材库解耦,不建 `asset_object`);沿用图校验 objectKey 必须属于该游戏当前媒体;构建冻结资料(只含 objectKey)。
- 读:新增 `GET /api/game-distribution/media/read-url`,按 objectKey 返回签名读地址;判定 = 已发布且 active 的 game 且 objectKey 命中 `cover_object_key` / `screenshots_json`,**不经素材库 ACL**;需要同源字节时再补 `.../media/read-bytes`。签名与字节中转先抽公共函数,供 `/api/assets/read-url` 与新路由共用。
- 整包:`PUT .../versions/{version_id}/package` 及分片端点保持现状(raw zip / octet-stream)。
@@ -60,7 +60,7 @@
| Date | 2026-10-06 |
| 适用边界 | game-distribution 发布资料媒体(封面/截图)的上传与公开读取;发行包上传链路不变 |
- 写:`POST /api/game-distribution/games`、`POST /api/game-distribution/games/{gameId}/versions`、`PATCH /api/game-distribution/my-games/{gameId}` 接受 `multipart/form-data`:元数据 JSON 文本 part;`cover` 二进制 part(可选);`coverObjectKey` 文本 part(沿用线上封面);`screenshots` 文本 JSON 数组(沿用线上 objectKey);截图二进制 parts(≤6 张)。硬切,不再接受 `coverAssetId`。
- 写:`POST /api/game-distribution/games`、`POST /api/game-distribution/games/{gameId}/versions`、`PATCH /api/game-distribution/my-games/{gameId}` 接受 `multipart/form-data`:`metadata` 文本 part(JSON,含 `coverObjectKey` 与 `screenshots: (string|null)[]`);`cover` 二进制 part(可选,出现时覆盖 `coverObjectKey`);`screenshot` 二进制 parts(≤6 张,按 `null` 槽位顺序消费)。`string` 槽位表示沿用线上 objectKey。硬切,不再接受 `coverAssetId`。
- 存储:新图由服务端写入项目快照桶 `agc/project-snapshots/v1/game-distribution/media/<gameId>/{cover|screenshot}-<uuid>.<ext>`,**不建 `asset_object`**;冻结资料与游戏行只存 `coverObjectKey` / 截图 objectKey。
- 读:`GET /api/game-distribution/media/read-url`(匿名)按 objectKey 返回签名读地址;判定 = 已发布且 active 的 game 且 objectKey 命中其冻结媒体;需要同源字节时用 `.../media/read-bytes`。
- 幂等:`request_digest` 覆盖规范化元数据 + 新图字节 hash + 沿用 objectKey,保持重放语义。
@@ -124,7 +124,7 @@ export type GameDistributionGame = {
icon: string;
/**
* 公开封面的 OSS objectKey;未上传封面时为空,展示层需回退到 coverColor/icon 占位。
* 通过 `/api/assets/read-url` 换签名 URL 读取,不接受直连外链。
* 通过 `/api/game-distribution/media/read-url` 换签名 URL 读取,不接受直连外链。
*/
coverObjectKey?: string | null;
/** 公开截图的 OSS objectKey 列表,最多 6 张,随版本冻结。 */
@@ -169,9 +169,10 @@ export type GameDistributionCreateGameRequest = {
description?: string;
category: GameDistributionCategory;
tags?: string[];
coverAssetId?: string;
/** 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。 */
screenshots?: string[];
/** 沿用线上封面时给出既有对象键;同时携带 `cover` 二进制 part 时以 part 为准。 */
coverObjectKey?: string | null;
/** 与最终截图顺序一一对应:`string` 沿用线上 objectKey,`null` 取按序的 `screenshot` 二进制 part。 */
screenshots?: (string | null)[];
deviceSupport: GameDistributionDeviceSupport;
inputModes: GameDistributionInputMode[];
orientation: GameDistributionOrientation;
@@ -214,9 +215,10 @@ export type GameDistributionUpdateGameMetadataRequest = {
description?: string | null;
category: GameDistributionCategory;
tags?: string[];
coverAssetId?: string | null;
/** 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。 */
screenshots?: string[];
/** 沿用线上封面时给出既有对象键;同时携带 `cover` 二进制 part 时以 part 为准。 */
coverObjectKey?: string | null;
/** 与最终截图顺序一一对应:`string` 沿用线上 objectKey,`null` 取按序的 `screenshot` 二进制 part。 */
screenshots?: (string | null)[];
deviceSupport: GameDistributionDeviceSupport;
inputModes: GameDistributionInputMode[];
orientation: GameDistributionOrientation;
@@ -252,17 +254,10 @@ export type GameDistributionRecoveryAction =
| 'fix_package'
| 'fix_metadata';
/** 冻结资料里的截图:同时保留素材 ID(作者续发可复用)与对象键(展示换签用)。 */
export type GameDistributionFrozenScreenshot = {
assetId: string;
objectKey: string;
};
/**
* 随版本冻结的游戏资料快照。
*
* 只有作者本人(版本回读)与管理员(审核回读)会拿到素材 ID;公开投影只给对象键。
* 历史版本可能没有快照,读取方必须按空值处理。
* 发布媒体只保留对象键;历史版本可能没有快照,读取方必须按空值处理。
*/
export type GameDistributionVersionFrozenMetadata = {
title?: string;
@@ -270,9 +265,8 @@ export type GameDistributionVersionFrozenMetadata = {
description?: string;
category?: GameDistributionCategory;
tags?: string[];
coverAssetId?: string | null;
coverObjectKey?: string | null;
screenshots?: GameDistributionFrozenScreenshot[];
screenshots?: string[];
deviceSupport?: GameDistributionDeviceSupport;
inputModes?: GameDistributionInputMode[];
orientation?: GameDistributionOrientation;
@@ -8,6 +8,7 @@ pub mod external_api;
pub mod external_generation;
pub mod frontend_runtime_config;
pub mod game_distribution;
pub mod game_distribution_media;
pub mod health;
pub mod internal;
pub mod platform;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
//! 游戏分发发布媒体的 multipart 解析与对象写入。
//!
//! 三条写路径(创建游戏、创建版本、编辑资料)统一收 `multipart/form-data`:
//! 一个 `metadata` 文本 part + 可选 `cover` 二进制 part + 按出现顺序的 `screenshot` 二进制 part。
//! 新图由服务端写入项目快照桶并只回 objectKey,不建 `asset_object`,与素材库 ACL 解耦。
use std::collections::BTreeMap;
use axum::{extract::Multipart, http::StatusCode};
use platform_oss::{OssInternalPutObjectRequest, OssObjectAccess};
use uuid::Uuid;
use crate::{http_error::AppError, platform_errors::map_oss_error, state::AppState};
/// 发布媒体与发行包同桶,路径按 gameId 分组。
pub(crate) const GAME_DISTRIBUTION_MEDIA_PREFIX: &str =
"agc/project-snapshots/v1/game-distribution/media/";
pub(crate) const MAX_GAME_SCREENSHOTS: usize = 6;
const MAX_SINGLE_MEDIA_BYTES: usize = 8 * 1024 * 1024;
/// 单个图片 part 的原始字节与声明类型。
pub(crate) struct UploadedImage {
pub content_type: String,
pub bytes: Vec<u8>,
}
/// 一次发布写请求里的图片素材(元数据 JSON 与文件 part 分离解析)。
pub(crate) struct PublishMultipart {
pub metadata: serde_json::Value,
pub cover: Option<UploadedImage>,
pub screenshots: Vec<UploadedImage>,
}
#[derive(Clone, Copy)]
pub(crate) enum MediaKind {
Cover,
Screenshot,
}
impl MediaKind {
fn slug(self) -> &'static str {
match self {
MediaKind::Cover => "cover",
MediaKind::Screenshot => "screenshot",
}
}
fn label(self) -> &'static str {
match self {
MediaKind::Cover => "封面",
MediaKind::Screenshot => "截图",
}
}
}
/// 解析发布写请求;`metadata` 的 JSON 形状由各端点自行反序列化。
pub(crate) async fn parse_publish_multipart(
mut multipart: Multipart,
) -> Result<PublishMultipart, AppError> {
let mut metadata: Option<serde_json::Value> = None;
let mut cover: Option<UploadedImage> = None;
let mut screenshots: Vec<UploadedImage> = Vec::new();
while let Some(field) = multipart.next_field().await.map_err(|error| {
tracing::warn!(error = %error, "游戏分发 multipart 字段解析失败");
bad_request("multipart 请求无效")
})? {
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"metadata" => {
if metadata.is_some() {
return Err(bad_request("metadata 字段只能出现一次"));
}
let text = field
.text()
.await
.map_err(|_| bad_request("metadata 字段读取失败"))?;
metadata = Some(
serde_json::from_str(&text)
.map_err(|_| bad_request("metadata 字段不是合法 JSON"))?,
);
}
"cover" => {
if cover.is_some() {
return Err(bad_request("cover 字段只能出现一次"));
}
cover = Some(read_image_field(field, MediaKind::Cover).await?);
}
"screenshot" => {
if screenshots.len() >= MAX_GAME_SCREENSHOTS {
return Err(bad_request(format!(
"游戏截图最多 {MAX_GAME_SCREENSHOTS} 张"
)));
}
screenshots.push(read_image_field(field, MediaKind::Screenshot).await?);
}
other => return Err(bad_request(format!("不支持的 multipart 字段:{other}"))),
}
}
let metadata = metadata.ok_or_else(|| bad_request("缺少 metadata 字段"))?;
Ok(PublishMultipart {
metadata,
cover,
screenshots,
})
}
/// 写入新图并返回 objectKey;失败按 OSS 错误口径上抛。
pub(crate) async fn upload_media_image(
state: &AppState,
game_id: &str,
kind: MediaKind,
image: &UploadedImage,
) -> Result<String, AppError> {
let oss = state.project_snapshot_oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("游戏发布媒体 OSS 未配置")
})?;
let object_key = format!(
"{GAME_DISTRIBUTION_MEDIA_PREFIX}{game_id}/{}-{}.{}",
kind.slug(),
Uuid::new_v4().simple(),
media_extension(image.content_type.as_str()),
);
oss.put_internal_object(
state.editor_oss_http_client(),
OssInternalPutObjectRequest {
object_key: object_key.clone(),
content_type: Some(image.content_type.clone()),
access: OssObjectAccess::Private,
metadata: BTreeMap::new(),
body: image.bytes.clone(),
},
)
.await
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
Ok(object_key)
}
async fn read_image_field(
field: axum::extract::multipart::Field<'_>,
kind: MediaKind,
) -> Result<UploadedImage, AppError> {
let label = kind.label();
let content_type = field.content_type().unwrap_or_default().trim().to_string();
if !content_type.starts_with("image/") {
return Err(bad_request(format!("{label}必须是图片")));
}
let bytes = field
.bytes()
.await
.map_err(|_| bad_request(format!("{label}读取失败")))?;
if bytes.is_empty() {
return Err(bad_request(format!("{label}不能为空")));
}
if bytes.len() > MAX_SINGLE_MEDIA_BYTES {
return Err(
AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_message(format!(
"{label}超过大小上限:{}MB",
MAX_SINGLE_MEDIA_BYTES / 1024 / 1024
)),
);
}
Ok(UploadedImage {
content_type,
bytes: bytes.to_vec(),
})
}
fn media_extension(content_type: &str) -> &'static str {
match content_type.trim().to_ascii_lowercase().as_str() {
"image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
"image/avif" => "avif",
_ => "bin",
}
}
fn bad_request(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message)
}
@@ -226,11 +226,12 @@ pub struct GameDistributionCreateGameRequest {
pub category: String,
#[serde(default)]
pub tags: Vec<String>,
/// 沿用线上封面时给出既有对象键;同时携带 `cover` 二进制 part 时以 part 为准。
#[serde(default)]
pub cover_asset_id: Option<String>,
/// 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。
pub cover_object_key: Option<String>,
/// 与最终截图顺序一一对应:`Some(objectKey)` 沿用线上,`None` 取按顺序出现的 `screenshot` 二进制 part。
#[serde(default)]
pub screenshots: Vec<String>,
pub screenshots: Vec<Option<String>>,
pub device_support: GameDistributionDeviceSupport,
pub input_modes: Vec<GameDistributionInputMode>,
pub orientation: GameDistributionOrientation,
@@ -275,11 +276,12 @@ pub struct GameDistributionUpdateGameMetadataRequest {
pub category: String,
#[serde(default)]
pub tags: Vec<String>,
/// 沿用线上封面时给出既有对象键;同时携带 `cover` 二进制 part 时以 part 为准。
#[serde(default)]
pub cover_asset_id: Option<String>,
/// 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。
pub cover_object_key: Option<String>,
/// 与最终截图顺序一一对应:`Some(objectKey)` 沿用线上,`None` 取按顺序出现的 `screenshot` 二进制 part。
#[serde(default)]
pub screenshots: Vec<String>,
pub screenshots: Vec<Option<String>>,
pub device_support: GameDistributionDeviceSupport,
pub input_modes: Vec<GameDistributionInputMode>,
pub orientation: GameDistributionOrientation,
@@ -480,18 +482,27 @@ mod tests {
"title": "游戏",
"summary": "简介",
"category": "益智",
"coverAssetId": "asset-cover",
"screenshots": ["asset-shot"],
"coverObjectKey": "generated/cover.png",
"screenshots": [null, "generated/shot.png"],
"deviceSupport": { "desktop": true, "mobile": false, "touch": false },
"inputModes": ["keyboard"],
"orientation": "responsive",
}))
.expect("资料编辑请求应可解析");
assert_eq!(payload.expected_publication_revision, 3);
assert_eq!(payload.cover_asset_id.as_deref(), Some("asset-cover"));
assert_eq!(
payload.cover_object_key.as_deref(),
Some("generated/cover.png")
);
assert_eq!(
payload.screenshots,
vec![None, Some("generated/shot.png".to_string())]
);
let serialized = serde_json::to_value(&payload).expect("资料编辑请求应可序列化");
assert_eq!(serialized["expectedPublicationRevision"], 3);
assert_eq!(serialized["coverAssetId"], "asset-cover");
assert_eq!(serialized["coverObjectKey"], "generated/cover.png");
assert_eq!(serialized["screenshots"][0], serde_json::Value::Null);
assert_eq!(serialized["screenshots"][1], "generated/shot.png");
}
#[test]
@@ -164,7 +164,6 @@ pub struct GameDistributionGameRecord {
pub description: String,
pub category: String,
pub tags_json: String,
pub cover_asset_id: Option<String>,
pub author_name: Option<String>,
pub author_avatar_url: Option<String>,
pub device_support_desktop: bool,
@@ -274,7 +273,6 @@ fn map_game(
description: value.description,
category: value.category,
tags_json: value.tags_json,
cover_asset_id: value.cover_asset_id,
author_name: value.author_name,
author_avatar_url: value.author_avatar_url,
device_support_desktop: value.device_support_desktop,
@@ -76,7 +76,6 @@ pub struct GameDistributionCreateGameRecordInput {
pub description: Option<String>,
pub category: String,
pub tags_json: String,
pub cover_asset_id: Option<String>,
pub author_name: Option<String>,
pub author_avatar_url: Option<String>,
pub device_support_desktop: bool,
@@ -89,6 +88,10 @@ pub struct GameDistributionCreateGameRecordInput {
pub now_micros: i64,
/// 同一作者的本地项目标识;重复发布会复用既有 game_id。
pub local_project_id: Option<String>,
/// 创建时即持久化的封面 OSS objectKey(发布媒体直传)。
pub cover_object_key: Option<String>,
/// 创建时即持久化的截图 OSS objectKey JSON 数组。
pub screenshots_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -221,7 +224,6 @@ pub struct GameDistributionUpdateMetadataRecordInput {
pub description: Option<String>,
pub category: String,
pub tags_json: String,
pub cover_asset_id: Option<String>,
pub cover_object_key: Option<String>,
pub screenshots_json: Option<String>,
pub device_support_desktop: bool,
@@ -626,6 +628,38 @@ impl SpacetimeClient {
.await
}
/// 按 OSS objectKey 判定公开媒体是否可匿名读取;只回公开授权布尔。
pub async fn get_game_distribution_media_read_access(
&self,
object_key: String,
) -> Result<bool, SpacetimeClientError> {
self.call_after_connect(
"get_game_distribution_media_read_access_and_return",
move |connection, sender| {
connection
.procedures()
.get_game_distribution_media_read_access_and_return_then(
object_key,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(|result| {
if result.ok {
Ok(result.public_read_granted)
} else {
Err(SpacetimeClientError::procedure_failed(
result.error_message,
))
}
});
send_once(&sender, mapped);
},
);
},
)
.await
}
/// 批量累加已公开游戏的游玩次数;非公开游戏由事务静默跳过。
pub async fn increment_game_distribution_game_play_counts(
&self,
@@ -681,7 +715,6 @@ impl SpacetimeClient {
description: input.description,
category: input.category,
tags_json: input.tags_json,
cover_asset_id: input.cover_asset_id,
author_name: input.author_name,
author_avatar_url: input.author_avatar_url,
device_support_desktop: input.device_support_desktop,
@@ -693,6 +726,8 @@ impl SpacetimeClient {
request_digest: input.request_digest,
now_micros: input.now_micros,
local_project_id: input.local_project_id,
cover_object_key: input.cover_object_key,
screenshots_json: input.screenshots_json,
};
self.call_after_connect(
"create_game_distribution_game",
@@ -1140,7 +1175,6 @@ impl SpacetimeClient {
description: input.description,
category: input.category,
tags_json: input.tags_json,
cover_asset_id: input.cover_asset_id,
cover_object_key: input.cover_object_key,
screenshots_json: input.screenshots_json,
device_support_desktop: input.device_support_desktop,
@@ -444,6 +444,7 @@ pub mod game_distribution_game_type;
pub mod game_distribution_get_game_input_type;
pub mod game_distribution_idempotency_receipt_table;
pub mod game_distribution_idempotency_receipt_type;
pub mod game_distribution_media_read_access_procedure_result_type;
pub mod game_distribution_my_review_input_type;
pub mod game_distribution_owner_game_list_input_type;
pub mod game_distribution_owner_game_list_procedure_result_type;
@@ -507,6 +508,7 @@ pub mod get_external_generation_job_summary_and_return_procedure;
pub mod get_external_generation_queue_stats_and_return_procedure;
pub mod get_feature_gate_config_procedure;
pub mod get_game_distribution_game_and_return_procedure;
pub mod get_game_distribution_media_read_access_and_return_procedure;
pub mod get_game_distribution_my_review_and_return_procedure;
pub mod get_game_distribution_purchase_and_return_procedure;
pub mod get_game_distribution_version_and_return_procedure;
@@ -1359,6 +1361,7 @@ pub use game_distribution_game_type::GameDistributionGame;
pub use game_distribution_get_game_input_type::GameDistributionGetGameInput;
pub use game_distribution_idempotency_receipt_table::*;
pub use game_distribution_idempotency_receipt_type::GameDistributionIdempotencyReceipt;
pub use game_distribution_media_read_access_procedure_result_type::GameDistributionMediaReadAccessProcedureResult;
pub use game_distribution_my_review_input_type::GameDistributionMyReviewInput;
pub use game_distribution_owner_game_list_input_type::GameDistributionOwnerGameListInput;
pub use game_distribution_owner_game_list_procedure_result_type::GameDistributionOwnerGameListProcedureResult;
@@ -1422,6 +1425,7 @@ pub use get_external_generation_job_summary_and_return_procedure::get_external_g
pub use get_external_generation_queue_stats_and_return_procedure::get_external_generation_queue_stats_and_return;
pub use get_feature_gate_config_procedure::get_feature_gate_config;
pub use get_game_distribution_game_and_return_procedure::get_game_distribution_game_and_return;
pub use get_game_distribution_media_read_access_and_return_procedure::get_game_distribution_media_read_access_and_return;
pub use get_game_distribution_my_review_and_return_procedure::get_game_distribution_my_review_and_return;
pub use get_game_distribution_purchase_and_return_procedure::get_game_distribution_purchase_and_return;
pub use get_game_distribution_version_and_return_procedure::get_game_distribution_version_and_return;
@@ -14,7 +14,6 @@ pub struct GameDistributionCreateGameInput {
pub description: Option<String>,
pub category: String,
pub tags_json: String,
pub cover_asset_id: Option<String>,
pub author_name: Option<String>,
pub author_avatar_url: Option<String>,
pub device_support_desktop: bool,
@@ -26,6 +25,8 @@ pub struct GameDistributionCreateGameInput {
pub request_digest: String,
pub now_micros: i64,
pub local_project_id: Option<String>,
pub cover_object_key: Option<String>,
pub screenshots_json: Option<String>,
}
impl __sdk::InModule for GameDistributionCreateGameInput {
@@ -14,7 +14,6 @@ pub struct GameDistributionGameSnapshot {
pub description: String,
pub category: String,
pub tags_json: String,
pub cover_asset_id: Option<String>,
pub author_name: Option<String>,
pub author_avatar_url: Option<String>,
pub device_support_desktop: bool,
@@ -14,7 +14,6 @@ pub struct GameDistributionGame {
pub description: String,
pub category: String,
pub tags_json: String,
pub cover_asset_id: Option<String>,
pub author_name: Option<String>,
pub author_avatar_url: Option<String>,
pub device_support_desktop: bool,
@@ -50,7 +49,6 @@ pub struct GameDistributionGameCols {
pub description: __sdk::__query_builder::Col<GameDistributionGame, String>,
pub category: __sdk::__query_builder::Col<GameDistributionGame, String>,
pub tags_json: __sdk::__query_builder::Col<GameDistributionGame, String>,
pub cover_asset_id: __sdk::__query_builder::Col<GameDistributionGame, Option<String>>,
pub author_name: __sdk::__query_builder::Col<GameDistributionGame, Option<String>>,
pub author_avatar_url: __sdk::__query_builder::Col<GameDistributionGame, Option<String>>,
pub device_support_desktop: __sdk::__query_builder::Col<GameDistributionGame, bool>,
@@ -82,7 +80,6 @@ impl __sdk::__query_builder::HasCols for GameDistributionGame {
description: __sdk::__query_builder::Col::new(table_name, "description"),
category: __sdk::__query_builder::Col::new(table_name, "category"),
tags_json: __sdk::__query_builder::Col::new(table_name, "tags_json"),
cover_asset_id: __sdk::__query_builder::Col::new(table_name, "cover_asset_id"),
author_name: __sdk::__query_builder::Col::new(table_name, "author_name"),
author_avatar_url: __sdk::__query_builder::Col::new(table_name, "author_avatar_url"),
device_support_desktop: __sdk::__query_builder::Col::new(
@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionMediaReadAccessProcedureResult {
pub ok: bool,
pub public_read_granted: bool,
pub error_message: Option<String>,
}
impl __sdk::InModule for GameDistributionMediaReadAccessProcedureResult {
type Module = super::RemoteModule;
}
@@ -15,7 +15,6 @@ pub struct GameDistributionUpdateMetadataInput {
pub description: Option<String>,
pub category: String,
pub tags_json: String,
pub cover_asset_id: Option<String>,
pub cover_object_key: Option<String>,
pub screenshots_json: Option<String>,
pub device_support_desktop: bool,
@@ -0,0 +1,58 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_media_read_access_procedure_result_type::GameDistributionMediaReadAccessProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct GetGameDistributionMediaReadAccessAndReturnArgs {
pub object_key: String,
}
impl __sdk::InModule for GetGameDistributionMediaReadAccessAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `get_game_distribution_media_read_access_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait get_game_distribution_media_read_access_and_return {
fn get_game_distribution_media_read_access_and_return(&self, object_key: String) {
self.get_game_distribution_media_read_access_and_return_then(object_key, |_, _| {});
}
fn get_game_distribution_media_read_access_and_return_then(
&self,
object_key: String,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionMediaReadAccessProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl get_game_distribution_media_read_access_and_return for super::RemoteProcedures {
fn get_game_distribution_media_read_access_and_return_then(
&self,
object_key: String,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionMediaReadAccessProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, GameDistributionMediaReadAccessProcedureResult>(
"get_game_distribution_media_read_access_and_return",
GetGameDistributionMediaReadAccessAndReturnArgs { object_key },
__callback,
);
}
}
@@ -6357,13 +6357,7 @@ pub(crate) fn asset_location_has_public_showcase_read_grant(
asset_object: Option<&module_assets::AssetObjectUpsertSnapshot>,
) -> bool {
let asset_object_granted = asset_object
.is_some_and(|asset_object| asset_object_has_public_showcase_read_grant(ctx, asset_object))
|| asset_object.is_some_and(|asset_object| {
crate::game_distribution::game_distribution_asset_has_public_read_grant(
ctx,
asset_object.asset_object_id.as_str(),
)
});
.is_some_and(|asset_object| asset_object_has_public_showcase_read_grant(ctx, asset_object));
let campaign_config = ctx
.db
.editor_showcase_campaign_config()
@@ -738,7 +738,6 @@ pub struct GameDistributionGame {
pub(crate) description: String,
pub(crate) category: String,
pub(crate) tags_json: String,
pub(crate) cover_asset_id: Option<String>,
pub(crate) author_name: Option<String>,
pub(crate) author_avatar_url: Option<String>,
pub(crate) device_support_desktop: bool,
@@ -758,7 +757,7 @@ pub struct GameDistributionGame {
/// 审核通过后生效的封面对象键;客户端用它换平台签名读地址。
#[default(None::<String>)]
pub(crate) cover_object_key: Option<String>,
/// 审核通过后生效的截图对象键 JSON 数组(与 `cover_asset_id` 同源,来自版本冻结资料)。
/// 审核通过后生效的截图对象键 JSON 数组;每项是 OSS objectKey 字符串,来自版本冻结资料。
#[default(None::<String>)]
pub(crate) screenshots_json: Option<String>,
/// 作者软删除时间。非空表示该作品已从作者视图、公开目录/详情与发行网关下线;
@@ -950,7 +949,6 @@ pub struct GameDistributionCreateGameInput {
pub description: Option<String>,
pub category: String,
pub tags_json: String,
pub cover_asset_id: Option<String>,
pub author_name: Option<String>,
pub author_avatar_url: Option<String>,
pub device_support_desktop: bool,
@@ -963,6 +961,10 @@ pub struct GameDistributionCreateGameInput {
pub now_micros: i64,
/// 同一作者的本地项目标识;重复发布会复用既有 game_id 而不是新建游戏。
pub local_project_id: Option<String>,
/// 创建时即持久化的封面 OSS objectKey(发布媒体直传,不再依赖 assetId)。
pub cover_object_key: Option<String>,
/// 创建时即持久化的截图 OSS objectKey JSON 数组。
pub screenshots_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -1115,7 +1117,6 @@ pub struct GameDistributionUpdateMetadataInput {
pub description: Option<String>,
pub category: String,
pub tags_json: String,
pub cover_asset_id: Option<String>,
pub cover_object_key: Option<String>,
pub screenshots_json: Option<String>,
pub device_support_desktop: bool,
@@ -1191,7 +1192,6 @@ pub struct GameDistributionGameSnapshot {
pub description: String,
pub category: String,
pub tags_json: String,
pub cover_asset_id: Option<String>,
pub author_name: Option<String>,
pub author_avatar_url: Option<String>,
pub device_support_desktop: bool,
@@ -1340,6 +1340,14 @@ pub struct GameDistributionGetGameInput {
pub owner_user_id: Option<String>,
}
/// 公开媒体读权限判定结果:只暴露 OSS objectKey 是否可被匿名读者访问。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionMediaReadAccessProcedureResult {
pub ok: bool,
pub public_read_granted: bool,
pub error_message: Option<String>,
}
/// 买断制购买输入。`expected_price_mud_points` 与服务端当前价格做 CAS,
/// 不一致说明作者已通过新版本调价,必须刷新后重试。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -1845,6 +1853,39 @@ pub fn get_public_game_distribution_game_and_return(
}
}
/// 按 OSS objectKey 判定公开可读:只读判定,不接受 assetId。
///
/// 与资产读链路解耦:公开游戏的封面 objectKey 或截图 objectKey 列表命中即授权;
/// 未公开、已暂停、已删除、无有效版本的游戏不授权。
#[spacetimedb::procedure]
pub fn get_game_distribution_media_read_access_and_return(
ctx: &mut ProcedureContext,
object_key: String,
) -> GameDistributionMediaReadAccessProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
require_editor_generation_runtime_service_identity(tx, caller)?;
let object_key = object_key.trim();
if object_key.is_empty() {
return Err("object_key 不能为空".to_string());
}
Ok(game_distribution_media_has_public_read_grant(
tx, object_key,
))
}) {
Ok(public_read_granted) => GameDistributionMediaReadAccessProcedureResult {
ok: true,
public_read_granted,
error_message: None,
},
Err(error) => GameDistributionMediaReadAccessProcedureResult {
ok: false,
public_read_granted: false,
error_message: Some(error),
},
}
}
/// 管理员读取任意版本状态;admin 权限由 api-server 在调用前校验,procedure 只接受受信服务身份。
#[spacetimedb::procedure]
pub fn get_game_distribution_version_and_return(
@@ -2037,7 +2078,6 @@ fn create_game_distribution_game_tx(
description: input.description.unwrap_or_default(),
category,
tags_json: input.tags_json,
cover_asset_id: input.cover_asset_id,
author_name: input.author_name,
author_avatar_url: input.author_avatar_url,
device_support_desktop: input.device_support_desktop,
@@ -2052,8 +2092,8 @@ fn create_game_distribution_game_tx(
created_at: now,
updated_at: now,
local_project_id,
cover_object_key: None,
screenshots_json: None,
cover_object_key: input.cover_object_key,
screenshots_json: input.screenshots_json,
deleted_at: None,
// 新建游戏固定免费;付费价格只在审核通过时随版本冻结资料生效。
price_mud_points: 0,
@@ -3161,7 +3201,6 @@ fn update_game_distribution_game_metadata_tx(
game.description = input.description.unwrap_or_default();
game.category = required_game_distribution_text(input.category, "category")?;
game.tags_json = input.tags_json;
game.cover_asset_id = input.cover_asset_id;
game.cover_object_key = input.cover_object_key;
game.screenshots_json = input.screenshots_json;
game.device_support_desktop = input.device_support_desktop;
@@ -3674,10 +3713,7 @@ fn get_public_game_distribution_game_tx(
let Some(game) = ctx.db.game_distribution_game().game_id().find(&game_id) else {
return Ok(None);
};
if game.visibility != GAME_DISTRIBUTION_VISIBILITY_PUBLISHED
|| game.active_version_id.is_none()
|| game.deleted_at.is_some()
{
if !game_distribution_game_is_publicly_visible(&game) {
return Ok(None);
}
Ok(public_game_distribution_snapshot(ctx, &game))
@@ -4084,7 +4120,6 @@ fn game_distribution_game_snapshot(game: &GameDistributionGame) -> GameDistribut
description: game.description.clone(),
category: game.category.clone(),
tags_json: game.tags_json.clone(),
cover_asset_id: game.cover_asset_id.clone(),
author_name: game.author_name.clone(),
author_avatar_url: game.author_avatar_url.clone(),
device_support_desktop: game.device_support_desktop,
@@ -4116,10 +4151,9 @@ pub(crate) struct GameDistributionFrozenMetadata {
pub category: String,
#[serde(default)]
pub tags: Vec<String>,
pub cover_asset_id: String,
pub cover_object_key: String,
#[serde(default)]
pub screenshots: Vec<GameDistributionFrozenScreenshot>,
pub screenshots: Vec<String>,
pub device_support: GameDistributionFrozenDeviceSupport,
#[serde(default)]
pub input_modes: Vec<String>,
@@ -4129,13 +4163,6 @@ pub(crate) struct GameDistributionFrozenMetadata {
pub price_mud_points: u64,
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GameDistributionFrozenScreenshot {
pub asset_id: String,
pub object_key: String,
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub(crate) struct GameDistributionFrozenDeviceSupport {
pub desktop: bool,
@@ -4162,7 +4189,6 @@ fn apply_game_distribution_frozen_metadata(
game.description = frozen.description.clone().unwrap_or_default();
game.category = frozen.category.clone();
game.tags_json = serde_json::to_string(&frozen.tags).unwrap_or_else(|_| "[]".to_string());
game.cover_asset_id = Some(frozen.cover_asset_id.clone());
game.cover_object_key = Some(frozen.cover_object_key.clone());
game.screenshots_json = serde_json::to_string(&frozen.screenshots).ok();
game.device_support_desktop = frozen.device_support.desktop;
@@ -4175,31 +4201,29 @@ fn apply_game_distribution_frozen_metadata(
game.price_mud_points = frozen.price_mud_points;
}
/// 已公开游戏的封面/截图素材允许匿名读者换取签名读地址。
pub(crate) fn game_distribution_asset_has_public_read_grant(
/// 公开可见判定与公开目录/公开详情一致:已发布、已有有效版本且未软删除。
fn game_distribution_game_is_publicly_visible(game: &GameDistributionGame) -> bool {
game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED
&& game.active_version_id.is_some()
&& game.deleted_at.is_none()
}
/// 已公开游戏的封面/截图 OSS objectKey 允许匿名读者换取签名读地址。
pub(crate) fn game_distribution_media_has_public_read_grant(
ctx: &ReducerContext,
asset_object_id: &str,
object_key: &str,
) -> bool {
ctx.db.game_distribution_game().iter().any(|game| {
if game.visibility != GAME_DISTRIBUTION_VISIBILITY_PUBLISHED
|| game.active_version_id.is_none()
|| game.deleted_at.is_some()
{
if !game_distribution_game_is_publicly_visible(&game) {
return false;
}
if game.cover_asset_id.as_deref() == Some(asset_object_id) {
if game.cover_object_key.as_deref() == Some(object_key) {
return true;
}
game.screenshots_json
.as_deref()
.and_then(|json| {
serde_json::from_str::<Vec<GameDistributionFrozenScreenshot>>(json).ok()
})
.is_some_and(|screenshots| {
screenshots
.iter()
.any(|screenshot| screenshot.asset_id == asset_object_id)
})
.and_then(|json| serde_json::from_str::<Vec<String>>(json).ok())
.is_some_and(|screenshots| screenshots.iter().any(|key| key == object_key))
})
}
@@ -4349,7 +4373,6 @@ mod tests {
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,
@@ -215,6 +215,8 @@ macro_rules! migration_tables {
// 定价:游戏表末尾追加 price_mud_points(默认 0),购买记录含成交价快照与钱包流水 ID。
// 资料随版本冻结:版本表末尾追加 metadata_json,游戏表末尾追加
// cover_object_key / screenshots_json,均为可空列,旧行按“未冻结资料”处理。
// 媒体去 assetId 化:游戏表删除 cover_asset_id,封面/截图只保留 OSS objectKey
// (封面 cover_object_key、截图 screenshots_json 为 objectKey 字符串数组)。
// 软删除:游戏表末尾追加可空 deleted_at;非空表示作者已删除该作品,
// 行本身仍随迁移导出,版本行与冻结资料保持不可变。
game_distribution_game,
@@ -1,7 +1,7 @@
import './gameDistribution.css';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import type { GameDistributionGame } from '../../services/gameDistributionClient';
import { useGameDistributionMediaReadUrl } from './useGameDistributionMediaReadUrl';
/**
* 游戏封面。
@@ -23,9 +23,7 @@ export function GameCover({
objectKey?: string | null;
}) {
const targetKey = (objectKey ?? game.coverObjectKey ?? '').trim();
const { resolvedUrl } = useResolvedAssetReadUrl(null, {
objectKey: targetKey,
});
const { resolvedUrl } = useGameDistributionMediaReadUrl(targetKey);
return (
<div
className={`game-cover ${compact ? 'game-cover--compact' : ''}`}
@@ -11,7 +11,6 @@ import type {
GameDistributionRecoveryAction,
GameDistributionVersionSummary,
} from '../../../packages/shared/src/contracts/gameDistribution';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { ApiClientError } from '../../services/apiClient';
import {
type GameDistributionGame,
@@ -44,6 +43,7 @@ import {
VERSION_STATUS_NEEDS_AUTHOR_ACTION,
} from './gameVersionHistoryModel';
import { useGameAuthorDisplayName } from './useGameAuthorName';
import { useGameDistributionMediaReadUrl } from './useGameDistributionMediaReadUrl';
import { useGameReviews } from './useGameReviews';
type GameDetailPageProps = {
@@ -118,7 +118,7 @@ function GameDetailResolvedAsset({
objectKey,
role,
}: GameDetailAssetRenderInput) {
const { resolvedUrl } = useResolvedAssetReadUrl(null, { objectKey });
const { resolvedUrl } = useGameDistributionMediaReadUrl(objectKey);
if (!resolvedUrl) return null;
return (
<img
@@ -101,6 +101,18 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
},
}));
// 游戏封面/截图改走游戏分发媒体换签路由,用同一份换签结果桩替换,避免真实请求。
vi.mock('./useGameDistributionMediaReadUrl', () => ({
useGameDistributionMediaReadUrl: (objectKey: string | null | undefined) => {
const key = (objectKey ?? '').trim();
return {
resolvedUrl: resolvedAssetUrlMock(key),
isResolving: false,
shouldResolve: Boolean(key),
};
},
}));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -1,76 +1,60 @@
import { useRef, useState } from 'react';
import { useRef } from 'react';
import {
createLocalGamePublishImage,
GAME_PUBLISH_IMAGE_ACCEPT,
type GamePublishImageAsset,
type GamePublishImage,
MAX_GAME_SCREENSHOTS,
uploadGamePublishImageAsset,
revokeGamePublishImagePreview,
} from './gamePublishAssets';
type GamePublishMediaFieldsetProps = {
cover: GamePublishImageAsset | null;
screenshots: GamePublishImageAsset[];
/** 提交流程进行中时禁止换图;上传中的禁用由组件自己叠加。 */
cover: GamePublishImage | null;
screenshots: GamePublishImage[];
/** 提交流程进行中时禁止换图。 */
disabled?: boolean;
/**
* 线上封面没有可复用素材 ID 时的提示。
*
* 发布与资料编辑对“为什么必须重选”的说法不同,其余文案共用。
*/
missingCoverHint: string;
onCoverChange: (asset: GamePublishImageAsset | null) => void;
onScreenshotsChange: (assets: GamePublishImageAsset[]) => void;
onCoverChange: (image: GamePublishImage | null) => void;
onScreenshotsChange: (images: GamePublishImage[]) => void;
onError: (message: string) => void;
/** 上传进行中状态;调用方用它禁用提交。 */
onUploadingChange: (uploading: boolean) => void;
};
/**
* 封面与截图选择区。
*
* 发布游戏与「作品资料编辑」共用同一份选图、上传与移除交互:两处都只需要素材 ID,
* 本地只负责把文件先传成平台素材再交给服务端冻结/保存。
* 发布游戏与「作品资料编辑」共用同一份选图与移除交互:这里只做本地预检、生成 blob 预览并
* 保留原始 `File`,不再提前上传成平台素材;文件会随保存/发布请求以 multipart 二进制 part 提交。
*/
export function GamePublishMediaFieldset({
cover,
screenshots,
disabled = false,
missingCoverHint,
onCoverChange,
onScreenshotsChange,
onError,
onUploadingChange,
}: GamePublishMediaFieldsetProps) {
const coverInputRef = useRef<HTMLInputElement>(null);
const screenshotInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const fieldDisabled = disabled || isUploading;
async function handleCoverSelected(file: File | null) {
function handleCoverSelected(file: File | null) {
if (!file) return;
onError('');
setIsUploading(true);
onUploadingChange(true);
try {
const uploaded = await uploadGamePublishImageAsset(file, {
kind: 'cover',
});
onCoverChange(uploaded);
} catch (uploadError: unknown) {
const image = createLocalGamePublishImage(file, 'cover');
revokeGamePublishImagePreview(cover);
onCoverChange(image);
} catch (selectError: unknown) {
onError(
uploadError instanceof Error
? uploadError.message
: '封面上传失败,请重试',
selectError instanceof Error
? selectError.message
: '封面图片不可用,请重新选择',
);
} finally {
setIsUploading(false);
onUploadingChange(false);
// 清空 input 才能让作者重新选择同一张图。
if (coverInputRef.current) coverInputRef.current.value = '';
}
}
async function handleScreenshotsSelected(files: File[]) {
function handleScreenshotsSelected(files: File[]) {
if (files.length === 0) return;
const remaining = MAX_GAME_SCREENSHOTS - screenshots.length;
if (files.length > remaining) {
@@ -83,38 +67,39 @@ export function GamePublishMediaFieldset({
return;
}
onError('');
setIsUploading(true);
onUploadingChange(true);
let nextScreenshots = [...screenshots];
try {
// 逐张入库:中途失败时已经传好的截图仍然保留,作者不用重新选择。
let nextScreenshots = [...screenshots];
// 逐张收下:中途某张不合规时已选中的截图仍然保留,作者不用重新选择。
for (const file of files) {
const uploaded = await uploadGamePublishImageAsset(file, {
kind: 'screenshot',
});
if (nextScreenshots.length >= MAX_GAME_SCREENSHOTS) continue;
nextScreenshots = [...nextScreenshots, uploaded];
nextScreenshots = [
...nextScreenshots,
createLocalGamePublishImage(file, 'screenshot'),
];
onScreenshotsChange(nextScreenshots);
}
} catch (uploadError: unknown) {
} catch (selectError: unknown) {
onError(
uploadError instanceof Error
? uploadError.message
: '截图上传失败,请重试',
selectError instanceof Error
? selectError.message
: '截图图片不可用,请重新选择',
);
} finally {
setIsUploading(false);
onUploadingChange(false);
if (screenshotInputRef.current) screenshotInputRef.current.value = '';
}
}
function removeScreenshot(index: number) {
revokeGamePublishImagePreview(screenshots[index]);
onScreenshotsChange(
screenshots.filter((_, itemIndex) => itemIndex !== index),
);
}
function removeCover() {
revokeGamePublishImagePreview(cover);
onCoverChange(null);
}
return (
<fieldset className="game-publish-fieldset game-publish-media">
<legend>封面与截图</legend>
@@ -133,16 +118,16 @@ export function GamePublishMediaFieldset({
游戏封面(必需)
{/*
原生 file 控件在清空 value 后会显示“未选择任何文件”,与下方的“已选择”提示互相
矛盾;上传后必须清空 value 才能重选同一张图,所以这里改用自定义触发按钮。
矛盾;选择后必须清空 value 才能重选同一张图,所以这里改用自定义触发按钮。
*/}
<span className="game-publish-media__picker">
<input
ref={coverInputRef}
type="file"
accept={GAME_PUBLISH_IMAGE_ACCEPT}
disabled={fieldDisabled}
disabled={disabled}
onChange={(event) => {
void handleCoverSelected(event.target.files?.[0] ?? null);
handleCoverSelected(event.target.files?.[0] ?? null);
}}
/>
<span className="game-publish-media__pick">
@@ -150,10 +135,10 @@ export function GamePublishMediaFieldset({
</span>
</span>
<span className="game-publish-hint">
{cover?.assetObjectId
{cover?.file
? `已选择「${cover.name}」;重新选择即可替换当前封面。`
: cover
? missingCoverHint
? `已沿用线上封面「${cover.name}」;重新选择即可替换。`
: '封面会作为广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'}
</span>
</label>
@@ -161,8 +146,8 @@ export function GamePublishMediaFieldset({
<button
type="button"
className="game-publish-media__remove"
disabled={fieldDisabled}
onClick={() => onCoverChange(null)}
disabled={disabled}
onClick={removeCover}
>
移除封面
</button>
@@ -178,13 +163,9 @@ export function GamePublishMediaFieldset({
type="file"
accept={GAME_PUBLISH_IMAGE_ACCEPT}
multiple
disabled={
fieldDisabled || screenshots.length >= MAX_GAME_SCREENSHOTS
}
disabled={disabled || screenshots.length >= MAX_GAME_SCREENSHOTS}
onChange={(event) => {
void handleScreenshotsSelected(
Array.from(event.target.files ?? []),
);
handleScreenshotsSelected(Array.from(event.target.files ?? []));
}}
/>
<span className="game-publish-media__pick">
@@ -199,13 +180,13 @@ export function GamePublishMediaFieldset({
</label>
{screenshots.length > 0 ? (
<ul className="game-publish-media__strip">
{screenshots.map((asset, index) => (
{screenshots.map((image, index) => (
<li
key={`${asset.assetObjectId || asset.objectKey}-${index}`}
key={`${image.objectKey ?? image.name}-${index}`}
className="game-publish-media__shot"
>
{asset.src ? (
<img src={asset.src} alt={`截图 ${index + 1} 预览`} />
{image.src ? (
<img src={image.src} alt={`截图 ${index + 1} 预览`} />
) : (
<span className="game-publish-media__placeholder">
{`截图 ${index + 1}`}
@@ -214,7 +195,7 @@ export function GamePublishMediaFieldset({
<button
type="button"
className="game-publish-media__remove"
disabled={fieldDisabled}
disabled={disabled}
onClick={() => removeScreenshot(index)}
>
{`移除截图 ${index + 1}`}
@@ -224,9 +205,6 @@ export function GamePublishMediaFieldset({
</ul>
) : null}
</div>
{isUploading ? (
<span className="game-publish-hint">正在上传图片,请稍候…</span>
) : null}
</fieldset>
);
}
@@ -14,10 +14,7 @@ import {
uploadGamePackage,
} from '../../services/gameDistributionClient';
import { AuthUiContext } from '../auth/AuthUiContext';
import {
resolveGamePublishImagePreview,
uploadGamePublishImageAsset,
} from './gamePublishAssets';
import { resolveGamePublishImagePreview } from './gamePublishAssets';
import { resolvePublishMetadataError } from './gamePublishMetadata';
import { GamePublishPage } from './GamePublishPage';
@@ -29,12 +26,11 @@ vi.mock('../../services/frontendRuntimeConfigService', () => ({
loadFrontendRuntimeConfigMock(...args),
}));
// 只替换真实上传与换签,保留常量与本地校验,避免测试真的打 OSS。
// 只替换线上图换签,保留常量、本地校验与本地预览,避免测试真的打 OSS。
vi.mock('./gamePublishAssets', async (importOriginal) => {
const actual = await importOriginal<typeof import('./gamePublishAssets')>();
return {
...actual,
uploadGamePublishImageAsset: vi.fn(),
resolveGamePublishImagePreview: vi.fn(),
};
});
@@ -42,6 +38,7 @@ vi.mock('./gamePublishAssets', async (importOriginal) => {
vi.mock('../../services/gameDistributionClient', () => ({
createGame: vi.fn(),
createGameVersion: vi.fn(),
getGameDistributionMediaReadUrl: vi.fn().mockResolvedValue(''),
getGameVersion: vi.fn(),
listMyGames: vi.fn(),
submitGameVersion: vi.fn(),
@@ -118,6 +115,22 @@ function buildImageFile(name: string, type = 'image/png') {
return new File(['image-bytes'], name, { type });
}
/**
* 独立内存 localStorage。
*
* 新版 Node 的 jsdom localStorage 依赖运行时 flag,测试显式注入内存存储,草稿读写不再受
* 运行环境影响(与 `gameDistributionClient.test.ts` 同一做法)。
*/
function stubMemoryLocalStorage() {
const values = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
clear: () => values.clear(),
});
}
beforeEach(() => {
loadFrontendRuntimeConfigMock.mockReset();
loadFrontendRuntimeConfigMock.mockResolvedValue({
@@ -125,23 +138,15 @@ beforeEach(() => {
agcTemplateLibraryEnabled: false,
gameDistributionPublishEnabled: true,
});
vi.mocked(uploadGamePublishImageAsset).mockReset();
vi.mocked(resolveGamePublishImagePreview).mockReset();
vi.mocked(resolveGamePublishImagePreview).mockResolvedValue('');
vi.mocked(uploadGamePublishImageAsset).mockImplementation(
async (file: File) => ({
assetObjectId: `asset-${file.name}`,
objectKey: `game-distribution/uploads/${file.name}`,
src: `https://cdn.example.com/${file.name}`,
name: file.name,
}),
);
vi.mocked(createGame).mockReset();
vi.mocked(createGameVersion).mockReset();
vi.mocked(getGameVersion).mockReset();
vi.mocked(listMyGames).mockReset();
vi.mocked(uploadGamePackage).mockReset();
vi.mocked(submitGameVersion).mockReset();
stubMemoryLocalStorage();
window.localStorage.clear();
});
@@ -156,8 +161,8 @@ async function selectScreenshots(names: string[]) {
fireEvent.change(screen.getByLabelText(/游戏截图/u), {
target: { files: names.map((name) => buildImageFile(name)) },
});
for (const name of names) {
await screen.findByText(new RegExp(`截图 ${names.indexOf(name) + 1}`, 'u'));
for (let index = 0; index < names.length; index += 1) {
await screen.findByRole('button', { name: `移除截图 ${index + 1}` });
}
}
@@ -182,7 +187,7 @@ test('元数据校验与服务端口径一致', () => {
title: ' ',
summary: '简介',
deviceSupport: desktop,
coverAssetId: 'asset-cover',
hasCover: true,
}),
).toBe('游戏标题必须为 1 到 40 个字符');
expect(
@@ -190,7 +195,7 @@ test('元数据校验与服务端口径一致', () => {
title: '游戏',
summary: '简介',
deviceSupport: { desktop: false, mobile: true, touch: false },
coverAssetId: 'asset-cover',
hasCover: true,
}),
).toBe('选择移动端时必须支持触控');
expect(
@@ -198,7 +203,7 @@ test('元数据校验与服务端口径一致', () => {
title: '游戏',
summary: '简介',
deviceSupport: { desktop: false, mobile: false, touch: false },
coverAssetId: 'asset-cover',
hasCover: true,
}),
).toBe('至少选择桌面端或移动端');
// 服务端要求发布必须带封面,且截图上限 6 张。
@@ -214,7 +219,7 @@ test('元数据校验与服务端口径一致', () => {
title: '游戏',
summary: '简介',
deviceSupport: desktop,
coverAssetId: 'asset-cover',
hasCover: true,
screenshotCount: 7,
}),
).toBe('游戏截图最多 6 张');
@@ -223,7 +228,7 @@ test('元数据校验与服务端口径一致', () => {
title: '游戏',
summary: '简介',
deviceSupport: desktop,
coverAssetId: 'asset-cover',
hasCover: true,
screenshotCount: 6,
}),
).toBe('');
@@ -247,7 +252,7 @@ test('草稿恢复按版本冻结价预填并锁住定价,提示写明将沿
status: 'uploaded',
recoveryAction: 'upload',
priceMudPoints: 240,
frozenMetadata: { coverAssetId: 'asset-cover-old' },
frozenMetadata: { coverObjectKey: 'game-distribution/cover/old.png' },
},
} as never);
@@ -274,7 +279,7 @@ test('越界价格在网页元数据兜底里被拦下', () => {
title: '游戏',
summary: '简介',
deviceSupport: desktop,
coverAssetId: 'asset-cover',
hasCover: true,
priceMudPoints: 1000001,
}),
).toBe('买断价必须是 0 到 1000000 之间的整数泥点');
@@ -283,7 +288,7 @@ test('越界价格在网页元数据兜底里被拦下', () => {
title: '游戏',
summary: '简介',
deviceSupport: desktop,
coverAssetId: 'asset-cover',
hasCover: true,
priceMudPoints: 0,
}),
).toBe('');
@@ -432,13 +437,13 @@ test('缺少封面时本地先拦截且不创建游戏', async () => {
expect(uploadGamePackage).not.toHaveBeenCalled();
});
test('封面上传失败时给出原因且不提交', async () => {
vi.mocked(uploadGamePublishImageAsset).mockRejectedValue(
new Error('游戏封面过大,请压缩后再上传(当前 9.0MB,最多 6MB)。'),
);
test('封面本地预检失败时给出原因且不提交', async () => {
await renderPage();
const huge = buildImageFile('huge.png');
// jsdom 里构造 9MB 字符串代价高,直接声明文件体积触发本地体积校验。
Object.defineProperty(huge, 'size', { value: 9 * 1024 * 1024 });
fireEvent.change(screen.getByLabelText(/游戏封面/u), {
target: { files: [buildImageFile('huge.png')] },
target: { files: [huge] },
});
expect(
@@ -459,7 +464,7 @@ test('截图超过 6 张时本地拦截', async () => {
});
expect(await screen.findByText(/游戏截图最多 6 张/u)).toBeTruthy();
expect(uploadGamePublishImageAsset).not.toHaveBeenCalled();
expect(screen.queryByText(/移除截图 1/u)).toBeNull();
expect(createGame).not.toHaveBeenCalled();
});
@@ -504,15 +509,28 @@ test('按创建游戏、创建版本、上传、送审顺序提交并展示审
summary: '测试简介',
category: '休闲',
inputModes: ['keyboard', 'mouse', 'touch'],
coverAssetId: 'asset-cover.png',
screenshots: ['asset-shot-1.png', 'asset-shot-2.png'],
// 本地新图在资料槽位里写 null,由同一次 multipart 的二进制 part 补位。
coverObjectKey: null,
screenshots: [null, null],
}),
);
const createMedia = vi.mocked(createGame).mock.calls[0]?.[2];
expect(createMedia?.cover?.name).toBe('cover.png');
expect(createMedia?.screenshots?.map((file) => file?.name)).toEqual([
'shot-1.png',
'shot-2.png',
]);
expect(vi.mocked(createGameVersion).mock.calls[0]?.[0]).toBe('game-1');
const versionPayload = vi.mocked(createGameVersion).mock.calls[0]?.[1];
expect(versionPayload?.packageEntryPath).toBe('index.html');
expect(versionPayload?.packageFileCount).toBe(2);
expect(versionPayload?.packageSha256).toMatch(/^[0-9a-f]{64}$/u);
const versionMedia = vi.mocked(createGameVersion).mock.calls[0]?.[3];
expect(versionMedia?.cover?.name).toBe('cover.png');
expect(versionMedia?.screenshots?.map((file) => file?.name)).toEqual([
'shot-1.png',
'shot-2.png',
]);
expect(vi.mocked(uploadGamePackage).mock.calls[0]?.[0]).toBe('gamever-1');
expect(vi.mocked(submitGameVersion).mock.calls[0]?.[1]).toBe(0);
@@ -649,14 +667,8 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
recoveryAction: 'none',
priceMudPoints: 240,
frozenMetadata: {
coverAssetId: 'asset-cover-old',
coverObjectKey: 'game-distribution/cover/old.png',
screenshots: [
{
assetId: 'asset-shot-old',
objectKey: 'game-distribution/screenshot/old.png',
},
],
screenshots: ['game-distribution/screenshot/old.png'],
},
},
} as never);
@@ -683,13 +695,13 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
expect(screen.getByLabelText('游戏名称')).toHaveProperty('value', '星轨防线');
expect(screen.getByLabelText('分类')).toHaveProperty('value', '动作');
// 冻结资料直接回填封面与截图,作者不需要为了沿用封面重新上传一次。
// 冻结资料直接回填封面与截图,作者不需要为了沿用封面重新选图。
await waitFor(() =>
expect(resolveGamePublishImagePreview).toHaveBeenCalledWith(
'game-distribution/cover/old.png',
),
);
expect(uploadGamePublishImageAsset).not.toHaveBeenCalled();
expect(screen.queryByText(/本次发布需要重新选择一次封面图片/u)).toBeNull();
const file = await buildZipFile();
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
@@ -710,11 +722,15 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
title: '星轨防线',
category: '动作',
tags: ['塔防'],
coverAssetId: 'asset-cover-old',
screenshots: ['asset-shot-old'],
// 未换图时沿用版本冻结资料里的 objectKey,二进制 part 不带封面。
coverObjectKey: 'game-distribution/cover/old.png',
screenshots: ['game-distribution/screenshot/old.png'],
}),
}),
);
const updateMedia = vi.mocked(createGameVersion).mock.calls[0]?.[3];
expect(updateMedia?.cover).toBeNull();
expect(updateMedia?.screenshots).toEqual([null]);
expect(vi.mocked(submitGameVersion).mock.calls[0]?.[0]).toBe('gamever-3');
expect(vi.mocked(submitGameVersion).mock.calls[0]?.[1]).toBe(4);
expect(
@@ -722,7 +738,7 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
).toBeTruthy();
});
test('线上封面没有冻结素材时要求重新选择封面', async () => {
test('线上封面没有冻结快照时沿用游戏行封面对象键', async () => {
vi.mocked(listMyGames).mockResolvedValue([
{
id: 'game-1',
@@ -747,7 +763,7 @@ test('线上封面没有冻结素材时要求重新选择封面', async () => {
versions: [],
} as never,
]);
// 历史版本没有冻结快照:只能看到线上封面,但没有可复用的素材 ID。
// 历史版本没有冻结快照:回退到游戏行封面 objectKey,可以继续沿用。
vi.mocked(getGameVersion).mockResolvedValue({
game: {
id: 'game-1',
@@ -771,22 +787,26 @@ test('线上封面没有冻结素材时要求重新选择封面', async () => {
renderPublishPage(createAuthValue(), 'game-1');
expect(
await screen.findByText(/本次发布需要重新选择一次封面图片/u),
).toBeTruthy();
await screen.findByLabelText('游戏名称');
await waitFor(() =>
expect(resolveGamePublishImagePreview).toHaveBeenCalledWith(
'game-distribution/cover/legacy.png',
),
);
const file = await buildZipFile();
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
target: { files: [file] },
});
fireEvent.click(screen.getByRole('button', { name: '提交新版本审核' }));
expect(
await screen.findByText(
'本次发布需要重新选择一次封面图片(当前展示的是线上封面)',
),
).toBeTruthy();
expect(createGameVersion).not.toHaveBeenCalled();
await waitFor(() => expect(createGameVersion).toHaveBeenCalledTimes(1));
expect(vi.mocked(createGameVersion).mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
gameMetadata: expect.objectContaining({
coverObjectKey: 'game-distribution/cover/legacy.png',
}),
}),
);
});
test('更新模式读不到归属游戏时失败关闭且不提交', async () => {

Some files were not shown because too many files have changed in this diff Show More