feat: 小菜记账 v1.0 - 完整功能实现

核心功能:
- 记账CRUD(支出/收入/分类/备注/日期)
- 统计分析(概览/分类占比/每日趋势)
- 预算管理(按月设置/进度条/超支提醒)
- 数据导出CSV

安全与认证:
- HMAC-SHA256签名token认证
- 用户数据隔离
- 输入验证与错误处理
- CORS配置

前端优化:
- 骨架屏加载
- 账单按日期分组
- 预算页面重构(快捷预设+Numpad)
- SvgIcon组件(H5+微信双端适配)
- 下拉刷新

后端优化:
- 共享日期工具函数
- 数据库连接池优化
- 健康检查端点
- 优雅关闭处理

技术栈:
- 前端:Uni-app (Vue 3 + Pinia)
- 后端:Node.js + Express + MySQL
This commit is contained in:
2026-05-29 16:14:15 +08:00
commit c7f29aa7a7
70 changed files with 32282 additions and 0 deletions

12
server/.env.example Normal file
View File

@@ -0,0 +1,12 @@
# Server
PORT=3000
# MySQL
DB_HOST=your_mysql_host_here
DB_USER=your_mysql_user_here
DB_PASSWORD=your_mysql_password_here
DB_NAME=your_mysql_name_here
# WeChat Mini Program
WX_APPID=your_wx_appid_here
WX_SECRET=your_wx_secret_here

2236
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
server/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "xiaocai-server",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc && shx cp src/db/*.sql dist/db/",
"start": "node dist/index.js",
"db:init": "tsx src/db/init.ts"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^4.18.2",
"mysql2": "^3.9.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.11.0",
"shx": "^0.4.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}

View File

@@ -0,0 +1,17 @@
import mysql from 'mysql2/promise'
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'xiaocai',
password: process.env.DB_PASSWORD || 'xiaocai123',
database: process.env.DB_NAME || 'xiaocai',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
})
;(pool as any).on('error', (err: any) => {
console.error('[DB] Pool error:', err?.message || err)
})
export default pool

51
server/src/db/init.ts Normal file
View File

@@ -0,0 +1,51 @@
import mysql from 'mysql2/promise'
import pool from './connection'
import { readFileSync } from 'fs'
import { join } from 'path'
export async function initDatabase() {
const dbName = process.env.DB_NAME || 'xiaocai'
// 先用不指定数据库的连接,确保数据库存在
const bootstrap = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'xiaocai',
password: process.env.DB_PASSWORD || 'xiaocai123',
multipleStatements: true,
})
await bootstrap.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`)
await bootstrap.end()
// 用独立连接执行建表和填充(需要 multipleStatements
const initConn = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'xiaocai',
password: process.env.DB_PASSWORD || 'xiaocai123',
database: dbName,
multipleStatements: true,
})
try {
const schemaPath = join(__dirname, 'schema.sql')
const seedPath = join(__dirname, 'seed.sql')
const schema = readFileSync(schemaPath, 'utf-8')
const cleanSchema = schema
.replace(/CREATE DATABASE.*?;/gi, '')
.replace(/USE\s+\w+;/gi, '')
await initConn.query(cleanSchema)
const seed = readFileSync(seedPath, 'utf-8')
await initConn.query(seed)
console.log('[DB] Database initialized')
} catch (err: any) {
if (err.code === 'ER_DUP_ENTRY' || err.errno === 1062) {
console.log('[DB] Already initialized, skipping seed')
} else {
console.error('[DB] Init failed:', err.message)
}
} finally {
await initConn.end()
}
}

48
server/src/db/schema.sql Normal file
View File

@@ -0,0 +1,48 @@
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
openid VARCHAR(100) UNIQUE,
session_key VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS categories (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT DEFAULT 0 COMMENT '0=默认分类, 其他=用户自定义',
name VARCHAR(50) NOT NULL,
icon VARCHAR(50) NOT NULL,
color VARCHAR(20) NOT NULL,
type ENUM('expense', 'income') NOT NULL,
sort_order INT DEFAULT 0,
is_custom TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_name_type (user_id, name, type),
INDEX idx_user_custom (user_id, is_custom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS transactions (
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,
note VARCHAR(200),
date DATE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_date (user_id, date),
INDEX idx_user_type_date (user_id, type, date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS budgets (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
amount INT NOT NULL COMMENT '单位:分',
month VARCHAR(7) NOT NULL COMMENT '格式2026-05',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_month (user_id, month),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

17
server/src/db/seed.sql Normal file
View File

@@ -0,0 +1,17 @@
INSERT IGNORE INTO categories (user_id, name, icon, color, type, sort_order) VALUES
(0, '餐饮', 'Coffee', '#FF8C69', 'expense', 1),
(0, '饮品', 'Coffee', '#FF8C69', 'expense', 2),
(0, '购物', 'ShoppingBag', '#FFB347', 'expense', 3),
(0, '交通', 'Car', '#6C9BCF', 'expense', 4),
(0, '住房', 'Home', '#7BC67E', 'expense', 5),
(0, '医疗', 'Heart', '#FF6B6B', 'expense', 6),
(0, '教育', 'BookOpen', '#6C9BCF', 'expense', 7),
(0, '娱乐', 'Film', '#B39DDB', 'expense', 8),
(0, '服饰', 'Shirt', '#FFD166', 'expense', 9),
(0, '美容', 'Smile', '#FF8C69', 'expense', 10),
(0, '通讯', 'Smartphone', '#6C9BCF', 'expense', 11),
(0, '其他', 'MoreHorizontal', '#BFB3B3', 'expense', 12),
(0, '工资', 'DollarSign', '#7BC67E', 'income', 1),
(0, '副业', 'TrendingUp', '#7BC67E', 'income', 2),
(0, '理财', 'PieChart', '#7BC67E', 'income', 3),
(0, '其他收入', 'Plus', '#BFB3B3', 'income', 4);

74
server/src/index.ts Normal file
View File

@@ -0,0 +1,74 @@
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import { authMiddleware } from './middleware/auth'
import { initDatabase } from './db/init'
import pool from './db/connection'
import authRoutes from './routes/auth'
import transactionRoutes from './routes/transaction'
import statsRoutes from './routes/stats'
import budgetRoutes from './routes/budget'
import categoryRoutes from './routes/category'
// Warn if token secret is using default fallback
if (!process.env.TOKEN_SECRET) {
console.warn('[Security] TOKEN_SECRET not set — using default fallback. Set TOKEN_SECRET in .env for production!')
}
const app = express()
const PORT = process.env.PORT || 3000
const ALLOWED_ORIGINS = (process.env.CORS_ORIGINS || '').split(',').filter(Boolean)
app.use(cors({
origin: (origin, callback) => {
// No origin = WeChat mini-program request (always allow)
if (!origin) return callback(null, true)
// If no CORS_ORIGINS configured, allow all (dev mode)
if (ALLOWED_ORIGINS.length === 0) return callback(null, true)
// Check whitelist
if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true)
callback(null, false)
},
credentials: true,
}))
app.use(express.json({ limit: '10kb' }))
app.use(authMiddleware)
app.use('/api/auth', authRoutes)
app.use('/api/transactions', transactionRoutes)
app.use('/api/stats', statsRoutes)
app.use('/api/budget', budgetRoutes)
app.use('/api/categories', categoryRoutes)
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() })
}
})
let server: any
initDatabase().then(() => {
server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})
})
// Graceful shutdown
function shutdown(signal: string) {
console.log(`[Server] ${signal} received, shutting down gracefully...`)
server?.close(() => {
pool.end().then(() => {
console.log('[Server] All connections closed')
process.exit(0)
})
})
// Force exit after 10s
setTimeout(() => process.exit(1), 10000)
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))

View File

@@ -0,0 +1,64 @@
import { Request, Response, NextFunction } from 'express'
import { createHmac } from 'crypto'
export interface AuthRequest extends Request {
userId?: number
}
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
const TOKEN_EXPIRY = 30 * 24 * 60 * 60 * 1000 // 30 days
// 不需要认证的路径
const PUBLIC_PATHS = ['/api/auth/login', '/api/health']
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
// 公开路径跳过认证
if (PUBLIC_PATHS.some(p => req.path === p)) {
return next()
}
// 从 Authorization header 获取 token
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ code: 40100, message: '未登录' })
}
const token = authHeader.slice(7)
try {
const decoded = Buffer.from(token, 'base64').toString('utf-8')
const parts = decoded.split(':')
if (parts.length !== 3) {
return res.status(401).json({ code: 40100, message: 'token无效' })
}
const [userIdStr, timestamp, signature] = parts
const userId = parseInt(userIdStr)
const ts = parseInt(timestamp)
if (isNaN(userId) || isNaN(ts)) {
return res.status(401).json({ code: 40100, message: 'token无效' })
}
// Check expiry and reject future timestamps
const now = Date.now()
if (now - ts > TOKEN_EXPIRY) {
return res.status(401).json({ code: 40101, message: 'token已过期' })
}
if (ts > now + 60000) {
return res.status(401).json({ code: 40100, message: 'token无效' })
}
// Verify HMAC signature (full length)
const payload = `${userId}:${timestamp}`
const expectedSig = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
if (signature !== expectedSig) {
return res.status(401).json({ code: 40100, message: 'token无效' })
}
req.userId = userId
} catch {
return res.status(401).json({ code: 40100, message: 'token无效' })
}
next()
}

53
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,53 @@
import { Router, Request, Response } from 'express'
import { createHmac } from 'crypto'
import pool from '../db/connection'
const router = Router()
const APPID = process.env.WX_APPID || ''
const SECRET = process.env.WX_SECRET || ''
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
export function signToken(userId: number): string {
const payload = `${userId}:${Date.now()}`
const signature = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
return Buffer.from(`${payload}:${signature}`).toString('base64')
}
router.post('/login', async (req: Request, res: Response) => {
try {
const { code } = req.body
if (!code) {
return res.status(400).json({ code: 40001, message: '缺少code参数' })
}
// 调用微信 code2session 接口
const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${APPID}&secret=${SECRET}&js_code=${code}&grant_type=authorization_code`
const wxRes = await fetch(url)
const wxData = await wxRes.json() as any
if (wxData.errcode) {
console.error('[Auth] WeChat API error:', wxData.errcode, wxData.errmsg)
return res.status(400).json({ code: 40002, message: '微信登录失败' })
}
const { openid, session_key } = wxData
// 查找或创建用户 (atomic: handles race condition)
const [result] = await pool.query(
'INSERT INTO users (openid, session_key) VALUES (?, ?) ON DUPLICATE KEY UPDATE session_key = ?, id = LAST_INSERT_ID(id)',
[openid, session_key, session_key]
)
const userId = (result as any).insertId
// HMAC signed token with expiry
const token = signToken(userId)
res.json({ code: 0, data: { token, userId } })
} catch (err: any) {
console.error('[Auth] Login failed:', err.message)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -0,0 +1,49 @@
import { Router, Response } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
import { getCurrentMonth, getMonthRange } from '../utils/date'
const router = Router()
function validateMonth(m: string): string {
return getMonthRange(m) ? m : getCurrentMonth()
}
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const month = validateMonth((req.query.month as string) || getCurrentMonth())
const [rows] = await pool.query(
'SELECT * FROM budgets WHERE user_id = ? AND month = ?',
[req.userId, month]
)
const budget = (rows as any[])[0] || null
res.json({ code: 0, data: budget })
} catch (err) {
console.error('[Budget] GET error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const { amount, month } = 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之间的正整数单位' })
}
const m = validateMonth(month || getCurrentMonth())
await pool.query(
'INSERT INTO budgets (user_id, amount, month) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE amount = ?',
[req.userId, amount, m, amount]
)
res.json({ code: 0 })
} catch (err) {
console.error('[Budget] POST error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -0,0 +1,57 @@
import { Router, Response } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
const router = Router()
router.get('/', async (req: AuthRequest, res: Response) => {
try {
// Returns: default categories (user_id=0) + user's own custom categories
const [rows] = await pool.query(
'SELECT * FROM categories WHERE user_id = 0 OR user_id = ? ORDER BY sort_order',
[req.userId]
)
res.json({ code: 0, data: rows })
} catch (err) {
console.error('[Category] GET error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const { name, icon, color, type } = req.body
if (!name || !icon || !color || !type) {
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
}
if (!['expense', 'income'].includes(type)) {
return res.status(400).json({ code: 40002, message: '类型无效' })
}
const [result] = await pool.query(
'INSERT INTO categories (user_id, name, icon, color, type, is_custom) VALUES (?, ?, ?, ?, ?, 1)',
[req.userId, name, icon, color, type]
)
res.json({ code: 0, data: { id: (result as any).insertId } })
} catch (err) {
console.error('[Category] POST error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const [result] = await pool.query(
'DELETE FROM categories WHERE id = ? AND is_custom = 1 AND user_id = ?',
[req.params.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('[Category] DELETE error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -0,0 +1,91 @@
import { Router, Response } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
import { getCurrentMonth, getMonthRange } from '../utils/date'
const router = Router()
router.get('/overview', async (req: AuthRequest, res: Response) => {
try {
const { month } = req.query
const m = (month as string) || getCurrentMonth()
const range = getMonthRange(m)
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
const { startDate, endDate } = range
const [rows] = await pool.query(
`SELECT
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as expense,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as income,
COUNT(*) as count
FROM transactions WHERE user_id = ? AND date >= ? AND date <= ?`,
[req.userId, startDate, endDate]
)
const row = (rows as any[])[0]
const now = new Date()
const [year, mon] = m.split('-').map(Number)
const isCurrentMonth = year === now.getFullYear() && mon === now.getMonth() + 1
const elapsedDays = isCurrentMonth ? now.getDate() : new Date(year, mon, 0).getDate()
const daily = elapsedDays > 0 ? Math.round(row.expense / elapsedDays) : 0
res.json({
code: 0,
data: { expense: row.expense, income: row.income, count: row.count, daily }
})
} catch (err) {
console.error('[Stats] overview error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.get('/category', async (req: AuthRequest, res: Response) => {
try {
const { month, type = 'expense' } = req.query
const m = (month as string) || getCurrentMonth()
const range = getMonthRange(m)
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
const { startDate, endDate } = range
const [rows] = await pool.query(
`SELECT c.id, COALESCE(c.name, '未分类') as name, COALESCE(c.icon, '?') as icon, COALESCE(c.color, '#BFB3B3') as color, SUM(t.amount) as amount, COUNT(t.id) as count
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.user_id = ? AND t.type = ? AND t.date >= ? AND t.date <= ?
GROUP BY c.id
ORDER BY amount DESC`,
[req.userId, type, startDate, endDate]
)
res.json({ code: 0, data: rows })
} catch (err) {
console.error('[Stats] category error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.get('/trend', async (req: AuthRequest, res: Response) => {
try {
const { month, type = 'expense' } = req.query
const m = (month as string) || getCurrentMonth()
const range = getMonthRange(m)
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
const { startDate, endDate } = range
const [rows] = await pool.query(
`SELECT date, SUM(amount) as amount
FROM transactions
WHERE user_id = ? AND type = ? AND date >= ? AND date <= ?
GROUP BY date
ORDER BY date`,
[req.userId, type, startDate, endDate]
)
res.json({ code: 0, data: rows })
} catch (err) {
console.error('[Stats] trend error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -0,0 +1,165 @@
import { Router, Response } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
const router = Router()
const VALID_TYPES = ['expense', 'income']
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
function safeInt(val: any, fallback: number, min: number, max: number): number {
const n = parseInt(val)
if (isNaN(n)) return fallback
return Math.min(Math.max(n, min), max)
}
router.get('/:id', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
`SELECT t.*, c.name as category_name, c.icon as category_icon, c.color as category_color
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.id = ? AND t.user_id = ?`,
[req.params.id, req.userId]
)
const tx = (rows as any[])[0]
if (!tx) {
return res.status(404).json({ code: 40400, message: '记录不存在' })
}
res.json({ code: 0, data: tx })
} catch (err) {
console.error('[Transaction] GET by ID error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const { page, pageSize, type, startDate, endDate } = req.query
const pSize = safeInt(pageSize, 20, 1, 100)
const pNum = safeInt(page, 1, 1, 99999)
const offset = (pNum - 1) * pSize
let where = 'WHERE t.user_id = ?'
const params: any[] = [req.userId]
if (type && VALID_TYPES.includes(type as string)) {
where += ' AND t.type = ?'; params.push(type)
}
if (startDate && DATE_REGEX.test(startDate as string)) {
where += ' AND t.date >= ?'; params.push(startDate)
}
if (endDate && DATE_REGEX.test(endDate as string)) {
where += ' AND t.date <= ?'; params.push(endDate)
}
const [rows] = await pool.query(
`SELECT t.*, c.name as category_name, c.icon as category_icon, c.color as category_color
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
${where}
ORDER BY t.date DESC, t.created_at DESC
LIMIT ? OFFSET ?`,
[...params, pSize, offset]
)
const [countResult] = await pool.query(
`SELECT COUNT(*) as total FROM transactions t ${where}`,
params
)
res.json({
code: 0,
data: {
list: rows,
total: (countResult as any)[0].total,
page: pNum,
pageSize: pSize
}
})
} catch (err) {
console.error('[Transaction] GET error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const { amount, type, category_id, note, date } = 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之间的正整数单位' })
}
if (!type || !date) {
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
}
if (!VALID_TYPES.includes(type)) {
return res.status(400).json({ code: 40002, message: '类型无效' })
}
if (!DATE_REGEX.test(date)) {
return res.status(400).json({ code: 40003, message: '日期格式无效' })
}
if (note && note.length > 200) {
return res.status(400).json({ code: 40004, message: '备注不能超过200字' })
}
const [result] = await pool.query(
'INSERT INTO transactions (user_id, amount, type, category_id, note, date) VALUES (?, ?, ?, ?, ?, ?)',
[req.userId, amount, type, category_id || null, note || null, date]
)
res.json({ code: 0, data: { id: (result as any).insertId } })
} catch (err) {
console.error('[Transaction] POST 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
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之间的正整数单位' })
}
if (!type || !date) {
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
}
if (!VALID_TYPES.includes(type)) {
return res.status(400).json({ code: 40002, message: '类型无效' })
}
if (!DATE_REGEX.test(date)) {
return res.status(400).json({ code: 40003, message: '日期格式无效' })
}
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: '记录不存在' })
}
res.json({ code: 0 })
} catch (err) {
console.error('[Transaction] PUT error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const [result] = await pool.query(
'DELETE FROM transactions WHERE id = ? AND user_id = ?',
[req.params.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('[Transaction] DELETE error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

17
server/src/utils/date.ts Normal file
View File

@@ -0,0 +1,17 @@
const MONTH_REGEX = /^\d{4}-(0[1-9]|1[0-2])$/
export function getCurrentMonth(): string {
const d = new Date()
const offset = 8 * 60 * 60 * 1000
const local = new Date(d.getTime() + offset)
return local.toISOString().slice(0, 7)
}
export function getMonthRange(m: string): { startDate: string; endDate: string } | null {
if (!MONTH_REGEX.test(m)) return null
const [year, mon] = m.split('-').map(Number)
const startDate = `${m}-01`
const lastDay = new Date(year, mon, 0).getDate()
const endDate = `${m}-${String(lastDay).padStart(2, '0')}`
return { startDate, endDate }
}

19
server/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

1032
server/yarn.lock Normal file

File diff suppressed because it is too large Load Diff