diff --git a/ecosystem.config.js b/ecosystem.config.js index 47fc6ad..e37f01f 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -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 = { apps: [{ name: 'gamer', script: 'server/app.js', env: { + ...runtimeEnv, NODE_ENV: 'production', PORT: 3000 }, diff --git a/package-lock.json b/package-lock.json index 1a76b68..479742d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -427,9 +427,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -633,9 +633,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -988,9 +988,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -1047,12 +1047,13 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -1220,14 +1221,14 @@ "license": "ISC" }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -1239,13 +1240,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" diff --git a/public/js/app.js b/public/js/app.js index 25ac46e..37628f2 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -62,6 +62,39 @@ function headers() { return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; } +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, (ch) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[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) { if (!isoStr) return ''; const d = new Date(isoStr); @@ -81,7 +114,7 @@ function formatLocalDate(input) { async function loadTotalStats() { try { - const res = await fetch(API + '/api/stats/total', { headers: headers() }); + const res = await apiFetch(API + '/api/stats/total'); if (!res.ok) return; const s = await res.json(); 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); container.innerHTML = rows.map((r, i) => { 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 `
${i + 1}
-
${r.name}
+
${name}
${r.count}单
`; @@ -112,7 +146,7 @@ function renderHorizontalChart(containerId, rows, clickHandler, fromPage) { async function openTakeRanking() { showPage('takeRankingPage'); 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; const data = await res.json(); const rows = data.rows || []; @@ -141,7 +175,7 @@ async function openTakeRanking() { async function openDispatchRanking() { showPage('dispatchRankingPage'); 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; const data = await res.json(); const rows = data.rows || []; @@ -178,9 +212,10 @@ async function openDispatchRanking() { const container = document.getElementById('dispatchRankingChart'); container.innerHTML = rows.map((r, i) => { const pct = Math.max(((r.dispatch_count || 0) / max) * 100, 2); - return `
+ const name = escapeHtml(r.name); + return `
${i + 1}
-
${r.name}
+
${name}
${r.dispatch_count}单
`; diff --git a/public/js/autocomplete.js b/public/js/autocomplete.js index 53a767b..7a83366 100644 --- a/public/js/autocomplete.js +++ b/public/js/autocomplete.js @@ -4,7 +4,7 @@ const _acCache = {}; async function acFetchNames(field) { if (_acCache[field] && Date.now() - _acCache[field].ts < 60000) return _acCache[field].data; 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 []; const data = await res.json(); _acCache[field] = { data, ts: Date.now() }; @@ -45,14 +45,16 @@ function acSetup(inputId, field) { if (filtered.length === 0) { list.classList.remove('show'); return; } activeIdx = -1; list.innerHTML = filtered.slice(0, 10).map((n, i) => { - let display = n; + let display = escapeHtml(n); if (q) { const idx = n.toLowerCase().indexOf(q); if (idx >= 0) { - display = n.slice(0, idx) + '' + n.slice(idx, idx + q.length) + '' + n.slice(idx + q.length); + display = escapeHtml(n.slice(0, idx)) + + '' + escapeHtml(n.slice(idx, idx + q.length)) + '' + + escapeHtml(n.slice(idx + q.length)); } } - return `
${display}
`; + return `
${display}
`; }).join(''); list.classList.add('show'); } diff --git a/public/js/order-list.js b/public/js/order-list.js index 36cedfa..50eebf6 100644 --- a/public/js/order-list.js +++ b/public/js/order-list.js @@ -38,9 +38,8 @@ async function loadListData(listId) { showLoading(listId, true); try { - const res = await fetch( - API + cfg.endpoint + '?page=' + state.page + '&limit=' + PAGE_SIZE + '&year=' + currentYear + '&month=' + currentMonth, - { headers: headers() } + const res = await apiFetch( + API + cfg.endpoint + '?page=' + state.page + '&limit=' + PAGE_SIZE + '&year=' + currentYear + '&month=' + currentMonth ); if (!res.ok) { @@ -173,37 +172,37 @@ async function loadMilkTea() { return loadListData('milkTeaList'); } function renderOrderItem(r) { const time = formatLocalTime(r.created_at); - return '
' + - '
' + (r.boss || '-') + '' + (r.amount || 0).toFixed(2) + '
' + - '
' + (r.quantity || 0) + '单 × ' + (r.unit_price || 0).toFixed(2) + '' + time + (r.partner ? ' · ' + r.partner : '') + '
'; + return '
' + + '
' + escapeHtml(r.boss || '-') + '' + (r.amount || 0).toFixed(2) + '
' + + '
' + (r.quantity || 0) + '单 × ' + (r.unit_price || 0).toFixed(2) + '' + escapeHtml(time + (r.partner ? ' · ' + r.partner : '')) + '
'; } function renderOtherIncomeItem(r) { const time = formatLocalTime(r.created_at); const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-'); - return '
' + - '
' + displayCategory + (r.boss ? ' · ' + r.boss : '') + '' + (r.amount || 0).toFixed(2) + '
' + - '
' + (r.source || '') + '' + time + '
'; + return '
' + + '
' + escapeHtml(displayCategory + (r.boss ? ' · ' + r.boss : '')) + '' + (r.amount || 0).toFixed(2) + '
' + + '
' + escapeHtml(r.source || '') + '' + escapeHtml(time) + '
'; } function renderDispatchItem(r) { const time = formatLocalTime(r.created_at); const unitPrice = r.unit_price || (r.quantity ? r.amount / r.quantity : 0); - return '
' + - '
' + (r.boss || '-') + '' + (r.amount || 0).toFixed(2) + '
' + - '
' + (r.quantity || 0) + '单 × ' + (r.rebate || 0).toFixed(2) + '(单价' + unitPrice.toFixed(2) + ')' + time + (r.partner ? ' · ' + r.partner : '') + '
'; + return '
' + + '
' + escapeHtml(r.boss || '-') + '' + (r.amount || 0).toFixed(2) + '
' + + '
' + (r.quantity || 0) + '单 × ' + (r.rebate || 0).toFixed(2) + '(单价' + unitPrice.toFixed(2) + ')' + escapeHtml(time + (r.partner ? ' · ' + r.partner : '')) + '
'; } function renderRedEnvelopeItem(r) { const time = formatLocalTime(r.created_at); - return '
' + + return '
' + '
红包' + (r.amount || 0).toFixed(2) + '
' + - '
' + (r.source || '') + '' + time + '
'; + '
' + escapeHtml(r.source || '') + '' + escapeHtml(time) + '
'; } function renderMilkTeaItem(r) { const time = formatLocalTime(r.created_at); - return '
' + + return '
' + '
奶茶' + (r.amount || 0).toFixed(2) + '
' + - '
' + (r.source || '') + '' + time + '
'; -} \ No newline at end of file + '
' + escapeHtml(r.source || '') + '' + escapeHtml(time) + '
'; +} diff --git a/public/js/ranking.js b/public/js/ranking.js index e15a6a2..225c3b7 100644 --- a/public/js/ranking.js +++ b/public/js/ranking.js @@ -17,7 +17,7 @@ async function loadRankingDetail() { url += '&endDate=' + formatLocalDate(d); } try { - const res = await fetch(url, { headers: headers() }); + const res = await apiFetch(url); if (!res.ok) return; const rows = await res.json(); const container = document.getElementById('rankingDetailList'); @@ -29,9 +29,10 @@ async function loadRankingDetail() { const max = Math.max(...rows.map(r => r.dispatch_count), 1); container.innerHTML = rows.map((r, i) => { const pct = Math.max((r.dispatch_count / max) * 100, 2); - return `
+ const boss = escapeHtml(r.boss); + return `
${i + 1}
-
${r.boss}
+
${boss}
${r.dispatch_count}单
`; @@ -40,9 +41,10 @@ async function loadRankingDetail() { const max = Math.max(...rows.map(r => r.take_count), 1); container.innerHTML = rows.map((r, i) => { const pct = Math.max((r.take_count / max) * 100, 2); - return `
+ const boss = escapeHtml(r.boss); + return `
${i + 1}
-
${r.boss}
+
${boss}
${r.take_count}单
`; @@ -51,9 +53,10 @@ async function loadRankingDetail() { const max = Math.max(...rows.map(r => r.total_income), 1); container.innerHTML = rows.map((r, i) => { const pct = Math.max((r.total_income / max) * 100, 2); - return `
+ const boss = escapeHtml(r.boss); + return `
${i + 1}
-
${r.boss}
+
${boss}
${r.total_income.toFixed(2)}
`; diff --git a/public/js/stats.js b/public/js/stats.js index b3f2043..e1c7f0c 100644 --- a/public/js/stats.js +++ b/public/js/stats.js @@ -2,10 +2,10 @@ async function loadStats() { try { // 并行加载所有数据 const [monthlyRes, bossRankRes, partnerRankRes, weeklyRes] = await Promise.all([ - fetch(API + '/api/stats/monthly?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }), - fetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }), - fetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }), - fetch(API + '/api/stats/daily-income', { headers: headers() }) + apiFetch(API + '/api/stats/monthly?year=' + currentYear + '&month=' + currentMonth), + apiFetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth), + apiFetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth), + apiFetch(API + '/api/stats/daily-income') ]); if (monthlyRes.ok) { @@ -40,9 +40,38 @@ async function loadStats() { } 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 `
+
+ ${escapeHtml(displayCategory + suffix)} + ${amountPrefix}${(r.amount || 0).toFixed(2)} +
+
+ ${escapeHtml(line)} + ${escapeHtml(time + (opts.partnerTime && r.partner ? ' · ' + r.partner : ''))} +
+
`; +} + async function loadWeeklyChart() { 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; const days = await res.json(); renderWeeklyChart(days); @@ -61,7 +90,7 @@ function renderWeeklyChart(days) { const height = Math.max((d.income / max) * barMaxHeight, 4); const label = d.date.slice(5).replace('-', '/'); const amount = d.income > 0 ? '' + d.income.toFixed(0) : '0'; - return `
+ return `
${amount}
${label} @@ -81,7 +110,7 @@ async function openDailyDetail(date) { showPage('dailyDetailPage'); } 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; const data = await res.json(); document.getElementById('dailySummary').innerHTML = ` @@ -95,17 +124,12 @@ async function openDailyDetail(date) { } container.innerHTML = data.records.map(r => { const time = formatLocalTime(r.created_at); - const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-'); - return `
-
- ${displayCategory}${r.boss ? ' · ' + r.boss : ''} - ${(r.amount || 0).toFixed(2)} -
-
- ${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 || '')} - ${time}${r.partner ? ' · ' + r.partner : ''} -
-
`; + return renderRecordItem(r, { + time, + suffix: r.boss ? ' · ' + r.boss : '', + line: recordLineText(r, 'source'), + partnerTime: true + }); }).join(''); } catch (e) { console.error(e); } } @@ -144,7 +168,7 @@ async function loadYearlyNetIncome() { } 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; const data = await res.json(); @@ -214,7 +238,7 @@ async function loadMonthlyDetail(type) { } 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; const data = await res.json(); const color = type === 'income' ? '#27ae60' : '#e74c3c'; @@ -238,17 +262,13 @@ async function loadMonthlyDetail(type) { html += `
${date}
`; records.forEach(r => { const time = formatLocalTime(r.created_at); - const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-'); - html += `
-
- ${displayCategory}${r.boss ? ' · ' + r.boss : r.source ? ' · ' + r.source : r.destination ? ' · ' + r.destination : ''} - ${type === 'expense' ? '-' : ''}${(r.amount || 0).toFixed(2)} -
-
- ${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 || '')} - ${time}${r.partner ? ' · ' + r.partner : ''} -
-
`; + html += renderRecordItem(r, { + time, + suffix: r.boss ? ' · ' + r.boss : r.source ? ' · ' + r.source : r.destination ? ' · ' + r.destination : '', + line: recordLineText(r, 'note'), + amountPrefix: type === 'expense' ? '-' : '', + partnerTime: true + }); }); html += '
'; } @@ -258,7 +278,7 @@ async function loadMonthlyDetail(type) { async function loadBossRankingPreview() { 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; const rows = await res.json(); renderBossRankingPreview(rows); @@ -275,11 +295,12 @@ function renderHBarRanking(rows, { containerId, getValue, getLabel, onClick, for const max = Math.max(...rows.map(r => getValue(r)), 1); container.innerHTML = rows.map((r, i) => { 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)); - return `
+ return `
${i + 1}
-
${label}
+
${label}
${value}
`; @@ -392,7 +413,7 @@ async function loadBossDetail(append) { } try { - const res = await fetch(url, { headers: headers() }); + const res = await apiFetch(url); if (!res.ok) { bossDetailLoading = false; return; } const data = await res.json(); @@ -447,17 +468,11 @@ async function loadBossDetail(append) { html += `
${month} 收入 ${group.total.toFixed(2)}
`; group.records.forEach(r => { const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at); - const displayCategory = r.category === '红包收入' ? '红包' : r.category; - html += `
-
- ${displayCategory}${r.partner ? ' · ' + r.partner : ''} - ${(r.amount || 0).toFixed(2)} -
-
- ${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 || '')} - ${time} -
-
`; + html += renderRecordItem(r, { + time, + suffix: r.partner ? ' · ' + r.partner : '', + line: recordLineText(r, 'note') + }); }); html += '
'; } @@ -502,7 +517,7 @@ function setupBossDetailScroll() { async function loadPartnerRankingPreview() { 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; const rows = await res.json(); renderPartnerRankingPreview(rows); @@ -542,7 +557,7 @@ async function loadPartnerRankingDetail() { } if (params.length) url += '?' + params.join('&'); try { - const res = await fetch(url, { headers: headers() }); + const res = await apiFetch(url); if (!res.ok) return; const data = await res.json(); const rows = data.rows || []; @@ -565,9 +580,10 @@ async function loadPartnerRankingDetail() { const max = Math.max(...rows.map(r => r.dispatch_count), 1); container.innerHTML = rows.map((r, i) => { const pct = Math.max((r.dispatch_count / max) * 100, 2); - return `
+ const partner = escapeHtml(r.partner); + return `
${i + 1}
-
${r.partner}
+
${partner}
${r.dispatch_count}单
`; @@ -667,7 +683,7 @@ async function loadPartnerDetail(append) { } try { - const res = await fetch(url, { headers: headers() }); + const res = await apiFetch(url); if (!res.ok) { partnerDetailLoading = false; return; } const data = await res.json(); @@ -722,17 +738,11 @@ async function loadPartnerDetail(append) { html += `
${month} 收入 ${group.total.toFixed(2)}
`; group.records.forEach(r => { const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at); - const displayCategory = r.category === '红包收入' ? '红包' : r.category; - html += `
-
- ${displayCategory}${r.boss ? ' · ' + r.boss : ''} - ${(r.amount || 0).toFixed(2)} -
-
- ${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 || '')} - ${time} -
-
`; + html += renderRecordItem(r, { + time, + suffix: r.boss ? ' · ' + r.boss : '', + line: recordLineText(r, 'source') + }); }); html += '
'; } diff --git a/server/app.js b/server/app.js index 1614d00..d3ba575 100644 --- a/server/app.js +++ b/server/app.js @@ -17,7 +17,14 @@ const app = express(); // 请求日志中间件 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(compression()); app.use(express.static(path.join(__dirname, '..', 'public'), { diff --git a/server/config.js b/server/config.js index e2fa30b..01cb9aa 100644 --- a/server/config.js +++ b/server/config.js @@ -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 = { // 服务器配置 port: process.env.PORT || 3000, host: process.env.HOST || '0.0.0.0', // JWT 配置 - jwtSecret: process.env.JWT_SECRET || 'gamer-order-secret-2024', + jwtSecret: jwtSecret || 'dev-only-gamer-order-secret', 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: { ttl: 2 * 60 * 1000, // 缓存有效期 2 分钟 @@ -22,6 +38,10 @@ module.exports = { maxLimit: 100 // 最大每页条数 }, + import: { + maxRecords: 5000 + }, + // 日志配置 log: { level: process.env.LOG_LEVEL || 'info', // debug, info, warn, error @@ -36,4 +56,4 @@ module.exports = { // 允许的联系人字段 contactFields: ['boss', 'partner', 'source', 'destination'] -}; \ No newline at end of file +}; diff --git a/server/db.js b/server/db.js index 39be8dd..6d20e66 100644 --- a/server/db.js +++ b/server/db.js @@ -1,5 +1,4 @@ const Database = require('better-sqlite3'); -const bcrypt = require('bcryptjs'); const path = require('path'); const logger = require('./utils/logger'); @@ -108,11 +107,17 @@ function migrateAddRebateColumn() { * 初始化默认用户 */ function initDefaultUser() { + if (process.env.NODE_ENV === 'production') { + logger.info('生产环境跳过默认用户初始化'); + return; + } + const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get('lizhilun'); if (!existingUser) { + const bcrypt = require('bcryptjs'); const hash = bcrypt.hashSync('lizhilun', 10); db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash); - logger.info('默认用户 lizhilun 已创建'); + logger.info('开发默认用户 lizhilun 已创建'); } } diff --git a/server/routes/auth.js b/server/routes/auth.js index 1bbc221..3832a66 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -7,6 +7,43 @@ const logger = require('../utils/logger'); 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 中间件 function auth(req, res, next) { const token = req.headers.authorization?.split(' ')[1]; @@ -27,15 +64,58 @@ router.post('/login', (req, res) => { 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); if (!user || !bcrypt.compareSync(password, user.password)) { + recordFailedLogin(loginKey); logger.warn(`登录失败: ${username}`); return res.status(401).json({ error: '用户名或密码错误' }); } + resetLoginAttempts(loginKey); const token = jwt.sign({ id: user.id, username: user.username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn }); logger.info(`用户登录: ${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 }; diff --git a/server/routes/io.js b/server/routes/io.js index b3e1243..3de605e 100644 --- a/server/routes/io.js +++ b/server/routes/io.js @@ -1,10 +1,26 @@ const express = require('express'); const db = require('../db'); +const config = require('../config'); 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(); +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) => { try { @@ -89,29 +105,63 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res, if (!lines[i].trim()) continue; const values = parseCSVLine(lines[i]); const record = {}; - csvHeaders.forEach((h, idx) => { record[h] = values[idx] || null; }); - if (record.amount) record.amount = parseFloat(record.amount); - if (record.quantity) record.quantity = parseInt(record.quantity) || null; - if (record.unit_price) record.unit_price = parseFloat(record.unit_price) || null; - if (record.rebate) record.rebate = parseFloat(record.rebate) || null; + csvHeaders.forEach((h, idx) => { record[h] = emptyToNull(values[idx]); }); + if (record.amount !== null) record.amount = parseFloat(record.amount); + if (record.quantity !== null) record.quantity = parseInt(record.quantity, 10); + if (record.unit_price !== null) record.unit_price = parseFloat(record.unit_price); + if (record.rebate !== null) record.rebate = parseFloat(record.rebate); records.push(record); } } - for (const r of records) { - for (const f of requiredFields) { - if (!r[f] && r[f] !== 0) { - throw new AppError(`记录缺少必填字段: ${f}`, 400); - } - } - if (!['income', 'expense'].includes(r.type)) { - throw new AppError(`无效的类型: ${r.type}`, 400); - } + if (records.length > config.import.maxRecords) { + throw new AppError(`单次最多导入 ${config.import.maxRecords} 条记录`, 400); } - const existingCheck = db.prepare( - 'SELECT id FROM records WHERE user_id = ? AND created_at = ? AND category = ? AND amount = ?' - ); + const allCategories = [...config.categories.income, ...config.categories.expense]; + 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(` INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -120,21 +170,36 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res, let imported = 0, skipped = 0; const insertMany = db.transaction((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; } insertStmt.run(req.user.id, r.type, r.category, r.amount, - r.quantity || null, r.unit_price || null, r.rebate || null, - r.boss || null, r.partner || null, - r.source || null, r.destination || null, - r.note || null, r.created_at); + nullableDbValue(r.quantity), nullableDbValue(r.unit_price), nullableDbValue(r.rebate), + nullableDbValue(r.boss), nullableDbValue(r.partner), + nullableDbValue(r.source), nullableDbValue(r.destination), + nullableDbValue(r.note), r.created_at); imported++; } }); - insertMany(records); + insertMany(normalizedRecords); logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped} 条`); - res.json({ imported, skipped, total: records.length }); + res.json({ imported, skipped, total: normalizedRecords.length }); } catch (err) { if (err instanceof SyntaxError) { next(new AppError('文件解析失败: ' + err.message, 400)); diff --git a/server/routes/records.js b/server/routes/records.js index 89f0d4f..c70457c 100644 --- a/server/routes/records.js +++ b/server/routes/records.js @@ -2,7 +2,17 @@ const express = require('express'); const db = require('../db'); const config = require('../config'); 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(); @@ -18,13 +28,34 @@ router.post('/', (req, res, next) => { const allCategories = [...config.categories.income, ...config.categories.expense]; 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(` 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()); + 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}`); res.json({ id: result.lastInsertRowid }); @@ -35,8 +66,9 @@ router.post('/', (req, res, next) => { // 通用分页查询函数 function queryPagedRecords(userId, year, month, options) { - const { page = 1, limit = 20 } = options; - const offset = (Number(page) - 1) * Number(limit); + const page = validatePage(options.page, 1); + const limit = validateLimit(options.limit, config.pagination.defaultLimit, config.pagination.maxLimit); + const offset = (page - 1) * limit; const { startDate, endDate } = getMonthRange(year, month); 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( `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) => { 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 = ?'; const params = [req.user.id]; if (type) { @@ -130,7 +164,7 @@ router.get('/', (req, res) => { params.push(type); } 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)); }); @@ -144,12 +178,37 @@ router.put('/:id', (req, res, next) => { 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(` 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); + `).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}`); res.json({ success: true }); diff --git a/server/utils/helpers.js b/server/utils/helpers.js index 4f22d33..54eeaec 100644 --- a/server/utils/helpers.js +++ b/server/utils/helpers.js @@ -58,7 +58,7 @@ function validateRequired(obj, fields) { function validateNumber(value, field, min, max) { const num = Number(value); - if (isNaN(num)) { + if (!Number.isFinite(num)) { throw new AppError(`${field} 必须是数字`, 400); } if (min !== undefined && num < min) { @@ -80,6 +80,11 @@ function validateString(value, field, maxLength) { 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) { if (!list.includes(value)) { throw new AppError(`${field} 值无效`, 400); @@ -87,6 +92,24 @@ function validateInList(value, field, list) { 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 = { AppError, getMonthRange, @@ -94,5 +117,9 @@ module.exports = { validateRequired, validateNumber, validateString, - validateInList + optionalString, + validateInList, + validatePage, + validateLimit, + validateIsoDate };