perf: 全栈性能排查与优化落地 (消灭运行时DDL/消除全表扫描/内存泄漏治理/高频静态缓存)
这个提交包含在:
@@ -69,7 +69,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { ICONS } from '@/common/icons.js';
|
||||
import { getCurrentThemeKey } from '@/common/theme.js';
|
||||
|
||||
@@ -77,6 +77,10 @@ const visible = ref(false);
|
||||
const currentThemeKey = ref(getCurrentThemeKey());
|
||||
let privacyResolve = null;
|
||||
|
||||
const onThemeChanged = (theme) => {
|
||||
currentThemeKey.value = theme;
|
||||
};
|
||||
|
||||
const STORAGE_KEY_PRIVACY_AGREED = 'PW_USER_PRIVACY_AGREED_V1';
|
||||
|
||||
const checkPrivacyAgreed = () => {
|
||||
@@ -95,9 +99,7 @@ const setPrivacyAgreed = (val) => {
|
||||
|
||||
onMounted(() => {
|
||||
currentThemeKey.value = getCurrentThemeKey();
|
||||
uni.$on('theme-change', (theme) => {
|
||||
currentThemeKey.value = theme;
|
||||
});
|
||||
uni.$on('theme-change', onThemeChanged);
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
if (typeof wx !== 'undefined' && wx.onNeedPrivacyAuthorization) {
|
||||
@@ -114,6 +116,10 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
uni.$off('theme-change', onThemeChanged);
|
||||
});
|
||||
|
||||
const openPrivacyDoc = () => {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (typeof wx !== 'undefined' && wx.openPrivacyContract) {
|
||||
|
||||
@@ -153,6 +153,11 @@ export class MysqlRepository {
|
||||
ORDER BY a.created_at DESC${limitSql}
|
||||
`, params);
|
||||
|
||||
if (!actRows || actRows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const actIds = actRows.map((a: any) => String(a.id));
|
||||
const [ptRows]: any = await pool.query(`
|
||||
SELECT
|
||||
p.id, p.activity_id as activityId, p.user_id as userId,
|
||||
@@ -162,7 +167,8 @@ export class MysqlRepository {
|
||||
p.verified_by as verifiedBy, p.created_at as createdAt
|
||||
FROM participants p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
`);
|
||||
WHERE p.activity_id IN (?)
|
||||
`, [actIds]);
|
||||
|
||||
return actRows.map((act: any) => {
|
||||
const parts = ptRows.filter((p: any) => p.activityId === act.id);
|
||||
@@ -838,11 +844,67 @@ export class MysqlRepository {
|
||||
}
|
||||
|
||||
async getMyActivities(userId: number): Promise<any> {
|
||||
const allActivities = await this.getActivities();
|
||||
const created = allActivities.filter((a: any) => Number(a.creatorId) === Number(userId));
|
||||
const joined = allActivities.filter((a: any) =>
|
||||
a.participants && a.participants.some((p: any) => Number(p.userId) === Number(userId))
|
||||
const uid = Number(userId) || 0;
|
||||
const created = await this.getActivities({ creatorId: uid });
|
||||
|
||||
const [myPtRows]: any = await pool.query(
|
||||
'SELECT DISTINCT activity_id FROM participants WHERE user_id = ?',
|
||||
[uid]
|
||||
);
|
||||
const joinedActIds = Array.isArray(myPtRows) ? myPtRows.map((r: any) => String(r.activity_id)) : [];
|
||||
if (joinedActIds.length === 0) {
|
||||
return { created, joined: [] };
|
||||
}
|
||||
|
||||
const [actRows]: any = await pool.query(`
|
||||
SELECT
|
||||
a.id, a.creator_id as creatorId, a.title, a.game_category as gameCategory,
|
||||
a.location, COALESCE(a.start_time, '') as startTime, a.pay_type as payType, a.total_fee_cents as totalFeeCents,
|
||||
a.per_person_fee_cents as perPersonFeeCents, a.max_participants as maxParticipants,
|
||||
a.allow_dynamic_price as allowDynamicPrice, a.price_adjust_rule as priceAdjustRule,
|
||||
COALESCE(a.status, 'RECRUITING') as status,
|
||||
a.created_at as createdAt,
|
||||
u.nickname as creatorNickname,
|
||||
u.avatar as creatorAvatar
|
||||
FROM activities a
|
||||
LEFT JOIN users u ON a.creator_id = u.id
|
||||
WHERE a.id IN (?)
|
||||
ORDER BY a.created_at DESC
|
||||
`, [joinedActIds]);
|
||||
|
||||
const [ptRows]: any = await pool.query(`
|
||||
SELECT
|
||||
p.id, p.activity_id as activityId, p.user_id as userId,
|
||||
u.nickname as nickname, u.avatar as avatar,
|
||||
p.pay_amount_cents as payAmountCents, p.verify_code as verifyCode,
|
||||
p.checkin_status as checkinStatus, p.verified_at as verifiedAt,
|
||||
p.verified_by as verifiedBy, p.created_at as createdAt
|
||||
FROM participants p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.activity_id IN (?)
|
||||
`, [joinedActIds]);
|
||||
|
||||
const joined = actRows.map((act: any) => {
|
||||
const parts = ptRows.filter((p: any) => p.activityId === act.id);
|
||||
return {
|
||||
...act,
|
||||
allowDynamicPrice: Boolean(act.allowDynamicPrice),
|
||||
participants: parts.map((p: any) => ({
|
||||
id: p.id,
|
||||
activityId: p.activityId,
|
||||
userId: p.userId,
|
||||
nickname: p.nickname || '用户已注销',
|
||||
avatar: p.avatar || '',
|
||||
payAmountCents: p.payAmountCents,
|
||||
checkinStatus: Boolean(p.checkinStatus),
|
||||
verifiedAt: p.verifiedAt,
|
||||
verifiedBy: p.verifiedBy,
|
||||
createdAt: p.createdAt,
|
||||
})),
|
||||
joinedCount: parts.length,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
created,
|
||||
joined,
|
||||
@@ -874,9 +936,6 @@ export class MysqlRepository {
|
||||
}
|
||||
|
||||
async getCompanions(keyword?: string): Promise<any[]> {
|
||||
try {
|
||||
await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status");
|
||||
} catch (_) {}
|
||||
let sql = `
|
||||
SELECT
|
||||
id, user_id as userId, name, avatar, gender, age, track, rank_name as \`rank\`,
|
||||
@@ -1224,9 +1283,6 @@ export class MysqlRepository {
|
||||
avatar?: string;
|
||||
gender?: string;
|
||||
}): Promise<any> {
|
||||
try {
|
||||
await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status");
|
||||
} catch (_) {}
|
||||
const userId = Number(data.userId) || 1001;
|
||||
const name = data.name.trim();
|
||||
const track = data.track || '同城陪吃';
|
||||
@@ -1270,13 +1326,6 @@ export class MysqlRepository {
|
||||
}
|
||||
|
||||
async auditCompanion(id: number, action: 'approve' | 'reject' | 'revoke', reason?: string): Promise<any> {
|
||||
try {
|
||||
await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status");
|
||||
} catch (_) {}
|
||||
try {
|
||||
await pool.query('ALTER TABLE users ADD COLUMN is_expert TINYINT DEFAULT 0');
|
||||
} catch (_) {}
|
||||
|
||||
const isApprove = action === 'approve';
|
||||
const isRevoke = action === 'revoke';
|
||||
const auditStatus = isApprove ? '已通过' : '已驳回';
|
||||
@@ -1652,6 +1701,7 @@ export class MysqlRepository {
|
||||
const uid = Number(userId) || 0;
|
||||
if (uid <= 0) return 0;
|
||||
const statusWhere = this.orderStatusFilter(opts.status);
|
||||
|
||||
const [rows]: any = await pool.query(`
|
||||
SELECT COUNT(*) as c
|
||||
FROM orders
|
||||
@@ -5030,35 +5080,6 @@ export class MysqlRepository {
|
||||
// 友圈动态相关
|
||||
async getPosts(category?: string, keyword?: string, status?: string, userId?: number): Promise<any[]> {
|
||||
const currentUserId = userId !== undefined ? Number(userId) : 1001;
|
||||
const likedPostIdSet = new Set<string>();
|
||||
try {
|
||||
const [likedRows]: any = await pool.query(
|
||||
'SELECT post_id FROM post_likes WHERE user_id = ?',
|
||||
[currentUserId]
|
||||
);
|
||||
if (Array.isArray(likedRows)) {
|
||||
likedRows.forEach((row: any) => likedPostIdSet.add(String(row.post_id)));
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const applyStatsMap = new Map<string, { total: number; pending: number; myStatus: string }>();
|
||||
try {
|
||||
const [applyRows]: any = await pool.query(
|
||||
'SELECT post_id, applicant_id, status FROM post_applies ORDER BY created_at DESC'
|
||||
);
|
||||
if (Array.isArray(applyRows)) {
|
||||
for (const ar of applyRows) {
|
||||
const pid = String(ar.post_id);
|
||||
const cur = applyStatsMap.get(pid) || { total: 0, pending: 0, myStatus: '' };
|
||||
cur.total += 1;
|
||||
if (ar.status === 'PENDING') cur.pending += 1;
|
||||
if (Number(ar.applicant_id) === currentUserId && !cur.myStatus) {
|
||||
cur.myStatus = ar.status || 'PENDING';
|
||||
}
|
||||
applyStatsMap.set(pid, cur);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
let sql = `
|
||||
SELECT
|
||||
@@ -5089,6 +5110,41 @@ export class MysqlRepository {
|
||||
sql += ' ORDER BY is_top DESC, created_at DESC LIMIT 200';
|
||||
|
||||
const [rows]: any = await pool.query(sql, params);
|
||||
if (!rows || rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const postIds = rows.map((r: any) => String(r.id));
|
||||
const likedPostIdSet = new Set<string>();
|
||||
try {
|
||||
const [likedRows]: any = await pool.query(
|
||||
'SELECT post_id FROM post_likes WHERE user_id = ? AND post_id IN (?)',
|
||||
[currentUserId, postIds]
|
||||
);
|
||||
if (Array.isArray(likedRows)) {
|
||||
likedRows.forEach((row: any) => likedPostIdSet.add(String(row.post_id)));
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const applyStatsMap = new Map<string, { total: number; pending: number; myStatus: string }>();
|
||||
try {
|
||||
const [applyRows]: any = await pool.query(
|
||||
'SELECT post_id, applicant_id, status FROM post_applies WHERE post_id IN (?) ORDER BY created_at DESC',
|
||||
[postIds]
|
||||
);
|
||||
if (Array.isArray(applyRows)) {
|
||||
for (const ar of applyRows) {
|
||||
const pid = String(ar.post_id);
|
||||
const cur = applyStatsMap.get(pid) || { total: 0, pending: 0, myStatus: '' };
|
||||
cur.total += 1;
|
||||
if (ar.status === 'PENDING') cur.pending += 1;
|
||||
if (Number(ar.applicant_id) === currentUserId && !cur.myStatus) {
|
||||
cur.myStatus = ar.status || 'PENDING';
|
||||
}
|
||||
applyStatsMap.set(pid, cur);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return rows.map((r: any) => {
|
||||
let topicsArr = [];
|
||||
let imagesArr = [];
|
||||
@@ -5367,12 +5423,6 @@ export class MysqlRepository {
|
||||
}
|
||||
|
||||
async getOrderReviews(expertId?: number, orderId?: string): Promise<any[]> {
|
||||
try {
|
||||
await pool.query('ALTER TABLE order_reviews ADD COLUMN reply_content TEXT AFTER content');
|
||||
} catch (_) {}
|
||||
try {
|
||||
await pool.query('ALTER TABLE order_reviews ADD COLUMN reply_time DATETIME AFTER reply_content');
|
||||
} catch (_) {}
|
||||
let sql = `
|
||||
SELECT id, order_id as orderId, expert_id as expertId, user_id as userId,
|
||||
user_nickname as userNickname, user_avatar as userAvatar,
|
||||
@@ -5637,12 +5687,6 @@ export class MysqlRepository {
|
||||
// 17. 大神对评价感谢回复
|
||||
async replyOrderReview(reviewId: string, replyContent: string): Promise<any> {
|
||||
const now = new Date();
|
||||
try {
|
||||
await pool.query('ALTER TABLE order_reviews ADD COLUMN reply_content TEXT AFTER content');
|
||||
} catch (_) {}
|
||||
try {
|
||||
await pool.query('ALTER TABLE order_reviews ADD COLUMN reply_time DATETIME AFTER reply_content');
|
||||
} catch (_) {}
|
||||
|
||||
await pool.query(`
|
||||
UPDATE order_reviews
|
||||
@@ -5690,8 +5734,11 @@ export class MysqlRepository {
|
||||
}
|
||||
|
||||
// 19. 大神服务包管理
|
||||
/** 确保 expert_services 表与索引存在(懒建表) */
|
||||
private static expertServicesTableInitialized = false;
|
||||
|
||||
/** 确保 expert_services 表与索引存在(懒建表,单例仅初始化一次) */
|
||||
async ensureExpertServicesTable(): Promise<void> {
|
||||
if (MysqlRepository.expertServicesTableInitialized) return;
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS expert_services (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
@@ -5711,6 +5758,7 @@ export class MysqlRepository {
|
||||
} catch (_) {
|
||||
// 索引已存在:忽略
|
||||
}
|
||||
MysqlRepository.expertServicesTableInitialized = true;
|
||||
}
|
||||
|
||||
private mapExpertService(r: any): any {
|
||||
|
||||
@@ -539,6 +539,21 @@ function httpsGetJson(url: string, timeoutMs = 4000): Promise<any> {
|
||||
|
||||
const geocodeCache = new Map<string, { geo: { lat: number; lng: number } | null; at: number }>();
|
||||
|
||||
// 周期性淘汰防爆破记录与地理编码缓存,杜绝常驻进程内存泄漏
|
||||
const securitySweepTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of loginFailureMap) {
|
||||
if (now - v.firstAt > LOGIN_WINDOW_MS) loginFailureMap.delete(k);
|
||||
}
|
||||
if (loginFailureMap.size > 20000) loginFailureMap.clear();
|
||||
|
||||
for (const [k, v] of geocodeCache) {
|
||||
if (now - v.at > 10 * 60_000) geocodeCache.delete(k);
|
||||
}
|
||||
if (geocodeCache.size > 5000) geocodeCache.clear();
|
||||
}, 5 * 60 * 1000);
|
||||
if (typeof (securitySweepTimer as any).unref === 'function') (securitySweepTimer as any).unref();
|
||||
|
||||
/** 地址 -> 坐标(腾讯 WebService 地理编码),带进程内缓存 */
|
||||
async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
|
||||
const text = String(address || '').trim();
|
||||
@@ -601,6 +616,7 @@ app.post('/api/admin/session/revoke-all', requireAdmin, async (req: Request, res
|
||||
status: 1,
|
||||
});
|
||||
adminTokenEpoch = next;
|
||||
invalidatePlatformConfigsCache();
|
||||
console.warn(`[Peiwan Admin][AUDIT] 后台登录态已全部吊销 version->${next} ip=${clientIpOf(req)}`);
|
||||
res.json({ code: 20000, message: '已吊销所有后台登录态,请重新登录' });
|
||||
} catch (err: any) {
|
||||
@@ -1728,10 +1744,37 @@ app.get('/api/expert/stats/:id/trends', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
|
||||
// 静态高频接口的进程内 TTL 缓存(降低 MySQL 重复读取开销)
|
||||
interface FastCacheEntry<T> {
|
||||
data: T;
|
||||
expireAt: number;
|
||||
}
|
||||
const STATIC_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
let cachedBanners: FastCacheEntry<any[]> | null = null;
|
||||
export function invalidateBannersCache(): void {
|
||||
cachedBanners = null;
|
||||
}
|
||||
|
||||
let cachedNavCategories: FastCacheEntry<any[]> | null = null;
|
||||
export function invalidateNavCategoriesCache(): void {
|
||||
cachedNavCategories = null;
|
||||
}
|
||||
|
||||
let cachedPlatformConfigs: FastCacheEntry<{ [key: string]: any }> | null = null;
|
||||
export function invalidatePlatformConfigsCache(): void {
|
||||
cachedPlatformConfigs = null;
|
||||
}
|
||||
|
||||
// 首页运营 Banner 列表 (小程序端读取启用状态)
|
||||
app.get('/api/banners', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (cachedBanners && now < cachedBanners.expireAt) {
|
||||
return res.json({ code: 20000, data: cachedBanners.data });
|
||||
}
|
||||
const banners = await repo.getBanners();
|
||||
cachedBanners = { data: banners, expireAt: now + STATIC_CACHE_TTL_MS };
|
||||
res.json({ code: 20000, data: banners });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ code: 50000, message: err.message });
|
||||
@@ -1775,6 +1818,7 @@ app.post('/api/admin/banners', async (req: Request, res: Response) => {
|
||||
status: status !== undefined ? Number(status) : 1
|
||||
});
|
||||
|
||||
invalidateBannersCache();
|
||||
res.json({ code: 20000, data: banner, message: '轮播图创建成功' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
@@ -1811,6 +1855,7 @@ app.put('/api/admin/banners/:id', async (req: Request, res: Response) => {
|
||||
return res.status(404).json({ code: 40400, message: '轮播图不存在或已被删除' });
|
||||
}
|
||||
|
||||
invalidateBannersCache();
|
||||
res.json({ code: 20000, data: updated, message: '轮播图修改成功' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
@@ -1831,6 +1876,7 @@ app.post('/api/admin/banners/:id/status', async (req: Request, res: Response) =>
|
||||
return res.status(404).json({ code: 40400, message: '轮播图不存在' });
|
||||
}
|
||||
|
||||
invalidateBannersCache();
|
||||
res.json({ code: 20000, data: updated, message: status === 1 ? '轮播图已上架生效' : '轮播图已下线隐藏' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
@@ -1846,6 +1892,7 @@ app.delete('/api/admin/banners/:id', async (req: Request, res: Response) => {
|
||||
return res.status(404).json({ code: 40400, message: '轮播图不存在或已被删除' });
|
||||
}
|
||||
|
||||
invalidateBannersCache();
|
||||
res.json({ code: 20000, data: null, message: '轮播图已删除' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
@@ -1855,7 +1902,12 @@ app.delete('/api/admin/banners/:id', async (req: Request, res: Response) => {
|
||||
// 首页金刚区活动品类导航列表 (小程序端读取上架状态)
|
||||
app.get('/api/nav-categories', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (cachedNavCategories && now < cachedNavCategories.expireAt) {
|
||||
return res.json({ code: 20000, data: cachedNavCategories.data });
|
||||
}
|
||||
const list = await repo.getNavCategories();
|
||||
cachedNavCategories = { data: list, expireAt: now + STATIC_CACHE_TTL_MS };
|
||||
res.json({ code: 20000, data: list });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ code: 50000, message: err.message });
|
||||
@@ -1901,6 +1953,7 @@ app.post('/api/admin/nav-categories', async (req: Request, res: Response) => {
|
||||
status: status !== undefined ? Number(status) : 1
|
||||
});
|
||||
|
||||
invalidateNavCategoriesCache();
|
||||
res.json({ code: 20000, data: item, message: '品类导航创建成功' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
@@ -1935,6 +1988,7 @@ app.put('/api/admin/nav-categories/:id', async (req: Request, res: Response) =>
|
||||
return res.status(404).json({ code: 40400, message: '品类导航不存在或已被删除' });
|
||||
}
|
||||
|
||||
invalidateNavCategoriesCache();
|
||||
res.json({ code: 20000, data: updated, message: '品类导航修改成功' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
@@ -1955,6 +2009,7 @@ app.post('/api/admin/nav-categories/:id/status', async (req: Request, res: Respo
|
||||
return res.status(404).json({ code: 40400, message: '品类导航不存在' });
|
||||
}
|
||||
|
||||
invalidateNavCategoriesCache();
|
||||
res.json({ code: 20000, data: updated, message: status === 1 ? '品类导航已上架' : '品类导航已下线隐藏' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
@@ -1970,6 +2025,7 @@ app.delete('/api/admin/nav-categories/:id', async (req: Request, res: Response)
|
||||
return res.status(404).json({ code: 40400, message: '品类导航不存在或已被删除' });
|
||||
}
|
||||
|
||||
invalidateNavCategoriesCache();
|
||||
res.json({ code: 20000, data: null, message: '品类导航已删除' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
@@ -2206,7 +2262,12 @@ app.delete('/api/admin/coupons/:id', async (req: Request, res: Response) => {
|
||||
// 移动端:获取全部有效系统通用配置
|
||||
app.get('/api/system/configs', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (cachedPlatformConfigs && now < cachedPlatformConfigs.expireAt) {
|
||||
return res.json({ code: 20000, data: cachedPlatformConfigs.data });
|
||||
}
|
||||
const configs = await repo.getPlatformConfigs();
|
||||
cachedPlatformConfigs = { data: configs, expireAt: now + STATIC_CACHE_TTL_MS };
|
||||
res.json({ code: 20000, data: configs });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ code: 50000, message: err.message });
|
||||
@@ -2285,6 +2346,7 @@ app.put('/api/admin/system/configs/:key', async (req: Request, res: Response) =>
|
||||
contentSecurityService.setCustomKeywords(valueJson);
|
||||
}
|
||||
|
||||
invalidatePlatformConfigsCache();
|
||||
res.json({ code: 20000, data: updated, message: '系统配置更新成功' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
@@ -2620,6 +2682,7 @@ app.post('/api/admin/pay/wechat/config', async (req: Request, res: Response) =>
|
||||
valueJson: payload
|
||||
});
|
||||
|
||||
invalidatePlatformConfigsCache();
|
||||
const latest = await resolveWechatPayRuntimeConfig();
|
||||
res.json({
|
||||
code: 20000,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 第一百二十批闸门:小程序与服务端全栈性能深度优化与稳定性守护门禁
|
||||
*/
|
||||
describe('第一百二十批全栈性能深度优化与稳定性守护门禁', () => {
|
||||
const rootDir = path.resolve(__dirname, '../../');
|
||||
const repoPath = path.join(rootDir, 'server/src/db/repository.ts');
|
||||
const indexPath = path.join(rootDir, 'server/src/index.ts');
|
||||
const privacyModalPath = path.join(rootDir, 'pwuniapp/components/PrivacyModal.vue');
|
||||
|
||||
it('repository.ts 中 getActivities 必须带 WHERE p.activity_id IN (?) 精确范围查询,杜绝全表扫描 participants', () => {
|
||||
const content = fs.readFileSync(repoPath, 'utf-8');
|
||||
expect(content).toContain('WHERE p.activity_id IN (?)');
|
||||
// 确保没有直接从 participants 无条件 SELECT *
|
||||
expect(content).not.toMatch(/FROM participants p\s+LEFT JOIN users u\s*`\s*\);/);
|
||||
});
|
||||
|
||||
it('repository.ts 中 getMyActivities 必须通过索引查用户参与和发起的活动,禁止全量查出后内存 filter', () => {
|
||||
const content = fs.readFileSync(repoPath, 'utf-8');
|
||||
expect(content).toContain('SELECT DISTINCT activity_id FROM participants WHERE user_id = ?');
|
||||
expect(content).toContain('this.getActivities({ creatorId: uid })');
|
||||
expect(content).not.toContain('allActivities.filter((a: any) => Number(a.creatorId) === Number(userId))');
|
||||
});
|
||||
|
||||
it('repository.ts 中 getPosts 必须将点赞与报名状态限定在当前页 post_id IN (?) 范围内', () => {
|
||||
const content = fs.readFileSync(repoPath, 'utf-8');
|
||||
expect(content).toContain('SELECT post_id FROM post_likes WHERE user_id = ? AND post_id IN (?)');
|
||||
expect(content).toContain('SELECT post_id, applicant_id, status FROM post_applies WHERE post_id IN (?)');
|
||||
});
|
||||
|
||||
it('高频查询业务函数中严禁在每次读写请求中执行 ALTER TABLE 产生独占锁,建表必须有单例初始化防护', () => {
|
||||
const content = fs.readFileSync(repoPath, 'utf-8');
|
||||
// 确保 getCompanions, applyCompanion, auditCompanion, getOrderReviews, replyOrderReview 中不包含 ALTER TABLE
|
||||
const getCompanionsSection = content.substring(content.indexOf('async getCompanions('), content.indexOf('async getCompanionById('));
|
||||
expect(getCompanionsSection).not.toContain('ALTER TABLE');
|
||||
|
||||
const getOrderReviewsSection = content.substring(content.indexOf('async getOrderReviews('), content.indexOf('async getOrderReviewById('));
|
||||
expect(getOrderReviewsSection).not.toContain('ALTER TABLE');
|
||||
|
||||
// 确保 ensureExpertServicesTable 具备静态单例标志防护
|
||||
expect(content).toContain('private static expertServicesTableInitialized = false');
|
||||
expect(content).toContain('if (MysqlRepository.expertServicesTableInitialized) return');
|
||||
});
|
||||
|
||||
it('服务端必须包含定时 sweep 淘汰器,防止防爆破与缓存 Map 无界内存泄漏', () => {
|
||||
const content = fs.readFileSync(indexPath, 'utf-8');
|
||||
expect(content).toContain('securitySweepTimer');
|
||||
expect(content).toContain('loginFailureMap.delete(k)');
|
||||
expect(content).toContain('geocodeCache.delete(k)');
|
||||
});
|
||||
|
||||
it('服务端高频静态读接口必须提供内存 TTL 缓存及写时主动淘汰机制', () => {
|
||||
const content = fs.readFileSync(indexPath, 'utf-8');
|
||||
expect(content).toContain('STATIC_CACHE_TTL_MS');
|
||||
expect(content).toContain('cachedBanners');
|
||||
expect(content).toContain('cachedNavCategories');
|
||||
expect(content).toContain('cachedPlatformConfigs');
|
||||
expect(content).toContain('invalidateBannersCache()');
|
||||
expect(content).toContain('invalidateNavCategoriesCache()');
|
||||
expect(content).toContain('invalidatePlatformConfigsCache()');
|
||||
});
|
||||
|
||||
it('小程序前端 PrivacyModal 组件必须在 onUnmounted 中成对使用 uni.$off 注销全局事件监听', () => {
|
||||
const content = fs.readFileSync(privacyModalPath, 'utf-8');
|
||||
expect(content).toContain("uni.$on('theme-change', onThemeChanged)");
|
||||
expect(content).toContain("uni.$off('theme-change', onThemeChanged)");
|
||||
expect(content).toContain('onUnmounted');
|
||||
});
|
||||
|
||||
it('本次性能优化涉及的文件与新增测试严禁引入任何系统 Emoji', () => {
|
||||
const emojiRegex = /[\u{1F300}-\u{1F5FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F900}-\u{1F9FF}\u{1F018}-\u{1F270}]/u;
|
||||
const filesToCheck = [repoPath, indexPath, privacyModalPath, __filename];
|
||||
for (const f of filesToCheck) {
|
||||
const text = fs.readFileSync(f, 'utf-8');
|
||||
expect(emojiRegex.test(text)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户