refactor: 系统优化 - 错误处理、输入验证、无限滚动

主要改进:
- 新增配置管理 (server/config.js)
- 新增日志工具 (server/utils/logger.js)
- 添加全局错误处理中间件,统一错误响应格式
- 添加输入验证和 AppError 错误类
- 重构 db.js 迁移逻辑,代码更清晰
- 前端列表改为无限滚动加载,每页 20 条
- 封装 AppState 状态管理模块

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lizhilun
2026-03-26 10:39:58 +08:00
co-authored by Claude Opus 4.6
parent 19e5a48f07
commit d551841035
12 changed files with 789 additions and 350 deletions
+2 -2
View File
@@ -460,9 +460,9 @@
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<script src="js/autocomplete.js"></script> <script src="js/autocomplete.js"></script>
<script src="js/month-picker.js"></script> <script src="js/month-picker.js"></script>
<script src="js/stats.js?v=2026032522"></script> <script src="js/stats.js?v=20260326"></script>
<script src="js/ranking.js"></script> <script src="js/ranking.js"></script>
<script src="js/order-list.js?v=2026032521"></script> <script src="js/order-list.js?v=20260326"></script>
<script src="js/edit-modal.js"></script> <script src="js/edit-modal.js"></script>
<script src="js/record-form.js"></script> <script src="js/record-form.js"></script>
<script src="js/import-export.js"></script> <script src="js/import-export.js"></script>
+58 -7
View File
@@ -1,11 +1,62 @@
// ============================================
// 应用状态管理
// ============================================
const API = ''; const API = '';
let token = localStorage.getItem('token');
let username = localStorage.getItem('username'); const AppState = {
let currentType = 'income'; // 认证状态
let currentCategory = '接单'; auth: {
let currentYear = new Date().getFullYear(); token: localStorage.getItem('token'),
let currentMonth = new Date().getMonth() + 1; username: localStorage.getItem('username')
let currentNav = 'stats'; },
// 导航状态
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() { function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
+87 -44
View File
@@ -1,9 +1,8 @@
// ============================================ // ============================================
// 通用列表管理 - 一次性加载 // 通用列表管理 - 无限滚动加载
// ============================================ // ============================================
// 通用列表配置 const config = {
const listConfig = {
orderList: { endpoint: '/api/records/orders', emptyMsg: '暂无接单记录', renderItem: renderOrderItem }, orderList: { endpoint: '/api/records/orders', emptyMsg: '暂无接单记录', renderItem: renderOrderItem },
otherIncomeList: { endpoint: '/api/records/other-income', emptyMsg: '暂无红包及其他收入记录', renderItem: renderOtherIncomeItem }, otherIncomeList: { endpoint: '/api/records/other-income', emptyMsg: '暂无红包及其他收入记录', renderItem: renderOtherIncomeItem },
dispatchList: { endpoint: '/api/records/dispatches', emptyMsg: '暂无派单记录', renderItem: renderDispatchItem }, dispatchList: { endpoint: '/api/records/dispatches', emptyMsg: '暂无派单记录', renderItem: renderDispatchItem },
@@ -11,88 +10,96 @@ const listConfig = {
milkTeaList: { endpoint: '/api/records/milk-tea', emptyMsg: '暂无奶茶收入记录', renderItem: renderMilkTeaItem } milkTeaList: { endpoint: '/api/records/milk-tea', emptyMsg: '暂无奶茶收入记录', renderItem: renderMilkTeaItem }
}; };
// 通用列表加载状态(按 listId 存储) const PAGE_SIZE = 20;
const listState = {
orderList: { loading: false, records: [] }, const listState = {};
otherIncomeList: { loading: false, records: [] },
dispatchList: { loading: false, records: [] },
redEnvelopeList: { loading: false, records: [] },
milkTeaList: { loading: false, records: [] }
};
// 初始化列表 - 清空状态
function initList(listId) { function initList(listId) {
const state = listState[listId]; listState[listId] = {
if (!state) return; loading: false,
records: [],
state.loading = false; page: 1,
state.records = []; hasMore: true,
totalCount: 0
};
const container = document.getElementById(listId); const container = document.getElementById(listId);
if (container) { if (container) container.innerHTML = '';
container.innerHTML = '';
}
} }
// 加载列表数据
async function loadListData(listId) { async function loadListData(listId) {
const config = listConfig[listId]; const cfg = config[listId];
const state = listState[listId]; const state = listState[listId];
if (!config || !state) return; if (!cfg || !state) return;
// 防止重复加载 if (state.loading || !state.hasMore) return;
if (state.loading) return;
state.loading = true; state.loading = true;
showLoading(listId, true); showLoading(listId, true);
try { 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) { if (!res.ok) {
console.error(`[loadListData] fetch failed: ${res.status}`); console.error('[loadListData] fetch failed:', res.status);
state.loading = false; state.loading = false;
showLoading(listId, false); showLoading(listId, false);
return; return;
} }
const data = await res.json(); 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); renderList(listId);
setupScrollObserver(listId);
} catch (e) { } catch (e) {
console.error(`[loadListData] error:`, e); console.error('[loadListData] error:', e);
} finally { } finally {
state.loading = false; state.loading = false;
showLoading(listId, false); showLoading(listId, false);
} }
} }
// 显示/隐藏加载指示器
function showLoading(listId, show) { function showLoading(listId, show) {
const loadingId = listId.replace('List', 'Loading'); const loadingId = listId.replace('List', 'Loading');
const loadingEl = document.getElementById(loadingId); const loadingEl = document.getElementById(loadingId);
if (loadingEl) { if (loadingEl) {
loadingEl.style.display = show ? 'block' : 'none'; 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) { function renderList(listId) {
const config = listConfig[listId]; const cfg = config[listId];
const state = listState[listId]; const state = listState[listId];
if (!config || !state) return; if (!cfg || !state) return;
const container = document.getElementById(listId); const container = document.getElementById(listId);
if (!container) return; if (!container) return;
if (state.records.length === 0) { if (state.records.length === 0) {
container.innerHTML = '<div class="ranking-empty">' + config.emptyMsg + '</div>'; container.innerHTML = '<div class="ranking-empty">' + cfg.emptyMsg + '</div>';
return; return;
} }
// 按日期分组
const grouped = {}; const grouped = {};
state.records.forEach(r => { state.records.forEach(r => {
const date = formatLocalDate(r.created_at); const date = formatLocalDate(r.created_at);
@@ -102,17 +109,53 @@ function renderList(listId) {
container.innerHTML = ''; container.innerHTML = '';
// 生成 HTML
let html = '';
for (const [date, records] of Object.entries(grouped)) { for (const [date, records] of Object.entries(grouped)) {
html += '<div class="order-date-group" data-date="' + date + '"><div class="order-date-label">' + date + '</div>'; const group = document.createElement('div');
records.forEach(r => { group.className = 'order-date-group';
html += config.renderItem(r); group.setAttribute('data-date', date);
});
html += '</div>';
}
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;
} }
// ============================================ // ============================================
+35 -3
View File
@@ -3,6 +3,8 @@ const cors = require('cors');
const path = require('path'); const path = require('path');
const compression = require('compression'); const compression = require('compression');
const config = require('./config');
const logger = require('./utils/logger');
const db = require('./db'); const db = require('./db');
const { router: authRouter, auth } = require('./routes/auth'); const { router: authRouter, auth } = require('./routes/auth');
const statsRouter = require('./routes/stats'); const statsRouter = require('./routes/stats');
@@ -11,6 +13,9 @@ const ioRouter = require('./routes/io');
const app = express(); const app = express();
// 请求日志中间件
app.use(logger.requestLogger);
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use(compression()); app.use(compression());
@@ -44,7 +49,34 @@ app.use('/api/stats', auth, statsRouter);
app.use('/api/records', auth, recordsRouter); app.use('/api/records', auth, recordsRouter);
app.use('/api', auth, ioRouter); app.use('/api', auth, ioRouter);
const PORT = process.env.PORT || 3000; // 404 处理
app.listen(PORT, '0.0.0.0', () => { app.use((req, res) => {
console.log(`服务器运行在 http://0.0.0.0:${PORT}`); 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}`);
}); });
+39
View File
@@ -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']
};
+104 -74
View File
@@ -1,95 +1,125 @@
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const path = require('path'); const path = require('path');
const logger = require('./utils/logger');
const db = new Database(path.join(__dirname, '..', 'data.db')); const db = new Database(path.join(__dirname, '..', 'data.db'));
// 启用 WAL 模式和外键约束
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON'); db.pragma('foreign_keys = ON');
// 创建表 /**
db.exec(` * 创建表结构
CREATE TABLE IF NOT EXISTS users ( */
id INTEGER PRIMARY KEY AUTOINCREMENT, function createTables() {
username TEXT UNIQUE NOT NULL, db.exec(`
password TEXT NOT NULL, CREATE TABLE IF NOT EXISTS users (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP 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 ( CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
type TEXT NOT NULL CHECK(type IN ('income', 'expense')), type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
category TEXT NOT NULL, category TEXT NOT NULL,
amount REAL NOT NULL, amount REAL NOT NULL,
quantity INTEGER, quantity INTEGER,
unit_price REAL, unit_price REAL,
boss TEXT, boss TEXT,
partner TEXT, partner TEXT,
source TEXT, source TEXT,
destination TEXT, destination TEXT,
note TEXT, note TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) 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_user_id ON records(user_id);
CREATE INDEX IF NOT EXISTS idx_records_category ON records(category); 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_created_at ON records(created_at);
CREATE INDEX IF NOT EXISTS idx_records_type ON records(type); 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_boss ON records(boss);
CREATE INDEX IF NOT EXISTS idx_records_partner ON records(partner); 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_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_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_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_destination ON records(destination);
`); `);
logger.debug('表结构创建完成');
}
// 迁移:去掉 category 的 CHECK 约束 /**
try { * 迁移:去掉 category 的 CHECK 约束
const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get(); */
if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) { function migrateRemoveCategoryCheck() {
db.exec(` try {
CREATE TABLE records_new ( const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get();
id INTEGER PRIMARY KEY AUTOINCREMENT, if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) {
user_id INTEGER NOT NULL, logger.info('执行迁移:移除 category CHECK 约束');
type TEXT NOT NULL CHECK(type IN ('income', 'expense')), db.exec(`
category TEXT NOT NULL, CREATE TABLE records_new (
amount REAL NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT,
quantity INTEGER, user_id INTEGER NOT NULL,
unit_price REAL, type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
boss TEXT, category TEXT NOT NULL,
partner TEXT, amount REAL NOT NULL,
source TEXT, quantity INTEGER,
destination TEXT, unit_price REAL,
note TEXT, boss TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, partner TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) source TEXT,
); destination TEXT,
INSERT INTO records_new SELECT * FROM records; note TEXT,
DROP TABLE records; created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
ALTER TABLE records_new RENAME TO records; 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 { * 迁移:添加 rebate 列
const cols = db.prepare("PRAGMA table_info(records)").all(); */
if (!cols.find(c => c.name === 'rebate')) { function migrateAddRebateColumn() {
db.exec("ALTER TABLE records ADD COLUMN rebate REAL"); 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); function initDefaultUser() {
db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash); 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; module.exports = db;
+12 -3
View File
@@ -2,16 +2,17 @@ const express = require('express');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const db = require('../db'); const db = require('../db');
const config = require('../config');
const logger = require('../utils/logger');
const router = express.Router(); const router = express.Router();
const SECRET = 'gamer-order-secret-2024';
// JWT 中间件 // JWT 中间件
function auth(req, res, next) { function auth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1]; const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: '未登录' }); if (!token) return res.status(401).json({ error: '未登录' });
try { try {
req.user = jwt.verify(token, SECRET); req.user = jwt.verify(token, config.jwtSecret);
next(); next();
} catch { } catch {
res.status(401).json({ error: '登录已过期' }); res.status(401).json({ error: '登录已过期' });
@@ -21,11 +22,19 @@ function auth(req, res, next) {
// 登录 // 登录
router.post('/login', (req, res) => { router.post('/login', (req, res) => {
const { username, password } = req.body; 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); const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user || !bcrypt.compareSync(password, user.password)) { if (!user || !bcrypt.compareSync(password, user.password)) {
logger.warn(`登录失败: ${username}`);
return res.status(401).json({ error: '用户名或密码错误' }); 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 }); res.json({ token, username: user.username });
}); });
+64 -53
View File
@@ -1,61 +1,68 @@
const express = require('express'); const express = require('express');
const db = require('../db'); const db = require('../db');
const { parseCSVLine } = require('../utils/helpers'); const logger = require('../utils/logger');
const { parseCSVLine, AppError } = require('../utils/helpers');
const router = express.Router(); const router = express.Router();
// 导出数据 // 导出数据
router.get('/export', (req, res) => { router.get('/export', (req, res, next) => {
const format = req.query.format; try {
if (!format || !['json', 'csv'].includes(format)) { const format = req.query.format;
return res.status(400).json({ error: '请指定格式: json 或 csv' }); 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 { try {
const raw = req.body; const raw = req.body;
if (!raw || typeof raw !== 'string') { if (!raw || typeof raw !== 'string') {
return res.status(400).json({ error: '未收到文件内容' }); throw new AppError('未收到文件内容', 400);
} }
let records = []; let records = [];
@@ -66,16 +73,16 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res)
if (trimmed.startsWith('{')) { if (trimmed.startsWith('{')) {
const data = JSON.parse(trimmed); const data = JSON.parse(trimmed);
if (!data.exportVersion || !Array.isArray(data.records)) { if (!data.exportVersion || !Array.isArray(data.records)) {
return res.status(400).json({ error: '无效的JSON导出文件格式' }); throw new AppError('无效的JSON导出文件格式', 400);
} }
records = data.records; records = data.records;
} else { } else {
const lines = trimmed.split('\n').map(l => l.replace(/\r$/, '')); const lines = trimmed.split('\n').map(l => l.replace(/\r$/, ''));
if (lines.length < 2) { if (lines.length < 2) {
return res.status(400).json({ error: 'CSV文件为空或格式不正确' }); throw new AppError('CSV文件为空或格式不正确', 400);
} }
if (lines[0] !== allFields.join(',')) { if (lines[0] !== allFields.join(',')) {
return res.status(400).json({ error: 'CSV列头不匹配,请使用本系统导出的文件' }); throw new AppError('CSV列头不匹配,请使用本系统导出的文件', 400);
} }
const csvHeaders = lines[0].split(','); const csvHeaders = lines[0].split(',');
for (let i = 1; i < lines.length; i++) { 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 r of records) {
for (const f of requiredFields) { for (const f of requiredFields) {
if (!r[f] && r[f] !== 0) { if (!r[f] && r[f] !== 0) {
return res.status(400).json({ error: `记录缺少必填字段: ${f}` }); throw new AppError(`记录缺少必填字段: ${f}`, 400);
} }
} }
if (!['income', 'expense'].includes(r.type)) { 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); insertMany(records);
logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped}`);
res.json({ imported, skipped, total: records.length }); res.json({ imported, skipped, total: records.length });
} catch (e) { } catch (err) {
console.error('Import error:', e); if (err instanceof SyntaxError) {
res.status(400).json({ error: '文件解析失败: ' + e.message }); next(new AppError('文件解析失败: ' + err.message, 400));
} else {
next(err);
}
} }
}); });
+63 -19
View File
@@ -1,18 +1,36 @@
const express = require('express'); const express = require('express');
const db = require('../db'); 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(); const router = express.Router();
// 新增记录 // 新增记录
router.post('/', (req, res) => { router.post('/', (req, res, next) => {
const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body; try {
const stmt = db.prepare(` const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body;
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) // 输入验证
`); validateRequired(req.body, ['type', 'category', 'amount']);
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()); validateInList(type, 'type', ['income', 'expense']);
res.json({ id: result.lastInsertRowid });
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) => { router.put('/:id', (req, res, next) => {
const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body; try {
db.prepare(` const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body;
UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ? const id = Number(req.params.id);
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); if (!id || id <= 0) {
res.json({ success: true }); 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) => { router.delete('/:id', (req, res, next) => {
db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id); try {
res.json({ success: true }); 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; module.exports = router;
+178 -141
View File
@@ -1,13 +1,15 @@
const express = require('express'); const express = require('express');
const db = require('../db'); 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(); const router = express.Router();
// 内存缓存(限制大小,基于LRU) // 内存缓存(限制大小,基于LRU)
const statsCache = new Map(); const statsCache = new Map();
const CACHE_TTL = 2 * 60 * 1000; const CACHE_TTL = config.cache.ttl;
const MAX_CACHE_SIZE = 100; const MAX_CACHE_SIZE = config.cache.maxSize;
function getCached(key, fetchFn) { function getCached(key, fetchFn) {
const cached = statsCache.get(key); const cached = statsCache.get(key);
@@ -24,9 +26,10 @@ function getCached(key, fetchFn) {
} }
return Promise.resolve(fetchFn()).then(data => { return Promise.resolve(fetchFn()).then(data => {
statsCache.set(key, { data, ts: Date.now() }); statsCache.set(key, { data, ts: Date.now() });
logger.debug(`缓存更新: ${key}`);
return data; return data;
}).catch(err => { }).catch(err => {
console.error('Cache fetch error:', err.message); logger.error('缓存获取失败:', err.message);
throw err; throw err;
}); });
} }
@@ -54,48 +57,60 @@ function formatLocalDate(date) {
} }
// 本月统计 // 本月统计
router.get('/monthly', (req, res) => { router.get('/monthly', (req, res, next) => {
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); try {
const cacheKey = `monthly:${req.user.id}:${startDate}:${endDate}`; const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
getCached(cacheKey, () => { const cacheKey = `monthly:${req.user.id}:${startDate}:${endDate}`;
const stats = db.prepare(` getCached(cacheKey, () => {
SELECT const stats = db.prepare(`
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_order_count, SELECT
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 take_order_count,
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 take_order_income,
COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as dispatch_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 redEnvelope_income, COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as dispatch_order_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 redEnvelope_income,
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 redEnvelope_count,
COALESCE(SUM(CASE WHEN category = '奶茶' THEN 1 ELSE 0 END), 0) as milkTea_count, COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as milkTea_income,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income, COALESCE(SUM(CASE WHEN category = '奶茶' THEN 1 ELSE 0 END), 0) as milkTea_count,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
FROM records COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? FROM records
`).get(req.user.id, startDate, endDate); WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
return stats; `).get(req.user.id, startDate, endDate);
}).then(stats => res.json(stats)); return stats;
}).then(stats => res.json(stats)).catch(next);
} catch (err) {
next(err);
}
}); });
// 月收入/支出记录列表 // 月收入/支出记录列表
router.get('/monthly-records', (req, res) => { router.get('/monthly-records', (req, res, next) => {
const { type, page = 1, limit = 20 } = req.query; try {
if (!type || !['income', 'expense'].includes(type)) return res.status(400).json({ error: '缺少类型参数' }); const { type, page = 1, limit = 20 } = req.query;
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month); if (!type || !['income', 'expense'].includes(type)) {
const offset = (Number(page) - 1) * Number(limit); 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(` const stats = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total, COUNT(*) as count 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') < ? FROM records WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
`).get(req.user.id, type, startDate, endDate); `).get(req.user.id, type, startDate, endDate);
const records = db.prepare(` const records = db.prepare(`
SELECT * FROM records SELECT * FROM records
WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
ORDER BY created_at DESC LIMIT ? OFFSET ? ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, type, startDate, endDate, Number(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) => { router.get('/boss-records', (req, res, next) => {
const boss = req.query.boss; try {
const { page = 1, limit = 20, year, month } = req.query; const boss = req.query.boss;
if (!boss) return res.status(400).json({ error: '缺少老板参数' }); const { page = 1, limit = 20, year, month } = req.query;
const offset = (Number(page) - 1) * Number(limit); if (!boss) throw new AppError('缺少老板参数', 400);
// 日期过滤条件 const validatedPage = Math.max(1, Number(page) || 1);
let dateFilter = ''; const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit);
let dateParams = []; const offset = (validatedPage - 1) * validatedLimit;
if (year && month) {
const startDate = `${year}-${String(month).padStart(2, '0')}-01`; // 日期过滤条件
const endDate = `${year}-${String(month).padStart(2, '0')}-31`; let dateFilter = '';
dateFilter = ` AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') <= ?`; let dateParams = [];
dateParams = [startDate, endDate]; 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) => { router.get('/partner-records', (req, res, next) => {
const partner = req.query.partner; try {
const { page = 1, limit = 20, year, month } = req.query; const partner = req.query.partner;
if (!partner) return res.status(400).json({ error: '缺少陪玩参数' }); const { page = 1, limit = 20, year, month } = req.query;
const offset = (Number(page) - 1) * Number(limit); if (!partner) throw new AppError('缺少陪玩参数', 400);
// 日期过滤条件 const validatedPage = Math.max(1, Number(page) || 1);
let dateFilter = ''; const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit);
let dateParams = []; const offset = (validatedPage - 1) * validatedLimit;
if (year && month) {
const startDate = `${year}-${String(month).padStart(2, '0')}-01`; // 日期过滤条件
const endDate = `${year}-${String(month).padStart(2, '0')}-31`; let dateFilter = '';
dateFilter = ` AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') <= ?`; let dateParams = [];
dateParams = [startDate, endDate]; 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天每日收入 // 最近7天每日收入
@@ -399,25 +428,33 @@ router.get('/daily-income', (req, res) => {
}); });
// 某天收入记录详情 // 某天收入记录详情
router.get('/daily-detail', (req, res) => { router.get('/daily-detail', (req, res, next) => {
const { date, page = 1, limit = 20 } = req.query; try {
if (!date) return res.status(400).json({ error: '缺少日期参数' }); const { date, page = 1, limit = 20 } = req.query;
const offset = (Number(page) - 1) * Number(limit); if (!date) throw new AppError('缺少日期参数', 400);
const nextDay = new Date(date);
nextDay.setDate(nextDay.getDate() + 1);
const endDate = formatLocalDate(nextDay);
const stats = db.prepare(` const validatedPage = Math.max(1, Number(page) || 1);
SELECT COALESCE(SUM(amount), 0) as total_income, COUNT(*) as count const validatedLimit = Math.min(Math.max(1, Number(limit) || 20), config.pagination.maxLimit);
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? const offset = (validatedPage - 1) * validatedLimit;
`).get(req.user.id, date, endDate);
const records = db.prepare(` const nextDay = new Date(date);
SELECT * FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ? nextDay.setDate(nextDay.getDate() + 1);
ORDER BY created_at DESC LIMIT ? OFFSET ? const endDate = formatLocalDate(nextDay);
`).all(req.user.id, date, endDate, Number(limit), offset);
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);
}
}); });
// 老板收入排行 - 首页预览 // 老板收入排行 - 首页预览
+67 -3
View File
@@ -1,4 +1,17 @@
// 日期范围辅助函数 /**
* 应用错误类
*/
class AppError extends Error {
constructor(message, status = 400) {
super(message);
this.status = status;
this.expose = true; // 允许向客户端暴露错误信息
}
}
/**
* 日期范围辅助函数
*/
function getMonthRange(qYear, qMonth) { function getMonthRange(qYear, qMonth) {
const now = new Date(); const now = new Date();
const y = Number(qYear) || now.getFullYear(); const y = Number(qYear) || now.getFullYear();
@@ -10,7 +23,9 @@ function getMonthRange(qYear, qMonth) {
return { startDate, endDate }; return { startDate, endDate };
} }
// CSV行解析(处理引号内的逗号) /**
* CSV行解析(处理引号内的逗号)
*/
function parseCSVLine(line) { function parseCSVLine(line) {
const result = []; const result = [];
let current = ''; let current = '';
@@ -31,4 +46,53 @@ function parseCSVLine(line) {
return result; 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
};
+79
View File
@@ -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;