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:
+325
@@ -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'] ?? '')) : '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user