feat(web): T-WEB-004 动作数据上传 + 来源五元组校验

This commit is contained in:
2026-09-14 19:00:55 +08:00
parent d9614aadb0
commit ff8c4dd26a
5 changed files with 430 additions and 0 deletions
+219
View File
@@ -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<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,
});
});
}
}