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

查看文件

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