Merge branch 'master' into fix/empty-input
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m46s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m35s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 6m15s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m49s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m46s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m35s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 6m15s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m49s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:模板包缺少清单声明的 entry:index.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, a,b 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 把工具链解到 `<tools>/.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('<owner>', match[1])
|
||||
.replaceAll('<repo>', match[2])
|
||||
.replaceAll('<version>', match[3])
|
||||
.replaceAll('<asset>', 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);
|
||||
}
|
||||
@@ -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/<owner>/<repo>/<version>/<asset>',
|
||||
}),
|
||||
'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);
|
||||
});
|
||||
@@ -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 <id,id,...>]`(脚本现场打包 zip、按内容地址上传、逐个回读校验、持锁提交清单)。
|
||||
- 准备新模板(ZIP 里放什么、不能放什么、封面与元数据约束、发布前自检)先读 `docs/【模板规范】AGC模板包组织指南-2026-09-21.md`。
|
||||
- 同一个 `templateVersion` 的 ZIP 字节发生变化时发布会失败关闭,必须递增该模板的 `templateVersion`;发布不删除历史对象,历史版本仍可按旧键下载。
|
||||
|
||||
@@ -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):当前工作台页面和验收边界。
|
||||
|
||||
@@ -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 / 后台下架流程处理,历史对象保留。
|
||||
@@ -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_<index>` / `cover_<index>` 文件字段,字段下标与 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/<id>/cover.*` 一致),仅接受真实 PNG / JPEG / WebP,上限与编辑路径相同(5 MiB、单边 4096、1600 万像素);上传按字节嗅探格式,不信任 multipart 声明的 content-type。SVG 仍只可能来自 CLI 历史发布,编辑与上传都不产生新的 SVG 封面。
|
||||
- 后台「模板管理」页新增上传入口:可多选 ZIP、逐行编辑元数据(含可选封面)、批量提交、逐行显示校验错误;沿用现有写入确认、防重复提交与刷新语义。
|
||||
|
||||
## 不做
|
||||
|
||||
- 不做模板删除、版本回滚、下架条目清理、历史对象回收。
|
||||
- 不改 ZIP 内容、不做服务端重新打包(CLI 仍是确定性打包与 `--only` 定向发布的入口)。
|
||||
- 不做审核流、不做从 URL 拉取、不做 CLI 源布局目录(`v1/<id>/{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-<时间戳>` 模板验证一批多模板 + 一次清单提交,验证后下架保留历史对象)。
|
||||
@@ -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 不得变」门禁拒绝,发布器因此无法把仓库状态发上去。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
@@ -66,6 +78,7 @@ templates/
|
||||
- 所有对象键必须落在 `templates/` 前缀内;客户端只用「受信任 OSS 主机 + 对象键」自行拼 URL,**不直接信任清单里的地址**。
|
||||
- 任何一项校验失败(schema、标识符、sha256、尺寸、键前缀)都让整次清单读取失败,前端拿到的是全有或全无的清单。
|
||||
- 模板源在仓库 `apps/ai-game-creator-shell/template-library/`:`v1/<id>/{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 <id,id,...>]`。ZIP、封面和元数据分别以自身字节的 SHA-256 定位,只创建新对象或复用逐字节校验一致的已有对象;全部对象回读一致后才更新 `index.json`。失败不回收已上传对象,旧清单及其引用始终可读。
|
||||
- 只更新指定模板时使用 `--only <id,id,...>`,在发布锁内读取最新清单,只替换指定 ID,其余条目和未知扩展字段保留。首次清单 404 可由本次选择初始化;读取异常或清单非法时停止。全量发布也遵守相同锁与版本门禁。
|
||||
- `templates/README.md` 与上述正文不同:它不是内容寻址对象,而是**覆盖写的说明文档**,源在仓库 `apps/ai-game-creator-shell/template-library/README.md`(上限 64 KiB),由同一次发布在清单之前写入并回读校验。客户端从不读它,改契约只改仓库源即可,不要再手工维护线上副本。
|
||||
|
||||
@@ -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`、`<triggers/>` 留空(定时器写在 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,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。
|
||||
|
||||
@@ -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/<id>/{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 <dir> --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)。
|
||||
@@ -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 {
|
||||
|
||||
Generated
+1
@@ -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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user