定位获取城市功能,需要微信地图的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
+25
View File
@@ -70,6 +70,31 @@ export const DEFAULT_REGION: RegionSelection = {
district: '金水区',
};
export const FALLBACK_CITY_REGION: RegionSelection = {
province: '河南省',
city: '郑州市',
district: REGION_ALL,
};
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
const cityName = city.endsWith('市') ? city : `${city}`;
const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province;
const cities = getCities(provinceInTree);
const matchedCity = cities.includes(cityName)
? cityName
: cities.find((c) => c.replace(/市$/, '') === city.replace(/市$/, '')) ?? cityName;
const districts = getDistricts(provinceInTree, matchedCity);
const districtName =
district && districts.includes(district)
? district
: REGION_ALL;
return normalizeRegionSelection({
province: provinceInTree,
city: cities.includes(matchedCity) ? matchedCity : matchedCity,
district: districtName,
});
}
/** 校验已选地区是否仍存在于数据源中 */
export function normalizeRegionSelection(selection: RegionSelection): RegionSelection {
if (selection.province === REGION_ALL) {
+144
View File
@@ -0,0 +1,144 @@
import { getWechatLocationDetailed } from '@dukang/weixin-sdk';
import { apiBase } from './api';
import { weixinSdk } from './weixin';
import { regionFromGeo, type RegionSelection } from './region-data';
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
export const CITY_STORAGE_KEY = 'dukang_selected_city';
export const FALLBACK_CITY_CODE = '410100';
export type ResolvedUserCity = {
province: string;
city: string;
district: string;
cityCode?: string;
cityName?: string;
openCity: boolean;
region: RegionSelection;
displayCity: string;
};
type GpsCityCache = ResolvedUserCity & { timestamp: number };
function readCache(): GpsCityCache | null {
try {
const raw = sessionStorage.getItem(GPS_CITY_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as GpsCityCache;
if (Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
return parsed;
} catch {
return null;
}
}
function writeCache(data: ResolvedUserCity) {
sessionStorage.setItem(
GPS_CITY_STORAGE_KEY,
JSON.stringify({ ...data, timestamp: Date.now() } satisfies GpsCityCache),
);
}
async function reportLocationToServer(payload: {
latitude?: number;
longitude?: number;
sdk: 'jssdk' | 'geolocation';
status: 'success' | 'fail';
errMsg?: string;
}) {
const token = localStorage.getItem('accessToken');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': 'USER_H5',
};
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${apiBase}/common/wechat/location`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
const json = await res.json();
if (json.code !== 0) {
throw new Error(json.message || '定位上报失败');
}
return json.data as {
province?: string;
city?: string;
district?: string;
cityCode?: string;
cityName?: string;
openCity?: boolean;
};
}
function toResolved(data: {
province?: string;
city?: string;
district?: string;
cityCode?: string;
cityName?: string;
openCity?: boolean;
}): ResolvedUserCity | null {
if (!data.province || !data.city) return null;
const region = regionFromGeo(data.province, data.city, data.district);
const displayCity = data.cityName ?? (data.city.endsWith('市') ? data.city : `${data.city}`);
return {
province: data.province,
city: data.city,
district: data.district ?? '',
cityCode: data.cityCode,
cityName: data.cityName,
openCity: !!data.openCity,
region,
displayCity,
};
}
/** 获取并解析用户当前城市(微信 JSSDK 优先),失败返回 null */
export async function resolveUserCity(force = false): Promise<ResolvedUserCity | null> {
if (!force) {
const cached = readCache();
if (cached) return cached;
}
const outcome = await getWechatLocationDetailed({
apiBase,
clientApp: 'USER_H5',
getAccessToken: () => localStorage.getItem('accessToken'),
});
if (!outcome.location) {
await reportLocationToServer({
sdk: outcome.sdk,
status: 'fail',
errMsg: outcome.errMsg,
}).catch(() => {});
return null;
}
try {
const data = await reportLocationToServer({
latitude: outcome.location.latitude,
longitude: outcome.location.longitude,
sdk: outcome.sdk,
status: 'success',
});
const resolved = toResolved(data);
if (resolved) {
writeCache(resolved);
if (resolved.openCity && resolved.cityCode) {
localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode);
}
}
return resolved;
} catch {
return null;
}
}
export function syncCityCodeFromGps(resolved: ResolvedUserCity) {
if (resolved.openCity && resolved.cityCode) {
localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode);
}
}
+33 -7
View File
@@ -6,6 +6,11 @@ import TabMainHeader from '../components/TabMainHeader';
import AppToast from '../components/AppToast';
import { getProductImages } from '../lib/product-images';
import { track } from '../lib/analytics';
import {
CITY_STORAGE_KEY,
FALLBACK_CITY_CODE,
resolveUserCity,
} from '../lib/wechat-location';
import CouponBadge from '@dukang/shared-ui/CouponBadge';
type Product = {
@@ -34,18 +39,18 @@ const AROMA_TABS = [
{ key: 'NONGXIANG', label: '浓香型', open: false },
];
const CITY_STORAGE_KEY = 'dukang_selected_city';
export default function HomePage() {
const [tab, setTab] = useState('QINGXIANG');
const [products, setProducts] = useState<Product[]>([]);
const [cities, setCities] = useState<City[]>([]);
const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || '410100');
const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || FALLBACK_CITY_CODE);
const [locatedCityLabel, setLocatedCityLabel] = useState('');
const [citySource, setCitySource] = useState<'auto' | 'manual'>('auto');
const [toast, setToast] = useState('');
useEffect(() => {
track('home_view', { pagePath: '/' });
}, []);
track('home_view', { pagePath: '/', cityCode });
}, [cityCode]);
useEffect(() => {
request<City[]>('USER_H5', '/catalog/cities').then((list) => {
@@ -56,6 +61,21 @@ export default function HomePage() {
});
}, []);
useEffect(() => {
if (citySource !== 'auto') return;
resolveUserCity().then((resolved) => {
if (!resolved) return;
setLocatedCityLabel(resolved.displayCity);
if (resolved.openCity && resolved.cityCode) {
setCityCode(resolved.cityCode);
} else {
setCityCode(FALLBACK_CITY_CODE);
setToast('当前城市暂未开城,已展示郑州商品');
window.setTimeout(() => setToast(''), 2200);
}
});
}, [citySource]);
useEffect(() => {
if (!cityCode) return;
localStorage.setItem(CITY_STORAGE_KEY, cityCode);
@@ -76,6 +96,7 @@ export default function HomePage() {
}
const selectedCity = cities.find((c) => c.code === cityCode);
const headerCityLabel = locatedCityLabel || selectedCity?.name || '郑州市';
const filtered = products.filter((p) => p.aromaType === tab);
const onSale = tab === 'QINGXIANG';
@@ -86,10 +107,15 @@ export default function HomePage() {
extra={(
<div className="tab-main-city">
<span className="material-symbols-outlined">location_on</span>
<span className="tab-main-city-label">{headerCityLabel}</span>
<select
value={cityCode}
onChange={(e) => setCityCode(e.target.value)}
style={{ border: 'none', background: 'transparent', font: 'inherit', color: 'inherit' }}
onChange={(e) => {
setCitySource('manual');
setCityCode(e.target.value);
}}
className="tab-main-city-select"
aria-label="选择开城城市"
>
{cities.map((c) => (
<option key={c.code} value={c.code}>{c.name}</option>
+57 -11
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
@@ -7,10 +7,12 @@ import TabMainHeader from '../components/TabMainHeader';
import RegionPicker from '../components/RegionPicker';
import {
DEFAULT_REGION,
FALLBACK_CITY_REGION,
formatRegion,
REGION_ALL,
type RegionSelection,
} from '../lib/region-data';
import { FALLBACK_CITY_CODE, resolveUserCity } from '../lib/wechat-location';
type OpenCity = {
code: string;
@@ -62,6 +64,9 @@ export default function StoreListPage() {
const [keyword, setKeyword] = useState('');
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
const [filterMode, setFilterMode] = useState<'auto' | 'manual'>('auto');
const [usedFallback, setUsedFallback] = useState(false);
const [geoReady, setGeoReady] = useState(false);
const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]);
@@ -74,15 +79,45 @@ export default function StoreListPage() {
}, []);
useEffect(() => {
if (filterMode !== 'auto' || geoReady) return;
resolveUserCity().then((resolved) => {
if (resolved) {
setRegion(resolved.region);
}
setGeoReady(true);
});
}, [filterMode, geoReady]);
const fetchStores = useCallback(
async (targetRegion: RegionSelection, allowFallback: boolean) => {
const code = resolveCityCode(targetRegion, cities);
const qs = code ? `?cityCode=${encodeURIComponent(code)}` : '';
const data = await request<StoreItem[]>('USER_H5', `/stores${qs}`).catch(() => [] as StoreItem[]);
if (
allowFallback &&
filterMode === 'auto' &&
data.length === 0 &&
!usedFallback &&
code &&
code !== FALLBACK_CITY_CODE
) {
setUsedFallback(true);
setRegion(FALLBACK_CITY_REGION);
return null;
}
return data;
},
[cities, filterMode, usedFallback],
);
useEffect(() => {
if (filterMode === 'auto' && !geoReady) return;
let cancelled = false;
setLoading(true);
const qs = cityCode ? `?cityCode=${encodeURIComponent(cityCode)}` : '';
request<StoreItem[]>('USER_H5', `/stores${qs}`)
fetchStores(region, true)
.then((data) => {
if (!cancelled) setStores(data);
})
.catch(() => {
if (!cancelled) setStores([]);
if (!cancelled && data) setStores(data);
})
.finally(() => {
if (!cancelled) setLoading(false);
@@ -90,7 +125,7 @@ export default function StoreListPage() {
return () => {
cancelled = true;
};
}, [cityCode]);
}, [region, cities, filterMode, geoReady, fetchStores, usedFallback]);
const filtered = useMemo(() => {
let list = stores;
@@ -122,6 +157,10 @@ export default function StoreListPage() {
}, [stores, categoryTab, keyword, region, cityCode]);
const regionLabel = formatRegion(region.province, region.city, region.district);
const emptyMessage =
filterMode === 'manual' && filtered.length === 0
? '未找到匹配门店'
: '暂无门店';
return (
<div className="page store-page">
@@ -144,7 +183,10 @@ export default function StoreListPage() {
type="search"
placeholder="搜索门店名称或地址"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onChange={(e) => {
setFilterMode('manual');
setKeyword(e.target.value);
}}
/>
</div>
</div>
@@ -156,7 +198,10 @@ export default function StoreListPage() {
key={tab}
type="button"
className={`store-category-tab${categoryTab === tab ? ' active' : ''}`}
onClick={() => setCategoryTab(tab)}
onClick={() => {
setFilterMode('manual');
setCategoryTab(tab);
}}
>
{tab}
</button>
@@ -215,7 +260,7 @@ export default function StoreListPage() {
))}
{!loading && filtered.length === 0 && (
<div className="store-empty">{stores.length === 0 ? '暂无门店' : '未找到匹配门店'}</div>
<div className="store-empty">{emptyMessage}</div>
)}
{!loading && filtered.length > 0 && (
@@ -228,6 +273,7 @@ export default function StoreListPage() {
value={region}
onClose={() => setRegionPickerOpen(false)}
onConfirm={(next) => {
setFilterMode('manual');
setRegion(next);
setRegionPickerOpen(false);
}}
+18
View File
@@ -603,6 +603,24 @@
font-size: 12px;
font-weight: 500;
letter-spacing: 0.05em;
max-width: 46vw;
}
.tab-main-city-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 72px;
}
.tab-main-city-select {
border: none;
background: transparent;
font: inherit;
color: inherit;
max-width: 72px;
opacity: 0.85;
font-size: 11px;
}
.tab-main-city .material-symbols-outlined {
+3
View File
@@ -14,6 +14,8 @@ export interface AppConfig {
aliyunSmsTemplateCode: string;
aliyunSmsAccessKeyId: string;
aliyunSmsAccessKeySecret: string;
/** 腾讯位置服务 Key(逆地理编码) */
tencentLbsKey: string;
}
/** 总部客服电话(C 端联系客服) */
@@ -39,5 +41,6 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
aliyunSmsTemplateCode: e.ALIYUN_SMS_TEMPLATE_CODE ?? '',
aliyunSmsAccessKeyId: e.ALIYUN_SMS_ACCESS_KEY_ID ?? e.OSS_ACCESS_KEY_ID ?? '',
aliyunSmsAccessKeySecret: e.ALIYUN_SMS_ACCESS_KEY_SECRET ?? e.OSS_ACCESS_KEY_SECRET ?? '',
tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
};
}
+4 -1
View File
@@ -2,9 +2,11 @@ export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env';
export { initWechatJssdk, ensureJssdkReady, isJssdkReady } from './jssdk';
export {
getWechatLocation,
getWechatLocationDetailed,
canUseWechatLocation,
isWechatEnv,
} from './location';
export type { WechatLocationOutcome } from './location';
export { scanQrCode } from './scan';
export { invokeWechatPay } from './pay';
export { chooseWechatImages, canUseWechatChooseImage } from './chooseImage';
@@ -24,7 +26,7 @@ export { DEFAULT_JS_API_LIST } from './types';
import type { WeixinSdkConfig } from './types';
import { initWechatJssdk } from './jssdk';
import { getWechatLocation } from './location';
import { getWechatLocation, getWechatLocationDetailed } from './location';
import { scanQrCode } from './scan';
import { invokeWechatPay } from './pay';
import { chooseWechatImages } from './chooseImage';
@@ -45,6 +47,7 @@ export function createWeixinSdk(config: WeixinSdkConfig) {
bindWechatPhone(config, payload),
getPhoneNumber: () => getWechatPhoneNumber(config),
getLocation: () => getWechatLocation(config),
getLocationDetailed: () => getWechatLocationDetailed(config),
scanQrCode: () => scanQrCode(config),
chooseImages: (options?: Parameters<typeof chooseWechatImages>[1]) =>
chooseWechatImages(config, options),
+43 -8
View File
@@ -3,16 +3,29 @@ import { getRuntimePlatform, isWechatBrowser } from './env';
import { ensureJssdkReady } from './jssdk';
import type { WeixinSdkConfig } from './types';
export type WechatLocationOutcome = {
sdk: 'jssdk' | 'geolocation';
location: WechatGpsLocation | null;
errMsg?: string;
};
/** 获取 GPS 定位(微信 JSSDK / 小程序优先,否则 H5 Geolocation */
export async function getWechatLocation(config?: WeixinSdkConfig): Promise<WechatGpsLocation | null> {
const outcome = await getWechatLocationDetailed(config);
return outcome.location;
}
export async function getWechatLocationDetailed(
config?: WeixinSdkConfig,
): Promise<WechatLocationOutcome> {
const platform = getRuntimePlatform();
if (platform === 'mini' && window.wx?.getLocation) {
return new Promise((resolve) => {
window.wx!.getLocation!({
type: 'gcj02',
success: (res) => resolve(res),
fail: () => resolve(null),
success: (res) => resolve({ sdk: 'jssdk', location: res }),
fail: (res) => resolve({ sdk: 'jssdk', location: null, errMsg: res.errMsg }),
});
});
}
@@ -25,29 +38,51 @@ export async function getWechatLocation(config?: WeixinSdkConfig): Promise<Wecha
getAccessToken: config.getAccessToken,
});
if (window.wx?.getLocation) {
const jsApiOk = await new Promise<boolean>((resolve) => {
window.wx!.checkJsApi?.({
jsApiList: ['getLocation'],
success: (res) => resolve(!!res.checkResult?.getLocation),
fail: () => resolve(false),
});
});
if (!jsApiOk) {
return { sdk: 'jssdk', location: null, errMsg: 'getLocation 未授权或不可用' };
}
return new Promise((resolve) => {
window.wx!.getLocation!({
type: 'gcj02',
success: (res) => resolve(res),
fail: () => resolve(null),
success: (res) => resolve({ sdk: 'jssdk', location: res }),
fail: (res) => resolve({ sdk: 'jssdk', location: null, errMsg: res.errMsg }),
});
});
}
} catch {
/* fall through */
return { sdk: 'jssdk', location: null, errMsg: '微信 JSSDK getLocation 不可用' };
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
return { sdk: 'jssdk', location: null, errMsg };
}
}
if (typeof navigator === 'undefined' || !navigator.geolocation) return null;
if (typeof navigator === 'undefined' || !navigator.geolocation) {
return { sdk: 'geolocation', location: null, errMsg: '浏览器不支持定位' };
}
return new Promise((resolve) => {
navigator.geolocation.getCurrentPosition(
(pos) =>
resolve({
sdk: 'geolocation',
location: {
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
accuracy: pos.coords.accuracy,
},
}),
(err) =>
resolve({
sdk: 'geolocation',
location: null,
errMsg: err.message || '定位失败',
}),
() => resolve(null),
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 60_000 },
);
});
+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) {
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');
return {
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,
});
}
}