feat(web): 客户端目录导出 /api/catalog/export 与版本指纹
This commit is contained in:
@@ -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<CatalogBundle> {
|
||||
return this.catalog.exportBundle();
|
||||
}
|
||||
|
||||
/** 契约版本 + 目录指纹(增量同步) */
|
||||
@Get('version')
|
||||
version(): Promise<{ contractVersion: Record<string, string>; revision: string; generatedAt: string }> {
|
||||
return this.catalog.version();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<string, string>;
|
||||
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<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 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' } }),
|
||||
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<string, string>; 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user