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
+2
-2
@@ -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
@@ -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 };
|
||||||
|
|||||||
+85
-42
@@ -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: [] },
|
function initList(listId) {
|
||||||
redEnvelopeList: { loading: false, records: [] },
|
listState[listId] = {
|
||||||
milkTeaList: { loading: false, records: [] }
|
loading: false,
|
||||||
|
records: [],
|
||||||
|
page: 1,
|
||||||
|
hasMore: true,
|
||||||
|
totalCount: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始化列表 - 清空状态
|
|
||||||
function initList(listId) {
|
|
||||||
const state = listState[listId];
|
|
||||||
if (!state) return;
|
|
||||||
|
|
||||||
state.loading = false;
|
|
||||||
state.records = [];
|
|
||||||
|
|
||||||
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');
|
||||||
|
group.className = 'order-date-group';
|
||||||
|
group.setAttribute('data-date', date);
|
||||||
|
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'order-date-label';
|
||||||
|
label.textContent = date;
|
||||||
|
group.appendChild(label);
|
||||||
|
|
||||||
records.forEach(r => {
|
records.forEach(r => {
|
||||||
html += config.renderItem(r);
|
const item = document.createElement('div');
|
||||||
|
item.innerHTML = cfg.renderItem(r);
|
||||||
|
group.appendChild(item.firstChild);
|
||||||
});
|
});
|
||||||
html += '</div>';
|
|
||||||
|
container.appendChild(group);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = html;
|
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
@@ -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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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']
|
||||||
|
};
|
||||||
+36
-6
@@ -1,13 +1,18 @@
|
|||||||
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');
|
||||||
|
|
||||||
// 创建表
|
/**
|
||||||
|
* 创建表结构
|
||||||
|
*/
|
||||||
|
function createTables() {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -44,11 +49,17 @@ db.exec(`
|
|||||||
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 约束
|
/**
|
||||||
|
* 迁移:去掉 category 的 CHECK 约束
|
||||||
|
*/
|
||||||
|
function migrateRemoveCategoryCheck() {
|
||||||
try {
|
try {
|
||||||
const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get();
|
const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='records'").get();
|
||||||
if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) {
|
if (tableInfo && tableInfo.sql.includes("CHECK(category IN")) {
|
||||||
|
logger.info('执行迁移:移除 category CHECK 约束');
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE records_new (
|
CREATE TABLE records_new (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -70,26 +81,45 @@ try {
|
|||||||
DROP TABLE records;
|
DROP TABLE records;
|
||||||
ALTER TABLE records_new RENAME TO records;
|
ALTER TABLE records_new RENAME TO records;
|
||||||
`);
|
`);
|
||||||
|
logger.info('迁移完成:category CHECK 约束已移除');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Migration skipped:', e.message);
|
logger.warn('迁移跳过 (category):', e.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 迁移:添加 rebate 列(派单返点)
|
/**
|
||||||
|
* 迁移:添加 rebate 列
|
||||||
|
*/
|
||||||
|
function migrateAddRebateColumn() {
|
||||||
try {
|
try {
|
||||||
const cols = db.prepare("PRAGMA table_info(records)").all();
|
const cols = db.prepare("PRAGMA table_info(records)").all();
|
||||||
if (!cols.find(c => c.name === 'rebate')) {
|
if (!cols.find(c => c.name === 'rebate')) {
|
||||||
|
logger.info('执行迁移:添加 rebate 列');
|
||||||
db.exec("ALTER TABLE records ADD COLUMN rebate REAL");
|
db.exec("ALTER TABLE records ADD COLUMN rebate REAL");
|
||||||
|
logger.info('迁移完成:rebate 列已添加');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Rebate migration skipped:', e.message);
|
logger.warn('迁移跳过 (rebate):', e.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化默认用户 lizhilun
|
/**
|
||||||
|
* 初始化默认用户
|
||||||
|
*/
|
||||||
|
function initDefaultUser() {
|
||||||
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get('lizhilun');
|
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get('lizhilun');
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
const hash = bcrypt.hashSync('lizhilun', 10);
|
const hash = bcrypt.hashSync('lizhilun', 10);
|
||||||
db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash);
|
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
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+24
-13
@@ -1,14 +1,16 @@
|
|||||||
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) => {
|
||||||
|
try {
|
||||||
const format = req.query.format;
|
const format = req.query.format;
|
||||||
if (!format || !['json', 'csv'].includes(format)) {
|
if (!format || !['json', 'csv'].includes(format)) {
|
||||||
return res.status(400).json({ error: '请指定格式: json 或 csv' });
|
throw new AppError('请指定格式: json 或 csv', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = db.prepare(
|
const records = db.prepare(
|
||||||
@@ -26,6 +28,7 @@ router.get('/export', (req, res) => {
|
|||||||
recordCount: records.length,
|
recordCount: records.length,
|
||||||
records
|
records
|
||||||
};
|
};
|
||||||
|
logger.info(`用户 ${req.user.username} 导出 JSON 数据: ${records.length} 条`);
|
||||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
|
||||||
return res.send(JSON.stringify(exportData, null, 2));
|
return res.send(JSON.stringify(exportData, null, 2));
|
||||||
@@ -45,17 +48,21 @@ router.get('/export', (req, res) => {
|
|||||||
return str;
|
return str;
|
||||||
}).join(',');
|
}).join(',');
|
||||||
});
|
});
|
||||||
|
logger.info(`用户 ${req.user.username} 导出 CSV 数据: ${records.length} 条`);
|
||||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
|
||||||
res.send(BOM + cols.join(',') + '\n' + csvRows.join('\n'));
|
res.send(BOM + cols.join(',') + '\n' + csvRows.join('\n'));
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 导入数据
|
// 导入数据
|
||||||
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
try {
|
||||||
const { type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at } = req.body;
|
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(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
|
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
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());
|
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 });
|
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) => {
|
||||||
|
try {
|
||||||
const { quantity, unit_price, rebate, amount, boss, partner, source, destination, note, created_at } = req.body;
|
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(`
|
db.prepare(`
|
||||||
UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ?
|
UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ?
|
||||||
WHERE id = ? AND user_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);
|
`).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 });
|
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 {
|
||||||
|
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 });
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+65
-28
@@ -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,7 +57,8 @@ function formatLocalDate(date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 本月统计
|
// 本月统计
|
||||||
router.get('/monthly', (req, res) => {
|
router.get('/monthly', (req, res, next) => {
|
||||||
|
try {
|
||||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||||
const cacheKey = `monthly:${req.user.id}:${startDate}:${endDate}`;
|
const cacheKey = `monthly:${req.user.id}:${startDate}:${endDate}`;
|
||||||
getCached(cacheKey, () => {
|
getCached(cacheKey, () => {
|
||||||
@@ -74,15 +78,23 @@ router.get('/monthly', (req, res) => {
|
|||||||
WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
||||||
`).get(req.user.id, startDate, endDate);
|
`).get(req.user.id, startDate, endDate);
|
||||||
return stats;
|
return stats;
|
||||||
}).then(stats => res.json(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) => {
|
||||||
|
try {
|
||||||
const { type, page = 1, limit = 20 } = req.query;
|
const { type, page = 1, limit = 20 } = req.query;
|
||||||
if (!type || !['income', 'expense'].includes(type)) return res.status(400).json({ error: '缺少类型参数' });
|
if (!type || !['income', 'expense'].includes(type)) {
|
||||||
|
throw new AppError('缺少类型参数或类型无效', 400);
|
||||||
|
}
|
||||||
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
|
||||||
const offset = (Number(page) - 1) * Number(limit);
|
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
|
||||||
@@ -93,9 +105,12 @@ router.get('/monthly-records', (req, res) => {
|
|||||||
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,11 +292,15 @@ router.get('/total', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 老板交易记录
|
// 老板交易记录
|
||||||
router.get('/boss-records', (req, res) => {
|
router.get('/boss-records', (req, res, next) => {
|
||||||
|
try {
|
||||||
const boss = req.query.boss;
|
const boss = req.query.boss;
|
||||||
const { page = 1, limit = 20, year, month } = req.query;
|
const { page = 1, limit = 20, year, month } = req.query;
|
||||||
if (!boss) return res.status(400).json({ error: '缺少老板参数' });
|
if (!boss) throw new AppError('缺少老板参数', 400);
|
||||||
const offset = (Number(page) - 1) * Number(limit);
|
|
||||||
|
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 dateFilter = '';
|
||||||
@@ -307,7 +326,7 @@ router.get('/boss-records', (req, res) => {
|
|||||||
|
|
||||||
const records = db.prepare(`
|
const records = db.prepare(`
|
||||||
SELECT * FROM records WHERE user_id = ? AND COALESCE(boss, source) = ?${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?
|
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);
|
`).all(req.user.id, boss, ...dateParams, validatedLimit, offset);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
my_income: summary.my_income,
|
my_income: summary.my_income,
|
||||||
@@ -315,18 +334,25 @@ router.get('/boss-records', (req, res) => {
|
|||||||
take_count: summary.take_count,
|
take_count: summary.take_count,
|
||||||
dispatch_count: summary.dispatch_count,
|
dispatch_count: summary.dispatch_count,
|
||||||
records,
|
records,
|
||||||
page: Number(page),
|
page: validatedPage,
|
||||||
limit: Number(limit),
|
limit: validatedLimit,
|
||||||
totalCount: summary.count
|
totalCount: summary.count
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 陪玩交易记录
|
// 陪玩交易记录
|
||||||
router.get('/partner-records', (req, res) => {
|
router.get('/partner-records', (req, res, next) => {
|
||||||
|
try {
|
||||||
const partner = req.query.partner;
|
const partner = req.query.partner;
|
||||||
const { page = 1, limit = 20, year, month } = req.query;
|
const { page = 1, limit = 20, year, month } = req.query;
|
||||||
if (!partner) return res.status(400).json({ error: '缺少陪玩参数' });
|
if (!partner) throw new AppError('缺少陪玩参数', 400);
|
||||||
const offset = (Number(page) - 1) * Number(limit);
|
|
||||||
|
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 dateFilter = '';
|
||||||
@@ -357,7 +383,7 @@ router.get('/partner-records', (req, res) => {
|
|||||||
(category = '派单' AND partner = ?) OR
|
(category = '派单' AND partner = ?) OR
|
||||||
(type = 'income' AND category != '派单' AND category != '接单' AND source = ?)
|
(type = 'income' AND category != '派单' AND category != '接单' AND source = ?)
|
||||||
)${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?
|
)${dateFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||||
`).all(req.user.id, partner, partner, ...dateParams, Number(limit), offset);
|
`).all(req.user.id, partner, partner, ...dateParams, validatedLimit, offset);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
dispatch_count: summary.dispatch_count,
|
dispatch_count: summary.dispatch_count,
|
||||||
@@ -365,10 +391,13 @@ router.get('/partner-records', (req, res) => {
|
|||||||
rebate_income: summary.rebate_income,
|
rebate_income: summary.rebate_income,
|
||||||
other_income: summary.other_income,
|
other_income: summary.other_income,
|
||||||
records,
|
records,
|
||||||
page: Number(page),
|
page: validatedPage,
|
||||||
limit: Number(limit),
|
limit: validatedLimit,
|
||||||
totalCount: summary.count
|
totalCount: summary.count
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 最近7天每日收入
|
// 最近7天每日收入
|
||||||
@@ -399,10 +428,15 @@ router.get('/daily-income', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 某天收入记录详情
|
// 某天收入记录详情
|
||||||
router.get('/daily-detail', (req, res) => {
|
router.get('/daily-detail', (req, res, next) => {
|
||||||
|
try {
|
||||||
const { date, page = 1, limit = 20 } = req.query;
|
const { date, page = 1, limit = 20 } = req.query;
|
||||||
if (!date) return res.status(400).json({ error: '缺少日期参数' });
|
if (!date) throw new AppError('缺少日期参数', 400);
|
||||||
const offset = (Number(page) - 1) * Number(limit);
|
|
||||||
|
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 nextDay = new Date(date);
|
const nextDay = new Date(date);
|
||||||
nextDay.setDate(nextDay.getDate() + 1);
|
nextDay.setDate(nextDay.getDate() + 1);
|
||||||
const endDate = formatLocalDate(nextDay);
|
const endDate = formatLocalDate(nextDay);
|
||||||
@@ -415,9 +449,12 @@ router.get('/daily-detail', (req, res) => {
|
|||||||
const records = db.prepare(`
|
const records = db.prepare(`
|
||||||
SELECT * FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
|
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 ?
|
ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||||
`).all(req.user.id, date, endDate, Number(limit), offset);
|
`).all(req.user.id, date, endDate, validatedLimit, offset);
|
||||||
|
|
||||||
res.json({ total_income: stats.total_income, records, page: Number(page), limit: Number(limit), totalCount: stats.count });
|
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) {
|
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
|
||||||
|
};
|
||||||
|
|||||||
@@ -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