diff --git a/web-server/src/catalog/catalog.controller.ts b/web-server/src/catalog/catalog.controller.ts new file mode 100644 index 0000000..068947c --- /dev/null +++ b/web-server/src/catalog/catalog.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get } from '@nestjs/common'; +import { CatalogBundle, CatalogService } from './catalog.service'; + +@Controller('api/catalog') +export class CatalogController { + constructor(private readonly catalog: CatalogService) {} + + /** 客户端一次性拉取已发布目录 */ + @Get('export') + export(): Promise { + return this.catalog.exportBundle(); + } + + /** 契约版本 + 目录指纹(增量同步) */ + @Get('version') + version(): Promise<{ contractVersion: Record; revision: string; generatedAt: string }> { + return this.catalog.version(); + } +} diff --git a/web-server/src/catalog/catalog.module.ts b/web-server/src/catalog/catalog.module.ts new file mode 100644 index 0000000..e0fcb0b --- /dev/null +++ b/web-server/src/catalog/catalog.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CharacterModel } from '../entities/model.entity'; +import { Motion } from '../entities/motion.entity'; +import { Song } from '../entities/song.entity'; +import { ModelsModule } from '../models/models.module'; +import { MotionsModule } from '../motions/motions.module'; +import { SongsModule } from '../songs/songs.module'; +import { CatalogController } from './catalog.controller'; +import { CatalogService } from './catalog.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Song, Motion, CharacterModel]), + SongsModule, + MotionsModule, + ModelsModule, + ], + controllers: [CatalogController], + providers: [CatalogService], +}) +export class CatalogModule {} diff --git a/web-server/src/catalog/catalog.service.ts b/web-server/src/catalog/catalog.service.ts new file mode 100644 index 0000000..daa1238 --- /dev/null +++ b/web-server/src/catalog/catalog.service.ts @@ -0,0 +1,97 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { createHash } from 'node:crypto'; +import { Repository } from 'typeorm'; +import { 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 { SongsService } from '../songs/songs.service'; + +export interface CatalogBundle { + contractVersion: Record; + generatedAt: string; + songs: SongDto[]; + motions: MotionDto[]; + models: ModelDto[]; + 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 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' } }), + this.models.find({ where: { status: PublishStatus.Published }, order: { id: 'ASC' } }), + ]); + + const [songDtos, motionDtos, modelDtos, defaultModelId] = await Promise.all([ + this.songsService.toDtos(songs), + this.motionsService.toDtos(motions), + this.modelsService.toDtos(models), + this.modelsService.resolveDefaultModelId(), + ]); + + return { + contractVersion: this.config.contractVersions, + generatedAt: new Date().toISOString(), + songs: songDtos, + motions: motionDtos, + models: modelDtos, + defaultModelId, + }; + } + + /** + * 目录指纹:客户端据此判断是否需要重新拉取。 + * 指纹只覆盖影响客户端行为的字段。 + */ + async version(): Promise<{ contractVersion: Record; revision: string; generatedAt: string }> { + 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' } }), + this.models.find({ where: { status: PublishStatus.Published }, order: { id: 'ASC' } }), + ]); + + 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]), + }); + + return { + contractVersion: this.config.contractVersions, + revision: createHash('sha256').update(fingerprint).digest('hex'), + generatedAt: new Date().toISOString(), + }; + } +}