Commit Graph
60 Commits
Author SHA1 Message Date
zijunchiang 12041f2e58 fix(upload,models): 上传落盘补齐兜底 + 不可用模型归因准确(v0.7.5)
两个真实缺陷,都由本轮的「跨端边界验收」暴露:

一、上传落盘:去重命中时删掉了唯一副本(T-WEB-017)
persistTempFile(动作 / 模型 / 场景上传走的通路)在 sha256 去重命中时直接
safeUnlink(tempPath) 并返回旧记录 —— 一旦记录指向的路径不存在(换过 STORAGE_DIR、
只恢复数据库、手工删过文件),上传仍返回 201,但后续动作预处理 500
(实测 ENOENT .../<sha>.vmd)、模型分析与缩略图静默失败。持久化通路 persistBuffer
早在上一轮就补了「文件缺失则按原路径补写」,这条通路漏了;现在同样处理:
先 existsSync(absolutePath(existing)),缺失则按记录里的原路径 rename(跨设备退化为
copyFile)补写,只有确认目标存在才删临时文件。
新增 test/storage-persist.spec.ts(5 项):首次上传 / 去重命中且文件在 /
去重命中但文件已丢失(补写而非删除)/ persistBuffer 回归 / 空文件拒绝。
反向验证:临时还原修复后第 3 条立刻失败,恢复后 5 项全过。

二、不可用模型的原因归因不准确(T-WEB-017)
unusableReason() 只要 stats 里有 humanoid 数字就归因于 humanoid,于是一个直接上传的
.glb(骨架完全合规、drivable=true、只是没有 VRM 容器)会被写成
「humanoid 不达标(19/22)」,把审核引向"重新绑定骨骼"而不是"以 VRM 交付"。
实测(跨端验收用真实 PMX→Assimp→GLB,886 KB):
  修复前 → humanoid 不达标(19/22),未产出 VRM
  修复后 → 未产出 VRM(格式为 GLB;humanoid 覆盖 19/22 已达标),需重新以 VRM 交付才能进入客户端目录
现在按 drivable 区分:不满足 16 根必需骨才归因 humanoid;达标但没 VRM 容器则明确说"已达标"。
新增 2 项单测(model-usability 5 → 7 项)。

三、跨端边界验收(Windows × Web)新增两类场景,双方都断言
  * 不可用模型:真实 PMX 用 Assimp 转 GLB 后入库(等价"转换只产出 GLB")→
    usable=false、原因准确、**不进入 catalog/export**、defaultModelId 不变;
    包内客户端的 bundle.models 里没有任何非 VRM 模型;
  * 场景 config 只给 {physics:{gravity:{y:-9.8}}} → 服务端补齐默认
    (碰撞体 auto、地面 0.6/45°、静风、相机 follow、环境光 1、出生点 1 条),
    客户端解析后逐项一致。

实测:npm run verify 全绿(契约 6 项 / 35 路由 / 版本 0.7.5 / 27 文件 309 项测试 /
build / 产物级实体注册核对 / 启动冒烟);跨端集成验收在**发布包起的服务端**上全部通过。
版本 0.7.4 → 0.7.5。
2026-09-18 03:03:50 +08:00
zijunchiang 5a78749e49 test(codec): 钉住关键点帧的跨实现语义 + 提供一致性向量生成器(T-WIN-021)
`web-server/src/preprocess/keypoint-codec.ts` 是契约 websocket-keypoints §2 的
**参考实现**(其 encodeBinaryFrame 注释即「Android 端参考实现」,
decodeBinaryFrame 注释即「Windows 端参考实现(T-WIN-004)」)。Android 已用
「金标准字节向量」与它对齐,而 **Windows 的 GDScript 解码器此前只跟自己比过**
(自己编码、自己解码)—— 那条边由本仓暴露出的向量来补。

新增:
  * scripts/gen-frame-vectors.mts(+ npm run vectors:frame):用参考实现生成 12 个
    用例的字节与**参考解码结果**,供 Windows 仓的 accept-frame-vectors.sh 锁定比对。
    向量里同时给出 inputPoints(原始 float64 输入)、f32InputPoints / f32Hex
    (收敛到 float32 的**同输入**基准)与 points(参考解码结果),各自注明用途。
  * test/keypoint-frame-codec.spec.ts(7 项):把参考实现对其他两端的承诺显式钉住 ——
    i16 最小值边界(-32768/32767 必须是 0x8000,不能被夹成 -32767)、
    **取整规则**(JS Math.round 半数朝 +∞:-2.5 → -2,与 Android 的 Math.round 一致)、
    置信度阈值 128/255 与两端夹紧、u32 frame_id(0xFFFFFFFF / 0x80000000)、
    flags 只取低 8 位、非法帧(长度 / magic / 版本)拒绝、20 组随机输入的往返量化误差。

实测:npm run verify 全绿 —— 契约 6 项 / 35 路由 / 版本一致 / 26 文件 302 项测试
(原 295)/ build / 产物级实体注册核对 / 启动冒烟。
2026-09-17 05:51:26 +08:00
zijunchiang 06b476c4e2 test(accept): 产物级端到端验收自动化 + 实体注册核对落到 dist(v0.7.4 实测全通过)
背景:`npm run verify` 跑的是源码与刚构建的 dist,而交付给部署方的是
`dist-release/*.tar.gz`;两者之间隔着「解包 → npm install --omit=dev → 迁移 →
起进程」四步。本项目三次生产事故(v0.7.0/v0.7.1 起不来、v0.7.3 场景上传 400、
v0.7.4 `No metadata for "Scene"`)全都只在产物真正跑起来时才暴露。

一、`scripts/check-bootstrap.mjs` 新增「产物级实体注册核对」
   直接读包内 `dist/entities/`:逐个 import 每个 *.entity.js(@Entity() 装饰器
   在模块加载时写 metadata),要求
     * 每个实体文件里被 @Entity() 装饰的类都在 ENTITIES 里;
     * ENTITIES 里不允许有未装饰的成员;不允许重复;
     * Scene 必须注册(点名)。
   为什么必须单独有这一条:漏注册实体**不影响启动**(TypeORM 只按 ENTITIES 注册,
   DI 图照样解析、/health 正常、migrationsPending 0),只在第一次查该实体时抛
   `No metadata`,导致 /api/scenes、/api/catalog/export、/api/catalog/version、
   /api/reviews?status=pending 全部 500 —— 客户端一个目录都拉不到。
   原来的启动冒烟与 entities-registry.spec.ts(查 src/)都抓不到它。

   反向验证(在真实 v0.7.4 发布包里复现生产缺陷):把包内
   dist/entities/index.js 的 ENTITIES 去掉 Scene 后,核对立即失败并给出
   「scene.entity.js 里的 Scene 没有注册进 ENTITIES」与「ENTITIES 里没有 Scene」;
   还原后通过。

二、`scripts/accept-release.sh`(新增):一个 tar.gz 的产物级端到端验收
   解包 → 装生产依赖 → 跑迁移 → 起服务 → 校验 /health 关键字段
   (版本 / 数据库 / migrationsPending / scenes.available)→ 产物级实体注册核对
   (用仓库当前的 checker 覆盖包内旧版本,验收标准以最新为准)→ 跑发布包自带的
   test/smoke.mjs → 停服务。只需要一个现成的 MySQL/MariaDB,不装数据库
   (与项目「不部署 MySQL」的边界一致)。

   实测(opendance-web-v0.7.4.tar.gz + 真实 MySQL 9.7.2,全新库从零迁移):
     ✓ /health version=0.7.4 与 package.json 一致
     ✓ 数据库已连接(opendance_accept,pending=0)
     ✓ scenes.available=true(生产 v0.7.3 是 false + No metadata)
     ✓ 产物级实体注册核对通过:6 个实体文件全部注册进 ENTITIES(含 Scene)
     == 结果:80 通过 / 0 失败 ==
   冒烟 [7] 段即 ④ 验收:POST /api/scenes 201、一律 pending、GLB 直传不重编码、
   glbUrl 生成、重力/风/摩擦/坡度/出生点/相机模式按配置原样入库、越界夹紧与非法
   枚举回退、审核通过后 catalog/export 的 scenes[] 带完整物理参数与 GLB/缩略图字段、
   目录指纹纳入场景变更、详情、状态过滤、DELETE 204。

   `npm run verify` 全绿:契约 6 项 / 35 路由 / 版本一致 / 295 项测试 / build /
   产物级注册核对 / 启动冒烟。
2026-09-17 04:44:30 +08:00
zijunchiang 2da02ee423 test(real-samples): 用真实素材端到端验证 ①模型→VRM 与 ②动作内部标准
`model-archive.spec.ts` / `mmd-vmd.spec.ts` 用的都是合成夹具与假 Assimp,
证明得了边界,证明不了「真素材能出片」。新增 opt-in 的
`test/real-samples.spec.ts`(6 项),样例或 Assimp 缺失时整体跳过,
CI 与无素材的开发机不受影响:

① 模型 → VRM(两个真实 PMX 压缩包)
   - 初音未来 1.0 MB → 1.10 MB VRM,humanoid 19/22,2 张贴图全内嵌;
   - 奥黛塔 28.1 MB → 26.08 MB VRM,humanoid 21/22,18 张贴图全内嵌,
     39 材质(36 项双面已补回);两者 drivable = true;
   - 断言 VRMC_vrm 1.0、所有 images 带 bufferView 且无 uri、无 MMD 专属
     材质扩展泄漏、蒙皮下标不越界,并**逐字节复算 POSITION 访问器的
     min/max**(glTF 唯一强制填写的边界,客户端据此做视锥剔除与 AABB;
     读访问器必须按 byteStride 走,否则交错缓冲里的 u16 会被读成乱码)。

② 动作内部标准 + 骨骼重绑定(真实 VMD,0.8 MB / 96.7 s)
   - maxFrame 2900 → 2901 帧、标准骨架 31/31 命中、双侧腿部 IK 链齐备;
   - MMD 骨骼名 → joint-map 候选名的重绑定结果逐一比对(15 个键);
   - 15 个直接关节的置信度恒为 1.0(绝不退化成 0.5 的推算值),
     其余 18 点为 0.5,缺失为 0;骨盆归零后投影 max|xy| = 0.85 ≤ 1;
   - NDJSON 往返一致、缩略图 PNG 有效、手腕摆幅非退化。

顺带修 `assimp-locate.spec.ts` 的非密闭性:其用例断言的是 PATH/常见系统
目录查找,但会继承外部 ASSIMP_BIN(assimp-lib.sh 中 ASSIMP_BIN 优先级高于
PATH),开发者按本项目文档设过 ASSIMP_BIN 时 npm test 会假失败。

规模 24 文件 / 289 项 → 25 文件 / 295 项。产物与已发布的 v0.7.4 无关
(发布包不含 web-server/test/),故不新发版本。
2026-09-17 04:11:24 +08:00
zijunchiang d1c0dd6e7a test(scenes): 加带真实数据库的端到端用例(sql.js 内存库),把生产根因钉死
新增 test/scenes-e2e.spec.ts(4 项,全部真实 SQL,不是 mock):

1. Scene 在 DataSource 里有 metadata、getRepository 不抛错;
2. **反向验证生产根因**:当 ENTITIES 漏掉 Scene 时,`getRepository(Scene)` **不会抛**
   (TypeORM 0.3 是惰性的——这正是"启动完全正常"的原因),而**第一次查询**会 reject
   并抛出与生产完全一致的 `No metadata for "Scene" was found.`;
3. **上传→归档→落库→发布→目录导出 全链路**(真实 StorageService + 真实仓储):
   - 用 multer diskStorage 的文件形状(只有 path、没有 buffer)上传 .glb;
   - 断言 GLB 已归档进 storage、临时目录不再残留该文件(验证 diskStorage 修复);
   - 落 pending;`published()` 为空;发布后能取到完整物理参数(重力/风/地面/相机);
   - `CatalogService.exportBundle()` 的 `scenes[]` 含该场景且参数完整;
   - `catalog/version` 指纹为 64 位十六进制;`scenes.diagnostics()` 报告可用;
   - 删除后 `published()` 不再返回(软删除生效);
4. 越界/非法参数在**真实落库往返**(jsonText transformer)后仍是收敛值
   (重力 -200、摩擦 1、colliders auto、相机 follow)。

实现细节:库内用 `sql.js`(WASM,无原生依赖,devDependency)跑 TypeORM 的 sqljs 驱动;
SQLite 本身对列类型名宽松,但 TypeORM 的 sqlite 驱动会校验 normalizeType 的结果,
因此测试里加了一处**有注释的兼容垫片**:`char→varchar`、`mediumtext/longtext/tinytext→text`、
`enum→varchar`,以及 `bigint` 自增主键→`integer`(SQLite 的 AUTOINCREMENT 只允许
INTEGER PRIMARY KEY)。只影响本测试进程,不改动任何实体定义。

验证:npm run verify 通过(24 文件 / 289 项测试 + 契约/路由/版本一致 + 启动冒烟)。
2026-09-17 03:52:33 +08:00
zijunchiang 7a74f598be fix(entities): 场景功能的真正根因 —— Scene 未注册进 ENTITIES(v0.7.4)
部署 v0.7.3 后,/health 的 scenes.error 给出决定性线索:

  No metadata for "Scene" was found.

src/entities/index.ts 的 ENTITIES 一直漏了 Scene:
  export const ENTITIES = [Asset, Song, Motion, CharacterModel, ReviewRecord];

TypeORM 只按 ENTITIES 注册 metadata,漏注册的后果极其隐蔽:
- 启动完全正常(DI 可解析、/health 正常、migrationsPending 0);
- 迁移也正常(AddScenes 已执行,表其实是建好的);
- 但第一次查 Scene 就抛 No metadata,于是 /api/scenes、/api/catalog/export、
  /api/catalog/version、/api/reviews?status=pending 全部 500
  → 客户端拉不到任何目录、管理端待审列表打不开。

即:此前"数据库缺 scenes 表"的判断也是错的(v0.7.3 的降级与诊断脚本因此仍然值得保留
—— 它们把"场景坏了"限制成"场景不可用",而不是整站不可用)。

修复:ENTITIES 加入 Scene 并导出。

新增静态防线 test/entities-registry.spec.ts(4 项):扫描 src/entities/*.entity.ts 里的
@Entity() 类并逐一与 ENTITIES 对账(漏一个即失败)、检查无重复/无多余、核对 AddScenes
迁移文件存在。已做反向验证:把 Scene 从 ENTITIES 移除后该测试立刻失败。
这类缺陷既过得了 tsc、也过得了启动冒烟(应用能启动),只能靠静态核对兜住。

文档:README.md/.txt 增加 v0.7.4 修订说明;tasks/015-scene-management.md 增
「缺陷修复记录四」;开发文档 §4.12 补「新增实体必须加入 ENTITIES」的硬性要求。

验证:npm run verify 通过(23 文件 / 285 项测试 + 契约/路由/版本一致 + 启动冒烟);
发布产物 opendance-web-v0.7.4.tar.gz
sha256 e680c23fc382b6c39498608ea0154f8f80e9e6b5c13518f0f88095644ce88fe1。
2026-09-17 03:48:52 +08:00
zijunchiang 74332e20d8 docs(proxy): 更正「Gitea 附件通道坏了/磁盘写满」的误判 —— 实际是上传字段名写错
排查 Windows 整包上传时的 500 曾被判成"Gitea 宿主机磁盘写满"。**这个结论是错的**,
本次用对照实验确认并更正:Gitea 的 Release 附件接口 multipart 字段名是
**`attachment`**,用 `file=` 会得到

  HTTP 500 {"message":"","url":"https://gitea.kasugano.cn/api/swagger"}

空消息,极易误判成服务器故障。同一 1 KB 文件、同一 Release 的实测对照:
  -F "attachment=@probe.bin"                      → 201
  --data-binary @probe.bin (+?name=)              → 201
  -F "file=@probe.bin"                            → 500(空消息)

**仍然真实存在、且是唯一限制的是 IIS `maxAllowedContentLength`(默认 30,000,000 字节)**:
927 KB / 14 MB / 16,372,427 B 通过;30.5 MB / 38,245,602 B / 40 MB 一律 HTTP 413。

文档更正:
- deploy/proxy/README.md 第九节重写 9.1(含"误判更正"小节)与 9.3(复验命令改用
  正确字段名),并给出两种可直接照抄的上传写法;
- DSH 开发文档 v1.6 §5.7 的"阶段二"表格改为"误判更正"记录;
- opendance-windows/tasks/014-full-package.md 同步更正并附正确上传命令;
- 本仓库 README.md/.txt 的 v0.7.3 说明补第 6 条。

验证:npm run verify 通过(22 文件 / 281 项测试 + 启动冒烟);重新打包
opendance-web-v0.7.3.tar.gz(sha256 见 dist-release/SHA256SUMS)。
2026-09-17 03:39:36 +08:00
zijunchiang 828c7cea49 fix(scenes): 场景上传一律 400 —— diskStorage 却按内存存储判断 file.buffer
现场(真实部署 v0.7.2,用 curl 对照验证过):
  POST /api/models  → 201(同一份 multipart 文件)
  POST /api/scenes  → 400「上传场景必须带 file 字段(.glb 为主…)」
即**任何场景都传不上来**(冒烟脚本里 6 项场景断言全红)。

根因:上传中间件在 storage.module.ts 里配置为 multer 的 **diskStorage**
(先落 ${STORAGE_DIR}/tmp,避免 512 MiB 级 VRM 打爆内存),落盘后的文件对象
**只有 path、没有 buffer**;而 ScenesService.create() 当初按内存存储写:

  if (!file || !file.buffer || file.buffer.length === 0) { …400… }

file.buffer 恒为 undefined,于是必然 400。

修复:与 models/motions 统一到同一套上传处理
- requireUploadedFile(file):校验确实落盘(path),并修正 busboy 按 latin1 解出的
  中文/日文文件名;
- 直接导入(.glb/.gltf):storage.persistTempFile(AssetKind.Scene, upload.path, …)
  —— 归档并顺带清理临时文件;
- 需转换(.zip/.pmx/.fbx):readFile(upload.path) 取 buffer 后转换 → persistBuffer,
  并在 finally 中 unlink(upload.path);
- 体积上限用 upload.size 判断(UPLOAD_MAX_BYTES),超限 400 且不落盘;
- UploadedSceneFile 改为 UploadedFileLike 的别名(有 path 无 buffer);
- 日志里的文件名改用解码后的 upload.originalname。

同时全局排查过同类写法:除这两处注释外,代码里已无按 file.buffer 处理上传的地方。

新增 test/scene-upload.spec.ts(5 项,修复前必失败):diskStorage 形状的文件不再
被判成「缺少 file 字段」、GLB 直传标注未做重编码且落 pending、中文文件名被还原、
缺文件仍 400、超限 400 且不落盘。

文档:README.md/.txt 的 v0.7.3 说明补上这条;tasks/015-scene-management.md 增
「缺陷修复记录三」。

验证:npm run verify 通过(契约六项一致、35 条路由、22 文件 / 281 项测试、
nest build、启动冒烟);重新打包 opendance-web-v0.7.3.tar.gz
sha256 07463a3b9f971346d0af1f73919e6e4dfa98ef20fd160419405d57272e9d4524。
2026-09-17 03:28:31 +08:00
zijunchiang f3b97fec53 fix(scenes): 场景表不可用不再拖垮整站(生产事故修复)+ 诊断脚本
现场(真实部署 v0.7.2):数据库缺 scenes 表,于是
  /api/scenes、/api/scenes/:id、/api/catalog/export、/api/catalog/version、
  /api/reviews?status=pending 全部 500,而 /api/songs、/api/motions、/api/models
  正常。后果不是"场景不可用",而是客户端拉不到任何目录(Windows/Android 全废)、
管理端待审列表打不开。`/health` 却显示 database.connected=true、
migrationsPending=0 —— 因为 DataSource.showMigrations() 只比较**代码里加载到的**
迁移;部署的 dist 若缺 AddScenes.js,该迁移不在比较集合里,于是"没有待执行迁移"
但表不存在(最隐蔽的一种不一致)。

修复思路:契约与文档都写明「场景是可选资源」(没有可用场景时客户端回退内置空
场景),所以场景表的问题不允许影响与场景无关的功能 —— 改为降级:

- ScenesService 新增 queryScenes() 包装:数据库层面失败时返回空结果,**只记一次
  日志**(避免刷屏),原因写入 diagnostics();下一次查询成功即自动复位;
- ReviewsService.listByStatus() 对场景查询单独 catch,待审列表照常返回其它资源;
- /health 新增 scenes: { available, error } —— 降级但绝不静默;
- HealthModule 增加 imports: [ScenesModule](HealthController 注入了 ScenesService,
  Nest 的 DI 按模块解析,漏了会直接 UnknownDependenciesException;
  scripts/check-bootstrap.mjs 已能拦住这一类)。

新增 web-server/scripts/diagnose-scenes.mjs(默认只读):打印运行期真实连的库、
表清单、typeorm_migrations 记录、**代码实际加载的迁移**、scenes 实际列 vs 实体
期望列的逐列 diff;`--fix` 仅在缺表时用迁移里同一份 DDL 幂等补表。

顺带修正 models.controller.ts 的误导性注释:模型上传**不接受** isPrimary/status
(契约:上传一律 pending,默认模型由审核通过时带 setPrimary=true 或
POST /api/models/:id/primary 设定)。test/smoke.mjs 此前正是按错误预期断言
"isPrimary 已置位",已改为:上传断言 pending + isPrimary=false → 审核通过时带
setPrimary=true → 再断言 isPrimary=true;同时给目录导出/指纹等检查补上失败细节
(HTTP 状态 + 响应体),并把待审列表查询的 pageSize 放大到 200(避免库里待审项多
时新条目被挤出第一页)。

文档:README.md/.txt 增加 v0.7.3 修订说明;tasks/015-scene-management.md 增
「缺陷修复记录二」;开发文档 §4.12 补充硬性要求(场景不可用必须降级 + /health 暴露
+ showMigrations 的陷阱说明)。

测试:新增 test/scenes-degradation.spec.ts(6 项);npm run verify 通过
(契约六项一致、35 条路由、21 文件 / 276 项测试、nest build、启动冒烟);
发布产物解压后 npm ci --omit=dev + node dist/main.js 实测打印「应用版本 v0.7.3」。

注意:Gitea 附件上传通道仍故障(1 KB 也瞬时 500),因此本版**尚未上传 Release**,
产物先在本地交付(sha256 见 SHA256SUMS)。
2026-09-17 03:24:56 +08:00
zijunchiang 2224195d05 docs: 补齐部署必需的 Gitea 域名与属主(不再脱敏成占位符)
域名与仓库属主属于「部署必需且非隐私」的信息,此前写成 `<Gitea 地址>` / `<owner>`
会让文档无法照做(照抄的 clone / 下载命令直接失败)。本次把它们补成真实值:
gitea.kasugano.cn / zijunchiang。口令、访问令牌、数据库口令等**秘密项**继续用占位符。

改动:
- README.md / README.txt:发布包下载地址补成真实 Release 地址
  (https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/vX.Y.Z/…),
  并写明域名与属主是部署必需信息、不是隐私项;
- README.md / README.txt:说明 `<服务器地址>` 是**部署方自己的**服务端地址
  (部署方决定),不是被脱敏的信息,避免与「被隐藏的域名」混淆;
- deploy/proxy/README.md:第九节的附件接口复验 curl 补成真实 Gitea 域名与属主;
  自检清单里 `<反代地址>` 加一行说明(你们自己的反代域名或 IP);
- deploy/systemd/opendance-api.service:`Documentation=` 补成真实仓库地址。
2026-09-17 03:07:23 +08:00
zijunchiang 50688e7ce4 test: 补目录导出交付逻辑与场景端到端冒烟(①/④ 的验收证据)
目标 ④(场景管理)此前只有"上传/配置收敛"的测试,**没有任何测试覆盖
「场景是否真的随 catalog/export 下发到客户端」**;①(非 VRM 不进目录)也只有
实现没有断言。补上两层:

1. 新增 test/catalog-export.spec.ts(12 项,纯 mock、不依赖数据库)
   - ① 目录查询必须同时限定 status=published 与 format=vrm;
     /catalog/version 的模型查询同样只算 VRM;
     GLB/glTF/FBX 这类"未产出 VRM"的中间格式不得进入目录;
   - ④ exportBundle().scenes 来自 ScenesService.published(),且
     重力/风/地面/碰撞体等物理参数完整透传;无场景时是空数组而非 undefined;
   - ④ 场景新增/改名/换 GLB/更新时间变化都必须改变 catalog/version 指纹
     (否则客户端不会重新拉取);
   - defaultModelId 透传与空值回退。

2. test/smoke.mjs 新增 [7] 场景管理端到端段(18 项断言),并在 [9] 清理:
   - 上传 GLB 场景 → 一律 pending、GLB 直传不重编码、glbUrl 生成;
   - 合法物理参数原样入库;越界参数(重力 -9999、摩擦 5)被夹紧到 -200/1;
     非法枚举(colliders/相机模式)回退默认值;
   - 缺少来源五元组 → 400;
   - **待审场景不出现在客户端目录**;审核通过后才进入 scenes[],且带完整物理参数;
   - 目录指纹随场景发布而变化;GET /api/scenes/:id 与 ?status= 过滤可用。

   夹具可信性已单独验证:用**真实检查器**(inspectModelFile)解析 smoke 里同款
   构造方式生成的场景 GLB,得到 meshes=1 / vertices=3 / triangles=1 / nodes=1,
   证明该夹具是合法 GLB,而不是"写了个跑不通的脚本"。

顺带核查管理端构建产物:/scenes 路由、"场景管理"入口、"v0.7.2" 版本标签与
物理参数相关文案都在 bundle 中(Web 左下角版本号来自 package.json,见
scripts/check-version.mjs)。

验证:npm run verify 通过(契约六项一致、35 条路由一致、版本一致、
20 文件 / 269 项测试、nest build、启动冒烟)。
2026-09-17 01:56:20 +08:00
zijunchiang 7037abf066 docs(proxy): 修正 Gitea 附件上传失败的结论——与体积无关,指向宿主机磁盘
上一版把失败记成「两阶段 + 约 16.4 MB 边界」,那是**误判**:
当时磁盘正在被失败的请求逐步填满,所以看起来像有个体积阈值。补做与体积、
与仓库都无关的隔离测试后,结论要改:

- 1 KB 与 100 KB 也失败,且**瞬间**失败(0.14~0.3 s,不是读完请求体才判断)
  -> 不是 [attachment] MAX_SIZE 这类体积上限;
- opendance-web / opendance-windows / opendance-android 三个 Release 的资产
  上传接口**同样**失败 -> 与仓库无关;
- 创建 Release、PATCH Release 说明(纯 JSON)都成功 -> 只有「附件文件落盘」
  这条通道坏掉;
- 全部失败期间仍有 927 KB 的附件成功上传过一次 -> 间歇性。

最可能的原因:**Gitea 宿主机磁盘(或上传临时目录所在分区/tmpfs)几乎写满**
(Gitea 先把请求体写入数据目录的临时目录,空间不足时返回 500 而非 413)。

同时确认:IIS 那一关**已经放开且生效**(38,245,661 字节的请求体被完整接收,
不再 413),因此若仍失败应直接排查 Gitea 宿主机,而不是继续改 IIS。

deploy/proxy/README.md 第九节的 9.1 重写为上述隔离结论,并给出运维排查顺序
(df -h / Gitea 日志里的真实错误 / 清理临时文件 / 最后再改 MAX_SIZE)。

Windows 端 tasks/014-full-package.md 同步修正(原先的「16.4 MB 边界」表述作废)。
2026-09-17 01:51:32 +08:00
zijunchiang 2433cd22a5 fix(boot): CatalogModule 漏 import ScenesModule 导致产物起不来 + /health 版本号错
两个都是**只有真正启动服务才会暴露**的缺陷,v0.7.0 / v0.7.1 的产物受影响。

1. 【致命】CatalogModule 漏 import ScenesModule
   CatalogService 因为 catalog/export 要下发 scenes[] 而注入 ScenesService,但
   CatalogModule 的 imports 只列了 SongsModule / MotionsModule / ModelsModule。
   Nest 的 DI 按模块解析,于是 node dist/main.js 在 bootstrap 阶段直接抛
   UnknownDependenciesException: Nest can't resolve dependencies of the
   CatalogService (... ? ...) —— 服务完全起不来(装得上、起不来)。
   为什么三层检查全绿:nest build/tsc 看不出运行时 DI;258 项单元测试直接 new
   服务或测纯函数,从不编译 AppModule;打包脚本只校验文件清单与压缩包完整性。
   修复:CatalogModule.imports 补上 ScenesModule。

2. /health 的 version 在原生部署下永远是 0.1.0
   原实现 process.env.npm_package_version ?? '0.1.0',而该变量只在 npm 脚本下
   存在;正式部署是原生服务(ExecStart=node dist/main.js),npm 不参与 ->
   恒为 undefined -> 回退到硬编码 0.1.0,与管理端左下角展示的 vX.Y.Z 冲突,
   违反文档 11.2 第 4 条「可核对:Web 端至少与 GET /health 的 version 一致」。
   修复:新增 src/config/app-version.ts 作为运行时唯一解析点
   (APP_VERSION -> 运行入口旁/工作目录的 package.json(带包名校验)
   -> npm_package_version -> 显式 0.0.0),/health 改用 resolveAppVersion(),
   main.ts 启动首行也打印版本号。新增 12 项测试,其中一项显式模拟无 npm
   环境变量的原生部署。

防线(都挂进了 verify 与打包脚本)
- scripts/check-bootstrap.mjs:以正式部署方式(node dist/main.js、无 npm 环境
  变量、不需要数据库)拉起真实产物,断言①无依赖解析类错误②启动日志的应用版本
  等于 package.json ③已推进到数据库连接阶段。已做反向验证:删掉 ScenesModule
  后该检查立刻失败。
- scripts/check-version.mjs(并入 contracts:check):校验
  web-server/package.json = admin/package.json = project.yaml,正则禁止
  health.controller.ts 再出现硬编码版本回退,管理端已构建时断言产物 JS 含 vX.Y.Z。
- npm run verify 现在包含 bootstrap:check;package-release.sh 在构建后、打包前
  各跑一次(构建管理端后再跑 version:check,因为此时才需要校验产物内版本)。

文档:README.md / README.txt 增加 v0.7.2 修订说明;
tasks/011-version-display.md 与 tasks/015-scene-management.md 补「缺陷修复记录」
(含为什么当时没被发现的三层检查对照表)。

验证:npm run verify 通过(契约六项一致、35 条路由、258 项测试、nest build、
启动冒烟);产物 opendance-web-v0.7.2.tar.gz
sha256 f04ecc85d17517ec00754238616eb1237cdb2fe17440af6dce731827c0922b26。
2026-09-17 01:49:52 +08:00
zijunchiang 698d60bb65 docs(proxy): 记录 Gitea 附件上传上限的两阶段实测(IIS 放开后仍是 500)
按文档 §5.7 硬性要求 4「若用 A 方案,先在发布文档里记录实测上限」,把这次
Windows v0.2.0 整包(38,245,661 字节)发布的实测结果写进 deploy/proxy/README.md
第九节,并修正第九节里「只改 IIS 就够」的隐含假设:

第一阶段(IIS 未放开):14 MB 通过;30.5 / 40 MB 返回 HTTP 413。
第二阶段(IIS 已放开):38,245,661 字节的请求体被**完整接收**(115 s),
不再有 413 —— 说明 maxAllowedContentLength 已经够用;但随后 **Gitea 自己返回
HTTP 500**(响应体是 Gitea 的 API 错误信封,message 为空),瓶颈转移到了 Gitea 侧。

同接口边界测量:914 KB 与 16,372,427 B 通过;16,400,000 B 及以上失败;
且**在若干次大包失败之后,连 100 KB 都会在 0.3 秒内 500** ——
这不符合「上传体积上限」的特征(体积判断会先读完请求体),
更像上传临时目录 / 数据盘写满(Gitea 先写 data/tmp)。

因此文档明确要求运维按顺序查两件事:①Gitea 数据盘剩余空间(含 data/tmp,
以及它是否在小容量 tmpfs 上)并清理失败上传的残留临时文件;
②app.ini 的 [attachment] MAX_SIZE。并在 IIS 配置小节加醒目提示:
若放开 requestLimits 后仍为 500 而非 413,说明请求体已到达 Gitea,
要按上述两件事继续排查。
2026-09-17 01:40:27 +08:00
zijunchiang 3738bd91b9 release(v0.7.1): 契约 websocket-keypoints 0.2.0 + 补齐场景规格与任务文档
契约变更(T-WEB-016,与 Android T-AND-011「两端一起改」配套)
- websocket-keypoints 0.1.0 -> 0.2.0,均为向后兼容(子协议名与二进制帧
  version 均不变,只新增可选控制字段与一种新控制类型):
  1.1 保活:接收端必须声明 silentTimeoutMs 与 heartbeatIntervalMs,且心跳必须
      小于静默超时的一半。根因是客户端只能用 OkHttp 的协议层 ping,而它由
      底层库自动应答、**不暴露给应用层**,接收端根本看不到,无法续命;
  3.1 hello.deviceId(该安装实例的稳定标识,不使用任何硬件序列号);
  3.2/3.3 welcome/config 携带保活字段;
  3.6 应用层 ping/pong(回显 sentAtMs,pong 另带 serverAtMs 供算 RTT);
  5   设备去重:同一 deviceId 只保留最新连接,旧连接以 1000 关闭
      (刻意用 1000:对端据 §7 不会重连,否则形成"挤掉-重连-再挤掉"循环);
  7   关闭码与重连策略:1000/1001/1002 与主动停止不重连,其余重连;
  7.1 退避参数冻结:min(500ms*2^(n-1), 15000ms) + ±20% 抖动;
  8   连接状态机(两端同名,日志可直接对照)。
- contracts/VERSION 与 src/config/contract-versions.ts 同步到 0.2.0。

补齐 rest-api.yaml 漏写的场景结构(只补规格,不改线格式,版本号不变)
- CatalogBundle 增加 scenes[](标注 0.5.0 起下发);
- 新增 Scene / SceneConfig / Vec3 schemas。catalog/export 自 v0.7.0 起就返回
  scenes[],data-models 7 也已冻结,但规格漏写会让两端对字段边界
  (如 0~1、±200)的理解不一致。

deploy/proxy/README.md 新增第九节:Gitea 站点的附件上传上限
- 与一~五节的 Web 服务反代是**两个不同站点**;实测 30.5 MB / 40 MB 上传返回
  HTTP 413,根因是 IIS 默认 maxAllowedContentLength = 30,000,000 字节;
- 给出放宽到 256 MB 的 IIS / ARR / Gitea app.ini / nginx 四处配置与复验命令;
- 这解释了 Windows 端 v0.1.0 为何被拆成 zip.part0/1/2(交付缺陷)。

文档与脚本
- 补齐 tasks/011~016 与 project.yaml 条目(T-WEB-011~015 此前已在代码中落地
  却缺任务说明书);project.yaml 的 contracts_version 由单个 0.4.0 改为六项字典;
- scripts/package-release.sh 的版本号改为从 web-server/package.json 读取
  (与 GET /health 的 version、管理端左下角展示同源),并与 project.yaml 交叉校验;
  同时补上 SHA256SUMS 与自校验(此前是手工生成的)。

验证:npm run verify 通过(contracts:check 六项一致 + 35 条路由一致、
typecheck、246 项测试、nest build);产物 opendance-web-v0.7.1.tar.gz
sha256 27f92cfb125bbcf29077d4ad90c44720cbca46d23ddef12cd7826d73bd8865e2。
2026-09-17 01:30:41 +08:00
zijunchiang 50073b97cf docs(contracts): 补齐 rest-api 里 CatalogBundle.scenes[] 与 Scene/SceneConfig 结构
`catalog/export` 自 v0.7.0 起就返回 `scenes[]`(catalog.service.ts 已实现,
data-models.md §7 也已冻结),但 rest-api.yaml 的 CatalogBundle 漏了这个字段,
且 components 里没有 Scene / SceneConfig 定义 —— 属于"实现已发布、规格漏写"。

本次只补规格,不改线格式,因此六个契约版本号均不变(rest-api 仍为 0.5.0,
与文档 v1.6 §5.6「客户端契约升级到 rest-api 0.5.0」一致):
- CatalogBundle 增加 scenes[](标注 0.5.0 起下发)
- 新增 Scene(含 glbUrl/thumbnailUrl/config/stats/来源五元组/status)
- 新增 SceneConfig(physics.gravity/wind/ground/colliders、environment、
  spawnPoints<=16、camera)与复用的 Vec3

注入依赖:Windows 端 T-WIN-013 需要按这份结构生成 GDScript 类型,
规格缺失会导致两端对字段边界(如 0~1、±200)的理解不一致。

同时补 deploy/proxy/README.md 第九节:托管 Gitea 的 IIS 站点
附件上传上限(与一~五节的 Web 服务反代是两个不同站点)。
实测 30.5 MB / 40 MB 上传返回 HTTP 413,根因是 IIS 默认
maxAllowedContentLength = 30,000,000 字节;给出放宽到 256 MB 的
IIS / ARR / Gitea app.ini / nginx 四处配置与复验命令。
这解释了 Windows 端 v0.1.0 为何被拆成 zip.part0/1/2(交付缺陷)。
2026-09-17 00:46:35 +08:00
zijunchiang d6376ec5a5 更新 README.md 2026-09-15 22:17:49 +08:00
zijunchiang d61843d068 test: 二分探针 round1(8 段) 2026-09-15 21:38:37 +08:00
zijunchiang f61d40eafc test: 临时 Unicode 探针(用于测定 Gitea 混淆字符判定规则,随后删除) 2026-09-15 21:37:23 +08:00
zijunchiang 06c2fd688b docs: 全角 | / ? 改 ASCII(Gitea 混淆字符检测的剩余触发项) 2026-09-15 21:36:41 +08:00
zijunchiang 6913e181b5 docs: 其余易混淆字符改 ASCII(× − ≥ ≤ ≈ ✓ 等),消除 Gitea 告警
实测确认 Gitea 的「模棱两可 Unicode」检测覆盖:— – … § → × − ≥ ≤ ≈ ✓。
三库 .md/.txt 全部替换为 x - >= <= ~ OK;contracts 三库同源保持一致。
重新打包 v0.2.0。
2026-09-15 21:35:10 +08:00
zijunchiang 5de0891706 docs: 修回被误压空格的 ':=' 写法;契约与文档去真实 MariaDB 版本串
- contracts/websocket-keypoints.md(三库同源):把上一轮空格清理误伤的
  'var x:= ...' 修回 'var x := ...',保持文档内代码样例可读
- contracts/rest-api.yaml(三库同源):'如 MariaDB 10.11.19' -> '如 MariaDB 10.5+'
- DSH.md / tasks/001:去掉真实部署实例版本号,改为'由部署方确认'的表述
- 重新打包 v0.2.0

验证:契约 6 项一致(三库 md5 相同)、30 条路由一致。
2026-09-15 21:33:20 +08:00
zijunchiang 8c2f2afaea docs: 三库文档规范化(去混淆字符 + 占位符)+ Web README 初次部署清单
- 三库所有 .md/.txt 与配置样例中的 — – … § → 一律换成 ASCII(Gitea 的
  「模棱两可的 Unicode 字符」告警即由这几个字符触发;中文全角标点不受影响,
  实测 README.txt 修正后告警消失)
- contracts/* 在三库保持逐字节一致(已校验 md5)
- 残留真实值清理:README.txt 建库 SQL 的 dpendance -> opendance/opendance_app,
  Android HostDiscovery.kt 注释示例 192.168.1.23 -> 192.0.2.23
- Web README.txt 新增「初次部署(从零跑起来)」8 步清单与「常见问题(初次部署)」
  7 条;去掉纯文本 README 里的 markdown 粗体标记;统一 '以 root 直接执行' 写法
- 重新打包 v0.2.0

验证:168 项单测通过;契约 6 项与 30 条路由一致。
2026-09-15 21:28:12 +08:00
zijunchiang ebd871018f docs(readme): 新增初次部署清单与常见问题;统一 root 执行写法;清除 markdown 粗体标记与残留真实库名 2026-09-15 21:27:29 +08:00
zijunchiang 9c8379e5eb docs(readme): 破折号/箭头/省略号/节号改 ASCII,消除 Gitea 混淆字符告警 2026-09-15 21:25:56 +08:00
zijunchiang 9f08d316d0 docs: 清除文档中的真实部署信息与易混淆 Unicode 字符
需求:公开仓库的说明文档不应暴露开发/部署期信息,且 Gitea 会对 README.txt
报"含模棱两可的 Unicode 字符"。

敏感信息(全部替换为占位符或文档示例值):
- 真实数据库实例:DB_HOST=192.168.150.10 / DB_USER=dpendance / DB_NAME=dpendance
  → <数据库主机> / opendance_app / opendance(web-server/.env.example、
  deploy/.env.example、contracts/database.md ×3 库、project.yaml、README.txt
  的建库 SQL、tasks/001、tasks/002)
- 内网网段 192.168.150.0/24 / 192.168.150.20 / 192.168.1.10
  → RFC 5737 文档网段 192.0.2.0/24 等(含测试夹具,行为不变)
- 真实 Gitea 域名 → https://<Gitea 地址>/<owner>/<repo>(systemd 单元、
  Windows/Android README 的交叉链接)
- 测试夹具里的真实口令字面量 → 虚构口令(仍覆盖特殊字符场景)
- 保留 /opt/opendance、/var/lib/opendance 等产品默认安装路径(非敏感)

README.txt 易混淆字符:
- 全角拉丁字母(I/K 等)与全角 ASCII 标点((),:;?)→ 半角 + 中文排版空格
- 正文里的乱码字节样例(模å…)→ 改为文字描述
- × → x,★ → *
- 注意:代码/测试/契约里的全角「IK」是 MMD 真实骨骼名,**必须保留**,
  已回滚误改(mmd-skeleton.ts、mmd-vmd.spec.ts、inspect-vmd.mjs、各契约)

验证:168 项单测通过(含 VMD 31 项,确认全角骨骼名未被破坏)。
2026-09-15 21:25:25 +08:00
zijunchiang c342a804a1 feat(models): 新增历史模型回填(reanalyze)并修复存储去重的悬挂文件问题
背景:升级到带 models.stats 的版本后,库里已有模型没有分析与缩略图,
审核界面会全部显示"未分析、无预览图"。E2E 验证时还发现一个真问题:
sha256 去重命中旧资产但文件不存在(换过 STORAGE_DIR、或只恢复数据库)时,
分析会静默产出"全 0 结果"。

- POST /api/models/:id/reanalyze:重新分析(面数/骨骼树/humanoid/可驱动性)
  并重建缩略图,只改 stats 与 thumbnailAssetId;文件在存储中不存在时返回 409,
  避免用空结果覆盖已有分析数据
- scripts/reanalyze-models.mjs:批量回填 CLI(--dry-run / --model-id / --force)
- StorageService.persistBuffer:去重命中但文件不存在时按原路径**自愈补写**
- 契约:rest-api 新增 /api/models/{id}/reanalyze(路由 29 → 30 条)
- 文档:README / tasks/005 / 发布说明补回填章节与升级步骤

实测:真实 21MB VRM(290 骨骼)回填出面数 53552 / 顶点 44662 / humanoid 7/22,
并重建 512×512 缩略图;文件缺失场景返回 409 且不覆盖既有数据。
测试 168 项通过;契约与 30 条路由一致。
2026-09-15 21:18:15 +08:00
zijunchiang 53bf182476 feat(models): 发布 v0.2.0 —— GLB/VRM 主力格式、自动缩略图与分析、模型审核与骨骼预览
需求 1:系统确定以 GLB / VRM 为模型主力格式
- 服务端新增支持库:
  - model-inspector.ts:解析 GLB/VRM 的面数、顶点、材质、贴图、形变目标、
    **骨骼树(名称/层级/是否蒙皮/humanoid 映射/子节点数)**、humanoid 覆盖率与可驱动性
  - model-thumbnail.ts:**纯 TS 软件光栅化**(z-buffer + Lambert + 完整蒙皮矩阵)
    生成 512×512 PNG 缩略图,零原生依赖、不装 headless-gl
  - vrm-writer.ts:GLB → VRM 1.0(写入 VRMC_vrm 扩展与 humanoid 映射)
  - gltf-packer.ts:glTF(.gltf + .bin + 贴图)→ 单文件 GLB
- humanoid 候选表与 Godot 客户端 humanoid_map.gd **逐字对齐**,并有单测断言两边一致
  (服务端判定的"可驱动" = 客户端实际能否驱动)

需求 2:模型管理
- .glb / .vrm / .gltf **直接导入**(不重编码,GLB 含 VRM 扩展会自动识别为 VRM)
- .pmx / .fbx 服务端转换:assimp → .glb →(失败)→ .gltf 后自行打包成 GLB
  →(MODEL_CONVERT_FORMAT=vrm 时)补 VRMC_vrm 产出 VRM 1.0;每步 assimp info 回读校验
- .zip **自动搜索包内可用模型**:.vrm → .glb → .gltf(直接导入)优先,
  其次 .pmx → .fbx(转换);同优先级内按"指名字 → 目录最浅 → 文件最大"
- 入库后自动分析(models.stats)与生成缩略图(models.thumbnail_asset_id),
  失败均不阻断入库;管理端列表显示小缩略图、预览放开到 vrm/glb/gltf

需求 3:审核功能
- 上传的模型状态**一律 pending**(不再自动发布、不再自动设默认)
- 审核界面新增模型面板:体积、面数、顶点、材质、贴图、骨骼、形变目标等指标,
  humanoid 覆盖进度、可驱动性结论(缺哪些必需骨骼)、warnings,以及
  **骨骼详细预览**(层级缩进、蒙皮/Humanoid 徽章、子节点数、搜索保留祖先链、
  仅看蒙皮、限高滚动 + 只渲染前 400 行)
- 审核通过可带 setPrimary=true 一并设为默认模型(此时才写源码种子);
  POST /api/models/:id/primary 仅允许 published(否则 409)
- scripts/import-default-model.mjs 改为"上传 → 审核通过 + 设默认"流程

其它:
- models 新增 stats 列(迁移 AddModelStats1789460000000,已在真实库执行)
- 契约升至 rest-api/data-models/model-formats 0.4.0、database 0.3.0(另两库已同步)
- 版本号 0.1.0 → 0.2.0(package.json ×2 + lockfile + project.yaml)
- 文档:README.txt / DSH.md / project.yaml / tasks 005·007·010 全面更新;
  顶层开发书更新为 v1.4

验证:168 项单测通过(10 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实端到端:MMD 压缩包 → GLB + 分析(面数 9071/骨骼 238/humanoid 0-22) + 缩略图(15KB PNG);
GLB 直接导入 sha256 与源文件一致;审核通过后 defaultModelId 由 VRM 变为该模型
2026-09-15 20:37:53 +08:00
zijunchiang ccced34dd6 fix(models): 默认模型改用「压缩包 sha256」匹配(转换产物非字节级确定)
实测发现:同一份 zip 连续转换三次得到三个不同的产物 sha256(大小都是 1,152,464
字节,差异集中在 BIN chunk 的十几字节),说明 Assimp 的 GLB 导出不是字节级确定的。
因此原先把「转换产物 sha256」当作默认模型匹配键的设计是错的——换一次转换就失效。

- default-model.ts / default-model-source.ts:种子拆成 sourceSha256(上传压缩包的
  sha256,稳定,主匹配键)与 fileSha256(产物 sha256,仅作记录)
- models.service.ts:resolveDefaultModelId 依次按 压缩包 sha256 → 产物 sha256 →
  名称 匹配,都落空才回落到 VRM 优先规则并告警
- ModelDto 增加 sourceSha256(非压缩包上传为 null)
- scripts/import-default-model.mjs:写种子时用 sourceSha256;顺带修掉位置参数解析
  (文件当第一个参数时识别不出,会打印用法)
- 契约与文档同步:data-models §4.1、model-formats §1.1 记录该确定性结论;
  README / DSH / tasks/005 的「按产物 sha256 匹配」表述已更正

另附:把模型格式能力的实测矩阵写进 Release 说明(原生只支持 glTF 系;
PMX→FBX 失败;VRM→GLB 丢 VRM 扩展;humanoid 骨骼命名覆盖率)。

验证:125 项单测通过;清理掉两个"文件只在本机临时目录"的模型(避免 sha256 去重
导致后续上传不落盘);种子按压缩包 sha256 命中后 defaultModelId 正确解析。
2026-09-15 19:36:37 +08:00
zijunchiang e4351c5062 feat(models): 模型压缩包自动转换 + 默认模型写进源码;首次启动 .env 引导;HOST 默认 0.0.0.0
需求 1:模型上传支持压缩包(MMD 模型包)
- common/zip-reader.ts:纯 TS 极简 ZIP 读取器(EOCD/中央目录、STORE+DEFLATE、
  ZIP64 大小字段、Shift-JIS/GBK 文件名回退、拒绝 zip-slip 与加密条目)
- models/model-convert.ts:解压 → 选主模型(指名字/最浅/最大)→ Assimp 导出 FBX
  → **assimp info 回读校验**(Assimp 对带蒙皮大骨架模型会写出损坏文件:
  实测 714 骨骼 PMX 导出退出码 11、产物回读报 FBX-DOM no Objects dictionary found)
  → 失败自动回退 GLB;两种都失败则 422
- models/glb-textures.ts:把 Assimp 写成外部 URI 的贴图内嵌进 GLB 的 BIN chunk,
  保证模型库里单文件即可加载(否则是白模)
- 溯源:压缩包原件存为 model-archive 资产;models 新增 source_asset_id /
  conversion_note(迁移 1789450000000-AddModelConversionFields,已在真实库上
  验证 up/down 可逆)
- 默认模型:转换成功且 autoDefault 不为 false → is_primary=1 + published,
  并把身份(名称/格式/产物 sha256)写进源码 src/config/default-model.ts;
  resolveDefaultModelId 增加 DEFAULT_MODEL_ID 与源码种子两步(按 sha256 匹配,
  换库仍有效),实测库中有 2 个 VRM 时默认模型仍解析为该 GLB
- scripts/import-default-model.mjs:运维 CLI(上传 zip 并写源码 / --write-source-only)
- 修复 multipart 中文文件名乱码(upload.util.decodeUploadName)
- DTO/契约新增 conversionNote、fileSha256;管理端 accept 增加 .zip 并展示转换来源

需求 2:首次启动 .env 引导
- deploy/init-env.sh:交互式逐项填写 8 个键(回车保留默认、口令不回显不加引号、
  端口校验、mysql2 连通性自检且口令只经环境变量传递、只改这 8 个键并保留模板注释、
  chmod 600、结尾打码摘要);--force/--check/-y/-i/--skip-db-check 等开关;
  非交互(-y、OPENDANCE_NONINTERACTIVE、stdin 关闭)绝不提问、读取带超时
- install.sh 缺 .env 时改为调用引导,失败才 die

需求 3:默认监听 0.0.0.0
- 代码默认值、两份 .env 模板、install.sh、init-env.sh 五处一致,并新增测试守护
  (未设置 HOST 时为 0.0.0.0;两份模板都写着 HOST=0.0.0.0)

契约:rest-api / data-models / model-formats → 0.3.0,database → 0.2.0(另两库已同步)

验证:125 项单测通过(7 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实包端到端 HTTP 201 / 0.89s / format=glb / isPrimary=true / published;
GLB 1,152,464 字节、1 mesh / 240 nodes / 1 skin(238 joints) / 2 张内嵌贴图;
迁移 revert→run 实测可逆且数据无损恢复
2026-09-15 13:55:54 +08:00
zijunchiang bba5bcf59e feat(web): 端口分离 + VMD 动作支持;契约升至 0.2.0
需求 1:管理端端口与软件 API 端口分离
- 软件 API 端口 PORT(默认 3000)只提供 /api、/health、/static、/contracts,
  不提供管理页面(未命中路径 404);管理端端口 ADMIN_PORT(默认 3001)
  提供管理页面与全部接口;ADMIN_HOST 可单独限制管理端来源
- main.ts 同进程监听两个端口,用 req.socket.localPort 区分端口行为;
  ADMIN_PORT=PORT 为单端口兼容模式(只创建一个 server,无 EADDRINUSE)
- 端口非法在生产环境中止启动;ADMIN_PORT=PORT 只 warn
- 启动日志分别打印两个端口的访问地址

需求 2:动作数据支持 VMD(MikuMikuDance)
- vmd-parser.ts:二进制解析(30 字节头 + Shift-JIS 模型名 + 111 字节/条骨骼
  关键帧 + morph 段)、MMD 贝塞尔插值、GBK 模型名回退、固定 30fps
- mmd-skeleton.ts:标准 MMD 骨架;静止比例从参考 PMX 骨骼段实测得到
  (足首 1.43 / 膝 6.57 / 髋 11.50 / 首 16.21 / 頭 17.16,腿长 10.09)
- mmd-pose.ts:FK + 两骨骼解析腿部 IK(膝盖朝正面,不可达收敛为伸直)
  + 左手系→右手系 Z 镜像(由真实数据对照实验确定:94.1% vs 5.9%)
- retarget.ts:抽出 worldFramesToKeypointFrames(),BVH 与 VMD 共用重定向
- MotionFormat 增 vmd、扩展名映射增 .vmd;管理端上传支持 .vmd
- scripts/inspect-vmd.mjs:上传前自查工具

契约:rest-api / data-models / model-formats → 0.2.0(另两库已同步逐字节一致)

验证:85 项单测通过(VMD 31 项);契约与 29 条路由一致;typecheck/build 通过;
真实样本经 HTTP 端到端:霜雪千年 2901 帧(IK 腿)、恋爱循环 4604 帧(FK 腿)
均 preprocessState=done、0 NaN、0 越界(测试数据已从数据库清理)。
2026-09-15 08:47:37 +08:00
zijunchiang 76725e5ce3 feat(log): 启动日志打印真实可访问地址,回环绑定时显式告警
绑定 0.0.0.0 时日志直接拼 http://0.0.0.0:3000——浏览器打不开,
排障时只会看到"已启动"却不知道访问哪个 IP。

- 新增 src/config/access-hint.ts:describeAccess() 把绑定地址翻译成
  监听描述 + 可访问地址(回环 / 具体网卡 / 通配取真实网卡 IPv4);
  独立成模块是为了可测——main.ts 顶层会执行 bootstrap(),不能被 import
- main.ts:启动日志改为「监听 …(全部网卡,外部可访问)」+ 真实访问地址;
  HOST=127.0.0.1 时用 warn 明确提示"只能本机访问"并给出修复方法
- 新增 test/access-log.spec.ts(4 项)覆盖三种绑定模式,
  断言通配模式绝不出现 0.0.0.0 形式的 URL

实测三种模式日志输出正确;单测 35/35、契约与 29 条路由校验通过。
2026-09-15 02:04:51 +08:00
zijunchiang 44451028dd fix(deploy): 安装脚本按真实 HOST 报告监听地址,并主动提示外部不可达
背景:旧版 .env.example 的 HOST=127.0.0.1 会让服务只监听回环,
本机 curl 一切正常、安装日志毫无异常,但外部永远连不上。

install.sh 修复:
- 不再写死 API_HOST=127.0.0.1:从 deploy/.env 读 HOST 得到 BIND_HOST,
  绑定具体网卡时用该地址做健康检查(原来用回环会误判启动失败)
- 汇总输出改为真实监听地址 + 按三种绑定模式给出正确 URL
  (回环写 127.0.0.1、具体网卡写该地址、通配写探测到的对外 IP)
- 新增安装期外部可达性自测:用对外 IP 请求 /health,
  只监听回环 → 打印改 HOST 与重启命令;
  监听正常但自测失败 → 打印防火墙 / 云安全组放行步骤
- 反代提示里回显当前 HOST 实际值,避免照抄"建议 127.0.0.1"改坏配置

README.txt 同步说明。验证:用真实脚本片段 + 打桩跑四种场景
(回环 / 通配可达 / 通配被防火墙拦 / 指定网卡),输出逐一核对。
2026-09-15 01:48:04 +08:00
zijunchiang 34c5040e6b feat(deploy): 新增外部访问诊断脚本 check-access.sh
"服务已启动但外部访问不了"时一次性定位原因,只读诊断、不改配置:
- 读出 .env 的 HOST/PORT,并对 127.0.0.1 这种只监听回环的配置直接报错
- 检查实际监听地址(ss/netstat),区分通配与仅回环
- 回环 + 对外 IP 两次 /health 自测(用 ip route get 取真实出口 IP)
- 检查 80/443 上有没有反向代理,并说明本项目不部署反代
- 检查 firewalld / ufw / iptables 对端口的放行情况,给出修复命令
- 结尾按"直连后端"与"经反代"两条路径给出结论

同时更新 README.txt、deploy/proxy/README.md(新增第八节:还没有反代时
如何直连后端)与打包校验清单。
2026-09-15 01:43:35 +08:00
zijunchiang 321ab1f584 docs(web): 清理 Docker/nginx 残留引用,反代职责边界写进任务书与契约
- tasks/010-deploy.md: 按"仅业务服务、不部署反代、服务自托管管理端"重写,
  补充四层进程降级、无 sudo 降权、上传上限两侧一致等实现说明与验收表
- deploy/uninstall.sh: 删除 nginx 站点清理逻辑(本项目从未安装过反代,
  也不应改动运维侧的 nginx / IIS 配置)
- scripts/package-release.sh: 修正产物结构注释,去掉已删除的
  web-server/admin/nginx.conf 拷贝与 deploy/nginx 备份清理
- web-server/.env.example: 修正"容器内路径 / docker-compose 挂载"过时注释
- contracts/rest-api.yaml: 服务地址描述改为业务服务部署语义
2026-09-15 01:27:47 +08:00
zijunchiang 8c76b0a599 feat(web): 移除 Docker 与反代部署,改为 Linux 业务服务并自带管理端托管
需求变更:Web 端只保留业务需求,反向代理交由另外的服务处理,
本项目仅提供 IIS / nginx 反代说明文档,不部署反代。

主要改动:
- web-server: 新增管理端静态托管(ADMIN_DIR、SPA 深链接回退),
  反代因此只需一条全匹配转发规则;新增 adminDir 配置项
- deploy/install.sh: 删除 nginx 安装与站点配置逻辑,改为原生 Linux
  服务部署(systemd → OpenRC → SysVinit → 后台进程四级降级),
  打印反代交接指引
- deploy/ctl.sh: 启停/状态/日志脚本
- deploy/proxy/: 新增反代说明文档(README.md、nginx.conf.example、
  web.config.example),标注上传上限与超时必须同步调整
- 删除 deploy/nginx/、web-server/admin/nginx.conf、Dockerfile、
  docker-compose.yml、.dockerignore
- scripts/package-release.sh: 锚定排除规则 + 产物完整性校验
- 文档同步:README.txt、DSH.md、project.yaml、tasks/008、tasks/010
2026-09-15 01:25:19 +08:00
zijunchiang 653d9d168c feat(deploy): 缺少 deploy/.env 时复用安装目录中的历史配置
用户在重新解压发布包后再次执行 install.sh,因缺少 deploy/.env 被拦下。
按文档流程每次升级都要重新解压,若每次都要求重填数据库口令,既繁琐
又容易出错(口令含 * 等特殊字符)。

- 优先使用当前目录的 deploy/.env
- 其次复用 $APP_DIR/deploy/.env(上次安装留下的配置)
- 两处都没有才报错,且错误信息列出需要填写的五个字段及注意事项

实测三种情形:
  全新安装(两处都无)→ 报错并给出字段说明,退出码 1
  安装目录有历史配置   → 自动复用,退出码 0
  当前目录已有 .env    → 直接使用,不覆盖
2026-09-15 01:03:20 +08:00
zijunchiang 87a1ded2d2 fix(deploy): install.sh 复制时同样误排除了 storage 目录
用户重装后仍报同一错误,且日志显示「复制 web-server … 174 个文件」。
经复现:发布包内 web-server 有 180 个文件(含 src/storage 2 个、
dist/storage 4 个),但 install.sh 复制后只剩 174 个——storage 被剔除了。

根因:上一轮只修了 package-release.sh,install.sh 里**还有一份独立的
copy_tree() 排除规则**,其中 'storage' 同样未锚定。install.sh 的
COPY_EXCLUDES 与 tar 回退分支都把 'storage' 当作任意层级模式,
于是 src/storage 与 dist/storage 在复制进 /opt/opendance 时被剔除。

这是同一类错误在本项目的第三次(.gitignore → package-release.sh →
install.sh)。因此这次不再只改模式,而是把语义显式化并加硬校验:

- 排除规则拆成三类常量,语义明确:
    TOP_DIRS  仅顶层:storage data tmp dist-release   → 必须锚定
    ANY_DIRS  任意层级:node_modules .git coverage .gradle
    TOP_FILES 仅顶层文件:.env .env.local
- rsync 用前导 '/' 锚定;tar 对 TOP_* 只给 './x'(绝不给 '*/storage'),
  对 ANY_* 才给 './x' + '*/x'
- 复制后硬校验关键产物(dist/main.js、dist/storage/storage.module.js、
  dist/app.module.js、admin/index.html),缺失即 die 并指出是排除规则问题

实测:
- rsync 路径:180 文件,src/storage 与 dist/storage 均在,
  运行时 storage 与 .env 未带入,.env.example 保留
- tar 回退路径(隔离 PATH 屏蔽 rsync 后真跑):同样 180 文件,存储目录完整
- git 检出布局:214 文件,嵌套 admin/node_modules 未带入,本地 .env 未带入
- 对照实验:把锚定改回未锚定 → 复制剩 174 文件 → 守卫立刻报
  「缺少关键文件 dist/storage/storage.module.js」并终止,不再拖到启动才炸
2026-09-15 01:00:58 +08:00
zijunchiang caca7672cb fix(release): 修复发布包缺失 storage 模块,并加入产物完整性校验
用户实测:用发布包部署后启动报 MODULE_NOT_FOUND
  at dist/models/models.module.js:13  require('../storage/storage.module')

根因:package-release.sh 的 rsync 用了 --exclude 'storage'。该模式匹配
**任意层级**的同名目录,把源码目录 src/storage/ 与编译产物 dist/storage/
一并排除——发布包里完全没有 storage 模块,服务必然起不来。
这与第一阶段 .gitignore 里 'storage/' 误伤 src/storage/ 是同一类错误,
本项目第二次踩到。

修复:
- 排除模式全部锚定到 web-server 根:'/storage/'、'/data/'、'/tmp/'、
  '/.env'、'/admin/',不再匹配嵌套同名目录
- 新增产物完整性校验(打包后自动执行,失败即删除压缩包):
  src 下每个 .ts 必须有对应 dist/*.js;关键文件清单齐全;
  不得混入 node_modules / 真实 .env / 运行时存储 / keystore
- ctl.sh 补上缺失的 warn 函数(line 120: warn: command not found)

实测:
- 对照实验:排除模式改回 'storage' 后校验准确报出缺少
  dist/storage/storage.module.js 并删除压缩包,退出码 1
- 新包端到端:解压 → npm ci --omit=dev(213 包)→ 启动 → /health 200、
  MariaDB 已连接、迁移 0、catalog/songs/models/motions 全部 200
2026-09-15 00:54:22 +08:00
zijunchiang f6fe9af0a2 fix(deploy): 修复 NODE_ENV 污染导致管理端构建失败(tsc: not found)
用户实测:部署时构建管理端报 `sh: 1: tsc: not found`,且 npm 只装了 23 个包。

根因:脚本开头用 `set -a; . deploy/.env; set +a` 载入 .env,其中的
NODE_ENV=production 被导出到脚本自身环境;npm 见到它便会跳过
devDependencies,于是 typescript / vite / nest 等构建工具全部缺失。
本地实测完全复现:
  NODE_ENV=production npm ci          → added 23 package,无 tsc
  env -u NODE_ENV npm ci --include=dev → added 53 package,tsc/vite 齐备

修复:
- 新增 npm_install_with_dev():统一用 `env -u NODE_ENV npm ... --include=dev`
- 管理端构建改用它;**API 构建回退路径存在同一隐患,一并修复**
- 管理端改为在 $APP_DIR/web-server/admin 内构建(不再污染源码树、
  也不要求源码目录可写),构建完删除该处 node_modules 释放磁盘
- 构建失败时给出可操作提示(改用发布包)

另修复一处静默的信息泄露:
- 复制源码树时未排除 .env,本地开发用的 web-server/.env(含真实数据库
  口令)会被复制进 /opt/opendance/web-server/.env;deploy/.env 才是权威配置,
  多一个 dotenv 配置源只会造成排障困惑。已把 .env / .env.local 加入排除,
  并验证 .env.example 不受影响(209 → 208 个文件)

实测:
  旧行为 tsc 缺失、构建报 tsc: not found(与用户报错一致)
  新行为 tsc/vite 齐备,vite build 成功产出 dist/index.html + 3 个 assets
  复制排除:.env 不复制、.env.example 保留、node_modules 无泄漏
2026-09-15 00:44:54 +08:00
zijunchiang c092b833fb fix(deploy): 支持无 systemd 的环境,并修复降级路径的两个真实缺陷
用户实测:服务器没有 systemctl,install.sh 直接报错退出。

原错误信息本身也是错的(建议 SKIP_NGINX=1,但那只跳过 nginx,仍走 systemctl)。

进程管理改为四层降级:
  systemd → OpenRC → SysVinit → nohup 后台进程(deploy/ctl.sh)
- 新增 deploy/ctl.sh:start/stop/restart/status/logs,PID 文件 + 进程名双重判定
- 新增 deploy/openrc/opendance-api、deploy/initd/opendance-api
- 无 init 系统时写 crontab @reboot 实现开机自启
- uninstall.sh 同步支持四种模式清理(原先只处理 systemd,会留下运行中的进程)
- 汇总输出按实际初始化系统给出对应运维命令

测试中发现并修复降级路径的两个真实缺陷:

1. nohup 无法调用 shell 函数
    实测退出码 127 且什么都不执行
   (nohup 是外部命令,只能执行可执行文件)。
   这会让无 systemd 的降级路径完全失效——服务根本起不来。
   改为按 runuser / setpriv / su 显式拼接成外部命令后再 nohup。
   另加启动后 1 秒存活检测,失败立即打印日志,不再白等 30 秒。

2. find_pids 在无进程时返回一个空格
    在空输入下仍会执行一次 echo,输出空行,
   最终得到 " ";调用方 [ -n ... ] 会判为「有进程」,
   导致 start 直接跳过、status 谎报运行中。改为纯 bash 拼接,空时返回空串。

实测(真实 API 进程,端口 3100):
  start → 健康检查 200,PID 正确
  重复 start → 识别已在运行,进程数保持 1
  status → 运行中
  restart → 新 PID,健康检查 200
  stop → 无残留进程,端口释放
  无 process 时 find_pids 返回空串,is_running=false
2026-09-15 00:34:52 +08:00
zijunchiang 1ffe3059af fix(deploy): install.sh 不再依赖 sudo
用户实测报错:deploy/install.sh: line 173: sudo: command not found

本脚本已经强制要求 root 权限,用 sudo 降权到服务用户本就多余,
且部分服务器(尤其精简系统/容器)根本没有安装 sudo。

- 新增 as_service_user():runuser → setpriv → su 依次回退,
  并在三者都不可用时给出明确错误
- 替换全部 sudo -u 调用(npm ci / npm install / npm run build / 迁移)
- 新增 http_get() / http_ok():curl → wget → node fetch 依次回退,
  健康检查不再强依赖 curl
- 启动前检查 systemctl(本部署依赖 systemd),缺失时给出可操作提示
- 修正 install.sh / uninstall.sh 中所有「用 sudo 执行」的提示文案

实测(桩替换 runuser 验证分派与参数传递):
  有 runuser:runuser -u opendance -- npm ci --omit=dev --loglevel=error
  仅 su:正确回退到 su
  HTTP:curl 路径与 node fetch 回退路径返回一致
2026-09-15 00:27:27 +08:00
zijunchiang 7cd607ecde fix(deploy): 修复 install.sh 同步文件时的长时间卡死
用户实测:执行到「同步程序文件到 /opt/opendance」后卡死。

根因:在源码检出目录执行时,web-server/ 下有 node_modules(236MB /
27359 个文件,admin 下还有 122MB),原实现用 cp -r 全量复制,
随后又对整个目录 chown -R,处理三万个文件表现为卡死。

修复:
- 复制改用 copy_tree(),排除 node_modules/.git/storage/dist-release 等
  运行期可再生目录;有 rsync 用 rsync -a --delete,否则用 tar 管道回退
- tar 回退的排除模式同时给出 './x' 与 '*/x':只写 './x' 无法匹配嵌套的
  './admin/node_modules'(实测会漏掉 122MB,等于没修)
- 增加目录安全检查:安装目录与源目录相同、或二者互相包含时直接报错退出,
  避免 rm -rf 删掉源文件 / cp 无限递归
- 支持两种目录布局:发布包 admin/ 在根目录,源码检出在
  web-server/admin/dist(缺失则现场构建),此前从源码目录执行必然在
  「缺少 admin/ 目录」处失败
- 每个目录打印复制结果与文件数,npm 安装前给出预计耗时提示,
  不再有长时间无输出的「假卡死」
- 失败时 trap 打印行号

实测(用脚本中真实代码块驱动的用例):
  源码检出目录 → /tmp:rsync 路径 1 秒 / 209 文件;tar 回退路径同样 209 文件
  发布包解压目录 → /tmp:0 秒,admin 与 dist/main.js 均正确
  同目录 / 嵌套目录两种危险布局:均被拒绝并给出可操作提示
  两条路径均无 node_modules 泄漏
2026-09-15 00:14:06 +08:00
zijunchiang 32525dd59a docs(web): 清理残留的 Docker 引用(admin README / project.yaml / 注释) 2026-09-14 22:47:47 +08:00
zijunchiang 07ee30d704 docs(web): 开发环境样例补充 HOST 说明 2026-09-14 22:45:31 +08:00
zijunchiang 75498db1c0 feat(web): 部署方式由 Docker 改为 Linux 原生站点(nginx + systemd)
需求变更:Web 服务端不再使用 Docker,改为直接运行在 Linux 上的网站。

- 移除 deploy/docker-compose.yml、web-server/Dockerfile、
  web-server/admin/Dockerfile、web-server/.dockerignore
- 新增 deploy/install.sh:建服务用户与目录 → 装运行期依赖 → 迁移
  → 注册 systemd 服务 → 配置 nginx 站点 → 健康检查,一键完成
- 新增 deploy/systemd/opendance-api.service:ProtectSystem=strict 等
  安全加固,ReadWritePaths 限定为上传目录
- 新增 deploy/nginx/opendance.conf:同站点托管管理端静态资源 +
  反代 /api /health /static,client_max_body_size 512m,SPA 回退
- 新增 deploy/uninstall.sh:默认保留数据,--purge 才删除
- 新增配置项 HOST(AppConfig.bindHost):原生部署下 Node 只监听
  127.0.0.1,不直接对外暴露,实测外部 IP 不可达
- 同步更新 contracts/rest-api.yaml、DSH.md、project.yaml、README.txt、
  tasks/008、tasks/010 与发布打包脚本
2026-09-14 22:42:56 +08:00
zijunchiang 0c133986b4 test(web): 新增演示数据播种脚本(供管理端与客户端联调) 2026-09-14 20:08:20 +08:00
zijunchiang dbd9155b40 fix(release): 打包清单读取避免 SIGPIPE 误报 2026-09-14 19:01:58 +08:00
zijunchiang b9215d4190 docs(web): 任务说明、DSH 约束与交付文档 2026-09-14 19:00:55 +08:00
zijunchiang 5a8b77b0a4 feat(web): T-WEB-010 部署脚本(Docker Compose,不含 MySQL) 2026-09-14 19:00:55 +08:00
zijunchiang 640736c77b feat(web): T-WEB-008/009 管理端界面(歌曲/模型/动作/审核 + VRM 预览) 2026-09-14 19:00:55 +08:00
zijunchiang 0f66b53480 feat(web): 客户端目录导出 /api/catalog/export 与版本指纹 2026-09-14 19:00:55 +08:00
zijunchiang 7ff3f1598d feat(web): T-WEB-007 上传审核流程(状态机 + 审核流水) 2026-09-14 19:00:55 +08:00
zijunchiang a2e3282603 feat(web): T-WEB-006 BVH/FBX → MediaPipe33 关键点预处理 2026-09-14 19:00:55 +08:00
zijunchiang 73e793318e feat(web): T-WEB-005 模型上传 + VRM 优先 + 版本识别 2026-09-14 19:00:55 +08:00
zijunchiang ff8c4dd26a feat(web): T-WEB-004 动作数据上传 + 来源五元组校验 2026-09-14 19:00:55 +08:00
zijunchiang d9614aadb0 feat(web): T-WEB-003 /api/songs CRUD + 音频上传 2026-09-14 19:00:55 +08:00
zijunchiang 6714b1e67c feat(web): T-WEB-002 TypeORM 迁移生成表结构(5 表,可逆) 2026-09-14 19:00:55 +08:00
zijunchiang bd6cb9fa90 feat(web): T-WEB-001 NestJS 骨架 + 连接现有 MySQL 实例 2026-09-14 19:00:55 +08:00
zijunchiang 9b8fc6be96 chore(contracts): 冻结 v0.1.0 跨端契约(REST/WebSocket/数据模型/拓扑/格式/数据库) 2026-09-14 19:00:55 +08:00