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 用例全绿
这个提交包含在:
@@ -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;
|
||||
|
||||
@@ -115,6 +115,9 @@ export async function initDatabase(): Promise<void> {
|
||||
audit_status VARCHAR(32) DEFAULT '已通过',
|
||||
id_card_masked VARCHAR(32),
|
||||
tags JSON,
|
||||
latitude DECIMAL(10,7) DEFAULT NULL,
|
||||
longitude DECIMAL(10,7) DEFAULT NULL,
|
||||
last_active_at DATETIME DEFAULT NULL,
|
||||
apply_time DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
@@ -132,6 +135,15 @@ export async function initDatabase(): Promise<void> {
|
||||
try {
|
||||
await conn.query("ALTER TABLE companions ADD COLUMN voice_intro_url VARCHAR(255) NOT NULL DEFAULT '' AFTER voice_title");
|
||||
} catch (_) {}
|
||||
try {
|
||||
await conn.query('ALTER TABLE companions ADD COLUMN latitude DECIMAL(10,7) DEFAULT NULL AFTER tags');
|
||||
} catch (_) {}
|
||||
try {
|
||||
await conn.query('ALTER TABLE companions ADD COLUMN longitude DECIMAL(10,7) DEFAULT NULL AFTER latitude');
|
||||
} catch (_) {}
|
||||
try {
|
||||
await conn.query('ALTER TABLE companions ADD COLUMN last_active_at DATETIME DEFAULT NULL AFTER longitude');
|
||||
} catch (_) {}
|
||||
|
||||
// 4. 订单表
|
||||
await conn.query(`
|
||||
@@ -796,6 +808,7 @@ export async function initDatabase(): Promise<void> {
|
||||
['arbitrations', 'idx_arb_order', 'order_id'],
|
||||
['companions', 'idx_comp_user', 'user_id'],
|
||||
['companions', 'idx_comp_status', 'status'],
|
||||
['companions', 'idx_comp_active', 'status, last_active_at'],
|
||||
['companions', 'idx_comp_audit', 'audit_status'],
|
||||
['payment_refunds', 'idx_pr_order', 'order_id'],
|
||||
];
|
||||
|
||||
@@ -898,6 +898,96 @@ export class MysqlRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/** 大神接单心跳:刷新最后活跃时间,并在有定位时更新最近坐标(坐标不对外返回) */
|
||||
async updateCompanionHeartbeat(companionId: number, lat?: number | null, lng?: number | null): Promise<void> {
|
||||
const id = Number(companionId) || 0;
|
||||
if (id <= 0) return;
|
||||
const nLat = Number(lat);
|
||||
const nLng = Number(lng);
|
||||
const hasGeo =
|
||||
Number.isFinite(nLat) && Number.isFinite(nLng) &&
|
||||
Math.abs(nLat) <= 90 && Math.abs(nLng) <= 180 &&
|
||||
!(nLat === 0 && nLng === 0);
|
||||
if (hasGeo) {
|
||||
await pool.query(
|
||||
'UPDATE companions SET last_active_at = NOW(), latitude = ?, longitude = ? WHERE id = ?',
|
||||
[nLat, nLng, id]
|
||||
);
|
||||
} else {
|
||||
await pool.query('UPDATE companions SET last_active_at = NOW() WHERE id = ?', [id]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 即时匹配候选:真实注册、审核通过、当前接单中、10 分钟内有心跳。
|
||||
* 传入用户坐标时按 Haversine 距离过滤 5km 并升序;不返回精确经纬度,只返回距离。
|
||||
*/
|
||||
async matchCompanions(opts: {
|
||||
lat?: number | null;
|
||||
lng?: number | null;
|
||||
radiusKm?: number | null;
|
||||
excludeUserId?: number;
|
||||
limit?: number;
|
||||
} = {}): Promise<any[]> {
|
||||
const excludeUserId = Number(opts.excludeUserId) || 0;
|
||||
const limit = Math.min(20, Math.max(1, Number(opts.limit) || 10));
|
||||
const [rows]: any = await pool.query(`
|
||||
SELECT id, user_id as userId, name, avatar, gender, age, track, rank_name as \`rank\`,
|
||||
hourly_rate as hourlyRate, rating, order_count as orderCount, status,
|
||||
last_active_at as lastActiveAt, latitude, longitude
|
||||
FROM companions
|
||||
WHERE user_id IS NOT NULL AND user_id > 0 AND user_id <> ?
|
||||
AND audit_status = '已通过'
|
||||
AND status = '接单中'
|
||||
AND last_active_at IS NOT NULL
|
||||
AND last_active_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE)
|
||||
ORDER BY rating DESC, order_count DESC, id ASC
|
||||
LIMIT 200
|
||||
`, [excludeUserId]);
|
||||
|
||||
const fromLat = Number(opts.lat);
|
||||
const fromLng = Number(opts.lng);
|
||||
const hasFrom =
|
||||
Number.isFinite(fromLat) && Number.isFinite(fromLng) &&
|
||||
Math.abs(fromLat) <= 90 && Math.abs(fromLng) <= 180 &&
|
||||
!(fromLat === 0 && fromLng === 0);
|
||||
const radius = Math.min(50, Math.max(1, Number(opts.radiusKm) || 5));
|
||||
|
||||
const list = (rows || [])
|
||||
.map((r: any) => {
|
||||
const lat = r.latitude === null || r.latitude === undefined ? null : Number(r.latitude);
|
||||
const lng = r.longitude === null || r.longitude === undefined ? null : Number(r.longitude);
|
||||
const hasGeo = lat !== null && lng !== null && Number.isFinite(lat) && Number.isFinite(lng);
|
||||
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 || '',
|
||||
age: Number(r.age) || 0,
|
||||
track: r.track || '',
|
||||
rankTag: r.rank || '',
|
||||
price: Number(r.hourlyRate) || 0,
|
||||
rating: Number(r.rating) || 0,
|
||||
orderCount: Number(r.orderCount) || 0,
|
||||
isOnline: true,
|
||||
lastActiveAt: r.lastActiveAt,
|
||||
distanceKm: distanceKm === null ? null : Number(distanceKm.toFixed(3)),
|
||||
distanceText: distanceKm === null ? '同城' : formatDistanceText(distanceKm),
|
||||
};
|
||||
})
|
||||
.filter((r: any) => (hasFrom ? r.distanceKm !== null && r.distanceKm <= radius : true));
|
||||
|
||||
list.sort((a: any, b: any) => {
|
||||
if (a.distanceKm !== null && b.distanceKm !== null) return a.distanceKm - b.distanceKm;
|
||||
if (a.distanceKm !== null) return -1;
|
||||
if (b.distanceKm !== null) return 1;
|
||||
return (b.rating - a.rating) || (b.orderCount - a.orderCount);
|
||||
});
|
||||
return list.slice(0, limit);
|
||||
}
|
||||
|
||||
async getCompanionById(id: number | string): Promise<any> {
|
||||
let c = await this.resolveCompanionRow(id);
|
||||
// 是否为"非真实大神档案"的合成资料(用于对其隐藏虚构评分/价格/服务等)
|
||||
@@ -1082,6 +1172,10 @@ export class MysqlRepository {
|
||||
if (data.status !== undefined) {
|
||||
fields.push('status = ?');
|
||||
values.push(data.status);
|
||||
// 切换为接单中时立即刷新活跃时间,避免必须等下一次心跳才进入匹配池
|
||||
if (data.status === '接单中') {
|
||||
fields.push('last_active_at = NOW()');
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length > 0) {
|
||||
|
||||
@@ -1288,6 +1288,68 @@ app.get('/api/companions', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 即时匹配:仅返回真实注册、审核通过、心跳在线的接单大神;带坐标时按 5km 距离过滤并排序
|
||||
app.get('/api/companions/match', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const lat = Number(req.query.lat);
|
||||
const lng = Number(req.query.lng);
|
||||
const hasGeo =
|
||||
Number.isFinite(lat) && Number.isFinite(lng) &&
|
||||
Math.abs(lat) <= 90 && Math.abs(lng) <= 180 &&
|
||||
!(lat === 0 && lng === 0);
|
||||
const radiusKm = Math.min(50, Math.max(1, Number(req.query.radius) || 5));
|
||||
const limit = Math.min(20, Math.max(1, Number(req.query.limit) || 10));
|
||||
const excludeUserId = resolveRequestUserId(req) || 0;
|
||||
const list = await repo.matchCompanions({
|
||||
lat: hasGeo ? lat : null,
|
||||
lng: hasGeo ? lng : null,
|
||||
radiusKm,
|
||||
excludeUserId,
|
||||
limit,
|
||||
});
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: { list, total: list.length, radiusKm, hasGeo },
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ code: 50000, message: err.message || '即时匹配异常' });
|
||||
}
|
||||
});
|
||||
|
||||
// 大神接单心跳:刷新 last_active_at(有定位时同步最近坐标,坐标不对外返回)
|
||||
app.post('/api/companions/heartbeat', rateLimit({ name: 'companion_heartbeat', windowMs: 60 * 1000, max: 40, key: rateWriteKey }), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const uid = resolveSecureUserId(
|
||||
req,
|
||||
Number(req.body?.userId || req.headers['x-user-id'] || 0),
|
||||
'大神接单心跳'
|
||||
);
|
||||
const comp = await repo.getCompanionByUserId(uid);
|
||||
if (!comp) {
|
||||
return res.status(404).json({ code: 40400, message: '尚未入驻大神,无法上报接单状态' });
|
||||
}
|
||||
if (comp.audit_status !== '已通过') {
|
||||
return res.status(403).json({ code: 40300, message: '大神档案未通过审核,无法进入匹配池' });
|
||||
}
|
||||
const lat = Number(req.body?.lat);
|
||||
const lng = Number(req.body?.lng);
|
||||
await repo.updateCompanionHeartbeat(Number(comp.id), lat, lng);
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: {
|
||||
companionId: Number(comp.id),
|
||||
isOnline: comp.status === '接单中',
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
},
|
||||
message: '心跳已更新',
|
||||
});
|
||||
} catch (err: any) {
|
||||
const statusCode = err.status || 400;
|
||||
const errCode = err.code || 40000;
|
||||
res.status(statusCode).json({ code: errCode, message: err.message || '心跳上报失败' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/companions/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const companion = await repo.getCompanionById(String(req.params.id));
|
||||
|
||||
@@ -349,14 +349,14 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => {
|
||||
const content = fs.readFileSync(indexVuePath, 'utf-8');
|
||||
const apiContent = fs.readFileSync(path.join(rootDir, 'pwuniapp', 'common', 'api.js'), 'utf-8');
|
||||
|
||||
expect(content).toContain('await getCompanions()');
|
||||
expect(content).toContain('Number(e.userId) > 0');
|
||||
expect(content).toContain('Number(e.userId) !== uid');
|
||||
expect(content).toContain('matchCompanions({ ...(pos || {}), radius: 5, limit: 20 })');
|
||||
expect(content).toContain('lastMatchedId');
|
||||
expect(content).toContain('matchedMeta');
|
||||
expect(content).toContain('换一位');
|
||||
expect(content).toContain('在线大神即时匹配');
|
||||
expect(content).not.toContain('首位接单大神命中');
|
||||
expect(apiContent).toContain('userId: Number(c.userId) || 0');
|
||||
expect(apiContent).toContain('export async function matchCompanions');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
81
server/tests/lbs-match.spec.ts
普通文件
81
server/tests/lbs-match.spec.ts
普通文件
@@ -0,0 +1,81 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 大神 LBS 即时匹配与接单心跳闸门:
|
||||
* - companions 必须持久化最近坐标与 last_active_at;
|
||||
* - 匹配只取真实注册、审核通过、接单中、10 分钟内有心跳的大神;
|
||||
* - 带用户坐标时按 Haversine 距离过滤 5km 并升序;
|
||||
* - 大神页前台必须持续心跳,首页即时匹配必须走服务端匹配接口。
|
||||
*/
|
||||
describe('大神 LBS 即时匹配与接单心跳', () => {
|
||||
const root = path.resolve(__dirname, '../../');
|
||||
const read = (rel: string) => fs.readFileSync(path.join(root, rel), 'utf-8');
|
||||
const mysql = read('server/src/db/mysql.ts');
|
||||
const repo = read('server/src/db/repository.ts');
|
||||
const indexCode = read('server/src/index.ts');
|
||||
const api = read('pwuniapp/common/api.js');
|
||||
const heartbeat = read('pwuniapp/common/expert-heartbeat.js');
|
||||
const home = read('pwuniapp/pages/index/index.vue');
|
||||
const workspace = read('pwuniapp/pages/expert/workspace.vue');
|
||||
const hall = read('pwuniapp/pages/expert/hall.vue');
|
||||
const radar = read('pwuniapp/pages/expert/radar.vue');
|
||||
|
||||
it('companions 表必须有坐标、最后活跃时间与匹配索引', () => {
|
||||
expect(mysql).toContain('latitude DECIMAL(10,7) DEFAULT NULL');
|
||||
expect(mysql).toContain('longitude DECIMAL(10,7) DEFAULT NULL');
|
||||
expect(mysql).toContain('last_active_at DATETIME DEFAULT NULL');
|
||||
expect(mysql).toContain("ALTER TABLE companions ADD COLUMN latitude");
|
||||
expect(mysql).toContain("['companions', 'idx_comp_active', 'status, last_active_at']");
|
||||
});
|
||||
|
||||
it('匹配仓储必须只取真实注册、审核通过、接单中且 10 分钟内有心跳的大神', () => {
|
||||
expect(repo).toContain('async matchCompanions(');
|
||||
expect(repo).toContain('user_id IS NOT NULL AND user_id > 0');
|
||||
expect(repo).toContain("audit_status = '已通过'");
|
||||
expect(repo).toContain("status = '接单中'");
|
||||
expect(repo).toContain('last_active_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE)');
|
||||
expect(repo).toContain('haversineKm(fromLat, fromLng');
|
||||
expect(repo).toContain('distanceText');
|
||||
expect(repo).toContain('async updateCompanionHeartbeat(');
|
||||
// 接单中切换时立即刷新活跃时间
|
||||
expect(repo).toContain("fields.push('last_active_at = NOW()')");
|
||||
});
|
||||
|
||||
it('匹配接口必须在详情路由之前注册,心跳接口必须鉴权', () => {
|
||||
const matchIdx = indexCode.indexOf("app.get('/api/companions/match'");
|
||||
const detailIdx = indexCode.indexOf("app.get('/api/companions/:id'");
|
||||
expect(matchIdx).toBeGreaterThan(-1);
|
||||
expect(detailIdx).toBeGreaterThan(matchIdx);
|
||||
expect(indexCode).toContain("app.post('/api/companions/heartbeat'");
|
||||
expect(indexCode).toContain("'大神接单心跳'");
|
||||
expect(indexCode).toContain('repo.matchCompanions({');
|
||||
expect(indexCode).toContain('repo.updateCompanionHeartbeat(');
|
||||
});
|
||||
|
||||
it('客户端必须提供匹配与心跳 API,且不返回精确坐标', () => {
|
||||
expect(api).toContain('export async function matchCompanions');
|
||||
expect(api).toContain('export async function sendCompanionHeartbeat');
|
||||
expect(api).toContain('/companions/match?');
|
||||
expect(api).toContain("http.post('/companions/heartbeat'");
|
||||
expect(api).toContain('distanceText: c.distanceText');
|
||||
});
|
||||
|
||||
it('大神工作台/大厅/雷达前台必须启动心跳,离开时停止', () => {
|
||||
expect(heartbeat).toContain('export function startHeartbeat');
|
||||
expect(heartbeat).toContain('export function stopHeartbeat');
|
||||
expect(heartbeat).toContain('sendCompanionHeartbeat');
|
||||
for (const page of [workspace, hall, radar]) {
|
||||
expect(page).toContain('startHeartbeat()');
|
||||
expect(page).toContain('stopHeartbeat()');
|
||||
}
|
||||
});
|
||||
|
||||
it('首页即时匹配必须调用服务端匹配接口并展示真实距离', () => {
|
||||
expect(home).toContain('matchCompanions({ ...(pos || {}), radius: 5, limit: 20 })');
|
||||
expect(home).toContain('getUserLocation');
|
||||
expect(home).toContain('distanceText');
|
||||
expect(home).toContain('暂无可匹配的接单大神');
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户