Files
dukang/server/dukang-api/src/integrations/map/tencent-lbs.provider.ts
T
2026-08-04 21:38:49 +08:00

442 lines
13 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { loadAppConfig } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import type { WechatActorRef } from '../wechat/wechat-log.util';
import { buildTencentLbsRequestUrl } from './tencent-lbs.sign';
export type ReverseGeocodeResult = {
province: string;
city: string;
district: string;
logId: bigint;
};
export type GeocodeAddressResult = {
latitude: number;
longitude: number;
logId: bigint;
};
export type PlaceSuggestItem = {
id: string;
title: string;
address: string;
latitude: number;
longitude: number;
city?: string;
};
export type ReverseGeocodeDetailResult = {
latitude: number;
longitude: number;
address: string;
name?: string;
province: string;
city: string;
district: string;
logId: bigint;
};
function normalizeCityName(name: string) {
return name.replace(/市$/, '').trim();
}
type TencentPlaceRow = {
id?: string;
title?: string;
address?: string;
city?: string;
location?: { lat?: number; lng?: number };
};
@Injectable()
export class TencentLbsProvider {
private readonly logger = new Logger(TencentLbsProvider.name);
constructor(private readonly prisma: PrismaService) {}
/** 每次读取,避免构造时缓存、以及系统设置热更新后仍用旧 Key/SK */
private getLbsKey() {
return (loadAppConfig().tencentLbsKey || '').trim();
}
private getLbsSecretKey() {
return (loadAppConfig().tencentLbsSecretKey || '').trim();
}
private lbsUrl(path: string, params: Record<string, string>) {
return buildTencentLbsRequestUrl(path, params, {
key: this.getLbsKey(),
secretKey: this.getLbsSecretKey(),
});
}
isEnabled() {
return !!this.getLbsKey();
}
/** 地址 → 坐标(正向地理编码) */
async geocodeAddress(
address: string,
actorRef?: WechatActorRef,
): Promise<GeocodeAddressResult | null> {
const trimmed = address.replace(/\s+/g, '').trim();
const baseLog = {
provider: 'WECHAT_MAP' as const,
scene: 'GEOCODE',
refType: actorRef?.refType,
refId: actorRef?.refId,
requestUrl: 'https://apis.map.qq.com/ws/geocoder/v1/',
requestBody: { address: trimmed.slice(0, 200) },
};
if (!trimmed) return null;
if (!this.isEnabled()) {
await this.prisma.logThirdParty.create({
data: {
...baseLog,
status: 'FAILED',
errorMessage: 'TENCENT_LBS_KEY 未配置',
},
});
return null;
}
const url = this.lbsUrl('/ws/geocoder/v1', { address: trimmed });
try {
const res = await fetch(url);
const data = (await res.json()) as {
status?: number;
message?: string;
result?: { location?: { lat?: number; lng?: number } };
};
const loc = data.result?.location;
const ok =
data.status === 0 &&
typeof loc?.lat === 'number' &&
typeof loc?.lng === 'number' &&
Number.isFinite(loc.lat) &&
Number.isFinite(loc.lng);
const log = await this.prisma.logThirdParty.create({
data: {
...baseLog,
responseBody: {
status: data.status,
message: data.message,
lat: loc?.lat,
lng: loc?.lng,
},
status: ok ? 'SUCCESS' : 'FAILED',
errorMessage: ok ? undefined : data.message ?? '地理编码失败',
},
});
if (!ok || !loc) return null;
return { latitude: loc.lat!, longitude: loc.lng!, logId: log.id };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Tencent LBS geocode failed: ${message}`);
await this.prisma.logThirdParty.create({
data: {
...baseLog,
status: 'FAILED',
errorMessage: message.slice(0, 512),
},
});
return null;
}
}
async reverseGeocode(
latitude: number,
longitude: number,
actorRef?: WechatActorRef,
): Promise<ReverseGeocodeResult | null> {
const baseLog = {
provider: 'WECHAT_MAP' as const,
scene: 'REVERSE_GEOCODE',
refType: actorRef?.refType,
refId: actorRef?.refId,
requestUrl: 'https://apis.map.qq.com/ws/geocoder/v1/',
requestBody: {
latitude: Number(latitude.toFixed(6)),
longitude: Number(longitude.toFixed(6)),
},
};
if (!this.isEnabled()) {
const log = await this.prisma.logThirdParty.create({
data: {
...baseLog,
status: 'FAILED',
errorMessage: 'TENCENT_LBS_KEY 未配置',
},
});
this.logger.warn('Tencent LBS key missing, skip reverse geocode');
return null;
}
const url = this.lbsUrl('/ws/geocoder/v1', {
location: `${latitude},${longitude}`,
get_poi: '0',
});
try {
const res = await fetch(url);
const data = (await res.json()) as {
status?: number;
message?: string;
result?: {
ad_info?: {
province?: string;
city?: string;
district?: string;
};
};
};
const ad = data.result?.ad_info;
const ok = data.status === 0 && !!ad?.city;
const responseBody = {
status: data.status,
message: data.message,
province: ad?.province,
city: ad?.city,
district: ad?.district,
};
const log = await this.prisma.logThirdParty.create({
data: {
...baseLog,
responseBody,
status: ok ? 'SUCCESS' : 'FAILED',
errorMessage: ok ? undefined : data.message ?? '逆地理编码失败',
},
});
if (!ok || !ad?.province || !ad?.city) {
return null;
}
return {
province: ad.province,
city: normalizeCityName(ad.city),
district: ad.district ?? '',
logId: log.id,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Tencent LBS reverse geocode failed: ${message}`);
await this.prisma.logThirdParty.create({
data: {
...baseLog,
status: 'FAILED',
errorMessage: message.slice(0, 512),
},
});
return null;
}
}
private mapPlaceRows(rows: TencentPlaceRow[] | undefined): PlaceSuggestItem[] {
if (!rows?.length) return [];
const out: PlaceSuggestItem[] = [];
for (const row of rows) {
const lat = Number(row.location?.lat);
const lng = Number(row.location?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
const title = (row.title || '').trim();
const address = (row.address || '').trim();
if (!title && !address) continue;
out.push({
id: String(row.id || `${lat},${lng}`),
title: title || address,
address: address || title,
latitude: lat,
longitude: lng,
city: row.city?.trim() || undefined,
});
}
return out;
}
/** 关键词输入提示(地点搜索) */
async suggestPlaces(
keyword: string,
options?: { region?: string; latitude?: number; longitude?: number },
): Promise<{ items: PlaceSuggestItem[]; error?: string }> {
const trimmed = keyword.trim();
if (!trimmed) return { items: [] };
if (!this.isEnabled()) {
return { items: [], error: 'TENCENT_LBS_KEY 未配置' };
}
const params: Record<string, string> = {
keyword: trimmed.slice(0, 64),
policy: '1',
page_index: '1',
page_size: '20',
};
const region = options?.region?.trim();
if (region) params.region = region;
if (
options?.latitude != null &&
options?.longitude != null &&
Number.isFinite(options.latitude) &&
Number.isFinite(options.longitude)
) {
params.location = `${options.latitude},${options.longitude}`;
}
try {
const res = await fetch(this.lbsUrl('/ws/place/v1/suggestion', params));
const data = (await res.json()) as {
status?: number;
message?: string;
data?: TencentPlaceRow[];
};
if (data.status !== 0) {
this.logger.warn(`Tencent LBS suggest failed: ${data.message ?? data.status}`);
return { items: [], error: data.message || '地点搜索失败' };
}
return { items: this.mapPlaceRows(data.data) };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Tencent LBS suggest failed: ${message}`);
return { items: [], error: message };
}
}
/** 周边地点(打开选点时预填附近列表) */
async exploreNearby(
latitude: number,
longitude: number,
radiusMeters = 1000,
): Promise<{ items: PlaceSuggestItem[]; error?: string }> {
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return { items: [], error: '经纬度无效' };
}
if (!this.isEnabled()) {
return { items: [], error: 'TENCENT_LBS_KEY 未配置' };
}
const radius = Math.min(5000, Math.max(200, Math.round(radiusMeters)));
const url = this.lbsUrl('/ws/place/v1/explore', {
boundary: `nearby(${latitude},${longitude},${radius})`,
policy: '1',
page_size: '20',
});
try {
const res = await fetch(url);
const data = (await res.json()) as {
status?: number;
message?: string;
data?: TencentPlaceRow[];
};
if (data.status !== 0) {
this.logger.warn(`Tencent LBS explore failed: ${data.message ?? data.status}`);
return { items: [], error: data.message || '周边检索失败' };
}
return { items: this.mapPlaceRows(data.data) };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Tencent LBS explore failed: ${message}`);
return { items: [], error: message };
}
}
/** 逆地理(含地址文案,供选点回填) */
async reverseGeocodeDetail(
latitude: number,
longitude: number,
): Promise<{ item: ReverseGeocodeDetailResult | null; error?: string }> {
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return { item: null, error: '经纬度无效' };
}
if (!this.isEnabled()) {
return { item: null, error: 'TENCENT_LBS_KEY 未配置' };
}
const url = this.lbsUrl('/ws/geocoder/v1', {
location: `${latitude},${longitude}`,
get_poi: '1',
});
try {
const res = await fetch(url);
const data = (await res.json()) as {
status?: number;
message?: string;
result?: {
address?: string;
formatted_addresses?: { recommend?: string; rough?: string };
address_component?: {
province?: string;
city?: string;
district?: string;
street?: string;
street_number?: string;
};
ad_info?: {
province?: string;
city?: string;
district?: string;
};
pois?: Array<{ title?: string; address?: string }>;
};
};
if (data.status !== 0 || !data.result) {
return { item: null, error: data.message || '逆地理编码失败' };
}
const result = data.result;
const ad = result.ad_info ?? result.address_component;
const province = ad?.province ?? '';
const city = normalizeCityName(ad?.city ?? '');
const district = ad?.district ?? '';
const recommend =
result.formatted_addresses?.recommend?.trim() ||
result.formatted_addresses?.rough?.trim() ||
result.address?.trim() ||
'';
const poiTitle = result.pois?.[0]?.title?.trim();
if (!recommend && !poiTitle) {
return { item: null, error: '未解析到地址' };
}
const log = await this.prisma.logThirdParty.create({
data: {
provider: 'WECHAT_MAP',
scene: 'REVERSE_GEOCODE',
requestUrl: 'https://apis.map.qq.com/ws/geocoder/v1/',
requestBody: { latitude, longitude, detail: true },
responseBody: {
status: data.status,
address: recommend,
city,
},
status: 'SUCCESS',
},
});
return {
item: {
latitude,
longitude,
address: recommend || poiTitle || '',
name: poiTitle || recommend || undefined,
province,
city,
district,
logId: log.id,
},
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Tencent LBS reverse detail failed: ${message}`);
return { item: null, error: message };
}
}
}