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
1 change: 1 addition & 0 deletions apps/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"snakecase-keys": "6.0.0",
"source-map-support": "^0.5.21",
"ua-parser-js": "2.0.6",
"uuid": "^13.0.0",
"vm2": "3.9.19",
"wildcard-match": "5.1.4",
"xss": "1.0.15",
Expand Down
38 changes: 28 additions & 10 deletions apps/core/src/modules/backup/backup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ export class BackupService {
}

@CronOnce(CronExpression.EVERY_DAY_AT_1AM, { name: 'backupDB' })
@CronDescription('备份 DB 并上传 COS')
@CronDescription('备份 DB 并上传 S3')
async backupDB() {
const backup = await this.backup()
if (!backup) {
Expand All @@ -288,26 +288,44 @@ export class BackupService {
}

scheduleManager.schedule(async () => {
const { backupOptions } = await this.configs.waitForConfigReady()
const { backupOptions, s3Options } =
await this.configs.waitForConfigReady()

const { endpoint, bucket, region, secretId, secretKey } =
backupOptions || {}
if (!endpoint || !bucket || !region || !secretId || !secretKey) {
if (!backupOptions?.enable) {
this.logger.log('未启用 S3 备份')
return
}

const { endpoint, bucket, region, accessKeyId, secretAccessKey } =
s3Options || {}
if (!endpoint || !bucket || !region || !accessKeyId || !secretAccessKey) {
this.logger.warn('S3 配置不完整,无法上传备份')
return
}

const s3 = new S3Uploader({
bucket,
region,
accessKey: secretId,
secretKey,
accessKey: accessKeyId,
secretKey: secretAccessKey,
endpoint,
})

const remoteFileKey = backup.path.slice(backup.path.lastIndexOf('/') + 1)
this.logger.log('--> 开始上传到 S3')
if (s3Options.customDomain) {
s3.setCustomDomain(s3Options.customDomain)
}

const { parsePlaceholder } = await import('~/utils/path-placeholder.util')
const localFilename = backup.path.slice(backup.path.lastIndexOf('/') + 1)
const remotePath = backupOptions.path || 'backups/{Y}/{m}/{filename}'
const remoteFileKey = parsePlaceholder(remotePath, {
filename: localFilename,
})

this.logger.log(`--> 开始上传到 S3: ${remoteFileKey}`)
await s3.uploadFile(backup.buffer, remoteFileKey).catch((error) => {
this.logger.error('--> 上传失败了')
this.logger.error('--> 上传失败')
this.logger.error(error)
throw error
})

Expand Down
23 changes: 17 additions & 6 deletions apps/core/src/modules/configs/configs.default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,24 @@ export const generateDefaultConfig: () => IConfig = () => ({
allowSubPath: false,
enableAvatarInternalization: true,
},
s3Options: {
endpoint: '',
accessKeyId: '',
secretAccessKey: '',
bucket: '',
region: '',
customDomain: '',
pathStyleAccess: false,
},
backupOptions: {
enable: true,
endpoint: null!,
region: null!,
bucket: null!,
secretId: null!,
secretKey: null!,
enable: false,
path: 'backups/{Y}/{m}/backup-{Y}{m}{d}-{h}{i}{s}.zip',
},
imageBedOptions: {
enable: false,
path: 'images/{Y}/{m}/{uuid}.{ext}',
allowedFormats: 'jpg,jpeg,png,gif,webp',
maxSizeMB: 10,
},
baiduSearchOptions: { enable: false, token: null! },
bingSearchOptions: { enable: false, token: null! },
Expand Down
124 changes: 104 additions & 20 deletions apps/core/src/modules/configs/configs.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,15 @@ export class UrlDto {
}

class MailOption {
@IsInt()
@Transform(({ value: val }) => Number.parseInt(val))
@IsOptional()
@Transform(
({ value }) => {
if (value === undefined || value === null) return undefined
return typeof value === 'number' ? value : Number.parseInt(value, 10)
},
{ toClassOnly: true },
)
@IsInt()
@JSONSchemaNumberField('SMTP 端口', halfFieldOption)
port: number
@IsUrl({ require_protocol: false })
Expand Down Expand Up @@ -150,11 +156,17 @@ export class CommentOptionsDto {
})
aiReviewType: 'binary' | 'score'

@IsOptional()
@Transform(
({ value }) => {
if (value === undefined || value === null) return undefined
return typeof value === 'number' ? value : Number.parseInt(value, 10)
},
{ toClassOnly: true },
)
@IsInt()
@Transform(({ value: val }) => Number.parseInt(val))
@Min(1)
@Max(10)
@IsOptional()
@JSONSchemaNumberField('AI 审核阈值', {
description: '分数大于多少时会被归类为垃圾评论,范围为 1-10, 默认为 5',
})
Expand Down Expand Up @@ -193,40 +205,112 @@ export class CommentOptionsDto {
recordIpLocation?: boolean
}

@JSONSchema({ title: '备份' })
export class BackupOptionsDto {
@IsBoolean()
@IsOptional()
@JSONSchemaToggleField('开启自动备份', {
description: '填写以下 S3 信息,将同时上传备份到 S3',
})
enable: boolean

@JSONSchema({ title: 'S3 对象存储设置' })
export class S3OptionsDto {
@IsString()
@IsOptional()
@JSONSchemaPlainField('S3 服务端点')
@JSONSchemaPlainField('S3 服务端点', {
description:
'例如:https://s3.amazonaws.com 或 https://oss-cn-hangzhou.aliyuncs.com',
})
endpoint?: string

@IsString()
@IsOptional()
@JSONSchemaHalfGirdPlainField('SecretId')
secretId?: string
@JSONSchemaHalfGirdPlainField('Access Key ID / SecretId')
accessKeyId?: string

@IsOptional()
@IsString()
@JSONSchemaPasswordField('SecretKey', halfFieldOption)
@JSONSchemaPasswordField('Secret Access Key / SecretKey', halfFieldOption)
@SecretField
secretKey?: string
secretAccessKey?: string

@IsOptional()
@IsString()
@JSONSchemaHalfGirdPlainField('Bucket')
@JSONSchemaHalfGirdPlainField('Bucket 名称')
bucket?: string

@IsString()
@IsOptional()
@JSONSchemaHalfGirdPlainField('地域 Region')
region: string
region?: string

@IsString()
@IsOptional()
@JSONSchemaPlainField('自定义域名', {
description:
'如果配置了 CDN 或自定义域名,填写此项;留空则使用默认的 S3 URL',
})
customDomain?: string

@IsBoolean()
@IsOptional()
@JSONSchemaToggleField('路径风格访问', {
description:
'启用路径风格访问(Path-style),适用于 MinIO 等兼容 S3 的服务',
})
pathStyleAccess?: boolean
}

@JSONSchema({ title: '备份设置' })
export class BackupOptionsDto {
@IsBoolean()
@IsOptional()
@JSONSchemaToggleField('开启自动备份到 S3', {
description: '需要先配置 S3 对象存储设置',
})
enable: boolean

@IsString()
@IsOptional()
@JSONSchemaPlainField('备份文件路径', {
description:
'支持占位符:{Y}年4位 {y}年2位 {m}月 {d}日 {h}时 {i}分 {s}秒 {timestamp}时间戳 {uuid} {md5} 等',
})
path?: string
}

@JSONSchema({ title: '图床设置' })
export class ImageBedOptionsDto {
@IsBoolean()
@IsOptional()
@JSONSchemaToggleField('启用 S3 图床', {
description:
'启用后,编辑器上传的图片将存储到 S3;需要先配置 S3 对象存储设置',
})
enable: boolean

@IsString()
@IsOptional()
@JSONSchemaPlainField('图片存储路径', {
description:
'支持占位符:{Y}年4位 {y}年2位 {m}月 {d}日 {h}时 {i}分 {s}秒 {timestamp}时间戳 {uuid} {md5} {md5-16} {str-N}随机字符串 {filename}原文件名',
})
path?: string

@IsString()
@IsOptional()
@JSONSchemaPlainField('允许的图片格式', {
description: '逗号分隔,例如:jpg,jpeg,png,gif,webp',
})
allowedFormats?: string

@IsOptional()
@Transform(
({ value }) => {
if (value === undefined || value === null) return undefined
return typeof value === 'number' ? value : Number.parseInt(value, 10)
},
{ toClassOnly: true },
)
@IsInt()
@Min(1)
@Max(100)
@JSONSchemaNumberField('最大文件大小(MB)', {
description: '单个图片文件的最大大小限制,单位:MB',
})
maxSizeMB?: number
}

@JSONSchema({ title: '百度推送设定' })
Expand Down
9 changes: 9 additions & 0 deletions apps/core/src/modules/configs/configs.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import {
CommentOptionsDto,
FeatureListDto,
FriendLinkOptionsDto,
ImageBedOptionsDto,
MailOptionsDto,
OAuthDto,
S3OptionsDto,
SeoDto,
TextOptionsDto,
ThirdPartyServiceIntegrationDto,
Expand Down Expand Up @@ -63,8 +65,15 @@ export abstract class IConfig {
@ConfigField(() => FriendLinkOptionsDto)
friendLinkOptions: Required<FriendLinkOptionsDto>

@ConfigField(() => S3OptionsDto)
s3Options: Required<S3OptionsDto>

@ConfigField(() => BackupOptionsDto)
backupOptions: Required<BackupOptionsDto>

@ConfigField(() => ImageBedOptionsDto)
imageBedOptions: Required<ImageBedOptionsDto>

@ConfigField(() => BaiduSearchOptionsDto)
baiduSearchOptions: Required<BaiduSearchOptionsDto>
@ConfigField(() => BingSearchOptionsDto)
Expand Down
48 changes: 38 additions & 10 deletions apps/core/src/modules/configs/configs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,25 +140,53 @@ export class ConfigsService {
}
}

private removeUndefinedValues<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') {
return obj
}

if (Array.isArray(obj)) {
return obj as T
}

const result = {} as T
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined) {
result[key as keyof T] =
typeof value === 'object' && value !== null
? this.removeUndefinedValues(value)
: value
}
}
return result
}

private async patch<T extends keyof IConfig>(
key: T,
data: Partial<IConfig[T]>,
): Promise<IConfig[T]> {
const config = await this.getConfig()

const filteredData = this.removeUndefinedValues(data)

const updatedConfigRow = await this.optionModel
.findOneAndUpdate(
{ name: key as string },
{
value: mergeWith(cloneDeep(config[key]), data, (old, newer) => {
// 数组不合并
if (Array.isArray(old)) {
return newer
}
// 对象合并
if (typeof old === 'object' && typeof newer === 'object') {
return { ...old, ...newer }
}
}),
value: mergeWith(
cloneDeep(config[key]),
filteredData,
(old, newer) => {
// 数组不合并
if (Array.isArray(old)) {
return newer
}
// 对象合并
if (typeof old === 'object' && typeof newer === 'object') {
return { ...old, ...newer }
}
},
),
},
{ upsert: true, new: true },
)
Expand Down
Loading
Loading