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/autocomplete.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/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/record-form.js"></script>
|
||||
<script src="js/import-export.js"></script>
|
||||
|
||||
+58
-7
@@ -1,11 +1,62 @@
|
||||
// ============================================
|
||||
// 应用状态管理
|
||||
// ============================================
|
||||
const API = '';
|
||||
let token = localStorage.getItem('token');
|
||||
let username = localStorage.getItem('username');
|
||||
let currentType = 'income';
|
||||
let currentCategory = '接单';
|
||||
let currentYear = new Date().getFullYear();
|
||||
let currentMonth = new Date().getMonth() + 1;
|
||||
let currentNav = 'stats';
|
||||
|
||||
const AppState = {
|
||||
// 认证状态
|
||||
auth: {
|
||||
token: localStorage.getItem('token'),
|
||||
username: localStorage.getItem('username')
|
||||
},
|
||||
|
||||
// 导航状态
|
||||
nav: {
|
||||
currentType: 'income',
|
||||
currentCategory: '接单',
|
||||
currentYear: new Date().getFullYear(),
|
||||
currentMonth: new Date().getMonth() + 1,
|
||||
currentNav: 'stats'
|
||||
},
|
||||
|
||||
// 状态更新方法
|
||||
setAuth(token, username) {
|
||||
this.auth.token = token;
|
||||
this.auth.username = username;
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('username', username);
|
||||
},
|
||||
|
||||
clearAuth() {
|
||||
this.auth.token = null;
|
||||
this.auth.username = null;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('username');
|
||||
},
|
||||
|
||||
setCategory(type, category) {
|
||||
this.nav.currentType = type;
|
||||
this.nav.currentCategory = category;
|
||||
},
|
||||
|
||||
setYearMonth(year, month) {
|
||||
this.nav.currentYear = year;
|
||||
this.nav.currentMonth = month;
|
||||
},
|
||||
|
||||
setNav(nav) {
|
||||
this.nav.currentNav = nav;
|
||||
}
|
||||
};
|
||||
|
||||
// 兼容旧代码的全局变量
|
||||
let token = AppState.auth.token;
|
||||
let username = AppState.auth.username;
|
||||
let currentType = AppState.nav.currentType;
|
||||
let currentCategory = AppState.nav.currentCategory;
|
||||
let currentYear = AppState.nav.currentYear;
|
||||
let currentMonth = AppState.nav.currentMonth;
|
||||
let currentNav = AppState.nav.currentNav;
|
||||
|
||||
function headers() {
|
||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||
|
||||
+88
-45
@@ -1,9 +1,8 @@
|
||||
// ============================================
|
||||
// 通用列表管理 - 一次性加载
|
||||
// 通用列表管理 - 无限滚动加载
|
||||
// ============================================
|
||||
|
||||
// 通用列表配置
|
||||
const listConfig = {
|
||||
const config = {
|
||||
orderList: { endpoint: '/api/records/orders', emptyMsg: '暂无接单记录', renderItem: renderOrderItem },
|
||||
otherIncomeList: { endpoint: '/api/records/other-income', emptyMsg: '暂无红包及其他收入记录', renderItem: renderOtherIncomeItem },
|
||||
dispatchList: { endpoint: '/api/records/dispatches', emptyMsg: '暂无派单记录', renderItem: renderDispatchItem },
|
||||
@@ -11,88 +10,96 @@ const listConfig = {
|
||||
milkTeaList: { endpoint: '/api/records/milk-tea', emptyMsg: '暂无奶茶收入记录', renderItem: renderMilkTeaItem }
|
||||
};
|
||||
|
||||
// 通用列表加载状态(按 listId 存储)
|
||||
const listState = {
|
||||
orderList: { loading: false, records: [] },
|
||||
otherIncomeList: { loading: false, records: [] },
|
||||
dispatchList: { loading: false, records: [] },
|
||||
redEnvelopeList: { loading: false, records: [] },
|
||||
milkTeaList: { loading: false, records: [] }
|
||||
};
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const listState = {};
|
||||
|
||||
// 初始化列表 - 清空状态
|
||||
function initList(listId) {
|
||||
const state = listState[listId];
|
||||
if (!state) return;
|
||||
|
||||
state.loading = false;
|
||||
state.records = [];
|
||||
listState[listId] = {
|
||||
loading: false,
|
||||
records: [],
|
||||
page: 1,
|
||||
hasMore: true,
|
||||
totalCount: 0
|
||||
};
|
||||
|
||||
const container = document.getElementById(listId);
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
if (container) container.innerHTML = '';
|
||||
}
|
||||
|
||||
// 加载列表数据
|
||||
async function loadListData(listId) {
|
||||
const config = listConfig[listId];
|
||||
const cfg = config[listId];
|
||||
const state = listState[listId];
|
||||
if (!config || !state) return;
|
||||
if (!cfg || !state) return;
|
||||
|
||||
// 防止重复加载
|
||||
if (state.loading) return;
|
||||
if (state.loading || !state.hasMore) return;
|
||||
|
||||
state.loading = true;
|
||||
showLoading(listId, true);
|
||||
|
||||
try {
|
||||
const res = await fetch(API + config.endpoint + '?page=1&limit=1000&year=' + currentYear + '&month=' + currentMonth, { headers: headers() });
|
||||
const res = await fetch(
|
||||
API + cfg.endpoint + '?page=' + state.page + '&limit=' + PAGE_SIZE + '&year=' + currentYear + '&month=' + currentMonth,
|
||||
{ headers: headers() }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`[loadListData] fetch failed: ${res.status}`);
|
||||
console.error('[loadListData] fetch failed:', res.status);
|
||||
state.loading = false;
|
||||
showLoading(listId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const newRecords = data.records || [];
|
||||
const total = data.total || 0;
|
||||
|
||||
state.records = data.records || [];
|
||||
state.records = state.records.concat(newRecords);
|
||||
state.totalCount = total;
|
||||
state.hasMore = state.records.length < total;
|
||||
state.page++;
|
||||
|
||||
renderList(listId);
|
||||
setupScrollObserver(listId);
|
||||
} catch (e) {
|
||||
console.error(`[loadListData] error:`, e);
|
||||
console.error('[loadListData] error:', e);
|
||||
} finally {
|
||||
state.loading = false;
|
||||
showLoading(listId, false);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示/隐藏加载指示器
|
||||
function showLoading(listId, show) {
|
||||
const loadingId = listId.replace('List', 'Loading');
|
||||
const loadingEl = document.getElementById(loadingId);
|
||||
if (loadingEl) {
|
||||
loadingEl.style.display = show ? 'block' : 'none';
|
||||
if (!show && listState[listId]) {
|
||||
const state = listState[listId];
|
||||
if (state.hasMore) {
|
||||
loadingEl.textContent = '下拉加载更多...';
|
||||
} else if (state.records.length > 0) {
|
||||
loadingEl.textContent = '已加载全部';
|
||||
} else {
|
||||
loadingEl.textContent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染列表
|
||||
function renderList(listId) {
|
||||
const config = listConfig[listId];
|
||||
const cfg = config[listId];
|
||||
const state = listState[listId];
|
||||
if (!config || !state) return;
|
||||
if (!cfg || !state) return;
|
||||
|
||||
const container = document.getElementById(listId);
|
||||
if (!container) return;
|
||||
|
||||
if (state.records.length === 0) {
|
||||
container.innerHTML = '<div class="ranking-empty">' + config.emptyMsg + '</div>';
|
||||
container.innerHTML = '<div class="ranking-empty">' + cfg.emptyMsg + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 按日期分组
|
||||
const grouped = {};
|
||||
state.records.forEach(r => {
|
||||
const date = formatLocalDate(r.created_at);
|
||||
@@ -102,17 +109,53 @@ function renderList(listId) {
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
// 生成 HTML
|
||||
let html = '';
|
||||
for (const [date, records] of Object.entries(grouped)) {
|
||||
html += '<div class="order-date-group" data-date="' + date + '"><div class="order-date-label">' + date + '</div>';
|
||||
records.forEach(r => {
|
||||
html += config.renderItem(r);
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
const group = document.createElement('div');
|
||||
group.className = 'order-date-group';
|
||||
group.setAttribute('data-date', date);
|
||||
|
||||
container.innerHTML = html;
|
||||
const label = document.createElement('div');
|
||||
label.className = 'order-date-label';
|
||||
label.textContent = date;
|
||||
group.appendChild(label);
|
||||
|
||||
records.forEach(r => {
|
||||
const item = document.createElement('div');
|
||||
item.innerHTML = cfg.renderItem(r);
|
||||
group.appendChild(item.firstChild);
|
||||
});
|
||||
|
||||
container.appendChild(group);
|
||||
}
|
||||
}
|
||||
|
||||
function setupScrollObserver(listId) {
|
||||
const state = listState[listId];
|
||||
if (!state || !state.hasMore) return;
|
||||
|
||||
const container = document.getElementById(listId);
|
||||
if (!container) return;
|
||||
|
||||
// 移除旧的 sentinel
|
||||
const oldSentinel = container.querySelector('.scroll-sentinel');
|
||||
if (oldSentinel) oldSentinel.remove();
|
||||
|
||||
// 创建新的 sentinel
|
||||
const sentinel = document.createElement('div');
|
||||
sentinel.className = 'scroll-sentinel';
|
||||
sentinel.style.height = '1px';
|
||||
container.appendChild(sentinel);
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && !state.loading && state.hasMore) {
|
||||
loadListData(listId);
|
||||
}
|
||||
}, { rootMargin: '100px' });
|
||||
|
||||
observer.observe(sentinel);
|
||||
|
||||
// 存储 observer 以便清理
|
||||
state.observer = observer;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -163,4 +206,4 @@ function renderMilkTeaItem(r) {
|
||||
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "'") + ')\'>' +
|
||||
'<div class="order-item-top"><span class="order-item-boss">奶茶</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
||||
'<div class="order-item-bottom"><span>' + (r.source || '') + '</span><span>' + time + '</span></div></div>';
|
||||
}
|
||||
}
|
||||
+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