490 行
19 KiB
PHP
490 行
19 KiB
PHP
<?php
|
||
|
||
namespace app\common\library;
|
||
|
||
use think\Config;
|
||
use think\Log;
|
||
|
||
/**
|
||
* 天地图(Tianditu)地理位置服务组件
|
||
* 提供高精度的正/逆地理编码、物理测距以及现场打卡异地防作弊核算
|
||
*/
|
||
class TiandituService
|
||
{
|
||
/**
|
||
* 获取天地图 Key(严格读取站点配置或 .env,不在源码内硬编码密钥)
|
||
* @return string
|
||
*/
|
||
public static function getKey()
|
||
{
|
||
$key = Config::get('site.tianditu_key');
|
||
if (empty($key) && class_exists('\\think\\Env')) {
|
||
$key = \think\Env::get('tianditu.key', '');
|
||
}
|
||
return trim((string)$key);
|
||
}
|
||
|
||
/**
|
||
* 正地理编码:输入结构化地址,返回高精度经纬度
|
||
* @param string $address 详细地址,如“成都市武侯区天府三街腾讯大厦”
|
||
* @return array|null ['lng' => float, 'lat' => float, 'level' => string]
|
||
*/
|
||
public static function geocoder($address)
|
||
{
|
||
$address = trim((string)$address);
|
||
if (empty($address)) {
|
||
return null;
|
||
}
|
||
|
||
$key = self::getKey();
|
||
if ($key === '') {
|
||
Log::record('[TiandituService] 天地图密钥 site.tianditu_key 未配置,无法执行正地理编码', 'error');
|
||
return null;
|
||
}
|
||
$url = 'https://api.tianditu.gov.cn/geocoder?ds=' . urlencode(json_encode(['keyWord' => $address])) . '&tk=' . $key;
|
||
|
||
$response = self::httpGet($url);
|
||
if (!$response) {
|
||
return null;
|
||
}
|
||
|
||
$data = json_decode($response, true);
|
||
if (isset($data['status']) && $data['status'] === '0' && !empty($data['location'])) {
|
||
return [
|
||
'lng' => (float)$data['location']['lon'],
|
||
'lat' => (float)$data['location']['lat'],
|
||
'level' => isset($data['location']['level']) ? $data['location']['level'] : '',
|
||
];
|
||
}
|
||
|
||
Log::record('[TiandituService] geocoder failed for address: ' . $address . ', resp: ' . $response, 'notice');
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 逆地理编码:输入经纬度,返回行政区划与标准门牌地址
|
||
* @param float $lat 纬度
|
||
* @param float $lng 经度
|
||
* @return array|null
|
||
*/
|
||
public static function regeocoder($lat, $lng)
|
||
{
|
||
$lat = (float)$lat;
|
||
$lng = (float)$lng;
|
||
if (abs($lat) < 0.0001 && abs($lng) < 0.0001) {
|
||
return null;
|
||
}
|
||
|
||
$key = self::getKey();
|
||
if ($key === '') {
|
||
Log::record('[TiandituService] 天地图密钥 site.tianditu_key 未配置,无法执行逆地理编码', 'error');
|
||
return null;
|
||
}
|
||
$postData = [
|
||
'postStr' => json_encode([
|
||
'lon' => $lng,
|
||
'lat' => $lat,
|
||
'ver' => 1
|
||
]),
|
||
'type' => 'geocode',
|
||
'tk' => $key
|
||
];
|
||
|
||
$url = 'https://api.tianditu.gov.cn/geocoder?' . http_build_query($postData);
|
||
$response = self::httpGet($url);
|
||
if (!$response) {
|
||
return null;
|
||
}
|
||
|
||
$data = json_decode($response, true);
|
||
if (isset($data['status']) && $data['status'] === '0' && isset($data['result'])) {
|
||
$comp = isset($data['result']['addressComponent']) ? $data['result']['addressComponent'] : [];
|
||
return [
|
||
'formatted_address' => isset($data['result']['formatted_address']) ? $data['result']['formatted_address'] : '',
|
||
'province' => isset($comp['province']) ? $comp['province'] : '',
|
||
'city' => isset($comp['city']) ? $comp['city'] : '',
|
||
'district' => isset($comp['county']) ? $comp['county'] : '',
|
||
'poi' => isset($comp['poi']) ? $comp['poi'] : '',
|
||
'road' => isset($comp['road']) ? $comp['road'] : '',
|
||
];
|
||
}
|
||
|
||
Log::record("[TiandituService] regeocoder failed for ({$lat}, {$lng}), resp: " . $response, 'notice');
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 关键词地点检索(POI 搜索 + 正地理编码融合兜底)
|
||
* @param string $keyword 地名/区域/小区关键词
|
||
* @param int $limit 最多返回条数
|
||
* @param string $city 可选限定城市名,提高同城检索命中率
|
||
* @param float|null $nearLat 可选参考中心纬度(用于计算距离展示)
|
||
* @param float|null $nearLng 可选参考中心经度
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
public static function searchPlaces($keyword, $limit = 10, $city = '', $nearLat = null, $nearLng = null)
|
||
{
|
||
$keyword = trim((string)$keyword);
|
||
if ($keyword === '') {
|
||
return [];
|
||
}
|
||
$limit = min(20, max(1, (int)$limit));
|
||
$key = self::getKey();
|
||
if ($key === '') {
|
||
return [];
|
||
}
|
||
|
||
$city = trim((string)$city);
|
||
$effectiveQuery = $keyword;
|
||
if ($city !== '' && mb_strpos($keyword, mb_substr($city, 0, 2)) === false) {
|
||
$effectiveQuery = $city . ' ' . $keyword;
|
||
}
|
||
|
||
$results = [];
|
||
$seen = [];
|
||
$hasNear = $nearLat !== null && $nearLng !== null && (abs((float)$nearLat) > 0.0001 || abs((float)$nearLng) > 0.0001);
|
||
|
||
$pois = self::requestPoiSearch([
|
||
'keyWord' => $effectiveQuery,
|
||
'level' => '12',
|
||
'mapBound' => '73.0,3.0,136.0,54.0',
|
||
'queryType' => '1',
|
||
'count' => (string)$limit,
|
||
'start' => '0',
|
||
], $key);
|
||
|
||
// 若限定城市后结果为空,放宽至全国关键词再查一次
|
||
if (empty($pois) && $effectiveQuery !== $keyword) {
|
||
$pois = self::requestPoiSearch([
|
||
'keyWord' => $keyword,
|
||
'level' => '12',
|
||
'mapBound' => '73.0,3.0,136.0,54.0',
|
||
'queryType' => '1',
|
||
'count' => (string)$limit,
|
||
'start' => '0',
|
||
], $key);
|
||
}
|
||
|
||
foreach ($pois as $poi) {
|
||
$item = self::formatPoiItem($poi, $keyword, '', $hasNear ? (float)$nearLat : null, $hasNear ? (float)$nearLng : null);
|
||
if ($item === null) {
|
||
continue;
|
||
}
|
||
$dedupKey = mb_strtolower($item['name']) . '|' . round($item['lat'], 4) . '|' . round($item['lng'], 4);
|
||
if (isset($seen[$dedupKey])) {
|
||
continue;
|
||
}
|
||
$seen[$dedupKey] = true;
|
||
$results[] = $item;
|
||
}
|
||
|
||
// 若属于行政区划或短关键词且结果偏少,补充一次正地理编码结果置顶
|
||
$geo = self::geocoder($effectiveQuery) ?: ($effectiveQuery !== $keyword ? self::geocoder($keyword) : null);
|
||
if (is_array($geo) && (!empty($geo['lng']) && !empty($geo['lat']))) {
|
||
$geoLng = round((float)$geo['lng'], 6);
|
||
$geoLat = round((float)$geo['lat'], 6);
|
||
$regeo = self::regeocoder($geoLat, $geoLng);
|
||
$cityLabel = '';
|
||
$districtLabel = '';
|
||
$fullLabel = $keyword;
|
||
if (is_array($regeo)) {
|
||
$cityLabel = trim((string)($regeo['city'] ?: ($regeo['province'] ?? '')));
|
||
$districtLabel = trim((string)($regeo['district'] ?? ''));
|
||
$fullLabel = trim(($regeo['province'] ?? '') . ' ' . $cityLabel . ' ' . $districtLabel);
|
||
if (!empty($regeo['formatted_address'])) {
|
||
$fullLabel = $regeo['formatted_address'];
|
||
}
|
||
}
|
||
$distM = $hasNear ? (int)round(self::distance((float)$nearLat, (float)$nearLng, $geoLat, $geoLng)) : null;
|
||
$geoDedup = mb_strtolower($keyword) . '|' . round($geoLat, 4) . '|' . round($geoLng, 4);
|
||
if (!isset($seen[$geoDedup])) {
|
||
array_unshift($results, [
|
||
'name' => $keyword,
|
||
'address' => $fullLabel ?: $keyword,
|
||
'city' => $cityLabel,
|
||
'district' => $districtLabel,
|
||
'lng' => $geoLng,
|
||
'lat' => $geoLat,
|
||
'distance_m' => $distM,
|
||
'distance_text' => self::formatMetersText($distM),
|
||
'category' => 'area',
|
||
]);
|
||
}
|
||
}
|
||
|
||
return array_slice($results, 0, $limit);
|
||
}
|
||
|
||
/**
|
||
* 基于经纬度的周边真实 POI 地标检索(配合地图选点展示周边小区/大厦/广场)
|
||
* @param float $lat 中心纬度
|
||
* @param float $lng 中心经度
|
||
* @param string $category all|residential|office|plaza
|
||
* @param int $radius 搜索半径(米,默认 3000)
|
||
* @param int $limit 最多返回条数
|
||
* @return array{center: array<string,mixed>|null, list: array<int, array<string,mixed>>}
|
||
*/
|
||
public static function nearbyPlaces($lat, $lng, $category = 'all', $radius = 3000, $limit = 14)
|
||
{
|
||
$lat = round((float)$lat, 6);
|
||
$lng = round((float)$lng, 6);
|
||
$limit = min(20, max(1, (int)$limit));
|
||
$radius = max(500, min(10000, (int)$radius));
|
||
|
||
if (abs($lat) < 0.0001 && abs($lng) < 0.0001) {
|
||
return ['center' => null, 'list' => []];
|
||
}
|
||
|
||
$regeo = self::regeocoder($lat, $lng);
|
||
$centerCity = is_array($regeo) ? trim((string)($regeo['city'] ?: ($regeo['province'] ?? ''))) : '';
|
||
$centerDistrict = is_array($regeo) ? trim((string)($regeo['district'] ?? '')) : '';
|
||
$centerPoi = is_array($regeo) ? trim((string)($regeo['poi'] ?? '')) : '';
|
||
$centerRoad = is_array($regeo) ? trim((string)($regeo['road'] ?? '')) : '';
|
||
$centerFormatted = is_array($regeo) ? trim((string)($regeo['formatted_address'] ?? '')) : '';
|
||
$centerName = $centerPoi !== '' ? $centerPoi : ($centerRoad !== '' ? $centerRoad : ($centerDistrict !== '' ? ($centerDistrict . '附近') : '当前地图中心'));
|
||
$centerAddress = $centerFormatted !== '' ? $centerFormatted : trim($centerCity . $centerDistrict . $centerRoad);
|
||
|
||
$centerInfo = [
|
||
'name' => $centerName,
|
||
'address' => $centerAddress ?: '已选定坐标点位',
|
||
'province' => is_array($regeo) ? trim((string)($regeo['province'] ?? '')) : '',
|
||
'city' => $centerCity,
|
||
'district' => $centerDistrict,
|
||
'road' => $centerRoad,
|
||
'poi' => $centerPoi,
|
||
'formatted_address' => $centerFormatted,
|
||
'lng' => $lng,
|
||
'lat' => $lat,
|
||
];
|
||
|
||
$list = [];
|
||
$seen = [];
|
||
|
||
// 中心位置锚点本身固定作为首条
|
||
$list[] = [
|
||
'name' => $centerName,
|
||
'address' => $centerAddress ?: '当前选定中心点',
|
||
'city' => $centerCity,
|
||
'district' => $centerDistrict,
|
||
'lng' => $lng,
|
||
'lat' => $lat,
|
||
'distance_m' => 0,
|
||
'distance_text' => '当前点',
|
||
'category' => 'center',
|
||
'is_center' => 1,
|
||
];
|
||
$seen[mb_strtolower($centerName)] = true;
|
||
|
||
$key = self::getKey();
|
||
if ($key !== '') {
|
||
$categoryKeywordMap = [
|
||
'residential' => [['kw' => '小区', 'cat' => 'residential']],
|
||
'office' => [['kw' => '大厦', 'cat' => 'office']],
|
||
'plaza' => [['kw' => '广场', 'cat' => 'plaza']],
|
||
'all' => [
|
||
['kw' => '小区', 'cat' => 'residential'],
|
||
['kw' => '大厦', 'cat' => 'office'],
|
||
['kw' => '广场', 'cat' => 'plaza'],
|
||
],
|
||
];
|
||
$queries = $categoryKeywordMap[$category] ?? $categoryKeywordMap['all'];
|
||
$perCount = count($queries) > 1 ? 6 : $limit;
|
||
$poiRows = [];
|
||
|
||
foreach ($queries as $qItem) {
|
||
$rawPois = self::requestPoiSearch([
|
||
'keyWord' => $qItem['kw'],
|
||
'level' => '15',
|
||
'queryType' => '3',
|
||
'pointLonlat' => $lng . ',' . $lat,
|
||
'queryRadius' => (string)$radius,
|
||
'count' => (string)$perCount,
|
||
'start' => '0',
|
||
], $key);
|
||
|
||
foreach ($rawPois as $rp) {
|
||
$item = self::formatPoiItem($rp, $qItem['kw'], $qItem['cat'], $lat, $lng, $centerCity, $centerDistrict);
|
||
if ($item === null) {
|
||
continue;
|
||
}
|
||
$nameKey = mb_strtolower($item['name']);
|
||
if (isset($seen[$nameKey])) {
|
||
continue;
|
||
}
|
||
$seen[$nameKey] = true;
|
||
$poiRows[] = $item;
|
||
}
|
||
}
|
||
|
||
usort($poiRows, function ($a, $b) {
|
||
return ((int)($a['distance_m'] ?? 999999)) <=> ((int)($b['distance_m'] ?? 999999));
|
||
});
|
||
|
||
$list = array_merge($list, array_slice($poiRows, 0, max(0, $limit - 1)));
|
||
}
|
||
|
||
return [
|
||
'center' => $centerInfo,
|
||
'list' => $list,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 统一请求天地图 v2/search 接口
|
||
*/
|
||
protected static function requestPoiSearch(array $postParams, $key)
|
||
{
|
||
$postStr = json_encode($postParams, JSON_UNESCAPED_UNICODE);
|
||
$url = 'https://api.tianditu.gov.cn/v2/search?postStr=' . urlencode($postStr) . '&type=query&tk=' . $key;
|
||
$resp = self::httpGet($url);
|
||
if (!$resp) {
|
||
return [];
|
||
}
|
||
$data = json_decode($resp, true);
|
||
if (is_array($data) && !empty($data['pois']) && is_array($data['pois'])) {
|
||
return $data['pois'];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
/**
|
||
* 统一标准化 POI 输出项,避免搜索与周边查询逻辑重复
|
||
*/
|
||
protected static function formatPoiItem(array $poi, $fallbackName, $category = '', $refLat = null, $refLng = null, $defaultCity = '', $defaultDistrict = '')
|
||
{
|
||
if (empty($poi['lonlat'])) {
|
||
return null;
|
||
}
|
||
$parts = explode(',', (string)$poi['lonlat']);
|
||
if (count($parts) !== 2) {
|
||
return null;
|
||
}
|
||
$lng = round((float)$parts[0], 6);
|
||
$lat = round((float)$parts[1], 6);
|
||
if (abs($lat) < 0.0001 && abs($lng) < 0.0001) {
|
||
return null;
|
||
}
|
||
|
||
$distM = null;
|
||
if ($refLat !== null && $refLng !== null) {
|
||
$distM = (int)round(self::distance($refLat, $refLng, $lat, $lng));
|
||
} elseif (!empty($poi['distance'])) {
|
||
$rawDist = strtolower(trim((string)$poi['distance']));
|
||
if (strpos($rawDist, 'km') !== false) {
|
||
$distM = (int)round((float)str_replace('km', '', $rawDist) * 1000);
|
||
} else {
|
||
$distM = (int)round((float)str_replace('m', '', $rawDist));
|
||
}
|
||
}
|
||
|
||
return [
|
||
'name' => trim((string)($poi['name'] ?? $fallbackName)),
|
||
'address' => trim((string)($poi['address'] ?? '')),
|
||
'city' => trim((string)($poi['city'] ?? $defaultCity)),
|
||
'district' => trim((string)($poi['county'] ?? $defaultDistrict)),
|
||
'lng' => $lng,
|
||
'lat' => $lat,
|
||
'distance_m' => $distM,
|
||
'distance_text' => self::formatMetersText($distM),
|
||
'category' => $category,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 米数转紧凑展示距离
|
||
*/
|
||
protected static function formatMetersText($meters)
|
||
{
|
||
if ($meters === null) {
|
||
return '';
|
||
}
|
||
$m = (int)$meters;
|
||
if ($m < 1000) {
|
||
return max(10, $m) . 'm';
|
||
}
|
||
return round($m / 1000, 2) . 'km';
|
||
}
|
||
|
||
/**
|
||
* 高精度两点大圆球面距离计算(Haversine Formula)
|
||
* @param float $lat1 起点纬度
|
||
* @param float $lng1 起点经度
|
||
* @param float $lat2 终点纬度
|
||
* @param float $lng2 终点经度
|
||
* @return float 距离(单位:米,保留1位小数)
|
||
*/
|
||
public static function distance($lat1, $lng1, $lat2, $lng2)
|
||
{
|
||
$lat1 = (float)$lat1;
|
||
$lng1 = (float)$lng1;
|
||
$lat2 = (float)$lat2;
|
||
$lng2 = (float)$lng2;
|
||
|
||
if (abs($lat1 - $lat2) < 0.0000001 && abs($lng1 - $lng2) < 0.0000001) {
|
||
return 0.0;
|
||
}
|
||
|
||
$earthRadius = 6378137.0; // 地球半径(米)
|
||
|
||
$radLat1 = deg2rad($lat1);
|
||
$radLat2 = deg2rad($lat2);
|
||
$deltaLat = $radLat1 - $radLat2;
|
||
$deltaLng = deg2rad($lng1) - deg2rad($lng2);
|
||
|
||
$val = 2 * asin(sqrt(pow(sin($deltaLat / 2), 2) + cos($radLat1) * cos($radLat2) * pow(sin($deltaLng / 2), 2)));
|
||
$dist = $val * $earthRadius;
|
||
|
||
return round($dist, 1);
|
||
}
|
||
|
||
/**
|
||
* 异地打卡防作弊检测
|
||
* 判断师傅现场签到 GPS 与订单下单地址物理距离是否在允许安全半径内
|
||
* @param float $orderLat 订单设定的纬度
|
||
* @param float $orderLng 订单设定的经度
|
||
* @param float $punchLat 师傅签到的纬度
|
||
* @param float $punchLng 师傅签到的经度
|
||
* @param float $allowedDistance 允许的最大打卡偏差(米),默认 1000 米
|
||
* @return array ['is_pass' => bool, 'distance' => float, 'reason' => string]
|
||
*/
|
||
public static function checkArriveDistance($orderLat, $orderLng, $punchLat, $punchLng, $allowedDistance = 1000.0)
|
||
{
|
||
// 若订单未维护或无经纬度,宽容放行但记录无基准
|
||
if (abs((float)$orderLat) < 0.0001 || abs((float)$orderLng) < 0.0001) {
|
||
return [
|
||
'is_pass' => true,
|
||
'distance' => 0.0,
|
||
'reason' => '订单无基准经纬度,跳过距离校验'
|
||
];
|
||
}
|
||
|
||
$dist = self::distance($orderLat, $orderLng, $punchLat, $punchLng);
|
||
$isPass = ($dist <= $allowedDistance);
|
||
|
||
return [
|
||
'is_pass' => $isPass,
|
||
'distance' => $dist,
|
||
'reason' => $isPass ? '签到位置在合理范围内' : "签到定位与订单地址相距 {$dist} 米,超过 {$allowedDistance} 米限制"
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 发送轻量 HTTP GET 请求
|
||
*/
|
||
protected static function httpGet($url, $timeout = 3)
|
||
{
|
||
$ch = curl_init();
|
||
curl_setopt($ch, CURLOPT_URL, $url);
|
||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||
$result = curl_exec($ch);
|
||
if ($result === false) {
|
||
Log::record('[TiandituService] curl_exec 失败: ' . curl_error($ch), 'warning');
|
||
}
|
||
curl_close($ch);
|
||
return $result;
|
||
}
|
||
}
|