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:
2026-06-10 17:30:36 +08:00
parent 5df910c9f1
commit 31f6487d61
36 changed files with 4162 additions and 64 deletions

View File

@@ -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() {

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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: '缺少下载令牌' })
}
// 解析 tokenbackupId: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
View 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

View File

@@ -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 直接嵌入 SQLexecute 对参数类型要求严格)
// 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 } })

View File

@@ -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
View 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

View File

@@ -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: '服务器错误' })