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
+41
View File
@@ -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 };
}
+111
View File
@@ -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;
}
+56
View File
@@ -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} 不存在`);
}
+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,
}),
);
}
}
+40
View File
@@ -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 {}
+219
View File
@@ -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 {
/* 忽略:临时文件可能已被移走 */
}
}
}