feat(web): T-WEB-003 /api/songs CRUD + 音频上传

This commit is contained in:
2026-09-14 19:00:55 +08:00
parent 6714b1e67c
commit d9614aadb0
10 changed files with 907 additions and 0 deletions
+136
View File
@@ -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;
}
+54
View File
@@ -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(),
};
}
+73
View File
@@ -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);
}
}
+14
View 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 {}
+163
View File
@@ -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,
}),
);
}
}