Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/core/src/modules/configs/configs.default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,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: {
Expand Down
2 changes: 2 additions & 0 deletions apps/core/src/modules/configs/configs.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type BingSearchOptionsSchema,
type CommentOptionsSchema,
type FeatureListSchema,
type FileUploadOptionsSchema,
type FriendLinkOptionsSchema,
type ImageStorageOptionsSchema,
type MailOptionsSchema,
Expand Down Expand Up @@ -39,6 +40,7 @@ export abstract class IConfig {
friendLinkOptions: Required<z.infer<typeof FriendLinkOptionsSchema>>
backupOptions: Required<z.infer<typeof BackupOptionsSchema>>
imageStorageOptions: Required<z.infer<typeof ImageStorageOptionsSchema>>
fileUploadOptions: Required<z.infer<typeof FileUploadOptionsSchema>>
baiduSearchOptions: Required<z.infer<typeof BaiduSearchOptionsSchema>>
bingSearchOptions: Required<z.infer<typeof BingSearchOptionsSchema>>
algoliaSearchOptions: Required<z.infer<typeof AlgoliaSearchOptionsSchema>>
Expand Down
28 changes: 27 additions & 1 deletion apps/core/src/modules/configs/configs.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,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(
Expand All @@ -165,6 +166,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<typeof FileUploadOptionsSchema>

// ==================== Baidu Search Options ====================
export const BaiduSearchOptionsSchema = section('百度推送设定', {
enable: field.toggle(z.boolean().optional(), '开启推送'),
Expand Down Expand Up @@ -421,6 +445,7 @@ export const configSchemaMapping = {
friendLinkOptions: FriendLinkOptionsSchema,
backupOptions: BackupOptionsSchema,
imageStorageOptions: ImageStorageOptionsSchema,
fileUploadOptions: FileUploadOptionsSchema,
baiduSearchOptions: BaiduSearchOptionsSchema,
bingSearchOptions: BingSearchOptionsSchema,
algoliaSearchOptions: AlgoliaSearchOptionsSchema,
Expand All @@ -446,6 +471,7 @@ export const FullConfigSchema = withMeta(
friendLinkOptions: FriendLinkOptionsSchema,
backupOptions: BackupOptionsSchema,
imageStorageOptions: ImageStorageOptionsSchema,
fileUploadOptions: FileUploadOptionsSchema,
baiduSearchOptions: BaiduSearchOptionsSchema,
bingSearchOptions: BingSearchOptionsSchema,
algoliaSearchOptions: AlgoliaSearchOptionsSchema,
Expand Down
65 changes: 53 additions & 12 deletions apps/core/src/modules/file/file.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,18 @@ import { ApiController } from '~/common/decorators/api-controller.decorator'
import { Auth } from '~/common/decorators/auth.decorator'
import { HTTPDecorators } from '~/common/decorators/http.decorator'
import { CannotFindException } from '~/common/exceptions/cant-find.exception'
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 type { FastifyReply, FastifyRequest } from 'fastify'
import { lookup } from 'mime-types'
import { customAlphabet } from 'nanoid'
import { FileReferenceService } from './file-reference.service'
import {
BatchOrphanDeleteDto,
Expand Down Expand Up @@ -113,9 +116,17 @@ export class FileController {
const buffer = await fs.readFile(localPath)
const contentType = lookup(filename) || 'application/octet-stream'

const objectKey = config.prefix
? `${config.prefix.replace(/\/+$/, '')}/${filename}`
: filename
// 处理 prefix 中的模板变量
let prefixPath = ''
if (config.prefix) {
prefixPath = replaceFilenameTemplate(config.prefix, {
originalFilename: filename,
fileType: 'image',
})
prefixPath = prefixPath.replace(/\/+$/, '')
}

const objectKey = prefixPath ? `${prefixPath}/${filename}` : filename

const s3Url = await s3Uploader.uploadBuffer(
buffer,
Expand All @@ -141,7 +152,7 @@ export class FileController {
}

private extractLocalImageFilename(url: string): string | null {
const match = url.match(/\/objects\/image\/([^/?#]+)/)
const match = url.match(/\/objects\/image\/([^#/?]+)/)
return match ? match[1] : null
}

Expand Down Expand Up @@ -248,20 +259,50 @@ export class FileController {
const file = await this.uploadService.getAndValidMultipartField(req)
const { type = 'file' } = query

const ext = path.extname(file.filename)
const filename = customAlphabet(alphabet)(18) + ext.toLowerCase()
// 获取文件上传配置
const uploadConfig = await this.configsService.get('fileUploadOptions')

// 生成文件名(可能包含子路径)
const rawFilename = generateFilename(uploadConfig, {
originalFilename: file.filename,
fileType: type,
})

// 生成基础路径
const basePath = generateFilePath(uploadConfig, {
originalFilename: file.filename,
fileType: type,
})

// 构建相对路径(相对于文件类型目录)
// 如果 basePath 就是 type,则直接使用 rawFilename
// 否则,将 basePath 中除去 type 部分后与 rawFilename 合并
let relativePath: string
if (basePath === type || !basePath) {
// basePath 就是 type 或为空,直接使用 rawFilename
relativePath = rawFilename
} else {
// basePath 包含自定义路径,需要去除开头的 type 部分
const pathWithoutType = basePath.startsWith(`${type}/`)
? basePath.slice(Math.max(0, type.length + 1))
: basePath
relativePath = path.join(pathWithoutType, rawFilename)
}

await this.service.writeFile(type, filename, file.file)
await this.service.writeFile(type, relativePath, file.file)

const fileUrl = await this.service.resolveFileUrl(type, filename)
const fileUrl = await this.service.resolveFileUrl(type, relativePath)

if (type === 'image') {
await this.fileReferenceService.createPendingReference(fileUrl, filename)
await this.fileReferenceService.createPendingReference(
fileUrl,
relativePath,
)
}

return {
url: fileUrl,
name: filename,
name: path.basename(relativePath),
}
}

Expand Down
17 changes: 13 additions & 4 deletions apps/core/src/processors/helper/helper.image-migration.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Injectable, Logger } from '@nestjs/common'
import { STATIC_FILE_DIR } from '~/constants/path.constant'
import { ConfigsService } from '~/modules/configs/configs.service'
import type { ImageModel } from '~/shared/model/image.model'
import { replaceFilenameTemplate } from '~/utils/filename-template.util'
import { S3Uploader } from '~/utils/s3.util'
import { lookup } from 'mime-types'

Expand All @@ -25,7 +26,7 @@ export class ImageMigrationService {
}

extractLocalImageFilename(url: string): string | null {
const match = url.match(/\/objects\/image\/([^/?#]+)/)
const match = url.match(/\/objects\/image\/([^#/?]+)/)
return match ? match[1] : null
}

Expand Down Expand Up @@ -95,9 +96,17 @@ export class ImageMigrationService {
const buffer = await readFile(localPath)
const contentType = lookup(filename) || 'application/octet-stream'

const objectKey = config.prefix
? `${config.prefix.replace(/\/+$/, '')}/${filename}`
: filename
// 处理 prefix 中的模板变量
let prefixPath = ''
if (config.prefix) {
prefixPath = replaceFilenameTemplate(config.prefix, {
originalFilename: filename,
fileType: 'image',
})
prefixPath = prefixPath.replace(/\/+$/, '')
}

const objectKey = prefixPath ? `${prefixPath}/${filename}` : filename

const s3Url = await s3Uploader.uploadBuffer(
buffer,
Expand Down
Loading