從 Redis 快取內容讀取 fetcher 寫入時的時間戳(ISO8601 +08:00),原本 只放在 debug 區塊沒暴露。ScanResponse model 加對應 Optional[str] 欄位。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
219 lines
7.3 KiB
Python
219 lines
7.3 KiB
Python
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,避免同時多個 fetcher;FETCH_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
|
||
generated_at: Optional[str] = None # fetcher 寫入 Redis 的時間(ISO8601 +08:00)
|
||
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-revalidate:Redis 可達且(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),
|
||
"generated_at": data.get("generated_at"),
|
||
"items": items_filtered,
|
||
"debug": debug_info
|
||
}
|