diff --git a/README.md b/README.md index 244ce0d..9410049 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,15 @@ OpenDance Studio **阶段一**: Web 管理服务端(NestJS + TypeORM + 现有 My ## 状态 -**阶段一已完成**: T-WEB-001 -> T-WEB-010 全部落地,当前版本 **v0.5.0**(契约 `rest-api 0.4.0`、`data-models 0.6.0`、`model-formats 0.5.0`、`skeleton-topology 0.2.0`、`database 0.3.0`)。 +**阶段一已完成**: 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`)。 + +v0.6.0 修订(**场景管理**,契约 rest-api -> 0.5.0、data-models -> 0.7.0、database -> 0.4.0): + +1. **新增场景管理**(T-WEB-015):场景**以 GLB 为主**(不做 humanoid 重绑定、不转 VRM);上传 `.glb/.gltf` 直接导入,`.zip/.pmx/.fbx` 由服务端转 GLB; +2. **场景物理参数**(`SceneConfig` 冻结在契约 data-models 7.1):重力向量、**风向动态**(基础风 + 阵风幅度/频率/方向/坐标系)、地面摩擦/弹性/坡度、碰撞体来源(auto/mesh/none)、环境光/雾、出生点(≤16)、相机;**服务端只存参数、不跑物理**(仿真在客户端 Godot); +3. **入库即收敛**:越界夹紧、非法枚举回退默认值,保证客户端拿到的参数一定可解释;`scenes.config`/`stats` 用 MEDIUMTEXT; +4. **端点**:`GET/POST /api/scenes`、`GET/PATCH/DELETE /api/scenes/{id}`(共 35 条路由,`check-routes` 通过);审核复用 `/api/reviews/scene/:id/approve|reject`(上传一律 `pending`);`catalog/export` 增 `scenes[]` 且指纹纳入场景变更; +5. 缩略图复用模型光栅化器 + 分析复用模型检查器;新增 6 项测试;规模 **19 文件 / 243 项**。 v0.5.0 修订(**动作预览**,契约 data-models -> 0.6.0): @@ -101,15 +109,15 @@ v0.2.0 主要变更: 3. 取得发布包并校验(地址用你们自己的制品库替换; 下面是本仓库的发布地址) ```bash - curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.5.0/opendance-web-v0.5.0.tar.gz - curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.5.0/SHA256SUMS + 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 sha256sum -c SHA256SUMS # 期望输出 "OK" ``` 4. 解压并生成配置(交互式引导,逐项问答,口令不回显) ```bash - tar -xzf opendance-web-v0.5.0.tar.gz && cd opendance-web-v0.5.0 + tar -xzf opendance-web-v0.6.0.tar.gz && cd opendance-web-v0.6.0 bash deploy/init-env.sh ``` @@ -179,8 +187,8 @@ FLUSH PRIVILEGES; ### 5. 部署步骤 ```bash -tar -xzf opendance-web-v0.5.0.tar.gz -cd opendance-web-v0.5.0 +tar -xzf opendance-web-v0.6.0.tar.gz +cd opendance-web-v0.6.0 bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下) bash deploy/install.sh # 已以 root 登录, 不需要 sudo ``` @@ -353,12 +361,12 @@ npm run dev # http://localhost:5173(/api 与 /health 代理到 3000) cd web-server npm run contracts:check # 契约版本一致性 npm run typecheck # TypeScript 严格检查 -npm run test # 237 项单元测试 / 18 个文件(VMD 31、模型压缩包 27、 +npm run test # 243 项单元测试 / 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、MMD 材质对齐 9、模型可用性 5、MMD 骨名重绑定 4、动作预览 8、场景管理 6) npm run build # 生产构建 npm run start:prod & # 启动 npm run test:smoke # 57 项端到端冒烟(需要数据库可连通) diff --git a/README.txt b/README.txt index 887cb1d..fae8d46 100644 --- a/README.txt +++ b/README.txt @@ -1,5 +1,5 @@ ================================================================ -OpenDance Studio -- Web 管理服务端 v0.5.0 使用说明 +OpenDance Studio -- Web 管理服务端 v0.6.0 使用说明 ================================================================ 本服务端是 OpenDance Studio 三端中的第一端, 负责管理歌曲、VRM 模型与 @@ -8,6 +8,17 @@ OpenDance Studio -- Web 管理服务端 v0.5.0 使用说明 重要: 本服务端 不部署、不管理 MySQL。数据库为部署方提供的现有实例, 连接信息通过 .env 注入。 +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。 + 2. 场景物理参数(SceneConfig, 契约 data-models 7.1): 重力向量、风向动态 + (基础风 + 阵风幅度/频率/方向/坐标系)、地面摩擦/弹性/坡度、碰撞体来源、 + 环境光/雾、出生点(<=16)、相机; 服务端只存参数不跑物理(仿真在客户端)。 + 3. 入库即收敛: 越界夹紧、非法枚举回退默认; scenes.config/stats 用 MEDIUMTEXT。 + 4. 端点: GET/POST /api/scenes、GET/PATCH/DELETE /api/scenes/{id}(共 35 条路由); + 审核复用 /api/reviews/scene/:id/approve|reject; catalog/export 增 scenes[]。 + 5. 新增 6 项测试; 规模 19 文件 / 243 项。 + v0.5.0 修订 - 动作预览(契约 data-models -> 0.6.0) 1. 动作骨架缩略图(服务端): motion-thumbnail.ts 把关键点序列画成正视图骨架 PNG, 随 /preprocess 生成并落库(motions.thumbnail_asset_id, 迁移 AddMotionThumbnail), @@ -125,11 +136,11 @@ v0.2.1 修订 2. 准备一个现有的 MySQL / MariaDB 实例, 并建好库与账号(见下方"前置要求") 本项目不会创建数据库本身 3. 取得发布包并校验(地址用你们自己的制品库替换) - curl -LO https://<制品库地址>/opendance-web-v0.5.0.tar.gz + curl -LO https://<制品库地址>/opendance-web-v0.6.0.tar.gz curl -LO https://<制品库地址>/SHA256SUMS sha256sum -c SHA256SUMS # 期望输出 "OK" 4. 解压并生成配置(交互式引导, 逐项问答, 口令不回显) - tar -xzf opendance-web-v0.5.0.tar.gz && cd opendance-web-v0.5.0 + tar -xzf opendance-web-v0.6.0.tar.gz && cd opendance-web-v0.6.0 bash deploy/init-env.sh 5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查) bash deploy/install.sh @@ -187,8 +198,8 @@ v0.2.1 修订 (本项目不会创建数据库本身, 也不需要 GRANT OPTION / FILE / SUPER 等管理权限) 部署步骤 - tar -xzf opendance-web-v0.5.0.tar.gz - cd opendance-web-v0.5.0 + tar -xzf opendance-web-v0.6.0.tar.gz + cd opendance-web-v0.6.0 bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下) bash deploy/install.sh # 已以 root 登录, 不需要 sudo @@ -363,12 +374,12 @@ deploy/.env 初始化引导(deploy/init-env.sh) cd web-server npm run contracts:check # 契约版本一致性 npm run typecheck # TypeScript 严格检查 - npm run test # 237 项单元测试 / 18 个文件(VMD 31、模型压缩包 24、 + npm run test # 243 项单元测试 / 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) + # MMD 材质对齐 9、模型可用性 5、MMD 骨名重绑定 4、动作预览 8、场景管理 6) npm run build # 生产构建 npm run start:prod & # 启动 npm run test:smoke # 57 项端到端冒烟(需要数据库可连通) diff --git a/contracts/VERSION b/contracts/VERSION index 2cdfbc4..16044a8 100644 --- a/contracts/VERSION +++ b/contracts/VERSION @@ -1,8 +1,8 @@ # OpenDance Studio 契约版本 # 规则:任何字段级变更必须提升对应条目版本,并回到 opendance-web 修改后再向另两端建迁移任务。 -rest-api: 0.4.0 +rest-api: 0.5.0 websocket-keypoints: 0.1.0 -data-models: 0.6.0 +data-models: 0.7.0 skeleton-topology: 0.2.0 model-formats: 0.5.0 -database: 0.3.0 +database: 0.4.0 diff --git a/contracts/data-models.md b/contracts/data-models.md index 694bd97..ecf33b9 100644 --- a/contracts/data-models.md +++ b/contracts/data-models.md @@ -83,7 +83,7 @@ rejected 已驳回 | `durationMs` | number\|null | | | | `keypointFormat` | string\|null | 读 | 预处理后固定为 `mediapipe33@0.1.0` | | `keypointUrl` | string\|null | 读 | 预处理产物(NDJSON 或.npz 的 JSON 封装) | -| `thumbnailUrl` | string\|null | 读 | 骨架缩略图(`/preprocess` 时生成,动作管理列表展示;未生成时为 null)(v0.6.0) | +| `thumbnailUrl` | string\|null | 读 | 骨架缩略图(`/preprocess` 时生成,动作管理列表展示;未生成时为 null)(v0.7.0) | | `keypointFrames` | number\|null | 读 | 关键点帧数 | | `preprocessState` | enum | 读 | `none` / `running` / `done` / `failed` | | `preprocessError` | string\|null | 读 | | @@ -163,3 +163,48 @@ rejected 已驳回 - 仅包含 `status = 'published'` 的记录。 - `defaultModelId` 由 4.1 的规则解析得出,客户端无需自行实现优先级。 + +--- + +## 7. `Scene`(场景,GLB 为主)(v0.7.0) + +场景与模型的区别:**以 GLB 为主**(不做 humanoid 重绑定、不转 VRM), +并携带**物理参数**(服务端只存参数、不跑物理;仿真在客户端)。 + +| 字段 | 类型 | 必填 | 说明 | +|--|--|--|--| +| `id` | number | 读 | | +| `name` | string | ✅ | 场景名称,<=200 | +| `glbUrl` | string\|null | 读 | 场景 GLB 下载地址 | +| `thumbnailUrl` | string\|null | 读 | 缩略图(服务端离线渲染) | +| `fileSize` | number\|null | 读 | | +| `fileSha256` | string\|null | 读 | | +| `conversionNote` | string\|null | 读 | 由 `.zip`/`.pmx`/`.fbx` 转换而来时的说明 | +| `config` | `SceneConfig` | ✅ | 物理 / 环境 / 出生点 / 相机,见 7.1 | +| `stats` | object\|null | 读 | 场景 GLB 分析(面数 / 网格 / 材质 / warnings) | +| `sourceType` `sourceName` `sourceUrl` `license` `author` | string | ✅ | 来源五元组 | +| `status` | enum | 读 | 上传一律 `pending`,经审核后 `published` | + +### 7.1 `SceneConfig`(冻结) + +```yaml +physics: + gravity: { x: 0, y: -9.8, z: 0 } # 米/秒^2,各分量 ±200 + wind: + base: { x: 0, y: 0, z: 0 } # 基础风向量(米/秒,±100) + gustAmplitude: 0 # 阵风幅度 0~100 + gustFrequency: 0.2 # 阵风频率 Hz,0.01~5 + gustDirection: { x: 1, y: 0, z: 0 } # 阵风方向(±1) + space: world | local # 风的作用坐标系 + ground: { friction: 0.6, restitution: 0, slopeLimit: 45 } # 0~1 / 0~1 / 0~89 度 + colliders: auto | mesh | none # auto=按 GLB 命名约定,mesh=三角网 +environment: + ambientColor: "#8fb4ff" # #RRGGBB + ambientIntensity: 1 # 0~4 + fogDensity: 0 # 0~1(0=关闭) +spawnPoints: [{ x: 0, y: 0, z: 0, yaw: 0 }] # 最多 16 个 +camera: { mode: follow | fixed | orbit, distance: 4.5, height: 1.6 } +``` + +服务端入库时对每项做**边界收敛**(越界夹紧、非法枚举回退默认值), +保证客户端拿到的参数一定可解释;`catalog/export` 的 `scenes[]` 直接下发该结构。 diff --git a/contracts/database.md b/contracts/database.md index 53762cd..7cc2805 100644 --- a/contracts/database.md +++ b/contracts/database.md @@ -36,7 +36,7 @@ DB_LOGGING=false - 每个 migration 必须可逆(提供 `down` 方法) - 迁移文件名格式:`-.ts` -## 增量迁移(v0.2.0 / v0.3.0) +## 增量迁移(v0.2.0 / v0.4.0) | 迁移 | 变更 | |--|--| @@ -63,3 +63,24 @@ draft ──submit──▶ pending ──approve──▶ published - `published` 才可被 Windows 客户端 / Android App 消费 - `/api/catalog/export` 只导出 `published` 记录 + +--- + +## 场景表 `scenes`(v0.4.0 / T-WEB-015) + +| 列 | 类型 | 说明 | +|--|--|--| +| `id` | bigint PK | | +| `name` | varchar(200) | 场景名称 | +| `glb_asset_id` | bigint NULL | 场景 GLB(以 GLB 为主,不转 VRM) | +| `thumbnail_asset_id` | bigint NULL | 缩略图 | +| `config` | **mediumtext** NULL | `SceneConfig` JSON(物理 / 环境 / 出生点 / 相机) | +| `stats` | **mediumtext** NULL | 场景 GLB 分析结果(复用模型检查器) | +| `file_size` / `file_sha256` / `conversion_note` | - | 与 models 同义 | +| `source_type` `source_name` `source_url` `license` `author` | - | 来源五元组(必填) | +| `status` | varchar(16) | 上传一律 `pending`,审核后 `published`/`rejected` | +| `created_at` `updated_at` `deleted_at` | datetime | 软删除 | + +* `config` / `stats` 用 **MEDIUMTEXT**(TEXT 的 64KB 上限在 `models.stats` 上踩过坑); +* 迁移:`AddScenes1789650000000`(可逆,`down` 为 `DROP TABLE scenes`); +* 状态机与审核复用既有约定(审核走 `/api/reviews/scene/:id/approve|reject`)。 diff --git a/contracts/rest-api.yaml b/contracts/rest-api.yaml index e0b9557..6964940 100644 --- a/contracts/rest-api.yaml +++ b/contracts/rest-api.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: OpenDance Studio Web 管理服务端 API - version: 0.4.0 + version: 0.5.0 description: | OpenDance Studio 的唯一数据源与契约权威。 契约版本见 contracts/VERSION。字段语义见 contracts/data-models.md。 @@ -594,6 +594,70 @@ paths: contractVersion: { type: object, additionalProperties: { type: string } } revision: { type: string, description: 目录内容 sha256,变化即需重新拉取 } + /api/scenes: + get: + tags: [scenes] + summary: 场景列表(v0.5.0) + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + - name: q + in: query + schema: { type: string } + description: 按 name/author 模糊匹配 + - name: status + in: query + schema: { type: string, enum: [draft, pending, published, rejected] } + responses: + '200': + description: 分页结果 { items, total, page, pageSize } + post: + tags: [scenes] + summary: 上传场景(multipart:file + name + 来源五元组 + 可选 config) + description: | + `file` 以 **.glb 为主**(直接导入,不重编码);`.gltf` 直接导入; + `.zip` / `.pmx` / `.fbx` 由服务端转换为 GLB(场景不做 humanoid 重绑定、不转 VRM)。 + `config` 为 JSON 字符串(或平铺字段 gravity / wind / environment / spawnPoints / camera), + 含物理参数:重力向量、风向动态(基础风 + 阵风)、地面摩擦/弹性/坡度、碰撞体来源。 + 服务端**只存参数、不跑物理**(仿真在客户端)。上传一律 status=pending。 + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [file, name, sourceType, sourceName, sourceUrl, license, author] + properties: + file: { type: string, format: binary } + name: { type: string } + config: { type: string, description: SceneConfig 的 JSON 字符串 } + sourceType: { type: string } + sourceName: { type: string } + sourceUrl: { type: string } + license: { type: string } + author: { type: string } + responses: + '201': + description: 已入库(pending) + /api/scenes/{id}: + parameters: [{ $ref: '#/components/parameters/Id' }] + get: + tags: [scenes] + summary: 场景详情 + responses: + '200': { description: Scene } + '404': { description: 不存在 } + patch: + tags: [scenes] + summary: 更新场景名称 / 物理参数(状态变更走 /api/reviews) + responses: + '200': { description: Scene } + '400': { description: 参数非法或试图直接改状态 } + delete: + tags: [scenes] + summary: 删除场景(软删除) + responses: + '204': { description: 已删除 } + components: parameters: Id: diff --git a/project.yaml b/project.yaml index c0c62af..a1bdffc 100644 --- a/project.yaml +++ b/project.yaml @@ -1,5 +1,5 @@ project: opendance-web-server -version: 0.5.0 +version: 0.6.0 repository: opendance-web default_branch: main contracts_version: 0.4.0 @@ -60,13 +60,13 @@ tasks: status: done deliverable: - artifact: opendance-web-v0.5.0.tar.gz + artifact: opendance-web-v0.6.0.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/ # 当前版本契约快照(data-models 0.6.0) + - contracts/ # 当前版本契约快照(rest-api 0.5.0 / data-models 0.7.0 / database 0.4.0) - THIRD_PARTY.md # 开源声明 - README.txt # 使用说明 diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 9e2eed0..6af8ca0 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -1,6 +1,6 @@ #!/bin/bash # ============================================================ -# 打包发布产物:opendance-web-v0.5.0.tar.gz +# 打包发布产物:opendance-web-v0.6.0.tar.gz # # 产物结构(Linux 业务服务部署,不使用 Docker、不部署反向代理): # web-server/ NestJS 源码 + 构建产物 @@ -21,7 +21,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_ROOT" -VERSION="${1:-0.5.0}" +VERSION="${1:-0.6.0}" NAME="opendance-web-v${VERSION}" OUT_DIR="$REPO_ROOT/dist-release" STAGE="$OUT_DIR/$NAME" diff --git a/web-server/admin/package.json b/web-server/admin/package.json index 77ada64..0387f51 100644 --- a/web-server/admin/package.json +++ b/web-server/admin/package.json @@ -1,7 +1,7 @@ { "name": "opendance-admin", "private": true, - "version": "0.5.0", + "version": "0.6.0", "type": "module", "description": "OpenDance Studio 管理端(React + TypeScript + Vite)", "scripts": { diff --git a/web-server/package.json b/web-server/package.json index b18d8ee..572d6c6 100644 --- a/web-server/package.json +++ b/web-server/package.json @@ -1,6 +1,6 @@ { "name": "opendance-web-server", - "version": "0.5.0", + "version": "0.6.0", "description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)", "license": "MIT", "private": true, diff --git a/web-server/src/app.module.ts b/web-server/src/app.module.ts index a617899..4283ea9 100644 --- a/web-server/src/app.module.ts +++ b/web-server/src/app.module.ts @@ -9,6 +9,7 @@ import { ModelsModule } from './models/models.module'; import { MotionsModule } from './motions/motions.module'; import { PreprocessModule } from './preprocess/preprocess.module'; import { ReviewsModule } from './reviews/reviews.module'; +import { ScenesModule } from './scenes/scenes.module'; import { SongsModule } from './songs/songs.module'; import { StorageModule } from './storage/storage.module'; @@ -39,6 +40,7 @@ import { StorageModule } from './storage/storage.module'; StorageModule, HealthModule, + ScenesModule, SongsModule, MotionsModule, ModelsModule, diff --git a/web-server/src/catalog/catalog.service.ts b/web-server/src/catalog/catalog.service.ts index 44f2c07..e9d4ec0 100644 --- a/web-server/src/catalog/catalog.service.ts +++ b/web-server/src/catalog/catalog.service.ts @@ -12,6 +12,8 @@ import { ModelsService } from '../models/models.service'; import { MotionDto } from '../motions/motion.serializer'; import { MotionsService } from '../motions/motions.service'; import { SongDto } from '../songs/song.serializer'; +import { ScenesService } from '../scenes/scenes.service'; +import type { SceneDto } from '../scenes/scene.serializer'; import { SongsService } from '../songs/songs.service'; export interface CatalogBundle { @@ -20,6 +22,8 @@ export interface CatalogBundle { songs: SongDto[]; motions: MotionDto[]; models: ModelDto[]; + /** 已发布场景(含物理参数);契约 data-models 0.7.0 */ + scenes: SceneDto[]; defaultModelId: number | null; } @@ -36,6 +40,7 @@ export class CatalogService { private readonly songsService: SongsService, private readonly motionsService: MotionsService, private readonly modelsService: ModelsService, + private readonly scenesService: ScenesService, private readonly config: AppConfig, ) {} @@ -50,10 +55,11 @@ export class CatalogService { }), ]); - const [songDtos, motionDtos, modelDtos, defaultModelId] = await Promise.all([ + const [songDtos, motionDtos, modelDtos, sceneDtos, defaultModelId] = await Promise.all([ this.songsService.toDtos(songs), this.motionsService.toDtos(motions), this.modelsService.toDtos(models), + this.scenesService.published(), this.modelsService.resolveDefaultModelId(), ]); @@ -63,6 +69,7 @@ export class CatalogService { songs: songDtos, motions: motionDtos, models: modelDtos, + scenes: sceneDtos, defaultModelId, }; } @@ -72,7 +79,7 @@ export class CatalogService { * 指纹只覆盖影响客户端行为的字段。 */ async version(): Promise<{ contractVersion: Record; revision: string; generatedAt: string }> { - const [songs, motions, models] = await Promise.all([ + const [songs, motions, models, scenes] = await Promise.all([ this.songs.find({ where: { status: PublishStatus.Published }, order: { id: 'ASC' } }), this.motions.find({ where: { status: PublishStatus.Published }, order: { id: 'ASC' } }), // v0.5.0:只交付**已产出 VRM** 的模型(usable),未产出 VRM 的不进入客户端目录 @@ -80,6 +87,7 @@ export class CatalogService { where: { status: PublishStatus.Published, format: ModelFormat.Vrm }, order: { id: 'ASC' }, }), + this.scenesService.published(), ]); const fingerprint = JSON.stringify({ @@ -94,6 +102,7 @@ export class CatalogService { m.preprocessState, ]), models: models.map((m) => [m.id, m.name, m.format, m.isPrimary, m.updatedAt.toISOString(), m.fileAssetId]), + scenes: scenes.map((s) => [s.id, s.name, s.updatedAt, s.glbUrl, s.status]), }); return { diff --git a/web-server/src/common/enums.ts b/web-server/src/common/enums.ts index ceb13be..a1afe4b 100644 --- a/web-server/src/common/enums.ts +++ b/web-server/src/common/enums.ts @@ -60,6 +60,8 @@ export enum TargetType { Song = 'song', Motion = 'motion', Model = 'model', + /** 场景(T-WEB-015) */ + Scene = 'scene', } export enum AssetKind { @@ -72,6 +74,10 @@ export enum AssetKind { Thumbnail = 'thumbnail', /** 动作关键点序列的骨架缩略图(T-WEB-014) */ MotionThumbnail = 'motion-thumbnail', + /** 场景 GLB(T-WEB-015) */ + Scene = 'scene', + /** 场景缩略图 */ + SceneThumbnail = 'scene-thumbnail', Keypoints = 'keypoints', } diff --git a/web-server/src/config/contract-versions.ts b/web-server/src/config/contract-versions.ts index 5406c62..81f8080 100644 --- a/web-server/src/config/contract-versions.ts +++ b/web-server/src/config/contract-versions.ts @@ -9,12 +9,12 @@ import { join } from 'node:path'; * 未设置该变量时回落到这里的常量)。 */ export const CONTRACT_VERSIONS: Readonly> = Object.freeze({ - 'rest-api': '0.4.0', + 'rest-api': '0.5.0', 'websocket-keypoints': '0.1.0', - 'data-models': '0.6.0', + 'data-models': '0.7.0', 'skeleton-topology': '0.2.0', 'model-formats': '0.5.0', - database: '0.3.0', + database: '0.4.0', }); /** 解析 `contracts/VERSION`(`key: value` 每行一条,`#` 起注释) */ diff --git a/web-server/src/entities/scene.entity.ts b/web-server/src/entities/scene.entity.ts new file mode 100644 index 0000000..51e8ed6 --- /dev/null +++ b/web-server/src/entities/scene.entity.ts @@ -0,0 +1,95 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { bigintNumber } from '../common/transformers'; +import { PublishStatus, SourceType } from '../common/enums'; +import type { SceneConfig } from '../scenes/scene-config'; + +/** JSON 文本列(MEDIUMTEXT):TEXT 的 64KB 上限在模型 stats 上已经踩过坑 */ +const jsonText = { + to: (value: unknown): string | null => (value === null || value === undefined ? null : JSON.stringify(value)), + from: (value: string | null): unknown => { + if (value === null || value === undefined || value === '') return null; + try { + return JSON.parse(value); + } catch { + return null; + } + }, +}; + +/** + * 场景 —— 契约 data-models.md §7(T-WEB-015)。 + * + * **以 GLB 为主**(与模型区分:场景不做 humanoid 重绑定、不转 VRM); + * `config` 存物理/环境/出生点/相机参数(重力、风向动态等), + * **服务端只存参数不跑物理**,仿真在客户端(Godot 内置物理)。 + */ +@Entity('scenes') +@Index('idx_scenes_status', ['status']) +export class Scene { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id!: number; + + @Column({ type: 'varchar', length: 200 }) + name!: string; + + /** 场景 GLB 资产 */ + @Column({ name: 'glb_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber }) + glbAssetId!: number | null; + + @Column({ name: 'thumbnail_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber }) + thumbnailAssetId!: number | null; + + /** 物理 / 环境 / 出生点 / 相机(见 scenes/scene-config.ts) */ + @Column({ name: 'config', type: 'mediumtext', nullable: true, transformer: jsonText }) + config!: SceneConfig | null; + + /** 场景 GLB 的分析结果(面数 / 网格 / 材质 / warnings,复用模型检查器) */ + @Column({ name: 'stats', type: 'mediumtext', nullable: true, transformer: jsonText }) + stats!: Record | null; + + @Column({ name: 'file_size', type: 'bigint', nullable: true, transformer: bigintNumber }) + fileSize!: number | null; + + @Column({ name: 'file_sha256', type: 'varchar', length: 64, nullable: true }) + fileSha256!: string | null; + + /** 由 `.zip` / `.pmx` / `.fbx` 转换而来时的说明 */ + @Column({ name: 'conversion_note', type: 'varchar', length: 500, nullable: true }) + conversionNote!: string | null; + + // ---- 来源五元组(必填)---- + @Column({ name: 'source_type', type: 'varchar', length: 32 }) + sourceType!: SourceType; + + @Column({ name: 'source_name', type: 'varchar', length: 200 }) + sourceName!: string; + + @Column({ name: 'source_url', type: 'varchar', length: 500 }) + sourceUrl!: string; + + @Column({ type: 'varchar', length: 100 }) + license!: string; + + @Column({ type: 'varchar', length: 200 }) + author!: string; + + @Column({ type: 'varchar', length: 16, default: PublishStatus.Pending }) + status!: PublishStatus; + + @CreateDateColumn({ name: 'created_at', type: 'datetime' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'datetime' }) + updatedAt!: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'datetime', nullable: true }) + deletedAt!: Date | null; +} diff --git a/web-server/src/migrations/1789650000000-AddScenes.ts b/web-server/src/migrations/1789650000000-AddScenes.ts new file mode 100644 index 0000000..8534df1 --- /dev/null +++ b/web-server/src/migrations/1789650000000-AddScenes.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * 场景管理(T-WEB-015,契约 database 0.4.0 / data-models 0.7.0)。 + * + * 场景以 **GLB 为主**:`glb_asset_id` 指向存储里的场景文件; + * `config` 存物理参数(重力 / 风向动态 / 地面 / 碰撞体)与环境、出生点、相机; + * `stats` 复用模型检查器的分析结果。服务端**只存参数、不跑物理**。 + */ +export class AddScenes1789650000000 implements MigrationInterface { + name = 'AddScenes1789650000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE \`scenes\` ( + \`id\` bigint NOT NULL AUTO_INCREMENT, + \`name\` varchar(200) NOT NULL, + \`glb_asset_id\` bigint NULL, + \`thumbnail_asset_id\` bigint NULL, + \`config\` mediumtext NULL, + \`stats\` mediumtext NULL, + \`file_size\` bigint NULL, + \`file_sha256\` varchar(64) NULL, + \`conversion_note\` varchar(500) NULL, + \`source_type\` varchar(32) NOT NULL, + \`source_name\` varchar(200) NOT NULL, + \`source_url\` varchar(500) NOT NULL, + \`license\` varchar(100) NOT NULL, + \`author\` varchar(200) NOT NULL, + \`status\` varchar(16) NOT NULL DEFAULT 'pending', + \`created_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`updated_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + \`deleted_at\` datetime NULL, + INDEX \`idx_scenes_status\` (\`status\`), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE \`scenes\``); + } +} diff --git a/web-server/src/reviews/reviews.module.ts b/web-server/src/reviews/reviews.module.ts index 257ee4b..cc96434 100644 --- a/web-server/src/reviews/reviews.module.ts +++ b/web-server/src/reviews/reviews.module.ts @@ -1,3 +1,4 @@ +import { Scene } from '../entities/scene.entity'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ModelsModule } from '../models/models.module'; @@ -9,7 +10,7 @@ import { ReviewsController } from './reviews.controller'; import { ReviewsService } from './reviews.service'; @Module({ - imports: [TypeOrmModule.forFeature([ReviewRecord, Song, Motion, CharacterModel]), ModelsModule], + imports: [TypeOrmModule.forFeature([Scene, ReviewRecord, Song, Motion, CharacterModel]), ModelsModule], controllers: [ReviewsController], providers: [ReviewsService], exports: [ReviewsService], diff --git a/web-server/src/reviews/reviews.service.ts b/web-server/src/reviews/reviews.service.ts index 821aa41..90af219 100644 --- a/web-server/src/reviews/reviews.service.ts +++ b/web-server/src/reviews/reviews.service.ts @@ -19,6 +19,7 @@ import { Paged, toPaged } from '../common/pagination'; import { CharacterModel } from '../entities/model.entity'; import { Motion } from '../entities/motion.entity'; import { ReviewRecord } from '../entities/review-record.entity'; +import { Scene } from '../entities/scene.entity'; import { Song } from '../entities/song.entity'; import { QueryReviewDto, ReviewActionDto } from './review.dto'; @@ -68,6 +69,7 @@ export class ReviewsService { @InjectRepository(Song) private readonly songs: Repository, @InjectRepository(Motion) private readonly motions: Repository, @InjectRepository(CharacterModel) private readonly models: Repository, + @InjectRepository(Scene) private readonly scenes: Repository, private readonly modelsService: ModelsService, ) {} @@ -84,15 +86,17 @@ export class ReviewsService { async listByStatus(status: PublishStatus, query: QueryReviewDto): Promise> { const rows: PendingItemDto[] = []; - const [songs, motions, models] = await Promise.all([ + const [songs, motions, models, scenes] = await Promise.all([ this.songs.find({ where: { status } }), this.motions.find({ where: { status } }), this.models.find({ where: { status } }), + this.scenes.find({ where: { status } }), ]); rows.push(...songs.map((s) => toPending(TargetType.Song, s, s.title))); rows.push(...motions.map((m) => toPending(TargetType.Motion, m, m.name))); rows.push(...models.map((m) => toPending(TargetType.Model, m, m.name))); + rows.push(...scenes.map((s) => toPending(TargetType.Scene, s, s.name))); rows.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)); const total = rows.length; @@ -133,6 +137,14 @@ export class ReviewsService { save = () => this.motions.save(motion); break; } + case TargetType.Scene: { + const scene = await this.scenes.findOne({ where: { id } }); + if (!scene) throw new NotFoundException(`场景 #${id} 不存在`); + entity = scene; + name = scene.name; + save = () => this.scenes.save(scene); + break; + } case TargetType.Model: { const model = await this.models.findOne({ where: { id } }); if (!model) throw new NotFoundException(`模型 #${id} 不存在`); diff --git a/web-server/src/scenes/scene-config.ts b/web-server/src/scenes/scene-config.ts new file mode 100644 index 0000000..e4bde25 --- /dev/null +++ b/web-server/src/scenes/scene-config.ts @@ -0,0 +1,163 @@ +/** + * 场景物理与环境的配置定义(T-WEB-015)。 + * + * 职责划分:**服务端只存参数、不跑物理**;仿真在客户端(Windows 端用 Godot 内置物理: + * RigidBody3D / Area3D 风场 / PhysicsServer3D)。因此这里只做 + * "结构校验 + 默认值 + 边界收敛",保证下发给客户端的参数一定可解释。 + */ + +export interface Vec3 { + x: number; + y: number; + z: number; +} + +/** 风:基础风 + 阵风(正弦叠加) */ +export interface WindConfig { + base: Vec3; + /** 阵风幅度(米/秒,0 表示无阵风;上限 100) */ + gustAmplitude: number; + /** 阵风频率(Hz,0.01~5) */ + gustFrequency: number; + /** 阵风方向(上限 ±1) */ + gustDirection: Vec3; + /** 世界坐标 or 场景局部坐标 */ + space: 'world' | 'local'; +} + +export interface GroundConfig { + /** 摩擦系数 0~1 */ + friction: number; + /** 弹性 0~1 */ + restitution: number; + /** 可行走坡度上限(度)0~89 */ + slopeLimit: number; +} + +export interface EnvironmentConfig { + /** 环境光颜色(#RRGGBB) */ + ambientColor: string; + /** 环境光强度 0~4 */ + ambientIntensity: number; + /** 雾浓度 0~1(0 = 关闭) */ + fogDensity: number; +} + +export interface SpawnPoint { + x: number; + y: number; + z: number; + /** 朝向(度,绕 Y 轴) */ + yaw: number; +} + +export interface CameraConfig { + mode: 'follow' | 'fixed' | 'orbit'; + distance: number; + height: number; +} + +export interface SceneConfig { + physics: { + /** 重力向量(米/秒^2),默认 (0,-9.8,0) */ + gravity: Vec3; + wind: WindConfig; + ground: GroundConfig; + /** 碰撞体来源:auto(按 GLB 命名约定)| mesh(三角网)| none */ + colliders: 'auto' | 'mesh' | 'none'; + }; + environment: EnvironmentConfig; + spawnPoints: SpawnPoint[]; + camera: CameraConfig; +} + +export const SCENE_CONFIG_DEFAULTS: 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 }, +}; + +function num(value: unknown, fallback: number, min: number, max: number): number { + const parsed = typeof value === 'number' && Number.isFinite(value) ? value : fallback; + return Math.min(max, Math.max(min, parsed)); +} + +function vec3(value: unknown, fallback: Vec3, limit = 1000): Vec3 { + const raw = (value ?? {}) as Partial; + return { + x: num(raw.x, fallback.x, -limit, limit), + y: num(raw.y, fallback.y, -limit, limit), + z: num(raw.z, fallback.z, -limit, limit), + }; +} + +function hexColor(value: unknown, fallback: string): string { + return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback; +} + +/** + * 把任意输入收敛成合法 `SceneConfig`:缺项用默认值、越界夹紧、非法枚举回退。 + * **永不抛错** —— 保证客户端拿到的参数一定可解释。 + */ +export function normalizeSceneConfig(input: unknown): SceneConfig { + const raw = (input ?? {}) as Partial; + const physics = (raw.physics ?? {}) as Partial; + const wind = (physics.wind ?? {}) as Partial; + const ground = (physics.ground ?? {}) as Partial; + const environment = (raw.environment ?? {}) as Partial; + const camera = (raw.camera ?? {}) as Partial; + const defaults = SCENE_CONFIG_DEFAULTS; + const spawnPoints = Array.isArray(raw.spawnPoints) && raw.spawnPoints.length > 0 + ? raw.spawnPoints + : defaults.spawnPoints; + + return { + physics: { + gravity: vec3(physics.gravity, defaults.physics.gravity, 200), + wind: { + base: vec3(wind.base, defaults.physics.wind.base, 100), + gustAmplitude: num(wind.gustAmplitude, defaults.physics.wind.gustAmplitude, 0, 100), + gustFrequency: num(wind.gustFrequency, defaults.physics.wind.gustFrequency, 0.01, 5), + gustDirection: vec3(wind.gustDirection, defaults.physics.wind.gustDirection, 1), + space: wind.space === 'local' ? 'local' : 'world', + }, + ground: { + friction: num(ground.friction, defaults.physics.ground.friction, 0, 1), + restitution: num(ground.restitution, defaults.physics.ground.restitution, 0, 1), + slopeLimit: num(ground.slopeLimit, defaults.physics.ground.slopeLimit, 0, 89), + }, + colliders: physics.colliders === 'mesh' || physics.colliders === 'none' ? physics.colliders : 'auto', + }, + environment: { + ambientColor: hexColor(environment.ambientColor, defaults.environment.ambientColor), + ambientIntensity: num(environment.ambientIntensity, defaults.environment.ambientIntensity, 0, 4), + fogDensity: num(environment.fogDensity, defaults.environment.fogDensity, 0, 1), + }, + spawnPoints: spawnPoints.slice(0, 16).map((point) => { + const item = (point ?? {}) as Partial; + return { + x: num(item.x, 0, -1000, 1000), + y: num(item.y, 0, -1000, 1000), + z: num(item.z, 0, -1000, 1000), + yaw: num(item.yaw, 0, -360, 360), + }; + }), + camera: { + mode: camera.mode === 'fixed' || camera.mode === 'orbit' ? camera.mode : defaults.camera.mode, + distance: num(camera.distance, defaults.camera.distance, 0.5, 50), + height: num(camera.height, defaults.camera.height, 0, 20), + }, + }; +} diff --git a/web-server/src/scenes/scene.serializer.ts b/web-server/src/scenes/scene.serializer.ts new file mode 100644 index 0000000..2c235d9 --- /dev/null +++ b/web-server/src/scenes/scene.serializer.ts @@ -0,0 +1,52 @@ +import { PublishStatus, SourceType } from '../common/enums'; +import { Scene } from '../entities/scene.entity'; +import type { SceneConfig } from './scene-config'; + +/** 契约 data-models.md §7 的对外表示 */ +export interface SceneDto { + id: number; + name: string; + glbUrl: string | null; + thumbnailUrl: string | null; + fileSize: number | null; + fileSha256: string | null; + conversionNote: string | null; + /** 物理 / 环境 / 出生点 / 相机(服务端只存参数,仿真在客户端) */ + config: SceneConfig | null; + stats: Record | null; + sourceType: SourceType; + sourceName: string; + sourceUrl: string; + license: string; + author: string; + status: PublishStatus; + createdAt: string; + updatedAt: string; +} + +export interface SceneUrlLookup { + glbUrl: string | null; + thumbnailUrl: string | null; +} + +export function toSceneDto(scene: Scene, urls: SceneUrlLookup): SceneDto { + return { + id: scene.id, + name: scene.name, + glbUrl: urls.glbUrl, + thumbnailUrl: urls.thumbnailUrl, + fileSize: scene.fileSize ?? null, + fileSha256: scene.fileSha256 ?? null, + conversionNote: scene.conversionNote ?? null, + config: scene.config ?? null, + stats: scene.stats ?? null, + sourceType: scene.sourceType, + sourceName: scene.sourceName, + sourceUrl: scene.sourceUrl, + license: scene.license, + author: scene.author, + status: scene.status, + createdAt: scene.createdAt.toISOString(), + updatedAt: scene.updatedAt.toISOString(), + }; +} diff --git a/web-server/src/scenes/scenes.controller.ts b/web-server/src/scenes/scenes.controller.ts new file mode 100644 index 0000000..dd02842 --- /dev/null +++ b/web-server/src/scenes/scenes.controller.ts @@ -0,0 +1,68 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + Patch, + Post, + Query, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ScenesService, type UploadedSceneFile } from './scenes.service'; +import type { SceneDto } from './scene.serializer'; + +/** 场景管理(T-WEB-015):契约 rest-api.yaml /api/scenes */ +@Controller('api/scenes') +export class ScenesController { + constructor(private readonly scenes: ScenesService) {} + + @Get() + list( + @Query('q') q?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ): Promise<{ items: SceneDto[]; total: number; page: number; pageSize: number }> { + return this.scenes.list({ + q, + status, + page: page ? Number(page) : undefined, + pageSize: pageSize ? Number(pageSize) : undefined, + }); + } + + /** 上传场景:multipart,`file`(.glb 为主)+ 名称 + 来源五元组 + 可选 config(JSON) */ + @Post() + @UseInterceptors(FileInterceptor('file')) + create( + @Body() body: Record, + @UploadedFile() file: UploadedSceneFile | undefined, + ): Promise { + return this.scenes.create(body, file); + } + + @Get(':id') + detail(@Param('id', ParseIntPipe) id: number): Promise { + return this.scenes.findOne(id); + } + + @Patch(':id') + update( + @Param('id', ParseIntPipe) id: number, + @Body() body: Record, + ): Promise { + return this.scenes.update(id, body); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param('id', ParseIntPipe) id: number): Promise { + await this.scenes.remove(id); + } +} diff --git a/web-server/src/scenes/scenes.module.ts b/web-server/src/scenes/scenes.module.ts new file mode 100644 index 0000000..c458e7f --- /dev/null +++ b/web-server/src/scenes/scenes.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Scene } from '../entities/scene.entity'; +import { StorageModule } from '../storage/storage.module'; +import { ScenesController } from './scenes.controller'; +import { ScenesService } from './scenes.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Scene]), StorageModule], + controllers: [ScenesController], + providers: [ScenesService], + exports: [ScenesService], +}) +export class ScenesModule {} diff --git a/web-server/src/scenes/scenes.service.ts b/web-server/src/scenes/scenes.service.ts new file mode 100644 index 0000000..21c277d --- /dev/null +++ b/web-server/src/scenes/scenes.service.ts @@ -0,0 +1,262 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { existsSync } from 'node:fs'; +import { Repository } from 'typeorm'; +import { AssetKind, ModelFormat, PublishStatus } from '../common/enums'; +import { assertSourceTupleComplete, type SourceTuple } from '../common/source.dto'; +import { AppConfig } from '../config/configuration'; +import { Scene } from '../entities/scene.entity'; +import { convertModelArchive, convertModelFile } from '../models/model-convert'; +import { inspectModelFile } from '../models/model-inspector'; +import { renderModelThumbnail } from '../models/model-thumbnail'; +import { StorageService } from '../storage/storage.service'; +import { normalizeSceneConfig, SCENE_CONFIG_DEFAULTS, type SceneConfig } from './scene-config'; +import { type SceneDto, toSceneDto } from './scene.serializer'; + +export interface UploadedSceneFile { + buffer: Buffer; + originalname: string; + size: number; + mimetype?: string; +} + +const SUPPORTED_DIRECT = new Set(['.glb', '.gltf']); + +function extOf(name: string): string { + const index = name.lastIndexOf('.'); + return index >= 0 ? name.slice(index).toLowerCase() : ''; +} + +/** + * 场景管理(T-WEB-015)。 + * + * 与模型管理的差异(契约 model-formats 1.1 / data-models §7): + * * 场景**以 GLB 为主**,不做 humanoid 重绑定、不转 VRM; + * * 场景携带**物理参数**(重力 / 风向动态 / 地面 / 碰撞体)与环境、出生点、相机; + * * 服务端**只存参数、不跑物理**:仿真在客户端(Godot 内置物理)。 + */ +@Injectable() +export class ScenesService { + private readonly logger = new Logger(ScenesService.name); + + constructor( + @InjectRepository(Scene) private readonly scenes: Repository, + private readonly storage: StorageService, + private readonly config: AppConfig, + ) {} + + async create(body: Record, file: UploadedSceneFile | undefined): Promise { + if (!file || !file.buffer || file.buffer.length === 0) { + throw new BadRequestException('上传场景必须带 file 字段(.glb 为主,也接受 .gltf/.zip/.fbx/.pmx)'); + } + const name = typeof body.name === 'string' && body.name.trim().length > 0 ? body.name.trim() : ''; + if (name.length === 0) throw new BadRequestException('场景名称 name 必填'); + const tuple = assertSourceTupleComplete({ + sourceType: body.sourceType as SourceTuple['sourceType'], + sourceName: body.sourceName as string, + sourceUrl: body.sourceUrl as string, + license: body.license as string, + author: body.author as string, + }); + if (!tuple) { + throw new BadRequestException('上传场景必须填写完整的来源信息(sourceType/sourceName/sourceUrl/license/author)'); + } + + const parsed = this.parseConfigInput(body); + const ext = extOf(file.originalname || ''); + let buffer: Buffer; + let conversionNote: string; + + if (SUPPORTED_DIRECT.has(ext)) { + // 场景主力格式:GLB 直接导入(不重编码) + buffer = file.buffer; + conversionNote = `直接导入(${ext.slice(1).toUpperCase()},未做重编码)`; + } else { + // 其余格式统一转 GLB(场景不做 VRM) + try { + const converted = + ext === '.zip' + ? await convertModelArchive(file.buffer, { + assimpBin: this.config.assimpBin, + timeoutMs: this.config.modelConvertTimeoutMs, + preferFormat: 'glb', + }) + : await convertModelFile(file.buffer, file.originalname, { + assimpBin: this.config.assimpBin, + timeoutMs: this.config.modelConvertTimeoutMs, + preferFormat: 'glb', + }); + buffer = converted.buffer; + conversionNote = converted.note.slice(0, 500); + } catch (error) { + throw new BadRequestException( + `场景转换失败(${ext || '未知扩展名'}):${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + const stored = await this.storage.persistBuffer( + AssetKind.Scene, + buffer, + file.originalname || `scene-${Date.now()}.glb`, + 'model/gltf-binary', + ); + + const absolutePath = this.storage.absolutePath(stored.asset); + const stats = await this.inspectSafely(absolutePath, buffer.length, name); + const thumbnailAssetId = await this.thumbnailSafely(absolutePath); + + const scene = this.scenes.create({ + name, + glbAssetId: stored.asset.id, + thumbnailAssetId, + config: parsed, + stats: stats as unknown as Record, + fileSize: stored.asset.size, + fileSha256: stored.asset.sha256, + conversionNote, + sourceType: tuple.sourceType as Scene['sourceType'], + sourceName: tuple.sourceName, + sourceUrl: tuple.sourceUrl, + license: tuple.license, + author: tuple.author, + // 与模型/动作一致:上传一律待审核 + status: PublishStatus.Pending, + }); + const saved = await this.scenes.save(scene); + this.logger.log( + `场景已入库待审:#${saved.id} ${name}(${file.originalname},${conversionNote})` + + ` 重力=(${parsed.physics.gravity.x},${parsed.physics.gravity.y},${parsed.physics.gravity.z})`, + ); + const [dto] = await this.toDtos([saved]); + return dto; + } + + /** 场景配置:既接受 `config` JSON 字符串(multipart 常用),也接受平铺字段 */ + private parseConfigInput(body: Record): SceneConfig { + const raw = body.config; + if (typeof raw === 'string' && raw.trim().length > 0) { + try { + return normalizeSceneConfig(JSON.parse(raw)); + } catch { + throw new BadRequestException('config 不是合法的 JSON'); + } + } + if (raw && typeof raw === 'object') return normalizeSceneConfig(raw); + if (body.gravity || body.wind || body.environment || body.spawnPoints || body.camera) { + return normalizeSceneConfig({ + physics: { gravity: body.gravity, wind: body.wind }, + environment: body.environment, + spawnPoints: body.spawnPoints, + camera: body.camera, + }); + } + return SCENE_CONFIG_DEFAULTS; + } + + private async inspectSafely( + absolutePath: string, + fileSize: number, + name: string, + ): Promise | null> { + try { + return (await inspectModelFile(absolutePath, fileSize)) as unknown as Record; + } catch (error) { + this.logger.warn(`场景「${name}」分析失败:${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + + private async thumbnailSafely(absolutePath: string): Promise { + try { + const rendered = await renderModelThumbnail(absolutePath, { size: 512 }); + if (!rendered) return null; + const stored = await this.storage.persistBuffer( + AssetKind.SceneThumbnail, + rendered.png, + `scene-thumb-${Date.now()}.png`, + 'image/png', + ); + return stored.asset.id; + } catch (error) { + this.logger.warn(`场景缩略图生成失败:${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + + async list(query: { + q?: string; + status?: string; + page?: number; + pageSize?: number; + }): Promise<{ items: SceneDto[]; total: number; page: number; pageSize: number }> { + const page = Math.max(1, Math.floor(query.page ?? 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(query.pageSize ?? 20))); + const qb = this.scenes.createQueryBuilder('s'); + if (query.q) { + qb.andWhere('(s.name LIKE :q OR s.author LIKE :q)', { q: `%${query.q}%` }); + } + if (query.status) qb.andWhere('s.status = :status', { status: query.status }); + qb.orderBy('s.id', 'DESC').skip((page - 1) * pageSize).take(pageSize); + const [rows, total] = await qb.getManyAndCount(); + return { items: await this.toDtos(rows), total, page, pageSize }; + } + + async findOne(id: number): Promise { + const scene = await this.getEntity(id); + const [dto] = await this.toDtos([scene]); + return dto; + } + + async update(id: number, body: Record): Promise { + const scene = await this.getEntity(id); + if (typeof body.name === 'string' && body.name.trim().length > 0) scene.name = body.name.trim(); + if (body.config !== undefined || body.gravity || body.wind || body.environment) { + scene.config = this.parseConfigInput(body); + } + if (typeof body.status === 'string' && body.status !== scene.status) { + throw new BadRequestException('状态变更请走 /api/reviews 接口(上传一律待审核)'); + } + const saved = await this.scenes.save(scene); + const [dto] = await this.toDtos([saved]); + return dto; + } + + /** 软删除(与歌曲一致:保留数据,客户端目录按 status 过滤) */ + async remove(id: number): Promise { + const scene = await this.getEntity(id); + await this.scenes.softRemove(scene); + } + + async getEntity(id: number): Promise { + const scene = await this.scenes.findOne({ where: { id } }); + if (!scene) throw new NotFoundException(`场景 #${id} 不存在`); + return scene; + } + + async toDtos(rows: Scene[]): Promise { + const assets = await this.storage.findMany( + rows.flatMap((row) => [row.glbAssetId, row.thumbnailAssetId]), + ); + return rows.map((row) => { + const glb = row.glbAssetId ? assets.get(row.glbAssetId) : undefined; + const thumbnail = row.thumbnailAssetId ? assets.get(row.thumbnailAssetId) : undefined; + if (glb && !existsSync(this.storage.absolutePath(glb))) { + this.logger.warn(`场景 #${row.id} 的 GLB 在存储中不存在:${this.storage.absolutePath(glb)}`); + } + return toSceneDto(row, { + glbUrl: glb ? this.storage.url(glb) : null, + thumbnailUrl: thumbnail ? this.storage.url(thumbnail) : null, + }); + }); + } + + /** 供 catalog 使用:已发布场景(含物理参数) */ + async published(): Promise { + const rows = await this.scenes.find({ + where: { status: PublishStatus.Published }, + order: { id: 'ASC' }, + }); + return this.toDtos(rows); + } +} diff --git a/web-server/test/model-usability.spec.ts b/web-server/test/model-usability.spec.ts index 798a74a..b6cdda7 100644 --- a/web-server/test/model-usability.spec.ts +++ b/web-server/test/model-usability.spec.ts @@ -76,6 +76,7 @@ describe('目录导出只含可用模型', () => { }; const songsService = { toDtos: vi.fn().mockResolvedValue([]) }; const motionsService = { toDtos: vi.fn().mockResolvedValue([]) }; + const scenesService = { published: vi.fn().mockResolvedValue([]) }; const modelsService = { toDtos: vi.fn().mockImplementation((rows: CharacterModel[]) => rows.map((row) => toModelDto(row, { fileUrl: '/f', thumbnailUrl: null })), @@ -90,6 +91,7 @@ describe('目录导出只含可用模型', () => { songsService as never, motionsService as never, modelsService as never, + scenesService as never, config as never, ); } diff --git a/web-server/test/scene-config.spec.ts b/web-server/test/scene-config.spec.ts new file mode 100644 index 0000000..983a21e --- /dev/null +++ b/web-server/test/scene-config.spec.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeSceneConfig, SCENE_CONFIG_DEFAULTS } from '../src/scenes/scene-config'; +import { toSceneDto } from '../src/scenes/scene.serializer'; +import { PublishStatus, SourceType } from '../src/common/enums'; +import type { Scene } from '../src/entities/scene.entity'; + +/** + * 场景物理参数(T-WEB-015)。 + * + * 服务端**只存参数、不跑物理**,所以这里锁两件事: + * 1. 越界/非法输入一律被收敛成可解释的值(客户端拿到就一定能用); + * 2. 缺省即 `SCENE_CONFIG_DEFAULTS`(重力 -9.8 等),且序列化结构稳定。 + */ +describe('SceneConfig 归一化', () => { + it('空输入 -> 默认值(重力 -9.8、无风、跟随相机)', () => { + const config = normalizeSceneConfig(undefined); + expect(config).toEqual(SCENE_CONFIG_DEFAULTS); + expect(config.physics.gravity).toEqual({ x: 0, y: -9.8, z: 0 }); + expect(config.physics.wind.gustAmplitude).toBe(0); + expect(config.camera.mode).toBe('follow'); + }); + + it('越界数值被夹紧(重力 ±200、摩擦 0~1、频率 0.01~5、雾 0~1)', () => { + const config = normalizeSceneConfig({ + physics: { + gravity: { x: 9999, y: -9999, z: 0 }, + wind: { base: { x: 500, y: 0, z: 0 }, gustAmplitude: 999, gustFrequency: 99 }, + ground: { friction: 5, restitution: -1, slopeLimit: 120 }, + }, + environment: { ambientIntensity: 99, fogDensity: 9 }, + camera: { distance: 999, height: -5 }, + }); + expect(config.physics.gravity.x).toBe(200); + expect(config.physics.gravity.y).toBe(-200); + expect(config.physics.wind.base.x).toBe(100); + expect(config.physics.wind.gustAmplitude).toBe(100); + expect(config.physics.wind.gustFrequency).toBe(5); + expect(config.physics.ground.friction).toBe(1); + expect(config.physics.ground.restitution).toBe(0); + expect(config.physics.ground.slopeLimit).toBe(89); + expect(config.environment.ambientIntensity).toBe(4); + expect(config.environment.fogDensity).toBe(1); + expect(config.camera.distance).toBe(50); + expect(config.camera.height).toBe(0); + }); + + it('非法枚举回退默认;风坐标系只认 local/world', () => { + const config = normalizeSceneConfig({ + physics: { colliders: 'banana', wind: { space: 'space' } }, + camera: { mode: 'fly' }, + environment: { ambientColor: 'red' }, + }); + expect(config.physics.colliders).toBe('auto'); + expect(config.physics.wind.space).toBe('world'); + expect(config.camera.mode).toBe('follow'); + expect(config.environment.ambientColor).toBe(SCENE_CONFIG_DEFAULTS.environment.ambientColor); + + const local = normalizeSceneConfig({ physics: { colliders: 'mesh', wind: { space: 'local' } } }); + expect(local.physics.colliders).toBe('mesh'); + expect(local.physics.wind.space).toBe('local'); + }); + + it('出生点最多 16 个,坐标与朝向被夹紧', () => { + const many = Array.from({ length: 40 }, () => ({ x: 1e9, y: 0, z: 0, yaw: 1e9 })); + const config = normalizeSceneConfig({ spawnPoints: many }); + expect(config.spawnPoints.length).toBe(16); + expect(config.spawnPoints[0].x).toBe(1000); + expect(config.spawnPoints[0].yaw).toBe(360); + }); + + it('合法 #RRGGBB 颜色被接受并小写化', () => { + const config = normalizeSceneConfig({ environment: { ambientColor: '#AABBCC' } }); + expect(config.environment.ambientColor).toBe('#aabbcc'); + }); +}); + +describe('SceneDto 序列化', () => { + it('带出 config 与来源五元组;URL 由调用方注入', () => { + const scene = { + id: 3, + name: '黄昏广场', + glbAssetId: 11, + thumbnailAssetId: 12, + config: normalizeSceneConfig({ physics: { gravity: { x: 0, y: -3.5, z: 0 } } }), + stats: { triangles: 1234 }, + fileSize: 456, + fileSha256: 'abc', + conversionNote: '直接导入(GLB,未做重编码)', + sourceType: SourceType.Other, + sourceName: 'x', + sourceUrl: 'y', + license: 'z', + author: 'a', + status: PublishStatus.Pending, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + } as unknown as Scene; + const dto = toSceneDto(scene, { glbUrl: '/static/scene/x.glb', thumbnailUrl: '/static/scene/x.png' }); + expect(dto.id).toBe(3); + expect(dto.glbUrl).toBe('/static/scene/x.glb'); + expect(dto.config?.physics.gravity.y).toBe(-3.5); + expect(dto.stats).toEqual({ triangles: 1234 }); + expect(dto.status).toBe(PublishStatus.Pending); + expect(dto.createdAt).toBe('2026-01-01T00:00:00.000Z'); + }); +});