2025-04-25 01:26:04 +08:00
|
|
|
import { IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
|
|
|
|
|
import fs from "fs";
|
|
|
|
|
import path from "path";
|
|
|
|
|
import dayjs from "dayjs";
|
|
|
|
|
import { AbstractPlusTaskPlugin } from "@certd/plugin-plus";
|
|
|
|
|
import JSZip from "jszip";
|
|
|
|
|
import * as os from "node:os";
|
|
|
|
|
import { OssClientContext, ossClientFactory, OssClientRemoveByOpts, SshAccess, SshClient } from "@certd/plugin-lib";
|
2025-10-27 15:25:41 +08:00
|
|
|
import { pipeline } from "stream/promises";
|
|
|
|
|
const defaultBackupDir = "certd_backup";
|
|
|
|
|
const defaultFilePrefix = "db_backup";
|
2025-04-24 17:27:13 +08:00
|
|
|
|
2024-10-15 19:27:55 +08:00
|
|
|
@IsTaskPlugin({
|
2025-10-27 15:25:41 +08:00
|
|
|
name: "DBBackupPlugin",
|
|
|
|
|
title: "数据库备份",
|
|
|
|
|
icon: "lucide:database-backup",
|
|
|
|
|
desc: "【仅管理员可用】仅支持备份SQLite数据库",
|
2025-07-22 11:51:27 +08:00
|
|
|
group: pluginGroups.admin.key,
|
2025-04-10 09:35:50 +08:00
|
|
|
showRunStrategy: true,
|
2024-10-15 19:27:55 +08:00
|
|
|
default: {
|
|
|
|
|
strategy: {
|
|
|
|
|
runStrategy: RunStrategy.AlwaysRun,
|
|
|
|
|
},
|
|
|
|
|
},
|
2025-10-27 15:25:41 +08:00
|
|
|
onlyAdmin: true,
|
2024-10-15 19:27:55 +08:00
|
|
|
needPlus: true,
|
|
|
|
|
})
|
|
|
|
|
export class DBBackupPlugin extends AbstractPlusTaskPlugin {
|
|
|
|
|
@TaskInput({
|
2025-10-27 15:25:41 +08:00
|
|
|
title: "备份方式",
|
|
|
|
|
value: "local",
|
2024-10-15 19:27:55 +08:00
|
|
|
component: {
|
2025-10-27 15:25:41 +08:00
|
|
|
name: "a-select",
|
2024-10-15 19:27:55 +08:00
|
|
|
options: [
|
2025-10-27 15:25:41 +08:00
|
|
|
{ label: "本地复制", value: "local" },
|
|
|
|
|
{ label: "ssh上传", value: "ssh" },
|
|
|
|
|
{ label: "oss上传", value: "oss" },
|
2024-10-15 19:27:55 +08:00
|
|
|
],
|
2025-10-27 15:25:41 +08:00
|
|
|
placeholder: "",
|
2024-10-15 19:27:55 +08:00
|
|
|
},
|
2025-10-27 15:25:41 +08:00
|
|
|
helper: "支持本地复制、ssh上传",
|
2024-10-15 19:27:55 +08:00
|
|
|
required: true,
|
|
|
|
|
})
|
2025-10-27 15:25:41 +08:00
|
|
|
backupMode = "local";
|
2024-10-15 19:27:55 +08:00
|
|
|
|
|
|
|
|
@TaskInput({
|
2025-10-27 15:25:41 +08:00
|
|
|
title: "主机登录授权",
|
2024-10-15 19:27:55 +08:00
|
|
|
component: {
|
2025-10-27 15:25:41 +08:00
|
|
|
name: "access-selector",
|
|
|
|
|
type: "ssh",
|
2024-10-15 19:27:55 +08:00
|
|
|
},
|
|
|
|
|
mergeScript: `
|
|
|
|
|
return {
|
|
|
|
|
show:ctx.compute(({form})=>{
|
|
|
|
|
return form.backupMode === 'ssh';
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
`,
|
|
|
|
|
required: true,
|
|
|
|
|
})
|
|
|
|
|
sshAccessId!: number;
|
|
|
|
|
|
2025-04-24 17:27:13 +08:00
|
|
|
@TaskInput({
|
2025-10-27 15:25:41 +08:00
|
|
|
title: "OSS类型",
|
2025-04-24 17:27:13 +08:00
|
|
|
component: {
|
2025-10-27 15:25:41 +08:00
|
|
|
name: "a-select",
|
2025-04-24 17:27:13 +08:00
|
|
|
options: [
|
2025-10-27 15:25:41 +08:00
|
|
|
{ value: "alioss", label: "阿里云OSS" },
|
|
|
|
|
{ value: "s3", label: "MinIO/S3" },
|
|
|
|
|
{ value: "qiniuoss", label: "七牛云" },
|
|
|
|
|
{ value: "tencentcos", label: "腾讯云COS" },
|
|
|
|
|
{ value: "ftp", label: "Ftp" },
|
|
|
|
|
{ value: "sftp", label: "Sftp" },
|
|
|
|
|
],
|
2025-04-24 17:27:13 +08:00
|
|
|
},
|
|
|
|
|
mergeScript: `
|
|
|
|
|
return {
|
|
|
|
|
show:ctx.compute(({form})=>{
|
|
|
|
|
return form.backupMode === 'oss';
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
`,
|
|
|
|
|
required: true,
|
|
|
|
|
})
|
|
|
|
|
ossType!: string;
|
|
|
|
|
|
|
|
|
|
@TaskInput({
|
2025-10-27 15:25:41 +08:00
|
|
|
title: "OSS授权",
|
2025-04-24 17:27:13 +08:00
|
|
|
component: {
|
2025-10-27 15:25:41 +08:00
|
|
|
name: "access-selector",
|
2025-04-24 17:27:13 +08:00
|
|
|
},
|
|
|
|
|
mergeScript: `
|
|
|
|
|
return {
|
|
|
|
|
show:ctx.compute(({form})=>{
|
2025-04-25 01:26:04 +08:00
|
|
|
return form.backupMode === 'oss';
|
2025-04-24 17:27:13 +08:00
|
|
|
}),
|
|
|
|
|
component:{
|
|
|
|
|
type: ctx.compute(({form})=>{
|
|
|
|
|
return form.ossType;
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`,
|
|
|
|
|
required: true,
|
|
|
|
|
})
|
|
|
|
|
ossAccessId!: number;
|
|
|
|
|
|
2024-10-15 19:27:55 +08:00
|
|
|
@TaskInput({
|
2025-10-27 15:25:41 +08:00
|
|
|
title: "备份保存目录",
|
2024-10-15 19:27:55 +08:00
|
|
|
component: {
|
2025-10-27 15:25:41 +08:00
|
|
|
name: "a-input",
|
|
|
|
|
type: "value",
|
2024-10-15 19:27:55 +08:00
|
|
|
placeholder: `默认${defaultBackupDir}`,
|
|
|
|
|
},
|
|
|
|
|
helper: `ssh方式默认保存在当前用户的${defaultBackupDir}目录下,本地方式默认保存在data/${defaultBackupDir}目录下,也可以填写绝对路径`,
|
|
|
|
|
required: false,
|
|
|
|
|
})
|
|
|
|
|
backupDir: string = defaultBackupDir;
|
|
|
|
|
|
|
|
|
|
@TaskInput({
|
2025-10-27 15:25:41 +08:00
|
|
|
title: "备份文件前缀",
|
2024-10-15 19:27:55 +08:00
|
|
|
component: {
|
2025-10-27 15:25:41 +08:00
|
|
|
name: "a-input",
|
|
|
|
|
vModel: "value",
|
2024-10-15 19:27:55 +08:00
|
|
|
placeholder: `默认${defaultFilePrefix}`,
|
|
|
|
|
},
|
|
|
|
|
required: false,
|
|
|
|
|
})
|
|
|
|
|
filePrefix: string = defaultFilePrefix;
|
|
|
|
|
|
2024-11-30 17:36:47 +08:00
|
|
|
@TaskInput({
|
2025-10-27 15:25:41 +08:00
|
|
|
title: "附加上传文件",
|
2024-11-30 17:36:47 +08:00
|
|
|
value: true,
|
|
|
|
|
component: {
|
2025-10-27 15:25:41 +08:00
|
|
|
name: "a-switch",
|
|
|
|
|
vModel: "checked",
|
2024-11-30 17:36:47 +08:00
|
|
|
placeholder: `是否备份上传的头像等文件`,
|
|
|
|
|
},
|
|
|
|
|
required: false,
|
|
|
|
|
})
|
|
|
|
|
withUpload = true;
|
|
|
|
|
|
2024-10-15 19:27:55 +08:00
|
|
|
@TaskInput({
|
2025-10-27 15:25:41 +08:00
|
|
|
title: "删除过期备份",
|
2024-10-15 19:27:55 +08:00
|
|
|
component: {
|
2025-10-27 15:25:41 +08:00
|
|
|
name: "a-input-number",
|
|
|
|
|
vModel: "value",
|
|
|
|
|
placeholder: "20",
|
2024-10-15 19:27:55 +08:00
|
|
|
},
|
2025-10-27 15:25:41 +08:00
|
|
|
helper: "删除多少天前的备份,不填则不删除,windows暂不支持",
|
2024-10-15 19:27:55 +08:00
|
|
|
required: false,
|
|
|
|
|
})
|
|
|
|
|
retainDays!: number;
|
|
|
|
|
|
2025-10-27 15:25:41 +08:00
|
|
|
async onInstance() {}
|
2025-04-24 17:27:13 +08:00
|
|
|
|
2024-10-15 19:27:55 +08:00
|
|
|
async execute(): Promise<void> {
|
2025-07-22 11:51:27 +08:00
|
|
|
if (!this.isAdmin()) {
|
2025-10-27 15:25:41 +08:00
|
|
|
throw new Error("只有管理员才能运行此任务");
|
2025-07-22 11:51:27 +08:00
|
|
|
}
|
|
|
|
|
|
2025-10-27 15:25:41 +08:00
|
|
|
this.logger.info("开始备份数据库");
|
2024-10-15 19:27:55 +08:00
|
|
|
|
|
|
|
|
let dbPath = process.env.certd_typeorm_dataSource_default_database;
|
2025-10-27 15:25:41 +08:00
|
|
|
dbPath = dbPath || "./data/db.sqlite";
|
2024-10-15 19:27:55 +08:00
|
|
|
if (!fs.existsSync(dbPath)) {
|
2025-10-27 15:25:41 +08:00
|
|
|
this.logger.error("数据库文件不存在:", dbPath);
|
2024-10-15 19:27:55 +08:00
|
|
|
return;
|
|
|
|
|
}
|
2025-10-27 15:25:41 +08:00
|
|
|
const dbTmpFilename = `${this.filePrefix}_${dayjs().format("YYYYMMDD_HHmmss")}_sqlite`;
|
2024-10-16 12:35:09 +08:00
|
|
|
const dbZipFilename = `${dbTmpFilename}.zip`;
|
2025-10-27 15:25:41 +08:00
|
|
|
const tempDir = path.resolve(os.tmpdir(), "certd_backup");
|
2024-10-16 12:35:09 +08:00
|
|
|
if (!fs.existsSync(tempDir)) {
|
2025-10-27 15:25:41 +08:00
|
|
|
await fs.promises.mkdir(tempDir, { recursive: true });
|
2024-10-16 12:35:09 +08:00
|
|
|
}
|
|
|
|
|
const dbTmpPath = path.resolve(tempDir, dbTmpFilename);
|
|
|
|
|
const dbZipPath = path.resolve(tempDir, dbZipFilename);
|
|
|
|
|
|
2025-10-27 15:25:41 +08:00
|
|
|
try {
|
|
|
|
|
//复制到临时目录
|
|
|
|
|
await fs.promises.copyFile(dbPath, dbTmpPath);
|
|
|
|
|
// //本地压缩
|
|
|
|
|
// const zip = new JSZip();
|
|
|
|
|
// const stream = fs.createReadStream(dbTmpPath);
|
|
|
|
|
// // 使用流的方式添加文件内容
|
|
|
|
|
// zip.file(dbTmpFilename, stream, {binary: true, compression: 'DEFLATE'});
|
|
|
|
|
|
|
|
|
|
// const uploadDir = path.resolve('data', 'upload');
|
|
|
|
|
// if (this.withUpload && fs.existsSync(uploadDir)) {
|
|
|
|
|
// zip.folder(uploadDir);
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
// const content = await zip.generateAsync({type: 'nodebuffer'});
|
|
|
|
|
|
|
|
|
|
// await fs.promises.writeFile(dbZipPath, content);
|
|
|
|
|
// 创建可写流
|
|
|
|
|
const outputStream = fs.createWriteStream(dbZipPath);
|
|
|
|
|
const zip = new JSZip();
|
|
|
|
|
|
|
|
|
|
// 添加数据库文件
|
|
|
|
|
const dbStream = fs.createReadStream(dbTmpPath);
|
|
|
|
|
zip.file(dbTmpFilename, dbStream, { binary: true, compression: "DEFLATE" });
|
|
|
|
|
|
|
|
|
|
// 处理上传目录
|
|
|
|
|
const uploadDir = path.resolve("data", "upload");
|
|
|
|
|
if (this.withUpload && fs.existsSync(uploadDir)) {
|
|
|
|
|
zip.folder("upload"); // 注意:这里应该是相对路径
|
|
|
|
|
}
|
2024-11-30 17:36:47 +08:00
|
|
|
|
2025-10-27 15:25:41 +08:00
|
|
|
// 使用流式生成
|
|
|
|
|
const zipStream = zip.generateNodeStream({
|
|
|
|
|
type: "nodebuffer",
|
|
|
|
|
streamFiles: true,
|
|
|
|
|
compression: "DEFLATE",
|
|
|
|
|
});
|
2024-10-16 12:35:09 +08:00
|
|
|
|
2025-10-27 15:25:41 +08:00
|
|
|
// 管道传输
|
|
|
|
|
await pipeline(zipStream, outputStream);
|
|
|
|
|
this.logger.info(`数据库文件压缩完成:${dbZipPath}`);
|
2024-10-16 12:20:42 +08:00
|
|
|
|
2025-10-27 15:25:41 +08:00
|
|
|
this.logger.info("开始备份,当前备份方式:", this.backupMode);
|
|
|
|
|
const backupDir = this.backupDir || defaultBackupDir;
|
|
|
|
|
const backupFilePath = `${backupDir}/${dbZipFilename}`;
|
2024-10-15 19:27:55 +08:00
|
|
|
|
2025-10-27 15:25:41 +08:00
|
|
|
if (this.backupMode === "local") {
|
2025-08-04 18:31:06 +08:00
|
|
|
await this.localBackup(dbZipPath, backupDir, backupFilePath);
|
2025-10-27 15:25:41 +08:00
|
|
|
} else if (this.backupMode === "ssh") {
|
2025-08-04 18:31:06 +08:00
|
|
|
await this.sshBackup(dbZipPath, backupDir, backupFilePath);
|
2025-10-27 15:25:41 +08:00
|
|
|
} else if (this.backupMode === "oss") {
|
2025-08-04 18:31:06 +08:00
|
|
|
await this.ossBackup(dbZipPath, backupDir, backupFilePath);
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(`不支持的备份方式:${this.backupMode}`);
|
|
|
|
|
}
|
2025-10-27 15:25:41 +08:00
|
|
|
} finally {
|
2025-08-04 18:31:06 +08:00
|
|
|
//删除临时目录
|
2025-10-27 15:25:41 +08:00
|
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
2024-10-15 19:27:55 +08:00
|
|
|
}
|
|
|
|
|
|
2025-10-27 15:25:41 +08:00
|
|
|
this.logger.info("数据库备份完成");
|
2024-10-15 19:27:55 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async localBackup(dbPath: string, backupDir: string, backupPath: string) {
|
2025-10-27 15:25:41 +08:00
|
|
|
if (!backupPath.startsWith("/")) {
|
|
|
|
|
backupPath = path.join("./data/", backupPath);
|
2024-10-15 19:27:55 +08:00
|
|
|
}
|
2024-10-15 19:42:39 +08:00
|
|
|
const dir = path.dirname(backupPath);
|
2024-10-15 19:27:55 +08:00
|
|
|
if (!fs.existsSync(dir)) {
|
2025-10-27 15:25:41 +08:00
|
|
|
await fs.promises.mkdir(dir, { recursive: true });
|
2024-10-15 19:27:55 +08:00
|
|
|
}
|
2024-10-15 19:42:39 +08:00
|
|
|
backupPath = path.resolve(backupPath);
|
2024-10-15 19:27:55 +08:00
|
|
|
await fs.promises.copyFile(dbPath, backupPath);
|
2025-10-27 15:25:41 +08:00
|
|
|
this.logger.info("备份文件路径:", backupPath);
|
2024-10-15 19:27:55 +08:00
|
|
|
|
|
|
|
|
if (this.retainDays > 0) {
|
|
|
|
|
// 删除过期备份
|
2025-10-27 15:25:41 +08:00
|
|
|
this.logger.info("开始删除过期备份文件");
|
2024-10-15 19:27:55 +08:00
|
|
|
const files = fs.readdirSync(dir);
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
let count = 0;
|
|
|
|
|
files.forEach(file => {
|
|
|
|
|
const filePath = path.join(dir, file);
|
|
|
|
|
const stat = fs.statSync(filePath);
|
|
|
|
|
if (now - stat.mtimeMs > this.retainDays * 24 * 60 * 60 * 1000) {
|
|
|
|
|
fs.unlinkSync(filePath as fs.PathLike);
|
|
|
|
|
count++;
|
2025-10-27 15:25:41 +08:00
|
|
|
this.logger.info("删除过期备份文件:", filePath);
|
2024-10-15 19:27:55 +08:00
|
|
|
}
|
|
|
|
|
});
|
2025-10-27 15:25:41 +08:00
|
|
|
this.logger.info("删除过期备份文件数:", count);
|
2024-10-15 19:27:55 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async sshBackup(dbPath: string, backupDir: string, backupPath: string) {
|
2025-04-12 00:14:55 +08:00
|
|
|
const access: SshAccess = await this.getAccess(this.sshAccessId);
|
2024-10-15 19:27:55 +08:00
|
|
|
const sshClient = new SshClient(this.logger);
|
2025-10-27 15:25:41 +08:00
|
|
|
this.logger.info("备份目录:", backupPath);
|
2024-10-15 19:27:55 +08:00
|
|
|
await sshClient.uploadFiles({
|
|
|
|
|
connectConf: access,
|
2025-10-27 15:25:41 +08:00
|
|
|
transports: [{ localPath: dbPath, remotePath: backupPath }],
|
2024-10-15 19:27:55 +08:00
|
|
|
mkdirs: true,
|
|
|
|
|
});
|
2025-10-27 15:25:41 +08:00
|
|
|
this.logger.info("备份文件上传完成");
|
2024-10-15 19:27:55 +08:00
|
|
|
|
|
|
|
|
if (this.retainDays > 0) {
|
|
|
|
|
// 删除过期备份
|
2025-10-27 15:25:41 +08:00
|
|
|
this.logger.info("开始删除过期备份文件");
|
2024-10-15 19:27:55 +08:00
|
|
|
const isWin = access.windows;
|
2024-10-15 19:42:39 +08:00
|
|
|
let script: string[] = [];
|
2024-10-15 19:27:55 +08:00
|
|
|
if (isWin) {
|
2025-10-27 15:25:41 +08:00
|
|
|
throw new Error("删除过期文件暂不支持windows系统");
|
2024-10-15 19:27:55 +08:00
|
|
|
// script = `forfiles /p ${backupDir} /s /d -${this.retainDays} /c "cmd /c del @path"`;
|
|
|
|
|
} else {
|
2025-10-27 15:25:41 +08:00
|
|
|
script = [`cd ${backupDir}`, "echo 备份目录", "pwd", `find . -type f -mtime +${this.retainDays} -name '${this.filePrefix}*' -exec rm -f {} \\;`];
|
2024-10-15 19:27:55 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await sshClient.exec({
|
|
|
|
|
connectConf: access,
|
|
|
|
|
script,
|
|
|
|
|
});
|
2025-10-27 15:25:41 +08:00
|
|
|
this.logger.info("删除过期备份文件完成");
|
2024-10-15 19:27:55 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async ossBackup(dbPath: string, backupDir: string, backupPath: string) {
|
2025-04-25 01:26:04 +08:00
|
|
|
if (!this.ossAccessId) {
|
2025-10-27 15:25:41 +08:00
|
|
|
throw new Error("未配置ossAccessId");
|
2025-04-25 01:26:04 +08:00
|
|
|
}
|
|
|
|
|
const access = await this.getAccess(this.ossAccessId);
|
2025-10-27 15:25:41 +08:00
|
|
|
const ossType = this.ossType;
|
2025-04-25 01:26:04 +08:00
|
|
|
|
|
|
|
|
const ctx: OssClientContext = {
|
|
|
|
|
logger: this.logger,
|
|
|
|
|
utils: this.ctx.utils,
|
2025-10-27 15:25:41 +08:00
|
|
|
accessService: this.accessService,
|
|
|
|
|
};
|
2025-04-25 01:26:04 +08:00
|
|
|
|
|
|
|
|
this.logger.info(`开始备份文件到:${ossType}`);
|
2025-10-27 15:25:41 +08:00
|
|
|
const client = await ossClientFactory.createOssClientByType(ossType, {
|
2025-04-25 01:26:04 +08:00
|
|
|
access,
|
|
|
|
|
ctx,
|
2025-10-27 15:25:41 +08:00
|
|
|
});
|
2025-04-25 01:26:04 +08:00
|
|
|
|
|
|
|
|
await client.upload(backupPath, dbPath);
|
|
|
|
|
|
|
|
|
|
if (this.retainDays > 0) {
|
|
|
|
|
// 删除过期备份
|
2025-10-27 15:25:41 +08:00
|
|
|
this.logger.info("开始删除过期备份文件");
|
2025-04-25 01:26:04 +08:00
|
|
|
const removeByOpts: OssClientRemoveByOpts = {
|
|
|
|
|
dir: backupDir,
|
|
|
|
|
beforeDays: this.retainDays,
|
|
|
|
|
};
|
|
|
|
|
await client.removeBy(removeByOpts);
|
2025-10-27 15:25:41 +08:00
|
|
|
this.logger.info("删除过期备份文件完成");
|
|
|
|
|
} else {
|
|
|
|
|
this.logger.info("已禁止删除过期文件");
|
2025-04-25 01:26:04 +08:00
|
|
|
}
|
2024-10-15 19:27:55 +08:00
|
|
|
}
|
|
|
|
|
}
|
2025-04-24 17:27:13 +08:00
|
|
|
|
2024-10-15 19:27:55 +08:00
|
|
|
new DBBackupPlugin();
|