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 走势查询
这个提交包含在:
@@ -29,12 +29,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import { ICONS } from '@/common/icons.js';
|
import { ICONS } from '@/common/icons.js';
|
||||||
import { getCurrentThemeKey } from '@/common/theme.js';
|
import { getCurrentThemeKey } from '@/common/theme.js';
|
||||||
import { getPlatformConfigs } from '@/common/api.js';
|
import { getPlatformConfigs } from '@/common/api.js';
|
||||||
|
|
||||||
const visible = ref(false);
|
const visible = ref(false);
|
||||||
|
let popupTimer = null;
|
||||||
const config = ref({
|
const config = ref({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
title: '',
|
title: '',
|
||||||
@@ -81,7 +82,8 @@ const checkAndDisplayPopup = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 延迟 600ms 展示,保证首屏骨架屏转场顺畅
|
// 延迟 600ms 展示,保证首屏骨架屏转场顺畅
|
||||||
setTimeout(() => {
|
if (popupTimer) clearTimeout(popupTimer);
|
||||||
|
popupTimer = setTimeout(() => {
|
||||||
visible.value = true;
|
visible.value = true;
|
||||||
uni.setStorageSync(storageKey, String(now));
|
uni.setStorageSync(storageKey, String(now));
|
||||||
}, 600);
|
}, 600);
|
||||||
@@ -107,6 +109,13 @@ const handleAction = () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
checkAndDisplayPopup();
|
checkAndDisplayPopup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (popupTimer) {
|
||||||
|
clearTimeout(popupTimer);
|
||||||
|
popupTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -129,6 +129,7 @@
|
|||||||
v-for="(img, idx) in post.images"
|
v-for="(img, idx) in post.images"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
class="post-img"
|
class="post-img"
|
||||||
|
lazy-load
|
||||||
:src="img"
|
:src="img"
|
||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
@click.stop="previewImage(post.images, idx)"
|
@click.stop="previewImage(post.images, idx)"
|
||||||
|
|||||||
@@ -397,8 +397,9 @@ const loadHallData = async () => {
|
|||||||
}
|
}
|
||||||
}).catch(() => {}),
|
}).catch(() => {}),
|
||||||
|
|
||||||
getActivities().then((list) => {
|
getActivities({ page: 1, pageSize: 20 }).then((res) => {
|
||||||
const arr = Array.isArray(list) ? list : [];
|
// 大厅只展示最近 5 个可报名组局,按页取数避免每 30 秒拉全量活动与参与者
|
||||||
|
const arr = Array.isArray(res) ? res : (res && Array.isArray(res.list) ? res.list : []);
|
||||||
nearbyActivities.value = arr
|
nearbyActivities.value = arr
|
||||||
.filter((a) => a && (a.status === 'RECRUITING' || a.status === '招募中'))
|
.filter((a) => a && (a.status === 'RECRUITING' || a.status === '招募中'))
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
>
|
>
|
||||||
<!-- 封面图构图优化:高保真黄金比例与底部流光渐变 -->
|
<!-- 封面图构图优化:高保真黄金比例与底部流光渐变 -->
|
||||||
<view class="card-cover-wrap">
|
<view class="card-cover-wrap">
|
||||||
<image class="card-cover" :src="expert.avatar" mode="aspectFill" />
|
<image class="card-cover" :src="expert.avatar" mode="aspectFill" lazy-load />
|
||||||
<view class="cover-gradient"></view>
|
<view class="cover-gradient"></view>
|
||||||
|
|
||||||
<view class="status-tag" :class="expert.isOnline ? 'online' : 'offline'">
|
<view class="status-tag" :class="expert.isOnline ? 'online' : 'offline'">
|
||||||
@@ -489,6 +489,15 @@ onShow(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
uni.$off('theme-change', onThemeChanged);
|
uni.$off('theme-change', onThemeChanged);
|
||||||
|
if (searchTimer) {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = null;
|
||||||
|
}
|
||||||
|
if (matchTimer) {
|
||||||
|
clearTimeout(matchTimer);
|
||||||
|
matchTimer = null;
|
||||||
|
}
|
||||||
|
matchSeq += 1;
|
||||||
try { if (homeVoiceAudio) { homeVoiceAudio.destroy(); homeVoiceAudio = null; } } catch (_) {}
|
try { if (homeVoiceAudio) { homeVoiceAudio.destroy(); homeVoiceAudio = null; } } catch (_) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const dbConfig = {
|
|||||||
database: process.env.MYSQL_DATABASE || 'peiwan',
|
database: process.env.MYSQL_DATABASE || 'peiwan',
|
||||||
charset: 'utf8mb4',
|
charset: 'utf8mb4',
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 10,
|
connectionLimit: Math.max(5, Number(process.env.MYSQL_CONNECTION_LIMIT) || 20),
|
||||||
queueLimit: 0,
|
queueLimit: Math.max(0, Number(process.env.MYSQL_QUEUE_LIMIT) || 100),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pool: Pool = mysql.createPool(dbConfig);
|
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_expert', 'expert_id'],
|
||||||
['orders', 'idx_orders_claimed_by', 'claimed_by'],
|
['orders', 'idx_orders_claimed_by', 'claimed_by'],
|
||||||
['orders', 'idx_orders_status_created', 'status, created_at'],
|
['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_verify_code', 'verify_code'],
|
||||||
['orders', 'idx_orders_companion_name', 'companion_name'],
|
['orders', 'idx_orders_companion_name', 'companion_name'],
|
||||||
['order_reviews', 'idx_reviews_expert_created', 'expert_id, created_at'],
|
['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'],
|
['favorites', 'idx_fav_expert', 'expert_id'],
|
||||||
['distribution_records', 'idx_dist_created', 'created_at'],
|
['distribution_records', 'idx_dist_created', 'created_at'],
|
||||||
['posts', 'idx_posts_status_top_created', 'status, is_top, 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'],
|
['posts', 'idx_posts_category', 'category'],
|
||||||
['reports', 'idx_reports_status', 'status'],
|
['reports', 'idx_reports_status', 'status'],
|
||||||
['reports', 'idx_reports_reporter', 'reporter_id'],
|
['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_creator', 'creator_id'],
|
||||||
['activities', 'idx_act_status', 'status'],
|
['activities', 'idx_act_status', 'status'],
|
||||||
['activities', 'idx_act_status_created', 'status, created_at'],
|
['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_status_created', 'status, created_at'],
|
||||||
['payment_intents', 'idx_pi_order', 'order_id'],
|
['payment_intents', 'idx_pi_order', 'order_id'],
|
||||||
['payment_intents', 'idx_pi_status', 'status'],
|
['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_inviter', 'inviter_id'],
|
||||||
['users', 'idx_users_nickname', 'nickname'],
|
['users', 'idx_users_nickname', 'nickname'],
|
||||||
['participants', 'idx_pt_user', 'user_id'],
|
['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_likes', 'idx_post_likes_post', 'post_id'],
|
||||||
['post_comments', 'idx_pc_post', 'post_id'],
|
['post_comments', 'idx_pc_post', 'post_id'],
|
||||||
['post_applies', 'idx_pa_post', 'post_id'],
|
['post_applies', 'idx_pa_post', 'post_id'],
|
||||||
|
|||||||
@@ -318,8 +318,6 @@ export class MysqlRepository {
|
|||||||
return this.getActivityById(id);
|
return this.getActivityById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private verifyCodeCache = new Map<string, { participantId: string; userId: number; expireAt: number }>();
|
|
||||||
|
|
||||||
private async settleActivityCheckinAndReferral(
|
private async settleActivityCheckinAndReferral(
|
||||||
activityId: string,
|
activityId: string,
|
||||||
creatorId: number,
|
creatorId: number,
|
||||||
@@ -507,21 +505,12 @@ export class MysqlRepository {
|
|||||||
|
|
||||||
await conn.commit();
|
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 {
|
return {
|
||||||
id: ptId,
|
id: ptId,
|
||||||
activityId,
|
activityId,
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
payAmountCents: joinFeeCents,
|
payAmountCents: joinFeeCents,
|
||||||
verifyCode,
|
verifyCode,
|
||||||
cacheKey,
|
|
||||||
checkinStatus: false,
|
checkinStatus: false,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -563,9 +552,6 @@ export class MysqlRepository {
|
|||||||
throw new Error('该核销码已核销过,不可重复核销(防重校验已生效)');
|
throw new Error('该核销码已核销过,不可重复核销(防重校验已生效)');
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = `act:verify:${activityId}:${verifyCode}`;
|
|
||||||
this.verifyCodeCache.delete(cacheKey);
|
|
||||||
|
|
||||||
// 触发活动费用清算逻辑(划入发起人账户)及二级分销返佣
|
// 触发活动费用清算逻辑(划入发起人账户)及二级分销返佣
|
||||||
await this.settleActivityCheckinAndReferral(
|
await this.settleActivityCheckinAndReferral(
|
||||||
activityId,
|
activityId,
|
||||||
@@ -666,7 +652,6 @@ export class MysqlRepository {
|
|||||||
if (!updateRes || updateRes.affectedRows === 0) {
|
if (!updateRes || updateRes.affectedRows === 0) {
|
||||||
throw new Error(`核销码 [${verifyCode}] 已完成现场签到,不可重复核销(防重校验已生效)`);
|
throw new Error(`核销码 [${verifyCode}] 已完成现场签到,不可重复核销(防重校验已生效)`);
|
||||||
}
|
}
|
||||||
this.verifyCodeCache.delete(`act:verify:${info.relatedId}:${verifyCode}`);
|
|
||||||
await this.settleActivityCheckinAndReferral(
|
await this.settleActivityCheckinAndReferral(
|
||||||
String(info.relatedId),
|
String(info.relatedId),
|
||||||
Number(info.creatorId || operatorId),
|
Number(info.creatorId || operatorId),
|
||||||
@@ -2465,84 +2450,91 @@ export class MysqlRepository {
|
|||||||
const eId = scope.companionId || scope.userId;
|
const eId = scope.companionId || scope.userId;
|
||||||
const eUid = scope.userId;
|
const eUid = scope.userId;
|
||||||
const now = new Date();
|
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 天走势
|
// 1. 近 7 天走势:一次 GROUP BY,避免每天一次查询
|
||||||
const daily: { label: string; income: number; orders: number; avgPrice: number }[] = [];
|
const dailyLabels: { label: string; key: string }[] = [];
|
||||||
for (let i = 6; i >= 0; i--) {
|
for (let i = 6; i >= 0; i--) {
|
||||||
const d = new Date(now.getTime() - i * 86400000);
|
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
|
||||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
dailyLabels.push({
|
||||||
const dd = String(d.getDate()).padStart(2, '0');
|
label: `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`,
|
||||||
const dateStr = `${d.getFullYear()}-${mm}-${dd}`;
|
key: `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`,
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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 周走势
|
// 2. 近 4 周走势:按 7 天分桶,一次查询
|
||||||
const weekly: { label: string; income: number; orders: number; avgPrice: number }[] = [];
|
const weekStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 27);
|
||||||
for (let w = 3; w >= 0; w--) {
|
const weeklyLabels = [3, 2, 1, 0].map((w) => ({
|
||||||
const startD = new Date(now.getTime() - (w * 7 + 6) * 86400000);
|
bucket: 3 - w,
|
||||||
const endD = new Date(now.getTime() - w * 7 * 86400000);
|
label: w === 0 ? '本周' : `${w}周前`,
|
||||||
const label = w === 0 ? '本周' : `${w}周前`;
|
}));
|
||||||
try {
|
let weeklyMap = new Map<number, any>();
|
||||||
const [wRows]: any = await pool.query(`
|
try {
|
||||||
SELECT
|
const [wRows]: any = await pool.query(`
|
||||||
COUNT(*) as orders,
|
SELECT FLOOR(DATEDIFF(created_at, ?) / 7) as bucket,
|
||||||
COALESCE(SUM(amount), 0) as income
|
COUNT(*) as orders,
|
||||||
FROM orders
|
COALESCE(SUM(amount), 0) as income
|
||||||
WHERE (expert_id = ? OR claimed_by = ?)
|
FROM orders
|
||||||
AND created_at >= ? AND created_at <= ?
|
WHERE (expert_id = ? OR claimed_by = ?)
|
||||||
AND status IN ('已完成', '已到场核销', '服务中', '待确认')
|
AND created_at >= ?
|
||||||
`, [eId, eUid, startD, endD]);
|
AND status IN ${statuses}
|
||||||
const count = Number(wRows[0]?.orders || 0);
|
GROUP BY bucket
|
||||||
const inc = Number(Number(wRows[0]?.income || 0).toFixed(2));
|
`, [weekStart, eId, eUid, weekStart]);
|
||||||
const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0;
|
weeklyMap = new Map((wRows || []).map((r: any) => [Number(r.bucket), r]));
|
||||||
weekly.push({ label, income: inc, orders: count, avgPrice: avg });
|
} catch (_) {}
|
||||||
} catch (_) {
|
const weekly = weeklyLabels.map(({ bucket, label }) => makePoint(label, weeklyMap.get(bucket)));
|
||||||
weekly.push({ label, income: 0, orders: 0, avgPrice: 0 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 近 6 个月走势
|
// 3. 近 6 个月走势:一次 GROUP BY
|
||||||
const monthly: { label: string; income: number; orders: number; avgPrice: number }[] = [];
|
const monthStart = new Date(now.getFullYear(), now.getMonth() - 5, 1);
|
||||||
|
const monthlyLabels: { label: string; key: string }[] = [];
|
||||||
for (let m = 5; m >= 0; m--) {
|
for (let m = 5; m >= 0; m--) {
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - m, 1);
|
const d = new Date(now.getFullYear(), now.getMonth() - m, 1);
|
||||||
const label = `${d.getMonth() + 1}月`;
|
monthlyLabels.push({
|
||||||
const yearMonth = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
label: `${d.getMonth() + 1}月`,
|
||||||
try {
|
key: `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`,
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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 };
|
return { daily, weekly, monthly };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1651,10 +1651,12 @@ app.get('/api/orders/pool', async (req: Request, res: Response) => {
|
|||||||
if (hasFrom) {
|
if (hasFrom) {
|
||||||
const missing = pre.filter((o: any) => !o.hasGeo && o.location).slice(0, 5);
|
const missing = pre.filter((o: any) => !o.hasGeo && o.location).slice(0, 5);
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const o of missing) {
|
// 并行地理编码,避免 5 条历史无坐标订单串行等待外部接口造成请求卡顿
|
||||||
const geo = await geocodeAddress(o.location);
|
const geoResults = await Promise.allSettled(missing.map((o: any) => geocodeAddress(o.location)));
|
||||||
if (geo) {
|
for (let i = 0; i < missing.length; i++) {
|
||||||
await repo.updateOrderGeo(o.id, geo.lat, geo.lng);
|
const settled = geoResults[i];
|
||||||
|
if (settled.status === 'fulfilled' && settled.value) {
|
||||||
|
await repo.updateOrderGeo(missing[i].id, settled.value.lat, settled.value.lng);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -486,11 +486,10 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => {
|
|||||||
expect(wsContent).toContain('completeOrderApi');
|
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 repoPath = path.join(serverSrcDir, 'db', 'repository.ts');
|
||||||
const content = fs.readFileSync(repoPath, 'utf-8');
|
const content = fs.readFileSync(repoPath, 'utf-8');
|
||||||
|
|
||||||
expect(content).toContain('act:verify:');
|
|
||||||
expect(content).toContain('WHERE id = ? AND checkin_status = 0');
|
expect(content).toContain('WHERE id = ? AND checkin_status = 0');
|
||||||
expect(content).toContain('settleActivityCheckinAndReferral');
|
expect(content).toContain('settleActivityCheckinAndReferral');
|
||||||
expect(content).toContain('活动核销费用清算');
|
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('告警与审计表必须落库,并补充巡检所需索引', () => {
|
it('告警与审计表必须落库,并补充巡检所需索引', () => {
|
||||||
expect(mysql).toContain('CREATE TABLE IF NOT EXISTS system_alerts');
|
expect(mysql).toContain('CREATE TABLE IF NOT EXISTS system_alerts');
|
||||||
expect(mysql).toContain('CREATE TABLE IF NOT EXISTS admin_audit_logs');
|
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'");
|
expect(mysql).toContain("['payment_intents', 'idx_pi_status_created'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户