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