fix security and import validation

This commit is contained in:
lizhilun
2026-07-01 16:13:47 +08:00
parent 6467048f38
commit e69930799c
14 changed files with 498 additions and 163 deletions
+22
View File
@@ -1,8 +1,30 @@
const fs = require('fs');
const path = require('path');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return {};
return fs.readFileSync(filePath, 'utf8')
.split(/\r?\n/)
.reduce((env, line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return env;
const idx = trimmed.indexOf('=');
if (idx === -1) return env;
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
env[key] = value.replace(/^['"]|['"]$/g, '');
return env;
}, {});
}
const runtimeEnv = loadEnvFile(path.join(__dirname, '.env'));
module.exports = { module.exports = {
apps: [{ apps: [{
name: 'gamer', name: 'gamer',
script: 'server/app.js', script: 'server/app.js',
env: { env: {
...runtimeEnv,
NODE_ENV: 'production', NODE_ENV: 'production',
PORT: 3000 PORT: 3000
}, },
+23 -22
View File
@@ -427,9 +427,9 @@
} }
}, },
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
@@ -633,9 +633,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -988,9 +988,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "8.3.0", "version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -1047,12 +1047,13 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.1", "version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">=0.6" "node": ">=0.6"
@@ -1220,14 +1221,14 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3", "object-inspect": "^1.13.4",
"side-channel-list": "^1.0.0", "side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1", "side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2" "side-channel-weakmap": "^1.0.2"
}, },
@@ -1239,13 +1240,13 @@
} }
}, },
"node_modules/side-channel-list": { "node_modules/side-channel-list": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3" "object-inspect": "^1.13.4"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
+42 -7
View File
@@ -62,6 +62,39 @@ function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
} }
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[ch]));
}
function escapeAttr(value) {
return escapeHtml(value);
}
function jsString(value) {
return escapeAttr(JSON.stringify(String(value ?? '')));
}
function recordArg(record) {
return escapeAttr(JSON.stringify(record));
}
// 统一的 API 请求函数,自动处理 401 跳转到登录页
async function apiFetch(url, options = {}) {
const res = await fetch(url, { ...options, headers: { ...headers(), ...(options.headers || {}) } });
if (res.status === 401) {
AppState.clearAuth();
showPage('loginPage');
throw new Error('Unauthorized');
}
return res;
}
function formatLocalTime(isoStr) { function formatLocalTime(isoStr) {
if (!isoStr) return ''; if (!isoStr) return '';
const d = new Date(isoStr); const d = new Date(isoStr);
@@ -81,7 +114,7 @@ function formatLocalDate(input) {
async function loadTotalStats() { async function loadTotalStats() {
try { try {
const res = await fetch(API + '/api/stats/total', { headers: headers() }); const res = await apiFetch(API + '/api/stats/total');
if (!res.ok) return; if (!res.ok) return;
const s = await res.json(); const s = await res.json();
document.getElementById('totalTakeCount').textContent = s.take_count; document.getElementById('totalTakeCount').textContent = s.take_count;
@@ -99,10 +132,11 @@ function renderHorizontalChart(containerId, rows, clickHandler, fromPage) {
const max = Math.max(...rows.map(r => r.count), 1); const max = Math.max(...rows.map(r => r.count), 1);
container.innerHTML = rows.map((r, i) => { container.innerHTML = rows.map((r, i) => {
const pct = Math.max((r.count / max) * 100, 2); const pct = Math.max((r.count / max) * 100, 2);
const onclick = clickHandler ? ` onclick="${clickHandler}('${r.name.replace(/'/g, "\\'")}', '${fromPage}')"` : ''; const onclick = clickHandler ? ` onclick="${clickHandler}(${jsString(r.name)}, ${jsString(fromPage)})"` : '';
const name = escapeHtml(r.name);
return `<div class="hbar-row"${onclick}> return `<div class="hbar-row"${onclick}>
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div> <div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${r.name}">${r.name}</div> <div class="hbar-label" title="${escapeAttr(r.name)}">${name}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div> <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
<div class="hbar-value">${r.count}单</div> <div class="hbar-value">${r.count}单</div>
</div>`; </div>`;
@@ -112,7 +146,7 @@ function renderHorizontalChart(containerId, rows, clickHandler, fromPage) {
async function openTakeRanking() { async function openTakeRanking() {
showPage('takeRankingPage'); showPage('takeRankingPage');
try { try {
const res = await fetch(API + '/api/stats/take-ranking', { headers: headers() }); const res = await apiFetch(API + '/api/stats/take-ranking');
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const rows = data.rows || []; const rows = data.rows || [];
@@ -141,7 +175,7 @@ async function openTakeRanking() {
async function openDispatchRanking() { async function openDispatchRanking() {
showPage('dispatchRankingPage'); showPage('dispatchRankingPage');
try { try {
const res = await fetch(API + '/api/stats/dispatch-ranking', { headers: headers() }); const res = await apiFetch(API + '/api/stats/dispatch-ranking');
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const rows = data.rows || []; const rows = data.rows || [];
@@ -178,9 +212,10 @@ async function openDispatchRanking() {
const container = document.getElementById('dispatchRankingChart'); const container = document.getElementById('dispatchRankingChart');
container.innerHTML = rows.map((r, i) => { container.innerHTML = rows.map((r, i) => {
const pct = Math.max(((r.dispatch_count || 0) / max) * 100, 2); const pct = Math.max(((r.dispatch_count || 0) / max) * 100, 2);
return `<div class="hbar-row" onclick="openPartnerDetail('${r.name.replace(/'/g, "\\'")}', 'dispatchRankingPage')"> const name = escapeHtml(r.name);
return `<div class="hbar-row" onclick="openPartnerDetail(${jsString(r.name)}, 'dispatchRankingPage')">
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div> <div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${r.name}">${r.name}</div> <div class="hbar-label" title="${escapeAttr(r.name)}">${name}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div> <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
<div class="hbar-value">${r.dispatch_count}单</div> <div class="hbar-value">${r.dispatch_count}单</div>
</div>`; </div>`;
+6 -4
View File
@@ -4,7 +4,7 @@ const _acCache = {};
async function acFetchNames(field) { async function acFetchNames(field) {
if (_acCache[field] && Date.now() - _acCache[field].ts < 60000) return _acCache[field].data; if (_acCache[field] && Date.now() - _acCache[field].ts < 60000) return _acCache[field].data;
try { try {
const res = await fetch(API + '/api/contacts?field=' + field, { headers: headers() }); const res = await apiFetch(API + '/api/contacts?field=' + field);
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json(); const data = await res.json();
_acCache[field] = { data, ts: Date.now() }; _acCache[field] = { data, ts: Date.now() };
@@ -45,14 +45,16 @@ function acSetup(inputId, field) {
if (filtered.length === 0) { list.classList.remove('show'); return; } if (filtered.length === 0) { list.classList.remove('show'); return; }
activeIdx = -1; activeIdx = -1;
list.innerHTML = filtered.slice(0, 10).map((n, i) => { list.innerHTML = filtered.slice(0, 10).map((n, i) => {
let display = n; let display = escapeHtml(n);
if (q) { if (q) {
const idx = n.toLowerCase().indexOf(q); const idx = n.toLowerCase().indexOf(q);
if (idx >= 0) { if (idx >= 0) {
display = n.slice(0, idx) + '<span class="ac-highlight">' + n.slice(idx, idx + q.length) + '</span>' + n.slice(idx + q.length); display = escapeHtml(n.slice(0, idx)) +
'<span class="ac-highlight">' + escapeHtml(n.slice(idx, idx + q.length)) + '</span>' +
escapeHtml(n.slice(idx + q.length));
} }
} }
return `<div class="ac-item" data-idx="${i}" data-value="${n.replace(/"/g, '&quot;')}">${display}</div>`; return `<div class="ac-item" data-idx="${i}" data-value="${escapeAttr(n)}">${display}</div>`;
}).join(''); }).join('');
list.classList.add('show'); list.classList.add('show');
} }
+16 -17
View File
@@ -38,9 +38,8 @@ async function loadListData(listId) {
showLoading(listId, true); showLoading(listId, true);
try { try {
const res = await fetch( const res = await apiFetch(
API + cfg.endpoint + '?page=' + state.page + '&limit=' + PAGE_SIZE + '&year=' + currentYear + '&month=' + currentMonth, API + cfg.endpoint + '?page=' + state.page + '&limit=' + PAGE_SIZE + '&year=' + currentYear + '&month=' + currentMonth
{ headers: headers() }
); );
if (!res.ok) { if (!res.ok) {
@@ -173,37 +172,37 @@ async function loadMilkTea() { return loadListData('milkTeaList'); }
function renderOrderItem(r) { function renderOrderItem(r) {
const time = formatLocalTime(r.created_at); const time = formatLocalTime(r.created_at);
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "&#39;") + ')\'>' + return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
'<div class="order-item-top"><span class="order-item-boss">' + (r.boss || '-') + '</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' + '<div class="order-item-top"><span class="order-item-boss">' + escapeHtml(r.boss || '-') + '</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
'<div class="order-item-bottom"><span>' + (r.quantity || 0) + '单 × ' + (r.unit_price || 0).toFixed(2) + '</span><span>' + time + (r.partner ? ' · ' + r.partner : '') + '</span></div></div>'; '<div class="order-item-bottom"><span>' + (r.quantity || 0) + '单 × ' + (r.unit_price || 0).toFixed(2) + '</span><span>' + escapeHtml(time + (r.partner ? ' · ' + r.partner : '')) + '</span></div></div>';
} }
function renderOtherIncomeItem(r) { function renderOtherIncomeItem(r) {
const time = formatLocalTime(r.created_at); const time = formatLocalTime(r.created_at);
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-'); const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "&#39;") + ')\'>' + return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
'<div class="order-item-top"><span class="order-item-boss">' + displayCategory + (r.boss ? ' · ' + r.boss : '') + '</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' + '<div class="order-item-top"><span class="order-item-boss">' + escapeHtml(displayCategory + (r.boss ? ' · ' + r.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>'; '<div class="order-item-bottom"><span>' + escapeHtml(r.source || '') + '</span><span>' + escapeHtml(time) + '</span></div></div>';
} }
function renderDispatchItem(r) { function renderDispatchItem(r) {
const time = formatLocalTime(r.created_at); const time = formatLocalTime(r.created_at);
const unitPrice = r.unit_price || (r.quantity ? r.amount / r.quantity : 0); const unitPrice = r.unit_price || (r.quantity ? r.amount / r.quantity : 0);
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "&#39;") + ')\'>' + return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
'<div class="order-item-top"><span class="order-item-boss">' + (r.boss || '-') + '</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' + '<div class="order-item-top"><span class="order-item-boss">' + escapeHtml(r.boss || '-') + '</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
'<div class="order-item-bottom"><span>' + (r.quantity || 0) + '单 × ' + (r.rebate || 0).toFixed(2) + '(单价' + unitPrice.toFixed(2) + '</span><span>' + time + (r.partner ? ' · ' + r.partner : '') + '</span></div></div>'; '<div class="order-item-bottom"><span>' + (r.quantity || 0) + '单 × ' + (r.rebate || 0).toFixed(2) + '(单价' + unitPrice.toFixed(2) + '</span><span>' + escapeHtml(time + (r.partner ? ' · ' + r.partner : '')) + '</span></div></div>';
} }
function renderRedEnvelopeItem(r) { function renderRedEnvelopeItem(r) {
const time = formatLocalTime(r.created_at); const time = formatLocalTime(r.created_at);
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "&#39;") + ')\'>' + return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
'<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-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>'; '<div class="order-item-bottom"><span>' + escapeHtml(r.source || '') + '</span><span>' + escapeHtml(time) + '</span></div></div>';
} }
function renderMilkTeaItem(r) { function renderMilkTeaItem(r) {
const time = formatLocalTime(r.created_at); const time = formatLocalTime(r.created_at);
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "&#39;") + ')\'>' + return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
'<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-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>'; '<div class="order-item-bottom"><span>' + escapeHtml(r.source || '') + '</span><span>' + escapeHtml(time) + '</span></div></div>';
} }
+10 -7
View File
@@ -17,7 +17,7 @@ async function loadRankingDetail() {
url += '&endDate=' + formatLocalDate(d); url += '&endDate=' + formatLocalDate(d);
} }
try { try {
const res = await fetch(url, { headers: headers() }); const res = await apiFetch(url);
if (!res.ok) return; if (!res.ok) return;
const rows = await res.json(); const rows = await res.json();
const container = document.getElementById('rankingDetailList'); const container = document.getElementById('rankingDetailList');
@@ -29,9 +29,10 @@ async function loadRankingDetail() {
const max = Math.max(...rows.map(r => r.dispatch_count), 1); const max = Math.max(...rows.map(r => r.dispatch_count), 1);
container.innerHTML = rows.map((r, i) => { container.innerHTML = rows.map((r, i) => {
const pct = Math.max((r.dispatch_count / max) * 100, 2); const pct = Math.max((r.dispatch_count / max) * 100, 2);
return `<div class="hbar-row" onclick="openBossDetail('${r.boss.replace(/'/g, "\\'")}', 'rankingPage')"> const boss = escapeHtml(r.boss);
return `<div class="hbar-row" onclick="openBossDetail(${jsString(r.boss)}, 'rankingPage')">
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div> <div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${r.boss}">${r.boss}</div> <div class="hbar-label" title="${escapeAttr(r.boss)}">${boss}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div> <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
<div class="hbar-value">${r.dispatch_count}单</div> <div class="hbar-value">${r.dispatch_count}单</div>
</div>`; </div>`;
@@ -40,9 +41,10 @@ async function loadRankingDetail() {
const max = Math.max(...rows.map(r => r.take_count), 1); const max = Math.max(...rows.map(r => r.take_count), 1);
container.innerHTML = rows.map((r, i) => { container.innerHTML = rows.map((r, i) => {
const pct = Math.max((r.take_count / max) * 100, 2); const pct = Math.max((r.take_count / max) * 100, 2);
return `<div class="hbar-row" onclick="openBossDetail('${r.boss.replace(/'/g, "\\'")}', 'rankingPage')"> const boss = escapeHtml(r.boss);
return `<div class="hbar-row" onclick="openBossDetail(${jsString(r.boss)}, 'rankingPage')">
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div> <div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${r.boss}">${r.boss}</div> <div class="hbar-label" title="${escapeAttr(r.boss)}">${boss}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div> <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
<div class="hbar-value">${r.take_count}单</div> <div class="hbar-value">${r.take_count}单</div>
</div>`; </div>`;
@@ -51,9 +53,10 @@ async function loadRankingDetail() {
const max = Math.max(...rows.map(r => r.total_income), 1); const max = Math.max(...rows.map(r => r.total_income), 1);
container.innerHTML = rows.map((r, i) => { container.innerHTML = rows.map((r, i) => {
const pct = Math.max((r.total_income / max) * 100, 2); const pct = Math.max((r.total_income / max) * 100, 2);
return `<div class="hbar-row" onclick="openBossDetail('${r.boss.replace(/'/g, "\\'")}', 'rankingPage')"> const boss = escapeHtml(r.boss);
return `<div class="hbar-row" onclick="openBossDetail(${jsString(r.boss)}, 'rankingPage')">
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div> <div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${r.boss}">${r.boss}</div> <div class="hbar-label" title="${escapeAttr(r.boss)}">${boss}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div> <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
<div class="hbar-value">${r.total_income.toFixed(2)}</div> <div class="hbar-value">${r.total_income.toFixed(2)}</div>
</div>`; </div>`;
+73 -63
View File
@@ -2,10 +2,10 @@ async function loadStats() {
try { try {
// 并行加载所有数据 // 并行加载所有数据
const [monthlyRes, bossRankRes, partnerRankRes, weeklyRes] = await Promise.all([ const [monthlyRes, bossRankRes, partnerRankRes, weeklyRes] = await Promise.all([
fetch(API + '/api/stats/monthly?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }), apiFetch(API + '/api/stats/monthly?year=' + currentYear + '&month=' + currentMonth),
fetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }), apiFetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth),
fetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }), apiFetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth),
fetch(API + '/api/stats/daily-income', { headers: headers() }) apiFetch(API + '/api/stats/daily-income')
]); ]);
if (monthlyRes.ok) { if (monthlyRes.ok) {
@@ -40,9 +40,38 @@ async function loadStats() {
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
function recordLineText(r, fallbackField) {
if (r.quantity) {
if (r.category === '派单') {
const rebate = (r.rebate || r.amount / r.quantity).toFixed(2);
return r.quantity + '单 × ' + rebate + '(单价' + (r.unit_price || 0).toFixed(2) + '';
}
return r.quantity + '单 × ' + (r.unit_price || 0).toFixed(2);
}
return r[fallbackField] || '';
}
function renderRecordItem(r, opts = {}) {
const time = opts.time || formatLocalTime(r.created_at);
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
const suffix = opts.suffix || '';
const line = opts.line !== undefined ? opts.line : recordLineText(r, 'note');
const amountPrefix = opts.amountPrefix || '';
return `<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="${recordArg(r)}">
<div class="order-item-top">
<span class="order-item-boss">${escapeHtml(displayCategory + suffix)}</span>
<span class="order-item-amount">${amountPrefix}${(r.amount || 0).toFixed(2)}</span>
</div>
<div class="order-item-bottom">
<span>${escapeHtml(line)}</span>
<span>${escapeHtml(time + (opts.partnerTime && r.partner ? ' · ' + r.partner : ''))}</span>
</div>
</div>`;
}
async function loadWeeklyChart() { async function loadWeeklyChart() {
try { try {
const res = await fetch(API + '/api/stats/daily-income', { headers: headers() }); const res = await apiFetch(API + '/api/stats/daily-income');
if (!res.ok) return; if (!res.ok) return;
const days = await res.json(); const days = await res.json();
renderWeeklyChart(days); renderWeeklyChart(days);
@@ -61,7 +90,7 @@ function renderWeeklyChart(days) {
const height = Math.max((d.income / max) * barMaxHeight, 4); const height = Math.max((d.income / max) * barMaxHeight, 4);
const label = d.date.slice(5).replace('-', '/'); const label = d.date.slice(5).replace('-', '/');
const amount = d.income > 0 ? '' + d.income.toFixed(0) : '0'; const amount = d.income > 0 ? '' + d.income.toFixed(0) : '0';
return `<div class="weekly-bar-col" onclick="openDailyDetail('${d.date}')"> return `<div class="weekly-bar-col" onclick="openDailyDetail(${jsString(d.date)})">
<span class="weekly-bar-amount">${amount}</span> <span class="weekly-bar-amount">${amount}</span>
<div class="weekly-bar" style="height:${height}px"></div> <div class="weekly-bar" style="height:${height}px"></div>
<span class="weekly-bar-date">${label}</span> <span class="weekly-bar-date">${label}</span>
@@ -81,7 +110,7 @@ async function openDailyDetail(date) {
showPage('dailyDetailPage'); showPage('dailyDetailPage');
} }
try { try {
const res = await fetch(API + '/api/stats/daily-detail?date=' + date, { headers: headers() }); const res = await apiFetch(API + '/api/stats/daily-detail?date=' + date);
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
document.getElementById('dailySummary').innerHTML = ` document.getElementById('dailySummary').innerHTML = `
@@ -95,17 +124,12 @@ async function openDailyDetail(date) {
} }
container.innerHTML = data.records.map(r => { container.innerHTML = data.records.map(r => {
const time = formatLocalTime(r.created_at); const time = formatLocalTime(r.created_at);
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-'); return renderRecordItem(r, {
return `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "&#39;")})'> time,
<div class="order-item-top"> suffix: r.boss ? ' · ' + r.boss : '',
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : ''}</span> line: recordLineText(r, 'source'),
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span> partnerTime: true
</div> });
<div class="order-item-bottom">
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + '' : (r.unit_price || 0).toFixed(2)) : (r.source || '')}</span>
<span>${time}${r.partner ? ' · ' + r.partner : ''}</span>
</div>
</div>`;
}).join(''); }).join('');
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
@@ -144,7 +168,7 @@ async function loadYearlyNetIncome() {
} }
try { try {
const res = await fetch(API + '/api/stats/yearly-net-income', { headers: headers() }); const res = await apiFetch(API + '/api/stats/yearly-net-income');
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
@@ -214,7 +238,7 @@ async function loadMonthlyDetail(type) {
} }
try { try {
const res = await fetch(API + '/api/stats/monthly-records?type=' + type + '&year=' + monthlyDetailYear + '&month=' + monthlyDetailMonth + '&limit=1000', { headers: headers() }); const res = await apiFetch(API + '/api/stats/monthly-records?type=' + type + '&year=' + monthlyDetailYear + '&month=' + monthlyDetailMonth + '&limit=1000');
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const color = type === 'income' ? '#27ae60' : '#e74c3c'; const color = type === 'income' ? '#27ae60' : '#e74c3c';
@@ -238,17 +262,13 @@ async function loadMonthlyDetail(type) {
html += `<div class="order-date-group"><div class="order-date-label">${date}</div>`; html += `<div class="order-date-group"><div class="order-date-label">${date}</div>`;
records.forEach(r => { records.forEach(r => {
const time = formatLocalTime(r.created_at); const time = formatLocalTime(r.created_at);
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-'); html += renderRecordItem(r, {
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "&#39;")})'> time,
<div class="order-item-top"> suffix: r.boss ? ' · ' + r.boss : r.source ? ' · ' + r.source : r.destination ? ' · ' + r.destination : '',
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : r.source ? ' · ' + r.source : r.destination ? ' · ' + r.destination : ''}</span> line: recordLineText(r, 'note'),
<span class="order-item-amount">${type === 'expense' ? '-' : ''}${(r.amount || 0).toFixed(2)}</span> amountPrefix: type === 'expense' ? '-' : '',
</div> partnerTime: true
<div class="order-item-bottom"> });
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + '' : (r.unit_price || 0).toFixed(2)) : (r.note || '')}</span>
<span>${time}${r.partner ? ' · ' + r.partner : ''}</span>
</div>
</div>`;
}); });
html += '</div>'; html += '</div>';
} }
@@ -258,7 +278,7 @@ async function loadMonthlyDetail(type) {
async function loadBossRankingPreview() { async function loadBossRankingPreview() {
try { try {
const res = await fetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); const res = await apiFetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth);
if (!res.ok) return; if (!res.ok) return;
const rows = await res.json(); const rows = await res.json();
renderBossRankingPreview(rows); renderBossRankingPreview(rows);
@@ -275,11 +295,12 @@ function renderHBarRanking(rows, { containerId, getValue, getLabel, onClick, for
const max = Math.max(...rows.map(r => getValue(r)), 1); const max = Math.max(...rows.map(r => getValue(r)), 1);
container.innerHTML = rows.map((r, i) => { container.innerHTML = rows.map((r, i) => {
const pct = Math.max((getValue(r) / max) * 100, 2); const pct = Math.max((getValue(r) / max) * 100, 2);
const label = getLabel(r).replace(/'/g, "\\'"); const rawLabel = getLabel(r);
const label = escapeHtml(rawLabel);
const value = formatValue ? formatValue(getValue(r)) : (typeof getValue(r) === 'number' ? getValue(r).toFixed(2) : getValue(r)); const value = formatValue ? formatValue(getValue(r)) : (typeof getValue(r) === 'number' ? getValue(r).toFixed(2) : getValue(r));
return `<div class="hbar-row" onclick="${onClick}('${label}', 'homePage')"> return `<div class="hbar-row" onclick="${onClick}(${jsString(rawLabel)}, 'homePage')">
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div> <div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${label}">${label}</div> <div class="hbar-label" title="${escapeAttr(rawLabel)}">${label}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div> <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
<div class="hbar-value">${value}</div> <div class="hbar-value">${value}</div>
</div>`; </div>`;
@@ -392,7 +413,7 @@ async function loadBossDetail(append) {
} }
try { try {
const res = await fetch(url, { headers: headers() }); const res = await apiFetch(url);
if (!res.ok) { bossDetailLoading = false; return; } if (!res.ok) { bossDetailLoading = false; return; }
const data = await res.json(); const data = await res.json();
@@ -447,17 +468,11 @@ async function loadBossDetail(append) {
html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`; html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`;
group.records.forEach(r => { group.records.forEach(r => {
const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at); const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at);
const displayCategory = r.category === '红包收入' ? '红包' : r.category; html += renderRecordItem(r, {
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "&#39;")})'> time,
<div class="order-item-top"> suffix: r.partner ? ' · ' + r.partner : '',
<span class="order-item-boss">${displayCategory}${r.partner ? ' · ' + r.partner : ''}</span> line: recordLineText(r, 'note')
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span> });
</div>
<div class="order-item-bottom">
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + '' : (r.unit_price || 0).toFixed(2)) : (r.note || '')}</span>
<span>${time}</span>
</div>
</div>`;
}); });
html += '</div>'; html += '</div>';
} }
@@ -502,7 +517,7 @@ function setupBossDetailScroll() {
async function loadPartnerRankingPreview() { async function loadPartnerRankingPreview() {
try { try {
const res = await fetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }); const res = await apiFetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth);
if (!res.ok) return; if (!res.ok) return;
const rows = await res.json(); const rows = await res.json();
renderPartnerRankingPreview(rows); renderPartnerRankingPreview(rows);
@@ -542,7 +557,7 @@ async function loadPartnerRankingDetail() {
} }
if (params.length) url += '?' + params.join('&'); if (params.length) url += '?' + params.join('&');
try { try {
const res = await fetch(url, { headers: headers() }); const res = await apiFetch(url);
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const rows = data.rows || []; const rows = data.rows || [];
@@ -565,9 +580,10 @@ async function loadPartnerRankingDetail() {
const max = Math.max(...rows.map(r => r.dispatch_count), 1); const max = Math.max(...rows.map(r => r.dispatch_count), 1);
container.innerHTML = rows.map((r, i) => { container.innerHTML = rows.map((r, i) => {
const pct = Math.max((r.dispatch_count / max) * 100, 2); const pct = Math.max((r.dispatch_count / max) * 100, 2);
return `<div class="hbar-row" onclick="openPartnerDetail('${r.partner.replace(/'/g, "\\'")}', 'partnerRankingPage')"> const partner = escapeHtml(r.partner);
return `<div class="hbar-row" onclick="openPartnerDetail(${jsString(r.partner)}, 'partnerRankingPage')">
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div> <div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
<div class="hbar-label" title="${r.partner}">${r.partner}</div> <div class="hbar-label" title="${escapeAttr(r.partner)}">${partner}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div> <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
<div class="hbar-value">${r.dispatch_count}单</div> <div class="hbar-value">${r.dispatch_count}单</div>
</div>`; </div>`;
@@ -667,7 +683,7 @@ async function loadPartnerDetail(append) {
} }
try { try {
const res = await fetch(url, { headers: headers() }); const res = await apiFetch(url);
if (!res.ok) { partnerDetailLoading = false; return; } if (!res.ok) { partnerDetailLoading = false; return; }
const data = await res.json(); const data = await res.json();
@@ -722,17 +738,11 @@ async function loadPartnerDetail(append) {
html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`; html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`;
group.records.forEach(r => { group.records.forEach(r => {
const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at); const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at);
const displayCategory = r.category === '红包收入' ? '红包' : r.category; html += renderRecordItem(r, {
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "&#39;")})'> time,
<div class="order-item-top"> suffix: r.boss ? ' · ' + r.boss : '',
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : ''}</span> line: recordLineText(r, 'source')
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span> });
</div>
<div class="order-item-bottom">
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + '' : (r.unit_price || 0).toFixed(2)) : (r.source || '')}</span>
<span>${time}</span>
</div>
</div>`;
}); });
html += '</div>'; html += '</div>';
} }
+8 -1
View File
@@ -17,7 +17,14 @@ const app = express();
// 请求日志中间件 // 请求日志中间件
app.use(logger.requestLogger); app.use(logger.requestLogger);
app.use(cors()); app.use(cors({
origin(origin, callback) {
if (!origin || config.corsOrigins.includes(origin)) {
return callback(null, true);
}
return callback(null, false);
}
}));
app.use(express.json()); app.use(express.json());
app.use(compression()); app.use(compression());
app.use(express.static(path.join(__dirname, '..', 'public'), { app.use(express.static(path.join(__dirname, '..', 'public'), {
+22 -2
View File
@@ -1,15 +1,31 @@
/** /**
* 应用配置 * 应用配置
*/ */
const isProduction = process.env.NODE_ENV === 'production';
const jwtSecret = process.env.JWT_SECRET || '';
if (isProduction && !jwtSecret) {
throw new Error('JWT_SECRET must be set in production');
}
module.exports = { module.exports = {
// 服务器配置 // 服务器配置
port: process.env.PORT || 3000, port: process.env.PORT || 3000,
host: process.env.HOST || '0.0.0.0', host: process.env.HOST || '0.0.0.0',
// JWT 配置 // JWT 配置
jwtSecret: process.env.JWT_SECRET || 'gamer-order-secret-2024', jwtSecret: jwtSecret || 'dev-only-gamer-order-secret',
jwtExpiresIn: '7d', jwtExpiresIn: '7d',
// 注册默认关闭,生产环境可通过 ENABLE_REGISTRATION=true 显式开启
enableRegistration: process.env.ENABLE_REGISTRATION === 'true',
// CORS 配置:默认同源;如需跨域,用逗号分隔配置 CORS_ORIGINS
corsOrigins: (process.env.CORS_ORIGINS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean),
// 缓存配置 // 缓存配置
cache: { cache: {
ttl: 2 * 60 * 1000, // 缓存有效期 2 分钟 ttl: 2 * 60 * 1000, // 缓存有效期 2 分钟
@@ -22,6 +38,10 @@ module.exports = {
maxLimit: 100 // 最大每页条数 maxLimit: 100 // 最大每页条数
}, },
import: {
maxRecords: 5000
},
// 日志配置 // 日志配置
log: { log: {
level: process.env.LOG_LEVEL || 'info', // debug, info, warn, error level: process.env.LOG_LEVEL || 'info', // debug, info, warn, error
@@ -36,4 +56,4 @@ module.exports = {
// 允许的联系人字段 // 允许的联系人字段
contactFields: ['boss', 'partner', 'source', 'destination'] contactFields: ['boss', 'partner', 'source', 'destination']
}; };
+7 -2
View File
@@ -1,5 +1,4 @@
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs');
const path = require('path'); const path = require('path');
const logger = require('./utils/logger'); const logger = require('./utils/logger');
@@ -108,11 +107,17 @@ function migrateAddRebateColumn() {
* 初始化默认用户 * 初始化默认用户
*/ */
function initDefaultUser() { function initDefaultUser() {
if (process.env.NODE_ENV === 'production') {
logger.info('生产环境跳过默认用户初始化');
return;
}
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 bcrypt = require('bcryptjs');
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 已创建'); logger.info('开发默认用户 lizhilun 已创建');
} }
} }
+80
View File
@@ -7,6 +7,43 @@ const logger = require('../utils/logger');
const router = express.Router(); const router = express.Router();
const loginAttempts = new Map();
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const LOGIN_MAX_FAILURES = 5;
function normalizeLoginKey(req, username) {
return `${req.ip || req.socket.remoteAddress || 'unknown'}:${String(username || '').trim().toLowerCase()}`;
}
function getLoginAttempt(key) {
const now = Date.now();
const attempt = loginAttempts.get(key);
if (!attempt || attempt.resetAt <= now) {
const fresh = { failures: 0, resetAt: now + LOGIN_WINDOW_MS };
loginAttempts.set(key, fresh);
return fresh;
}
return attempt;
}
function clearExpiredLoginAttempts() {
const now = Date.now();
for (const [key, attempt] of loginAttempts) {
if (attempt.resetAt <= now) loginAttempts.delete(key);
}
}
function recordFailedLogin(key) {
const attempt = getLoginAttempt(key);
attempt.failures += 1;
if (loginAttempts.size > 1000) clearExpiredLoginAttempts();
return attempt;
}
function resetLoginAttempts(key) {
loginAttempts.delete(key);
}
// 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];
@@ -27,15 +64,58 @@ router.post('/login', (req, res) => {
return res.status(400).json({ error: '用户名和密码不能为空' }); return res.status(400).json({ error: '用户名和密码不能为空' });
} }
const loginKey = normalizeLoginKey(req, username);
const attempt = getLoginAttempt(loginKey);
if (attempt.failures >= LOGIN_MAX_FAILURES) {
const retryAfter = Math.ceil((attempt.resetAt - Date.now()) / 1000);
res.setHeader('Retry-After', String(Math.max(retryAfter, 1)));
return res.status(429).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)) {
recordFailedLogin(loginKey);
logger.warn(`登录失败: ${username}`); logger.warn(`登录失败: ${username}`);
return res.status(401).json({ error: '用户名或密码错误' }); return res.status(401).json({ error: '用户名或密码错误' });
} }
resetLoginAttempts(loginKey);
const token = jwt.sign({ id: user.id, username: user.username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn }); const token = jwt.sign({ id: user.id, username: user.username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
logger.info(`用户登录: ${username}`); logger.info(`用户登录: ${username}`);
res.json({ token, username: user.username }); res.json({ token, username: user.username });
}); });
// 注册
router.post('/register', (req, res) => {
if (!config.enableRegistration) {
return res.status(403).json({ error: '注册已关闭' });
}
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: '用户名和密码不能为空' });
}
if (username.length < 2 || username.length > 20) {
return res.status(400).json({ error: '用户名长度需在2-20个字符之间' });
}
if (password.length < 6) {
return res.status(400).json({ error: '密码长度不能少于6个字符' });
}
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existing) {
return res.status(409).json({ error: '用户名已存在' });
}
const hash = bcrypt.hashSync(password, 10);
const result = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run(username, hash);
logger.info(`新用户注册: ${username}`);
const token = jwt.sign({ id: result.lastInsertRowid, username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
res.json({ token, username });
});
module.exports = { router, auth }; module.exports = { router, auth };
+90 -25
View File
@@ -1,10 +1,26 @@
const express = require('express'); const express = require('express');
const db = require('../db'); const db = require('../db');
const config = require('../config');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { parseCSVLine, AppError } = require('../utils/helpers'); const {
parseCSVLine,
AppError,
validateNumber,
validateInList,
validateIsoDate,
optionalString
} = require('../utils/helpers');
const router = express.Router(); const router = express.Router();
function emptyToNull(value) {
return value === undefined || value === null || value === '' ? null : value;
}
function nullableDbValue(value) {
return value === undefined || value === null || value === '' ? null : value;
}
// 导出数据 // 导出数据
router.get('/export', (req, res, next) => { router.get('/export', (req, res, next) => {
try { try {
@@ -89,29 +105,63 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res,
if (!lines[i].trim()) continue; if (!lines[i].trim()) continue;
const values = parseCSVLine(lines[i]); const values = parseCSVLine(lines[i]);
const record = {}; const record = {};
csvHeaders.forEach((h, idx) => { record[h] = values[idx] || null; }); csvHeaders.forEach((h, idx) => { record[h] = emptyToNull(values[idx]); });
if (record.amount) record.amount = parseFloat(record.amount); if (record.amount !== null) record.amount = parseFloat(record.amount);
if (record.quantity) record.quantity = parseInt(record.quantity) || null; if (record.quantity !== null) record.quantity = parseInt(record.quantity, 10);
if (record.unit_price) record.unit_price = parseFloat(record.unit_price) || null; if (record.unit_price !== null) record.unit_price = parseFloat(record.unit_price);
if (record.rebate) record.rebate = parseFloat(record.rebate) || null; if (record.rebate !== null) record.rebate = parseFloat(record.rebate);
records.push(record); records.push(record);
} }
} }
for (const r of records) { if (records.length > config.import.maxRecords) {
for (const f of requiredFields) { throw new AppError(`单次最多导入 ${config.import.maxRecords} 条记录`, 400);
if (!r[f] && r[f] !== 0) {
throw new AppError(`记录缺少必填字段: ${f}`, 400);
}
}
if (!['income', 'expense'].includes(r.type)) {
throw new AppError(`无效的类型: ${r.type}`, 400);
}
} }
const existingCheck = db.prepare( const allCategories = [...config.categories.income, ...config.categories.expense];
'SELECT id FROM records WHERE user_id = ? AND created_at = ? AND category = ? AND amount = ?' const normalizedRecords = records.map((r, index) => {
); for (const f of requiredFields) {
if (!r[f] && r[f] !== 0) {
throw new AppError(`${index + 1} 条记录缺少必填字段: ${f}`, 400);
}
}
validateInList(r.type, 'type', ['income', 'expense']);
validateInList(r.category, 'category', allCategories);
const amount = r.category === '派单'
? validateNumber(r.amount, '金额')
: validateNumber(r.amount, '金额', 0);
return {
type: r.type,
category: r.category,
amount,
quantity: r.quantity === undefined || r.quantity === null || r.quantity === '' ? null : validateNumber(r.quantity, '数量', 0),
unit_price: r.unit_price === undefined || r.unit_price === null || r.unit_price === '' ? null : validateNumber(r.unit_price, '单价', 0),
rebate: r.rebate === undefined || r.rebate === null || r.rebate === '' ? null : validateNumber(r.rebate, '返点'),
boss: optionalString(r.boss, '老板', 100),
partner: optionalString(r.partner, '陪玩', 100),
source: optionalString(r.source, '来源', 100),
destination: optionalString(r.destination, '去向', 100),
note: optionalString(r.note, '备注', 500),
created_at: validateIsoDate(r.created_at, '创建时间')
};
});
const existingCheck = db.prepare(`
SELECT id FROM records
WHERE user_id = ?
AND type = ?
AND category = ?
AND amount = ?
AND COALESCE(quantity, '') = COALESCE(?, '')
AND COALESCE(unit_price, '') = COALESCE(?, '')
AND COALESCE(rebate, '') = COALESCE(?, '')
AND COALESCE(boss, '') = COALESCE(?, '')
AND COALESCE(partner, '') = COALESCE(?, '')
AND COALESCE(source, '') = COALESCE(?, '')
AND COALESCE(destination, '') = COALESCE(?, '')
AND COALESCE(note, '') = COALESCE(?, '')
AND created_at = ?
`);
const insertStmt = db.prepare(` const insertStmt = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -120,21 +170,36 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res,
let imported = 0, skipped = 0; let imported = 0, skipped = 0;
const insertMany = db.transaction((recs) => { const insertMany = db.transaction((recs) => {
for (const r of recs) { for (const r of recs) {
if (existingCheck.get(req.user.id, r.created_at, r.category, r.amount)) { const values = [
req.user.id,
r.type,
r.category,
r.amount,
r.quantity,
r.unit_price,
r.rebate,
r.boss,
r.partner,
r.source,
r.destination,
r.note,
r.created_at
];
if (existingCheck.get(...values)) {
skipped++; continue; skipped++; continue;
} }
insertStmt.run(req.user.id, r.type, r.category, r.amount, insertStmt.run(req.user.id, r.type, r.category, r.amount,
r.quantity || null, r.unit_price || null, r.rebate || null, nullableDbValue(r.quantity), nullableDbValue(r.unit_price), nullableDbValue(r.rebate),
r.boss || null, r.partner || null, nullableDbValue(r.boss), nullableDbValue(r.partner),
r.source || null, r.destination || null, nullableDbValue(r.source), nullableDbValue(r.destination),
r.note || null, r.created_at); nullableDbValue(r.note), r.created_at);
imported++; imported++;
} }
}); });
insertMany(records); insertMany(normalizedRecords);
logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped}`); logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped}`);
res.json({ imported, skipped, total: records.length }); res.json({ imported, skipped, total: normalizedRecords.length });
} catch (err) { } catch (err) {
if (err instanceof SyntaxError) { if (err instanceof SyntaxError) {
next(new AppError('文件解析失败: ' + err.message, 400)); next(new AppError('文件解析失败: ' + err.message, 400));
+70 -11
View File
@@ -2,7 +2,17 @@ const express = require('express');
const db = require('../db'); const db = require('../db');
const config = require('../config'); const config = require('../config');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { getMonthRange, AppError, validateRequired, validateNumber, validateInList } = require('../utils/helpers'); const {
getMonthRange,
AppError,
validateRequired,
validateNumber,
validateInList,
validatePage,
validateLimit,
validateIsoDate,
optionalString
} = require('../utils/helpers');
const router = express.Router(); const router = express.Router();
@@ -18,13 +28,34 @@ router.post('/', (req, res, next) => {
const allCategories = [...config.categories.income, ...config.categories.expense]; const allCategories = [...config.categories.income, ...config.categories.expense];
validateInList(category, 'category', allCategories); validateInList(category, 'category', allCategories);
const validatedAmount = validateNumber(amount, '金额', 0); // 派单的返点可为负数,因此金额也允许为负数
const validatedAmount = category === '派单'
? validateNumber(amount, '金额')
: validateNumber(amount, '金额', 0);
const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
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, validatedAmount, 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,
validatedQuantity,
validatedUnitPrice,
validatedRebate,
optionalString(boss, '老板', 100),
optionalString(partner, '陪玩', 100),
optionalString(source, '来源', 100),
optionalString(destination, '去向', 100),
optionalString(note, '备注', 500),
validatedCreatedAt
);
logger.info(`用户 ${req.user.username} 新增记录: ${category} ${validatedAmount}`); logger.info(`用户 ${req.user.username} 新增记录: ${category} ${validatedAmount}`);
res.json({ id: result.lastInsertRowid }); res.json({ id: result.lastInsertRowid });
@@ -35,8 +66,9 @@ router.post('/', (req, res, next) => {
// 通用分页查询函数 // 通用分页查询函数
function queryPagedRecords(userId, year, month, options) { function queryPagedRecords(userId, year, month, options) {
const { page = 1, limit = 20 } = options; const page = validatePage(options.page, 1);
const offset = (Number(page) - 1) * Number(limit); const limit = validateLimit(options.limit, config.pagination.defaultLimit, config.pagination.maxLimit);
const offset = (page - 1) * limit;
const { startDate, endDate } = getMonthRange(year, month); const { startDate, endDate } = getMonthRange(year, month);
let whereClause = `user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?`; let whereClause = `user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?`;
@@ -61,9 +93,9 @@ function queryPagedRecords(userId, year, month, options) {
const records = db.prepare( const records = db.prepare(
`SELECT * FROM records WHERE ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?` `SELECT * FROM records WHERE ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`
).all(...params, Number(limit), offset); ).all(...params, limit, offset);
return { records, total, page: Number(page), limit: Number(limit) }; return { records, total, page, limit };
} }
// 接单列表 // 接单列表
@@ -122,7 +154,9 @@ router.get('/dispatches', (req, res) => {
// 获取记录列表 // 获取记录列表
router.get('/', (req, res) => { router.get('/', (req, res) => {
const { type, page = 1, limit = 20 } = req.query; const { type, page = 1, limit = 20 } = req.query;
const offset = (page - 1) * limit; const validatedPage = validatePage(page, 1);
const validatedLimit = validateLimit(limit, config.pagination.defaultLimit, config.pagination.maxLimit);
const offset = (validatedPage - 1) * validatedLimit;
let sql = 'SELECT * FROM records WHERE user_id = ?'; let sql = 'SELECT * FROM records WHERE user_id = ?';
const params = [req.user.id]; const params = [req.user.id];
if (type) { if (type) {
@@ -130,7 +164,7 @@ router.get('/', (req, res) => {
params.push(type); params.push(type);
} }
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
params.push(Number(limit), Number(offset)); params.push(validatedLimit, offset);
res.json(db.prepare(sql).all(...params)); res.json(db.prepare(sql).all(...params));
}); });
@@ -144,12 +178,37 @@ router.put('/:id', (req, res, next) => {
throw new AppError('无效的记录ID', 400); throw new AppError('无效的记录ID', 400);
} }
const validatedAmount = validateNumber(amount, '金额', 0); // 获取原有记录以判断类别(派单允许负数金额)
const existing = db.prepare('SELECT category FROM records WHERE id = ? AND user_id = ?').get(id, req.user.id);
if (!existing) {
throw new AppError('记录不存在', 404);
}
const validatedAmount = existing.category === '派单'
? validateNumber(amount, '金额')
: validateNumber(amount, '金额', 0);
const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
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, validatedAmount, boss || null, partner || null, source || null, destination || null, note || null, created_at || null, id, req.user.id); `).run(
validatedQuantity,
validatedUnitPrice,
validatedRebate,
validatedAmount,
optionalString(boss, '老板', 100),
optionalString(partner, '陪玩', 100),
optionalString(source, '来源', 100),
optionalString(destination, '去向', 100),
optionalString(note, '备注', 500),
validatedCreatedAt,
id,
req.user.id
);
logger.debug(`用户 ${req.user.username} 更新记录: ${id}`); logger.debug(`用户 ${req.user.username} 更新记录: ${id}`);
res.json({ success: true }); res.json({ success: true });
+29 -2
View File
@@ -58,7 +58,7 @@ function validateRequired(obj, fields) {
function validateNumber(value, field, min, max) { function validateNumber(value, field, min, max) {
const num = Number(value); const num = Number(value);
if (isNaN(num)) { if (!Number.isFinite(num)) {
throw new AppError(`${field} 必须是数字`, 400); throw new AppError(`${field} 必须是数字`, 400);
} }
if (min !== undefined && num < min) { if (min !== undefined && num < min) {
@@ -80,6 +80,11 @@ function validateString(value, field, maxLength) {
return value.trim(); return value.trim();
} }
function optionalString(value, field, maxLength) {
if (value === undefined || value === null || value === '') return null;
return validateString(String(value), field, maxLength);
}
function validateInList(value, field, list) { function validateInList(value, field, list) {
if (!list.includes(value)) { if (!list.includes(value)) {
throw new AppError(`${field} 值无效`, 400); throw new AppError(`${field} 值无效`, 400);
@@ -87,6 +92,24 @@ function validateInList(value, field, list) {
return value; return value;
} }
function validatePage(value, defaultValue = 1) {
return Math.max(1, Number.parseInt(value, 10) || defaultValue);
}
function validateLimit(value, defaultValue, maxLimit) {
const limit = Number.parseInt(value, 10) || defaultValue;
return Math.min(Math.max(1, limit), maxLimit);
}
function validateIsoDate(value, field) {
if (value === undefined || value === null || value === '') return new Date().toISOString();
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new AppError(`${field} 格式无效`, 400);
}
return date.toISOString();
}
module.exports = { module.exports = {
AppError, AppError,
getMonthRange, getMonthRange,
@@ -94,5 +117,9 @@ module.exports = {
validateRequired, validateRequired,
validateNumber, validateNumber,
validateString, validateString,
validateInList optionalString,
validateInList,
validatePage,
validateLimit,
validateIsoDate
}; };