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 };
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户