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

查看文件

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