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
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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
+127
@@ -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();
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user