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:
2026-06-03 17:04:22 +08:00
parent 9162b2c87c
commit 21f4d81959
45 changed files with 3145 additions and 348 deletions

View File

@@ -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 []