接入 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_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.
+14
View File
@@ -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
########################################
+2
View File
@@ -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
+12
View File
@@ -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}
+42
View File
@@ -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
```
+33
View File
@@ -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 前端最小变量
+3
View File
@@ -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
+2
View File
@@ -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
+80
View File
@@ -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
+73 -5
View File
@@ -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
+34
View File
@@ -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")
+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
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