游戏分发发布媒体改 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:
@@ -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;
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
-1
@@ -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(
|
||||
|
||||
+17
@@ -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;
|
||||
}
|
||||
-1
@@ -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,
|
||||
|
||||
+58
@@ -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
Reference in New Issue
Block a user