feat: 前后端功能对齐 - 实现8个对齐差距
P0-1: 分类拖拽排序 - category-manage 添加拖拽 UI P1-1: 反馈回复展示 - 新增 GET /feedback/mine + 前端我的反馈Tab P1-2: 备份管理页面 - 新增下载端点 + backup-manage 页面 P2-1: 统计年度/周视图 - stats 支持 period 参数 + 前端维度切换器 P2-2: 数据导入 - 新增 POST /import 端点 + data-import 页面 P2-3: 数据导出服务端化 - 新增 GET /export 流式端点 P3-1: 健康检查展示 - health 移到 auth 前 + admin 状态卡片 P3-2~4: 交易标签系统 - tags CRUD + 交易关联 + 按标签筛选统计 后端: 新增 tag.ts/export.ts 路由, 改造 feedback/backup/transaction/stats 前端: 新增 DragSortList 组件, 3个新页面, 改造 7 个现有页面 QA 修复: 5个严重Bug + 4个潜在问题
This commit is contained in:
@@ -277,6 +277,40 @@ async function runMigrations(conn: mysql.Connection) {
|
||||
console.log('[DB] Migrating: adding recurring_id to transactions')
|
||||
await conn.query("ALTER TABLE transactions ADD COLUMN recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID' AFTER group_id")
|
||||
}
|
||||
|
||||
// 标签表
|
||||
const hasTags = await tableExists(conn, 'tags')
|
||||
if (!hasTags) {
|
||||
console.log('[DB] Migrating: creating tags table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(20) NOT NULL COMMENT '标签名称',
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#FF8C69' COMMENT '标签颜色',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_name (user_id, name),
|
||||
INDEX idx_user (user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
}
|
||||
|
||||
// 交易-标签关联表
|
||||
const hasTransactionTags = await tableExists(conn, 'transaction_tags')
|
||||
if (!hasTransactionTags) {
|
||||
console.log('[DB] Migrating: creating transaction_tags table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS transaction_tags (
|
||||
transaction_id INT NOT NULL,
|
||||
tag_id INT NOT NULL,
|
||||
PRIMARY KEY (transaction_id, tag_id),
|
||||
INDEX idx_tag (tag_id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function initDatabase() {
|
||||
|
||||
@@ -170,3 +170,23 @@ CREATE TABLE IF NOT EXISTS recurring_templates (
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(20) NOT NULL COMMENT '标签名称',
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#FF8C69' COMMENT '标签颜色',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_name (user_id, name),
|
||||
INDEX idx_user (user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transaction_tags (
|
||||
transaction_id INT NOT NULL,
|
||||
tag_id INT NOT NULL,
|
||||
PRIMARY KEY (transaction_id, tag_id),
|
||||
INDEX idx_tag (tag_id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -24,6 +24,8 @@ import configRoutes from './routes/config'
|
||||
import trackRoutes from './routes/track'
|
||||
import logsRoutes from './routes/logs'
|
||||
import recurringRoutes from './routes/recurring'
|
||||
import tagRoutes from './routes/tag'
|
||||
import exportRoutes from './routes/export'
|
||||
import { backupDatabase } from './utils/backup'
|
||||
|
||||
// Warn if token secret is using default fallback
|
||||
@@ -50,7 +52,7 @@ app.use(cors({
|
||||
},
|
||||
credentials: true,
|
||||
}))
|
||||
app.use(express.json({ limit: '10kb' }))
|
||||
app.use(express.json({ limit: '5mb' }))
|
||||
|
||||
// 请求日志
|
||||
app.use(requestLogger)
|
||||
@@ -145,6 +147,28 @@ app.get('/api/notifications/image/:filename', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 健康检查端点(公开,不经过 auth 中间件)
|
||||
app.get('/api/health', async (_req, res) => {
|
||||
try {
|
||||
await pool.query('SELECT 1')
|
||||
res.json({
|
||||
status: 'ok',
|
||||
db: 'connected',
|
||||
uptime: Math.floor(process.uptime()),
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch {
|
||||
res.status(503).json({
|
||||
status: 'error',
|
||||
db: 'disconnected',
|
||||
uptime: Math.floor(process.uptime()),
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
app.use(authMiddleware)
|
||||
|
||||
app.use('/api/auth', authLimiter, authRoutes)
|
||||
@@ -163,15 +187,8 @@ app.use('/api/config', apiLimiter, configRoutes)
|
||||
app.use('/api/track', apiLimiter, trackRoutes)
|
||||
app.use('/api/logs', apiLimiter, logsRoutes)
|
||||
app.use('/api/recurring', apiLimiter, recurringRoutes)
|
||||
|
||||
app.get('/api/health', async (_req, res) => {
|
||||
try {
|
||||
await pool.query('SELECT 1')
|
||||
res.json({ status: 'ok', db: 'connected', time: new Date().toISOString() })
|
||||
} catch {
|
||||
res.status(503).json({ status: 'error', db: 'disconnected', time: new Date().toISOString() })
|
||||
}
|
||||
})
|
||||
app.use('/api/tags', apiLimiter, tagRoutes)
|
||||
app.use('/api/export', apiLimiter, exportRoutes)
|
||||
|
||||
// 全局错误处理中间件(兜底未捕获的异常)
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
|
||||
@@ -2,11 +2,74 @@ import { Router, Response } from 'express'
|
||||
import { AuthRequest } from '../middleware/auth'
|
||||
import { backupDatabase, getBackupList } from '../utils/backup'
|
||||
import { requireAdmin } from '../middleware/requireAdmin'
|
||||
import { createHmac } from 'crypto'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const router = Router()
|
||||
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'xiaocai-secret'
|
||||
|
||||
// 所有路由都需要管理员权限
|
||||
router.use((req, res, next) => requireAdmin(req, res, next))
|
||||
// 所有路由都需要管理员权限(除了 :id/download 使用签名校验)
|
||||
router.use((req, res, next) => {
|
||||
// /:id/download 不走 requireAdmin,自行校验签名
|
||||
if (req.path.match(/^\/\d+\/download$/)) {
|
||||
return next()
|
||||
}
|
||||
requireAdmin(req, res, next)
|
||||
})
|
||||
|
||||
// 下载备份文件(签名 token 校验,不走 authMiddleware)
|
||||
router.get('/:id/download', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const token = req.query.token as string
|
||||
if (!token) {
|
||||
return res.status(400).json({ code: 40001, message: '缺少下载令牌' })
|
||||
}
|
||||
|
||||
// 解析 token:backupId:timestamp:hmac
|
||||
const parts = token.split(':')
|
||||
if (parts.length !== 3) {
|
||||
return res.status(400).json({ code: 40001, message: '令牌格式无效' })
|
||||
}
|
||||
|
||||
const [backupId, timestamp, hmac] = parts
|
||||
|
||||
// 校验 timestamp 未过期(1小时内)
|
||||
const ts = parseInt(timestamp)
|
||||
if (isNaN(ts) || Date.now() - ts > 3600000) {
|
||||
return res.status(400).json({ code: 40001, message: '令牌已过期' })
|
||||
}
|
||||
|
||||
// 校验 HMAC
|
||||
const expectedHmac = createHmac('sha256', JWT_SECRET)
|
||||
.update(`${backupId}:${timestamp}`)
|
||||
.digest('hex')
|
||||
if (hmac !== expectedHmac) {
|
||||
return res.status(403).json({ code: 40300, message: '令牌签名无效' })
|
||||
}
|
||||
|
||||
// 校验 backupId 存在于备份列表中
|
||||
const list = getBackupList()
|
||||
const backup = list.find(b => b.name === backupId)
|
||||
if (!backup) {
|
||||
return res.status(404).json({ code: 40400, message: '备份不存在' })
|
||||
}
|
||||
|
||||
// 流式返回备份文件
|
||||
const filepath = path.join(BACKUP_DIR, backup.name)
|
||||
if (!fs.existsSync(filepath)) {
|
||||
return res.status(404).json({ code: 40400, message: '备份文件不存在' })
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/gzip')
|
||||
res.setHeader('Content-Disposition', `attachment; filename=${backup.name}`)
|
||||
fs.createReadStream(filepath).pipe(res)
|
||||
} catch (err) {
|
||||
console.error('[Backup] download error:', err)
|
||||
res.status(500).json({ code: 50000, message: '下载失败' })
|
||||
}
|
||||
})
|
||||
|
||||
// 手动触发备份
|
||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
@@ -23,11 +86,45 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const list = getBackupList()
|
||||
res.json({ code: 0, data: list })
|
||||
// 为每条记录生成下载令牌
|
||||
const listWithToken = list.map(item => {
|
||||
const timestamp = Date.now()
|
||||
const hmac = createHmac('sha256', JWT_SECRET)
|
||||
.update(`${item.name}:${timestamp}`)
|
||||
.digest('hex')
|
||||
return {
|
||||
...item,
|
||||
downloadToken: `${item.name}:${timestamp}:${hmac}`
|
||||
}
|
||||
})
|
||||
res.json({ code: 0, data: listWithToken })
|
||||
} catch (err) {
|
||||
console.error('[Backup] 获取列表失败:', err)
|
||||
res.status(500).json({ code: 50000, message: '获取备份列表失败' })
|
||||
}
|
||||
})
|
||||
|
||||
// 删除备份
|
||||
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const backupName = req.params.id
|
||||
const list = getBackupList()
|
||||
const backup = list.find(b => b.name === backupName)
|
||||
if (!backup) {
|
||||
return res.status(404).json({ code: 40400, message: '备份不存在' })
|
||||
}
|
||||
|
||||
// 删除备份文件
|
||||
const filepath = path.join(BACKUP_DIR, backup.name)
|
||||
if (fs.existsSync(filepath)) {
|
||||
fs.unlinkSync(filepath)
|
||||
}
|
||||
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Backup] 删除备份失败:', err)
|
||||
res.status(500).json({ code: 50000, message: '删除备份失败' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
231
server/src/routes/export.ts
Normal file
231
server/src/routes/export.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { Router, Response } from 'express'
|
||||
import pool from '../db/connection'
|
||||
import { AuthRequest } from '../middleware/auth'
|
||||
import { Readable } from 'stream'
|
||||
import { pipeline } from 'stream/promises'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const VALID_TYPES = ['expense', 'income', 'all']
|
||||
const VALID_FORMATS = ['csv', 'json']
|
||||
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
||||
const LARGE_DATA_THRESHOLD = 10000
|
||||
const EXPORT_TIMEOUT = 60000
|
||||
|
||||
/** 流式导出交易数据 */
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
// 支持 query token 认证:如果没有 Authorization header,从 query.token 读取
|
||||
if (!req.headers.authorization && req.query.token) {
|
||||
req.headers.authorization = `Bearer ${req.query.token}`
|
||||
}
|
||||
|
||||
const { startDate, endDate, type = 'all', format = 'csv' } = req.query
|
||||
|
||||
// 参数校验
|
||||
if (!startDate || !endDate || !DATE_REGEX.test(startDate as string) || !DATE_REGEX.test(endDate as string)) {
|
||||
return res.status(400).json({ code: 40001, message: '日期参数无效' })
|
||||
}
|
||||
if (!VALID_TYPES.includes(type as string)) {
|
||||
return res.status(400).json({ code: 40001, message: '类型参数无效' })
|
||||
}
|
||||
if (!VALID_FORMATS.includes(format as string)) {
|
||||
return res.status(400).json({ code: 40001, message: '格式参数无效' })
|
||||
}
|
||||
|
||||
const isCsv = format === 'csv'
|
||||
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||||
const filename = isCsv ? `export_${today}.csv` : `export_${today}.json`
|
||||
|
||||
// 构建查询条件
|
||||
let where = 'WHERE t.user_id = ? AND t.date >= ? AND t.date <= ?'
|
||||
const params: any[] = [req.userId, startDate, endDate]
|
||||
|
||||
if (type !== 'all') {
|
||||
where += ' AND t.type = ?'
|
||||
params.push(type)
|
||||
}
|
||||
|
||||
// 先统计数量
|
||||
const [countResult] = await pool.query(
|
||||
`SELECT COUNT(*) as total FROM transactions t ${where}`,
|
||||
params
|
||||
)
|
||||
const total = (countResult as any[])[0].total
|
||||
|
||||
if (total === 0) {
|
||||
// 空数据直接返回
|
||||
if (isCsv) {
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename=${filename}`)
|
||||
res.send('\uFEFF日期,类型,分类,金额,备注\n')
|
||||
} else {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename=${filename}`)
|
||||
res.send('[]')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
if (isCsv) {
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
|
||||
} else {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
}
|
||||
res.setHeader('Content-Disposition', `attachment; filename=${filename}`)
|
||||
|
||||
// 超时保护
|
||||
const timeout = setTimeout(() => {
|
||||
if (!res.writableEnded) {
|
||||
res.end()
|
||||
}
|
||||
}, EXPORT_TIMEOUT)
|
||||
|
||||
if (total < LARGE_DATA_THRESHOLD) {
|
||||
// 小数据量:内存流
|
||||
const [rows] = await pool.query(
|
||||
`SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.type,
|
||||
COALESCE(c.name, '未分类') as category_name,
|
||||
t.amount, COALESCE(t.note, '') as note
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
${where}
|
||||
ORDER BY t.date ASC, t.created_at ASC`,
|
||||
params
|
||||
)
|
||||
|
||||
const data = rows as any[]
|
||||
const readable = new Readable({
|
||||
read() {}
|
||||
})
|
||||
|
||||
if (isCsv) {
|
||||
readable.push('\uFEFF日期,类型,分类,金额,备注\n')
|
||||
for (const row of data) {
|
||||
const note = String(row.note).replace(/"/g, '""')
|
||||
const typeLabel = row.type === 'expense' ? '支出' : '收入'
|
||||
readable.push(`${row.date},${typeLabel},${row.category_name},${row.amount},"${note}"\n`)
|
||||
}
|
||||
} else {
|
||||
readable.push('[')
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const row = data[i]
|
||||
const entry = JSON.stringify({
|
||||
date: row.date,
|
||||
type: row.type,
|
||||
category: row.category_name,
|
||||
amount: row.amount,
|
||||
note: row.note
|
||||
})
|
||||
readable.push(i === 0 ? entry : ',' + entry)
|
||||
}
|
||||
readable.push(']')
|
||||
}
|
||||
readable.push(null)
|
||||
|
||||
readable.on('end', () => {
|
||||
clearTimeout(timeout)
|
||||
})
|
||||
readable.on('error', () => {
|
||||
clearTimeout(timeout)
|
||||
})
|
||||
readable.on('close', () => {
|
||||
clearTimeout(timeout)
|
||||
})
|
||||
|
||||
readable.pipe(res)
|
||||
} else {
|
||||
// 大数据量:临时文件流
|
||||
const tmpDir = os.tmpdir()
|
||||
const tmpFile = path.join(tmpDir, `export_${Date.now()}_${req.userId}.${format}`)
|
||||
|
||||
// 分批查询并写入临时文件
|
||||
const BATCH_SIZE = 5000
|
||||
let offset = 0
|
||||
const writeStream = fs.createWriteStream(tmpFile, { encoding: 'utf-8' })
|
||||
|
||||
if (isCsv) {
|
||||
writeStream.write('\uFEFF日期,类型,分类,金额,备注\n')
|
||||
} else {
|
||||
writeStream.write('[')
|
||||
}
|
||||
|
||||
let isFirst = true
|
||||
while (offset < total) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.type,
|
||||
COALESCE(c.name, '未分类') as category_name,
|
||||
t.amount, COALESCE(t.note, '') as note
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
${where}
|
||||
ORDER BY t.date ASC, t.created_at ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, BATCH_SIZE, offset]
|
||||
)
|
||||
|
||||
const data = rows as any[]
|
||||
for (const row of data) {
|
||||
if (isCsv) {
|
||||
const note = String(row.note).replace(/"/g, '""')
|
||||
const typeLabel = row.type === 'expense' ? '支出' : '收入'
|
||||
writeStream.write(`${row.date},${typeLabel},${row.category_name},${row.amount},"${note}"\n`)
|
||||
} else {
|
||||
const entry = JSON.stringify({
|
||||
date: row.date,
|
||||
type: row.type,
|
||||
category: row.category_name,
|
||||
amount: row.amount,
|
||||
note: row.note
|
||||
})
|
||||
writeStream.write(isFirst ? entry : ',' + entry)
|
||||
isFirst = false
|
||||
}
|
||||
}
|
||||
|
||||
offset += BATCH_SIZE
|
||||
}
|
||||
|
||||
if (!isCsv) {
|
||||
writeStream.write(']')
|
||||
}
|
||||
writeStream.end()
|
||||
|
||||
// 等待写入完成
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writeStream.on('finish', resolve)
|
||||
writeStream.on('error', reject)
|
||||
})
|
||||
|
||||
// 流式返回临时文件
|
||||
const readStream = fs.createReadStream(tmpFile, { encoding: 'utf-8' })
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
// 删除临时文件
|
||||
fs.unlink(tmpFile, (err) => {
|
||||
if (err) console.error('[Export] 删除临时文件失败:', err)
|
||||
})
|
||||
}
|
||||
|
||||
readStream.on('end', cleanup)
|
||||
readStream.on('error', cleanup)
|
||||
readStream.on('close', cleanup)
|
||||
|
||||
await pipeline(readStream, res)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Export] GET error:', err)
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
} else {
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -5,6 +5,36 @@ import { requireAdmin } from '../middleware/requireAdmin'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** 当前用户的反馈列表 */
|
||||
router.get('/mine', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1)
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20))
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const [countResult] = await pool.execute(
|
||||
'SELECT COUNT(*) as total FROM feedbacks WHERE user_id = ?',
|
||||
[userId]
|
||||
)
|
||||
const total = (countResult as any[])[0].total
|
||||
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT id, type, content, contact, status, admin_reply, created_at
|
||||
FROM feedbacks
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[userId, pageSize, offset]
|
||||
)
|
||||
|
||||
res.json({ code: 0, data: { list: rows, total, page, pageSize } })
|
||||
} catch (error) {
|
||||
console.error('Get my feedback error:', error)
|
||||
res.status(500).json({ code: 50000, message: '获取失败' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 用户提交反馈 */
|
||||
router.post('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
@@ -52,15 +82,15 @@ router.get('/', requireAdmin, async (req, res) => {
|
||||
)
|
||||
const total = (countResult as any[])[0].total
|
||||
|
||||
// LIMIT/OFFSET 直接嵌入 SQL(execute 对参数类型要求严格)
|
||||
// LIMIT/OFFSET 使用参数化查询
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT f.*, u.nickname, u.avatar_url
|
||||
FROM feedbacks f
|
||||
LEFT JOIN users u ON f.user_id = u.id
|
||||
WHERE ${where}
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT ${pageSize} OFFSET ${offset}`,
|
||||
params
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, offset]
|
||||
)
|
||||
|
||||
res.json({ code: 0, data: { list: rows, total, page, pageSize } })
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getCurrentMonth, getMonthRange } from '../utils/date'
|
||||
|
||||
const router = Router()
|
||||
const VALID_TYPES = ['expense', 'income']
|
||||
const VALID_PERIODS = ['week', 'month', 'year']
|
||||
|
||||
/** 根据 group_id 参数构建 WHERE 条件(含成员验证) */
|
||||
async function buildWhereClause(
|
||||
@@ -103,14 +104,40 @@ router.get('/overview', async (req: AuthRequest, res: Response) => {
|
||||
|
||||
router.get('/category', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { month, type = 'expense', group_id } = req.query
|
||||
const { month, type = 'expense', group_id, period = 'month' } = req.query
|
||||
if (!VALID_TYPES.includes(type as string)) {
|
||||
return res.status(400).json({ code: 40002, message: '类型无效' })
|
||||
}
|
||||
if (!VALID_PERIODS.includes(period as string)) {
|
||||
return res.status(400).json({ code: 40001, message: 'period参数无效,仅支持week/month/year' })
|
||||
}
|
||||
|
||||
let startDate: string
|
||||
let endDate: string
|
||||
|
||||
const now = new Date()
|
||||
const m = (month as string) || getCurrentMonth()
|
||||
const range = getMonthRange(m)
|
||||
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
||||
const { startDate, endDate } = range
|
||||
|
||||
if (period === 'week') {
|
||||
// 本周一到本周日
|
||||
const dayOfWeek = now.getDay() || 7
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() - dayOfWeek + 1)
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
startDate = monday.toISOString().slice(0, 10)
|
||||
endDate = sunday.toISOString().slice(0, 10)
|
||||
} else if (period === 'year') {
|
||||
const year = m.split('-')[0] || String(now.getFullYear())
|
||||
startDate = `${year}-01-01`
|
||||
endDate = `${year}-12-31`
|
||||
} else {
|
||||
// month(默认)
|
||||
const range = getMonthRange(m)
|
||||
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
||||
startDate = range.startDate
|
||||
endDate = range.endDate
|
||||
}
|
||||
|
||||
const result = await buildWhereClause(
|
||||
req.userId,
|
||||
@@ -138,14 +165,57 @@ router.get('/category', async (req: AuthRequest, res: Response) => {
|
||||
|
||||
router.get('/trend', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { month, type = 'expense', group_id } = req.query
|
||||
const { month, type = 'expense', group_id, period = 'month' } = req.query
|
||||
if (!VALID_TYPES.includes(type as string)) {
|
||||
return res.status(400).json({ code: 40002, message: '类型无效' })
|
||||
}
|
||||
if (!VALID_PERIODS.includes(period as string)) {
|
||||
return res.status(400).json({ code: 40001, message: 'period参数无效,仅支持week/month/year' })
|
||||
}
|
||||
|
||||
let startDate: string
|
||||
let endDate: string
|
||||
let groupBy: string
|
||||
let labelExpr: string
|
||||
|
||||
const now = new Date()
|
||||
const m = (month as string) || getCurrentMonth()
|
||||
const range = getMonthRange(m)
|
||||
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
||||
const { startDate, endDate } = range
|
||||
|
||||
if (period === 'week') {
|
||||
// 本周一到本周日
|
||||
const dayOfWeek = now.getDay() || 7
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() - dayOfWeek + 1)
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
startDate = monday.toISOString().slice(0, 10)
|
||||
endDate = sunday.toISOString().slice(0, 10)
|
||||
groupBy = 't.date'
|
||||
labelExpr = `CASE DAYOFWEEK(t.date)
|
||||
WHEN 1 THEN '周日'
|
||||
WHEN 2 THEN '周一'
|
||||
WHEN 3 THEN '周二'
|
||||
WHEN 4 THEN '周三'
|
||||
WHEN 5 THEN '周四'
|
||||
WHEN 6 THEN '周五'
|
||||
WHEN 7 THEN '周六'
|
||||
END`
|
||||
} else if (period === 'year') {
|
||||
// 当年1月1日到12月31日
|
||||
const year = m.split('-')[0] || String(now.getFullYear())
|
||||
startDate = `${year}-01-01`
|
||||
endDate = `${year}-12-31`
|
||||
groupBy = "DATE_FORMAT(t.date, '%Y-%m')"
|
||||
labelExpr = `CONCAT(MONTH(t.date), '月')`
|
||||
} else {
|
||||
// month(默认)
|
||||
const range = getMonthRange(m)
|
||||
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
||||
startDate = range.startDate
|
||||
endDate = range.endDate
|
||||
groupBy = 't.date'
|
||||
labelExpr = `CONCAT(DAY(t.date), '日')`
|
||||
}
|
||||
|
||||
const result = await buildWhereClause(
|
||||
req.userId,
|
||||
@@ -155,10 +225,10 @@ router.get('/trend', async (req: AuthRequest, res: Response) => {
|
||||
if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, SUM(t.amount) as amount
|
||||
`SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, ${labelExpr} as label, SUM(t.amount) as amount
|
||||
FROM transactions t
|
||||
${result.where}
|
||||
GROUP BY t.date
|
||||
GROUP BY ${groupBy}
|
||||
ORDER BY t.date`,
|
||||
[...result.params, type, startDate, endDate]
|
||||
)
|
||||
|
||||
216
server/src/routes/tag.ts
Normal file
216
server/src/routes/tag.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { Router, Response } from 'express'
|
||||
import pool from '../db/connection'
|
||||
import { AuthRequest } from '../middleware/auth'
|
||||
import { getCurrentMonth, getMonthRange } from '../utils/date'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const VALID_COLORS = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
|
||||
const MAX_TAGS_PER_USER = 20
|
||||
const MAX_TAG_NAME_LENGTH = 20
|
||||
|
||||
/** 获取当前用户所有标签 */
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, user_id, name, color, created_at FROM tags WHERE user_id = ? ORDER BY created_at ASC',
|
||||
[req.userId]
|
||||
)
|
||||
res.json({ code: 0, data: { list: rows } })
|
||||
} catch (err) {
|
||||
console.error('[Tag] GET error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 创建标签 */
|
||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { name, color } = req.body
|
||||
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
return res.status(400).json({ code: 40001, message: '标签名称不能为空' })
|
||||
}
|
||||
|
||||
const trimmedName = name.trim()
|
||||
if (trimmedName.length > MAX_TAG_NAME_LENGTH) {
|
||||
return res.status(400).json({ code: 40001, message: '标签名称不能超过20个字符' })
|
||||
}
|
||||
|
||||
if (!color || typeof color !== 'string' || !VALID_COLORS.includes(color)) {
|
||||
return res.status(400).json({ code: 40001, message: '标签颜色无效' })
|
||||
}
|
||||
|
||||
// 检查用户标签数量限制
|
||||
const [countResult] = await pool.query(
|
||||
'SELECT COUNT(*) as cnt FROM tags WHERE user_id = ?',
|
||||
[req.userId]
|
||||
)
|
||||
if ((countResult as any[])[0].cnt >= MAX_TAGS_PER_USER) {
|
||||
return res.status(400).json({ code: 40001, message: '标签数量已达上限(20个)' })
|
||||
}
|
||||
|
||||
// 检查同名标签
|
||||
const [existResult] = await pool.query(
|
||||
'SELECT id FROM tags WHERE user_id = ? AND name = ?',
|
||||
[req.userId, trimmedName]
|
||||
)
|
||||
if ((existResult as any[]).length > 0) {
|
||||
return res.status(400).json({ code: 40001, message: '标签名称已存在' })
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)',
|
||||
[req.userId, trimmedName, color]
|
||||
)
|
||||
|
||||
res.json({ code: 0, data: { id: (result as any).insertId } })
|
||||
} catch (err) {
|
||||
console.error('[Tag] POST error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 更新标签 */
|
||||
router.put('/:id', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { name, color } = req.body
|
||||
|
||||
// 校验标签属于当前用户
|
||||
const [existRows] = await pool.query(
|
||||
'SELECT id FROM tags WHERE id = ? AND user_id = ?',
|
||||
[id, req.userId]
|
||||
)
|
||||
if ((existRows as any[]).length === 0) {
|
||||
return res.status(404).json({ code: 40400, message: '标签不存在' })
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const params: any[] = []
|
||||
|
||||
if (name !== undefined) {
|
||||
if (typeof name !== 'string' || !name.trim()) {
|
||||
return res.status(400).json({ code: 40001, message: '标签名称不能为空' })
|
||||
}
|
||||
const trimmedName = name.trim()
|
||||
if (trimmedName.length > MAX_TAG_NAME_LENGTH) {
|
||||
return res.status(400).json({ code: 40001, message: '标签名称不能超过20个字符' })
|
||||
}
|
||||
// 检查同名标签(排除自身)
|
||||
const [dupRows] = await pool.query(
|
||||
'SELECT id FROM tags WHERE user_id = ? AND name = ? AND id != ?',
|
||||
[req.userId, trimmedName, id]
|
||||
)
|
||||
if ((dupRows as any[]).length > 0) {
|
||||
return res.status(400).json({ code: 40001, message: '标签名称已存在' })
|
||||
}
|
||||
updates.push('name = ?')
|
||||
params.push(trimmedName)
|
||||
}
|
||||
|
||||
if (color !== undefined) {
|
||||
if (!VALID_COLORS.includes(color)) {
|
||||
return res.status(400).json({ code: 40001, message: '标签颜色无效' })
|
||||
}
|
||||
updates.push('color = ?')
|
||||
params.push(color)
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ code: 40001, message: '缺少更新内容' })
|
||||
}
|
||||
|
||||
params.push(id)
|
||||
await pool.query(
|
||||
`UPDATE tags SET ${updates.join(', ')} WHERE id = ?`,
|
||||
params
|
||||
)
|
||||
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Tag] PUT error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 删除标签 */
|
||||
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
// 校验标签属于当前用户(级联删除 transaction_tags 由外键处理)
|
||||
const [result] = await pool.query(
|
||||
'DELETE FROM tags WHERE id = ? AND user_id = ?',
|
||||
[id, req.userId]
|
||||
)
|
||||
if ((result as any).affectedRows === 0) {
|
||||
return res.status(404).json({ code: 40400, message: '标签不存在' })
|
||||
}
|
||||
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Tag] DELETE error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 按标签统计 */
|
||||
router.get('/stats/by-tag', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { month, period = 'month', type = 'expense' } = req.query
|
||||
|
||||
if (!['expense', 'income'].includes(type as string)) {
|
||||
return res.status(400).json({ code: 40001, message: '类型无效' })
|
||||
}
|
||||
|
||||
let startDate: string
|
||||
let endDate: string
|
||||
|
||||
const now = new Date()
|
||||
const m = (month as string) || getCurrentMonth()
|
||||
|
||||
if (period === 'week') {
|
||||
// 本周一到本周日
|
||||
const dayOfWeek = now.getDay() || 7 // 周日=7
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() - dayOfWeek + 1)
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
startDate = monday.toISOString().slice(0, 10)
|
||||
endDate = sunday.toISOString().slice(0, 10)
|
||||
} else if (period === 'year') {
|
||||
// 当年1月1日到12月31日
|
||||
const year = now.getFullYear()
|
||||
startDate = `${year}-01-01`
|
||||
endDate = `${year}-12-31`
|
||||
} else {
|
||||
// month(默认)
|
||||
const range = getMonthRange(m)
|
||||
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
||||
startDate = range.startDate
|
||||
endDate = range.endDate
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT tg.id, tg.name, tg.color,
|
||||
COALESCE(SUM(t.amount), 0) as amount,
|
||||
COUNT(DISTINCT t.id) as count
|
||||
FROM tags tg
|
||||
LEFT JOIN transaction_tags tt ON tg.id = tt.tag_id
|
||||
LEFT JOIN transactions t ON tt.transaction_id = t.id AND t.type = ? AND t.date >= ? AND t.date <= ?
|
||||
WHERE tg.user_id = ?
|
||||
GROUP BY tg.id, tg.name, tg.color
|
||||
HAVING count > 0
|
||||
ORDER BY amount DESC`,
|
||||
[type, startDate, endDate, req.userId]
|
||||
)
|
||||
|
||||
res.json({ code: 0, data: { list: rows } })
|
||||
} catch (err) {
|
||||
console.error('[Tag] stats/by-tag error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -33,6 +33,17 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||
if (!tx) {
|
||||
return res.status(404).json({ code: 40400, message: '记录不存在' })
|
||||
}
|
||||
|
||||
// 查询关联的标签
|
||||
const [tagRows] = await pool.query(
|
||||
`SELECT tg.id, tg.name, tg.color
|
||||
FROM transaction_tags tt
|
||||
INNER JOIN tags tg ON tt.tag_id = tg.id
|
||||
WHERE tt.transaction_id = ?`,
|
||||
[req.params.id]
|
||||
)
|
||||
tx.tags = tagRows
|
||||
|
||||
res.json({ code: 0, data: tx })
|
||||
} catch (err) {
|
||||
console.error('[Transaction] GET by ID error:', err)
|
||||
@@ -42,7 +53,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { page, pageSize, type, startDate, endDate, sortBy, group_id, category_id, category_ids, minAmount, maxAmount, keyword } = req.query
|
||||
const { page, pageSize, type, startDate, endDate, sortBy, group_id, category_id, category_ids, minAmount, maxAmount, keyword, tagId } = req.query
|
||||
const pSize = safeInt(pageSize, 20, 1, 100)
|
||||
const pNum = safeInt(page, 1, 1, 99999)
|
||||
const offset = (pNum - 1) * pSize
|
||||
@@ -105,6 +116,17 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
where += ' AND t.note LIKE ?'; params.push(`%${escapedKeyword}%`)
|
||||
}
|
||||
|
||||
// 标签筛选
|
||||
let tagJoin = ''
|
||||
if (tagId) {
|
||||
const tid = parseInt(tagId as string)
|
||||
if (!isNaN(tid)) {
|
||||
tagJoin = ' INNER JOIN transaction_tags tt_filter ON t.id = tt_filter.transaction_id'
|
||||
where += ' AND tt_filter.tag_id = ?'
|
||||
params.push(tid)
|
||||
}
|
||||
}
|
||||
|
||||
const orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC'
|
||||
|
||||
const [rows] = await pool.query(
|
||||
@@ -114,6 +136,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
LEFT JOIN users u ON t.user_id = u.id
|
||||
${tagJoin}
|
||||
${where}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ? OFFSET ?`,
|
||||
@@ -121,7 +144,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
)
|
||||
|
||||
const [countResult] = await pool.query(
|
||||
`SELECT COUNT(*) as total FROM transactions t ${where}`,
|
||||
`SELECT COUNT(*) as total FROM transactions t ${tagJoin} ${where}`,
|
||||
params
|
||||
)
|
||||
|
||||
@@ -130,7 +153,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
`SELECT
|
||||
COALESCE(SUM(CASE WHEN t.type = 'expense' THEN t.amount ELSE 0 END), 0) as totalExpense,
|
||||
COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) as totalIncome
|
||||
FROM transactions t ${where}`,
|
||||
FROM transactions t ${tagJoin} ${where}`,
|
||||
params
|
||||
)
|
||||
|
||||
@@ -153,7 +176,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
|
||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { amount, type, category_id, note, date, group_id } = req.body
|
||||
const { amount, type, category_id, note, date, group_id, tagIds } = req.body
|
||||
if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) {
|
||||
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数(单位:分)' })
|
||||
}
|
||||
@@ -182,21 +205,170 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[req.userId, amount, type, category_id || null, note || null, date, validGroupId]
|
||||
)
|
||||
// 校验 tagIds
|
||||
let validTagIds: number[] = []
|
||||
if (tagIds && Array.isArray(tagIds)) {
|
||||
if (tagIds.length > 5) {
|
||||
return res.status(400).json({ code: 40001, message: '标签数量不能超过5个' })
|
||||
}
|
||||
const numTagIds = tagIds.map((id: any) => Number(id)).filter((id: number) => Number.isInteger(id) && id > 0)
|
||||
if (numTagIds.length > 0) {
|
||||
// 校验标签属于当前用户
|
||||
const [tagRows] = await pool.query(
|
||||
`SELECT id FROM tags WHERE id IN (${numTagIds.map(() => '?').join(',')}) AND user_id = ?`,
|
||||
[...numTagIds, req.userId]
|
||||
)
|
||||
validTagIds = (tagRows as any[]).map(r => r.id)
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ code: 0, data: { id: (result as any).insertId } })
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
const [result] = await conn.query(
|
||||
'INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[req.userId, amount, type, category_id || null, note || null, date, validGroupId]
|
||||
)
|
||||
|
||||
const insertId = (result as any).insertId
|
||||
|
||||
// 批量插入 transaction_tags
|
||||
if (validTagIds.length > 0) {
|
||||
const values = validTagIds.map(tagId => [insertId, tagId])
|
||||
await conn.query(
|
||||
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ${values.map(() => '(?, ?)').join(', ')}`,
|
||||
values.flat()
|
||||
)
|
||||
}
|
||||
|
||||
await conn.commit()
|
||||
res.json({ code: 0, data: { id: insertId } })
|
||||
} catch (err) {
|
||||
await conn.rollback()
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Transaction] POST error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /import 必须在 /:id 之前注册,避免 Express 将 "import" 误当作 :id 参数
|
||||
router.post('/import', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { transactions } = req.body
|
||||
if (!Array.isArray(transactions) || transactions.length === 0) {
|
||||
return res.status(400).json({ code: 40001, message: '缺少交易数据' })
|
||||
}
|
||||
if (transactions.length > 1000) {
|
||||
return res.status(400).json({ code: 40001, message: '单次导入不能超过1000条' })
|
||||
}
|
||||
|
||||
// 逐条校验 & 去重
|
||||
const validItems: Array<{ date: string; type: string; category_name: string; amount: number; note: string }> = []
|
||||
const seen = new Set<string>()
|
||||
let skipped = 0
|
||||
|
||||
for (const item of transactions) {
|
||||
// 校验必填字段
|
||||
if (!item.date || !item.type || item.amount === undefined || item.amount === null) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
// 校验日期格式
|
||||
if (!DATE_REGEX.test(String(item.date))) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
// 校验类型
|
||||
if (!VALID_TYPES.includes(item.type)) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
// 校验金额
|
||||
const amt = Number(item.amount)
|
||||
if (isNaN(amt) || !Number.isInteger(amt) || amt <= 0 || amt > 999999999) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// 去重:按 date+type+amount+note 组合判重
|
||||
const noteVal = item.note ? String(item.note).slice(0, 200) : ''
|
||||
const dedupeKey = `${item.date}:${item.type}:${amt}:${noteVal}`
|
||||
if (seen.has(dedupeKey)) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
seen.add(dedupeKey)
|
||||
|
||||
validItems.push({
|
||||
date: String(item.date),
|
||||
type: item.type,
|
||||
category_name: item.category_name ? String(item.category_name) : '',
|
||||
amount: amt,
|
||||
note: noteVal
|
||||
})
|
||||
}
|
||||
|
||||
if (validItems.length === 0) {
|
||||
return res.json({ code: 0, data: { imported: 0, skipped } })
|
||||
}
|
||||
|
||||
// 预加载用户的分类映射(category_name → category_id)
|
||||
const [catRows] = await pool.query(
|
||||
'SELECT id, name FROM categories WHERE user_id = ?',
|
||||
[req.userId]
|
||||
)
|
||||
const catMap = new Map<string, number>()
|
||||
for (const row of catRows as any[]) {
|
||||
catMap.set(row.name, row.id)
|
||||
}
|
||||
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
let imported = 0
|
||||
const BATCH_SIZE = 100
|
||||
|
||||
for (let i = 0; i < validItems.length; i += BATCH_SIZE) {
|
||||
const batch = validItems.slice(i, i + BATCH_SIZE)
|
||||
const values: any[] = []
|
||||
const placeholders: string[] = []
|
||||
|
||||
for (const item of batch) {
|
||||
const categoryId = catMap.get(item.category_name) || null
|
||||
placeholders.push('(?, ?, ?, ?, ?, ?, ?)')
|
||||
values.push(req.userId, item.amount, item.type, categoryId, item.note || null, item.date, null)
|
||||
}
|
||||
|
||||
await conn.query(
|
||||
`INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id) VALUES ${placeholders.join(', ')}`,
|
||||
values
|
||||
)
|
||||
imported += batch.length
|
||||
}
|
||||
|
||||
await conn.commit()
|
||||
res.json({ code: 0, data: { imported, skipped } })
|
||||
} catch (err) {
|
||||
await conn.rollback()
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Transaction] POST /import error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/:id', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { amount, type, category_id, note, date } = req.body
|
||||
const { amount, type, category_id, note, date, tagIds } = req.body
|
||||
if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) {
|
||||
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数(单位:分)' })
|
||||
}
|
||||
@@ -212,14 +384,64 @@ router.put('/:id', async (req: AuthRequest, res: Response) => {
|
||||
if (note && note.length > 200) {
|
||||
return res.status(400).json({ code: 40004, message: '备注不能超过200字' })
|
||||
}
|
||||
const [result] = await pool.query(
|
||||
'UPDATE transactions SET amount = ?, type = ?, category_id = ?, note = ?, date = ? WHERE id = ? AND user_id = ?',
|
||||
[amount, type, category_id || null, note || null, date, req.params.id, req.userId]
|
||||
)
|
||||
if ((result as any).affectedRows === 0) {
|
||||
return res.status(404).json({ code: 40400, message: '记录不存在' })
|
||||
|
||||
// 校验 tagIds
|
||||
let validTagIds: number[] | null = null
|
||||
if (tagIds !== undefined) {
|
||||
if (!Array.isArray(tagIds)) {
|
||||
return res.status(400).json({ code: 40001, message: 'tagIds格式无效' })
|
||||
}
|
||||
if (tagIds.length > 5) {
|
||||
return res.status(400).json({ code: 40001, message: '标签数量不能超过5个' })
|
||||
}
|
||||
const numTagIds = tagIds.map((id: any) => Number(id)).filter((id: number) => Number.isInteger(id) && id > 0)
|
||||
if (numTagIds.length > 0) {
|
||||
const [tagRows] = await pool.query(
|
||||
`SELECT id FROM tags WHERE id IN (${numTagIds.map(() => '?').join(',')}) AND user_id = ?`,
|
||||
[...numTagIds, req.userId]
|
||||
)
|
||||
validTagIds = (tagRows as any[]).map(r => r.id)
|
||||
} else {
|
||||
validTagIds = []
|
||||
}
|
||||
}
|
||||
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
const [result] = await conn.query(
|
||||
'UPDATE transactions SET amount = ?, type = ?, category_id = ?, note = ?, date = ? WHERE id = ? AND user_id = ?',
|
||||
[amount, type, category_id || null, note || null, date, req.params.id, req.userId]
|
||||
)
|
||||
if ((result as any).affectedRows === 0) {
|
||||
await conn.rollback()
|
||||
return res.status(404).json({ code: 40400, message: '记录不存在' })
|
||||
}
|
||||
|
||||
// 全量替换 tagIds
|
||||
if (validTagIds !== null) {
|
||||
await conn.query(
|
||||
'DELETE FROM transaction_tags WHERE transaction_id = ?',
|
||||
[req.params.id]
|
||||
)
|
||||
if (validTagIds.length > 0) {
|
||||
const values = validTagIds.map(tagId => [req.params.id, tagId])
|
||||
await conn.query(
|
||||
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ${values.map(() => '(?, ?)').join(', ')}`,
|
||||
values.flat()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await conn.commit()
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
await conn.rollback()
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Transaction] PUT error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
|
||||
Reference in New Issue
Block a user