docs: 消除 Gitea 模棱两可 Unicode 告警(全角标点紧邻 ASCII 时改为半角)

依据 Gitea v1.27.3 modules/charset/escape_stream.go 的判定规则
(analyzeDetectResults:仅当左右相邻字符都不是非 ASCII 时才标记,
且 _common 表恒定生效),把说明文档中会被标记的全角标点
(,:;?!() 等)改为半角,并保证成对括号宽度一致。
中文语境中的全角标点(左右均为中文)保持原样,不动代码与契约语义。

同时删除临时探针目录 _probe/(仅用于确认 Gitea 判定规则)。
This commit is contained in:
opendance
2026-09-15 21:45:51 +08:00
parent a34cca007c
commit 60c182f1b2
28 changed files with 276 additions and 615 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
# T-WEB-001 骨架 + 连接现有 MySQL
- **状态**:done
- **状态**: done
- **依赖**:无
## 目标
@@ -29,7 +29,7 @@ web-server/src/db/data-source.ts
| `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/db/data-source.ts` | TypeORM DataSource(CLI + 运行期共用), `synchronize: false` |
| `web-server/src/health/health.controller.ts` | `GET /health`,真实探测数据库连通性 |
| `web-server/.env.example` | 环境变量样例 |
@@ -61,4 +61,4 @@ curl -s http://localhost:3000/health
## 结果
- 已连通 `<数据库主机>:3306/opendance`,`/health` 返回 200 且 `connected=true`。
- 已连通 `<数据库主机>:3306/opendance`, `/health` 返回 200 且 `connected=true`。
+4 -4
View File
@@ -1,7 +1,7 @@
# T-WEB-002 TypeORM 迁移生成表结构
- **状态**:done
- **依赖**:T-WEB-001
- **状态**: done
- **依赖**: T-WEB-001
## 目标
@@ -25,7 +25,7 @@ web-server/src/db/data-source.ts
|---|---|---|
| `assets` | 上传文件元数据 | `sha256` 唯一索引(去重)、`stored_path`、`size` |
| `songs` | 曲目 | 来源五元组、`status`、`deleted_at` |
| `motions` | 动作数据 | `song_id` FK(ON DELETE SET NULL)、预处理产物列 |
| `motions` | 动作数据 | `song_id` FK(ON DELETE SET NULL)、预处理产物列 |
| `models` | 角色模型 | `format`、`vrm_version`、`is_primary` |
| `review_records` | 审核流水 | `(target_type, target_id)` 复合索引 |
@@ -33,7 +33,7 @@ web-server/src/db/data-source.ts
- 表名 `snake_case` 复数;主键 `BIGINT AUTO_INCREMENT`
- 时间列 `DATETIME`;软删除 `deleted_at`
- Tags 使用 `simple-json`(MariaDB 下落 LONGTEXT,规避 JSON 类型差异)
- Tags 使用 `simple-json`(MariaDB 下落 LONGTEXT,规避 JSON 类型差异)
## 交付物
+3 -3
View File
@@ -1,7 +1,7 @@
# T-WEB-003 /api/songs CRUD
- **状态**:done
- **依赖**:T-WEB-002
- **状态**: done
- **依赖**: T-WEB-002
## 目标
@@ -27,7 +27,7 @@ web-server/src/common/pagination.ts
| GET | `/api/songs/:id` | 详情 |
| PATCH | `/api/songs/:id` | 更新(来源需五元组同时提交) |
| DELETE | `/api/songs/:id` | **软删除** |
| POST | `/api/songs/:id/audio` | 上传音频(multipart,字段名 `file`) |
| POST | `/api/songs/:id/audio` | 上传音频(multipart,字段名 `file`) |
| POST | `/api/songs/:id/cover` | 上传封面 |
## 关键实现
+13 -13
View File
@@ -1,7 +1,7 @@
# T-WEB-004 动作数据上传 + 来源字段
- **状态**:done
- **依赖**:T-WEB-002
- **状态**: done
- **依赖**: T-WEB-002
## 目标
@@ -29,17 +29,17 @@ web-server/src/preprocess/retarget.ts # worldFramesToKeypointFrames()
| GET | `/api/motions` | 分页 + `q`/`songId`/`format`/`status` 过滤 |
| POST | `/api/motions` | multipart 上传 |
| GET | `/api/motions/:id` | 详情 |
| PATCH | `/api/motions/:id` | 更新元数据(含 `detachSong`) |
| PATCH | `/api/motions/:id` | 更新元数据(含 `detachSong`) |
| DELETE | `/api/motions/:id` | 软删除 |
## 存储策略(`contracts/model-formats.md` 3)
## 存储策略(`contracts/model-formats.md` 3)
```
${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}
```
- 上传先落 `${STORAGE_DIR}/tmp`(multer diskStorage),避免大文件占用内存
- `StorageService` 流式计算 sha256;**相同内容只落盘一次**,复用 `assets` 记录
- 上传先落 `${STORAGE_DIR}/tmp`(multer diskStorage),避免大文件占用内存
- `StorageService` 流式计算 sha256; **相同内容只落盘一次**,复用 `assets` 记录
- 并发同文件上传靠 `assets.sha256` 唯一索引兜底,冲突后回退到已存在记录
- 路径永不使用原始文件名;`absolutePath()` 拒绝逃逸存储根目录
@@ -48,14 +48,14 @@ ${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}
- 扩展名白名单:`.bvh.vmd.fbx.gltf.glb.json`,其他 -> 400
- 来源五元组缺任一项 -> 400
- `songId` 指向不存在的曲目 -> 400
- 单文件上限 `UPLOAD_MAX_BYTES`(默认 512 MiB)
- 单文件上限 `UPLOAD_MAX_BYTES`(默认 512 MiB)
## VMD 支持(`.vmd`)
## VMD 支持(`.vmd`)
- 管理端上传控件已支持 `.vmd`(accept、格式筛选、中文标签「VMD(MMD)」)
- 入库后 `format=vmd`;预处理走内置链路,不依赖 Assimp:
- 管理端上传控件已支持 `.vmd`(accept、格式筛选、中文标签「VMD(MMD)」)
- 入库后 `format=vmd`;预处理走内置链路,不依赖 Assimp:
`vmd-parser.ts`(二进制 + Shift-JIS + 贝塞尔插值)-> `mmd-skeleton.ts`(标准骨架)
-> `mmd-pose.ts`(FK + 腿部 IK + Z 镜像)-> `retarget.ts` 的
-> `mmd-pose.ts`(FK + 腿部 IK + Z 镜像)-> `retarget.ts` 的
`worldFramesToKeypointFrames()`(与 BVH 共用骨盆归零 / 自适应缩放 / 投影 / 关节角)
- 固定 30 fps;腿部按 `足IK` 是否带位移自动选 IK 或 FK
- 上传后处理失败与 BVH 一致:写入 `preprocessState=failed` 与可读错误
@@ -73,8 +73,8 @@ VMD 实测(用 `../示例文件/` 下的两个样本):
| 样本 | 帧数 | 腿部驱动 | 结果 |
|--|--|--|--|
| `【动作】霜雪千年.vmd`(0.79 MiB) | 2901 | `足IK` 驱动(IK) | 0 NaN / 0 越界,预处理约 0.4s |
| `恋爱循环.vmd`(20.24 MiB) | 4604 | 烘焙 FK | 0 NaN / 0 越界,预处理约 2.5s |
| `【动作】霜雪千年.vmd`(0.79 MiB) | 2901 | `足IK` 驱动(IK) | 0 NaN / 0 越界,预处理约 0.4s |
| `恋爱循环.vmd`(20.24 MiB) | 4604 | 烘焙 FK | 0 NaN / 0 越界,预处理约 2.5s |
上传前可先用诊断工具自查(只读):
+36 -36
View File
@@ -1,12 +1,12 @@
# T-WEB-005 模型上传(GLB/VRM 直接导入 + PMX/FBX 转换 + zip 自动搜索 + 分析/缩略图 + 上传即待审)
- **状态**:done(v0.2.0 重构:GLB/VRM 主力、上传即待审)
- **依赖**:T-WEB-002
- **状态**: done(v0.2.0 重构:GLB/VRM 主力、上传即待审)
- **依赖**: T-WEB-002
## 目标
系统以 **GLB / VRM 为主力格式**(服务端与客户端都一等公民):`.glb`/`.vrm`/`.gltf`
**直接导入、不重编码**,`.pmx`/`.fbx` 服务端转换(**优先 GLB、其次 VRM**),`.zip`
**直接导入、不重编码**, `.pmx`/`.fbx` 服务端转换(**优先 GLB、其次 VRM**),`.zip`
**自动搜索包内可用模型**。入库后自动**分析**(面数 / 骨骼树 / humanoid 覆盖 / 可驱动性)
与生成**缩略图**;状态**一律 `pending`**,经审核后才可能成为默认模型。
@@ -38,11 +38,11 @@ web-server/scripts/import-default-model.mjs
| 方法 | 路径 | 说明 |
|--|--|--|
| GET | `/api/models` | 分页 + `q`/`format`/`status`/`primaryOnly` |
| POST | `/api/models` | multipart 上传(`file` + 名称 + 来源五元组);`file` 接受 `.glb`/`.vrm`/`.gltf`/`.pmx`/`.fbx`/`.zip`;**入库一律 pending** |
| GET | `/api/models/:id` | 详情(含 `stats`、`thumbnailUrl`、`conversionNote`、`fileSha256`、`sourceSha256`) |
| POST | `/api/models` | multipart 上传(`file` + 名称 + 来源五元组);`file` 接受 `.glb`/`.vrm`/`.gltf`/`.pmx`/`.fbx`/`.zip`; **入库一律 pending** |
| GET | `/api/models/:id` | 详情(含 `stats`、`thumbnailUrl`、`conversionNote`、`fileSha256`、`sourceSha256`) |
| PATCH | `/api/models/:id` | 更新 |
| DELETE | `/api/models/:id` | 软删除 |
| POST | `/api/models/:id/primary` | 设为全局默认模型(**仅 `published` 允许,否则 409**) |
| POST | `/api/models/:id/primary` | 设为全局默认模型(**仅 `published` 允许,否则 409**) |
## 格式策略(服务端与客户端一致)
@@ -54,7 +54,7 @@ web-server/scripts/import-default-model.mjs
| `.zip` | **自动搜索包内可用模型**(见下) |
客户端(Godot)本来就用内置 `GLTFDocument` 加载 glTF/GLB/VRM
(`opendance-windows/src/model/vrm_loader.gd`),**无需改动**。服务端 humanoid 候选表
(`opendance-windows/src/model/vrm_loader.gd`), **无需改动**。服务端 humanoid 候选表
与客户端 `humanoid_map.gd` **逐字对齐**,由 `test/model-inspector.spec.ts` 断言两边一致。
## 压缩包自动搜索与转换
@@ -74,14 +74,14 @@ web-server/scripts/import-default-model.mjs
| ZIP 读取 | `common/zip-reader.ts` | 中央目录/EOCD、STORE(0)+DEFLATE(8)、ZIP64 大小字段、Shift-JIS/GBK 文件名回退;拒绝 zip-slip 与加密条目;不支持的压缩方式报错 |
| 选主模型 | `model-convert.ts` | 格式优先级 `.vrm` -> `.glb` -> `.gltf` -> `.pmx` -> `.fbx`;同一优先级内"`MODEL_CONVERT_PMX` 指名字(默认 `Hatsune Miku.pmx`,大小写不敏感)-> 目录最浅 -> 文件最大" |
| 直接导入 | `importModelFile()` | `.vrm`/`.glb`/`.gltf` 原样入库;`.glb` 若含 `VRMC_vrm`/`VRM` 扩展会被识别为 VRM |
| 转换 | `convertModelFile()` / `convertModelArchive()` | `MODEL_CONVERT_FORMAT=glb`(默认)|`vrm`;超时 `MODEL_CONVERT_TIMEOUT_MS`(默认 180000) |
| 转换 | `convertModelFile()` / `convertModelArchive()` | `MODEL_CONVERT_FORMAT=glb`(默认)|`vrm`;超时 `MODEL_CONVERT_TIMEOUT_MS`(默认 180000) |
| 回读校验 | `exportAndValidate()` | 每步产物**必须** `assimp info` 回读;退出码 0 但产物损坏也判失败 |
| GLB 打包 | `gltf-packer.ts` | GLB 直出失败时导出 `.gltf`,把 `.bin` 与外部贴图自行合成单文件 GLB |
| VRM 封装 | `vrm-writer.ts` | `MODEL_CONVERT_FORMAT=vrm` 时只改 JSON chunk 补 `extensions.VRMC_vrm`;humanoid 骨骼不全 -> **回退交付 GLB**,缺失骨骼写进说明 |
| 贴图内嵌 | `glb-textures.ts` | 把 GLB 中外部 URI 贴图追加 bufferView 并内嵌进 BIN chunk(4 字节对齐) |
| VRM 封装 | `vrm-writer.ts` | `MODEL_CONVERT_FORMAT=vrm` 时只改 JSON chunk 补 `extensions.VRMC_vrm`; humanoid 骨骼不全 -> **回退交付 GLB**,缺失骨骼写进说明 |
| 贴图内嵌 | `glb-textures.ts` | 把 GLB 中外部 URI 贴图追加 bufferView 并内嵌进 BIN chunk(4 字节对齐) |
| 溯源 | 迁移 `AddModelConversionFields1789450000000` | 压缩包原件存为 `model-archive` 资产 -> `models.source_asset_id`;说明 -> `models.conversion_note` |
| 失败 | `models.service.ts` | 无可用模型 / Assimp 不可用 / 两条路径都失败 -> **HTTP 422**,不产生模型记录 |
| 文件名编码 | `common/upload.util.ts` `decodeUploadName()` | 修正 multipart 文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_...`) |
| 文件名编码 | `common/upload.util.ts` `decodeUploadName()` | 修正 multipart 文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_...`) |
| DTO | `conversionNote` / `fileSha256` / `sourceSha256` / `stats` / `thumbnailUrl` | 契约 `data-models.md` 4、`rest-api.yaml` |
## 入库后自动:分析 + 缩略图(尽力而为,失败不影响入库)
@@ -93,11 +93,11 @@ web-server/scripts/import-default-model.mjs
## 状态与默认模型(硬性)
- **上传一律 `status=pending`**(`models.service.ts` 固定 `PublishStatus.Pending`),
`is_primary=false`:**不再自动发布、不再自动设为默认**;服务端不再识别 `autoDefault`。
- 默认模型只在下列时机设置,并把身份写进源码 `src/config/default-model.ts`:
1. 审核通过带 `setPrimary:true`:`POST /api/reviews/model/:id/approve`;
2. 显式设置:`POST /api/models/:id/primary`(**仅 `published` 允许,否则 409**)。
- **上传一律 `status=pending`**(`models.service.ts` 固定 `PublishStatus.Pending`),
`is_primary=false`: **不再自动发布、不再自动设为默认**;服务端不再识别 `autoDefault`。
- 默认模型只在下列时机设置,并把身份写进源码 `src/config/default-model.ts`:
1. 审核通过带 `setPrimary:true`: `POST /api/reviews/model/:id/approve`;
2. 显式设置:`POST /api/models/:id/primary`(**仅 `published` 允许,否则 409**)。
- `GET /api/catalog/export` 的 `defaultModelId` 解析顺序(`resolveDefaultModelId()`):
```
@@ -116,27 +116,27 @@ web-server/scripts/import-default-model.mjs
`vrm-inspector.ts` 直接读取 GLB 头部 JSON chunk,不依赖完整 3D 库:
- `extensions.VRMC_vrm` -> VRM **1.0**(取 `specVersion`)
- `extensions.VRMC_vrm` -> VRM **1.0**(取 `specVersion`)
- `extensions.VRM` -> VRM **0.x**
- 两者皆无 -> `null`(普通 GLB)
- 两者皆无 -> `null`(普通 GLB)
## 实测(v0.2.0)
## 实测(v0.2.0)
- 上传含 `Hatsune Miku.pmx` 的 MMD 压缩包 -> **HTTP 201 / 0.86s**、`format=glb`、
`status=pending`、`conversionNote=Hatsune Miku.pmx -> GLB,已内嵌 2 张贴图`。
分析结果:`vertices=7155 triangles=9071 materials=3 textures=2 images=2 nodes=240
bones=238 skins=1 morphTargets=0 humanoidMapped=0/22 drivable=false maxBoneDepth=23`;
bones=238 skins=1 morphTargets=0 humanoidMapped=0/22 drivable=false maxBoneDepth=23`;
`missingRequiredBones` 为**全部 16 根**(日文 MMD 骨名无法映射到 humanoid)。
- 直接上传 `.glb` -> **HTTP 201 / 0.36s**、`conversionNote=直接导入(GLB,未做重编码)`,
- 直接上传 `.glb` -> **HTTP 201 / 0.36s**、`conversionNote=直接导入(GLB,未做重编码)`,
入库文件 sha256 与源文件**一致**(不重编码)。
- 缩略图:512x512 PNG / 15,043 字节,服务端纯 TS 光栅化(含蒙皮),目视确认为居中的
T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms)。
- 审核:`POST /api/reviews/model/22/approve` 带 `setPrimary:true` -> 200,
模型 `status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882...`;
T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms)。
- 审核:`POST /api/reviews/model/22/approve` 带 `setPrimary:true` -> 200,
模型 `status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882...`;
重建后 `GET /api/catalog/export` 的 `defaultModelId` 由 VRM 变为 **22**
(库中另有 2 个已发布 VRM)。
### 运维 CLI:`web-server/scripts/import-default-model.mjs`
### 运维 CLI: `web-server/scripts/import-default-model.mjs`
服务进程在 systemd 加固(`ProtectSystem=strict`)下写不了程序目录,源码种子
通常由本脚本在沙箱外补写。需先 `npm run build`(复用 `dist/models/default-model-source.js`)。
@@ -146,15 +146,15 @@ cd web-server
node scripts/import-default-model.mjs 模型.zip --url http://127.0.0.1:3000 \
--name "初音未来 Hatsune Miku" --author "Boku Wa Luka" \
--license "MMD 模型使用条款(见压缩包内说明)" --source-type other
# 上传 -> 服务端转换入库(pending)-> 把身份写进 src/config/default-model.ts
# 上传 -> 服务端转换入库(pending)-> 把身份写进 src/config/default-model.ts
node scripts/import-default-model.mjs --write-source-only --model-id 22 # 只补写源码
node scripts/import-default-model.mjs --write-source-only --model-id 22 --dry-run
```
上传一律 pending,因此要让 CLI 写出的种子在 `defaultModelId` 中生效,仍需审核通过
(勾选「同时设为全局默认模型」)或调用 `POST /api/models/:id/primary`;
`--url` 默认 `http://127.0.0.1:3000`,`--source-type` 默认 `other`。
(勾选「同时设为全局默认模型」)或调用 `POST /api/models/:id/primary`;
`--url` 默认 `http://127.0.0.1:3000`, `--source-type` 默认 `other`。
## 验收
@@ -170,7 +170,7 @@ npm run test:smoke # 冒烟 [4] 段落
| `.zip` 自动搜索包内模型 | ✅ 优先级 `.vrm`>`glb`>`.gltf`>`.pmx`>`.fbx`;同优先级按指名字->目录最浅->文件最大 |
| `.pmx` 转换优先 GLB | ✅ 实测 201 / 0.86s、`format=glb`、2 张贴图内嵌 |
| GLB 失败回退 glTF->GLB 打包 | ✅ GLB 直出失败时 `gltf-packer.ts` 自行打包;每步 `assimp info` 回读校验 |
| `MODEL_CONVERT_FORMAT=vrm` 封装 | ✅ `vrm-writer.ts` 补 `VRMC_vrm`;humanoid 不全回退 GLB |
| `MODEL_CONVERT_FORMAT=vrm` 封装 | ✅ `vrm-writer.ts` 补 `VRMC_vrm`; humanoid 不全回退 GLB |
| 自动分析写入 `models.stats` | ✅ 实测 `triangles=9071 bones=238 humanoidMapped=0/22 drivable=false` |
| 缩略图生成且不影响入库 | ✅ 512x512 / 15,043 B;失败只告警 |
| 上传一律 pending、不自动设默认 | ✅ `create()` 固定 `PublishStatus.Pending` + `isPrimary=false` |
@@ -179,15 +179,15 @@ npm run test:smoke # 冒烟 [4] 段落
| 失败语义 | ✅ 无可用模型 / Assimp 不可用 / 两条路径均失败 -> HTTP 422,不产生模型记录 |
| ZIP 安全 | ✅ 拒绝 zip-slip、加密条目、未知压缩方式(`model-archive.spec.ts` 24 项) |
| humanoid 与客户端一致 | ✅ `model-inspector.spec.ts` 抽 `humanoid_map.gd` 逐键比对(25 项) |
| VRM 版本识别 | ✅ `test/vrm-inspector.spec.ts`(6 例) |
| VRM 版本识别 | ✅ `test/vrm-inspector.spec.ts`(6 例) |
`test/model-archive.spec.ts`(**24 项**)覆盖 ZIP 解析 / zip-slip / 加密 / 未知压缩方式、
`test/model-archive.spec.ts`(**24 项**)覆盖 ZIP 解析 / zip-slip / 加密 / 未知压缩方式、
主模型挑选优先级、GLB 贴图内嵌、用**假 Assimp** 对照多种回退路径、源码渲染 + `tsc` 校验。
`test/model-inspector.spec.ts`(**25 项**)覆盖 GLB 统计与骨骼树、VRM 识别、必需 16 根
`test/model-inspector.spec.ts`(**25 项**)覆盖 GLB 统计与骨骼树、VRM 识别、必需 16 根
humanoid 骨、非 glTF 容器降级、截断与健壮性、与客户端映射表逐字一致。
`test/model-thumbnail.spec.ts`(**8 项**)覆盖缩略图 PNG 结构、蒙皮与降级路径。
`test/model-thumbnail.spec.ts`(**8 项**)覆盖缩略图 PNG 结构、蒙皮与降级路径。
## 追加:历史模型回填(`POST /api/models/:id/reanalyze`)
## 追加:历史模型回填(`POST /api/models/:id/reanalyze`)
升级到带 `models.stats` 的版本后,已有模型还没有分析与缩略图。重新分析接口只改
`stats` 与 `thumbnailAssetId` 两个字段,用于回填 / 文件替换后刷新 / 缩略图渲染器升级后重出图。
@@ -198,8 +198,8 @@ node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 # 回填
node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --dry-run # 只列清单
```
实测(真实 21MB VRM,290 骨骼):回填出面数 53552 / 顶点 44662 / 骨骼 290 /
实测(真实 21MB VRM, 290 骨骼):回填出面数 53552 / 顶点 44662 / 骨骼 290 /
humanoid 7/22,并重建 512x512 缩略图。
文件在存储中丢失时返回 **409** 并说明原因,不会用全 0 结果覆盖既有分析数据。
另外 `StorageService.persistBuffer` 在 sha256 去重命中但文件不存在时会**自愈补写**,
另外 `StorageService.persistBuffer` 在 sha256 去重命中但文件不存在时会**自愈补写**,
避免换过 `STORAGE_DIR` 或只恢复数据库后出现"静默全 0 分析"。
+10 -10
View File
@@ -1,7 +1,7 @@
# T-WEB-006 BVH/VMD/FBX -> 关键点序列预处理
- **状态**:done
- **依赖**:T-WEB-004
- **状态**: done
- **依赖**: T-WEB-004
## 目标
@@ -28,7 +28,7 @@ web-server/src/preprocess/*
| 文件 | 职责 |
|--|--|
| `bvh-parser.ts` | 纯 TS BVH 词法/语法解析 + 逐帧前向运动学(FK) |
| `bvh-parser.ts` | 纯 TS BVH 词法/语法解析 + 逐帧前向运动学(FK) |
| `vmd-parser.ts` | VMD 二进制解析:30 字节头 + Shift-JIS 模型名 + 111 字节/条骨骼关键帧 + morph 段;MMD 贝塞尔插值,固定 30fps |
| `mmd-skeleton.ts` | 标准 MMD 骨架(含全角/半角 `IK`/`IK` 别名归一);静止比例从参考 PMX 实测 |
| `mmd-pose.ts` | MMD FK + 两骨骼解析腿部 IK + 左手系->右手系 Z 镜像,输出逐帧世界坐标 |
@@ -36,14 +36,14 @@ web-server/src/preprocess/*
| `joint-map.ts` | 关节名 -> MediaPipe 点索引的默认映射表 |
| `retarget.ts` | 导出的 `worldFramesToKeypointFrames()`:任意骨架世界坐标 -> 骨盆归零 -> 自适应缩放 -> 投影 -> 9 个关节角;BVH 与 VMD 共用 |
| `keypoint-codec.ts` | 239 字节二进制帧编解码、NDJSON 序列化/解析、关键点 JSON 校验 |
| `assimp.ts` | 可选外部 Assimp 调用(FBX/glTF -> BVH) |
| `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`,
- 投影:`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**。
@@ -53,7 +53,7 @@ web-server/src/preprocess/*
|--|--|
| `.bvh` | 内置解析器,无外部依赖 |
| `.vmd` | 内置解析器,无外部依赖;固定 30fps,见「VMD 通路」 |
| `.json` | 直通校验与规范化(须符合 `mediapipe33@0.1.0`) |
| `.json` | 直通校验与规范化(须符合 `mediapipe33@0.1.0`) |
| `.fbx` `.gltf` `.glb` | 调用可选 Assimp 转 BVH;未安装 Assimp 时写入可读错误并置 `preprocessState=failed` |
## VMD 通路
@@ -67,13 +67,13 @@ web-server/src/preprocess/*
- VMD 不含骨骼长度、只有旋转(少数骨骼带位移),必须靠 `mmd-skeleton.ts` 的
标准骨架做 FK;骨骼名按别名表归一(全角 `IK` / 半角 `IK`),未纳入标准骨架的
骨骼(裙摆 / 物理 / 表情等)被忽略但计数。
- **腿部驱动自动判定**:`足IK` 轨道存在非零位移 -> 两骨骼解析 IK(膝盖朝角色
- **腿部驱动自动判定**: `足IK` 轨道存在非零位移 -> 两骨骼解析 IK(膝盖朝角色
正面弯,目标不可达时收敛为伸直,不产生 NaN);否则走 FK 旋转(部分动作数据把
腿烘焙成 FK)。
- **手性**:MMD 为左手系,统一做 Z 镜像(旋转 `(x,y,z,w) -> (-x,-y,z,w)`、位移 z
- **手性**: MMD 为左手系,统一做 Z 镜像(旋转 `(x,y,z,w) -> (-x,-y,z,w)`、位移 z
取反)后进入右手系通路。由真实数据对照实验确定:腿部烘焙为 FK 的样本中,
`zMirrored` 下「膝盖朝前」占 94.1%,不转换仅 5.9%(见 `test/mmd-vmd.spec.ts`)。
- 重定向**完全复用 BVH 通路**:`worldFramesToKeypointFrames()` 负责骨盆归零 /
- 重定向**完全复用 BVH 通路**: `worldFramesToKeypointFrames()` 负责骨盆归零 /
自适应缩放 / 图像空间投影 / 9 个关节角,因此两种来源的产物结构一致。
- 固定 **30 fps**,上传时填写的 `fps` 被忽略。
@@ -91,7 +91,7 @@ NDJSON 往返、解码器拒绝错误长度与 magic。
VMD 侧另测:二进制解析与截断报错、贝塞尔/四元数插值、标准骨架比例与镜像、
FK 与腿部 IK(可达/不可达/退化)、手性对照、合成 VMD 端到端 0 NaN / 0 越界。
两个真实样本实测(`../示例文件/`):`【动作】霜雪千年.vmd` 2901 帧(腿部 IK)
两个真实样本实测(`../示例文件/`):`【动作】霜雪千年.vmd` 2901 帧(腿部 IK)
约 0.4s、`恋爱循环.vmd` 4604 帧(烘焙 FK)约 2.5s,均 0 NaN / 0 越界。
样例数据由 `test/fixtures/generate-sample-bvh.mjs` 生成(60 帧挥手 + 深蹲,
+13 -13
View File
@@ -1,7 +1,7 @@
# T-WEB-007 上传审核流程
- **状态**:done(v0.2.0 追加:模型审核面板 / 骨骼详细界面 / `setPrimary`)
- **依赖**:T-WEB-003 / 004 / 005
- **状态**: done(v0.2.0 追加:模型审核面板 / 骨骼详细界面 / `setPrimary`)
- **依赖**: T-WEB-003 / 004 / 005
## 目标
@@ -34,45 +34,45 @@ draft ──submit──▶ pending ──approve──▶ published
| 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`;**模型**可带 `setPrimary:true` |
| POST | `/api/reviews/:targetType/:id/reject` | -> `rejected`,**必须填写意见** |
| POST | `/api/reviews/:targetType/:id/approve` | -> `published`; **模型**可带 `setPrimary:true` |
| POST | `/api/reviews/:targetType/:id/reject` | -> `rejected`, **必须填写意见** |
| POST | `/api/reviews/:targetType/:id/withdraw` | -> `draft` |
## 关键实现
- `STATUS_TRANSITIONS` 是状态机的唯一来源;非法流转一律 **409**,
- `STATUS_TRANSITIONS` 是状态机的唯一来源;非法流转一律 **409**,
重复审核同一状态同样 409。
- 驳回未填 `comment` -> **400**(合规要求:拒绝侵权内容需留痕)。
- 每次成功流转都写一条 `review_records`,记录 `fromStatus` / `toStatus` /
`reviewer` / `comment`。
- 三类资产的直连 PATCH 也会校验状态机,避免绕过审核流程。
- `/api/catalog/export` 与 `/api/catalog/version` 只导出 `published`。
- **模型上传只能 pending**:`ModelsService.create()` 固定
- **模型上传只能 pending**: `ModelsService.create()` 固定
`status=PublishStatus.Pending` + `isPrimary=false`,请求体里的 `autoDefault`
等旁路一律不生效(硬性约束,见 DSH.md 6.9)。
## 模型审核面板(v0.2.0)
## 模型审核面板(v0.2.0)
待审列表只有 `PendingItem`(不含模型详情),面板用 `item.targetId` 现拉
`GET /api/models/:id`(`admin/src/components/ModelReviewPanel.tsx`):
`GET /api/models/:id`(`admin/src/components/ModelReviewPanel.tsx`):
| 区域 | 内容 |
|--|--|
| 基本 | 缩略图、格式、状态、文件地址、转换说明 |
| 指标 | 体积(`fileSize`)/ 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 / 骨骼 / 节点 / 形变目标 / 动画 / 图元 / 蒙皮 / 骨骼最大层级 |
| 指标 | 体积(`fileSize`)/ 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 / 骨骼 / 节点 / 形变目标 / 动画 / 图元 / 蒙皮 / 骨骼最大层级 |
| 可驱动性 | 结论横幅:16 根必需 humanoid 骨齐全 -> 可驱动;不全 -> 列出缺哪些(直接标红);非 glTF 容器 -> 只能审核来源与体积 |
| humanoid 覆盖 | 已映射 / 总数(22)+ 进度条 + 已命中规范名徽章明细 |
| humanoid 覆盖 | 已映射 / 总数(22)+ 进度条 + 已命中规范名徽章明细 |
| warnings | `ModelStats.warnings` 逐条列出 |
| 骨骼详细界面 | 扁平骨骼树按 `depth` 缩进(14px/层),显示骨骼名、是否蒙皮徽章、humanoid 映射徽章、子节点数;支持按名搜索(保留祖先链)、「仅显示蒙皮骨骼」开关、限高滚动 + **只渲染前 400 行**(超出提示还有多少根),千根骨骼不卡 |
| stats 为空 | 提示"暂无分析数据(旧数据或后端尚未完成分析)",只能按格式/体积/来源判断 |
### 通过 / 驳回 / 设为默认
- 面板底部勾选「同时设为全局默认模型」-> 点「通过」时请求体带 `setPrimary:true`:
- 面板底部勾选「同时设为全局默认模型」-> 点「通过」时请求体带 `setPrimary:true`:
`ReviewsService.transition()` 保存模型为 `published` 后调用
`ModelsService.setPrimary(id)`(同一事务清其余 `is_primary`),并把身份写进源码
`src/config/default-model.ts`。
- 单独设置走 `POST /api/models/:id/primary`:**仅 `published` 允许,否则 409**。
- 单独设置走 `POST /api/models/:id/primary`: **仅 `published` 允许,否则 409**。
- 驳回必须填意见(400)。
### 骨骼树过滤(`admin/src/lib/bone-tree.ts`,纯函数、可单测)
@@ -101,4 +101,4 @@ npm run test:smoke
| `setPrimary` 随审核通过生效 | ✅ `POST /api/reviews/model/22/approve` + `setPrimary:true` -> 200、`status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882...` |
| 单独设默认仅 published | ✅ `POST /api/models/:id/primary` 非 published -> 409 |
| 驳回必须填意见 | ✅ 无 `comment` -> 400 |
| 目录重建后默认模型 | ✅ `GET /api/catalog/export` 的 `defaultModelId` 由 VRM 变为 **22**(库中另有 2 个已发布 VRM) |
| 目录重建后默认模型 | ✅ `GET /api/catalog/export` 的 `defaultModelId` 由 VRM 变为 **22**(库中另有 2 个已发布 VRM) |
+6 -6
View File
@@ -1,7 +1,7 @@
# T-WEB-008 歌曲管理界面
- **状态**:done
- **依赖**:T-WEB-003
- **状态**: done
- **依赖**: T-WEB-003
## 目标
@@ -19,11 +19,11 @@ web-server/admin/package.json
## 交付物
`web-server/admin/`(React 18 + Vite + TypeScript):
`web-server/admin/`(React 18 + Vite + TypeScript):
| 路径 | 说明 |
|--|--|
| `src/pages/SongsPage.tsx` | 歌曲列表 + 分页 + 检索(`q` / `status` / `artist`) |
| `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 类型 |
@@ -48,6 +48,6 @@ npm run build # tsc -b && vite build 必须成功
## 构建与运行
- 开发:`npm run dev`(5173,`/api` 与 `/health` 代理到 `localhost:3000`)
- 生产:`npm run build` 产物由**后端服务自身**托管(`ADMIN_DIR`,含 SPA 回退),反向代理只需整体转发到后端端口(见 `deploy/proxy/README.md`)
- 开发:`npm run dev`(5173, `/api` 与 `/health` 代理到 `localhost:3000`)
- 生产:`npm run build` 产物由**后端服务自身**托管(`ADMIN_DIR`,含 SPA 回退),反向代理只需整体转发到后端端口(见 `deploy/proxy/README.md`)
- `VITE_API_BASE` 可覆盖 API 基地址;留空表示同源
+6 -6
View File
@@ -1,7 +1,7 @@
# T-WEB-009 模型管理界面 + 来源展示
- **状态**:done
- **依赖**:T-WEB-005
- **状态**: done
- **依赖**: T-WEB-005
## 目标
@@ -30,11 +30,11 @@ web-server/admin/src/components/VrmPreview.tsx
- 表格列:预览缩略图、名称、格式(VRM 高亮徽章)、VRM 版本、是否默认、
**来源五元组**、许可、状态、操作
- **来源展示(本任务验收重点)**:整行展示 `sourceType` + `sourceName` + `author`
+ `license`,`sourceUrl` 可点击外链跳转
- 上传:`POST /api/models`(multipart `file` + 名称 + `isPrimary` + 来源五元组 +
+ `license`, `sourceUrl` 可点击外链跳转
- 上传:`POST /api/models`(multipart `file` + 名称 + `isPrimary` + 来源五元组 +
状态),客户端校验与歌曲页一致
- 「设为默认」-> `POST /api/models/:id/primary`,成功后刷新列表以反映互斥结果
- 3D 预览:`GLTFLoader` + `VRMLoaderPlugin` 加载 `fileUrl`,
- 3D 预览:`GLTFLoader` + `VRMLoaderPlugin` 加载 `fileUrl`,
支持鼠标拖拽旋转与滚轮缩放,正确处理 VRM 0.x / 1.0 元数据差异、
未选择模型、加载失败三种状态,卸载时释放资源
@@ -45,5 +45,5 @@ cd web-server/admin
npm run build # 必须成功
```
另由 `npm run test:smoke`([4] 段落)验证后端确实在列表接口返回完整来源字段,
另由 `npm run test:smoke`([4] 段落)验证后端确实在列表接口返回完整来源字段,
供本页面展示。
+47 -47
View File
@@ -1,17 +1,17 @@
# T-WEB-010 部署(Linux 业务服务,不使用 Docker,不部署反代)
- **状态**:done(两次需求变更后重做)
- **依赖**:T-WEB-001 ~ 009
- **状态**: done(两次需求变更后重做)
- **依赖**: T-WEB-001 ~ 009
## 目标
Web 服务端以**一个 Linux 业务服务**的形式部署运行:Node 进程由系统服务
(systemd / OpenRC / SysVinit / 后台进程)托管,**同时提供 REST API 与管理端页面**。
**不使用 Docker**,**不部署 MySQL**(数据库为部署方提供的现有实例),
(systemd / OpenRC / SysVinit / 后台进程)托管,**同时提供 REST API 与管理端页面**。
**不使用 Docker**, **不部署 MySQL**(数据库为部署方提供的现有实例),
**也不部署反向代理**(由另外的服务/团队负责)。
> **需求变更记录**
> 1. 初版按文档 4.4 实现为 Docker Compose;用户要求改为"运行在 Linux 的网站",
> 1. 初版按文档 4.4 实现为 Docker Compose;用户要求改为"运行在 Linux 的网站",
> 于是替换为 nginx + systemd 原生站点部署,并删除 `deploy/docker-compose.yml`、
> `web-server/Dockerfile`、`web-server/admin/Dockerfile`。
> 2. 随后用户明确:"web 端仅需要业务需求,反代等工作交由另外的服务处理。
@@ -20,7 +20,7 @@ Web 服务端以**一个 Linux 业务服务**的形式部署运行:Node 进程
> 托管(`ADMIN_DIR` + SPA 深链接回退),并新增 `deploy/proxy/` 只交付说明文档。
> 3. 再随后用户要求**管理端口与软件 API 端口分离**:新增 `ADMIN_PORT`
> (默认 `PORT + 1`)与可选的 `ADMIN_HOST`(默认跟随 `HOST`)。软件 API 端口
> 只给客户端调接口(`/api`、`/health`、`/static`、`/contracts`,`/` 等返回 404),
> 只给客户端调接口(`/api`、`/health`、`/static`、`/contracts`, `/` 等返回 404),
> 管理端端口给浏览器(管理页面 + 全部接口)。`ADMIN_PORT === PORT` 保留为
> 单端口兼容模式,只 warn 不中止。install.sh 的汇总与反代说明改为两个端口。
> 4. 本次追加两项:
@@ -29,7 +29,7 @@ Web 服务端以**一个 Linux 业务服务**的形式部署运行:Node 进程
> `install.sh` 在缺 `deploy/.env` 时先调用它,失败才 die(安装目录已有上次
> 安装留下的.env 则直接复用,保持原语义)。
> b) **默认监听 0.0.0.0 固化**:确认 `configuration.ts` 的
> `bindHost = process.env.HOST?? '0.0.0.0'`、`adminHost` 默认跟随 HOST,
> `bindHost = process.env.HOST?? '0.0.0.0'`、`adminHost` 默认跟随 HOST,
> 两个 `.env.example` 与 `init-env.sh` 的 HOST 默认值均为 `0.0.0.0`。
## load
@@ -52,10 +52,10 @@ deploy/proxy/README.md
```
Windows/Godot 客户端、Android App ─▶ 软件 API 端口:3000(只提供接口,无管理页面)
├── /api/ REST API
├── /health 健康检查(数据库不可用返回 503)
├── /health 健康检查(数据库不可用返回 503)
├── /static/ 上传文件
└── /contracts/ 契约快照
(/、/songs 等返回 404,有意设计)
(/、/songs 等返回 404,有意设计)
浏览器 ─▶ 反向代理(外部提供,IIS / nginx / 网关...)──▶ 管理端端口:3001
│ 整端口转发,不需要托管静态文件 ├── / 管理端页面(含 SPA 深链接回退)
@@ -81,15 +81,15 @@ Windows/Godot 客户端、Android App ─▶ 软件 API 端口:3000(只提供
| `deploy/.env.example` | 部署环境变量样例(`PORT` / `ADMIN_PORT` / `ADMIN_HOST` / `HOST` 说明与上传上限告警) |
| `deploy/systemd/opendance-api.service` | systemd 单元,含 `ProtectSystem=strict` 等安全加固 |
| `deploy/openrc/opendance-api` | OpenRC 服务脚本 |
| `deploy/initd/opendance-api` | SysVinit / LSB 启动脚本(转调 ctl.sh) |
| `deploy/initd/opendance-api` | SysVinit / LSB 启动脚本(转调 ctl.sh) |
| `deploy/ctl.sh` | 无 init 系统时的进程控制(后台进程 + PID 文件),start/stop/restart/status/logs |
| `deploy/proxy/README.md` | **反代说明文档**:转发目标、绑定地址、自检清单、故障对照 |
| `deploy/proxy/nginx.conf.example` | nginx 配置样例(上传上限、超时、keepalive、可选 HTTPS) |
| `deploy/proxy/nginx.conf.example` | nginx 配置样例(上传上限、超时、keepalive、可选 HTTPS) |
| `deploy/proxy/web.config.example` | IIS 配置样例(需 ARR + URL Rewrite 模块) |
## deploy/.env 初始化引导(init-env.sh)
## deploy/.env 初始化引导(init-env.sh)
首次部署不再要求手工 `cp deploy/.env.example deploy/.env && vi deploy/.env`:
首次部署不再要求手工 `cp deploy/.env.example deploy/.env && vi deploy/.env`:
```
install.sh 检测到缺 deploy/.env
@@ -97,11 +97,11 @@ install.sh 检测到缺 deploy/.env
└── 否则调用 deploy/init-env.sh
├── 缺.env -> 从.env.example 复制,chmod 600
├── 交互(TTY / 管道)-> 逐项询问并写回
└── 非交互(CI、</dev/null、-y)-> 只校验,绝不阻塞;失败则 install.sh die
└── 非交互(CI、</dev/null、-y)-> 只校验,绝不阻塞;失败则 install.sh die
```
逐项询问 `DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME / PORT / ADMIN_PORT / HOST`
共 8 个键,回车保留默认;`DB_PASS` 用 `read -rs` 不回显、按约定写盘**不加引号**;
共 8 个键,回车保留默认;`DB_PASS` 用 `read -rs` 不回显、按约定写盘**不加引号**;
校验必填项与端口范围;可用 Node + mysql2 做连通性自检(口令只经环境变量传给子进程,
失败仅告警);只替换/追加这 8 个键、保留模板注释,写完 `chmod 600` 并回读校验;
结尾打印摘要(口令 `***`)与本次变更列表。
@@ -116,21 +116,21 @@ install.sh 检测到缺 deploy/.env
| `--skip-db-check` | 跳过数据库连通性自检 |
| `--dir DIR` | 等价于 `--env-file DIR/.env --example DIR/.env.example` |
| `--env-file PATH` / `--example PATH` | 指定 `.env` / 模板路径 |
| `--timeout SECONDS` | 非 TTY 下每次读取超时秒数(默认 10) |
| `--timeout SECONDS` | 非 TTY 下每次读取超时秒数(默认 10) |
| `-h` / `--help` | 显示帮助 |
环境变量:`OPENDANCE_NONINTERACTIVE` / `OPENDANCE_INTERACTIVE` /
`OPENDANCE_SKIP_DB_CHECK` / `OPENDANCE_ENV_FILE` / `OPENDANCE_ENV_EXAMPLE` /
`OPENDANCE_PROMPT_TIMEOUT`。
**非交互绝不挂住**:stdin 非 TTY 且是 `</dev/null` 或已关闭,或带 `-y` 等开关 ->
**非交互绝不挂住**: stdin 非 TTY 且是 `</dev/null` 或已关闭,或带 `-y` 等开关 ->
只校验不提问;所有 read 带超时,EOF/超时立即 `exit 1`。
**默认监听 0.0.0.0**:`configuration.ts` 的
`bindHost = process.env.HOST?? '0.0.0.0'`,`adminHost` 默认跟随 `HOST`
(HOST 未设置时同样回落 `0.0.0.0`);`deploy/.env.example` 与
`web-server/.env.example` 均写 `HOST=0.0.0.0`;`install.sh` 的
`BIND_HOST="${HOST:-0.0.0.0}"`;`init-env.sh` 的 HOST 默认值也是 `0.0.0.0`。
**默认监听 0.0.0.0**: `configuration.ts` 的
`bindHost = process.env.HOST?? '0.0.0.0'`, `adminHost` 默认跟随 `HOST`
(HOST 未设置时同样回落 `0.0.0.0`); `deploy/.env.example` 与
`web-server/.env.example` 均写 `HOST=0.0.0.0`; `install.sh` 的
`BIND_HOST="${HOST:-0.0.0.0}"`; `init-env.sh` 的 HOST 默认值也是 `0.0.0.0`。
注意 `install.sh` 里的 `HEALTH_HOST` / `API_HOST` 用的是**回环地址**做本机健康检查,
与对外监听的默认值无关。
@@ -142,7 +142,7 @@ install.sh 检测到缺 deploy/.env
1. systemd /run/systemd/system 存在且 systemctl 可用
2. OpenRC rc-service + rc-update 可用
3. SysVinit /etc/init.d 存在且 update-rc.d 或 chkconfig 可用
4. 都没有 nohup 后台进程 + PID 文件(deploy/ctl.sh)
4. 都没有 nohup 后台进程 + PID 文件(deploy/ctl.sh)
并写 crontab @reboot 实现开机自启
```
@@ -156,45 +156,45 @@ install.sh 检测到缺 deploy/.env
## 关键实现
- **双端口**:一个 Node 进程创建两个 HTTP server--软件 API 端口(`PORT`,默认 3000)
- **双端口**:一个 Node 进程创建两个 HTTP server--软件 API 端口(`PORT`,默认 3000)
与管理端端口(`ADMIN_PORT`,默认 3001),复用同一个 express 实例;用
`req.socket.localPort` 判断请求来自哪个端口,决定是否提供管理页面。
`ADMIN_PORT === PORT` 时只建一个 server(单端口兼容模式,避免 EADDRINUSE)
- **服务自身托管管理端**:`app.useStaticAssets(adminDir,...)` + SPA 回退中间件
`ADMIN_PORT === PORT` 时只建一个 server(单端口兼容模式,避免 EADDRINUSE)
- **服务自身托管管理端**: `app.useStaticAssets(adminDir,...)` + SPA 回退中间件
(仅管理端口生效);回退会跳过 `/api/`、`/health`、`/static/`、`/contracts/`
以及带扩展名的路径,保证未知 API 仍返回 404 而不是 HTML
- **软件 API 端口不提供管理页面**:`/`、`/songs` 等在该端口一律 404(有意设计)
- **软件 API 端口不提供管理页面**: `/`、`/songs` 等在该端口一律 404(有意设计)
- **`HOST` / `ADMIN_HOST` 绑定地址**:同机反代建议 `127.0.0.1`,异机反代用
`0.0.0.0` + 防火墙;`ADMIN_HOST` 可选(默认跟随 `HOST`),可单独把管理端限制在
本机或内网;`HOST` 默认取 `0.0.0.0` 以保证开箱可用
- **首次部署的.env 由引导生成**:`install.sh` 缺 `deploy/.env` 时先调用
- **首次部署的.env 由引导生成**: `install.sh` 缺 `deploy/.env` 时先调用
`deploy/init-env.sh`(安装目录已有上次安装的 `.env` 则直接复用);引导脚本在
非交互环境下只校验、绝不阻塞,校验失败时 install.sh 才 die
- **端口校验**:`PORT` / `ADMIN_PORT` 必须是 1..65535 的整数;非法值在生产环境
- **端口校验**: `PORT` / `ADMIN_PORT` 必须是 1..65535 的整数;非法值在生产环境
启动时快速失败(`validateEnv` 的 errors),`ADMIN_PORT === PORT` 只 warn
- **install.sh 汇总与反代说明**:结尾分别打印两个端口的访问地址、监听配置与
防火墙放行命令(`firewall-cmd` / `ufw` 同时放行两个端口)
- **systemd 加固**:`NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、
- **systemd 加固**: `NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、
`ReadWritePaths=/var/lib/opendance`,进程只能写自己的存储目录
- **上传上限两侧一致**:后端 `UPLOAD_MAX_BYTES=536870912`(512 MiB),
- **上传上限两侧一致**:后端 `UPLOAD_MAX_BYTES=536870912`(512 MiB),
反代侧 `client_max_body_size` / `maxAllowedContentLength` 必须不小于此值
- **模型转换配置**(`deploy/.env.example` 已写入):
- **模型转换配置**(`deploy/.env.example` 已写入):
- `ASSIMP_BIN=assimp`:上传 `.zip` 模型包或 `.pmx`/`.fbx` 单文件时**必须**能调用到,
否则转换失败 **422**
- `MODEL_CONVERT_FORMAT=glb`(默认)|`vrm`:只影响需要转换的 PMX/FBX 的交付格式。
`glb` 通用,两个客户端都原生加载;`vrm` 在 GLB 之上补 `VRMC_vrm` 扩展
(humanoid 骨骼 / 表情语义),**humanoid 骨骼名映射不全时自动回退交付 GLB**
(humanoid 骨骼 / 表情语义), **humanoid 骨骼名映射不全时自动回退交付 GLB**
并写进 `models.conversion_note`。产物一律 `assimp info` 回读校验
- `MODEL_CONVERT_PMX` / `MODEL_CONVERT_TIMEOUT_MS` 语义见 README 四
- **迁移用编译产物**:`web-server/scripts/migrate.mjs` 检测到 `dist/` 时走
`typeorm -d dist/db/data-source.js`,无需在服务器上装 devDependencies;
- **迁移用编译产物**: `web-server/scripts/migrate.mjs` 检测到 `dist/` 时走
`typeorm -d dist/db/data-source.js`,无需在服务器上装 devDependencies;
本版本迁移共 3 个:`InitSchema1789382903060`、
`AddModelConversionFields1789450000000`、**`AddModelStats1789460000000`**
(新增 `models.stats TEXT`,存模型分析结果,已在真实库执行)
- **依赖安装显式带 dev**:安装脚本先 `set -a;. deploy/.env` 导出了
`NODE_ENV=production`,会让 npm 跳过 devDependencies 导致 `tsc: not found`;
`NODE_ENV=production`,会让 npm 跳过 devDependencies 导致 `tsc: not found`;
故用 `env -u NODE_ENV npm ci --include=dev`
- **复制文件时锚定排除**:`storage/` 这类模式会误伤 `src/storage/` 与 `dist/storage/`
- **复制文件时锚定排除**: `storage/` 这类模式会误伤 `src/storage/` 与 `dist/storage/`
(曾导致 `MODULE_NOT_FOUND...storage.module`),排除规则一律用 `/storage/` 锚定
- **`/contracts/` 静态暴露**:另两端可直接从站点取契约快照
@@ -202,7 +202,7 @@ install.sh 检测到缺 deploy/.env
```bash
bash deploy/init-env.sh # 或手工 cp deploy/.env.example deploy/.env 后填写 DB_*
bash deploy/install.sh # 需 root(不需要 sudo)
bash deploy/install.sh # 需 root(不需要 sudo)
curl -fsS http://localhost:3000/health
curl -fsS http://localhost:3001/health
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/ # 期望 404(软件 API 端口无管理页面)
@@ -212,12 +212,12 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200
| 验收项 | 状态 |
|--|--|
| `deploy/install.sh` 脚本语法与幂等性 | ✅ `bash -n` 通过;重复执行会覆盖程序文件、保留 `.env` |
| 连接现有 MySQL 成功 | ✅ 已用真实实例验证(`/health` 200,`database.connected=true`) |
| TypeORM migration 执行成功 | ✅ 5 张业务表已创建并验证;v0.2.0 的 `AddModelStats1789460000000`(`models.stats TEXT`)已在真实库执行 |
| 连接现有 MySQL 成功 | ✅ 已用真实实例验证(`/health` 200, `database.connected=true`) |
| TypeORM migration 执行成功 | ✅ 5 张业务表已创建并验证;v0.2.0 的 `AddModelStats1789460000000`(`models.stats TEXT`)已在真实库执行 |
| 模型转换配置 | ✅ `deploy/.env.example` 写入 `ASSIMP_BIN` / `MODEL_CONVERT_FORMAT=glb|vrm` / `MODEL_CONVERT_PMX` / `MODEL_CONVERT_TIMEOUT_MS`;不合规输入转换失败 422 |
| `curl http://<host>:3000/health` 通过 | ✅ 直连后端端口已验证 |
| 软件 API 端口 `/` 返回 404 | ✅ `PORT=3000` 上 `/`、`/songs` 均 404(有意设计,无管理页面) |
| 管理端端口 `/` 返回 200 | ✅ `ADMIN_PORT=3001` 上 `/`、`/songs`、`/models` 均返回同一份 HTML(200) |
| 管理端端口 `/` 返回 200 | ✅ `ADMIN_PORT=3001` 上 `/`、`/songs`、`/models` 均返回同一份 HTML(200) |
| 前端深链接回退 | ✅ 未知静态路径回退到 `index.html`,未知 API 仍 404 |
| `ADMIN_PORT === PORT` 单端口兼容模式 | ✅ 只建一个 server,管理页面回到 API 端口,日志提示「未分端口」 |
| 端口非法在生产环境中止启动 | ✅ `validateEnv` 对非 1..65535 整数端口报 error |
@@ -225,11 +225,11 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200
| 不使用 Docker | ✅ 仓库内已无 Dockerfile / docker-compose / Docker 引用 |
| 不部署反向代理 | ✅ 仅交付 `deploy/proxy/` 文档,install.sh 中无任何反代安装动作 |
| 缺 `deploy/.env` 时由 `init-env.sh` 引导 | ✅ 交互逐项询问 8 个键;只改这 8 个键、保留模板注释、`chmod 600`、回读校验;安装目录已有 `.env` 时直接复用 |
| `init-env.sh --check` / 非交互不阻塞 | ✅ `test/init-env.spec.ts`(16 项)覆盖 `--check`、`-y`、EOF/超时立即退出、口令不加引号写盘 |
| `init-env.sh --check` / 非交互不阻塞 | ✅ `test/init-env.spec.ts`(16 项)覆盖 `--check`、`-y`、EOF/超时立即退出、口令不加引号写盘 |
| `HOST` 默认 `0.0.0.0` | ✅ `configuration.ts`、`deploy/.env.example`、`web-server/.env.example`、`install.sh`、`init-env.sh` 五处一致 |
> **环境限制说明**:本次执行环境**无 root 权限**,且**未安装 nginx**(按需求也不应安装)。
> 因此 `deploy/install.sh` 的服务注册链路(systemd / OpenRC / SysVinit / crontab)
> 因此 `deploy/install.sh` 的服务注册链路(systemd / OpenRC / SysVinit / crontab)
> 未能在本机实机跑通。已完成的等价验证:
> - 后端以 `node dist/main.js` 直连真实数据库启动,两个端口 `/health` 均 200
> - 管理端托管与 SPA 回退逐路径实测(`/`、`/songs`、`/models`、`/api/*`、未知路径),
@@ -238,7 +238,7 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200
> - `npm run migration:run` 走编译产物成功
> - 端到端冒烟 57 项全通过
> - `install.sh` / `uninstall.sh` / `ctl.sh` 通过 `bash -n` 语法校验
> - `init-env.sh` 通过 `bash -n`,并由 `test/init-env.spec.ts`(16 项)覆盖
> - `init-env.sh` 通过 `bash -n`,并由 `test/init-env.spec.ts`(16 项)覆盖
> 交互写回 / `--check` / 非交互 / 超时 / 幂等 / 口令不加引号
> - 反代文档中的占位符与后端实际行为逐条对照(两个端口、路径、上传上限、健康检查)
>
@@ -253,13 +253,13 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200
内容:`web-server/`(源码 + dist)、`admin/`(构建产物)、`deploy/`
(含 `proxy/` 反代文档)、`contracts/` 快照、`THIRD_PARTY.md`、`README.txt`。
打包脚本会对产物做硬校验:`src/**/*.ts` 必须都有对应 `dist/*.js`,
打包脚本会对产物做硬校验:`src/**/*.ts` 必须都有对应 `dist/*.js`,
关键文件(`dist/main.js`、`dist/storage/storage.module.js`、`admin/index.html`、
`deploy/proxy/*`)必须齐全,且不得含真实 `.env` 与 Docker/nginx 安装配置。
v0.2.0 起校验清单追加模型新模块:`dist/models/model-inspector.js`、
`dist/models/model-thumbnail.js`、`dist/models/vrm-writer.js`、
`dist/models/gltf-packer.js`、`dist/migrations/1789460000000-AddModelStats.js`。
> **发布包名变更**:`opendance-web-v0.1.0.tar.gz` -> **`opendance-web-v0.2.0.tar.gz`**
> (`scripts/package-release.sh` 的 `VERSION` 默认值已改为 `0.2.0`,
> **发布包名变更**: `opendance-web-v0.1.0.tar.gz` -> **`opendance-web-v0.2.0.tar.gz`**
> (`scripts/package-release.sh` 的 `VERSION` 默认值已改为 `0.2.0`,
> 与 `project.yaml` 的 `version: 0.2.0`、`web-server/package.json` 一致)。