diff --git a/app/ApiHandler.php b/app/ApiHandler.php index 2d586cb..e75c654 100644 --- a/app/ApiHandler.php +++ b/app/ApiHandler.php @@ -112,6 +112,13 @@ final class ApiHandler case 'resources': $this->resources($id, $sub, $method); return; case 'books': $this->books($id, $sub, $method); return; case 'notes': $this->notes($id, $sub, $method); return; + /* 🔴 2026-10-30(工单 #36):高德**地址联想**代理。 + 端上地点输入框要"输入简单地址 → 下拉带详细地址", + 但高德 Web 服务 key 不能进客户端(反编译即泄漏、配额被跑光)。 + ⇒ 走服务端代理,与网页端 `assets/js/addr_suggest.js` 同一个上游。 + ⚠️ 与 `geo` 平级,不能做成 `calendar/tips` —— 它是站点级工具, + 不属于某个业务模块(影院/行程也用得上)。 */ + case 'geo': $this->geo($sub, $method); return; // 🔴 v45:登录(手机号 + 密码 → API 令牌)。 // 为什么必须走 API 而不是网页登录后复制 Cookie: // App 是 Bearer 令牌那套,没有会话;而原先前端只能让人 @@ -1409,6 +1416,49 @@ final class ApiHandler Api::ok($rows); } + /** + * `GET /api/v1/geo/tips?kw=星巴克&limit=10` —— 地址联想(高德输入提示代理)。 + * + * 🔴 2026-10-30(工单 #36):网页端地址联想早就有,"地名后面必须带详细地址" + * 是用户同一天的诉求(#32);鸿蒙端原本**连联想都没有**(纯文本框), + * 这里给端上补上,字段口径与网页端 `result.tips` 对齐: + * 每条都给 `name` + `detail`(省市区 + 门牌),端上把 detail 画在名字下面, + * 同名地点才分得开 —— 这正是用户要的"作为参考,让用户更好地选择"。 + * + * 为什么要**服务端代理**而不是端上直连高德: + * 高德 Web 服务 key 是服务端密钥,打进安装包等于公开(反编译/抓包即可拿到), + * 配额会被跑光、key 还可能被高德封。网页端那套 JS API key + 安全密钥 + * 是给浏览器域名白名单用的,原生端没有等价机制。 + * + * @param string $sub 只认 `tips`(留出扩展余地,将来要 `regeo` 也能挂这里) + * @param string $method 只允许 GET + */ + private function geo(string $sub, string $method): void + { + /* ⚠️ 要登录:这是**要花配额**的接口,不开放匿名调用 + (否则别人拿本站在跑自己的地址联想,配额算我们的)。 */ + Api::me(); + + if ($method !== 'GET') { + Api::fail('地址联想只支持 GET', 'method_not_allowed', 405); + } + if ($sub !== 'tips') { + Api::fail('未知的地理接口:' . $sub, 'not_found', 404); + } + + $kw = Api::str('kw', '', 60); + /* 少于 2 个字直接返回空数组(而不是报错): + 端上照常手敲,不该因为"字太少"弹一个错误框。 + 阈值与网页端 addr_suggest.js 一致,两边体验才对得上。 */ + if (mb_strlen($kw, 'UTF-8') < 2) { + Api::ok([]); + } + $limit = (int)Api::str('limit', '10'); + if ($limit <= 0) { $limit = 10; } + + Api::ok(Geo::tips($kw, $limit)); + } + private function events(int $id, string $sub, string $method): void { [$fam, $pid] = Api::me(); diff --git a/app/Geo.php b/app/Geo.php index 1d76d5e..697f4f7 100644 --- a/app/Geo.php +++ b/app/Geo.php @@ -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 + * 失败/没配 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 里取出"城市"。 *