feat(web): T-WEB-003 /api/songs CRUD + 音频上传
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
/** 契约 data-models.md §1:列表接口统一分页参数 */
|
||||
export class PageQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
pageSize: number = 20;
|
||||
|
||||
get skip(): number {
|
||||
return (this.page - 1) * this.pageSize;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Paged<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export function toPaged<T>(items: T[], total: number, query: { page: number; pageSize: number }): Paged<T> {
|
||||
return { items, total, page: query.page, pageSize: query.pageSize };
|
||||
}
|
||||
|
||||
/** 规范化分页参数(用于非 DTO 校验路径,如内部调用) */
|
||||
export function normalizePaging(page?: number, pageSize?: number, maxPageSize = 200): { page: number; pageSize: number; skip: number } {
|
||||
const p = Number.isFinite(page) && (page as number) >= 1 ? Math.floor(page as number) : 1;
|
||||
const rawSize = Number.isFinite(pageSize) ? Math.floor(pageSize as number) : 20;
|
||||
const size = Math.min(Math.max(rawSize, 1), maxPageSize);
|
||||
return { page: p, pageSize: size, skip: (p - 1) * size };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { IsEnum, IsOptional, IsString, IsUrl, MaxLength, MinLength } from 'class-validator';
|
||||
import { SourceType } from './enums';
|
||||
|
||||
/**
|
||||
* 来源(出处)五元组 —— 契约 data-models.md §1。
|
||||
* 任何资产(song / motion / model)入库时五元组必须齐备。
|
||||
*/
|
||||
export class SourceFieldsDto {
|
||||
@IsEnum(SourceType, { message: 'sourceType 必须为 original/licensed/cc/public_domain/user_upload/other' })
|
||||
sourceType!: SourceType;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1, { message: 'sourceName 不能为空' })
|
||||
@MaxLength(200)
|
||||
sourceName!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
@IsUrl(
|
||||
{ require_protocol: true, protocols: ['http', 'https'] },
|
||||
{ message: 'sourceUrl 必须为 http(s) 链接' },
|
||||
)
|
||||
sourceUrl!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1, { message: 'license 不能为空' })
|
||||
@MaxLength(100)
|
||||
license!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1, { message: 'author 不能为空' })
|
||||
@MaxLength(200)
|
||||
author!: string;
|
||||
}
|
||||
|
||||
/** 更新用:全部可选,但提交时必须五元组同时提交(见 assertSourceTupleComplete) */
|
||||
export class PartialSourceFieldsDto {
|
||||
@IsOptional()
|
||||
@IsEnum(SourceType)
|
||||
sourceType?: SourceType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
sourceName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
sourceUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
license?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
author?: string;
|
||||
}
|
||||
|
||||
export const SOURCE_FIELD_KEYS = [
|
||||
'sourceType',
|
||||
'sourceName',
|
||||
'sourceUrl',
|
||||
'license',
|
||||
'author',
|
||||
] as const;
|
||||
|
||||
export type SourceTuple = {
|
||||
sourceType: string;
|
||||
sourceName: string;
|
||||
sourceUrl: string;
|
||||
license: string;
|
||||
author: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 校验五元组要么全部缺省(不修改来源),要么全部齐备且合法。
|
||||
* 用于 PATCH 更新路径 —— class-validator 无法表达「全有或全无」。
|
||||
*/
|
||||
export function assertSourceTupleComplete(input: Partial<SourceTuple>): Partial<SourceTuple> | null {
|
||||
const present = SOURCE_FIELD_KEYS.filter((k) => input[k] !== undefined && input[k] !== null);
|
||||
if (present.length === 0) return null;
|
||||
|
||||
if (present.length !== SOURCE_FIELD_KEYS.length) {
|
||||
const missing = SOURCE_FIELD_KEYS.filter((k) => !present.includes(k));
|
||||
throw new BadRequestException(`来源信息必须完整提交,缺少:${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const tuple = {} as SourceTuple;
|
||||
for (const key of SOURCE_FIELD_KEYS) {
|
||||
const value = input[key];
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new BadRequestException(`来源字段 ${key} 不能为空`);
|
||||
}
|
||||
tuple[key] = value.trim();
|
||||
}
|
||||
|
||||
if (!/^https?:\/\/\S+$/i.test(tuple.sourceUrl)) {
|
||||
throw new BadRequestException('sourceUrl 必须为 http(s) 链接');
|
||||
}
|
||||
if (!Object.values(SourceType).includes(tuple.sourceType as SourceType)) {
|
||||
throw new BadRequestException(
|
||||
`sourceType 必须为 ${Object.values(SourceType).join('/')} 之一`,
|
||||
);
|
||||
}
|
||||
return tuple;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { extname } from 'node:path';
|
||||
|
||||
/** 上传文件的最小信息(兼容 multer 的 Express.Multer.File 与测试替身) */
|
||||
export interface UploadedFileLike {
|
||||
originalname: string;
|
||||
mimetype: string;
|
||||
path: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function requireUploadedFile(file: UploadedFileLike | undefined, label = 'file'): UploadedFileLike {
|
||||
if (!file) throw new BadRequestException(`缺少上传文件字段 ${label}`);
|
||||
if (!file.path) throw new BadRequestException('上传文件未落盘');
|
||||
return file;
|
||||
}
|
||||
|
||||
/** 按扩展名白名单解析格式;不在白名单内一律 422 */
|
||||
export function resolveFormatByExtension(
|
||||
originalName: string,
|
||||
map: Record<string, string>,
|
||||
kindLabel: string,
|
||||
): { ext: string; format: string } {
|
||||
const ext = extname(originalName).toLowerCase();
|
||||
const format = map[ext];
|
||||
if (!format) {
|
||||
throw new BadRequestException(
|
||||
`${kindLabel}格式不支持:${ext || '(无扩展名)'},允许:${Object.keys(map).join(', ')}`,
|
||||
);
|
||||
}
|
||||
return { ext, format };
|
||||
}
|
||||
|
||||
export function parseOptionalInt(value: unknown, field: string): number | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) throw new BadRequestException(`${field} 必须为数字`);
|
||||
return Math.trunc(num);
|
||||
}
|
||||
|
||||
export function parseOptionalNumber(value: unknown, field: string): number | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) throw new BadRequestException(`${field} 必须为数字`);
|
||||
return num;
|
||||
}
|
||||
|
||||
export function parseOptionalBool(value: unknown): boolean {
|
||||
if (value === undefined || value === null || value === '') return false;
|
||||
if (typeof value === 'boolean') return value;
|
||||
return value === 'true' || value === '1' || value === 1;
|
||||
}
|
||||
|
||||
export function notFound(what: string, id: number | string): NotFoundException {
|
||||
return new NotFoundException(`${what} #${id} 不存在`);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { extname, join, resolve } from 'node:path';
|
||||
import { diskStorage } from 'multer';
|
||||
import { AppConfig } from '../config/configuration';
|
||||
import { Asset } from '../entities/asset.entity';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
/**
|
||||
* 上传文件先落到 `${STORAGE_DIR}/tmp`,再由 StorageService 做 sha256 去重与归档。
|
||||
* 使用磁盘临时目录而非内存,避免 512 MiB 级 VRM/FBX 打爆进程内存。
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Asset]),
|
||||
MulterModule.registerAsync({
|
||||
inject: [AppConfig],
|
||||
useFactory: (config: AppConfig) => {
|
||||
const tmpDir = join(resolve(config.storageDir), 'tmp');
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
return {
|
||||
storage: diskStorage({
|
||||
destination: tmpDir,
|
||||
filename: (_req, file, callback) => {
|
||||
const ext = extname(file.originalname).toLowerCase().slice(0, 16);
|
||||
callback(null, `${Date.now()}-${randomUUID()}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: config.uploadMaxBytes, files: 1 },
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [StorageService],
|
||||
exports: [StorageService, MulterModule],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { copyFile, mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import { extname, join, resolve, sep } from 'node:path';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { AssetKind } from '../common/enums';
|
||||
import { AppConfig } from '../config/configuration';
|
||||
import { Asset } from '../entities/asset.entity';
|
||||
|
||||
/** 上传产物落盘后的结果 */
|
||||
export interface StoredAsset {
|
||||
asset: Asset;
|
||||
/** 可直接访问的相对 URL,如 /static/song-audio/2026/09/ab/abcd….mp3 */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function sha256File(path: string): Promise<string> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const hash = createHash('sha256');
|
||||
const stream = createReadStream(path);
|
||||
stream.on('error', reject);
|
||||
stream.on('data', (chunk) => hash.update(chunk));
|
||||
stream.on('end', () => resolvePromise(hash.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件存储服务。
|
||||
*
|
||||
* 约定(contracts/model-formats.md §3):
|
||||
* - 存储路径 `${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}`
|
||||
* - 相同 sha256 复用同一 assets 记录,不重复落盘
|
||||
* - 永不使用用户提供的原始文件名作为路径
|
||||
*/
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private readonly root: string;
|
||||
private readonly tmpDir: string;
|
||||
|
||||
constructor(
|
||||
private readonly config: AppConfig,
|
||||
@InjectRepository(Asset) private readonly assets: Repository<Asset>,
|
||||
) {
|
||||
this.root = resolve(this.config.storageDir);
|
||||
this.tmpDir = join(this.root, 'tmp');
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await mkdir(this.tmpDir, { recursive: true });
|
||||
this.logger.log(`存储根目录:${this.root}`);
|
||||
}
|
||||
|
||||
get temporaryDir(): string {
|
||||
return this.tmpDir;
|
||||
}
|
||||
|
||||
/** 将相对存储路径解析为绝对路径;拒绝逃逸出存储根目录的路径 */
|
||||
absolutePath(asset: Pick<Asset, 'storedPath'>): string {
|
||||
const abs = resolve(this.root, asset.storedPath);
|
||||
if (abs !== this.root && !abs.startsWith(this.root + sep)) {
|
||||
throw new NotFoundException('非法的存储路径');
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
|
||||
/** 资产访问 URL(静态挂载点 /static) */
|
||||
url(asset: Pick<Asset, 'storedPath'> | null | undefined): string | null {
|
||||
if (!asset) return null;
|
||||
return `/static/${asset.storedPath.split(sep).join('/')}`;
|
||||
}
|
||||
|
||||
async findById(id: number | null | undefined): Promise<Asset | null> {
|
||||
if (id === null || id === undefined) return null;
|
||||
return this.assets.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findMany(ids: Array<number | null | undefined>): Promise<Map<number, Asset>> {
|
||||
const wanted = [...new Set(ids.filter((v): v is number => typeof v === 'number' && v > 0))];
|
||||
if (wanted.length === 0) return new Map();
|
||||
const rows = await this.assets.find({ where: { id: In(wanted) } });
|
||||
return new Map(rows.map((row) => [row.id, row]));
|
||||
}
|
||||
|
||||
/** 批量解析 URL,避免 N+1 */
|
||||
async urlMap(ids: Array<number | null | undefined>): Promise<Map<number, string>> {
|
||||
const map = await this.findMany(ids);
|
||||
return new Map([...map.entries()].map(([id, asset]) => [id, this.url(asset) as string]));
|
||||
}
|
||||
|
||||
/** 保存 multer 落盘的临时文件(去重 + 归档),并清理临时文件 */
|
||||
async persistTempFile(
|
||||
kind: AssetKind,
|
||||
tempPath: string,
|
||||
originalName: string,
|
||||
mime: string,
|
||||
): Promise<StoredAsset> {
|
||||
try {
|
||||
const info = await stat(tempPath);
|
||||
if (info.size === 0) throw new NotFoundException('上传文件为空');
|
||||
const sha256 = await sha256File(tempPath);
|
||||
|
||||
const existing = await this.assets.findOne({ where: { sha256 } });
|
||||
if (existing) {
|
||||
await this.safeUnlink(tempPath);
|
||||
return { asset: existing, url: this.url(existing) as string };
|
||||
}
|
||||
|
||||
const ext = extname(originalName).toLowerCase();
|
||||
const now = new Date();
|
||||
const yyyy = String(now.getUTCFullYear());
|
||||
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
const relDir = join(kind, yyyy, mm, sha256.slice(0, 2));
|
||||
const relPath = join(relDir, `${sha256}${ext}`);
|
||||
const absDir = join(this.root, relDir);
|
||||
await mkdir(absDir, { recursive: true });
|
||||
|
||||
const absTarget = join(absDir, `${sha256}${ext}`);
|
||||
try {
|
||||
await rename(tempPath, absTarget);
|
||||
} catch {
|
||||
// 跨设备时 rename 会失败,退化为复制
|
||||
await copyFile(tempPath, absTarget);
|
||||
await this.safeUnlink(tempPath);
|
||||
}
|
||||
|
||||
const entity = this.assets.create({
|
||||
kind,
|
||||
originalName: originalName.slice(0, 255),
|
||||
storedPath: relPath,
|
||||
mime: mime || 'application/octet-stream',
|
||||
size: info.size,
|
||||
sha256,
|
||||
});
|
||||
|
||||
try {
|
||||
const saved = await this.assets.save(entity);
|
||||
return { asset: saved, url: this.url(saved) as string };
|
||||
} catch (error) {
|
||||
// 并发上传同一文件:唯一索引冲突后回退到已存在记录
|
||||
const raced = await this.assets.findOne({ where: { sha256 } });
|
||||
if (raced) {
|
||||
await this.safeUnlink(absTarget);
|
||||
return { asset: raced, url: this.url(raced) as string };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
await this.safeUnlink(tempPath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存内存中的内容(预处理产物等) */
|
||||
async persistBuffer(
|
||||
kind: AssetKind,
|
||||
content: Buffer | string,
|
||||
originalName: string,
|
||||
mime: string,
|
||||
): Promise<StoredAsset> {
|
||||
const sha256 = createHash('sha256').update(content).digest('hex');
|
||||
const existing = await this.assets.findOne({ where: { sha256 } });
|
||||
if (existing) return { asset: existing, url: this.url(existing) as string };
|
||||
|
||||
const ext = extname(originalName).toLowerCase();
|
||||
const now = new Date();
|
||||
const relDir = join(kind, String(now.getUTCFullYear()), String(now.getUTCMonth() + 1).padStart(2, '0'), sha256.slice(0, 2));
|
||||
const relPath = join(relDir, `${sha256}${ext}`);
|
||||
await mkdir(join(this.root, relDir), { recursive: true });
|
||||
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8');
|
||||
await writeFile(join(this.root, relPath), buf);
|
||||
|
||||
const entity = this.assets.create({
|
||||
kind,
|
||||
originalName: originalName.slice(0, 255),
|
||||
storedPath: relPath,
|
||||
mime: mime || 'application/octet-stream',
|
||||
size: buf.byteLength,
|
||||
sha256,
|
||||
});
|
||||
const saved = await this.assets.save(entity);
|
||||
return { asset: saved, url: this.url(saved) as string };
|
||||
}
|
||||
|
||||
/** 读取资产内容(供关键点下载等场景) */
|
||||
async readAssetText(asset: Asset): Promise<string> {
|
||||
const abs = this.absolutePath(asset);
|
||||
const chunks: Buffer[] = [];
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const stream = createReadStream(abs);
|
||||
stream.on('data', (c) => chunks.push(c as Buffer));
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolvePromise());
|
||||
});
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
async stats(): Promise<{ files: number; totalBytes: number }> {
|
||||
const row = await this.assets
|
||||
.createQueryBuilder('a')
|
||||
.select('COUNT(a.id)', 'files')
|
||||
.addSelect('COALESCE(SUM(a.size), 0)', 'totalBytes')
|
||||
.getRawOne<{ files: string; totalBytes: string }>();
|
||||
return {
|
||||
files: Number(row?.files ?? 0),
|
||||
totalBytes: Number(row?.totalBytes ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
private async safeUnlink(path: string): Promise<void> {
|
||||
try {
|
||||
await unlink(path);
|
||||
} catch {
|
||||
/* 忽略:临时文件可能已被移走 */
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user