refactor: API 请求整合与 Bug 修复
前端重构: - 新增 api/ 目录统一管理所有 API 请求 - 所有 stores 和 pages 改为使用 api 模块 - 修复日期格式化函数,支持 Date 对象和 ISO 字符串 - 修复 TransactionItem 使用 CategoryIcon 组件 - 优化 ChartWrapper 区分 H5 和小程序环境 后端修复: - 修复 auth 中间件 PUBLIC_PATHS 缺少 demo-login - 修复备份工具命令注入漏洞,改用 execFile - 修复 stats 路由 type 参数验证 - 优化分类排序为批量 SQL 更新 - 合并迁移和删除为数据库事务原子操作 - 修复交易和统计日期返回格式 - 新增自动备份功能(启动时备份)
This commit is contained in:
100
server/src/utils/backup.ts
Normal file
100
server/src/utils/backup.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import zlib from 'zlib'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const gzipAsync = promisify(zlib.gzip)
|
||||
|
||||
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
|
||||
const MAX_BACKUPS = 7 // 保留最近 7 天的备份
|
||||
|
||||
/**
|
||||
* 执行数据库备份
|
||||
* @returns 备份文件路径
|
||||
*/
|
||||
export async function backupDatabase(): Promise<string> {
|
||||
// 确保备份目录存在
|
||||
if (!fs.existsSync(BACKUP_DIR)) {
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
const date = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
const filename = `xiaocai_${date}.sql.gz`
|
||||
const filepath = path.join(BACKUP_DIR, filename)
|
||||
|
||||
const dbHost = process.env.DB_HOST || 'localhost'
|
||||
const dbUser = process.env.DB_USER || 'root'
|
||||
const dbPassword = process.env.DB_PASSWORD || ''
|
||||
const dbName = process.env.DB_NAME || 'xiaocai'
|
||||
|
||||
try {
|
||||
// 使用 execFile 避免命令注入,参数通过数组传递
|
||||
const args = [
|
||||
`-h${dbHost}`,
|
||||
`-u${dbUser}`,
|
||||
...(dbPassword ? [`-p${dbPassword}`] : []),
|
||||
dbName
|
||||
]
|
||||
|
||||
const { stdout } = await execFileAsync('mysqldump', args, { maxBuffer: 50 * 1024 * 1024 })
|
||||
|
||||
// 使用 Node.js zlib 压缩
|
||||
const compressed = await gzipAsync(Buffer.from(stdout, 'utf-8'))
|
||||
fs.writeFileSync(filepath, compressed)
|
||||
|
||||
console.log(`[Backup] 完成: ${filename}`)
|
||||
|
||||
// 清理旧备份
|
||||
await cleanOldBackups()
|
||||
|
||||
return filepath
|
||||
} catch (err) {
|
||||
console.error('[Backup] 失败:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的备份文件
|
||||
*/
|
||||
async function cleanOldBackups(): Promise<void> {
|
||||
try {
|
||||
if (!fs.existsSync(BACKUP_DIR)) return
|
||||
|
||||
const files = fs.readdirSync(BACKUP_DIR)
|
||||
.filter(f => f.startsWith('xiaocai_') && (f.endsWith('.sql.gz') || f.endsWith('.sql')))
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
// 删除超出保留数量的文件
|
||||
for (let i = MAX_BACKUPS; i < files.length; i++) {
|
||||
const filepath = path.join(BACKUP_DIR, files[i])
|
||||
fs.unlinkSync(filepath)
|
||||
console.log(`[Backup] 清理旧备份: ${files[i]}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Backup] 清理失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取备份列表
|
||||
*/
|
||||
export function getBackupList(): Array<{ name: string; size: number; date: string }> {
|
||||
if (!fs.existsSync(BACKUP_DIR)) return []
|
||||
|
||||
return fs.readdirSync(BACKUP_DIR)
|
||||
.filter(f => f.startsWith('xiaocai_') && (f.endsWith('.sql.gz') || f.endsWith('.sql')))
|
||||
.map(f => {
|
||||
const filepath = path.join(BACKUP_DIR, f)
|
||||
const stats = fs.statSync(filepath)
|
||||
return {
|
||||
name: f,
|
||||
size: stats.size,
|
||||
date: stats.mtime.toISOString()
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
}
|
||||
Reference in New Issue
Block a user