定位获取城市功能,需要微信地图的key?
This commit is contained in:
@@ -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}×tamp=${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}×tamp=${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: {
|
||||
|
||||
Reference in New Issue
Block a user