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/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/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md index a70a94d6f..111dfa757 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