Files
pokemon-radar-api/pokemon_radar_api.py
Timmy 2f7285437b feat: /scan 採 stale-while-revalidate,閒置時零呼叫
- /scan 永遠秒回當前 Redis 快取,若 TTL < STALE_THRESHOLD 或 key 不存在,用 daemon thread 呼叫 radar_fetcher.main() 非同步更新
- 以 Redis SET NX EX 當互斥 lock(pokemon:radar:fetching,TTL 180s)避免並發 fetcher
- 新增設定:STALE_THRESHOLD=120、FETCH_LOCK_KEY、FETCH_LOCK_TTL=180
- 原本需要 cron 定期跑 fetcher;現在 API 端會自動觸發,沒人打就不動

驗證:
- cold start /scan: 26ms 回空、同時搶 lock
- 背景 fetcher 跑完(~16s Function Server)後寫入 Redis 並釋放 lock
- 下一次 /scan 13ms 回 96 筆、TTL 299s
- 快取新鮮時重複打 /scan 不會重複觸發(refresh counter=1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 09:30:53 +08:00

217 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import os
import sys
import threading
import time
from typing import Any, Dict, List, Optional
import redis
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from pydantic import BaseModel, Field
# 載入環境變數(要在 import radar_fetcher 之前,讓它 module-level 讀到)
load_dotenv()
import radar_fetcher
from pokemon_location_fetcher import PokemonRadarClient
# ========= 設定 =========
REDIS_HOST = os.getenv("REDIS_HOST", "192.168.42.211")
REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
REDIS_DB = int(os.getenv("REDIS_DB", 0))
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", None)
REDIS_KEY = "pokemon:radar:latest"
# Stale-while-revalidate快取剩餘 TTL 低於此值時,在背景觸發 fetcher 更新
STALE_THRESHOLD = int(os.getenv("STALE_THRESHOLD", 120))
# 互斥 lock避免同時多個 fetcherFETCH_LOCK_TTL 要 >= fetcher 最長執行時間
FETCH_LOCK_KEY = os.getenv("FETCH_LOCK_KEY", "pokemon:radar:fetching")
FETCH_LOCK_TTL = int(os.getenv("FETCH_LOCK_TTL", 180))
# ========= FastAPI 初始化 =========
app = FastAPI(title="Pokemon Radar API (Redis Backed)", version="2.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ========= Redis 連線池 =========
try:
redis_pool = redis.ConnectionPool(
host=REDIS_HOST,
port=REDIS_PORT,
db=REDIS_DB,
password=REDIS_PASSWORD,
decode_responses=True,
socket_connect_timeout=5
)
except Exception as e:
print(f"❌ Redis 設定錯誤: {e}")
sys.exit(1)
# ========= Models (保持與原本相容) =========
class ScanRequest(BaseModel):
# 雖然改用 Redis但保留這些欄位讓前端/n8n 不用改
# 實際上 lat, lng, zoom, shot, state_path, ua... 在此模式下會被忽略
lat: float = Field(..., description="(已忽略) 緯度")
lng: float = Field(..., description="(已忽略) 經度")
zoom: int = Field(16, description="(已忽略) 地圖縮放")
shot: bool = Field(False, description="(已忽略) 是否擷取畫面")
state_path: Optional[str] = Field(None, description="(已忽略)")
ua_from_state: bool = Field(True, description="(已忽略)")
# === 這些篩選參數依然有效 ===
min_remaining: Optional[int] = Field(None, ge=0, description="只保留 剩餘秒數 ≥ 此值")
min_iv: Optional[int] = Field(None, ge=0, le=100, description="只保留 IV% ≥ 此值")
perfect_only: bool = Field(False, description="只保留 100% IV")
species: Optional[str] = Field("", description="dex id 或關鍵字,例如 '133,伊布'")
save_files: bool = Field(False, description="(已忽略) API 不再負責存檔")
class Item(BaseModel):
id: Optional[int]
name: Optional[str]
gender: Optional[str]
remaining: Optional[int]
expire_time: Optional[int] = None
expire_at: Optional[str] = None
latitude: Optional[float]
longitude: Optional[float]
iv_pct: Optional[int]
iv_atk: Optional[int]
iv_def: Optional[int]
iv_sta: Optional[int]
cp: Optional[int]
level: Optional[int]
is_perfect: bool
fast_move: Optional[str] = None
charge_move: Optional[str] = None
class ScanResponse(BaseModel):
source: str = "redis"
ttl: int
count_raw: int
count_filtered: int
items: List[Item]
debug: Dict[str, Any]
# ========= 工具Redis Helper =========
def get_data_from_redis():
try:
r = redis.Redis(connection_pool=redis_pool)
if not r.exists(REDIS_KEY):
return None, 0, None
ttl = r.ttl(REDIS_KEY)
raw_json = r.get(REDIS_KEY)
except redis.exceptions.RedisError as e:
return None, 0, f"redis unreachable: {e}"
try:
return json.loads(raw_json), ttl, None
except json.JSONDecodeError:
return None, 0, "redis payload not json"
# ========= 依賴Client (只用來做 postprocess) =========
# 我們不需要傳入真實的 url/token因為不會用到 fetch
_filter_helper = PokemonRadarClient(url="", token="")
# ========= Stale-while-revalidate 背景刷新 =========
def _trigger_background_refresh() -> None:
"""若沒有其他 fetcher 在跑,開一個 daemon thread 更新 Redis 快取。非阻塞。"""
try:
r = redis.Redis(connection_pool=redis_pool)
acquired = r.set(FETCH_LOCK_KEY, str(time.time()), nx=True, ex=FETCH_LOCK_TTL)
except redis.exceptions.RedisError as e:
logger.warning(f"refresh lock check failed: {e}")
return
if not acquired:
return # 有其他 fetcher 正在跑
def _run() -> None:
try:
logger.info("background refresh: start")
radar_fetcher.main()
logger.info("background refresh: done")
except Exception as e:
logger.exception(f"background refresh failed: {e}")
finally:
try:
redis.Redis(connection_pool=redis_pool).delete(FETCH_LOCK_KEY)
except redis.exceptions.RedisError:
pass
threading.Thread(target=_run, daemon=True, name="radar-refresh").start()
# ========= 路由 =========
@app.get("/ping")
def ping():
return {"status": "ok", "backend": "redis"}
@app.post("/scan", response_model=ScanResponse)
def scan(req: ScanRequest) -> Any:
# 1. 從 Redis 讀取資料
data, ttl, err = get_data_from_redis()
# Stale-while-revalidateRedis 可達且key 不存在 or TTL 快過期)時在背景觸發更新
if err is None and (data is None or (0 < ttl < STALE_THRESHOLD)):
_trigger_background_refresh()
if not data:
# Redis 沒資料或連不上,都回傳空結果而不是 500避免前端炸掉
return {
"source": "redis (unreachable)" if err else "redis (empty)",
"ttl": 0,
"count_raw": 0,
"count_filtered": 0,
"items": [],
"debug": {"note": err or "Redis key not found or expired"}
}
# 2. 取得原始列表
# 根據你的 twpk_radar.mjs 結構items 可能在 'results' 或 'items' 欄位
items_raw = data.get("results", data.get("items", []))
# 3. 使用 Helper 進行篩選 (Process & Filter)
# 這一步很重要,因為 Redis 存的是 raw data我們要根據 API 請求即時篩選
try:
items_filtered = _filter_helper.postprocess(
items_raw,
min_remaining=req.min_remaining,
min_iv=req.min_iv,
perfect_only=req.perfect_only,
species=req.species or "",
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Filtering error: {e}")
# 4. 準備 Debug 資訊
debug_info = data.get("debug", {})
# 可以在這裡加入來源資訊
debug_info["api_source"] = "Cached from Redis"
debug_info["redis_ttl"] = ttl
# 5. 回傳結果
return {
"source": "redis",
"ttl": ttl,
"count_raw": len(items_raw),
"count_filtered": len(items_filtered),
"items": items_filtered,
"debug": debug_info
}