Files
FamilyBook/app/Geo.php
T

660 lines
32 KiB
PHP
Raw Normal View History

<?php
/**
* 城市 → 经纬度(v24 用户 2026-10-03 第 1 条)。
*
* 用户原话:「天气这个这样吧,让用户在个人设置中填写自己的城市。」
*
* 所以天气的地点从「管理员在系统设置里填经纬度」改成了「**每个人**在自己的
* 个人设置里填城市名」。于是就必须解决"城市名 → 坐标"这一步。
*
* 三级回落,**每一级都可用**:
* ① 内置表(下面 CITIES):全国地级市 + 直辖市 + 计划单列市,共 197 条。
* **不联网**、不受外部接口抖动影响 —— 绝大多数人到这里就结束了。
* ② 个人设置留了「自定义经纬度」两个框:内置表里没有的地方(镇、村、海外)
* 直接填经纬度即可,不用等我们加表。
* ③ 都没有 → 首页整块不显示天气。
*
* ⚠️ 为什么不直接让用户填经纬度就完了:普通人不知道自己在哪个坐标。
* 填城市名是"人话",坐标是我们要解决的实现细节,不该外泄成用户负担。
*
* ⚠️ 为什么不在请求时去调 Open-Meteo 的地理编码接口(那也能查中文地名):
* 实测它对中国**地级市**的命中率很差 —— 中文名「长沙」「汕头」「湛江」「荆门」
* 「泸州」全部查不到(返回的是同名村/镇,甚至第一版查「青岛」返回的
* 第一条在**辽宁**)。要靠它就得先猜拼音、再按省份过滤,一轮下来仍是不可靠的。
* 离线表是确定的:写死的东西不会哪天变成另一个省。
*
* ⚠️ 表里的坐标是**逐条核对过**的(生成脚本 _tooling/_fb_geo_gen.py:
* 用拼音查 Open-Meteo 并按省份过滤,输出每条的 admin1 供人工核对,
* 个别被同名小镇盖住的如「淮安」取公开的城区坐标)。
* 改表请重跑那个脚本,别手敲坐标。
*/
declare(strict_types=1);
namespace App;
final class Geo
{
/**
* 直辖市 / 地级市 → [纬度, 经度, 省份]
*
* @var array<string, array{0:float,1:float,2:string}>
*/
public const CITIES = [
'北京' => [39.9075, 116.3972, '北京市'],
'上海' => [31.2222, 121.4581, '上海市'],
'天津' => [39.1422, 117.1767, '天津市'],
'重庆' => [29.5603, 106.5577, '重庆市'],
'石家庄' => [38.0414, 114.4786, '河北省'],
'唐山' => [39.6438, 118.1832, '河北省'],
'保定' => [38.8729, 115.4625, '河北省'],
'廊坊' => [39.5208, 116.7147, '河北省'],
'秦皇岛' => [39.9410, 119.5894, '河北省'],
'沧州' => [38.3112, 116.8533, '河北省'],
'邯郸' => [36.6100, 114.4876, '河北省'],
'邢台' => [37.0622, 114.4927, '河北省'],
'张家口' => [40.7834, 114.8714, '河北省'],
'承德' => [40.9519, 117.9588, '河北省'],
'衡水' => [37.7391, 115.6835, '河北省'],
'太原' => [37.8694, 112.5603, '山西省'],
'大同' => [40.0936, 113.2914, '山西省'],
'临汾' => [36.0889, 111.5189, '山西省'],
'运城' => [35.0231, 110.9928, '山西省'],
'长治' => [34.8659, 110.3957, '山西省'],
'晋中' => [37.6840, 112.7547, '山西省'],
'呼和浩特' => [40.8106, 111.6522, '内蒙古自治区'],
'包头' => [40.6516, 109.8439, '内蒙古自治区'],
'鄂尔多斯' => [39.6086, 109.7816, '内蒙古自治区'],
'赤峰' => [42.2683, 118.9636, '内蒙古自治区'],
'沈阳' => [41.7922, 123.4328, '辽宁省'],
'大连' => [38.9122, 121.6022, '辽宁省'],
'鞍山' => [41.1236, 122.9900, '辽宁省'],
'抚顺' => [41.8867, 123.9436, '辽宁省'],
'本溪' => [41.2886, 123.7650, '辽宁省'],
'丹东' => [40.1292, 124.3947, '辽宁省'],
'锦州' => [41.1078, 121.1417, '辽宁省'],
'营口' => [40.6647, 122.2318, '辽宁省'],
'盘锦' => [41.1210, 122.0739, '辽宁省'],
'葫芦岛' => [40.7524, 120.8355, '辽宁省'],
'朝阳' => [40.4844, 123.5369, '辽宁省'],
'长春' => [43.8800, 125.3228, '吉林省'],
'吉林' => [43.8465, 126.5608, '吉林省'],
'四平' => [43.1614, 124.3778, '吉林省'],
'通化' => [41.7197, 125.9264, '吉林省'],
'延吉' => [42.8882, 129.5024, '吉林省'],
'哈尔滨' => [45.7500, 126.6500, '黑龙江省'],
'齐齐哈尔' => [47.3392, 123.9615, '黑龙江省'],
'大庆' => [46.5833, 125.0000, '黑龙江省'],
'牡丹江' => [44.5480, 129.6259, '黑龙江省'],
'佳木斯' => [46.7971, 130.3112, '黑龙江省'],
'南京' => [32.0617, 118.7778, '江苏省'],
'苏州' => [31.3041, 120.5954, '江苏省'],
'无锡' => [31.5689, 120.2886, '江苏省'],
'徐州' => [34.2044, 117.2839, '江苏省'],
'常州' => [31.7736, 119.9540, '江苏省'],
'南通' => [32.0303, 120.8747, '江苏省'],
'扬州' => [32.3972, 119.4358, '江苏省'],
'盐城' => [33.3575, 120.1573, '江苏省'],
'泰州' => [32.4907, 119.9081, '江苏省'],
'镇江' => [32.2109, 119.4551, '江苏省'],
'淮安' => [33.5975, 119.0211, '江苏省'],
'连云港' => [34.5984, 119.2156, '江苏省'],
'宿迁' => [33.9492, 118.2958, '江苏省'],
'杭州' => [30.2936, 120.1614, '浙江省'],
'宁波' => [29.8782, 121.5494, '浙江省'],
'温州' => [27.9994, 120.6668, '浙江省'],
'嘉兴' => [30.7522, 120.7500, '浙江省'],
'绍兴' => [30.0024, 120.5786, '浙江省'],
'台州' => [28.6627, 121.4331, '浙江省'],
'金华' => [29.1068, 119.6442, '浙江省'],
'湖州' => [30.8703, 120.0933, '浙江省'],
'衢州' => [28.9594, 118.8686, '浙江省'],
'丽水' => [28.4604, 119.9103, '浙江省'],
'舟山' => [29.9887, 122.2049, '浙江省'],
'合肥' => [31.8639, 117.2808, '安徽省'],
'芜湖' => [31.3526, 118.4295, '安徽省'],
'蚌埠' => [32.9408, 117.3608, '安徽省'],
'安庆' => [30.5136, 117.0472, '安徽省'],
'马鞍山' => [31.6858, 118.5101, '安徽省'],
'淮南' => [32.6264, 116.9969, '安徽省'],
'黄山' => [29.7114, 118.3125, '安徽省'],
'滁州' => [32.3219, 118.2978, '安徽省'],
'阜阳' => [32.9000, 115.8167, '安徽省'],
'宿州' => [33.6361, 116.9789, '安徽省'],
'六安' => [31.7356, 116.5169, '安徽省'],
'亳州' => [33.8772, 115.7703, '安徽省'],
'福州' => [26.0614, 119.3061, '福建省'],
'厦门' => [24.4798, 118.0819, '福建省'],
'泉州' => [24.9139, 118.5858, '福建省'],
'漳州' => [24.5133, 117.6556, '福建省'],
'莆田' => [25.4394, 119.0103, '福建省'],
'三明' => [26.2486, 117.6186, '福建省'],
'南平' => [26.6450, 118.1736, '福建省'],
'龙岩' => [25.0749, 117.0178, '福建省'],
'宁德' => [26.6617, 119.5228, '福建省'],
'南昌' => [28.6840, 115.8531, '江西省'],
'九江' => [29.7048, 116.0021, '江西省'],
'赣州' => [25.8466, 114.9326, '江西省'],
'景德镇' => [29.2947, 117.2079, '江西省'],
'上饶' => [28.4518, 117.9429, '江西省'],
'宜春' => [27.8333, 114.4000, '江西省'],
'吉安' => [27.1172, 114.9793, '江西省'],
'抚州' => [27.9600, 116.3333, '江西省'],
'济南' => [36.6683, 116.9972, '山东省'],
'青岛' => [36.0649, 120.3804, '山东省'],
'烟台' => [37.4765, 121.4408, '山东省'],
'威海' => [37.5091, 122.1136, '山东省'],
'潍坊' => [36.7100, 119.1019, '山东省'],
'淄博' => [36.7906, 118.0633, '山东省'],
'临沂' => [35.0631, 118.3428, '山东省'],
'济宁' => [35.4050, 116.5814, '山东省'],
'泰安' => [36.1853, 117.1200, '山东省'],
'日照' => [35.4141, 119.5291, '山东省'],
'东营' => [37.4627, 118.4917, '山东省'],
'滨州' => [37.3667, 118.0167, '山东省'],
'德州' => [37.4466, 116.3671, '山东省'],
'聊城' => [36.4506, 116.0025, '山东省'],
'菏泽' => [35.2393, 115.4736, '山东省'],
'枣庄' => [34.8647, 117.5542, '山东省'],
'郑州' => [34.7578, 113.6486, '河南省'],
'洛阳' => [34.6735, 112.4368, '河南省'],
'开封' => [34.7986, 114.3074, '河南省'],
'新乡' => [35.1903, 113.8015, '河南省'],
'安阳' => [36.0960, 114.3828, '河南省'],
'焦作' => [35.2392, 113.2391, '河南省'],
'平顶山' => [33.7309, 113.3155, '河南省'],
'许昌' => [34.0319, 113.8630, '河南省'],
'漯河' => [33.5639, 114.0427, '河南省'],
'三门峡' => [34.7808, 111.1929, '河南省'],
'南阳' => [33.0052, 112.5466, '河南省'],
'商丘' => [34.4143, 115.6561, '河南省'],
'信阳' => [32.1228, 114.0656, '河南省'],
'周口' => [33.6333, 114.6333, '河南省'],
'驻马店' => [32.9794, 114.0294, '河南省'],
'武汉' => [30.5833, 114.2667, '湖北省'],
'宜昌' => [30.7144, 111.2847, '湖北省'],
'襄阳' => [32.0422, 112.1448, '湖北省'],
'荆州' => [30.3503, 112.1903, '湖北省'],
'黄石' => [30.2471, 115.0481, '湖北省'],
'十堰' => [32.6475, 110.7781, '湖北省'],
'孝感' => [30.9269, 113.9222, '湖北省'],
'荆门' => [31.0336, 112.2047, '湖北省'],
'长沙' => [28.1987, 112.9709, '湖南省'],
'株洲' => [27.8333, 113.1500, '湖南省'],
'湘潭' => [27.8500, 112.9000, '湖南省'],
'衡阳' => [26.8895, 112.6189, '湖南省'],
'岳阳' => [29.3745, 113.0948, '湖南省'],
'常德' => [29.0321, 111.6984, '湖南省'],
'郴州' => [25.8000, 113.0333, '湖南省'],
'张家界' => [29.1294, 110.4783, '湖南省'],
'广州' => [23.1167, 113.2500, '广东省'],
'深圳' => [22.5455, 114.0683, '广东省'],
'珠海' => [22.2769, 113.5678, '广东省'],
'汕头' => [23.3549, 116.6788, '广东省'],
'佛山' => [23.0268, 113.1315, '广东省'],
'东莞' => [23.0180, 113.7487, '广东省'],
'中山' => [22.5231, 113.3791, '广东省'],
'惠州' => [23.1115, 114.4152, '广东省'],
'江门' => [22.5833, 113.0833, '广东省'],
'湛江' => [21.2339, 110.3875, '广东省'],
'肇庆' => [23.0489, 112.4609, '广东省'],
'茂名' => [21.6663, 110.9136, '广东省'],
'揭阳' => [23.5418, 116.3658, '广东省'],
'潮州' => [23.6540, 116.6226, '广东省'],
'梅州' => [24.2886, 116.1177, '广东省'],
'河源' => [23.7333, 114.6833, '广东省'],
'清远' => [23.7000, 113.0333, '广东省'],
'韶关' => [24.8000, 113.5833, '广东省'],
'阳江' => [21.8556, 111.9627, '广东省'],
'云浮' => [22.9279, 112.0381, '广东省'],
'南宁' => [22.8167, 108.3167, '广西壮族自治区'],
'桂林' => [25.2802, 110.2964, '广西壮族自治区'],
'柳州' => [24.3240, 109.4070, '广西壮族自治区'],
'北海' => [21.4835, 109.1155, '广西壮族自治区'],
'海口' => [20.0342, 110.3465, '海南省'],
'三亚' => [18.2543, 109.5095, '海南省'],
'成都' => [30.6667, 104.0667, '四川省'],
'绵阳' => [31.4678, 104.6817, '四川省'],
'德阳' => [31.1302, 104.3820, '四川省'],
'宜宾' => [28.7593, 104.6399, '四川省'],
'泸州' => [28.8903, 105.4257, '四川省'],
'南充' => [30.7951, 106.0847, '四川省'],
'乐山' => [29.5623, 103.7639, '四川省'],
'贵阳' => [26.5833, 106.7167, '贵州省'],
'遵义' => [27.6867, 106.9072, '贵州省'],
'昆明' => [25.0389, 102.7183, '云南省'],
'大理' => [25.5847, 100.2123, '云南省'],
'丽江' => [26.8688, 100.2207, '云南省'],
'曲靖' => [25.4833, 103.7833, '云南省'],
'拉萨' => [29.6500, 91.1000, '西藏自治区'],
'西安' => [34.3378, 108.7026, '陕西省'],
'宝鸡' => [34.3678, 107.2370, '陕西省'],
'咸阳' => [34.3378, 108.7026, '陕西省'],
'渭南' => [34.5035, 109.5089, '陕西省'],
'榆林' => [38.2918, 109.7375, '陕西省'],
'兰州' => [36.0570, 103.8399, '甘肃省'],
'天水' => [34.5795, 105.7424, '甘肃省'],
'西宁' => [36.6255, 101.7574, '青海省'],
'银川' => [38.4681, 106.2731, '宁夏回族自治区'],
'乌鲁木齐' => [43.8010, 87.6005, '新疆维吾尔自治区'],
'喀什' => [39.4672, 75.9868, '新疆维吾尔自治区'],
];
/**
* 找坐标。返回 ['lat' => '36.0649', 'lon' => '120.3804', 'place' => '山东省青岛'] 或 null。
*
* 接受:完整市名(青岛) / 带「市」的(青岛市) / 带省份前缀的(山东省青岛)。
* 不做模糊匹配 —— "猜错城市"比"没有城市"糟得多(会让人以为天气坏了)。
*/
public static function lookup(string $q): ?array
{
$name = self::normalize($q);
if ($name === '' || !isset(self::CITIES[$name])) {
return null;
}
[$lat, $lon, $prov] = self::CITIES[$name];
return [
'lat' => number_format($lat, 4, '.', ''),
'lon' => number_format($lon, 4, '.', ''),
'place' => self::label($prov, $name),
];
}
/**
* 归一化:去空白 → 去末尾「市」→ 去省份前缀。
*
* ⚠️ 顺序很重要:**先**去「市」再去省份前缀。
* 反过来的话「上海市」会被当成"省份=上海市 + 空市名",
* 前缀一剥就什么都不剩了 → 直辖市全都查不到(北京/上海/天津/重庆)。
*
* ⚠️ 去省份用"已知省名清单"前缀匹配,而不是"取最后两个字":
* 「内蒙古」是三个字、「新疆」是两个字,按长度切必然切错。
*/
public static function normalize(string $q): string
{
$q = trim(preg_replace('/\s+/u', '', $q));
if ($q === '') {
return '';
}
// ① 末尾的「市」去掉(表里存的是不带「市」的名字)
if (mb_substr($q, -1, 1, 'UTF-8') === '市' && mb_strlen($q, 'UTF-8') > 1) {
$q = (string)mb_substr($q, 0, mb_strlen($q, 'UTF-8') - 1, 'UTF-8');
}
// ② 「山东省青岛」/「河北石家庄」这类带省份的写法,去掉省份前缀
foreach (self::PROVINCE_WORDS as $pw) {
if ($pw !== '' && mb_strpos($q, $pw) === 0 && mb_strlen($q, 'UTF-8') > mb_strlen($pw, 'UTF-8')) {
$q = (string)mb_substr($q, mb_strlen($pw, 'UTF-8'), null, 'UTF-8');
break;
}
}
return $q;
}
/**
* 展示用的地名:省份简称 + 市名(如「山东·青岛」),直辖市只说市名。
*
* ⚠️ 判据不能写成「市名只有两个字就是直辖市」——「青岛」「大连」「厦门」
* 「深圳」都是两个字,那样会把它们漏成光秃秃的「青岛」,
* 而没人在乎「北京」是不是直辖市,大家在乎的是"我看到的天气是哪儿的"。
*/
private static function label(string $prov, string $city): string
{
$short = self::PROV_SHORT[$prov] ?? '';
if ($short === '') {
return $city; // 直辖市 / 认不出的省份:只说市名
}
return $short . '·' . $city;
}
/** 完整省份名 → 简称(直辖市映射到空串 = 不加前缀) */
private const PROV_SHORT = [
'北京市' => '', '天津市' => '', '上海市' => '', '重庆市' => '',
'河北省' => '河北', '山西省' => '山西', '辽宁省' => '辽宁', '吉林省' => '吉林',
'黑龙江省' => '黑龙江', '江苏省' => '江苏', '浙江省' => '浙江', '安徽省' => '安徽',
'福建省' => '福建', '江西省' => '江西', '山东省' => '山东', '河南省' => '河南',
'湖北省' => '湖北', '湖南省' => '湖南', '广东省' => '广东', '海南省' => '海南',
'四川省' => '四川', '贵州省' => '贵州', '云南省' => '云南', '陕西省' => '陕西',
'甘肃省' => '甘肃', '青海省' => '青海', '台湾省' => '台湾',
'内蒙古自治区' => '内蒙古', '广西壮族自治区' => '广西',
'西藏自治区' => '西藏', '宁夏回族自治区' => '宁夏',
'新疆维吾尔自治区' => '新疆',
];
/** 支持"省份前缀"写法的省名清单(长的排前面,避免「山西」被「山」抢先匹配) */
private const PROVINCE_WORDS = [
'新疆维吾尔自治区', '广西壮族自治区', '宁夏回族自治区', '内蒙古自治区',
'西藏自治区', '香港特别行政区', '澳门特别行政区',
'黑龙江省', '内蒙古', '新疆', '广西', '宁夏', '西藏',
'北京市', '天津市', '上海市', '重庆市',
'河北省', '山西省', '辽宁省', '吉林省', '江苏省', '浙江省', '安徽省',
'福建省', '江西省', '山东省', '河南省', '湖北省', '湖南省', '广东省',
'海南省', '四川省', '贵州省', '云南省', '陕西省', '甘肃省', '青海省',
'台湾省',
];
// ================================================================
// 以下为 v24 第 17 批新增:**地址 → 城市**(用于旅程的城市聚合统计)
// ================================================================
/**
* 高德 Web 服务的逆地理端点 / 超时。
*
* ⚠️ 与上面天气那套(离线表)是**两条独立路径**,互不影响:
* 天气要"城市 → 坐标"(离线表够用),
* 旅程要"坐标/地址 → 城市"(必须联网问高德,因为地址千奇百怪)。
*/
private const REGEO_URL = 'https://restapi.amap.com/v3/geocode/regeo';
private const HTTP_TIMEOUT = 3;
/** 直辖市(高德对它们的 `city` 字段返回空数组,要认 `province`) */
private const MUNICIPALITIES = ['北京市', '上海市', '天津市', '重庆市'];
/**
* 从「经纬度 + 地址文本」解析**城市名**(如「青岛市」)。
*
* 三级回落,每一级都可用(用户第 1 条要的就是"城市",不能因为接口挂了就统计不出来):
* ① **高德逆地理**(有坐标时):`addressComponent.city` 是权威答案;
* ② 离线城市表 `CITIES` 做**地址包含匹配**(「黑龙江省伊春市…」里
* 能认出已知的城市名;不联网、不受接口抖动影响);
* ③ 从地址里提「XX市」(最朴素的一条,但中文地址里几乎总有)。
*
* ⚠️ 三级都拿不到就返回**空串**,不猜:`place` 里填的是
* 「溪水国家森林公园」「青岛胶东国际机场」这类**地点名**,
* 从里面猜城市只会猜错(用户宁可少一个城市,也不要一个假城市名)。
*
* ⚠️ 绝不抛异常:这是"锦上添花",它挂了不能让保存行程失败。
*/
public static function cityFromPoint($lng, $lat, string $fallback = ''): string
{
if (is_numeric($lng) && is_numeric($lat)) {
$ac = self::regeo((string)$lng, (string)$lat);
if ($ac !== null) {
$c = self::cityOfComponents($ac);
if ($c !== '') {
return $c;
}
}
}
return self::cityFromText($fallback);
}
/**
* 调高德逆地理,返回 `regeocode.addressComponent`(失败返回 null)。
*
* ⚠️ 用 cURL 而不是 `file_get_contents`:后者在 `allow_url_fopen=0`
* 的主机上不可用(宝塔默认可能就是关的),而且**没有超时控制**
* —— 高德慢一点,用户保存行程就会一直转圈。
* ⚠️ 成败看 body 里的 `status`(字符串 "1"),**不是** HTTP 状态码:
* 配额用完时高德返回 200 + status="0",只看 HTTP 会误判成功。
*/
public static function regeo(string $lng, string $lat): ?array
{
$key = self::apiKey();
if ($key === '' || !function_exists('curl_init')) {
return null;
}
$url = self::REGEO_URL . '?' . http_build_query([
'key' => $key,
'location' => $lng . ',' . $lat, // 高德要「经度,纬度」
'extensions' => 'base',
'output' => 'JSON',
]);
$ch = curl_init($url);
if ($ch === false) {
return null;
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => self::HTTP_TIMEOUT,
CURLOPT_CONNECTTIMEOUT => self::HTTP_TIMEOUT,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'FamilyBook/1.0',
]);
$body = curl_exec($ch);
$err = curl_errno($ch);
curl_close($ch);
if ($err !== 0 || !is_string($body) || $body === '') {
return null;
}
$j = json_decode($body, true);
if (!is_array($j) || (string)($j['status'] ?? '') !== '1') {
return null;
}
$ac = $j['regeocode']['addressComponent'] ?? null;
return is_array($ac) ? $ac : null;
}
/**
* 从高德的 addressComponent 里取出"城市"。
*
* ⚠️ 直辖市(北京/上海/天津/重庆)的 `city` 字段是**空数组或空串**,
* 它们本身就是城市一级。只读 `city` 会让这四地的行程一个城市都统计不出来
* (用户会觉得"北京那次怎么不算")。
*/
public static function cityOfComponents(array $ac): string
{
$pick = static function ($v): string {
// ⚠️ 高德在没有值时返回**空数组**(不是空串),is_array 这层不能省
if (is_array($v)) {
$v = $v['name'] ?? '';
}
return trim((string)$v);
};
$city = $pick($ac['city'] ?? '');
$prov = $pick($ac['province'] ?? '');
if ($city !== '') {
return $city;
}
foreach (self::MUNICIPALITIES as $m) {
if ($prov === $m) {
return $m;
}
}
return '';
}
/**
* 从**地址文本**里提城市(不联网;②③ 两级都走这里)。
*
* 顺序(可靠 → 不可靠):
* ① 先提「XX市」:中文地址里城市几乎总以「XX市」出现,
* 而且地址是 **省 → 市 → 区** 的层级顺序,所以取**第一个**「XX市」
* 就是地级市那级。
* ⚠️ 不能取"最后一个":「黑龙江省黑河市五大连池市五大连池镇」取最后
* 会得到县级市「五大连池市」,再往前回溯窗口一截就成「大连市」了
* (实测踩到);而且「山东省青岛市市南区」的"市南区"里还有个"市"字。
* ② 提不出来时才查离线表 `CITIES` 做**包含匹配**(最长优先)。
* ⚠️ 只在 ① 失败时用:「长春市朝阳区」包含「朝阳」(辽宁朝阳市在表里),
* 先走离线表会返回「朝阳市」—— 离了大谱(实测踩到)。
* 「青岛胶东国际机场」这种**没有"市"字的地点名**才是这一级要救的。
* ③ 直辖市(不带"市"字也可能写:「北京朝阳区」)。
*
* ⚠️ 不猜地点名:`place` 字段里全是「溪水国家森林公园」这种,
* 从里面猜城市只会猜错(宁可不计,不要算错)。
* 注意「青岛胶东国际机场」能提出来是因为"青岛"是**已知城市名**(离线表里有),
* 不是靠猜。
*/
public static function cityFromText(string $text): string
{
$t = trim($text);
if ($t === '') {
return '';
}
// ---- ⓪ 直辖市**先认**(必须在剥省份前缀之前)----
// ⚠️ 顺序错了会出大错(实测踩到):「北京市朝阳区建国路」如果先剥掉
// 「北京市」(它在 PROVINCE_WORDS 里),剩下「朝阳区建国路」,
// 接着在离线表里匹配到「朝阳」→ 返回**辽宁朝阳市**。离了大谱。
// 所以直辖市要在最前面单独认掉。
foreach (self::MUNICIPALITIES as $m) {
if (mb_strpos($t, $m, 0, 'UTF-8') !== false) {
return $m;
}
// 也可能是短名(「北京朝阳区」)
if (mb_strpos($t, mb_substr($m, 0, 2, 'UTF-8'), 0, 'UTF-8') !== false) {
return $m;
}
}
// ---- ① 提「XX市」----
// ⚠️ 三步的顺序与量词都很讲究,每一条都是实测踩出来的:
//
// a) **先去省份前缀**。不去的话「内蒙古自治区呼和浩特市」用惰性匹配
// 最多看 7 个字,`{2,7}?` 够不到「呼和浩特市」(省名占了 6 个字)
// → 提不出城市。
// b) **惰性量词 `{2,7}?`**,不是贪婪。贪婪会先试最长的,
// 「山东省青岛市市南区」会匹配到 `青岛市市`(把市南区的"市"当成后缀)
// → 得出「青岛市市」。惰性从最短开始试,先命中「青岛市」。
// c) **取第一个命中的**。地址是"省 → 市 → 区"的层级顺序,
// 第一个「XX市」就是地级市那级。
// ⚠️ 不能取最后一个:「黑龙江省黑河市五大连池市五大连池镇」取最后
// 会拿到县级市「五大连池市」,再回溯窗口一截就成了「大连市」。
$body = $t;
foreach (self::PROVINCE_WORDS as $pw) {
if ($pw !== '' && mb_strpos($body, $pw, 0, 'UTF-8') === 0) {
$body = (string)mb_substr($body, mb_strlen($pw, 'UTF-8'), null, 'UTF-8');
break;
}
}
// 排除"市场 / 市民 / 市区"这类:它们后面那个字会暴露自己
$badNext = ['场', '民', '集', '政', '长', '面', '内', '外', '郊', '容'];
if (preg_match('/([\x{4e00}-\x{9fa5}]{2,7}?)市([\x{4e00}-\x{9fa5}])?/u',
$body, $m)) {
$cand = $m[1];
$nxt = (string)($m[2] ?? '');
if (!($nxt !== '' && in_array($nxt, $badNext, true))) {
// 兜一层:万一身体里还残留「省」(「XX省XX市」连着写、没被上面剥掉)
$sp = mb_strpos($cand, '省', 0, 'UTF-8');
if ($sp !== false) {
$cand = (string)mb_substr($cand, $sp + 1, null, 'UTF-8');
}
if (mb_strlen($cand, 'UTF-8') >= 2) {
return $cand . '市';
}
}
}
// ---- ② 离线表包含匹配(最长优先)----
// 只有 ① 提不出来才走这里(见上面的说明:先走会被「朝阳」这类误命中)。
//
// 🔴 这一级**很危险**(实盘 dry-run 抓到):「五大连池风景区-龙门石寨景区」
// 里包含「大连」二字 → 被判成**辽宁大连市**(离了大谱)。
// 收紧两条:
// a) 匹配到的城市名**前面那一个字不能是汉字**(字符串开头除外)——
// 「五大连池」的"大连"前面是"五",被排除;「青岛胶东国际机场」的
// "青岛"前面是开头,保留。
// b) 匹配位置**后面**那一个字也不能是"字"这类能拼成新词的
// (「张家口」的"张家"后面是"口")—— 其实 a) 已够,b) 作补充。
// 老实说这一级只该救「XX机场」「XX站」这种**没有市字的地点名**,
// 真正的地址(带省市)都会在 ① 就被提出来。
$best = '';
$bestPos = -1;
foreach (self::CITIES as $name => $_xy) {
if ($name === '') {
continue;
}
// 直辖市在表里存的是短名,这一级不处理(交给 ③,且已经在前面的 ⓪ 认过了)
$isMuni = false;
foreach (self::MUNICIPALITIES as $mu) {
if (mb_substr($mu, 0, 2, 'UTF-8') === $name) { $isMuni = true; break; }
}
if ($isMuni) {
continue;
}
$p = mb_strpos($t, $name, 0, 'UTF-8');
if ($p === false) {
continue;
}
// (a) 前面那个字若是汉字 → 说明这个"城市名"是更长地名的一部分,跳过
if ($p > 0) {
$prev = mb_substr($t, $p - 1, 1, 'UTF-8');
if (preg_match('/[\x{4e00}-\x{9fa5}]/u', $prev)) {
continue;
}
}
// 最长优先(并列时取靠前的)
if (mb_strlen($name, 'UTF-8') > mb_strlen($best, 'UTF-8')) {
$best = $name;
$bestPos = $p;
}
}
if ($best !== '') {
return $best . '市';
}
// ---- ③ 直辖市(不带"市")----
foreach (self::MUNICIPALITIES as $m) {
$short = mb_substr($m, 0, 2, 'UTF-8');
if (mb_strpos($t, $short, 0, 'UTF-8') !== false) {
return $m;
}
}
return '';
}
/**
* 一段行程的**城市集合**(出发地 + 到达地,去重)。
*
* 用户第 1 条:「有多段行程,都有青岛作为出发地或者目的地,那么就只记一次」
* —— 正是这里:出发地与到达地**都算**,然后去重。
*
* @param array $n trip_nodes 行(读 from_city / to_city)
* @return string[] 城市名(出发地在前)
*/
public static function nodeCities(array $n): array
{
$out = [];
foreach (['from_city', 'to_city'] as $k) {
$c = trim((string)($n[$k] ?? ''));
if ($c !== '' && !in_array($c, $out, true)) {
$out[] = $c;
}
}
return $out;
}
/**
* 高德导航链接(TripLog 同款;用户第 3 条要"记录详细地址"就是为了能导航过去)。
*
* ⚠️ 有坐标走 `navigation`(精确到点),没坐标只能走 `search`(按名字搜)。
*/
public static function navUrl($lng, $lat, string $name = '', string $mode = 'car'): string
{
$a = fb_config('amap');
$src = (is_array($a) ? trim((string)($a['nav_src'] ?? '')) : '') ?: 'family.example.com';
if (is_numeric($lng) && is_numeric($lat)) {
$to = (string)$lng . ',' . (string)$lat . ($name !== '' ? ',' . rawurlencode($name) : '');
return 'https://uri.amap.com/navigation?to=' . $to
. '&mode=' . rawurlencode($mode)
. '&policy=1&src=' . rawurlencode($src) . '&coordinate=gaode&callnative=1';
}
$kw = trim($name);
if ($kw === '') {
return '';
}
return 'https://uri.amap.com/search?keyword=' . rawurlencode($kw) . '&src=' . rawurlencode($src);
}
/** 高德 **Web 服务** key(服务端逆地理用;没配返回空串 → 自动走离线兜底) */
public static function apiKey(): string
{
$a = fb_config('amap');
return is_array($a) ? trim((string)($a['key'] ?? '')) : '';
}
/**
* 前端地址联想需要的配置(**JS API** key + 安全密钥)。
*
* @return array{key:string, security:string, nav_src:string}
*/
public static function jsConfig(): array
{
$a = fb_config('amap');
return [
'key' => is_array($a) ? trim((string)($a['key'] ?? '')) : '',
'security' => is_array($a) ? trim((string)($a['security_js'] ?? '')) : '',
'nav_src' => is_array($a) ? trim((string)($a['nav_src'] ?? '')) : '',
];
}
}