feat: 周期账单 — 自动生成定期交易(房租/订阅/工资)
This commit is contained in:
@@ -243,6 +243,40 @@ async function runMigrations(conn: mysql.Connection) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
}
|
||||
|
||||
// 周期账单模板表
|
||||
const hasRecurring = await tableExists(conn, 'recurring_templates')
|
||||
if (!hasRecurring) {
|
||||
console.log('[DB] Migrating: creating recurring_templates table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS recurring_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
amount INT NOT NULL COMMENT '单位:分',
|
||||
type ENUM('expense', 'income') NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
note VARCHAR(200) DEFAULT '',
|
||||
frequency ENUM('weekly', 'monthly', 'yearly') NOT NULL DEFAULT 'monthly',
|
||||
next_date DATE NOT NULL COMMENT '下次生成日期',
|
||||
end_date DATE DEFAULT NULL COMMENT '截止日期,NULL=永久',
|
||||
is_active TINYINT(1) DEFAULT 1,
|
||||
group_id INT DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_next_date (next_date),
|
||||
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
|
||||
`)
|
||||
}
|
||||
|
||||
// transactions 表添加 recurring_id
|
||||
const hasRecurringId = await columnExists(conn, 'transactions', 'recurring_id')
|
||||
if (!hasRecurringId) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
export async function initDatabase() {
|
||||
|
||||
@@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS transactions (
|
||||
note VARCHAR(200),
|
||||
date DATE NOT NULL,
|
||||
group_id INT DEFAULT NULL COMMENT '群组标签(仅标记,群组账单通过 user_id 关联 group_members 统计)',
|
||||
recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_date (user_id, date),
|
||||
@@ -149,3 +150,23 @@ CREATE TABLE IF NOT EXISTS track_events (
|
||||
INDEX idx_created (created_at),
|
||||
INDEX idx_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recurring_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
amount INT NOT NULL COMMENT '单位:分',
|
||||
type ENUM('expense', 'income') NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
note VARCHAR(200) DEFAULT '',
|
||||
frequency ENUM('weekly', 'monthly', 'yearly') NOT NULL DEFAULT 'monthly',
|
||||
next_date DATE NOT NULL COMMENT '下次生成日期',
|
||||
end_date DATE DEFAULT NULL COMMENT '截止日期,NULL=永久',
|
||||
is_active TINYINT(1) DEFAULT 1,
|
||||
group_id INT DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_next_date (next_date),
|
||||
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;
|
||||
|
||||
@@ -23,6 +23,7 @@ import feedbackRoutes from './routes/feedback'
|
||||
import configRoutes from './routes/config'
|
||||
import trackRoutes from './routes/track'
|
||||
import logsRoutes from './routes/logs'
|
||||
import recurringRoutes from './routes/recurring'
|
||||
import { backupDatabase } from './utils/backup'
|
||||
|
||||
// Warn if token secret is using default fallback
|
||||
@@ -161,6 +162,7 @@ app.use('/api/feedback', apiLimiter, feedbackRoutes)
|
||||
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 {
|
||||
|
||||
205
server/src/routes/recurring.ts
Normal file
205
server/src/routes/recurring.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { Router } from 'express'
|
||||
import pool from '../db/connection'
|
||||
import { authMiddleware } from '../middleware/auth'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
/** 获取用户的周期模板列表 */
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT rt.*, c.name as category_name, c.icon as category_icon, c.color as category_color
|
||||
FROM recurring_templates rt
|
||||
LEFT JOIN categories c ON rt.category_id = c.id
|
||||
WHERE rt.user_id = ?
|
||||
ORDER BY rt.is_active DESC, rt.next_date ASC`,
|
||||
[userId]
|
||||
)
|
||||
res.json({ code: 0, data: rows })
|
||||
} catch (error) {
|
||||
console.error('Get recurring templates error:', error)
|
||||
res.status(500).json({ code: 50000, message: '获取失败' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 创建周期模板 */
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId
|
||||
const { amount, type, category_id, note, frequency, next_date, end_date, group_id } = req.body
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
return res.status(400).json({ code: 40001, message: '请输入金额' })
|
||||
}
|
||||
if (!type || !['expense', 'income'].includes(type)) {
|
||||
return res.status(400).json({ code: 40001, message: '请选择类型' })
|
||||
}
|
||||
if (!category_id) {
|
||||
return res.status(400).json({ code: 40001, message: '请选择分类' })
|
||||
}
|
||||
if (!frequency || !['weekly', 'monthly', 'yearly'].includes(frequency)) {
|
||||
return res.status(400).json({ code: 40001, message: '请选择周期' })
|
||||
}
|
||||
if (!next_date) {
|
||||
return res.status(400).json({ code: 40001, message: '请选择开始日期' })
|
||||
}
|
||||
|
||||
const [result] = await pool.execute(
|
||||
`INSERT INTO recurring_templates (user_id, amount, type, category_id, note, frequency, next_date, end_date, group_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[userId, amount, type, category_id, note || '', frequency, next_date, end_date || null, group_id || null]
|
||||
)
|
||||
res.json({ code: 0, data: { id: (result as any).insertId } })
|
||||
} catch (error) {
|
||||
console.error('Create recurring template error:', error)
|
||||
res.status(500).json({ code: 50000, message: '创建失败' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 更新周期模板 */
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId
|
||||
const { id } = req.params
|
||||
const { amount, type, category_id, note, frequency, next_date, end_date, is_active } = req.body
|
||||
|
||||
// 校验所有权
|
||||
const [rows] = await pool.execute(
|
||||
'SELECT id FROM recurring_templates WHERE id = ? AND user_id = ?',
|
||||
[id, userId]
|
||||
)
|
||||
if ((rows as any[]).length === 0) {
|
||||
return res.status(404).json({ code: 40400, message: '模板不存在' })
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const params: any[] = []
|
||||
|
||||
if (amount !== undefined) { updates.push('amount = ?'); params.push(amount) }
|
||||
if (type !== undefined) { updates.push('type = ?'); params.push(type) }
|
||||
if (category_id !== undefined) { updates.push('category_id = ?'); params.push(category_id) }
|
||||
if (note !== undefined) { updates.push('note = ?'); params.push(note) }
|
||||
if (frequency !== undefined) { updates.push('frequency = ?'); params.push(frequency) }
|
||||
if (next_date !== undefined) { updates.push('next_date = ?'); params.push(next_date) }
|
||||
if (end_date !== undefined) { updates.push('end_date = ?'); params.push(end_date) }
|
||||
if (is_active !== undefined) { updates.push('is_active = ?'); params.push(is_active) }
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ code: 40001, message: '无更新字段' })
|
||||
}
|
||||
|
||||
params.push(id, userId)
|
||||
await pool.execute(
|
||||
`UPDATE recurring_templates SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`,
|
||||
params
|
||||
)
|
||||
res.json({ code: 0 })
|
||||
} catch (error) {
|
||||
console.error('Update recurring template error:', error)
|
||||
res.status(500).json({ code: 50000, message: '更新失败' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 删除周期模板 */
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId
|
||||
const { id } = req.params
|
||||
|
||||
const [rows] = await pool.execute(
|
||||
'SELECT id FROM recurring_templates WHERE id = ? AND user_id = ?',
|
||||
[id, userId]
|
||||
)
|
||||
if ((rows as any[]).length === 0) {
|
||||
return res.status(404).json({ code: 40400, message: '模板不存在' })
|
||||
}
|
||||
|
||||
await pool.execute(
|
||||
'DELETE FROM recurring_templates WHERE id = ? AND user_id = ?',
|
||||
[id, userId]
|
||||
)
|
||||
res.json({ code: 0 })
|
||||
} catch (error) {
|
||||
console.error('Delete recurring template error:', error)
|
||||
res.status(500).json({ code: 50000, message: '删除失败' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 同步:检查所有活跃模板,生成到期交易 */
|
||||
router.post('/sync', async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId
|
||||
// 获取该用户所有活跃且到期(next_date <= 今天)的模板
|
||||
const [templates] = await pool.execute(
|
||||
`SELECT * FROM recurring_templates
|
||||
WHERE user_id = ? AND is_active = 1 AND next_date <= CURDATE()
|
||||
ORDER BY next_date ASC`,
|
||||
[userId]
|
||||
)
|
||||
|
||||
let generated = 0
|
||||
const today = new Date()
|
||||
const todayStr = today.toISOString().slice(0, 10)
|
||||
|
||||
for (const t of templates as any[]) {
|
||||
let next = new Date(t.next_date)
|
||||
const end = t.end_date ? new Date(t.end_date) : null
|
||||
|
||||
// 生成所有错过的交易
|
||||
while (next <= today) {
|
||||
if (end && next > end) break
|
||||
|
||||
// 检查是否已生成过(避免重复)
|
||||
const [existing] = await pool.execute(
|
||||
`SELECT id FROM transactions
|
||||
WHERE user_id = ? AND recurring_id = ? AND date = ?`,
|
||||
[userId, t.id, next.toISOString().slice(0, 10)]
|
||||
)
|
||||
if ((existing as any[]).length === 0) {
|
||||
await pool.execute(
|
||||
`INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id, recurring_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[userId, t.amount, t.type, t.category_id, t.note,
|
||||
next.toISOString().slice(0, 10), t.group_id, t.id]
|
||||
)
|
||||
generated++
|
||||
}
|
||||
|
||||
// 推进到下一周期
|
||||
if (t.frequency === 'weekly') {
|
||||
next = new Date(next.getTime() + 7 * 86400000)
|
||||
} else if (t.frequency === 'monthly') {
|
||||
next = new Date(next.getFullYear(), next.getMonth() + 1, next.getDate())
|
||||
} else if (t.frequency === 'yearly') {
|
||||
next = new Date(next.getFullYear() + 1, next.getMonth(), next.getDate())
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 next_date 到下一周期
|
||||
let newNext: Date
|
||||
if (t.frequency === 'weekly') {
|
||||
newNext = new Date(new Date(t.next_date).getTime() + 7 * 86400000)
|
||||
} else if (t.frequency === 'monthly') {
|
||||
const d = new Date(t.next_date)
|
||||
newNext = new Date(d.getFullYear(), d.getMonth() + 1, d.getDate())
|
||||
} else {
|
||||
const d = new Date(t.next_date)
|
||||
newNext = new Date(d.getFullYear() + 1, d.getMonth(), d.getDate())
|
||||
}
|
||||
|
||||
await pool.execute(
|
||||
'UPDATE recurring_templates SET next_date = ? WHERE id = ?',
|
||||
[newNext.toISOString().slice(0, 10), t.id]
|
||||
)
|
||||
}
|
||||
|
||||
res.json({ code: 0, data: { generated } })
|
||||
} catch (error) {
|
||||
console.error('Sync recurring error:', error)
|
||||
res.status(500).json({ code: 50000, message: '同步失败' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user