Files
laoyang f58a128816 行程:#47~#51 打包 —— 双轨状态、动态类型、动作按钮、去提示 (#51)
#47 编辑浮层居中、类型换行、去掉手动状态选择、按钮改胶囊
#48 计划/实际双轨:完成即转实际行程,取消≠删除,记真实开始/结束时间
#50 类型点击无反应(@Builder 值传参不建立依赖)
#51 节点类型动态加载(服务端9种,原端上硬编码5种)+ 地址结构化回调

API 新增:GET /trips/<id>/node-types、PATCH /trips/<id>/nodes/action
DB 新增:trip_nodes.actual_started_at / actual_ended_at
2026-11-05 12:40:57 +08:00

885 lines
41 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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` 字段里全是「溪水国家森林公园」这种,
* 从里面猜城市只会猜错(宁可不计,不要算错)。
* 注意「青岛胶东国际机场」能提出来是因为"青岛"是**已知城市名**(离线表里有),
* 不是靠猜。
*/
/**
* 把任意「行政区划串」归一化为**纯市名**(「青岛市」)。
*
* 🔴 2026-11-05 新增。用户原话:
* 「它那个城市原来我说的是指定城市就可以,比如北京市、青岛市。
* 现在弄得很乱,什么省也带上了,市也带上了,区甚至都带上了,
* 这没必要,直接城市就可以。」
*
* ## 为什么必须在服务端做
*
* 前端(网页 `addr_suggest.js`、鸿蒙端)都可能传进「山东省青岛市市南区」,
* 这是历史行为。靠"前端自觉"不可靠:
* · 老版本 App 还在用;
* · 第三方/脚本调用不经过我们的前端;
* · 老数据已经脏了。
* ⇒ 服务端统一归一化,前端传什么都剥成市名。**这是唯一的真防线。**
*
* ## 取值顺序
*
* ① 已经就是纯市名(「青岛市」)→ 原样返回
* ② 含「XX市」→ 取**第一个**(中文地址是 省→市→区 层级顺序)
* ⚠️ 不能取最后一个:「黑龙江省黑河市五大连池市」会得到县级市
* 「五大连池市」;「山东省青岛市市南区」的"市南区"里还有"市"字
* ③ 直辖市不带"市"字(「北京朝阳区」)→ 开头匹配四直辖市
* ④ 都没有 → 返回空串(**不猜**)
*
* @param string $raw 可能带省/区/空格/「·」分隔的行政区划串
* @return string 纯市名;认不出返回空串
*/
public static function cityOnly(string $raw): string
{
$s = trim($raw);
if ($s === '') {
return '';
}
/* 已经是纯市名(最常见的正常情况)→ 直接返回,不做正则 */
if (mb_substr($s, -1, 1, 'UTF-8') === '市' && mb_strpos($s, ' ') === false
&& mb_strpos($s, '·') === false && mb_strpos($s, '省') === false
&& mb_strpos($s, '区') === false && mb_strpos($s, '县') === false) {
return $s;
}
/* 带「·」的(老格式「山东·青岛」)→ 取后半段再走一遍 */
$dot = mb_strpos($s, '·');
if ($dot !== false) {
$s = trim(mb_substr($s, $dot + 1, null, 'UTF-8'));
if ($s !== '') {
/* 「青岛」这种不带市的也要认 —— 补上市字再返回 */
if (mb_substr($s, -1, 1, 'UTF-8') === '市') {
return $s;
}
if (!preg_match('/[省区县旗盟]/u', $s)) {
return $s . '市';
}
}
}
/* 空格分隔的(「山东省 青岛市 市南区」)→ 逐段找第一个「XX市」 */
$segs = preg_split('/\s+/u', $s) ?: [];
foreach ($segs as $seg) {
$got = self::firstCityWord($seg);
if ($got !== '') {
return $got;
}
}
/* 整串里找第一个「XX市」 */
$got = self::firstCityWord($s);
if ($got !== '') {
return $got;
}
/* 直辖市不带"市"字(「北京朝阳区」) */
foreach (self::MUNICIPALITIES as $m) {
$short = rtrim($m, '市');
if ($short !== '' && mb_strpos($s, $short) === 0) {
return $m;
}
}
return '';
}
/**
* 从一个片段里取**第一个**「XX市」,XX 中不允许出现层级字眼。
*
* ⚠️ `[^省市县区旗盟]` 这个字符类不能省:
* 否则「山东省青岛市」会匹配到「山东省市」(把"山东省"当成了城市名前缀)。
*/
private static function firstCityWord(string $s): string
{
if ($s === '') {
return '';
}
if (preg_match('/([^\s省市县区旗盟的了吧呢啊个这那有字方地]{2,6})市/u', $s, $m)) {
return $m[1] . '市';
}
return '';
}
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(服务端逆地理用;没配返回空串 → 自动走离线兜底) */
/**
* 地址联想(高德输入提示)。
*
* 🔴 2026-11-03 新增。鸿蒙端 Map Kit 报 1002600003(AGC 认证失败),
* 而 AGC 那条链路需要开地图服务 + 重新生成 Profile,可能等 24 小时。
* ⇒ 加这个接口:端上走我们的服务器,服务器用**高德 Web 服务 key** 调 inputtips。
*
* 好处(不只是绕过 AGC):
* · **数据源与网页端完全一致** —— 网页 `addr_suggest.js` 也用高德,
* 两端选中的地址是同一套 POI,不会再出现"网页能搜到、手机搜不到";
* · inputtips 直接给 `district`(省市区)+ `address`(门牌)+ `location`(坐标),
* 地址天生完整,不用像 Map Kit 那样自己拼。
*
* @param string $kw 关键词
* @param int $limit 最多几条
* @return array<int,array{name:string,detail:string,district:string,address:string,lng:string,lat:string}>
* **任何失败都返回空数组**,不抛错(联想挂掉不能拖垮"记一条日程")
*/
public static function suggest(string $kw, int $limit = 8): array
{
$q = trim($kw);
if ($q === '' || mb_strlen($q, 'UTF-8') < 2) {
return [];
}
$key = self::webKey();
if ($key === '') {
return [];
}
$url = 'https://restapi.amap.com/v3/assistant/inputtips'
. '?key=' . urlencode($key)
. '&keywords=' . urlencode($q)
. '&datatype=all'
. '&citylimit=false';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 6,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_SSL_VERIFYPEER => true,
]);
$body = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err !== '' || !is_string($body) || $body === '') {
return [];
}
$j = json_decode($body, true);
if (!is_array($j) || (string)($j['status'] ?? '') !== '1' || empty($j['tips'])) {
return [];
}
$out = [];
foreach ($j['tips'] as $t) {
/* ⚠️ 高德的字段类型**不保证是字符串**:
实测 `district`/`address`/`location` 在部分条目里是**数组**(空数组),
直接当字符串用会触发 "Array to string conversion" 警告。
⇒ 统一过一遍 toStr()。 */
$name = self::toStr($t['name'] ?? '');
$dist = self::toStr($t['district'] ?? '');
$addr = self::toStr($t['address'] ?? '');
$loc = self::toStr($t['location'] ?? '');
/* ⚠️ 有些条目是"纯行政区划"(如 name=招商银行、district=空、address=空),
没有地址也没有坐标 ⇒ 面板上就是一行空白,跳过。 */
if ($name === '' || ($addr === '' && $dist === '' && $loc === '')) {
continue;
}
$lng = '';
$lat = '';
if ($loc !== '' && strpos($loc, ',') !== false) {
$seg = explode(',', $loc);
if (count($seg) === 2) {
// 高德给的是 "经度,纬度"
$lng = (string)$seg[0];
$lat = (string)$seg[1];
}
}
// 详细地址 = 省市区 + 门牌(用户要的"参考信息")
$detail = $dist;
if ($addr !== '') {
$detail = $detail === '' ? $addr : ($detail . $addr);
}
$out[] = [
'name' => $name,
'detail' => $detail,
'district' => $dist,
'address' => $detail,
'lng' => $lng,
'lat' => $lat,
];
if (count($out) >= $limit) {
break;
}
}
return $out;
}
/**
* 把高德返回的值统一成字符串。
*
* 高德的 PHP 侧字段类型不稳定(字符串 / 空数组 / 数组都见过),
* 直接 `(string)` 会触发 "Array to string conversion" 警告并得到 "Array"。
*/
private static function toStr($v): string
{
if (is_string($v)) {
return trim($v);
}
if (is_int($v) || is_float($v)) {
return (string)$v;
}
// 数组/对象/null 一律当空(高德用 [] 表示"没这个字段")
return '';
}
/** 端上地址联想用的 key(Web 服务类型) */
public static function webKey(): string
{
$a = fb_config('amap_web');
return is_array($a) ? trim((string)($a['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'] ?? '')) : '',
];
}
}