作品管理与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

补齐 Phaser4 客户端发布前校验、阶段进度与原生幂等账本

增加 Web 作者私有作品管理、资料编辑、版本历史与软删除

增加后台作品筛选、游标分页与删除治理

同步 SpacetimeDB schema、bindings、DTO、文档与定向测试
This commit is contained in:
2026-10-01 16:30:40 +08:00
parent 1f055e9902
commit a2b70d92b8
80 changed files with 5836 additions and 676 deletions
+27 -1
View File
@@ -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(
+23 -3
View File
@@ -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 },
);
}
+12 -1
View File
@@ -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();
+1
View File
@@ -936,6 +936,7 @@ export function App({
setPublishProgress({
phase: 'running',
message: '正在构建并打包试玩包,请稍候…',
step: 'build',
});
try {
const result = await invoke<LocalProjectExportPackageResult>(
@@ -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>
);
}
@@ -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,
);
}
+96
View File
@@ -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