feat(lbs): 真实距离与按距离派单落地

- 数据库:orders/activities 新增 latitude/longitude(含存量表 ALTER 迁移)

- 服务端:Haversine 计算 distanceKm/distanceText,派单池支持 lat/lng/radius 过滤并按距离升序;历史无坐标订单用腾讯地理编码兜底并回写缓存

- 新增 /api/lbs/reverse 逆地址解析(Key 仅服务端持有,登录 + 限流)

- 小程序:雷达/接单大厅改用真实定位与真实距离,删除全部写死演示距离;下单页新增地图选点,组局页保存选点经纬度

- 修复接单大厅点接单会新建订单的问题,改为真正 claimOrder 认领

- 新增 lbs-distance 护栏(110 套件/729 用例全绿)
这个提交包含在:
xcbyy
2026-10-21 14:29:33 +08:00
父节点 ecc4a116c4
当前提交 68e41ca962
共有 10 个文件被更改,包括 584 次插入 和 235 次删除

查看文件

@@ -87,6 +87,8 @@ export async function createActivity(payload) {
title: payload.title,
gameCategory: payload.game || payload.gameCategory || '美食陪吃',
location: payload.location,
latitude: payload.latitude,
longitude: payload.longitude,
startTime: payload.startTime,
payType: payload.payType === 'AA' ? 1 : 2,
totalFeeCents: Math.round((Number(payload.totalFee) || 0) * 100),
@@ -684,16 +686,38 @@ export async function getUserFinanceRecords(userId) {
/**
* 获取可抢订单池 (真实读取 MySQL orders 待接单列表)
*/
export async function getOrdersPool() {
export async function getOrdersPool(options = {}) {
try {
const res = await http.get('/orders/pool');
if (Array.isArray(res) && res.length > 0) {
const lat = Number(options.lat);
const lng = Number(options.lng);
const radius = Number(options.radius);
const qs = [];
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
qs.push(`lat=${lat}`, `lng=${lng}`);
if (Number.isFinite(radius) && radius > 0) qs.push(`radius=${radius}`);
}
const res = await http.get(`/orders/pool${qs.length ? `?${qs.join('&')}` : ''}`);
if (Array.isArray(res)) {
return res;
}
} catch (err) {
console.warn('[API] 获取可抢订单池异常:', err.message);
throw err;
}
return [];
}
/**
* 坐标反查行政区/地址(雷达顶部定位文案)
*/
export async function reverseGeocode(latitude, longitude) {
try {
const res = await http.get(`/lbs/reverse?lat=${Number(latitude)}&lng=${Number(longitude)}`);
return res || null;
} catch (err) {
console.warn('[API] 逆地址解析异常:', err.message);
return null;
}
return null;
}
/**

查看文件

@@ -121,7 +121,7 @@
:key="zoneItem"
class="zone-chip-item btn-active"
:class="{ selected: form.location === zoneItem }"
@click="form.location = zoneItem"
@click="selectZone(zoneItem)"
>
{{ zoneItem.split(' ')[0] }}
</view>
@@ -380,6 +380,8 @@ const form = ref({
title: '',
startTime: '',
location: '',
latitude: null,
longitude: null,
maxParticipants: 4,
aaFee: '',
pricingMode: 'PER_PERSON',
@@ -470,12 +472,20 @@ const selectQuickTime = (timeItem) => {
}
};
const selectZone = (zoneItem) => {
form.value.location = zoneItem;
form.value.latitude = null;
form.value.longitude = null;
};
const handleChooseLocation = () => {
// #ifdef MP-WEIXIN || APP-PLUS
uni.chooseLocation({
success: (res) => {
if (res && (res.name || res.address)) {
form.value.location = res.name ? `${res.address ? res.address + ' ' : ''}${res.name}` : res.address;
form.value.latitude = Number.isFinite(Number(res.latitude)) ? Number(res.latitude) : null;
form.value.longitude = Number.isFinite(Number(res.longitude)) ? Number(res.longitude) : null;
uni.showToast({ title: '已选取地点', icon: 'success' });
}
},
@@ -483,7 +493,7 @@ const handleChooseLocation = () => {
console.warn('[ActivityCreate] 地图选点取消或未授权:', err);
// 若未授权或在H5/开发环境,平滑引导点选推荐公开商圈
if (recommendPublicZones.value.length > 0) {
form.value.location = recommendPublicZones.value[0];
selectZone(recommendPublicZones.value[0]);
uni.showToast({ title: '已填入推荐商圈', icon: 'none' });
}
}
@@ -492,7 +502,7 @@ const handleChooseLocation = () => {
// #ifndef MP-WEIXIN || APP-PLUS
if (recommendPublicZones.value.length > 0) {
form.value.location = recommendPublicZones.value[0];
selectZone(recommendPublicZones.value[0]);
uni.showToast({ title: '已为您填入示范商圈', icon: 'none' });
}
// #endif
@@ -647,6 +657,8 @@ const handleSubmit = async () => {
maxParticipants: form.value.maxParticipants,
startTime: trimmedTime,
location: trimmedLocation,
latitude: form.value.latitude,
longitude: form.value.longitude,
creatorNickname: cachedUser.nickname || '同城局长',
creatorAvatar: cachedUser.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=150&h=150&fit=crop',
};

查看文件

@@ -58,6 +58,10 @@
<!-- 派单池订单列表 -->
<view class="dispatch-list">
<view v-if="filteredOrders.length === 0" class="hall-empty">
<text class="hall-empty-title">暂无待接需求单</text>
<text class="hall-empty-desc">开启定位并稍后刷新,附近新单会优先推给你</text>
</view>
<view
v-for="order in filteredOrders"
:key="order.id"
@@ -227,7 +231,8 @@ 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 { createOrder, getOrdersPool, getExpertStats, claimOrder, updateCompanionProfile, getUserProfile } from '@/common/api.js';
import { claimOrder, getOrdersPool, getExpertStats, updateCompanionProfile, getUserProfile } from '@/common/api.js';
import { getUserLocation } from '@/common/location.js';
import { getNavBarInfo } from '@/common/nav.js';
const currentThemeKey = ref(getCurrentThemeKey());
@@ -289,6 +294,13 @@ const loadHallData = async () => {
isOnline.value = uni.getStorageSync('EXPERT_RECEIVING_STATUS') !== '0';
// 2. 并行动态读取最新资料、经营数据与派单池
let myCoords = { lat: null, lng: null };
try {
const pos = await getUserLocation();
myCoords = { lat: pos.latitude, lng: pos.longitude };
} catch (_) {
// 未授权定位:距离显示「同城」,不影响大厅浏览
}
try {
await Promise.allSettled([
getUserProfile(uid).then((profile) => {
@@ -317,8 +329,8 @@ const loadHallData = async () => {
}
}).catch(() => {}),
getOrdersPool().then((pool) => {
if (pool && pool.length > 0) {
getOrdersPool(myCoords).then((pool) => {
if (Array.isArray(pool)) {
orders.value = pool.map(p => {
let typeClass = 'cat-food';
if (p.category.includes('喝') || p.category.includes('咖啡')) typeClass = 'cat-drink';
@@ -328,7 +340,7 @@ const loadHallData = async () => {
...p,
typeClass,
timeSlot: p.timeSlot || '今日内随时可约',
distance: '1.2km内',
distance: p.distanceText || '同城',
requirement: p.requirement || '按约定准时到场,文明健康社交。'
};
});
@@ -382,64 +394,7 @@ const categories = ref([
{ key: 'game', name: '线上电竞', count: 3, icon: ICONS.gameCrosshair }
]);
const orders = ref([
{
id: 101,
category: '同城美食陪吃',
typeClass: 'cat-food',
payMethod: 'AA制各付',
reward: 120,
title: '三里屯北京烤鸭探店·寻2位好脾气饭搭子',
location: '朝阳区 · 三里屯SOHO商场',
distance: '1.1km',
timeSlot: '今日 18:30 - 20:30',
requirement: '已提前订座,现场各自出示4位核销码,性格随和开朗。',
userName: '小鹿斑比',
userAvatar: 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=100'
},
{
id: 102,
category: '线上电竞开黑',
typeClass: 'cat-game',
payMethod: '老板全包',
reward: 70,
title: '王者荣耀 · 微信区荣耀王者晋级双排',
location: '线上远程开黑',
distance: '全国',
timeSlot: '接单后即刻开始 (2局)',
requirement: '擅长射手或打野位,带飞晋级,游戏内清晰沟通不压力。',
userName: '想赢一把好难',
userAvatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=100'
},
{
id: 103,
category: '同城下午茶陪喝',
typeClass: 'cat-drink',
payMethod: '房主买单',
reward: 150,
title: '工体精品手冲咖啡馆 · 倾听解压与交流',
location: '朝阳区 · 工体北路',
distance: '1.8km',
timeSlot: '今日 15:00 - 17:00',
requirement: '纯正规咖啡馆,工作压力大想倾诉,求善解人意的倾听导师。',
userName: '风吹过的夏天',
userAvatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=100'
},
{
id: 104,
category: '同城KTV陪唱',
typeClass: 'cat-sing',
payMethod: 'AA分摊',
reward: 200,
title: '量贩纯K包厢麦霸欢唱 · 流行情歌对唱',
location: '朝阳区 · 青年路大悦城店',
distance: '3.2km',
timeSlot: '今日 20:00 - 23:00',
requirement: '爱唱歌、不跑调,会带动气氛,严禁任何违规,正规量贩KTV。',
userName: '麦霸小王子',
userAvatar: 'https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=100'
}
]);
const orders = ref([]);
const filteredOrders = computed(() => {
if (activeCategory.value === 'all') return orders.value;
@@ -476,20 +431,10 @@ const handleGrabOrder = (order) => {
if (res.confirm) {
uni.showLoading({ title: '正在锁定订单...' });
try {
await createOrder({
buyerId: 2001,
clientName: order.userName || '客户老板',
await claimOrder(order.id, {
expertId: expert.value.id || 2006,
companionName: expert.value.nickname || '接单大神',
expertAvatar: expert.value.avatar || DEFAULT_EXPERT_AVATAR,
track: order.category || '同城生活',
serviceTitle: order.title,
hourlyRate: order.reward,
durationHours: 2,
amount: order.reward,
location: order.location,
expenseBurden: order.payMethod,
status: '履约中'
expertAvatar: expert.value.avatar || DEFAULT_EXPERT_AVATAR
});
} catch (err) {
uni.hideLoading();
@@ -802,6 +747,29 @@ const copyOrderNo = () => {
flex-direction: column;
gap: 20rpx;
.hall-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10rpx;
padding: 60rpx 24rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.03);
border: 1rpx dashed rgba(255, 255, 255, 0.12);
.hall-empty-title {
font-size: 28rpx;
font-weight: 600;
color: #E2E8F0;
}
.hall-empty-desc {
font-size: 24rpx;
color: #94A3B8;
}
}
.dispatch-card {
background: #151D30;
border-radius: 24rpx;

查看文件

@@ -125,6 +125,10 @@
<!-- 订单卡片流 -->
<view class="orders-stream">
<view v-if="nearbyOrders.length === 0" class="pool-empty">
<text class="pool-empty-title">附近 {{ currentRadius }}km 暂无待接需求单</text>
<text class="pool-empty-desc">调整巡航半径或点击右上角刷新试试</text>
</view>
<view
v-for="item in nearbyOrders"
:key="item.id"
@@ -185,7 +189,8 @@ import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
import { isExpertCertified } from '@/common/role.js';
import { getNavBarInfo } from '@/common/nav.js';
import { getOrdersPool, claimOrder, createOrder } from '@/common/api.js';
import { getOrdersPool, claimOrder, createOrder, reverseGeocode } from '@/common/api.js';
import { getUserLocation } from '@/common/location.js';
const navInfo = ref(getNavBarInfo());
const navStyle = computed(() => ({
@@ -203,9 +208,12 @@ const currentRadius = ref(5);
const rangeOptions = [1, 3, 5];
const selectedOrderId = ref(1);
const myCoords = ref({ lat: null, lng: null });
const locating = ref(false);
const currentLocation = ref({
name: '朝阳区 · 三里屯太古里北区',
address: '北京市朝阳区三里屯路19号院 (精度 ±5m)'
name: '正在定位…',
address: '正在获取当前位置'
});
const onThemeChanged = (newTheme) => {
@@ -213,63 +221,98 @@ const onThemeChanged = (newTheme) => {
applyThemeToNativeUI(newTheme);
};
const markerPosition = (distanceKm, idx) => {
const radius = Math.max(1, Number(currentRadius.value) || 5);
const ratio = Number.isFinite(Number(distanceKm))
? Math.min(0.92, Math.max(0.16, Number(distanceKm) / radius))
: 0.5;
const angle = (Math.PI * 2 * idx) / 5 - Math.PI / 2;
const top = 50 + Math.sin(angle) * ratio * 40;
const left = 50 + Math.cos(angle) * ratio * 40;
return { top: `${top.toFixed(1)}%`, left: `${left.toFixed(1)}%` };
};
const loadNearbyOrdersFromPool = async () => {
try {
const pool = await getOrdersPool();
if (pool && pool.length > 0) {
const coords = [
{ top: '28%', left: '22%', dist: '650m' },
{ top: '32%', left: '72%', dist: '1.4km' },
{ top: '68%', left: '30%', dist: '2.2km' },
{ top: '65%', left: '75%', dist: '3.8km' },
{ top: '45%', left: '50%', dist: '900m' }
];
nearbyOrders.value = pool.slice(0, 5).map((p, idx) => {
let typeClass = 'type-food';
let typeIcon = ICONS.utensils;
if (p.category.includes('喝') || p.category.includes('咖啡')) {
typeClass = 'type-coffee';
typeIcon = ICONS.coffee;
} else if (p.category.includes('唱') || p.category.includes('KTV')) {
typeClass = 'type-ktv';
typeIcon = ICONS.micVocal;
} else if (p.category.includes('游戏') || p.category.includes('电竞')) {
typeClass = 'type-game';
typeIcon = ICONS.gameAll;
}
const coord = coords[idx % coords.length];
return {
id: p.id,
categoryName: p.category,
title: p.title,
requirement: p.requirement || '按约定准时到场,现场出示4位核销码,文明健康社交。',
reward: p.reward,
duration: '2小时',
distance: coord.dist,
timeAgo: '刚刚',
buyerName: p.userName || '客户老板',
buyerAvatar: p.userAvatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120',
typeClass,
typeIcon,
markerTop: coord.top,
markerLeft: coord.left
};
});
if (nearbyOrders.value.length > 0) {
selectedOrderId.value = nearbyOrders.value[0].id;
const pool = await getOrdersPool({
lat: myCoords.value.lat,
lng: myCoords.value.lng,
radius: currentRadius.value
});
nearbyOrders.value = (pool || []).slice(0, 5).map((p, idx) => {
let typeClass = 'type-food';
let typeIcon = ICONS.utensils;
if (String(p.category || '').includes('喝') || String(p.category || '').includes('咖啡')) {
typeClass = 'type-coffee';
typeIcon = ICONS.coffee;
} else if (String(p.category || '').includes('唱') || String(p.category || '').includes('KTV')) {
typeClass = 'type-ktv';
typeIcon = ICONS.micVocal;
} else if (String(p.category || '').includes('游戏') || String(p.category || '').includes('电竞')) {
typeClass = 'type-game';
typeIcon = ICONS.gameAll;
}
const pos = markerPosition(p.distanceKm, idx);
return {
id: p.id,
categoryName: p.category,
title: p.title,
requirement: p.requirement || '按约定准时到场,现场出示4位核销码,文明健康社交。',
reward: p.reward,
duration: '2小时',
distance: p.distanceText || '同城',
distanceKm: p.distanceKm,
timeAgo: '刚刚',
buyerName: p.userName || '客户老板',
buyerAvatar: p.userAvatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120',
typeClass,
typeIcon,
markerTop: pos.top,
markerLeft: pos.left
};
});
if (nearbyOrders.value.length > 0) {
selectedOrderId.value = nearbyOrders.value[0].id;
}
} catch (err) {
console.warn('[Radar] 加载雷达单池异常:', err);
uni.showToast({ title: '获取附近需求单失败,请稍后重试', icon: 'none' });
}
};
const refreshLocation = async () => {
if (locating.value) return;
locating.value = true;
try {
const pos = await getUserLocation();
myCoords.value = { lat: pos.latitude, lng: pos.longitude };
const geo = await reverseGeocode(pos.latitude, pos.longitude);
if (geo && geo.name) {
currentLocation.value = { name: geo.name, address: geo.address || '当前位置' };
} else {
currentLocation.value = {
name: '当前位置',
address: `经纬度 ${Number(pos.latitude).toFixed(4)}, ${Number(pos.longitude).toFixed(4)}`
};
}
} catch (err) {
myCoords.value = { lat: null, lng: null };
currentLocation.value = {
name: '未开启定位',
address: '开启定位权限后可查看附近需求单的真实距离'
};
uni.showToast({ title: '未获取到定位,距离将显示为同城', icon: 'none' });
} finally {
locating.value = false;
}
await loadNearbyOrdersFromPool();
};
onMounted(() => {
navInfo.value = getNavBarInfo();
currentThemeKey.value = getCurrentThemeKey();
applyThemeToNativeUI(currentThemeKey.value);
uni.$on('theme-change', onThemeChanged);
loadNearbyOrdersFromPool();
});
onShow(() => {
@@ -288,7 +331,7 @@ onShow(() => {
currentThemeKey.value = getCurrentThemeKey();
applyThemeToNativeUI(currentThemeKey.value);
uni.hideTabBar({ animation: false, fail: () => {} });
loadNearbyOrdersFromPool();
refreshLocation();
});
onUnmounted(() => {
@@ -296,90 +339,27 @@ onUnmounted(() => {
});
const nearbyOrders = ref([
{
id: 1,
categoryName: '同城美食陪吃',
title: '三里屯太古里 · 铜锅涮肉AA搭子',
requirement: '吃货随和好相处,现场出示4位核销码,餐费已预订AA',
reward: 98,
duration: '2小时',
distance: '650m',
timeAgo: '5分钟前',
buyerName: '北京吃货小王',
buyerAvatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120',
typeClass: 'type-food',
typeIcon: ICONS.utensils,
markerTop: '28%',
markerLeft: '22%'
},
{
id: 2,
categoryName: '同城下午茶陪喝',
title: '工体北路 · 独立手冲咖啡闲聊倾听',
requirement: '安静舒适咖啡馆,主要是倾听工作解压,老板全买单请客',
reward: 120,
duration: '2小时',
distance: '1.4km',
timeAgo: '12分钟前',
buyerName: '晴天小筑',
buyerAvatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=120',
typeClass: 'type-coffee',
typeIcon: ICONS.coffee,
markerTop: '32%',
markerLeft: '72%'
},
{
id: 3,
categoryName: '同城KTV陪唱',
title: '工体量贩纯K · 麦霸对唱与气氛组',
requirement: '会唱周杰伦/陈奕迅经典情歌,气氛欢快,拒绝私密',
reward: 180,
duration: '2.5小时',
distance: '2.2km',
timeAgo: '18分钟前',
buyerName: '欢唱麦霸哥',
buyerAvatar: 'https://images.unsplash.com/photo-1570295999919-56ceb5ecca61?w=120',
typeClass: 'type-ktv',
typeIcon: ICONS.micVocal,
markerTop: '68%',
markerLeft: '30%'
},
{
id: 4,
categoryName: '线上王者冲分',
title: '王者荣耀 · 荣耀王者晋级双排急召',
requirement: '擅长打野/射手节奏位,全程语音沟通,保星连胜',
reward: 70,
duration: '2局',
distance: '3.8km',
timeAgo: '25分钟前',
buyerName: '摘星少年',
buyerAvatar: 'https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=120',
typeClass: 'type-game',
typeIcon: ICONS.gameAll,
markerTop: '65%',
markerLeft: '75%'
}
]);
const nearbyOrders = ref([]);
const changeRadius = (r) => {
const changeRadius = async (r) => {
currentRadius.value = r;
uni.showToast({
title: `已更新探测范围至周边 ${r} 公里`,
icon: 'none'
});
await loadNearbyOrdersFromPool();
};
const handleRelocate = () => {
uni.showLoading({ title: '正在校准GPS...' });
setTimeout(() => {
uni.hideLoading();
uni.showToast({
title: 'GPS定位已校准 (精度 ±3m)',
icon: 'success'
});
}, 400);
const handleRelocate = async () => {
uni.showLoading({ title: '正在重新定位...' });
try {
await refreshLocation();
} catch (_) {}
uni.hideLoading();
uni.showToast({
title: myCoords.value.lat ? '定位已更新' : '定位失败,请检查定位权限',
icon: myCoords.value.lat ? 'success' : 'none'
});
};
const toggleRadarSwitch = (e) => {
@@ -394,12 +374,14 @@ const selectMarkerOrder = (id) => {
selectedOrderId.value = id;
};
const refreshNearbyOrders = () => {
const refreshNearbyOrders = async () => {
uni.showLoading({ title: '雷达刷新中...' });
setTimeout(() => {
uni.hideLoading();
uni.showToast({ title: '已匹配最新 5km 派单池', icon: 'success' });
}, 500);
await refreshLocation();
uni.hideLoading();
uni.showToast({
title: `已匹配 ${currentRadius.value}km 内最新派单池`,
icon: 'success'
});
};
const handleTakeOrder = (order) => {
@@ -938,6 +920,29 @@ const handleTakeOrder = (order) => {
flex-direction: column;
gap: 20rpx;
.pool-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10rpx;
padding: 60rpx 24rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.03);
border: 1rpx dashed rgba(255, 255, 255, 0.12);
.pool-empty-title {
font-size: 28rpx;
font-weight: 600;
color: #E2E8F0;
}
.pool-empty-desc {
font-size: 24rpx;
color: #94A3B8;
}
}
.nearby-card {
background: #151D30;
border-radius: 24rpx;

查看文件

@@ -76,6 +76,7 @@
<view class="remark-row">
<text class="cell-label">碰面场所</text>
<input class="remark-input" v-model="meetVenue" placeholder="正规场所,如:工体纯K量贩KTV/朝阳大悦城" placeholder-class="ph" />
<view class="pick-loc-btn btn-active" @click="pickVenue">地图选点</view>
</view>
<view class="expense-row">
<text class="cell-label">现场消费</text>
@@ -326,6 +327,28 @@ const timeType = ref('NOW');
const discount = ref(0);
const remark = ref('');
const meetVenue = ref('朝阳大悦城海底捞');
const meetCoords = ref({ latitude: null, longitude: null });
const pickVenue = () => {
// #ifdef MP-WEIXIN || APP-PLUS
uni.chooseLocation({
success: (res) => {
if (!res) return;
const name = res.name ? `${res.address ? res.address + ' ' : ''}${res.name}` : res.address;
if (name) meetVenue.value = name;
meetCoords.value = {
latitude: Number.isFinite(Number(res.latitude)) ? Number(res.latitude) : null,
longitude: Number.isFinite(Number(res.longitude)) ? Number(res.longitude) : null
};
uni.showToast({ title: '已选择碰面地点', icon: 'success' });
},
fail: () => uni.showToast({ title: '未选择地点,可手动填写', icon: 'none' })
});
// #endif
// #ifndef MP-WEIXIN || APP-PLUS
uni.showToast({ title: '当前环境不支持地图选点,请手动填写', icon: 'none' });
// #endif
};
const expenseBurden = ref('AA');
const isOffline = computed(() => {
@@ -518,6 +541,8 @@ const handlePay = async () => {
userCouponId: usingUserCoupon ? String(currentCoupon.value.id) : '',
payType: payMethod.value,
location: isOffline.value ? meetVenue.value : (remark.value || '线上语音开黑'),
latitude: isOffline.value ? meetCoords.value.latitude : null,
longitude: isOffline.value ? meetCoords.value.longitude : null,
expenseBurden: expenseBurden.value === 'AA' ? 'AA制各自付' : (expenseBurden.value === 'CLIENT' ? '客户全包' : '大神自理'),
remark: remark.value
};
@@ -829,6 +854,15 @@ const handlePay = async () => {
.ph {
color: var(--text-muted);
}
.pick-loc-btn {
flex-shrink: 0;
margin-left: 12rpx;
padding: 8rpx 18rpx;
border-radius: 24rpx;
font-size: 22rpx;
color: #FFFFFF;
background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%);
}
}
.expense-row {

查看文件

@@ -27,6 +27,8 @@ export async function initDatabase(): Promise<void> {
title VARCHAR(255) NOT NULL,
game_category VARCHAR(64) NOT NULL,
location VARCHAR(255),
latitude DECIMAL(10,6) DEFAULT NULL,
longitude DECIMAL(10,6) DEFAULT NULL,
start_time VARCHAR(64) DEFAULT '',
pay_type INT NOT NULL DEFAULT 1,
total_fee_cents INT DEFAULT 0,
@@ -45,6 +47,17 @@ export async function initDatabase(): Promise<void> {
// 字段已存在忽略
}
try {
await conn.query(`ALTER TABLE activities ADD COLUMN latitude DECIMAL(10,6) DEFAULT NULL AFTER location`);
} catch (_) {
// 字段已存在忽略
}
try {
await conn.query(`ALTER TABLE activities ADD COLUMN longitude DECIMAL(10,6) DEFAULT NULL AFTER latitude`);
} catch (_) {
// 字段已存在忽略
}
try {
await conn.query(`ALTER TABLE activities ADD COLUMN status VARCHAR(32) DEFAULT 'RECRUITING' AFTER price_adjust_rule`);
} catch (_) {
@@ -137,6 +150,8 @@ export async function initDatabase(): Promise<void> {
duration_hours INT NOT NULL DEFAULT 2,
amount DECIMAL(10,2) NOT NULL,
location VARCHAR(255),
latitude DECIMAL(10,6) DEFAULT NULL,
longitude DECIMAL(10,6) DEFAULT NULL,
verify_code VARCHAR(8),
remark VARCHAR(255),
status VARCHAR(32) NOT NULL DEFAULT '待支付',
@@ -151,6 +166,8 @@ export async function initDatabase(): Promise<void> {
"ALTER TABLE orders ADD COLUMN expert_avatar TEXT AFTER companion_name",
"ALTER TABLE orders ADD COLUMN service_title VARCHAR(255) DEFAULT '同城陪伴服务' AFTER track",
"ALTER TABLE orders ADD COLUMN verify_code VARCHAR(8) AFTER location",
"ALTER TABLE orders ADD COLUMN latitude DECIMAL(10,6) DEFAULT NULL AFTER location",
"ALTER TABLE orders ADD COLUMN longitude DECIMAL(10,6) DEFAULT NULL AFTER latitude",
"ALTER TABLE orders ADD COLUMN remark VARCHAR(255) AFTER verify_code",
"ALTER TABLE orders ADD COLUMN pay_type VARCHAR(32) DEFAULT 'WECHAT' AFTER status",
"ALTER TABLE orders ADD COLUMN coupon_discount DECIMAL(10,2) DEFAULT 0.00 AFTER amount",

查看文件

@@ -9,6 +9,32 @@ function logServerError(scope: string, err: any): void {
} catch (_) {}
}
/** 坐标归一化:非法/越界值一律存 NULL,避免脏数据影响距离计算 */
function normalizeCoord(v: any, limit: number): number | null {
const n = Number(v);
if (!Number.isFinite(n) || Math.abs(n) > limit) return null;
return Number(n.toFixed(6));
}
/** 球面距离(千米,Haversine) */
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const R = 6371;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/** 距离文案:<1km 显示米(取整到 10m),否则显示一位小数 km */
export function formatDistanceText(km: number): string {
if (!Number.isFinite(km) || km < 0) return '同城';
if (km < 1) return `${Math.max(10, Math.round((km * 1000) / 10) * 10)}m`;
return `${km.toFixed(1)}km`;
}
export interface ActivityRow {
id: string;
creatorId: number;
@@ -119,6 +145,8 @@ export class MysqlRepository {
title: string;
gameCategory: string;
location?: string;
latitude?: number | null;
longitude?: number | null;
startTime?: string;
payType?: number;
totalFeeCents?: number;
@@ -155,14 +183,16 @@ export class MysqlRepository {
await pool.query(`
INSERT INTO activities
(id, creator_id, title, game_category, location, start_time, pay_type, total_fee_cents, per_person_fee_cents, max_participants, allow_dynamic_price, price_adjust_rule)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, creator_id, title, game_category, location, latitude, longitude, start_time, pay_type, total_fee_cents, per_person_fee_cents, max_participants, allow_dynamic_price, price_adjust_rule)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
id,
data.creatorId,
data.title,
data.gameCategory,
data.location || '',
normalizeCoord(data.latitude, 90),
normalizeCoord(data.longitude, 180),
data.startTime || '',
payType,
totalFeeCents,
@@ -1411,6 +1441,8 @@ export class MysqlRepository {
userCouponId?: string;
payType?: string;
location?: string;
latitude?: number | null;
longitude?: number | null;
expenseBurden?: string;
remark?: string;
status?: string;
@@ -1475,6 +1507,8 @@ export class MysqlRepository {
const amount = Number(Math.max(baseAmount - couponDiscount, 0.01).toFixed(2));
const payType = data.payType === 'BALANCE' ? 'BALANCE' : 'WECHAT';
const location = data.location || '朝阳大悦城';
const orderLat = normalizeCoord(data.latitude, 90);
const orderLng = normalizeCoord(data.longitude, 180);
const expenseBurden = data.expenseBurden || 'AA制各自付';
const verifyCode = String(Math.floor(1000 + Math.random() * 9000));
const remark = data.remark || '';
@@ -1513,14 +1547,14 @@ export class MysqlRepository {
companion_name, expert_avatar, track, service_title,
expense_burden, hourly_rate, duration_hours, amount,
coupon_discount, user_coupon_id, pay_type,
location, verify_code, remark, status, paid_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${status === '待付款' ? 'NULL' : 'NOW()'}, NOW())
location, latitude, longitude, verify_code, remark, status, paid_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${status === '待付款' ? 'NULL' : 'NOW()'}, NOW())
`, [
id, buyerId, expertId, orderNo, clientName,
companionName, expertAvatar, track, serviceTitle,
expenseBurden, hourlyRate, durationHours, amount,
couponDiscount, userCouponId || null, payType,
location, verifyCode, remark, status
location, orderLat, orderLng, verifyCode, remark, status
]);
// 占用"我的券"(与订单同事务,避免下单成功但券未核销导致重复使用)
@@ -1923,19 +1957,19 @@ export class MysqlRepository {
}
}
async getOrdersPool(): Promise<any[]> {
async getOrdersPool(opts: { fromLat?: number | null; fromLng?: number | null; radiusKm?: number | null } = {}): Promise<any[]> {
const [rows]: any = await pool.query(`
SELECT
id, order_no as orderNo, client_name as clientName,
track as category, service_title as serviceTitle,
hourly_rate as hourlyRate, duration_hours as durationHours,
amount, location, expense_burden as payMethod,
amount, location, latitude, longitude, expense_burden as payMethod,
status, created_at as createdAt
FROM orders
WHERE status = '待接单'
AND (claimed_by IS NULL OR claimed_by = 0)
ORDER BY created_at DESC
LIMIT 20
LIMIT 50
`);
// 默认池保障
@@ -1945,21 +1979,61 @@ export class MysqlRepository {
if (s.length <= 1) return `${s}**`;
return `${s[0]}**`;
};
const poolList = (rows || []).map((r: any) => ({
id: r.id,
category: r.category || '同城生活',
title: r.serviceTitle || `${r.category} · 专业同城陪伴`,
location: r.location || '北京市朝阳区商圈',
reward: Number(r.amount) || 120,
timeSlot: '今日内随时可约',
userName: maskClientName(r.clientName),
userAvatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120',
payMethod: r.payMethod || 'AA制各付',
countdown: 45,
status: r.status,
}));
return poolList;
const fromLat = Number(opts.fromLat);
const fromLng = Number(opts.fromLng);
const hasFrom = Number.isFinite(fromLat) && Number.isFinite(fromLng) && Math.abs(fromLat) <= 90 && Math.abs(fromLng) <= 180;
const radius = Number(opts.radiusKm);
const hasRadius = hasFrom && Number.isFinite(radius) && radius > 0;
let poolList = (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, lng) : null;
return {
id: r.id,
category: r.category || '同城生活',
title: r.serviceTitle || `${r.category} · 专业同城陪伴`,
location: r.location || '北京市朝阳区商圈',
latitude: hasGeo ? lat : null,
longitude: hasGeo ? lng : null,
hasGeo,
distanceKm: distanceKm === null ? null : Number(distanceKm.toFixed(3)),
distanceText: distanceKm === null ? '同城' : formatDistanceText(distanceKm),
reward: Number(r.amount) || 120,
timeSlot: '今日内随时可约',
userName: maskClientName(r.clientName),
userAvatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120',
payMethod: r.payMethod || 'AA制各付',
countdown: 45,
status: r.status,
};
});
// 有定位且有半径:仅保留半径内(无坐标的单无法判断距离,一并排除)
if (hasRadius) {
poolList = poolList.filter((o: any) => o.distanceKm !== null && o.distanceKm <= radius);
}
// 有定位:按距离升序
if (hasFrom) {
poolList.sort((a: any, b: any) => {
if (a.distanceKm === null && b.distanceKm === null) return 0;
if (a.distanceKm === null) return 1;
if (b.distanceKm === null) return -1;
return a.distanceKm - b.distanceKm;
});
}
return poolList.slice(0, 20);
}
/** 地理编码兜底结果回写(避免重复解析同一地址) */
async updateOrderGeo(id: string, latitude: number, longitude: number): Promise<void> {
const lat = normalizeCoord(latitude, 90);
const lng = normalizeCoord(longitude, 180);
if (lat === null || lng === null) return;
await pool.query('UPDATE orders SET latitude = ?, longitude = ? WHERE id = ? AND (latitude IS NULL OR longitude IS NULL)', [lat, lng, id]);
}
async claimOrder(orderId: string, expertData: { expertId: number; companionName: string; expertAvatar?: string }): Promise<any> {

查看文件

@@ -489,6 +489,99 @@ function assertCompanionActor(
throw e;
}
// ===== 腾讯位置服务(仅服务端调用,Key 绝不下发小程序)=====
let mapKeyCache: { key: string; at: number } | null = null;
async function resolveTencentMapKey(): Promise<string> {
const envKey = String(process.env.TENCENT_MAP_KEY || '').trim();
if (envKey) return envKey;
const now = Date.now();
if (mapKeyCache && now - mapKeyCache.at < 60_000) return mapKeyCache.key;
let key = '';
try {
const record = await repo.getPlatformConfigByKey('map_location_config');
const value = (record && record.valueJson) || {};
key = String(value.mapKey || '').trim();
} catch (e) {
logServerError('resolveTencentMapKey', e);
}
mapKeyCache = { key, at: now };
return key;
}
function httpsGetJson(url: string, timeoutMs = 4000): Promise<any> {
return new Promise((resolve, reject) => {
const req = https.get(url, (resp) => {
let data = '';
resp.on('data', (chunk) => {
data += chunk;
});
resp.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(e);
}
});
});
req.setTimeout(timeoutMs, () => req.destroy(new Error('腾讯位置服务请求超时')));
req.on('error', reject);
});
}
const geocodeCache = new Map<string, { geo: { lat: number; lng: number } | null; at: number }>();
/** 地址 -> 坐标(腾讯 WebService 地理编码),带进程内缓存 */
async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
const text = String(address || '').trim();
if (!text) return null;
const cached = geocodeCache.get(text);
if (cached && Date.now() - cached.at < 10 * 60_000) return cached.geo;
const key = await resolveTencentMapKey();
if (!key) return null;
try {
const url = `https://apis.map.qq.com/ws/geocoder/v1/?address=${encodeURIComponent(text)}&key=${encodeURIComponent(key)}`;
const data = await httpsGetJson(url);
if (data && data.status === 0 && data.result && data.result.location) {
const geo = { lat: Number(data.result.location.lat), lng: Number(data.result.location.lng) };
if (Number.isFinite(geo.lat) && Number.isFinite(geo.lng)) {
geocodeCache.set(text, { geo, at: Date.now() });
return geo;
}
}
logServerError('geocodeAddress', new Error(`腾讯地理编码失败: ${data && (data.message || data.status)}`));
geocodeCache.set(text, { geo: null, at: Date.now() });
} catch (e) {
logServerError('geocodeAddress', e);
}
return null;
}
/** 坐标 -> 行政区/地址(腾讯 WebService 逆地址解析) */
async function reverseGeocode(lat: number, lng: number): Promise<any | null> {
const key = await resolveTencentMapKey();
if (!key) return null;
try {
const url = `https://apis.map.qq.com/ws/geocoder/v1/?location=${lat},${lng}&key=${encodeURIComponent(key)}&get_poi=0`;
const data = await httpsGetJson(url);
if (data && data.status === 0 && data.result) {
const r = data.result;
const comp = r.address_component || {};
const city = comp.city || '';
const district = comp.district || '';
return {
name: [city, district].filter(Boolean).join(' · ') || r.address || '当前位置',
address: r.address || '',
latitude: lat,
longitude: lng,
};
}
} catch (e) {
logServerError('reverseGeocode', e);
}
return null;
}
// 管理端:吊销所有后台登录态(版本号 +1,所有已签发 admin token 立即失效)
app.post('/api/admin/session/revoke-all', requireAdmin, async (req: Request, res: Response) => {
try {
@@ -1381,15 +1474,54 @@ app.get('/api/orders', async (req: Request, res: Response) => {
});
// 获取即时可抢订单池 (落盘 MySQL)
app.get('/api/orders/pool', async (_req: Request, res: Response) => {
app.get('/api/orders/pool', async (req: Request, res: Response) => {
try {
const list = await repo.getOrdersPool();
const lat = Number(req.query.lat);
const lng = Number(req.query.lng);
const radius = Number(req.query.radius);
const hasFrom = Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180 && !(lat === 0 && lng === 0);
// 先取不带半径的全量池:为历史无坐标订单做地理编码兜底(每轮最多 5 条,结果回写入库)
let pre = await repo.getOrdersPool(hasFrom ? { fromLat: lat, fromLng: lng } : {});
if (hasFrom) {
const missing = pre.filter((o: any) => !o.hasGeo && o.location).slice(0, 5);
let changed = false;
for (const o of missing) {
const geo = await geocodeAddress(o.location);
if (geo) {
await repo.updateOrderGeo(o.id, geo.lat, geo.lng);
changed = true;
}
}
if (changed) pre = await repo.getOrdersPool({ fromLat: lat, fromLng: lng });
}
const list = hasFrom && Number.isFinite(radius) && radius > 0
? pre.filter((o: any) => o.distanceKm !== null && o.distanceKm <= radius)
: pre;
res.json({ code: 20000, data: list });
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
// 坐标反查行政区/地址(雷达/接单大厅顶部定位文案;仅登录用户可调用,避免刷额度)
app.get('/api/lbs/reverse', rateLimit({ name: 'lbs_reverse', windowMs: 60 * 1000, max: 30, key: rateWriteKey }), async (req: Request, res: Response) => {
try {
if (SECURE_MODE) resolveSecureUserId(req, Number(req.query.userId || 0), '解析当前位置');
const lat = Number(req.query.lat);
const lng = Number(req.query.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) {
return res.status(400).json({ code: 40000, message: '经纬度参数不合法' });
}
const data = await reverseGeocode(lat, lng);
res.json({ code: 20000, data });
} catch (err: any) {
const statusCode = err.status || 500;
res.status(statusCode).json({ code: err.code || 50000, message: err.message });
}
});
// 大神抢单/接单认领 (落盘 MySQL)
app.post('/api/orders/:id/claim', async (req: Request, res: Response) => {
try {

查看文件

@@ -0,0 +1,81 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第九十四批:真实距离(LBS)落地闸门。
* 背景:雷达页/接单大厅的「650m / 1.4km / 1.2km内」全部是写死演示值,
* orders/activities 也没有经纬度字段,导致「按距离派单」实际不生效。
*/
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 apiCode = read('pwuniapp/common/api.js');
const radar = read('pwuniapp/pages/expert/radar.vue');
const hall = read('pwuniapp/pages/expert/hall.vue');
const orderCreate = read('pwuniapp/pages/order/create.vue');
const activityCreate = read('pwuniapp/pages/activity/create.vue');
it('订单与组局表必须存储经纬度', () => {
expect(mysql).toContain('latitude DECIMAL(10,6)');
expect(mysql).toContain('longitude DECIMAL(10,6)');
expect(mysql).toContain('ALTER TABLE activities ADD COLUMN latitude');
expect(mysql).toContain('ALTER TABLE orders ADD COLUMN latitude');
});
it('服务端按 Haversine 计算距离并返回距离文案', () => {
expect(repo).toContain('export function haversineKm');
expect(repo).toContain('export function formatDistanceText');
expect(repo).toContain('distanceText');
expect(repo).toContain('radiusKm');
expect(repo).toContain('async updateOrderGeo');
});
it('派单池接口接收定位并按半径过滤,历史地址走腾讯地理编码兜底', () => {
const start = indexCode.indexOf("app.get('/api/orders/pool'");
expect(start).toBeGreaterThan(-1);
const block = indexCode.slice(start, start + 2200);
expect(block).toContain('req.query.lat');
expect(block).toContain('req.query.radius');
expect(block).toContain('geocodeAddress');
expect(block).toContain('repo.updateOrderGeo');
});
it('逆地址解析接口仅服务端持有 Key,并要求登录', () => {
expect(indexCode).toContain("app.get('/api/lbs/reverse'");
expect(indexCode).toContain('resolveTencentMapKey');
expect(indexCode).toContain("resolveSecureUserId(req, Number(req.query.userId || 0), '解析当前位置')");
// Key 不允许出现在小程序端
expect(apiCode).not.toContain('7I4BZ');
});
it('小程序雷达与接单大厅改用真实距离,删除写死演示距离', () => {
expect(radar).toContain('getUserLocation');
expect(radar).toContain('p.distanceText');
expect(radar).not.toContain("distance: '650m'");
expect(radar).not.toContain("dist: '1.4km'");
expect(hall).toContain('getUserLocation');
expect(hall).toContain('p.distanceText');
expect(hall).not.toContain("'1.2km内'");
expect(hall).not.toContain("'1.1km'");
});
it('下单/组局在选点后提交经纬度', () => {
expect(orderCreate).toContain('pickVenue');
expect(orderCreate).toContain('uni.chooseLocation');
expect(orderCreate).toContain('latitude: isOffline.value ? meetCoords.value.latitude : null');
expect(activityCreate).toContain('form.value.latitude');
expect(apiCode).toContain('latitude: payload.latitude');
});
it('小程序的派单池调用支持携带定位与半径', () => {
expect(apiCode).toContain('export async function getOrdersPool(options = {})');
expect(apiCode).toContain('qs.push(`lat=${lat}`, `lng=${lng}`)');
expect(apiCode).toContain('radius=${radius}');
});
});

查看文件

@@ -12,9 +12,11 @@ describe('第二十九批抢单池过滤闸门', () => {
it('抢单池必须排除终态/已核销订单并过滤已认领订单', () => {
const start = repoCode.indexOf('async getOrdersPool(');
expect(start).toBeGreaterThan(-1);
const block = repoCode.slice(start, start + 1400);
const block = repoCode.slice(start, start + 5200);
expect(block).toContain("WHERE status = '待接单'");
expect(block).toContain('claimed_by IS NULL');
expect(block).toContain('LIMIT 20');
// SQL 多取候选,最终按距离排序后截断 20 条
expect(block).toContain('LIMIT 50');
expect(block).toContain('slice(0, 20)');
});
});