openapi: 3.0.3 info: title: OpenDance Studio Web 管理服务端 API version: 0.4.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://: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: 上传模型(GLB/VRM 直接导入;PMX/FBX 服务端转 GLB 或 VRM) description: | 系统以 **GLB / VRM 为主力格式**(两个客户端都原生加载): - `.glb` / `.vrm` / `.gltf`:**直接导入**,不重编码 - `.pmx` / `.fbx`:服务端转换,**优先 GLB、其次 VRM** - `.zip`:**自动搜索包内可用模型** -- `.vrm` -> `.glb` -> `.gltf`(直接导入) 优先,其次 `.pmx` -> `.fbx`(转换)。压缩包原件作为 `model-archive` 资产留存。 转换链路(`model-convert.ts`):`assimp export -> .glb` ->(失败)`-> .gltf` 后 由服务端自行打包成 GLB ->(`MODEL_CONVERT_FORMAT=vrm` 时)在 GLB 之上补 `VRMC_vrm` 扩展产出 VRM 1.0。每一步都 `assimp info` **回读校验**, 并把过程写进 `conversionNote`;贴图会内嵌进 GLB,保证单文件可用。 入库时自动做两件事并写入 `stats` / `thumbnailUrl`:模型分析(面数、骨骼树、 humanoid 覆盖、可驱动性)与缩略图生成。 **状态一律 `pending`(待审核)**:模型必须审核通过后才可能被客户端消费; 设为默认模型请在审核通过时带 `setPrimary=true`,或调用 `POST /api/models/{id}/primary`(仅已发布模型可设)。 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 } # 注意:上传不再提供"自动发布/自动设为默认",模型一律进入待审核 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}/reanalyze: parameters: [{ $ref: '#/components/parameters/Id' }] post: tags: [models] summary: 重新分析模型并重建缩略图(升级回填 / 文件替换后刷新) description: | 重新计算 `stats`(面数、骨骼树、humanoid 覆盖、可驱动性)并重出缩略图, 只改这两个字段。用于:升级到带 `stats` 的版本后回填历史模型、 模型文件被替换后刷新、缩略图渲染器升级后重出图。 批量回填用 `node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000`。 responses: '200': description: 重新分析后的模型 content: application/json: schema: { $ref: '#/components/schemas/Model' } '404': { $ref: '#/components/responses/NotFound' } '409': { 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.5+' } 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 导出非字节级确定),仅作记录 stats: type: object nullable: true description: >- 模型分析结果(面数 / 顶点数 / 贴图 / 骨骼树 / humanoid 覆盖 / 可驱动性), 审核界面据此判断"能不能驱动与评分"。结构见 contracts/model-formats.md 1.1 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 }