Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ac7f65c92 | |||
| 2f0e2a48a3 | |||
| 00b96f244d | |||
| 0dde6ee501 | |||
| 4a91e5865a | |||
| 4a2b270714 | |||
| 789aef4ce5 | |||
| 7ff6e46774 | |||
| 2254831db5 | |||
| 64425e8d1a | |||
| 328ac31844 |
@@ -213,6 +213,8 @@ module.exports = {
|
||||
'!src/services/clipboard.test.ts',
|
||||
'!src/services/frontendRuntimeConfigService.ts',
|
||||
'!src/services/frontendRuntimeConfigService.test.ts',
|
||||
'!src/services/gameDistributionClient.ts',
|
||||
'!src/services/gameDistributionClient.test.ts',
|
||||
'!src/services/sseStream.ts',
|
||||
'!src/services/sseStream.test.ts',
|
||||
'src/AdventurePanel.tsx',
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
executeAdminRechargeRefund,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminUserDetail,
|
||||
listAdminGameDistributionReviews,
|
||||
listAdminRechargeOrders,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
reviewAdminGameDistributionVersion,
|
||||
suspendAdminGameDistributionGame,
|
||||
updateAdminAccount,
|
||||
uploadAdminEditorShowcaseCampaignImage,
|
||||
upsertAdminFeatureGateConfig,
|
||||
@@ -364,3 +367,134 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ entries: [], nextCursor: null }), {
|
||||
status: 200,
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await listAdminGameDistributionReviews('admin-token');
|
||||
await reviewAdminGameDistributionVersion(
|
||||
'admin-token',
|
||||
'gamever/1',
|
||||
'game-review-key-1',
|
||||
{
|
||||
decision: 'approve',
|
||||
expectedPublicationRevision: 3,
|
||||
entryUrl: 'https://games.example.test/releases/game_1/index.html',
|
||||
},
|
||||
);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'/admin/api/game-distribution/reviews?limit=48',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
'/admin/api/game-distribution/versions/gamever%2F1/review',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer admin-token',
|
||||
'Idempotency-Key': 'game-review-key-1',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
decision: 'approve',
|
||||
expectedPublicationRevision: 3,
|
||||
entryUrl: 'https://games.example.test/releases/game_1/index.html',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ game: {}, replayed: false }), {
|
||||
status: 200,
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await suspendAdminGameDistributionGame(
|
||||
'admin-token',
|
||||
'game/1',
|
||||
'game-suspend-key-1',
|
||||
{ expectedPublicationRevision: 7, reason: '版权投诉' },
|
||||
);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'/admin/api/game-distribution/games/game%2F1/suspend',
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer admin-token',
|
||||
'Idempotency-Key': 'game-suspend-key-1',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
expectedPublicationRevision: 7,
|
||||
reason: '版权投诉',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
suspendAdminGameDistributionGame('admin-token', ' ', 'key', {
|
||||
expectedPublicationRevision: 1,
|
||||
}),
|
||||
).toThrow('缺少游戏 ID');
|
||||
expect(() =>
|
||||
suspendAdminGameDistributionGame('admin-token', 'game-1', ' ', {
|
||||
expectedPublicationRevision: 1,
|
||||
}),
|
||||
).toThrow('下架幂等键必须是 1 到 128 个字符');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('游戏审核拒绝请求携带理由,空幂等键在本地失败关闭', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ version: {}, replayed: false }), {
|
||||
status: 200,
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await reviewAdminGameDistributionVersion(
|
||||
'admin-token',
|
||||
'version-1',
|
||||
'game-review-key-2',
|
||||
{
|
||||
decision: 'reject',
|
||||
expectedPublicationRevision: 0,
|
||||
reviewReason: '运行时报错',
|
||||
},
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
decision: 'reject',
|
||||
expectedPublicationRevision: 0,
|
||||
reviewReason: '运行时报错',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
reviewAdminGameDistributionVersion('admin-token', 'version-1', ' ', {
|
||||
decision: 'reject',
|
||||
expectedPublicationRevision: 0,
|
||||
reviewReason: 'x',
|
||||
}),
|
||||
).toThrow('审核幂等键必须是 1 到 128 个字符');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -29,6 +29,9 @@ import type {
|
||||
AdminExternalApiKeyListQuery,
|
||||
AdminExternalApiKeyListResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminGameDistributionReviewListResponse,
|
||||
AdminGameDistributionReviewRequest,
|
||||
AdminGameDistributionReviewResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
@@ -1176,3 +1179,72 @@ export function saveAgcModelCatalog(
|
||||
{ token, method: 'PUT', body },
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminGameDistributionReviews(token: string, limit = 48) {
|
||||
const normalizedLimit = Number.isFinite(limit)
|
||||
? Math.min(Math.max(Math.trunc(limit), 1), 48)
|
||||
: 48;
|
||||
return request<AdminGameDistributionReviewListResponse>(
|
||||
`/admin/api/game-distribution/reviews?limit=${normalizedLimit}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
|
||||
* 两条审核结论。
|
||||
*/
|
||||
/**
|
||||
* 安全下架整个游戏。管理员下架同样要求 CAS 修订号与幂等键,避免并发审核互相覆盖。
|
||||
*/
|
||||
export function suspendAdminGameDistributionGame(
|
||||
token: string,
|
||||
gameId: string,
|
||||
idempotencyKey: string,
|
||||
payload: import('./adminApiTypes').AdminGameDistributionSuspendRequest,
|
||||
) {
|
||||
const normalizedGameId = gameId.trim();
|
||||
const normalizedKey = idempotencyKey.trim();
|
||||
if (!normalizedGameId) {
|
||||
throw new Error('缺少游戏 ID');
|
||||
}
|
||||
if (!normalizedKey || normalizedKey.length > 128) {
|
||||
throw new Error('下架幂等键必须是 1 到 128 个字符');
|
||||
}
|
||||
return request<
|
||||
import('./adminApiTypes').AdminGameDistributionSuspendResponse
|
||||
>(
|
||||
`/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/suspend`,
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
headers: { 'Idempotency-Key': normalizedKey },
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function reviewAdminGameDistributionVersion(
|
||||
token: string,
|
||||
versionId: string,
|
||||
idempotencyKey: string,
|
||||
payload: AdminGameDistributionReviewRequest,
|
||||
) {
|
||||
const normalizedVersionId = versionId.trim();
|
||||
const normalizedKey = idempotencyKey.trim();
|
||||
if (!normalizedVersionId) {
|
||||
throw new Error('缺少发行版本 ID');
|
||||
}
|
||||
if (!normalizedKey || normalizedKey.length > 128) {
|
||||
throw new Error('审核幂等键必须是 1 到 128 个字符');
|
||||
}
|
||||
return request<AdminGameDistributionReviewResponse>(
|
||||
`/admin/api/game-distribution/versions/${encodeURIComponent(normalizedVersionId)}/review`,
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
headers: { 'Idempotency-Key': normalizedKey },
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1031,3 +1031,48 @@ export interface AdminAgcModelCatalog {
|
||||
defaultModelId: string;
|
||||
models: AdminAgcModel[];
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionReviewEntry {
|
||||
versionId: string;
|
||||
gameId: string;
|
||||
versionNumber: number;
|
||||
packageSha256: string;
|
||||
packageBytes: number;
|
||||
status: string;
|
||||
publicationRevision: number;
|
||||
reviewReason: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionReviewListResponse {
|
||||
entries: AdminGameDistributionReviewEntry[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionReviewRequest {
|
||||
decision: 'approve' | 'reject';
|
||||
expectedPublicationRevision: number;
|
||||
reviewReason?: string;
|
||||
entryUrl?: string;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionReviewResponse {
|
||||
version: AdminGameDistributionReviewEntry;
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionSuspendRequest {
|
||||
expectedPublicationRevision: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionSuspendResponse {
|
||||
game: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
publicationRevision: number;
|
||||
};
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
|
||||
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
|
||||
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
|
||||
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
||||
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
|
||||
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
||||
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||
@@ -300,6 +301,12 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'game-distribution' ? (
|
||||
<AdminGameDistributionReviewPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'editor-assets' ? (
|
||||
<AdminEditorAssetQueryPage
|
||||
token={token}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Coins,
|
||||
Database,
|
||||
FolderArchive,
|
||||
Gamepad2,
|
||||
GitBranch,
|
||||
Images,
|
||||
LayoutDashboard,
|
||||
@@ -49,6 +50,7 @@ const routeIcons = {
|
||||
'recharge-orders': ReceiptText,
|
||||
'editor-generation-pricing': Coins,
|
||||
'editor-showcase': Star,
|
||||
'game-distribution': Gamepad2,
|
||||
'editor-assets': Images,
|
||||
'project-snapshots': FolderArchive,
|
||||
accounts: Users,
|
||||
|
||||
@@ -147,3 +147,24 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
|
||||
}),
|
||||
).not.toContainEqual(route);
|
||||
});
|
||||
|
||||
test('后台游戏审核路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'game-distribution',
|
||||
label: '游戏审核',
|
||||
hash: '#game-distribution',
|
||||
});
|
||||
expect(resolveAdminRoute('#game-distribution')).toBe('game-distribution');
|
||||
expect(routeHash('game-distribution')).toBe('#game-distribution');
|
||||
});
|
||||
|
||||
test('member 可单独获得游戏审核 Tab 权限', () => {
|
||||
const routes = getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['game-distribution'],
|
||||
});
|
||||
expect(routes.map((route) => route.id)).toEqual(['game-distribution']);
|
||||
expect(resolveAccessibleAdminRoute('#game-distribution', routes)).toBe(
|
||||
'game-distribution',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export type AdminRouteId =
|
||||
| 'recharge-orders'
|
||||
| 'editor-generation-pricing'
|
||||
| 'editor-showcase'
|
||||
| 'game-distribution'
|
||||
| 'editor-assets'
|
||||
| 'project-snapshots'
|
||||
| 'agc-models'
|
||||
@@ -54,6 +55,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
},
|
||||
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
|
||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
listAdminGameDistributionReviews,
|
||||
reviewAdminGameDistributionVersion,
|
||||
suspendAdminGameDistributionGame,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
|
||||
import {
|
||||
AdminGameDistributionReviewPage,
|
||||
resolveGameReleaseEntryUrlError,
|
||||
} from './AdminGameDistributionReviewPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
isAdminApiError: vi.fn(
|
||||
(error: unknown) =>
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'status' in error &&
|
||||
typeof error.status === 'number',
|
||||
),
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
listAdminGameDistributionReviews: vi.fn(),
|
||||
reviewAdminGameDistributionVersion: vi.fn(),
|
||||
suspendAdminGameDistributionGame: vi.fn(),
|
||||
}));
|
||||
|
||||
const entry: AdminGameDistributionReviewEntry = {
|
||||
versionId: 'version-1',
|
||||
gameId: 'game_1',
|
||||
versionNumber: 2,
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageBytes: 2048,
|
||||
status: 'pending_review',
|
||||
publicationRevision: 4,
|
||||
reviewReason: null,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
updatedAt: '2026-09-20T08:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listAdminGameDistributionReviews).mockReset();
|
||||
vi.mocked(reviewAdminGameDistributionVersion).mockReset();
|
||||
vi.mocked(suspendAdminGameDistributionGame).mockReset();
|
||||
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
|
||||
entries: [entry],
|
||||
nextCursor: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('发行入口必须是带完整来源的 HTTPS 地址', () => {
|
||||
expect(resolveGameReleaseEntryUrlError('')).toBe('请填写发行入口');
|
||||
expect(
|
||||
resolveGameReleaseEntryUrlError('http://games.test/a/index.html'),
|
||||
).toBe('发行入口必须以 https:// 开头');
|
||||
expect(
|
||||
resolveGameReleaseEntryUrlError('https://games.test/a/index.html?token=1'),
|
||||
).toBe('发行入口不能包含 query 或 fragment');
|
||||
expect(
|
||||
resolveGameReleaseEntryUrlError('https://u:p@games.test/a/index.html'),
|
||||
).toBe('发行入口不能包含凭据');
|
||||
expect(
|
||||
resolveGameReleaseEntryUrlError('https://games.test/a/index.html'),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('通过审核时提交当前 publicationRevision 与发行入口并刷新列表', async () => {
|
||||
vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({
|
||||
version: { ...entry, status: 'published' },
|
||||
replayed: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发行入口'), {
|
||||
target: { value: 'https://games.test/releases/game_1/index.html' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '通过' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const [token, versionId, idempotencyKey, payload] =
|
||||
vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? [];
|
||||
expect(token).toBe('admin-token');
|
||||
expect(versionId).toBe('version-1');
|
||||
expect(String(idempotencyKey)).toContain('version-1');
|
||||
expect(payload).toEqual({
|
||||
decision: 'approve',
|
||||
expectedPublicationRevision: 4,
|
||||
entryUrl: 'https://games.test/releases/game_1/index.html',
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes(
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('缺少拒绝理由时不调用审核接口', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
|
||||
|
||||
expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy();
|
||||
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('安全下架需要二次确认,并携带公开修订号与原因', async () => {
|
||||
vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({
|
||||
game: {
|
||||
id: 'game_1',
|
||||
title: '测试游戏',
|
||||
status: 'suspended',
|
||||
publicationRevision: 5,
|
||||
},
|
||||
replayed: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('下架原因'), {
|
||||
target: { value: '盗用素材' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
|
||||
|
||||
// 第一次点击只弹出确认面板,不直接调用后端。
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
expect(await screen.findByRole('dialog')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const [token, gameId, idempotencyKey, payload] =
|
||||
vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? [];
|
||||
expect(token).toBe('admin-token');
|
||||
expect(gameId).toBe('game_1');
|
||||
expect(String(idempotencyKey)).toContain('game_1');
|
||||
expect(payload).toEqual({
|
||||
expectedPublicationRevision: 4,
|
||||
reason: '盗用素材',
|
||||
});
|
||||
expect(await screen.findByText(/已安全下架/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('取消确认时不下架', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
|
||||
await screen.findByRole('dialog');
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
listAdminGameDistributionReviews,
|
||||
reviewAdminGameDistributionVersion,
|
||||
suspendAdminGameDistributionGame,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminGameDistributionReviewPageProps {
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value >= 1024 * 1024) {
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
if (value >= 1024) {
|
||||
return `${(value / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
return `${value} B`;
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return parsed.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function createSuspendIdempotencyKey(gameId: string) {
|
||||
const random =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `game-suspend-${gameId}-${random}`.slice(0, 128);
|
||||
}
|
||||
|
||||
function createReviewIdempotencyKey(versionId: string) {
|
||||
const random =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `game-review-${versionId}-${random}`.slice(0, 128);
|
||||
}
|
||||
|
||||
export function resolveGameReleaseEntryUrlError(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return '请填写发行入口';
|
||||
if (!normalized.startsWith('https://')) {
|
||||
return '发行入口必须以 https:// 开头';
|
||||
}
|
||||
if (normalized.includes('?') || normalized.includes('#')) {
|
||||
return '发行入口不能包含 query 或 fragment';
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
if (parsed.username || parsed.password) {
|
||||
return '发行入口不能包含凭据';
|
||||
}
|
||||
} catch {
|
||||
return '发行入口不是合法 URL';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function AdminGameDistributionReviewPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: AdminGameDistributionReviewPageProps) {
|
||||
const [entries, setEntries] = useState<AdminGameDistributionReviewEntry[]>(
|
||||
[],
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [busyVersionId, setBusyVersionId] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [entryUrlByVersion, setEntryUrlByVersion] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [reasonByVersion, setReasonByVersion] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [busyGameId, setBusyGameId] = useState('');
|
||||
const writeConfirm = useAdminWriteConfirm();
|
||||
|
||||
const loadReviews = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await listAdminGameDistributionReviews(token);
|
||||
setEntries(response.entries);
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [token, onUnauthorized]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadReviews();
|
||||
}, [loadReviews]);
|
||||
|
||||
async function submitReview(
|
||||
entry: AdminGameDistributionReviewEntry,
|
||||
decision: 'approve' | 'reject',
|
||||
) {
|
||||
const entryUrl = (entryUrlByVersion[entry.versionId] ?? '').trim();
|
||||
const reason = (reasonByVersion[entry.versionId] ?? '').trim();
|
||||
if (decision === 'approve') {
|
||||
const invalid = resolveGameReleaseEntryUrlError(entryUrl);
|
||||
if (invalid) {
|
||||
setErrorMessage(invalid);
|
||||
return;
|
||||
}
|
||||
} else if (!reason) {
|
||||
setErrorMessage('拒绝审核必须填写理由');
|
||||
return;
|
||||
}
|
||||
setBusyVersionId(entry.versionId);
|
||||
setErrorMessage('');
|
||||
setStatusMessage('');
|
||||
try {
|
||||
await reviewAdminGameDistributionVersion(
|
||||
token,
|
||||
entry.versionId,
|
||||
createReviewIdempotencyKey(entry.versionId),
|
||||
decision === 'approve'
|
||||
? {
|
||||
decision,
|
||||
expectedPublicationRevision: entry.publicationRevision,
|
||||
entryUrl,
|
||||
}
|
||||
: {
|
||||
decision,
|
||||
expectedPublicationRevision: entry.publicationRevision,
|
||||
reviewReason: reason,
|
||||
},
|
||||
);
|
||||
setStatusMessage(
|
||||
decision === 'approve'
|
||||
? `版本 v${entry.versionNumber} 已通过审核`
|
||||
: `版本 v${entry.versionNumber} 已拒绝`,
|
||||
);
|
||||
await loadReviews();
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setBusyVersionId('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时
|
||||
* 由服务端返回冲突,前端只提示刷新,不静默重试。
|
||||
*/
|
||||
async function suspendGame(entry: AdminGameDistributionReviewEntry) {
|
||||
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
|
||||
const confirmed = await writeConfirm.confirmWrite({
|
||||
action: '安全下架游戏',
|
||||
target: `${entry.gameId}(版本 v${entry.versionNumber})`,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
setBusyGameId(entry.gameId);
|
||||
setErrorMessage('');
|
||||
setStatusMessage('');
|
||||
try {
|
||||
await suspendAdminGameDistributionGame(
|
||||
token,
|
||||
entry.gameId,
|
||||
createSuspendIdempotencyKey(entry.gameId),
|
||||
{
|
||||
expectedPublicationRevision: entry.publicationRevision,
|
||||
...(reason ? { reason } : {}),
|
||||
},
|
||||
);
|
||||
setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`);
|
||||
setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' }));
|
||||
await loadReviews();
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setBusyGameId('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
<h1>游戏审核</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-secondary-button"
|
||||
onClick={() => void loadReviews()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCcw aria-hidden="true" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert admin-alert-warning" role="alert">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{statusMessage ? (
|
||||
<div className="admin-alert admin-alert-success" role="status">
|
||||
{statusMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h2>待审版本</h2>
|
||||
<span className="admin-muted-text">共 {entries.length} 条</span>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="admin-muted-text">正在加载待审版本…</p>
|
||||
) : null}
|
||||
|
||||
{!isLoading && entries.length === 0 ? (
|
||||
<p className="admin-muted-text">当前没有待审核的游戏版本。</p>
|
||||
) : null}
|
||||
|
||||
{!isLoading && entries.length > 0 ? (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>游戏</th>
|
||||
<th>版本</th>
|
||||
<th>发行包</th>
|
||||
<th>提交时间</th>
|
||||
<th>审核</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
const busy = busyVersionId === entry.versionId;
|
||||
return (
|
||||
<tr key={entry.versionId}>
|
||||
<td>
|
||||
<code>{entry.gameId}</code>
|
||||
</td>
|
||||
<td>
|
||||
v{entry.versionNumber}
|
||||
<div className="admin-muted-text">{entry.status}</div>
|
||||
{entry.reviewReason ? (
|
||||
<div className="admin-muted-text">
|
||||
{entry.reviewReason}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
{formatBytes(entry.packageBytes)}
|
||||
<div className="admin-muted-text">
|
||||
<code>{entry.packageSha256.slice(0, 12)}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatTime(entry.createdAt)}</td>
|
||||
<td>
|
||||
<div className="admin-action-row">
|
||||
<div className="admin-field">
|
||||
<label
|
||||
htmlFor={`game-release-url-${entry.versionId}`}
|
||||
>
|
||||
发行入口
|
||||
</label>
|
||||
<input
|
||||
id={`game-release-url-${entry.versionId}`}
|
||||
value={entryUrlByVersion[entry.versionId] ?? ''}
|
||||
placeholder="https://"
|
||||
onChange={(event) =>
|
||||
setEntryUrlByVersion((current) => ({
|
||||
...current,
|
||||
[entry.versionId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-primary-button"
|
||||
disabled={busy}
|
||||
onClick={() => void submitReview(entry, 'approve')}
|
||||
>
|
||||
通过
|
||||
</button>
|
||||
<div className="admin-field">
|
||||
<label
|
||||
htmlFor={`game-reject-reason-${entry.versionId}`}
|
||||
>
|
||||
拒绝理由
|
||||
</label>
|
||||
<input
|
||||
id={`game-reject-reason-${entry.versionId}`}
|
||||
value={reasonByVersion[entry.versionId] ?? ''}
|
||||
onChange={(event) =>
|
||||
setReasonByVersion((current) => ({
|
||||
...current,
|
||||
[entry.versionId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-ghost-button"
|
||||
disabled={busy}
|
||||
onClick={() => void submitReview(entry, 'reject')}
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
<div className="admin-field">
|
||||
<label
|
||||
htmlFor={`game-suspend-reason-${entry.versionId}`}
|
||||
>
|
||||
下架原因
|
||||
</label>
|
||||
<input
|
||||
id={`game-suspend-reason-${entry.versionId}`}
|
||||
value={suspendReasonByGame[entry.gameId] ?? ''}
|
||||
onChange={(event) =>
|
||||
setSuspendReasonByGame((current) => ({
|
||||
...current,
|
||||
[entry.gameId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-danger-button"
|
||||
disabled={busy || busyGameId === entry.gameId}
|
||||
onClick={() => void suspendGame(entry)}
|
||||
>
|
||||
{busyGameId === entry.gameId
|
||||
? '正在下架…'
|
||||
: '安全下架'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{writeConfirm.confirmDialog}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -5933,6 +5933,16 @@ pub(crate) fn export_local_project_package(
|
||||
export_local_project_package_at(root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_export_package(
|
||||
project_path: String,
|
||||
package_relative_path: String,
|
||||
) -> Result<LocalProjectExportPackagePayload, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "project.export_package")?;
|
||||
read_local_project_export_package_at(root, package_relative_path.trim())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_local_project_export_packages(
|
||||
project_path: String,
|
||||
|
||||
@@ -2692,6 +2692,7 @@ fn main() {
|
||||
build_local_project_index,
|
||||
create_local_project_checkpoint,
|
||||
export_local_project_package,
|
||||
read_local_project_export_package,
|
||||
list_local_project_export_packages,
|
||||
diff_local_project_checkpoint,
|
||||
restore_local_project_checkpoint,
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
use super::*;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeSet;
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct LocalProjectExportPackageFileDigest {
|
||||
pub(crate) path: String,
|
||||
pub(crate) size_bytes: u64,
|
||||
pub(crate) sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct LocalProjectExportPackagePayload {
|
||||
pub(crate) package_relative_path: String,
|
||||
pub(crate) package_bytes: Vec<u8>,
|
||||
pub(crate) package_sha256: String,
|
||||
pub(crate) package_size_bytes: u64,
|
||||
pub(crate) files: Vec<LocalProjectExportPackageFileDigest>,
|
||||
}
|
||||
|
||||
pub(crate) fn export_local_project_package_at(
|
||||
root: &Path,
|
||||
) -> Result<LocalProjectExportPackageResult, String> {
|
||||
@@ -110,6 +132,114 @@ pub(crate) fn export_local_project_package_at(
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a previously exported package for the explicit AGC publish flow.
|
||||
///
|
||||
/// The caller receives the package bytes and a deterministic file manifest, but
|
||||
/// never receives a filesystem path that it could accidentally send to the API.
|
||||
pub(crate) fn read_local_project_export_package_at(
|
||||
root: &Path,
|
||||
package_relative_path: &str,
|
||||
) -> Result<LocalProjectExportPackagePayload, String> {
|
||||
validate_project_root(root)?;
|
||||
let normalized = normalize_export_package_entry_path(package_relative_path)?;
|
||||
if !normalized.starts_with("exports/playtest-package-")
|
||||
|| !normalized.ends_with(".zip")
|
||||
|| normalized.contains('/') && normalized.split('/').count() != 2
|
||||
{
|
||||
return Err("发行包路径必须是 exports/playtest-package-*.zip".to_string());
|
||||
}
|
||||
let package_path = resolve_local_project_path(root, &normalized)?;
|
||||
prepare_game_creator_private_path_for_read(&package_path, false, "发行包")?;
|
||||
let metadata = checked_export_package_metadata(&package_path, &normalized)?;
|
||||
if !metadata.is_file() {
|
||||
return Err("发行包必须是普通文件".to_string());
|
||||
}
|
||||
if metadata.len() == 0 || metadata.len() > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
|
||||
return Err("发行包大小超出本地发布上限".to_string());
|
||||
}
|
||||
let package_bytes =
|
||||
fs::read(&package_path).map_err(|error| format!("读取发行包失败:{error}"))?;
|
||||
if package_bytes.len() as u64 != metadata.len() {
|
||||
return Err("发行包在读取期间发生变化,请重新导出".to_string());
|
||||
}
|
||||
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(&package_bytes))
|
||||
.map_err(|error| format!("读取发行包 ZIP 失败:{error}"))?;
|
||||
let mut entries = Vec::with_capacity(archive.len());
|
||||
let mut seen = BTreeSet::new();
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(index)
|
||||
.map_err(|error| format!("读取发行包条目失败:{error}"))?;
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let source_path = normalize_export_package_entry_path(entry.name())?;
|
||||
// 本地试玩包以 game/index.html 为入口,而平台发行合同要求根
|
||||
// index.html。把 game/ 前缀剥离到内存 ZIP,避免上传本地路径或修改
|
||||
// 工作区里的原始导出文件;根目录的 README/assets 等公共条目原样保留。
|
||||
let path = source_path
|
||||
.strip_prefix("game/")
|
||||
.unwrap_or(source_path.as_str())
|
||||
.to_string();
|
||||
let path = normalize_export_package_entry_path(&path)?;
|
||||
if !seen.insert(path.clone()) {
|
||||
return Err(format!("发行包包含重复条目:{path}"));
|
||||
}
|
||||
let expected_size = entry.size();
|
||||
let mut content = Vec::with_capacity(expected_size.min(16 * 1024 * 1024) as usize);
|
||||
entry
|
||||
.read_to_end(&mut content)
|
||||
.map_err(|error| format!("读取发行包文件失败:{path}: {error}"))?;
|
||||
if content.len() as u64 != expected_size {
|
||||
return Err(format!("发行包条目长度不一致:{path}"));
|
||||
}
|
||||
entries.push((path, content));
|
||||
}
|
||||
entries.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
if entries.is_empty() {
|
||||
return Err("发行包没有可上传文件".to_string());
|
||||
}
|
||||
|
||||
let mut normalized_writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
for (path, content) in &entries {
|
||||
normalized_writer
|
||||
.start_file(path, options)
|
||||
.map_err(|error| format!("写入发行包条目失败:{path}: {error}"))?;
|
||||
normalized_writer
|
||||
.write_all(content)
|
||||
.map_err(|error| format!("写入发行包文件失败:{path}: {error}"))?;
|
||||
}
|
||||
let normalized_cursor = normalized_writer
|
||||
.finish()
|
||||
.map_err(|error| format!("完成发行包失败:{error}"))?;
|
||||
let package_bytes = normalized_cursor.into_inner();
|
||||
if package_bytes.is_empty() || package_bytes.len() as u64 > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
|
||||
return Err("归一化发行包大小超出本地发布上限".to_string());
|
||||
}
|
||||
let package_sha256 = format!("{:x}", Sha256::digest(&package_bytes));
|
||||
let files = entries
|
||||
.into_iter()
|
||||
.map(|(path, content)| LocalProjectExportPackageFileDigest {
|
||||
size_bytes: content.len() as u64,
|
||||
sha256: format!("{:x}", Sha256::digest(&content)),
|
||||
path,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !files.iter().any(|file| file.path == "index.html") {
|
||||
return Err("归一化发行包缺少根 index.html".to_string());
|
||||
}
|
||||
Ok(LocalProjectExportPackagePayload {
|
||||
package_relative_path: normalized,
|
||||
package_size_bytes: package_bytes.len() as u64,
|
||||
package_bytes,
|
||||
package_sha256,
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result<String, String> {
|
||||
let seed = unix_millis();
|
||||
for suffix in 0..1000 {
|
||||
|
||||
@@ -3906,6 +3906,54 @@ fn local_project_export_package_uses_runtime_whitelist_and_records() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() {
|
||||
let root = unique_project_path();
|
||||
init_existing_html_project_at(&root, "project-publish", "在线试玩项目").expect("project init");
|
||||
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
|
||||
.expect("write playable html");
|
||||
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
|
||||
|
||||
let exported = export_local_project_package_at(&root).expect("export package");
|
||||
let payload = read_local_project_export_package_at(&root, &exported.package_relative_path)
|
||||
.expect("read publish payload");
|
||||
|
||||
assert_eq!(
|
||||
payload.package_relative_path,
|
||||
exported.package_relative_path
|
||||
);
|
||||
assert_eq!(
|
||||
payload.package_size_bytes,
|
||||
payload.package_bytes.len() as u64
|
||||
);
|
||||
assert_eq!(payload.files.len(), 2);
|
||||
assert!(payload.files.iter().any(|file| file.path == "index.html"));
|
||||
assert!(payload
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "exports/README.md"));
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&payload.package_bytes))
|
||||
.expect("read normalized package");
|
||||
let names = (0..archive.len())
|
||||
.map(|index| {
|
||||
archive
|
||||
.by_index(index)
|
||||
.expect("normalized entry")
|
||||
.name()
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(names.iter().any(|name| name == "index.html"));
|
||||
assert!(!names.iter().any(|name| name.starts_with("game/")));
|
||||
assert_eq!(payload.package_sha256.len(), 64);
|
||||
assert!(payload
|
||||
.package_sha256
|
||||
.chars()
|
||||
.all(|value| value.is_ascii_hexdigit()));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_export_package_list_only_returns_recent_playtest_zips() {
|
||||
let root = unique_project_path();
|
||||
|
||||
@@ -97,6 +97,7 @@ import type {
|
||||
TauriInvoke,
|
||||
UploadLocalAssetResult,
|
||||
} from './app/types';
|
||||
import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel';
|
||||
import { useWindowChrome } from './components/windowChromeContext';
|
||||
import {
|
||||
agentConversationId,
|
||||
@@ -1070,6 +1071,9 @@ export function App({
|
||||
const [filePath, setFilePath] = useState('game/index.html');
|
||||
const [fileDraft, setFileDraft] = useState('');
|
||||
const [fileStatus, setFileStatus] = useState('未读取');
|
||||
const [publishPackageResult, setPublishPackageResult] =
|
||||
useState<LocalProjectExportPackageResult | null>(null);
|
||||
const [publishPanelOpen, setPublishPanelOpen] = useState(false);
|
||||
const [agentRunTrace, setAgentRunTrace] =
|
||||
useState<GameCreationAgentRunTrace | null>(null);
|
||||
const [agentRunHistory, setAgentRunHistory] = useState<AgentRunHistoryItem[]>(
|
||||
@@ -7483,6 +7487,8 @@ export function App({
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
setFileStatus(`已导出本地试玩包:${result.packageRelativePath}`);
|
||||
setPublishPackageResult(result);
|
||||
setPublishPanelOpen(true);
|
||||
setCommandLog((current) => [...current, 'project.export_package']);
|
||||
void refreshManifest(nextProjectPath);
|
||||
if (announceToChat) {
|
||||
@@ -11785,6 +11791,15 @@ export function App({
|
||||
workspaceStatus={workspaceStatus}
|
||||
expectedRunId={projectSupervisorExpectedRunId}
|
||||
versions={chatProjectVersions}
|
||||
overlay={
|
||||
<GameDistributionPublishPanel
|
||||
open={publishPanelOpen}
|
||||
projectPath={supervisorProjectPath}
|
||||
manifest={manifest}
|
||||
packageResult={publishPackageResult}
|
||||
onClose={() => setPublishPanelOpen(false)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11970,6 +11985,15 @@ export function App({
|
||||
onProfessionalToolAction={handleProjectProfessionalAgentToolAction}
|
||||
onProfessionalRetry={handleProjectProfessionalAgentRetry}
|
||||
onUserInput={handleProjectSupervisorUserInput}
|
||||
overlay={
|
||||
<GameDistributionPublishPanel
|
||||
open={publishPanelOpen}
|
||||
projectPath={localProject?.projectPath ?? projectPath}
|
||||
manifest={manifest}
|
||||
packageResult={publishPackageResult}
|
||||
onClose={() => setPublishPanelOpen(false)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12077,6 +12101,14 @@ export function App({
|
||||
workspaceStatus={workspaceStatus}
|
||||
/>
|
||||
|
||||
<GameDistributionPublishPanel
|
||||
open={publishPanelOpen}
|
||||
projectPath={localProject?.projectPath ?? projectPath}
|
||||
manifest={manifest}
|
||||
packageResult={publishPackageResult}
|
||||
onClose={() => setPublishPanelOpen(false)}
|
||||
/>
|
||||
|
||||
{runtimeConfigOpen ? (
|
||||
<RuntimeConfigDialog
|
||||
projectPath={localProject?.projectPath}
|
||||
|
||||
@@ -794,6 +794,20 @@ export interface LocalProjectExportPackageResult {
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface LocalProjectExportPackageFileDigest {
|
||||
path: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface LocalProjectExportPackagePayload {
|
||||
packageRelativePath: string;
|
||||
packageBytes: number[];
|
||||
packageSha256: string;
|
||||
packageSizeBytes: number;
|
||||
files: LocalProjectExportPackageFileDigest[];
|
||||
}
|
||||
|
||||
export interface LocalProjectExportPackageSummary {
|
||||
packagePath: string;
|
||||
packageRelativePath: string;
|
||||
|
||||
+511
File diff suppressed because it is too large
Load Diff
@@ -150,6 +150,8 @@ function AgentReasoning({
|
||||
type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
|
||||
|
||||
type ProjectSupervisorViewProps = RuntimePanelProps & {
|
||||
/** 面板层:发布等业务面板以 portal 形式挂到 supervisor 视图内部。 */
|
||||
overlay?: ReactNode;
|
||||
activeVersionId?: string | null;
|
||||
/** 输入盒待发送附件:随下次提交进入回合(direct-codex 才渲染)。 */
|
||||
attachments?: DirectCodexTurnAttachment[];
|
||||
@@ -215,6 +217,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
|
||||
};
|
||||
|
||||
export function ProjectSupervisorView({
|
||||
overlay,
|
||||
activeVersionId = null,
|
||||
attachments = [],
|
||||
attachmentNotice = '',
|
||||
@@ -895,6 +898,7 @@ export function ProjectSupervisorView({
|
||||
closeOnEscape={false}
|
||||
/>
|
||||
) : null}
|
||||
{overlay}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Send, Settings } from 'lucide-react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
FormEventHandler,
|
||||
ReactNode,
|
||||
Ref,
|
||||
RefObject,
|
||||
UIEvent,
|
||||
@@ -42,6 +43,7 @@ type RuntimeControlProps = ComponentProps<
|
||||
const CHAT_SCROLL_BOTTOM_THRESHOLD = 24;
|
||||
|
||||
type SupervisorChatOnlyViewProps = {
|
||||
overlay?: ReactNode;
|
||||
activeVersionId?: string | null;
|
||||
chatAgentBusy: boolean;
|
||||
chatInput: string;
|
||||
@@ -80,6 +82,7 @@ type SupervisorChatOnlyViewProps = {
|
||||
};
|
||||
|
||||
export function SupervisorChatOnlyView({
|
||||
overlay,
|
||||
activeVersionId = null,
|
||||
chatAgentBusy,
|
||||
chatInput,
|
||||
@@ -351,6 +354,7 @@ export function SupervisorChatOnlyView({
|
||||
onClose={onCloseRuntimeConfig}
|
||||
/>
|
||||
) : null}
|
||||
{overlay}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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;
|
||||
let uploadResponse: Response;
|
||||
try {
|
||||
uploadResponse = await uploadFetch(uploadHost, {
|
||||
method: 'POST',
|
||||
body: buildDirectUploadFormData(ticket.upload, args.file),
|
||||
});
|
||||
} catch (error) {
|
||||
// Tauri http 插件在目标不在能力作用域、网络不可达或请求被取消时直接抛错;
|
||||
// 统一转成可操作文案,避免把英文插件错误原样暴露给作者。
|
||||
const detail = error instanceof Error ? error.message.trim() : '';
|
||||
throw new Error(
|
||||
`上传素材失败:无法访问素材存储,请检查网络后重试${
|
||||
detail ? `(${detail.slice(0, 120)})` : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { GameCreationAppManifest } from '../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type {
|
||||
GameDistributionCategory,
|
||||
GameDistributionCreateGameRequest,
|
||||
GameDistributionCreateVersionRequest,
|
||||
GameDistributionInputMode,
|
||||
GameDistributionOrientation,
|
||||
} from '../../../../packages/shared/src/contracts/gameDistribution';
|
||||
import type {
|
||||
LocalProjectExportPackagePayload,
|
||||
TauriInvoke,
|
||||
} from '../app/types';
|
||||
import { requestClientApi } from './clientApi';
|
||||
|
||||
export type GameDistributionPublishMetadata = {
|
||||
title: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
category: GameDistributionCategory;
|
||||
tags: string[];
|
||||
/** 平台素材库里的封面素材 ID;服务端要求发布必须带封面。 */
|
||||
coverAssetId: string;
|
||||
/** 平台素材库里的截图素材 ID,最多 6 张。 */
|
||||
screenshots?: string[];
|
||||
deviceSupport: {
|
||||
desktop: boolean;
|
||||
mobile: boolean;
|
||||
touch: boolean;
|
||||
};
|
||||
inputModes: GameDistributionInputMode[];
|
||||
orientation: GameDistributionOrientation;
|
||||
};
|
||||
|
||||
/** 游戏截图上限与服务端 `MAX_GAME_SCREENSHOTS` 保持一致。 */
|
||||
export const MAX_AGC_GAME_SCREENSHOTS = 6;
|
||||
|
||||
export type GameDistributionPublishResult = {
|
||||
gameId: string;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
status: string;
|
||||
packageSha256: string;
|
||||
packageSizeBytes: number;
|
||||
fileCount: number;
|
||||
};
|
||||
|
||||
type CreatedGame = { id: string; publicationRevision?: number };
|
||||
type CreatedVersion = {
|
||||
gameId: string;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export function createGameDistributionPublishKey() {
|
||||
const randomUuid =
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `agc-publish-${randomUuid}`;
|
||||
}
|
||||
|
||||
function normalizeMetadata(
|
||||
manifest: GameCreationAppManifest,
|
||||
metadata?: Partial<GameDistributionPublishMetadata>,
|
||||
): GameDistributionPublishMetadata {
|
||||
const title = (metadata?.title ?? manifest.name).trim();
|
||||
const summary = (
|
||||
metadata?.summary ??
|
||||
manifest.goal ??
|
||||
'由陶泥儿创作的可在线游玩游戏'
|
||||
).trim();
|
||||
if (!title || title.length > 40) {
|
||||
throw new Error('游戏标题必须为 1 到 40 个字符');
|
||||
}
|
||||
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,
|
||||
touch: true,
|
||||
},
|
||||
inputModes: metadata?.inputModes ?? ['keyboard', 'mouse', 'touch'],
|
||||
orientation: metadata?.orientation ?? 'responsive',
|
||||
};
|
||||
}
|
||||
|
||||
function toCreateGameRequest(
|
||||
metadata: GameDistributionPublishMetadata,
|
||||
localProjectId: string,
|
||||
): GameDistributionCreateGameRequest {
|
||||
return {
|
||||
localProjectId,
|
||||
title: metadata.title,
|
||||
summary: metadata.summary,
|
||||
description: metadata.description,
|
||||
category: metadata.category,
|
||||
tags: metadata.tags,
|
||||
coverAssetId: metadata.coverAssetId,
|
||||
screenshots: metadata.screenshots ?? [],
|
||||
deviceSupport: metadata.deviceSupport,
|
||||
inputModes: metadata.inputModes,
|
||||
orientation: metadata.orientation,
|
||||
};
|
||||
}
|
||||
|
||||
export async function publishLocalProjectGame(args: {
|
||||
invoke: TauriInvoke;
|
||||
projectPath: string;
|
||||
packageRelativePath: string;
|
||||
manifest: GameCreationAppManifest;
|
||||
metadata?: Partial<GameDistributionPublishMetadata>;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<GameDistributionPublishResult> {
|
||||
const projectPath = args.projectPath.trim();
|
||||
const packageRelativePath = args.packageRelativePath.trim();
|
||||
if (!projectPath || !packageRelativePath) {
|
||||
throw new Error('发布需要绑定本地项目和试玩包');
|
||||
}
|
||||
const payload = await args.invoke<LocalProjectExportPackagePayload>(
|
||||
'read_local_project_export_package',
|
||||
{ projectPath, packageRelativePath },
|
||||
);
|
||||
if (
|
||||
!payload.packageBytes.length ||
|
||||
payload.packageSizeBytes !== payload.packageBytes.length ||
|
||||
payload.files.length === 0
|
||||
) {
|
||||
throw new Error('本地发行包摘要无效,请重新导出试玩包');
|
||||
}
|
||||
if (payload.packageRelativePath !== packageRelativePath) {
|
||||
throw new Error('本地发行包路径已变化,请重新导出试玩包');
|
||||
}
|
||||
|
||||
const metadata = normalizeMetadata(args.manifest, args.metadata);
|
||||
const localProjectId = args.manifest.projectId.trim();
|
||||
if (!localProjectId) {
|
||||
throw new Error('发布需要本地项目标识,请重新打开项目后再试');
|
||||
}
|
||||
const gameMetadata = toCreateGameRequest(metadata, localProjectId);
|
||||
const rootKey =
|
||||
args.idempotencyKey?.trim() || createGameDistributionPublishKey();
|
||||
const game = await requestClientApi<CreatedGame>(
|
||||
'/api/game-distribution/games',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': `${rootKey}:game`,
|
||||
},
|
||||
body: JSON.stringify(gameMetadata),
|
||||
},
|
||||
'创建平台游戏失败',
|
||||
);
|
||||
if (!game?.id?.trim()) {
|
||||
throw new Error('创建平台游戏未返回游戏 ID');
|
||||
}
|
||||
|
||||
const versionRequest: GameDistributionCreateVersionRequest = {
|
||||
localProjectId,
|
||||
packageSha256: payload.packageSha256,
|
||||
packageBytes: payload.packageSizeBytes,
|
||||
packageFileCount: payload.files.length,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata,
|
||||
};
|
||||
const version = await requestClientApi<CreatedVersion>(
|
||||
`/api/game-distribution/games/${encodeURIComponent(game.id)}/versions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': `${rootKey}:version`,
|
||||
},
|
||||
body: JSON.stringify(versionRequest),
|
||||
},
|
||||
'创建游戏发行版本失败',
|
||||
);
|
||||
if (!version?.versionId?.trim()) {
|
||||
throw new Error('创建发行版本未返回版本 ID');
|
||||
}
|
||||
|
||||
const packageBody = new Blob([new Uint8Array(payload.packageBytes)], {
|
||||
type: 'application/zip',
|
||||
});
|
||||
const uploaded = await requestClientApi<{
|
||||
versionId: string;
|
||||
status: string;
|
||||
}>(
|
||||
`/api/game-distribution/versions/${encodeURIComponent(version.versionId)}/package`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Idempotency-Key': `${rootKey}:upload`,
|
||||
},
|
||||
body: packageBody,
|
||||
},
|
||||
'上传游戏发行包失败',
|
||||
);
|
||||
const submitted = await requestClientApi<{
|
||||
game?: { publicationRevision?: number };
|
||||
version?: { status?: string };
|
||||
}>(
|
||||
`/api/game-distribution/versions/${encodeURIComponent(version.versionId)}/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': `${rootKey}:submit`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
expectedPublicationRevision: game.publicationRevision ?? 0,
|
||||
}),
|
||||
},
|
||||
'提交审核失败',
|
||||
);
|
||||
return {
|
||||
gameId: game.id,
|
||||
versionId: version.versionId,
|
||||
versionNumber: version.versionNumber,
|
||||
status: submitted?.version?.status ?? uploaded?.status ?? 'pending_review',
|
||||
packageSha256: payload.packageSha256,
|
||||
packageSizeBytes: payload.packageSizeBytes,
|
||||
fileCount: payload.files.length,
|
||||
};
|
||||
}
|
||||
@@ -3768,6 +3768,371 @@ textarea {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel {
|
||||
width: min(520px, 100%);
|
||||
max-height: min(760px, calc(100vh - 48px));
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
border: 1px solid rgb(104 77 57 / 16%);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 24px 80px rgb(74 48 33 / 20%);
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__header h2 {
|
||||
margin: 5px 0 0;
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__header > button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid rgb(104 77 57 / 16%);
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__eyebrow {
|
||||
color: #a8663d;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__intro {
|
||||
margin: 18px 0;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__package {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin-bottom: 18px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgb(168 102 61 / 16%);
|
||||
border-radius: 12px;
|
||||
background: rgb(168 102 61 / 6%);
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__package span:first-child {
|
||||
overflow: hidden;
|
||||
color: var(--platform-text-strong);
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__fields {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__fields label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__fields input,
|
||||
.game-distribution-publish-panel__fields textarea,
|
||||
.game-distribution-publish-panel__fields select {
|
||||
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;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__fields textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__fields input:focus-visible,
|
||||
.game-distribution-publish-panel__fields textarea:focus-visible,
|
||||
.game-distribution-publish-panel__fields select:focus-visible {
|
||||
border-color: #a8663d;
|
||||
outline: 2px solid rgb(168 102 61 / 20%);
|
||||
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__picker {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 透明 input 覆盖在触发按钮上:真实点击永远命中原生控件,自定义文案只负责显示。 */
|
||||
.game-distribution-publish-panel__picker input[type='file'] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__pick {
|
||||
display: inline-flex;
|
||||
pointer-events: none;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 76%);
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__picker:hover
|
||||
.game-distribution-publish-panel__pick {
|
||||
border-color: #a8663d;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__picker
|
||||
input[type='file']:focus-visible
|
||||
+ .game-distribution-publish-panel__pick {
|
||||
outline: 2px solid rgb(168 102 61 / 24%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__picker
|
||||
input[type='file']:disabled
|
||||
+ .game-distribution-publish-panel__pick {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__success {
|
||||
margin-top: 18px;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
background: rgb(51 125 87 / 9%);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__success p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__mono {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__actions button {
|
||||
min-height: 38px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid rgb(104 77 57 / 20%);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-strong);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__actions button:last-child {
|
||||
border-color: #a8663d;
|
||||
background: #a8663d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__actions button:disabled,
|
||||
.game-distribution-publish-panel__header > button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.game-distribution-publish-panel {
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__header h2 {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fb;
|
||||
|
||||
+8
@@ -849,6 +849,14 @@ export function registerProjectToolsAndPreviewTests() {
|
||||
/已导出本地试玩包:exports\/playtest-package-unit\.zip/,
|
||||
),
|
||||
).not.toBeNull();
|
||||
// 导出成功后会直接打开发布到游戏广场的面板;它是模态,先确认出现再关闭,
|
||||
// 然后回到预览快捷操作的会话流。
|
||||
const publishPanel = await screen.findByRole('dialog', {
|
||||
name: '发布到游戏广场',
|
||||
});
|
||||
expect(publishPanel).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭发布面板' }));
|
||||
|
||||
const exportRevealButtons = screen.getAllByRole('button', {
|
||||
name: '显示目录',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// @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('直传网络/作用域错误转成可操作文案', async () => {
|
||||
const uploadFetch = vi.fn(async () => {
|
||||
throw new Error('url not allowed on the configured scope');
|
||||
});
|
||||
|
||||
await expect(
|
||||
uploadPlatformMediaAsset({
|
||||
file: buildCoverFile(),
|
||||
assetKind: 'game_distribution_cover',
|
||||
pathSegments: ['game-distribution', 'cover', '42'],
|
||||
entityId: 'game-distribution-cover',
|
||||
fetchImpl: uploadFetch,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'上传素材失败:无法访问素材存储,请检查网络后重试(url not allowed on the configured scope)',
|
||||
);
|
||||
|
||||
// 直传抛错时不得继续登记素材。
|
||||
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(
|
||||
'素材上传地址为空,请稍后重试',
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user