feat(lbs): 大神真实 5km 即时匹配与接单心跳

- companions 新增 latitude/longitude/last_active_at 与 idx_comp_active;切换接单中立即刷新活跃时间

- 新增 /api/companions/heartbeat(鉴权)与 /api/companions/match(真实注册+审核通过+接单中+10分钟心跳,Haversine 5km 过滤与距离排序,不返回精确坐标)

- 工作台/接单大厅/雷达前台每 60s 心跳,离开停止;首页即时匹配改为服务端 LBS 匹配并展示真实距离

- 线上实测:2010 匹配排除自己,仅命中 280m 的 8 号;匿名命中 9 号 10m、8 号 280m;北京 5km 为 0;118 套件/771 用例全绿
这个提交包含在:
xcbyy
2026-10-22 18:44:02 +08:00
父节点 b573c1ed05
当前提交 001f81eaca
共有 11 个文件被更改,包括 425 次插入 和 19 次删除

查看文件

@@ -249,6 +249,59 @@ export async function getCompanions(keyword) {
return [];
}
/**
* 即时匹配在线大神:服务端按真实注册、审核通过、10 分钟心跳在线过滤;
* 传入定位时按 5km 距离过滤并升序返回(只返回距离,不返回精确坐标)。
*/
export async function matchCompanions(options = {}) {
try {
const qs = [];
const lat = Number(options.lat);
const lng = Number(options.lng);
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
qs.push(`lat=${lat}`, `lng=${lng}`);
}
qs.push(`radius=${Math.min(50, Math.max(1, Number(options.radius) || 5))}`);
qs.push(`limit=${Math.min(20, Math.max(1, Number(options.limit) || 10))}`);
const res = await http.get(`/companions/match?${qs.join('&')}`);
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' : ''),
age: Number(c.age) || 0,
game: c.track === 'OFFLINE_MEAL' ? '美食陪吃'
: (c.track === 'OFFLINE_SING' ? '同城陪唱'
: (c.track === 'OFFLINE_DRINK' ? '下午茶陪喝' : (c.track || ''))),
track: c.track || '',
rankTag: c.rankTag || '',
price: Number(c.price) || 0,
rating: Number(c.rating) || 0,
orderCount: Number(c.orderCount) || 0,
isOnline: Boolean(c.isOnline),
distanceKm: c.distanceKm === null || c.distanceKm === undefined ? null : Number(c.distanceKm),
distanceText: c.distanceText || '同城',
}));
} catch (err) {
console.warn('[API] 即时匹配大神异常:', err.message);
return [];
}
}
/** 大神接单心跳:刷新在线状态;定位失败时仍上报活跃时间 */
export async function sendCompanionHeartbeat({ lat, lng } = {}) {
const payload = {};
const nLat = Number(lat);
const nLng = Number(lng);
if (Number.isFinite(nLat) && Number.isFinite(nLng) && !(nLat === 0 && nLng === 0)) {
payload.lat = nLat;
payload.lng = nLng;
}
return http.post('/companions/heartbeat', payload);
}
/**
* 获取单个大神详情 (真实从 MySQL 读取)
*/

查看文件

@@ -0,0 +1,61 @@
// 大神接单心跳:工作台/接单大厅/雷达页在前台时每 60 秒上报一次
// 服务端以 last_active_at 判断是否真实在线(10 分钟无心跳自动移出匹配池)
import { getUserLocation } from './location.js';
import { sendCompanionHeartbeat, normalizeUserId } from './api.js';
let timer = null;
let inFlight = false;
let lastSentAt = 0;
let coords = null;
let coordsAt = 0;
const COORD_TTL_MS = 5 * 60 * 1000;
async function resolveCoords() {
if (coords && Date.now() - coordsAt < COORD_TTL_MS) return coords;
try {
const pos = await getUserLocation();
coords = { lat: pos.latitude, lng: pos.longitude };
coordsAt = Date.now();
return coords;
} catch (_) {
// 用户拒绝定位时仍上报活跃时间,只是没有距离信息
return coords;
}
}
export async function sendHeartbeat(force = false) {
if (inFlight) return;
if (!force && Date.now() - lastSentAt < 45 * 1000) return;
if (!normalizeUserId()) return;
inFlight = true;
try {
const pos = await resolveCoords();
await sendCompanionHeartbeat(pos || {});
lastSentAt = Date.now();
} catch (_) {
} finally {
inFlight = false;
}
}
export function startHeartbeat(intervalMs = 60 * 1000) {
if (timer) return;
void sendHeartbeat(true);
timer = setInterval(() => {
void sendHeartbeat();
}, Math.max(30 * 1000, Number(intervalMs) || 60 * 1000));
if (timer && typeof timer.unref === 'function') timer.unref();
}
export function stopHeartbeat() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
export function resetHeartbeatLocation() {
coords = null;
coordsAt = 0;
}

查看文件

@@ -253,6 +253,7 @@ 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 { getUserLocation } from '@/common/location.js';
import { startHeartbeat, stopHeartbeat } from '@/common/expert-heartbeat.js';
import { getNavBarInfo } from '@/common/nav.js';
const currentThemeKey = ref(getCurrentThemeKey());
@@ -436,15 +437,19 @@ onShow(() => {
uni.hideTabBar({ animation: false, fail: () => {} });
loadHallData();
startHallPolling();
if (isOnline.value) startHeartbeat();
else stopHeartbeat();
});
onHide(() => {
stopHallPolling();
stopHeartbeat();
});
onUnmounted(() => {
uni.$off('theme-change', onThemeChanged);
stopHallPolling();
stopHeartbeat();
});
@@ -482,6 +487,11 @@ const toggleOnlineStatus = async () => {
status: isOnline.value ? '接单中' : '休息中'
});
} catch (_) {}
if (isOnline.value) {
startHeartbeat();
} else {
stopHeartbeat();
}
uni.showToast({
title: isOnline.value ? '已开启在线接单' : '已设置为打烊休息',
icon: isOnline.value ? 'success' : 'none',

查看文件

@@ -191,6 +191,7 @@ import { isExpertCertified } from '@/common/role.js';
import { getNavBarInfo } from '@/common/nav.js';
import { getOrdersPool, claimOrder, reverseGeocode } from '@/common/api.js';
import { getUserLocation } from '@/common/location.js';
import { startHeartbeat, stopHeartbeat } from '@/common/expert-heartbeat.js';
const navInfo = ref(getNavBarInfo());
const navStyle = computed(() => ({
@@ -350,15 +351,19 @@ onShow(() => {
uni.hideTabBar({ animation: false, fail: () => {} });
refreshLocation();
startPolling();
if (isRadarActive.value) startHeartbeat();
else stopHeartbeat();
});
onHide(() => {
stopPolling();
stopHeartbeat();
});
onUnmounted(() => {
uni.$off('theme-change', onThemeChanged);
stopPolling();
stopHeartbeat();
});
@@ -389,8 +394,10 @@ const toggleRadarSwitch = (e) => {
isRadarActive.value = e.detail.value;
if (isRadarActive.value) {
startPolling();
startHeartbeat();
} else {
stopPolling();
stopHeartbeat();
}
uni.showToast({
title: isRadarActive.value ? '已开启 5km 智能巡航接单' : '已挂起智能巡航',

查看文件

@@ -464,7 +464,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { onShow, onHide } from '@dcloudio/uni-app';
import { ICONS } from '@/common/icons.js';
import { openSupportSheet } from '@/common/support.js';
import { isExpertCertified } from '@/common/role.js';
@@ -491,6 +491,7 @@ import {
getUserWithdrawals,
normalizeUserId
} from '@/common/api.js';
import { startHeartbeat, stopHeartbeat } from '@/common/expert-heartbeat.js';
const currentThemeKey = ref(getCurrentThemeKey());
@@ -671,6 +672,7 @@ const playVoice = () => {
onUnmounted(() => {
uni.$off('theme-change', onThemeChanged);
stopHeartbeat();
stopVoiceTimer();
try { if (recorderManager && voiceRecording.value) recorderManager.stop(); } catch (_) {}
try { if (voiceAudioCtx) { voiceAudioCtx.destroy(); voiceAudioCtx = null; } } catch (_) {}
@@ -809,6 +811,11 @@ const toggleReceivingStatus = async () => {
}
isReceivingOrders.value = next;
uni.setStorageSync('EXPERT_RECEIVING_STATUS', next ? '1' : '0');
if (next) {
startHeartbeat();
} else {
stopHeartbeat();
}
uni.showToast({
title: next ? '已开启在线接单通道!' : '已打烊停止接单',
icon: 'none'
@@ -899,6 +906,7 @@ onMounted(() => {
uni.$on('theme-change', onThemeChanged);
if (isExpertCertified()) {
loadWorkspaceOrders();
if (isReceivingOrders.value) startHeartbeat();
}
});
@@ -917,9 +925,15 @@ onShow(() => {
return;
}
isReceivingOrders.value = uni.getStorageSync('EXPERT_RECEIVING_STATUS') !== '0';
if (isReceivingOrders.value) startHeartbeat();
else stopHeartbeat();
loadWorkspaceOrders();
});
onHide(() => {
stopHeartbeat();
});
const verifyInputCode = async () => {
const codeStr = inputDigits.value.join('');
uni.showLoading({ title: '核验核销码中...' });

查看文件

@@ -246,7 +246,7 @@
{{ matchedExpert.isOnline ? '接单中' : '休息中' }}
</text>
</view>
<text class="matched-game">{{ matchedExpert.game }} · {{ matchedExpert.rankTag }}</text>
<text class="matched-game">{{ matchedMeta }}</text>
<text class="matched-price">¥{{ matchedExpert.price }}/小时</text>
</view>
</view>
@@ -264,7 +264,7 @@
<!-- 无在线大神匹配 -->
<view class="match-empty-box" v-else>
<text class="empty-txt">当前暂无空闲在线大神,请稍后再试</text>
<text class="empty-txt">暂无可匹配的接单大神,请稍后再试</text>
<button class="btn-match-action primary btn-active" @click="closeMatchModal">返回大厅</button>
</view>
</view>
@@ -280,7 +280,8 @@ import AppTabBar from '@/components/CustomTabBar.vue';
import SkeletonScreen from '@/components/SkeletonScreen.vue';
import PrivacyModal from '@/components/PrivacyModal.vue';
import HomePopupModal from '@/components/HomePopupModal.vue';
import { getCompanions, getBanners, getNavCategories, bindInviteCode, normalizeUserId } from '@/common/api.js';
import { getCompanions, matchCompanions, getBanners, getNavCategories, bindInviteCode } from '@/common/api.js';
import { getUserLocation } from '@/common/location.js';
import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
import { getNavBarInfo } from '@/common/nav.js';
@@ -596,35 +597,45 @@ const startInstantMatch = async () => {
if (matchTimer) clearTimeout(matchTimer);
const startedAt = Date.now();
let freshList = null;
let pos = null;
try {
freshList = await getCompanions();
const loc = await getUserLocation();
pos = { lat: loc.latitude, lng: loc.longitude };
} catch (_) {
// 未授权定位时仍可匹配在线大神,只是不展示距离
}
let freshList = [];
try {
freshList = await matchCompanions({ ...(pos || {}), radius: 5, limit: 20 });
} catch (_) {}
if (seq !== matchSeq) return;
if (Array.isArray(freshList) && freshList.length > 0) {
experts.value = freshList;
}
const wait = Math.max(0, 600 - (Date.now() - startedAt));
matchTimer = setTimeout(() => {
if (seq !== matchSeq) return;
isMatching.value = false;
const uid = Number(normalizeUserId()) || 0;
const eligible = (experts.value || []).filter(
(e) => e.isOnline && Number(e.userId) > 0 && Number(e.userId) !== uid
);
const rotated = eligible.filter((e) => String(e.id) !== String(lastMatchedId));
const pool = rotated.length > 0 ? rotated : eligible;
const rotated = freshList.filter((e) => String(e.id) !== String(lastMatchedId));
const pool = rotated.length > 0 ? rotated : freshList;
if (pool.length === 0) {
matchedExpert.value = null;
return;
}
const picked = pool[Math.floor(Math.random() * pool.length)];
// 服务端已按真实距离升序;换一位取下一位最近的大神
const picked = pool[0];
matchedExpert.value = picked;
lastMatchedId = String(picked.id);
}, wait);
};
const matchedMeta = computed(() => {
const e = matchedExpert.value;
if (!e) return '';
const parts = [e.game, e.rankTag].filter(Boolean);
if (e.distanceText && e.distanceText !== '同城') parts.push(`距你 ${e.distanceText}`);
return parts.join(' · ');
});
const closeMatchModal = () => {
showMatchModal.value = false;
matchSeq += 1;