后台模板上传(页面):模板管理页支持批量选择 ZIP 与封面并整批提交
Project CI / AI game creator shell Rust crates (push) Successful in 3m2s
Project CI / AI game creator shell Rust smoke (push) Successful in 4m5s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled

- 新增上传模型:文件名派生模板 ID、按同名匹配封面、逐行字段校验与 multipart 组装(标签去重与上限)
- 模板管理页新增「上传模板」入口与弹窗:多选 ZIP、按 ID 同名多选封面、逐行编辑 ID/名称/版本/运行时/entry、逐行错误、沿用写入确认与防重复提交
- 失败只在弹窗内交代并区分 409 刷新引导与 503 锁占用提示,401 仍交由会话处理;成功后用服务端快照刷新列表并展示逐条导入结果
- admin API client 支持 multipart 请求(不预设 JSON Content-Type),新增 importAdminAgcTemplates 与对应类型
- 主规范新增「后台模板上传」章节并收口原「不上传 ZIP」表述;决策记录补一条
- 验证:admin-web typecheck、页面/模型/客户端 40 项 vitest、check:doc-index、check:encoding、cargo fmt --check、git diff --check
This commit is contained in:
kdletters
2026-09-21 16:06:07 +08:00
parent 4863c8c066
commit 2e23a54211
10 changed files with 1089 additions and 10 deletions
@@ -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(
+17
View File
@@ -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<string, string>;
signal?: AbortSignal;
}
@@ -174,6 +177,8 @@ export async function request<T>(
if (typeof options.body !== 'undefined') {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(options.body);
} else if (options.formData) {
init.body = options.formData;
}
const response = await fetch(buildRequestUrl(path), init);
@@ -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<AdminImportAgcTemplatesResponse>(
'/admin/api/agc-templates/import',
{
token,
method: 'POST',
formData,
},
);
}
+32
View File
@@ -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[];
}
@@ -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<typeof import('../api/adminApiClient')>()),
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<HTMLInputElement>(
`input[accept="${accept}"]`,
);
if (!input) throw new Error(`missing file input for ${accept}`);
fireEvent.change(input, { target: { files } });
}
test('批量上传:多选 ZIP 生成行,封面匹配齐了才能提交', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
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(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
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(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
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(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
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:模板包缺少清单声明的 entryindex.html',
}),
);
fireEvent.click(
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
);
await confirmWrite();
expect(await screen.findByText(/模板包缺少清单声明的 entry/)).not.toBeNull();
});
@@ -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<string, string>;
submitting: boolean;
error: string;
results: AdminImportAgcTemplateResult[] | null;
};
const runtimeLabels: Record<string, string> = {
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<EditingTemplate | null>(null);
const [upload, setUpload] = useState<TemplateUploadState | null>(null);
const mounted = useRef(false);
const readGeneration = useRef(0);
const readController = useRef<AbortController | null>(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) {
<section className="admin-page admin-page-wide admin-agc-templates genarrative-ui">
<div className="admin-page-heading">
<h2></h2>
<Button
variant="secondary"
disabled={loading || busy}
onClick={() => void refresh()}
>
<RefreshCcw size={16} aria-hidden="true" />
</Button>
<div className="admin-actions">
<Button
variant="secondary"
disabled={writesDisabled || !snapshot}
onClick={openUpload}
>
<Upload size={16} aria-hidden="true" />
</Button>
<Button
variant="secondary"
disabled={loading || busy}
onClick={() => void refresh()}
>
<RefreshCcw size={16} aria-hidden="true" />
</Button>
</div>
</div>
{readOnly ? (
<Status tone="warning"></Status>
@@ -378,6 +499,41 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
) : (
confirmDialog
)}
{upload ? (
<Modal
open
title="上传模板"
description={`一批最多 ${TEMPLATE_IMPORT_MAX_BATCH} 个模板;发布后不可删除,只能下架`}
closeLabel="关闭模板上传"
onClose={closeUpload}
closeOnEscape={!upload.submitting}
closeOnBackdrop={!upload.submitting}
className="admin-agc-template-upload-dialog genarrative-ui"
>
<TemplateUploadDialog
state={upload}
disabled={writesDisabled}
readOnly={readOnly}
busy={busy}
conflict={conflict}
onRowsChange={(rows) =>
setUpload((current) =>
current
? {
...current,
rows,
errors: validateTemplateUploadRows(rows),
}
: current,
)
}
onSubmit={() => void submitUpload(upload.rows)}
onClose={closeUpload}
onRefresh={() => void refresh()}
/>
{confirmDialog}
</Modal>
) : null}
</section>
);
}
@@ -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<TemplateUploadRow>) {
onRowsChange(
state.rows.map((row) => (row.key === key ? { ...row, ...patch } : row)),
);
}
return (
<>
{readOnly ? (
<Status tone="warning"></Status>
) : null}
<div className="admin-agc-template-upload-pickers">
<label className="admin-agc-template-upload-picker">
<span> .zip</span>
<input
type="file"
accept=".zip,application/zip"
multiple
disabled={disabled || uploadBusy}
onChange={(event) => {
appendZipFiles([...(event.currentTarget.files ?? [])]);
event.currentTarget.value = '';
}}
/>
</label>
<label className="admin-agc-template-upload-picker">
<span> ID PNG / JPEG / WebP</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
disabled={disabled || uploadBusy}
onChange={(event) => {
onRowsChange(
attachCoverFiles(state.rows, [
...(event.currentTarget.files ?? []),
]),
);
event.currentTarget.value = '';
}}
/>
</label>
</div>
{state.rows.length === 0 ? (
<Status tone="info">
</Status>
) : (
<div className="admin-agc-template-upload-scroll">
<Table>
<TableHead>
<TableRow>
<TableHeader></TableHeader>
<TableHeader>ID</TableHeader>
<TableHeader></TableHeader>
<TableHeader></TableHeader>
<TableHeader></TableHeader>
<TableHeader>entry</TableHeader>
<TableHeader></TableHeader>
<TableHeader></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{state.rows.map((row) => {
const rowError = state.errors[row.key];
const rowDisabled = disabled || uploadBusy;
return (
<TableRow key={row.key}>
<TableCell>
<div className="admin-agc-template-upload-file">
{row.zipFile.name}
</div>
{rowError ? (
<div
className="admin-agc-template-upload-row-error"
role="alert"
>
{rowError}
</div>
) : null}
</TableCell>
<TableCell>
<TextField
label="ID"
value={row.id}
disabled={rowDisabled}
onChange={(event) =>
updateRow(row.key, { id: event.currentTarget.value })
}
/>
</TableCell>
<TableCell>
<TextField
label="名称"
value={row.title}
disabled={rowDisabled}
onChange={(event) =>
updateRow(row.key, {
title: event.currentTarget.value,
})
}
/>
</TableCell>
<TableCell>
<TextField
label="版本"
value={row.templateVersion}
disabled={rowDisabled}
onChange={(event) =>
updateRow(row.key, {
templateVersion: event.currentTarget.value,
})
}
/>
</TableCell>
<TableCell>
<SelectField
label="运行时"
value={row.runtime}
disabled={rowDisabled}
onChange={(event) =>
updateRow(row.key, {
runtime: event.currentTarget.value,
})
}
>
{TEMPLATE_UPLOAD_RUNTIMES.map((value) => (
<option key={value} value={value}>
{runtimeLabels[value] ?? value}
</option>
))}
</SelectField>
</TableCell>
<TableCell>
<TextField
label="entry"
value={row.entry}
disabled={rowDisabled}
onChange={(event) =>
updateRow(row.key, {
entry: event.currentTarget.value,
})
}
/>
</TableCell>
<TableCell>
{row.coverFile ? (
<span className="admin-agc-template-upload-file">
{row.coverFile.name}
</span>
) : (
<Status tone="warning"></Status>
)}
</TableCell>
<TableCell>
<Button
variant="secondary"
disabled={rowDisabled}
onClick={() =>
onRowsChange(
state.rows.filter((entry) => entry.key !== row.key),
)
}
>
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{state.error ? (
<Status tone="error" role="alert">
{state.error}
</Status>
) : null}
{conflict ? (
<Button variant="secondary" disabled={busy} onClick={onRefresh}>
</Button>
) : null}
{state.results ? (
<Status tone="success">
{`已导入 ${state.results.length} 个模板:${state.results
.map((result) => `${result.id}@${result.templateVersion}`)
.join('、')}`}
</Status>
) : null}
<div className="admin-agc-template-dialog-actions">
<Button
type="button"
variant="secondary"
disabled={uploadBusy}
onClick={onClose}
>
</Button>
<Button
type="button"
disabled={
disabled ||
uploadBusy ||
state.rows.length === 0 ||
rowErrorCount > 0
}
onClick={onSubmit}
>
{uploadBusy ? '上传中…' : `上传 ${state.rows.length} 个模板`}
</Button>
</div>
</>
);
}
@@ -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<TemplateUploadRow> = {}) {
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, ab c ')).toEqual(['a', 'b', 'c']);
const many = Array.from({ length: 20 }, (_, index) => `t${index}`).join(
',',
);
expect(parseTemplateTags(many)).toHaveLength(16);
});
});
@@ -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<string, File>();
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<string>();
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<string, string> {
const errors: Record<string, string> = {};
const seen = new Set<string>();
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;
}
+50
View File
@@ -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);
}
}
* {
@@ -1,5 +1,16 @@
# 决策记录
## 2026-09-21 后台模板上传:成品 ZIP + 每模板封面,批量全有或全无
- 背景:模板发布此前只有本地 CLI(源目录 + 确定性打包 + `--only`),后台上传需要一条不依赖本地仓库的通道,并支持一次提交多个模板。
- 决策(输入形态):后台上传**成品 ZIP**(库内字节原样发布,不重新打包),每个模板必须同时给一张 PNG/JPEG/WebP 封面(与 CLI 源布局 `v1/<id>/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 不得变」门禁拒绝,发布器因此无法把仓库状态发上去。
@@ -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_<index>` / `cover_<index>` 文件字段,下标与 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/<id>/sha256/<摘要>/{template.zip,cover.*,template.json}`;在发布锁内先 CAS 校验 `expectedRevision`(过期返回 409),写入后逐个回读校验,最后提交一次清单;清单写入结果不明时保留锁并返回 503。断连由独立任务持有,不会在清单 PUT 在途时提前解锁。
- 版本与保留语义:新 ID 默认上架;已存在 ID 就地更新并保留 `enabled` 分组、其它条目与未知扩展字段(含下架条目);同一 ID 同一 `templateVersion` 的 ZIP 字节不同时拒绝并要求递增版本(与 CLI 同一句文案),字节完全一致时按内容复用,响应里以 `reusedObjects` 标出。
- 后台页面沿用既有写入确认、防重复提交与刷新语义:409 提示刷新后重试;上传失败只在弹窗内交代,401 仍交由会话处理。
## OSS 契约
```text