接入 Cloudflare 免费能力并修正跑道日高
This commit is contained in:
@@ -26,6 +26,14 @@ POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
|
||||
POLYWEATHER_REDIS_STREAM_KEY=stream:city_observation
|
||||
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
|
||||
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
|
||||
# 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
|
||||
@@ -124,6 +132,10 @@ POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
|
||||
########################################
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
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.
|
||||
NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES=false
|
||||
# Optional: browser-visible FastAPI base URL for Vercel deployments.
|
||||
|
||||
@@ -19,6 +19,12 @@ SUPABASE_SERVICE_ROLE_KEY=
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
########################################
|
||||
# Cloudflare Turnstile
|
||||
########################################
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
POLYWEATHER_TURNSTILE_SECRET_KEY=
|
||||
|
||||
########################################
|
||||
# Entitlement / dashboard
|
||||
########################################
|
||||
@@ -40,6 +46,14 @@ POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_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
|
||||
########################################
|
||||
|
||||
@@ -93,6 +93,7 @@ jobs:
|
||||
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_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: |
|
||||
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_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_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY}"
|
||||
)
|
||||
fi
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ services:
|
||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://polyweather.top}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
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_PROJECT_ID: ${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID:-}
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN: ${POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN}
|
||||
@@ -76,6 +77,10 @@ services:
|
||||
POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true}
|
||||
POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true}
|
||||
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:
|
||||
interval: 30s
|
||||
retries: 3
|
||||
@@ -101,6 +106,13 @@ services:
|
||||
env_file: *id001
|
||||
environment:
|
||||
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_REDIS_REQUIRED: ${POLYWEATHER_REDIS_REQUIRED:-true}
|
||||
POLYWEATHER_REDIS_STREAM_MAXLEN: ${POLYWEATHER_REDIS_STREAM_MAXLEN:-100000}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
|
||||
缓存规则只允许作用于前端域名 `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
|
||||
@@ -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`。
|
||||
|
||||
也可以用脚本:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -112,6 +112,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `POLYWEATHER_PAYMENT_ENABLED`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
|
||||
- `POLYGON_WALLET_WATCH_ENABLED`
|
||||
- `POLYWEATHER_TURNSTILE_BYPASS`
|
||||
- `POLYWEATHER_TURNSTILE_ENFORCE_ACTION`
|
||||
- `POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT`
|
||||
- `TELEGRAM_ALERT_PUSH_ENABLED`
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
|
||||
|
||||
@@ -154,6 +157,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
|
||||
- `POLYWEATHER_TURNSTILE_SECRET_KEY`
|
||||
- `POLYWEATHER_R2_ACCESS_KEY_ID`
|
||||
- `POLYWEATHER_R2_SECRET_ACCESS_KEY`
|
||||
|
||||
## 5. 推荐部署矩阵
|
||||
|
||||
@@ -186,6 +192,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL`
|
||||
- `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS`
|
||||
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY`
|
||||
- `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS`
|
||||
- `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS`
|
||||
- `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES`
|
||||
@@ -205,8 +212,34 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
|
||||
- `VPS_SSH_KEY` / `VPS_HOST` / `VPS_USER` / `GHCR_PAT`(SSH 部署到 VPS)
|
||||
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ZONE_ID`(同步 Cloudflare Cache Rules)
|
||||
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY`(构建期注入前端镜像)
|
||||
- 前端构建期 `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.1 前端最小变量
|
||||
|
||||
@@ -38,6 +38,9 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
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
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
|
||||
@@ -14,6 +14,7 @@ ARG NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
|
||||
ARG NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
|
||||
ARG NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL
|
||||
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_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_POLYGON_RPC_URL=$NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL
|
||||
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
|
||||
COPY --from=deps /app/frontend/node_modules ./node_modules
|
||||
|
||||
@@ -4,6 +4,7 @@ set -u
|
||||
|
||||
BASE_URL="${1:-https://polyweather.top}"
|
||||
CURL_BIN="${CURL_BIN:-curl}"
|
||||
REQUIRE_CF_CACHE="${REQUIRE_CF_CACHE:-false}"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
@@ -40,6 +41,11 @@ header_value() {
|
||||
| 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() {
|
||||
local haystack="$1"
|
||||
local needle="$2"
|
||||
@@ -97,6 +103,78 @@ check_cached_endpoint() {
|
||||
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() {
|
||||
local endpoint="$1"
|
||||
local label="$2"
|
||||
@@ -168,12 +246,14 @@ main() {
|
||||
|
||||
check_cached_endpoint "/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_force_refresh_nostore "/api/city/ankara/summary?force_refresh=true" "city summary force_refresh"
|
||||
|
||||
check_cached_endpoint "/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 ""
|
||||
if [ "$FAIL_COUNT" -gt 0 ]; then
|
||||
|
||||
@@ -5,7 +5,7 @@ import re
|
||||
import threading
|
||||
import time
|
||||
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
|
||||
|
||||
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 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_i18n import (
|
||||
copy_text as _copy,
|
||||
@@ -1494,15 +1495,78 @@ def _get_airport_daily_high(city_weather: Dict[str, Any]):
|
||||
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]:
|
||||
"""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")
|
||||
if not isinstance(history, dict) or not history:
|
||||
return None
|
||||
local_date, utc_offset_seconds = _runway_history_context(city_weather, city)
|
||||
settlement_pair = _settlement_runway_for_city(city)
|
||||
if settlement_pair:
|
||||
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:
|
||||
temps = [p.get("temp") for p in settlement_pts if isinstance(p, dict) and p.get("temp") is not None]
|
||||
if temps:
|
||||
@@ -1510,8 +1574,12 @@ def _runway_history_daily_max(city_weather: Dict[str, Any], city: str) -> Option
|
||||
# Fallback: max across all runways
|
||||
all_temps = []
|
||||
for pts in history.values():
|
||||
if isinstance(pts, list):
|
||||
all_temps.extend(p.get("temp") for p in pts if isinstance(p, dict) and p.get("temp") is not None)
|
||||
today_pts = _today_runway_history_points(
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,13 @@ def test_frontend_dockerfile_uses_standalone_multistage_runtime():
|
||||
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():
|
||||
nginx_conf = (ROOT / "deploy" / "nginx" / "polyweather.conf").read_text(
|
||||
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
|
||||
|
||||
|
||||
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():
|
||||
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:")
|
||||
|
||||
|
||||
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():
|
||||
source = (ROOT / "web" / "analysis_service.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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):
|
||||
import src.utils.telegram_push as telegram_push
|
||||
|
||||
|
||||
Reference in New Issue
Block a user