perf: 清理无界缓存、合并收益走势查询、补齐组合索引并优化前端定时器/图片加载
- 移除只写不读的 verifyCodeCache(无界内存增长) - 大神收益走势由 20 次逐日/逐周/逐月查询改为 3 次 GROUP BY 聚合 - 派单池地理编码改为并行,避免串行等待外部接口 - 补齐 orders/activities/participants/user_notifications/finance/withdrawals/posts 组合索引;修复 withdrawals 使用不存在 created_at 的失效索引 - 数据库连接池支持 MYSQL_CONNECTION_LIMIT/MYSQL_QUEUE_LIMIT - 首页/弹窗定时器卸载清理,活动列表参与者按活动 id 查询,大厅组局按页取数,图片 lazy-load - 121 套件/792 用例全绿;已部署并验证新索引与 9ms 走势查询
这个提交包含在:
@@ -9,8 +9,8 @@ export const dbConfig = {
|
||||
database: process.env.MYSQL_DATABASE || 'peiwan',
|
||||
charset: 'utf8mb4',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
connectionLimit: Math.max(5, Number(process.env.MYSQL_CONNECTION_LIMIT) || 20),
|
||||
queueLimit: Math.max(0, Number(process.env.MYSQL_QUEUE_LIMIT) || 100),
|
||||
};
|
||||
|
||||
export const pool: Pool = mysql.createPool(dbConfig);
|
||||
@@ -773,6 +773,9 @@ export async function initDatabase(): Promise<void> {
|
||||
['orders', 'idx_orders_expert', 'expert_id'],
|
||||
['orders', 'idx_orders_claimed_by', 'claimed_by'],
|
||||
['orders', 'idx_orders_status_created', 'status, created_at'],
|
||||
['orders', 'idx_orders_buyer_created', 'buyer_id, created_at'],
|
||||
['orders', 'idx_orders_claimed_created', 'claimed_by, created_at'],
|
||||
['orders', 'idx_orders_expert_created', 'expert_id, created_at'],
|
||||
['orders', 'idx_orders_verify_code', 'verify_code'],
|
||||
['orders', 'idx_orders_companion_name', 'companion_name'],
|
||||
['order_reviews', 'idx_reviews_expert_created', 'expert_id, created_at'],
|
||||
@@ -784,6 +787,7 @@ export async function initDatabase(): Promise<void> {
|
||||
['favorites', 'idx_fav_expert', 'expert_id'],
|
||||
['distribution_records', 'idx_dist_created', 'created_at'],
|
||||
['posts', 'idx_posts_status_top_created', 'status, is_top, created_at'],
|
||||
['posts', 'idx_posts_top_created', 'is_top, created_at'],
|
||||
['posts', 'idx_posts_category', 'category'],
|
||||
['reports', 'idx_reports_status', 'status'],
|
||||
['reports', 'idx_reports_reporter', 'reporter_id'],
|
||||
@@ -792,7 +796,9 @@ export async function initDatabase(): Promise<void> {
|
||||
['activities', 'idx_act_creator', 'creator_id'],
|
||||
['activities', 'idx_act_status', 'status'],
|
||||
['activities', 'idx_act_status_created', 'status, created_at'],
|
||||
['withdrawals', 'idx_wd_status_created', 'status, created_at'],
|
||||
['activities', 'idx_act_pay_created', 'pay_type, created_at'],
|
||||
['activities', 'idx_act_creator_created', 'creator_id, created_at'],
|
||||
['withdrawals', 'idx_wd_status_apply', 'status, apply_time'],
|
||||
['payment_intents', 'idx_pi_status_created', 'status, created_at'],
|
||||
['payment_intents', 'idx_pi_order', 'order_id'],
|
||||
['payment_intents', 'idx_pi_status', 'status'],
|
||||
@@ -800,6 +806,11 @@ export async function initDatabase(): Promise<void> {
|
||||
['users', 'idx_users_inviter', 'inviter_id'],
|
||||
['users', 'idx_users_nickname', 'nickname'],
|
||||
['participants', 'idx_pt_user', 'user_id'],
|
||||
['participants', 'idx_activity_created', 'activity_id, created_at'],
|
||||
['user_notifications', 'idx_notif_user_created', 'user_id, created_at, id'],
|
||||
['finance_records', 'idx_fin_user_created', 'user_id, created_at'],
|
||||
['withdrawals', 'idx_wd_companion_apply', 'companion_id, apply_time'],
|
||||
['withdrawals', 'idx_wd_status_updated', 'status, updated_at'],
|
||||
['post_likes', 'idx_post_likes_post', 'post_id'],
|
||||
['post_comments', 'idx_pc_post', 'post_id'],
|
||||
['post_applies', 'idx_pa_post', 'post_id'],
|
||||
|
||||
@@ -318,8 +318,6 @@ export class MysqlRepository {
|
||||
return this.getActivityById(id);
|
||||
}
|
||||
|
||||
private verifyCodeCache = new Map<string, { participantId: string; userId: number; expireAt: number }>();
|
||||
|
||||
private async settleActivityCheckinAndReferral(
|
||||
activityId: string,
|
||||
creatorId: number,
|
||||
@@ -507,21 +505,12 @@ export class MysqlRepository {
|
||||
|
||||
await conn.commit();
|
||||
|
||||
// 7. 提交成功后再写入核销码缓存索引 act:verify:{activity_id}:{verify_code} -> participant_id (TTL 24h)
|
||||
const cacheKey = `act:verify:${activityId}:${verifyCode}`;
|
||||
this.verifyCodeCache.set(cacheKey, {
|
||||
participantId: ptId,
|
||||
userId: params.userId,
|
||||
expireAt: Date.now() + 24 * 3600 * 1000
|
||||
});
|
||||
|
||||
return {
|
||||
id: ptId,
|
||||
activityId,
|
||||
userId: params.userId,
|
||||
payAmountCents: joinFeeCents,
|
||||
verifyCode,
|
||||
cacheKey,
|
||||
checkinStatus: false,
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -563,9 +552,6 @@ export class MysqlRepository {
|
||||
throw new Error('该核销码已核销过,不可重复核销(防重校验已生效)');
|
||||
}
|
||||
|
||||
const cacheKey = `act:verify:${activityId}:${verifyCode}`;
|
||||
this.verifyCodeCache.delete(cacheKey);
|
||||
|
||||
// 触发活动费用清算逻辑(划入发起人账户)及二级分销返佣
|
||||
await this.settleActivityCheckinAndReferral(
|
||||
activityId,
|
||||
@@ -666,7 +652,6 @@ export class MysqlRepository {
|
||||
if (!updateRes || updateRes.affectedRows === 0) {
|
||||
throw new Error(`核销码 [${verifyCode}] 已完成现场签到,不可重复核销(防重校验已生效)`);
|
||||
}
|
||||
this.verifyCodeCache.delete(`act:verify:${info.relatedId}:${verifyCode}`);
|
||||
await this.settleActivityCheckinAndReferral(
|
||||
String(info.relatedId),
|
||||
Number(info.creatorId || operatorId),
|
||||
@@ -2465,84 +2450,91 @@ export class MysqlRepository {
|
||||
const eId = scope.companionId || scope.userId;
|
||||
const eUid = scope.userId;
|
||||
const now = new Date();
|
||||
const statuses = "('已完成', '已到场核销', '服务中', '待确认')";
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
const makePoint = (label: string, row: any) => {
|
||||
const count = Number(row?.orders || 0);
|
||||
const income = Number(Number(row?.income || 0).toFixed(2));
|
||||
return {
|
||||
label,
|
||||
income,
|
||||
orders: count,
|
||||
avgPrice: count > 0 ? Number((income / count).toFixed(2)) : 0,
|
||||
};
|
||||
};
|
||||
|
||||
// 1. 近 7 天走势
|
||||
const daily: { label: string; income: number; orders: number; avgPrice: number }[] = [];
|
||||
// 1. 近 7 天走势:一次 GROUP BY,避免每天一次查询
|
||||
const dailyLabels: { label: string; key: string }[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(now.getTime() - i * 86400000);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const dateStr = `${d.getFullYear()}-${mm}-${dd}`;
|
||||
const label = `${mm}-${dd}`;
|
||||
|
||||
try {
|
||||
const [dayRows]: any = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) as orders,
|
||||
COALESCE(SUM(amount), 0) as income
|
||||
FROM orders
|
||||
WHERE (expert_id = ? OR claimed_by = ?)
|
||||
AND DATE(created_at) = ?
|
||||
AND status IN ('已完成', '已到场核销', '服务中', '待确认')
|
||||
`, [eId, eUid, dateStr]);
|
||||
const count = Number(dayRows[0]?.orders || 0);
|
||||
const inc = Number(Number(dayRows[0]?.income || 0).toFixed(2));
|
||||
const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0;
|
||||
daily.push({ label, income: inc, orders: count, avgPrice: avg });
|
||||
} catch (_) {
|
||||
daily.push({ label, income: 0, orders: 0, avgPrice: 0 });
|
||||
}
|
||||
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
|
||||
dailyLabels.push({
|
||||
label: `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`,
|
||||
key: `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`,
|
||||
});
|
||||
}
|
||||
let dailyMap = new Map<string, any>();
|
||||
try {
|
||||
const [dayRows]: any = await pool.query(`
|
||||
SELECT DATE_FORMAT(created_at, '%Y-%m-%d') as d,
|
||||
COUNT(*) as orders,
|
||||
COALESCE(SUM(amount), 0) as income
|
||||
FROM orders
|
||||
WHERE (expert_id = ? OR claimed_by = ?)
|
||||
AND created_at >= ?
|
||||
AND status IN ${statuses}
|
||||
GROUP BY d
|
||||
`, [eId, eUid, new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6)]);
|
||||
dailyMap = new Map((dayRows || []).map((r: any) => [String(r.d), r]));
|
||||
} catch (_) {}
|
||||
const daily = dailyLabels.map(({ label, key }) => makePoint(label, dailyMap.get(key)));
|
||||
|
||||
// 2. 近 4 周走势
|
||||
const weekly: { label: string; income: number; orders: number; avgPrice: number }[] = [];
|
||||
for (let w = 3; w >= 0; w--) {
|
||||
const startD = new Date(now.getTime() - (w * 7 + 6) * 86400000);
|
||||
const endD = new Date(now.getTime() - w * 7 * 86400000);
|
||||
const label = w === 0 ? '本周' : `${w}周前`;
|
||||
try {
|
||||
const [wRows]: any = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) as orders,
|
||||
COALESCE(SUM(amount), 0) as income
|
||||
FROM orders
|
||||
WHERE (expert_id = ? OR claimed_by = ?)
|
||||
AND created_at >= ? AND created_at <= ?
|
||||
AND status IN ('已完成', '已到场核销', '服务中', '待确认')
|
||||
`, [eId, eUid, startD, endD]);
|
||||
const count = Number(wRows[0]?.orders || 0);
|
||||
const inc = Number(Number(wRows[0]?.income || 0).toFixed(2));
|
||||
const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0;
|
||||
weekly.push({ label, income: inc, orders: count, avgPrice: avg });
|
||||
} catch (_) {
|
||||
weekly.push({ label, income: 0, orders: 0, avgPrice: 0 });
|
||||
}
|
||||
}
|
||||
// 2. 近 4 周走势:按 7 天分桶,一次查询
|
||||
const weekStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 27);
|
||||
const weeklyLabels = [3, 2, 1, 0].map((w) => ({
|
||||
bucket: 3 - w,
|
||||
label: w === 0 ? '本周' : `${w}周前`,
|
||||
}));
|
||||
let weeklyMap = new Map<number, any>();
|
||||
try {
|
||||
const [wRows]: any = await pool.query(`
|
||||
SELECT FLOOR(DATEDIFF(created_at, ?) / 7) as bucket,
|
||||
COUNT(*) as orders,
|
||||
COALESCE(SUM(amount), 0) as income
|
||||
FROM orders
|
||||
WHERE (expert_id = ? OR claimed_by = ?)
|
||||
AND created_at >= ?
|
||||
AND status IN ${statuses}
|
||||
GROUP BY bucket
|
||||
`, [weekStart, eId, eUid, weekStart]);
|
||||
weeklyMap = new Map((wRows || []).map((r: any) => [Number(r.bucket), r]));
|
||||
} catch (_) {}
|
||||
const weekly = weeklyLabels.map(({ bucket, label }) => makePoint(label, weeklyMap.get(bucket)));
|
||||
|
||||
// 3. 近 6 个月走势
|
||||
const monthly: { label: string; income: number; orders: number; avgPrice: number }[] = [];
|
||||
// 3. 近 6 个月走势:一次 GROUP BY
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth() - 5, 1);
|
||||
const monthlyLabels: { label: string; key: string }[] = [];
|
||||
for (let m = 5; m >= 0; m--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - m, 1);
|
||||
const label = `${d.getMonth() + 1}月`;
|
||||
const yearMonth = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
try {
|
||||
const [mRows]: any = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) as orders,
|
||||
COALESCE(SUM(amount), 0) as income
|
||||
FROM orders
|
||||
WHERE (expert_id = ? OR claimed_by = ?)
|
||||
AND DATE_FORMAT(created_at, '%Y-%m') = ?
|
||||
AND status IN ('已完成', '已到场核销', '服务中', '待确认')
|
||||
`, [eId, eUid, yearMonth]);
|
||||
const count = Number(mRows[0]?.orders || 0);
|
||||
const inc = Number(Number(mRows[0]?.income || 0).toFixed(2));
|
||||
const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0;
|
||||
monthly.push({ label, income: inc, orders: count, avgPrice: avg });
|
||||
} catch (_) {
|
||||
monthly.push({ label, income: 0, orders: 0, avgPrice: 0 });
|
||||
}
|
||||
monthlyLabels.push({
|
||||
label: `${d.getMonth() + 1}月`,
|
||||
key: `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`,
|
||||
});
|
||||
}
|
||||
let monthlyMap = new Map<string, any>();
|
||||
try {
|
||||
const [mRows]: any = await pool.query(`
|
||||
SELECT DATE_FORMAT(created_at, '%Y-%m') as ym,
|
||||
COUNT(*) as orders,
|
||||
COALESCE(SUM(amount), 0) as income
|
||||
FROM orders
|
||||
WHERE (expert_id = ? OR claimed_by = ?)
|
||||
AND created_at >= ?
|
||||
AND status IN ${statuses}
|
||||
GROUP BY ym
|
||||
`, [eId, eUid, monthStart]);
|
||||
monthlyMap = new Map((mRows || []).map((r: any) => [String(r.ym), r]));
|
||||
} catch (_) {}
|
||||
const monthly = monthlyLabels.map(({ label, key }) => makePoint(label, monthlyMap.get(key)));
|
||||
|
||||
return { daily, weekly, monthly };
|
||||
}
|
||||
|
||||
@@ -1651,10 +1651,12 @@ app.get('/api/orders/pool', async (req: Request, res: Response) => {
|
||||
if (hasFrom) {
|
||||
const missing = pre.filter((o: any) => !o.hasGeo && o.location).slice(0, 5);
|
||||
let changed = false;
|
||||
for (const o of missing) {
|
||||
const geo = await geocodeAddress(o.location);
|
||||
if (geo) {
|
||||
await repo.updateOrderGeo(o.id, geo.lat, geo.lng);
|
||||
// 并行地理编码,避免 5 条历史无坐标订单串行等待外部接口造成请求卡顿
|
||||
const geoResults = await Promise.allSettled(missing.map((o: any) => geocodeAddress(o.location)));
|
||||
for (let i = 0; i < missing.length; i++) {
|
||||
const settled = geoResults[i];
|
||||
if (settled.status === 'fulfilled' && settled.value) {
|
||||
await repo.updateOrderGeo(missing[i].id, settled.value.lat, settled.value.lng);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,11 +486,10 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => {
|
||||
expect(wsContent).toContain('completeOrderApi');
|
||||
});
|
||||
|
||||
it('3.1 活动 4 位核销码必须建立 act:verify 缓存索引、执行 CAS 原子防重核销,并在核销后触发发起人费用清算与付费活动二级分润', () => {
|
||||
it('3.1 活动 4 位核销码必须执行 CAS 原子防重核销,并在核销后触发发起人费用清算与付费活动二级分润', () => {
|
||||
const repoPath = path.join(serverSrcDir, 'db', 'repository.ts');
|
||||
const content = fs.readFileSync(repoPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('act:verify:');
|
||||
expect(content).toContain('WHERE id = ? AND checkin_status = 0');
|
||||
expect(content).toContain('settleActivityCheckinAndReferral');
|
||||
expect(content).toContain('活动核销费用清算');
|
||||
|
||||
63
server/tests/performance.spec.ts
普通文件
63
server/tests/performance.spec.ts
普通文件
@@ -0,0 +1,63 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 性能回归闸门:
|
||||
* - 不允许无界内存缓存;
|
||||
* - 收益走势不得按天/周/月逐条查询;
|
||||
* - 活动列表参与者不得全表扫描;
|
||||
* - 外部地理编码不得串行阻塞;
|
||||
* - 索引定义必须与真实字段一致。
|
||||
*/
|
||||
describe('性能与内存回归闸门', () => {
|
||||
const root = path.resolve(__dirname, '../../');
|
||||
const read = (rel: string) => fs.readFileSync(path.join(root, rel), 'utf-8');
|
||||
const repo = read('server/src/db/repository.ts');
|
||||
const mysql = read('server/src/db/mysql.ts');
|
||||
const indexCode = read('server/src/index.ts');
|
||||
|
||||
it('不得保留只写不读的无界 verifyCodeCache', () => {
|
||||
expect(repo).not.toContain('verifyCodeCache');
|
||||
});
|
||||
|
||||
it('大神收益走势必须使用 GROUP BY 聚合,而不是按天/周/月逐条查询', () => {
|
||||
const start = repo.indexOf('async getExpertRevenueTrends(');
|
||||
const block = repo.slice(start, start + 6000);
|
||||
expect(block).toContain('GROUP BY d');
|
||||
expect(block).toContain('GROUP BY bucket');
|
||||
expect(block).toContain('GROUP BY ym');
|
||||
});
|
||||
|
||||
it('活动列表参与者必须按当前页活动 id 查询', () => {
|
||||
expect(repo).toContain('WHERE p.activity_id IN (?)');
|
||||
});
|
||||
|
||||
it('派单池地理编码必须并行,避免串行等待外部接口', () => {
|
||||
expect(indexCode).toContain('Promise.allSettled(missing.map');
|
||||
});
|
||||
|
||||
it('提现索引必须使用真实存在的 apply_time,而不是不存在的 created_at', () => {
|
||||
expect(mysql).toContain("['withdrawals', 'idx_wd_status_apply', 'status, apply_time']");
|
||||
expect(mysql).toContain("['withdrawals', 'idx_wd_companion_apply', 'companion_id, apply_time']");
|
||||
expect(mysql).not.toContain("['withdrawals', 'idx_wd_status_created', 'status, created_at']");
|
||||
});
|
||||
|
||||
it('关键列表查询必须有组合索引', () => {
|
||||
for (const v of [
|
||||
"['orders', 'idx_orders_buyer_created', 'buyer_id, created_at']",
|
||||
"['orders', 'idx_orders_claimed_created', 'claimed_by, created_at']",
|
||||
"['activities', 'idx_act_pay_created', 'pay_type, created_at']",
|
||||
"['participants', 'idx_activity_created', 'activity_id, created_at']",
|
||||
"['user_notifications', 'idx_notif_user_created', 'user_id, created_at, id']",
|
||||
"['posts', 'idx_posts_top_created', 'is_top, created_at']",
|
||||
]) {
|
||||
expect(mysql).toContain(v);
|
||||
}
|
||||
});
|
||||
|
||||
it('数据库连接池必须可通过环境变量调优,并有排队上限', () => {
|
||||
expect(mysql).toContain('MYSQL_CONNECTION_LIMIT');
|
||||
expect(mysql).toContain('MYSQL_QUEUE_LIMIT');
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ describe('第一百批系统告警与后台审计', () => {
|
||||
it('告警与审计表必须落库,并补充巡检所需索引', () => {
|
||||
expect(mysql).toContain('CREATE TABLE IF NOT EXISTS system_alerts');
|
||||
expect(mysql).toContain('CREATE TABLE IF NOT EXISTS admin_audit_logs');
|
||||
expect(mysql).toContain("['withdrawals', 'idx_wd_status_created'");
|
||||
expect(mysql).toContain("['withdrawals', 'idx_wd_status_apply'");
|
||||
expect(mysql).toContain("['payment_intents', 'idx_pi_status_created'");
|
||||
});
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户