# 基礎設施地圖與 2026-04-20 演進紀錄 這份文件的受眾是**一年後忘光的你自己**。涵蓋: 1. 目前整套 pokemon 家族的**架構現況**(哪台機跑什麼、流量怎麼走) 2. 今天(2026-04-20)做了什麼、**為什麼**這麼做 3. **日常操作** cookbook 4. 已知限制與 follow-up 單一專案的使用手冊請看各 repo 自己的 `README.md`。本檔是**跨 repo / 跨機器的全景圖**。 --- ## 1. 架構現況 ``` 使用者瀏覽器 │ │ https://web.lotimmy.com/pokemon/ ▼ NPM (192.168.42.124:443, LE 憑證) │ 反代 ▼ caddy (192.168.42.104:80) serve /opt/caddy/www/pokemon/ │ index.html + img/ 等靜態檔 ▼ 瀏覽器 JS fetch https://poke-api.lotimmy.com/scan │ │ NPM (192.168.42.124:443, LE 憑證) 反代 ▼ pokemon_radar_api (192.168.42.108:8008, supervisor 管) │ stale-while-revalidate: │ - /scan 永遠秒回 Redis 當下資料 │ - 若 TTL < STALE_THRESHOLD 或 key 不存在 → 背景 thread 呼叫 radar_fetcher.main() │ 互斥 lock key: pokemon:radar:fetching (180s TTL) ▼ Redis (192.168.42.108:127.0.0.1:6379, Docker redis:7-alpine) │ key: pokemon:radar:latest (TTL 300s) ▲ │ 寫入 radar_fetcher (192.168.42.108:8008 API 背景 thread 觸發) │ │ POST code → twpk_radar.mjs ▼ Function Server (192.168.42.124:13000) │ Puppeteer + stealth + state.json cookies ▼ twpkinfo.com/ipoke.aspx ``` ### 另一條:自動部署 pipeline ``` 本機 git push → Gitea (192.168.42.124:31337) │ POST /hooks/ + HMAC-SHA256 (X-Gitea-Signature) ▼ git-webhook (192.168.42.104:9000, Docker) │ 動態讀 hooks//{secret,deploy.sh} │ 簽章驗證通過 → subprocess deploy.sh ▼ git clone → rsync 到該 repo 配置的 target dir │ ▼ caddy (或其他 serve) 自動提供新版(無需 reload) ``` --- ## 2. 機器與端點速查 | 機器 | 功能 | 關鍵 port / 路徑 | 服務管理 | |---|---|---|---| | `192.168.42.108` (CT108) | `pokemon_radar_api` + 本機 Redis + 其他 8 個 uvicorn service | `:8008` API、`:127.0.0.1:6379` Redis | supervisor (`/etc/supervisor/conf.d/pokemon-radar-api.conf`) | | `192.168.42.104` (CT104) | caddy 靜態檔 + `git-webhook` 自動部署 | `:80`/`:443` caddy、`:9000` webhook | caddy 手動啟動、webhook 用 Docker Compose | | `192.168.42.124` | Gitea + NPM + Function Server(browser automation)+ Pi-hole 等 | `:31337` Gitea、`:81` NPM UI、`:13000` Function Server | Docker Compose(多個) | | `192.168.42.211` | **已死** — 舊的生產 Redis,網路不通 | — | — | ### 外部 domain | Domain | A record | 服務 | |---|---|---| | `web.lotimmy.com` | NPM public IP | 前端地圖(caddy 上) | | `poke-api.lotimmy.com` | `125.229.110.50`(NPM public) | 後端 API | | `.lotimmy.com` / `.timmy.us.kg` / `.timmy.edu.kg` | 同上 | NPM 管理的其他 30+ 服務 | ### Git Repos | Repo | 遠端 | 角色 | |---|---|---| | `pokemon-radar-api` | `http://192.168.42.124:31337/timmy/pokemon-radar-api.git` (master) | 後端 API + fetcher | | `pokemon` | `http://192.168.42.124:31337/timmy/pokemon.git` (main) | 前端 Leaflet 地圖 | 兩 repo 都是**公開 Gitea repo**(內網),sensitive 的 `.env` / `state.json` 在 `.gitignore` 內不上傳。 --- ## 3. 今天做了什麼(2026-04-20) 按時序與因果。中間發現不少 legacy 問題,列出來避免未來重蹈。 ### 3.1 pokemon-radar-api 本機環境整理 - 發現 repo 根目錄有 5 個 `*_BACKUP_` 手動備份檔,已清除 - 修 `/scan`:舊版 `get_data_from_redis()` 只 catch `json.JSONDecodeError`,Redis 連不上時會直接 500。改成回 `(data, ttl, err)` tuple、`scan()` 依 `err` 區分 `redis`/`redis (empty)`/`redis (unreachable)`,**所有情況都回 200** - `radar_fetcher._postprocess` 與舊 CLI 的 `PokemonRadarClient.postprocess` 篩選邏輯重複實作,目前維持(改動 scope 太大) ### 3.2 建立 Gitea repo + 推送 - 原本 repo 是 git init 但從未 push 過 - 新建 Gitea `timmy/pokemon-radar-api`(public) - 關鍵:**第一次 push 前先補 `.gitignore` 擋 `.env` / `state.json` / runtime 輸出**,否則會連 `FUNCTION_TOKEN` 和 cookies 一起推上去 - Push 用 `git -c http.extraHeader="Authorization: token ..."` 方式,token 不存 `.git/config` ### 3.3 移除 hard-coded token - `run_pokemon_radar_api.sh` 和 `pokemon_location_fetcher.py` 裡都寫死 `FUNCTION_TOKEN=6R0W53R135510` - 改成從 env 讀(`os.getenv("FUNCTION_TOKEN", "")`),並在 `pokemon_location_fetcher.py` 補 `load_dotenv()` - `pyproject.toml` 補宣告 `python-dotenv`(專案已在用但未列 dep) - **注意**:git 歷史第一個 commit `f7ef105` 仍含 token,因 repo 是 public 且已 push。要徹底清需 `git filter-repo`,目前暫不做 ### 3.4 實作 stale-while-revalidate (v2.1) - 問題:定期 cron 跑 fetcher → 沒人用時也一直打 Function Server + twpkinfo.com,會被標記 - 解法:`/scan` 永遠秒回當下 Redis 資料;若 TTL < `STALE_THRESHOLD`(預設 120s)或 key 不存在,用 `threading.Thread` 背景呼叫 `radar_fetcher.main()` 非同步更新 - 互斥 lock:Redis `SET NX EX` on key `pokemon:radar:fetching`(TTL 180s)避免並發 - cron 可以完全停掉(之前的 `fetch_and_upload.sh` / `radar_fetcher.py` 排程在生產 crontab 裡都已註解) - 新增環境變數:`STALE_THRESHOLD`、`FETCH_LOCK_KEY`、`FETCH_LOCK_TTL` ### 3.5 前端 repo 改走 API(Mixed Content 坑) - 使用者要前端直接打 API,不走舊的 `scp spots.json → caddy` 流程 - 第一次改:`API_URL = http://192.168.42.108:8008/scan`(**錯**:使用者一開始說 88.108,subagent 照做後使用者更正為 42.108) - 第二次改:`API_URL = http://192.168.42.108:8008/scan`(對的 IP) - **但**頁面是 `https://web.lotimmy.com/pokemon/`,打 HTTP API 是 Mixed Content,瀏覽器擋 - 第三次改:`API_URL = https://poke-api.lotimmy.com/scan`(走 NPM 反代) ### 3.6 生產機 42.108 部署(發現多層問題) - **發現 1**:生產機 `/opt/pokemon-radar-api` 根本不是健康 git repo(`.git` 存在但 master 0 commits,是手工 scp 上去的 Dec 8 舊版) - **發現 2**:舊版 API 還沒有 `/scan` fix,連不到 Redis 直接 500 - **發現 3**:生產 `.env` `REDIS_HOST=192.168.42.211` 已經**連不到**(該機器不存在了) - 做法: 1. `sudo tar` 備份整個目錄到 `/root/pokemon-radar-api-backup-.tar.gz` 2. 把 `.env` / `state.json` 搬到 `/tmp` 3. `rm -rf /opt/pokemon-radar-api` + 從 Gitea `git clone` 4. 還原 `.env` / `state.json` 5. `uv sync` 6. `supervisorctl restart pokemon-radar-api` - 驗證:`/scan` 回 200,但 `source=redis (unreachable)` — Redis 死的事實被誠實暴露 ### 3.7 在 42.108 跑本機 Redis - `docker run -d --name pokemon-radar-redis --restart unless-stopped -p 127.0.0.1:6379:6379 redis:7-alpine`(**綁 loopback only**,不對外暴露) - `.env`:`REDIS_HOST=127.0.0.1`、`REDIS_PASSWORD=` 留空 - `supervisorctl restart pokemon-radar-api` - 第一次 `/scan`:`source=redis (empty)` + 背景 fetcher 觸發(lock 有值) - 背景 fetcher 完成:51 筆進 Redis、TTL 299s - 第二次 `/scan`:`source=redis`、12ms 回應、有資料 ### 3.8 NPM 反代 HTTPS(解 Mixed Content) - DNS: `poke-api.lotimmy.com` A record → NPM public IP (`125.229.110.50`),僅 DNS 不走 CF proxy(LE HTTP challenge 需要直達) - NPM API (`http://192.168.42.124:81/api/`): 1. `POST /api/tokens` 用 `admin@example.tw` + password → JWT 2. `POST /api/nginx/proxy-hosts` 建 proxy host,`certificate_id: "new"` 一併申請 LE cert - 建好 proxy_host id=56 / cert id=84 - `curl https://poke-api.lotimmy.com/ping` → 200 ### 3.9 前端部署到 caddy(Phase 1) - caddy `/opt/caddy/www/pokemon/` 目錄 perm `777`、timmy 可直接寫(但本身嵌在 `/opt/caddy/.git` 的 repo 結構裡,所以不能單獨 git init 變新 repo) - `scp index.html` 手動覆蓋一次,讓 prod 立即有新版 - 同時為 Phase 2 的 webhook 自動化鋪路 ### 3.10 Webhook-driven 自動部署(Phase 2) 先做版(單 repo 寫死 pokemon): - 在 42.104 建 `/opt/git-webhook/`: - `server.py`(Python stdlib `http.server`,50 行,HMAC-SHA256 驗證) - `deploy.sh`(git clone depth=1 → rsync --exclude=.git 到 `/opt/caddy/www/pokemon/`) - `Dockerfile`(`python:3.12-alpine` + `apk add git rsync`) - `docker-compose.yml`(mount `/opt/caddy/www/pokemon`) - Gitea API `POST /api/v1/repos/timmy/pokemon/hooks` 建 webhook(events=push, branch_filter=main, 帶 secret) **卡關**:Gitea test webhook 204 但 container 沒收到。查 Gitea log 發現: ``` Unable to deliver webhook task[N]: ... webhook can only call allowed HTTP servers (check your webhook.ALLOWED_HOST_LIST setting), deny '192.168.42.104:9000' ``` Gitea 預設 SSRF 保護擋所有 RFC1918 內網 IP。 **解法**:在 `/opt/gitea/data/gitea/conf/app.ini` 加: ```ini [webhook] ALLOWED_HOST_LIST = external,192.168.42.0/24,loopback ``` `sudo docker restart gitea`。修好後 webhook 立刻通。 ### 3.11 Webhook 動態化 + helper 單 repo 寫死會限制未來。改成動態 hook loader: - `server.py` 接 `POST /hooks/` → 從 `/app/hooks//secret` 讀該 hook 的 secret、`/app/hooks//deploy.sh` 跑部署 - `server.py` 和 `hooks/` 都是 volume mount(不用 rebuild image 就可新增) - `/opt/caddy/www` 整個 mount 進 container,container 能寫任何子目錄 - 新增 helper `/opt/git-webhook/add-deploy.sh `: 1. 生 random secret 寫到 `hooks//secret` 2. 寫 `hooks//deploy.sh`(git clone + rsync) 3. 呼叫 Gitea API 建 webhook 4. 觸發 test 驗證 舊 global secret 從 `.env` 遷到 `hooks/pokemon/secret`(沿用原值保持 Gitea 端不用改)。 ### 3.12 前端 SWR 冷啟動 UX:空資料自動重試 問題:Redis 快取過期時,第一次 `/scan` 回 `source: "redis (empty)"`,要等 ~30-60 秒 fetcher 完成才有資料。使用者第一次開頁就撞上,看到紅框誤以為壞了。 前端 `loadScan()` 改成: - 空資料不走紅框錯誤分支,改顯示中性灰框 banner「正在更新資料,請稍候…(N/10)」 - 每 8 秒 `setTimeout` 遞迴重試,上限 10 次(80 秒) - 拿到資料自動清 timer + 隱藏 banner - 超時顯示中性警告「後端仍無資料,請稍後重新整理」 - 真正網路 / HTTP / JSON 錯誤 → 維持原本紅框 這次的 push 也是 **webhook pipeline 第一次真實使用**:push → Gitea 觸發 webhook → 42.104 自動 rsync → caddy 下次 request 即新版。之前都是用 Gitea API 的 test endpoint 模擬。 ### 3.13.5 多點 union 掃描(zoom 調整) 原設計 `RADAR_ZOOM=11` 是給舊 pipeline(scp spots.json 給 caddy)用的,想一次抓整個北台灣。新 pipeline 前端會聚焦特定街區,zoom=11 下單位面積密度太低(實測 twpk 某 1km² 街區顯示 98 marker,我們 API 在同範圍為 0)。 改成 **多點 union**: - `radar_fetcher.py` 支援 `RADAR_TARGETS=name:lat,lng;...` 環境變數 - 主流程 loop 每個 target 呼叫 Function Server,以 `(id, lat, lng, expire_time)` dedupe 後 union - 單點失敗不阻斷其他 target - 搭配 `RADAR_ZOOM=15` 街區級密度 - `FETCH_LOCK_TTL=600` 保 29 點序列跑的耗時(~5 分鐘) **目前生產 29 個 target**(涵蓋大台北 / 新北 / 宜蘭主要人口區 + 新竹備援): ``` 台北:台北車站(25.0478,121.5170)、中山(25.0634,121.5264)、松山(25.0499,121.5777)、 信義(25.0330,121.5645)、大安(25.0263,121.5443)、萬華(25.0356,121.4990)、 士林(25.0875,121.5260)、北投(25.1316,121.4986)、內湖(25.0824,121.5877)、 南港(25.0544,121.6065)、文山(24.9890,121.5691) 新北:板橋(25.0141,121.4631)、三重(25.0613,121.4860)、新莊(25.0385,121.4502)、 中和(25.0070,121.4995)、永和(25.0093,121.5148)、新店(24.9650,121.5423)、 土城(24.9722,121.4419)、汐止(25.0692,121.6415)、淡水(25.1718,121.4489)、 三峽(24.9349,121.3746)、林口(25.0775,121.3931)、蘆洲(25.0872,121.4692) 新竹:新竹(24.8138,120.9675) 宜蘭:宜蘭市(24.7511,121.7535)、羅東(24.6770,121.7681)、礁溪(24.8234,121.7708)、 蘇澳(24.5952,121.8523)、頭城(24.8600,121.8256) ``` 驗證:single fetch 回 760 筆 filtered(台北市 577 / 新北市 713 / 宜蘭縣 64)。街區級密度仍不如 twpk(zoom 17 視角下單點有 98 marker),但整個北北宜已有覆蓋;若要再補可加密 target 或降 `MIN_REMAINING`。 ### 3.13 UI 顯示資料更新時間 使用者想看到「這份資料什麼時候抓的」: - 後端 `/scan` response 新增 `generated_at` 欄位(ISO 8601,台北時區),從 Redis 存的 fetcher payload 頂層讀出 - `ScanResponse` model 補 `Optional[str]` 對應 - 前端右下角 `
` 半透明深色 banner,文字格式「資料更新於 HH:MM:SS(N 分鐘前)」 - 相對時間:<30s 剛剛 / <60m 分鐘前 / <24h 小時前 / 否則日期 - `setInterval` 每 10 秒重算顯示(不重 fetch) - 只在成功拿到有 items 的 response 時更新,空/錯誤分支保留上次值 後端變更需手動部署到 42.108:`git pull && uv sync && supervisorctl restart`。前端透過 webhook 自動部署。 --- ## 4. 日常操作 Cookbook ### 新增一個 repo 自動部署 前提:repo 已在 Gitea,且有 main branch。 ```bash ssh 192.168.42.104 'sudo /opt/git-webhook/add-deploy.sh \ ' ``` 範例(把 `timmy/blog` 自動部署到 caddy): ```bash ssh 192.168.42.104 'sudo /opt/git-webhook/add-deploy.sh \ timmy blog /opt/caddy/www/blog 737e5eb08811...' ``` 完成後,push 該 repo main → 自動部署。 ### 查 webhook deploy log ```bash ssh 192.168.42.104 'sudo docker logs git-webhook --tail 50' ``` ### 手動重新部署 ```bash ssh 192.168.42.104 'sudo /opt/git-webhook/hooks//deploy.sh' ``` ### 暫停自動部署 ```bash ssh 192.168.42.104 'sudo docker stop git-webhook' # 停掉 webhook receiver # 或 # 在 Gitea UI (http://192.168.42.124:31337///settings/hooks) disable ``` ### API 端(42.108)操作 ```bash # 重啟 ssh 192.168.42.108 'sudo supervisorctl restart pokemon-radar-api' # 查 log ssh 192.168.42.108 'sudo tail -f /var/log/supervisor/pokemon-radar-api.log' # 看 Redis 內容 ssh 192.168.42.108 'sudo docker exec pokemon-radar-redis redis-cli GET pokemon:radar:latest' | head -100 # 手動觸發 fetcher ssh 192.168.42.108 'cd /opt/pokemon-radar-api && sudo /root/.local/bin/uv run python radar_fetcher.py' ``` ### 更新生產 API 程式碼 後續 pokemon-radar-api 的 commit push 到 Gitea 後,生產機還要手動 pull + restart: ```bash ssh 192.168.42.108 'cd /opt/pokemon-radar-api && sudo git pull && sudo /root/.local/bin/uv sync && sudo supervisorctl restart pokemon-radar-api' ``` (若要把 API 端也做成 webhook 自動部署,需要設計一個能「git pull + uv sync + supervisorctl restart」的 deploy.sh,並讓 webhook container 有權限 ssh 或 supervisorctl — 比前端複雜。先保持手動) ### 新增 NPM 反代 + LE cert ```bash # 1. DNS: 在 Cloudflare 加 .lotimmy.com A record → 125.229.110.50(僅 DNS,不走 CF proxy) # 2. NPM API 建 proxy host(或到 http://192.168.42.124:81/ UI 按按鈕) TOKEN=$(curl -sS -X POST http://192.168.42.124:81/api/tokens \ -H "Content-Type: application/json" \ -d '{"identity":"admin@example.tw","secret":""}' \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])') curl -sS -X POST http://192.168.42.124:81/api/nginx/proxy-hosts \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "domain_names": [".lotimmy.com"], "forward_scheme": "http", "forward_host": "192.168.42.X", "forward_port": NNNN, "certificate_id": "new", "ssl_forced": true, "http2_support": true, "block_exploits": true, "meta": {"letsencrypt_agree": true, "letsencrypt_email": "admin@example.tw", "dns_challenge": false}, "locations": [] }' ``` --- ## 5. 已知限制與 follow-up ### 必看 - **state.json 是整條 pipeline 最脆弱的點**:過期時 Function Server 返回 200 但 results 空或登入頁,pipeline 靜默吐 0 筆。目前 state 是 2025-11-04 的,仍有效但總有一天會壞。重登 twpkinfo.com 匯出新 state 即可 - **42.108 supervisor conf 裡仍硬寫 `POKER_TOKEN="6R0W53R135510"`**(`/etc/supervisor/conf.d/pokemon-radar-api.conf` 的 `environment=` 那行),程式碼已不讀這個變數,但機器上 token 還在。非緊急,可空檔時清掉 - **第一個 commit `f7ef105` 含歷史 token**:public repo 歷史檔仍含當時 `FUNCTION_TOKEN=6R0W53R135510`,要徹底清需 `git filter-repo` + force push。目前暫不處理 ### 小疵 - `.env.bak.` 備份檔留在 `/opt/pokemon-radar-api/` 和 `/opt/gitea/data/gitea/conf/`,確認系統穩定後可刪 - 兩個 fetcher(`radar_fetcher.py` + `pokemon_location_fetcher.py`)篩選邏輯重複實作,合併到共用 module 是理想做法(但兩條 pipeline 短期共存,現在不急) - 前端 `API_URL` 仍 hardcode 在 `index.html`,未來要做 dev/prod 切換可抽 `config.js` 或用 `window.location.hostname` 判斷 - 前端空 items 用紅色 `showLoadError` 呈現,嚴格說這不是錯誤而是 SWR 暫態,可改中性 banner + auto retry - `pokemon-radar-api` 沒做 webhook 自動部署(還是手動 git pull + restart),若希望一致化可仿 `add-deploy.sh` 但加上 `supervisorctl restart` 步驟 ### 沒做但值得之後做 - 監控:webhook deploy 失敗時沒通知、API 500 時沒通知;可考慮 ntfy / Discord webhook 串起來 - Redis 在 42.108 Docker 裡,**沒設 persistence volume**;restart container 資料丟掉但 SWR 會自動重建,實務上無感。若以後需要 persist:加 `-v redis-data:/data` - `add-deploy.sh` 需要 Gitea token 當 arg,使用上每次要貼。可在 42.104 寫個 `/opt/git-webhook/.env` 放 `GITEA_TOKEN`,helper 自動 source - `pokemon-radar-api` 後端還是手動 `git pull + restart`。仿 `add-deploy.sh` 做一個帶 `supervisorctl restart` 的 deploy.sh 即可(需要 webhook container 能 ssh 進 host 執行 supervisorctl,或改用 host-level daemon)