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

- 服务端:游戏表追加可空 cover_object_key / screenshots_json,版本表追加可空 metadata_json,创建版本时校验必需封面、最多 6 张截图与图片素材归属,并从素材记录派生对象键生成冻结资料。
- 服务端:审核通过时把冻结资料整体生效到游戏行;只有已公开且存在有效活动版本的游戏,其封面/截图素材才在 /api/assets/read-url 获得匿名读授权。
- 服务端:作者与管理员回读版本时追加 frozenMetadata,带回封面与截图素材 ID 供续发复用,公开投影仍只暴露对象键。
- 网页:/games/publish 新增「封面与截图」区,复用平台图片直传与 confirm 通道,缺封面或截图超量在发请求前拦截,更新版本时按冻结资料回填且不要求重新上传。
- 网页:游戏广场卡片与详情页 hero 换签展示真实封面,详情页新增可点击的截图缩略图条,无素材或换签失败时静默回退原有渐变占位。
- AGC:发布面板支持封面必选与截图最多 6 张,新增素材直传服务并在创建游戏前拦截缺封面,同一文件重复提交复用素材 ID。
- AGC:http 能力作用域新增阿里云 OSS 直传地址,客户端同时校验直传目标必须是平台素材存储。
- 契约与文档:同步 shared-contracts、packages/shared 契约,更新 SpacetimeDB 迁移注释、后端架构、玩法链路与实施计划文档,并补齐网页端与 AGC 定向测试。
This commit is contained in:
2026-09-20 22:19:50 +08:00
parent 789aef4ce5
commit 4a2b270714
34 changed files with 2439 additions and 13 deletions
@@ -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",
@@ -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,
+125
View File
@@ -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,
@@ -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,
@@ -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"),
}
}
}
@@ -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 {
@@ -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