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:
parent
b2a0b563c9
commit
4c8c4360ad
3
.gitignore
vendored
3
.gitignore
vendored
@ -28,6 +28,9 @@ lerna-debug.log*
|
||||
Server/target
|
||||
Server/.vite
|
||||
|
||||
# --- Tooling(本地索引/缓存,勿入库)---
|
||||
.codegraph/
|
||||
|
||||
# --- Secrets(密钥/私钥/本地环境,永不入库)---
|
||||
*.pem
|
||||
*.env
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
122
Client/src/lib/amap.ts
Normal file
@ -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<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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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` |
|
||||
|
||||
Loading…
Reference in New Issue
Block a user