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 用例全绿
这个提交包含在:
xcbyy
2026-10-22 18:44:02 +08:00
父节点 b573c1ed05
当前提交 001f81eaca
共有 11 个文件被更改,包括 425 次插入 和 19 次删除

查看文件

@@ -115,6 +115,9 @@ export async function initDatabase(): Promise<void> {
audit_status VARCHAR(32) DEFAULT '已通过',
id_card_masked VARCHAR(32),
tags JSON,
latitude DECIMAL(10,7) DEFAULT NULL,
longitude DECIMAL(10,7) DEFAULT NULL,
last_active_at DATETIME DEFAULT NULL,
apply_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
@@ -132,6 +135,15 @@ export async function initDatabase(): Promise<void> {
try {
await conn.query("ALTER TABLE companions ADD COLUMN voice_intro_url VARCHAR(255) NOT NULL DEFAULT '' AFTER voice_title");
} catch (_) {}
try {
await conn.query('ALTER TABLE companions ADD COLUMN latitude DECIMAL(10,7) DEFAULT NULL AFTER tags');
} catch (_) {}
try {
await conn.query('ALTER TABLE companions ADD COLUMN longitude DECIMAL(10,7) DEFAULT NULL AFTER latitude');
} catch (_) {}
try {
await conn.query('ALTER TABLE companions ADD COLUMN last_active_at DATETIME DEFAULT NULL AFTER longitude');
} catch (_) {}
// 4. 订单表
await conn.query(`
@@ -796,6 +808,7 @@ export async function initDatabase(): Promise<void> {
['arbitrations', 'idx_arb_order', 'order_id'],
['companions', 'idx_comp_user', 'user_id'],
['companions', 'idx_comp_status', 'status'],
['companions', 'idx_comp_active', 'status, last_active_at'],
['companions', 'idx_comp_audit', 'audit_status'],
['payment_refunds', 'idx_pr_order', 'order_id'],
];

查看文件

@@ -898,6 +898,96 @@ export class MysqlRepository {
});
}
/** 大神接单心跳:刷新最后活跃时间,并在有定位时更新最近坐标(坐标不对外返回) */
async updateCompanionHeartbeat(companionId: number, lat?: number | null, lng?: number | null): Promise<void> {
const id = Number(companionId) || 0;
if (id <= 0) return;
const nLat = Number(lat);
const nLng = Number(lng);
const hasGeo =
Number.isFinite(nLat) && Number.isFinite(nLng) &&
Math.abs(nLat) <= 90 && Math.abs(nLng) <= 180 &&
!(nLat === 0 && nLng === 0);
if (hasGeo) {
await pool.query(
'UPDATE companions SET last_active_at = NOW(), latitude = ?, longitude = ? WHERE id = ?',
[nLat, nLng, id]
);
} else {
await pool.query('UPDATE companions SET last_active_at = NOW() WHERE id = ?', [id]);
}
}
/**
* 即时匹配候选:真实注册、审核通过、当前接单中、10 分钟内有心跳。
* 传入用户坐标时按 Haversine 距离过滤 5km 并升序;不返回精确经纬度,只返回距离。
*/
async matchCompanions(opts: {
lat?: number | null;
lng?: number | null;
radiusKm?: number | null;
excludeUserId?: number;
limit?: number;
} = {}): Promise<any[]> {
const excludeUserId = Number(opts.excludeUserId) || 0;
const limit = Math.min(20, Math.max(1, Number(opts.limit) || 10));
const [rows]: any = await pool.query(`
SELECT id, user_id as userId, name, avatar, gender, age, track, rank_name as \`rank\`,
hourly_rate as hourlyRate, rating, order_count as orderCount, status,
last_active_at as lastActiveAt, latitude, longitude
FROM companions
WHERE user_id IS NOT NULL AND user_id > 0 AND user_id <> ?
AND audit_status = '已通过'
AND status = '接单中'
AND last_active_at IS NOT NULL
AND last_active_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE)
ORDER BY rating DESC, order_count DESC, id ASC
LIMIT 200
`, [excludeUserId]);
const fromLat = Number(opts.lat);
const fromLng = Number(opts.lng);
const hasFrom =
Number.isFinite(fromLat) && Number.isFinite(fromLng) &&
Math.abs(fromLat) <= 90 && Math.abs(fromLng) <= 180 &&
!(fromLat === 0 && fromLng === 0);
const radius = Math.min(50, Math.max(1, Number(opts.radiusKm) || 5));
const list = (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 as number, lng as number) : null;
return {
id: String(r.id),
userId: Number(r.userId) || 0,
nickname: r.name || '',
avatar: r.avatar || '',
gender: r.gender || '',
age: Number(r.age) || 0,
track: r.track || '',
rankTag: r.rank || '',
price: Number(r.hourlyRate) || 0,
rating: Number(r.rating) || 0,
orderCount: Number(r.orderCount) || 0,
isOnline: true,
lastActiveAt: r.lastActiveAt,
distanceKm: distanceKm === null ? null : Number(distanceKm.toFixed(3)),
distanceText: distanceKm === null ? '同城' : formatDistanceText(distanceKm),
};
})
.filter((r: any) => (hasFrom ? r.distanceKm !== null && r.distanceKm <= radius : true));
list.sort((a: any, b: any) => {
if (a.distanceKm !== null && b.distanceKm !== null) return a.distanceKm - b.distanceKm;
if (a.distanceKm !== null) return -1;
if (b.distanceKm !== null) return 1;
return (b.rating - a.rating) || (b.orderCount - a.orderCount);
});
return list.slice(0, limit);
}
async getCompanionById(id: number | string): Promise<any> {
let c = await this.resolveCompanionRow(id);
// 是否为"非真实大神档案"的合成资料(用于对其隐藏虚构评分/价格/服务等)
@@ -1082,6 +1172,10 @@ export class MysqlRepository {
if (data.status !== undefined) {
fields.push('status = ?');
values.push(data.status);
// 切换为接单中时立即刷新活跃时间,避免必须等下一次心跳才进入匹配池
if (data.status === '接单中') {
fields.push('last_active_at = NOW()');
}
}
if (fields.length > 0) {

查看文件

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

查看文件

@@ -349,14 +349,14 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => {
const content = fs.readFileSync(indexVuePath, 'utf-8');
const apiContent = fs.readFileSync(path.join(rootDir, 'pwuniapp', 'common', 'api.js'), 'utf-8');
expect(content).toContain('await getCompanions()');
expect(content).toContain('Number(e.userId) > 0');
expect(content).toContain('Number(e.userId) !== uid');
expect(content).toContain('matchCompanions({ ...(pos || {}), radius: 5, limit: 20 })');
expect(content).toContain('lastMatchedId');
expect(content).toContain('matchedMeta');
expect(content).toContain('换一位');
expect(content).toContain('在线大神即时匹配');
expect(content).not.toContain('首位接单大神命中');
expect(apiContent).toContain('userId: Number(c.userId) || 0');
expect(apiContent).toContain('export async function matchCompanions');
});
});

查看文件

@@ -0,0 +1,81 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 大神 LBS 即时匹配与接单心跳闸门:
* - companions 必须持久化最近坐标与 last_active_at;
* - 匹配只取真实注册、审核通过、接单中、10 分钟内有心跳的大神;
* - 带用户坐标时按 Haversine 距离过滤 5km 并升序;
* - 大神页前台必须持续心跳,首页即时匹配必须走服务端匹配接口。
*/
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 api = read('pwuniapp/common/api.js');
const heartbeat = read('pwuniapp/common/expert-heartbeat.js');
const home = read('pwuniapp/pages/index/index.vue');
const workspace = read('pwuniapp/pages/expert/workspace.vue');
const hall = read('pwuniapp/pages/expert/hall.vue');
const radar = read('pwuniapp/pages/expert/radar.vue');
it('companions 表必须有坐标、最后活跃时间与匹配索引', () => {
expect(mysql).toContain('latitude DECIMAL(10,7) DEFAULT NULL');
expect(mysql).toContain('longitude DECIMAL(10,7) DEFAULT NULL');
expect(mysql).toContain('last_active_at DATETIME DEFAULT NULL');
expect(mysql).toContain("ALTER TABLE companions ADD COLUMN latitude");
expect(mysql).toContain("['companions', 'idx_comp_active', 'status, last_active_at']");
});
it('匹配仓储必须只取真实注册、审核通过、接单中且 10 分钟内有心跳的大神', () => {
expect(repo).toContain('async matchCompanions(');
expect(repo).toContain('user_id IS NOT NULL AND user_id > 0');
expect(repo).toContain("audit_status = '已通过'");
expect(repo).toContain("status = '接单中'");
expect(repo).toContain('last_active_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE)');
expect(repo).toContain('haversineKm(fromLat, fromLng');
expect(repo).toContain('distanceText');
expect(repo).toContain('async updateCompanionHeartbeat(');
// 接单中切换时立即刷新活跃时间
expect(repo).toContain("fields.push('last_active_at = NOW()')");
});
it('匹配接口必须在详情路由之前注册,心跳接口必须鉴权', () => {
const matchIdx = indexCode.indexOf("app.get('/api/companions/match'");
const detailIdx = indexCode.indexOf("app.get('/api/companions/:id'");
expect(matchIdx).toBeGreaterThan(-1);
expect(detailIdx).toBeGreaterThan(matchIdx);
expect(indexCode).toContain("app.post('/api/companions/heartbeat'");
expect(indexCode).toContain("'大神接单心跳'");
expect(indexCode).toContain('repo.matchCompanions({');
expect(indexCode).toContain('repo.updateCompanionHeartbeat(');
});
it('客户端必须提供匹配与心跳 API,且不返回精确坐标', () => {
expect(api).toContain('export async function matchCompanions');
expect(api).toContain('export async function sendCompanionHeartbeat');
expect(api).toContain('/companions/match?');
expect(api).toContain("http.post('/companions/heartbeat'");
expect(api).toContain('distanceText: c.distanceText');
});
it('大神工作台/大厅/雷达前台必须启动心跳,离开时停止', () => {
expect(heartbeat).toContain('export function startHeartbeat');
expect(heartbeat).toContain('export function stopHeartbeat');
expect(heartbeat).toContain('sendCompanionHeartbeat');
for (const page of [workspace, hall, radar]) {
expect(page).toContain('startHeartbeat()');
expect(page).toContain('stopHeartbeat()');
}
});
it('首页即时匹配必须调用服务端匹配接口并展示真实距离', () => {
expect(home).toContain('matchCompanions({ ...(pos || {}), radius: 5, limit: 20 })');
expect(home).toContain('getUserLocation');
expect(home).toContain('distanceText');
expect(home).toContain('暂无可匹配的接单大神');
});
});