*/ 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'; /** 高德输入提示(地址联想)端点 —— 与网页端 AutoComplete **同一个上游** */ private const TIPS_URL = 'https://restapi.amap.com/v3/assistant/inputtips'; 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; } /** * 高德**输入提示**(地址联想)—— 给**鸿蒙端**用。 * * 🔴 为什么这个接口在服务端、而不是让端上直接调高德(2026-10-30,工单 #36): * 1. 高德 Web 服务 key 是**服务端密钥**,放进客户端安装包就等于公开 * (反编译/抓包都能拿到,配额被人跑光、还可能被高德封 key); * 2. 网页端用的是 **JS API key + 安全密钥**(`jsConfig()`), * 那套机制是给浏览器域名白名单用的,原生端没有等价物; * 3. 端上要的字段和网页 `result.tips` 一致(名字 + 省市区 + 门牌), * 在服务端归一一次,端上就不用重复解析高德那三套不齐的字段。 * * 🔴 用户 2026-10-30 的原始诉求(网页端已实现,本单同步到端上): * 「输入一个简单地址、下拉显示详细地址时,不能只把地点告诉我, * 一定要把详细地址也放在后面。因为同一个地址可能有好几个, * 我根本不知道应该选哪一个。」 * ⇒ 返回的每一项都必须带 `detail`(省市区 + 门牌),端上把它画在名字下面, * 同名地点才分得开。 * * ⚠️ 高德的 `tips` 字段**不齐**(实测): * · POI 类:`name` + `address` + `district` * · 纯行政区划:`name`(就是地名)+ `district`,**没有 address** * ⇒ `detail` 要把能拿到的都拼上,全空就退回 `district`,不能是空串。 * ⚠️ 有些 tip 是**公交线路/地铁站**(`typecode` 150x),`location` 也可能为空数组; * 这类对"填地点"没用,过滤掉。 * * @param string $kw 关键词(用户已输入的文字) * @param int $limit 最多返回几条(端上视口有限,默认 10) * @return array * 失败/没配 key/关键词太短 ⇒ **返回空数组**,不抛错(调用方照常手敲) */ public static function tips(string $kw, int $limit = 10): array { $kw = trim($kw); /* 少于 2 个字不查:一个字的候选既多又没用,还白烧配额。 (与网页端 `addr_suggest.js` 的阈值一致,两边体验才对得上。) */ if (mb_strlen($kw, 'UTF-8') < 2) { return []; } $key = self::apiKey(); if ($key === '' || !function_exists('curl_init')) { return []; } $url = self::TIPS_URL . '?' . http_build_query([ 'key' => $key, 'keywords' => $kw, /* `city` 留空 = 全国范围。⚠️ 不要默认塞"青岛": 用户可能正在记外地的行程,限定城市反而搜不到。 */ 'city' => '', 'citylimit' => 'false', 'output' => 'JSON', ]); $ch = curl_init($url); if ($ch === false) { return []; } 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 []; } $j = json_decode($body, true); /* ⚠️ 成败看 body 里的 `status`(字符串 "1"),**不是** HTTP 状态码: 配额用完时高德返回 200 + status="0"。 */ if (!is_array($j) || (string)($j['status'] ?? '') !== '1') { return []; } $tips = $j['tips'] ?? []; if (!is_array($tips)) { return []; } $out = []; foreach ($tips as $t) { if (!is_array($t)) { continue; } $name = trim((string)($t['name'] ?? '')); if ($name === '') { continue; } /* 过滤公交/地铁线路(typecode 以 15 开头)。 ⚠️ 高德的 `typecode` 有时是空数组 ⇒ 只在**确实是字符串**时判。 */ $tc = $t['typecode'] ?? ''; if (is_string($tc) && strpos($tc, '15') === 0) { continue; } /* `district` 在不同条目里是字符串,也可能是空数组 ⇒ 统一成串 */ $district = ''; if (isset($t['district']) && is_string($t['district'])) { $district = trim($t['district']); } elseif (isset($t['adcode']) && is_string($t['adcode'])) { // 只有 adcode 时拿不到地名,留空,别硬凑 $district = ''; } $address = ''; if (isset($t['address']) && is_string($t['address'])) { $address = trim($t['address']); } /* detail = 省市区 + 门牌,去重(address 常以 district 开头或反之) */ $parts = []; if ($district !== '') { $parts[] = $district; } if ($address !== '' && strpos($address, $district) !== 0) { $parts[] = $address; } elseif ($address !== '' && $district === '') { $parts[] = $address; } $detail = trim(preg_replace('/\s+/u', ' ', implode(' ', $parts)) ?? ''); /* 坐标:高德给 "lng,lat" 字符串;也可能是空数组 */ $lng = ''; $lat = ''; if (isset($t['location']) && is_string($t['location']) && strpos($t['location'], ',') !== false) { [$lng, $lat] = array_map('trim', explode(',', $t['location'], 2)); } $out[] = [ 'name' => $name, 'detail' => $detail, 'district' => $district, 'address' => $address, 'lng' => $lng, 'lat' => $lat, ]; if (count($out) >= max(1, min(20, $limit))) { break; } } return $out; } /** * 从高德的 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'] ?? '')) : '', ]; } }