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。
This commit is contained in:
2026-09-17 03:48:52 +08:00
parent 74332e20d8
commit 7a74f598be
8 changed files with 164 additions and 18 deletions
+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": {
+1 -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,
+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);
});
});