2 Commits
Author SHA1 Message Date
zijunchiang d1c0dd6e7a test(scenes): 加带真实数据库的端到端用例(sql.js 内存库),把生产根因钉死
新增 test/scenes-e2e.spec.ts(4 项,全部真实 SQL,不是 mock):

1. Scene 在 DataSource 里有 metadata、getRepository 不抛错;
2. **反向验证生产根因**:当 ENTITIES 漏掉 Scene 时,`getRepository(Scene)` **不会抛**
   (TypeORM 0.3 是惰性的——这正是"启动完全正常"的原因),而**第一次查询**会 reject
   并抛出与生产完全一致的 `No metadata for "Scene" was found.`;
3. **上传→归档→落库→发布→目录导出 全链路**(真实 StorageService + 真实仓储):
   - 用 multer diskStorage 的文件形状(只有 path、没有 buffer)上传 .glb;
   - 断言 GLB 已归档进 storage、临时目录不再残留该文件(验证 diskStorage 修复);
   - 落 pending;`published()` 为空;发布后能取到完整物理参数(重力/风/地面/相机);
   - `CatalogService.exportBundle()` 的 `scenes[]` 含该场景且参数完整;
   - `catalog/version` 指纹为 64 位十六进制;`scenes.diagnostics()` 报告可用;
   - 删除后 `published()` 不再返回(软删除生效);
4. 越界/非法参数在**真实落库往返**(jsonText transformer)后仍是收敛值
   (重力 -200、摩擦 1、colliders auto、相机 follow)。

实现细节:库内用 `sql.js`(WASM,无原生依赖,devDependency)跑 TypeORM 的 sqljs 驱动;
SQLite 本身对列类型名宽松,但 TypeORM 的 sqlite 驱动会校验 normalizeType 的结果,
因此测试里加了一处**有注释的兼容垫片**:`char→varchar`、`mediumtext/longtext/tinytext→text`、
`enum→varchar`,以及 `bigint` 自增主键→`integer`(SQLite 的 AUTOINCREMENT 只允许
INTEGER PRIMARY KEY)。只影响本测试进程,不改动任何实体定义。

验证:npm run verify 通过(24 文件 / 289 项测试 + 契约/路由/版本一致 + 启动冒烟)。
2026-09-17 03:52:33 +08:00
zijunchiang 7a74f598be fix(entities): 场景功能的真正根因 —— Scene 未注册进 ENTITIES(v0.7.4)
部署 v0.7.3 后,/health 的 scenes.error 给出决定性线索:

  No metadata for "Scene" was found.

src/entities/index.ts 的 ENTITIES 一直漏了 Scene:
  export const ENTITIES = [Asset, Song, Motion, CharacterModel, ReviewRecord];

TypeORM 只按 ENTITIES 注册 metadata,漏注册的后果极其隐蔽:
- 启动完全正常(DI 可解析、/health 正常、migrationsPending 0);
- 迁移也正常(AddScenes 已执行,表其实是建好的);
- 但第一次查 Scene 就抛 No metadata,于是 /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 对账(漏一个即失败)、检查无重复/无多余、核对 AddScenes
迁移文件存在。已做反向验证:把 Scene 从 ENTITIES 移除后该测试立刻失败。
这类缺陷既过得了 tsc、也过得了启动冒烟(应用能启动),只能靠静态核对兜住。

文档:README.md/.txt 增加 v0.7.4 修订说明;tasks/015-scene-management.md 增
「缺陷修复记录四」;开发文档 §4.12 补「新增实体必须加入 ENTITIES」的硬性要求。

验证:npm run verify 通过(23 文件 / 285 项测试 + 契约/路由/版本一致 + 启动冒烟);
发布产物 opendance-web-v0.7.4.tar.gz
sha256 e680c23fc382b6c39498608ea0154f8f80e9e6b5c13518f0f88095644ce88fe1。
2026-09-17 03:48:52 +08:00
10 changed files with 438 additions and 20 deletions
+29 -6
View File
@@ -10,7 +10,30 @@ OpenDance Studio **阶段一**: Web 管理服务端(NestJS + TypeORM + 现有 My
## 状态
**阶段一已完成**: T-WEB-001 -> T-WEB-016 全部落地,当前版本 **v0.7.3**(契约 `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()` 当初按**内存存储**写,
@@ -171,15 +194,15 @@ v0.2.0 主要变更:
3. 取得发布包并校验(**本仓库的真实发布地址**, 域名 `gitea.kasugano.cn`、属主 `zijunchiang` 属部署必需信息, 不是隐私项)
```bash
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.3/opendance-web-v0.7.3.tar.gz
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.3/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.3.tar.gz && cd opendance-web-v0.7.3
tar -xzf opendance-web-v0.7.4.tar.gz && cd opendance-web-v0.7.4
bash deploy/init-env.sh
```
@@ -250,8 +273,8 @@ FLUSH PRIVILEGES;
### 5. 部署步骤
```bash
tar -xzf opendance-web-v0.7.3.tar.gz
cd opendance-web-v0.7.3
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
```
+16 -5
View File
@@ -1,5 +1,5 @@
================================================================
OpenDance Studio -- Web 管理服务端 v0.7.3 使用说明
OpenDance Studio -- Web 管理服务端 v0.7.4 使用说明
================================================================
本服务端是 OpenDance Studio 三端中的第一端, 负责管理歌曲、VRM 模型与
@@ -8,6 +8,17 @@ OpenDance Studio -- Web 管理服务端 v0.7.3 使用说明
重要: 本服务端 不部署、不管理 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) => 任何场景都传不上来,
@@ -206,11 +217,11 @@ v0.2.1 修订
本项目不会创建数据库本身
3. 取得发布包并校验(以下是本仓库的真实发布地址; 域名 gitea.kasugano.cn
与属主 zijunchiang 属部署必需信息, 不是隐私项)
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.2/opendance-web-v0.7.3.tar.gz
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.3.tar.gz && cd opendance-web-v0.7.3
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
@@ -269,8 +280,8 @@ v0.2.1 修订
(本项目不会创建数据库本身, 也不需要 GRANT OPTION / FILE / SUPER 等管理权限)
部署步骤
tar -xzf opendance-web-v0.7.3.tar.gz
cd opendance-web-v0.7.3
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
+2 -2
View File
@@ -1,5 +1,5 @@
project: opendance-web-server
version: 0.7.3
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.3.tar.gz
artifact: opendance-web-v0.7.4.tar.gz
packaging: Linux 业务服务(系统服务托管,双端口:软件 API + 管理端,不含 MySQL、不含反向代理、不使用 Docker)
includes:
- web-server/ # NestJS 源码 + 构建产物(含 BVH / VMD 内置解析、ZIP 读取与模型压缩包转换)
+37
View File
@@ -140,3 +140,40 @@ if (!file || !file.buffer || file.buffer.length === 0) { …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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "opendance-admin",
"private": true,
"version": "0.7.3",
"version": "0.7.4",
"type": "module",
"description": "OpenDance Studio 管理端(React + TypeScript + Vite)",
"scripts": {
+10 -2
View File
@@ -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",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opendance-web-server",
"version": "0.7.3",
"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",
+11 -3
View File
@@ -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];
+67
View File
@@ -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);
});
});
+263
View File
@@ -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);
});