diff --git a/web-server/test/seed.mjs b/web-server/test/seed.mjs new file mode 100644 index 0000000..eed56bf --- /dev/null +++ b/web-server/test/seed.mjs @@ -0,0 +1,183 @@ +/** + * 演示数据播种脚本 —— 供管理端界面、Windows 客户端联调与集成测试使用。 + * + * 与 smoke.mjs 不同:本脚本**保留**数据并把三类资产都置为 published, + * 使 GET /api/catalog/export 能返回可用于客户端联调的完整目录。 + * + * 用法: + * API_BASE=http://127.0.0.1:3000 node test/seed.mjs + * + * 幂等:重复执行只会追加新记录,不会破坏既有数据。 + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const API = process.env.API_BASE ?? 'http://127.0.0.1:3000'; + +const SOURCE = { + sourceType: 'original', + sourceName: 'OpenDance 官方示例库', + sourceUrl: 'https://example.com/opendance/samples', + license: 'CC-BY-4.0', + author: 'OpenDance Studio', +}; + +async function request(method, path, { json, form } = {}) { + const init = { method }; + if (json !== undefined) { + init.headers = { 'Content-Type': 'application/json' }; + init.body = JSON.stringify(json); + } + if (form !== undefined) init.body = form; + const res = await fetch(`${API}${path}`, init); + const text = await res.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + return { status: res.status, body }; +} + +function fileForm(fields, filename, content, mime = 'application/octet-stream') { + const form = new FormData(); + for (const [key, value] of Object.entries(fields)) form.append(key, String(value)); + form.append('file', new Blob([content], { type: mime }), filename); + return form; +} + +function buildGlb(json) { + const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8'); + const padded = Buffer.concat([jsonBuf, Buffer.alloc((4 - (jsonBuf.length % 4)) % 4, 0x20)]); + const header = Buffer.alloc(12); + header.write('glTF', 0, 'ascii'); + header.writeUInt32LE(2, 4); + header.writeUInt32LE(12 + 8 + padded.length, 8); + const chunk = Buffer.alloc(8); + chunk.writeUInt32LE(padded.length, 0); + chunk.writeUInt32LE(0x4e4f534a, 4); + return Buffer.concat([header, chunk, padded]); +} + +async function publish(targetType, id, reviewer = 'seed') { + const submit = await request('POST', `/api/reviews/${targetType}/${id}/submit`, { json: { reviewer } }); + if (submit.status === 409) return submit; // 已处于目标状态 + const approve = await request('POST', `/api/reviews/${targetType}/${id}/approve`, { + json: { reviewer, comment: '演示数据自动发布' }, + }); + return approve; +} + +async function main() { + console.log(`\n== 播种演示数据 @ ${API} ==\n`); + + const health = await request('GET', '/health'); + if (health.status !== 200) { + console.error('服务端不可达,先启动:cd web-server && npm run start:prod'); + process.exit(1); + } + console.log(`服务端正常,数据库 ${health.body.database.server}`); + + // ---------- 曲目 ---------- + const song = await request('POST', '/api/songs', { + json: { + title: '示例曲目 · 小星星(Demo)', + artist: 'OpenDance 示例', + album: '客户端联调样例', + language: 'instrumental', + durationMs: 96000, + bpm: 100, + difficulty: 2, + tags: ['示例', '联调', '儿童'], + ...SOURCE, + }, + }); + if (song.status !== 201) { + console.error('创建曲目失败:', song.status, song.body); + process.exit(1); + } + const songId = song.body.id; + console.log(` 曲目 #${songId} ${song.body.title}`); + + await publish('song', songId); + + // ---------- 动作(BVH + 预处理) ---------- + const bvh = readFileSync(join(here, 'fixtures', 'sample.bvh')); + const motion = await request('POST', '/api/motions', { + form: fileForm( + { name: '示例动作 · 挥手深蹲(Demo)', songId, fps: 30, ...SOURCE }, + 'sample.bvh', + bvh, + 'text/plain', + ), + }); + if (motion.status !== 201) { + console.error('上传动作失败:', motion.status, motion.body); + process.exit(1); + } + const motionId = motion.body.id; + console.log(` 动作 #${motionId} ${motion.body.name}`); + + const pre = await request('POST', `/api/motions/${motionId}/preprocess`); + if (pre.status !== 200) { + console.error('预处理失败:', pre.status, pre.body); + process.exit(1); + } + console.log(` 预处理完成:${pre.body.keypointFrames} 帧 × 33 点,fps=${pre.body.fps}`); + await publish('motion', motionId); + + // ---------- 模型(VRM 优先) ---------- + const vrm = buildGlb({ + asset: { version: '2.0', generator: 'OpenDance seed' }, + extensions: { VRMC_vrm: { specVersion: '1.0' } }, + meshes: [], + nodes: [], + scenes: [{ nodes: [] }], + scene: 0, + }); + const model = await request('POST', '/api/models', { + form: fileForm( + { name: '示例模型 · VRM 1.0 占位(Demo)', isPrimary: true, ...SOURCE }, + 'demo-avatar.vrm', + vrm, + 'model/gltf-binary', + ), + }); + if (model.status !== 201) { + console.error('上传模型失败:', model.status, model.body); + process.exit(1); + } + const modelId = model.body.id; + console.log(` 模型 #${modelId} ${model.body.name}(vrmVersion=${model.body.vrmVersion})`); + await publish('model', modelId); + + // ---------- 校验目录导出 ---------- + const bundle = await request('GET', '/api/catalog/export'); + const summary = { + songs: bundle.body.songs.length, + motions: bundle.body.motions.length, + models: bundle.body.models.length, + defaultModelId: bundle.body.defaultModelId, + }; + console.log('\n已发布目录:', JSON.stringify(summary)); + + const scorable = bundle.body.motions.filter((m) => m.preprocessState === 'done'); + if (scorable.length === 0) { + console.error('目录中没有可评分动作,客户端集成测试将被跳过'); + process.exit(1); + } + + console.log('\n完成。Windows 客户端现在可联调:'); + console.log(` 可评分动作:${scorable.map((m) => `#${m.id}(${m.keypointFrames} 帧)`).join(', ')}`); + console.log(` 默认模型:#${bundle.body.defaultModelId}`); + console.log(''); +} + +main().catch((err) => { + console.error('播种失败:', err); + process.exit(1); +});