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, }); }); } }