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) { return buildTencentLbsRequestUrl(path, params, { key: this.getLbsKey(), secretKey: this.getLbsSecretKey(), }); } isEnabled() { return !!this.getLbsKey(); } /** 地址 → 坐标(正向地理编码) */ async geocodeAddress( address: string, actorRef?: WechatActorRef, ): Promise { 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 { 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 = { 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 }; } } }