Merge remote-tracking branch 'origin/master' into refactor/extract-dep-from-ref-inputer
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 21s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 20s
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-09-22 17:41:24 +08:00
216 changed files with 29618 additions and 281 deletions
@@ -7,9 +7,12 @@ import {
getAdminFeatureGateConfig,
getAdminUserDetail,
importAdminAgcTemplates,
listAdminGameDistributionReviews,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
updateAdminAccount,
updateAdminAgcTemplate,
uploadAdminEditorShowcaseCampaignImage,
@@ -454,3 +457,134 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => {
}),
);
});
test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ entries: [], nextCursor: null }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await listAdminGameDistributionReviews('admin-token');
await reviewAdminGameDistributionVersion(
'admin-token',
'gamever/1',
'game-review-key-1',
{
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
},
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/reviews?limit=48',
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'/admin/api/game-distribution/versions/gamever%2F1/review',
);
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-review-key-1',
}),
body: JSON.stringify({
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
}),
}),
);
});
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ game: {}, replayed: false }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await suspendAdminGameDistributionGame(
'admin-token',
'game/1',
'game-suspend-key-1',
{ expectedPublicationRevision: 7, reason: '版权投诉' },
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/games/game%2F1/suspend',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-suspend-key-1',
}),
body: JSON.stringify({
expectedPublicationRevision: 7,
reason: '版权投诉',
}),
}),
);
expect(() =>
suspendAdminGameDistributionGame('admin-token', ' ', 'key', {
expectedPublicationRevision: 1,
}),
).toThrow('缺少游戏 ID');
expect(() =>
suspendAdminGameDistributionGame('admin-token', 'game-1', ' ', {
expectedPublicationRevision: 1,
}),
).toThrow('下架幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test('游戏审核拒绝请求携带理由,空幂等键在本地失败关闭', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ version: {}, replayed: false }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await reviewAdminGameDistributionVersion(
'admin-token',
'version-1',
'game-review-key-2',
{
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: '运行时报错',
},
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
body: JSON.stringify({
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: '运行时报错',
}),
}),
);
expect(() =>
reviewAdminGameDistributionVersion('admin-token', 'version-1', ' ', {
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: 'x',
}),
).toThrow('审核幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
+72
View File
@@ -30,6 +30,9 @@ import type {
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
AdminImportAgcTemplatesResponse,
AdminLoginResponse,
AdminMeResponse,
@@ -1200,6 +1203,75 @@ export function saveAgcModelCatalog(
);
}
export function listAdminGameDistributionReviews(token: string, limit = 48) {
const normalizedLimit = Number.isFinite(limit)
? Math.min(Math.max(Math.trunc(limit), 1), 48)
: 48;
return request<AdminGameDistributionReviewListResponse>(
`/admin/api/game-distribution/reviews?limit=${normalizedLimit}`,
{ token },
);
}
/**
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
* 两条审核结论。
*/
/**
* 安全下架整个游戏。管理员下架同样要求 CAS 修订号与幂等键,避免并发审核互相覆盖。
*/
export function suspendAdminGameDistributionGame(
token: string,
gameId: string,
idempotencyKey: string,
payload: import('./adminApiTypes').AdminGameDistributionSuspendRequest,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) {
throw new Error('缺少游戏 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('下架幂等键必须是 1 到 128 个字符');
}
return request<
import('./adminApiTypes').AdminGameDistributionSuspendResponse
>(
`/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/suspend`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
export function reviewAdminGameDistributionVersion(
token: string,
versionId: string,
idempotencyKey: string,
payload: AdminGameDistributionReviewRequest,
) {
const normalizedVersionId = versionId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedVersionId) {
throw new Error('缺少发行版本 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('审核幂等键必须是 1 到 128 个字符');
}
return request<AdminGameDistributionReviewResponse>(
`/admin/api/game-distribution/versions/${encodeURIComponent(normalizedVersionId)}/review`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
token,
+45
View File
@@ -1043,6 +1043,51 @@ export interface AdminAgcModelCatalog {
models: AdminAgcModel[];
}
export interface AdminGameDistributionReviewEntry {
versionId: string;
gameId: string;
versionNumber: number;
packageSha256: string;
packageBytes: number;
status: string;
publicationRevision: number;
reviewReason: string | null;
createdAt: string;
updatedAt: string;
}
export interface AdminGameDistributionReviewListResponse {
entries: AdminGameDistributionReviewEntry[];
nextCursor: string | null;
}
export interface AdminGameDistributionReviewRequest {
decision: 'approve' | 'reject';
expectedPublicationRevision: number;
reviewReason?: string;
entryUrl?: string;
}
export interface AdminGameDistributionReviewResponse {
version: AdminGameDistributionReviewEntry;
replayed: boolean;
}
export interface AdminGameDistributionSuspendRequest {
expectedPublicationRevision: number;
reason?: string;
}
export interface AdminGameDistributionSuspendResponse {
game: {
id: string;
title: string;
status: string;
publicationRevision: number;
};
replayed: boolean;
}
export interface AdminAgcTemplatePayload {
id: string;
title: string;
+7
View File
@@ -27,6 +27,7 @@ import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
@@ -307,6 +308,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'game-distribution' ? (
<AdminGameDistributionReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
+2
View File
@@ -5,6 +5,7 @@ import {
Coins,
Database,
FolderArchive,
Gamepad2,
GitBranch,
Images,
LayoutDashboard,
@@ -49,6 +50,7 @@ const routeIcons = {
'recharge-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'game-distribution': Gamepad2,
'editor-assets': Images,
'project-snapshots': FolderArchive,
accounts: Users,
@@ -148,6 +148,27 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
).not.toContainEqual(route);
});
test('后台游戏审核路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'game-distribution',
label: '游戏审核',
hash: '#game-distribution',
});
expect(resolveAdminRoute('#game-distribution')).toBe('game-distribution');
expect(routeHash('game-distribution')).toBe('#game-distribution');
});
test('member 可单独获得游戏审核 Tab 权限', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['game-distribution'],
});
expect(routes.map((route) => route.id)).toEqual(['game-distribution']);
expect(resolveAccessibleAdminRoute('#game-distribution', routes)).toBe(
'game-distribution',
);
});
test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => {
expect(adminRoutes).toContainEqual({
id: 'agc-templates',
+2
View File
@@ -15,6 +15,7 @@ export type AdminRouteId =
| 'recharge-orders'
| 'editor-generation-pricing'
| 'editor-showcase'
| 'game-distribution'
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
@@ -56,6 +57,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
@@ -0,0 +1,186 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import {
listAdminGameDistributionReviews,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import {
AdminGameDistributionReviewPage,
resolveGameReleaseEntryUrlError,
} from './AdminGameDistributionReviewPage';
vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(
(error: unknown) =>
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof error.status === 'number',
),
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
listAdminGameDistributionReviews: vi.fn(),
reviewAdminGameDistributionVersion: vi.fn(),
suspendAdminGameDistributionGame: vi.fn(),
}));
const entry: AdminGameDistributionReviewEntry = {
versionId: 'version-1',
gameId: 'game_1',
versionNumber: 2,
packageSha256: 'a'.repeat(64),
packageBytes: 2048,
status: 'pending_review',
publicationRevision: 4,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T08:00:00Z',
};
beforeEach(() => {
vi.mocked(listAdminGameDistributionReviews).mockReset();
vi.mocked(reviewAdminGameDistributionVersion).mockReset();
vi.mocked(suspendAdminGameDistributionGame).mockReset();
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
entries: [entry],
nextCursor: null,
});
});
test('发行入口必须是带完整来源的 HTTPS 地址', () => {
expect(resolveGameReleaseEntryUrlError('')).toBe('请填写发行入口');
expect(
resolveGameReleaseEntryUrlError('http://games.test/a/index.html'),
).toBe('发行入口必须以 https:// 开头');
expect(
resolveGameReleaseEntryUrlError('https://games.test/a/index.html?token=1'),
).toBe('发行入口不能包含 query 或 fragment');
expect(
resolveGameReleaseEntryUrlError('https://u:p@games.test/a/index.html'),
).toBe('发行入口不能包含凭据');
expect(
resolveGameReleaseEntryUrlError('https://games.test/a/index.html'),
).toBe('');
});
test('通过审核时提交当前 publicationRevision 与发行入口并刷新列表', async () => {
vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({
version: { ...entry, status: 'published' },
replayed: false,
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('发行入口'), {
target: { value: 'https://games.test/releases/game_1/index.html' },
});
fireEvent.click(screen.getByRole('button', { name: '通过' }));
await waitFor(() =>
expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1),
);
const [token, versionId, idempotencyKey, payload] =
vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(versionId).toBe('version-1');
expect(String(idempotencyKey)).toContain('version-1');
expect(payload).toEqual({
decision: 'approve',
expectedPublicationRevision: 4,
entryUrl: 'https://games.test/releases/game_1/index.html',
});
await waitFor(() =>
expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes(
2,
),
);
});
test('缺少拒绝理由时不调用审核接口', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy();
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
});
test('安全下架需要二次确认,并携带公开修订号与原因', async () => {
vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({
game: {
id: 'game_1',
title: '测试游戏',
status: 'suspended',
publicationRevision: 5,
},
replayed: false,
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('下架原因'), {
target: { value: '盗用素材' },
});
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
// 第一次点击只弹出确认面板,不直接调用后端。
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
expect(await screen.findByRole('dialog')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
);
const [token, gameId, idempotencyKey, payload] =
vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(gameId).toBe('game_1');
expect(String(idempotencyKey)).toContain('game_1');
expect(payload).toEqual({
expectedPublicationRevision: 4,
reason: '盗用素材',
});
expect(await screen.findByText(//u)).toBeTruthy();
});
test('取消确认时不下架', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '取消' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
});
@@ -0,0 +1,365 @@
import { RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {
listAdminGameDistributionReviews,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminGameDistributionReviewPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
function formatBytes(value: number) {
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KiB`;
}
return `${value} B`;
}
function formatTime(value: string) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString('zh-CN', { hour12: false });
}
function createSuspendIdempotencyKey(gameId: string) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `game-suspend-${gameId}-${random}`.slice(0, 128);
}
function createReviewIdempotencyKey(versionId: string) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `game-review-${versionId}-${random}`.slice(0, 128);
}
export function resolveGameReleaseEntryUrlError(value: string) {
const normalized = value.trim();
if (!normalized) return '请填写发行入口';
if (!normalized.startsWith('https://')) {
return '发行入口必须以 https:// 开头';
}
if (normalized.includes('?') || normalized.includes('#')) {
return '发行入口不能包含 query 或 fragment';
}
try {
const parsed = new URL(normalized);
if (parsed.username || parsed.password) {
return '发行入口不能包含凭据';
}
} catch {
return '发行入口不是合法 URL';
}
return '';
}
export function AdminGameDistributionReviewPage({
token,
onUnauthorized,
}: AdminGameDistributionReviewPageProps) {
const [entries, setEntries] = useState<AdminGameDistributionReviewEntry[]>(
[],
);
const [isLoading, setIsLoading] = useState(false);
const [busyVersionId, setBusyVersionId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [entryUrlByVersion, setEntryUrlByVersion] = useState<
Record<string, string>
>({});
const [reasonByVersion, setReasonByVersion] = useState<
Record<string, string>
>({});
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
Record<string, string>
>({});
const [busyGameId, setBusyGameId] = useState('');
const writeConfirm = useAdminWriteConfirm();
const loadReviews = useCallback(async () => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminGameDistributionReviews(token);
setEntries(response.entries);
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [token, onUnauthorized]);
useEffect(() => {
void loadReviews();
}, [loadReviews]);
async function submitReview(
entry: AdminGameDistributionReviewEntry,
decision: 'approve' | 'reject',
) {
const entryUrl = (entryUrlByVersion[entry.versionId] ?? '').trim();
const reason = (reasonByVersion[entry.versionId] ?? '').trim();
if (decision === 'approve') {
const invalid = resolveGameReleaseEntryUrlError(entryUrl);
if (invalid) {
setErrorMessage(invalid);
return;
}
} else if (!reason) {
setErrorMessage('拒绝审核必须填写理由');
return;
}
setBusyVersionId(entry.versionId);
setErrorMessage('');
setStatusMessage('');
try {
await reviewAdminGameDistributionVersion(
token,
entry.versionId,
createReviewIdempotencyKey(entry.versionId),
decision === 'approve'
? {
decision,
expectedPublicationRevision: entry.publicationRevision,
entryUrl,
}
: {
decision,
expectedPublicationRevision: entry.publicationRevision,
reviewReason: reason,
},
);
setStatusMessage(
decision === 'approve'
? `版本 v${entry.versionNumber} 已通过审核`
: `版本 v${entry.versionNumber} 已拒绝`,
);
await loadReviews();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyVersionId('');
}
}
/**
* 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时
* 由服务端返回冲突,前端只提示刷新,不静默重试。
*/
async function suspendGame(entry: AdminGameDistributionReviewEntry) {
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
const confirmed = await writeConfirm.confirmWrite({
action: '安全下架游戏',
target: `${entry.gameId}(版本 v${entry.versionNumber}`,
});
if (!confirmed) return;
setBusyGameId(entry.gameId);
setErrorMessage('');
setStatusMessage('');
try {
await suspendAdminGameDistributionGame(
token,
entry.gameId,
createSuspendIdempotencyKey(entry.gameId),
{
expectedPublicationRevision: entry.publicationRevision,
...(reason ? { reason } : {}),
},
);
setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`);
setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' }));
await loadReviews();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyGameId('');
}
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<h1></h1>
<button
type="button"
className="admin-secondary-button"
onClick={() => void loadReviews()}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" />
</button>
</div>
{errorMessage ? (
<div className="admin-alert admin-alert-warning" role="alert">
{errorMessage}
</div>
) : null}
{statusMessage ? (
<div className="admin-alert admin-alert-success" role="status">
{statusMessage}
</div>
) : null}
<div className="admin-panel">
<div className="admin-panel-heading">
<h2></h2>
<span className="admin-muted-text"> {entries.length} </span>
</div>
{isLoading ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && entries.length === 0 ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && entries.length > 0 ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const busy = busyVersionId === entry.versionId;
return (
<tr key={entry.versionId}>
<td>
<code>{entry.gameId}</code>
</td>
<td>
v{entry.versionNumber}
<div className="admin-muted-text">{entry.status}</div>
{entry.reviewReason ? (
<div className="admin-muted-text">
{entry.reviewReason}
</div>
) : null}
</td>
<td>
{formatBytes(entry.packageBytes)}
<div className="admin-muted-text">
<code>{entry.packageSha256.slice(0, 12)}</code>
</div>
</td>
<td>{formatTime(entry.createdAt)}</td>
<td>
<div className="admin-action-row">
<div className="admin-field">
<label
htmlFor={`game-release-url-${entry.versionId}`}
>
</label>
<input
id={`game-release-url-${entry.versionId}`}
value={entryUrlByVersion[entry.versionId] ?? ''}
placeholder="https://"
onChange={(event) =>
setEntryUrlByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-primary-button"
disabled={busy}
onClick={() => void submitReview(entry, 'approve')}
>
</button>
<div className="admin-field">
<label
htmlFor={`game-reject-reason-${entry.versionId}`}
>
</label>
<input
id={`game-reject-reason-${entry.versionId}`}
value={reasonByVersion[entry.versionId] ?? ''}
onChange={(event) =>
setReasonByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-ghost-button"
disabled={busy}
onClick={() => void submitReview(entry, 'reject')}
>
</button>
<div className="admin-field">
<label
htmlFor={`game-suspend-reason-${entry.versionId}`}
>
</label>
<input
id={`game-suspend-reason-${entry.versionId}`}
value={suspendReasonByGame[entry.gameId] ?? ''}
onChange={(event) =>
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-danger-button"
disabled={busy || busyGameId === entry.gameId}
onClick={() => void suspendGame(entry)}
>
{busyGameId === entry.gameId
? '正在下架…'
: '安全下架'}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
</div>
{writeConfirm.confirmDialog}
</section>
);
}
@@ -200,15 +200,16 @@ const allowedUncalledTauriCommands = [
'read_agc_plugin_panel',
'set_agc_plugin_enabled',
// 下面这些命令的调用方只有随 Project Supervisor 前端链路一起删除的旧命令聊天入口;
// 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / 导出包 /
// 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 /
// 画板同步 / 素材登记 / 权限策略 / 本地草案 / 平台美术),Rust 侧只剩注册与实现,
// `*_at` helper 仍由 Rust 用例覆盖。接回新入口还是删除属于 native 能力取舍,先按
// native-only 登记,避免孤儿检查一直报错。
// 预览不在本清单:`activate_local_game_preview` 已按 ADR 回接到 App 的「运行」入口。
// 导出试玩包同样不在本清单:发布链路(`requestGamePublish`)已把它接回
// DirectProject 聊天头的「发布」入口。
'build_local_project_index',
'control_agent_run',
'create_local_project_checkpoint',
'export_local_project_package',
'generate_local_game_draft',
'generate_platform_art_asset',
'import_canvas_asset',
@@ -88,15 +88,21 @@ export function resolveNsisCacheDir(
env = process.env,
platform = process.platform,
) {
const pathImpl = platform === 'win32' ? path.win32 : path.posix;
const explicit = env.AGC_TAURI_NSIS_CACHE_DIR?.trim();
if (explicit) return path.resolve(explicit);
if (explicit) return pathImpl.resolve(explicit);
// Jenkins Windows 节点以 SYSTEM 运行,ProgramData 稳定可写且不受工作区清理影响;
// 缓存里只有待解压的原始归档,不会从该目录执行任何程序。
if (platform === 'win32') {
const programData = env.ProgramData?.trim() || 'C:\\ProgramData';
return path.join(programData, 'genarrative', 'tauri-nsis-cache');
return pathImpl.join(programData, 'genarrative', 'tauri-nsis-cache');
}
return path.join(os.homedir(), '.cache', 'genarrative', 'tauri-nsis-cache');
return pathImpl.join(
os.homedir(),
'.cache',
'genarrative',
'tauri-nsis-cache',
);
}
/** 与 tauri-bundler 相同的镜像开关语义,便于构建机绕过不可达的 GitHub。 */
@@ -7,6 +7,7 @@ import { test } from 'node:test';
import JSZip from 'jszip';
import {
defaultAppRoot,
ensureNsisToolset,
extractNsisArchive,
NSIS_ARCHIVE_ASSET_NAME,
@@ -23,10 +24,7 @@ import {
verifyNsisToolset,
} from './nsis-toolset.mjs';
const appRoot = path.resolve(
path.dirname(new URL(import.meta.url).pathname),
'..',
);
const appRoot = defaultAppRoot();
const silentLogger = { log() {}, warn() {} };
function createSandbox() {
@@ -98,6 +96,14 @@ test('NSIS 工具链目录与 Tauri useLocalToolsDir 配置保持一致', () =>
});
test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => {
assert.equal(
resolveNsisCacheDir({ AGC_TAURI_NSIS_CACHE_DIR: 'D:\\agc-cache' }, 'win32'),
'D:\\agc-cache',
);
assert.equal(
resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'),
path.win32.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'),
);
assert.equal(
resolveNsisCacheDir(
{ AGC_TAURI_NSIS_CACHE_DIR: '/tmp/agc-cache' },
@@ -105,13 +111,9 @@ test('缓存目录默认落在工作区之外并支持环境变量覆盖', () =>
),
'/tmp/agc-cache',
);
assert.equal(
resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'),
path.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'),
);
assert.ok(
resolveNsisCacheDir({}, 'linux').endsWith(
path.join('.cache', 'genarrative', 'tauri-nsis-cache'),
path.posix.join('.cache', 'genarrative', 'tauri-nsis-cache'),
),
);
});
@@ -150,6 +150,7 @@ function formatDuration(milliseconds) {
// 编译一次,直接拿到测试可执行文件:后续每片都运行同一个二进制,不再各自调用 cargo,
// 免得 N 个 cargo 去争 package cache 与 target 目录锁。
function resolveTestExecutable() {
const startedAt = Date.now();
return new Promise((resolve, reject) => {
const cargoArguments = buildCargoArguments({
kind: options.targetKind,
@@ -194,6 +195,9 @@ function resolveTestExecutable() {
reject(new Error(`unable to start cargo: ${error.message}`));
});
child.on('close', (code) => {
console.log(
`[rust-shards] compile duration=${formatDuration(Date.now() - startedAt)} exit=${code}`,
);
if (code !== 0) {
reject(
new Error(
@@ -16,7 +16,8 @@
{ "url": "https://www.genarrative.world/api/*" },
{ "url": "https://*/api/*" },
{ "url": "http://localhost:*/*" },
{ "url": "http://127.0.0.1:*/*" }
{ "url": "http://127.0.0.1:*/*" },
{ "url": "https://*.aliyuncs.com/*" }
]
},
"opener:default",
@@ -5985,6 +5985,16 @@ pub(crate) fn export_local_project_package(
export_local_project_package_at(root)
}
#[tauri::command]
pub(crate) fn read_local_project_export_package(
project_path: String,
package_relative_path: String,
) -> Result<LocalProjectExportPackagePayload, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_package")?;
read_local_project_export_package_at(root, package_relative_path.trim())
}
#[tauri::command]
pub(crate) fn list_local_project_export_packages(
project_path: String,
@@ -2717,6 +2717,7 @@ fn main() {
build_local_project_index,
create_local_project_checkpoint,
export_local_project_package,
read_local_project_export_package,
list_local_project_export_packages,
diff_local_project_checkpoint,
restore_local_project_checkpoint,
@@ -2,6 +2,11 @@ use super::*;
#[cfg(target_os = "linux")]
use std::process::Stdio;
#[cfg(target_os = "linux")]
mod owner_fixture_cleanup;
#[cfg(target_os = "linux")]
use owner_fixture_cleanup::{project_processes, OwnerFixtureCleanup};
static PROCESS_SESSION_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn process_session_test_guard() -> std::sync::MutexGuard<'static, ()> {
@@ -1743,20 +1748,6 @@ fn process_session_runner_owner_fixture() {
#[cfg(target_os = "linux")]
#[test]
fn process_session_owner_sigkill_leaves_no_child_process() {
fn project_processes(root: &Path) -> Vec<i32> {
let canonical_root = fs::canonicalize(root).expect("canonical test project");
fs::read_dir("/proc")
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let process_id = entry.file_name().to_string_lossy().parse::<i32>().ok()?;
let cwd = fs::read_link(entry.path().join("cwd")).ok()?;
(cwd == canonical_root).then_some(process_id)
})
.collect()
}
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "owner-process-project", "Owner Process Project")
@@ -1787,6 +1778,10 @@ setInterval(() => {}, 1000);
.stderr(Stdio::null())
.spawn()
.expect("spawn owner fixture test process");
let cleanup = OwnerFixtureCleanup {
owner: &mut owner,
root,
};
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while (!root.join("owner-ready").is_file() || project_processes(root).is_empty())
&& std::time::Instant::now() < deadline
@@ -1799,9 +1794,9 @@ setInterval(() => {}, 1000);
"sandbox child should be visible from host /proc"
);
let owner_pid = i32::try_from(owner.id()).expect("owner pid");
assert_eq!(unsafe { libc::kill(owner_pid, libc::SIGKILL) }, 0);
owner.wait().expect("reap owner fixture");
// Linux Child::kill 发送 SIGKILL;先检查真实子树回收,再由 guard 兜底。
cleanup.owner.kill().expect("SIGKILL owner fixture");
cleanup.owner.wait().expect("reap owner fixture");
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let remaining = project_processes(root);
@@ -0,0 +1,127 @@
use std::fs;
use std::path::Path;
use std::process::Child;
use std::thread;
use std::time::{Duration, Instant};
pub(super) fn project_processes(root: &Path) -> Vec<i32> {
let Ok(canonical_root) = fs::canonicalize(root) else {
return Vec::new();
};
fs::read_dir("/proc")
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let process_id = entry.file_name().to_string_lossy().parse::<i32>().ok()?;
if process_id <= 1 || process_id == std::process::id() as i32 {
return None;
}
let cwd = fs::read_link(entry.path().join("cwd")).ok()?;
(cwd == canonical_root).then_some(process_id)
})
.collect()
}
pub(super) struct OwnerFixtureCleanup<'a> {
pub(super) owner: &'a mut Child,
pub(super) root: &'a Path,
}
impl Drop for OwnerFixtureCleanup<'_> {
fn drop(&mut self) {
let _ = self.owner.kill();
let _ = self.owner.wait();
// 正常路径先验证子进程自行退出;这里只兜底作用域退出(包括 panic)后的残留。
// 项目目录由每条用例独占,不能按进程名清理其他用例或开发进程。
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let remaining = project_processes(self.root);
if remaining.is_empty() {
return;
}
for process_id in &remaining {
unsafe {
libc::kill(*process_id, libc::SIGKILL);
}
}
if Instant::now() >= deadline {
// Drop 可能在 panic 展开期间执行,不能再次 panic。
use std::io::Write;
let _ = writeln!(
std::io::stderr(),
"owner fixture cleanup timed out: pids={remaining:?}"
);
return;
}
thread::sleep(Duration::from_millis(25));
}
}
}
#[test]
fn owner_fixture_cleanup_reaps_processes_on_panic_without_touching_other_projects() {
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::process::{Command, Stdio};
// 回归夹具自己的回收不能依赖被测 guard,否则 guard 回归时测试也会泄漏。
struct Sleeper(Child);
impl Drop for Sleeper {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn sleeper(root: &Path) -> Sleeper {
Sleeper(
Command::new("sleep")
.arg("60")
.current_dir(root)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn cleanup fixture"),
)
}
// 同时覆盖 owner 刚启动就失败,以及已有残留进程时失败。
for has_residual in [false, true] {
let project = tempfile::tempdir().expect("cleanup project");
let other_project = tempfile::tempdir().expect("unrelated project");
let mut owner = sleeper(project.path());
let cleanup = OwnerFixtureCleanup {
owner: &mut owner.0,
root: project.path(),
};
// 故意不依赖 owner 退出监测,验证兜底能清理仍留在项目目录的进程。
let mut residual = has_residual.then(|| sleeper(project.path()));
let mut other = sleeper(other_project.path());
let result = catch_unwind(AssertUnwindSafe(move || {
let _cleanup = cleanup;
panic!("simulate an assertion failure before owner shutdown");
}));
let owner_status = owner.0.try_wait();
let residual_status = residual.as_mut().map(|child| child.0.try_wait());
let other_status = other.0.try_wait();
// 即使 guard 回归,先收口本测试持有的进程再断言,避免回归用例自身泄漏。
drop(owner);
drop(residual);
drop(other);
assert!(result.is_err());
assert!(matches!(owner_status, Ok(Some(_))), "owner must exit");
if has_residual {
assert!(
matches!(residual_status, Some(Ok(Some(_)))),
"residual process must exit"
);
}
assert!(
matches!(other_status, Ok(None)),
"other project must survive"
);
}
}
@@ -1,5 +1,27 @@
use super::*;
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::io::{Cursor, Read};
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectExportPackageFileDigest {
pub(crate) path: String,
pub(crate) size_bytes: u64,
pub(crate) sha256: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectExportPackagePayload {
pub(crate) package_relative_path: String,
pub(crate) package_bytes: Vec<u8>,
pub(crate) package_sha256: String,
pub(crate) package_size_bytes: u64,
pub(crate) files: Vec<LocalProjectExportPackageFileDigest>,
}
pub(crate) fn export_local_project_package_at(
root: &Path,
) -> Result<LocalProjectExportPackageResult, String> {
@@ -110,6 +132,114 @@ pub(crate) fn export_local_project_package_at(
})
}
/// Read a previously exported package for the explicit AGC publish flow.
///
/// The caller receives the package bytes and a deterministic file manifest, but
/// never receives a filesystem path that it could accidentally send to the API.
pub(crate) fn read_local_project_export_package_at(
root: &Path,
package_relative_path: &str,
) -> Result<LocalProjectExportPackagePayload, String> {
validate_project_root(root)?;
let normalized = normalize_export_package_entry_path(package_relative_path)?;
if !normalized.starts_with("exports/playtest-package-")
|| !normalized.ends_with(".zip")
|| normalized.contains('/') && normalized.split('/').count() != 2
{
return Err("发行包路径必须是 exports/playtest-package-*.zip".to_string());
}
let package_path = resolve_local_project_path(root, &normalized)?;
prepare_game_creator_private_path_for_read(&package_path, false, "发行包")?;
let metadata = checked_export_package_metadata(&package_path, &normalized)?;
if !metadata.is_file() {
return Err("发行包必须是普通文件".to_string());
}
if metadata.len() == 0 || metadata.len() > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
return Err("发行包大小超出本地发布上限".to_string());
}
let package_bytes =
fs::read(&package_path).map_err(|error| format!("读取发行包失败:{error}"))?;
if package_bytes.len() as u64 != metadata.len() {
return Err("发行包在读取期间发生变化,请重新导出".to_string());
}
let mut archive = zip::ZipArchive::new(Cursor::new(&package_bytes))
.map_err(|error| format!("读取发行包 ZIP 失败:{error}"))?;
let mut entries = Vec::with_capacity(archive.len());
let mut seen = BTreeSet::new();
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|error| format!("读取发行包条目失败:{error}"))?;
if entry.is_dir() {
continue;
}
let source_path = normalize_export_package_entry_path(entry.name())?;
// 本地试玩包以 game/index.html 为入口,而平台发行合同要求根
// index.html。把 game/ 前缀剥离到内存 ZIP,避免上传本地路径或修改
// 工作区里的原始导出文件;根目录的 README/assets 等公共条目原样保留。
let path = source_path
.strip_prefix("game/")
.unwrap_or(source_path.as_str())
.to_string();
let path = normalize_export_package_entry_path(&path)?;
if !seen.insert(path.clone()) {
return Err(format!("发行包包含重复条目:{path}"));
}
let expected_size = entry.size();
let mut content = Vec::with_capacity(expected_size.min(16 * 1024 * 1024) as usize);
entry
.read_to_end(&mut content)
.map_err(|error| format!("读取发行包文件失败:{path}: {error}"))?;
if content.len() as u64 != expected_size {
return Err(format!("发行包条目长度不一致:{path}"));
}
entries.push((path, content));
}
entries.sort_by(|left, right| left.0.cmp(&right.0));
if entries.is_empty() {
return Err("发行包没有可上传文件".to_string());
}
let mut normalized_writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (path, content) in &entries {
normalized_writer
.start_file(path, options)
.map_err(|error| format!("写入发行包条目失败:{path}: {error}"))?;
normalized_writer
.write_all(content)
.map_err(|error| format!("写入发行包文件失败:{path}: {error}"))?;
}
let normalized_cursor = normalized_writer
.finish()
.map_err(|error| format!("完成发行包失败:{error}"))?;
let package_bytes = normalized_cursor.into_inner();
if package_bytes.is_empty() || package_bytes.len() as u64 > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
return Err("归一化发行包大小超出本地发布上限".to_string());
}
let package_sha256 = format!("{:x}", Sha256::digest(&package_bytes));
let files = entries
.into_iter()
.map(|(path, content)| LocalProjectExportPackageFileDigest {
size_bytes: content.len() as u64,
sha256: format!("{:x}", Sha256::digest(&content)),
path,
})
.collect::<Vec<_>>();
if !files.iter().any(|file| file.path == "index.html") {
return Err("归一化发行包缺少根 index.html".to_string());
}
Ok(LocalProjectExportPackagePayload {
package_relative_path: normalized,
package_size_bytes: package_bytes.len() as u64,
package_bytes,
package_sha256,
files,
})
}
pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result<String, String> {
let seed = unix_millis();
for suffix in 0..1000 {
@@ -348,79 +348,113 @@ pub(crate) fn should_skip_project_index_path(relative_path: &str) -> bool {
|| should_skip_project_snapshot_path(relative_path)
}
/// 项目索引、checkpoint、Agent 上下文与 git 检查共用的排除口径:`.agent` 是这些结果的
/// 本机控制面,不参与其中。
pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool {
project_snapshot_path_is_excluded(relative_path, false)
}
/**
* 项目快照同步(上传)的排除口径。
*
* 与 `should_skip_project_snapshot_path` 是同一份组件与后缀规则,唯一区别是 `.agent`:
* 它承载项目身份与 Agent 状态(`manifest.json`、`agent.db`、会话、运行日志、checkpoint、
* workbench、`project.lock`),必须整目录随快照同步,因此不再把 `.agent` 组件本身当作
* 排除项,并放行其中的 Agent 状态数据库(`.db` / `.db-wal` / `.db-shm`)。
*
* 其余排除项在 `.agent` 内同样生效:版本库、依赖与构建目录、凭据目录、敏感后缀、
* `.env*` 与凭据类文件名一律不参与同步;符号链接与重解析点在扫描阶段单独跳过。
*/
pub(crate) fn should_skip_project_snapshot_sync_path(relative_path: &str) -> bool {
project_snapshot_path_is_excluded(relative_path, true)
}
/// 任意层级出现即排除的目录组件。`.agent` 只有项目快照同步会放行。
const PROJECT_SNAPSHOT_EXCLUDED_COMPONENTS: &[&str] = &[
".agent",
".git",
".hg",
".svn",
".ssh",
".aws",
".azure",
".gnupg",
".kube",
".docker",
".gcloud",
".terraform",
".password-store",
".secrets",
"secrets",
"credentials",
"node_modules",
"target",
"dist",
"build",
".next",
"coverage",
".cache",
];
/// 凭据、密钥与数据库转储类文件名后缀。
const PROJECT_SNAPSHOT_EXCLUDED_SUFFIXES: &[&str] = &[
".pem",
".key",
".p12",
".pfx",
".ppk",
".jks",
".keystore",
".kdbx",
".db",
".db-wal",
".db-shm",
".sqlite",
".sqlite-wal",
".sqlite-shm",
".sqlite3",
".sqlite3-wal",
".sqlite3-shm",
".sql",
".sql.gz",
".sql.bz2",
".sql.xz",
".dump",
".dump.gz",
".dmp",
".bak",
".mdb",
".accdb",
".rdb",
".bson",
".pgdump",
".tfstate",
".tfstate.backup",
];
/// `.agent` 内的 Agent 状态数据库(`agent.db` 及其 WAL / SHM 旁文件)属于项目状态,
/// 随快照同步;其它数据库与转储后缀仍然排除。
const PROJECT_AGENT_STATE_DATABASE_SUFFIXES: &[&str] = &[".db", ".db-wal", ".db-shm"];
fn project_snapshot_path_is_excluded(relative_path: &str, include_agent_state: bool) -> bool {
let components = relative_path
.split('/')
.filter(|component| !component.is_empty())
.map(str::to_ascii_lowercase)
.collect::<Vec<_>>();
if components.iter().any(|component| {
matches!(
component.as_str(),
".agent"
| ".git"
| ".hg"
| ".svn"
| ".ssh"
| ".aws"
| ".azure"
| ".gnupg"
| ".kube"
| ".docker"
| ".gcloud"
| ".terraform"
| ".password-store"
| ".secrets"
| "secrets"
| "credentials"
| "node_modules"
| "target"
| "dist"
| "build"
| ".next"
| "coverage"
| ".cache"
)
PROJECT_SNAPSHOT_EXCLUDED_COMPONENTS.contains(&component.as_str())
&& !(include_agent_state && component == ".agent")
}) {
return true;
}
let Some(file_name) = components.last() else {
return true;
};
let sensitive_suffixes = [
".pem",
".key",
".p12",
".pfx",
".ppk",
".jks",
".keystore",
".kdbx",
".db",
".db-wal",
".db-shm",
".sqlite",
".sqlite-wal",
".sqlite-shm",
".sqlite3",
".sqlite3-wal",
".sqlite3-shm",
".sql",
".sql.gz",
".sql.bz2",
".sql.xz",
".dump",
".dump.gz",
".dmp",
".bak",
".mdb",
".accdb",
".rdb",
".bson",
".pgdump",
".tfstate",
".tfstate.backup",
];
let agent_state_database = include_agent_state
&& components
.first()
.is_some_and(|first| first.as_str() == ".agent");
let structured_secret_suffixes = [".json", ".txt", ".toml", ".yaml", ".yml"];
file_name == ".env"
|| file_name.starts_with(".env.")
@@ -471,9 +505,10 @@ pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool {
|| file_name.starts_with("id_ecdsa")
|| file_name.starts_with("id_ed25519")
|| file_name.starts_with("id_xmss")
|| sensitive_suffixes
.iter()
.any(|suffix| file_name.ends_with(suffix))
|| PROJECT_SNAPSHOT_EXCLUDED_SUFFIXES.iter().any(|suffix| {
file_name.ends_with(suffix)
&& !(agent_state_database && PROJECT_AGENT_STATE_DATABASE_SUFFIXES.contains(suffix))
})
|| ((file_name.contains("cookie") || file_name.contains("credential"))
&& structured_secret_suffixes
.iter()
@@ -23,9 +23,10 @@ pub(crate) struct ProjectSnapshotScanResult {
pub(crate) skipped: Vec<ProjectSnapshotSkippedPath>,
}
/// 扫描项目目录,复用 checkpoint / 项目索引同一份排除口径
/// `.agent`、版本控制目录、依赖与构建产物目录、凭据目录、符号链接与重解析点
/// 都不参与同步,超出单文件上限的文件进入跳过清单而不是静默丢弃。
/// 扫描项目目录,排除口径见 `should_skip_project_snapshot_sync_path`
/// `.agent` 是项目身份与 Agent 状态的权威位置,整目录参与同步;版本控制目录、
/// 依赖与构建产物目录、凭据目录、符号链接与重解析点都不参与同步,超出单文件
/// 上限的文件进入跳过清单而不是静默丢弃。
pub(crate) fn scan_project_snapshot_files(
root: &Path,
max_file_bytes: u64,
@@ -52,7 +53,7 @@ pub(crate) fn scan_project_snapshot_files(
let Ok(relative_path) = relative_project_path(root, &path) else {
continue;
};
if should_skip_project_snapshot_path(&relative_path) {
if should_skip_project_snapshot_sync_path(&relative_path) {
continue;
}
let metadata = match fs::symlink_metadata(&path) {
@@ -95,8 +95,6 @@ fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() {
let root = fixture_root();
write_fixture_file(root.path(), "game/index.html", b"<html></html>");
write_fixture_file(root.path(), "assets/manifest.json", b"{}");
write_fixture_file(root.path(), ".agent/runtime/state.json", b"{}");
write_fixture_file(root.path(), ".agent/manifest.json", b"{}");
write_fixture_file(root.path(), "node_modules/pkg/index.js", b"export {};");
write_fixture_file(root.path(), "game/dist/bundle.js", b"bundle");
write_fixture_file(root.path(), "secrets/key.pem", b"private-key");
@@ -111,7 +109,7 @@ fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() {
assert_eq!(
scanned,
vec!["assets/manifest.json".to_string()],
".agent、node_modules、dist 与凭据目录里的文件不能进入候选集合"
"node_modules、dist 与凭据目录里的文件不能进入候选集合"
);
let skipped = scan
.skipped
@@ -125,6 +123,111 @@ fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() {
);
}
#[test]
fn project_snapshot_scan_uploads_whole_agent_directory() {
let root = fixture_root();
write_fixture_file(root.path(), "game/index.html", b"<html></html>");
write_fixture_file(root.path(), ".agent/manifest.json", b"{}");
write_fixture_file(root.path(), ".agent/agent.db", b"sqlite");
write_fixture_file(root.path(), ".agent/agent.db-wal", b"wal");
write_fixture_file(root.path(), ".agent/project.lock", b"{}");
write_fixture_file(root.path(), ".agent/.manifest.json.lock", b"");
write_fixture_file(root.path(), ".agent/conversations/project.jsonl", b"{}\n");
write_fixture_file(root.path(), ".agent/runtime/events/art.jsonl", b"{}\n");
write_fixture_file(
root.path(),
".agent/runtime/command-env/home/.config.json",
b"{}",
);
write_fixture_file(root.path(), ".agent/logs/command.log", b"log");
write_fixture_file(
root.path(),
".agent/checkpoints/0001/manifest.json",
b"{\"files\":[]}",
);
write_fixture_file(
root.path(),
".agent/workbench/resource-layouts/art.json",
b"{}",
);
let scan = scan_fixture(root.path());
let scanned = scan
.files
.iter()
.map(|file| file.relative_path.clone())
.collect::<Vec<_>>();
assert_eq!(
scanned,
vec![
".agent/.manifest.json.lock".to_string(),
".agent/agent.db".to_string(),
".agent/agent.db-wal".to_string(),
".agent/checkpoints/0001/manifest.json".to_string(),
".agent/conversations/project.jsonl".to_string(),
".agent/logs/command.log".to_string(),
".agent/manifest.json".to_string(),
".agent/project.lock".to_string(),
".agent/runtime/command-env/home/.config.json".to_string(),
".agent/runtime/events/art.jsonl".to_string(),
".agent/workbench/resource-layouts/art.json".to_string(),
"game/index.html".to_string(),
],
"`.agent` 是项目身份与 Agent 状态的权威位置,必须整目录参与同步"
);
assert!(
scan.skipped.is_empty(),
"`.agent` 内的普通文件既不跳过也不延后"
);
}
#[test]
fn project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials() {
for relative_path in [
".agent/manifest.json",
".agent/agent.db",
".agent/agent.db-wal",
".agent/agent.db-shm",
".agent/project.lock",
".agent/runtime/events/art.jsonl",
".agent/runtime/locks/append/01.lock",
".agent/checkpoints/0001/manifest.json",
".agent/conversations/project.jsonl",
".agent/workbench/resource-layouts/art.json",
".AGENT/manifest.json",
] {
assert!(
!should_skip_project_snapshot_sync_path(relative_path),
"`.agent` 状态必须参与同步:{relative_path}"
);
}
for relative_path in [
".agent/credentials/platform.json",
".agent/.ssh/id_rsa",
".agent/certs/server.pem",
".agent/node_modules/pkg/index.js",
".agent/runtime/command-env/home/.env",
".agent/runtime/command-env/home/.npmrc",
".agent/backup/game.sql",
".git/config",
"node_modules/pkg/index.js",
"game/dist/bundle.js",
"secrets/key.pem",
"",
] {
assert!(
should_skip_project_snapshot_sync_path(relative_path),
"凭据、版本库与构建产物仍然排除:{relative_path}"
);
}
// 项目索引、checkpoint 与 Agent 上下文继续排除整个 `.agent`,本变更只放开快照同步。
assert!(should_skip_project_snapshot_path(".agent/manifest.json"));
assert!(should_skip_project_index_path(".agent/manifest.json"));
assert!(should_skip_project_index_path(".agent/agent.db"));
}
#[test]
fn project_snapshot_diff_reuses_metadata_and_reports_a_single_modification() {
let root = fixture_root();
@@ -3935,6 +3935,54 @@ fn local_project_export_package_uses_runtime_whitelist_and_records() {
fs::remove_dir_all(root).ok();
}
#[test]
fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-publish", "在线试玩项目").expect("project init");
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
.expect("write playable html");
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
let exported = export_local_project_package_at(&root).expect("export package");
let payload = read_local_project_export_package_at(&root, &exported.package_relative_path)
.expect("read publish payload");
assert_eq!(
payload.package_relative_path,
exported.package_relative_path
);
assert_eq!(
payload.package_size_bytes,
payload.package_bytes.len() as u64
);
assert_eq!(payload.files.len(), 2);
assert!(payload.files.iter().any(|file| file.path == "index.html"));
assert!(payload
.files
.iter()
.any(|file| file.path == "exports/README.md"));
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&payload.package_bytes))
.expect("read normalized package");
let names = (0..archive.len())
.map(|index| {
archive
.by_index(index)
.expect("normalized entry")
.name()
.to_string()
})
.collect::<Vec<_>>();
assert!(names.iter().any(|name| name == "index.html"));
assert!(!names.iter().any(|name| name.starts_with("game/")));
assert_eq!(payload.package_sha256.len(), 64);
assert!(payload
.package_sha256
.chars()
.all(|value| value.is_ascii_hexdigit()));
fs::remove_dir_all(root).ok();
}
#[test]
fn local_project_export_package_list_only_returns_recent_playtest_zips() {
let root = unique_project_path();
+110 -10
View File
@@ -40,12 +40,14 @@ import type {
LocalGameProjectRevisionStatus,
LocalPreviewResult,
LocalPreviewStatus,
LocalProjectExportPackageResult,
LocalProjectFileResult,
LocalProjectKind,
PendingUiConfirmation,
ProjectPermissionPolicyView,
TauriInvoke,
} from './app/types';
import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel';
import {
agentConversationId,
agentRuntimeStateFromResult,
@@ -91,6 +93,7 @@ import {
type ResourceReferenceInsertEventDetail,
} from './features/project-workspace/resourceReferences';
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
import { readGamePublishAvailability } from './services/gameDistributionPublish';
import {
setAgcPluginProjectPath,
startAvailableAgcEditorPlugins,
@@ -359,6 +362,12 @@ export function App({
const chatComposerRef = useRef<ResourceReferenceInputHandle | null>(null);
const [chatAgentBusy, setChatAgentBusy] = useState(false);
// 发布到游戏广场:试玩包导出结果与面板开关由工作台壳持有,聊天容器只负责触发。
const [publishPackageResult, setPublishPackageResult] =
useState<LocalProjectExportPackageResult | null>(null);
const [publishPanelOpen, setPublishPanelOpen] = useState(false);
// 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。
const [gamePublishAllowed, setGamePublishAllowed] = useState(false);
const [projectChatError, setProjectChatError] = useState('');
const [designAgentTransientReply, setDesignAgentTransientReplyVisible] =
useState('');
@@ -511,6 +520,7 @@ export function App({
texts.push(entry.text);
reasoningByMessageId.set(entry.messageId, texts);
}
// 持久策划消息没有发送时间,不能把读取时刻显示成历史发送时间。
const messages: ChatMessage[] = view.messages
.filter((message) => message.text.trim())
.map((message) => ({
@@ -519,7 +529,6 @@ export function App({
runtimeOwned: true,
messageId: message.id,
reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'),
updatedAt: Date.now(),
}));
const initialPrompt = initialPlanningPromptLatchRef.current.prompt;
if (
@@ -532,7 +541,6 @@ export function App({
role: 'user',
text: initialPrompt,
runtimeOwned: true,
updatedAt: Date.now(),
});
}
return messages;
@@ -870,7 +878,15 @@ export function App({
if (messageList) {
messageList.scrollTop = messageList.scrollHeight;
}
}, [messages, projectChatError]);
}, [
messages,
projectChatError,
designAgentTransientReply,
designAgentReasoning,
designAgentView,
pendingUiConfirmation,
chatFileImportNotice,
]);
useEffect(() => {
latestMessagesRef.current = messages;
@@ -1170,6 +1186,71 @@ export function App({
}
}
useEffect(() => {
let cancelled = false;
void readGamePublishAvailability()
.then((allowed) => {
if (!cancelled) setGamePublishAllowed(allowed);
})
.catch(() => {
if (!cancelled) setGamePublishAllowed(false);
});
return () => {
cancelled = true;
};
}, [localProject?.projectPath]);
/**
* 导出试玩包并打开发布面板。
*
* 权限口径沿用本地命令:`project.export_package` 需要确认时先入队,确认后再导出;
* 导出结果只留在壳里,发布面板关闭即丢弃,不写入项目。
*/
async function requestGamePublish() {
const invoke = resolveTauriInvoke();
if (!invoke) {
setWorkspaceStatus('需要在 Tauri App 内发布');
return;
}
const nextProjectPath =
resolveChatProjectPath(localProject) ?? projectPath.trim();
if (!nextProjectPath) {
setWorkspaceStatus('先打开一个项目再发布');
return;
}
const runExport = async () => {
try {
const result = await invoke<LocalProjectExportPackageResult>(
'export_local_project_package',
{ projectPath: nextProjectPath },
);
setWorkspaceStatus(`已导出本地试玩包:${result.packageRelativePath}`);
setPublishPackageResult(result);
setPublishPanelOpen(true);
appendLocalPermissionLog(
nextProjectPath,
'command.auto',
'project.export_package',
);
} catch (error) {
setWorkspaceStatus(
error instanceof Error ? error.message : String(error),
);
}
};
const queued = await queueProjectPolicyConfirmationIfNeeded(
invoke,
'project.export_package',
nextProjectPath,
'导出试玩包并打开「发布到游戏广场」面板。',
'导出试玩包需要确认,确认后继续。',
() => void runExport(),
);
if (!queued) {
await runExport();
}
}
async function confirmUiCommand() {
const pending = pendingUiConfirmation;
if (!pending) {
@@ -2232,13 +2313,25 @@ export function App({
// 普通项目固定走 DirectProject 自己的聊天容器:订阅、历史、发送、队列和附件都由
// 容器持有,工作台壳只提供项目身份、入口首轮需求和两条权限门。
return (
<DirectProjectChatView
ensureConversationReadAllowed={ensureDirectHistoryReadAllowed}
ensureConversationWriteAllowed={ensureDirectTurnWriteAllowed}
initialTurn={initialDirectTurn}
projectPath={localProject?.projectPath ?? projectPath ?? null}
ref={directProjectChatRef}
/>
<>
<DirectProjectChatView
ensureConversationReadAllowed={ensureDirectHistoryReadAllowed}
ensureConversationWriteAllowed={ensureDirectTurnWriteAllowed}
initialTurn={initialDirectTurn}
onRequestGamePublish={
gamePublishAllowed ? requestGamePublish : undefined
}
projectPath={localProject?.projectPath ?? projectPath ?? null}
ref={directProjectChatRef}
/>
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={localProject?.projectPath ?? projectPath ?? ''}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
</>
);
}
@@ -2382,6 +2475,13 @@ export function App({
onClose={() => setRuntimeConfigOpen(false)}
/>
) : null}
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={localProject?.projectPath ?? projectPath ?? ''}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
</>
);
}
@@ -801,6 +801,20 @@ export interface LocalProjectExportPackageResult {
totalBytes: number;
}
export interface LocalProjectExportPackageFileDigest {
path: string;
sizeBytes: number;
sha256: string;
}
export interface LocalProjectExportPackagePayload {
packageRelativePath: string;
packageBytes: number[];
packageSha256: string;
packageSizeBytes: number;
files: LocalProjectExportPackageFileDigest[];
}
export interface LocalProjectExportPackageSummary {
packagePath: string;
packageRelativePath: string;

Some files were not shown because too many files have changed in this diff Show More