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
+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`。