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 次删除

查看文件

@@ -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> {