diff --git a/web-server/src/motions/motion.dto.ts b/web-server/src/motions/motion.dto.ts new file mode 100644 index 0000000..ea8c902 --- /dev/null +++ b/web-server/src/motions/motion.dto.ts @@ -0,0 +1,58 @@ +import { Type } from 'class-transformer'; +import { IsBoolean, IsEnum, IsInt, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator'; +import { MotionFormat, PublishStatus } from '../common/enums'; +import { PageQueryDto } from '../common/pagination'; +import { PartialSourceFieldsDto } from '../common/source.dto'; + +/** + * 动作上传走 multipart/form-data,字段以字符串到达(见 MotionsService.create), + * 因此上传路径不使用 DTO,只在此处校验更新与查询。 + */ +export class UpdateMotionDto extends PartialSourceFieldsDto { + @IsOptional() + @IsString() + @MaxLength(200) + name?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + songId?: number; + + /** 显式解绑曲目 */ + @IsOptional() + @Type(() => Boolean) + @IsBoolean() + detachSong?: boolean; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + fps?: number; + + @IsOptional() + @IsEnum(PublishStatus) + status?: PublishStatus; +} + +export class QueryMotionDto extends PageQueryDto { + @IsOptional() + @IsString() + @MaxLength(200) + q?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + songId?: number; + + @IsOptional() + @IsEnum(MotionFormat) + format?: MotionFormat; + + @IsOptional() + @IsEnum(PublishStatus) + status?: PublishStatus; +} diff --git a/web-server/src/motions/motion.serializer.ts b/web-server/src/motions/motion.serializer.ts new file mode 100644 index 0000000..53afc8b --- /dev/null +++ b/web-server/src/motions/motion.serializer.ts @@ -0,0 +1,61 @@ +import { MotionFormat, PreprocessState, PublishStatus, SourceType } from '../common/enums'; +import { Motion } from '../entities/motion.entity'; + +/** 契约 data-models.md §3 的对外表示 */ +export interface MotionDto { + id: number; + songId: number | null; + name: string; + format: MotionFormat; + fileUrl: string | null; + fileSize: number | null; + fps: number | null; + frameCount: number | null; + durationMs: number | null; + keypointFormat: string | null; + keypointUrl: string | null; + keypointFrames: number | null; + preprocessState: PreprocessState; + preprocessError: string | null; + sourceType: SourceType; + sourceName: string; + sourceUrl: string; + license: string; + author: string; + status: PublishStatus; + createdAt: string; + updatedAt: string; +} + +export interface MotionUrlLookup { + fileUrl: string | null; + keypointUrl: string | null; + fileSize?: number | null; +} + +export function toMotionDto(motion: Motion, urls: MotionUrlLookup): MotionDto { + return { + id: motion.id, + songId: motion.songId, + name: motion.name, + format: motion.format, + fileUrl: urls.fileUrl, + fileSize: urls.fileSize ?? null, + fps: motion.fps, + frameCount: motion.frameCount, + durationMs: motion.durationMs, + keypointFormat: motion.keypointFormat, + keypointUrl: urls.keypointUrl, + keypointFrames: motion.keypointFrames, + preprocessState: motion.preprocessState, + preprocessError: motion.preprocessError, + sourceType: motion.sourceType, + sourceName: motion.sourceName, + sourceUrl: motion.sourceUrl, + license: motion.license, + author: motion.author, + status: motion.status, + createdAt: motion.createdAt.toISOString(), + updatedAt: motion.updatedAt.toISOString(), + }; +} diff --git a/web-server/src/motions/motions.controller.ts b/web-server/src/motions/motions.controller.ts new file mode 100644 index 0000000..c9858e6 --- /dev/null +++ b/web-server/src/motions/motions.controller.ts @@ -0,0 +1,76 @@ +import { + Body, + Controller, + Delete, + Get, + Header, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + Patch, + Post, + Query, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Response } from 'express'; +import { Paged } from '../common/pagination'; +import { UploadedFileLike } from '../common/upload.util'; +import { QueryMotionDto, UpdateMotionDto } from './motion.dto'; +import { MotionDto } from './motion.serializer'; +import { MotionsService } from './motions.service'; + +@Controller('api/motions') +export class MotionsController { + constructor(private readonly motions: MotionsService) {} + + @Get() + list(@Query() query: QueryMotionDto): Promise> { + return this.motions.list(query); + } + + /** multipart/form-data:file + name + 来源五元组 + 可选 songId/fps/status/autoPreprocess */ + @Post() + @UseInterceptors(FileInterceptor('file')) + create( + @Body() body: Record, + @UploadedFile() file: UploadedFileLike | undefined, + ): Promise { + return this.motions.create(body, file); + } + + @Get(':id') + detail(@Param('id', ParseIntPipe) id: number): Promise { + return this.motions.findOne(id); + } + + @Patch(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateMotionDto): Promise { + return this.motions.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('id', ParseIntPipe) id: number): Promise { + return this.motions.remove(id); + } + + /** T-WEB-006:BVH/FBX → MediaPipe33 关键点序列 */ + @Post(':id/preprocess') + @HttpCode(HttpStatus.OK) + preprocess(@Param('id', ParseIntPipe) id: number): Promise { + return this.motions.runPreprocess(id); + } + + /** 关键点序列下载(NDJSON),契约 rest-api.yaml /api/motions/{id}/keypoints */ + @Get(':id/keypoints') + @Header('Content-Type', 'application/x-ndjson; charset=utf-8') + async keypoints(@Param('id', ParseIntPipe) id: number, @Res() res: Response): Promise { + const ndjson = await this.motions.keypointsNdjson(id); + res.setHeader('Content-Disposition', `attachment; filename="motion-${id}-keypoints.ndjson"`); + res.send(ndjson); + } +} diff --git a/web-server/src/motions/motions.module.ts b/web-server/src/motions/motions.module.ts new file mode 100644 index 0000000..37bc578 --- /dev/null +++ b/web-server/src/motions/motions.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Motion } from '../entities/motion.entity'; +import { Song } from '../entities/song.entity'; +import { PreprocessModule } from '../preprocess/preprocess.module'; +import { StorageModule } from '../storage/storage.module'; +import { MotionsController } from './motions.controller'; +import { MotionsService } from './motions.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Motion, Song]), StorageModule, PreprocessModule], + controllers: [MotionsController], + providers: [MotionsService], + exports: [MotionsService], +}) +export class MotionsModule {} diff --git a/web-server/src/motions/motions.service.ts b/web-server/src/motions/motions.service.ts new file mode 100644 index 0000000..aee6ff4 --- /dev/null +++ b/web-server/src/motions/motions.service.ts @@ -0,0 +1,219 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Brackets, Repository } from 'typeorm'; +import { + AssetKind, + MOTION_EXTENSION_MAP, + MotionFormat, + PublishStatus, + canTransition, +} from '../common/enums'; +import { Paged, toPaged } from '../common/pagination'; +import { SourceTuple, assertSourceTupleComplete } from '../common/source.dto'; +import { + UploadedFileLike, + parseOptionalBool, + parseOptionalInt, + parseOptionalNumber, + requireUploadedFile, + resolveFormatByExtension, +} from '../common/upload.util'; +import { Motion } from '../entities/motion.entity'; +import { Song } from '../entities/song.entity'; +import { PreprocessService } from '../preprocess/preprocess.service'; +import { StorageService } from '../storage/storage.service'; +import { QueryMotionDto, UpdateMotionDto } from './motion.dto'; +import { MotionDto, toMotionDto } from './motion.serializer'; + +@Injectable() +export class MotionsService { + constructor( + @InjectRepository(Motion) private readonly motions: Repository, + @InjectRepository(Song) private readonly songs: Repository, + private readonly storage: StorageService, + private readonly preprocess: PreprocessService, + ) {} + + async list(query: QueryMotionDto): Promise> { + const qb = this.motions.createQueryBuilder('m').orderBy('m.id', 'DESC'); + if (query.status) qb.andWhere('m.status = :status', { status: query.status }); + if (query.format) qb.andWhere('m.format = :format', { format: query.format }); + if (query.songId) qb.andWhere('m.song_id = :songId', { songId: query.songId }); + if (query.q) { + const pattern = `%${query.q.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`; + qb.andWhere( + new Brackets((w) => { + w.where('m.name LIKE :pattern', { pattern }).orWhere('m.author LIKE :pattern', { pattern }); + }), + ); + } + + const [rows, total] = await qb.skip(query.skip).take(query.pageSize).getManyAndCount(); + return toPaged(await this.toDtos(rows), total, query); + } + + async findOne(id: number): Promise { + const motion = await this.getEntity(id); + const [dto] = await this.toDtos([motion]); + return dto; + } + + async getEntity(id: number): Promise { + const motion = await this.motions.findOne({ where: { id } }); + if (!motion) throw new NotFoundException(`动作 #${id} 不存在`); + return motion; + } + + /** + * 上传动作文件 + 来源字段。 + * multipart 的字段均为字符串,这里显式解析并强制来源五元组齐备。 + */ + async create(body: Record, file: UploadedFileLike | undefined): Promise { + const upload = requireUploadedFile(file); + + const name = typeof body.name === 'string' ? body.name.trim() : ''; + if (!name) throw new BadRequestException('name 不能为空'); + if (name.length > 200) throw new BadRequestException('name 长度不能超过 200'); + + const { format } = resolveFormatByExtension(upload.originalname, MOTION_EXTENSION_MAP, '动作文件'); + + const tuple = assertSourceTupleComplete({ + sourceType: body.sourceType as SourceTuple['sourceType'], + sourceName: body.sourceName as string, + sourceUrl: body.sourceUrl as string, + license: body.license as string, + author: body.author as string, + }); + if (!tuple) { + throw new BadRequestException('上传动作必须填写完整的来源信息(sourceType/sourceName/sourceUrl/license/author)'); + } + + const songId = parseOptionalInt(body.songId, 'songId') ?? null; + if (songId !== null) await this.assertSongExists(songId); + + const stored = await this.storage.persistTempFile( + AssetKind.Motion, + upload.path, + upload.originalname, + upload.mimetype, + ); + + const motion = this.motions.create({ + songId, + name, + format: format as MotionFormat, + fileAssetId: stored.asset.id, + fps: parseOptionalNumber(body.fps, 'fps') ?? null, + sourceType: tuple.sourceType as Motion['sourceType'], + sourceName: tuple.sourceName, + sourceUrl: tuple.sourceUrl, + license: tuple.license, + author: tuple.author, + status: this.parseStatus(body.status), + }); + + const saved = await this.motions.save(motion); + + if (parseOptionalBool(body.autoPreprocess)) { + // 预处理失败不阻塞上传:失败原因已写入 motion.preprocessError + try { + const refreshed = await this.getEntity(saved.id); + await this.preprocess.run(refreshed); + } catch { + /* 已记录在 preprocessError */ + } + } + + const final = await this.getEntity(saved.id); + const [dto] = await this.toDtos([final]); + return dto; + } + + async update(id: number, dto: UpdateMotionDto): Promise { + const motion = await this.getEntity(id); + + if (dto.status && dto.status !== motion.status && !canTransition(motion.status, dto.status)) { + throw new ConflictException( + `状态不允许从 ${motion.status} 直接变更为 ${dto.status},请使用 /api/reviews 接口`, + ); + } + + const tuple = assertSourceTupleComplete({ + sourceType: dto.sourceType, + sourceName: dto.sourceName, + sourceUrl: dto.sourceUrl, + license: dto.license, + author: dto.author, + }); + if (tuple) Object.assign(motion, tuple); + + if (dto.name !== undefined) motion.name = dto.name.trim(); + if (dto.fps !== undefined) motion.fps = dto.fps; + if (dto.status !== undefined) motion.status = dto.status; + if (dto.detachSong) { + motion.songId = null; + } else if (dto.songId !== undefined) { + await this.assertSongExists(dto.songId); + motion.songId = dto.songId; + } + + const saved = await this.motions.save(motion); + const [result] = await this.toDtos([saved]); + return result; + } + + async remove(id: number): Promise { + const motion = await this.getEntity(id); + await this.motions.softRemove(motion); + } + + /** T-WEB-006:触发预处理 */ + async runPreprocess(id: number): Promise { + const motion = await this.getEntity(id); + const saved = await this.preprocess.run(motion); + const [dto] = await this.toDtos([saved]); + return dto; + } + + async keypointsNdjson(id: number): Promise { + const motion = await this.getEntity(id); + return this.preprocess.readKeypointsNdjson(motion); + } + + /** 已发布动作的关键点序列(供 catalog 使用) */ + async keypointTextFor(motion: Motion): Promise { + try { + return await this.preprocess.readKeypointsNdjson(motion); + } catch { + return null; + } + } + + private parseStatus(value: unknown): PublishStatus { + if (typeof value !== 'string' || value === '') return PublishStatus.Draft; + if (!Object.values(PublishStatus).includes(value as PublishStatus)) { + throw new BadRequestException(`status 非法:${value}`); + } + return value as PublishStatus; + } + + private async assertSongExists(songId: number): Promise { + const exists = await this.songs.exist({ where: { id: songId } }); + if (!exists) throw new BadRequestException(`关联曲目 #${songId} 不存在`); + } + + async toDtos(rows: Motion[]): Promise { + const assets = await this.storage.findMany( + rows.flatMap((row) => [row.fileAssetId, row.keypointAssetId]), + ); + return rows.map((row) => { + const file = row.fileAssetId ? assets.get(row.fileAssetId) : undefined; + const keypoints = row.keypointAssetId ? assets.get(row.keypointAssetId) : undefined; + return toMotionDto(row, { + fileUrl: file ? this.storage.url(file) : null, + keypointUrl: keypoints ? this.storage.url(keypoints) : null, + fileSize: file?.size ?? null, + }); + }); + } +}