契约先行(三端逐字节一致,已同步 windows/android):
- rest-api -> 0.5.0:新增 GET/POST /api/scenes、GET/PATCH/DELETE /api/scenes/{id}
- data-models -> 0.7.0:新增 §7 Scene 与 7.1 SceneConfig(物理/环境/出生点/相机)
- database -> 0.4.0:新增 scenes 表(config/stats 用 MEDIUMTEXT,迁移可逆)
后端:
- entities/scene.entity.ts + 迁移 AddScenes1789650000000(scenes 表)
- scenes/scene-config.ts:SceneConfig 定义 + 默认值 + **边界收敛**(越界夹紧、
非法枚举回退),保证客户端拿到的参数一定可解释;服务端只存参数不跑物理
- scenes/scenes.service.ts:上传(.glb/.gltf 直接导入;.zip/.pmx/.fbx 转 GLB,
场景不做 humanoid 重绑定、不转 VRM)、列表/详情/更新/软删除、缩略图复用模型
光栅化器、分析复用模型检查器、config 同时接受 JSON 字符串与平铺字段
- scenes/scenes.controller.ts + scenes.module.ts(app.module 注册)
- 审核复用 /api/reviews/:type/:id:TargetType.Scene 接入待审列表与状态流转
- catalog/export 增 scenes[],目录指纹纳入场景变更
- 枚举:TargetType.Scene、AssetKind.Scene / SceneThumbnail
测试:新增 test/scene-config.spec.ts 6 项(默认值/夹紧/枚举回退/出生点上限/颜色/
序列化);规模 19 文件 / 243 项;check-routes 35 条一致。版本 0.6.0。
注:管理端场景页(GLB 预览 + 物理参数可视化)与 Windows 场景选择留待下一轮。
115 lines
4.5 KiB
TypeScript
115 lines
4.5 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { createHash } from 'node:crypto';
|
|
import { Repository } from 'typeorm';
|
|
import { ModelFormat, PublishStatus } from '../common/enums';
|
|
import { AppConfig } from '../config/configuration';
|
|
import { CharacterModel } from '../entities/model.entity';
|
|
import { Motion } from '../entities/motion.entity';
|
|
import { Song } from '../entities/song.entity';
|
|
import { ModelDto } from '../models/model.serializer';
|
|
import { ModelsService } from '../models/models.service';
|
|
import { MotionDto } from '../motions/motion.serializer';
|
|
import { MotionsService } from '../motions/motions.service';
|
|
import { SongDto } from '../songs/song.serializer';
|
|
import { ScenesService } from '../scenes/scenes.service';
|
|
import type { SceneDto } from '../scenes/scene.serializer';
|
|
import { SongsService } from '../songs/songs.service';
|
|
|
|
export interface CatalogBundle {
|
|
contractVersion: Record<string, string>;
|
|
generatedAt: string;
|
|
songs: SongDto[];
|
|
motions: MotionDto[];
|
|
models: ModelDto[];
|
|
/** 已发布场景(含物理参数);契约 data-models 0.7.0 */
|
|
scenes: SceneDto[];
|
|
defaultModelId: number | null;
|
|
}
|
|
|
|
/**
|
|
* 客户端(Windows / Android)一次性拉取已发布目录。
|
|
* 契约 rest-api.yaml /api/catalog/export,仅包含 status = published。
|
|
*/
|
|
@Injectable()
|
|
export class CatalogService {
|
|
constructor(
|
|
@InjectRepository(Song) private readonly songs: Repository<Song>,
|
|
@InjectRepository(Motion) private readonly motions: Repository<Motion>,
|
|
@InjectRepository(CharacterModel) private readonly models: Repository<CharacterModel>,
|
|
private readonly songsService: SongsService,
|
|
private readonly motionsService: MotionsService,
|
|
private readonly modelsService: ModelsService,
|
|
private readonly scenesService: ScenesService,
|
|
private readonly config: AppConfig,
|
|
) {}
|
|
|
|
async exportBundle(): Promise<CatalogBundle> {
|
|
const [songs, motions, models] = await Promise.all([
|
|
this.songs.find({ where: { status: PublishStatus.Published }, order: { id: 'ASC' } }),
|
|
this.motions.find({ where: { status: PublishStatus.Published }, order: { id: 'ASC' } }),
|
|
// v0.5.0:只交付**已产出 VRM** 的模型(usable),未产出 VRM 的不进入客户端目录
|
|
this.models.find({
|
|
where: { status: PublishStatus.Published, format: ModelFormat.Vrm },
|
|
order: { id: 'ASC' },
|
|
}),
|
|
]);
|
|
|
|
const [songDtos, motionDtos, modelDtos, sceneDtos, defaultModelId] = await Promise.all([
|
|
this.songsService.toDtos(songs),
|
|
this.motionsService.toDtos(motions),
|
|
this.modelsService.toDtos(models),
|
|
this.scenesService.published(),
|
|
this.modelsService.resolveDefaultModelId(),
|
|
]);
|
|
|
|
return {
|
|
contractVersion: this.config.contractVersions,
|
|
generatedAt: new Date().toISOString(),
|
|
songs: songDtos,
|
|
motions: motionDtos,
|
|
models: modelDtos,
|
|
scenes: sceneDtos,
|
|
defaultModelId,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 目录指纹:客户端据此判断是否需要重新拉取。
|
|
* 指纹只覆盖影响客户端行为的字段。
|
|
*/
|
|
async version(): Promise<{ contractVersion: Record<string, string>; revision: string; generatedAt: string }> {
|
|
const [songs, motions, models, scenes] = await Promise.all([
|
|
this.songs.find({ where: { status: PublishStatus.Published }, order: { id: 'ASC' } }),
|
|
this.motions.find({ where: { status: PublishStatus.Published }, order: { id: 'ASC' } }),
|
|
// v0.5.0:只交付**已产出 VRM** 的模型(usable),未产出 VRM 的不进入客户端目录
|
|
this.models.find({
|
|
where: { status: PublishStatus.Published, format: ModelFormat.Vrm },
|
|
order: { id: 'ASC' },
|
|
}),
|
|
this.scenesService.published(),
|
|
]);
|
|
|
|
const fingerprint = JSON.stringify({
|
|
songs: songs.map((s) => [s.id, s.title, s.artist, s.updatedAt.toISOString(), s.audioAssetId, s.coverAssetId]),
|
|
motions: motions.map((m) => [
|
|
m.id,
|
|
m.name,
|
|
m.songId,
|
|
m.updatedAt.toISOString(),
|
|
m.keypointAssetId,
|
|
m.keypointFrames,
|
|
m.preprocessState,
|
|
]),
|
|
models: models.map((m) => [m.id, m.name, m.format, m.isPrimary, m.updatedAt.toISOString(), m.fileAssetId]),
|
|
scenes: scenes.map((s) => [s.id, s.name, s.updatedAt, s.glbUrl, s.status]),
|
|
});
|
|
|
|
return {
|
|
contractVersion: this.config.contractVersions,
|
|
revision: createHash('sha256').update(fingerprint).digest('hex'),
|
|
generatedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
}
|