feat: 用户信息完善 + 一起记功能
- users 表添加 nickname、avatar_url 字段 - 用户信息 API(GET/PUT /me)+ 头像上传(multer) - 头像通过 API 路由获取(公开,不经过 auth) - 登录接口返回用户昵称和头像 - 用户信息 Store + 个人资料编辑页面 - 我的页面和首页显示真实用户信息 - 群组表(groups、group_members)+ transactions 添加 group_id - 群组 API(创建/加入/退出/解散) - 交易和统计 API 支持 group_id 视图切换 - 群组客户端 Store + 身份切换 - 群组管理页面 - App 初始化群组 Store - UPLOAD_DIR 配置化 - Node.js 备份替代 mysqldump - 统一前端 BASE_URL(config.ts) - uploads 加入 .gitignore - 修复 trust proxy 警告
This commit is contained in:
@@ -1,21 +1,18 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import zlib from 'zlib'
|
||||
import pool from '../db/connection'
|
||||
|
||||
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 备份文件路径
|
||||
* 使用 Node.js mysql2 备份数据库(不依赖 mysqldump)
|
||||
* 导出所有表的 INSERT 语句,gzip 压缩存储
|
||||
*/
|
||||
export async function backupDatabase(): Promise<string> {
|
||||
// 确保备份目录存在
|
||||
if (!fs.existsSync(BACKUP_DIR)) {
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true })
|
||||
}
|
||||
@@ -24,64 +21,64 @@ export async function backupDatabase(): Promise<string> {
|
||||
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'
|
||||
const lines: string[] = []
|
||||
lines.push(`-- 小菜记账 数据库备份`)
|
||||
lines.push(`-- 时间: ${new Date().toISOString()}`)
|
||||
lines.push('')
|
||||
|
||||
try {
|
||||
// 使用 execFile 避免命令注入,参数通过数组传递
|
||||
const args = [
|
||||
`-h${dbHost}`,
|
||||
`-u${dbUser}`,
|
||||
...(dbPassword ? [`-p${dbPassword}`] : []),
|
||||
dbName
|
||||
]
|
||||
// 获取所有表名
|
||||
const [tables] = await pool.query('SHOW TABLES')
|
||||
const tableNames = (tables as any[]).map(row => Object.values(row)[0] as string)
|
||||
|
||||
const { stdout } = await execFileAsync('mysqldump', args, { maxBuffer: 50 * 1024 * 1024 })
|
||||
for (const tableName of tableNames) {
|
||||
lines.push(`-- ----------------------------`)
|
||||
lines.push(`-- 表: ${tableName}`)
|
||||
lines.push(`-- ----------------------------`)
|
||||
|
||||
// 使用 Node.js zlib 压缩
|
||||
const compressed = await gzipAsync(Buffer.from(stdout, 'utf-8'))
|
||||
fs.writeFileSync(filepath, compressed)
|
||||
// 获取表数据
|
||||
const [rows] = await pool.query(`SELECT * FROM \`${tableName}\``)
|
||||
const data = rows as any[]
|
||||
|
||||
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]}`)
|
||||
if (data.length === 0) {
|
||||
lines.push(`-- ${tableName}: 无数据`)
|
||||
lines.push('')
|
||||
continue
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Backup] 清理失败:', err)
|
||||
|
||||
// 获取列名
|
||||
const columns = Object.keys(data[0])
|
||||
const colList = columns.map(c => `\`${c}\``).join(', ')
|
||||
|
||||
// 生成 INSERT 语句(每 100 行一批)
|
||||
const BATCH_SIZE = 100
|
||||
for (let i = 0; i < data.length; i += BATCH_SIZE) {
|
||||
const batch = data.slice(i, i + BATCH_SIZE)
|
||||
const values = batch.map(row => {
|
||||
const vals = columns.map(col => {
|
||||
const v = row[col]
|
||||
if (v === null) return 'NULL'
|
||||
if (typeof v === 'number') return String(v)
|
||||
if (typeof v === 'boolean') return v ? '1' : '0'
|
||||
// mysql2 escape 处理所有字符串/日期转义(自带引号)
|
||||
return pool.escape(v)
|
||||
})
|
||||
return `(${vals.join(', ')})`
|
||||
}).join(',\n ')
|
||||
|
||||
lines.push(`INSERT INTO \`${tableName}\` (${colList}) VALUES`)
|
||||
lines.push(` ${values};`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
const sql = lines.join('\n')
|
||||
const compressed = await gzipAsync(Buffer.from(sql, 'utf-8'))
|
||||
fs.writeFileSync(filepath, compressed)
|
||||
|
||||
console.log(`[Backup] 完成: ${filename} (${tableNames.length} 表)`)
|
||||
return filepath
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取备份列表
|
||||
*/
|
||||
export function getBackupList(): Array<{ name: string; size: number; date: string }> {
|
||||
if (!fs.existsSync(BACKUP_DIR)) return []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user