定位获取城市功能,需要微信地图的key?
This commit is contained in:
@@ -70,6 +70,31 @@ export const DEFAULT_REGION: RegionSelection = {
|
|||||||
district: '金水区',
|
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 {
|
export function normalizeRegionSelection(selection: RegionSelection): RegionSelection {
|
||||||
if (selection.province === REGION_ALL) {
|
if (selection.province === REGION_ALL) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ import TabMainHeader from '../components/TabMainHeader';
|
|||||||
import AppToast from '../components/AppToast';
|
import AppToast from '../components/AppToast';
|
||||||
import { getProductImages } from '../lib/product-images';
|
import { getProductImages } from '../lib/product-images';
|
||||||
import { track } from '../lib/analytics';
|
import { track } from '../lib/analytics';
|
||||||
|
import {
|
||||||
|
CITY_STORAGE_KEY,
|
||||||
|
FALLBACK_CITY_CODE,
|
||||||
|
resolveUserCity,
|
||||||
|
} from '../lib/wechat-location';
|
||||||
import CouponBadge from '@dukang/shared-ui/CouponBadge';
|
import CouponBadge from '@dukang/shared-ui/CouponBadge';
|
||||||
|
|
||||||
type Product = {
|
type Product = {
|
||||||
@@ -34,18 +39,18 @@ const AROMA_TABS = [
|
|||||||
{ key: 'NONGXIANG', label: '浓香型', open: false },
|
{ key: 'NONGXIANG', label: '浓香型', open: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CITY_STORAGE_KEY = 'dukang_selected_city';
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [tab, setTab] = useState('QINGXIANG');
|
const [tab, setTab] = useState('QINGXIANG');
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [cities, setCities] = useState<City[]>([]);
|
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('');
|
const [toast, setToast] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
track('home_view', { pagePath: '/' });
|
track('home_view', { pagePath: '/', cityCode });
|
||||||
}, []);
|
}, [cityCode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<City[]>('USER_H5', '/catalog/cities').then((list) => {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!cityCode) return;
|
if (!cityCode) return;
|
||||||
localStorage.setItem(CITY_STORAGE_KEY, cityCode);
|
localStorage.setItem(CITY_STORAGE_KEY, cityCode);
|
||||||
@@ -76,6 +96,7 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedCity = cities.find((c) => c.code === cityCode);
|
const selectedCity = cities.find((c) => c.code === cityCode);
|
||||||
|
const headerCityLabel = locatedCityLabel || selectedCity?.name || '郑州市';
|
||||||
const filtered = products.filter((p) => p.aromaType === tab);
|
const filtered = products.filter((p) => p.aromaType === tab);
|
||||||
const onSale = tab === 'QINGXIANG';
|
const onSale = tab === 'QINGXIANG';
|
||||||
|
|
||||||
@@ -86,10 +107,15 @@ export default function HomePage() {
|
|||||||
extra={(
|
extra={(
|
||||||
<div className="tab-main-city">
|
<div className="tab-main-city">
|
||||||
<span className="material-symbols-outlined">location_on</span>
|
<span className="material-symbols-outlined">location_on</span>
|
||||||
|
<span className="tab-main-city-label">{headerCityLabel}</span>
|
||||||
<select
|
<select
|
||||||
value={cityCode}
|
value={cityCode}
|
||||||
onChange={(e) => setCityCode(e.target.value)}
|
onChange={(e) => {
|
||||||
style={{ border: 'none', background: 'transparent', font: 'inherit', color: 'inherit' }}
|
setCitySource('manual');
|
||||||
|
setCityCode(e.target.value);
|
||||||
|
}}
|
||||||
|
className="tab-main-city-select"
|
||||||
|
aria-label="选择开城城市"
|
||||||
>
|
>
|
||||||
{cities.map((c) => (
|
{cities.map((c) => (
|
||||||
<option key={c.code} value={c.code}>{c.name}</option>
|
<option key={c.code} value={c.code}>{c.name}</option>
|
||||||
|
|||||||
@@ -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 { Link, useNavigate } from 'react-router-dom';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
@@ -7,10 +7,12 @@ import TabMainHeader from '../components/TabMainHeader';
|
|||||||
import RegionPicker from '../components/RegionPicker';
|
import RegionPicker from '../components/RegionPicker';
|
||||||
import {
|
import {
|
||||||
DEFAULT_REGION,
|
DEFAULT_REGION,
|
||||||
|
FALLBACK_CITY_REGION,
|
||||||
formatRegion,
|
formatRegion,
|
||||||
REGION_ALL,
|
REGION_ALL,
|
||||||
type RegionSelection,
|
type RegionSelection,
|
||||||
} from '../lib/region-data';
|
} from '../lib/region-data';
|
||||||
|
import { FALLBACK_CITY_CODE, resolveUserCity } from '../lib/wechat-location';
|
||||||
|
|
||||||
type OpenCity = {
|
type OpenCity = {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -62,6 +64,9 @@ export default function StoreListPage() {
|
|||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
|
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
|
||||||
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
|
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]);
|
const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]);
|
||||||
|
|
||||||
@@ -74,15 +79,45 @@ export default function StoreListPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const qs = cityCode ? `?cityCode=${encodeURIComponent(cityCode)}` : '';
|
fetchStores(region, true)
|
||||||
request<StoreItem[]>('USER_H5', `/stores${qs}`)
|
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) setStores(data);
|
if (!cancelled && data) setStores(data);
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setStores([]);
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
@@ -90,7 +125,7 @@ export default function StoreListPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [cityCode]);
|
}, [region, cities, filterMode, geoReady, fetchStores, usedFallback]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let list = stores;
|
let list = stores;
|
||||||
@@ -122,6 +157,10 @@ export default function StoreListPage() {
|
|||||||
}, [stores, categoryTab, keyword, region, cityCode]);
|
}, [stores, categoryTab, keyword, region, cityCode]);
|
||||||
|
|
||||||
const regionLabel = formatRegion(region.province, region.city, region.district);
|
const regionLabel = formatRegion(region.province, region.city, region.district);
|
||||||
|
const emptyMessage =
|
||||||
|
filterMode === 'manual' && filtered.length === 0
|
||||||
|
? '未找到匹配门店'
|
||||||
|
: '暂无门店';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page store-page">
|
<div className="page store-page">
|
||||||
@@ -144,7 +183,10 @@ export default function StoreListPage() {
|
|||||||
type="search"
|
type="search"
|
||||||
placeholder="搜索门店名称或地址"
|
placeholder="搜索门店名称或地址"
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setFilterMode('manual');
|
||||||
|
setKeyword(e.target.value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,7 +198,10 @@ export default function StoreListPage() {
|
|||||||
key={tab}
|
key={tab}
|
||||||
type="button"
|
type="button"
|
||||||
className={`store-category-tab${categoryTab === tab ? ' active' : ''}`}
|
className={`store-category-tab${categoryTab === tab ? ' active' : ''}`}
|
||||||
onClick={() => setCategoryTab(tab)}
|
onClick={() => {
|
||||||
|
setFilterMode('manual');
|
||||||
|
setCategoryTab(tab);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
</button>
|
</button>
|
||||||
@@ -215,7 +260,7 @@ export default function StoreListPage() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{!loading && filtered.length === 0 && (
|
{!loading && filtered.length === 0 && (
|
||||||
<div className="store-empty">{stores.length === 0 ? '暂无门店' : '未找到匹配门店'}</div>
|
<div className="store-empty">{emptyMessage}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && filtered.length > 0 && (
|
{!loading && filtered.length > 0 && (
|
||||||
@@ -228,6 +273,7 @@ export default function StoreListPage() {
|
|||||||
value={region}
|
value={region}
|
||||||
onClose={() => setRegionPickerOpen(false)}
|
onClose={() => setRegionPickerOpen(false)}
|
||||||
onConfirm={(next) => {
|
onConfirm={(next) => {
|
||||||
|
setFilterMode('manual');
|
||||||
setRegion(next);
|
setRegion(next);
|
||||||
setRegionPickerOpen(false);
|
setRegionPickerOpen(false);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -603,6 +603,24 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.05em;
|
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 {
|
.tab-main-city .material-symbols-outlined {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface AppConfig {
|
|||||||
aliyunSmsTemplateCode: string;
|
aliyunSmsTemplateCode: string;
|
||||||
aliyunSmsAccessKeyId: string;
|
aliyunSmsAccessKeyId: string;
|
||||||
aliyunSmsAccessKeySecret: string;
|
aliyunSmsAccessKeySecret: string;
|
||||||
|
/** 腾讯位置服务 Key(逆地理编码) */
|
||||||
|
tencentLbsKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 总部客服电话(C 端联系客服) */
|
/** 总部客服电话(C 端联系客服) */
|
||||||
@@ -39,5 +41,6 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
|
|||||||
aliyunSmsTemplateCode: e.ALIYUN_SMS_TEMPLATE_CODE ?? '',
|
aliyunSmsTemplateCode: e.ALIYUN_SMS_TEMPLATE_CODE ?? '',
|
||||||
aliyunSmsAccessKeyId: e.ALIYUN_SMS_ACCESS_KEY_ID ?? e.OSS_ACCESS_KEY_ID ?? '',
|
aliyunSmsAccessKeyId: e.ALIYUN_SMS_ACCESS_KEY_ID ?? e.OSS_ACCESS_KEY_ID ?? '',
|
||||||
aliyunSmsAccessKeySecret: e.ALIYUN_SMS_ACCESS_KEY_SECRET ?? e.OSS_ACCESS_KEY_SECRET ?? '',
|
aliyunSmsAccessKeySecret: e.ALIYUN_SMS_ACCESS_KEY_SECRET ?? e.OSS_ACCESS_KEY_SECRET ?? '',
|
||||||
|
tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env';
|
|||||||
export { initWechatJssdk, ensureJssdkReady, isJssdkReady } from './jssdk';
|
export { initWechatJssdk, ensureJssdkReady, isJssdkReady } from './jssdk';
|
||||||
export {
|
export {
|
||||||
getWechatLocation,
|
getWechatLocation,
|
||||||
|
getWechatLocationDetailed,
|
||||||
canUseWechatLocation,
|
canUseWechatLocation,
|
||||||
isWechatEnv,
|
isWechatEnv,
|
||||||
} from './location';
|
} from './location';
|
||||||
|
export type { WechatLocationOutcome } from './location';
|
||||||
export { scanQrCode } from './scan';
|
export { scanQrCode } from './scan';
|
||||||
export { invokeWechatPay } from './pay';
|
export { invokeWechatPay } from './pay';
|
||||||
export { chooseWechatImages, canUseWechatChooseImage } from './chooseImage';
|
export { chooseWechatImages, canUseWechatChooseImage } from './chooseImage';
|
||||||
@@ -24,7 +26,7 @@ export { DEFAULT_JS_API_LIST } from './types';
|
|||||||
|
|
||||||
import type { WeixinSdkConfig } from './types';
|
import type { WeixinSdkConfig } from './types';
|
||||||
import { initWechatJssdk } from './jssdk';
|
import { initWechatJssdk } from './jssdk';
|
||||||
import { getWechatLocation } from './location';
|
import { getWechatLocation, getWechatLocationDetailed } from './location';
|
||||||
import { scanQrCode } from './scan';
|
import { scanQrCode } from './scan';
|
||||||
import { invokeWechatPay } from './pay';
|
import { invokeWechatPay } from './pay';
|
||||||
import { chooseWechatImages } from './chooseImage';
|
import { chooseWechatImages } from './chooseImage';
|
||||||
@@ -45,6 +47,7 @@ export function createWeixinSdk(config: WeixinSdkConfig) {
|
|||||||
bindWechatPhone(config, payload),
|
bindWechatPhone(config, payload),
|
||||||
getPhoneNumber: () => getWechatPhoneNumber(config),
|
getPhoneNumber: () => getWechatPhoneNumber(config),
|
||||||
getLocation: () => getWechatLocation(config),
|
getLocation: () => getWechatLocation(config),
|
||||||
|
getLocationDetailed: () => getWechatLocationDetailed(config),
|
||||||
scanQrCode: () => scanQrCode(config),
|
scanQrCode: () => scanQrCode(config),
|
||||||
chooseImages: (options?: Parameters<typeof chooseWechatImages>[1]) =>
|
chooseImages: (options?: Parameters<typeof chooseWechatImages>[1]) =>
|
||||||
chooseWechatImages(config, options),
|
chooseWechatImages(config, options),
|
||||||
|
|||||||
@@ -3,16 +3,29 @@ import { getRuntimePlatform, isWechatBrowser } from './env';
|
|||||||
import { ensureJssdkReady } from './jssdk';
|
import { ensureJssdkReady } from './jssdk';
|
||||||
import type { WeixinSdkConfig } from './types';
|
import type { WeixinSdkConfig } from './types';
|
||||||
|
|
||||||
|
export type WechatLocationOutcome = {
|
||||||
|
sdk: 'jssdk' | 'geolocation';
|
||||||
|
location: WechatGpsLocation | null;
|
||||||
|
errMsg?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** 获取 GPS 定位(微信 JSSDK / 小程序优先,否则 H5 Geolocation) */
|
/** 获取 GPS 定位(微信 JSSDK / 小程序优先,否则 H5 Geolocation) */
|
||||||
export async function getWechatLocation(config?: WeixinSdkConfig): Promise<WechatGpsLocation | null> {
|
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();
|
const platform = getRuntimePlatform();
|
||||||
|
|
||||||
if (platform === 'mini' && window.wx?.getLocation) {
|
if (platform === 'mini' && window.wx?.getLocation) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
window.wx!.getLocation!({
|
window.wx!.getLocation!({
|
||||||
type: 'gcj02',
|
type: 'gcj02',
|
||||||
success: (res) => resolve(res),
|
success: (res) => resolve({ sdk: 'jssdk', location: res }),
|
||||||
fail: () => resolve(null),
|
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,
|
getAccessToken: config.getAccessToken,
|
||||||
});
|
});
|
||||||
if (window.wx?.getLocation) {
|
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) => {
|
return new Promise((resolve) => {
|
||||||
window.wx!.getLocation!({
|
window.wx!.getLocation!({
|
||||||
type: 'gcj02',
|
type: 'gcj02',
|
||||||
success: (res) => resolve(res),
|
success: (res) => resolve({ sdk: 'jssdk', location: res }),
|
||||||
fail: () => resolve(null),
|
fail: (res) => resolve({ sdk: 'jssdk', location: null, errMsg: res.errMsg }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
return { sdk: 'jssdk', location: null, errMsg: '微信 JSSDK getLocation 不可用' };
|
||||||
/* fall through */
|
} 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) => {
|
return new Promise((resolve) => {
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(pos) =>
|
(pos) =>
|
||||||
resolve({
|
resolve({
|
||||||
latitude: pos.coords.latitude,
|
sdk: 'geolocation',
|
||||||
longitude: pos.coords.longitude,
|
location: {
|
||||||
accuracy: pos.coords.accuracy,
|
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 },
|
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 60_000 },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ WX_API_V3_KEY=
|
|||||||
WX_PLATFORM_CERT=
|
WX_PLATFORM_CERT=
|
||||||
WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay
|
WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay
|
||||||
|
|
||||||
|
# 腾讯位置服务(逆地理编码,微信定位展示城市)
|
||||||
|
TENCENT_LBS_KEY=
|
||||||
|
|
||||||
# 阿里云 OSS(ali-oss@6.x;OSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL)
|
# 阿里云 OSS(ali-oss@6.x;OSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL)
|
||||||
# RAM 用户需具备 PutObject 权限;可在控制台 Bucket 授权策略中为该 RAM UID 授予读写
|
# RAM 用户需具备 PutObject 权限;可在控制台 Bucket 授权策略中为该 RAM UID 授予读写
|
||||||
# 文档:https://help.aliyun.com/zh/oss/user-guide/use-bucket-policy-to-grant-permission-to-access-oss/
|
# 文档: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 DELIVERY_PROVIDER = 'DELIVERY_PROVIDER';
|
||||||
export const WECHAT_PROVIDER = 'WECHAT_PROVIDER';
|
export const WECHAT_PROVIDER = 'WECHAT_PROVIDER';
|
||||||
export const OSS_PROVIDER = 'OSS_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 { WechatDisabledProvider } from './wechat/wechat.disabled.provider';
|
||||||
import { OssMockProvider } from './oss/oss.mock.provider';
|
import { OssMockProvider } from './oss/oss.mock.provider';
|
||||||
import { OssAliyunProvider } from './oss/oss.aliyun.provider';
|
import { OssAliyunProvider } from './oss/oss.aliyun.provider';
|
||||||
|
import { TencentLbsProvider } from './map/tencent-lbs.provider';
|
||||||
import {
|
import {
|
||||||
SMS_PROVIDER,
|
SMS_PROVIDER,
|
||||||
PAY_PROVIDER,
|
PAY_PROVIDER,
|
||||||
DELIVERY_PROVIDER,
|
DELIVERY_PROVIDER,
|
||||||
WECHAT_PROVIDER,
|
WECHAT_PROVIDER,
|
||||||
OSS_PROVIDER,
|
OSS_PROVIDER,
|
||||||
|
MAP_PROVIDER,
|
||||||
} from './integrations.constants';
|
} from './integrations.constants';
|
||||||
import { CourierModule } from './courier/courier.module';
|
import { CourierModule } from './courier/courier.module';
|
||||||
import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
||||||
@@ -78,7 +80,9 @@ import type { ISmsProvider } from './sms/sms.interface';
|
|||||||
inject: [OssMockProvider, OssAliyunProvider],
|
inject: [OssMockProvider, OssAliyunProvider],
|
||||||
},
|
},
|
||||||
DeliveryMockProvider,
|
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 {}
|
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 { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||||
import { loadAppConfig } from '@dukang/shared-types';
|
import { loadAppConfig } from '@dukang/shared-types';
|
||||||
import { RedisService } from '../../common/redis/redis.service';
|
import { RedisService } from '../../common/redis/redis.service';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './wechat.interface';
|
import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './wechat.interface';
|
||||||
|
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
|
||||||
import {
|
import {
|
||||||
decryptPayResource,
|
decryptPayResource,
|
||||||
verifyPaySignature,
|
verifyPaySignature,
|
||||||
@@ -28,7 +30,10 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
|
private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
|
||||||
private readonly platformCert = (process.env.WX_PLATFORM_CERT ?? '').replace(/\\n/g, '\n');
|
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() {
|
isEnabled() {
|
||||||
return this.config.wechatAuthEnabled && !!this.appId && !!this.appSecret;
|
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`;
|
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');
|
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
||||||
url.searchParams.set('appid', this.appId);
|
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('js_code', code);
|
||||||
url.searchParams.set('grant_type', 'authorization_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<{
|
const data = await this.fetchJson<{
|
||||||
openid?: string;
|
openid?: string;
|
||||||
unionid?: string;
|
unionid?: string;
|
||||||
session_key?: string;
|
session_key?: string;
|
||||||
errcode?: number;
|
errcode?: number;
|
||||||
errmsg?: string;
|
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) {
|
if (!data.openid) {
|
||||||
throw new InternalServerErrorException(data.errmsg || '微信 code2session 失败');
|
throw new InternalServerErrorException(data.errmsg || '微信 code2session 失败');
|
||||||
}
|
}
|
||||||
@@ -83,12 +106,17 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async oauth2AccessToken(code: string): Promise<WechatOAuthSession> {
|
async oauth2AccessToken(code: string, actorRef?: WechatActorRef): Promise<WechatOAuthSession> {
|
||||||
const url = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
|
const maskedUrl = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
|
||||||
url.searchParams.set('appid', this.appId);
|
maskedUrl.searchParams.set('appid', this.appId);
|
||||||
url.searchParams.set('secret', this.appSecret);
|
maskedUrl.searchParams.set('secret', '***');
|
||||||
url.searchParams.set('code', code);
|
maskedUrl.searchParams.set('code', code);
|
||||||
url.searchParams.set('grant_type', 'authorization_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<{
|
const data = await this.fetchJson<{
|
||||||
openid?: string;
|
openid?: string;
|
||||||
unionid?: string;
|
unionid?: string;
|
||||||
@@ -96,7 +124,20 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
errcode?: number;
|
errcode?: number;
|
||||||
errmsg?: string;
|
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) {
|
if (!data.openid) {
|
||||||
throw new InternalServerErrorException(data.errmsg || '微信 OAuth 失败');
|
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) {
|
||||||
const ticket = await this.getJsapiTicket();
|
try {
|
||||||
const nonceStr = randomBytes(8).toString('hex');
|
const ticket = await this.getJsapiTicket();
|
||||||
const timestamp = Math.floor(Date.now() / 1000);
|
const nonceStr = randomBytes(8).toString('hex');
|
||||||
const raw = `jsapi_ticket=${ticket}&noncestr=${nonceStr}×tamp=${timestamp}&url=${url}`;
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
const signature = createHash('sha1').update(raw).digest('hex');
|
const raw = `jsapi_ticket=${ticket}&noncestr=${nonceStr}×tamp=${timestamp}&url=${url}`;
|
||||||
return {
|
const signature = createHash('sha1').update(raw).digest('hex');
|
||||||
appId: this.appId,
|
const config = {
|
||||||
timestamp,
|
appId: this.appId,
|
||||||
nonceStr,
|
timestamp,
|
||||||
signature,
|
nonceStr,
|
||||||
jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay', 'chooseImage', 'getLocalImgData'],
|
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') {
|
if (platform === 'h5') {
|
||||||
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
|
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
|
||||||
}
|
}
|
||||||
const accessToken = await this.getAccessToken();
|
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<{
|
const data = await this.fetchJson<{
|
||||||
errcode?: number;
|
errcode?: number;
|
||||||
errmsg?: string;
|
errmsg?: string;
|
||||||
phone_info?: { phoneNumber?: string; purePhoneNumber?: string };
|
phone_info?: { phoneNumber?: string; purePhoneNumber?: string };
|
||||||
}>(url, {
|
}>(apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ code }),
|
body: JSON.stringify({ code }),
|
||||||
});
|
});
|
||||||
const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber;
|
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) {
|
if (!phone) {
|
||||||
throw new InternalServerErrorException(data.errmsg || '获取手机号失败');
|
throw new InternalServerErrorException(data.errmsg || '获取手机号失败');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,19 +31,23 @@ export interface IWechatProvider {
|
|||||||
getMchId(): string;
|
getMchId(): string;
|
||||||
|
|
||||||
/** 小程序 code2session */
|
/** 小程序 code2session */
|
||||||
code2Session(code: string): Promise<WechatCodeSession>;
|
code2Session(code: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatCodeSession>;
|
||||||
|
|
||||||
/** 公众号 H5 OAuth code 换 openId */
|
/** 公众号 H5 OAuth code 换 openId */
|
||||||
oauth2AccessToken(code: string): Promise<WechatOAuthSession>;
|
oauth2AccessToken(code: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatOAuthSession>;
|
||||||
|
|
||||||
/** JSSDK 签名配置 */
|
/** JSSDK 签名配置 */
|
||||||
createJssdkConfig(url: string): Promise<WechatJssdkConfig>;
|
createJssdkConfig(url: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatJssdkConfig>;
|
||||||
|
|
||||||
/** 构建公众号 OAuth 授权 URL */
|
/** 构建公众号 OAuth 授权 URL */
|
||||||
buildOAuthUrl(redirectUri: string, state: string, scope?: string): string;
|
buildOAuthUrl(redirectUri: string, state: string, scope?: string): string;
|
||||||
|
|
||||||
/** 小程序手机号 code 解密(或调用微信 getPhoneNumber 接口) */
|
/** 小程序手机号 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 统一下单) */
|
/** 创建 JSAPI 预支付参数(使用 WX_MCH_ID 统一下单) */
|
||||||
createJsapiPrepay(params: {
|
createJsapiPrepay(params: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { IamModule } from '../iam/iam.module';
|
import { IamModule } from '../iam/iam.module';
|
||||||
|
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||||
import { ResourceService } from './resource.service';
|
import { ResourceService } from './resource.service';
|
||||||
import { EventService } from './event.service';
|
import { EventService } from './event.service';
|
||||||
@@ -11,9 +12,10 @@ import { TicketController } from './ticket.controller';
|
|||||||
import { ThirdPartyLogController } from './third-party-log.controller';
|
import { ThirdPartyLogController } from './third-party-log.controller';
|
||||||
import { WechatController } from './wechat.controller';
|
import { WechatController } from './wechat.controller';
|
||||||
import { ClientConfigController } from './client-config.controller';
|
import { ClientConfigController } from './client-config.controller';
|
||||||
|
import { WechatLocationService } from './wechat-location.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IamModule, IntegrationsModule],
|
imports: [IamModule, IntegrationsModule, forwardRef(() => AnalyticsModule)],
|
||||||
controllers: [
|
controllers: [
|
||||||
ResourceController,
|
ResourceController,
|
||||||
EventController,
|
EventController,
|
||||||
@@ -22,7 +24,7 @@ import { ClientConfigController } from './client-config.controller';
|
|||||||
WechatController,
|
WechatController,
|
||||||
ClientConfigController,
|
ClientConfigController,
|
||||||
],
|
],
|
||||||
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService],
|
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService, WechatLocationService],
|
||||||
exports: [ResourceService, EventService, TicketService],
|
exports: [ResourceService, EventService, TicketService],
|
||||||
})
|
})
|
||||||
export class CommonModule {}
|
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 { BadRequestException, Body, Controller, Get, Inject, Post, Query, Req, UseGuards } from '@nestjs/common';
|
||||||
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
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 { WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
||||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
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 {
|
class PhoneNumberDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -14,15 +19,43 @@ class PhoneNumberDto {
|
|||||||
platform?: 'mini' | 'h5';
|
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')
|
@Controller('common/wechat')
|
||||||
export class WechatController {
|
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')
|
@Get('jssdk-config')
|
||||||
async jssdkConfig(@Query('url') url: string) {
|
async jssdkConfig(@Query('url') url: string, @Req() req: Request) {
|
||||||
if (!url) throw new BadRequestException('url 参数必填');
|
if (!url) throw new BadRequestException('url 参数必填');
|
||||||
const pageUrl = decodeURIComponent(url).split('#')[0];
|
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')
|
@Get('oauth-url')
|
||||||
@@ -41,4 +74,21 @@ export class WechatController {
|
|||||||
.getPhoneNumberByCode(dto.code, dto.platform ?? 'mini')
|
.getPhoneNumberByCode(dto.code, dto.platform ?? 'mini')
|
||||||
.then((phone) => ({ phone }));
|
.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