v24 第 17 批:城市聚合 / 高德地址 / 表单精简(用户 10 条)

用户 10 条原话要点与落地:
- ①「统计出来的不是目的地,也不是车站,而是城市…城市要聚合,有多段行程,
  都有青岛作为出发地或者目的地,那么就只记一次」
  → trip_nodes 补 from_city/to_city(Schema v22),Trip::cities() 改成
    「出发地 ∪ 到达地」去重、按最早时间 + 出发地优先排。
    详情页信息区显示的就是这个聚合结果(第 4 条:「图片右侧显示的是聚合的城市,
    而不是终点地址」)。
- ②「类型不够丰富,你看看 triplog 里面都有哪些,都拿过来」
  → NodeType::TRIPLOG_TYPES(休息/突发/购物/拜访亲友),在 all() 里读时自愈补齐。
- ③「出行方式,只有选择自驾,才让选车辆;车辆下拉框不需要出现什么财务里登记的车,
  只把车辆列出来就行了」→ DRIVE_MODES 收窄成 ['drive'];
  Vehicle::pickable() 只回车辆库;车辆页删掉「财务里已登记的车」那块。
- ③「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」
  → seat_info / gate 从表单与列表**彻底移除**(列保留,老数据不丢;
    saveNode 的 UPDATE 也不再写这两列,否则会抹掉迁来的数据)。
- ③「出发地和到达地,都用高德地图的接口,记录详细地址」+「参与人弹框选择」
  +「给参与人标注角色这个功能去掉」
  → 新增 templates/trips/_loc_field.php(高德联想 + 详细地址 + 坐标 + 城市 +
    导航链接,没配 key 也能手输);参与人改用全局弹框选人组件;
    角色输入框删除,saveNodeMembers 改成差集增删(否则老 role_tag 被静默抹掉)。
- ④「triplog 中的行程花销数据,没有同步过来,需要迁移过来」
  → 新增 _tooling/_fb_b17_backfill.php:迁地址(20 条)、花销(4 笔 ¥8584)、
    车辆(3 台)。线上已执行并核对(笔数与金额一分不差)。
- ④「行程中,时间明明具体到了时分秒,怎么不显示?」
  → Trip::nodeSpan():行程时间精确到时分(同一天只写一次日期、跨天写月日、
    两头都没时分就不写 00:00)。

其他:
- App\Geo 扩展 cityFromPoint / cityFromText / navUrl / jsConfig。
  中文地址提城市修掉四种错法(白名单→黑名单、取最后→取第一个、直辖市最先认、
  离线表只能兜底),并加了「五大连池 ≠ 大连」的位置守卫。
- config.example.php 加 amap 段(key / security_js / nav_src)。

验证:
- e2e 920/0(+29 条本批断言)· 升级 149/0(+15 条 v22 断言)·
  城市解析 32/32(新套件,样例全是 TripLog 线上真实地址)·
  桌面几何 428/0 · 移动端 688/0 · 交互 92/0 · 窄屏溢出 24/0。
- 反向验证 5/5:城市退回读 place / 只算到达地 / 时间退回只到日期 /
  打车也要车 / 类型不补 —— 五种坏法全部被断言抓住。
- 部署:服务器自检 19/19;线上 schema v22;补迁后花销 4 笔 ¥8584 与源库对齐。
This commit is contained in:
2026-10-05 16:45:10 +08:00
parent 2e51d3f5e4
commit c30ca99d22
13 changed files with 1158 additions and 235 deletions
+325
View File
@@ -331,4 +331,329 @@ final class Geo
'海南省', '四川省', '贵州省', '云南省', '陕西省', '甘肃省', '青海省',
'台湾省',
];
// ================================================================
// 以下为 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'] ?? '')) : '',
];
}
}