fix(admin,partner): harden Tencent locpicker coords and confirm flow
CI / verify (pull_request) Has been cancelled

Accept latlng-only postMessage, confirm before apply, and read LBS key per request.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-28 00:47:08 +08:00
parent 50349dd8e9
commit 9ed0c24d11
7 changed files with 157 additions and 35 deletions
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Modal, Spin, Typography, message } from 'antd';
import { Button, Modal, Space, Spin, Typography, message } from 'antd';
import type { ClientRuntimeConfig } from '@dukang/shared-types';
import { request } from '../lib/api';
import {
@@ -39,9 +39,13 @@ export default function TencentLocPickerModal({
const [key, setKey] = useState<string | null>(cachedKey ?? null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
useEffect(() => {
if (!open) return;
if (!open) {
setPending(null);
return;
}
let cancelled = false;
setError('');
if (key) return;
@@ -70,30 +74,56 @@ export default function TencentLocPickerModal({
function onMessage(event: MessageEvent) {
const picked = parseTencentLocPickerMessage(event.data);
if (!picked) return;
onPick(picked);
onClose();
setPending(picked);
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [open, onPick, onClose]);
}, [open]);
const src = useMemo(() => {
if (!key) return '';
const lat = latitude != null ? Number(latitude) : undefined;
const lng = longitude != null ? Number(longitude) : undefined;
return buildTencentLocPickerUrl(key, {
latitude: latitude != null ? Number(latitude) : undefined,
longitude: longitude != null ? Number(longitude) : undefined,
latitude: lat != null && Number.isFinite(lat) ? lat : undefined,
longitude: lng != null && Number.isFinite(lng) ? lng : undefined,
});
}, [key, latitude, longitude]);
function confirmPick() {
if (!pending) {
message.warning('请先在地图上选择或搜索一个位置');
return;
}
onPick(pending);
onClose();
}
return (
<Modal
title="腾讯地图选点"
open={open}
onCancel={onClose}
footer={null}
width={720}
destroyOnClose
styles={{ body: { padding: 0, height: 560 } }}
footer={
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary" style={{ maxWidth: 420 }} ellipsis>
{pending
? `${pending.latitude.toFixed(6)}, ${pending.longitude.toFixed(6)}${
pending.name ? ` · ${pending.name}` : ''
}`
: '在地图中点选 / 搜索后,点击确认选点'}
</Typography.Text>
<Space>
<Button onClick={onClose}></Button>
<Button type="primary" disabled={!pending} onClick={confirmPick}>
</Button>
</Space>
</Space>
}
>
{loading ? (
<div style={{ height: 560, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
@@ -102,11 +132,16 @@ export default function TencentLocPickerModal({
) : error ? (
<div style={{ padding: 24 }}>
<Typography.Text type="danger">{error}</Typography.Text>
<Typography.Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}>
Key
WebServiceAPI apis.map.qq.com
</Typography.Paragraph>
</div>
) : src ? (
<iframe
title="腾讯地图选点"
src={src}
allow="geolocation *"
style={{ width: '100%', height: 560, border: 0, display: 'block' }}
/>
) : null}
+26 -6
View File
@@ -14,7 +14,22 @@ type LocPickerMessage = {
cityname?: string;
};
const REFERER = 'dukang-haoke';
const REFERER = 'dukang';
function coerceMessageData(data: unknown): LocPickerMessage | null {
if (data == null) return null;
if (typeof data === 'string') {
const trimmed = data.trim();
if (!trimmed || (trimmed[0] !== '{' && trimmed[0] !== '[')) return null;
try {
return JSON.parse(trimmed) as LocPickerMessage;
} catch {
return null;
}
}
if (typeof data === 'object') return data as LocPickerMessage;
return null;
}
export function buildTencentLocPickerUrl(
key: string,
@@ -26,6 +41,8 @@ export function buildTencentLocPickerUrl(
key,
referer: REFERER,
policy: '1',
total: '20',
radius: '2000',
});
const lat = options?.latitude;
const lng = options?.longitude;
@@ -43,18 +60,21 @@ export function buildTencentLocPickerUrl(
}
export function parseTencentLocPickerMessage(data: unknown): TencentPickedLocation | null {
const loc = data as LocPickerMessage;
const loc = coerceMessageData(data);
if (!loc || loc.module !== 'locationPicker') return null;
const lat = Number(loc.latlng?.lat);
const lng = Number(loc.latlng?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
// 列表滑动过程中偶发空 POI,忽略无名称且无地址的噪声
if (!loc.poiname && !loc.poiaddress) return null;
let name = loc.poiname?.trim() || undefined;
const address = loc.poiaddress?.trim() || undefined;
if (name === '我的位置' && address) name = address;
return {
latitude: lat,
longitude: lng,
address: loc.poiaddress?.trim() || undefined,
name: loc.poiname?.trim() || undefined,
address,
name,
cityname: loc.cityname?.trim() || undefined,
};
}
@@ -40,9 +40,13 @@ export default function TencentLocPickerOverlay({
const [key, setKey] = useState<string | null>(cachedKey ?? null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
useEffect(() => {
if (!open) return;
if (!open) {
setPending(null);
return;
}
let cancelled = false;
setError('');
if (key) return;
@@ -67,23 +71,30 @@ export default function TencentLocPickerOverlay({
function onMessage(event: MessageEvent) {
const picked = parseTencentLocPickerMessage(event.data);
if (!picked) return;
onPick(picked);
onClose();
setPending(picked);
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [open, onPick, onClose]);
}, [open]);
const src = useMemo(() => {
if (!key) return '';
const lat = latitude != null ? Number(latitude) : undefined;
const lng = longitude != null ? Number(longitude) : undefined;
return buildTencentLocPickerUrl(key, {
latitude: latitude != null ? Number(latitude) : undefined,
longitude: longitude != null ? Number(longitude) : undefined,
latitude: lat != null && Number.isFinite(lat) ? lat : undefined,
longitude: lng != null && Number.isFinite(lng) ? lng : undefined,
});
}, [key, latitude, longitude]);
if (!open) return null;
function confirmPick() {
if (!pending) return;
onPick(pending);
onClose();
}
return (
<div
style={{
@@ -109,21 +120,50 @@ export default function TencentLocPickerOverlay({
</button>
<span style={{ fontWeight: 600 }}></span>
<span style={{ width: 52 }} />
<button
type="button"
className="partner-btn-primary"
style={{ padding: '6px 12px', opacity: pending ? 1 : 0.45, width: 'auto' }}
disabled={!pending}
onClick={confirmPick}
>
</button>
</div>
{pending ? (
<p
className="label-md text-muted"
style={{
margin: 0,
padding: '8px 16px',
borderBottom: '1px solid rgba(0,0,0,0.04)',
flexShrink: 0,
}}
>
{pending.latitude.toFixed(6)}, {pending.longitude.toFixed(6)}
{pending.name ? ` · ${pending.name}` : ''}
</p>
) : null}
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
{loading ? (
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
</p>
) : error ? (
<p className="label-md" style={{ padding: 24, color: 'var(--color-heritage-red, #a61d24)' }}>
{error}
</p>
<div style={{ padding: 24 }}>
<p className="label-md" style={{ color: 'var(--color-heritage-red, #a61d24)' }}>
{error}
</p>
<p className="label-md text-muted" style={{ marginTop: 12 }}>
Key WebServiceAPI
apis.map.qq.com
</p>
</div>
) : src ? (
<iframe
title="腾讯地图选点"
src={src}
allow="geolocation *"
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
/>
) : null}
+26 -5
View File
@@ -14,7 +14,22 @@ type LocPickerMessage = {
cityname?: string;
};
const REFERER = 'dukang-haoke';
const REFERER = 'dukang';
function coerceMessageData(data: unknown): LocPickerMessage | null {
if (data == null) return null;
if (typeof data === 'string') {
const trimmed = data.trim();
if (!trimmed || (trimmed[0] !== '{' && trimmed[0] !== '[')) return null;
try {
return JSON.parse(trimmed) as LocPickerMessage;
} catch {
return null;
}
}
if (typeof data === 'object') return data as LocPickerMessage;
return null;
}
export function buildTencentLocPickerUrl(
key: string,
@@ -26,6 +41,8 @@ export function buildTencentLocPickerUrl(
key,
referer: REFERER,
policy: '1',
total: '20',
radius: '2000',
});
const lat = options?.latitude;
const lng = options?.longitude;
@@ -43,17 +60,21 @@ export function buildTencentLocPickerUrl(
}
export function parseTencentLocPickerMessage(data: unknown): TencentPickedLocation | null {
const loc = data as LocPickerMessage;
const loc = coerceMessageData(data);
if (!loc || loc.module !== 'locationPicker') return null;
const lat = Number(loc.latlng?.lat);
const lng = Number(loc.latlng?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
if (!loc.poiname && !loc.poiaddress) return null;
let name = loc.poiname?.trim() || undefined;
const address = loc.poiaddress?.trim() || undefined;
if (name === '我的位置' && address) name = address;
return {
latitude: lat,
longitude: lng,
address: loc.poiaddress?.trim() || undefined,
name: loc.poiname?.trim() || undefined,
address,
name,
cityname: loc.cityname?.trim() || undefined,
};
}
+3 -1
View File
@@ -60,7 +60,9 @@ WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
WECOM_AIBOT_ENABLED=false
# 腾讯位置服务(地理编码 / 逆地理 / 地图选点组件)
# 地图选点需在控制台为 Key 配置域名白名单,并允许组件域名 apis.map.qq.com
# 控制台须:1) 开启 WebServiceAPI(否则选点搜不到附近列表)
# 2) WebService 域名白名单加入 apis.map.qq.com
# 3) 浏览器 Key 按管理端 / 合伙人 H5 域名限制(可选)
TENCENT_LBS_KEY=
# 阿里云 OSSali-oss@6.x;凭证齐全时直传,缺失则服务端报错)
@@ -107,7 +107,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
{ key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false },
{ key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false },
{ key: 'TENCENT_LBS_KEY', label: '腾讯位置服务 Key地理编码 / 地图选点', group: G.app, type: 'password', secret: true, requiresRestart: false },
{ key: 'TENCENT_LBS_KEY', label: '腾讯位置服务 Key须开 WebServiceAPI;白名单含 apis.map.qq.com', group: G.app, type: 'password', secret: true, requiresRestart: false },
{ key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false },
{ key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false },
@@ -23,12 +23,16 @@ function normalizeCityName(name: string) {
@Injectable()
export class TencentLbsProvider {
private readonly logger = new Logger(TencentLbsProvider.name);
private readonly config = loadAppConfig();
constructor(private readonly prisma: PrismaService) {}
/** 每次读取,避免构造时缓存、以及系统设置热更新后仍用旧 Key */
private getLbsKey() {
return (loadAppConfig().tencentLbsKey || '').trim();
}
isEnabled() {
return !!this.config.tencentLbsKey;
return !!this.getLbsKey();
}
/** 地址 → 坐标(正向地理编码) */
@@ -61,7 +65,7 @@ export class TencentLbsProvider {
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
url.searchParams.set('address', trimmed);
url.searchParams.set('key', this.config.tencentLbsKey);
url.searchParams.set('key', this.getLbsKey());
try {
const res = await fetch(url.toString());
@@ -140,7 +144,7 @@ export class TencentLbsProvider {
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('key', this.getLbsKey());
url.searchParams.set('get_poi', '0');
try {