Files
opendance-windows/contracts/rest-api.yaml
T
zijunchiang 9f7b926a9a docs: 新增「初次部署(从零跑起来)」;去混淆字符与真实内网信息
- README.md / README.txt 新增初次部署章节:前置条件、获取代码、首次运行、
  配置服务端地址(API 3000 / 管理端 3001 分离)、与 Android 的 WebSocket 联通、
  打包导出、自检清单、常见问题
- 三库同源契约:':=' 写法修回、去掉真实 MariaDB 版本串
- 全文去除易被 Gitea 判为「模棱两可 Unicode」的字符(- - ... -> 取代 — – … § →)
- 真实内网地址与库名改占位符
2026-09-15 21:33:27 +08:00

880 lines
30 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.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://<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,变化即需重新拉取 }
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 }