docs(web): 任务说明、DSH 约束与交付文档

This commit is contained in:
2026-09-14 19:00:55 +08:00
parent 5a8b77b0a4
commit b9215d4190
16 changed files with 1461 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
# DSH.md —— opendance-web(OpenDance Studio 项目一:Web 管理服务端)
## 0. 本文件的作用
本文件是 **opendance-web 仓库内**的 DSH 执行约束。顶层说明书见用户提供的
《DSH 开发文档:OpenDance Studio》,二者冲突时以顶层文档为准。
## 1. 项目定位
Web 管理服务端是另两端(Windows 客户端、Android 辅助 App)的**唯一数据源**与
**契约权威**。它管理歌曲、VRM 模型与动作来源,并输出冻结的 REST / WebSocket /
数据模型契约。
```
现有 MySQL 实例(外部,本项目不部署)
│
▼
opendance-web ←── 本仓库
│ 冻结 REST API + 数据模型 + WebSocket 关键点协议
├──▶ opendance-windows
└──▶ opendance-android
```
## 2. 技术栈
| 组件 | 选型 |
|---|---|
| 后端 | NestJS 11 |
| ORM | TypeORM 0.3 |
| 数据库 | **现有 MySQL 兼容实例**(当前部署为 MariaDB 10.11.19),mysql2 驱动 |
| 管理前端 | React 18 + Vite + TypeScript |
| 3D 预览 | three.js + @pixiv/three-vrm |
| 多格式解析 | BVH 内置纯 TS 解析器;FBX/glTF 走可选外部 Assimp |
| 认证 | **本版本不含认证**(内网部署);上线前须补 ≥ T-WEB-011 |
## 3. 单次会话加载范围
允许加载:
1. 本文件 `DSH.md`
2. `project.yaml`
3. 当前 `tasks/xxx.md`
4. 该任务文件中 `load` 列出的文件
5. `contracts/` 中被明确引用的**锚点片段**
禁止加载:
- `opendance-windows` / `opendance-android` 的任何源码
- 非当前任务的 `tasks/*.md`
- 完整 `contracts/` 目录(只引用锚点)
- `node_modules/`、`dist/`、构建产物、二进制、模型文件、上传存储
## 4. 上下文预算
| 项 | 建议值 |
|---|---|
| 单任务文件数 | ≤ 6 |
| 单任务代码行数 | ≤ 1500 |
| 单任务 Token | ≤ 12K |
| 契约引用 | 只引用锚点,不加载全文 |
## 5. 目录结构
```
opendance-web/
├── project.yaml # 任务清单与状态
├── DSH.md # 本文件
├── tasks/ # 单任务说明书(001~010)
├── contracts/ # ★ 跨端契约(权威)
│ ├── VERSION # 契约版本(唯一来源)
│ ├── rest-api.yaml # OpenAPI 3.0
│ ├── websocket-keypoints.md # 239 字节关键点帧 + 控制消息
│ ├── data-models.md # 资源字段与 VRM 优先规则
│ ├── skeleton-topology.md # MediaPipe 33 点拓扑 + 关节角
│ ├── model-formats.md # 支持格式、预处理产物、上传限制
│ ├── database.md # 现有 MySQL 连接与迁移约定
│ └── open-source-credits.md # 开源声明锚点(三端共用)
├── web-server/ # NestJS 服务端
│ ├── src/ # 源码(entities / modules / preprocess)
│ ├── admin/ # React 管理端(构建上下文)
│ ├── test/ # 单元测试 + 端到端冒烟
│ ├── scripts/ # migrate.mjs / check-contracts.mjs
│ └── Dockerfile
├── deploy/ # ★ 部署(不含 MySQL)
│ ├── docker-compose.yml
│ ├── install.sh
│ └── .env.example
├── scripts/package-release.sh # 发布产物打包
└── THIRD_PARTY.md
```
## 6. 硬性约束
1. **不部署 MySQL**。数据库为部署方提供的现有实例,连接信息只经环境变量注入。
2. **`DB_SYNCHRONIZE` 生产必须为 `false`**,所有表结构变更走 migration;每个
migration 必须可逆。
3. **绝不把凭证写入仓库**。`.env` 已在 `.gitignore` 中,只提交 `.env.example`。
4. **来源五元组强制必填**:`source_type` / `source_name` / `source_url` / `license`
/ `author`。任何资产入库缺一即 400。
5. **契约先行**:改接口先改 `contracts/`,再提升 `contracts/VERSION`,同步
`web-server/src/config/contract-versions.ts`,最后才改实现。
`npm run contracts:check` 在构建前拦截漂移。
6. **不使用 SMPL** 及其衍生拓扑(许可风险),统一 MediaPipe 33 点。
7. **上传文件永不使用原始文件名作为路径**,一律 `sha256` 去重后归档。
## 7. 每任务的执行步骤
```bash
# 1. 静态与单元校验
cd web-server
npm run contracts:check
npm run typecheck
npm run test
# 2. 构建
npm run build
# 3. 端到端冒烟(需数据库可连通)
npm run start:prod &
npm run test:smoke
# 4. 数据库迁移
npm run migration:run
# 5. 提交
git checkout -b task/T-WEB-0XX
git add . && git commit -m "feat(web): <scope>"
git push origin task/T-WEB-0XX
# Gitea 创建 PR → 合并到 dev
```
## 8. 环境变量
见 `web-server/.env.example` 与 `deploy/.env.example`。生产关键项:
```
DB_HOST DB_PORT DB_USER DB_PASS DB_NAME
DB_SYNCHRONIZE=false
STORAGE_DIR=/data/storage
UPLOAD_MAX_BYTES=536870912
ASSIMP_BIN=assimp # 可选
CONTRACTS_DIR=/contracts # 可选,读取实时契约版本
```
## 9. 上下文超标时的拆分策略
| 超标原因 | 拆分方式 |
|---|---|
| 单任务涉及多个端点 | 按端点拆 |
| 单文件过大 | 按类/函数拆,先生成接口再分实现 |
| 契约全文加载 | 改用锚点引用 |
| 需要参考其他项目 | 只读契约,不读源码 |
| UI 与逻辑耦合 | UI 任务只调用已定义接口 |
| 测试与实现同任务 | 测试单独任务,共享接口摘要 |
+149
View File
@@ -0,0 +1,149 @@
================================================================
OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明
================================================================
本服务端是 OpenDance Studio 三端中的第一端,负责管理歌曲、VRM 模型与
动作来源,是 Windows 客户端与 Android 辅助 App 的唯一数据源与契约权威。
重要:本服务端 **不部署、不管理 MySQL**。数据库为部署方提供的现有实例,
连接信息通过 .env 注入。
----------------------------------------------------------------
一、快速部署(Docker Compose)
----------------------------------------------------------------
前置要求
- Linux 服务器,已安装 Docker 与 docker compose
- 一个可连通的现有 MySQL / MariaDB 实例,并已创建数据库与账号:
CREATE DATABASE IF NOT EXISTS dpendance
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'dpendance'@'%' IDENTIFIED BY '<密码>';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES
ON dpendance.* TO 'dpendance'@'%';
FLUSH PRIVILEGES;
(本项目不会创建数据库本身,也不需要 GRANT OPTION / FILE / SUPER 等管理权限)
部署步骤
cd deploy
cp .env.example .env
vi .env # 填写 DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME
./install.sh
脚本会依次:校验 .env → 构建镜像 → 执行 TypeORM 迁移 → 启动服务 → 健康检查。
部署完成后
API: http://<host>:3000
Admin: http://<host>:8080
健康检查: http://<host>:3000/health
停止服务
./uninstall.sh # 保留上传数据卷
./uninstall.sh -v # 连数据卷一起删除
----------------------------------------------------------------
二、本地开发
----------------------------------------------------------------
后端
cd web-server
cp .env.example .env # 填写 DB_*
npm install
npm run migration:run # 首次需要
npm run start:dev # http://localhost:3000
管理前端
cd web-server/admin
npm install
npm run dev # http://localhost:5173(/api 与 /health 代理到 3000)
----------------------------------------------------------------
三、验证
----------------------------------------------------------------
cd web-server
npm run contracts:check # 契约版本一致性
npm run typecheck # TypeScript 严格检查
npm run test # 31 项单元测试
npm run build # 生产构建
npm run start:prod & # 启动
npm run test:smoke # 57 项端到端冒烟(需要数据库可连通)
cd ../admin && npm run build # 管理端构建
----------------------------------------------------------------
四、环境变量
----------------------------------------------------------------
DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME
现有 MySQL 实例连接信息(必填)
DB_SYNCHRONIZE
必须为 false。生产环境启动时若检测到 true 会直接中止启动。
所有表结构变更通过 TypeORM migration 执行。
DB_LOGGING 是否打印 SQL(默认 false)
PORT API 监听端口(默认 3000)
STORAGE_DIR 上传文件存储目录(默认 ./storage,容器内 /data/storage)
UPLOAD_MAX_BYTES 单文件上限,默认 536870912(512 MiB)
ASSIMP_BIN 可选:Assimp 可执行文件,FBX/glTF 动作预处理需要
CONTRACTS_DIR 可选:契约目录,设置后 /health 反映实时契约版本
----------------------------------------------------------------
五、主要 API
----------------------------------------------------------------
GET /health 健康检查(含数据库连通性)
GET /api/songs 曲目列表(分页 / 检索)
POST /api/songs 新建曲目
GET /api/songs/:id 曲目详情
PATCH /api/songs/:id 更新曲目
DELETE /api/songs/:id 删除曲目(软删除)
POST /api/songs/:id/audio 上传音频
GET /api/motions 动作列表
POST /api/motions 上传动作 + 来源信息
POST /api/motions/:id/preprocess BVH/FBX → MediaPipe33 关键点
GET /api/motions/:id/keypoints 下载关键点序列(NDJSON)
GET /api/models 模型列表
POST /api/models 上传模型 + 来源信息(VRM 优先)
POST /api/models/:id/primary 设为全局默认模型
GET /api/reviews 审核流水 / 待审列表
POST /api/reviews/:type/:id/approve 审核通过
POST /api/reviews/:type/:id/reject 审核驳回(必须填意见)
GET /api/catalog/export 客户端一次性拉取已发布目录
GET /api/catalog/version 契约版本 + 目录指纹
完整契约(OpenAPI 3.0)见 contracts/rest-api.yaml。
上传文件通过 /static/<kind>/<yyyy>/<mm>/<sha256>.ext 访问,
相同内容只落盘一次(sha256 去重)。
----------------------------------------------------------------
六、目录结构
----------------------------------------------------------------
project.yaml 任务清单与状态
DSH.md 本项目 DSH 执行约束
tasks/ 单任务说明书(T-WEB-001 ~ 010)
contracts/ 跨端契约(权威,改接口先改这里)
web-server/ NestJS 服务端源码
web-server/admin/ React 管理端
deploy/ Docker Compose 部署(不含 MySQL)
THIRD_PARTY.md 第三方开源项目声明
----------------------------------------------------------------
七、安全提示
----------------------------------------------------------------
1. .env 已被 .gitignore 忽略,禁止提交;仓库中只保留 .env.example。
2. 数据库账号使用最小权限,不授予 GRANT OPTION / FILE / SUPER。
3. 本版本未内置管理端登录认证,请部署在内网或由反向代理提供鉴权。
4. 上传的模型与动作必须填写来源五元组并通过审核后才对客户端可见。
5. 未使用 SMPL 拓扑,统一 MediaPipe 33 点,规避非商用许可限制。
----------------------------------------------------------------
八、第三方开源声明
----------------------------------------------------------------
见 THIRD_PARTY.md,或访问 http://<admin-host>:8080/ 的「关于」页面。
+124
View File
@@ -0,0 +1,124 @@
# 第三方开源项目声明
**OpenDance Studio Web 管理服务端 v0.1.0**
本软件使用了以下开源项目,特此声明。完整锚点见 `contracts/open-source-credits.md`
(三端共用,任一端新增依赖须回到本项目更新该表)。
## 1. MediaPipe
- 版权:Copyright 2023 Google LLC
- 许可证:Apache License 2.0
- 源链接:https://github.com/google-ai-edge/mediapipe
- 使用端:Android 辅助 App(本项目仅使用其 **33 点骨骼拓扑定义**作为契约,
不包含其代码)
## 2. Three.js
- 许可证:MIT
- 源链接:https://github.com/mrdoob/three.js
- 使用端:Web 管理端(模型 3D 预览)
## 3. @pixiv/three-vrm
- 许可证:MIT
- 源链接:https://github.com/pixiv/three-vrm
- 使用端:Web 管理端(VRM 加载与渲染)
## 4. Assimp
- 许可证:BSD-3-Clause
- 源链接:https://github.com/assimp/assimp
- 使用端:Web 服务端(**可选**外部依赖,FBX/glTF 动作 → BVH 转换)、Windows 客户端
## 5. Godot Engine
- 版权:Copyright Godot Engine contributors
- 许可证:MIT
- 源链接:https://github.com/godotengine/godot
- 使用端:Windows 客户端
## 6. godot-vrm
- 许可证:MIT
- 源链接:https://github.com/V-Sekai/godot-vrm
- 使用端:Windows 客户端
## 7. NestJS
- 许可证:MIT
- 源链接:https://github.com/nestjs/nest
- 使用端:Web 服务端
## 8. TypeORM
- 许可证:MIT
- 源链接:https://github.com/typeorm/typeorm
- 使用端:Web 服务端
## 9. OkHttp
- 许可证:Apache License 2.0
- 源链接:https://github.com/square/okhttp
- 使用端:Android 辅助 App
## 10. bvh
- 许可证:MIT
- 源链接:https://github.com/aimoonchen/bvh
- 使用端:Web 服务端(BVH 解析参考实现)、Android 辅助 App
---
## 补充声明(实际引入的构建期/运行时依赖)
以下为文档模板 10 项之外、本项目实际引入的依赖,一并声明以保证完整性。
## 11. React
- 许可证:MIT
- 源链接:https://github.com/facebook/react
- 使用端:Web 管理端
## 12. Vite
- 许可证:MIT
- 源链接:https://github.com/vitejs/vite
- 使用端:Web 管理端(构建工具)
## 13. mysql2
- 许可证:MIT
- 源链接:https://github.com/sidorares/node-mysql2
- 使用端:Web 服务端(MySQL/MariaDB 驱动)
## 14. class-validator / class-transformer
- 许可证:MIT
- 源链接:https://github.com/typestack/class-validator ,
https://github.com/typestack/class-transformer
- 使用端:Web 服务端(请求校验与转换)
## 15. CameraX
- 许可证:Apache License 2.0
- 源链接:https://developer.android.com/jetpack/androidx/releases/camera
- 使用端:Android 辅助 App
## 16. Jetpack Compose
- 许可证:Apache License 2.0
- 源链接:https://developer.android.com/jetpack/compose
- 使用端:Android 辅助 App
---
## 许可合规说明
1. 本项目自身代码以 MIT 许可发布。
2. 上述依赖的完整许可证文本随各依赖包的 `LICENSE` 文件分发;发布产物中
`web-server/node_modules/` 内已包含各依赖的许可证副本。
3. **未使用 SMPL 模型及其衍生拓扑**,统一采用 MediaPipe 33 点拓扑,
以规避 SMPL 的非商用许可限制。
4. 上传的模型与动作资产必须填写来源五元组(来源类型、名称、链接、许可、作者),
并在管理端审核界面展示、由审核流程校验,拒绝侵权内容入库。
+71
View File
@@ -0,0 +1,71 @@
project: opendance-web-server
version: 0.1.0
repository: opendance-web
default_branch: main
contracts_version: 0.1.0
# 契约变更流程(文档 §7.2):
# 任何跨端契约变更必须先在本项目修改 contracts/ 并提升版本,
# 再在 opendance-windows / opendance-android 创建适配任务。
database:
# 使用现有实例,本项目不部署、不管理 MySQL
managed_by: external
engine: MySQL 兼容(当前部署为 MariaDB 10.11.19)
name: dpendance
charset: utf8mb4
migrations: TypeORM migration
synchronize: forbidden
tasks:
- id: T-WEB-001
file: tasks/001-scaffold.md
scope: NestJS 骨架 + 连接现有 MySQL
status: done
- id: T-WEB-002
file: tasks/002-db-migration.md
scope: TypeORM 迁移生成 opendance 表结构
status: done
- id: T-WEB-003
file: tasks/003-song-crud.md
scope: /api/songs CRUD
status: done
- id: T-WEB-004
file: tasks/004-motion-upload.md
scope: 动作数据上传 + 来源字段
status: done
- id: T-WEB-005
file: tasks/005-model-upload.md
scope: 模型上传 + VRM 优先 + 来源字段
status: done
- id: T-WEB-006
file: tasks/006-keypoint-preprocess.md
scope: BVH/FBX → 关键点序列预处理
status: done
- id: T-WEB-007
file: tasks/007-upload-review.md
scope: 上传审核流程
status: done
- id: T-WEB-008
file: tasks/008-admin-ui-songs.md
scope: 歌曲管理界面
status: done
- id: T-WEB-009
file: tasks/009-admin-ui-models.md
scope: 模型管理界面 + 来源展示
status: done
- id: T-WEB-010
file: tasks/010-deploy.md
scope: 部署脚本(不部署 MySQL)
status: done
deliverable:
artifact: opendance-web-v0.1.0.tar.gz
packaging: Docker Compose(不含 MySQL)
includes:
- web-server/ # NestJS 源码 + 构建产物
- admin/ # React 管理端构建产物
- deploy/ # docker-compose.yml / install.sh / .env.example
- contracts/ # 当前版本契约快照
- THIRD_PARTY.md # 开源声明
- README.txt # 使用说明
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# ============================================================
# 打包发布产物:opendance-web-v0.1.0.tar.gz
#
# 产物结构(文档 §4.4):
# web-server/ NestJS 源码 + 构建产物
# admin/ React 管理端构建产物
# deploy/ docker-compose.yml / install.sh / .env.example
# contracts/ 当前版本契约快照
# THIRD_PARTY.md 开源项目声明
# README.txt 使用说明
#
# 用法:./scripts/package-release.sh [版本号]
# ============================================================
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
VERSION="${1:-0.1.0}"
NAME="opendance-web-v${VERSION}"
OUT_DIR="$REPO_ROOT/dist-release"
STAGE="$OUT_DIR/$NAME"
log() { printf '\033[1;34m[package]\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
# ---------- 1. 契约一致性 ----------
log "校验契约版本一致性..."
( cd web-server && node scripts/check-contracts.mjs )
# ---------- 2. 后端构建 ----------
log "构建后端..."
( cd web-server && npm run build )
# ---------- 3. 管理端构建 ----------
if [ -d web-server/admin/node_modules ]; then
log "构建管理端..."
( cd web-server/admin && npm run build )
else
log "警告:web-server/admin/node_modules 不存在,跳过管理端构建(产物将缺少 admin/)"
fi
# ---------- 4. 组织产物目录 ----------
log "整理产物目录 $STAGE ..."
rm -rf "$STAGE"
mkdir -p "$STAGE"
# 后端源码 + 构建产物(排除依赖、测试、存储、密钥)
rsync -a --delete \
--exclude 'node_modules' \
--exclude 'storage' \
--exclude 'data' \
--exclude '.env' \
--exclude '.env.local' \
--exclude 'admin' \
web-server/ "$STAGE/web-server/"
# 管理端构建产物(文档要求 admin/ 与 web-server/ 平级)
if [ -d web-server/admin/dist ]; then
mkdir -p "$STAGE/admin"
cp -r web-server/admin/dist/. "$STAGE/admin/"
cp web-server/admin/nginx.conf "$STAGE/admin/" 2>/dev/null || true
else
log "警告:未找到 web-server/admin/dist,产物不含 admin/"
fi
cp -r deploy "$STAGE/deploy"
rm -f "$STAGE/deploy/.env"
cp -r contracts "$STAGE/contracts"
cp THIRD_PARTY.md README.txt project.yaml DSH.md "$STAGE/"
cp -r tasks "$STAGE/tasks"
# ---------- 5. 打包 ----------
log "生成压缩包..."
( cd "$OUT_DIR" && tar -czf "$NAME.tar.gz" "$NAME" )
log "完成:$OUT_DIR/$NAME.tar.gz"
echo
echo "产物内容:"
tar -tzf "$OUT_DIR/$NAME.tar.gz" | head -20
echo " ...(共 $(tar -tzf "$OUT_DIR/$NAME.tar.gz" | wc -l) 项)"
echo
echo "部署方式:解压后进入 deploy/,复制 .env.example 为 .env 并填写数据库信息,执行 ./install.sh"
+63
View File
@@ -0,0 +1,63 @@
# T-WEB-001 骨架 + 连接现有 MySQL
- **状态**:done
- **依赖**:无
## 目标
建立 NestJS 服务端骨架,并**连接部署方提供的现有 MySQL 实例**。本项目不部署、
不管理 MySQL。
## load
```
DSH.md
contracts/database.md
web-server/package.json
web-server/src/main.ts
web-server/src/app.module.ts
web-server/src/config/configuration.ts
web-server/src/db/data-source.ts
```
## 交付物
| 文件 | 说明 |
|---|---|
| `web-server/package.json` | NestJS 11 + TypeORM 0.3 + mysql2 依赖与脚本 |
| `web-server/src/main.ts` | 启动引导:环境校验、全局校验管道、CORS、静态资源、优雅关闭 |
| `web-server/src/app.module.ts` | 根模块,`TypeOrmModule.forRootAsync` 由 `AppConfig` 注入连接信息 |
| `web-server/src/config/configuration.ts` | 类型化配置层,唯一读取 `process.env` 的位置 |
| `web-server/src/config/config.module.ts` | 全局配置模块 |
| `web-server/src/db/data-source.ts` | TypeORM DataSource(CLI + 运行期共用),`synchronize: false` |
| `web-server/src/health/health.controller.ts` | `GET /health`,真实探测数据库连通性 |
| `web-server/.env.example` | 环境变量样例 |
## 关键实现
- `synchronize` 在代码中恒为 `false`,不读取环境变量覆盖,从结构上杜绝生产误开。
- `validateEnv()` 在启动时检查必填项,生产环境检测到 `DB_SYNCHRONIZE=true` 直接
以退出码 1 中止。
- `/health` 返回数据库版本、库名、待执行迁移数、存储目录、Assimp 可用性、契约版本;
数据库不可用时返回 **503**。
## 验收
```bash
cd web-server
cp .env.example .env # 填写 DB_* 后
npm install
npm run start:prod
curl -s http://localhost:3000/health
```
期望:`status=ok`、`database.connected=true`、`database.server` 形如
`10.11.19-MariaDB-...`。
> **环境差异记录**:文档假设 MySQL 8.x,实际部署实例为 **MariaDB 10.11.19**。
> 已确认 TypeORM `mysql` 驱动(mysql2)兼容,SQL 限制在两者共同子集内,
> 详见 `contracts/database.md`。
## 结果
- 已连通 `192.168.150.10:3306/dpendance`,`/health` 返回 200 且 `connected=true`。
+57
View File
@@ -0,0 +1,57 @@
# T-WEB-002 TypeORM 迁移生成表结构
- **状态**:done
- **依赖**:T-WEB-001
## 目标
用 TypeORM migration 在现有实例的 `dpendance` 库中建立全部表结构。不使用
`DB_SYNCHRONIZE`,迁移必须可逆。
## load
```
DSH.md
contracts/database.md
contracts/data-models.md
web-server/src/entities/*.entity.ts
web-server/src/migrations/*.ts
web-server/src/db/data-source.ts
```
## 表结构
| 表 | 用途 | 关键列 |
|---|---|---|
| `assets` | 上传文件元数据 | `sha256` 唯一索引(去重)、`stored_path`、`size` |
| `songs` | 曲目 | 来源五元组、`status`、`deleted_at` |
| `motions` | 动作数据 | `song_id` FK(ON DELETE SET NULL)、预处理产物列 |
| `models` | 角色模型 | `format`、`vrm_version`、`is_primary` |
| `review_records` | 审核流水 | `(target_type, target_id)` 复合索引 |
统一约定(`contracts/database.md`):
- 表名 `snake_case` 复数;主键 `BIGINT AUTO_INCREMENT`
- 时间列 `DATETIME`;软删除 `deleted_at`
- Tags 使用 `simple-json`(MariaDB 下落 LONGTEXT,规避 JSON 类型差异)
## 交付物
- `web-server/src/entities/{asset,song,motion,model,review-record}.entity.ts`
- `web-server/src/migrations/1789382903060-InitSchema.ts`(由 `migration:generate`
对真实库生成,保证与实体零漂移)
- `web-server/src/db/data-source.ts` 的 `migrations` 通配配置
## 验收
```bash
cd web-server
npm run migration:run # 1 migrations were executed successfully
npm run migration:show # [X] 1 InitSchema1789382903060
npm run migration:revert && npm run migration:run # 验证可逆
```
## 结果
- 迁移已在 `dpendance` 库执行成功,创建 5 张业务表 + `typeorm_migrations`。
- `down()` 完整删除索引、外键与表,可逆性通过 `migration:revert` 验证。
+52
View File
@@ -0,0 +1,52 @@
# T-WEB-003 /api/songs CRUD
- **状态**:done
- **依赖**:T-WEB-002
## 目标
曲目的增删改查与分页检索,字段语义符合 `contracts/data-models.md` §2。
## load
```
DSH.md
contracts/rest-api.yaml # 锚点:#/paths/~1api~1songs
contracts/data-models.md # 锚点:§1 通用约定、§2 Song
web-server/src/songs/*
web-server/src/common/source.dto.ts
web-server/src/common/pagination.ts
```
## 端点
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/songs` | 分页 + `q`/`artist`/`status` 过滤 |
| POST | `/api/songs` | 新建(来源五元组必填) |
| GET | `/api/songs/:id` | 详情 |
| PATCH | `/api/songs/:id` | 更新(来源需五元组同时提交) |
| DELETE | `/api/songs/:id` | **软删除** |
| POST | `/api/songs/:id/audio` | 上传音频(multipart,字段名 `file`) |
| POST | `/api/songs/:id/cover` | 上传封面 |
## 关键实现
- `likePattern()` 转义 `%` `_` `\`,用户输入不会被当作 LIKE 模式。
- `assertSourceTupleComplete()` 实现「全有或全无」:PATCH 只给部分来源字段 → 400。
- PATCH 直接改 `status` 时校验状态机;非法流转 → 409 并提示改用 `/api/reviews`。
- `toDtos()` 批量解析 asset → URL,避免 N+1。
## 验收
```bash
npm run test:smoke
```
冒烟脚本 [1] 段落覆盖:创建 201、id 为 number、来源回显、tags 往返、bpm 为 number、
分页结构、模糊查询命中、音频上传后 `audioUrl` 以 `/static/` 开头、PATCH 生效、
部分来源字段 400、软删除后不再出现在列表。
## 结果
全部通过(见 `web-server/test/smoke.mjs`)。
+56
View File
@@ -0,0 +1,56 @@
# T-WEB-004 动作数据上传 + 来源字段
- **状态**:done
- **依赖**:T-WEB-002
## 目标
上传动作文件(BVH / FBX / glTF / GLB / keypoints-json),**强制填写来源五元组**。
## load
```
DSH.md
contracts/rest-api.yaml # 锚点:#/paths/~1api~1motions
contracts/data-models.md # 锚点:§3 Motion
contracts/model-formats.md # 锚点:§2 动作格式、§3 上传限制
web-server/src/motions/*
web-server/src/storage/storage.service.ts
```
## 端点
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/motions` | 分页 + `q`/`songId`/`format`/`status` 过滤 |
| POST | `/api/motions` | multipart 上传 |
| GET | `/api/motions/:id` | 详情 |
| PATCH | `/api/motions/:id` | 更新元数据(含 `detachSong`) |
| DELETE | `/api/motions/:id` | 软删除 |
## 存储策略(`contracts/model-formats.md` §3)
```
${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}
```
- 上传先落 `${STORAGE_DIR}/tmp`(multer diskStorage),避免大文件占用内存
- `StorageService` 流式计算 sha256;**相同内容只落盘一次**,复用 `assets` 记录
- 并发同文件上传靠 `assets.sha256` 唯一索引兜底,冲突后回退到已存在记录
- 路径永不使用原始文件名;`absolutePath()` 拒绝逃逸存储根目录
## 校验
- 扩展名白名单:`.bvh .fbx .gltf .glb .json`,其他 → 400
- 来源五元组缺任一项 → 400
- `songId` 指向不存在的曲目 → 400
- 单文件上限 `UPLOAD_MAX_BYTES`(默认 512 MiB)
## 验收
```bash
npm run test:smoke
```
冒烟 [2] 段落:上传 201、自动识别 `format=bvh`、`fileSize` 记录、缺来源 400、
`evil.exe` 400。
+61
View File
@@ -0,0 +1,61 @@
# T-WEB-005 模型上传 + VRM 优先 + 来源字段
- **状态**:done
- **依赖**:T-WEB-002
## 目标
上传角色模型,**VRM 优先**,识别 VRM 版本,并维护全局默认模型。
## load
```
DSH.md
contracts/rest-api.yaml # 锚点:#/paths/~1api~1models
contracts/data-models.md # 锚点:§4 Model、§4.1 VRM 优先规则
contracts/model-formats.md # 锚点:§1 角色模型
web-server/src/models/*
web-server/src/models/vrm-inspector.ts
```
## 端点
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/models` | 分页 + `q`/`format`/`status`/`primaryOnly` |
| POST | `/api/models` | multipart 上传(`file` + 名称 + 来源五元组 + `isPrimary`) |
| GET | `/api/models/:id` | 详情 |
| PATCH | `/api/models/:id` | 更新 |
| DELETE | `/api/models/:id` | 软删除 |
| POST | `/api/models/:id/primary` | 设为全局默认模型 |
## VRM 优先规则(`contracts/data-models.md` §4.1,已实现于 `resolveDefaultModelId()`)
```
1. 显式 modelId(客户端侧)
2. format='vrm' 且 published 且 is_primary=1
3. format='vrm' 且 published,按 id 升序取第一个
4. published,按 (format='vrm' DESC, id ASC) 取第一个
5. 无 → 客户端使用内置占位模型
```
`POST /:id/primary` 在**同一事务**内先清零其余 `is_primary`,保证互斥。
## VRM 版本识别
`vrm-inspector.ts` 直接读取 GLB 头部 JSON chunk,不依赖完整 3D 库:
- `extensions.VRMC_vrm` → VRM **1.0**(取 `specVersion`)
- `extensions.VRM` → VRM **0.x**
- 两者皆无 → `null`(普通 GLB)
## 验收
```bash
npm run test # test/vrm-inspector.spec.ts,6 例
npm run test:smoke # 冒烟 [4] 段落
```
覆盖:真实 GLB 构造下识别 1.0 / 0.0 / null、文本 glTF 不误判、过小文件报错、
非 JSON 首 chunk 报错、上传 201、`isPrimary` 置位、缺来源 400、按格式过滤列表、
列表含来源信息(管理端展示用)。
+67
View File
@@ -0,0 +1,67 @@
# T-WEB-006 BVH/FBX → 关键点序列预处理
- **状态**:done
- **依赖**:T-WEB-004
## 目标
把上传的 3D 动作数据转换为与 Android 端输出**同构**的 MediaPipe 33 点关键点序列,
使 Windows 端 DTW 引擎只有一条输入通路。
## load
```
DSH.md
contracts/model-formats.md # 锚点:§2 动作格式、§2.1 预处理产物、§2.2 关节名映射
contracts/skeleton-topology.md # 锚点:§1 33 点、§3 父节点表、§5 关节角
web-server/src/preprocess/*
```
## 端点
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/motions/:id/preprocess` | 执行预处理,返回更新后的 Motion |
| GET | `/api/motions/:id/keypoints` | 下载 NDJSON 关键点序列 |
## 实现分层
| 文件 | 职责 |
|---|---|
| `bvh-parser.ts` | 纯 TS BVH 词法/语法解析 + 逐帧前向运动学(FK) |
| `math3d.ts` | 向量/矩阵工具、旋转合成、关节角余弦 |
| `joint-map.ts` | BVH 关节名 → MediaPipe 点索引的默认映射表 |
| `retarget.ts` | 单帧解算 35 项(33 点 + neck/pelvis 虚拟关节)→ 骨盆归零 → 自适应缩放 → 投影 |
| `keypoint-codec.ts` | 239 字节二进制帧编解码、NDJSON 序列化/解析、关键点 JSON 校验 |
| `assimp.ts` | 可选外部 Assimp 调用(FBX/glTF → BVH) |
| `preprocess.service.ts` | 编排、落盘、状态与错误记录 |
## 拓扑与缩放(契约冻结)
- 33 点顺序与父节点表来自 `contracts/skeleton-topology.md`,虚拟关节
`neck=33`、`pelvis=34` 只参与关节角计算,**不进入输出序列**。
- 投影:`x_img = 0.5 + x·s`,`y_img = 0.5 − y·s`,`z_img = −z·s`,
`s = 0.35 / max(|x|,|y|,|z|)`(按整段动作自适应,保证不出画)。
- 置信度分层:直接映射 **1.0**、按层级推算 **0.5**、无法推算 **0.0**。
## 格式支持
| 格式 | 处理 |
|---|---|
| `.bvh` | 内置解析器,无外部依赖 |
| `.json` | 直通校验与规范化(须符合 `mediapipe33@0.1.0`) |
| `.fbx` `.gltf` `.glb` | 调用可选 Assimp 转 BVH;未安装 Assimp 时写入可读错误并置 `preprocessState=failed` |
## 验收
```bash
npm run test # 25 例:解析 / FK / 重定向 / 编解码 / 拓扑 / 契约版本
npm run test:smoke # 冒烟 [3] 段落
```
覆盖:60 帧样例经预处理后 **60 帧 × 33 点 × 9 关节角**、坐标落在
MediaPipe 归一化范围、置信度分层、时间戳按帧率推进、序列随时间变化、
NDJSON 往返、解码器拒绝错误长度与 magic。
样例数据由 `test/fixtures/generate-sample-bvh.mjs` 生成(60 帧挥手 + 深蹲,
21 关节 / 63 通道),可复现。
+55
View File
@@ -0,0 +1,55 @@
# T-WEB-007 上传审核流程
- **状态**:done
- **依赖**:T-WEB-003 / 004 / 005
## 目标
对歌曲、动作、模型三类资产实施统一的状态机与审核流水,确保只有审核通过的资产
被客户端消费。
## load
```
DSH.md
contracts/rest-api.yaml # 锚点:#/paths/~1api~1reviews
contracts/data-models.md # 锚点:§1.2 PublishStatus、§5 ReviewRecord
contracts/database.md # 锚点:状态机
web-server/src/reviews/*
```
## 状态机(契约冻结)
```
draft ──submit──▶ pending ──approve──▶ published
│
└──reject──▶ rejected ──submit──▶ pending
```
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/reviews` | 不传 `status` → 审核流水;传 `status` → 该状态待审资产(三类合并) |
| GET | `/api/reviews/:targetType/:id/history` | 单个资产的审核历史 |
| POST | `/api/reviews/:targetType/:id/submit` | → `pending` |
| POST | `/api/reviews/:targetType/:id/approve` | → `published` |
| POST | `/api/reviews/:targetType/:id/reject` | → `rejected`,**必须填写意见** |
| POST | `/api/reviews/:targetType/:id/withdraw` | → `draft` |
## 关键实现
- `STATUS_TRANSITIONS` 是状态机的唯一来源;非法流转一律 **409**,
重复审核同一状态同样 409。
- 驳回未填 `comment` → **400**(合规要求:拒绝侵权内容需留痕)。
- 每次成功流转都写一条 `review_records`,记录 `fromStatus` / `toStatus` /
`reviewer` / `comment`。
- 三类资产的直连 PATCH 也会校验状态机,避免绕过审核流程。
- `/api/catalog/export` 与 `/api/catalog/version` 只导出 `published`。
## 验收
```bash
npm run test:smoke
```
冒烟 [5] 段落:submit → pending、待审列表包含、驳回无意见 400、approve → published、
重复审核 409、流水恰好 2 条。
+53
View File
@@ -0,0 +1,53 @@
# T-WEB-008 歌曲管理界面
- **状态**:done
- **依赖**:T-WEB-003
## 目标
React 管理端的歌曲管理页面:列表、检索、新建/编辑、音频上传与试听、删除。
## load
```
DSH.md
contracts/rest-api.yaml # 锚点:#/paths/~1api~1songs、#/components/schemas/Song
contracts/data-models.md # 锚点:§2 Song
web-server/admin/src/**
web-server/admin/package.json
```
## 交付物
`web-server/admin/`(React 18 + Vite + TypeScript):
| 路径 | 说明 |
|---|---|
| `src/pages/SongsPage.tsx` | 歌曲列表 + 分页 + 检索(`q` / `status` / `artist`) |
| `src/components/SongForm*` | 新建/编辑表单,含来源五元组 |
| `src/api/client.ts` | fetch 封装;非 2xx 抛 `ApiError`,兼容 Nest 的 `message: string[]` |
| `src/api/types.ts` | 与契约对齐的 TypeScript 类型 |
| `src/styles.css` | 无 UI 框架,纯 CSS |
## 页面能力
- 分页表格 + 关键词/状态/歌手过滤
- 新建与编辑:曲名、歌手、专辑、语言、时长(秒输入 → 毫秒提交)、BPM、
难度 1–5、标签(逗号分隔)、状态、**来源五元组**
- 音频上传(`POST /api/songs/:id/audio`,字段名 `file`)与 `<audio controls>` 试听
- 删除需二次确认(软删除)
- 客户端校验:曲名/歌手与来源五项必填,`sourceUrl` 必须以 `http` 开头
- 加载中 / 空列表 / 请求失败三态均有明确反馈,绝不出现白屏
## 验收
```bash
cd web-server/admin
npm run build # tsc -b && vite build 必须成功
```
## 构建与运行
- 开发:`npm run dev`(5173,`/api` 与 `/health` 代理到 `localhost:3000`)
- 生产:多阶段 Dockerfile 构建后由 nginx 托管,`/api` 反代到 `api:3000`
- `VITE_API_BASE` 可覆盖 API 基地址;留空表示同源
+49
View File
@@ -0,0 +1,49 @@
# T-WEB-009 模型管理界面 + 来源展示
- **状态**:done
- **依赖**:T-WEB-005
## 目标
React 管理端的模型管理页面:列表、上传、**来源信息完整展示**、VRM 优先设置、
3D 预览。
## load
```
DSH.md
contracts/rest-api.yaml # 锚点:#/paths/~1api~1models
contracts/data-models.md # 锚点:§4 Model、§4.1 VRM 优先规则
web-server/admin/src/pages/ModelsPage.tsx
web-server/admin/src/components/VrmPreview.tsx
```
## 交付物
| 路径 | 说明 |
|---|---|
| `src/pages/ModelsPage.tsx` | 模型列表 + 上传表单 + 设为默认 |
| `src/components/VrmPreview.tsx` | three.js + @pixiv/three-vrm 的 3D 预览面板 |
## 页面能力
- 表格列:预览缩略图、名称、格式(VRM 高亮徽章)、VRM 版本、是否默认、
**来源五元组**、许可、状态、操作
- **来源展示(本任务验收重点)**:整行展示 `sourceType` + `sourceName` + `author`
+ `license`,`sourceUrl` 可点击外链跳转
- 上传:`POST /api/models`(multipart `file` + 名称 + `isPrimary` + 来源五元组 +
状态),客户端校验与歌曲页一致
- 「设为默认」→ `POST /api/models/:id/primary`,成功后刷新列表以反映互斥结果
- 3D 预览:`GLTFLoader` + `VRMLoaderPlugin` 加载 `fileUrl`,
支持鼠标拖拽旋转与滚轮缩放,正确处理 VRM 0.x / 1.0 元数据差异、
未选择模型、加载失败三种状态,卸载时释放资源
## 验收
```bash
cd web-server/admin
npm run build # 必须成功
```
另由 `npm run test:smoke`([4] 段落)验证后端确实在列表接口返回完整来源字段,
供本页面展示。
+87
View File
@@ -0,0 +1,87 @@
# T-WEB-010 部署脚本(不部署 MySQL)
- **状态**:done
- **依赖**:T-WEB-001 ~ 009
## 目标
Web 服务端可在干净 Linux 服务器上**一键部署**,连接**现有 MySQL 实例**。
部署脚本**不创建、不管理 MySQL 容器**。
## load
```
DSH.md
contracts/database.md
deploy/docker-compose.yml
deploy/install.sh
deploy/.env.example
web-server/Dockerfile
web-server/admin/Dockerfile
```
## 交付物
| 文件 | 说明 |
|---|---|
| `deploy/docker-compose.yml` | 两个服务:`api`(NestJS)+ `admin`(nginx 静态站点)。**无 mysql 服务** |
| `deploy/install.sh` | 前置检查 → 构建 → 迁移 → 启动 → 健康检查 |
| `deploy/uninstall.sh` | 停止服务(默认保留数据卷) |
| `deploy/.env.example` | 部署环境变量样例 |
| `web-server/Dockerfile` | 多阶段构建,非 root 运行,内置 HEALTHCHECK |
| `web-server/admin/Dockerfile` | 多阶段:Node 构建 → nginx 托管 |
| `scripts/package-release.sh` | 生成 `opendance-web-v0.1.0.tar.gz` |
## compose 要点
- 数据库连接全部来自 `.env`;`DB_SYNCHRONIZE` 在 compose 中**硬编码为 `"false"`**
- `api` 的 `CONTRACTS_DIR=/contracts`,只读挂载仓库 `contracts/`,
使 `/health` 与 `/api/catalog/version` 能反映实时契约版本
- 上传文件持久化到命名卷 `opendance-storage` → `/data/storage`
- `admin` 依赖 `api` 的健康状态(`condition: service_healthy`)
- `admin` 的 nginx 将 `/api/` 与 `/health` 反代到 `http://api:3000`
## 迁移执行方式
`install.sh` 调用 `docker compose run --rm api npm run migration:run`。
`web-server/scripts/migrate.mjs` 会自动选择执行方式:
- 存在 `dist/db/data-source.js` → `typeorm -d dist/db/data-source.js migration:run`
(生产镜像已 `npm prune --omit=dev`,无需 ts-node)
- 否则 → `typeorm-ts-node-commonjs -d src/db/data-source.ts migration:run`(开发)
因此同一条命令在本地与容器内行为一致。
## 验收(文档 §4.4 T-WEB-010)
```bash
cp deploy/.env.example deploy/.env # 填写 DB_*
./deploy/install.sh
```
| 验收项 | 状态 |
|---|---|
| 干净 Linux 服务器执行 `deploy/install.sh` | ✅ 脚本就绪(本机无 Docker,未能实机执行,见下) |
| 连接现有 MySQL 成功 | ✅ 已用真实实例验证(`/health` 200) |
| TypeORM migration 执行成功,表结构完整 | ✅ 5 张业务表已创建并验证 |
| `curl http://localhost:3000/health` 通过 | ✅ 返回 200 + `connected: true` |
| Admin 页面可访问 | ✅ 前端 `npm run build` 通过,nginx 配置就绪 |
| 不创建、不管理 MySQL 容器 | ✅ compose 中无 mysql 服务,镜像内无数据库 |
> **环境限制说明**:本次执行环境的宿主机**未安装 Docker**(无 `docker` /
> `docker-compose`,无 `/var/run/docker.sock`),因此 `install.sh` 的
> `docker compose up` 路径未能实机跑通。已通过的等价验证:
> 应用以 `node dist/main.js` 直连真实数据库启动,`/health` 200,
> `npm run migration:run` 成功,端到端冒烟 57 项全通过。
> 需要在有 Docker 的机器上补做一次 `install.sh` 实机验收。
## 发布产物
```bash
./scripts/package-release.sh
# → dist-release/opendance-web-v0.1.0.tar.gz
```
内容:`web-server/`(源码 + dist)、`admin/`(构建产物)、`deploy/`、
`contracts/` 快照、`THIRD_PARTY.md`、`README.txt`。
+279
View File
@@ -0,0 +1,279 @@
/**
* OpenDance Studio Web 服务端 —— 端到端冒烟验收脚本。
*
* 覆盖 T-WEB-003 ~ T-WEB-009 的主要接口:
* 歌曲 CRUD → 动作上传 → 关键点预处理 → 关键点下载
* → 模型上传(VRM 优先)→ 审核流转 → 目录导出 → 负向用例
*
* 用法:
* API_BASE=http://127.0.0.1:3000 node test/smoke.mjs
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const API = process.env.API_BASE ?? 'http://127.0.0.1:3000';
let passed = 0;
let failed = 0;
const failures = [];
function check(name, condition, detail = '') {
if (condition) {
passed += 1;
console.log(` ✓ ${name}`);
} else {
failed += 1;
failures.push(`${name}${detail ? ` — ${detail}` : ''}`);
console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`);
}
}
async function request(method, path, { json, form } = {}) {
const init = { method };
if (json !== undefined) {
init.headers = { 'Content-Type': 'application/json' };
init.body = JSON.stringify(json);
}
if (form !== undefined) {
init.body = form;
}
const res = await fetch(`${API}${path}`, init);
const text = await res.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
return { status: res.status, body, headers: res.headers };
}
const SOURCE = {
sourceType: 'original',
sourceName: '冒烟测试来源',
sourceUrl: 'https://example.com/source',
license: 'MIT',
author: 'OpenDance QA',
};
function fileForm(fields, filename, content, mime = 'application/octet-stream') {
const form = new FormData();
for (const [key, value] of Object.entries(fields)) form.append(key, String(value));
form.append('file', new Blob([content], { type: mime }), filename);
return form;
}
/** 构造合法的 GLB(magic + version + length + JSON chunk) */
function buildGlb(json) {
const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8');
const padded = Buffer.concat([jsonBuf, Buffer.alloc((4 - (jsonBuf.length % 4)) % 4, 0x20)]);
const header = Buffer.alloc(12);
header.write('glTF', 0, 'ascii');
header.writeUInt32LE(2, 4);
header.writeUInt32LE(12 + 8 + padded.length, 8);
const chunkHeader = Buffer.alloc(8);
chunkHeader.writeUInt32LE(padded.length, 0);
chunkHeader.writeUInt32LE(0x4e4f534a, 4); // 'JSON'
return Buffer.concat([header, chunkHeader, padded]);
}
async function main() {
console.log(`\n== OpenDance Studio Web 服务端冒烟验收 @ ${API} ==\n`);
// ---------- 0. 健康检查 ----------
console.log('[0] 健康检查');
const health = await request('GET', '/health');
check('GET /health 返回 200', health.status === 200, `实际 ${health.status}`);
check('数据库已连接', health.body?.database?.connected === true, JSON.stringify(health.body?.database));
check('迁移无待执行项', health.body?.database?.migrationsPending === 0);
check('契约版本已暴露', Boolean(health.body?.contractVersion?.['rest-api']));
// ---------- 1. T-WEB-003 歌曲 CRUD ----------
console.log('\n[1] T-WEB-003 歌曲 CRUD');
const created = await request('POST', '/api/songs', {
json: {
title: `冒烟曲目 ${Date.now()}`,
artist: 'QA 歌手',
album: 'QA 专辑',
language: 'zh',
durationMs: 180000,
bpm: 128.5,
difficulty: 3,
tags: ['测试', 'dance'],
...SOURCE,
},
});
check('POST /api/songs 返回 201', created.status === 201, `实际 ${created.status} ${JSON.stringify(created.body)}`);
const songId = created.body?.id;
check('返回 id 为数字', typeof songId === 'number', `实际 ${typeof songId}`);
check('来源五元组回显完整', created.body?.sourceName === SOURCE.sourceName && created.body?.license === 'MIT');
check('tags 正确往返', Array.isArray(created.body?.tags) && created.body.tags.length === 2);
check('bpm 为数字而非字符串', typeof created.body?.bpm === 'number', `实际 ${typeof created.body?.bpm}`);
const list = await request('GET', '/api/songs?q=冒烟&page=1&pageSize=5');
check('GET /api/songs 分页结构正确', list.status === 200 && Array.isArray(list.body?.items) && typeof list.body?.total === 'number');
check('模糊查询命中新建曲目', list.body?.items?.some((s) => s.id === songId));
const audioForm = new FormData();
audioForm.append('file', new Blob([Buffer.from('ID3fake-audio-bytes')], { type: 'audio/mpeg' }), 'test.mp3');
const audio = await request('POST', `/api/songs/${songId}/audio`, { form: audioForm });
check('POST /api/songs/:id/audio 上传成功', audio.status === 201, `实际 ${audio.status}`);
check('audioUrl 已生成', typeof audio.body?.audioUrl === 'string' && audio.body.audioUrl.startsWith('/static/'), String(audio.body?.audioUrl));
const patched = await request('PATCH', `/api/songs/${songId}`, { json: { difficulty: 5 } });
check('PATCH /api/songs/:id 更新成功', patched.status === 200 && patched.body?.difficulty === 5);
const badSource = await request('PATCH', `/api/songs/${songId}`, { json: { license: 'GPL' } });
check('PATCH 只给部分来源字段 → 400', badSource.status === 400, `实际 ${badSource.status}`);
// ---------- 2. T-WEB-004 动作上传 ----------
console.log('\n[2] T-WEB-004 动作上传 + 来源字段');
const bvh = readFileSync(join(here, 'fixtures', 'sample.bvh'));
const motionRes = await request('POST', '/api/motions', {
form: fileForm(
{ name: '冒烟动作-挥手深蹲', songId, autoPreprocess: false, ...SOURCE },
'sample.bvh',
bvh,
'text/plain',
),
});
check('POST /api/motions 返回 201', motionRes.status === 201, `实际 ${motionRes.status} ${JSON.stringify(motionRes.body)}`);
const motionId = motionRes.body?.id;
check('自动识别格式为 bvh', motionRes.body?.format === 'bvh', String(motionRes.body?.format));
check('fileSize 已记录', typeof motionRes.body?.fileSize === 'number' && motionRes.body.fileSize > 0);
check('预处理初始状态为 none', motionRes.body?.preprocessState === 'none');
const missingSource = await request('POST', '/api/motions', {
form: fileForm({ name: '缺少来源' }, 'sample.bvh', bvh),
});
check('缺少来源五元组 → 400', missingSource.status === 400, `实际 ${missingSource.status}`);
const badExt = await request('POST', '/api/motions', {
form: fileForm({ name: '非法格式', ...SOURCE }, 'evil.exe', bvh),
});
check('非法扩展名 → 400', badExt.status === 400, `实际 ${badExt.status}`);
// ---------- 3. T-WEB-006 关键点预处理 ----------
console.log('\n[3] T-WEB-006 BVH → MediaPipe33 预处理');
const pre = await request('POST', `/api/motions/${motionId}/preprocess`);
check('POST /api/motions/:id/preprocess 成功', pre.status === 200, `实际 ${pre.status} ${JSON.stringify(pre.body)}`);
check('preprocessState = done', pre.body?.preprocessState === 'done', String(pre.body?.preprocessError));
check('keypointFormat 为 mediapipe33@0.1.0', pre.body?.keypointFormat === 'mediapipe33@0.1.0');
check('keypointFrames = 60', pre.body?.keypointFrames === 60, String(pre.body?.keypointFrames));
check('fps ≈ 30', Math.abs((pre.body?.fps ?? 0) - 30) < 0.01, String(pre.body?.fps));
const kp = await fetch(`${API}/api/motions/${motionId}/keypoints`);
const kpText = await kp.text();
const lines = kpText.trim().split('\n');
check('GET /api/motions/:id/keypoints 返回 200', kp.status === 200, `实际 ${kp.status}`);
check('NDJSON 共 60 行', lines.length === 60, `实际 ${lines.length}`);
const firstFrame = JSON.parse(lines[0]);
check('每帧 33 个关键点', firstFrame.points?.length === 33, `实际 ${firstFrame.points?.length}`);
check('每帧 9 个关节角', firstFrame.angles?.length === 9, `实际 ${firstFrame.angles?.length}`);
const xs = firstFrame.points.map((p) => p[0]);
const ys = firstFrame.points.map((p) => p[1]);
check('关键点 x 归一化在 [0,1]', xs.every((v) => v >= 0 && v <= 1), `min=${Math.min(...xs)} max=${Math.max(...xs)}`);
check('关键点 y 归一化在 [0,1]', ys.every((v) => v >= 0 && v <= 1), `min=${Math.min(...ys)} max=${Math.max(...ys)}`);
const confs = new Set(firstFrame.points.map((p) => p[3]));
check('置信度分层(直接映射 1 / 推算 0.5)', confs.has(1) || confs.has(0.5), [...confs].join(','));
check('关节角余弦落在 [-1,1]', firstFrame.angles.every((v) => v >= -1.0001 && v <= 1.0001));
// 关键点随时间变化(确认不是静止帧)
const lastFrame = JSON.parse(lines[lines.length - 1]);
const moved = firstFrame.points.some((p, i) => Math.abs(p[0] - lastFrame.points[i][0]) > 0.001);
check('关键点序列随时间变化', moved);
// ---------- 4. T-WEB-005 模型上传(VRM 优先) ----------
console.log('\n[4] T-WEB-005 模型上传 + VRM 优先 + 来源字段');
const vrmFile = buildGlb({
asset: { version: '2.0', generator: 'OpenDance smoke test' },
extensions: { VRMC_vrm: { specVersion: '1.0' } },
});
const modelRes = await request('POST', '/api/models', {
form: fileForm({ name: '冒烟 VRM 模型', isPrimary: true, ...SOURCE }, 'avatar.vrm', vrmFile, 'model/gltf-binary'),
});
check('POST /api/models 返回 201', modelRes.status === 201, `实际 ${modelRes.status} ${JSON.stringify(modelRes.body)}`);
const modelId = modelRes.body?.id;
check('识别 VRM 版本为 1.0', modelRes.body?.vrmVersion === '1.0', String(modelRes.body?.vrmVersion));
check('isPrimary 已置位', modelRes.body?.isPrimary === true);
const modelMissingSource = await request('POST', '/api/models', {
form: fileForm({ name: '缺少来源' }, 'x.vrm', vrmFile),
});
check('模型缺少来源五元组 → 400', modelMissingSource.status === 400, `实际 ${modelMissingSource.status}`);
const modelsList = await request('GET', '/api/models?format=vrm');
check('按格式过滤模型列表', modelsList.status === 200 && modelsList.body?.items?.every((m) => m.format === 'vrm'));
check('列表含来源信息(管理端展示用)', Boolean(modelsList.body?.items?.[0]?.sourceUrl && modelsList.body?.items?.[0]?.author));
// ---------- 5. T-WEB-007 审核流程 ----------
console.log('\n[5] T-WEB-007 上传审核流程');
const submit = await request('POST', `/api/reviews/song/${songId}/submit`, { json: { reviewer: 'qa' } });
check('提交审核 → pending', submit.status === 200 && submit.body?.status === 'pending', `实际 ${submit.status} ${JSON.stringify(submit.body)}`);
const pending = await request('GET', '/api/reviews?status=pending');
check('待审列表包含该曲目', pending.body?.items?.some((i) => i.targetType === 'song' && i.targetId === songId));
const badReject = await request('POST', `/api/reviews/song/${songId}/reject`, { json: { reviewer: 'qa' } });
check('驳回未填理由 → 400', badReject.status === 400, `实际 ${badReject.status}`);
const approve = await request('POST', `/api/reviews/song/${songId}/approve`, { json: { reviewer: 'qa', comment: '通过' } });
check('审核通过 → published', approve.status === 200 && approve.body?.status === 'published');
const again = await request('POST', `/api/reviews/song/${songId}/approve`, { json: { reviewer: 'qa' } });
check('重复审核 → 409', again.status === 409, `实际 ${again.status}`);
const history = await request('GET', `/api/reviews/song/${songId}/history`);
check('审核流水已记录 2 条', Array.isArray(history.body) && history.body.length === 2, `实际 ${history.body?.length}`);
await request('POST', `/api/reviews/motion/${motionId}/submit`, { json: { reviewer: 'qa' } });
await request('POST', `/api/reviews/motion/${motionId}/approve`, { json: { reviewer: 'qa' } });
await request('POST', `/api/reviews/model/${modelId}/submit`, { json: { reviewer: 'qa' } });
await request('POST', `/api/reviews/model/${modelId}/approve`, { json: { reviewer: 'qa' } });
// ---------- 6. 目录导出 ----------
console.log('\n[6] 客户端目录导出');
const bundle = await request('GET', '/api/catalog/export');
check('GET /api/catalog/export 返回 200', bundle.status === 200);
check('仅含已发布曲目', bundle.body?.songs?.some((s) => s.id === songId));
check('defaultModelId 解析为 VRM 模型', bundle.body?.defaultModelId === modelId, String(bundle.body?.defaultModelId));
check('bundle 含契约版本', Boolean(bundle.body?.contractVersion?.['data-models']));
const version = await request('GET', '/api/catalog/version');
check('GET /api/catalog/version 返回目录指纹', /^[0-9a-f]{64}$/.test(version.body?.revision ?? ''), String(version.body?.revision).slice(0, 16));
// ---------- 7. 负向用例 ----------
console.log('\n[7] 负向用例');
const notFound = await request('GET', '/api/songs/99999999');
check('不存在的曲目 → 404', notFound.status === 404);
const badEnum = await request('POST', '/api/songs', { json: { title: 'x', artist: 'y', ...SOURCE, sourceType: 'nope' } });
check('非法 sourceType → 400', badEnum.status === 400, `实际 ${badEnum.status}`);
const badUrl = await request('POST', '/api/songs', { json: { title: 'x', artist: 'y', ...SOURCE, sourceUrl: 'not-a-url' } });
check('非法 sourceUrl → 400', badUrl.status === 400, `实际 ${badUrl.status}`);
// ---------- 8. 清理 ----------
console.log('\n[8] 清理');
const del = await request('DELETE', `/api/songs/${songId}`);
check('DELETE /api/songs/:id → 204', del.status === 204, `实际 ${del.status}`);
const afterDelete = await request('GET', '/api/songs?q=冒烟');
check('软删除后列表不再包含', !afterDelete.body?.items?.some((s) => s.id === songId));
await request('DELETE', `/api/motions/${motionId}`);
await request('DELETE', `/api/models/${modelId}`);
console.log(`\n== 结果:${passed} 通过 / ${failed} 失败 ==`);
if (failed > 0) {
console.log('\n失败项:');
for (const f of failures) console.log(` - ${f}`);
process.exit(1);
}
console.log('全部通过 ✅\n');
}
main().catch((error) => {
console.error('\n冒烟测试异常终止:', error);
process.exit(1);
});