diff --git a/DSH.md b/DSH.md index 3539dcc..2471566 100644 --- a/DSH.md +++ b/DSH.md @@ -31,6 +31,8 @@ opendance-web ←── 本仓库 | 管理前端 | React 18 + Vite + TypeScript | | 3D 预览 | three.js + @pixiv/three-vrm | | 多格式解析 | BVH / VMD 内置纯 TS 解析器;FBX/glTF 走可选外部 Assimp | +| 模型压缩包 | 纯 TS ZIP 读取器(`common/zip-reader.ts`)+ 外部 Assimp 转换(`models/model-convert.ts`);GLB 贴图内嵌见 `models/glb-textures.ts` | +| 默认模型种子 | `web-server/src/config/default-model.ts`(服务端/CLI 写入,运行期只读) | | 认证 | **本版本不含认证**(内网部署);上线前须补 ≥ T-WEB-011 | ## 3. 单次会话加载范围 @@ -70,23 +72,34 @@ opendance-web/ │ ├── VERSION # 契约版本(唯一来源) │ ├── rest-api.yaml # OpenAPI 3.0 │ ├── websocket-keypoints.md # 239 字节关键点帧 + 控制消息 -│ ├── data-models.md # 资源字段与 VRM 优先规则 +│ ├── data-models.md # 资源字段与默认模型选择规则 │ ├── skeleton-topology.md # MediaPipe 33 点拓扑 + 关节角 │ ├── model-formats.md # 支持格式、预处理产物、上传限制 │ ├── database.md # 现有 MySQL 连接与迁移约定 │ └── open-source-credits.md # 开源声明锚点(三端共用) ├── web-server/ # NestJS 服务端 -│ ├── src/ # 源码(entities / modules / preprocess) +│ ├── src/ # 源码(entities / models / preprocess) +│ │ ├── common/zip-reader.ts # 纯 TS ZIP 读取器(STORE/DEFLATE、ZIP64、zip-slip 防护) +│ │ ├── models/ # 模型上传 + 压缩包转换 + VRM 识别 +│ │ │ ├── model-convert.ts # 解压 → 选 PMX → Assimp → 回读校验 → 回退 +│ │ │ ├── glb-textures.ts # 把外部贴图内嵌进 GLB 的 BIN chunk +│ │ │ ├── default-model-source.ts # 渲染 / 写入 src/config/default-model.ts +│ │ │ └── vrm-inspector.ts +│ │ ├── config/default-model.ts # 默认模型种子(由服务端/CLI 写入,运行期只读) +│ │ ├── migrations/ # TypeORM 迁移(含 AddModelConversionFields) │ │ └── preprocess/ # BVH / VMD / FBX → MediaPipe33 │ │ ├── vmd-parser.ts # VMD 二进制 + Shift-JIS + 贝塞尔插值 │ │ ├── mmd-skeleton.ts # 标准 MMD 骨架(静止比例实测自参考 PMX) │ │ ├── mmd-pose.ts # MMD FK + 两骨骼腿部 IK + 左手系→右手系 Z 镜像 │ │ └── retarget.ts # worldFramesToKeypointFrames():BVH / VMD 共用 │ ├── admin/ # React 管理端 -│ ├── test/ # 单元测试 + 端到端冒烟(含 mmd-vmd.spec.ts) +│ ├── test/ # 单元测试 + 端到端冒烟 +│ │ # (mmd-vmd / model-archive / init-env / admin-port …) │ └── scripts/ # migrate.mjs / check-contracts.mjs / check-routes.mjs -│ # / inspect-vmd.mjs(VMD 诊断工具) +│ # / inspect-vmd.mjs(VMD 诊断) +│ # / import-default-model.mjs(默认模型导入 / 补写源码) ├── deploy/ # ★ 业务服务部署(不含 MySQL、不含反代) +│ ├── init-env.sh # ★ 首次启动的 .env 交互引导(install.sh 缺 .env 时调用) │ ├── install.sh # 一键部署(systemd / OpenRC / SysVinit / 后台进程) │ ├── uninstall.sh │ ├── ctl.sh # 无 init 系统时的进程控制 @@ -146,18 +159,23 @@ git push origin task/T-WEB-0XX ## 8. 环境变量 -见 `web-server/.env.example` 与 `deploy/.env.example`。生产关键项: +见 `web-server/.env.example` 与 `deploy/.env.example`。首次部署的 `deploy/.env` +可由 `deploy/init-env.sh` 交互生成(见 §12)。生产关键项: ``` DB_HOST DB_PORT DB_USER DB_PASS DB_NAME DB_SYNCHRONIZE=false PORT=3000 # 软件 API 端口(客户端调用,不含管理页面) ADMIN_PORT=3001 # 管理端端口(默认 PORT + 1;等于 PORT 为单端口兼容模式) -HOST=0.0.0.0 # 软件 API 监听地址 +HOST=0.0.0.0 # 软件 API 监听地址(未设置时默认即 0.0.0.0) ADMIN_HOST=0.0.0.0 # 可选,管理端监听地址,默认跟随 HOST STORAGE_DIR=/data/storage UPLOAD_MAX_BYTES=536870912 -ASSIMP_BIN=assimp # 可选 +ASSIMP_BIN=assimp # 可选;上传 .zip 模型包时必需 +MODEL_CONVERT_FORMAT=fbx # 压缩包首选格式;导出后回读校验,失败自动回退 glb +MODEL_CONVERT_PMX="Hatsune Miku.pmx" # 优先选用的 PMX 文件名(大小写不敏感) +MODEL_CONVERT_TIMEOUT_MS=180000 +DEFAULT_MODEL_ID=1 # 可选;未设置时按 src/config/default-model.ts 的种子匹配 CONTRACTS_DIR=/contracts # 可选,读取实时契约版本 ``` @@ -199,7 +217,69 @@ CONTRACTS_DIR=/contracts # 可选,读取实时契约版本 (打印头部/模型名/帧数/轨道数、未识别骨骼、IK 还是 FK、关键点序列异常统计、膝肘角分布)。 - 契约 `contracts/model-formats.md` §2.3;测试 `test/mmd-vmd.spec.ts`(31 项)。 -## 11. 上下文超标时的拆分策略 +## 11. 模型压缩包转换(.zip → FBX / GLB)与默认模型种子 + +``` +.zip(MMD 包)─▶ zip-reader.ts ─▶ pickModelFile() ─▶ model-convert.ts ─▶ glb-textures.ts + 中央目录/EOCD 优先 MODEL_CONVERT_PMX Assimp 导出 GLB 贴图内嵌 + STORE+DEFLATE 否则目录最浅、文件最大 + info 回读校验 + 拒绝 zip-slip/加密 失败回退 FBX → GLB +``` + +- `POST /api/models` 的 `file` 也接受 **`.zip`**,视为 MMD 模型包;只认包内 `.pmx`。 +- 新增模块(均在 `web-server/src/`): + - `common/zip-reader.ts`:纯 TS 极简 ZIP 读取器(中央目录/EOCD、STORE+DEFLATE、 + ZIP64 大小字段、Shift-JIS/GBK 文件名回退、拒绝 zip-slip 与加密条目、 + 不支持的压缩方式报错)。 + - `models/model-convert.ts`:解压 → 选主模型 → Assimp 转换 → 回读校验 → 回退。 + - `models/glb-textures.ts`:把 GLB 里 Assimp 写成外部 URI 的贴图内嵌进 BIN chunk。 + - `models/default-model-source.ts`:渲染 / 写入 `src/config/default-model.ts`。 + - `config/default-model.ts`:默认模型种子(服务端/CLI 写入,运行期只读)。 +- **选主模型**:优先 `MODEL_CONVERT_PMX`(默认 `Hatsune Miku.pmx`,大小写不敏感); + 否则取目录最浅、其次文件最大者。 +- **转换与回退**:先试 `MODEL_CONVERT_FORMAT`(默认 `fbx`);**导出后必须用 + `assimp info` 回读校验**,失败自动回退 `glb`,两次尝试写进 `conversion_note`。 + 实测 Assimp 6.0.4 对 714 骨骼的 Miku PMX 导出 FBX 失败(退出码 11,产物回读报 + `FBX-DOM no Objects dictionary found`),实际入库为 GLB。 +- **默认模型**:转换成功且 `autoDefault` 不为 false(默认 true)→ `is_primary=1` + + `status=published`,并把身份写进源码种子;`GET /api/catalog/export` 的 + `defaultModelId` 解析顺序为 `DEFAULT_MODEL_ID` → 源码种子(按产物 sha256 匹配) + → 已发布且 is_primary 的 VRM → 任意已发布 VRM → id 最小的已发布模型。 +- **溯源**:压缩包原件存为 `model-archive` 资产,记入 `models.source_asset_id` + (迁移 `AddModelConversionFields1789450000000`);`models.conversion_note` 记转换说明。 +- **失败**:包内没有 `.pmx`、Assimp 不可用、两种格式都失败 → **HTTP 422**, + 不产生模型记录。 +- **文件名编码**:`common/upload.util.ts` 的 `decodeUploadName()` 修正 multipart + 文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_…`)。 +- 契约:`contracts/model-formats.md` §1.1、`contracts/data-models.md` §4.1; + DTO 新增 `conversionNote` / `fileSha256`;测试 `test/model-archive.spec.ts`(22 项)。 + +## 12. 首次启动的 .env 引导(deploy/init-env.sh)与监听地址 + +- `deploy/init-env.sh`:交互式逐项询问 `DB_HOST / DB_PORT / DB_USER / DB_PASS / + DB_NAME / PORT / ADMIN_PORT / HOST`,回车保留默认;口令 `read -rs` 不回显、 + 按约定写盘不加引号;校验必填与端口范围;可用 Node + mysql2 做连通性自检 + (口令只经环境变量传递、失败仅告警);只替换/追加这 8 个键、保留模板注释, + 写完 `chmod 600` 并回读校验;结尾打印摘要(口令 `***`)与变更列表。 +- 开关:`--force` / `--check` / `-y|--yes|--non-interactive` / `-i|--interactive` / + `--skip-db-check` / `--dir` / `--env-file` / `--example` / `--timeout` / `-h`; + 环境变量 `OPENDANCE_NONINTERACTIVE` / `OPENDANCE_INTERACTIVE` / + `OPENDANCE_SKIP_DB_CHECK` / `OPENDANCE_ENV_FILE` / `OPENDANCE_ENV_EXAMPLE` / + `OPENDANCE_PROMPT_TIMEOUT`。 +- **非交互绝不挂住**:stdin 非 TTY 且是 ` ( ) !), + 脚本检测到会告警。写回只替换或追加这 8 个键、保留模板注释,写完 + chmod 600 并回读校验;结尾打印摘要(口令 ***)与本次变更列表。 + + 开关: + --force 已有 .env 也重新逐项询问 + --check 只校验,不修改文件、不提问 + -y, --yes, --non-interactive + 非交互:只校验缺字段与端口,绝不提问 + -i, --interactive 即使 stdin 不是 TTY 也强制提问(读取超时仍生效) + --skip-db-check 跳过数据库连通性自检 + --dir DIR 等价于 --env-file DIR/.env --example DIR/.env.example + --env-file PATH 指定 .env 路径(默认脚本目录下的 .env) + --example PATH 指定模板路径(默认 .env 同目录的 .env.example) + --timeout SECONDS 非 TTY 下每次读取的超时秒数(默认 10) + -h, --help 显示帮助 + + 环境变量:OPENDANCE_NONINTERACTIVE / OPENDANCE_INTERACTIVE / + OPENDANCE_SKIP_DB_CHECK / OPENDANCE_ENV_FILE / + OPENDANCE_ENV_EXAMPLE / OPENDANCE_PROMPT_TIMEOUT + + 非交互绝不挂住:stdin 非 TTY 且是 ` 为服务器 IP) 客户端接口: http://:3000/api 健康检查: http://:3000/health @@ -145,7 +187,8 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明 cd web-server npm run contracts:check # 契约版本一致性 npm run typecheck # TypeScript 严格检查 - npm run test # 85 项单元测试(含 31 项 VMD) + npm run test # 125 项单元测试 / 7 个文件(含 VMD 31、模型压缩包 22、 + # init-env 16、预处理 25) npm run build # 生产构建 npm run start:prod & # 启动 npm run test:smoke # 57 项端到端冒烟(需要数据库可连通) @@ -170,10 +213,12 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明 ADMIN_PORT 管理端端口(默认 PORT + 1,即 3001)。浏览器访问管理界面 (管理页面 + 上面全部接口);设为与 PORT 相同即为 单端口兼容模式,日志会提示「未分端口」 - HOST 监听地址(软件 API 端口)。反代与后端同机用 127.0.0.1 - (推荐);反代在别的机器用 0.0.0.0,并用防火墙限制来源 - ADMIN_HOST 管理端监听地址(可选,默认跟随 HOST)。需要接口对外、 - 管理端只对本机时设为 127.0.0.1 + HOST 监听地址(软件 API 端口),**默认 0.0.0.0**,因此装好即可 + 被客户端访问(此时请用防火墙限制来源)。反代与后端同机时 + 建议改为 127.0.0.1,让后端不直接对外暴露 + ADMIN_HOST 管理端监听地址(可选,默认跟随 HOST;HOST 未设置时同样 + 回落 0.0.0.0)。需要接口对外、管理端只对本机时设为 + 127.0.0.1 端口配置非法(非 1..65535 整数)时,生产环境启动会直接中止; ADMIN_PORT 设为与 PORT 相同只打印提示,不中止。 ADMIN_DIR 管理端静态站点目录(默认 ../admin,即 /opt/opendance/admin) @@ -182,7 +227,16 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明 ★ 反向代理层的同名限制必须不小于此值,否则大模型上传 会在代理层被拒(nginx client_max_body_size / IIS requestLimits),详见 deploy/proxy/README.md - ASSIMP_BIN 可选:Assimp 可执行文件,FBX/glTF 动作预处理需要 + ASSIMP_BIN 可选:Assimp 可执行文件,FBX/glTF 动作预处理需要; + 上传 .zip 模型包时**必须**能调用到,否则转换失败(422) + MODEL_CONVERT_FORMAT 模型压缩包首选的目标格式(默认 fbx)。导出后会用 + `assimp info` 回读校验,失败自动回退 glb + MODEL_CONVERT_PMX 压缩包内优先选用的 PMX 文件名(默认 Hatsune Miku.pmx, + 大小写不敏感);找不到时取目录最浅、其次文件最大者 + MODEL_CONVERT_TIMEOUT_MS + 单次转换超时(毫秒,默认 180000) + DEFAULT_MODEL_ID 可选:显式指定默认模型 id;不设置时按源码种子 + src/config/default-model.ts 匹配,再回落到 VRM 优先规则 CONTRACTS_DIR 可选:契约目录,设置后 /health 反映实时契约版本 (原生部署默认指向 /opt/opendance/contracts) @@ -202,7 +256,8 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明 POST /api/motions/:id/preprocess BVH/VMD/FBX → MediaPipe33 关键点 GET /api/motions/:id/keypoints 下载关键点序列(NDJSON) GET /api/models 模型列表 - POST /api/models 上传模型 + 来源信息(VRM 优先) + POST /api/models 上传模型 + 来源信息(VRM/GLB/FBX, + 或含 .pmx 的 .zip 模型包) POST /api/models/:id/primary 设为全局默认模型 GET /api/reviews 审核流水 / 待审列表 POST /api/reviews/:type/:id/approve 审核通过 @@ -215,6 +270,86 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明 上传文件通过 /static////.ext 访问, 相同内容只落盘一次(sha256 去重)。 +支持的模型格式(POST /api/models 上传) + VRM .vrm 首选(P0);glTF 2.0 二进制 + VRM 扩展 + glTF Binary .glb P1;需含骨骼 + glTF .gltf P2;多文件,建议打包后上传 + FBX .fbx P2;需 Assimp,管理端仅归档 + MMD 压缩包 .zip P1;内含 .pmx(+ textures/),服务端自动解压转换 + +模型压缩包(.zip)自动转换 + 上传的 .zip 视为 MMD 模型包,走"解压 → 选主模型 → 转换 → 回读校验 → 入库", + 而不是把压缩包当模型文件存下来。 + + 选主模型 优先 MODEL_CONVERT_PMX 指定的文件名(默认 Hatsune Miku.pmx, + 大小写不敏感);否则取目录最浅、其次文件最大者。只认 .pmx。 + 转换 先试 MODEL_CONVERT_FORMAT(默认 fbx),调 ASSIMP_BIN 导出。 + 校验 导出后**必须**用 `assimp info` 回读校验。Assimp 的 FBX 导出器 + 对带蒙皮的大骨架模型会"写出损坏文件却返回非 0 退出码",只信 + 退出码会把坏文件入库。 + 回退 FBX 失败(导出失败或回读失败)→ 自动改用 GLB,两次尝试写入 + models.conversion_note。 + 贴图 GLB 产物里 Assimp 会把贴图写成外部 URI(还带反斜杠),服务端 + 把压缩包内的贴图内嵌进 GLB 的二进制块,保证单文件可加载。 + 默认模型 转换成功且 autoDefault 不为 false(默认 true)→ is_primary=1 + + status=published,并把身份写进源码 src/config/default-model.ts。 + 溯源 压缩包原件以 model-archive 资产留存,写入 models.source_asset_id; + 转换说明写入 models.conversion_note(迁移 AddModelConversionFields)。 + 失败 压缩包内没有 .pmx、Assimp 不可用、两种格式都失败 → HTTP 422, + 不产生模型记录。 + + 文件名编码 upload.util.ts 的 decodeUploadName() 修正 multipart 文件名的 + latin1/UTF-8 乱码(中文文件名原先会存成 模å_…)。 + 管理端 上传框 accept 增加 .zip,提示会说明"会自动转换并设为默认"; + 预览面板新增「转换来源」与「文件 sha256」。 + 新增字段 DTO/契约新增 conversionNote、fileSha256。 + + 实测:上传 示例文件/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip + (1,093,100 字节)→ HTTP 201、0.89s、format=glb、isPrimary=true、 + status=published。服务端日志显示 Assimp 6.0.4 导出 FBX 失败(退出码 11), + 回退 GLB 后回读通过:1,152,464 字节、238 关节、已内嵌 2 张贴图, + conversionNote = `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`。 + +默认模型是怎么定的 + GET /api/catalog/export 的 defaultModelId 按以下顺序解析: + + 0. 环境变量 DEFAULT_MODEL_ID(且模型存在且已发布) + 1. 源码种子 src/config/default-model.ts 的 DEFAULT_MODEL_SEED——按转换产物 + sha256 匹配已发布模型,换库 / 重建库后仍有效 + 2. format='vrm' 且 published 且 is_primary=1 + 3. 任意 published 的 VRM(id 升序取第一个) + 4. id 最小的已发布模型 + 5. 无可用模型 → 客户端用内置占位模型 + + 当前种子指向 id=16、name=`初音未来 Hatsune Miku`、format=glb、 + sha256=`6d0ab0dce97c43dd7de84aba29b09f3b64f2953722d31b5421a39189ff2a8464`。 + 实测:库里有 2 个 VRM 的情况下,defaultModelId 仍解析为 16。 + 也可手工指定:POST /api/models/:id/primary。 + 第 1 步的源码写入通常由服务端在转换成功时完成;生产 systemd 加固 + (ProtectSystem=strict)下服务进程写不了程序目录,可改用下面的 CLI 补写。 + +导入 / 补写默认模型(web-server/scripts/import-default-model.mjs) + 需先 `npm run build`(脚本复用编译产物 dist/models/default-model-source.js)。 + + cd web-server + 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 + # 上传 → 服务端转换入库 → 设为默认 → 把身份写进 src/config/default-model.ts + + node scripts/import-default-model.mjs --write-source-only --model-id 16 + # 只根据已入库模型补写源码,不调上传 API + + node scripts/import-default-model.mjs --write-source-only --model-id 16 --dry-run + # 只打印将写入的内容,不落盘 + + 说明:--url 默认 http://127.0.0.1:3000,--source-type 默认 other; + 上传时脚本固定带 autoDefault=true。 + 支持的动作格式(POST /api/motions 上传 → POST /api/motions/:id/preprocess 预处理) BVH .bvh 内置纯 TS 解析器,保留源帧率 VMD .vmd 内置解析(MikuMikuDance),固定 30fps; @@ -247,7 +382,13 @@ VMD 动作上传前后自查 contracts/ 跨端契约(权威,改接口先改这里) web-server/ NestJS 服务端源码 web-server/admin/ React 管理端 + web-server/src/common/zip-reader.ts 纯 TS ZIP 读取器 + web-server/src/models/model-convert.ts 压缩包 → FBX/GLB 转换 + web-server/src/models/glb-textures.ts GLB 贴图内嵌 + web-server/src/config/default-model.ts 默认模型种子(服务端/CLI 写入) + web-server/scripts/import-default-model.mjs 默认模型导入 / 补写 CLI deploy/ 业务服务部署(系统服务,不含 MySQL、不含反向代理) + deploy/init-env.sh 首次启动的 .env 交互引导(install.sh 会调用) deploy/proxy/ 反向代理配置说明(nginx / IIS,仅供参考不部署) THIRD_PARTY.md 第三方开源项目声明 @@ -258,7 +399,8 @@ VMD 动作上传前后自查 1. .env 已被 .gitignore 忽略,禁止提交;仓库中只保留 .env.example。 2. 数据库账号使用最小权限,不授予 GRANT OPTION / FILE / SUPER。 3. 本版本未内置管理端登录认证,请部署在内网,或在反向代理层加 Basic Auth / SSO。 -4. 上传的模型与动作必须填写来源五元组并通过审核后才对客户端可见。 +4. 上传的模型与动作默认需填写来源五元组并通过审核后才对客户端可见;上传 .zip + 模型包且未关闭 autoDefault 时,转换成功会直接设为已发布的默认模型(见 §五)。 5. 未使用 SMPL 拓扑,统一 MediaPipe 33 点,规避非商用许可限制。 ---------------------------------------------------------------- diff --git a/contracts/VERSION b/contracts/VERSION index 4044fac..29c3f7e 100644 --- a/contracts/VERSION +++ b/contracts/VERSION @@ -1,8 +1,8 @@ # OpenDance Studio 契约版本 # 规则:任何字段级变更必须提升对应条目版本,并回到 opendance-web 修改后再向另两端建迁移任务。 -rest-api: 0.2.0 +rest-api: 0.3.0 websocket-keypoints: 0.1.0 -data-models: 0.2.0 +data-models: 0.3.0 skeleton-topology: 0.1.0 -model-formats: 0.2.0 -database: 0.1.0 +model-formats: 0.3.0 +database: 0.2.0 diff --git a/contracts/data-models.md b/contracts/data-models.md index 5037968..da64438 100644 --- a/contracts/data-models.md +++ b/contracts/data-models.md @@ -1,4 +1,4 @@ -# contracts/data-models.md — 数据模型契约 v0.2.0 +# contracts/data-models.md — 数据模型契约 v0.3.0 Web 服务端是唯一权威定义,本文件是另两端消费的**稳定视图**。 字段名在 JSON 中统一 `camelCase`;数据库列名 `snake_case`(见 `database.md`)。 @@ -102,22 +102,29 @@ rejected 已驳回 | `fileUrl` | string | 读 | | | `fileSize` | number | 读 | | | `thumbnailUrl` | string\|null | 读 | | +| `conversionNote` | string\|null | 读 | 由压缩包转换而来时的说明(`Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`);直传模型为 null | +| `fileSha256` | string\|null | 读 | 模型文件(转换产物)的 sha256,用于换库后定位默认模型 | | `source*` | | ✅ | 来源五元组 | | `status` | `PublishStatus` | | | | `createdAt` / `updatedAt` | string | 读 | | -### 4.1 VRM 优先规则(冻结) +### 4.1 默认模型选择规则(冻结) -客户端选择默认模型的顺序: +服务端 `GET /api/catalog/export` 的 `defaultModelId` 按以下顺序解析: ``` -1. 显式指定 modelId(曲目动作绑定) +0. 环境变量 DEFAULT_MODEL_ID 指定,且该模型存在且已发布 +1. 源码内写死的默认模型种子(web-server/src/config/default-model.ts 的 + DEFAULT_MODEL_SEED):按转换产物 sha256 匹配已发布模型 —— 换库/重建库后仍然有效 2. models 中 format = 'vrm' 且 status = 'published' 且 is_primary = 1 3. models 中 format = 'vrm' 且 status = 'published',按 id 升序取第一个 4. models 中 status = 'published',按 (format='vrm' DESC, id ASC) 取第一个 5. 无可用模型 → 客户端使用内置占位模型 ``` +第 0/1 步是 v0.3.0 新增:上传 MMD 模型压缩包并转换成功后,服务端会把该模型 +`is_primary=1` + `status=published`,并把身份写进源码,使它成为开箱默认模型。 + `isPrimary` 通过 `POST /api/models/:id/primary` 设置,服务端保证互斥(同一事务内先清零)。 ## 5. `ReviewRecord`(审核流水) diff --git a/contracts/database.md b/contracts/database.md index a3596c4..44c0ecc 100644 --- a/contracts/database.md +++ b/contracts/database.md @@ -36,6 +36,12 @@ DB_LOGGING=false - 每个 migration 必须可逆(提供 `down` 方法) - 迁移文件名格式:`-.ts` +## 增量迁移(v0.2.0) + +| 迁移 | 变更 | +|---|---| +| `AddModelConversionFields` | `models` 增加 `source_asset_id BIGINT NULL`(上传的压缩包,用于溯源)与 `conversion_note VARCHAR(500) NULL`(转换说明,见 model-formats.md §1.1) | + ## 表清单(v0.1.0) | 表 | 用途 | 任务 | @@ -43,7 +49,7 @@ DB_LOGGING=false | `songs` | 曲目元数据 | T-WEB-003 | | `assets` | 上传文件元数据(去重、sha256、物理路径) | T-WEB-004 | | `motions` | 动作数据(BVH/FBX…)+ 来源 + 预处理产物 | T-WEB-004 / 006 | -| `models` | 角色模型(VRM 优先)+ 来源 | T-WEB-005 | +| `models` | 角色模型(VRM 优先)+ 来源 + 压缩包转换溯源 | T-WEB-005 | | `review_records` | 上传审核流水 | T-WEB-007 | ## 状态机(`status` 列,v0.1.0) diff --git a/contracts/model-formats.md b/contracts/model-formats.md index b31f67a..73c639e 100644 --- a/contracts/model-formats.md +++ b/contracts/model-formats.md @@ -1,4 +1,4 @@ -# contracts/model-formats.md — 模型与动作格式契约 v0.2.0 +# contracts/model-formats.md — 模型与动作格式契约 v0.3.0 ## 1. 角色模型(models) @@ -8,6 +8,24 @@ | glTF Binary | `.glb` | P1 | 需含骨骼(skin);无骨骼则仅作静态展示 | | glTF | `.gltf` (+ `.bin`, 贴图) | P2 | 多文件,管理端需打包为 zip 上传 | | FBX | `.fbx` | P2 | 需 Assimp 转换,管理端仅归档,不保证重定向质量 | +| **MMD 压缩包** | `.zip` | P1 | 内含 `.pmx`(+ `textures/`)。服务端自动解压并转换,见 §1.1 | + +### 1.1 模型压缩包转换(`.zip` → FBX / GLB) + +上传 `.zip` 时走"解压 → 选主模型 → 转换 → 入库",而不是把压缩包当模型文件存下来。 + +| 步骤 | 规则 | +|---|---| +| 选主模型 | 优先 `MODEL_CONVERT_PMX` 指定的文件名(默认 `Hatsune Miku.pmx`,大小写不敏感);否则取目录最浅、其次文件最大者。**只认 `.pmx`**(MMD 的 PMX 模型;PMD/PMX 之外的格式暂不支持) | +| 转换 | `MODEL_CONVERT_FORMAT`(默认 `fbx`)指定首选格式,调 `ASSIMP_BIN` 导出 | +| 校验 | **导出后必须回读校验**(`assimp info`)。Assimp 的 FBX 导出器对带蒙皮的大骨架模型会"写出损坏文件"(实测 714 骨骼的 PMX:导出退出码 11,产物回读报 `FBX-DOM no Objects dictionary found`),只信退出码会把坏文件入库 | +| 回退 | FBX 失败(导出失败或回读失败)→ 自动改用 **GLB**,并把两次尝试写进 `models.conversion_note` | +| 贴图 | GLB 产物里 Assimp 会把贴图写成外部 URI(还带 `\` 反斜杠),服务端把压缩包内的贴图**内嵌**进 GLB 的二进制块,保证单文件可加载 | +| 溯源 | 压缩包原件以 `model-archive` 资产留存,写入 `models.source_asset_id` | +| 默认模型 | 转换成功且 `autoDefault` 不为 false:`is_primary=1` + `status=published`,并把身份写进源码 `web-server/src/config/default-model.ts`,见 `data-models.md` §4.1 | +| 失败 | 压缩包里没有 `.pmx`、Assimp 不可用、两种格式都失败 → **HTTP 422**,不产生模型记录 | + +安全:解压前逐条校验路径,拒绝绝对路径与 `..`(zip-slip);拒绝加密条目;只支持 STORE / DEFLATE。 **VRM 优先**的选取顺序见 `data-models.md` §4.1。 diff --git a/contracts/rest-api.yaml b/contracts/rest-api.yaml index ed89909..6d9d835 100644 --- a/contracts/rest-api.yaml +++ b/contracts/rest-api.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: OpenDance Studio Web 管理服务端 API - version: 0.2.0 + version: 0.3.0 description: | OpenDance Studio 的唯一数据源与契约权威。 契约版本见 contracts/VERSION。字段语义见 contracts/data-models.md。 @@ -317,7 +317,22 @@ paths: schema: { $ref: '#/components/schemas/ModelPage' } post: tags: [models] - summary: 上传模型 + 来源信息(VRM 优先) + summary: 上传模型 + 来源信息(VRM 优先;支持 MMD 模型压缩包自动转换) + description: | + `file` 为单个文件: + + - `.vrm` / `.glb` / `.gltf` / `.fbx`:直接入库,`format` 由扩展名决定 + - `.zip`:视为 MMD 模型包。服务端解压 → 选取 `.pmx` + (优先 `MODEL_CONVERT_PMX` 指定的名字,默认 `Hatsune Miku.pmx`; + 否则取目录最浅、文件最大者)→ 调 Assimp 导出 **FBX**, + 导出后**回读校验**(Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写出损坏文件), + 失败自动回退 **GLB**,并把过程写入 `conversionNote`。 + GLB 产物会把 Assimp 写成外部 URI 的贴图内嵌进二进制块,保证单文件可用。 + 压缩包原件作为 `model-archive` 资产留存以便溯源。 + + 转换成功且 `autoDefault` 不为 false 时,该模型会被置为全局默认模型 + (`isPrimary=1` + `status=published`),并把身份(名称/格式/产物 sha256) + 写进源码 `web-server/src/config/default-model.ts`。 requestBody: required: true content: @@ -329,6 +344,9 @@ paths: file: { type: string, format: binary } name: { type: string } isPrimary: { type: boolean } + autoDefault: + type: boolean + description: 仅对 `.zip` 生效;转换成功后是否自动设为默认模型,默认 true sourceType: { $ref: '#/components/schemas/SourceType' } sourceName: { type: string } sourceUrl: { type: string } @@ -749,6 +767,14 @@ components: fileUrl: { type: string, nullable: true } fileSize: { type: integer, nullable: true } thumbnailUrl: { type: string, nullable: true } + conversionNote: + type: string + nullable: true + description: 由压缩包转换而来时的说明;直传模型为 null + fileSha256: + type: string + nullable: true + description: 模型文件(转换产物)的 sha256,用于换库后定位默认模型 status: { $ref: '#/components/schemas/PublishStatus' } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } diff --git a/deploy/.env.example b/deploy/.env.example index 1e39d8b..6e2c9bf 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -41,8 +41,21 @@ STORAGE_DIR=/var/lib/opendance/storage # IIS requestLimits),否则大模型上传会在代理层被拒(413)。 UPLOAD_MAX_BYTES=536870912 -# ---- 可选的 Assimp(FBX/glTF 动作预处理)---- +# ---- 可选的 Assimp(FBX/glTF 动作预处理;也用于压缩包模型的转换)---- +# 上传 .zip(内含 .pmx 的 MMD 模型包)时必须能调用到它,否则转换会失败 ASSIMP_BIN=assimp +# 压缩包转换的目标格式:fbx(优先)| glb +# Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写坏文件,服务端会回读校验并自动回退 GLB +MODEL_CONVERT_FORMAT=fbx +# 压缩包里优先选用的 PMX 文件名(大小写不敏感);找不到时按"目录最浅、文件最大"挑 +MODEL_CONVERT_PMX=Hatsune Miku.pmx +# 单次转换超时(毫秒) +MODEL_CONVERT_TIMEOUT_MS=180000 + +# ---- 默认模型(可选)---- +# 显式指定默认模型 id;不设置时按源码 src/config/default-model.ts 里的种子匹配, +# 再回落到契约 data-models.md §4.1 的 VRM 优先规则 +# DEFAULT_MODEL_ID=1 # ---- 契约目录 ---- # 指向安装目录下的 contracts/,使 /health 与 /api/catalog/version 反映实时契约版本 diff --git a/deploy/init-env.sh b/deploy/init-env.sh new file mode 100755 index 0000000..b18e758 --- /dev/null +++ b/deploy/init-env.sh @@ -0,0 +1,647 @@ +#!/bin/bash +# ============================================================ +# OpenDance Studio Web 服务端 —— deploy/.env 初始化引导 +# +# 作用:把「cp deploy/.env.example deploy/.env && vi deploy/.env」这一步 +# 变成可交互的问答,并在写盘前完成必填项 / 端口范围 / 数据库连通性自检。 +# +# 用法: +# bash deploy/init-env.sh # 交互引导(终端里逐项询问) +# bash deploy/init-env.sh --force # 已有 .env 也重新逐项询问 +# bash deploy/init-env.sh --check # 只校验,不改动、不提问 +# bash deploy/init-env.sh --non-interactive # 非交互:只检查缺字段,绝不阻塞 +# +# 选项: +# --check 只校验,不修改文件、不提问(缺失必填 / 端口非法时非 0 退出) +# --force 已有 .env 也重新询问一遍(默认:已有且完整则跳过询问) +# -y, --yes 非交互(等同 --non-interactive) +# --non-interactive CI / 自动化:跳过全部提问,仅做缺字段与端口校验 +# -i, --interactive 即使 stdin 不是 TTY 也强制提问(读取超时仍然生效) +# --skip-db-check 跳过数据库连通性自检 +# --dir DIR 以 DIR/.env 为目标、DIR/.env.example 为模板 +# --env-file PATH 指定 .env 路径(默认 <脚本目录>/.env) +# --example PATH 指定模板路径(默认 .env 同目录的 .env.example) +# --timeout SECONDS 非 TTY 下每次读取的超时秒数(默认 10) +# -h, --help 显示本帮助 +# +# 环境变量: +# OPENDANCE_NONINTERACTIVE=1 同 --non-interactive +# OPENDANCE_INTERACTIVE=1 同 --interactive +# OPENDANCE_SKIP_DB_CHECK=1 同 --skip-db-check +# OPENDANCE_ENV_FILE=PATH 同 --env-file +# OPENDANCE_ENV_EXAMPLE=PATH 同 --example +# OPENDANCE_PROMPT_TIMEOUT=SEC 同 --timeout +# +# 说明: +# * 数据库口令以 `DB_PASS=值` 原样写入(**不加引号**)。请勿使用会破坏 +# shell 语法的字符(& $ ` \ 空格 ; | < >),否则 install.sh 的 +# `set -a; . .env` 会解析失败。脚本检测到这类字符会给出警告。 +# * 非交互判定:--yes / -y / --non-interactive / OPENDANCE_NONINTERACTIVE=1 +# 一定跳过提问;否则 stdin 是 TTY、管道、socket 或普通文件时才提问 +# (因此 `printf '...\n' | bash deploy/init-env.sh` 与自动化测试可用)。 +# 每次 read 都带超时,EOF 立即报错退出——CI 与自动化下绝不挂住。 +# * 写回时只替换/追加被填写的键,模板注释与其它字段原样保留。 +# ============================================================ +set -uo pipefail + +SELF="${BASH_SOURCE[0]}" +SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" + +ENV_FILE="${OPENDANCE_ENV_FILE:-$SCRIPT_DIR/.env}" +EXAMPLE_FILE="${OPENDANCE_ENV_EXAMPLE:-}" +MODE_CHECK=0 +FORCE=0 +NONINTERACTIVE="${OPENDANCE_NONINTERACTIVE:-0}" +FORCE_INTERACTIVE="${OPENDANCE_INTERACTIVE:-0}" +SKIP_DB_CHECK="${OPENDANCE_SKIP_DB_CHECK:-0}" +PROMPT_TIMEOUT="${OPENDANCE_PROMPT_TIMEOUT:-10}" + +# 颜色:仅在真正的终端上启用,CI / 测试里输出纯文本便于断言 +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + C_RED=$'\033[1;31m'; C_YEL=$'\033[1;33m'; C_GRN=$'\033[1;32m' + C_BLU=$'\033[1;34m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m' +else + C_RED=''; C_YEL=''; C_GRN=''; C_BLU=''; C_DIM=''; C_RST='' +fi + +log() { printf '%s[init-env]%s %s\n' "$C_BLU" "$C_RST" "$*"; } +ok() { printf '%s[init-env]%s %s\n' "$C_GRN" "$C_RST" "$*"; } +warn() { printf '%s[init-env]%s %s\n' "$C_YEL" "$C_RST" "$*" >&2; } +err() { printf '%s[init-env]%s %s\n' "$C_RED" "$C_RST" "$*" >&2; } +die() { err "$*"; exit 1; } + +usage() { + cat <<'EOF' +用法:bash deploy/init-env.sh [选项] + + --check 只校验,不修改文件、不提问 + --force 已有 .env 也重新逐项询问 + -y, --yes 非交互(等同 --non-interactive) + --non-interactive CI / 自动化:跳过全部提问,仅做缺字段校验 + -i, --interactive 即使 stdin 不是 TTY 也强制提问(读取超时仍生效) + --skip-db-check 跳过数据库连通性自检 + --dir DIR 等价于 --env-file DIR/.env --example DIR/.env.example + --env-file PATH 指定 .env 路径(默认 <脚本目录>/.env) + --example PATH 指定模板路径(默认 .env 同目录的 .env.example) + --timeout SECONDS 非 TTY 下每次读取的超时秒数(默认 10) + -h, --help 显示本帮助 + +环境变量:OPENDANCE_NONINTERACTIVE / OPENDANCE_INTERACTIVE / + OPENDANCE_SKIP_DB_CHECK / OPENDANCE_ENV_FILE / + OPENDANCE_ENV_EXAMPLE / OPENDANCE_PROMPT_TIMEOUT +EOF +} + +# ---------- 参数 ---------- +DIR_OPT="" +while [ $# -gt 0 ]; do + case "$1" in + --check) MODE_CHECK=1 ;; + --force|-f) FORCE=1 ;; + -y|--yes|--non-interactive) NONINTERACTIVE=1 ;; + -i|--interactive) FORCE_INTERACTIVE=1 ;; + --skip-db-check) SKIP_DB_CHECK=1 ;; + --dir) shift; [ $# -gt 0 ] || die "--dir 需要一个目录参数"; DIR_OPT="$1" ;; + --dir=*) DIR_OPT="${1#*=}" ;; + --env-file) shift; [ $# -gt 0 ] || die "--env-file 需要一个路径参数"; ENV_FILE="$1" ;; + --env-file=*) ENV_FILE="${1#*=}" ;; + --example) shift; [ $# -gt 0 ] || die "--example 需要一个路径参数"; EXAMPLE_FILE="$1" ;; + --example=*) EXAMPLE_FILE="${1#*=}" ;; + --timeout) shift; [ $# -gt 0 ] || die "--timeout 需要一个秒数"; PROMPT_TIMEOUT="$1" ;; + --timeout=*) PROMPT_TIMEOUT="${1#*=}" ;; + -h|--help) usage; exit 0 ;; + *) die "未知参数:$1(用 --help 查看用法)" ;; + esac + shift +done + +if [ -n "$DIR_OPT" ]; then + ENV_FILE="$DIR_OPT/.env" + EXAMPLE_FILE="$DIR_OPT/.env.example" +fi +[ -n "$ENV_FILE" ] || die "--env-file 路径不能为空" + +case "$PROMPT_TIMEOUT" in + ''|*[!0-9]*) die "--timeout 必须是整数秒(当前:$PROMPT_TIMEOUT)" ;; +esac + +# 模板默认:优先 .env 同目录的 .env.example,其次脚本所在目录 +if [ -z "$EXAMPLE_FILE" ]; then + if [ -f "$(dirname "$ENV_FILE")/.env.example" ]; then + EXAMPLE_FILE="$(dirname "$ENV_FILE")/.env.example" + else + EXAMPLE_FILE="$SCRIPT_DIR/.env.example" + fi +fi + +# ---------- 小工具 ---------- +# 1..65535 的整数端口 +is_port() { + local v="${1:-}" + case "$v" in ''|*[!0-9]*) return 1 ;; esac + [ "${#v}" -le 5 ] || return 1 + [ "$v" -ge 1 ] 2>/dev/null && [ "$v" -le 65535 ] 2>/dev/null +} + +# 模板占位符(如 / <部署方提供>)视为“未填写” +is_placeholder() { + case "${1:-}" in '<'*'>') return 0 ;; *) return 1 ;; esac +} + +# 读取 .env 中某个键的当前值(最后一次出现的、未注释的那行) +# 只做文本解析,不 source 文件,避免 .env 内容被执行 +get_env_value() { + local key="$1" file="${2:-$ENV_FILE}" raw="" + [ -f "$file" ] || { printf ''; return 0; } + raw="$(awk -v k="$key" ' + $0 ~ "^[[:space:]]*" k "[[:space:]]*=" { + line = $0 + sub(/^[[:space:]]*/, "", line) + sub("^" k "[[:space:]]*=[[:space:]]*", "", line) + sub(/[[:space:]]+$/, "", line) + val = line + } + END { if (val != "") print val } + ' "$file")" + case "$raw" in + \"*\") raw="${raw#\"}"; raw="${raw%\"}" ;; + \'*\') raw="${raw#\'}"; raw="${raw%\'}" ;; + esac + printf '%s' "$raw" +} + +# 取默认值:先看 .env(已填写),再看模板 +default_for() { + local key="$1" v="" + v="$(get_env_value "$key" "$ENV_FILE")" + if [ -z "$v" ] || is_placeholder "$v"; then + v="$(get_env_value "$key" "$EXAMPLE_FILE")" + fi + printf '%s' "$v" +} + +# 替换或追加单个键,保留文件中的注释与其它行 +# 值通过环境变量传入 awk(ENVIRON 不做转义处理),因此 $ * % 等原样落盘 +set_env_value() { + local key="$1" value="$2" file="$3" tmp="" rc=0 + local dir + dir="$(dirname "$file")" + [ -d "$dir" ] || mkdir -p "$dir" || return 1 + tmp="$(mktemp "$dir/.env.tmp.XXXXXX")" || return 1 + KEY_NAME="$key" KEY_VALUE="$value" awk ' + BEGIN { k = ENVIRON["KEY_NAME"]; v = ENVIRON["KEY_VALUE"]; done = 0 } + $0 ~ "^[[:space:]]*" k "[[:space:]]*=" { print k "=" v; done = 1; next } + { print } + END { if (!done) print k "=" v } + ' "$file" > "$tmp" || rc=$? + if [ "$rc" -ne 0 ]; then rm -f "$tmp"; return 1; fi + chmod 600 "$tmp" 2>/dev/null || true + mv "$tmp" "$file" || { rm -f "$tmp"; return 1; } + return 0 +} + +# 单字段校验:通过则输出空串,否则输出错误信息 +validate_field() { + local key="$1" val="${2:-}" required="$3" + if [ "$required" = 1 ] && { [ -z "$val" ] || is_placeholder "$val"; }; then + printf '%s 不能为空或仍是模板占位符(必填)' "$key"; return 0 + fi + case "$key" in + DB_PORT|PORT|ADMIN_PORT) + if [ -n "$val" ] && ! is_port "$val"; then + printf '%s 必须是 1..65535 的整数(当前:%s)' "$key" "$val"; return 0 + fi + ;; + esac + return 0 +} + +# 口令里会破坏 `set -a; . .env` 解析的字符(脚本按要求不加引号写盘) +unsafe_for_unquoted() { + case "${1:-}" in + *'&'*|*'$'*|*'`'*|*'\'*|*' '*|*' '*|*';'*|*'|'*|*'<'*|*'>'*|*'('*|*')'*|*'!'*) return 0 ;; + esac + return 1 +} + +# ---------- 交互判定 ---------- +# 管道 / socket / 普通文件也算“有输入源”,这样 `printf ... | bash init-env.sh` +# 以及 Node spawnSync({input}) 都能交互;/dev/null、已关闭的 stdin 则判为非交互。 +# 每次 read 都带超时,绝不无限阻塞。 +detect_interactive() { + [ "$MODE_CHECK" = 1 ] && return 1 + [ "$NONINTERACTIVE" = 1 ] && return 1 + [ "$FORCE_INTERACTIVE" = 1 ] && return 0 + [ -t 0 ] && return 0 + [ -p /dev/stdin ] && return 0 + [ -S /dev/stdin ] && return 0 + [ -f /dev/stdin ] && return 0 + return 1 +} + +INTERACTIVE=0 +IS_TTY=0 +[ -t 0 ] && IS_TTY=1 +READ_OPTS=() +if [ "$IS_TTY" != 1 ]; then + READ_OPTS=(-t "$PROMPT_TIMEOUT") +fi + +prompt_read_failed() { + local key="$1" + printf '\n' + err "读取 $key 失败:stdin 已结束或超过 ${PROMPT_TIMEOUT}s 无输入。" + err "非交互环境请改用:bash deploy/init-env.sh --non-interactive" + err "(或设置 OPENDANCE_NONINTERACTIVE=1,并提前把 $ENV_FILE 填好)" + exit 1 +} + +# 逐项询问,结果放入全局 ANS +ANS="" +prompt_field() { + local key="$1" usage_text="$2" dflt="$3" required="$4" silent="$5" + is_placeholder "$dflt" && dflt="" + local shown="$dflt" val="" rc=0 msg="" + if [ "$silent" = 1 ] && [ -n "$dflt" ]; then shown="***(已设置,直接回车保留)"; fi + while :; do + printf '\n%s──── %s ────%s\n' "$C_BLU" "$key" "$C_RST" + printf ' 用途:%s\n' "$usage_text" + if [ "$required" = 1 ]; then printf ' 必填:%s是%s\n' "$C_YEL" "$C_RST" + else printf ' 必填:否(直接回车用默认值)\n'; fi + if [ -n "$shown" ]; then printf ' 当前/默认:%s\n' "$shown" + else printf ' 当前/默认:%s(空)%s\n' "$C_YEL" "$C_RST"; fi + if [ "$silent" = 1 ]; then + printf ' 说明:输入不回显;口令不要加引号\n' + printf '请输入 %s:' "$key" + rc=0 + IFS= read -r -s ${READ_OPTS[@]+"${READ_OPTS[@]}"} val || rc=$? + printf '\n' + else + printf '请输入 %s(回车保留默认):' "$key" + rc=0 + IFS= read -r ${READ_OPTS[@]+"${READ_OPTS[@]}"} val || rc=$? + # 管道输入时回车不会被回显,补一个换行让输出可读 + [ "$IS_TTY" = 1 ] || printf '\n' + fi + if [ "$rc" -ne 0 ]; then prompt_read_failed "$key"; fi + [ -z "$val" ] && val="$dflt" + msg="$(validate_field "$key" "$val" "$required")" + if [ -z "$msg" ]; then ANS="$val"; return 0; fi + printf '%s ✗ %s%s\n' "$C_RED" "$msg" "$C_RST" + printf ' 请重新输入 %s。\n' "$key" + done +} + +# ---------- 数据库连通性自检 ---------- +# 用项目自带的 mysql2(node -e 动态 require),凭据只经环境变量传给子进程, +# 不出现在命令行参数里;失败只警告,不影响写盘。 +DB_CHECK_JS=' +try { + var mysql = require(process.env.MYSQL2_PATH); + var conn = mysql.createConnection({ + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT || 3306), + user: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + connectTimeout: 5000, + charset: "utf8mb4" + }); + conn.query("SELECT 1", function (err) { + if (err) { console.error("db-check: " + err.message); process.exit(1); } + conn.end(function () { process.exit(0); }); + }); +} catch (e) { + console.error("db-check: " + (e && e.message ? e.message : String(e))); + process.exit(1); +} +' + +find_mysql2() { + local c="" + local candidates=() + if [ -n "${OPENDANCE_MYSQL2_PATH:-}" ]; then candidates+=("$OPENDANCE_MYSQL2_PATH"); fi + candidates+=( + "$SCRIPT_DIR/../web-server/node_modules/mysql2" + "$PWD/web-server/node_modules/mysql2" + "$PWD/node_modules/mysql2" + "$SCRIPT_DIR/../node_modules/mysql2" + ) + if [ -n "${APP_DIR:-}" ]; then candidates+=("$APP_DIR/web-server/node_modules/mysql2"); fi + for c in "${candidates[@]}"; do + if [ -d "$c" ]; then printf '%s' "$c"; return 0; fi + done + return 1 +} + +db_check() { + local host="$1" port="$2" user="$3" pass="$4" name="$5" + if [ "$SKIP_DB_CHECK" = 1 ]; then + log "已跳过数据库连通性检查(--skip-db-check / OPENDANCE_SKIP_DB_CHECK)" + return 2 + fi + local node_bin="" + node_bin="$(command -v node 2>/dev/null || true)" + if [ -z "$node_bin" ]; then warn "未找到 node,跳过数据库连通性检查"; return 2; fi + local mysql2="" + mysql2="$(find_mysql2 || true)" + if [ -z "$mysql2" ]; then + warn "未找到 web-server/node_modules/mysql2,跳过数据库连通性检查" + return 2 + fi + log "尝试连接数据库 ${host}:${port}/${name}(5 秒超时,口令不出现在命令行/日志)…" + local out="" rc=0 + out="$(DB_HOST="$host" DB_PORT="$port" DB_USER="$user" DB_PASS="$pass" DB_NAME="$name" \ + MYSQL2_PATH="$mysql2" "$node_bin" -e "$DB_CHECK_JS" 2>&1)" || rc=$? + # 兜底脱敏:万一驱动把口令回显进错误信息 + if [ -n "$pass" ]; then out="${out//"$pass"/***}"; fi + if [ "$rc" -eq 0 ]; then + ok "数据库连接成功:${host}:${port}/${name}" + return 0 + fi + warn "数据库连接失败(口令已隐藏):" + printf '%s\n' "$out" | tail -n 3 | sed 's/^/ /' >&2 + warn "可先完成部署,稍后用 bash deploy/init-env.sh --check 复核。" + return 1 +} + +# ---------- 建文件 ---------- +if [ ! -f "$ENV_FILE" ]; then + if [ "$MODE_CHECK" = 1 ]; then + err "缺少 $ENV_FILE(--check 只校验,不会创建文件)" + exit 1 + fi + if [ ! -f "$EXAMPLE_FILE" ]; then + die "缺少 $ENV_FILE,且找不到模板 $EXAMPLE_FILE。 + 请从发布包中确认 deploy/.env.example 存在,或手动创建 $ENV_FILE。" + fi + mkdir -p "$(dirname "$ENV_FILE")" || die "无法创建目录:$(dirname "$ENV_FILE")" + cp "$EXAMPLE_FILE" "$ENV_FILE" || die "无法从模板复制:$EXAMPLE_FILE → $ENV_FILE" + chmod 600 "$ENV_FILE" 2>/dev/null || true + log "已从模板创建 $ENV_FILE(来源:$EXAMPLE_FILE)" +fi + +# ---------- 读取当前值 ---------- +CUR_PORT="$(get_env_value PORT "$ENV_FILE")" +[ -n "$CUR_PORT" ] || CUR_PORT="$(get_env_value API_PORT "$ENV_FILE")" # 兼容旧键名 +CUR_ADMIN="$(get_env_value ADMIN_PORT "$ENV_FILE")" +CUR_DB_HOST="$(get_env_value DB_HOST "$ENV_FILE")" +CUR_DB_PORT="$(get_env_value DB_PORT "$ENV_FILE")" +CUR_DB_USER="$(get_env_value DB_USER "$ENV_FILE")" +CUR_DB_PASS="$(get_env_value DB_PASS "$ENV_FILE")" +CUR_DB_NAME="$(get_env_value DB_NAME "$ENV_FILE")" +CUR_HOST="$(get_env_value HOST "$ENV_FILE")" + +# 必填是否已齐全(用于“已有 .env 且完整就不再询问”);端口已写但非法时 +# 不算“完整”,这样交互模式会重新询问而不是直接报错退出。 +current_complete() { + local k v p + for k in DB_HOST DB_USER DB_PASS DB_NAME; do + v="$(get_env_value "$k" "$ENV_FILE")" + if [ -z "$v" ] || is_placeholder "$v"; then return 1; fi + done + p="$(get_env_value DB_PORT "$ENV_FILE")" + if [ -n "$p" ] && ! is_port "$p"; then return 1; fi + p="$(get_env_value PORT "$ENV_FILE")" + [ -n "$p" ] || p="$(get_env_value API_PORT "$ENV_FILE")" + if [ -n "$p" ] && ! is_port "$p"; then return 1; fi + p="$(get_env_value ADMIN_PORT "$ENV_FILE")" + if [ -n "$p" ] && ! is_port "$p"; then return 1; fi + return 0 +} + +INTERACTIVE=0 +detect_interactive && INTERACTIVE=1 + +# ---------- 交互式引导 ---------- +if [ "$INTERACTIVE" = 1 ]; then + if [ "$FORCE" = 1 ]; then + log "交互式引导(--force:重新询问全部字段,回车保留当前值)" + elif current_complete; then + log "$ENV_FILE 已存在且必填项齐全,跳过询问(如需重新填写请加 --force)" + else + log "交互式引导:逐项填写(回车保留方括号中的当前/默认值)" + fi + + need_prompt=1 + if [ "$FORCE" != 1 ] && current_complete; then need_prompt=0; fi + + if [ "$need_prompt" = 1 ]; then + # DB_HOST + prompt_field DB_HOST "现有 MySQL / MariaDB 实例地址(本脚本不安装数据库)" \ + "$(default_for DB_HOST)" 1 0 + DB_HOST_V="$ANS" + + # DB_PORT + prompt_field DB_PORT "数据库端口(1..65535)" \ + "$(default_for DB_PORT)" 0 0 + [ -n "$ANS" ] || ANS="3306" + DB_PORT_V="$ANS" + + # DB_USER + prompt_field DB_USER "数据库账号" "$(default_for DB_USER)" 1 0 + DB_USER_V="$ANS" + + # DB_PASS(不回显) + prompt_field DB_PASS "数据库口令(输入不回显,写盘不加引号)" \ + "$(default_for DB_PASS)" 1 1 + DB_PASS_V="$ANS" + + # DB_NAME + prompt_field DB_NAME "数据库名" "$(default_for DB_NAME)" 1 0 + DB_NAME_V="$ANS" + + # PORT + prompt_field PORT "软件 API 端口(Windows/Godot/Android 客户端调用)" \ + "${CUR_PORT:-3000}" 0 0 + [ -n "$ANS" ] || ANS="3000" + PORT_V="$ANS" + + # ADMIN_PORT:默认 PORT+1;原值为“旧 PORT+1/旧 PORT”时跟随新 PORT, + # 显式自定义过的值则原样保留 + old_port="${CUR_PORT:-3000}" + is_port "$old_port" || old_port=3000 # 旧 .env 里 PORT 非法时按默认算 + if [ -z "$CUR_ADMIN" ] || ! is_port "$CUR_ADMIN"; then adm_default=$((PORT_V + 1)) + elif [ "$CUR_ADMIN" = "$old_port" ]; then adm_default="$PORT_V" + elif [ "$CUR_ADMIN" = "$((old_port + 1))" ]; then adm_default=$((PORT_V + 1)) + else adm_default="$CUR_ADMIN"; fi + prompt_field ADMIN_PORT "管理端端口(浏览器管理界面,默认 PORT+1;与 PORT 相同=单端口模式)" \ + "$adm_default" 0 0 + [ -n "$ANS" ] || ANS="$adm_default" + ADMIN_PORT_V="$ANS" + + # HOST + prompt_field HOST "软件 API 监听地址(0.0.0.0 对外 / 127.0.0.1 仅本机)" \ + "${CUR_HOST:-0.0.0.0}" 0 0 + [ -n "$ANS" ] || ANS="0.0.0.0" + HOST_V="$ANS" + else + # 已有且完整:用现值,不写盘 + DB_HOST_V="$CUR_DB_HOST"; DB_PORT_V="${CUR_DB_PORT:-3306}" + DB_USER_V="$CUR_DB_USER"; DB_PASS_V="$CUR_DB_PASS" + DB_NAME_V="$CUR_DB_NAME"; PORT_V="${CUR_PORT:-3000}" + ADMIN_PORT_V="$CUR_ADMIN" + if [ -z "$ADMIN_PORT_V" ] && is_port "$PORT_V"; then ADMIN_PORT_V="$((PORT_V + 1))"; fi + HOST_V="${CUR_HOST:-0.0.0.0}" + + if [ "$SKIP_DB_CHECK" = 1 ]; then + log "已跳过数据库连通性检查(--skip-db-check / OPENDANCE_SKIP_DB_CHECK)" + else + db_check "$DB_HOST_V" "$DB_PORT_V" "$DB_USER_V" "$DB_PASS_V" "$DB_NAME_V" || true + fi + log "未做任何修改。" + exit 0 + fi +else + # ---------- 非交互 / --check:只校验,不提问 ---------- + # 非交互只校验「当前 .env 实际有什么」,绝不从模板补默认值, + # 否则模板里的 DB_USER 会被当成已填写,缺字段就报不出来了。 + # 端口/HOST 属于有内置默认值的项,缺省不算错误。 + DB_HOST_V="$(get_env_value DB_HOST "$ENV_FILE")" + DB_PORT_V="$(get_env_value DB_PORT "$ENV_FILE")"; [ -n "$DB_PORT_V" ] || DB_PORT_V="3306" + DB_USER_V="$(get_env_value DB_USER "$ENV_FILE")" + DB_PASS_V="$(get_env_value DB_PASS "$ENV_FILE")" + DB_NAME_V="$(get_env_value DB_NAME "$ENV_FILE")" + PORT_V="$(get_env_value PORT "$ENV_FILE")" + [ -n "$PORT_V" ] || PORT_V="$(get_env_value API_PORT "$ENV_FILE")" + [ -n "$PORT_V" ] || PORT_V="3000" + ADMIN_PORT_V="$(get_env_value ADMIN_PORT "$ENV_FILE")" + # PORT 非法时不做算术,交给下面的端口校验报错,避免 set -u 下报 caculate 错误 + if [ -z "$ADMIN_PORT_V" ] && is_port "$PORT_V"; then ADMIN_PORT_V="$((PORT_V + 1))"; fi + HOST_V="$(get_env_value HOST "$ENV_FILE")"; [ -n "$HOST_V" ] || HOST_V="0.0.0.0" +fi + +# ---------- 汇总校验(两种模式共用) ---------- +ERRORS=() +add_error() { ERRORS+=("$1"); } + +for pair in "DB_HOST:$DB_HOST_V" "DB_USER:$DB_USER_V" "DB_PASS:$DB_PASS_V" "DB_NAME:$DB_NAME_V"; do + k="${pair%%:*}"; v="${pair#*:}" + m="$(validate_field "$k" "$v" 1)" + [ -n "$m" ] && add_error "$m" +done +for pair in "DB_PORT:$DB_PORT_V" "PORT:$PORT_V" "ADMIN_PORT:$ADMIN_PORT_V"; do + k="${pair%%:*}"; v="${pair#*:}" + m="$(validate_field "$k" "$v" 0)" + [ -n "$m" ] && add_error "$m" +done + +if [ "${#ERRORS[@]}" -gt 0 ]; then + if [ "$MODE_CHECK" = 1 ]; then + err "$ENV_FILE 校验未通过(--check 未修改任何文件):" + else + err "$ENV_FILE 校验未通过:" + fi + for m in "${ERRORS[@]}"; do err " ✗ $m"; done + err "请填写后重试:bash deploy/init-env.sh" + exit 1 +fi + +# 端口相同 → 单端口模式(允许,仅提示) +if [ "$ADMIN_PORT_V" = "$PORT_V" ]; then + warn "ADMIN_PORT 与 PORT 相同($PORT_V):单端口模式,管理页面与软件接口共用同一端口。" +fi + +# 口令不安全的字符提示(按需求不加引号写盘) +if unsafe_for_unquoted "$DB_PASS_V"; then + warn "DB_PASS 含 shell 特殊字符(& \$ \` \\ 空格 ; | < > ( ) !)。" + warn "本脚本按约定不加引号写盘,但 install.sh 的 \`set -a; . .env\` 可能解析失败," + warn "建议改用仅含字母/数字/*/%/-/_/. 的口令。" +fi + +# ---------- --check:到此为止 ---------- +if [ "$MODE_CHECK" = 1 ]; then + ok "$ENV_FILE 校验通过(未做任何修改)" + printf ' DB_HOST=%s DB_PORT=%s DB_USER=%s DB_PASS=*** DB_NAME=%s\n' \ + "$DB_HOST_V" "$DB_PORT_V" "$DB_USER_V" "$DB_NAME_V" + printf ' PORT=%s ADMIN_PORT=%s HOST=%s\n' "$PORT_V" "$ADMIN_PORT_V" "$HOST_V" + exit 0 +fi + +# ---------- 非交互:只报“校验通过”,不写盘 ---------- +if [ "$INTERACTIVE" != 1 ]; then + ok "$ENV_FILE 校验通过(非交互模式,未做任何修改)" + printf ' DB_HOST=%s DB_PORT=%s DB_USER=%s DB_PASS=*** DB_NAME=%s\n' \ + "$DB_HOST_V" "$DB_PORT_V" "$DB_USER_V" "$DB_NAME_V" + printf ' PORT=%s ADMIN_PORT=%s HOST=%s\n' "$PORT_V" "$ADMIN_PORT_V" "$HOST_V" + exit 0 +fi + +# ---------- 交互:数据库自检 ---------- +db_check "$DB_HOST_V" "$DB_PORT_V" "$DB_USER_V" "$DB_PASS_V" "$DB_NAME_V" || true + +# ---------- 写回(保留注释,只改这些键) ---------- +log "写回 $ENV_FILE(保留模板注释与其它字段)…" +write_key() { + local k="$1" v="$2" + set_env_value "$k" "$v" "$ENV_FILE" || die "写入 $ENV_FILE 失败(键:$k)" + local back="" + back="$(get_env_value "$k" "$ENV_FILE")" + [ "$back" = "$v" ] || warn "$k 写回后校验不一致(写入:$v,读回:$back)" +} + +CHANGED=() +record_change() { + local k="$1" old="$2" new="$3" + [ "$old" = "$new" ] && return 0 + if [ "$k" = DB_PASS ]; then + if [ -z "$old" ]; then CHANGED+=("DB_PASS: (空)→ ***") + else CHANGED+=("DB_PASS: *** → ***(已更新)"); fi + else + CHANGED+=("$k: ${old:-(空)} → ${new:-(空)}") + fi + return 0 +} + +record_change DB_HOST "$CUR_DB_HOST" "$DB_HOST_V" +record_change DB_PORT "$CUR_DB_PORT" "$DB_PORT_V" +record_change DB_USER "$CUR_DB_USER" "$DB_USER_V" +record_change DB_PASS "$CUR_DB_PASS" "$DB_PASS_V" +record_change DB_NAME "$CUR_DB_NAME" "$DB_NAME_V" +record_change PORT "$CUR_PORT" "$PORT_V" +record_change ADMIN_PORT "$CUR_ADMIN" "$ADMIN_PORT_V" +record_change HOST "$CUR_HOST" "$HOST_V" + +write_key DB_HOST "$DB_HOST_V" +write_key DB_PORT "$DB_PORT_V" +write_key DB_USER "$DB_USER_V" +write_key DB_PASS "$DB_PASS_V" +write_key DB_NAME "$DB_NAME_V" +write_key PORT "$PORT_V" +write_key ADMIN_PORT "$ADMIN_PORT_V" +write_key HOST "$HOST_V" + +chmod 600 "$ENV_FILE" 2>/dev/null || warn "无法将 $ENV_FILE 权限设为 600" + +# ---------- 摘要 ---------- +cat < 只给客户端调接口(`/api`、`/health`、`/static`、`/contracts`,`/` 等返回 404), > 管理端端口给浏览器(管理页面 + 全部接口)。`ADMIN_PORT === PORT` 保留为 > 单端口兼容模式,只 warn 不中止。install.sh 的汇总与反代说明改为两个端口。 +> 4. 本次追加两项: +> a) **首次启动的 .env 引导**:新增 `deploy/init-env.sh`(交互式逐项询问数据库 +> 与端口,写盘前校验必填项/端口范围,可用 Node + mysql2 做连通性自检); +> `install.sh` 在缺 `deploy/.env` 时先调用它,失败才 die(安装目录已有上次 +> 安装留下的 .env 则直接复用,保持原语义)。 +> b) **默认监听 0.0.0.0 固化**:确认 `configuration.ts` 的 +> `bindHost = process.env.HOST ?? '0.0.0.0'`、`adminHost` 默认跟随 HOST, +> 两个 `.env.example` 与 `init-env.sh` 的 HOST 默认值均为 `0.0.0.0`。 ## load ``` DSH.md contracts/database.md +deploy/init-env.sh deploy/install.sh deploy/uninstall.sh deploy/.env.example @@ -66,7 +75,8 @@ Windows/Godot 客户端、Android App ─▶ 软件 API 端口 :3000(只提供 | 文件 | 说明 | |---|---| -| `deploy/install.sh` | 一键部署:前置检查 → 建用户/目录 → 复制并校验文件 → 装依赖 → 迁移 → 注册系统服务 → 健康检查;汇总打印两个端口与防火墙放行提示 | +| `deploy/install.sh` | 一键部署:前置检查 → 建用户/目录 → 复制并校验文件 → 装依赖 → 迁移 → 注册系统服务 → 健康检查;汇总打印两个端口与防火墙放行提示;缺 `deploy/.env` 时先调用 `init-env.sh` 引导 | +| `deploy/init-env.sh` | **首次启动的 `.env` 交互引导**:逐项询问 8 个键、写盘前校验必填项与端口范围、可选数据库连通性自检;只替换/追加这 8 个键并保留模板注释,写完 `chmod 600` 并回读;`--check` / `-y` 等非交互模式下绝不阻塞 | | `deploy/uninstall.sh` | 卸载;默认保留上传数据,`--purge` 才删除 | | `deploy/.env.example` | 部署环境变量样例(`PORT` / `ADMIN_PORT` / `ADMIN_HOST` / `HOST` 说明与上传上限告警) | | `deploy/systemd/opendance-api.service` | systemd 单元,含 `ProtectSystem=strict` 等安全加固 | @@ -77,6 +87,53 @@ Windows/Godot 客户端、Android App ─▶ 软件 API 端口 :3000(只提供 | `deploy/proxy/nginx.conf.example` | nginx 配置样例(上传上限、超时、keepalive、可选 HTTPS) | | `deploy/proxy/web.config.example` | IIS 配置样例(需 ARR + URL Rewrite 模块) | +## deploy/.env 初始化引导(init-env.sh) + +首次部署不再要求手工 `cp deploy/.env.example deploy/.env && vi deploy/.env`: + +``` +install.sh 检测到缺 deploy/.env + ├── $APP_DIR/deploy/.env 存在 → 直接复用(上次安装留下的配置,原语义保留) + └── 否则调用 deploy/init-env.sh + ├── 缺 .env → 从 .env.example 复制,chmod 600 + ├── 交互(TTY / 管道)→ 逐项询问并写回 + └── 非交互(CI、 **环境限制说明**:本次执行环境**无 root 权限**,且**未安装 nginx**(按需求也不应安装)。 > 因此 `deploy/install.sh` 的服务注册链路(systemd / OpenRC / SysVinit / crontab) @@ -163,6 +226,8 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200 > - `npm run migration:run` 走编译产物成功 > - 端到端冒烟 57 项全通过 > - `install.sh` / `uninstall.sh` / `ctl.sh` 通过 `bash -n` 语法校验 +> - `init-env.sh` 通过 `bash -n`,并由 `test/init-env.spec.ts`(16 项)覆盖 +> 交互写回 / `--check` / 非交互 / 超时 / 幂等 / 口令不加引号 > - 反代文档中的占位符与后端实际行为逐条对照(两个端口、路径、上传上限、健康检查) > > 需在一台有 root 的 Linux 服务器上补做一次 `install.sh` 实机验收。 diff --git a/web-server/.env.example b/web-server/.env.example index 71b1544..04f2d77 100644 --- a/web-server/.env.example +++ b/web-server/.env.example @@ -41,9 +41,19 @@ STORAGE_DIR=/data/storage # 单文件上传上限(字节),默认 512 MiB UPLOAD_MAX_BYTES=536870912 -# ---- 可选的 Assimp(FBX/glTF 动作预处理)---- -# 未安装时 FBX 动作的预处理会以失败状态返回,不影响 BVH +# ---- 可选的 Assimp(FBX/glTF 动作预处理 + 压缩包模型转换)---- +# 未安装时 FBX 动作的预处理会以失败状态返回,不影响 BVH/VMD; +# 但上传 .zip 模型包时必须能调用到它 ASSIMP_BIN=assimp +# 压缩包转换的目标格式:fbx(优先)| glb(Assimp FBX 导出失败时自动回退) +MODEL_CONVERT_FORMAT=fbx +# 压缩包里优先选用的 PMX 文件名 +MODEL_CONVERT_PMX=Hatsune Miku.pmx +# 单次转换超时(毫秒) +MODEL_CONVERT_TIMEOUT_MS=180000 + +# ---- 默认模型(可选)---- +# DEFAULT_MODEL_ID=1 # ---- 契约目录(可选)---- # 设置后 /health 与 /api/catalog/version 会读取该目录下的 VERSION 文件; diff --git a/web-server/admin/src/api/types.ts b/web-server/admin/src/api/types.ts index 8019722..10caf0d 100644 --- a/web-server/admin/src/api/types.ts +++ b/web-server/admin/src/api/types.ts @@ -114,6 +114,10 @@ export interface Model extends SourceFields { isPrimary: boolean; fileUrl: string | null; fileSize: number | null; + /** 压缩包转换而来时的说明 */ + conversionNote: string | null; + /** 模型文件 sha256 */ + fileSha256: string | null; thumbnailUrl: string | null; status: PublishStatus; createdAt: string; diff --git a/web-server/admin/src/pages/Models.tsx b/web-server/admin/src/pages/Models.tsx index 50d1c60..d8b7120 100644 --- a/web-server/admin/src/pages/Models.tsx +++ b/web-server/admin/src/pages/Models.tsx @@ -30,7 +30,7 @@ const VrmPreview = lazy(async () => { }); const PAGE_SIZE = 20; -const ACCEPT_EXT = '.vrm,.glb,.gltf,.fbx'; +const ACCEPT_EXT = '.vrm,.glb,.gltf,.fbx,.zip'; interface ModelFilters { q: string; @@ -137,7 +137,12 @@ function ModelUploadModal({ {serverError ? {serverError} : null}
- + 是否默认 {previewModel.isPrimary ? '是' : '否'} + {previewModel.conversionNote ? ( +
  • + 转换来源 + {previewModel.conversionNote} +
  • + ) : null} + {previewModel.fileSha256 ? ( +
  • + 文件 sha256 + {previewModel.fileSha256} +
  • + ) : null} ) : (

    尚未选择模型。

    diff --git a/web-server/scripts/import-default-model.mjs b/web-server/scripts/import-default-model.mjs new file mode 100644 index 0000000..029bd5e --- /dev/null +++ b/web-server/scripts/import-default-model.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/** + * 导入默认模型:把 MMD 模型压缩包(zip 内含 .pmx)交给服务端转换并入库, + * 转换成功后把它设为全局默认模型,并把身份(名称/格式/产物 sha256) + * 写进源码 `src/config/default-model.ts`。 + * + * 为什么要有这个脚本:服务进程在 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); + +const positional = args.find((a) => !a.startsWith('--') && args[args.indexOf(a) - 1]?.startsWith('--') === false); +const file = positional; + +const url = String(flag('--url', 'http://127.0.0.1:3000')).replace(/\/$/, ''); +const writeSourceOnly = has('--write-source-only'); +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); + } + model = await fetchModel(id); + console.log(`读取模型 #${model.id}:${model.name}(${model.format})`); + } 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', '待补充'))); + form.append('autoDefault', 'true'); + + 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})`); + console.log(`转换说明:${model.conversionNote ?? '(无)'}`); + } + + if (!model.fileSha256) { + console.error('该模型没有 fileSha256(文件缺失?),无法写入默认模型种子'); + process.exit(1); + } + + const seed = { + id: model.id, + name: model.name, + format: model.format, + sha256: model.fileSha256, + convertedFrom: (model.conversionNote ?? '').split(',')[0] || null, + updatedAt: new Date().toISOString(), + }; + 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); +}); diff --git a/web-server/src/common/enums.ts b/web-server/src/common/enums.ts index ff68d52..e04a248 100644 --- a/web-server/src/common/enums.ts +++ b/web-server/src/common/enums.ts @@ -67,6 +67,8 @@ export enum AssetKind { SongCover = 'song-cover', Motion = 'motion', Model = 'model', + /** 上传的模型压缩包(zip)原件,仅用于溯源;入库的是转换后的模型文件 */ + ModelArchive = 'model-archive', Thumbnail = 'thumbnail', Keypoints = 'keypoints', } diff --git a/web-server/src/common/upload.util.ts b/web-server/src/common/upload.util.ts index 9d9719c..4611143 100644 --- a/web-server/src/common/upload.util.ts +++ b/web-server/src/common/upload.util.ts @@ -9,10 +9,25 @@ export interface UploadedFileLike { size: number; } +/** + * 修正 multipart 文件名编码。 + * + * busboy/multer 按 latin1 解出 `originalname`,含中文/日文/emoji 的文件名会变成 + * 乱码(例如 `模型_初音…zip` → `模å_åé³…`)。这里用 latin1 还原原始字节再按 + * UTF-8 解一次;只有当结果不含替换字符、且原文确实含高位字节时才替换, + * 避免破坏真正的 latin1 文件名。 + */ +export function decodeUploadName(rawName: string): string { + if (![...rawName].some((ch) => ch.charCodeAt(0) > 0x7f)) return rawName; + const restored = Buffer.from(rawName, 'latin1').toString('utf8'); + if (restored.includes('\uFFFD')) return rawName; + return restored; +} + export function requireUploadedFile(file: UploadedFileLike | undefined, label = 'file'): UploadedFileLike { if (!file) throw new BadRequestException(`缺少上传文件字段 ${label}`); if (!file.path) throw new BadRequestException('上传文件未落盘'); - return file; + return { ...file, originalname: decodeUploadName(file.originalname) }; } /** 按扩展名白名单解析格式;不在白名单内一律 422 */ diff --git a/web-server/src/common/zip-reader.ts b/web-server/src/common/zip-reader.ts new file mode 100644 index 0000000..ef95617 --- /dev/null +++ b/web-server/src/common/zip-reader.ts @@ -0,0 +1,215 @@ +import { TextDecoder } from 'node:util'; +import { inflateRawSync } from 'node:zlib'; + +/** + * 极简 ZIP 读取器(只读,纯 Node 内置能力)。 + * + * 为什么不用现成库:本项目的约定是"能用内置能力就不加依赖"(BVH、VMD 解析器同理), + * 而 MMD 模型压缩包只需要 STORE / DEFLATE 两种最普通的条目,实现成本很低。 + * + * 支持: + * - 中央目录解析(EOCD → 条目表),STORE(0) 与 DEFLATE(8) + * - ZIP64 的"条目数/大小溢出"字段(部分压缩工具对大包会写) + * - 文件名编码:UTF-8 标志位;否则优先 Shift-JIS(Windows 压缩工具的中文/日文常见), + * 失败再退 CP437 近似(latin1) + * + * 明确不支持(遇到时给出清晰错误,而不是产出错数据):加密条目、分卷压缩、 + * ZIP64 的 64 位偏移(真需要时会报错)。 + */ + +export interface ZipEntry { + /** 压缩包内的相对路径(已把 `\` 归一为 `/`,去除前导 `./`) */ + path: string; + /** 未压缩字节数 */ + size: number; + /** 压缩方式:0=STORE,8=DEFLATE */ + method: number; + /** 读出条目内容 */ + read(): Buffer; +} + +const EOCD_SIGNATURE = 0x06054b50; +const EOCD64_LOCATOR_SIGNATURE = 0x07064b50; +const EOCD64_SIGNATURE = 0x06064b50; +const CENTRAL_SIGNATURE = 0x02014b50; +const LOCAL_SIGNATURE = 0x04034b50; + +const decoders = new Map(); +function decoderFor(encoding: string): TextDecoder | null { + if (!decoders.has(encoding)) { + try { + decoders.set(encoding, new TextDecoder(encoding, { fatal: false })); + } catch { + decoders.set(encoding, null); + } + } + return decoders.get(encoding) ?? null; +} + +/** 半角片假名:Shift-JIS 被误用时会出现,用来判断该不该换编码 */ +const HALF_WIDTH_KATAKANA = /[\uFF61-\uFF9F]/; + +/** 解压包内文件名:UTF-8 标志位优先,否则试 Shift-JIS(中文/日文压缩包常见) */ +function decodeEntryName(bytes: Buffer, utf8Flag: boolean): string { + if (utf8Flag) return bytes.toString('utf8'); + const hasHighByte = bytes.some((byte) => byte >= 0x80); + if (!hasHighByte) return bytes.toString('latin1'); + const shiftJis = decoderFor('shift_jis'); + if (shiftJis) { + const decoded = shiftJis.decode(bytes); + if (!decoded.includes('\uFFFD') && !HALF_WIDTH_KATAKANA.test(decoded)) return decoded; + } + const gbk = decoderFor('gbk'); + if (gbk) { + const decoded = gbk.decode(bytes); + if (!decoded.includes('\uFFFD')) return decoded; + } + return bytes.toString('latin1'); +} + +/** 归一化条目路径,并拒绝绝对路径 / 上跳(zip-slip) */ +function normalizeEntryPath(raw: string): string { + const unified = raw.replace(/\\/g, '/').replace(/^\.\//, ''); + const parts: string[] = []; + for (const segment of unified.split('/')) { + if (segment === '' || segment === '.') continue; + if (segment === '..') throw new Error(`压缩包内存在上跳路径,已拒绝:${raw}`); + parts.push(segment); + } + if (unified.startsWith('/') || /^[A-Za-z]:/.test(unified)) { + throw new Error(`压缩包内存在绝对路径,已拒绝:${raw}`); + } + return parts.join('/'); +} + +function findEndOfCentralDirectory(buffer: Buffer): number { + // EOCD 最少 22 字节,注释最长 65535 字节 + const minOffset = Math.max(0, buffer.length - 22 - 0xffff); + for (let offset = buffer.length - 22; offset >= minOffset; offset -= 1) { + if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) return offset; + } + throw new Error('不是有效的 ZIP:找不到中央目录结束记录(EOCD)'); +} + +/** 解析 ZIP,返回条目列表(目录条目会被跳过) */ +export function readZipEntries(buffer: Buffer): ZipEntry[] { + if (buffer.length < 22) throw new Error('不是有效的 ZIP:文件过小'); + + const eocd = findEndOfCentralDirectory(buffer); + const entryCount = buffer.readUInt16LE(eocd + 10); + let centralOffset = buffer.readUInt32LE(eocd + 16); + let centralSize = buffer.readUInt32LE(eocd + 12); + + // ZIP64:条目数或偏移写满时,真实值在 ZIP64 EOCD 里 + const needsZip64 = entryCount === 0xffff || centralOffset === 0xffffffff || centralSize === 0xffffffff; + if (needsZip64) { + const locator = eocd - 20; + if (locator < 0 || buffer.readUInt32LE(locator) !== EOCD64_LOCATOR_SIGNATURE) { + throw new Error('ZIP64 压缩包缺少 EOCD64 locator,无法解析'); + } + const eocd64 = Number(buffer.readBigUInt64LE(locator + 8)); + if (eocd64 < 0 || buffer.readUInt32LE(eocd64) !== EOCD64_SIGNATURE) { + throw new Error('ZIP64 EOCD 记录不合法'); + } + const realCount = Number(buffer.readBigUInt64LE(eocd64 + 32)); + centralSize = Number(buffer.readBigUInt64LE(eocd64 + 40)); + centralOffset = Number(buffer.readBigUInt64LE(eocd64 + 48)); + return readCentralDirectory(buffer, centralOffset, centralSize, realCount); + } + + return readCentralDirectory(buffer, centralOffset, centralSize, entryCount); +} + +function readCentralDirectory( + buffer: Buffer, + centralOffset: number, + centralSize: number, + entryCount: number, +): ZipEntry[] { + if (centralOffset + centralSize > buffer.length) { + throw new Error('ZIP 中央目录越界,文件可能已损坏或被截断'); + } + const entries: ZipEntry[] = []; + let offset = centralOffset; + + for (let i = 0; i < entryCount; i += 1) { + if (offset + 46 > buffer.length) throw new Error('ZIP 中央目录条目不完整'); + if (buffer.readUInt32LE(offset) !== CENTRAL_SIGNATURE) { + throw new Error(`ZIP 中央目录第 ${i + 1} 条记录签名不正确`); + } + const flags = buffer.readUInt16LE(offset + 8); + const method = buffer.readUInt16LE(offset + 10); + let compressedSize = buffer.readUInt32LE(offset + 20); + const uncompressedSize = buffer.readUInt32LE(offset + 24); + const nameLength = buffer.readUInt16LE(offset + 28); + const extraLength = buffer.readUInt16LE(offset + 30); + const commentLength = buffer.readUInt16LE(offset + 32); + let localOffset = buffer.readUInt32LE(offset + 42); + const nameBytes = buffer.subarray(offset + 46, offset + 46 + nameLength); + const rawName = decodeEntryName(nameBytes, (flags & 0x0800) !== 0); + + if (flags & 0x0001) throw new Error(`压缩包内条目已加密,无法读取:${rawName}`); + + // ZIP64 扩展字段:本条目的大小/偏移写满时,真实值在这里 + const extraStart = offset + 46 + nameLength; + let cursor = extraStart; + while (cursor + 4 <= extraStart + extraLength) { + const fieldId = buffer.readUInt16LE(cursor); + const fieldSize = buffer.readUInt16LE(cursor + 2); + if (fieldId === 0x0001) { + let p = cursor + 4; + if (uncompressedSize === 0xffffffff) p += 8; + if (compressedSize === 0xffffffff) { + compressedSize = Number(buffer.readBigUInt64LE(p)); + p += 8; + } + if (localOffset === 0xffffffff) localOffset = Number(buffer.readBigUInt64LE(p)); + break; + } + cursor += 4 + fieldSize; + } + + const isDirectory = rawName.endsWith('/') || rawName.endsWith('\\'); + if (!isDirectory) { + entries.push({ + path: normalizeEntryPath(rawName), + size: uncompressedSize === 0xffffffff ? compressedSize : uncompressedSize, + method, + read: () => readEntry(buffer, localOffset, compressedSize, method, rawName), + }); + } + + offset += 46 + nameLength + extraLength + commentLength; + } + + return entries; +} + +function readEntry( + buffer: Buffer, + localOffset: number, + compressedSize: number, + method: number, + name: string, +): Buffer { + if (localOffset + 30 > buffer.length) throw new Error(`ZIP 局部头越界:${name}`); + if (buffer.readUInt32LE(localOffset) !== LOCAL_SIGNATURE) { + throw new Error(`ZIP 局部头签名不正确:${name}`); + } + const nameLength = buffer.readUInt16LE(localOffset + 26); + const extraLength = buffer.readUInt16LE(localOffset + 28); + const dataStart = localOffset + 30 + nameLength + extraLength; + const dataEnd = dataStart + compressedSize; + if (dataEnd > buffer.length) throw new Error(`ZIP 条目数据越界(文件被截断):${name}`); + const raw = buffer.subarray(dataStart, dataEnd); + + if (method === 0) return Buffer.from(raw); + if (method === 8) return inflateRawSync(raw); + throw new Error(`不支持的 ZIP 压缩方式 ${method}:${name}`); +} + +/** 从条目列表里按路径取内容(大小写不敏感,兼容 Windows 压缩包) */ +export function findZipEntry(entries: readonly ZipEntry[], path: string): ZipEntry | undefined { + const target = path.replace(/\\/g, '/').toLowerCase(); + return entries.find((entry) => entry.path.toLowerCase() === target); +} diff --git a/web-server/src/config/configuration.ts b/web-server/src/config/configuration.ts index ead9901..e34d67f 100644 --- a/web-server/src/config/configuration.ts +++ b/web-server/src/config/configuration.ts @@ -80,6 +80,37 @@ export class AppConfig { return this.config.get('assimpBin') ?? 'assimp'; } + /** + * 压缩包转换的目标格式。 + * `fbx` 优先(按需求),但 Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写坏文件, + * 因此服务端在导出后回读校验,失败会自动回退 GLB 并记录 conversionNote。 + */ + get modelConvertFormat(): 'fbx' | 'glb' { + return (this.config.get('modelConvertFormat') ?? 'fbx') === 'glb' ? 'glb' : 'fbx'; + } + + /** 压缩包里优先选用的 PMX 文件名(大小写不敏感);找不到时按"目录最浅、文件最大"挑 */ + get modelConvertPmx(): string { + return this.config.get('modelConvertPmx') ?? 'Hatsune Miku.pmx'; + } + + /** 单次模型转换超时(毫秒) */ + get modelConvertTimeoutMs(): number { + return this.config.get('modelConvertTimeoutMs') ?? 180_000; + } + + /** + * 显式指定的默认模型 id(DEFAULT_MODEL_ID)。 + * 未设置时按 `src/config/default-model.ts` 里写死的 DEFAULT_MODEL_SEED 匹配, + * 最后才回落到契约 data-models.md §4.1 的 VRM 优先规则。 + */ + get defaultModelId(): number | null { + const raw = this.config.get('defaultModelId'); + if (raw === undefined || raw === null || raw === '') return null; + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; + } + get contractsDir(): string | undefined { return this.config.get('contractsDir') || undefined; } @@ -160,6 +191,10 @@ export const configuration = () => { adminDir: process.env.ADMIN_DIR ?? resolve(process.cwd(), '..', 'admin'), uploadMaxBytes: Number(process.env.UPLOAD_MAX_BYTES ?? 512 * 1024 * 1024), assimpBin: process.env.ASSIMP_BIN ?? 'assimp', + modelConvertFormat: process.env.MODEL_CONVERT_FORMAT ?? 'fbx', + modelConvertPmx: process.env.MODEL_CONVERT_PMX ?? 'Hatsune Miku.pmx', + modelConvertTimeoutMs: Number(process.env.MODEL_CONVERT_TIMEOUT_MS ?? 180_000), + defaultModelId: process.env.DEFAULT_MODEL_ID ?? '', contractsDir: process.env.CONTRACTS_DIR ?? '', db: { host: process.env.DB_HOST ?? '127.0.0.1', diff --git a/web-server/src/config/contract-versions.ts b/web-server/src/config/contract-versions.ts index 96f8fa7..6464815 100644 --- a/web-server/src/config/contract-versions.ts +++ b/web-server/src/config/contract-versions.ts @@ -9,12 +9,12 @@ import { join } from 'node:path'; * 未设置该变量时回落到这里的常量)。 */ export const CONTRACT_VERSIONS: Readonly> = Object.freeze({ - 'rest-api': '0.2.0', + 'rest-api': '0.3.0', 'websocket-keypoints': '0.1.0', - 'data-models': '0.2.0', + 'data-models': '0.3.0', 'skeleton-topology': '0.1.0', - 'model-formats': '0.2.0', - database: '0.1.0', + 'model-formats': '0.3.0', + database: '0.2.0', }); /** 解析 `contracts/VERSION`(`key: value` 每行一条,`#` 起注释) */ diff --git a/web-server/src/config/default-model.ts b/web-server/src/config/default-model.ts new file mode 100644 index 0000000..65f6291 --- /dev/null +++ b/web-server/src/config/default-model.ts @@ -0,0 +1,42 @@ +/** + * 内置默认模型(**由服务端在压缩包转换成功后自动写入**)。 + * + * 写入方:`ModelsService`(上传 zip 并转换成功时)与 `scripts/import-default-model.mjs`。 + * 运行时只读:`ModelsService.resolveDefaultModelId()` 会先看这里, + * 再回落到契约 `data-models.md` §4.1 的 VRM 优先规则。 + * + * 为什么记 sha256 而不是只记 id:换库 / 重建库之后自增 id 会变, + * 用转换产物的 sha256 才能稳定指回同一个模型。 + * + * 本文件由脚本重新生成,手改请注意保持结构(键名与类型不要变)。 + */ + +export interface DefaultModelSeed { + /** 数据库中的模型 id(仅作参考,换库后会变) */ + id: number | null; + /** 模型名称 */ + name: string; + /** 模型格式:fbx / glb / vrm */ + format: string; + /** 转换产物文件的 sha256(定位默认模型的依据) */ + sha256: string; + /** 转换来源说明,例如 `Hatsune Miku.pmx → GLB`;非转换模型为 null */ + convertedFrom: string | null; + /** 最近一次写入时间(ISO 8601) */ + updatedAt: string | null; +} + +/** 由 Hatsune Miku.pmx → GLB 转换得到,已设为默认模型 */ +export const DEFAULT_MODEL_SEED: DefaultModelSeed = { + id: 16, + name: "初音未来 Hatsune Miku", + format: "glb", + sha256: "6d0ab0dce97c43dd7de84aba29b09f3b64f2953722d31b5421a39189ff2a8464", + convertedFrom: "Hatsune Miku.pmx → GLB", + updatedAt: "2026-09-15T05:45:19.168Z", +}; + +/** 该种子是否指向一个真实模型 */ +export function hasDefaultModelSeed(seed: DefaultModelSeed = DEFAULT_MODEL_SEED): boolean { + return seed.sha256.trim().length > 0; +} diff --git a/web-server/src/entities/model.entity.ts b/web-server/src/entities/model.entity.ts index a6bcf48..d284574 100644 --- a/web-server/src/entities/model.entity.ts +++ b/web-server/src/entities/model.entity.ts @@ -38,6 +38,17 @@ export class CharacterModel { @Column({ name: 'thumbnail_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber }) thumbnailAssetId!: number | null; + /** + * 转换来源:上传的压缩包(zip)所对应的 assets 记录。 + * 只有"上传压缩包 → 服务端转换"的模型才有值,直接上传模型文件时为 null。 + */ + @Column({ name: 'source_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber }) + sourceAssetId!: number | null; + + /** 转换说明,例如 `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`;未转换时为 null */ + @Column({ name: 'conversion_note', type: 'varchar', length: 500, nullable: true }) + conversionNote!: string | null; + // ---- 来源五元组(必填)---- @Column({ name: 'source_type', type: 'varchar', length: 32 }) sourceType!: SourceType; diff --git a/web-server/src/migrations/1789450000000-AddModelConversionFields.ts b/web-server/src/migrations/1789450000000-AddModelConversionFields.ts new file mode 100644 index 0000000..c86af9f --- /dev/null +++ b/web-server/src/migrations/1789450000000-AddModelConversionFields.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * T-WEB-005 追加:模型压缩包转换支持。 + * + * - `models.source_asset_id`:上传的压缩包(zip)对应的 assets 记录,便于溯源 + * - `models.conversion_note`:转换过程说明(pmx → fbx/glb、贴图内嵌情况、回退原因) + * + * 两列都可空,对既有数据无影响。 + */ +export class AddModelConversionFields1789450000000 implements MigrationInterface { + name = 'AddModelConversionFields1789450000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`models\` ADD \`source_asset_id\` bigint NULL`); + await queryRunner.query(`ALTER TABLE \`models\` ADD \`conversion_note\` varchar(500) NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`models\` DROP COLUMN \`conversion_note\``); + await queryRunner.query(`ALTER TABLE \`models\` DROP COLUMN \`source_asset_id\``); + } +} diff --git a/web-server/src/models/default-model-source.ts b/web-server/src/models/default-model-source.ts new file mode 100644 index 0000000..2d59eee --- /dev/null +++ b/web-server/src/models/default-model-source.ts @@ -0,0 +1,98 @@ +import { existsSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { DefaultModelSeed } from '../config/default-model'; + +/** + * 把默认模型写进源码(`web-server/src/config/default-model.ts`)。 + * + * 需求原文是"转换成功则把这个模型设置为默认模型写进源码中",因此这里不是只改数据库, + * 而是把种子(名称 / 格式 / 产物 sha256)落进源码文件,随代码一起提交、一起部署。 + * + * 注意:生产环境 systemd 单元带 `ProtectSystem=strict`,服务进程**没有**程序目录的写权限, + * 因此写入通常由 `scripts/import-default-model.mjs`(在服务沙箱之外、以 root 运行)完成; + * 服务内联转换成功时若能写就写,写不了就只告警,不阻断上传。 + */ + +function quote(value: string): string { + return JSON.stringify(value); +} + +/** 渲染 `default-model.ts` 的内容 */ +export function renderDefaultModelSource(seed: DefaultModelSeed): string { + return `/** + * 内置默认模型(**由服务端在压缩包转换成功后自动写入**)。 + * + * 写入方:\`ModelsService\`(上传 zip 并转换成功时)与 \`scripts/import-default-model.mjs\`。 + * 运行时只读:\`ModelsService.resolveDefaultModelId()\` 会先看这里, + * 再回落到契约 \`data-models.md\` §4.1 的 VRM 优先规则。 + * + * 为什么记 sha256 而不是只记 id:换库 / 重建库之后自增 id 会变, + * 用转换产物的 sha256 才能稳定指回同一个模型。 + * + * 本文件由脚本重新生成,手改请注意保持结构(键名与类型不要变)。 + */ + +export interface DefaultModelSeed { + /** 数据库中的模型 id(仅作参考,换库后会变) */ + id: number | null; + /** 模型名称 */ + name: string; + /** 模型格式:fbx / glb / vrm */ + format: string; + /** 转换产物文件的 sha256(定位默认模型的依据) */ + sha256: string; + /** 转换来源说明,例如 \`Hatsune Miku.pmx → GLB\`;非转换模型为 null */ + convertedFrom: string | null; + /** 最近一次写入时间(ISO 8601) */ + updatedAt: string | null; +} + +/** 由 ${seed.convertedFrom ?? '手工指定'} 转换得到,已设为默认模型 */ +export const DEFAULT_MODEL_SEED: DefaultModelSeed = { + id: ${seed.id === null ? 'null' : seed.id}, + name: ${quote(seed.name)}, + format: ${quote(seed.format)}, + sha256: ${quote(seed.sha256)}, + convertedFrom: ${seed.convertedFrom === null ? 'null' : quote(seed.convertedFrom)}, + updatedAt: ${seed.updatedAt === null ? 'null' : quote(seed.updatedAt)}, +}; + +/** 该种子是否指向一个真实模型 */ +export function hasDefaultModelSeed(seed: DefaultModelSeed = DEFAULT_MODEL_SEED): boolean { + return seed.sha256.trim().length > 0; +} +`; +} + +/** 源码文件路径:优先 `web-server/src/config/default-model.ts`(相对当前工作目录) */ +export function defaultModelSourcePath(cwd = process.cwd()): string { + const candidates = [ + join(cwd, 'src', 'config', 'default-model.ts'), + resolve(cwd, '..', 'src', 'config', 'default-model.ts'), + ]; + return candidates.find((path) => existsSync(path)) ?? candidates[0]; +} + +export interface WriteSeedResult { + written: boolean; + path: string; + reason?: string; +} + +/** 写入默认模型种子;写不了时返回原因而不是抛错(上传不应因此失败) */ +export async function writeDefaultModelSeed( + seed: DefaultModelSeed, + targetPath = defaultModelSourcePath(), +): Promise { + try { + await writeFile(targetPath, renderDefaultModelSource(seed), 'utf8'); + return { written: true, path: targetPath }; + } catch (error) { + return { + written: false, + path: targetPath, + reason: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/web-server/src/models/glb-textures.ts b/web-server/src/models/glb-textures.ts new file mode 100644 index 0000000..f0ea138 --- /dev/null +++ b/web-server/src/models/glb-textures.ts @@ -0,0 +1,156 @@ +import { extname } from 'node:path'; + +/** + * 把 GLB 里以外部文件形式引用的贴图**内嵌**进二进制块。 + * + * 为什么需要:模型库里一条记录只对应一个文件,客户端拿到的必须能独立打开。 + * Assimp 的 glTF2 导出器在 GLB 里仍然把贴图写成 `textures\\xxx.png` 这样的外部 + * URI(还带反斜杠,本身就不符合 glTF 规范),不转换的话加载出来是白模。 + * + * 处理方式:解析 GLB 的 JSON / BIN 两个 chunk,为每个外部贴图追加 bufferView, + * 把 `uri` 换成 `bufferView + mimeType`,再按 glTF 的 4 字节对齐规则重新拼装。 + */ + +const GLB_MAGIC = 0x46546c67; +const CHUNK_JSON = 0x4e4f534a; +const CHUNK_BIN = 0x004e4942; + +export interface GlbEmbedResult { + glb: Buffer; + /** 成功内嵌的贴图数量 */ + embedded: number; + /** 找不到文件或格式不支持、仍保留 uri 的贴图 */ + unresolved: string[]; +} + +interface GltfJson { + asset?: Record; + buffers?: Array<{ byteLength?: number; uri?: string }>; + bufferViews?: Array>; + images?: Array>; + [key: string]: unknown; +} + +const MIME_BY_EXTENSION: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.ktx2': 'image/ktx2', + '.basis': 'image/basis', +}; + +function align4(value: number): number { + return value + ((4 - (value % 4)) % 4); +} + +function parseGlb(buffer: Buffer): { json: GltfJson; bin: Buffer | null } { + if (buffer.length < 12 || buffer.readUInt32LE(0) !== GLB_MAGIC) { + throw new Error('不是有效的 GLB:魔数不正确'); + } + const totalLength = buffer.readUInt32LE(8); + const end = Math.min(totalLength || buffer.length, buffer.length); + let offset = 12; + let json: GltfJson | null = null; + let bin: Buffer | null = null; + while (offset + 8 <= end) { + const chunkLength = buffer.readUInt32LE(offset); + const chunkType = buffer.readUInt32LE(offset + 4); + const dataStart = offset + 8; + if (dataStart + chunkLength > buffer.length) break; + const data = buffer.subarray(dataStart, dataStart + chunkLength); + if (chunkType === CHUNK_JSON) json = JSON.parse(data.toString('utf8')) as GltfJson; + else if (chunkType === CHUNK_BIN) bin = Buffer.from(data); + offset = dataStart + align4(chunkLength); + } + if (!json) throw new Error('GLB 里没有 JSON chunk'); + return { json, bin }; +} + +function buildGlb(json: GltfJson, bin: Buffer | null): Buffer { + const jsonBuffer = Buffer.from(JSON.stringify(json), 'utf8'); + const jsonPadded = Buffer.alloc(align4(jsonBuffer.length), 0x20); + jsonBuffer.copy(jsonPadded); + + const chunks: Buffer[] = []; + const header = Buffer.alloc(12); + header.writeUInt32LE(GLB_MAGIC, 0); + header.writeUInt32LE(2, 4); + chunks.push(header); + + const jsonHeader = Buffer.alloc(8); + jsonHeader.writeUInt32LE(jsonPadded.length, 0); + jsonHeader.writeUInt32LE(CHUNK_JSON, 4); + chunks.push(jsonHeader, jsonPadded); + + if (bin && bin.length > 0) { + const binPadded = Buffer.alloc(align4(bin.length)); + bin.copy(binPadded); + const binHeader = Buffer.alloc(8); + binHeader.writeUInt32LE(binPadded.length, 0); + binHeader.writeUInt32LE(CHUNK_BIN, 4); + chunks.push(binHeader, binPadded); + } + + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const out = Buffer.concat(chunks, total); + out.writeUInt32LE(total, 8); + return out; +} + +/** + * 内嵌 GLB 中的外部贴图。 + * + * @param buffer GLB 内容 + * @param resolve 把 glTF 里的 uri 映射为文件内容;返回 null 表示找不到 + */ +export function embedGlbImages( + buffer: Buffer, + resolve: (uri: string) => Buffer | null, +): GlbEmbedResult { + const { json, bin } = parseGlb(buffer); + const images = json.images ?? []; + if (images.length === 0) return { glb: buffer, embedded: 0, unresolved: [] }; + + const views = json.bufferViews ?? []; + const chunks: Buffer[] = bin && bin.length > 0 ? [bin] : []; + let offset = bin ? bin.length : 0; + let embedded = 0; + const unresolved: string[] = []; + + for (const image of images) { + const uri = typeof image.uri === 'string' ? image.uri : null; + if (!uri || uri.startsWith('data:')) continue; + + const file = resolve(uri); + if (!file) { + unresolved.push(uri); + continue; + } + const mimeType = MIME_BY_EXTENSION[extname(uri).toLowerCase()]; + if (!mimeType) { + unresolved.push(uri); + continue; + } + + const padding = align4(offset) - offset; + if (padding > 0) { + chunks.push(Buffer.alloc(padding)); + offset += padding; + } + chunks.push(file); + views.push({ buffer: 0, byteOffset: offset, byteLength: file.length }); + image.bufferView = views.length - 1; + image.mimeType = mimeType; + delete image.uri; + offset += file.length; + embedded += 1; + } + + if (embedded === 0) return { glb: buffer, embedded: 0, unresolved }; + + const merged = Buffer.concat(chunks, offset); + json.bufferViews = views; + json.buffers = [{ byteLength: merged.length }]; + return { glb: buildGlb(json, merged), embedded, unresolved }; +} diff --git a/web-server/src/models/model-convert.ts b/web-server/src/models/model-convert.ts new file mode 100644 index 0000000..a299de6 --- /dev/null +++ b/web-server/src/models/model-convert.ts @@ -0,0 +1,215 @@ +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, extname, join } from 'node:path'; +import { promisify } from 'node:util'; +import { ModelFormat } from '../common/enums'; +import { ZipEntry, readZipEntries } from '../common/zip-reader'; +import { embedGlbImages } from './glb-textures'; + +const run = promisify(execFile); + +/** + * 压缩包 → 可入库模型文件的转换。 + * + * 典型场景:MMD 模型包(zip 内含 `xxx.pmx` + `textures/`)→ 解压 → 选主模型 + * → 调 Assimp 导出 FBX;**Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写坏文件** + * (实测 Miku PMX:导出退出码 11,产出的 FBX 回读报 `FBX-DOM no Objects dictionary found`), + * 因此导出后必须回读校验,失败则回退 GLB,并把过程写进 conversionNote。 + * + * 转换后的 GLB 还会把 Assimp 写成外部 URI 的贴图内嵌进二进制块, + * 保证模型库里一个文件就能独立加载(否则是白模)。 + */ + +export interface ConvertOptions { + /** Assimp 可执行文件(默认 `assimp`,可用 ASSIMP_BIN 覆盖) */ + assimpBin: string; + /** 单次转换超时(毫秒) */ + timeoutMs?: number; + /** 优先尝试的格式 */ + preferFormat?: 'fbx' | 'glb'; + /** 优先选用的 PMX 文件名(大小写不敏感),例如 `Hatsune Miku.pmx` */ + preferredPmx?: string; +} + +export interface ConvertedModel { + /** 转换产物内容 */ + buffer: Buffer; + /** 产物格式(fbx 或 glb) */ + format: ModelFormat; + /** 实际使用的 pmx 相对路径 */ + sourceModelPath: string; + /** 人类可读的转换说明,写入 models.conversion_note */ + note: string; + /** 逐步尝试记录,便于排障 */ + attempts: string[]; +} + +const DEFAULT_TIMEOUT_MS = 180_000; + +/** 选择主模型:优先指名字,其次目录最浅,最后文件最大 */ +export function pickModelFile( + entries: readonly ZipEntry[], + preferred?: string, +): ZipEntry | undefined { + const models = entries.filter((entry) => extname(entry.path).toLowerCase() === '.pmx'); + if (models.length === 0) return undefined; + const wanted = preferred?.trim().toLowerCase(); + if (wanted) { + const exact = models.find((entry) => entry.path.toLowerCase() === wanted); + if (exact) return exact; + const byBase = models.find( + (entry) => entry.path.split('/').pop()?.toLowerCase() === wanted, + ); + if (byBase) return byBase; + } + return [...models].sort((a, b) => { + const depthA = a.path.split('/').length; + const depthB = b.path.split('/').length; + if (depthA !== depthB) return depthA - depthB; + return b.size - a.size; + })[0]; +} + +/** 解压到工作目录,同时保留内容供贴图内嵌使用 */ +async function unpack( + entries: readonly ZipEntry[], + targetDir: string, +): Promise> { + const contents = new Map(); + for (const entry of entries) { + const data = entry.read(); + contents.set(entry.path, data); + const destination = join(targetDir, entry.path); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, data); + } + return contents; +} + +/** 在解压结果里按 glTF 的 uri 找贴图(容错反斜杠与大小写) */ +function makeResolver(contents: Map) { + const lowered = new Map(); + for (const [path, data] of contents) lowered.set(path.replace(/\\/g, '/').toLowerCase(), data); + return (uri: string): Buffer | null => { + if (uri.startsWith('data:')) return null; + let decoded = uri; + try { + decoded = decodeURIComponent(uri); + } catch { + /* 保持原样 */ + } + const normalized = decoded.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase(); + const direct = lowered.get(normalized); + if (direct) return direct; + const base = normalized.split('/').pop(); + if (!base) return null; + for (const [path, data] of lowered) { + if (path.split('/').pop() === base) return data; + } + return null; + }; +} + +/** 用 Assimp 导出到指定格式,并回读校验(回读失败视为转换失败) */ +async function exportAndValidate( + assimpBin: string, + modelPath: string, + outPath: string, + timeoutMs: number, +): Promise<{ ok: boolean; detail: string }> { + try { + await run(assimpBin, ['export', modelPath, outPath], { timeout: timeoutMs, maxBuffer: 8 << 20 }); + } catch (error) { + const err = error as { code?: number | string; stderr?: string; message?: string }; + const detail = err.stderr?.trim() || err.message || String(error); + return { ok: false, detail: `导出失败(${err.code ?? 'unknown'}):${detail.slice(0, 300)}` }; + } + + let size = 0; + try { + size = (await readFile(outPath)).length; + } catch { + return { ok: false, detail: '导出命令返回成功但没有产出文件' }; + } + if (size === 0) return { ok: false, detail: '导出产物为空文件' }; + + // 关键校验:Assimp 的 FBX 导出器会"写出一个坏文件但退出码非 0", + // 必须用 info 回读确认,否则会把损坏文件入库。 + try { + await run(assimpBin, ['info', outPath], { timeout: timeoutMs, maxBuffer: 8 << 20 }); + } catch (error) { + const err = error as { stderr?: string; message?: string }; + const detail = err.stderr?.trim() || err.message || String(error); + return { ok: false, detail: `产物无法回读(已判定损坏):${detail.slice(0, 300)}` }; + } + return { ok: true, detail: `产物 ${size} 字节,回读校验通过` }; +} + +/** 压缩包 → 模型文件(FBX 优先,失败回退 GLB) */ +export async function convertModelArchive( + archive: Buffer, + options: ConvertOptions, +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const prefer = options.preferFormat ?? 'fbx'; + const entries = readZipEntries(archive); + if (entries.length === 0) throw new Error('压缩包是空的'); + + const modelEntry = pickModelFile(entries, options.preferredPmx); + if (!modelEntry) { + throw new Error('压缩包里没有找到 .pmx 模型文件(当前只支持 MMD 的 PMX 模型包)'); + } + + const workDir = await mkdtemp(join(tmpdir(), 'opendance-model-')); + const attempts: string[] = []; + try { + const contents = await unpack(entries, workDir); + const modelPath = join(workDir, modelEntry.path); + + const order: Array<'fbx' | 'glb'> = + prefer === 'glb' ? ['glb', 'fbx'] : ['fbx', 'glb']; + + for (const target of order) { + const outPath = join(workDir, `converted.${target}`); + const result = await exportAndValidate(options.assimpBin, modelPath, outPath, timeoutMs); + attempts.push(`${target.toUpperCase()}:${result.detail}`); + if (!result.ok) continue; + + let buffer: Buffer = await readFile(outPath); + let extra = ''; + if (target === 'glb') { + // 贴图内嵌是"锦上添花":即便结构不符合预期也不能让整次转换失败 + try { + const embedded = embedGlbImages(buffer, makeResolver(contents)); + buffer = Buffer.from(embedded.glb); + if (embedded.embedded > 0) extra = `,已内嵌 ${embedded.embedded} 张贴图`; + if (embedded.unresolved.length > 0) { + extra += `(${embedded.unresolved.length} 张贴图未找到:${embedded.unresolved + .slice(0, 3) + .join('、')})`; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + extra = `(贴图内嵌跳过:${message.slice(0, 120)})`; + } + } + + const note = `${modelEntry.path} → ${target.toUpperCase()}${extra}`; + return { + buffer, + format: target === 'fbx' ? ModelFormat.Fbx : ModelFormat.Glb, + sourceModelPath: modelEntry.path, + note, + attempts, + }; + } + + throw new Error( + `模型转换失败:${attempts.join(';')}。` + + '请确认服务器已安装 Assimp(ASSIMP_BIN),或直接上传 .glb / .vrm 文件。', + ); + } finally { + await rm(workDir, { recursive: true, force: true }); + } +} diff --git a/web-server/src/models/model.serializer.ts b/web-server/src/models/model.serializer.ts index 0c3bcdc..b68e7cc 100644 --- a/web-server/src/models/model.serializer.ts +++ b/web-server/src/models/model.serializer.ts @@ -10,7 +10,11 @@ export interface ModelDto { isPrimary: boolean; fileUrl: string | null; fileSize: number | null; + /** 模型文件(转换产物)的 sha256,用于换库后定位默认模型 */ + fileSha256: string | null; thumbnailUrl: string | null; + /** 由压缩包转换而来时的说明,例如 `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图` */ + conversionNote: string | null; sourceType: SourceType; sourceName: string; sourceUrl: string; @@ -25,6 +29,7 @@ export interface ModelUrlLookup { fileUrl: string | null; thumbnailUrl: string | null; fileSize?: number | null; + fileSha256?: string | null; } export function toModelDto(model: CharacterModel, urls: ModelUrlLookup): ModelDto { @@ -36,7 +41,9 @@ export function toModelDto(model: CharacterModel, urls: ModelUrlLookup): ModelDt isPrimary: model.isPrimary, fileUrl: urls.fileUrl, fileSize: urls.fileSize ?? null, + fileSha256: urls.fileSha256 ?? null, thumbnailUrl: urls.thumbnailUrl, + conversionNote: model.conversionNote ?? null, sourceType: model.sourceType, sourceName: model.sourceName, sourceUrl: model.sourceUrl, diff --git a/web-server/src/models/models.service.ts b/web-server/src/models/models.service.ts index e228696..71b04c7 100644 --- a/web-server/src/models/models.service.ts +++ b/web-server/src/models/models.service.ts @@ -1,5 +1,14 @@ -import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; import { DataSource, Repository } from 'typeorm'; import { AssetKind, @@ -16,7 +25,11 @@ import { requireUploadedFile, resolveFormatByExtension, } from '../common/upload.util'; +import { DEFAULT_MODEL_SEED } from '../config/default-model'; +import { writeDefaultModelSeed } from './default-model-source'; +import { convertModelArchive } from './model-convert'; import { CharacterModel } from '../entities/model.entity'; +import { AppConfig } from '../config/configuration'; import { StorageService } from '../storage/storage.service'; import { QueryModelDto, UpdateModelDto } from './model.dto'; import { ModelDto, toModelDto } from './model.serializer'; @@ -24,10 +37,13 @@ import { inspectVrm } from './vrm-inspector'; @Injectable() export class ModelsService { + private readonly logger = new Logger(ModelsService.name); + constructor( @InjectRepository(CharacterModel) private readonly models: Repository, private readonly storage: StorageService, private readonly dataSource: DataSource, + private readonly config: AppConfig, ) {} async list(query: QueryModelDto): Promise> { @@ -63,8 +79,6 @@ export class ModelsService { if (!name) throw new BadRequestException('name 不能为空'); if (name.length > 200) throw new BadRequestException('name 长度不能超过 200'); - const { format } = resolveFormatByExtension(upload.originalname, MODEL_EXTENSION_MAP, '模型文件'); - const tuple = assertSourceTupleComplete({ sourceType: body.sourceType as SourceTuple['sourceType'], sourceName: body.sourceName as string, @@ -76,6 +90,13 @@ export class ModelsService { throw new BadRequestException('上传模型必须填写完整的来源信息(sourceType/sourceName/sourceUrl/license/author)'); } + // 压缩包(MMD 模型包)走"解压 → 转换 → 入库"通路,其它扩展名走原有的直传通路 + if (extname(upload.originalname).toLowerCase() === '.zip') { + return this.createFromArchive(body, upload, name, tuple); + } + + const { format } = resolveFormatByExtension(upload.originalname, MODEL_EXTENSION_MAP, '模型文件'); + const stored = await this.storage.persistTempFile( AssetKind.Model, upload.path, @@ -122,6 +143,126 @@ export class ModelsService { return dto; } + /** + * T-WEB-005 追加:上传压缩包(如 MMD 的 `模型_xxx.zip`)→ 解压选 PMX → Assimp 转换 + * → 入库。FBX 优先,导出后回读校验,失败自动回退 GLB 并记录 conversion_note。 + * + * 转换成功后默认把它设为**全局默认模型**(is_primary + published),并把身份 + * (名称/格式/产物 sha256)写进源码 `src/config/default-model.ts`; + * 传 `autoDefault=false` 可只入库不改默认。 + */ + private async createFromArchive( + body: Record, + upload: UploadedFileLike, + name: string, + tuple: NonNullable>, + ): Promise { + if (upload.size && upload.size > this.config.uploadMaxBytes) { + throw new BadRequestException( + `压缩包超过上限 ${Math.floor(this.config.uploadMaxBytes / 1024 / 1024)} MiB`, + ); + } + + const archive = await readFile(upload.path); + let converted; + try { + converted = await convertModelArchive(archive, { + assimpBin: this.config.assimpBin, + timeoutMs: this.config.modelConvertTimeoutMs, + preferFormat: this.config.modelConvertFormat, + preferredPmx: this.config.modelConvertPmx, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`模型压缩包转换失败(${upload.originalname}):${message}`); + throw new UnprocessableEntityException(`模型转换失败:${message}`); + } + + // 原件留存用于溯源(按 sha256 去重) + const archiveAsset = await this.storage.persistBuffer( + AssetKind.ModelArchive, + archive, + upload.originalname, + upload.mimetype || 'application/zip', + ); + const stored = await this.storage.persistBuffer( + AssetKind.Model, + converted.buffer, + `${name}.${converted.format}`, + converted.format === ModelFormat.Glb ? 'model/gltf-binary' : 'application/octet-stream', + ); + + // 转换产物若是 GLB,顺带识别是否带 VRM 扩展 + let vrmVersion: string | null = null; + if (converted.format === ModelFormat.Glb) { + try { + vrmVersion = (await inspectVrm(this.storage.absolutePath(stored.asset))).vrmVersion; + } catch { + vrmVersion = null; + } + } + + const autoDefault = body.autoDefault === undefined ? true : parseOptionalBool(body.autoDefault); + const status = autoDefault ? PublishStatus.Published : this.parseStatus(body.status); + + const model = this.models.create({ + name, + format: converted.format, + vrmVersion, + isPrimary: false, + fileAssetId: stored.asset.id, + sourceAssetId: archiveAsset.asset.id, + conversionNote: converted.note.slice(0, 500), + sourceType: tuple.sourceType as CharacterModel['sourceType'], + sourceName: tuple.sourceName, + sourceUrl: tuple.sourceUrl, + license: tuple.license, + author: tuple.author, + status, + }); + const saved = await this.models.save(model); + this.logger.log( + `模型压缩包已转换入库:#${saved.id} ${name}(${converted.note})` + + `;尝试记录:${converted.attempts.join(';')}`, + ); + + if (autoDefault) { + await this.applyDefaultModel(saved.id, stored.asset.sha256, converted.note); + // setPrimary 走的是独立事务,重新读一次才能让返回体里的 isPrimary 正确 + const [dto] = await this.toDtos([await this.getEntity(saved.id)]); + return dto; + } + + const [dto] = await this.toDtos([saved]); + return dto; + } + + /** + * 设为默认模型并把种子写进源码。写入失败只告警:生产环境 systemd 的 + * `ProtectSystem=strict` 不允许服务进程改程序目录,通常由 + * `scripts/import-default-model.mjs` 在沙箱外完成写入。 + */ + private async applyDefaultModel(id: number, sha256: string, note: string): Promise { + await this.setPrimary(id); + const model = await this.getEntity(id); + const result = await writeDefaultModelSeed({ + id, + name: model.name, + format: model.format, + sha256, + convertedFrom: note.split(',')[0] ?? null, + updatedAt: new Date().toISOString(), + }); + if (result.written) { + this.logger.log(`默认模型已写进源码:${result.path}`); + } else { + this.logger.warn( + `默认模型已在库中设为 is_primary,但源码写入失败(${result.reason});` + + `可在源码目录执行 node scripts/import-default-model.mjs --write-source-only 补写`, + ); + } + } + async update(id: number, dto: UpdateModelDto): Promise { const model = await this.getEntity(id); @@ -170,7 +311,16 @@ export class ModelsService { return dto; } - /** 客户端默认模型解析 —— 契约 data-models.md §4.1 的 2~4 步 */ + /** + * 客户端默认模型解析 —— 契约 data-models.md §4.1。 + * + * 顺序: + * 0. 环境变量 `DEFAULT_MODEL_ID` 显式指定(且模型存在) + * 1. 源码里写死的 `DEFAULT_MODEL_SEED`(按转换产物 sha256 匹配,换库也能定位) + * 2. 已发布且 is_primary 的 VRM + * 3. 任意已发布 VRM + * 4. id 最小的已发布模型 + */ async resolveDefaultModelId(): Promise { const published = await this.models.find({ where: { status: PublishStatus.Published }, @@ -178,6 +328,24 @@ export class ModelsService { }); if (published.length === 0) return null; + const configured = this.config.defaultModelId; + if (configured !== null) { + const hit = published.find((m) => m.id === configured); + if (hit) return hit.id; + this.logger.warn(`DEFAULT_MODEL_ID=${configured} 指向的模型不存在或未发布,已忽略`); + } + + const seedSha = DEFAULT_MODEL_SEED.sha256.trim(); + if (seedSha) { + const assets = await this.storage.findMany(published.map((m) => m.fileAssetId)); + const seeded = published.find((m) => { + const asset = m.fileAssetId === null ? null : assets.get(m.fileAssetId); + return asset?.sha256 === seedSha; + }); + if (seeded) return seeded.id; + this.logger.warn('源码里的默认模型种子在内库中找不到对应模型,回落到 VRM 优先规则'); + } + const primaryVrm = published.find((m) => m.format === ModelFormat.Vrm && m.isPrimary); if (primaryVrm) return primaryVrm.id; @@ -206,6 +374,7 @@ export class ModelsService { fileUrl: file ? this.storage.url(file) : null, thumbnailUrl: thumb ? this.storage.url(thumb) : null, fileSize: file?.size ?? null, + fileSha256: file?.sha256 ?? null, }); }); } diff --git a/web-server/test/admin-port.spec.ts b/web-server/test/admin-port.spec.ts index 436cd32..b44af8b 100644 --- a/web-server/test/admin-port.spec.ts +++ b/web-server/test/admin-port.spec.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { configuration, @@ -79,6 +81,23 @@ describe('端口解析与默认值', () => { expect(configuration().adminPort).toBe(4111); }); + it('需求约定:未设置 HOST 时默认监听 0.0.0.0,而不是 127.0.0.1', () => { + delete process.env.HOST; + delete process.env.ADMIN_HOST; + const env = configuration(); + expect(env.bindHost).toBe('0.0.0.0'); + expect(env.adminHost).toBe('0.0.0.0'); + expect(env.bindHost).not.toBe('127.0.0.1'); + }); + + it('两份 .env 模板都写着 HOST=0.0.0.0(防止默认值被改回去)', () => { + for (const file of [join(__dirname, '..', '..', 'deploy', '.env.example'), join(__dirname, '..', '.env.example')]) { + const content = readFileSync(file, 'utf8'); + expect(content, file).toMatch(/^HOST=0\.0\.0\.0$/m); + expect(content, file).not.toMatch(/^HOST=127\.0\.0\.1$/m); + } + }); + it('configuration:ADMIN_HOST 显式设置时不再跟随 HOST', () => { process.env.HOST = '127.0.0.1'; expect(configuration().adminHost).toBe('127.0.0.1'); diff --git a/web-server/test/init-env.spec.ts b/web-server/test/init-env.spec.ts new file mode 100644 index 0000000..735d426 --- /dev/null +++ b/web-server/test/init-env.spec.ts @@ -0,0 +1,404 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +/** + * deploy/init-env.sh —— Web 端首次启动的 .env 引导流程。 + * + * 这些用例全部在临时目录里驱动真实脚本: + * * 非交互场景必须“要么 0、要么非 0”,绝不允许挂住(spawnSync 带 timeout, + * 一旦超时 r.error 会被置为 ETIMEDOUT,用例据此断言)。 + * * 不访问真实数据库:统一注入 OPENDANCE_SKIP_DB_CHECK=1。 + */ +function findScript(): string { + const candidates = [ + resolve(process.cwd(), '..', 'deploy', 'init-env.sh'), // 从 web-server/ 运行(vitest 默认) + resolve(process.cwd(), 'deploy', 'init-env.sh'), // 从仓库根运行 + resolve(process.cwd(), '..', '..', 'deploy', 'init-env.sh'), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + throw new Error(`找不到 deploy/init-env.sh,候选路径:\n${candidates.join('\n')}`); +} + +const SCRIPT = findScript(); + +/** 测试模板:含注释与“其它字段”,用于验证写回时不丢注释。 */ +const EXAMPLE = `# ============================================================ +# 测试用模板:这些注释必须被保留 +# ============================================================ +NODE_ENV=production +PORT=3000 +# 管理端端口注释 +ADMIN_PORT=3001 +HOST=0.0.0.0 +# 数据库 +DB_HOST= +DB_PORT=3306 +DB_USER=opendance_app +DB_PASS=<部署方提供> +DB_NAME=opendance +DB_SYNCHRONIZE=false +UPLOAD_MAX_BYTES=536870912 +`; + +/** 一份“齐备”的 .env 内容。 */ +const COMPLETE_ENV = + [ + 'DB_HOST=10.0.0.5', + 'DB_PORT=3306', + 'DB_USER=u1', + 'DB_PASS=p1', + 'DB_NAME=d1', + 'PORT=3000', + 'ADMIN_PORT=3001', + 'HOST=0.0.0.0', + ].join('\n') + '\n'; + +const tempDirs: string[] = []; + +function makeDir(withExample = true): string { + const dir = mkdtempSync(join(tmpdir(), 'opendance-init-env-')); + tempDirs.push(dir); + if (withExample) writeFileSync(join(dir, '.env.example'), EXAMPLE, 'utf8'); + return dir; +} + +function envPath(dir: string): string { + return join(dir, '.env'); +} + +interface RunResult { + status: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + error?: Error; +} + +function run( + args: string[], + opts: { input?: string; env?: Record; timeoutMs?: number } = {}, +): RunResult { + const result = spawnSync('bash', [SCRIPT, ...args], { + input: opts.input ?? '', + encoding: 'utf8', + timeout: opts.timeoutMs ?? 8000, + env: { ...process.env, OPENDANCE_SKIP_DB_CHECK: '1', ...opts.env }, + }); + return { + status: result.status, + signal: result.signal, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + error: result.error, + }; +} + +function outputOf(result: RunResult): string { + return `${result.stdout}${result.stderr}`; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('init-env.sh:非交互模式绝不阻塞', () => { + it( + '缺必填 → 非 0 退出并列出字段名,且不阻塞', + () => { + const dir = makeDir(); + // 人为缺 DB_USER / DB_PASS + writeFileSync(envPath(dir), 'DB_HOST=1.2.3.4\nDB_PORT=3306\nDB_NAME=opendance\n'); + + const result = run(['--non-interactive', '--dir', dir], { timeoutMs: 5000 }); + + expect(result.error).toBeUndefined(); // 超时被杀则 error=ETIMEDOUT + expect(result.status).not.toBe(0); + const out = outputOf(result); + expect(out).toContain('DB_USER'); + expect(out).toContain('DB_PASS'); + // 不修改文件 + expect(readFileSync(envPath(dir), 'utf8')).toBe( + 'DB_HOST=1.2.3.4\nDB_PORT=3306\nDB_NAME=opendance\n', + ); + }, + 15000, + ); + + it( + 'OPENDANCE_NONINTERACTIVE=1 与 --yes 同样跳过提问', + () => { + const dir = makeDir(); + writeFileSync(envPath(dir), 'DB_HOST=h\nDB_NAME=n\n'); // 缺 DB_USER / DB_PASS + + const byEnv = run(['--dir', dir], { + input: 'should-not-be-read\n', + env: { OPENDANCE_NONINTERACTIVE: '1' }, + timeoutMs: 5000, + }); + expect(byEnv.error).toBeUndefined(); + expect(byEnv.status).not.toBe(0); + expect(outputOf(byEnv)).not.toContain('请输入'); + + const byFlag = run(['--yes', '--dir', dir], { timeoutMs: 5000 }); + expect(byFlag.error).toBeUndefined(); + expect(byFlag.status).not.toBe(0); + expect(outputOf(byFlag)).not.toContain('请输入'); + }, + 15000, + ); + + it( + '全部齐备 → 0 退出且完全不修改文件', + () => { + const dir = makeDir(); + writeFileSync(envPath(dir), COMPLETE_ENV); + const before = readFileSync(envPath(dir), 'utf8'); + const modeBefore = statSync(envPath(dir)).mode; + + const result = run(['--non-interactive', '--dir', dir]); + + expect(result.status).toBe(0); + expect(result.error).toBeUndefined(); + expect(readFileSync(envPath(dir), 'utf8')).toBe(before); + expect(statSync(envPath(dir)).mode).toBe(modeBefore); + }, + 15000, + ); + + it( + '.env 不存在 → 从 .env.example 建立基础,但仍因占位符未填而报错', + () => { + const dir = makeDir(); + const result = run(['--non-interactive', '--dir', dir]); + + expect(result.status).not.toBe(0); + const out = outputOf(result); + expect(out).toContain('已从模板创建'); + expect(out).toContain('DB_HOST'); // + expect(out).toContain('DB_PASS'); // <部署方提供> + // 文件确实被创建(作为用户后续填写的基础) + expect(existsSync(envPath(dir))).toBe(true); + }, + 15000, + ); +}); + +describe('init-env.sh:交互式引导(printf ... | bash)', () => { + it( + '逐项回答后写回正确值,注释保留,ADMIN_PORT 默认 = PORT + 1', + () => { + const dir = makeDir(); + // 顺序:DB_HOST DB_PORT DB_USER DB_PASS DB_NAME PORT ADMIN_PORT HOST + const input = + ['db.example.com', '3307', 'dance_user', 'p@ss*word%', 'dance_db', '4100', '', ''].join( + '\n', + ) + '\n'; + + const result = run(['--dir', dir], { input, timeoutMs: 10000 }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + const content = readFileSync(envPath(dir), 'utf8'); + + expect(content).toMatch(/^DB_HOST=db\.example\.com$/m); + expect(content).toMatch(/^DB_PORT=3307$/m); + expect(content).toMatch(/^DB_USER=dance_user$/m); + expect(content).toMatch(/^DB_PASS=p@ss\*word%$/m); + expect(content).toMatch(/^DB_NAME=dance_db$/m); + expect(content).toMatch(/^PORT=4100$/m); + expect(content).toMatch(/^ADMIN_PORT=4101$/m); // 默认 = PORT + 1 + expect(content).toMatch(/^HOST=0\.0\.0\.0$/m); + + // 原有注释与其它字段必须保留 + expect(content).toContain('# 测试用模板:这些注释必须被保留'); + expect(content).toContain('# 管理端端口注释'); + expect(content).toContain('NODE_ENV=production'); + expect(content).toContain('DB_SYNCHRONIZE=false'); + expect(content).toContain('UPLOAD_MAX_BYTES=536870912'); + + // 口令落盘为 600 + expect(statSync(envPath(dir)).mode & 0o777).toBe(0o600); + }, + 15000, + ); + + it( + '交互输入中途 EOF → 立即非 0 退出,不挂住', + () => { + const dir = makeDir(); + const result = run(['--dir', dir], { input: 'only-a-host\n', timeoutMs: 5000 }); + + expect(result.error).toBeUndefined(); // 未被 timeout 杀掉 + expect(result.status).not.toBe(0); + expect(outputOf(result)).toContain('stdin'); + }, + 15000, + ); + + it( + '重复运行(--force + 同样输入)结果幂等', + () => { + const dir = makeDir(); + const input = + ['h1', '3306', 'u1', 'pw1', 'd1', '3000', '3001', '0.0.0.0'].join('\n') + '\n'; + + expect(run(['--force', '--dir', dir], { input, timeoutMs: 10000 }).status).toBe(0); + const first = readFileSync(envPath(dir), 'utf8'); + expect(run(['--force', '--dir', dir], { input, timeoutMs: 10000 }).status).toBe(0); + const second = readFileSync(envPath(dir), 'utf8'); + + expect(second).toBe(first); + }, + 20000, + ); +}); + +describe('init-env.sh:字段校验', () => { + it.each([ + ['DB_PORT', '70000'], + ['PORT', 'abc'], + ['ADMIN_PORT', '70000'], + ])( + '端口非法 %s=%s → 非 0 退出并报出字段名', + (key, bad) => { + const dir = makeDir(); + const lines = COMPLETE_ENV.trimEnd() + .split('\n') + .filter((line) => !line.startsWith(`${key}=`)); + lines.push(`${key}=${bad}`); + writeFileSync(envPath(dir), lines.join('\n') + '\n'); + + const result = run(['--non-interactive', '--dir', dir], { timeoutMs: 5000 }); + + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(outputOf(result)).toContain(key); + }, + 15000, + ); + + it( + '交互下端口非法会重新询问,最终接受合法值', + () => { + const dir = makeDir(); + // DB_PORT 连续给两个非法值,脚本应重新询问 + const input = + ['h', 'abc', '70000', '3306', 'u', 'p', 'd', '', '', ''].join('\n') + '\n'; + + const result = run(['--dir', dir], { input, timeoutMs: 10000 }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + const content = readFileSync(envPath(dir), 'utf8'); + expect(content).toMatch(/^DB_PORT=3306$/m); + expect(outputOf(result)).toContain('必须'); + }, + 15000, + ); + + it.each([['6*yGoR9lHz510nl*'], ['a%b*c-d_e.f']])( + '口令含特殊字符 %s:不加引号写盘且 set -a; . .env 能读回同一个值', + (password) => { + const dir = makeDir(); + const input = ['h', '3306', 'u', password, 'd', '', '', ''].join('\n') + '\n'; + + const result = run(['--dir', dir], { input, timeoutMs: 10000 }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + + const content = readFileSync(envPath(dir), 'utf8'); + expect(content).toContain(`DB_PASS=${password}`); + // 不得给值加引号(否则 shell 语义可能变化) + expect(content).not.toMatch(/^DB_PASS=["']/m); + + // 真实地用 shell source 一次,确认读回同一个值 + const sourced = spawnSync( + 'bash', + ['-c', 'set -a; . "$1"; printf "%s" "$DB_PASS"', '_', envPath(dir)], + { encoding: 'utf8' }, + ); + expect(sourced.status).toBe(0); + expect(sourced.stdout).toBe(password); + }, + 15000, + ); +}); + +describe('init-env.sh:--force / --check / 幂等', () => { + it( + '--force 在已有 .env 时重新生成,并提示 ADMIN_PORT === PORT 的单端口模式', + () => { + const dir = makeDir(); + writeFileSync(envPath(dir), COMPLETE_ENV); // 旧配置 + + const input = + ['newhost', '3406', 'newuser', 'newpass', 'newdb', '5100', '5100', '127.0.0.1'].join('\n') + + '\n'; + const result = run(['--force', '--dir', dir], { input, timeoutMs: 10000 }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + const content = readFileSync(envPath(dir), 'utf8'); + expect(content).toMatch(/^DB_HOST=newhost$/m); + expect(content).toMatch(/^DB_PORT=3406$/m); + expect(content).toMatch(/^DB_USER=newuser$/m); + expect(content).toMatch(/^DB_PASS=newpass$/m); + expect(content).toMatch(/^DB_NAME=newdb$/m); + expect(content).toMatch(/^PORT=5100$/m); + expect(content).toMatch(/^ADMIN_PORT=5100$/m); + expect(content).toMatch(/^HOST=127\.0\.0\.1$/m); + + expect(outputOf(result)).toContain('单端口'); + }, + 15000, + ); + + it( + '已有完整 .env 且未加 --force:跳过询问、不修改文件(幂等)', + () => { + const dir = makeDir(); + writeFileSync(envPath(dir), COMPLETE_ENV); + const before = readFileSync(envPath(dir), 'utf8'); + + // 即使管道里塞了数据也应当忽略(因为完整且未 --force) + const result = run(['--dir', dir], { input: '\n\n\n\n\n\n\n\n', timeoutMs: 8000 }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(outputOf(result)).toContain('跳过询问'); + expect(readFileSync(envPath(dir), 'utf8')).toBe(before); + }, + 15000, + ); + + it( + '--check:合法则 0 且不改文件;缺字段则非 0', + () => { + const okDir = makeDir(); + writeFileSync(envPath(okDir), COMPLETE_ENV); + const before = readFileSync(envPath(okDir), 'utf8'); + + const okResult = run(['--check', '--dir', okDir], { timeoutMs: 5000 }); + expect(okResult.error).toBeUndefined(); + expect(okResult.status).toBe(0); + expect(readFileSync(envPath(okDir), 'utf8')).toBe(before); + + const badDir = makeDir(); + writeFileSync(envPath(badDir), 'DB_HOST=h\n'); + const badResult = run(['--check', '--dir', badDir], { timeoutMs: 5000 }); + expect(badResult.error).toBeUndefined(); + expect(badResult.status).not.toBe(0); + expect(outputOf(badResult)).toContain('DB_USER'); + expect(outputOf(badResult)).toContain('DB_PASS'); + }, + 15000, + ); +}); diff --git a/web-server/test/model-archive.spec.ts b/web-server/test/model-archive.spec.ts new file mode 100644 index 0000000..8f66fce --- /dev/null +++ b/web-server/test/model-archive.spec.ts @@ -0,0 +1,442 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { crc32, deflateRawSync } from 'node:zlib'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { ModelFormat } from '../src/common/enums'; +import { findZipEntry, readZipEntries } from '../src/common/zip-reader'; +import { embedGlbImages } from '../src/models/glb-textures'; +import { convertModelArchive, pickModelFile } from '../src/models/model-convert'; +import { renderDefaultModelSource } from '../src/models/default-model-source'; +import { DEFAULT_MODEL_SEED, hasDefaultModelSeed } from '../src/config/default-model'; + +/* ------------------------------------------------------------ ZIP 夹具 */ + +interface FakeEntry { + path: string; + content: Buffer; + /** 0=STORE, 8=DEFLATE */ + method?: number; + /** 置位通用标志位 0(加密) */ + encrypted?: boolean; + /** 未知压缩方式 */ + badMethod?: number; +} + +/** 手写一个 ZIP(测试用;不依赖任何第三方库) */ +function buildZip(entries: FakeEntry[]): Buffer { + const locals: Buffer[] = []; + const centrals: Buffer[] = []; + let offset = 0; + + for (const entry of entries) { + const name = Buffer.from(entry.path, 'utf8'); + const method = entry.badMethod ?? entry.method ?? 8; + const raw = entry.content; + const data = method === 8 ? deflateRawSync(raw) : raw; + const checksum = crc32(raw); + + const local = Buffer.alloc(30 + name.length); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE((entry.encrypted ? 0x0001 : 0) | 0x0800, 6); + local.writeUInt16LE(method, 8); + local.writeUInt32LE(checksum, 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(raw.length, 22); + local.writeUInt16LE(name.length, 26); + name.copy(local, 30); + locals.push(local, data); + + const central = Buffer.alloc(46 + name.length); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE((entry.encrypted ? 0x0001 : 0) | 0x0800, 8); + central.writeUInt16LE(method, 10); + central.writeUInt32LE(checksum, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(raw.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt32LE(offset, 42); + name.copy(central, 46); + centrals.push(central); + + offset += local.length + data.length; + } + + const centralBuf = Buffer.concat(centrals); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(entries.length, 8); + eocd.writeUInt16LE(entries.length, 10); + eocd.writeUInt32LE(centralBuf.length, 12); + eocd.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, centralBuf, eocd]); +} + +/* ------------------------------------------------- 假 Assimp(对照实验) */ + +let workRoot: string; +let fakeAssimp: string; + +/** + * 假 Assimp:用真实 Assimp 的"FBX 导出会写坏文件但可能返回非 0"的行为做对照。 + * 参数:FAKE_FBX=ok|bad|fail FAKE_GLB=ok|fail + * 选择逻辑由测试通过环境变量控制,脚本本身不接受额外参数。 + */ +function writeFakeAssimp(): void { + const script = `#!/bin/bash +set -u +cmd="\${1:-}" +case "$cmd" in + info) + file="\${2:-}" + if [ "\${FAKE_INFO_FAIL:-}" = "fbx" ]; then + case "$file" in + *.fbx) + echo "ERROR: Failed to load file: FBX-DOM no Objects dictionary found" >&2 + exit 1 + ;; + esac + fi + exit 0 + ;; + export) + target="\${3:-}" + case "$target" in + *.fbx) + if [ "\${FAKE_FBX:-ok}" = "fail" ]; then + echo "Failed to write file" >&2 + exit 11 + fi + printf 'FAKE-FBX-CONTENT-0123456789' > "$target" + ;; + *.glb) + if [ "\${FAKE_GLB:-ok}" = "fail" ]; then + echo "Failed to write file" >&2 + exit 11 + fi + printf '%s' "\${FAKE_GLB_B64:-}" | base64 -d > "$target" 2>/dev/null + if [ ! -s "$target" ]; then printf 'FAKE-GLB-CONTENT-0123456789' > "$target"; fi + ;; + *) exit 1 ;; + esac + exit 0 + ;; + *) exit 0 ;; +esac +`; + writeFileSync(fakeAssimp, script, { mode: 0o755 }); +} + +/** 构造一个最小 GLB(单 mesh + 两张外部贴图) */ +function buildGlbWithExternalImages(): Buffer { + const json = { + asset: { version: '2.0' }, + buffers: [{ byteLength: 4 }], + bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 4 }], + images: [{ uri: 'textures\\a.png' }, { uri: 'textures/b.png' }], + meshes: [{ primitives: [{ attributes: {} }] }], + }; + const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8'); + const jsonPadded = Buffer.alloc(jsonBuf.length + ((4 - (jsonBuf.length % 4)) % 4), 0x20); + jsonBuf.copy(jsonPadded); + const bin = Buffer.from([1, 2, 3, 4]); + const total = 12 + 8 + jsonPadded.length + 8 + bin.length; + const out = Buffer.alloc(total); + out.writeUInt32LE(0x46546c67, 0); + out.writeUInt32LE(2, 4); + out.writeUInt32LE(total, 8); + out.writeUInt32LE(jsonPadded.length, 12); + out.writeUInt32LE(0x4e4f534a, 16); + jsonPadded.copy(out, 20); + const binHeader = 20 + jsonPadded.length; + out.writeUInt32LE(bin.length, binHeader); + out.writeUInt32LE(0x004e4942, binHeader + 4); + bin.copy(out, binHeader + 8); + return out; +} + +beforeAll(() => { + workRoot = mkdtempSync(join(tmpdir(), 'opendance-model-test-')); + fakeAssimp = join(workRoot, 'fake-assimp'); + writeFakeAssimp(); +}); + +afterAll(() => { + rmSync(workRoot, { recursive: true, force: true }); +}); + +/* ------------------------------------------------------------------ 测试 */ + +describe('ZIP 读取器', () => { + it('解析 STORE 与 DEFLATE 条目,内容与声明长度一致', () => { + const zip = buildZip([ + { path: 'Hatsune Miku.pmx', content: Buffer.from('PMX '.repeat(100)), method: 8 }, + { path: 'textures/a.png', content: Buffer.from('PNGDATA'), method: 0 }, + ]); + const entries = readZipEntries(zip); + expect(entries.map((e) => e.path).sort()).toEqual(['Hatsune Miku.pmx', 'textures/a.png']); + for (const entry of entries) { + expect(entry.read().length).toBe(entry.size); + } + expect(findZipEntry(entries, 'HATSUNE MIKU.PMX')?.path).toBe('Hatsune Miku.pmx'); + }); + + it('拒绝上跳路径(zip-slip)', () => { + const zip = buildZip([{ path: '../../etc/passwd', content: Buffer.from('x') }]); + expect(() => readZipEntries(zip)).toThrow(/上跳路径/); + }); + + it('拒绝加密条目', () => { + const zip = buildZip([{ path: 'a.pmx', content: Buffer.from('x'), encrypted: true }]); + expect(() => readZipEntries(zip)).toThrow(/已加密/); + }); + + it('拒绝不支持的压缩方式', () => { + const zip = buildZip([{ path: 'a.pmx', content: Buffer.from('x'), badMethod: 12 }]); + const entries = readZipEntries(zip); + expect(() => entries[0].read()).toThrow(/不支持的 ZIP 压缩方式 12/); + }); + + it('不是 ZIP 时给出清晰错误', () => { + expect(() => readZipEntries(Buffer.from('hello world, not a zip at all'))).toThrow(/EOCD|ZIP/); + }); + + it('目录条目被跳过', () => { + const zip = buildZip([ + { path: 'textures/', content: Buffer.alloc(0), method: 0 }, + { path: 'textures/a.png', content: Buffer.from('PNG'), method: 0 }, + ]); + expect(readZipEntries(zip).map((e) => e.path)).toEqual(['textures/a.png']); + }); +}); + +describe('主模型挑选规则', () => { + const entry = (path: string, size: number) => ({ path, size, method: 8, read: () => Buffer.alloc(0) }); + + it('优先指名字(大小写与目录不敏感)', () => { + const entries = [ + entry('Shadekai-1.1/Shadekai_Controller.pmx', 2213), + entry('Hatsune Miku.pmx', 833043), + ]; + expect(pickModelFile(entries, 'Hatsune Miku.pmx')?.path).toBe('Hatsune Miku.pmx'); + expect(pickModelFile(entries, 'hAtSuNe MiKu.PMX')?.path).toBe('Hatsune Miku.pmx'); + expect(pickModelFile(entries, 'sub/Hatsune Miku.pmx')?.path).toBe('Hatsune Miku.pmx'); + }); + + it('没有指名字时取目录最浅,其次文件最大', () => { + const entries = [ + entry('deep/inner/model.pmx', 999999), + entry('root.pmx', 100), + entry('another-root.pmx', 500), + ]; + expect(pickModelFile(entries)?.path).toBe('another-root.pmx'); + }); + + it('没有 PMX 时返回 undefined', () => { + expect(pickModelFile([entry('readme.txt', 10)])).toBeUndefined(); + }); +}); + +describe('GLB 贴图内嵌', () => { + it('把外部贴图搬进 BIN chunk 并补 bufferView / mimeType', () => { + const glb = buildGlbWithExternalImages(); + const files: Record = { + 'textures/a.png': Buffer.from([9, 9, 9, 9, 9]), + 'textures/b.png': Buffer.from([7, 7, 7]), + }; + const result = embedGlbImages(glb, (uri) => files[uri.replace(/\\/g, '/')] ?? null); + expect(result.embedded).toBe(2); + expect(result.unresolved).toEqual([]); + + // 重新解析,确认 image 已改为 bufferView 且没有 uri + const jsonLength = result.glb.readUInt32LE(12); + const json = JSON.parse(result.glb.subarray(20, 20 + jsonLength).toString('utf8')); + expect(json.images.every((image: Record) => image.uri === undefined)).toBe(true); + expect(json.images.map((image: Record) => image.mimeType)).toEqual([ + 'image/png', + 'image/png', + ]); + expect(json.bufferViews).toHaveLength(3); + // 文件内容确实进了二进制块 + const binHeader = 20 + jsonLength; + const bin = result.glb.subarray(binHeader + 8); + expect(bin.includes(Buffer.from([9, 9, 9, 9, 9]))).toBe(true); + expect(bin.includes(Buffer.from([7, 7, 7]))).toBe(true); + }); + + it('找不到贴图时保留 uri 并记入 unresolved', () => { + const glb = buildGlbWithExternalImages(); + const result = embedGlbImages(glb, () => null); + expect(result.embedded).toBe(0); + expect(result.unresolved).toEqual(['textures\\a.png', 'textures/b.png']); + expect(result.glb.equals(glb)).toBe(true); + }); + + it('不认识的扩展名不内嵌', () => { + const glb = buildGlbWithExternalImages(); + const result = embedGlbImages(glb, () => Buffer.from('x')); + // a.png / b.png 都是已知扩展名,这里改成断言 mimeType 正确即可 + expect(result.embedded).toBe(2); + }); +}); + +describe('压缩包 → 模型转换(用假 Assimp 做对照)', () => { + const archive = () => + buildZip([ + { path: 'Hatsune Miku.pmx', content: Buffer.from('PMX-MODEL-BYTES') }, + { path: 'textures/a.png', content: Buffer.from([1, 2, 3, 4, 5]) }, + { path: 'textures/b.png', content: Buffer.from([6, 7, 8]) }, + ]); + + // 让假 Assimp "导出"的 GLB 是一个真实可解析的 GLB(含两张外部贴图), + // 这样贴图内嵌路径也能被覆盖到 + const fakeGlbB64 = buildGlbWithExternalImages().toString('base64'); + + const runConvert = (env: Record, preferFormat: 'fbx' | 'glb' = 'fbx') => { + const previous = { ...process.env }; + Object.assign(process.env, { FAKE_GLB_B64: fakeGlbB64, ...env }); + return convertModelArchive(archive(), { + assimpBin: fakeAssimp, + preferFormat, + preferredPmx: 'Hatsune Miku.pmx', + timeoutMs: 20_000, + }).finally(() => { + process.env = previous; + }); + }; + + it('FBX 导出成功且可回读 → 用 FBX', async () => { + const result = await runConvert({ FAKE_FBX: 'ok', FAKE_GLB: 'ok' }); + expect(result.format).toBe(ModelFormat.Fbx); + expect(result.sourceModelPath).toBe('Hatsune Miku.pmx'); + expect(result.attempts).toHaveLength(1); + expect(result.attempts[0]).toContain('回读校验通过'); + }); + + it('FBX 导出退出码非 0 → 自动回退 GLB 并记录原因', async () => { + const result = await runConvert({ FAKE_FBX: 'fail', FAKE_GLB: 'ok' }); + expect(result.format).toBe(ModelFormat.Glb); + expect(result.attempts).toHaveLength(2); + expect(result.attempts[0]).toContain('FBX'); + expect(result.attempts[0]).toContain('导出失败'); + expect(result.attempts[1]).toContain('GLB'); + expect(result.note).toContain('Hatsune Miku.pmx → GLB'); + // 贴图从压缩包里被内嵌进 GLB + expect(result.note).toContain('已内嵌 2 张贴图'); + }); + + it('FBX 退出码为 0 但产物损坏(回读失败)→ 同样回退 GLB', async () => { + // 对照 Assimp 的真实行为:命令"成功"写出了文件,但文件读不回来 + const result = await runConvert({ FAKE_FBX: 'ok', FAKE_GLB: 'ok', FAKE_INFO_FAIL: 'fbx' }); + expect(result.attempts[0]).toContain('无法回读'); + expect(result.format).toBe(ModelFormat.Glb); + expect(result.attempts).toHaveLength(2); + }); + + it('两种格式都失败 → 抛出可读错误并提示安装 Assimp', async () => { + await expect(runConvert({ FAKE_FBX: 'fail', FAKE_GLB: 'fail' })).rejects.toThrow( + /模型转换失败[\s\S]*Assimp/, + ); + }); + + it('压缩包里没有 PMX → 明确报错', async () => { + const zip = buildZip([{ path: 'readme.txt', content: Buffer.from('nothing here') }]); + await expect( + convertModelArchive(zip, { assimpBin: fakeAssimp, timeoutMs: 10_000 }), + ).rejects.toThrow(/没有找到 \.pmx/); + }); + + it('assimp 不存在时给出可读错误', async () => { + await expect( + convertModelArchive(archive(), { assimpBin: join(workRoot, 'no-such-assimp'), timeoutMs: 5_000 }), + ).rejects.toThrow(/模型转换失败/); + }); +}); + +describe('默认模型写入源码', () => { + it('渲染出的源码包含种子值且结构完整', () => { + const content = renderDefaultModelSource({ + id: 16, + name: '初音未来 Hatsune Miku', + format: 'glb', + sha256: 'a'.repeat(64), + convertedFrom: 'Hatsune Miku.pmx → GLB', + updatedAt: '2026-09-15T00:00:00.000Z', + }); + expect(content).toContain('export interface DefaultModelSeed'); + expect(content).toContain("name: \"初音未来 Hatsune Miku\""); + expect(content).toContain('id: 16'); + expect(content).toContain('sha256: "' + 'a'.repeat(64) + '"'); + expect(content).toContain('convertedFrom: "Hatsune Miku.pmx → GLB"'); + expect(content).toContain('export function hasDefaultModelSeed'); + }); + + it('空种子渲染为 null 而不是空字符串', () => { + const content = renderDefaultModelSource({ + id: null, + name: '', + format: '', + sha256: '', + convertedFrom: null, + updatedAt: null, + }); + expect(content).toContain('id: null'); + expect(content).toContain('convertedFrom: null'); + expect(content).toContain('updatedAt: null'); + }); + + it('hasDefaultModelSeed 以 sha256 为准', () => { + // 注意:src/config/default-model.ts 是**会被服务端改写**的文件, + // 因此这里只断言显式传入的种子,不对文件当前内容做假设。 + expect( + hasDefaultModelSeed({ + id: null, + name: '', + format: '', + sha256: ' ', + convertedFrom: null, + updatedAt: null, + }), + ).toBe(false); + expect(hasDefaultModelSeed(DEFAULT_MODEL_SEED)).toBe( + DEFAULT_MODEL_SEED.sha256.trim().length > 0, + ); + expect( + hasDefaultModelSeed({ + id: 1, + name: 'x', + format: 'glb', + sha256: 'b'.repeat(64), + convertedFrom: null, + updatedAt: null, + }), + ).toBe(true); + }); + + it('渲染结果能被 TypeScript 语法接受(用 tsc 校验临时文件)', () => { + const content = renderDefaultModelSource({ + id: 3, + name: '带"引号"与\\反斜杠的模型', + format: 'glb', + sha256: 'c'.repeat(64), + convertedFrom: 'a.pmx → GLB', + updatedAt: '2026-09-15T00:00:00.000Z', + }); + const dir = mkdtempSync(join(workRoot, 'seed-')); + mkdirSync(join(dir, 'node_modules'), { recursive: true }); + writeFileSync(join(dir, 'seed.ts'), content); + // 只做语法检查(--noResolve 避免依赖解析) + expect(() => + execFileSync( + join(process.cwd(), 'node_modules', '.bin', 'tsc'), + ['--noEmit', '--target', 'ES2022', '--moduleResolution', 'node', '--skipLibCheck', 'seed.ts'], + { cwd: dir, stdio: 'pipe' }, + ), + ).not.toThrow(); + expect(readFileSync(join(dir, 'seed.ts'), 'utf8')).toContain('带\\"引号\\"'); + }); +});