From f082ae82768757538f5bbc4a1240c8c31bc8f52c Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Fri, 23 Oct 2026 09:05:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(theme):=20=E4=BF=AE=E5=A4=8D=E9=97=A8?= =?UTF-8?q?=E7=A5=A8=E9=A1=B5=E5=AF=BC=E8=88=AA=E6=A0=8F=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E8=84=B1=E8=8A=82=E4=B8=8E=E4=B8=AA=E4=BA=BA=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E5=8F=8C=E8=BA=AB=E4=BB=BD=E5=8D=A1=E7=89=87=E6=B5=81=E5=85=89?= =?UTF-8?q?=E5=8A=A8=E7=94=BB=EF=BC=8C=E5=8A=A0=E5=9B=BA=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E5=8F=82=E4=B8=8E=E4=BA=BA=E7=94=BB=E5=83=8F=E4=B8=8E=E5=A4=A7?= =?UTF-8?q?=E7=A5=9E=E9=9A=90=E7=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pwuniapp/common/api.js | 6 +-- pwuniapp/pages/activity/detail.vue | 20 ++++++- pwuniapp/pages/activity/list.vue | 25 +++++++-- pwuniapp/pages/expert/hall.vue | 36 ++++++++++--- pwuniapp/pages/expert/workspace.vue | 7 +++ pwuniapp/pages/index/index.vue | 2 +- server/src/db/mysql.ts | 16 ++++++ server/src/db/repository.ts | 53 ++++++++++++------- server/src/index.ts | 5 +- server/tests/companion-detail-privacy.spec.ts | 2 +- .../companions-directory-privacy.spec.ts | 2 +- server/tests/expert-stats-scope.spec.ts | 2 + server/tests/lbs-match.spec.ts | 9 ++++ server/tests/read-scope.spec.ts | 2 + 14 files changed, 147 insertions(+), 40 deletions(-) diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index 99336aa..584f7ca 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -38,7 +38,8 @@ export function formatActivityData(raw) { startTime: raw.startTime || '', location: raw.location || '', creatorId: raw.creatorId != null ? Number(raw.creatorId) : (raw.creator_id != null ? Number(raw.creator_id) : 0), - creatorNickname: raw.creatorNickname || '', + // 创建人已被删除时给出真实状态,避免列表显示空白局长 + creatorNickname: raw.creatorNickname || '局长已注销', creatorAvatar: raw.creatorAvatar || '', status: raw.status || (joinedCount >= (raw.maxParticipants || 4) ? 'FULL' : 'RECRUITING'), allowDynamicPrice: Boolean(raw.allowDynamicPrice), @@ -236,7 +237,7 @@ export async function getCompanions(keyword) { price: Number(c.hourlyRate) || 0, rating: Number(c.rating) || 0, orderCount: Number(c.orderCount) || 0, - isOnline: c.status === '接单中', + isOnline: c.isOnline !== undefined ? Boolean(c.isOnline) : c.status === '接单中', voiceDuration: Number(c.voiceDuration) || 0, voiceType: 'voice', voiceTitle: c.voiceTitle || '', @@ -267,7 +268,6 @@ export async function matchCompanions(options = {}) { const list = Array.isArray(res) ? res : (res && Array.isArray(res.list) ? res.list : []); return list.map((c) => ({ id: String(c.id), - userId: Number(c.userId) || 0, nickname: c.nickname || '', avatar: c.avatar || '', gender: c.gender === '男' ? 'male' : (c.gender === '女' ? 'female' : ''), diff --git a/pwuniapp/pages/activity/detail.vue b/pwuniapp/pages/activity/detail.vue index 906fe40..e8d2da7 100644 --- a/pwuniapp/pages/activity/detail.vue +++ b/pwuniapp/pages/activity/detail.vue @@ -72,7 +72,10 @@ 组局阵地与发起人 - + + + ? + {{ activity.creatorNickname }} @@ -1147,6 +1150,21 @@ const handleJoin = () => { height: 80rpx; border-radius: 40rpx; margin-right: 16rpx; + flex-shrink: 0; + } + + .creator-avatar-empty { + display: flex; + align-items: center; + justify-content: center; + background: rgba(148, 163, 184, 0.18); + border: 1px solid rgba(148, 163, 184, 0.3); + + .creator-avatar-placeholder { + font-size: 30rpx; + color: #94A3B8; + font-weight: 700; + } } .creator-info { diff --git a/pwuniapp/pages/activity/list.vue b/pwuniapp/pages/activity/list.vue index 1289ef9..a3b2e28 100644 --- a/pwuniapp/pages/activity/list.vue +++ b/pwuniapp/pages/activity/list.vue @@ -140,7 +140,10 @@ - + + + ? + 局长:{{ act.creatorNickname }} @@ -346,9 +349,9 @@ onUnmounted(() => { }); const getCategoryTagClass = (game) => { - if (game === '美食陪吃') return 'tag-meal'; - if (game === '同城陪唱') return 'tag-sing'; - if (game === '下午茶陪喝' || game === '同城微醺') return 'tag-drink'; + if (game === '美食陪吃' || game === '美食探店') return 'tag-meal'; + if (game === '同城陪唱' || game === 'KTV嗨唱') return 'tag-sing'; + if (game === '下午茶陪喝' || game === '下午茶歇' || game === '同城微醺') return 'tag-drink'; return ''; }; @@ -802,6 +805,20 @@ const createActivity = () => { height: 48rpx; border-radius: 24rpx; margin-right: 12rpx; + flex-shrink: 0; + } + .creator-avatar-empty { + display: flex; + align-items: center; + justify-content: center; + background: rgba(148, 163, 184, 0.18); + border: 1px solid rgba(148, 163, 184, 0.3); + + .creator-avatar-placeholder { + font-size: 22rpx; + color: #94A3B8; + font-weight: 700; + } } .creator-name { font-size: 22rpx; diff --git a/pwuniapp/pages/expert/hall.vue b/pwuniapp/pages/expert/hall.vue index 8d3dddd..fef51f2 100644 --- a/pwuniapp/pages/expert/hall.vue +++ b/pwuniapp/pages/expert/hall.vue @@ -251,7 +251,7 @@ import AppTabBar from '@/components/CustomTabBar.vue'; import { ICONS } from '@/common/icons.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { isExpertCertified } from '@/common/role.js'; -import { claimOrder, getOrdersPool, getExpertStats, updateCompanionProfile, getUserProfile, getActivities, normalizeUserId } from '@/common/api.js'; +import { claimOrder, getOrdersPool, getExpertStats, updateCompanionProfile, getUserProfile, getActivities, getMyExpertProfile, normalizeUserId } from '@/common/api.js'; import { getUserLocation } from '@/common/location.js'; import { startHeartbeat, stopHeartbeat } from '@/common/expert-heartbeat.js'; import { getNavBarInfo } from '@/common/nav.js'; @@ -363,6 +363,15 @@ const loadHallData = async () => { } }).catch(() => {}), + getMyExpertProfile().then((profile) => { + if (profile && profile.status) { + isOnline.value = profile.status === '接单中'; + uni.setStorageSync('EXPERT_RECEIVING_STATUS', isOnline.value ? '1' : '0'); + if (isOnline.value) startHeartbeat(); + else stopHeartbeat(); + } + }).catch(() => {}), + getOrdersPool(myCoords).then((pool) => { if (Array.isArray(pool)) { orders.value = pool.map(p => { @@ -475,26 +484,37 @@ const filteredOrders = computed(() => { }); const toggleOnlineStatus = async () => { - isOnline.value = !isOnline.value; - uni.setStorageSync('EXPERT_RECEIVING_STATUS', isOnline.value ? '1' : '0'); const eid = Number(expert.value.id) || normalizeUserId(); if (!eid) { uni.showToast({ title: '请先登录后再操作', icon: 'none' }); return; } + const next = !isOnline.value; + isOnline.value = next; + uni.setStorageSync('EXPERT_RECEIVING_STATUS', next ? '1' : '0'); try { await updateCompanionProfile(eid, { - status: isOnline.value ? '接单中' : '休息中' + status: next ? '接单中' : '休息中' }); - } catch (_) {} - if (isOnline.value) { + } catch (err) { + // 服务端失败必须回滚本地状态,避免界面显示在线但实际不接单 + isOnline.value = !next; + uni.setStorageSync('EXPERT_RECEIVING_STATUS', !next ? '1' : '0'); + stopHeartbeat(); + uni.showToast({ + title: (err && err.message) || '接单状态切换失败,请稍后重试', + icon: 'none' + }); + return; + } + if (next) { startHeartbeat(); } else { stopHeartbeat(); } uni.showToast({ - title: isOnline.value ? '已开启在线接单' : '已设置为打烊休息', - icon: isOnline.value ? 'success' : 'none', + title: next ? '已开启在线接单' : '已设置为打烊休息', + icon: next ? 'success' : 'none', duration: 1500 }); }; diff --git a/pwuniapp/pages/expert/workspace.vue b/pwuniapp/pages/expert/workspace.vue index ccb3b51..efb54fd 100644 --- a/pwuniapp/pages/expert/workspace.vue +++ b/pwuniapp/pages/expert/workspace.vue @@ -562,6 +562,13 @@ const loadExpertProfile = async () => { voiceIntroUrl: profile.voiceIntroUrl || '', voiceDuration: Number(profile.voiceDuration) || 0 }; + // 以服务端接单状态为准,避免本地缓存显示在线但实际已休息 + if (profile.status) { + isReceivingOrders.value = profile.status === '接单中'; + uni.setStorageSync('EXPERT_RECEIVING_STATUS', isReceivingOrders.value ? '1' : '0'); + if (isReceivingOrders.value) startHeartbeat(); + else stopHeartbeat(); + } } } catch (_) {} }; diff --git a/pwuniapp/pages/index/index.vue b/pwuniapp/pages/index/index.vue index c08e012..f1f2bc6 100644 --- a/pwuniapp/pages/index/index.vue +++ b/pwuniapp/pages/index/index.vue @@ -70,7 +70,7 @@ - 精选在线大神 + 精选大神 diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index 4f632f5..694be64 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -836,6 +836,22 @@ export async function initDatabase(): Promise { // 检查并插入初始种子数据(若表为空) await seedInitialDataIfEmpty(conn); + // 数据修复:历史订单 expert_id 指向已不存在的档案(如 101)时,按 companion_name 回填真实档案 id + // 避免完单结算时按旧的 101→1 兼容映射把钱结算给错误的大神 + // 放在种子数据之后执行,保证全新数据库的种子订单也能被修复 + try { + await conn.query(` + UPDATE orders o + JOIN companions c ON c.name = o.companion_name + SET o.expert_id = c.id + WHERE o.expert_id >= 100 AND o.expert_id < 200 + AND o.expert_id NOT IN (SELECT id FROM companions) + AND o.companion_name IS NOT NULL AND o.companion_name <> '' + `); + } catch (_) { + // 表不存在或数据不满足条件:忽略 + } + console.log('[MySQL] peiwan 数据库核心表及种子数据已完整就绪'); } finally { conn.release(); diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index 5d7a7b2..cf02c20 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -155,11 +155,13 @@ export class MysqlRepository { const [ptRows]: any = await pool.query(` SELECT - id, activity_id as activityId, user_id as userId, - pay_amount_cents as payAmountCents, verify_code as verifyCode, - checkin_status as checkinStatus, verified_at as verifiedAt, - verified_by as verifiedBy, created_at as createdAt - FROM participants + 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 `); return actRows.map((act: any) => { @@ -172,6 +174,8 @@ export class MysqlRepository { 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, @@ -220,13 +224,15 @@ export class MysqlRepository { const [ptRows]: any = await pool.query(` SELECT - id, activity_id as activityId, user_id as userId, - pay_amount_cents as payAmountCents, verify_code as verifyCode, - checkin_status as checkinStatus, verified_at as verifiedAt, - verified_by as verifiedBy, created_at as createdAt - FROM participants - WHERE activity_id = ? - ORDER BY created_at ASC + 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 = ? + ORDER BY p.created_at ASC `, [id]); return { @@ -872,7 +878,8 @@ export class MysqlRepository { id, user_id as userId, name, avatar, gender, age, track, rank_name as \`rank\`, hourly_rate as hourlyRate, voice_duration as voiceDuration, voice_title as voiceTitle, voice_intro_url as voiceIntroUrl, rating, order_count as orderCount, - status, audit_status as auditStatus, COALESCE(reject_reason, '') as rejectReason, + status, (status = '接单中' AND last_active_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE)) as isOnline, + audit_status as auditStatus, COALESCE(reject_reason, '') as rejectReason, id_card_masked as idCardMasked, tags, apply_time as applyTime FROM companions `; @@ -893,6 +900,7 @@ export class MysqlRepository { } catch (_) {} return { ...r, + isOnline: Boolean(r.isOnline), tags: tagsArr }; }); @@ -914,7 +922,11 @@ export class MysqlRepository { [nLat, nLng, id] ); } else { - await pool.query('UPDATE companions SET last_active_at = NOW() WHERE id = ?', [id]); + // 本次没有有效定位:清空旧坐标,避免用过期位置参与 5km 匹配 + await pool.query( + 'UPDATE companions SET last_active_at = NOW(), latitude = NULL, longitude = NULL WHERE id = ?', + [id] + ); } } @@ -961,7 +973,6 @@ export class MysqlRepository { const distanceKm = hasFrom && hasGeo ? haversineKm(fromLat, fromLng, lat as number, lng as number) : null; return { id: String(r.id), - userId: Number(r.userId) || 0, nickname: r.name || '', avatar: r.avatar || '', gender: r.gender || '', @@ -1622,8 +1633,8 @@ export class MysqlRepository { FROM orders WHERE ( buyer_id = ? - OR expert_id = ? OR claimed_by = ? + OR expert_id IN (SELECT id FROM companions WHERE user_id = ?) OR companion_name IN (SELECT name FROM companions WHERE user_id = ?) ) AND ${statusWhere} @@ -1642,8 +1653,8 @@ export class MysqlRepository { FROM orders WHERE ( buyer_id = ? - OR expert_id = ? OR claimed_by = ? + OR expert_id IN (SELECT id FROM companions WHERE user_id = ?) OR companion_name IN (SELECT name FROM companions WHERE user_id = ?) ) AND ${statusWhere} @@ -2359,7 +2370,11 @@ export class MysqlRepository { if (raw <= 0) return { companionId: 0, userId: 0 }; const comp = await this.resolveCompanionRow(raw); if (comp) { - return { companionId: Number(comp.id) || raw, userId: Number(comp.user_id) || raw }; + // 未绑定登录用户的种子档案不能把「档案 id」当「用户 id」去匹配 claimed_by,避免 id 撞号 + return { + companionId: Number(comp.id) || raw, + userId: comp.user_id != null ? Number(comp.user_id) : 0, + }; } return { companionId: raw, userId: raw }; } @@ -2394,7 +2409,7 @@ export class MysqlRepository { }> { const scope = await this.resolveExpertOrderScope(expertId); const eId = scope.companionId || scope.userId; - const eUid = scope.userId || scope.companionId; + const eUid = scope.userId; const now = new Date(); // 1. 近 7 天走势 diff --git a/server/src/index.ts b/server/src/index.ts index 55deabd..3623d9b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1276,7 +1276,8 @@ app.get('/api/companions', async (req: Request, res: Response) => { const publicList = companions .filter((c: any) => c.auditStatus === '已通过') .map((c: any) => { - const { rejectReason, ...rest } = c; + // 公开目录不返回内部审核备注、用户 id 与证件脱敏串 + const { rejectReason, userId, idCardMasked, ...rest } = c; return rest; }); res.json({ @@ -1360,7 +1361,7 @@ app.get('/api/companions/:id', async (req: Request, res: Response) => { if (isVerifiedAdminRequest(req)) { return res.json({ code: 20000, data: companion }); } - const { rejectReason, ...publicCompanion } = companion; + const { rejectReason, userId, idCardMasked, ...publicCompanion } = companion; res.json({ code: 20000, data: publicCompanion diff --git a/server/tests/companion-detail-privacy.spec.ts b/server/tests/companion-detail-privacy.spec.ts index b9549e8..36dc7d9 100644 --- a/server/tests/companion-detail-privacy.spec.ts +++ b/server/tests/companion-detail-privacy.spec.ts @@ -14,6 +14,6 @@ describe('第三十一批大神详情隐私闸门', () => { expect(start).toBeGreaterThan(-1); const block = indexCode.slice(start, start + 800); expect(block).toContain('isVerifiedAdminRequest(req)'); - expect(block).toContain('const { rejectReason, ...publicCompanion } = companion;'); + expect(block).toContain('const { rejectReason, userId, idCardMasked, ...publicCompanion } = companion;'); }); }); diff --git a/server/tests/companions-directory-privacy.spec.ts b/server/tests/companions-directory-privacy.spec.ts index 2537cc5..23c5f2b 100644 --- a/server/tests/companions-directory-privacy.spec.ts +++ b/server/tests/companions-directory-privacy.spec.ts @@ -15,6 +15,6 @@ describe('第三十批大神目录隐私闸门', () => { const block = indexCode.slice(start, start + 900); expect(block).toContain('isVerifiedAdminRequest(req)'); expect(block).toContain("c.auditStatus === '已通过'"); - expect(block).toContain('const { rejectReason, ...rest } = c;'); + expect(block).toContain('const { rejectReason, userId, idCardMasked, ...rest } = c;'); }); }); diff --git a/server/tests/expert-stats-scope.spec.ts b/server/tests/expert-stats-scope.spec.ts index 67cd005..5abccbc 100644 --- a/server/tests/expert-stats-scope.spec.ts +++ b/server/tests/expert-stats-scope.spec.ts @@ -19,6 +19,8 @@ describe('第五十批大神收益统计范围闸门', () => { const stats = repoCode.slice(start, start + 1600); expect(stats).toContain('resolveExpertOrderScope'); expect(stats).toContain('(expert_id = ? OR claimed_by = ?)'); + // 未绑定用户的种子档案不得把档案 id 冒充用户 id 去匹配 claimed_by + expect(repoCode).toContain('userId: comp.user_id != null ? Number(comp.user_id) : 0'); const trends = repoCode.slice(repoCode.indexOf('async getExpertRevenueTrends(')); expect(trends.slice(0, 6000)).toContain('(expert_id = ? OR claimed_by = ?)'); }); diff --git a/server/tests/lbs-match.spec.ts b/server/tests/lbs-match.spec.ts index e6d0880..6b001aa 100644 --- a/server/tests/lbs-match.spec.ts +++ b/server/tests/lbs-match.spec.ts @@ -41,6 +41,15 @@ describe('大神 LBS 即时匹配与接单心跳', () => { expect(repo).toContain('async updateCompanionHeartbeat('); // 接单中切换时立即刷新活跃时间 expect(repo).toContain("fields.push('last_active_at = NOW()')"); + // 无定位心跳必须清空旧坐标,避免过期位置继续参与 5km 匹配 + expect(repo).toContain('latitude = NULL, longitude = NULL'); + // 列表在线状态同样以心跳为准 + expect(repo).toContain("(status = '接单中' AND last_active_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE)) as isOnline"); + }); + + it('历史 expert_id=101 等失效订单必须按大神名称回填真实档案 id', () => { + expect(mysql).toContain('o.expert_id >= 100 AND o.expert_id < 200'); + expect(mysql).toContain('JOIN companions c ON c.name = o.companion_name'); }); it('匹配接口必须在详情路由之前注册,心跳接口必须鉴权', () => { diff --git a/server/tests/read-scope.spec.ts b/server/tests/read-scope.spec.ts index 03634c7..a85722b 100644 --- a/server/tests/read-scope.spec.ts +++ b/server/tests/read-scope.spec.ts @@ -24,6 +24,8 @@ describe('第十二批读接口数据隔离闸门 (订单 / 我的组局 / 分 const block = repoCode.slice(start, start + 2200); expect(block).toContain('buyer_id = ?'); expect(block).toContain('claimed_by = ?'); + expect(block).toContain('expert_id IN (SELECT id FROM companions WHERE user_id = ?)'); + expect(block).not.toContain('OR expert_id = ?'); expect(block).toContain('companion_name IN (SELECT name FROM companions'); // 不得用「用户 id == 档案 id」做兜底归属,避免 id 撞号导致越权看到他人订单 expect(block).not.toContain('WHERE user_id = ? OR id = ?');