diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index ecd7501ac..61c881989 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,50 @@ 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..a6eac6b07 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -30,6 +30,7 @@ import type { AdminExternalApiKeyListQuery, AdminExternalApiKeyListResponse, AdminFeatureGateConfigResponse, + AdminImportAgcTemplatesResponse, AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, @@ -87,6 +88,8 @@ interface AdminRequestOptions { method?: string; token?: string; body?: unknown; + /** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */ + formData?: FormData; headers?: Record; signal?: AbortSignal; } @@ -174,6 +177,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); @@ -1196,3 +1201,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..322bc1d62 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1065,3 +1065,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/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/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..caa2dedaa 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); + } } * { 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/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 92b719262..70cfbdd87 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -13,6 +13,7 @@ import { defaultEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; +import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; import { stageNodeRuntime } from './stage-node-runtime.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -877,14 +878,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..15bcc11aa 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -590,6 +590,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/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/template-library/README.md b/apps/ai-game-creator-shell/template-library/README.md index 692abfbfc..5c2a69162 100644 --- a/apps/ai-game-creator-shell/template-library/README.md +++ b/apps/ai-game-creator-shell/template-library/README.md @@ -7,4 +7,5 @@ - 客户端只信任「受信任 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/docs/README.md b/docs/README.md index 63f7d95a5..baaa5723f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,6 +48,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/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/【里程碑】后台模板上传-2026-09-21.md b/docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md new file mode 100644 index 000000000..c886a92a5 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md @@ -0,0 +1,69 @@ +# 后台模板上传 + +| 字段 | 值 | +| --- | --- | +| 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`、至少一个文件,条目数与解压后体积受上限保护。 +- 封面**每个模板必填**(与 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) | +| 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-<时间戳>` 模板验证一批多模板 + 一次清单提交,验证后下架保留历史对象)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 447c7f88a..865a409fd 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,16 @@ # 决策记录 +## 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 不得变」门禁拒绝,发布器因此无法把仓库状态发上去。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index ad9d02273..0205590b1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5425,6 +5425,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。 diff --git a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md index a70a94d6f..67e327bde 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。 +- 封面每个模板必填,按字节嗅探格式(只接受真实 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,6 +78,7 @@ 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/` 现场打包(条目排序、固定时间戳,同内容重复打包摘要一致)。 +- 模板包内容怎么组织(根目录结构、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`。失败不回收已上传对象,旧清单及其引用始终可读。 - 只更新指定模板时使用 `--only `,在发布锁内读取最新清单,只替换指定 ID,其余条目和未知扩展字段保留。首次清单 404 可由本次选择初始化;读取异常或清单非法时停止。全量发布也遵守相同锁与版本门禁。 - `templates/README.md` 与上述正文不同:它不是内容寻址对象,而是**覆盖写的说明文档**,源在仓库 `apps/ai-game-creator-shell/template-library/README.md`(上限 64 KiB),由同一次发布在清单之前写入并回读校验。客户端从不读它,改契约只改仓库源即可,不要再手工维护线上副本。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index c7916f2c8..ca6d7ec82 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -147,6 +147,8 @@ 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,不重新发号。 +`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,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。 diff --git a/docs/【模板规范】AGC模板包组织指南-2026-09-21.md b/docs/【模板规范】AGC模板包组织指南-2026-09-21.md new file mode 100644 index 000000000..18bfa39b7 --- /dev/null +++ b/docs/【模板规范】AGC模板包组织指南-2026-09-21.md @@ -0,0 +1,113 @@ +# 【模板规范】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`),不要用这种结构。 + +### 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/`。 +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. 首次上架后,在客户端「模板库」里搜到该模板并实际建一次项目。 + +## 已知边界 + +- 当前工具**不会**自动拦截 `.agent/`、`.git/`、`node_modules/` 这类目录: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/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 391c714be..c5257d6f4 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/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_templates.rs b/server-rs/crates/api-server/src/admin_templates.rs index 18116eca0..6ab5d192a 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,357 @@ 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()) +} + +/// 只做校验、不落盘:确认上传的是合法 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 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 +704,178 @@ 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_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/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index fd418e6e7..39f6a03e8 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -56,6 +56,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) @@ -228,6 +236,7 @@ mod route_contract_tests { ), ("/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/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/src/template_library.rs b/server-rs/crates/platform-oss/src/template_library.rs index 0d538d7d0..d85d8326b 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] { diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 889b94223..60d7ca511 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -135,6 +135,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, +} + // 登录成功后返回管理员访问令牌与基础会话信息。 /// 后台创作入口开关列表响应。