fix(scenes): 场景表不可用不再拖垮整站(生产事故修复)+ 诊断脚本
现场(真实部署 v0.7.2):数据库缺 scenes 表,于是
/api/scenes、/api/scenes/:id、/api/catalog/export、/api/catalog/version、
/api/reviews?status=pending 全部 500,而 /api/songs、/api/motions、/api/models
正常。后果不是"场景不可用",而是客户端拉不到任何目录(Windows/Android 全废)、
管理端待审列表打不开。`/health` 却显示 database.connected=true、
migrationsPending=0 —— 因为 DataSource.showMigrations() 只比较**代码里加载到的**
迁移;部署的 dist 若缺 AddScenes.js,该迁移不在比较集合里,于是"没有待执行迁移"
但表不存在(最隐蔽的一种不一致)。
修复思路:契约与文档都写明「场景是可选资源」(没有可用场景时客户端回退内置空
场景),所以场景表的问题不允许影响与场景无关的功能 —— 改为降级:
- ScenesService 新增 queryScenes() 包装:数据库层面失败时返回空结果,**只记一次
日志**(避免刷屏),原因写入 diagnostics();下一次查询成功即自动复位;
- ReviewsService.listByStatus() 对场景查询单独 catch,待审列表照常返回其它资源;
- /health 新增 scenes: { available, error } —— 降级但绝不静默;
- HealthModule 增加 imports: [ScenesModule](HealthController 注入了 ScenesService,
Nest 的 DI 按模块解析,漏了会直接 UnknownDependenciesException;
scripts/check-bootstrap.mjs 已能拦住这一类)。
新增 web-server/scripts/diagnose-scenes.mjs(默认只读):打印运行期真实连的库、
表清单、typeorm_migrations 记录、**代码实际加载的迁移**、scenes 实际列 vs 实体
期望列的逐列 diff;`--fix` 仅在缺表时用迁移里同一份 DDL 幂等补表。
顺带修正 models.controller.ts 的误导性注释:模型上传**不接受** isPrimary/status
(契约:上传一律 pending,默认模型由审核通过时带 setPrimary=true 或
POST /api/models/:id/primary 设定)。test/smoke.mjs 此前正是按错误预期断言
"isPrimary 已置位",已改为:上传断言 pending + isPrimary=false → 审核通过时带
setPrimary=true → 再断言 isPrimary=true;同时给目录导出/指纹等检查补上失败细节
(HTTP 状态 + 响应体),并把待审列表查询的 pageSize 放大到 200(避免库里待审项多
时新条目被挤出第一页)。
文档:README.md/.txt 增加 v0.7.3 修订说明;tasks/015-scene-management.md 增
「缺陷修复记录二」;开发文档 §4.12 补充硬性要求(场景不可用必须降级 + /health 暴露
+ showMigrations 的陷阱说明)。
测试:新增 test/scenes-degradation.spec.ts(6 项);npm run verify 通过
(契约六项一致、35 条路由、21 文件 / 276 项测试、nest build、启动冒烟);
发布产物解压后 npm ci --omit=dev + node dist/main.js 实测打印「应用版本 v0.7.3」。
注意:Gitea 附件上传通道仍故障(1 KB 也瞬时 500),因此本版**尚未上传 Release**,
产物先在本地交付(sha256 见 SHA256SUMS)。
This commit is contained in:
@@ -10,7 +10,31 @@ 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.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`)。
|
||||
|
||||
v0.7.3 修订(**场景表不可用不再拖垮整站** —— 生产事故修复):
|
||||
|
||||
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`)锁定降级行为。
|
||||
|
||||
v0.7.2 修订(**修复产物"装得上、起不来"+ `/health` 报错版本号**):
|
||||
|
||||
@@ -131,15 +155,15 @@ v0.2.0 主要变更:
|
||||
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.3/opendance-web-v0.7.3.tar.gz
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.3/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.3.tar.gz && cd opendance-web-v0.7.3
|
||||
bash deploy/init-env.sh
|
||||
```
|
||||
|
||||
@@ -210,8 +234,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.3.tar.gz
|
||||
cd opendance-web-v0.7.3
|
||||
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
|
||||
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
|
||||
```
|
||||
|
||||
+21
-5
@@ -1,5 +1,5 @@
|
||||
================================================================
|
||||
OpenDance Studio -- Web 管理服务端 v0.7.2 使用说明
|
||||
OpenDance Studio -- Web 管理服务端 v0.7.3 使用说明
|
||||
================================================================
|
||||
|
||||
本服务端是 OpenDance Studio 三端中的第一端, 负责管理歌曲、VRM 模型与
|
||||
@@ -8,6 +8,22 @@ OpenDance Studio -- Web 管理服务端 v0.7.2 使用说明
|
||||
重要: 本服务端 不部署、不管理 MySQL。数据库为部署方提供的现有实例,
|
||||
连接信息通过 .env 注入。
|
||||
|
||||
v0.7.3 修订 - 场景表不可用不再拖垮整站(生产事故修复)
|
||||
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 项测试。
|
||||
|
||||
v0.7.2 修订 - 修复产物"装得上、起不来" + /health 报错版本号
|
||||
1. CatalogModule 漏 import ScenesModule(致命): CatalogService 注入
|
||||
ScenesService, 但 CatalogModule 只列了 Songs/Motions/Models。Nest 的 DI
|
||||
@@ -178,11 +194,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.2.tar.gz
|
||||
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/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.3.tar.gz && cd opendance-web-v0.7.3
|
||||
bash deploy/init-env.sh
|
||||
5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查)
|
||||
bash deploy/install.sh
|
||||
@@ -241,8 +257,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.3.tar.gz
|
||||
cd opendance-web-v0.7.3
|
||||
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
|
||||
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
|
||||
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
project: opendance-web-server
|
||||
version: 0.7.2
|
||||
version: 0.7.3
|
||||
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.3.tar.gz
|
||||
packaging: Linux 业务服务(系统服务托管,双端口:软件 API + 管理端,不含 MySQL、不含反向代理、不使用 Docker)
|
||||
includes:
|
||||
- web-server/ # NestJS 源码 + 构建产物(含 BVH / VMD 内置解析、ZIP 读取与模型压缩包转换)
|
||||
|
||||
@@ -73,3 +73,42 @@
|
||||
③启动确实推进到数据库连接阶段。已挂进 `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` 与
|
||||
待审列表在降级下仍可用」。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "opendance-admin",
|
||||
"private": true,
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"type": "module",
|
||||
"description": "OpenDance Studio 管理端(React + TypeScript + Vite)",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opendance-web-server",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -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('');
|
||||
@@ -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)));
|
||||
|
||||
@@ -35,6 +35,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,6 +52,49 @@ 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) {
|
||||
throw new BadRequestException('上传场景必须带 file 字段(.glb 为主,也接受 .gltf/.zip/.fbx/.pmx)');
|
||||
@@ -253,10 +303,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,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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,18 +241,36 @@ 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. T-WEB-015 场景管理(GLB + 物理参数) ----------
|
||||
console.log('\n[7] T-WEB-015 场景管理(GLB + 物理参数)');
|
||||
|
||||
Reference in New Issue
Block a user