定位获取城市功能,需要微信地图的key?

This commit is contained in:
2026-07-06 14:02:07 +08:00
parent 67af6d7d53
commit c87d79109a
18 changed files with 792 additions and 70 deletions
+3
View File
@@ -37,6 +37,9 @@ WX_API_V3_KEY=
WX_PLATFORM_CERT=
WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay
# 腾讯位置服务(逆地理编码,微信定位展示城市)
TENCENT_LBS_KEY=
# 阿里云 OSSali-oss@6.xOSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL)
# RAM 用户需具备 PutObject 权限;可在控制台 Bucket 授权策略中为该 RAM UID 授予读写
# 文档:https://help.aliyun.com/zh/oss/user-guide/use-bucket-policy-to-grant-permission-to-access-oss/
@@ -3,3 +3,4 @@ export const PAY_PROVIDER = 'PAY_PROVIDER';
export const DELIVERY_PROVIDER = 'DELIVERY_PROVIDER';
export const WECHAT_PROVIDER = 'WECHAT_PROVIDER';
export const OSS_PROVIDER = 'OSS_PROVIDER';
export const MAP_PROVIDER = 'MAP_PROVIDER';
@@ -11,12 +11,14 @@ import { WechatApiProvider } from './wechat/wechat.api.provider';
import { WechatDisabledProvider } from './wechat/wechat.disabled.provider';
import { OssMockProvider } from './oss/oss.mock.provider';
import { OssAliyunProvider } from './oss/oss.aliyun.provider';
import { TencentLbsProvider } from './map/tencent-lbs.provider';
import {
SMS_PROVIDER,
PAY_PROVIDER,
DELIVERY_PROVIDER,
WECHAT_PROVIDER,
OSS_PROVIDER,
MAP_PROVIDER,
} from './integrations.constants';
import { CourierModule } from './courier/courier.module';
import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
@@ -78,7 +80,9 @@ import type { ISmsProvider } from './sms/sms.interface';
inject: [OssMockProvider, OssAliyunProvider],
},
DeliveryMockProvider,
TencentLbsProvider,
{ provide: MAP_PROVIDER, useExisting: TencentLbsProvider },
],
exports: [SMS_PROVIDER, SmsCodeStore, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, CourierModule],
exports: [SMS_PROVIDER, SmsCodeStore, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, MAP_PROVIDER, CourierModule],
})
export class IntegrationsModule {}
@@ -0,0 +1,119 @@
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';
export type ReverseGeocodeResult = {
province: string;
city: string;
district: string;
logId: bigint;
};
function normalizeCityName(name: string) {
return name.replace(/市$/, '').trim();
}
@Injectable()
export class TencentLbsProvider {
private readonly logger = new Logger(TencentLbsProvider.name);
private readonly config = loadAppConfig();
constructor(private readonly prisma: PrismaService) {}
isEnabled() {
return !!this.config.tencentLbsKey;
}
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 location = `${latitude},${longitude}`;
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
url.searchParams.set('location', location);
url.searchParams.set('key', this.config.tencentLbsKey);
url.searchParams.set('get_poi', '0');
try {
const res = await fetch(url.toString());
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;
}
}
}
@@ -0,0 +1,35 @@
import type { PrismaService } from '../../common/prisma/prisma.module';
export type WechatActorRef = {
refType: string;
refId: bigint;
};
type LogWechatAuthInput = {
scene: string;
requestUrl?: string;
requestBody?: Record<string, unknown>;
responseBody?: Record<string, unknown>;
externalNo?: string;
status: 'SUCCESS' | 'FAILED';
errorMessage?: string;
actorRef?: WechatActorRef;
};
export async function logWechatAuth(prisma: PrismaService, input: LogWechatAuthInput) {
const row = await prisma.logThirdParty.create({
data: {
provider: 'WECHAT_AUTH',
scene: input.scene,
refType: input.actorRef?.refType,
refId: input.actorRef?.refId,
requestUrl: input.requestUrl?.slice(0, 512),
requestBody: input.requestBody as never,
responseBody: input.responseBody as never,
externalNo: input.externalNo,
status: input.status,
errorMessage: input.errorMessage?.slice(0, 512),
},
});
return row.id;
}
@@ -2,7 +2,9 @@ import { createDecipheriv, createHash, createSign, randomBytes, randomUUID } fro
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { loadAppConfig } from '@dukang/shared-types';
import { RedisService } from '../../common/redis/redis.service';
import { PrismaService } from '../../common/prisma/prisma.module';
import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './wechat.interface';
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
import {
decryptPayResource,
verifyPaySignature,
@@ -28,7 +30,10 @@ export class WechatApiProvider implements IWechatProvider {
private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
private readonly platformCert = (process.env.WX_PLATFORM_CERT ?? '').replace(/\\n/g, '\n');
constructor(private readonly redis: RedisService) {}
constructor(
private readonly redis: RedisService,
private readonly prisma: PrismaService,
) {}
isEnabled() {
return this.config.wechatAuthEnabled && !!this.appId && !!this.appSecret;
@@ -60,19 +65,37 @@ export class WechatApiProvider implements IWechatProvider {
return `https://open.weixin.qq.com/connect/oauth2/authorize?${qs.toString()}#wechat_redirect`;
}
async code2Session(code: string): Promise<WechatCodeSession> {
async code2Session(code: string, actorRef?: WechatActorRef): Promise<WechatCodeSession> {
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
url.searchParams.set('appid', this.appId);
url.searchParams.set('secret', this.appSecret);
url.searchParams.set('secret', '***');
url.searchParams.set('js_code', code);
url.searchParams.set('grant_type', 'authorization_code');
const apiUrl = new URL('https://api.weixin.qq.com/sns/jscode2session');
apiUrl.searchParams.set('appid', this.appId);
apiUrl.searchParams.set('secret', this.appSecret);
apiUrl.searchParams.set('js_code', code);
apiUrl.searchParams.set('grant_type', 'authorization_code');
const data = await this.fetchJson<{
openid?: string;
unionid?: string;
session_key?: string;
errcode?: number;
errmsg?: string;
}>(url.toString());
}>(apiUrl.toString());
const ok = !!data.openid;
await logWechatAuth(this.prisma, {
scene: 'LOGIN',
requestUrl: url.toString(),
requestBody: { grant_type: 'authorization_code', platform: 'mini' },
responseBody: ok
? { openid: data.openid, unionid: data.unionid }
: { errcode: data.errcode, errmsg: data.errmsg },
externalNo: data.openid,
status: ok ? 'SUCCESS' : 'FAILED',
errorMessage: ok ? undefined : data.errmsg || '微信 code2session 失败',
actorRef,
});
if (!data.openid) {
throw new InternalServerErrorException(data.errmsg || '微信 code2session 失败');
}
@@ -83,12 +106,17 @@ export class WechatApiProvider implements IWechatProvider {
};
}
async oauth2AccessToken(code: string): Promise<WechatOAuthSession> {
const url = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
url.searchParams.set('appid', this.appId);
url.searchParams.set('secret', this.appSecret);
url.searchParams.set('code', code);
url.searchParams.set('grant_type', 'authorization_code');
async oauth2AccessToken(code: string, actorRef?: WechatActorRef): Promise<WechatOAuthSession> {
const maskedUrl = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
maskedUrl.searchParams.set('appid', this.appId);
maskedUrl.searchParams.set('secret', '***');
maskedUrl.searchParams.set('code', code);
maskedUrl.searchParams.set('grant_type', 'authorization_code');
const apiUrl = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
apiUrl.searchParams.set('appid', this.appId);
apiUrl.searchParams.set('secret', this.appSecret);
apiUrl.searchParams.set('code', code);
apiUrl.searchParams.set('grant_type', 'authorization_code');
const data = await this.fetchJson<{
openid?: string;
unionid?: string;
@@ -96,7 +124,20 @@ export class WechatApiProvider implements IWechatProvider {
refresh_token?: string;
errcode?: number;
errmsg?: string;
}>(url.toString());
}>(apiUrl.toString());
const ok = !!data.openid;
await logWechatAuth(this.prisma, {
scene: 'LOGIN',
requestUrl: maskedUrl.toString(),
requestBody: { grant_type: 'authorization_code', platform: 'h5' },
responseBody: ok
? { openid: data.openid, unionid: data.unionid }
: { errcode: data.errcode, errmsg: data.errmsg },
externalNo: data.openid,
status: ok ? 'SUCCESS' : 'FAILED',
errorMessage: ok ? undefined : data.errmsg || '微信 OAuth 失败',
actorRef,
});
if (!data.openid) {
throw new InternalServerErrorException(data.errmsg || '微信 OAuth 失败');
}
@@ -108,37 +149,69 @@ export class WechatApiProvider implements IWechatProvider {
};
}
async createJssdkConfig(url: string) {
const ticket = await this.getJsapiTicket();
const nonceStr = randomBytes(8).toString('hex');
const timestamp = Math.floor(Date.now() / 1000);
const raw = `jsapi_ticket=${ticket}&noncestr=${nonceStr}&timestamp=${timestamp}&url=${url}`;
const signature = createHash('sha1').update(raw).digest('hex');
return {
appId: this.appId,
timestamp,
nonceStr,
signature,
jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay', 'chooseImage', 'getLocalImgData'],
};
async createJssdkConfig(url: string, actorRef?: WechatActorRef) {
try {
const ticket = await this.getJsapiTicket();
const nonceStr = randomBytes(8).toString('hex');
const timestamp = Math.floor(Date.now() / 1000);
const raw = `jsapi_ticket=${ticket}&noncestr=${nonceStr}&timestamp=${timestamp}&url=${url}`;
const signature = createHash('sha1').update(raw).digest('hex');
const config = {
appId: this.appId,
timestamp,
nonceStr,
signature,
jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay', 'chooseImage', 'getLocalImgData'],
};
await logWechatAuth(this.prisma, {
scene: 'JSSDK_CONFIG',
requestUrl: url.split('#')[0],
requestBody: { appId: this.appId },
responseBody: { appId: this.appId, timestamp, nonceStr },
status: 'SUCCESS',
actorRef,
});
return config;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await logWechatAuth(this.prisma, {
scene: 'JSSDK_CONFIG',
requestUrl: url.split('#')[0],
requestBody: { appId: this.appId },
status: 'FAILED',
errorMessage: message,
actorRef,
});
throw err;
}
}
async getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise<string> {
async getPhoneNumberByCode(code: string, platform: 'mini' | 'h5', actorRef?: WechatActorRef): Promise<string> {
if (platform === 'h5') {
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
}
const accessToken = await this.getAccessToken();
const url = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
const apiUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
const data = await this.fetchJson<{
errcode?: number;
errmsg?: string;
phone_info?: { phoneNumber?: string; purePhoneNumber?: string };
}>(url, {
}>(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber;
const ok = !!phone;
await logWechatAuth(this.prisma, {
scene: 'BIND_PHONE',
requestUrl: 'https://api.weixin.qq.com/wxa/business/getuserphonenumber',
requestBody: { platform },
responseBody: ok ? { phone: `${phone!.slice(0, 3)}****${phone!.slice(-4)}` } : { errcode: data.errcode, errmsg: data.errmsg },
status: ok ? 'SUCCESS' : 'FAILED',
errorMessage: ok ? undefined : data.errmsg || '获取手机号失败',
actorRef,
});
if (!phone) {
throw new InternalServerErrorException(data.errmsg || '获取手机号失败');
}
@@ -31,19 +31,23 @@ export interface IWechatProvider {
getMchId(): string;
/** 小程序 code2session */
code2Session(code: string): Promise<WechatCodeSession>;
code2Session(code: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatCodeSession>;
/** 公众号 H5 OAuth code 换 openId */
oauth2AccessToken(code: string): Promise<WechatOAuthSession>;
oauth2AccessToken(code: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatOAuthSession>;
/** JSSDK 签名配置 */
createJssdkConfig(url: string): Promise<WechatJssdkConfig>;
createJssdkConfig(url: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatJssdkConfig>;
/** 构建公众号 OAuth 授权 URL */
buildOAuthUrl(redirectUri: string, state: string, scope?: string): string;
/** 小程序手机号 code 解密(或调用微信 getPhoneNumber 接口) */
getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise<string>;
getPhoneNumberByCode(
code: string,
platform: 'mini' | 'h5',
actorRef?: { refType: string; refId: bigint },
): Promise<string>;
/** 创建 JSAPI 预支付参数(使用 WX_MCH_ID 统一下单) */
createJsapiPrepay(params: {
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { AnalyticsModule } from '../analytics/analytics.module';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { ResourceService } from './resource.service';
import { EventService } from './event.service';
@@ -11,9 +12,10 @@ import { TicketController } from './ticket.controller';
import { ThirdPartyLogController } from './third-party-log.controller';
import { WechatController } from './wechat.controller';
import { ClientConfigController } from './client-config.controller';
import { WechatLocationService } from './wechat-location.service';
@Module({
imports: [IamModule, IntegrationsModule],
imports: [IamModule, IntegrationsModule, forwardRef(() => AnalyticsModule)],
controllers: [
ResourceController,
EventController,
@@ -22,7 +24,7 @@ import { ClientConfigController } from './client-config.controller';
WechatController,
ClientConfigController,
],
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService],
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService, WechatLocationService],
exports: [ResourceService, EventService, TicketService],
})
export class CommonModule {}
@@ -0,0 +1,131 @@
import { Injectable } from '@nestjs/common';
import { ClientApp } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
import { logWechatAuth, type WechatActorRef } from '../../integrations/wechat/wechat-log.util';
import { AnalyticsService } from '../analytics/analytics.service';
export type ReportWechatLocationInput = {
latitude?: number;
longitude?: number;
sdk: 'jssdk' | 'geolocation';
status: 'success' | 'fail';
errMsg?: string;
clientApp?: string;
userId?: bigint;
};
export type ReportWechatLocationResult = {
province?: string;
city?: string;
district?: string;
cityCode?: string;
cityName?: string;
openCity: boolean;
thirdPartyLogIds: {
location?: string;
geocode?: string;
};
};
function normalizeCityName(name: string) {
return name.replace(/市$/, '').trim();
}
function matchOpenCity(
cities: Array<{ code: string; name: string; province: string }>,
province: string,
city: string,
) {
const cityNorm = normalizeCityName(city);
return cities.find((c) => {
const nameNorm = normalizeCityName(c.name);
if (nameNorm !== cityNorm && c.name !== city && c.name !== `${cityNorm}`) return false;
if (c.province && province && c.province !== province) return false;
return true;
});
}
@Injectable()
export class WechatLocationService {
constructor(
private readonly prisma: PrismaService,
private readonly tencentLbs: TencentLbsProvider,
private readonly analyticsService: AnalyticsService,
) {}
async reportLocation(input: ReportWechatLocationInput): Promise<ReportWechatLocationResult> {
const actorRef: WechatActorRef | undefined = input.userId
? { refType: 'USER', refId: input.userId }
: undefined;
const clientApp = (input.clientApp as ClientApp) || ClientApp.USER_H5;
const thirdPartyLogIds: ReportWechatLocationResult['thirdPartyLogIds'] = {};
const locationLogId = await logWechatAuth(this.prisma, {
scene: 'GET_LOCATION',
requestBody: {
sdk: input.sdk,
status: input.status,
...(input.latitude != null && input.longitude != null
? {
latitude: Number(input.latitude.toFixed(3)),
longitude: Number(input.longitude.toFixed(3)),
}
: {}),
...(input.errMsg ? { errMsg: input.errMsg.slice(0, 200) } : {}),
},
responseBody: { reported: true },
status: input.status === 'success' ? 'SUCCESS' : 'FAILED',
errorMessage: input.status === 'fail' ? input.errMsg?.slice(0, 512) : undefined,
actorRef,
});
thirdPartyLogIds.location = locationLogId.toString();
if (input.status !== 'success' || input.latitude == null || input.longitude == null) {
return { openCity: false, thirdPartyLogIds };
}
const geo = await this.tencentLbs.reverseGeocode(input.latitude, input.longitude, actorRef);
if (geo) {
thirdPartyLogIds.geocode = geo.logId.toString();
}
if (!geo) {
return { openCity: false, thirdPartyLogIds };
}
const openCities = await this.prisma.commonCity.findMany({
where: { status: 'ACTIVE' },
select: { code: true, name: true, province: true },
});
const matched = matchOpenCity(openCities, geo.province, geo.city);
const result: ReportWechatLocationResult = {
province: geo.province,
city: geo.city,
district: geo.district,
cityCode: matched?.code,
cityName: matched?.name ?? `${geo.city}`,
openCity: !!matched,
thirdPartyLogIds,
};
if (input.userId) {
const mapLogId = geo.logId;
this.analyticsService.trackOneSafe(input.userId, clientApp, {
eventName: 'wechat_location',
refType: 'THIRD_PARTY_LOG',
refId: mapLogId,
extraJson: {
sdk: input.sdk,
province: geo.province,
city: geo.city,
district: geo.district,
openCity: !!matched,
cityCode: matched?.code,
},
});
}
return result;
}
}
@@ -1,7 +1,12 @@
import { BadRequestException, Body, Controller, Get, Inject, Post, Query } from '@nestjs/common';
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { BadRequestException, Body, Controller, Get, Inject, Post, Query, Req, UseGuards } from '@nestjs/common';
import { IsIn, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
import { ClientApp } from '@dukang/shared-types';
import type { Request } from 'express';
import { WECHAT_PROVIDER } from '../../integrations/integrations.constants';
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { WechatLocationService } from './wechat-location.service';
class PhoneNumberDto {
@IsString()
@@ -14,15 +19,43 @@ class PhoneNumberDto {
platform?: 'mini' | 'h5';
}
class WechatLocationDto {
@IsNumber()
@IsOptional()
latitude?: number;
@IsNumber()
@IsOptional()
longitude?: number;
@IsString()
@IsIn(['jssdk', 'geolocation'])
sdk: 'jssdk' | 'geolocation';
@IsString()
@IsIn(['success', 'fail'])
status: 'success' | 'fail';
@IsString()
@IsOptional()
errMsg?: string;
}
@Controller('common/wechat')
export class WechatController {
constructor(@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider) {}
constructor(
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
private readonly locationService: WechatLocationService,
) {}
@Get('jssdk-config')
async jssdkConfig(@Query('url') url: string) {
async jssdkConfig(@Query('url') url: string, @Req() req: Request) {
if (!url) throw new BadRequestException('url 参数必填');
const pageUrl = decodeURIComponent(url).split('#')[0];
return this.wechat.createJssdkConfig(pageUrl);
const user = (req as Request & { user?: AuthUser }).user;
const actorRef =
user?.actorType === 'USER' ? { refType: 'USER', refId: user.actorId } : undefined;
return this.wechat.createJssdkConfig(pageUrl, actorRef);
}
@Get('oauth-url')
@@ -41,4 +74,21 @@ export class WechatController {
.getPhoneNumberByCode(dto.code, dto.platform ?? 'mini')
.then((phone) => ({ phone }));
}
@Post('location')
@UseGuards(OptionalJwtAuthGuard)
reportLocation(@Req() req: Request, @Body() dto: WechatLocationDto) {
const user = (req as Request & { user?: AuthUser }).user;
const userId = user?.actorType === 'USER' ? user.actorId : undefined;
const clientApp = (req.headers['x-client-app'] as string) || ClientApp.USER_H5;
return this.locationService.reportLocation({
latitude: dto.latitude,
longitude: dto.longitude,
sdk: dto.sdk,
status: dto.status,
errMsg: dto.errMsg,
clientApp,
userId,
});
}
}