Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e8cc0475b |
@@ -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)。
|
||||
|
||||
@@ -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/<id>/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 项端到端冒烟(需要数据库可连通)
|
||||
|
||||
+25
-7
@@ -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 项端到端冒烟(需要数据库可连通)
|
||||
|
||||
+2
-2
@@ -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 读取与模型压缩包转换)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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] 段落
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, unknown> | null): string | null =>
|
||||
value === null || value === undefined
|
||||
? null
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
: JSON.stringify(value),
|
||||
from: (value: string | null): Record<string, unknown> | null => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
try {
|
||||
return JSON.parse(value) as Record<string, unknown>;
|
||||
} catch {
|
||||
// 历史脏数据不应该让整个接口 500
|
||||
return { parseError: true, raw: value.slice(0, 200) };
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
stats!: Record<string, unknown> | null;
|
||||
|
||||
// ---- 来源五元组(必填)----
|
||||
|
||||
@@ -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<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`models\` MODIFY \`stats\` mediumtext NULL`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// 回退可能截断超长数据:这里只改列型,不做内容裁剪
|
||||
await queryRunner.query(`ALTER TABLE \`models\` MODIFY \`stats\` text NULL`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<CharacterModel> {
|
||||
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<string, unknown>;
|
||||
return await this.models.save(model);
|
||||
}
|
||||
}
|
||||
|
||||
/** 分析模型;任何异常都降级为一条 warning,不阻断上传 */
|
||||
private async analyzeSafely(
|
||||
absolutePath: string,
|
||||
|
||||
@@ -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(/上跳路径/);
|
||||
|
||||
@@ -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;');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user