接入 Cloudflare 免费能力并修正跑道日高

This commit is contained in:
2569718930@qq.com
2026-06-16 03:59:01 +08:00
parent 7756b137a2
commit f9d3fc2d96
12 changed files with 342 additions and 5 deletions
+12
View File
@@ -26,6 +26,14 @@ POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
POLYWEATHER_REDIS_STREAM_KEY=stream:city_observation POLYWEATHER_REDIS_STREAM_KEY=stream:city_observation
POLYWEATHER_REDIS_STREAM_MAXLEN=50000 POLYWEATHER_REDIS_STREAM_MAXLEN=50000
POLYWEATHER_REDIS_REQUIRED=true POLYWEATHER_REDIS_REQUIRED=true
# Optional Cloudflare R2 cold archive for realtime SSE/event snapshots.
POLYWEATHER_R2_ARCHIVE_SOURCE=redis
POLYWEATHER_R2_ACCOUNT_ID=
POLYWEATHER_R2_BUCKET=
POLYWEATHER_R2_ENDPOINT_URL=
POLYWEATHER_R2_REGION=auto
POLYWEATHER_R2_ACCESS_KEY_ID=
POLYWEATHER_R2_SECRET_ACCESS_KEY=
# Backend CORS allowlist. Add your Vercel production/preview domains when # Backend CORS allowlist. Add your Vercel production/preview domains when
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL points browsers directly at this backend. # NEXT_PUBLIC_POLYWEATHER_API_BASE_URL points browsers directly at this backend.
WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather.top,https://www.polyweather.top,https://api.polyweather.top WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather.top,https://www.polyweather.top,https://api.polyweather.top
@@ -124,6 +132,10 @@ POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
######################################## ########################################
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
POLYWEATHER_TURNSTILE_BYPASS=false
POLYWEATHER_TURNSTILE_ENFORCE_ACTION=false
POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT=false
# Optional: disable homepage city summary preloading. Default is enabled. # Optional: disable homepage city summary preloading. Default is enabled.
NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES=false NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES=false
# Optional: browser-visible FastAPI base URL for Vercel deployments. # Optional: browser-visible FastAPI base URL for Vercel deployments.
+14
View File
@@ -19,6 +19,12 @@ SUPABASE_SERVICE_ROLE_KEY=
NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_PUBLIC_SUPABASE_ANON_KEY=
########################################
# Cloudflare Turnstile
########################################
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
POLYWEATHER_TURNSTILE_SECRET_KEY=
######################################## ########################################
# Entitlement / dashboard # Entitlement / dashboard
######################################## ########################################
@@ -40,6 +46,14 @@ POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON= POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON= POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
########################################
# Cloudflare R2 archive
########################################
POLYWEATHER_R2_ACCOUNT_ID=
POLYWEATHER_R2_BUCKET=
POLYWEATHER_R2_ACCESS_KEY_ID=
POLYWEATHER_R2_SECRET_ACCESS_KEY=
######################################## ########################################
# Optional exchange / market secrets # Optional exchange / market secrets
######################################## ########################################
+2
View File
@@ -93,6 +93,7 @@ jobs:
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }} NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }} NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }} NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${{ secrets.NEXT_PUBLIC_TURNSTILE_SITE_KEY || '' }}
run: | run: |
set -euo pipefail set -euo pipefail
@@ -109,6 +110,7 @@ jobs:
--build-arg "NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID}" --build-arg "NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID}"
--build-arg "NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL}" --build-arg "NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL}"
--build-arg "NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS}" --build-arg "NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS}"
--build-arg "NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY}"
) )
fi fi
+12
View File
@@ -69,6 +69,7 @@ services:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://polyweather.top} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://polyweather.top}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL:-https://polygon-bor-rpc.publicnode.com} NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL:-https://polygon-bor-rpc.publicnode.com}
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID:-} NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID:-}
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN: ${POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN} POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN: ${POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN}
@@ -76,6 +77,10 @@ services:
POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true} POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true}
POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true} POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true}
POLYWEATHER_OPS_ADMIN_EMAILS: ${POLYWEATHER_OPS_ADMIN_EMAILS:-} POLYWEATHER_OPS_ADMIN_EMAILS: ${POLYWEATHER_OPS_ADMIN_EMAILS:-}
POLYWEATHER_TURNSTILE_BYPASS: ${POLYWEATHER_TURNSTILE_BYPASS:-false}
POLYWEATHER_TURNSTILE_ENFORCE_ACTION: ${POLYWEATHER_TURNSTILE_ENFORCE_ACTION:-false}
POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT: ${POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT:-false}
POLYWEATHER_TURNSTILE_SECRET_KEY: ${POLYWEATHER_TURNSTILE_SECRET_KEY:-}
healthcheck: healthcheck:
interval: 30s interval: 30s
retries: 3 retries: 3
@@ -101,6 +106,13 @@ services:
env_file: *id001 env_file: *id001
environment: environment:
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0} POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_R2_ACCESS_KEY_ID: ${POLYWEATHER_R2_ACCESS_KEY_ID:-}
POLYWEATHER_R2_ACCOUNT_ID: ${POLYWEATHER_R2_ACCOUNT_ID:-}
POLYWEATHER_R2_ARCHIVE_SOURCE: ${POLYWEATHER_R2_ARCHIVE_SOURCE:-redis}
POLYWEATHER_R2_BUCKET: ${POLYWEATHER_R2_BUCKET:-}
POLYWEATHER_R2_ENDPOINT_URL: ${POLYWEATHER_R2_ENDPOINT_URL:-}
POLYWEATHER_R2_REGION: ${POLYWEATHER_R2_REGION:-auto}
POLYWEATHER_R2_SECRET_ACCESS_KEY: ${POLYWEATHER_R2_SECRET_ACCESS_KEY:-}
POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis} POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}
POLYWEATHER_REDIS_REQUIRED: ${POLYWEATHER_REDIS_REQUIRED:-true} POLYWEATHER_REDIS_REQUIRED: ${POLYWEATHER_REDIS_REQUIRED:-true}
POLYWEATHER_REDIS_STREAM_MAXLEN: ${POLYWEATHER_REDIS_STREAM_MAXLEN:-100000} POLYWEATHER_REDIS_STREAM_MAXLEN: ${POLYWEATHER_REDIS_STREAM_MAXLEN:-100000}
+42
View File
@@ -6,6 +6,14 @@
缓存规则只允许作用于前端域名 `polyweather.top``api.polyweather.top` 是带服务令牌和会员校验的后端源站,必须绕过 Cloudflare 缓存,避免缓存命中绕过后端权限检查。 缓存规则只允许作用于前端域名 `polyweather.top``api.polyweather.top` 是带服务令牌和会员校验的后端源站,必须绕过 Cloudflare 缓存,避免缓存命中绕过后端权限检查。
## 已接入的免费能力
- Cache Rules:仓库脚本 `scripts/configure_cloudflare_free.py` 同步公开缓存规则,`scripts/validate_frontend_cache.sh` 可检查 `CF-Cache-Status`
- Turnstile:登录/注册经 Supabase Auth captcha token;反馈提交和支付创建订单在 Next API Route 校验后再转发给后端。
- R2`scripts/archive_realtime_events_to_r2.py` 可把 Redis/SQLite 中的 SSE 事件按天归档为 JSONL,上传到 Cloudflare R2。
暂不建议把 Tunnel 作为本轮替代 Nginx:当前 Nginx 还承担 TLS、回源隔离和本地 compose 端口映射,Tunnel 需要单独灰度和回滚方案。
## 基础设置 ## 基础设置
- `Caching > Configuration > Browser Cache TTL`Respect Existing Headers - `Caching > Configuration > Browser Cache TTL`Respect Existing Headers
@@ -161,3 +169,37 @@ curl.exe -sS -D - -o NUL "https://polyweather.top/api/scan/terminal?limit=1"
``` ```
正常命中应看到 `CF-Cache-Status: HIT` 和递增的 `Age`。首次请求通常是 `MISS`;动态或明确 `no-store` 的请求应保持 `DYNAMIC``BYPASS` 正常命中应看到 `CF-Cache-Status: HIT` 和递增的 `Age`。首次请求通常是 `MISS`;动态或明确 `no-store` 的请求应保持 `DYNAMIC``BYPASS`
也可以用脚本:
```bash
scripts/validate_frontend_cache.sh https://polyweather.top
REQUIRE_CF_CACHE=true scripts/validate_frontend_cache.sh https://polyweather.top
```
第二条会把缺失 `CF-Cache-Status` 或持续 `MISS` 作为失败处理,适合 Cache Rules 同步后的线上检查。
## Turnstile
Cloudflare 控制台创建 Turnstile site 后,配置:
- GitHub Secrets`NEXT_PUBLIC_TURNSTILE_SITE_KEY`
- VPS / Docker `.env``NEXT_PUBLIC_TURNSTILE_SITE_KEY`
- VPS / Docker `.env``POLYWEATHER_TURNSTILE_SECRET_KEY`
`NEXT_PUBLIC_TURNSTILE_SITE_KEY` 属于前端构建期变量,改动后必须重新构建前端镜像。`POLYWEATHER_TURNSTILE_SECRET_KEY` 只在服务端使用,不要提交仓库。
## R2 事件归档
R2 用于冷归档,不替代 Redis Stream / SQLite 的热路径。推荐先用 dry-run 看事件量:
```bash
python scripts/archive_realtime_events_to_r2.py --source redis --date 2026-06-16 --dry-run
python scripts/archive_realtime_events_to_r2.py --source redis --date 2026-06-16
```
对象 key 形如:
```text
sse-events/2026/06/16/city_observation_patch.redis.jsonl
```
+33
View File
@@ -112,6 +112,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `POLYWEATHER_PAYMENT_ENABLED` - `POLYWEATHER_PAYMENT_ENABLED`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON` - `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- `POLYGON_WALLET_WATCH_ENABLED` - `POLYGON_WALLET_WATCH_ENABLED`
- `POLYWEATHER_TURNSTILE_BYPASS`
- `POLYWEATHER_TURNSTILE_ENFORCE_ACTION`
- `POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT`
- `TELEGRAM_ALERT_PUSH_ENABLED` - `TELEGRAM_ALERT_PUSH_ENABLED`
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED` - `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
@@ -154,6 +157,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN` - `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN` - `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` - `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
- `POLYWEATHER_TURNSTILE_SECRET_KEY`
- `POLYWEATHER_R2_ACCESS_KEY_ID`
- `POLYWEATHER_R2_SECRET_ACCESS_KEY`
## 5. 推荐部署矩阵 ## 5. 推荐部署矩阵
@@ -186,6 +192,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` - `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
- `NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL` - `NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL`
- `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` - `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS`
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY`
- `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS` - `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS`
- `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS` - `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS`
- `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES` - `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES`
@@ -205,8 +212,34 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `VPS_SSH_KEY` / `VPS_HOST` / `VPS_USER` / `GHCR_PAT`SSH 部署到 VPS - `VPS_SSH_KEY` / `VPS_HOST` / `VPS_USER` / `GHCR_PAT`SSH 部署到 VPS
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ZONE_ID`(同步 Cloudflare Cache Rules - `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ZONE_ID`(同步 Cloudflare Cache Rules
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY`(构建期注入前端镜像)
- 前端构建期 `NEXT_PUBLIC_*`(注入前端镜像) - 前端构建期 `NEXT_PUBLIC_*`(注入前端镜像)
### 5.4 Cloudflare 免费能力
Turnstile
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY` 是浏览器可见的 site key,属于前端构建期变量;改动后需要重新构建前端镜像。
- `POLYWEATHER_TURNSTILE_SECRET_KEY` 只放 VPS / Docker `.env`,用于 Next API Route 服务端校验。
- `POLYWEATHER_TURNSTILE_BYPASS=true` 可在排障时临时关闭校验。
- 支付 tx 提交默认不强制二次 Turnstile,因为 Cloudflare token 是一次性校验;订单创建已做校验。只有确认 UX 能支持二次挑战时,才设置 `POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT=true`
R2
- `POLYWEATHER_R2_ACCOUNT_ID`
- `POLYWEATHER_R2_BUCKET`
- `POLYWEATHER_R2_ACCESS_KEY_ID`
- `POLYWEATHER_R2_SECRET_ACCESS_KEY`
- `POLYWEATHER_R2_REGION=auto`
- `POLYWEATHER_R2_ARCHIVE_SOURCE=redis`
归档脚本只读 Redis Stream 或 SQLite,不删除热路径数据:
```bash
python scripts/archive_realtime_events_to_r2.py --date 2026-06-16 --dry-run
python scripts/archive_realtime_events_to_r2.py --date 2026-06-16
```
## 6. 最小部署示例 ## 6. 最小部署示例
### 6.1 前端最小变量 ### 6.1 前端最小变量
+3
View File
@@ -38,6 +38,9 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
POLYWEATHER_TURNSTILE_SECRET_KEY=
POLYWEATHER_TURNSTILE_BYPASS=false
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
+2
View File
@@ -14,6 +14,7 @@ ARG NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
ARG NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID ARG NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
ARG NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL ARG NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL
ARG NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS ARG NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
@@ -23,6 +24,7 @@ ENV NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=$NEXT_PUBLIC_POLYWEATHER_LOCAL_FUL
ENV NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=$NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID ENV NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=$NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
ENV NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=$NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL ENV NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=$NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL
ENV NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=$NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS ENV NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=$NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
WORKDIR /app/frontend WORKDIR /app/frontend
COPY --from=deps /app/frontend/node_modules ./node_modules COPY --from=deps /app/frontend/node_modules ./node_modules
+80
View File
@@ -4,6 +4,7 @@ set -u
BASE_URL="${1:-https://polyweather.top}" BASE_URL="${1:-https://polyweather.top}"
CURL_BIN="${CURL_BIN:-curl}" CURL_BIN="${CURL_BIN:-curl}"
REQUIRE_CF_CACHE="${REQUIRE_CF_CACHE:-false}"
PASS_COUNT=0 PASS_COUNT=0
FAIL_COUNT=0 FAIL_COUNT=0
@@ -40,6 +41,11 @@ header_value() {
| awk -F': ' -v k="$key" 'tolower($1)==tolower(k) { print $2; exit }' | awk -F': ' -v k="$key" 'tolower($1)==tolower(k) { print $2; exit }'
} }
cf_cache_status() {
local headers="$1"
header_value "$headers" "CF-Cache-Status"
}
contains_ci() { contains_ci() {
local haystack="$1" local haystack="$1"
local needle="$2" local needle="$2"
@@ -97,6 +103,78 @@ check_cached_endpoint() {
fi fi
} }
check_cloudflare_cache_hit() {
local endpoint="$1"
local label="$2"
local url="${BASE_URL%/}${endpoint}"
local headers1 headers2
if ! headers1="$(header_dump "$url")"; then
fail "$label first Cloudflare cache request failed: $url"
return
fi
if ! headers2="$(header_dump "$url")"; then
fail "$label second Cloudflare cache request failed: $url"
return
fi
local code2 status1 status2
code2="$(status_code "$headers2")"
status1="$(cf_cache_status "$headers1")"
status2="$(cf_cache_status "$headers2")"
if [ "$code2" != "200" ]; then
fail "$label Cloudflare cache status check expected HTTP 200, got $code2"
return
fi
case "$status1" in
HIT|MISS|REVALIDATED|STALE|UPDATING|EXPIRED)
pass "$label Cloudflare first status observable: $status1"
;;
"")
if [ "$REQUIRE_CF_CACHE" = "true" ]; then
fail "$label missing CF-Cache-Status on first request"
else
pass "$label CF-Cache-Status unavailable; set REQUIRE_CF_CACHE=true to enforce"
fi
;;
*)
if [ "$REQUIRE_CF_CACHE" = "true" ]; then
fail "$label unexpected first CF-Cache-Status: $status1"
else
pass "$label Cloudflare first status non-cacheable but observed: $status1"
fi
;;
esac
case "$status2" in
HIT|REVALIDATED)
pass "$label Cloudflare edge cache hit: $status2"
;;
MISS)
if [ "$REQUIRE_CF_CACHE" = "true" ]; then
fail "$label Cloudflare edge cache expected HIT or REVALIDATED, got MISS"
else
pass "$label Cloudflare returned MISS; cache rule may still be warming"
fi
;;
"")
if [ "$REQUIRE_CF_CACHE" = "true" ]; then
fail "$label missing CF-Cache-Status on second request"
else
pass "$label CF-Cache-Status missing on second request; enforcement disabled"
fi
;;
*)
if [ "$REQUIRE_CF_CACHE" = "true" ]; then
fail "$label Cloudflare edge cache expected HIT or REVALIDATED, got $status2"
else
pass "$label Cloudflare edge cache not enforced, got $status2"
fi
;;
esac
}
check_force_refresh_nostore() { check_force_refresh_nostore() {
local endpoint="$1" local endpoint="$1"
local label="$2" local label="$2"
@@ -168,12 +246,14 @@ main() {
check_cached_endpoint "/api/cities" "cities" check_cached_endpoint "/api/cities" "cities"
check_if_none_match_304 "/api/cities" "cities" check_if_none_match_304 "/api/cities" "cities"
check_cloudflare_cache_hit "/api/cities" "cities edge cache"
check_cached_endpoint "/api/city/ankara/summary" "city summary" check_cached_endpoint "/api/city/ankara/summary" "city summary"
check_force_refresh_nostore "/api/city/ankara/summary?force_refresh=true" "city summary force_refresh" check_force_refresh_nostore "/api/city/ankara/summary?force_refresh=true" "city summary force_refresh"
check_cached_endpoint "/api/history/ankara" "history" check_cached_endpoint "/api/history/ankara" "history"
check_if_none_match_304 "/api/history/ankara" "history" check_if_none_match_304 "/api/history/ankara" "history"
check_cloudflare_cache_hit "/api/scan/terminal?limit=1" "scan terminal edge cache"
print_line "" print_line ""
if [ "$FAIL_COUNT" -gt 0 ]; then if [ "$FAIL_COUNT" -gt 0 ]; then
+73 -5
View File
@@ -5,7 +5,7 @@ import re
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Set, Tuple from typing import Any, Dict, List, Optional, Set, Tuple
import requests as requests_lib import requests as requests_lib
@@ -19,6 +19,7 @@ from src.database.runtime_state import (
) )
from src.data_collection.city_registry import ALIASES from src.data_collection.city_registry import ALIASES
from src.data_collection.city_registry import CITY_REGISTRY from src.data_collection.city_registry import CITY_REGISTRY
from src.data_collection.city_time import get_city_utc_offset_seconds
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env, parse_telegram_chat_ids from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env, parse_telegram_chat_ids
from src.utils.telegram_i18n import ( from src.utils.telegram_i18n import (
copy_text as _copy, copy_text as _copy,
@@ -1494,15 +1495,78 @@ def _get_airport_daily_high(city_weather: Dict[str, Any]):
return max_so_far, max_time return max_so_far, max_time
def _runway_history_point_local_date(point: Dict[str, Any], utc_offset_seconds: int) -> str:
raw_time = (
point.get("time")
or point.get("timestamp")
or point.get("observed_at")
or point.get("otime_utc")
or ""
)
parsed = _parse_iso_datetime_utc(raw_time)
if parsed is None:
return ""
return (parsed + timedelta(seconds=utc_offset_seconds)).date().isoformat()
def _today_runway_history_points(
points: Any,
*,
local_date: str,
utc_offset_seconds: int,
) -> List[Dict[str, Any]]:
if not isinstance(points, list):
return []
if not local_date:
return [p for p in points if isinstance(p, dict)]
return [
p
for p in points
if isinstance(p, dict)
and _runway_history_point_local_date(p, utc_offset_seconds) == local_date
]
def _runway_history_context(city_weather: Dict[str, Any], city: str) -> Tuple[str, int]:
local_date = str(city_weather.get("local_date") or "").strip()
anchor_values = [
(city_weather.get("amos") or {}).get("observation_time"),
(city_weather.get("airport_primary") or {}).get("obs_time"),
(city_weather.get("airport_current") or {}).get("obs_time"),
(city_weather.get("current") or {}).get("observed_at"),
(city_weather.get("canonical_temperature") or {}).get("observed_at"),
]
anchor_dt = next(
(parsed for parsed in (_parse_iso_datetime_utc(value) for value in anchor_values) if parsed is not None),
None,
)
try:
utc_offset_seconds = int(
city_weather.get("utc_offset_seconds")
if city_weather.get("utc_offset_seconds") is not None
else get_city_utc_offset_seconds(city, anchor_dt),
)
except Exception:
utc_offset_seconds = 0
if not local_date and anchor_dt is not None:
local_date = (anchor_dt + timedelta(seconds=utc_offset_seconds)).date().isoformat()
return local_date, utc_offset_seconds
def _runway_history_daily_max(city_weather: Dict[str, Any], city: str) -> Optional[float]: def _runway_history_daily_max(city_weather: Dict[str, Any], city: str) -> Optional[float]:
"""Compute today's runway high from runway_plate_history.""" """Compute today's local-date runway high from runway_plate_history."""
history = city_weather.get("runway_plate_history") history = city_weather.get("runway_plate_history")
if not isinstance(history, dict) or not history: if not isinstance(history, dict) or not history:
return None return None
local_date, utc_offset_seconds = _runway_history_context(city_weather, city)
settlement_pair = _settlement_runway_for_city(city) settlement_pair = _settlement_runway_for_city(city)
if settlement_pair: if settlement_pair:
settlement_key = f"{settlement_pair[0]}/{settlement_pair[1]}" settlement_key = f"{settlement_pair[0]}/{settlement_pair[1]}"
settlement_pts = history.get(settlement_key) settlement_pts = _today_runway_history_points(
history.get(settlement_key),
local_date=local_date,
utc_offset_seconds=utc_offset_seconds,
)
if settlement_pts: if settlement_pts:
temps = [p.get("temp") for p in settlement_pts if isinstance(p, dict) and p.get("temp") is not None] temps = [p.get("temp") for p in settlement_pts if isinstance(p, dict) and p.get("temp") is not None]
if temps: if temps:
@@ -1510,8 +1574,12 @@ def _runway_history_daily_max(city_weather: Dict[str, Any], city: str) -> Option
# Fallback: max across all runways # Fallback: max across all runways
all_temps = [] all_temps = []
for pts in history.values(): for pts in history.values():
if isinstance(pts, list): today_pts = _today_runway_history_points(
all_temps.extend(p.get("temp") for p in pts if isinstance(p, dict) and p.get("temp") is not None) pts,
local_date=local_date,
utc_offset_seconds=utc_offset_seconds,
)
all_temps.extend(p.get("temp") for p in today_pts if p.get("temp") is not None)
return round(max(all_temps), 1) if all_temps else None return round(max(all_temps), 1) if all_temps else None
+34
View File
@@ -18,6 +18,13 @@ def test_frontend_dockerfile_uses_standalone_multistage_runtime():
assert "CMD [\"node\", \"server.js\"]" in dockerfile assert "CMD [\"node\", \"server.js\"]" in dockerfile
def test_frontend_dockerfile_accepts_turnstile_site_key_build_arg():
dockerfile = (ROOT / "frontend" / "Dockerfile").read_text(encoding="utf-8")
assert "ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY" in dockerfile
assert "ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY" in dockerfile
def test_nginx_proxy_buffers_cover_supabase_auth_cookies(): def test_nginx_proxy_buffers_cover_supabase_auth_cookies():
nginx_conf = (ROOT / "deploy" / "nginx" / "polyweather.conf").read_text( nginx_conf = (ROOT / "deploy" / "nginx" / "polyweather.conf").read_text(
encoding="utf-8" encoding="utf-8"
@@ -136,6 +143,26 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
assert "POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}" in collector_block assert "POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}" in collector_block
def test_docker_compose_exposes_cloudflare_free_runtime_env():
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
frontend_block = compose.split(" polyweather_frontend:", 1)[1].split(
"\n polyweather_web:",
1,
)[0]
web_block = compose.split(" polyweather_web:", 1)[1].split(
"\n polyweather_collector:",
1,
)[0]
assert "NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-}" in frontend_block
assert "POLYWEATHER_TURNSTILE_SECRET_KEY: ${POLYWEATHER_TURNSTILE_SECRET_KEY:-}" in frontend_block
assert "POLYWEATHER_TURNSTILE_BYPASS: ${POLYWEATHER_TURNSTILE_BYPASS:-false}" in frontend_block
assert "POLYWEATHER_R2_ACCOUNT_ID: ${POLYWEATHER_R2_ACCOUNT_ID:-}" in web_block
assert "POLYWEATHER_R2_BUCKET: ${POLYWEATHER_R2_BUCKET:-}" in web_block
assert "POLYWEATHER_R2_ACCESS_KEY_ID: ${POLYWEATHER_R2_ACCESS_KEY_ID:-}" in web_block
assert "POLYWEATHER_R2_SECRET_ACCESS_KEY: ${POLYWEATHER_R2_SECRET_ACCESS_KEY:-}" in web_block
def test_scan_terminal_backend_timeout_returns_before_next_proxy_abort(): def test_scan_terminal_backend_timeout_returns_before_next_proxy_abort():
import web.services.scan_terminal_config as scan_terminal_config import web.services.scan_terminal_config as scan_terminal_config
@@ -172,6 +199,13 @@ def test_deploy_workflow_applies_cloudflare_rules_when_token_is_available():
assert workflow.index("cloudflare-cache-rules:") < workflow.index("deploy:") assert workflow.index("cloudflare-cache-rules:") < workflow.index("deploy:")
def test_deploy_workflow_passes_turnstile_site_key_to_frontend_build():
workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
assert "NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${{ secrets.NEXT_PUBLIC_TURNSTILE_SITE_KEY || '' }}" in workflow
assert '--build-arg "NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY}"' in workflow
def test_probability_engine_uses_enriched_multi_model_snapshot(): def test_probability_engine_uses_enriched_multi_model_snapshot():
source = (ROOT / "web" / "analysis_service.py").read_text(encoding="utf-8") source = (ROOT / "web" / "analysis_service.py").read_text(encoding="utf-8")
+35
View File
@@ -177,6 +177,41 @@ def test_wuhan_runway_high_uses_local_today_not_rolling_24h_history():
assert "Today's runway high / 今日跑道高点: 34.4°C" not in text assert "Today's runway high / 今日跑道高点: 34.4°C" not in text
def test_runway_high_infers_local_today_when_payload_lacks_local_date():
text = _build_airport_status_message(
"shanghai",
{
"city": "shanghai",
"current": {"temp": 24.6},
"airport_current": {"max_so_far": 35.0, "max_temp_time": "15:00"},
"runway_plate_history": {
"17L/35R": [
{"time": "2026-06-15T07:00:00Z", "temp": 35.0},
{"time": "2026-06-15T19:43:00Z", "temp": 24.6},
],
},
"amos": {
"source": "amsc_awos",
"icao": "ZSPD",
"observation_time": "2026-06-15T19:43:00Z",
"runway_obs": {
"runway_pairs": [("17L", "35R")],
"temperatures": [(24.6, None)],
"point_temperatures": [
{"runway": "17L/35R", "tdz_temp": 24.2, "mid_temp": None, "end_temp": 24.6, "target_runway_max": 24.6},
],
},
},
},
28.0,
"03:43",
language="both",
)
assert "Today's runway high / 今日跑道高点: 24.6°C" in text
assert "Today's runway high / 今日跑道高点: 35.0°C" not in text
def test_telegram_slope_uses_settlement_endpoint_not_runway_max(monkeypatch): def test_telegram_slope_uses_settlement_endpoint_not_runway_max(monkeypatch):
import src.utils.telegram_push as telegram_push import src.utils.telegram_push as telegram_push