diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index 40b0540..02f3ab8 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -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; } /** diff --git a/pwuniapp/pages/activity/create.vue b/pwuniapp/pages/activity/create.vue index ba0dbfa..f403374 100644 --- a/pwuniapp/pages/activity/create.vue +++ b/pwuniapp/pages/activity/create.vue @@ -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] }} @@ -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', }; diff --git a/pwuniapp/pages/expert/hall.vue b/pwuniapp/pages/expert/hall.vue index b79347e..e6e093b 100644 --- a/pwuniapp/pages/expert/hall.vue +++ b/pwuniapp/pages/expert/hall.vue @@ -58,6 +58,10 @@ + + 暂无待接需求单 + 开启定位并稍后刷新,附近新单会优先推给你 + { 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; diff --git a/pwuniapp/pages/expert/radar.vue b/pwuniapp/pages/expert/radar.vue index c10dd2d..71ed07f 100644 --- a/pwuniapp/pages/expert/radar.vue +++ b/pwuniapp/pages/expert/radar.vue @@ -125,6 +125,10 @@ + + 附近 {{ currentRadius }}km 暂无待接需求单 + 调整巡航半径或点击右上角刷新试试 + ({ @@ -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; diff --git a/pwuniapp/pages/order/create.vue b/pwuniapp/pages/order/create.vue index d0568ec..47c5a92 100644 --- a/pwuniapp/pages/order/create.vue +++ b/pwuniapp/pages/order/create.vue @@ -76,6 +76,7 @@ 碰面场所 + 地图选点 现场消费 @@ -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 { diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index 824aaa6..5244e2b 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -27,6 +27,8 @@ export async function initDatabase(): Promise { 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 { // 字段已存在忽略 } + 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 { 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 { "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", diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index a90ab78..64cac82 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -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 { + async getOrdersPool(opts: { fromLat?: number | null; fromLng?: number | null; radiusKm?: number | null } = {}): Promise { 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 { + 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 { diff --git a/server/src/index.ts b/server/src/index.ts index 876891f..93b94c6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -489,6 +489,99 @@ function assertCompanionActor( throw e; } +// ===== 腾讯位置服务(仅服务端调用,Key 绝不下发小程序)===== +let mapKeyCache: { key: string; at: number } | null = null; + +async function resolveTencentMapKey(): Promise { + 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 { + 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(); + +/** 地址 -> 坐标(腾讯 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 { + 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 { diff --git a/server/tests/lbs-distance.spec.ts b/server/tests/lbs-distance.spec.ts new file mode 100644 index 0000000..9e59c86 --- /dev/null +++ b/server/tests/lbs-distance.spec.ts @@ -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}'); + }); +}); diff --git a/server/tests/orders-pool-filter.spec.ts b/server/tests/orders-pool-filter.spec.ts index 7402a39..6c7b44e 100644 --- a/server/tests/orders-pool-filter.spec.ts +++ b/server/tests/orders-pool-filter.spec.ts @@ -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)'); }); });