From f3b97fec5301766d2ab46101b81b34be7e547144 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Thu, 17 Sep 2026 03:24:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(scenes):=20=E5=9C=BA=E6=99=AF=E8=A1=A8?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E7=94=A8=E4=B8=8D=E5=86=8D=E6=8B=96=E5=9E=AE?= =?UTF-8?q?=E6=95=B4=E7=AB=99=EF=BC=88=E7=94=9F=E4=BA=A7=E4=BA=8B=E6=95=85?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=89+=20=E8=AF=8A=E6=96=AD=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现场(真实部署 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)。 --- README.md | 36 +++- README.txt | 26 ++- project.yaml | 4 +- tasks/015-scene-management.md | 39 ++++ web-server/admin/package.json | 2 +- web-server/package.json | 2 +- web-server/scripts/diagnose-scenes.mjs | 216 +++++++++++++++++++++ web-server/src/health/health.controller.ts | 12 ++ web-server/src/health/health.module.ts | 5 + web-server/src/models/models.controller.ts | 6 +- web-server/src/reviews/reviews.service.ts | 15 +- web-server/src/scenes/scenes.service.ts | 66 ++++++- web-server/test/scenes-degradation.spec.ts | 131 +++++++++++++ web-server/test/smoke.mjs | 40 +++- 14 files changed, 571 insertions(+), 29 deletions(-) create mode 100644 web-server/scripts/diagnose-scenes.mjs create mode 100644 web-server/test/scenes-degradation.spec.ts diff --git a/README.md b/README.md index f391ba7..a967afc 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/README.txt b/README.txt index 15454ea..5cbd2d9 100644 --- a/README.txt +++ b/README.txt @@ -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 diff --git a/project.yaml b/project.yaml index aadaa64..ab11a05 100644 --- a/project.yaml +++ b/project.yaml @@ -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 读取与模型压缩包转换) diff --git a/tasks/015-scene-management.md b/tasks/015-scene-management.md index 8460605..41f285e 100644 --- a/tasks/015-scene-management.md +++ b/tasks/015-scene-management.md @@ -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` 与 +待审列表在降级下仍可用」。 diff --git a/web-server/admin/package.json b/web-server/admin/package.json index 6c1d0f3..115bdaa 100644 --- a/web-server/admin/package.json +++ b/web-server/admin/package.json @@ -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": { diff --git a/web-server/package.json b/web-server/package.json index 3a61b91..7ad7710 100644 --- a/web-server/package.json +++ b/web-server/package.json @@ -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, diff --git a/web-server/scripts/diagnose-scenes.mjs b/web-server/scripts/diagnose-scenes.mjs new file mode 100644 index 0000000..2ae6fad --- /dev/null +++ b/web-server/scripts/diagnose-scenes.mjs @@ -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 /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 /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(''); diff --git a/web-server/src/health/health.controller.ts b/web-server/src/health/health.controller.ts index 6aa8dee..6ac8675 100644 --- a/web-server/src/health/health.controller.ts +++ b/web-server/src/health/health.controller.ts @@ -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) { diff --git a/web-server/src/health/health.module.ts b/web-server/src/health/health.module.ts index 7476abe..6777686 100644 --- a/web-server/src/health/health.module.ts +++ b/web-server/src/health/health.module.ts @@ -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 {} diff --git a/web-server/src/models/models.controller.ts b/web-server/src/models/models.controller.ts index 8c0187c..d6b5180 100644 --- a/web-server/src/models/models.controller.ts +++ b/web-server/src/models/models.controller.ts @@ -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( diff --git a/web-server/src/reviews/reviews.service.ts b/web-server/src/reviews/reviews.service.ts index 90af219..2d690b2 100644 --- a/web-server/src/reviews/reviews.service.ts +++ b/web-server/src/reviews/reviews.service.ts @@ -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, @InjectRepository(Song) private readonly songs: Repository, @@ -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))); diff --git a/web-server/src/scenes/scenes.service.ts b/web-server/src/scenes/scenes.service.ts index 21c277d..51a2e0c 100644 --- a/web-server/src/scenes/scenes.service.ts +++ b/web-server/src/scenes/scenes.service.ts @@ -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(label: string, run: () => Promise, fallback: T): Promise { + 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 /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, file: UploadedSceneFile | undefined): Promise { 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 { - 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); + }, + [], + ); } } diff --git a/web-server/test/scenes-degradation.spec.ts b/web-server/test/scenes-degradation.spec.ts new file mode 100644 index 0000000..7248baa --- /dev/null +++ b/web-server/test/scenes-degradation.spec.ts @@ -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([]); + }); +}); diff --git a/web-server/test/smoke.mjs b/web-server/test/smoke.mjs index c1cb3d4..f2f7f7a 100644 --- a/web-server/test/smoke.mjs +++ b/web-server/test/smoke.mjs @@ -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 + 物理参数)');