feat(web): 客户端目录导出 /api/catalog/export 与版本指纹

This commit is contained in:
2026-09-14 19:00:55 +08:00
parent 7ff3f1598d
commit 0f66b53480
3 changed files with 138 additions and 0 deletions
@@ -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();
}
}
+22
View File
@@ -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 {}
+97
View File
@@ -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(),
};
}
}