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:
co-authored by
Claude Opus 4.6
parent
19e5a48f07
commit
d551841035
+35
-3
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
+12
-3
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
+64
-53
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+63
-19
@@ -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;
|
||||
|
||||
+178
-141
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
// 老板收入排行 - 首页预览
|
||||
|
||||
+67
-3
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user