Files
opendance-web/web-server/src/motions/motions.service.ts
T

220 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Motion>,
@InjectRepository(Song) private readonly songs: Repository<Song>,
private readonly storage: StorageService,
private readonly preprocess: PreprocessService,
) {}
async list(query: QueryMotionDto): Promise<Paged<MotionDto>> {
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<MotionDto> {
const motion = await this.getEntity(id);
const [dto] = await this.toDtos([motion]);
return dto;
}
async getEntity(id: number): Promise<Motion> {
const motion = await this.motions.findOne({ where: { id } });
if (!motion) throw new NotFoundException(`动作 #${id} 不存在`);
return motion;
}
/**
* 上传动作文件 + 来源字段。
* multipart 的字段均为字符串,这里显式解析并强制来源五元组齐备。
*/
async create(body: Record<string, unknown>, file: UploadedFileLike | undefined): Promise<MotionDto> {
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<MotionDto> {
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<void> {
const motion = await this.getEntity(id);
await this.motions.softRemove(motion);
}
/** T-WEB-006:触发预处理 */
async runPreprocess(id: number): Promise<MotionDto> {
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<string> {
const motion = await this.getEntity(id);
return this.preprocess.readKeypointsNdjson(motion);
}
/** 已发布动作的关键点序列(供 catalog 使用) */
async keypointTextFor(motion: Motion): Promise<string | null> {
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<void> {
const exists = await this.songs.exist({ where: { id: songId } });
if (!exists) throw new BadRequestException(`关联曲目 #${songId} 不存在`);
}
async toDtos(rows: Motion[]): Promise<MotionDto[]> {
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,
});
});
}
}