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; 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, @InjectRepository(Motion) private readonly motions: Repository, @InjectRepository(CharacterModel) private readonly models: Repository, private readonly songsService: SongsService, private readonly motionsService: MotionsService, private readonly modelsService: ModelsService, private readonly scenesService: ScenesService, private readonly config: AppConfig, ) {} async exportBundle(): Promise { 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; 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(), }; } }