From 4c8c4360ad5f11cf416ce61404c744bf06045304 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 20 Jun 2026 20:45:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(weather):=20=E8=B5=B7=E5=A7=8B=E9=A1=B5?= =?UTF-8?q?=E5=A4=A9=E6=B0=94/=E5=AE=9A=E4=BD=8D=E7=BB=86=E8=8A=82?= =?UTF-8?q?=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Client/src/lib/amap.ts:高德 JS 定位封装 - weather.ts / useWeather.ts:带经纬度/城市请求,定位失败不兜底 - Server weather.rs:相应调整 - 文档同步;.gitignore 忽略本地 .codegraph 索引 Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + Client/src/api/weather.ts | 22 +++++- Client/src/hooks/useWeather.ts | 5 +- Client/src/lib/amap.ts | 122 +++++++++++++++++++++++++++++ Server/src/weather.rs | 59 ++++++++++---- docs/startpage-weather-location.md | 48 ++++++++++-- 6 files changed, 233 insertions(+), 26 deletions(-) create mode 100644 Client/src/lib/amap.ts diff --git a/.gitignore b/.gitignore index ee0048d..e539dd1 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ lerna-debug.log* Server/target Server/.vite +# --- Tooling(本地索引/缓存,勿入库)--- +.codegraph/ + # --- Secrets(密钥/私钥/本地环境,永不入库)--- *.pem *.env diff --git a/Client/src/api/weather.ts b/Client/src/api/weather.ts index ed5c1f8..dc581f4 100644 --- a/Client/src/api/weather.ts +++ b/Client/src/api/weather.ts @@ -27,9 +27,25 @@ export type WeatherResult = message?: string } -/** 拉取一次天气;网络层失败(后端不可达等)会抛错,由调用方兜底。 */ -export async function fetchWeather(): Promise { - const res = await fetch(`${API_BASE}/api/web/weather`) +/** 前端定位结果(来自 AMap.Geolocation);缺省则后端走 IP 定位兜底。 */ +export interface WeatherLocation { + lon: number + lat: number + city?: string +} + +/** + * 拉取一次天气;网络层失败(后端不可达等)会抛错,由调用方兜底。 + * 传入 `loc` 时把经纬度/城市带给后端,后端据此跳过高德 IP 定位、直接取天气。 + */ +export async function fetchWeather(loc?: WeatherLocation): Promise { + let url = `${API_BASE}/api/web/weather` + if (loc) { + const p = new URLSearchParams({ lon: String(loc.lon), lat: String(loc.lat) }) + if (loc.city) p.set('city', loc.city) + url += `?${p.toString()}` + } + const res = await fetch(url) if (!res.ok) throw new Error(`HTTP ${res.status}`) return (await res.json()) as WeatherResult } diff --git a/Client/src/hooks/useWeather.ts b/Client/src/hooks/useWeather.ts index 71c7934..4367495 100644 --- a/Client/src/hooks/useWeather.ts +++ b/Client/src/hooks/useWeather.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { fetchWeather, type WeatherResult } from '../api/weather' +import { locate } from '../lib/amap' export interface WeatherState { loading: boolean @@ -21,7 +22,9 @@ export function useWeather(intervalMs = 30 * 60 * 1000): WeatherState { const load = async () => { try { - const data = await fetchWeather() + // 先前端定位(未配 JS Key 或失败 → null,后端用 IP 兜底)。locate 不抛错。 + const geo = await locate() + const data = await fetchWeather(geo ?? undefined) if (!cancelled) setState({ loading: false, data, error: false }) } catch { if (!cancelled) setState({ loading: false, data: null, error: true }) diff --git a/Client/src/lib/amap.ts b/Client/src/lib/amap.ts new file mode 100644 index 0000000..9678a26 --- /dev/null +++ b/Client/src/lib/amap.ts @@ -0,0 +1,122 @@ +// 高德 JS API(AMap.Geolocation)前端定位封装。 +// +// 设计: +// - Key 与安全密钥都从环境变量读,**不写死**:`VITE_AMAP_JS_KEY` / `VITE_AMAP_JS_CODE`。 +// 两者是高德「Web端(JS API)」专属密钥,和后端的 Web 服务 REST `AMAP_KEY` 是两套。 +// - 未配置 Key → `locate()` 直接返回 null,由后端 IP 定位兜底(即旧行为,绝不退化)。 +// - 定位两段式:先浏览器原生定位(GPS/WiFi,精度高,但**需 HTTPS**,非 localhost 的 +// HTTP 页面下浏览器会禁用);失败/被拒 → 退回高德纯 IP 城市定位(getCityInfo)。 +// - 任何失败都吞掉返回 null(不打扰页面),后端继续兜底。 + +const KEY = import.meta.env.VITE_AMAP_JS_KEY as string | undefined +const CODE = import.meta.env.VITE_AMAP_JS_CODE as string | undefined + +export interface GeoResult { + /** 经度 */ + lon: number + /** 纬度 */ + lat: number + /** 城市名(直辖市/定位精度不足时可能为空) */ + city?: string +} + +// 高德 SDK 没有官方类型,这里只声明用到的最小子集。 +interface AMapGeolocation { + getCurrentPosition(cb: (status: string, result: GeoPositionResult) => void): void + getCityInfo(cb: (status: string, result: GeoCityResult) => void): void +} +interface GeoPositionResult { + position?: { lng: number; lat: number } + addressComponent?: { city?: string; province?: string } +} +interface GeoCityResult { + /** [lng, lat] 城市中心 */ + center?: [number, number] + city?: string + province?: string +} +interface AMapNamespace { + plugin(name: string, onload: () => void): void + Geolocation: new (opts: Record) => AMapGeolocation +} + +declare global { + interface Window { + AMap?: AMapNamespace + _AMapSecurityConfig?: { securityJsCode: string } + } +} + +let sdkPromise: Promise | null = null + +/** 按需注入高德 JSAPI 脚本(单例)。安全密钥须在脚本加载前挂到 window。 */ +function loadSdk(): Promise { + if (sdkPromise) return sdkPromise + sdkPromise = new Promise((resolve, reject) => { + if (!KEY) return reject(new Error('AMap JS key not configured')) + if (window.AMap) return resolve(window.AMap) + if (CODE) window._AMapSecurityConfig = { securityJsCode: CODE } + + const s = document.createElement('script') + s.src = `https://webapi.amap.com/maps?v=2.0&key=${encodeURIComponent(KEY)}&plugin=AMap.Geolocation` + s.async = true + s.onload = () => + window.AMap ? resolve(window.AMap) : reject(new Error('AMap SDK init failed')) + s.onerror = () => reject(new Error('AMap SDK script load error')) + document.head.appendChild(s) + }) + return sdkPromise +} + +/** + * 取当前位置。未配置 Key 或定位失败时返回 null(交给后端兜底)。 + * 不会抛错。 + */ +export async function locate(): Promise { + if (!KEY) return null + + let AMap: AMapNamespace + try { + AMap = await loadSdk() + } catch { + return null + } + + return new Promise((resolve) => { + let settled = false + const finish = (r: GeoResult | null) => { + if (!settled) { + settled = true + resolve(r) + } + } + // 整体兜底超时,避免插件卡死不回调。 + const timer = setTimeout(() => finish(null), 12_000) + const done = (r: GeoResult | null) => { + clearTimeout(timer) + finish(r) + } + + AMap.plugin('AMap.Geolocation', () => { + const geo = new AMap.Geolocation({ enableHighAccuracy: true, timeout: 10_000 }) + geo.getCurrentPosition((status, result) => { + if (status === 'complete' && result.position) { + done({ + lon: result.position.lng, + lat: result.position.lat, + city: result.addressComponent?.city || result.addressComponent?.province, + }) + } else { + // 原生/精确定位失败(多因 HTTP 下被禁或用户拒绝)→ 退回纯 IP 城市定位。 + geo.getCityInfo((s2, r2) => { + if (s2 === 'complete' && r2.center) { + done({ lon: r2.center[0], lat: r2.center[1], city: r2.city || r2.province }) + } else { + done(null) + } + }) + } + }) + }) + }) +} diff --git a/Server/src/weather.rs b/Server/src/weather.rs index 9e96e5f..b670b5e 100644 --- a/Server/src/weather.rs +++ b/Server/src/weather.rs @@ -99,12 +99,26 @@ struct Inner { cache: HashMap, } -struct Located { +/// 定位结果:城市名 + 和风用的 "lon,lat" 坐标。 +/// 既可来自高德 IP 定位,也可来自前端 AMap.Geolocation 插件。 +pub struct Located { city: String, /// "lon,lat" coord: String, } +impl Located { + /// 前端定位(AMap.Geolocation)传来的经纬度 → Located。 + /// 坐标按和风要求格式化成 "lon,lat"(两位小数足够,且能把附近用户聚到同一缓存键)。 + pub fn from_front(lon: f64, lat: f64, city: Option) -> Self { + let city = city + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()) + .unwrap_or_else(|| "未知".to_string()); + Self { city, coord: format!("{lon:.2},{lat:.2}") } + } +} + /// 和风 JWT 认证所需材料(控制台创建凭据时拿到)。 struct QWeatherAuth { /// Ed25519 私钥(PKCS#8 PEM)解析出的签名密钥 @@ -222,20 +236,34 @@ impl WeatherService { } /// 取天气:先查缓存,再查额度,最后才调上游。注意全程不跨 await 持锁。 - pub async fn get(&self, ip: &str) -> WeatherOutcome { - if self.amap_key.is_none() || self.qweather.is_none() { + /// + /// `front` 为前端 AMap.Geolocation 传来的定位(坐标 + 城市): + /// - 有:直接用,**不调高德**(也不消耗高德额度),只调和风。 + /// - 无:退回高德 IP 定位兜底(需 `AMAP_KEY`)。 + pub async fn get(&self, ip: &str, front: Option) -> WeatherOutcome { + if self.qweather.is_none() { + return WeatherOutcome::NotConfigured; + } + // 没有前端坐标时才依赖高德 IP 定位,故此时才要求 AMAP_KEY。 + if front.is_none() && self.amap_key.is_none() { return WeatherOutcome::NotConfigured; } - // 1) 命中新鲜缓存直接返回;否则预检两家额度(任一用尽即报)。 + // 缓存键:前端定位按坐标聚合(不同位置各一份),IP 兜底按访问者 IP。 + let cache_key = match &front { + Some(f) => format!("c:{}", f.coord), + None => format!("ip:{ip}"), + }; + + // 1) 命中新鲜缓存直接返回;否则预检所需额度(走 IP 兜底才需要高德)。 { let mut inner = self.inner.lock().unwrap(); - if let Some(e) = inner.cache.get(ip) { + if let Some(e) = inner.cache.get(&cache_key) { if e.at.elapsed() < self.cache_ttl { return WeatherOutcome::Cached(e.data.clone()); } } - if !inner.amap.has_budget() { + if front.is_none() && !inner.amap.has_budget() { return WeatherOutcome::QuotaExceeded { provider: "amap" }; } if !inner.qweather.has_budget() { @@ -243,13 +271,16 @@ impl WeatherService { } } - // 2) 高德定位(成功才计数) - let loc = match self.locate(ip).await { - Ok(l) => { - self.inner.lock().unwrap().amap.consume(); - l - } - Err(e) => return WeatherOutcome::Failed(format!("amap: {e}")), + // 2) 定位:前端给了就用;否则高德 IP 定位兜底(成功才计数)。 + let loc = match front { + Some(f) => f, + None => match self.locate(ip).await { + Ok(l) => { + self.inner.lock().unwrap().amap.consume(); + l + } + Err(e) => return WeatherOutcome::Failed(format!("amap: {e}")), + }, }; // 3) 和风实时天气(成功才计数) @@ -273,7 +304,7 @@ impl WeatherService { .lock() .unwrap() .cache - .insert(ip.to_string(), CacheEntry { at: Instant::now(), data: data.clone() }); + .insert(cache_key, CacheEntry { at: Instant::now(), data: data.clone() }); WeatherOutcome::Fresh(data) } diff --git a/docs/startpage-weather-location.md b/docs/startpage-weather-location.md index 44cc21c..8232aee 100644 --- a/docs/startpage-weather-location.md +++ b/docs/startpage-weather-location.md @@ -1,19 +1,34 @@ # 起始页:天气 + 定位(已实现) 导航起始页问候语下方展示「天气图标 · 城市 · 天气 · 气温」。 -**城市来自高德 IP 定位,天气来自和风实时天气。** 密钥只在后端,前端不可见。 +**定位优先用前端高德 `AMap.Geolocation`(能 GPS 就 GPS),失败退回后端高德 IP 定位; +天气始终来自和风实时天气(私钥只在后端)。** ## 架构 ``` -浏览器 → GET /api/web/weather (后端) - ├─ 高德 /v3/ip?ip=<访问者IP> → 城市名 + 城市中心坐标 +浏览器 + ├─ AMap.Geolocation 前端定位(JS API) + │ ├─ 浏览器原生定位(GPS/WiFi,精度高,**需 HTTPS**)→ 坐标 + 城市 + │ └─ 失败/被拒 → 高德纯 IP 城市定位(getCityInfo) + │ ↓ 带 lon,lat,city + └─ GET /api/web/weather[?lon=&lat=&city=] (后端) + ├─ 带了坐标 → 直接用,**不调高德**(省高德额度) + ├─ 没带坐标 → 高德 /v3/ip?ip=<访问者IP> 兜底 → 城市名 + 城市中心坐标 └─ 和风 /v7/weather/now?location= → 天气文字 / 气温 / 图标码 ``` -- 后端:`Server/src/weather.rs`(服务 + 限流 + 缓存)、路由 `Server/src/routes/web.rs` 的 `/api/web/weather`。 +> **为什么前端定位 + 后端兜底两套并存:** 前端原生定位(GPS)能到街道级、还能绕开 +> 「数据中心 / CGNAT / 境外 IP 高德返回空」的痛点,但**浏览器原生定位在非 localhost 的 +> HTTP 页面下被禁用**。当前线上是裸 HTTP,所以现阶段前端只会走它的 IP 退路(精度等同后端 +> IP 定位);**等站点上了 HTTPS,GPS 会自动开始生效,无需改代码**。前端定位彻底失败时, +> 后端再用 REST Key 做一次 IP 定位作双保险。 + +- 后端:`Server/src/weather.rs`(服务 + 限流 + 缓存,`get()` 接收可选前端坐标)、 + 路由 `Server/src/routes/web.rs` 的 `/api/web/weather`(解析可选 `lon/lat/city`)。 - 前端: - - `Client/src/api/weather.ts`、`Client/src/hooks/useWeather.ts`(拉取,30 分钟刷新) + - `Client/src/lib/amap.ts`:加载高德 JSAPI + `AMap.Geolocation`,封装 `locate()` + - `Client/src/api/weather.ts`、`Client/src/hooks/useWeather.ts`(先定位再拉取,30 分钟刷新) - `Client/src/components/StartPage.tsx`:统一持有天气状态,派生**昼夜背景**与**天气动效**,并把天气传给 `Greeting` - `Client/src/components/start/Greeting.tsx`:展示「天气图标 · 城市 · 天气 · 气温」 - `Client/src/components/start/WeatherIcon.tsx`:天气文字旁的静态图标 @@ -24,16 +39,33 @@ - **高德 300 次/天、和风 900 次/天**,各自独立计数,**北京时间 0 点归零**。 - 任一服务商额度用尽 → 接口返回 `{ ok:false, reason:"quota_exceeded", provider }`, 前端显示「天气请求次数已用完,明日 0 点恢复」。 -- **按访问者 IP 缓存 10 分钟**:前端每 30 分钟刷新一次,正常用量远低于额度。 +- **缓存 10 分钟**:前端带坐标时按 `坐标` 聚合缓存,未带时按访问者 `IP` 缓存; + 前端每 30 分钟刷新一次,正常用量远低于额度。 - 计数只在“真的调用了上游”时增加(命中缓存不计数)。 + **前端已定位(带了坐标)时不调高德、也不消耗高德额度**,只消耗和风额度。 ## 环境变量 -后端启动时读取(缺高德 Key 或和风 JWT 任一项时接口返回 `not_configured`,前端静默不展示): +### 前端(Vite,`Client/.env.local`,**不进仓库**) + +高德 **Web端(JS API)** 密钥,给 `AMap.Geolocation` 前端定位用。与后端 `AMAP_KEY` 是**两套**。 +不配则前端不定位、直接由后端 IP 兜底(旧行为,不会报错)。Vite 在 dev 与 build 时都会加载 +`.env.local`,故本地构建出的 `dist` 会内置此 Key(JS Key 本就暴露在前端,靠高德控制台 +**「安全域名」白名单**约束,不是机密)。 | 变量 | 必填 | 说明 | |------|------|------| -| `AMAP_KEY` | 是 | 高德 **Web服务(REST)** Key | +| `VITE_AMAP_JS_KEY` | 否 | 高德 **Web端(JS API)** Key | +| `VITE_AMAP_JS_CODE` | 否 | 高德 **安全密钥(securityJsCode)**;JSAPI 2.0 起与 Key 成对必需,缺它定位报 `INVALID_USER_SCODE` | + +### 后端 + +后端启动时读取(缺和风 JWT 时接口返回 `not_configured`,前端静默不展示; +`AMAP_KEY` 仅 IP 兜底路径需要,前端总能定位时可不配): + +| 变量 | 必填 | 说明 | +|------|------|------| +| `AMAP_KEY` | 兜底必填 | 高德 **Web服务(REST)** Key,仅前端定位失败的 IP 兜底路径用 | | `QWEATHER_HOST` | 是 | 和风**专属 API Host**(控制台给的,如 `https://xxxx.qweatherapi.com`) | | `QWEATHER_PROJECT_ID` | 是 | 和风**项目 ID** → JWT 的 `sub` | | `QWEATHER_KEY_ID` | 是 | 和风**凭据 ID** → JWT 头的 `kid` |