Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2433cd22a5 | ||
|
|
698d60bb65 | ||
|
|
3738bd91b9 | ||
|
|
50073b97cf | ||
|
|
f2e231c0b2 |
@@ -10,7 +10,29 @@ OpenDance Studio **阶段一**: Web 管理服务端(NestJS + TypeORM + 现有 My
|
||||
|
||||
## 状态
|
||||
|
||||
**阶段一已完成**: T-WEB-001 -> T-WEB-010 全部落地,当前版本 **v0.6.0**(契约 `rest-api 0.5.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.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`)。
|
||||
|
||||
v0.7.2 修订(**修复产物"装得上、起不来"+ `/health` 报错版本号**):
|
||||
|
||||
1. **`CatalogModule` 漏 import `ScenesModule`(致命)**:`CatalogService` 注入了 `ScenesService`(因为 `catalog/export` 要下发 `scenes[]`),但 `CatalogModule` 只在 `imports` 里列了 `SongsModule / MotionsModule / ModelsModule`。Nest 的 DI 是**按模块**解析的,于是 `node dist/main.js` 在 bootstrap 阶段直接抛 `UnknownDependenciesException` —— **v0.7.0 / v0.7.1 的产物根本起不来**(装得上、起不来)。而当时 `nest build` 通过、258 项单元测试全绿、打包脚本的产物完整性校验也通过,因为它们都**从不编译 AppModule**;
|
||||
2. **新增启动冒烟 `scripts/check-bootstrap.mjs`(并挂进 `verify` 与打包脚本)**:以正式部署方式(`ExecStart=node dist/main.js`,**不带任何 npm 环境变量**、不需要数据库)拉起真实产物,断言①日志里没有依赖解析类错误(模块图可解析)②日志里的「应用版本 vX.Y.Z」等于 `package.json` 的 version ③启动确实推进到了数据库连接阶段。已做**反向验证**:把 `ScenesModule` 删掉后该检查立刻失败;
|
||||
3. **`/health` 的版本号在原生部署下永远是 0.1.0**:原来用 `process.env.npm_package_version ?? '0.1.0'`,而该变量只在 npm 脚本下存在,正式部署是原生服务 → 为 `undefined` → 与管理端左下角展示的 vX.Y.Z 不一致,违反文档 §11.2 第 4 条「可核对」。新增 `src/config/app-version.ts` 作为**运行时唯一解析点**(`APP_VERSION` → 运行入口旁/工作目录的 `package.json`(带包名校验) → `npm_package_version` → 显式 `0.0.0`),`main.ts` 启动首行也打印版本号,便于与 `/health`、管理端核对;
|
||||
4. **新增 `scripts/check-version.mjs`**(并入 `contracts:check`):校验 `web-server/package.json` = `admin/package.json` = `project.yaml`,**禁止** `health.controller.ts` 再出现硬编码版本回退;管理端已构建时断言产物 JS 内含 `vX.Y.Z`;打包脚本在构建管理端后再次执行;
|
||||
5. 新增 12 项测试(`test/app-version.spec.ts`),其中一项显式模拟「无 npm 环境变量」的原生部署。
|
||||
|
||||
v0.7.1 修订(**契约补齐 + 文档补齐**,契约 `websocket-keypoints` 0.1.0 -> 0.2.0):
|
||||
|
||||
1. **补齐 `rest-api.yaml` 里漏写的场景结构**:`catalog/export` 自 v0.7.0 起就返回 `scenes[]`,`data-models.md` 7 也已冻结,但 `CatalogBundle` 漏了这个字段、`components` 里也没有 `Scene`/`SceneConfig`。本次只补规格、**不改线格式**,故六个契约版本号不变(rest-api 仍 0.5.0);
|
||||
2. **契约 `websocket-keypoints` -> 0.2.0**(T-WEB-016,与 Android `T-AND-011` 的"两端一起改"配套):新增 1.1 **保活**(接收端必须声明 `silentTimeoutMs`/`heartbeatIntervalMs`,且**心跳 < 静默超时的一半**)、3.1 `hello.deviceId`、3.2/3.3 welcome/config 携带保活字段、3.6 应用层 `ping`/`pong`、5 **设备去重**、7 **关闭码与重连策略**、7.1 退避与 ±20% 抖动、8 **连接状态机**(两端同名)。子协议名与二进制帧 `version` 均不变,**向后兼容**;
|
||||
3. **`deploy/proxy/README.md` 新增第九节**:托管 Gitea 的 IIS 站点附件上传上限(与一~五节的 Web 服务反代是**两个不同站点**)。实测 30.5 MB / 40 MB 返回 HTTP 413,根因是 IIS 默认 `maxAllowedContentLength = 30,000,000` 字节;给出放宽到 256 MB 的 IIS/ARR/Gitea `app.ini`/nginx 四处配置与复验命令 —— 这正是 Windows 端 v0.1.0 被拆成 `zip.part0/1/2` 的根因;
|
||||
4. **补齐 `tasks/011` ~ `tasks/016`**:此前 T-WEB-011~015 已在代码中落地但缺少任务说明书与 `project.yaml` 条目,本次一并补上(T-WEB-011 版本号展示 / 012 VRM 优先 / 013 动作标准与骨骼重绑定 / 014 动作预览 / 015 场景管理 / 016 契约 0.2.0);
|
||||
5. 规模 **18 文件 / 246 项**测试,`contracts:check` 通过、`check-routes` 35 条路由一致。
|
||||
|
||||
v0.7.0 修订(**管理端场景页**,补齐场景端到端可用):
|
||||
|
||||
1. **管理端「场景管理」页**(`/scenes`):列表(40px 缩略图 + **物理摘要**「重力 -9.8 | 风 5.0m/s + 阵风 2.5m/s@0.5Hz」+ 状态)、上传表单(**GLB 文件 + 名称 + 来源五元组 + 物理参数**:重力、风基础向量、阵风幅度/频率、地面摩擦/坡度、碰撞体、环境光)、详情面板(复用 3D 预览 + **参数可视化编辑并保存**);
|
||||
2. 新增 `physicsSummary()` 纯函数与场景页样式(两栏自适应);路由 `/scenes` + 侧栏「场景管理」入口;
|
||||
3. 新增 2 项测试;规模 **19 文件 / 245 项**。
|
||||
|
||||
v0.6.0 修订(**场景管理**,契约 rest-api -> 0.5.0、data-models -> 0.7.0、database -> 0.4.0):
|
||||
|
||||
@@ -109,15 +131,15 @@ v0.2.0 主要变更:
|
||||
3. 取得发布包并校验(地址用你们自己的制品库替换; 下面是本仓库的发布地址)
|
||||
|
||||
```bash
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.6.0/opendance-web-v0.6.0.tar.gz
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.6.0/SHA256SUMS
|
||||
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
|
||||
sha256sum -c SHA256SUMS # 期望输出 "OK"
|
||||
```
|
||||
|
||||
4. 解压并生成配置(交互式引导,逐项问答,口令不回显)
|
||||
|
||||
```bash
|
||||
tar -xzf opendance-web-v0.6.0.tar.gz && cd opendance-web-v0.6.0
|
||||
tar -xzf opendance-web-v0.7.2.tar.gz && cd opendance-web-v0.7.2
|
||||
bash deploy/init-env.sh
|
||||
```
|
||||
|
||||
@@ -187,8 +209,8 @@ FLUSH PRIVILEGES;
|
||||
### 5. 部署步骤
|
||||
|
||||
```bash
|
||||
tar -xzf opendance-web-v0.6.0.tar.gz
|
||||
cd opendance-web-v0.6.0
|
||||
tar -xzf opendance-web-v0.7.2.tar.gz
|
||||
cd opendance-web-v0.7.2
|
||||
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
|
||||
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
|
||||
```
|
||||
@@ -361,12 +383,12 @@ npm run dev # http://localhost:5173(/api 与 /health 代理到 3000)
|
||||
cd web-server
|
||||
npm run contracts:check # 契约版本一致性
|
||||
npm run typecheck # TypeScript 严格检查
|
||||
npm run test # 243 项单元测试 / 19 个文件(VMD 31、模型压缩包 27、
|
||||
npm run test # 245 项单元测试 / 19 个文件(VMD 31、模型压缩包 27、
|
||||
# 模型检查器 25、缩略图 8、骨骼过滤 8、
|
||||
# 预处理 25、init-env 16、admin-port 16、
|
||||
# access-log 9、VRM 检查器 6、.env 预检 12、
|
||||
# Assimp 定位与安装 13、stats 容量与编码 12、
|
||||
# 缩略图贴图采样 6、MMD 材质对齐 9、模型可用性 5、MMD 骨名重绑定 4、动作预览 8、场景管理 6)
|
||||
# 缩略图贴图采样 6、MMD 材质对齐 9、模型可用性 5、MMD 骨名重绑定 4、动作预览 8、场景管理 6、场景页 2)
|
||||
npm run build # 生产构建
|
||||
npm run start:prod & # 启动
|
||||
npm run test:smoke # 57 项端到端冒烟(需要数据库可连通)
|
||||
|
||||
+48
-7
@@ -1,5 +1,5 @@
|
||||
================================================================
|
||||
OpenDance Studio -- Web 管理服务端 v0.6.0 使用说明
|
||||
OpenDance Studio -- Web 管理服务端 v0.7.2 使用说明
|
||||
================================================================
|
||||
|
||||
本服务端是 OpenDance Studio 三端中的第一端, 负责管理歌曲、VRM 模型与
|
||||
@@ -8,6 +8,47 @@ OpenDance Studio -- Web 管理服务端 v0.6.0 使用说明
|
||||
重要: 本服务端 不部署、不管理 MySQL。数据库为部署方提供的现有实例,
|
||||
连接信息通过 .env 注入。
|
||||
|
||||
v0.7.2 修订 - 修复产物"装得上、起不来" + /health 报错版本号
|
||||
1. CatalogModule 漏 import ScenesModule(致命): CatalogService 注入
|
||||
ScenesService, 但 CatalogModule 只列了 Songs/Motions/Models。Nest 的 DI
|
||||
按模块解析 -> node dist/main.js 在 bootstrap 直接抛
|
||||
UnknownDependenciesException -> v0.7.0/v0.7.1 的产物根本起不来。
|
||||
当时 build 通过、258 项测试全绿、打包完整性校验通过, 因为它们都不编译 AppModule。
|
||||
2. 新增启动冒烟 scripts/check-bootstrap.mjs(挂进 verify 与打包脚本):
|
||||
以正式部署方式(node dist/main.js, 无 npm 环境变量, 不需要数据库)拉起真实产物,
|
||||
断言无依赖解析错误 + 应用版本等于 package.json + 已推进到数据库连接阶段。
|
||||
已反向验证: 删掉 ScenesModule 后该检查立刻失败。
|
||||
3. /health 版本号在原生部署下永远是 0.1.0: 原来用 npm_package_version,
|
||||
该变量只在 npm 脚本下存在。新增 src/config/app-version.ts 作为运行时唯一
|
||||
解析点(APP_VERSION -> package.json(带包名校验) -> npm_package_version -> 0.0.0),
|
||||
main.ts 启动首行也打印版本号。
|
||||
4. 新增 scripts/check-version.mjs(并入 contracts:check): 校验三处版本一致并
|
||||
禁止 health.controller.ts 硬编码版本回退; 打包脚本构建管理端后再跑一次。
|
||||
5. 新增 12 项测试(含"模拟无 npm 环境变量的原生部署")。
|
||||
|
||||
v0.7.1 修订 - 契约补齐 + 文档补齐(契约 websocket-keypoints 0.1.0 -> 0.2.0)
|
||||
1. 补齐 rest-api.yaml 里漏写的场景结构(CatalogBundle.scenes[] + Scene +
|
||||
SceneConfig): catalog/export 自 v0.7.0 起就返回 scenes[],规格漏写会导致
|
||||
两端对字段边界的理解不一致。只补规格、不改线格式,六个契约版本号不变。
|
||||
2. 契约 websocket-keypoints -> 0.2.0(与 Android T-AND-011 配套):
|
||||
1.1 保活(接收端声明 silentTimeoutMs/heartbeatIntervalMs,心跳必须小于
|
||||
静默超时的一半)、3.1 hello.deviceId、3.6 应用层 ping/pong、
|
||||
5 设备去重、7 关闭码与重连策略、7.1 退避与 ±20% 抖动、8 连接状态机。
|
||||
子协议名与帧 version 均不变,向后兼容。
|
||||
3. deploy/proxy/README.md 新增第九节: 托管 Gitea 的 IIS 站点附件上传上限
|
||||
(与一~五节的 Web 服务反代是两个不同站点)。实测 30.5/40 MB 返回 HTTP 413,
|
||||
根因是 IIS 默认 maxAllowedContentLength = 30,000,000 字节;给出放宽到
|
||||
256 MB 的配置与复验命令。这是 Windows 端 v0.1.0 被拆成 zip.part0/1/2 的根因。
|
||||
4. 补齐 tasks/011 ~ 016 与 project.yaml 条目(此前已落地但缺任务说明书)。
|
||||
5. 规模 18 文件 / 246 项测试; contracts:check 通过, 35 条路由一致。
|
||||
|
||||
v0.7.0 修订 - 管理端场景页(补齐场景端到端可用)
|
||||
1. 新增「场景管理」页(/scenes): 列表(40px 缩略图 + 物理摘要 + 状态)、
|
||||
上传(GLB 文件 + 名称 + 来源五元组 + 物理参数: 重力/风/阵风/地面/碰撞体/环境光)、
|
||||
详情(复用 3D 预览 + 参数可视化编辑并保存)。
|
||||
2. 新增 physicsSummary() 纯函数、场景页样式与侧栏入口。
|
||||
3. 新增 2 项测试; 规模 19 文件 / 245 项。
|
||||
|
||||
v0.6.0 修订 - 场景管理(契约 rest-api 0.5.0 / data-models 0.7.0 / database 0.4.0)
|
||||
1. 场景以 GLB 为主(不做 humanoid 重绑定、不转 VRM): .glb/.gltf 直接导入,
|
||||
.zip/.pmx/.fbx 由服务端转 GLB。
|
||||
@@ -136,11 +177,11 @@ v0.2.1 修订
|
||||
2. 准备一个现有的 MySQL / MariaDB 实例, 并建好库与账号(见下方"前置要求")
|
||||
本项目不会创建数据库本身
|
||||
3. 取得发布包并校验(地址用你们自己的制品库替换)
|
||||
curl -LO https://<制品库地址>/opendance-web-v0.6.0.tar.gz
|
||||
curl -LO https://<制品库地址>/opendance-web-v0.7.2.tar.gz
|
||||
curl -LO https://<制品库地址>/SHA256SUMS
|
||||
sha256sum -c SHA256SUMS # 期望输出 "OK"
|
||||
4. 解压并生成配置(交互式引导, 逐项问答, 口令不回显)
|
||||
tar -xzf opendance-web-v0.6.0.tar.gz && cd opendance-web-v0.6.0
|
||||
tar -xzf opendance-web-v0.7.2.tar.gz && cd opendance-web-v0.7.2
|
||||
bash deploy/init-env.sh
|
||||
5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查)
|
||||
bash deploy/install.sh
|
||||
@@ -198,8 +239,8 @@ v0.2.1 修订
|
||||
(本项目不会创建数据库本身, 也不需要 GRANT OPTION / FILE / SUPER 等管理权限)
|
||||
|
||||
部署步骤
|
||||
tar -xzf opendance-web-v0.6.0.tar.gz
|
||||
cd opendance-web-v0.6.0
|
||||
tar -xzf opendance-web-v0.7.2.tar.gz
|
||||
cd opendance-web-v0.7.2
|
||||
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
|
||||
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
|
||||
|
||||
@@ -374,12 +415,12 @@ deploy/.env 初始化引导(deploy/init-env.sh)
|
||||
cd web-server
|
||||
npm run contracts:check # 契约版本一致性
|
||||
npm run typecheck # TypeScript 严格检查
|
||||
npm run test # 243 项单元测试 / 19 个文件(VMD 31、模型压缩包 24、
|
||||
npm run test # 245 项单元测试 / 19 个文件(VMD 31、模型压缩包 24、
|
||||
# 模型检查器 25、缩略图 8、骨骼过滤 8、
|
||||
# 预处理 25、init-env 16、admin-port 16、
|
||||
# access-log 9、VRM 检查器 6、.env 预检 12、
|
||||
# Assimp 定位与安装 13、stats 容量与编码 12、缩略图贴图 6、
|
||||
# MMD 材质对齐 9、模型可用性 5、MMD 骨名重绑定 4、动作预览 8、场景管理 6)
|
||||
# MMD 材质对齐 9、模型可用性 5、MMD 骨名重绑定 4、动作预览 8、场景管理 6、场景页 2)
|
||||
npm run build # 生产构建
|
||||
npm run start:prod & # 启动
|
||||
npm run test:smoke # 57 项端到端冒烟(需要数据库可连通)
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# OpenDance Studio 契约版本
|
||||
# 规则:任何字段级变更必须提升对应条目版本,并回到 opendance-web 修改后再向另两端建迁移任务。
|
||||
rest-api: 0.5.0
|
||||
websocket-keypoints: 0.1.0
|
||||
websocket-keypoints: 0.2.0
|
||||
data-models: 0.7.0
|
||||
skeleton-topology: 0.2.0
|
||||
model-formats: 0.5.0
|
||||
|
||||
@@ -940,4 +940,96 @@ components:
|
||||
songs: { type: array, items: { $ref: '#/components/schemas/Song' } }
|
||||
motions: { type: array, items: { $ref: '#/components/schemas/Motion' } }
|
||||
models: { type: array, items: { $ref: '#/components/schemas/Model' } }
|
||||
scenes:
|
||||
type: array
|
||||
description: 已发布场景(rest-api 0.5.0 起随目录下发;结构见 data-models.md 7)
|
||||
items: { $ref: '#/components/schemas/Scene' }
|
||||
defaultModelId: { type: integer, format: int64, nullable: true }
|
||||
|
||||
Scene:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SourceFields'
|
||||
- type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
name: { type: string }
|
||||
glbUrl: { type: string, nullable: true, description: 场景 GLB 下载地址 }
|
||||
thumbnailUrl: { type: string, nullable: true }
|
||||
fileSize: { type: integer, nullable: true }
|
||||
fileSha256: { type: string, nullable: true }
|
||||
conversionNote:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 由 .zip/.pmx/.fbx 转换而来时的说明;直接上传 GLB 为 null
|
||||
config:
|
||||
allOf: [{ $ref: '#/components/schemas/SceneConfig' }]
|
||||
nullable: true
|
||||
description: 物理 / 环境 / 出生点 / 相机;服务端只存参数,仿真在客户端
|
||||
stats:
|
||||
type: object
|
||||
nullable: true
|
||||
description: 场景 GLB 分析(面数 / 网格 / 材质 / warnings)
|
||||
status: { $ref: '#/components/schemas/PublishStatus' }
|
||||
createdAt: { type: string, format: date-time }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
|
||||
SceneConfig:
|
||||
type: object
|
||||
description: >-
|
||||
data-models.md 7.1 冻结结构。服务端入库时对每项做边界收敛
|
||||
(越界夹紧、非法枚举回退默认值),客户端拿到的参数一定可解释。
|
||||
properties:
|
||||
physics:
|
||||
type: object
|
||||
properties:
|
||||
gravity:
|
||||
type: object
|
||||
description: 米/秒^2,各分量 ±200
|
||||
properties:
|
||||
x: { type: number }
|
||||
y: { type: number }
|
||||
z: { type: number }
|
||||
wind:
|
||||
type: object
|
||||
properties:
|
||||
base: { $ref: '#/components/schemas/Vec3' }
|
||||
gustAmplitude: { type: number, description: 0~100 }
|
||||
gustFrequency: { type: number, description: Hz,0.01~5 }
|
||||
gustDirection: { $ref: '#/components/schemas/Vec3' }
|
||||
space: { type: string, enum: [world, local] }
|
||||
ground:
|
||||
type: object
|
||||
properties:
|
||||
friction: { type: number, description: 0~1 }
|
||||
restitution: { type: number, description: 0~1 }
|
||||
slopeLimit: { type: number, description: 0~89 度 }
|
||||
colliders: { type: string, enum: [auto, mesh, none] }
|
||||
environment:
|
||||
type: object
|
||||
properties:
|
||||
ambientColor: { type: string, description: '#RRGGBB' }
|
||||
ambientIntensity: { type: number, description: 0~4 }
|
||||
fogDensity: { type: number, description: 0~1,0=关闭 }
|
||||
spawnPoints:
|
||||
type: array
|
||||
maxItems: 16
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
x: { type: number }
|
||||
y: { type: number }
|
||||
z: { type: number }
|
||||
yaw: { type: number, description: 朝向(度,绕 Y 轴) }
|
||||
camera:
|
||||
type: object
|
||||
properties:
|
||||
mode: { type: string, enum: [follow, fixed, orbit] }
|
||||
distance: { type: number, description: 0.5~50 }
|
||||
height: { type: number, description: 0~20 }
|
||||
|
||||
Vec3:
|
||||
type: object
|
||||
properties:
|
||||
x: { type: number }
|
||||
y: { type: number }
|
||||
z: { type: number }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# contracts/websocket-keypoints.md - 关键点传输契约 v0.1.0
|
||||
# contracts/websocket-keypoints.md - 关键点传输契约 v0.2.0
|
||||
|
||||
Android 辅助 App(发送端)-> Windows 客户端(接收端)。
|
||||
服务端不参与实时链路,但 T-WEB-006 预处理产出的序列必须与此结构同构。
|
||||
@@ -14,6 +14,22 @@ ws://<windows-host>:8765/pose
|
||||
- 二进制帧传输关键点,文本帧传输控制消息(JSON)。
|
||||
- 默认 30 FPS;发送端允许丢帧,接收端必须按 `frame_id` 检测丢包并插值补齐。
|
||||
|
||||
### 1.1 保活(v0.2.0 新增,向后兼容)
|
||||
|
||||
接收端有**静默超时**:超过 `silentTimeoutMs` 没有收到任何数据帧或文本帧即断开
|
||||
(`close(1001, "timeout")`)。为此:
|
||||
|
||||
- 接收端必须在 `welcome`(以及需要变更时的 `config`)中声明
|
||||
`silentTimeoutMs` 与期望的 `heartbeatIntervalMs`;
|
||||
- 发送端按 `heartbeatIntervalMs` 周期发送 `ping`(见 §3.6),
|
||||
且 **`heartbeatIntervalMs` 必须小于 `silentTimeoutMs` 的一半**;
|
||||
- 发送端每帧关键点与每秒 `stats` 同样算作活动,`ping` 只是兜底,
|
||||
保证「画面里没有人 / 相机暂停」时连接不会被误判掉线。
|
||||
|
||||
> 子协议名与二进制帧 `version` 均**不变**:本版本只**新增可选**控制字段与
|
||||
> 一种新的控制类型,遵循 §5 的兼容规则;仅实现 v0.1.0 的一端仍可正常互通
|
||||
> (它不发 `ping`、不读 `silentTimeoutMs`,但每帧与每秒 stats 已足够保活)。
|
||||
|
||||
## 2. 二进制帧:239 字节(冻结)
|
||||
|
||||
**小端序(little-endian)**。
|
||||
@@ -76,26 +92,54 @@ func decode(buf: PackedByteArray) -> Dictionary:
|
||||
{
|
||||
"type": "hello",
|
||||
"protocol": "opendance.pose.v1",
|
||||
"deviceId": "a1b2c3d4e5f60718",
|
||||
"device": "Pixel 8",
|
||||
"appVersion": "0.1.0",
|
||||
"appVersion": "0.2.0",
|
||||
"cameraFacing": "front",
|
||||
"targetFps": 30,
|
||||
"resolution": "640x480"
|
||||
}
|
||||
```
|
||||
|
||||
- `deviceId`(v0.2.0 新增,**可选但强烈建议**):该安装实例的稳定标识
|
||||
(建议 16 位十六进制,取自 `ANDROID_ID` 的哈希,不使用任何硬件序列号)。
|
||||
接收端**必须按 `deviceId` 去重**:同一 `deviceId` 的新连接替换旧连接,
|
||||
设备数按去重后计数(否则一次「反复重连」会被显示成多台设备)。
|
||||
未提供 `deviceId` 时接收端退回按连接计数。
|
||||
|
||||
### 3.2 `welcome` - Windows -> Android
|
||||
|
||||
```json
|
||||
{ "type": "welcome", "protocol": "opendance.pose.v1", "sessionId": "uuid", "expectedFps": 30 }
|
||||
{
|
||||
"type": "welcome",
|
||||
"protocol": "opendance.pose.v1",
|
||||
"sessionId": "uuid",
|
||||
"expectedFps": 30,
|
||||
"silentTimeoutMs": 15000,
|
||||
"heartbeatIntervalMs": 5000
|
||||
}
|
||||
```
|
||||
|
||||
- `silentTimeoutMs` / `heartbeatIntervalMs`(v0.2.0 新增):见 §1.1。
|
||||
发送端若未读到这两个字段,应使用保守默认值(超时 5000、心跳 1000)。
|
||||
|
||||
### 3.3 `config` - Windows -> Android,运行中调整
|
||||
|
||||
```json
|
||||
{ "type": "config", "mirror": true, "targetFps": 30, "upperBodyOnly": false, "resolution": "640x480" }
|
||||
{
|
||||
"type": "config",
|
||||
"mirror": true,
|
||||
"targetFps": 30,
|
||||
"upperBodyOnly": false,
|
||||
"resolution": "640x480",
|
||||
"silentTimeoutMs": 15000,
|
||||
"heartbeatIntervalMs": 5000
|
||||
}
|
||||
```
|
||||
|
||||
- `silentTimeoutMs` / `heartbeatIntervalMs` 为 v0.2.0 新增的可选字段,
|
||||
接收端在运行中调整保活参数时一并下发。
|
||||
|
||||
### 3.4 `stats` - Android -> Windows,每秒一次
|
||||
|
||||
```json
|
||||
@@ -108,6 +152,31 @@ func decode(buf: PackedByteArray) -> Dictionary:
|
||||
{ "type": "bye", "reason": "user_stop" }
|
||||
```
|
||||
|
||||
`bye` 表示**主动**结束:发出方在发送后立即关闭连接,接收方据此关闭且
|
||||
**不得**自动重连(见 §6)。
|
||||
|
||||
### 3.6 `ping` / `pong` - 保活(v0.2.0 新增)
|
||||
|
||||
Android -> Windows:
|
||||
|
||||
```json
|
||||
{ "type": "ping", "sentAtMs": 1730000000000 }
|
||||
```
|
||||
|
||||
Windows -> Android(可选回复,用于 RTT):
|
||||
|
||||
```json
|
||||
{ "type": "pong", "sentAtMs": 1730000000000, "serverAtMs": 1730000000042 }
|
||||
```
|
||||
|
||||
- `sentAtMs` 为发送方的本地毫秒时间戳,原样回传;发送端用
|
||||
`now - sentAtMs` 估算 RTT。
|
||||
- 接收端收到任何一帧(二进制或文本)都算活动,`ping` 无需额外应答即可保活;
|
||||
`pong` 只用于诊断。
|
||||
- **不要**依赖 WebSocket 协议层的 ping 帧(`Sec-WebSocket-Ping`):它由底层
|
||||
库自动处理,**不会**暴露给应用层,因此不能被接收端的静默超时逻辑看到。
|
||||
这也是必须定义应用层 `ping` 的原因。
|
||||
|
||||
## 4. JSON 调试帧(可选,仅开发期)
|
||||
|
||||
```json
|
||||
@@ -123,9 +192,68 @@ func decode(buf: PackedByteArray) -> Dictionary:
|
||||
|
||||
生产链路必须使用二进制帧;JSON 帧仅用于联调,接收端可选择性支持。
|
||||
|
||||
## 5. 兼容性规则
|
||||
## 5. 设备去重(v0.2.0 新增)
|
||||
|
||||
接收端按 §3.1 的 `deviceId` 去重:
|
||||
|
||||
| 情况 | 行为 |
|
||||
|--|--|
|
||||
| 新连接的 `deviceId` 已存在 | 关闭旧连接(`close(1000, "replaced by new connection")`),保留新连接;设备数不变 |
|
||||
| `deviceId` 缺失或为空 | 不做去重,按连接计数 |
|
||||
| 同一 `deviceId` 的旧连接已断开 | 正常替换,不影响设备数 |
|
||||
|
||||
这样「手机闪断后立刻重连」不会在接收端显示成两台设备,也不会让
|
||||
「已连接 N 台」抖动。
|
||||
|
||||
## 6. 兼容性规则
|
||||
|
||||
- `version` 不同的大版本接收端必须拒绝并在 `bye.reason` 中说明。
|
||||
- `flags` 新增位不得改变既有位语义;旧接收端必须忽略未知位。
|
||||
- 33 点顺序一旦冻结不得插入或重排;新增点只能追加新 `version`。
|
||||
- 服务端 `motions.keypoint_format` 字段取值 `mediapipe33@0.1.0` 对应本契约。
|
||||
|
||||
## 7. 关闭码与重连策略(v0.2.0 新增,两端必须一致)
|
||||
|
||||
| 关闭码 / 情形 | 含义 | 发送端是否自动重连 |
|
||||
|--|--|--|
|
||||
| `1000` 正常关闭 | 任一方主动结束(含收到 `bye`) | **否** |
|
||||
| `1001` going away | 接收端主动关闭(服务重启、`stop()`、静默超时踢人) | **否**(见下方说明) |
|
||||
| `1002` 协议错误 | 子协议 / 协议版本不匹配 | **否**(重连也不会成功) |
|
||||
| 连接失败 / 超时 / `1006` 异常断开 | 网络抖动、对端进程退出、Wi-Fi 切换 | **是** |
|
||||
| 客户端主动 `disconnect()` | 用户点「结束推流」 | **否** |
|
||||
|
||||
- 静默超时由接收端发 `close(1001, "timeout")`;正常情况下发送端按 §1.1 保活,
|
||||
**不应**触发它。若仍频繁触发,属于保活参数或实现缺陷,需按 §7 排查,
|
||||
而不是靠重连掩盖。
|
||||
- 接收端因「同一 `deviceId` 被新连接替换」关闭旧连接时用 `1000`,
|
||||
旧连接不重连。
|
||||
|
||||
### 7.1 退避参数(冻结)
|
||||
|
||||
```
|
||||
第 n 次重连等待 = min(500ms * 2^(n-1), 15000ms) * (1 ± 20% 抖动)
|
||||
即 0.5s → 1s → 2s → 4s → 8s → 15s(封顶),每次叠加 ±20% 抖动
|
||||
连接成功后计数清零
|
||||
```
|
||||
|
||||
## 8. 连接状态机(v0.2.0 新增,两端同名,便于对照日志)
|
||||
|
||||
```
|
||||
idle → resolving → connecting → handshaking → streaming → degraded
|
||||
↑ │
|
||||
└──────── reconnecting ←───────────┘
|
||||
stopped ← 主动停止 / 1000 / 1001 / 1002(终态,不再重连)
|
||||
```
|
||||
|
||||
| 状态 | 含义 | 进入条件(发送端) |
|
||||
|--|--|--|
|
||||
| `idle` | 尚未开始连接 | 初始状态 |
|
||||
| `resolving` | 解析主机地址(DNS / 局域网发现) | `connect()` 且主机名非 IP 字面量 |
|
||||
| `connecting` | TCP + WebSocket 升级中 | 已发起请求 |
|
||||
| `handshaking` | 已连接,等待 `welcome` | `onOpen`,已发送 `hello` |
|
||||
| `streaming` | 正常推流 | 收到 `welcome` 或已成功发送首帧 |
|
||||
| `degraded` | 仍连接但异常(丢弃帧增长 / 发送失败) | `send` 失败或持续丢帧 |
|
||||
| `reconnecting` | 退避等待中 | 非主动断开 |
|
||||
| `stopped` | 已停止(终态) | 主动停止 / 1000 / 1001 / 1002 |
|
||||
|
||||
接收端(Windows)在日志与状态面板中使用同名状态描述每个客户端连接。
|
||||
|
||||
@@ -204,6 +204,7 @@ curl -i http://<反代地址>/api/catalog/version
|
||||
| 客户端接口全 404 | 把客户端的转发规则指到了管理端口之外,或误把 `/api` 前缀重写掉;客户端应转发到软件 API 端口 |
|
||||
| 管理页面 404 | 把浏览器的转发规则指到了**软件 API 端口**(该端口不提供管理页面,这是有意设计);改指 `ADMIN_PORT` |
|
||||
| 两个端口都转发到同一条规则 | 若确实想合并两个端口,设置 `ADMIN_PORT=PORT` 走单端口模式,而不是让反代去猜 |
|
||||
| **Gitea 的 Release 附件上传 413** | 这是**另一台站点**(托管 Gitea 的 IIS),本服务的 `UPLOAD_MAX_BYTES` 管不到它;见第九节 |
|
||||
|
||||
## 八、还没有反代时:直连后端怎么访问
|
||||
|
||||
@@ -226,3 +227,125 @@ bash deploy/check-access.sh /opt/opendance # 指定安装目录
|
||||
脚本会打印 `.env` 中的 `HOST`/`PORT`、实际监听地址(重点看是否只监听
|
||||
`127.0.0.1`)、回环与对外 IP 两次自测结果、80/443 上有没有反代、
|
||||
firewalld / ufw / iptables 的放行情况,并直接给出修复命令。
|
||||
|
||||
---
|
||||
|
||||
## 九、Gitea 站点的附件上传上限(Windows 客户端整包必须放开)
|
||||
|
||||
> 本节与第一~五节是**两个不同的站点**:前者是本 Web 服务的反代,后者是托管
|
||||
> **Gitea**(源码仓库 + Release 附件)的站点。Windows 客户端的发布整包走 Gitea,
|
||||
> 因此**必须**单独放开 Gitea 站点的上限。
|
||||
|
||||
### 9.1 实测结论(两阶段)
|
||||
|
||||
**第一阶段(IIS 上限未放开时)**
|
||||
|
||||
| 上传体积 | 结果 |
|
||||
|--|--|
|
||||
| 14 MB | 通过(HTTP 201) |
|
||||
| 30.5 MB | **失败 HTTP 413**(IIS「请求实体过大」页面) |
|
||||
| 40 MB | **失败 HTTP 413** |
|
||||
|
||||
**第二阶段(IIS 上限放开之后)** —— 放开的效果是**可验证**的:
|
||||
38,245,661 字节的请求体被**完整接收**(耗时 115 s),不再出现 413,
|
||||
说明 IIS 的 `maxAllowedContentLength` 已经够用。但随后 **Gitea 自己返回 HTTP 500**
|
||||
(响应体是 Gitea 的 API 错误信封 `{"message":"","url":".../api/swagger"}`,
|
||||
`message` 为空)——即瓶颈已从 IIS 转移到 Gitea 侧。
|
||||
|
||||
同阶段的边界测量(同一个 Release 的资产上传接口):
|
||||
|
||||
| 上传体积 | 结果 |
|
||||
|--|--|
|
||||
| 914 KB(web v0.7.1 tar.gz) | 通过 |
|
||||
| 16,372,427 B(android v0.2.0 zip) | 通过 |
|
||||
| 16,400,000 B | **HTTP 500** |
|
||||
| 16,500,000 B / 18 MB / 21 MB / 24 MB | **HTTP 500** |
|
||||
| 100,000 B(在第一轮失败之后) | **HTTP 500,且仅耗时 0.3 s** |
|
||||
|
||||
最后一行是关键:**在若干次大包失败之后,连 100 KB 都会在 0.3 秒内 500** ——
|
||||
这不符合"上传体积上限"的特征(上限判断会先读完请求体),
|
||||
更像**上传临时目录 / 数据盘已满**(Gitea 先把请求体写入 `data/tmp`,
|
||||
失败时可能在磁盘上留下残留临时文件)。
|
||||
|
||||
> **因此运维侧需要按顺序检查两件事**:
|
||||
> 1. **Gitea 数据盘的剩余空间**(尤其是 `data/tmp`,以及它是否落在小容量的
|
||||
> tmpfs 上),并清理失败上传留下的临时文件;
|
||||
> 2. Gitea `app.ini` 的 `[attachment] MAX_SIZE`(下文第 3 条)。
|
||||
>
|
||||
> 只放开 IIS 是不够的:本节第 1 步之后仍然 500,说明还有 Gitea 侧的限制或磁盘问题。
|
||||
|
||||
→ 默认上限就是 **IIS 的 `maxAllowedContentLength = 30,000,000` 字节(≈28.6 MiB)**。
|
||||
Windows 客户端整包约 38 MB(`OpenDance.exe` ≈104 MB 未压缩 + `OpenDance.pck`),
|
||||
早先因此被拆成 `zip.part0/1/2`(每卷 13 MiB)才传上去 —— 用户拿到的是
|
||||
**无法直接解压的碎片**,必须自己拼接,属于交付缺陷。
|
||||
|
||||
### 9.2 正确做法:放开 Gitea 站点上限到 256 MB
|
||||
|
||||
1. **IIS**(托管 Gitea 的站点根目录 `web.config`):
|
||||
|
||||
```xml
|
||||
<configuration>
|
||||
<system.webServer>
|
||||
<security>
|
||||
<requestFiltering>
|
||||
<!-- 256 MiB;默认 30,000,000 字节,Windows 整包会被 413 掉 -->
|
||||
<requestLimits maxAllowedContentLength="268435456" />
|
||||
</requestFiltering>
|
||||
</security>
|
||||
<serverRuntime uploadReadAheadSize="268435456" />
|
||||
</system.webServer>
|
||||
</configuration>
|
||||
```
|
||||
|
||||
`uploadReadAheadSize` 必须一起改(SSL 场景下默认仅 48 KB,会中途失败)。
|
||||
|
||||
2. **ARR**(服务器级,不是站点级)在 `applicationHost.config` 中同步放宽请求体上限:
|
||||
|
||||
```xml
|
||||
<system.webServer>
|
||||
<proxy>
|
||||
<forwarding />
|
||||
</proxy>
|
||||
</system.webServer>
|
||||
<webProxy enabled="true" timeout="00:05:00" />
|
||||
<serverRuntime uploadReadAheadSize="268435456" />
|
||||
```
|
||||
|
||||
并在 IIS 管理器 → Application Request Routing Cache → Server Proxy Settings
|
||||
中确认请求体上限(`maxRequestBytes`)≥ `268435456`。
|
||||
|
||||
3. **Gitea 自身** `app.ini`:
|
||||
|
||||
```ini
|
||||
[attachment]
|
||||
; 默认通常为 4~32 MB,必须一起放大,否则 IIS 放行了 Gitea 仍会拒绝
|
||||
MAX_SIZE = 256
|
||||
```
|
||||
|
||||
4. **若 Gitea 前面还有 nginx**(IIS 之外或之内):
|
||||
|
||||
```nginx
|
||||
client_max_body_size 256m;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
```
|
||||
|
||||
5. 改完执行 `iisreset /noforce`(或只重启站点),再按下节复验。
|
||||
|
||||
> **注意**:`requestLimits` 只是第一道闸。IIS 放开后若仍为 500(而不是 413),
|
||||
> 说明请求体已经到达 Gitea,此时要按 9.1 的两件事继续排查
|
||||
> (Gitea 数据盘空间 / `[attachment] MAX_SIZE`)。
|
||||
|
||||
### 9.3 复验方法
|
||||
|
||||
```bash
|
||||
# 造一个 40 MB 文件上传到 Gitea 的附件接口,预期 HTTP 201(不再是 413)
|
||||
head -c 40000000 /dev/urandom > /tmp/probe.bin
|
||||
curl -i -X POST \
|
||||
-H "Authorization: token <token>" \
|
||||
-F "file=@/tmp/probe.bin" \
|
||||
"https://<Gitea 地址>/api/v1/repos/<owner>/<repo>/releases/<id>/assets?name=probe.bin"
|
||||
```
|
||||
|
||||
在放开上限之前,**不要**用分卷绕过:Windows 端 `build/package-release.sh`
|
||||
会拒绝生成 `.partN`,超限时直接告警并要求先放宽本节的配置。
|
||||
|
||||
+34
-4
@@ -1,8 +1,14 @@
|
||||
project: opendance-web-server
|
||||
version: 0.6.0
|
||||
version: 0.7.2
|
||||
repository: opendance-web
|
||||
default_branch: main
|
||||
contracts_version: 0.4.0
|
||||
contracts_version:
|
||||
rest-api: 0.5.0
|
||||
websocket-keypoints: 0.2.0
|
||||
data-models: 0.7.0
|
||||
skeleton-topology: 0.2.0
|
||||
model-formats: 0.5.0
|
||||
database: 0.4.0
|
||||
|
||||
# 契约变更流程(文档 7.2):
|
||||
# 任何跨端契约变更必须先在本项目修改 contracts/ 并提升版本,
|
||||
@@ -58,15 +64,39 @@ tasks:
|
||||
file: tasks/010-deploy.md
|
||||
scope: 部署脚本(双端口:软件 API 3000 + 管理端 3001,不部署 MySQL)+ deploy/.env 初始化引导(init-env.sh,HOST 默认 0.0.0.0)
|
||||
status: done
|
||||
- id: T-WEB-011
|
||||
file: tasks/011-version-display.md
|
||||
scope: 管理端左下角展示当前版本号(唯一来源 package.json)
|
||||
status: done
|
||||
- id: T-WEB-012
|
||||
file: tasks/012-vrm-first.md
|
||||
scope: 模型主力格式全面转为 VRM(非 VRM 入库标记不可用,不进已发布目录)
|
||||
status: done
|
||||
- id: T-WEB-013
|
||||
file: tasks/013-bone-rebinding.md
|
||||
scope: 动作内部标准(33 点)+ 模型骨骼按名重绑定(含 MMD/中文名)
|
||||
status: done
|
||||
- id: T-WEB-014
|
||||
file: tasks/014-motion-preview.md
|
||||
scope: 动作管理预览(离线骨架缩略图 + 管理端 3D 回放)
|
||||
status: done
|
||||
- id: T-WEB-015
|
||||
file: tasks/015-scene-management.md
|
||||
scope: 场景管理(GLB + 重力/风/物理参数,服务端只存参数)
|
||||
status: done
|
||||
- id: T-WEB-016
|
||||
file: tasks/016-websocket-contract-020.md
|
||||
scope: 契约 websocket-keypoints 0.2.0(保活 / 设备去重 / 状态机,与 T-AND-011 对齐)
|
||||
status: done
|
||||
|
||||
deliverable:
|
||||
artifact: opendance-web-v0.6.0.tar.gz
|
||||
artifact: opendance-web-v0.7.2.tar.gz
|
||||
packaging: Linux 业务服务(系统服务托管,双端口:软件 API + 管理端,不含 MySQL、不含反向代理、不使用 Docker)
|
||||
includes:
|
||||
- web-server/ # NestJS 源码 + 构建产物(含 BVH / VMD 内置解析、ZIP 读取与模型压缩包转换)
|
||||
- admin/ # React 管理端构建产物
|
||||
- deploy/ # install.sh / init-env.sh / systemd / openrc / initd / ctl.sh / .env.example(PORT、ADMIN_PORT、HOST)
|
||||
- deploy/proxy/ # 反向代理说明(nginx / IIS,仅供参考不部署)
|
||||
- contracts/ # 当前版本契约快照(rest-api 0.5.0 / data-models 0.7.0 / database 0.4.0)
|
||||
- contracts/ # 当前版本契约快照(rest-api 0.5.0 / websocket-keypoints 0.2.0 / data-models 0.7.0 / database 0.4.0)
|
||||
- THIRD_PARTY.md # 开源声明
|
||||
- README.txt # 使用说明
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# 打包发布产物:opendance-web-v0.6.0.tar.gz
|
||||
# 打包发布产物:opendance-web-v0.7.0.tar.gz
|
||||
#
|
||||
# 产物结构(Linux 业务服务部署,不使用 Docker、不部署反向代理):
|
||||
# web-server/ NestJS 源码 + 构建产物
|
||||
@@ -21,7 +21,20 @@ set -euo pipefail
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
VERSION="${1:-0.6.0}"
|
||||
# 版本号唯一来源:web-server/package.json 的 version(与 GET /health 的 version 同源,
|
||||
# 也是管理端左下角展示的那个值)。不传参数时自动读取,并与 project.yaml 交叉校验。
|
||||
PKG_VERSION="$(node -p "require('./web-server/package.json').version" 2>/dev/null || true)"
|
||||
YAML_VERSION="$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)"
|
||||
VERSION="${1:-$PKG_VERSION}"
|
||||
VERSION="${VERSION:-0.7.1}"
|
||||
if [ -n "$PKG_VERSION" ] && [ "$VERSION" != "$PKG_VERSION" ]; then
|
||||
echo "[error] 版本号不一致:参数=$VERSION,web-server/package.json=$PKG_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$YAML_VERSION" ] && [ "$VERSION" != "$YAML_VERSION" ]; then
|
||||
echo "[error] 版本号不一致:参数=$VERSION,project.yaml=$YAML_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
NAME="opendance-web-v${VERSION}"
|
||||
OUT_DIR="$REPO_ROOT/dist-release"
|
||||
STAGE="$OUT_DIR/$NAME"
|
||||
@@ -36,11 +49,16 @@ log "校验契约版本一致性..."
|
||||
# ---------- 2. 后端构建 ----------
|
||||
log "构建后端..."
|
||||
( cd web-server && npm run build )
|
||||
# 构建后**启动冒烟**:产物能不能真正 boot 起来(模块 DI 图可解析 + 版本号正确)。
|
||||
# v0.7.0/v0.7.1 就是漏了这一步才把「装得上、起不来」的产物发了出去。
|
||||
( cd web-server && npm run bootstrap:check )
|
||||
|
||||
# ---------- 3. 管理端构建 ----------
|
||||
if [ -d web-server/admin/node_modules ]; then
|
||||
log "构建管理端..."
|
||||
( cd web-server/admin && npm run build )
|
||||
# 构建后再次校验版本一致性:此时管理端产物已存在,会额外断言产物 JS 内含 vX.Y.Z
|
||||
( cd web-server && npm run version:check )
|
||||
else
|
||||
log "警告:web-server/admin/node_modules 不存在,跳过管理端构建(产物将缺少 admin/)"
|
||||
fi
|
||||
@@ -169,7 +187,15 @@ if [ "$fail" -ne 0 ]; then
|
||||
die "产物完整性校验失败,已删除有问题的压缩包"
|
||||
fi
|
||||
|
||||
# ---- SHA256SUMS(裸文件名,下载目录内 sha256sum -c 直接可用) ----
|
||||
( cd "$OUT_DIR" && sha256sum "$NAME.tar.gz" > SHA256SUMS )
|
||||
log "完成:$OUT_DIR/$NAME.tar.gz"
|
||||
log "已生成 $OUT_DIR/SHA256SUMS"
|
||||
if ( cd "$OUT_DIR" && sha256sum -c SHA256SUMS --status ); then
|
||||
log "sha256 自校验:OK"
|
||||
else
|
||||
die "sha256 自校验失败"
|
||||
fi
|
||||
echo
|
||||
echo "产物内容:"
|
||||
# 先把清单读入变量,避免 tar 因管道提前关闭(SIGPIPE)而误报 "write error"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# T-WEB-011 管理端展示当前版本号
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WEB-010
|
||||
- **文档**: v1.6 §11(版本号展示)
|
||||
|
||||
## 目标
|
||||
|
||||
管理端页面**左下角**展示当前运行版本 `vX.Y.Z`,且与 `GET /health` 的 `version`
|
||||
同源(文档 §11.1 的硬性要求:单一来源、可核对)。
|
||||
|
||||
## 方案
|
||||
|
||||
| 项 | 做法 |
|
||||
|--|--|
|
||||
| 唯一来源 | `web-server/package.json` 的 `version`(同时是 `/health` 的 `version`) |
|
||||
| 注入方式 | Vite 构建期 `define: { __APP_VERSION__: <package.json version> }` |
|
||||
| 展示位置 | `admin/src/components/Layout.tsx` 侧边栏底部 `APP_VERSION_LABEL` |
|
||||
| 禁止重复 | `admin/src/lib/version.ts` 是唯一读取点,界面不出现版本号字面量 |
|
||||
|
||||
`/health` 的 `version` 取 `process.env.npm_package_version`(由 npm 脚本注入),
|
||||
与构建期注入管理端的值来自同一个 `package.json`,因此**不可能不一致**。
|
||||
|
||||
## 验收(文档 §11.3)
|
||||
|
||||
| 验收项 | 结果 |
|
||||
|--|--|
|
||||
| 管理端任一页面左下角可见 `vX.Y.Z` | ✅ 侧边栏底部固定显示 |
|
||||
| 与 `GET /health` 的 `version` 相同 | ✅ 同源于 `web-server/package.json` |
|
||||
| 与产物文件名一致 | ✅ `opendance-web-vX.Y.Z.tar.gz`;打包脚本读取同一个 package.json |
|
||||
|
||||
## 缺陷修复记录(v0.7.2)
|
||||
|
||||
**缺陷**:`/health` 的 `version` 在正式部署里**永远是 `0.1.0`**。
|
||||
|
||||
原实现是 `process.env.npm_package_version ?? '0.1.0'`。这个环境变量
|
||||
**只在通过 npm 脚本启动时才存在**,而本项目的正式部署方式是原生服务
|
||||
(systemd / OpenRC / initd,`ExecStart=node dist/main.js`)—— npm 不参与,
|
||||
于是它恒为 `undefined`,`/health` 就回退到硬编码的 `0.1.0`。
|
||||
这直接违反文档 §11.2 第 4 条「可核对:Web 端至少与 `GET /health` 的 `version` 一致」:
|
||||
管理端左下角显示 v0.7.x,而 `/health` 说 0.1.0。
|
||||
|
||||
**修复**:
|
||||
|
||||
* 新增 `src/config/app-version.ts` 作为**运行时唯一解析点**,按优先级尝试
|
||||
`APP_VERSION` 环境变量 → 运行入口旁的 `package.json` → 工作目录的
|
||||
`package.json` → `npm_package_version`,每一步都做**包名校验**
|
||||
(必须是 `opendance-web-server`),避免读到上层仓库的 `package.json`;
|
||||
全都拿不到时返回显式的 `0.0.0`,而不是伪装成某个具体版本;
|
||||
* `/health` 改为 `version: resolveAppVersion()`;
|
||||
* `main.ts` 启动首行也打印 `应用版本 vX.Y.Z`,即使后面数据库连不上、进程退出,
|
||||
日志里也一定能看到本次运行的版本号。
|
||||
|
||||
**防线**:
|
||||
|
||||
* `scripts/check-version.mjs`(并入 `npm run contracts:check`)校验
|
||||
`web-server/package.json` = `admin/package.json` = `project.yaml`,并
|
||||
**禁止** `health.controller.ts` 再出现硬编码版本回退(正则拦截 `?? 'x.y.z'`);
|
||||
管理端已构建时还会断言产物 JS 内确实含 `vX.Y.Z`;
|
||||
* `test/app-version.spec.ts` 12 项,其中一项**显式模拟无 npm 环境变量**的原生部署;
|
||||
* `scripts/check-bootstrap.mjs` 用真实的 `node dist/main.js` 验证启动日志里的版本号。
|
||||
@@ -0,0 +1,32 @@
|
||||
# T-WEB-012 模型主力格式:全面转为 VRM
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WEB-005 / T-WEB-007
|
||||
- **文档**: v1.6 §4.9
|
||||
- **契约**: `model-formats 0.5.0`、`data-models 0.7.0`
|
||||
|
||||
## 目标
|
||||
|
||||
所有入库模型统一转成 **VRM**(glTF 2.0 容器 + humanoid 扩展),
|
||||
客户端只消费 VRM,避免三种格式各自的骨骼命名/坐标系差异在客户端叠加。
|
||||
|
||||
## 方案
|
||||
|
||||
| 项 | 做法 |
|
||||
|--|--|
|
||||
| 转换默认值 | `MODEL_CONVERT_FORMAT` 默认 `vrm`(`.env.example` 同步) |
|
||||
| 直传 | `.vrm` / `.glb` 已经是 glTF 容器:直接入库、**不重编码** |
|
||||
| 需转换 | `.pmx` / `.fbx` / `.zip` 由 Assimp 转 GLB,再补齐 VRM 扩展 |
|
||||
| 可用性判定 | `isUsableModel()`:`format === 'vrm'` 才可用 |
|
||||
| 不可用模型 | **入库但标记为「不可用」**,`unusableReason()` 说明原因;不进已发布目录 |
|
||||
| 目录过滤 | `catalog/export` 只下发 `format = vrm` 的模型;`catalog/version` 指纹同样只算可用的 |
|
||||
| 默认模型 | `resolveDefaultModelId()` 只在可用模型里挑 |
|
||||
|
||||
## 验收
|
||||
|
||||
| 验收项 | 结果 |
|
||||
|--|--|
|
||||
| 上传 PMX/FBX/zip 后产出 VRM | ✅ Assimp → GLB → VRM 扩展补齐;实测 MMD 模型(初音未来 / 奥黛塔)转换成功 |
|
||||
| 非 VRM 模型不出现在客户端目录 | ✅ `catalog/export` 过滤 + 指纹过滤;`test/model-usability.spec.ts` 覆盖 |
|
||||
| 不可用模型有明确原因 | ✅ DTO 增 `usable` / `unusableReason` 字段 |
|
||||
| 三端契约一致 | ✅ `data-models 0.7.0` / `model-formats 0.5.0` 快照三端逐字节一致 |
|
||||
@@ -0,0 +1,42 @@
|
||||
# T-WEB-013 动作内部标准与模型骨骼重绑定
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WEB-006 / T-WEB-012
|
||||
- **文档**: v1.6 §4.10
|
||||
- **契约**: `skeleton-topology 0.2.0`、`data-models 0.7.0`
|
||||
|
||||
## 目标
|
||||
|
||||
1. **动作内部标准**:所有动作(BVH / VMD / FBX / glTF)统一预处理成
|
||||
MediaPipe 33 点关键点序列(`mediapipe33@0.1.0`),客户端只认这一种;
|
||||
2. **模型骨骼重绑定**:任意命名风格的模型都能被关键点驱动 —— 按**名字**映射到
|
||||
canonical humanoid 骨骼,而不是要求模型符合某一种命名规范。
|
||||
|
||||
## 方案
|
||||
|
||||
### 动作内部标准
|
||||
|
||||
`preprocess.service.ts` 按扩展名分派解析器,统一产出 NDJSON 关键点序列 +
|
||||
9 个关节角特征(108 维),与 Android 端推流结构完全同构 —— 因此客户端
|
||||
DTW 的两侧输入是同一套特征。
|
||||
|
||||
### 骨骼重绑定
|
||||
|
||||
| 项 | 做法 |
|
||||
|--|--|
|
||||
| 归一化 | `normalizeBoneName()`:保留 CJK 与假名(**不能再只留 `a-z0-9`**),全角 ASCII 折叠(`足IK` → `足ik`) |
|
||||
| 候选表 | `HUMANOID_CANDIDATES` 覆盖 VRM/VRoid、Mixamo、MMD 日文名、中文常见名 |
|
||||
| 生成而非手写 | Godot 端 `src/model/humanoid_map.gd` 的 CANDIDATES **由服务端表生成**,并有跨端一致性测试 |
|
||||
| 覆盖度 | `coverage()` / `missing_bones()` 暴露给审核界面与客户端 |
|
||||
|
||||
> **踩坑记录**:第一版 `normalizeBoneName()` 只保留 `[a-z0-9]`,把 CJK 名字
|
||||
> 整个抹成空串,导致 MMD 模型 humanoid 覆盖 **0/22**。修复后实测
|
||||
> 奥黛塔 **21/22**、初音未来 **19/22**,两者 `drivable = true` 且 VRM 正常产出。
|
||||
|
||||
## 验收
|
||||
|
||||
| 验收项 | 结果 |
|
||||
|--|--|
|
||||
| MMD(日文骨骼名)模型可驱动 | ✅ 实测 21/22、19/22 覆盖;客户端可正常驱动 |
|
||||
| Godot 端候选表与服务端一致 | ✅ 一致性测试(跨仓库生成 + 校验) |
|
||||
| 动作统一为 33 点 | ✅ `keypoint_format = mediapipe33@0.1.0`;Android 端推流同构 |
|
||||
@@ -0,0 +1,30 @@
|
||||
# T-WEB-014 动作管理:预览功能
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WEB-004 / T-WEB-006
|
||||
- **文档**: v1.6 §4.11(重要)
|
||||
- **契约**: `data-models 0.6.0`(新增 `thumbnailUrl`)
|
||||
|
||||
## 目标
|
||||
|
||||
审核动作时能**直接看到动作长什么样**,而不是只看文件名与帧数 ——
|
||||
动作是否可用、是否与曲目匹配,靠肉眼判断最快。
|
||||
|
||||
## 方案
|
||||
|
||||
| 项 | 做法 |
|
||||
|--|--|
|
||||
| 服务端缩略图 | `motions/motion-thumbnail.ts` 用关键点序列离线渲染骨架 PNG(前端视角的"动作剪影"),零原生依赖 |
|
||||
| 入库时机 | 预处理 `run()` 末尾生成(**非致命**:失败不阻塞预处理完成,只记录) |
|
||||
| 契约 | `data-models 0.6.0` 给 Motion 增加 `thumbnailUrl`;迁移 `1789600000000-AddMotionThumbnail` |
|
||||
| 管理端预览 | `admin/src/components/MotionPreview.tsx`:3D 骨架回放(Three.js),可拖动时间轴逐帧查看 |
|
||||
| 依赖数据 | `admin/src/lib/keypoint-sequence.ts` 解析 NDJSON,`skeleton-topology.ts` 提供父节点表 |
|
||||
|
||||
## 验收
|
||||
|
||||
| 验收项 | 结果 |
|
||||
|--|--|
|
||||
| 动作列表显示缩略图 | ✅ 离线渲染的骨架 PNG |
|
||||
| 可逐帧回放查看动作 | ✅ `MotionPreview` 时间轴 + 3D 骨架 |
|
||||
| 缩略图生成失败不影响预处理 | ✅ 非致命分支;错误写入日志 |
|
||||
| 契约三端一致 | ✅ `data-models 0.6.0` → 后续 0.7.0;快照逐字节一致 |
|
||||
@@ -0,0 +1,75 @@
|
||||
# T-WEB-015 场景管理(新增模块)
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WEB-007(审核流程)
|
||||
- **文档**: v1.6 §4.12
|
||||
- **契约**: `rest-api 0.5.0`、`data-models 0.7.0`、`database 0.4.0`
|
||||
|
||||
## 目标
|
||||
|
||||
新增**场景**这一类资源:客户端跟练时的 3D 环境(GLB)+ 物理参数
|
||||
(重力 / 风 / 地面 / 碰撞体 / 环境 / 出生点 / 相机)。
|
||||
服务端**只存参数、不跑物理**,仿真在客户端(Windows 端用 Godot 内置物理)。
|
||||
|
||||
## 与模型的区别
|
||||
|
||||
| 项 | 模型 | 场景 |
|
||||
|--|--|--|
|
||||
| 主力格式 | **VRM**(humanoid 重绑定) | **GLB**(不做重绑定、不转 VRM) |
|
||||
| 转换 | PMX/FBX/zip → GLB → VRM | `.glb`/`.gltf` 直接导入;`.zip`/`.pmx`/`.fbx` → GLB |
|
||||
| 附加数据 | 骨骼 / 材质 / 缩略图 | **物理参数** `SceneConfig` |
|
||||
|
||||
## 交付物
|
||||
|
||||
| 文件 | 说明 |
|
||||
|--|--|
|
||||
| `contracts/data-models.md` §7 | `Scene` 与冻结的 `SceneConfig` 结构 |
|
||||
| `contracts/rest-api.yaml` | `/api/scenes` 增删改查 + 审核复用 `/api/reviews/scene/:id/approve\|reject` + `catalog/export.scenes[]` |
|
||||
| `migrations/1789650000000-AddScenes.ts` | `scenes` 表;`config` / `stats` 用 **MEDIUMTEXT** |
|
||||
| `src/scenes/scene-config.ts` | `normalizeSceneConfig()`:缺项默认、越界夹紧、非法枚举回退,**永不抛错** |
|
||||
| `src/scenes/{scenes.service,scene.serializer,scenes.controller,scenes.module}.ts` | 业务实现(上传一律 `pending`;删除时清理源文件/GLB/缩略图) |
|
||||
| `src/models/model-inspector.ts` | 场景复用模型检查器做 GLB 分析 |
|
||||
| `src/models/model-thumbnail.ts` | 场景复用模型光栅化器出缩略图 |
|
||||
| `admin/src/pages/Scenes.tsx` | 列表 / 上传 / 审核 / 删除 / 物理参数编辑(编辑期即按同一规则夹紧) |
|
||||
| `admin/src/lib/scene-physics.ts` | 与后端同规则的收敛(保存前就不会产生非法枚举) |
|
||||
|
||||
> **MEDIUMTEXT 的原因**:`models.stats` 曾在 TEXT(65,535 字节)上溢出
|
||||
> (实测 70,980 字节 → MySQL 1406 → 500)。场景的 `stats` 与 `config` 同样可能很大,
|
||||
> 因此**一开始就用 MEDIUMTEXT**,并保留 `shrinkStatsForStorage()` 兜底。
|
||||
|
||||
## 验收
|
||||
|
||||
| 验收项 | 结果 |
|
||||
|--|--|
|
||||
| 场景可上传(GLB 直传 / zip 自动解包 / PMX 转换) | ✅ 与模型同一套转换管线 |
|
||||
| 物理参数可在管理端可视化编辑 | ✅ 重力 / 风 / 摩擦 / 弹性 / 碰撞体 / 环境 / 出生点 / 相机 |
|
||||
| 非法参数不会入库 | ✅ 前后端同规则收敛;`test/scene-config.spec.ts` 9 项边界用例 |
|
||||
| 客户端能拿到场景 | ✅ `catalog/export.scenes[]`;目录指纹纳入场景变更 |
|
||||
| 路由与契约一致 | ✅ `check-routes` 共 **35** 条 |
|
||||
|
||||
## 缺陷修复记录(v0.7.2)
|
||||
|
||||
**缺陷**:`CatalogModule` 的 `imports` 里漏了 `ScenesModule`。
|
||||
|
||||
`CatalogService` 因为 `catalog/export` 要下发 `scenes[]` 而注入了 `ScenesService`,
|
||||
但 Nest 的 DI 是**按模块**解析的 —— 不在同一个模块的 `imports` 里,就解析不到。
|
||||
后果是 `node dist/main.js` 在 bootstrap 阶段直接抛
|
||||
`UnknownDependenciesException: Nest can't resolve dependencies of the CatalogService (... ? ...)`,
|
||||
**服务完全起不来**(v0.7.0 / v0.7.1 的产物都是这样)。
|
||||
|
||||
**为什么当时没被发现**:三层检查全都"绿"——
|
||||
|
||||
| 检查 | 为什么没抓到 |
|
||||
|--|--|
|
||||
| `nest build` / `tsc --noEmit` | 模块 import 缺失是**运行时** DI 问题,类型系统看不出来 |
|
||||
| 258 项单元测试 | 它们直接 new 服务或测纯函数,**从不编译 `AppModule`** |
|
||||
| 打包脚本的产物完整性校验 | 只校验文件清单、压缩包结构与 PE/魔数,不启动服务 |
|
||||
|
||||
**修复**:在 `CatalogModule.imports` 中补上 `ScenesModule`。
|
||||
|
||||
**防线**:新增 `scripts/check-bootstrap.mjs`,以正式部署方式
|
||||
(`ExecStart=node dist/main.js`、**不带任何 npm 环境变量**、**不需要数据库**)
|
||||
拉起真实产物,断言①无依赖解析类错误②启动日志里的应用版本等于 `package.json`
|
||||
③启动确实推进到数据库连接阶段。已挂进 `npm run verify` 与
|
||||
`scripts/package-release.sh`(构建后、打包前),并做过**反向验证**:
|
||||
把 `ScenesModule` 删除后该检查立刻失败。
|
||||
@@ -0,0 +1,48 @@
|
||||
# T-WEB-016 契约提升:websocket-keypoints 0.2.0(保活 / 去重 / 状态机)
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WEB-003(本项目为契约源)
|
||||
- **文档**: v1.6 §6.5 / §7.2
|
||||
- **触发**: Android 端 T-AND-011「连接状态异常、频繁重连」要求**两端一起改**
|
||||
|
||||
## 背景
|
||||
|
||||
按文档 §7.2,任何跨端契约变更必须**先在本项目修改 `contracts/` 并提升版本**,
|
||||
再向另两端建迁移任务。本次 Android 端排查出"频繁重连"的根因在协议层:
|
||||
接收端有静默超时,但**契约里没有声明超时值,也没有定义任何应用层保活消息**。
|
||||
|
||||
## 变更内容(均为**向后兼容**:子协议名与二进制帧 `version` 均不变)
|
||||
|
||||
| 章节 | 新增 | 为什么必须写进契约 |
|
||||
|--|--|--|
|
||||
| §1.1 保活 | 接收端必须在 `welcome`(及需要变更时的 `config`)中声明 `silentTimeoutMs` 与 `heartbeatIntervalMs`;且**心跳必须小于静默超时的一半** | 客户端原先只能用 OkHttp 的**协议层** ping,而它由底层库自动应答、**不暴露给应用层**,接收端根本看不到,无法续命 |
|
||||
| §3.1 | `hello.deviceId`(该安装实例的稳定标识,**不使用任何硬件序列号**) | 没有它,一次闪断重连会被接收端显示成两台设备 |
|
||||
| §3.2 / §3.3 | `welcome` / `config` 携带上述保活字段 | 让客户端按服务端节奏保活,而不是各写一套魔数 |
|
||||
| §3.6 | 应用层 `ping` / `pong`(回显 `sentAtMs`,`pong` 另带 `serverAtMs` 供算 RTT) | 协议层 ping 对应用层不可见,必须自定义 |
|
||||
| §5 | 设备去重规则:同一 `deviceId` 只保留最新连接,旧连接以 `1000` 关闭 | 用 `1000` 是刻意的:对端据 §7 不会重连,否则会形成"挤掉-重连-再挤掉"的循环 |
|
||||
| §7 | 关闭码与重连策略:`1000` / `1001` / `1002` 与主动停止**不重连**;其余重连 | 原实现"只要能重连就重连",服务端重启或协议不匹配时也会疯狂重连 |
|
||||
| §7.1 | 退避参数冻结:`min(500ms * 2^(n-1), 15000ms)` + **±20% 抖动** | 避免同网段多台设备同时重连打尖峰 |
|
||||
| §8 | 连接状态机 `idle → resolving → connecting → handshaking → streaming → degraded → reconnecting → stopped` | 两端同名,日志可直接对照 |
|
||||
|
||||
## 兼容性
|
||||
|
||||
* 子协议 `opendance.pose.v1` 与二进制帧 `version = 0x01` **均不变**;
|
||||
* 只**新增可选**控制字段与一种新控制类型;
|
||||
* 仅实现 v0.1.0 的一端仍可正常互通(它不发 `ping`、不读 `silentTimeoutMs`,
|
||||
但每帧关键点 + 每秒 `stats` 已足够保活)。
|
||||
|
||||
## 改动清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|--|--|
|
||||
| `contracts/websocket-keypoints.md` | 上文全部章节 |
|
||||
| `contracts/VERSION` | `websocket-keypoints: 0.1.0` → `0.2.0` |
|
||||
| `web-server/src/config/contract-versions.ts` | 编译期常量同步为 0.2.0(`check-contracts.mjs` 强制) |
|
||||
|
||||
## 验收
|
||||
|
||||
| 验收项 | 结果 |
|
||||
|--|--|
|
||||
| `npm run contracts:check` 通过 | ✅ 六项契约版本一致,35 条路由与契约一致 |
|
||||
| 三端快照逐字节一致 | ✅ windows / android 的 `contracts/` md5 与 web 完全相同 |
|
||||
| 另两端的迁移任务已建立 | ✅ Windows `T-WIN-015`、Android `T-AND-011` 均已落地并引用本契约 |
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "opendance-admin",
|
||||
"private": true,
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.2",
|
||||
"type": "module",
|
||||
"description": "OpenDance Studio 管理端(React + TypeScript + Vite)",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { Layout } from './components/Layout';
|
||||
import { DashboardPage } from './pages/Dashboard';
|
||||
import { ModelsPage } from './pages/Models';
|
||||
import { ScenesPage } from './pages/Scenes';
|
||||
import { MotionsPage } from './pages/Motions';
|
||||
import { ReviewsPage } from './pages/Reviews';
|
||||
import { SongsPage } from './pages/Songs';
|
||||
@@ -14,6 +15,7 @@ export default function App() {
|
||||
<Route path="songs" element={<SongsPage />} />
|
||||
<Route path="models" element={<ModelsPage />} />
|
||||
<Route path="motions" element={<MotionsPage />} />
|
||||
<Route path="scenes" element={<ScenesPage />} />
|
||||
<Route path="reviews" element={<ReviewsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost, apiUpload } from './client';
|
||||
import type {
|
||||
Scene,
|
||||
SceneConfig,
|
||||
ScenePage,
|
||||
SceneUpdate,
|
||||
ContractVersion,
|
||||
Health,
|
||||
Model,
|
||||
@@ -12,6 +16,7 @@ import type {
|
||||
MotionUpdate,
|
||||
PendingPage,
|
||||
PublishStatus,
|
||||
SourceType,
|
||||
ReviewActionBody,
|
||||
ReviewPage,
|
||||
Song,
|
||||
@@ -233,3 +238,54 @@ export function getHealth(): Promise<Health> {
|
||||
export function getCatalogVersion(): Promise<ContractVersion> {
|
||||
return apiGet<ContractVersion>('/api/catalog/version');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 场景(T-WEB-015) */
|
||||
|
||||
export function listScenes(
|
||||
params: ListParams & { q?: string; status?: PublishStatus | '' },
|
||||
): Promise<ScenePage> {
|
||||
return apiGet<ScenePage>('/api/scenes', {
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
q: params.q,
|
||||
status: params.status,
|
||||
});
|
||||
}
|
||||
|
||||
export function getScene(id: number): Promise<Scene> {
|
||||
return apiGet<Scene>(`/api/scenes/${id}`);
|
||||
}
|
||||
|
||||
export interface SceneUploadInput {
|
||||
file: File;
|
||||
name: string;
|
||||
/** 物理/环境/出生点/相机;序列化成 JSON 字符串随 multipart 提交 */
|
||||
config: SceneConfig;
|
||||
sourceType: SourceType;
|
||||
sourceName: string;
|
||||
sourceUrl: string;
|
||||
license: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
/** 上传场景:multipart(file + name + config + 来源五元组) */
|
||||
export function uploadScene(input: SceneUploadInput): Promise<Scene> {
|
||||
const form = new FormData();
|
||||
form.append('file', input.file);
|
||||
form.append('name', input.name);
|
||||
form.append('config', JSON.stringify(input.config));
|
||||
form.append('sourceType', input.sourceType);
|
||||
form.append('sourceName', input.sourceName);
|
||||
form.append('sourceUrl', input.sourceUrl);
|
||||
form.append('license', input.license);
|
||||
form.append('author', input.author);
|
||||
return apiUpload<Scene>('/api/scenes', form);
|
||||
}
|
||||
|
||||
export function updateScene(id: number, payload: SceneUpdate): Promise<Scene> {
|
||||
return apiPatch<Scene>(`/api/scenes/${id}`, payload);
|
||||
}
|
||||
|
||||
export function deleteScene(id: number): Promise<void> {
|
||||
return apiDelete(`/api/scenes/${id}`);
|
||||
}
|
||||
|
||||
@@ -303,3 +303,59 @@ export interface ApiErrorBody {
|
||||
message?: string | string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 场景(v0.7.0 / T-WEB-015) */
|
||||
|
||||
export interface Vec3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface SceneWind {
|
||||
base: Vec3;
|
||||
gustAmplitude: number;
|
||||
gustFrequency: number;
|
||||
gustDirection: Vec3;
|
||||
space: 'world' | 'local';
|
||||
}
|
||||
|
||||
export interface SceneConfig {
|
||||
physics: {
|
||||
gravity: Vec3;
|
||||
wind: SceneWind;
|
||||
ground: { friction: number; restitution: number; slopeLimit: number };
|
||||
colliders: 'auto' | 'mesh' | 'none';
|
||||
};
|
||||
environment: { ambientColor: string; ambientIntensity: number; fogDensity: number };
|
||||
spawnPoints: Array<{ x: number; y: number; z: number; yaw: number }>;
|
||||
camera: { mode: 'follow' | 'fixed' | 'orbit'; distance: number; height: number };
|
||||
}
|
||||
|
||||
/** 场景:以 GLB 为主(不做 humanoid 重绑定、不转 VRM)+ 物理参数 */
|
||||
export interface Scene extends SourceFields {
|
||||
id: number;
|
||||
name: string;
|
||||
glbUrl: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
fileSize: number | null;
|
||||
fileSha256: string | null;
|
||||
conversionNote: string | null;
|
||||
config: SceneConfig | null;
|
||||
stats: Record<string, unknown> | null;
|
||||
status: PublishStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ScenePage {
|
||||
items: Scene[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface SceneUpdate {
|
||||
name?: string;
|
||||
config?: SceneConfig;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 场景物理参数的展示与默认值(T-WEB-015)。
|
||||
*
|
||||
* 放在 `.ts` 里而不是页面组件内:管理端的纯逻辑要与页面解耦,便于在服务端的
|
||||
* vitest 里直接断言(`.tsx` 需要 jsx 编译选项,服务端 tsconfig 不带)。
|
||||
* 默认值必须与契约 `data-models.md` 7.1 / 服务端 `scene-config.ts` 保持一致。
|
||||
*/
|
||||
import type { SceneConfig } from '../api/types';
|
||||
|
||||
export const DEFAULT_SCENE_CONFIG: SceneConfig = {
|
||||
physics: {
|
||||
gravity: { x: 0, y: -9.8, z: 0 },
|
||||
wind: {
|
||||
base: { x: 0, y: 0, z: 0 },
|
||||
gustAmplitude: 0,
|
||||
gustFrequency: 0.2,
|
||||
gustDirection: { x: 1, y: 0, z: 0 },
|
||||
space: 'world',
|
||||
},
|
||||
ground: { friction: 0.6, restitution: 0, slopeLimit: 45 },
|
||||
colliders: 'auto',
|
||||
},
|
||||
environment: { ambientColor: '#8fb4ff', ambientIntensity: 1, fogDensity: 0 },
|
||||
spawnPoints: [{ x: 0, y: 0, z: 0, yaw: 0 }],
|
||||
camera: { mode: 'follow', distance: 4.5, height: 1.6 },
|
||||
};
|
||||
|
||||
/**
|
||||
* 列表里的物理摘要:一眼看出重力与风(服务端只存参数、仿真在客户端,
|
||||
* 因此列表必须把这两个最关键的物理量显示出来)。
|
||||
*/
|
||||
export function physicsSummary(config: SceneConfig | null): string {
|
||||
if (!config) return '—';
|
||||
const { gravity, wind } = config.physics;
|
||||
const windSpeed = Math.hypot(wind.base.x, wind.base.y, wind.base.z);
|
||||
const gust =
|
||||
wind.gustAmplitude > 0
|
||||
? ` + 阵风 ${wind.gustAmplitude.toFixed(1)}m/s@${wind.gustFrequency}Hz`
|
||||
: '';
|
||||
return `重力 ${gravity.y.toFixed(1)} | 风 ${windSpeed.toFixed(1)}m/s${gust}`;
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* 场景管理页(T-WEB-015)。
|
||||
*
|
||||
* 与模型/动作页的差异:场景**以 GLB 为主**且携带**物理参数**,因此本页有三块:
|
||||
* 1. 列表:缩略图 + 物理摘要(重力 / 风)+ 状态;
|
||||
* 2. 上传:GLB 文件 + 名称 + 来源五元组 + 物理参数(重力、风向动态、地面、碰撞体、环境);
|
||||
* 3. 详情:复用管理端 3D 预览(GLB/glTF/VRM 同一加载器)+ 参数可视化(可编辑后保存)。
|
||||
*
|
||||
* 服务端只存参数、不跑物理;这里的"物理"只是**参数编辑与展示**。
|
||||
*/
|
||||
import { lazy, Suspense, useState, type FormEvent } from 'react';
|
||||
|
||||
import { deleteScene, listScenes, updateScene, uploadScene } from '../api/endpoints';
|
||||
import { errorMessage } from '../api/client';
|
||||
import type { PublishStatus, Scene, SceneConfig, SourceType } from '../api/types';
|
||||
import { SOURCE_TYPES } from '../api/types';
|
||||
import { formatBytes, formatDateTime } from '../lib/format';
|
||||
import { DEFAULT_SCENE_CONFIG, physicsSummary } from '../lib/scene-physics';
|
||||
import { SOURCE_TYPE_LABELS, STATUS_OPTIONS } from '../lib/labels';
|
||||
import { hasErrors, requireText, validateSourceFields, type FieldErrors } from '../lib/validation';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
import { Empty, ErrorBox, Loading, StatusBadge } from '../components/ui';
|
||||
|
||||
const VrmPreview = lazy(async () => {
|
||||
const module = await import('../components/VrmPreview');
|
||||
return { default: module.VrmPreview };
|
||||
});
|
||||
|
||||
interface SceneFormState {
|
||||
name: string;
|
||||
sourceType: string;
|
||||
sourceName: string;
|
||||
sourceUrl: string;
|
||||
license: string;
|
||||
author: string;
|
||||
gravityY: string;
|
||||
windX: string;
|
||||
windY: string;
|
||||
windZ: string;
|
||||
gustAmplitude: string;
|
||||
gustFrequency: string;
|
||||
gustDirectionX: string;
|
||||
groundFriction: string;
|
||||
groundSlopeLimit: string;
|
||||
colliders: 'auto' | 'mesh' | 'none';
|
||||
ambientColor: string;
|
||||
ambientIntensity: string;
|
||||
cameraMode: 'follow' | 'fixed' | 'orbit';
|
||||
cameraDistance: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: SceneFormState = {
|
||||
name: '',
|
||||
sourceType: '',
|
||||
sourceName: '',
|
||||
sourceUrl: '',
|
||||
license: '',
|
||||
author: '',
|
||||
gravityY: '-9.8',
|
||||
windX: '0',
|
||||
windY: '0',
|
||||
windZ: '0',
|
||||
gustAmplitude: '0',
|
||||
gustFrequency: '0.2',
|
||||
gustDirectionX: '1',
|
||||
groundFriction: '0.6',
|
||||
groundSlopeLimit: '45',
|
||||
colliders: 'auto',
|
||||
ambientColor: '#8fb4ff',
|
||||
ambientIntensity: '1',
|
||||
cameraMode: 'follow',
|
||||
cameraDistance: '4.5',
|
||||
};
|
||||
|
||||
function numberOf(value: string, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** 表单 -> 契约 SceneConfig(越界由服务端再收敛一次) */
|
||||
function toConfig(form: SceneFormState): SceneConfig {
|
||||
return {
|
||||
physics: {
|
||||
gravity: { x: 0, y: numberOf(form.gravityY, -9.8), z: 0 },
|
||||
wind: {
|
||||
base: {
|
||||
x: numberOf(form.windX, 0),
|
||||
y: numberOf(form.windY, 0),
|
||||
z: numberOf(form.windZ, 0),
|
||||
},
|
||||
gustAmplitude: numberOf(form.gustAmplitude, 0),
|
||||
gustFrequency: numberOf(form.gustFrequency, 0.2),
|
||||
gustDirection: { x: numberOf(form.gustDirectionX, 1), y: 0, z: 0 },
|
||||
space: 'world',
|
||||
},
|
||||
ground: {
|
||||
friction: numberOf(form.groundFriction, 0.6),
|
||||
restitution: 0,
|
||||
slopeLimit: numberOf(form.groundSlopeLimit, 45),
|
||||
},
|
||||
colliders: form.colliders,
|
||||
},
|
||||
environment: {
|
||||
ambientColor: /^#[0-9a-fA-F]{6}$/.test(form.ambientColor) ? form.ambientColor : '#8fb4ff',
|
||||
ambientIntensity: numberOf(form.ambientIntensity, 1),
|
||||
fogDensity: 0,
|
||||
},
|
||||
spawnPoints: [{ x: 0, y: 0, z: 0, yaw: 0 }],
|
||||
camera: {
|
||||
mode: form.cameraMode,
|
||||
distance: numberOf(form.cameraDistance, 4.5),
|
||||
height: 1.6,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field-label">
|
||||
{label}
|
||||
{hint ? <em className="muted small"> {hint}</em> : null}
|
||||
</span>
|
||||
{children}
|
||||
{error ? <span className="field-error">{error}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SceneUploadForm({ onSaved }: { onSaved: () => void }): JSX.Element {
|
||||
const [form, setForm] = useState<SceneFormState>(EMPTY_FORM);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const patch = (next: Partial<SceneFormState>): void => setForm((previous) => ({ ...previous, ...next }));
|
||||
|
||||
const submit = async (event: FormEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
const nextErrors: FieldErrors = {};
|
||||
requireText(form.name, 'name', '名称', nextErrors);
|
||||
validateSourceFields(
|
||||
{ ...form, sourceType: form.sourceType as SourceType | '' },
|
||||
nextErrors,
|
||||
);
|
||||
setErrors(nextErrors);
|
||||
if (hasErrors(nextErrors) || !file) return;
|
||||
setSaving(true);
|
||||
setServerError(null);
|
||||
try {
|
||||
await uploadScene({
|
||||
file,
|
||||
name: form.name.trim(),
|
||||
config: toConfig(form),
|
||||
sourceType: form.sourceType as (typeof SOURCE_TYPES)[number],
|
||||
sourceName: form.sourceName.trim(),
|
||||
sourceUrl: form.sourceUrl.trim(),
|
||||
license: form.license.trim(),
|
||||
author: form.author.trim(),
|
||||
});
|
||||
setForm(EMPTY_FORM);
|
||||
setFile(null);
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
setServerError(errorMessage(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="panel-body" onSubmit={submit}>
|
||||
<div className="form-grid">
|
||||
<Field label="场景文件 file" hint=".glb 为主(.gltf/.zip/.fbx/.pmx 服务端转 GLB)" error={errors.file}>
|
||||
<input type="file" accept=".glb,.gltf,.zip,.fbx,.pmx" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||
</Field>
|
||||
<Field label="名称 name" error={errors.name}>
|
||||
<input value={form.name} onChange={(event) => patch({ name: event.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<h4 className="form-section">物理参数(服务端只存参数,仿真在客户端)</h4>
|
||||
<div className="form-grid">
|
||||
<Field label="重力 Y (m/s²)" hint="默认 -9.8">
|
||||
<input value={form.gravityY} onChange={(event) => patch({ gravityY: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="碰撞体 colliders">
|
||||
<select value={form.colliders} onChange={(event) => patch({ colliders: event.target.value as SceneFormState['colliders'] })}>
|
||||
<option value="auto">auto(按 GLB 命名约定)</option>
|
||||
<option value="mesh">mesh(三角网)</option>
|
||||
<option value="none">none(不使用)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="风 基础向量 X/Y/Z (m/s)">
|
||||
<div className="inline-inputs">
|
||||
<input value={form.windX} onChange={(event) => patch({ windX: event.target.value })} />
|
||||
<input value={form.windY} onChange={(event) => patch({ windY: event.target.value })} />
|
||||
<input value={form.windZ} onChange={(event) => patch({ windZ: event.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="阵风幅度 (m/s) / 频率 (Hz)">
|
||||
<div className="inline-inputs">
|
||||
<input value={form.gustAmplitude} onChange={(event) => patch({ gustAmplitude: event.target.value })} />
|
||||
<input value={form.gustFrequency} onChange={(event) => patch({ gustFrequency: event.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="地面摩擦 / 坡度上限(度)">
|
||||
<div className="inline-inputs">
|
||||
<input value={form.groundFriction} onChange={(event) => patch({ groundFriction: event.target.value })} />
|
||||
<input value={form.groundSlopeLimit} onChange={(event) => patch({ groundSlopeLimit: event.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="环境光颜色 / 强度">
|
||||
<div className="inline-inputs">
|
||||
<input value={form.ambientColor} onChange={(event) => patch({ ambientColor: event.target.value })} />
|
||||
<input value={form.ambientIntensity} onChange={(event) => patch({ ambientIntensity: event.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<h4 className="form-section">来源五元组(必填)</h4>
|
||||
<div className="form-grid">
|
||||
<Field label="来源类型 sourceType" error={errors.sourceType}>
|
||||
<select value={form.sourceType} onChange={(event) => patch({ sourceType: event.target.value })}>
|
||||
<option value="">请选择</option>
|
||||
{SOURCE_TYPES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{SOURCE_TYPE_LABELS[value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="来源名称 sourceName" error={errors.sourceName}>
|
||||
<input value={form.sourceName} onChange={(event) => patch({ sourceName: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="来源链接 sourceUrl" error={errors.sourceUrl}>
|
||||
<input value={form.sourceUrl} onChange={(event) => patch({ sourceUrl: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="许可 license" error={errors.license}>
|
||||
<input value={form.license} onChange={(event) => patch({ license: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="作者 author" error={errors.author}>
|
||||
<input value={form.author} onChange={(event) => patch({ author: event.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{serverError ? <ErrorBox message={serverError} /> : null}
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? '上传中…' : '上传场景(待审核)'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** 详情:3D 预览 + 参数编辑 */
|
||||
function SceneDetail({ scene, onChanged }: { scene: Scene; onChanged: () => void }): JSX.Element {
|
||||
const initial = scene.config ?? DEFAULT_SCENE_CONFIG;
|
||||
const [form, setForm] = useState<SceneFormState>({
|
||||
...EMPTY_FORM,
|
||||
name: scene.name,
|
||||
gravityY: String(initial.physics.gravity.y),
|
||||
windX: String(initial.physics.wind.base.x),
|
||||
windY: String(initial.physics.wind.base.y),
|
||||
windZ: String(initial.physics.wind.base.z),
|
||||
gustAmplitude: String(initial.physics.wind.gustAmplitude),
|
||||
gustFrequency: String(initial.physics.wind.gustFrequency),
|
||||
gustDirectionX: String(initial.physics.wind.gustDirection.x),
|
||||
groundFriction: String(initial.physics.ground.friction),
|
||||
groundSlopeLimit: String(initial.physics.ground.slopeLimit),
|
||||
colliders: initial.physics.colliders,
|
||||
ambientColor: initial.environment.ambientColor,
|
||||
ambientIntensity: String(initial.environment.ambientIntensity),
|
||||
cameraMode: initial.camera.mode,
|
||||
cameraDistance: String(initial.camera.distance),
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await updateScene(scene.id, { name: form.name.trim(), config: toConfig(form) });
|
||||
setMessage('已保存');
|
||||
onChanged();
|
||||
} catch (cause) {
|
||||
setError(errorMessage(cause));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="scene-detail">
|
||||
<div className="scene-preview">
|
||||
{scene.glbUrl ? (
|
||||
<Suspense fallback={<Loading text="正在加载 3D 预览…" />}>
|
||||
<VrmPreview fileUrl={scene.glbUrl} name={scene.name} height={320} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<Empty text="该场景没有 GLB 文件" />
|
||||
)}
|
||||
</div>
|
||||
<div className="scene-params">
|
||||
<div className="kv-grid">
|
||||
<div className="kv">
|
||||
<span className="muted">文件</span>
|
||||
<code>{formatBytes(scene.fileSize)}</code>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span className="muted">物理摘要</span>
|
||||
<code>{physicsSummary(scene.config)}</code>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span className="muted">转换说明</span>
|
||||
<code className="break">{scene.conversionNote ?? '—'}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<Field label="名称">
|
||||
<input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="重力 Y (m/s²)">
|
||||
<input value={form.gravityY} onChange={(event) => setForm({ ...form, gravityY: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="风 X/Y/Z (m/s)">
|
||||
<div className="inline-inputs">
|
||||
<input value={form.windX} onChange={(event) => setForm({ ...form, windX: event.target.value })} />
|
||||
<input value={form.windY} onChange={(event) => setForm({ ...form, windY: event.target.value })} />
|
||||
<input value={form.windZ} onChange={(event) => setForm({ ...form, windZ: event.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="阵风幅度 / 频率">
|
||||
<div className="inline-inputs">
|
||||
<input value={form.gustAmplitude} onChange={(event) => setForm({ ...form, gustAmplitude: event.target.value })} />
|
||||
<input value={form.gustFrequency} onChange={(event) => setForm({ ...form, gustFrequency: event.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="地面摩擦 / 坡度上限">
|
||||
<div className="inline-inputs">
|
||||
<input value={form.groundFriction} onChange={(event) => setForm({ ...form, groundFriction: event.target.value })} />
|
||||
<input value={form.groundSlopeLimit} onChange={(event) => setForm({ ...form, groundSlopeLimit: event.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="碰撞体">
|
||||
<select
|
||||
value={form.colliders}
|
||||
onChange={(event) => setForm({ ...form, colliders: event.target.value as SceneFormState['colliders'] })}
|
||||
>
|
||||
<option value="auto">auto</option>
|
||||
<option value="mesh">mesh</option>
|
||||
<option value="none">none</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
{message ? <p className="muted small">{message}</p> : null}
|
||||
{error ? <ErrorBox message={error} /> : null}
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-primary" disabled={saving} onClick={save}>
|
||||
{saving ? '保存中…' : '保存参数'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScenesPage(): JSX.Element {
|
||||
const [q, setQ] = useState('');
|
||||
const [status, setStatus] = useState<PublishStatus | ''>('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [detailId, setDetailId] = useState<number | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
|
||||
const state = useAsync(() => listScenes({ q, status, page, pageSize: 20 }), [q, status, page]);
|
||||
const items = state.data?.items ?? [];
|
||||
const detail = items.find((item) => item.id === detailId) ?? null;
|
||||
|
||||
const remove = async (scene: Scene): Promise<void> => {
|
||||
setBusyId(scene.id);
|
||||
try {
|
||||
await deleteScene(scene.id);
|
||||
state.reload();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1>场景管理</h1>
|
||||
<p className="muted">
|
||||
场景以 <strong>GLB</strong> 为主(不做 humanoid 重绑定、不转 VRM),并携带物理参数:
|
||||
重力、风向动态、地面与碰撞体。服务端只存参数,仿真在 Windows 客户端。
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setUploadOpen((value) => !value)}>
|
||||
{uploadOpen ? '收起上传' : '上传场景'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{uploadOpen ? (
|
||||
<section className="panel">
|
||||
<header className="panel-head">
|
||||
<h2>上传场景</h2>
|
||||
<span className="muted">POST /api/scenes(multipart)</span>
|
||||
</header>
|
||||
<SceneUploadForm
|
||||
onSaved={() => {
|
||||
setUploadOpen(false);
|
||||
state.reload();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="panel">
|
||||
<header className="panel-head">
|
||||
<h2>场景列表</h2>
|
||||
<div className="filters">
|
||||
<input
|
||||
placeholder="搜索名称 / 作者"
|
||||
value={q}
|
||||
onChange={(event) => {
|
||||
setQ(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(event) => {
|
||||
setStatus(event.target.value as PublishStatus | '');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{state.error ? <ErrorBox message={state.error} onRetry={state.reload} /> : null}
|
||||
{state.loading && !state.data ? <Loading text="正在加载场景…" /> : null}
|
||||
{!state.loading && !state.error && items.length === 0 ? (
|
||||
<Empty text="还没有场景,点击右上角「上传场景」(.glb 为主)。" />
|
||||
) : null}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>预览</th>
|
||||
<th>名称</th>
|
||||
<th>物理参数</th>
|
||||
<th>体积</th>
|
||||
<th>状态</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((scene) => (
|
||||
<tr key={scene.id}>
|
||||
<td>
|
||||
{scene.thumbnailUrl ? (
|
||||
<img
|
||||
className="thumb-40 thumb-clickable"
|
||||
src={scene.thumbnailUrl}
|
||||
alt={`${scene.name} 缩略图`}
|
||||
title="点击查看详情与参数"
|
||||
onClick={() => setDetailId(detailId === scene.id ? null : scene.id)}
|
||||
/>
|
||||
) : (
|
||||
<span className="muted small">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="cell-title">{scene.name}</div>
|
||||
<div className="muted small">
|
||||
#{scene.id} · {formatDateTime(scene.updatedAt)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="mono small">{physicsSummary(scene.config)}</td>
|
||||
<td className="mono">{formatBytes(scene.fileSize)}</td>
|
||||
<td>
|
||||
<StatusBadge status={scene.status} />
|
||||
</td>
|
||||
<td className="col-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
onClick={() => setDetailId(detailId === scene.id ? null : scene.id)}
|
||||
>
|
||||
{detailId === scene.id ? '收起详情' : '详情 / 预览'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
disabled={busyId === scene.id}
|
||||
onClick={() => remove(scene)}
|
||||
title="DELETE /api/scenes/:id(软删除)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detail ? (
|
||||
<div className="panel-body scene-detail-panel">
|
||||
<header className="panel-head">
|
||||
<h3>场景详情 #{detail.id}</h3>
|
||||
<span className="muted">参数变更走 PATCH /api/scenes/:id;状态变更走审核页</span>
|
||||
</header>
|
||||
<SceneDetail scene={detail} onChanged={state.reload} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1539,3 +1539,56 @@ textarea {
|
||||
font-size: 12px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
/* ---- 场景管理(T-WEB-015) ---- */
|
||||
.scene-detail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.scene-detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.scene-detail-panel {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.scene-preview {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: #181a20;
|
||||
}
|
||||
|
||||
.inline-inputs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.inline-inputs input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin: 14px 0 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filters input,
|
||||
.filters select {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "opendance-web-server",
|
||||
"version": "0.2.0",
|
||||
"version": "0.7.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "opendance-web-server",
|
||||
"version": "0.2.0",
|
||||
"version": "0.7.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.12",
|
||||
@@ -35,7 +35,7 @@
|
||||
"vitest": "^3.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/core": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opendance-web-server",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.2",
|
||||
"description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -16,13 +16,15 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:smoke": "node test/smoke.mjs",
|
||||
"contracts:check": "node scripts/check-contracts.mjs && node scripts/check-routes.mjs",
|
||||
"verify": "npm run contracts:check && npm run typecheck && npm run test && npm run build",
|
||||
"contracts:check": "node scripts/check-contracts.mjs && node scripts/check-routes.mjs && node scripts/check-version.mjs",
|
||||
"verify": "npm run contracts:check && npm run typecheck && npm run test && npm run build && npm run bootstrap:check",
|
||||
"typeorm": "node scripts/migrate.mjs",
|
||||
"migration:generate": "node scripts/migrate.mjs generate",
|
||||
"migration:run": "node scripts/migrate.mjs run",
|
||||
"migration:revert": "node scripts/migrate.mjs revert",
|
||||
"migration:show": "node scripts/migrate.mjs show"
|
||||
"migration:show": "node scripts/migrate.mjs show",
|
||||
"version:check": "node scripts/check-version.mjs",
|
||||
"bootstrap:check": "node scripts/check-bootstrap.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.12",
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 启动冒烟检查:**编译产物能不能真正 boot 起来**(不需要数据库)。
|
||||
*
|
||||
* 为什么必须有这一层:`src/catalog/catalog.module.ts` 曾经漏了 `ScenesModule`,
|
||||
* 而 `CatalogService` 注入了 `ScenesService` —— Nest 的 DI 是**按模块**解析的,
|
||||
* 于是 `node dist/main.js` 在 bootstrap 阶段直接抛 `UnknownDependenciesException`,
|
||||
* **服务根本起不来**。但当时所有检查都是绿的:
|
||||
* - `nest build` 通过(类型没问题);
|
||||
* - 258 项单元测试通过(它们直接 new 服务 / 测纯函数,从不编译 AppModule);
|
||||
* - 打包脚本只校验文件清单与压缩包完整性。
|
||||
* 结果就是 v0.7.0 / v0.7.1 的产物「装得上、起不来」。
|
||||
*
|
||||
* 本脚本以**正式部署的方式**(systemd 的 `ExecStart=node dist/main.js`,
|
||||
* 不带任何 npm 环境变量)拉起真实产物,然后:
|
||||
* 1. 断言日志里**没有**依赖解析类错误(UnknownDependenciesException 等)→ 模块图可解析;
|
||||
* 2. 断言日志里的「应用版本 vX.Y.Z」等于 `package.json` 的 version
|
||||
* → 原生部署下版本号解析正确(原先会退化成硬编码的 0.1.0);
|
||||
* 3. 断言启动确实**推进到了数据库连接阶段** → 证明 Nest 已把整个 DI 图解析完,
|
||||
* 而不是中途失败。
|
||||
*
|
||||
* 不需要数据库:TypeORM 连不上时日志里会出现明确的连接错误,那就够了 ——
|
||||
* 本脚本的职责是「启动能否走到连库这一步」,真连库的端到端验收由
|
||||
* `test/smoke.mjs`(部署好服务端后运行)负责。
|
||||
*
|
||||
* 用法:node scripts/check-bootstrap.mjs
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const serverDir = join(here, '..');
|
||||
const entry = join(serverDir, 'dist', 'main.js');
|
||||
|
||||
const EXPECTED_VERSION = JSON.parse(
|
||||
readFileSync(join(serverDir, 'package.json'), 'utf8'),
|
||||
).version;
|
||||
|
||||
/** 依赖解析失败的标志(任一出现即失败) */
|
||||
const DI_ERROR_PATTERNS = [
|
||||
/UnknownDependenciesException/,
|
||||
/Nest can't resolve dependencies/i,
|
||||
/cannot resolve dependencies/i,
|
||||
/Nest can't resolve/i,
|
||||
];
|
||||
|
||||
/** 走到数据库连接阶段的标志 */
|
||||
const DB_STAGE_PATTERNS = [
|
||||
/Unable to connect to the database/i,
|
||||
/ECONNREFUSED/,
|
||||
/getaddrinfo/i,
|
||||
/Access denied for user/i,
|
||||
/Unknown database/i,
|
||||
/服务已?启动/,
|
||||
/已启动,监听/,
|
||||
];
|
||||
|
||||
const TIMEOUT_MS = Number(process.env.BOOTSTRAP_TIMEOUT_MS ?? 40_000);
|
||||
|
||||
function freePort(base) {
|
||||
// 用一个不常见的端口即可;连库失败是预期的,端口只要不撞上真实服务
|
||||
return base + Math.floor(Math.random() * 200);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'opendance-boot-'));
|
||||
|
||||
// 模拟 systemd:删掉 npm_* 环境变量(ExecStart=node dist/main.js 时它们不存在)
|
||||
const env = { ...process.env };
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith('npm_')) delete env[key];
|
||||
}
|
||||
delete env.APP_VERSION; // 也不允许用显式覆盖,必须走 package.json
|
||||
Object.assign(env, {
|
||||
NODE_ENV: 'development', // 免得 validateEnv 在生产模式直接 exit(1)
|
||||
HOST: '127.0.0.1',
|
||||
PORT: String(freePort(34000)),
|
||||
ADMIN_PORT: String(freePort(35000)),
|
||||
DB_HOST: '127.0.0.1',
|
||||
DB_PORT: '3399', // 故意指向一个没有 MySQL 的端口
|
||||
DB_USER: 'opendance_probe',
|
||||
DB_PASSWORD: 'opendance_probe',
|
||||
DB_NAME: 'opendance_probe',
|
||||
DB_LOGGING: 'false',
|
||||
STORAGE_DIR: join(scratch, 'storage'),
|
||||
ADMIN_DIR: join(scratch, 'admin'),
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [entry], {
|
||||
cwd: serverDir,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let output = '';
|
||||
let finished = false;
|
||||
|
||||
const decide = () => {
|
||||
if (finished) return null;
|
||||
const diError = DI_ERROR_PATTERNS.find((re) => re.test(output));
|
||||
if (diError) {
|
||||
finished = true;
|
||||
return { ok: false, reason: 'di', pattern: String(diError) };
|
||||
}
|
||||
const versionLine = output.match(/应用版本 v(\S+)/);
|
||||
const dbStage = DB_STAGE_PATTERNS.some((re) => re.test(output));
|
||||
if (versionLine && dbStage) {
|
||||
finished = true;
|
||||
return { ok: true, version: versionLine[1] };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const result = await new Promise((resolvePromise) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolvePromise(decide() ?? { ok: false, reason: 'timeout' });
|
||||
}, TIMEOUT_MS);
|
||||
|
||||
const onData = (chunk) => {
|
||||
output += chunk.toString();
|
||||
const decided = decide();
|
||||
if (decided) {
|
||||
clearTimeout(timer);
|
||||
child.kill('SIGKILL');
|
||||
resolvePromise(decided);
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on('data', onData);
|
||||
child.stderr.on('data', onData);
|
||||
child.on('exit', () => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise(decide() ?? { ok: false, reason: 'exit' });
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise({ ok: false, reason: 'spawn', error: error.message });
|
||||
});
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
if (result.version !== EXPECTED_VERSION) {
|
||||
console.error(
|
||||
`✗ 启动日志里的应用版本 v${result.version} 与 web-server/package.json 的 ${EXPECTED_VERSION} 不一致`,
|
||||
);
|
||||
console.error(' (原生部署下版本号必须从 package.json 解析,不能依赖 npm 环境变量)');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`✓ 启动冒烟通过:模块依赖图可解析(无 UnknownDependenciesException)、` +
|
||||
`应用版本 v${result.version} 与 package.json 一致、已推进到数据库连接阶段`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('✗ 启动冒烟失败:');
|
||||
if (result.reason === 'di') {
|
||||
console.error(` 依赖解析错误(匹配 ${result.pattern})—— 有模块忘记 import 提供者了。`);
|
||||
console.error(' 提示:检查各 @Module 的 imports 是否覆盖了构造函数里注入的服务。');
|
||||
} else if (result.reason === 'timeout') {
|
||||
console.error(' 进程既没有报依赖错误,也没有推进到数据库连接阶段(超时)。');
|
||||
} else if (result.reason === 'exit') {
|
||||
console.error(' 进程提前退出,且日志里既没有依赖错误也没有数据库阶段标志。');
|
||||
} else if (result.reason === 'spawn') {
|
||||
console.error(` 无法启动 ${entry}:${result.error}`);
|
||||
}
|
||||
console.error('\n---- 进程输出(末尾 40 行)----');
|
||||
console.error(output.split('\n').slice(-40).join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 版本号一致性校验(文档 §11.2「单一来源 / 可核对」)。
|
||||
*
|
||||
* 守护的缺陷:`/health` 曾经用
|
||||
* process.env.npm_package_version ?? '0.1.0'
|
||||
* 而正式部署方式是原生服务(`ExecStart=node dist/main.js`),npm 不参与,
|
||||
* 该环境变量为 undefined —— 于是真实部署里 `/health` 永远报 **0.1.0**,
|
||||
* 与管理端左下角展示的 vX.Y.Z 不一致。
|
||||
*
|
||||
* 校验项:
|
||||
* 1. web-server/package.json 的 version === project.yaml 的 version
|
||||
* 2. web-server/admin/package.json 的 version === web-server/package.json 的 version
|
||||
* 3. src/config/app-version.ts 存在,且 health.controller 通过它取版本
|
||||
* 4. health.controller 里不得再出现硬编码的版本回退(如 `?? '0.1.0'`)
|
||||
* 5. 若管理端已构建(web-server/admin/dist 存在),其 JS 包内必须含 `v<version>`
|
||||
*
|
||||
* 用法:node scripts/check-version.mjs
|
||||
*/
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const serverDir = join(here, '..');
|
||||
const repoDir = join(serverDir, '..');
|
||||
|
||||
const problems = [];
|
||||
const notes = [];
|
||||
|
||||
function readJson(file) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(file, 'utf8'));
|
||||
} catch (error) {
|
||||
problems.push(`无法解析 ${file}:${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readYamlVersion(file) {
|
||||
if (!existsSync(file)) {
|
||||
problems.push(`缺少 ${file}`);
|
||||
return null;
|
||||
}
|
||||
const line = readFileSync(file, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.find((l) => l.trimStart().startsWith('version:'));
|
||||
if (!line) {
|
||||
problems.push(`无法在 ${file} 中定位 version:`);
|
||||
return null;
|
||||
}
|
||||
return line.slice(line.indexOf(':') + 1).trim();
|
||||
}
|
||||
|
||||
// ---- 1. package.json 与 project.yaml ----
|
||||
const serverPkg = readJson(join(serverDir, 'package.json'));
|
||||
const yamlVersion = readYamlVersion(join(repoDir, 'project.yaml'));
|
||||
if (serverPkg && yamlVersion && serverPkg.version !== yamlVersion) {
|
||||
problems.push(`版本不一致:web-server/package.json=${serverPkg.version} vs project.yaml=${yamlVersion}`);
|
||||
}
|
||||
|
||||
// ---- 2. 管理端 package.json 与之同源 ----
|
||||
const adminPkgPath = join(serverDir, 'admin', 'package.json');
|
||||
if (existsSync(adminPkgPath)) {
|
||||
const adminPkg = readJson(adminPkgPath);
|
||||
if (serverPkg && adminPkg && adminPkg.version !== serverPkg.version) {
|
||||
problems.push(
|
||||
`版本不一致:web-server/admin/package.json=${adminPkg.version} vs web-server/package.json=${serverPkg.version}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 3 & 4. /health 的版本必须来自 package.json,不能再硬编码 ----
|
||||
const healthPath = join(serverDir, 'src', 'health', 'health.controller.ts');
|
||||
const versionModulePath = join(serverDir, 'src', 'config', 'app-version.ts');
|
||||
if (!existsSync(versionModulePath)) {
|
||||
problems.push('缺少 src/config/app-version.ts(版本号运行时唯一解析点)');
|
||||
}
|
||||
if (!existsSync(healthPath)) {
|
||||
problems.push('缺少 src/health/health.controller.ts');
|
||||
} else {
|
||||
const health = readFileSync(healthPath, 'utf8');
|
||||
if (!health.includes('resolveAppVersion')) {
|
||||
problems.push('health.controller.ts 未使用 resolveAppVersion(),/health 的版本可能不是单一来源');
|
||||
}
|
||||
// 硬编码版本回退:`?? 'x.y.z'` 或 `|| "x.y.z"`
|
||||
const hardcoded = health.match(/\?\?\s*['"]\d+\.\d+\.\d+['"]|\|\|\s*['"]\d+\.\d+\.\d+['"]/);
|
||||
if (hardcoded) {
|
||||
problems.push(
|
||||
`health.controller.ts 存在硬编码版本回退 ${hardcoded[0]};应使用 resolveAppVersion()(否则原生部署下 /health 会报错版本)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 5. 管理端构建产物必须带上与 package.json 相同的版本 ----
|
||||
const adminDist = join(serverDir, 'admin', 'dist', 'assets');
|
||||
if (serverPkg && existsSync(adminDist)) {
|
||||
const files = readdirSync(adminDist).filter((f) => f.endsWith('.js'));
|
||||
const found = new Set();
|
||||
for (const file of files) {
|
||||
const text = readFileSync(join(adminDist, file), 'utf8');
|
||||
for (const m of text.matchAll(/v\d+\.\d+\.\d+/g)) found.add(m[0]);
|
||||
}
|
||||
const expected = `v${serverPkg.version}`;
|
||||
if (found.size === 0) {
|
||||
problems.push(`管理端构建产物中找不到任何 vX.Y.Z(期望 ${expected})`);
|
||||
} else if (!found.has(expected)) {
|
||||
problems.push(`管理端构建产物中的版本 ${[...found].join('/')} 与 package.json 的 ${expected} 不一致`);
|
||||
} else {
|
||||
notes.push(`管理端构建产物含 ${expected}`);
|
||||
}
|
||||
} else {
|
||||
notes.push('管理端尚未构建,跳过产物内版本校验(打包脚本会在构建后再次执行本脚本)');
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.error('✗ 版本一致性校验失败:');
|
||||
for (const p of problems) console.error(` - ${p}`);
|
||||
console.error('\n请同步 web-server/package.json、web-server/admin/package.json、project.yaml;');
|
||||
console.error('/health 的版本必须来自 src/config/app-version.ts(读 package.json),不得硬编码。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const n of notes) console.log(` · ${n}`);
|
||||
console.log(
|
||||
`✓ 版本一致(${serverPkg?.version}):web-server/package.json = admin/package.json = project.yaml,` +
|
||||
`/health 由 resolveAppVersion() 读取 package.json`,
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import { Motion } from '../entities/motion.entity';
|
||||
import { Song } from '../entities/song.entity';
|
||||
import { ModelsModule } from '../models/models.module';
|
||||
import { MotionsModule } from '../motions/motions.module';
|
||||
import { ScenesModule } from '../scenes/scenes.module';
|
||||
import { SongsModule } from '../songs/songs.module';
|
||||
import { CatalogController } from './catalog.controller';
|
||||
import { CatalogService } from './catalog.service';
|
||||
@@ -15,6 +16,11 @@ import { CatalogService } from './catalog.service';
|
||||
SongsModule,
|
||||
MotionsModule,
|
||||
ModelsModule,
|
||||
// CatalogService 注入 ScenesService(catalog/export 要下发 scenes[])。
|
||||
// Nest 的 DI 是**按模块**解析的:不在这里 import,bootstrap 时会直接
|
||||
// UnknownDependenciesException,服务根本起不来 —— 由 scripts/check-bootstrap.mjs
|
||||
// 在构建后强制拦截(v0.7.0/v0.7.1 就是漏了这一步才把「装得上、起不来」的产物发了出去)。
|
||||
ScenesModule,
|
||||
],
|
||||
controllers: [CatalogController],
|
||||
providers: [CatalogService],
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* 应用版本号的**运行时唯一解析点**(文档 §11.1 / §11.2)。
|
||||
*
|
||||
* 为什么不能只用 `process.env.npm_package_version`:
|
||||
* 那个环境变量**只在通过 npm 脚本启动时**才存在(`npm start` / `npm run start:prod`)。
|
||||
* 而本项目的正式部署方式是**原生服务**(systemd / OpenRC / initd),
|
||||
* `ExecStart=node dist/main.js` —— npm 不参与,于是 `npm_package_version` 为 undefined,
|
||||
* 原来那句 `?? '0.1.0'` 会让 `GET /health` 在真实部署里**永远报 0.1.0**,
|
||||
* 与「管理端左下角展示 vX.Y.Z、且必须与 /health 一致」的硬性要求直接冲突。
|
||||
*
|
||||
* 因此改为从 **`package.json` 实体文件**读取(构建期注入管理端的也是这同一份文件),
|
||||
* 依次尝试多个候选路径,取第一个「包名匹配」的结果:
|
||||
*
|
||||
* 1. `APP_VERSION` 环境变量(显式覆盖,给运维留一个不重新打包即可改的口子)
|
||||
* 2. 运行入口所在目录的 `../package.json`(`dist/main.js` → `web-server/package.json`)
|
||||
* 3. 进程工作目录下的 `package.json`(systemd 的 `WorkingDirectory=.../web-server`)
|
||||
* 4. `npm_package_version`(npm 脚本启动时的兜底)
|
||||
* 5. 都没有则 `'0.0.0'` —— 显式可见的“未知版本”,绝不伪装成某个具体版本
|
||||
*
|
||||
* 候选路径都必须通过 [parsePackageVersion] 的**包名校验**,
|
||||
* 避免把上层仓库或别的 Node 项目的 `package.json` 误当成自己。
|
||||
*/
|
||||
|
||||
/** 本包的 `name`,用于校验读到的 package.json 确实是本项目 */
|
||||
export const PACKAGE_NAME = 'opendance-web-server';
|
||||
|
||||
/** 无法解析时的显式占位值(不是任何真实版本,便于一眼看出异常) */
|
||||
export const UNKNOWN_VERSION = '0.0.0';
|
||||
|
||||
/**
|
||||
* 从 `package.json` 文本中解析版本号。
|
||||
* **纯函数**:只在「JSON 合法 + 包名匹配 + version 形如 x.y.z」时才返回版本,
|
||||
* 否则返回 null(调用方继续尝试下一个候选路径)。
|
||||
*/
|
||||
export function parsePackageVersion(content: string): string | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
const pkg = parsed as Record<string, unknown>;
|
||||
if (pkg.name !== PACKAGE_NAME) return null;
|
||||
const version = pkg.version;
|
||||
if (typeof version !== 'string') return null;
|
||||
const trimmed = version.trim();
|
||||
// 形如 X.Y.Z(允许预发布后缀),防止读到 'workspace:*' 之类的非版本值
|
||||
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** 收集候选 `package.json` 路径(顺序即优先级) */
|
||||
export function packageJsonCandidates(): string[] {
|
||||
const out: string[] = [];
|
||||
|
||||
// 2. 运行入口:CJS 下可用 require.main;ESM/测试环境下跳过
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const main = typeof require !== 'undefined' ? require.main : undefined;
|
||||
const filename: unknown = main?.filename;
|
||||
if (typeof filename === 'string' && filename.length > 0) {
|
||||
out.push(resolve(dirname(filename), '..', 'package.json'));
|
||||
}
|
||||
} catch {
|
||||
/* 非 CJS 环境,忽略 */
|
||||
}
|
||||
|
||||
// 2b. 编译产物相对位置:dist/config/app-version.js → web-server/package.json
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-undef
|
||||
const here = typeof __dirname === 'string' ? __dirname : '';
|
||||
if (here) out.push(resolve(here, '..', '..', 'package.json'));
|
||||
} catch {
|
||||
/* ESM 下 __dirname 未定义,忽略 */
|
||||
}
|
||||
|
||||
// 3. 工作目录(systemd 的 WorkingDirectory=.../web-server)
|
||||
out.push(join(process.cwd(), 'package.json'));
|
||||
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
function readVersion(file: string): string | null {
|
||||
if (!existsSync(file)) return null;
|
||||
try {
|
||||
return parsePackageVersion(readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析当前运行版本;结果对同一进程稳定(缓存) */
|
||||
let cached: string | null = null;
|
||||
|
||||
export function resolveAppVersion(): string {
|
||||
if (cached !== null) return cached;
|
||||
|
||||
const explicit = process.env.APP_VERSION?.trim();
|
||||
if (explicit) {
|
||||
cached = explicit;
|
||||
return cached;
|
||||
}
|
||||
|
||||
for (const file of packageJsonCandidates()) {
|
||||
const version = readVersion(file);
|
||||
if (version) {
|
||||
cached = version;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
const npmVersion = process.env.npm_package_version?.trim();
|
||||
if (npmVersion && /^\d+\.\d+\.\d+/.test(npmVersion)) {
|
||||
cached = npmVersion;
|
||||
return cached;
|
||||
}
|
||||
|
||||
cached = UNKNOWN_VERSION;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** 测试用:清空缓存(生产代码不要调用) */
|
||||
export function resetAppVersionCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { join } from 'node:path';
|
||||
*/
|
||||
export const CONTRACT_VERSIONS: Readonly<Record<string, string>> = Object.freeze({
|
||||
'rest-api': '0.5.0',
|
||||
'websocket-keypoints': '0.1.0',
|
||||
'websocket-keypoints': '0.2.0',
|
||||
'data-models': '0.7.0',
|
||||
'skeleton-topology': '0.2.0',
|
||||
'model-formats': '0.5.0',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, Res, ServiceUnavailableException } from '@nestjs/commo
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { Response } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { resolveAppVersion } from '../config/app-version';
|
||||
import { AppConfig } from '../config/configuration';
|
||||
import { detectAssimp } from '../preprocess/assimp';
|
||||
|
||||
@@ -69,7 +70,10 @@ export class HealthController {
|
||||
const report: HealthReport = {
|
||||
status: database.connected ? 'ok' : 'degraded',
|
||||
uptimeSec: Math.round(process.uptime() * 1000) / 1000,
|
||||
version: process.env.npm_package_version ?? '0.1.0',
|
||||
// 版本号唯一解析点:读 package.json 实体文件,而不是只在 npm 脚本下才存在的
|
||||
// npm_package_version(原生部署 ExecStart=node dist/main.js 时它是 undefined,
|
||||
// 会让 /health 永远报一个陈旧的硬编码版本,与管理端左下角的展示冲突)。
|
||||
version: resolveAppVersion(),
|
||||
contractVersion: this.config.contractVersions,
|
||||
database,
|
||||
storage: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { extname, join, resolve } from 'node:path';
|
||||
import { AppModule } from './app.module';
|
||||
import { describeDualAccess, preferredUrl } from './config/access-hint';
|
||||
import { resolveAppVersion } from './config/app-version';
|
||||
import {
|
||||
AppConfig,
|
||||
configuration,
|
||||
@@ -37,6 +38,12 @@ function listenServer(server: Server, port: number, host: string): Promise<void>
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
// 先打版本号:这是「当前运行版本」的唯一来源(web-server/package.json,
|
||||
// 由 resolveAppVersion() 解析,不依赖 npm 环境变量)。
|
||||
// 放在最前面还有一个作用:即使后面数据库连不上、进程退出,
|
||||
// 日志里也一定能看到本次运行的版本号(便于与 /health、管理端左下角核对)。
|
||||
logger.log(`OpenDance Studio Web 服务端 应用版本 v${resolveAppVersion()}`);
|
||||
|
||||
// 启动前校验环境变量(生产环境禁止 DB_SYNCHRONIZE=true、端口必须合法等)
|
||||
const { errors, warnings } = validateEnv(configuration());
|
||||
for (const warning of warnings) logger.warn(`环境变量提示:${warning}`);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
PACKAGE_NAME,
|
||||
UNKNOWN_VERSION,
|
||||
packageJsonCandidates,
|
||||
parsePackageVersion,
|
||||
resetAppVersionCache,
|
||||
resolveAppVersion,
|
||||
} from '../src/config/app-version';
|
||||
|
||||
/**
|
||||
* 版本号展示的**运行时唯一来源**(文档 §11.1 / §11.2)。
|
||||
*
|
||||
* 这里守护的是一个真实发生过的缺陷:原来 `/health` 用
|
||||
* `process.env.npm_package_version ?? '0.1.0'`,而正式部署方式是原生服务
|
||||
* (`ExecStart=node dist/main.js`),npm 不参与 → 该环境变量为 undefined →
|
||||
* `/health` 在真实部署里永远报 **0.1.0**,与管理端左下角展示的 vX.Y.Z 不一致,
|
||||
* 直接违反「可核对:Web 端至少与 GET /health 的 version 一致」。
|
||||
*/
|
||||
|
||||
const serverPackageJson = JSON.parse(
|
||||
readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'),
|
||||
) as { name: string; version: string };
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.APP_VERSION;
|
||||
resetAppVersionCache();
|
||||
});
|
||||
|
||||
describe('parsePackageVersion(纯函数:只在包名与格式都对时才认)', () => {
|
||||
it('接受本项目的 package.json', () => {
|
||||
expect(
|
||||
parsePackageVersion(JSON.stringify({ name: PACKAGE_NAME, version: '1.2.3' })),
|
||||
).toBe('1.2.3');
|
||||
});
|
||||
|
||||
it('接受带预发布后缀的版本', () => {
|
||||
expect(
|
||||
parsePackageVersion(JSON.stringify({ name: PACKAGE_NAME, version: '1.2.3-rc.1' })),
|
||||
).toBe('1.2.3-rc.1');
|
||||
});
|
||||
|
||||
it('拒绝包名不匹配的文件(避免读到上层仓库的 package.json)', () => {
|
||||
expect(parsePackageVersion(JSON.stringify({ name: 'some-other-pkg', version: '9.9.9' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('拒绝缺少 name / version 的文件', () => {
|
||||
expect(parsePackageVersion(JSON.stringify({ version: '1.2.3' }))).toBeNull();
|
||||
expect(parsePackageVersion(JSON.stringify({ name: PACKAGE_NAME }))).toBeNull();
|
||||
expect(parsePackageVersion(JSON.stringify({ name: PACKAGE_NAME, version: 123 }))).toBeNull();
|
||||
});
|
||||
|
||||
it('拒绝非版本串(例如 workspace:*)', () => {
|
||||
expect(parsePackageVersion(JSON.stringify({ name: PACKAGE_NAME, version: 'workspace:*' }))).toBeNull();
|
||||
expect(parsePackageVersion(JSON.stringify({ name: PACKAGE_NAME, version: '^1.0.0' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('拒绝非法 JSON 与非对象', () => {
|
||||
expect(parsePackageVersion('{ 不是 JSON')).toBeNull();
|
||||
expect(parsePackageVersion('"字符串"')).toBeNull();
|
||||
expect(parsePackageVersion('null')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAppVersion(运行时解析)', () => {
|
||||
it('解析结果等于 web-server/package.json 的 version(单一来源)', () => {
|
||||
expect(resolveAppVersion()).toBe(serverPackageJson.version);
|
||||
});
|
||||
|
||||
it('与 package.json 里声明的包名一致(校验逻辑没有把别的包读进来)', () => {
|
||||
expect(serverPackageJson.name).toBe(PACKAGE_NAME);
|
||||
});
|
||||
|
||||
it('未通过 npm 启动时也能拿到真实版本(不依赖 npm_package_version)', () => {
|
||||
// 模拟原生部署:systemd 直接跑 node dist/main.js,没有 npm 环境变量
|
||||
const saved = process.env.npm_package_version;
|
||||
delete process.env.npm_package_version;
|
||||
delete process.env.APP_VERSION;
|
||||
resetAppVersionCache();
|
||||
try {
|
||||
const version = resolveAppVersion();
|
||||
expect(version).toBe(serverPackageJson.version);
|
||||
expect(version).not.toBe('0.1.0');
|
||||
expect(version).not.toBe(UNKNOWN_VERSION);
|
||||
} finally {
|
||||
if (saved !== undefined) process.env.npm_package_version = saved;
|
||||
resetAppVersionCache();
|
||||
}
|
||||
});
|
||||
|
||||
it('APP_VERSION 环境变量可显式覆盖(运维不重新打包也能改)', () => {
|
||||
process.env.APP_VERSION = '9.8.7';
|
||||
resetAppVersionCache();
|
||||
expect(resolveAppVersion()).toBe('9.8.7');
|
||||
});
|
||||
|
||||
it('候选路径包含工作目录(systemd WorkingDirectory=web-server)', () => {
|
||||
const candidates = packageJsonCandidates();
|
||||
expect(candidates).toContain(join(process.cwd(), 'package.json'));
|
||||
// 去重:同一路径不应出现两次
|
||||
expect(new Set(candidates).size).toBe(candidates.length);
|
||||
});
|
||||
|
||||
it('结果稳定(同一进程内缓存,不会来回变)', () => {
|
||||
const first = resolveAppVersion();
|
||||
const second = resolveAppVersion();
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeSceneConfig, SCENE_CONFIG_DEFAULTS } from '../src/scenes/scene-config';
|
||||
import { DEFAULT_SCENE_CONFIG, physicsSummary } from '../admin/src/lib/scene-physics';
|
||||
import { toSceneDto } from '../src/scenes/scene.serializer';
|
||||
import { PublishStatus, SourceType } from '../src/common/enums';
|
||||
import type { Scene } from '../src/entities/scene.entity';
|
||||
@@ -105,3 +106,34 @@ describe('SceneDto 序列化', () => {
|
||||
expect(dto.createdAt).toBe('2026-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('场景列表的物理摘要(管理端)', () => {
|
||||
it('展示重力与风速,并在有阵风时标注幅度/频率', () => {
|
||||
const calm = physicsSummary(normalizeSceneConfig(undefined));
|
||||
expect(calm).toContain('重力 -9.8');
|
||||
expect(calm).toContain('风 0.0m/s');
|
||||
expect(calm).not.toContain('阵风');
|
||||
|
||||
const windy = physicsSummary(
|
||||
normalizeSceneConfig({
|
||||
physics: {
|
||||
wind: {
|
||||
base: { x: 3, y: 0, z: 4 },
|
||||
gustAmplitude: 2.5,
|
||||
gustFrequency: 0.5,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(windy).toContain('风 5.0m/s');
|
||||
expect(windy).toContain('阵风 2.5m/s@0.5Hz');
|
||||
});
|
||||
|
||||
it('没有 config 时显示占位符(不抛错)', () => {
|
||||
expect(physicsSummary(null)).toBe('—');
|
||||
});
|
||||
|
||||
it('管理端默认值与服务端一致(契约 7.1 单一真源)', () => {
|
||||
expect(DEFAULT_SCENE_CONFIG).toEqual(SCENE_CONFIG_DEFAULTS);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user