From d9614aadb05fbacc726026528b1989315fb9be76 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Mon, 14 Sep 2026 19:00:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20T-WEB-003=20/api/songs=20CRUD=20+?= =?UTF-8?q?=20=E9=9F=B3=E9=A2=91=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-server/src/common/pagination.ts | 41 ++++ web-server/src/common/source.dto.ts | 111 +++++++++++ web-server/src/common/upload.util.ts | 56 ++++++ web-server/src/songs/song.dto.ts | 136 ++++++++++++++ web-server/src/songs/song.serializer.ts | 54 ++++++ web-server/src/songs/songs.controller.ts | 73 ++++++++ web-server/src/songs/songs.module.ts | 14 ++ web-server/src/songs/songs.service.ts | 163 ++++++++++++++++ web-server/src/storage/storage.module.ts | 40 ++++ web-server/src/storage/storage.service.ts | 219 ++++++++++++++++++++++ 10 files changed, 907 insertions(+) create mode 100644 web-server/src/common/pagination.ts create mode 100644 web-server/src/common/source.dto.ts create mode 100644 web-server/src/common/upload.util.ts create mode 100644 web-server/src/songs/song.dto.ts create mode 100644 web-server/src/songs/song.serializer.ts create mode 100644 web-server/src/songs/songs.controller.ts create mode 100644 web-server/src/songs/songs.module.ts create mode 100644 web-server/src/songs/songs.service.ts create mode 100644 web-server/src/storage/storage.module.ts create mode 100644 web-server/src/storage/storage.service.ts diff --git a/web-server/src/common/pagination.ts b/web-server/src/common/pagination.ts new file mode 100644 index 0000000..7d49076 --- /dev/null +++ b/web-server/src/common/pagination.ts @@ -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 { + items: T[]; + total: number; + page: number; + pageSize: number; +} + +export function toPaged(items: T[], total: number, query: { page: number; pageSize: number }): Paged { + 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 }; +} diff --git a/web-server/src/common/source.dto.ts b/web-server/src/common/source.dto.ts new file mode 100644 index 0000000..36d734b --- /dev/null +++ b/web-server/src/common/source.dto.ts @@ -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): Partial | 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; +} diff --git a/web-server/src/common/upload.util.ts b/web-server/src/common/upload.util.ts new file mode 100644 index 0000000..9d9719c --- /dev/null +++ b/web-server/src/common/upload.util.ts @@ -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, + 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} 不存在`); +} diff --git a/web-server/src/songs/song.dto.ts b/web-server/src/songs/song.dto.ts new file mode 100644 index 0000000..89528bb --- /dev/null +++ b/web-server/src/songs/song.dto.ts @@ -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; +} diff --git a/web-server/src/songs/song.serializer.ts b/web-server/src/songs/song.serializer.ts new file mode 100644 index 0000000..cfa89f3 --- /dev/null +++ b/web-server/src/songs/song.serializer.ts @@ -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(), + }; +} diff --git a/web-server/src/songs/songs.controller.ts b/web-server/src/songs/songs.controller.ts new file mode 100644 index 0000000..4235aa8 --- /dev/null +++ b/web-server/src/songs/songs.controller.ts @@ -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> { + return this.songs.list(query); + } + + @Post() + create(@Body() dto: CreateSongDto): Promise { + return this.songs.create(dto); + } + + @Get(':id') + detail(@Param('id', ParseIntPipe) id: number): Promise { + return this.songs.findOne(id); + } + + @Patch(':id') + update( + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdateSongDto, + ): Promise { + return this.songs.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('id', ParseIntPipe) id: number): Promise { + return this.songs.remove(id); + } + + @Post(':id/audio') + @UseInterceptors(FileInterceptor('file')) + attachAudio( + @Param('id', ParseIntPipe) id: number, + @UploadedFile() file: UploadedFileLike | undefined, + ): Promise { + return this.songs.attachAudio(id, file); + } + + @Post(':id/cover') + @UseInterceptors(FileInterceptor('file')) + attachCover( + @Param('id', ParseIntPipe) id: number, + @UploadedFile() file: UploadedFileLike | undefined, + ): Promise { + return this.songs.attachCover(id, file); + } +} diff --git a/web-server/src/songs/songs.module.ts b/web-server/src/songs/songs.module.ts new file mode 100644 index 0000000..b95e223 --- /dev/null +++ b/web-server/src/songs/songs.module.ts @@ -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 {} diff --git a/web-server/src/songs/songs.service.ts b/web-server/src/songs/songs.service.ts new file mode 100644 index 0000000..9b20d19 --- /dev/null +++ b/web-server/src/songs/songs.service.ts @@ -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, + private readonly storage: StorageService, + ) {} + + async list(query: QuerySongDto): Promise> { + 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 { + const song = await this.getEntity(id); + const [dto] = await this.toDtos([song]); + return dto; + } + + async getEntity(id: number): Promise { + const song = await this.songs.findOne({ where: { id } }); + if (!song) throw new NotFoundException(`曲目 #${id} 不存在`); + return song; + } + + async create(dto: CreateSongDto): Promise { + 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 { + 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 | 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 { + const song = await this.getEntity(id); + await this.songs.softRemove(song); + } + + async attachAudio(id: number, file: UploadedFileLike | undefined): Promise { + 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 { + 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 { + 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, + }), + ); + } +} diff --git a/web-server/src/storage/storage.module.ts b/web-server/src/storage/storage.module.ts new file mode 100644 index 0000000..f68ccd9 --- /dev/null +++ b/web-server/src/storage/storage.module.ts @@ -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 {} diff --git a/web-server/src/storage/storage.service.ts b/web-server/src/storage/storage.service.ts new file mode 100644 index 0000000..f78cf7d --- /dev/null +++ b/web-server/src/storage/storage.service.ts @@ -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 { + 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, + ) { + this.root = resolve(this.config.storageDir); + this.tmpDir = join(this.root, 'tmp'); + } + + async onModuleInit(): Promise { + await mkdir(this.tmpDir, { recursive: true }); + this.logger.log(`存储根目录:${this.root}`); + } + + get temporaryDir(): string { + return this.tmpDir; + } + + /** 将相对存储路径解析为绝对路径;拒绝逃逸出存储根目录的路径 */ + absolutePath(asset: Pick): 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 | null | undefined): string | null { + if (!asset) return null; + return `/static/${asset.storedPath.split(sep).join('/')}`; + } + + async findById(id: number | null | undefined): Promise { + if (id === null || id === undefined) return null; + return this.assets.findOne({ where: { id } }); + } + + async findMany(ids: Array): Promise> { + 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): Promise> { + 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 { + 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 { + 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 { + const abs = this.absolutePath(asset); + const chunks: Buffer[] = []; + await new Promise((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 { + try { + await unlink(path); + } catch { + /* 忽略:临时文件可能已被移走 */ + } + } +}