Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1c0dd6e7a | ||
|
|
7a74f598be | ||
|
|
74332e20d8 | ||
|
|
828c7cea49 | ||
|
|
f3b97fec53 | ||
|
|
2224195d05 | ||
|
|
50688e7ce4 | ||
|
|
7037abf066 |
@@ -10,7 +10,70 @@ OpenDance Studio **阶段一**: Web 管理服务端(NestJS + TypeORM + 现有 My
|
||||
|
||||
## 状态
|
||||
|
||||
**阶段一已完成**: T-WEB-001 -> T-WEB-016 全部落地,当前版本 **v0.7.2**(契约 `rest-api 0.5.0`、`websocket-keypoints 0.2.0`、`data-models 0.7.0`、`model-formats 0.5.0`、`skeleton-topology 0.2.0`、`database 0.4.0`)。
|
||||
**阶段一已完成**: T-WEB-001 -> T-WEB-016 全部落地,当前版本 **v0.7.4**(契约 `rest-api 0.5.0`、`websocket-keypoints 0.2.0`、`data-models 0.7.0`、`model-formats 0.5.0`、`skeleton-topology 0.2.0`、`database 0.4.0`)。
|
||||
|
||||
v0.7.4 修订(**场景功能的真正根因:`Scene` 没有注册进 `ENTITIES`**):
|
||||
|
||||
1. **根因(生产实测)**:`/health` 的 `scenes.error` 返回
|
||||
`No metadata for "Scene" was found.` —— `src/entities/index.ts` 的
|
||||
`ENTITIES = [Asset, Song, Motion, CharacterModel, ReviewRecord]` **漏了 `Scene`**。
|
||||
TypeORM 只按 `ENTITIES` 注册 metadata,于是:
|
||||
* **启动完全正常**(DI 图可解析、`/health` 正常、`migrationsPending 0`),
|
||||
* 但**第一次查 Scene 就抛**`No metadata`,于是 `/api/scenes`、
|
||||
`/api/catalog/export`、`/api/catalog/version`、`/api/reviews?status=pending`
|
||||
全部 500 —— 客户端拉不到任何目录、管理端待审列表打不开;
|
||||
* 我此前"数据库缺 scenes 表"的判断**也是错的**:表其实是由 AddScenes 迁移建好的,
|
||||
缺的是**实体注册**;
|
||||
2. **修复**:`ENTITIES` 加入 `Scene`(并导出)。这也解释了为什么 v0.7.3 的降级修复是必要的
|
||||
—— 即使根因是漏注册,降级也保证了"场景坏了不拖垮整站";
|
||||
3. **新增静态防线**`test/entities-registry.spec.ts`(4 项):扫描
|
||||
`src/entities/*.entity.ts` 的 `@Entity()` 类,**逐一与 `ENTITIES` 对账**
|
||||
(漏一个即失败)、检查无重复/无多余,并核对 `AddScenes` 迁移文件确实存在。
|
||||
已做**反向验证**:把 `Scene` 从 `ENTITIES` 移除,该测试立刻失败。
|
||||
这类"漏注册"既过得了 `tsc`,也过得了启动冒烟(应用能启动),
|
||||
只能靠静态核对兜住;
|
||||
4. `web-server/scripts/diagnose-scenes.mjs` 保持不变(默认只读),部署 v0.7.4 后运行它
|
||||
可确认 `scenes` 表与列的实际情况。
|
||||
|
||||
v0.7.3 修订(**场景表不可用不再拖垮整站** —— 生产事故修复):
|
||||
**另有一个更直接的缺陷同时修掉**:`ScenesService.create()` 当初按**内存存储**写,
|
||||
判断的是 `file.buffer`,而上传中间件(`storage.module.ts`)用的是 multer 的
|
||||
**diskStorage** —— 文件对象只有 `path`、没有 `buffer`,于是**任何场景都传不上来**,
|
||||
一律 400「上传场景必须带 file 字段」。现在与 models/motions 走同一套:
|
||||
`requireUploadedFile()` 校验落盘并修正 latin1 文件名编码,直接导入走
|
||||
`persistTempFile()`(顺带清理临时文件),需转换时 `readFile(upload.path)` 后再转,
|
||||
并在 `finally` 中清掉临时文件;
|
||||
|
||||
|
||||
1. **现场**:数据库里没有 `scenes` 表(迁移没落到该库 / 部署的 dist 里缺迁移文件),于是
|
||||
`GET /api/scenes`、`/api/catalog/export`、`/api/catalog/version`、
|
||||
`/api/reviews?status=pending` **全部 500**,而 `/api/songs`、`/api/models` 正常。
|
||||
后果是**客户端拉不到任何目录**(Windows/Android 都用不了)、管理端待审列表也打不开;
|
||||
2. **契约与文档都写明「场景是可选资源」**(没有可用场景时客户端回退内置空场景),所以
|
||||
场景表不可用**不应该**连带打挂与场景无关的功能。本次改为**降级**:
|
||||
* `ScenesService` 新增 `queryScenes()` 包装:数据库层面失败时返回空结果、
|
||||
**只记一次日志**(避免刷屏),并把原因记在 `diagnostics()` 里;
|
||||
* 表恢复后自动回到可用状态(下一次成功查询即清除降级标记);
|
||||
* `ReviewsService.listByStatus()` 对场景查询单独 catch,待审列表照常返回其它资源;
|
||||
* `/health` 新增 `scenes: { available, error }` —— **降级但绝不静默**;
|
||||
3. **新增诊断脚本** `web-server/scripts/diagnose-scenes.mjs`(默认只读):
|
||||
打印运行期真实连的库、表清单、`typeorm_migrations` 记录、**代码实际加载的迁移**
|
||||
(用于识别"迁移没被加载 → `/health` 显示 pending 0 但表不存在"这种最隐蔽的情况)、
|
||||
以及 `scenes` 实际列 vs 实体期望列的逐列 diff;`--fix` 仅在缺表时用迁移里
|
||||
同一份 DDL 幂等补表;
|
||||
4. 顺带修正 `models.controller.ts` 里一句误导性注释:模型上传**不接受**
|
||||
`isPrimary`/`status`(契约:上传一律 `pending`,默认模型由审核通过时带
|
||||
`setPrimary=true` 或 `POST /api/models/:id/primary` 设定) —— 冒烟脚本此前正是
|
||||
按错误预期断言,已同步修正;
|
||||
5. 新增 6 项测试(`test/scenes-degradation.spec.ts`)锁定降级行为。
|
||||
6. **更正一次误判**:排查过程中曾把 Gitea 附件接口的 `HTTP 500 {"message":"","url":"…/api/swagger"}`
|
||||
(空消息)判成"宿主机磁盘写满"。**实际是上传写法错误** —— 该接口 multipart 字段名是
|
||||
**`attachment`**,用 `file=` 就会得到这个空消息 500。实测对照:同一 1 KB 文件,
|
||||
`-F "attachment=@…"` → **201**,`--data-binary @…` + `?name=` → **201**,
|
||||
`-F "file=@…"` → **500**。两种正确写法已写进 `deploy/proxy/README.md` 第九节。
|
||||
**仍然真实存在的唯一限制是 IIS `maxAllowedContentLength`(默认 30,000,000 字节)**:
|
||||
927 KB / 14 MB / 16.4 MB 通过,30.5 MB / 38.2 MB / 40 MB 一律 HTTP 413。
|
||||
|
||||
|
||||
v0.7.2 修订(**修复产物"装得上、起不来"+ `/health` 报错版本号**):
|
||||
|
||||
@@ -128,18 +191,18 @@ v0.2.0 主要变更:
|
||||
2. 准备一个现有的 MySQL / MariaDB 实例,并建好库与账号(见下方[前置要求](#前置要求))
|
||||
本项目**不会创建数据库本身**
|
||||
|
||||
3. 取得发布包并校验(地址用你们自己的制品库替换; 下面是本仓库的发布地址)
|
||||
3. 取得发布包并校验(**本仓库的真实发布地址**, 域名 `gitea.kasugano.cn`、属主 `zijunchiang` 属部署必需信息, 不是隐私项)
|
||||
|
||||
```bash
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.2/opendance-web-v0.7.2.tar.gz
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.2/SHA256SUMS
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.4/opendance-web-v0.7.4.tar.gz
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.4/SHA256SUMS
|
||||
sha256sum -c SHA256SUMS # 期望输出 "OK"
|
||||
```
|
||||
|
||||
4. 解压并生成配置(交互式引导,逐项问答,口令不回显)
|
||||
|
||||
```bash
|
||||
tar -xzf opendance-web-v0.7.2.tar.gz && cd opendance-web-v0.7.2
|
||||
tar -xzf opendance-web-v0.7.4.tar.gz && cd opendance-web-v0.7.4
|
||||
bash deploy/init-env.sh
|
||||
```
|
||||
|
||||
@@ -149,7 +212,8 @@ v0.2.0 主要变更:
|
||||
bash deploy/install.sh
|
||||
```
|
||||
|
||||
6. 验证(把 `<服务器地址>` 换成实际地址)
|
||||
6. 验证(把 `<服务器地址>` 换成实际地址 —— 它是**你们自己服务器的地址**,
|
||||
不是被脱敏的信息;本项目的文档只对 Gitea 域名这类部署必需信息直接写明真实值)
|
||||
|
||||
```bash
|
||||
curl -fsS http://<服务器地址>:3000/health # 期望 {"status":"ok",...}
|
||||
@@ -209,8 +273,8 @@ FLUSH PRIVILEGES;
|
||||
### 5. 部署步骤
|
||||
|
||||
```bash
|
||||
tar -xzf opendance-web-v0.7.2.tar.gz
|
||||
cd opendance-web-v0.7.2
|
||||
tar -xzf opendance-web-v0.7.4.tar.gz
|
||||
cd opendance-web-v0.7.4
|
||||
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
|
||||
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
|
||||
```
|
||||
|
||||
+49
-8
@@ -1,5 +1,5 @@
|
||||
================================================================
|
||||
OpenDance Studio -- Web 管理服务端 v0.7.2 使用说明
|
||||
OpenDance Studio -- Web 管理服务端 v0.7.4 使用说明
|
||||
================================================================
|
||||
|
||||
本服务端是 OpenDance Studio 三端中的第一端, 负责管理歌曲、VRM 模型与
|
||||
@@ -8,6 +8,45 @@ OpenDance Studio -- Web 管理服务端 v0.7.2 使用说明
|
||||
重要: 本服务端 不部署、不管理 MySQL。数据库为部署方提供的现有实例,
|
||||
连接信息通过 .env 注入。
|
||||
|
||||
v0.7.4 修订 - 场景功能的真正根因: Scene 没有注册进 ENTITIES
|
||||
1. 生产实测 /health 的 scenes.error = 'No metadata for "Scene" was found.'
|
||||
-- src/entities/index.ts 的 ENTITIES 漏了 Scene。TypeORM 只按 ENTITIES 注册
|
||||
metadata, 于是启动完全正常、但第一次查 Scene 就抛错 => /api/scenes、
|
||||
/api/catalog/export、/api/catalog/version、/api/reviews?status=pending 全 500。
|
||||
此前"数据库缺 scenes 表"的判断也是错的: 表已由 AddScenes 迁移建好, 缺的是实体注册。
|
||||
2. 修复: ENTITIES 加入 Scene 并导出。
|
||||
3. 新增 test/entities-registry.spec.ts(4 项): 扫描 @Entity() 类与 ENTITIES 对账,
|
||||
漏一个即失败; 已反向验证(移除 Scene 立刻失败)。
|
||||
4. 部署后运行 node scripts/diagnose-scenes.mjs 可确认表与列的实际状态。
|
||||
|
||||
v0.7.3 修订 - 场景表不可用不再拖垮整站(生产事故修复)
|
||||
另修一个更直接的缺陷: ScenesService.create() 按内存存储写(判断 file.buffer),
|
||||
而上传中间件用的是 multer diskStorage(只有 path、没有 buffer) => 任何场景都传不上来,
|
||||
一律 400。现与 models/motions 同一套: requireUploadedFile + persistTempFile,
|
||||
需转换时 readFile(upload.path) 并在 finally 清理临时文件。
|
||||
|
||||
1. 现场: 数据库缺 scenes 表 -> /api/scenes、/api/catalog/export、
|
||||
/api/catalog/version、/api/reviews?status=pending 全部 500,
|
||||
而 /api/songs、/api/models 正常 => 客户端拉不到任何目录、管理端待审列表打不开。
|
||||
2. 契约与文档都写明"场景是可选资源", 因此改为降级: ScenesService 的
|
||||
queryScenes() 在数据库层面失败时返回空结果 + 只记一次日志;
|
||||
表恢复后自动复位; ReviewsService 对待审列表里的场景查询单独 catch;
|
||||
/health 新增 scenes:{available,error}(降级但绝不静默)。
|
||||
3. 新增 scripts/diagnose-scenes.mjs(默认只读): 打印运行期真实连的库、表清单、
|
||||
迁移记录、代码实际加载的迁移、scenes 实际列 vs 实体期望列 diff;
|
||||
--fix 仅在缺表时用迁移同一份 DDL 幂等补表。
|
||||
4. 修正 models.controller 注释: 模型上传不接受 isPrimary/status(契约: 上传一律
|
||||
pending, 默认模型由审核通过时 setPrimary=true 或 /primary 设定),
|
||||
冒烟脚本的错误预期已同步修正。
|
||||
5. 新增 6 项测试。
|
||||
6. 更正一次误判: Gitea 附件接口的 HTTP 500 空消息("message":"")曾被我判成宿主机
|
||||
磁盘写满, 实际是**上传写法错误** -- 该接口 multipart 字段名是 attachment,
|
||||
用 file= 就会得到空消息 500。正确写法两种(attachment=@... 或
|
||||
--data-binary + ?name=)已写进 deploy/proxy/README.md 第九节。
|
||||
仍然真实存在的唯一限制是 IIS maxAllowedContentLength(默认 30,000,000 字节):
|
||||
927KB/14MB/16.4MB 通过, 30.5MB/38.2MB/40MB 一律 413。
|
||||
|
||||
|
||||
v0.7.2 修订 - 修复产物"装得上、起不来" + /health 报错版本号
|
||||
1. CatalogModule 漏 import ScenesModule(致命): CatalogService 注入
|
||||
ScenesService, 但 CatalogModule 只列了 Songs/Motions/Models。Nest 的 DI
|
||||
@@ -176,16 +215,18 @@ v0.2.1 修订
|
||||
node -v # 期望 v22 或更高
|
||||
2. 准备一个现有的 MySQL / MariaDB 实例, 并建好库与账号(见下方"前置要求")
|
||||
本项目不会创建数据库本身
|
||||
3. 取得发布包并校验(地址用你们自己的制品库替换)
|
||||
curl -LO https://<制品库地址>/opendance-web-v0.7.2.tar.gz
|
||||
curl -LO https://<制品库地址>/SHA256SUMS
|
||||
3. 取得发布包并校验(以下是本仓库的真实发布地址; 域名 gitea.kasugano.cn
|
||||
与属主 zijunchiang 属部署必需信息, 不是隐私项)
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.2/opendance-web-v0.7.4.tar.gz
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.2/SHA256SUMS
|
||||
sha256sum -c SHA256SUMS # 期望输出 "OK"
|
||||
4. 解压并生成配置(交互式引导, 逐项问答, 口令不回显)
|
||||
tar -xzf opendance-web-v0.7.2.tar.gz && cd opendance-web-v0.7.2
|
||||
tar -xzf opendance-web-v0.7.4.tar.gz && cd opendance-web-v0.7.4
|
||||
bash deploy/init-env.sh
|
||||
5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查)
|
||||
bash deploy/install.sh
|
||||
6. 验证(把 <服务器地址> 换成实际地址)
|
||||
6. 验证(把 <服务器地址> 换成实际地址 -- 它是你们自己服务器的地址,
|
||||
不是被脱敏的信息)
|
||||
curl -fsS http://<服务器地址>:3000/health # 期望 {"status":"ok",...}
|
||||
curl -sI http://<服务器地址>:3001/ | head -1 # 期望 200, 管理端页面
|
||||
curl -sI http://<服务器地址>:3000/ | head -1 # 期望 404, API 端口不提供管理页
|
||||
@@ -239,8 +280,8 @@ v0.2.1 修订
|
||||
(本项目不会创建数据库本身, 也不需要 GRANT OPTION / FILE / SUPER 等管理权限)
|
||||
|
||||
部署步骤
|
||||
tar -xzf opendance-web-v0.7.2.tar.gz
|
||||
cd opendance-web-v0.7.2
|
||||
tar -xzf opendance-web-v0.7.4.tar.gz
|
||||
cd opendance-web-v0.7.4
|
||||
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
|
||||
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
|
||||
|
||||
|
||||
+61
-38
@@ -179,6 +179,7 @@ curl -i http://127.0.0.1:3000/health
|
||||
curl -i http://127.0.0.1:3001/health
|
||||
|
||||
# 2. 经反代可访问
|
||||
# <反代地址> 指你们自己的反代域名或 IP(由部署方决定),不是被脱敏的信息。
|
||||
curl -i http://<反代地址>/health
|
||||
|
||||
# 3. 管理端页面可打开(应返回 HTML)
|
||||
@@ -236,48 +237,66 @@ firewalld / ufw / iptables 的放行情况,并直接给出修复命令。
|
||||
> **Gitea**(源码仓库 + Release 附件)的站点。Windows 客户端的发布整包走 Gitea,
|
||||
> 因此**必须**单独放开 Gitea 站点的上限。
|
||||
|
||||
### 9.1 实测结论(两阶段)
|
||||
### 9.1 实测结论(含一次**误判更正**,请以本节为准)
|
||||
|
||||
**第一阶段(IIS 上限未放开时)**
|
||||
#### A. IIS 的 `maxAllowedContentLength` 是**真实且当前仍在生效**的上限
|
||||
|
||||
| 上传体积 | 结果 |
|
||||
|--|--|
|
||||
| 14 MB | 通过(HTTP 201) |
|
||||
| 927 KB / 14 MB / 16,372,427 B | 通过(HTTP 201) |
|
||||
| 30.5 MB | **失败 HTTP 413**(IIS「请求实体过大」页面) |
|
||||
| 38,245,602 B(Windows 整包) | **失败 HTTP 413** |
|
||||
| 40 MB | **失败 HTTP 413** |
|
||||
|
||||
**第二阶段(IIS 上限放开之后)** —— 放开的效果是**可验证**的:
|
||||
38,245,661 字节的请求体被**完整接收**(耗时 115 s),不再出现 413,
|
||||
说明 IIS 的 `maxAllowedContentLength` 已经够用。但随后 **Gitea 自己返回 HTTP 500**
|
||||
(响应体是 Gitea 的 API 错误信封 `{"message":"","url":".../api/swagger"}`,
|
||||
`message` 为空)——即瓶颈已从 IIS 转移到 Gitea 侧。
|
||||
→ IIS 的默认值 **`maxAllowedContentLength = 30,000,000` 字节(≈28.6 MiB)** 没有被放开,
|
||||
这就是 Windows 端整包(约 37 MB)至今还要拆分卷/无法上传的**唯一真实原因**。
|
||||
(`exe` 约 104 MB 未压缩 + `pck`;`deploy/proxy` 之外的另一台站点,见本节开头说明。)
|
||||
|
||||
同阶段的边界测量(同一个 Release 的资产上传接口):
|
||||
**放开办法**:见 9.2 的四条配置(IIS `requestLimits` + `uploadReadAheadSize`、
|
||||
ARR `maxRequestBytes`、Gitea `app.ini` 的 `[attachment] MAX_SIZE`、前置 nginx 的
|
||||
`client_max_body_size`),改完 `iisreset /noforce`。
|
||||
|
||||
| 上传体积 | 结果 |
|
||||
#### B. 【更正】Gitea 附件接口是好的 —— 之前"HTTP 500 / 磁盘写满"是**请求写法错误**
|
||||
|
||||
排查过程中我一度判定"Gitea 附件通道坏了、宿主机磁盘写满",**这个结论是错的**,特此更正,
|
||||
并把踩坑点记录下来:
|
||||
|
||||
Gitea `POST /repos/{owner}/{repo}/releases/{id}/assets` 的 multipart 字段名是
|
||||
**`attachment`**(见 `/api/v1/swagger`),不是 `file`。用 `-F "file=@…"` 提交时
|
||||
**Gitea 返回 HTTP 500,且响应体是空消息**:
|
||||
|
||||
```json
|
||||
{"message":"","url":"https://<gitea>/api/swagger"}
|
||||
```
|
||||
|
||||
这个空消息极像"服务器内部错误 / 磁盘写满",实际只是"没有找到名为 `attachment` 的表单字段"。
|
||||
实测对照(同一个 1 KB 文件、同一个 Release):
|
||||
|
||||
| 写法 | 结果 |
|
||||
|--|--|
|
||||
| 914 KB(web v0.7.1 tar.gz) | 通过 |
|
||||
| 16,372,427 B(android v0.2.0 zip) | 通过 |
|
||||
| 16,400,000 B | **HTTP 500** |
|
||||
| 16,500,000 B / 18 MB / 21 MB / 24 MB | **HTTP 500** |
|
||||
| 100,000 B(在第一轮失败之后) | **HTTP 500,且仅耗时 0.3 s** |
|
||||
| `-F "attachment=@probe.bin"` | **201** |
|
||||
| `--data-binary @probe.bin` + `-H "Content-Type: application/octet-stream"`(URL 带 `?name=`) | **201** |
|
||||
| `-F "file=@probe.bin"` | **500**(空消息) |
|
||||
|
||||
最后一行是关键:**在若干次大包失败之后,连 100 KB 都会在 0.3 秒内 500** ——
|
||||
这不符合"上传体积上限"的特征(上限判断会先读完请求体),
|
||||
更像**上传临时目录 / 数据盘已满**(Gitea 先把请求体写入 `data/tmp`,
|
||||
失败时可能在磁盘上留下残留临时文件)。
|
||||
**正确的两种上传写法**(都可直接照抄):
|
||||
|
||||
> **因此运维侧需要按顺序检查两件事**:
|
||||
> 1. **Gitea 数据盘的剩余空间**(尤其是 `data/tmp`,以及它是否落在小容量的
|
||||
> tmpfs 上),并清理失败上传留下的临时文件;
|
||||
> 2. Gitea `app.ini` 的 `[attachment] MAX_SIZE`(下文第 3 条)。
|
||||
>
|
||||
> 只放开 IIS 是不够的:本节第 1 步之后仍然 500,说明还有 Gitea 侧的限制或磁盘问题。
|
||||
```bash
|
||||
# 写法 1:multipart,字段名必须是 attachment
|
||||
curl -f -X POST \
|
||||
-H "Authorization: token <token>" \
|
||||
-F "attachment=@opendance-windows-vX.Y.Z.zip" \
|
||||
"https://gitea.kasugano.cn/api/v1/repos/zijunchiang/opendance-windows/releases/<id>/assets?name=opendance-windows-vX.Y.Z.zip"
|
||||
|
||||
→ 默认上限就是 **IIS 的 `maxAllowedContentLength = 30,000,000` 字节(≈28.6 MiB)**。
|
||||
Windows 客户端整包约 38 MB(`OpenDance.exe` ≈104 MB 未压缩 + `OpenDance.pck`),
|
||||
早先因此被拆成 `zip.part0/1/2`(每卷 13 MiB)才传上去 —— 用户拿到的是
|
||||
**无法直接解压的碎片**,必须自己拼接,属于交付缺陷。
|
||||
# 写法 2:裸 body + ?name=(大文件更省内存)
|
||||
curl -f -X POST \
|
||||
-H "Authorization: token <token>" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @SHA256SUMS \
|
||||
"https://gitea.kasugano.cn/api/v1/repos/zijunchiang/opendance-web/releases/<id>/assets?name=SHA256SUMS"
|
||||
```
|
||||
|
||||
> 另:`[attachment] MAX_SIZE` 仍然是需要放开的一项(默认可能只有 4~32 MB),
|
||||
> 但它超限时返回的是**带消息**的错误,而不是上面那种空消息 500。
|
||||
|
||||
### 9.2 正确做法:放开 Gitea 站点上限到 256 MB
|
||||
|
||||
@@ -332,20 +351,24 @@ Windows 客户端整包约 38 MB(`OpenDance.exe` ≈104 MB 未压缩 + `OpenDa
|
||||
|
||||
5. 改完执行 `iisreset /noforce`(或只重启站点),再按下节复验。
|
||||
|
||||
> **注意**:`requestLimits` 只是第一道闸。IIS 放开后若仍为 500(而不是 413),
|
||||
> 说明请求体已经到达 Gitea,此时要按 9.1 的两件事继续排查
|
||||
> (Gitea 数据盘空间 / `[attachment] MAX_SIZE`)。
|
||||
> **注意**:`requestLimits` 只是第一道闸。IIS 放开后若变成 **500**(而不是 413),
|
||||
> 说明请求体已经到达 Gitea —— 此时按 9.1 的结论排查 **Gitea 宿主机磁盘空间**
|
||||
> (1 KB 也失败就说明与体积无关),然后再看 `[attachment] MAX_SIZE`。
|
||||
|
||||
### 9.3 复验方法
|
||||
### 9.3 复验方法(用**正确**的字段名,否则会得到误导性的空消息 500)
|
||||
|
||||
```bash
|
||||
# 造一个 40 MB 文件上传到 Gitea 的附件接口,预期 HTTP 201(不再是 413)
|
||||
# 造一个 40 MB 文件,直接打 Gitea 的附件接口
|
||||
head -c 40000000 /dev/urandom > /tmp/probe.bin
|
||||
curl -i -X POST \
|
||||
-H "Authorization: token <token>" \
|
||||
-F "file=@/tmp/probe.bin" \
|
||||
"https://<Gitea 地址>/api/v1/repos/<owner>/<repo>/releases/<id>/assets?name=probe.bin"
|
||||
-F "attachment=@/tmp/probe.bin" \
|
||||
"https://gitea.kasugano.cn/api/v1/repos/zijunchiang/<repo>/releases/<id>/assets?name=probe.bin"
|
||||
```
|
||||
|
||||
在放开上限之前,**不要**用分卷绕过:Windows 端 `build/package-release.sh`
|
||||
会拒绝生成 `.partN`,超限时直接告警并要求先放宽本节的配置。
|
||||
预期:
|
||||
|
||||
* 放开 IIS 前 → **HTTP 413**(IIS 的"请求实体过大")
|
||||
* 放开 IIS 后 → **HTTP 201**(返回资产 JSON)
|
||||
* 若得到 **HTTP 500 + `{"message":"","url":"…/api/swagger"}`** → 十有八九是**字段名写错**
|
||||
(用了 `file` 而不是 `attachment`),不是服务器故障
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[Unit]
|
||||
Description=OpenDance Studio Web 管理服务端(NestJS)
|
||||
Documentation=https://<Gitea 地址>/<owner>/opendance-web
|
||||
Documentation=https://gitea.kasugano.cn/zijunchiang/opendance-web
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
project: opendance-web-server
|
||||
version: 0.7.2
|
||||
version: 0.7.4
|
||||
repository: opendance-web
|
||||
default_branch: main
|
||||
contracts_version:
|
||||
@@ -90,7 +90,7 @@ tasks:
|
||||
status: done
|
||||
|
||||
deliverable:
|
||||
artifact: opendance-web-v0.7.2.tar.gz
|
||||
artifact: opendance-web-v0.7.4.tar.gz
|
||||
packaging: Linux 业务服务(系统服务托管,双端口:软件 API + 管理端,不含 MySQL、不含反向代理、不使用 Docker)
|
||||
includes:
|
||||
- web-server/ # NestJS 源码 + 构建产物(含 BVH / VMD 内置解析、ZIP 读取与模型压缩包转换)
|
||||
|
||||
@@ -73,3 +73,107 @@
|
||||
③启动确实推进到数据库连接阶段。已挂进 `npm run verify` 与
|
||||
`scripts/package-release.sh`(构建后、打包前),并做过**反向验证**:
|
||||
把 `ScenesModule` 删除后该检查立刻失败。
|
||||
|
||||
## 缺陷修复记录二(v0.7.3):场景表不可用会拖垮**整站**
|
||||
|
||||
**现场**(真实部署,Web v0.7.2):
|
||||
|
||||
| 请求 | 结果 |
|
||||
|--|--|
|
||||
| `GET /api/songs` / `/api/motions` / `/api/models` | 200 |
|
||||
| `GET /api/scenes`、`/api/scenes/1`、`/api/scenes?status=draft` | **500** |
|
||||
| `GET /api/catalog/export`、`/api/catalog/version` | **500** |
|
||||
| `GET /api/reviews?status=pending`(待审列表) | **500** |
|
||||
| `GET /api/reviews`(仅审核流水) | 200 |
|
||||
|
||||
`/health` 显示 `version 0.7.2`、`database.connected true`、`migrationsPending 0` ——
|
||||
看起来一切正常,但**凡是要查 `scenes` 表的请求全部 500**。后果不是"场景不可用",
|
||||
而是**客户端拉不到任何目录**(Windows/Android 双双不可用)、管理端待审列表打不开。
|
||||
|
||||
**为什么 `migrationsPending` 是 0 却有表缺失**:`DataSource.showMigrations()` 只比较
|
||||
**代码里加载到的**迁移与数据库记录;如果部署的 `dist/migrations/` 里没有
|
||||
`AddScenes.js`(或 glob 没匹配到),这个迁移根本不在比较集合里,于是"没有待执行迁移"
|
||||
—— 表却不存在。这种"静默不一致"靠 `/health` 的 `pending` 是发现不了的。
|
||||
|
||||
**修复(降级,而不是继续让它 500)**:
|
||||
|
||||
契约与文档都写明**场景是可选资源**(没有可用场景时客户端回退内置空场景),
|
||||
所以场景表的问题**不允许**影响与场景无关的功能:
|
||||
|
||||
1. `ScenesService.queryScenes()`:数据库层面失败 → 返回空结果、**只记一次日志**
|
||||
(避免刷屏)、把原因写入 `diagnostics()`;下一次查询成功即自动复位;
|
||||
2. `ReviewsService.listByStatus()`:场景查询单独 `catch`,待审列表照常返回歌曲/动作/模型;
|
||||
3. `/health` 新增 `scenes: { available, error }` —— **降级但绝不静默**;
|
||||
4. 新增诊断脚本 `web-server/scripts/diagnose-scenes.mjs`(默认只读):
|
||||
打印运行期真实连的库、表清单、迁移记录、**代码实际加载的迁移**、
|
||||
`scenes` 实际列 vs 实体期望列的逐列 diff;`--fix` 仅在缺表时用迁移里
|
||||
同一份 DDL 幂等补表。
|
||||
|
||||
**测试**:`test/scenes-degradation.spec.ts`(6 项)锁定「表缺失 → 空场景而不是抛错」、
|
||||
「原因被记录并被 `/health` 暴露」、「表恢复后自动复位」、「`catalog/export` 与
|
||||
待审列表在降级下仍可用」。
|
||||
|
||||
## 缺陷修复记录三(v0.7.3):场景**根本传不上来**(diskStorage vs 内存存储)
|
||||
|
||||
**现场**:`POST /api/scenes` 一律返回 400「上传场景必须带 file 字段(.glb 为主…)」,
|
||||
而同一份 multipart 请求打 `/api/models` 返回 **201**(已经用 curl 对照验证)。
|
||||
|
||||
**根因**:上传中间件在 `storage.module.ts` 里配置为 multer 的 **`diskStorage`**
|
||||
(先落 `${STORAGE_DIR}/tmp`,避免 512 MiB 级 VRM 打爆内存),落盘后的文件对象
|
||||
**只有 `path`、没有 `buffer`**。而 `ScenesService.create()` 当初按内存存储写:
|
||||
|
||||
```ts
|
||||
if (!file || !file.buffer || file.buffer.length === 0) { …400… }
|
||||
```
|
||||
|
||||
`file.buffer` 恒为 undefined → **任何场景都传不上来**。
|
||||
|
||||
**修复**:与 models / motions 统一到同一套上传处理:
|
||||
|
||||
| 步骤 | 做法 |
|
||||
|--|--|
|
||||
| 校验 | `requireUploadedFile(file)` —— 校验确实落盘(`path` 存在),并修正 busboy 按 latin1 解出的中文/日文文件名 |
|
||||
| 直接导入(`.glb`/`.gltf`) | `storage.persistTempFile(AssetKind.Scene, upload.path, …)` —— 直接归档并**顺带清理临时文件** |
|
||||
| 需转换(`.zip`/`.pmx`/`.fbx`) | `readFile(upload.path)` 取 buffer 后转换 → `persistBuffer`,并在 `finally` 中 `unlink(upload.path)` |
|
||||
| 体积上限 | 用 `upload.size` 判断(`UPLOAD_MAX_BYTES`),超限 400 且不落盘 |
|
||||
|
||||
**测试**:`test/scene-upload.spec.ts`(5 项)—— diskStorage 形状的文件不再被判成
|
||||
"缺少 file 字段"、GLB 直传标注"未做重编码"且落 `pending`、中文文件名被还原、
|
||||
缺文件仍 400、超限 400 且不落盘。该用例在修复前必然失败。
|
||||
|
||||
## 缺陷修复记录四(v0.7.4):**真正的根因** —— `Scene` 没注册进 `ENTITIES`
|
||||
|
||||
部署 v0.7.3 后,`/health` 的 `scenes.error` 给出了决定性线索:
|
||||
|
||||
```
|
||||
No metadata for "Scene" was found.
|
||||
```
|
||||
|
||||
`src/entities/index.ts` 是:
|
||||
|
||||
```ts
|
||||
export const ENTITIES = [Asset, Song, Motion, CharacterModel, ReviewRecord]; // ← 少了 Scene
|
||||
```
|
||||
|
||||
TypeORM 的 DataSource **只按 `ENTITIES` 注册 metadata**,漏注册的后果非常隐蔽:
|
||||
|
||||
| 环节 | 表现 |
|
||||
|--|--|
|
||||
| 启动 | **完全正常** —— DI 图可解析、`/health` 正常、`migrationsPending 0` |
|
||||
| 迁移 | 正常(`AddScenes` 已执行,**表其实是建好的**) |
|
||||
| 第一次查该实体 | 抛 `No metadata for "Scene" was found.` |
|
||||
| 连带影响 | `/api/scenes`、`/api/catalog/export`、`/api/catalog/version`、`/api/reviews?status=pending` 全部 500 → **客户端拉不到任何目录** |
|
||||
|
||||
也就是说:**此前"数据库缺 scenes 表"的判断是错的**(v0.7.3 的降级与诊断脚本因此仍然值得保留 ——
|
||||
它们把"场景坏了"限制成"场景不可用",而不是整站不可用)。
|
||||
|
||||
**修复**:`ENTITIES` 加入 `Scene` 并导出。
|
||||
|
||||
**新增静态防线** `test/entities-registry.spec.ts`(4 项):
|
||||
|
||||
* 扫描 `src/entities/*.entity.ts` 里所有 `@Entity()` 类,**逐一与 `ENTITIES` 对账**
|
||||
(漏一个即失败;已反向验证:移除 `Scene` 后测试立刻失败);
|
||||
* 检查 `ENTITIES` 无重复、无多余项;
|
||||
* 核对 `AddScenes` 迁移文件确实存在(迁移文件缺失时 `showMigrations()` 仍会显示 0 个待执行)。
|
||||
|
||||
这类"漏注册"既过得了 `tsc`,也过得了启动冒烟(应用能启动),因此**只能靠静态核对兜住**。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "opendance-admin",
|
||||
"private": true,
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.4",
|
||||
"type": "module",
|
||||
"description": "OpenDance Studio 管理端(React + TypeScript + Vite)",
|
||||
"scripts": {
|
||||
|
||||
Generated
+10
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "opendance-web-server",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "opendance-web-server",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.12",
|
||||
@@ -29,6 +29,7 @@
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.13.10",
|
||||
"source-map-support": "^0.5.21",
|
||||
"sql.js": "^1.14.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
@@ -5669,6 +5670,13 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/sql.js": {
|
||||
"version": "1.14.2",
|
||||
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz",
|
||||
"integrity": "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opendance-web-server",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.4",
|
||||
"description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -47,6 +47,7 @@
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.13.10",
|
||||
"source-map-support": "^0.5.21",
|
||||
"sql.js": "^1.14.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 场景表(scenes)诊断 —— 只读,默认不改任何数据。
|
||||
*
|
||||
* 背景:`/api/scenes`、`/api/catalog/export`、`/api/catalog/version`、
|
||||
* `/api/reviews?status=pending` 全部返回 500,而 `/api/songs`、`/api/models`
|
||||
* 正常 —— 说明**凡是查 scenes 表就失败**。本脚本用来把原因定位到具体一格:
|
||||
*
|
||||
* 1. 运行期到底连的是哪个库(host / database / user);
|
||||
* 2. `scenes` 表是否存在;
|
||||
* 3. `typeorm_migrations` 里记录了哪些迁移(AddScenes 在不在);
|
||||
* 4. 当前代码**加载**了哪些迁移(如果 AddScenes 没被加载,
|
||||
* `showMigrations()` 会返回"没有待执行迁移",于是 `/health` 的
|
||||
* `migrationsPending` 显示 0,但表其实不存在 —— 这是最隐蔽的一种情况);
|
||||
* 5. 若表存在:实际列 vs 实体期望列,逐列 diff(缺列/多列/类型不符)。
|
||||
*
|
||||
* 用法(在安装目录的 web-server/ 下,用与服务端相同的环境变量):
|
||||
*
|
||||
* cd <APP_DIR>/web-server
|
||||
* set -a; . ../deploy/.env; set +a # 与服务端同一份 .env
|
||||
* node scripts/diagnose-scenes.mjs # 只读诊断
|
||||
* node scripts/diagnose-scenes.mjs --fix # 仅在缺表时建表(幂等,写到库)
|
||||
*
|
||||
* 注意:`--fix` 只做一件事 —— 用迁移里**完全相同**的 DDL
|
||||
* `CREATE TABLE IF NOT EXISTS scenes (...)` 补表;不删不改任何现有数据。
|
||||
*/
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const serverDir = resolve(here, '..');
|
||||
const require = createRequire(join(serverDir, 'package.json'));
|
||||
|
||||
const FIX = process.argv.includes('--fix');
|
||||
|
||||
function loadCompiled(relative) {
|
||||
const path = join(serverDir, 'dist', relative);
|
||||
try {
|
||||
return require(path);
|
||||
} catch (error) {
|
||||
console.error(`✗ 无法加载编译产物 ${path}:${error.message}`);
|
||||
console.error(' 请确认在安装目录的 web-server/ 下执行,且已 npm run build(发布包内含 dist/)。');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const dataSourceModule = loadCompiled('db/data-source.js');
|
||||
const buildOptions = dataSourceModule.buildDataSourceOptions ?? dataSourceModule.default?.options;
|
||||
if (typeof buildOptions !== 'function') {
|
||||
console.error('✗ dist/db/data-source.js 未导出 buildDataSourceOptions(),无法按运行期同样的规则解析配置');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const options = buildOptions(process.env);
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
console.log('\n== OpenDance 场景表诊断 ==');
|
||||
console.log(` 模式 : ${FIX ? '诊断 + 缺表时补表(--fix)' : '只读'}`);
|
||||
console.log(` 连接目标 : ${options.username}@${options.host}:${options.port}/${options.database}`);
|
||||
console.log(` DB_NAME 来源 : ${process.env.DB_NAME ? 'process.env(systemd EnvironmentFile / 手工 export)' : '.env 文件(dotenv)'}`);
|
||||
console.log(` 工作目录 : ${process.cwd()}`);
|
||||
console.log(` 编译产物目录 : ${join(serverDir, 'dist')}`);
|
||||
|
||||
// ------------------------------------------------------------------ 迁移加载情况
|
||||
const { DataSource } = require('typeorm');
|
||||
const dataSource = new DataSource(options);
|
||||
|
||||
let loadedMigrations = [];
|
||||
try {
|
||||
await dataSource.initialize();
|
||||
loadedMigrations = (dataSource.migrations ?? []).map((m) => ({
|
||||
name: m.name ?? m.constructor?.name ?? '(匿名)',
|
||||
timestamp: m.timestamp ?? null,
|
||||
}));
|
||||
console.log(`\n 已加载迁移 : ${loadedMigrations.length} 个`);
|
||||
for (const m of loadedMigrations) console.log(` [loaded] ${m.timestamp ?? '?'} ${m.name}`);
|
||||
if (!loadedMigrations.some((m) => m.name.includes('AddScenes'))) {
|
||||
console.log(' !! 未加载到 AddScenes 迁移 —— 说明 dist/migrations 里缺这个文件,');
|
||||
console.log(' 或 data-source 的 migrations glob 没匹配到它。');
|
||||
console.log(' (此时 /health 的 migrationsPending 会显示 0,但表并不存在)');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`\n✗ 连接数据库失败:${error.message}`);
|
||||
console.error(' 请确认已 `set -a; . ../deploy/.env; set +a`(与服务端同一份环境变量)。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 只读查询
|
||||
const conn = await mysql.createConnection({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
user: options.username,
|
||||
password: options.password,
|
||||
database: options.database,
|
||||
charset: 'utf8mb4',
|
||||
});
|
||||
|
||||
const [tableRows] = await conn.query('SHOW TABLES');
|
||||
const tables = tableRows.map((row) => Object.values(row)[0]);
|
||||
const hasScenes = tables.includes('scenes');
|
||||
const hasMigrationsTable = tables.includes('typeorm_migrations');
|
||||
|
||||
console.log(`\n 库内表数量 : ${tables.length}`);
|
||||
console.log(` scenes 表存在 : ${hasScenes ? '是' : '**否**'}`);
|
||||
console.log(` typeorm_migrations 存在 : ${hasMigrationsTable ? '是' : '否'}`);
|
||||
|
||||
let executed = [];
|
||||
if (hasMigrationsTable) {
|
||||
const [rows] = await conn.query('SELECT id, timestamp, name FROM typeorm_migrations ORDER BY timestamp');
|
||||
executed = rows;
|
||||
console.log(`\n 已记录迁移 : ${rows.length} 个`);
|
||||
for (const r of rows) console.log(` [done] ${r.timestamp} ${r.name}`);
|
||||
const scenesRecorded = rows.some((r) => String(r.name).includes('AddScenes'));
|
||||
console.log(` AddScenes 已记录: ${scenesRecorded ? '是' : '**否**'}`);
|
||||
}
|
||||
|
||||
const pending = loadedMigrations.filter((m) => !executed.some((e) => e.name === m.name));
|
||||
console.log(`\n 待执行迁移 : ${pending.length} 个`);
|
||||
for (const m of pending) console.log(` [pending] ${m.timestamp ?? '?'} ${m.name}`);
|
||||
|
||||
// ------------------------------------------------------------------ 列 diff
|
||||
let actualColumns = [];
|
||||
if (hasScenes) {
|
||||
const [cols] = await conn.query('SHOW COLUMNS FROM scenes');
|
||||
actualColumns = cols.map((c) => ({ name: c.Field, type: String(c.Type), nullable: c.Null === 'YES' }));
|
||||
console.log(`\n scenes 实际列 : ${actualColumns.length} 个`);
|
||||
|
||||
const sceneMetadata = dataSource.entityMetadatas.find((m) => m.tableName === 'scenes');
|
||||
if (sceneMetadata) {
|
||||
const expected = sceneMetadata.columns.map((c) => ({
|
||||
name: c.databaseName,
|
||||
type: c.type,
|
||||
nullable: c.isNullable,
|
||||
}));
|
||||
console.log(` 实体期望列 : ${expected.length} 个`);
|
||||
const missing = expected.filter((e) => !actualColumns.some((a) => a.name === e.name));
|
||||
const extra = actualColumns.filter((a) => !expected.some((e) => e.name === a.name));
|
||||
const typeMismatch = expected
|
||||
.map((e) => ({ e, a: actualColumns.find((a) => a.name === e.name) }))
|
||||
.filter(({ e, a }) => a && String(a.type).toLowerCase() !== String(e.type).toLowerCase());
|
||||
console.log(` ✗ 库中缺列 : ${missing.length ? missing.map((m) => `${m.name}(${m.type})`).join(', ') : '无'}`);
|
||||
console.log(` ✗ 库中多列 : ${extra.length ? extra.map((m) => `${m.name}(${m.type})`).join(', ') : '无'}`);
|
||||
console.log(` ! 类型不一致 : ${typeMismatch.length ? typeMismatch.map(({ e, a }) => `${e.name}: 实体=${e.type} 库=${a.type}`).join('; ') : '无'}`);
|
||||
} else {
|
||||
console.log(' ! 实体元数据里找不到 scenes(entities 未包含 Scene?)');
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 结论 / 修复
|
||||
console.log('\n== 结论 ==');
|
||||
if (!hasScenes) {
|
||||
const scenesLoaded = loadedMigrations.some((m) => m.name.includes('AddScenes'));
|
||||
const scenesRecorded = executed.some((r) => String(r.name).includes('AddScenes'));
|
||||
if (scenesLoaded && !scenesRecorded) {
|
||||
console.log(' scenes 表缺失,且 AddScenes 迁移被正确加载但**尚未执行**。');
|
||||
console.log(' → 直接执行迁移即可:');
|
||||
console.log(' cd <APP_DIR>/web-server && set -a && . ../deploy/.env && set +a && node scripts/migrate.mjs run');
|
||||
} else if (scenesLoaded && scenesRecorded) {
|
||||
console.log(' scenes 表缺失,但迁移已被记录为"已执行" —— 说明该迁移当初没有真正建表');
|
||||
console.log(' (或跑向了另一个库:注意 deploy/.env 与 web-server/.env 的 DB_NAME 是否一致)。');
|
||||
console.log(' → 用 `--fix` 幂等补表:node scripts/diagnose-scenes.mjs --fix');
|
||||
} else if (!scenesLoaded) {
|
||||
console.log(' scenes 表缺失,且**当前代码没有加载 AddScenes 迁移**。');
|
||||
console.log(' → 检查 dist/migrations/1789650000000-AddScenes.js 是否存在;');
|
||||
console.log(' 若不存在,说明部署的 dist 不是最新构建,请用最新发布包重跑 deploy/install.sh。');
|
||||
console.log(' → 若文件存在却未加载,请把上方「已加载迁移」清单发回。');
|
||||
}
|
||||
} else {
|
||||
const sceneMetadata = dataSource.entityMetadatas.find((m) => m.tableName === 'scenes');
|
||||
const expected = sceneMetadata ? sceneMetadata.columns.map((c) => c.databaseName) : [];
|
||||
const missing = expected.filter((e) => !actualColumns.some((a) => a.name === e));
|
||||
if (missing.length > 0) {
|
||||
console.log(` scenes 表存在但**缺少列**:${missing.join(', ')} —— 这就是 SELECT/INSERT 500 的原因。`);
|
||||
console.log(' → 需要补列(手工 ALTER 或按最新发布包重跑迁移),请把本输出发回以给出精确 SQL。');
|
||||
} else {
|
||||
console.log(' scenes 表存在且列与实体一致 —— 那 500 的原因不在这张表上,请把服务端日志发回:');
|
||||
console.log(' journalctl -u opendance-api -n 80 --no-pager');
|
||||
}
|
||||
}
|
||||
|
||||
if (FIX && !hasScenes) {
|
||||
console.log('\n== 执行 --fix:用迁移中的同一份 DDL 幂等补表 ==');
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS \`scenes\` (
|
||||
\`id\` bigint NOT NULL AUTO_INCREMENT,
|
||||
\`name\` varchar(200) NOT NULL,
|
||||
\`glb_asset_id\` bigint NULL,
|
||||
\`thumbnail_asset_id\` bigint NULL,
|
||||
\`config\` mediumtext NULL,
|
||||
\`stats\` mediumtext NULL,
|
||||
\`file_size\` bigint NULL,
|
||||
\`file_sha256\` varchar(64) NULL,
|
||||
\`conversion_note\` varchar(500) NULL,
|
||||
\`source_type\` varchar(32) NOT NULL,
|
||||
\`source_name\` varchar(200) NOT NULL,
|
||||
\`source_url\` varchar(500) NOT NULL,
|
||||
\`license\` varchar(100) NOT NULL,
|
||||
\`author\` varchar(200) NOT NULL,
|
||||
\`status\` varchar(16) NOT NULL DEFAULT 'pending',
|
||||
\`created_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
\`updated_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
\`deleted_at\` datetime NULL,
|
||||
INDEX \`idx_scenes_status\` (\`status\`),
|
||||
PRIMARY KEY (\`id\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
const [after] = await conn.query('SHOW TABLES LIKE \'scenes\'');
|
||||
console.log(after.length === 1 ? ' ✓ scenes 表已创建' : ' ✗ 建表后仍未出现,请把本输出发回');
|
||||
console.log(' → 若这次是「迁移已记录但表缺失」,为确保一致性,建议再执行一次:');
|
||||
console.log(' node scripts/migrate.mjs run');
|
||||
}
|
||||
|
||||
await conn.end();
|
||||
await dataSource.destroy();
|
||||
console.log('');
|
||||
@@ -2,9 +2,17 @@ import { Asset } from './asset.entity';
|
||||
import { CharacterModel } from './model.entity';
|
||||
import { Motion } from './motion.entity';
|
||||
import { ReviewRecord } from './review-record.entity';
|
||||
import { Scene } from './scene.entity';
|
||||
import { Song } from './song.entity';
|
||||
|
||||
export { Asset, CharacterModel, Motion, ReviewRecord, Song };
|
||||
export { Asset, CharacterModel, Motion, ReviewRecord, Scene, Song };
|
||||
|
||||
/** 显式列出实体:glob 在 ts-node 与编译产物下行为不一致,显式最稳 */
|
||||
export const ENTITIES = [Asset, Song, Motion, CharacterModel, ReviewRecord];
|
||||
/**
|
||||
* 显式列出实体:glob 在 ts-node 与编译产物下行为不一致,显式最稳。
|
||||
*
|
||||
* ⚠️ **漏加一个实体不会在启动时报错,只会在第一次查它时报**
|
||||
* `No metadata for "<Name>" was found.` —— 生产上就踩过:`Scene` 忘了加进来,
|
||||
* 于是所有场景相关请求 500,连 `catalog/export` 都跟着挂(客户端拉不到目录)。
|
||||
* 因此新增实体时**必须同时**加到这里,`test/entities-registry.spec.ts` 会强制核对。
|
||||
*/
|
||||
export const ENTITIES = [Asset, Song, Motion, CharacterModel, ReviewRecord, Scene];
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DataSource } from 'typeorm';
|
||||
import { resolveAppVersion } from '../config/app-version';
|
||||
import { AppConfig } from '../config/configuration';
|
||||
import { detectAssimp } from '../preprocess/assimp';
|
||||
import { ScenesService } from '../scenes/scenes.service';
|
||||
|
||||
export interface HealthReport {
|
||||
status: 'ok' | 'degraded';
|
||||
@@ -30,6 +31,15 @@ export interface HealthReport {
|
||||
version?: string;
|
||||
error?: string;
|
||||
};
|
||||
/**
|
||||
* 场景子系统可用性(T-WEB-015)。
|
||||
* 场景是**可选资源**:scenes 表不可用时服务降级为「无场景」而不是整站 500,
|
||||
* 但这里必须如实暴露,否则问题会被静默吞掉。
|
||||
*/
|
||||
scenes: {
|
||||
available: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('health')
|
||||
@@ -37,6 +47,7 @@ export class HealthController {
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly config: AppConfig,
|
||||
private readonly scenesService: ScenesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -82,6 +93,7 @@ export class HealthController {
|
||||
totalBytes: 0,
|
||||
},
|
||||
assimp,
|
||||
scenes: this.scenesService.diagnostics(),
|
||||
};
|
||||
|
||||
if (!database.connected) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScenesModule } from '../scenes/scenes.module';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
// HealthController 注入 ScenesService(用于暴露场景子系统可用性)。
|
||||
// Nest 的 DI 按模块解析:少了这个 imports,bootstrap 会直接
|
||||
// UnknownDependenciesException —— 见 scripts/check-bootstrap.mjs。
|
||||
imports: [ScenesModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
||||
@@ -29,7 +29,11 @@ export class ModelsController {
|
||||
return this.models.list(query);
|
||||
}
|
||||
|
||||
/** multipart/form-data:file + name + 来源五元组 + isPrimary/status */
|
||||
/**
|
||||
* multipart/form-data:file + name + 来源五元组。
|
||||
* **上传一律 pending,且不接受 isPrimary/status**(契约 rest-api POST /api/models):
|
||||
* 默认模型请在审核通过时带 `setPrimary=true`,或调用 `POST /api/models/{id}/primary`。
|
||||
*/
|
||||
@Post()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
create(
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
@@ -64,6 +65,8 @@ interface Reviewable {
|
||||
|
||||
@Injectable()
|
||||
export class ReviewsService {
|
||||
private readonly logger = new Logger(ReviewsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ReviewRecord) private readonly records: Repository<ReviewRecord>,
|
||||
@InjectRepository(Song) private readonly songs: Repository<Song>,
|
||||
@@ -90,7 +93,17 @@ export class ReviewsService {
|
||||
this.songs.find({ where: { status } }),
|
||||
this.motions.find({ where: { status } }),
|
||||
this.models.find({ where: { status } }),
|
||||
this.scenes.find({ where: { status } }),
|
||||
// 场景是**可选资源**:scenes 表缺失/结构不一致时不能让整个待审列表 500
|
||||
// (那会连带打挂管理端与客户端目录)。降级为空列表,原因由
|
||||
// ScenesService 记日志并在 /health 暴露。
|
||||
this.scenes
|
||||
.find({ where: { status } })
|
||||
.catch((error: unknown) => {
|
||||
this.logger.error(
|
||||
`待审列表中的场景查询失败,已跳过场景:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return [] as Scene[];
|
||||
}),
|
||||
]);
|
||||
|
||||
rows.push(...songs.map((s) => toPending(TargetType.Song, s, s.title)));
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, unlink } from 'node:fs/promises';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AssetKind, ModelFormat, PublishStatus } from '../common/enums';
|
||||
import { assertSourceTupleComplete, type SourceTuple } from '../common/source.dto';
|
||||
import { requireUploadedFile, type UploadedFileLike } from '../common/upload.util';
|
||||
import { AppConfig } from '../config/configuration';
|
||||
import { Scene } from '../entities/scene.entity';
|
||||
import { convertModelArchive, convertModelFile } from '../models/model-convert';
|
||||
import { inspectModelFile } from '../models/model-inspector';
|
||||
import { renderModelThumbnail } from '../models/model-thumbnail';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import type { StoredAsset } from '../storage/storage.service';
|
||||
import { normalizeSceneConfig, SCENE_CONFIG_DEFAULTS, type SceneConfig } from './scene-config';
|
||||
import { type SceneDto, toSceneDto } from './scene.serializer';
|
||||
|
||||
export interface UploadedSceneFile {
|
||||
buffer: Buffer;
|
||||
originalname: string;
|
||||
size: number;
|
||||
mimetype?: string;
|
||||
}
|
||||
/**
|
||||
* 上传文件的最小信息 —— 与 models/motions 共用 `UploadedFileLike`。
|
||||
* 注意上传中间件是 multer 的 **diskStorage**:文件只有 `path`,**没有 `buffer`**,
|
||||
* 所以不能按 `file.buffer` 判断(那会让场景永远传不上来)。
|
||||
*/
|
||||
export type UploadedSceneFile = UploadedFileLike;
|
||||
|
||||
const SUPPORTED_DIRECT = new Set(['.glb', '.gltf']);
|
||||
|
||||
@@ -35,6 +38,13 @@ function extOf(name: string): string {
|
||||
* * 场景携带**物理参数**(重力 / 风向动态 / 地面 / 碰撞体)与环境、出生点、相机;
|
||||
* * 服务端**只存参数、不跑物理**:仿真在客户端(Godot 内置物理)。
|
||||
*/
|
||||
/** 场景子系统的可用性(供 /health 与降级逻辑使用) */
|
||||
export interface ScenesAvailability {
|
||||
available: boolean;
|
||||
/** 不可用时的原因(数据库错误信息,已截断) */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ScenesService {
|
||||
private readonly logger = new Logger(ScenesService.name);
|
||||
@@ -45,10 +55,58 @@ export class ScenesService {
|
||||
private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 场景表是否可用。
|
||||
*
|
||||
* **场景是可选资源**(文档 §5.6:没有可用场景时客户端回退内置空场景),因此
|
||||
* 「场景表缺失 / 列与实体不一致 / 账号无权限」**不允许**把 `catalog/export`、
|
||||
* `catalog/version`、`/api/reviews?status=pending` 这些与场景无关的功能一起带崩 ——
|
||||
* 曾经就是这样:数据库里没有 scenes 表,于是客户端拉不到任何目录(整站不可用)。
|
||||
*
|
||||
* 这里记住最后一次探测结果:出错时**只记一次日志**(避免刷屏)并降级为「无场景」,
|
||||
* 同时把原因暴露给 `/health`,保证问题依然可见、只是不再致命。
|
||||
*/
|
||||
private availability: ScenesAvailability = { available: true, error: null };
|
||||
|
||||
/** 场景子系统当前是否可用(/health 展示) */
|
||||
diagnostics(): ScenesAvailability {
|
||||
return { ...this.availability };
|
||||
}
|
||||
|
||||
/** 执行一次场景查询;数据库层面失败时降级为空结果并记录原因 */
|
||||
private async queryScenes<T>(label: string, run: () => Promise<T>, fallback: T): Promise<T> {
|
||||
try {
|
||||
const result = await run();
|
||||
if (!this.availability.available) {
|
||||
this.logger.log(`场景子系统已恢复(${label})`);
|
||||
this.availability = { available: true, error: null };
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (this.availability.available || this.availability.error !== message) {
|
||||
this.logger.error(
|
||||
`场景查询失败(${label}),已降级为「无场景」:${message}。` +
|
||||
'常见原因:scenes 表不存在 / 列与实体不一致 / 数据库账号无权限。' +
|
||||
'排查:cd <APP_DIR>/web-server && set -a && . ../deploy/.env && set +a && ' +
|
||||
'node scripts/diagnose-scenes.mjs',
|
||||
);
|
||||
}
|
||||
this.availability = { available: false, error: message.slice(0, 300) };
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async create(body: Record<string, unknown>, file: UploadedSceneFile | undefined): Promise<SceneDto> {
|
||||
if (!file || !file.buffer || file.buffer.length === 0) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('上传场景必须带 file 字段(.glb 为主,也接受 .gltf/.zip/.fbx/.pmx)');
|
||||
}
|
||||
// 上传中间件用的是 **diskStorage**(见 storage.module.ts),文件只有 `path`、没有
|
||||
// `buffer`。这里必须与 models/motions 走同一套:requireUploadedFile 校验落盘 +
|
||||
// 修正 latin1 文件名编码,后续要么 persistTempFile 直接归档(顺带清理临时文件),
|
||||
// 要么读成 buffer 再转换。此前按 `file.buffer` 判断,导致**任何场景都传不上来**(400)。
|
||||
const upload = requireUploadedFile(file, 'file');
|
||||
const name = typeof body.name === 'string' && body.name.trim().length > 0 ? body.name.trim() : '';
|
||||
if (name.length === 0) throw new BadRequestException('场景名称 name 必填');
|
||||
const tuple = assertSourceTupleComplete({
|
||||
@@ -63,47 +121,63 @@ export class ScenesService {
|
||||
}
|
||||
|
||||
const parsed = this.parseConfigInput(body);
|
||||
const ext = extOf(file.originalname || '');
|
||||
let buffer: Buffer;
|
||||
const ext = extOf(upload.originalname || '');
|
||||
if (upload.size && upload.size > this.config.uploadMaxBytes) {
|
||||
throw new BadRequestException(
|
||||
`上传文件超过上限 ${Math.floor(this.config.uploadMaxBytes / 1024 / 1024)} MiB`,
|
||||
);
|
||||
}
|
||||
|
||||
let stored: StoredAsset;
|
||||
let conversionNote: string;
|
||||
let storedSize: number;
|
||||
|
||||
if (SUPPORTED_DIRECT.has(ext)) {
|
||||
// 场景主力格式:GLB 直接导入(不重编码)
|
||||
buffer = file.buffer;
|
||||
// 场景主力格式:GLB 直接导入(不重编码)——直接归档 multer 落盘的临时文件
|
||||
stored = await this.storage.persistTempFile(
|
||||
AssetKind.Scene,
|
||||
upload.path,
|
||||
upload.originalname || `scene-${Date.now()}.glb`,
|
||||
upload.mimetype || 'model/gltf-binary',
|
||||
);
|
||||
conversionNote = `直接导入(${ext.slice(1).toUpperCase()},未做重编码)`;
|
||||
storedSize = stored.asset.size;
|
||||
} else {
|
||||
// 其余格式统一转 GLB(场景不做 VRM)
|
||||
// 其余格式统一转 GLB(场景不做 VRM);转换需要 buffer,读完再转换,
|
||||
// 无论成败都要清掉临时文件(persistTempFile 那条路会自己清)
|
||||
try {
|
||||
const source = await readFile(upload.path);
|
||||
const converted =
|
||||
ext === '.zip'
|
||||
? await convertModelArchive(file.buffer, {
|
||||
? await convertModelArchive(source, {
|
||||
assimpBin: this.config.assimpBin,
|
||||
timeoutMs: this.config.modelConvertTimeoutMs,
|
||||
preferFormat: 'glb',
|
||||
})
|
||||
: await convertModelFile(file.buffer, file.originalname, {
|
||||
: await convertModelFile(source, upload.originalname, {
|
||||
assimpBin: this.config.assimpBin,
|
||||
timeoutMs: this.config.modelConvertTimeoutMs,
|
||||
preferFormat: 'glb',
|
||||
});
|
||||
buffer = converted.buffer;
|
||||
stored = await this.storage.persistBuffer(
|
||||
AssetKind.Scene,
|
||||
converted.buffer,
|
||||
upload.originalname || `scene-${Date.now()}.glb`,
|
||||
'model/gltf-binary',
|
||||
);
|
||||
storedSize = converted.buffer.length;
|
||||
conversionNote = converted.note.slice(0, 500);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(
|
||||
`场景转换失败(${ext || '未知扩展名'}):${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
await unlink(upload.path).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const stored = await this.storage.persistBuffer(
|
||||
AssetKind.Scene,
|
||||
buffer,
|
||||
file.originalname || `scene-${Date.now()}.glb`,
|
||||
'model/gltf-binary',
|
||||
);
|
||||
|
||||
const absolutePath = this.storage.absolutePath(stored.asset);
|
||||
const stats = await this.inspectSafely(absolutePath, buffer.length, name);
|
||||
const stats = await this.inspectSafely(absolutePath, storedSize, name);
|
||||
const thumbnailAssetId = await this.thumbnailSafely(absolutePath);
|
||||
|
||||
const scene = this.scenes.create({
|
||||
@@ -125,7 +199,7 @@ export class ScenesService {
|
||||
});
|
||||
const saved = await this.scenes.save(scene);
|
||||
this.logger.log(
|
||||
`场景已入库待审:#${saved.id} ${name}(${file.originalname},${conversionNote})` +
|
||||
`场景已入库待审:#${saved.id} ${name}(${upload.originalname},${conversionNote})` +
|
||||
` 重力=(${parsed.physics.gravity.x},${parsed.physics.gravity.y},${parsed.physics.gravity.z})`,
|
||||
);
|
||||
const [dto] = await this.toDtos([saved]);
|
||||
@@ -253,10 +327,16 @@ export class ScenesService {
|
||||
|
||||
/** 供 catalog 使用:已发布场景(含物理参数) */
|
||||
async published(): Promise<SceneDto[]> {
|
||||
const rows = await this.scenes.find({
|
||||
where: { status: PublishStatus.Published },
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
return this.toDtos(rows);
|
||||
return this.queryScenes(
|
||||
'published',
|
||||
async () => {
|
||||
const rows = await this.scenes.find({
|
||||
where: { status: PublishStatus.Published },
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
return this.toDtos(rows);
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ModelFormat, PublishStatus } from '../src/common/enums';
|
||||
import { CatalogService } from '../src/catalog/catalog.service';
|
||||
import { SCENE_CONFIG_DEFAULTS, normalizeSceneConfig } from '../src/scenes/scene-config';
|
||||
|
||||
/**
|
||||
* 目录导出(`GET /api/catalog/export` 与 `/api/catalog/version`)的**交付逻辑**。
|
||||
*
|
||||
* 这里守护两条硬性需求:
|
||||
*
|
||||
* 1. **① 非 VRM 模型不得进入客户端目录**:查询必须同时限定
|
||||
* `status = published` 与 `format = vrm` —— 即使库里有已审核通过的 PMX/GLB,
|
||||
* 也不能出现在 `models[]` 里(文档 §4.9:非 VRM 入库但标记不可用、
|
||||
* 不进入已发布目录);
|
||||
* 2. **④ 场景(含物理参数)必须随目录下发**,且场景变更要体现在
|
||||
* `catalog/version` 的指纹里,否则客户端不会重新拉取。
|
||||
*
|
||||
* 之所以要单独测这一层:`CatalogService` 注入 `ScenesService` 时曾在
|
||||
* `CatalogModule` 里漏了 `imports`,导致**整个服务起不来**(v0.7.0/v0.7.1)。
|
||||
* 服务能起来之后,这里再保证「起来之后的目录内容是对的」。
|
||||
*/
|
||||
|
||||
interface Stub {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
function repository(rows: unknown[]): Stub {
|
||||
return {
|
||||
find: vi.fn(async () => rows),
|
||||
};
|
||||
}
|
||||
|
||||
function build(overrides: {
|
||||
songs?: unknown[];
|
||||
motions?: unknown[];
|
||||
models?: unknown[];
|
||||
scenes?: unknown[];
|
||||
sceneDtos?: unknown[];
|
||||
defaultModelId?: number | null;
|
||||
} = {}) {
|
||||
const songsRepo = repository(overrides.songs ?? []);
|
||||
const motionsRepo = repository(overrides.motions ?? []);
|
||||
const modelsRepo = repository(overrides.models ?? []);
|
||||
const scenesService = {
|
||||
published: vi.fn(async () => overrides.sceneDtos ?? []),
|
||||
};
|
||||
const songsService = { toDtos: vi.fn(async (rows: unknown[]) => rows) };
|
||||
const motionsService = { toDtos: vi.fn(async (rows: unknown[]) => rows) };
|
||||
const modelsService = {
|
||||
toDtos: vi.fn(async (rows: unknown[]) => rows),
|
||||
resolveDefaultModelId: vi.fn(async () => overrides.defaultModelId ?? null),
|
||||
};
|
||||
const config = {
|
||||
contractVersions: {
|
||||
'rest-api': '0.5.0',
|
||||
'websocket-keypoints': '0.2.0',
|
||||
'data-models': '0.7.0',
|
||||
'skeleton-topology': '0.2.0',
|
||||
'model-formats': '0.5.0',
|
||||
database: '0.4.0',
|
||||
},
|
||||
};
|
||||
|
||||
const service = new CatalogService(
|
||||
songsRepo as never,
|
||||
motionsRepo as never,
|
||||
modelsRepo as never,
|
||||
songsService as never,
|
||||
motionsService as never,
|
||||
modelsService as never,
|
||||
scenesService as never,
|
||||
config as never,
|
||||
);
|
||||
return { service, songsRepo, motionsRepo, modelsRepo, scenesService, modelsService };
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个"已审核通过但不是 VRM"的模型,用来验证它不会进入目录。
|
||||
* 库里可能同时存在 GLB / glTF / FBX(未产出 VRM 的中间产物),
|
||||
* 契约 §4.9 要求它们**不得**出现在客户端目录里。
|
||||
*/
|
||||
const NON_VRM_MODEL = {
|
||||
id: 7,
|
||||
name: '未产出 VRM 的模型(GLB)',
|
||||
format: ModelFormat.Glb,
|
||||
status: PublishStatus.Published,
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
const VRM_MODEL = {
|
||||
id: 8,
|
||||
name: 'VRoid 模型',
|
||||
format: ModelFormat.Vrm,
|
||||
status: PublishStatus.Published,
|
||||
isPrimary: true,
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
const SCENE_DTO = {
|
||||
id: 3,
|
||||
name: '舞台',
|
||||
glbUrl: '/static/scene/2026/01/ab/abcd.glb',
|
||||
thumbnailUrl: '/static/scene/2026/01/ab/abcd.png',
|
||||
fileSize: 2048,
|
||||
fileSha256: 'abcdef0123456789',
|
||||
conversionNote: '直接导入(GLB,未做重编码)',
|
||||
config: normalizeSceneConfig({
|
||||
physics: {
|
||||
gravity: { x: 0, y: -3.5, z: 0 },
|
||||
wind: { base: { x: 2, y: 0, z: -1 }, gustAmplitude: 4, gustFrequency: 0.5, space: 'world' },
|
||||
ground: { friction: 0.8, restitution: 0.1, slopeLimit: 30 },
|
||||
colliders: 'auto',
|
||||
},
|
||||
}),
|
||||
stats: { meshes: 1 },
|
||||
sourceType: 'original',
|
||||
sourceName: '自建场景',
|
||||
sourceUrl: 'https://example.com/scene',
|
||||
license: 'MIT',
|
||||
author: 'OpenDance',
|
||||
status: PublishStatus.Published,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('① 目录只交付已产出 VRM 的模型', () => {
|
||||
it('查询同时限定 status=published 与 format=vrm', async () => {
|
||||
const { service, modelsRepo } = build({ models: [VRM_MODEL] });
|
||||
await service.exportBundle();
|
||||
|
||||
expect(modelsRepo.find).toHaveBeenCalledTimes(1);
|
||||
const options = modelsRepo.find.mock.calls[0][0] as {
|
||||
where: { status: string; format: string };
|
||||
order: { id: string };
|
||||
};
|
||||
expect(options.where.status).toBe(PublishStatus.Published);
|
||||
expect(options.where.format).toBe(ModelFormat.Vrm);
|
||||
expect(options.order).toEqual({ id: 'ASC' });
|
||||
});
|
||||
|
||||
it('/api/catalog/version 的模型查询同样只算 VRM(指纹不应受非 VRM 影响)', async () => {
|
||||
const { service, modelsRepo } = build({ models: [VRM_MODEL] });
|
||||
await service.version();
|
||||
|
||||
const options = modelsRepo.find.mock.calls[0][0] as { where: { format: string } };
|
||||
expect(options.where.format).toBe(ModelFormat.Vrm);
|
||||
});
|
||||
|
||||
it('库里的 GLB/glTF/FBX 都不会进入目录(只有 vrm 被允许)', async () => {
|
||||
const { service, modelsRepo } = build({ models: [VRM_MODEL] });
|
||||
await service.exportBundle();
|
||||
const options = modelsRepo.find.mock.calls[0][0] as { where: { format: string } };
|
||||
|
||||
// 这三种是"尚未产出 VRM"的中间格式,契约 §4.9 明确要求它们不进已发布目录
|
||||
for (const format of [ModelFormat.Glb, ModelFormat.Gltf, ModelFormat.Fbx]) {
|
||||
expect(format).not.toBe(options.where.format);
|
||||
}
|
||||
expect(NON_VRM_MODEL.format).not.toBe(options.where.format);
|
||||
});
|
||||
|
||||
it('defaultModelId 直接透传服务端解析结果', async () => {
|
||||
const { service } = build({ models: [VRM_MODEL], defaultModelId: 8 });
|
||||
const bundle = await service.exportBundle();
|
||||
expect(bundle.defaultModelId).toBe(8);
|
||||
});
|
||||
|
||||
it('没有任何可用模型时 defaultModelId 为 null(客户端自行回退)', async () => {
|
||||
const { service } = build({ defaultModelId: null });
|
||||
const bundle = await service.exportBundle();
|
||||
expect(bundle.defaultModelId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('④ 场景随目录下发(含物理参数)', () => {
|
||||
it('exportBundle().scenes 来自 ScenesService.published()', async () => {
|
||||
const { service, scenesService } = build({ sceneDtos: [SCENE_DTO] });
|
||||
const bundle = await service.exportBundle();
|
||||
|
||||
expect(scenesService.published).toHaveBeenCalledTimes(1);
|
||||
expect(bundle.scenes).toHaveLength(1);
|
||||
expect(bundle.scenes[0].id).toBe(3);
|
||||
expect(bundle.scenes[0].name).toBe('舞台');
|
||||
});
|
||||
|
||||
it('场景的物理参数完整透传(客户端据此落地重力/风/地面)', async () => {
|
||||
const { service } = build({ sceneDtos: [SCENE_DTO] });
|
||||
const bundle = await service.exportBundle();
|
||||
const config = bundle.scenes[0].config;
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.physics.gravity.y).toBeCloseTo(-3.5);
|
||||
expect(config?.physics.wind.base.x).toBeCloseTo(2);
|
||||
expect(config?.physics.wind.gustAmplitude).toBeCloseTo(4);
|
||||
expect(config?.physics.ground.friction).toBeCloseTo(0.8);
|
||||
expect(config?.physics.colliders).toBe('auto');
|
||||
});
|
||||
|
||||
it('scenes 字段在没有任何已发布场景时是空数组而不是 undefined', async () => {
|
||||
const { service } = build({ sceneDtos: [] });
|
||||
const bundle = await service.exportBundle();
|
||||
expect(Array.isArray(bundle.scenes)).toBe(true);
|
||||
expect(bundle.scenes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('契约版本随目录一并下发(客户端据此判断协议兼容)', async () => {
|
||||
const { service } = build({ sceneDtos: [SCENE_DTO] });
|
||||
const bundle = await service.exportBundle();
|
||||
expect(bundle.contractVersion['rest-api']).toBe('0.5.0');
|
||||
expect(bundle.contractVersion['data-models']).toBe('0.7.0');
|
||||
expect(bundle.contractVersion['websocket-keypoints']).toBe('0.2.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('④ 场景变更必须改变 catalog/version 指纹', () => {
|
||||
const scene = (overrides: Partial<{ id: number; name: string; updatedAt: string; glbUrl: string }>) => ({
|
||||
id: 3,
|
||||
name: '舞台',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
glbUrl: '/static/scene/a.glb',
|
||||
status: PublishStatus.Published,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('相同目录 → 指纹稳定;新增/改名/换 GLB → 指纹变化', async () => {
|
||||
const before = await build({ sceneDtos: [scene({})] }).service.version();
|
||||
const same = await build({ sceneDtos: [scene({})] }).service.version();
|
||||
expect(same.revision).toBe(before.revision);
|
||||
|
||||
const renamed = await build({ sceneDtos: [scene({ name: '舞台 2' })] }).service.version();
|
||||
expect(renamed.revision).not.toBe(before.revision);
|
||||
|
||||
const replaced = await build({
|
||||
sceneDtos: [scene({ glbUrl: '/static/scene/b.glb' })],
|
||||
}).service.version();
|
||||
expect(replaced.revision).not.toBe(before.revision);
|
||||
|
||||
const touched = await build({
|
||||
sceneDtos: [scene({ updatedAt: '2026-02-02T00:00:00.000Z' })],
|
||||
}).service.version();
|
||||
expect(touched.revision).not.toBe(before.revision);
|
||||
});
|
||||
|
||||
it('未发布任何场景时指纹仍可计算(64 位十六进制)', async () => {
|
||||
const { service } = build({ sceneDtos: [] });
|
||||
const version = await service.version();
|
||||
expect(version.revision).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('默认场景配置是冻结默认值(无场景时客户端按默认物理参数回退)', () => {
|
||||
expect(SCENE_CONFIG_DEFAULTS.physics.gravity.y).toBeCloseTo(-9.8);
|
||||
expect(SCENE_CONFIG_DEFAULTS.physics.wind.gustAmplitude).toBe(0);
|
||||
expect(SCENE_CONFIG_DEFAULTS.camera.mode).toBe('follow');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ENTITIES } from '../src/entities';
|
||||
|
||||
/**
|
||||
* **每个 `@Entity()` 装饰的类都必须出现在 `ENTITIES` 里。**
|
||||
*
|
||||
* 生产事故:`scene.entity.ts` 定义了 `@Entity('scenes')` 的 `Scene`,
|
||||
* 但 `src/entities/index.ts` 的 `ENTITIES` 漏了它。TypeORM 的 DataSource 只按
|
||||
* `ENTITIES` 注册 metadata,于是:
|
||||
*
|
||||
* * **启动完全正常**(DI 图能解析、`/health` 也正常),
|
||||
* * 但**第一次查该实体**就抛 `No metadata for "Scene" was found.` →
|
||||
* `/api/scenes`、`/api/catalog/export`、`/api/catalog/version`、
|
||||
* `/api/reviews?status=pending` 全部 500,客户端拉不到任何目录。
|
||||
*
|
||||
* 这类"漏注册"既不会被 `tsc` 发现,也不会被启动冒烟发现,只能靠**静态核对**
|
||||
* 兜住,所以这里直接扫 `src/entities/*.entity.ts` 的 `@Entity` 装饰器,
|
||||
* 与 `ENTITIES` 逐一对账。
|
||||
*/
|
||||
const entitiesDir = join(__dirname, '..', 'src', 'entities');
|
||||
|
||||
function decoratedEntityClasses(): string[] {
|
||||
const files = readdirSync(entitiesDir).filter((f) => f.endsWith('.entity.ts'));
|
||||
const classes: string[] = [];
|
||||
for (const file of files) {
|
||||
const source = readFileSync(join(entitiesDir, file), 'utf8');
|
||||
// 只认真正带 @Entity() 装饰器的类;@Entity() 与 class 之间可能还有别的装饰器
|
||||
// (例如 `@Index(...)`)或注释,所以先定位 @Entity(,再往后找第一个 export class。
|
||||
const entityIndex = source.indexOf('@Entity(');
|
||||
if (entityIndex < 0) continue;
|
||||
const after = source.slice(entityIndex);
|
||||
const match = after.match(/export class (\w+)/);
|
||||
if (match) classes.push(match[1]);
|
||||
}
|
||||
return classes.sort();
|
||||
}
|
||||
|
||||
describe('ENTITIES 注册表完整性', () => {
|
||||
const decorated = decoratedEntityClasses();
|
||||
const registered = ENTITIES.map((e) => e.name).sort();
|
||||
|
||||
it('扫到了实体文件(防止本测试因路径变化而空跑)', () => {
|
||||
expect(decorated.length).toBeGreaterThanOrEqual(6);
|
||||
expect(decorated).toContain('Scene');
|
||||
});
|
||||
|
||||
it('每个 @Entity() 类都在 ENTITIES 中(漏一个就会在运行期抛 No metadata 错误)', () => {
|
||||
const missing = decorated.filter((name) => !registered.includes(name));
|
||||
expect(missing, `以下实体有 @Entity() 但没有加入 ENTITIES:${missing.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('ENTITIES 里没有多余/重复项', () => {
|
||||
expect(new Set(registered).size).toBe(ENTITIES.length);
|
||||
const extra = registered.filter((name) => !decorated.includes(name));
|
||||
expect(extra, `ENTITIES 里有未定义 @Entity() 的类:${extra.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('runMigrations 会加载到 AddScenes(场景表迁移必须在迁移列表里)', () => {
|
||||
// 迁移文件与实体是两件事:漏了迁移文件时 showMigrations() 仍会显示 0 个待执行,
|
||||
// 因为 TypeORM 只比较"代码里加载到的"迁移。这里直接核对文件存在。
|
||||
const migrationsDir = join(__dirname, '..', 'src', 'migrations');
|
||||
const files = readdirSync(migrationsDir);
|
||||
expect(files.some((f) => f.includes('AddScenes'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import { AssetKind, PublishStatus } from '../src/common/enums';
|
||||
import { ScenesService } from '../src/scenes/scenes.service';
|
||||
|
||||
/**
|
||||
* 场景上传必须兼容 multer 的 **diskStorage**。
|
||||
*
|
||||
* 现场(真实部署):上传中间件在 `storage.module.ts` 里配置为 `diskStorage`,
|
||||
* 落盘后的文件对象**只有 `path`、没有 `buffer`**;而 `ScenesService.create()`
|
||||
* 当初按内存存储写,判断的是 `!file.buffer`,于是**任何场景都传不上来**,
|
||||
* 一律 400「上传场景必须带 file 字段」—— 冒烟脚本里 6 项场景断言全红。
|
||||
*
|
||||
* 正确的做法与 models/motions 一致:`requireUploadedFile(file)` 校验落盘、
|
||||
* 修正 latin1 文件名编码,直接导入走 `persistTempFile()`(顺带清理临时文件),
|
||||
* 需要转换时再 `readFile(upload.path)`。
|
||||
*/
|
||||
const dir = mkdtempSync(join(tmpdir(), 'opendance-scene-upload-'));
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
/** 一个最小的 GLB(JSON-only),仅用于让"直接导入"这条路径有文件可读 */
|
||||
function tinyGlb(): Buffer {
|
||||
const json = Buffer.from(
|
||||
JSON.stringify({
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ name: 'Floor_col' }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
|
||||
accessors: [{ count: 3 }],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
const padded = Buffer.concat([json, Buffer.alloc((4 - (json.length % 4)) % 4, 0x20)]);
|
||||
const header = Buffer.alloc(12);
|
||||
header.write('glTF', 0, 'ascii');
|
||||
header.writeUInt32LE(2, 4);
|
||||
header.writeUInt32LE(12 + 8 + padded.length, 8);
|
||||
const chunkHeader = Buffer.alloc(8);
|
||||
chunkHeader.writeUInt32LE(padded.length, 0);
|
||||
chunkHeader.writeUInt32LE(0x4e4f534a, 4);
|
||||
return Buffer.concat([header, chunkHeader, padded]);
|
||||
}
|
||||
|
||||
/** 造一个 **diskStorage 形状**的上传文件:有 path,没有 buffer */
|
||||
function diskUpload(name = 'stage.glb') {
|
||||
const path = join(dir, `${Date.now()}-${Math.random().toString(16).slice(2)}${name.slice(name.lastIndexOf('.'))}`);
|
||||
writeFileSync(path, tinyGlb());
|
||||
const size = tinyGlb().length;
|
||||
return { originalname: name, mimetype: 'model/gltf-binary', path, size };
|
||||
}
|
||||
|
||||
const SOURCE = {
|
||||
sourceType: 'original',
|
||||
sourceName: '测试来源',
|
||||
sourceUrl: 'https://example.com/s',
|
||||
license: 'MIT',
|
||||
author: 'QA',
|
||||
};
|
||||
|
||||
function buildService() {
|
||||
const persisted = {
|
||||
id: 42,
|
||||
kind: AssetKind.Scene,
|
||||
sha256: 'a'.repeat(64),
|
||||
storedPath: 'scene/2026/09/aa/x.glb',
|
||||
size: 216,
|
||||
};
|
||||
const persistTempFile = vi.fn(async () => ({ asset: persisted, url: '/static/scene/x.glb' }));
|
||||
const persistBuffer = vi.fn(async () => ({ asset: persisted, url: '/static/scene/x.glb' }));
|
||||
const storage = {
|
||||
persistTempFile,
|
||||
persistBuffer,
|
||||
absolutePath: vi.fn(() => join(dir, 'not-a-real-file.glb')),
|
||||
url: vi.fn(() => '/static/scene/x.glb'),
|
||||
findMany: vi.fn(async () => new Map()),
|
||||
};
|
||||
const scenesRepository = {
|
||||
create: vi.fn((value: Record<string, unknown>) => value),
|
||||
save: vi.fn(async (value: Record<string, unknown>) => ({
|
||||
...value,
|
||||
id: 7,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
})),
|
||||
find: vi.fn(async () => []),
|
||||
};
|
||||
const config = { uploadMaxBytes: 512 * 1024 * 1024, assimpBin: 'assimp', modelConvertTimeoutMs: 1000 };
|
||||
const service = new ScenesService(scenesRepository as never, storage as never, config as never);
|
||||
return { service, storage, persistTempFile, persistBuffer, scenesRepository };
|
||||
}
|
||||
|
||||
describe('场景上传兼容 multer diskStorage', () => {
|
||||
it('diskStorage 形状的文件(只有 path)不会再被判成"缺少 file 字段"', async () => {
|
||||
const { service, persistTempFile } = buildService();
|
||||
const dto = await service.create({ name: '冒烟场景', ...SOURCE }, diskUpload() as never);
|
||||
|
||||
expect(dto).toBeTruthy();
|
||||
// 直接导入走 persistTempFile(落盘的临时文件被归档)
|
||||
expect(persistTempFile).toHaveBeenCalledTimes(1);
|
||||
const call = persistTempFile.mock.calls[0] as unknown[];
|
||||
expect(call[0]).toBe(AssetKind.Scene);
|
||||
expect(String(call[1])).toMatch(/\.glb$/);
|
||||
expect(call[2]).toBe('stage.glb');
|
||||
});
|
||||
|
||||
it('GLB 直传仍标注"未做重编码",并落 pending 待审', async () => {
|
||||
const { service } = buildService();
|
||||
const dto = await service.create({ name: '冒烟场景', ...SOURCE }, diskUpload() as never);
|
||||
expect(String(dto.conversionNote)).toContain('直接导入');
|
||||
expect(dto.status).toBe(PublishStatus.Pending);
|
||||
});
|
||||
|
||||
it('文件名用 latin1 乱码传入时会被还原(与 models/motions 同一套)', async () => {
|
||||
const { service, persistTempFile } = buildService();
|
||||
// busboy 会把 UTF-8 文件名按 latin1 解出来
|
||||
const mangled = Buffer.from('舞台.glb', 'utf8').toString('latin1');
|
||||
await service.create({ name: '中文名场景', ...SOURCE }, diskUpload(mangled) as never);
|
||||
const call = persistTempFile.mock.calls[0] as unknown[];
|
||||
expect(call[2]).toBe('舞台.glb');
|
||||
});
|
||||
|
||||
it('缺少文件时仍然 400(错误语义没有被削弱)', async () => {
|
||||
const { service } = buildService();
|
||||
await expect(service.create({ name: '无文件', ...SOURCE }, undefined)).rejects.toThrow(/必须带 file 字段/);
|
||||
});
|
||||
|
||||
it('超过 UPLOAD_MAX_BYTES 时 400 且不落盘', async () => {
|
||||
const { service, persistTempFile } = buildService();
|
||||
const upload = diskUpload();
|
||||
await expect(
|
||||
service.create({ name: '超大场景', ...SOURCE }, { ...upload, size: 999 * 1024 * 1024 } as never),
|
||||
).rejects.toThrow(/超过上限/);
|
||||
expect(persistTempFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { PublishStatus } from '../src/common/enums';
|
||||
import { CatalogService } from '../src/catalog/catalog.service';
|
||||
import { ReviewsService } from '../src/reviews/reviews.service';
|
||||
import { ScenesService } from '../src/scenes/scenes.service';
|
||||
|
||||
/**
|
||||
* 场景子系统必须**可降级**(不是可选项的健壮性,而是生产事故的直接教训)。
|
||||
*
|
||||
* 现场:数据库里没有 `scenes` 表(迁移没落到该库 / dist 里缺迁移文件),于是
|
||||
* - `GET /api/scenes` → 500
|
||||
* - `GET /api/catalog/export` → 500 ← 客户端**拉不到任何目录**,整站不可用
|
||||
* - `GET /api/catalog/version` → 500
|
||||
* - `GET /api/reviews?status=pending` → 500 ← 管理端待审列表也挂了
|
||||
* 而 `/api/songs`、`/api/models` 都正常。
|
||||
*
|
||||
* 契约与文档都明确「**场景是可选资源**」(没有可用场景时客户端回退内置空场景),
|
||||
* 因此场景表不可用**不能**连带打挂与场景无关的功能。这里守护这条底线:
|
||||
* 降级为空场景 + 日志 + `/health` 如实暴露,而不是 500。
|
||||
*/
|
||||
describe('ScenesService 降级(scenes 表不可用时)', () => {
|
||||
const boom = () => {
|
||||
throw new Error("Table 'opendance.scenes' doesn't exist");
|
||||
};
|
||||
|
||||
function buildScenes(repository: unknown) {
|
||||
const service = new ScenesService(
|
||||
repository as never,
|
||||
{ findMany: vi.fn(async () => new Map()), url: () => '' } as never,
|
||||
{} as never,
|
||||
);
|
||||
return service;
|
||||
}
|
||||
|
||||
it('published() 在表缺失时返回空数组而不是抛错', async () => {
|
||||
const service = buildScenes({ find: vi.fn(async () => boom()) });
|
||||
await expect(service.published()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('published() 同时记录可用性与原因(供 /health 暴露)', async () => {
|
||||
const service = buildScenes({ find: vi.fn(async () => boom()) });
|
||||
expect(service.diagnostics()).toEqual({ available: true, error: null });
|
||||
|
||||
await service.published();
|
||||
const diag = service.diagnostics();
|
||||
expect(diag.available).toBe(false);
|
||||
expect(diag.error).toContain("doesn't exist");
|
||||
});
|
||||
|
||||
it('表恢复后自动回到可用状态', async () => {
|
||||
const find = vi.fn(async () => boom());
|
||||
const service = buildScenes({ find });
|
||||
|
||||
await service.published();
|
||||
expect(service.diagnostics().available).toBe(false);
|
||||
|
||||
// 表建好后:查询成功(空结果),可用性应自动恢复
|
||||
find.mockImplementation(async () => [] as never);
|
||||
await expect(service.published()).resolves.toEqual([]);
|
||||
expect(service.diagnostics()).toEqual({ available: true, error: null });
|
||||
});
|
||||
|
||||
it('错误信息截断到 300 字符(避免把整段 SQL 错误塞进 /health)', async () => {
|
||||
const service = buildScenes({
|
||||
find: vi.fn(async () => {
|
||||
throw new Error('x'.repeat(1000));
|
||||
}),
|
||||
});
|
||||
await service.published();
|
||||
expect(service.diagnostics().error?.length).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('降级不会波及与场景无关的功能', () => {
|
||||
it('catalog/export 仍然成功,scenes 为空数组(客户端可继续拉目录)', async () => {
|
||||
const scenesService = new ScenesService(
|
||||
{
|
||||
find: vi.fn(async () => {
|
||||
throw new Error("Table 'opendance.scenes' doesn't exist");
|
||||
}),
|
||||
} as never,
|
||||
{ findMany: vi.fn(async () => new Map()), url: () => '' } as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const repository = (rows: unknown[]) => ({ find: vi.fn(async () => rows) });
|
||||
const service = new CatalogService(
|
||||
repository([]) as never,
|
||||
repository([]) as never,
|
||||
repository([]) as never,
|
||||
{ toDtos: vi.fn(async (rows: unknown[]) => rows) } as never,
|
||||
{ toDtos: vi.fn(async (rows: unknown[]) => rows) } as never,
|
||||
{
|
||||
toDtos: vi.fn(async (rows: unknown[]) => rows),
|
||||
resolveDefaultModelId: vi.fn(async () => null),
|
||||
} as never,
|
||||
scenesService as never,
|
||||
{ contractVersions: { 'rest-api': '0.5.0' } } as never,
|
||||
);
|
||||
|
||||
const bundle = await service.exportBundle();
|
||||
expect(bundle.scenes).toEqual([]);
|
||||
expect(bundle.contractVersion['rest-api']).toBe('0.5.0');
|
||||
const version = await service.version();
|
||||
expect(version.revision).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(scenesService.diagnostics().available).toBe(false);
|
||||
});
|
||||
|
||||
it('待审列表跳过场景而不是整体 500', async () => {
|
||||
const service = new ReviewsService(
|
||||
{} as never,
|
||||
{ find: vi.fn(async () => []) } as never, // songs
|
||||
{ find: vi.fn(async () => []) } as never, // motions
|
||||
{ find: vi.fn(async () => []) } as never, // models
|
||||
{
|
||||
find: vi.fn(async () => {
|
||||
throw new Error("Table 'opendance.scenes' doesn't exist");
|
||||
}),
|
||||
} as never, // scenes
|
||||
{} as never, // modelsService
|
||||
);
|
||||
|
||||
const page = await service.listByStatus(PublishStatus.Pending, {
|
||||
skip: 0,
|
||||
pageSize: 20,
|
||||
page: 1,
|
||||
} as never);
|
||||
expect(page.total).toBe(0);
|
||||
expect(page.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { AssetKind, PublishStatus } from '../src/common/enums';
|
||||
import { ENTITIES, Scene } from '../src/entities';
|
||||
import { Asset } from '../src/entities/asset.entity';
|
||||
import { CatalogService } from '../src/catalog/catalog.service';
|
||||
import { ScenesService } from '../src/scenes/scenes.service';
|
||||
import { StorageService } from '../src/storage/storage.service';
|
||||
|
||||
/**
|
||||
* **带真实数据库**的端到端验证(库内跑的是 sql.js,不需要外部 MySQL)。
|
||||
*
|
||||
* 这一层补上单元测试覆盖不到的两件事:
|
||||
*
|
||||
* 1. **实体注册**:`Scene` 必须真的在 DataSource 的 metadata 里 —— 生产事故就是
|
||||
* `src/entities/index.ts` 的 `ENTITIES` 漏了 `Scene`,导致启动正常、但第一次查它
|
||||
* 就抛 `No metadata for "Scene" was found.`,进而把 `catalog/export` 一起打挂。
|
||||
* 2. **上传落盘链路**:走**真实的** `StorageService`(multer diskStorage 的落盘形状:
|
||||
* 文件对象只有 `path`、没有 `buffer`)→ 归档 → 落库 → `published()` → `catalog/export`。
|
||||
*
|
||||
* 断言用的是真实 SQL(TypeORM 建表 + 插入 + 查询),不是 mock。
|
||||
*/
|
||||
|
||||
/**
|
||||
* sqljs 兼容垫片:SQLite 本身对列类型名非常宽松(只有 5 种 affinity),但 TypeORM 的
|
||||
* sqlite 驱动会按 `normalizeType` 的结果去校验 `column.type`,于是 MySQL 专有写法
|
||||
* (`char(64)` / `mediumtext`)会被判"不支持"。这里把它们映射成等价的 sqlite 类型,
|
||||
* 让**真实的实体**(Asset / Scene)能在内存库里建表 —— 只影响本测试进程。
|
||||
*/
|
||||
function installSqljsTypeShim(): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { AbstractSqliteDriver } = require('typeorm/driver/sqlite-abstract/AbstractSqliteDriver');
|
||||
const aliases: Record<string, string> = {
|
||||
char: 'varchar',
|
||||
mediumtext: 'text',
|
||||
longtext: 'text',
|
||||
tinytext: 'text',
|
||||
enum: 'varchar',
|
||||
};
|
||||
const original = AbstractSqliteDriver.prototype.normalizeType;
|
||||
AbstractSqliteDriver.prototype.normalizeType = function patched(column: {
|
||||
type?: unknown;
|
||||
isPrimary?: boolean;
|
||||
isGenerated?: boolean;
|
||||
}) {
|
||||
const raw = typeof column?.type === 'string' ? column.type.toLowerCase() : '';
|
||||
// SQLite 的 AUTOINCREMENT 只允许出现在 `INTEGER PRIMARY KEY` 上,
|
||||
// 而本项目用的是 `bigint` 自增主键(MySQL 习惯)——测试里映射成 integer。
|
||||
if (raw === 'bigint' && column?.isPrimary && column?.isGenerated) return 'integer';
|
||||
if (aliases[raw]) return aliases[raw];
|
||||
return original.call(this, column);
|
||||
};
|
||||
}
|
||||
installSqljsTypeShim();
|
||||
|
||||
const storageRoot = mkdtempSync(join(tmpdir(), 'opendance-e2e-'));
|
||||
const uploadDir = mkdtempSync(join(tmpdir(), 'opendance-upload-'));
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(storageRoot, { recursive: true, force: true });
|
||||
rmSync(uploadDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function tinyGlb(): Buffer {
|
||||
const json = Buffer.from(
|
||||
JSON.stringify({
|
||||
asset: { version: '2.0', generator: 'opendance e2e' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ name: 'Floor_col' }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
|
||||
accessors: [{ count: 3 }],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
const padded = Buffer.concat([json, Buffer.alloc((4 - (json.length % 4)) % 4, 0x20)]);
|
||||
const header = Buffer.alloc(12);
|
||||
header.write('glTF', 0, 'ascii');
|
||||
header.writeUInt32LE(2, 4);
|
||||
header.writeUInt32LE(12 + 8 + padded.length, 8);
|
||||
const chunkHeader = Buffer.alloc(8);
|
||||
chunkHeader.writeUInt32LE(padded.length, 0);
|
||||
chunkHeader.writeUInt32LE(0x4e4f534a, 4);
|
||||
return Buffer.concat([header, chunkHeader, padded]);
|
||||
}
|
||||
|
||||
/** 模拟 multer 的 diskStorage 产物:只落盘、不带 buffer */
|
||||
function diskUpload(name: string) {
|
||||
const path = join(uploadDir, `${Date.now()}-${Math.random().toString(16).slice(2)}-${name}`);
|
||||
writeFileSync(path, tinyGlb());
|
||||
return { originalname: name, mimetype: 'model/gltf-binary', path, size: tinyGlb().length };
|
||||
}
|
||||
|
||||
const SOURCE = {
|
||||
sourceType: 'original',
|
||||
sourceName: 'e2e 来源',
|
||||
sourceUrl: 'https://example.com/e2e',
|
||||
license: 'MIT',
|
||||
author: 'e2e',
|
||||
};
|
||||
|
||||
const CONFIG = {
|
||||
physics: {
|
||||
gravity: { x: 0, y: -3.5, z: 0 },
|
||||
wind: { base: { x: 2, y: 0, z: -1 }, gustAmplitude: 4, gustFrequency: 0.5, space: 'world' },
|
||||
ground: { friction: 0.8, restitution: 0.1, slopeLimit: 30 },
|
||||
colliders: 'auto',
|
||||
},
|
||||
camera: { mode: 'orbit', distance: 6, height: 2 },
|
||||
};
|
||||
|
||||
let dataSource: DataSource;
|
||||
|
||||
beforeAll(async () => {
|
||||
dataSource = new DataSource({
|
||||
type: 'sqljs',
|
||||
entities: ENTITIES,
|
||||
synchronize: true,
|
||||
dropSchema: true,
|
||||
logging: false,
|
||||
});
|
||||
await dataSource.initialize();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await dataSource?.destroy();
|
||||
});
|
||||
|
||||
describe('实体注册(生产事故的根因)', () => {
|
||||
it('Scene 有 metadata 且 getRepository 不抛错', () => {
|
||||
expect(dataSource.hasMetadata(Scene)).toBe(true);
|
||||
expect(() => dataSource.getRepository(Scene)).not.toThrow();
|
||||
});
|
||||
|
||||
it('反向验证:ENTITIES 漏掉 Scene 时会复现生产上的报错原文', async () => {
|
||||
const broken = new DataSource({
|
||||
type: 'sqljs',
|
||||
// 故意漏掉 Scene —— 复现 src/entities/index.ts 曾经的写法
|
||||
entities: ENTITIES.filter((e) => e !== Scene),
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
});
|
||||
await broken.initialize();
|
||||
try {
|
||||
expect(broken.hasMetadata(Scene)).toBe(false);
|
||||
// 关键:**getRepository 不会抛**(TypeORM 0.3 是惰性的)——这正是"启动完全正常"
|
||||
// 的原因;错误要到**第一次真正查询**时才暴露,与生产现象完全一致。
|
||||
expect(() => broken.getRepository(Scene)).not.toThrow();
|
||||
await expect(broken.getRepository(Scene).find()).rejects.toThrow(
|
||||
/No metadata for "Scene" was found/,
|
||||
);
|
||||
} finally {
|
||||
await broken.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('场景上传 → 目录导出(真实数据库 + 真实落盘)', () => {
|
||||
it('diskStorage 形状的上传:归档、落库、published()、catalog/export 全部打通', async () => {
|
||||
const storage = new StorageService(
|
||||
{ storageDir: storageRoot, uploadMaxBytes: 512 * 1024 * 1024 } as never,
|
||||
dataSource.getRepository(Asset),
|
||||
);
|
||||
await storage.onModuleInit();
|
||||
|
||||
const scenes = new ScenesService(
|
||||
dataSource.getRepository(Scene),
|
||||
storage,
|
||||
{ assimpBin: 'assimp', modelConvertTimeoutMs: 1000, uploadMaxBytes: 512 * 1024 * 1024 } as never,
|
||||
);
|
||||
|
||||
// 1) 上传(直接导入 .glb)
|
||||
const upload = diskUpload('stage.glb');
|
||||
const scene = await scenes.create({ name: 'e2e 舞台', config: JSON.stringify(CONFIG), ...SOURCE }, upload as never);
|
||||
expect(scene.id).toBeGreaterThan(0);
|
||||
expect(scene.status).toBe(PublishStatus.Pending);
|
||||
expect(String(scene.conversionNote)).toContain('直接导入');
|
||||
expect(scene.config?.physics.gravity.y).toBeCloseTo(-3.5);
|
||||
expect(scene.config?.physics.wind.gustAmplitude).toBeCloseTo(4);
|
||||
expect(scene.config?.camera.mode).toBe('orbit');
|
||||
|
||||
// 2) 归档:GLB 已进 storage,临时目录里不再残留该文件
|
||||
const assetRows = await dataSource.getRepository(Asset).find({ where: { kind: AssetKind.Scene } });
|
||||
expect(assetRows.length).toBe(1);
|
||||
expect(assetRows[0].storedPath).toMatch(/\.glb$/);
|
||||
const tmpLeft = readdirSync(join(storageRoot, 'tmp'));
|
||||
expect(tmpLeft).not.toContain(upload.path.slice(upload.path.lastIndexOf('/') + 1));
|
||||
|
||||
// 3) 未发布时 published() 为空(不上架)
|
||||
expect(await scenes.published()).toEqual([]);
|
||||
|
||||
// 4) 发布后 published() 能取到,且物理参数完整
|
||||
await dataSource.getRepository(Scene).update({ id: scene.id }, { status: PublishStatus.Published });
|
||||
const published = await scenes.published();
|
||||
expect(published.length).toBe(1);
|
||||
expect(published[0].config?.physics.ground.friction).toBeCloseTo(0.8);
|
||||
expect(published[0].glbUrl).toMatch(/^\/static\//);
|
||||
|
||||
// 5) 目录导出带上场景,且指纹随之变化
|
||||
const repository = (rows: unknown[]) => ({ find: async () => rows });
|
||||
const catalog = new CatalogService(
|
||||
repository([]) as never,
|
||||
repository([]) as never,
|
||||
repository([]) as never,
|
||||
{ toDtos: async (rows: unknown[]) => rows } as never,
|
||||
{ toDtos: async (rows: unknown[]) => rows } as never,
|
||||
{ toDtos: async (rows: unknown[]) => rows, resolveDefaultModelId: async () => null } as never,
|
||||
scenes,
|
||||
{ contractVersions: { 'rest-api': '0.5.0' } } as never,
|
||||
);
|
||||
|
||||
const bundle = await catalog.exportBundle();
|
||||
expect(bundle.scenes.map((s) => s.name)).toContain('e2e 舞台');
|
||||
expect(bundle.scenes[0].config?.physics.gravity.y).toBeCloseTo(-3.5);
|
||||
|
||||
const version = await catalog.version();
|
||||
expect(version.revision).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
// 6) 场景子系统报告为可用(不再降级)
|
||||
expect(scenes.diagnostics()).toEqual({ available: true, error: null });
|
||||
|
||||
// 7) 删除场景:行被软删除,published() 不再返回
|
||||
await scenes.remove(scene.id);
|
||||
expect(await scenes.published()).toEqual([]);
|
||||
}, 30_000);
|
||||
|
||||
it('越界/非法参数在真实落库时也被收敛(不会把非法物理下发到客户端)', async () => {
|
||||
const storage = new StorageService(
|
||||
{ storageDir: storageRoot, uploadMaxBytes: 512 * 1024 * 1024 } as never,
|
||||
dataSource.getRepository(Asset),
|
||||
);
|
||||
await storage.onModuleInit();
|
||||
const scenes = new ScenesService(
|
||||
dataSource.getRepository(Scene),
|
||||
storage,
|
||||
{ assimpBin: 'assimp', modelConvertTimeoutMs: 1000, uploadMaxBytes: 512 * 1024 * 1024 } as never,
|
||||
);
|
||||
|
||||
const scene = await scenes.create(
|
||||
{
|
||||
name: 'e2e 越界场景',
|
||||
config: JSON.stringify({
|
||||
physics: { gravity: { y: -9999 }, ground: { friction: 5 }, colliders: '不存在的枚举' },
|
||||
camera: { mode: '无此模式' },
|
||||
}),
|
||||
...SOURCE,
|
||||
},
|
||||
diskUpload('clamped.glb') as never,
|
||||
);
|
||||
expect(scene.config?.physics.gravity.y).toBeCloseTo(-200);
|
||||
expect(scene.config?.physics.ground.friction).toBeCloseTo(1);
|
||||
expect(scene.config?.physics.colliders).toBe('auto');
|
||||
expect(scene.config?.camera.mode).toBe('follow');
|
||||
|
||||
// 落库后重新读出来仍是收敛后的值(transformer 往返正常)
|
||||
const reloaded = await scenes.findOne(scene.id);
|
||||
expect(reloaded.config?.physics.gravity.y).toBeCloseTo(-200);
|
||||
await scenes.remove(scene.id);
|
||||
}, 30_000);
|
||||
});
|
||||
+194
-11
@@ -194,12 +194,15 @@ async function main() {
|
||||
});
|
||||
|
||||
const modelRes = await request('POST', '/api/models', {
|
||||
form: fileForm({ name: '冒烟 VRM 模型', isPrimary: true, ...SOURCE }, 'avatar.vrm', vrmFile, 'model/gltf-binary'),
|
||||
form: fileForm({ name: '冒烟 VRM 模型', ...SOURCE }, 'avatar.vrm', vrmFile, 'model/gltf-binary'),
|
||||
});
|
||||
check('POST /api/models 返回 201', modelRes.status === 201, `实际 ${modelRes.status} ${JSON.stringify(modelRes.body)}`);
|
||||
const modelId = modelRes.body?.id;
|
||||
check('识别 VRM 版本为 1.0', modelRes.body?.vrmVersion === '1.0', String(modelRes.body?.vrmVersion));
|
||||
check('isPrimary 已置位', modelRes.body?.isPrimary === true);
|
||||
check('上传后状态为待审核', modelRes.body?.status === 'pending', String(modelRes.body?.status));
|
||||
// 契约 data-models §4 / rest-api POST /api/models:**上传一律 pending,且不设默认模型**;
|
||||
// 默认模型只能由「审核通过时带 setPrimary=true」或 POST /api/models/{id}/primary 设定。
|
||||
check('上传阶段不置默认模型(isPrimary=false)', modelRes.body?.isPrimary === false, String(modelRes.body?.isPrimary));
|
||||
|
||||
const modelMissingSource = await request('POST', '/api/models', {
|
||||
form: fileForm({ name: '缺少来源' }, 'x.vrm', vrmFile),
|
||||
@@ -215,8 +218,13 @@ async function main() {
|
||||
const submit = await request('POST', `/api/reviews/song/${songId}/submit`, { json: { reviewer: 'qa' } });
|
||||
check('提交审核 → pending', submit.status === 200 && submit.body?.status === 'pending', `实际 ${submit.status} ${JSON.stringify(submit.body)}`);
|
||||
|
||||
const pending = await request('GET', '/api/reviews?status=pending');
|
||||
check('待审列表包含该曲目', pending.body?.items?.some((i) => i.targetType === 'song' && i.targetId === songId));
|
||||
// 待审列表默认分页:显式放大 pageSize,避免库里已有大量待审项时新条目被挤出第一页
|
||||
const pending = await request('GET', '/api/reviews?status=pending&pageSize=200');
|
||||
check(
|
||||
'待审列表包含该曲目',
|
||||
pending.status === 200 && pending.body?.items?.some((i) => i.targetType === 'song' && i.targetId === songId),
|
||||
`HTTP ${pending.status},total=${pending.body?.total},条数=${pending.body?.items?.length},${JSON.stringify(pending.body).slice(0, 200)}`,
|
||||
);
|
||||
|
||||
const badReject = await request('POST', `/api/reviews/song/${songId}/reject`, { json: { reviewer: 'qa' } });
|
||||
check('驳回未填理由 → 400', badReject.status === 400, `实际 ${badReject.status}`);
|
||||
@@ -233,21 +241,191 @@ async function main() {
|
||||
await request('POST', `/api/reviews/motion/${motionId}/submit`, { json: { reviewer: 'qa' } });
|
||||
await request('POST', `/api/reviews/motion/${motionId}/approve`, { json: { reviewer: 'qa' } });
|
||||
await request('POST', `/api/reviews/model/${modelId}/submit`, { json: { reviewer: 'qa' } });
|
||||
await request('POST', `/api/reviews/model/${modelId}/approve`, { json: { reviewer: 'qa' } });
|
||||
// 默认模型按契约在此设定:审核通过时带 setPrimary=true(等价于 POST /api/models/:id/primary)
|
||||
const modelApprove = await request('POST', `/api/reviews/model/${modelId}/approve`, {
|
||||
json: { reviewer: 'qa', setPrimary: true },
|
||||
});
|
||||
check(
|
||||
'模型审核通过并设为默认(setPrimary=true)',
|
||||
modelApprove.status === 200 && modelApprove.body?.status === 'published',
|
||||
`HTTP ${modelApprove.status} ${JSON.stringify(modelApprove.body).slice(0, 160)}`,
|
||||
);
|
||||
const modelAfter = await request('GET', `/api/models/${modelId}`);
|
||||
check('审核通过后 isPrimary 已置位', modelAfter.body?.isPrimary === true, String(modelAfter.body?.isPrimary));
|
||||
|
||||
// ---------- 6. 目录导出 ----------
|
||||
console.log('\n[6] 客户端目录导出');
|
||||
const bundle = await request('GET', '/api/catalog/export');
|
||||
check('GET /api/catalog/export 返回 200', bundle.status === 200);
|
||||
check(
|
||||
'GET /api/catalog/export 返回 200',
|
||||
bundle.status === 200,
|
||||
`HTTP ${bundle.status} ${JSON.stringify(bundle.body).slice(0, 200)}`,
|
||||
);
|
||||
check('仅含已发布曲目', bundle.body?.songs?.some((s) => s.id === songId));
|
||||
check('defaultModelId 解析为 VRM 模型', bundle.body?.defaultModelId === modelId, String(bundle.body?.defaultModelId));
|
||||
check('bundle 含契约版本', Boolean(bundle.body?.contractVersion?.['data-models']));
|
||||
|
||||
const version = await request('GET', '/api/catalog/version');
|
||||
check('GET /api/catalog/version 返回目录指纹', /^[0-9a-f]{64}$/.test(version.body?.revision ?? ''), String(version.body?.revision).slice(0, 16));
|
||||
check(
|
||||
'GET /api/catalog/version 返回目录指纹',
|
||||
/^[0-9a-f]{64}$/.test(version.body?.revision ?? ''),
|
||||
`HTTP ${version.status} ${JSON.stringify(version.body).slice(0, 200)}`,
|
||||
);
|
||||
|
||||
// ---------- 7. 负向用例 ----------
|
||||
console.log('\n[7] 负向用例');
|
||||
// ---------- 7. T-WEB-015 场景管理(GLB + 物理参数) ----------
|
||||
console.log('\n[7] T-WEB-015 场景管理(GLB + 物理参数)');
|
||||
const sceneConfig = {
|
||||
physics: {
|
||||
gravity: { x: 0, y: -3.5, z: 0 },
|
||||
wind: {
|
||||
base: { x: 2, y: 0, z: -1 },
|
||||
gustAmplitude: 4,
|
||||
gustFrequency: 0.5,
|
||||
gustDirection: { x: 1, y: 0, z: 0 },
|
||||
space: 'world',
|
||||
},
|
||||
ground: { friction: 0.8, restitution: 0.1, slopeLimit: 30 },
|
||||
colliders: 'auto',
|
||||
},
|
||||
environment: { ambientColor: '#112233', ambientIntensity: 2, fogDensity: 0.2 },
|
||||
spawnPoints: [{ x: 1, y: 0, z: -2, yaw: 90 }],
|
||||
camera: { mode: 'orbit', distance: 6, height: 2 },
|
||||
};
|
||||
// 场景 GLB:一个 mesh(POSITION=3)+ 一个按客户端 `colliders: auto` 命名约定
|
||||
// 命名的节点(*_col)。分析/缩略图失败是非致命的,下面的断言只依赖入库结果。
|
||||
const sceneGlb = buildGlb({
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ name: 'Floor_col' }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
|
||||
accessors: [{ count: 3 }],
|
||||
});
|
||||
const sceneRes = await request('POST', '/api/scenes', {
|
||||
form: fileForm(
|
||||
{ name: `冒烟场景 ${Date.now()}`, config: JSON.stringify(sceneConfig), ...SOURCE },
|
||||
'stage.glb',
|
||||
sceneGlb,
|
||||
'model/gltf-binary',
|
||||
),
|
||||
});
|
||||
check('POST /api/scenes 返回 201', sceneRes.status === 201, `实际 ${sceneRes.status} ${JSON.stringify(sceneRes.body)}`);
|
||||
const sceneId = sceneRes.body?.id;
|
||||
check('上传场景一律待审核', sceneRes.body?.status === 'pending', String(sceneRes.body?.status));
|
||||
check(
|
||||
'GLB 直传不重编码(conversionNote 说明)',
|
||||
typeof sceneRes.body?.conversionNote === 'string' && sceneRes.body.conversionNote.includes('直接导入'),
|
||||
String(sceneRes.body?.conversionNote),
|
||||
);
|
||||
check(
|
||||
'glbUrl 已生成',
|
||||
typeof sceneRes.body?.glbUrl === 'string' && sceneRes.body.glbUrl.startsWith('/static/'),
|
||||
String(sceneRes.body?.glbUrl),
|
||||
);
|
||||
|
||||
// 物理参数入库即收敛:合法值原样保留
|
||||
const sceneCfg = sceneRes.body?.config;
|
||||
check('重力按配置入库', sceneCfg?.physics?.gravity?.y === -3.5, JSON.stringify(sceneCfg?.physics?.gravity));
|
||||
check('风基础向量入库', sceneCfg?.physics?.wind?.base?.x === 2, JSON.stringify(sceneCfg?.physics?.wind));
|
||||
check(
|
||||
'地面摩擦/坡度入库',
|
||||
sceneCfg?.physics?.ground?.friction === 0.8 && sceneCfg?.physics?.ground?.slopeLimit === 30,
|
||||
JSON.stringify(sceneCfg?.physics?.ground),
|
||||
);
|
||||
check(
|
||||
'出生点与相机模式入库',
|
||||
sceneCfg?.spawnPoints?.[0]?.yaw === 90 && sceneCfg?.camera?.mode === 'orbit',
|
||||
JSON.stringify({ spawn: sceneCfg?.spawnPoints, camera: sceneCfg?.camera }),
|
||||
);
|
||||
|
||||
// 物理参数入库即收敛:越界夹紧、非法枚举回退
|
||||
const clampRes = await request('POST', '/api/scenes', {
|
||||
form: fileForm(
|
||||
{
|
||||
name: `冒烟越界场景 ${Date.now()}`,
|
||||
config: JSON.stringify({
|
||||
physics: { gravity: { y: -9999 }, ground: { friction: 5 }, colliders: '不存在的枚举' },
|
||||
camera: { mode: '无此模式' },
|
||||
}),
|
||||
...SOURCE,
|
||||
},
|
||||
'stage-clamped.glb',
|
||||
sceneGlb,
|
||||
'model/gltf-binary',
|
||||
),
|
||||
});
|
||||
const clampId = clampRes.body?.id;
|
||||
check(
|
||||
'越界参数被夹紧(不会把非法物理下发到客户端)',
|
||||
clampRes.body?.config?.physics?.gravity?.y === -200 && clampRes.body?.config?.physics?.ground?.friction === 1,
|
||||
JSON.stringify(clampRes.body?.config?.physics),
|
||||
);
|
||||
check(
|
||||
'非法枚举回退默认值',
|
||||
clampRes.body?.config?.physics?.colliders === 'auto' && clampRes.body?.config?.camera?.mode === 'follow',
|
||||
JSON.stringify({ colliders: clampRes.body?.config?.physics?.colliders, camera: clampRes.body?.config?.camera?.mode }),
|
||||
);
|
||||
|
||||
const sceneMissingSource = await request('POST', '/api/scenes', {
|
||||
form: fileForm({ name: '缺来源' }, 'x.glb', sceneGlb, 'model/gltf-binary'),
|
||||
});
|
||||
check('场景缺少来源五元组 → 400', sceneMissingSource.status === 400, `实际 ${sceneMissingSource.status}`);
|
||||
|
||||
// 未审核前不得进入客户端目录
|
||||
const versionBeforeScene = await request('GET', '/api/catalog/version');
|
||||
const bundleBeforeScene = await request('GET', '/api/catalog/export');
|
||||
check(
|
||||
'待审场景不出现在客户端目录',
|
||||
!bundleBeforeScene.body?.scenes?.some((s) => s.id === sceneId),
|
||||
JSON.stringify(bundleBeforeScene.body?.scenes?.map((s) => s.id)),
|
||||
);
|
||||
|
||||
await request('POST', `/api/reviews/scene/${sceneId}/submit`, { json: { reviewer: 'qa' } });
|
||||
const sceneApprove = await request('POST', `/api/reviews/scene/${sceneId}/approve`, {
|
||||
json: { reviewer: 'qa', comment: '通过' },
|
||||
});
|
||||
check(
|
||||
'场景审核通过 → published',
|
||||
sceneApprove.status === 200 && sceneApprove.body?.status === 'published',
|
||||
`实际 ${sceneApprove.status} ${JSON.stringify(sceneApprove.body)}`,
|
||||
);
|
||||
|
||||
const bundleAfterScene = await request('GET', '/api/catalog/export');
|
||||
const publishedScene = bundleAfterScene.body?.scenes?.find((s) => s.id === sceneId);
|
||||
check(
|
||||
'发布后场景进入客户端目录 scenes[]',
|
||||
Boolean(publishedScene),
|
||||
JSON.stringify(bundleAfterScene.body?.scenes?.map((s) => s.id)),
|
||||
);
|
||||
check(
|
||||
'目录里的场景带完整物理参数',
|
||||
publishedScene?.config?.physics?.gravity?.y === -3.5 && publishedScene?.config?.physics?.wind?.gustAmplitude === 4,
|
||||
JSON.stringify(publishedScene?.config?.physics),
|
||||
);
|
||||
check(
|
||||
'目录里的场景带 GLB 与缩略图字段',
|
||||
typeof publishedScene?.glbUrl === 'string' && 'thumbnailUrl' in (publishedScene ?? {}),
|
||||
JSON.stringify({ glbUrl: publishedScene?.glbUrl, thumbnailUrl: publishedScene?.thumbnailUrl }),
|
||||
);
|
||||
|
||||
const versionAfterScene = await request('GET', '/api/catalog/version');
|
||||
check(
|
||||
'目录指纹纳入场景变更(客户端会因此重新拉取)',
|
||||
versionAfterScene.body?.revision !== versionBeforeScene.body?.revision,
|
||||
`${String(versionBeforeScene.body?.revision).slice(0, 12)} -> ${String(versionAfterScene.body?.revision).slice(0, 12)}`,
|
||||
);
|
||||
|
||||
const sceneDetail = await request('GET', `/api/scenes/${sceneId}`);
|
||||
check('GET /api/scenes/:id 返回详情', sceneDetail.status === 200 && sceneDetail.body?.id === sceneId);
|
||||
const sceneList = await request('GET', '/api/scenes?status=published');
|
||||
check(
|
||||
'场景列表可按状态过滤',
|
||||
sceneList.status === 200 && sceneList.body?.items?.some((s) => s.id === sceneId),
|
||||
`实际 ${sceneList.status}`,
|
||||
);
|
||||
|
||||
// ---------- 8. 负向用例 ----------
|
||||
console.log('\n[8] 负向用例');
|
||||
const notFound = await request('GET', '/api/songs/99999999');
|
||||
check('不存在的曲目 → 404', notFound.status === 404);
|
||||
const badEnum = await request('POST', '/api/songs', { json: { title: 'x', artist: 'y', ...SOURCE, sourceType: 'nope' } });
|
||||
@@ -255,14 +433,19 @@ async function main() {
|
||||
const badUrl = await request('POST', '/api/songs', { json: { title: 'x', artist: 'y', ...SOURCE, sourceUrl: 'not-a-url' } });
|
||||
check('非法 sourceUrl → 400', badUrl.status === 400, `实际 ${badUrl.status}`);
|
||||
|
||||
// ---------- 8. 清理 ----------
|
||||
console.log('\n[8] 清理');
|
||||
// ---------- 9. 清理 ----------
|
||||
console.log('\n[9] 清理');
|
||||
const del = await request('DELETE', `/api/songs/${songId}`);
|
||||
check('DELETE /api/songs/:id → 204', del.status === 204, `实际 ${del.status}`);
|
||||
const afterDelete = await request('GET', '/api/songs?q=冒烟');
|
||||
check('软删除后列表不再包含', !afterDelete.body?.items?.some((s) => s.id === songId));
|
||||
await request('DELETE', `/api/motions/${motionId}`);
|
||||
await request('DELETE', `/api/models/${modelId}`);
|
||||
if (sceneId) {
|
||||
const delScene = await request('DELETE', `/api/scenes/${sceneId}`);
|
||||
check('DELETE /api/scenes/:id → 204', delScene.status === 204, `实际 ${delScene.status}`);
|
||||
}
|
||||
if (clampId) await request('DELETE', `/api/scenes/${clampId}`);
|
||||
|
||||
console.log(`\n== 结果:${passed} 通过 / ${failed} 失败 ==`);
|
||||
if (failed > 0) {
|
||||
|
||||
Reference in New Issue
Block a user