Files
opendance-android/contracts/rest-api.yaml
T
zijunchiang 7ebbaef631 feat(net,pose): 连接状态机与保活对齐 + MediaPipe 崩溃修复(T-AND-010/011/012)
契约 websocket-keypoints 0.1.0 -> 0.2.0(向后兼容,子协议名与帧 version 不变;
先在 opendance-web 修改并提升版本,再同步三端快照)。

T-AND-011:连接状态异常、频繁重连(查实 3 个根因)
1. 保活用错了层:Windows 端有 5s 静默超时(只看应用层收到的帧),而本端只有
   OkHttp 的协议层 ping —— 它由底层库自动应答、**不暴露给应用层**,根本不能续命。
   画面里没人时 5s 必被踢、再重连,循环往复。
   - 契约新增 §1.1(welcome/config 声明 silentTimeoutMs 与 heartbeatIntervalMs,
     且心跳必须 < 静默超时的一半)与 §3.6(应用层 ping/pong);
   - Windows 端超时 5s -> 15s、期望心跳 5s,并在 welcome/config 中下发;
   - 本端按声明发 ping,连续 3 个周期无 pong 才判定链路已死并主动重连。
2. 不该重连时也重连:契约新增 §7 关闭码策略,1000/1001/1002 与主动停止一律不重连。
3. frame_id 恒为 0:PoseDetector.detect() 算了 safeTimestamp 却忘了写回
   lastTimestampMs,接收端的丢包检测因此完全失效。已修。

其他对齐:
- 契约新增 §8 连接状态机 idle -> resolving -> connecting -> handshaking ->
  streaming -> degraded -> reconnecting -> stopped,两端同名便于对照日志;
  resolving 只对主机名生效,局域网直连 IP 直接进 connecting;
- 退避与抖动按 §7.1 冻结:0.5s -> 1 -> 2 -> 4 -> 8 -> 15s 封顶 + ±20% 抖动;
- 契约新增 §5 设备去重:hello 携带稳定 deviceId(ANDROID_ID 的 SHA-256 前 16 位,
  不含硬件序列号),Windows 端据此去重、旧连接以 1000 关闭;
- 新增 util/ConnectionLog(环形缓冲,最近 500 条:状态迁移 / close code /
  子协议协商 / 首帧时间 / 保活参数),运行页可一键导出(FileProvider)。

T-AND-012:MediaPipe 初始化崩溃(ExceptionInInitializerError: null)
- 诊断:新增 pose/PoseDiagnostics 展开 cause 链取真因(通常 UnsatisfiedLinkError)、
  区分 native 加载失败、比对设备 ABI 与 APK 内 ABI 并给出可执行结论;
- native 解压:packaging { jniLibs { useLegacyPackaging = true } }(AGP 8 release
  默认 extractNativeLibs=false,MediaPipe JNI 对它不兼容);
- R8:补 -keepclasseswithmembernames 的 native 方法保留规则,并 -printseeds 输出
  seeds 供发布前核对(实测保留 20169 条 MediaPipe 条目);
- 构建期新增 verifyReleaseAbi:release ABI 必须恰为 arm64-v8a/armeabi-v7a、
  每个 ABI 下必须有 MediaPipe native 库、模型资源必须存在;
- 未换库:诊断指向集成配置而非 MediaPipe 本身,保留 33 点契约零成本。

T-AND-010:版本号展示与单一来源
- 版本唯一来源 versionName(= project.yaml 的 version);versionCode 按
  10000*主+100*次+修订 递增(0.2.0 -> 200);
- 契约版本改为构建期解析 contracts/VERSION 后注入 BuildConfig(六项各自独立
  递增,原先的单个 CONTRACT_VERSION 常量已失真);界面不再手写任何版本号;
- 新增 checkReleaseConsistency 并挂到 preBuild,三者不一致直接 BUILD FAILED。

README/DSH/THIRD_PARTY/tasks 同步更新(含「导出连接日志」「保活参数」
「最近断开原因」等新增可观察项与排查表)。

测试:56 项全部通过(新增 ReconnectPolicyTest 11 项、PoseDiagnosticsTest 12 项);
build/verify.sh 全通过(单元测试 -> 构建 -> 签名/ABI/native/seeds/dex 校验)
2026-09-17 01:10:06 +08:00

1036 lines
36 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.5.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: 上传模型(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,变化即需重新拉取 }
/api/scenes:
get:
tags: [scenes]
summary: 场景列表(v0.5.0)
parameters:
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/PageSize'
- name: q
in: query
schema: { type: string }
description: 按 name/author 模糊匹配
- name: status
in: query
schema: { type: string, enum: [draft, pending, published, rejected] }
responses:
'200':
description: 分页结果 { items, total, page, pageSize }
post:
tags: [scenes]
summary: 上传场景(multipart:file + name + 来源五元组 + 可选 config)
description: |
`file` 以 **.glb 为主**(直接导入,不重编码);`.gltf` 直接导入;
`.zip` / `.pmx` / `.fbx` 由服务端转换为 GLB(场景不做 humanoid 重绑定、不转 VRM)。
`config` 为 JSON 字符串(或平铺字段 gravity / wind / environment / spawnPoints / camera),
含物理参数:重力向量、风向动态(基础风 + 阵风)、地面摩擦/弹性/坡度、碰撞体来源。
服务端**只存参数、不跑物理**(仿真在客户端)。上传一律 status=pending。
requestBody:
content:
multipart/form-data:
schema:
type: object
required: [file, name, sourceType, sourceName, sourceUrl, license, author]
properties:
file: { type: string, format: binary }
name: { type: string }
config: { type: string, description: SceneConfig 的 JSON 字符串 }
sourceType: { type: string }
sourceName: { type: string }
sourceUrl: { type: string }
license: { type: string }
author: { type: string }
responses:
'201':
description: 已入库(pending)
/api/scenes/{id}:
parameters: [{ $ref: '#/components/parameters/Id' }]
get:
tags: [scenes]
summary: 场景详情
responses:
'200': { description: Scene }
'404': { description: 不存在 }
patch:
tags: [scenes]
summary: 更新场景名称 / 物理参数(状态变更走 /api/reviews)
responses:
'200': { description: Scene }
'400': { description: 参数非法或试图直接改状态 }
delete:
tags: [scenes]
summary: 删除场景(软删除)
responses:
'204': { description: 已删除 }
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' } }
scenes:
type: array
description: 已发布场景(rest-api 0.5.0 起随目录下发;结构见 data-models.md 7)
items: { $ref: '#/components/schemas/Scene' }
defaultModelId: { type: integer, format: int64, nullable: true }
Scene:
allOf:
- $ref: '#/components/schemas/SourceFields'
- type: object
properties:
id: { type: integer, format: int64 }
name: { type: string }
glbUrl: { type: string, nullable: true, description: 场景 GLB 下载地址 }
thumbnailUrl: { type: string, nullable: true }
fileSize: { type: integer, nullable: true }
fileSha256: { type: string, nullable: true }
conversionNote:
type: string
nullable: true
description: 由 .zip/.pmx/.fbx 转换而来时的说明;直接上传 GLB 为 null
config:
allOf: [{ $ref: '#/components/schemas/SceneConfig' }]
nullable: true
description: 物理 / 环境 / 出生点 / 相机;服务端只存参数,仿真在客户端
stats:
type: object
nullable: true
description: 场景 GLB 分析(面数 / 网格 / 材质 / warnings)
status: { $ref: '#/components/schemas/PublishStatus' }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
SceneConfig:
type: object
description: >-
data-models.md 7.1 冻结结构。服务端入库时对每项做边界收敛
(越界夹紧、非法枚举回退默认值),客户端拿到的参数一定可解释。
properties:
physics:
type: object
properties:
gravity:
type: object
description: 米/秒^2,各分量 ±200
properties:
x: { type: number }
y: { type: number }
z: { type: number }
wind:
type: object
properties:
base: { $ref: '#/components/schemas/Vec3' }
gustAmplitude: { type: number, description: 0~100 }
gustFrequency: { type: number, description: Hz,0.01~5 }
gustDirection: { $ref: '#/components/schemas/Vec3' }
space: { type: string, enum: [world, local] }
ground:
type: object
properties:
friction: { type: number, description: 0~1 }
restitution: { type: number, description: 0~1 }
slopeLimit: { type: number, description: 0~89 度 }
colliders: { type: string, enum: [auto, mesh, none] }
environment:
type: object
properties:
ambientColor: { type: string, description: '#RRGGBB' }
ambientIntensity: { type: number, description: 0~4 }
fogDensity: { type: number, description: 0~1,0=关闭 }
spawnPoints:
type: array
maxItems: 16
items:
type: object
properties:
x: { type: number }
y: { type: number }
z: { type: number }
yaw: { type: number, description: 朝向(度,绕 Y 轴) }
camera:
type: object
properties:
mode: { type: string, enum: [follow, fixed, orbit] }
distance: { type: number, description: 0.5~50 }
height: { type: number, description: 0~20 }
Vec3:
type: object
properties:
x: { type: number }
y: { type: number }
z: { type: number }