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:
12
server/.env.example
Normal file
12
server/.env.example
Normal 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
2236
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
server/package.json
Normal file
25
server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
17
server/src/db/connection.ts
Normal file
17
server/src/db/connection.ts
Normal 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
51
server/src/db/init.ts
Normal 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
48
server/src/db/schema.sql
Normal 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
17
server/src/db/seed.sql
Normal 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
74
server/src/index.ts
Normal 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'))
|
||||
64
server/src/middleware/auth.ts
Normal file
64
server/src/middleware/auth.ts
Normal 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
53
server/src/routes/auth.ts
Normal 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
|
||||
49
server/src/routes/budget.ts
Normal file
49
server/src/routes/budget.ts
Normal 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
|
||||
57
server/src/routes/category.ts
Normal file
57
server/src/routes/category.ts
Normal 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
|
||||
91
server/src/routes/stats.ts
Normal file
91
server/src/routes/stats.ts
Normal 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
|
||||
165
server/src/routes/transaction.ts
Normal file
165
server/src/routes/transaction.ts
Normal 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
17
server/src/utils/date.ts
Normal 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
19
server/tsconfig.json
Normal 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
1032
server/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user