# contracts/data-models.md - 数据模型契约 v0.5.0 Web 服务端是唯一权威定义,本文件是另两端消费的**稳定视图**。 字段名在 JSON 中统一 `camelCase`;数据库列名 `snake_case`(见 `database.md`)。 ## 1. 通用约定 - 所有资源返回 `id: number`(BIGINT,超出 2^53 前不会发生)。 - 所有时间为 ISO 8601 UTC 字符串,例:`2026-09-14T10:00:00.000Z`。 - 列表接口统一分页响应: ```json { "items": [], "total": 0, "page": 1, "pageSize": 20 } ``` - 错误响应统一: ```json { "statusCode": 400, "message": "...", "error": "Bad Request", "details": {} } ``` - **来源(出处)五元组**是必填项,出现在 `songs` / `motions` / `models` 三处: | 字段 | 类型 | 说明 | |--|--|--| | `sourceType` | enum | 见 1.1 | | `sourceName` | string | 来源名称,如「官方曲库」「作者名」 | | `sourceUrl` | string | 来源链接,必须为 http(s) | | `license` | string | 许可标识,如 `CC-BY-4.0`、`MIT`、`proprietary`、`unknown` | | `author` | string | 作者 / 权利人 | ### 1.1 `SourceType` 枚举 ``` original 原创 licensed 已获授权 cc Creative Commons public_domain 公共领域 user_upload 用户上传(需审核) other 其他(须在 sourceName 说明) ``` ### 1.2 `PublishStatus` 枚举 ``` draft 草稿,仅管理端可见 pending 待审核 published 已发布,客户端可见 rejected 已驳回 ``` ## 2. `Song` | 字段 | 类型 | 必填 | 说明 | |--|--|--|--| | `id` | number | 读 | | | `title` | string | ✅ | 曲名,<=200 | | `artist` | string | ✅ | 演唱者,<=200 | | `album` | string | | 专辑,<=200 | | `language` | string | | `zh` / `en` / `ja` / `ko` / `instrumental` | | `durationMs` | number | | 时长毫秒 | | `bpm` | number | | 节拍 | | `difficulty` | number | | 1..5 | | `tags` | string[] | | | | `coverUrl` | string\|null | | 封面访问地址 | | `audioUrl` | string\|null | | 音频访问地址 | | `source*` | | ✅ | 来源五元组 | | `status` | `PublishStatus` | | 默认 `draft` | | `createdAt` / `updatedAt` | string | 读 | | ## 3. `Motion`(动作数据) | 字段 | 类型 | 必填 | 说明 | |--|--|--|--| | `id` | number | 读 | | | `songId` | number\|null | | 关联曲目 | | `name` | string | ✅ | 动作名称,<=200 | | `format` | enum | ✅ | `bvh` / `vmd` / `fbx` / `gltf` / `glb` / `keypoints-json` | | `fileUrl` | string | 读 | 原始动作文件 | | `fileSize` | number | 读 | 字节 | | `fps` | number\|null | | 原始帧率 | | `frameCount` | number\|null | | 原始帧数 | | `durationMs` | number\|null | | | | `keypointFormat` | string\|null | 读 | 预处理后固定为 `mediapipe33@0.1.0` | | `keypointUrl` | string\|null | 读 | 预处理产物(NDJSON 或.npz 的 JSON 封装) | | `thumbnailUrl` | string\|null | 读 | 骨架缩略图(`/preprocess` 时生成,动作管理列表展示;未生成时为 null)(v0.7.0) | | `keypointFrames` | number\|null | 读 | 关键点帧数 | | `preprocessState` | enum | 读 | `none` / `running` / `done` / `failed` | | `preprocessError` | string\|null | 读 | | | `source*` | | ✅ | 来源五元组 | | `status` | `PublishStatus` | | | | `createdAt` / `updatedAt` | string | 读 | | ## 4. `Model`(角色模型,VRM 优先) | 字段 | 类型 | 必填 | 说明 | |--|--|--|--| | `id` | number | 读 | | | `name` | string | ✅ | 模型名称,<=200 | | `format` | enum | ✅ | `vrm`(主力)/ `glb`(回退或历史数据)/ `gltf` / `fbx`。**所有上传模型一律转换为 VRM**,只有 humanoid 不达标时回退为 `glb` 并置 `usable=false`,见 model-formats.md 1.1 | | `vrmVersion` | string\|null | 读 | `0.x` / `1.0`,非 VRM 为 null | | `usable` | boolean | 读 | **是否可被客户端使用**:等价于"已产出 VRM"(`format=vrm`)。`false` 的模型**不出现在 `catalog/export`**(v0.5.0) | | `unusableReason` | string\|null | 读 | `usable=false` 时的原因,例如 `humanoid 不达标(0/22),未产出 VRM` | | `isPrimary` | boolean | | 全局默认模型;**同一时刻全库至多一个 `true`** | | `fileUrl` | string | 读 | | | `fileSize` | number | 读 | | | `thumbnailUrl` | string\|null | 读 | | | `conversionNote` | string\|null | 读 | 由压缩包转换而来时的说明(`Hatsune Miku.pmx -> GLB,已内嵌 2 张贴图`);直传模型为 null | | `fileSha256` | string\|null | 读 | 模型文件(转换产物)的 sha256 -- **每次转换都会变**,仅作记录 | | `sourceSha256` | string\|null | 读 | 上传压缩包的 sha256(稳定);默认模型匹配以它为准,非压缩包上传为 null | | `stats` | object\|null | 读 | 模型分析结果:面数 / 顶点数 / 贴图 / **骨骼树** / humanoid 覆盖 / 可驱动性,见 model-formats.md 1.1 | | `source*` | | ✅ | 来源五元组 | | `status` | `PublishStatus` | | | | `createdAt` / `updatedAt` | string | 读 | | ### 4.1 默认模型选择规则(冻结) 服务端 `GET /api/catalog/export` 的 `defaultModelId` 按以下顺序解析: ``` 0. 环境变量 DEFAULT_MODEL_ID 指定,且该模型存在且已发布 1. 源码内写死的默认模型种子(web-server/src/config/default-model.ts 的 DEFAULT_MODEL_SEED):按 **sourceSha256(上传压缩包的 sha256)** 匹配已发布模型, 换库/重建库后仍然有效;没有压缩包时退回按产物 sha256,最后按名称兜底。 ⚠ 不能用产物 sha256 当主键:实测 Assimp 的 GLB 导出不是字节级确定的, 同一份 zip 每次转换得到的文件 sha256 都不同(BIN chunk 有十几字节浮动) 2. models 中 format = 'vrm' 且 status = 'published' 且 is_primary = 1 3. models 中 format = 'vrm' 且 status = 'published',按 id 升序取第一个 4. models 中 status = 'published',按 (format='vrm' DESC, id ASC) 取第一个 5. 无可用模型 -> 客户端使用内置占位模型 ``` 第 0/1 步是 v0.3.0 新增:上传 MMD 模型压缩包并转换成功后,服务端会把该模型 `is_primary=1` + `status=published`,并把身份写进源码,使它成为开箱默认模型。 `isPrimary` 通过 `POST /api/models/:id/primary` 设置,服务端保证互斥(同一事务内先清零)。 ## 5. `ReviewRecord`(审核流水) | 字段 | 类型 | 说明 | |--|--|--| | `id` | number | | | `targetType` | enum | `song` / `motion` / `model` | | `targetId` | number | | | `action` | enum | `submit` / `approve` / `reject` / `withdraw` | | `fromStatus` / `toStatus` | `PublishStatus` | | | `reviewer` | string\|null | 审核人标识 | | `comment` | string\|null | 审核意见 | | `createdAt` | string | | ## 6. `CatalogBundle`(客户端一次性拉取,`GET /api/catalog/export`) ```json { "contractVersion": { "rest-api": "0.1.0", "data-models": "0.1.0", "skeleton-topology": "0.1.0", "websocket-keypoints": "0.1.0" }, "generatedAt": "2026-09-14T10:00:00.000Z", "songs": [], "motions": [], "models": [], "defaultModelId": 3 } ``` - 仅包含 `status = 'published'` 的记录。 - `defaultModelId` 由 4.1 的规则解析得出,客户端无需自行实现优先级。 --- ## 7. `Scene`(场景,GLB 为主)(v0.7.0) 场景与模型的区别:**以 GLB 为主**(不做 humanoid 重绑定、不转 VRM), 并携带**物理参数**(服务端只存参数、不跑物理;仿真在客户端)。 | 字段 | 类型 | 必填 | 说明 | |--|--|--|--| | `id` | number | 读 | | | `name` | string | ✅ | 场景名称,<=200 | | `glbUrl` | string\|null | 读 | 场景 GLB 下载地址 | | `thumbnailUrl` | string\|null | 读 | 缩略图(服务端离线渲染) | | `fileSize` | number\|null | 读 | | | `fileSha256` | string\|null | 读 | | | `conversionNote` | string\|null | 读 | 由 `.zip`/`.pmx`/`.fbx` 转换而来时的说明 | | `config` | `SceneConfig` | ✅ | 物理 / 环境 / 出生点 / 相机,见 7.1 | | `stats` | object\|null | 读 | 场景 GLB 分析(面数 / 网格 / 材质 / warnings) | | `sourceType` `sourceName` `sourceUrl` `license` `author` | string | ✅ | 来源五元组 | | `status` | enum | 读 | 上传一律 `pending`,经审核后 `published` | ### 7.1 `SceneConfig`(冻结) ```yaml physics: gravity: { x: 0, y: -9.8, z: 0 } # 米/秒^2,各分量 ±200 wind: base: { x: 0, y: 0, z: 0 } # 基础风向量(米/秒,±100) gustAmplitude: 0 # 阵风幅度 0~100 gustFrequency: 0.2 # 阵风频率 Hz,0.01~5 gustDirection: { x: 1, y: 0, z: 0 } # 阵风方向(±1) space: world | local # 风的作用坐标系 ground: { friction: 0.6, restitution: 0, slopeLimit: 45 } # 0~1 / 0~1 / 0~89 度 colliders: auto | mesh | none # auto=按 GLB 命名约定,mesh=三角网 environment: ambientColor: "#8fb4ff" # #RRGGBB ambientIntensity: 1 # 0~4 fogDensity: 0 # 0~1(0=关闭) spawnPoints: [{ x: 0, y: 0, z: 0, yaw: 0 }] # 最多 16 个 camera: { mode: follow | fixed | orbit, distance: 4.5, height: 1.6 } ``` 服务端入库时对每项做**边界收敛**(越界夹紧、非法枚举回退默认值), 保证客户端拿到的参数一定可解释;`catalog/export` 的 `scenes[]` 直接下发该结构。