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

查看文件

@@ -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)');
});
});