Files
opendance-web/contracts/rest-api.yaml
T
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

855 lines
29 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
openapi: 3.0.3
info:
title: OpenDance Studio Web 管理服务端 API
version: 0.3.0
description: |
OpenDance Studio 的唯一数据源与契约权威。
契约版本见 contracts/VERSION。字段语义见 contracts/data-models.md。
- 所有 JSON 字段 camelCase
- 列表接口统一 { items, total, page, pageSize }
- 来源五元组(sourceType/sourceName/sourceUrl/license/author)在三类资产上均为必填
servers:
- url: http://localhost:3000
description: 本地开发(软件 API 端口)
- url: http://<host>:3000
description: >-
Linux 业务服务部署的**软件 API 端口**(`PORT`,默认 3000)。Windows/Godot 客户端与
Android App 调用本端口的 /api、/health、/static、/contracts。
管理端页面在**管理端口**(`ADMIN_PORT`,默认 3001),该端口同时提供全部接口;
API 端口不提供管理页面,未命中的路径一律 404。
反向代理由另外的服务提供(非本项目交付物),按用途分别转发到对应端口即可。
tags:
- name: health
- name: songs
- name: motions
- name: models
- name: reviews
- name: catalog
- name: assets
paths:
/health:
get:
tags: [health]
summary: 健康检查(含数据库连通性)
responses:
'200':
description: 服务与数据库均正常
content:
application/json:
schema:
$ref: '#/components/schemas/Health'
'503':
description: 数据库不可用
content:
application/json:
schema:
$ref: '#/components/schemas/Health'
/api/songs:
get:
tags: [songs]
summary: 曲目列表
parameters:
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/PageSize'
- name: q
in: query
schema: { type: string }
description: 按 title/artist/album 模糊匹配
- name: status
in: query
schema: { $ref: '#/components/schemas/PublishStatus' }
- name: artist
in: query
schema: { type: string }
responses:
'200':
description: 分页曲目
content:
application/json:
schema:
$ref: '#/components/schemas/SongPage'
post:
tags: [songs]
summary: 新建曲目
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SongCreate'
responses:
'201':
description: 已创建
content:
application/json:
schema: { $ref: '#/components/schemas/Song' }
'400': { $ref: '#/components/responses/BadRequest' }
/api/songs/{id}:
parameters: [{ $ref: '#/components/parameters/Id' }]
get:
tags: [songs]
summary: 曲目详情
responses:
'200':
description: 曲目
content:
application/json:
schema: { $ref: '#/components/schemas/Song' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
tags: [songs]
summary: 更新曲目
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/SongUpdate' }
responses:
'200':
description: 更新后的曲目
content:
application/json:
schema: { $ref: '#/components/schemas/Song' }
'400': { $ref: '#/components/responses/BadRequest' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
tags: [songs]
summary: 删除曲目(软删除)
responses:
'204': { description: 已删除 }
'404': { $ref: '#/components/responses/NotFound' }
/api/songs/{id}/audio:
parameters: [{ $ref: '#/components/parameters/Id' }]
post:
tags: [songs]
summary: 上传曲目音频
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary }
responses:
'201':
description: 上传后的曲目
content:
application/json:
schema: { $ref: '#/components/schemas/Song' }
'400': { $ref: '#/components/responses/BadRequest' }
/api/songs/{id}/cover:
parameters: [{ $ref: '#/components/parameters/Id' }]
post:
tags: [songs]
summary: 上传曲目封面
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary }
responses:
'201':
description: 上传后的曲目
content:
application/json:
schema: { $ref: '#/components/schemas/Song' }
'400': { $ref: '#/components/responses/BadRequest' }
/api/motions:
get:
tags: [motions]
summary: 动作列表
parameters:
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/PageSize'
- name: q
in: query
schema: { type: string }
- name: songId
in: query
schema: { type: integer, format: int64 }
- name: format
in: query
schema: { type: string, enum: [bvh, vmd, fbx, gltf, glb, keypoints-json] }
- name: status
in: query
schema: { $ref: '#/components/schemas/PublishStatus' }
responses:
'200':
description: 分页动作
content:
application/json:
schema: { $ref: '#/components/schemas/MotionPage' }
post:
tags: [motions]
summary: 上传动作文件 + 来源信息
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file, name, sourceType, sourceName, sourceUrl, license, author]
properties:
file: { type: string, format: binary }
name: { type: string }
songId: { type: integer, format: int64 }
fps: { type: number }
sourceType: { $ref: '#/components/schemas/SourceType' }
sourceName: { type: string }
sourceUrl: { type: string }
license: { type: string }
author: { type: string }
status: { $ref: '#/components/schemas/PublishStatus' }
autoPreprocess: { type: boolean, default: false }
responses:
'201':
description: 已创建
content:
application/json:
schema: { $ref: '#/components/schemas/Motion' }
'400': { $ref: '#/components/responses/BadRequest' }
'422':
description: 文件格式不支持或无法解析
/api/motions/{id}:
parameters: [{ $ref: '#/components/parameters/Id' }]
get:
tags: [motions]
summary: 动作详情
responses:
'200':
description: 动作
content:
application/json:
schema: { $ref: '#/components/schemas/Motion' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
tags: [motions]
summary: 更新动作元数据
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/MotionUpdate' }
responses:
'200':
description: 更新后的动作
content:
application/json:
schema: { $ref: '#/components/schemas/Motion' }
'400': { $ref: '#/components/responses/BadRequest' }
delete:
tags: [motions]
summary: 删除动作(软删除)
responses:
'204': { description: 已删除 }
/api/motions/{id}/preprocess:
parameters: [{ $ref: '#/components/parameters/Id' }]
post:
tags: [motions]
summary: 触发 BVH/FBX → MediaPipe33 关键点序列预处理(同步执行)
description: |
同步执行并返回最终状态:成功时 preprocessState=done;
失败时返回 422,且 Motion.preprocessError 记录可读原因。
responses:
'200':
description: 预处理完成
content:
application/json:
schema: { $ref: '#/components/schemas/Motion' }
'404': { $ref: '#/components/responses/NotFound' }
'422':
description: 预处理失败,reason 见 preprocessError
/api/motions/{id}/keypoints:
parameters: [{ $ref: '#/components/parameters/Id' }]
get:
tags: [motions]
summary: 下载关键点序列(NDJSON,每行一帧)
responses:
'200':
description: |
application/x-ndjson,每行一个帧对象:
{"frame":0,"timeMs":0,"points":[[x,y,z,conf], …33],"angles":[9 个余弦]}
content:
application/x-ndjson:
schema: { type: string }
'404': { $ref: '#/components/responses/NotFound' }
'409':
description: 尚未完成预处理
/api/models:
get:
tags: [models]
summary: 模型列表
parameters:
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/PageSize'
- name: q
in: query
schema: { type: string }
- name: format
in: query
schema: { type: string, enum: [vrm, glb, gltf, fbx] }
- name: status
in: query
schema: { $ref: '#/components/schemas/PublishStatus' }
responses:
'200':
description: 分页模型
content:
application/json:
schema: { $ref: '#/components/schemas/ModelPage' }
post:
tags: [models]
summary: 上传模型 + 来源信息(VRM 优先;支持 MMD 模型压缩包自动转换)
description: |
`file` 为单个文件:
- `.vrm` / `.glb` / `.gltf` / `.fbx`:直接入库,`format` 由扩展名决定
- `.zip`:视为 MMD 模型包。服务端解压 → 选取 `.pmx`
(优先 `MODEL_CONVERT_PMX` 指定的名字,默认 `Hatsune Miku.pmx`;
否则取目录最浅、文件最大者)→ 调 Assimp 导出 **FBX**,
导出后**回读校验**(Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写出损坏文件),
失败自动回退 **GLB**,并把过程写入 `conversionNote`。
GLB 产物会把 Assimp 写成外部 URI 的贴图内嵌进二进制块,保证单文件可用。
压缩包原件作为 `model-archive` 资产留存以便溯源。
转换成功且 `autoDefault` 不为 false 时,该模型会被置为全局默认模型
(`isPrimary=1` + `status=published`),并把身份(名称/格式/**压缩包 sha256**)
写进源码 `web-server/src/config/default-model.ts`。
注意:**转换产物不是字节级确定的**(同一份 zip 每次转换的产物 sha256 都不同),
所以默认模型种子以压缩包 sha256 为匹配键,产物 sha256 只作记录。
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file, name, sourceType, sourceName, sourceUrl, license, author]
properties:
file: { type: string, format: binary }
name: { type: string }
isPrimary: { type: boolean }
autoDefault:
type: boolean
description: 仅对 `.zip` 生效;转换成功后是否自动设为默认模型,默认 true
sourceType: { $ref: '#/components/schemas/SourceType' }
sourceName: { type: string }
sourceUrl: { type: string }
license: { type: string }
author: { type: string }
status: { $ref: '#/components/schemas/PublishStatus' }
responses:
'201':
description: 已创建
content:
application/json:
schema: { $ref: '#/components/schemas/Model' }
'400': { $ref: '#/components/responses/BadRequest' }
'422':
description: 不支持的模型格式
/api/models/{id}:
parameters: [{ $ref: '#/components/parameters/Id' }]
get:
tags: [models]
summary: 模型详情
responses:
'200':
description: 模型
content:
application/json:
schema: { $ref: '#/components/schemas/Model' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
tags: [models]
summary: 更新模型元数据
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ModelUpdate' }
responses:
'200':
description: 更新后的模型
content:
application/json:
schema: { $ref: '#/components/schemas/Model' }
'400': { $ref: '#/components/responses/BadRequest' }
delete:
tags: [models]
summary: 删除模型(软删除)
responses:
'204': { description: 已删除 }
/api/models/{id}/primary:
parameters: [{ $ref: '#/components/parameters/Id' }]
post:
tags: [models]
summary: 设为全局默认模型(互斥,同事务清零其余)
responses:
'200':
description: 设置后的模型
content:
application/json:
schema: { $ref: '#/components/schemas/Model' }
'404': { $ref: '#/components/responses/NotFound' }
/api/reviews:
get:
tags: [reviews]
summary: 审核流水 / 待审列表
parameters:
- name: targetType
in: query
schema: { type: string, enum: [song, motion, model] }
- name: targetId
in: query
schema: { type: integer, format: int64 }
- name: status
in: query
schema: { $ref: '#/components/schemas/PublishStatus' }
description: 传入时改为返回该状态的待审资产列表(合并三类)
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/PageSize'
responses:
'200':
description: 审核流水或待审资产
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/ReviewPage'
- $ref: '#/components/schemas/PendingPage'
/api/reviews/{targetType}/{id}/submit:
parameters:
- name: targetType
in: path
required: true
schema: { type: string, enum: [song, motion, model] }
- $ref: '#/components/parameters/Id'
post:
tags: [reviews]
summary: 提交审核
requestBody:
content:
application/json:
schema:
type: object
properties:
reviewer: { type: string }
comment: { type: string }
responses:
'200': { description: 状态已流转 }
'409': { description: 当前状态不允许该操作 }
/api/reviews/{targetType}/{id}/approve:
parameters:
- name: targetType
in: path
required: true
schema: { type: string, enum: [song, motion, model] }
- $ref: '#/components/parameters/Id'
post:
tags: [reviews]
summary: 审核通过 → published
requestBody:
content:
application/json:
schema:
type: object
properties:
reviewer: { type: string }
comment: { type: string }
responses:
'200': { description: 状态已流转 }
'409': { description: 当前状态不允许该操作 }
/api/reviews/{targetType}/{id}/withdraw:
parameters:
- name: targetType
in: path
required: true
schema: { type: string, enum: [song, motion, model] }
- $ref: '#/components/parameters/Id'
post:
tags: [reviews]
summary: 撤回审核 → draft
requestBody:
content:
application/json:
schema:
type: object
properties:
reviewer: { type: string }
comment: { type: string }
responses:
'200': { description: 状态已流转 }
'409': { description: 当前状态不允许该操作 }
/api/reviews/{targetType}/{id}/history:
parameters:
- name: targetType
in: path
required: true
schema: { type: string, enum: [song, motion, model] }
- $ref: '#/components/parameters/Id'
get:
tags: [reviews]
summary: 单个资产的审核历史
responses:
'200':
description: 审核流水(按时间倒序)
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/ReviewRecord' }
'404': { $ref: '#/components/responses/NotFound' }
/api/reviews/{targetType}/{id}/reject:
parameters:
- name: targetType
in: path
required: true
schema: { type: string, enum: [song, motion, model] }
- $ref: '#/components/parameters/Id'
post:
tags: [reviews]
summary: 审核驳回 → rejected
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [comment]
properties:
reviewer: { type: string }
comment: { type: string }
responses:
'200': { description: 状态已流转 }
'400': { description: 驳回必须填写理由 }
'409': { description: 当前状态不允许该操作 }
/api/catalog/export:
get:
tags: [catalog]
summary: 客户端一次性拉取已发布目录
responses:
'200':
description: CatalogBundle
content:
application/json:
schema: { $ref: '#/components/schemas/CatalogBundle' }
/api/catalog/version:
get:
tags: [catalog]
summary: 契约版本与目录指纹(客户端增量同步用)
responses:
'200':
description: 版本信息
content:
application/json:
schema:
type: object
properties:
contractVersion: { type: object, additionalProperties: { type: string } }
revision: { type: string, description: 目录内容 sha256,变化即需重新拉取 }
components:
parameters:
Id:
name: id
in: path
required: true
schema: { type: integer, format: int64 }
Page:
name: page
in: query
schema: { type: integer, minimum: 1, default: 1 }
PageSize:
name: pageSize
in: query
schema: { type: integer, minimum: 1, maximum: 200, default: 20 }
responses:
BadRequest:
description: 参数校验失败(含来源五元组缺失)
content:
application/json:
schema: { $ref: '#/components/schemas/ApiError' }
NotFound:
description: 资源不存在
content:
application/json:
schema: { $ref: '#/components/schemas/ApiError' }
schemas:
SourceType:
type: string
enum: [original, licensed, cc, public_domain, user_upload, other]
PublishStatus:
type: string
enum: [draft, pending, published, rejected]
MotionFormat:
type: string
enum: [bvh, vmd, fbx, gltf, glb, keypoints-json]
ModelFormat:
type: string
enum: [vrm, glb, gltf, fbx]
ApiError:
type: object
properties:
statusCode: { type: integer }
message: { oneOf: [{ type: string }, { type: array, items: { type: string } }] }
error: { type: string }
SourceFields:
type: object
required: [sourceType, sourceName, sourceUrl, license, author]
properties:
sourceType: { $ref: '#/components/schemas/SourceType' }
sourceName: { type: string, maxLength: 200 }
sourceUrl: { type: string, maxLength: 500, description: 必须为 http(s) URL }
license: { type: string, maxLength: 100 }
author: { type: string, maxLength: 200 }
Health:
type: object
properties:
status: { type: string, enum: [ok, degraded] }
uptimeSec: { type: number }
version: { type: string }
contractVersion: { type: object, additionalProperties: { type: string } }
database:
type: object
properties:
connected: { type: boolean }
server: { type: string, description: '如 MariaDB 10.11.19' }
database: { type: string }
migrationsPending: { type: integer }
Song:
allOf:
- $ref: '#/components/schemas/SourceFields'
- type: object
properties:
id: { type: integer, format: int64 }
title: { type: string }
artist: { type: string }
album: { type: string, nullable: true }
language: { type: string, nullable: true }
durationMs: { type: integer, nullable: true }
bpm: { type: number, nullable: true }
difficulty: { type: integer, nullable: true, minimum: 1, maximum: 5 }
tags: { type: array, items: { type: string } }
coverUrl: { type: string, nullable: true }
audioUrl: { type: string, nullable: true }
status: { $ref: '#/components/schemas/PublishStatus' }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
SongCreate:
allOf:
- $ref: '#/components/schemas/SourceFields'
- type: object
required: [title, artist]
properties:
title: { type: string, maxLength: 200 }
artist: { type: string, maxLength: 200 }
album: { type: string, maxLength: 200 }
language: { type: string }
durationMs: { type: integer }
bpm: { type: number }
difficulty: { type: integer, minimum: 1, maximum: 5 }
tags: { type: array, items: { type: string } }
status: { $ref: '#/components/schemas/PublishStatus' }
SongUpdate:
type: object
description: 所有字段可选;提供 source* 时必须五元组同时提供
properties:
title: { type: string }
artist: { type: string }
album: { type: string }
language: { type: string }
durationMs: { type: integer }
bpm: { type: number }
difficulty: { type: integer, minimum: 1, maximum: 5 }
tags: { type: array, items: { type: string } }
status: { $ref: '#/components/schemas/PublishStatus' }
sourceType: { $ref: '#/components/schemas/SourceType' }
sourceName: { type: string }
sourceUrl: { type: string }
license: { type: string }
author: { type: string }
SongPage:
type: object
properties:
items: { type: array, items: { $ref: '#/components/schemas/Song' } }
total: { type: integer }
page: { type: integer }
pageSize: { type: integer }
Motion:
allOf:
- $ref: '#/components/schemas/SourceFields'
- type: object
properties:
id: { type: integer, format: int64 }
songId: { type: integer, format: int64, nullable: true }
name: { type: string }
format: { $ref: '#/components/schemas/MotionFormat' }
fileUrl: { type: string, nullable: true }
fileSize: { type: integer, nullable: true }
fps: { type: number, nullable: true }
frameCount: { type: integer, nullable: true }
durationMs: { type: integer, nullable: true }
keypointFormat: { type: string, nullable: true }
keypointUrl: { type: string, nullable: true }
keypointFrames: { type: integer, nullable: true }
preprocessState: { type: string, enum: [none, running, done, failed] }
preprocessError: { type: string, nullable: true }
status: { $ref: '#/components/schemas/PublishStatus' }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
MotionUpdate:
type: object
properties:
name: { type: string }
songId: { type: integer, format: int64, nullable: true }
fps: { type: number }
status: { $ref: '#/components/schemas/PublishStatus' }
sourceType: { $ref: '#/components/schemas/SourceType' }
sourceName: { type: string }
sourceUrl: { type: string }
license: { type: string }
author: { type: string }
MotionPage:
type: object
properties:
items: { type: array, items: { $ref: '#/components/schemas/Motion' } }
total: { type: integer }
page: { type: integer }
pageSize: { type: integer }
Model:
allOf:
- $ref: '#/components/schemas/SourceFields'
- type: object
properties:
id: { type: integer, format: int64 }
name: { type: string }
format: { $ref: '#/components/schemas/ModelFormat' }
vrmVersion: { type: string, nullable: true }
isPrimary: { type: boolean }
fileUrl: { type: string, nullable: true }
fileSize: { type: integer, nullable: true }
thumbnailUrl: { type: string, nullable: true }
conversionNote:
type: string
nullable: true
description: 由压缩包转换而来时的说明;直传模型为 null
fileSha256:
type: string
nullable: true
description: 模型文件(转换产物)的 sha256 —— 每次转换都会变(Assimp 导出非字节级确定),仅作记录
sourceSha256:
type: string
nullable: true
description: 上传压缩包的 sha256(稳定);默认模型匹配以它为准,非压缩包上传为 null
status: { $ref: '#/components/schemas/PublishStatus' }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
ModelUpdate:
type: object
properties:
name: { type: string }
status: { $ref: '#/components/schemas/PublishStatus' }
sourceType: { $ref: '#/components/schemas/SourceType' }
sourceName: { type: string }
sourceUrl: { type: string }
license: { type: string }
author: { type: string }
ModelPage:
type: object
properties:
items: { type: array, items: { $ref: '#/components/schemas/Model' } }
total: { type: integer }
page: { type: integer }
pageSize: { type: integer }
ReviewRecord:
type: object
properties:
id: { type: integer, format: int64 }
targetType: { type: string, enum: [song, motion, model] }
targetId: { type: integer, format: int64 }
action: { type: string, enum: [submit, approve, reject, withdraw] }
fromStatus: { $ref: '#/components/schemas/PublishStatus' }
toStatus: { $ref: '#/components/schemas/PublishStatus' }
reviewer: { type: string, nullable: true }
comment: { type: string, nullable: true }
createdAt: { type: string, format: date-time }
ReviewPage:
type: object
properties:
items: { type: array, items: { $ref: '#/components/schemas/ReviewRecord' } }
total: { type: integer }
page: { type: integer }
pageSize: { type: integer }
PendingPage:
type: object
properties:
items:
type: array
items:
type: object
properties:
targetType: { type: string, enum: [song, motion, model] }
targetId: { type: integer, format: int64 }
name: { type: string }
status: { $ref: '#/components/schemas/PublishStatus' }
source: { $ref: '#/components/schemas/SourceFields' }
updatedAt: { type: string, format: date-time }
total: { type: integer }
page: { type: integer }
pageSize: { type: integer }
CatalogBundle:
type: object
properties:
contractVersion: { type: object, additionalProperties: { type: string } }
generatedAt: { type: string, format: date-time }
songs: { type: array, items: { $ref: '#/components/schemas/Song' } }
motions: { type: array, items: { $ref: '#/components/schemas/Motion' } }
models: { type: array, items: { $ref: '#/components/schemas/Model' } }
defaultModelId: { type: integer, format: int64, nullable: true }