契约 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 校验)
1036 lines
36 KiB
YAML
1036 lines
36 KiB
YAML
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 }
|