feat(web): T-WEB-003 /api/songs CRUD + 音频上传
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { PublishStatus } from '../common/enums';
|
||||
import { PageQueryDto } from '../common/pagination';
|
||||
import { PartialSourceFieldsDto, SourceFieldsDto } from '../common/source.dto';
|
||||
|
||||
export class CreateSongDto extends SourceFieldsDto {
|
||||
@IsString()
|
||||
@MinLength(1, { message: 'title 不能为空' })
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1, { message: 'artist 不能为空' })
|
||||
@MaxLength(200)
|
||||
artist!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
album?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
language?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
durationMs?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(400)
|
||||
bpm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
difficulty?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(PublishStatus)
|
||||
status?: PublishStatus;
|
||||
}
|
||||
|
||||
export class UpdateSongDto extends PartialSourceFieldsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
artist?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
album?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
language?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
durationMs?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(400)
|
||||
bpm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
difficulty?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(PublishStatus)
|
||||
status?: PublishStatus;
|
||||
}
|
||||
|
||||
export class QuerySongDto extends PageQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
artist?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(PublishStatus)
|
||||
status?: PublishStatus;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { PublishStatus, SourceType } from '../common/enums';
|
||||
import { Song } from '../entities/song.entity';
|
||||
|
||||
/** 契约 data-models.md §2 的对外表示 */
|
||||
export interface SongDto {
|
||||
id: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string | null;
|
||||
language: string | null;
|
||||
durationMs: number | null;
|
||||
bpm: number | null;
|
||||
difficulty: number | null;
|
||||
tags: string[];
|
||||
coverUrl: string | null;
|
||||
audioUrl: string | null;
|
||||
sourceType: SourceType;
|
||||
sourceName: string;
|
||||
sourceUrl: string;
|
||||
license: string;
|
||||
author: string;
|
||||
status: PublishStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SongUrlLookup {
|
||||
coverUrl: string | null;
|
||||
audioUrl: string | null;
|
||||
}
|
||||
|
||||
export function toSongDto(song: Song, urls: SongUrlLookup): SongDto {
|
||||
return {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
language: song.language,
|
||||
durationMs: song.durationMs,
|
||||
bpm: song.bpm,
|
||||
difficulty: song.difficulty,
|
||||
tags: song.tags ?? [],
|
||||
coverUrl: urls.coverUrl,
|
||||
audioUrl: urls.audioUrl,
|
||||
sourceType: song.sourceType,
|
||||
sourceName: song.sourceName,
|
||||
sourceUrl: song.sourceUrl,
|
||||
license: song.license,
|
||||
author: song.author,
|
||||
status: song.status,
|
||||
createdAt: song.createdAt.toISOString(),
|
||||
updatedAt: song.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Paged } from '../common/pagination';
|
||||
import { UploadedFileLike } from '../common/upload.util';
|
||||
import { CreateSongDto, QuerySongDto, UpdateSongDto } from './song.dto';
|
||||
import { SongDto } from './song.serializer';
|
||||
import { SongsService } from './songs.service';
|
||||
|
||||
@Controller('api/songs')
|
||||
export class SongsController {
|
||||
constructor(private readonly songs: SongsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: QuerySongDto): Promise<Paged<SongDto>> {
|
||||
return this.songs.list(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateSongDto): Promise<SongDto> {
|
||||
return this.songs.create(dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id', ParseIntPipe) id: number): Promise<SongDto> {
|
||||
return this.songs.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateSongDto,
|
||||
): Promise<SongDto> {
|
||||
return this.songs.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
return this.songs.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/audio')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
attachAudio(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
): Promise<SongDto> {
|
||||
return this.songs.attachAudio(id, file);
|
||||
}
|
||||
|
||||
@Post(':id/cover')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
attachCover(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
): Promise<SongDto> {
|
||||
return this.songs.attachCover(id, file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Song } from '../entities/song.entity';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { SongsController } from './songs.controller';
|
||||
import { SongsService } from './songs.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Song]), StorageModule],
|
||||
controllers: [SongsController],
|
||||
providers: [SongsService],
|
||||
exports: [SongsService],
|
||||
})
|
||||
export class SongsModule {}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Brackets, Repository } from 'typeorm';
|
||||
import { AssetKind, PublishStatus, canTransition } from '../common/enums';
|
||||
import { Paged, toPaged } from '../common/pagination';
|
||||
import { SourceTuple, assertSourceTupleComplete } from '../common/source.dto';
|
||||
import { UploadedFileLike, requireUploadedFile } from '../common/upload.util';
|
||||
import { Song } from '../entities/song.entity';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { CreateSongDto, QuerySongDto, UpdateSongDto } from './song.dto';
|
||||
import { SongDto, toSongDto } from './song.serializer';
|
||||
|
||||
/** 转义 LIKE 通配符,避免用户输入被当作模式 */
|
||||
export function likePattern(input: string): string {
|
||||
return `%${input.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SongsService {
|
||||
constructor(
|
||||
@InjectRepository(Song) private readonly songs: Repository<Song>,
|
||||
private readonly storage: StorageService,
|
||||
) {}
|
||||
|
||||
async list(query: QuerySongDto): Promise<Paged<SongDto>> {
|
||||
const qb = this.songs.createQueryBuilder('s').orderBy('s.id', 'DESC');
|
||||
|
||||
if (query.status) qb.andWhere('s.status = :status', { status: query.status });
|
||||
if (query.artist) qb.andWhere('s.artist LIKE :artist', { artist: likePattern(query.artist) });
|
||||
if (query.q) {
|
||||
const pattern = likePattern(query.q);
|
||||
qb.andWhere(
|
||||
new Brackets((w) => {
|
||||
w.where('s.title LIKE :pattern', { pattern })
|
||||
.orWhere('s.artist LIKE :pattern', { pattern })
|
||||
.orWhere('s.album 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<SongDto> {
|
||||
const song = await this.getEntity(id);
|
||||
const [dto] = await this.toDtos([song]);
|
||||
return dto;
|
||||
}
|
||||
|
||||
async getEntity(id: number): Promise<Song> {
|
||||
const song = await this.songs.findOne({ where: { id } });
|
||||
if (!song) throw new NotFoundException(`曲目 #${id} 不存在`);
|
||||
return song;
|
||||
}
|
||||
|
||||
async create(dto: CreateSongDto): Promise<SongDto> {
|
||||
const song = this.songs.create({
|
||||
title: dto.title.trim(),
|
||||
artist: dto.artist.trim(),
|
||||
album: dto.album?.trim() ?? null,
|
||||
language: dto.language?.trim() ?? null,
|
||||
durationMs: dto.durationMs ?? null,
|
||||
bpm: dto.bpm ?? null,
|
||||
difficulty: dto.difficulty ?? null,
|
||||
tags: dto.tags && dto.tags.length > 0 ? dto.tags.map((t) => t.trim()).filter(Boolean) : null,
|
||||
sourceType: dto.sourceType,
|
||||
sourceName: dto.sourceName.trim(),
|
||||
sourceUrl: dto.sourceUrl.trim(),
|
||||
license: dto.license.trim(),
|
||||
author: dto.author.trim(),
|
||||
status: dto.status ?? PublishStatus.Draft,
|
||||
});
|
||||
const saved = await this.songs.save(song);
|
||||
const [result] = await this.toDtos([saved]);
|
||||
return result;
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateSongDto): Promise<SongDto> {
|
||||
const song = await this.getEntity(id);
|
||||
|
||||
if (dto.status && dto.status !== song.status && !canTransition(song.status, dto.status)) {
|
||||
throw new ConflictException(
|
||||
`状态不允许从 ${song.status} 直接变更为 ${dto.status},请使用 /api/reviews 接口`,
|
||||
);
|
||||
}
|
||||
|
||||
const tuple: Partial<SourceTuple> | null = assertSourceTupleComplete({
|
||||
sourceType: dto.sourceType,
|
||||
sourceName: dto.sourceName,
|
||||
sourceUrl: dto.sourceUrl,
|
||||
license: dto.license,
|
||||
author: dto.author,
|
||||
});
|
||||
if (tuple) Object.assign(song, tuple);
|
||||
|
||||
if (dto.title !== undefined) song.title = dto.title.trim();
|
||||
if (dto.artist !== undefined) song.artist = dto.artist.trim();
|
||||
if (dto.album !== undefined) song.album = dto.album?.trim() || null;
|
||||
if (dto.language !== undefined) song.language = dto.language?.trim() || null;
|
||||
if (dto.durationMs !== undefined) song.durationMs = dto.durationMs;
|
||||
if (dto.bpm !== undefined) song.bpm = dto.bpm;
|
||||
if (dto.difficulty !== undefined) song.difficulty = dto.difficulty;
|
||||
if (dto.tags !== undefined) song.tags = dto.tags.length ? dto.tags.map((t) => t.trim()).filter(Boolean) : null;
|
||||
if (dto.status !== undefined) song.status = dto.status;
|
||||
|
||||
const saved = await this.songs.save(song);
|
||||
const [result] = await this.toDtos([saved]);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 软删除:契约 database.md 使用 deleted_at */
|
||||
async remove(id: number): Promise<void> {
|
||||
const song = await this.getEntity(id);
|
||||
await this.songs.softRemove(song);
|
||||
}
|
||||
|
||||
async attachAudio(id: number, file: UploadedFileLike | undefined): Promise<SongDto> {
|
||||
const song = await this.getEntity(id);
|
||||
const upload = requireUploadedFile(file);
|
||||
const stored = await this.storage.persistTempFile(
|
||||
AssetKind.SongAudio,
|
||||
upload.path,
|
||||
upload.originalname,
|
||||
upload.mimetype,
|
||||
);
|
||||
song.audioAssetId = stored.asset.id;
|
||||
const saved = await this.songs.save(song);
|
||||
const [result] = await this.toDtos([saved]);
|
||||
return result;
|
||||
}
|
||||
|
||||
async attachCover(id: number, file: UploadedFileLike | undefined): Promise<SongDto> {
|
||||
const song = await this.getEntity(id);
|
||||
const upload = requireUploadedFile(file);
|
||||
const stored = await this.storage.persistTempFile(
|
||||
AssetKind.SongCover,
|
||||
upload.path,
|
||||
upload.originalname,
|
||||
upload.mimetype,
|
||||
);
|
||||
song.coverAssetId = stored.asset.id;
|
||||
const saved = await this.songs.save(song);
|
||||
const [result] = await this.toDtos([saved]);
|
||||
return result;
|
||||
}
|
||||
|
||||
async toDtos(rows: Song[]): Promise<SongDto[]> {
|
||||
const urls = await this.storage.urlMap(
|
||||
rows.flatMap((row) => [row.coverAssetId, row.audioAssetId]),
|
||||
);
|
||||
return rows.map((row) =>
|
||||
toSongDto(row, {
|
||||
coverUrl: row.coverAssetId ? urls.get(row.coverAssetId) ?? null : null,
|
||||
audioUrl: row.audioAssetId ? urls.get(row.audioAssetId) ?? null : null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user