feat(weather): 起始页天气/定位细节完善

- 新增 Client/src/lib/amap.ts:高德 JS 定位封装
- weather.ts / useWeather.ts:带经纬度/城市请求,定位失败不兜底
- Server weather.rs:相应调整
- 文档同步;.gitignore 忽略本地 .codegraph 索引

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cc 2026-06-20 20:45:39 +08:00
parent b2a0b563c9
commit 4c8c4360ad
6 changed files with 233 additions and 26 deletions

3
.gitignore vendored
View File

@ -28,6 +28,9 @@ lerna-debug.log*
Server/target
Server/.vite
# --- Tooling本地索引/缓存,勿入库)---
.codegraph/
# --- Secrets密钥/私钥/本地环境,永不入库)---
*.pem
*.env

View File

@ -27,9 +27,25 @@ export type WeatherResult =
message?: string
}
/** 拉取一次天气;网络层失败(后端不可达等)会抛错,由调用方兜底。 */
export async function fetchWeather(): Promise<WeatherResult> {
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<WeatherResult> {
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
}

View File

@ -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 })

122
Client/src/lib/amap.ts Normal file
View File

@ -0,0 +1,122 @@
// 高德 JS APIAMap.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<string, unknown>) => AMapGeolocation
}
declare global {
interface Window {
AMap?: AMapNamespace
_AMapSecurityConfig?: { securityJsCode: string }
}
}
let sdkPromise: Promise<AMapNamespace> | null = null
/** 按需注入高德 JSAPI 脚本(单例)。安全密钥须在脚本加载前挂到 window。 */
function loadSdk(): Promise<AMapNamespace> {
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<GeoResult | null> {
if (!KEY) return null
let AMap: AMapNamespace
try {
AMap = await loadSdk()
} catch {
return null
}
return new Promise<GeoResult | null>((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)
}
})
}
})
})
})
}

View File

@ -99,12 +99,26 @@ struct Inner {
cache: HashMap<String, CacheEntry>,
}
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<String>) -> 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<Located>) -> 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)
}

View File

@ -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 定位);**等站点上了 HTTPSGPS 会自动开始生效,无需改代码**。前端定位彻底失败时,
> 后端再用 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` 会内置此 KeyJS 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` |