作品管理与Phaser4客户端发布闭环
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m21s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m18s
Project CI / Frontend tests (pull_request) Successful in 2m8s
Project CI / Repository checks (pull_request) Failing after 16s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m56s
Project CI / Native shell tests (pull_request) Successful in 5m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m21s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m18s
Project CI / Frontend tests (pull_request) Successful in 2m8s
Project CI / Repository checks (pull_request) Failing after 16s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m56s
Project CI / Native shell tests (pull_request) Successful in 5m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
补齐 Phaser4 客户端发布前校验、阶段进度与原生幂等账本 增加 Web 作者私有作品管理、资料编辑、版本历史与软删除 增加后台作品筛选、游标分页与删除治理 同步 SpacetimeDB schema、bindings、DTO、文档与定向测试
This commit is contained in:
@@ -568,7 +568,7 @@ test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键'
|
||||
);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'/admin/api/game-distribution/games?limit=50',
|
||||
'/admin/api/game-distribution/games?limit=80',
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -606,6 +606,32 @@ test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键'
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('游戏管理列表把筛选与游标编码进查询串', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({ ok: true, data: { games: [], nextCursor: null } }),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await listAdminGameDistributionGames('admin-token', {
|
||||
limit: 20,
|
||||
keyword: ' 拼图 ',
|
||||
owner: 'user_9',
|
||||
status: 'suspended',
|
||||
cursor: '1710000000000:game_1',
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'/admin/api/game-distribution/games?limit=20&keyword=%E6%8B%BC%E5%9B%BE&owner=user_9&status=suspended&cursor=1710000000000%3Agame_1',
|
||||
);
|
||||
});
|
||||
|
||||
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
AdminExternalApiKeyListResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminGameDistributionGameListResponse,
|
||||
AdminGameDistributionGameStatus,
|
||||
AdminGameDistributionRestoreRequest,
|
||||
AdminGameDistributionRestoreResponse,
|
||||
AdminGameDistributionReviewListResponse,
|
||||
@@ -1243,17 +1244,36 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台作品管理列表。
|
||||
*
|
||||
* 关键词、作者与状态过滤以及游标分页都由服务端执行:前端只透传查询参数,
|
||||
* 不本地过滤,否则分页边界会与 `nextCursor` 的语义打架。
|
||||
*/
|
||||
export function listAdminGameDistributionGames(
|
||||
token: string,
|
||||
options: { limit?: number } = {},
|
||||
options: {
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
owner?: string;
|
||||
status?: AdminGameDistributionGameStatus | '';
|
||||
cursor?: string | null;
|
||||
} = {},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const requestedLimit = options.limit ?? 50;
|
||||
const normalizedLimit = Number.isFinite(requestedLimit)
|
||||
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50)
|
||||
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 200)
|
||||
: 50;
|
||||
const params = new URLSearchParams({ limit: String(normalizedLimit) });
|
||||
const keyword = options.keyword?.trim();
|
||||
if (keyword) params.set('keyword', keyword);
|
||||
const owner = options.owner?.trim();
|
||||
if (owner) params.set('owner', owner);
|
||||
if (options.status) params.set('status', options.status);
|
||||
if (options.cursor) params.set('cursor', options.cursor);
|
||||
return request<AdminGameDistributionGameListResponse>(
|
||||
`/admin/api/game-distribution/games?limit=${normalizedLimit}`,
|
||||
`/admin/api/game-distribution/games?${params.toString()}`,
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1158,18 +1158,29 @@ export interface AdminGameDistributionGameEntry {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
status: string;
|
||||
status: AdminGameDistributionGameStatus;
|
||||
versionCount: number;
|
||||
playCount: number;
|
||||
activeVersionId: string | null;
|
||||
publicationRevision: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** 作者软删除时间;为空表示未删除。 */
|
||||
deletedAt: string | null;
|
||||
versions: AdminGameDistributionGameVersionEntry[];
|
||||
}
|
||||
|
||||
/** 后台作品列表的状态:游戏可见性 + 作者软删除。 */
|
||||
export type AdminGameDistributionGameStatus =
|
||||
| 'published'
|
||||
| 'unpublished'
|
||||
| 'suspended'
|
||||
| 'deleted';
|
||||
|
||||
export interface AdminGameDistributionGameListResponse {
|
||||
games: AdminGameDistributionGameEntry[];
|
||||
/** 还有下一页时为下一页游标;旧响应没有该字段,按空值处理。 */
|
||||
nextCursor?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionRestoreRequest {
|
||||
|
||||
@@ -68,6 +68,7 @@ const publishedGame: AdminGameDistributionGameEntry = {
|
||||
publicationRevision: 4,
|
||||
createdAt: '2026-09-18T08:00:00Z',
|
||||
updatedAt: '2026-09-20T10:00:00Z',
|
||||
deletedAt: null,
|
||||
versions: [version],
|
||||
};
|
||||
|
||||
@@ -86,6 +87,26 @@ const suspendedGame: AdminGameDistributionGameEntry = {
|
||||
publicationRevision: 7,
|
||||
createdAt: '2026-09-19T08:00:00Z',
|
||||
updatedAt: '2026-09-21T08:00:00Z',
|
||||
deletedAt: null,
|
||||
versions: [],
|
||||
};
|
||||
|
||||
const deletedGame: AdminGameDistributionGameEntry = {
|
||||
gameId: 'game_3',
|
||||
title: '已删除作品',
|
||||
author: {
|
||||
id: 'user_3',
|
||||
name: '作者丙',
|
||||
avatarUrl: null,
|
||||
},
|
||||
status: 'deleted',
|
||||
versionCount: 1,
|
||||
playCount: 3,
|
||||
activeVersionId: null,
|
||||
publicationRevision: 2,
|
||||
createdAt: '2026-09-17T08:00:00Z',
|
||||
updatedAt: '2026-09-22T08:00:00Z',
|
||||
deletedAt: '2026-09-22T08:00:00Z',
|
||||
versions: [],
|
||||
};
|
||||
|
||||
@@ -263,3 +284,92 @@ test('401 交给 onUnauthorized 处理', async () => {
|
||||
);
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
|
||||
test('筛选条件按服务端口径透传,重置回到无条件第一页', async () => {
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
fireEvent.change(screen.getByPlaceholderText('标题、gameId 或作者 ID'), {
|
||||
target: { value: ' 拼图 ' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('作者 user ID'), {
|
||||
target: { value: 'user_9' },
|
||||
});
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '状态' }), {
|
||||
target: { value: 'suspended' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
const [, applied] = vi.mocked(listAdminGameDistributionGames).mock.calls[1]!;
|
||||
expect(applied).toEqual({
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
keyword: '拼图',
|
||||
owner: 'user_9',
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(3),
|
||||
);
|
||||
const [, reset] = vi.mocked(listAdminGameDistributionGames).mock.calls[2]!;
|
||||
expect(reset).toEqual({
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
keyword: '',
|
||||
owner: '',
|
||||
status: '',
|
||||
});
|
||||
expect(
|
||||
(screen.getByPlaceholderText('标题、gameId 或作者 ID') as HTMLInputElement)
|
||||
.value,
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('游标分页把上一页返回的 nextCursor 传给下一页', async () => {
|
||||
vi.mocked(listAdminGameDistributionGames)
|
||||
.mockResolvedValueOnce({ games: [publishedGame], nextCursor: 'cursor-1' })
|
||||
.mockResolvedValueOnce({ games: [suspendedGame], nextCursor: null });
|
||||
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
const nextButton = screen.getByRole('button', { name: '下一页' });
|
||||
expect((nextButton as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
await screen.findByText('下架游戏');
|
||||
expect(screen.queryByText('测试游戏')).toBeNull();
|
||||
const [, secondPage] = vi.mocked(listAdminGameDistributionGames).mock
|
||||
.calls[1]!;
|
||||
expect(secondPage?.cursor).toBe('cursor-1');
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '下一页' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('已删除作品显示删除时间且不再提供下架与恢复', async () => {
|
||||
vi.mocked(listAdminGameDistributionGames).mockResolvedValue({
|
||||
games: [deletedGame],
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const row = (await screen.findByText('已删除作品')).closest('tr')!;
|
||||
expect(within(row).getByText('已删除')).toBeTruthy();
|
||||
expect(within(row).getByText(/删除于/)).toBeTruthy();
|
||||
expect(within(row).queryByRole('button', { name: '下架' })).toBeNull();
|
||||
expect(within(row).queryByRole('button', { name: '恢复' })).toBeNull();
|
||||
expect(within(row).getByRole('button', { name: '版本历史' })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -4,15 +4,17 @@ import {
|
||||
AdminAlert,
|
||||
AdminButton,
|
||||
AdminDialog,
|
||||
AdminField,
|
||||
AdminFieldGroup,
|
||||
AdminListPanel,
|
||||
AdminPage,
|
||||
AdminPageHeading,
|
||||
AdminPagination,
|
||||
AdminStatusPill,
|
||||
formatAdminDateTime,
|
||||
} from '@genarrative/shared/components';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { RefreshCcw, Search, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
listAdminGameDistributionGames,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminGameDistributionGameEntry,
|
||||
AdminGameDistributionGameStatus,
|
||||
AdminGameDistributionGameVersionEntry,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
@@ -38,8 +41,32 @@ const GAME_STATUS_META: Record<
|
||||
published: { label: '已公开', tone: 'ok' },
|
||||
suspended: { label: '已下架', tone: 'error' },
|
||||
unpublished: { label: '未公开', tone: 'pending' },
|
||||
// 作者软删除:保留在后台可见(默认视图之外),但不再公开可玩。
|
||||
deleted: { label: '已删除', tone: 'error' },
|
||||
};
|
||||
|
||||
const STATUS_FILTER_OPTIONS: {
|
||||
value: AdminGameDistributionGameStatus | '';
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'published', label: '已公开' },
|
||||
{ value: 'unpublished', label: '未公开' },
|
||||
{ value: 'suspended', label: '已下架' },
|
||||
{ value: 'deleted', label: '已删除' },
|
||||
];
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
const PAGE_SIZE_OPTIONS = [20, 50, 100];
|
||||
|
||||
interface GameFilters {
|
||||
keyword: string;
|
||||
owner: string;
|
||||
status: AdminGameDistributionGameStatus | '';
|
||||
}
|
||||
|
||||
const EMPTY_FILTERS: GameFilters = { keyword: '', owner: '', status: '' };
|
||||
|
||||
function gameStatusMeta(status: string) {
|
||||
return (
|
||||
GAME_STATUS_META[status] ?? {
|
||||
@@ -89,7 +116,13 @@ export function AdminGameManagementPage({
|
||||
onUnauthorized,
|
||||
}: AdminGameManagementPageProps) {
|
||||
const [games, setGames] = useState<AdminGameDistributionGameEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [pageIndex, setPageIndex] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [draftFilters, setDraftFilters] = useState<GameFilters>(EMPTY_FILTERS);
|
||||
const [filters, setFilters] = useState<GameFilters>(EMPTY_FILTERS);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [busyGameId, setBusyGameId] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
@@ -99,26 +132,109 @@ export function AdminGameManagementPage({
|
||||
const [versionGame, setVersionGame] =
|
||||
useState<AdminGameDistributionGameEntry | null>(null);
|
||||
const writeConfirm = useAdminWriteConfirm();
|
||||
const listController = useRef<AbortController | null>(null);
|
||||
// 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回,
|
||||
// 因此按页记录已取得的游标,翻页只在这些游标之间移动。
|
||||
const pageCursors = useRef<(string | null)[]>([null]);
|
||||
|
||||
const loadGames = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await listAdminGameDistributionGames(token, {
|
||||
limit: 50,
|
||||
});
|
||||
setGames(response.games);
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [token, onUnauthorized]);
|
||||
const loadGames = useCallback(
|
||||
async (cursor: string | null, page: number) => {
|
||||
listController.current?.abort();
|
||||
const controller = new AbortController();
|
||||
listController.current = controller;
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await listAdminGameDistributionGames(
|
||||
token,
|
||||
{
|
||||
limit: pageSize,
|
||||
cursor,
|
||||
keyword: filters.keyword,
|
||||
owner: filters.owner,
|
||||
status: filters.status,
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setGames(response.games);
|
||||
setNextCursor(response.nextCursor ?? null);
|
||||
setPageIndex(page);
|
||||
setHasLoaded(true);
|
||||
} catch (error: unknown) {
|
||||
// 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。
|
||||
if (!controller.signal.aborted)
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
if (listController.current === controller) {
|
||||
listController.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
token,
|
||||
onUnauthorized,
|
||||
pageSize,
|
||||
filters.keyword,
|
||||
filters.owner,
|
||||
filters.status,
|
||||
],
|
||||
);
|
||||
|
||||
// 条件或每页条数变化时回到第一页;这里是唯一的"重置游标"入口。
|
||||
useEffect(() => {
|
||||
void loadGames();
|
||||
pageCursors.current = [null];
|
||||
setPageIndex(1);
|
||||
void loadGames(null, 1);
|
||||
}, [loadGames]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
listController.current?.abort();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
function applyFilters() {
|
||||
setFilters({
|
||||
keyword: draftFilters.keyword.trim(),
|
||||
owner: draftFilters.owner.trim(),
|
||||
status: draftFilters.status,
|
||||
});
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setDraftFilters(EMPTY_FILTERS);
|
||||
setFilters(EMPTY_FILTERS);
|
||||
}
|
||||
|
||||
function refreshCurrentPage() {
|
||||
void loadGames(pageCursors.current[pageIndex - 1] ?? null, pageIndex);
|
||||
}
|
||||
|
||||
function goToNextPage() {
|
||||
if (!nextCursor) return;
|
||||
const nextPage = pageIndex + 1;
|
||||
pageCursors.current[nextPage - 1] = nextCursor;
|
||||
void loadGames(nextCursor, nextPage);
|
||||
}
|
||||
|
||||
function goToPreviousPage() {
|
||||
if (pageIndex <= 1) return;
|
||||
const previousPage = pageIndex - 1;
|
||||
void loadGames(pageCursors.current[previousPage - 1] ?? null, previousPage);
|
||||
}
|
||||
|
||||
function changePageSize(size: number) {
|
||||
pageCursors.current = [null];
|
||||
setPageSize(size);
|
||||
}
|
||||
|
||||
const filtersActive = Boolean(
|
||||
filters.keyword || filters.owner || filters.status,
|
||||
);
|
||||
|
||||
async function suspendGame(entry: AdminGameDistributionGameEntry) {
|
||||
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
|
||||
if (!reason) {
|
||||
@@ -151,7 +267,7 @@ export function AdminGameManagementPage({
|
||||
...current,
|
||||
[entry.gameId]: '',
|
||||
}));
|
||||
await loadGames();
|
||||
refreshCurrentPage();
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
@@ -177,7 +293,7 @@ export function AdminGameManagementPage({
|
||||
{ expectedPublicationRevision: entry.publicationRevision },
|
||||
);
|
||||
setStatusMessage(`游戏《${entry.title || entry.gameId}》已恢复`);
|
||||
await loadGames();
|
||||
refreshCurrentPage();
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
@@ -193,7 +309,7 @@ export function AdminGameManagementPage({
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => void loadGames()}
|
||||
onClick={refreshCurrentPage}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCcw aria-hidden="true" />
|
||||
@@ -211,14 +327,93 @@ export function AdminGameManagementPage({
|
||||
<AdminAlert tone="success">{statusMessage}</AdminAlert>
|
||||
) : null}
|
||||
|
||||
<form
|
||||
className="admin-stack"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
applyFilters();
|
||||
}}
|
||||
>
|
||||
<AdminActionRow>
|
||||
<AdminField label="关键词" compact>
|
||||
<input
|
||||
aria-label="关键词"
|
||||
placeholder="标题、gameId 或作者 ID"
|
||||
value={draftFilters.keyword}
|
||||
onChange={(event) =>
|
||||
setDraftFilters((current) => ({
|
||||
...current,
|
||||
keyword: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</AdminField>
|
||||
<AdminField label="作者" compact>
|
||||
<input
|
||||
aria-label="作者"
|
||||
placeholder="作者 user ID"
|
||||
value={draftFilters.owner}
|
||||
onChange={(event) =>
|
||||
setDraftFilters((current) => ({
|
||||
...current,
|
||||
owner: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</AdminField>
|
||||
<AdminField label="状态" compact>
|
||||
<select
|
||||
aria-label="状态"
|
||||
value={draftFilters.status}
|
||||
onChange={(event) =>
|
||||
setDraftFilters((current) => ({
|
||||
...current,
|
||||
status: event.target.value as
|
||||
| AdminGameDistributionGameStatus
|
||||
| '',
|
||||
}))
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{STATUS_FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.value || 'all'} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</AdminField>
|
||||
<AdminButton variant="primary" type="submit" disabled={isLoading}>
|
||||
<Search aria-hidden="true" />
|
||||
查询
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={resetFilters}
|
||||
disabled={isLoading && !filtersActive}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
重置
|
||||
</AdminButton>
|
||||
</AdminActionRow>
|
||||
</form>
|
||||
|
||||
<AdminListPanel
|
||||
ariaLabel="游戏列表"
|
||||
busy={isLoading}
|
||||
busyText="正在加载游戏列表…"
|
||||
busyVariant="line"
|
||||
emptyText="暂无游戏。"
|
||||
emptyText={filtersActive ? '没有匹配的作品。' : '暂无游戏。'}
|
||||
emptyVariant="text"
|
||||
showEmpty={hasLoaded && games.length === 0 && !errorMessage}
|
||||
title="游戏列表"
|
||||
extra={<span className="admin-muted-text">共 {games.length} 条</span>}
|
||||
extra={
|
||||
<span className="admin-muted-text">
|
||||
第 {pageIndex} 页{games.length ? `,本页 ${games.length} 条` : ''}
|
||||
</span>
|
||||
}
|
||||
rows={games}
|
||||
size="wide"
|
||||
columns={[
|
||||
@@ -230,8 +425,31 @@ export function AdminGameManagementPage({
|
||||
{ key: 'playCount', label: '游玩数' },
|
||||
{ key: 'actions', label: '操作' },
|
||||
]}
|
||||
pagination={
|
||||
hasLoaded ? (
|
||||
<AdminPagination
|
||||
ariaLabel="游戏列表分页"
|
||||
info={
|
||||
<>
|
||||
第 {pageIndex} 页
|
||||
{games.length ? `,本页 ${games.length} 条` : ''}
|
||||
</>
|
||||
}
|
||||
pageSize={{
|
||||
value: pageSize,
|
||||
options: PAGE_SIZE_OPTIONS,
|
||||
ariaLabel: '每页条数',
|
||||
onChange: changePageSize,
|
||||
}}
|
||||
busy={isLoading}
|
||||
previous={{ disabled: pageIndex <= 1, onClick: goToPreviousPage }}
|
||||
next={{ disabled: !nextCursor, onClick: goToNextPage }}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
renderRow={(entry) => {
|
||||
const status = gameStatusMeta(entry.status);
|
||||
const isDeleted = entry.status === 'deleted';
|
||||
const isSuspended = entry.status === 'suspended';
|
||||
const busy = busyGameId === entry.gameId;
|
||||
return (
|
||||
@@ -277,12 +495,26 @@ export function AdminGameManagementPage({
|
||||
<AdminStatusPill tone={status.tone}>
|
||||
{status.label}
|
||||
</AdminStatusPill>
|
||||
{entry.deletedAt ? (
|
||||
<small className="admin-muted-text">
|
||||
删除于 {formatOptionalTime(entry.deletedAt)}
|
||||
</small>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{entry.versionCount}</td>
|
||||
<td>{entry.playCount}</td>
|
||||
<td>
|
||||
<AdminActionRow>
|
||||
{!isSuspended ? (
|
||||
{isDeleted ? null : isSuspended ? (
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void restoreGame(entry)}
|
||||
>
|
||||
{busy ? '处理中…' : '恢复'}
|
||||
</AdminButton>
|
||||
) : (
|
||||
<>
|
||||
<AdminFieldGroup
|
||||
label="下架原因"
|
||||
@@ -309,15 +541,6 @@ export function AdminGameManagementPage({
|
||||
{busy ? '处理中…' : '下架'}
|
||||
</AdminButton>
|
||||
</>
|
||||
) : (
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void restoreGame(entry)}
|
||||
>
|
||||
{busy ? '处理中…' : '恢复'}
|
||||
</AdminButton>
|
||||
)}
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
|
||||
@@ -6163,6 +6163,10 @@ pub(crate) async fn upload_local_project_game_package(
|
||||
}
|
||||
let emit_handle = app.clone();
|
||||
let progress_version_id = version_id.clone();
|
||||
let progress_publish_id = idempotency_key.trim().to_string();
|
||||
let verify_handle = app.clone();
|
||||
let verify_version_id = version_id.clone();
|
||||
let verify_publish_id = progress_publish_id.clone();
|
||||
crate::game_package_upload::upload_staged_game_package(
|
||||
&client,
|
||||
crate::game_package_upload::GamePackageUploadRequest {
|
||||
@@ -6178,11 +6182,27 @@ pub(crate) async fn upload_local_project_game_package(
|
||||
crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
|
||||
crate::game_package_upload::progress_event_payload(
|
||||
&progress_version_id,
|
||||
&progress_publish_id,
|
||||
"upload",
|
||||
"正在上传发行包…",
|
||||
received_bytes,
|
||||
total_bytes,
|
||||
),
|
||||
);
|
||||
},
|
||||
move || {
|
||||
let _ = verify_handle.emit(
|
||||
crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
|
||||
crate::game_package_upload::progress_event_payload(
|
||||
&verify_version_id,
|
||||
&verify_publish_id,
|
||||
"verify",
|
||||
"正在校验发行包…",
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -495,19 +495,18 @@ pub(crate) async fn generate_game_distribution_cover(
|
||||
}
|
||||
|
||||
fn stage_publish_package(
|
||||
app: &AppHandle,
|
||||
app_data_dir: &Path,
|
||||
project_path: &str,
|
||||
package_relative_path: &str,
|
||||
) -> Result<StagedGamePackage, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
crate::project::enforce_project_permission_policy(root, "project.export_package")?;
|
||||
// 发布前先按平台合同校验工程栈:非 Phaser 4 / 非 Vite 的 npm 工程在这里就给出
|
||||
// 可操作错误,不进入后续任何服务端请求。
|
||||
crate::project::ensure_publish_project_stack(root)?;
|
||||
let payload =
|
||||
crate::project::read_local_project_export_package_at(root, package_relative_path.trim())?;
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))?;
|
||||
let staging_dir = game_package_upload_staging_dir(&app_data_dir);
|
||||
let staging_dir = game_package_upload_staging_dir(app_data_dir);
|
||||
let mut staged = stage_game_package_bytes(
|
||||
&staging_dir,
|
||||
&payload.package_sha256,
|
||||
@@ -517,6 +516,29 @@ fn stage_publish_package(
|
||||
Ok(staged)
|
||||
}
|
||||
|
||||
/// 发布进度事件:阶段 + 文案 + (仅上传阶段有意义的)已收字节/总字节。
|
||||
fn emit_publish_progress(
|
||||
app: &AppHandle,
|
||||
publish_id: &str,
|
||||
version_id: &str,
|
||||
phase: &str,
|
||||
message: &str,
|
||||
received_bytes: u64,
|
||||
total_bytes: u64,
|
||||
) {
|
||||
let _ = app.emit(
|
||||
PACKAGE_UPLOAD_PROGRESS_EVENT,
|
||||
crate::game_package_upload::progress_event_payload(
|
||||
version_id,
|
||||
publish_id,
|
||||
phase,
|
||||
message,
|
||||
received_bytes,
|
||||
total_bytes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn publish_local_project_game(
|
||||
app: AppHandle,
|
||||
@@ -526,7 +548,11 @@ pub(crate) async fn publish_local_project_game(
|
||||
idempotency_key: Option<String>,
|
||||
) -> Result<GameDistributionPublishResult, String> {
|
||||
let snapshot = require_platform_session()?;
|
||||
let staged = stage_publish_package(&app, &project_path, &package_relative_path)?;
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))?;
|
||||
let staged = stage_publish_package(&app_data_dir, &project_path, &package_relative_path)?;
|
||||
let local_project_id = metadata
|
||||
.local_project_id
|
||||
.as_deref()
|
||||
@@ -546,13 +572,36 @@ pub(crate) async fn publish_local_project_game(
|
||||
if metadata.screenshots.len() > 6 {
|
||||
return Err("游戏截图最多 6 张".to_string());
|
||||
}
|
||||
let root_key = idempotency_key
|
||||
// 根幂等键由账本按「账号 + origin + 本地项目 + 包摘要」解析:同一份包重发、响应丢失后
|
||||
// 重试、进程重启后的分片续传都落在同一次服务端尝试上;包内容变了才换键。
|
||||
let root_key = match idempotency_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("agc-publish-{}", uuid::Uuid::new_v4()));
|
||||
{
|
||||
Some(explicit) => explicit.to_string(),
|
||||
None => {
|
||||
crate::game_publish_attempt::resolve_game_publish_root_key(
|
||||
&app_data_dir,
|
||||
&snapshot.user_id,
|
||||
&snapshot.api_base_url,
|
||||
&local_project_id,
|
||||
&staged.package_sha256,
|
||||
)?
|
||||
.0
|
||||
}
|
||||
};
|
||||
let client = build_client()?;
|
||||
// 准备阶段还没有版本标识,事件只用根键关联。
|
||||
emit_publish_progress(
|
||||
&app,
|
||||
&root_key,
|
||||
"",
|
||||
"prepare",
|
||||
"正在创建平台游戏与发行版本…",
|
||||
0,
|
||||
0,
|
||||
);
|
||||
let game_value = request_json(
|
||||
&client,
|
||||
&snapshot,
|
||||
@@ -572,7 +621,7 @@ pub(crate) async fn publish_local_project_game(
|
||||
return Err("创建平台游戏未返回游戏 ID".to_string());
|
||||
}
|
||||
let version_request = GameDistributionCreateVersionRequest {
|
||||
local_project_id: Some(local_project_id),
|
||||
local_project_id: Some(local_project_id.clone()),
|
||||
package_sha256: staged.package_sha256.clone(),
|
||||
package_bytes: staged.package_size_bytes,
|
||||
package_file_count: staged.package_file_count,
|
||||
@@ -605,8 +654,21 @@ pub(crate) async fn publish_local_project_game(
|
||||
}
|
||||
validate_session(&snapshot)?;
|
||||
let progress_version_id = version.version_id.clone();
|
||||
let verify_version_id = version.version_id.clone();
|
||||
let emit_handle = app.clone();
|
||||
let verify_handle = app.clone();
|
||||
let session_identity = snapshot.identity();
|
||||
emit_publish_progress(
|
||||
&app,
|
||||
&root_key,
|
||||
&version.version_id,
|
||||
"upload",
|
||||
"正在上传发行包…",
|
||||
0,
|
||||
staged.package_size_bytes,
|
||||
);
|
||||
let upload_publish_id = root_key.clone();
|
||||
let verify_publish_id = root_key.clone();
|
||||
let uploaded: GamePackageUploadOutcome = upload_staged_game_package(
|
||||
&client,
|
||||
GamePackageUploadRequest {
|
||||
@@ -618,18 +680,39 @@ pub(crate) async fn publish_local_project_game(
|
||||
session_identity: Some(&session_identity),
|
||||
},
|
||||
move |received_bytes, total_bytes| {
|
||||
let _ = emit_handle.emit(
|
||||
PACKAGE_UPLOAD_PROGRESS_EVENT,
|
||||
crate::game_package_upload::progress_event_payload(
|
||||
&progress_version_id,
|
||||
received_bytes,
|
||||
total_bytes,
|
||||
),
|
||||
emit_publish_progress(
|
||||
&emit_handle,
|
||||
&upload_publish_id,
|
||||
&progress_version_id,
|
||||
"upload",
|
||||
"正在上传发行包…",
|
||||
received_bytes,
|
||||
total_bytes,
|
||||
);
|
||||
},
|
||||
move || {
|
||||
emit_publish_progress(
|
||||
&verify_handle,
|
||||
&verify_publish_id,
|
||||
&verify_version_id,
|
||||
"verify",
|
||||
"正在校验发行包…",
|
||||
0,
|
||||
0,
|
||||
);
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
validate_session(&snapshot)?;
|
||||
emit_publish_progress(
|
||||
&app,
|
||||
&root_key,
|
||||
&version.version_id,
|
||||
"submit",
|
||||
"正在提交审核…",
|
||||
0,
|
||||
0,
|
||||
);
|
||||
let submit_value = request_json(
|
||||
&client,
|
||||
&snapshot,
|
||||
@@ -650,6 +733,14 @@ pub(crate) async fn publish_local_project_game(
|
||||
.await?;
|
||||
let submitted: SubmittedVersion =
|
||||
serde_json::from_value(submit_value).unwrap_or(SubmittedVersion { version: None });
|
||||
// 服务端已经完成收口(送审成功):清掉本次尝试记录,下一次同内容的发布就是一个新版本。
|
||||
// 清理失败不影响这次已经成功的发布——重试只会命中服务端幂等收据,不会多建游戏或版本。
|
||||
let _ = crate::game_publish_attempt::clear_game_publish_attempt(
|
||||
&app_data_dir,
|
||||
&snapshot.user_id,
|
||||
&snapshot.api_base_url,
|
||||
&local_project_id,
|
||||
);
|
||||
Ok(GameDistributionPublishResult {
|
||||
game_id: game.id,
|
||||
version_id: version.version_id,
|
||||
|
||||
@@ -366,10 +366,14 @@ impl<'a> GamePackageUploadRequest<'a> {
|
||||
}
|
||||
|
||||
/// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。
|
||||
///
|
||||
/// `on_verify` 在整包收齐、调用服务端 `/complete`(重算摘要 + 展开清单 + 确认完整对象)
|
||||
/// 之前触发:那一步是真正的「校验」,渲染层据此把进度切到校验阶段。
|
||||
pub(crate) async fn upload_staged_game_package(
|
||||
client: &reqwest::Client,
|
||||
request: GamePackageUploadRequest<'_>,
|
||||
mut on_progress: impl FnMut(u64, u64),
|
||||
mut on_verify: impl FnMut(),
|
||||
) -> Result<GamePackageUploadOutcome, String> {
|
||||
let total_bytes = fs::metadata(request.staging_path)
|
||||
.map_err(|error| format!("读取发行包暂存文件失败:{error}"))?
|
||||
@@ -444,6 +448,7 @@ pub(crate) async fn upload_staged_game_package(
|
||||
on_progress(received, total_bytes);
|
||||
}
|
||||
let access_token = request.current_access_token()?;
|
||||
on_verify();
|
||||
let mut outcome = complete_upload(
|
||||
client,
|
||||
request.api_base_url,
|
||||
@@ -456,14 +461,25 @@ pub(crate) async fn upload_staged_game_package(
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// 进度事件的载荷形状(渲染进程按它显示进度)。
|
||||
/// 进度事件的载荷形状(渲染进程按它显示阶段与百分比)。
|
||||
///
|
||||
/// `phase` 是发布链路的稳定阶段标识(`prepare` / `upload` / `verify` / `submit`),
|
||||
/// 渲染层据此点亮步骤条;`upload` 之外的阶段 `receivedBytes` / `totalBytes` 为 0,
|
||||
/// 表示该阶段没有可换算的百分比而不是 0% 进度。`publishId` 是本次发布尝试的根幂等键,
|
||||
/// 只用于把事件归到同一次发布,不含 token 或任何凭据。
|
||||
pub(crate) fn progress_event_payload(
|
||||
version_id: &str,
|
||||
publish_id: &str,
|
||||
phase: &str,
|
||||
message: &str,
|
||||
received_bytes: u64,
|
||||
total_bytes: u64,
|
||||
) -> Value {
|
||||
json!({
|
||||
"versionId": version_id,
|
||||
"publishId": publish_id,
|
||||
"phase": phase,
|
||||
"message": message,
|
||||
"receivedBytes": received_bytes,
|
||||
"totalBytes": total_bytes,
|
||||
})
|
||||
@@ -473,6 +489,29 @@ pub(crate) fn progress_event_payload(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn progress_payload_carries_publish_stage_and_percent_inputs() {
|
||||
let payload = progress_event_payload(
|
||||
"ver-1",
|
||||
"agc-publish-key",
|
||||
"upload",
|
||||
"正在上传发行包…",
|
||||
8 * 1024 * 1024,
|
||||
12 * 1024 * 1024,
|
||||
);
|
||||
assert_eq!(payload["versionId"], "ver-1");
|
||||
assert_eq!(payload["publishId"], "agc-publish-key");
|
||||
assert_eq!(payload["phase"], "upload");
|
||||
assert_eq!(payload["message"], "正在上传发行包…");
|
||||
assert_eq!(payload["receivedBytes"], 8 * 1024 * 1024);
|
||||
assert_eq!(payload["totalBytes"], 12 * 1024 * 1024);
|
||||
// 载荷只带本地标识与计数,不带 token、路径或包字节。
|
||||
let serialized = payload.to_string();
|
||||
for forbidden in ["token", "Bearer", "stagingPath", ":\\"] {
|
||||
assert!(!serialized.contains(forbidden), "{serialized}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_plan_covers_whole_package_without_gaps_or_overlap() {
|
||||
let total = 20 * 1024 * 1024 + 3;
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
//! AGC 发布尝试账本:把「同一份发行包的发布尝试」固定到同一个根幂等键上。
|
||||
//!
|
||||
//! 发布根幂等键派生 `:game` / `:version` / `:upload` / `:submit` 四个子键。只有当
|
||||
//! **同一账号、同一本地项目、同一发行包字节**重发时复用同一个根键,服务端才会把
|
||||
//! 重发识别成同一次尝试:响应丢失后重试不会多出一个版本,进程重启后分片续传仍接在
|
||||
//! 原来那个版本的已收字节上。包内容变了(重新导出或改了工程)就是新尝试,必须换键,
|
||||
//! 否则服务端会拿旧版本声明的体积拒绝这份新包。
|
||||
//!
|
||||
//! 账本落在应用数据目录(`app_data_dir`),不写进项目、也不参与项目快照同步;内容只
|
||||
//! 有账号标识、本地项目标识、包摘要和根键,**不含 token、不含任何本地绝对路径**。
|
||||
//! 一次发布在服务端完整收口(送审成功)后账本记录即被清掉,下一次发布自然是一个新版本。
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const GAME_PUBLISH_ATTEMPT_SCHEMA_VERSION: &str = "agc-game-publish-attempts.v1";
|
||||
const GAME_PUBLISH_ATTEMPT_FILE_NAME: &str = "game-publish-attempts.json";
|
||||
/// 账本上限:只服务「同一作者在多个项目里各有一次未收口发布」这一种场景,超出按更新时间丢弃最旧记录。
|
||||
const MAX_GAME_PUBLISH_ATTEMPTS: usize = 64;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GamePublishAttemptRecord {
|
||||
/// 平台账号标识(`user_account.user_id`),只从当前会话主体派生。
|
||||
account_id: String,
|
||||
/// 平台 origin:同一账号在不同环境(dev / release)的尝试必须互不复用。
|
||||
origin: String,
|
||||
/// AGC 本地项目标识(`manifest.projectId`),不是本地路径。
|
||||
local_project_id: String,
|
||||
/// 归一化发行包摘要;换包即换尝试。
|
||||
package_sha256: String,
|
||||
/// 本次尝试的发布根幂等键。
|
||||
root_key: String,
|
||||
updated_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GamePublishAttemptFile {
|
||||
#[serde(default = "game_publish_attempt_schema_version")]
|
||||
schema_version: String,
|
||||
#[serde(default)]
|
||||
attempts: Vec<GamePublishAttemptRecord>,
|
||||
}
|
||||
|
||||
fn game_publish_attempt_schema_version() -> String {
|
||||
GAME_PUBLISH_ATTEMPT_SCHEMA_VERSION.to_string()
|
||||
}
|
||||
|
||||
/// 账本读改写串行化:同一进程里的并发发布不会互相覆盖记录。
|
||||
static GAME_PUBLISH_ATTEMPT_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
pub(crate) fn game_publish_attempt_path(app_data_dir: &Path) -> PathBuf {
|
||||
app_data_dir.join(GAME_PUBLISH_ATTEMPT_FILE_NAME)
|
||||
}
|
||||
|
||||
/// 新的发布根幂等键:形状与渲染层历史键一致(`agc-publish-<uuid>`),便于服务端日志关联。
|
||||
pub(crate) fn new_game_publish_root_key() -> String {
|
||||
format!("agc-publish-{}", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
fn unix_timestamp() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn is_valid_package_sha256(value: &str) -> bool {
|
||||
value.len() == 64 && value.chars().all(|character| character.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn is_valid_attempt_identity(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
!trimmed.is_empty() && trimmed.len() <= 256 && !trimmed.contains(['\n', '\r'])
|
||||
}
|
||||
|
||||
fn normalize_record(record: GamePublishAttemptRecord) -> Option<GamePublishAttemptRecord> {
|
||||
if !is_valid_attempt_identity(&record.account_id)
|
||||
|| !is_valid_attempt_identity(&record.origin)
|
||||
|| !is_valid_attempt_identity(&record.local_project_id)
|
||||
|| !is_valid_package_sha256(&record.package_sha256)
|
||||
|| !is_valid_attempt_identity(&record.root_key)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(GamePublishAttemptRecord {
|
||||
account_id: record.account_id.trim().to_string(),
|
||||
origin: record.origin.trim().to_string(),
|
||||
local_project_id: record.local_project_id.trim().to_string(),
|
||||
package_sha256: record.package_sha256.trim().to_ascii_lowercase(),
|
||||
root_key: record.root_key.trim().to_string(),
|
||||
updated_at: record.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// 读取账本;文件缺失、损坏或含无效记录时按「没有可复用尝试」处理,发布本身不因此失败。
|
||||
fn read_attempts_at(path: &Path) -> Vec<GamePublishAttemptRecord> {
|
||||
let Ok(metadata) = fs::symlink_metadata(path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(content) = fs::read_to_string(path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(file) = serde_json::from_str::<GamePublishAttemptFile>(&content) else {
|
||||
return Vec::new();
|
||||
};
|
||||
file.attempts
|
||||
.into_iter()
|
||||
.filter_map(normalize_record)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 原子写入账本:临时文件 + `sync_all` + 改名,模式与工作区偏好存储一致。
|
||||
fn write_attempts_at(path: &Path, attempts: Vec<GamePublishAttemptRecord>) -> Result<(), String> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| "发布尝试账本缺少父目录".to_string())?;
|
||||
fs::create_dir_all(parent).map_err(|error| format!("创建发布尝试账本目录失败:{error}"))?;
|
||||
if let Ok(metadata) = fs::symlink_metadata(path) {
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err("发布尝试账本必须是普通文件".to_string());
|
||||
}
|
||||
}
|
||||
let file = GamePublishAttemptFile {
|
||||
schema_version: game_publish_attempt_schema_version(),
|
||||
attempts,
|
||||
};
|
||||
let content = serde_json::to_string_pretty(&file)
|
||||
.map_err(|error| format!("序列化发布尝试账本失败:{error}"))?;
|
||||
let temp_path = path.with_file_name(format!(
|
||||
".{}.tmp.{}.{}",
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(GAME_PUBLISH_ATTEMPT_FILE_NAME),
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
));
|
||||
let mut handle = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&temp_path)
|
||||
.map_err(|error| format!("创建发布尝试账本临时文件失败:{error}"))?;
|
||||
let write_result = handle
|
||||
.write_all(format!("{content}\n").as_bytes())
|
||||
.and_then(|_| handle.sync_all());
|
||||
drop(handle);
|
||||
if let Err(error) = write_result {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
return Err(format!("写入发布尝试账本失败:{error}"));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if path.exists() {
|
||||
fs::remove_file(path).map_err(|error| {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
format!("替换发布尝试账本失败:{error}")
|
||||
})?;
|
||||
}
|
||||
if let Err(error) = fs::rename(&temp_path, path) {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
return Err(format!("提交发布尝试账本失败:{error}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn attempts_capped(mut attempts: Vec<GamePublishAttemptRecord>) -> Vec<GamePublishAttemptRecord> {
|
||||
if attempts.len() <= MAX_GAME_PUBLISH_ATTEMPTS {
|
||||
return attempts;
|
||||
}
|
||||
attempts.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
|
||||
attempts.truncate(MAX_GAME_PUBLISH_ATTEMPTS);
|
||||
attempts
|
||||
}
|
||||
|
||||
/// 解析本次发布要用的根幂等键。
|
||||
///
|
||||
/// 命中「同账号 + 同 origin + 同本地项目 + 同包摘要」时复用既有键(响应丢失、进程重启、
|
||||
/// 分片续传都落在同一次服务端尝试上);否则铸一个新键并落盘。返回的第二个值表示是否复用。
|
||||
pub(crate) fn resolve_game_publish_root_key(
|
||||
app_data_dir: &Path,
|
||||
account_id: &str,
|
||||
origin: &str,
|
||||
local_project_id: &str,
|
||||
package_sha256: &str,
|
||||
) -> Result<(String, bool), String> {
|
||||
let candidate = normalize_record(GamePublishAttemptRecord {
|
||||
account_id: account_id.to_string(),
|
||||
origin: origin.to_string(),
|
||||
local_project_id: local_project_id.to_string(),
|
||||
package_sha256: package_sha256.to_string(),
|
||||
root_key: new_game_publish_root_key(),
|
||||
updated_at: unix_timestamp(),
|
||||
})
|
||||
.ok_or_else(|| "发布尝试账本身份无效,请重新登录或重新打开项目后再试".to_string())?;
|
||||
let path = game_publish_attempt_path(app_data_dir);
|
||||
let _guard = GAME_PUBLISH_ATTEMPT_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "发布尝试账本锁不可用".to_string())?;
|
||||
let mut attempts = read_attempts_at(&path);
|
||||
if let Some(existing) = attempts.iter().find(|record| {
|
||||
record.account_id == candidate.account_id
|
||||
&& record.origin == candidate.origin
|
||||
&& record.local_project_id == candidate.local_project_id
|
||||
&& record.package_sha256 == candidate.package_sha256
|
||||
}) {
|
||||
return Ok((existing.root_key.clone(), true));
|
||||
}
|
||||
let root_key = candidate.root_key.clone();
|
||||
attempts.retain(|record| {
|
||||
!(record.account_id == candidate.account_id
|
||||
&& record.origin == candidate.origin
|
||||
&& record.local_project_id == candidate.local_project_id)
|
||||
});
|
||||
attempts.push(candidate);
|
||||
write_attempts_at(&path, attempts_capped(attempts))?;
|
||||
Ok((root_key, false))
|
||||
}
|
||||
|
||||
/// 一次发布在服务端收口后清掉记录:下一次同样内容的发布就是一个新版本。
|
||||
///
|
||||
/// 清理失败不影响已经成功的发布——重试只会命中服务端幂等收据而不会多建版本,
|
||||
/// 代价是作者可能看不到新版本,因此这里只在账本可写时才是「干净」的。
|
||||
pub(crate) fn clear_game_publish_attempt(
|
||||
app_data_dir: &Path,
|
||||
account_id: &str,
|
||||
origin: &str,
|
||||
local_project_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let path = game_publish_attempt_path(app_data_dir);
|
||||
let _guard = GAME_PUBLISH_ATTEMPT_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "发布尝试账本锁不可用".to_string())?;
|
||||
let mut attempts = read_attempts_at(&path);
|
||||
let before = attempts.len();
|
||||
attempts.retain(|record| {
|
||||
!(record.account_id == account_id
|
||||
&& record.origin == origin
|
||||
&& record.local_project_id == local_project_id)
|
||||
});
|
||||
if attempts.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
write_attempts_at(&path, attempts)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"agc-game-publish-attempt-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).expect("fixture dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_package_reuses_root_key_and_new_package_rotates_it() {
|
||||
let dir = fixture_dir("reuse");
|
||||
let sha = "a".repeat(64);
|
||||
let (first, reused) =
|
||||
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &sha)
|
||||
.expect("first key");
|
||||
assert!(!reused);
|
||||
assert!(first.starts_with("agc-publish-"));
|
||||
|
||||
let (again, reused) =
|
||||
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &sha)
|
||||
.expect("reused key");
|
||||
assert!(reused, "同一份包重发必须复用同一个根幂等键");
|
||||
assert_eq!(again, first);
|
||||
|
||||
// 重启不改变结论:换一个进程视角重新读盘仍然是同一个键。
|
||||
let (after_restart, reused) =
|
||||
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &sha)
|
||||
.expect("restarted key");
|
||||
assert!(reused);
|
||||
assert_eq!(after_restart, first);
|
||||
|
||||
let other_sha = "b".repeat(64);
|
||||
let (rotated, reused) =
|
||||
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &other_sha)
|
||||
.expect("rotated key");
|
||||
assert!(!reused, "换了包就不是同一次尝试");
|
||||
assert_ne!(rotated, first);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attempts_are_scoped_per_account_origin_and_project() {
|
||||
let dir = fixture_dir("scope");
|
||||
let sha = "c".repeat(64);
|
||||
let (base, _) =
|
||||
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &sha)
|
||||
.expect("base key");
|
||||
for (account, origin, project) in [
|
||||
("user-2", "https://dev.test", "proj-1"),
|
||||
("user-1", "https://release.test", "proj-1"),
|
||||
("user-1", "https://dev.test", "proj-2"),
|
||||
] {
|
||||
let (scoped, reused) =
|
||||
resolve_game_publish_root_key(&dir, account, origin, project, &sha)
|
||||
.expect("scoped key");
|
||||
assert!(!reused, "不同账号/origin/项目不得复用别人的发布尝试");
|
||||
assert_ne!(scoped, base);
|
||||
}
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_attempt_rotates_the_next_publish_of_the_same_package() {
|
||||
let dir = fixture_dir("clear");
|
||||
let sha = "d".repeat(64);
|
||||
let (first, _) =
|
||||
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &sha)
|
||||
.expect("first key");
|
||||
clear_game_publish_attempt(&dir, "user-1", "https://dev.test", "proj-1")
|
||||
.expect("clear attempt");
|
||||
let (second, reused) =
|
||||
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &sha)
|
||||
.expect("second key");
|
||||
assert!(!reused, "收口过的尝试不再复用,下一次发布是新版本");
|
||||
assert_ne!(second, first);
|
||||
// 清掉不存在的记录是幂等的空操作。
|
||||
clear_game_publish_attempt(&dir, "user-1", "https://dev.test", "proj-1")
|
||||
.expect("clear again");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ledger_only_holds_identifiers_and_summaries() {
|
||||
let dir = fixture_dir("shape");
|
||||
let sha = "e".repeat(64);
|
||||
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &sha)
|
||||
.expect("key");
|
||||
let content =
|
||||
fs::read_to_string(game_publish_attempt_path(&dir)).expect("read ledger file");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&content).expect("ledger json");
|
||||
assert_eq!(
|
||||
parsed.get("schemaVersion").and_then(|value| value.as_str()),
|
||||
Some(GAME_PUBLISH_ATTEMPT_SCHEMA_VERSION)
|
||||
);
|
||||
let record = parsed
|
||||
.get("attempts")
|
||||
.and_then(|value| value.as_array())
|
||||
.and_then(|records| records.first())
|
||||
.expect("attempt record");
|
||||
assert_eq!(
|
||||
record
|
||||
.get("localProjectId")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("proj-1")
|
||||
);
|
||||
// 账本不写 token、刷新凭据,也不写本地绝对路径。
|
||||
for forbidden in [
|
||||
"token",
|
||||
"access",
|
||||
"refresh",
|
||||
"Authorization",
|
||||
":\\",
|
||||
"/home/",
|
||||
] {
|
||||
assert!(
|
||||
!content.contains(forbidden),
|
||||
"账本出现了不该持久化的字段:{forbidden}"
|
||||
);
|
||||
}
|
||||
// 原子写入不残留临时文件。
|
||||
let leftovers = fs::read_dir(&dir)
|
||||
.expect("dir")
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| entry.file_name().to_string_lossy().starts_with('.'))
|
||||
.count();
|
||||
assert_eq!(leftovers, 0, "临时文件必须被改名或清理");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,7 @@ mod environment_check;
|
||||
pub mod error_report;
|
||||
mod game_distribution_publish;
|
||||
mod game_package_upload;
|
||||
mod game_publish_attempt;
|
||||
mod git_inspect;
|
||||
mod goal;
|
||||
mod http_client;
|
||||
|
||||
@@ -269,6 +269,9 @@ pub(crate) async fn export_local_project_package_for_publish_at(
|
||||
);
|
||||
}
|
||||
let plan = resolve_publish_build_plan(root)?;
|
||||
// 首版发行包合同只覆盖 Phaser 4 + Vite 的 npm 工程:在跑安装/构建之前先说清楚,
|
||||
// 避免作者等完一次 npm install 才拿到一句泛化错误。
|
||||
ensure_publish_project_stack_at(root, plan.cwd_relative)?;
|
||||
// `game/` 子工程构建前必须先有依赖:缺 node_modules 时由发布流程自己补一次安装,
|
||||
// 否则作者要点两次(先 bootstrap 再发布)。
|
||||
if plan.needs_dependency_install {
|
||||
@@ -301,6 +304,173 @@ pub(crate) async fn export_local_project_package_for_publish_at(
|
||||
export_local_project_package_at(root)
|
||||
}
|
||||
|
||||
/// 解析依赖声明里的版本号主版本:跳过 `^`、`~`、`>=` 等前缀,取第一段连续数字。
|
||||
///
|
||||
/// `*`、`latest`、`workspace:*` 这类没有数字的声明返回 `None`,由调用方决定是否回退到
|
||||
/// `package-lock.json` 的解析结果。
|
||||
fn declared_version_major(spec: &str) -> Option<u64> {
|
||||
let digits = spec
|
||||
.trim()
|
||||
.chars()
|
||||
.skip_while(|character| !character.is_ascii_digit())
|
||||
.take_while(char::is_ascii_digit)
|
||||
.collect::<String>();
|
||||
digits.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
/// 读取依赖声明原文;兼容 `"phaser": "4.2.1"` 与 `"phaser": { "version": "4.2.1" }` 两种写法。
|
||||
fn declared_dependency_spec(package: &serde_json::Value, name: &str) -> Option<String> {
|
||||
const SECTIONS: [&str; 3] = ["dependencies", "devDependencies", "peerDependencies"];
|
||||
for section in SECTIONS {
|
||||
let Some(entry) = package.get(section).and_then(|deps| deps.get(name)) else {
|
||||
continue;
|
||||
};
|
||||
let spec = match entry {
|
||||
serde_json::Value::String(spec) => spec.trim().to_string(),
|
||||
serde_json::Value::Object(object) => object
|
||||
.get("version")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
if !spec.is_empty() {
|
||||
return Some(spec);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `package-lock.json` 的已解析版本:v2/v3 看 `packages["node_modules/<name>"]`,v1 看 `dependencies`。
|
||||
fn locked_dependency_version(package_root: &Path, name: &str) -> Option<String> {
|
||||
let lock_path = package_root.join("package-lock.json");
|
||||
let content = fs::read_to_string(&lock_path).ok()?;
|
||||
let lock: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
let from_packages = lock
|
||||
.get("packages")
|
||||
.and_then(|packages| packages.get(format!("node_modules/{name}")))
|
||||
.and_then(|entry| entry.get("version"))
|
||||
.and_then(serde_json::Value::as_str);
|
||||
from_packages
|
||||
.or_else(|| {
|
||||
lock.get("dependencies")
|
||||
.and_then(|dependencies| dependencies.get(name))
|
||||
.and_then(|entry| entry.get("version"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
})
|
||||
.map(|version| version.trim().to_string())
|
||||
.filter(|version| !version.is_empty())
|
||||
}
|
||||
|
||||
/// 读取某个 npm 工作区的 `package.json`;不存在或不是普通文件时返回 `None`。
|
||||
fn read_publish_workspace_package(
|
||||
root: &Path,
|
||||
cwd_relative: &str,
|
||||
) -> Result<Option<(PathBuf, serde_json::Value)>, String> {
|
||||
let package_root = if cwd_relative == "." {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
resolve_local_project_path(root, cwd_relative)?
|
||||
};
|
||||
let package_path = package_root.join("package.json");
|
||||
let metadata = match fs::symlink_metadata(&package_path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(format!("{cwd_relative}/package.json 必须是普通文件"));
|
||||
}
|
||||
let content = fs::read_to_string(&package_path)
|
||||
.map_err(|error| format!("读取 {cwd_relative}/package.json 失败:{error}"))?;
|
||||
let package: serde_json::Value = serde_json::from_str(&content)
|
||||
.map_err(|error| format!("解析 {cwd_relative}/package.json 失败:{error}"))?;
|
||||
Ok(Some((package_root, package)))
|
||||
}
|
||||
|
||||
fn workspace_has_vite_config(package_root: &Path) -> bool {
|
||||
[
|
||||
"vite.config.js",
|
||||
"vite.config.mjs",
|
||||
"vite.config.cjs",
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vite.config.cts",
|
||||
]
|
||||
.iter()
|
||||
.any(|name| package_root.join(name).is_file())
|
||||
}
|
||||
|
||||
/// 发布到游戏广场首版只接受 Phaser 4 + Vite 的 npm 工程。
|
||||
///
|
||||
/// 只校验「确实存在 npm 构建工作区」的项目(`resolve_publish_build_cwd` 命中):老的
|
||||
/// 单文件 HTML 项目没有 `package.json`,不在首版 npm 打包合同内,保持原有发布能力。
|
||||
/// 判定顺序以**将被构建的那个工作区**为准,它没声明 phaser 时再看项目里的另一个工作区
|
||||
/// (根工程 / `game` 子工程),避免多工作区项目被误判;校验失败必须给出可操作错误——
|
||||
/// 缺什么依赖、当前声明是什么版本、要怎么做。错误文案只带项目内相对路径。
|
||||
fn ensure_publish_project_stack_at(
|
||||
root: &Path,
|
||||
build_cwd_relative: &'static str,
|
||||
) -> Result<(), String> {
|
||||
let candidates: [&'static str; 2] = if build_cwd_relative == "." {
|
||||
[".", "game"]
|
||||
} else {
|
||||
[build_cwd_relative, "."]
|
||||
};
|
||||
let mut phaser: Option<(String, String, PathBuf)> = None;
|
||||
let mut vite_ready = false;
|
||||
let mut workspace_found = false;
|
||||
for cwd_relative in candidates {
|
||||
let Some((package_root, package)) = read_publish_workspace_package(root, cwd_relative)?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
workspace_found = true;
|
||||
if phaser.is_none() {
|
||||
if let Some(spec) = declared_dependency_spec(&package, "phaser") {
|
||||
phaser = Some((spec, cwd_relative.to_string(), package_root.clone()));
|
||||
}
|
||||
}
|
||||
if declared_dependency_spec(&package, "vite").is_some()
|
||||
|| workspace_has_vite_config(&package_root)
|
||||
{
|
||||
vite_ready = true;
|
||||
}
|
||||
}
|
||||
if !workspace_found {
|
||||
return Ok(());
|
||||
}
|
||||
let Some((spec, cwd_relative, package_root)) = phaser else {
|
||||
return Err(format!(
|
||||
"发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{build_cwd_relative}/package.json 未声明 phaser 依赖。请先安装 Phaser 4(例如 npm install phaser@4.2.1)后重新发布。"
|
||||
));
|
||||
};
|
||||
let major = declared_version_major(&spec).or_else(|| {
|
||||
locked_dependency_version(&package_root, "phaser")
|
||||
.as_deref()
|
||||
.and_then(declared_version_major)
|
||||
});
|
||||
if major.is_some_and(|major| major != 4) {
|
||||
return Err(format!(
|
||||
"发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{cwd_relative}/package.json 声明的 phaser 版本是 {spec}。请升级到 Phaser 4(例如 npm install phaser@4.2.1)后重新发布。"
|
||||
));
|
||||
}
|
||||
if !vite_ready {
|
||||
return Err(format!(
|
||||
"发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{cwd_relative}/package.json 未声明 vite 依赖,也没有 vite.config.*。请改用 Vite 构建(例如 npm create vite)后重新发布。"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 发布前校验工程栈:存在 npm 构建工作区时要求 Phaser 4 + Vite,否则不作判断。
|
||||
pub(crate) fn ensure_publish_project_stack(root: &Path) -> Result<(), String> {
|
||||
match resolve_publish_build_cwd(root)? {
|
||||
Some(cwd_relative) => ensure_publish_project_stack_at(root, cwd_relative),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 发布前的客户端预检:包体不得超过平台上限。
|
||||
///
|
||||
/// 服务端仍会独立校验;这里先判一次是为了给作者可操作错误(含实际体积与上限),
|
||||
@@ -660,6 +830,35 @@ pub(crate) fn normalize_export_package_entry_path(relative_path: &str) -> Result
|
||||
mod npm_export_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn declared_version_major_reads_semver_prefixes_and_ranges() {
|
||||
assert_eq!(declared_version_major("4.2.1"), Some(4));
|
||||
assert_eq!(declared_version_major("^4.2.1"), Some(4));
|
||||
assert_eq!(declared_version_major("~4.2.1"), Some(4));
|
||||
assert_eq!(declared_version_major(">=4 <5"), Some(4));
|
||||
assert_eq!(declared_version_major("3.90.0"), Some(3));
|
||||
assert_eq!(declared_version_major("latest"), None);
|
||||
assert_eq!(declared_version_major("*"), None);
|
||||
assert_eq!(declared_version_major("workspace:*"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_dependency_spec_accepts_string_and_object_forms() {
|
||||
let package = serde_json::json!({
|
||||
"dependencies": { "phaser": "^4.2.1" },
|
||||
"devDependencies": { "vite": { "version": "^6.2.0" } }
|
||||
});
|
||||
assert_eq!(
|
||||
declared_dependency_spec(&package, "phaser").as_deref(),
|
||||
Some("^4.2.1")
|
||||
);
|
||||
assert_eq!(
|
||||
declared_dependency_spec(&package, "vite").as_deref(),
|
||||
Some("^6.2.0")
|
||||
);
|
||||
assert_eq!(declared_dependency_spec(&package, "three"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_precheck_rejects_packages_over_the_platform_limit() {
|
||||
let limit = shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES;
|
||||
|
||||
@@ -4019,6 +4019,148 @@ fn publish_build_plan_falls_back_to_root_npm_build() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_stack_validation_accepts_scaffolded_phaser4_vite_project() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-stack-ok", "栈校验通过项目").expect("project init");
|
||||
ensure_publish_project_stack(&root).expect("Phaser 4 + Vite 脚手架应通过发布前工程栈校验");
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_stack_validation_reports_actionable_error_for_phaser3() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-stack-phaser3", "Phaser 3 项目")
|
||||
.expect("project init");
|
||||
fs::write(
|
||||
root.join("game/package.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "stack-phaser3",
|
||||
"private": true,
|
||||
"scripts": { "build": "vite build" },
|
||||
"dependencies": { "phaser": "^3.90.0" },
|
||||
"devDependencies": { "vite": "^6.2.0" }
|
||||
}))
|
||||
.expect("serialize phaser3 package json"),
|
||||
)
|
||||
.expect("write phaser3 package json");
|
||||
|
||||
let error = ensure_publish_project_stack(&root).expect_err("Phaser 3 必须被拦下");
|
||||
|
||||
assert!(error.contains("只支持 Phaser 4"), "{error}");
|
||||
assert!(error.contains("3.90.0"), "错误要带上当前声明版本:{error}");
|
||||
assert!(
|
||||
error.contains("npm install phaser@4.2.1"),
|
||||
"错误要给出可操作修复:{error}"
|
||||
);
|
||||
// 错误文案只带项目内相对路径,不带本地绝对路径。
|
||||
assert!(
|
||||
!error.contains(&root.to_string_lossy().to_string()),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_stack_validation_requires_phaser_dependency_and_vite() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-stack-missing", "缺依赖项目").expect("project init");
|
||||
fs::write(
|
||||
root.join("game/package.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "stack-no-phaser",
|
||||
"private": true,
|
||||
"scripts": { "build": "vite build" },
|
||||
"devDependencies": { "vite": "^6.2.0" }
|
||||
}))
|
||||
.expect("serialize no-phaser package json"),
|
||||
)
|
||||
.expect("write no-phaser package json");
|
||||
let error = ensure_publish_project_stack(&root).expect_err("缺 phaser 必须被拦下");
|
||||
assert!(error.contains("未声明 phaser 依赖"), "{error}");
|
||||
|
||||
// Phaser 4 但没有 Vite(依赖与配置都不存在):错误必须指向 Vite。
|
||||
fs::remove_file(root.join("game/vite.config.js")).expect("remove scaffold vite config");
|
||||
fs::write(
|
||||
root.join("game/package.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "stack-webpack",
|
||||
"private": true,
|
||||
"scripts": { "build": "webpack" },
|
||||
"dependencies": { "phaser": "4.2.1" }
|
||||
}))
|
||||
.expect("serialize non-vite package json"),
|
||||
)
|
||||
.expect("write non-vite package json");
|
||||
let error = ensure_publish_project_stack(&root).expect_err("非 Vite 工程必须被拦下");
|
||||
assert!(error.contains("vite"), "{error}");
|
||||
assert!(error.contains("Vite 构建"), "{error}");
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_stack_validation_reads_semver_ranges_and_lockfile_versions() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-stack-semver", "版本区间项目")
|
||||
.expect("project init");
|
||||
fs::write(
|
||||
root.join("game/package.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "stack-semver",
|
||||
"private": true,
|
||||
"scripts": { "build": "vite build" },
|
||||
"dependencies": { "phaser": ">=4 <5" },
|
||||
"devDependencies": { "vite": "^6.2.0" }
|
||||
}))
|
||||
.expect("serialize semver package json"),
|
||||
)
|
||||
.expect("write semver package json");
|
||||
ensure_publish_project_stack(&root).expect(">=4 <5 属于 Phaser 4");
|
||||
|
||||
// 声明成 `*` 时以 package-lock.json 的解析版本为准。
|
||||
fs::write(
|
||||
root.join("game/package.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "stack-star",
|
||||
"private": true,
|
||||
"scripts": { "build": "vite build" },
|
||||
"dependencies": { "phaser": "*" },
|
||||
"devDependencies": { "vite": "^6.2.0" }
|
||||
}))
|
||||
.expect("serialize star package json"),
|
||||
)
|
||||
.expect("write star package json");
|
||||
fs::write(
|
||||
root.join("game/package-lock.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "stack-star",
|
||||
"lockfileVersion": 3,
|
||||
"packages": { "": {}, "node_modules/phaser": { "version": "3.90.0" } }
|
||||
}))
|
||||
.expect("serialize lockfile"),
|
||||
)
|
||||
.expect("write lockfile");
|
||||
let error = ensure_publish_project_stack(&root).expect_err("锁文件里的 Phaser 3 也要拦下");
|
||||
assert!(error.contains("只支持 Phaser 4"), "{error}");
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_stack_validation_skips_projects_without_npm_build_workspace() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-stack-legacy", "无 npm 构建项目")
|
||||
.expect("project init");
|
||||
// 去掉 npm 工作区(没有 package.json 也就没有 build 脚本),退化成老单文件项目。
|
||||
fs::remove_file(root.join("game/package.json")).expect("remove game package json");
|
||||
|
||||
ensure_publish_project_stack(&root).expect("没有 npm 构建工作区时不做 Phaser 4 判断");
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_export_runs_project_build_before_packaging() {
|
||||
let root = unique_project_path();
|
||||
|
||||
@@ -936,6 +936,7 @@ export function App({
|
||||
setPublishProgress({
|
||||
phase: 'running',
|
||||
message: '正在构建并打包试玩包,请稍候…',
|
||||
step: 'build',
|
||||
});
|
||||
try {
|
||||
const result = await invoke<LocalProjectExportPackageResult>(
|
||||
|
||||
+55
-10
@@ -8,7 +8,6 @@ import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { LocalProjectExportPackageResult } from '../../app/types';
|
||||
import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload';
|
||||
import {
|
||||
createGameDistributionPublishKey,
|
||||
type GameDistributionPublishMetadata,
|
||||
type GameDistributionPublishResult,
|
||||
generateGameDistributionCover,
|
||||
@@ -17,7 +16,14 @@ import {
|
||||
readGameCoverGenerationPrice,
|
||||
suggestGameDistributionPublishMetadata,
|
||||
} from '../../services/gameDistributionPublish';
|
||||
import {
|
||||
computeGamePackageUploadPercent,
|
||||
type GamePublishRuntimePhase,
|
||||
subscribeGamePackageUploadProgress,
|
||||
} from '../../services/gamePackageUploadProgress';
|
||||
import type { TauriEventUnsubscribe } from '../../services/tauriEventSubscription';
|
||||
import { ThemedModal } from '../modal/ThemedModal';
|
||||
import { GamePublishPhaseSteps } from './GamePublishPhaseSteps';
|
||||
|
||||
/** 封面与截图都是公开展示素材,限制单张体积,避免手机原图直传拖垮发布流程。 */
|
||||
const PANEL_IMAGE_MAX_BYTES = 6 * 1024 * 1024;
|
||||
@@ -182,7 +188,12 @@ export function GameDistributionPublishPanel({
|
||||
number | null
|
||||
>(null);
|
||||
const [coverGenerationDetail, setCoverGenerationDetail] = useState('');
|
||||
const publishIdempotencyKeyRef = useRef('');
|
||||
/** 发布进行中的阶段与百分比;来自 `game-package-upload-progress`。 */
|
||||
const [publishPhase, setPublishPhase] = useState<{
|
||||
phase: GamePublishRuntimePhase;
|
||||
message: string;
|
||||
percent: number | null;
|
||||
} | null>(null);
|
||||
const metadataSuggestionRequestRef = useRef(0);
|
||||
const summaryTouchedRef = useRef(false);
|
||||
const categoryTouchedRef = useRef(false);
|
||||
@@ -210,6 +221,7 @@ export function GameDistributionPublishPanel({
|
||||
setCoverGenerationPrice(null);
|
||||
setCoverGenerationDetail('');
|
||||
setPreviewScreenshot(null);
|
||||
setPublishPhase(null);
|
||||
summaryTouchedRef.current = false;
|
||||
categoryTouchedRef.current = false;
|
||||
|
||||
@@ -240,12 +252,6 @@ export function GameDistributionPublishPanel({
|
||||
});
|
||||
}, [manifest, open, packageResult?.packageRelativePath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
publishIdempotencyKeyRef.current = createGameDistributionPublishKey();
|
||||
}
|
||||
}, [open, packageResult?.packageRelativePath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
@@ -485,6 +491,32 @@ export function GameDistributionPublishPanel({
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setPublishPhase({
|
||||
phase: 'prepare',
|
||||
message: '正在准备发布…',
|
||||
percent: null,
|
||||
});
|
||||
// 先订阅再发起发布:Rust 的「准备 / 上传 / 校验 / 送审」事件都在命令执行期间回传,
|
||||
// 订阅是异步的,晚订阅会丢掉开头几个阶段。事件的 versionId 在命令返回前对渲染层
|
||||
// 不可知,所以只按「本次订阅窗口」收敛——同一窗口同一时刻只有一次发布在跑。
|
||||
let unsubscribe: TauriEventUnsubscribe = () => undefined;
|
||||
try {
|
||||
unsubscribe = await subscribeGamePackageUploadProgress((progress) => {
|
||||
setPublishPhase({
|
||||
phase: progress.phase,
|
||||
message: progress.message || '正在发布…',
|
||||
percent:
|
||||
progress.phase === 'upload'
|
||||
? computeGamePackageUploadPercent(
|
||||
progress.receivedBytes,
|
||||
progress.totalBytes,
|
||||
)
|
||||
: null,
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// 订阅失败只影响阶段条:发布本身照常进行,按钮上仍显示「正在发布…」。
|
||||
}
|
||||
try {
|
||||
const next = await publishLocalProjectGame({
|
||||
invoke,
|
||||
@@ -506,7 +538,6 @@ export function GameDistributionPublishPanel({
|
||||
)
|
||||
.map((item) => item.assetObjectId),
|
||||
},
|
||||
idempotencyKey: publishIdempotencyKeyRef.current,
|
||||
});
|
||||
setResult(next);
|
||||
onPublished?.(next);
|
||||
@@ -515,7 +546,9 @@ export function GameDistributionPublishPanel({
|
||||
nextError instanceof Error ? nextError.message : String(nextError),
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
setBusy(false);
|
||||
setPublishPhase(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -811,6 +844,18 @@ export function GameDistributionPublishPanel({
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{busy && publishPhase ? (
|
||||
<div
|
||||
className="game-distribution-publish-panel__progress"
|
||||
role="status"
|
||||
>
|
||||
<GamePublishPhaseSteps
|
||||
activeStep={publishPhase.phase}
|
||||
percent={publishPhase.percent}
|
||||
message={publishPhase.message}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="game-distribution-publish-panel__actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -833,7 +878,7 @@ export function GameDistributionPublishPanel({
|
||||
}
|
||||
>
|
||||
{busy
|
||||
? '上传中…'
|
||||
? '正在发布…'
|
||||
: generatingCover
|
||||
? '封面生成中…'
|
||||
: screenshotsUploading
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
GAME_PUBLISH_STEPS,
|
||||
type GamePublishStepId,
|
||||
} from '../../services/gamePackageUploadProgress';
|
||||
|
||||
/**
|
||||
* 发布阶段步骤条:构建 → 准备 → 上传 → 校验 → 送审。
|
||||
*
|
||||
* 只做展示:当前阶段之前的一律标成已完成,当前阶段带百分比(只有上传阶段有可换算的总量),
|
||||
* 之后的仍为待执行。组件不订阅事件,进度由调用方(发布面板 / 全屏进度弹窗)注入。
|
||||
*/
|
||||
export function GamePublishPhaseSteps({
|
||||
activeStep,
|
||||
percent = null,
|
||||
message = '',
|
||||
}: {
|
||||
activeStep: GamePublishStepId;
|
||||
percent?: number | null;
|
||||
message?: string;
|
||||
}) {
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
GAME_PUBLISH_STEPS.findIndex((step) => step.id === activeStep),
|
||||
);
|
||||
const activeLabel = GAME_PUBLISH_STEPS[activeIndex]?.label ?? '';
|
||||
return (
|
||||
<div className="game-publish-phase">
|
||||
<ol className="game-publish-phase__steps" aria-label="发布阶段">
|
||||
{GAME_PUBLISH_STEPS.map((step, index) => {
|
||||
const state =
|
||||
index === activeIndex
|
||||
? 'active'
|
||||
: index < activeIndex
|
||||
? 'done'
|
||||
: 'pending';
|
||||
return (
|
||||
<li
|
||||
key={step.id}
|
||||
className={`game-publish-phase__step is-${state}`}
|
||||
aria-current={state === 'active' ? 'step' : undefined}
|
||||
>
|
||||
<span className="game-publish-phase__marker" aria-hidden="true">
|
||||
{state === 'done' ? '✓' : index + 1}
|
||||
</span>
|
||||
<span className="game-publish-phase__label">{step.label}</span>
|
||||
{state === 'active' && percent !== null ? (
|
||||
<span className="game-publish-phase__percent">{percent}%</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{percent === null ? null : (
|
||||
<div
|
||||
className="game-publish-phase__bar"
|
||||
role="progressbar"
|
||||
aria-label={`${activeLabel}进度`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
>
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{message ? (
|
||||
<p className="game-publish-phase__message">{message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+13
@@ -1,11 +1,17 @@
|
||||
import { CircleAlert, LoaderCircle } from 'lucide-react';
|
||||
|
||||
import type { GamePublishStepId } from '../../services/gamePackageUploadProgress';
|
||||
import { ThemedModal } from '../modal/ThemedModal';
|
||||
import { GamePublishPhaseSteps } from './GamePublishPhaseSteps';
|
||||
|
||||
export type GamePublishProgressState =
|
||||
| {
|
||||
phase: 'running';
|
||||
message: string;
|
||||
/** 当前步骤;缺省按「构建」处理——导出打包是发布链路的第一步。 */
|
||||
step?: GamePublishStepId;
|
||||
/** 只有上传阶段有可换算的百分比;其它阶段为 `null`(不确定进度)。 */
|
||||
percent?: number | null;
|
||||
}
|
||||
| {
|
||||
phase: 'failed';
|
||||
@@ -21,6 +27,7 @@ export function GamePublishProgressDialog({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const failed = progress?.phase === 'failed';
|
||||
const running = progress?.phase === 'running' ? progress : null;
|
||||
return (
|
||||
<ThemedModal
|
||||
open={progress !== null}
|
||||
@@ -46,6 +53,12 @@ export function GamePublishProgressDialog({
|
||||
<p role={failed ? 'alert' : 'status'}>
|
||||
{failed ? progress.detail : progress?.message}
|
||||
</p>
|
||||
{running ? (
|
||||
<GamePublishPhaseSteps
|
||||
activeStep={running.step ?? 'build'}
|
||||
percent={running.percent ?? null}
|
||||
/>
|
||||
) : null}
|
||||
{failed ? (
|
||||
<button type="button" onClick={onClose}>
|
||||
关闭
|
||||
|
||||
@@ -61,14 +61,6 @@ export type GameDistributionPublishResult = {
|
||||
fileCount: number;
|
||||
};
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
export type GameDistributionPublishMetadataSuggestion = {
|
||||
summary: string;
|
||||
category: GameDistributionCategory;
|
||||
@@ -226,6 +218,12 @@ export async function publishLocalProjectGame(args: {
|
||||
packageRelativePath: string;
|
||||
manifest: GameCreationAppManifest;
|
||||
metadata?: Partial<GameDistributionPublishMetadata>;
|
||||
/**
|
||||
* 发布根幂等键。渲染层默认**不**生成也不持久化它:原生发布链路按
|
||||
* 「账号身份 + 本地项目 + 发行包摘要」在应用数据里维护发布尝试账本,同一份包
|
||||
* 重发、响应丢失、进程重启与分片续传都落在同一次服务端尝试上。只有显式传入
|
||||
* (测试或调用方已持有键)时才覆盖账本。
|
||||
*/
|
||||
idempotencyKey?: string;
|
||||
}): Promise<GameDistributionPublishResult> {
|
||||
const projectPath = args.projectPath.trim();
|
||||
@@ -239,8 +237,7 @@ export async function publishLocalProjectGame(args: {
|
||||
throw new Error('发布需要本地项目标识,请重新打开项目后再试');
|
||||
}
|
||||
const gameMetadata = toCreateGameRequest(metadata, localProjectId);
|
||||
const rootKey =
|
||||
args.idempotencyKey?.trim() || createGameDistributionPublishKey();
|
||||
const rootKey = args.idempotencyKey?.trim() ?? '';
|
||||
// 发布编排、认证、origin、分片续传与送审都在 Rust 完成;渲染进程只传递
|
||||
// 已归一化的资料和本地项目相对包路径,不接触 token、base URL 或整包字节。
|
||||
return args.invoke<GameDistributionPublishResult>(
|
||||
@@ -249,7 +246,7 @@ export async function publishLocalProjectGame(args: {
|
||||
projectPath,
|
||||
packageRelativePath,
|
||||
metadata: gameMetadata,
|
||||
idempotencyKey: rootKey,
|
||||
...(rootKey ? { idempotencyKey: rootKey } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
subscribeTauriEvent,
|
||||
type TauriEventSubscribeOptions,
|
||||
type TauriEventUnsubscribe,
|
||||
} from './tauriEventSubscription';
|
||||
|
||||
/**
|
||||
* AGC 发布进度事件:Rust 在发布编排的每个阶段回传,渲染层只做展示。
|
||||
*
|
||||
* 事件名与 Rust `game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT` 绑定:
|
||||
* 分片上传的字节进度和「准备 / 上传 / 校验 / 送审」阶段切换走同一条事件,避免两条
|
||||
* 事件流在 UI 上互相覆盖。载荷只含标识、阶段、文案与字节数,不含 token、本地路径
|
||||
* 或整包字节。
|
||||
*/
|
||||
export const GAME_PACKAGE_UPLOAD_PROGRESS_EVENT =
|
||||
'game-package-upload-progress';
|
||||
|
||||
/** Rust 发布编排回传的阶段:准备(建游戏/版本)→ 上传 → 校验 → 送审。 */
|
||||
export type GamePublishRuntimePhase =
|
||||
| 'prepare'
|
||||
| 'upload'
|
||||
| 'verify'
|
||||
| 'submit';
|
||||
|
||||
export type GamePackageUploadProgressEvent = {
|
||||
versionId: string;
|
||||
/** 本次发布尝试的根幂等键;准备阶段还没有版本标识时只有它可用。 */
|
||||
publishId: string;
|
||||
phase: GamePublishRuntimePhase;
|
||||
message: string;
|
||||
receivedBytes: number;
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 发布 UI 的完整步骤条:`build` 由渲染层在导出打包阶段点亮(Rust 不 emit 它),
|
||||
* 其余四步来自 `game-package-upload-progress`。
|
||||
*/
|
||||
export type GamePublishStepId = 'build' | GamePublishRuntimePhase;
|
||||
|
||||
export type GamePublishStep = {
|
||||
id: GamePublishStepId;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const GAME_PUBLISH_STEPS: readonly GamePublishStep[] = [
|
||||
{ id: 'build', label: '构建' },
|
||||
{ id: 'prepare', label: '准备' },
|
||||
{ id: 'upload', label: '上传' },
|
||||
{ id: 'verify', label: '校验' },
|
||||
{ id: 'submit', label: '送审' },
|
||||
];
|
||||
|
||||
const RUNTIME_PHASES: readonly GamePublishRuntimePhase[] = [
|
||||
'prepare',
|
||||
'upload',
|
||||
'verify',
|
||||
'submit',
|
||||
];
|
||||
|
||||
function readText(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
/** 把事件载荷收敛成渲染层认识的形状;不认识的阶段返回 `null` 由调用方忽略。 */
|
||||
export function readGamePackageUploadProgressEvent(
|
||||
payload: unknown,
|
||||
): GamePackageUploadProgressEvent | null {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const phase = readText(record.phase);
|
||||
if (!RUNTIME_PHASES.includes(phase as GamePublishRuntimePhase)) return null;
|
||||
const readBytes = (value: unknown) =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? Math.floor(value)
|
||||
: 0;
|
||||
return {
|
||||
versionId: readText(record.versionId),
|
||||
publishId: readText(record.publishId),
|
||||
phase: phase as GamePublishRuntimePhase,
|
||||
message: readText(record.message),
|
||||
receivedBytes: readBytes(record.receivedBytes),
|
||||
totalBytes: readBytes(record.totalBytes),
|
||||
};
|
||||
}
|
||||
|
||||
/** 上传百分比(0–100,整数);没有可换算的总量(非上传阶段)时返回 `null`。 */
|
||||
export function computeGamePackageUploadPercent(
|
||||
receivedBytes: number,
|
||||
totalBytes: number,
|
||||
): number | null {
|
||||
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null;
|
||||
if (!Number.isFinite(receivedBytes) || receivedBytes <= 0) return 0;
|
||||
const ratio = Math.min(1, receivedBytes / totalBytes);
|
||||
return Math.round(ratio * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅发布进度事件;载荷无效(阶段不认识)的事件被吞掉,不让 UI 退回错误阶段。
|
||||
*
|
||||
* 与其它 Tauri 事件入口一样,这里在真实 WebView 内走自建登记以避开 tauri 2.11 的
|
||||
* 注销竞态;浏览器预览与 jsdom 里沿用注入的事件桥接或返回空操作。
|
||||
*/
|
||||
export function subscribeGamePackageUploadProgress(
|
||||
handler: (event: GamePackageUploadProgressEvent) => void,
|
||||
options: TauriEventSubscribeOptions = {},
|
||||
): Promise<TauriEventUnsubscribe> {
|
||||
return subscribeTauriEvent<unknown>(
|
||||
GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
|
||||
(event) => {
|
||||
const progress = readGamePackageUploadProgressEvent(event.payload);
|
||||
if (progress) handler(progress);
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -3913,6 +3913,14 @@ textarea {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__progress {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgb(104 77 57 / 14%);
|
||||
border-radius: 12px;
|
||||
background: rgb(168 102 61 / 6%);
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__success {
|
||||
margin-top: 18px;
|
||||
padding: 16px;
|
||||
@@ -9894,6 +9902,94 @@ iframe.preview-frame {
|
||||
box-shadow: 0 0 0 3px rgb(199 101 61 / 28%);
|
||||
}
|
||||
|
||||
/* 发布阶段步骤条:构建 → 准备 → 上传 → 校验 → 送审。 */
|
||||
.game-publish-phase {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.game-publish-phase__steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 6px 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.game-publish-phase__step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgb(104 77 57 / 16%);
|
||||
border-radius: 999px;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.game-publish-phase__step.is-done {
|
||||
border-color: rgb(51 125 87 / 26%);
|
||||
color: #337d57;
|
||||
}
|
||||
|
||||
.game-publish-phase__step.is-active {
|
||||
border-color: #a8663d;
|
||||
background: rgb(168 102 61 / 10%);
|
||||
color: #a8663d;
|
||||
}
|
||||
|
||||
.game-publish-phase__marker {
|
||||
display: grid;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: rgb(104 77 57 / 10%);
|
||||
font-size: 10px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.game-publish-phase__step.is-done .game-publish-phase__marker {
|
||||
background: rgb(51 125 87 / 14%);
|
||||
}
|
||||
|
||||
.game-publish-phase__step.is-active .game-publish-phase__marker {
|
||||
background: #a8663d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.game-publish-phase__percent {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.game-publish-phase__bar {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgb(104 77 57 / 12%);
|
||||
}
|
||||
|
||||
.game-publish-phase__bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #c7653d;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.game-publish-phase__message {
|
||||
margin: 0;
|
||||
color: #8f7367;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.game-approval-backdrop {
|
||||
position: fixed;
|
||||
top: var(--window-chrome-height);
|
||||
|
||||
@@ -91,6 +91,31 @@ describe('Rust 发布 facade', () => {
|
||||
expect(calls[0]?.args).not.toHaveProperty('packageBytes');
|
||||
});
|
||||
|
||||
test('未显式给出根幂等键时不生成键,交由原生账本解析', async () => {
|
||||
const { invoke, calls } = installNativeInvoke((command) => {
|
||||
if (command === 'publish_local_project_game') return PUBLISH_RESULT;
|
||||
throw new Error(`未预期的命令:${command}`);
|
||||
});
|
||||
|
||||
await publishLocalProjectGame({
|
||||
invoke,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
// 渲染层不铸键也不落盘:Rust 按「账号 + 本地项目 + 包摘要」的账本解析根幂等键,
|
||||
// 这样响应丢失、重启与分片续传才会落在同一次服务端尝试上。
|
||||
expect(calls[0]?.args).not.toHaveProperty('idempotencyKey');
|
||||
expect(calls[0]?.args).toMatchObject({
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
metadata: { localProjectId: 'local-proj-1' },
|
||||
});
|
||||
});
|
||||
|
||||
test('缺少本地项目标识时在发起 native command 前失败关闭', async () => {
|
||||
const invoke = vi.fn() as unknown as TauriInvoke;
|
||||
await expect(
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* 分片协议(upload-state → chunk → complete)代跑,等同于原生上传器的行为;
|
||||
* 原生实现自身的分片规划、权威偏移续传与错误分类在 Rust 单测里覆盖。
|
||||
*/
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
@@ -298,6 +298,32 @@ async function buildStagedPackage(): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 原生发布尝试账本的替身:与 Rust `game_publish_attempt` 同口径。
|
||||
*
|
||||
* 同一账号 + 同一本地项目 + **同一份发行包摘要**复用同一个根幂等键(响应丢失后重发、
|
||||
* 进程重启、分片续传都落在同一次服务端尝试上);包内容变了或上一次已经收口(送审成功)
|
||||
* 就换新键,下一次发布会新增版本而不是复用旧版本。
|
||||
*/
|
||||
function createPublishAttemptLedger() {
|
||||
const attempts = new Map<string, string>();
|
||||
return {
|
||||
resolve(localProjectId: string, packageSha256: string): string {
|
||||
const key = `${localProjectId}:${packageSha256}`;
|
||||
const existing = attempts.get(key);
|
||||
if (existing) return existing;
|
||||
const rootKey = `agc-publish-live-${randomUUID()}`;
|
||||
attempts.set(key, rootKey);
|
||||
return rootKey;
|
||||
},
|
||||
clear(localProjectId: string): void {
|
||||
for (const key of [...attempts.keys()]) {
|
||||
if (key.startsWith(`${localProjectId}:`)) attempts.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type PackageUploadState = {
|
||||
receivedBytes: number;
|
||||
chunkBytes: number;
|
||||
@@ -461,13 +487,21 @@ liveTest(
|
||||
} as unknown as GameCreationAppManifest;
|
||||
// 记录本次发布实际发送过的分片偏移,用来证明「中断后不重传已收字节」。
|
||||
const sentOffsets: number[] = [];
|
||||
const attempts = createPublishAttemptLedger();
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command !== 'publish_local_project_game') {
|
||||
throw new Error(`未预期的命令:${command}`);
|
||||
}
|
||||
const metadata = args?.metadata as Record<string, unknown>;
|
||||
const rootKey = String(args?.idempotencyKey ?? '');
|
||||
// 渲染层不再铸键;显式传入时覆盖账本,与 Rust 发布命令同口径。
|
||||
const explicitKey = String(args?.idempotencyKey ?? '').trim();
|
||||
const rootKey =
|
||||
explicitKey ||
|
||||
attempts.resolve(
|
||||
String(metadata.localProjectId),
|
||||
staged.packageSha256,
|
||||
);
|
||||
const game = await unwrap<{
|
||||
id: string;
|
||||
publicationRevision?: number;
|
||||
@@ -534,6 +568,8 @@ liveTest(
|
||||
},
|
||||
),
|
||||
);
|
||||
// 送审成功即服务端收口:原生账本同样在这里清除本次尝试。
|
||||
attempts.clear(String(metadata.localProjectId));
|
||||
return {
|
||||
gameId: game.id,
|
||||
versionId: version.versionId,
|
||||
@@ -612,7 +648,8 @@ liveTest(
|
||||
expect(readResult.version.recoveryAction).toBe('wait');
|
||||
expect(readResult.game.id).toBe(first.gameId);
|
||||
|
||||
// 第二次发布不带旧幂等键:同 localProjectId 必须复用同一 gameId 并新增版本。
|
||||
// 第二次发布:上一次已在送审成功时收口,账本清除,因此这里换新键——同
|
||||
// localProjectId 必须复用同一 gameId 并新增版本。
|
||||
const second = await publishLocalProjectGame({
|
||||
invoke,
|
||||
projectPath: '/data/dsk/Genarrative/tmp-agc-live-project',
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 真实的 HTTP 链路由 `gameDistributionPublishLive.test.ts` 覆盖。
|
||||
*/
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -25,6 +26,10 @@ import {
|
||||
readGameCoverGenerationPrice,
|
||||
suggestGameDistributionPublishMetadata,
|
||||
} from '../src/services/gameDistributionPublish';
|
||||
import { GAME_PACKAGE_UPLOAD_PROGRESS_EVENT } from '../src/services/gamePackageUploadProgress';
|
||||
import { createTauriEventFake, type TauriEventFake } from './tauriEventFake';
|
||||
|
||||
let eventFake: TauriEventFake | null = null;
|
||||
|
||||
vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -124,6 +129,8 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
eventFake?.restore();
|
||||
eventFake = null;
|
||||
vi.mocked(generateGameDistributionCover).mockReset();
|
||||
vi.mocked(publishLocalProjectGame).mockReset();
|
||||
vi.mocked(readGameCoverGenerationPrice).mockReset();
|
||||
@@ -311,7 +318,9 @@ describe('GameDistributionPublishPanel', () => {
|
||||
coverAssetId: 'asset_cover',
|
||||
screenshots: ['asset_shot_1'],
|
||||
});
|
||||
expect(String(args?.idempotencyKey)).toMatch(/^agc-publish-/u);
|
||||
// 根幂等键由原生发布链路按「账号 + 本地项目 + 包摘要」的账本解析并持久化,
|
||||
// 渲染层不再自己铸键,因此这里不带 idempotencyKey。
|
||||
expect(args?.idempotencyKey).toBeUndefined();
|
||||
expect(invoke).toBeDefined();
|
||||
|
||||
expect(await screen.findByText('已提交审核')).not.toBeNull();
|
||||
@@ -335,7 +344,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
fireEvent.click(submit);
|
||||
fireEvent.click(submit);
|
||||
|
||||
expect(await screen.findByText('上传中…')).not.toBeNull();
|
||||
expect(await screen.findByText('正在发布…')).not.toBeNull();
|
||||
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolvePublish({
|
||||
@@ -350,6 +359,113 @@ describe('GameDistributionPublishPanel', () => {
|
||||
expect(await screen.findByText('已提交审核')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('发布进行中按原生事件显示阶段与上传百分比', async () => {
|
||||
let resolvePublish: (value: unknown) => void = () => undefined;
|
||||
vi.mocked(publishLocalProjectGame).mockImplementation(() => {
|
||||
const pending = Promise.withResolvers<unknown>();
|
||||
resolvePublish = pending.resolve;
|
||||
return pending.promise as never;
|
||||
});
|
||||
eventFake = createTauriEventFake();
|
||||
eventFake.install();
|
||||
renderPanel();
|
||||
await selectCover();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
await waitFor(() =>
|
||||
expect(eventFake?.pendingRegistrationEvals()).toBeGreaterThan(0),
|
||||
);
|
||||
// 订阅发生在发起 native 命令之前,事件不会丢;这里让注册 eval 落地后才能投递。
|
||||
eventFake.flushRegistrationEvals();
|
||||
|
||||
// 导出打包已经完成,因此构建步骤是「已完成」,当前停在准备步骤。
|
||||
const steps = await screen.findByRole('list', { name: '发布阶段' });
|
||||
expect(within(steps).getByText('构建').closest('li')?.className).toContain(
|
||||
'is-done',
|
||||
);
|
||||
expect(
|
||||
within(steps)
|
||||
.getByText('准备')
|
||||
.closest('li')
|
||||
?.getAttribute('aria-current'),
|
||||
).toBe('step');
|
||||
|
||||
// 上传阶段:百分比只由「已收字节 / 总字节」换算。
|
||||
act(() => {
|
||||
eventFake?.emit(GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, {
|
||||
versionId: 'gamever_1',
|
||||
publishId: 'agc-publish-unit',
|
||||
phase: 'upload',
|
||||
message: '正在上传发行包…',
|
||||
receivedBytes: 5 * 1024 * 1024,
|
||||
totalBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText('正在上传发行包…')).not.toBeNull();
|
||||
expect(
|
||||
(
|
||||
await screen.findByRole('progressbar', { name: '上传进度' })
|
||||
).getAttribute('aria-valuenow'),
|
||||
).toBe('50');
|
||||
|
||||
// 校验与送审阶段没有可换算的总量:进度条隐去,阶段照常前进。
|
||||
act(() => {
|
||||
eventFake?.emit(GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, {
|
||||
versionId: 'gamever_1',
|
||||
publishId: 'agc-publish-unit',
|
||||
phase: 'verify',
|
||||
message: '正在校验发行包…',
|
||||
receivedBytes: 0,
|
||||
totalBytes: 0,
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText('正在校验发行包…')).not.toBeNull();
|
||||
expect(screen.queryByRole('progressbar')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
eventFake?.emit(GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, {
|
||||
versionId: 'gamever_1',
|
||||
publishId: 'agc-publish-unit',
|
||||
phase: 'submit',
|
||||
message: '正在提交审核…',
|
||||
receivedBytes: 0,
|
||||
totalBytes: 0,
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText('正在提交审核…')).not.toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole('list', { name: '发布阶段' }))
|
||||
.getByText('送审')
|
||||
.closest('li')
|
||||
?.getAttribute('aria-current'),
|
||||
).toBe('step');
|
||||
|
||||
// 不认识的阶段不允许把进度打回错误位置,也不显示它的文案。
|
||||
act(() => {
|
||||
eventFake?.emit(GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, {
|
||||
versionId: 'gamever_1',
|
||||
publishId: 'agc-publish-unit',
|
||||
phase: 'unexpected-phase',
|
||||
message: '不该出现的阶段',
|
||||
receivedBytes: 0,
|
||||
totalBytes: 0,
|
||||
});
|
||||
});
|
||||
expect(screen.queryByText('不该出现的阶段')).toBeNull();
|
||||
expect(screen.getByText('正在提交审核…')).not.toBeNull();
|
||||
|
||||
resolvePublish({
|
||||
gameId: 'game_1',
|
||||
versionId: 'gamever_1',
|
||||
versionNumber: 1,
|
||||
status: 'pending_review',
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 3,
|
||||
fileCount: 2,
|
||||
});
|
||||
expect(await screen.findByText('已提交审核')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('服务端失败时保留面板并展示可读错误', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
vi.mocked(publishLocalProjectGame).mockRejectedValue(
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 发布进度事件的前端收敛层:Rust 的 `game-package-upload-progress` 载荷在这里被
|
||||
* 规范化(未知阶段丢弃)、换算百分比,并通过统一的事件订阅入口投递给 UI。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
computeGamePackageUploadPercent,
|
||||
GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
|
||||
readGamePackageUploadProgressEvent,
|
||||
subscribeGamePackageUploadProgress,
|
||||
} from '../src/services/gamePackageUploadProgress';
|
||||
import { createTauriEventFake, type TauriEventFake } from './tauriEventFake';
|
||||
|
||||
let fake: TauriEventFake | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
fake = createTauriEventFake();
|
||||
fake.install();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fake?.restore();
|
||||
fake = null;
|
||||
});
|
||||
|
||||
describe('发布进度事件收敛', () => {
|
||||
it('规范化原生载荷:阶段、文案与字节计数都对上', () => {
|
||||
expect(
|
||||
readGamePackageUploadProgressEvent({
|
||||
versionId: ' gamever_1 ',
|
||||
publishId: ' agc-publish-1 ',
|
||||
phase: 'upload',
|
||||
message: ' 正在上传发行包… ',
|
||||
receivedBytes: 5_242_880.9,
|
||||
totalBytes: 10_000_000,
|
||||
}),
|
||||
).toEqual({
|
||||
versionId: 'gamever_1',
|
||||
publishId: 'agc-publish-1',
|
||||
phase: 'upload',
|
||||
message: '正在上传发行包…',
|
||||
receivedBytes: 5_242_880,
|
||||
totalBytes: 10_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('不认识的阶段与非法载荷一律丢弃,不让 UI 退回错误阶段', () => {
|
||||
expect(readGamePackageUploadProgressEvent(null)).toBeNull();
|
||||
expect(readGamePackageUploadProgressEvent('upload')).toBeNull();
|
||||
expect(
|
||||
readGamePackageUploadProgressEvent({
|
||||
phase: 'staging',
|
||||
receivedBytes: 1,
|
||||
totalBytes: 2,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
readGamePackageUploadProgressEvent({
|
||||
phase: 'verify',
|
||||
receivedBytes: -3,
|
||||
totalBytes: Number.NaN,
|
||||
}),
|
||||
).toEqual({
|
||||
versionId: '',
|
||||
publishId: '',
|
||||
phase: 'verify',
|
||||
message: '',
|
||||
receivedBytes: 0,
|
||||
totalBytes: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('只有拿到总字节才换算百分比,其余阶段返回 null', () => {
|
||||
expect(computeGamePackageUploadPercent(0, 0)).toBeNull();
|
||||
expect(computeGamePackageUploadPercent(5, 0)).toBeNull();
|
||||
expect(computeGamePackageUploadPercent(0, 10)).toBe(0);
|
||||
expect(computeGamePackageUploadPercent(5, 10)).toBe(50);
|
||||
expect(computeGamePackageUploadPercent(1, 3)).toBe(33);
|
||||
expect(computeGamePackageUploadPercent(12, 10)).toBe(100);
|
||||
});
|
||||
|
||||
it('订阅只投递规范化后的事件,注销后不再投递', async () => {
|
||||
const handler = vi.fn();
|
||||
const unsubscribe = await subscribeGamePackageUploadProgress(handler);
|
||||
fake?.flushRegistrationEvals();
|
||||
|
||||
fake?.emit(GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, {
|
||||
versionId: 'gamever_1',
|
||||
publishId: 'agc-publish-1',
|
||||
phase: 'upload',
|
||||
message: '正在上传发行包…',
|
||||
receivedBytes: 10,
|
||||
totalBytes: 40,
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
versionId: 'gamever_1',
|
||||
publishId: 'agc-publish-1',
|
||||
phase: 'upload',
|
||||
message: '正在上传发行包…',
|
||||
receivedBytes: 10,
|
||||
totalBytes: 40,
|
||||
});
|
||||
|
||||
// 未知阶段不投递:UI 不会因为一条不认识的事件退回错误步骤。
|
||||
fake?.emit(GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, {
|
||||
phase: 'unknown',
|
||||
message: '不该出现',
|
||||
receivedBytes: 0,
|
||||
totalBytes: 0,
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsubscribe();
|
||||
fake?.emit(GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, {
|
||||
phase: 'submit',
|
||||
message: '正在提交审核…',
|
||||
receivedBytes: 0,
|
||||
totalBytes: 0,
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(fake?.jsCallbackCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('没有原生宿主时订阅是空操作而不是报错', async () => {
|
||||
fake?.restore();
|
||||
fake = null;
|
||||
const handler = vi.fn();
|
||||
const unsubscribe = await subscribeGamePackageUploadProgress(handler);
|
||||
expect(typeof unsubscribe).toBe('function');
|
||||
unsubscribe();
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,14 +7,9 @@
|
||||
|
||||
在 10.7「陶泥儿游戏平台」上线前,把「创作者作品管理(用户侧 / 后台)+ 客户端打包上传(只保证 Phaser 4)」这条链路补齐到可用:作品能进来、能看得见状态、能管住。
|
||||
|
||||
## 当前事实
|
||||
|
||||
- 发布管道(AGC 构建打包 → 8 MiB 分片上传 → 送审 → 后台审核 → 公开可玩 → 发行网关)已实现,并有真实栈证据(见[实施计划【游戏分发阶段A领域合同】](【实施计划】游戏分发阶段A领域合同-2026-09-19.md))。
|
||||
- 未通电:`game-distribution:publish` 灰度默认关闭;生产 0 个作品;dev 仅 4 条记录。
|
||||
- 创作者侧缺作品管理:AGC 内没有作品列表 / 详情;`/games/mine` 不能删除作品、不能改展示资料、看不到版本历史。
|
||||
- 作者点自己的「审核中 / 被驳回 / 已下架」作品只有 404:公开详情只服务已公开投影。
|
||||
- 后台作品管理前端固定 50 条、无搜索无分页。
|
||||
- 客户端发布没有真实进度(分片进度事件 Rust 已 emit、前端零监听);幂等键只存在于面板内存,关面板即换 key。
|
||||
- 本轮已补齐:客户端 Phaser 4 + Vite 工程栈门禁、发布阶段/上传百分比、发布根幂等账本;Web 作者私有详情、状态/版本筛选、版本历史、资料编辑、软删除和作者操作;后台作品搜索、作者/状态筛选、游标分页与删除状态展示。
|
||||
- 生产发布开关仍默认关闭;真实客户端 GUI 发布、生产首个作品和发布包清理策略仍需运行时取证。
|
||||
|
||||
## 已落地(本分支)
|
||||
|
||||
@@ -26,23 +21,21 @@
|
||||
|
||||
设计要点:不新增 SpacetimeDB 表与 procedure。详情路由的 game 走 `get_game_distribution_game`(owner 作用域,精确命中),版本沿用作者自有列表 procedure 的聚合结果。
|
||||
|
||||
## 计划
|
||||
## 计划与落地状态
|
||||
|
||||
### 波 1:闭环可用
|
||||
|
||||
1. AGC 真机把「构建 → 打包 → 分片上传 → 送审」跑通一次(唯一还没在客户端 GUI 取证的环节)。
|
||||
2. 发布进度接线:前端监听既有 `game-package-upload-progress`,渲染真实阶段与百分比。
|
||||
3. 幂等键落盘:按 `gameId` + 本地项目持久化,重试复用同一 key。
|
||||
4. AGC「我的作品」页:状态筛选、内联驳回理由、行内操作。
|
||||
5. AGC 作品详情抽屉:展示资料、当前公开版本、版本历史、公开链接。
|
||||
6. 运营开闸 + 生产发布第一个作品,走完审核 → 公开 → 游客可玩。
|
||||
- [ ] AGC 真机把「构建 → 打包 → 分片上传 → 送审」跑通一次。
|
||||
- [x] 发布进度接线:前端监听既有 `game-package-upload-progress`,渲染真实阶段与百分比。
|
||||
- [x] 幂等键落盘:按账号、origin、本地项目和包摘要持久化根键,重试复用同一 key。
|
||||
- [ ] 运营开闸 + 生产发布第一个作品,走完审核 → 公开 → 游客可玩。
|
||||
|
||||
### 波 2:管理完整
|
||||
|
||||
7. 作品资料编辑:展示资料在 `game` 级可编辑、立即生效并留审计;包仍随版本冻结。
|
||||
8. 作品软删除:`game_distribution_game` 末尾追加 `deleted_at`,公开投影与列表过滤软删行,OSS 对象进入回收窗口。
|
||||
9. 网页侧对齐:`/games/mine` 补删除、资料编辑、版本历史、作者看自己未公开作品。
|
||||
10. 后台作品列表:分页 + 搜索 + 按作者筛选(当前前端固定 50 条)。
|
||||
- [x] 作品资料编辑:展示资料在 `game` 级可编辑、立即生效并留审计;包仍随版本冻结。
|
||||
- [x] 作品软删除:`game_distribution_game` 末尾追加 `deleted_at`,公开投影与列表过滤软删行。
|
||||
- [x] 网页侧对齐:`/games/mine` 补删除、资料编辑、版本历史、作者查看未公开作品。
|
||||
- [x] 后台作品列表:游标分页 + 搜索 + 按作者/状态筛选。
|
||||
|
||||
### 波 3:运营治理
|
||||
|
||||
@@ -51,20 +44,38 @@
|
||||
13. 精选 / 推荐位(与游玩线的推荐排序定边界)。
|
||||
14. 举报处理(先确认能否复用现役反馈管道)。
|
||||
|
||||
## 尚未完成
|
||||
|
||||
- AGC 真机 GUI 端到端发布取证。
|
||||
- 生产环境发布第一个作品并开闸。
|
||||
- 非 Phaser(Godot / Cocos / Unity)发布的主动拦截:这些工程没有 npm 构建工作区,仍只给泛化错误文案(单 HTML 项目同理)。
|
||||
- 发布包清理策略的上线验收。
|
||||
|
||||
## 不变式
|
||||
|
||||
- 新增字段一律追加到 Rust 表结构体末尾并带明确默认值;不改名、不重排、不改类型。改 schema 后同步 `migration.rs`、表目录与生成绑定,并跑 `npm run check:spacetime-schema`。
|
||||
- 公开投影不得携带作者私有字段(包摘要、驳回理由、文档本地路径)。作者视角走 owner 作用域路由。
|
||||
- 复用现役管道与组件,不新造上传、审核、发行通道。
|
||||
|
||||
## 波 1 · AGC 客户端发布切片(2026-10-01 落地;自动门禁已通过,真实 GUI/生产待验收)
|
||||
|
||||
| 项 | 落点 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| 发布进度接线 | `src-tauri/src/game_package_upload.rs`、`game_distribution_publish.rs`、`commands.rs`、`src/services/gamePackageUploadProgress.ts`、`src/components/game-distribution/GamePublishProgressDialog.tsx`、`GamePublishPhaseSteps.tsx`、`GameDistributionPublishPanel.tsx`、`App.tsx` | 既有 `game-package-upload-progress` 事件扩展出 `phase`(`prepare` / `upload` / `verify` / `submit`)与 `message`,上传阶段仍带 `receivedBytes` / `totalBytes`;`/complete`(重算摘要 + 展开清单)之前由上传器回调切到「校验」。渲染层在全屏进度弹窗显示「构建」步骤,在发布面板显示「构建 → 准备 → 上传 → 校验 → 送审」步骤条与上传百分比。 |
|
||||
| 根幂等键落盘 | 新增 `src-tauri/src/game_publish_attempt.rs`(应用数据目录 `game-publish-attempts.json`,原子写 + 进程内锁) | 键按「账号 + origin + 本地项目 + 归一化包摘要」解析:同一份包重发、响应丢失、进程重启与分片续传复用同一根键(服务端因此只认一次尝试);送审成功后清除记录,下一次发布会新增版本。渲染层不再铸键,`publishLocalProjectGame` 只在显式传入时才带 `idempotencyKey`。账本只存账号标识、origin、本地项目标识、包摘要与根键,不含 token 与本地绝对路径。 |
|
||||
| 发布前工程栈校验 | `src-tauri/src/project/export.rs`(`ensure_publish_project_stack`,导出与发布两条入口都调用) | 存在 npm 构建工作区时要求 Phaser 4 + Vite:缺 `phaser` 依赖、声明的主版本不是 4(含 `package-lock.json` 回退判定)、以及既没有 `vite` 依赖也没有 `vite.config.*` 都给出可操作中文错误(指明缺什么、当前声明、怎么修)。没有 npm 工作区的老单文件项目保持原有发布能力。 |
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 作者自有游戏列表与新增的作者详情共享同一个单次条数上限(48):单作者作品数超过上限时,详情页拿不到版本记录。放量前需要补一条按 `gameId` 精确列版本的 procedure。
|
||||
- 客户端不暴露 `localProjectId`:本地项目与线上作品的对应关系由客户端本地账本维护,服务端只在发布时用它复用 `gameId`。
|
||||
- 发布尝试账本按「本地项目 + 包摘要」判同一尝试:改了工程或重新导出了不同内容的包就是新尝试(新建版本),旧版本的半成品包仍需走网页作者侧的恢复/重置入口。
|
||||
- 发布阶段条按「本次订阅窗口」收敛(事件的 `versionId` 在命令返回前对渲染层不可知);同一窗口同一时刻只有一次发布在跑。
|
||||
- 工程栈校验按首版「只支持 Phaser 4」执行:npm 工程(含 Three.js 等三维工程)未声明 `phaser` 会被拦下并提示安装 Phaser 4。若产品决定放行其它 npm 技术栈,需要在这里改成「phaser 声明存在时必须是 4,其余技术栈按白名单放行」。
|
||||
|
||||
## 尚未完成
|
||||
|
||||
- AGC 真机 GUI 端到端发布取证。
|
||||
- 生产环境发布第一个作品并开闸。
|
||||
- 非 Phaser(Godot / Cocos / Unity / 单 HTML)发布的主动拦截:当前只给泛化错误文案。
|
||||
- 非 Phaser(Godot / Cocos / Unity)发布的主动拦截:这些工程没有 npm 构建工作区,仍只给泛化错误文案(单 HTML 项目同理)。
|
||||
- 发布包清理策略的上线验收。
|
||||
|
||||
@@ -458,8 +458,10 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- 源码:`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}` 数组);创建游戏时 `api-server` 就复核封面/截图素材存在且属于当前作者(不存在 400、他人素材 403),创建版本时按同一口径再次复核并派生对象键。 发布写入受灰度配置键 `game-distribution:publish` 约束:**灰度默认关闭**,未配置或 `enabled=false` 时写入口(创建游戏/版本、确认包、送审、审核通过激活)返回 503 `GAME_DISTRIBUTION_PUBLISH_DISABLED`,`enabled=true` 且白名单/比例/标签命中才放行,读取与安全下架保持可用;同一判据在 `GET /api/runtime/frontend-config` 以 `gameDistributionPublishEnabled` 下发给前端入口,匿名恒为 `false`。只有可见性为 `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` 的投影。
|
||||
- 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。已软删除的游戏不参与复用:删除后重新发布同一本地项目应得到新的游戏身份。
|
||||
- 软删除:游戏行末尾追加可空 `deleted_at`(2026-10-01)。非空表示作者已删除该作品:`delete_game_distribution_game_and_return` 只写该时间戳并把公开投影下线(可见性回到 `unpublished`、撤销当前公开版本、递增 `publication_revision`),版本行、发行包与其冻结资料一律不改写。软删行不进入作者列表(`list_owner_game_distribution_games_and_return`)、公开目录(`list_public_game_distribution_games_and_return`)、公开详情(`get_public_game_distribution_game_and_return`)、发行网关素材授权(`game_distribution_asset_has_public_read_grant`)与审核队列;后台默认视图同样排除,只有显式 `status=deleted` 才会读到。作者侧版本回读对软删作品返回空(404),因此上传、确认与送审入口一并关闭。
|
||||
- 资料编辑:`update_game_distribution_game_metadata_and_return` 覆盖游戏行上的展示字段(标题/简介/详介/分类/标签/封面/截图/设备/输入模式/方向)并立即生效,要求 `expected_publication_revision` CAS;版本行与冻结资料不变,下一次审核通过仍会用新版本的冻结资料覆盖游戏行。
|
||||
- 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published`、`deleted_at` 为空且存在有效 `active_version_id` 的投影。
|
||||
|
||||
### `game_distribution_version`
|
||||
|
||||
@@ -473,11 +475,12 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
|
||||
### 后台游戏管理读模型与恢复动作(2026-09-23)
|
||||
|
||||
- 页面目标:后台新增「游戏管理」页,展示全量游戏(标题 / 作者名 + 头像 / gameId / 状态 / 版本数 / 游玩数),行内提供安全下架、恢复与版本历史;它是运营面的全量视图,不替代 `#game-distribution` 待审队列。
|
||||
- 数据来源:新增只读 procedure `list_admin_game_distribution_games_and_return`(输入 `GameDistributionAdminGameListInput { limit }`)。它在同一事务里读 `game_distribution_game`,按 `by_game_distribution_version_game_id` 统计每个游戏的版本数并取最近 20 个版本;作者名与头像按 `user_account.user_id` 读时联 `display_name` / `avatar_url`(行内快照为空时以联表结果为准)。不新增表、不改 schema、不改公开投影。
|
||||
- 恢复动作:新增 procedure `restore_game_distribution_game_and_return`(输入 `GameDistributionRestoreInput`)。它只允许管理员解除 `suspended`:重新激活该游戏最近一个由管理员暂停撤回(`status = revoked`、`published_at` 非空且 `reviewed_by_user_id` 非空)的版本,恢复 `visibility = published` 并递增 `publication_revision`;作者自行下架的版本不写审核者,因此不会被恢复动作重新公开。没有可恢复版本、`expected_publication_revision` CAS 不符或游戏不在暂停态时失败关闭。幂等收据复用 `game_distribution_idempotency_receipt`(action = `restore`),恢复动作不受发布灰度开关限制,与安全下架同口径。
|
||||
- 后台 HTTP:`GET /admin/api/game-distribution/games?limit=` 返回 `{ games: [{ gameId, title, author{ id, name, avatarUrl }, status, versionCount, playCount, activeVersionId, publicationRevision, createdAt, updatedAt, versions: [...] }] }`;`POST /admin/api/game-distribution/games/{gameId}/restore` 要求 `Idempotency-Key` 与 `expectedPublicationRevision`,返回 `{ game, replayed }`。两者都走 `require_admin_auth`,Tab 权限为 `game-management` 或 `editor-showcase`,不新增公开契约。
|
||||
- 前端:`apps/admin-web` 新增 `#game-management` 路由与 `AdminGameManagementPage`,复用现有 `admin-table` 表格与 `useAdminWriteConfirm` 二次确认;版本历史在弹层内展示,长列表保持横向滚动。
|
||||
- 页面目标:后台新增「游戏管理」页,展示游戏(标题 / 作者名 + 头像 / gameId / 状态 / 版本数 / 游玩数),支持关键词、作者与状态筛选与游标分页,行内提供安全下架、恢复与版本历史;它是运营面的全量视图,不替代 `#game-distribution` 待审队列。
|
||||
- 数据来源:只读 procedure `list_admin_game_distribution_games_and_return`(输入 `GameDistributionAdminGameListInput { limit, keyword, ownerUserId, status, cursor }`)。它在同一事务里读 `game_distribution_game`,按 `by_game_distribution_version_game_id` 统计每个游戏的版本数并取最近 20 个版本;作者名与头像按 `user_account.user_id` 读时联 `display_name` / `avatar_url`(行内快照为空时以联表结果为准)。不新增表、不改公开投影。
|
||||
- 筛选与分页(2026-10-01):`keyword` 大小写不敏感匹配标题、gameId 或作者 user ID;`ownerUserId` 精确匹配作者;`status` 取 `published` / `unpublished` / `suspended` / `deleted`,为空时排除已软删除作品(`deleted` 只返回软删行)。排序仍是创建时间倒序、gameId 升序,游标是 `<created_at_micros>:<gameId>`,结果类型新追加 `next_cursor`;多取一条判断是否还有下一页,因此**不会**像旧版那样静默截断到 200 条。
|
||||
- 恢复动作:procedure `restore_game_distribution_game_and_return`(输入 `GameDistributionRestoreInput`)只允许管理员解除 `suspended`:重新激活该游戏最近一个由管理员暂停撤回(`status = revoked`、`published_at` 非空且 `reviewed_by_user_id` 非空)的版本,恢复 `visibility = published` 并递增 `publication_revision`;作者自行下架的版本不写审核者,因此不会被恢复动作重新公开。没有可恢复版本、`expected_publication_revision` CAS 不符、游戏不在暂停态或已软删除时失败关闭。幂等收据复用 `game_distribution_idempotency_receipt`(action = `restore`),恢复动作不受发布灰度开关限制,与安全下架同口径。
|
||||
- 后台 HTTP:`GET /admin/api/game-distribution/games?limit=&keyword=&owner=&status=&cursor=` 返回 `{ games: [{ gameId, title, author{ id, name, avatarUrl }, status, versionCount, playCount, activeVersionId, publicationRevision, createdAt, updatedAt, deletedAt, versions: [...] }], nextCursor }`(旧字段保持原样,`deletedAt` 与 `nextCursor` 是新增项);`status` 不在白名单时返回 400。`POST /admin/api/game-distribution/games/{gameId}/restore` 要求 `Idempotency-Key` 与 `expectedPublicationRevision`,返回 `{ game, replayed }`。两者都走 `require_admin_auth`,Tab 权限为 `game-management` 或 `editor-showcase`,不新增公开契约。
|
||||
- 前端:`apps/admin-web` 的 `#game-management` 路由与 `AdminGameManagementPage` 提供关键词/作者/状态筛选、`AdminPagination` 游标翻页与版本历史弹层;`status=deleted` 行展示删除时间,下架/恢复沿用 `useAdminWriteConfirm` 二次确认。后台 fake API(`scripts/admin-web-fake-api.mjs`)按同一查询参数过滤并返回 `nextCursor`。
|
||||
- 验收:`cargo test -p api-server game_distribution`、`cargo test -p spacetime-module game_distribution`、`npm run spacetime:generate` 后 `npm run check:spacetime-schema`、admin-web 定向 Vitest + typecheck、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
### `game_distribution_idempotency_receipt`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user