#!/usr/bin/env node /** * 导入默认模型:把模型压缩包交给服务端导入/转换并入库, * 再**审核通过并设为全局默认模型**(服务端随之把默认模型身份写进源码 * `src/config/default-model.ts`)。 * * 注意:服务端上传的模型**一律进入待审核(pending)**,所以本脚本会显式调用 * `POST /api/reviews/model/{id}/approve`(带 `setPrimary=true`)完成"通过 + 设默认"。 * 只想入库不通过审核时加 `--no-approve`。 * * 为什么要有这个脚本:服务进程在 systemd 加固(ProtectSystem=strict)下**不能** * 改写程序目录,写源码这一步必须在沙箱外完成,因此由本脚本(以 root/运维身份运行) * 调 API 之后自己写文件。 * * 用法(在 web-server 目录下,需先 `npm run build`): * node scripts/import-default-model.mjs 模型.zip \ * --url http://127.0.0.1:3000 \ * --name "初音未来 Hatsune Miku" \ * --author "Boku Wa Luka" \ * --license "MMD 模型使用条款(见压缩包内说明)" \ * --source-name "模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip" \ * --source-url "本地文件:模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip" \ * --source-type other * * # 只根据已入库的模型补写源码(库里的默认模型已知时) * node scripts/import-default-model.mjs --write-source-only --model-id 16 * * # 只写源码文件、不调 API(配合 --write-source-only) * node scripts/import-default-model.mjs --write-source-only --model-id 16 --dry-run */ import { createRequire } from 'node:module'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { basename, join, resolve } from 'node:path'; const require = createRequire(import.meta.url); const args = process.argv.slice(2); const flag = (name, fallback = undefined) => { const index = args.indexOf(name); return index >= 0 && args[index + 1] && !args[index + 1].startsWith('--') ? args[index + 1] : index >= 0 ? true : fallback; }; const has = (name) => args.includes(name); /** 需要跟一个取值的开关(其余 --x 视为布尔开关) */ const VALUE_FLAGS = new Set([ '--url', '--name', '--author', '--license', '--source-name', '--source-url', '--source-type', '--model-id', '--dir', '--env-file', ]); /** 第一个"不是开关、也不是开关取值"的参数即输入文件 */ function positionalFile(argv) { for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg.startsWith('--')) { if (VALUE_FLAGS.has(arg)) i += 1; continue; } return arg; } return undefined; } const file = positionalFile(args); const url = String(flag('--url', 'http://127.0.0.1:3000')).replace(/\/$/, ''); const writeSourceOnly = has('--write-source-only'); const noApprove = has('--no-approve'); const dryRun = has('--dry-run'); const sourceDir = resolve(process.cwd(), 'src', 'config'); const sourceFile = join(sourceDir, 'default-model.ts'); function loadRenderer() { const distPath = join(process.cwd(), 'dist', 'models', 'default-model-source.js'); if (!existsSync(distPath)) { console.error('未找到 dist/models/default-model-source.js,请先执行:npm run build'); process.exit(2); } return require(distPath); } async function api(path, options = {}) { const response = await fetch(`${url}${path}`, options); const text = await response.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch { body = text; } if (!response.ok) { const detail = body && typeof body === 'object' ? JSON.stringify(body) : String(body); throw new Error(`${options.method ?? 'GET'} ${path} → HTTP ${response.status}:${detail}`); } return body; } async function fetchModel(id) { const model = await api(`/api/models/${id}`); return model; } async function main() { const { renderDefaultModelSource, defaultModelSourcePath } = loadRenderer(); const target = defaultModelSourcePath(process.cwd()); let model; if (writeSourceOnly) { const id = Number(flag('--model-id', 0)); if (!Number.isInteger(id) || id <= 0) { console.error('--write-source-only 需要 --model-id <已入库的模型 id>'); process.exit(2); } // 服务端只在 setPrimary 时写源码,这里直接调用它的 primary 接口 model = await api(`/api/models/${id}/primary`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); console.log(`已把 #${id} 设为默认模型:${model.name}(${model.format})`); console.log('服务端已把默认模型身份写进源码 src/config/default-model.ts'); return; } else { if (!file) { console.error( '用法:node scripts/import-default-model.mjs <模型.zip> [--url ...] [--name ...] [--author ...] [--license ...]', ); process.exit(2); } const buffer = readFileSync(resolve(file)); const form = new FormData(); form.append('file', new Blob([buffer]), basename(file)); form.append('name', String(flag('--name', basename(file).replace(/\.[^.]+$/, '')))); form.append('sourceType', String(flag('--source-type', 'other'))); form.append('sourceName', String(flag('--source-name', basename(file)))); form.append('sourceUrl', String(flag('--source-url', `本地文件:${basename(file)}`))); form.append('license', String(flag('--license', '待补充:MMD 模型使用条款(见压缩包内说明)'))); form.append('author', String(flag('--author', '待补充'))); console.log(`上传并导入:${basename(file)}(${(buffer.length / 1024 / 1024).toFixed(2)} MiB)→ ${url}`); model = await api('/api/models', { method: 'POST', body: form }); console.log(`入库成功:#${model.id} ${model.name}(格式 ${model.format},状态 ${model.status})`); console.log(`说明:${model.conversionNote ?? '(无)'}`); const stats = model.stats ?? {}; if (stats.triangles !== undefined) { console.log( `模型分析:面数=${stats.triangles} 骨骼=${stats.bones} ` + `humanoid=${stats.humanoidMapped}/${stats.humanoidTotal} 可驱动=${stats.drivable}`, ); if (stats.drivable === false && Array.isArray(stats.missingRequiredBones)) { console.log(` 注意:缺 ${stats.missingRequiredBones.join('、')},客户端可能无法驱动`); } } if (noApprove) { console.log('已按 --no-approve 跳过审核:模型仍在待审,源码种子未写入'); return; } await api(`/api/reviews/model/${model.id}/approve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer: 'import-default-model', comment: '由 scripts/import-default-model.mjs 导入并设为默认模型', setPrimary: true, }), }); console.log(`已通过审核并设为默认模型:#${model.id}`); model = await fetchModel(model.id); } if (!model.fileSha256 && !model.sourceSha256) { console.error('该模型没有 fileSha256 / sourceSha256(文件缺失?),无法写入默认模型种子'); process.exit(1); } const seed = { id: model.id, name: model.name, format: model.format, // 主匹配键用上传压缩包的 sha256(稳定);直接上传模型文件时退回产物 sha256 sourceSha256: model.sourceSha256 ?? model.fileSha256, fileSha256: model.fileSha256 ?? '', convertedFrom: (model.conversionNote ?? '').split(',')[0] || null, updatedAt: new Date().toISOString(), }; console.log(` 种子匹配键:sourceSha256=${String(seed.sourceSha256).slice(0, 16)}…`); if (model.sourceSha256) { console.log(' (用的是压缩包 sha256:Assimp 转换产物每次都不一样,只有它稳定)'); } const content = renderDefaultModelSource(seed); if (dryRun) { console.log(`--dry-run:将写入 ${target}\n`); console.log(content); return; } writeFileSync(target, content, 'utf8'); console.log(`已把默认模型写进源码:${target}`); console.log('建议接着执行:npm run typecheck && git add src/config/default-model.ts'); } main().catch((error) => { console.error(`失败:${error instanceof Error ? error.message : String(error)}`); process.exit(1); });