diff --git a/apps/core/src/common/zod/custom.ts b/apps/core/src/common/zod/custom.ts index df837c98f09..4c9c986adf8 100644 --- a/apps/core/src/common/zod/custom.ts +++ b/apps/core/src/common/zod/custom.ts @@ -1,6 +1,7 @@ -import { normalizeLanguageCode } from '~/utils/lang.util' import { z } from 'zod' +import { normalizeLanguageCode } from '~/utils/lang.util' + export const zBooleanOrString = z.union([z.boolean(), z.string()]) export const zTransformEmptyNull = (schema: T) => diff --git a/apps/core/src/migration/version/v10.0.5.ts b/apps/core/src/migration/version/v10.0.5.ts index e2fda426035..80d9597b98c 100644 --- a/apps/core/src/migration/version/v10.0.5.ts +++ b/apps/core/src/migration/version/v10.0.5.ts @@ -1,5 +1,6 @@ import type { Db } from 'mongodb' import { nanoid } from 'nanoid' + import { defineMigration } from '../helper' const COLLECTIONS = ['posts', 'notes', 'pages'] diff --git a/apps/core/src/modules/aggregate/aggregate.controller.ts b/apps/core/src/modules/aggregate/aggregate.controller.ts index 6477f0821f9..a9bead38044 100644 --- a/apps/core/src/modules/aggregate/aggregate.controller.ts +++ b/apps/core/src/modules/aggregate/aggregate.controller.ts @@ -1,5 +1,7 @@ import { CacheKey, CacheTTL } from '@nestjs/cache-manager' import { Get, Query } from '@nestjs/common' +import { omit } from 'es-toolkit/compat' + import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HttpCache } from '~/common/decorators/cache.decorator' @@ -7,7 +9,7 @@ import { Lang } from '~/common/decorators/lang.decorator' import { IsAuthenticated } from '~/common/decorators/role.decorator' import { CacheKeys } from '~/constants/cache.constant' import { TranslationService } from '~/processors/helper/helper.translation.service' -import { omit } from 'es-toolkit/compat' + import { AnalyzeService } from '../analyze/analyze.service' import { ConfigsService } from '../configs/configs.service' import { NoteService } from '../note/note.service' diff --git a/apps/core/src/modules/aggregate/aggregate.schema.ts b/apps/core/src/modules/aggregate/aggregate.schema.ts index dea68fe0336..585e315e4a3 100644 --- a/apps/core/src/modules/aggregate/aggregate.schema.ts +++ b/apps/core/src/modules/aggregate/aggregate.schema.ts @@ -1,7 +1,8 @@ -import { zCoerceInt, zLang } from '~/common/zod' import { createZodDto } from 'nestjs-zod' import { z } from 'zod' +import { zCoerceInt, zLang } from '~/common/zod' + /** * Top query schema */ diff --git a/apps/core/src/modules/aggregate/aggregate.service.ts b/apps/core/src/modules/aggregate/aggregate.service.ts index 8d7afdfdaaf..308257dd3f7 100644 --- a/apps/core/src/modules/aggregate/aggregate.service.ts +++ b/apps/core/src/modules/aggregate/aggregate.service.ts @@ -1,8 +1,12 @@ import { URL } from 'node:url' + import { forwardRef, Inject, Injectable } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import type { ReturnModelType } from '@typegoose/typegoose' import type { AnyParamConstructor } from '@typegoose/typegoose/lib/types' +import { pick } from 'es-toolkit/compat' +import type { PipelineStage } from 'mongoose' + import { API_CACHE_PREFIX, CacheKeys, @@ -19,8 +23,7 @@ import { RedisService } from '~/processors/redis/redis.service' import { addYearCondition } from '~/transformers/db-query.transformer' import { getRedisKey } from '~/utils/redis.util' import { getShortDate } from '~/utils/time.util' -import { pick } from 'es-toolkit/compat' -import type { PipelineStage } from 'mongoose' + import { AnalyzeService } from '../analyze/analyze.service' import type { CategoryModel } from '../category/category.model' import type { CategoryService } from '../category/category.service' diff --git a/apps/core/src/modules/ai/runtime/anthropic.runtime.ts b/apps/core/src/modules/ai/runtime/anthropic.runtime.ts index 972602ff973..2a845cf60cd 100644 --- a/apps/core/src/modules/ai/runtime/anthropic.runtime.ts +++ b/apps/core/src/modules/ai/runtime/anthropic.runtime.ts @@ -1,6 +1,8 @@ import Anthropic from '@anthropic-ai/sdk' -import { isDev } from '~/global/env.global' import type { z } from 'zod' + +import { isDev } from '~/global/env.global' + import { BaseRuntime } from './base.runtime' import type { GenerateStructuredOptions, diff --git a/apps/core/src/modules/ai/runtime/types.ts b/apps/core/src/modules/ai/runtime/types.ts index 3705d183831..e484931f176 100644 --- a/apps/core/src/modules/ai/runtime/types.ts +++ b/apps/core/src/modules/ai/runtime/types.ts @@ -1,4 +1,5 @@ import type { z } from 'zod' + import type { AIProviderType } from '../ai.types' export interface RuntimeProviderInfo { diff --git a/apps/core/src/modules/configs/configs.default.ts b/apps/core/src/modules/configs/configs.default.ts index 375d15756a0..f45de025e38 100644 --- a/apps/core/src/modules/configs/configs.default.ts +++ b/apps/core/src/modules/configs/configs.default.ts @@ -72,6 +72,11 @@ export const generateDefaultConfig: () => IConfig = () => ({ customDomain: '', prefix: '', }, + fileUploadOptions: { + enableCustomNaming: false, + filenameTemplate: '{Y}{m}{d}/{md5-16}{ext}', + pathTemplate: '{type}', + }, baiduSearchOptions: { enable: false, token: null! }, bingSearchOptions: { enable: false, token: null! }, algoliaSearchOptions: { diff --git a/apps/core/src/modules/configs/configs.interface.ts b/apps/core/src/modules/configs/configs.interface.ts index 6319105ecba..722c1bd0311 100644 --- a/apps/core/src/modules/configs/configs.interface.ts +++ b/apps/core/src/modules/configs/configs.interface.ts @@ -11,6 +11,7 @@ import { type BingSearchOptionsSchema, type CommentOptionsSchema, type FeatureListSchema, + type FileUploadOptionsSchema, type FriendLinkOptionsSchema, type ImageStorageOptionsSchema, type MailOptionsSchema, @@ -39,6 +40,7 @@ export abstract class IConfig { friendLinkOptions: Required> backupOptions: Required> imageStorageOptions: Required> + fileUploadOptions: Required> baiduSearchOptions: Required> bingSearchOptions: Required> algoliaSearchOptions: Required> diff --git a/apps/core/src/modules/configs/configs.schema.ts b/apps/core/src/modules/configs/configs.schema.ts index 4a34629df22..9ce1d1e2265 100644 --- a/apps/core/src/modules/configs/configs.schema.ts +++ b/apps/core/src/modules/configs/configs.schema.ts @@ -147,7 +147,8 @@ export const ImageStorageOptionsSchema = section('图床设置', { }, ), prefix: field.plain(z.string().optional(), '文件路径前缀', { - description: '上传到 S3 的文件路径前缀,例如 images/', + description: + '上传到 S3 的文件路径前缀,支持模板占位符: {Y}年4位, {y}年2位, {m}月, {d}日, {h}时, {i}分, {s}秒, {md5}随机MD5, {type}文件类型等。例如: blog/{Y}/{m}/{d} 或 images/', }), }) export class ImageStorageOptionsDto extends createZodDto( @@ -157,6 +158,29 @@ export type ImageStorageOptionsConfig = z.infer< typeof ImageStorageOptionsSchema > +// ==================== File Upload Options ==================== +export const FileUploadOptionsSchema = section('文件上传设定', { + enableCustomNaming: field.toggle( + z.boolean().optional(), + '启用自定义文件命名', + { + description: '开启后将使用下方的命名模板规则', + }, + ), + filenameTemplate: field.plain(z.string().optional(), '文件名模板', { + description: + '支持占位符: {Y}年4位, {y}年2位, {m}月, {d}日, {h}时, {i}分, {s}秒, {ms}毫秒, {timestamp}时间戳, {md5}随机MD5, {md5-16}随机MD5(16位), {uuid}UUID, {str-数字}随机字符串, {filename}原文件名(含扩展名), {name}原文件名(不含扩展名), {ext}扩展名', + }), + pathTemplate: field.plain(z.string().optional(), '文件路径模板', { + description: + '支持占位符同文件名模板,另外支持 {type} 文件类型, {localFolder:数字} 原文件所在文件夹层级', + }), +}) +export class FileUploadOptionsDto extends createZodDto( + FileUploadOptionsSchema, +) {} +export type FileUploadOptionsConfig = z.infer + // ==================== Baidu Search Options ==================== export const BaiduSearchOptionsSchema = section('百度推送设定', { enable: field.toggle(z.boolean().optional(), '开启推送'), @@ -413,6 +437,7 @@ export const configSchemaMapping = { friendLinkOptions: FriendLinkOptionsSchema, backupOptions: BackupOptionsSchema, imageStorageOptions: ImageStorageOptionsSchema, + fileUploadOptions: FileUploadOptionsSchema, baiduSearchOptions: BaiduSearchOptionsSchema, bingSearchOptions: BingSearchOptionsSchema, algoliaSearchOptions: AlgoliaSearchOptionsSchema, @@ -438,6 +463,7 @@ export const FullConfigSchema = withMeta( friendLinkOptions: FriendLinkOptionsSchema, backupOptions: BackupOptionsSchema, imageStorageOptions: ImageStorageOptionsSchema, + fileUploadOptions: FileUploadOptionsSchema, baiduSearchOptions: BaiduSearchOptionsSchema, bingSearchOptions: BingSearchOptionsSchema, algoliaSearchOptions: AlgoliaSearchOptionsSchema, diff --git a/apps/core/src/modules/cron-task/cron-business.service.ts b/apps/core/src/modules/cron-task/cron-business.service.ts index 9bedcb4050e..b9d0aecddbb 100644 --- a/apps/core/src/modules/cron-task/cron-business.service.ts +++ b/apps/core/src/modules/cron-task/cron-business.service.ts @@ -1,5 +1,9 @@ import { rm } from 'node:fs/promises' + import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common' +import dayjs from 'dayjs' +import { mkdirp } from 'mkdirp' + import { RedisKeys } from '~/constants/cache.constant' import { STATIC_FILE_TRASH_DIR, TEMP_DIR } from '~/constants/path.constant' import { AggregateService } from '~/modules/aggregate/aggregate.service' @@ -12,8 +16,6 @@ import { JWTService } from '~/processors/helper/helper.jwt.service' import { RedisService } from '~/processors/redis/redis.service' import { InjectModel } from '~/transformers/model.transformer' import { getRedisKey } from '~/utils/redis.util' -import dayjs from 'dayjs' -import { mkdirp } from 'mkdirp' /** * CronBusinessService - Cron 任务业务逻辑层 diff --git a/apps/core/src/modules/cron-task/cron-task.scheduler.ts b/apps/core/src/modules/cron-task/cron-task.scheduler.ts index ed2dba9b8ec..db32f2dfeed 100644 --- a/apps/core/src/modules/cron-task/cron-task.scheduler.ts +++ b/apps/core/src/modules/cron-task/cron-task.scheduler.ts @@ -1,6 +1,8 @@ import { Injectable, Logger } from '@nestjs/common' import { CronExpression } from '@nestjs/schedule' + import { CronOnce } from '~/common/decorators/cron-once.decorator' + import { CronTaskService } from './cron-task.service' import { CronTaskType } from './cron-task.types' diff --git a/apps/core/src/modules/file/file-reference.model.ts b/apps/core/src/modules/file/file-reference.model.ts index 5bd853981f3..fa2b8213a66 100644 --- a/apps/core/src/modules/file/file-reference.model.ts +++ b/apps/core/src/modules/file/file-reference.model.ts @@ -1,4 +1,5 @@ import { index, modelOptions, prop, Severity } from '@typegoose/typegoose' + import { FILE_REFERENCE_COLLECTION_NAME } from '~/constants/db.constant' import { BaseModel } from '~/shared/model/base.model' diff --git a/apps/core/src/modules/file/file.controller.ts b/apps/core/src/modules/file/file.controller.ts index 15a55f522ae..42bf508f9a4 100644 --- a/apps/core/src/modules/file/file.controller.ts +++ b/apps/core/src/modules/file/file.controller.ts @@ -16,7 +16,6 @@ import { import { Throttle } from '@nestjs/throttler' import type { FastifyReply, FastifyRequest } from 'fastify' import { lookup } from 'mime-types' -import { customAlphabet } from 'nanoid' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' @@ -24,11 +23,15 @@ import { HTTPDecorators } from '~/common/decorators/http.decorator' import { BizException } from '~/common/exceptions/biz.exception' import { CannotFindException } from '~/common/exceptions/cant-find.exception' import { ErrorCodeEnum } from '~/constants/error-code.constant' -import { alphabet } from '~/constants/other.constant' import { STATIC_FILE_DIR } from '~/constants/path.constant' import { ConfigsService } from '~/modules/configs/configs.service' import { UploadService } from '~/processors/helper/helper.upload.service' import { PagerDto } from '~/shared/dto/pager.dto' +import { + generateFilename, + generateFilePath, + replaceFilenameTemplate, +} from '~/utils/filename-template.util' import { S3Uploader } from '~/utils/s3.util' import { @@ -157,6 +160,9 @@ export class FileController { async upload(@Query() query: FileUploadDto, @Req() req: FastifyRequest) { const { type = 'file' } = query + // 获取文件上传配置 + const uploadConfig = await this.configsService.get('fileUploadOptions') + if (type === 'image') { const config = await this.configsService.get('imageStorageOptions') if ( @@ -173,11 +179,23 @@ export class FileController { maxFileSize: 20 * 1024 * 1024, }) - const ext = path.extname(file.filename) - const filename = customAlphabet(alphabet)(18) + ext.toLowerCase() - const objectKey = config.prefix - ? `${config.prefix.replace(/\/+$/, '')}/${filename}` - : filename + // 生成文件名(支持模板或默认随机名) + const filename = generateFilename(uploadConfig, { + originalFilename: file.filename, + fileType: type, + }) + + // 处理 prefix 中的模板变量 + let prefixPath = '' + if (config.prefix) { + prefixPath = replaceFilenameTemplate(config.prefix, { + originalFilename: file.filename, + fileType: 'image', + }) + prefixPath = prefixPath.replace(/\/+$/, '') + } + + const objectKey = prefixPath ? `${prefixPath}/${filename}` : filename const chunks: Buffer[] = [] for await (const chunk of file.file) { @@ -213,13 +231,34 @@ export class FileController { } const file = await this.uploadService.getAndValidMultipartField(req) - const ext = path.extname(file.filename) - const filename = customAlphabet(alphabet)(18) + ext.toLowerCase() - await this.service.writeFile(type, filename, file.file) - const fileUrl = await this.service.resolveFileUrl(type, filename) + // 生成文件名(可能包含子路径) + const rawFilename = generateFilename(uploadConfig, { + originalFilename: file.filename, + fileType: type, + }) + + // 生成基础路径 + const basePath = generateFilePath(uploadConfig, { + originalFilename: file.filename, + fileType: type, + }) + + // 构建相对路径(相对于文件类型目录) + let relativePath: string + if (basePath === type || !basePath) { + relativePath = rawFilename + } else { + const pathWithoutType = basePath.startsWith(`${type}/`) + ? basePath.slice(Math.max(0, type.length + 1)) + : basePath + relativePath = path.join(pathWithoutType, rawFilename) + } + + await this.service.writeFile(type, relativePath, file.file) + const fileUrl = await this.service.resolveFileUrl(type, relativePath) - return { url: fileUrl, name: filename } + return { url: fileUrl, name: path.basename(relativePath) } } @Put('/:type/:name') diff --git a/apps/core/src/modules/file/file.schema.ts b/apps/core/src/modules/file/file.schema.ts index 6d0abdd3e03..48f2803dd6d 100644 --- a/apps/core/src/modules/file/file.schema.ts +++ b/apps/core/src/modules/file/file.schema.ts @@ -1,5 +1,6 @@ import { createZodDto } from 'nestjs-zod' import { z } from 'zod' + import { FileTypeEnum } from './file.type' /** diff --git a/apps/core/src/modules/helper/helper.controller.ts b/apps/core/src/modules/helper/helper.controller.ts index 972cf4aa916..c45a706abf2 100644 --- a/apps/core/src/modules/helper/helper.controller.ts +++ b/apps/core/src/modules/helper/helper.controller.ts @@ -1,5 +1,7 @@ import { Get, Param, Post, Query, Res } from '@nestjs/common' import { ModuleRef } from '@nestjs/core' +import type { FastifyReply } from 'fastify' + import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { BizException } from '~/common/exceptions/biz.exception' @@ -11,7 +13,7 @@ import { UrlBuilderService } from '~/processors/helper/helper.url-builder.servic import { MongoIdDto } from '~/shared/dto/id.dto' import { isLexical } from '~/utils/content.util' import { AsyncQueue } from '~/utils/queue.util' -import type { FastifyReply } from 'fastify' + import { NoteService } from '../note/note.service' import { PageService } from '../page/page.service' import { PostService } from '../post/post.service' diff --git a/apps/core/src/modules/note/note.schema.ts b/apps/core/src/modules/note/note.schema.ts index 7c260e77295..0299abcd857 100644 --- a/apps/core/src/modules/note/note.schema.ts +++ b/apps/core/src/modules/note/note.schema.ts @@ -1,3 +1,6 @@ +import { createZodDto } from 'nestjs-zod' +import { z } from 'zod' + import { zCoerceBoolean, zCoerceInt, @@ -10,8 +13,6 @@ import { import { PagerSchema } from '~/shared/dto/pager.dto' import { WriteBaseSchema } from '~/shared/schema' import { ImageSchema } from '~/shared/schema/image.schema' -import { createZodDto } from 'nestjs-zod' -import { z } from 'zod' /** * Coordinate schema diff --git a/apps/core/src/modules/page/page.schema.ts b/apps/core/src/modules/page/page.schema.ts index 164817bd73e..c27703f0ee6 100644 --- a/apps/core/src/modules/page/page.schema.ts +++ b/apps/core/src/modules/page/page.schema.ts @@ -1,8 +1,9 @@ +import { createZodDto } from 'nestjs-zod' +import { z } from 'zod' + import { zCoerceInt, zMongoId, zNonEmptyString, zPrefer } from '~/common/zod' import { WriteBaseSchema } from '~/shared/schema' import { ImageSchema } from '~/shared/schema/image.schema' -import { createZodDto } from 'nestjs-zod' -import { z } from 'zod' /** * Page schema for API validation diff --git a/apps/core/src/modules/post/post.schema.ts b/apps/core/src/modules/post/post.schema.ts index b68529643ef..9a9b7754880 100644 --- a/apps/core/src/modules/post/post.schema.ts +++ b/apps/core/src/modules/post/post.schema.ts @@ -1,3 +1,6 @@ +import { createZodDto } from 'nestjs-zod' +import { z } from 'zod' + import { zArrayUnique, zCoerceInt, @@ -10,8 +13,6 @@ import { import { PagerSchema } from '~/shared/dto/pager.dto' import { WriteBaseSchema } from '~/shared/schema' import { ImageSchema } from '~/shared/schema/image.schema' -import { createZodDto } from 'nestjs-zod' -import { z } from 'zod' /** * Post schema for API validation diff --git a/apps/core/src/utils/filename-template.util.ts b/apps/core/src/utils/filename-template.util.ts new file mode 100644 index 00000000000..2c4531776c9 --- /dev/null +++ b/apps/core/src/utils/filename-template.util.ts @@ -0,0 +1,189 @@ +import crypto from 'node:crypto' +import path from 'node:path' +import { alphabet } from '~/constants/other.constant' +import { customAlphabet } from 'nanoid' + +/** + * 文件名模板占位符替换工具 + * 支持的占位符: + * - {Y} 年份 (4位) + * - {y} 年份 (2位) + * - {m} 月份 (2位) + * - {d} 日期 (2位) + * - {h} 小时 (2位) + * - {i} 分钟 (2位) + * - {s} 秒钟 (2位) + * - {ms} 毫秒 (3位) + * - {timestamp} 时间戳 (毫秒) + * - {md5} 随机MD5字符串 (32位) + * - {md5-16} 随机MD5字符串 (16位) + * - {uuid} UUID字符串 + * - {str-数字} 随机字符串,数字表示长度 + * - {filename} 原文件名 (包含扩展名) + * - {name} 原文件名 (不含扩展名) + * - {ext} 扩展名 (包含点号) + * - {type} 文件类型 + * - {localFolder:数字} 原文件所在文件夹 (数字表示层级) + */ +export interface FilenameTemplateContext { + /** + * 原始文件名 (包含扩展名) + */ + originalFilename: string + + /** + * 文件类型 (如: image, file, avatar, icon) + */ + fileType?: string + + /** + * 本地文件夹路径 (用于 localFolder 占位符) + */ + localFolderPath?: string +} + +/** + * 生成一个随机的 MD5 字符串 + */ +function generateRandomMd5(): string { + return crypto.randomBytes(16).toString('hex') +} + +/** + * 生成一个 UUID v4 字符串 + */ +function generateUuid(): string { + return crypto.randomUUID() +} + +/** + * 格式化数字,补零到指定位数 + */ +function padZero(num: number, length: number): string { + return num.toString().padStart(length, '0') +} + +/** + * 提取文件名中的文件夹层级 + * @param folderPath 文件夹路径 + * @param level 提取的层级数 + */ +function extractFolderLevel( + folderPath: string | undefined, + level: number, +): string { + if (!folderPath) return '' + + const parts = folderPath.split(/[/\\]/).filter(Boolean) + if (level <= 0 || level > parts.length) return '' + + return parts.slice(-level).join('/') +} + +/** + * 替换模板中的占位符 + * @param template 模板字符串 + * @param context 上下文信息 + * @returns 替换后的字符串 + */ +export function replaceFilenameTemplate( + template: string, + context: FilenameTemplateContext, +): string { + const now = new Date() + const { originalFilename, fileType = '', localFolderPath } = context + + // 提取文件名和扩展名 + const ext = path.extname(originalFilename).toLowerCase() + const nameWithoutExt = path.basename(originalFilename, ext) + + let result = template + + // 时间相关占位符 + result = result.replaceAll('{Y}', now.getFullYear().toString()) + result = result.replaceAll('{y}', padZero(now.getFullYear() % 100, 2)) + result = result.replaceAll('{m}', padZero(now.getMonth() + 1, 2)) + result = result.replaceAll('{d}', padZero(now.getDate(), 2)) + result = result.replaceAll('{h}', padZero(now.getHours(), 2)) + result = result.replaceAll('{i}', padZero(now.getMinutes(), 2)) + result = result.replaceAll('{s}', padZero(now.getSeconds(), 2)) + result = result.replaceAll('{ms}', padZero(now.getMilliseconds(), 3)) + result = result.replaceAll('{timestamp}', now.getTime().toString()) + + // 随机字符串占位符 + result = result.replaceAll('{md5}', () => generateRandomMd5()) + result = result.replaceAll('{md5-16}', () => generateRandomMd5().slice(0, 16)) + result = result.replaceAll('{uuid}', () => generateUuid()) + + // 自定义长度的随机字符串 {str-数字} + // eslint-disable-next-line unicorn/better-regex + result = result.replaceAll(/\{str-(\d+)\}/g, (_match, length) => { + const len = Number.parseInt(length, 10) + return customAlphabet(alphabet)(len) + }) + + // 文件名相关占位符 + result = result.replaceAll('{filename}', originalFilename) + result = result.replaceAll('{name}', nameWithoutExt) + result = result.replaceAll('{ext}', ext) + + // 文件类型占位符 + result = result.replaceAll('{type}', fileType) + + // 本地文件夹占位符 {localFolder:数字} + // eslint-disable-next-line unicorn/better-regex + result = result.replaceAll(/\{localFolder:(\d+)\}/g, (_match, level) => { + const lvl = Number.parseInt(level, 10) + return extractFolderLevel(localFolderPath, lvl) + }) + + // 防止路径遍历:移除父目录引用(..) + const segments = result.split(/[/\\]+/) + const safeSegments = segments.filter(segment => segment !== '..') + const safeResult = safeSegments.join('/') + + return safeResult +} + +/** + * 生成文件名(应用模板或使用默认规则) + * @param config 配置对象 + * @param context 上下文信息 + * @returns 生成的文件名 + */ +export function generateFilename( + config: { + enableCustomNaming?: boolean + filenameTemplate?: string + }, + context: FilenameTemplateContext, +): string { + // 如果未启用自定义命名或没有模板,使用默认规则 + if (!config.enableCustomNaming || !config.filenameTemplate) { + const ext = path.extname(context.originalFilename).toLowerCase() + return customAlphabet(alphabet)(18) + ext + } + + return replaceFilenameTemplate(config.filenameTemplate, context) +} + +/** + * 生成文件路径(应用模板或使用默认规则) + * @param config 配置对象 + * @param context 上下文信息 + * @returns 生成的路径 + */ +export function generateFilePath( + config: { + enableCustomNaming?: boolean + pathTemplate?: string + }, + context: FilenameTemplateContext, +): string { + // 如果未启用自定义命名或没有路径模板,使用默认规则(文件类型) + if (!config.enableCustomNaming || !config.pathTemplate) { + return context.fileType || '' + } + + return replaceFilenameTemplate(config.pathTemplate, context) +} diff --git a/apps/core/src/utils/s3.util.spec.ts b/apps/core/src/utils/s3.util.spec.ts index 426b7711e26..c6f61663a16 100644 --- a/apps/core/src/utils/s3.util.spec.ts +++ b/apps/core/src/utils/s3.util.spec.ts @@ -1,9 +1,9 @@ import * as crypto from 'node:crypto' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { S3UploaderOptions } from './s3.util' -import { S3Uploader } from './s3.util' +import type { S3EndpointStrategy, S3UploaderOptions } from './s3.util' +import { DefaultS3Strategy, S3Uploader, TencentCosStrategy } from './s3.util' // Mock fetch global.fetch = vi.fn() @@ -162,4 +162,123 @@ describe('S3Uploader', () => { ) }) }) + + describe('endpoint strategies', () => { + afterEach(() => { + S3Uploader.resetStrategies() + }) + + describe('TencentCosStrategy', () => { + const strategy = new TencentCosStrategy() + + it('should match myqcloud.com hosts', () => { + expect(strategy.matches('cos.ap-guangzhou.myqcloud.com')).toBe(true) + }) + + it('should match hosts containing .cos.', () => { + expect(strategy.matches('bucket.cos.ap-guangzhou.myqcloud.com')).toBe( + true, + ) + }) + + it('should not match unrelated hosts', () => { + expect(strategy.matches('s3.amazonaws.com')).toBe(false) + }) + + it('should resolve virtual-hosted style for cos.* hosts', () => { + const result = strategy.resolve({ + host: 'cos.ap-guangzhou.myqcloud.com', + bucket: 'my-bucket', + encodedObjectKey: 'path/to/file.png', + protocol: 'https:', + }) + expect(result.requestHost).toBe( + 'my-bucket.cos.ap-guangzhou.myqcloud.com', + ) + expect(result.canonicalUri).toBe('/path/to/file.png') + expect(result.baseUrl).toBe( + 'https://my-bucket.cos.ap-guangzhou.myqcloud.com', + ) + }) + }) + + describe('DefaultS3Strategy', () => { + const strategy = new DefaultS3Strategy() + + it('should match any host', () => { + expect(strategy.matches('anything.example.com')).toBe(true) + }) + + it('should use path style when host does not start with bucket', () => { + const result = strategy.resolve({ + host: 's3.us-east-1.amazonaws.com', + bucket: 'my-bucket', + encodedObjectKey: 'file.txt', + protocol: 'https:', + }) + expect(result.canonicalUri).toBe('/my-bucket/file.txt') + expect(result.requestHost).toBe('s3.us-east-1.amazonaws.com') + }) + + it('should use virtual-hosted style when host starts with bucket', () => { + const result = strategy.resolve({ + host: 'my-bucket.s3.us-east-1.amazonaws.com', + bucket: 'my-bucket', + encodedObjectKey: 'file.txt', + protocol: 'https:', + }) + expect(result.canonicalUri).toBe('/file.txt') + }) + }) + + describe('custom strategy registration', () => { + it('should use a registered custom strategy when it matches', async () => { + const customStrategy: S3EndpointStrategy = { + name: 'CustomProvider', + matches: (host) => host.includes('custom-storage.example.com'), + resolve: (ctx) => ({ + requestHost: `${ctx.bucket}.custom-storage.example.com`, + canonicalUri: `/${ctx.encodedObjectKey}`, + baseUrl: `${ctx.protocol}//${ctx.bucket}.custom-storage.example.com`, + }), + } + + S3Uploader.registerStrategy(customStrategy) + + const customUploader = new S3Uploader({ + bucket: 'test-bucket', + region: 'us-east-1', + accessKey: 'key', + secretKey: 'secret', + endpoint: 'https://custom-storage.example.com', + }) + + await customUploader.uploadToS3('obj', mockBuffer, 'text/plain') + + expect(fetch).toHaveBeenCalledWith( + 'https://test-bucket.custom-storage.example.com/obj', + expect.objectContaining({ method: 'PUT' }), + ) + }) + + it('should fall back to default strategy when no custom matches', async () => { + const neverMatchStrategy: S3EndpointStrategy = { + name: 'NeverMatch', + matches: () => false, + resolve: () => { + throw new Error('Should not be called') + }, + } + + S3Uploader.registerStrategy(neverMatchStrategy) + + await uploader.uploadToS3('obj', mockBuffer, 'text/plain') + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/test-bucket/obj'), + expect.objectContaining({ method: 'PUT' }), + ) + }) + }) + }) }) diff --git a/apps/core/src/utils/s3.util.ts b/apps/core/src/utils/s3.util.ts index 615c98bfd3a..79d2d6c67b3 100644 --- a/apps/core/src/utils/s3.util.ts +++ b/apps/core/src/utils/s3.util.ts @@ -10,10 +10,148 @@ export interface S3UploaderOptions { endpoint?: string } +/** + * Resolved endpoint information used for signing and sending S3 requests. + */ +export interface S3ResolvedEndpoint { + /** The Host header value */ + requestHost: string + /** The canonical URI used for AWS Signature V4 signing */ + canonicalUri: string + /** The base URL (scheme + host) for the final HTTP request */ + baseUrl: string +} + +/** + * Extensible strategy interface for resolving S3-compatible endpoint styles. + * + * Implement this interface to add support for additional S3-compatible storage + * providers that require custom host / URI resolution (e.g. virtual-hosted + * style, path style, or provider-specific conventions). + */ +export interface S3EndpointStrategy { + /** Human-readable name for debugging / logging */ + readonly name: string + + /** + * Return `true` when this strategy should handle the given host. + * Strategies are evaluated in registration order; the first match wins. + */ + matches: (host: string) => boolean + + /** + * Resolve the request host, canonical URI, and base URL for the given + * endpoint information. + */ + resolve: (ctx: { + host: string + bucket: string + encodedObjectKey: string + protocol: string + }) => S3ResolvedEndpoint +} + +// --------------------------------------------------------------------------- +// Built-in strategies +// --------------------------------------------------------------------------- + +/** + * Strategy for Tencent Cloud COS. + * + * Converts `cos..myqcloud.com` → `.cos..myqcloud.com` + * (virtual-hosted style) and uses `/` as the canonical URI. + */ +export class TencentCosStrategy implements S3EndpointStrategy { + readonly name = 'TencentCOS' + + matches(host: string): boolean { + return host.includes('myqcloud.com') || host.includes('.cos.') + } + + resolve(ctx: { + host: string + bucket: string + encodedObjectKey: string + protocol: string + }): S3ResolvedEndpoint { + let requestHost = ctx.host + const cosMatch = ctx.host.match(/^cos\.(.+)$/) + if (cosMatch) { + requestHost = `${ctx.bucket}.cos.${cosMatch[1]}` + } + return { + requestHost, + canonicalUri: `/${ctx.encodedObjectKey}`, + baseUrl: `${ctx.protocol}//${requestHost}`, + } + } +} + +/** + * Default strategy for AWS S3 and most S3-compatible services. + * + * - If the host already starts with `.`, it assumes virtual-hosted + * style and uses `/` as the canonical URI. + * - Otherwise it falls back to path style: `//`. + */ +export class DefaultS3Strategy implements S3EndpointStrategy { + readonly name = 'DefaultS3' + + /** Always matches – used as the fallback strategy. */ + matches(_host: string): boolean { + return true + } + + resolve(ctx: { + host: string + bucket: string + encodedObjectKey: string + protocol: string + }): S3ResolvedEndpoint { + const isVirtualHosted = ctx.host.startsWith(`${ctx.bucket}.`) + const canonicalUri = isVirtualHosted + ? `/${ctx.encodedObjectKey}` + : `/${ctx.bucket}/${ctx.encodedObjectKey}` + return { + requestHost: ctx.host, + canonicalUri, + baseUrl: `${ctx.protocol}//${ctx.host}`, + } + } +} + export class S3Uploader { private options: S3UploaderOptions private customDomain: string = '' + /** + * Ordered list of endpoint strategies. The first strategy whose `matches()` + * returns `true` is used. The {@link DefaultS3Strategy} is always appended + * as a fallback. + */ + private static globalStrategies: S3EndpointStrategy[] = [ + new TencentCosStrategy(), + ] + + /** + * Register a custom endpoint strategy. Strategies registered earlier take + * precedence. The built-in {@link DefaultS3Strategy} is always evaluated + * last, so you do not need to worry about ordering relative to it. + */ + static registerStrategy(strategy: S3EndpointStrategy): void { + S3Uploader.globalStrategies.push(strategy) + } + + /** + * Remove all custom strategies and reset to defaults. + * Useful in tests. + */ + static resetStrategies(): void { + S3Uploader.globalStrategies = [new TencentCosStrategy()] + } + + private static readonly defaultStrategy = new DefaultS3Strategy() + constructor(options: S3UploaderOptions) { this.options = options } @@ -54,6 +192,26 @@ export class S3Uploader { return crypto.createHmac('sha256', key).update(message).digest() } + /** + * Walk the strategy chain and return the first matching result. + */ + private resolveEndpoint( + host: string, + encodedObjectKey: string, + protocol: string, + ): S3ResolvedEndpoint { + const ctx = { host, bucket: this.bucket, encodedObjectKey, protocol } + + for (const strategy of S3Uploader.globalStrategies) { + if (strategy.matches(host)) { + return strategy.resolve(ctx) + } + } + + // Fallback – always matches + return S3Uploader.defaultStrategy.resolve(ctx) + } + async uploadImage(imageData: Buffer, path: string): Promise { const md5Filename = crypto.createHash('md5').update(imageData).digest('hex') const objectKey = `${path}/${md5Filename}.png` @@ -209,17 +367,21 @@ export class S3Uploader { // Set request headers const url = new URL(this.endpoint) const host = url.host - const contentLength = fileData.length.toString() // URI encode each path segment for signing const encodedObjectKey = objectKey .split('/') .map((seg) => encodeURIComponent(seg)) .join('/') - const canonicalUri = `/${this.bucket}/${encodedObjectKey}` + + // Resolve endpoint using the extensible strategy chain + const resolved = this.resolveEndpoint(host, encodedObjectKey, url.protocol) + const { requestHost, canonicalUri } = resolved + + const contentLength = fileData.length.toString() const headers: Record = { - Host: host, + Host: requestHost, 'Content-Type': contentType, 'Content-Length': contentLength, 'x-amz-date': xAmzDate, @@ -271,7 +433,7 @@ export class S3Uploader { const authorization = `${algorithm} Credential=${this.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}` // Create and send PUT request - const requestUrl = `${this.endpoint}${canonicalUri}` + const requestUrl = `${resolved.baseUrl}${canonicalUri}` const fetchOptions: RequestInit & { dispatcher?: unknown } = { method: 'PUT', @@ -292,7 +454,10 @@ export class S3Uploader { const response = await fetch(requestUrl, fetchOptions as RequestInit) if (!response.ok) { - throw new Error(`Upload failed with status code: ${response.status}`) + const responseText = await response.text() + throw new Error( + `Upload failed with status code: ${response.status} - ${responseText}`, + ) } } finally { if (isDev) { diff --git a/apps/core/test/mock/processors/file.mock.ts b/apps/core/test/mock/processors/file.mock.ts index 463fc3e8d87..436f1b2c262 100644 --- a/apps/core/test/mock/processors/file.mock.ts +++ b/apps/core/test/mock/processors/file.mock.ts @@ -1,6 +1,7 @@ +import { defineProvider } from 'test/helper/defineProvider' + import { FileReferenceService } from '~/modules/file/file-reference.service' import { ImageService } from '~/processors/helper/helper.image.service' -import { defineProvider } from 'test/helper/defineProvider' export const fileReferenceProvider = defineProvider({ provide: FileReferenceService, diff --git a/apps/core/test/src/modules/note/note.controller.e2e-spec.ts b/apps/core/test/src/modules/note/note.controller.e2e-spec.ts index 84fe6afd526..fe5fd90a3bf 100644 --- a/apps/core/test/src/modules/note/note.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/note/note.controller.e2e-spec.ts @@ -1,16 +1,4 @@ -import { createRedisProvider } from '@/mock/modules/redis.mock' import { APP_INTERCEPTOR } from '@nestjs/core' -import { apiRoutePrefix } from '~/common/decorators/api-controller.decorator' -import { OptionModel } from '~/modules/configs/configs.model' -import { DraftHistoryService } from '~/modules/draft/draft-history.service' -import { DraftModel } from '~/modules/draft/draft.model' -import { DraftService } from '~/modules/draft/draft.service' -import { NoteController } from '~/modules/note/note.controller' -import { NoteModel } from '~/modules/note/note.model' -import { NoteService } from '~/modules/note/note.service' -import { HttpService } from '~/processors/helper/helper.http.service' -import { ImageService } from '~/processors/helper/helper.image.service' -import { LexicalService } from '~/processors/helper/helper.lexical.service' import { createE2EApp } from 'test/helper/create-e2e-app' import { authPassHeader } from 'test/mock/guard/auth.guard' import { MockingCountingInterceptor } from 'test/mock/interceptors/counting.interceptor' @@ -22,6 +10,20 @@ import { countingServiceProvider } from 'test/mock/processors/counting.mock' import { eventEmitterProvider } from 'test/mock/processors/event.mock' import { fileReferenceProvider } from 'test/mock/processors/file.mock' import { translationProvider } from 'test/mock/processors/translation.mock' + +import { createRedisProvider } from '@/mock/modules/redis.mock' +import { apiRoutePrefix } from '~/common/decorators/api-controller.decorator' +import { OptionModel } from '~/modules/configs/configs.model' +import { DraftModel } from '~/modules/draft/draft.model' +import { DraftService } from '~/modules/draft/draft.service' +import { DraftHistoryService } from '~/modules/draft/draft-history.service' +import { NoteController } from '~/modules/note/note.controller' +import { NoteModel } from '~/modules/note/note.model' +import { NoteService } from '~/modules/note/note.service' +import { HttpService } from '~/processors/helper/helper.http.service' +import { ImageService } from '~/processors/helper/helper.image.service' +import { LexicalService } from '~/processors/helper/helper.lexical.service' + import MockDbData from './note.e2e-mock.db' describe('NoteController (e2e)', async () => { diff --git a/apps/core/test/src/modules/note/note.service.spec.ts b/apps/core/test/src/modules/note/note.service.spec.ts index fdb0e834fc6..276aadf81cc 100644 --- a/apps/core/test/src/modules/note/note.service.spec.ts +++ b/apps/core/test/src/modules/note/note.service.spec.ts @@ -1,4 +1,14 @@ import { Test } from '@nestjs/testing' +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest' + import { CannotFindException } from '~/common/exceptions/cant-find.exception' import { CommentService } from '~/modules/comment/comment.service' import { DraftService } from '~/modules/draft/draft.service' @@ -10,15 +20,6 @@ import { EventManagerService } from '~/processors/helper/helper.event.service' import { ImageService } from '~/processors/helper/helper.image.service' import { LexicalService } from '~/processors/helper/helper.lexical.service' import { getModelToken } from '~/transformers/model.transformer' -import { - afterEach, - beforeEach, - describe, - expect, - it, - vi, - type Mock, -} from 'vitest' describe('NoteService', () => { let noteService: NoteService diff --git a/apps/core/test/src/modules/post/post-content-format.spec.ts b/apps/core/test/src/modules/post/post-content-format.spec.ts index 9d34f2b82cf..308adaaba3e 100644 --- a/apps/core/test/src/modules/post/post-content-format.spec.ts +++ b/apps/core/test/src/modules/post/post-content-format.spec.ts @@ -1,5 +1,20 @@ -import { createRedisProvider } from '@/mock/modules/redis.mock' import { APP_INTERCEPTOR } from '@nestjs/core' +import { createE2EApp } from 'test/helper/create-e2e-app' +import { authPassHeader } from 'test/mock/guard/auth.guard' +import { MockingCountingInterceptor } from 'test/mock/interceptors/counting.interceptor' +import { authProvider } from 'test/mock/modules/auth.mock' +import { commentProvider } from 'test/mock/modules/comment.mock' +import { configProvider } from 'test/mock/modules/config.mock' +import { gatewayProviders } from 'test/mock/modules/gateway.mock' +import { countingServiceProvider } from 'test/mock/processors/counting.mock' +import { eventEmitterProvider } from 'test/mock/processors/event.mock' +import { + fileReferenceProvider, + imageServiceProvider, +} from 'test/mock/processors/file.mock' +import { translationProvider } from 'test/mock/processors/translation.mock' + +import { createRedisProvider } from '@/mock/modules/redis.mock' import { apiRoutePrefix } from '~/common/decorators/api-controller.decorator' import { CATEGORY_SERVICE_TOKEN, @@ -10,9 +25,9 @@ import { CategoryModel } from '~/modules/category/category.model' import { CategoryService } from '~/modules/category/category.service' import { CommentModel } from '~/modules/comment/comment.model' import { OptionModel } from '~/modules/configs/configs.model' -import { DraftHistoryService } from '~/modules/draft/draft-history.service' import { DraftModel } from '~/modules/draft/draft.model' import { DraftService } from '~/modules/draft/draft.service' +import { DraftHistoryService } from '~/modules/draft/draft-history.service' import { PostController } from '~/modules/post/post.controller' import { PostModel } from '~/modules/post/post.model' import { PostService } from '~/modules/post/post.service' @@ -21,20 +36,6 @@ import { SlugTrackerService } from '~/modules/slug-tracker/slug-tracker.service' import { HttpService } from '~/processors/helper/helper.http.service' import { LexicalService } from '~/processors/helper/helper.lexical.service' import { ContentFormat } from '~/shared/types/content-format.type' -import { createE2EApp } from 'test/helper/create-e2e-app' -import { authPassHeader } from 'test/mock/guard/auth.guard' -import { MockingCountingInterceptor } from 'test/mock/interceptors/counting.interceptor' -import { authProvider } from 'test/mock/modules/auth.mock' -import { commentProvider } from 'test/mock/modules/comment.mock' -import { configProvider } from 'test/mock/modules/config.mock' -import { gatewayProviders } from 'test/mock/modules/gateway.mock' -import { countingServiceProvider } from 'test/mock/processors/counting.mock' -import { eventEmitterProvider } from 'test/mock/processors/event.mock' -import { - fileReferenceProvider, - imageServiceProvider, -} from 'test/mock/processors/file.mock' -import { translationProvider } from 'test/mock/processors/translation.mock' describe('Post ContentFormat (e2e)', async () => { let categoryId: string diff --git a/apps/core/test/src/modules/post/post.controller.e2e-spec.ts b/apps/core/test/src/modules/post/post.controller.e2e-spec.ts index 38340b1c46b..877bc4c79b4 100644 --- a/apps/core/test/src/modules/post/post.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/post/post.controller.e2e-spec.ts @@ -1,5 +1,20 @@ -import { createRedisProvider } from '@/mock/modules/redis.mock' import { APP_INTERCEPTOR } from '@nestjs/core' +import { createE2EApp } from 'test/helper/create-e2e-app' +import { authPassHeader } from 'test/mock/guard/auth.guard' +import { MockingCountingInterceptor } from 'test/mock/interceptors/counting.interceptor' +import { authProvider } from 'test/mock/modules/auth.mock' +import { commentProvider } from 'test/mock/modules/comment.mock' +import { configProvider } from 'test/mock/modules/config.mock' +import { gatewayProviders } from 'test/mock/modules/gateway.mock' +import { countingServiceProvider } from 'test/mock/processors/counting.mock' +import { eventEmitterProvider } from 'test/mock/processors/event.mock' +import { + fileReferenceProvider, + imageServiceProvider, +} from 'test/mock/processors/file.mock' +import { translationProvider } from 'test/mock/processors/translation.mock' + +import { createRedisProvider } from '@/mock/modules/redis.mock' import { apiRoutePrefix } from '~/common/decorators/api-controller.decorator' import { CATEGORY_SERVICE_TOKEN, @@ -10,9 +25,9 @@ import { CategoryModel } from '~/modules/category/category.model' import { CategoryService } from '~/modules/category/category.service' import { CommentModel } from '~/modules/comment/comment.model' import { OptionModel } from '~/modules/configs/configs.model' -import { DraftHistoryService } from '~/modules/draft/draft-history.service' import { DraftModel } from '~/modules/draft/draft.model' import { DraftService } from '~/modules/draft/draft.service' +import { DraftHistoryService } from '~/modules/draft/draft-history.service' import { PostController } from '~/modules/post/post.controller' import { PostModel } from '~/modules/post/post.model' import { PostService } from '~/modules/post/post.service' @@ -20,20 +35,7 @@ import { SlugTrackerModel } from '~/modules/slug-tracker/slug-tracker.model' import { SlugTrackerService } from '~/modules/slug-tracker/slug-tracker.service' import { HttpService } from '~/processors/helper/helper.http.service' import { LexicalService } from '~/processors/helper/helper.lexical.service' -import { createE2EApp } from 'test/helper/create-e2e-app' -import { authPassHeader } from 'test/mock/guard/auth.guard' -import { MockingCountingInterceptor } from 'test/mock/interceptors/counting.interceptor' -import { authProvider } from 'test/mock/modules/auth.mock' -import { commentProvider } from 'test/mock/modules/comment.mock' -import { configProvider } from 'test/mock/modules/config.mock' -import { gatewayProviders } from 'test/mock/modules/gateway.mock' -import { countingServiceProvider } from 'test/mock/processors/counting.mock' -import { eventEmitterProvider } from 'test/mock/processors/event.mock' -import { - fileReferenceProvider, - imageServiceProvider, -} from 'test/mock/processors/file.mock' -import { translationProvider } from 'test/mock/processors/translation.mock' + import MockDbData, { categoryModels } from './post.e2e-mock.db' describe('PostController (e2e)', async () => { diff --git a/apps/core/test/src/modules/post/post.service.spec.ts b/apps/core/test/src/modules/post/post.service.spec.ts index 164d942c6fe..14928ba5634 100644 --- a/apps/core/test/src/modules/post/post.service.spec.ts +++ b/apps/core/test/src/modules/post/post.service.spec.ts @@ -1,5 +1,16 @@ import { ModuleRef } from '@nestjs/core' import { Test } from '@nestjs/testing' +import { Types } from 'mongoose' +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest' + import { BusinessException } from '~/common/exceptions/biz.exception' import { ArticleTypeEnum } from '~/constants/article.constant' import { @@ -15,16 +26,6 @@ import { EventManagerService } from '~/processors/helper/helper.event.service' import { ImageService } from '~/processors/helper/helper.image.service' import { LexicalService } from '~/processors/helper/helper.lexical.service' import { getModelToken } from '~/transformers/model.transformer' -import { Types } from 'mongoose' -import { - afterEach, - beforeEach, - describe, - expect, - it, - vi, - type Mock, -} from 'vitest' describe('PostService', () => { let postService: PostService diff --git a/apps/core/test/src/utils/filename-template.util.spec.ts b/apps/core/test/src/utils/filename-template.util.spec.ts new file mode 100644 index 00000000000..a9089e529e0 --- /dev/null +++ b/apps/core/test/src/utils/filename-template.util.spec.ts @@ -0,0 +1,167 @@ +import { + generateFilename, + generateFilePath, + replaceFilenameTemplate, +} from '~/utils/filename-template.util' +import { describe, expect, it } from 'vitest' + +describe('Filename Template Util', () => { + const mockContext = { + originalFilename: 'test-photo.jpg', + fileType: 'image', + localFolderPath: 'photos/2026/vacation', + } + + describe('replaceFilenameTemplate', () => { + it('应该替换文件名占位符', () => { + const result = replaceFilenameTemplate('{filename}', mockContext) + expect(result).toBe('test-photo.jpg') + }) + + it('应该替换文件名(不含扩展名)和扩展名', () => { + const result = replaceFilenameTemplate('{name}{ext}', mockContext) + expect(result).toBe('test-photo.jpg') + }) + + it('应该替换文件类型', () => { + const result = replaceFilenameTemplate('{type}/{filename}', mockContext) + expect(result).toBe('image/test-photo.jpg') + }) + + it('应该替换年份占位符', () => { + const template = '{Y}/{y}/{filename}' + const result = replaceFilenameTemplate(template, mockContext) + expect(result).toMatch(/^\d{4}\/\d{2}\/test-photo\.jpg$/) + }) + + it('应该替换日期时间占位符', () => { + const template = '{Y}{m}{d}_{h}{i}{s}{ext}' + const result = replaceFilenameTemplate(template, mockContext) + expect(result).toMatch(/^\d{8}_\d{6}\.jpg$/) + }) + + it('应该生成MD5随机字符串', () => { + const result = replaceFilenameTemplate('{md5}{ext}', mockContext) + expect(result).toMatch(/^[a-f0-9]{32}\.jpg$/) + }) + + it('应该生成16位MD5随机字符串', () => { + const result = replaceFilenameTemplate('{md5-16}{ext}', mockContext) + expect(result).toMatch(/^[a-f0-9]{16}\.jpg$/) + }) + + it('应该生成UUID', () => { + const result = replaceFilenameTemplate('{uuid}{ext}', mockContext) + expect(result).toMatch( + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.jpg$/, + ) + }) + + it('应该生成自定义长度的随机字符串', () => { + const result = replaceFilenameTemplate('{str-8}{ext}', mockContext) + expect(result).toMatch(/^.{8}\.jpg$/) + }) + + it('应该生成时间戳', () => { + const result = replaceFilenameTemplate('{timestamp}{ext}', mockContext) + expect(result).toMatch(/^\d+\.jpg$/) + }) + + it('应该提取文件夹层级', () => { + const result1 = replaceFilenameTemplate( + '{localFolder:1}/{filename}', + mockContext, + ) + expect(result1).toBe('vacation/test-photo.jpg') + + const result2 = replaceFilenameTemplate( + '{localFolder:2}/{filename}', + mockContext, + ) + expect(result2).toBe('2026/vacation/test-photo.jpg') + + const result3 = replaceFilenameTemplate( + '{localFolder:3}/{filename}', + mockContext, + ) + expect(result3).toBe('photos/2026/vacation/test-photo.jpg') + }) + + it('应该处理没有本地文件夹路径的情况', () => { + const contextWithoutFolder = { + ...mockContext, + localFolderPath: undefined, + } + const result = replaceFilenameTemplate( + '{localFolder:1}/{filename}', + contextWithoutFolder, + ) + expect(result).toBe('/test-photo.jpg') + }) + + it('应该正确处理复杂模板', () => { + const template = '{type}/{Y}/{m}/{d}/{md5-16}{ext}' + const result = replaceFilenameTemplate(template, mockContext) + expect(result).toMatch(/^image\/\d{4}\/\d{2}\/\d{2}\/[a-f0-9]{16}\.jpg$/) + }) + }) + + describe('generateFilename', () => { + it('未启用自定义命名时应该生成默认文件名', () => { + const config = { + enableCustomNaming: false, + filenameTemplate: '{Y}{m}{d}/{md5-16}{ext}', + } + const result = generateFilename(config, mockContext) + // 默认文件名是18位随机字符 + 扩展名 + expect(result).toMatch(/^.{18}\.jpg$/) + }) + + it('启用自定义命名时应该使用模板', () => { + const config = { + enableCustomNaming: true, + filenameTemplate: '{Y}{m}{d}/{md5-16}{ext}', + } + const result = generateFilename(config, mockContext) + expect(result).toMatch(/^\d{8}\/[a-f0-9]{16}\.jpg$/) + }) + + it('没有模板时应该使用默认规则', () => { + const config = { + enableCustomNaming: true, + filenameTemplate: undefined, + } + const result = generateFilename(config, mockContext) + expect(result).toMatch(/^.{18}\.jpg$/) + }) + }) + + describe('generateFilePath', () => { + it('未启用自定义命名时应该返回文件类型', () => { + const config = { + enableCustomNaming: false, + pathTemplate: '{type}/{Y}/{m}', + } + const result = generateFilePath(config, mockContext) + expect(result).toBe('image') + }) + + it('启用自定义命名时应该使用路径模板', () => { + const config = { + enableCustomNaming: true, + pathTemplate: '{type}/{Y}/{m}', + } + const result = generateFilePath(config, mockContext) + expect(result).toMatch(/^image\/\d{4}\/\d{2}$/) + }) + + it('没有路径模板时应该返回文件类型', () => { + const config = { + enableCustomNaming: true, + pathTemplate: undefined, + } + const result = generateFilePath(config, mockContext) + expect(result).toBe('image') + }) + }) +}) diff --git a/packages/api-client/controllers/aggregate.ts b/packages/api-client/controllers/aggregate.ts index ba38737d1f0..234c2b9c04e 100644 --- a/packages/api-client/controllers/aggregate.ts +++ b/packages/api-client/controllers/aggregate.ts @@ -13,6 +13,7 @@ import type { } from '~/models/aggregate' import { sortOrderToNumber } from '~/utils' import { autoBind } from '~/utils/auto-bind' + import type { HTTPClient } from '../core' declare module '../core/client' { diff --git a/packages/api-client/controllers/note.ts b/packages/api-client/controllers/note.ts index 279e3b48336..165abe2e6f0 100644 --- a/packages/api-client/controllers/note.ts +++ b/packages/api-client/controllers/note.ts @@ -10,6 +10,7 @@ import type { NoteWrappedWithLikedPayload, } from '~/models/note' import { autoBind } from '~/utils/auto-bind' + import type { HTTPClient } from '../core/client' import type { SortOptions } from './base' diff --git a/packages/api-client/controllers/page.ts b/packages/api-client/controllers/page.ts index 60f719ae852..f9568242224 100644 --- a/packages/api-client/controllers/page.ts +++ b/packages/api-client/controllers/page.ts @@ -5,6 +5,7 @@ import type { SelectFields } from '~/interfaces/types' import type { PaginateResult } from '~/models/base' import type { PageModel } from '~/models/page' import { autoBind } from '~/utils/auto-bind' + import type { HTTPClient } from '../core' declare module '../core/client' { diff --git a/packages/api-client/controllers/post.ts b/packages/api-client/controllers/post.ts index 632af21c5ee..19578e4ed6b 100644 --- a/packages/api-client/controllers/post.ts +++ b/packages/api-client/controllers/post.ts @@ -10,6 +10,7 @@ import type { } from '~/models/base' import type { PostModel } from '~/models/post' import { autoBind } from '~/utils/auto-bind' + import type { HTTPClient } from '../core/client' declare module '../core/client' {