WIP: 游戏共创(作品 Fork):共创授权与改编血缘、创作族谱、取件与改编闭环 #622
@@ -101,12 +101,34 @@ import type {
|
||||
ProfileTaskConfigAdminResponse,
|
||||
ProfileWalletConfigAdminResponse,
|
||||
} from './adminApiTypes';
|
||||
import type {
|
||||
AdminThemeStatusFilter,
|
||||
GameDistributionAdminThemeListResponse,
|
||||
GameDistributionAdminThemeMemberListResponse,
|
||||
GameDistributionAdminThemeMemberRemovalResponse,
|
||||
GameDistributionAdminThemeMemberResponse,
|
||||
GameDistributionAdminThemeMutationResponse,
|
||||
GameDistributionCreateThemeRequest,
|
||||
GameDistributionUpdateThemeRequest,
|
||||
GameDistributionUpsertThemeMemberRequest,
|
||||
} from './adminGameThemeTypes';
|
||||
|
||||
const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
|
||||
const ADMIN_API_BASE_URL = normalizeBaseUrl(
|
||||
import.meta.env.VITE_ADMIN_API_BASE_URL ?? '',
|
||||
);
|
||||
|
||||
/**
|
||||
* 后台主题列表的 `limit` 上界:与服务端 `MAX_ADMIN_THEME_LIST_LIMIT` 同值。
|
||||
*
|
||||
* 服务端按上限截断(不报错),客户端也按同一口径夹一次,避免发出一条注定被截断的请求。
|
||||
*/
|
||||
const ADMIN_THEME_LIST_LIMIT_MAX = 200;
|
||||
|
||||
/** 后台成员名单每页条数:缺省与服务端一致,上限 50(超出服务端截断,这里先夹)。 */
|
||||
const ADMIN_THEME_MEMBER_PAGE_SIZE = 20;
|
||||
const ADMIN_THEME_MEMBER_PAGE_SIZE_MAX = 50;
|
||||
|
||||
interface AdminRequestOptions {
|
||||
method?: string;
|
||||
token?: string;
|
||||
@@ -1512,3 +1534,149 @@ export function importAdminAgcTemplates(token: string, formData: FormData) {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台共创主题列表:`GET /admin/api/game-distribution/themes?limit=&status=`。
|
||||
*
|
||||
* 含 draft / archived;`status=all`(或省略)不过滤。**没有游标**:`limit` 上限 200,服务端按
|
||||
* 上限截断,这里也按同一口径夹一次,避免把一条必然被截断的请求发出去。
|
||||
*/
|
||||
export function listAdminGameDistributionThemes(
|
||||
token: string,
|
||||
options: {
|
||||
limit?: number;
|
||||
status?: AdminThemeStatusFilter;
|
||||
} = {},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const requestedLimit = options.limit ?? ADMIN_THEME_LIST_LIMIT_MAX;
|
||||
const normalizedLimit = Number.isFinite(requestedLimit)
|
||||
? Math.min(Math.max(Math.trunc(requestedLimit), 1), ADMIN_THEME_LIST_LIMIT_MAX)
|
||||
: ADMIN_THEME_LIST_LIMIT_MAX;
|
||||
const params = new URLSearchParams({ limit: String(normalizedLimit) });
|
||||
const status = options.status?.trim();
|
||||
if (status && status !== 'all') params.set('status', status);
|
||||
return request<GameDistributionAdminThemeListResponse>(
|
||||
`/admin/api/game-distribution/themes?${params.toString()}`,
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
/** 后台创建主题:`theme_id` 由服务端生成,客户端只给字段;写操作必须带幂等键。 */
|
||||
export function createAdminGameDistributionTheme(
|
||||
token: string,
|
||||
idempotencyKey: string,
|
||||
payload: GameDistributionCreateThemeRequest,
|
||||
) {
|
||||
return request<GameDistributionAdminThemeMutationResponse>(
|
||||
'/admin/api/game-distribution/themes',
|
||||
{
|
||||
token,
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': normalizeThemeIdempotencyKey(idempotencyKey) },
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 后台整体覆盖主题字段:`name/summary/badge/sortOrder/status` 都是必填(不是部分更新)。 */
|
||||
export function updateAdminGameDistributionTheme(
|
||||
token: string,
|
||||
themeId: string,
|
||||
idempotencyKey: string,
|
||||
payload: GameDistributionUpdateThemeRequest,
|
||||
) {
|
||||
return request<GameDistributionAdminThemeMutationResponse>(
|
||||
`/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}`,
|
||||
{
|
||||
token,
|
||||
method: 'PUT',
|
||||
headers: { 'Idempotency-Key': normalizeThemeIdempotencyKey(idempotencyKey) },
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 增 / 改主题成员:**不要求幂等键**(成员身份完全由路径给出,重复调用只改 `sortOrder`)。
|
||||
*
|
||||
* 只允许根作品:非根作品服务端回 409 `THEME_MEMBER_NOT_ROOT`。
|
||||
*/
|
||||
export function upsertAdminGameDistributionThemeMember(
|
||||
token: string,
|
||||
themeId: string,
|
||||
rootGameId: string,
|
||||
payload: GameDistributionUpsertThemeMemberRequest,
|
||||
) {
|
||||
return request<GameDistributionAdminThemeMemberResponse>(
|
||||
`/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members/${encodeURIComponent(normalizeRootGameId(rootGameId))}`,
|
||||
{ token, method: 'PUT', body: payload },
|
||||
);
|
||||
}
|
||||
|
||||
/** 移除主题成员:不要求幂等键;成员不存在也算成功(200,返回同一个形状)。 */
|
||||
export function removeAdminGameDistributionThemeMember(
|
||||
token: string,
|
||||
themeId: string,
|
||||
rootGameId: string,
|
||||
) {
|
||||
return request<GameDistributionAdminThemeMemberRemovalResponse>(
|
||||
`/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members/${encodeURIComponent(normalizeRootGameId(rootGameId))}`,
|
||||
{ token, method: 'DELETE' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台主题成员名单:`GET …/themes/{themeId}/members?limit=&cursor=`。
|
||||
*
|
||||
* **不套公开可见性过滤**,草稿 / 归档主题照常可读——这正是这条接口存在的理由。每行带
|
||||
* `visible`(此刻公开投影是否包含它)与 `visibility`(为什么:`published`/`unpublished`/
|
||||
* `suspended`/`deleted`/`missing`)两个独立字段,界面不要互相推导。
|
||||
*
|
||||
* `limit` 缺省 20 / 上限 50(服务端口径),这里按同一口径夹一次;`cursor` 用上一页的 `nextCursor`。
|
||||
*/
|
||||
export function listAdminGameDistributionThemeMembers(
|
||||
token: string,
|
||||
themeId: string,
|
||||
options: { limit?: number; cursor?: string | null } = {},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const requestedLimit = options.limit ?? ADMIN_THEME_MEMBER_PAGE_SIZE;
|
||||
const normalizedLimit = Number.isFinite(requestedLimit)
|
||||
? Math.min(
|
||||
Math.max(Math.trunc(requestedLimit), 1),
|
||||
ADMIN_THEME_MEMBER_PAGE_SIZE_MAX,
|
||||
)
|
||||
: ADMIN_THEME_MEMBER_PAGE_SIZE;
|
||||
const params = new URLSearchParams({ limit: String(normalizedLimit) });
|
||||
const cursor = options.cursor?.trim();
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
return request<GameDistributionAdminThemeMemberListResponse>(
|
||||
`/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members?${params.toString()}`,
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeThemeId(themeId: string) {
|
||||
const normalized = themeId.trim();
|
||||
if (!normalized) {
|
||||
throw new Error('缺少主题 ID');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRootGameId(rootGameId: string) {
|
||||
const normalized = rootGameId.trim();
|
||||
if (!normalized) {
|
||||
throw new Error('缺少根作品 ID');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeThemeIdempotencyKey(idempotencyKey: string) {
|
||||
const normalized = idempotencyKey.trim();
|
||||
if (!normalized || normalized.length > 128) {
|
||||
throw new Error('主题写操作幂等键必须是 1 到 128 个字符');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 后台「共创主题」接口用到的契约类型**透传**入口。
|
||||
*
|
||||
* 这里**不再重复定义任何载荷**:主题的后台类型已在
|
||||
* `packages/shared/src/contracts/gameDistribution.ts` 登记(并纳入 DTO parity 的
|
||||
* `TS_ONLY_TYPES` / `RESPONSE_BUILDERS`),后台 UI 直接用契约名,避免两份定义并存漂移。
|
||||
*
|
||||
* 为什么用相对路径而不是包名 `@genarrative/shared`:仓库根的
|
||||
* `node_modules/@genarrative/shared` 软链指向**主工作树**,主题契约只在本分支上,走包名会拿到
|
||||
* 缺这些类型的旧副本(实测 TS2724)。AGC 侧同样用这种相对引用。
|
||||
*/
|
||||
export type {
|
||||
GameDistributionAdminTheme,
|
||||
GameDistributionAdminThemeListResponse,
|
||||
GameDistributionAdminThemeMemberListResponse,
|
||||
GameDistributionAdminThemeMemberRemovalResponse,
|
||||
GameDistributionAdminThemeMemberResponse,
|
||||
GameDistributionAdminThemeMemberRow,
|
||||
GameDistributionAdminThemeMemberVisibility,
|
||||
GameDistributionAdminThemeMutationResponse,
|
||||
GameDistributionCreateThemeRequest,
|
||||
GameDistributionThemeStatus,
|
||||
GameDistributionUpdateThemeRequest,
|
||||
GameDistributionUpsertThemeMemberRequest,
|
||||
} from '../../../../packages/shared/src/contracts/gameDistribution';
|
||||
|
||||
import type { GameDistributionThemeStatus } from '../../../../packages/shared/src/contracts/gameDistribution';
|
||||
|
||||
/**
|
||||
* 后台主题列表的 `status` 查询值。
|
||||
*
|
||||
* `all` 是**接口查询参数**的白名单取值(不过滤),不是主题状态本身,所以没有进共享契约。
|
||||
*/
|
||||
export type AdminThemeStatusFilter = GameDistributionThemeStatus | 'all';
|
||||
@@ -33,6 +33,7 @@ import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
||||
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
|
||||
import { AdminGameManagementPage } from '../pages/AdminGameManagementPage';
|
||||
import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage';
|
||||
import { AdminGameThemesPage } from '../pages/AdminGameThemesPage';
|
||||
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
||||
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||
@@ -360,6 +361,12 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'game-themes' ? (
|
||||
<AdminGameThemesPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'editor-assets' ? (
|
||||
<AdminEditorAssetQueryPage
|
||||
token={token}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Gamepad2,
|
||||
GitBranch,
|
||||
Images,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
@@ -64,6 +65,7 @@ const routeIcons = {
|
||||
'game-distribution': Gamepad2,
|
||||
'game-management': Swords,
|
||||
'game-reviews': Star,
|
||||
'game-themes': Layers,
|
||||
'editor-assets': Images,
|
||||
'project-snapshots': FolderArchive,
|
||||
accounts: Users,
|
||||
|
||||
@@ -21,6 +21,7 @@ export type AdminRouteId =
|
||||
| 'game-distribution'
|
||||
| 'game-management'
|
||||
| 'game-reviews'
|
||||
| 'game-themes'
|
||||
| 'editor-assets'
|
||||
| 'project-snapshots'
|
||||
| 'agc-models'
|
||||
@@ -68,6 +69,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
|
||||
{ id: 'game-management', label: '游戏管理', hash: '#game-management' },
|
||||
{ id: 'game-reviews', label: '游戏评价', hash: '#game-reviews' },
|
||||
{ id: 'game-themes', label: '共创主题', hash: '#game-themes' },
|
||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||
@@ -131,6 +133,7 @@ export const adminNavGroups: AdminNavGroupDefinition[] = [
|
||||
'game-distribution',
|
||||
'game-management',
|
||||
'game-reviews',
|
||||
'game-themes',
|
||||
'editor-assets',
|
||||
'project-snapshots',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createAdminGameDistributionTheme,
|
||||
listAdminGameDistributionThemeMembers,
|
||||
listAdminGameDistributionThemes,
|
||||
removeAdminGameDistributionThemeMember,
|
||||
updateAdminGameDistributionTheme,
|
||||
upsertAdminGameDistributionThemeMember,
|
||||
} from '../api/adminApiClient';
|
||||
import type { GameDistributionAdminTheme } from '../api/adminGameThemeTypes';
|
||||
import { AdminGameThemesPage } from './AdminGameThemesPage';
|
||||
|
||||
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 : '请求失败',
|
||||
),
|
||||
listAdminGameDistributionThemes: vi.fn(),
|
||||
createAdminGameDistributionTheme: vi.fn(),
|
||||
updateAdminGameDistributionTheme: vi.fn(),
|
||||
upsertAdminGameDistributionThemeMember: vi.fn(),
|
||||
removeAdminGameDistributionThemeMember: vi.fn(),
|
||||
listAdminGameDistributionThemeMembers: vi.fn(),
|
||||
}));
|
||||
|
||||
const publishedTheme: GameDistributionAdminTheme = {
|
||||
themeId: 'theme_1',
|
||||
name: '星际防线',
|
||||
summary: '同一母版下的二创合集',
|
||||
badge: '热门',
|
||||
sortOrder: 10,
|
||||
status: 'published',
|
||||
memberCount: 3,
|
||||
createdAt: '2026-10-01T08:00:00Z',
|
||||
updatedAt: '2026-10-02T08:00:00Z',
|
||||
};
|
||||
|
||||
const draftTheme: GameDistributionAdminTheme = {
|
||||
...publishedTheme,
|
||||
themeId: 'theme_2',
|
||||
name: '草稿主题',
|
||||
badge: '新',
|
||||
sortOrder: 20,
|
||||
status: 'draft',
|
||||
memberCount: 0,
|
||||
};
|
||||
|
||||
function mockList(themes: GameDistributionAdminTheme[] = [publishedTheme]) {
|
||||
vi.mocked(listAdminGameDistributionThemes).mockResolvedValue({ themes });
|
||||
}
|
||||
|
||||
function renderPage(
|
||||
onUnauthorized: (message?: string) => void = () => undefined,
|
||||
) {
|
||||
return render(
|
||||
<AdminGameThemesPage token="admin-token" onUnauthorized={onUnauthorized} />,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockList();
|
||||
// 成员面板默认给一份空名单,个别用例再覆盖。
|
||||
vi.mocked(listAdminGameDistributionThemeMembers).mockResolvedValue({
|
||||
themeId: 'theme_1',
|
||||
totalMembers: 0,
|
||||
members: [],
|
||||
nextCursor: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('列表展示名称、角标、状态、排序与成员数,并按状态过滤', async () => {
|
||||
renderPage();
|
||||
|
||||
const row = (await screen.findByText('星际防线')).closest('tr')!;
|
||||
expect(within(row).getByText('热门')).toBeTruthy();
|
||||
expect(within(row).getByText('已发布')).toBeTruthy();
|
||||
expect(within(row).getByText('10')).toBeTruthy();
|
||||
expect(within(row).getByText('3')).toBeTruthy();
|
||||
expect(within(row).getByText('theme_1')).toBeTruthy();
|
||||
|
||||
expect(listAdminGameDistributionThemes).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
{ limit: 200, status: 'all' },
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// 切到归档过滤:请求带上 status,且列表按新数据渲染。
|
||||
mockList([{ ...draftTheme, status: 'archived', name: '归档主题' }]);
|
||||
fireEvent.change(screen.getByLabelText('状态过滤'), {
|
||||
target: { value: 'archived' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /查询/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameDistributionThemes).toHaveBeenLastCalledWith(
|
||||
'admin-token',
|
||||
{ limit: 200, status: 'archived' },
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText('归档主题')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('创建主题:先校验再写,写操作带幂等键,重放要提示', async () => {
|
||||
vi.mocked(createAdminGameDistributionTheme).mockResolvedValue({
|
||||
theme: publishedTheme,
|
||||
replayed: true,
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText('星际防线');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建主题' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
|
||||
// 名称为空:本地就拦下,不发请求。
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
|
||||
expect(
|
||||
await within(dialog).findByText('主题名不能为空'),
|
||||
).toBeTruthy();
|
||||
expect(createAdminGameDistributionTheme).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('新建主题主题名'), {
|
||||
target: { value: '星际防线二' },
|
||||
});
|
||||
fireEvent.change(within(dialog).getByLabelText('新建主题简介'), {
|
||||
target: { value: '第二个主题' },
|
||||
});
|
||||
fireEvent.change(within(dialog).getByLabelText('新建主题角标'), {
|
||||
target: { value: '新' },
|
||||
});
|
||||
fireEvent.change(within(dialog).getByLabelText('新建主题排序号'), {
|
||||
target: { value: '5' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
|
||||
|
||||
// 写操作要先过确认对话框。
|
||||
const confirm = await screen.findByRole('dialog', { name: '确认操作' });
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createAdminGameDistributionTheme).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const [token, idempotencyKey, payload] = vi.mocked(
|
||||
createAdminGameDistributionTheme,
|
||||
).mock.calls[0] ?? [];
|
||||
expect(token).toBe('admin-token');
|
||||
expect(payload).toEqual({
|
||||
name: '星际防线二',
|
||||
summary: '第二个主题',
|
||||
badge: '新',
|
||||
sortOrder: 5,
|
||||
status: 'draft',
|
||||
});
|
||||
expect(String(idempotencyKey).length).toBeGreaterThan(8);
|
||||
expect(String(idempotencyKey).length).toBeLessThanOrEqual(128);
|
||||
expect(
|
||||
await screen.findByText(/已存在(幂等重放,未重复写入)/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('创建时同键换请求的 409 要原样解释给运营', async () => {
|
||||
vi.mocked(createAdminGameDistributionTheme).mockRejectedValue(
|
||||
Object.assign(new Error('Idempotency-Key 已用于不同的请求'), {
|
||||
status: 409,
|
||||
code: 'ADMIN_IDEMPOTENCY_CONFLICT',
|
||||
}),
|
||||
);
|
||||
renderPage();
|
||||
await screen.findByText('星际防线');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建主题' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
fireEvent.change(within(dialog).getByLabelText('新建主题主题名'), {
|
||||
target: { value: '冲突主题' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
|
||||
const confirm = await screen.findByRole('dialog', { name: '确认操作' });
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: '确认' }));
|
||||
|
||||
expect(
|
||||
await within(dialog).findByText(/Idempotency-Key 已用于不同的请求/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('编辑主题:整体覆盖字段,并提示归档后公开侧不可读', async () => {
|
||||
vi.mocked(updateAdminGameDistributionTheme).mockResolvedValue({
|
||||
theme: { ...draftTheme, status: 'archived' },
|
||||
replayed: false,
|
||||
});
|
||||
renderPage();
|
||||
const row = (await screen.findByText('星际防线')).closest('tr')!;
|
||||
fireEvent.click(within(row).getByRole('button', { name: '编辑' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(
|
||||
within(dialog).getByText(/归档(archived)后公开侧不可读/),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('编辑主题主题名'), {
|
||||
target: { value: '星际防线(归档)' },
|
||||
});
|
||||
fireEvent.change(within(dialog).getByLabelText('编辑主题状态'), {
|
||||
target: { value: 'archived' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '保存' }));
|
||||
const confirm = await screen.findByRole('dialog', { name: '确认操作' });
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateAdminGameDistributionTheme).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const [token, themeId, , payload] = vi.mocked(
|
||||
updateAdminGameDistributionTheme,
|
||||
).mock.calls[0] ?? [];
|
||||
expect(token).toBe('admin-token');
|
||||
expect(themeId).toBe('theme_1');
|
||||
expect(payload).toMatchObject({
|
||||
name: '星际防线(归档)',
|
||||
status: 'archived',
|
||||
});
|
||||
});
|
||||
|
||||
test('加非根作品:把 409 翻成「只能收录根作品」的可读原因', async () => {
|
||||
vi.mocked(upsertAdminGameDistributionThemeMember).mockRejectedValue(
|
||||
Object.assign(new Error('not root'), {
|
||||
status: 409,
|
||||
code: 'THEME_MEMBER_NOT_ROOT',
|
||||
}),
|
||||
);
|
||||
renderPage();
|
||||
const row = (await screen.findByText('星际防线')).closest('tr')!;
|
||||
fireEvent.click(within(row).getByRole('button', { name: '成员' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
fireEvent.change(within(dialog).getByLabelText('根作品 ID'), {
|
||||
target: { value: 'game_child_1' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '添加成员' }));
|
||||
|
||||
expect(
|
||||
await within(dialog).findByText(
|
||||
'该作品不是根作品(母版);主题只能收录根作品',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(upsertAdminGameDistributionThemeMember).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
'theme_1',
|
||||
'game_child_1',
|
||||
{ sortOrder: 0 },
|
||||
);
|
||||
});
|
||||
|
||||
test('成员面板:草稿主题也能读到成员名单,逐条可移除', async () => {
|
||||
mockList([draftTheme]);
|
||||
vi.mocked(listAdminGameDistributionThemeMembers).mockResolvedValue({
|
||||
themeId: 'theme_2',
|
||||
totalMembers: 2,
|
||||
members: [
|
||||
{
|
||||
rootGameId: 'game_root_1',
|
||||
title: '母版作品',
|
||||
sortOrder: 0,
|
||||
createdAt: '2026-10-01T08:00:00Z',
|
||||
visible: true,
|
||||
visibility: 'published',
|
||||
},
|
||||
{
|
||||
rootGameId: 'game_root_2',
|
||||
title: null,
|
||||
sortOrder: 5,
|
||||
createdAt: '2026-10-01T09:00:00Z',
|
||||
visible: false,
|
||||
visibility: 'missing',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(removeAdminGameDistributionThemeMember).mockResolvedValue({
|
||||
themeId: 'theme_2',
|
||||
rootGameId: 'game_root_1',
|
||||
});
|
||||
renderPage();
|
||||
const row = (await screen.findByText('草稿主题')).closest('tr')!;
|
||||
fireEvent.click(within(row).getByRole('button', { name: '成员' }));
|
||||
|
||||
// 草稿主题也走后台名单接口(不套公开可见性过滤,也不再借公开投影)。
|
||||
expect(listAdminGameDistributionThemeMembers).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
'theme_2',
|
||||
{ limit: 20 },
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
const members = await within(dialog).findByRole('list', {
|
||||
name: '主题成员名单',
|
||||
});
|
||||
expect(within(members).getByText('母版作品')).toBeTruthy();
|
||||
// 游戏行不存在时标题为 null:仍要出现在名单里,否则与 totalMembers 对不上。
|
||||
expect(within(members).getByText('(游戏行不存在)')).toBeTruthy();
|
||||
expect(within(members).getByText('game_root_2')).toBeTruthy();
|
||||
expect(within(members).getByText('公开可见')).toBeTruthy();
|
||||
expect(within(members).getByText('游戏行不存在')).toBeTruthy();
|
||||
expect(within(dialog).getByText(/成员行总数 2;已加载 2 条/)).toBeTruthy();
|
||||
|
||||
// 只移除点中的那一条。
|
||||
fireEvent.click(within(members).getAllByRole('button', { name: '移除' })[0]!);
|
||||
await waitFor(() =>
|
||||
expect(removeAdminGameDistributionThemeMember).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
'theme_2',
|
||||
'game_root_1',
|
||||
),
|
||||
);
|
||||
// 移除后重读第一页(不是本地删行:游标基于排序)。
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameDistributionThemeMembers).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
});
|
||||
|
||||
test('成员面板:区分「已公开但无公开版本」与其它不可见原因', async () => {
|
||||
vi.mocked(listAdminGameDistributionThemeMembers).mockResolvedValue({
|
||||
themeId: 'theme_1',
|
||||
totalMembers: 3,
|
||||
members: [
|
||||
{
|
||||
rootGameId: 'game_root_a',
|
||||
title: '已公开无版本',
|
||||
sortOrder: 0,
|
||||
createdAt: '2026-10-01T08:00:00Z',
|
||||
// 关键异常态:游戏行 published 但此刻公开投影不含它。
|
||||
visible: false,
|
||||
visibility: 'published',
|
||||
},
|
||||
{
|
||||
rootGameId: 'game_root_b',
|
||||
title: '已下架',
|
||||
sortOrder: 1,
|
||||
createdAt: '2026-10-01T08:00:00Z',
|
||||
visible: false,
|
||||
visibility: 'suspended',
|
||||
},
|
||||
{
|
||||
rootGameId: 'game_root_c',
|
||||
title: '已删除',
|
||||
sortOrder: 2,
|
||||
createdAt: '2026-10-01T08:00:00Z',
|
||||
visible: false,
|
||||
visibility: 'deleted',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
renderPage();
|
||||
const row = (await screen.findByText('星际防线')).closest('tr')!;
|
||||
fireEvent.click(within(row).getByRole('button', { name: '成员' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
const members = await within(dialog).findByRole('list', {
|
||||
name: '主题成员名单',
|
||||
});
|
||||
// `visible` 与 `visibility` 独立:published + 不可见必须说清是「没有公开版本」。
|
||||
expect(within(members).getByText('已公开,但无公开版本')).toBeTruthy();
|
||||
expect(within(members).getByText('作品已下架')).toBeTruthy();
|
||||
expect(within(members).getByText('作品已删除')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('成员面板:成员多于首页时可按游标加载更多', async () => {
|
||||
vi.mocked(listAdminGameDistributionThemeMembers)
|
||||
.mockResolvedValueOnce({
|
||||
themeId: 'theme_1',
|
||||
totalMembers: 2,
|
||||
members: [
|
||||
{
|
||||
rootGameId: 'game_root_1',
|
||||
title: '母版一',
|
||||
sortOrder: 0,
|
||||
createdAt: '2026-10-01T08:00:00Z',
|
||||
visible: true,
|
||||
visibility: 'published',
|
||||
},
|
||||
],
|
||||
nextCursor: '0:theme_1:game_root_1',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
themeId: 'theme_1',
|
||||
totalMembers: 2,
|
||||
members: [
|
||||
{
|
||||
rootGameId: 'game_root_2',
|
||||
title: '母版二',
|
||||
sortOrder: 1,
|
||||
createdAt: '2026-10-01T09:00:00Z',
|
||||
visible: true,
|
||||
visibility: 'published',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
renderPage();
|
||||
const row = (await screen.findByText('星际防线')).closest('tr')!;
|
||||
fireEvent.click(within(row).getByRole('button', { name: '成员' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
await within(dialog).findByRole('list', { name: '主题成员名单' });
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '加载更多成员' }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameDistributionThemeMembers).toHaveBeenLastCalledWith(
|
||||
'admin-token',
|
||||
'theme_1',
|
||||
{ limit: 20, cursor: '0:theme_1:game_root_1' },
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(within(dialog).getByText('game_root_2')).toBeTruthy(),
|
||||
);
|
||||
// 末页没有 nextCursor → 按钮消失。
|
||||
expect(within(dialog).queryByRole('button', { name: '加载更多成员' })).toBeNull();
|
||||
});
|
||||
|
||||
test('成员面板:读取失败时如实报错,不渲染成空名单', async () => {
|
||||
vi.mocked(listAdminGameDistributionThemeMembers).mockRejectedValue(
|
||||
Object.assign(new Error('游标非法'), {
|
||||
status: 400,
|
||||
code: 'THEME_INVALID_CURSOR',
|
||||
}),
|
||||
);
|
||||
renderPage();
|
||||
const row = (await screen.findByText('星际防线')).closest('tr')!;
|
||||
fireEvent.click(within(row).getByRole('button', { name: '成员' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(await within(dialog).findByText('游标非法')).toBeTruthy();
|
||||
expect(within(dialog).queryByText(/还没有成员/)).toBeNull();
|
||||
});
|
||||
|
||||
test('401 时交给上层处理登录失效,不把错误塞进页面', async () => {
|
||||
const onUnauthorized = vi.fn();
|
||||
vi.mocked(listAdminGameDistributionThemes).mockRejectedValue(
|
||||
Object.assign(new Error('unauthorized'), { status: 401 }),
|
||||
);
|
||||
renderPage(onUnauthorized);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -138,6 +138,16 @@ const allowedUncalledTauriCommands = [
|
||||
'suggest_game_distribution_publish_metadata',
|
||||
'read_game_distribution_publication',
|
||||
'upload_local_project_game_package',
|
||||
// 共创列表封面:命令名由渲染层以常量 `COVER_COMMAND` 传给**注入的** invoke
|
||||
// (`src/features/platform-fork/platformForkCover.ts`,注入是为了让封面解析能单测),
|
||||
// 静态扫描只认字面量调用点,因此采不到这条命令名 → 显式登记为 native-only 白名单。
|
||||
// 同一批的 `list_platform_game_catalog` 与 `create_local_project_from_platform_game`
|
||||
// 都是字面量调用点,不需要登记。
|
||||
'read_public_game_cover_preview',
|
||||
// 父作品卡(Fork 工程确认页与发布面板):与封面同理,命令名以常量 `DETAIL_COMMAND` 传给
|
||||
// **注入的** invoke(`src/features/platform-fork/forkParentCard.ts`),静态扫描只认字面量
|
||||
// 调用点,因此显式登记为 native-only 白名单。
|
||||
'read_public_game_detail',
|
||||
// 账户与钱包由 Rust typed command 持有 origin/Bearer/envelope;命令名在 `accountHost.ts`
|
||||
// 里以字面量出现,静态扫描仍按共享注册表核验。
|
||||
'read_profile_recharge_center',
|
||||
|
||||
+137
-2
@@ -671,7 +671,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
"which",
|
||||
"windows-registry",
|
||||
"windows-registry 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -820,6 +820,26 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const_fn"
|
||||
version = "0.4.12"
|
||||
@@ -1164,6 +1184,15 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||
dependencies = [
|
||||
"const-random",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.27.0"
|
||||
@@ -1767,6 +1796,7 @@ dependencies = [
|
||||
"jsonschema",
|
||||
"libc",
|
||||
"maud",
|
||||
"module-game-distribution",
|
||||
"nalgebra",
|
||||
"oxc_allocator",
|
||||
"oxc_ast",
|
||||
@@ -1789,8 +1819,10 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
@@ -2068,6 +2100,12 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -2242,7 +2280,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
"windows-registry 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2890,6 +2928,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-game-distribution"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"serde",
|
||||
"sha2",
|
||||
"shared-kernel",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
@@ -3420,6 +3469,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||
dependencies = [
|
||||
"dlv-list",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
@@ -4488,6 +4547,16 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -5000,6 +5069,14 @@ dependencies = [
|
||||
"ts-rs 12.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared-kernel"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"time",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
@@ -5535,6 +5612,27 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d489b8ecceae1cd09f6e1f7606f2095ac721cc8d54cf2f0e6bb377cc52cff6"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"plist",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"url",
|
||||
"windows-registry 0.5.3",
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.1"
|
||||
@@ -5599,6 +5697,23 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin-deep-link",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.11.0"
|
||||
@@ -5859,6 +5974,15 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
@@ -6999,6 +7123,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
|
||||
@@ -68,6 +68,8 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "mul
|
||||
regex = "1"
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-deep-link = "2.4.9"
|
||||
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-updater = "2.11.0"
|
||||
@@ -82,6 +84,11 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
maud = "0.27.0"
|
||||
|
||||
# P1-b 执行级交叉证据:客户端打包器产出的字节直接喂给服务端校验器
|
||||
# (`module-game-distribution::validate_project_bundle_zip`),替代只有文本级的一致性门禁。
|
||||
[dev-dependencies]
|
||||
module-game-distribution = { path = "../../../server-rs/crates/module-game-distribution" }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ values!(CreationSource {
|
||||
HomeGame,
|
||||
HomeDesign,
|
||||
Template,
|
||||
SelectedDirectory
|
||||
SelectedDirectory,
|
||||
// 从平台作品 Fork:把平台作品的工程复制成本地项目继续改造。
|
||||
PlatformGame
|
||||
});
|
||||
values!(OpenSource {
|
||||
Create,
|
||||
|
||||
@@ -275,3 +275,24 @@ fn frozen_context_and_origin_do_not_inherit_new_account() {
|
||||
== false
|
||||
);
|
||||
}
|
||||
|
||||
/// 新增创建来源变体(平台作品改编)不得改变既有取值或解析:老变体照常,新变体按 snake_case
|
||||
/// 落到 `platform_game`,未知值仍然失败关闭。
|
||||
#[test]
|
||||
fn creation_source_gains_platform_game_without_breaking_existing_values() {
|
||||
for (wire, expected) in [
|
||||
("home_game", CreationSource::HomeGame),
|
||||
("home_design", CreationSource::HomeDesign),
|
||||
("template", CreationSource::Template),
|
||||
("selected_directory", CreationSource::SelectedDirectory),
|
||||
("platform_game", CreationSource::PlatformGame),
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<CreationSource>(json!(wire)).unwrap(),
|
||||
expected,
|
||||
"{wire}"
|
||||
);
|
||||
assert_eq!(serde_json::to_value(expected).unwrap(), json!(wire));
|
||||
}
|
||||
assert!(serde_json::from_value::<CreationSource>(json!("remix")).is_err());
|
||||
}
|
||||
|
||||
@@ -1204,9 +1204,10 @@ pub(crate) async fn upload_local_project_game_package(
|
||||
let verify_handle = app.clone();
|
||||
let verify_version_id = version_id.clone();
|
||||
let verify_publish_id = progress_publish_id.clone();
|
||||
crate::game_package_upload::upload_staged_game_package(
|
||||
crate::game_package_upload::upload_staged_version_asset(
|
||||
&client,
|
||||
crate::game_package_upload::GamePackageUploadRequest {
|
||||
asset: crate::game_package_upload::PackageUploadAsset::ReleasePackage,
|
||||
staging_path: &resolved_path,
|
||||
version_id: &version_id,
|
||||
api_base_url: api_base_url.trim(),
|
||||
|
||||
@@ -8,6 +8,7 @@ use client_extensions::*;
|
||||
use environment_check::preflight_web_game_creation;
|
||||
use error_report::*;
|
||||
use game_distribution_publish::*;
|
||||
use game_fork::*;
|
||||
use plugin_host::{
|
||||
call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel,
|
||||
refresh_agc_plugins, reload_agc_plugin, set_agc_plugin_enabled, set_agc_plugin_project_path,
|
||||
@@ -390,6 +391,13 @@ pub(super) fn run() {
|
||||
)));
|
||||
let setup_log = Arc::clone(&startup_log);
|
||||
let app = tauri::Builder::default()
|
||||
// 单实例必须第一个注册:Windows/Linux 的深链是「带 URL 启动第二个进程」,
|
||||
// 单实例插件把第二个实例的 argv 转交给已有实例(`deep-link` feature 会让 deep-link
|
||||
// 插件据此发事件),否则用户点第二次链接会再开一个窗口而不是回到已有窗口。
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||
game_fork::focus_main_window_for_deep_link(app);
|
||||
}))
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
@@ -527,6 +535,8 @@ pub(super) fn run() {
|
||||
)
|
||||
})?;
|
||||
setup_log.append("startup.runner.start.complete");
|
||||
// Fork 深链:先取冷启动 URL,再订阅后续链接(并补一次协议注册)。
|
||||
game_fork::initialize_fork_deep_link(app.handle());
|
||||
setup_log.append("startup.setup.complete");
|
||||
Ok(())
|
||||
})
|
||||
@@ -539,6 +549,7 @@ pub(super) fn run() {
|
||||
stop_game_creator_external_mcp,
|
||||
create_automatic_local_game_project,
|
||||
create_automatic_local_game_project_from_template,
|
||||
create_local_project_from_platform_game,
|
||||
init_local_game_project,
|
||||
fetch_game_template_library,
|
||||
get_game_template_library_access,
|
||||
@@ -612,6 +623,10 @@ pub(super) fn run() {
|
||||
suggest_game_distribution_publish_metadata,
|
||||
read_game_cover_generation_price,
|
||||
generate_game_distribution_cover,
|
||||
list_platform_game_catalog,
|
||||
read_public_game_cover_preview,
|
||||
read_public_game_detail,
|
||||
read_local_project_fork_source,
|
||||
read_game_distribution_publication,
|
||||
publish_local_project_game,
|
||||
read_game_creator_app_config,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
// 桌面 Fork 取件接入;摘要校验、参考副本落点与来源记录规则保留在父模块供现有测试验证。
|
||||
use super::*;
|
||||
use crate::game_distribution_publish::{fetch_platform_game_fork_source, require_platform_session};
|
||||
use serde_json::{json, Value};
|
||||
use shared_contracts::game_distribution::GameDistributionForkSourceKind;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
|
||||
/// 深链事件名:渲染层订阅同名事件,收到后预填 Fork 入口(**不**自动开始下载)。
|
||||
pub(crate) const FORK_DEEP_LINK_EVENT: &str = "game-fork-deep-link";
|
||||
|
||||
/// 主窗口标签,与 `tauri.conf.json` 的 `app.windows[0].label` 一致。
|
||||
const MAIN_WINDOW_LABEL: &str = "client";
|
||||
|
||||
/// 把主窗口带到前台:深链可能来自浏览器点击,用户期望看到的正是这个窗口。
|
||||
pub(crate) fn focus_main_window_for_deep_link(app: &tauri::AppHandle) {
|
||||
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
|
||||
app_log!("game.fork.deep_link.window_missing: 找不到主窗口 {MAIN_WINDOW_LABEL}");
|
||||
return;
|
||||
};
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
|
||||
/// 处理一条深链:解析出 gameId 就交给渲染层预填,解析失败就把原因交给渲染层展示。
|
||||
///
|
||||
/// 两种结果都先聚焦窗口——用户点了链接却什么都没发生是最糟的失败方式。
|
||||
fn handle_fork_deep_link_url(app: &tauri::AppHandle, raw_url: &str) {
|
||||
focus_main_window_for_deep_link(app);
|
||||
let payload = match fork_game_id_from_deep_link(raw_url) {
|
||||
Ok(game_id) => json!({ "gameId": game_id, "message": Value::Null }),
|
||||
Err(message) => {
|
||||
app_log!("game.fork.deep_link.invalid: {message}");
|
||||
json!({ "gameId": Value::Null, "message": message })
|
||||
}
|
||||
};
|
||||
if let Err(error) = app.emit(FORK_DEEP_LINK_EVENT, payload) {
|
||||
app_log!("game.fork.deep_link.emit_failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册并接管 Fork 深链。
|
||||
///
|
||||
/// - **冷启动**(Windows/Linux):URL 是启动参数,deep-link 插件在插件初始化阶段就解析并暂存,
|
||||
/// 而应用的 `setup` 晚于插件,所以这里先取「当前值」,再订阅后续链接。
|
||||
/// - **已运行**:Windows/Linux 会再起一个进程,由 `tauri-plugin-single-instance`(带 `deep-link`
|
||||
/// feature)把第二个实例的 argv 转交给已有实例,交给 deep-link 插件发事件;macOS 由系统把
|
||||
/// URL 交给已运行的 .app。
|
||||
/// - **协议注册**:Windows 写 `HKCU\Software\Classes\<scheme>`、Linux 写用户级 `.desktop`
|
||||
/// (macOS 由打包时注入 `Info.plist` 的 `CFBundleURLTypes`)。这里运行时再注册一次,覆盖
|
||||
/// 便携/未按安装器安装的场景;失败只记日志,不影响启动。
|
||||
pub(crate) fn initialize_fork_deep_link(app: &tauri::AppHandle) {
|
||||
match app.deep_link().get_current() {
|
||||
Ok(Some(urls)) => {
|
||||
for url in urls {
|
||||
handle_fork_deep_link_url(app, url.as_str());
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => app_log!("game.fork.deep_link.current_failed: {error}"),
|
||||
}
|
||||
let handle = app.clone();
|
||||
app.deep_link().on_open_url(move |event| {
|
||||
for url in event.urls() {
|
||||
handle_fork_deep_link_url(&handle, url.as_str());
|
||||
}
|
||||
});
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
if let Err(error) = app.deep_link().register_all() {
|
||||
app_log!("game.fork.deep_link.register_failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Fork 工程同步进度事件:每进入一个**真实阶段**发一次,渲染层据此渲染状态条。
|
||||
pub(crate) const FORK_PROGRESS_EVENT: &str = "game-fork-progress";
|
||||
|
||||
/// 把阶段透给渲染层;发事件失败只记日志,绝不影响 Fork 本身。
|
||||
fn emit_fork_stage(app: &tauri::AppHandle, stage: ForkSyncStage) {
|
||||
if let Err(error) = app.emit(
|
||||
FORK_PROGRESS_EVENT,
|
||||
json!({ "stage": stage.key(), "label": stage.label() }),
|
||||
) {
|
||||
app_log!("game.fork.progress.emit_failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 读本项目的改编来源记录(`.agent/fork-source.json`)。
|
||||
///
|
||||
/// 发布面板用它判断「这是不是衍生作品」:衍生作品的「本次核心改动说明」必填、授权档位继承父作品
|
||||
/// 且不可选(服务端口径)。文件缺失/损坏按「不是衍生作品」处理(`None`),与发布链路读它时的口径
|
||||
/// 一致——同一个可选元数据文件不该让面板报错。
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_fork_source(
|
||||
project_path: String,
|
||||
) -> Result<Option<ProjectForkSourceRecord>, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
if root.as_os_str().is_empty() {
|
||||
return Err("读取改编来源需要本地项目路径".to_string());
|
||||
}
|
||||
Ok(read_project_fork_source(root))
|
||||
}
|
||||
|
||||
/// 从平台作品 Fork:按取件形态建成项目。
|
||||
///
|
||||
/// - `source: "project"`(工程源包):解压即得作者的工程,再走标准初始化补 `.agent` 身份。
|
||||
/// - `source: "package"`(成品包):平台只有成品时生成脚手架,成品放进
|
||||
/// `reference/<gameId>/`。
|
||||
///
|
||||
/// 阶段(渲染层状态条只显示这些真实发生过的阶段,不发假百分比):
|
||||
/// 取回作品内容 → 校验摘要与字节 → 解压到项目 → 生成工程与来源记录。
|
||||
///
|
||||
/// 失败关闭点(按顺序):
|
||||
/// 1. 没有平台登录态 → 直接给出可操作的登录提示,不发起任何请求;
|
||||
/// 2. 取件元数据/下载失败(404 / 409 / 403 / 401)→ 原样返回可区分的错误,不建目录;
|
||||
/// 3. 字节数或 SHA-256 与元数据不符 → 在解压**之前**失败,不落盘;
|
||||
/// 4. 建项或解压途中失败 → 删除半成品项目目录。
|
||||
#[tauri::command]
|
||||
pub(crate) async fn create_local_project_from_platform_game(
|
||||
app: tauri::AppHandle,
|
||||
game_id: String,
|
||||
name: Option<String>,
|
||||
planning: Option<bool>,
|
||||
projects_root: Option<String>,
|
||||
) -> Result<InitLocalProjectResult, String> {
|
||||
let analytics_context = crate::analytics::gui::capture_analytics_context();
|
||||
let game_id = game_id.trim().to_string();
|
||||
if game_id.is_empty() {
|
||||
return Err("请先选择要 Fork 的平台作品".to_string());
|
||||
}
|
||||
// 先冻结会话身份:取件与建项之间换号必须失败关闭,不能把 A 的取件结果落成 B 的项目。
|
||||
let session = require_platform_session()?;
|
||||
let identity = session.identity();
|
||||
let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?;
|
||||
emit_fork_stage(&app, ForkSyncStage::Download);
|
||||
let (source, package_bytes) = fetch_platform_game_fork_source(&game_id).await?;
|
||||
emit_fork_stage(&app, ForkSyncStage::Verify);
|
||||
verify_fork_source_bytes(&package_bytes, &source.sha256, source.bytes)?;
|
||||
let stage_handle = app.clone();
|
||||
let result = with_validated_platform_session_identity(&identity, || {
|
||||
create_project_from_platform_fork_at(
|
||||
&projects_root,
|
||||
&source.game_id,
|
||||
&source.version_id,
|
||||
match source.source {
|
||||
// 取件形态决定建项方式:工程源包解压即工程;成品包生成脚手架 + 参考副本。
|
||||
GameDistributionForkSourceKind::Project => ProjectForkSourceKind::Project,
|
||||
GameDistributionForkSourceKind::Package => ProjectForkSourceKind::Package,
|
||||
},
|
||||
&package_bytes,
|
||||
name.as_deref(),
|
||||
planning.unwrap_or(false),
|
||||
&|stage| emit_fork_stage(&stage_handle, stage),
|
||||
)
|
||||
});
|
||||
if let Ok(project) = &result {
|
||||
crate::analytics::gui::created(
|
||||
analytics_context,
|
||||
Path::new(&project.project_path),
|
||||
project.manifest.project_id.clone(),
|
||||
crate::analytics::contract::CreationSource::PlatformGame,
|
||||
// 平台作品 ID 作为来源标识:与模板链路的 `template_id` 位置同义(创建来源的稳定 ID)。
|
||||
Some(source.game_id.clone()),
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -15,8 +15,9 @@ use serde_json::Value;
|
||||
|
||||
mod runtime;
|
||||
pub(crate) use runtime::{
|
||||
game_package_upload_staging_dir, progress_event_payload, upload_staged_game_package,
|
||||
GamePackageUploadOutcome, GamePackageUploadRequest, GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
|
||||
game_package_upload_staging_dir, progress_event_payload, upload_staged_version_asset,
|
||||
GamePackageUploadOutcome, GamePackageUploadRequest, PackageUploadAsset,
|
||||
GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
|
||||
};
|
||||
|
||||
/// 已暂存(归一化后)的发行包:内容寻址,重启后同一包复用同一个文件,续传才有意义。
|
||||
@@ -104,10 +105,11 @@ fn next_chunk_plan(received: u64, total: u64, chunk_bytes: u64) -> Option<ChunkP
|
||||
})
|
||||
}
|
||||
|
||||
fn upload_url(base_url: &str, version_id: &str, suffix: &str) -> String {
|
||||
fn upload_url(base_url: &str, version_id: &str, asset: PackageUploadAsset, suffix: &str) -> String {
|
||||
format!(
|
||||
"{}/api/game-distribution/versions/{version_id}/package{suffix}",
|
||||
base_url.trim_end_matches('/')
|
||||
"{}/api/game-distribution/versions/{version_id}/{}{suffix}",
|
||||
base_url.trim_end_matches('/'),
|
||||
asset.url_segment()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -181,6 +183,45 @@ mod tests {
|
||||
assert!(next_chunk_plan(0, total, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_asset_routes_and_offset_codes_stay_apart() {
|
||||
// 两条资产走同一段续传逻辑,URL 资源段与「偏移不符」错误码必须各自独立:
|
||||
// 判错码会让偏移续传认不出服务端给的位置。
|
||||
assert_eq!(
|
||||
upload_url(
|
||||
"https://dev.test/",
|
||||
"version_1",
|
||||
PackageUploadAsset::ReleasePackage,
|
||||
"/chunk"
|
||||
),
|
||||
"https://dev.test/api/game-distribution/versions/version_1/package/chunk"
|
||||
);
|
||||
assert_eq!(
|
||||
upload_url(
|
||||
"https://dev.test",
|
||||
"version_1",
|
||||
PackageUploadAsset::ProjectBundle,
|
||||
"/upload-state"
|
||||
),
|
||||
"https://dev.test/api/game-distribution/versions/version_1/project-bundle/upload-state"
|
||||
);
|
||||
assert_eq!(
|
||||
PackageUploadAsset::ReleasePackage.offset_mismatch_code(),
|
||||
"PACKAGE_UPLOAD_OFFSET_MISMATCH"
|
||||
);
|
||||
assert_eq!(
|
||||
PackageUploadAsset::ProjectBundle.offset_mismatch_code(),
|
||||
"PROJECT_BUNDLE_UPLOAD_OFFSET_MISMATCH"
|
||||
);
|
||||
// 只有发行包在创建版本时预登记了大小。
|
||||
assert!(PackageUploadAsset::ReleasePackage.has_declared_bytes());
|
||||
assert!(!PackageUploadAsset::ProjectBundle.has_declared_bytes());
|
||||
// 文案必须能区分是哪条资产失败了(面板据此提示)。
|
||||
assert!(PackageUploadAsset::ProjectBundle
|
||||
.label()
|
||||
.contains("工程源包"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_mismatch_response_yields_authoritative_position() {
|
||||
let body = r#"{"error":{"code":"PACKAGE_UPLOAD_OFFSET_MISMATCH","message":"偏移不一致","details":{"provider":"game-distribution","receivedBytes":16777216}}}"#;
|
||||
@@ -201,11 +242,21 @@ mod tests {
|
||||
#[test]
|
||||
fn upload_url_joins_base_without_double_slash() {
|
||||
assert_eq!(
|
||||
upload_url("https://dev.genarrative.world/", "ver-1", "/chunk"),
|
||||
upload_url(
|
||||
"https://dev.genarrative.world/",
|
||||
"ver-1",
|
||||
PackageUploadAsset::ReleasePackage,
|
||||
"/chunk"
|
||||
),
|
||||
"https://dev.genarrative.world/api/game-distribution/versions/ver-1/package/chunk"
|
||||
);
|
||||
assert_eq!(
|
||||
upload_url("http://127.0.0.1:10001", "ver 1", "/complete"),
|
||||
upload_url(
|
||||
"http://127.0.0.1:10001",
|
||||
"ver 1",
|
||||
PackageUploadAsset::ReleasePackage,
|
||||
"/complete"
|
||||
),
|
||||
"http://127.0.0.1:10001/api/game-distribution/versions/ver 1/package/complete"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,48 @@ pub(crate) struct GamePackageUploadOutcome {
|
||||
pub(crate) uploaded_bytes: u64,
|
||||
}
|
||||
|
||||
/// 可续传上传的版本级资产:发行包与工程源包共用同一条分片续传链路,只有资源段与文案不同。
|
||||
///
|
||||
/// 两侧服务端路由同形,但**错误码不同**(`PACKAGE_UPLOAD_OFFSET_MISMATCH` /
|
||||
/// `PROJECT_BUNDLE_UPLOAD_OFFSET_MISMATCH`),偏移续传必须按资产判码,不能靠子串猜。
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub(crate) enum PackageUploadAsset {
|
||||
ReleasePackage,
|
||||
ProjectBundle,
|
||||
}
|
||||
|
||||
impl PackageUploadAsset {
|
||||
/// URL 里的资源段:`…/versions/{version_id}/<segment>`。
|
||||
pub(super) fn url_segment(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReleasePackage => "package",
|
||||
Self::ProjectBundle => "project-bundle",
|
||||
}
|
||||
}
|
||||
|
||||
/// 面向用户的资产名。
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReleasePackage => "发行包",
|
||||
Self::ProjectBundle => "工程源包",
|
||||
}
|
||||
}
|
||||
|
||||
/// 服务端「偏移不符」错误码;偏移续传按它取权威偏移。
|
||||
pub(super) fn offset_mismatch_code(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReleasePackage => "PACKAGE_UPLOAD_OFFSET_MISMATCH",
|
||||
Self::ProjectBundle => "PROJECT_BUNDLE_UPLOAD_OFFSET_MISMATCH",
|
||||
}
|
||||
}
|
||||
|
||||
/// 只有发行包在创建版本时预登记了大小;工程源包没有,因此不做「本地体积 vs 声明体积」校验。
|
||||
pub(super) fn has_declared_bytes(self) -> bool {
|
||||
matches!(self, Self::ReleasePackage)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PackageUploadState {
|
||||
@@ -43,12 +85,16 @@ pub(crate) fn game_package_upload_staging_dir(app_data_dir: &Path) -> PathBuf {
|
||||
app_data_dir.join(UPLOAD_STAGING_DIR_NAME)
|
||||
}
|
||||
|
||||
fn read_chunk(file: &mut File, plan: ChunkPlan) -> Result<Vec<u8>, String> {
|
||||
fn read_chunk(
|
||||
file: &mut File,
|
||||
plan: ChunkPlan,
|
||||
asset: PackageUploadAsset,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
file.seek(SeekFrom::Start(plan.offset))
|
||||
.map_err(|error| format!("读取发行包失败:{error}"))?;
|
||||
.map_err(|error| format!("读取{}失败:{error}", asset.label()))?;
|
||||
let mut buffer = vec![0_u8; usize::try_from(plan.length).unwrap_or(0)];
|
||||
file.read_exact(&mut buffer)
|
||||
.map_err(|error| format!("读取发行包失败:{error}"))?;
|
||||
.map_err(|error| format!("读取{}失败:{error}", asset.label()))?;
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
@@ -80,52 +126,57 @@ pub(crate) enum GamePackageUploadStep {
|
||||
}
|
||||
|
||||
impl GamePackageUploadStep {
|
||||
fn label(self) -> &'static str {
|
||||
fn label(self, asset: PackageUploadAsset) -> String {
|
||||
match self {
|
||||
Self::ReadUploadState => "读取上传状态失败",
|
||||
Self::UploadChunk => "发行包分片上传失败",
|
||||
Self::CompleteUpload => "完成发行包上传失败",
|
||||
Self::ReadUploadState => format!("读取{}上传状态失败", asset.label()),
|
||||
Self::UploadChunk => format!("{}分片上传失败", asset.label()),
|
||||
Self::CompleteUpload => format!("完成{}上传失败", asset.label()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 发行包上传失败:`step` 说明是哪一步,`detail` 是这一步的原始事实(服务端 message / code /
|
||||
/// `HTTP {status}`)。
|
||||
/// 上传失败:`asset` + `step` 说明是哪条资产的哪一步,`detail` 是这一步的原始事实
|
||||
/// (服务端 message / code / `HTTP {status}`)。
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub(crate) struct GamePackageUploadError {
|
||||
pub(crate) asset: PackageUploadAsset,
|
||||
pub(crate) step: GamePackageUploadStep,
|
||||
pub(crate) detail: String,
|
||||
}
|
||||
|
||||
impl GamePackageUploadError {
|
||||
fn read_upload_state(detail: impl Into<String>) -> Self {
|
||||
fn new(
|
||||
asset: PackageUploadAsset,
|
||||
step: GamePackageUploadStep,
|
||||
detail: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
step: GamePackageUploadStep::ReadUploadState,
|
||||
asset,
|
||||
step,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upload_chunk(detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
step: GamePackageUploadStep::UploadChunk,
|
||||
detail: detail.into(),
|
||||
}
|
||||
fn read_upload_state(asset: PackageUploadAsset, detail: impl Into<String>) -> Self {
|
||||
Self::new(asset, GamePackageUploadStep::ReadUploadState, detail)
|
||||
}
|
||||
|
||||
fn complete_upload(detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
step: GamePackageUploadStep::CompleteUpload,
|
||||
detail: detail.into(),
|
||||
}
|
||||
fn upload_chunk(asset: PackageUploadAsset, detail: impl Into<String>) -> Self {
|
||||
Self::new(asset, GamePackageUploadStep::UploadChunk, detail)
|
||||
}
|
||||
|
||||
fn complete_upload(asset: PackageUploadAsset, detail: impl Into<String>) -> Self {
|
||||
Self::new(asset, GamePackageUploadStep::CompleteUpload, detail)
|
||||
}
|
||||
|
||||
/// 给用户/模型的那句:步骤 + 事实。步骤来自 `step` 枚举,不从调用点硬拼。
|
||||
pub(crate) fn to_user_msg(&self) -> String {
|
||||
let label = self.step.label(self.asset);
|
||||
let detail = self.detail.trim();
|
||||
if detail.is_empty() {
|
||||
self.step.label().to_string()
|
||||
label
|
||||
} else {
|
||||
format!("{}:{detail}", self.step.label())
|
||||
format!("{label}:{detail}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,36 +193,38 @@ async fn read_upload_state(
|
||||
base_url: &str,
|
||||
version_id: &str,
|
||||
access_token: &str,
|
||||
asset: PackageUploadAsset,
|
||||
) -> Result<PackageUploadState, GamePackageUploadError> {
|
||||
let response = platform_request(
|
||||
client,
|
||||
access_token,
|
||||
reqwest::Method::GET,
|
||||
&upload_url(base_url, version_id, "/upload-state"),
|
||||
&upload_url(base_url, version_id, asset, "/upload-state"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
GamePackageUploadError::read_upload_state(format!(
|
||||
"无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"
|
||||
))
|
||||
GamePackageUploadError::read_upload_state(
|
||||
asset,
|
||||
format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"),
|
||||
)
|
||||
})?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| GamePackageUploadError::read_upload_state(format!("{error}")))?;
|
||||
.map_err(|error| GamePackageUploadError::read_upload_state(asset, format!("{error}")))?;
|
||||
if status >= 400 {
|
||||
let (code, message) = parse_server_error(status, &body);
|
||||
let detail = server_error_detail(code, message, || format!("HTTP {status}"));
|
||||
return Err(GamePackageUploadError::read_upload_state(detail));
|
||||
return Err(GamePackageUploadError::read_upload_state(asset, detail));
|
||||
}
|
||||
let parsed: Value = serde_json::from_str(&body).map_err(|error| {
|
||||
GamePackageUploadError::read_upload_state(format!("响应不是合法 JSON:{error}"))
|
||||
GamePackageUploadError::read_upload_state(asset, format!("响应不是合法 JSON:{error}"))
|
||||
})?;
|
||||
let payload = parsed.get("data").unwrap_or(&parsed);
|
||||
serde_json::from_value(payload.clone()).map_err(|error| {
|
||||
GamePackageUploadError::read_upload_state(format!("响应缺少字段:{error}"))
|
||||
GamePackageUploadError::read_upload_state(asset, format!("响应缺少字段:{error}"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -182,6 +235,7 @@ async fn upload_chunk(
|
||||
version_id: &str,
|
||||
access_token: &str,
|
||||
idempotency_key: &str,
|
||||
asset: PackageUploadAsset,
|
||||
plan: ChunkPlan,
|
||||
body: Vec<u8>,
|
||||
) -> Result<u64, ChunkUploadError> {
|
||||
@@ -189,7 +243,7 @@ async fn upload_chunk(
|
||||
client,
|
||||
access_token,
|
||||
reqwest::Method::PUT,
|
||||
&upload_url(base_url, version_id, "/chunk"),
|
||||
&upload_url(base_url, version_id, asset, "/chunk"),
|
||||
)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(UPLOAD_OFFSET_HEADER, plan.offset.to_string())
|
||||
@@ -198,19 +252,24 @@ async fn upload_chunk(
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!(
|
||||
"请求未送达:{error}"
|
||||
)))
|
||||
ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(
|
||||
asset,
|
||||
format!("请求未送达:{error}"),
|
||||
))
|
||||
})?;
|
||||
let status = response.status().as_u16();
|
||||
let text = response.text().await.map_err(|error| {
|
||||
ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!("{error}")))
|
||||
ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(
|
||||
asset,
|
||||
format!("{error}"),
|
||||
))
|
||||
})?;
|
||||
if status == 409 {
|
||||
let (code, message) = parse_server_error(status, &text);
|
||||
if code.as_deref() == Some("PACKAGE_UPLOAD_OFFSET_MISMATCH") {
|
||||
if code.as_deref() == Some(asset.offset_mismatch_code()) {
|
||||
let received = parse_received_bytes(&text).ok_or_else(|| {
|
||||
ChunkUploadError::Fatal(GamePackageUploadError::upload_chunk(
|
||||
asset,
|
||||
"分片偏移不一致,但服务端未返回权威偏移",
|
||||
))
|
||||
})?;
|
||||
@@ -218,27 +277,28 @@ async fn upload_chunk(
|
||||
}
|
||||
let detail = server_error_detail(code, message, || format!("HTTP {status}"));
|
||||
return Err(ChunkUploadError::Fatal(
|
||||
GamePackageUploadError::upload_chunk(detail),
|
||||
GamePackageUploadError::upload_chunk(asset, detail),
|
||||
));
|
||||
}
|
||||
if status >= 500 || status == 408 || status == 429 {
|
||||
let (code, message) = parse_server_error(status, &text);
|
||||
let detail = server_error_detail(code, message, || format!("HTTP {status}"));
|
||||
return Err(ChunkUploadError::Retryable(
|
||||
GamePackageUploadError::upload_chunk(detail),
|
||||
GamePackageUploadError::upload_chunk(asset, detail),
|
||||
));
|
||||
}
|
||||
if status >= 400 {
|
||||
let (code, message) = parse_server_error(status, &text);
|
||||
let detail = server_error_detail(code, message, || format!("HTTP {status}"));
|
||||
return Err(ChunkUploadError::Fatal(
|
||||
GamePackageUploadError::upload_chunk(detail),
|
||||
GamePackageUploadError::upload_chunk(asset, detail),
|
||||
));
|
||||
}
|
||||
let parsed: Value = serde_json::from_str(&text).map_err(|error| {
|
||||
ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!(
|
||||
"响应不是合法 JSON:{error}"
|
||||
)))
|
||||
ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(
|
||||
asset,
|
||||
format!("响应不是合法 JSON:{error}"),
|
||||
))
|
||||
})?;
|
||||
let payload = parsed.get("data").unwrap_or(&parsed);
|
||||
payload
|
||||
@@ -246,6 +306,7 @@ async fn upload_chunk(
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| {
|
||||
ChunkUploadError::Fatal(GamePackageUploadError::upload_chunk(
|
||||
asset,
|
||||
"响应缺少 receivedBytes",
|
||||
))
|
||||
})
|
||||
@@ -262,29 +323,32 @@ async fn complete_upload(
|
||||
version_id: &str,
|
||||
access_token: &str,
|
||||
idempotency_key: &str,
|
||||
asset: PackageUploadAsset,
|
||||
) -> Result<GamePackageUploadOutcome, GamePackageUploadError> {
|
||||
let response = platform_request(
|
||||
client,
|
||||
access_token,
|
||||
reqwest::Method::POST,
|
||||
&upload_url(base_url, version_id, "/complete"),
|
||||
&upload_url(base_url, version_id, asset, "/complete"),
|
||||
)
|
||||
.header("Idempotency-Key", format!("{idempotency_key}:complete"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| GamePackageUploadError::complete_upload(format!("请求未送达:{error}")))?;
|
||||
.map_err(|error| {
|
||||
GamePackageUploadError::complete_upload(asset, format!("请求未送达:{error}"))
|
||||
})?;
|
||||
let status = response.status().as_u16();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| GamePackageUploadError::complete_upload(format!("{error}")))?;
|
||||
.map_err(|error| GamePackageUploadError::complete_upload(asset, format!("{error}")))?;
|
||||
if status >= 400 {
|
||||
let (code, message) = parse_server_error(status, &text);
|
||||
let detail = server_error_detail(code, message, || format!("HTTP {status}"));
|
||||
return Err(GamePackageUploadError::complete_upload(detail));
|
||||
return Err(GamePackageUploadError::complete_upload(asset, detail));
|
||||
}
|
||||
let parsed: Value = serde_json::from_str(&text).map_err(|error| {
|
||||
GamePackageUploadError::complete_upload(format!("响应不是合法 JSON:{error}"))
|
||||
GamePackageUploadError::complete_upload(asset, format!("响应不是合法 JSON:{error}"))
|
||||
})?;
|
||||
let payload = parsed.get("data").unwrap_or(&parsed);
|
||||
Ok(GamePackageUploadOutcome {
|
||||
@@ -303,6 +367,7 @@ async fn complete_upload(
|
||||
}
|
||||
|
||||
pub(crate) struct GamePackageUploadRequest<'a> {
|
||||
pub(crate) asset: PackageUploadAsset,
|
||||
pub(crate) staging_path: &'a Path,
|
||||
pub(crate) version_id: &'a str,
|
||||
pub(crate) api_base_url: &'a str,
|
||||
@@ -325,17 +390,22 @@ impl<'a> GamePackageUploadRequest<'a> {
|
||||
}
|
||||
|
||||
/// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。
|
||||
pub(crate) async fn upload_staged_game_package(
|
||||
///
|
||||
/// 发行包与工程源包共用这一段:差异全部收在 `request.asset`(URL 资源段、文案、偏移错误码、
|
||||
/// 是否有预登记大小)。
|
||||
pub(crate) async fn upload_staged_version_asset(
|
||||
client: &reqwest::Client,
|
||||
request: GamePackageUploadRequest<'_>,
|
||||
mut on_progress: impl FnMut(u64, u64),
|
||||
mut on_verify: impl FnMut(),
|
||||
) -> Result<GamePackageUploadOutcome, String> {
|
||||
let asset = request.asset;
|
||||
let label = asset.label();
|
||||
let total_bytes = fs::metadata(request.staging_path)
|
||||
.map_err(|error| format!("读取发行包暂存文件失败:{error}"))?
|
||||
.map_err(|error| format!("读取{label}暂存文件失败:{error}"))?
|
||||
.len();
|
||||
if total_bytes == 0 {
|
||||
return Err("发行包暂存文件为空,请重新导出试玩包".to_string());
|
||||
return Err(format!("{label}暂存文件为空,请重新导出后再发布"));
|
||||
}
|
||||
let access_token = request.current_access_token()?;
|
||||
let state = read_upload_state(
|
||||
@@ -343,11 +413,16 @@ pub(crate) async fn upload_staged_game_package(
|
||||
request.api_base_url,
|
||||
request.version_id,
|
||||
&access_token,
|
||||
asset,
|
||||
)
|
||||
.await?;
|
||||
if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes {
|
||||
// 只有发行包在创建版本时预登记了大小;工程源包的体积由 complete 时的整包校验确定。
|
||||
if asset.has_declared_bytes()
|
||||
&& state.declared_package_bytes != 0
|
||||
&& state.declared_package_bytes != total_bytes
|
||||
{
|
||||
return Err(format!(
|
||||
"本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布",
|
||||
"本地{label}与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布",
|
||||
total_bytes, state.declared_package_bytes
|
||||
));
|
||||
}
|
||||
@@ -359,9 +434,9 @@ pub(crate) async fn upload_staged_game_package(
|
||||
let mut received = state.received_bytes.min(total_bytes);
|
||||
on_progress(received, total_bytes);
|
||||
let mut file = File::open(request.staging_path)
|
||||
.map_err(|error| format!("打开发行包暂存文件失败:{error}"))?;
|
||||
.map_err(|error| format!("打开{label}暂存文件失败:{error}"))?;
|
||||
while let Some(plan) = next_chunk_plan(received, total_bytes, chunk_bytes) {
|
||||
let body = read_chunk(&mut file, plan)?;
|
||||
let body = read_chunk(&mut file, plan, asset)?;
|
||||
let mut attempt = 1_usize;
|
||||
loop {
|
||||
let access_token = request.current_access_token()?;
|
||||
@@ -371,6 +446,7 @@ pub(crate) async fn upload_staged_game_package(
|
||||
request.version_id,
|
||||
&access_token,
|
||||
request.idempotency_key,
|
||||
asset,
|
||||
plan,
|
||||
body.clone(),
|
||||
)
|
||||
@@ -401,6 +477,7 @@ pub(crate) async fn upload_staged_game_package(
|
||||
request.api_base_url,
|
||||
request.version_id,
|
||||
&access_token,
|
||||
asset,
|
||||
)
|
||||
.await?;
|
||||
received = authoritative.received_bytes.min(total_bytes);
|
||||
@@ -414,6 +491,7 @@ pub(crate) async fn upload_staged_game_package(
|
||||
request.version_id,
|
||||
&access_token,
|
||||
request.idempotency_key,
|
||||
asset,
|
||||
)
|
||||
.await?;
|
||||
outcome.uploaded_bytes = total_bytes;
|
||||
|
||||
@@ -103,6 +103,7 @@ mod editor_adapters;
|
||||
mod environment_check;
|
||||
pub mod error_report;
|
||||
mod game_distribution_publish;
|
||||
mod game_fork;
|
||||
mod game_package_upload;
|
||||
mod game_publish_attempt;
|
||||
mod git_inspect;
|
||||
@@ -119,6 +120,7 @@ mod process_identity;
|
||||
mod process_session;
|
||||
mod process_session_bridge;
|
||||
mod project;
|
||||
mod project_bundle;
|
||||
mod project_snapshot;
|
||||
mod repository_context;
|
||||
mod resource_inspect;
|
||||
|
||||
@@ -12,6 +12,7 @@ mod conversation;
|
||||
mod export;
|
||||
mod external_editor_bindings;
|
||||
mod filesystem;
|
||||
mod fork_source;
|
||||
mod manifest;
|
||||
mod memory;
|
||||
mod model_usage;
|
||||
@@ -32,6 +33,7 @@ pub(crate) use conversation::*;
|
||||
pub(crate) use export::*;
|
||||
pub(crate) use external_editor_bindings::*;
|
||||
pub(crate) use filesystem::*;
|
||||
pub(crate) use fork_source::*;
|
||||
pub(crate) use manifest::*;
|
||||
pub(crate) use memory::*;
|
||||
pub(crate) use model_usage::*;
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! 项目 Fork 来源记录:`.agent/fork-source.json`。
|
||||
//!
|
||||
//! 承载「本项目 Fork 自平台作品 X」这一事实:由「从平台作品 Fork」在取件校验通过、
|
||||
//! 建项完成之后写入,由发布链路的首次发布读取并并入创建作品请求的 `fork` 声明。
|
||||
//!
|
||||
//! 落点选择(与 `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` §3.5.1 一致):
|
||||
//! - `.agent/**` 随项目快照上云、随用户整目录拷贝,是唯一既「随行」又不干扰游戏产物的位置;
|
||||
//! - 导出包(`exports/playtest-package-*.zip`)**不**透传 `.agent/`:这是已知取舍——
|
||||
//! 血缘不需要随试玩包分发,平台侧的血缘在创建作品时一次性写入;
|
||||
//! - 不改 `manifest.json`:它是 `deny_unknown_fields` 且只接受 v1,加顶层字段等于改契约。
|
||||
//!
|
||||
//! 读取必须是「可选项语义」:文件缺失、损坏、字段不合法或路径不合格一律按「没有来源记录」
|
||||
//! 处理(返回 `None`),绝不能让一个可选元数据文件把发布整体拖失败。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 当前写入的记录格式版本。
|
||||
///
|
||||
/// v2 起多带 `source`(取件形态),v1 记录**仍必须能读**:老项目里已有 v1 文件,
|
||||
/// 读不回来就等于把「改编自哪个作品」这条事实丢了。
|
||||
pub(crate) const FORK_SOURCE_SCHEMA_VERSION: &str = "agc-fork-source.v2";
|
||||
/// 仍接受的旧版本:v1 没有 `source`,按「成品包」解释(当时只有这一条取件通道)。
|
||||
pub(crate) const FORK_SOURCE_SCHEMA_VERSION_V1: &str = "agc-fork-source.v1";
|
||||
|
||||
/// 取件形态:工程源包(作者的工程)还是已构建成品包。
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum ProjectForkSourceKind {
|
||||
/// 已构建的成品包:AGC 侧生成脚手架,并把成品放进 `reference/<gameId>/`。
|
||||
#[default]
|
||||
Package,
|
||||
/// 工程源包:解压即得作者的工程,不需要再生成脚手架。
|
||||
Project,
|
||||
}
|
||||
|
||||
/// 相对项目根的位置:`.agent` 是项目身份与 Agent 状态的权威目录,整目录随项目同步。
|
||||
pub(crate) const FORK_SOURCE_RELATIVE_PATH: &str = ".agent/fork-source.json";
|
||||
|
||||
const FORK_SOURCE_LABEL: &str = "项目 Fork 来源记录";
|
||||
|
||||
/// 「本项目 Fork 自平台作品」的本地记录。
|
||||
///
|
||||
/// `schema_version` 必填;`game_id` / `version_id` 是平台侧的作品身份与建立血缘时锁定的父版本,
|
||||
/// 发布时原样并入创建作品请求的 `fork` 字段。`source` 从 v2 起记录取件形态;v1 记录没有该字段,
|
||||
/// 读回来按「成品包」(当时只有这一条通道),因此老项目仍能正确声明 Fork 来源。
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(crate) struct ProjectForkSourceRecord {
|
||||
pub(crate) schema_version: String,
|
||||
pub(crate) game_id: String,
|
||||
pub(crate) version_id: String,
|
||||
/// 取件形态;v1 记录缺省 → 成品包。
|
||||
#[serde(default)]
|
||||
pub(crate) source: ProjectForkSourceKind,
|
||||
/// 建项成功的时刻(epoch 毫秒),只作本地审计,不参与任何上传。
|
||||
pub(crate) adopted_at_millis: u64,
|
||||
}
|
||||
|
||||
impl ProjectForkSourceRecord {
|
||||
/// 新建一条来源记录;时间戳由本模块统一生成,调用方不自己造时间。
|
||||
pub(crate) fn new(game_id: &str, version_id: &str, source: ProjectForkSourceKind) -> Self {
|
||||
Self {
|
||||
schema_version: FORK_SOURCE_SCHEMA_VERSION.to_string(),
|
||||
game_id: game_id.to_string(),
|
||||
version_id: version_id.to_string(),
|
||||
source,
|
||||
adopted_at_millis: now_millis(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 来源记录在本项目里的绝对路径。
|
||||
pub(crate) fn fork_source_record_path(project_root: &Path) -> PathBuf {
|
||||
project_root.join(FORK_SOURCE_RELATIVE_PATH)
|
||||
}
|
||||
|
||||
/// 解析来源记录正文:格式版本不认识、字段缺失或标识为空都按「没有来源记录」处理。
|
||||
///
|
||||
/// 抽成纯函数是为了让「损坏不 panic」这条规则可以被单测钉住,而不是散在读取路径里。
|
||||
pub(crate) fn parse_project_fork_source(bytes: &[u8]) -> Option<ProjectForkSourceRecord> {
|
||||
let record: ProjectForkSourceRecord = serde_json::from_slice(bytes).ok()?;
|
||||
// v1 与 v2 都接受:v1 缺 `source`,serde 默认填成品包(见结构体注释)。
|
||||
if record.schema_version != FORK_SOURCE_SCHEMA_VERSION
|
||||
&& record.schema_version != FORK_SOURCE_SCHEMA_VERSION_V1
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if record.game_id.trim().is_empty() || record.version_id.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(record)
|
||||
}
|
||||
|
||||
/// 读取来源记录:缺失、损坏、异形一律 `None`,不返回错误、不 panic。
|
||||
pub(crate) fn read_project_fork_source(project_root: &Path) -> Option<ProjectForkSourceRecord> {
|
||||
let path = fork_source_record_path(project_root);
|
||||
// 复用私有路径门禁:链接、非普通文件或权限不达标的记录都不读,失败即当作「没有记录」。
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, FORK_SOURCE_LABEL).ok()? {
|
||||
return None;
|
||||
}
|
||||
let bytes = fs::read(&path).ok()?;
|
||||
parse_project_fork_source(&bytes)
|
||||
}
|
||||
|
||||
/// 写入来源记录:走 AGC 管理文件的统一入口(校验父目录策略、临时 inode 原子安装)。
|
||||
pub(crate) fn write_project_fork_source(
|
||||
project_root: &Path,
|
||||
record: &ProjectForkSourceRecord,
|
||||
) -> Result<(), String> {
|
||||
let body = serde_json::to_vec_pretty(record)
|
||||
.map_err(|error| format!("序列化 {FORK_SOURCE_LABEL}失败:{error}"))?;
|
||||
write_game_creator_private_file(
|
||||
&fork_source_record_path(project_root),
|
||||
&body,
|
||||
FORK_SOURCE_LABEL,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_root(label: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|elapsed| elapsed.as_nanos())
|
||||
.unwrap_or_default();
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"agc-fork-source-{label}-{}-{nonce}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&root).expect("create temp project root");
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_source_record_round_trips_through_project_agent_directory() {
|
||||
let root = test_root("round-trip");
|
||||
let record = ProjectForkSourceRecord::new(
|
||||
"game_parent_1",
|
||||
"gamever_parent_1",
|
||||
ProjectForkSourceKind::Project,
|
||||
);
|
||||
write_project_fork_source(&root, &record).expect("write fork source record");
|
||||
|
||||
// 落点固定:`.agent/fork-source.json`,不落在项目根,也不进 manifest。
|
||||
assert!(fork_source_record_path(&root).is_file());
|
||||
assert_eq!(
|
||||
fork_source_record_path(&root),
|
||||
root.join(".agent/fork-source.json")
|
||||
);
|
||||
let read = read_project_fork_source(&root).expect("read back");
|
||||
assert_eq!(read, record);
|
||||
assert_eq!(read.schema_version, FORK_SOURCE_SCHEMA_VERSION);
|
||||
assert_eq!(read.source, ProjectForkSourceKind::Project);
|
||||
assert_eq!(read.game_id, "game_parent_1");
|
||||
assert_eq!(read.version_id, "gamever_parent_1");
|
||||
assert!(read.adopted_at_millis > 0);
|
||||
|
||||
// 覆盖写:同一路径第二次写入必须替换而不是失败。
|
||||
let updated = ProjectForkSourceRecord::new(
|
||||
"game_parent_2",
|
||||
"gamever_parent_2",
|
||||
ProjectForkSourceKind::Package,
|
||||
);
|
||||
write_project_fork_source(&root, &updated).expect("overwrite fork source record");
|
||||
assert_eq!(read_project_fork_source(&root), Some(updated));
|
||||
}
|
||||
|
||||
/// v1 记录(M2a 写的,没有 `source`)必须仍能读:否则老项目会丢掉「改编自哪个作品」,
|
||||
/// 发布时静默不带声明。
|
||||
#[test]
|
||||
fn v1_fork_source_record_still_reads_as_package_source() {
|
||||
let root = test_root("v1-compat");
|
||||
let path = fork_source_record_path(&root);
|
||||
write_game_creator_private_file(
|
||||
&path,
|
||||
br#"{"schemaVersion":"agc-fork-source.v1","gameId":"game_legacy","versionId":"gamever_legacy","adoptedAtMillis":123}"#,
|
||||
FORK_SOURCE_LABEL,
|
||||
)
|
||||
.expect("write v1 record");
|
||||
|
||||
let record = read_project_fork_source(&root).expect("v1 记录必须仍可读");
|
||||
assert_eq!(record.schema_version, FORK_SOURCE_SCHEMA_VERSION_V1);
|
||||
assert_eq!(record.game_id, "game_legacy");
|
||||
assert_eq!(record.version_id, "gamever_legacy");
|
||||
// v1 时代只有成品包通道,缺省按成品包解释。
|
||||
assert_eq!(record.source, ProjectForkSourceKind::Package);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fork_source_record_reads_as_none() {
|
||||
let root = test_root("missing");
|
||||
assert_eq!(read_project_fork_source(&root), None);
|
||||
// 连 `.agent` 目录都没有时同样只能是 None,不能报错。
|
||||
assert!(!root.join(".agent").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_fork_source_record_reads_as_none_without_panicking() {
|
||||
let root = test_root("corrupted");
|
||||
let path = fork_source_record_path(&root);
|
||||
for body in [
|
||||
// 不是 JSON
|
||||
b"not-json{".as_slice(),
|
||||
// 不是对象
|
||||
b"[]".as_slice(),
|
||||
// 合法 JSON 但缺字段
|
||||
br#"{"schemaVersion":"agc-fork-source.v1","gameId":"game_1"}"#.as_slice(),
|
||||
// 未知顶层字段(deny_unknown_fields)
|
||||
br#"{"schemaVersion":"agc-fork-source.v1","gameId":"game_1","versionId":"v1","adoptedAtMillis":1,"extra":true}"#.as_slice(),
|
||||
// 完全不认识的格式版本(v1/v2 之外的都按「没有记录」)
|
||||
br#"{"schemaVersion":"agc-fork-source.v9","gameId":"game_1","versionId":"v1","adoptedAtMillis":1}"#.as_slice(),
|
||||
// 已知版本但 source 取值非法
|
||||
br#"{"schemaVersion":"agc-fork-source.v2","gameId":"game_1","versionId":"v1","source":"remix","adoptedAtMillis":1}"#.as_slice(),
|
||||
// 标识为空
|
||||
br#"{"schemaVersion":"agc-fork-source.v1","gameId":" ","versionId":"v1","adoptedAtMillis":1}"#.as_slice(),
|
||||
// 空文件
|
||||
b"".as_slice(),
|
||||
] {
|
||||
write_game_creator_private_file(&path, body, FORK_SOURCE_LABEL)
|
||||
.expect("write corrupted record");
|
||||
assert_eq!(read_project_fork_source(&root), None, "{body:?}");
|
||||
}
|
||||
// 目录顶替文件:不是普通文件,同样只能得到 None。
|
||||
fs::remove_file(&path).expect("remove file");
|
||||
fs::create_dir(&path).expect("create directory in place of record");
|
||||
assert_eq!(read_project_fork_source(&root), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_source_parse_accepts_only_current_schema_and_non_empty_identifiers() {
|
||||
let valid = br#"{"schemaVersion":"agc-fork-source.v1","gameId":"game_1","versionId":"gamever_1","adoptedAtMillis":7}"#;
|
||||
let record = parse_project_fork_source(valid).expect("valid record");
|
||||
assert_eq!(record.adopted_at_millis, 7);
|
||||
assert!(parse_project_fork_source(b"null").is_none());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -622,7 +622,10 @@ pub(crate) fn safe_archive_relative_path(raw: &str) -> Result<PathBuf, String> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn extract_template_archive(bytes: &[u8], destination: &Path) -> Result<usize, String> {
|
||||
/// 解压模板归档到目标目录:条目数、单文件大小、符号链接与条目路径都走同一套门禁。
|
||||
///
|
||||
/// `pub(crate)`:Fork 取件(`game_fork`)复用同一套归档门禁,不另起一份解压实现。
|
||||
pub(crate) fn extract_template_archive(bytes: &[u8], destination: &Path) -> Result<usize, String> {
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes))
|
||||
.map_err(|error| format!("模板包不是有效 zip:{error}"))?;
|
||||
if archive.len() > TEMPLATE_ARCHIVE_MAX_FILES {
|
||||
|
||||
@@ -60,6 +60,11 @@
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["genarrative"]
|
||||
}
|
||||
},
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRFN0NFOEUzNDczNDg4Q0IKUldUTGlEUkg0K2g4VGpaQ3FiTXdoNnJTV0JDSWU4VjQrTkcrMkovS2RleFloUXVhdWZIVGpMOTYK",
|
||||
"endpoints": [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user