442 lines
13 KiB
TypeScript
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 };
|
|
}
|
|
}
|
|
}
|