合并 master 并接入 DirectProject 新聊天架构

- 合并 origin/master(304 个提交:DirectProject 聊天容器重构、Project Supervisor 退役、策划附件导入、CI 隔离编译缓存等)。
- 接受 master 对 ProjectSupervisorView / SupervisorChatOnlyView 的退役与预览快捷测试收敛;发布入口改由 DirectProject 聊天头承载。
- DirectProjectChatHeader 新增「发布到游戏广场」入口(无回调不渲染、回合忙态禁用),DirectProjectChatView 透传 onRequestGamePublish。
- App.tsx 继续由工作台壳持有试玩包导出与 GameDistributionPublishPanel,沿用 project.export_package 权限确认队列;check-config 把该命令从 native-only 清单移回 App invoke。
- 后台游戏审核 API / 类型 / 路由测试与 master 新增的 AGC 模板管理按双方保留合并,并修掉拼接造成的接口与用例闭合缺陷。
- 修正 master 自带的 viteProxyConfig 断言:/api/creation-entry 属退役路由,测试改为断言不进入代理。
- 记录合并踩坑:语法结构内部的冲突不能简单按「双方保留」拼接,必须按某一侧骨架重建并跑 tsc 与单文件测试。
- 验证:全量 vitest 393 文件 / 4374 用例通过,root / AGC / admin-web 三端 typecheck,cargo check 与游戏分发 Rust 测试,encoding、doc-index、rustfmt、SpacetimeDB schema guard。
This commit is contained in:
2026-09-22 16:45:29 +08:00
651 changed files with 78860 additions and 60952 deletions
@@ -3,8 +3,10 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
createAdminAccount,
executeAdminRechargeRefund,
getAdminAgcTemplates,
getAdminFeatureGateConfig,
getAdminUserDetail,
importAdminAgcTemplates,
listAdminGameDistributionReviews,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
@@ -12,6 +14,7 @@ import {
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
updateAdminAccount,
updateAdminAgcTemplate,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminFeatureGateConfig,
upsertProfileWalletConfig,
@@ -21,6 +24,93 @@ afterEach(() => {
vi.unstubAllGlobals();
});
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
const library = { revision: 'revision-new', writable: true, templates: [] };
const fetchMock = vi.fn().mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: library }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
const controller = new AbortController();
expect(await getAdminAgcTemplates('admin-token', controller.signal)).toEqual(
library,
);
const update = {
expectedRevision: 'revision-old',
title: '空白模板',
summary: '简介',
tags: ['2D'],
enabled: true,
cover: { contentType: 'image/png', dataBase64: 'aW1hZ2U=' },
};
expect(
await updateAdminAgcTemplate('admin-token', 'template/1', update),
).toEqual(library);
expect(fetchMock.mock.calls[0]).toEqual([
'/admin/api/agc-templates',
expect.objectContaining({
method: 'GET',
signal: controller.signal,
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
}),
]);
expect(fetchMock.mock.calls[1]).toEqual([
'/admin/api/agc-templates/template%2F1',
expect.objectContaining({
method: 'PUT',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Content-Type': 'application/json',
}),
body: JSON.stringify(update),
}),
]);
});
test('模板批量导入走 multipart,不预设 JSON Content-Type', async () => {
const imported = {
revision: 'rev-2',
writable: true,
templates: [],
imported: [
{
id: 'alpha',
templateVersion: '0.1.0',
zipSizeBytes: 4,
zipSha256: 'a'.repeat(64),
reusedObjects: false,
},
],
};
const fetchMock = vi.fn().mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: imported }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
const form = new FormData();
form.append(
'manifest',
JSON.stringify({ expectedRevision: 'rev-1', templates: [] }),
);
form.append(
'zip_0',
new File([new Uint8Array([1])], 'alpha.zip', { type: 'application/zip' }),
);
expect(await importAdminAgcTemplates('admin-token', form)).toEqual(imported);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('/admin/api/agc-templates/import');
expect(init.method).toBe('POST');
expect(init.body).toBe(form);
expect(init.headers).not.toHaveProperty('Content-Type');
expect(init.headers.Authorization).toBe('Bearer admin-token');
});
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
+54 -1
View File
@@ -1,5 +1,6 @@
import type {
AdminAccountListResponse,
AdminAgcTemplateLibraryResponse,
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
@@ -32,9 +33,11 @@ import type {
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
AdminImportAgcTemplatesResponse,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminProjectSnapshotChannelsResponse,
AdminProjectSnapshotListQuery,
AdminProjectSnapshotListResponse,
AdminRechargeOrderListQuery,
@@ -50,6 +53,7 @@ import type {
AdminTrackingEventListResponse,
AdminUpdateAccountRequest,
AdminUpdateAccountResponse,
AdminUpdateAgcTemplateRequest,
AdminUploadedEditorShowcaseCampaignImage,
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest,
@@ -88,6 +92,8 @@ interface AdminRequestOptions {
method?: string;
token?: string;
body?: unknown;
/** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */
formData?: FormData;
headers?: Record<string, string>;
signal?: AbortSignal;
}
@@ -175,6 +181,8 @@ export async function request<T>(
if (typeof options.body !== 'undefined') {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(options.body);
} else if (options.formData) {
init.body = options.formData;
}
const response = await fetch(buildRequestUrl(path), init);
@@ -210,6 +218,7 @@ export function listAdminProjectSnapshots(
) {
const params = new URLSearchParams();
if (query.cursor) params.set('cursor', query.cursor);
if (query.channel) params.set('channel', query.channel);
params.set('limit', String(query.limit ?? 20));
return request<AdminProjectSnapshotListResponse>(
`/admin/api/project-snapshots?${params.toString()}`,
@@ -217,13 +226,27 @@ export function listAdminProjectSnapshots(
);
}
export function getAdminProjectSnapshotChannels(
token: string,
signal?: AbortSignal,
) {
return request<AdminProjectSnapshotChannelsResponse>(
'/admin/api/project-snapshots/channels',
{ token, signal },
);
}
export async function downloadAdminProjectSnapshot(
token: string,
channel: string,
userId: string,
projectId: string,
signal?: AbortSignal,
) {
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`;
const params = new URLSearchParams();
if (channel) params.set('channel', channel);
const query = params.toString();
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download${query ? `?${query}` : ''}`;
const response = await fetch(buildRequestUrl(path), {
headers: {
Authorization: `Bearer ${token.trim()}`,
@@ -1248,3 +1271,33 @@ export function reviewAdminGameDistributionVersion(
},
);
}
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
token,
signal,
});
}
export function updateAdminAgcTemplate(
token: string,
id: string,
body: AdminUpdateAgcTemplateRequest,
) {
return request<AdminAgcTemplateLibraryResponse>(
`/admin/api/agc-templates/${encodeURIComponent(id)}`,
{ token, method: 'PUT', body },
);
}
/** 批量导入模板包:manifest 与 zip_N / cover_N 一起走 multipart,一批一次锁一次提交。 */
export function importAdminAgcTemplates(token: string, formData: FormData) {
return request<AdminImportAgcTemplatesResponse>(
'/admin/api/agc-templates/import',
{
token,
method: 'POST',
formData,
},
);
}
+75
View File
@@ -105,11 +105,15 @@ export interface AdminProjectSnapshotEntry {
fileCount: number;
totalBytes: number;
status: 'ready' | 'partial' | 'unverified';
channel: string;
authorDisplayName?: string | null;
authorPublicUserCode?: string | null;
}
export interface AdminProjectSnapshotListQuery {
cursor?: string | null;
limit?: number;
channel?: string | null;
}
export interface AdminProjectSnapshotListResponse {
@@ -117,6 +121,11 @@ export interface AdminProjectSnapshotListResponse {
nextCursor: string | null;
}
export interface AdminProjectSnapshotChannelsResponse {
defaultChannel: string;
channels: string[];
}
export interface AdminErrorReportEntry {
batchId: string;
eventCount: number;
@@ -144,6 +153,8 @@ export interface AdminErrorReportListResponse {
}
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
firstFingerprint?: string;
firstSource?: string;
note?: string;
events: Array<Record<string, unknown>>;
logNames: string[];
@@ -1076,3 +1087,67 @@ export interface AdminGameDistributionSuspendResponse {
};
replayed: boolean;
}
export interface AdminAgcTemplatePayload {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
enabled: boolean;
coverUrl: string;
zipSizeBytes: number;
}
export interface AdminAgcTemplateLibraryResponse {
revision: string;
writable: boolean;
templates: AdminAgcTemplatePayload[];
}
export interface AdminUpdateAgcTemplateRequest {
expectedRevision: string;
title: string;
summary: string;
tags: string[];
enabled: boolean;
cover?: {
contentType: string;
dataBase64: string;
};
}
export interface AdminImportAgcTemplateItemPayload {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
entry: string;
zipField: string;
coverField: string;
}
export interface AdminImportAgcTemplatesManifest {
expectedRevision: string;
templates: AdminImportAgcTemplateItemPayload[];
}
export interface AdminImportAgcTemplateResult {
id: string;
templateVersion: string;
zipSizeBytes: number;
zipSha256: string;
reusedObjects: boolean;
}
export interface AdminImportAgcTemplatesResponse
extends AdminAgcTemplateLibraryResponse {
imported: AdminImportAgcTemplateResult[];
}
@@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
downloadAdminProjectSnapshot,
getAdminProjectSnapshotChannels,
listAdminProjectSnapshots,
} from './adminApiClient';
@@ -21,12 +22,12 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
expect(
await listAdminProjectSnapshots(
'admin-token',
{ cursor: 'user/a+项目', limit: 20 },
{ cursor: 'user/a+项目', limit: 20, channel: 'release' },
controller.signal,
),
).toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&limit=20',
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&channel=release&limit=20',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
signal: controller.signal,
@@ -34,6 +35,23 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
);
});
test('渠道列表按后台授权读取,解析本部署渠道', async () => {
const payload = { defaultChannel: 'release', channels: ['dev', 'release'] };
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ ok: true, data: payload })),
);
vi.stubGlobal('fetch', fetchMock);
expect(await getAdminProjectSnapshotChannels('admin-token')).toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/project-snapshots/channels',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
}),
);
});
test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('PK\u0003\u0004', {
@@ -48,6 +66,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
const controller = new AbortController();
const archive = await downloadAdminProjectSnapshot(
'admin-token',
'release',
'user/a',
'project/b',
controller.signal,
@@ -55,7 +74,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
expect(archive.filename).toBe('三消-r2.zip');
expect(archive.blob.type).toBe('application/zip');
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download',
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download?channel=release',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
@@ -81,7 +100,8 @@ test.each([
vi.fn().mockResolvedValue(new Response('PK', { headers })),
);
expect(
(await downloadAdminProjectSnapshot('token', 'user', 'project')).filename,
(await downloadAdminProjectSnapshot('token', 'release', 'user', 'project'))
.filename,
).toBe(expected.replace('工程', 'project'));
});
@@ -104,7 +124,7 @@ test.each([401, 403, 409, 500])(
),
);
await expect(
downloadAdminProjectSnapshot('token', 'user', 'project'),
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
).rejects.toMatchObject({
status,
code: 'SNAPSHOT_FAILURE',
@@ -123,6 +143,6 @@ test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
),
);
await expect(
downloadAdminProjectSnapshot('token', 'user', 'project'),
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
});