perf: 系统性能优化 - 数据库索引、缓存、GZIP压缩和查询合并

- 添加3个复合索引提升查询性能 (user_date, user_category, destination)
- 添加GZIP压缩和静态资源缓存 (maxAge: 1h)
- 添加API响应no-cache头
- 添加stats端点2分钟内存缓存 (per-user key, LRU淘汰)
- 合并多次数据库查询为单次查询
- 添加滚动防抖改善移动端体验
- 修复res.once内存泄漏问题

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lizhilun
2026-03-25 10:25:37 +08:00
co-authored by Claude Opus 4.6
parent 3a4048a29e
commit df3a9daa65
6 changed files with 250 additions and 122 deletions
+11 -1
View File
@@ -1,6 +1,7 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const compression = require('compression');
const db = require('./db');
const { router: authRouter, auth } = require('./routes/auth');
@@ -12,9 +13,18 @@ const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, '..', 'public')));
app.use(compression());
app.use(express.static(path.join(__dirname, '..', 'public'), {
maxAge: '1h',
etag: true
}));
// 联系人自动补全
app.use('/api', (req, res, next) => {
res.setHeader('Cache-Control', 'no-cache');
next();
});
app.get('/api/contacts', auth, (req, res) => {
const field = req.query.field;
const allowed = ['boss', 'partner', 'source', 'destination'];
+3
View File
@@ -40,6 +40,9 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_records_boss ON records(boss);
CREATE INDEX IF NOT EXISTS idx_records_partner ON records(partner);
CREATE INDEX IF NOT EXISTS idx_records_source ON records(source);
CREATE INDEX IF NOT EXISTS idx_records_user_date ON records(user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_records_user_category ON records(user_id, category, created_at);
CREATE INDEX IF NOT EXISTS idx_records_destination ON records(destination);
`);
// 迁移:去掉 category 的 CHECK 约束
+157 -118
View File
@@ -4,6 +4,47 @@ const { getMonthRange } = require('../utils/helpers');
const router = express.Router();
// 内存缓存(限制大小,基于LRU)
const statsCache = new Map();
const CACHE_TTL = 2 * 60 * 1000;
const MAX_CACHE_SIZE = 100;
function getCached(key, fetchFn) {
const cached = statsCache.get(key);
if (cached && Date.now() - cached.ts < CACHE_TTL) {
// LRU: move to end
statsCache.delete(key);
statsCache.set(key, cached);
return Promise.resolve(cached.data);
}
if (statsCache.size >= MAX_CACHE_SIZE) {
// Delete oldest entry
const firstKey = statsCache.keys().next().value;
statsCache.delete(firstKey);
}
return fetchFn().then(data => {
statsCache.set(key, { data, ts: Date.now() });
return data;
}).catch(err => {
console.error('Cache fetch error:', err.message);
return fetchFn(); // fallback to direct fetch
});
}
// POST/DELETE/PUT/PATCH 后清除当前用户的缓存
router.use((req, res, next) => {
res.once('finish', () => {
if (['POST', 'DELETE', 'PUT', 'PATCH'].includes(req.method) && req.user?.id) {
for (const key of statsCache.keys()) {
if (key.includes(`:${req.user.id}:`)) {
statsCache.delete(key);
}
}
}
});
next();
});
// 格式化本地日期为 YYYY-MM-DD
function formatLocalDate(date) {
const y = date.getFullYear();
@@ -15,22 +56,25 @@ function formatLocalDate(date) {
// 本月统计
router.get('/monthly', (req, res) => {
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
const stats = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_order_count,
COALESCE(SUM(CASE WHEN category = '接单' THEN amount ELSE 0 END), 0) as take_order_income,
COALESCE(SUM(CASE WHEN category = '单' THEN quantity ELSE 0 END), 0) as dispatch_order_count,
COALESCE(SUM(CASE WHEN category = '单' THEN amount ELSE 0 END), 0) as dispatch_order_income,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN 1 ELSE 0 END), 0) as redEnvelope_count,
COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as milkTea_income,
COALESCE(SUM(CASE WHEN category = '奶茶' THEN 1 ELSE 0 END), 0) as milkTea_count,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
FROM records
WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
`).get(req.user.id, startDate, endDate);
res.json(stats);
const cacheKey = `monthly:${req.user.id}:${startDate}:${endDate}`;
getCached(cacheKey, () => {
const stats = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN category = '单' THEN quantity ELSE 0 END), 0) as take_order_count,
COALESCE(SUM(CASE WHEN category = '单' THEN amount ELSE 0 END), 0) as take_order_income,
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_order_count,
COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as dispatch_order_income,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN 1 ELSE 0 END), 0) as redEnvelope_count,
COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as milkTea_income,
COALESCE(SUM(CASE WHEN category = '奶茶' THEN 1 ELSE 0 END), 0) as milkTea_count,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
FROM records
WHERE user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
`).get(req.user.id, startDate, endDate);
return stats;
}).then(stats => res.json(stats));
});
// 月收入/支出记录列表
@@ -40,7 +84,6 @@ router.get('/monthly-records', (req, res) => {
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
const offset = (Number(page) - 1) * Number(limit);
// 合并 summary 和 total 为一次查询
const stats = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total, COUNT(*) as count
FROM records WHERE user_id = ? AND type = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
@@ -57,19 +100,7 @@ router.get('/monthly-records', (req, res) => {
// 年度净收入按年统计
router.get('/yearly-net-income', (req, res) => {
// 获取所有年份数据
const yearlyStats = db.prepare(`
SELECT
strftime('%Y', created_at) as year,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
FROM records
WHERE user_id = ?
GROUP BY year ORDER BY year DESC
`).all(req.user.id);
// 获取所有年份的每月数据
const monthlyByYear = db.prepare(`
const combined = db.prepare(`
SELECT
strftime('%Y', created_at) as year,
strftime('%m', created_at) as month,
@@ -77,41 +108,59 @@ router.get('/yearly-net-income', (req, res) => {
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
FROM records
WHERE user_id = ?
GROUP BY year, month ORDER BY year DESC, month
`).all(req.user.id);
// 按年份分组每月数据
const monthlyData = {};
monthlyByYear.forEach(m => {
if (!monthlyData[m.year]) monthlyData[m.year] = [];
monthlyData[m.year].push({
month: m.month,
total_income: m.total_income,
total_expense: m.total_expense
});
});
const grandTotal = db.prepare(`
GROUP BY year, month
UNION ALL
SELECT
strftime('%Y', created_at) as year,
'00' as month,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
FROM records WHERE user_id = ?
`).get(req.user.id);
FROM records
WHERE user_id = ?
GROUP BY year
`).all(req.user.id, req.user.id);
const yearlyStats = [];
const monthlyData = {};
let grandTotal = { total_income: 0, total_expense: 0 };
combined.forEach(row => {
grandTotal.total_income += row.total_income;
grandTotal.total_expense += row.total_expense;
if (row.month === '00') {
yearlyStats.push({
year: row.year,
total_income: row.total_income,
total_expense: row.total_expense
});
} else {
if (!monthlyData[row.year]) monthlyData[row.year] = [];
monthlyData[row.year].push({
month: row.month,
total_income: row.total_income,
total_expense: row.total_expense
});
}
});
yearlyStats.sort((a, b) => b.year - a.year);
res.json({ yearlyStats, monthlyData, grandTotal });
});
// 陪玩派单榜预览
router.get('/partner-ranking/preview', (req, res) => {
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
const rows = db.prepare(`
SELECT partner, COALESCE(SUM(quantity), 0) as dispatch_count, COALESCE(SUM(amount), 0) as dispatch_income
FROM records
WHERE user_id = ? AND category = '派单' AND partner IS NOT NULL AND partner != ''
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
GROUP BY partner ORDER BY dispatch_count DESC LIMIT 10
`).all(req.user.id, startDate, endDate);
res.json(rows);
const cacheKey = `partner-ranking-preview:${req.user.id}:${startDate}:${endDate}`;
getCached(cacheKey, () => {
const rows = db.prepare(`
SELECT partner, COALESCE(SUM(quantity), 0) as dispatch_count, COALESCE(SUM(amount), 0) as dispatch_income
FROM records
WHERE user_id = ? AND category = '派单' AND partner IS NOT NULL AND partner != ''
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
GROUP BY partner ORDER BY dispatch_count DESC LIMIT 10
`).all(req.user.id, startDate, endDate);
return rows;
}).then(rows => res.json(rows));
});
// 陪玩派单榜详情
@@ -141,24 +190,23 @@ router.get('/partner-ranking', (req, res) => {
rows.forEach(row => {
row.partner_income = (row.dispatch_income || 0) - (row.rebate_income || 0);
});
const summary = db.prepare(`
const summaryStats = db.prepare(`
SELECT
COALESCE(SUM(quantity), 0) as total_dispatch_count,
COALESCE(SUM(quantity * COALESCE(unit_price, 0)), 0) as total_dispatch_income,
COALESCE(SUM(amount), 0) as total_rebate_income,
COALESCE(SUM(quantity * COALESCE(unit_price, 0)), 0) - COALESCE(SUM(amount), 0) as total_partner_income
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as total_dispatch_count,
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity * COALESCE(unit_price, 0) ELSE 0 END), 0) as total_dispatch_income,
COALESCE(SUM(CASE WHEN category = '派单' THEN amount ELSE 0 END), 0) as total_rebate_income,
COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as total_milkTea_income
FROM records
WHERE user_id = ? AND category = '派单' AND partner IS NOT NULL AND partner != ''
WHERE user_id = ? AND type = 'income' AND category IN ('派单', '奶茶')
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
`).get(req.user.id, start, end);
// 获取奶茶收入
const milkTeaStats = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total_milkTea_income
FROM records
WHERE user_id = ? AND type = 'income' AND category = '奶茶'
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
`).get(req.user.id, start, end);
summary.total_milkTea_income = milkTeaStats.total_milkTea_income;
const summary = {
total_dispatch_count: summaryStats.total_dispatch_count,
total_dispatch_income: summaryStats.total_dispatch_income,
total_rebate_income: summaryStats.total_rebate_income,
total_partner_income: summaryStats.total_dispatch_income - summaryStats.total_rebate_income,
total_milkTea_income: summaryStats.total_milkTea_income
};
res.json({ rows, summary });
});
@@ -169,22 +217,18 @@ router.get('/take-ranking', (req, res) => {
FROM records WHERE user_id = ? AND category = '接单' AND boss IS NOT NULL AND boss != ''
GROUP BY boss ORDER BY count DESC
`).all(req.user.id);
// 获取接单总收入和红包收入
const stats = db.prepare(`
SELECT
COALESCE(SUM(quantity), 0) as total_take_count,
COALESCE(SUM(amount), 0) as total_take_income
FROM records WHERE user_id = ? AND category = '接单'
`).get(req.user.id);
const redEnvelopeStats = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total_redEnvelope_income
FROM records WHERE user_id = ? AND type = 'income' AND category = '红包收入'
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as total_take_count,
COALESCE(SUM(CASE WHEN category = '接单' THEN amount ELSE 0 END), 0) as total_take_income,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as total_redEnvelope_income
FROM records WHERE user_id = ? AND type = 'income' AND category IN ('接单', '红包收入')
`).get(req.user.id);
const result = {
rows: rows,
total_take_count: stats.total_take_count,
total_take_income: stats.total_take_income,
total_redEnvelope_income: redEnvelopeStats.total_redEnvelope_income
total_redEnvelope_income: stats.total_redEnvelope_income
};
res.json(result);
});
@@ -204,19 +248,16 @@ router.get('/dispatch-ranking', (req, res) => {
rows.forEach(row => {
row.partner_income = (row.dispatch_income || 0) - (row.rebate_income || 0);
});
// 获取派单总数和奶茶收入
const dispatchTotal = db.prepare(`
SELECT COALESCE(SUM(quantity), 0) as total_dispatch_count
FROM records WHERE user_id = ? AND category = '派单'
`).get(req.user.id);
const milkTeaStats = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total_milkTea_income
FROM records WHERE user_id = ? AND type = 'income' AND category = '奶茶'
const stats = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as total_dispatch_count,
COALESCE(SUM(CASE WHEN category = '奶茶' THEN amount ELSE 0 END), 0) as total_milkTea_income
FROM records WHERE user_id = ? AND type = 'income' AND category IN ('派单', '奶茶')
`).get(req.user.id);
const result = {
rows: rows,
total_dispatch_count: dispatchTotal.total_dispatch_count,
total_milkTea_income: milkTeaStats.total_milkTea_income
total_dispatch_count: stats.total_dispatch_count,
total_milkTea_income: stats.total_milkTea_income
};
res.json(result);
});
@@ -252,7 +293,6 @@ router.get('/boss-records', (req, res) => {
dateParams = [startDate, endDate];
}
// 合并 summary 查询为一次
const summary = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as my_income,
@@ -298,8 +338,6 @@ router.get('/partner-records', (req, res) => {
dateParams = [startDate, endDate];
}
// 合并 summary 查询为一次
// 注意: source 在 CASE 和 WHERE 中各出现一次,都需要 partner 值
const summary = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count,
@@ -347,15 +385,17 @@ router.get('/daily-income', (req, res) => {
endDay.setDate(endDay.getDate() + 1);
const endDate = formatLocalDate(endDay);
const rows = db.prepare(`
SELECT DATE(created_at, 'localtime') as date, COALESCE(SUM(amount), 0) as income
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
GROUP BY DATE(created_at, 'localtime')
`).all(req.user.id, startDate, endDate);
const map = {};
rows.forEach(r => { map[r.date] = r.income; });
res.json(days.map(d => ({ date: d, income: map[d] || 0 })));
const cacheKey = `daily-income:${req.user.id}:${startDate}`;
getCached(cacheKey, () => {
const rows = db.prepare(`
SELECT DATE(created_at, 'localtime') as date, COALESCE(SUM(amount), 0) as income
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
GROUP BY DATE(created_at, 'localtime')
`).all(req.user.id, startDate, endDate);
const map = {};
rows.forEach(r => { map[r.date] = r.income; });
return days.map(d => ({ date: d, income: map[d] || 0 }));
}).then(data => res.json(data));
});
// 某天收入记录详情
@@ -367,37 +407,36 @@ router.get('/daily-detail', (req, res) => {
nextDay.setDate(nextDay.getDate() + 1);
const endDate = formatLocalDate(nextDay);
const summary = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total_income
const stats = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total_income, COUNT(*) as count
FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
`).get(req.user.id, date, endDate);
const total = db.prepare(`
SELECT COUNT(*) as count FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
`).get(req.user.id, date, endDate).count;
const records = db.prepare(`
SELECT * FROM records WHERE user_id = ? AND type = 'income' AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, date, endDate, Number(limit), offset);
res.json({ total_income: summary.total_income, records, page: Number(page), limit: Number(limit), totalCount: total });
res.json({ total_income: stats.total_income, records, page: Number(page), limit: Number(limit), totalCount: stats.count });
});
// 老板收入排行 - 首页预览
router.get('/boss-ranking/preview', (req, res) => {
const { startDate, endDate } = getMonthRange(req.query.year, req.query.month);
const rows = db.prepare(`
SELECT COALESCE(boss, source) as boss, SUM(amount) as total_income,
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count,
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income
FROM records
WHERE user_id = ? AND type = 'income' AND (boss IS NOT NULL AND boss != '' OR source IS NOT NULL AND source != '')
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
GROUP BY COALESCE(boss, source) ORDER BY total_income DESC LIMIT 10
`).all(req.user.id, startDate, endDate);
res.json(rows);
const cacheKey = `boss-ranking-preview:${req.user.id}:${startDate}:${endDate}`;
getCached(cacheKey, () => {
const rows = db.prepare(`
SELECT COALESCE(boss, source) as boss, SUM(amount) as total_income,
COALESCE(SUM(CASE WHEN category = '接单' THEN quantity ELSE 0 END), 0) as take_count,
COALESCE(SUM(CASE WHEN category = '派单' THEN quantity ELSE 0 END), 0) as dispatch_count,
COALESCE(SUM(CASE WHEN category = '红包收入' THEN amount ELSE 0 END), 0) as redEnvelope_income
FROM records
WHERE user_id = ? AND type = 'income' AND (boss IS NOT NULL AND boss != '' OR source IS NOT NULL AND source != '')
AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?
GROUP BY COALESCE(boss, source) ORDER BY total_income DESC LIMIT 10
`).all(req.user.id, startDate, endDate);
return rows;
}).then(rows => res.json(rows));
});
// 老板收入排行 - 详情页(支持筛选)