补齐游戏分发封面与截图的冻结、上传与展示
- 服务端:游戏表追加可空 cover_object_key / screenshots_json,版本表追加可空 metadata_json,创建版本时校验必需封面、最多 6 张截图与图片素材归属,并从素材记录派生对象键生成冻结资料。 - 服务端:审核通过时把冻结资料整体生效到游戏行;只有已公开且存在有效活动版本的游戏,其封面/截图素材才在 /api/assets/read-url 获得匿名读授权。 - 服务端:作者与管理员回读版本时追加 frozenMetadata,带回封面与截图素材 ID 供续发复用,公开投影仍只暴露对象键。 - 网页:/games/publish 新增「封面与截图」区,复用平台图片直传与 confirm 通道,缺封面或截图超量在发请求前拦截,更新版本时按冻结资料回填且不要求重新上传。 - 网页:游戏广场卡片与详情页 hero 换签展示真实封面,详情页新增可点击的截图缩略图条,无素材或换签失败时静默回退原有渐变占位。 - AGC:发布面板支持封面必选与截图最多 6 张,新增素材直传服务并在创建游戏前拦截缺封面,同一文件重复提交复用素材 ID。 - AGC:http 能力作用域新增阿里云 OSS 直传地址,客户端同时校验直传目标必须是平台素材存储。 - 契约与文档:同步 shared-contracts、packages/shared 契约,更新 SpacetimeDB 迁移注释、后端架构、玩法链路与实施计划文档,并补齐网页端与 AGC 定向测试。
This commit is contained in:
@@ -16,7 +16,8 @@
|
||||
{ "url": "https://www.genarrative.world/api/*" },
|
||||
{ "url": "https://*/api/*" },
|
||||
{ "url": "http://localhost:*/*" },
|
||||
{ "url": "http://127.0.0.1:*/*" }
|
||||
{ "url": "http://127.0.0.1:*/*" },
|
||||
{ "url": "https://*.aliyuncs.com/*" }
|
||||
]
|
||||
},
|
||||
"opener:default",
|
||||
|
||||
+280
-2
@@ -3,14 +3,64 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { LocalProjectExportPackageResult } from '../../app/types';
|
||||
import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload';
|
||||
import {
|
||||
createGameDistributionPublishKey,
|
||||
type GameDistributionPublishMetadata,
|
||||
type GameDistributionPublishResult,
|
||||
MAX_AGC_GAME_SCREENSHOTS,
|
||||
publishLocalProjectGame,
|
||||
} from '../../services/gameDistributionPublish';
|
||||
import { ThemedModal } from '../modal/ThemedModal';
|
||||
|
||||
/** 封面与截图都是公开展示素材,限制单张体积,避免手机原图直传拖垮发布流程。 */
|
||||
const PANEL_IMAGE_MAX_BYTES = 6 * 1024 * 1024;
|
||||
const PANEL_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif';
|
||||
|
||||
type PanelImageKind = 'cover' | 'screenshot';
|
||||
|
||||
type PanelImageAsset = {
|
||||
/** 文件签名;同一文件重复选择时复用已上传素材,不重复直传。 */
|
||||
signature: string;
|
||||
name: string;
|
||||
assetObjectId: string;
|
||||
previewUrl: string;
|
||||
};
|
||||
|
||||
function resolvePanelImageLabel(kind: PanelImageKind) {
|
||||
return kind === 'cover' ? '游戏封面' : '游戏截图';
|
||||
}
|
||||
|
||||
/** 本地预检;服务端仍会独立校验素材归属与图片类型。 */
|
||||
function resolvePanelImageFileError(file: File, kind: PanelImageKind) {
|
||||
const label = resolvePanelImageLabel(kind);
|
||||
if (file.size <= 0) return `${label}文件为空,请重新选择`;
|
||||
if (file.size > PANEL_IMAGE_MAX_BYTES) {
|
||||
return `${label}过大,请压缩后再上传(最多 6MB)`;
|
||||
}
|
||||
const contentType = file.type.trim();
|
||||
if (contentType && !contentType.startsWith('image/')) {
|
||||
return `${label}必须是图片文件`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildPanelImageSignature(file: File, kind: PanelImageKind) {
|
||||
return `${kind}:${file.name}:${file.size}:${file.lastModified}`;
|
||||
}
|
||||
|
||||
/** 预览只在 WebView 支持 object URL 时生成;否则退回文字占位,不影响上传。 */
|
||||
function buildPanelImagePreviewUrl(file: File) {
|
||||
if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return URL.createObjectURL(file);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
'休闲',
|
||||
'益智',
|
||||
@@ -45,7 +95,15 @@ export function GameDistributionPublishPanel({
|
||||
const [result, setResult] = useState<GameDistributionPublishResult | null>(
|
||||
null,
|
||||
);
|
||||
const [cover, setCover] = useState<PanelImageAsset | null>(null);
|
||||
const [screenshots, setScreenshots] = useState<PanelImageAsset[]>([]);
|
||||
const [uploadingLabel, setUploadingLabel] = useState('');
|
||||
const publishIdempotencyKeyRef = useRef('');
|
||||
const coverInputRef = useRef<HTMLInputElement>(null);
|
||||
const screenshotInputRef = useRef<HTMLInputElement>(null);
|
||||
// 文件签名 → 素材 ID:同一次打开面板重复提交不会重复上传同一张图。
|
||||
const uploadedAssetCacheRef = useRef(new Map<string, string>());
|
||||
const objectUrlsRef = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -65,6 +123,121 @@ export function GameDistributionPublishPanel({
|
||||
}
|
||||
}, [open, packageResult?.packageRelativePath]);
|
||||
|
||||
useEffect(() => {
|
||||
const objectUrls = objectUrlsRef.current;
|
||||
return () => {
|
||||
objectUrls.forEach((url) => {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// 预览地址释放失败不影响关闭面板。
|
||||
}
|
||||
});
|
||||
objectUrls.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function resolveAssetObjectId(file: File, kind: PanelImageKind) {
|
||||
const signature = buildPanelImageSignature(file, kind);
|
||||
const cached = uploadedAssetCacheRef.current.get(signature);
|
||||
if (cached) return cached;
|
||||
const uploaded = await uploadPlatformMediaAsset({
|
||||
file,
|
||||
assetKind:
|
||||
kind === 'cover'
|
||||
? 'game_distribution_cover'
|
||||
: 'game_distribution_screenshot',
|
||||
pathSegments: ['game-distribution', kind, `${Date.now()}`],
|
||||
entityId: `game-distribution-${kind}`,
|
||||
metadata: { game_distribution_media: kind },
|
||||
});
|
||||
uploadedAssetCacheRef.current.set(signature, uploaded.assetObjectId);
|
||||
return uploaded.assetObjectId;
|
||||
}
|
||||
|
||||
function buildPanelImageAsset(
|
||||
file: File,
|
||||
kind: PanelImageKind,
|
||||
assetObjectId: string,
|
||||
) {
|
||||
const previewUrl = buildPanelImagePreviewUrl(file);
|
||||
if (previewUrl) objectUrlsRef.current.add(previewUrl);
|
||||
return {
|
||||
signature: buildPanelImageSignature(file, kind),
|
||||
name: file.name.trim() || resolvePanelImageLabel(kind),
|
||||
assetObjectId,
|
||||
previewUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCoverSelected(file: File | null) {
|
||||
if (!file) return;
|
||||
setError('');
|
||||
const fileError = resolvePanelImageFileError(file, 'cover');
|
||||
if (fileError) {
|
||||
setError(fileError);
|
||||
return;
|
||||
}
|
||||
setUploadingLabel('正在上传封面…');
|
||||
try {
|
||||
const assetObjectId = await resolveAssetObjectId(file, 'cover');
|
||||
setCover(buildPanelImageAsset(file, 'cover', assetObjectId));
|
||||
} catch (uploadError) {
|
||||
setError(
|
||||
uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: '封面上传失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
setUploadingLabel('');
|
||||
if (coverInputRef.current) coverInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScreenshotsSelected(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
const remaining = MAX_AGC_GAME_SCREENSHOTS - screenshots.length;
|
||||
if (files.length > remaining) {
|
||||
setError(
|
||||
remaining > 0
|
||||
? `游戏截图最多 6 张,还可以再选 ${remaining} 张`
|
||||
: '游戏截图最多 6 张',
|
||||
);
|
||||
if (screenshotInputRef.current) screenshotInputRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setUploadingLabel('正在上传截图…');
|
||||
try {
|
||||
for (const file of files) {
|
||||
const fileError = resolvePanelImageFileError(file, 'screenshot');
|
||||
if (fileError) {
|
||||
setError(fileError);
|
||||
return;
|
||||
}
|
||||
const assetObjectId = await resolveAssetObjectId(file, 'screenshot');
|
||||
// 逐张入库:中途失败时已传好的截图保留,作者不用重新选择。
|
||||
setScreenshots((current) =>
|
||||
current.length >= MAX_AGC_GAME_SCREENSHOTS
|
||||
? current
|
||||
: [
|
||||
...current,
|
||||
buildPanelImageAsset(file, 'screenshot', assetObjectId),
|
||||
],
|
||||
);
|
||||
}
|
||||
} catch (uploadError) {
|
||||
setError(
|
||||
uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: '截图上传失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
setUploadingLabel('');
|
||||
if (screenshotInputRef.current) screenshotInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!packageResult || !projectPath.trim()) {
|
||||
setError('请先导出有效的试玩包');
|
||||
@@ -75,6 +248,14 @@ export function GameDistributionPublishPanel({
|
||||
setError('需要在 Tauri App 内发布');
|
||||
return;
|
||||
}
|
||||
if (!cover) {
|
||||
setError('请先选择游戏封面(JPG/PNG/WebP)');
|
||||
return;
|
||||
}
|
||||
if (uploadingLabel) {
|
||||
setError('素材还在上传中,请稍候再发布');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -83,7 +264,13 @@ export function GameDistributionPublishPanel({
|
||||
projectPath,
|
||||
packageRelativePath: packageResult.packageRelativePath,
|
||||
manifest,
|
||||
metadata: { title, summary, category },
|
||||
metadata: {
|
||||
title,
|
||||
summary,
|
||||
category,
|
||||
coverAssetId: cover.assetObjectId,
|
||||
screenshots: screenshots.map((item) => item.assetObjectId),
|
||||
},
|
||||
idempotencyKey: publishIdempotencyKeyRef.current,
|
||||
});
|
||||
setResult(next);
|
||||
@@ -190,6 +377,97 @@ export function GameDistributionPublishPanel({
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="game-distribution-publish-panel__media"
|
||||
aria-label="封面与截图"
|
||||
>
|
||||
<div className="game-distribution-publish-panel__cover">
|
||||
<div className="game-distribution-publish-panel__cover-preview">
|
||||
{cover?.previewUrl ? (
|
||||
<img src={cover.previewUrl} alt="游戏封面预览" />
|
||||
) : (
|
||||
<span>{cover ? '封面已上传' : '还没有封面'}</span>
|
||||
)}
|
||||
</div>
|
||||
<label>
|
||||
游戏封面(必需)
|
||||
<input
|
||||
ref={coverInputRef}
|
||||
type="file"
|
||||
accept={PANEL_IMAGE_ACCEPT}
|
||||
onChange={(event) => {
|
||||
void handleCoverSelected(event.target.files?.[0] ?? null);
|
||||
}}
|
||||
/>
|
||||
<span className="game-distribution-publish-panel__hint">
|
||||
{cover
|
||||
? `已选择「${cover.name}」;重新选择即可替换封面。`
|
||||
: '封面会作为游戏广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'}
|
||||
</span>
|
||||
</label>
|
||||
{cover ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCover(null)}
|
||||
disabled={busy}
|
||||
>
|
||||
移除封面
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<label>
|
||||
{`游戏截图(可选,最多 ${MAX_AGC_GAME_SCREENSHOTS} 张)`}
|
||||
<input
|
||||
ref={screenshotInputRef}
|
||||
type="file"
|
||||
accept={PANEL_IMAGE_ACCEPT}
|
||||
multiple
|
||||
disabled={
|
||||
busy || screenshots.length >= MAX_AGC_GAME_SCREENSHOTS
|
||||
}
|
||||
onChange={(event) => {
|
||||
void handleScreenshotsSelected(
|
||||
Array.from(event.target.files ?? []),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="game-distribution-publish-panel__hint">
|
||||
截图会按选择顺序展示在游戏详情页;不需要可以留空。
|
||||
</span>
|
||||
</label>
|
||||
{screenshots.length > 0 ? (
|
||||
<ul className="game-distribution-publish-panel__shots">
|
||||
{screenshots.map((item, index) => (
|
||||
<li key={item.signature}>
|
||||
{item.previewUrl ? (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt={`截图 ${index + 1} 预览`}
|
||||
/>
|
||||
) : (
|
||||
<span>{`截图 ${index + 1}`}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setScreenshots((current) =>
|
||||
current.filter((_, itemIndex) => itemIndex !== index),
|
||||
)
|
||||
}
|
||||
>
|
||||
{`移除截图 ${index + 1}`}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{uploadingLabel ? (
|
||||
<span className="game-distribution-publish-panel__hint">
|
||||
{uploadingLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="game-distribution-publish-panel__error" role="alert">
|
||||
{error}
|
||||
@@ -206,7 +484,7 @@ export function GameDistributionPublishPanel({
|
||||
busy || !title.trim() || !summary.trim() || !packageResult
|
||||
}
|
||||
>
|
||||
{busy ? '上传中…' : '发布游戏'}
|
||||
{busy ? '上传中…' : uploadingLabel ? '素材上传中…' : '发布游戏'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
import { requestClientApi } from './clientApi';
|
||||
|
||||
/** 直传凭证里的对象存储目标;字段口径与平台 `/api/assets/direct-upload-tickets` 一致。 */
|
||||
type DirectUploadTicketResponse = {
|
||||
upload: {
|
||||
bucket: string;
|
||||
host: string;
|
||||
objectKey: string;
|
||||
legacyPublicPath: string;
|
||||
formFields: Record<string, string | null | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
type ConfirmAssetObjectResponse = {
|
||||
assetObject: {
|
||||
assetObjectId: string;
|
||||
objectKey: string;
|
||||
assetKind: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UploadedPlatformAsset = {
|
||||
assetObjectId: string;
|
||||
objectKey: string;
|
||||
};
|
||||
|
||||
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* 平台直传凭证支持的 OSS 主机白名单前缀。
|
||||
*
|
||||
* Tauri 的 http 插件只允许访问 `capabilities/main.json` 里声明的地址,这里再做一次校验,
|
||||
* 保证即使能力配置放宽到 `*.aliyuncs.com`,客户端也只把文件发到平台素材存储,而不是任意主机。
|
||||
*/
|
||||
const PLATFORM_UPLOAD_HOST_SUFFIXES = ['.aliyuncs.com'];
|
||||
|
||||
function isLocalUploadHost(parsed: URL) {
|
||||
return (
|
||||
parsed.protocol === 'http:' &&
|
||||
(parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost')
|
||||
);
|
||||
}
|
||||
|
||||
/** 校验直传地址;不是平台素材存储时直接失败关闭,避免把本地文件发给第三方主机。 */
|
||||
export function resolvePlatformAssetUploadUrl(host: string) {
|
||||
const trimmedHost = host.trim();
|
||||
if (!trimmedHost) {
|
||||
throw new Error('素材上传地址为空,请稍后重试');
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmedHost);
|
||||
} catch {
|
||||
throw new Error('素材上传地址无效,请稍后重试');
|
||||
}
|
||||
const isOssHost =
|
||||
parsed.protocol === 'https:' &&
|
||||
PLATFORM_UPLOAD_HOST_SUFFIXES.some((suffix) =>
|
||||
parsed.hostname.endsWith(suffix),
|
||||
);
|
||||
if (!isOssHost && !isLocalUploadHost(parsed)) {
|
||||
throw new Error('素材上传地址不属于平台素材存储,已终止上传');
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function buildDirectUploadFormData(
|
||||
upload: DirectUploadTicketResponse['upload'],
|
||||
file: File,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
Object.entries(upload.formFields ?? {}).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
// OSS 要求 file 字段位于表单末尾,否则签名校验会失败。
|
||||
formData.append('file', file, file.name);
|
||||
return formData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把本地文件上传成平台素材对象并返回素材标识。
|
||||
*
|
||||
* 三步与网页端一致:申请直传凭证 → 直传对象存储 → confirm 登记素材。AGC 无法在浏览器里
|
||||
* 直接跨域直传 OSS,所以直传这一步固定走 Tauri HTTP 插件(Rust 侧发起请求)。
|
||||
*/
|
||||
export async function uploadPlatformMediaAsset(args: {
|
||||
file: File;
|
||||
assetKind: string;
|
||||
pathSegments: string[];
|
||||
entityId: string;
|
||||
metadata?: Record<string, string>;
|
||||
/** 单测注入的直传实现;正式运行时固定使用 Tauri HTTP。 */
|
||||
fetchImpl?: FetchLike;
|
||||
}): Promise<UploadedPlatformAsset> {
|
||||
const fileName = args.file.name.trim() || 'cover.png';
|
||||
const contentType = args.file.type.trim() || 'application/octet-stream';
|
||||
const ticket = await requestClientApi<DirectUploadTicketResponse>(
|
||||
'/api/assets/direct-upload-tickets',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: args.pathSegments,
|
||||
fileName,
|
||||
contentType,
|
||||
access: 'private',
|
||||
maxSizeBytes: args.file.size,
|
||||
metadata: {
|
||||
asset_kind: args.assetKind,
|
||||
...args.metadata,
|
||||
},
|
||||
}),
|
||||
},
|
||||
'创建素材上传凭证失败',
|
||||
);
|
||||
|
||||
const uploadHost = resolvePlatformAssetUploadUrl(ticket.upload.host);
|
||||
const uploadFetch = args.fetchImpl ?? tauriHttpFetch;
|
||||
const uploadResponse = await uploadFetch(uploadHost, {
|
||||
method: 'POST',
|
||||
body: buildDirectUploadFormData(ticket.upload, args.file),
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(
|
||||
`上传素材到对象存储失败(HTTP ${uploadResponse.status}),请重试`,
|
||||
);
|
||||
}
|
||||
|
||||
const confirmed = await requestClientApi<ConfirmAssetObjectResponse>(
|
||||
'/api/assets/objects/confirm',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
bucket: ticket.upload.bucket,
|
||||
objectKey: ticket.upload.objectKey,
|
||||
contentType,
|
||||
contentLength: args.file.size,
|
||||
assetKind: args.assetKind,
|
||||
accessPolicy: 'private',
|
||||
entityId: args.entityId,
|
||||
}),
|
||||
},
|
||||
'确认素材资产失败',
|
||||
);
|
||||
return {
|
||||
assetObjectId: confirmed.assetObject.assetObjectId,
|
||||
objectKey: confirmed.assetObject.objectKey,
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export type GameDistributionPublishMetadata = {
|
||||
description: string;
|
||||
category: GameDistributionCategory;
|
||||
tags: string[];
|
||||
/** 平台素材库里的封面素材 ID;服务端要求发布必须带封面。 */
|
||||
coverAssetId: string;
|
||||
/** 平台素材库里的截图素材 ID,最多 6 张。 */
|
||||
screenshots?: string[];
|
||||
deviceSupport: {
|
||||
desktop: boolean;
|
||||
mobile: boolean;
|
||||
@@ -27,6 +31,9 @@ export type GameDistributionPublishMetadata = {
|
||||
orientation: GameDistributionOrientation;
|
||||
};
|
||||
|
||||
/** 游戏截图上限与服务端 `MAX_GAME_SCREENSHOTS` 保持一致。 */
|
||||
export const MAX_AGC_GAME_SCREENSHOTS = 6;
|
||||
|
||||
export type GameDistributionPublishResult = {
|
||||
gameId: string;
|
||||
versionId: string;
|
||||
@@ -69,12 +76,25 @@ function normalizeMetadata(
|
||||
if (!summary || summary.length > 120) {
|
||||
throw new Error('游戏简介必须为 1 到 120 个字符');
|
||||
}
|
||||
const coverAssetId = (metadata?.coverAssetId ?? '').trim();
|
||||
if (!coverAssetId) {
|
||||
// 服务端会拒绝没有封面的发布;在创建游戏前失败关闭,避免留下无资料的半成品。
|
||||
throw new Error('请先选择游戏封面(JPG/PNG/WebP),再发布到游戏广场');
|
||||
}
|
||||
const screenshots = (metadata?.screenshots ?? [])
|
||||
.map((screenshot) => screenshot.trim())
|
||||
.filter(Boolean);
|
||||
if (screenshots.length > MAX_AGC_GAME_SCREENSHOTS) {
|
||||
throw new Error('游戏截图最多 6 张');
|
||||
}
|
||||
return {
|
||||
title,
|
||||
summary,
|
||||
description: (metadata?.description ?? summary).trim().slice(0, 2_000),
|
||||
category: metadata?.category ?? '其他',
|
||||
tags: metadata?.tags ?? [],
|
||||
coverAssetId,
|
||||
screenshots,
|
||||
deviceSupport: metadata?.deviceSupport ?? {
|
||||
desktop: true,
|
||||
mobile: true,
|
||||
@@ -96,6 +116,8 @@ function toCreateGameRequest(
|
||||
description: metadata.description,
|
||||
category: metadata.category,
|
||||
tags: metadata.tags,
|
||||
coverAssetId: metadata.coverAssetId,
|
||||
screenshots: metadata.screenshots ?? [],
|
||||
deviceSupport: metadata.deviceSupport,
|
||||
inputModes: metadata.inputModes,
|
||||
orientation: metadata.orientation,
|
||||
|
||||
@@ -3878,6 +3878,131 @@ textarea {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__media {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed rgb(104 77 57 / 18%);
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__media > label,
|
||||
.game-distribution-publish-panel__cover label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__media input[type='file'] {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 10px;
|
||||
background: rgb(255 255 255 / 76%);
|
||||
color: var(--platform-text-strong);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 168px) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover-preview {
|
||||
display: grid;
|
||||
aspect-ratio: 16 / 9;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 12px;
|
||||
background: rgb(168 102 61 / 8%);
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__hint {
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover > button,
|
||||
.game-distribution-publish-panel__shots button {
|
||||
justify-self: start;
|
||||
width: fit-content;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-muted);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 移除封面按钮固定落在文字列,避免占掉预览列的第二行。 */
|
||||
.game-distribution-publish-panel__cover > button {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots li {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: 124px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots img,
|
||||
.game-distribution-publish-panel__shots li > span {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots li > span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-style: dashed;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.game-distribution-publish-panel__cover {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover > button {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__error {
|
||||
margin: 14px 0 0;
|
||||
color: #b42318;
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AGC 素材直传(封面 / 截图)的三步链路边界。
|
||||
*
|
||||
* 这里只替换平台 API 调用与对象存储直传实现,验证「凭证 → 直传 → confirm」的字段口径、
|
||||
* 地址白名单与错误文案,不触达真实 Tauri HTTP 插件或 OSS。
|
||||
*/
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const requestClientApiMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../src/services/clientApi', () => ({
|
||||
requestClientApi: (...args: unknown[]) => requestClientApiMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
resolvePlatformAssetUploadUrl,
|
||||
uploadPlatformMediaAsset,
|
||||
} from '../src/services/assetDirectUpload';
|
||||
|
||||
const TICKET = {
|
||||
upload: {
|
||||
bucket: 'genarrative-assets',
|
||||
host: 'https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/',
|
||||
objectKey:
|
||||
'generated-character-drafts/game-distribution/cover/42/cover.png',
|
||||
legacyPublicPath: '/generated-character-drafts/game-distribution/cover/42',
|
||||
formFields: {
|
||||
key: 'generated-character-drafts/game-distribution/cover/42/cover.png',
|
||||
policy: 'policy-value',
|
||||
OSSAccessKeyId: 'ak-value',
|
||||
signature: 'signature-value',
|
||||
success_action_status: '204',
|
||||
'x-oss-meta-asset_kind': 'game_distribution_cover',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CONFIRMED = {
|
||||
assetObject: {
|
||||
assetObjectId: 'asset_cover_1',
|
||||
objectKey: TICKET.upload.objectKey,
|
||||
assetKind: 'game_distribution_cover',
|
||||
},
|
||||
};
|
||||
|
||||
function buildCoverFile() {
|
||||
return new File(['cover-bytes'], 'cover.png', { type: 'image/png' });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
requestClientApiMock.mockReset();
|
||||
requestClientApiMock
|
||||
.mockResolvedValueOnce(TICKET)
|
||||
.mockResolvedValueOnce(CONFIRMED);
|
||||
});
|
||||
|
||||
test('封面按凭证、直传、confirm 三步上传并返回素材标识', async () => {
|
||||
const uploadFetch = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
const file = buildCoverFile();
|
||||
|
||||
const uploaded = await uploadPlatformMediaAsset({
|
||||
file,
|
||||
assetKind: 'game_distribution_cover',
|
||||
pathSegments: ['game-distribution', 'cover', '42'],
|
||||
entityId: 'game-distribution-cover',
|
||||
metadata: { game_distribution_media: 'cover' },
|
||||
fetchImpl: uploadFetch,
|
||||
});
|
||||
|
||||
expect(uploaded).toEqual({
|
||||
assetObjectId: 'asset_cover_1',
|
||||
objectKey: TICKET.upload.objectKey,
|
||||
});
|
||||
|
||||
const ticketCall = requestClientApiMock.mock.calls[0];
|
||||
expect(ticketCall?.[0]).toBe('/api/assets/direct-upload-tickets');
|
||||
const ticketBody = JSON.parse(String((ticketCall?.[1] as RequestInit).body));
|
||||
expect(ticketBody).toEqual(
|
||||
expect.objectContaining({
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'cover', '42'],
|
||||
fileName: 'cover.png',
|
||||
contentType: 'image/png',
|
||||
access: 'private',
|
||||
maxSizeBytes: file.size,
|
||||
metadata: {
|
||||
asset_kind: 'game_distribution_cover',
|
||||
game_distribution_media: 'cover',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const uploadCall = uploadFetch.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(uploadCall[0]).toBe(TICKET.upload.host);
|
||||
expect(uploadCall[1].method).toBe('POST');
|
||||
const formData = uploadCall[1].body as FormData;
|
||||
expect(formData.get('policy')).toBe('policy-value');
|
||||
expect(formData.get('signature')).toBe('signature-value');
|
||||
expect(formData.get('success_action_status')).toBe('204');
|
||||
const uploadedFile = formData.get('file');
|
||||
expect(uploadedFile).toBeInstanceOf(File);
|
||||
expect((uploadedFile as File).name).toBe('cover.png');
|
||||
|
||||
const confirmCall = requestClientApiMock.mock.calls[1];
|
||||
expect(confirmCall?.[0]).toBe('/api/assets/objects/confirm');
|
||||
expect(JSON.parse(String((confirmCall?.[1] as RequestInit).body))).toEqual({
|
||||
bucket: 'genarrative-assets',
|
||||
objectKey: TICKET.upload.objectKey,
|
||||
contentType: 'image/png',
|
||||
contentLength: file.size,
|
||||
assetKind: 'game_distribution_cover',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'game-distribution-cover',
|
||||
});
|
||||
});
|
||||
|
||||
test('直传地址不属于平台素材存储时失败关闭,且不发送文件', async () => {
|
||||
requestClientApiMock.mockReset();
|
||||
requestClientApiMock
|
||||
.mockResolvedValueOnce({
|
||||
upload: { ...TICKET.upload, host: 'https://evil.example.com/upload' },
|
||||
})
|
||||
.mockResolvedValueOnce(CONFIRMED);
|
||||
const uploadFetch = vi.fn();
|
||||
|
||||
await expect(
|
||||
uploadPlatformMediaAsset({
|
||||
file: buildCoverFile(),
|
||||
assetKind: 'game_distribution_cover',
|
||||
pathSegments: ['game-distribution', 'cover', '42'],
|
||||
entityId: 'game-distribution-cover',
|
||||
fetchImpl: uploadFetch as never,
|
||||
}),
|
||||
).rejects.toThrow('素材上传地址不属于平台素材存储,已终止上传');
|
||||
|
||||
expect(uploadFetch).not.toHaveBeenCalled();
|
||||
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('对象存储拒绝上传时给出可重试文案', async () => {
|
||||
const uploadFetch = vi.fn(
|
||||
async () => new Response('denied', { status: 403 }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uploadPlatformMediaAsset({
|
||||
file: buildCoverFile(),
|
||||
assetKind: 'game_distribution_screenshot',
|
||||
pathSegments: ['game-distribution', 'screenshot', '42'],
|
||||
entityId: 'game-distribution-screenshot',
|
||||
fetchImpl: uploadFetch,
|
||||
}),
|
||||
).rejects.toThrow('上传素材到对象存储失败(HTTP 403),请重试');
|
||||
|
||||
// 直传失败时不得再登记素材,避免留下没有实体的素材记录。
|
||||
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('本地回环与阿里云 OSS 之外的主机一律拒绝', () => {
|
||||
expect(resolvePlatformAssetUploadUrl('http://127.0.0.1:9000/bucket')).toBe(
|
||||
'http://127.0.0.1:9000/bucket',
|
||||
);
|
||||
expect(
|
||||
resolvePlatformAssetUploadUrl(
|
||||
'https://genarrative-assets.oss-cn-beijing.aliyuncs.com/',
|
||||
),
|
||||
).toBe('https://genarrative-assets.oss-cn-beijing.aliyuncs.com/');
|
||||
expect(() =>
|
||||
resolvePlatformAssetUploadUrl('http://oss.example.com/'),
|
||||
).toThrow('素材上传地址不属于平台素材存储,已终止上传');
|
||||
expect(() => resolvePlatformAssetUploadUrl(' ')).toThrow(
|
||||
'素材上传地址为空,请稍后重试',
|
||||
);
|
||||
});
|
||||
@@ -77,6 +77,10 @@ test('发布时携带本地项目标识,让重复发布复用同一个平台
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
metadata: {
|
||||
coverAssetId: 'asset_cover',
|
||||
screenshots: ['asset_shot_1', 'asset_shot_2'],
|
||||
},
|
||||
});
|
||||
|
||||
const createGameCall = fetchClientHttp.mock.calls[0];
|
||||
@@ -85,6 +89,8 @@ test('发布时携带本地项目标识,让重复发布复用同一个平台
|
||||
String((createGameCall?.[1] as RequestInit).body),
|
||||
);
|
||||
expect(createGameBody.localProjectId).toBe('local-proj-1');
|
||||
expect(createGameBody.coverAssetId).toBe('asset_cover');
|
||||
expect(createGameBody.screenshots).toEqual(['asset_shot_1', 'asset_shot_2']);
|
||||
|
||||
const createVersionCall = fetchClientHttp.mock.calls[1];
|
||||
expect(createVersionCall?.[0]).toBe(
|
||||
@@ -112,7 +118,49 @@ test('缺少本地项目标识时在发起请求前失败关闭', async () => {
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest,
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
}),
|
||||
).rejects.toThrow('发布需要本地项目标识');
|
||||
expect(fetchClientHttp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('缺少封面时在创建游戏前失败关闭', async () => {
|
||||
await expect(
|
||||
publishLocalProjectGame({
|
||||
invoke: (async () => ({
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
packageBytes: [1],
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 1,
|
||||
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
|
||||
})) as never,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
metadata: { coverAssetId: ' ' },
|
||||
}),
|
||||
).rejects.toThrow('请先选择游戏封面');
|
||||
expect(fetchClientHttp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('截图超过 6 张时在创建游戏前失败关闭', async () => {
|
||||
await expect(
|
||||
publishLocalProjectGame({
|
||||
invoke: (async () => ({
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
packageBytes: [1],
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 1,
|
||||
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
|
||||
})) as never,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
metadata: {
|
||||
coverAssetId: 'asset_cover',
|
||||
screenshots: Array.from({ length: 7 }, (_, index) => `asset_${index}`),
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('游戏截图最多 6 张');
|
||||
expect(fetchClientHttp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -17,9 +17,21 @@ import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { LocalProjectExportPackagePayload } from '../src/app/types';
|
||||
import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload';
|
||||
import { setStoredAuthAccessToken } from '../src/services/clientAuth';
|
||||
import { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
|
||||
|
||||
/** 1x1 透明 PNG:真实上传一张合法图片作为封面,避免依赖本地素材文件。 */
|
||||
const LIVE_COVER_PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||
|
||||
function buildLiveCoverFile() {
|
||||
const bytes = Buffer.from(LIVE_COVER_PNG_BASE64, 'base64');
|
||||
return new File([new Uint8Array(bytes)], 'agc-live-cover.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
}
|
||||
|
||||
const liveBaseUrl = (process.env.GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL ?? '')
|
||||
.trim()
|
||||
.replace(/\/+$/u, '');
|
||||
@@ -154,6 +166,16 @@ liveTest(
|
||||
goal: '验证 AGC 一键发布链路',
|
||||
} as unknown as GameCreationAppManifest;
|
||||
const invoke = vi.fn(async () => payload);
|
||||
// 服务端要求发布必须带封面:真实走一遍凭证 → 直传 → confirm。
|
||||
const uploadedCover = await uploadPlatformMediaAsset({
|
||||
file: buildLiveCoverFile(),
|
||||
assetKind: 'game_distribution_cover',
|
||||
pathSegments: ['game-distribution', 'cover', stamp],
|
||||
entityId: 'game-distribution-cover',
|
||||
// jsdom 里没有 Tauri HTTP 插件,复用测试注入的 fetch bridge 直连 dev OSS。
|
||||
fetchImpl: (input, init) => realFetch(apiUrl(input), init),
|
||||
});
|
||||
expect(uploadedCover.assetObjectId).toMatch(/\S/u);
|
||||
const metadata = {
|
||||
summary: '由 AGC 发布函数真实提交',
|
||||
description:
|
||||
@@ -163,6 +185,8 @@ liveTest(
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
inputModes: ['keyboard', 'mouse'] as const,
|
||||
orientation: 'landscape' as const,
|
||||
coverAssetId: uploadedCover.assetObjectId,
|
||||
screenshots: [] as string[],
|
||||
};
|
||||
|
||||
const first = await publishLocalProjectGame({
|
||||
|
||||
@@ -17,6 +17,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { LocalProjectExportPackageResult } from '../src/app/types';
|
||||
import { GameDistributionPublishPanel } from '../src/components/game-distribution/GameDistributionPublishPanel';
|
||||
import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload';
|
||||
import { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
|
||||
|
||||
vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => {
|
||||
@@ -27,6 +28,11 @@ vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => {
|
||||
return { ...actual, publishLocalProjectGame: vi.fn() };
|
||||
});
|
||||
|
||||
// 面板只负责选图与调用上传;这里替换掉真实直传,避免测试触达 Tauri/OSS。
|
||||
vi.mock('../src/services/assetDirectUpload', () => ({
|
||||
uploadPlatformMediaAsset: vi.fn(),
|
||||
}));
|
||||
|
||||
type TauriInvoke = (
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
@@ -57,6 +63,25 @@ const PACKAGE_RESULT = {
|
||||
totalBytes: 3,
|
||||
} as unknown as LocalProjectExportPackageResult;
|
||||
|
||||
function buildImageFile(name: string) {
|
||||
return new File(['cover-bytes'], name, { type: 'image/png' });
|
||||
}
|
||||
|
||||
/** 选择封面并等待上传完成;面板只有在素材拿到 ID 之后才允许发布。 */
|
||||
async function selectCover(
|
||||
file: File = buildImageFile('cover.png'),
|
||||
assetObjectId = 'asset_cover',
|
||||
) {
|
||||
vi.mocked(uploadPlatformMediaAsset).mockResolvedValueOnce({
|
||||
assetObjectId,
|
||||
objectKey: `game-distribution/cover/${file.name}`,
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/游戏封面/u), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
await screen.findByText(new RegExp(`已选择「${file.name}」`, 'u'));
|
||||
}
|
||||
|
||||
function renderPanel(
|
||||
overrides: Partial<Parameters<typeof GameDistributionPublishPanel>[0]> = {},
|
||||
) {
|
||||
@@ -79,6 +104,7 @@ function renderPanel(
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.mocked(publishLocalProjectGame).mockReset();
|
||||
vi.mocked(uploadPlatformMediaAsset).mockReset();
|
||||
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
|
||||
window.localStorage.clear();
|
||||
});
|
||||
@@ -123,6 +149,15 @@ describe('GameDistributionPublishPanel', () => {
|
||||
fireEvent.change(screen.getByLabelText('分类'), {
|
||||
target: { value: '动作' },
|
||||
});
|
||||
await selectCover();
|
||||
vi.mocked(uploadPlatformMediaAsset).mockResolvedValueOnce({
|
||||
assetObjectId: 'asset_shot_1',
|
||||
objectKey: 'game-distribution/screenshot/shot-1.png',
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/游戏截图/u), {
|
||||
target: { files: [buildImageFile('shot-1.png')] },
|
||||
});
|
||||
await screen.findByText('移除截图 1');
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -137,6 +172,8 @@ describe('GameDistributionPublishPanel', () => {
|
||||
title: '星轨防线二',
|
||||
summary: '守住轨道城',
|
||||
category: '动作',
|
||||
coverAssetId: 'asset_cover',
|
||||
screenshots: ['asset_shot_1'],
|
||||
});
|
||||
expect(String(args?.idempotencyKey)).toMatch(/^agc-publish-/u);
|
||||
expect(invoke).toBeDefined();
|
||||
@@ -156,6 +193,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
}) as never,
|
||||
);
|
||||
renderPanel();
|
||||
await selectCover();
|
||||
|
||||
const submit = screen.getByRole('button', { name: '发布游戏' });
|
||||
fireEvent.click(submit);
|
||||
@@ -182,6 +220,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
new Error('上传游戏发行包失败:游戏分发服务暂不可用(503)'),
|
||||
);
|
||||
renderPanel();
|
||||
await selectCover();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
|
||||
@@ -193,6 +232,77 @@ describe('GameDistributionPublishPanel', () => {
|
||||
expect(screen.queryByText('已提交审核')).toBeNull();
|
||||
});
|
||||
|
||||
test('没有选择封面时不发起发布并给出可操作提示', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('请先选择游戏封面(JPG/PNG/WebP)'),
|
||||
).not.toBeNull();
|
||||
expect(publishLocalProjectGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('封面与截图都先上传成平台素材,重复提交复用同一素材 ID', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
vi.mocked(publishLocalProjectGame).mockRejectedValue(new Error('先失败'));
|
||||
const coverFile = buildImageFile('cover.png');
|
||||
renderPanel();
|
||||
await selectCover(coverFile, 'asset_cover_cached');
|
||||
|
||||
expect(vi.mocked(uploadPlatformMediaAsset).mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
assetKind: 'game_distribution_cover',
|
||||
entityId: 'game-distribution-cover',
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
await waitFor(() =>
|
||||
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
// 再次选择同一个文件对象不应重新上传(面板按文件签名复用素材 ID)。
|
||||
fireEvent.change(screen.getByLabelText(/游戏封面/u), {
|
||||
target: { files: [coverFile] },
|
||||
});
|
||||
await screen.findByText(/已选择「cover.png」/u);
|
||||
expect(uploadPlatformMediaAsset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('截图超过 6 张时本地拦截且不上传', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
renderPanel();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/游戏截图/u), {
|
||||
target: {
|
||||
files: Array.from({ length: 7 }, (_, index) =>
|
||||
buildImageFile(`shot-${index}.png`),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/游戏截图最多 6 张/u)).not.toBeNull();
|
||||
expect(uploadPlatformMediaAsset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('素材上传失败时保留面板并展示原因', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
vi.mocked(uploadPlatformMediaAsset).mockRejectedValueOnce(
|
||||
new Error('创建素材上传凭证失败'),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/游戏封面/u), {
|
||||
target: { files: [buildImageFile('cover.png')] },
|
||||
});
|
||||
|
||||
expect(await screen.findByText('创建素材上传凭证失败')).not.toBeNull();
|
||||
expect(screen.getByText('还没有封面')).not.toBeNull();
|
||||
expect(publishLocalProjectGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('不在 Tauri 宿主或缺少试玩包时失败关闭且不调用发布接口', async () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
|
||||
@@ -86,6 +86,16 @@
|
||||
- 移动视口真实游玩:headless Chromium 以 `390x844` 打开已发布游戏 `/games/play?id=<id>`,页面显示「横屏设计,旋转设备」提示与移动端底部导航,点击「开始游戏」后 iframe 以 `sandbox="allow-scripts"` + `allow="fullscreen"` 挂载发行网关地址并 `ready`,`index.html` 与 `assets/app.js` 均返回 200,控制台无 CSP/CORP 报错;截图存于本轮验证记录(不入库)。
|
||||
本轮同时修掉三个真实缺陷:Vite dev 代理缺少 `/api/game-distribution` 前缀(本地全部 404);详情页在「已发布但没有 controls」时误显示「暂未发布可玩版本」;发行网关的 `Cross-Origin-Resource-Policy: same-origin` 会让 opaque origin 沙箱内的游戏加载不了自己的脚本(改为 `cross-origin` + 无凭据 CORS,详见 `pitfalls.md`)。
|
||||
|
||||
- 资料冻结与展示闭环(封面 + 截图):游戏表末尾新增可空 `cover_object_key` / `screenshots_json`,版本表末尾新增可空 `metadata_json`;创建版本时 api-server 校验「必需封面、≤6 张截图、素材属于当前作者且 `content_type` 为 `image/`」,并从素材记录派生对象键生成冻结快照,审核通过时整体生效到游戏行。
|
||||
- 公开素材读授权:只有 `published` 且存在有效 `active_version_id` 的游戏,其封面/截图素材才在 `/api/assets/read-url` 获得匿名读授权;其余素材仍按 owner 校验。
|
||||
- 作者续发复用:版本回读(作者本人)与审核回读(管理员)的版本投影新增 `frozenMetadata`,带回 `coverAssetId` / `screenshots[].assetId`;`/games/publish` 更新模式据此预填封面与截图,不要求作者为沿用封面重新上传;快照缺素材 ID 的旧版本明确要求重新选择封面。公开投影仍只暴露对象键。
|
||||
- 网页发布资料入口:`/games/publish` 新增「封面与截图」区(封面必需、截图 ≤6、可逐张移除、上传中禁用提交),复用平台图片直传 + confirm 通道;本地校验与服务端口径对齐(缺封面/超 6 张在发请求前拦截)。
|
||||
- 网页展示:游戏广场卡片与详情页 hero 用 `useResolvedAssetReadUrl` 换签展示真实封面,详情页在存在截图时给出可点击缩略图条(封面 + 截图,选中态 + 键盘可达),换签失败或无素材时静默回退原有渐变占位,不出现空框。
|
||||
- 本切片验证:`cargo check -p api-server -p spacetime-module -p spacetime-client`;`cargo test -p api-server game_distribution`(17 passed,含新增 `version_detail_payload_exposes_frozen_metadata_to_owner`);`npm run typecheck`;`npx vitest run src/components/game-distribution src/services/gameDistributionClient.test.ts`(46 passed);改动文件 `eslint --max-warnings 0` 与 `npm run check:encoding`。
|
||||
|
||||
- AGC 发布面板资料入口:新增 `apps/ai-game-creator-shell/src/services/assetDirectUpload.ts`(凭证 → 直传 → confirm,直传固定走 Tauri HTTP 插件并校验目标主机必须是平台素材存储),面板支持封面必选 + 截图 ≤6、本地预览、逐张移除、上传中禁用发布;缺封面时在创建游戏前失败关闭,服务端「封面」类错误原样展示;同一文件重复提交复用素材 ID 不重复直传。为支持直传,`src-tauri/capabilities/main.json` 的 http 作用域新增 `https://*.aliyuncs.com/*`(配合客户端主机白名单,避免把本地文件发给任意主机)。
|
||||
- AGC 测试证据:`npx vitest run apps/ai-game-creator-shell/tests/assetDirectUpload.test.ts apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx apps/ai-game-creator-shell/tests/clientApi.test.ts`(27 passed);`npm --prefix apps/ai-game-creator-shell run typecheck`;改动文件 `eslint --max-warnings 0`。
|
||||
|
||||
## 尚未完成
|
||||
|
||||
- 真实独立发行域名、通配 TLS 与 CDN 仍属部署侧:边缘模板与门禁已就绪,本地已用真实 nginx 验证按主机映射、Cookie 403 与命名空间隔离,但仍需在真实域名/证书下跑一次“审核通过 → 游玩 → 换版 → 下架”并确认 CDN TTL 不超过 60 秒窗口。
|
||||
|
||||
@@ -663,6 +663,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- Rust 结构体:`GameDistributionGame`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`
|
||||
- 用途:游戏分发稳定身份与公开版本指针。保存 owner、标题/简介/分类资料、设备与输入声明、`publication_revision`、当前 `active_version_id`、可见性和游玩计数;标签与输入模式按版本化 JSON 保存,展示资料由 `api-server` 通过 `spacetime-client` 归一后返回。
|
||||
- 公开素材:游戏行末尾追加可空 `cover_object_key` 与 `screenshots_json`(截图 `{assetId, objectKey}` 数组)。只有可见性为 `published` 且存在有效 `active_version_id` 的游戏,其封面/截图素材才在 `/api/assets/read-url` 上获得匿名读授权。
|
||||
- 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。
|
||||
- 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published` 且存在有效 `active_version_id` 的投影。
|
||||
|
||||
@@ -672,6 +673,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`
|
||||
- 用途:不可变发行版本与真实包确认事实。创建后冻结 `package_sha256`、字节数、文件数、根入口和版本号;后续只推进上传、校验、审核、公开、撤回状态,并记录私有对象键、文件清单、入口 URL、审核者和阶段时间。
|
||||
- 索引:`by_game_distribution_version_game_id`、`by_game_distribution_version_owner_user_id`。真实 ZIP 由 `api-server` 校验并写入私有 OSS 后,才通过 facade 确认 `uploaded`;表不保存 ZIP 正文。
|
||||
- 冻结资料:版本表末尾追加可空 `metadata_json`,保存创建版本时由 api-server 校验(标题/简介/分类/标签/设备/方向/必需封面/≤6 张截图)并从素材记录派生对象键后的资料快照;`approve_game_distribution_version_and_return` 通过审核时把该快照整体生效到游戏行,因此公开投影展示的始终是“已随版本审核通过”的资料,旧版本(无快照)保持原值。
|
||||
- 作者回读投影:版本回读(作者本人)与审核回读(管理员)在版本 payload 上追加 `frozenMetadata`(冻结快照原样 JSON,历史版本为 `null`)。只有公开投影会剥掉素材 ID,作者与管理员拿到 `coverAssetId` / `screenshots[].assetId`,因此作者续发时可以直接复用同一批封面与截图素材,不需要为了沿用封面重新上传一次;素材 ID 缺失(旧版本)时前端必须要求作者重新选择封面,不能用对象键反推素材身份。
|
||||
- 撤回与回读:`cancel_game_distribution_version_and_return` 只允许把未参与当前公开投影的版本推进到 `cancelled`,并要求 `expected_publication_revision` 与游戏公开修订号一致;`get_game_distribution_version_and_return` 供管理员按版本 ID 直读。客户端看到的 `recoveryAction` 由 `api-server` 按 `status` 派生,不落表。
|
||||
|
||||
### `game_distribution_idempotency_receipt`
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。
|
||||
3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。
|
||||
4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。
|
||||
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。
|
||||
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。
|
||||
6. `supportedDevices` 至少包含 `desktop` 或 `mobile`;`inputModes` 来自 `keyboard`、`mouse`、`touch`;声明移动端必须包含 `touch`。`orientation` 为 `landscape`、`portrait` 或 `responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。
|
||||
7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。
|
||||
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
|
||||
|
||||
@@ -64,6 +64,13 @@ export type GameDistributionGame = {
|
||||
tags: string[];
|
||||
coverColor: string;
|
||||
icon: string;
|
||||
/**
|
||||
* 公开封面的 OSS objectKey;未上传封面时为空,展示层需回退到 coverColor/icon 占位。
|
||||
* 通过 `/api/assets/read-url` 换签名 URL 读取,不接受直连外链。
|
||||
*/
|
||||
coverObjectKey?: string | null;
|
||||
/** 公开截图的 OSS objectKey 列表,最多 6 张,随版本冻结。 */
|
||||
screenshots?: string[];
|
||||
author: GameDistributionAuthor;
|
||||
deviceSupport: GameDistributionDeviceSupport;
|
||||
inputModes?: GameDistributionInputMode[];
|
||||
@@ -90,6 +97,8 @@ export type GameDistributionCreateGameRequest = {
|
||||
category: GameDistributionCategory;
|
||||
tags?: string[];
|
||||
coverAssetId?: string;
|
||||
/** 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。 */
|
||||
screenshots?: string[];
|
||||
deviceSupport: GameDistributionDeviceSupport;
|
||||
inputModes: GameDistributionInputMode[];
|
||||
orientation: GameDistributionOrientation;
|
||||
@@ -130,10 +139,37 @@ export type GameDistributionRecoveryAction =
|
||||
| 'fix_package'
|
||||
| 'fix_metadata';
|
||||
|
||||
/** 冻结资料里的截图:同时保留素材 ID(作者续发可复用)与对象键(展示换签用)。 */
|
||||
export type GameDistributionFrozenScreenshot = {
|
||||
assetId: string;
|
||||
objectKey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 随版本冻结的游戏资料快照。
|
||||
*
|
||||
* 只有作者本人(版本回读)与管理员(审核回读)会拿到素材 ID;公开投影只给对象键。
|
||||
* 历史版本可能没有快照,读取方必须按空值处理。
|
||||
*/
|
||||
export type GameDistributionVersionFrozenMetadata = {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
category?: GameDistributionCategory;
|
||||
tags?: string[];
|
||||
coverAssetId?: string | null;
|
||||
coverObjectKey?: string | null;
|
||||
screenshots?: GameDistributionFrozenScreenshot[];
|
||||
deviceSupport?: GameDistributionDeviceSupport;
|
||||
inputModes?: GameDistributionInputMode[];
|
||||
orientation?: GameDistributionOrientation;
|
||||
};
|
||||
|
||||
export type GameDistributionVersionDetail = {
|
||||
game: GameDistributionGame;
|
||||
version: GameDistributionPrivateVersion & {
|
||||
recoveryAction: GameDistributionRecoveryAction;
|
||||
frozenMetadata?: GameDistributionVersionFrozenMetadata | null;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ pub(crate) const MAX_PACKAGE_REQUEST_BODY_BYTES: usize = MAX_PACKAGE_BYTES as us
|
||||
const MAX_LIST_LIMIT: u32 = 48;
|
||||
const MAX_IDEMPOTENCY_KEY_CHARS: usize = 128;
|
||||
const MAX_PACKAGE_MANIFEST_JSON_BYTES: usize = 2 * 1024 * 1024;
|
||||
/// 首版截图上限,与主规范冻结口径一致。
|
||||
const MAX_GAME_SCREENSHOTS: usize = 6;
|
||||
const GAME_DISTRIBUTION_OBJECT_PREFIX: &str = "agc/project-snapshots/v1/game-distribution/";
|
||||
const GAME_DISTRIBUTION_PUBLISHED_STATUS: &str = "published";
|
||||
/// 发行包 PUT 的尝试次数与退避,口径与 `platform-oss` 的可重试分类一致。
|
||||
@@ -521,8 +523,11 @@ async fn create_version(
|
||||
validate_version_declaration(&payload)?;
|
||||
let now = now_micros();
|
||||
let version_id = format!("gamever_{}", Uuid::new_v4().simple());
|
||||
let metadata_json =
|
||||
resolve_version_metadata_json(&state, auth.claims().user_id(), &payload.game_metadata)
|
||||
.await?;
|
||||
let request_digest = compute_request_digest(
|
||||
&serde_json::to_vec(&(game_id.as_str(), &payload))
|
||||
&serde_json::to_vec(&(game_id.as_str(), &payload, metadata_json.as_str()))
|
||||
.map_err(|error| internal(error.to_string()))?,
|
||||
);
|
||||
let version = state
|
||||
@@ -532,6 +537,7 @@ async fn create_version(
|
||||
game_id,
|
||||
owner_user_id: auth.claims().user_id().to_string(),
|
||||
version_id,
|
||||
metadata_json,
|
||||
package_sha256: payload.package_sha256,
|
||||
package_bytes: payload.package_bytes,
|
||||
package_file_count: payload.package_file_count,
|
||||
@@ -1252,6 +1258,25 @@ fn validate_game_metadata(payload: &GameDistributionCreateGameRequest) -> Result
|
||||
if payload.device_support.mobile && !payload.device_support.touch {
|
||||
return Err(bad_request("声明支持移动端时必须支持触控"));
|
||||
}
|
||||
if payload
|
||||
.cover_asset_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_none()
|
||||
{
|
||||
return Err(bad_request("发布游戏必须提供封面"));
|
||||
}
|
||||
if payload.screenshots.len() > MAX_GAME_SCREENSHOTS {
|
||||
return Err(bad_request("游戏截图最多 6 张"));
|
||||
}
|
||||
if payload
|
||||
.screenshots
|
||||
.iter()
|
||||
.any(|screenshot| screenshot.trim().is_empty())
|
||||
{
|
||||
return Err(bad_request("游戏截图素材 ID 不能为空"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1331,6 +1356,14 @@ fn public_game_payload(game: GameDistributionPublicGameRecord) -> Value {
|
||||
|
||||
fn game_payload(game: &GameDistributionGameRecord) -> Value {
|
||||
let tags = serde_json::from_str::<Vec<String>>(&game.tags_json).unwrap_or_default();
|
||||
let screenshots = game
|
||||
.screenshots_json
|
||||
.as_deref()
|
||||
.and_then(|json| serde_json::from_str::<Vec<FrozenGameScreenshot>>(json).ok())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|screenshot| screenshot.object_key)
|
||||
.collect::<Vec<_>>();
|
||||
let input_modes =
|
||||
serde_json::from_str::<Vec<GameDistributionInputMode>>(&game.input_modes_json)
|
||||
.unwrap_or_default();
|
||||
@@ -1343,6 +1376,8 @@ fn game_payload(game: &GameDistributionGameRecord) -> Value {
|
||||
"tags": tags,
|
||||
"coverColor": "#F3E4D0",
|
||||
"icon": "🎮",
|
||||
"coverObjectKey": game.cover_object_key,
|
||||
"screenshots": screenshots,
|
||||
"author": { "id": game.owner_user_id, "name": game.author_name.as_deref().unwrap_or("创作者"), "avatarUrl": game.author_avatar_url },
|
||||
"deviceSupport": { "desktop": game.device_support_desktop, "mobile": game.device_support_mobile, "touch": game.device_support_touch },
|
||||
"inputModes": input_modes,
|
||||
@@ -1416,6 +1451,94 @@ async fn ensure_publish_enabled(state: &AppState, user_id: Option<&str>) -> Resu
|
||||
}
|
||||
}
|
||||
|
||||
/// 冻结资料快照里的截图素材。
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FrozenGameScreenshot {
|
||||
asset_id: String,
|
||||
object_key: String,
|
||||
}
|
||||
|
||||
/// 校验封面/截图素材归属并生成版本冻结资料 JSON。
|
||||
///
|
||||
/// 对象键由服务端从素材记录派生,客户端只能提供素材 ID;素材必须属于当前作者且是图片。
|
||||
async fn resolve_version_metadata_json(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
metadata: &GameDistributionCreateGameRequest,
|
||||
) -> Result<String, AppError> {
|
||||
let cover_asset_id = metadata
|
||||
.cover_asset_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| bad_request("发布游戏必须提供封面"))?
|
||||
.to_string();
|
||||
let cover_object_key =
|
||||
resolve_owned_image_object_key(state, owner_user_id, cover_asset_id.as_str()).await?;
|
||||
let mut screenshots = Vec::with_capacity(metadata.screenshots.len());
|
||||
for asset_id in &metadata.screenshots {
|
||||
let asset_id = asset_id.trim();
|
||||
if asset_id.is_empty() {
|
||||
return Err(bad_request("游戏截图素材 ID 不能为空"));
|
||||
}
|
||||
let object_key = resolve_owned_image_object_key(state, owner_user_id, asset_id).await?;
|
||||
screenshots.push(FrozenGameScreenshot {
|
||||
asset_id: asset_id.to_string(),
|
||||
object_key,
|
||||
});
|
||||
}
|
||||
let tags = metadata
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| tag.trim())
|
||||
.filter(|tag| !tag.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let snapshot = json!({
|
||||
"title": metadata.title.trim(),
|
||||
"summary": metadata.summary.trim(),
|
||||
"description": metadata
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| metadata.summary.trim().to_string()),
|
||||
"category": metadata.category,
|
||||
"tags": tags,
|
||||
"coverAssetId": cover_asset_id,
|
||||
"coverObjectKey": cover_object_key,
|
||||
"screenshots": screenshots,
|
||||
"deviceSupport": {
|
||||
"desktop": metadata.device_support.desktop,
|
||||
"mobile": metadata.device_support.mobile,
|
||||
"touch": metadata.device_support.touch,
|
||||
},
|
||||
"inputModes": metadata.input_modes,
|
||||
"orientation": metadata.orientation,
|
||||
});
|
||||
serde_json::to_string(&snapshot).map_err(|error| internal(error.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_owned_image_object_key(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
asset_object_id: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let asset = state
|
||||
.spacetime_client()
|
||||
.get_asset_object(asset_object_id.to_string())
|
||||
.await
|
||||
.map_err(map_spacetime_error)?
|
||||
.ok_or_else(|| bad_request("封面或截图素材不存在"))?;
|
||||
if asset.owner_user_id.as_deref() != Some(owner_user_id) {
|
||||
return Err(AppError::from_status(StatusCode::FORBIDDEN)
|
||||
.with_message("封面或截图素材不属于当前账号"));
|
||||
}
|
||||
let content_type = asset.content_type.as_deref().unwrap_or("");
|
||||
if !content_type.starts_with("image/") {
|
||||
return Err(bad_request("封面和截图必须是图片素材"));
|
||||
}
|
||||
Ok(asset.object_key)
|
||||
}
|
||||
|
||||
/// 读取当前主体名下的版本;未知版本和别人的版本都按不可见处理(404)。
|
||||
async fn load_owner_version_or_404(
|
||||
state: &AppState,
|
||||
@@ -1436,17 +1559,28 @@ async fn load_owner_version_or_404(
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本私有投影:在通用私有字段上追加客户端恢复动作。
|
||||
/// 版本私有投影:在通用私有字段上追加客户端恢复动作与随版本冻结的资料快照。
|
||||
///
|
||||
/// 该投影只用于作者本人与管理员回读,因此可以带上冻结资料里的素材 ID:作者更新游戏时
|
||||
/// 复用同一批素材,不需要为了沿用封面重新上传一次;快照缺失(历史版本)时按空值返回。
|
||||
fn version_detail_payload(
|
||||
version: &GameDistributionVersionRecord,
|
||||
game: &GameDistributionGameRecord,
|
||||
) -> Value {
|
||||
let mut payload = private_version_payload(version);
|
||||
let frozen_metadata = version
|
||||
.metadata_json
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
if let Value::Object(ref mut object) = payload {
|
||||
object.insert(
|
||||
"recoveryAction".to_string(),
|
||||
Value::String(recovery_action_for_status(version.status.as_str()).to_string()),
|
||||
);
|
||||
object.insert("frozenMetadata".to_string(), frozen_metadata);
|
||||
}
|
||||
json!({ "game": game_payload(game), "version": payload })
|
||||
}
|
||||
@@ -1548,6 +1682,7 @@ mod tests {
|
||||
GameDistributionCreateGameRequest {
|
||||
local_project_id: None,
|
||||
title: "测试游戏".to_string(),
|
||||
screenshots: Vec::new(),
|
||||
summary: "用于验证发行合同".to_string(),
|
||||
description: Some("描述".to_string()),
|
||||
category: "益智".to_string(),
|
||||
@@ -1575,6 +1710,137 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_requires_cover_and_limits_screenshots() {
|
||||
let mut payload = metadata();
|
||||
payload.cover_asset_id = None;
|
||||
assert_eq!(
|
||||
validate_game_metadata(&payload)
|
||||
.expect_err("缺少封面必须被拒")
|
||||
.status_code(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
|
||||
let mut payload = metadata();
|
||||
payload.cover_asset_id = Some("asset_cover".to_string());
|
||||
payload.screenshots = (0..7).map(|index| format!("asset_{index}")).collect();
|
||||
assert_eq!(
|
||||
validate_game_metadata(&payload)
|
||||
.expect_err("超过 6 张截图必须被拒")
|
||||
.status_code(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
|
||||
let mut payload = metadata();
|
||||
payload.cover_asset_id = Some("asset_cover".to_string());
|
||||
payload.screenshots = (0..6).map(|index| format!("asset_{index}")).collect();
|
||||
validate_game_metadata(&payload).expect("封面 + 6 张截图应通过校验");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_payload_exposes_cover_and_screenshot_object_keys() {
|
||||
let game = GameDistributionGameRecord {
|
||||
game_id: "game_1".to_string(),
|
||||
owner_user_id: "user_1".to_string(),
|
||||
title: "封面游戏".to_string(),
|
||||
summary: "摘要".to_string(),
|
||||
description: "描述".to_string(),
|
||||
category: "益智".to_string(),
|
||||
tags_json: "[]".to_string(),
|
||||
cover_asset_id: Some("asset_cover".to_string()),
|
||||
author_name: None,
|
||||
author_avatar_url: None,
|
||||
device_support_desktop: true,
|
||||
device_support_mobile: false,
|
||||
device_support_touch: false,
|
||||
input_modes_json: "[]".to_string(),
|
||||
orientation: "responsive".to_string(),
|
||||
publication_revision: 1,
|
||||
active_version_id: Some("version_1".to_string()),
|
||||
visibility: "published".to_string(),
|
||||
play_count: 0,
|
||||
created_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
updated_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
cover_object_key: Some("generated/game-cover.png".to_string()),
|
||||
screenshots_json: Some(
|
||||
r#"[{"assetId":"asset_1","objectKey":"generated/shot-1.png"}]"#.to_string(),
|
||||
),
|
||||
};
|
||||
let payload = game_payload(&game);
|
||||
assert_eq!(
|
||||
payload["coverObjectKey"],
|
||||
Value::String("generated/game-cover.png".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["screenshots"][0],
|
||||
Value::String("generated/shot-1.png".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_detail_payload_exposes_frozen_metadata_to_owner() {
|
||||
let game = GameDistributionGameRecord {
|
||||
game_id: "game_1".to_string(),
|
||||
owner_user_id: "user_1".to_string(),
|
||||
title: "封面游戏".to_string(),
|
||||
summary: "摘要".to_string(),
|
||||
description: "描述".to_string(),
|
||||
category: "益智".to_string(),
|
||||
tags_json: "[]".to_string(),
|
||||
cover_asset_id: Some("asset_cover".to_string()),
|
||||
author_name: None,
|
||||
author_avatar_url: None,
|
||||
device_support_desktop: true,
|
||||
device_support_mobile: false,
|
||||
device_support_touch: false,
|
||||
input_modes_json: "[]".to_string(),
|
||||
orientation: "responsive".to_string(),
|
||||
publication_revision: 1,
|
||||
active_version_id: Some("version_1".to_string()),
|
||||
visibility: "published".to_string(),
|
||||
play_count: 0,
|
||||
created_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
updated_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
cover_object_key: Some("generated/game-cover.png".to_string()),
|
||||
screenshots_json: None,
|
||||
};
|
||||
let mut version = GameDistributionVersionRecord {
|
||||
version_id: "version_2".to_string(),
|
||||
game_id: "game_1".to_string(),
|
||||
owner_user_id: "user_1".to_string(),
|
||||
version_number: 2,
|
||||
package_sha256: "a".repeat(64),
|
||||
package_bytes: 1024,
|
||||
package_file_count: 1,
|
||||
package_entry_path: "index.html".to_string(),
|
||||
status: "pending_review".to_string(),
|
||||
review_reason: None,
|
||||
entry_url: None,
|
||||
publication_revision: 1,
|
||||
created_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
updated_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
metadata_json: Some(
|
||||
r#"{"coverAssetId":"asset_cover","coverObjectKey":"generated/game-cover.png","screenshots":[{"assetId":"asset_shot","objectKey":"generated/shot.png"}]}"#
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let payload = version_detail_payload(&version, &game);
|
||||
assert_eq!(
|
||||
payload["version"]["frozenMetadata"]["coverAssetId"],
|
||||
Value::String("asset_cover".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["version"]["frozenMetadata"]["screenshots"][0]["assetId"],
|
||||
Value::String("asset_shot".to_string())
|
||||
);
|
||||
|
||||
// 历史版本没有冻结资料时按 null 返回,客户端必须按空值处理。
|
||||
version.metadata_json = None;
|
||||
let legacy_payload = version_detail_payload(&version, &game);
|
||||
assert!(legacy_payload["version"]["frozenMetadata"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_validation_errors_are_unprocessable() {
|
||||
let error = map_package_error(ReleasePackageError::MissingEntry);
|
||||
|
||||
@@ -120,6 +120,9 @@ pub struct GameDistributionCreateGameRequest {
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub cover_asset_id: Option<String>,
|
||||
/// 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。
|
||||
#[serde(default)]
|
||||
pub screenshots: Vec<String>,
|
||||
pub device_support: GameDistributionDeviceSupport,
|
||||
pub input_modes: Vec<GameDistributionInputMode>,
|
||||
pub orientation: GameDistributionOrientation,
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct GameDistributionGameRecord {
|
||||
pub play_count: u64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub cover_object_key: Option<String>,
|
||||
pub screenshots_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
@@ -41,6 +43,8 @@ pub struct GameDistributionVersionRecord {
|
||||
pub publication_revision: u64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
/// 创建版本时冻结的资料快照;历史版本可能为空,读取方必须容忍。
|
||||
pub metadata_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
@@ -80,6 +84,8 @@ fn map_game(
|
||||
play_count: value.play_count,
|
||||
created_at: shared_kernel::format_timestamp_micros(value.created_at_micros),
|
||||
updated_at: shared_kernel::format_timestamp_micros(value.updated_at_micros),
|
||||
cover_object_key: value.cover_object_key,
|
||||
screenshots_json: value.screenshots_json,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +107,7 @@ fn map_version(
|
||||
publication_revision: value.publication_revision,
|
||||
created_at: shared_kernel::format_timestamp_micros(value.created_at_micros),
|
||||
updated_at: shared_kernel::format_timestamp_micros(value.updated_at_micros),
|
||||
metadata_json: value.metadata_json,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ pub struct GameDistributionCreateVersionRecordInput {
|
||||
pub game_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub version_id: String,
|
||||
/// api-server 校验并解析素材后生成的资料快照 JSON。
|
||||
pub metadata_json: String,
|
||||
pub package_sha256: String,
|
||||
pub package_bytes: u64,
|
||||
pub package_file_count: u32,
|
||||
@@ -292,6 +294,7 @@ impl SpacetimeClient {
|
||||
game_id: input.game_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
version_id: input.version_id,
|
||||
metadata_json: input.metadata_json,
|
||||
package_sha_256: input.package_sha256,
|
||||
package_bytes: input.package_bytes,
|
||||
package_file_count: input.package_file_count,
|
||||
|
||||
+1
@@ -10,6 +10,7 @@ pub struct GameDistributionCreateVersionInput {
|
||||
pub game_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub version_id: String,
|
||||
pub metadata_json: String,
|
||||
pub package_sha_256: String,
|
||||
pub package_bytes: u64,
|
||||
pub package_file_count: u32,
|
||||
|
||||
+2
@@ -28,6 +28,8 @@ pub struct GameDistributionGameSnapshot {
|
||||
pub play_count: u64,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
pub cover_object_key: Option<String>,
|
||||
pub screenshots_json: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionGameSnapshot {
|
||||
|
||||
@@ -29,6 +29,8 @@ pub struct GameDistributionGame {
|
||||
pub created_at: __sdk::Timestamp,
|
||||
pub updated_at: __sdk::Timestamp,
|
||||
pub local_project_id: Option<String>,
|
||||
pub cover_object_key: Option<String>,
|
||||
pub screenshots_json: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionGame {
|
||||
@@ -61,6 +63,8 @@ pub struct GameDistributionGameCols {
|
||||
pub created_at: __sdk::__query_builder::Col<GameDistributionGame, __sdk::Timestamp>,
|
||||
pub updated_at: __sdk::__query_builder::Col<GameDistributionGame, __sdk::Timestamp>,
|
||||
pub local_project_id: __sdk::__query_builder::Col<GameDistributionGame, Option<String>>,
|
||||
pub cover_object_key: __sdk::__query_builder::Col<GameDistributionGame, Option<String>>,
|
||||
pub screenshots_json: __sdk::__query_builder::Col<GameDistributionGame, Option<String>>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for GameDistributionGame {
|
||||
@@ -101,6 +105,8 @@ impl __sdk::__query_builder::HasCols for GameDistributionGame {
|
||||
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
||||
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
||||
local_project_id: __sdk::__query_builder::Col::new(table_name, "local_project_id"),
|
||||
cover_object_key: __sdk::__query_builder::Col::new(table_name, "cover_object_key"),
|
||||
screenshots_json: __sdk::__query_builder::Col::new(table_name, "screenshots_json"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -21,6 +21,7 @@ pub struct GameDistributionVersionSnapshot {
|
||||
pub publication_revision: u64,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
pub metadata_json: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionVersionSnapshot {
|
||||
|
||||
+3
@@ -31,6 +31,7 @@ pub struct GameDistributionVersion {
|
||||
pub revoked_at: Option<__sdk::Timestamp>,
|
||||
pub last_error_code: Option<String>,
|
||||
pub last_error_message: Option<String>,
|
||||
pub metadata_json: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionVersion {
|
||||
@@ -67,6 +68,7 @@ pub struct GameDistributionVersionCols {
|
||||
pub revoked_at: __sdk::__query_builder::Col<GameDistributionVersion, Option<__sdk::Timestamp>>,
|
||||
pub last_error_code: __sdk::__query_builder::Col<GameDistributionVersion, Option<String>>,
|
||||
pub last_error_message: __sdk::__query_builder::Col<GameDistributionVersion, Option<String>>,
|
||||
pub metadata_json: __sdk::__query_builder::Col<GameDistributionVersion, Option<String>>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for GameDistributionVersion {
|
||||
@@ -103,6 +105,7 @@ impl __sdk::__query_builder::HasCols for GameDistributionVersion {
|
||||
revoked_at: __sdk::__query_builder::Col::new(table_name, "revoked_at"),
|
||||
last_error_code: __sdk::__query_builder::Col::new(table_name, "last_error_code"),
|
||||
last_error_message: __sdk::__query_builder::Col::new(table_name, "last_error_message"),
|
||||
metadata_json: __sdk::__query_builder::Col::new(table_name, "metadata_json"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6329,7 +6329,13 @@ 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));
|
||||
.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(),
|
||||
)
|
||||
});
|
||||
let campaign_config = ctx
|
||||
.db
|
||||
.editor_showcase_campaign_config()
|
||||
|
||||
@@ -40,6 +40,12 @@ pub struct GameDistributionGame {
|
||||
/// AGC / 网页发布方的本地项目标识。只用于同一作者复用游戏身份,不构成所有权证明。
|
||||
#[default(None::<String>)]
|
||||
pub(crate) local_project_id: Option<String>,
|
||||
/// 审核通过后生效的封面对象键;客户端用它换平台签名读地址。
|
||||
#[default(None::<String>)]
|
||||
pub(crate) cover_object_key: Option<String>,
|
||||
/// 审核通过后生效的截图对象键 JSON 数组(与 `cover_asset_id` 同源,来自版本冻结资料)。
|
||||
#[default(None::<String>)]
|
||||
pub(crate) screenshots_json: Option<String>,
|
||||
}
|
||||
|
||||
/// 游戏发行版本。包摘要、确认字节数和送审资料在创建后作为不可变快照保留;状态和
|
||||
@@ -94,6 +100,9 @@ pub struct GameDistributionVersion {
|
||||
pub(crate) last_error_code: Option<String>,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) last_error_message: Option<String>,
|
||||
/// 该版本冻结的作者资料快照(JSON,含封面与截图素材键)。审核通过时整体生效。
|
||||
#[default(None::<String>)]
|
||||
pub(crate) metadata_json: Option<String>,
|
||||
}
|
||||
|
||||
/// 创建、提交、审核、撤回和下架等写操作的幂等收据。
|
||||
@@ -194,6 +203,8 @@ pub struct GameDistributionCreateVersionInput {
|
||||
pub game_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub version_id: String,
|
||||
/// 由 api-server 校验并解析素材后生成的资料快照 JSON。
|
||||
pub metadata_json: String,
|
||||
pub package_sha256: String,
|
||||
pub package_bytes: u64,
|
||||
pub package_file_count: u32,
|
||||
@@ -350,6 +361,8 @@ pub struct GameDistributionGameSnapshot {
|
||||
pub play_count: u64,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
pub cover_object_key: Option<String>,
|
||||
pub screenshots_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
@@ -368,6 +381,8 @@ pub struct GameDistributionVersionSnapshot {
|
||||
pub publication_revision: u64,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
/// 该版本冻结的资料快照 JSON;旧版本为 None。
|
||||
pub metadata_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
@@ -927,6 +942,8 @@ fn create_game_distribution_game_tx(
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
local_project_id,
|
||||
cover_object_key: None,
|
||||
screenshots_json: None,
|
||||
});
|
||||
insert_game_distribution_receipt(
|
||||
ctx,
|
||||
@@ -963,6 +980,7 @@ fn create_game_distribution_version_tx(
|
||||
let game_id = required_game_distribution_text(input.game_id, "game_id")?;
|
||||
let version_id = required_game_distribution_text(input.version_id, "version_id")?;
|
||||
let package_sha256 = required_game_distribution_text(input.package_sha256, "package_sha256")?;
|
||||
let metadata_json = required_game_distribution_text(input.metadata_json, "metadata_json")?;
|
||||
let package_entry_path =
|
||||
required_game_distribution_text(input.package_entry_path, "package_entry_path")?;
|
||||
let idempotency_key =
|
||||
@@ -1054,6 +1072,7 @@ fn create_game_distribution_version_tx(
|
||||
revoked_at: None,
|
||||
last_error_code: None,
|
||||
last_error_message: None,
|
||||
metadata_json: Some(metadata_json),
|
||||
});
|
||||
insert_game_distribution_receipt(
|
||||
ctx,
|
||||
@@ -1545,6 +1564,11 @@ fn approve_game_distribution_version_tx(
|
||||
.game_distribution_version()
|
||||
.version_id()
|
||||
.update(version.clone());
|
||||
// 资料随版本冻结:审核通过时把该版本的资料快照整体生效到公开投影。
|
||||
if let Some(metadata_json) = version.metadata_json.as_deref() {
|
||||
let frozen = parse_game_distribution_frozen_metadata(metadata_json)?;
|
||||
apply_game_distribution_frozen_metadata(&mut game, &frozen);
|
||||
}
|
||||
game.active_version_id = Some(version.version_id.clone());
|
||||
game.visibility = GAME_DISTRIBUTION_VISIBILITY_PUBLISHED.to_string();
|
||||
game.publication_revision = game
|
||||
@@ -2159,9 +2183,99 @@ fn game_distribution_game_snapshot(game: &GameDistributionGame) -> GameDistribut
|
||||
play_count: game.play_count,
|
||||
created_at_micros: game.created_at.to_micros_since_unix_epoch(),
|
||||
updated_at_micros: game.updated_at.to_micros_since_unix_epoch(),
|
||||
cover_object_key: game.cover_object_key.clone(),
|
||||
screenshots_json: game.screenshots_json.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本冻结资料的 JSON 形状;由 api-server 在创建版本前校验并解析素材后写入。
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GameDistributionFrozenMetadata {
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
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 device_support: GameDistributionFrozenDeviceSupport,
|
||||
#[serde(default)]
|
||||
pub input_modes: Vec<String>,
|
||||
pub orientation: String,
|
||||
}
|
||||
|
||||
#[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,
|
||||
pub mobile: bool,
|
||||
pub touch: bool,
|
||||
}
|
||||
|
||||
fn parse_game_distribution_frozen_metadata(
|
||||
metadata_json: &str,
|
||||
) -> Result<GameDistributionFrozenMetadata, String> {
|
||||
serde_json::from_str(metadata_json).map_err(|_| "版本冻结资料格式无效".to_string())
|
||||
}
|
||||
|
||||
fn apply_game_distribution_frozen_metadata(
|
||||
game: &mut GameDistributionGame,
|
||||
frozen: &GameDistributionFrozenMetadata,
|
||||
) {
|
||||
game.title = frozen.title.clone();
|
||||
game.summary = frozen.summary.clone();
|
||||
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;
|
||||
game.device_support_mobile = frozen.device_support.mobile;
|
||||
game.device_support_touch = frozen.device_support.touch;
|
||||
game.input_modes_json =
|
||||
serde_json::to_string(&frozen.input_modes).unwrap_or_else(|_| "[]".to_string());
|
||||
game.orientation = frozen.orientation.clone();
|
||||
}
|
||||
|
||||
/// 已公开游戏的封面/截图素材允许匿名读者换取签名读地址。
|
||||
pub(crate) fn game_distribution_asset_has_public_read_grant(
|
||||
ctx: &ReducerContext,
|
||||
asset_object_id: &str,
|
||||
) -> bool {
|
||||
ctx.db.game_distribution_game().iter().any(|game| {
|
||||
if game.visibility != GAME_DISTRIBUTION_VISIBILITY_PUBLISHED
|
||||
|| game.active_version_id.is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if game.cover_asset_id.as_deref() == Some(asset_object_id) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn game_distribution_version_snapshot(
|
||||
version: &GameDistributionVersion,
|
||||
) -> GameDistributionVersionSnapshot {
|
||||
@@ -2180,6 +2294,7 @@ fn game_distribution_version_snapshot(
|
||||
publication_revision: 0,
|
||||
created_at_micros: version.created_at.to_micros_since_unix_epoch(),
|
||||
updated_at_micros: version.updated_at.to_micros_since_unix_epoch(),
|
||||
metadata_json: version.metadata_json.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -254,6 +254,8 @@ macro_rules! migration_tables {
|
||||
external_api_key,
|
||||
// 游戏分发:游戏身份、不可变发行版本和幂等收据都属于业务事实,
|
||||
// 随迁移导出/导入;私有 ZIP 与展开内容保存在对象存储,不进入这两张表。
|
||||
// 资料随版本冻结:版本表末尾追加 metadata_json,游戏表末尾追加
|
||||
// cover_object_key / screenshots_json,均为可空列,旧行按“未冻结资料”处理。
|
||||
game_distribution_game,
|
||||
game_distribution_version,
|
||||
game_distribution_idempotency_receipt,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user