diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index ecd7501ac..16b39ca89 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -6,6 +6,7 @@ import { getAdminAgcTemplates, getAdminFeatureGateConfig, getAdminUserDetail, + importAdminAgcTemplates, listAdminRechargeOrders, reconcileAdminUserConsumption, resolveAdminRechargeRefundManualReview, @@ -65,6 +66,48 @@ test('模板管理读取和更新复用认证封装,提交 revision 和封面 ]); }); +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( diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 47e7ebd67..d85e5a8c8 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -30,9 +30,11 @@ import type { AdminExternalApiKeyListQuery, AdminExternalApiKeyListResponse, AdminFeatureGateConfigResponse, + AdminImportAgcTemplatesResponse, AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, + AdminProjectSnapshotChannelsResponse, AdminProjectSnapshotListQuery, AdminProjectSnapshotListResponse, AdminRechargeOrderListQuery, @@ -87,6 +89,8 @@ interface AdminRequestOptions { method?: string; token?: string; body?: unknown; + /** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */ + formData?: FormData; headers?: Record; signal?: AbortSignal; } @@ -174,6 +178,8 @@ export async function request( 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); @@ -209,6 +215,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( `/admin/api/project-snapshots?${params.toString()}`, @@ -216,13 +223,27 @@ export function listAdminProjectSnapshots( ); } +export function getAdminProjectSnapshotChannels( + token: string, + signal?: AbortSignal, +) { + return request( + '/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()}`, @@ -1196,3 +1217,15 @@ export function updateAdminAgcTemplate( { token, method: 'PUT', body }, ); } + +/** 批量导入模板包:manifest 与 zip_N / cover_N 一起走 multipart,一批一次锁一次提交。 */ +export function importAdminAgcTemplates(token: string, formData: FormData) { + return request( + '/admin/api/agc-templates/import', + { + token, + method: 'POST', + formData, + }, + ); +} diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index dfcd4b993..44b833586 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -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; @@ -1065,3 +1074,35 @@ export interface AdminUpdateAgcTemplateRequest { 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[]; +} diff --git a/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts b/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts index bee81d5f9..5ae88ce97 100644 --- a/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts +++ b/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts @@ -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' }); }); diff --git a/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx b/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx index b96acd4fd..667e44af5 100644 --- a/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx @@ -13,6 +13,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { AdminApiError, getAdminAgcTemplates, + importAdminAgcTemplates, updateAdminAgcTemplate, } from '../api/adminApiClient'; import type { @@ -24,6 +25,7 @@ import { AdminAgcTemplatesPage } from './AdminAgcTemplatesPage'; vi.mock('../api/adminApiClient', async (importOriginal) => ({ ...(await importOriginal()), getAdminAgcTemplates: vi.fn(), + importAdminAgcTemplates: vi.fn(), updateAdminAgcTemplate: vi.fn(), })); @@ -79,6 +81,21 @@ beforeEach(() => { : entry, ), })); + vi.mocked(importAdminAgcTemplates) + .mockReset() + .mockImplementation(async () => ({ + ...structuredClone(library), + revision: 'rev-imported', + imported: [ + { + id: 'alpha', + templateVersion: '0.1.0', + zipSizeBytes: 4, + zipSha256: 'a'.repeat(64), + reusedObjects: false, + }, + ], + })); vi.stubGlobal( 'URL', Object.assign(class extends URL {}, { @@ -496,3 +513,133 @@ test('读取失败显示真实错误,401 交给会话处理且不显示空库' expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'), ); }); + +function uploadZipFile(name: string) { + return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, { + type: 'application/zip', + }); +} + +function uploadCoverFile(name: string) { + return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, { + type: 'image/png', + }); +} + +async function openUploadDialog() { + fireEvent.click(await screen.findByRole('button', { name: '上传模板' })); + return screen.getByRole('dialog', { name: '上传模板' }); +} + +function pickUploadFiles(dialog: HTMLElement, accept: string, files: File[]) { + const input = dialog.querySelector( + `input[accept="${accept}"]`, + ); + if (!input) throw new Error(`missing file input for ${accept}`); + fireEvent.change(input, { target: { files } }); +} + +test('批量上传:多选 ZIP 生成行,封面匹配齐了才能提交', async () => { + render(); + const dialog = await openUploadDialog(); + + pickUploadFiles(dialog, '.zip,application/zip', [ + uploadZipFile('alpha.zip'), + uploadZipFile('beta.zip'), + ]); + expect(within(dialog).getByText('alpha.zip')).not.toBeNull(); + expect(within(dialog).getByText('beta.zip')).not.toBeNull(); + expect( + within(dialog) + .getByRole('button', { name: /上传 2 个模板/ }) + .hasAttribute('disabled'), + ).toBe(true); + expect(within(dialog).getAllByText('未匹配封面')).toHaveLength(2); + + pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [ + uploadCoverFile('alpha.png'), + uploadCoverFile('beta.png'), + ]); + expect(within(dialog).getByText('alpha.png')).not.toBeNull(); + expect( + within(dialog) + .getByRole('button', { name: /上传 2 个模板/ }) + .hasAttribute('disabled'), + ).toBe(false); +}); + +test('批量上传:确认后提交 manifest 与文件,成功后刷新列表', async () => { + render(); + const dialog = await openUploadDialog(); + pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]); + pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [ + uploadCoverFile('alpha.png'), + ]); + + fireEvent.click( + within(dialog).getByRole('button', { name: /上传 1 个模板/ }), + ); + await confirmWrite(); + + await waitFor(() => expect(importAdminAgcTemplates).toHaveBeenCalledTimes(1)); + const formData = vi.mocked(importAdminAgcTemplates).mock.calls[0]![1]; + const manifest = JSON.parse(String(formData.get('manifest'))); + expect(manifest.expectedRevision).toBe('rev-1'); + expect(manifest.templates[0]).toMatchObject({ + id: 'alpha', + zipField: 'zip_0', + coverField: 'cover_0', + }); + expect((formData.get('zip_0') as File).name).toBe('alpha.zip'); + expect((formData.get('cover_0') as File).name).toBe('alpha.png'); + expect( + (await screen.findAllByText(/已导入 1 个模板/)).length, + ).toBeGreaterThan(0); +}); + +test('批量上传:冲突给出刷新引导', async () => { + render(); + const dialog = await openUploadDialog(); + pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]); + pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [ + uploadCoverFile('alpha.png'), + ]); + + vi.mocked(importAdminAgcTemplates).mockRejectedValueOnce( + new AdminApiError({ + status: 409, + message: '模板库已更新,请刷新后重新编辑', + }), + ); + fireEvent.click( + within(dialog).getByRole('button', { name: /上传 1 个模板/ }), + ); + await confirmWrite(); + expect(await screen.findByText(/请刷新列表后重试/)).not.toBeNull(); + expect( + within(dialog) + .getByRole('button', { name: /上传 1 个模板/ }) + .hasAttribute('disabled'), + ).toBe(true); +}); + +test('批量上传:服务端拒绝时展示真实原因', async () => { + render(); + const dialog = await openUploadDialog(); + pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]); + pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [ + uploadCoverFile('alpha.png'), + ]); + + vi.mocked(importAdminAgcTemplates).mockRejectedValueOnce( + new AdminApiError({ + status: 400, + message: 'alpha:模板包缺少清单声明的 entry:index.html', + }), + ); + fireEvent.click( + within(dialog).getByRole('button', { name: /上传 1 个模板/ }), + ); + await confirmWrite(); + expect(await screen.findByText(/模板包缺少清单声明的 entry/)).not.toBeNull(); +}); diff --git a/apps/admin-web/src/pages/AdminAgcTemplatesPage.tsx b/apps/admin-web/src/pages/AdminAgcTemplatesPage.tsx index 989ca2ce0..f5cd5fb16 100644 --- a/apps/admin-web/src/pages/AdminAgcTemplatesPage.tsx +++ b/apps/admin-web/src/pages/AdminAgcTemplatesPage.tsx @@ -11,7 +11,7 @@ import { TableRow, TextField, } from '@genarrative/shared/components'; -import { RefreshCcw } from 'lucide-react'; +import { RefreshCcw, Upload } from 'lucide-react'; import { type FormEvent, useCallback, @@ -21,16 +21,28 @@ import { } from 'react'; import { + formatAdminApiError, getAdminAgcTemplates, + importAdminAgcTemplates, isAdminApiError, updateAdminAgcTemplate, } from '../api/adminApiClient'; import type { AdminAgcTemplateLibraryResponse, AdminAgcTemplatePayload, + AdminImportAgcTemplateResult, AdminUpdateAgcTemplateRequest, } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { + attachCoverFiles, + buildTemplateImportFormData, + deriveTemplateUploadRow, + TEMPLATE_IMPORT_MAX_BATCH, + TEMPLATE_UPLOAD_RUNTIMES, + type TemplateUploadRow, + validateTemplateUploadRows, +} from './adminAgcTemplateUploadModel'; import { handlePageError, splitLines } from './pageUtils'; type PageProps = { token: string; onUnauthorized: (message?: string) => void }; @@ -39,6 +51,14 @@ type EditingTemplate = { revision: string; key: number; }; + +type TemplateUploadState = { + rows: TemplateUploadRow[]; + errors: Record; + submitting: boolean; + error: string; + results: AdminImportAgcTemplateResult[] | null; +}; const runtimeLabels: Record = { html: 'HTML', cocos: 'Cocos', @@ -63,6 +83,7 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) { const [notice, setNotice] = useState(''); const [conflict, setConflict] = useState(false); const [editing, setEditing] = useState(null); + const [upload, setUpload] = useState(null); const mounted = useRef(false); const readGeneration = useRef(0); const readController = useRef(null); @@ -165,6 +186,96 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) { setEditing(null); } + function openUpload() { + if (writesDisabled || !snapshot) return; + setUpload({ + rows: [], + errors: {}, + submitting: false, + error: '', + results: null, + }); + } + + function closeUpload() { + if (upload?.submitting) return; + setUpload(null); + } + + async function submitUpload(rows: TemplateUploadRow[]) { + const revision = snapshot?.revision; + if ( + !revision || + writing.current || + loading || + conflict || + !snapshot?.writable + ) { + return; + } + const errors = validateTemplateUploadRows(rows); + if (Object.keys(errors).length > 0) { + setUpload((current) => (current ? { ...current, errors } : current)); + return; + } + writing.current = true; + setUpload((current) => + current + ? { ...current, submitting: true, errors: {}, error: '', results: null } + : current, + ); + setError(''); + setNotice(''); + try { + const confirmed = await confirmWrite({ + action: '上传模板', + target: `${rows.length} 个模板(发布后不可删除,只能下架)`, + }); + if (!confirmed || !mounted.current) return; + const response = await importAdminAgcTemplates( + token, + buildTemplateImportFormData(revision, rows), + ); + if (!mounted.current) return; + readGeneration.current += 1; + readController.current?.abort(); + setSnapshot({ + revision: response.revision, + writable: response.writable, + templates: response.templates, + }); + setConflict(false); + setNotice(`已导入 ${response.imported.length} 个模板`); + setUpload((current) => + current + ? { + ...current, + submitting: false, + rows: [], + results: response.imported, + } + : current, + ); + } catch (error) { + if (!mounted.current) return; + const conflictNow = isAdminApiError(error) && error.status === 409; + setConflict(conflictNow); + const message = formatUploadError(error); + setUpload((current) => + current ? { ...current, submitting: false, error: message } : current, + ); + // 上传失败只在弹窗里交代:401 仍要交给会话处理,其余错误不要同时打到列表页。 + if (isAdminApiError(error) && error.status === 401) { + handlePageError(error, unauthorized.current, setError); + } + } finally { + writing.current = false; + setUpload((current) => + current ? { ...current, submitting: false } : current, + ); + } + } + const terms = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean); const entries = (snapshot?.templates ?? []).filter((entry) => { const searchable = [entry.id, entry.title, ...entry.tags] @@ -186,14 +297,24 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {

模板管理

- +
+ + +
{readOnly ? ( 当前为只读模式,无法保存或上下架 @@ -378,6 +499,41 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) { ) : ( confirmDialog )} + {upload ? ( + + + setUpload((current) => + current + ? { + ...current, + rows, + errors: validateTemplateUploadRows(rows), + } + : current, + ) + } + onSubmit={() => void submitUpload(upload.rows)} + onClose={closeUpload} + onRefresh={() => void refresh()} + /> + {confirmDialog} + + ) : null}
); } @@ -627,3 +783,266 @@ function formatTemplateSize(bytes: number) { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; } + +function formatUploadError(error: unknown) { + const message = formatAdminApiError(error); + if (isAdminApiError(error) && error.status === 409) { + return `${message}(模板库已更新,请刷新列表后重试)`; + } + if (isAdminApiError(error) && error.status === 503) { + return `${message}(发布锁可能仍被占用,请联系运维核对)`; + } + return message; +} + +function TemplateUploadDialog({ + state, + disabled, + readOnly, + busy, + conflict, + onRowsChange, + onSubmit, + onClose, + onRefresh, +}: { + state: TemplateUploadState; + disabled: boolean; + readOnly: boolean; + busy: boolean; + conflict: boolean; + onRowsChange: (rows: TemplateUploadRow[]) => void; + onSubmit: () => void; + onClose: () => void; + onRefresh: () => void; +}) { + const uploadBusy = state.submitting; + const rowErrorCount = Object.keys(state.errors).length; + + function appendZipFiles(files: File[]) { + const known = new Set(state.rows.map((row) => row.key)); + const next = [...state.rows]; + for (const file of files) { + if (!/\.zip$/iu.test(file.name) || known.has(file.name)) continue; + known.add(file.name); + next.push(deriveTemplateUploadRow(file)); + } + onRowsChange(next); + } + + function updateRow(key: string, patch: Partial) { + onRowsChange( + state.rows.map((row) => (row.key === key ? { ...row, ...patch } : row)), + ); + } + + return ( + <> + {readOnly ? ( + 当前为只读模式,无法上传 + ) : null} +
+ + +
+ {state.rows.length === 0 ? ( + + 还没有选择模板包。简介、标签与引擎可在上传后用「编辑」补齐。 + + ) : ( +
+ + + + 模板包 + ID + 名称 + 版本 + 运行时 + entry + 封面 + 操作 + + + + {state.rows.map((row) => { + const rowError = state.errors[row.key]; + const rowDisabled = disabled || uploadBusy; + return ( + + +
+ {row.zipFile.name} +
+ {rowError ? ( +
+ {rowError} +
+ ) : null} +
+ + + updateRow(row.key, { id: event.currentTarget.value }) + } + /> + + + + updateRow(row.key, { + title: event.currentTarget.value, + }) + } + /> + + + + updateRow(row.key, { + templateVersion: event.currentTarget.value, + }) + } + /> + + + + updateRow(row.key, { + runtime: event.currentTarget.value, + }) + } + > + {TEMPLATE_UPLOAD_RUNTIMES.map((value) => ( + + ))} + + + + + updateRow(row.key, { + entry: event.currentTarget.value, + }) + } + /> + + + {row.coverFile ? ( + + {row.coverFile.name} + + ) : ( + 未匹配封面 + )} + + + + +
+ ); + })} +
+
+
+ )} + {state.error ? ( + + {state.error} + + ) : null} + {conflict ? ( + + ) : null} + {state.results ? ( + + {`已导入 ${state.results.length} 个模板:${state.results + .map((result) => `${result.id}@${result.templateVersion}`) + .join('、')}`} + + ) : null} +
+ + +
+ + ); +} diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx index 30e4ba2ae..7916e60e3 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx @@ -13,6 +13,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { AdminApiError, downloadAdminProjectSnapshot, + getAdminProjectSnapshotChannels, listAdminProjectSnapshots, } from '../api/adminApiClient'; import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; @@ -23,6 +24,7 @@ vi.mock('../api/adminApiClient', async () => ({ '../api/adminApiClient', )), downloadAdminProjectSnapshot: vi.fn(), + getAdminProjectSnapshotChannels: vi.fn(), listAdminProjectSnapshots: vi.fn(), })); @@ -35,12 +37,18 @@ const entry: AdminProjectSnapshotEntry = { fileCount: 12, totalBytes: 2048, status: 'ready', + channel: 'dev', + authorDisplayName: '陶泥作者', + authorPublicUserCode: 'SY-00000007', }; beforeEach(() => { vi.mocked(listAdminProjectSnapshots) .mockReset() .mockResolvedValue({ items: [entry], nextCursor: null }); + vi.mocked(getAdminProjectSnapshotChannels) + .mockReset() + .mockResolvedValue({ defaultChannel: 'dev', channels: ['dev'] }); vi.mocked(downloadAdminProjectSnapshot).mockReset(); }); afterEach(() => { @@ -88,36 +96,166 @@ test('按项目展示完整性并限制未完成工程下载', async () => { ).toBe(false); }); -test('加载更多合并项目,刷新失败保留列表和错误,重试从首页开始', async () => { +test('按游标翻页回到上一页时复用已取得的游标', async () => { + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }], + nextCursor: 'page-3', + }) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }); + render(); + await screen.findByText('三消工程'); + const pagination = await screen.findByRole('navigation', { + name: '项目工程分页', + }); + expect(pagination.textContent).toContain('第 1 页'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 1, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect( + screen.getByRole('button', { name: '上一页' }).hasAttribute('disabled'), + ).toBe(true); + + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('第二工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 2, + 'token', + { cursor: 'page-2', limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect(screen.queryByText('三消工程')).toBeNull(); + expect(pagination.textContent).toContain('第 2 页'); + expect( + screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'), + ).toBe(false); + + fireEvent.click(screen.getByRole('button', { name: '上一页' })); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect(pagination.textContent).toContain('第 1 页'); +}); + +test('切换每页条数从第一页按新条数重新加载', async () => { + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }], + nextCursor: 'page-3', + }) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('第二工程'); + const pagination = screen.getByRole('navigation', { name: '项目工程分页' }); + expect(pagination.textContent).toContain('第 2 页'); + + fireEvent.change(screen.getByLabelText('每页条数'), { + target: { value: '50' }, + }); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, + 'token', + { cursor: null, limit: 50, channel: 'dev' }, + expect.any(AbortSignal), + ); + // 重新加载时页脚会重建,必须重新取节点再断言。 + expect( + screen.getByRole('navigation', { name: '项目工程分页' }).textContent, + ).toContain('第 1 页'); +}); + +test('刷新重载当前页,翻页失败保留当前页并提示错误', async () => { vi.mocked(listAdminProjectSnapshots) .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) .mockResolvedValueOnce({ items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }], nextCursor: null, }) - .mockRejectedValueOnce(new Error('远端清单读取失败')) - .mockResolvedValueOnce({ items: [], nextCursor: null }); + .mockRejectedValueOnce(new Error('翻页读取失败')); render(); - fireEvent.click(await screen.findByRole('button', { name: '加载更多' })); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); await screen.findByText('第二工程'); - expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( - 2, - 'token', - { cursor: 'page-2', limit: 20 }, - expect.any(AbortSignal), - ); - expect(screen.getByText('三消工程')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); await screen.findByRole('alert'); - expect(screen.getByText('第二工程')).toBeTruthy(); - expect(screen.queryByText('暂无已上传项目')).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: '刷新' })); - await screen.findByText('暂无已上传项目'); - expect(listAdminProjectSnapshots).toHaveBeenLastCalledWith( + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, 'token', - { cursor: null, limit: 20 }, + { cursor: 'page-2', limit: 20, channel: 'dev' }, expect.any(AbortSignal), ); + const pagination = screen.getByRole('navigation', { name: '项目工程分页' }); + expect(pagination.textContent).toContain('第 2 页'); + expect(screen.getByText('第二工程')).toBeTruthy(); + expect(screen.queryByText('暂无已上传项目')).toBeNull(); + + vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({ + items: [], + nextCursor: null, + }); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await screen.findByText('暂无已上传项目'); + expect(screen.queryByRole('alert')).toBeNull(); +}); + +test('用户列与素材查询同口径展示昵称、陶泥号和用户详情入口', async () => { + render(); + const row = (await screen.findByText('三消工程')).closest('tr')!; + expect(within(row).getByText('陶泥作者')).toBeTruthy(); + expect(within(row).getByText('SY-00000007')).toBeTruthy(); + expect( + within(row).getByRole('button', { name: '查看用户信息' }), + ).toBeTruthy(); + expect(within(row).queryByText('user-1')).toBeNull(); +}); + +test('默认查询本部署渠道,切换渠道后从第一页按该渠道重新查询', async () => { + vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({ + defaultChannel: 'release', + channels: ['dev', 'release'], + }); + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }) + .mockResolvedValueOnce({ + items: [{ ...entry, channel: 'dev', projectName: 'dev 工程' }], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 1, + 'token', + { cursor: null, limit: 20, channel: 'release' }, + expect.any(AbortSignal), + ); + const channelSelect = screen.getByLabelText('项目工程渠道'); + expect((channelSelect as HTMLSelectElement).value).toBe('release'); + + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('第 2 页,本页 1 个项目'); + fireEvent.change(channelSelect, { target: { value: 'dev' } }); + await screen.findByText('dev 工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect(screen.getByText('第 1 页,本页 1 个项目')).toBeTruthy(); }); test('下载使用返回的中文文件名,随后释放对象 URL', async () => { @@ -154,6 +292,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () = expect(createObjectURL).toHaveBeenCalledWith(blob); expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith( 'token', + 'dev', 'user-1', 'project-1', expect.any(AbortSignal), @@ -164,7 +303,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () = test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => { vi.mocked(downloadAdminProjectSnapshot).mockImplementation( - (_token, _user, _project, signal) => + (_token, _channel, _user, _project, signal) => new Promise((_resolve, reject) => { signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), @@ -177,7 +316,7 @@ test('取消下载中止请求且不显示错误,卸载中止列表请求', as fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' })); fireEvent.click(await screen.findByRole('button', { name: '取消下载' })); expect( - vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted, + vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]?.aborted, ).toBe(true); await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {})); @@ -248,6 +387,10 @@ test('更换登录令牌丢弃旧列表和晚返回请求', async () => { onUnauthorized={onUnauthorized} />, ); + // 渠道确定之后才会发出列表请求,这里等到旧令牌的请求真的在途再换令牌。 + await waitFor(() => + expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(1), + ); const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2]; view.rerender( { , ); fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' })); - const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]; + const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]; view.unmount(); expect(signal?.aborted).toBe(true); await act(async () => { diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx index 489956117..30621d554 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx @@ -1,11 +1,19 @@ -import { Download, RefreshCcw, X } from 'lucide-react'; +import { + ChevronLeft, + ChevronRight, + Download, + RefreshCcw, + X, +} from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { downloadAdminProjectSnapshot, + getAdminProjectSnapshotChannels, listAdminProjectSnapshots, } from '../api/adminApiClient'; import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; +import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; import { handlePageError } from './pageUtils'; interface AdminProjectSnapshotsPageProps { @@ -31,21 +39,32 @@ const snapshotStatuses = { }, }; +const DEFAULT_PAGE_SIZE = 20; +const PAGE_SIZE_OPTIONS = [20, 50, 100]; + export function AdminProjectSnapshotsPage({ token, onUnauthorized, }: AdminProjectSnapshotsPageProps) { const [items, setItems] = useState([]); const [nextCursor, setNextCursor] = useState(null); + const [pageIndex, setPageIndex] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + // null 表示渠道还没确定:先读完可选渠道再发列表请求,避免用错渠道白跑一次。 + const [channel, setChannel] = useState(null); + const [channelOptions, setChannelOptions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [downloadingKey, setDownloadingKey] = useState(null); const listController = useRef(null); const downloadController = useRef(null); + // 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回, + // 因此按页记录已取得的游标,翻页只在这些游标之间移动。 + const pageCursors = useRef<(string | null)[]>([null]); const loadPage = useCallback( - async (cursor: string | null = null) => { + async (cursor: string | null, limit: number, page: number) => { listController.current?.abort(); const controller = new AbortController(); listController.current = controller; @@ -54,23 +73,16 @@ export function AdminProjectSnapshotsPage({ try { const response = await listAdminProjectSnapshots( token, - { cursor, limit: 20 }, + { cursor, limit, channel }, controller.signal, ); if (controller.signal.aborted) return; - setItems((current) => { - if (!cursor) return response.items; - const entries = new Map( - current.map((entry) => [snapshotKey(entry), entry]), - ); - response.items.forEach((entry) => - entries.set(snapshotKey(entry), entry), - ); - return [...entries.values()]; - }); + setItems(response.items); setNextCursor(response.nextCursor); + setPageIndex(page); setHasLoaded(true); } catch (error: unknown) { + // 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。 if (!controller.signal.aborted) handlePageError(error, onUnauthorized, setErrorMessage); } finally { @@ -80,22 +92,70 @@ export function AdminProjectSnapshotsPage({ } } }, - [token, onUnauthorized], + [token, onUnauthorized, channel], ); useEffect(() => { + // 换令牌或首次进入时取一次可选渠道;已选渠道保持不变,只在还没选时落到本部署渠道。 + const controller = new AbortController(); + void (async () => { + try { + const response = await getAdminProjectSnapshotChannels( + token, + controller.signal, + ); + if (controller.signal.aborted) return; + setChannelOptions(response.channels); + setChannel((current) => current ?? response.defaultChannel); + } catch (error: unknown) { + if (controller.signal.aborted) return; + // 渠道列表失败不阻塞查询:不带渠道按本部署渠道查询,并提示失败原因。 + handlePageError(error, onUnauthorized, setErrorMessage); + setChannel((current) => current ?? ''); + } + })(); + return () => controller.abort(); + }, [token, onUnauthorized]); + + useEffect(() => { + if (channel === null) return undefined; + pageCursors.current = [null]; setItems([]); setNextCursor(null); + setPageIndex(1); setHasLoaded(false); setDownloadingKey(null); - void loadPage(); + void loadPage(null, pageSize, 1); return () => { listController.current?.abort(); listController.current = null; downloadController.current?.abort(); downloadController.current = null; }; - }, [loadPage]); + }, [loadPage, pageSize, channel]); + + function goToNextPage() { + if (!nextCursor) return; + pageCursors.current[pageIndex] = nextCursor; + void loadPage(nextCursor, pageSize, pageIndex + 1); + } + + function goToPreviousPage() { + if (pageIndex <= 1) return; + void loadPage( + pageCursors.current[pageIndex - 2] ?? null, + pageSize, + pageIndex - 1, + ); + } + + function refreshCurrentPage() { + void loadPage( + pageCursors.current[pageIndex - 1] ?? null, + pageSize, + pageIndex, + ); + } async function downloadProject(entry: AdminProjectSnapshotEntry) { if (downloadController.current || entry.status === 'partial') return; @@ -106,6 +166,7 @@ export function AdminProjectSnapshotsPage({ try { const archive = await downloadAdminProjectSnapshot( token, + channel ?? '', entry.userId, entry.projectId, controller.signal, @@ -140,19 +201,42 @@ export function AdminProjectSnapshotsPage({ setDownloadingKey(null); } + // 渠道列表读取失败时至少保留当前渠道,避免选择框空掉后看不出在查哪个渠道。 + const visibleChannelOptions = channelOptions.length + ? channelOptions + : channel + ? [channel] + : []; + return (

项目工程

- +
+ + +
{errorMessage ? (
@@ -169,7 +253,7 @@ export function AdminProjectSnapshotsPage({ 项目 - 用户 ID + 用户 同步时间 文件数 体积 @@ -187,7 +271,22 @@ export function AdminProjectSnapshotsPage({ {entry.projectName || entry.projectId} {entry.projectId} - {entry.userId} + +
+
+ {projectOwnerDisplayName(entry)} + + {entry.authorPublicUserCode?.trim() || '-'} + +
+ +
+ {new Date(entry.syncedAtMs).toLocaleString('zh-CN', { @@ -239,23 +338,66 @@ export function AdminProjectSnapshotsPage({ {hasLoaded && items.length === 0 && !errorMessage ? (

暂无已上传项目

) : null} - {nextCursor ? ( -
- -
+ {hasLoaded ? ( + ) : null}
); } +function projectOwnerDisplayName(entry: AdminProjectSnapshotEntry) { + return ( + entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-' + ); +} + function snapshotKey(entry: AdminProjectSnapshotEntry) { return `${entry.userId}/${entry.projectId}`; } diff --git a/apps/admin-web/src/pages/adminAgcTemplateUploadModel.test.ts b/apps/admin-web/src/pages/adminAgcTemplateUploadModel.test.ts new file mode 100644 index 000000000..c6a958909 --- /dev/null +++ b/apps/admin-web/src/pages/adminAgcTemplateUploadModel.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; + +import { + attachCoverFiles, + buildTemplateImportFormData, + deriveTemplateUploadRow, + parseTemplateTags, + sanitizeTemplateId, + TEMPLATE_IMPORT_MAX_BATCH, + type TemplateUploadRow, + validateTemplateUploadRows, +} from './adminAgcTemplateUploadModel'; + +function zipFile(name: string) { + return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, { + type: 'application/zip', + }); +} + +function coverFile(name: string) { + return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, { + type: 'image/png', + }); +} + +function row(zipName: string, patch: Partial = {}) { + return { ...deriveTemplateUploadRow(zipFile(zipName)), ...patch }; +} + +describe('sanitizeTemplateId', () => { + it('lowercases, keeps whitelisted characters and drops traversal', () => { + expect(sanitizeTemplateId('Cocos Empty 2D.zip')).toBe('cocos-empty-2d'); + expect(sanitizeTemplateId('../../evil.zip')).toBe('evil'); + expect(sanitizeTemplateId('a..b.zip')).toBe('a.b'); + expect(sanitizeTemplateId('__hidden__')).toBe('hidden__'); + expect(sanitizeTemplateId(`${'x'.repeat(90)}.zip`)).toHaveLength(64); + }); +}); + +describe('deriveTemplateUploadRow', () => { + it('starts from safe defaults so a batch only needs covers attached', () => { + const derived = row('my-template.zip'); + expect(derived.id).toBe('my-template'); + expect(derived.title).toBe('my-template'); + expect(derived.runtime).toBe('html'); + expect(derived.templateVersion).toBe('0.1.0'); + expect(derived.entry).toBe('index.html'); + expect(derived.coverFile).toBeNull(); + }); +}); + +describe('attachCoverFiles', () => { + it('matches covers by template id and ignores non-image files', () => { + const rows = [row('alpha.zip'), row('beta.zip')]; + const covers = [ + coverFile('alpha.png'), + coverFile('beta.webp'), + coverFile('notes.txt'), + ]; + + const next = attachCoverFiles(rows, covers); + + expect(next[0]?.coverFile?.name).toBe('alpha.png'); + expect(next[1]?.coverFile?.name).toBe('beta.webp'); + }); + + it('keeps an already matched cover when the new selection has no partner', () => { + const rows = [row('alpha.zip', { coverFile: coverFile('alpha.png') })]; + const next = attachCoverFiles(rows, [coverFile('other.png')]); + expect(next[0]?.coverFile?.name).toBe('alpha.png'); + }); +}); + +describe('validateTemplateUploadRows', () => { + it('accepts a complete batch', () => { + const rows = [ + row('alpha.zip', { coverFile: coverFile('alpha.png') }), + row('beta.zip', { coverFile: coverFile('beta.png'), runtime: 'cocos' }), + ]; + expect(validateTemplateUploadRows(rows)).toEqual({}); + }); + + it('reports missing cover, duplicate id and invalid fields per row', () => { + const rows = [ + row('alpha.zip'), + row('alpha.zip', { coverFile: coverFile('alpha.png'), key: 'second' }), + row('gamma.zip', { + coverFile: coverFile('gamma.png'), + runtime: 'docker', + templateVersion: 'Bad Version', + entry: '../escape.js', + title: ' ', + }), + ]; + const errors = validateTemplateUploadRows(rows); + expect(errors['alpha.zip']).toContain('缺少封面'); + expect(errors.second).toContain('ID 在本批次内重复'); + expect(errors['gamma.zip']).toContain('名称必须是 1-80 个字符'); + }); + + it('rejects a batch larger than the server limit', () => { + const rows = Array.from( + { length: TEMPLATE_IMPORT_MAX_BATCH + 1 }, + (_, index) => + row(`template-${index}.zip`, { + coverFile: coverFile(`template-${index}.png`), + }), + ); + const errors = validateTemplateUploadRows(rows); + expect(Object.keys(errors)).toHaveLength(TEMPLATE_IMPORT_MAX_BATCH + 1); + expect(errors['template-0.zip']).toContain('单批最多上传'); + }); +}); + +describe('buildTemplateImportFormData', () => { + it('writes manifest field names and attaches zip / cover per row', () => { + const rows = [ + row('alpha.zip', { + coverFile: coverFile('alpha.png'), + tags: '起步, 起步 2d', + title: ' Alpha ', + }), + row('beta.zip', { coverFile: coverFile('beta.png') }), + ]; + + const form = buildTemplateImportFormData('a'.repeat(64), rows); + const manifest = JSON.parse(String(form.get('manifest'))); + + expect(manifest.expectedRevision).toBe('a'.repeat(64)); + expect(manifest.templates).toHaveLength(2); + expect(manifest.templates[0]).toMatchObject({ + id: 'alpha', + title: 'Alpha', + tags: ['起步', '2d'], + zipField: 'zip_0', + coverField: 'cover_0', + }); + expect(manifest.templates[1]).toMatchObject({ + id: 'beta', + zipField: 'zip_1', + coverField: 'cover_1', + }); + expect((form.get('zip_0') as File).name).toBe('alpha.zip'); + expect((form.get('cover_1') as File).name).toBe('beta.png'); + }); + + it('parses tags with dedupe and caps them at the server limit', () => { + expect(parseTemplateTags(' a, a,b c ')).toEqual(['a', 'b', 'c']); + const many = Array.from({ length: 20 }, (_, index) => `t${index}`).join( + ',', + ); + expect(parseTemplateTags(many)).toHaveLength(16); + }); +}); diff --git a/apps/admin-web/src/pages/adminAgcTemplateUploadModel.ts b/apps/admin-web/src/pages/adminAgcTemplateUploadModel.ts new file mode 100644 index 000000000..11192981a --- /dev/null +++ b/apps/admin-web/src/pages/adminAgcTemplateUploadModel.ts @@ -0,0 +1,191 @@ +import type { + AdminImportAgcTemplateItemPayload, + AdminImportAgcTemplatesManifest, +} from '../api/adminApiTypes'; + +/** 与服务端 module-assets 的导入上限保持一致;超出请分批或改走 CLI。 */ +export const TEMPLATE_IMPORT_MAX_BATCH = 20; +export const TEMPLATE_UPLOAD_RUNTIMES = [ + 'html', + 'unity', + 'godot', + 'cocos', +] as const; +const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u; +const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u; +const ENTRY_PATTERN = /^(?![\\/:])(?!.*\.\.)[^\s\\:]+$/u; +const COVER_EXTENSION_PATTERN = /\.(png|jpe?g|webp)$/iu; + +export interface TemplateUploadRow { + /** 稳定行标识:用 ZIP 文件名,重选文件后不会串行。 */ + key: string; + zipFile: File; + coverFile: File | null; + id: string; + title: string; + summary: string; + tags: string; + runtime: string; + engine: string; + engineVersion: string; + templateVersion: string; + entry: string; +} + +/** 文件名 → 模板 ID:小写、只留白名单字符,并去掉 `..` 与开头的非字母数字。 */ +export function sanitizeTemplateId(value: string) { + return value + .replace(/\.zip$/iu, '') + .trim() + .toLowerCase() + .replace(/\.{2,}/gu, '.') + .replace(/[^a-z0-9._-]+/gu, '-') + .replace(/^[^a-z0-9]+/u, '') + .slice(0, 64); +} + +export function deriveTemplateUploadRow(zipFile: File): TemplateUploadRow { + const id = sanitizeTemplateId(zipFile.name); + return { + key: zipFile.name, + zipFile, + coverFile: null, + id, + title: id, + summary: '', + tags: '', + runtime: 'html', + engine: '', + engineVersion: '', + templateVersion: '0.1.0', + entry: 'index.html', + }; +} + +/** 封面按「与模板 ID 同名的图片」匹配,一次多选即可覆盖整批。 */ +export function attachCoverFiles( + rows: TemplateUploadRow[], + coverFiles: File[], +): TemplateUploadRow[] { + const covers = new Map(); + for (const file of coverFiles) { + if (!COVER_EXTENSION_PATTERN.test(file.name)) continue; + const stem = sanitizeTemplateId( + file.name.replace(COVER_EXTENSION_PATTERN, ''), + ); + if (!covers.has(stem)) covers.set(stem, file); + } + return rows.map((row) => { + const matched = covers.get(row.id.trim().toLowerCase()) ?? null; + return matched ? { ...row, coverFile: matched } : row; + }); +} + +export function parseTemplateTags(value: string) { + const seen = new Set(); + const tags: string[] = []; + for (const raw of value.split(/[,,\s]+/u)) { + const tag = raw.trim(); + if (!tag || seen.has(tag)) continue; + seen.add(tag); + tags.push(tag); + } + return tags.slice(0, 16); +} + +/** 逐行校验:返回 row.key → 错误文案;空对象表示整批可以提交。 */ +export function validateTemplateUploadRows( + rows: TemplateUploadRow[], +): Record { + const errors: Record = {}; + const seen = new Set(); + const fail = (row: TemplateUploadRow, message: string) => { + if (!errors[row.key]) errors[row.key] = message; + }; + if (rows.length === 0) return errors; + if (rows.length > TEMPLATE_IMPORT_MAX_BATCH) { + for (const row of rows) { + fail(row, `单批最多上传 ${TEMPLATE_IMPORT_MAX_BATCH} 个模板`); + } + return errors; + } + for (const row of rows) { + const id = row.id.trim(); + if (!TEMPLATE_ID_PATTERN.test(id)) { + fail( + row, + 'ID 必须是 1-64 位小写字母、数字、点、下划线或连字符,且以字母数字开头', + ); + } else if (seen.has(id)) { + fail(row, 'ID 在本批次内重复'); + } else { + seen.add(id); + } + if (!row.title.trim() || row.title.trim().length > 80) { + fail(row, '名称必须是 1-80 个字符'); + } + if (row.summary.trim().length > 1000) { + fail(row, '简介最多 1000 个字符'); + } + if (!TEMPLATE_VERSION_PATTERN.test(row.templateVersion.trim())) { + fail(row, '版本号必须是 1-32 位小写字母、数字、点、下划线或连字符'); + } + if ( + !TEMPLATE_UPLOAD_RUNTIMES.includes( + row.runtime as (typeof TEMPLATE_UPLOAD_RUNTIMES)[number], + ) + ) { + fail(row, `运行时只能是 ${TEMPLATE_UPLOAD_RUNTIMES.join(' / ')}`); + } + if (!ENTRY_PATTERN.test(row.entry.trim())) { + fail(row, 'entry 必须是模板包内的相对路径'); + } + if (!row.coverFile) { + fail(row, '缺少封面:请上传与模板 ID 同名的 PNG / JPEG / WebP'); + } + } + return errors; +} + +export function buildTemplateImportManifest( + expectedRevision: string, + rows: TemplateUploadRow[], +): AdminImportAgcTemplatesManifest { + return { + expectedRevision, + templates: rows.map((row, index) => { + const item: AdminImportAgcTemplateItemPayload = { + id: row.id.trim(), + title: row.title.trim(), + summary: row.summary.trim(), + tags: parseTemplateTags(row.tags), + runtime: row.runtime, + engine: row.engine.trim(), + engineVersion: row.engineVersion.trim(), + templateVersion: row.templateVersion.trim(), + entry: row.entry.trim(), + zipField: `zip_${index}`, + coverField: `cover_${index}`, + }; + return item; + }), + }; +} + +export function buildTemplateImportFormData( + expectedRevision: string, + rows: TemplateUploadRow[], +): FormData { + const form = new FormData(); + form.append( + 'manifest', + JSON.stringify(buildTemplateImportManifest(expectedRevision, rows)), + ); + rows.forEach((row, index) => { + form.append(`zip_${index}`, row.zipFile, row.zipFile.name); + if (row.coverFile) { + form.append(`cover_${index}`, row.coverFile, row.coverFile.name); + } + }); + return form; +} diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index fdc22f363..2198adbdf 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -88,10 +88,60 @@ z-index: 1100; } +.admin-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.admin-agc-template-upload-dialog { + border-radius: 10px; + max-width: min(1180px, calc(100vw - 24px)); +} + +.admin-agc-template-upload-pickers { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.admin-agc-template-upload-picker { + display: grid; + gap: 6px; + font-size: 13px; +} + +.admin-agc-template-upload-picker input[type='file'] { + width: 100%; + font-size: 12px; +} + +/* 上传行数多时表格自己滚动,窄屏不撑坏整页布局。 */ +.admin-agc-template-upload-scroll { + max-height: min(52vh, 520px); + overflow: auto; +} + +.admin-agc-template-upload-file { + font-size: 12px; + word-break: break-all; +} + +.admin-agc-template-upload-row-error { + margin-top: 4px; + color: #b3261e; + font-size: 12px; +} + @media (max-width: 680px) { .admin-agc-template-filters { grid-template-columns: minmax(0, 1fr); } + + .admin-agc-template-upload-dialog { + max-width: calc(100vw - 12px); + } } * { @@ -1544,15 +1594,15 @@ button:disabled { } .admin-project-snapshot-table th:first-child { - width: 20%; + width: 18%; } .admin-project-snapshot-table th:nth-child(2) { - width: 14%; + width: 18%; } .admin-project-snapshot-table th:nth-child(3) { - width: 18%; + width: 16%; } .admin-project-snapshot-table th:nth-child(4) { @@ -1639,6 +1689,21 @@ button:disabled { } } +.admin-project-snapshot-pagination { + justify-content: space-between; + gap: 12px; +} + +.admin-project-snapshot-pagination-info { + color: #755a49; + font-size: 13px; + font-weight: 700; +} + +.admin-project-snapshot-pagination .admin-field { + min-width: 92px; +} + .admin-recharge-table { min-width: 1080px; table-layout: fixed; diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 931fec88c..385f52277 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -9,6 +9,7 @@ "dev-stack": "node scripts/start-dev-stack.mjs", "build": "node scripts/build-release.mjs", "release:upload": "node scripts/release-upload.mjs", + "nsis:prepare": "node scripts/ensure-nsis-toolset.mjs", "skill-pack:check": "node scripts/check-skill-pack.mjs", "skill-pack:sync": "node scripts/check-skill-pack.mjs --write", "skill-pack:test": "node --test scripts/check-skill-pack.test.mjs", @@ -75,6 +76,7 @@ "@types/react-dom": "^19.2.3", "@types/react-window": "^1.8.8", "@types/three": "^0.184.1", + "jszip": "^3.10.1", "tailwindcss": "^4.1.14", "typescript": "~5.8.2", "vitest": "^0.34.6" diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index b1f317674..45656fc67 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -20,7 +20,10 @@ import { createInterface } from 'node:readline/promises'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { inflateSync } from 'node:zlib'; -export const appIdentifier = 'world.genarrative.ai-game-creator'; +import { AGC_APP_IDENTIFIER } from './channel-identity.mjs'; + +// 联调工具驱动的始终是默认渠道客户端:安装身份取渠道基线,不跟随发布渠道。 +export const appIdentifier = AGC_APP_IDENTIFIER; export const configFileName = 'game-creator.config.json'; export const localConfigFileName = 'game-creator.config.local.json'; export const runnerEndpointFileName = 'agent-runner.endpoint.json'; diff --git a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs index 49bf93abf..67c4ad0b8 100644 --- a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs +++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs @@ -14,6 +14,7 @@ import { resolveReleasePartition, runTauriBuild, } from './build-release.mjs'; +import { resolveChannelInstallIdentity } from './channel-identity.mjs'; import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs'; import { readUpdaterPubkey, @@ -39,27 +40,19 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = path.resolve(appRoot, '../..'); /** - * 产品名只从 Tauri 配置读取:它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。 - * 写死会在改名后让入口静默找错对象(清理、打包、归档三处一起失效)。 + * 产品名只从渠道安装身份派生(渠道身份由构建期 `--config` 注入 Tauri 配置): + * 它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。写死会在改名或换渠道后 + * 让入口静默找错对象(清理、打包、归档三处一起失效)。 */ -function readProductName() { - const read = (file) => - JSON.parse(fs.readFileSync(path.join(appRoot, 'src-tauri', file), 'utf8')); - const base = read('tauri.conf.json'); - const macosPath = path.join(appRoot, 'src-tauri', 'tauri.macos.conf.json'); - const productName = fs.existsSync(macosPath) - ? (read('tauri.macos.conf.json').productName ?? base.productName) - : base.productName; +function resolveProductName(channel) { + const { productName } = resolveChannelInstallIdentity(channel); assert.ok( typeof productName === 'string' && productName.trim().length > 0, - 'Tauri 配置缺少 productName', + '渠道安装身份缺少 productName', ); return productName; } -const productName = readProductName(); -const appBundleName = `${productName}.app`; -const updaterArtifactName = `${productName}.app.tar.gz`; assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行'); assert.equal( process.env.JENKINS_URL?.length > 0, @@ -102,6 +95,9 @@ process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target'); const macTarget = 'aarch64-apple-darwin'; const context = resolveReleaseContext([`--target=${macTarget}`]); const partition = resolveReleasePartition(context.channel, context.target); +const productName = resolveProductName(context.channel); +const appBundleName = `${productName}.app`; +const updaterArtifactName = `${productName}.app.tar.gz`; const version = await prepareReleaseVersion(context); // 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`, // 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。 diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 92b719262..bd1d5907b 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -13,6 +13,11 @@ import { defaultEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; +import { + resolveChannelInstallIdentity, + resolveReleaseChannel, +} from './channel-identity.mjs'; +import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; import { stageNodeRuntime } from './stage-node-runtime.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -88,14 +93,7 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock'); const defaultOssBaseUrl = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc'; -const reservedChannelNames = new Set([ - 'win', - 'mac', - 'windows', - 'macos', - 'darwin', - 'linux', -]); +export { resolveReleaseChannel } from './channel-identity.mjs'; /** * 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须 @@ -164,21 +162,6 @@ export function resolveReleasePlatform(target = defaultTarget()) { throw new Error(`不支持的发布目标:${target}`); } -export function resolveReleaseChannel(env = process.env) { - const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev'; - if ( - !/^[a-z][a-z0-9-]{0,31}$/u.test(channel) || - channel.endsWith('-') || - reservedChannelNames.has(channel) || - /-(win|mac)$/u.test(channel) - ) { - throw new Error( - '发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道', - ); - } - return channel; -} - /** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */ export function resolveReleasePartition( channel = resolveReleaseChannel(), @@ -427,12 +410,19 @@ export function buildTauriBuildArguments( ]; } -/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */ +/** + * 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道, + * 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录, + * 不同渠道必须在同一台设备上并存而不是互相顶掉。 + */ export function createChannelConfig( channel = resolveReleaseChannel(), target = defaultTarget(), ) { + const { productName, identifier } = resolveChannelInstallIdentity(channel); return { + productName, + identifier, plugins: { updater: { endpoints: [updateManifestUrl(channel, target)], @@ -877,14 +867,19 @@ export async function buildRelease( args = [], { prepareVersion = prepareReleaseVersion, + prepareToolset = prepareNsisToolsetForRelease, build = runTauriBuild, generateManifest = generateUpdateManifest, } = {}, ) { const context = resolveReleaseContext(args); - if (!args.includes('--no-bundle')) await prepareVersion(context); + const bundling = !args.includes('--no-bundle'); + if (bundling) await prepareVersion(context); + // Tauri bundler 下载 NSIS 工具链时不重试,网络截断会直接毁掉整次打包; + // 因此打包前先在 Windows 目标上预置(详见 nsis-toolset.mjs)。 + if (bundling) await prepareToolset(context, { bundling }); build(args, context); - if (!args.includes('--no-bundle')) return generateManifest(context); + if (bundling) return generateManifest(context); } if ( diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 59e1a878b..848b74692 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -37,6 +37,11 @@ import { selectReleaseArtifact, updateManifestUrl, } from './build-release.mjs'; +import { + AGC_APP_IDENTIFIER, + AGC_PRODUCT_NAME, + resolveChannelInstallIdentity, +} from './channel-identity.mjs'; const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; @@ -184,6 +189,8 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json', ); assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), { + productName: AGC_PRODUCT_NAME, + identifier: AGC_APP_IDENTIFIER, plugins: { updater: { endpoints: [ @@ -204,6 +211,68 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { }); }); +test('channel install identity isolates co-installed builds and keeps the default channel stable', () => { + // 默认渠道必须保持已发布客户端身份:改身份等于换一个 App,升级链会断。 + assert.deepEqual(resolveChannelInstallIdentity('dev'), { + productName: AGC_PRODUCT_NAME, + identifier: AGC_APP_IDENTIFIER, + }); + assert.deepEqual(resolveChannelInstallIdentity('release'), { + productName: '陶泥儿 Release', + identifier: `${AGC_APP_IDENTIFIER}.release`, + }); + assert.deepEqual(resolveChannelInstallIdentity('beta-2'), { + productName: '陶泥儿 Beta-2', + identifier: `${AGC_APP_IDENTIFIER}.beta-2`, + }); + + // 同一台设备上不同渠道的安装目录、卸载项与数据目录必须互不相同。 + for (const channel of ['release', 'beta-2', 'a'.repeat(32)]) { + const identity = resolveChannelInstallIdentity(channel); + assert.notEqual(identity.productName, AGC_PRODUCT_NAME); + assert.notEqual(identity.identifier, AGC_APP_IDENTIFIER); + assert.ok(identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`)); + } + + for (const channel of ['dev-win', 'Release', 'win', 'beta-']) { + assert.throws( + () => resolveChannelInstallIdentity(channel), + /发布渠道无效/u, + ); + } +}); + +test('channel install identity is baked into the same build-time config as the endpoint', () => { + withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => { + const config = createChannelConfig('release', windowsTarget); + assert.equal(config.productName, '陶泥儿 Release'); + assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`); + assert.match( + config.plugins.updater.endpoints[0], + /\/release-win\/latest\.json$/u, + ); + }); +}); + +test('channel products keep first-install selection working under the channel product name', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-')); + try { + const { productName } = resolveChannelInstallIdentity('release'); + const dmg = path.join(root, `${productName}_${packageVersion}_aarch64.dmg`); + writeFileSync(dmg, 'channel first installation disk image'); + writeFileSync(path.join(root, 'windows.exe'), 'wrong platform'); + assert.equal( + selectFirstInstallArtifact([dmg, path.join(root, 'windows.exe')], { + target: 'aarch64-apple-darwin', + version: packageVersion, + }), + dmg, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('packaged renderer receives the same channel as the updater manifest', () => { const context = resolveReleaseContext([], { AGC_BUILD_TARGET: windowsTarget, @@ -590,6 +659,71 @@ test('no-bundle smoke skips version writes and manifest generation', async () => assert.deepEqual(steps, ['dev']); }); +test('Windows 打包在 Tauri 构建前预置 NSIS 工具链', async () => { + const events = []; + await buildRelease(['--target', windowsTarget], { + prepareVersion: () => { + events.push('version'); + }, + prepareToolset: (context, options) => { + events.push(`toolset:${context.target}:${options.bundling}`); + }, + build: () => { + events.push('build'); + }, + generateManifest: () => { + events.push('manifest'); + }, + }); + assert.deepEqual(events, [ + 'version', + `toolset:${windowsTarget}:true`, + 'build', + 'manifest', + ]); +}); + +test('NSIS 工具链预置失败即失败关闭,不进入 Tauri 构建', async () => { + const events = []; + await assert.rejects( + buildRelease(['--target', windowsTarget], { + prepareVersion: () => { + events.push('version'); + }, + prepareToolset: () => { + throw new Error('NSIS 工具链预置失败:下载 nsis-3.11.zip 失败'); + }, + build: () => { + events.push('build'); + }, + generateManifest: () => { + events.push('manifest'); + }, + }), + /NSIS 工具链预置失败/u, + ); + assert.deepEqual(events, ['version']); +}); + +test('--no-bundle 不预置 NSIS 工具链', async () => { + const steps = []; + await buildRelease(['--no-bundle', '--target', windowsTarget], { + prepareVersion: () => { + steps.push('version'); + }, + prepareToolset: () => { + steps.push('toolset'); + }, + build: () => { + steps.push('build'); + }, + generateManifest: () => { + steps.push('manifest'); + }, + }); + assert.deepEqual(steps, ['build']); +}); + test('release stages Node before Tauri and injects its resource mapping only for bundles', () => { const context = resolveReleaseContext(['--target', windowsTarget]); const events = []; diff --git a/apps/ai-game-creator-shell/scripts/channel-identity.mjs b/apps/ai-game-creator-shell/scripts/channel-identity.mjs new file mode 100644 index 000000000..5de68db22 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/channel-identity.mjs @@ -0,0 +1,73 @@ +/** + * AGC 渠道 → 安装身份。 + * + * 渠道同时决定两件事: + * - 更新端点:OSS 分区 `-win` / `-mac` 的清单地址; + * - 安装身份:`productName` 与 `identifier`。 + * + * 安装身份决定 Windows 安装目录与卸载项、macOS `.app` 名字与 bundle id、 + * Windows WebView2 数据目录以及 `%APPDATA%\` 客户端数据目录。 + * 因此不同渠道的包体在同一台设备上并存时互不顶掉,也不会共享登录态、 + * 本地项目与运行锁。 + * + * 默认渠道 `dev` 保持已发布客户端身份不变:升级链路与既有安装不能断。 + */ + +export const AGC_DEFAULT_CHANNEL = 'dev'; +export const AGC_PRODUCT_NAME = '陶泥儿'; +export const AGC_APP_IDENTIFIER = 'world.genarrative.ai-game-creator'; + +const reservedChannelNames = new Set([ + 'win', + 'mac', + 'windows', + 'macos', + 'darwin', + 'linux', +]); + +/** 校验渠道名:小写字母开头,允许数字与连字符,系统名不属于渠道。 */ +export function validateReleaseChannel(channel) { + if ( + typeof channel !== 'string' || + !/^[a-z][a-z0-9-]{0,31}$/u.test(channel) || + channel.endsWith('-') || + reservedChannelNames.has(channel) || + /-(win|mac)$/u.test(channel) + ) { + throw new Error( + '发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道', + ); + } + return channel; +} + +export function resolveReleaseChannel(env = process.env) { + return validateReleaseChannel(env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev'); +} + +/** 安装身份里的展示后缀:`release` → `Release`,`beta-2` → `Beta-2`。 */ +export function channelDisplaySuffix(channel) { + return validateReleaseChannel(channel) + .split('-') + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join('-'); +} + +/** + * 渠道对应的安装身份。默认渠道返回基线身份,其它渠道派生渠道后缀, + * 保证同一台设备上不同渠道互不覆盖。 + */ +export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) { + validateReleaseChannel(channel); + if (channel === AGC_DEFAULT_CHANNEL) { + return Object.freeze({ + productName: AGC_PRODUCT_NAME, + identifier: AGC_APP_IDENTIFIER, + }); + } + return Object.freeze({ + productName: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`, + identifier: `${AGC_APP_IDENTIFIER}.${channel}`, + }); +} diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index bb0093b09..567fcb258 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -27,6 +27,11 @@ import { appIdentifier, defaultRealSwarmTestTask, } from './agent-swarm-test-chat.mjs'; +import { + AGC_APP_IDENTIFIER, + AGC_PRODUCT_NAME, + resolveChannelInstallIdentity, +} from './channel-identity.mjs'; import { askHidden, assertSafeGameCreatorConfigDestination, @@ -1363,7 +1368,8 @@ if ( } for (const requiredSource of [ - "export const appIdentifier = 'world.genarrative.ai-game-creator'", + "import { AGC_APP_IDENTIFIER } from './channel-identity.mjs'", + 'export const appIdentifier = AGC_APP_IDENTIFIER', "'--swarm-chat'", "'--autonomous-game-build'", "'--preview-serve'", @@ -1374,14 +1380,38 @@ for (const requiredSource of [ } } -if (tauriConfig.productName !== '陶泥儿') { +// 基线配置必须等于默认渠道的安装身份:默认渠道不能改身份,否则已发布客户端 +// 的升级链路与既有安装目录都会断开。 +const defaultChannelIdentity = resolveChannelInstallIdentity('dev'); +if (tauriConfig.productName !== AGC_PRODUCT_NAME) { throw new Error('AI game creator shell productName drifted'); } -if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') { +if (tauriConfig.identifier !== AGC_APP_IDENTIFIER) { throw new Error('AI game creator shell identifier drifted'); } +if ( + tauriConfig.productName !== defaultChannelIdentity.productName || + tauriConfig.identifier !== defaultChannelIdentity.identifier +) { + throw new Error( + 'AI game creator shell baseline config must match the default channel identity', + ); +} + +// 非默认渠道必须派生出独立安装身份,否则同机安装会互相顶掉。 +for (const channel of ['release', 'beta-2']) { + const identity = resolveChannelInstallIdentity(channel); + if ( + identity.productName === defaultChannelIdentity.productName || + identity.identifier === defaultChannelIdentity.identifier || + !identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`) + ) { + throw new Error(`channel install identity not isolated: ${channel}`); + } +} + const expectedBundledDesignAgentResources = { 'design-agent': 'design-agent', ...Object.fromEntries( diff --git a/apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs b/apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs new file mode 100644 index 000000000..a8f35a453 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs @@ -0,0 +1,28 @@ +// Jenkins Windows 预检入口:在数分钟的 Rust 编译之前完成 NSIS 工具链预置。 +// +// 预置失败必须在此之前失败关闭,避免 bundler 用 `io: unexpected end of file` +// 把网络问题伪装成打包问题。 + +import { ensureNsisToolset, LOG_PREFIX } from './nsis-toolset.mjs'; + +// Jenkins 阶段用 `$ErrorActionPreference = 'Stop'` 执行 Powershell:重试告警走 +// stderr 时可能被 PowerShell 当成终止错误,因此重试与进度一律写 stdout,只有 +// 最终失败才写 stderr 并以退出码 1 失败关闭。 +const logger = { + log: (message) => console.log(message), + warn: (message) => console.log(`${message}(将重试)`), +}; + +try { + const result = await ensureNsisToolset({ logger }); + console.log(`${LOG_PREFIX} NSIS 工具链目录:${result.nsisDir}`); + console.log(`${LOG_PREFIX} NSIS 原始归档缓存:${result.cacheDir}`); + console.log( + result.reused + ? `${LOG_PREFIX} NSIS 工具链复用已有目录,未访问网络` + : `${LOG_PREFIX} NSIS 工具链本次预置:${result.downloaded.join('、')}`, + ); +} catch (error) { + console.error(`${LOG_PREFIX} NSIS 工具链预置失败:${error.message}`); + process.exit(1); +} diff --git a/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs b/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs new file mode 100644 index 000000000..d79c9f8a7 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs @@ -0,0 +1,342 @@ +// Tauri Windows bundler 的 NSIS 工具链预置。 +// +// 背景:`tauri build` 打 Windows NSIS 包时会现场从 GitHub 下载 `nsis-3.11.zip` +// 与 `nsis_tauri_utils.dll`(见 tauri-bundler `bundle/windows/nsis/mod.rs`)。 +// 构建机每个检出(`git clean -fdx`)都会丢掉 `target/.tauri` 缓存,于是每次 +// 发布都要重新下载;响应一旦被截断,bundler 只会报 `io: unexpected end of file`, +// 整条流水线在 Rust 编译数分钟之后才失败。 +// +// 这里在打包前用固定哈希 + 重试预置同一份工具链目录:bundler 检查到必需文件齐全 +// 且 `nsis_tauri_utils.dll` 哈希一致后就不会再自行下载。原始归档(两个文件) +// 额外缓存在工作区之外,构建机重复构建时不再依赖 GitHub 连通性。 + +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import JSZip from 'jszip'; + +export const LOG_PREFIX = '[ai-game-creator-shell]'; + +/** bundler 把工具链解到 `/.tauri/NSIS`(`bundle.useLocalToolsDir: true`)。 */ +export const NSIS_TOOLSET_DIR_NAME = 'NSIS'; + +export const NSIS_ARCHIVE_ASSET_NAME = 'nsis-3.11.zip'; +export const NSIS_ARCHIVE_URL = + 'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip'; +export const NSIS_ARCHIVE_SHA1 = 'ef7ff767e5cbd9edd22add3a32c9b8f4500bb10d'; +export const NSIS_ARCHIVE_TOP_LEVEL_DIR = 'nsis-3.11'; + +export const NSIS_TAURI_UTILS_ASSET_NAME = 'nsis_tauri_utils.dll'; +export const NSIS_TAURI_UTILS_URL = + 'https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll'; +export const NSIS_TAURI_UTILS_SHA1 = '75197fee3c6a814fe035788d1c34ead39349b860'; +export const NSIS_TAURI_UTILS_REQUIRED_FILE = + 'Plugins/x86-unicode/additional/nsis_tauri_utils.dll'; + +/** + * 与 tauri-bundler 2.9.x 的 `NSIS_REQUIRED_FILES` 逐条对齐:少一条 bundler 就会 + * 删掉整个目录重新下载,等于预置失效。升级 `@tauri-apps/cli` 时要同步核对。 + */ +export const NSIS_REQUIRED_FILES = [ + 'makensis.exe', + 'Bin/makensis.exe', + 'Stubs/lzma-x86-unicode', + 'Stubs/lzma_solid-x86-unicode', + NSIS_TAURI_UTILS_REQUIRED_FILE, + 'Include/MUI2.nsh', + 'Include/FileFunc.nsh', + 'Include/x64.nsh', + 'Include/nsDialogs.nsh', + 'Include/WinMessages.nsh', + 'Include/Win/COM.nsh', + 'Include/Win/Propkey.nsh', + 'Include/Win/RestartManager.nsh', +]; + +/** 需要预置的原始归档;测试可注入同结构描述替换其中的地址与哈希。 */ +export const NSIS_ASSETS = [ + { + assetName: NSIS_ARCHIVE_ASSET_NAME, + url: NSIS_ARCHIVE_URL, + sha1: NSIS_ARCHIVE_SHA1, + }, + { + assetName: NSIS_TAURI_UTILS_ASSET_NAME, + url: NSIS_TAURI_UTILS_URL, + sha1: NSIS_TAURI_UTILS_SHA1, + }, +]; + +const DEFAULT_DOWNLOAD_ATTEMPTS = 4; +const DEFAULT_RETRY_DELAY_MS = 3000; +const DEFAULT_DOWNLOAD_TIMEOUT_MS = 180_000; + +export function defaultAppRoot() { + return fileURLToPath(new URL('..', import.meta.url)); +} + +export function resolveTauriToolsDir(appRoot = defaultAppRoot()) { + // 必须与 `src-tauri/tauri.windows.conf.json` 的 `bundle.useLocalToolsDir: true` + // 保持一致,否则预置的文件不在 bundler 的查找路径上。 + return path.join(appRoot, 'src-tauri', 'target', '.tauri'); +} + +export function resolveNsisCacheDir( + env = process.env, + platform = process.platform, +) { + const explicit = env.AGC_TAURI_NSIS_CACHE_DIR?.trim(); + if (explicit) return path.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 path.join(os.homedir(), '.cache', 'genarrative', 'tauri-nsis-cache'); +} + +/** 与 tauri-bundler 相同的镜像开关语义,便于构建机绕过不可达的 GitHub。 */ +export function resolveDownloadUrl(url, env = process.env) { + if (!url.startsWith('https://github.com/')) return url; + const template = env.TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE?.trim(); + const match = + /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/releases\/download\/([^/]+)\/(.+)$/u.exec( + url, + ); + if (template && match) { + return template + .replaceAll('', match[1]) + .replaceAll('', match[2]) + .replaceAll('', match[3]) + .replaceAll('', match[4]); + } + const base = env.TAURI_BUNDLER_TOOLS_GITHUB_MIRROR?.trim(); + if (base) return `${base.replace(/\/+$/u, '')}/${url}`; + return url; +} + +export function sha1Of(data) { + return createHash('sha1').update(data).digest('hex'); +} + +function sha1OfFile(filePath) { + try { + return sha1Of(fs.readFileSync(filePath)); + } catch { + return null; + } +} + +export function verifyNsisToolset( + nsisDir, + { utilsSha1 = NSIS_TAURI_UTILS_SHA1 } = {}, +) { + const missing = NSIS_REQUIRED_FILES.filter( + (relativePath) => !fs.existsSync(path.join(nsisDir, relativePath)), + ); + const hashMismatch = + missing.length === 0 && + sha1OfFile(path.join(nsisDir, NSIS_TAURI_UTILS_REQUIRED_FILE)) !== + utilsSha1; + return { ok: missing.length === 0 && !hashMismatch, missing, hashMismatch }; +} + +/** 解析 zip 条目落盘位置,并拒绝 `../` 这类越界路径。 */ +export function resolveArchiveEntryTarget(rootDir, entryName) { + const root = path.resolve(rootDir); + const target = path.resolve(root, entryName); + if (target !== root && !target.startsWith(`${root}${path.sep}`)) { + throw new Error(`NSIS 归档包含越界路径:${entryName}`); + } + return target; +} + +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function downloadBuffer(url, { fetchImpl, timeoutMs }) { + const response = await fetchImpl(url, { + redirect: 'follow', + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`.trim()); + } + const data = Buffer.from(await response.arrayBuffer()); + if (data.length === 0) throw new Error('响应为空'); + return data; +} + +async function downloadVerifiedAsset({ + assetName, + url, + sha1, + env, + fetchImpl, + attempts, + retryDelayMs, + timeoutMs, + logger, +}) { + const downloadUrl = resolveDownloadUrl(url, env); + let lastError; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const data = await downloadBuffer(downloadUrl, { fetchImpl, timeoutMs }); + const actual = sha1Of(data); + if (actual !== sha1) { + throw new Error(`SHA1 不匹配(期望 ${sha1},实际 ${actual})`); + } + logger.log( + `${LOG_PREFIX} NSIS 工具链:已下载 ${assetName}(${data.length} 字节,第 ${attempt} 次尝试)`, + ); + return data; + } catch (error) { + lastError = error; + logger.warn( + `${LOG_PREFIX} NSIS 工具链:下载 ${assetName} 失败(第 ${attempt}/${attempts} 次):${error.message}`, + ); + if (attempt < attempts) await sleep(retryDelayMs * attempt); + } + } + throw new Error( + `下载 ${assetName} 失败(已重试 ${attempts} 次):${lastError?.message ?? '未知错误'}\n` + + `下载地址:${downloadUrl}\n` + + `可先把该文件放入缓存目录(AGC_TAURI_NSIS_CACHE_DIR)或配置 ` + + `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE 后重试。`, + ); +} + +async function ensureCachedAsset(options) { + const { assetName, sha1, cacheDir, logger } = options; + const cachePath = path.join(cacheDir, assetName); + if (sha1OfFile(cachePath) === sha1) { + logger.log(`${LOG_PREFIX} NSIS 工具链:命中缓存 ${cachePath}`); + return cachePath; + } + if (fs.existsSync(cachePath)) { + logger.warn( + `${LOG_PREFIX} NSIS 工具链:缓存文件校验失败,重新下载 ${cachePath}`, + ); + } + const data = await downloadVerifiedAsset(options); + fs.mkdirSync(cacheDir, { recursive: true }); + const tempPath = `${cachePath}.tmp-${process.pid}`; + fs.writeFileSync(tempPath, data); + fs.rmSync(cachePath, { force: true }); + fs.renameSync(tempPath, cachePath); + return cachePath; +} + +export async function extractNsisArchive(archivePath, destinationDir) { + const archive = await JSZip.loadAsync(fs.readFileSync(archivePath)); + for (const [entryName, entry] of Object.entries(archive.files)) { + if (entry.dir) continue; + const target = resolveArchiveEntryTarget(destinationDir, entryName); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, await entry.async('nodebuffer')); + } +} + +/** + * 预置 `target/.tauri/NSIS`:已就绪时零网络直接返回,否则用缓存或重试下载补齐。 + * 返回结构用于测试与日志,不参与发布产物。 + */ +export async function ensureNsisToolset({ + appRoot = defaultAppRoot(), + toolsDir = resolveTauriToolsDir(appRoot), + cacheDir = resolveNsisCacheDir(process.env), + env = process.env, + fetchImpl = globalThis.fetch, + assets = NSIS_ASSETS, + attempts = DEFAULT_DOWNLOAD_ATTEMPTS, + retryDelayMs = DEFAULT_RETRY_DELAY_MS, + timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS, + logger = console, +} = {}) { + const nsisDir = path.join(toolsDir, NSIS_TOOLSET_DIR_NAME); + // 生产路径下这里恒等于 bundler 固定的 `nsis_tauri_utils.dll` SHA1;测试注入 + // 自己的归档描述时,校验口径必须与被注入的资产一致。 + const missingAsset = [ + NSIS_ARCHIVE_ASSET_NAME, + NSIS_TAURI_UTILS_ASSET_NAME, + ].find((assetName) => !assets.some((asset) => asset.assetName === assetName)); + if (missingAsset) throw new Error(`NSIS 资产描述缺少 ${missingAsset}`); + const utilsSha1 = + assets.find((asset) => asset.assetName === NSIS_TAURI_UTILS_ASSET_NAME) + ?.sha1 ?? NSIS_TAURI_UTILS_SHA1; + const existing = verifyNsisToolset(nsisDir, { utilsSha1 }); + if (existing.ok) { + logger.log(`${LOG_PREFIX} NSIS 工具链已就绪:${nsisDir}`); + return { nsisDir, toolsDir, cacheDir, reused: true, downloaded: [] }; + } + logger.log( + `${LOG_PREFIX} NSIS 工具链需要预置:${nsisDir}` + + (existing.missing.length > 0 + ? `(缺少 ${existing.missing.length} 个文件)` + : '(哈希不符)'), + ); + + const downloadOptions = { + env, + fetchImpl, + attempts, + retryDelayMs, + timeoutMs, + cacheDir, + logger, + }; + const assetPaths = {}; + for (const asset of assets) { + assetPaths[asset.assetName] = await ensureCachedAsset({ + ...asset, + ...downloadOptions, + }); + } + + fs.rmSync(nsisDir, { recursive: true, force: true }); + await extractNsisArchive(assetPaths[NSIS_ARCHIVE_ASSET_NAME], toolsDir); + const extractedDir = path.join(toolsDir, NSIS_ARCHIVE_TOP_LEVEL_DIR); + if (!fs.existsSync(extractedDir)) { + throw new Error( + `NSIS 归档结构不符合预期:${assetPaths[NSIS_ARCHIVE_ASSET_NAME]} 未解出 ${NSIS_ARCHIVE_TOP_LEVEL_DIR}`, + ); + } + fs.renameSync(extractedDir, nsisDir); + + const utilsTarget = path.join(nsisDir, NSIS_TAURI_UTILS_REQUIRED_FILE); + fs.mkdirSync(path.dirname(utilsTarget), { recursive: true }); + fs.copyFileSync(assetPaths[NSIS_TAURI_UTILS_ASSET_NAME], utilsTarget); + + const installed = verifyNsisToolset(nsisDir, { utilsSha1 }); + if (!installed.ok) { + throw new Error( + `NSIS 工具链预置不完整:缺少 ${installed.missing.join(', ') || '无'};` + + `哈希不符=${installed.hashMismatch}`, + ); + } + logger.log(`${LOG_PREFIX} NSIS 工具链预置完成:${nsisDir}`); + return { + nsisDir, + toolsDir, + cacheDir, + reused: false, + downloaded: assets.map((asset) => asset.assetName), + }; +} + +/** `buildRelease` 用:只在 Windows 目标且需要打包时预置 NSIS 工具链。 */ +export async function prepareNsisToolsetForRelease( + context, + { bundling = true, ...deps } = {}, +) { + if (!bundling || !context.target.includes('windows')) return null; + return ensureNsisToolset(deps); +} diff --git a/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs b/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs new file mode 100644 index 000000000..26faca27e --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs @@ -0,0 +1,331 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import JSZip from 'jszip'; + +import { + ensureNsisToolset, + extractNsisArchive, + NSIS_ARCHIVE_ASSET_NAME, + NSIS_ARCHIVE_TOP_LEVEL_DIR, + NSIS_REQUIRED_FILES, + NSIS_TAURI_UTILS_ASSET_NAME, + NSIS_TOOLSET_DIR_NAME, + prepareNsisToolsetForRelease, + resolveArchiveEntryTarget, + resolveDownloadUrl, + resolveNsisCacheDir, + resolveTauriToolsDir, + sha1Of, + verifyNsisToolset, +} from './nsis-toolset.mjs'; + +const appRoot = path.resolve( + path.dirname(new URL(import.meta.url).pathname), + '..', +); +const silentLogger = { log() {}, warn() {} }; + +function createSandbox() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'agc-nsis-toolset-')); +} + +/** 与真实归档同构的最小 zip:只保留 bundler 必需文件。 */ +async function createArchiveFixture(extraEntries = {}) { + const zip = new JSZip(); + for (const relativePath of NSIS_REQUIRED_FILES) { + zip.file( + `${NSIS_ARCHIVE_TOP_LEVEL_DIR}/${relativePath}`, + `fixture:${relativePath}`, + ); + } + for (const [name, contents] of Object.entries(extraEntries)) { + zip.file(name, contents); + } + return zip.generateAsync({ type: 'nodebuffer' }); +} + +function fixtureAssets({ archive, utils }) { + return [ + { + assetName: NSIS_ARCHIVE_ASSET_NAME, + url: `https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/${NSIS_ARCHIVE_ASSET_NAME}`, + sha1: sha1Of(archive), + }, + { + assetName: NSIS_TAURI_UTILS_ASSET_NAME, + url: `https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/${NSIS_TAURI_UTILS_ASSET_NAME}`, + sha1: sha1Of(utils), + }, + ]; +} + +function fixtureFetch({ archive, utils, failures = 0 }) { + let remainingFailures = failures; + const calls = []; + const fetchImpl = async (url) => { + calls.push(url); + if (remainingFailures > 0) { + remainingFailures -= 1; + throw new Error('network truncated'); + } + const body = url.includes(NSIS_TAURI_UTILS_ASSET_NAME) ? utils : archive; + return { + ok: true, + status: 200, + statusText: 'OK', + arrayBuffer: async () => body, + }; + }; + return { fetchImpl, calls }; +} + +test('NSIS 工具链目录与 Tauri useLocalToolsDir 配置保持一致', () => { + const config = JSON.parse( + fs.readFileSync( + path.join(appRoot, 'src-tauri', 'tauri.windows.conf.json'), + 'utf8', + ), + ); + assert.equal(config.bundle.useLocalToolsDir, true); + assert.equal( + resolveTauriToolsDir(appRoot), + path.join(appRoot, 'src-tauri', 'target', '.tauri'), + ); +}); + +test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => { + assert.equal( + resolveNsisCacheDir( + { AGC_TAURI_NSIS_CACHE_DIR: '/tmp/agc-cache' }, + 'linux', + ), + '/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'), + ), + ); +}); + +test('下载地址支持 tauri bundler 的两套 GitHub 镜像开关', () => { + const url = + 'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip'; + assert.equal(resolveDownloadUrl(url, {}), url); + assert.equal( + resolveDownloadUrl(url, { + TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE: + 'https://mirror.example.com////', + }), + 'https://mirror.example.com/tauri-apps/binary-releases/nsis-3.11/nsis-3.11.zip', + ); + assert.equal( + resolveDownloadUrl(url, { + TAURI_BUNDLER_TOOLS_GITHUB_MIRROR: 'https://mirror.example.com/', + }), + `https://mirror.example.com/${url}`, + ); +}); + +test('工具链已就绪时零下载复用', async () => { + const sandbox = createSandbox(); + const toolsDir = path.join(sandbox, '.tauri'); + const cacheDir = path.join(sandbox, 'cache'); + const archive = await createArchiveFixture(); + const utils = Buffer.from('nsis-tauri-utils-dll'); + const assets = fixtureAssets({ archive, utils }); + + await ensureNsisToolset({ + toolsDir, + cacheDir, + assets, + fetchImpl: fixtureFetch({ archive, utils }).fetchImpl, + logger: silentLogger, + }); + + let fetchCalls = 0; + const reused = await ensureNsisToolset({ + toolsDir, + cacheDir, + assets, + fetchImpl: async () => { + fetchCalls += 1; + throw new Error('工具链已就绪时不应访问网络'); + }, + logger: silentLogger, + }); + assert.equal(reused.reused, true); + assert.deepEqual(reused.downloaded, []); + assert.equal(fetchCalls, 0); + assert.equal(reused.nsisDir, path.join(toolsDir, NSIS_TOOLSET_DIR_NAME)); +}); + +test('冷启动时下载、校验、解压并落缓存,重跑走缓存', async () => { + const sandbox = createSandbox(); + const toolsDir = path.join(sandbox, '.tauri'); + const cacheDir = path.join(sandbox, 'cache'); + const archive = await createArchiveFixture(); + const utils = Buffer.from('nsis-tauri-utils-dll'); + const assets = fixtureAssets({ archive, utils }); + const { fetchImpl, calls } = fixtureFetch({ archive, utils }); + + const result = await ensureNsisToolset({ + toolsDir, + cacheDir, + assets, + fetchImpl, + logger: silentLogger, + }); + assert.deepEqual(calls.length, 2); + assert.deepEqual(result.downloaded, [ + NSIS_ARCHIVE_ASSET_NAME, + NSIS_TAURI_UTILS_ASSET_NAME, + ]); + assert.equal( + verifyNsisToolset(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), { + utilsSha1: sha1Of(utils), + }).ok, + true, + ); + assert.equal( + fs.readFileSync( + path.join( + toolsDir, + NSIS_TOOLSET_DIR_NAME, + 'Plugins/x86-unicode/additional/nsis_tauri_utils.dll', + ), + 'utf8', + ), + 'nsis-tauri-utils-dll', + ); + + // 第二次构建:清空工作区工具目录后仍应零下载恢复(模拟 Jenkins git clean -fdx)。 + fs.rmSync(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), { + recursive: true, + force: true, + }); + const offline = await ensureNsisToolset({ + toolsDir, + cacheDir, + assets, + fetchImpl: async () => { + throw new Error('命中缓存时不应访问网络'); + }, + logger: silentLogger, + }); + assert.equal( + verifyNsisToolset(offline.nsisDir, { utilsSha1: sha1Of(utils) }).ok, + true, + ); +}); + +test('下载失败按次数重试,最终成功', async () => { + const sandbox = createSandbox(); + const archive = await createArchiveFixture(); + const utils = Buffer.from('dll'); + const { fetchImpl, calls } = fixtureFetch({ archive, utils, failures: 2 }); + const result = await ensureNsisToolset({ + toolsDir: path.join(sandbox, '.tauri'), + cacheDir: path.join(sandbox, 'cache'), + assets: fixtureAssets({ archive, utils }), + fetchImpl, + attempts: 3, + retryDelayMs: 1, + logger: silentLogger, + }); + assert.equal(result.downloaded.length, 2); + assert.equal(calls.length, 4); +}); + +test('哈希不匹配时报错并给出可操作提示', async () => { + const sandbox = createSandbox(); + const { fetchImpl } = fixtureFetch({ + archive: Buffer.from('corrupted'), + utils: Buffer.from('corrupted'), + }); + await assert.rejects( + ensureNsisToolset({ + toolsDir: path.join(sandbox, '.tauri'), + cacheDir: path.join(sandbox, 'cache'), + assets: [ + { + assetName: NSIS_ARCHIVE_ASSET_NAME, + url: 'https://github.com/a/b/releases/download/1/n.zip', + sha1: 'deadbeef', + }, + { + assetName: NSIS_TAURI_UTILS_ASSET_NAME, + url: 'https://github.com/a/b/releases/download/1/n.dll', + sha1: 'deadbeef', + }, + ], + fetchImpl, + attempts: 2, + retryDelayMs: 1, + logger: silentLogger, + }), + /SHA1 不匹配/u, + ); + assert.equal( + fs.existsSync(path.join(sandbox, 'cache', NSIS_ARCHIVE_ASSET_NAME)), + false, + ); +}); + +test('归档越界路径与缺失必需文件都会失败关闭', async () => { + const sandbox = createSandbox(); + assert.throws( + () => + resolveArchiveEntryTarget(path.join(sandbox, 'extract'), '../escape.txt'), + /越界路径/u, + ); + assert.equal( + resolveArchiveEntryTarget(path.join(sandbox, 'extract'), 'nsis-3.11/a/b'), + path.resolve(sandbox, 'extract', 'nsis-3.11/a/b'), + ); + + const emptyArchive = new JSZip() + .file(`${NSIS_ARCHIVE_TOP_LEVEL_DIR}/makensis.exe`, 'only-one') + .generateAsync({ type: 'nodebuffer' }); + const emptyPath = path.join(sandbox, 'incomplete.zip'); + fs.writeFileSync(emptyPath, await emptyArchive); + const toolsDir = path.join(sandbox, 'incomplete-tools'); + await extractNsisArchive(emptyPath, toolsDir); + const status = verifyNsisToolset( + path.join(toolsDir, NSIS_ARCHIVE_TOP_LEVEL_DIR), + ); + assert.equal(status.ok, false); + assert.ok(status.missing.includes('Bin/makensis.exe')); +}); + +test('非 Windows 目标或 --no-bundle 不预置工具链', async () => { + let called = 0; + const deps = { + ensure: async () => { + called += 1; + }, + }; + assert.equal( + await prepareNsisToolsetForRelease( + { target: 'x86_64-pc-windows-msvc' }, + { bundling: false, ...deps }, + ), + null, + ); + assert.equal( + await prepareNsisToolsetForRelease( + { target: 'aarch64-apple-darwin' }, + deps, + ), + null, + ); + assert.equal(called, 0); +}); diff --git a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs index b62fe76a1..fc3546be7 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs @@ -174,8 +174,16 @@ test('macOS release entry and smoke script derive product names from config and new URL('./build-macos-ci.mjs', import.meta.url), 'utf8', ); - // 产品名决定 *.app、updater 归档与 DMG 卷名:写死会在改名后静默找错对象。 - assert.ok(entry.includes('readProductName'), '入口必须从 Tauri 配置读产品名'); + // 产品名决定 *.app、updater 归档与 DMG 卷名:它必须从渠道安装身份派生, + // 写死会在换渠道或改名后静默找错对象。 + assert.ok( + entry.includes('resolveChannelInstallIdentity'), + '入口必须从渠道安装身份派生产品名', + ); + assert.ok( + entry.includes('resolveProductName(context.channel)'), + '产品名必须按当前发布渠道解析', + ); assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名'); assert.ok( entry.includes("const macTarget = 'aarch64-apple-darwin'"), diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json index b452cdca0..d259be149 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json @@ -13,7 +13,7 @@ "taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求", "taonier_prepare_game_art.parameters.mode": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate", "agc_generate_image.description": "生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集。仅在用户明确要求生成新图时调用。", - "agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可", + "agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可。kind=icon-spritesheet 时,去除首尾空白后的描述须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交;超限拒绝,不截断、不拆条,客户端不追加生图指令", "agc_generate_image.parameters.kind": "image=普通新图(保留生成原图),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图,ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图", "agc_generate_image.parameters.assetName": "本地素材的人类可读显示名称", "agc_generate_image.parameters.outputPath": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json index d0a2ce412..bb96a7205 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json @@ -23,8 +23,6 @@ "icon_spec_generation": "为这个 Web 小游戏生成一张 1:1 的统一视觉规范图,作为后续 UI 设计图和透明游戏图集的共同权威参考。规范板必须分区展示:玩家主体及其成长形态、核心目标或收集物、场景地块与障碍、HUD/操作图标、得分/受击/胜负反馈、主辅强调色与材质规则。所有元素使用一致的正交视角、轮廓、光照和原创视觉语言,留出清楚间距;不要生成完整游戏截图、海报、黑底图集或纯文字说明。玩法机制只用于理解功能,不授权复刻现有作品。\n\n项目视觉需求:{}", "ui_design_generation": "根据下方当前项目 UI 需求生成一张完整的游戏 UI/UX 原型图,玩法与界面结构以这些需求为准。画面是完整 16:9 桌面端单屏界面,并同时明确移动端重排意图;清楚呈现当前玩法所需的分数/资源/生命/局内状态 HUD、主要可玩区域、玩家与目标/收集物/危险物、开始和主要操作、失败状态与重新开始、键盘和触控提示。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。采用项目已经确定的原创命名、角色轮廓、配色、场景材质和界面视觉语言。\n\n当前项目 UI 需求:{}", "scene_generation": "为 Web 小游戏生成一张可直接作为运行画面底图的原创 16:9 场景背景。严格从下方用户需求提炼自己的游戏主题、地点、季节、材质和氛围;画面要为真实可玩区域留出足够清楚的中部空间,并有前景、中景、远景层次。不得画玩家角色、道具、棋子、障碍、HUD、操作按钮、文字、Logo、完整游戏截图、海报或素材图集;这些元素会从独立透明核心图集中绘制。不得自行假设为塔防或加入玩法合同中不存在的实体;必须原创,不得复刻现有游戏场景、贴图、标志性布局或受保护视觉语言。\n\n用户需求:{}", - "default_art_brief": "需要一张可直接用于 Web 小游戏首版原型的核心美术素材。", - "spritesheet_generation": "为 Web 小游戏首版原型生成一张可切分的原创透明核心美术素材图集,适合放入本地 assets 并被游戏直接引用。严格从用户需求和美术 brief 提取当前项目自己的标题、玩法实体、目标物、收集物、障碍、状态与反馈,素材类别与数量以当前项目需求为准。所有元素沿用当前规范图的轮廓、配色、材质和光照,分区排布并留出清楚切分间距。角色轮廓、图标排布与配色采用项目原创设计。\n用户需求:{}\n美术资产 brief:{}", "ui_inspection_focus": "请只依据真实可见像素判断这是否是可供前端直接实现的完整游戏 UI 原型,不能依据文件名、生成提示词或图片内自述放行。纯场景图、概念图、地图、海报或只展示角色而没有可玩界面的插画必须判定失败;按当前项目的玩法识别界面结构与关键要素。逐项检查:informationHud=清楚显示当前玩法需要的分数、资源、生命、关卡或局内状态;gameplaySurface=主要可玩区域及空间规则清楚;objectiveEntities=玩家主体、目标/收集/危险物、谜题或文本选项、轨道等当前玩法等价关键要素可辨;primaryControls=当前玩法需要的开始、移动、暂停或操作控件清楚;failureRestartFlow=存在可识别的结束态表现意图或明确重开入口;responsiveLayout=能从可见布局、触控目标和可重排分组判断移动适配意图,实际双视口另由浏览器验证;implementationClarity=分区、层级和文字清楚到可指导 HTML/CSS;originalTheme=原创主题且未复刻现有游戏角色、Logo、贴图或受保护视觉语言。请只返回一个 JSON object,字段必须严格为:{\"checks\":{\"informationHud\":true,\"gameplaySurface\":true,\"objectiveEntities\":true,\"primaryControls\":true,\"failureRestartFlow\":true,\"responsiveLayout\":true,\"implementationClarity\":true,\"originalTheme\":true},\"issues\":[\"未通过项及原因;全部通过时必须为空数组\"],\"summary\":\"500 字以内中文结论\"}。只有八项 checks 全为 true 且 issues 为空才通过。", "default_inspection_focus": "请检查布局、遮挡、裁切、视觉层级、素材一致性,以及桌面与移动视口是否可用。", "custom_inspection_focus": "检查重点:{question}", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json index 1d93990b3..75bf4c9f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json @@ -36,7 +36,7 @@ "preview.start.description": "启动当前项目的 loopback HTTP 预览。", "preview.validate.description": "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect.description": "让视觉模型检查一至两张项目内图片。", - "canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。", + "canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 的 prompt 去除首尾空白后须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交,超限拒绝,不截断、不拆条,客户端不追加生图指令。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。", "ui.workflow.run.description": "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-design 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。", "cocos.editor.execute.description": "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。", "unity.editor.execute.description": "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 8c11e693f..35c52a8bc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -7697,7 +7697,7 @@ done &temp.path().join("host"), &project, "turn-0001", - "fixture-request", + &format!("{:x}", Sha256::digest("请创建菜单".as_bytes())), false, &super::super::direct_validation::DirectValidationConfig::default(), ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index 45ea19f54..f63a13da1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -28,7 +28,7 @@ pub(crate) fn validate_direct_codex_user_item( // 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落 // 分隔、软换行与 chip 后的分隔空格就是这样落进 canonical content 的,前端不为它过滤。 if !content_has_meaningful_input(&message.content) { - return Err("DirectProject user item content 不能为空".to_string()); + return Err("聊天内容不能为空".to_string()); } let manifest = read_manifest_for_project(root)?; let mut reference_count = 0usize; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index cdbcadf88..d3e94388a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -283,7 +283,7 @@ fn render_ui_design_code_context( mod tests { use super::{ direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, - direct_codex_user_item_to_wire_input, + direct_codex_user_item_to_wire_input, validate_direct_codex_user_item, }; use crate::agent::direct_codex_user_item::model::DirectCodexUserItem; use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE; @@ -476,6 +476,143 @@ mod tests { assert_ne!(projected["content"][0]["type"], "text"); } + #[test] + fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item = json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": ""}, + {"type": "input_text", "text": "你好"}, + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": "第二段"}, + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": " "} + ] + }); + let projected = direct_codex_user_item_to_response_item(root.path(), &item) + .expect("user response item should project"); + assert_eq!(projected["content"], item["content"]); + let canonical = serde_json::from_value(item).expect("canonical user item"); + assert_eq!( + direct_codex_user_item_to_prompt(root.path(), &canonical).expect("multiline prompt"), + "你好\n第二段\n " + ); + } + + #[test] + fn user_input_rejects_empty_or_whitespace_only_messages() { + let root = prompt_context_project(); + for content in [ + json!([]), + json!([{"type": "input_text", "text": ""}]), + json!([ + {"type": "input_text", "text": ""}, + {"type": "input_text", "text": " \t\r\n\u{3000}"} + ]), + ] { + let item = serde_json::from_value(json!({ + "type": "message", "role": "user", "id": "turn-1:user", "content": content + })) + .expect("canonical user item"); + assert_eq!( + validate_direct_codex_user_item(root.path(), &item), + Err("聊天内容不能为空".to_string()) + ); + } + } + + #[test] + fn valid_references_allow_missing_text_and_whitespace_parts() { + let root = prompt_context_project(); + let asset_id = register_fixture_asset( + root.path(), + "assets/hero.png", + GameCreationAppAssetKind::Character, + "image/png", + ); + for (reference, expected_text) in [ + ( + json!({"type": "agc_resource_reference", "resourceId": asset_id}), + format!("[素材引用 resourceId={asset_id};项目路径=assets/hero.png]"), + ), + ( + json!({"type": "agc_runtime_region_reference", "label": "主画面"}), + "[运行画面区域:名称=主画面 ]".to_string(), + ), + ] { + for (content, expected_prompt) in [ + (json!([reference.clone()]), expected_text.clone()), + ( + json!([ + {"type": "input_text", "text": ""}, + {"type": "input_text", "text": "\n"}, + reference, + {"type": "input_text", "text": " "} + ]), + format!("\n{expected_text} "), + ), + ] { + let item = json!({ + "type": "message", "role": "user", "id": "turn-1:user", "content": content + }); + let canonical = serde_json::from_value(item.clone()).expect("canonical user item"); + assert_eq!( + direct_codex_user_item_to_prompt(root.path(), &canonical) + .expect("reference prompt"), + expected_prompt + ); + let projected = direct_codex_user_item_to_response_item(root.path(), &item) + .expect("reference history projection"); + assert_eq!( + projected["content"].as_array().unwrap().len(), + content.as_array().unwrap().len() + ); + } + } + } + + #[test] + fn nonempty_text_does_not_bypass_invalid_reference_validation() { + let root = prompt_context_project(); + for (reference, expected_error) in [ + ( + json!({"type": "agc_resource_reference", "resourceId": " "}), + "引用的素材 ID 无效,请移除后重新选择", + ), + ( + json!({"type": "agc_resource_reference", "resourceId": "missing"}), + "引用的素材已不存在,请移除后重新选择", + ), + ( + json!({"type": "agc_runtime_region_reference", "label": " "}), + "运行画面区域缺少名称", + ), + ( + json!({"type": "agc_runtime_region_reference", "label": "主画面", "resourceIds": ["missing"]}), + "引用的素材已不存在,请移除后重新选择", + ), + ] { + let item = serde_json::from_value(json!({ + "type": "message", "role": "user", "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "有真实文字"}, + reference, + {"type": "input_text", "text": " "} + ] + })) + .expect("canonical user item"); + assert_eq!( + validate_direct_codex_user_item(root.path(), &item), + Err(expected_error.to_string()) + ); + } + } + #[test] fn history_item_without_type_fails_closed() { let error = direct_codex_user_item_to_response_item( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index ee87f122e..19790ef61 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -3407,7 +3407,7 @@ async fn start_tool_bridge_for_source( } #[cfg(test)] -pub(in crate::agent) struct DirectExecutionTestFixture { +pub(crate) struct DirectExecutionTestFixture { execution: Option, _host: tempfile::TempDir, } @@ -3426,7 +3426,7 @@ impl Drop for DirectExecutionTestFixture { } #[cfg(test)] -pub(in crate::agent) async fn direct_execution_fixture( +pub(crate) async fn direct_execution_fixture( root: &Path, turn: &str, ) -> DirectExecutionTestFixture { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index 74046bc2e..5b09bc5d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -72,7 +72,8 @@ pub(crate) use canvas_generation::{ platform_art_asset_art_spec, platform_art_asset_output_extension_matches, platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path, project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call, - PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS, + validate_platform_art_icon_prompt, PlatformArtAssetGenerationOptions, + PLATFORM_ART_ASSET_GENERATION_KINDS, }; #[allow(unused_imports)] pub(crate) use draft_validation::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 49df719b9..e79f40ac9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -687,7 +687,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options( let snapshot = platform_art_generation_runtime_request_snapshot(&state)?; // 只恢复经过身份校验的已有请求;显式改参和新请求仍走当前合同。 if snapshot.generation_kind == generation_kind - && snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &[], &options) + && snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &options) && options.asset_kind == kind && options.aspect_ratio == ratio && options.image_size == size @@ -2261,39 +2261,16 @@ async fn resolve_platform_art_generation_references_at( Ok(PlatformArtGenerationReferences { canonical, ordered }) } -fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { - // External Editor validates each description independently (currently at - // 200 Unicode characters). Keep the gameplay context short enough that a - // long creation request cannot reject the atlas before it is queued. - const MAX_DESCRIPTION_CHARS: usize = 200; - const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:"; - let category = - "按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定"; - let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub( - category - .chars() - .count() - .saturating_add(CONTEXT_PREFIX.chars().count()), - ); - let project_context = truncate_inline_bounded(prompt.trim(), context_budget); - vec![format!("{category}{CONTEXT_PREFIX}{project_context}")] -} - -fn truncate_inline_bounded(value: &str, max_chars: usize) -> String { - let normalized = value.split_whitespace().collect::>().join(" "); - let actual_chars = normalized.chars().count(); - if actual_chars <= max_chars { - return normalized; +pub(crate) fn validate_platform_art_icon_prompt(prompt: &str) -> Result<(), String> { + // 与 External Editor 的单条 iconDescriptions 合同一致;超限拒绝,不截断或拆条。 + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("图标素材描述不能为空".to_string()); } - if max_chars <= 3 { - return ".".repeat(max_chars); + if prompt.chars().count() > 200 { + return Err("图标素材描述不能超过 200 个字符".to_string()); } - let mut output = normalized - .chars() - .take(max_chars.saturating_sub(3)) - .collect::(); - output.push_str("..."); - output + Ok(()) } fn decode_platform_art_image_with_limits( @@ -2565,10 +2542,10 @@ pub(in crate::agent) struct AdmittedPlatformArtGeneration { pub(in crate::agent) fn admit_platform_art_generation_at( root: &Path, prompt: &str, - briefs: &[AgentGroupBrief], + _briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> Result { - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_prompt = build_platform_art_asset_prompt(prompt, options); let context = standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; let guard = super::external_generation_state::acquire_durable_platform_art_generation_guard( @@ -2590,7 +2567,7 @@ pub(in crate::agent) async fn generate_admitted_platform_art_asset_at( options: &PlatformArtAssetGenerationOptions, admission: AdmittedPlatformArtGeneration, ) -> Result { - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_prompt = build_platform_art_asset_prompt(prompt, options); let context = standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; if admission.context != context @@ -2618,7 +2595,7 @@ pub(crate) async fn generate_platform_art_asset_with_options_at( briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> Result { - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_prompt = build_platform_art_asset_prompt(prompt, options); let runtime_context = standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; generate_platform_art_asset_with_runtime_options_at( @@ -2645,7 +2622,7 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( if options.asset_kind != GameCreationAppAssetKind::IconSpritesheet { return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string()); } - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_prompt = build_platform_art_asset_prompt(prompt, options); let runtime_context = standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; generate_platform_art_asset_with_runtime_options_at( @@ -3106,6 +3083,9 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at options: &PlatformArtAssetGenerationOptions, runtime_context: Option<&PlatformArtGenerationRuntimeContext>, ) -> Result { + if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet { + validate_platform_art_icon_prompt(prompt)?; + } enforce_project_permission_policy(root, "canvas.asset_generate")?; let persisted_runtime_state = runtime_context .map(|context| { @@ -3150,7 +3130,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at .timeout(EXTERNAL_GENERATION_SUBMIT_TIMEOUT) .build() .map_err(|error| format!("创建 External Editor 生成提交客户端失败:{error}"))?; - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_prompt = build_platform_art_asset_prompt(prompt, options); let ( generated, canvas_context, @@ -3348,7 +3328,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "/api/external/v1/editor/icon-spritesheets/generations", serde_json::json!({ "referenceId": reference_id, - "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), + "iconDescriptions": [generation_prompt], "sliceCount": options.slice_count, "sliceMode": options.slice_mode, "gridX": options.grid_x, @@ -8413,7 +8393,6 @@ pub(crate) fn platform_art_asset_art_spec( pub(crate) fn build_platform_art_asset_prompt( prompt: &str, - briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> String { if options.asset_kind == GameCreationAppAssetKind::Image { @@ -8452,20 +8431,8 @@ pub(crate) fn build_platform_art_asset_prompt( truncate_prompt_context(prompt.trim()) ); } - let art_asset_brief = briefs - .iter() - .flat_map(|brief| brief.role_briefs.iter()) - .find(|role_brief| { - role_brief.group_definition.id == "art" && role_brief.role_definition.id == "asset" - }) - .map(|role_brief| role_brief.markdown.trim()) - .filter(|markdown| !markdown.is_empty()) - .unwrap_or(prompt_text!("media.default_art_brief")); - format!( - prompt_text!("media.spritesheet_generation"), - truncate_inline(prompt, 240), - truncate_prompt_context(art_asset_brief) - ) + // 图集的工程化生图指令由 API Server 统一添加,客户端仅保留用户原文。 + prompt.trim().to_string() } #[cfg(test)] @@ -8533,15 +8500,15 @@ mod canvas_generation_tests { } #[test] - fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() { - // 放行 icon-spritesheet 必须真的走到图集提示词与图集请求合同, - // 否则新 IPC 只是加了一个能通过校验但不生成图集的 kind。 + fn generation_kind_catalog_keeps_art_spritesheet_description_and_art_spec() { + // 图集描述由用户提供,工程化生图提示词由 API Server 添加。 let options = PlatformArtAssetGenerationOptions { asset_kind: GameCreationAppAssetKind::IconSpritesheet, ..PlatformArtAssetGenerationOptions::default() }; - assert!( - build_platform_art_asset_prompt("原创收集玩法", &[], &options).contains("素材图集") + assert_eq!( + build_platform_art_asset_prompt("原创收集玩法", &options), + "原创收集玩法" ); assert_eq!( platform_art_asset_art_spec(&options)["assetType"], @@ -8945,7 +8912,7 @@ mod canvas_generation_tests { asset_label: "失败后可重试素材".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let generated_prompt = build_platform_art_asset_prompt("相同手工请求", &[], &options); + let generated_prompt = build_platform_art_asset_prompt("相同手工请求", &options); let runtime_context = standalone_platform_art_generation_runtime_context(&generated_prompt, &options, false) .expect("standalone retry context"); @@ -9121,8 +9088,8 @@ mod canvas_generation_tests { asset_label: "图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &[], &options); - let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &[], &options); + let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &options); + let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &options); let first = standalone_platform_art_generation_runtime_context(&first_prompt, &options, false) .expect("first standalone slot context"); @@ -9556,7 +9523,7 @@ mod canvas_generation_tests { asset_label: "图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &[], &options); + let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &options); let context = standalone_platform_art_generation_runtime_context(&prompt, &options, false) .expect("current standalone context"); let legacy_run_id = legacy_standalone_platform_art_generation_run_id(&options, false) @@ -9676,8 +9643,7 @@ mod canvas_generation_tests { asset_label: "另一个图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let other_prompt = - build_platform_art_asset_prompt("另一个旧槽手工请求", &[], &other_options); + let other_prompt = build_platform_art_asset_prompt("另一个旧槽手工请求", &other_options); let other_context = standalone_platform_art_generation_runtime_context( &other_prompt, &other_options, @@ -10877,7 +10843,7 @@ mod canvas_generation_tests { let current_prompt = "恢复时使用同一个生成意图"; let current_options = PlatformArtAssetGenerationOptions::default(); let durable_generation_prompt = - build_platform_art_asset_prompt(current_prompt, &[], ¤t_options); + build_platform_art_asset_prompt(current_prompt, ¤t_options); let request_body = serde_json::json!({ "prompt": "持久化且必须原样重发的生成正文", "kind": "spec", @@ -11143,7 +11109,7 @@ mod canvas_generation_tests { screen_color: None, }; let prompt = "生成同一套整包美术"; - let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); + let generation_prompt = build_platform_art_asset_prompt(prompt, &options); let request_body = serde_json::json!({ "prompt": generation_prompt, "kind": "spec", @@ -11530,8 +11496,7 @@ mod canvas_generation_tests { asset_label: "账号隔离测试".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let generation_prompt = - build_platform_art_asset_prompt(current_prompt, &[], ¤t_options); + let generation_prompt = build_platform_art_asset_prompt(current_prompt, ¤t_options); let request_body = serde_json::json!({ "prompt": generation_prompt, "kind": "spec", @@ -11739,7 +11704,7 @@ mod canvas_generation_tests { ..PlatformArtAssetGenerationOptions::default() }; let durable_generation_prompt = - build_platform_art_asset_prompt(current_prompt, &[], ¤t_options); + build_platform_art_asset_prompt(current_prompt, ¤t_options); let request_body = serde_json::json!({ "prompt": "持久化的原始生成正文", "kind": "spec", @@ -11883,7 +11848,7 @@ mod canvas_generation_tests { }; let options = PlatformArtAssetGenerationOptions::default(); let old_generation_prompt = - build_platform_art_asset_prompt("原来的贪吃蛇美术方向", &[], &options); + build_platform_art_asset_prompt("原来的贪吃蛇美术方向", &options); let binding_access = ExternalEditorBindingAccess::new(&base_url, "changed-intent-key", None) .expect("prepare changed-intent binding access"); @@ -12050,7 +12015,7 @@ mod canvas_generation_tests { screen_color: None, }; let prompt = "保持同一个生成提示词"; - let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); + let generation_prompt = build_platform_art_asset_prompt(prompt, &options); let binding_access = ExternalEditorBindingAccess::new(&base_url, "changed-reference-key", None) .expect("prepare changed-reference binding access"); @@ -12313,8 +12278,7 @@ mod canvas_generation_tests { .expect("create accepted failure ledger access"); let current_prompt = "失败恢复提示词"; let current_options = PlatformArtAssetGenerationOptions::default(); - let generation_prompt = - build_platform_art_asset_prompt(current_prompt, &[], ¤t_options); + let generation_prompt = build_platform_art_asset_prompt(current_prompt, ¤t_options); let (state, _) = prepare_platform_art_generation_runtime_state( root, &runtime_context, @@ -12517,7 +12481,7 @@ mod canvas_generation_tests { screen_color: None, }; let prompt = "恢复已受理视觉规范图"; - let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); + let generation_prompt = build_platform_art_asset_prompt(prompt, &options); let (_, _, frozen_platform_session) = resolve_canvas_sync_api_credentials(None, None).expect("freeze recovery account"); let frozen_platform_session = @@ -12831,21 +12795,32 @@ mod canvas_generation_tests { } #[test] - fn canonical_art_spritesheet_request_preserves_project_requirements() { - let descriptions = canonical_art_spritesheet_icon_descriptions("原创收集玩法"); - assert_eq!(descriptions.len(), 1); - assert!(descriptions[0].contains("原创收集玩法")); - assert!(descriptions[0].contains("数量、类别、排列和切片方式由本次需求决定")); + fn canonical_art_spritesheet_request_preserves_description_and_internal_newlines() { + let options = PlatformArtAssetGenerationOptions { + asset_kind: GameCreationAppAssetKind::IconSpritesheet, + ..PlatformArtAssetGenerationOptions::default() + }; + let prompt = "\u{0085} 金币\n木制宝箱\n银色钥匙 \u{0085}"; + validate_platform_art_icon_prompt(prompt).expect("valid multiline description"); + assert_eq!( + build_platform_art_asset_prompt(prompt, &options), + "金币\n木制宝箱\n银色钥匙" + ); } #[test] - fn canonical_art_spritesheet_descriptions_obey_external_editor_item_limit() { - let descriptions = canonical_art_spritesheet_icon_descriptions(&"原创玩法需求".repeat(128)); - - assert!(!descriptions.is_empty()); - assert!(descriptions - .iter() - .all(|description| description.chars().count() <= 200)); + fn canonical_art_spritesheet_description_obeys_external_editor_unicode_item_limit() { + let boundary = "🪙".repeat(200); + validate_platform_art_icon_prompt(&format!("\u{0085} {boundary}\n")) + .expect("200 Unicode characters after Rust trim must pass"); + let error = validate_platform_art_icon_prompt(&"🪙".repeat(201)) + .expect_err("201 Unicode characters must be rejected instead of truncated"); + assert!(error.contains("200"), "{error}"); + for blank in ["", " \r\n\t", "\u{0085}\u{2003}"] { + assert!(validate_platform_art_icon_prompt(blank).is_err()); + } + validate_platform_art_icon_prompt("\u{feff}") + .expect("BOM is not whitespace under the API Server Rust trim contract"); } #[tokio::test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index 5c5168d14..c03b02a91 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -241,11 +241,72 @@ impl ProjectCommandTree { #[cfg(not(windows))] { let _ = child; + #[cfg(target_os = "linux")] + { + let Self::Group { pid, .. } = self; + // 容器 PID 1 可能不回收 bwrap 的孤儿僵尸;它们不再执行,也无法被信号终止。 + // 仅在确认没有存活成员时免除清理,存活成员仍须通过 leader 身份核对。 + if !linux_project_command_group_has_live_members(*pid)? { + return Ok(()); + } + } self.request_owned_group_termination().map(|_| ()) } } } +#[cfg(target_os = "linux")] +fn linux_project_command_group_has_live_members(group: u32) -> Result { + let inspect = || -> std::io::Result { + let process_group = i32::try_from(group) + .ok() + .filter(|group| *group > 0) + .ok_or_else(|| std::io::Error::other("受控命令进程组身份无效"))?; + if unsafe { libc::kill(-process_group, 0) } != 0 { + let error = std::io::Error::last_os_error(); + return if error.raw_os_error() == Some(libc::ESRCH) { + Ok(false) + } else { + Err(error) + }; + } + for entry in fs::read_dir("/proc")? { + let entry = entry?; + if entry.file_name().to_string_lossy().parse::().is_err() { + continue; + } + let stat = match fs::read(entry.path().join("stat")) { + Ok(stat) => stat, + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + || error.raw_os_error() == Some(libc::ESRCH) => + { + continue; + } + Err(error) => return Err(error), + }; + let invalid_stat = || std::io::Error::other("无法解析 /proc 进程组状态"); + // comm 可以包含括号和非 UTF-8 字节;只解析最后一个分隔符后的 ASCII 字段。 + let end = stat + .windows(2) + .rposition(|pair| pair == b") ") + .ok_or_else(invalid_stat)?; + let tail = std::str::from_utf8(&stat[end + 2..]).map_err(|_| invalid_stat())?; + let mut fields = tail.split_whitespace(); + let state = fields.next().ok_or_else(invalid_stat)?; + let process_group = fields + .nth(1) + .and_then(|value| value.parse::().ok()) + .ok_or_else(invalid_stat)?; + if process_group == group && state != "Z" && state != "X" { + return Ok(true); + } + } + Ok(false) + }; + inspect().map_err(|error| format!("读取受控进程组存活状态失败:{error}")) +} + #[cfg(any(unix, test))] fn owned_project_command_group_identity_matches( expected: Option<&str>, @@ -2504,6 +2565,67 @@ where mod tests { use super::*; + #[cfg(target_os = "linux")] + #[tokio::test] + async fn exited_group_accepts_orphan_zombies_but_rejects_live_members() { + use std::os::unix::process::CommandExt; + + const TEST: &str = + "command_exec::tests::exited_group_accepts_orphan_zombies_but_rejects_live_members"; + const FIXTURE: &str = "AGC_COMMAND_ORPHAN_FIXTURE"; + if std::env::var_os(FIXTURE).is_none() { + // subreaper 只影响隔离夹具,避免接管并行测试的子进程。 + let output = tokio::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST, "--nocapture"]) + .env(FIXTURE, "1") + .output() + .await + .unwrap(); + assert!(output.status.success(), "{output:?}"); + return; + } + assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0); + let mut command = tokio::process::Command::new("/bin/sh"); + command + .args(["-c", "sleep 60 & echo $!; read release"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + command.as_std_mut().process_group(0); + let mut child = command.spawn().unwrap(); + let tree = ProjectCommandTree::attach(&child).unwrap(); + let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap()); + let mut line = String::new(); + tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line) + .await + .unwrap(); + let descendant: i32 = line.trim().parse().unwrap(); + drop(child.stdin.take()); + child.wait().await.unwrap(); + let live_result = tree.after_main_exit(&mut child).await; + assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0); + let mut info = unsafe { std::mem::zeroed::() }; + assert_eq!( + unsafe { + libc::waitid( + libc::P_PID, + descendant as u32, + &mut info, + libc::WEXITED | libc::WNOWAIT, + ) + }, + 0 + ); + let zombie_result = tree.after_main_exit(&mut child).await; + assert_eq!( + unsafe { libc::waitpid(descendant, std::ptr::null_mut(), 0) }, + descendant + ); + let error = live_result.expect_err("存活成员缺少 leader 身份时必须拒绝清理"); + assert!(error.contains("leader 身份未确认"), "{error}"); + zombie_result.expect("已回收 leader 的进程组只剩僵尸时不应要求人工核对"); + tree.after_main_exit(&mut child).await.unwrap(); + } + #[test] fn owned_process_group_refuses_missing_or_reused_leader_identity() { assert!(owned_project_command_group_identity_matches( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 0c6eccc9b..fa4163a4d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -652,7 +652,7 @@ pub(crate) fn import_local_godot_project( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.create")?; if discover_local_godot_project_root(root)?.is_none() { - return Err("所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string()); + return Err("所选工作区未在根目录或一层子目录发现 project.godot".to_string()); } let _lock = acquire_project_write_lock(root, "project.create")?; import_local_godot_project_at(root, project_id.trim(), name.trim()) @@ -4932,6 +4932,9 @@ pub(crate) fn prepare_local_project_asset_generation( } let asset_kind = normalize_platform_art_asset_generation_kind(kind) .ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?; + if asset_kind == GameCreationAppAssetKind::IconSpritesheet { + crate::agent::validate_platform_art_icon_prompt(prompt)?; + } // 参考入参只接受当前项目 manifest 素材 id:路径、远端 resourceId 与超限在这里就被拒绝, // 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。 let reference_asset_ids = @@ -4941,7 +4944,12 @@ pub(crate) fn prepare_local_project_asset_generation( let target_category = normalize_platform_art_target_category(target_category)?; Ok(LocalProjectAssetGenerationRequest { root: PathBuf::from(project_path), - prompt: local_project_asset_prompt(prompt)?, + prompt: if asset_kind == GameCreationAppAssetKind::IconSpritesheet { + // 图集只采用 API Server 的 trim + 单条长度合同,不套普通图片的控制字符限制。 + prompt.trim().to_string() + } else { + local_project_asset_prompt(prompt)? + }, options: PlatformArtAssetGenerationOptions { output_path: local_project_asset_single_line( output_path, @@ -5045,6 +5053,28 @@ mod local_project_asset_generation_tests { ) } + #[test] + fn icon_prompt_matches_api_server_single_description_contract() { + for prompt in [ + " 金币\n\n宝箱\t钥匙 ".to_string(), + "😀".repeat(200), + "\u{85}金币\u{85}".to_string(), + "\u{feff}".to_string(), + "金币\u{0}宝箱".to_string(), + ] { + assert_eq!( + prepare("icon-spritesheet", &prompt) + .expect("valid server description") + .prompt, + prompt.trim() + ); + } + for prompt in ["\u{85}\n ".to_string(), "😀".repeat(201)] { + assert!(prepare("icon-spritesheet", &prompt).is_err()); + } + assert!(prepare("image", &"图".repeat(201)).is_ok()); + } + #[test] fn toolbar_kinds_all_reach_the_shared_generation_channel() { for kind in [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index d3f31417c..839311a3f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -862,6 +862,48 @@ pub(crate) fn validate_game_creator_private_path_ancestors( Ok(()) } +/// 客户端安装身份基线:`productName` / `identifier` 由构建期按渠道注入。 +/// +/// 默认渠道保持基线身份,其它渠道派生 `<基线>.<渠道>`,因此同一台设备上 +/// 不同渠道各自拥有独立的安装目录与 AppData 数据目录。 +#[cfg(windows)] +const GAME_CREATOR_APP_IDENTIFIER: &str = "world.genarrative.ai-game-creator"; + +#[cfg(windows)] +fn is_game_creator_packaged_app_data_leaf(name: &std::ffi::OsStr) -> bool { + let Some(name) = name.to_str() else { + return false; + }; + let Some(remainder) = name.strip_prefix(GAME_CREATOR_APP_IDENTIFIER) else { + return false; + }; + if remainder.is_empty() { + return true; + } + // 渠道名是小写字母开头的 32 位以内小写字母、数字与连字符。 + remainder + .strip_prefix('.') + .is_some_and(|channel| !channel.is_empty() && channel.len() <= 32) +} + +/// 路径是否位于 `<平台配置根>/<安装身份目录>` 之内。提权助手是独立进程, +/// 看不到父进程的配置目录覆盖,因此这里必须按目录名识别全部渠道身份。 +#[cfg(windows)] +fn path_is_inside_game_creator_packaged_app_data(root: &Path, path: &Path) -> bool { + let root = normalize_windows_policy_path(root); + let path = normalize_windows_policy_path(path); + let Ok(relative) = path.strip_prefix(&root) else { + return false; + }; + relative + .components() + .next() + .is_some_and(|component| match component { + std::path::Component::Normal(name) => is_game_creator_packaged_app_data_leaf(name), + _ => false, + }) +} + /// Automatic ACL repair for managed paths is limited to objects AGC owns. A /// separate, explicit user-selected scope below covers native picker/project /// root results, including projects stored outside the current profile. @@ -900,21 +942,20 @@ fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool { // elevated helper runs in a fresh process, so the in-memory runtime // config-dir override is unavailable there; recognize the packaged // path from the user's profile as well. - let packaged_app_data = home - .join("AppData") - .join("Local") - .join("world.genarrative.ai-game-creator"); - if starts_with_path(&packaged_app_data) { + #[cfg(windows)] + if path_is_inside_game_creator_packaged_app_data(&home.join("AppData").join("Local"), &path) + { return true; } } + #[cfg(windows)] for environment_name in ["LOCALAPPDATA", "APPDATA"] { if let Some(root) = std::env::var_os(environment_name) .map(PathBuf::from) .filter(|candidate| candidate.is_absolute()) { - if starts_with_path(&root.join("world.genarrative.ai-game-creator")) { + if path_is_inside_game_creator_packaged_app_data(&root, &path) { return true; } } @@ -1096,10 +1137,9 @@ fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScop .filter(|candidate| candidate.is_absolute()) { if is_builtin_root(home.join(".config").join("genarrative")) - || is_builtin_root( - home.join("AppData") - .join("Local") - .join("world.genarrative.ai-game-creator"), + || path_is_inside_game_creator_packaged_app_data( + &home.join("AppData").join("Local"), + &path, ) { return WindowsAclRepairScope::Managed; @@ -1110,7 +1150,7 @@ fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScop .map(PathBuf::from) .filter(|candidate| candidate.is_absolute()) { - if is_builtin_root(root.join("world.genarrative.ai-game-creator")) { + if path_is_inside_game_creator_packaged_app_data(&root, &path) { return WindowsAclRepairScope::Managed; } } @@ -5044,18 +5084,38 @@ mod private_path_elevation_policy_tests { #[cfg(windows)] #[test] - fn verbatim_packaged_appdata_path_keeps_managed_repair_scope() { + fn packaged_appdata_paths_keep_managed_repair_scope_for_every_channel() { let root = std::env::var_os("LOCALAPPDATA") .or_else(|| std::env::var_os("APPDATA")) .map(PathBuf::from) .expect("local appdata"); - let packaged = root.join("world.genarrative.ai-game-creator"); - let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display())); - assert!(game_creator_private_path_allows_auto_elevation(&verbatim)); + // 默认渠道是基线目录,其它渠道派生 `<基线>.<渠道>`;提权助手按目录名识别, + // 两种身份都必须落在 managed 赋权范围内。 + for leaf in [ + "world.genarrative.ai-game-creator", + "world.genarrative.ai-game-creator.release", + "world.genarrative.ai-game-creator.beta-2", + ] { + let packaged = root.join(leaf).join("diagnostics"); + let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display())); + assert!( + game_creator_private_path_allows_auto_elevation(&verbatim), + "{leaf}" + ); + assert_eq!( + game_creator_runtime_config_repair_scope(&verbatim), + WindowsAclRepairScope::Managed, + "{leaf}" + ); + } + + // 相似前缀不是安装身份目录,不能落进 managed 赋权范围。 + let foreign = root.join("world.genarrative.ai-game-creator-backup"); + assert!(!game_creator_private_path_allows_auto_elevation(&foreign)); assert_eq!( - game_creator_runtime_config_repair_scope(&verbatim), - WindowsAclRepairScope::Managed + game_creator_runtime_config_repair_scope(&foreign), + WindowsAclRepairScope::UserSelected ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index c96f5f101..905311f77 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2005,6 +2005,13 @@ fn show_startup_error_dialog(log_path: Option<&Path>) { } } +/// 客户端产品名跟随构建期渠道身份:默认渠道是「陶泥儿」,其它渠道带渠道后缀 +/// (例如「陶泥儿 Release」)。同机并存的渠道客户端因此在窗口标题、任务栏与 +/// Alt-Tab 里可区分;默认渠道结果不变。 +pub(crate) fn game_creator_product_name(app: &tauri::AppHandle) -> String { + app.package_info().name.clone() +} + /// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir` /// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时 /// `StartupLogSlot::fail` 仍然必须给出用户可见提示。 @@ -2468,6 +2475,16 @@ fn main() { setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized"); error })?; + // 主窗口标题与产品名保持一致:配置里的标题来自基线配置,渠道后缀只 + // 由构建期身份决定,因此必须在这里按产品名覆盖。 + match app.get_webview_window("client") { + Some(window) => { + if let Err(error) = window.set_title(&game_creator_product_name(app.handle())) { + app_log!("startup.window-title.failed: {error}"); + } + } + None => app_log!("startup.window-title.failed: 缺少 client 主窗口"), + } spawn_project_snapshot_scheduler(app.handle().clone()); if let Err(error) = builtin_plugins::initialize(&config_dir) { app_log!("startup.builtin-plugins.initialize.failed: {error}"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 2094ef492..ae4ad0550 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -84,9 +84,15 @@ fn godot_metadata_is_link(metadata: &fs::Metadata) -> bool { metadata.file_type().is_symlink() || godot_metadata_is_reparse_point(metadata) } +/// Godot 工程标记:`project.godot` 解析为普通文件即命中。 +/// +/// 2026-09-21 解除“必须是普通文件”的限制:符号链接、Windows reparse point 与 +/// 硬链接一律跟随,不再因为 `project.godot` 本身是链接而拒绝整个工作区。判据只 +/// 保留“解析后仍是文件”,目录或悬空链接仍然不算命中。 fn inspect_godot_project_marker(root: &Path) -> Result { let project_file = root.join("project.godot"); - let metadata = match fs::symlink_metadata(&project_file) { + // `fs::metadata` 跟随符号链接 / reparse point,因此链接指向的真实对象才是判据。 + let metadata = match fs::metadata(&project_file) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(error) => { @@ -96,64 +102,12 @@ fn inspect_godot_project_marker(root: &Path) -> Result { )); } }; - if godot_metadata_is_link(&metadata) { - return Err(format!( - "Godot 项目文件不能是符号链接或 reparse point:{}", - project_file.display() - )); - } if !metadata.is_file() { return Err(format!( - "Godot 项目文件必须是普通文件:{}", + "Godot 项目文件必须是文件:{}", project_file.display() )); } - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - if metadata.nlink() != 1 { - return Err(format!( - "Godot 项目文件不能是硬链接文件:{}", - project_file.display() - )); - } - } - #[cfg(windows)] - { - use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Storage::FileSystem::{ - GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, - }; - - const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; - let file = fs::File::open(&project_file).map_err(|error| { - format!( - "打开 Godot 项目文件失败:{}: {error}", - project_file.display() - ) - })?; - // SAFETY: the structure is plain data initialized by GetFileInformationByHandle. - let mut information = unsafe { std::mem::zeroed::() }; - // SAFETY: file owns a live handle and information is a valid output pointer. - // 取不到句柄信息、目录与 reparse point 一律按拒绝处理,保持 fail-closed。 - if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 - || information.dwFileAttributes - & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) - != 0 - { - return Err(format!( - "读取 Godot 项目文件 Windows 身份失败:{}", - std::io::Error::last_os_error() - )); - } - if information.nNumberOfLinks != 1 { - return Err(format!( - "Godot 项目文件不能是硬链接文件:{}", - project_file.display() - )); - } - } Ok(true) } @@ -166,6 +120,62 @@ fn validate_godot_project_child_name(name: &std::ffi::OsStr) -> Result Result<(), String> { + let path = root.join("project.godot"); + let text = match fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "读取 Godot 工程配置失败:{}: {error}", + path.display() + )); + } + }; + let newline = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let ends_with_newline = text.ends_with('\n'); + let mut replaced = false; + let mut lines = Vec::new(); + for line in text.lines() { + let trimmed = line.trim_start(); + if !replaced { + if let Some(rest) = trimmed.strip_prefix("config/name") { + let rest = rest.trim_start(); + if let Some(value) = rest.strip_prefix('=') { + let value = value.trim(); + if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { + let indent = &line[..line.len() - trimmed.len()]; + lines.push(format!( + "{indent}config/name=\"{}\"", + escape_godot_project_string(name) + )); + replaced = true; + continue; + } + } + } + } + lines.push(line.to_string()); + } + if !replaced { + return Ok(()); + } + let mut updated = lines.join(newline); + if ends_with_newline { + updated.push_str(newline); + } + write_game_creator_private_file(&path, updated.as_bytes(), "Godot 工程配置") +} + +fn escape_godot_project_string(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + fn validate_manifest_godot_project_root(value: Option<&str>) -> Result<(), String> { let Some(value) = value else { return Ok(()); @@ -346,13 +356,9 @@ pub(crate) fn discover_local_godot_project_root( matches.push(validate_godot_project_child_name(&entry.file_name())?); } matches.sort(); - if matches.len() > 1 { - return Err(format!( - "工作区一层子目录中发现多个 Godot 项目:{}", - matches.join("、") - )); - } - let Some(relative_root) = matches.pop() else { + // 2026-09-21 解除“必须唯一”的限制:一层子目录出现多个 Godot 工程时按名称排序 + // 取第一个,结果确定且可复现,不再因为存在第二个工程而整体失败关闭。 + let Some(relative_root) = matches.into_iter().next() else { return Ok(None); }; @@ -694,9 +700,8 @@ pub(crate) fn import_local_godot_project_at( if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { return Err("Godot 工作区目录不存在或不是普通文件夹".to_string()); } - let godot_project_root = discover_local_godot_project_root(root)?.ok_or_else(|| { - "所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string() - })?; + let godot_project_root = discover_local_godot_project_root(root)? + .ok_or_else(|| "所选工作区未在根目录或一层子目录发现 project.godot".to_string())?; if project_id.is_empty() { return Err("项目 ID 不能为空".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs index 434adb727..ab0f99690 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs @@ -133,17 +133,18 @@ fn root_godot_project_takes_priority_over_direct_child_projects() { } #[test] -fn rejects_multiple_direct_child_godot_projects_before_writing_agent_metadata() { +fn picks_the_first_direct_child_godot_project_in_name_order() { let workspace = godot_import_test_path("multiple-children"); write_godot_project(&workspace.join("alpha"), "Alpha"); write_godot_project(&workspace.join("beta"), "Beta"); - let error = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous") - .expect_err("multiple direct child Godot projects must fail"); + let result = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous") + .expect("multiple direct child Godot projects must resolve deterministically"); - assert!(error.contains("多个 Godot 项目"), "{error}"); - assert!(!workspace.join(".agent").exists()); - assert!(!workspace.join("alpha/.agent").exists()); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha")); + assert_manifest_godot_root(&workspace, "alpha"); + assert!(workspace.join(".agent/manifest.json").is_file()); + // 未选中的候选工程不写任何 AGC 元数据。 assert!(!workspace.join("beta/.agent").exists()); fs::remove_dir_all(workspace).ok(); } @@ -187,23 +188,21 @@ fn calibrates_existing_manifest_to_the_discovered_godot_root() { } #[test] -fn ambiguous_layout_does_not_rewrite_an_existing_manifest() { +fn ambiguous_layout_calibrates_an_existing_manifest_deterministically() { let workspace = godot_import_test_path("ambiguous-existing"); init_local_game_project_at(&workspace, "existing-project", "Existing Project") .expect("initialize existing workspace"); write_godot_project(&workspace.join("game"), "Game"); write_godot_project(&workspace.join("other"), "Other"); - let manifest_path = workspace.join(".agent/manifest.json"); - let original = fs::read(&manifest_path).expect("read original manifest"); - let error = import_local_godot_project_at(&workspace, "ignored", "Ignored") - .expect_err("ambiguous existing workspace must fail"); + let result = import_local_godot_project_at(&workspace, "ignored", "Ignored") + .expect("ambiguous existing workspace must calibrate to one candidate"); - assert!(error.contains("多个 Godot 项目"), "{error}"); - assert_eq!( - fs::read(&manifest_path).expect("read unchanged manifest"), - original - ); + assert_eq!(result.manifest.project_id, "existing-project"); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game")); + assert_manifest_godot_root(&workspace, "game"); + assert!(!workspace.join("game/.agent").exists()); + assert!(!workspace.join("other/.agent").exists()); fs::remove_dir_all(workspace).ok(); } @@ -286,7 +285,7 @@ fn manifest_read_rejects_unsafe_persisted_godot_project_root() { #[cfg(unix)] #[test] -fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() { +fn accepts_symbolic_link_project_marker() { use std::os::unix::fs::symlink; let workspace = godot_import_test_path("linked-marker"); @@ -294,11 +293,51 @@ fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() { fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker"); symlink("real.godot", workspace.join("project.godot")).expect("link project marker"); - let error = import_local_godot_project_at(&workspace, "linked", "Linked") - .expect_err("linked project.godot must fail"); + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("linked project.godot must be accepted"); - assert!(error.contains("符号链接"), "{error}"); - assert!(!workspace.join(".agent").exists()); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_manifest_godot_root(&workspace, "."); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(windows)] +#[test] +fn accepts_windows_hard_link_project_marker() { + let workspace = godot_import_test_path("windows-hard-link-marker"); + fs::create_dir_all(&workspace).expect("create hard link marker workspace"); + fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker"); + fs::hard_link( + workspace.join("real.godot"), + workspace.join("project.godot"), + ) + .expect("hard link project marker"); + + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("hard linked project.godot must be accepted"); + + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_manifest_godot_root(&workspace, "."); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(windows)] +#[test] +fn accepts_windows_reparse_project_marker() { + let workspace = godot_import_test_path("windows-reparse-marker"); + fs::create_dir_all(&workspace).expect("create reparse marker workspace"); + fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker"); + if std::os::windows::fs::symlink_file("real.godot", workspace.join("project.godot")).is_err() { + // 未开启开发者模式的机器创建文件符号链接需要额外权限,跳过而不是误报通过。 + fs::remove_dir_all(workspace).ok(); + return; + } + + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("reparse point project.godot must be accepted"); + + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_manifest_godot_root(&workspace, "."); fs::remove_dir_all(workspace).ok(); } @@ -344,7 +383,7 @@ fn ignores_unrelated_symbolic_link_while_importing_a_unique_regular_child() { #[cfg(unix)] #[test] -fn rejects_linked_marker_inside_a_regular_child_candidate() { +fn accepts_linked_marker_inside_a_regular_child_candidate() { use std::os::unix::fs::symlink; let workspace = godot_import_test_path("linked-child-marker"); @@ -353,11 +392,12 @@ fn rejects_linked_marker_inside_a_regular_child_candidate() { fs::write(godot_root.join("real.godot"), "[application]\n").expect("write real marker"); symlink("real.godot", godot_root.join("project.godot")).expect("link project marker"); - let error = import_local_godot_project_at(&workspace, "linked", "Linked") - .expect_err("linked marker in a regular child must fail"); + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("linked marker in a regular child must be accepted"); - assert!(error.contains("符号链接"), "{error}"); - assert!(!workspace.join(".agent").exists()); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game")); + assert_manifest_godot_root(&workspace, "game"); + assert!(!godot_root.join(".agent").exists()); fs::remove_dir_all(workspace).ok(); } @@ -397,6 +437,42 @@ fn rejects_non_godot_directory_without_writing_agent_metadata() { fs::remove_dir_all(root).ok(); } +#[test] +fn rewrites_only_the_godot_display_name_line() { + let root = godot_import_test_path("display-name"); + fs::create_dir_all(&root).expect("create project root"); + fs::write( + root.join("project.godot"), + "config_version=5\n\n[application]\n\nconfig/name=\"模板名\"\nrun/main_scene=\"res://scenes/main.tscn\"\n", + ) + .expect("write project.godot"); + + apply_godot_project_display_name(&root, "我的\"平台跳跃\"").expect("rewrite display name"); + + assert_eq!( + fs::read_to_string(root.join("project.godot")).expect("read project.godot"), + "config_version=5\n\n[application]\n\nconfig/name=\"我的\\\"平台跳跃\\\"\"\nrun/main_scene=\"res://scenes/main.tscn\"\n" + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn keeps_a_godot_project_without_a_display_name_line_untouched() { + let root = godot_import_test_path("no-display-name"); + fs::create_dir_all(&root).expect("create project root"); + let original = + "config_version=5\n\n[application]\n\nrun/main_scene=\"res://scenes/main.tscn\"\n"; + fs::write(root.join("project.godot"), original).expect("write project.godot"); + + apply_godot_project_display_name(&root, "我的项目").expect("no display name is not an error"); + + assert_eq!( + fs::read_to_string(root.join("project.godot")).expect("read project.godot"), + original + ); + fs::remove_dir_all(root).ok(); +} + fn write_raw_manifest_fixture(workspace: &Path, payload: &serde_json::Value) -> (PathBuf, String) { let manifest_path = workspace.join(".agent/manifest.json"); fs::create_dir_all(manifest_path.parent().expect("manifest parent")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index b9a4e644f..fbf8fbc10 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -807,6 +807,45 @@ async fn ensure_template_installed( }) } +/// 清单来源:远端读取或本机缓存兜底。快照里的 `source` 原样回传,前端据此提示「本机缓存」。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TemplateIndexSource { + Network, + Cache, +} + +impl TemplateIndexSource { + fn as_str(self) -> &'static str { + match self { + Self::Network => "network", + Self::Cache => "cache", + } + } +} + +/// 远端清单不可用时回退本机缓存。 +/// +/// 两条路径都要过同一份 schema 校验:远端已经答话但正文非法时直接失败关闭, +/// 不用缓存掩盖;缓存自己损坏时也不能被当成可用清单。 +fn resolve_template_index( + remote: Result, + cached: Option, +) -> Result<(String, TemplateIndexSource), String> { + match remote { + Ok(body) => { + parse_game_template_library_index(&body)?; + Ok((body, TemplateIndexSource::Network)) + } + Err(error) => match cached { + Some(cached) => { + parse_game_template_library_index(&cached)?; + Ok((cached, TemplateIndexSource::Cache)) + } + None => Err(error), + }, + } +} + #[tauri::command] pub(crate) async fn fetch_game_template_library( app: tauri::AppHandle, @@ -820,26 +859,21 @@ pub(crate) async fn fetch_game_template_library( TEMPLATE_LIBRARY_INDEX_KEY ); let client = build_template_library_client(); - let (body, source) = + let remote = match fetch_limited_bytes(&client, &index_url, TEMPLATE_LIBRARY_MAX_INDEX_BYTES).await { + // 响应回来了但正文不是合法 UTF-8 属于「远端答非所问」,不能用缓存掩盖。 Ok(bytes) => { - let body = - String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?; - parse_game_template_library_index(&body)?; - with_validated_platform_session_identity(&identity, || { - write_cached_index(&cache_root, &body); - Ok(()) - })?; - (body, "network") + Ok(String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?) } - Err(error) => match read_cached_index(&cache_root) { - Some(cached) => { - parse_game_template_library_index(&cached)?; - (cached, "cache") - } - None => return Err(error), - }, + Err(error) => Err(error), }; + let (body, source) = resolve_template_index(remote, read_cached_index(&cache_root))?; + if source == TemplateIndexSource::Network { + with_validated_platform_session_identity(&identity, || { + write_cached_index(&cache_root, &body); + Ok(()) + })?; + } validate_platform_session_identity(&identity)?; let (header, templates) = parse_game_template_library_index(&body)?; let installed = collect_installed_records(&cache_root); @@ -859,7 +893,7 @@ pub(crate) async fn fetch_game_template_library( library_version: header.library_version, updated_at: header.updated_at, fetched_at_millis: now_millis(), - source: source.to_string(), + source: source.as_str().to_string(), templates: entries, }) } @@ -956,6 +990,16 @@ pub(crate) fn create_project_from_installed_template_at( &project_name, ); } + // Godot 模板同样按工程文件识别:走既有 Godot 导入流程,写入 + // `godotProjectRoot` 并按用户输入改写工程显示名,不生成 Web 占位入口。 + if discover_local_godot_project_root(&project_root)?.is_some() { + apply_godot_project_display_name(&project_root, &project_name)?; + return import_local_godot_project_at( + &project_root, + &format!("gameagent-{workspace_id}"), + &project_name, + ); + } init_local_game_project_at( &project_root, &format!("gameagent-{workspace_id}"), @@ -1181,6 +1225,50 @@ mod tests { )) } + #[test] + fn template_index_prefers_the_network_body_and_labels_its_source() { + let body = sample_index_body(); + let (resolved, source) = + resolve_template_index(Ok(body.clone()), Some("{not json".to_string())) + .expect("合法远端正文优先于缓存"); + assert_eq!(source.as_str(), "network"); + assert_eq!(resolved, body); + } + + #[test] + fn template_index_falls_back_to_the_cache_body_when_the_remote_fetch_fails() { + let cached = sample_index_body(); + let (resolved, source) = + resolve_template_index(Err("网络不可用".to_string()), Some(cached.clone())) + .expect("远端不可用时回退本机缓存"); + assert_eq!(source.as_str(), "cache"); + assert_eq!(resolved, cached); + } + + #[test] + fn template_index_keeps_the_remote_error_when_no_cache_exists() { + let error = resolve_template_index(Err("远端超时".to_string()), None) + .expect_err("没有缓存时必须暴露远端错误"); + assert_eq!(error, "远端超时"); + } + + #[test] + fn template_index_rejects_an_invalid_remote_body_without_masking_it_with_cache() { + let cached = sample_index_body(); + let invalid = cached.replace(TEMPLATE_LIBRARY_SCHEMA_VERSION, "agc-template-library.v2"); + let error = resolve_template_index(Ok(invalid), Some(cached)) + .expect_err("远端已答话但正文非法时不得用缓存掩盖"); + assert!(error.contains("版本不受支持"), "{error}"); + } + + #[test] + fn template_index_rejects_a_corrupt_cache_instead_of_serving_it() { + let error = + resolve_template_index(Err("网络不可用".to_string()), Some("{not json".to_string())) + .expect_err("损坏的缓存必须失败关闭"); + assert!(error.contains("不是有效 JSON"), "{error}"); + } + #[test] fn rejects_index_with_unsupported_schema_or_duplicate_templates() { let unsupported = @@ -1323,6 +1411,8 @@ mod tests { .unwrap() .join("escape.txt") .exists()); + // 越界归档失败后不能留下安装记录:目录残留不算「已下载」。 + assert!(collect_installed_records(destination.path()).is_empty()); } #[test] @@ -1371,6 +1461,13 @@ mod tests { let error = install_template_archive(cache_root.path(), &summary, &archive) .expect_err("digest mismatch rejected"); assert!(error.contains("完整性校验失败"), "{error}"); + + // 大小或摘要不一致必须在任何落盘之前失败:不留安装目录,也不产生「已下载」判据。 + let directory = + installed_template_dir(cache_root.path(), &summary.id, &summary.template_version) + .expect("install dir"); + assert!(!directory.exists(), "拒绝的模板不得留下安装目录"); + assert!(collect_installed_records(cache_root.path()).is_empty()); } #[test] @@ -1405,6 +1502,49 @@ mod tests { fs::remove_dir_all(&projects_root).ok(); } + #[test] + fn godot_template_creates_native_project_with_relative_root_and_display_name() { + let cache_root = tempfile::tempdir().expect("temp dir"); + let projects_root = unique_projects_root(); + let config_source = "config_version=5\n\n[application]\n\nconfig/name=\"Godot 模板\"\nrun/main_scene=\"res://scenes/main.tscn\"\n"; + let archive = build_archive(&[ + ("project.godot", config_source.as_bytes()), + ("scenes/main.tscn", b"[gd_scene format=3]\n"), + ]); + let mut summary = sample_summary(); + summary.id = "godot-fixture-template".to_string(); + summary.runtime = "godot".to_string(); + summary.entry = "project.godot".to_string(); + summary.zip_size_bytes = archive.len() as u64; + summary.zip_sha256 = sha256_hex(&archive); + let record = install_template_archive(cache_root.path(), &summary, &archive) + .expect("install Godot template"); + + let result = create_project_from_installed_template_at( + &projects_root, + Path::new(&record.project_dir), + Some("我的平台跳跃"), + false, + ) + .expect("create Godot project from template"); + + // Godot 模板走 Godot 导入:记录相对根,且不生成 Web 占位入口与并行目录。 + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_eq!(result.manifest.name, "我的平台跳跃"); + let project_root = Path::new(&result.project_path); + assert!(!project_root.join("game").exists()); + assert!(project_root.join("scenes/main.tscn").is_file()); + let config = + fs::read_to_string(project_root.join("project.godot")).expect("read project.godot"); + assert!(config.contains("config/name=\"我的平台跳跃\""), "{config}"); + assert!( + config.contains("run/main_scene=\"res://scenes/main.tscn\""), + "只改显示名,其余行逐字保留:{config}" + ); + assert!(!project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).exists()); + fs::remove_dir_all(&projects_root).ok(); + } + #[test] fn refuses_to_create_project_when_template_is_not_installed() { let projects_root = tempfile::tempdir().expect("temp dir"); @@ -1415,6 +1555,38 @@ mod tests { assert!(error.contains("模板尚未安装"), "{error}"); } + #[test] + fn failed_project_creation_removes_the_partial_project_directory() { + let cache_root = tempfile::tempdir().expect("temp dir"); + let projects_root = unique_projects_root(); + // 安装目录里只有安装记录、没有任何可复制文件:复制阶段必须失败关闭, + // 且已经创建的项目目录要被清掉,不能把半成品留在自动工作区里。 + let directory = installed_template_dir(cache_root.path(), "empty-template", "1.0.0") + .expect("install dir"); + ensure_game_creator_private_directory_tree(&directory, "模板安装目录") + .expect("create install dir"); + write_game_creator_private_file( + &directory.join(TEMPLATE_INSTALLED_MARKER_FILE), + b"{\"templateId\":\"empty-template\"}", + "模板安装记录", + ) + .expect("write install marker"); + + let error = + create_project_from_installed_template_at(&projects_root, &directory, None, false) + .expect_err("模板没有可复制文件时必须失败关闭"); + assert!(error.contains("没有可复制的文件"), "{error}"); + let leftovers = fs::read_dir(&projects_root) + .map(|entries| { + entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .collect::>() + }) + .unwrap_or_default(); + assert!(leftovers.is_empty(), "失败不得留下项目目录:{leftovers:?}"); + } + fn template_source_files(root: &Path) -> Vec<(String, Vec)> { fn collect(root: &Path, directory: &Path, files: &mut Vec<(String, Vec)>) { for entry in fs::read_dir(directory).expect("read template directory") { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs index b64d1a51d..42aee42ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs @@ -1827,7 +1827,7 @@ async fn agent_runtime_command_exec_diagnostic_command_cannot_pass_verification_ ) .await; - assert_eq!(observation.status, "ok"); + assert_observation_status(&observation, "ok"); assert!(observation.summary.contains("只作为诊断结果")); assert!(observation .detail diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index b722582a2..6e9abb9e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1649,6 +1649,7 @@ async fn direct_image_generation_notifies_after_manifest_commit() { }, ) .expect("allow generation"); + let execution = direct_execution_fixture(&root, "direct-image-refresh-turn").await; let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).expect("bind event receiver"); let sink = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); @@ -1691,6 +1692,12 @@ async fn direct_image_generation_notifies_after_manifest_commit() { .await .expect("read rejected result"); assert_eq!(rejected["isError"], true); + assert!( + rejected["content"][0]["text"] + .as_str() + .is_some_and(|message| message.contains("prompt")), + "{rejected}" + ); assert_eq!( read_manifest_invalidation_relay_payload_with_deadline(&listener) .expect_err("rejected generation must not emit a commit") @@ -1698,6 +1705,7 @@ async fn direct_image_generation_notifies_after_manifest_commit() { io::ErrorKind::TimedOut ); drop(bridge); + drop(execution); fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); } @@ -2532,42 +2540,45 @@ fn project_directory_status_reports_workspace_relative_godot_root() { } #[test] -fn project_directory_status_rejects_ambiguous_direct_child_godot_projects() { +fn project_directory_status_resolves_ambiguous_direct_child_godot_projects() { let root = unique_project_path(); - for child in ["alpha", "beta"] { + // 目录枚举顺序不可信:故意倒序创建,证明选择按名称而不是按创建顺序。 + for child in ["beta", "alpha"] { let godot_root = root.join(child); fs::create_dir_all(&godot_root).expect("create nested Godot project"); fs::write(godot_root.join("project.godot"), "[application]\n") .expect("write nested project.godot"); } - let error = inspect_local_project_directory_sync(root.to_string_lossy().to_string()) - .expect_err("ambiguous Godot workspace must fail inspection"); + let status = inspect_local_project_directory_sync(root.to_string_lossy().to_string()) + .expect("multiple direct child Godot projects must resolve deterministically"); - assert!(error.contains("多个 Godot 项目"), "{error}"); + assert!(status.is_godot_project); + assert_eq!(status.godot_project_root.as_deref(), Some("alpha")); fs::remove_dir_all(root).ok(); } #[test] -fn godot_import_command_rejects_ambiguity_before_creating_the_project_lock() { +fn godot_import_command_resolves_child_ambiguity_and_releases_the_lock() { let root = unique_project_path(); - for child in ["alpha", "beta"] { + for child in ["beta", "alpha"] { let godot_root = root.join(child); fs::create_dir_all(&godot_root).expect("create nested Godot project"); fs::write(godot_root.join("project.godot"), "[application]\n") .expect("write nested project.godot"); } - let error = import_local_godot_project( + let result = import_local_godot_project( root.to_string_lossy().into_owned(), "ambiguous".to_string(), "Ambiguous".to_string(), ) - .expect_err("ambiguous Godot workspace must fail before locking"); + .expect("ambiguous Godot workspace must import the first candidate deterministically"); - assert!(error.contains("多个 Godot 项目"), "{error}"); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha")); + assert!(root.join(".agent/manifest.json").is_file()); + assert!(!root.join("beta/.agent").exists()); assert!(!root.join(PROJECT_WRITE_LOCK_PATH).exists()); - assert!(!root.join(".agent").exists()); fs::remove_dir_all(root).ok(); } @@ -3542,7 +3553,8 @@ async fn generate_local_project_asset_command_generates_icon_spritesheet_from_th { let root = unique_project_path(); let config_dir = unique_project_path(); - let base_url = spawn_mock_external_canvas_generation_api_server(None); + let (request_sender, request_receiver) = mpsc::channel(); + let base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender)); let _platform_session = crate::platform_session::install_test_platform_session( "toolbar-spritesheet-user", "editor-toolbar-spritesheet-key", @@ -3597,7 +3609,7 @@ async fn generate_local_project_asset_command_generates_icon_spritesheet_from_th let asset = generate_local_project_asset( root.to_string_lossy().into_owned(), "icon-spritesheet".to_string(), - "原创收集玩法素材图集".to_string(), + "\u{0085} 金币\n木制宝箱\n银色钥匙 \u{0085}".to_string(), Some("1:1".to_string()), Some("1K".to_string()), Some("游戏首版图集".to_string()), @@ -3608,6 +3620,23 @@ async fn generate_local_project_asset_command_generates_icon_spritesheet_from_th .await .expect("toolbar spritesheet generation"); + let generation_request = request_receiver + .try_iter() + .find(|request| request.starts_with("POST /api/editor/icon-spritesheets/generations ")) + .expect("captured toolbar spritesheet request"); + let generation_body: Value = serde_json::from_str( + generation_request + .split_once("\r\n\r\n") + .expect("generation request body") + .1, + ) + .expect("generation request json"); + assert_eq!( + generation_body["iconDescriptions"], + serde_json::json!(["金币\n木制宝箱\n银色钥匙"]), + "客户端只去除首尾空白,完整保留用户描述,不注入或截断提示词" + ); + assert!(asset.local_path.starts_with("assets/canvas-generated/")); let manifest: Value = serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) @@ -6358,7 +6387,6 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开", - &[], &options, ); for expected in [ @@ -6400,7 +6428,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { } #[test] -fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_defense() { +fn art_spritesheet_generation_prompt_preserves_user_description() { let options = PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), asset_kind: GameCreationAppAssetKind::IconSpritesheet, @@ -6409,21 +6437,12 @@ fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_de }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇,需要蛇头、直身、转角、尾部和四类食物", - &[], &options, ); - for expected in [ - "原创透明核心美术素材图集", - "严格从用户需求和美术 brief 提取", - "素材类别与数量以当前项目需求为准", - "蛇头、直身、转角、尾部和四类食物", - "角色轮廓、图标排布与配色采用项目原创设计", - ] { - assert!( - prompt.contains(expected), - "art generation prompt missing {expected}" - ); - } + assert_eq!( + prompt, + "原创网格贪吃蛇,需要蛇头、直身、转角、尾部和四类食物" + ); let art_spec = platform_art_asset_art_spec(&options).to_string(); for expected in [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index 38488aa41..4406f2e29 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -157,7 +157,7 @@ pub(crate) fn open_game_creator_workspace_window( existing.close().map_err(|error| error.to_string())?; } tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path)) - .title("陶泥儿") + .title(crate::game_creator_product_name(&app)) .decorations(false) .inner_size(1180.0, 820.0) .min_inner_size(760.0, 560.0) @@ -176,7 +176,7 @@ pub(crate) fn open_game_creator_launcher_window( existing.set_focus().map_err(|error| error.to_string())?; } else { tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url()) - .title("陶泥儿") + .title(crate::game_creator_product_name(&app)) .decorations(false) .inner_size(820.0, 640.0) .min_inner_size(720.0, 520.0) diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index f4960c22f..8e49a1463 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -17,6 +17,7 @@ import type { GameIterationVersion, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveEditorImageSizeLabel } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; +import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../../../../src/services/image-editor/editorProjectClient'; import { ThemedModal } from '../../components/modal/ThemedModal'; import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart'; import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel'; @@ -232,9 +233,17 @@ export function ResourceCanvasAssetGenerationPanelView({ }, [], ); - // 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust - // `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。 - const promptMaxLength = resourceEditPromptMaxLength('image-reference'); + // 图集原文作为单条 iconDescriptions,复用网站合同;其余图片入口保持原上限。 + const promptMaxLength = + action.assetKind === 'icon-spritesheet' + ? EDITOR_ICON_DESCRIPTION_MAX_CHARS + : resourceEditPromptMaxLength('image-reference'); + // Rust str::trim 使用 Unicode White_Space;JS trim 对 NEL/BOM 的定义不同。 + const normalizePrompt = (value: string) => + action.assetKind === 'icon-spritesheet' + ? value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, '') + : value.trim(); + const normalizedPrompt = normalizePrompt(prompt); /** * 参考选择只对有真实参考能力的入口呈现。 * @@ -282,9 +291,13 @@ export function ResourceCanvasAssetGenerationPanelView({ assets: assets ?? [], }) : []; - const promptTooLong = prompt.trim().length > promptMaxLength; + const promptLength = + action.assetKind === 'icon-spritesheet' + ? Array.from(normalizedPrompt).length + : normalizedPrompt.length; + const promptTooLong = promptLength > promptMaxLength; const promptTooLongError = promptTooLong - ? `生成提示词最多 ${promptMaxLength} 个字符,当前 ${prompt.trim().length} 个` + ? `生成提示词最多 ${promptMaxLength} 个字符,当前 ${promptLength} 个` : null; /** * 重试面板里「输入被改动过」的判据。 @@ -299,12 +312,12 @@ export function ResourceCanvasAssetGenerationPanelView({ boundRequestRef.current.referenceIds, referenceAssetIds, ) || - prompt.trim() !== boundRequestRef.current.prompt.trim() || + normalizedPrompt !== normalizePrompt(boundRequestRef.current.prompt) || assetName.trim() !== boundRequestRef.current.assetName.trim() || aspectRatio !== boundRequestRef.current.aspectRatio || imageSize !== boundRequestRef.current.imageSize); const canSubmit = - prompt.trim().length > 0 && + normalizedPrompt.length > 0 && assetName.trim().length > 0 && !referenceError && !referenceIssue && @@ -374,7 +387,6 @@ export function ResourceCanvasAssetGenerationPanelView({ function submit(event: FormEvent) { event.preventDefault(); - const normalizedPrompt = prompt.trim(); const normalizedAssetName = assetName.trim(); // 超限与超长在提交入口再挡一次:按钮禁用只是表现,不能当唯一防线。 if ( @@ -470,7 +482,6 @@ export function ResourceCanvasAssetGenerationPanelView({ aria-label="生成提示词" rows={3} autoFocus - maxLength={promptMaxLength} placeholder={action.promptPlaceholder} value={prompt} onChange={(event) => setPrompt(event.currentTarget.value)} diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 37ebb7361..57bff26e3 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -1081,30 +1081,16 @@ export function RuntimeConfigDialog({ : '已启用'} {plugin.enabled && plugin.hasRuntime ? ( - <> - - - + ) : null} {plugin.status === 'running' ? plugin.panels.map((panel) => ( diff --git a/apps/ai-game-creator-shell/template-library/README.md b/apps/ai-game-creator-shell/template-library/README.md new file mode 100644 index 000000000..5c2a69162 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/README.md @@ -0,0 +1,11 @@ +# AGC 游戏模板库 + +- 用途:AGC 客户端读取的**游戏模板库**,正文是可直接解压到项目根的 zip(zip 根 == AGC 项目根,例如 `game/index.html`)。 +- 位置:bucket `agc-dev`(endpoint `oss-rg-china-mainland.aliyuncs.com`)的 `templates/` 前缀,随 bucket 公共读。客户端只读 `templates/index.json`(schema `agc-template-library.v1`)。 +- 目录结构:`templates/index.json` + `templates/v1//sha256/<内容摘要>/{template.zip,template.json,cover.*}`;条目元数据含 `tags`、封面键与尺寸/摘要、包大小与 SHA-256、`templateVersion`。所有正文按自身字节摘要寻址,重复发布复用同一对象。 +- 封面支持 `png / jpg / webp / svg`。 +- 客户端只信任「受信任 OSS 主机 + 对象键」,清单里的地址字段不参与请求;下载后强校验包大小与 SHA-256,安装完成的唯一判据是模板目录里的 `installed.json`。 +- 这份文件就是线上 `templates/README.md` 的源:内容随每次发布覆盖写,所以改契约要改这里,不要只改线上对象。 +- 契约与验收以仓库文档 `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md` 为准;模板源在 `apps/ai-game-creator-shell/template-library/`,发布用 `node scripts/agc-template-library-publish.mjs --source apps/ai-game-creator-shell/template-library [--dry-run] [--only ]`(脚本现场打包 zip、按内容地址上传、逐个回读校验、持锁提交清单)。 +- 准备新模板(ZIP 里放什么、不能放什么、封面与元数据约束、发布前自检)先读 `docs/【模板规范】AGC模板包组织指南-2026-09-21.md`。 +- 同一个 `templateVersion` 的 ZIP 字节发生变化时发布会失败关闭,必须递增该模板的 `templateVersion`;发布不删除历史对象,历史版本仍可按旧键下载。 diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/meta.json b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/meta.json index f099612e4..fe50a2870 100644 --- a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/meta.json +++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/meta.json @@ -11,7 +11,7 @@ "runtime": "html", "engine": "three.js", "engineVersion": "0.180.0", - "templateVersion": "0.1.0", + "templateVersion": "0.1.1", "entry": "game/index.html", "coverWidth": 960, "coverHeight": 540 diff --git a/apps/ai-game-creator-shell/template-library/v1/cocos-empty-2d/meta.json b/apps/ai-game-creator-shell/template-library/v1/cocos-empty-2d/meta.json index 5aa321e36..47569d748 100644 --- a/apps/ai-game-creator-shell/template-library/v1/cocos-empty-2d/meta.json +++ b/apps/ai-game-creator-shell/template-library/v1/cocos-empty-2d/meta.json @@ -11,7 +11,7 @@ "runtime": "cocos", "engine": "cocos-creator", "engineVersion": "3.8.8", - "templateVersion": "0.1.0", + "templateVersion": "0.1.1", "entry": "package.json", "coverWidth": 960, "coverHeight": 540 diff --git a/apps/ai-game-creator-shell/template-library/v1/cocos-empty-3d-hq/meta.json b/apps/ai-game-creator-shell/template-library/v1/cocos-empty-3d-hq/meta.json index a586e3366..d1b5df3b8 100644 --- a/apps/ai-game-creator-shell/template-library/v1/cocos-empty-3d-hq/meta.json +++ b/apps/ai-game-creator-shell/template-library/v1/cocos-empty-3d-hq/meta.json @@ -11,7 +11,7 @@ "runtime": "cocos", "engine": "cocos-creator", "engineVersion": "3.8.8", - "templateVersion": "0.1.0", + "templateVersion": "0.1.1", "entry": "package.json", "coverWidth": 960, "coverHeight": 540 diff --git a/apps/ai-game-creator-shell/template-library/v1/cocos-empty-3d/meta.json b/apps/ai-game-creator-shell/template-library/v1/cocos-empty-3d/meta.json index 493f35be0..257389c1c 100644 --- a/apps/ai-game-creator-shell/template-library/v1/cocos-empty-3d/meta.json +++ b/apps/ai-game-creator-shell/template-library/v1/cocos-empty-3d/meta.json @@ -11,7 +11,7 @@ "runtime": "cocos", "engine": "cocos-creator", "engineVersion": "3.8.8", - "templateVersion": "0.1.0", + "templateVersion": "0.1.1", "entry": "package.json", "coverWidth": 960, "coverHeight": 540 diff --git a/apps/ai-game-creator-shell/template-library/v1/cocos-hello-world/meta.json b/apps/ai-game-creator-shell/template-library/v1/cocos-hello-world/meta.json index 8ff875247..fc9079559 100644 --- a/apps/ai-game-creator-shell/template-library/v1/cocos-hello-world/meta.json +++ b/apps/ai-game-creator-shell/template-library/v1/cocos-hello-world/meta.json @@ -11,7 +11,7 @@ "runtime": "cocos", "engine": "cocos-creator", "engineVersion": "3.8.8", - "templateVersion": "0.1.0", + "templateVersion": "0.1.1", "entry": "package.json", "coverWidth": 960, "coverHeight": 540 diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/cover.svg b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/cover.svg new file mode 100644 index 000000000..f22daa939 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/cover.svg @@ -0,0 +1,12 @@ + + + + + + + + + Godot 空白 2D 工程 + Godot 4.7 · GDScript + 1280×720 窗口 · GL Compatibility + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/meta.json b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/meta.json new file mode 100644 index 000000000..c9befb571 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/meta.json @@ -0,0 +1,18 @@ +{ + "id": "godot-empty-2d", + "title": "Godot 空白 2D 工程", + "summary": "Godot 4.7 原生二维空白工程:1280×720 窗口、GL Compatibility 渲染、单场景入口与占位说明节点已就绪。", + "tags": [ + "空白", + "起步工程", + "2d", + "godot" + ], + "runtime": "godot", + "engine": "godot", + "engineVersion": "4.7", + "templateVersion": "0.1.0", + "entry": "project.godot", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/.gitignore b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/.gitignore new file mode 100644 index 000000000..a643a2581 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/.gitignore @@ -0,0 +1,6 @@ +# Godot 4+ 编辑器与导入缓存 +.godot/ + +# 导出产物 +export/ +build/ diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/icon.svg b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/icon.svg new file mode 100644 index 000000000..4675426b7 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/project.godot b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/project.godot new file mode 100644 index 000000000..bc60b7c8e --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/project.godot @@ -0,0 +1,16 @@ +; Engine configuration file. +; AGC 模板库:Godot 4.7 空白 2D 工程。解压后即为工程根。 + +config_version=5 + +[application] + +config/name="Godot 空白 2D 工程" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scenes/main.tscn b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scenes/main.tscn new file mode 100644 index 000000000..52bbc79c8 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scenes/main.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] + +[node name="Main" type="Node2D"] +script = ExtResource("1_main") + +[node name="Hint" type="Label" parent="."] +offset_left = 48.0 +offset_top = 48.0 +offset_right = 1232.0 +offset_bottom = 160.0 +text = "AGC · Godot 空白 2D 工程" +theme_override_font_sizes/font_size = 28 diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scripts/main.gd b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scripts/main.gd new file mode 100644 index 000000000..cadbd6801 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scripts/main.gd @@ -0,0 +1,12 @@ +extends Node2D + +## AGC · Godot 空白 2D 工程 +## +## 只保留最小可运行骨架:一个 Node2D 根节点加一段说明文字。 +## 继续开发时把新场景放进 `scenes/`,其余节点从 `scenes/main.tscn` 挂载。 + +@onready var _hint: Label = $Hint + + +func _ready() -> void: + _hint.text = "AGC · Godot 空白 2D 工程\n把场景挂到 scenes/ 即可开始,入口在 project.godot 的 run/main_scene" diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/cover.svg b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/cover.svg new file mode 100644 index 000000000..c8ff1405e --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/cover.svg @@ -0,0 +1,11 @@ + + + + + + + + Godot 空白 3D 场景 + Godot 4.7 · GDScript + 相机 · 平行光 · 天空环境 + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/meta.json b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/meta.json new file mode 100644 index 000000000..3b3abd3ac --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/meta.json @@ -0,0 +1,18 @@ +{ + "id": "godot-empty-3d", + "title": "Godot 空白 3D 场景", + "summary": "Godot 4.7 原生三维空白工程:相机、平行光、天空环境与一个自转立方体已就绪,可直接开始搭建场景。", + "tags": [ + "空白", + "起步工程", + "3d", + "godot" + ], + "runtime": "godot", + "engine": "godot", + "engineVersion": "4.7", + "templateVersion": "0.1.0", + "entry": "project.godot", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/.gitignore b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/.gitignore new file mode 100644 index 000000000..a643a2581 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/.gitignore @@ -0,0 +1,6 @@ +# Godot 4+ 编辑器与导入缓存 +.godot/ + +# 导出产物 +export/ +build/ diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/icon.svg b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/icon.svg new file mode 100644 index 000000000..1f92223a4 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/project.godot b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/project.godot new file mode 100644 index 000000000..6a8bb2ea0 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/project.godot @@ -0,0 +1,16 @@ +; Engine configuration file. +; AGC 模板库:Godot 4.7 空白 3D 场景。解压后即为工程根。 + +config_version=5 + +[application] + +config/name="Godot 空白 3D 场景" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scenes/main.tscn b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scenes/main.tscn new file mode 100644 index 000000000..cc3aa8ff1 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scenes/main.tscn @@ -0,0 +1,43 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] + +[sub_resource type="Environment" id="Environment_main"] +background_mode = 1 +background_color = Color(0.09, 0.11, 0.16, 1) +ambient_light_source = 1 +ambient_light_color = Color(0.62, 0.71, 0.86, 1) +ambient_light_energy = 0.4 + +[sub_resource type="BoxMesh" id="BoxMesh_cube"] +size = Vector3(1.6, 1.6, 1.6) + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_cube"] +albedo_color = Color(0.28, 0.55, 0.75, 1) +roughness = 0.4 + +[node name="Main" type="Node3D"] +script = ExtResource("1_main") + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource("Environment_main") + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."] +transform = Transform3D(0.866025, -0.25, 0.433013, 0, 0.866025, 0.5, -0.5, -0.433013, 0.75, 3, 6, 4) + +[node name="Camera3D" type="Camera3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 0.951057, 0.309017, 0, -0.309017, 0.951057, 0, 2.4, 6) + +[node name="Cube" type="MeshInstance3D" parent="."] +material_override = SubResource("StandardMaterial3D_cube") +mesh = SubResource("BoxMesh_cube") + +[node name="Hud" type="CanvasLayer" parent="."] + +[node name="Hint" type="Label" parent="Hud"] +offset_left = 40.0 +offset_top = 32.0 +offset_right = 1000.0 +offset_bottom = 96.0 +text = "AGC · Godot 空白 3D 场景" +theme_override_font_sizes/font_size = 26 diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scripts/main.gd b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scripts/main.gd new file mode 100644 index 000000000..bfe52ec9a --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scripts/main.gd @@ -0,0 +1,13 @@ +extends Node3D + +## AGC · Godot 空白 3D 场景 +## +## 只保留最小可运行骨架:相机、平行光、天空环境与一个缓慢自转的立方体。 +## 继续开发时把新场景放进 `scenes/`,或在 `Cube` 下挂模型与脚本。 + +@onready var _cube: MeshInstance3D = $Cube + + +func _process(delta: float) -> void: + _cube.rotate_y(delta * 0.6) + _cube.rotate_x(delta * 0.2) diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/cover.svg b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/cover.svg new file mode 100644 index 000000000..ae5f254b7 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/cover.svg @@ -0,0 +1,12 @@ + + + + + + + + + Godot Hello World + Godot 4.7 · GDScript + 移动与收集 · 最小可玩示例 + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/meta.json b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/meta.json new file mode 100644 index 000000000..ad0b84a73 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/meta.json @@ -0,0 +1,18 @@ +{ + "id": "godot-hello-world", + "title": "Godot Hello World", + "summary": "Godot 4.7 最小可玩示例:方向键移动方块,碰到金色目标加分,空格重置;只用 GDScript 与内置输入动作。", + "tags": [ + "示例", + "起步工程", + "2d", + "godot" + ], + "runtime": "godot", + "engine": "godot", + "engineVersion": "4.7", + "templateVersion": "0.1.0", + "entry": "project.godot", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/.gitignore b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/.gitignore new file mode 100644 index 000000000..a643a2581 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/.gitignore @@ -0,0 +1,6 @@ +# Godot 4+ 编辑器与导入缓存 +.godot/ + +# 导出产物 +export/ +build/ diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/icon.svg b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/icon.svg new file mode 100644 index 000000000..b46265b60 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/project.godot b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/project.godot new file mode 100644 index 000000000..4804660ed --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/project.godot @@ -0,0 +1,16 @@ +; Engine configuration file. +; AGC 模板库:Godot 4.7 二维移动与收集示例。解压后即为工程根。 + +config_version=5 + +[application] + +config/name="Godot Hello World" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scenes/main.tscn b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scenes/main.tscn new file mode 100644 index 000000000..47043871e --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scenes/main.tscn @@ -0,0 +1,34 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] + +[node name="Main" type="Node2D"] +script = ExtResource("1_main") + +[node name="Backdrop" type="Polygon2D" parent="."] +polygon = PackedVector2Array(0, 0, 1280, 0, 1280, 720, 0, 720) +color = Color(0.11, 0.15, 0.21, 1) + +[node name="Player" type="Node2D" parent="."] +position = Vector2(320, 360) + +[node name="Body" type="Polygon2D" parent="Player"] +polygon = PackedVector2Array(-24, -24, 24, -24, 24, 24, -24, 24) +color = Color(0.28, 0.55, 0.75, 1) + +[node name="Target" type="Node2D" parent="."] +position = Vector2(900, 240) + +[node name="Body" type="Polygon2D" parent="Target"] +polygon = PackedVector2Array(-18, -18, 18, -18, 18, 18, -18, 18) +color = Color(0.95, 0.76, 0.31, 1) + +[node name="Hud" type="CanvasLayer" parent="."] + +[node name="Score" type="Label" parent="Hud"] +offset_left = 40.0 +offset_top = 32.0 +offset_right = 900.0 +offset_bottom = 120.0 +text = "得分:0" +theme_override_font_sizes/font_size = 26 diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scripts/main.gd b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scripts/main.gd new file mode 100644 index 000000000..76a23f51b --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scripts/main.gd @@ -0,0 +1,53 @@ +extends Node2D + +## AGC · Godot Hello World +## +## 最小可玩示例:方向键移动方块,碰到金色目标得分并随机刷新目标; +## 空格或回车重置得分。只使用 Godot 内置的 ui_* 输入动作,不需要额外输入映射。 + +const SPEED := 420.0 +const PICKUP_RADIUS := 42.0 +const PLAYER_BOUNDS := Rect2(28.0, 28.0, 1224.0, 664.0) + +var _score := 0 + +@onready var _player: Node2D = $Player +@onready var _target: Node2D = $Target +@onready var _hud: Label = $Hud/Score + + +func _ready() -> void: + _randomize_target() + _update_hud() + + +func _process(delta: float) -> void: + var direction := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down") + _player.position += direction * SPEED * delta + _player.position.x = clampf( + _player.position.x, PLAYER_BOUNDS.position.x, PLAYER_BOUNDS.end.x + ) + _player.position.y = clampf( + _player.position.y, PLAYER_BOUNDS.position.y, PLAYER_BOUNDS.end.y + ) + if _player.position.distance_to(_target.position) <= PICKUP_RADIUS: + _score += 1 + _randomize_target() + _update_hud() + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_accept"): + _score = 0 + _update_hud() + + +func _randomize_target() -> void: + _target.position = Vector2( + randf_range(PLAYER_BOUNDS.position.x, PLAYER_BOUNDS.end.x), + randf_range(PLAYER_BOUNDS.position.y, PLAYER_BOUNDS.end.y), + ) + + +func _update_hud() -> void: + _hud.text = "得分:%d\n方向键移动 · 空格/回车重置" % _score diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/cover.svg b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/cover.svg new file mode 100644 index 000000000..6ee82e63c --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/cover.svg @@ -0,0 +1,13 @@ + + + + + + + + + + Godot 2D 平台跳跃 + Godot 4.7 · GDScript + 角色 · 平台 · 相机跟随 · 金币 + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/meta.json b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/meta.json new file mode 100644 index 000000000..4157bd441 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/meta.json @@ -0,0 +1,18 @@ +{ + "id": "godot-platformer-2d", + "title": "Godot 2D 平台跳跃起步工程", + "summary": "Godot 4.7 原生二维平台跳跃骨架:CharacterBody2D 角色、地面与两级平台、相机跟随、三枚金币与重开,全部用 GDScript 与内置输入动作实现。", + "tags": [ + "起步工程", + "2d", + "平台跳跃", + "godot" + ], + "runtime": "godot", + "engine": "godot", + "engineVersion": "4.7", + "templateVersion": "0.1.0", + "entry": "project.godot", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/.gitignore b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/.gitignore new file mode 100644 index 000000000..a643a2581 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/.gitignore @@ -0,0 +1,6 @@ +# Godot 4+ 编辑器与导入缓存 +.godot/ + +# 导出产物 +export/ +build/ diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/icon.svg b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/icon.svg new file mode 100644 index 000000000..fd4ef4619 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/project.godot b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/project.godot new file mode 100644 index 000000000..d6cb7b5b6 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/project.godot @@ -0,0 +1,16 @@ +; Engine configuration file. +; AGC 模板库:Godot 4.7 二维平台跳跃起步工程。解压后即为工程根。 + +config_version=5 + +[application] + +config/name="Godot 2D 平台跳跃起步工程" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scenes/main.tscn b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scenes/main.tscn new file mode 100644 index 000000000..4545bf2bd --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scenes/main.tscn @@ -0,0 +1,97 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] +[ext_resource type="Script" path="res://scripts/player.gd" id="2_player"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_player"] +size = Vector2(40, 56) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_ground"] +size = Vector2(1280, 64) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_platform"] +size = Vector2(280, 32) + +[node name="Main" type="Node2D"] +script = ExtResource("1_main") + +[node name="Backdrop" type="Polygon2D" parent="."] +polygon = PackedVector2Array(0, 0, 1280, 0, 1280, 720, 0, 720) +color = Color(0.11, 0.15, 0.21, 1) + +[node name="Ground" type="StaticBody2D" parent="."] +position = Vector2(640, 688) + +[node name="Shape" type="CollisionShape2D" parent="Ground"] +shape = SubResource("RectangleShape2D_ground") + +[node name="Visual" type="Polygon2D" parent="Ground"] +polygon = PackedVector2Array(-640, -32, 640, -32, 640, 32, -640, 32) +color = Color(0.2, 0.27, 0.35, 1) + +[node name="Platform" type="StaticBody2D" parent="."] +position = Vector2(360, 560) + +[node name="Shape" type="CollisionShape2D" parent="Platform"] +shape = SubResource("RectangleShape2D_platform") + +[node name="Visual" type="Polygon2D" parent="Platform"] +polygon = PackedVector2Array(-140, -16, 140, -16, 140, 16, -140, 16) +color = Color(0.24, 0.34, 0.44, 1) + +[node name="Platform2" type="StaticBody2D" parent="."] +position = Vector2(900, 470) + +[node name="Shape" type="CollisionShape2D" parent="Platform2"] +shape = SubResource("RectangleShape2D_platform") + +[node name="Visual" type="Polygon2D" parent="Platform2"] +polygon = PackedVector2Array(-140, -16, 140, -16, 140, 16, -140, 16) +color = Color(0.24, 0.34, 0.44, 1) + +[node name="Coins" type="Node2D" parent="."] + +[node name="Coin1" type="Node2D" parent="Coins"] +position = Vector2(360, 500) + +[node name="Visual" type="Polygon2D" parent="Coins/Coin1"] +polygon = PackedVector2Array(0, -16, 16, 0, 0, 16, -16, 0) +color = Color(0.95, 0.76, 0.31, 1) + +[node name="Coin2" type="Node2D" parent="Coins"] +position = Vector2(900, 410) + +[node name="Visual" type="Polygon2D" parent="Coins/Coin2"] +polygon = PackedVector2Array(0, -16, 16, 0, 0, 16, -16, 0) +color = Color(0.95, 0.76, 0.31, 1) + +[node name="Coin3" type="Node2D" parent="Coins"] +position = Vector2(1160, 620) + +[node name="Visual" type="Polygon2D" parent="Coins/Coin3"] +polygon = PackedVector2Array(0, -16, 16, 0, 0, 16, -16, 0) +color = Color(0.95, 0.76, 0.31, 1) + +[node name="Player" type="CharacterBody2D" parent="."] +position = Vector2(160, 560) +script = ExtResource("2_player") + +[node name="Shape" type="CollisionShape2D" parent="Player"] +shape = SubResource("RectangleShape2D_player") + +[node name="Visual" type="Polygon2D" parent="Player"] +polygon = PackedVector2Array(-20, -28, 20, -28, 20, 28, -20, 28) +color = Color(0.28, 0.55, 0.75, 1) + +[node name="Camera2D" type="Camera2D" parent="Player"] +position_smoothing_enabled = true + +[node name="Hud" type="CanvasLayer" parent="."] + +[node name="Status" type="Label" parent="Hud"] +offset_left = 40.0 +offset_top = 32.0 +offset_right = 1100.0 +offset_bottom = 130.0 +text = "已收集 0 / 3" +theme_override_font_sizes/font_size = 26 diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/main.gd b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/main.gd new file mode 100644 index 000000000..60c7593e0 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/main.gd @@ -0,0 +1,51 @@ +extends Node2D + +## AGC · 平台跳跃起步工程 +## +## 收集三枚金币:踩平台跳上去即可。金币按玩家中心与目标的距离判定, +## 不依赖物理层设置,改关卡时直接挪 Coin 节点位置就行。Esc 重来。 + +const PICKUP_RADIUS := 34.0 +const SPAWN_POSITION := Vector2(160.0, 560.0) + +var _score := 0 + +@onready var _player: CharacterBody2D = $Player +@onready var _coins: Node2D = $Coins +@onready var _status: Label = $Hud/Status + + +func _ready() -> void: + _update_status() + + +func _process(_delta: float) -> void: + for coin in _coins.get_children(): + if not coin.visible: + continue + if _player.global_position.distance_to(coin.global_position) <= PICKUP_RADIUS: + coin.visible = false + _score += 1 + _update_status() + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_cancel"): + _restart() + + +func _restart() -> void: + for coin in _coins.get_children(): + coin.visible = true + _player.position = SPAWN_POSITION + _player.velocity = Vector2.ZERO + _score = 0 + _update_status() + + +func _update_status() -> void: + var total := _coins.get_child_count() + if _score >= total: + _status.text = "已收集 %d / %d —— 全部完成!Esc 重来" % [_score, total] + else: + _status.text = "已收集 %d / %d\n方向键移动 · 空格/回车跳跃 · Esc 重来" % [_score, total] diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/player.gd b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/player.gd new file mode 100644 index 000000000..cc01695ff --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/player.gd @@ -0,0 +1,23 @@ +extends CharacterBody2D + +## AGC · 平台跳跃玩家控制 +## +## 方向键左右移动,空格 / 回车跳跃。只使用 Godot 内置的 ui_* 输入动作; +## 需要 WASD 或手柄时在「项目设置 → 输入映射」里给这两个动作补事件即可。 + +const SPEED := 320.0 +const JUMP_VELOCITY := -700.0 +const GRAVITY := 1500.0 + + +func _physics_process(delta: float) -> void: + if not is_on_floor(): + velocity.y += GRAVITY * delta + if is_on_floor() and Input.is_action_just_pressed("ui_accept"): + velocity.y = JUMP_VELOCITY + var direction := Input.get_axis("ui_left", "ui_right") + if direction != 0.0: + velocity.x = direction * SPEED + else: + velocity.x = move_toward(velocity.x, 0.0, SPEED) + move_and_slide() diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/meta.json b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/meta.json index 71f52ba90..9fac2fe2b 100644 --- a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/meta.json +++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/meta.json @@ -11,7 +11,7 @@ "runtime": "html", "engine": "phaser", "engineVersion": "4.2.1", - "templateVersion": "0.1.0", + "templateVersion": "0.1.1", "entry": "game/index.html", "coverWidth": 960, "coverHeight": 540 diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/meta.json b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/meta.json index 2b492a71d..4d8910066 100644 --- a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/meta.json +++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/meta.json @@ -11,7 +11,7 @@ "runtime": "html", "engine": "three.js", "engineVersion": "0.180.0", - "templateVersion": "0.1.0", + "templateVersion": "0.1.1", "entry": "game/index.html", "coverWidth": 960, "coverHeight": 540 diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx index db0e195da..279df287d 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx @@ -1,5 +1,11 @@ // @vitest-environment jsdom -import { cleanup, render, screen, within } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; @@ -398,7 +404,7 @@ describe('生成面板的参考接线', () => { action={spritesheetAction} assets={[asset('asset-a', 'image/png', 'assets/a.png')]} draft={{ - prompt: '一套敌人头像', + prompt: ' 金币\n\n宝箱\t钥匙 ', assetName: '图标素材', aspectRatio: '1:1', imageSize: '1K', @@ -438,10 +444,59 @@ describe('生成面板的参考接线', () => { expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ kind: 'icon-spritesheet', + prompt: '金币\n\n宝箱\t钥匙', references: [], }); }); + test.each([ + ['😀'.repeat(200), true, '😀'.repeat(200)], + ['😀'.repeat(201), false, '😀'.repeat(201)], + ['\u0085金币\u0085', true, '金币'], + ['\u0085\n ', false, ''], + ['\uFEFF', true, '\uFEFF'], + ])( + '图集描述按 API 的 Unicode 字符与空白语义校验(%#)', + (prompt, accepted, expected) => { + const onSubmit = vi.fn(); + render( + undefined} + />, + ); + const input = screen.getByLabelText('生成提示词') as HTMLTextAreaElement; + fireEvent.change(input, { target: { value: prompt } }); + expect(input.value).toBe(prompt); + expect(input.hasAttribute('maxlength')).toBe(false); + const button = screen.getByRole('button', { + name: '生成图标素材', + }) as HTMLButtonElement; + expect(button.disabled).toBe(!accepted); + fireEvent.submit(input.closest('form')!); + if (accepted) { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ prompt: expected }), + ); + } else { + expect(onSubmit).not.toHaveBeenCalled(); + if (Array.from(prompt).length > 200) { + expect( + screen.getByText('生成提示词最多 200 个字符,当前 201 个'), + ).not.toBeNull(); + } + } + }, + ); + test('点提示词输入区不该自己弹出素材选择框', async () => { const user = userEvent.setup(); const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index f6e170485..0802340d6 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -827,7 +827,8 @@ describe('ResourceReferenceInput', () => { const deleteButton = screen.getByRole('button', { name: '移除引用 hero' }); expect(chip?.contains(deleteButton)).toBe(true); - // 提交用的结构化引用完整保留,末尾那个分隔空格提交前会被 trim 掉。 + // 草稿 text 投影会 trim,但提交用的 content 仍保留引用节点后的分隔空格; + // 结构化引用完整保留,后端按整条消息判断是否有有效内容。 expect(draftText(onChange.mock.calls.at(-1)?.[0])).toBe('@hero'); expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toEqual(['hero']); diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index eefb1ae55..c97ad913c 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -163,14 +163,17 @@ ALIYUN_OSS_POST_MAX_SIZE_BYTES=20971520 ALIYUN_OSS_SUCCESS_ACTION_STATUS=200 # AGC 项目定时快照上传目标。对象只落在服务端私有前缀 -# agc/project-snapshots/v1/{user}/{project}/ 下;AccessKey 为空时回退 ALIYUN_OSS_ACCESS_KEY_*, +# agc/project-snapshots/v2/{channel}/{user}/{project}/ 下;AccessKey 为空时回退 ALIYUN_OSS_ACCESS_KEY_*, # 因此回退凭据必须对目标 bucket 具备该前缀的 PutObject/GetObject/DeleteObject 权限。 # bucket 未单独配置时默认 agc-dev,未配置凭据时 api-server 跳过该客户端, # 接口返回 503 且客户端失败关闭(不写空对象、不推进本地索引)。 +# channel 是部署渠道:缺省沿用 GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL,正式部署必须显式设为 +# release,否则本部署上传会落在 dev 渠道;渠道名非法时接口返回 503,不回落。 GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET=agc-dev GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT=oss-rg-china-mainland.aliyuncs.com GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID= GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET= +GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL=dev # SpacetimeDB 数据目录 OSS 冷备份配置。可由 cron / Jenkins 调用发布包内 scripts/database-backup-to-oss.mjs。 GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb diff --git a/docs/README.md b/docs/README.md index 5575a8a2a..d1af5b3c0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,6 +49,7 @@ - [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、固定 dev 服务、OSS 清单与官网最新客户端下载。 - [AGC 总版本号与发号](./technical/【技术方案】AGC总版本号与发号-2026-09-20.md):客户端版本号收口到 OSS `agc/global-version.json`,统一构建一次发号供各渠道共用,渠道高水位降级为断言。 - [AGC 模板库与模板建项](./technical/【技术方案】AGC模板库与模板建项-2026-09-17.md):`templates/` 前缀的模板库契约、下载安装与「用模板建项目」链路。 +- [AGC 模板包组织指南](./【模板规范】AGC模板包组织指南-2026-09-21.md):模板 ZIP 的根目录结构、Cocos 工程保留项、禁止放入的内容、封面与体积上限、版本不可变与发布前自检。 - [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。 - [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md):Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。 - [项目开发工作台 PRD](./prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md):当前工作台页面和验收边界。 diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index c45bb7ce1..cdf6b4e73 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -1,6 +1,6 @@ # AI 游戏创作项目开发工作台 PRD -更新时间:`2026-09-21`(2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」、**「生成任务」侧栏从画布左侧贴边改为画布右上角锚点(开关常驻)**、**资源卡可拖到对话栏批量 @ 引用**、**替换面板改为非模态浮层并支持在画布上点选目标**(验收现场两条修正:任务锚点改挂画布那一格、展开后开关让位),见 §3.10 / §5.3 / §7.8 / §7.9 与 [`【功能说明】AGC聊天素材引用`](../【功能说明】AGC聊天素材引用-2026-09-08.md);2026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变) +更新时间:`2026-09-21`(2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」、**「生成任务」侧栏从画布左侧贴边改为画布右上角锚点(开关常驻)**、**资源卡可拖到对话栏批量 @ 引用**、**替换面板改为非模态浮层并支持在画布上点选目标**(验收现场两条修正:任务锚点改挂画布那一格、展开后开关让位),见 §3.10 / §5.3 / §7.8 / §7.9 与 [`【功能说明】AGC聊天素材引用`](../【功能说明】AGC聊天素材引用-2026-09-08.md);2026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变;2026-09-21 Godot 工作区发现放宽:一层多命中按目录名排序取第一个,`project.godot` 允许是链接/reparse point/硬链接,见 §3.8) ## 1. 产品定位 @@ -136,7 +136,7 @@ ### 3.8 现有 Godot 项目 -- 首页和项目组统一使用“打开项目”,不提供独立 Godot 按钮。用户选择的目录作为工作区根;系统先检查根目录,再检查一层直接子目录中的普通文件 `project.godot`。根目录命中优先;一层发现多个 Godot 工程时必须提示歧义,不猜测目标。 +- 首页和项目组统一使用“打开项目”,不提供独立 Godot 按钮。用户选择的目录作为工作区根;系统先检查根目录,再检查一层直接子目录中的 `project.godot`。根目录命中优先;一层命中多个 Godot 工程时按目录名排序取第一个,不再因为存在第二个工程而失败或要求用户改选。`project.godot` 本身是符号链接、Windows reparse point 或硬链接时按链接目标判定,解析后是文件即命中;目录与悬空链接仍不算命中。 - 工作区根始终绑定文件读取、修改、命令 cwd、对话、Runtime 和最近项目记录;`.agent/`、Agent DB 与日志也只写在这里。`project.godot` 所在目录额外以 `godotProjectRoot` 记录为相对工作区的 `.` 或单层目录名,不把项目作用域切到 Godot 子目录。 - 首次打开只在工作区根创建 `.agent/` 元数据;已有 `.agent/manifest.json` 时读取并按当前唯一文件布局校准 `godotProjectRoot`。Godot 源码、场景、资源和项目配置继续使用原目录结构,不复制工程,也不建立 `game/`、`assets/`、`memory/`、`exports/` 平行目录。 - Godot 项目使用 `standard` Run Profile,避免套用 Web 原型的 `game/index.html`、本地 HTTP 预览和自主 Web 游戏完成门;本期不提供 Godot 内嵌运行预览。 diff --git a/docs/project-memory/plans/【实施计划】AGC模板库客户端接入-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC模板库客户端接入-2026-09-17.md deleted file mode 100644 index 0b26a6b5b..000000000 --- a/docs/project-memory/plans/【实施计划】AGC模板库客户端接入-2026-09-17.md +++ /dev/null @@ -1,63 +0,0 @@ -# AGC 模板库客户端接入实施计划 - -Version: 1.0 -Status: active -Date: 2026-09-17 -Milestone Spec: `docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md` - -## 步骤 - -1. **OSS 库布局与契约** - - - 在 `agc-dev` 落地 `templates/` 前缀:`index.json`、`v1//{template.json,template.zip,cover.*}`。 - - 清单补齐 `tags`、`coverKey/coverWidth/coverHeight/coverSha256`,正文改为 zip(zip 根 == 项目根)。 - - 模板源落在 `apps/ai-game-creator-shell/template-library/v1//{meta.json,project/**,cover.*}`;zip 由 `scripts/agc-template-library-publish.mjs` 现场打包(不落仓库)。 - - 交付:发布脚本(校验 + 打包 + 上传 + 回读校验,支持 `--dry-run` / `--prune`)、`templates/README.md`,以及 5 个模板(3 个空白 + 2 个起步工程)。 - - 验收:匿名 `GET templates/index.json` 可读,每个 `zipKey` 回读 SHA-256 与清单一致。 - -2. **Rust 模板库模块** - - - 新增 `src-tauri/src/template_library.rs`:清单解析与校验、受信任 base、缓存/安装目录、zip 安全解压、安装记录、由模板建项目。 - - 注册命令 `fetch_game_template_library`、`download_game_template`、`create_automatic_local_game_project_from_template`。 - - 交付:模块内 8 项单测(schema/重复模板、键前缀、base 校验、解压逃逸、摘要与大小、安装记录、建项目与失败清理)。 - - 验收:`cargo test --bin genarrative-ai-game-creator-shell template_library` 全绿。 - -3. **前端状态链路** - - - `src/features/template-library/templateLibraryModel.ts`(类型与搜索/筛选纯函数)与 `useTemplateLibrary.ts`(拉取、下载、建项目、就地更新已下载状态)。 - - `useHomeProjectCreation` 增加 `enterCreatedTemplateProject`,复用既有进项目通道。 - - 交付:9 项模型单测。 - - 验收:`npx vitest run src/features/template-library` 全绿。 - -4. **界面接入** - - - 新增 `src/view/template-library/index.tsx` 全屏页;`LauncherView` 增加 `template-library`;左侧导航加模板库入口。 - - 首页「灵感推荐」替换为 `TemplateRecommendations`;删除 `InspirationGallery.tsx` 与 `assets/inspiration/`。 - - `tauri.conf.json` 的 `img-src` 放行受信任 OSS 主机以加载封面。 - - 验收:模板库页可搜索、筛选、下载、显示已下载并成功建项目;首页推荐位可跳转。 - -5. **文档与共享记忆** - - 主规范 `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`,并在 `docs/README.md` 建索引。 - - 本里程碑与实施计划;`decision-log.md` 记录库路径、清单 schema、缓存目录与 CSP 约定。 - - 验收:`node scripts/check-doc-index.mjs` 通过。 - -## 验证命令 - -```bash -cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library -cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library -- --ignored -cd apps/ai-game-creator-shell && npx tsc -p tsconfig.json --noEmit -npx vitest run apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx -node scripts/agc-template-library-publish.mjs --source --dry-run -npm run check:encoding -node scripts/check-doc-index.mjs -git diff --check -``` - -## 风险与回退 - -- **封面走 WebView 直连**:仅放行受信任 OSS 主机;若日后改用后端签名,清单的 `coverKey` 不变。 -- **模板包体积**:下载上限 512 MiB、解压文件数 4096、单文件 256 MiB;超限直接拒绝,不落盘。 -- **清单漂移**:客户端只信「受信任主机 + 对象键」,清单中的地址字段不参与请求。 -- **回退**:清空 `templates/` 前缀即回到空模板库;客户端保留错误与空态展示,不阻断其它功能。 diff --git a/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md b/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md new file mode 100644 index 000000000..0b57d90e7 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md @@ -0,0 +1,49 @@ +# 【实施计划】AGC 渠道安装身份隔离 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md` | +| Status | ready | +| Owner | 当前 Agent | + +## 修改边界 + +- 允许修改: + - `apps/ai-game-creator-shell/scripts/channel-identity.mjs`(新增,渠道身份单点定义) + - `apps/ai-game-creator-shell/scripts/build-release.mjs`、`build-macos-ci.mjs`、`check-config.mjs`、`agent-swarm-test-chat.mjs` + - `apps/ai-game-creator-shell/src-tauri/src/main.rs`、`src-tauri/src/windows.rs`、`src-tauri/src/config.rs` + - 对应测试:`build-release.test.mjs`、`prepare-macos-codex.test.mjs` + - 文档:AGC 更新主规范、共享记忆与本计划对 +- 明确不修改:OSS 分区布局、官网下载接口与页面、Jenkins Job 参数、渠道版本发行逻辑、Apple 签名/公证、移动壳。 + +## 实现顺序 + +1. 抽出 `channel-identity.mjs`:渠道校验、渠道显示名、`resolveChannelInstallIdentity()`;`build-release.mjs` 复用并再导出渠道校验。 +2. 渠道 `--config` 同时注入 `productName` 与 `identifier`;macOS 发布入口按发布渠道解析产品名(`.app`、updater 归档、DMG 卷名与文件名)。 +3. Rust:主窗口/工作区/启动器窗口标题取构建期产品名;AGC 自有 AppData 目录的 ACL managed 识别覆盖 `<基线>` 与 `<基线>.<渠道>`。 +4. 门禁与测试:`check-config.mjs` 断言基线等于默认渠道身份、非默认渠道身份隔离;`build-release.test.mjs` 增补身份与首装包用例;`prepare-macos-codex.test.mjs` 改断言为消费渠道身份。 +5. 文档:更新 AGC 更新主规范与共享记忆,登记决策与踩坑。 + +## 验证命令 + +1. `node --check apps/ai-game-creator-shell/scripts/channel-identity.mjs apps/ai-game-creator-shell/scripts/build-release.mjs apps/ai-game-creator-shell/scripts/build-macos-ci.mjs apps/ai-game-creator-shell/scripts/check-config.mjs` +2. `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/release-oss.test.mjs apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs` +3. `node apps/ai-game-creator-shell/scripts/check-config.mjs` +4. `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- config::tests:: --test-threads=1` +5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` + +## 验证结果 + +- `node --test build-release.test.mjs release-oss.test.mjs prepare-macos-codex.test.mjs cargo-features.test.mjs`:64/64 通过(新增渠道身份、身份注入与渠道 DMG 首装选择三条用例)。 +- `node apps/ai-game-creator-shell/scripts/check-config.mjs`、`npm --prefix apps/ai-game-creator-shell run typecheck`:通过。 +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- config::private_path_elevation_policy_tests`:12/12 通过。 +- `AGC_UPDATE_CHANNEL=release npm --prefix apps/ai-game-creator-shell run build -- --no-bundle --debug`:Tauri 接受派生的 `productName` / `identifier` 并完成构建;产物字符串实测 `陶泥儿 Release` × 1、`agc/release-win/latest.json` × 1、`world.genarrative.ai-game-creator.release` × 1、`agc/dev-win/latest.json` × 0。 +- `cargo fmt --check`(AGC 壳)、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`:通过。 +- 未执行:真实渠道打包(需要签名私钥与发号/上传授权)、双渠道真机安装与并存、macOS 节点实跑。 + +## 风险与回滚点 + +- 风险:默认渠道身份若被改动,既有安装目录、卸载项与升级链会断。回滚点:基线 `tauri.conf.json` 与默认渠道映射不变,门禁用例钉住。 +- 风险:非默认渠道首次以新身份安装,老 `dev` 用户不会自动迁移本地数据。回滚点:渠道身份只影响非默认渠道构建,撤销该渠道的构建产物即可,仓库侧无数据迁移。 +- 风险:窗口标题改为构建期产品名后,标题不再等于配置里的字面量。回滚点:去掉 `main.rs` 的标题覆盖调用,行为回到配置标题。 +- 风险:ACL managed 识别放宽到前缀族。回滚点:`is_game_creator_packaged_app_data_leaf` 收紧回单一直线值,但非默认渠道的提权修复会重新失败关闭。 diff --git a/docs/project-memory/plans/【实施计划】后台模板上传-2026-09-21.md b/docs/project-memory/plans/【实施计划】后台模板上传-2026-09-21.md new file mode 100644 index 000000000..1a3a1b1df --- /dev/null +++ b/docs/project-memory/plans/【实施计划】后台模板上传-2026-09-21.md @@ -0,0 +1,40 @@ +# 后台模板上传实施计划 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | in-progress | +| Date | 2026-09-21 | +| Milestone Spec | `docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md` | + +## 修改边界与顺序 + +1. **领域规则(`module-assets/template_library.rs`)**:新增「导入准备」函数——按 ID 合并清单条目(新增默认上架、已存在就地更新并保留 `enabled` 与未知字段),校验 ID / 版本 / entry / runtime / 显示字段上限,产出待提交的清单与 `template.json` 字节;不接触存储。 +2. **存储(复用 `platform-oss/template_library.rs`)**:只使用既有 `begin_publish` / `read_index` / `put_immutable` / `commit_index` / `finish`;如缺少「读单个对象作元数据基线」的能力,再补最小只读方法,不改锁与提交语义。 +3. **契约(`shared-contracts/admin.rs` + `apps/admin-web/src/api/adminApiTypes.ts`)**:新增导入请求(manifest)与导入结果 DTO;错误体沿用现有 `AppError` + 逐项原因结构。 +4. **接口(`api-server/admin_templates.rs` + `modules/admin.rs`)**:新增 multipart handler,按「解析 manifest → 校验每个 ZIP / 封面 → 取锁 → CAS → 写内容寻址对象并回读 → 提交清单 → 释放锁」顺序实现;路由套 `require_admin_auth` 与 256 MiB body 上限,并同步路由契约测试与页签权限矩阵测试。 +5. **后台页面(`AdminAgcTemplatesPage.tsx` + `adminApiClient.ts`)**:新增「上传模板」入口与弹窗(多选 ZIP、逐行元数据、批量提交、逐行错误、写入确认),沿用既有 `useAdminWriteConfirm` 与刷新语义。 +6. **文档**:主规范新增「后台模板上传」章节;决策记录补一条;主规范中「本轮只允许…不上传 ZIP」改为指向新章节。 + +## 验证命令 + +```bash +cargo test --locked -p module-assets --manifest-path server-rs/Cargo.toml -- template_library +cargo test --locked -p api-server --manifest-path server-rs/Cargo.toml -- agc_template +cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check +npm run admin-web:typecheck +npx vitest run apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx apps/admin-web/src/api/adminApiClient.test.ts +npm run check:encoding +npm run check:doc-index +git diff --check +``` + +接口 smoke:`npm run dev:api-server`(本地 8082)+ `npm run dev:admin-web`,先用未授权/无页签权限请求确认失败关闭,再以隔离存储替身或用户显式确认的 dev bucket 做一次真实导入。 + +## 时间盒与风险 + +- **风险:真实发布不可逆**。导入会写公共 bucket 且不提供删除,因此默认只在本地用替身验证;对 dev bucket 的写验证必须由用户显式确认,测试用模板需可在事后下架。 +- **风险:ZIP 原字节发布与 CLI 确定性打包不一致**。同一模板可能被两条路径写成不同字节;由「同 ID 同版本字节必须一致」的门禁兜住,导入失败时提示改用 CLI 或递增版本。 +- **风险:大文件内存**。ZIP(≤64 MiB)与封面在内存中校验,单批上限 20;需要更大模板时走 CLI。 +- **风险:批量部分写入**。所有对象在清单提交前写入且不删除;中途失败时清单不变、已写对象成为未被引用的历史对象,由后续同键复用。 +- **回退**:下线路由与页面入口即可停止使用;已发布内容按既有 CLI / 后台下架流程处理,历史对象保留。 diff --git a/docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md index f6483d226..6272c2d1b 100644 --- a/docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md +++ b/docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md @@ -1,7 +1,7 @@ # AGC 模板库客户端接入 Version: 1.0 -Status: active +Status: accepted(2026-09-21 复核:7 条验收项均有定向、前端与线上真连证据) Date: 2026-09-17 Parent Spec: `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md` @@ -38,3 +38,17 @@ AGC 客户端能读取公共 OSS 上的游戏模板库,并把「浏览 → 筛 - 现有自动工作区建项链路(`create_automatic_local_game_project_at` / `init_local_game_project_at`)。 - 现有 AGC 更新通道使用的受信任 OSS 主机与 CSP 白名单口径。 - 仓库 OSS 凭据(本机 `.env.secrets.local` 的 `ALIYUN_OSS_*`)与 `scripts/agc-template-library-publish.mjs`。 + +## 验收证据(2026-09-21) + +| 验收项 | 证据 | +| --- | --- | +| 1 读清单 + 合并已安装状态 + 远端不可用回退缓存并标 `source=cache` | `template_library::tests::template_index_*` 5 项(网络优先并标 `network`、远端失败回退缓存并标 `cache`、无缓存暴露远端错误、远端正文非法不用缓存掩盖、缓存损坏失败关闭);`fetches_the_live_template_library_index` 真连通过 | +| 2 下载拒绝字节数 / SHA-256 / 越界键 / 非 `templates/` 前缀,且不落半成品 | `rejects_archive_when_size_or_digest_do_not_match_the_index`(含新增断言:拒绝后无安装目录、无已下载判据)、`rejects_object_keys_outside_the_templates_prefix`、`rejects_template_base_url_outside_trusted_oss`;`downloads_and_installs_a_live_template` 真连通过 | +| 3 解压拒绝绝对路径 / `..` / 盘符 / 符号链接;安装完成才写 `installed.json` | `rejects_archive_entries_that_escape_the_destination`(含新增断言:失败后不产生安装记录)、`installs_template_archive_and_reports_it_as_installed`、`parses_content_addressed_objects_without_changing_the_template_contract` | +| 4 建项具备模板文件 + `.agent` + 标准目录,失败不留项目目录 | `creates_project_from_installed_template_without_leaking_install_marker`、`installs_official_cocos_templates_and_creates_native_projects`、新增 `failed_project_creation_removes_the_partial_project_directory`、`refuses_to_create_project_when_template_is_not_installed`;`downloads_and_creates_live_cocos_templates` 真连通过 | +| 5 页面筛选 / 卡片 / 已下载徽标 / 更新入口 / 浮层提示 / 版本落后先重下 | `templateLibraryView.test.tsx` 12 项、`templateLibraryModel.test.ts` 9 项、`templateLibraryGrid.test.ts` 6 项、`useTemplateLibrary.test.tsx` 11 项 | +| 6 首页推荐位与左侧导航入口 | `appSurface.test.ts` 首页模板用例 2 项:灰度外隐藏入口与推荐位、点击推荐位进入模板库且不建项目 | +| 7 定向用例 + 类型 / 编码 / 文档检查 + 可选真连 | Rust 24 项 + 线上 3 项、前端 38 项 + `appSurface` 2 项、AGC `typecheck`、`check:encoding`、`check:doc-index`、`git diff --check` 全部通过;匿名 `GET templates/index.json` 返回 200(`schemaVersion=agc-template-library.v1`、9 个模板) | + +仍未验证:Creator 内场景运行;真机 iOS / Android 观感。持久结论已并入主规范 [`【技术方案】AGC模板库与模板建项-2026-09-17.md`](../../technical/【技术方案】AGC模板库与模板建项-2026-09-17.md),本里程碑的实施计划已按工作流删除。 diff --git a/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md b/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md new file mode 100644 index 000000000..dea7272cd --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md @@ -0,0 +1,44 @@ +# 里程碑:AGC 渠道安装身份隔离 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | 代码与源码级验收已落地,等待真机双渠道安装验收 | +| Date | 2026-09-21 | +| Parent Spec | `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` | + +## 目标 + +不同渠道的 AGC 包体在同一台设备上并存:安装、运行、客户端数据与更新互不覆盖、互不顶掉。 + +## 范围 + +- 构建期按渠道产出安装身份(`productName` / `identifier`),默认渠道 `dev` 身份保持不变。 +- 渠道产物命名、首装包选择、macOS 发布入口与配置门禁都消费同一份渠道身份。 +- 同机并存的可见区分(窗口标题)与 AGC 自有 AppData 目录的提权 ACL 识别范围。 + +## 不在范围内 + +- 不迁移、不共享既有本地项目、工程快照、模板、登录态与诊断数据。 +- 不新增渠道,不改 OSS 分区布局、官网下载页与后端接口。 +- 不做 Apple 代码签名/公证,不恢复 Intel 架构。 + +## 依赖与前置条件 + +- 渠道与更新端点合同已落地(`-win` / `-mac` 分区与 Tauri updater 端点)。 +- 构建入口统一在 Tauri 构建前注入渠道 `--config`。 + +## 验收标准 + +- [x] 默认渠道的 `productName` / `identifier` 与基线配置逐字一致,既有安装与升级链不断(`check-config.mjs` + 渠道身份用例)。 +- [x] `release` 与自定义渠道派生独立 `productName` 与 `identifier`,与 `dev` 可在同一台设备并存(源码级;真机安装见下方未完成项)。 +- [x] 渠道产物(NSIS `.exe`、`.app.tar.gz`、DMG)与首装包选择跟随渠道身份且唯一匹配(发布脚本用例)。 +- [x] 非默认渠道的客户端数据目录、WebView2 目录与窗口标题跟随渠道身份(构建产物字符串实测 + 运行期标题取产品名)。 +- [x] AGC 自有 AppData 目录的 ACL managed 范围覆盖全部渠道身份,且不扩大到相似前缀目录(Rust 定向 12/12)。 +- [ ] 真机:同一台设备同时安装 `dev` 与 `release`,二者可并存、可各自原地更新。 + +## 证据要求 + +- 自动化:`build-release.test.mjs`、`release-oss.test.mjs`、`prepare-macos-codex.test.mjs`、`check-config.mjs` 与 Rust `config::tests::` 定向测试。 +- 运行时:单渠道 `--no-bundle` 构建烟测;真机双渠道安装、并存与各自更新。 +- 边界:非法渠道失败关闭;默认渠道身份不变;相似前缀目录不进入 managed 赋权范围。 diff --git a/docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md b/docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md new file mode 100644 index 000000000..344a05f21 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md @@ -0,0 +1,73 @@ +# 后台模板上传 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | implemented-awaiting-runtime-acceptance(接口、页面与定向用例已交付;真实 dev bucket 写入 smoke 未执行) | +| Date | 2026-09-21 | +| Parent Spec | `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md` | + +## 目标与范围 + +管理员在后台直接上传模板并发布到公共模板库,不必走本地 CLI;一批可以带多个模板(批量上传),一批只做一次发布锁和一次清单提交。 + +- 新增 `POST /admin/api/agc-templates/import`(`multipart/form-data`):`manifest` 文本字段 + `zip_` / `cover_` 文件字段,字段下标与 manifest 条目顺序一一对应。 +- manifest:`{ expectedRevision, templates: [{ id, title, summary, tags, runtime, engine, engineVersion, entry, templateVersion }] }`;`expectedRevision` 是当前清单字节 SHA-256,用于锁内 CAS。 +- 限制:单批最多 20 个模板;单个 ZIP ≤ 64 MiB;单张封面 ≤ 5 MiB;请求体 ≤ 200 MiB;`id` / `templateVersion` / `entry` 走既有标识符与相对路径白名单;`runtime` 仅接受 `html / unity / godot / cocos`。存储层为 `application/zip` 单独放宽单对象上限到 64 MiB(图片与元数据仍是 5 MiB)。 +- 语义:一批**全有或全无**——任一模板校验失败都在写入前整批拒绝并逐项给出原因;通过后在同一把发布锁内写入全部内容寻址对象、逐个回读校验,最后提交一次清单。 +- 新 ID 默认上架;已存在 ID 的导入就地更新该条目(保留 `enabled` 状态、所属分组与未知扩展字段,包含下架条目);同一 ID 同一 `templateVersion` 但 ZIP 字节不同时拒绝,提示递增 `templateVersion`。 +- ZIP 由服务端校验后按原字节发布(不重新打包、不解压落盘):合法 zip、无符号链接、无绝对路径 / `..` / 盘符条目、必须包含声明的 `entry`、至少一个文件,条目数与解压后体积受上限保护;正文含 `.agent` / `.git` / `.svn` / `node_modules` 段(任意层级)或根目录 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode` 的包直接拒绝,与 CLI 打包同一份段名单。 +- 封面**每个模板必填**(与 CLI 源布局 `v1//cover.*` 一致),仅接受真实 PNG / JPEG / WebP,上限与编辑路径相同(5 MiB、单边 4096、1600 万像素);上传按字节嗅探格式,不信任 multipart 声明的 content-type。SVG 仍只可能来自 CLI 历史发布,编辑与上传都不产生新的 SVG 封面。 +- 后台「模板管理」页新增上传入口:可多选 ZIP、逐行编辑元数据(含可选封面)、批量提交、逐行显示校验错误;沿用现有写入确认、防重复提交与刷新语义。 + +## 不做 + +- 不做模板删除、版本回滚、下架条目清理、历史对象回收。 +- 不改 ZIP 内容、不做服务端重新打包(CLI 仍是确定性打包与 `--only` 定向发布的入口)。 +- 不做审核流、不做从 URL 拉取、不做 CLI 源布局目录(`v1//{meta.json,project/**,cover.*}`)的自动打包上传。 +- 不新增 SpacetimeDB 表或 schema。 + +## 合同与依赖 + +- 存储与锁:复用 `platform-oss` 的 `TemplateLibraryStore` / `TemplatePublishSession`(`begin_publish` / `read_index` / `put_immutable` / `commit_index` / `finish`)与既有版本控制前置检查;不新增第二套发布协议。 +- 领域规则:`module-assets/template_library.rs` 承担清单合并与字段校验(保留未知字段、下架状态、两组间 ID 唯一),沿用既有标识符 / 相对路径 / 摘要规则。 +- DTO:`shared-contracts/admin.rs` 新增导入结果类型;前端类型在 `apps/admin-web/src/api/adminApiTypes.ts`。 +- 鉴权:沿用后台认证与 `agc-templates` 页签权限;路由挂载在 `modules/admin.rs`。 + +## 验收标准 + +1. 权限与路由:未授权读取/写入均失败关闭;无 `agc-templates` 页签权限不可导入;路由契约测试覆盖新路径与方法。 +2. 校验失败关闭:manifest 非法、ID/版本/entry 越界、runtime 不在白名单、ZIP 非法或含越界/符号链接条目、缺少 entry、封面超限或非真实图片,全部在**任何写入之前**整批拒绝,响应逐项给出模板 ID 与原因。 +3. 批量原子性:一批 3 个模板(含 1 个新 ID、1 个新版本、1 个下架 ID)在锁内一次提交;中途任一环节失败时清单与历史对象不变,返回可诊断错误。 +4. 版本一致性:同 ID 同版本、ZIP 字节不同必须拒绝并要求递增版本;同 ID 同版本、字节完全一致视为幂等复用,不报错。 +5. CAS 与锁:`expectedRevision` 过期返回 409;锁被占用返回 409;清单写入结果不明时保留锁并返回 503,不自动重试。 +6. 保留语义:既有条目的展示字段按上传内容更新,`enabled` 分组、其它条目、未知扩展字段与历史对象不变;下架条目仍留在 `inactiveTemplates`。 +7. 内容寻址与回读:ZIP / 封面 / template.json 以自身字节摘要寻址,写入后逐项回读校验,清单最后提交且返回最新快照。 +8. 界面:桌面与窄屏都能完成多选 ZIP、逐行元数据编辑、批量提交与错误展示;保存中防重复提交,409 引导刷新后重试。 +9. 检查:`cargo test`(module-assets / platform-oss / api-server 定向)、`npm run admin-web:typecheck`、后台页面 vitest、`npm run check:encoding`、`npm run check:doc-index`、`cargo fmt --check`、`git diff --check` 全部通过。 + +## 依赖 + +- 已交付的后台模板管理链路(`GET/PUT /admin/api/agc-templates`、页面、锁与内容寻址发布协议)。 +- 模板库 OSS 写凭据(`GENARRATIVE_AGC_TEMPLATE_LIBRARY_OSS_ACCESS_KEY_ID/SECRET` 或成套 `ALIYUN_OSS_*`);无凭据时导入返回 503 且页面明确不可用。 +- 真实 dev bucket 的写入验证需要用户显式确认(见实施计划「验证」)。 + +## 实现与证据(2026-09-21) + +| 验收项 | 证据 | +| --- | --- | +| 1 权限与路由 | 路由契约测试覆盖 `POST /admin/api/agc-templates/import`;页签权限矩阵用例覆盖 GET / PUT / POST 三条路径(`admin::tests::agc_template_routes_require_the_template_tab_permission`) | +| 2 校验失败关闭(写入前整批拒绝) | `template_import_archive_validation_fails_closed`(缺 entry / 越界路径 / 符号链接)、`template_import_plan_rejects_unknown_and_unreferenced_fields`(未引用字段、缺 ZIP 字段、字段前缀、未知 manifest 字段、非法 zip)、`template_import_archive_rejects_identity_and_build_directories`(13 条身份 / 版本库 / 依赖 / 构建目录逐条拒绝 + 正文内 `game/dist/**`、`game/.gitignore` 放行) | +| 3 批量原子性 | `module-assets` 的 `template_import_appends_new_entries_with_metadata`(3 条口径:新 ID 入 `templates`、下架条目留在 `inactiveTemplates`、未知字段保留);发布编排在锁内写完全部对象后才提交一次清单 | +| 4 版本一致性 | `template_import_rejects_same_version_with_different_bytes`(同句文案)、`template_import_reuses_identical_bytes_for_the_same_version`(`reused`) | +| 5 CAS 与锁 | `import_templates` 复用 `check_revision`(409)与 `TemplatePublishSession`(`commit_index` 不确定即留锁 → 503);存储层既有用例覆盖锁与不确定写入 | +| 6 保留语义 | 领域用例断言 `enabled` 分组、`extension` 未知字段与 `metadataKey` 保持不变 | +| 7 内容寻址与回读 | `template_import_plan_uses_content_addressed_keys_and_sniffed_cover`;`put_immutable` 逐个回读校验 | +| 8 界面 | 页面用例:多选 ZIP 生成行、封面匹配齐才可提交、确认后提交 manifest 与文件、成功后刷新列表、409 刷新引导、400 展示真实原因(共 40 项 vitest 通过) | +| 9 检查 | `cargo test`(module-assets 11、platform-oss 12、api-server 定向 4)、`npm run admin-web:typecheck`、`check:encoding`、`check:doc-index`(193 份)、`cargo fmt --check`、`git diff --check` 全部通过 | + +仍未执行:真实 `agc-dev` bucket 的批量写入 smoke(需要用户显式确认;建议用两个 `smoke-import-<时间戳>` 模板验证一批多模板 + 一次清单提交,验证后下架保留历史对象)。 + +## 追加:模板正文目录门禁(2026-09-21) + +验收标准 2 的补充落地——把「不要放进 ZIP」从文档约定升级为机器门禁,CLI 与后台上传同一份段名单(任意层级 `.agent` / `.git` / `.svn` / `node_modules`,根目录 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode`)。证据:Rust `admin_templates` 8 项通过(含新增 `template_import_archive_rejects_identity_and_build_directories`);Node 发布回归 28 项通过(含新增 CLI 门禁用例);仓库 9 个模板源无一条命中,本地重打包 ZIP 摘要与线上清单 `zipSha256` 逐条一致。规则说明见 `docs/【模板规范】AGC模板包组织指南-2026-09-21.md`。 diff --git a/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md b/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md index ae4a90f62..04bfa55a1 100644 --- a/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md +++ b/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md @@ -23,7 +23,7 @@ - [ ] 正式项目窗口可被同步调度识别;周期与关闭触发保持有界,失败有项目级诊断。 - [ ] 未单独配置快照目标时仍使用 agc-dev,只复用资源存储凭据;显式快照目标保持有效,不迁移现存对象。 -- [ ] 后台列表显示项目名/ID、用户 ID、同步时间、文件数、体积和完整性,支持刷新与分页。 +- [ ] 后台列表按部署渠道查询,显示项目名/ID、用户(昵称 + 陶泥号)、同步时间、文件数、体积和完整性,支持刷新与游标分页(每页 20/50/100 + 上一页/下一页)。 - [ ] ZIP 按清单还原相对路径;不含对象存储摘要目录;空文件可上传与导出。 - [ ] 清单名称/完整性变化在无内容差异时也提交,partial 可恢复 ready,历史缺字段不冒充 ready。 - [ ] 缺失、损坏、越界路径和非完整清单失败关闭;历史未声明完整性的清单明确标记,允许导出已有文件但不称为完整工程。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f7122a909..486e4f036 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8,6 +8,71 @@ - 代价与取舍:master 用例里钉住 `.project-supervisor-composer-controls`、`项目总控消息` 的断言改为当前类名 `.project-chat-composer-controls` 与「立项策划消息」;本分支 CSS 的 `project-supervisor-* → project-chat-*` 改名对 master 新增规则同样生效。`workspaceProjectKind` 在两条读它的路径(壳层 Cocos 插件门禁、Supervisor 提交路由)都退役后删除,插件可用性改由插件宿主自判。`.env` 的本地私有改动不进入本次合并。 - 验证方式:`apps/ai-game-creator-shell` `npm run typecheck` 与仓库 `npm run typecheck`、`npx vitest run apps/ai-game-creator-shell/tests`(168 个文件,除 5 个 jsdom `localStorage` 环境失败文件与 1 条本分支既有的 `resourceTagStatsRefresh` 失败外全绿,`appSurface.test.ts` 198 passed / 13 skipped / 0 failed)、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`、改动文件 eslint 0 error。Rust 侧定向 `cargo test --bin genarrative-ai-game-creator-shell -- direct_codex_user_item | skill_pack:: | sessions::developer_project_file_and_memory_writes_advance_project_revision` 全过;整套 Rust 分片在本容器有 55 条环境性失败(`/sbin -> usr/bin` 让 `command.exec` 沙箱的 merged-usr 预检失败,报 `command.exec sandbox unavailable`),与本次合并无关:这些用例所在模块与 master 逐字相同。 +## 2026-09-21 渠道进安装身份:不同渠道的 AGC 包体在同一台设备并存 + +- 背景:渠道此前只决定更新端点(`plugins.updater.endpoints`)与渲染层平台 origin,`productName` / `identifier` 与渠道无关,于是所有渠道共用 `%LOCALAPPDATA%\陶泥儿` 安装目录、同一个卸载项(`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\陶泥儿`,另有 `HKCU\Software\genarrative\陶泥儿`)以及同一份 `%APPDATA%\world.genarrative.ai-game-creator` 数据目录。本机 0.1.48 安装实测:主程序二进制里只有 1 处 `agc/dev-win/latest.json`、0 处 release 端点,说明渠道在产物里只体现为端点。后果是后装的渠道静默顶掉先装的渠道,并接管更新端点、平台服务器与本地登录态/项目数据。 +- 决策:渠道同时决定**安装身份**。默认渠道 `dev` 保持基线 `productName = 陶泥儿`、`identifier = world.genarrative.ai-game-creator`(既有安装目录、卸载项与升级链不断);其它渠道派生 `陶泥儿 <渠道显示名>`(`release` → `陶泥儿 Release`)与 `world.genarrative.ai-game-creator.<渠道>`。身份与更新端点必须在同一个构建期 `--config` 里注入,禁止分别回读默认值。窗口标题、macOS 产物名(`.app` / updater 归档 / DMG 卷名)、首装包选择与 Windows 提权 ACL 的 managed 识别范围同批跟随该身份。 +- 边界:不做本地数据迁移或共享——切渠道等于换一个客户端;`release` 与自定义渠道首次以新身份安装,不接管、不迁移既有 `dev` 安装与本地项目,由用户自行决定是否卸载其一。 +- 影响范围:新增 `apps/ai-game-creator-shell/scripts/channel-identity.mjs`;`build-release.mjs`、`build-macos-ci.mjs`、`check-config.mjs`、`agent-swarm-test-chat.mjs`、`src-tauri/src/{main.rs,windows.rs,config.rs}` 与对应测试;主规范 `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`。 +- 验证方式:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs release-oss.test.mjs prepare-macos-codex.test.mjs cargo-features.test.mjs`(64/64,新增渠道身份与渠道 DMG 首装选择用例)、`node apps/ai-game-creator-shell/scripts/check-config.mjs`(基线等于默认渠道身份、非默认渠道身份隔离)、`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- config::private_path_elevation_policy_tests`(12/12,含基线/`<基线>.release`/`<基线>.beta-2` 与相似前缀反向断言)、`AGC_UPDATE_CHANNEL=release npm --prefix apps/ai-game-creator-shell run build -- --no-bundle --debug`(产物字符串实测 `陶泥儿 Release` × 1、`agc/release-win/latest.json` × 1、`world.genarrative.ai-game-creator.release` × 1、`agc/dev-win/latest.json` × 0)。真机双渠道安装、并存与各自更新尚未执行,按未验证项记录。 + +## 2026-09-21 项目快照按部署渠道分区,后台按渠道查看并按素材查询口径展示用户 + +- 背景:AGC 项目快照此前统一写在 `agc/project-snapshots/v1/{user}/{project}/`,而开发与正式两套部署共用同一个 bucket(都默认 `agc-dev`)。结果是渠道混在一层前缀里:正式后台会列出开发渠道上传的项目,列表上也看不出项目属于哪个渠道;同时“项目工程”列表只有裸用户 ID,且用“加载更多”逐段追加,翻页与定位都困难。 +- 决策(存储):对象键升级为 `agc/project-snapshots/v2/{channel}/{user}/{project}/`。渠道是**部署渠道**,由服务端 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL` 决定(缺省沿用 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`,当前缺省 `dev`),客户端不上报渠道、也不需要发新版;渠道名必须是小写字母开头的 `[a-z0-9-]{1,32}`,非法值在上传与后台查询处失败关闭(`503`/`400`),不悄悄回落。正式部署必须在 api-server 环境里显式写 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL=release`。 +- 决策(后台):新增 `GET /admin/api/project-snapshots/channels` 返回本部署渠道与远端已存在渠道的并集;列表与下载接口都接受 `channel`(缺省本部署渠道),游标里带上渠道并在解码时校验一致,跨渠道复用游标一律 400。页面顶部提供“渠道”选择框,切换渠道回到第 1 页。 +- 决策(列表形态):“用户 ID”列改为与“素材查询”同口径的“用户”列——昵称 + 陶泥号 + 用户详情入口,昵称/陶泥号由 api-server 用 `resolve_work_author_by_user_id` 解析(账号不可读时回退占位作者),原始 `userId` 不再直接铺在列里;“加载更多”改为游标分页(每页 20/50/100 + 上一页/下一页 + 当前页),前端按页记录游标链,换令牌或换每页条数从第 1 页重来;翻页失败保留当前页。 +- 原因:渠道属于“这套部署服务哪个客户端渠道”,客户端正式包按构建渠道连接不同平台服务(release → 正式站,dev → 开发站),所以服务端是唯一可靠的渠道来源;把渠道放进对象键第一层,既不需要迁移历史对象就能让两套部署互不可见,也让后台的渠道维度天然对齐存储布局。 +- 影响范围:`server-rs/crates/platform-oss/src/{lib.rs,project_snapshots.rs,template_library.rs,examples/agc_project_snapshot_live_smoke.rs}`、`server-rs/crates/api-server/src/{config.rs,project_snapshots.rs,admin_project_snapshots.rs,modules/admin.rs}`、`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/{api/adminApiClient.ts,api/adminApiTypes.ts,pages/AdminProjectSnapshotsPage.tsx,pages/AdminProjectSnapshotsPage.test.tsx,styles/admin.css}`、`deploy/env/api-server.env.example` 与本仓运维/技术文档。 +- 未迁移的历史对象:`agc/project-snapshots/v1/` 下现存对象(只读核对过的两份历史清单)保留在 OSS,但不再写入、不再进入后台列表;需要取回时按旧前缀在 OSS 侧直接读取,确实要在后台看到时再单独开一个只读兼容视图。 +- 验证方式:`cargo test -p platform-oss snapshot` 7 passed(渠道校验、键布局、v2 根渠道枚举、v1 历史键仍必须私有);`cargo test -p api-server project_snapshot` 18 passed/1 ignored(渠道失败关闭、游标跨渠道拒绝、用户昵称/陶泥号解析、归档与配额回归);`cargo test -p api-server protected_route_matrix`、`route_contract` 通过(新路由纳入后台鉴权矩阵);`npm run admin-web:typecheck` 与 `npx vitest run apps/admin-web/src` 202 passed。 + +## 2026-09-21 Godot 模板入库与按模板建项的 Godot 分流 + +- 背景:模板库此前只有网页(html)、Cocos 与 Unity 占位,`runtime` 白名单早就接受 `godot`,但既没有 Godot 模板,也没有按模板建项的 Godot 分流——Godot 模板即使上传,建项也会落到 Web 分支,写出 `game/index.html` 占位入口并让 `godotProjectRoot` 为空。 +- 决策(模板内容):新增四个仓库内手写的 Godot 4.7 模板 `godot-empty-2d`、`godot-empty-3d`、`godot-hello-world`、`godot-platformer-2d`,`entry` 统一为 `project.godot`。模板只用内置 `ui_*` 输入动作、GL Compatibility 渲染,并用 `Polygon2D` / `BoxMesh` 搭可视骨架,不引入外部贴图或音频二进制;不打包 `.godot/` 缓存与导出产物。 +- 决策(建项分流):`create_project_from_installed_template_at` 在复制模板后按工程文件分流——Cocos 更新自身身份后走 Cocos 导入,Godot 先改写 `project.godot` 里 `[application]` 段的 `config/name`(只改这一行,其余字节逐字保留)再走既有 Godot 导入,写入 `godotProjectRoot: "."`,其余继续走 `init_local_game_project_at`。分流靠工程文件识别,不新增只读 `entry` 或 `runtime` 字段的契约。 +- 原因:Godot 工程身份是 `project.godot` 所在目录,与 Cocos 的 `package.json` 身份同一类问题;复用既有导入流程能同时拿到相对根记录、`.agent` 初始化与「不生成 Web 占位入口」这三条既有保证,并且与 Cocos 分支保持对称。 +- 影响范围:`apps/ai-game-creator-shell/template-library/v1/godot-*`(新增模板源)、`apps/ai-game-creator-shell/src-tauri/src/template_library.rs`、`.../src/project/manifest.rs`(`apply_godot_project_display_name`)、`.../src/project/manifest/import_tests.rs`、`docs/【模板规范】AGC模板包组织指南-2026-09-21.md`、`docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`。 +- 验证方式:本机 Godot `4.7.2.stable` 对四个模板逐一做「主场景实例化 + 3 帧 + GDScript `--check-only`」,平台跳跃模板另做真实物理试玩(落地 y=627.99、1 秒右移 320px、跳上平台 y=515.93、三枚金币全收集);Rust 定向回归 `import_tests::rewrites_only_the_godot_display_name_line`、`import_tests::keeps_a_godot_project_without_a_display_name_line_untouched`、`template_library::tests::godot_template_creates_native_project_with_relative_root_and_display_name` 与既有 Cocos/Web 建项回归;发布走 `--only godot-*` 定向合并。 + +## 2026-09-21 Godot 工作区发现放宽与内置插件行去掉手动启动 + +- 背景:2026-08-10 “发现与歧义决策”把「一层多命中」和「`project.godot` 必须是普通文件」两条定为失败关闭。这两种布局都会让整个工作区被判成「没有 Godot 工程」,而 `PluginHost::list` 只按发现结果过滤,结果是 Godot 内置插件在设置→扩展 里直接消失,用户看不到任何可诊断入口。 +- 决策(发现):根目录命中仍然优先;根未命中时一层直接子目录多命中改为按目录名排序取第一个,`godotProjectRoot` 记录该相对目录名,结果确定且可复现。`project.godot` 允许是符号链接 / Windows reparse point / 硬链接,判据改为「链接目标解析后是文件」,目录与悬空链接仍不算命中。工作区根本身是链接、候选子目录是链接、二层及更深不递归这三条边界不变。 +- 决策(界面):设置→扩展 的内置插件行去掉手动「启动 / 停止」按钮。启动本来就由项目切换时的 `startAvailableAgcEditorPlugins` 自动完成,手动按钮只是第二个可绕过入口;停止改走该行的启用/禁用开关,`set_agc_plugin_enabled(false)` 会停止插件并断开编辑器连接。导入扩展行的启动按钮保留,因为导入扩展没有自动启动路径。 +- 原因:Godot 插件列表是按项目过滤的,发现失败等于功能静默消失;把不确定性收敛成一个确定的排序选择,比让用户面对空列表更好。链接放宽只作用于只读的工程标记文件,受管描述文件、运行缓存与项目目录的链接拒绝规则不动。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs`、`.../src/commands.rs`(错误文案)、`.../src/project/manifest/import_tests.rs`、`.../src/tests/project.rs`、`apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx`、`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` §3.8、`docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 +- 验证方式:`cargo test --locked -p genarrative-ai-game-creator-shell --bin genarrative-ai-game-creator-shell -- project::manifest::import_tests::` 与 `-- tests::project::`;前端 `agc:typecheck` 与 `runtime-settings` / `pluginHost` 套件。 + +## 2026-09-21 模板正文目录门禁:CLI 打包与后台上传同一份段名单 + +- 背景:模板包组织指南把 `.agent/`、`.git/`、`node_modules/`、根目录 `dist/` 等列为「不要放进 ZIP」,但两条发布路径此前只校验路径安全与 `entry` 是否存在,放进去的东西会跟着建到用户项目里(模板自带 `.agent/` 会让新项目继承一个陌生身份)。这条约定只靠作者自觉。 +- 决策:升级成机器门禁,CLI(`readProjectFiles`)与后台上传(`validate_import_archive`)用同一份段名单与同一句文案——任意层级拒绝 `.agent` / `.git` / `.svn` / `node_modules`,根目录拒绝 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode`。同名目录段只在根目录受限:正文内 `game/dist/**` 是模板自身内容;`.gitignore` 不等于 `.git`,Cocos 模板的 `.creator` / `.gitignore` 不受影响。 +- 原因:两类目录性质不同。`.agent/`、`.git/`、`.svn/`、`node_modules/` 放哪一层都是错的,进包即污染用户项目;而 `dist` / `library` / `temp` 这类只说明「作者把编辑器缓存或构建产物当成了模板内容」,出现在根目录才是信号,全面禁止会误伤工程内的正常同名目录。 +- 影响范围:`scripts/agc-template-library-publish.mjs` 及其测试、`server-rs/crates/api-server/src/admin_templates.rs`、`docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`、`docs/【模板规范】AGC模板包组织指南-2026-09-21.md`、`docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md`。 +- 验证方式:`node --test scripts/agc-template-library-publish.test.mjs`(28 项,新增 1 项:13 条拒绝用例 + `game/dist/**` 与 `game/.gitignore` 放行断言);`cargo test --locked -p api-server admin_templates`(8 项,新增 `template_import_archive_rejects_identity_and_build_directories`);仓库现有 9 个模板源无一条命中,本地重打包的 ZIP 摘要与线上清单 `zipSha256` 逐条一致;`cargo fmt --check`、`check:encoding`、`check:doc-index`、`git diff --check` 通过。 + +## 2026-09-21 后台模板上传:成品 ZIP + 每模板封面,批量全有或全无 + +- 背景:模板发布此前只有本地 CLI(源目录 + 确定性打包 + `--only`),后台上传需要一条不依赖本地仓库的通道,并支持一次提交多个模板。 +- 决策(输入形态):后台上传**成品 ZIP**(库内字节原样发布,不重新打包),每个模板必须同时给一张 PNG/JPEG/WebP 封面(与 CLI 源布局 `v1//cover.*` 一致),`id/title/templateVersion/runtime/entry` 由页面逐行确认;简介、标签与引擎上传后用编辑接口补齐。服务端按字节嗅探封面格式,不信任 multipart 声明的 content-type。 +- 决策(批量语义):一批 = 一把发布锁 + 一次清单提交,**全有或全无**;任一模板的 manifest/归档/封面不合法都在写入前整批拒绝并逐项给出原因。单批上限 20 个模板、单包 64 MiB、单封面 5 MiB、请求体 200 MiB。 +- 决策(校验与安全):归档只校验不落盘——合法 zip、无符号链接、无绝对路径 / `..` / 盘符条目、必须包含声明的 `entry`、条目数 ≤ 4096 且解压后 ≤ 512 MiB。 +- 决策(版本与保留):新 ID 默认上架;已存在 ID 就地更新并保留 `enabled` 分组、其它条目与未知扩展字段;同一 ID 同一 `templateVersion` 的 ZIP 字节不同时拒绝并要求递增版本,字节一致时按内容复用(`reusedObjects`)。 +- 决策(存储契约):`platform-oss` 的 `put_immutable` 为 `application/zip` 单独放宽到 64 MiB(此前只有 json/图片的 5 MiB),图片与元数据上限不变。 +- 影响范围:`server-rs/crates/{shared-contracts/api-server/module-assets/platform-oss}`、`apps/admin-web/src/{api,pages,styles}`、模板库技术方案与 `docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md`。 +- 验证方式:`cargo test`(module-assets 11、platform-oss 12、api-server 定向 4)、`npm run admin-web:typecheck`、后台页面与模型 40 项 vitest、`check:encoding`、`check:doc-index`、`git diff --check`;真实 dev bucket 写入验证需用户显式确认。 + +## 2026-09-21 模板库线上产物对齐仓库源:递增版本重发 + 说明文档纳入发布 + +- 背景:`agc-dev` 上的 `templates/` 产物停在 2026-09-17 发布的那一版,仓库源在那之后改过(`5e4ff54a9` 删掉模板内嵌 `package-lock.json`、`game.js` / `main.js` 等内容调整),9 个模板里 7 个的 ZIP 与线上不一致;dry-run 被「同一 `templateVersion` 的 ZIP 不得变」门禁拒绝,发布器因此无法把仓库状态发上去。 +- 决策:按门禁要求为内容已变的 7 个模板递增 `templateVersion` 到 `0.1.1`(`blank-2d-canvas`、`blank-web` 内容未变,保持 `0.1.0`),并完成一次真实发布;对象使用内容寻址键 `v1//sha256/<摘要>/…`,`index.json` 与说明文档在发布锁内最后提交,历史对象不删除。 +- 决策(说明文档):`templates/README.md` 不再是手工副本——它由发布脚本从仓库源 `apps/ai-game-creator-shell/template-library/README.md`(≤64 KiB)覆盖写入并回读校验,写在清单之前;改契约只改仓库源。 +- 决策(CLI 行为):发布器遇到门禁拒绝时用 `process.exitCode = 1` 正常退出,不再 `process.exit(1)` 让 Node 在 fetch 句柄未关闭时抛 libuv 断言(此前现场只剩 `Assertion failed`,看不到拒绝原因)。 +- 影响范围:`apps/ai-game-creator-shell/template-library/v1/*/meta.json`、`apps/ai-game-creator-shell/template-library/README.md`、`scripts/agc-template-library-publish.mjs` 与其测试、本文件与模板库技术方案。 +- 验证方式:`node --test scripts/agc-template-library-publish.test.mjs`(27 项,含新增「说明文档随发布覆盖写且写在清单之前」「源目录没有 README 时不写线上文档」);真实发布 28 个对象回读通过;匿名读取线上清单(9 个模板、7 个 `0.1.1`、全部内容寻址键)与 `templates/README.md`(与仓库源逐字节一致);AGC 壳 3 项线上用例(读清单、下载安装、下载并原生建项 Cocos)重跑通过。 + ## 2026-09-21 macOS 发布改为只出 arm64 单架构(Intel 暂不支持) - 背景:Mac 发布管线按 `universal-apple-darwin` 构建,但随包 Node 便携运行时只有**单架构官方发行版**(`stage-node-runtime.mjs` 从 `process.execPath` 取材),于是 macOS Job #7~#13 连续失败在「Node 运行时不支持发布目标:universal-apple-darwin」。期间出现过一版「按宿主架构放行」的过渡实现,它能骗过通用包自检(`check-macos-bundle.mjs` 按 `process.arch` 校验),但 Intel 上那份 arm64 侧车不可执行,并且已发布的 dev-mac 0.1.86 就带着这个缺陷。 @@ -8242,7 +8307,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-08-10 AGC 打开现有 Godot 项目 - 项目双根决策(2026-08-14 更新):项目组只保留通用“打开项目 / 新建项目”,不再提供独立 Godot 入口。用户选择目录始终是工作区根,也是 `.agent`、Session、Runner、沙箱、通用文件工具和外围资料的唯一授权根;实际 Godot 根由根目录或一层直接子目录中的普通文件 `project.godot` 唯一确定,并以工作区相对 `godotProjectRoot` 记录,根目录使用 `.`。不得把 Runtime 根切换成 Godot 子目录,也不得复制工程或建立第二套工作区。 -- 发现与歧义决策:根目录命中优先;根未命中时只检查一层直接子目录,唯一命中才通过,多个命中在任何 `.agent` 写入前失败关闭。候选目录与工程文件拒绝符号链接和 Windows reparse point,二层及更深不递归。未来只有 Godot 专属命令显式使用经过校验的相对 Godot cwd。 +- 发现与歧义决策:根目录命中优先;根未命中时只检查一层直接子目录,唯一命中才通过,多个命中在任何 `.agent` 写入前失败关闭。候选目录与工程文件拒绝符号链接和 Windows reparse point,二层及更深不递归。未来只有 Godot 专属命令显式使用经过校验的相对 Godot cwd。(2026-09-21 部分取代:多命中改为按目录名排序取第一个,`project.godot` 允许链接并按目标判定;候选子目录与更深层的边界不变,见本文件「2026-09-21 Godot 工作区发现放宽与内置插件行去掉手动启动」。) - 元数据决策:首次导入只在工作区根创建并保留 `.agent/manifest.json`、`.agent/agent.db`、`.agent/logs/` 与 `.agent/runtime/`;不得创建默认 Web 原型的 `game/`、`assets/`、`memory/`、`exports/`。已有有效 `.agent` 项目继续复用身份;缺失或错误的可推导 `godotProjectRoot` 只在 Godot 打开边界按唯一文件布局校准,歧义时不改写。 - Windows 锁文件决策:提升权限进程新建 `.agent/.manifest.json.lock` 时,Windows 可能把 owner 设为 `Administrators`。仅在固定锁路径已取得不共享独占句柄并确认是普通、非 reparse、单链接文件后,才初始化为当前 `TokenUser`;随后再次复核句柄并执行原有 owner/DACL 校验,不放宽既有异常对象的安全规则。 - 运行决策:Godot 项目提交给 Project Supervisor 时使用 `standard` Run Profile,避免触发 Web 专用 `game/index.html`、HTTP preview 与自主 Web 完成门。Godot 编辑器启动和内嵌运行预览不在本切片范围。 @@ -9019,7 +9084,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(矩阵):`ui-interaction` = 生成图片 / 生成规范(图标规范、自定义规范)/ 生成图标素材 / 生成 UI 设计图 / 上传;`character` = 生成图片 / 生成规范(角色规范、自定义规范)/ 生成角色形象 / 上传;`scene` = 生成图片 / 生成规范(自定义规范)/ 上传;`audio` = 生成背景音乐 / 生成音效 / 上传。文档、待归类、项目版本、「所有资源」展开态与资源总览**不渲染**工具栏(`resolveResourceCanvasBottomTools` 返回空数组,渲染判据 `resourceBookState.view === 'child'` 且 category 命中四个栏目)。工具项与顺序是纯数据表,事实源落在 `resourceCanvasBottomToolbarModel.ts`。 - 决策(落点与几何):工具栏是 `.game-resource-book-manager` 的**直接子节点**(与画本场景、右下 Dock、通知条并列,不进带 `scale()` 的场景层),锚在**左下角**(`left/bottom: 14px; z-index: 40`)——右下角是既有缩放 / 撤销 Dock,左下角是画布上唯一两者都不占的稳定空位;外壳消费共享 `packages/image-canvas-react` 的 `CanvasToolbar / CanvasToolbarGroup / CanvasChromeButton`(AGC 侧第一次真正消费这组共享 chrome)。一处必要覆盖:共享 `.genarrative-image-canvas__toolbar` 是横向滚动容器,会裁掉它自己弹出的二级菜单,消费端改成 `overflow: visible` + `flex-wrap: wrap`。 - 决策(接线与收敛):图片类入口 `generate_local_project_asset`(kind 映射:图片 `image` / 角色形象 `character` / 图标规范 `icon-spec` / 角色规范与自定义规范 `spec` / 图标素材 `art-spritesheet` / UI 设计图 `ui-prototype`);音频入口复用既有 `derive_local_project_resource` 无源生成链路(**不另写一份音频生成**);上传复用 `upload_local_asset`。两者成功后的刷新形状统一为「**配对读 `(revision, manifest)`** → `onManifestChange` → `pendingResourceFocusRef` 定位新卡」,不重算依赖图、不另写布局。**音频入口收敛**:既有「生成素材」浮层入口改为只保留视频(`ResourceCanvasGenerationPanelView` 新增 `kinds` prop,单类型入口不再渲染类型选择器,面板标题与提交文案跟类型走),视频能力不删只是不进工具栏。 -- 决策(参数口径,避免假控件):比例 / 尺寸选项与默认值复用网页端纯模型 `ImageCanvasGenerationModel.ts`,但按本地 IPC 白名单收窄(本地通道明确拒绝 `4:3`,照搬就是一个点了必失败的选项);提示词上限复用 `resourceEditPromptMaxLength`(32000,与 Rust `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 同口径);**面板不渲染模型选择器**(本地 IPC 没有 `model` 入参)。规范入口是固定档:只读展示规格,不给点了不生效的比例控件。 +- 决策(参数口径,避免假控件):比例 / 尺寸选项与默认值复用网页端纯模型 `ImageCanvasGenerationModel.ts`,但按本地 IPC 白名单收窄(本地通道明确拒绝 `4:3`,照搬就是一个点了必失败的选项);图标素材描述去除首尾空白后最多 `200` 个 Unicode 字符,作为唯一 `iconDescriptions` 元素原样提交,不包装、截断或拆条;其余图片提示词上限为 `32000`,前端与 Rust 原生校验保持一致;**面板不渲染模型选择器**(本地 IPC 没有 `model` 入参)。规范入口是固定档:只读展示规格,不给点了不生效的比例控件。 - 决策(前置规范图,本轮的关键设计):`ui-prototype` 与 `art-spritesheet` 在 Rust 侧要求项目里已有登记并绑定当前账号的 `assets/art-spec.png`(`local_path` 精确匹配 + `kind == icon-spec` + 图片媒体类型 + 画布来源)。前端判据 `projectHasIconSpecReference` 与它逐字对齐;缺前置时入口**保持可点击**并给可执行原因(`aria-disabled` 而非原生 `disabled`,文案含 `assets/art-spec.png` 与「生成规范 → 图标规范」),且零生成请求。**同时**把「图标规范」入口在缺前置时按 `assets/art-spec.png` 落盘(`outputPath`),否则那两条入口会被前置条件永久锁死、工具栏自身无法满足自己的前置;已有权威规范图时不再传 `outputPath`(Rust `replace_existing` 固定 `false`,指向已存在文件会被硬拒),改为生成一张新的普通图标规范资产。这条是**前端策略**,不扩接口。 - 已知能力缺口(用户口径是「缺参数就停下汇报,不自行扩接口」,记账备查):本地 IPC 没有 ① `model` ② `specType` ③ `replaceExisting` 入参。后果:面板不给模型选择;**角色规范与自定义规范共用 `spec` 通道**(靠 `assetName` / 提示词区分),因此「角色规范」不是真正的角色设定板通道;权威规范图存在时无法重写它。另:网页端 composer 子视图(含 `ImageCanvasSpecGenerationPanelView` 直连 `/api/editor/llm/icon-specs/*`)**没有复用**,因为 AGC 无该 BFF 通道且本地 IPC 不接受模型 / 参考图入参,照搬会渲染改不了请求的控件——本轮只把**纯模型**接进来。 - 影响范围:`apps/ai-game-creator-shell/src/features/resource-canvas/{resourceCanvasBottomToolbarModel.ts,ResourceCanvasBottomToolbarView.tsx,ResourceCanvasAssetGenerationPanelView.tsx,ResourceCanvasGenerationPanelView.tsx,resourceCanvasChrome.css}`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、测试 `apps/ai-game-creator-shell/tests/{resourceCanvasBottomToolbar.test.tsx(新增),resourceCanvasGenerationEntry.test.tsx,projectResourceLiveIntegration.test.tsx,appSurface/project-development.suite.ts}`、PRD §3.10 / §7.9 / §8、`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(新增)、验收用例 S11 / S11a。**未动**:Rust、external v1 / OpenAPI、`packages/`(只消费共享 chrome)、SpacetimeDB。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 20a516087..b60e45240 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,29 @@ # 踩坑与排障记录 +## 客户端图标素材描述不能先包装再截断 + +- 现象:资源画布填写了具体图标需求,平台实际收到的 `iconDescriptions` 却只包含泛化的小游戏美术指令,生成结果不遵循输入。 +- 原因:客户端先给用户描述套“Web 小游戏首版原型、核心美术素材”和默认 brief 模板,再把包装后的文本压入单项 `200` 字符;固定前缀已占满额度,用户描述在发给 API Server 之前就被丢弃。服务端校验只能看到被截短的文本,无法恢复原需求。 +- 处理:客户端画布图标生成只 trim 描述并以单项 `iconDescriptions` 原样提交,保留内部换行;面板与原生入口按 `200` 个 Unicode 码点校验并拒绝空白或超限输入,不静默截断、不机械拆条。服务端共用的提示词流程负责规范图、背景与排布要求;其它图片生成的 `32000` 字符上限保持不变。 +- 验证:检查实际请求体与 trim 后的原文一致,并覆盖 `200/201` 码点、补充平面字符、换行和空白输入;不能只断言“请求长度未超限”。完整合同见 [画板图标素材生成入口设计](../../【编辑器】画板图标素材生成入口设计-2026-06-15.md)。 + +## 2026-09-21 不同渠道的包体在同一台设备安装会互相顶掉 + +- **现象**:在一台已经装了某个渠道 AGC 客户端的设备上安装另一个渠道的安装包,装完后旧客户端直接消失(安装目录被覆盖、卸载项被接管),更新端点、平台服务器与本地登录态一起换成新渠道的;两个渠道的客户端无法共存。 +- **原因**:渠道此前只烘焙了 `plugins.updater.endpoints` 与 `VITE_AGC_PLATFORM_CHANNEL`(`apps/ai-game-creator-shell/scripts/build-release.mjs` 的 `createChannelConfig`),`productName` / `identifier` 用的是渠道无关的基线值。Tauri 的 Windows 安装目录与卸载项由 `productName` 决定,WebView2 数据目录与客户端数据目录由 `identifier` 决定,于是所有渠道落到 `%LOCALAPPDATA%\陶泥儿`、`HKCU\...\Uninstall\陶泥儿` 与 `%APPDATA%\world.genarrative.ai-game-creator`。 +- **处理(现行口径)**:渠道进入安装身份,默认渠道保持基线身份不变,其它渠道派生 `<产品名> <渠道显示名>` 与 `<基线>.<渠道>`;身份与端点在同一次构建期 `--config` 注入。见决策记录 2026-09-21 条目。 +- **核对方式**:装完任渠道的包后看 `HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\<产品名>` 的 `InstallLocation`、`%APPDATA%\` 与主程序窗口标题是否按渠道分开;同名安装目录或同名数据目录说明身份没有生效。 +- **易错点**:只改安装包文件名或快捷方式名而不改 `identifier`,两个渠道仍会抢同一份 Agent Runner / 项目锁与登录态;反过来把渠道后缀加在默认渠道上,既有安装的升级链会断(客户端认不出旧安装)。相似前缀目录(如 `world.genarrative.ai-game-creator-backup`)不得进入提权 ACL 的 managed 范围。 +- **关联**:`apps/ai-game-creator-shell/scripts/channel-identity.mjs`、`build-release.mjs`、`build-macos-ci.mjs`、`src-tauri/src/config.rs`。 + +## 发布器守卫拒绝时不要把 process.exit 用在 fetch 句柄未关闭处 + +- 现象:`agc-template-library-publish.mjs --dry-run` 撞上「同一 `templateVersion` 的 ZIP 不得变」门禁时,终端只剩一句 `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c`,看不到任何拒绝原因,看起来像脚本崩溃而不是被拒绝。 +- 原因:`main().catch(...)` 里直接 `process.exit(1)`;此时 dry-run 刚用 fetch 读过公共清单,句柄仍在关闭流程中,Node/libuv 在 Windows 上先抛断言,把真实错误信息挤掉。 +- 处理:`catch` 里只设 `process.exitCode = 1`,让事件循环自然退出;同批把说明文档纳入受管发布(见决策记录 2026-09-21 条目)。 +- 验证:把模板源复制到临时目录、把某个模板的 `templateVersion` 改回与线上同版本并改动一个字节,`--dry-run` 应打印 `同版本 ZIP 内容或尺寸变化,请递增 templateVersion` 且退出码为 1,不再出现 `Assertion failed`。 +- 关联:`scripts/agc-template-library-publish.mjs`。 + ## macOS 只出 arm64 单架构,universal 必须失败关闭 `stage-node-runtime.mjs` 只把**构建宿主的 Node**打成便携运行时(官方发行版是单架构,没有 universal 发行版),而 2026-09-21 之前 `build-macos-ci.mjs` 构建的是 `universal-apple-darwin`:macOS Job #7~#13 因此在 `stageNodeRuntime` 直接抛「Node 运行时不支持发布目标:universal-apple-darwin」。期间出现过一版「按宿主架构放行」的过渡实现(`targetRuntime` 对 universal 返回宿主架构),它能骗过通用包自检(`check-macos-bundle.mjs` 按 `process.arch` 校验),但**Intel Mac 上这份 arm64 侧车不可执行**,等于把坏包发出去。当前决策:macOS 固定只构建 `aarch64-apple-darwin`,清单只登记 `darwin-aarch64`,`targetRuntime('universal-apple-darwin')` 保持失败关闭。恢复 Intel 的正确路径是先在 staging 支持按架构各带一份**同版本**运行时(另下载另一架构官方发行版)并让通用包自检按架构分别校验,再切回 universal 目标、把 `darwin-x86_64` 键登记回去;不得用「只带宿主架构」充数,也不得把 arm64 产物登记成 x86_64 键。 @@ -4135,6 +4159,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:活会话下 finalization 和 `runner.shutdown_if_idle` 必须失败关闭;分别验证 graceful handler 尾部输出、宽限超时后的 force、忽略 SIGHUP 的 npm 孙进程和 Windows Job 路径,只有 child 已终态、同组残留已处理且 PTY 尾部排空才出现唯一 terminal record。另用允许程序证明代理和固定 cwd 不是文件系统 / 网络沙箱,不得把该现象误写成测试失败或安全能力。 - 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`、`apps/ai-game-creator-shell/src-tauri/src/runner.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent.rs`。 +## 一次性命令不能把孤儿僵尸误判为仍在执行的进程组 + +- 现象:Linux CI 的命令已退出,但 `command.exec` 返回 `needs-reconciliation`,后续修复计划或输出读取请求一直等不到;普通 WSL 下相同命令可通过。 +- 原因:容器 PID 1 未回收孤儿 `bwrap` 僵尸,`kill(-pgid, 0)` 仍返回成功;主进程已被 wait 回收,后续 leader 启动身份核对必然失败,掩盖了真实退出结果与原本应触发的日志审计错误。 +- 处理:确认 target 终态且回收主进程后扫描 `/proc//stat`,空组或仅含 `Z / X` 成员无需发送信号;有存活成员仍保留 leader 身份门禁,读取失败保守进入 reconciliation,不放宽未知进程组的信号权限。 +- 验证:隔离 subreaper 夹具覆盖 leader 已回收时的存活后代拒绝、孤儿僵尸接受和空组接受;原有诊断命令、审计失败、命令修复与长输出/历史读取测试在不回收孤儿的 PID namespace 下验证。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/command_exec.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs`。 + ## 命令环境变量、代理和进程组不能冒充 OS 沙箱 - 现象:命令看似使用隔离 HOME / TMP、离线包管理器和不可达代理,仍能直接读取宿主用户文件、用原始 socket 联网,或由 `project.verify` 的平行 npm spawn 绕开 `command.exec` 限制。 @@ -5417,6 +5449,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 处理:Windows 专用 Tauri 配置设置 `bundle.useLocalToolsDir: true`,把工具缓存到 `src-tauri/target/.tauri/NSIS`;Jenkins 预检验证实际用户、项目工具目录可写,并在构建失败时打印实际缓存路径和绝对路径执行结果。 - 验证:不要把 PATH 中 `makensis` 可发现当作 Tauri bundler 工具可执行的充分证据;需要在 Windows Agent 上检查 `target/.tauri/NSIS/makensis.exe`、ACL、EDR/Defender 和直接 `-VERSION` 结果。 +## Tauri NSIS 工具链必须在打包前预置并重试(2026-09-21) + +- 现象:AGC Windows 发布构建已完成 Rust release,Tauri 依次打印 `Downloading .../nsis-3.11.zip`、`Info extracting NSIS`、`Downloading .../nsis_tauri_utils.dll` 之后,直接以 ``failed to bundle project `io: unexpected end of file` `` 失败(退出码 1),安装包不会产出。 +- 原因:tauri-bundler 的 `download_and_verify` 现场从 GitHub 取 NSIS 工具链,只有一次机会、没有重试;响应体被截断即报 `io: unexpected end of file`,看起来像打包错误其实是网络问题。Checkout 阶段的 `git clean -fdx` 每次都会清掉 `target/.tauri`,所以每个构建都要重新下载,在受限网络下必然反复失败。 +- 处理:新增 `apps/ai-game-creator-shell/scripts/nsis-toolset.mjs` 与 `ensure-nsis-toolset.mjs`,在 `buildRelease`(Windows 目标且需要打包时)与 Jenkins `Tauri NSIS toolchain` 阶段按固定 SHA1 预置 `target/.tauri/NSIS`:原始归档带 4 次重试,缓存在工作区外的 `%ProgramData%\genarrative\tauri-nsis-cache`(可用 `AGC_TAURI_NSIS_CACHE_DIR` 覆盖),镜像开关沿用 bundler 的 `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE` / `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR`。该阶段同时执行 `makensis.exe -VERSION`,把 2026-09-02 记录的缓存目录不可执行问题也提前到编译之前暴露。Checkout 阶段改为 `git clean -fdx -e apps/ai-game-creator-shell/src-tauri/target/.tauri`:Tauri 的工具缓存位于工作区内,裸 `git clean -fdx` 会连它一起删,排除后同一节点的稳态构建不再需要联网,只有冷缓存(新节点、工作区重建)才下载。 +- 验证:`node --test apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs`(已就绪零下载复用、缓存离线还原、失败重试、哈希不符与归档越界失败关闭)与 `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs`;真实节点上该阶段必须早于 Rust 编译失败关闭。 +- 注意:不要改回 `bundle.useLocalToolsDir: false` 去用 `%LOCALAPPDATA%`,也不要依赖 PATH 里预装的 `makensis`;升级 `@tauri-apps/cli` 时同步核对归档 URL、SHA1 与必需文件清单。 + ## AGC 登录态续期必须同步本地运行时 - 模型目录 HTTP 请求与 DirectProject 的 Rust/app-server 使用同一账号,但凭据分别保存在 WebView 与 Rust / Runner;续期应复用 `requestPlatformSessionRefresh` 完成用户核验及本地会话安装,不能只写 localStorage。 @@ -5891,3 +5931,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **成因**:DirectProject 回合真正的输入只来自 canonical `userItem`。`direct_codex_user_item_to_codex_turn_input`(`agent/direct_codex_user_item/wire.rs`)在 DirectProject 分支重建 `turn/start.input`,`LlmRunRequest` 里那份 `user_prompt` 会被覆盖;客户端投影走的是另一套口径(素材被删或改名时退化成裸 resourceId),一旦有人把它接回 Codex,就把 `@显示名` 标签污染了模型输入。 - **现行口径**:IPC 只传 `projectPath`、`clientTurnId`、`userItem` 与可选 `creationType`;Rust 只从 `userItem` 派生回合输入(空判定与三维契约探测用的派生 prompt 仍在 Rust 内部生成)。前端那份 `@显示名` 投影只服务本地的 `/history` 识别与队列 chip 文案,不出 IPC;界面断言只能读 `userItem.content`。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs`、`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts`、`apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts`。 + +## 2026-09-21 DirectProject 结构化消息不能逐个拒绝空白文本片段 + +- **现象**:多行正文、末尾空段落或合法引用前后的分隔空格会让有内容的消息报错;编辑器为了保持结构产生的空白文本片段被误判为“聊天内容为空”。 +- **原因**:校验器对每个 `input_text` 单独执行 `trim().is_empty()` 并立即拒绝,混淆了结构化片段合法性和整条消息是否有实际内容。 +- **处理(现行口径)**:`input_text` 允许空字符串、空格和换行,校验过程保持全部片段的原文、分段与顺序,不做合并或删除;遍历完整条消息后,只在既没有非空白文字、也没有任意非文本 part(素材引用 / 运行画面引用 / Skill 引用 / 附件引用)时返回“聊天内容不能为空”。各类引用仍逐个执行原有校验,消息带正文也不能绕过非法引用。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 3cd48c7d9..2729c69de 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -58,7 +58,7 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐 - DirectProject 对话先在完整历史中按回合/原始 item 身份关联,再分页渲染;每个回合只有一个呈现入口。有流按 item `seq` 交替文本和工具,无流采用历史正文;禁止位置猜配或同时展示累计回复与 item 正文。流写入单调归并,收尾等待落盘任务,不按磁盘“最后一段”猜最终回复位置。详见 AGC 实施计划的“DirectProject 回合展示唯一归属”。 - 回合生命周期只由活动 client 回合快照和 Direct 事件恢复;Provider 的历史终态通知不能创建活动 client 回合。消息发送时间保存在历史信封,原始 item 不混入宿主字段;完成后的中间文本和工具默认收进“执行过程”,最终回复及失败提示保持可见。 -- AGC 安装产品名统一为“陶泥儿”,由 Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述;Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。内部可执行文件名与应用 identifier 保持稳定。 +- AGC 安装产品名基线为“陶泥儿”,由 Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述;默认渠道 `dev` 保持基线产品名与 identifier `world.genarrative.ai-game-creator` 不变,其它渠道派生 `陶泥儿 <渠道显示名>` 与 `<基线>.<渠道>`,让不同渠道的包体在同一台设备上并存而不互相顶掉(详见《AGC客户端更新检查与下载》的渠道与安装身份合同)。Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。内部可执行文件名保持稳定。 - 新 Web 游戏为 `game/` 下的 npm + Vite + Phaser 4.2.1 工程,使用包导入且允许其它依赖;npm 预览与导出只读取 dist,运行素材需纳入构建。单 HTML → Phaser 迁移固定走 DirectProject:文件落盘后先用受控 `project.bootstrap` 在 `game` 执行无参数 `npm install`,再用支持相对 cwd 的 `project.verify` 构建并确认 `game/dist/index.html`,已有单 HTML/Godot 不通过 JSON Generator 伪装成 npm 工程。 diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index aed755b62..41778637a 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -35,7 +35,7 @@ - AGC 批量追加素材标签由原生在一次项目写锁与 revision CAS 下合并各项原标签,先校验全批再写 manifest;前端不能循环单素材分类命令,不回传展示层推导的分类或旧标签全集,以免部分写入或覆盖未编辑字段。 -- AGC 正式包的平台服务跟随构建渠道:`release` 连接 `https://www.genarrative.world`,`dev` 连接 `https://dev.genarrative.world`;本地 debug 态保留 release/dev/custom 服务器选择,会话凭据始终按 origin 隔离。发布渠道为 `dev/release/自定义名称`,Windows/Mac 是系统,OSS 的 `-win/mac` 仅是延续既有地址的分区。官网通过服务端 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(默认 dev)选择渠道,公开同源 `/api/client-downloads` 汇总其各系统首装包与真实版本;未发布隐藏,单系统失败不影响其它下载,不跨渠道补齐。发布先上传 EXE/DMG 再写对应分区清单,不维护会互相覆盖的共享 OSS 索引。主站 Vite 代理复用实际 `runtimeServerTarget`。完整约定见 AGC 客户端更新检查与下载专题。 +- AGC 正式包的平台服务跟随构建渠道:`release` 连接 `https://www.genarrative.world`,`dev` 连接 `https://dev.genarrative.world`;本地 debug 态保留 release/dev/custom 服务器选择,会话凭据始终按 origin 隔离。发布渠道为 `dev/release/自定义名称`,Windows/Mac 是系统,OSS 的 `-win/mac` 仅是延续既有地址的分区。官网通过服务端 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(默认 dev)选择渠道,公开同源 `/api/client-downloads` 汇总其各系统首装包与真实版本;未发布隐藏,单系统失败不影响其它下载,不跨渠道补齐。发布先上传 EXE/DMG 再写对应分区清单,不维护会互相覆盖的共享 OSS 索引。主站 Vite 代理复用实际 `runtimeServerTarget`。渠道同时决定安装身份:默认渠道 `dev` 必须保持基线 `productName` / `identifier` 不变,其它渠道派生独立身份,使不同渠道的包体可在同一台设备并存且本地数据不共享;身份与更新端点必须在同一次构建期注入里确定,禁止分别回读默认值。完整约定见 AGC 客户端更新检查与下载专题。 - AGC 模板库灰度复用 `agc:template-library`:未配置关闭,已配置时遵循现有灰度启停、用户 ID/标签和比例规则;服务端返回权威结论,客户端入口和原生清单/下载/建项均执行门禁,主体切换丢弃旧异步结果。公开 OSS 不是保密边界,已创建项目不受影响。 - 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。 diff --git a/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md b/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md index b33697696..5e3dbf114 100644 --- a/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md +++ b/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md @@ -3,7 +3,9 @@ > 文档状态:`current` > 规范关系:承接 AGC 通用插件宿主与编辑器适配主规范 -更新时间:`2026-09-20` +更新时间:`2026-09-21` + +2026-09-21 更新:解除工作区发现上的两条限制——一层子目录多命中时按目录名排序取第一个(不再整体失败关闭),`project.godot` 允许是符号链接 / Windows reparse point / 硬链接(按链接目标判定);同时设置→扩展 的内置插件行去掉手动「启动 / 停止」,启动统一由前端自动完成、停止改走该行启用开关。 ## 目标与边界 @@ -15,7 +17,7 @@ 将 Godot 编辑器操控接入现有 AGC PluginHost、EditorAdapter、Runner、内置插件开关、权限审计和 Agent 工具链。Windows x64 的 Godot 4.7 及以上标准编辑器是首个实现目标,实机验收使用 4.7.2;其他平台和 .NET 编辑器不得从该结果推断支持。 -初版工程路径支持 Windows 本地盘符目录;UNC/网络共享路径在准备描述文件前明确拒绝。链接/reparse point 继续按同一文件边界失败关闭。 +初版工程路径支持 Windows 本地盘符目录;UNC/网络共享路径在准备描述文件前明确拒绝。受管描述文件、缓存与运行缓存继续按同一文件边界对链接/reparse point 失败关闭;唯一例外是工作区发现读取的 `project.godot`,它允许是链接并按目标判定。 DLL 原件随 AGC 安装包放在插件资源目录中;Godot Windows 加载器会在被加载文件旁生成 `~DLL`,因此宿主在 AGC 私有配置目录的运行缓存中按编辑器实例和构建身份准备临时加载副本。AGC 向项目新增一个可扫描的受管 `.gdextension` 描述文件,通过绝对路径引用该实例的加载副本;Godot 可自动生成其同名 `.uid` 伴生文件。DLL 不复制进工程。重新聚焦 Godot 后,由官方文件扫描完成首次加载;不需要用户打开或运行脚本,不创建 EditorPlugin addon,不修改 project.godot 或业务场景文件。编译工具链只属于开发与打包环境,不要求终端用户安装编译器。 @@ -24,9 +26,9 @@ DLL 原件随 AGC 安装包放在插件资源目录中;Godot Windows 加载器 ## 入口与归属 - 插件 id 为 `agc-godot-editor`,适配器为 `godot-editor`;命令 `godot.editor.execute`、连接能力 `godot.editor.connection`,DirectProject 工具为 `agc_godot_execute`。复用已有扩展列表和启用开关,不建立平行插件管理页面。 -- 项目发现沿用现有 Godot 工作区合同:工作区根保持用户选定目录;实际 Godot 根由普通 project.godot 在根或唯一一层子目录中确定。准备描述文件和读取 Godot 缓存只作用于实际 Godot 根,通用文件工具/Runtime 的工作区根不改变。 +- 项目发现沿用现有 Godot 工作区合同:工作区根保持用户选定目录;实际 Godot 根由根目录或一层直接子目录中的 project.godot 确定,一层命中多个时按目录名排序取第一个(确定性,不再报歧义),`project.godot` 本身是链接时按链接目标判定。准备描述文件和读取 Godot 缓存只作用于实际 Godot 根,通用文件工具/Runtime 的工作区根不改变。 - 平台、内置开关、项目及目标身份必须在执行入口重新检查。插件只能处理宿主传入的当前受控项目,模型不能覆盖项目路径、DLL 路径、端口、令牌或目标实例。 -- Godot 的插件列表、启动和 Agent 工具目录继续按当前 Godot 项目过滤;Cocos/Unity 沿用各自不按工程类型过滤的合同。前端统一消费宿主列表自动启动三种编辑器插件,不在界面重复推断项目类型。Godot 的项目切换仍撤销旧插件上下文并停止旧实例。 +- Godot 的插件列表、启动和 Agent 工具目录继续按当前 Godot 项目过滤;Cocos/Unity 沿用各自不按工程类型过滤的合同。前端统一消费宿主列表自动启动三种编辑器插件,不在界面重复推断项目类型;设置→扩展 的内置插件行只保留启用/禁用与重载,不再提供手动启动/停止入口。Godot 的项目切换仍撤销旧插件上下文并停止旧实例。 - 插件启动先完成项目事件订阅并接收当前受控项目快照,再注册命令和连接能力;命令可见时必须已经具备执行上下文。订阅期间收到的新项目事件优先于迟到的初始快照。 - 只连接已打开且唯一匹配真实工程路径的 Godot Editor;校验 PID、进程启动身份、Godot 版本、握手中的工程路径与会话代次。多个候选、非编辑器、路径不符或已退出的进程均拒绝,不启动或关闭用户编辑器。 - GUI、DirectProject 和 Agent Runtime 的原生操作统一由长寿命 Runner 持有。项目切换使连接失效,迟到回执不能改变新项目状态。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 69559ba2a..68388ccc4 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -1,6 +1,6 @@ # AGC 客户端更新检查与下载 -更新时间:`2026-09-20` +更新时间:`2026-09-21` 本文件是 AGC 客户端自动更新的主规范:更新能力由 Tauri 官方插件 `tauri-plugin-updater` 承担,并按下文渠道分发。 @@ -18,6 +18,17 @@ - 验收必须覆盖 dev/release/自定义渠道各自端点和对象地址、独立版本、非法名称、旧 dev 地址延续、release 不写旧迁移桥、网站配置贯通、跨渠道链接拒绝和部分失败。 - 更新链路的信任来源从「清单里的 sha256 + 受信域名」升级为「发布签名 + 受信域名」:清单里的 `signature` 由构建期私钥生成,客户端用内置公钥校验,校验不过就拒绝安装。 +### 渠道与安装身份合同 + +- 渠道同时决定**更新端点**与**安装身份**,两者都由构建期写入产物。默认渠道 `dev` 保持基线身份 `productName = 陶泥儿`、`identifier = world.genarrative.ai-game-creator`;其它渠道(`release` 与自定义渠道)派生 `productName = 陶泥儿 <渠道显示名>`(`release` → `陶泥儿 Release`、`beta-2` → `陶泥儿 Beta-2`)与 `identifier = world.genarrative.ai-game-creator.<渠道>`。渠道显示名按连字符分段首字母大写,不改动渠道本身。 +- 默认渠道身份**不可变更**:既有安装目录、卸载项、快捷方式与已发布客户端的升级链都建立在基线身份上。渠道身份由 `apps/ai-game-creator-shell/scripts/channel-identity.mjs` 单点定义,构建入口、macOS 发布入口与配置门禁共同消费;基线 `tauri.conf.json` 必须逐字等于默认渠道身份。 +- 安装身份决定的持久与可见事实:Windows 安装目录 `%LOCALAPPDATA%\<产品名>`、卸载项与 `HKCU\Software\genarrative\<产品名>`、WebView2 数据目录 `%LOCALAPPDATA%\`、客户端数据目录 `%APPDATA%\`;macOS `.app` 名、bundle id、DMG 卷名与菜单栏应用名。 +- 同机并存:不同渠道的包体可以在同一台设备上同时安装并同时运行,互不覆盖、互不顶掉;同一渠道的新版本仍是原地升级,因为更新端点与安装身份同属一个渠道。 +- 数据不跨渠道共享:本地项目、工程快照、模板、登录态、诊断日志与 Runner/项目锁按渠道身份分目录。切渠道等于换一个客户端,不迁移、不合并本地数据;渠道内的 origin 隔离规则不变。 +- 主窗口与工作区/启动器窗口标题取构建期产品名,让同机并存的渠道客户端在任务栏与 Alt-Tab 中可区分;默认渠道标题仍是「陶泥儿」。 +- 首装包与更新包的对象名包含产品名(如 `陶泥儿 Release_0.1.96_x64-setup.exe`、`陶泥儿 Release_0.1.96_aarch64.dmg`)。清单 `downloads` 地址由发布脚本按本次真实产物派生,禁止写死产品名;首装包选择按 `<版本>_<架构>.dmg` 唯一匹配,不依赖产品名字面量。 +- Windows 提权 ACL 修复助手按目录名识别安装身份:`<基线>` 与 `<基线>.<渠道>` 都在 AGC 自有的 managed 范围内;相似前缀(例如 `world.genarrative.ai-game-creator-backup`)不在范围内,落回 user-selected 范围或直接拒绝。 + ## 非目标 - 不做灰度放量、分批更新、强制更新和自动回滚;渠道只决定「取哪份清单」。 @@ -110,11 +121,21 @@ - 迁移起点:已发布客户端(含当前线上版本)内置自研清单地址 `agc/latest.json`(sha256 格式),下载与安装由自研 Rust 命令完成。 - 迁移策略见「未决问题与决策」。迁移完成后,自研清单解析、下载命令、下载进度事件以及为此放行的 CSP / HTTP 白名单条目按「四不写」整条删除,不留兼容分支与墓碑说明。 +- 渠道安装身份映射(`` 为 `dev`、`release` 或自定义名称;`` 为渠道显示名): + +| 渠道 | productName | identifier | Windows 安装目录 | 客户端数据目录 | +| -------------------- | ------------------ | ---------------------------------------------- | ----------------------------------- | ----------------------------------------------- | +| `dev`(默认) | `陶泥儿` | `world.genarrative.ai-game-creator` | `%LOCALAPPDATA%\陶泥儿` | `%APPDATA%\world.genarrative.ai-game-creator` | +| `release` / 自定义 | `陶泥儿 ` | `world.genarrative.ai-game-creator.` | `%LOCALAPPDATA%\陶泥儿 ` | `%APPDATA%\world.genarrative.ai-game-creator.` | + +- 安装身份迁移:`dev` 客户端保持原身份,升级链路连续;`release` 与自定义渠道首次以新身份安装,**不接管也不迁移**任何既有 `dev` 安装、本地项目或登录态,设备上因此可以同时存在两个渠道的客户端,由用户自行决定是否卸载其一。 + ## 构建与发布 - 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 - 发布入口只解析一次目标,优先级为 CLI `--target value` / `--target=value` / `-t value`、`AGC_BUILD_TARGET`、Windows 默认值;重复/空目标与不支持目标失败关闭。版本高水位、构建 feature/渠道端点、bundle 路径、产物后缀、清单平台键及摘要必须消费同一个发布上下文,不能分别回读默认目标。 - 渠道由 `AGC_UPDATE_CHANNEL` 显式指定,默认 dev;Windows 与 macOS 目标均支持 dev、release 和自定义渠道,目标校验独立进行。 +- 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName`、`identifier` 与 updater 端点:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。 - 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。 - 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。 - 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。 @@ -157,6 +178,25 @@ | 真实更新闭环(含升级后重启) | 0.1.47 客户端按提示下载安装并重启 | 通过(2026-09-17 用户实测:提示 → 下载 → 安装 → 关于页显示新版本,再次检查为已是最新) | | 更新摘要端到端展示 | 公网读取渠道清单 `notes` 与客户端更新提示 | 通过(2026-09-17 用户实测:0.1.62 清单带 8 条自动摘要,客户端提示正常显示多行内容) | +渠道安装身份隔离已于 `2026-09-21` 完成源码验收: + +| 条款 | 验收方式 | 结果 | +| --- | --- | --- | +| 渠道身份派生与默认渠道不变 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过;`dev` 逐字等于基线 `陶泥儿` / `world.genarrative.ai-game-creator`,`release` / `beta-2` 派生独立产品名与 identifier,非法渠道失败关闭 | +| 身份与端点同批注入 | 同上的渠道 `--config` 用例 | 通过;`productName` / `identifier` 与 `/-win|mac/latest.json` 来自同一次解析 | +| 渠道产物首装包选择 | 同上的渠道 DMG 夹具用例 | 通过;`陶泥儿 Release_<版本>_aarch64.dmg` 仍按 `<版本>_<架构>.dmg` 唯一匹配 | +| 基线配置等于默认渠道身份 | `node apps/ai-game-creator-shell/scripts/check-config.mjs` | 通过;基线漂移与非默认渠道身份不隔离都会失败关闭 | +| 全量发布脚本回归 | `node --test build-release.test.mjs release-oss.test.mjs prepare-macos-codex.test.mjs cargo-features.test.mjs` | 通过(64/64,含 macOS 入口按渠道解析产品名的守卫) | +| AGC 自有 AppData 提权 ACL 范围 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- config::private_path_elevation_policy_tests` | 通过(12/12;含基线、`<基线>.release`、`<基线>.beta-2` 与相似前缀 `-backup` 的反向断言) | +| 渠道身份进入真实构建产物 | `AGC_UPDATE_CHANNEL=release npm --prefix apps/ai-game-creator-shell run build -- --no-bundle --debug` | 通过;Tauri 接受派生的 `productName` / `identifier` 并完成构建;产物字符串实测 `陶泥儿 Release` × 1、`agc/release-win/latest.json` × 1、`world.genarrative.ai-game-creator.release` × 1、`agc/dev-win/latest.json` × 0 | + +未验证项(不得按已通过处理): + +- 真实 Windows 双渠道安装与并存:尚未在同一台设备安装 `dev` 与 `release` 两个渠道的安装包,安装目录/卸载项/数据目录的分离与两个客户端同时运行属于发布验收,本次只到源码与脚本层级。 +- 未生成安装包:本轮构建烟测止于 `--no-bundle`,NSIS 安装目录 / 卸载项 / 快捷方式按渠道分开、以及「装完 release 后 dev 仍在」的现场证据需要一次真实渠道打包与安装。 +- macOS 侧只验证到入口派生逻辑:`.app` 名、bundle id 与 DMG 卷名的渠道派生没有在 macOS 节点实跑。 +- 客户端数据隔离的运行期事实(`%APPDATA%\` 分目录、登录态不跨渠道)未做真机对照。 + 待执行证据(首次渠道发布后回填): | 条款 | 验收方式 | 证据 | @@ -174,6 +214,7 @@ - macOS 发布方式:已接入专用 macOS Jenkins 节点(label `genarrative-agc-macos`,EXCLUSIVE 单 executor),由 `Jenkinsfile.ai-game-creator-shell-macos-build` 执行 `scripts/build-macos-ci.mjs` 完成 arm64 单架构构建、arm64 隔离 smoke、arm64 DMG(`<产品名>_<版本>_aarch64.dmg`)、分区清单生成、更新包验签与 OSS 上传。`AGC_RELEASE_DRY_RUN` 默认为关(与 Windows 渠道对称,即直接发布),只有勾选后才退化为「只打印上传计划、不写 OSS」的演练。 - macOS 代码签名与公证暂缺:产物为未签名 + 未公证,构建入口剥离 `APPLE_*` 凭据跳过 Apple 签名,不传 `--no-sign`(它还会跳过 updater 的 minisign 签名,产物将没有 `.sig`);构建清单实测记录 `appleSigned` 与签名类型,`latest.json` 侧固定记录 `notarized=false`,首装需用户在 Gatekeeper 中手动放行。该限制作为已知未验证项记录,不静默通过;「安装 → 重启接管新版本」的自动更新闭环仍需实机验收。 - 更新包验签门禁:构建完成、上传 OSS 之前,用产物内烘焙的 `plugins.updater.pubkey` 复核 `<更新包>.sig`(Tauri 使用 minisign 的 `ED` 预哈希模式)。keyId 不一致或校验失败立即失败关闭,禁止上传——客户端校验失败会直接拒绝安装,且公钥发布后不可更换。 +- 渠道安装身份(2026-09-21):渠道此前只决定更新端点,`productName` / `identifier` 与渠道无关,导致不同渠道的包体共用 `%LOCALAPPDATA%\陶泥儿`、同一个卸载项与同一份 `%APPDATA%\world.genarrative.ai-game-creator` 数据目录,后装的渠道静默顶掉先装的渠道并接管更新端点与本地登录态。现决策为「渠道进安装身份」:默认渠道保持基线身份不动,其它渠道派生 `<产品名> <渠道显示名>` 与 `<基线>.<渠道>`,渠道内仍原地升级,同机并存与数据隔离成立。窗口标题、macOS 产物名与 Windows 提权 ACL 的 managed 识别范围同批跟随该身份。 待办: diff --git a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md index 684cd4df2..90aa75885 100644 --- a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md +++ b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md @@ -22,7 +22,7 @@ AGC 客户端接入公共 OSS 上的**游戏模板库**(真·游戏模板, ## 后台模板管理 - 后台新增 `#agc-templates`「模板管理」页签,复用现有后台布局、列表、公共表单/独立弹窗及写入确认。提供名称/ID/标签搜索、运行时和上下架筛选,展示封面、名称、简介、标签、引擎/版本、包大小与上架状态。 -- 本轮只允许编辑名称、简介、标签、封面和上架状态;不新增模板、不上传 ZIP、不修改 ID、运行时、引擎或模板版本,不删除包、历史对象或已建项目。 +- 编辑接口只允许改名称、简介、标签、封面和上架状态,不修改 ID、运行时、引擎或模板版本;**模板新增与 ZIP 上传见「后台模板上传」章节**。两条路径都不删除包、历史对象或已建项目。 - 唯一数据真相仍是 OSS `templates/index.json`:`templates` 保存上架条目,新增可选 `inactiveTemplates` 保存下架条目,两个数组之间 ID 唯一。后台合并展示两组;AGC 客户端仍只读取 `templates`,刷新后不展示下架项。下架不是资源访问撤销,旧清单缓存和已下载项目不受影响。下架条目元数据位于公开清单,不承载私密草稿。 - 后台读取/写入分别为 `GET /admin/api/agc-templates`、`PUT /admin/api/agc-templates/{id}`,均经过现有后台认证及 `agc-templates` 页签权限。owner 默认可用,member 需显式分配该权限;不新增数据库表或 schema。部署时后台、API 与引用权限白名单的 SpacetimeDB 模块需同步更新,成员账号才可保存新页签权限。 - GET 返回 `{ revision, writable, templates }`:revision 是完整原始清单字节的 SHA-256;每条包含 `id/title/summary/tags/runtime/engine/engineVersion/templateVersion/enabled/coverUrl/zipSizeBytes`。不可用或格式错误返回可诊断错误,不能当作空模板库。 @@ -33,6 +33,18 @@ AGC 客户端接入公共 OSS 上的**游戏模板库**(真·游戏模板, - CLI 与后台共享锁和清单合同。CLI 合并保留未选条目与 `inactiveTemplates`,更新下架模板仍保持下架;新增模板默认上架,CLI 不承担删除或上下架。显式发布选中 ID 时,展示字段按该模板源更新,后台编辑结果持续有效直到下一次显式发布该 ID。两端都必须保留另一组条目,不能因本地模板源较旧而抹掉后台记录。 - 验收包含权限、入口挂载、过滤、编辑与图片校验、上下架往返、未知字段保留、过期 revision/锁争用、上传失败与未知提交、CLI 对下架项的更新/保留,以及桌面/窄屏真实浏览器验证。真实 OSS 写入和生产部署不在本轮验证范围,使用隔离存储替身。 +## 后台模板上传 + +- 后台「模板管理」页提供「上传模板」入口:一次可多选 `.zip`,再按模板 ID 同名多选封面,逐行确认 ID / 名称 / 版本 / 运行时 / entry 后整批提交。简介、标签与引擎可在上传后用编辑接口补齐。 +- 接口 `POST /admin/api/agc-templates/import`(`multipart/form-data`):`manifest` 文本字段 + `zip_` / `cover_` 文件字段,下标与 manifest 条目顺序一一对应。manifest 为 `{ expectedRevision, templates: [{ id, title, summary, tags, runtime, engine, engineVersion, entry, templateVersion, zipField, coverField }] }`,禁止未知字段。 +- 限制:单批最多 20 个模板;单个 ZIP ≤ 64 MiB;单张封面 ≤ 5 MiB;请求体 ≤ 200 MiB;`runtime` 仅接受 `html / unity / godot / cocos`,`id` / `templateVersion` / `entry` 走既有标识符与相对路径白名单。存储层为 `application/zip` 单独放宽单对象上限到 64 MiB,图片与元数据仍是 5 MiB。 +- 语义:一批**全有或全无**。任一模板的 manifest 字段、归档或封面不合法,都在任何写入之前整批拒绝,并逐项给出模板 ID 与原因。 +- 归档校验不落盘:合法 zip、无符号链接、无绝对路径 / `..` / 盘符 / 反斜杠条目、必须包含 manifest 声明的 `entry`、条目数 ≤ 4096 且解压后总大小 ≤ 512 MiB;并拒绝含 `.agent` / `.git` / `.svn` / `node_modules` 段(任意层级)与根目录 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode` 的包(与 CLI 打包同一门禁,见 [`【模板规范】AGC 模板包组织指南`](../【模板规范】AGC模板包组织指南-2026-09-21.md))。 +- 封面每个模板必填,按字节嗅探格式(只接受真实 PNG / JPEG / WebP),不信任 multipart 声明的 content-type;上限与编辑路径相同(5 MiB、单边 4096、1600 万像素)。SVG 仍只可能来自 CLI 历史发布。 +- 发布复用既有协议:ZIP 按上传字节原样发布(不重新打包、不删除历史对象),对象键为 `templates/v1//sha256/<摘要>/{template.zip,cover.*,template.json}`;在发布锁内先 CAS 校验 `expectedRevision`(过期返回 409),写入后逐个回读校验,最后提交一次清单;清单写入结果不明时保留锁并返回 503。断连由独立任务持有,不会在清单 PUT 在途时提前解锁。 +- 版本与保留语义:新 ID 默认上架;已存在 ID 就地更新并保留 `enabled` 分组、其它条目与未知扩展字段(含下架条目);同一 ID 同一 `templateVersion` 的 ZIP 字节不同时拒绝并要求递增版本(与 CLI 同一句文案),字节完全一致时按内容复用,响应里以 `reusedObjects` 标出。 +- 后台页面沿用既有写入确认、防重复提交与刷新语义:409 提示刷新后重试;上传失败只在弹窗内交代,401 仍交由会话处理。 + ## OSS 契约 ```text @@ -66,15 +78,19 @@ templates/ - 所有对象键必须落在 `templates/` 前缀内;客户端只用「受信任 OSS 主机 + 对象键」自行拼 URL,**不直接信任清单里的地址**。 - 任何一项校验失败(schema、标识符、sha256、尺寸、键前缀)都让整次清单读取失败,前端拿到的是全有或全无的清单。 - 模板源在仓库 `apps/ai-game-creator-shell/template-library/`:`v1//{meta.json, project/**, cover.(png|jpg|webp|svg)}`,`template.zip` **不落仓库**,由脚本按 `project/` 现场打包(条目排序、固定时间戳,同内容重复打包摘要一致)。 -- 上传与校验由 [`scripts/agc-template-library-publish.mjs`](../../scripts/agc-template-library-publish.mjs) 完成:`--source apps/ai-game-creator-shell/template-library [--dry-run] [--only ]`。ZIP、封面和元数据分别以自身字节的 SHA-256 定位,只创建新对象或复用逐字节校验一致的已有对象;全部对象回读一致后才更新 `index.json`。失败不回收已上传对象,旧清单及其引用始终可读。 +- 模板包内容怎么组织(根目录结构、Cocos 工程保留项、不要放的东西、封面与体积上限、发布前自检)见 [`docs/【模板规范】AGC模板包组织指南-2026-09-21.md`](../【模板规范】AGC模板包组织指南-2026-09-21.md)。 +- 上传与校验由 [`scripts/agc-template-library-publish.mjs`](../../scripts/agc-template-library-publish.mjs) 完成:`--source apps/ai-game-creator-shell/template-library [--dry-run] [--only ]`。ZIP、封面和元数据分别以自身字节的 SHA-256 定位,只创建新对象或复用逐字节校验一致的已有对象;全部对象回读一致后才更新 `index.json`。失败不回收已上传对象,旧清单及其引用始终可读。打包阶段执行与后台上传相同的正文门禁(`readProjectFiles`):正文含 `.agent` / `.git` / `.svn` / `node_modules` 段(任意层级)或根目录 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode` 的模板直接报错,不产生任何上传对象。 - 只更新指定模板时使用 `--only `,在发布锁内读取最新清单,只替换指定 ID,其余条目和未知扩展字段保留。首次清单 404 可由本次选择初始化;读取异常或清单非法时停止。全量发布也遵守相同锁与版本门禁。 +- `templates/README.md` 与上述正文不同:它不是内容寻址对象,而是**覆盖写的说明文档**,源在仓库 `apps/ai-game-creator-shell/template-library/README.md`(上限 64 KiB),由同一次发布在清单之前写入并回读校验。客户端从不读它,改契约只改仓库源即可,不要再手工维护线上副本。 - 正式发布先通过 `GetBucketVersioning` 确认 Bucket 从未开启版本控制,再用 `x-oss-forbid-overwrite: true` 原子创建 `.publish-lock.json`;版本控制 Enabled、Suspended、检查无权限或无法判定时均在写入前停止。所有写同一清单的发布进程必须使用此锁,发布期间不得改变 Bucket 版本控制配置。锁没有自动过期或抢占机制,已被占用时直接失败,重新执行须重新获取锁并读取最新清单。 - 只释放本任务已明确获取且 owner 标识仍一致的锁。获取结果不明时不猜测删除。正文对象不可变,其写入失败可安全释放本任务的锁;清单 PUT 已发起后若遇到断连、超时或服务端 5xx 等不确定结果,必须保留锁并报错,防止旧在途请求晚于下一发布者写入。清单收到确定成功或确定拒绝响应后才进入正常解锁路径;不自动重发清单写入,也不凭一次 GET 猜测在途 PUT 已结束。遗留锁须在确认原请求及进程已终止或完成后由运维处理;释放失败必须报告,不伪装为发布成功。 - `--dry-run` 仅构造和读取合并计划,不读取凭据、不获取锁、不 PUT/DELETE。发布不删除历史对象,也不提供随发布清理的选项;旧客户端缓存和未完成下载可能仍引用旧键。 - 同一 ID、同一 `templateVersion` 的 ZIP 大小或摘要改变时,在上传正文前拒绝,要求更新模板版本;只把相同 ZIP 迁移到新键可保留版本。客户端现有 `/` 缓存行为不变。 -- 当前模板:`blank-web`(空白网页)、`blank-2d-canvas`(空白二维画布)、`blank-3d-scene`(空白三维场景)、`phaser-2d-starter`(Phaser 2D 起步工程)、`threejs-3d-starter`(Three.js 3D 起步工程),以及 Cocos Creator 3.8.8 的 `cocos-empty-2d`、`cocos-empty-3d`、`cocos-empty-3d-hq`、`cocos-hello-world`。 +- 当前模板:`blank-web`(空白网页)、`blank-2d-canvas`(空白二维画布)、`blank-3d-scene`(空白三维场景)、`phaser-2d-starter`(Phaser 2D 起步工程)、`threejs-3d-starter`(Three.js 3D 起步工程)、Cocos Creator 3.8.8 的 `cocos-empty-2d`、`cocos-empty-3d`、`cocos-empty-3d-hq`、`cocos-hello-world`,以及 Godot 4.7 的 `godot-empty-2d`、`godot-empty-3d`、`godot-hello-world`、`godot-platformer-2d`。 - Cocos 内容来自 Creator 3.8.8 随附的 `resources/templates/{empty-2d,empty,empty-quality,hello-3d-world}`,保留官方资源、`.meta`、设置和模板预设;补齐 `package.json.creator.version`,空模板以 `assets/.gitkeep` 保证资源目录进入 Git 和 ZIP。`entry` 为 `package.json`,不打包编辑器生成的缓存或用户项目数据。 - Cocos 建项在复制后按实际 `package.json.creator.version + assets/` 识别,复用既有 Cocos 导入流程,写入 `cocosProjectRoot: "."`;每个新项目重建 `package.json.uuid` 并写入所选项目名。只创建 `.agent` 管理目录,不生成 Web 占位入口;模板源与本机安装缓存不被改写。 +- Godot 内容为仓库内手写的 Godot 4.7 工程(`project.godot` + `scenes/` + `scripts/`,GL Compatibility 渲染,只用内置 `ui_*` 输入动作,不依赖外部贴图),`entry` 为 `project.godot`,不打包 `.godot/` 编辑器缓存与导出产物。 +- Godot 建项在复制后按 `project.godot` 识别,复用既有 Godot 导入流程,写入 `godotProjectRoot: "."`,并把工程显示名改写成用户选择的项目名(只改 `[application]` 段的 `config/name` 一行,其余字节逐字保留);不生成 `game/` 占位入口与 `assets/`、`memory/`、`exports/` 并行目录,也不改写模板源与本机安装缓存。 - 客户端可用 `AGC_TEMPLATE_LIBRARY_BASE_URL` 覆盖库地址;只接受 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com`(拒绝其他主机、路径、http)。 ## 客户端实现 @@ -83,9 +99,9 @@ templates/ | 命令 | 行为 | | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `fetch_game_template_library` | 读 `templates/index.json`(≤4 MiB),校验后缓存到 `/templates/index.json`;网络失败时回退本机缓存并在 `source` 标 `cache` | +| `fetch_game_template_library` | 读 `templates/index.json`(≤4 MiB),校验后缓存到 `/templates/index.json`;网络失败时回退本机缓存并在 `source` 标 `cache`。远端已经答话但正文不是合法 UTF-8 或不符合 schema、以及缓存自己损坏时,一律失败关闭,不用缓存掩盖远端错误 | | `download_game_template` | 取清单里对应条目,流式下载 zip(≤512 MiB),校验字节数与 SHA-256,解压到 `/templates/installed///`,最后写 `installed.json` 作为安装完成的唯一标记 | -| `create_automatic_local_game_project_from_template` | 需要时先安装模板,然后在自动工作区根目录下按既有自动工作区规则建目录:先复制模板文件;Cocos 项目更新自身身份后走既有 Cocos 导入,其余沿现有 `init_local_game_project_at` 初始化。根目录默认是 `/projects/`,用户可选 `projectsRoot` 覆盖(必须来自本机目录选择器并通过私有路径门禁),见 [`【实施计划】AGC项目创建目录可选-2026-09-17.md`](../project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md) | +| `create_automatic_local_game_project_from_template` | 需要时先安装模板,然后在自动工作区根目录下按既有自动工作区规则建目录:先复制模板文件;Cocos 项目更新自身身份后走既有 Cocos 导入,Godot 项目改写工程显示名后走既有 Godot 导入,其余沿现有 `init_local_game_project_at` 初始化。根目录默认是 `/projects/`,用户可选 `projectsRoot` 覆盖(必须来自本机目录选择器并通过私有路径门禁),见 [`【实施计划】AGC项目创建目录可选-2026-09-17.md`](../project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md) | 安全与健壮性: @@ -93,6 +109,7 @@ templates/ - 安装目录名由标识符白名单拼出,不拼接远端字符串;重装时只清理该模板自己的安装目录。 - 模板文件与安装记录统一走 `write_game_creator_private_file` / `ensure_game_creator_private_directory_tree`,保持项目目录的私有 DACL 口径。 - 建项目失败时删除刚创建的项目目录,不留半成品。 +- 大小 / 摘要校验在落盘之前完成,被拒绝的模板包不产生任何安装目录;`installed.json` 是「已下载」的唯一判据,目录残留(例如解压中途失败)不构成已安装,下次安装会先清理该模板自己的安装目录。 ### 前端 @@ -114,6 +131,14 @@ Cocos 回归分别覆盖仓库模板和线上真实 ZIP 的安装、连续建项 `2026-09-19` 发布一致性验收:Node 发布回归 22 项通过,覆盖两个发布者竞争、正文/清单写入失败、迟到清单 PUT、锁归属、版本控制拒绝、V1 签名及真实 CLI 的无写入 dry-run。Rust 定向回归 15 项通过,新增内容地址的清单解析与 URL 保留校验;3 项线上用例本轮未重复执行,此前同日线上下载及原生建项已通过。只读 dry-run 保留线上九个模板并仅计划更新四个 Cocos 条目。格式、编码、文档索引、定向 ESLint 与 diff 检查通过。全部并发/故障写入证据来自离线替身,未执行真实 OSS 锁写入或发布,也未验证 Creator 内场景运行。 +`2026-09-21` 客户端接入复核:Rust 定向 24 项通过(新增清单来源判定 5 项:合法远端正文优先并标 `network`、远端失败回退缓存并标 `cache`、无缓存时暴露远端错误、远端正文非法时不用缓存掩盖、缓存损坏时失败关闭;新增建项目失败清理 1 项;并在大小/摘要不一致、越界归档两条用例上补「拒绝后不留安装目录、不产生已下载判据」断言)。3 项线上用例(读线上清单、下载安装线上模板、下载并原生建项 Cocos 模板)本轮全部真实执行通过;匿名 `GET https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/index.json` 返回 200、`schemaVersion=agc-template-library.v1`、9 个模板。前端 38 项通过(模型 9、网格 6、页面 12、控制器 11),`appSurface` 首页模板用例 2 项通过(灰度外隐藏入口与推荐位、点击进入模板库不建项),AGC `typecheck` 通过。仍未验证:Creator 内场景运行,以及真机 iOS / Android 观感。 + +`2026-09-21` 模板库重发布:把线上库从 09-17 的旧产物对齐到仓库当前源。线上 9 个模板里 7 个(`blank-3d-scene`、`cocos-empty-2d/3d/3d-hq/hello-world`、`phaser-2d-starter`、`threejs-3d-starter`)的 ZIP 与仓库不一致(旧产物来自 PR 定稿前的源,例如模板内嵌 `package-lock.json` 已由 `5e4ff54a9` 删除、`game.js` / `main.js` 后来改过),其中 2 个原本一致(`blank-2d-canvas`、`blank-web`)。按「同版本 ZIP 不得变」门禁,为这 7 个模板递增 `templateVersion` 到 `0.1.1` 后重发:28 个内容寻址对象逐个回读校验通过,`index.json` 与 `templates/README.md` 在锁内提交;线上清单现在全部指向 `v1//sha256/<摘要>/` 键,`libraryVersion=1`、9 个模板、无下架条目。发布脚本本次同时补齐说明文档受管写入(源文件即 `apps/ai-game-creator-shell/template-library/README.md`)并修掉「守卫拒绝后直接 `process.exit(1)` 触发 libuv 断言崩溃、看不到原因」的问题;线上 README 与仓库源已逐字节一致。 + +`2026-09-21` 模板正文目录门禁:CLI 打包(`readProjectFiles`)与后台上传(`validate_import_archive`)统一拒绝正文含 `.agent` / `.git` / `.svn` / `node_modules` 段(任意层级)与根目录 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode` 的模板,两处用同一份段名单与同一句文案。同名目录段只在根目录受限:正文内 `game/dist/**` 与 `.gitignore` 仍合法(`.gitignore` 不等于 `.git`),Cocos 模板的 `.creator` / `.gitignore` 不受影响。证据:Node 发布回归 28 项通过(新增 1 项,13 条拒绝用例 + 2 条放行断言);Rust `admin_templates` 8 项回归通过(新增 `template_import_archive_rejects_identity_and_build_directories`);仓库现有 9 个模板源无一条命中门禁,本地重打包的 9 个 ZIP 摘要与线上 `index.json` 的 `zipSha256` 逐条一致(匿名读清单 200、9 个模板、0 个下架条目),即门禁未改变任何已发布字节。未执行真实 OSS 写入。 + +`2026-09-21` Godot 模板入库:新增 `godot-empty-2d` / `godot-empty-3d` / `godot-hello-world` / `godot-platformer-2d` 四个仓库内手写模板,并给建项补 Godot 分流。模板内容用本机 Godot `4.7.2.stable` 逐项验证:四个模板的主场景都能实例化并跑满 3 帧、全部 GDScript 过 `--check-only`(同一条命令对故意写错的脚本报告 Parse Error,证明检查有效);平台跳跃模板另做真机物理试玩——角色落地 y=627.99(地面顶 656 − 半高 28)、按住右 1 秒位移 320px、连按跳跃后落在平台上 y=515.93(平台顶 544 − 28)、三枚金币全部收集后 HUD 变为「已收集 3 / 3 —— 全部完成!Esc 重来」。Rust 侧 4 项定向回归通过:`template_library::tests::godot_template_creates_native_project_with_relative_root_and_display_name`(Godot 模板建项得到 `godotProjectRoot: "."`、无 `game/` 占位入口、`config/name` 改写成所选名称且其余行逐字保留)、`import_tests::rewrites_only_the_godot_display_name_line`、`import_tests::keeps_a_godot_project_without_a_display_name_line_untouched`,以及既有 Cocos/Web 建项回归未受影响。发布保持 `--only` 定向:只新增四个 Godot 模板对象并重写清单,其余 9 个模板的键与版本不变;未在客户端「模板库」里实际建一次项目。 + ## 本地压测假数据注入(feature 控制) 模板库的数据源在 Rust 侧(清单校验、安装状态、下载与建项目都在这里),TS 只消费快照做渲染,所以假数据注入也放在 Rust 侧,走与真实完全一致的链路。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 39a9f1760..b07266394 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -254,6 +254,7 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir - - `cwd` 必须是项目内规范相对目录,拒绝符号链接、绝对路径、`..`、Windows 盘符 / UNC / ADS 和整个 `.agent` 控制面;超时固定在 1-300 秒,stdin 关闭,stdout / stderr 采用有界头尾保留并先做凭据清洗。 - 默认权限为 `confirm`。确认摘要包含程序、argv 摘要、cwd 和超时;精确动作继续绑定 actionId、repository fingerprint、project revision 和 execution owner。Runner 在 `executing` 阶段退出时保持 `needs-reconciliation`,不得自动重放命令。 - 子进程继承环境清空;可执行文件必须从项目外安全绝对目录解析为绝对路径,子进程 PATH 只保留这些已规范化目录,并注入隔离 HOME / TMP / cache、离线包管理配置和不可达代理。超时或读流失败时 Runtime 请求终止受控进程组并检查终止调用结果,但这不等同于完整 detached-process / 容器隔离。首版安全等级与现有 `project.verify` 相同:固定程序和参数策略加用户确认,不宣称已经具备 Codex CLI 的完整 OS sandbox;在完成平台沙箱前不得把 `command.exec` 默认改为 `auto`。 +- Linux 一次性命令确认 target 终态并回收主进程后,按 `/proc//stat` 核对同 PGID 成员;空组或只剩 `Z / X` 成员无需再发送信号,不因容器 PID 1 未回收孤儿僵尸而误报 reconciliation。存在存活成员时仍必须核对原 leader 启动身份,身份缺失或不匹配时拒绝发送信号;读取或解析进程状态失败同样进入 reconciliation。此判断不扩展为 detached-process 隔离证明,也不改变诊断命令与验证 gate 的区分。 - Cargo / npm 缓存固定写入项目私有 `.agent/runtime/command-env/cache`,不复用或改写用户宿主缓存,也不允许联网补依赖。依赖未进入项目 vendor、现有 `node_modules` 或隔离缓存时,命令应以真实失败输出回到 Agent;首版不为“跑通命令”复制宿主的 Cargo registry、凭据或用户级配置。 - `command.exec` 的执行前后源码指纹各自最多遍历 20,000 个目录项、10,000 个受保护文件和 512 MiB 正文;执行前超预算直接拒绝启动,执行后无法完成指纹则进入 `needs-reconciliation`,不得把截断扫描当成完整验证凭证。 - 命令结束后重建安全项目文件指纹。若命令改写了受保护项目文件,则保持 verification gate 未通过并要求 Agent 重新检查;每次真正启动命令前已经保守推进一次 revision。可签发验证凭证的命令仅限 `cargo check/test/clippy/fmt/build`、`npm test`、命名为 `check/typecheck/test/lint/build/verify/validate` 的 npm 验证脚本及精确 `node --test`;`git`、`rg`、`cargo metadata` 和普通 `npm run` 即使退出码为 0 也只作为诊断结果。只有验证型命令退出码为 0、未超时、未改写受保护文件,且命令日志、manifest 投影和 Agent DB 审计全部成功后,才允许绑定当前 revision 的 passed gate;任一审计失败必须先保持 failed gate,再进入 `needs-reconciliation`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 0790e9eba..5610729d3 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,16 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-09-21 Godot 工作区发现放宽与内置插件行去掉手动启动 + +本节覆盖下文“打开项目自动识别 Godot”中的旧口径:判定从「唯一命中」放宽为「确定性命中」,`project.godot` 从「必须是普通文件」放宽为「按链接目标判定」。 + +| 要求 | 必须成立的行为 | 完成证据 | +| --- | --- | --- | +| 确定性多命中 | 根目录未命中时,一层直接子目录中多个 `project.godot` 命中按目录名排序取第一个;不再报「多个 Godot 项目」,也不再要求用户改选具体工程 | `cargo test --locked -p genarrative-ai-game-creator-shell --bin genarrative-ai-game-creator-shell -- project::manifest::import_tests::` 与 `-- tests::project::` | +| 链接标记 | `project.godot` 是符号链接 / Windows reparse point / 硬链接时按链接目标判定,目标解析为普通文件即命中;目录与悬空链接仍不算命中 | `import_tests::accepts_symbolic_link_project_marker`(unix)、`import_tests::accepts_windows_hard_link_project_marker`、`import_tests::accepts_windows_reparse_project_marker` | +| 保持不变的边界 | 工作区根本身是链接、候选子目录是链接、二层及更深目录不递归,这三条既有边界不动 | `import_tests::ignores_symbolic_link_child_candidate_without_writing_agent_metadata`、`import_tests::ignores_windows_reparse_child_candidate_without_writing_agent_metadata`、`import_tests::ignores_godot_projects_below_the_first_child_level` | +| 内置插件行 | 设置→扩展 的内置插件行不再渲染手动「启动 / 停止」按钮;启动由项目切换时的前端自动启动承担,停止走该行启用开关(禁用即停止并断开编辑器连接);导入扩展行的启动按钮保留 | `apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx`、`tests/pluginHost.test.ts` | + ## 2026-09-20 DirectProject 七项效率闭环(补齐合同) 本节补齐并覆盖下节中仅靠 Skill 要求预检、收尾、批读和原生命令预算的部分。完整目标仍为:自动预检、宿主验收与收尾、分层验证、统一执行/返修预算、稳定测试基线、请求耗时与批量读取、所有工具并行。已有代码及测试不等于全部目标已完成;按下表逐项验收。 @@ -48,6 +59,8 @@ ### 宿主验收与执行许可合同 - 正式 GUI 和 CLI 的共同 Direct 回合入口建立宿主控制状态,绑定 canonical 项目路径、稳定 clientTurnId 和原始用户输入摘要;宿主私有目录保存权威账本并独占该回合,项目 `.agent` 仅允许保存展示副本。配置或项目侧文件被改写、工具切换、Provider 重试和进程重启不得刷新同一回合的预算。 +- Direct 回合集成测试也按生产入口计算原始用户输入的 SHA-256 十六进制摘要(64 字符),不能用请求名称替代。用户回显过滤回归继续覆盖实时消息去重、回合起止身份关联及历史落盘过滤。 +- 直接启动 Direct 工具桥的图片生成通知测试,须复用真实宿主执行会话与已登记交付合同夹具,再发起工具请求;继续验证资源提交后发出 manifest 失效通知,以及空提示词被参数校验拒绝且不发通知,不绕过执行许可门禁。 - 普通聊天与读取不要求交付合同。首次修改、代码执行或付费扩项之前,模型通过结构化工具登记本轮必需范围与验收项;合同非空、有界且只冻结一次。模型只能声明要求,不能提交“通过”作为证据。后续扩项留到新的用户回合。 - 明确新 Web 创建由宿主可信脚手架凭证及尚未交付的宿主记录判定,CLI 同样据此判定,不从提示文本猜测;这种回合即使模型没有调用工具或没有登记合同,也不得按普通聊天宣布交付。已有项目只有未激活合同且从未产生副作用时才允许直接聊天结束。 - 验收项为明确类型的产物、构建/测试命令、双端视觉或指定固定场景的双端玩法。可信新 Web 游戏由宿主补充构建、双端视觉和玩法底线,不能由模型声明“已有项目”降低。已有项目按冻结的变更范围选择层级;平台美术只在用户目标要求时成为必需项。 @@ -199,6 +212,7 @@ Rust 侧在 `server-rs/crates/shared-contracts` 维护唯一权威 `GameCreation ## 资源画布生成、展示与布局合同 +- 客户端「生成图标素材」只将用户描述去除首尾空白后,保留内部换行并作为唯一 `iconDescriptions` 元素提交;不添加小游戏首版原型、核心美术或默认美术 brief,不截断、不按段落或字数拆条。面板与原生入口均要求描述非空且最多 `200` 个 Unicode 字符(按码点计数),超限在提交前明确拒绝;其余图片生成提示词维持 `32000` 字符上限。规范图、纯色背景、素材排布等生图约束由网站与客户端共用的 API Server 流程统一添加。完整请求合同见 [画板图标素材生成入口设计](../【编辑器】画板图标素材生成入口设计-2026-06-15.md)。 - 生成工具点击后先在当前栏目创建临时占位卡,并以卡片为锚点展示独立生成浮层;占位不登记为正式素材、不进入 Agent 可引用资源集。上传仍沿用文件选择,不创建虚假生成任务。关闭编辑浮层不应丢失正在执行的任务;切换项目不得将旧项目结果或草稿写入新项目。 - 图片生成支持从现有素材选择器添加真实参考;引用携带稳定资源身份并经既有原生权限、归属与类型校验传至生成链路,不能仅拼接名称。缺少规范图不阻止打开面板;确有规范前置的操作必须在提交前满足要求,不能绕过后端校验。用户可通过现有工具栏先生成规范图。 - 占位可移动。提交复用正式生成任务、幂等与结果登记链路;成功结果使用占位最新位置,失败保留输入与引用供重试。已受理但响应不确定时先对账,不能无条件再次发起付费生成。关闭、删除占位和后台任务的行为需保持既有任务所有权,不把隐藏展示当作取消任务。 @@ -571,7 +585,8 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 模式合同:客户端 AppData 配置新增全局 `agentMode`,只接受 `codex_cli / provider`。缺省和新安装默认使用 `codex_cli`,原有 HTTP LLM Provider 路径完整保留并可显式切回 `provider`;切换只影响下一次节点请求,不新增 Runner、任务图、会话库、配置库或业务事实源。 - 调度边界:正式 DAG、manifest、Agent task/session/run 身份、队列、锁、委派、all-join、完成门、Provider lifecycle、持久 retry/handoff 与 `needs-reconciliation` 继续由现有 AGC Runtime 掌控。每个被调度节点在 `codex_cli` 模式下直接启动一次非交互 `codex exec` 充当该节点的推理 Agent;Codex 返回当前 Runtime 广告函数的结构化调用,Runtime 仍是唯一 ToolHost,不允许 CLI 自己写项目、执行命令、调用 MCP 或形成第二套 revision / verification 真相。 - 安装包侧车:Windows x64 release 固定随 Tauri resource 打包 `@openai/codex@0.155.1` 的原生 `codex.exe`;Rust build script 从 AGC 子包锁定依赖 stage 到 resource,并写入版本与 SHA-256 清单。固定版本只在 `build_support/codex_bundle.rs` 声明一次,构建脚本、宿主补丁执行器身份、逐次审批协议允许列表和模型目录捕获共同引用它,避免多处字面量漂移。Windows 侧车映射只写入 `tauri.windows.conf.json`,通用 `tauri.conf.json` 不得让 Linux / macOS 构建依赖未生成的 Windows 二进制。运行时只在文件摘要和 `codex-cli` 版本同时匹配清单时优先选内置侧车;缺失、损坏或版本漂移时跳过它,按既有 npm 安装、PATH 顺序回退。安装包同时携带 Apache-2.0 第三方声明;API Key、`auth.json`、Cookie、Token、用户 `CODEX_HOME`、用户配置和项目数据绝不打包。 -- Windows x64 release 安装包只生成 NSIS,不生成 MSI:`tauri.windows.conf.json` 的 `bundle.targets` 固定为 `["nsis"]`,通用配置继续保留其它平台的默认打包目标。安装后的产品名、开始菜单 / 桌面快捷方式和 EXE 产品描述统一由 `tauri.conf.json` 的 `productName: "陶泥儿"` 生成;应用 identifier 与内部可执行文件名保持稳定。内置 Codex 资源安装到顶层 `coding-agent/win-x64/`,运行时从同一路径查找 `bin/codex.exe` 与 `manifest.json`;仓库 staging 仍使用 `resources/codex/win-x64/`,包内子目录、组件名、版本和完整性校验保持原合同。 +- Windows x64 release 安装包只生成 NSIS,不生成 MSI:`tauri.windows.conf.json` 的 `bundle.targets` 固定为 `["nsis"]`,通用配置继续保留其它平台的默认打包目标。安装后的产品名、开始菜单 / 桌面快捷方式和 EXE 产品描述由 `tauri.conf.json` 的 `productName` 生成:基线是 `陶泥儿`,发布构建按渠道由 `--config` 覆盖为 `陶泥儿 <渠道显示名>`,identifier 同批派生 `<基线>.<渠道>`(默认渠道保持基线值),因此不同渠道的包体可在同一台设备并存;内部可执行文件名保持稳定。内置 Codex 资源安装到顶层 `coding-agent/win-x64/`,运行时从同一路径查找 `bin/codex.exe` 与 `manifest.json`;仓库 staging 仍使用 `resources/codex/win-x64/`,包内子目录、组件名、版本和完整性校验保持原合同。 +- Windows 渠道 AppData 目录归属判断及其调用分支统一保留 Windows 条件编译;通用项目路径策略测试仍可在 Linux 运行,实际 ACL 修复保持原平台门禁与授权范围。 - macOS 安装包必须携带锁定版本的原生 Codex、`codex-code-mode-host`、`rg`、上游 zsh、`codex-package.json` 和第三方声明,保留上游相对布局;构建时按 Cargo 目标选择 npm 原生依赖,缺文件、版本或目标不匹配立即失败,不借用开发机 PATH 里的 Codex。资源只在 `tauri.macos.conf.json` 映射到 `Contents/Resources/coding-agent/mac-native/darwin-arm64/` 与 `darwin-x64/`。构建与运行共享平台文件白名单,运行时由当前 `.app/Contents/MacOS` 定位相邻 `Resources`,完整性与版本验证通过后优先使用内置组件;失败沿既有外部安装回退,不能运行未校验的内置文件。macOS 当前只构建 arm64 单架构,但资源映射仍并列携带两套锁定原生 Codex 依赖(运行切片按 Cargo 目标只选择对应目录),恢复 Intel 时无需改动资源布局;随包 Node 只有宿主架构那一份,所以不得构建 universal 包。不读取全局 Codex。 - 内置插件的清单、运行入口与面板同时在 Windows/macOS 随包分发,继续由既有 PluginHost 的应用资源目录扫描入口发现;不携带开发依赖、缓存、测试或私有配置。插件文件随包不等于原生适配器跨平台:Cocos 进程桥接仍受现有 Windows 实现和 feature 门禁约束,macOS 原生桥接另行设计与验收,不复制 Windows DLL 冒充支持。系统 Node、用户 Cocos Creator、账号登录、网络和生成工程的 npm 工具链仍是现有外部前提,不在此次 Codex 侧车补齐中隐式变更。 - macOS 安装包验收必须包括:脱离仓库位置的 `.app` 资源与架构检查、受限 PATH/隔离 HOME 下内置 Codex 启动和 app-server 握手、必需文件缺失/篡改/平台错误的拒绝测试,以及 DMG 完整性检查。真实登录、Provider 对话、GUI 和 Cocos 操作必须独立列出证据,不能用压缩包生成或 `--version` 成功替代。未配置正式签名、公证的本地测试包不得作为公开发行包。 @@ -1776,7 +1791,7 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 本地索引是增量对比的唯一依据:`/project-snapshots//index.json` 保存上次成功同步的相对路径、校验和、字节数和修改时间。项目根使用现有 manifest 的稳定 `project_id` 作为远端身份,路径不再作为身份。 - 可观测性按产品口径收敛到本机日志:同步结果、失败分类、延后与跳过计数只写入 AppData 诊断日志(`project_snapshot.sync.*` 前缀),客户端界面不暴露上传状态、时间线或入口按钮。`read_local_project_snapshot_state` 与 `sync_local_project_snapshot` 两条命令仅作为 native-only 的排障与联调入口登记,不在渲染层调用。 - 远端写入经 `api-server`,客户端只持平台登录态 Access Token。两条登录态路由:`POST /api/agc/project-snapshots/files`(单文件,正文为原始字节,元数据走查询串)与 `POST /api/agc/project-snapshots/manifest`(本次同步后的完整清单)。 -- 对象键与清单由服务端决定:文件键为 `agc/project-snapshots/v1/{userId}/{projectId}/files/{sizeBytes}-{checksumDigest}/{relPath}`,清单键为 `agc/project-snapshots/v1/{userId}/{projectId}/manifest.json`。键里带字节数与摘要,因此"对象已存在且长度一致"可以作为内容一致的判据;路径按原始大小写保留,不走 `put_object` 的低位规范化。`agc` 前缀继续是服务端专用私有前缀,通用对象键解析与客户端直传票据都不覆盖它。 +- 对象键与清单由服务端决定:文件键为 `agc/project-snapshots/v2/{channel}/{userId}/{projectId}/files/{sizeBytes}-{checksumDigest}/{relPath}`,清单键为 `agc/project-snapshots/v2/{channel}/{userId}/{projectId}/manifest.json`;`channel` 是本部署渠道(`GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL`,缺省沿用 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`),同一 bucket 因此天然按渠道分区,开发与正式部署互不可见对方项目。键里带字节数与摘要,因此"对象已存在且长度一致"可以作为内容一致的判据;路径按原始大小写保留,不走 `put_object` 的低位规范化。`agc` 前缀(含历史无渠道的 `agc/project-snapshots/v1/`)继续是服务端专用私有前缀,通用对象键解析与客户端直传票据都不覆盖它。后台“项目工程”按渠道查询与下载,渠道名非法时失败关闭,历史 v1 对象不再列出。 - 目标 bucket 使用独立配置 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET` / `_ENDPOINT` / `_ACCESS_KEY_ID` / `_ACCESS_KEY_SECRET`,默认 `agc-dev` + `oss-rg-china-mainland.aliyuncs.com`。只允许凭据回退 `ALIYUN_OSS_ACCESS_KEY_ID` / `_ACCESS_KEY_SECRET`;bucket 与 endpoint 不跟随资源存储的 `ALIYUN_OSS_BUCKET` / `_ENDPOINT`,避免默认写入其它 bucket。显式快照目标配置继续优先;不自动搬迁其它 bucket 的现存数据。 ### 正常、失败、重试与幂等行为 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 41bf0967e..317fb6bb0 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -15,6 +15,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, 提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item:`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。 +输入校验按整条消息判断是否有内容:每个 `input_text` 片段都允许是空字符串、空格或换行,不逐片段拒绝,也不合并、删除或改写片段;原始文字、分段和 `content[]` 顺序保持不变。整条消息必须至少包含一段非空白文字,或至少一个非文本 part(素材引用 / 运行画面引用 / Skill 引用 / 附件引用),否则返回“聊天内容不能为空”。各类引用继续执行原有字段、数量、manifest 归属和路径安全校验;即使消息同时带有正文,非法引用也必须拒绝,不能由正文绕过。 + ## 拖拽引用(2026-09-21) 除了 `@` 输入与「引用」按钮,资源卡还支持**拖到对话**:在资源画布上按住一张卡拖到右侧 Agent 对话栏,松手即把这次拖动真正参与位移的那批素材整批 `@` 进输入框(框选多选后拖任意一张 = 整批引用;拖未选中的卡 = 只引用它自己)。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index c7916f2c8..23d4a3cd5 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -147,6 +147,10 @@ revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 手工发布入口 `Genarrative-Manual-Build-And-Deploy` 的 `DEPLOY_TARGET` 与 AGC 更新渠道是两个独立维度。手工入口必须把 `release` 映射为 `AGC_UPDATE_CHANNEL=release`、把 `development` 映射为 `dev`,并把该参数同时透传给 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`;只传 `AGC_RELEASE_VERSION` 时下游会落回各自默认 `dev`。统一号 `0.1.95` 的 release 包应分别位于 `agc/release-win/0.1.95/` 与 `agc/release-mac/0.1.95/`,渠道清单为对应目录下的 `latest.json`,不存在 `agc/release/` 这一层。补发本轮已烧号的版本时,直接以相同 `AGC_RELEASE_VERSION` 重跑两条 AGC Job,不重新发号。 +渠道参数同时决定安装身份:默认渠道 `dev` 产出 `productName=陶泥儿` + `identifier=world.genarrative.ai-game-creator`(既有安装与升级链),其它渠道产出 `陶泥儿 <渠道显示名>` + `<基线>.<渠道>`,因此同一台设备可以同时装 `dev` 与 `release` 两个客户端,互不覆盖、数据不共享(渠道与安装身份合同见《AGC客户端更新检查与下载》)。发布后核对发布对象名(如 `agc/release-win/<版本>/陶泥儿 Release_<版本>_x64-setup.exe`)与装机后的卸载项、`%APPDATA%\` 是否按渠道分开。 + +`Genarrative-Agc-Windows-Build` 的 `Tauri NSIS toolchain` 阶段必须在 Rust 编译前预置 NSIS 工具链并失败关闭:tauri-bundler 打包时现场从 GitHub 下载 `nsis-3.11.zip` 与 `nsis_tauri_utils.dll` 且不重试,构建机每次检出都会重下,响应一旦被截断就只能抛 `io: unexpected end of file`,让发布在编译数分钟后才失败。该阶段先跑 `node apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs`(固定 SHA1 校验、4 次重试、解压到 `target/.tauri/NSIS`),再执行 `makensis.exe -VERSION` 验证可执行性;Checkout 的 `git clean -fdx` 必须带 `-e apps/ai-game-creator-shell/src-tauri/target/.tauri`,只保留这份工具缓存、其余 `target/` 内容照常清空,否则工作区内缓存会被每个构建删掉,退回到「每次从 GitHub 重下」(实测裸 `git clean -fdx` 会输出 `Would remove apps/ai-game-creator-shell/src-tauri/target/`);原始归档缓存在工作区外的 `%ProgramData%\genarrative\tauri-nsis-cache`(可用 `AGC_TAURI_NSIS_CACHE_DIR` 覆盖),因此同一节点只有冷缓存才需要联网,离线补缓存时把这两个文件放进缓存目录即可;构建机确实无法访问 GitHub 时使用 bundler 自带的 `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE` / `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR` 指向可达镜像。升级 `@tauri-apps/cli` 时必须同步核对 `nsis-toolset.mjs` 里的归档地址、SHA1 与必需文件清单(与 tauri-bundler 的 `NSIS_REQUIRED_FILES` 逐条对齐),否则预置会被 bundler 判为不完整。 + 调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh`、`` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`:Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发。 Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。该阶段只能通过 `agent none` 和显式 `node(...)` 分配目标机,直接执行 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`;目标机不得 checkout Git、挂载 Git SSH 凭据或依赖 Jenkins workspace 源码。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。 @@ -550,7 +554,7 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null ### AGC 项目快照上传目标 -后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。 +后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照,默认只看本部署渠道,顶部“渠道”选择框可切换远端已存在的其它渠道;列表按游标分页(每页 20/50/100,上一页复用已取得的游标,远端不给总数所以只显示当前页)。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。“用户”列与“素材查询”同口径展示昵称与陶泥号,并可点开用户详情;不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。 自动上传以原生登记的活动工程为准:打开即首传、每 300 秒周期同步、切换/关闭补传。排障同时核对 AppData `project-snapshots` 索引、`project_snapshot.sync.*` 日志和远端清单;只有测试项目的历史清单不能证明现役项目同步生效。前端在同一窗口内切项目时必须登记生命周期,不能只检查 URL 是否包含 `projectPath`。 @@ -559,15 +563,23 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null AGC 客户端按周期与项目关闭时机把用户项目增量上传到 `agc-dev`。客户端只持有平台登录态 Access Token,经 `POST /api/agc/project-snapshots/files`(单文件原始字节)与 `POST /api/agc/project-snapshots/manifest`(本次同步清单)交给 `api-server`,由服务端写入私有前缀 -`agc/project-snapshots/v1/{user}/{project}/`;客户端不直连 OSS,也不持有 OSS 凭据。 +`agc/project-snapshots/v2/{channel}/{user}/{project}/`;`channel` 是本部署渠道,客户端不上报渠道、也不直连 OSS, +因此同一 bucket 可以由开发与正式两套部署分别落在 `dev/` 与 `release/` 下。 ```env GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET=agc-dev GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT=oss-rg-china-mainland.aliyuncs.com GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID= GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET= +GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL=dev ``` +`GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL` 未配置时沿用 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(缺省 `dev`); +正式部署必须显式设成 `release`,否则正式上传会写进 `dev/` 渠道。渠道名必须是小写字母开头的 +小写字母 / 数字 / 连字符(≤32 字符),非法时上传与后台查询都返回 `503`/`400`,不会回落到别的渠道。 +历史无渠道前缀 `agc/project-snapshots/v1/` 只保留对象、不再写入也不再进入后台列表;需要取回历史对象时 +按旧前缀直接在 OSS 侧读取。 + 专用凭据为空时回退 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET`,bucket 与 endpoint 仍默认指向 AGC 发行 bucket,因此回退凭据必须具备目标 bucket 该前缀的 `PutObject` / `GetObject` / `DeleteObject` 权限;`api-server` 启动时会打印一行 `AGC 项目快照 OSS 客户端已启用`(含 bucket、 @@ -576,7 +588,7 @@ endpoint 与凭据来源,不含密钥),凭据缺失或只配一半则跳 远端占用按当前清单收敛:每次清单写入成功后,服务端用上一版清单反推不再被引用的对象键并删除,单次最多 回收 2000 个,上一版清单不可读时整轮跳过(不会误删)。因此不需要额外配置 bucket 生命周期来防止无界 -增长;`agc/project-snapshots/v1/` 下同一路径只保留当前内容,历史版本不保留。配额口径:单文件 64 MiB、 +增长;`agc/project-snapshots/v2/{channel}/` 下同一路径只保留当前内容,历史版本不保留。配额口径:单文件 64 MiB、 单次同步上传预算 512 MiB、单项目常驻 2 GiB;超限分别表现为跳过/延后/413。服务端另有进程内小时配额 (文件 3000 次、清单 120 次)与同一项目 5 秒最小清单间隔,超限返回 `429` 并带 `Retry-After`。 @@ -734,7 +746,7 @@ Mac 单架构(arm64)构建脚本位于 `jenkins/Jenkinsfile.ai-game-creator- Job 名为 `Genarrative-Agc-MacOS-Build`,SCM 直接读取仓库内上述 Jenkinsfile,参数为 `SOURCE_BRANCH`、`COMMIT_HASH`、`AGC_UPDATE_CHANNEL`、`AGC_RELEASE_VERSION`、`AGC_RELEASE_DRY_RUN`、`AGC_UPDATE_RELEASE_NOTES`、`OSSUTIL_BIN`、`CARGO_BUILD_JOBS`、`NOTIFICATION_EMAILS`。渠道参数是基础名(不含系统,默认 `dev`),脚本不接受 `dev-mac` 这类系统后缀,写入分区固定推导为 `-mac`:这与 Windows Job 的 `-win` 对称,也延续已发布客户端的端点。 -该 Job 的职责是构建并发布 `-mac` 分区更新:执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 arm64 单架构 app、arm64 隔离 smoke、arm64 DMG(`<产品名>_<版本>_aarch64.dmg`)与分区清单 `latest.json`,用产物内烘焙的公钥复核更新包签名(`verify-updater-signature.mjs`),最后按 `AGC_RELEASE_DRY_RUN` 决定是否上传 OSS。签到会同时取 `master`,让渠道清单里上一次发布的 commit 可解析——缺了它更新摘要会退化成「最近客户端改动」(该步失败只降级摘要,不阻断发布)。产物名(`*.app`、updater 归档、DMG、卷名)一律从 Tauri `productName` 推导,校验脚本从包内 `Info.plist` 读取可执行名,改产品名不会让入口静默找错对象;隔离 smoke 用 `ditto --clone` 复制副本(实测整轮 6.8 秒,此前整包复制约 1 分钟),并在构建前删除本次将写出的 DMG/更新包/签名,保证归档产物一定来自本次构建。归档限 `artifacts/` 下的 DMG、SHA-256、`latest.json`、更新包签名、更新摘要、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 +该 Job 的职责是构建并发布 `-mac` 分区更新:执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 arm64 单架构 app、arm64 隔离 smoke、arm64 DMG(`<产品名>_<版本>_aarch64.dmg`)与分区清单 `latest.json`,用产物内烘焙的公钥复核更新包签名(`verify-updater-signature.mjs`),最后按 `AGC_RELEASE_DRY_RUN` 决定是否上传 OSS。签到会同时取 `master`,让渠道清单里上一次发布的 commit 可解析——缺了它更新摘要会退化成「最近客户端改动」(该步失败只降级摘要,不阻断发布)。产物名(`*.app`、updater 归档、DMG、卷名)一律从渠道安装身份派生的产品名推导(默认渠道即基线 `productName`,`release` 等渠道带渠道后缀),校验脚本从包内 `Info.plist` 读取可执行名,改产品名或换渠道都不会让入口静默找错对象;隔离 smoke 用 `ditto --clone` 复制副本(实测整轮 6.8 秒,此前整包复制约 1 分钟),并在构建前删除本次将写出的 DMG/更新包/签名,保证归档产物一定来自本次构建。归档限 `artifacts/` 下的 DMG、SHA-256、`latest.json`、更新包签名、更新摘要、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 发布凭据全部走 Jenkins 全局凭据,并在 `withCredentials` 内注入当前进程:`AgcUpdaterSigningKey`(与 `AgcUpdaterSigningKeyPassword`)映射为 `TAURI_SIGNING_PRIVATE_KEY` / `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,`AliyunAccessKeyId` / `AliyunaccessKeySecret` 映射为 `AGC_OSS_ACCESS_KEY_ID` / `AGC_OSS_ACCESS_KEY_SECRET`;私钥与凭据不写入 workspace、日志或归档产物。上传顺序为更新包、签名、首装包,三者全部成功后才覆盖 `agc/-mac/latest.json` 指针;`AGC_RELEASE_DRY_RUN` 与 Windows 渠道对称:默认关闭即真发布,勾选后才退化为演练(只打印将上传的对象、不写任何 OSS 对象)。本 Job 是正式发布入口,调度器在发号后与 Windows 一起触发它,并额外传 `SKIP_IF_SUPERSEDED=true`——Mac 节点是日常办公机,离线期间排队的旧构建在节点回来后若已被源码分支推进,直接跳过而不发布过期版本。Mac 节点需要 `ossutil`(实测 1.7.19 原生 arm64 可用,装在 `~/.local/bin`,已在 Job 的 PATH 内),可用 `OSSUTIL_BIN` 指定命令名或绝对路径。首次发布建议显式指定 `AGC_RELEASE_VERSION`,避免按渠道高水位递增时出现版本链回退。 diff --git a/docs/【模板规范】AGC模板包组织指南-2026-09-21.md b/docs/【模板规范】AGC模板包组织指南-2026-09-21.md new file mode 100644 index 000000000..f92bd0183 --- /dev/null +++ b/docs/【模板规范】AGC模板包组织指南-2026-09-21.md @@ -0,0 +1,124 @@ +# 【模板规范】AGC 模板包组织指南 + +给准备 AGC(AI 游戏创作)游戏模板的人:说明模板源目录与成品 ZIP 该怎么组织、什么东西不能放、发布前怎么自检。 + +权威合同仍是 [`docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`](technical/【技术方案】AGC模板库与模板建项-2026-09-17.md)(发布协议、锁、版本门禁、后台接口都在那里);本文件只讲「内容怎么组织」。 + +## 一句话契约 + +**ZIP 的根目录 == 建项后用户项目的根目录**:解压出来看到什么,用户项目里就有什么(例如 `game/index.html`、`package.json`),不要再套一层 `my-template/` 目录。 + +## 两条发布路径 + +| 路径 | 输入 | 适用场景 | +| --- | --- | --- | +| CLI 发布 | 源目录 `v1//{meta.json, project/**, cover.(png\|jpg\|jpeg\|webp\|svg)}`,其中 `project/**` 就是 ZIP 根 | 仓库内长期维护、确定性打包、`--only` 定向发布 | +| 后台「上传模板」 | 成品 ZIP + 同 ID 封面 + 表单元数据,一批最多 20 个 | 一次性上传,不依赖本地仓库 | + +CLI 打包规则:递归收集 `project/**` 下的普通文件(按条目名排序、固定时间戳,同一份内容重复打包摘要一致),只接受普通文件与目录,遇到符号链接等其它类型直接报错。 + +## ZIP 内容规则 + +### 必须满足 + +- 根目录就是项目根;`entry` 是相对 ZIP 根、不以 `/` 开头、不含 `..`、不含空白或控制字符的路径。 +- ZIP 必须包含 `entry` 指向的文件,并且至少有一个文件(空 ZIP、只含目录的 ZIP 都会被拒绝)。 +- 条目不能用绝对路径、盘符、反斜杠或 `..`;不允许符号链接。 + +### 建议结构(html / three.js / phaser 一类) + +```text +game/index.html # entry,推荐 +game/game.js +game/style.css +game/package.json # 可选 +game/vite.config.js # 可选 +assets/… # 可选 +``` + +- 建项时会在复制模板文件之后补齐 `game/`、`assets/`、`memory/`、`memory/agents/`、`exports/`,并创建 `.agent/` 清单;这些目录不用在模板里手工占位。 +- 模板没有 `game/index.html` 时,建项会写入一份默认入口占位页;所以 html 类模板应当自带 `game/index.html`,并把 `entry` 填成 `game/index.html`。根目录的 `index.html` 不会被当成游戏入口(建项仍会补默认 `game/index.html`),不要用这种结构。 + +### Godot 项目(`runtime=godot`) + +- 根目录直接放 Godot 工程:`project.godot`(此时 `entry` 用 `project.godot`)、`scenes/`、`scripts/`、`icon.svg`、`.gitignore` 等,`.import` / `.uid` 之类由引擎生成的伴生文件不必手工准备。 +- `project.godot` 要能独立打开:`config_version=5`,且 `[application]` 段的 `run/main_scene` 指向工程内真实场景。建项会把 `config/name` 改写成用户选择的项目名(只改这一行),模板里的名字只是初始值。 +- 目标版本是 Godot 4.7:`config/features=PackedStringArray("4.7", "GL Compatibility")` 配套 `renderer/rendering_method="gl_compatibility"` 兼容面最大,2D 与轻量 3D 模板都用这一套。 +- 建项按 `project.godot` 识别 Godot 模板,写入 `godotProjectRoot: "."`,不生成 `game/` 占位入口与 `assets/`、`memory/`、`exports/` 并行目录。 +- 不要打包 `.godot/` 编辑器与导入缓存、导出产物(`export/`、`build/`);模板正文保持「克隆下来直接就能打开」的源状态。 +- 输入优先只用 Godot 内置的 `ui_*` 动作;需要自定义动作时把 `[input]` 段写进 `project.godot`,不要让模板依赖开发者本机的输入映射。 +- 建议不引入外部贴图与音频,用 `Polygon2D`、`ColorRect`、`BoxMesh` 一类节点搭出可见骨架,避免模板源里出现难以复核的二进制素材。 + +### Cocos Creator 项目(`runtime=cocos`) + +- 根目录直接放 Creator 工程:`package.json`(此时 `entry` 用 `package.json`)、`assets/`、`settings/`、`profiles/`、`.creator/`、`tsconfig.json`、`.gitignore` 等官方结构**原样保留**,`.meta` 与导入设置必须一起带上,否则导入后资源关系会丢。 +- `package.json` 必须带 `creator.version`(当前模板库口径是 3.8.8);建项时会重写 `package.json` 的 `name` 与 `uuid`,模板里的这两个值不会出现在用户项目里。 +- 空工程用 `assets/.gitkeep` 之类的占位文件保证空资源目录能进 ZIP。 +- 不打包编辑器缓存与构建产物:`library/`、`temp/`、`local/`、`build/`,以及任何用户项目数据。 + +### 不要放进 ZIP + +- `node_modules/`、`dist/`、构建产物、打包缓存。 +- `.git/`、`.svn/`、`.vscode/`、`.idea/` 等工程外元数据。 +- `.agent/`:项目身份、对话账本和 `.agent/manifest.json` 由建项流程生成;模板自带会让新项目继承一个陌生身份。 +- 密钥、Token、`.env*`、个人绝对路径、日志文件。 +- 嵌套的 `package-lock.json`:模板工程内嵌锁文件已在 2026-09-17 清理,需要锁文件请在用户项目里自行生成。 +- 符号链接与任何非普通文件。 + +### 路径与体积上限 + +| 项 | 上限 | +| --- | --- | +| ZIP 内路径 | 相对路径,无 `..`、盘符、反斜杠、空白与控制字符 | +| 条目数 | ≤ 4096 | +| 单个文件(解压后) | ≤ 256 MiB | +| 解压后总量 | ≤ 512 MiB | +| ZIP 体积 | 客户端下载 ≤ 512 MiB;后台「上传模板」≤ 64 MiB(更大请走 CLI) | +| 清单体积 | ≤ 4 MiB(模板条目很多时注意) | + +## 封面 + +- 必须每个模板一张。后台页面**按文件名匹配**模板 ID:`cocos-empty-2d.zip` ↔ `cocos-empty-2d.png`;CLI 源目录下则必须恰好一张 `cover.(png|jpg|jpeg|webp|svg)`。 +- 后台**上传**只接受真实 PNG / JPEG / WebP(按字节嗅探,不信任浏览器声明的 content-type);SVG 只用于 CLI 发布的历史模板。 +- 上限:≤ 5 MiB、单边 ≤ 4096 像素、≤ 1600 万像素;建议 960×540,与现有模板一致。 + +## 元数据字段 + +| 字段 | 约束 | +| --- | --- | +| `id` | `^[a-z0-9][a-z0-9._-]{0,63}$`;CLI 下目录名必须等于 `meta.id` | +| `title` | 非空;后台编辑上限 80 字符 | +| `summary` | 可空;后台编辑上限 1000 字符 | +| `tags` | CLI 要求非空字符串数组;去重后 ≤ 16 个、每个 ≤ 32 字符 | +| `runtime` | 只能是 `html` / `unity` / `godot` / `cocos` | +| `engine` / `engineVersion` | 可空;例如 `cocos-creator` / `3.8.8`、`three.js` / `0.180.0` | +| `templateVersion` | `^[a-z0-9][a-z0-9._-]{0,31}$`;新模板从 `0.1.0` 起 | +| `entry` | 见上,必须是 ZIP 内真实存在的文件 | +| `coverWidth` / `coverHeight` | 可选;建议填真实尺寸,后台路径按上传图片实际尺寸记录 | + +## 版本与不可变 + +- 同一 `id` 同一 `templateVersion` 的 ZIP 字节**不可变**:改了内容必须递增 `templateVersion`,否则 CLI 与后台上传都会拒绝并提示递增版本。 +- 发布不删除历史对象;下架只是把条目移进 `inactiveTemplates`,历史版本仍可按旧对象键下载。 +- 只改名称、简介、标签、封面或上下架 → 用后台「编辑」,不必动版本;改了包内容 → 递增版本。 + +## 发布前自检 + +1. 核对 ZIP 结构:`unzip -l your-template.zip`,确认没有外层目录、`entry` 存在、没有 `.agent/`、`.git/`、`node_modules/`、`dist/`(Godot 模板另需确认没有 `.godot/`)。 +2. 核对体积与条目数(见上表)。 +3. CLI 路径:`node scripts/agc-template-library-publish.mjs --source --dry-run` 查看合并计划与摘要,确认后再去掉 `--dry-run`。 +4. 后台路径:上传页逐行核对 ID / 名称 / 版本 / 运行时 / entry,确认封面已按 ID 匹配。 +5. 发布后匿名核验清单:`curl -s https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/index.json`。 +6. 首次上架后,在客户端「模板库」里搜到该模板并实际建一次项目。 + +## 已知边界 + +- 上面「不要放进 ZIP」的目录里,**身份 / 版本库 / 依赖 / 构建产物已有机器门禁**:任意层级的 `.agent/`、`.git/`、`.svn/`、`node_modules/`,以及根目录的 `dist/`、`build/`、`library/`、`temp/`、`local/`、`.idea/`、`.vscode/`,都会在发布前被拒绝——CLI 在收集正文时就报错,后台「上传模板」在任何写入之前整批拒绝,不会出现「传上去了才发现」。同名目录段只在根目录受限,所以正文内的 `game/dist/**` 属于模板自身内容,不会被拦。 +- 门禁只覆盖上面列出的目录。其余内容(`.env*`、密钥、日志、个人绝对路径等)仍依赖模板作者遵守本指南:ZIP 里放了什么,建项后用户项目里就有什么(只有安装标记 `installed.json` 不会被复制)。 +- 客户端按 `installedVersion != templateVersion` 判断是否需要更新;同版本换内容不会触发更新,所以改了字节就必须递增版本。 + +## 相关文档 + +- 主规范:[【技术方案】AGC 模板库与模板建项](technical/【技术方案】AGC模板库与模板建项-2026-09-17.md)(含「后台模板管理」「后台模板上传」章节)。 +- 模板库说明(发布到 OSS `templates/README.md` 的源):[`apps/ai-game-creator-shell/template-library/README.md`](../apps/ai-game-creator-shell/template-library/README.md)。 +- 发布脚本:[`scripts/agc-template-library-publish.mjs`](../scripts/agc-template-library-publish.mjs)。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index b362711d4..26a79ab3e 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -2,7 +2,7 @@ 日期:`2026-06-15` -更新时间:`2026-09-17` +更新时间:`2026-09-21` ## 背景 @@ -40,6 +40,8 @@ ## 生成契约 - 前端提交到 `POST /api/editor/icon-spritesheets/generations`。 +- AGC 客户端资源画布的「生成图标素材」与网站画布共用服务端图集生成与提示词流程。客户端只去除描述首尾空白,将保留内部换行的完整原文作为唯一 `iconDescriptions` 元素提交;不追加“Web 小游戏首版原型”“核心美术素材”或默认美术 brief,不静默截断,也不按换行或固定字数拆成多项绕过单项上限。 +- 客户端面板和原生提交均按去除首尾空白后的描述校验:不能为空,最多 `200` 个 Unicode 字符(按码点计数)。空白与 API Server 的 Rust `str::trim` 一致,使用 Unicode `White_Space`,不套用普通图片入口额外的控制字符限制。超限明确拒绝并保留输入全文,不能只截取前 `240` 字符或把包装后的提示词截进 `200` 字符;其它客户端图片生成仍保留 `32000` 字符上限。单项最多 `200` 码点自然满足现有数组、聚合字符与 UTF-8 字节上限。服务端的规范图、背景、排列和切图要求在描述校验后统一加入,不占请求描述的长度额度;现有 API 数组与聚合上限不变。 - 图集拆分模式必须由调用方显式声明,任何入口都不得存在隐式默认值:`sliceMode` 是图标图集生成请求的必填字段,`connected-components` 按透明像素连通域切分,`grid` 按调用方提供的 `gridX × gridY` 网格切分。 - 请求缺失 `sliceMode`、传 `null` 或空字符串时,`POST /api/editor/icon-spritesheets/generations` 与 `POST /api/external/v1/editor/icon-spritesheets/generations` 都必须在引用解析、定价、入队和任何 provider / OSS 副作用之前返回 `400`,错误体带 `field=sliceMode`,message 复述本节的决策要求;服务端不得用兜底模式继续执行,也不得为该字段保留默认值。 - `grid` 必须同时提供 `gridX` 与 `gridY`(各 `1..32`,乘积不得超过当时生效的图集切片上限);只提供其中一个、越界或乘积超限同样在副作用之前 `400`。 @@ -110,6 +112,7 @@ - 点击 `生成图标素材` 后出现一叠空白图标占位和图标素材面板。 - `图标规范 -> 从画布中选择` 只能选择图标规范图,点击普通图片或角色规范图不会绑定。 - 默认提示文本会完整进入 prompt;用户输入不再被解析为素材数量。例如“各种敌人头像:骷髅 哥布林 强盗 龙 蝙蝠等”只是一段完整需求,不代表必须生成或拆出 `6` 个素材。 +- 客户端提交含中文、补充平面字符和内部换行的合法描述时,`iconDescriptions` 只有一个元素且等于去除首尾空白后的原文;`200` 个 Unicode 字符通过,`201` 个字符和全空白描述在付费提交前被拒绝。其它图片入口继续接受大于 `200`、不超过 `32000` 字符的提示词。 - 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。 - 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。 - 图标素材面板可选择 `style: "none" | "pixelArt"`;`none` 完整保持原处理路径,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「每个图标素材均为像素风格」并在 Alpha 回贴后、自动拆分前执行内存像素规整,直接持久化整数倍放大后的透明图集;该图集宽高比等于逻辑图、尺寸接近规整输入,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index d48f8d670..11ce61f3b 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -41,7 +41,11 @@ pipeline { if (Test-Path '.git') { & $git fetch --tags --force --prune origin "+refs/heads/$($env:SOURCE_BRANCH):refs/remotes/origin/$($env:SOURCE_BRANCH)" if ($LASTEXITCODE -ne 0) { throw 'Git fetch 失败' } - & $git clean -fdx + # Tauri 的 NSIS 工具缓存位于工作区内的 target/.tauri:裸 git clean -fdx + # 会把它一起删掉,导致每个构建都重新从 GitHub 下载工具链(下载无重试, + # 响应截断即 `io: unexpected end of file`)。这里只排除该缓存目录, + # 其余 target 内容照常清空。 + & $git clean -fdx -e 'apps/ai-game-creator-shell/src-tauri/target/.tauri' & $git reset --hard "origin/$($env:SOURCE_BRANCH)" } else { & $git clone --no-single-branch $env:GIT_REMOTE_URL . @@ -144,6 +148,26 @@ pipeline { } } + stage('Tauri NSIS toolchain') { + steps { + withEnv(["PATH=${env.AGC_WINDOWS_PATH}"]) { + // 必须早于 Rust 编译与打包:Tauri bundler 自己下载 NSIS 工具链时不重试, + // 响应截断只会报 `io: unexpected end of file`,让发布在数分钟后才失败。 + powershell ''' + $ErrorActionPreference = 'Stop' + node --test apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs + if ($LASTEXITCODE -ne 0) { throw "NSIS 工具链回归测试失败,退出码=$LASTEXITCODE" } + node apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs + if ($LASTEXITCODE -ne 0) { throw "NSIS 工具链预置失败,退出码=$LASTEXITCODE" } + $nsis = Join-Path $PWD 'apps/ai-game-creator-shell/src-tauri/target/.tauri/NSIS/makensis.exe' + if (-not (Test-Path $nsis)) { throw "NSIS makensis.exe 缺失:$nsis" } + & $nsis -VERSION + if ($LASTEXITCODE -ne 0) { throw "NSIS makensis.exe 无法执行,退出码=$LASTEXITCODE" } + ''' + } + } + } + stage('Build and upload') { steps { script { diff --git a/package-lock.json b/package-lock.json index 8d32eff53..a15be25e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -138,6 +138,7 @@ "@types/react-dom": "^19.2.3", "@types/react-window": "^1.8.8", "@types/three": "^0.184.1", + "jszip": "^3.10.1", "tailwindcss": "^4.1.14", "typescript": "~5.8.2", "vitest": "^0.34.6" diff --git a/package.json b/package.json index 7436a415f..57405379c 100644 --- a/package.json +++ b/package.json @@ -178,6 +178,7 @@ "ai-game-creator-shell:dev-server": "npm --prefix apps/ai-game-creator-shell run dev-server", "ai-game-creator-shell:build": "npm --prefix apps/ai-game-creator-shell run build --", "ai-game-creator-shell:release:upload": "npm --prefix apps/ai-game-creator-shell run release:upload", + "ai-game-creator-shell:nsis:prepare": "npm --prefix apps/ai-game-creator-shell run nsis:prepare", "ai-game-creator-shell:llm-status": "npm --prefix apps/ai-game-creator-shell run llm-status --", "ai-game-creator-shell:agent-task": "npm --prefix apps/ai-game-creator-shell run agent-task --", "agc:chat": "npm --prefix apps/ai-game-creator-shell run chat --", diff --git a/scripts/agc-template-library-publish.mjs b/scripts/agc-template-library-publish.mjs index e74b37c3f..6d5b3a3ef 100644 --- a/scripts/agc-template-library-publish.mjs +++ b/scripts/agc-template-library-publish.mjs @@ -25,6 +25,8 @@ import { deflateRawSync } from 'node:zlib'; const SCHEMA_VERSION = 'agc-template-library.v1'; const TEMPLATE_SCHEMA_VERSION = 'agc-template.v1'; +/** 说明文档上限:它随每次发布覆盖写 `templates/README.md`,不做内容寻址。 */ +const README_MAX_BYTES = 64 * 1024; const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u; const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u; const RUNTIMES = new Set(['html', 'unity', 'godot', 'cocos']); @@ -198,12 +200,39 @@ function buildZip(entries) { } function readProjectFiles(projectRoot) { + /** 正文任何层级都不允许出现的目录段:项目身份与版本库 / 依赖元数据。 */ + const forbiddenSegments = new Set(['.agent', '.git', '.svn', 'node_modules']); + /** 只允许出现在正文根目录之外的构建产物与编辑器工作区目录。 */ + const forbiddenRootSegments = new Set([ + 'dist', + 'build', + 'library', + 'temp', + 'local', + '.idea', + '.vscode', + ]); + // 与后台「上传模板」共用同一条门禁,依据 docs/【模板规范】AGC模板包组织指南-2026-09-21.md。 + const violation = (relative) => { + const segments = relative.split('/'); + for (const segment of segments) { + if (forbiddenSegments.has(segment)) { + return `模板正文不允许包含 ${segment} 目录:${relative}`; + } + } + if (forbiddenRootSegments.has(segments[0])) { + return `模板正文根目录不允许包含 ${segments[0]} 目录:${relative}`; + } + return null; + }; const files = []; const walk = (directory, prefix) => { for (const entry of readdirSync(directory, { withFileTypes: true }).sort( (left, right) => left.name.localeCompare(right.name), )) { const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const rejected = violation(relative); + if (rejected) throw new Error(rejected); const full = join(directory, entry.name); if (entry.isDirectory()) walk(full, relative); else if (entry.isFile()) @@ -380,7 +409,14 @@ export function buildLibrary(source, prefix, only = []) { body: Buffer.from(`${JSON.stringify(indexJson, null, 2)}\n`, 'utf8'), contentType: 'application/json', }); - return { objects, indexJson }; + // 说明文档不是内容寻址对象:它固定挂在 `templates/README.md`,随每次发布覆盖, + // 客户端从不读取它,但运维和后台都需要看到与当次契约一致的说明。 + const readmePath = join(source, 'README.md'); + const readmeBody = existsSync(readmePath) ? readFileSync(readmePath) : null; + if (readmeBody && readmeBody.length > README_MAX_BYTES) { + throw new Error(`README.md 超过 ${README_MAX_BYTES} 字节上限`); + } + return { objects, indexJson, readmeBody }; } export function mergeLibraryIndex(existing, selected) { @@ -769,25 +805,43 @@ export async function publishLibrary( `${JSON.stringify(indexJson, null, 2)}\n`, 'utf8', ); - // 指针写入结果不明时旧请求可能晚到,必须留锁,不能让后续发布者越过它。 - releaseAllowed = false; - const published = await checkedRequest( - () => client.put(indexKey, indexBytes, 'application/json'), - '发布清单', - ); - if (published.ok) { - releaseAllowed = true; - } else if ( - published.status >= 400 && - published.status < 500 && - published.status !== 408 - ) { - releaseAllowed = true; - throw new Error(`发布清单被拒绝:HTTP ${published.status}`); - } else { - throw new Error(`发布清单结果不明:HTTP ${published.status}`); + // 可变指针(说明文档、库清单)写入结果不明时旧请求可能晚到,必须留锁, + // 不能让后续发布者越过它。清单必须最后写,否则会出现「清单已指新对象、 + // 说明还是旧版」的可观测窗口。 + const pointerWrites = [ + ...(library.readmeBody + ? [ + { + key: `${prefix}/README.md`, + body: library.readmeBody, + contentType: 'text/markdown; charset=utf-8', + }, + ] + : []), + { key: indexKey, body: indexBytes, contentType: 'application/json' }, + ]; + for (const pointer of pointerWrites) { + releaseAllowed = false; + const published = await checkedRequest( + () => client.put(pointer.key, pointer.body, pointer.contentType), + `发布 ${pointer.key}`, + ); + if (published.ok) { + releaseAllowed = true; + } else if ( + published.status >= 400 && + published.status < 500 && + published.status !== 408 + ) { + releaseAllowed = true; + throw new Error(`发布 ${pointer.key} 被拒绝:HTTP ${published.status}`); + } else { + throw new Error( + `发布 ${pointer.key} 结果不明:HTTP ${published.status}`, + ); + } + await verifyObject(client, pointer.key, pointer.body); } - await verifyObject(client, indexKey, indexBytes); } catch (error) { failure = error; } finally { @@ -858,6 +912,11 @@ async function main() { for (const object of library.objects.slice(0, -1)) { console.log(` PUT ${object.key} (${object.body.length} B)`); } + if (library.readmeBody) { + console.log( + ` PUT ${args.prefix}/README.md (${library.readmeBody.length} B,覆盖写)`, + ); + } console.log( ` PUT ${args.prefix}/index.json (${Buffer.byteLength(`${JSON.stringify(indexJson, null, 2)}\n`)} B)`, ); @@ -871,6 +930,9 @@ if ( ) { main().catch((error) => { console.error(`[agc-template-library-publish] ${error.message}`); - process.exit(1); + // 直接 `process.exit(1)` 会在 fetch 句柄尚未关闭时触发 libuv 断言崩溃 + // (Windows 上实测只剩一句 Assertion failed,看不到拒绝原因); + // 交给事件循环自然退出,保留真实错误信息。 + process.exitCode = 1; }); } diff --git a/scripts/agc-template-library-publish.test.mjs b/scripts/agc-template-library-publish.test.mjs index 0bc802a13..ccb7180e3 100644 --- a/scripts/agc-template-library-publish.test.mjs +++ b/scripts/agc-template-library-publish.test.mjs @@ -1,9 +1,15 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { createHash, createHmac } from 'node:crypto'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; -import { basename, join, resolve } from 'node:path'; +import { basename, dirname, join, resolve } from 'node:path'; import test from 'node:test'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -87,6 +93,86 @@ test('官方 Cocos 模板生成完整且可重复的原生项目包', async () = } }); +function fixtureSource(projectEntries = []) { + const directory = mkdtempSync(join(tmpdir(), 'agc-template-publish-')); + const templateRoot = join(directory, 'v1', 'fixture-template'); + const write = (relative, bytes) => { + const full = join(templateRoot, relative); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, bytes); + }; + write( + 'meta.json', + `${JSON.stringify( + { + id: 'fixture-template', + title: '夹具模板', + summary: '', + tags: ['fixture'], + runtime: 'html', + entry: 'game/index.html', + templateVersion: '0.1.0', + }, + null, + 2, + )}\n`, + ); + write('cover.svg', '\n'); + write('game/index.html', '\n'); + for (const relative of projectEntries) write(`project/${relative}`, 'x\n'); + return directory; +} + +test('模板正文含身份/版本库/依赖/构建目录时拒绝打包', async () => { + const rejected = [ + ['.agent/manifest.json', '不允许包含 .agent 目录'], + ['game/.agent/ledger.json', '不允许包含 .agent 目录'], + ['.git/config', '不允许包含 .git 目录'], + ['.svn/entries', '不允许包含 .svn 目录'], + ['node_modules/three/package.json', '不允许包含 node_modules 目录'], + ['assets/node_modules/keep.txt', '不允许包含 node_modules 目录'], + ['dist/game.js', '根目录不允许包含 dist 目录'], + ['build/index.html', '根目录不允许包含 build 目录'], + ['library/import.json', '根目录不允许包含 library 目录'], + ['temp/asset.json', '根目录不允许包含 temp 目录'], + ['local/settings.json', '根目录不允许包含 local 目录'], + ['.vscode/settings.json', '根目录不允许包含 .vscode 目录'], + ['.idea/misc.xml', '根目录不允许包含 .idea 目录'], + ]; + for (const [relative, expected] of rejected) { + const directory = fixtureSource([relative]); + try { + assert.throws( + () => buildLibrary(directory, 'templates'), + (error) => String(error.message).includes(expected), + `${relative} 必须被拒绝`, + ); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + } + + // 同名目录段只在根目录被拒绝:正文内 game/dist/** 属于模板自身内容,.gitignore 也不是 .git。 + const directory = fixtureSource(['game/dist/app.js', 'game/.gitignore']); + try { + const built = buildLibrary(directory, 'templates'); + assert.deepEqual( + built.indexJson.templates.map((entry) => entry.id), + ['fixture-template'], + ); + const zip = await JSZip.loadAsync( + built.objects.find( + (object) => object.key === built.indexJson.templates[0].zipKey, + ).body, + { checkCRC32: true }, + ); + assert.ok(Object.keys(zip.files).includes('game/dist/app.js')); + assert.ok(Object.keys(zip.files).includes('game/.gitignore')); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); const indexKey = 'templates/index.json'; const lockKey = 'templates/.publish-lock.json'; @@ -190,6 +276,42 @@ const publish = (built, store) => log: () => {}, }); +test('说明文档随发布覆盖写线上 README,且写在清单之前', async () => { + const store = memoryOss(oldPublishedLibrary()); + const built = { + ...library(), + readmeBody: Buffer.from('# AGC 游戏模板库\n\n- 契约说明\n', 'utf8'), + }; + + await publish(built, store); + + assert.equal( + store.objects.get('templates/README.md').toString('utf8'), + built.readmeBody.toString('utf8'), + ); + const putKeys = store.calls + .filter((call) => call.method === 'PUT') + .map((call) => call.key); + assert.deepEqual(putKeys.slice(-2), [ + 'templates/README.md', + 'templates/index.json', + ]); +}); + +test('源目录没有 README 时不写线上说明文档', async () => { + const store = memoryOss(oldPublishedLibrary()); + + await publish({ ...library(), readmeBody: null }, store); + + assert.equal(store.objects.has('templates/README.md'), false); + assert.equal( + store.calls.some( + (call) => call.method === 'PUT' && call.key === 'templates/README.md', + ), + false, + ); +}); + test('所有发布正文均使用自身字节摘要定位,重复打包复用ZIP与封面地址', () => { const built = library(); for (const object of built.objects.filter( diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index ee33a4373..c05c18084 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -6852,6 +6852,7 @@ mod tests { for (method, path) in [ (Method::GET, "/admin/api/agc-templates"), (Method::PUT, "/admin/api/agc-templates/cocos-empty-2d"), + (Method::POST, "/admin/api/agc-templates/import"), ] { assert!(enforce_admin_request_permission("owner", &[], &[], &method, path).is_ok()); assert!( diff --git a/server-rs/crates/api-server/src/admin_project_snapshots.rs b/server-rs/crates/api-server/src/admin_project_snapshots.rs index 427281191..74aa9b684 100644 --- a/server-rs/crates/api-server/src/admin_project_snapshots.rs +++ b/server-rs/crates/api-server/src/admin_project_snapshots.rs @@ -1,6 +1,7 @@ //! 后台按项目读取私有快照,并在完整性验证后导出原始工程目录。 use std::{ + collections::BTreeSet, future::Future, io::Write, sync::{Arc, OnceLock}, @@ -18,11 +19,13 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use platform_oss::{ OssClient, OssError, OssGetObjectRequest, agc_project_snapshot_file_object_key, agc_project_snapshot_manifest_object_key, project_snapshots::SnapshotDirectoryPage, + validate_agc_project_snapshot_channel, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use shared_contracts::{ admin::{ + AdminProjectSnapshotChannelsResponse, AdminProjectSnapshotDownloadQuery, AdminProjectSnapshotItem, AdminProjectSnapshotStatus, AdminProjectSnapshotsQuery, AdminProjectSnapshotsResponse, }, @@ -45,6 +48,7 @@ use crate::{ project_snapshots::{MAX_MANIFEST_REQUEST_BODY_BYTES, project_snapshot_oss, validate_manifest}, request_context::RequestContext, state::AppState, + work_author::resolve_work_author_by_user_id, }; const MAX_DIRECTORY_REQUESTS: usize = 100; @@ -55,6 +59,7 @@ static DOWNLOAD_PERMITS: OnceLock> = OnceLock::new(); #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] struct SnapshotCursor { + channel: String, user: Option, project: Option, finished_user: bool, @@ -62,9 +67,10 @@ struct SnapshotCursor { } impl SnapshotCursor { - fn decode(value: Option<&str>) -> Result { + fn decode(value: Option<&str>, channel: &str) -> Result { let Some(value) = value.filter(|value| !value.is_empty()) else { return Ok(Self { + channel: channel.to_string(), finished_user: true, ..Self::default() }); @@ -77,6 +83,10 @@ impl SnapshotCursor { .map_err(|_| bad_request("项目列表游标无效"))?; let cursor: Self = serde_json::from_slice(&bytes).map_err(|_| bad_request("项目列表游标无效"))?; + // 游标只在同一渠道内有效:跨渠道复用会让目录推进落在别的渠道上。 + if cursor.channel != channel { + return Err(bad_request("项目列表游标与渠道不一致")); + } for segment in [&cursor.user, &cursor.project].into_iter().flatten() { validate_agc_project_snapshot_project_id(segment) .map_err(|_| bad_request("项目列表游标无效"))?; @@ -115,6 +125,7 @@ trait SnapshotStore: Sync { struct OssSnapshotStore<'a> { oss: &'a OssClient, client: &'a reqwest::Client, + channel: String, } impl SnapshotStore for OssSnapshotStore<'_> { @@ -125,7 +136,7 @@ impl SnapshotStore for OssSnapshotStore<'_> { limit: usize, ) -> Result { self.oss - .list_project_snapshot_directories(self.client, user, after, limit) + .list_project_snapshot_directories(self.client, &self.channel, user, after, limit) .await .map_err(|_| upstream("读取项目工程目录失败")) } @@ -135,7 +146,7 @@ impl SnapshotStore for OssSnapshotStore<'_> { user: &str, project: &str, ) -> Result, AppError> { - let object_key = agc_project_snapshot_manifest_object_key(user, project) + let object_key = agc_project_snapshot_manifest_object_key(&self.channel, user, project) .map_err(|_| bad_request("项目工程身份无效"))?; let bytes = match self .oss @@ -169,6 +180,7 @@ impl SnapshotStore for OssSnapshotStore<'_> { ) -> Result, AppError> { let digest = file.checksum.strip_prefix("fnv1a64:").unwrap_or_default(); let object_key = agc_project_snapshot_file_object_key( + &self.channel, user, project, file.size_bytes, @@ -189,7 +201,11 @@ impl SnapshotStore for OssSnapshotStore<'_> { } } -fn item(user: String, manifest: &AgcProjectSnapshotManifestRequest) -> AdminProjectSnapshotItem { +fn item( + channel: &str, + user: String, + manifest: &AgcProjectSnapshotManifestRequest, +) -> AdminProjectSnapshotItem { AdminProjectSnapshotItem { user_id: user, project_id: manifest.project_id.clone(), @@ -206,6 +222,19 @@ fn item(user: String, manifest: &AgcProjectSnapshotManifestRequest) -> AdminProj Some(_) => AdminProjectSnapshotStatus::Partial, None => AdminProjectSnapshotStatus::Unverified, }, + channel: channel.to_string(), + author_display_name: None, + author_public_user_code: None, + } +} + +/// 后台列表的“用户”列与素材查询同口径:昵称 + 陶泥号,账号不可解析时沿用占位作者。 +/// 查账号只影响展示,失败不回滚已经读到的清单统计。 +fn attach_snapshot_authors(state: &AppState, items: &mut [AdminProjectSnapshotItem]) { + for entry in items.iter_mut() { + let author = resolve_work_author_by_user_id(state, &entry.user_id, None, None); + entry.author_display_name = Some(author.display_name); + entry.author_public_user_code = author.public_user_code; } } @@ -231,6 +260,7 @@ async fn list_snapshots( }); }; cursor = SnapshotCursor { + channel: cursor.channel.clone(), user: Some(user), project: None, finished_user: false, @@ -255,7 +285,7 @@ async fn list_snapshots( for project in projects.directories { scanned += 1; if let Some(manifest) = store.manifest(user, &project).await? { - items.push(item(user.to_string(), &manifest)); + items.push(item(&cursor.channel, user.to_string(), &manifest)); } cursor.project = Some(project); } @@ -284,15 +314,69 @@ pub async fn admin_list_project_snapshots( Extension(_admin): Extension, Query(query): Query, ) -> Result, AppError> { - let cursor = SnapshotCursor::decode(query.cursor.as_deref())?; + let channel = requested_snapshot_channel(&state, query.channel.as_deref())?; + let cursor = SnapshotCursor::decode(query.cursor.as_deref(), &channel)?; let store = OssSnapshotStore { oss: project_snapshot_oss(&state)?, client: state.editor_oss_http_client(), + channel, }; - let response = list_snapshots(&store, cursor, query.limit.unwrap_or(20).clamp(1, 100)).await?; + let mut response = + list_snapshots(&store, cursor, query.limit.unwrap_or(20).clamp(1, 100)).await?; + attach_snapshot_authors(&state, &mut response.items); Ok(json_success_body(Some(&ctx), response)) } +/// 目标渠道:显式传入时必须合法(空串按未提供处理),缺省用本部署渠道。 +/// 非法渠道失败关闭,不落到别的渠道。 +fn requested_snapshot_channel( + state: &AppState, + requested: Option<&str>, +) -> Result { + let requested = requested.map(str::trim).filter(|value| !value.is_empty()); + match requested { + Some(channel) => validate_agc_project_snapshot_channel(channel) + .map_err(|_| bad_request("项目工程渠道无效")), + None => crate::project_snapshots::project_snapshot_channel(state), + } +} + +/// 后台渠道列表:本部署渠道与远端已存在渠道的并集。 +/// 远端目录里不符合渠道命名的条目直接跳过,不作为可查询渠道暴露。 +pub async fn admin_list_project_snapshot_channels( + State(state): State, + Extension(ctx): Extension, + Extension(_admin): Extension, +) -> Result, AppError> { + let default_channel = crate::project_snapshots::project_snapshot_channel(&state)?; + let oss = project_snapshot_oss(&state)?; + let client = state.editor_oss_http_client(); + let mut channels = BTreeSet::from([default_channel.clone()]); + let mut after: Option = None; + for _ in 0..MAX_DIRECTORY_REQUESTS { + let page = oss + .list_project_snapshot_channels(client, after.as_deref(), 100) + .await + .map_err(|_| upstream("读取项目工程渠道失败"))?; + channels.extend( + page.directories + .into_iter() + .filter(|name| validate_agc_project_snapshot_channel(name).is_ok()), + ); + match page.next_marker { + Some(next) => after = Some(next), + None => break, + } + } + Ok(json_success_body( + Some(&ctx), + AdminProjectSnapshotChannelsResponse { + default_channel, + channels: channels.into_iter().collect(), + }, + )) +} + fn verify_file(file: &AgcProjectSnapshotManifestFile, bytes: &[u8]) -> Result<(), AppError> { if bytes.len() as u64 != file.size_bytes || !agc_project_snapshot_checksum(bytes).eq_ignore_ascii_case(&file.checksum) @@ -441,6 +525,7 @@ pub async fn admin_download_project_snapshot( State(state): State, Extension(_admin): Extension, Path((user, project)): Path<(String, String)>, + Query(query): Query, ) -> Result { validate_agc_project_snapshot_project_id(&user).map_err(bad_request)?; validate_agc_project_snapshot_project_id(&project).map_err(bad_request)?; @@ -457,6 +542,7 @@ pub async fn admin_download_project_snapshot( let store = OssSnapshotStore { oss: project_snapshot_oss(&state)?, client: state.editor_oss_http_client(), + channel: requested_snapshot_channel(&state, query.channel.as_deref())?, }; let manifest = store.manifest(&user, &project).await?.ok_or_else(|| { AppError::from_status(StatusCode::NOT_FOUND).with_message("项目工程清单不存在") @@ -581,23 +667,63 @@ mod tests { "project-1", &[("game/empty", b""), ("game/main.js", b"123")], ); - let projection = item("user-1".to_string(), &manifest); + let projection = item("dev", "user-1".to_string(), &manifest); assert_eq!(projection.status, AdminProjectSnapshotStatus::Ready); assert_eq!((projection.file_count, projection.total_bytes), (2, 3)); + assert_eq!(projection.channel, "dev"); manifest.pending_files = Some(2); assert_eq!( - item("user-1".into(), &manifest).status, + item("release", "user-1".into(), &manifest).status, AdminProjectSnapshotStatus::Partial ); let mut legacy = serde_json::to_value(manifest).unwrap(); legacy.as_object_mut().unwrap().remove("pendingFiles"); legacy.as_object_mut().unwrap().remove("projectName"); let manifest = serde_json::from_value(legacy).unwrap(); - let projection = item("user-1".into(), &manifest); + let projection = item("dev", "user-1".into(), &manifest); assert_eq!(projection.status, AdminProjectSnapshotStatus::Unverified); assert_eq!(projection.project_name, "project-1"); } + /// 后台“用户”列与素材查询同口径:能查到账号时给昵称与陶泥号,查不到时给占位作者。 + #[test] + fn project_snapshots_items_resolve_author_profile_for_admin_list() { + use crate::{ + config::AppConfig, + work_author::{ORPHAN_WORK_AUTHOR_DISPLAY_NAME, ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE}, + }; + + let state = AppState::new(AppConfig::default()).expect("state should build"); + state + .auth_user_service() + .ensure_orphan_work_owner_user("user-1", "user-1", "陶泥用户", "SY-00000007") + .expect("fixture user should be inserted"); + + let mut items = vec![ + item("dev", "user-1".to_string(), &manifest("project-1", &[])), + item( + "dev", + "user-missing".to_string(), + &manifest("project-2", &[]), + ), + ]; + attach_snapshot_authors(&state, &mut items); + + assert_eq!(items[0].author_display_name.as_deref(), Some("陶泥用户")); + assert_eq!( + items[0].author_public_user_code.as_deref(), + Some("SY-00000007") + ); + assert_eq!( + items[1].author_display_name.as_deref(), + Some(ORPHAN_WORK_AUTHOR_DISPLAY_NAME) + ); + assert_eq!( + items[1].author_public_user_code.as_deref(), + Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE) + ); + } + #[tokio::test] async fn project_snapshots_pagination_continues_directories_without_rescan() { let mut store = Store::default(); @@ -610,13 +736,14 @@ mod tests { .manifests .insert((user.into(), project.into()), manifest(project, &[])); } - let first = list_snapshots(&store, SnapshotCursor::decode(None).unwrap(), 1) + let first = list_snapshots(&store, SnapshotCursor::decode(None, "dev").unwrap(), 1) .await .unwrap(); assert_eq!(first.items[0].project_id, "project-a"); + assert_eq!(first.items[0].channel, "dev"); let second = list_snapshots( &store, - SnapshotCursor::decode(first.next_cursor.as_deref()).unwrap(), + SnapshotCursor::decode(first.next_cursor.as_deref(), "dev").unwrap(), 1, ) .await @@ -624,7 +751,7 @@ mod tests { assert_eq!(second.items[0].project_id, "project-b"); let third = list_snapshots( &store, - SnapshotCursor::decode(second.next_cursor.as_deref()).unwrap(), + SnapshotCursor::decode(second.next_cursor.as_deref(), "dev").unwrap(), 1, ) .await @@ -638,13 +765,17 @@ mod tests { (Some("user-a".into()), Some("project-a".into()), 1) ); assert_eq!(calls[3], (None, Some("user-a".into()), 1)); - assert!(SnapshotCursor::decode(Some("not-json")).is_err()); + assert!(SnapshotCursor::decode(Some("not-json"), "dev").is_err()); + // 游标不能跨渠道复用,否则目录推进会落在别的渠道上。 + let next = first.next_cursor.as_deref().expect("first page cursor"); + assert!(SnapshotCursor::decode(Some(next), "release").is_err()); let escaped = SnapshotCursor { + channel: "dev".into(), user: Some("../user".into()), ..SnapshotCursor::default() } .encode(); - assert!(SnapshotCursor::decode(Some(&escaped)).is_err()); + assert!(SnapshotCursor::decode(Some(&escaped), "dev").is_err()); } #[tokio::test] @@ -887,11 +1018,14 @@ mod tests { .timeout(Duration::from_secs(60)) .build() .unwrap(); + let channel = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL") + .unwrap_or_else(|_| "dev".to_string()); let store = OssSnapshotStore { oss: &oss, client: &client, + channel: channel.clone(), }; - let mut cursor = SnapshotCursor::decode(None).unwrap(); + let mut cursor = SnapshotCursor::decode(None, &channel).unwrap(); let mut projects = 0; let mut total_files = 0; let mut total_bytes = 0_u64; @@ -938,7 +1072,7 @@ mod tests { } let Some(next) = page.next_cursor else { break }; assert!(page_index < 99, "只读 smoke 已达到分页上限"); - cursor = SnapshotCursor::decode(Some(&next)).unwrap(); + cursor = SnapshotCursor::decode(Some(&next), &channel).unwrap(); } assert!(projects > 0, "真实 bucket 未发现项目清单"); eprintln!( diff --git a/server-rs/crates/api-server/src/admin_templates.rs b/server-rs/crates/api-server/src/admin_templates.rs index 18116eca0..4f1ebe813 100644 --- a/server-rs/crates/api-server/src/admin_templates.rs +++ b/server-rs/crates/api-server/src/admin_templates.rs @@ -2,21 +2,24 @@ use std::io::Cursor; use axum::{ Json, - extract::{Extension, Path, State}, + extract::{Extension, Multipart, Path, State}, http::{HeaderValue, StatusCode, header::CACHE_CONTROL}, response::{IntoResponse, Response}, }; use base64::{Engine as _, engine::general_purpose::STANDARD}; use module_assets::template_library::{ - TemplateCover, TemplateDomainError, TemplateEdit, list_templates, prepare_template_edit, + MAX_TEMPLATE_IMPORT_BATCH, TemplateCover, TemplateDomainError, TemplateEdit, TemplateImport, + list_templates, prepare_template_edit, prepare_template_import, }; use platform_oss::template_library::{TemplateLibraryStore, TemplateStoreError}; use serde_json::Value; use sha2::{Digest, Sha256}; use shared_contracts::admin::{ AdminAgcTemplateCoverInput, AdminAgcTemplateListResponse, AdminAgcTemplatePayload, - AdminUpdateAgcTemplateRequest, + AdminImportAgcTemplateItem, AdminImportAgcTemplateResult, AdminImportAgcTemplatesManifest, + AdminImportAgcTemplatesResponse, AdminUpdateAgcTemplateRequest, }; +use std::collections::{BTreeMap, BTreeSet}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ @@ -26,6 +29,13 @@ use crate::{ const MAX_COVER_BYTES: usize = 5 * 1024 * 1024; const MAX_DOCUMENT_BYTES: usize = 4 * 1024 * 1024; +/// 后台上传:单批数量、单包体积与解压后的容量上限(路由层再叠加请求体总上限)。 +const MAX_IMPORT_MANIFEST_BYTES: usize = 256 * 1024; +const MAX_IMPORT_ZIP_BYTES: usize = 64 * 1024 * 1024; +const MAX_IMPORT_ARCHIVE_FILES: usize = 4096; +const MAX_IMPORT_ARCHIVE_FILE_BYTES: u64 = 256 * 1024 * 1024; +const MAX_IMPORT_ARCHIVE_TOTAL_BYTES: u64 = 512 * 1024 * 1024; +pub(crate) const AGC_TEMPLATE_IMPORT_BODY_LIMIT_BYTES: usize = 200 * 1024 * 1024; const PUBLIC_BASE: &str = "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/"; fn fingerprint(bytes: &[u8]) -> String { @@ -70,7 +80,7 @@ fn snapshot(bytes: &[u8], writable: bool) -> Result Response { +fn json_snapshot(context: &RequestContext, value: T) -> Response { let mut response = json_success_body(Some(context), value).into_response(); response .headers_mut() @@ -108,6 +118,15 @@ fn validate_cover(input: AdminAgcTemplateCoverInput) -> Result, +) -> Result { + let invalid = |message| AppError::from_status(StatusCode::BAD_REQUEST).with_message(message); if bytes.is_empty() || bytes.len() > MAX_COVER_BYTES { return Err(invalid("封面内容为空或超过 5 MiB")); } @@ -118,7 +137,7 @@ fn validate_cover(input: AdminAgcTemplateCoverInput) -> Result ("image/webp", "webp"), _ => return Err(invalid("封面仅支持 PNG、JPEG 或 WebP")), }; - if input.content_type != content_type { + if declared_content_type != content_type { return Err(invalid("封面格式与文件内容不一致")); } let mut reader = image::ImageReader::with_format(Cursor::new(&bytes), format); @@ -287,6 +306,384 @@ async fn update_template( } } +fn import_invalid(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message) +} + +/// 模板包内路径:只允许库内相对路径,拒绝绝对路径、盘符、反斜杠、`..` 与空段。 +fn safe_import_archive_path(value: &str) -> Result { + let trimmed = value.trim_end_matches('/'); + if trimmed.is_empty() + || trimmed.len() > 256 + || trimmed.starts_with(['/', '\\']) + || trimmed.contains('\\') + || trimmed.contains(':') + || trimmed.contains("..") + || trimmed + .chars() + .any(|value| value.is_control() || value.is_whitespace()) + || !trimmed + .split('/') + .all(|part| !part.is_empty() && part != ".") + { + return Err(format!("模板包条目路径无效:{value}")); + } + Ok(trimmed.to_string()) +} + +/// 任何层级都不允许出现的条目段:项目身份(`.agent`)与版本库 / 依赖元数据。 +const IMPORT_FORBIDDEN_SEGMENTS: [&str; 4] = [".agent", ".git", ".svn", "node_modules"]; +/// 只允许出现在模板根目录之外的构建产物与编辑器工作区目录。 +const IMPORT_FORBIDDEN_ROOT_SEGMENTS: [&str; 7] = [ + "dist", "build", "library", "temp", "local", ".idea", ".vscode", +]; + +/// 模板作者约定(见 `docs/【模板规范】AGC模板包组织指南-2026-09-21.md`):这些内容 +/// 进了包就会跟着建到用户项目里,因此发布前失败关闭,而不是靠作者自觉。 +fn import_entry_path_violation(relative: &str) -> Option { + let segments = relative.split('/').collect::>(); + for segment in &segments { + if IMPORT_FORBIDDEN_SEGMENTS.contains(segment) { + return Some(format!("模板包不允许包含 {segment} 目录")); + } + } + if let Some(first) = segments.first() { + if IMPORT_FORBIDDEN_ROOT_SEGMENTS.contains(first) { + return Some(format!("模板包根目录不允许包含 {first} 目录")); + } + } + None +} + +/// 只做校验、不落盘:确认上传的是合法 zip、条目安全,并包含清单声明的 entry。 +fn validate_import_archive(bytes: &[u8], entry: &str) -> Result<(), String> { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)) + .map_err(|error| format!("模板包不是有效 zip:{error}"))?; + if archive.len() == 0 { + return Err("模板包没有文件".to_string()); + } + if archive.len() > MAX_IMPORT_ARCHIVE_FILES { + return Err(format!("模板包文件数量超过 {MAX_IMPORT_ARCHIVE_FILES}")); + } + let mut total_bytes = 0_u64; + let mut has_entry = false; + for index in 0..archive.len() { + let file = archive + .by_index(index) + .map_err(|error| format!("读取模板包条目失败:{error}"))?; + if file + .unix_mode() + .is_some_and(|mode| mode & 0o170000 == 0o120000) + { + return Err("模板包不允许包含符号链接".to_string()); + } + let relative = safe_import_archive_path(file.name())?; + if let Some(violation) = import_entry_path_violation(&relative) { + return Err(violation); + } + if relative == entry { + has_entry = true; + } + if file.is_dir() { + continue; + } + if file.size() > MAX_IMPORT_ARCHIVE_FILE_BYTES { + return Err(format!("模板包文件超过大小上限:{relative}")); + } + total_bytes = total_bytes.saturating_add(file.size()); + if total_bytes > MAX_IMPORT_ARCHIVE_TOTAL_BYTES { + return Err("模板包解压后总大小超过上限".to_string()); + } + } + if !has_entry { + return Err(format!("模板包缺少清单声明的 entry:{entry}")); + } + Ok(()) +} + +/// 上传封面按字节嗅探格式:浏览器给的 part content-type 常是 octet-stream,不能当判据。 +fn uploaded_cover_content_type(bytes: &[u8]) -> Result<&'static str, AppError> { + match image::guess_format(bytes) { + Ok(image::ImageFormat::Png) => Ok("image/png"), + Ok(image::ImageFormat::Jpeg) => Ok("image/jpeg"), + Ok(image::ImageFormat::WebP) => Ok("image/webp"), + _ => Err(import_invalid("封面仅支持 PNG、JPEG 或 WebP")), + } +} + +#[derive(Debug)] +struct PlannedImport { + import: TemplateImport, + zip_bytes: Vec, + cover_bytes: Vec, + cover_content_type: &'static str, +} + +fn plan_import_item( + item: AdminImportAgcTemplateItem, + files: &BTreeMap>, +) -> Result { + let zip_field = item.zip_field.trim().to_string(); + let cover_field = item.cover_field.trim().to_string(); + if zip_field.is_empty() || cover_field.is_empty() || zip_field == cover_field { + return Err(import_invalid(format!( + "{} 必须分别提供 ZIP 与封面的文件字段名", + item.id + ))); + } + let zip_bytes = files + .get(&zip_field) + .ok_or_else(|| import_invalid(format!("{} 缺少 ZIP 文件字段 {zip_field}", item.id)))? + .clone(); + if zip_bytes.len() > MAX_IMPORT_ZIP_BYTES { + return Err(import_invalid(format!( + "{} 的模板包超过 {} MiB", + item.id, + MAX_IMPORT_ZIP_BYTES / (1024 * 1024) + ))); + } + validate_import_archive(&zip_bytes, item.entry.trim()) + .map_err(|error| import_invalid(format!("{}:{error}", item.id)))?; + let cover_bytes = files + .get(&cover_field) + .ok_or_else(|| import_invalid(format!("{} 缺少封面文件字段 {cover_field}", item.id)))? + .clone(); + let cover_content_type = uploaded_cover_content_type(&cover_bytes)?; + let cover = validate_cover_bytes(cover_content_type, cover_bytes)?; + let zip_sha256 = fingerprint(&zip_bytes); + let cover_sha256 = fingerprint(&cover.bytes); + Ok(PlannedImport { + import: TemplateImport { + id: item.id.clone(), + title: item.title, + summary: item.summary, + tags: item.tags, + runtime: item.runtime, + engine: item.engine, + engine_version: item.engine_version, + template_version: item.template_version, + entry: item.entry.trim().to_string(), + zip_key: format!("templates/v1/{}/sha256/{zip_sha256}/template.zip", item.id), + zip_sha256, + zip_size_bytes: zip_bytes.len() as u64, + cover_key: format!( + "templates/v1/{}/sha256/{cover_sha256}/cover.{}", + item.id, cover.extension + ), + cover_sha256, + cover_width: cover.width, + cover_height: cover.height, + }, + zip_bytes, + cover_bytes: cover.bytes, + cover_content_type, + }) +} + +/// 校验整批上传:任一模板不合法都在任何写入之前整批拒绝。 +fn plan_import( + manifest_bytes: &[u8], + files: &BTreeMap>, +) -> Result<(String, Vec), AppError> { + let manifest: AdminImportAgcTemplatesManifest = serde_json::from_slice(manifest_bytes) + .map_err(|_| import_invalid("导入 manifest 不是有效 JSON 或包含未知字段"))?; + if manifest.templates.is_empty() { + return Err(import_invalid("导入批次不能为空")); + } + if manifest.templates.len() > MAX_TEMPLATE_IMPORT_BATCH { + return Err(import_invalid(format!( + "单批最多导入 {MAX_TEMPLATE_IMPORT_BATCH} 个模板" + ))); + } + if manifest.expected_revision.len() != 64 + || !manifest + .expected_revision + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err(import_invalid("模板库版本标识无效")); + } + let mut used_fields = BTreeSet::new(); + let mut planned = Vec::with_capacity(manifest.templates.len()); + for item in manifest.templates { + let fields = [item.zip_field.trim(), item.cover_field.trim()]; + for field in fields { + if !field.starts_with("zip_") && !field.starts_with("cover_") { + return Err(import_invalid(format!( + "{} 的文件字段名必须以 zip_ 或 cover_ 开头", + item.id + ))); + } + if !used_fields.insert(field.to_string()) { + return Err(import_invalid(format!("导入批次内文件字段名重复:{field}"))); + } + } + planned.push(plan_import_item(item, files)?); + } + for name in files.keys() { + if name != "manifest" && !used_fields.contains(name) { + return Err(import_invalid(format!("请求包含未引用的文件字段:{name}"))); + } + } + Ok((manifest.expected_revision, planned)) +} + +async fn read_import_field( + field: axum::extract::multipart::Field<'_>, + limit: usize, + name: &str, +) -> Result, AppError> { + let mut field = field; + let mut bytes = Vec::new(); + while let Some(chunk) = field + .chunk() + .await + .map_err(|_| import_invalid(format!("读取 {name} 失败")))? + { + if bytes.len().saturating_add(chunk.len()) > limit { + return Err(import_invalid(format!("{name} 超过允许的大小上限"))); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +pub async fn admin_import_agc_templates( + State(state): State, + Extension(context): Extension, + Extension(_admin): Extension, + mut multipart: Multipart, +) -> Result { + let store = state.template_library_store().cloned().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("模板管理未配置可用的存储凭据,当前仅支持查看") + })?; + let mut manifest_bytes: Option> = None; + let mut files: BTreeMap> = BTreeMap::new(); + while let Some(field) = multipart + .next_field() + .await + .map_err(|_| import_invalid("导入请求不是有效的 multipart 表单"))? + { + let name = field.name().unwrap_or_default().trim().to_string(); + let limit = match name.as_str() { + "manifest" => MAX_IMPORT_MANIFEST_BYTES, + name if name.starts_with("zip_") => MAX_IMPORT_ZIP_BYTES, + name if name.starts_with("cover_") => MAX_COVER_BYTES, + _ => return Err(import_invalid(format!("导入请求包含未知字段:{name}"))), + }; + let bytes = read_import_field(field, limit, &name).await?; + if name == "manifest" { + manifest_bytes = Some(bytes); + } else if files.insert(name.clone(), bytes).is_some() { + return Err(import_invalid(format!("导入请求包含重复字段:{name}"))); + } + } + let manifest_bytes = + manifest_bytes.ok_or_else(|| import_invalid("导入请求缺少 manifest 字段"))?; + let (expected_revision, planned) = plan_import(&manifest_bytes, &files)?; + // 接受后的写入由独立任务持有,HTTP 断连不能在清单 PUT 在途时提前解锁。 + let result = + tokio::spawn(async move { import_templates(store, expected_revision, planned).await }) + .await + .map_err(|_| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("模板导入结果需要核对,请刷新列表;若发布锁仍被占用请联系运维") + })??; + Ok(json_snapshot(&context, result)) +} + +async fn import_templates( + store: TemplateLibraryStore, + expected_revision: String, + planned: Vec, +) -> Result { + let mut session = store + .begin_publish(uuid::Uuid::new_v4().to_string()) + .await + .map_err(map_store_error)?; + let outcome = async { + let current = session.read_index().await.map_err(map_store_error)?; + check_revision(¤t, &expected_revision)?; + let index = decode_index(¤t)?; + let updated_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))?; + let mut prepared = prepare_template_import( + index, + planned.iter().map(|item| item.import.clone()).collect(), + &updated_at, + ) + .map_err(map_domain_error)?; + for item in &planned { + let metadata = prepared + .metadata + .iter() + .find(|(id, _)| id == &item.import.id) + .map(|(_, value)| value.clone()) + .ok_or_else(|| import_invalid("导入准备结果缺少元数据"))?; + let metadata_bytes = document_bytes(&metadata)?; + let metadata_key = format!( + "templates/v1/{}/sha256/{}/template.json", + item.import.id, + fingerprint(&metadata_bytes) + ); + prepared + .set_metadata_key(&item.import.id, &metadata_key) + .map_err(map_domain_error)?; + session + .put_immutable( + &item.import.zip_key, + item.zip_bytes.clone(), + "application/zip", + ) + .await + .map_err(map_store_error)?; + session + .put_immutable( + &item.import.cover_key, + item.cover_bytes.clone(), + item.cover_content_type, + ) + .await + .map_err(map_store_error)?; + session + .put_immutable(&metadata_key, metadata_bytes, "application/json") + .await + .map_err(map_store_error)?; + } + let next_index_bytes = document_bytes(&prepared.index)?; + session + .commit_index(next_index_bytes.clone()) + .await + .map_err(map_store_error)?; + let snapshot = snapshot(&next_index_bytes, true)?; + let imported = planned + .iter() + .map(|item| AdminImportAgcTemplateResult { + id: item.import.id.clone(), + template_version: item.import.template_version.clone(), + zip_size_bytes: item.import.zip_size_bytes, + zip_sha256: item.import.zip_sha256.clone(), + reused_objects: prepared.reused.contains(&item.import.id), + }) + .collect::>(); + Ok::<_, AppError>((snapshot, imported)) + } + .await; + let released = session.finish().await.map_err(map_store_error); + match (outcome, released) { + (Ok((snapshot, imported)), Ok(())) => Ok(AdminImportAgcTemplatesResponse { + revision: snapshot.revision, + writable: snapshot.writable, + templates: snapshot.templates, + imported, + }), + (Err(error), Ok(())) => Err(error), + (_, Err(error)) => Err(error), + } +} + fn map_domain_error(error: TemplateDomainError) -> AppError { match error { TemplateDomainError::InvalidEdit(message) => { @@ -334,6 +731,225 @@ mod tests { } } + fn png_bytes() -> Vec { + let image = image::DynamicImage::new_rgb8(4, 3); + let mut bytes = Cursor::new(Vec::new()); + image + .write_to(&mut bytes, image::ImageFormat::Png) + .expect("PNG"); + bytes.into_inner() + } + + fn archive(entries: &[(&str, &[u8], Option)]) -> Vec { + use std::io::Write; + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, bytes, mode) in entries { + let mut options = zip::write::SimpleFileOptions::default(); + if let Some(mode) = mode { + options = options.unix_permissions(*mode); + } + if name.ends_with('/') { + writer.add_directory(*name, options).expect("directory"); + continue; + } + writer.start_file(*name, options).expect("start file"); + writer.write_all(bytes).expect("write file"); + } + writer.finish().expect("finish archive").into_inner() + } + + fn import_item() -> AdminImportAgcTemplateItem { + AdminImportAgcTemplateItem { + id: "smoke-import".to_string(), + title: " 冒烟模板 ".to_string(), + summary: "简介".to_string(), + tags: vec!["smoke".to_string()], + runtime: "html".to_string(), + engine: "none".to_string(), + engine_version: String::new(), + template_version: "0.1.0".to_string(), + entry: "index.html".to_string(), + zip_field: "zip_0".to_string(), + cover_field: "cover_0".to_string(), + } + } + + fn import_files() -> BTreeMap> { + let mut files = BTreeMap::new(); + files.insert( + "zip_0".to_string(), + archive(&[("index.html", b"", None)]), + ); + files.insert("cover_0".to_string(), png_bytes()); + files + } + + fn import_manifest(item: AdminImportAgcTemplateItem) -> Vec { + serde_json::to_vec(&AdminImportAgcTemplatesManifest { + expected_revision: "a".repeat(64), + templates: vec![item], + }) + .expect("manifest json") + } + + #[test] + fn template_import_archive_validation_fails_closed() { + let ok = archive(&[ + ("game/index.html", b"", None), + ("game/app.js", b"console.log(1)", None), + ]); + assert!(validate_import_archive(&ok, "game/index.html").is_ok()); + assert!( + validate_import_archive(&ok, "game/missing.html") + .expect_err("missing entry") + .contains("缺少清单声明的 entry") + ); + + let traversal = archive(&[("../escape.html", b"x", None)]); + assert!(validate_import_archive(&traversal, "../escape.html").is_err()); + + let symlink = { + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + writer + .add_symlink( + "link.html", + "index.html", + zip::write::SimpleFileOptions::default(), + ) + .expect("symlink entry"); + writer.finish().expect("finish archive").into_inner() + }; + assert!( + validate_import_archive(&symlink, "link.html") + .expect_err("symlink") + .contains("符号链接") + ); + } + + #[test] + fn template_import_archive_rejects_identity_and_build_directories() { + let forbidden = [ + (".agent/manifest.json", "模板包不允许包含 .agent 目录"), + ("game/.agent/ledger.json", "模板包不允许包含 .agent 目录"), + (".git/config", "模板包不允许包含 .git 目录"), + (".svn/entries", "模板包不允许包含 .svn 目录"), + ( + "node_modules/three/package.json", + "模板包不允许包含 node_modules 目录", + ), + ( + "assets/node_modules/keep.txt", + "模板包不允许包含 node_modules 目录", + ), + ("dist/game.js", "模板包根目录不允许包含 dist 目录"), + ("build/index.html", "模板包根目录不允许包含 build 目录"), + ("library/import.json", "模板包根目录不允许包含 library 目录"), + ("temp/asset.json", "模板包根目录不允许包含 temp 目录"), + ("local/settings.json", "模板包根目录不允许包含 local 目录"), + ( + ".vscode/settings.json", + "模板包根目录不允许包含 .vscode 目录", + ), + (".idea/misc.xml", "模板包根目录不允许包含 .idea 目录"), + ]; + for (name, expected) in forbidden { + let bytes = archive(&[ + ("game/index.html", b"", None), + (name, b"x", None), + ]); + assert_eq!( + validate_import_archive(&bytes, "game/index.html").expect_err(name), + expected, + "条目 {name} 必须被拒绝" + ); + } + + // 同名目录段只在根目录受限:正文内 `game/dist/**` 是模板自身内容,`.gitignore` 也不是 `.git`。 + let nested = archive(&[ + ("game/index.html", b"", None), + ("game/dist/app.js", b"x", None), + ("game/.gitignore", b"node_modules\n", None), + ]); + assert!(validate_import_archive(&nested, "game/index.html").is_ok()); + } + + #[test] + fn template_import_plan_uses_content_addressed_keys_and_sniffed_cover() { + let files = import_files(); + let (revision, planned) = + plan_import(&import_manifest(import_item()), &files).expect("plan import"); + + assert_eq!(revision, "a".repeat(64)); + let item = &planned[0]; + assert!( + item.import + .zip_key + .starts_with("templates/v1/smoke-import/sha256/") + ); + assert!(item.import.zip_key.ends_with("/template.zip")); + assert_eq!(item.import.zip_sha256, fingerprint(&files["zip_0"])); + assert_eq!(item.import.zip_size_bytes, files["zip_0"].len() as u64); + assert_eq!(item.cover_content_type, "image/png"); + assert!(item.import.cover_key.ends_with("/cover.png")); + assert_eq!((item.import.cover_width, item.import.cover_height), (4, 3)); + } + + #[test] + fn template_import_plan_rejects_unknown_and_unreferenced_fields() { + let manifest = import_manifest(import_item()); + let files = import_files(); + assert!(plan_import(&manifest, &files).is_ok()); + + let mut unreferenced = files.clone(); + unreferenced.insert("zip_1".to_string(), b"stray".to_vec()); + let error = plan_import(&manifest, &unreferenced).expect_err("unreferenced file field"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!( + error.message().contains("未引用的文件字段"), + "{}", + error.message() + ); + + let mut missing_zip = files.clone(); + missing_zip.remove("zip_0"); + let error = plan_import(&manifest, &missing_zip).expect_err("missing zip field"); + assert!( + error.message().contains("缺少 ZIP 文件字段"), + "{}", + error.message() + ); + + let mut wrong_prefix = import_item(); + wrong_prefix.zip_field = "payload_0".to_string(); + let error = plan_import(&import_manifest(wrong_prefix), &files) + .expect_err("field prefix must be explicit"); + assert!( + error.message().contains("必须以 zip_ 或 cover_ 开头"), + "{}", + error.message() + ); + + let unknown_field = serde_json::to_vec(&serde_json::json!({ + "expectedRevision": "a".repeat(64), + "templates": [], + "extra": 1, + })) + .expect("json"); + assert!(plan_import(&unknown_field, &files).is_err()); + + let broken_archive = { + let mut files = files.clone(); + files.insert("zip_0".to_string(), b"not a zip".to_vec()); + files + }; + let error = plan_import(&manifest, &broken_archive).expect_err("invalid zip"); + assert!( + error.message().contains("不是有效 zip"), + "{}", + error.message() + ); + } + #[test] fn template_cover_validates_actual_bytes_and_dimensions() { let cover = validate_cover(png_input()).expect("valid PNG"); diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index b3ff4c366..6bf7edc92 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -92,6 +92,8 @@ pub struct AppConfig { pub editor_bgfilter_circuit_cooldown: Duration, pub image_editor_agent_sidebar_enabled: bool, pub client_download_channel: String, + /// AGC 项目快照的部署渠道:上传与后台默认查询都按它分区。 + pub project_snapshot_channel: String, pub log_filter: String, pub otel_enabled: bool, pub admin_username: Option, @@ -402,6 +404,7 @@ impl Default for AppConfig { ), image_editor_agent_sidebar_enabled: false, client_download_channel: "dev".to_string(), + project_snapshot_channel: "dev".to_string(), log_filter: "info,tower_http=info".to_string(), otel_enabled: false, admin_username: None, @@ -724,6 +727,12 @@ impl AppConfig { // 显式空值或非法值也保留,由下载入口失败关闭,不能悄悄改读 dev。 config.client_download_channel = channel.trim().to_string(); } + // 快照渠道缺省沿用同一个部署渠道(本部署的客户端渠道),显式配置优先; + // 显式空值或非法值同样保留,由快照入口失败关闭。 + config.project_snapshot_channel = config.client_download_channel.clone(); + if let Ok(channel) = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL") { + config.project_snapshot_channel = channel.trim().to_string(); + } if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"]) { @@ -3092,6 +3101,43 @@ mod tests { } } + #[test] + fn project_snapshot_channel_follows_client_download_channel_unless_overridden() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock"); + let previous_snapshot = std::env::var_os("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL"); + let previous_download = std::env::var_os("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"); + unsafe { + std::env::remove_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL"); + std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"); + } + // 未显式配置快照渠道时跟随部署的客户端渠道,缺省是 dev。 + assert_eq!(AppConfig::from_env().project_snapshot_channel, "dev"); + unsafe { + std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", "release"); + } + assert_eq!(AppConfig::from_env().project_snapshot_channel, "release"); + // 显式配置优先;空值与非法值同样保留,由快照入口失败关闭。 + for (value, expected) in [(" qa-2026 ", "qa-2026"), ("", ""), ("Dev", "Dev")] { + unsafe { + std::env::set_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL", value); + } + assert_eq!(AppConfig::from_env().project_snapshot_channel, expected); + } + unsafe { + match previous_snapshot { + Some(value) => std::env::set_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL", value), + None => std::env::remove_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL"), + } + match previous_download { + Some(value) => std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", value), + None => std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"), + } + } + } + #[test] fn from_env_reads_prefixed_character_animation_ffmpeg_paths() { let _guard = ENV_LOCK diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index fd418e6e7..e16b4a82b 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -43,6 +43,10 @@ pub fn router(state: AppState) -> Router { "/admin/api/project-snapshots", get(crate::admin_project_snapshots::admin_list_project_snapshots), ), + ( + "/admin/api/project-snapshots/channels", + get(crate::admin_project_snapshots::admin_list_project_snapshot_channels), + ), ( "/admin/api/project-snapshots/{user_id}/{project_id}/download", get(crate::admin_project_snapshots::admin_download_project_snapshot), @@ -56,6 +60,14 @@ pub fn router(state: AppState) -> Router { axum::routing::put(crate::admin_templates::admin_update_agc_template) .layer(axum::extract::DefaultBodyLimit::max(8 * 1024 * 1024)), ), + ( + "/admin/api/agc-templates/import", + axum::routing::post(crate::admin_templates::admin_import_agc_templates).layer( + axum::extract::DefaultBodyLimit::max( + crate::admin_templates::AGC_TEMPLATE_IMPORT_BODY_LIMIT_BYTES, + ), + ), + ), ( "/admin/api/agc-models", get(crate::agc_models::admin_get_agc_models) @@ -222,12 +234,14 @@ mod route_contract_tests { const PROTECTED_ROUTES: &[(&str, &[&str])] = &[ ("/admin/api/project-snapshots", &["GET"]), + ("/admin/api/project-snapshots/channels", &["GET"]), ( "/admin/api/project-snapshots/{user_id}/{project_id}/download", &["GET"], ), ("/admin/api/agc-templates", &["GET"]), ("/admin/api/agc-templates/{id}", &["PUT"]), + ("/admin/api/agc-templates/import", &["POST"]), ("/admin/api/agc-models", &["GET", "PUT"]), ("/admin/api/accounts", &["GET", "POST"]), ("/admin/api/accounts/{account_id}", &["PUT"]), diff --git a/server-rs/crates/api-server/src/project_snapshots.rs b/server-rs/crates/api-server/src/project_snapshots.rs index 32a12fa21..6125328a7 100644 --- a/server-rs/crates/api-server/src/project_snapshots.rs +++ b/server-rs/crates/api-server/src/project_snapshots.rs @@ -16,6 +16,7 @@ use axum::{ use platform_oss::{ OssDeleteObjectRequest, OssGetObjectRequest, OssInternalPutObjectRequest, OssObjectAccess, agc_project_snapshot_file_object_key, agc_project_snapshot_manifest_object_key, + validate_agc_project_snapshot_channel, }; use serde_json::Value; use shared_contracts::agc_project_snapshots::{ @@ -67,7 +68,9 @@ pub async fn upload_project_snapshot_file( .strip_prefix("fnv1a64:") .unwrap_or_default() .to_string(); + let channel = project_snapshot_channel(&state)?; let object_key = agc_project_snapshot_file_object_key( + &channel, auth.claims().user_id(), &query.project_id, size_bytes, @@ -146,8 +149,10 @@ pub async fn upload_project_snapshot_manifest( consume_user_upload_quota(auth.claims().user_id(), ProjectSnapshotUploadKind::Manifest)?; validate_manifest(&payload)?; let user_id = auth.claims().user_id().to_string(); - let object_key = agc_project_snapshot_manifest_object_key(&user_id, &payload.project_id) - .map_err(|error| bad_request(error.to_string()))?; + let channel = project_snapshot_channel(&state)?; + let object_key = + agc_project_snapshot_manifest_object_key(&channel, &user_id, &payload.project_id) + .map_err(|error| bad_request(error.to_string()))?; // 上一版清单同时承担两个职责:项目级写入频率闸门,以及本轮远端对象回收的引用基线。 // 读不到或解析失败时只跳过回收,绝不据此删除任何对象。 let previous = read_project_snapshot_manifest(&state, &object_key).await; @@ -181,7 +186,8 @@ pub async fn upload_project_snapshot_manifest( })?; // 清单写入成功之后再回收:任何时刻远端对象集合都是当前清单的超集, // 不会出现清单引用了刚被删掉的对象。 - reclaim_unreferenced_objects(&state, oss, &user_id, previous.as_ref(), &payload).await; + reclaim_unreferenced_objects(&state, oss, &channel, &user_id, previous.as_ref(), &payload) + .await; Ok(json_success_body( Some(&ctx), @@ -293,6 +299,7 @@ async fn read_project_snapshot_manifest( async fn reclaim_unreferenced_objects( state: &AppState, oss: &platform_oss::OssClient, + channel: &str, user_id: &str, previous: Option<&AgcProjectSnapshotManifestRequest>, next: &AgcProjectSnapshotManifestRequest, @@ -330,6 +337,7 @@ async fn reclaim_unreferenced_objects( continue; }; let Ok(object_key) = agc_project_snapshot_file_object_key( + channel, user_id, &previous.project_id, file.size_bytes, @@ -433,6 +441,15 @@ pub(crate) fn project_snapshot_oss(state: &AppState) -> Result<&platform_oss::Os }) } +/// 本部署的快照渠道:配置文件缺省跟随客户端下载渠道,显式配置优先。 +/// 渠道名非法时失败关闭(503),不静默改写到其它渠道。 +pub(crate) fn project_snapshot_channel(state: &AppState) -> Result { + validate_agc_project_snapshot_channel(&state.config.project_snapshot_channel).map_err(|_| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("AGC 项目快照渠道配置无效") + }) +} + fn bad_request(m: impl Into) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_message(m) } @@ -567,4 +584,26 @@ mod tests { consume_user_upload_quota(user, ProjectSnapshotUploadKind::Manifest) .expect("清单配额独立计数"); } + + /// 渠道决定对象键的第一层目录:非法配置必须失败关闭,不能落到别的渠道。 + #[test] + fn project_snapshot_channel_fails_closed_on_invalid_configuration() { + use crate::{config::AppConfig, state::AppState}; + + let mut config = AppConfig::default(); + config.project_snapshot_channel = "release".to_string(); + let state = AppState::new(config).expect("state should build"); + assert_eq!( + project_snapshot_channel(&state).expect("valid channel"), + "release" + ); + + for invalid in ["", " Dev", "dev/2", "dev-"] { + let mut config = AppConfig::default(); + config.project_snapshot_channel = invalid.to_string(); + let state = AppState::new(config).expect("state should build"); + let error = project_snapshot_channel(&state).expect_err("非法渠道必须失败关闭"); + assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE); + } + } } diff --git a/server-rs/crates/module-assets/src/template_library.rs b/server-rs/crates/module-assets/src/template_library.rs index 37a6d841b..b1b5e71b2 100644 --- a/server-rs/crates/module-assets/src/template_library.rs +++ b/server-rs/crates/module-assets/src/template_library.rs @@ -71,6 +71,40 @@ pub struct PreparedTemplateEdit { pub metadata: Value, } +/// 单批导入的模板数量上限:更大的批量请走 CLI(`--only` 定向发布)。 +pub const MAX_TEMPLATE_IMPORT_BATCH: usize = 20; +/// 后台上传允许的运行时(与 CLI 的 `RUNTIMES` 保持一致)。 +pub const TEMPLATE_IMPORT_RUNTIMES: [&str; 4] = ["html", "unity", "godot", "cocos"]; + +/// 后台导入的单条模板:内容对象键与字节摘要由存储适配器按上传字节算出。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TemplateImport { + pub id: String, + pub title: String, + pub summary: String, + pub tags: Vec, + pub runtime: String, + pub engine: String, + pub engine_version: String, + pub template_version: String, + pub entry: String, + pub zip_key: String, + pub zip_sha256: String, + pub zip_size_bytes: u64, + pub cover_key: String, + pub cover_sha256: String, + pub cover_width: u32, + pub cover_height: u32, +} + +/// 一批导入的合并结果:待提交清单、每个模板的元数据字节,以及按内容复用旧对象的 ID。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PreparedTemplateImport { + pub index: Value, + pub metadata: Vec<(String, Value)>, + pub reused: Vec, +} + fn string_field<'a>(entry: &'a Value, field: &str) -> Result<&'a str, TemplateDomainError> { entry .get(field) @@ -375,6 +409,295 @@ pub fn prepare_template_edit( Ok(PreparedTemplateEdit { index, metadata }) } +fn valid_import_entry_path(value: &str) -> bool { + let trimmed = value.trim(); + !trimmed.is_empty() + && trimmed.len() <= 256 + && !trimmed.starts_with(['/', '\\']) + && !trimmed.contains('\\') + && !trimmed.contains(':') + && !trimmed.contains("..") + && !trimmed + .chars() + .any(|value| value.is_control() || value.is_whitespace()) + && trimmed + .split('/') + .all(|part| !part.is_empty() && part != ".") +} + +fn valid_import_object_key(key: &str, id: &str, sha256: &str, suffix: &str) -> bool { + valid_object_key(key) + && valid_hash(sha256) + && key.starts_with(&format!("templates/v1/{id}/sha256/")) + && key.ends_with(&format!("/{suffix}")) +} + +fn validate_import(import: &TemplateImport) -> Result<(), TemplateDomainError> { + if !valid_identifier(&import.id, 64) { + return Err(TemplateDomainError::InvalidEdit(format!( + "模板 ID 无效:{}", + import.id + ))); + } + if !valid_identifier(&import.template_version, 32) { + return Err(TemplateDomainError::InvalidEdit(format!( + "{} 的 templateVersion 无效", + import.id + ))); + } + if !TEMPLATE_IMPORT_RUNTIMES.contains(&import.runtime.as_str()) { + return Err(TemplateDomainError::InvalidEdit(format!( + "{} 的 runtime 不在允许列表内", + import.id + ))); + } + if !valid_import_entry_path(&import.entry) { + return Err(TemplateDomainError::InvalidEdit(format!( + "{} 的 entry 不是库内相对路径", + import.id + ))); + } + if import.zip_size_bytes == 0 + || !valid_import_object_key( + &import.zip_key, + &import.id, + &import.zip_sha256, + "template.zip", + ) + { + return Err(TemplateDomainError::InvalidEdit(format!( + "{} 的 ZIP 对象键或摘要无效", + import.id + ))); + } + let cover_extension = import + .cover_key + .rsplit('/') + .next() + .and_then(|name| name.rsplit_once('.')) + .map(|(_, extension)| extension.to_ascii_lowercase()) + .unwrap_or_default(); + if !matches!( + cover_extension.as_str(), + "png" | "jpg" | "jpeg" | "webp" | "svg" + ) || !valid_import_object_key( + &import.cover_key, + &import.id, + &import.cover_sha256, + &format!("cover.{cover_extension}"), + ) || import.cover_width == 0 + || import.cover_height == 0 + || import.cover_width > 4096 + || import.cover_height > 4096 + || u64::from(import.cover_width) * u64::from(import.cover_height) > 16_000_000 + { + return Err(TemplateDomainError::InvalidEdit(format!( + "{} 的封面引用或尺寸无效", + import.id + ))); + } + // 展示字段沿用编辑路径的同一套上限与去重规则。 + normalize_edit(TemplateEdit { + title: import.title.clone(), + summary: import.summary.clone(), + tags: import.tags.clone(), + enabled: true, + })?; + Ok(()) +} + +fn import_entry_json(import: &TemplateImport, updated_at: &str) -> Value { + json!({ + "id": import.id, + "title": import.title.trim(), + "summary": import.summary.trim(), + "tags": import.tags, + "runtime": import.runtime, + "engine": import.engine.trim(), + "engineVersion": import.engine_version.trim(), + "templateVersion": import.template_version, + "updatedAt": updated_at, + "entry": import.entry.trim(), + "zipKey": import.zip_key, + "zipSizeBytes": import.zip_size_bytes, + "zipSha256": import.zip_sha256, + "coverKey": import.cover_key, + "coverWidth": import.cover_width, + "coverHeight": import.cover_height, + "coverSha256": import.cover_sha256, + }) +} + +fn import_metadata_json(import: &TemplateImport, updated_at: &str) -> Value { + json!({ + "schemaVersion": "agc-template.v1", + "id": import.id, + "title": import.title.trim(), + "summary": import.summary.trim(), + "tags": import.tags, + "runtime": import.runtime, + "engine": import.engine.trim(), + "engineVersion": import.engine_version.trim(), + "templateVersion": import.template_version, + "updatedAt": updated_at, + "entry": import.entry.trim(), + "zip": { + "key": import.zip_key, + "sizeBytes": import.zip_size_bytes, + "sha256": import.zip_sha256, + }, + "cover": { + "key": import.cover_key, + "width": import.cover_width, + "height": import.cover_height, + "sha256": import.cover_sha256, + }, + }) +} + +/// 合并一批后台导入:新增 ID 默认上架,已存在条目就地更新并保留上架状态与未知扩展字段。 +/// +/// 同一 ID、同一 `templateVersion` 的 ZIP 字节不同时拒绝(与 CLI 发布门禁同一句文案); +/// 字节完全一致时按内容复用既有对象,只更新展示字段与元数据。整批在同一把发布锁内提交, +/// 因此本函数只产出待提交快照,不做任何写入。 +pub fn prepare_template_import( + mut index: Value, + imports: Vec, + updated_at: &str, +) -> Result { + if imports.is_empty() { + return Err(TemplateDomainError::InvalidEdit( + "导入批次不能为空".to_owned(), + )); + } + if imports.len() > MAX_TEMPLATE_IMPORT_BATCH { + return Err(TemplateDomainError::InvalidEdit(format!( + "单批最多导入 {MAX_TEMPLATE_IMPORT_BATCH} 个模板" + ))); + } + if updated_at.trim().is_empty() { + return Err(TemplateDomainError::InvalidEdit( + "模板更新时间无效".to_owned(), + )); + } + let mut batch_ids = BTreeSet::new(); + for import in &imports { + validate_import(import)?; + if !batch_ids.insert(import.id.clone()) { + return Err(TemplateDomainError::InvalidEdit(format!( + "同一批次出现重复模板 ID:{}", + import.id + ))); + } + } + + let current = list_templates(&index)?; + let mut reused = Vec::new(); + let mut metadata = Vec::with_capacity(imports.len()); + for mut import in imports { + // 展示字段统一走编辑路径的同一套归一化(trim + 标签去重), + // 清单与 template.json 必须写入归一化后的值,否则两端会不一致。 + let normalized = normalize_edit(TemplateEdit { + title: import.title.clone(), + summary: import.summary.clone(), + tags: import.tags.clone(), + enabled: true, + })?; + import.title = normalized.title; + import.summary = normalized.summary; + import.tags = normalized.tags; + let existing = current.iter().find(|entry| entry.id == import.id); + let mut position = None; + for (group, _) in GROUPS { + let Some(entries) = index.get(group).and_then(Value::as_array) else { + continue; + }; + if let Some(index_in_group) = entries.iter().position(|entry| { + entry.get("id").and_then(Value::as_str) == Some(import.id.as_str()) + }) { + position = Some((group, index_in_group)); + break; + } + } + + match (existing, position) { + (Some(existing), Some((group, index_in_group))) => { + if existing.template_version == import.template_version { + let same_bytes = existing.zip_size_bytes == import.zip_size_bytes + && existing.zip_sha256.eq_ignore_ascii_case(&import.zip_sha256); + if !same_bytes { + return Err(TemplateDomainError::InvalidEdit(format!( + "{}@{} 同版本 ZIP 内容或尺寸变化,请递增 templateVersion", + import.id, import.template_version + ))); + } + reused.push(import.id.clone()); + } + // 以既有条目为底:未知扩展字段与 enabled 分组原样保留。 + let mut entry = index[group][index_in_group].clone(); + let object = entry + .as_object_mut() + .ok_or(TemplateDomainError::InvalidIndex)?; + for (field, value) in import_entry_json(&import, updated_at) + .as_object() + .ok_or(TemplateDomainError::InvalidIndex)? + .iter() + { + object.insert(field.clone(), value.clone()); + } + index[group][index_in_group] = entry; + } + (None, None) => { + index + .as_object_mut() + .ok_or(TemplateDomainError::InvalidIndex)? + .entry("templates") + .or_insert_with(|| json!([])) + .as_array_mut() + .ok_or(TemplateDomainError::InvalidIndex)? + .push(import_entry_json(&import, updated_at)); + } + _ => return Err(TemplateDomainError::InvalidIndex), + } + metadata.push((import.id.clone(), import_metadata_json(&import, updated_at))); + } + index["updatedAt"] = json!(updated_at); + Ok(PreparedTemplateImport { + index, + metadata, + reused, + }) +} + +impl PreparedTemplateImport { + /// 元数据字节摘要由存储适配器计算后,回填每个导入条目的 `metadataKey`。 + pub fn set_metadata_key(&mut self, id: &str, key: &str) -> Result<(), TemplateDomainError> { + if !valid_object_key(key) { + return Err(TemplateDomainError::InvalidMetadata); + } + let mut found = false; + for (group, _) in GROUPS { + let Some(entries) = self.index.get_mut(group).and_then(Value::as_array_mut) else { + continue; + }; + for entry in entries.iter_mut() { + if entry.get("id").and_then(Value::as_str) != Some(id) { + continue; + } + entry + .as_object_mut() + .ok_or(TemplateDomainError::InvalidIndex)? + .insert("metadataKey".to_owned(), json!(key)); + found = true; + } + } + if !found { + return Err(TemplateDomainError::NotFound); + } + Ok(()) + } +} + impl PreparedTemplateEdit { /// 元数据字节摘要由存储适配器计算后,回填唯一被编辑条目的对象键。 pub fn set_metadata_key(&mut self, id: &str, key: &str) -> Result<(), TemplateDomainError> { @@ -433,6 +756,222 @@ mod tests { "files": [{"path": "package.json", "sizeBytes": 10, "sha256": "c".repeat(64)}], "unknown": {"keep": true}}) } + fn import(id: &str, version: &str, zip_sha: &str, size: u64) -> TemplateImport { + TemplateImport { + id: id.to_owned(), + title: " 新模板 ".to_owned(), + summary: " 新简介 ".to_owned(), + tags: vec![" cocos ".to_owned(), "cocos".to_owned(), "二维".to_owned()], + runtime: "cocos".to_owned(), + engine: "cocos-creator".to_owned(), + engine_version: "3.8.8".to_owned(), + template_version: version.to_owned(), + entry: "package.json".to_owned(), + zip_key: format!("templates/v1/{id}/sha256/{zip_sha}/template.zip"), + zip_sha256: zip_sha.to_owned(), + zip_size_bytes: size, + cover_key: format!("templates/v1/{id}/sha256/{}/cover.svg", "b".repeat(64)), + cover_sha256: "b".repeat(64), + cover_width: 960, + cover_height: 540, + } + } + + #[test] + fn template_import_appends_new_entries_with_metadata() { + let prepared = prepare_template_import( + index(), + vec![import("d", "0.1.0", &"d".repeat(64), 42)], + "2026-09-21T00:00:00Z", + ) + .expect("import new template"); + + let ids = prepared.index["templates"] + .as_array() + .expect("templates") + .iter() + .map(|entry| entry["id"].as_str().expect("id").to_owned()) + .collect::>(); + assert_eq!(ids, vec!["b", "a", "d"]); + assert!(prepared.reused.is_empty()); + assert_eq!( + prepared.index["inactiveTemplates"].as_array().map(Vec::len), + Some(1) + ); + assert_eq!(prepared.index["updatedAt"], json!("2026-09-21T00:00:00Z")); + + let imported = prepared.index["templates"] + .as_array() + .expect("templates") + .iter() + .find(|entry| entry["id"] == json!("d")) + .expect("imported entry"); + assert_eq!(imported["title"], json!("新模板")); + assert_eq!(imported["tags"], json!(["cocos", "二维"])); + assert_eq!(imported["zipSizeBytes"], json!(42)); + assert_eq!( + imported["zipKey"], + json!(format!( + "templates/v1/d/sha256/{}/template.zip", + "d".repeat(64) + )) + ); + + let (id, metadata) = prepared.metadata.first().expect("metadata"); + assert_eq!(id, "d"); + assert_eq!(metadata["schemaVersion"], json!("agc-template.v1")); + assert_eq!(metadata["templateVersion"], json!("0.1.0")); + assert_eq!(metadata["zip"]["sizeBytes"], json!(42)); + assert_eq!(metadata["zip"]["sha256"], json!("d".repeat(64))); + assert_eq!(metadata["cover"]["width"], json!(960)); + } + + #[test] + fn template_import_keeps_inactive_group_and_unknown_fields() { + let prepared = prepare_template_import( + index(), + vec![import("c", "0.2.0", &"e".repeat(64), 7)], + "2026-09-21T00:00:00Z", + ) + .expect("import new version of an inactive template"); + + assert_eq!( + prepared.index["inactiveTemplates"].as_array().map(Vec::len), + Some(1) + ); + let entry = &prepared.index["inactiveTemplates"][0]; + assert_eq!(entry["id"], json!("c")); + assert_eq!(entry["templateVersion"], json!("0.2.0")); + assert_eq!(entry["extension"], json!({"keep": true})); + assert_eq!(entry["metadataKey"], json!("templates/v1/c/template.json")); + } + + #[test] + fn template_import_rejects_same_version_with_different_bytes() { + let error = prepare_template_import( + index(), + vec![import("a", "0.1.0", &"f".repeat(64), 10)], + "2026-09-21T00:00:00Z", + ) + .expect_err("same version must keep identical bytes"); + assert!( + error + .to_string() + .contains("同版本 ZIP 内容或尺寸变化,请递增 templateVersion"), + "{error}" + ); + } + + #[test] + fn template_import_reuses_identical_bytes_for_the_same_version() { + let prepared = prepare_template_import( + index(), + vec![import("a", "0.1.0", &"a".repeat(64), 10)], + "2026-09-21T00:00:00Z", + ) + .expect("identical bytes are idempotent"); + + assert_eq!(prepared.reused, vec!["a".to_owned()]); + let entry = prepared.index["templates"] + .as_array() + .expect("templates") + .iter() + .find(|entry| entry["id"] == json!("a")) + .expect("entry"); + assert_eq!(entry["title"], json!("新模板")); + assert_eq!(entry["extension"], json!({"keep": true})); + } + + #[test] + fn template_import_rejects_invalid_batches_before_any_write() { + let cases: Vec<(Vec, &str)> = vec![ + (vec![], "导入批次不能为空"), + ( + (0..=MAX_TEMPLATE_IMPORT_BATCH) + .map(|index| { + import(&format!("t{index}"), "0.1.0", &format!("{:0>64}", index), 1) + }) + .collect(), + "单批最多导入", + ), + ( + vec![ + import("dup", "0.1.0", &"1".repeat(64), 1), + import("dup", "0.1.1", &"2".repeat(64), 1), + ], + "重复模板 ID", + ), + ]; + for (imports, expected) in cases { + let error = prepare_template_import(index(), imports, "2026-09-21T00:00:00Z") + .expect_err("invalid batch rejected"); + assert!(error.to_string().contains(expected), "{error}"); + } + + let mut bad_runtime = import("x", "0.1.0", &"3".repeat(64), 1); + bad_runtime.runtime = "unity-native".to_owned(); + assert!( + prepare_template_import(index(), vec![bad_runtime], "now") + .expect_err("runtime allowlist") + .to_string() + .contains("runtime 不在允许列表内") + ); + + let mut bad_entry = import("x", "0.1.0", &"3".repeat(64), 1); + bad_entry.entry = "../escape.json".to_owned(); + assert!( + prepare_template_import(index(), vec![bad_entry], "now") + .expect_err("entry must stay inside the library") + .to_string() + .contains("entry 不是库内相对路径") + ); + + let mut bad_id = import("x", "0.1.0", &"3".repeat(64), 1); + bad_id.id = "Bad.Id".to_owned(); + assert!( + prepare_template_import(index(), vec![bad_id], "now") + .expect_err("identifier whitelist") + .to_string() + .contains("模板 ID 无效") + ); + + let mut empty_zip = import("x", "0.1.0", &"3".repeat(64), 1); + empty_zip.zip_size_bytes = 0; + assert!( + prepare_template_import(index(), vec![empty_zip], "now") + .expect_err("empty zip rejected") + .to_string() + .contains("ZIP 对象键或摘要无效") + ); + } + + #[test] + fn template_import_backfills_metadata_key_after_hashing_metadata() { + let mut prepared = prepare_template_import( + index(), + vec![import("d", "0.1.0", &"d".repeat(64), 42)], + "2026-09-21T00:00:00Z", + ) + .expect("import"); + let key = format!("templates/v1/d/sha256/{}/template.json", "9".repeat(64)); + prepared + .set_metadata_key("d", &key) + .expect("set metadata key"); + + let entry = prepared.index["templates"] + .as_array() + .expect("templates") + .iter() + .find(|entry| entry["id"] == json!("d")) + .expect("entry"); + assert_eq!(entry["metadataKey"], json!(key)); + assert!( + prepared + .set_metadata_key("missing", &key) + .is_err_and(|error| error == TemplateDomainError::NotFound) + ); + } + fn edit(enabled: bool) -> TemplateEdit { TemplateEdit { title: " 新名称 ".to_owned(), diff --git a/server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs b/server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs index 2ebc8f144..67cf2c49d 100644 --- a/server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs +++ b/server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs @@ -8,7 +8,7 @@ //! cargo run -p platform-oss --example agc_project_snapshot_live_smoke --manifest-path server-rs/Cargo.toml //! ``` //! -//! 冒烟只写入 `agc/project-snapshots/v1/` 下的固定探针对象,并在结束时删除; +//! 冒烟只写入 `agc/project-snapshots/v2/dev/` 下的固定探针对象,并在结束时删除; //! 任何一步失败都会打印 `[FAIL]` 并以非 0 退出码结束,方便 CI 或人工判定。 use std::{ @@ -29,6 +29,7 @@ const DEFAULT_BUCKET: &str = "agc-dev"; const DEFAULT_ENDPOINT: &str = "oss-rg-china-mainland.aliyuncs.com"; const SMOKE_USER_ID: &str = "smoke-user"; const SMOKE_PROJECT_ID: &str = "smoke-project"; +const SMOKE_CHANNEL: &str = "dev"; const SMOKE_RELATIVE_PATH: &str = "smoke/README.txt"; const SMOKE_BODY: &[u8] = b"agc project snapshot live smoke\n"; const SMOKE_CHECKSUM_DIGEST: &str = "0123456789abcdef"; @@ -117,6 +118,7 @@ async fn run() -> SmokeResult<()> { // 3. 项目快照文件键:写入 → 读回。 let file_key = agc_project_snapshot_file_object_key( + SMOKE_CHANNEL, SMOKE_USER_ID, SMOKE_PROJECT_ID, SMOKE_BODY.len() as u64, @@ -124,8 +126,9 @@ async fn run() -> SmokeResult<()> { SMOKE_RELATIVE_PATH, ) .map_err(|error| format!("构造项目快照文件键失败({})", oss_error_label(&error)))?; - let manifest_key = agc_project_snapshot_manifest_object_key(SMOKE_USER_ID, SMOKE_PROJECT_ID) - .map_err(|error| format!("构造项目快照清单键失败({})", oss_error_label(&error)))?; + let manifest_key = + agc_project_snapshot_manifest_object_key(SMOKE_CHANNEL, SMOKE_USER_ID, SMOKE_PROJECT_ID) + .map_err(|error| format!("构造项目快照清单键失败({})", oss_error_label(&error)))?; let result = write_and_verify(&client, &http, &bucket, &file_key, &manifest_key).await; for key in [&file_key, &manifest_key] { diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 8c911a531..61b6cc213 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -1926,44 +1926,69 @@ fn normalize_editor_agent_messages_object_key(raw: &str) -> Result Result { + let allowed = !raw.is_empty() + && raw.len() <= 32 + && raw.as_bytes()[0].is_ascii_lowercase() + && raw + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && !raw.ends_with('-'); + if !allowed { + return Err(OssError::InvalidRequest("项目快照渠道名非法".to_string())); + } + Ok(raw.to_string()) +} + /// 项目快照文件对象键: -/// `agc/project-snapshots/v1/{user}/{project}/files/{size}-{digest}/{relativePath}`。 +/// `agc/project-snapshots/v2/{channel}/{user}/{project}/files/{size}-{digest}/{relativePath}`。 /// /// 键里同时带字节数与内容摘要,既让同一内容重复提交落在同一个对象上,也让 /// "对象已存在且长度一致" 可以作为内容一致的判据;相对路径按原始大小写保留, /// 不走 `put_object` 的低位规范化。 pub fn agc_project_snapshot_file_object_key( + channel: &str, user_id: &str, project_id: &str, size_bytes: u64, checksum_digest: &str, relative_path: &str, ) -> Result { + let channel = validate_agc_project_snapshot_channel(channel)?; let user = validate_internal_key_segment(user_id, "用户标识")?; let project = validate_internal_key_segment(project_id, "项目标识")?; let digest = validate_internal_checksum_digest(checksum_digest)?; let relative_path = validate_internal_relative_path(relative_path)?; Ok(format!( - "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{user}/{project}/files/{size_bytes}-{digest}/{relative_path}" + "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{channel}/{user}/{project}/files/{size_bytes}-{digest}/{relative_path}" )) } -/// 项目快照清单对象键:`agc/project-snapshots/v1/{user}/{project}/manifest.json`。 +/// 项目快照清单对象键: +/// `agc/project-snapshots/v2/{channel}/{user}/{project}/manifest.json`。 pub fn agc_project_snapshot_manifest_object_key( + channel: &str, user_id: &str, project_id: &str, ) -> Result { + let channel = validate_agc_project_snapshot_channel(channel)?; let user = validate_internal_key_segment(user_id, "用户标识")?; let project = validate_internal_key_segment(project_id, "项目标识")?; Ok(format!( - "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{user}/{project}/manifest.json" + "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{channel}/{user}/{project}/manifest.json" )) } @@ -3496,7 +3521,7 @@ mod tests { ); assert_eq!( LegacyAssetPrefix::from_object_key( - "agc/project-snapshots/v1/user-1/project-1/manifest.json" + "agc/project-snapshots/v2/dev/user-1/project-1/manifest.json" ), None, "AGC 内部前缀不能经由通用对象键解析变成客户端可写前缀" @@ -3524,6 +3549,7 @@ mod tests { #[test] fn agc_project_snapshot_object_keys_preserve_case_and_reject_traversal() { let file_key = agc_project_snapshot_file_object_key( + "dev", "user-1", "gameagent-1a2b3c4d", 1234, @@ -3533,12 +3559,12 @@ mod tests { .expect("file key"); assert_eq!( file_key, - "agc/project-snapshots/v1/user-1/gameagent-1a2b3c4d/files/1234-0123456789abcdef/Game/Scenes/Main.HTML" + "agc/project-snapshots/v2/dev/user-1/gameagent-1a2b3c4d/files/1234-0123456789abcdef/Game/Scenes/Main.HTML" ); assert_eq!( - agc_project_snapshot_manifest_object_key("user-1", "gameagent-1a2b3c4d") + agc_project_snapshot_manifest_object_key("release", "user-1", "gameagent-1a2b3c4d") .expect("manifest key"), - "agc/project-snapshots/v1/user-1/gameagent-1a2b3c4d/manifest.json" + "agc/project-snapshots/v2/release/user-1/gameagent-1a2b3c4d/manifest.json" ); for (user, project, path) in [ @@ -3549,20 +3575,64 @@ mod tests { ("user-1", "project-1", "game\\index.html"), ] { assert!( - agc_project_snapshot_file_object_key(user, project, 1, "abcdef", path).is_err(), + agc_project_snapshot_file_object_key("dev", user, project, 1, "abcdef", path) + .is_err(), "越界键片段必须被拒绝:{user} {project} {path}" ); } assert!( - agc_project_snapshot_file_object_key("user-1", "project-1", 1, "not-hex", "game/a.txt") - .is_err(), + agc_project_snapshot_file_object_key( + "dev", + "user-1", + "project-1", + 1, + "not-hex", + "game/a.txt" + ) + .is_err(), "摘要必须是十六进制" ); } + /// 渠道是对象键的第一层:非法渠道必须失败关闭,不能悄悄换成一个默认渠道。 + #[test] + fn agc_project_snapshot_channel_is_validated_before_it_reaches_the_key() { + assert_eq!( + validate_agc_project_snapshot_channel("release").expect("channel"), + "release" + ); + assert_eq!( + validate_agc_project_snapshot_channel("dev-internal-2").expect("channel"), + "dev-internal-2" + ); + for invalid in [ + "", + " dev", + "dev ", + "Dev", + "dev_internal", + "dev/internal", + "-dev", + "dev-", + "2dev", + "dev.", + &"d".repeat(33), + ] { + assert!( + validate_agc_project_snapshot_channel(invalid).is_err(), + "非法渠道必须被拒绝:{invalid}" + ); + assert!( + agc_project_snapshot_manifest_object_key(invalid, "user-1", "project-1").is_err(), + "非法渠道不能进入对象键:{invalid}" + ); + } + } + #[test] fn internal_object_prefixes_cover_agc_snapshots_but_reject_everything_else() { let file_key = agc_project_snapshot_file_object_key( + "dev", "user-1", "project-1", 7, @@ -3574,6 +3644,14 @@ mod tests { normalize_internal_object_key(&file_key).expect("snapshot key is internal"), file_key ); + // 无渠道的历史项目快照对象仍然必须保持服务端私有。 + assert_eq!( + normalize_internal_object_key( + "agc/project-snapshots/v1/user-1/project-1/manifest.json" + ) + .expect("legacy snapshot key stays internal"), + "agc/project-snapshots/v1/user-1/project-1/manifest.json" + ); assert_eq!( normalize_internal_object_key("agc/error-reports/v1/batch.zip") .expect("error report key stays internal"), diff --git a/server-rs/crates/platform-oss/src/project_snapshots.rs b/server-rs/crates/platform-oss/src/project_snapshots.rs index 0466591ad..1345c2c15 100644 --- a/server-rs/crates/platform-oss/src/project_snapshots.rs +++ b/server-rs/crates/platform-oss/src/project_snapshots.rs @@ -37,7 +37,7 @@ fn directory_segment(value: &str) -> Result { } fn list_query( - user_id: Option<&str>, + prefix: &str, after: Option<&str>, limit: usize, ) -> Result, OssError> { @@ -46,15 +46,8 @@ fn list_query( "项目目录分页大小必须为 1 到 100".to_string(), )); } - let prefix = match user_id { - Some(user) => format!( - "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{}/", - directory_segment(user)? - ), - None => AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX.to_string(), - }; let mut query = BTreeMap::from([ - ("prefix".to_string(), prefix.clone()), + ("prefix".to_string(), prefix.to_string()), ("delimiter".to_string(), "/".to_string()), ("max-keys".to_string(), limit.to_string()), ]); @@ -67,6 +60,12 @@ fn list_query( Ok(query) } +/// 渠道根前缀:`agc/project-snapshots/v2/{channel}/`。 +fn channel_root_prefix(channel: &str) -> Result { + let channel = validate_agc_project_snapshot_channel(channel)?; + Ok(format!("{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{channel}/")) +} + fn parse_directory_page( body: &[u8], query: &BTreeMap, @@ -119,15 +118,40 @@ fn parse_directory_page( } impl OssClient { - /// 第一层只列用户目录,第二层只列项目目录,不枚举文件对象或其它 bucket 前缀。 + /// 在本部署渠道下逐层列目录:`{channel}/` 下只列用户,用户下只列项目, + /// 不枚举文件对象,也不跨渠道。 pub async fn list_project_snapshot_directories( &self, client: &reqwest::Client, + channel: &str, user_id: Option<&str>, after: Option<&str>, limit: usize, ) -> Result { - let query = list_query(user_id, after, limit)?; + let mut prefix = channel_root_prefix(channel)?; + if let Some(user) = user_id { + prefix.push_str(&format!("{}/", directory_segment(user)?)); + } + self.list_project_snapshot_prefix(client, list_query(&prefix, after, limit)?) + .await + } + + /// 列出远端已存在的渠道目录:只在 v2 根下取第一层,不进入用户或项目。 + pub async fn list_project_snapshot_channels( + &self, + client: &reqwest::Client, + after: Option<&str>, + limit: usize, + ) -> Result { + let query = list_query(AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX, after, limit)?; + self.list_project_snapshot_prefix(client, query).await + } + + async fn list_project_snapshot_prefix( + &self, + client: &reqwest::Client, + query: BTreeMap, + ) -> Result { let mut target = build_object_url(&self.config.bucket, &self.config.endpoint, "") .map_err(OssError::InvalidRequest)?; target.set_query(Some(&build_canonical_query_string(&query))); @@ -177,11 +201,12 @@ pub(super) fn is_snapshot_file_key(key: &str) -> bool { return false; }; let parts = path.split('/').collect::>(); - parts.len() >= 5 - && directory_segment(parts[0]).is_ok() + parts.len() >= 6 + && validate_agc_project_snapshot_channel(parts[0]).is_ok() && directory_segment(parts[1]).is_ok() - && parts[2] == "files" - && parts[3].split_once('-').is_some_and(|(size, digest)| { + && directory_segment(parts[2]).is_ok() + && parts[3] == "files" + && parts[4].split_once('-').is_some_and(|(size, digest)| { size == "0" && digest.eq_ignore_ascii_case("cbf29ce484222325") }) } @@ -192,7 +217,7 @@ mod tests { #[test] fn snapshot_object_url_preserves_literal_reserved_characters() { - let key = "agc/project-snapshots/v1/user/project/files/1-abcd/game/图像 #100%.txt"; + let key = "agc/project-snapshots/v2/dev/user/project/files/1-abcd/game/图像 #100%.txt"; let url = build_object_url("bucket", "oss-cn-shanghai.aliyuncs.com", key).unwrap(); assert_eq!(url.fragment(), None); assert_eq!(url.query(), None); @@ -202,22 +227,26 @@ mod tests { #[test] fn snapshot_directory_query_is_prefix_scoped_and_bounded() { - let query = list_query(Some("user-1"), Some("project-1"), 20).unwrap(); - assert_eq!(query["prefix"], "agc/project-snapshots/v1/user-1/"); - assert_eq!( - query["marker"], - "agc/project-snapshots/v1/user-1/project-1/" - ); + let prefix = channel_root_prefix("dev").unwrap(); + assert_eq!(prefix, "agc/project-snapshots/v2/dev/"); + let query = list_query(&prefix, Some("project-1"), 20).unwrap(); + assert_eq!(query["prefix"], "agc/project-snapshots/v2/dev/"); + assert_eq!(query["marker"], "agc/project-snapshots/v2/dev/project-1/"); assert_eq!(query["delimiter"], "/"); - assert!(list_query(Some("../user"), None, 20).is_err()); - assert!(list_query(None, Some("escape/path"), 20).is_err()); - assert!(list_query(None, None, 101).is_err()); + assert!(list_query(&prefix, Some("escape/path"), 20).is_err()); + assert!(list_query(&prefix, None, 101).is_err()); + assert!(channel_root_prefix("../escape").is_err()); + assert!(channel_root_prefix("Dev").is_err()); } #[test] fn snapshot_directory_page_decodes_only_direct_children_and_advancing_cursor() { - let query = list_query(Some("user-1"), None, 20).unwrap(); - let xml = br#"agc/project-snapshots/v1/user-1//trueagc/project-snapshots/v1/user-1/project-1/agc/project-snapshots/v1/user-1/project-1/"#; + let query = list_query("agc/project-snapshots/v2/dev/user-1/", None, 20).unwrap(); + // 渠道枚举只读 v2 根的第一层,不进入任何渠道目录内部。 + let channels = list_query(AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX, Some("dev"), 20).unwrap(); + assert_eq!(channels["prefix"], "agc/project-snapshots/v2/"); + assert_eq!(channels["marker"], "agc/project-snapshots/v2/dev/"); + let xml = br#"agc/project-snapshots/v2/dev/user-1//trueagc/project-snapshots/v2/dev/user-1/project-1/agc/project-snapshots/v2/dev/user-1/project-1/"#; let page = parse_directory_page(xml, &query).unwrap(); assert_eq!(page.directories, ["project-1"]); assert_eq!(page.next_marker.as_deref(), Some("project-1")); @@ -238,14 +267,18 @@ mod tests { #[test] fn empty_internal_put_is_only_allowed_for_empty_snapshot_content_key() { assert!(is_snapshot_file_key( - "agc/project-snapshots/v1/user-1/project-1/files/0-cbf29ce484222325/game/empty.txt" + "agc/project-snapshots/v2/dev/user-1/project-1/files/0-cbf29ce484222325/game/empty.txt" )); assert!(!is_snapshot_file_key( - "agc/project-snapshots/v1/user-1/project-1/manifest.json" + "agc/project-snapshots/v2/dev/user-1/project-1/manifest.json" )); assert!(!is_snapshot_file_key("agc/error-reports/v1/report.zip")); assert!(!is_snapshot_file_key( - "agc/project-snapshots/v1/user-1/project-1/files/1-cbf29ce484222325/game/file.txt" + "agc/project-snapshots/v2/dev/user-1/project-1/files/1-cbf29ce484222325/game/file.txt" + )); + // 历史(无渠道)布局的对象键不是当前快照文件键。 + assert!(!is_snapshot_file_key( + "agc/project-snapshots/v1/user-1/project-1/files/0-cbf29ce484222325/game/empty.txt" )); } } diff --git a/server-rs/crates/platform-oss/src/template_library.rs b/server-rs/crates/platform-oss/src/template_library.rs index 0d538d7d0..668cddaea 100644 --- a/server-rs/crates/platform-oss/src/template_library.rs +++ b/server-rs/crates/platform-oss/src/template_library.rs @@ -15,6 +15,8 @@ const INDEX_KEY: &str = "templates/index.json"; const LOCK_KEY: &str = "templates/.publish-lock.json"; const MAX_INDEX_BYTES: usize = 4 * 1024 * 1024; const MAX_OBJECT_BYTES: usize = 5 * 1024 * 1024; +/// 模板包(`application/zip`)是后台导入路径唯一的超大对象,单独给出上限。 +const MAX_TEMPLATE_ZIP_BYTES: usize = 64 * 1024 * 1024; const MAX_CONTROL_BYTES: usize = 64 * 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -241,6 +243,8 @@ impl TemplatePublishSession { let parts: Vec<_> = key.split('/').collect(); let max_bytes = match content_type { "application/json" => MAX_INDEX_BYTES, + // 模板包按上传字节原样发布,见决策「后台模板上传」;仍受内容寻址与回读校验约束。 + "application/zip" => MAX_TEMPLATE_ZIP_BYTES, "image/png" | "image/jpeg" | "image/webp" => MAX_OBJECT_BYTES, _ => return Err(TemplateStoreError::Invalid), }; @@ -643,7 +647,8 @@ mod tests { .get("content-length") .and_then(|value| value.parse::().ok()) .unwrap_or(0); - if length > MAX_OBJECT_BYTES + 1024 { + // 模板包上限高于图片 / 元数据,替身必须按同一口径放行。 + if length > MAX_TEMPLATE_ZIP_BYTES + 1024 { return None; } let start = header_end + 4; @@ -1063,6 +1068,38 @@ mod tests { } } + #[tokio::test] + async fn template_zip_objects_accept_larger_bodies_than_images() { + let server = MockServer::start().await; + let mut session = server + .store + .begin_publish("zip-owner".to_string()) + .await + .unwrap(); + + // 6 MiB 超过图片 / 元数据的 5 MiB 上限:模板包必须放行,并仍按自身字节摘要定位与回读。 + let zip = vec![7_u8; 6 * 1024 * 1024]; + let key = format!("templates/v1/demo/sha256/{}/template.zip", sha256_hex(&zip)); + assert_eq!( + session + .put_immutable(&key, zip.clone(), "application/zip") + .await, + Ok(()) + ); + assert_eq!(server.count("PUT", &key), 1); + + // 未知内容类型仍然失败关闭。 + let body = b"tiny".to_vec(); + let unknown_key = format!("templates/v1/demo/sha256/{}/cover.png", sha256_hex(&body)); + assert_eq!( + session + .put_immutable(&unknown_key, body, "application/octet-stream") + .await, + Err(TemplateStoreError::Invalid) + ); + session.finish().await.unwrap(); + } + #[tokio::test] async fn uncertain_commit_never_retries_or_releases_even_if_the_write_reached_storage() { for status in [Some(500), Some(408), Some(302), None] { @@ -1185,6 +1222,7 @@ mod tests { LOCK_KEY, INDEX_KEY, "agc/project-snapshots/v1/a/b", + "agc/project-snapshots/v2/dev/a/b", "templates/v1/a/../index.json", "templates/v1/a/%2e%2e/index.json", "templates/v1/a/file?x", diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 889b94223..e899fefbb 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -37,6 +37,26 @@ pub const ADMIN_TAB_PERMISSIONS: [&str; 18] = [ pub struct AdminProjectSnapshotsQuery { pub cursor: Option, pub limit: Option, + /// 目标渠道;缺省用本部署渠道。 + pub channel: Option, +} + +/// 后台可查看的快照渠道列表。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminProjectSnapshotChannelsResponse { + /// 本部署渠道:上传与默认查询都用它。 + pub default_channel: String, + /// 本部署渠道与远端已存在渠道的并集(升序,去重)。 + pub channels: Vec, +} + +/// 私有工程快照下载查询:只接受渠道。 +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminProjectSnapshotDownloadQuery { + /// 目标渠道;缺省用本部署渠道。 + pub channel: Option, } #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -59,6 +79,14 @@ pub struct AdminProjectSnapshotItem { pub file_count: u32, pub total_bytes: u64, pub status: AdminProjectSnapshotStatus, + /// 项目所在渠道。 + pub channel: String, + /// 项目归属用户昵称,与素材查询同口径;账号不可解析时为占位作者。 + #[serde(default)] + pub author_display_name: Option, + /// 项目归属用户陶泥号,与素材查询同口径;账号不可解析时为占位账号。 + #[serde(default)] + pub author_public_user_code: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -135,6 +163,60 @@ pub struct AdminAgcTemplateCoverInput { pub data_base64: String, } +/// 后台批量导入模板的 manifest(`multipart/form-data` 的 `manifest` 文本字段)。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AdminImportAgcTemplatesManifest { + /// 上传前读到的清单字节摘要,锁内用于 CAS;过期返回 409。 + pub expected_revision: String, + pub templates: Vec, +} + +/// 单条导入模板:ZIP 与可选封面通过字段名引用同一请求里的文件字段。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AdminImportAgcTemplateItem { + pub id: String, + pub title: String, + #[serde(default)] + pub summary: String, + #[serde(default)] + pub tags: Vec, + pub runtime: String, + #[serde(default)] + pub engine: String, + #[serde(default)] + pub engine_version: String, + pub template_version: String, + pub entry: String, + /// 该条目的 ZIP 文件字段名(例如 `zip_0`)。 + pub zip_field: String, + /// 该条目的封面文件字段名(例如 `cover_0`);与 CLI 源布局一致,封面必填。 + pub cover_field: String, +} + +/// 批量导入结果:最新快照 + 逐条结果。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminImportAgcTemplatesResponse { + pub revision: String, + pub writable: bool, + pub templates: Vec, + pub imported: Vec, +} + +/// 单条导入结果。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminImportAgcTemplateResult { + pub id: String, + pub template_version: String, + pub zip_size_bytes: u64, + pub zip_sha256: String, + /// 同一版本上传完全相同的字节时按内容复用既有对象,没有新写内容。 + pub reused_objects: bool, +} + // 登录成功后返回管理员访问令牌与基础会话信息。 /// 后台创作入口开关列表响应。