feat(web): T-WEB-004 动作数据上传 + 来源五元组校验
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<Paged<MotionDto>> {
|
||||||
|
return this.motions.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** multipart/form-data:file + name + 来源五元组 + 可选 songId/fps/status/autoPreprocess */
|
||||||
|
@Post()
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
create(
|
||||||
|
@Body() body: Record<string, unknown>,
|
||||||
|
@UploadedFile() file: UploadedFileLike | undefined,
|
||||||
|
): Promise<MotionDto> {
|
||||||
|
return this.motions.create(body, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detail(@Param('id', ParseIntPipe) id: number): Promise<MotionDto> {
|
||||||
|
return this.motions.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateMotionDto): Promise<MotionDto> {
|
||||||
|
return this.motions.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.motions.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** T-WEB-006:BVH/FBX → MediaPipe33 关键点序列 */
|
||||||
|
@Post(':id/preprocess')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
preprocess(@Param('id', ParseIntPipe) id: number): Promise<MotionDto> {
|
||||||
|
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<void> {
|
||||||
|
const ndjson = await this.motions.keypointsNdjson(id);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="motion-${id}-keypoints.ndjson"`);
|
||||||
|
res.send(ndjson);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user