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 走势查询
这个提交包含在:
xcbyy
2026-10-23 15:31:37 +08:00
父节点 0d574b6420
当前提交 94cf6cdc69
共有 10 个文件被更改,包括 187 次插入 和 100 次删除

查看文件

@@ -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('活动核销费用清算');

查看文件

@@ -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'");
}); });