定位获取城市功能,需要微信地图的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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user