geo:新增地址联想接口 /api/v1/geo/tips(高德 inputtips 代理)laoyang/FamilyBook#36

鸿蒙端地点输入要「输入简单地址 → 下拉带详细地址」,但高德 Web 服务 key
不能进客户端(反编译/抓包即得,配额会被跑光、key 可能被封)。
网页端用的 JS API key + 安全密钥是域名白名单那套,原生端没有等价机制
⇒ 走服务端代理,与网页端 addr_suggest.js 同一个上游(/v3/assistant/inputtips)。

- Geo::tips():调高德 inputtips,把三套不齐的字段归一成
  name / detail(省市区+门牌) / district / address / lng / lat
- 过滤公交地铁线路(typecode 15xx)—— 对填地点没用
- 只看 body 里的 status 而不是 HTTP 码(配额用完高德回 200 + status=0)
- 少于 2 字不查,返回空数组而不是报错(阈值与网页端一致)
- ApiHandler::geo():GET only、要求登录(这是花配额的接口)

⚠️ 现有 key 是 JS API 类型,调 Web 服务接口返回 USERKEY_PLAT_NOMATCH(10009),
   需另配 Web 服务 key 才能真正工作。同样原因,既有 Geo::regeo() 一直是失效的
   (cityFromPoint 在走离线兜底),此问题另行处理。

解析逻辑已用 4 组模拟高德响应验证(同名可区分/行政区划兜底/线路过滤/
空 name 跳过/失败返回空),19 项全通过,含源码一致性检查。
This commit is contained in:
2026-10-30 08:03:20 +08:00
parent df657e5e5a
commit 29b2ca0e3c
2 changed files with 192 additions and 0 deletions
+142
View File
@@ -344,6 +344,8 @@ final class Geo
* 旅程要"坐标/地址 → 城市"(必须联网问高德,因为地址千奇百怪)。
*/
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`) */
@@ -424,6 +426,146 @@ final class Geo
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<int, array{name:string, detail:string, district:string, address:string, lng:string, lat:string}>
* 失败/没配 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 里取出"城市"。
*