diff --git a/.env.example b/.env.example index 88599476..cce055a5 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/.env.secrets.example b/.env.secrets.example index 0ec610e0..d0f651a7 100644 --- a/.env.secrets.example +++ b/.env.secrets.example @@ -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 ######################################## diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 403d61a7..8d48235f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index a32f50f8..05c740c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/docs/CLOUDFLARE_FREE_CACHE_ZH.md b/docs/CLOUDFLARE_FREE_CACHE_ZH.md index 85089fd4..f85725e3 100644 --- a/docs/CLOUDFLARE_FREE_CACHE_ZH.md +++ b/docs/CLOUDFLARE_FREE_CACHE_ZH.md @@ -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 +``` diff --git a/docs/CONFIGURATION_ZH.md b/docs/CONFIGURATION_ZH.md index 44d628df..e106faa3 100644 --- a/docs/CONFIGURATION_ZH.md +++ b/docs/CONFIGURATION_ZH.md @@ -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 前端最小变量 diff --git a/frontend/.env.example b/frontend/.env.example index 0cea5e99..81fab60a 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 703e676f..31cce8fe 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 diff --git a/scripts/validate_frontend_cache.sh b/scripts/validate_frontend_cache.sh index 0684a008..e84955a0 100644 --- a/scripts/validate_frontend_cache.sh +++ b/scripts/validate_frontend_cache.sh @@ -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 diff --git a/src/utils/telegram_push.py b/src/utils/telegram_push.py index 2563394b..acb8138a 100644 --- a/src/utils/telegram_push.py +++ b/src/utils/telegram_push.py @@ -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 diff --git a/tests/test_deployment_runtime_config.py b/tests/test_deployment_runtime_config.py index 2cb2fb83..cb3cd3cc 100644 --- a/tests/test_deployment_runtime_config.py +++ b/tests/test_deployment_runtime_config.py @@ -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") diff --git a/tests/test_telegram_hashtags.py b/tests/test_telegram_hashtags.py index b204711b..24e22f1a 100644 --- a/tests/test_telegram_hashtags.py +++ b/tests/test_telegram_hashtags.py @@ -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