From 8e8cc0475b43de7a2da3d18b1dd4389e1c5bbf3a Mon Sep 17 00:00:00 2001 From: opendance Date: Wed, 16 Sep 2026 03:30:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(models):=20=E4=BF=AE=E5=A4=8D=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=20PMX=20=E8=BF=94=E5=9B=9E=20500=EF=BC=88models.stats?= =?UTF-8?q?=20=E8=B6=85=20TEXT=20=E4=B8=8A=E9=99=90=EF=BC=89=E4=B8=8E=20GB?= =?UTF-8?q?K=20=E4=B8=A2=E8=B4=B4=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象(真实故障):导入示例文件/【奥黛塔】_by_原神.zip 返回 500 Internal server error。 根因一(500): 该 PMX 643 根骨骼、93,350 面,转换本身成功(GLB 23.7MB),但分析结果里的 骨骼树按骨骼逐条展开,JSON 实测 70,980 字节,而 models.stats 是 TEXT (上限 65,535)。MySQL/MariaDB 严格模式直接拒绝写入(ER_DATA_TOO_LONG / 1406),而这一步没有兜底 -> 未捕获 -> 500。 根因二(丢贴图): tex/髮.png 的 GBK 字节是 F3 8C。文件名解码先试 Shift-JIS,而 WHATWG 的 shift_jis 把未定义码位映射成私用区不可见字符(不是 U+FFFD),于是被判为 「解码成功」,永不回退 GBK;贴图名对不上,该模型 18 张贴图只内嵌了 17 张。 修复: 1. models.stats -> MEDIUMTEXT(实体 mediumtext + transformer,保留 JSON 语义; 不用 simple-json 以免元数据与库列型不一致),新增迁移 AddModelStatsMediumText1789550000000; 2. 新增 shrinkStatsForStorage() 兜底:即使库未迁移,也只截断骨骼树 (父在前,保留祖先链),面数/骨骼数/humanoid 覆盖/可驱动性/warnings 全部保留; 服务层 saveWithStatsFallback() 仅在 1406/DATA_TOO_LONG 时裁剪重试一次, 其它数据库错误照旧抛出; 3. 压缩包文件名解码把「私用区字符」也算作解错信号,回退 GBK;实测该模型 贴图由 17/18 变为 18/18 全部内嵌; 4. 新增 12 项测试(test/model-stats-limit.spec.ts 9 项 + 压缩包编码/目录条目 3 项); 5. 版本 0.2.3,README(.md/.txt)/DSH.md 补修订说明、500 的 FAQ 与 stats 容量约束。 --- DSH.md | 16 +- README.md | 23 ++- README.txt | 32 ++- project.yaml | 4 +- scripts/package-release.sh | 4 +- tasks/005-model-upload.md | 2 +- tasks/007-upload-review.md | 2 +- web-server/admin/package.json | 2 +- web-server/package.json | 2 +- web-server/src/common/zip-reader.ts | 17 +- web-server/src/entities/model.entity.ts | 29 ++- .../1789550000000-AddModelStatsMediumText.ts | 25 +++ web-server/src/models/model-inspector.ts | 67 +++++++ web-server/src/models/models.service.ts | 45 ++++- web-server/test/model-archive.spec.ts | 42 +++- web-server/test/model-stats-limit.spec.ts | 187 ++++++++++++++++++ 16 files changed, 464 insertions(+), 35 deletions(-) create mode 100644 web-server/src/migrations/1789550000000-AddModelStatsMediumText.ts create mode 100644 web-server/test/model-stats-limit.spec.ts diff --git a/DSH.md b/DSH.md index 7fac92e..998c0ca 100644 --- a/DSH.md +++ b/DSH.md @@ -101,7 +101,7 @@ opendance-web/ │ ├── admin/ # React 管理端 │ │ ├── src/components/ModelReviewPanel.tsx # 模型审核面板 + 骨骼详细界面 │ │ └── src/lib/bone-tree.ts # 骨骼树过滤(搜索保留祖先链) -│ ├── test/ # 单元测试 + 端到端冒烟(12 文件 / 193 项) +│ ├── test/ # 单元测试 + 端到端冒烟(13 文件 / 205 项) │ │ # mmd-vmd 31 / model-archive 24 / model-inspector 25 │ │ # / model-thumbnail 8 / admin-bone-filter 8 │ │ # / preprocess 25 / init-env 16 / admin-port 16 @@ -112,6 +112,7 @@ opendance-web/ ├── deploy/ # ★ 业务服务部署(不含 MySQL、不含反代) │ ├── init-env.sh # ★ 首次启动的.env 交互引导(install.sh 缺.env 时调用) │ ├── env-lib.sh # ★ deploy/.env 语法预检(被 install.sh / ctl.sh 引用) +│ ├── assimp-lib.sh # ★ Assimp 定位与安装(install.sh / check-access.sh 引用) │ ├── install.sh # 一键部署(systemd / OpenRC / SysVinit / 后台进程) │ ├── uninstall.sh │ ├── ctl.sh # 无 init 系统时的进程控制 @@ -334,13 +335,22 @@ POST /api/models ─┬─.glb /.vrm /.gltf ─▶ importModelFile() 直接导 T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms)。 - 管理端 3D 预览放开到 `vrm/glb/gltf`(`fbx` 仍提示"仅归档,请先转换为 GLB/VRM"); 列表页显示 40px 缩略图。 -- 测试:`npx vitest run` -> **12 个文件 / 193 项通过** +- 测试:`npx vitest run` -> **13 个文件 / 205 项通过** (`model-inspector` 25、`model-thumbnail` 8、`admin-bone-filter` 8、 `model-archive` 24、`mmd-vmd` 31、`preprocess` 25、`init-env` 16、 `admin-port` 16、`access-log` 9、`vrm-inspector` 6)。 -## 12. 首次启动的.env 引导(deploy/init-env.sh)、.env 预检与监听地址 +## 12. 首次启动的.env 引导(deploy/init-env.sh)、.env 预检、Assimp 与监听地址 +- `deploy/assimp-lib.sh`: Assimp(外部命令行工具)的定位与安装。`install.sh` 按发行版 + 自动安装(apt-get / dnf / yum / zypper / apk / pacman),装在非常规目录时把绝对路径写进 + `deploy/.env` 的 `ASSIMP_BIN`;装不上只告警不中止(`SKIP_ASSIMP_INSTALL=1` 跳过)。 + 缺失只影响 `.pmx/.fbx` 转换与 FBX/glTF 动作预处理,`.glb/.vrm` 不受影响。 +- `models.stats` 必须是 **MEDIUMTEXT**(迁移 `AddModelStatsMediumText`):骨骼树按骨骼逐条 + 展开,实测 643 骨骼的 PMX 分析结果 70,980 字节 > TEXT 上限 65,535,写入报 1406 并让上传 + 500。服务层另有 `shrinkStatsForStorage` 兜底(只裁骨骼树,保留全部汇总字段)。 +- 压缩包文件名解码:Shift-JIS 试解后若含**私用区字符**同样视为解错并回退 GBK(WHATWG 的 + shift_jis 把未定义码位映射成 PUA 而非 U+FFFD,会让 `tex/髮.png` 丢字符、贴图对不上)。 - `deploy/env-lib.sh`: `.env` 语法预检。`install.sh` / `ctl.sh` 用 `set -a; . deploy/.env` 载入配置时 shell 会真的执行该文件,值含空格却未加引号 会被切成「赋值 + 命令」(实测报 `line 53: Miku.pmx: command not found`,退出码 127)。 diff --git a/README.md b/README.md index 37ce032..bb8485a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,13 @@ OpenDance Studio **阶段一**: Web 管理服务端(NestJS + TypeORM + 现有 My ## 状态 -**阶段一已完成**: T-WEB-001 -> T-WEB-010 全部落地,当前版本 **v0.2.2**(契约 `rest-api / data-models / model-formats 0.4.0`、`database 0.3.0`)。 +**阶段一已完成**: T-WEB-001 -> T-WEB-010 全部落地,当前版本 **v0.2.3**(契约 `rest-api / data-models / model-formats 0.4.0`、`database 0.3.0`)。 + +v0.2.3 修订: + +1. **修复上传 PMX 返回 500**(`models.stats` 容量):分析结果里的骨骼树按骨骼逐条展开,实测一个 643 骨骼的 PMX(【奥黛塔】_by_原神.zip)就有 **70,980 字节**,而该列是 `TEXT`(上限 65,535),MySQL 严格模式直接拒绝写入(1406)导致 500。列迁移到 **MEDIUMTEXT**(`AddModelStatsMediumText`),并新增 `shrinkStatsForStorage` 兜底:即使库没迁移,也只截断骨骼树、保留面数/骨骼数/humanoid 覆盖/可驱动性,绝不让上传失败; +2. **修复 GBK 压缩包丢贴图**:文件名解码先试 Shift-JIS,而 WHATWG 的 shift_jis 会把**未定义码位映射成私用区不可见字符**(不是 U+FFFD),于是像 `tex/髮.png`(GBK 字节 `F3 8C`)这样的名字被判为「解码成功」而永不回退 GBK,贴图名对不上就丢图。现在私用区也算解错信号;实测该模型贴图由 17/18 变为 **18/18 全部内嵌**; +3. 新增 12 项测试(`test/model-stats-limit.spec.ts` 9 项 + 压缩包编码/目录条目 3 项)。 v0.2.2 修订: @@ -53,15 +59,15 @@ v0.2.0 主要变更: 3. 取得发布包并校验(地址用你们自己的制品库替换; 下面是本仓库的发布地址) ```bash - curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.2.2/opendance-web-v0.2.2.tar.gz - curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.2.2/SHA256SUMS + curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.2.3/opendance-web-v0.2.3.tar.gz + curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.2.3/SHA256SUMS sha256sum -c SHA256SUMS # 期望输出 "OK" ``` 4. 解压并生成配置(交互式引导,逐项问答,口令不回显) ```bash - tar -xzf opendance-web-v0.2.2.tar.gz && cd opendance-web-v0.2.2 + tar -xzf opendance-web-v0.2.3.tar.gz && cd opendance-web-v0.2.3 bash deploy/init-env.sh ``` @@ -131,8 +137,8 @@ FLUSH PRIVILEGES; ### 5. 部署步骤 ```bash -tar -xzf opendance-web-v0.2.2.tar.gz -cd opendance-web-v0.2.2 +tar -xzf opendance-web-v0.2.3.tar.gz +cd opendance-web-v0.2.3 bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下) bash deploy/install.sh # 已以 root 登录, 不需要 sudo ``` @@ -273,6 +279,7 @@ bash /opt/opendance/deploy/ctl.sh status | restart | logs | 服务本机 curl 通、外部访问不了 | `bash deploy/check-access.sh` 一次定位:打印 `HOST`/`PORT`、实际监听地址、对外 IP 自测、80/443 有无反代、防火墙放行情况,并给出修复命令 | | 管理页面打开是 404 | 说明访问的是软件 API 端口(默认 3000);管理页面在管理端端口(默认 3001) | | 上传 `.zip` / `.pmx` / `.fbx` 报转换失败(ENOENT / Assimp 不可用) | PMX/FBX 转换依赖外部 Assimp:`apt-get install -y assimp-utils`(RHEL 系 `dnf install -y assimp`,`install.sh` 现在会尝试自动安装)。装好后**不需要重启服务**;若不在服务 PATH 中,在 `deploy/.env` 设 `ASSIMP_BIN` 为绝对路径。只上传 `.glb` / `.vrm` 则不需要它 | +| 导入模型返回 **500 Internal server error** | 老库上多为 `models.stats` 超长(骨骼树按骨骼展开,643 骨骼即 70,980 字节 > TEXT 上限):执行 `ALTER TABLE models MODIFY stats MEDIUMTEXT NULL;` 或部署 v0.2.3(`install.sh` 会自动跑迁移)。v0.2.3 起服务端另有兜底,不会再 500 | | 安装时报 `command not found` / 退出码 127,且提示指向 `deploy/.env` 某一行 | 该行的值含空格或 shell 元字符却没加引号,被 shell 当成命令执行了。按预检提示给这个值加引号(如 `MODEL_CONVERT_PMX="Hatsune Miku.pmx"`)后重跑;`deploy/env-lib.sh` 会提前拦住并给出行号 | | 模型审核界面显示"未分析 / 无预览图" | 该模型是升级前入库的:调 `POST /api/models//reanalyze`,或批量跑 `node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000` | @@ -302,11 +309,11 @@ npm run dev # http://localhost:5173(/api 与 /health 代理到 3000) cd web-server npm run contracts:check # 契约版本一致性 npm run typecheck # TypeScript 严格检查 -npm run test # 193 项单元测试 / 12 个文件(VMD 31、模型压缩包 24、 +npm run test # 205 项单元测试 / 13 个文件(VMD 31、模型压缩包 27、 # 模型检查器 25、缩略图 8、骨骼过滤 8、 # 预处理 25、init-env 16、admin-port 16、 # access-log 9、VRM 检查器 6、.env 预检 12、 - # Assimp 定位与安装 13) + # Assimp 定位与安装 13、stats 容量与编码 12) npm run build # 生产构建 npm run start:prod & # 启动 npm run test:smoke # 57 项端到端冒烟(需要数据库可连通) diff --git a/README.txt b/README.txt index f9249c2..2c7e4e8 100644 --- a/README.txt +++ b/README.txt @@ -1,5 +1,5 @@ ================================================================ -OpenDance Studio -- Web 管理服务端 v0.2.2 使用说明 +OpenDance Studio -- Web 管理服务端 v0.2.3 使用说明 ================================================================ 本服务端是 OpenDance Studio 三端中的第一端, 负责管理歌曲、VRM 模型与 @@ -8,6 +8,19 @@ OpenDance Studio -- Web 管理服务端 v0.2.2 使用说明 重要: 本服务端 不部署、不管理 MySQL。数据库为部署方提供的现有实例, 连接信息通过 .env 注入。 +v0.2.3 修订 + 1. 修复上传 PMX 返回 500(models.stats 容量): 分析结果里的骨骼树按骨骼逐条 + 展开, 实测一个 643 骨骼的 PMX(【奥黛塔】_by_原神.zip)就有 70,980 字节, + 而该列是 TEXT(上限 65,535), MySQL 严格模式直接拒绝写入(1406)导致 500。 + 列迁移到 MEDIUMTEXT(AddModelStatsMediumText), 并新增 + shrinkStatsForStorage 兜底: 即使库没迁移, 也只截断骨骼树、保留面数/ + 骨骼数/humanoid 覆盖/可驱动性, 绝不让上传失败。 + 2. 修复 GBK 压缩包丢贴图: 文件名解码先试 Shift-JIS, 而 WHATWG 的 shift_jis + 会把未定义码位映射成私用区不可见字符(不是 U+FFFD), 于是像 tex/髮.png + (GBK 字节 F3 8C)这样的名字被判为"解码成功"而永不回退 GBK, 贴图名对不上 + 就丢图。现在私用区也算解错信号; 实测该模型贴图由 17/18 变为 18/18 全部内嵌。 + 3. 新增 12 项测试(test/model-stats-limit.spec.ts 9 项 + 压缩包编码/目录条目 3 项)。 + v0.2.2 修订 1. 修复 Assimp 缺失时的报错与部署体验: 服务器没装 Assimp 时, 上传 .pmx/.fbx (或含它们的压缩包)只得到两条 导出失败(ENOENT): spawn assimp ENOENT, @@ -45,11 +58,11 @@ v0.2.1 修订 2. 准备一个现有的 MySQL / MariaDB 实例, 并建好库与账号(见下方"前置要求") 本项目不会创建数据库本身 3. 取得发布包并校验(地址用你们自己的制品库替换) - curl -LO https://<制品库地址>/opendance-web-v0.2.2.tar.gz + curl -LO https://<制品库地址>/opendance-web-v0.2.3.tar.gz curl -LO https://<制品库地址>/SHA256SUMS sha256sum -c SHA256SUMS # 期望输出 "OK" 4. 解压并生成配置(交互式引导, 逐项问答, 口令不回显) - tar -xzf opendance-web-v0.2.2.tar.gz && cd opendance-web-v0.2.2 + tar -xzf opendance-web-v0.2.3.tar.gz && cd opendance-web-v0.2.3 bash deploy/init-env.sh 5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查) bash deploy/install.sh @@ -107,8 +120,8 @@ v0.2.1 修订 (本项目不会创建数据库本身, 也不需要 GRANT OPTION / FILE / SUPER 等管理权限) 部署步骤 - tar -xzf opendance-web-v0.2.2.tar.gz - cd opendance-web-v0.2.2 + tar -xzf opendance-web-v0.2.3.tar.gz + cd opendance-web-v0.2.3 bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下) bash deploy/install.sh # 已以 root 登录, 不需要 sudo @@ -239,6 +252,11 @@ deploy/.env 初始化引导(deploy/init-env.sh) (RHEL 系 dnf install -y assimp; install.sh 现在会尝试自动安装)。 装好后不需要重启服务; 若不在服务 PATH 中, 在 deploy/.env 里把 ASSIMP_BIN 设为绝对路径。只上传 .glb / .vrm 则不需要它。 +- 导入模型返回 500 Internal server error + 老库上多为 models.stats 超长(骨骼树按骨骼展开, 643 骨骼即 70,980 字节 + > TEXT 上限): 执行 ALTER TABLE models MODIFY stats MEDIUMTEXT NULL; + 或部署 v0.2.3(install.sh 会自动跑迁移)。v0.2.3 起服务端另有兜底, + 不会再 500。 - 安装时报 command not found / 退出码 127, 且提示指向 deploy/.env 某一行 该行的值含空格或 shell 元字符却没加引号, 被 shell 当成命令执行了。按预检 提示给这个值加引号(如 MODEL_CONVERT_PMX="Hatsune Miku.pmx")后重跑。 @@ -269,11 +287,11 @@ deploy/.env 初始化引导(deploy/init-env.sh) cd web-server npm run contracts:check # 契约版本一致性 npm run typecheck # TypeScript 严格检查 - npm run test # 193 项单元测试 / 12 个文件(VMD 31、模型压缩包 24、 + npm run test # 205 项单元测试 / 13 个文件(VMD 31、模型压缩包 24、 # 模型检查器 25、缩略图 8、骨骼过滤 8、 # 预处理 25、init-env 16、admin-port 16、 # access-log 9、VRM 检查器 6、.env 预检 12、 - # Assimp 定位与安装 13) + # Assimp 定位与安装 13、stats 容量与编码 12) npm run build # 生产构建 npm run start:prod & # 启动 npm run test:smoke # 57 项端到端冒烟(需要数据库可连通) diff --git a/project.yaml b/project.yaml index 4142a26..d8b107d 100644 --- a/project.yaml +++ b/project.yaml @@ -1,5 +1,5 @@ project: opendance-web-server -version: 0.2.2 +version: 0.2.3 repository: opendance-web default_branch: main contracts_version: 0.4.0 @@ -60,7 +60,7 @@ tasks: status: done deliverable: - artifact: opendance-web-v0.2.2.tar.gz + artifact: opendance-web-v0.2.3.tar.gz packaging: Linux 业务服务(系统服务托管,双端口:软件 API + 管理端,不含 MySQL、不含反向代理、不使用 Docker) includes: - web-server/ # NestJS 源码 + 构建产物(含 BVH / VMD 内置解析、ZIP 读取与模型压缩包转换) diff --git a/scripts/package-release.sh b/scripts/package-release.sh index b46a727..75553cf 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -1,6 +1,6 @@ #!/bin/bash # ============================================================ -# 打包发布产物:opendance-web-v0.2.2.tar.gz +# 打包发布产物:opendance-web-v0.2.3.tar.gz # # 产物结构(Linux 业务服务部署,不使用 Docker、不部署反向代理): # web-server/ NestJS 源码 + 构建产物 @@ -21,7 +21,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_ROOT" -VERSION="${1:-0.2.2}" +VERSION="${1:-0.2.3}" NAME="opendance-web-v${VERSION}" OUT_DIR="$REPO_ROOT/dist-release" STAGE="$OUT_DIR/$NAME" diff --git a/tasks/005-model-upload.md b/tasks/005-model-upload.md index 7f0cdbe..acfbe63 100644 --- a/tasks/005-model-upload.md +++ b/tasks/005-model-upload.md @@ -160,7 +160,7 @@ node scripts/import-default-model.mjs --write-source-only --model-id 22 --dry-ru ```bash cd web-server -npx vitest run # 12 个文件 / 193 项通过 +npx vitest run # 13 个文件 / 205 项通过 npm run test:smoke # 冒烟 [4] 段落 ``` diff --git a/tasks/007-upload-review.md b/tasks/007-upload-review.md index 3c1fdc5..20faf56 100644 --- a/tasks/007-upload-review.md +++ b/tasks/007-upload-review.md @@ -86,7 +86,7 @@ draft ──submit──▶ pending ──approve──▶ published ```bash cd web-server -npx vitest run # 12 个文件 / 193 项通过(含 admin-bone-filter 8 项、.env 预检 12 项、Assimp 13 项) +npx vitest run # 13 个文件 / 205 项通过(含 admin-bone-filter 8 项、.env 预检 12 项、Assimp 13 项) npm run test:smoke ``` diff --git a/web-server/admin/package.json b/web-server/admin/package.json index fa8d0ee..76a26ee 100644 --- a/web-server/admin/package.json +++ b/web-server/admin/package.json @@ -1,7 +1,7 @@ { "name": "opendance-admin", "private": true, - "version": "0.2.2", + "version": "0.2.3", "type": "module", "description": "OpenDance Studio 管理端(React + TypeScript + Vite)", "scripts": { diff --git a/web-server/package.json b/web-server/package.json index e42b76a..9eedccc 100644 --- a/web-server/package.json +++ b/web-server/package.json @@ -1,6 +1,6 @@ { "name": "opendance-web-server", - "version": "0.2.2", + "version": "0.2.3", "description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)", "license": "MIT", "private": true, diff --git a/web-server/src/common/zip-reader.ts b/web-server/src/common/zip-reader.ts index ef95617..a945f05 100644 --- a/web-server/src/common/zip-reader.ts +++ b/web-server/src/common/zip-reader.ts @@ -46,8 +46,21 @@ function decoderFor(encoding: string): TextDecoder | null { return decoders.get(encoding) ?? null; } -/** 半角片假名:Shift-JIS 被误用时会出现,用来判断该不该换编码 */ +/** + * 半角片假名:Shift-JIS 被误用时会出现,用来判断该不该换编码。 + * + * 私用区(PUA)同样是「Shift-JIS 解错了」的信号:WHATWG 的 shift_jis 表把 + * **未定义的码位**映射到私用区,而不是 U+FFFD,于是解码结果看起来「成功」, + * 却是看不见的乱码字。实测 GBK 的 `髮`(字节 F3 8C)就落在这里: + * 先试 Shift-JIS 得到 `tex/\uE0xx.png`(不可见),永远不会回退到 GBK, + * 贴图名对不上,模型入库后丢贴图。 + */ const HALF_WIDTH_KATAKANA = /[\uFF61-\uFF9F]/; +const PRIVATE_USE_AREA = /[\uE000-\uF8FF]/; +/** Shift-JIS 解码结果是否明显解错了(该换 GBK 再试) */ +function looksMisdecoded(text: string): boolean { + return text.includes('\uFFFD') || HALF_WIDTH_KATAKANA.test(text) || PRIVATE_USE_AREA.test(text); +} /** 解压包内文件名:UTF-8 标志位优先,否则试 Shift-JIS(中文/日文压缩包常见) */ function decodeEntryName(bytes: Buffer, utf8Flag: boolean): string { @@ -57,7 +70,7 @@ function decodeEntryName(bytes: Buffer, utf8Flag: boolean): string { const shiftJis = decoderFor('shift_jis'); if (shiftJis) { const decoded = shiftJis.decode(bytes); - if (!decoded.includes('\uFFFD') && !HALF_WIDTH_KATAKANA.test(decoded)) return decoded; + if (!looksMisdecoded(decoded)) return decoded; } const gbk = decoderFor('gbk'); if (gbk) { diff --git a/web-server/src/entities/model.entity.ts b/web-server/src/entities/model.entity.ts index 11469f0..f7024e1 100644 --- a/web-server/src/entities/model.entity.ts +++ b/web-server/src/entities/model.entity.ts @@ -52,8 +52,35 @@ export class CharacterModel { /** * 模型分析结果(面数 / 骨骼树 / humanoid 覆盖 / 可驱动性…),见 model-inspector.ts。 * 存 JSON 文本:审核界面要展示,且骨骼树可能上千行,不适合拆列。 + * + * 必须是 **MEDIUMTEXT**(16MB)而不是 TEXT(64KB):骨骼树按骨骼逐条展开, + * 实测一个 643 骨骼的 PMX(【奥黛塔】)分析结果就有 70,980 字节,超过 TEXT 上限, + * MySQL 严格模式下写入直接报 1406,上传接口 500。 + * 用 mediumtext + transformer 而不是 simple-json:simple-json 固定映射到 text, + * 实体元数据会和数据库列型不一致(后续 migration:generate 会把列改回 text)。 */ - @Column({ name: 'stats', type: 'simple-json', nullable: true }) + @Column({ + name: 'stats', + type: 'mediumtext', + nullable: true, + transformer: { + to: (value: Record | null): string | null => + value === null || value === undefined + ? null + : typeof value === 'string' + ? value + : JSON.stringify(value), + from: (value: string | null): Record | null => { + if (value === null || value === undefined || value === '') return null; + try { + return JSON.parse(value) as Record; + } catch { + // 历史脏数据不应该让整个接口 500 + return { parseError: true, raw: value.slice(0, 200) }; + } + }, + }, + }) stats!: Record | null; // ---- 来源五元组(必填)---- diff --git a/web-server/src/migrations/1789550000000-AddModelStatsMediumText.ts b/web-server/src/migrations/1789550000000-AddModelStatsMediumText.ts new file mode 100644 index 0000000..8d32559 --- /dev/null +++ b/web-server/src/migrations/1789550000000-AddModelStatsMediumText.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * `models.stats` 由 TEXT(64KB)扩到 MEDIUMTEXT(16MB)。 + * + * 起因(真实故障):上传含 PMX 的压缩包(【奥黛塔】_by_原神.zip,643 根骨骼) + * 时接口返回 500。模型转换本身成功,但分析结果里的骨骼树按骨骼逐条展开, + * JSON 实测 70,980 字节 > TEXT 上限 65,535,MySQL / MariaDB 严格模式直接拒绝 + * 写入(ER_DATA_TOO_LONG / 1406),而写入这一步没有兜底。 + * + * MMD / VRM 模型骨骼上千根很常见,MEDIUMTEXT 给到 16MB 足够; + * 服务端另有 shrinkStatsForStorage 兜底,避免在未迁移的库上再次 500。 + */ +export class AddModelStatsMediumText1789550000000 implements MigrationInterface { + name = 'AddModelStatsMediumText1789550000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`models\` MODIFY \`stats\` mediumtext NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + // 回退可能截断超长数据:这里只改列型,不做内容裁剪 + await queryRunner.query(`ALTER TABLE \`models\` MODIFY \`stats\` text NULL`); + } +} diff --git a/web-server/src/models/model-inspector.ts b/web-server/src/models/model-inspector.ts index d06c09d..0468c79 100644 --- a/web-server/src/models/model-inspector.ts +++ b/web-server/src/models/model-inspector.ts @@ -667,3 +667,70 @@ export async function inspectModelFile(absPath: string, fileSize?: number): Prom return analyzeGltfDocument(gltf, glb ? 'glb' : 'gltf', size, warnings); } + +/* --------------------------------------------------- 存储兜底:裁剪超大的 stats */ + +/** MySQL / MariaDB 的 TEXT 上限(未迁移 MEDIUMTEXT 的老库仍是这个值) */ +export const MYSQL_TEXT_LIMIT = 65535; + +/** 裁剪后至少保留的骨骼条目数:太少审核界面就看不出结构了 */ +const MIN_BONE_TREE_KEPT = 24; + +/** + * 把过大的 `ModelStats` 裁到能安全写进 TEXT 的大小(纯函数,永不抛异常)。 + * + * 为什么需要:`models.stats` 里最占地方的是骨骼树,按骨骼逐条展开。 + * 实测一个 643 骨骼的 PMX 分析结果 70,980 字节 > TEXT 上限 65,535, + * MySQL 严格模式会直接拒绝写入(1406)。迁移已把列扩到 MEDIUMTEXT, + * 这里再兜一层:万一跑在没迁移的库上,也宁可少存骨骼明细,不能让上传 500。 + * + * 保留策略:面数 / 骨骼数 / humanoid 覆盖 / 可驱动性 / warnings 一律保留, + * 只裁骨骼树(父在前,从头截断即保留祖先链),并追加一条 warning 说明被裁过。 + */ +export function shrinkStatsForStorage( + stats: ModelStats, + maxBytes: number = MYSQL_TEXT_LIMIT, +): ModelStats { + const fits = (candidate: ModelStats): boolean => { + try { + return JSON.stringify(candidate).length <= maxBytes; + } catch { + return false; + } + }; + if (fits(stats)) return stats; + + const note = `骨骼树过大(原 ${stats.boneTree.length} 条),存储前已截断`; + const withNote = (boneTree: BoneNode[]): ModelStats => ({ + ...stats, + boneTree, + warnings: stats.warnings.includes(note) ? stats.warnings : [...stats.warnings, note], + }); + + // 二分找能放下的最大前缀长度(骨骼树父在前,前缀即祖先链完整的子集) + let low = Math.min(MIN_BONE_TREE_KEPT, stats.boneTree.length); + let high = stats.boneTree.length; + let best = withNote(stats.boneTree.slice(0, low)); + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const candidate = withNote(stats.boneTree.slice(0, mid)); + if (fits(candidate)) { + best = candidate; + low = mid + 1; + } else { + high = mid - 1; + } + } + + // 极端情况:连最小骨骼树都放不下 -> 只留汇总字段 + if (!fits(best)) { + const summary = withNote([]); + if (fits(summary)) return summary; + return { + ...summary, + missingRequiredBones: summary.missingRequiredBones.slice(0, 16), + warnings: [note], + }; + } + return best; +} diff --git a/web-server/src/models/models.service.ts b/web-server/src/models/models.service.ts index 0dec914..7209361 100644 --- a/web-server/src/models/models.service.ts +++ b/web-server/src/models/models.service.ts @@ -29,7 +29,7 @@ import { convertModelFile, importModelFile, } from './model-convert'; -import { ModelStats, inspectModelFile } from './model-inspector'; +import { ModelStats, inspectModelFile, shrinkStatsForStorage } from './model-inspector'; import { renderModelThumbnail } from './model-thumbnail'; import { CharacterModel } from '../entities/model.entity'; import { AppConfig } from '../config/configuration'; @@ -38,6 +38,21 @@ import { QueryModelDto, UpdateModelDto } from './model.dto'; import { ModelDto, toModelDto } from './model.serializer'; import { inspectVrm } from './vrm-inspector'; +/** 判断是否为「数据超长」类数据库错误(MySQL 1406 / MariaDB ER_DATA_TOO_LONG) */ +function isDataTooLongError(error: unknown): boolean { + const err = error as { + code?: string; + errno?: number; + driverError?: { code?: string; errno?: number }; + message?: string; + }; + const code = err.driverError?.code ?? err.code; + const errno = err.driverError?.errno ?? err.errno; + if (errno === 1406) return true; + if (typeof code === 'string' && /DATA_TOO_LONG/i.test(code)) return true; + return typeof err.message === 'string' && /Data too long/i.test(err.message); +} + @Injectable() export class ModelsService { private readonly logger = new Logger(ModelsService.name); @@ -202,7 +217,7 @@ export class ModelsService { // 上传一律待审核:模型必须过审核才可能被客户端消费 status: PublishStatus.Pending, }); - const saved = await this.models.save(model); + const saved = await this.saveWithStatsFallback(model); this.logger.log( `模型已入库待审:#${saved.id} ${name}(${imported.format},${imported.direct ? '直接导入' : '转换'})` + ` 面数=${stats.triangles} 骨骼=${stats.bones} humanoid=${stats.humanoidMapped}/${stats.humanoidTotal}` + @@ -241,7 +256,7 @@ export class ModelsService { if (thumbnailAssetId !== null) model.thumbnailAssetId = thumbnailAssetId; if (stats.vrmVersion) model.vrmVersion = stats.vrmVersion; - const saved = await this.models.save(model); + const saved = await this.saveWithStatsFallback(model); this.logger.log( `模型 #${id} 重新分析完成:面数=${stats.triangles} 骨骼=${stats.bones} ` + `humanoid=${stats.humanoidMapped}/${stats.humanoidTotal} 可驱动=${stats.drivable}` + @@ -251,6 +266,30 @@ export class ModelsService { return dto; } + /** + * 保存模型;若因 `stats` 超长被数据库拒绝,裁剪后重试一次。 + * + * 骨骼树上千根的大模型并不罕见:TEXT(64KB) 时代实测 643 骨骼的 PMX 就会 + * 触发 1406 报错,而这一步抛出去就是 500。列已迁到 MEDIUMTEXT, + * 这里再兜一层,保证「分析结果太大」永远不会让上传失败。 + */ + private async saveWithStatsFallback(model: CharacterModel): Promise { + try { + return await this.models.save(model); + } catch (error) { + if (!isDataTooLongError(error)) throw error; + const original = model.stats as unknown as ModelStats | null; + if (!original) throw error; + const shrunk = shrinkStatsForStorage(original); + this.logger.warn( + `模型「${model.name}」分析结果超出数据库字段上限(${JSON.stringify(original).length} 字节),` + + `已裁剪骨骼树至 ${shrunk.boneTree.length} 条后重试`, + ); + model.stats = shrunk as unknown as Record; + return await this.models.save(model); + } + } + /** 分析模型;任何异常都降级为一条 warning,不阻断上传 */ private async analyzeSafely( absolutePath: string, diff --git a/web-server/test/model-archive.spec.ts b/web-server/test/model-archive.spec.ts index 713122f..bf357bd 100644 --- a/web-server/test/model-archive.spec.ts +++ b/web-server/test/model-archive.spec.ts @@ -22,6 +22,11 @@ interface FakeEntry { encrypted?: boolean; /** 未知压缩方式 */ badMethod?: number; + /** + * 直接指定文件名的原始字节,且**不置 UTF-8 标志位**(模拟 GBK / Shift-JIS 压缩包)。 + * 中文 MMD 压缩包很常见:有的条目是 UTF-8、有的是 GBK,且都没打标志位。 + */ + rawName?: Buffer; } /** 手写一个 ZIP(测试用;不依赖任何第三方库) */ @@ -31,7 +36,9 @@ function buildZip(entries: FakeEntry[]): Buffer { let offset = 0; for (const entry of entries) { - const name = Buffer.from(entry.path, 'utf8'); + const name = entry.rawName ?? Buffer.from(entry.path, 'utf8'); + // 给了 rawName 就当作「未声明 UTF-8」的老压缩包 + const nameFlags = entry.rawName ? (entry.encrypted ? 0x0001 : 0) : (entry.encrypted ? 0x0001 : 0) | 0x0800; const method = entry.badMethod ?? entry.method ?? 8; const raw = entry.content; const data = method === 8 ? deflateRawSync(raw) : raw; @@ -40,7 +47,7 @@ function buildZip(entries: FakeEntry[]): Buffer { 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(nameFlags, 6); local.writeUInt16LE(method, 8); local.writeUInt32LE(checksum, 14); local.writeUInt32LE(data.length, 18); @@ -53,7 +60,7 @@ function buildZip(entries: FakeEntry[]): Buffer { central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); - central.writeUInt16LE((entry.encrypted ? 0x0001 : 0) | 0x0800, 8); + central.writeUInt16LE(nameFlags, 8); central.writeUInt16LE(method, 10); central.writeUInt32LE(checksum, 16); central.writeUInt32LE(data.length, 20); @@ -220,6 +227,35 @@ describe('ZIP 读取器', () => { expect(findZipEntry(entries, 'HATSUNE MIKU.PMX')?.path).toBe('Hatsune Miku.pmx'); }); + it('GBK 压缩包:Shift-JIS 解出私用区乱码时回退 GBK(发/髮 这类字不再丢字符)', () => { + // b'tex/\xf3\x8c.png' 是 GBK 的「tex/髮.png」;Shift-JIS 会把它解成一个 + // 私用区不可见字符(不是 U+FFFD),旧实现会直接采信,导致贴图名对不上而丢图。 + const zip = buildZip([ + { path: 'raw', content: Buffer.from('PMX '.repeat(50)), rawName: Buffer.from([0x74, 0x65, 0x78, 0x2f, 0xf3, 0x8c, 0x2e, 0x70, 0x6e, 0x67]) }, + ]); + const entries = readZipEntries(zip); + expect(entries.map((e) => e.path)).toEqual(['tex/髮.png']); + }); + + it('Shift-JIS 名字仍然优先按 Shift-JIS 解(日文 MMD 压缩包)', () => { + // b'\x83\x41\x83\x43.pmx' 是 Shift-JIS 的「アイ.pmx」(半角片假名判断的姊妹用例) + const zip = buildZip([ + { path: 'raw', content: Buffer.from('PMX '.repeat(50)), rawName: Buffer.from([0x83, 0x41, 0x83, 0x43, 0x2e, 0x70, 0x6d, 0x78]) }, + ]); + const entries = readZipEntries(zip); + expect(entries[0].path).toBe('アイ.pmx'); + }); + + it('目录条目不会当成文件(避免解压时把目录当文件写)', () => { + const zip = buildZip([ + { path: 'spa/', content: Buffer.alloc(0), method: 0 }, + { path: 'spa/a.png', content: Buffer.from('PNG') }, + { path: '奥黛塔.pmx', content: Buffer.from('PMX '.repeat(50)) }, + ]); + const entries = readZipEntries(zip); + expect(entries.map((e) => e.path)).toEqual(['spa/a.png', '奥黛塔.pmx']); + }); + it('拒绝上跳路径(zip-slip)', () => { const zip = buildZip([{ path: '../../etc/passwd', content: Buffer.from('x') }]); expect(() => readZipEntries(zip)).toThrow(/上跳路径/); diff --git a/web-server/test/model-stats-limit.spec.ts b/web-server/test/model-stats-limit.spec.ts new file mode 100644 index 0000000..56eb59f --- /dev/null +++ b/web-server/test/model-stats-limit.spec.ts @@ -0,0 +1,187 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { getMetadataArgsStorage } from 'typeorm'; +import { describe, expect, it } from 'vitest'; + +import { CharacterModel } from '../src/entities/model.entity'; +import { + MYSQL_TEXT_LIMIT, + type BoneNode, + type ModelStats, + shrinkStatsForStorage, +} from '../src/models/model-inspector'; + +/** + * 骨骼树超出数据库字段上限导致的 500。 + * + * 起因(真实故障):上传含 PMX 的压缩包(【奥黛塔】_by_原神.zip,643 根骨骼) + * 返回 500。转换本身成功,但分析结果里骨骼树按骨骼逐条展开,JSON 实测 + * **70,980 字节** > `models.stats` 的 TEXT 上限 65,535,MySQL / MariaDB 严格模式 + * 直接拒绝写入(ER_DATA_TOO_LONG / 1406),而写入没有兜底。 + * + * 这里锁三件事: + * 1. 列必须是 MEDIUMTEXT(实体 + 迁移一致); + * 2. 服务层对「数据超长」有裁剪重试兜底; + * 3. 裁剪函数本身永不抛、且保留审核必需的汇总字段。 + */ + +/** 造一个规模接近真实的骨骼树(【奥黛塔】实测 643 根、每根约 110 字节) */ +function buildBoneTree(count: number): BoneNode[] { + return Array.from({ length: count }, (_, index) => ({ + index, + name: `Bone_${String(index).padStart(4, '0')}_髪_腕捩_ねじ`, + depth: Math.min(index % 40, 35), + parentName: index === 0 ? null : `Bone_${String(Math.max(0, index - 1)).padStart(4, '0')}_髪_腕捩_ねじ`, + childCount: index % 3, + inSkin: index % 7 !== 0, + humanoid: index < 7 ? ['hips', 'spine', 'chest', 'neck', 'head', 'leftShoulder', 'leftUpperArm'][index] : null, + })); +} + +function buildStats(bones: number): ModelStats { + return { + container: 'glb', + vrmVersion: null, + fileSize: 23_700_000, + meshes: 12, + primitives: 18, + vertices: 120_000, + triangles: 93_350, + materials: 9, + textures: 17, + images: 17, + animations: 0, + morphTargets: 24, + nodes: 700, + bones, + skins: 1, + humanoidMapped: 0, + humanoidTotal: 22, + missingRequiredBones: ['hips', 'spine', 'chest', 'neck', 'head', 'leftUpperArm'], + drivable: false, + maxBoneDepth: 35, + boneTree: buildBoneTree(bones), + warnings: [], + }; +} + +function findMigrationsDir(): string { + const candidates = [ + resolve(process.cwd(), 'src', 'migrations'), + resolve(process.cwd(), '..', 'src', 'migrations'), + ]; + for (const candidate of candidates) { + const files = readdirSync(candidate).filter((f) => f.endsWith('.ts')); + if (files.length > 0) return candidate; + } + throw new Error('找不到 src/migrations'); +} + +describe('models.stats 字段容量', () => { + it('643 根骨骼的分析结果确实超过 TEXT 上限(这就是 500 的原因)', () => { + const json = JSON.stringify(buildStats(643)); + expect(json.length).toBeGreaterThan(MYSQL_TEXT_LIMIT); + // 与真实故障量级一致(实测 70,980 字节) + expect(json.length).toBeGreaterThan(65_000); + expect(json.length).toBeLessThan(120_000); + }); + + it('实体列型是 mediumtext,且能 JSON 往返(不再被 simple-json 钉死在 text)', () => { + const column = getMetadataArgsStorage().columns.find( + (item) => item.target === CharacterModel && item.propertyName === 'stats', + ); + expect(column?.options.type).toBe('mediumtext'); + const transformer = column?.options.transformer as + | { to?: (v: unknown) => unknown; from?: (v: unknown) => unknown } + | undefined; + expect(typeof transformer?.to).toBe('function'); + expect(typeof transformer?.from).toBe('function'); + const stats = buildStats(643); + const stored = transformer!.to!(stats) as string; + expect(typeof stored).toBe('string'); + expect(JSON.parse(stored).bones).toBe(643); + expect((transformer!.from!(stored) as ModelStats).bones).toBe(643); + // 脏数据不能让接口 500 + expect(transformer!.from!('{ 坏 JSON')).toEqual({ parseError: true, raw: '{ 坏 JSON' }); + }); + + it('存在把 stats 扩到 MEDIUMTEXT 的迁移(install.sh 会自动执行)', () => { + const dir = findMigrationsDir(); + const file = readdirSync(dir).find((f) => /AddModelStatsMediumText.*\.ts$/.test(f)); + expect(file, '缺少 AddModelStatsMediumText 迁移').toBeTruthy(); + const sql = readFileSync(join(dir, file as string), 'utf8'); + // TS 源码里反引号是转义的,这里只断言关键字与方向 + expect(sql).toMatch(/MODIFY/i); + expect(sql).toMatch(/mediumtext/i); + expect(sql).toMatch(/ALTER TABLE/i); + expect(sql).toMatch(/implements MigrationInterface/); + }); +}); + +describe('shrinkStatsForStorage:写入前的兜底裁剪', () => { + it('没超限时原样返回(不做无谓修改)', () => { + const stats = buildStats(20); + expect(shrinkStatsForStorage(stats)).toBe(stats); + }); + + it('超限时裁剪骨骼树到能放下,并追加一条说明用的 warning', () => { + const stats = buildStats(643); + const shrunk = shrinkStatsForStorage(stats); + expect(JSON.stringify(shrunk).length).toBeLessThanOrEqual(MYSQL_TEXT_LIMIT); + expect(shrunk.boneTree.length).toBeLessThan(stats.boneTree.length); + // 审核必需的汇总字段一个都不能少 + expect(shrunk.triangles).toBe(93_350); + expect(shrunk.bones).toBe(643); + expect(shrunk.humanoidMapped).toBe(0); + expect(shrunk.humanoidTotal).toBe(22); + expect(shrunk.drivable).toBe(false); + expect(shrunk.missingRequiredBones).toEqual(stats.missingRequiredBones); + expect(shrunk.warnings.join()).toContain('已截断'); + }); + + it('保留的骨骼仍是父在前的祖先链(审核界面按 depth 缩进不会错乱)', () => { + const stats = buildStats(643); + const shrunk = shrinkStatsForStorage(stats); + const kept = shrunk.boneTree; + expect(kept.length).toBeGreaterThan(0); + for (let i = 1; i < kept.length; i += 1) { + expect(kept[i].depth).toBeLessThanOrEqual(kept[i - 1].depth + 1); + } + }); + + it('极端情况(连最小骨骼树都放不下)也不会抛,仍返回可存储的结果', () => { + const stats = buildStats(400); + const shrunk = shrinkStatsForStorage(stats, 512); + expect(JSON.stringify(shrunk).length).toBeLessThanOrEqual(1024); + expect(shrunk.triangles).toBe(93_350); + }); +}); + +describe('服务层兜底:数据超长时裁剪重试而不是 500', () => { + const source = readFileSync( + [ + resolve(process.cwd(), 'src', 'models', 'models.service.ts'), + resolve(process.cwd(), '..', 'src', 'models', 'models.service.ts'), + ].find((p) => { + try { + readFileSync(p); + return true; + } catch { + return false; + } + }) as string, + 'utf8', + ); + + it('create 与 reanalyze 都走 saveWithStatsFallback', () => { + const calls = source.match(/saveWithStatsFallback\(model\)/g) ?? []; + expect(calls.length).toBeGreaterThanOrEqual(2); + }); + + it('兜底只用 1406 / DATA_TOO_LONG 触发,其它数据库错误照旧抛出', () => { + expect(source).toContain('isDataTooLongError'); + expect(source).toMatch(/errno === 1406/); + expect(source).toMatch(/DATA_TOO_LONG/); + expect(source).toContain('if (!isDataTooLongError(error)) throw error;'); + }); +});