feat(lbs): 大神真实 5km 即时匹配与接单心跳
- companions 新增 latitude/longitude/last_active_at 与 idx_comp_active;切换接单中立即刷新活跃时间 - 新增 /api/companions/heartbeat(鉴权)与 /api/companions/match(真实注册+审核通过+接单中+10分钟心跳,Haversine 5km 过滤与距离排序,不返回精确坐标) - 工作台/接单大厅/雷达前台每 60s 心跳,离开停止;首页即时匹配改为服务端 LBS 匹配并展示真实距离 - 线上实测:2010 匹配排除自己,仅命中 280m 的 8 号;匿名命中 9 号 10m、8 号 280m;北京 5km 为 0;118 套件/771 用例全绿
这个提交包含在:
@@ -1288,6 +1288,68 @@ app.get('/api/companions', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 即时匹配:仅返回真实注册、审核通过、心跳在线的接单大神;带坐标时按 5km 距离过滤并排序
|
||||
app.get('/api/companions/match', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const lat = Number(req.query.lat);
|
||||
const lng = Number(req.query.lng);
|
||||
const hasGeo =
|
||||
Number.isFinite(lat) && Number.isFinite(lng) &&
|
||||
Math.abs(lat) <= 90 && Math.abs(lng) <= 180 &&
|
||||
!(lat === 0 && lng === 0);
|
||||
const radiusKm = Math.min(50, Math.max(1, Number(req.query.radius) || 5));
|
||||
const limit = Math.min(20, Math.max(1, Number(req.query.limit) || 10));
|
||||
const excludeUserId = resolveRequestUserId(req) || 0;
|
||||
const list = await repo.matchCompanions({
|
||||
lat: hasGeo ? lat : null,
|
||||
lng: hasGeo ? lng : null,
|
||||
radiusKm,
|
||||
excludeUserId,
|
||||
limit,
|
||||
});
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: { list, total: list.length, radiusKm, hasGeo },
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ code: 50000, message: err.message || '即时匹配异常' });
|
||||
}
|
||||
});
|
||||
|
||||
// 大神接单心跳:刷新 last_active_at(有定位时同步最近坐标,坐标不对外返回)
|
||||
app.post('/api/companions/heartbeat', rateLimit({ name: 'companion_heartbeat', windowMs: 60 * 1000, max: 40, key: rateWriteKey }), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const uid = resolveSecureUserId(
|
||||
req,
|
||||
Number(req.body?.userId || req.headers['x-user-id'] || 0),
|
||||
'大神接单心跳'
|
||||
);
|
||||
const comp = await repo.getCompanionByUserId(uid);
|
||||
if (!comp) {
|
||||
return res.status(404).json({ code: 40400, message: '尚未入驻大神,无法上报接单状态' });
|
||||
}
|
||||
if (comp.audit_status !== '已通过') {
|
||||
return res.status(403).json({ code: 40300, message: '大神档案未通过审核,无法进入匹配池' });
|
||||
}
|
||||
const lat = Number(req.body?.lat);
|
||||
const lng = Number(req.body?.lng);
|
||||
await repo.updateCompanionHeartbeat(Number(comp.id), lat, lng);
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: {
|
||||
companionId: Number(comp.id),
|
||||
isOnline: comp.status === '接单中',
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
},
|
||||
message: '心跳已更新',
|
||||
});
|
||||
} catch (err: any) {
|
||||
const statusCode = err.status || 400;
|
||||
const errCode = err.code || 40000;
|
||||
res.status(statusCode).json({ code: errCode, message: err.message || '心跳上报失败' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/companions/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const companion = await repo.getCompanionById(String(req.params.id));
|
||||
|
||||
在新工单中引用
屏蔽一个用户