diff --git a/public/index.html b/public/index.html index bcb292c..3718801 100644 --- a/public/index.html +++ b/public/index.html @@ -460,9 +460,9 @@ - + - + diff --git a/public/js/app.js b/public/js/app.js index 6360819..25ac46e 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,11 +1,62 @@ +// ============================================ +// 应用状态管理 +// ============================================ const API = ''; -let token = localStorage.getItem('token'); -let username = localStorage.getItem('username'); -let currentType = 'income'; -let currentCategory = '接单'; -let currentYear = new Date().getFullYear(); -let currentMonth = new Date().getMonth() + 1; -let currentNav = 'stats'; + +const AppState = { + // 认证状态 + auth: { + token: localStorage.getItem('token'), + username: localStorage.getItem('username') + }, + + // 导航状态 + nav: { + currentType: 'income', + currentCategory: '接单', + currentYear: new Date().getFullYear(), + currentMonth: new Date().getMonth() + 1, + currentNav: 'stats' + }, + + // 状态更新方法 + setAuth(token, username) { + this.auth.token = token; + this.auth.username = username; + localStorage.setItem('token', token); + localStorage.setItem('username', username); + }, + + clearAuth() { + this.auth.token = null; + this.auth.username = null; + localStorage.removeItem('token'); + localStorage.removeItem('username'); + }, + + setCategory(type, category) { + this.nav.currentType = type; + this.nav.currentCategory = category; + }, + + setYearMonth(year, month) { + this.nav.currentYear = year; + this.nav.currentMonth = month; + }, + + setNav(nav) { + this.nav.currentNav = nav; + } +}; + +// 兼容旧代码的全局变量 +let token = AppState.auth.token; +let username = AppState.auth.username; +let currentType = AppState.nav.currentType; +let currentCategory = AppState.nav.currentCategory; +let currentYear = AppState.nav.currentYear; +let currentMonth = AppState.nav.currentMonth; +let currentNav = AppState.nav.currentNav; function headers() { return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; diff --git a/public/js/order-list.js b/public/js/order-list.js index b456c37..36cedfa 100644 --- a/public/js/order-list.js +++ b/public/js/order-list.js @@ -1,9 +1,8 @@ // ============================================ -// 通用列表管理 - 一次性加载 +// 通用列表管理 - 无限滚动加载 // ============================================ -// 通用列表配置 -const listConfig = { +const config = { orderList: { endpoint: '/api/records/orders', emptyMsg: '暂无接单记录', renderItem: renderOrderItem }, otherIncomeList: { endpoint: '/api/records/other-income', emptyMsg: '暂无红包及其他收入记录', renderItem: renderOtherIncomeItem }, dispatchList: { endpoint: '/api/records/dispatches', emptyMsg: '暂无派单记录', renderItem: renderDispatchItem }, @@ -11,88 +10,96 @@ const listConfig = { milkTeaList: { endpoint: '/api/records/milk-tea', emptyMsg: '暂无奶茶收入记录', renderItem: renderMilkTeaItem } }; -// 通用列表加载状态(按 listId 存储) -const listState = { - orderList: { loading: false, records: [] }, - otherIncomeList: { loading: false, records: [] }, - dispatchList: { loading: false, records: [] }, - redEnvelopeList: { loading: false, records: [] }, - milkTeaList: { loading: false, records: [] } -}; +const PAGE_SIZE = 20; + +const listState = {}; -// 初始化列表 - 清空状态 function initList(listId) { - const state = listState[listId]; - if (!state) return; - - state.loading = false; - state.records = []; + listState[listId] = { + loading: false, + records: [], + page: 1, + hasMore: true, + totalCount: 0 + }; const container = document.getElementById(listId); - if (container) { - container.innerHTML = ''; - } + if (container) container.innerHTML = ''; } -// 加载列表数据 async function loadListData(listId) { - const config = listConfig[listId]; + const cfg = config[listId]; const state = listState[listId]; - if (!config || !state) return; + if (!cfg || !state) return; - // 防止重复加载 - if (state.loading) return; + if (state.loading || !state.hasMore) return; state.loading = true; showLoading(listId, true); try { - const res = await fetch(API + config.endpoint + '?page=1&limit=1000&year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); + const res = await fetch( + API + cfg.endpoint + '?page=' + state.page + '&limit=' + PAGE_SIZE + '&year=' + currentYear + '&month=' + currentMonth, + { headers: headers() } + ); if (!res.ok) { - console.error(`[loadListData] fetch failed: ${res.status}`); + console.error('[loadListData] fetch failed:', res.status); state.loading = false; showLoading(listId, false); return; } const data = await res.json(); + const newRecords = data.records || []; + const total = data.total || 0; - state.records = data.records || []; + state.records = state.records.concat(newRecords); + state.totalCount = total; + state.hasMore = state.records.length < total; + state.page++; renderList(listId); + setupScrollObserver(listId); } catch (e) { - console.error(`[loadListData] error:`, e); + console.error('[loadListData] error:', e); } finally { state.loading = false; showLoading(listId, false); } } -// 显示/隐藏加载指示器 function showLoading(listId, show) { const loadingId = listId.replace('List', 'Loading'); const loadingEl = document.getElementById(loadingId); if (loadingEl) { loadingEl.style.display = show ? 'block' : 'none'; + if (!show && listState[listId]) { + const state = listState[listId]; + if (state.hasMore) { + loadingEl.textContent = '下拉加载更多...'; + } else if (state.records.length > 0) { + loadingEl.textContent = '已加载全部'; + } else { + loadingEl.textContent = ''; + } + } } } -// 渲染列表 function renderList(listId) { - const config = listConfig[listId]; + const cfg = config[listId]; const state = listState[listId]; - if (!config || !state) return; + if (!cfg || !state) return; const container = document.getElementById(listId); if (!container) return; if (state.records.length === 0) { - container.innerHTML = '
' + config.emptyMsg + '
'; + container.innerHTML = '
' + cfg.emptyMsg + '
'; return; } - // 按日期分组 const grouped = {}; state.records.forEach(r => { const date = formatLocalDate(r.created_at); @@ -102,17 +109,53 @@ function renderList(listId) { container.innerHTML = ''; - // 生成 HTML - let html = ''; for (const [date, records] of Object.entries(grouped)) { - html += '
' + date + '
'; - records.forEach(r => { - html += config.renderItem(r); - }); - html += '
'; - } + const group = document.createElement('div'); + group.className = 'order-date-group'; + group.setAttribute('data-date', date); - container.innerHTML = html; + const label = document.createElement('div'); + label.className = 'order-date-label'; + label.textContent = date; + group.appendChild(label); + + records.forEach(r => { + const item = document.createElement('div'); + item.innerHTML = cfg.renderItem(r); + group.appendChild(item.firstChild); + }); + + container.appendChild(group); + } +} + +function setupScrollObserver(listId) { + const state = listState[listId]; + if (!state || !state.hasMore) return; + + const container = document.getElementById(listId); + if (!container) return; + + // 移除旧的 sentinel + const oldSentinel = container.querySelector('.scroll-sentinel'); + if (oldSentinel) oldSentinel.remove(); + + // 创建新的 sentinel + const sentinel = document.createElement('div'); + sentinel.className = 'scroll-sentinel'; + sentinel.style.height = '1px'; + container.appendChild(sentinel); + + const observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !state.loading && state.hasMore) { + loadListData(listId); + } + }, { rootMargin: '100px' }); + + observer.observe(sentinel); + + // 存储 observer 以便清理 + state.observer = observer; } // ============================================ @@ -163,4 +206,4 @@ function renderMilkTeaItem(r) { return '
' + '
奶茶' + (r.amount || 0).toFixed(2) + '
' + '
' + (r.source || '') + '' + time + '
'; -} +} \ No newline at end of file diff --git a/server/app.js b/server/app.js index 94d6460..a07ab3e 100644 --- a/server/app.js +++ b/server/app.js @@ -3,6 +3,8 @@ const cors = require('cors'); const path = require('path'); const compression = require('compression'); +const config = require('./config'); +const logger = require('./utils/logger'); const db = require('./db'); const { router: authRouter, auth } = require('./routes/auth'); const statsRouter = require('./routes/stats'); @@ -11,6 +13,9 @@ const ioRouter = require('./routes/io'); const app = express(); +// 请求日志中间件 +app.use(logger.requestLogger); + app.use(cors()); app.use(express.json()); app.use(compression()); @@ -44,7 +49,34 @@ app.use('/api/stats', auth, statsRouter); app.use('/api/records', auth, recordsRouter); app.use('/api', auth, ioRouter); -const PORT = process.env.PORT || 3000; -app.listen(PORT, '0.0.0.0', () => { - console.log(`服务器运行在 http://0.0.0.0:${PORT}`); +// 404 处理 +app.use((req, res) => { + res.status(404).json({ error: '接口不存在' }); +}); + +// 全局错误处理中间件 +app.use((err, req, res, next) => { + // 记录错误日志 + logger.error('Unhandled error:', err.message, err.stack); + + // 处理特定错误类型 + if (err.name === 'UnauthorizedError') { + return res.status(401).json({ error: '未授权访问' }); + } + + if (err.name === 'SyntaxError' && err.status === 400 && 'body' in err) { + return res.status(400).json({ error: 'JSON 格式错误' }); + } + + // 默认错误响应 + const status = err.status || err.statusCode || 500; + const message = err.expose ? err.message : '服务器内部错误'; + + res.status(status).json({ error: message }); +}); + +const PORT = config.port; +const HOST = config.host; +app.listen(PORT, HOST, () => { + logger.info(`服务器运行在 http://${HOST}:${PORT}`); }); diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..e2fa30b --- /dev/null +++ b/server/config.js @@ -0,0 +1,39 @@ +/** + * 应用配置 + */ +module.exports = { + // 服务器配置 + port: process.env.PORT || 3000, + host: process.env.HOST || '0.0.0.0', + + // JWT 配置 + jwtSecret: process.env.JWT_SECRET || 'gamer-order-secret-2024', + jwtExpiresIn: '7d', + + // 缓存配置 + cache: { + ttl: 2 * 60 * 1000, // 缓存有效期 2 分钟 + maxSize: 100 // 最大缓存条目数 + }, + + // 分页配置 + pagination: { + defaultLimit: 20, // 默认每页条数 + maxLimit: 100 // 最大每页条数 + }, + + // 日志配置 + log: { + level: process.env.LOG_LEVEL || 'info', // debug, info, warn, error + colorize: true + }, + + // 允许的类别 + categories: { + income: ['接单', '派单', '红包收入', '奶茶'], + expense: ['红包支出', '比心', '其他支出'] + }, + + // 允许的联系人字段 + contactFields: ['boss', 'partner', 'source', 'destination'] +}; \ No newline at end of file diff --git a/server/db.js b/server/db.js index 4052aec..39be8dd 100644 --- a/server/db.js +++ b/server/db.js @@ -1,95 +1,125 @@ const Database = require('better-sqlite3'); const bcrypt = require('bcryptjs'); const path = require('path'); +const logger = require('./utils/logger'); const db = new Database(path.join(__dirname, '..', 'data.db')); +// 启用 WAL 模式和外键约束 db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); -// 创建表 -db.exec(` - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); +/** + * 创建表结构 + */ +function createTables() { + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); - CREATE TABLE IF NOT EXISTS records ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - type TEXT NOT NULL CHECK(type IN ('income', 'expense')), - category TEXT NOT NULL, - amount REAL NOT NULL, - quantity INTEGER, - unit_price REAL, - boss TEXT, - partner TEXT, - source TEXT, - destination TEXT, - note TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) - ); + CREATE TABLE IF NOT EXISTS records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + type TEXT NOT NULL CHECK(type IN ('income', 'expense')), + category TEXT NOT NULL, + amount REAL NOT NULL, + quantity INTEGER, + unit_price REAL, + boss TEXT, + partner TEXT, + source TEXT, + destination TEXT, + note TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); - CREATE INDEX IF NOT EXISTS idx_records_user_id ON records(user_id); - CREATE INDEX IF NOT EXISTS idx_records_category ON records(category); - CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at); - CREATE INDEX IF NOT EXISTS idx_records_type ON records(type); - CREATE INDEX IF NOT EXISTS idx_records_boss ON records(boss); - CREATE INDEX IF NOT EXISTS idx_records_partner ON records(partner); - CREATE INDEX IF NOT EXISTS idx_records_source ON records(source); - CREATE INDEX IF NOT EXISTS idx_records_user_date ON records(user_id, created_at); - CREATE INDEX IF NOT EXISTS idx_records_user_category ON records(user_id, category, created_at); - CREATE INDEX IF NOT EXISTS idx_records_destination ON records(destination); -`); + CREATE INDEX IF NOT EXISTS idx_records_user_id ON records(user_id); + CREATE INDEX IF NOT EXISTS idx_records_category ON records(category); + CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at); + CREATE INDEX IF NOT EXISTS idx_records_type ON records(type); + CREATE INDEX IF NOT EXISTS idx_records_boss ON records(boss); + CREATE INDEX IF NOT EXISTS idx_records_partner ON records(partner); + CREATE INDEX IF NOT EXISTS idx_records_source ON records(source); + CREATE INDEX IF NOT EXISTS idx_records_user_date ON records(user_id, created_at); + CREATE INDEX IF NOT EXISTS idx_records_user_category ON records(user_id, category, created_at); + CREATE INDEX IF NOT EXISTS idx_records_destination ON records(destination); + `); + logger.debug('表结构创建完成'); +} -// 迁移:去掉 category 的 CHECK 约束 -try { - const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get(); - if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) { - db.exec(` - CREATE TABLE records_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - type TEXT NOT NULL CHECK(type IN ('income', 'expense')), - category TEXT NOT NULL, - amount REAL NOT NULL, - quantity INTEGER, - unit_price REAL, - boss TEXT, - partner TEXT, - source TEXT, - destination TEXT, - note TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) - ); - INSERT INTO records_new SELECT * FROM records; - DROP TABLE records; - ALTER TABLE records_new RENAME TO records; - `); +/** + * 迁移:去掉 category 的 CHECK 约束 + */ +function migrateRemoveCategoryCheck() { + try { + const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get(); + if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) { + logger.info('执行迁移:移除 category CHECK 约束'); + db.exec(` + CREATE TABLE records_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + type TEXT NOT NULL CHECK(type IN ('income', 'expense')), + category TEXT NOT NULL, + amount REAL NOT NULL, + quantity INTEGER, + unit_price REAL, + boss TEXT, + partner TEXT, + source TEXT, + destination TEXT, + note TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + INSERT INTO records_new SELECT * FROM records; + DROP TABLE records; + ALTER TABLE records_new RENAME TO records; + `); + logger.info('迁移完成:category CHECK 约束已移除'); + } + } catch (e) { + logger.warn('迁移跳过 (category):', e.message); } -} catch (e) { - console.log('Migration skipped:', e.message); } -// 迁移:添加 rebate 列(派单返点) -try { - const cols = db.prepare("PRAGMA table_info(records)").all(); - if (!cols.find(c => c.name === 'rebate')) { - db.exec("ALTER TABLE records ADD COLUMN rebate REAL"); +/** + * 迁移:添加 rebate 列 + */ +function migrateAddRebateColumn() { + try { + const cols = db.prepare("PRAGMA table_info(records)").all(); + if (!cols.find(c => c.name === 'rebate')) { + logger.info('执行迁移:添加 rebate 列'); + db.exec("ALTER TABLE records ADD COLUMN rebate REAL"); + logger.info('迁移完成:rebate 列已添加'); + } + } catch (e) { + logger.warn('迁移跳过 (rebate):', e.message); } -} catch (e) { - console.log('Rebate migration skipped:', e.message); } -// 初始化默认用户 lizhilun -const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get('lizhilun'); -if (!existingUser) { - const hash = bcrypt.hashSync('lizhilun', 10); - db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash); +/** + * 初始化默认用户 + */ +function initDefaultUser() { + const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get('lizhilun'); + if (!existingUser) { + const hash = bcrypt.hashSync('lizhilun', 10); + db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash); + logger.info('默认用户 lizhilun 已创建'); + } } +// 执行初始化 +createTables(); +migrateRemoveCategoryCheck(); +migrateAddRebateColumn(); +initDefaultUser(); + module.exports = db; diff --git a/server/routes/auth.js b/server/routes/auth.js index e33cf4e..1bbc221 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -2,16 +2,17 @@ const express = require('express'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const db = require('../db'); +const config = require('../config'); +const logger = require('../utils/logger'); const router = express.Router(); -const SECRET = 'gamer-order-secret-2024'; // JWT 中间件 function auth(req, res, next) { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: '未登录' }); try { - req.user = jwt.verify(token, SECRET); + req.user = jwt.verify(token, config.jwtSecret); next(); } catch { res.status(401).json({ error: '登录已过期' }); @@ -21,11 +22,19 @@ function auth(req, res, next) { // 登录 router.post('/login', (req, res) => { const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: '用户名和密码不能为空' }); + } + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); if (!user || !bcrypt.compareSync(password, user.password)) { + logger.warn(`登录失败: ${username}`); return res.status(401).json({ error: '用户名或密码错误' }); } - const token = jwt.sign({ id: user.id, username: user.username }, SECRET, { expiresIn: '7d' }); + + const token = jwt.sign({ id: user.id, username: user.username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn }); + logger.info(`用户登录: ${username}`); res.json({ token, username: user.username }); }); diff --git a/server/routes/io.js b/server/routes/io.js index 834409d..b3e1243 100644 --- a/server/routes/io.js +++ b/server/routes/io.js @@ -1,61 +1,68 @@ const express = require('express'); const db = require('../db'); -const { parseCSVLine } = require('../utils/helpers'); +const logger = require('../utils/logger'); +const { parseCSVLine, AppError } = require('../utils/helpers'); const router = express.Router(); // 导出数据 -router.get('/export', (req, res) => { - const format = req.query.format; - if (!format || !['json', 'csv'].includes(format)) { - return res.status(400).json({ error: '请指定格式: json 或 csv' }); +router.get('/export', (req, res, next) => { + try { + const format = req.query.format; + if (!format || !['json', 'csv'].includes(format)) { + throw new AppError('请指定格式: json 或 csv', 400); + } + + const records = db.prepare( + 'SELECT type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at FROM records WHERE user_id = ? ORDER BY created_at DESC' + ).all(req.user.id); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const filename = `gamer-export-${timestamp}`; + + if (format === 'json') { + const exportData = { + exportVersion: 1, + exportedAt: new Date().toISOString(), + username: req.user.username, + recordCount: records.length, + records + }; + logger.info(`用户 ${req.user.username} 导出 JSON 数据: ${records.length} 条`); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`); + return res.send(JSON.stringify(exportData, null, 2)); + } + + // CSV with UTF-8 BOM + const BOM = '\uFEFF'; + const cols = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at']; + const csvRows = records.map(r => { + return cols.map(col => { + const val = r[col]; + if (val === null || val === undefined) return ''; + const str = String(val); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return '"' + str.replace(/"/g, '""') + '"'; + } + return str; + }).join(','); + }); + logger.info(`用户 ${req.user.username} 导出 CSV 数据: ${records.length} 条`); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`); + res.send(BOM + cols.join(',') + '\n' + csvRows.join('\n')); + } catch (err) { + next(err); } - - const records = db.prepare( - 'SELECT type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at FROM records WHERE user_id = ? ORDER BY created_at DESC' - ).all(req.user.id); - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - const filename = `gamer-export-${timestamp}`; - - if (format === 'json') { - const exportData = { - exportVersion: 1, - exportedAt: new Date().toISOString(), - username: req.user.username, - recordCount: records.length, - records - }; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`); - return res.send(JSON.stringify(exportData, null, 2)); - } - - // CSV with UTF-8 BOM - const BOM = '\uFEFF'; - const cols = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at']; - const csvRows = records.map(r => { - return cols.map(col => { - const val = r[col]; - if (val === null || val === undefined) return ''; - const str = String(val); - if (str.includes(',') || str.includes('"') || str.includes('\n')) { - return '"' + str.replace(/"/g, '""') + '"'; - } - return str; - }).join(','); - }); - res.setHeader('Content-Type', 'text/csv; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`); - res.send(BOM + cols.join(',') + '\n' + csvRows.join('\n')); }); // 导入数据 -router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res) => { +router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res, next) => { try { const raw = req.body; if (!raw || typeof raw !== 'string') { - return res.status(400).json({ error: '未收到文件内容' }); + throw new AppError('未收到文件内容', 400); } let records = []; @@ -66,16 +73,16 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res) if (trimmed.startsWith('{')) { const data = JSON.parse(trimmed); if (!data.exportVersion || !Array.isArray(data.records)) { - return res.status(400).json({ error: '无效的JSON导出文件格式' }); + throw new AppError('无效的JSON导出文件格式', 400); } records = data.records; } else { const lines = trimmed.split('\n').map(l => l.replace(/\r$/, '')); if (lines.length < 2) { - return res.status(400).json({ error: 'CSV文件为空或格式不正确' }); + throw new AppError('CSV文件为空或格式不正确', 400); } if (lines[0] !== allFields.join(',')) { - return res.status(400).json({ error: 'CSV列头不匹配,请使用本系统导出的文件' }); + throw new AppError('CSV列头不匹配,请使用本系统导出的文件', 400); } const csvHeaders = lines[0].split(','); for (let i = 1; i < lines.length; i++) { @@ -94,11 +101,11 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res) for (const r of records) { for (const f of requiredFields) { if (!r[f] && r[f] !== 0) { - return res.status(400).json({ error: `记录缺少必填字段: ${f}` }); + throw new AppError(`记录缺少必填字段: ${f}`, 400); } } if (!['income', 'expense'].includes(r.type)) { - return res.status(400).json({ error: `无效的类型: ${r.type}` }); + throw new AppError(`无效的类型: ${r.type}`, 400); } } @@ -126,10 +133,14 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res) }); insertMany(records); + logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped} 条`); res.json({ imported, skipped, total: records.length }); - } catch (e) { - console.error('Import error:', e); - res.status(400).json({ error: '文件解析失败: ' + e.message }); + } catch (err) { + if (err instanceof SyntaxError) { + next(new AppError('文件解析失败: ' + err.message, 400)); + } else { + next(err); + } } }); diff --git a/server/routes/records.js b/server/routes/records.js index 0be5abb..89f0d4f 100644 --- a/server/routes/records.js +++ b/server/routes/records.js @@ -1,18 +1,36 @@ const express = require('express'); const db = require('../db'); -const { getMonthRange } = require('../utils/helpers'); +const config = require('../config'); +const logger = require('../utils/logger'); +const { getMonthRange, AppError, validateRequired, validateNumber, validateInList } = require('../utils/helpers'); const router = express.Router(); // 新增记录 -router.post('/', (req, res) => { - const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body; - const stmt = db.prepare(` - INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - const result = stmt.run(req.user.id, type, category, amount, quantity || null, unit_price || null, rebate || null, boss || null, partner || null, source || null, destination || null, note || null, created_at || new Date().toISOString()); - res.json({ id: result.lastInsertRowid }); +router.post('/', (req, res, next) => { + try { + const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body; + + // 输入验证 + validateRequired(req.body, ['type', 'category', 'amount']); + validateInList(type, 'type', ['income', 'expense']); + + const allCategories = [...config.categories.income, ...config.categories.expense]; + validateInList(category, 'category', allCategories); + + const validatedAmount = validateNumber(amount, '金额', 0); + + const stmt = db.prepare(` + INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run(req.user.id, type, category, validatedAmount, quantity || null, unit_price || null, rebate || null, boss || null, partner || null, source || null, destination || null, note || null, created_at || new Date().toISOString()); + + logger.info(`用户 ${req.user.username} 新增记录: ${category} ${validatedAmount}`); + res.json({ id: result.lastInsertRowid }); + } catch (err) { + next(err); + } }); // 通用分页查询函数 @@ -117,19 +135,45 @@ router.get('/', (req, res) => { }); // 更新记录 -router.put('/:id', (req, res) => { - const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body; - db.prepare(` - UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ? - WHERE id = ? AND user_id = ? - `).run(quantity || null, unit_price || null, rebate || null, amount, boss || null, partner || null, source || null, destination || null, note || null, created_at || null, req.params.id, req.user.id); - res.json({ success: true }); +router.put('/:id', (req, res, next) => { + try { + const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body; + const id = Number(req.params.id); + + if (!id || id <= 0) { + throw new AppError('无效的记录ID', 400); + } + + const validatedAmount = validateNumber(amount, '金额', 0); + + db.prepare(` + UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ? + WHERE id = ? AND user_id = ? + `).run(quantity || null, unit_price || null, rebate || null, validatedAmount, boss || null, partner || null, source || null, destination || null, note || null, created_at || null, id, req.user.id); + + logger.debug(`用户 ${req.user.username} 更新记录: ${id}`); + res.json({ success: true }); + } catch (err) { + next(err); + } }); // 删除记录 -router.delete('/:id', (req, res) => { - db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id); - res.json({ success: true }); +router.delete('/:id', (req, res, next) => { + try { + const id = Number(req.params.id); + + if (!id || id <= 0) { + throw new AppError('无效的记录ID', 400); + } + + db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?').run(id, req.user.id); + + logger.info(`用户 ${req.user.username} 删除记录: ${id}`); + res.json({ success: true }); + } catch (err) { + next(err); + } }); module.exports = router; diff --git a/server/routes/stats.js b/server/routes/stats.js index 2e9a33d..9c8f14b 100644 --- a/server/routes/stats.js +++ b/server/routes/stats.js @@ -1,13 +1,15 @@ const express = require('express'); const db = require('../db'); -const { getMonthRange } = require('../utils/helpers'); +const config = require('../config'); +const logger = require('../utils/logger'); +const { getMonthRange, AppError, validateRequired, validateNumber, validateInList } = require('../utils/helpers'); const router = express.Router(); // 内存缓存(限制大小,基于LRU) const statsCache = new Map(); -const CACHE_TTL = 2 * 60 * 1000; -const MAX_CACHE_SIZE = 100; +const CACHE_TTL = config.cache.ttl; +const MAX_CACHE_SIZE = config.cache.maxSize; function getCached(key, fetchFn) { const cached = statsCache.get(key); @@ -24,9 +26,10 @@ function getCached(key, fetchFn) { } return Promise.resolve(fetchFn()).then(data => { statsCache.set(key, { data, ts: Date.now() }); + logger.debug(`缓存更新: ${key}`); return data; }).catch(err => { - console.error('Cache fetch error:', err.message); + logger.error('缓存获取失败:', err.message); throw err; }); } @@ -54,48 +57,60 @@ function formatLocalDate(date) { } // 本月统计 -router.get('/monthly', (req, res) => { - const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); - const cacheKey = `monthly:${req.user.id}:${startDate}:${endDate}`; - getCached(cacheKey, () => { - const stats = db.prepare(` - SELECT - COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_order_count, - COALESCE(SUM(CASE WHEN category = '接单' THEN amount ELSE 0 END), 0) as take_order_income, - COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_order_count, - COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as dispatch_order_income, - COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income, - COALESCE(SUM(CASE WHEN category = '红包收入' THEN 1 ELSE 0 END), 0) as redEnvelope_count, - COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as milkTea_income, - COALESCE(SUM(CASE WHEN category = '奶茶' THEN 1 ELSE 0 END), 0) as milkTea_count, - COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income, - COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense - FROM records - WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? - `).get(req.user.id, startDate, endDate); - return stats; - }).then(stats => res.json(stats)); +router.get('/monthly', (req, res, next) => { + try { + const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); + const cacheKey = `monthly:${req.user.id}:${startDate}:${endDate}`; + getCached(cacheKey, () => { + const stats = db.prepare(` + SELECT + COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_order_count, + COALESCE(SUM(CASE WHEN category = '接单' THEN amount ELSE 0 END), 0) as take_order_income, + COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_order_count, + COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as dispatch_order_income, + COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income, + COALESCE(SUM(CASE WHEN category = '红包收入' THEN 1 ELSE 0 END), 0) as redEnvelope_count, + COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as milkTea_income, + COALESCE(SUM(CASE WHEN category = '奶茶' THEN 1 ELSE 0 END), 0) as milkTea_count, + COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income, + COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense + FROM records + WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + `).get(req.user.id, startDate, endDate); + return stats; + }).then(stats => res.json(stats)).catch(next); + } catch (err) { + next(err); + } }); // 月收入/支出记录列表 -router.get('/monthly-records', (req, res) => { - const { type, page = 1, limit = 20 } = req.query; - if (!type || !['income', 'expense'].includes(type)) return res.status(400).json({ error: '缺少类型参数' }); - const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); - const offset = (Number(page) - 1) * Number(limit); +router.get('/monthly-records', (req, res, next) => { + try { + const { type, page = 1, limit = 20 } = req.query; + if (!type || !['income', 'expense'].includes(type)) { + throw new AppError('缺少类型参数或类型无效', 400); + } + const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); + const validatedPage = Math.max(1, Number(page) || 1); + const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit); + const offset = (validatedPage - 1) * validatedLimit; - const stats = db.prepare(` - SELECT COALESCE(SUM(amount), 0) as total, COUNT(*) as count - FROM records WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? - `).get(req.user.id, type, startDate, endDate); + const stats = db.prepare(` + SELECT COALESCE(SUM(amount), 0) as total, COUNT(*) as count + FROM records WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + `).get(req.user.id, type, startDate, endDate); - const records = db.prepare(` - SELECT * FROM records - WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? - ORDER BY created_at DESC LIMIT ? OFFSET ? - `).all(req.user.id, type, startDate, endDate, Number(limit), offset); + const records = db.prepare(` + SELECT * FROM records + WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + ORDER BY created_at DESC LIMIT ? OFFSET ? + `).all(req.user.id, type, startDate, endDate, validatedLimit, offset); - res.json({ total: stats.total, records, page: Number(page), limit: Number(limit), totalCount: stats.count }); + res.json({ total: stats.total, records, page: validatedPage, limit: validatedLimit, totalCount: stats.count }); + } catch (err) { + next(err); + } }); // 年度净收入按年统计 @@ -277,98 +292,112 @@ router.get('/total', (req, res) => { }); // 老板交易记录 -router.get('/boss-records', (req, res) => { - const boss = req.query.boss; - const { page = 1, limit = 20, year, month } = req.query; - if (!boss) return res.status(400).json({ error: '缺少老板参数' }); - const offset = (Number(page) - 1) * Number(limit); +router.get('/boss-records', (req, res, next) => { + try { + const boss = req.query.boss; + const { page = 1, limit = 20, year, month } = req.query; + if (!boss) throw new AppError('缺少老板参数', 400); - // 日期过滤条件 - let dateFilter = ''; - let dateParams = []; - if (year && month) { - const startDate = `${year}-${String(month).padStart(2, '0')}-01`; - const endDate = `${year}-${String(month).padStart(2, '0')}-31`; - dateFilter = ` AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') <= ?`; - dateParams = [startDate, endDate]; + const validatedPage = Math.max(1, Number(page) || 1); + const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit); + const offset = (validatedPage - 1) * validatedLimit; + + // 日期过滤条件 + let dateFilter = ''; + let dateParams = []; + if (year && month) { + const startDate = `${year}-${String(month).padStart(2, '0')}-01`; + const endDate = `${year}-${String(month).padStart(2, '0')}-31`; + dateFilter = ` AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') <= ?`; + dateParams = [startDate, endDate]; + } + + const summary = db.prepare(` + SELECT + COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as my_income, + COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as dispatch_expense, + COALESCE(SUM(CASE WHEN category = '接单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as order_expense, + COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_expense, + COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count, + COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count, + COUNT(*) as count + FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?${dateFilter} + `).get(req.user.id, boss, ...dateParams); + + const records = db.prepare(` + SELECT * FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ? + `).all(req.user.id, boss, ...dateParams, validatedLimit, offset); + + res.json({ + my_income: summary.my_income, + boss_total_expense: summary.dispatch_expense + summary.order_expense + summary.redEnvelope_expense, + take_count: summary.take_count, + dispatch_count: summary.dispatch_count, + records, + page: validatedPage, + limit: validatedLimit, + totalCount: summary.count + }); + } catch (err) { + next(err); } - - const summary = db.prepare(` - SELECT - COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as my_income, - COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as dispatch_expense, - COALESCE(SUM(CASE WHEN category = '接单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as order_expense, - COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_expense, - COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count, - COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count, - COUNT(*) as count - FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?${dateFilter} - `).get(req.user.id, boss, ...dateParams); - - const records = db.prepare(` - SELECT * FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ? - `).all(req.user.id, boss, ...dateParams, Number(limit), offset); - - res.json({ - my_income: summary.my_income, - boss_total_expense: summary.dispatch_expense + summary.order_expense + summary.redEnvelope_expense, - take_count: summary.take_count, - dispatch_count: summary.dispatch_count, - records, - page: Number(page), - limit: Number(limit), - totalCount: summary.count - }); }); // 陪玩交易记录 -router.get('/partner-records', (req, res) => { - const partner = req.query.partner; - const { page = 1, limit = 20, year, month } = req.query; - if (!partner) return res.status(400).json({ error: '缺少陪玩参数' }); - const offset = (Number(page) - 1) * Number(limit); +router.get('/partner-records', (req, res, next) => { + try { + const partner = req.query.partner; + const { page = 1, limit = 20, year, month } = req.query; + if (!partner) throw new AppError('缺少陪玩参数', 400); - // 日期过滤条件 - let dateFilter = ''; - let dateParams = []; - if (year && month) { - const startDate = `${year}-${String(month).padStart(2, '0')}-01`; - const endDate = `${year}-${String(month).padStart(2, '0')}-31`; - dateFilter = ` AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') <= ?`; - dateParams = [startDate, endDate]; + const validatedPage = Math.max(1, Number(page) || 1); + const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit); + const offset = (validatedPage - 1) * validatedLimit; + + // 日期过滤条件 + let dateFilter = ''; + let dateParams = []; + if (year && month) { + const startDate = `${year}-${String(month).padStart(2, '0')}-01`; + const endDate = `${year}-${String(month).padStart(2, '0')}-31`; + dateFilter = ` AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') <= ?`; + dateParams = [startDate, endDate]; + } + + const summary = db.prepare(` + SELECT + COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count, + COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * (COALESCE(unit_price, 0) - COALESCE(rebate, amount * 1.0 / CASE WHEN quantity > 0 THEN quantity ELSE 1 END)) ELSE 0 END), 0) as spread_income, + COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as rebate_income, + COALESCE(SUM(CASE WHEN type = 'income' AND category != '派单' AND category != '接单' AND source = ? THEN amount ELSE 0 END), 0) as other_income, + COUNT(*) as count + FROM records WHERE user_id = ? AND ( + (category = '派单' AND partner = ?) OR + (type = 'income' AND category != '派单' AND category != '接单' AND source = ?) + )${dateFilter} + `).get(partner, req.user.id, partner, partner, ...dateParams); + + const records = db.prepare(` + SELECT * FROM records + WHERE user_id = ? AND ( + (category = '派单' AND partner = ?) OR + (type = 'income' AND category != '派单' AND category != '接单' AND source = ?) + )${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ? + `).all(req.user.id, partner, partner, ...dateParams, validatedLimit, offset); + + res.json({ + dispatch_count: summary.dispatch_count, + spread_income: summary.spread_income, + rebate_income: summary.rebate_income, + other_income: summary.other_income, + records, + page: validatedPage, + limit: validatedLimit, + totalCount: summary.count + }); + } catch (err) { + next(err); } - - const summary = db.prepare(` - SELECT - COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count, - COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * (COALESCE(unit_price, 0) - COALESCE(rebate, amount * 1.0 / CASE WHEN quantity > 0 THEN quantity ELSE 1 END)) ELSE 0 END), 0) as spread_income, - COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as rebate_income, - COALESCE(SUM(CASE WHEN type = 'income' AND category != '派单' AND category != '接单' AND source = ? THEN amount ELSE 0 END), 0) as other_income, - COUNT(*) as count - FROM records WHERE user_id = ? AND ( - (category = '派单' AND partner = ?) OR - (type = 'income' AND category != '派单' AND category != '接单' AND source = ?) - )${dateFilter} - `).get(partner, req.user.id, partner, partner, ...dateParams); - - const records = db.prepare(` - SELECT * FROM records - WHERE user_id = ? AND ( - (category = '派单' AND partner = ?) OR - (type = 'income' AND category != '派单' AND category != '接单' AND source = ?) - )${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ? - `).all(req.user.id, partner, partner, ...dateParams, Number(limit), offset); - - res.json({ - dispatch_count: summary.dispatch_count, - spread_income: summary.spread_income, - rebate_income: summary.rebate_income, - other_income: summary.other_income, - records, - page: Number(page), - limit: Number(limit), - totalCount: summary.count - }); }); // 最近7天每日收入 @@ -399,25 +428,33 @@ router.get('/daily-income', (req, res) => { }); // 某天收入记录详情 -router.get('/daily-detail', (req, res) => { - const { date, page = 1, limit = 20 } = req.query; - if (!date) return res.status(400).json({ error: '缺少日期参数' }); - const offset = (Number(page) - 1) * Number(limit); - const nextDay = new Date(date); - nextDay.setDate(nextDay.getDate() + 1); - const endDate = formatLocalDate(nextDay); +router.get('/daily-detail', (req, res, next) => { + try { + const { date, page = 1, limit = 20 } = req.query; + if (!date) throw new AppError('缺少日期参数', 400); - const stats = db.prepare(` - SELECT COALESCE(SUM(amount), 0) as total_income, COUNT(*) as count - FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? - `).get(req.user.id, date, endDate); + const validatedPage = Math.max(1, Number(page) || 1); + const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit); + const offset = (validatedPage - 1) * validatedLimit; - const records = db.prepare(` - SELECT * FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? - ORDER BY created_at DESC LIMIT ? OFFSET ? - `).all(req.user.id, date, endDate, Number(limit), offset); + const nextDay = new Date(date); + nextDay.setDate(nextDay.getDate() + 1); + const endDate = formatLocalDate(nextDay); - res.json({ total_income: stats.total_income, records, page: Number(page), limit: Number(limit), totalCount: stats.count }); + const stats = db.prepare(` + SELECT COALESCE(SUM(amount), 0) as total_income, COUNT(*) as count + FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + `).get(req.user.id, date, endDate); + + const records = db.prepare(` + SELECT * FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? + ORDER BY created_at DESC LIMIT ? OFFSET ? + `).all(req.user.id, date, endDate, validatedLimit, offset); + + res.json({ total_income: stats.total_income, records, page: validatedPage, limit: validatedLimit, totalCount: stats.count }); + } catch (err) { + next(err); + } }); // 老板收入排行 - 首页预览 diff --git a/server/utils/helpers.js b/server/utils/helpers.js index 6d9a885..4f22d33 100644 --- a/server/utils/helpers.js +++ b/server/utils/helpers.js @@ -1,4 +1,17 @@ -// 日期范围辅助函数 +/** + * 应用错误类 + */ +class AppError extends Error { + constructor(message, status = 400) { + super(message); + this.status = status; + this.expose = true; // 允许向客户端暴露错误信息 + } +} + +/** + * 日期范围辅助函数 + */ function getMonthRange(qYear, qMonth) { const now = new Date(); const y = Number(qYear) || now.getFullYear(); @@ -10,7 +23,9 @@ function getMonthRange(qYear, qMonth) { return { startDate, endDate }; } -// CSV行解析(处理引号内的逗号) +/** + * CSV行解析(处理引号内的逗号) + */ function parseCSVLine(line) { const result = []; let current = ''; @@ -31,4 +46,53 @@ function parseCSVLine(line) { return result; } -module.exports = { getMonthRange, parseCSVLine }; +/** + * 输入验证函数 + */ +function validateRequired(obj, fields) { + const missing = fields.filter(f => obj[f] === undefined || obj[f] === null || obj[f] === ''); + if (missing.length > 0) { + throw new AppError(`缺少必填字段: ${missing.join(', ')}`, 400); + } +} + +function validateNumber(value, field, min, max) { + const num = Number(value); + if (isNaN(num)) { + throw new AppError(`${field} 必须是数字`, 400); + } + if (min !== undefined && num < min) { + throw new AppError(`${field} 不能小于 ${min}`, 400); + } + if (max !== undefined && num > max) { + throw new AppError(`${field} 不能大于 ${max}`, 400); + } + return num; +} + +function validateString(value, field, maxLength) { + if (typeof value !== 'string') { + throw new AppError(`${field} 必须是字符串`, 400); + } + if (maxLength && value.length > maxLength) { + throw new AppError(`${field} 长度不能超过 ${maxLength}`, 400); + } + return value.trim(); +} + +function validateInList(value, field, list) { + if (!list.includes(value)) { + throw new AppError(`${field} 值无效`, 400); + } + return value; +} + +module.exports = { + AppError, + getMonthRange, + parseCSVLine, + validateRequired, + validateNumber, + validateString, + validateInList +}; diff --git a/server/utils/logger.js b/server/utils/logger.js new file mode 100644 index 0000000..9848d3c --- /dev/null +++ b/server/utils/logger.js @@ -0,0 +1,79 @@ +/** + * 简单日志工具 + * 支持日志级别和格式化输出 + */ + +const config = require('../config'); + +const LEVELS = { + debug: 0, + info: 1, + warn: 2, + error: 3 +}; + +const COLORS = { + debug: '\x1b[36m', // cyan + info: '\x1b[32m', // green + warn: '\x1b[33m', // yellow + error: '\x1b[31m', // red + reset: '\x1b[0m' +}; + +const currentLevel = LEVELS[config.log.level] || LEVELS.info; +const colorize = config.log.colorize && process.stdout.isTTY; + +function formatTime() { + const d = new Date(); + return d.toISOString().replace('T', ' ').slice(0, 19); +} + +function formatMessage(level, ...args) { + const timestamp = formatTime(); + const prefix = `[${timestamp}] [${level.toUpperCase()}]`; + + if (colorize) { + return `${COLORS[level]}${prefix}${COLORS.reset} ${args.join(' ')}`; + } + return `${prefix} ${args.join(' ')}`; +} + +const logger = { + debug(...args) { + if (currentLevel <= LEVELS.debug) { + console.log(formatMessage('debug', ...args)); + } + }, + + info(...args) { + if (currentLevel <= LEVELS.info) { + console.log(formatMessage('info', ...args)); + } + }, + + warn(...args) { + if (currentLevel <= LEVELS.warn) { + console.warn(formatMessage('warn', ...args)); + } + }, + + error(...args) { + if (currentLevel <= LEVELS.error) { + console.error(formatMessage('error', ...args)); + } + }, + + // 请求日志中间件 + requestLogger(req, res, next) { + const start = Date.now(); + res.on('finish', () => { + const duration = Date.now() - start; + const status = res.statusCode; + const level = status >= 400 ? 'warn' : 'info'; + logger[level](`${req.method} ${req.path} ${status} ${duration}ms`); + }); + next(); + } +}; + +module.exports = logger; \ No newline at end of file