Compare commits
356 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51b0e41c0f | |||
| 0a46933f6a | |||
| 004439b736 | |||
| b8a1950932 | |||
| 221edb480c | |||
| ba2db42eaa | |||
| 0c76874418 | |||
| 24415ee427 | |||
| 567e41ba2a | |||
| 566c47e683 | |||
| 4b4fb92070 | |||
| 6279b8ac32 | |||
| 75eb4eb35d | |||
| 0f65591931 | |||
| 9aa6653364 | |||
| e1376108f9 | |||
| 491415427c | |||
| 53cf5b556b | |||
| 8edcedb300 | |||
| 3931d21637 | |||
| d3e76ecac9 | |||
| 4627a110bf | |||
| 1aa4fab6d8 | |||
| 135198e161 | |||
| f1d9487017 | |||
| 482dc3c9e6 | |||
| 20d64a880a | |||
| ba59aebf87 | |||
| 6943f637ad | |||
| a2d9051be1 | |||
| 2a38a19f18 | |||
| aad917ccb6 | |||
| a9ed45a3d3 | |||
| 153595ccfc | |||
| 2d019d051f | |||
| 9dc0d598a0 | |||
| f6fbff9a28 | |||
| 3bc8e73ab6 | |||
| fbea6df175 | |||
| 1e1a78e946 | |||
| 1afd3c6976 | |||
| 14e356c3c9 | |||
| f9d3fc2d96 | |||
| 7756b137a2 | |||
| a94e6ecaf9 | |||
| e896ffa7ac | |||
| 382bc99f0c | |||
| 9d61d9443d | |||
| 555de2f415 | |||
| 316540e8f4 | |||
| eb5579dbbd | |||
| 6d7f213cd9 | |||
| dca4f2d618 | |||
| ed0447f408 | |||
| 3984e12c44 | |||
| 5075cf40b8 | |||
| fd9d768c6d | |||
| c0b050d2a7 | |||
| 55cfb202da | |||
| 95b55d4ab3 | |||
| 07e28481e8 | |||
| 383011f9fe | |||
| 7443383720 | |||
| 9e91e8576c | |||
| 4ff334aec4 | |||
| 7a9573c214 | |||
| 791b2d115f | |||
| 56f397bb2e | |||
| 05a36f857b | |||
| 3f8ae44149 | |||
| 690acf1154 | |||
| 15e0cb09b8 | |||
| 6b9caf8dc2 | |||
| 39977e4431 | |||
| 4210f5f753 | |||
| c79e0f36cc | |||
| 717786b1d8 | |||
| f98ab4a9fe | |||
| 59f19407cb | |||
| 8bf7cdd687 | |||
| e6ea72c458 | |||
| 4e38659a10 | |||
| 3b34ddbf12 | |||
| d1124ae17e | |||
| ddca698406 | |||
| 3120883bb8 | |||
| 2e11e3fd56 | |||
| ef21a5808e | |||
| e909f1f4c9 | |||
| 0e2b076535 | |||
| 7fb82c5eba | |||
| a9e9eed2f1 | |||
| 8bbe8f74c1 | |||
| c0a5912246 | |||
| 9cb794655b | |||
| 43aeb6047e | |||
| 00b163a8ec | |||
| d0c8d4c11b | |||
| ccc8866229 | |||
| 3e1e06d8e1 | |||
| e111031119 | |||
| a098a5ab20 | |||
| e2ebf5132d | |||
| ad1e203adf | |||
| abe08de769 | |||
| b1edb1b7e1 | |||
| 0f497ab822 | |||
| 715d79c9b5 | |||
| 2781a97d9d | |||
| 56f31c0aa2 | |||
| 450fa43e5d | |||
| 0f00b00b5e | |||
| 1664925607 | |||
| e6a5fd2dda | |||
| e783f35a4f | |||
| 0084235b57 | |||
| b4a23578f2 | |||
| 6142a9cb65 | |||
| e74dfb4953 | |||
| e6c99c946a | |||
| 25408797f1 | |||
| 4009a26431 | |||
| b53f8a3eee | |||
| 2752156473 | |||
| 5d0f2d4505 | |||
| 8e775e0429 | |||
| c4cf69c67f | |||
| 285191a8be | |||
| 00da22e594 | |||
| f071ac504d | |||
| 7547b0caec | |||
| efef09b625 | |||
| 1ceeb7e7e0 | |||
| e0cbe57071 | |||
| 9d120d7215 | |||
| be1d93fc09 | |||
| f0b1b57ae2 | |||
| f115be1fd3 | |||
| cf35e55da4 | |||
| 33fbf17c18 | |||
| 568d22ff1d | |||
| 63038cfaea | |||
| 43cc917861 | |||
| 8e4a9ae552 | |||
| 87bf7c3575 | |||
| c5e5e73ce1 | |||
| 9a7257008e | |||
| 5fa3fb8999 | |||
| d314aa04d7 | |||
| 5b65da6049 | |||
| dd58729607 | |||
| 5d57a5e597 | |||
| 5adf397a1e | |||
| 1eed84777a | |||
| c77f5fce0c | |||
| 0d9b8446ef | |||
| e085333a03 | |||
| 05ec1fd993 | |||
| 25bd077b25 | |||
| 1494db8e09 | |||
| 081bf9cc05 | |||
| 7e17ab8a1b | |||
| 35bfd0670c | |||
| f171049f60 | |||
| 5a7d302d11 | |||
| ba012736c9 | |||
| df40e6c2ca | |||
| c43d916680 | |||
| efd66ca80c | |||
| 93fe7a9b69 | |||
| 98026e510a | |||
| afdfe03174 | |||
| 658f29c655 | |||
| 24c4c2ebf6 | |||
| 2cd2125d62 | |||
| 772f8d85f5 | |||
| 3a1b2dfd6d | |||
| bd88f717fb | |||
| 45397f1780 | |||
| 7b965ac60d | |||
| c98ff34c1e | |||
| 471cdb9fa0 | |||
| d0c0bf7034 | |||
| f320d07e03 | |||
| 84fea5fcc9 | |||
| f32a8d990a | |||
| 6c7f3ef67f | |||
| 6273921005 | |||
| 7d1bfea8da | |||
| 961c9bd37c | |||
| b0fec20370 | |||
| 8f6925108c | |||
| 96dde3b50e | |||
| 505b75d6ce | |||
| 956cb8a8e3 | |||
| 721173aea3 | |||
| ec84243fb9 | |||
| e1054e684a | |||
| 58a2881fc8 | |||
| 4f4617ba0a | |||
| 26775693e7 | |||
| 139e5dbb06 | |||
| bbe3871b7c | |||
| 4389d4a40d | |||
| c3f9b974c3 | |||
| 39026344dc | |||
| 41e819735d | |||
| 12935f502f | |||
| c4b47a81a7 | |||
| 27e4f1efe0 | |||
| f66127da44 | |||
| fb4a144fcf | |||
| 3951735259 | |||
| a40b1ba137 | |||
| 555d3fa68f | |||
| f7625218ae | |||
| a5de604d85 | |||
| 11fd482378 | |||
| 524ae7c8cf | |||
| 98b69b180a | |||
| d354fb12dd | |||
| 2f5e4a40dd | |||
| 7a38e3588a | |||
| c2f9bc5994 | |||
| 89f77b8db4 | |||
| 2766b6463c | |||
| 2a6c826f50 | |||
| 09979f3bb5 | |||
| e6a1d14b09 | |||
| 2af1cf9cd8 | |||
| c3fd0582ad | |||
| 00f96150c5 | |||
| 92307a9f97 | |||
| a440f3d2b5 | |||
| afffacb529 | |||
| 137b0d8b3a | |||
| 876417c5ec | |||
| 35a93cbd9e | |||
| 9d136a337c | |||
| fc0a8b8ff5 | |||
| 8fc9f3ae1a | |||
| f1e577ea8a | |||
| 2fd8027a41 | |||
| 6407d0a29d | |||
| 573353421b | |||
| ac5b512a72 | |||
| 31fb1b3f28 | |||
| 49cb0bb42d | |||
| fb6c354317 | |||
| fee402145e | |||
| d1633331bc | |||
| 6e00160897 | |||
| 6bc53c6e5d | |||
| 7625a99020 | |||
| 3447a043ff | |||
| 10cf557b11 | |||
| eaf17c7a32 | |||
| 14526d6ce8 | |||
| 9a0d7ae444 | |||
| de5083ef18 | |||
| 2424b68532 | |||
| ff2a3da100 | |||
| 7040598e26 | |||
| 22f4ac1f26 | |||
| 3e5ff4ea8f | |||
| 39c7c1903c | |||
| eecaa48ec2 | |||
| d911af1225 | |||
| 38c6cefb27 | |||
| a3df2e00dc | |||
| fde69123cd | |||
| 2d963c16f3 | |||
| 071d749ef0 | |||
| 8d552a9279 | |||
| 9c5a08dc1e | |||
| ada5f274d3 | |||
| 78ea0326a5 | |||
| d3f444dbf6 | |||
| 8d26afdec0 | |||
| 5083b7c433 | |||
| 668f4d9bd3 | |||
| a4253c224a | |||
| 46effcd45f | |||
| 2a6d8748f4 | |||
| ca72072da0 | |||
| bf0fbe1b1d | |||
| 30e9513ce2 | |||
| aec9dd6a54 | |||
| 6222f60573 | |||
| a48a7bcca5 | |||
| 74a2d71644 | |||
| 9c239c0394 | |||
| 383dfe4d0e | |||
| 52f92d8650 | |||
| d9eea721ef | |||
| aa583e7440 | |||
| e00405df8a | |||
| 9bcfd9d1eb | |||
| 3fcda3f3cd | |||
| 345691ad71 | |||
| e864181c3c | |||
| 097971f107 | |||
| 90bc895000 | |||
| c0b20ed1bf | |||
| 5470da6b3a | |||
| a03b8095ee | |||
| 91896b4dad | |||
| 44638760b7 | |||
| c0b1edaed0 | |||
| 853a652e9b | |||
| c79619898a | |||
| 1c250906ed | |||
| ed8c898e0e | |||
| 57240e02ee | |||
| c26f89f134 | |||
| b3a63aa9af | |||
| 53268560ec | |||
| 68416b058e | |||
| 95c2258db0 | |||
| 8507afedd6 | |||
| 3e24080466 | |||
| 17ddd835e6 | |||
| 5228d2b3fd | |||
| 2f039abb2c | |||
| 522e35de7f | |||
| f8f5035225 | |||
| 23b5fafb25 | |||
| 2269aaefd7 | |||
| 9e3a4e2f45 | |||
| 8f52b1d80c | |||
| 1d9f0033a6 | |||
| 618bac8b57 | |||
| f2e90fbcda | |||
| 3cc2251b9b | |||
| c2fe8caddf | |||
| 784b27a954 | |||
| eef611adb4 | |||
| f56c604677 | |||
| 79b82a34cb | |||
| d83a0f0eef | |||
| 12d911f356 | |||
| be549daf50 | |||
| fb4a9d7e09 | |||
| 2e6349cbc6 | |||
| 4c68fe696b | |||
| b239ad131f | |||
| 651a93cacf | |||
| 5d784f78b9 | |||
| 725727d763 | |||
| 1a7d2a487f | |||
| 14141314d7 | |||
| 8609376c59 | |||
| 4d22191a2d | |||
| fb7e27d9ed | |||
| 7ff1d21b95 | |||
| 573768846e |
+43
-5
@@ -9,7 +9,7 @@
|
||||
########################################
|
||||
ENV=production
|
||||
LOG_LEVEL=INFO
|
||||
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
|
||||
POLYWEATHER_MAP_URL=https://polyweather.top/
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json
|
||||
@@ -19,9 +19,24 @@ UVICORN_WORKERS=1
|
||||
UID=1000
|
||||
GID=1000
|
||||
POLYWEATHER_STATE_STORAGE_MODE=sqlite
|
||||
# Realtime chart event store. Production should use Redis Stream; local/single-process
|
||||
# development can set POLYWEATHER_EVENT_STORE=sqlite.
|
||||
POLYWEATHER_EVENT_STORE=redis
|
||||
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-pro.vercel.app
|
||||
WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather.top,https://www.polyweather.top,https://api.polyweather.top
|
||||
|
||||
########################################
|
||||
# 2) Telegram bot minimal
|
||||
@@ -39,7 +54,7 @@ TELEGRAM_QUERY_TOPIC_CHAT_ID=
|
||||
TELEGRAM_QUERY_TOPIC_ID=
|
||||
TELEGRAM_QUERY_TOPIC_MAP=
|
||||
POLYWEATHER_BOT_GROUP_INVITE_URL=
|
||||
POLYWEATHER_APP_URL=https://polyweather-pro.vercel.app
|
||||
POLYWEATHER_APP_URL=https://polyweather.top
|
||||
# Global Telegram auto-push copy: both, en, or zh. Module-specific vars can override it.
|
||||
TELEGRAM_PUSH_LANGUAGE=both
|
||||
# High-frequency airport push loop. Keep workers at 1 on shared VPS.
|
||||
@@ -52,7 +67,7 @@ TELEGRAM_AIRPORT_PUSH_LANGUAGE=both
|
||||
POLYWEATHER_BOT_CPUS=0.75
|
||||
POLYWEATHER_BOT_MEM_LIMIT=768m
|
||||
POLYWEATHER_BOT_MEMSWAP_LIMIT=1g
|
||||
POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC=180
|
||||
POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC=60
|
||||
POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS=1
|
||||
|
||||
########################################
|
||||
@@ -68,6 +83,7 @@ OPEN_METEO_MIN_CALL_INTERVAL_SEC=5
|
||||
POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=1
|
||||
POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=600
|
||||
POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=45
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS=3000
|
||||
POLYWEATHER_HTTP_TIMEOUT_SEC=8
|
||||
POLYWEATHER_HTTP_RETRY_COUNT=0
|
||||
POLYWEATHER_HTTP_RETRY_BACKOFF_SEC=0.2
|
||||
@@ -116,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.
|
||||
@@ -154,6 +174,12 @@ POLYWEATHER_WEEKLY_REWARD_CHECK_INTERVAL_SEC=300
|
||||
POLYWEATHER_WEEKLY_REWARD_HTTP_TIMEOUT_SEC=10
|
||||
POLYWEATHER_WEEKLY_REWARD_ANNOUNCE_ENABLED=true
|
||||
|
||||
# Verified-user growth milestone rewards
|
||||
POLYWEATHER_GROWTH_REWARD_ENABLED=false
|
||||
POLYWEATHER_GROWTH_REWARD_CHECK_INTERVAL_SEC=21600
|
||||
POLYWEATHER_GROWTH_REWARD_HTTP_TIMEOUT_SEC=15
|
||||
POLYWEATHER_GROWTH_REWARD_ANNOUNCE_ENABLED=true
|
||||
|
||||
# Group message points
|
||||
POLYWEATHER_BOT_MESSAGE_POINTS=4
|
||||
POLYWEATHER_BOT_MESSAGE_DAILY_CAP=40
|
||||
@@ -164,13 +190,24 @@ POLYWEATHER_BOT_DEB_QUERY_COST=1
|
||||
|
||||
# Payments
|
||||
POLYWEATHER_PAYMENT_ENABLED=false
|
||||
# Default / legacy checkout chain. Keep Polygon as the default because the
|
||||
# deployed checkout contract currently lives there.
|
||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com
|
||||
# Optional multi-chain RPC map. Required when accepting non-default chain
|
||||
# transfers such as Ethereum mainnet USDC.
|
||||
# Example:
|
||||
# POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com"],"1":["https://ethereum-rpc.example"]}
|
||||
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
|
||||
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359
|
||||
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
|
||||
# Multi-token / multi-chain payment routes. Token rows can include:
|
||||
# chain_id, chain_code, chain_name, receiver_contract, direct_receiver_address,
|
||||
# supports_contract_checkout, supports_direct_transfer, confirmations,
|
||||
# explorer_tx_url.
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
|
||||
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
|
||||
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
|
||||
@@ -182,7 +219,8 @@ POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8}
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
|
||||
POLYMARKET_SIGNAL_EDGE_PCT=2
|
||||
|
||||
|
||||
@@ -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
|
||||
########################################
|
||||
@@ -34,11 +40,20 @@ METEOBLUE_API_KEY=
|
||||
# Wallet / payments
|
||||
########################################
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
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
|
||||
########################################
|
||||
|
||||
+69
-38
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt -r requirements-dev.txt
|
||||
pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.lock -r requirements-dev.lock
|
||||
|
||||
- name: Ruff
|
||||
run: python -m ruff check .
|
||||
@@ -73,9 +73,6 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -83,52 +80,86 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push backend
|
||||
if: matrix.image.name == 'backend'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.image.context }}
|
||||
file: ${{ matrix.image.file }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ matrix.image.tag }}:latest
|
||||
${{ matrix.image.tag }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Build and push image
|
||||
env:
|
||||
IMAGE_NAME: ${{ matrix.image.name }}
|
||||
IMAGE_CONTEXT: ${{ matrix.image.context }}
|
||||
IMAGE_FILE: ${{ matrix.image.file }}
|
||||
IMAGE_TAG: ${{ matrix.image.tag }}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
|
||||
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
|
||||
|
||||
- name: Build and push frontend
|
||||
if: matrix.image.name == 'frontend'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.image.context }}
|
||||
file: ${{ matrix.image.file }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ matrix.image.tag }}:latest
|
||||
${{ matrix.image.tag }}:${{ github.sha }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
NEXT_PUBLIC_SITE_URL=${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags=(-t "${IMAGE_TAG}:latest" -t "${IMAGE_TAG}:${GITHUB_SHA}")
|
||||
build_args=()
|
||||
|
||||
deploy:
|
||||
needs: [build-and-push]
|
||||
if [ "${IMAGE_NAME}" = "frontend" ]; then
|
||||
build_args=(
|
||||
--build-arg "NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}"
|
||||
--build-arg "NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}"
|
||||
--build-arg "NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}"
|
||||
--build-arg "NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL}"
|
||||
--build-arg "NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false"
|
||||
--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
|
||||
|
||||
docker build -f "${IMAGE_FILE}" "${tags[@]}" "${build_args[@]}" "${IMAGE_CONTEXT}"
|
||||
docker push "${IMAGE_TAG}:latest"
|
||||
docker push "${IMAGE_TAG}:${GITHUB_SHA}"
|
||||
|
||||
cloudflare-cache-rules:
|
||||
needs: [python-quality]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Apply Cloudflare cache rules
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
run: |
|
||||
if [ -z "${CLOUDFLARE_API_TOKEN}" ]; then
|
||||
echo "CLOUDFLARE_API_TOKEN is not configured; skipping Cache Rules sync"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "${CLOUDFLARE_ZONE_ID}" ]; then
|
||||
echo "CLOUDFLARE_ZONE_ID is not configured; skipping Cache Rules sync"
|
||||
exit 0
|
||||
fi
|
||||
python scripts/configure_cloudflare_free.py --apply
|
||||
|
||||
deploy:
|
||||
needs: [build-and-push]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: polyweather-production-deploy
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to VPS
|
||||
env:
|
||||
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
scp -o StrictHostKeyChecking=accept-new deploy.sh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/deploy.sh
|
||||
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
||||
bash /tmp/deploy.sh '${{ secrets.GHCR_PAT }}' '${{ github.sha }}'
|
||||
printf '%s\n' "$GHCR_PAT" | ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
||||
bash /tmp/deploy.sh '${{ github.sha }}'
|
||||
"
|
||||
|
||||
+8
-1
@@ -9,6 +9,8 @@ data/*.db
|
||||
data/*.db-*
|
||||
data/*.db.*
|
||||
data/*.json
|
||||
!data/city_thread_ids.json
|
||||
data/*backtest*.csv
|
||||
data/logs/
|
||||
data/historical/
|
||||
data/cache/
|
||||
@@ -42,6 +44,11 @@ Thumbs.db
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
frontend/.vercel/
|
||||
frontend/.env
|
||||
frontend/.env.local
|
||||
frontend/.env.production
|
||||
frontend/.env.*
|
||||
!frontend/.env.example
|
||||
frontend/*.tsbuildinfo
|
||||
frontend/.codex-next-dev*.log
|
||||
frontend/.codex-next-start*.log
|
||||
@@ -69,6 +76,6 @@ frontend/.next-start.log
|
||||
tmp_apikey.js
|
||||
tmp_obs.js
|
||||
tmp_rctp.html
|
||||
playwright-home-check.png
|
||||
.codex-backend-*.log
|
||||
frontend-next-*.log
|
||||
*.stackdump
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# PolyWeather Agent Instructions
|
||||
|
||||
## 语言和沟通
|
||||
|
||||
- 默认用中文回复用户。
|
||||
- 直接说明正在做什么、查到了什么、下一步是什么;不要写空泛客套话。
|
||||
- 如果用户要求“提交推送”“部署”“看日志”,必须在本地验证后再提交、推送,并检查 GitHub Actions 或线上状态。
|
||||
- 不要把多个不相关任务混在一个结论里;遇到新方向时,建议用户在同一个 Project 下开新 Thread。
|
||||
|
||||
## 项目和线程使用
|
||||
|
||||
- 一个 Project 对应 PolyWeather 这个共享代码库和长期方向。
|
||||
- 每个具体任务使用一条独立 Thread / Chat,例如:
|
||||
- 落地页与产品包装
|
||||
- 观测数据采集与 SSE patch
|
||||
- Telegram 推送
|
||||
- 付款与会员
|
||||
- 部署、CI、服务器状态
|
||||
- 同一个 Project 下的 Thread 共享文件夹和 `AGENTS.md`,但上下文分开,避免旧问题影响新任务判断。
|
||||
|
||||
## 代码工作原则
|
||||
|
||||
- 先读现有代码和配置,再改动;优先沿用项目已有模式。
|
||||
- 使用 `rg` / `rg --files` 查找文件和文本。
|
||||
- 手动编辑文件使用 `apply_patch`。
|
||||
- 不要回滚用户或其他 agent 已经做过的无关改动。
|
||||
- 只改和当前任务直接相关的文件,避免顺手重构。
|
||||
- 新增复杂逻辑时补充聚焦测试;窄改动保持验证范围匹配风险。
|
||||
|
||||
## 前端约定
|
||||
|
||||
- 前端位于 `frontend/`,使用 Next.js、React、TypeScript。
|
||||
- UI 修改必须关注移动端响应式、文本不重叠、按钮和标签不溢出。
|
||||
- 图表、终端、详情面板等工作界面应保持信息密度和可扫描性,避免营销式装饰。
|
||||
- 常用验证:
|
||||
- `cd frontend && npm run test:business`
|
||||
- `cd frontend && npm run typecheck`
|
||||
- 必要时启动本地预览并用浏览器检查桌面和移动视口;检查完关闭本地端口。
|
||||
|
||||
## 后端和数据约定
|
||||
|
||||
- Python 代码主要位于 `src/`、`web/`、`tests/`。
|
||||
- 观测数据刷新应以数据源原生频率为准,避免 Web、collector、Telegram 同时强刷同一外部源。
|
||||
- Telegram 默认只读最新缓存/DB;除非完全没有缓存,才允许兜底刷新。
|
||||
- 对外部源调用要考虑 singleflight、冷却、缓存和失败降级,避免 502/408 或 Supabase/磁盘 IO 压力。
|
||||
- 常用验证:
|
||||
- `python -m ruff check .`
|
||||
- `python -m pytest`
|
||||
|
||||
## CI、提交和部署
|
||||
|
||||
- `main` push 会触发 `.github/workflows/ci.yml`:
|
||||
- `python-quality`
|
||||
- `frontend-quality`
|
||||
- `build-and-push`
|
||||
- `deploy`
|
||||
- 提交前至少运行和改动相关的验证;推送前确认 `git status --short`。
|
||||
- 推送后检查 GitHub Actions 最新 run;如果失败,先定位失败 job 和 step,再修改。
|
||||
- 线上 smoke check 优先检查:
|
||||
- `https://api.polyweather.top/healthz`
|
||||
- `https://polyweather.top/`
|
||||
- 相关页面或 API 路径
|
||||
|
||||
## 产品方向备忘
|
||||
|
||||
- PolyWeather 当前重点不是售卖 API。
|
||||
- 核心差异化是结算源优先、实时观测源、跑道/城市细粒度温度、SSE patch、Telegram 缓存读取和面向交易/预测市场的解释能力。
|
||||
- 公开包装、教育内容、图表变量完整度和付费分层可以加强,但不要把产品定位改成通用天气 API。
|
||||
|
||||
## Memory 说明
|
||||
|
||||
- `AGENTS.md` 是项目内显式规则,跟随仓库和 Project。
|
||||
- Memory 是用户账号级偏好设置,agent 不能代替用户开启。
|
||||
- 建议在 Codex / ChatGPT 设置中开启 Memory,并保存长期偏好,例如:
|
||||
- PolyWeather 项目默认中文回复。
|
||||
- 每个任务开独立 Thread。
|
||||
- 修改后优先验证、提交、推送并检查部署状态。
|
||||
- 不要主动把 PolyWeather 包装成 API 售卖产品。
|
||||
+14
-1
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## 1.8.1 - 2026-05-28
|
||||
|
||||
### 文档与发布
|
||||
- README / README_ZH 改用 `frontend/public/static/web.png` 与 `frontend/public/static/tel.png` 作为产品截图,并移除旧 `docs/images` README 截图引用。
|
||||
- 同步版本源到 `1.8.1`,刷新 API、Supabase、技术债、PolygonScan 验证等文档标题版本。
|
||||
- 更新前端、实时事件、数据源、模型栈与服务文档,补齐 Redis Stream + SSE Patch、DEB hourly consensus、城市当地时间图表、legacy 高斯图表叠加、跑道/CoWIN 曲线和中英文 Telegram 推送口径。
|
||||
|
||||
### 当前线上口径确认
|
||||
- 生产实时层为“HTTP snapshot + SSE patch + replayable event store”;前端只消费 `/api/events`,不直接连接 Redis。
|
||||
- `deb_hourly_consensus.v1` 是峰值窗口与 DEB 曲线展示的优先小时路径;DEB 不作为实测来源。
|
||||
- AMSC/AMOS 跑道曲线和香港 CoWIN 6087 参考站曲线按城市当地时间展示,结算跑道高亮,辅助跑道弱化。
|
||||
|
||||
|
||||
## 1.8.0 - 2026-05-27
|
||||
|
||||
### 新增与重构
|
||||
@@ -36,7 +49,7 @@
|
||||
- 一键部署脚本:deploy.sh + deploy.ps1
|
||||
|
||||
### 移除
|
||||
- 删除 LGBM 全部代码和模型文件,EMOS 简化为纯 legacy 高斯分桶
|
||||
- 删除 LGBM 全部代码和模型文件,概率路径收口为 legacy 高斯分桶
|
||||
- 删除 Polymarket 价格拉取与 UI 层(MarketDecisionLine)
|
||||
- 删除 Groq、Meteoblue、NMC、俄罗斯 pogodaiklimat 数据源
|
||||
- 删除预热(prewarm)系统
|
||||
|
||||
@@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
PolyWeather Pro — a paid institutional weather-intelligence terminal. 50 monitored cities with real-time METAR/AMOS/MADIS observations, DEB multi-model temperature blending, Mu probability calibration, and intraday bias correction. Pure meteorological decision workspace; no market/price layer. Next.js 15 + React 19 (Vercel) frontend, FastAPI backend (VPS), Telegram bot.
|
||||
PolyWeather Pro — a paid institutional weather-intelligence terminal. 50 monitored cities with real-time METAR/AMOS/MADIS observations, DEB multi-model temperature blending, Mu probability calibration, and intraday bias correction. Pure meteorological decision workspace; no market/price layer. Next.js 15 + React 19 frontend (Docker / VPS, behind Cloudflare + Nginx), FastAPI backend (VPS), Telegram bot.
|
||||
|
||||
**Business model**: Paid-only, $10/month, no free tier, no trial. Landing page is public; `/terminal` requires login + active subscription.
|
||||
**Business model**: Paid-only, 29.9 USDC/month or 79.9 USDC/quarter, referral first month 20 USDC. New users get a one-time 3-day trial. Landing page is public; `/terminal` requires login + active subscription.
|
||||
|
||||
## Environment & Preferences
|
||||
|
||||
@@ -47,10 +47,12 @@ docker compose down && docker compose up -d --build
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Users → Next.js (Vercel) → FastAPI :8000 (VPS)
|
||||
/terminal (paid gate) Weather Collector
|
||||
/ (landing page) Analysis (DEB + Mu)
|
||||
Payment Layer (USDC on Polygon)
|
||||
Users → Cloudflare → Nginx → Docker Compose (VPS)
|
||||
├── Next.js frontend → FastAPI :8000
|
||||
│ /terminal (paid gate) Weather Collector
|
||||
│ / (landing page) Analysis (DEB + Mu)
|
||||
│ Payment Layer (USDC on Polygon)
|
||||
└── Redis (SSE event store)
|
||||
Telegram Bot → bot_listener.py
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -15,10 +15,10 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
gcc libhdf5-dev libnetcdf-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
COPY requirements.lock .
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --prefer-binary -r requirements.txt
|
||||
pip install --prefer-binary --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.lock
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -2,44 +2,51 @@
|
||||
|
||||
Production weather-intelligence stack for temperature settlement markets.
|
||||
|
||||
Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
|
||||
Official dashboard: [polyweather.top](https://polyweather.top/)
|
||||
中文说明: [README_ZH.md](README_ZH.md)
|
||||
|
||||
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday analysis, calibrated probability, model stack, TAF, settlement sources, history, and extension).
|
||||
Public docs center: `/docs/intro` on the main site (bilingual product documentation for the current terminal, chart reading, realtime source cadence, settlement stations, and the browser extension).
|
||||
|
||||
## Product Screenshots
|
||||
|
||||
### Global Dashboard
|
||||
### Realtime Terminal
|
||||
|
||||

|
||||

|
||||
|
||||
### City Analysis (Ankara)
|
||||
### Telegram Runway Alerts
|
||||
|
||||

|
||||

|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#yangyuan-zhen/PolyWeather&Date)
|
||||
|
||||
## Product Status (2026-05-23)
|
||||
## Product Status (2026-06-07)
|
||||
|
||||
- Subscription live: `Pro Monthly 10 USDC`.
|
||||
- Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards.
|
||||
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, max `3 USDC`).
|
||||
- Weekly leaderboard rewards restructured: smaller point bonuses for winners (200/100/50), all active users receive participation rewards.
|
||||
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e).
|
||||
- Subscription live: `Pro Monthly 29.9 USDC / 30 days` and `Pro Quarterly 79.9 USDC / 90 days`.
|
||||
- Referral pricing live: invited users can get the first monthly Pro at `20 USDC`; inviters receive `3500` points after a valid first Pro payment, capped at 10 paid invites per month.
|
||||
- Points are redeemable for payment discounts (`500 pts = 1 USDC`, monthly max `3 USDC`, quarterly max `8 USDC`). Useful user feedback can also receive manual point rewards through ops.
|
||||
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e) plus Ethereum mainnet USDC direct-transfer confirmation.
|
||||
- Auto-reconciliation live: event listener + periodic confirm loop.
|
||||
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
|
||||
- Ops dashboard live: `/ops` for memberships, leaderboard, user feedback triage, manual point grants, and payment incident triage.
|
||||
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
|
||||
- Realtime terminal live: visible city charts subscribe through `/api/events?cities=...&since_revision=...`, receive `city_observation_patch.v1` SSE patches, and replay short gaps from Redis Stream in production or SQLite fallback in local/single-node mode.
|
||||
- Chart refresh is observation-driven: live patches merge into the current chart without a loading overlay; only visible charts run a 60s no-patch fallback, and returning from a background browser tab triggers a foreground catch-up refresh.
|
||||
- Temperature charts default to All Day, keep an optional Peak window derived from the DEB hourly path, and render all timestamps in the selected city's local time.
|
||||
- The chart core has been split into focused logic/canvas/state modules; Recharts now receives explicit measured dimensions to avoid 0x0 rendering and disappearing curves.
|
||||
- DEB hourly consensus (`deb_hourly_consensus.v1`) is now the preferred hourly forecast path for peak-window detection and chart overlays; DEB remains a forecast curve, never an observation source.
|
||||
- Legacy Gaussian probability stays out of the default temperature chart surface; hover tooltips show `Gaussian μ` plus the full bucket distribution by temperature range.
|
||||
- Settlement runway curves are visible by default for AMSC/AMOS cities; the configured settlement runway is highlighted and auxiliary runways are shown as secondary context.
|
||||
- Hong Kong uses CoWIN station `6087` (Po Leung Kuk Choi Kai Yau School) as the 1-minute reference-station curve, with HKO 10-minute observations kept as the official meteorological layer.
|
||||
- Telegram airport/runway pushes are bilingual by default and use settlement-endpoint runway temperatures for slope/current/summary copy.
|
||||
- Runtime state, cache, and core offline training/backfill flows now use SQLite as the primary path; legacy JSON/JSONL files remain only for migration, export, and explicit fallback input.
|
||||
- EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout.
|
||||
- Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules.
|
||||
- Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives.
|
||||
- City decision cards now include the AI airport read: METAR, DEB, model cluster, and the AI expected-high center are resolved before mapping the result to temperature buckets.
|
||||
- AI airport reads now use in-page memory cache, browser `localStorage`, and backend short-TTL cache; returning from another dashboard tab restores existing stream text or final results before any new request is needed.
|
||||
- Terminal chart/detail workflow now combines settlement observations, DEB hourly consensus, model context, probability distribution tooltips, and market-bucket mapping without blocking the chart on AI text generation.
|
||||
- Terminal data uses page memory cache, browser `localStorage`, backend short-TTL cache, SSE patch replay, and foreground refresh so returning from another tab restores the latest visible chart state quickly.
|
||||
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
|
||||
- The card label “model-market difference” means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
|
||||
- Calibrated model probability is now the primary probability panel. It shows the active production probability engine (legacy Gaussian or EMOS), while model consensus remains a secondary reference.
|
||||
- The market-signal difference means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
|
||||
- Calibrated model probability is now the primary probability panel. It shows the active legacy Gaussian probability engine, while model consensus remains a secondary reference.
|
||||
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
|
||||
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
|
||||
- Trade cue now combines upper-air structure, `TAF`, market crowding, and `edge_percent`.
|
||||
@@ -62,10 +69,13 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
|
||||
- Aggregates observations and forecasts for 51 monitored cities.
|
||||
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
|
||||
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via legacy Gaussian or EMOS/CRPS calibration.
|
||||
- Adds city decision cards that combine AI airport reads, expected-high centers, full market-bucket mapping, and model-market difference in one view.
|
||||
- Builds a DEB-weighted hourly consensus path for peak-window logic and chart display.
|
||||
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via the legacy Gaussian calibration path.
|
||||
- Adds terminal chart/detail workflows that combine live observations, DEB-centered high-temperature context, market-bucket mapping, and model-market difference.
|
||||
- Shows calibrated Gaussian context in chart tooltips as `mu` plus the full temperature-range probability distribution, without reintroducing probability bands into the main temperature view.
|
||||
- Reuses one analysis core across web dashboard and Telegram bot.
|
||||
- Adds payment audit trails, replay tooling, and incident visibility in ops.
|
||||
- Adds an in-app feedback loop with chart context, user-visible feedback status, ops triage, and manual point rewards for useful reports and suggestions.
|
||||
- Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules.
|
||||
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
|
||||
- Adds official nearby-network and runway-level enhancement layers for China, Japan, Korea (AMOS runway sensors for Seoul/Busan), Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
|
||||
@@ -74,7 +84,7 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["Users (Web / Telegram)"] --> FE["Next.js Frontend (Vercel)"]
|
||||
U["Users (Web / Telegram)"] --> FE["Next.js Frontend (Docker / VPS)"]
|
||||
U --> BOT["Telegram Bot (VPS)"]
|
||||
FE --> API["FastAPI /web/app.py"]
|
||||
BOT --> API
|
||||
@@ -88,8 +98,11 @@ flowchart LR
|
||||
WX --> AMOS["AMOS runway sensors (Korea)"]
|
||||
WX --> HKO["HKO / CWA / NOAA / Official settlement sources"]
|
||||
|
||||
API --> ANA["DEB + Trend + Probability + Market Scan"]
|
||||
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
|
||||
API --> ANA["DEB + Hourly Consensus + Probability + Market Scan"]
|
||||
API --> SSE["SSE /api/events"]
|
||||
WX --> SSE
|
||||
SSE --> EVENT["Redis Stream / SQLite Event Log"]
|
||||
ANA --> PAY["Payment State (Multi-chain Intent + Event + Confirm Loop)"]
|
||||
ANA --> STATE["SQLite runtime state"]
|
||||
```
|
||||
|
||||
@@ -118,13 +131,17 @@ npm run dev
|
||||
|
||||
## Recent Highlights
|
||||
|
||||
- Gaussian probability tooltip now lists the full temperature-range distribution instead of only the highest-probability bucket, while the main chart remains focused on observations and forecasts.
|
||||
- User feedback is now a product loop: terminal submissions attach chart context, users can track status in-app, and ops can reward useful feedback with points.
|
||||
- Airport-linked contracts use the METAR / airport primary observing site as the settlement anchor. Wunderground pages are reference/history pages, not stations.
|
||||
- Taipei and Shenzhen retain their explicitly configured station history pages for reconciliation, but the docs avoid describing Wunderground itself as a physical station.
|
||||
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
|
||||
- Intraday analysis now separates meteorology conclusion, evidence chain, invalidation rules, confirmation rules, calibrated probability, and market reference.
|
||||
- `TAF` is used as an airport-side confirmation layer, not as the main temperature model.
|
||||
- Calibrated probability uses legacy Gaussian (default) or EMOS/CRPS when evaluated; model vote counts remain an explanatory consensus line, not the final probability.
|
||||
- Calibrated probability uses the legacy Gaussian path; model vote counts remain an explanatory consensus line, not the final probability.
|
||||
- Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience.
|
||||
- Realtime terminal charts use SSE patches plus replayable event storage; full HTTP detail remains the authoritative snapshot.
|
||||
- Chart observations are shown in the city's local time, not the browser timezone.
|
||||
|
||||
## Runtime Data (Recommended on VPS)
|
||||
|
||||
@@ -134,20 +151,13 @@ Use external runtime storage to avoid SQLite/git conflicts:
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
POLYWEATHER_STATE_STORAGE_MODE=sqlite
|
||||
POLYWEATHER_EVENT_STORE=redis
|
||||
POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
|
||||
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
|
||||
POLYWEATHER_REDIS_REQUIRED=true
|
||||
```
|
||||
|
||||
## EMOS Local Training
|
||||
|
||||
Do not run full EMOS retraining on a small VPS. The VPS should collect data and load approved calibration files; training should run on a local/dev machine using a copied production SQLite database:
|
||||
|
||||
```powershell
|
||||
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
|
||||
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
|
||||
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
|
||||
```
|
||||
|
||||
Promote a generated `default.json` only when `auto_retrain_report.json` has `ready_for_promotion=true`, and prefer `emos_shadow` before enabling `emos_primary`.
|
||||
For local development or a strict single-process fallback, keep `POLYWEATHER_EVENT_STORE=sqlite`.
|
||||
|
||||
## Ops Verification
|
||||
|
||||
@@ -162,7 +172,7 @@ curl http://127.0.0.1:8000/metrics
|
||||
### Frontend cache headers
|
||||
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather.top"
|
||||
```
|
||||
|
||||
### Payment auto-reconciliation logs
|
||||
@@ -177,12 +187,14 @@ docker compose logs -f polyweather | egrep "payment event loop started|payment c
|
||||
curl http://127.0.0.1:8000/api/payments/runtime
|
||||
```
|
||||
|
||||
### Payment chains
|
||||
|
||||
Production payment routes are configured by the backend. Polygon remains the default checkout-contract chain, while Ethereum mainnet USDC can be enabled as a direct-transfer route so users who pay on their wallet default network are still confirmed by `intent.chain_id`.
|
||||
|
||||
## Telegram Commands
|
||||
|
||||
| Command | Purpose |
|
||||
| :-- | :-- |
|
||||
| `/city <name>` | City real-time analysis |
|
||||
| `/deb <name>` | DEB historical reconciliation |
|
||||
| `/top` | User leaderboard |
|
||||
| `/id` | Show current chat ID |
|
||||
| `/diag` | Startup diagnostics |
|
||||
@@ -214,5 +226,5 @@ curl http://127.0.0.1:8000/api/payments/runtime
|
||||
|
||||
## Version
|
||||
|
||||
- Version: `v1.8.0`
|
||||
- Last Updated: `2026-05-23`
|
||||
- Version: `v1.8.1`
|
||||
- Last Updated: `2026-06-07`
|
||||
|
||||
+53
-48
@@ -2,32 +2,40 @@
|
||||
|
||||
面向温度结算市场的生产级气象情报系统。
|
||||
|
||||
官方看板:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
|
||||
官方看板:[polyweather.top](https://polyweather.top/)
|
||||
|
||||
## 产品截图
|
||||
|
||||
### 全球看板
|
||||
### 实时终端
|
||||
|
||||

|
||||

|
||||
|
||||
### 城市分析(Ankara)
|
||||
### Telegram 跑道推送
|
||||
|
||||

|
||||

|
||||
|
||||
## 当前产品状态(2026-05-23)
|
||||
## 当前产品状态(2026-06-07)
|
||||
|
||||
- 已上线订阅制:`Pro 月付 10 USDC`。
|
||||
- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖。
|
||||
- `/city` 与 `/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,最多抵 `3 USDC`)。
|
||||
- 周榜奖励已改造:降低赢家积分加成 (200/100/50),所有周活跃用户均享参与奖。
|
||||
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。
|
||||
- 已上线订阅制:`Pro 月付 29.9 USDC / 30 天`,`Pro 季度 79.9 USDC / 90 天`。
|
||||
- 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分。
|
||||
- 积分可用于支付抵扣(`500 分 = 1 USDC`,月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。真实、有上下文、有价值的用户反馈也可通过运营后台人工奖励积分。
|
||||
- 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励。
|
||||
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)+ Ethereum 主网 USDC 直转确认。
|
||||
- 已上线自动补单:事件监听 + 周期确认双链路。
|
||||
- 已上线支付运行态与审计接口:`/api/payments/runtime`。
|
||||
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。
|
||||
- 已上线轻量运营后台:`/ops`(会员、用户反馈处理、积分、补分、支付异常单)。
|
||||
- 已上线轻量可观测性:`/healthz`、`/api/system/status`、`/metrics`。
|
||||
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。
|
||||
- 实时终端已切换到可重放事件流:可见城市图表通过 `/api/events?cities=...&since_revision=...` 订阅 `city_observation_patch.v1`,生产环境使用 Redis Stream 做短窗口 replay,本地/单进程可回退 SQLite event log。
|
||||
- 图表刷新由实测事件驱动:SSE patch 直接合并到当前曲线,不弹 loading 遮罩;只有可见图表启用 60 秒无 patch 兜底,浏览器后台返回前台时会主动补齐最新 detail。
|
||||
- 城市图表默认展示“全天”,可选“高温”窗口由 DEB hourly path 推导;所有图表横轴都按城市当地时间展示,不按用户浏览器时区。
|
||||
- 核心图表组件已拆分为逻辑、状态与 canvas 渲染模块;Recharts 使用 `ResizeObserver` 后的明确宽高,规避 0x0 渲染和长时间挂页后曲线消失。
|
||||
- DEB hourly consensus(`deb_hourly_consensus.v1`)已作为峰值窗口和图表 DEB 曲线的优先小时路径;DEB 仍然是预测曲线,不作为实测来源。
|
||||
- legacy 高斯概率不再占用默认温度图主视图;hover tooltip 会展示 `Gaussian μ` 和完整温度区间概率分布。
|
||||
- AMSC/AMOS 城市的结算跑道曲线默认展示并高亮,辅助跑道作为弱化曲线保留;釜山单跑道只展示 `SR/SL` 结算跑道,不再重复显示 AMOS 聚合线。
|
||||
- 香港默认展示 CoWIN `6087`(保良局陈守仁小学)1 分钟参考站曲线,HKO 10 分钟实测保留为官方气象层。
|
||||
- Telegram 机场/跑道推送默认中英文双语,并统一使用结算端点跑道温度计算当前值、15 分钟趋势和文案。
|
||||
- 运行态状态、缓存与核心离线训练/回填链路已完成 SQLite 主路径收口;legacy JSON/JSONL 仅保留给迁移、导出与显式回退输入。
|
||||
- EMOS/CRPS 校准链路已接通,但生产主概率保持 `legacy` 或 `emos_shadow`;`emos_primary` 只在本地离线评估通过并手动灰度后启用。
|
||||
- 官方增强站网已统一接入:
|
||||
- `MGM`(土耳其)
|
||||
- `CMA/NMC`(中国内地)
|
||||
@@ -37,15 +45,15 @@
|
||||
- `CWA`(台湾)
|
||||
- 东京现已接入羽田 `JMA AMeDAS` 10 分钟温度作为官方增强层。
|
||||
- 已支持 Dashboard 定向预热 worker / cron 路径,运行态在 `/api/system/status` 与 `/ops` 可见。
|
||||
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与 prewarm heartbeat。
|
||||
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与运行态 heartbeat。
|
||||
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
|
||||
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
|
||||
- 城市决策卡已接入 AI 机场报文解读:先用 METAR、DEB、多模型集群和 AI 最高温中枢判断天气路径,再映射到温度桶。
|
||||
- AI 机场报文解读现在同时使用页面内存缓存、浏览器 `localStorage` 和后端短 TTL 缓存;从其他选项卡切回决策卡时会优先恢复已有流式内容或最终结果。
|
||||
- 终端图表/详情工作流已改为结构化实况 + DEB hourly consensus + 多模型集群 + 概率分布 tooltip + 市场温度桶,不再让图表等待 AI 文案生成。
|
||||
- 终端数据同时使用页面内存缓存、浏览器 `localStorage`、后端短 TTL 缓存、SSE patch replay 和前台恢复刷新;从其他选项卡切回时会优先恢复最新可见图表状态。
|
||||
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
|
||||
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
||||
- 概率区已改为”校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS),模型共识作为辅助参考。
|
||||
- 今日日内结构解读已支持可选 Groq 改写层,失败时自动回退规则文案。
|
||||
- 市场信号中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
||||
- 概率区已改为“校准模型概率”;默认展示 legacy 高斯概率引擎输出,模型共识作为辅助参考。
|
||||
- 今日日内结构解读以规则与结构化信号为主,AI 文案只作为可降级辅助层,不替代实测、DEB、TAF 或结算逻辑。
|
||||
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`。
|
||||
|
||||
## 许可证与商用边界(重要)
|
||||
@@ -62,17 +70,20 @@
|
||||
|
||||
- 聚合 51 个监控城市的实测与预报数据。
|
||||
- DEB(Dynamic Error Balancing)融合多模型最高温。
|
||||
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。
|
||||
- 地图城市决策卡把 AI 机场报文解读、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示。
|
||||
- 构建 DEB 加权小时共识曲线,用于峰值窗口判断和图表默认 DEB 展示。
|
||||
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯校准路径。
|
||||
- 天气决策台把结构化实况、DEB 高温路径、完整市场温度桶和模型-市场差放进图表/详情工作流。
|
||||
- 图表 tooltip 展示校准高斯上下文:`mu` 加完整温度区间概率分布,不把概率温度带重新放回主图。
|
||||
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
|
||||
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
|
||||
- 已上线站内反馈闭环:提交反馈时自动附带图表上下文,用户可查看处理状态,运营后台可为有价值反馈人工奖励积分。
|
||||
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
|
||||
|
||||
## 参考架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["用户(Web / Telegram)"] --> FE["Next.js 前端(Vercel)"]
|
||||
U["用户(Web / Telegram)"] --> FE["Next.js 前端(Docker / VPS)"]
|
||||
U --> BOT["Telegram Bot(VPS)"]
|
||||
FE --> API["FastAPI /web/app.py"]
|
||||
BOT --> API
|
||||
@@ -85,7 +96,10 @@ flowchart LR
|
||||
WX --> OM["Open-Meteo"]
|
||||
WX --> HKO["HKO / CWA / NOAA 等官方结算源"]
|
||||
|
||||
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
|
||||
API --> ANA["DEB + 小时共识 + 概率 + 市场扫描"]
|
||||
API --> SSE["SSE /api/events"]
|
||||
WX --> SSE
|
||||
SSE --> EVENT["Redis Stream / SQLite Event Log"]
|
||||
ANA --> PAY["支付状态(Intent + Event + Confirm Loop)"]
|
||||
ANA --> STATE["SQLite runtime state<br/>legacy files only for migration/export fallback"]
|
||||
```
|
||||
@@ -113,6 +127,11 @@ npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 近期更新
|
||||
|
||||
- 高斯概率 tooltip 已改为展示完整温度区间概率分布,不再只显示最高概率的单个区间;主图继续聚焦实测和预测曲线。
|
||||
- 用户反馈已形成产品闭环:终端提交会自动附带图表上下文,用户可在站内查看处理状态,运营侧可为真实、有建设性的反馈发放积分奖励。
|
||||
|
||||
## 运行数据目录(VPS 推荐)
|
||||
|
||||
建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住):
|
||||
@@ -121,25 +140,13 @@ npm run dev
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
POLYWEATHER_STATE_STORAGE_MODE=sqlite
|
||||
POLYWEATHER_EVENT_STORE=redis
|
||||
POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
|
||||
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
|
||||
POLYWEATHER_REDIS_REQUIRED=true
|
||||
```
|
||||
|
||||
## EMOS 本地训练流程
|
||||
|
||||
低配 VPS 只负责采集、服务和加载已通过评估的参数,不建议在 VPS 上跑 EMOS 全量训练。训练前先从 VPS 拉 SQLite 副本到本地:
|
||||
|
||||
```powershell
|
||||
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
|
||||
```
|
||||
|
||||
本地训练:
|
||||
|
||||
```powershell
|
||||
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
|
||||
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
|
||||
```
|
||||
|
||||
只有 `auto_retrain_report.json` 中 `ready_for_promotion=true` 时,才允许把候选 `default.json` 传回 VPS,并优先以 `emos_shadow` 观察。
|
||||
本地开发或严格单进程兜底可使用 `POLYWEATHER_EVENT_STORE=sqlite`。
|
||||
|
||||
## 运维验收
|
||||
|
||||
@@ -154,7 +161,7 @@ curl http://127.0.0.1:8000/metrics
|
||||
### 外部监控栈
|
||||
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather.top"
|
||||
```
|
||||
|
||||
### 支付自动补单日志
|
||||
@@ -187,7 +194,7 @@ curl http://127.0.0.1:8000/api/payments/runtime
|
||||
|
||||
### 运营后台
|
||||
|
||||
- 前端入口:`https://polyweather-pro.vercel.app/ops`
|
||||
- 前端入口:`https://polyweather.top/ops`
|
||||
- 后端需配置:
|
||||
|
||||
```env
|
||||
@@ -198,8 +205,6 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
|
||||
| 指令 | 用途 |
|
||||
| :-- | :-- |
|
||||
| `/city <name>` | 城市实时分析 |
|
||||
| `/deb <name>` | DEB 历史对账 |
|
||||
| `/top` | 用户积分排行 |
|
||||
| `/id` | 查看聊天 Chat ID |
|
||||
| `/diag` | Bot 启动诊断 |
|
||||
@@ -213,7 +218,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
- AGPL-3.0 边界:[docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
|
||||
- Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
||||
- 配置与密钥管理:[docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
|
||||
- 前端部署(Vercel):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
- 前端部署(Docker / VPS):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
- 技术债:[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- 机场实时数据源:[docs/AIRPORT_REALTIME_SOURCES.md](docs/AIRPORT_REALTIME_SOURCES.md)
|
||||
- 机场市场监控(中文):[docs/AIRPORT_MARKET_MONITOR_ZH.md](docs/AIRPORT_MARKET_MONITOR_ZH.md)
|
||||
@@ -223,12 +228,12 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
|
||||
- 模型栈与 DEB:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- DEB 模型家族去重规则:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
|
||||
- 发布流程:[RELEASE.md](RELEASE.md)
|
||||
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## 当前版本
|
||||
|
||||
- 版本:`v1.8.0`
|
||||
- 文档最后更新:`2026-05-23`
|
||||
- 版本:`v1.8.1`
|
||||
- 文档最后更新:`2026-06-07`
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@ cities:
|
||||
- id: new_york
|
||||
city: New York
|
||||
country: USA
|
||||
latitude: 40.7128
|
||||
longitude: -74.006
|
||||
latitude: 40.7769
|
||||
longitude: -73.874
|
||||
- id: chicago
|
||||
city: Chicago
|
||||
country: USA
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"amsterdam": 116398,
|
||||
"ankara": 116394,
|
||||
"atlanta": 13115,
|
||||
"austin": 13120,
|
||||
"beijing": 116383,
|
||||
"busan": 116381,
|
||||
"chengdu": 116388,
|
||||
"chicago": 13113,
|
||||
"chongqing": 116389,
|
||||
"dallas": 13119,
|
||||
"denver": 13114,
|
||||
"guangzhou": 116385,
|
||||
"helsinki": 116397,
|
||||
"hong kong": 116391,
|
||||
"houston": 13118,
|
||||
"istanbul": 116395,
|
||||
"los angeles": 13112,
|
||||
"miami": 13116,
|
||||
"new york": 13111,
|
||||
"paris": 116399,
|
||||
"qingdao": 116387,
|
||||
"san francisco": 13117,
|
||||
"seattle": 13122,
|
||||
"seoul": 116380,
|
||||
"shanghai": 116384,
|
||||
"shenzhen": 116386,
|
||||
"singapore": 116393,
|
||||
"taipei": 116392,
|
||||
"tel aviv": 116396,
|
||||
"tokyo": 116382,
|
||||
"wuhan": 116390
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
$VPS = "root@38.54.27.70"
|
||||
$VPS = "root@172.245.214.111"
|
||||
$PROJECT = "/root/PolyWeather"
|
||||
|
||||
Write-Host "🚀 Deploying to $VPS..." -ForegroundColor Cyan
|
||||
|
||||
@@ -1,52 +1,391 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
GHCR_PAT="$1"
|
||||
NEW_TAG="${2:-latest}"
|
||||
NEW_TAG="${1:-latest}"
|
||||
TAG_FILE="/var/lib/polyweather/.current_tag"
|
||||
COMPOSE_DIR="/root/PolyWeather"
|
||||
LOCK_FILE="${POLYWEATHER_DEPLOY_LOCK_FILE:-/var/lock/polyweather-deploy.lock}"
|
||||
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
|
||||
GHCR_PAT=""
|
||||
if ! IFS= read -r GHCR_PAT && [ -z "$GHCR_PAT" ]; then
|
||||
echo "❌ GHCR token must be provided on stdin"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$GHCR_PAT" ]; then
|
||||
echo "❌ GHCR token must be provided on stdin"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$LOCK_FILE")"
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
echo "❌ Another PolyWeather deploy is already running"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s' "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
|
||||
unset GHCR_PAT
|
||||
|
||||
cd "$COMPOSE_DIR"
|
||||
git fetch origin main && git reset --hard origin/main
|
||||
|
||||
sync_city_thread_ids() {
|
||||
local runtime_dir="${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}"
|
||||
local repo_file="$COMPOSE_DIR/data/city_thread_ids.json"
|
||||
local target_file="$runtime_dir/city_thread_ids.json"
|
||||
|
||||
if [ ! -f "$repo_file" ]; then
|
||||
echo "No repository city_thread_ids.json to sync"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$runtime_dir"
|
||||
REPO_CITY_THREAD_IDS_FILE="$repo_file" TARGET_CITY_THREAD_IDS_FILE="$target_file" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
repo_file = os.environ["REPO_CITY_THREAD_IDS_FILE"]
|
||||
target_file = os.environ["TARGET_CITY_THREAD_IDS_FILE"]
|
||||
|
||||
with open(repo_file, "r", encoding="utf-8") as f:
|
||||
repo_data = json.load(f)
|
||||
if not isinstance(repo_data, dict):
|
||||
raise SystemExit("repository city_thread_ids.json must contain an object")
|
||||
|
||||
target_data = {}
|
||||
if os.path.isfile(target_file) and os.path.getsize(target_file) > 0:
|
||||
try:
|
||||
with open(target_file, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, dict):
|
||||
target_data = loaded
|
||||
else:
|
||||
raise ValueError("target file is not an object")
|
||||
except Exception as exc:
|
||||
backup = f"{target_file}.invalid.{int(time.time())}"
|
||||
os.replace(target_file, backup)
|
||||
print(f"Backed up invalid city_thread_ids.json to {backup}: {exc}")
|
||||
|
||||
merged = dict(repo_data)
|
||||
merged.update(target_data)
|
||||
|
||||
if merged != target_data:
|
||||
tmp_file = f"{target_file}.tmp"
|
||||
with open(tmp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(merged, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.replace(tmp_file, target_file)
|
||||
print(f"Synced city_thread_ids.json: {len(target_data)} -> {len(merged)} cities")
|
||||
else:
|
||||
print(f"city_thread_ids.json already up to date: {len(target_data)} cities")
|
||||
PY
|
||||
}
|
||||
|
||||
sync_city_thread_ids
|
||||
|
||||
PREVIOUS_TAG=""
|
||||
if [ -f "$TAG_FILE" ]; then
|
||||
PREVIOUS_TAG=$(cat "$TAG_FILE")
|
||||
echo "Previous tag: $PREVIOUS_TAG"
|
||||
fi
|
||||
|
||||
export IMAGE_TAG="$NEW_TAG"
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# Wait for backend to be ready (retry up to 150s)
|
||||
echo "Waiting for backend..."
|
||||
for i in $(seq 1 30); do
|
||||
sleep 5
|
||||
if curl -fsSo /dev/null --max-time 5 "https://api.polyweather.top/healthz"; then
|
||||
echo "✅ healthz ready after ${i}x5s"
|
||||
break
|
||||
fi
|
||||
echo " retry $i/30..."
|
||||
done
|
||||
|
||||
FAILED=0
|
||||
curl -fsSo /dev/null --max-time 15 "https://api.polyweather.top/healthz" && echo "✅ healthz" || { echo "❌ healthz"; FAILED=1; }
|
||||
curl -fsSo /dev/null --max-time 10 "https://api.polyweather.top/api/cities" && echo "✅ cities" || { echo "❌ cities"; FAILED=1; }
|
||||
curl -fsSo /dev/null --max-time 10 "https://www.polyweather.top/" && echo "✅ frontend" || { echo "❌ frontend"; FAILED=1; }
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo "❌ Smoke tests failed. Rolling back..."
|
||||
rollback_to_previous() {
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
echo "Rolling back to $PREVIOUS_TAG..."
|
||||
export IMAGE_TAG="$PREVIOUS_TAG"
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
compose_up_retry "rollback" -d
|
||||
echo "✅ Rolled back to $PREVIOUS_TAG"
|
||||
else
|
||||
echo "⚠️ No previous tag to rollback to"
|
||||
fi
|
||||
}
|
||||
|
||||
compose_up_retry() {
|
||||
local name="$1"
|
||||
shift
|
||||
local output=""
|
||||
|
||||
for attempt in $(seq 1 6); do
|
||||
if output=$(docker compose up "$@" 2>&1); then
|
||||
echo "$output"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "$output"
|
||||
if echo "$output" | grep -qi "removal of container .* is already in progress"; then
|
||||
echo "Container removal is still in progress during ${name}; retry ${attempt}/6..."
|
||||
sleep 5
|
||||
continue
|
||||
fi
|
||||
|
||||
return 1
|
||||
done
|
||||
|
||||
echo "❌ docker compose up failed for ${name} after retries"
|
||||
return 1
|
||||
}
|
||||
|
||||
read_env_file_value() {
|
||||
local key="$1"
|
||||
if [ ! -f ".env" ]; then
|
||||
return 0
|
||||
fi
|
||||
awk -F= -v key="$key" '
|
||||
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
|
||||
value=$0
|
||||
sub("^[^=]*=", "", value)
|
||||
gsub("^[[:space:]]+|[[:space:]]+$", "", value)
|
||||
gsub(/^["'"'"']|["'"'"']$/, "", value)
|
||||
print value
|
||||
}
|
||||
' .env | tail -n 1
|
||||
}
|
||||
|
||||
resolve_env_value() {
|
||||
local primary_key="$1"
|
||||
local fallback_key="${2:-}"
|
||||
local value="${!primary_key:-}"
|
||||
|
||||
if [ -z "$value" ]; then
|
||||
value="$(read_env_file_value "$primary_key")"
|
||||
fi
|
||||
if [ -z "$value" ] && [ -n "$fallback_key" ]; then
|
||||
value="${!fallback_key:-}"
|
||||
if [ -z "$value" ]; then
|
||||
value="$(read_env_file_value "$fallback_key")"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
export IMAGE_TAG="$NEW_TAG"
|
||||
export POLYWEATHER_API_BASE_URL="${POLYWEATHER_FRONTEND_INTERNAL_API_BASE_URL:-http://polyweather_web:8000}"
|
||||
resolved_supabase_url="$(resolve_env_value "SUPABASE_URL" "NEXT_PUBLIC_SUPABASE_URL")"
|
||||
resolved_supabase_anon_key="$(resolve_env_value "SUPABASE_ANON_KEY" "NEXT_PUBLIC_SUPABASE_ANON_KEY")"
|
||||
if [ -n "$resolved_supabase_url" ]; then
|
||||
export SUPABASE_URL="$resolved_supabase_url"
|
||||
else
|
||||
unset SUPABASE_URL
|
||||
fi
|
||||
if [ -n "$resolved_supabase_anon_key" ]; then
|
||||
export SUPABASE_ANON_KEY="$resolved_supabase_anon_key"
|
||||
else
|
||||
unset SUPABASE_ANON_KEY
|
||||
fi
|
||||
pull_ok=0
|
||||
for pull_attempt in $(seq 1 6); do
|
||||
docker compose pull && pull_ok=1 && break
|
||||
echo "Image pull failed or tag not ready, retry ${pull_attempt}/6..."
|
||||
sleep 10
|
||||
done
|
||||
if [ "$pull_ok" != "1" ]; then
|
||||
echo "❌ Image pull failed after retries"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
smoke_check() {
|
||||
local name="$1"
|
||||
local url="$2"
|
||||
local timeout="$3"
|
||||
local attempts="${4:-6}"
|
||||
local delay="${5:-5}"
|
||||
local output=""
|
||||
|
||||
for i in $(seq 1 "$attempts"); do
|
||||
if output=$(curl -fsS -w "http=%{http_code} time=%{time_total}" -o /dev/null --max-time "$timeout" "$url" 2>&1); then
|
||||
echo "✅ $name ($output)"
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" != "$attempts" ]; then
|
||||
echo " $name retry $i/$attempts... ($output)"
|
||||
sleep "$delay"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "❌ $name ($output)"
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_local_service() {
|
||||
local name="$1"
|
||||
local url="$2"
|
||||
local timeout="${3:-5}"
|
||||
local attempts="${4:-30}"
|
||||
local delay="${5:-2}"
|
||||
|
||||
for i in $(seq 1 "$attempts"); do
|
||||
if curl -fsSo /dev/null --max-time "$timeout" "$url"; then
|
||||
echo "✅ $name ready after attempt $i/$attempts"
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" != "$attempts" ]; then
|
||||
echo " $name warming $i/$attempts..."
|
||||
sleep "$delay"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "❌ $name did not become ready"
|
||||
return 1
|
||||
}
|
||||
|
||||
warm_public_route() {
|
||||
local name="$1"
|
||||
local url="$2"
|
||||
local timeout="${3:-15}"
|
||||
local attempts="${4:-3}"
|
||||
local delay="${5:-2}"
|
||||
|
||||
for i in $(seq 1 "$attempts"); do
|
||||
if curl -fsSo /dev/null --max-time "$timeout" "$url"; then
|
||||
echo "✅ warmed $name"
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" != "$attempts" ]; then
|
||||
echo " warm $name retry $i/$attempts..."
|
||||
sleep "$delay"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "⚠️ warm $name failed"
|
||||
return 0
|
||||
}
|
||||
|
||||
wait_for_scan_terminal_snapshot() {
|
||||
local name="$1"
|
||||
local url="$2"
|
||||
local timeout="${3:-35}"
|
||||
local attempts="${4:-8}"
|
||||
local delay="${5:-5}"
|
||||
local output=""
|
||||
local body=""
|
||||
local compact=""
|
||||
local http_status=""
|
||||
local status=""
|
||||
|
||||
for i in $(seq 1 "$attempts"); do
|
||||
if output=$(curl -sS -w "\nhttp=%{http_code}" --max-time "$timeout" "$url" 2>&1); then
|
||||
http_status="$(printf '%s\n' "$output" | sed -n 's/^http=//p' | tail -n 1)"
|
||||
body="$(printf '%s\n' "$output" | sed '$d')"
|
||||
if [ "$http_status" = "401" ]; then
|
||||
echo "✅ $name protected after attempt $i/$attempts (http=401)"
|
||||
return 0
|
||||
fi
|
||||
compact="$(printf '%s' "$body" | tr -d '\n\r\t ')"
|
||||
if printf '%s' "$compact" | grep -q '"status":"ready"'; then
|
||||
echo "✅ $name ready after attempt $i/$attempts"
|
||||
return 0
|
||||
fi
|
||||
status="$(printf '%s' "$compact" | sed -n 's/.*"status":"\([^"]*\)".*/\1/p' | head -n 1)"
|
||||
echo " $name not ready attempt $i/$attempts http=${http_status:-unknown} status=${status:-unknown}"
|
||||
else
|
||||
echo " $name request failed attempt $i/$attempts ($output)"
|
||||
fi
|
||||
if [ "$i" != "$attempts" ]; then
|
||||
sleep "$delay"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "❌ $name did not return status=ready or http=401"
|
||||
return 1
|
||||
}
|
||||
|
||||
validate_frontend_api_base_url() {
|
||||
local api_base="${POLYWEATHER_API_BASE_URL:-}"
|
||||
if [ -z "$api_base" ]; then
|
||||
api_base="$(read_env_file_value "POLYWEATHER_API_BASE_URL")"
|
||||
fi
|
||||
local normalized
|
||||
normalized="$(printf '%s' "$api_base" | tr '[:upper:]' '[:lower:]' | sed 's/[[:space:]]//g; s#/*$##')"
|
||||
case "$normalized" in
|
||||
http://polyweather.top|https://polyweather.top|http://www.polyweather.top|https://www.polyweather.top)
|
||||
echo "❌ POLYWEATHER_API_BASE_URL must not point at the frontend site: $api_base"
|
||||
echo " Use the internal backend URL http://polyweather_web:8000 or the backend API host https://api.polyweather.top."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
PUBLIC_SMOKE_RECHECK_DELAY_SEC="${POLYWEATHER_PUBLIC_SMOKE_RECHECK_DELAY_SEC:-20}"
|
||||
|
||||
run_public_smoke_checks() {
|
||||
local phase="${1:-initial}"
|
||||
local failed=0
|
||||
|
||||
if [ "$phase" = "recheck" ]; then
|
||||
smoke_check "healthz recheck" "https://api.polyweather.top/healthz" 20 6 10 || failed=1
|
||||
smoke_check "frontend cities recheck" "https://polyweather.top/api/cities" 30 8 10 || failed=1
|
||||
smoke_check "frontend recheck" "https://www.polyweather.top/" 20 6 10 || failed=1
|
||||
else
|
||||
smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || failed=1
|
||||
smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5 || failed=1
|
||||
smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || failed=1
|
||||
fi
|
||||
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
validate_frontend_api_base_url
|
||||
|
||||
echo "Updating Redis dependency..."
|
||||
compose_up_retry "redis" -d polyweather_redis
|
||||
|
||||
echo "Updating backend services..."
|
||||
compose_up_retry "backend services" -d --no-deps polyweather_web polyweather
|
||||
|
||||
echo "Waiting for backend..."
|
||||
wait_for_local_service "backend healthz" "http://127.0.0.1:8000/healthz" 5 30 5 || FAILED_BACKEND=1
|
||||
FAILED_BACKEND="${FAILED_BACKEND:-0}"
|
||||
if [ "$FAILED_BACKEND" = "1" ]; then
|
||||
echo "❌ Backend did not become healthy"
|
||||
rollback_to_previous
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updating observation collector..."
|
||||
compose_up_retry "observation collector" -d --no-deps polyweather_collector
|
||||
|
||||
echo "Updating cache warmer..."
|
||||
compose_up_retry "cache warmer" -d --no-deps polyweather_warmer
|
||||
|
||||
echo "Updating training settlement worker..."
|
||||
compose_up_retry "training settlement" -d --no-deps polyweather_training_settlement
|
||||
|
||||
echo "Updating frontend..."
|
||||
compose_up_retry "frontend" -d --no-deps polyweather_frontend
|
||||
|
||||
echo "Waiting for frontend..."
|
||||
wait_for_local_service "frontend root" "http://127.0.0.1:3001/" 5 40 2 || FAILED_FRONTEND=1
|
||||
wait_for_local_service "frontend terminal" "http://127.0.0.1:3001/terminal" 10 20 2 || FAILED_FRONTEND=1
|
||||
wait_for_scan_terminal_snapshot "scan terminal snapshot" "http://127.0.0.1:3001/api/scan/terminal" 35 8 5 || FAILED_FRONTEND=1
|
||||
FAILED_FRONTEND="${FAILED_FRONTEND:-0}"
|
||||
if [ "$FAILED_FRONTEND" = "1" ]; then
|
||||
echo "❌ Frontend did not become healthy"
|
||||
rollback_to_previous
|
||||
exit 1
|
||||
fi
|
||||
|
||||
warm_public_route "terminal" "https://polyweather.top/terminal" 20 4 3
|
||||
warm_public_route "scan terminal" "https://polyweather.top/api/scan/terminal" 35 3 2
|
||||
warm_public_route "auth snapshot" "https://polyweather.top/api/auth/me?prefer_snapshot=1" 10 3 2
|
||||
warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?refresh_deb_recent=1" 15 2 2
|
||||
warm_public_route "cities" "https://polyweather.top/api/cities" 20 3 2
|
||||
|
||||
FAILED=0
|
||||
run_public_smoke_checks || FAILED=1
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo "⚠️ Initial public smoke failed; retrying before rollback..."
|
||||
sleep "$PUBLIC_SMOKE_RECHECK_DELAY_SEC"
|
||||
FAILED=0
|
||||
run_public_smoke_checks "recheck" || FAILED=1
|
||||
fi
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo "❌ Smoke tests failed. Rolling back..."
|
||||
rollback_to_previous
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -9,6 +9,19 @@ server {
|
||||
proxy_buffers 8 16k;
|
||||
proxy_busy_buffers_size 32k;
|
||||
|
||||
location /api/events {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_set_header Connection '';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
+219
-8
@@ -1,6 +1,24 @@
|
||||
services:
|
||||
polyweather_redis:
|
||||
image: redis:7-alpine
|
||||
container_name: polyweather_redis
|
||||
command: redis-server --appendonly yes --maxmemory ${POLYWEATHER_REDIS_MAXMEMORY:-512mb} --maxmemory-policy noeviction
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
interval: 10s
|
||||
retries: 5
|
||||
test:
|
||||
- CMD
|
||||
- redis-cli
|
||||
- ping
|
||||
timeout: 5s
|
||||
volumes:
|
||||
- polyweather_redis_data:/data
|
||||
polyweather:
|
||||
container_name: polyweather_bot
|
||||
depends_on:
|
||||
polyweather_redis:
|
||||
condition: service_healthy
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
@@ -10,7 +28,12 @@ services:
|
||||
env_file: &id001
|
||||
- .env
|
||||
environment:
|
||||
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC: ${POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC:-180}
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: bot
|
||||
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC: ${POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC:-60}
|
||||
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS: ${POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS:-1}
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
@@ -42,27 +65,38 @@ services:
|
||||
environment:
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL:-}
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS: 'false'
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS:-polyweather.top,www.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_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||
POLYWEATHER_API_BASE_URL: ${POLYWEATHER_API_BASE_URL:-http://polyweather_web:8000}
|
||||
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}
|
||||
POLYWEATHER_API_BASE_URL: http://polyweather_web:8000
|
||||
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
|
||||
test:
|
||||
- CMD
|
||||
- wget
|
||||
- -qO-
|
||||
- http://localhost:3000
|
||||
- CMD-SHELL
|
||||
- wget -qO- http://$(hostname):3000
|
||||
timeout: 5s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}
|
||||
ports:
|
||||
- 3001:3000
|
||||
- "127.0.0.1:3001:3000"
|
||||
restart: unless-stopped
|
||||
polyweather_web:
|
||||
command: python web/app.py
|
||||
depends_on:
|
||||
polyweather_redis:
|
||||
condition: service_healthy
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
@@ -70,6 +104,41 @@ services:
|
||||
max-file: "3"
|
||||
container_name: polyweather_web
|
||||
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}
|
||||
POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_ENABLED: ${POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_ENABLED:-true}
|
||||
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-3}
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-3}
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS:-3000}
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-8000}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-5}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
|
||||
POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC: '30'
|
||||
POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS: ${POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS:-4}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: ${POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED:-false}
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN: ${POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN}
|
||||
POLYWEATHER_SERVICE_ROLE: web
|
||||
SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
|
||||
SUPABASE_URL: ${SUPABASE_URL}
|
||||
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
retries: 3
|
||||
@@ -81,7 +150,147 @@ services:
|
||||
timeout: 5s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
ports:
|
||||
- 8000:8000
|
||||
- "127.0.0.1:8000:8000"
|
||||
restart: unless-stopped
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
polyweather_collector:
|
||||
command: python -m web.observation_collector_worker
|
||||
container_name: polyweather_collector
|
||||
depends_on:
|
||||
polyweather_redis:
|
||||
condition: service_healthy
|
||||
polyweather_web:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
cpus: ${POLYWEATHER_COLLECTOR_CPUS:-0.75}
|
||||
env_file: *id001
|
||||
environment:
|
||||
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ${POLYWEATHER_COLLECTOR_PATCH_ENDPOINT:-http://polyweather_web:8000/api/internal/collector-patch}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-15}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_CACHE_REFRESH_WORKERS: ${POLYWEATHER_OBSERVATION_COLLECTOR_CACHE_REFRESH_WORKERS:-2}
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: collector
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
|
||||
c.execute('SELECT 1'); c.close()
|
||||
timeout: 10s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
mem_limit: ${POLYWEATHER_COLLECTOR_MEM_LIMIT:-768m}
|
||||
memswap_limit: ${POLYWEATHER_COLLECTOR_MEMSWAP_LIMIT:-1g}
|
||||
pids_limit: 256
|
||||
restart: unless-stopped
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
polyweather_warmer:
|
||||
command: python -m web.cache_warmer_worker
|
||||
container_name: polyweather_warmer
|
||||
depends_on:
|
||||
polyweather_redis:
|
||||
condition: service_healthy
|
||||
polyweather_web:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
cpus: ${POLYWEATHER_WARMER_CPUS:-0.75}
|
||||
env_file: *id001
|
||||
environment:
|
||||
POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: warmer
|
||||
POLYWEATHER_WARMER_CITY_BATCH_SIZE: ${POLYWEATHER_WARMER_CITY_BATCH_SIZE:-16}
|
||||
POLYWEATHER_WARMER_CITY_INTERVAL_SEC: ${POLYWEATHER_WARMER_CITY_INTERVAL_SEC:-30}
|
||||
POLYWEATHER_WARMER_ENABLED: ${POLYWEATHER_WARMER_ENABLED:-true}
|
||||
POLYWEATHER_WARMER_SCAN_INTERVAL_SEC: ${POLYWEATHER_WARMER_SCAN_INTERVAL_SEC:-120}
|
||||
POLYWEATHER_WARMER_TICK_SEC: ${POLYWEATHER_WARMER_TICK_SEC:-30}
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
|
||||
c.execute('SELECT 1'); c.close()
|
||||
timeout: 10s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
mem_limit: ${POLYWEATHER_WARMER_MEM_LIMIT:-768m}
|
||||
memswap_limit: ${POLYWEATHER_WARMER_MEMSWAP_LIMIT:-1g}
|
||||
pids_limit: 256
|
||||
restart: unless-stopped
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
polyweather_training_settlement:
|
||||
command: python -m web.training_settlement_worker
|
||||
container_name: polyweather_training_settlement
|
||||
depends_on:
|
||||
polyweather_redis:
|
||||
condition: service_healthy
|
||||
polyweather_web:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
cpus: ${POLYWEATHER_TRAINING_SETTLEMENT_CPUS:-0.50}
|
||||
env_file: *id001
|
||||
environment:
|
||||
POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: training_settlement
|
||||
POLYWEATHER_TRAINING_SETTLEMENT_INITIAL_DELAY_SEC: ${POLYWEATHER_TRAINING_SETTLEMENT_INITIAL_DELAY_SEC:-60}
|
||||
POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC: ${POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC:-21600}
|
||||
POLYWEATHER_TRAINING_SETTLEMENT_LOOKBACK_DAYS: ${POLYWEATHER_TRAINING_SETTLEMENT_LOOKBACK_DAYS:-10}
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
|
||||
c.execute('SELECT 1'); c.close()
|
||||
timeout: 10s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
mem_limit: ${POLYWEATHER_TRAINING_SETTLEMENT_MEM_LIMIT:-768m}
|
||||
memswap_limit: ${POLYWEATHER_TRAINING_SETTLEMENT_MEMSWAP_LIMIT:-1g}
|
||||
pids_limit: 256
|
||||
restart: unless-stopped
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
@@ -90,3 +299,5 @@ services:
|
||||
x-polyweather-base:
|
||||
env_file: *id001
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
volumes:
|
||||
polyweather_redis_data:
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
# 机场高频实时数据源
|
||||
|
||||
最后更新:`2026-05-28`
|
||||
|
||||
## 已接入城市
|
||||
|
||||
| 城市 | 机场 | ICAO/站点 | 数据源 | 频率 | 类型 | 费用 |
|
||||
|------|------|-----------|--------|------|------|------|
|
||||
| 首尔 | 仁川国际 | RKSI | AMOS (`global.amo.go.kr`) | 1 分钟 | 跑道对温度(2对) | 免费 |
|
||||
| 釜山 | 金海国际 | RKPK | AMOS (`global.amo.go.kr`) | 1 分钟 | 跑道对温度(1对) | 免费 |
|
||||
| 香港 | CoWIN 6087 | 6087 | CoWIN (`cowin.hku.hk`) | 1 分钟 | 参考站温度(保良局陈守仁小学) | 免费 |
|
||||
| 香港 | HKO | HKO | HKO 官方 CSV (`data.weather.gov.hk`) | 10 分钟 | 官方气象站温度 | 免费 |
|
||||
| 台北 | 松山/中央气象署 | 466920 | CWA 开放数据 | 10 分钟 | 官方站点温度 | 免费 |
|
||||
| 北京 | 首都机场 | ZBAA | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 上海 | 浦东机场 | ZSPD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 广州 | 白云机场 | ZGGG | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 成都 | 双流机场 | ZUUU | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 重庆 | 江北机场 | ZUCK | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 武汉 | 天河机场 | ZHHH | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 青岛 | 胶东机场 | ZSQD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 东京 | 羽田 | RJTT | JMA AMeDAS (`jma.go.jp`) | 10 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 安卡拉 | Esenboğa | 17128 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 伊斯坦布尔 | 伊斯坦布尔机场 | 17058 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
|
||||
@@ -36,22 +48,41 @@
|
||||
> 后端通过 `weather_sources.py` 拉取并注入 `airport_primary`,前端市场监控通过
|
||||
> `resolveMonitorTemperature` 优先读取 `airport_primary.temp` 获得小数精度温度。
|
||||
|
||||
## 推送机制
|
||||
> **CoWIN 6087**: 香港图表默认参考站为 HKU CoWIN `6087`(保良局陈守仁小学)。
|
||||
> 该源提供约 1 分钟温度序列,作为 PM 最高温市场的高频参考曲线;HKO 10 分钟数据
|
||||
> 仍作为官方气象层保留。后端通过 `cowin_sources.py` 拉取并写入 `cowin_obs`。
|
||||
|
||||
> **AMSC AWOS**: 中国内地跑道城市读取 AMSC `getWindPlate` 中的 `TDZ_TEMP` /
|
||||
> `MID_TEMP` / `END_TEMP`。这些字段是跑道观测位置气温,不是道面温度。
|
||||
> 结算跑道展示使用配置的结算端点;辅助跑道只作为背景曲线。
|
||||
|
||||
## 独立观测采集器
|
||||
|
||||
- Web/API 进程启动 `observation-collector` 后台线程,按源频率独立采集,不依赖 Telegram 推送循环
|
||||
- 默认频率:AMOS 60s、AMSC AWOS 180s、MADIS HFMETAR 300s、CoWIN 60s、HKO 600s
|
||||
- 每次采集复用 `weather_sources.py` 现有 `_attach_*` 写入逻辑,负责写 `airport_obs_log` / `runway_obs_log` / 今日观测缓存,并通过 `/api/internal/collector-patch` 写 Redis Stream 或 SQLite event log 后广播 SSE
|
||||
- 采集成功后刷新对应城市 `panel` cache;前端继续使用 HTTP snapshot + SSE patch,不需要依赖 Telegram 触发更新
|
||||
- `observation_source_gate.py` 对 AMSC、AMOS、MADIS、HKO、CoWIN 做 per-source/per-city singleflight 和 SQLite cooldown,防止 Web 请求、collector 和兜底分析同时打同一个外部源
|
||||
|
||||
## Telegram 推送机制
|
||||
|
||||
- 每城按原生频率独立推送,不捆绑
|
||||
- 首尔/釜山 60s,其余 600s
|
||||
- 首尔/釜山 60s,中国 AMSC 城市 180s,其余 600s
|
||||
- 循环轮询 60s 以匹配最快频率
|
||||
- Telegram 推送优先读取网站侧 `full`/`panel` 城市缓存;缓存缺失时只做非强制 `panel` 分析兜底,不触发 `force_refresh_observations_only`
|
||||
- 仅当当前温度距 DEB 预测最高 ≤3°C 时推送
|
||||
- 确认过峰值后自动停止
|
||||
|
||||
## 前端实时同步与 SSE Patch 机制
|
||||
## 前端实时同步与 SSE Patch / Redis Stream 机制
|
||||
|
||||
为了向用户提供秒级实况响应并降低服务器负载,系统已从定时轮询架构全面迁移至 **Server-Sent Events (SSE) 增量更新(SSE Patch)** 架构。
|
||||
为了向用户提供接近行情盘的实况响应并降低服务器负载,系统使用 **HTTP snapshot + Server-Sent Events (SSE) Patch + 可重放事件日志** 架构。生产环境推荐 Redis Stream;本地或单进程可回退 SQLite event log。
|
||||
|
||||
### 1. 数据推送链路 (Data Pipeline)
|
||||
1. **Collector 采集端触发**:在 `weather_sources.py` 中,当高频实况源(如 AMOS, CoWIN, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
|
||||
2. **FastAPI SSE 广播**:FastAPI 后端的 `sse_router.py` 接收到 Patch 后,将其推入 `sse_manager` 进行全局广播,事件被包装为 `city_patch` 增量包,包含自增的全局 `revision` 和最新的 `changes`。
|
||||
3. **BFF 代理流**:浏览器前端通过 BFF (Next.js rewrites) 建立与 `/api/events` 的持久连接,从而无需定时轮询。
|
||||
1. **Observation Collector 采集端触发**:`web.observation_collector_service` 按源频率调用采集层;在 `weather_sources.py` 中,当高频实况源(如 AMOS, AMSC, CoWIN, HKO, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
|
||||
2. **标准化事件**:`realtime_patch_schema.py` 将旧 `city_patch` 或新 payload 统一成 `city_observation_patch.v1`。
|
||||
3. **事件存储**:生产环境写入 Redis Stream(`stream:city_observation`)并生成全局递增 `revision`;SQLite `observation_patch_events` 保留为本地/兜底 replay。
|
||||
4. **FastAPI SSE 广播**:FastAPI 后端的 `sse_router.py` 根据城市订阅集合向匹配连接推送 patch;断线重连时按 `since_revision` replay。
|
||||
5. **BFF 代理流**:浏览器前端通过 BFF 建立与 `/api/events` 的持久连接,从而无需固定整图轮询。
|
||||
|
||||
### 2. 前端消费与刷新规则 (Frontend Freshness Rules)
|
||||
- **扫描列表免轮询更新**:`use-scan-terminal-query.ts` 通过 `useSsePatchVersion` 钩子订阅全局 SSE 版本。当有任何城市产生更新时,列表将触发按需重绘,之前固定的 5 分钟 `setInterval` 定时轮询已被彻底禁用。
|
||||
@@ -62,6 +93,8 @@
|
||||
1. 调用轻量级的 `/api/city/{city}/summary` 快速拉取最新实况温度。
|
||||
2. 调用 `fetchHourlyForecastForCity(city, { ignoreCache: true })` 强刷完整的城市详情数据,确保数据一致性。
|
||||
- **按需加载与 Stagger 优化**:在加载城市详情时,前端会优先加载 Active 状态的图表,而处于 Background/非活动状态的图表则通过 staggered timer (按槽位索引延迟 300ms~1500ms) 异步获取,以分流请求峰值。
|
||||
- **前台恢复补齐**:浏览器标签页长时间在后台时,回来后会主动强刷可见图表 full detail,避免 SSE 被浏览器挂起后曲线落后。
|
||||
- **当地时间**:patch 中保留 `city_timezone` / `observed_at_utc`,前端按城市当地时间绘制横轴。
|
||||
|
||||
## 消息模板
|
||||
|
||||
@@ -83,6 +116,11 @@ Seoul / Incheon 16:03
|
||||
| `TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC` | 循环轮询间隔 | `60` |
|
||||
| `TELEGRAM_AIRPORT_PUSH_LANGUAGE` | 机场推送语言覆盖,可选 `both`/`en`/`zh` | `both` |
|
||||
| `KNMI_API_KEY` | KNMI API 密钥(阿姆斯特丹必填) | — |
|
||||
| `POLYWEATHER_EVENT_STORE` | 实时事件存储,可选 `redis`/`sqlite` | `sqlite` |
|
||||
| `POLYWEATHER_REDIS_URL` | Redis Stream 连接地址 | `redis://127.0.0.1:6379/0` |
|
||||
| `POLYWEATHER_REDIS_STREAM_KEY` | Redis Stream key | `stream:city_observation` |
|
||||
| `POLYWEATHER_REDIS_STREAM_MAXLEN` | Redis Stream 保留长度 | `50000` |
|
||||
| `POLYWEATHER_REDIS_REQUIRED` | Redis 不可用时是否启动失败 | `true` |
|
||||
|
||||
## 未接入城市
|
||||
|
||||
|
||||
+54
-41
@@ -1,13 +1,13 @@
|
||||
# PolyWeather API 文档(v1.8.0)
|
||||
# PolyWeather API 文档(v1.8.1)
|
||||
|
||||
最后更新:`2026-04-27`
|
||||
最后更新:`2026-05-28`
|
||||
|
||||
本文档描述当前对外可用 API 口径(`web/app.py` + `web/routes.py` + `frontend/app/api/*`)。
|
||||
|
||||
## 1. 基础信息
|
||||
|
||||
- 后端直连:`http://127.0.0.1:8000`
|
||||
- 前端 BFF:`https://polyweather-pro.vercel.app/api/*`
|
||||
- 前端 BFF:`https://polyweather.top/api/*`
|
||||
- 返回格式:`application/json`
|
||||
|
||||
## 2. 请求链路
|
||||
@@ -17,7 +17,9 @@ flowchart LR
|
||||
FE["Browser / Dashboard"] --> BFF["Next.js Route Handlers (/api/*)"]
|
||||
BFF --> API["FastAPI (/web/app.py + /web/routes.py)"]
|
||||
API --> WX["Weather Collector"]
|
||||
API --> ANA["DEB + Trend + Probability + Market Scan"]
|
||||
API --> ANA["DEB + Hourly Consensus + Probability + Market Scan"]
|
||||
API --> SSE["Realtime SSE (/api/events)"]
|
||||
SSE --> EVENT["Redis Stream / SQLite Event Log"]
|
||||
API --> PAY["Payment Intent + Event + Confirm Loops"]
|
||||
API --> OBS["healthz / system status / metrics"]
|
||||
```
|
||||
@@ -31,8 +33,27 @@ flowchart LR
|
||||
| `/api/city/{name}/summary` | GET | 轻量摘要 |
|
||||
| `/api/city/{name}/detail` | GET | 聚合详情(含 market_scan) |
|
||||
| `/api/history/{name}` | GET | 历史对账 |
|
||||
| `/api/scan/terminal/ai-city` | POST | 城市决策卡 AI 解读(非流式 JSON) |
|
||||
| `/api/scan/terminal/ai-city/stream` | POST | 城市决策卡 AI 解读(SSE 流式) |
|
||||
| `/api/events` | GET | SSE 实时观测事件流 |
|
||||
| `/api/internal/collector-patch` | POST | 采集器内部写入实时观测 patch |
|
||||
|
||||
### `GET /api/events`
|
||||
|
||||
浏览器实时图表入口,使用 `text/event-stream`。
|
||||
|
||||
参数:
|
||||
|
||||
- `cities=shanghai,hong kong`:可选,逗号分隔城市列表;为空表示订阅全部城市。
|
||||
- `since_revision=<int>`:可选,断线重连后从指定 revision 之后 replay。
|
||||
- `replay_limit=<int>`:可选,默认 `500`,后端会做上限保护。
|
||||
|
||||
事件:
|
||||
|
||||
- `connected`:连接建立,包含当前 `latest_revision`。
|
||||
- `city_observation_patch.v1`:标准实时观测 patch,包含城市、序列、温度、观测 UTC 时间、城市时区、revision。
|
||||
- `resync_required`:replay 窗口不足或事件存储不可用,前端应回到 HTTP snapshot 重建画面。
|
||||
- `heartbeat`:保活。
|
||||
|
||||
生产环境推荐 `POLYWEATHER_EVENT_STORE=redis`,以 Redis Stream 保存短窗口事件并支持多 worker fanout;本地或单进程可使用 SQLite event log。
|
||||
|
||||
### `GET /api/city/{name}/detail`
|
||||
|
||||
@@ -53,6 +74,7 @@ flowchart LR
|
||||
- `probabilities.engine / calibration_mode / calibration_version`
|
||||
- `probabilities.raw_mu / raw_sigma / calibrated_mu / calibrated_sigma`
|
||||
- `probabilities.shadow_distribution`
|
||||
- `deb.hourly_consensus / deb.hourly_path.base_source`
|
||||
- `intraday_meteorology.headline / confidence`
|
||||
- `intraday_meteorology.base_case_bucket / upside_bucket / downside_bucket`
|
||||
- `intraday_meteorology.next_observation_time`
|
||||
@@ -61,40 +83,6 @@ flowchart LR
|
||||
- `vertical_profile_signal.heating_setup / suppression_risk / trigger_risk / mixing_strength`
|
||||
- `taf.signal.peak_window / suppression_level / disruption_level / markers`
|
||||
|
||||
### `POST /api/scan/terminal/ai-city/stream`
|
||||
|
||||
城市决策卡使用该接口生成“AI 机场报文解读”。前端默认请求 SSE 流,流式展示机场报文解读片段,最终以 `final` 事件返回完整 payload。
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"city": "Buenos Aires",
|
||||
"force_refresh": false,
|
||||
"locale": "zh-CN"
|
||||
}
|
||||
```
|
||||
|
||||
SSE 事件:
|
||||
|
||||
- `progress`:阶段性状态,例如开始调用 AI、切换非流式重试。
|
||||
- `preview`:可显示的预览文本。
|
||||
- `delta`:模型流式文本增量,前端会从中提取机场报文解读片段。
|
||||
- `final`:完整 `AiCityForecastPayload`。
|
||||
|
||||
重点字段:
|
||||
|
||||
- `status`:通常为 `ready`;超时或降级时会带 `reason / reason_zh / reason_en`。
|
||||
- `cached`:是否命中后端 AI 缓存。
|
||||
- `degraded`:是否为降级结果。前端不会把 degraded 结果写入长期 localStorage,但会保留页面内存态,避免切换选项卡后空白。
|
||||
- `city_forecast.predicted_max`:AI 给出的预计最高温中枢候选。
|
||||
- `city_forecast.range_low / range_high`:AI 给出的天气区间。
|
||||
- `city_forecast.final_judgment_zh / final_judgment_en`:最终判断。
|
||||
- `city_forecast.metar_read_zh / metar_read_en`:机场报文解读。
|
||||
- `city_forecast.reasoning_zh / reasoning_en`:把 METAR、DEB、多模型集群与日内风险合并后的推理。
|
||||
- `city_forecast.model_cluster_note_zh / model_cluster_note_en`:模型集群说明。
|
||||
- `city_forecast.risks_zh / risks_en`:后续上修或下修触发条件。
|
||||
|
||||
### 城市决策卡市场层口径
|
||||
|
||||
- 前端会请求完整 `market_scan` / `all_buckets`,而不是只取 lite 结果。
|
||||
@@ -126,7 +114,7 @@ SSE 事件:
|
||||
|
||||
#### 2. `probabilities`
|
||||
|
||||
概率层基于 legacy 高斯分桶,以 DEB 融合预测 μ 和 ensemble spread σ 生成 1°C 粒度概率分布。
|
||||
概率层基于 legacy 高斯分桶,以 DEB 融合预测 `mu` 和 ensemble spread `sigma` 生成 1°C 粒度概率分布。前端图表将 legacy 高斯展示为水平概率温度带和 `mu` 参考线,不把概率分布渲染成时间序列曲线。
|
||||
|
||||
概率字段:
|
||||
|
||||
@@ -135,6 +123,22 @@ SSE 事件:
|
||||
- `distribution`:当天合约桶概率分布
|
||||
- `distribution_all`:包含外围桶的完整分布
|
||||
|
||||
#### 2.1 `deb.hourly_consensus`
|
||||
|
||||
DEB hourly consensus 是当前图表与峰值窗口的优先小时路径。
|
||||
|
||||
重点字段:
|
||||
|
||||
- `version`:当前为 `deb_hourly_consensus.v1`
|
||||
- `base_source`:`multi_model_hourly_deb_weights`
|
||||
- `times` / `temps`:城市当地日的小时路径
|
||||
- `model_weights`:DEB 权重折叠后的模型权重
|
||||
|
||||
说明:
|
||||
|
||||
- 该路径是预测曲线,不是实测来源。
|
||||
- 图表默认展示全天;“高温”视图仅根据该路径推导 peak-centric 窗口。
|
||||
|
||||
#### 3. `detail_depth`
|
||||
|
||||
`detail_depth` 用于区分轻量 detail 与完整 detail。前端如果发现:
|
||||
@@ -234,6 +238,13 @@ SSE 事件:
|
||||
|
||||
### 支付状态建议
|
||||
|
||||
`POST /api/payments/intents` 支持的关键字段:
|
||||
|
||||
- `plan_code`:套餐,例如 `pro_monthly`
|
||||
- `payment_mode`:`wallet` 或 `direct`
|
||||
- `chain_id`:可选;多链支付时前端传用户选择的链,例如 Polygon `137` 或 Ethereum `1`
|
||||
- `token_address`:可选;指定该链上的 USDC / USDC.e 合约地址
|
||||
|
||||
前端流程建议:
|
||||
|
||||
1. `POST /intents`
|
||||
@@ -291,6 +302,8 @@ SSE 事件:
|
||||
- `summary?force_refresh=true`:`Cache-Control: no-store`
|
||||
- 详情接口与支付接口:`no-store`
|
||||
- `METAR` / `TAF` / settlement current 由后端各自维护短 TTL 缓存
|
||||
- 实时事件层:`POLYWEATHER_EVENT_STORE=redis` 时使用 Redis Stream;未启用 Redis 时使用 SQLite `observation_patch_events` 作为 replay fallback
|
||||
- 前端终端图表:HTTP detail 仍是完整 snapshot,SSE patch 只做增量观测追加;长连接断开或后台恢复时前端会按 `since_revision` replay 或强刷 detail
|
||||
- 前端打开今日日内分析时,如果 full detail 或 market scan 正在同步,会先显示刷新锁,不展示可交互的旧内容
|
||||
- 城市决策卡 AI 解读前端缓存键为 `city + local_date + locale + METAR signature`;signature 优先使用原始 METAR,缺失时回退到报文时间、观测时间和温度
|
||||
- 城市决策卡 AI 解读使用两层前端缓存:页面内存缓存保存 loading / 流式进度 / 最终 payload,`localStorage` 保存最终成功 payload,默认 TTL 1 小时
|
||||
|
||||
+41
-11
@@ -1,6 +1,6 @@
|
||||
# 城市实时数据源总览
|
||||
|
||||
> 最后更新: 2026-05-26 | 51 城市
|
||||
> 最后更新: 2026-05-28 | 51 城市
|
||||
|
||||
## 数据源分级
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
| hong kong | CoWIN 6087 | ~1 min | cowin.hku.hk, 保良局陳守仁小學,前端图表默认展示 |
|
||||
| hong kong | HKO 官方 CSV | ~10 min | data.weather.gov.hk(文件名虽含 1min,实际 10min 一报) |
|
||||
| singapore | MSS 官方 API | ~1 min | api.data.gov.sg, 站号 S24 |
|
||||
| beijing | AMSC AWOS (ZBAA) | ~1 min | 中国 |
|
||||
| shanghai | AMSC AWOS (ZSPD) | ~1 min | 中国 |
|
||||
| guangzhou | AMSC AWOS (ZGGG) | ~1 min | 中国 |
|
||||
| chengdu | AMSC AWOS (ZUUU) | ~1 min | 中国 |
|
||||
| chongqing | AMSC AWOS (ZUCK) | ~1 min | 中国 |
|
||||
| wuhan | AMSC AWOS (ZHHH) | ~1 min | 中国 |
|
||||
| qingdao | AMSC AWOS (ZSQD) | ~1 min | 中国 |
|
||||
| beijing | AMSC AWOS (ZBAA) | 3 min | 中国 |
|
||||
| shanghai | AMSC AWOS (ZSPD) | 3 min | 中国 |
|
||||
| guangzhou | AMSC AWOS (ZGGG) | 3 min | 中国 |
|
||||
| chengdu | AMSC AWOS (ZUUU) | 3 min | 中国 |
|
||||
| chongqing | AMSC AWOS (ZUCK) | 3 min | 中国 |
|
||||
| wuhan | AMSC AWOS (ZHHH) | 3 min | 中国 |
|
||||
| qingdao | AMSC AWOS (ZSQD) | 3 min | 中国 |
|
||||
|
||||
### Tier 2 — 5 分钟高频 (MADIS)
|
||||
|
||||
@@ -111,11 +111,31 @@ ankara, istanbul, helsinki, amsterdam, paris
|
||||
- **Tier 4 城市**(仅 METAR):修正效果有限,不建议依赖
|
||||
|
||||
|
||||
## 实时事件与图表刷新逻辑
|
||||
|
||||
当前终端图表不是固定整图轮询,而是:
|
||||
|
||||
1. 首屏 / 切换城市时拉取 `/api/city/{city}/detail` 作为完整 snapshot。
|
||||
2. 可见图表连接 `/api/events?cities=...&since_revision=...&replay_limit=500`。
|
||||
3. 采集器产出 `city_observation_patch.v1` 后写入 Redis Stream(生产)或 SQLite event log(本地/兜底),再通过 SSE 推给浏览器。
|
||||
4. 前端把 patch 追加到已有实测序列,不显示 loading 遮罩;只有可见图表 2 分钟无 patch 时才启动 60 秒兜底刷新。
|
||||
5. 浏览器从后台切回前台时,前端会立即补一次 full detail,防止长时间挂页后图表落后。
|
||||
|
||||
频率取决于源头:
|
||||
|
||||
- AMOS / CoWIN / MSS:源头约 1 分钟,图表按 1 分钟粒度追加。
|
||||
- AMSC:中国跑道观测城市按 3 分钟采集,不再强制 60 秒刷新。
|
||||
- MADIS:源头约 5 分钟。
|
||||
- HKO / CWA / JMA / FMI / KNMI:源头约 10 分钟。
|
||||
- METAR-only 城市:按 METAR 可用频率和缓存 TTL,不伪装成 1 分钟实测。
|
||||
|
||||
所有图表横轴和 tooltip 时间均按城市当地时间展示,不按用户浏览器时区。
|
||||
|
||||
## 关于网站终端图表的数据曲线展示逻辑
|
||||
|
||||
### 1. 实测数据(默认全开,突出核心)
|
||||
|
||||
- **跑道全量展示**:北京、上海、广州、成都、重庆、武汉、首尔等城市的跑道实测数据,默认全量开启,无需手动勾选。
|
||||
- **跑道全量展示**:北京、上海、广州、成都、重庆、武汉、青岛、首尔、釜山等城市的跑道实测数据,默认全量开启,无需手动勾选。
|
||||
- **结算跑道高亮**:系统内置了各大机场的官方结算跑道映射。命中的跑道将被**重点强调**(加粗的青色实线 #009688,线宽 2.8),并标记为“[跑道号] 结算跑道”。具体的跑道映射如下:
|
||||
- 北京:19/01
|
||||
- 上海:17L/35R
|
||||
@@ -123,16 +143,26 @@ ankara, istanbul, helsinki, amsterdam, paris
|
||||
- 成都:02L/20R
|
||||
- 重庆:20R/02L
|
||||
- 武汉:04/22
|
||||
- 青岛:16/34
|
||||
- 首尔:15R/33L
|
||||
- 釜山:SR/SL
|
||||
- **辅助跑道弱化**:同一机场下的其他非结算跑道,也会同时展示,但采用较细的虚线(线宽 1.2)以作陪衬区分。
|
||||
- **其他实测展示**:所有城市的 METAR 报文曲线、官方气象站实测(如 Hong Kong / Lau Fau Shan 的香港天文台曲线)均默认展示。
|
||||
- **单跑道机场去重**:釜山只有 `SR/SL` 跑道曲线时,不再额外展示 AMOS 聚合线,避免两条线语义重复。
|
||||
- **香港参考曲线**:Hong Kong 默认展示 CoWIN `6087`(保良局陈守仁小学)1 分钟参考站曲线;HKO 10 分钟实测作为官方气象层保留。
|
||||
- **其他实测展示**:所有城市的 METAR 报文曲线、官方气象站实测(如 Shenzhen / Lau Fau Shan 的 HKO 自动站、Taipei 的 CWA)均默认展示。
|
||||
|
||||
### 2. 核心预测数据(默认展示)
|
||||
|
||||
- **DEB 模型融合**:作为平台核心的高精度智能融合预测曲线,默认始终展示给用户。
|
||||
- **DEB 模型融合**:作为平台核心的智能融合预测曲线,默认始终展示给用户。DEB 是预测,不参与“实测接近峰值”的视觉预警计算。
|
||||
- **DEB hourly consensus**:图表优先使用 `deb_hourly_consensus.v1` 的小时路径展示 DEB 曲线和推导“高温”窗口;如果缺失才回退旧的 hourly + DEB offset 路径。
|
||||
|
||||
### 3. 多模型原始数据(默认隐藏,按需自选)
|
||||
|
||||
- **保持整洁**:为了防止图表线缆过于杂乱,各大原始模型(ECMWF, GFS, ICON, GEM 等)的数据曲线在初次加载时**默认隐藏**。
|
||||
- **特例**:仅针对巴黎(Paris),由于其 AROME HD 是高精度的 15 分钟级临近预报,极具参考价值,因此默认开启。
|
||||
- **自由交互**:用户可通过图表底部的图例交互按钮,随时自由勾选、叠加或隐藏任意所需的数据曲线。
|
||||
|
||||
### 4. 高斯概率图层
|
||||
|
||||
- legacy 高斯概率不会作为时间序列曲线展示。
|
||||
- 图表上只渲染概率温度带和 `mu` 参考线,帮助用户判断当前实测距概率中心和高概率区域的关系。
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# Cloudflare 免费版缓存配置
|
||||
|
||||
目标:让 Cloudflare 承担公开页面、公开 API 和静态资源的重复读取,降低 VPS CPU、Next.js worker 和后端 detail-batch 压力,同时避免缓存账户、支付、反馈和实时连接。
|
||||
|
||||
代码已通过 `Cache-Control` 与 `Cloudflare-CDN-Cache-Control` 声明 TTL。由于免费版会严格遵守源站的 `max-age=0`,稳定公开路径的 Cache Rules 额外按状态码覆盖 Edge TTL;可能返回 partial、stale 或 failed 的接口继续尊重源站缓存头。不要给所有 `/api/*` 统一启用 Cache Everything。
|
||||
|
||||
缓存规则只允许作用于前端域名 `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 > Development Mode`:Off
|
||||
- `Caching > Configuration > Always Online`:On
|
||||
- `Caching > Tiered Cache`:Smart Tiered Cache,若当前免费套餐控制台提供则开启
|
||||
- `Speed > Optimization > Content Optimization > Brotli`:On
|
||||
- `Speed > Optimization > Content Optimization > Early Hints`:On
|
||||
- `Network > HTTP/3 (with QUIC)`:On
|
||||
- `Network > WebSockets`:On
|
||||
- 确保 `api.polyweather.top` 与 `polyweather.top` 代理状态为 Proxied
|
||||
|
||||
## Cache Rules
|
||||
|
||||
按以下顺序创建。Cloudflare 同一阶段最后匹配的规则生效,因此绕过规则必须放在公开缓存规则之后。免费版规则数量有限,因此使用路径集合合并表达式。
|
||||
|
||||
也可以使用仓库内脚本自动创建或更新规则。脚本会保留非 PolyWeather 规则,并把绕过规则放在最后。
|
||||
|
||||
Cloudflare 新版 Token UI 中,文档里的 Cache Rules 权限通常显示为:
|
||||
|
||||
- 权限策略资源:`指定域名` -> `polyweather.top`
|
||||
- `Cache & Performance` -> `Cache Settings` -> `Edit`
|
||||
|
||||
为了避免 Token 还需要列出 Zone,请同时在 GitHub Secrets 配置 `CLOUDFLARE_ZONE_ID`。Zone ID 在 Cloudflare 进入 `polyweather.top` 后,右侧 API 区域或 Overview 页面可以复制。
|
||||
|
||||
GitHub Actions 需要两个仓库 Secret:
|
||||
|
||||
- `CLOUDFLARE_API_TOKEN`
|
||||
- `CLOUDFLARE_ZONE_ID`
|
||||
|
||||
```powershell
|
||||
$env:CLOUDFLARE_API_TOKEN="<具有 Cache Settings Edit 权限的 token>"
|
||||
$env:CLOUDFLARE_ZONE_ID="<polyweather.top 的 Zone ID>"
|
||||
python scripts/configure_cloudflare_free.py
|
||||
python scripts/configure_cloudflare_free.py --apply
|
||||
```
|
||||
|
||||
第一条命令只输出计划;只有带 `--apply` 才会修改 Cloudflare。
|
||||
|
||||
部署流水线也会执行同一脚本。给 GitHub 仓库增加 `CLOUDFLARE_API_TOKEN` 和 `CLOUDFLARE_ZONE_ID` 两个 Secret 后,后续每次成功部署都会同步 Cache Rules;未配置时流水线会明确跳过,不影响主站部署。
|
||||
|
||||
### 1. 缓存公开内容
|
||||
|
||||
脚本会创建 5 条互不重叠的公开缓存规则。动作均为 Eligible for cache:
|
||||
|
||||
- 静态资源:2xx Edge TTL 覆盖为 1 年。
|
||||
- 首页和公开文档页:2xx Edge TTL 覆盖为 10 分钟。
|
||||
- `/api/cities`:2xx Edge TTL 覆盖为 5 分钟。
|
||||
- 城市详情与 detail-batch:尊重源站成功、partial、stale 和错误缓存策略。
|
||||
- `/api/scan/terminal` 与 `/api/system/status`:尊重源站成功、busy、failed 和错误缓存策略。
|
||||
|
||||
所有公开缓存规则都显式设置 Browser TTL 为 Respect origin,避免 Cloudflare 免费版默认 Browser Cache TTL 把 HTML 或数据接口改成 4 小时本地缓存。
|
||||
|
||||
### 2. 最后绕过后端域名、动态和敏感请求
|
||||
|
||||
动作:Bypass cache
|
||||
|
||||
表达式:
|
||||
|
||||
```text
|
||||
http.host eq "api.polyweather.top"
|
||||
or (http.request.method ne "GET" and http.request.method ne "HEAD")
|
||||
or starts_with(http.request.uri.path, "/api/auth/")
|
||||
or starts_with(http.request.uri.path, "/api/feedback")
|
||||
or starts_with(http.request.uri.path, "/api/events")
|
||||
or starts_with(http.request.uri.path, "/api/internal/")
|
||||
or starts_with(http.request.uri.path, "/api/ops/")
|
||||
or starts_with(http.request.uri.path, "/api/payments/")
|
||||
or starts_with(http.request.uri.path, "/account")
|
||||
or starts_with(http.request.uri.path, "/auth")
|
||||
or starts_with(http.request.uri.path, "/ops")
|
||||
or starts_with(http.request.uri.path, "/terminal")
|
||||
or http.request.uri.query contains "force_refresh=true"
|
||||
```
|
||||
|
||||
若 Dashboard 支持请求头或 Cookie 条件,再加入:
|
||||
|
||||
```text
|
||||
or any(http.request.headers["authorization"][*] ne "")
|
||||
or http.cookie contains "sb-"
|
||||
```
|
||||
|
||||
## 缓存 TTL
|
||||
|
||||
### 静态资源
|
||||
|
||||
动作:Eligible for cache;2xx Edge TTL 覆盖为一年。
|
||||
|
||||
表达式:
|
||||
|
||||
```text
|
||||
http.host eq "polyweather.top"
|
||||
and (
|
||||
starts_with(http.request.uri.path, "/_next/static/")
|
||||
or lower(http.request.uri.path.extension) in {
|
||||
"js" "css" "woff" "woff2" "png" "jpg" "jpeg" "webp" "avif" "svg" "ico"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
源站仍声明一年 immutable,Cloudflare Edge TTL 同样固定为一年。
|
||||
|
||||
### 公开页面
|
||||
|
||||
动作:Eligible for cache;2xx Edge TTL 覆盖为 10 分钟。
|
||||
|
||||
表达式:
|
||||
|
||||
```text
|
||||
http.host eq "polyweather.top"
|
||||
and (
|
||||
http.request.uri.path eq "/"
|
||||
or starts_with(http.request.uri.path, "/docs/")
|
||||
or starts_with(http.request.uri.path, "/modern/")
|
||||
or starts_with(http.request.uri.path, "/probabilities/")
|
||||
or starts_with(http.request.uri.path, "/subscription-help/")
|
||||
)
|
||||
```
|
||||
|
||||
源站 TTL:10 分钟,过期后允许后台刷新 1 小时。
|
||||
|
||||
### 公开数据接口
|
||||
|
||||
动作:Eligible for cache;城市列表的 2xx Edge TTL 覆盖为 5 分钟,详情、扫描和系统状态尊重源站缓存头。
|
||||
|
||||
表达式:
|
||||
|
||||
```text
|
||||
http.host eq "polyweather.top"
|
||||
and (
|
||||
http.request.uri.path eq "/api/cities"
|
||||
or http.request.uri.path eq "/api/cities/detail-batch"
|
||||
or starts_with(http.request.uri.path, "/api/city/")
|
||||
or http.request.uri.path eq "/api/scan/terminal"
|
||||
or http.request.uri.path eq "/api/system/status"
|
||||
)
|
||||
```
|
||||
|
||||
接口 TTL:
|
||||
|
||||
- `/api/cities`:5 分钟,过期后可复用 1 小时。
|
||||
- 城市详情与 detail-batch:60 秒,过期后可复用 5 分钟。
|
||||
- `/api/scan/terminal`:5 分钟,过期后可复用 15 分钟。
|
||||
- partial、busy、timeout、stale、force refresh 和错误响应:源站为 `no-store`,详情和扫描规则不会覆盖;force refresh 由最后一条绕过规则排除。
|
||||
|
||||
## 验证
|
||||
|
||||
每次规则变更后连续请求同一 URL,检查响应头:
|
||||
|
||||
```powershell
|
||||
curl.exe -sS -D - -o NUL "https://polyweather.top/api/cities"
|
||||
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
|
||||
```
|
||||
+20
-10
@@ -1,6 +1,6 @@
|
||||
# 商业化说明(Production)
|
||||
|
||||
最后更新:`2026-04-18`
|
||||
最后更新:`2026-05-30`
|
||||
|
||||
## 1. 定位
|
||||
|
||||
@@ -8,8 +8,8 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
|
||||
核心价值:
|
||||
|
||||
- 观测优先(METAR / 机场主站 / 明确官方站点;MGM、NMC、JMA、AMOS 等作为增强层)
|
||||
- 结算导向(DEB + 校准概率桶)
|
||||
- 观测优先(METAR / 机场主站 / 明确官方站点;MGM、JMA、AMOS、AMSC、CoWIN、CWA 等作为增强层)
|
||||
- 结算导向(DEB hourly consensus + 校准概率桶)
|
||||
- 气象判断优先(证据链、失效条件、下一观测点)
|
||||
- 市场映射(行情对照 + 错价雷达),但不把交易建议放在第一层产品承诺
|
||||
|
||||
@@ -18,12 +18,17 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
| 能力 | 状态 | 备注 |
|
||||
| :-- | :-- | :-- |
|
||||
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
|
||||
| 订阅套餐(Pro 月付) | 已上线 | `10 USDC / 30天` |
|
||||
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
|
||||
| 订阅套餐(Pro 月付 / 季度) | 已上线 | `29.9 USDC / 30天`;`79.9 USDC / 90天` |
|
||||
| 邀请积分 | 已上线 | 被邀请人首月 `20U`;邀请人每个有效付费邀请 `+3500` 积分 |
|
||||
| 用户增长奖励 | 已上线 | 按已验证用户里程碑,为当前有效且有 confirmed 入账记录的付费会员延长 Pro |
|
||||
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U`;邀请首月价不叠加积分抵扣 |
|
||||
| 合约支付 | 已上线 | Polygon,USDC + USDC.e |
|
||||
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
|
||||
| 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 |
|
||||
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop,按 intent.chain_id 查链 |
|
||||
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
|
||||
| 私有频道推送 | 已上线 | 可拆分业务频道 |
|
||||
| 实时终端 | 已上线 | SSE Patch + Redis Stream replay |
|
||||
| Telegram 双语推送 | 已上线 | 中英文一起推送,结算跑道端点口径 |
|
||||
|
||||
## 3. 权限模型(当前)
|
||||
|
||||
@@ -33,14 +38,18 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
- 今日日内深度分析(含高温时段)
|
||||
- 专业气象结论条、证据链、失效条件、确认条件
|
||||
- 概率分布层(基于 DEB 融合 + 高斯分桶)
|
||||
- 实时终端多城市图表(跑道/官方站点/DEB/概率带)
|
||||
- 历史对账 + 未来日期分析
|
||||
- 全平台智能气象推送
|
||||
- Telegram 缓存推送与业务频道通知
|
||||
|
||||
## 4. 收费与积分规则(默认)
|
||||
|
||||
- 套餐:`pro_monthly`(10 USDC / 30 天)
|
||||
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC
|
||||
- 实付下限:2 USDC(当积分满额时)
|
||||
- 套餐:`pro_monthly`(29.9 USDC / 30 天)、`pro_quarterly`(79.9 USDC / 90 天)
|
||||
- 邀请首月:被邀请人首次月付 20 USDC
|
||||
- 邀请奖励:邀请人 +3500 积分,每月最多 10 个有效付费邀请
|
||||
- 用户增长奖励:600 已验证用户 `+1天`、750 `+2天`、1000 `+3天`,此后每增长 100 人 `+3天`
|
||||
- 抵扣:500 积分抵 1 USDC;月付最高抵 3 USDC,季度最高抵 8 USDC
|
||||
- 积分来源:仅通过有效付费邀请和后台人工补发,不再通过 Telegram 群发言发放
|
||||
|
||||
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
|
||||
|
||||
@@ -62,6 +71,7 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
5. 安全边界:敏感配置不进仓库。
|
||||
6. 数据口径:机场市场按 METAR / 机场主站解释;Wunderground 只可描述为历史页面或参考入口,不描述成“站”。
|
||||
7. 加载口径:日内分析和右侧详情在 full detail 未补齐前必须显示同步状态,不能把旧缓存伪装成完整付费内容。
|
||||
8. 实时口径:终端图表应优先保留已有曲线并无痛合并 patch,不用 loading 遮罩覆盖已可用数据。
|
||||
|
||||
## 7. 后续路线
|
||||
|
||||
|
||||
+96
-23
@@ -1,5 +1,7 @@
|
||||
# 配置与密钥管理(中文)
|
||||
|
||||
最后更新:`2026-05-28`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
PolyWeather 的环境变量很多,但不是所有变量都属于同一层级。
|
||||
@@ -15,7 +17,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
3. 平台侧真实密钥
|
||||
放在:
|
||||
- VPS / Docker `.env`
|
||||
- Vercel Environment Variables
|
||||
- GitHub Secrets(构建期 `NEXT_PUBLIC_*` 通过 build-arg 注入前端镜像)
|
||||
- GitHub Secrets(如需要)
|
||||
|
||||
## 2. 为什么要拆
|
||||
@@ -64,7 +66,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
|
||||
用途:
|
||||
|
||||
- 前端本地开发与 Vercel 环境变量模板
|
||||
- 前端本地开发与容器运行时环境变量模板
|
||||
|
||||
## 4. 配置分级
|
||||
|
||||
@@ -79,6 +81,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `POLYWEATHER_RUNTIME_DATA_DIR`
|
||||
- `POLYWEATHER_DB_PATH`
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE`
|
||||
- `POLYWEATHER_EVENT_STORE`
|
||||
|
||||
前端:
|
||||
|
||||
@@ -104,8 +107,14 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION`
|
||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE`
|
||||
- `POLYWEATHER_EVENT_STORE`
|
||||
- `POLYWEATHER_REDIS_REQUIRED`
|
||||
- `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`
|
||||
|
||||
@@ -126,7 +135,11 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC`
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
|
||||
- `TAF_CACHE_TTL_SEC`
|
||||
- `POLYWEATHER_REDIS_URL`
|
||||
- `POLYWEATHER_REDIS_STREAM_KEY`
|
||||
- `POLYWEATHER_REDIS_STREAM_MAXLEN`
|
||||
|
||||
策略:
|
||||
|
||||
@@ -144,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. 推荐部署矩阵
|
||||
|
||||
@@ -155,20 +171,28 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- 所有 secrets
|
||||
- Bot / 支付 / watcher 配置
|
||||
|
||||
### 5.2 Vercel(前端)
|
||||
### 5.2 前端容器(Docker Compose)
|
||||
|
||||
建议只放前端真正需要的变量:
|
||||
前端与后端一起以 Docker Compose 部署,环境变量分两类来源:
|
||||
|
||||
- `POLYWEATHER_API_BASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
**运行时变量**(`.env` 或 compose `environment` 块):
|
||||
|
||||
- `POLYWEATHER_API_BASE_URL`(容器内使用 `http://polyweather_web:8000`)
|
||||
- `POLYWEATHER_AUTH_ENABLED`
|
||||
- `POLYWEATHER_AUTH_REQUIRED`
|
||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
|
||||
**构建期变量**(GitHub Secrets → CI `build-and-push` → `frontend/Dockerfile` 的 `ARG`,改了必须重新构建镜像):
|
||||
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- `NEXT_PUBLIC_SITE_URL`
|
||||
- `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`
|
||||
@@ -178,19 +202,43 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `/ops` 现在是前后端双层限制:
|
||||
- 前端页面入口读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- 后端写接口同样读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- 因此,Vercel 和 VPS / Docker 两侧都应配置相同的管理员邮箱白名单。
|
||||
- 因此,前端容器和后端容器两侧都应配置相同的管理员邮箱白名单。
|
||||
|
||||
不要把后端专用密钥全搬进 Vercel。
|
||||
不要把后端专用密钥全搬进前端容器。
|
||||
|
||||
### 5.3 GitHub Actions
|
||||
|
||||
当前 CI 不需要大规模 secrets。
|
||||
当前 CI 已配置自动部署,需要的 secrets 见 `.github/workflows/ci.yml`:
|
||||
|
||||
如果未来要做自动部署,再考虑:
|
||||
- `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_*`(注入前端镜像)
|
||||
|
||||
- `VERCEL_TOKEN`
|
||||
- `VERCEL_ORG_ID`
|
||||
- `VERCEL_PROJECT_ID`
|
||||
### 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. 最小部署示例
|
||||
|
||||
@@ -215,6 +263,10 @@ TELEGRAM_CHAT_ID=...
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
POLYWEATHER_STATE_STORAGE_MODE=sqlite
|
||||
POLYWEATHER_EVENT_STORE=redis
|
||||
POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
|
||||
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
|
||||
POLYWEATHER_REDIS_REQUIRED=true
|
||||
UID=1000
|
||||
GID=1000
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
@@ -236,13 +288,6 @@ TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
||||
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
|
||||
POLYWEATHER_SCAN_AI_ENABLED=false
|
||||
POLYWEATHER_SCAN_AI_API_KEY=...
|
||||
POLYWEATHER_SCAN_AI_PROVIDER=mimo
|
||||
POLYWEATHER_SCAN_AI_PROVIDER_LABEL=MiMo
|
||||
POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
POLYWEATHER_SCAN_AI_MODEL=mimo-v2.5-pro
|
||||
POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
|
||||
```
|
||||
|
||||
说明:
|
||||
@@ -252,15 +297,43 @@ POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
|
||||
- `POLYWEATHER_RUNTIME_DATA_DIR` 建议放在仓库外,例如 `/var/lib/polyweather`。
|
||||
- `docker-compose.yml` 会把这个目录同时挂载到容器内的 `/var/lib/polyweather` 和 `/app/data`,兼容现有缓存与 SQLite 路径。
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`。
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`。
|
||||
- `POLYWEATHER_EVENT_STORE=redis` 表示实时观测 patch 使用 Redis Stream 做短窗口 replay 和多 worker fanout;本地或单进程可改为 `sqlite`。
|
||||
- `POLYWEATHER_REDIS_REQUIRED=true` 表示 Redis 不可用时后端启动失败,避免生产环境广播不可 replay 的实时事件;开发环境可设为 `false` 允许回退 SQLite。
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持默认链的逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`。
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON` 用于多链支付,例如同时支持 Polygon 和 Ethereum 主网 USDC。
|
||||
- 机器人市场监控包含 `关键提醒` 与 `关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
|
||||
- `POLYWEATHER_SCAN_AI_*` 走 OpenAI-compatible `/chat/completions`;当前临时默认 MiMo,可用 `POLYWEATHER_SCAN_AI_BASE_URL` 与 `POLYWEATHER_SCAN_AI_MODEL` 随时切回其他兼容 provider。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
|
||||
|
||||
说明:
|
||||
|
||||
- 这层只负责把结构化信号改写成短摘要,不替代真实模型、机场锚点和结算逻辑。
|
||||
|
||||
### 6.3 支付多链配置示例
|
||||
|
||||
当前生产推荐:
|
||||
|
||||
- 默认链:Polygon `chain_id=137`,继续承载 checkout 合约支付。
|
||||
- 补充链:Ethereum Mainnet `chain_id=1`,正式支持 USDC 直转确认。
|
||||
- 前端创建支付 intent 时会提交用户选择的 `chain_id`;后端确认时按 intent 的链和 token 查询对应 RPC。
|
||||
|
||||
```env
|
||||
POLYWEATHER_PAYMENT_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
|
||||
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com","https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<polygon_checkout_contract>
|
||||
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=0x<treasury_or_receiver_wallet>
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<polygon_checkout_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>","is_default":true},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x<treasury_or_receiver_wallet>","supports_contract_checkout":false,"supports_direct_transfer":true,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}]
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- `POLYWEATHER_PAYMENT_CHAIN_ID` 只是默认链,不代表只支持这一条链。
|
||||
- `POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON` 里每个 token 必须有明确 `chain_id`。
|
||||
- Ethereum 行如果没有部署 checkout 合约,必须设置 `supports_contract_checkout=false`,前端会显示手动转账并阻止钱包合约支付。
|
||||
- 私有 RPC URL 带 API key 时应放入真实 `.env` 或密钥管理,不要提交。
|
||||
|
||||
### 6.5 机器人市场监控建议配置
|
||||
|
||||
这套配置围绕市场本身做两类推送:
|
||||
|
||||
+110
-142
@@ -1,57 +1,97 @@
|
||||
# 前端部署配置(Vercel)
|
||||
# 前端部署配置(Docker / VPS)
|
||||
|
||||
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。
|
||||
最后更新:`2026-06-14`
|
||||
|
||||
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。前端当前不再使用 Vercel,统一与后端一起以 Docker Compose 形式部署在同一台 VPS 上,前面挂 Cloudflare + Nginx。
|
||||
|
||||
## 一、部署目标
|
||||
|
||||
推荐方案:
|
||||
当前方案:
|
||||
|
||||
1. GitHub Actions 负责 `CI`
|
||||
2. Vercel 负责前端 `CD`
|
||||
3. FastAPI 后端单独部署在 VPS / Docker 主机
|
||||
1. GitHub Actions 负责 `CI`(`python-quality` + `frontend-quality`)
|
||||
2. `build-and-push` job 把前端构建为 Docker 镜像 `ghcr.io/yangyuan-zhen/polyweather-frontend`
|
||||
3. `deploy` job 通过 SSH 把 `deploy.sh` 推到 VPS,由 VPS 拉取新镜像并滚动更新
|
||||
|
||||
前端本身不直接访问天气源,而是通过 Next Route Handlers 转发到后端:
|
||||
|
||||
1. 浏览器 -> Vercel 上的 Next.js 前端
|
||||
2. Next `/api/*` -> `POLYWEATHER_API_BASE_URL`
|
||||
3. FastAPI 后端 -> 分析 / 支付 / 鉴权服务
|
||||
1. 浏览器 → Cloudflare → Nginx → 前端容器(Next.js standalone)
|
||||
2. Next `/api/*` → `POLYWEATHER_API_BASE_URL`(容器内默认 `http://polyweather_web:8000`)
|
||||
3. FastAPI 后端 → 分析 / 支付 / 鉴权服务
|
||||
|
||||
## 二、Vercel 项目设置
|
||||
实时图表同样走 Next Route Handler:
|
||||
|
||||
在 Vercel 导入 GitHub 仓库后,使用下面的设置:
|
||||
1. 浏览器 `EventSource` → `/api/events?cities=...&since_revision=...`
|
||||
2. Next 转发到 FastAPI `/api/events`
|
||||
3. FastAPI 从 Redis Stream / SQLite event log replay 后进入 live SSE
|
||||
|
||||
- Framework Preset: `Next.js`
|
||||
- Root Directory: `frontend`
|
||||
- Build Command: `npm run build`
|
||||
- Install Command: `npm ci`
|
||||
## 二、镜像与构建
|
||||
|
||||
如果仓库已经连接过 Vercel,通常只需要确认 `Root Directory` 仍然是 `frontend`。
|
||||
前端镜像定义在 `frontend/Dockerfile`,三阶段构建:
|
||||
|
||||
## 三、最小必填环境变量
|
||||
- `deps`:`npm ci` 安装依赖
|
||||
- `builder`:通过 `ARG` 注入 `NEXT_PUBLIC_*` 变量后执行 `npm run build`,产出 standalone 产物
|
||||
- `runner`:只拷贝 `.next/standalone`、`.next/static`、`public`,以 `node server.js` 启动
|
||||
|
||||
只部署天气看板和基础登录时,先填下面 4 项:
|
||||
`NEXT_PUBLIC_*` 变量是在 **构建期** 注入的(见 `frontend/Dockerfile` 的 `ARG` 块),CI 在 `.github/workflows/ci.yml` 的 `build-and-push` job 里从 GitHub Secrets 读取并作为 `--build-arg` 传入。修改这类变量必须重新构建镜像,仅改运行时环境无效。
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://<your-fastapi-host>
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://<your-project>.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your-anon-key>
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
## 三、Compose 服务
|
||||
|
||||
前端在 `docker-compose.yml` 中对应 `polyweather_frontend` 服务:
|
||||
|
||||
- 镜像:`ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}`
|
||||
- 容器内监听 `:3000`,映射到宿主 `127.0.0.1:3001`
|
||||
- 健康检查:`wget -qO- http://$(hostname):3000`
|
||||
- 运行时环境变量(非 `NEXT_PUBLIC_*` 的那部分)通过 compose `environment` 注入,例如:
|
||||
- `POLYWEATHER_API_BASE_URL=http://polyweather_web:8000`(容器内走后端服务名)
|
||||
- `POLYWEATHER_AUTH_ENABLED` / `POLYWEATHER_AUTH_REQUIRED`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
|
||||
`POLYWEATHER_API_BASE_URL` **禁止** 指向前端站点自身(`polyweather.top`),否则会形成回环。`deploy.sh` 里的 `validate_frontend_api_base_url` 会在部署前拦截。容器内应使用 `http://polyweather_web:8000`。
|
||||
|
||||
## 四、部署流程(`deploy.sh`)
|
||||
|
||||
生产部署由 GitHub Actions 在 `main` push 时触发,关键步骤:
|
||||
|
||||
1. SSH 登录 VPS,用 GHCR PAT 登录镜像仓库
|
||||
2. `git fetch origin main && git reset --hard origin/main` 同步仓库(含 `docker-compose.yml`)
|
||||
3. 同步 `data/city_thread_ids.json` 到运行态目录
|
||||
4. `docker compose pull` 拉取新镜像(带重试)
|
||||
5. 按顺序滚动更新:`redis` → `web` + `bot` → `collector` → `warmer` → `frontend`
|
||||
6. 每步后做本地健康检查;前端额外等待 `/terminal` 和 `/api/scan/terminal` 就绪
|
||||
7. 公网 smoke check:`https://api.polyweather.top/healthz`、`https://polyweather.top/api/cities`、`https://www.polyweather.top/`
|
||||
8. 任意一步失败自动回滚到上一个镜像 tag(记录在 `/var/lib/polyweather/.current_tag`)
|
||||
|
||||
部署失败时优先看 `deploy.sh` 输出里哪一步打了 `❌`,并检查 `docker compose logs polyweather_frontend`。
|
||||
|
||||
## 五、最小必填配置
|
||||
|
||||
只部署天气看板和基础登录时,至少需要:
|
||||
|
||||
构建期(CI Secrets,对应 `frontend/Dockerfile` 的 `ARG`):
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_SUPABASE_URL
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather.top
|
||||
```
|
||||
|
||||
建议显式补:
|
||||
运行期(`.env` 或 compose `environment`):
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=http://polyweather_web:8000
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=<与后端共享>
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `POLYWEATHER_API_BASE_URL`:前端所有 `/api/*` Route Handler 转发时依赖它,没填会直接返回 500。
|
||||
- `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY`:Supabase 客户端依赖它们。
|
||||
- `POLYWEATHER_AUTH_ENABLED`:关闭时,前端不会启用登录能力。
|
||||
- `POLYWEATHER_AUTH_REQUIRED`:控制 middleware 是否强制登录。
|
||||
- `POLYWEATHER_API_BASE_URL`:前端所有 `/api/*` Route Handler 转发时依赖它,没填或填错会直接返回 500。
|
||||
- `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY`:Supabase 客户端依赖它们,构建期注入。
|
||||
- `POLYWEATHER_AUTH_ENABLED` / `POLYWEATHER_AUTH_REQUIRED`:控制 middleware 是否强制登录。
|
||||
|
||||
## 四、按功能启用的可选环境变量
|
||||
## 六、按功能启用的可选环境变量
|
||||
|
||||
### 1. 分享式看板
|
||||
|
||||
@@ -61,119 +101,65 @@ POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
|
||||
设置后,可通过 `/?access_token=<token>` 打开带令牌的看板入口。
|
||||
|
||||
### 2. 前后端 entitlement 校验
|
||||
### 2. 钱包支付(构建期)
|
||||
|
||||
```env
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
```
|
||||
|
||||
仅当后端开启 entitlement / 订阅校验时需要。
|
||||
|
||||
### 3. 钱包支付
|
||||
|
||||
```env
|
||||
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
|
||||
```
|
||||
|
||||
如果不启用钱包支付,可以留空。
|
||||
|
||||
### 4. `/ops` 管理员页面守卫
|
||||
### 3. `/ops` 管理员页面守卫
|
||||
|
||||
```env
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
```
|
||||
|
||||
说明:
|
||||
`/ops` 页面入口会读取管理员邮箱白名单,前端和后端容器都应配置相同的值。
|
||||
|
||||
- `/ops` 现在不是只有后端接口限制,前端页面入口也会读取管理员邮箱白名单。
|
||||
- 因此前端部署到 Vercel 时,也应配置 `POLYWEATHER_OPS_ADMIN_EMAILS`。
|
||||
### 4. Telegram 入口(构建期)
|
||||
|
||||
### 5. Telegram 入口
|
||||
|
||||
```env
|
||||
```
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
|
||||
```
|
||||
|
||||
只影响按钮跳转,不影响核心页面加载。
|
||||
|
||||
### 6. 前端观测与预热开关(推荐默认关闭)
|
||||
### 5. 前端观测与预热开关(推荐默认关闭)
|
||||
|
||||
```env
|
||||
```
|
||||
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
|
||||
NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false
|
||||
NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
|
||||
```
|
||||
|
||||
说明:
|
||||
## 七、支付配置与旧镜像治理
|
||||
|
||||
- `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false`:关闭前端自建埋点。
|
||||
- `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false`:关闭前端 Web Vitals 上报。
|
||||
- `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false`:关闭首页全量城市 summary 预热,避免白白消耗 Vercel function / edge 成本。
|
||||
|
||||
## 五、支付配置与旧部署治理
|
||||
|
||||
支付区现在有一层额外防护:
|
||||
支付区有一层额外防护:
|
||||
|
||||
1. 用户点击支付前,前端会重新请求 `/api/payments/config`
|
||||
2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址
|
||||
3. 若后端返回的 `tx_payload.to` 与最新 `receiver_contract` 不一致,会直接阻断支付
|
||||
4. 多链支付时,前端会展示后端返回的网络列表,并把用户选择的 `chain_id` 传给后端创建 intent
|
||||
5. Ethereum 主网 USDC 当前走手动直转确认,前端不会把它当成 Polygon checkout 合约支付
|
||||
|
||||
这层防护的目的,是降低以下事故概率:
|
||||
这层防护降低以下事故概率:
|
||||
|
||||
- 用户使用长期未刷新的旧标签页
|
||||
- 命中旧 deployment URL
|
||||
- 页面本地状态残留旧收款地址
|
||||
- 用户在钱包默认网络(例如 Ethereum)付款,但系统按 Polygon intent 查账
|
||||
|
||||
如果你变更过支付收款地址,建议同步执行:
|
||||
如果变更过支付收款地址,由于前端镜像是构建期注入地址,需要触发一次 `main` push(或重新运行 deploy workflow)来发布新镜像;浏览器侧靠 `/api/payments/config` 的运行时校验兜底,无需等待所有用户刷新。
|
||||
|
||||
1. 在 Vercel 对当前 production 做一次 redeploy
|
||||
2. 删除明显过期、可能还带旧支付配置的旧 deployment
|
||||
3. 在 `Settings -> Security -> Deployment Retention Policy` 中收紧旧部署保留周期
|
||||
## 八、不要放进前端容器的变量
|
||||
|
||||
## 六、推荐的三套配置口径
|
||||
这些属于后端私密配置,不应该放到前端服务:
|
||||
|
||||
### 1. 公开游客模式
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://api.example.com
|
||||
POLYWEATHER_AUTH_ENABLED=false
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
```
|
||||
|
||||
适合公开演示站。
|
||||
|
||||
### 2. 正常登录模式
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://api.example.com
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
```
|
||||
|
||||
适合正式前端站点。
|
||||
|
||||
### 3. 登录 + entitlement 联动
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://api.example.com
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=<shared-token>
|
||||
```
|
||||
|
||||
适合前后端都启用了会员/订阅保护的生产环境。
|
||||
|
||||
## 七、不要放进 Vercel 的变量
|
||||
|
||||
这些属于后端私密配置,不应该放到前端项目:
|
||||
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`(除非前端 Route Handler 明确需要,且仅以非 `NEXT_PUBLIC_` 形式注入容器)
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN` 以外的后端 secret
|
||||
- 支付签名私钥 / 交易私钥 / 任何 bot 凭据
|
||||
@@ -183,73 +169,55 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=<shared-token>
|
||||
- `NEXT_PUBLIC_*` 会暴露给浏览器
|
||||
- 只有明确允许前端公开使用的值,才应加 `NEXT_PUBLIC_`
|
||||
|
||||
## 八、上线前检查
|
||||
## 九、上线前检查
|
||||
|
||||
Vercel 部署前至少确认:
|
||||
部署前至少确认:
|
||||
|
||||
1. `POLYWEATHER_API_BASE_URL` 指向可访问的后端生产地址
|
||||
2. `frontend/.env.example` 和 Vercel Project Settings 中的实际值一致
|
||||
1. `POLYWEATHER_API_BASE_URL` 指向容器内后端服务名 `http://polyweather_web:8000`,**不是** `polyweather.top`
|
||||
2. CI Secrets 中的 `NEXT_PUBLIC_*` 值与预期一致(构建期注入,改了要重新构建镜像)
|
||||
3. GitHub Actions 中 `frontend-quality` 已通过
|
||||
4. 如果启用鉴权,Supabase redirect URL 已包含前端域名
|
||||
5. `GET /api/payments/config` 返回的是当前最新地址,而不是旧收款合约
|
||||
6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在 Vercel 与后端同时配置
|
||||
6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在前端与后端容器同时配置
|
||||
7. 确认 `/api/events` 没有被 Cloudflare / Nginx 缓存或压缩成普通 JSON;它必须保持 `text/event-stream`
|
||||
|
||||
## 九、常见问题
|
||||
## 十、常见问题
|
||||
|
||||
### 1. 页面打开后 API 全部 500
|
||||
|
||||
先检查:
|
||||
先检查容器内 `POLYWEATHER_API_BASE_URL` 是否指向 `http://polyweather_web:8000`,以及 `polyweather_web` 容器是否健康。
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL
|
||||
```
|
||||
|
||||
这是最常见原因。
|
||||
|
||||
### 2. Vercel 构建通过,但登录失败
|
||||
### 2. 构建通过,但登录失败
|
||||
|
||||
先检查:
|
||||
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- Supabase 项目里的站点 URL / redirect URL
|
||||
- 构建期注入的 `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- Supabase 项目里的站点 URL / redirect URL 是否包含前端域名
|
||||
|
||||
### 3. 钱包入口显示未配置
|
||||
|
||||
先检查:
|
||||
检查构建期 `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` 是否注入(前端镜像需要重新构建)。
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
|
||||
```
|
||||
### 4. 改了 `NEXT_PUBLIC_*` 但线上没生效
|
||||
|
||||
这是钱包连接的必需项。
|
||||
这类变量是构建期注入的。仅改 CI Secrets 不会更新已部署镜像,需要重新触发 `build-and-push` + `deploy`(即一次 `main` push,或手动重跑 deploy workflow)。
|
||||
|
||||
## 十、Vercel 成本与节流建议
|
||||
## 十一、成本与节流建议
|
||||
|
||||
### 1. 建议先关闭的项目级能力
|
||||
### 1. Cloudflare 缓存规则
|
||||
|
||||
- `Web Analytics`
|
||||
- `Speed Insights`
|
||||
前端通过 `next.config.mjs` 的 `headers()` 为静态资源(`_next/static`、图片、字体)设置 `Cache-Control: public, max-age=31536000, immutable`,并为公共页面设置 `s-maxage=600, stale-while-revalidate=3600`。CI 的 `cloudflare-cache-rules` job 会同步 Cloudflare Cache Rules(见 `scripts/configure_cloudflare_free.py`)。
|
||||
|
||||
它们对排查前端体验有价值,但在 Hobby / 低预算阶段会额外消耗数据点和边缘资源。
|
||||
### 2. Cloudflare WAF 规则
|
||||
|
||||
### 2. 建议加的 Firewall 自定义规则
|
||||
|
||||
如果你的 Next.js 项目根本不提供 WordPress / PHP 路径,建议在 Vercel Firewall 里先 `Log` 再 `Deny` 这条规则:
|
||||
如果发现大量 WordPress / PHP 扫描流量命中 Next.js(实际并不提供这些路径),建议在 Cloudflare WAF 中先 `Log` 再 `Deny` 这条规则:
|
||||
|
||||
```regex
|
||||
(^/(wp-admin|wp-includes|wp-content|wp-login|wordpress|xmlrpc\.php))|\.php($|\?)
|
||||
```
|
||||
|
||||
目的:
|
||||
目的:在边缘层提前拦截扫描流量,避免无效请求继续触发 Nginx、Next.js middleware 与 route handler。
|
||||
|
||||
- 在边缘层提前拦截 WordPress / PHP 扫描流量
|
||||
- 避免无效请求继续触发 middleware 与 route handler
|
||||
### 3. SSE 路径不要进缓存
|
||||
|
||||
### 3. 建议的上线前检查
|
||||
|
||||
除了功能本身,额外确认:
|
||||
|
||||
1. `Web Analytics` 和 `Speed Insights` 是否真的关闭
|
||||
2. `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS` / `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS` / `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES` 是否保持关闭
|
||||
3. Firewall 自定义规则是否已从 `Log` 切到 `Deny`
|
||||
`/api/events` 必须保持 `text/event-stream`,Cloudflare 和 Nginx 都不应缓存或压缩它。检查 Nginx 配置(`deploy/nginx/polyweather.conf`)中对 `/api/events` 的 `proxy_buffering off`。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
本文档记录 PolyWeather 当前开放模型接入、区域覆盖差异,以及 DEB 在新增模型后的计权规则。
|
||||
|
||||
最后更新:`2026-04-18`
|
||||
最后更新:`2026-05-28`
|
||||
|
||||
## 1. 接入方式
|
||||
|
||||
@@ -165,11 +165,73 @@ raw current_forecasts
|
||||
-> 按模型家族去重
|
||||
-> 历史 MAE 统计
|
||||
-> MAE 倒数权重
|
||||
-> 输出 blended_high + weights_info
|
||||
-> 输出 raw blended_high
|
||||
-> recent signed-bias correction
|
||||
-> 输出 production prediction + raw_prediction + version
|
||||
```
|
||||
|
||||
当 `weights_info` 出现 `家族去重`,表示当前输入模型数量多于 DEB 实际入模数量,系统已先折叠同家族模型。
|
||||
|
||||
### 5.1 DEB hourly consensus
|
||||
|
||||
当前图表和峰值窗口优先使用 `deb_hourly_consensus.v1`。
|
||||
|
||||
入口:
|
||||
|
||||
- `src/analysis/deb_hourly_consensus.py`
|
||||
- `web/analysis_service.py`
|
||||
- `src/analysis/trend_engine.py`
|
||||
|
||||
处理逻辑:
|
||||
|
||||
```text
|
||||
multi_model hourly curves
|
||||
-> 按 DEB 可用模型和家族去重口径选择候选
|
||||
-> 使用 DEB 权重生成逐小时 consensus
|
||||
-> 输出 deb.hourly_consensus
|
||||
-> 图表 DEB Forecast 与 peak-window 逻辑优先读取该路径
|
||||
```
|
||||
|
||||
关键口径:
|
||||
|
||||
- `deb_hourly_consensus.v1` 是预测曲线,不是实测曲线。
|
||||
- 它只用于日内形状、峰值窗口和 DEB Forecast 展示;视觉预警和“接近峰值”状态必须优先读取实测/跑道/官方站点当前值与今日实测高点。
|
||||
- 如果多模型小时路径缺失,系统才回退旧的 `hourly_plus_deb_offset`。
|
||||
|
||||
### 5.2 版本化预测与偏差校正
|
||||
|
||||
DEB 原始融合逻辑不推倒重写,`calculate_dynamic_weights(...)` 仍作为 raw baseline。线上生产入口使用 `calculate_deb_prediction(...)` 包装 raw baseline,并在有足够历史样本时追加城市级 recent signed-bias correction。
|
||||
|
||||
当前版本:
|
||||
|
||||
- `deb_v1_raw`:原始 DEB,最近模型误差倒数加权后的融合值。
|
||||
- `deb_v1_recent_bias_corrected`:在 raw DEB 上叠加最近已结算样本的有符号偏差校正。偏差使用 shrinkage,样本少时自动收缩,避免单日异常过拟合。
|
||||
|
||||
API `deb` payload 会保留:
|
||||
|
||||
- `prediction`:当前生产使用值。
|
||||
- `raw_prediction`:未经 recent-bias correction 的原始 DEB。
|
||||
- `version`:生产值对应的 DEB 版本。
|
||||
- `bias_adjustment` / `bias_samples`:城市级偏差校正幅度与训练样本数。
|
||||
- `intraday_adjustment`:网页 full detail 中额外的日内观测路径修正,仅用于当前日实时展示。
|
||||
|
||||
版本化回测命令:
|
||||
|
||||
```bash
|
||||
python scripts/backtest_deb_versions.py \
|
||||
--output-json data/deb_backtest_latest.json \
|
||||
--output-csv data/deb_backtest_latest.csv
|
||||
```
|
||||
|
||||
本地 `data/polyweather.db` 于 2026-05-27 的回测样本显示:
|
||||
|
||||
| 版本 | 样本 | MAE | RMSE | Bias | 结算桶命中率 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| `deb_v1_raw` | 1272 | 1.626 | 2.602 | -0.633 | 25.8% |
|
||||
| `deb_v1_recent_bias_corrected` | 1272 | 1.499 | 2.542 | +0.263 | 31.4% |
|
||||
|
||||
该结果只证明当前历史样本上的离线表现改善;后续仍需要持续用版本化回测追踪不同城市、季节与结算源下的漂移。
|
||||
|
||||
## 6. 前端展示
|
||||
|
||||
网页的模型展示读取:
|
||||
@@ -177,6 +239,7 @@ raw current_forecasts
|
||||
- `multi_model`
|
||||
- `multi_model_daily`
|
||||
- `source_forecasts.open_meteo_multi_model.model_metadata`
|
||||
- `deb.hourly_consensus`
|
||||
|
||||
显示分组:
|
||||
|
||||
@@ -197,6 +260,13 @@ raw current_forecasts
|
||||
|
||||
区域模型不覆盖时不显示空模型。
|
||||
|
||||
终端图表默认:
|
||||
|
||||
- 展示“全天”视图,按城市当地日 00:00-23:59 和真实观测时间绘制。
|
||||
- 可选“高温”视图,窗口由 `deb_hourly_consensus.v1` 的峰值区域推导。
|
||||
- DEB Forecast 使用橙色预测曲线;实测/跑道/官方站点曲线独立展示,不把 DEB 当成实测。
|
||||
- legacy 高斯概率显示为水平温度带和 `mu` 参考线,不参与时间序列曲线。
|
||||
|
||||
### 6.1 “来源 Open-Meteo”是什么意思
|
||||
|
||||
前端中的 `来源 - Open-Meteo` 表示本次多模型数据通过 Open-Meteo model API 归一化接入。
|
||||
@@ -228,6 +298,7 @@ raw current_forecasts
|
||||
|
||||
- `tests/test_multi_model_sources.py`
|
||||
- `tests/test_deb_model_family.py`
|
||||
- `tests/test_deb_evaluation_upgrade.py`
|
||||
|
||||
重点覆盖:
|
||||
|
||||
@@ -237,3 +308,4 @@ raw current_forecasts
|
||||
- DEB 家族去重
|
||||
- 历史不足时的去重等权
|
||||
- 有历史 MAE 时的去重动态权重
|
||||
- DEB raw/corrected 版本化评估、recent-bias correction、JSON/CSV 回测输出
|
||||
|
||||
+17
-1
@@ -1,6 +1,6 @@
|
||||
# 外部监控与告警说明
|
||||
|
||||
最后更新:`2026-04-18`
|
||||
最后更新:`2026-05-28`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- Relay 把告警推到运营频道
|
||||
- Grafana 展示趋势面板
|
||||
- 巡检脚本补健康检查
|
||||
- 关注 `/api/events` 长连接与 realtime event store 是否正常 replay
|
||||
|
||||
## 2. 组件
|
||||
|
||||
@@ -89,6 +90,14 @@ POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
|
||||
- Source Requests by Outcome
|
||||
- Source Error Rate (15m)
|
||||
|
||||
实时事件层建议额外观察:
|
||||
|
||||
- Redis Stream latest revision
|
||||
- Redis 连接状态
|
||||
- SQLite fallback 是否被启用
|
||||
- SSE active connection count
|
||||
- `resync_required` 出现频率
|
||||
|
||||
## 7. 巡检脚本
|
||||
|
||||
手动巡检:
|
||||
@@ -102,6 +111,7 @@ python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
|
||||
- `/healthz`
|
||||
- `/api/system/status`
|
||||
- `/metrics`
|
||||
- `/api/events`(手动验证时查看 `connected` / `heartbeat` / replay 事件)
|
||||
|
||||
任何一项失败都会非零退出,适合挂到 crontab 或 systemd timer。
|
||||
|
||||
@@ -130,6 +140,11 @@ python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
|
||||
- 多日预报是否只返回当天单卡
|
||||
- 日内分析 full detail / market scan 是否仍在同步
|
||||
- 右侧详情面板是否正在用同步占位卡提示用户
|
||||
- 实时事件状态:
|
||||
- event store 类型(Redis / SQLite)
|
||||
- latest revision
|
||||
- Redis 是否连通
|
||||
- 是否处于 `degraded_from=redis` fallback
|
||||
|
||||
这意味着:
|
||||
|
||||
@@ -180,3 +195,4 @@ npm run build
|
||||
- 更细粒度支付指标
|
||||
- 按城市/来源拆分的业务 SLA
|
||||
- 按城市拆分的前端补齐耗时与 stale-detail 告警
|
||||
- Redis Stream 长度、内存与 replay gap 告警
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
前端入口:
|
||||
|
||||
- `https://polyweather-pro.vercel.app/ops`
|
||||
- `https://polyweather.top/ops`
|
||||
|
||||
## 2. 权限
|
||||
|
||||
|
||||
+49
-37
@@ -1,53 +1,65 @@
|
||||
# 外部服务依赖总览
|
||||
|
||||
最后更新:`2026-05-23`
|
||||
最后更新:`2026-05-29`
|
||||
|
||||
项目调用了 20 个外部服务,按状态分为三类。
|
||||
项目调用外部天气、鉴权、支付和实时事件服务。原则是:核心链路必须有明确健康检查;可选数据源不可拖垮已可用城市;实时事件层可从 Redis Stream 降级到 SQLite event log。
|
||||
|
||||
## 核心(必须有,挂了服务不可用)
|
||||
|
||||
| 服务 | 用途 | 状态 |
|
||||
| ---------------------- | --------------------- | ---- |
|
||||
| Open-Meteo | 52 城天气预报 | ✅ |
|
||||
| AviationWeather (NOAA) | METAR/TAF 航空观测 | ✅ |
|
||||
| MADIS (NOAA) | 美国 5 分钟高频观测 | ✅ |
|
||||
| Supabase | 用户认证 + 订阅 | ✅ |
|
||||
| Telegram Bot API | Bot 消息 + 群成员检查 | ✅ |
|
||||
| KNMI | Amsterdam 10 分钟观测 | ✅ |## 国家气象源(特定城市必须)
|
||||
| 服务 | 用途 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| Open-Meteo | 51 城天气预报、多模型小时曲线、DEB hourly consensus 输入 | ✅ |
|
||||
| AviationWeather (NOAA) | METAR / TAF 航空观测 | ✅ |
|
||||
| MADIS (NOAA) | 美国机场 5 分钟高频观测 | ✅ |
|
||||
| Supabase | 用户认证、订阅状态、会员恢复 | ✅ |
|
||||
| Telegram Bot API | Bot 消息、群成员检查、双语跑道推送 | ✅ |
|
||||
| Redis | `city_observation_patch.v1` Stream、SSE replay、多 worker fanout | ✅ |
|
||||
| SQLite | 运行态数据库、支付审计、实时事件 fallback | ✅ |
|
||||
|
||||
| 服务 | 城市 | 状态 |
|
||||
| -------------------- | --------------------- | ----------- |
|
||||
| JMA (日本) | Tokyo | ✅ |
|
||||
| KMA + AMOS (韩国) | Seoul, Busan | ✅ |
|
||||
| AMSC AWOS (中国) | 北京/上海/广州等 6 城 | ✅ |
|
||||
| MGM (土耳其) | Ankara, Istanbul | ✅ |
|
||||
| FMI (芬兰) | Helsinki | ✅ |
|
||||
| HKO (香港) | Hong Kong | ✅ |
|
||||
| CWA (台湾) | Taipei | ✅ |
|
||||
| NMC (中国) | 国内城市 fallback | ✅ |
|
||||
| Singapore MSS | Singapore | ✅ |
|
||||
| IMGW (波兰) | Warsaw | ⚠️ 未配 key |
|
||||
| Russia pogodaiklimat | Moscow | ❌ 已移除 |
|
||||
## 国家气象源(特定城市必须)
|
||||
|
||||
| 服务 | 城市 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| JMA AMeDAS | Tokyo | ✅ |
|
||||
| AMOS (韩国) | Seoul, Busan 跑道传感器 | ✅ |
|
||||
| AMSC AWOS (中国) | 北京、上海、广州、成都、重庆、武汉、青岛跑道端点气温 | ✅ |
|
||||
| MGM (土耳其) | Ankara, Istanbul | ✅ |
|
||||
| FMI (芬兰) | Helsinki | ✅ |
|
||||
| KNMI (荷兰) | Amsterdam | ✅(需 key) |
|
||||
| CoWIN 6087 (香港) | Hong Kong 1 分钟参考站 | ✅ |
|
||||
| HKO (香港) | Hong Kong / Shenzhen / Lau Fau Shan 10 分钟官方气象层 | ✅ |
|
||||
| CWA (台湾) | Taipei | ✅ |
|
||||
| Singapore MSS | Singapore | ✅ |
|
||||
| IMS Lod (以色列) | Tel Aviv | ✅ |
|
||||
| AEROWEB / AROME HD | Paris | ✅ |
|
||||
| NMC (中国) | 国内城市 fallback | ✅ |
|
||||
| IMGW (波兰) | Warsaw | ⚠️ 未配 key |
|
||||
|
||||
## 可选 / 已禁用
|
||||
|
||||
| 服务 | 用途 | 状态 |
|
||||
| -------------- | ------------- | ----------- |
|
||||
| 服务 | 用途 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| OpenWeatherMap | 天气 fallback | ⚠️ 未配 key |
|
||||
| VisualCrossing | 历史天气 | ⚠️ 未配 key |
|
||||
| Meteoblue | 天气预报 | ❌ 已移除 |
|
||||
| SynopticData | 美国站点观测 | ⚠️ 未配 key |
|
||||
| VisualCrossing | 历史天气 | ⚠️ 未配 key |
|
||||
| SynopticData | 美国站点观测 | ⚠️ 未配 key |
|
||||
| Meteoblue | 天气预报 | ❌ 已移除 |
|
||||
| Russia pogodaiklimat | Moscow 历史源 | ❌ 已移除 |
|
||||
| Groq | AI commentary | ❌ 已移除 |
|
||||
|
||||
## AI / 其他
|
||||
## AI / 支付 / 前端
|
||||
|
||||
| 服务 | 用途 | 状态 |
|
||||
| ----------------- | ---------------- | ----------- |
|
||||
| 服务 | 用途 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 |
|
||||
| DeepSeek | AI fallback | - 备用 |
|
||||
| Groq | AI commentary | ❌ 已移除 |
|
||||
| Polygon RPC | 链上支付 | ✅ |
|
||||
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key |
|
||||
| DeepSeek | AI fallback | 备用 |
|
||||
| Polygon RPC | checkout 合约支付、Polygon USDC / USDC.e 自动确认 | ✅ |
|
||||
| Ethereum RPC | Ethereum 主网 USDC 直转确认 | ✅(启用多链支付时必须) |
|
||||
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key 时钱包入口降级 |
|
||||
|
||||
## 合计
|
||||
## 运维口径
|
||||
|
||||
15 个在用,3 个可选/未配置,3 个已移除。
|
||||
- 生产实时事件推荐:`POLYWEATHER_EVENT_STORE=redis` + `POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0`。
|
||||
- 本地或单进程兜底:`POLYWEATHER_EVENT_STORE=sqlite`。
|
||||
- Redis 只负责短窗口 replay 与多 worker fanout,不是长期天气历史库。
|
||||
- DEB hourly consensus 依赖 Open-Meteo 多模型小时曲线;若上游限流,图表应保留已有 snapshot 和实测 patch,不把缺失模型误报为实测缺失。
|
||||
- 支付多链确认依赖 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`;如果启用 Ethereum 主网 USDC,必须配置 `chain_id=1` 的 RPC,否则用户提交 Ethereum tx hash 后无法自动确认。
|
||||
|
||||
+35
-11
@@ -1,12 +1,12 @@
|
||||
# Supabase + 登录 + 支付接入说明(v1.8.0)
|
||||
# Supabase + 登录 + 支付接入说明(v1.8.1)
|
||||
|
||||
最后更新:`2026-03-14`
|
||||
最后更新:`2026-05-29`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- 前端支持 Google 一键登录 + 邮箱注册/登录。
|
||||
- 后端支持 Supabase JWT 鉴权。
|
||||
- 支持 Polygon 合约支付(USDC / USDC.e)并自动确认开通订阅。
|
||||
- 支持 Polygon 合约支付(USDC / USDC.e)和 Ethereum 主网 USDC 直转支付,并自动确认开通订阅。
|
||||
|
||||
## 2. Supabase 控制台配置
|
||||
|
||||
@@ -15,13 +15,14 @@
|
||||
- `https://<project-ref>.supabase.co/auth/v1/callback`
|
||||
3. `Auth -> URL Configuration` 添加:
|
||||
- 站点 URL(生产域名)
|
||||
- 回调 URL(例如 `https://polyweather-pro.vercel.app/auth/callback`)
|
||||
- 回调 URL(例如 `https://polyweather.top/auth/callback`)
|
||||
|
||||
## 3. 数据库脚本
|
||||
|
||||
在 Supabase SQL Editor 执行:
|
||||
|
||||
- `scripts/supabase/schema.sql`
|
||||
- 既有生产项目遇到 Disk IO Budget 告警时,再执行 `scripts/supabase/io_budget_indexes.sql`
|
||||
|
||||
会创建支付与订阅相关表:
|
||||
|
||||
@@ -33,9 +34,18 @@
|
||||
- `payment_intents`
|
||||
- `payment_transactions`
|
||||
|
||||
### 3.1 Disk IO 告警处理
|
||||
|
||||
如果 Supabase 提示项目正在耗尽 Disk IO Budget,先在 SQL Editor 执行:
|
||||
|
||||
1. `scripts/supabase/io_budget_indexes.sql`
|
||||
2. `scripts/supabase/disk_io_diagnostics.sql`
|
||||
|
||||
第一个脚本会把热查询索引收敛为更低写放大的部分索引,移除已被唯一约束覆盖或无热路径使用的冗余索引,并对相关表执行 `ANALYZE`。第二个脚本用于查看表扫描、dead tuples、索引命中和 `pg_stat_statements` 中的高读块 SQL。生产执行后,继续观察 Supabase daily/hourly Disk IO 图表,确认请求延迟和 IO wait 是否下降。
|
||||
|
||||
## 4. 环境变量
|
||||
|
||||
### 4.1 前端(Vercel / frontend/.env.local)
|
||||
### 4.1 前端(Docker 容器 / frontend/.env.local)
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
@@ -66,30 +76,38 @@ SUPABASE_SERVICE_ROLE_KEY=
|
||||
SUPABASE_HTTP_TIMEOUT_SEC=8
|
||||
|
||||
POLYWEATHER_PAYMENT_ENABLED=true
|
||||
# 默认链仍是 Polygon,因为当前 checkout 合约部署在 Polygon。
|
||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<receiver_contract>
|
||||
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=0x<treasury_or_receiver_wallet>
|
||||
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
|
||||
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
|
||||
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
|
||||
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
|
||||
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
|
||||
|
||||
# 支持双币种(示例)
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_e","symbol":"USDC.e","name":"USDC.e (PoS)","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver>","is_default":true},{"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver>"}]
|
||||
# 支持多链多币种(示例)
|
||||
# Ethereum 主网 USDC 当前建议只开 direct transfer,不走 Polygon checkout 合约。
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>","is_default":true},{"code":"usdc_e_polygon","symbol":"USDC.e","name":"USDC.e on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>"},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x<treasury_or_receiver_wallet>","supports_contract_checkout":false,"supports_direct_transfer":true,"confirmations":2,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}]
|
||||
|
||||
# 套餐(当前只保留月付)
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"5","duration_days":30}}
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
|
||||
# 套餐
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"29.9","duration_days":30},"pro_quarterly":{"plan_id":102,"amount_usdc":"79.9","duration_days":90}}
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly
|
||||
|
||||
# 积分抵扣
|
||||
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8}
|
||||
|
||||
# 支付自动补单
|
||||
POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC=20
|
||||
POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_INTERVAL_SEC=300
|
||||
POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_AFTER_EMPTY_CYCLES=3
|
||||
```
|
||||
|
||||
## 5. 验证步骤
|
||||
@@ -100,10 +118,16 @@ POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
|
||||
- `POST /api/payments/wallets/challenge`
|
||||
- `POST /api/payments/wallets/verify`
|
||||
4. 支付流程:
|
||||
- `POST /api/payments/intents`
|
||||
- `POST /api/payments/intents`;多链时前端会带 `chain_id` 和 `token_address`
|
||||
- 发链上交易
|
||||
- `POST /api/payments/intents/{id}/submit`
|
||||
- `POST /api/payments/intents/{id}/confirm`
|
||||
5. 若前端显示 pending,轮询:
|
||||
- `GET /api/payments/intents/{id}`
|
||||
6. 确认订阅:`/api/auth/me` 返回 `subscription_active=true`。
|
||||
|
||||
## 6. 多链支付口径
|
||||
|
||||
- Polygon 是默认链,仍支持钱包合约支付和手动直转确认。
|
||||
- Ethereum 主网 USDC 是正式支付链路,但当前建议只走手动直转:用户选择 Ethereum 后,前端展示收款钱包、金额、代币合约和 Etherscan 链接,用户提交 tx hash 后后端按 `intent.chain_id=1` 查询 Ethereum RPC。
|
||||
- 不要只依赖前端文案阻止错链付款;后端必须把每笔 intent 的 `chain_id`、`token_address`、`receiver_address` 落库,并在确认时按该链校验 `Transfer` 事件。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 技术债与工程待办(v1.8.0)
|
||||
# 技术债与工程待办(v1.8.1)
|
||||
|
||||
最后更新:`2026-05-10`
|
||||
|
||||
@@ -60,7 +60,7 @@ flowchart TD
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/首次消息奖励/欢迎奖励/周排名奖励/周参与奖/手动补分) |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(有效付费邀请/后台人工补发/积分抵扣消费) |
|
||||
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
|
||||
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 审查日期:2026-06 | 视角:系统架构师 | 范围:完整数据采集→分析→API→前端状态
|
||||
>
|
||||
> **修复状态:8/8 已完成**
|
||||
> **修复状态:9/9 已完成;最后校准:2026-05-28**
|
||||
|
||||
## 一、数据架构总览
|
||||
|
||||
@@ -13,9 +13,8 @@
|
||||
Open-Meteo (预报+多模型) ─┐
|
||||
METAR/TAF (航空气象) ─┤
|
||||
NWS (美国) / MGM (土耳其) ─┤
|
||||
JMA/AMOS/NMC/HKO/CWA ─┤
|
||||
Wunderground / NOAA ─┤
|
||||
Polymarket Gamma/CLOB ─┤
|
||||
JMA/AMOS/AMSC/HKO/CWA ─┤
|
||||
CoWIN / MADIS / NOAA ─┤
|
||||
├─ WeatherDataCollector ├─ dashboard-client.ts
|
||||
│ (内存缓存 + SQLite磁盘缓存) │ (ETag浏览器缓存 + SWR)
|
||||
│ │
|
||||
@@ -27,8 +26,11 @@ Polymarket Gamma/CLOB ─┤
|
||||
│ ├─ ThreadPoolExecutor(4) │ (120s TTL)
|
||||
│ └─ AI 增强层 (DeepSeek) │
|
||||
│ │
|
||||
└─ FastAPI routes └─ API代理 (Next.js rewrites)
|
||||
(36个端点 + ETag 304)
|
||||
├─ FastAPI routes └─ API代理 (Next.js rewrites)
|
||||
│ (HTTP snapshot + ETag 304)
|
||||
│
|
||||
└─ SSE /api/events
|
||||
(Redis Stream / SQLite replay)
|
||||
```
|
||||
|
||||
## 二、数据采集层
|
||||
@@ -43,10 +45,8 @@ Polymarket Gamma/CLOB ─┤
|
||||
| NWS | 国家预报 | 美国 | 按请求 |
|
||||
| MGM | 国家官方 | 土耳其 | 300s |
|
||||
| ECMWF/GFS/ICON/GEM/JMA | 多模型 NWP | 全球 | 300s |
|
||||
| HKO/CWA/NOAA/AMOS/NMC | 结算观测 | 特定国家 | 60s (AMOS) / 300s |
|
||||
| Wunderground | 个人气象站 | 全球备用 | 按请求 |
|
||||
| Polymarket Gamma | 市场发现 | 所有温度市场 | 60s |
|
||||
| Polymarket CLOB | 订单簿 | 匹配市场 | 30s |
|
||||
| HKO/CWA/NOAA/AMOS/AMSC/CoWIN | 结算/参考观测 | 特定国家 | 60s-600s |
|
||||
| Redis Stream | 实时事件 replay / 多 worker fanout | 后端内部 | event-driven |
|
||||
|
||||
### 待改进
|
||||
|
||||
@@ -78,7 +78,7 @@ Polymarket Gamma/CLOB ─┤
|
||||
|
||||
**已修复:TTL 匹配** — `SCAN_TERMINAL_PAYLOAD_TTL_SEC` 30s → 120s,匹配 ThreadPoolExecutor(4)×60 城的实际重算耗时。
|
||||
|
||||
**已实现:SSE 增量推送(SSE Patch)与按需刷新** — 引入 FastAPI SSE 广播通道 (`/api/events`)。数据采集端更新时自动向 `/api/internal/collector-patch` 推送最新温度;前端扫描终端订阅该流,不再执行固定的 5 分钟定时轮询,而是根据 Patch 变化即时更新列表;当前选中图表基于 `useLatestPatch` 实现 1 分钟级温度的增量合并与实时曲线绘制。
|
||||
**已实现:SSE 增量推送(SSE Patch)与可重放事件日志** — 引入 FastAPI SSE 广播通道 (`/api/events`)。数据采集端更新时自动向 `/api/internal/collector-patch` 推送最新温度;后端标准化为 `city_observation_patch.v1`,写入 Redis Stream(生产)或 SQLite event log(兜底)后再广播。前端扫描终端订阅该流,不再执行固定的 5 分钟定时轮询,而是根据 Patch 变化即时更新列表;当前选中图表基于 `useLatestPatch` 实现 1 分钟级温度的增量合并与实时曲线绘制。
|
||||
|
||||
| # | 问题 | 优先级 |
|
||||
|---|------|------|
|
||||
@@ -94,7 +94,7 @@ Polymarket Gamma/CLOB ─┤
|
||||
|
||||
**已修复:扫描数据复用** — `preloadCityFromRow()` 从扫描终端行预填充城市详情缓存,选城市后详情面板立即显示。
|
||||
|
||||
**已实现:SSE 订阅与 2 分钟无 Patch 兜底机制** — 引入 `useLatestPatch` 与 `useSsePatchVersion` 管理实况数据的准实时合并。若长连接中断或 2 分钟内未收到任何增量 Patch,可见图表自动触发 60s 降级轮询(从 `/api/city/{city}/summary` 获取最新实况,并以 ignoreCache 强刷 full detail)。
|
||||
**已实现:SSE 订阅、replay 与 2 分钟无 Patch 兜底机制** — 引入 `useLatestPatch` 与 `useSsePatchVersion` 管理实况数据的准实时合并。若长连接中断,前端使用 `since_revision` replay 缺失事件;若 2 分钟内未收到任何增量 Patch,可见图表自动触发 60s 降级轮询(从 `/api/city/{city}/summary` 获取最新实况,并以 ignoreCache 强刷 full detail)。浏览器后台恢复时会主动刷新可见图表 full detail。
|
||||
|
||||
## 六、待办
|
||||
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
|
||||
## 执行摘要
|
||||
|
||||
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution)**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
|
||||
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution)**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、Ethereum 主网 USDC 直转确认、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
|
||||
|
||||
> **2026-05-23 更新(v1.7.0)**:Polymarket 价格拉取与 UI 层(MarketDecisionLine)已删除,`market_scan` 当前返回空;LGBM 已完全移除,概率引擎仅保留 legacy 高斯 + EMOS/CRPS;Groq、Meteoblue、NMC、pogodaiklimat 数据源和 prewarm 预热系统已移除。
|
||||
> **2026-05-29 更新**:支付 intent 已支持多链 `chain_id`,Polygon 继续承载 checkout 合约,Ethereum 主网 USDC 作为直转确认通道;终端图表继续使用 HTTP snapshot + SSE Patch + Redis Stream/SQLite replay 架构。
|
||||
## 项目概览
|
||||
|
||||
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
|
||||
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Docker/VPS 前端)+ VPS 后端 + Telegram Bot”。
|
||||
项目主功能可归纳为五层:
|
||||
**天气层(数据源/采集)**:聚合 51 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、韩国 AMOS 跑道级观测(首尔/釜山)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报)、美国 NWS(仅美国城市)、以及部分城市使用明确官方站点或历史页面入口(香港 HKO、台湾/深圳相关历史页面等)等。机场类市场仍以 METAR / 机场主站为结算锚点,Wunderground 不描述为物理观测站。
|
||||
**天气层(数据源/采集)**:聚合 51 个城市的实测与预报;支持 AviationWeather METAR/TAF、韩国 AMOS 跑道级观测(首尔/釜山)、中国 AMSC AWOS 跑道端点气温、香港 CoWIN 6087 / HKO、土耳其 MGM、Open-Meteo 多模型与集合预报、美国 MADIS HFMETAR 等。机场类市场仍以 METAR / 机场主站或明确官方结算源为锚点,Wunderground 不描述为物理观测站。
|
||||
**分析层(DEB/趋势/概率/结算口径)**:
|
||||
DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;运行态仍维护近 14 天 `daily_records` 缓存做当前对账,但长期监督真值与训练特征已经迁到 SQLite 永久表中,并支持基于 WU(Weather Underground 口径)四舍五入的结算命中评估。
|
||||
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
|
||||
**城市决策层(Scan Terminal / AI 机场报文解读)**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR,并通过 `/api/scan/terminal/ai-city/stream` 生成城市级 AI 解读。该解读由 `final_judgment`、`metar_read`、`reasoning`、`model_cluster_note`、`risks` 与原始 METAR 证据组成;最高温中枢优先使用 AI `predicted_max`,再回退到 DEB、多模型中心、日内 pace 或当前实测。
|
||||
**城市决策层(Scan Terminal / 结构化实况层)**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR/官方站点/跑道观测,并通过 `/api/city/{name}/detail` 生成城市级结构化数据。终端图表使用 HTTP snapshot + SSE patch + Redis/SQLite replay,最高温中枢优先使用 DEB hourly consensus,再结合多模型中心、日内 pace 和当前实测。
|
||||
**市场层(Polymarket 行情对照)**:*[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。
|
||||
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
|
||||
**商业化与支付**:订阅(`Pro Monthly 29.9 USDC / 30 天`、`Pro Quarterly 79.9 USDC / 90 天`)、邀请积分抵扣、Polygon 链上收款合约(USDC/USDC.e)、Ethereum 主网 USDC 直转确认,并提供“事件监听 + 周期确认”的自动补单机制。
|
||||
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
|
||||
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。
|
||||
(插图:项目 README 中包含产品截图,可用于快速理解信息架构与 UI 形态)
|
||||

|
||||
(插图:项目 README 中包含产品截图,可用于快速理解实时终端与 Telegram 推送形态)
|
||||

|
||||
|
||||
## 架构与代码库分析
|
||||
|
||||
@@ -28,8 +28,8 @@ DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
|
||||
从 README、Docker/Compose、入口脚本与核心模块引用关系,可以抽象出如下模块地图(按“运行时组件”与“Python 域模块”两层描述):
|
||||
| 层级 | 目录/文件 | 角色定位 | 关键说明 |
|
||||
| ------------- | ------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 运行时组件 | `frontend/` | Next.js 前端(Vercel) | 前端重构报告提到 App Router、Route Handlers(BFF)、缓存策略、支付与账户中心等;Scan Terminal 已新增城市决策卡、AI 机场报文解读、页面内存/localStorage 双层缓存、AI stream 小并发队列与完整市场桶映射。 |
|
||||
| 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` + `web/scan_terminal_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层;Scan Terminal 侧提供 `/api/scan/terminal/ai-city/stream`,城市 AI 默认 30s 超时并支持 stream parse failure 的非流式重试。 |
|
||||
| 运行时组件 | `frontend/` | Next.js 前端(Docker / VPS) | 前端重构报告提到 App Router、Route Handlers(BFF)、缓存策略、支付与账户中心等;Scan Terminal 已新增城市决策卡、结构化实况层、页面内存/localStorage 双层缓存、structured detail 小并发队列与完整市场桶映射。 |
|
||||
| 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` + `web/scan_terminal_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层;Scan Terminal 侧提供 `/api/city/{name}/detail`,城市结构化分析 默认 30s 超时并支持 stream parse failure 的非流式重试。 |
|
||||
| 运行时组件 | `bot_listener.py` + `src/bot/*` | Telegram Bot | 入口 `bot_listener.py` 调 `start_bot()`,并由 `StartupCoordinator` 启动多个后台 loop。 |
|
||||
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py`、`settlement_sources.py`、`metar_sources.py`、`mgm_sources.py`、`amos_station_sources.py`、`jma_amedas_sources.py`、`nws_open_meteo_sources.py`、`country_networks.py` 等。v1.7.0 已移除 NMC、pogodaiklimat、Meteoblue 数据源。 |
|
||||
| Python 域模块 | `src/analysis/*` | DEB/趋势/概率/结算口径 | `deb_algorithm.py`、`trend_engine.py`、`settlement_rounding.py`。 |
|
||||
@@ -44,14 +44,14 @@ DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Clients
|
||||
WEB[Next.js Frontend<br/>Vercel]
|
||||
CITYCARD[Scan Terminal City Cards<br/>AI airport read + bucket mapping]
|
||||
WEB[Next.js Frontend<br/>Docker / VPS]
|
||||
CITYCARD[Scan Terminal City Cards<br/>structured observations + probability mapping]
|
||||
TG[Telegram Bot<br/>TeleBot + Handlers]
|
||||
end
|
||||
|
||||
subgraph API
|
||||
FAST[FastAPI<br/>web/app.py]
|
||||
LLM[City AI stream<br/>OpenAI-compatible provider]
|
||||
LLM[City structured detail<br/>OpenAI-compatible provider]
|
||||
end
|
||||
|
||||
subgraph Data
|
||||
@@ -71,7 +71,7 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
||||
HKO[data.weather.gov.hk]
|
||||
CWA[opendata.cwa.gov.tw]
|
||||
SB[Supabase Auth/REST]
|
||||
RPC[Polygon RPC]
|
||||
RPC[Polygon / Ethereum RPC]
|
||||
end
|
||||
|
||||
subgraph Payments
|
||||
@@ -118,8 +118,8 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
||||
Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解释”的前端决策层:
|
||||
|
||||
1. **地图点击与 pinned city**:免费/付费入口都会先把城市加入决策卡;未付费用户若权限不足,仍应保留卡片承载升级/限制提示,而不是点击后无反馈。
|
||||
2. **full detail hydration**:卡片请求城市 full detail,拿到 DEB、当前/历史实测、多模型区间与最新 METAR。现阶段 detail hydration 仍偏保守串行,优先保障后端数据源稳定;真正消耗 LLM 的 AI 解读另行限流。
|
||||
3. **AI 机场报文解读**:前端最多同时保留 2 条城市 AI stream,第三个及以后城市会进入队列并展示排队提示,避免多个 provider stream 同时竞争导致第三城/第四城解析失败。当前临时使用 MiMo:`POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1`、`POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro`;其他后端城市 AI 配置建议为 `POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30`、`POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900`、`POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`。
|
||||
2. **full detail hydration**:卡片请求城市 full detail,拿到 DEB、当前/历史实测、多模型区间与最新 METAR。现阶段 detail hydration 仍偏保守串行,优先保障后端数据源稳定;真正消耗 LLM 的 结构化解读另行限流。
|
||||
3. **结构化实况层**:前端最多同时保留 2 条城市结构化分析 stream,第三个及以后城市会进入队列并展示排队提示,避免多个 provider stream 同时竞争导致第三城/第四城解析失败。当前临时使用 MiMo:`POLYWEATHER_API_BASE_URL=<backend>`、`POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=300`;其他后端城市结构化分析 配置建议为 `POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=120`、`POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=8`、`POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=300`。
|
||||
4. **缓存策略**:页面内存缓存保留 loading/stream/final 状态,切换选项卡返回时不应空白重拉;localStorage 持久化最终成功、非 degraded 的 payload;后端 city AI cache key 已移除当前 `local_time` 干扰,主要按城市、日期与 METAR signature 失效。
|
||||
5. **市场桶匹配**:城市市场扫描必须使用 full `all_buckets`,按温度 exact/range/“or higher”/“or lower” 方向严格匹配;不再用宽松 ±8°C fallback,以避免拿到 16°C 之类错误桶。前端展示统一使用“模型-市场差”,即 `model_probability - market_implied_probability`,并修复温度单位重复渲染(如 `31°°C`)。
|
||||
|
||||
@@ -127,7 +127,7 @@ Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解
|
||||
|
||||
**Python 依赖**:`requirements.txt` 包含 `requests`、`loguru`、`pyTelegramBotAPI`、`python-dotenv`、`numpy`、`web3`、`fastapi`、`uvicorn` 等,符合“采集+bot+api+链上交互”的需求。
|
||||
**容器环境**:`Dockerfile` 基于 `python:3.11-slim`,默认启动 bot;`docker-compose.yml` 通过不同 command 分别启动 bot 与 web(`python bot_listener.py` / `python web/app.py`),并挂载运行态数据目录。
|
||||
**前端依赖**:前端 README 描述 Next.js、Leaflet、Chart.js、Supabase Auth、WalletConnect 等;`frontend/package.json` 是前端依赖来源。
|
||||
**前端依赖**:前端 README 描述 Next.js、Leaflet、Recharts、Supabase Auth、WalletConnect 等;`frontend/package.json` 是前端依赖来源。
|
||||
### 数据预处理、模型与“训练/推理”管线
|
||||
|
||||
本项目的“模型”主要是统计融合与规则/启发式引擎,而非深度网络训练:
|
||||
@@ -136,7 +136,7 @@ Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解
|
||||
**DEB(Dynamic Error Balancing)**:以最近 N 天各模型的 MAE 计算倒数权重并做加权融合;同时将 `forecasts / actual_high / deb_prediction / mu / prob_snapshot` 写入 `data/daily_records.json`,并提供命中率/MAE/Brier 等统计口径。
|
||||
**概率引擎**:`trend_engine.py` 以集合预报的 p10/p90 推 σ(并考虑历史 MAE floor、风向/云量/压强的 shock_score、以及峰值窗口 time-decay),再用正态近似把连续分布映射为 WU 整数“温度桶概率”。
|
||||
**推理流水线(在线)**:
|
||||
Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引擎输出结构化结果(μ、概率桶、趋势、死盘/窗口判定、DEB 预测、市场扫描)→ 前端渲染或 bot 消息格式化。对城市决策卡而言,在线推理还会叠加“latest METAR + 多模型区间 + AI city stream + full all_buckets 市场匹配”,最终输出最高温中枢、AI 机场报文解读和模型-市场差。
|
||||
Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引擎输出结构化结果(μ、概率桶、趋势、死盘/窗口判定、DEB 预测、市场扫描)→ 前端渲染或 bot 消息格式化。对城市决策卡而言,在线推理还会叠加“latest METAR + 多模型区间 + structured city detail + full all_buckets 市场匹配”,最终输出最高温中枢、结构化实况层和模型-市场差。
|
||||
**检查点(checkpoints)**:传统 ML checkpoint 不适用;但项目现已形成两类“业务状态 checkpoint”:
|
||||
(a)SQLite 运行态存储(当前线上与核心离线链路主路径);(b)SQLite 永久真值/训练特征表(当前监督真值与训练样本长期主存);(c)legacy JSON/JSONL 文件(主要保留给迁移回滚、导出比对与显式回退输入)。当前设计仍支持 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`,但对线上部署与离线训练/回填而言,推荐目标状态都已经是 `sqlite`。
|
||||
### 测试、CI/CD 与运维验证
|
||||
@@ -148,7 +148,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
|
||||
### 优势
|
||||
|
||||
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),新增首次发言欢迎奖励与每日首条消息奖励,周奖励降低赢家积分差距并增加全员参与奖。
|
||||
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),积分获取切换为有效付费邀请奖励;Telegram 群发言不再发放积分。
|
||||
**复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。前端城市决策卡在此基础上补足“机场报文解释 + 市场桶动作口径”,让用户从地图点击可以直接进入可解释决策。
|
||||
**面向外部 API 的工程防护意识较强**:Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。
|
||||
**支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。
|
||||
@@ -159,7 +159,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
**CI 已建立,但组织级质量门禁未必完全收口**:CI 现已覆盖 Python、前端与 Docker build。当前问题不再是“缺 CI”,而是是否把这些 status check 绑定到 `main` 保护策略,以及是否逐步引入更严格的 pre-merge 审查。
|
||||
**运行态状态/缓存与核心离线链路的 SQLite 收口已完成**:`daily_records`、`telegram_alert_state`、`probability_training_snapshots`、`open_meteo` 缓存已经支持并在生产中主读 SQLite,迁移/校验脚本可用;进一步地,在临时移除 `data/*.json` / `data/*.jsonl` 后,训练集导出、概率拟合、评估报告、shadow report 和关键 backfill 脚本已验证仍可运行。当前 legacy 文件路径主要是显式回退入口,而不再是默认主输入。
|
||||
**第三方服务合规与稳定性风险**:
|
||||
项目强依赖外部 API(Open-Meteo、AviationWeather、global.amo.go.kr AMOS、NWS、HKO、CWA、Supabase)以及城市 AI provider(OpenAI-compatible stream,当前使用 MiMo)。其中 AviationWeather Data API 有明确速率限制;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市 AI 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
|
||||
项目强依赖外部 API(Open-Meteo、AviationWeather、global.amo.go.kr AMOS、NWS、HKO、CWA、Supabase)以及城市结构化分析 provider(OpenAI-compatible stream,当前使用 MiMo)。其中 AviationWeather Data API 有明确速率限制;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市结构化分析 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
|
||||
|
||||
> **v1.7.0 更新**:Polymarket(Gamma/CLOB)API 依赖已随市场价格拉取层一并移除。
|
||||
**许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部神经天气模型,仍需单独核验第三方代码与权重的商用条件:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
|
||||
@@ -181,11 +181,11 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
**对标结论**:PolyWeather 与这类“全球神经天气模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。
|
||||
## 优先级改进建议
|
||||
|
||||
下表按截至 `2026-05-23` 的真实状态重排优先级。已完成项不再继续列为”待做”,只保留当前仍需推进的事项。
|
||||
下表按截至 `2026-05-28` 的真实状态重排优先级。已完成项不再继续列为”待做”,只保留当前仍需推进的事项。
|
||||
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 中 | **把最小外部监控继续补深**:从“可告警”提升到“可运营” | 3–7 天 | 不再只知道服务坏没坏,还能看资源趋势、来源 SLA 和支付波动 | 指标过多会带来维护噪音 | 1) 增加节点 CPU/内存/磁盘 → 2) 增加 SQLite/支付体积与事件趋势 → 3) 把 HTTP/来源指标细分到城市/来源维度 → 4) 增加日报或异常摘要 |
|
||||
| 中 | **城市决策卡 AI 解读可观测性与回放测试** | 3–5 天 | 降低第三/第四/第五城市 AI 解读失败,验证缓存与队列是否真正生效 | 外部 AI stream 仍可能超时或输出截断,若无指标很难复盘 | 1) 记录 city-ai stream status/duration/retry/degraded/cache-hit/queue-depth → 2) 增加固定 METAR + detail + all_buckets fixture → 3) 回归断言 bucket 匹配、模型-市场差、温度单位与缓存 key → 4) 将生产 env 建议同步进部署文档 |
|
||||
| 中 | **城市决策卡 结构化解读可观测性与回放测试** | 3–5 天 | 降低第三/第四/第五城市结构化分析 解读失败,验证缓存与队列是否真正生效 | 外部 structured detail 仍可能超时或输出截断,若无指标很难复盘 | 1) 记录 city-ai stream status/duration/retry/degraded/cache-hit/queue-depth → 2) 增加固定 METAR + detail + all_buckets fixture → 3) 回归断言 bucket 匹配、模型-市场差、温度单位与缓存 key → 4) 将生产 env 建议同步进部署文档 |
|
||||
| - | ~~市场层升级为 async + 类型安全~~ | N/A | *[v1.7.0 已移除]* 市场价格拉取层已删除,此改进项不再适用 | - | - |
|
||||
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 1–2 周 | 在已完成的链下审计与容灾之上,进一步收紧链上授权边界 | 合约升级需要重新部署、迁移配置并再次验证 | 1) 维持现有事件重放、SQLite 审计、多 RPC fallback → 2) 升级合约到 SafeERC20 + Pausable → 3) 评估链上 plan/amount/token 绑定或 EIP-712 签名校验 → 4) 迁移后更新 PolygonScan 验证与支付审计文档 |
|
||||
| 中 | **将 CI 与分支保护/发布流程真正绑定** | 1–3 天 | 让现有 CI 从“存在”变成“强制门禁” | 历史分支/热修流程可能受影响 | 1) GitHub `main` 开启 required checks → 2) 把 release/tag 流程绑定 CI → 3) 明确热修例外流程 |
|
||||
@@ -194,7 +194,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
### 文档、测试与贡献流程的具体补强建议(落到仓库层面)
|
||||
|
||||
1)**文档体系**:保留现有中文 API/TechDebt 文档的同时,增加三份“高价值”文档:
|
||||
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级,并明确城市 AI 推荐配置(`POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30`、`POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900`、`POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`);(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、城市 AI stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。
|
||||
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级,并明确城市结构化分析 推荐配置(`POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=120`、`POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=8`、`POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=300`);(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、城市结构化分析 stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。
|
||||
2)**测试金字塔**:在现有 `trend_engine` 单测基础上,补齐:
|
||||
(a)天气 provider 的“录制回放”测试(VCR 思路:固定响应→确保解析稳定);(b)市场层的契约测试(Gamma/CLOB schema 变更时提前失败);(c)城市决策卡 fixture 测试(固定 `detail/market_scan/all_buckets/METAR` → 断言 bucket mapping、模型-市场差、温度单位、AI 缓存 key 与排队提示);(d)支付链路的本地链集成测试(Hardhat/Anvil + 事件扫描回放)。这些测试能把“外部依赖漂移”尽量转成可控的回归失败。
|
||||
3)**贡献工作流**:引入 `CONTRIBUTING.md`(分支策略、PR 模板、变更日志、版本号策略)、`CODEOWNERS`(核心模块审查人)、`SECURITY.md`(漏洞披露与密钥处理),并把静态检查(ruff/eslint)作为 pre-commit + CI 必过项。
|
||||
@@ -243,13 +243,13 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
||||
| 时间窗 | 里程碑 | 交付物 | 资源/备注 |
|
||||
| ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
|
||||
| 第 1 周 | 城市决策卡稳定性补强 | city-ai stream/cache/queue 指标;固定 METAR + `all_buckets` fixture;温度桶匹配与模型-市场差回归测试;生产 env 文档化 | 前端为主,后端补指标 |
|
||||
| 第 2 周 | 市场层与 Scan Terminal 数据回放 | 保存 `bucket_label/bucket_direction/model_market_diff/matching_reason`;支持回放第三/第四/第五城市 AI 解读失败案例 | 用真实失败样本压回归 |
|
||||
| 第 4–5 周 | 监控深挖与运维日报 | 来源 SLA、城市维度延迟、AI stream 状态、SQLite 体积、支付事件趋势、异常摘要 | 避免指标过多,先覆盖高频故障 |
|
||||
| 第 2 周 | 市场层与 Scan Terminal 数据回放 | 保存 `bucket_label/bucket_direction/model_market_diff/matching_reason`;支持回放第三/第四/第五城市结构化分析 解读失败案例 | 用真实失败样本压回归 |
|
||||
| 第 4–5 周 | 监控深挖与运维日报 | 来源 SLA、城市维度延迟、structured detail 状态、SQLite 体积、支付事件趋势、异常摘要 | 避免指标过多,先覆盖高频故障 |
|
||||
| 第 6 周 | 支付合约与发布门禁升级 | SafeERC20/Pausable 方案评审;CI required checks 与 release/tag 流程绑定;热修例外流程 | 合约升级需单独部署验证 |
|
||||
|
||||
### 主要风险与缓解策略
|
||||
|
||||
**外部 API / AI provider 速率限制与格式变更**:AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化;OpenAI-compatible city AI stream 可能出现 timeout、stream JSON 截断或并发竞争。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用);城市 AI 保持小并发队列、30s timeout、stream parse retry、页面内存缓存与 degraded fallback。
|
||||
**外部 API / AI provider 速率限制与格式变更**:AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化;OpenAI-compatible city structured detail 可能出现 timeout、stream JSON 截断或并发竞争。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用);城市结构化分析 保持小并发队列、30s timeout、stream parse retry、页面内存缓存与 degraded fallback。
|
||||
**密钥泄露与权限滥用**:Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。
|
||||
**支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。当前项目已经补齐“事件监听 + 确认补单”双路径、事件重放脚本、SQLite 审计事件与多 RPC fallback;现阶段的主要剩余风险不再是“没有防护”,而是链上合约仍为最小实现,owner 为单地址管理,且没有 pause 开关与 SafeERC20。
|
||||
**引入外部神经天气模型的商业合规风险**:GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
|
||||
@@ -270,5 +270,3 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
||||
- Pangu-Weather(权重 BY-NC-SA,禁商用):https://github.com/198808xc/Pangu-Weather
|
||||
- Polymarket 官方 Python CLOB SDK(MIT):https://github.com/Polymarket/py-clob-client
|
||||
- aiopolymarket(async、类型安全):https://github.com/the-odds-company/aiopolymarket
|
||||
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
|
||||
2. **Tab 下划线指示器不够明显**:`2px` 高度 + `opacity: 0.8` 的蓝色下划线容易被忽略。
|
||||
|
||||
3. **按钮层级不够清晰**:`.scan-primary-button`(蓝紫渐变)、`.scan-ai-button`(青绿渐变)、`.scan-ai-city-icon-button`(蓝色边框半透明)、`.scan-theme-button`(无边框无背景)四种视觉权重混在一起,用户难以判断优先级。
|
||||
3. **按钮层级不够清晰**:`.scan-primary-button`(蓝紫渐变)、`.scan-ai-button`(青绿渐变)、`.scan-city-icon-button`(蓝色边框半透明)、`.scan-theme-button`(无边框无背景)四种视觉权重混在一起,用户难以判断优先级。
|
||||
|
||||
4. **空状态/加载状态设计不一致**:
|
||||
- 地图加载有精美的云/雷达/热力动画
|
||||
@@ -233,7 +233,7 @@
|
||||
- Tab 切换缺少 `role="tablist"`/`role="tab"`/`aria-selected`
|
||||
- 折叠按钮缺少 `aria-expanded`
|
||||
|
||||
2. **焦点指示器不可见**:自定义按钮(如 `scan-theme-button`、`scan-ai-city-icon-button`)没有 focus-visible 样式
|
||||
2. **焦点指示器不可见**:自定义按钮(如 `scan-theme-button`、`scan-city-icon-button`)没有 focus-visible 样式
|
||||
|
||||
3. **颜色不是唯一的信息传达方式**:风险等级、Market decision 的色彩编码缺少对应的文字标签或图标补充
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 261 KiB |
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"recorded_at": "2026-06-12T23:32:49+08:00",
|
||||
"total_registered_users": 588,
|
||||
"verified_users": 573,
|
||||
"ever_signed_in_users": 561,
|
||||
"source": "supabase_auth_admin",
|
||||
"note": "Baseline recorded before verified-user growth milestone rewards launched."
|
||||
}
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
# PolyWeather 支付审计与防护说明
|
||||
|
||||
最后更新:`2026-03-21`
|
||||
最后更新:`2026-05-29`
|
||||
|
||||
## 1. 当前已落地的防护
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
|
||||
### 事件确认边界
|
||||
|
||||
- 后端只认链上 `OrderPaid` 事件。
|
||||
- 后端只认链上可验证事件:checkout 合约路径认 `OrderPaid`,直转路径认对应链上 USDC `Transfer`。
|
||||
- 前端提交 intent 不会直接视为支付完成。
|
||||
- `confirm_loop` 会再次按链上交易与确认数校验 intent。
|
||||
- `confirm_loop` 会再次按 intent 的 `chain_id`、`token_address`、收款地址、金额与确认数校验链上交易。
|
||||
- 若确认失败,当前会明确把 intent / transaction 落为失败态,而不是长期停留在 `submitted`。
|
||||
|
||||
当前已显式识别的失败原因包括:
|
||||
@@ -29,19 +29,23 @@
|
||||
- `receiver_mismatch`
|
||||
- `sender_mismatch`
|
||||
- `event_mismatch`
|
||||
- `token_mismatch`
|
||||
- `tx_reverted`
|
||||
|
||||
### RPC 多节点容灾
|
||||
|
||||
- 支持 `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- 支持默认链 `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- 支持多链 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
|
||||
- 格式示例:
|
||||
|
||||
```env
|
||||
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
|
||||
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com","https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
|
||||
```
|
||||
|
||||
- 启动时按顺序探活。
|
||||
- 当前节点断连或收据查询失败时,会自动切换到下一个可用 RPC。
|
||||
- 多链支付确认不会用默认链硬查交易;每笔 intent 会按自己的 `chain_id` 选择 RPC。
|
||||
|
||||
### 事件重放
|
||||
|
||||
@@ -87,8 +91,24 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
|
||||
- 已付款但未开通
|
||||
- 打到旧收款地址
|
||||
- 交易事件不匹配
|
||||
- 用户在钱包默认 Ethereum 网络付款,但旧系统只按 Polygon intent 查账
|
||||
|
||||
## 2. 当前合约的授权边界
|
||||
## 2. 当前支付链路边界
|
||||
|
||||
当前支持两类链路:
|
||||
|
||||
1. Polygon checkout 合约
|
||||
- 前端钱包支付会先切到 Polygon。
|
||||
- 后端创建 intent 后给出合约 `tx_payload`。
|
||||
- 确认时校验 `OrderPaid(orderId, payer, planId, amount, token)`。
|
||||
|
||||
2. Ethereum 主网 USDC 直转
|
||||
- 前端展示 Ethereum 网络、USDC 合约、收款钱包和金额。
|
||||
- 用户提交 tx hash 后,后端按 `intent.chain_id=1` 查询 Ethereum RPC。
|
||||
- 确认时校验 USDC `Transfer(from, to, amount)` 的 `to`、`token_address` 和金额。
|
||||
- 这条链路不依赖 Polygon checkout 合约,适合处理“用户钱包默认网络付款”的真实行为。
|
||||
|
||||
## 3. 当前合约的授权边界
|
||||
|
||||
合约源码:
|
||||
- [PolyWeatherCheckout.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckout.sol)
|
||||
@@ -110,7 +130,7 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
|
||||
4. 订单边界
|
||||
- 同一个 `orderId` 只能成功支付一次
|
||||
|
||||
## 3. 重入与重复支付判断
|
||||
## 4. 重入与重复支付判断
|
||||
|
||||
当前合约的 `pay` 逻辑顺序是:
|
||||
|
||||
@@ -136,7 +156,7 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
|
||||
- **最小可用支付合约**
|
||||
- 不是“全功能强防护合约”
|
||||
|
||||
## 4. 当前静态审计结论
|
||||
## 5. 当前静态审计结论
|
||||
|
||||
已提供脚本:
|
||||
- [check_payment_contract_security.py](/E:/web/PolyWeather/scripts/check_payment_contract_security.py)
|
||||
@@ -160,7 +180,7 @@ python scripts/check_payment_contract_security.py
|
||||
- 是否使用 SafeERC20
|
||||
- 是否在链上绑定套餐价格
|
||||
|
||||
## 5. 当前主要剩余风险
|
||||
## 6. 当前主要剩余风险
|
||||
|
||||
1. 单地址 owner
|
||||
- 建议把 `owner` 迁移到多签钱包
|
||||
@@ -175,7 +195,7 @@ python scripts/check_payment_contract_security.py
|
||||
- 当前使用 `IERC20.transferFrom`
|
||||
- 升级版合约更建议改为 OpenZeppelin `SafeERC20`
|
||||
|
||||
## 6. 推荐操作
|
||||
## 7. 推荐操作
|
||||
|
||||
### 每次支付配置变更后
|
||||
|
||||
@@ -233,7 +253,7 @@ docker compose exec polyweather_web python scripts/reconcile_subscription_by_ema
|
||||
- 用户声称已付费但未开通
|
||||
- 需要快速确认最近一笔 intent 是否能自动恢复
|
||||
|
||||
## 7. 下一版合约建议
|
||||
## 8. 下一版合约建议
|
||||
|
||||
如果后续升级合约,优先级建议:
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# PolyWeatherCheckout PolygonScan 验证(v1.8.0)
|
||||
# PolyWeatherCheckout PolygonScan 验证(v1.8.1)
|
||||
|
||||
最后更新:`2026-03-20`
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# PolyWeather 前端产品审查报告
|
||||
|
||||
> 审查日期:2026-06 | 视角:产品经理 | 范围:`frontend/` 全部页面、组件、用户流程
|
||||
> 审查日期:2026-06 | 视角:产品经理 | 范围:`frontend/` 全部页面、组件、用户流程 | 最后校准:2026-05-28
|
||||
|
||||
## 一、产品概览
|
||||
|
||||
PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核心价值主张:**结合多模型气象预报 + AI 机场报文解读 + Polymarket 市场价格,为交易决策提供一站式证据链。**
|
||||
PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核心价值主张:**结合多模型气象预报 + 结构化实况层 + Polymarket 市场价格,为交易决策提供一站式证据链。**
|
||||
|
||||
### 产品分层
|
||||
|
||||
@@ -12,13 +12,13 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|
||||
|------|------|------|
|
||||
| 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 |
|
||||
| Pro 试用 | 3 天全功能 | 注册后自动获得 |
|
||||
| Pro 订阅 | 城市决策卡(AI 机场报文 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 10 USDC/月(积分抵扣最多 3 USDC) |
|
||||
| Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 月付 29.9 USDC / 30 天,季度 79.9 USDC / 90 天(月付最多抵 3 USDC,季度最多抵 8 USDC) |
|
||||
|
||||
### 页面结构(9 个路由)
|
||||
|
||||
| 路由 | 功能 | 是否必需登录 |
|
||||
|------|------|-------------|
|
||||
| `/` | 主看板 — AI 天气决策台 | 否 |
|
||||
| `/` | 主看板 — 实时天气决策台 | 否 |
|
||||
| `/account` | 账户中心 — 身份/订阅/钱包/积分/Bot 绑定 | 是 |
|
||||
| `/auth/login` | 登录页(Google OAuth + 邮箱密码) | 否 |
|
||||
| `/docs/[...slug]` | 产品文档中心(8 篇双语文档) | 否 |
|
||||
@@ -37,7 +37,7 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|
||||
│ 点击城市 → 自动添加到决策卡工作区 + 切换到卡片视图 │
|
||||
│ 免费用户和 Pro 用户均可使用 │
|
||||
├─ 决策卡(分析) ──────────────────────────────┤
|
||||
│ 钉选的城市卡片:AI 机场解读 + 模型集群 + 市场层 + 图表 │
|
||||
│ 钉选的城市卡片:结构化实况 + 模型集群 + 市场层 + 实时图表 │
|
||||
│ 需要 Pro 订阅 │
|
||||
└──────────────────────────────────────────────┘
|
||||
└── 右侧栏:城市简报面板(始终可见,免费可用)
|
||||
@@ -46,14 +46,14 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|
||||
### 关键用户路径
|
||||
|
||||
1. **新用户落地** → 看到 3 步引导 → 地图 + 城市列表 → 点击城市 → 看到城市简报 → 想深入分析 → 遇到 Pro 付费墙(含功能说明)
|
||||
2. **Pro 用户工作流** → 地图选城市 → 自动钉选到决策卡 → 展开卡片 → 阅读 AI 报文解读 → 查看市场层 → 判断交易方向
|
||||
2. **Pro 用户工作流** → 地图选城市 → 自动钉选到决策卡 → 展开卡片 → 查看实测/DEB/概率/市场层 → 判断交易方向
|
||||
|
||||
## 三、做得好的地方
|
||||
|
||||
1. **地图 → 决策卡的自动流转设计** — 点击地图城市自动钉选到分析工作区并切换视图,"零步骤发现"
|
||||
2. **双语覆盖完整** — 所有 UI 文案、文档、AI 解读都有中英文对照,覆盖率接近 100%
|
||||
2. **双语覆盖完整** — 所有 UI 文案、文档、结构化解读都有中英文对照,覆盖率接近 100%
|
||||
3. **数据新鲜度可视化** — DataFreshnessBar 让用户一眼看到 METAR/模型/市场数据的新鲜度
|
||||
4. **AI 解读的产品化程度高** — 分层展示:快速判断 → 完整解读 → 证据链 → 风险提示
|
||||
4. **结构化解读的产品化程度高** — 分层展示:快速判断 → 完整解读 → 证据链 → 风险提示
|
||||
5. **免费层有实际价值** — 地图 + 城市简报不是"空壳",用户可以看真实气象数据
|
||||
6. **支付链路完整** — 从钱包绑定到链上签约到支付恢复,处理了多种异常情况
|
||||
7. **空状态有引导文字** — "Click a city on the map" 告诉用户下一步做什么
|
||||
@@ -67,4 +67,3 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|
||||
| 1 | **Docs 无搜索** — 8 篇文档没有搜索功能,用户必须逐篇浏览 | 🟢 待做 |
|
||||
| 2 | **Ops 面板无审计日志** — 管理员补发积分等操作没有审计记录 | 🟢 需后端 |
|
||||
| 3 | **注册后邮件验证引导** — 未验证邮箱的用户反复登录失败 | 🟡 需后端 |
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# PolyWeather Growth Rewards
|
||||
|
||||
PolyWeather has reached **588 registered users**.
|
||||
|
||||
That number matters because every new user helps us improve the product: more
|
||||
feedback, more real-world usage, and more pressure to make weather observations
|
||||
faster, clearer, and more reliable for prediction-market decisions.
|
||||
|
||||
We are introducing **PolyWeather Growth Rewards**.
|
||||
|
||||
From now on, verified-user milestones will unlock additional Pro time for every
|
||||
active paid member:
|
||||
|
||||
- 600 verified users: **+1 Pro day**
|
||||
- 750 verified users: **+2 Pro days**
|
||||
- 1,000 verified users: **+3 Pro days**
|
||||
- Every additional 100 verified users after 1,000: **+3 Pro days**
|
||||
|
||||
Rewards are issued automatically to active paid members when each milestone is
|
||||
reached. Every milestone can only be claimed once.
|
||||
|
||||
This is separate from our referral program. Referrals still reward the people
|
||||
who directly help PolyWeather grow, while Growth Rewards let all paying members
|
||||
share in the progress of the community.
|
||||
|
||||
We currently have **588 registered users**, including **573 verified users**.
|
||||
The first Growth Reward unlocks at 600 verified users.
|
||||
|
||||
Grow together. Earn more Pro time.
|
||||
|
||||
https://polyweather.top
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,107 @@
|
||||
# Redis Stream Realtime Event Architecture Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add Redis Stream as the production realtime event store and live fanout source while preserving the existing SSE patch protocol.
|
||||
|
||||
**Implementation status (2026-05-28):** completed in `v1.8.1`. Production uses Redis Stream when `POLYWEATHER_EVENT_STORE=redis`; SQLite remains the local/fallback event log. Browser API and frontend contract stayed on `/api/events` + `city_observation_patch.v1`.
|
||||
|
||||
**Architecture:** Keep `city_observation_patch.v1` and numeric `revision` as the browser contract. Add a Redis-backed event store beside the existing SQLite store, select it through an event-store factory, and let Redis-backed deployments fan out live events through a Redis subscriber loop instead of direct in-process ingest broadcast.
|
||||
|
||||
**Tech Stack:** FastAPI, redis-py, Redis Stream, SQLite fallback, pytest, existing frontend EventSource hook.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Redis Store Contract
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/test_redis_realtime_event_store.py`
|
||||
- Create: `web/redis_realtime_event_store.py`
|
||||
|
||||
- [x] **Step 1: Write failing tests**
|
||||
|
||||
Cover append, replay by city, replay gap, and fallback-independent event shape.
|
||||
|
||||
- [x] **Step 2: Verify tests fail**
|
||||
|
||||
Run: `python -m pytest tests/test_redis_realtime_event_store.py -q`
|
||||
|
||||
Expected: fails because `web.redis_realtime_event_store` does not exist.
|
||||
|
||||
- [x] **Step 3: Implement Redis store**
|
||||
|
||||
Add `RedisRealtimeEventStore` with `append_event`, `latest_revision`, `replay_events`, `replay_requires_resync`, and idempotent `start_live_subscription`.
|
||||
|
||||
- [x] **Step 4: Verify tests pass**
|
||||
|
||||
Run: `python -m pytest tests/test_redis_realtime_event_store.py -q`
|
||||
|
||||
### Task 2: Store Factory And SSE Router
|
||||
|
||||
**Files:**
|
||||
- Create: `web/realtime_event_store_factory.py`
|
||||
- Modify: `web/routers/sse_router.py`
|
||||
- Test: `tests/test_sse_replay.py`
|
||||
|
||||
- [x] **Step 1: Write failing tests**
|
||||
|
||||
Cover `POLYWEATHER_EVENT_STORE=redis` selecting Redis, SQLite fallback when Redis is not configured, and Redis ingest not doing direct local broadcast.
|
||||
|
||||
- [x] **Step 2: Verify tests fail**
|
||||
|
||||
Run: `python -m pytest tests/test_sse_replay.py tests/test_realtime_event_store_factory.py -q`
|
||||
|
||||
- [x] **Step 3: Implement factory and router integration**
|
||||
|
||||
Use Redis store when configured; otherwise keep existing SQLite store. Ingest broadcasts directly only for stores that do not provide external live fanout.
|
||||
|
||||
- [x] **Step 4: Verify tests pass**
|
||||
|
||||
Run: `python -m pytest tests/test_sse_replay.py tests/test_realtime_event_store_factory.py -q`
|
||||
|
||||
### Task 3: Dependency And Operational Config
|
||||
|
||||
**Files:**
|
||||
- Modify: `requirements.txt`
|
||||
- Modify: `docs/superpowers/specs/2026-05-27-redis-stream-realtime-event-architecture-design.md`
|
||||
|
||||
- [x] **Step 1: Add redis-py dependency**
|
||||
|
||||
Add `redis>=5.0.0` to `requirements.txt`.
|
||||
|
||||
- [x] **Step 2: Add final config notes**
|
||||
|
||||
Ensure the design doc names the runtime env vars used by code.
|
||||
|
||||
### Task 4: Verification
|
||||
|
||||
**Files:**
|
||||
- No production edits unless tests reveal a gap.
|
||||
|
||||
- [x] **Step 1: Run backend realtime tests**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_realtime_patch_schema.py tests/test_realtime_event_store.py tests/test_redis_realtime_event_store.py tests/test_realtime_event_store_factory.py tests/test_sse_replay.py -q
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run broader frontend/backend checks**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run test:business
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
- [x] **Step 3: Inspect diff**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
git status --short
|
||||
```
|
||||
@@ -0,0 +1,345 @@
|
||||
# Feedback Reward Account Detail Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let users see which submitted feedback earned points, how many points were awarded, and the reward reason in the account page.
|
||||
|
||||
**Architecture:** Store reward metadata directly on `user_feedback` rows so the existing feedback list API can expose the reward source without a separate ledger join. The account feedback panel renders reward state inline with each feedback item and stays silent for rows with no reward metadata.
|
||||
|
||||
**Tech Stack:** Python SQLite `DBManager`, FastAPI feedback service, Next.js/React account components, TypeScript source-based business tests.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `src/database/db_manager.py`: add reward columns, serialize them, and add `update_user_feedback_reward()` for future ops reward workflows.
|
||||
- Modify `tests/test_user_feedback.py`: add backend coverage for reward defaults and reward metadata round trip.
|
||||
- Modify `frontend/types/ops.ts`: add optional reward fields to `UserFeedbackEntry`.
|
||||
- Modify `frontend/components/account/AccountFeedbackPanel.tsx`: render reward labels and reasons inside each feedback row.
|
||||
- Modify `frontend/components/account/__tests__/accountFeedbackPanel.test.ts`: assert the panel handles reward display states.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend Feedback Reward Metadata
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_user_feedback.py`
|
||||
- Modify: `src/database/db_manager.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing backend test**
|
||||
|
||||
Add this test to `tests/test_user_feedback.py`:
|
||||
|
||||
```python
|
||||
def test_user_feedback_reward_metadata_round_trip(tmp_path):
|
||||
db = DBManager(str(tmp_path / "polyweather-feedback-reward.db"))
|
||||
|
||||
created = db.append_user_feedback(
|
||||
category="data",
|
||||
message="Hong Kong COWIN reading was stale.",
|
||||
user_id="user-reward",
|
||||
user_email="reward@example.com",
|
||||
)
|
||||
|
||||
assert created["reward_points"] == 0
|
||||
assert created["reward_reason"] == ""
|
||||
assert created["reward_status"] == ""
|
||||
assert created["rewarded_at"] is None
|
||||
|
||||
rewarded = db.update_user_feedback_reward(
|
||||
created["id"],
|
||||
points=300,
|
||||
reason="Valid data freshness report",
|
||||
status="granted",
|
||||
)
|
||||
|
||||
assert rewarded is not None
|
||||
assert rewarded["reward_points"] == 300
|
||||
assert rewarded["reward_reason"] == "Valid data freshness report"
|
||||
assert rewarded["reward_status"] == "granted"
|
||||
assert rewarded["rewarded_at"]
|
||||
|
||||
row = db.list_user_feedback(
|
||||
limit=10,
|
||||
user_id="user-reward",
|
||||
user_email="reward@example.com",
|
||||
)[0]
|
||||
assert row["id"] == created["id"]
|
||||
assert row["reward_points"] == 300
|
||||
assert row["reward_reason"] == "Valid data freshness report"
|
||||
assert row["reward_status"] == "granted"
|
||||
assert row["rewarded_at"] == rewarded["rewarded_at"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the backend test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_user_feedback.py::test_user_feedback_reward_metadata_round_trip -q
|
||||
```
|
||||
|
||||
Expected: failure because `reward_points` is missing or `update_user_feedback_reward` is undefined.
|
||||
|
||||
- [ ] **Step 3: Add reward columns and serialization**
|
||||
|
||||
In `src/database/db_manager.py`, update `user_feedback` schema and migration:
|
||||
|
||||
```python
|
||||
reward_points INTEGER DEFAULT 0,
|
||||
reward_reason TEXT DEFAULT '',
|
||||
rewarded_at TIMESTAMP,
|
||||
reward_status TEXT DEFAULT ''
|
||||
```
|
||||
|
||||
Add `_ensure_column()` calls for the same columns in the existing migration block.
|
||||
|
||||
Update every feedback `SELECT` to include:
|
||||
|
||||
```sql
|
||||
reward_points, reward_reason, rewarded_at, reward_status
|
||||
```
|
||||
|
||||
Update `_feedback_row_to_dict()` to return:
|
||||
|
||||
```python
|
||||
"reward_points": max(0, int(row["reward_points"] or 0)),
|
||||
"reward_reason": str(row["reward_reason"] or ""),
|
||||
"rewarded_at": row["rewarded_at"],
|
||||
"reward_status": str(row["reward_status"] or ""),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add reward update method**
|
||||
|
||||
Add this method near `update_user_feedback_status()` in `src/database/db_manager.py`:
|
||||
|
||||
```python
|
||||
def update_user_feedback_reward(
|
||||
self,
|
||||
feedback_id: int,
|
||||
*,
|
||||
points: int,
|
||||
reason: str = "",
|
||||
status: str = "granted",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
safe_points = max(0, int(points or 0))
|
||||
normalized_reason = str(reason or "").strip()[:500]
|
||||
normalized_status = str(status or "").strip().lower()[:40]
|
||||
if not normalized_status:
|
||||
normalized_status = "granted" if safe_points > 0 else "skipped"
|
||||
now = datetime.now().isoformat()
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE user_feedback
|
||||
SET reward_points = ?,
|
||||
reward_reason = ?,
|
||||
reward_status = ?,
|
||||
rewarded_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
safe_points,
|
||||
normalized_reason,
|
||||
normalized_status,
|
||||
now,
|
||||
now,
|
||||
int(feedback_id),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, category, message, source, status, contact, user_id,
|
||||
user_email, context_json, reward_points, reward_reason,
|
||||
rewarded_at, reward_status, created_at, updated_at
|
||||
FROM user_feedback
|
||||
WHERE id = ?
|
||||
""",
|
||||
(int(feedback_id),),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
return self._feedback_row_to_dict(row) if row else None
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_user_feedback.py -q
|
||||
```
|
||||
|
||||
Expected: all `test_user_feedback.py` tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Account Feedback Reward Display
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/types/ops.ts`
|
||||
- Modify: `frontend/components/account/AccountFeedbackPanel.tsx`
|
||||
- Modify: `frontend/components/account/__tests__/accountFeedbackPanel.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing frontend test**
|
||||
|
||||
Extend the final assertion in `frontend/components/account/__tests__/accountFeedbackPanel.test.ts` or add a new assertion:
|
||||
|
||||
```ts
|
||||
assert(
|
||||
feedbackPanelSource.includes("reward_points") &&
|
||||
feedbackPanelSource.includes("reward_reason") &&
|
||||
feedbackPanelSource.includes("reward_status") &&
|
||||
feedbackPanelSource.includes("formatRewardPoints") &&
|
||||
feedbackPanelSource.includes("renderFeedbackReward") &&
|
||||
feedbackPanelSource.includes("奖励原因"),
|
||||
"account feedback panel must show per-feedback reward points and reward reasons",
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the frontend business test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend; npm run test:business -- accountFeedbackPanel
|
||||
```
|
||||
|
||||
Expected: failure because the panel does not yet reference the reward fields.
|
||||
|
||||
- [ ] **Step 3: Extend frontend type**
|
||||
|
||||
Add optional fields to `UserFeedbackEntry` in `frontend/types/ops.ts`:
|
||||
|
||||
```ts
|
||||
reward_points?: number;
|
||||
reward_reason?: string;
|
||||
rewarded_at?: string | null;
|
||||
reward_status?: string;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add account reward rendering helpers**
|
||||
|
||||
In `AccountFeedbackPanel.tsx`, add helpers:
|
||||
|
||||
```tsx
|
||||
function formatRewardPoints(points?: number) {
|
||||
const value = Math.max(0, Number(points || 0));
|
||||
return `+${value.toLocaleString()} points`;
|
||||
}
|
||||
|
||||
function rewardStatusText(status?: string, isEn = false) {
|
||||
const key = String(status || "").toLowerCase();
|
||||
if (key === "pending") return isEn ? "Reward pending" : "奖励待处理";
|
||||
if (key === "skipped") return isEn ? "No points awarded" : "未发放积分";
|
||||
return isEn ? "Feedback reward" : "反馈奖励";
|
||||
}
|
||||
|
||||
function renderFeedbackReward(entry: UserFeedbackEntry, isEn: boolean) {
|
||||
const points = Math.max(0, Number(entry.reward_points || 0));
|
||||
const rewardStatus = String(entry.reward_status || "").toLowerCase();
|
||||
const reason = String(entry.reward_reason || "").trim();
|
||||
if (points <= 0 && !rewardStatus && !reason) return null;
|
||||
const granted = points > 0 || rewardStatus === "granted";
|
||||
return (
|
||||
<div className={`mt-2 rounded-lg border px-3 py-2 text-xs ${
|
||||
granted
|
||||
? "border-amber-200 bg-amber-50 text-amber-800"
|
||||
: "border-slate-200 bg-slate-50 text-slate-600"
|
||||
}`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-bold">{rewardStatusText(rewardStatus, isEn)}</span>
|
||||
{points > 0 ? <span className="font-mono font-black">{formatRewardPoints(points)}</span> : null}
|
||||
</div>
|
||||
{reason ? (
|
||||
<div className="mt-1 leading-5 text-slate-600">
|
||||
{isEn ? "Reason" : "奖励原因"}: {reason}
|
||||
</div>
|
||||
) : null}
|
||||
{entry.rewarded_at ? (
|
||||
<div className="mt-1 font-mono text-[11px] text-slate-400">
|
||||
{compactDate(entry.rewarded_at)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Render reward detail in each feedback row**
|
||||
|
||||
Inside each feedback row, after the feedback message paragraph, add:
|
||||
|
||||
```tsx
|
||||
{renderFeedbackReward(entry, isEn)}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run frontend business test**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend; npm run test:business -- accountFeedbackPanel
|
||||
```
|
||||
|
||||
Expected: `accountFeedbackPanel` passes.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Verification and Commit
|
||||
|
||||
**Files:**
|
||||
- Verify all touched files.
|
||||
|
||||
- [ ] **Step 1: Run targeted backend verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_user_feedback.py -q
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 2: Run targeted frontend verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend; npm run test:business -- accountFeedbackPanel
|
||||
```
|
||||
|
||||
Expected: `accountFeedbackPanel` test passes.
|
||||
|
||||
- [ ] **Step 3: Run broader low-cost verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m ruff check .
|
||||
cd frontend; npm run typecheck
|
||||
```
|
||||
|
||||
Expected: both commands pass.
|
||||
|
||||
- [ ] **Step 4: Check status and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git diff --check
|
||||
git add docs/superpowers/plans/2026-06-08-feedback-reward-account-detail.md tests/test_user_feedback.py src/database/db_manager.py frontend/types/ops.ts frontend/components/account/AccountFeedbackPanel.tsx frontend/components/account/__tests__/accountFeedbackPanel.test.ts
|
||||
git commit -m "Show feedback reward details in account"
|
||||
```
|
||||
|
||||
Expected: commit succeeds with only planned files staged.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: reward metadata fields, existing API payload, account inline display, missing metadata fallback, and future ops reuse are covered.
|
||||
- Scope control: the plan does not implement automatic reward issuance or external notifications.
|
||||
- Type consistency: frontend fields use `reward_points`, `reward_reason`, `rewarded_at`, and `reward_status`, matching backend serialization.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Growth Milestone Pro Rewards Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Automatically extend active paid Pro memberships when verified-user growth milestones are reached.
|
||||
|
||||
**Architecture:** A low-frequency Bot-owned loop reads Supabase Auth and payment data, stores daily growth history in SQLite, and grants idempotent milestone-specific bonus subscriptions. SQLite settlement and payout records prevent duplicate processing and support retries.
|
||||
|
||||
**Tech Stack:** Python, SQLite, Supabase REST/Auth Admin API, Telegram Bot runtime, pytest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Define milestone and eligibility policy
|
||||
|
||||
**Files:**
|
||||
- Create: `src/bot/growth_milestone_reward_loop.py`
|
||||
- Test: `tests/test_growth_milestone_reward_loop.py`
|
||||
|
||||
- [ ] Write failing tests for the 600/750/1000/100-step milestone schedule and paid-member eligibility intersection.
|
||||
- [ ] Run `python -m pytest tests/test_growth_milestone_reward_loop.py -q` and confirm failure because the module is missing.
|
||||
- [ ] Implement pure policy helpers and Supabase query helpers.
|
||||
- [ ] Re-run the focused tests and confirm they pass.
|
||||
|
||||
### Task 2: Persist history and settlement state
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/database/db_manager.py`
|
||||
- Test: `tests/test_growth_milestone_reward_loop.py`
|
||||
|
||||
- [ ] Write failing tests for daily snapshot upsert, payout idempotency, and milestone settlement.
|
||||
- [ ] Run the focused tests and confirm the new DB methods are missing.
|
||||
- [ ] Add `user_growth_snapshots`, `growth_milestone_runs`, and `growth_milestone_payouts`.
|
||||
- [ ] Re-run the focused tests and confirm they pass.
|
||||
|
||||
### Task 3: Run and announce automatic settlement
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/bot/growth_milestone_reward_loop.py`
|
||||
- Modify: `src/bot/runtime_coordinator.py`
|
||||
- Modify: `src/utils/config_validation.py`
|
||||
- Modify: `.env.example`
|
||||
- Test: `tests/test_growth_milestone_reward_loop.py`
|
||||
- Test: `tests/test_bot_runtime_coordinator.py`
|
||||
|
||||
- [ ] Write failing tests for idempotent bonus grants and runtime-loop registration.
|
||||
- [ ] Run focused tests and confirm the expected failures.
|
||||
- [ ] Implement the settlement loop, retry behavior, and bilingual announcement.
|
||||
- [ ] Re-run focused tests and confirm they pass.
|
||||
|
||||
### Task 4: Record baseline and publishable announcement
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/operations/user-growth-history.json`
|
||||
- Create: `docs/social/2026-06-12-growth-rewards-x-post.md`
|
||||
- Create: `docs/social/assets/polyweather-588-growth-rewards.png`
|
||||
|
||||
- [ ] Record the verified 2026-06-12 baseline.
|
||||
- [ ] Write the English X announcement with accurate total-user and verified-user wording.
|
||||
- [ ] Save the generated social image in the repository.
|
||||
|
||||
### Task 5: Verify and deploy
|
||||
|
||||
- [ ] Run focused backend tests.
|
||||
- [ ] Run `python -m ruff check src/bot/growth_milestone_reward_loop.py src/bot/runtime_coordinator.py src/database/db_manager.py tests/test_growth_milestone_reward_loop.py tests/test_bot_runtime_coordinator.py`.
|
||||
- [ ] Run the broader relevant test suite.
|
||||
- [ ] Commit and push to `main`.
|
||||
- [ ] Check GitHub Actions and production health.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# DEB Training Settlement Worker Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Restore automatic DEB training data freshness after the realtime collector/canonical-cache split.
|
||||
|
||||
**Architecture:** Add a dedicated low-frequency training settlement service that runs city analysis for forecast/DEB snapshots and reconciles recent settled actual highs. Keep it separate from the high-frequency observation collector so user-facing realtime updates stay light.
|
||||
|
||||
**Tech Stack:** Python, FastAPI service modules, Docker Compose, pytest, existing `update_daily_record()` and `reconcile_recent_actual_highs()` paths.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Training Settlement Service
|
||||
|
||||
**Files:**
|
||||
- Create: `web/training_settlement_service.py`
|
||||
- Create: `tests/test_training_settlement_service.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing service test**
|
||||
|
||||
```python
|
||||
def test_training_settlement_cycle_runs_analysis_and_reconciles_supported_cities():
|
||||
calls = {"analysis": [], "reconcile": []}
|
||||
|
||||
def analysis_runner(city):
|
||||
calls["analysis"].append(city)
|
||||
return {"city": city, "deb": {"prediction": 31.2}}
|
||||
|
||||
def reconciler(city, *, lookback_days):
|
||||
calls["reconcile"].append((city, lookback_days))
|
||||
return {"ok": True, "updated": 1}
|
||||
|
||||
result = run_training_settlement_cycle(
|
||||
city_registry={
|
||||
"shanghai": {"icao": "ZSSS", "settlement_source": "metar"},
|
||||
"legacy": {"settlement_source": "wunderground"},
|
||||
},
|
||||
analysis_runner=analysis_runner,
|
||||
actual_reconciler=reconciler,
|
||||
lookback_days=9,
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["processed"] == 1
|
||||
assert calls["analysis"] == ["shanghai"]
|
||||
assert calls["reconcile"] == [("shanghai", 9)]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `python -m pytest tests/test_training_settlement_service.py::test_training_settlement_cycle_runs_analysis_and_reconciles_supported_cities -q`
|
||||
|
||||
Expected: FAIL because `web.training_settlement_service` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the service**
|
||||
|
||||
Create `run_training_settlement_cycle()` with injectable `city_registry`, `analysis_runner`, and `actual_reconciler`. Default analysis runner calls `web.analysis_service._analyze(city, force_refresh=False, detail_mode="panel")`; default reconciler calls `src.analysis.deb_algorithm.reconcile_recent_actual_highs()`.
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `python -m pytest tests/test_training_settlement_service.py -q`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Worker Entrypoint And Compose
|
||||
|
||||
**Files:**
|
||||
- Create: `web/training_settlement_worker.py`
|
||||
- Modify: `docker-compose.yml`
|
||||
- Modify: `tests/test_deployment_runtime_config.py`
|
||||
|
||||
- [ ] **Step 1: Write failing deployment tests**
|
||||
|
||||
Assert the compose file contains `polyweather_training_settlement`, command `python -m web.training_settlement_worker`, role `training_settlement`, and conservative interval/lookback environment variables.
|
||||
|
||||
- [ ] **Step 2: Run the deployment test to verify it fails**
|
||||
|
||||
Run: `python -m pytest tests/test_deployment_runtime_config.py::test_runtime_compose_splits_realtime_workers -q`
|
||||
|
||||
Expected: FAIL because the worker service is absent.
|
||||
|
||||
- [ ] **Step 3: Implement worker and compose service**
|
||||
|
||||
The worker loops `run_training_settlement_cycle()` every `POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC` seconds, with initial delay and lookback from environment.
|
||||
|
||||
- [ ] **Step 4: Run focused tests**
|
||||
|
||||
Run: `python -m pytest tests/test_training_settlement_service.py tests/test_deployment_runtime_config.py -q`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Stale Monitoring
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/diagnostics/health.py`
|
||||
- Modify: `web/services/system_api.py`
|
||||
- Modify: `tests/test_web_observability.py`
|
||||
|
||||
- [ ] **Step 1: Write failing observability tests**
|
||||
|
||||
Assert system status training summaries include `stale_days` for daily/truth/features, and Prometheus exports stale gauges for training data.
|
||||
|
||||
- [ ] **Step 2: Run focused observability tests to verify failure**
|
||||
|
||||
Run: `python -m pytest tests/test_web_observability.py::test_system_status_includes_training_data tests/test_web_observability.py::test_metrics_endpoint_returns_prometheus_payload_for_ops_admin -q`
|
||||
|
||||
Expected: FAIL because stale fields/gauges are absent.
|
||||
|
||||
- [ ] **Step 3: Implement stale summary and gauges**
|
||||
|
||||
Add date-diff calculation against UTC today. Export `polyweather_daily_records_stale_days`, `polyweather_truth_records_stale_days`, `polyweather_training_features_stale_days`, and `polyweather_training_data_stale`.
|
||||
|
||||
- [ ] **Step 4: Run focused observability tests**
|
||||
|
||||
Run: `python -m pytest tests/test_web_observability.py::test_system_status_includes_training_data tests/test_web_observability.py::test_metrics_endpoint_returns_prometheus_payload_for_ops_admin -q`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 4: Verification And Backfill
|
||||
|
||||
**Files:**
|
||||
- Optional create: `scripts/backfill_training_settlement.py`
|
||||
|
||||
- [ ] **Step 1: Run backend checks**
|
||||
|
||||
Run: `python -m ruff check .`
|
||||
|
||||
Run: `python -m pytest tests/test_training_settlement_service.py tests/test_deployment_runtime_config.py tests/test_web_observability.py -q`
|
||||
|
||||
- [ ] **Step 2: Run local one-shot cycle**
|
||||
|
||||
Run: `python -m web.training_settlement_worker --once --lookback-days 10 --cities shanghai`
|
||||
|
||||
Expected: JSON-like log output with `ok=True`; local DB should get a fresh row for the current local target date if analysis succeeds.
|
||||
|
||||
- [ ] **Step 3: Production note**
|
||||
|
||||
Missed forecast snapshots from 2026-06-15 to 2026-06-22 cannot be reconstructed honestly unless archived city analysis payloads exist. The worker restores forward automatic samples; actual-high truth can still be reconciled for supported settlement sources.
|
||||
@@ -0,0 +1,408 @@
|
||||
# Redis Stream Realtime Event Architecture Design
|
||||
|
||||
> 日期: 2026-05-27
|
||||
> 范围: PolyWeather 网站图表实时观测事件层
|
||||
> 目标服务器: 2 vCPU / 8 GB RAM / 50 GB 系统盘
|
||||
> 实施状态: 2026-05-28 已落地为 `v1.8.1`
|
||||
|
||||
## 背景
|
||||
|
||||
当前实时层已经具备生产化雏形:
|
||||
|
||||
- 后端已有 `city_observation_patch.v1` schema。
|
||||
- `web/realtime_event_store.py` 使用 SQLite 保存 `observation_patch_events`,支持 `since_revision` replay。
|
||||
- `/api/events?cities=...&since_revision=...&replay_limit=...` 已经是前端 SSE 入口。
|
||||
- 前端 `frontend/hooks/use-sse-patches.ts` 使用 `EventSource`,并按当前可见城市订阅。
|
||||
|
||||
这解决了单进程、单实例下的实时推送和短窗口 replay,但还不够“一步到位”:
|
||||
|
||||
- 多 worker / 多实例时,进程内广播不能共享。
|
||||
- SQLite replay log 可以补数据,但不适合作为跨实例 live fanout。
|
||||
- 服务重启、浏览器后台恢复、SSE 断线后,需要更稳定的事件源。
|
||||
- 后续如果把采集器拆成独立进程,需要一个明确的后端事件总线。
|
||||
|
||||
## 目标
|
||||
|
||||
升级为“Redis Stream 主事件日志 + SSE 浏览器传输 + SQLite fallback”的架构:
|
||||
|
||||
- Redis Stream 是生产环境实时事件主通道。
|
||||
- SSE 仍然是浏览器唯一实时入口。
|
||||
- 前端协议保持 `city_observation_patch.v1`,不让浏览器感知 Redis。
|
||||
- 继续使用 numeric `revision`,兼容现有前端 `since_revision` 逻辑。
|
||||
- 支持按城市订阅、断线 replay、多 worker fanout。
|
||||
- 事件只保留短窗口;当前默认按 Redis Stream `MAXLEN` 控制,建议以约 24 小时 replay 为目标,不做 72 小时保留。
|
||||
|
||||
## 非目标
|
||||
|
||||
这次不做以下事情:
|
||||
|
||||
- 不引入 Kafka、RabbitMQ 或复杂 consumer group。
|
||||
- 不把天气历史数据全部 event sourcing。
|
||||
- 不让浏览器直接连接 Redis。
|
||||
- 不把 DEB 预测、模型曲线、概率分布都改成事件流。
|
||||
- 不做永久行情归档;超过 replay 窗口时继续走 HTTP full detail resync。
|
||||
|
||||
## 推荐架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Collector["WeatherDataCollector / city refresh"] --> Ingest["POST /api/internal/collector-patch"]
|
||||
Ingest --> Normalize["city_observation_patch.v1 normalizer"]
|
||||
Normalize --> Store["RealtimeEventStore interface"]
|
||||
Store --> Redis["Redis Stream: stream:city_observation"]
|
||||
Store --> SQLite["SQLite fallback: observation_patch_events"]
|
||||
Redis --> WorkerSub["Redis XREAD subscriber per API worker"]
|
||||
WorkerSub --> LocalFanout["Local SSE fanout"]
|
||||
SQLite --> ReplayFallback["SQLite replay fallback"]
|
||||
SSE["GET /api/events?cities=...&since_revision=..."] --> Browser["Frontend EventSource"]
|
||||
LocalFanout --> SSE
|
||||
Redis --> SSE
|
||||
ReplayFallback --> SSE
|
||||
Browser --> Chart["Chart applies patch"]
|
||||
Browser -->|resync_required| Detail["HTTP /api/city/{city}/detail"]
|
||||
```
|
||||
|
||||
关键原则:
|
||||
|
||||
- Redis Stream 负责后端事件保存、短窗口 replay、多实例共享。
|
||||
- SSE 负责服务端到浏览器的单向推送。
|
||||
- SQLite 只做 fallback 和本地开发,不再作为生产主事件总线。
|
||||
- 前端只认 patch event,不关心事件来自 Redis 还是 SQLite。
|
||||
|
||||
## 事件模型
|
||||
|
||||
继续使用现有 schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "city_observation_patch.v1",
|
||||
"revision": 12345,
|
||||
"city": "taipei",
|
||||
"source": "cwa",
|
||||
"obs_time": "2026-05-27T10:00:00+08:00",
|
||||
"observed_at_utc": "2026-05-27T02:00:00Z",
|
||||
"observed_at_local": "2026-05-27T10:00:00+08:00",
|
||||
"city_local_date": "2026-05-27",
|
||||
"city_timezone": "Asia/Taipei",
|
||||
"source_cadence_sec": 600,
|
||||
"ts": 1780000000000,
|
||||
"payload": {
|
||||
"temp": 34.2,
|
||||
"max_so_far": 34.2,
|
||||
"station_code": "466920",
|
||||
"station_label": "中央气象署台北站",
|
||||
"series_key": "settlement",
|
||||
"unit": "celsius"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`revision` 仍然是前端公开排序键,必须是全局单调递增整数。
|
||||
|
||||
Redis Stream 自身的 stream ID,例如 `1780000000000-0`,只作为后端内部定位信息,不暴露给前端作为主 revision。
|
||||
|
||||
## Redis Stream 设计
|
||||
|
||||
Stream key:
|
||||
|
||||
```text
|
||||
stream:city_observation
|
||||
```
|
||||
|
||||
Revision key:
|
||||
|
||||
```text
|
||||
counter:city_observation_revision
|
||||
```
|
||||
|
||||
每条 stream entry 字段:
|
||||
|
||||
- `revision`: 全局递增整数,公开给 SSE/前端。
|
||||
- `type`: `city_observation_patch.v1`
|
||||
- `city`: normalized city key。
|
||||
- `source`: source code,例如 `cwa`, `metar`, `amos`, `amsc_awos`。
|
||||
- `obs_time`: 原始观测时间。
|
||||
- `payload_json`: compact JSON。
|
||||
- `created_at_ms`: 写入 Redis 的 UTC epoch ms。
|
||||
- `producer_id`: API worker / collector instance id,便于排查。
|
||||
|
||||
写入必须通过 Lua 脚本原子完成:
|
||||
|
||||
1. `INCR counter:city_observation_revision`
|
||||
2. `XADD stream:city_observation MAXLEN ~ {maxlen} * revision ... payload_json ...`
|
||||
3. 返回 `{revision, stream_id}`
|
||||
|
||||
原因:
|
||||
|
||||
- 避免 `INCR` 成功但 `XADD` 失败后造成非必要 gap。
|
||||
- 让 revision 分配和事件入流成为一个原子操作。
|
||||
- 保持现有前端 numeric `since_revision` 不变。
|
||||
|
||||
## Replay 策略
|
||||
|
||||
`GET /api/events` 收到 `since_revision` 后:
|
||||
|
||||
1. 读取 Redis Stream 最旧 entry,拿到 oldest revision。
|
||||
2. 如果 `since_revision > 0` 且 `since_revision < oldest_revision - 1`,发送 `resync_required`。
|
||||
3. 从 Redis Stream 顺序扫描事件。
|
||||
4. 过滤 `revision > since_revision`。
|
||||
5. 过滤 `city IN subscribed_cities`。
|
||||
6. 返回最多 `replay_limit + 1` 条。
|
||||
7. 如果超过 `replay_limit`,发送 `resync_required`,让前端 HTTP resync。
|
||||
|
||||
当前事件量很小,单个 stream 顺序扫描 24 小时事件是可以接受的:
|
||||
|
||||
- 30 城市 * 1 条/分钟 * 24 小时 = 43,200 条。
|
||||
- replay 通常只发生在断线重连,不是高频查询。
|
||||
|
||||
如果后续城市量扩大,或者确实需要超过 24 小时的 replay,再加 per-city stream 或 Redis sorted index。
|
||||
|
||||
## Live Fanout
|
||||
|
||||
每个 API worker 启动一个 Redis subscriber loop:
|
||||
|
||||
```text
|
||||
XREAD BLOCK 5000 STREAMS stream:city_observation {last_seen_stream_id}
|
||||
```
|
||||
|
||||
读取到事件后:
|
||||
|
||||
1. 解析 `city_observation_patch.v1`。
|
||||
2. 按本进程 SSE 连接的 `cities` subscription 过滤。
|
||||
3. 推送给本进程匹配的浏览器连接。
|
||||
4. 更新 worker 内存中的 `last_seen_stream_id`。
|
||||
|
||||
ingest endpoint 写入 Redis 后不直接广播本地连接,由 subscriber loop 统一 fanout。这样避免同一个 worker 上“写入后本地广播一次、subscriber 又广播一次”的重复事件。
|
||||
|
||||
前端仍然用 revision 去重。即使极端情况下收到重复 revision,也应忽略旧 revision。
|
||||
|
||||
## SQLite Fallback
|
||||
|
||||
保留当前 SQLite store,但角色调整为:
|
||||
|
||||
- 本地开发无需 Redis 时使用。
|
||||
- Redis 不可用且允许降级时使用。
|
||||
- 单实例临时运行时可用。
|
||||
|
||||
新增环境变量:
|
||||
|
||||
```text
|
||||
POLYWEATHER_EVENT_STORE=redis
|
||||
POLYWEATHER_EVENT_STORE_FALLBACK=sqlite
|
||||
POLYWEATHER_REDIS_URL=redis://127.0.0.1:6379/0
|
||||
POLYWEATHER_REDIS_STREAM_KEY=stream:city_observation
|
||||
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
|
||||
POLYWEATHER_PATCH_EVENT_RETENTION_HOURS=24
|
||||
POLYWEATHER_REDIS_REQUIRED=true
|
||||
```
|
||||
|
||||
推荐生产策略:
|
||||
|
||||
- `POLYWEATHER_EVENT_STORE=redis`
|
||||
- `POLYWEATHER_REDIS_REQUIRED=true`
|
||||
- Redis 写入失败时 ingest 返回 503,不广播不可 replay 的事件。
|
||||
|
||||
推荐本地策略:
|
||||
|
||||
- 不配置 Redis,自动使用 SQLite。
|
||||
- 或 `POLYWEATHER_EVENT_STORE=sqlite`。
|
||||
|
||||
## Redis 容量与配置
|
||||
|
||||
当前 VPS 可以跑本机 Redis。
|
||||
|
||||
建议:
|
||||
|
||||
```text
|
||||
bind 127.0.0.1
|
||||
protected-mode yes
|
||||
appendonly yes
|
||||
appendfsync everysec
|
||||
maxmemory 512mb
|
||||
maxmemory-policy noeviction
|
||||
```
|
||||
|
||||
默认 `MAXLEN ~ 50000` 足够约 24 小时事件:
|
||||
|
||||
- 30 城市 * 1/min * 24h = 43,200 条。
|
||||
- payload 约 1-3 KB,实际内存取决于 Redis Stream overhead。
|
||||
- 512 MB Redis maxmemory 对当前事件量足够。
|
||||
|
||||
注意:这个事件流不是永久数据仓库。超过 24 小时窗口后,前端应 HTTP resync 当前城市 detail。
|
||||
|
||||
## 模块边界
|
||||
|
||||
新增/调整后端接口:
|
||||
|
||||
### `web/realtime_event_store.py`
|
||||
|
||||
定义统一接口:
|
||||
|
||||
```python
|
||||
class RealtimeEventStore:
|
||||
def append_event(self, event: dict) -> dict: ...
|
||||
def replay_events(self, *, cities: set[str] | None, since_revision: int, limit: int) -> list[dict]: ...
|
||||
def replay_requires_resync(self, *, cities: set[str] | None, since_revision: int, replay_count: int, limit: int) -> bool: ...
|
||||
def latest_revision(self) -> int: ...
|
||||
```
|
||||
|
||||
保留现有 SQLite 实现,新增 Redis 实现。
|
||||
|
||||
### `web/redis_realtime_event_store.py`
|
||||
|
||||
职责:
|
||||
|
||||
- Redis 连接与健康检查。
|
||||
- Lua append script。
|
||||
- Redis Stream replay。
|
||||
- maxlen trim。
|
||||
- entry 到 SSE event 的反序列化。
|
||||
|
||||
### `web/sse_manager.py`
|
||||
|
||||
职责:
|
||||
|
||||
- 管理本进程 SSE 连接。
|
||||
- 记录每个连接订阅城市集合。
|
||||
- 将 Redis subscriber 读到的 event fanout 给匹配连接。
|
||||
|
||||
### `web/routers/sse_router.py`
|
||||
|
||||
职责:
|
||||
|
||||
- `/api/internal/collector-patch` 规范化并写入 event store。
|
||||
- `/api/events` 处理 connected、replay、heartbeat、live stream。
|
||||
- 当 replay 不完整时发送 `resync_required`。
|
||||
|
||||
### 前端
|
||||
|
||||
前端原则上不需要大改:
|
||||
|
||||
- `use-sse-patches.ts` 继续使用 `EventSource`。
|
||||
- `lastRevision` 仍然是 number。
|
||||
- `resync_required` 继续触发当前可见城市 HTTP detail refresh。
|
||||
- 继续按可见城市列表构建 `cities` 参数。
|
||||
|
||||
## 运行流程
|
||||
|
||||
### 首屏
|
||||
|
||||
1. 前端 HTTP 加载 terminal rows。
|
||||
2. 可见图表 HTTP 加载 full detail。
|
||||
3. 前端连接 `/api/events?cities=...&since_revision=...&replay_limit=500`。
|
||||
4. 后端先 replay Redis Stream 中缺失事件,再进入 live stream。
|
||||
|
||||
### 新观测
|
||||
|
||||
1. 采集器产生 `city_patch` 或 v1 patch。
|
||||
2. ingest endpoint 规范化成 `city_observation_patch.v1`。
|
||||
3. Redis Lua script 写入 stream 并生成 revision。
|
||||
4. 每个 API worker 的 subscriber loop 读到事件。
|
||||
5. worker fanout 给订阅对应城市的 SSE 连接。
|
||||
6. 前端 apply patch,图表无痛追加点。
|
||||
|
||||
### 浏览器后台恢复
|
||||
|
||||
1. EventSource 如果断线,前端按指数退避重连。
|
||||
2. URL 带 `since_revision=lastRevision`。
|
||||
3. Redis replay 补齐后台期间错过的事件。
|
||||
4. 如果 revision 太旧或 replay 超限,前端收到 `resync_required`,HTTP 重拉 full detail。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- Redis append 失败且 `POLYWEATHER_REDIS_REQUIRED=true`:ingest 返回 503,不广播。
|
||||
- Redis append 失败且允许 fallback:写入 SQLite,并在 health 状态标记 degraded。
|
||||
- Redis replay 失败:SSE 发送 `resync_required`,然后继续尝试 live stream。
|
||||
- Redis subscriber loop 断开:指数退避重连;期间 SSE heartbeat 仍可维持连接,但不会有 live patch。
|
||||
- 前端收到 revision 倒退或重复:忽略。
|
||||
- 前端收到未知 event type:忽略并记录 debug。
|
||||
|
||||
## 监控与诊断
|
||||
|
||||
新增健康指标:
|
||||
|
||||
- 当前 event store mode: `redis` / `sqlite` / `degraded_sqlite`
|
||||
- Redis ping latency
|
||||
- latest revision
|
||||
- stream length
|
||||
- oldest revision
|
||||
- subscriber connected
|
||||
- SSE active connection count
|
||||
- dropped/resync_required count
|
||||
|
||||
建议暴露到现有 health endpoint 或日志:
|
||||
|
||||
```json
|
||||
{
|
||||
"realtime": {
|
||||
"store": "redis",
|
||||
"redis_connected": true,
|
||||
"stream_len": 43210,
|
||||
"latest_revision": 123456,
|
||||
"oldest_revision": 80200,
|
||||
"subscriber_connected": true,
|
||||
"sse_connections": 9
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 测试策略
|
||||
|
||||
后端:
|
||||
|
||||
- Redis store append 会生成全局递增 numeric revision。
|
||||
- Redis replay 支持 city 过滤。
|
||||
- Redis replay 支持 `since_revision`。
|
||||
- Redis replay 超出 retention/limit 返回 resync required。
|
||||
- Redis 不可用时,根据 env 严格失败或 fallback SQLite。
|
||||
- SQLite store 现有测试保持通过。
|
||||
- SSE router 在 Redis store 下仍发送 connected、replay、heartbeat。
|
||||
|
||||
前端:
|
||||
|
||||
- `use-sse-patches.ts` 保持 `revision` numeric。
|
||||
- 重连 URL 保留 `since_revision`。
|
||||
- 收到 `resync_required` 会触发 visible city refresh。
|
||||
- 重复 revision 不重复追加图表点。
|
||||
|
||||
验收命令:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_realtime_patch_schema.py tests/test_realtime_event_store.py tests/test_sse_replay.py
|
||||
python -m pytest tests/test_redis_realtime_event_store.py
|
||||
cd frontend
|
||||
npm run test:business
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 迁移步骤
|
||||
|
||||
1. 保留现有 SQLite implementation,抽出 event store factory。
|
||||
2. 新增 Redis implementation 和单元测试。
|
||||
3. 新增 Redis subscriber loop,但先在本地开发环境跑。
|
||||
4. `/api/events` 接入 event store factory。
|
||||
5. 线上安装 Redis,只监听 `127.0.0.1`。
|
||||
6. 先用 `POLYWEATHER_EVENT_STORE=redis`、`POLYWEATHER_REDIS_REQUIRED=false` 灰度。
|
||||
7. 观察 stream length、latest revision、SSE error、resync count。
|
||||
8. 稳定后切换 `POLYWEATHER_REDIS_REQUIRED=true`。
|
||||
9. 保留 SQLite fallback 代码,但生产不主动降级,避免多实例状态分裂。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 断开浏览器网络 1-5 分钟后恢复,图表自动补齐期间 patch。
|
||||
- 后台挂页面再回来,图表不需要等待 full loading 才更新当前点。
|
||||
- 多 worker 下,任意 worker 写入的 patch 都能推到其他 worker 的 SSE 连接。
|
||||
- `/api/events?cities=taipei,shanghai&since_revision=...` 只 replay 指定城市。
|
||||
- Redis 重启后,前端收到 `resync_required` 并 HTTP 重建画面。
|
||||
- 前端无需知道 Redis 存在。
|
||||
- 现有 `city_observation_patch.v1` schema 不破坏。
|
||||
|
||||
## 结论
|
||||
|
||||
这是一种“一步到位但不过度设计”的方案:
|
||||
|
||||
- 一步到位的是事件契约、replay、跨实例 fanout 和生产部署边界。
|
||||
- 不过度设计的是不用 Kafka、不做永久行情库、不重写前端协议。
|
||||
|
||||
对当前产品最关键的是:图表像股票行情一样无痛刷新,断线可补,后台恢复可追上,多实例以后不用再推倒实时层。
|
||||
@@ -0,0 +1,124 @@
|
||||
# Feedback Reward Account Detail Design
|
||||
|
||||
## Goal
|
||||
|
||||
Show users which submitted feedback earned points, how many points were awarded, and why.
|
||||
|
||||
The first implementation focuses on account-page visibility. The later ops workflow for automatic reward issuance should reuse the same fields and display contract.
|
||||
|
||||
## Current Context
|
||||
|
||||
- `user_feedback` already stores each submitted feedback item with `status`, `message`, `user_id`, `user_email`, timestamps, and context JSON.
|
||||
- `/api/feedback` already returns the current user's feedback list.
|
||||
- `AccountFeedbackPanel` already renders the current user's feedback in the account page.
|
||||
- Current points balance is shown through `/api/auth/me`, but account UI has no per-feedback reward source detail.
|
||||
- Existing points grant helpers update `users.points`; feedback-specific rewards are not yet linked back to a feedback row.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- Add feedback reward metadata to feedback rows.
|
||||
- Return reward metadata through the existing current-user feedback API.
|
||||
- Display feedback reward details in the account page next to the matching feedback item.
|
||||
- Keep the data contract ready for future ops automation: a processed feedback item can carry reward points and a human-readable reason.
|
||||
|
||||
Out of scope for this phase:
|
||||
|
||||
- Building the ops action that decides and grants points.
|
||||
- Sending push/email/Telegram notifications after ops processing.
|
||||
- Rebuilding a general points ledger for all point sources.
|
||||
- Merging historical Supabase referral ledger entries into the account page.
|
||||
|
||||
## Data Model
|
||||
|
||||
Extend `user_feedback` with nullable columns:
|
||||
|
||||
- `reward_points INTEGER DEFAULT 0`
|
||||
- `reward_reason TEXT DEFAULT ''`
|
||||
- `rewarded_at TIMESTAMP`
|
||||
- `reward_status TEXT DEFAULT ''`
|
||||
|
||||
Recommended `reward_status` values:
|
||||
|
||||
- empty string: no reward decision recorded
|
||||
- `granted`: points were awarded for this feedback
|
||||
- `skipped`: reviewed but no points awarded
|
||||
- `pending`: reward decision is queued or awaiting processing
|
||||
|
||||
The display should treat `reward_points > 0` as the strongest signal that the user earned points.
|
||||
|
||||
## API Contract
|
||||
|
||||
`DBManager._feedback_row_to_dict()` should include:
|
||||
|
||||
- `reward_points`
|
||||
- `reward_reason`
|
||||
- `rewarded_at`
|
||||
- `reward_status`
|
||||
|
||||
`GET /api/feedback` should continue using the same response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"feedback": [
|
||||
{
|
||||
"id": 123,
|
||||
"status": "resolved",
|
||||
"message": "Hong Kong COWIN reading looked stale",
|
||||
"reward_points": 300,
|
||||
"reward_reason": "Valid data freshness report",
|
||||
"rewarded_at": "2026-06-08T12:00:00",
|
||||
"reward_status": "granted"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The frontend proxy does not need a new route if it transparently forwards the existing payload.
|
||||
|
||||
## Account UI
|
||||
|
||||
`AccountFeedbackPanel` should show reward detail inside each feedback row:
|
||||
|
||||
- Granted reward: `+300 points` plus reward reason.
|
||||
- Skipped reward: a muted note that no points were awarded, with reason if available.
|
||||
- Pending reward: a small pending label.
|
||||
- No reward metadata: render nothing extra to avoid noise.
|
||||
|
||||
The account page should remain dense and scannable. Reward content belongs inside the existing feedback row, not in a separate marketing-style card.
|
||||
|
||||
## Future Ops Workflow
|
||||
|
||||
The later feedback-processing feature can use a single backend operation:
|
||||
|
||||
1. Update feedback status.
|
||||
2. Grant points to the matching user.
|
||||
3. Write `reward_points`, `reward_reason`, `rewarded_at`, and `reward_status = granted` back to the same feedback row.
|
||||
|
||||
If the grant fails, the operation should not mark the feedback as granted. It should either keep `reward_status = pending` or return a clear ops error.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Missing reward fields should default to zero or empty strings in API responses.
|
||||
- If old SQLite databases do not have the new columns, `DBManager` migration should add them with `_ensure_column`.
|
||||
- Account UI should tolerate partial payloads without crashing.
|
||||
- Reward reason should be bounded in storage and display to avoid oversized rows.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests:
|
||||
|
||||
- Existing feedback rows serialize reward fields with defaults.
|
||||
- Updating reward metadata for a feedback row returns the fields in `/api/feedback`.
|
||||
- Old rows without rewards still list normally.
|
||||
|
||||
Frontend tests:
|
||||
|
||||
- Account feedback panel displays `+N points` and reason for rewarded feedback.
|
||||
- Skipped and pending reward states render without implying points were granted.
|
||||
- Rows without reward metadata do not show an empty reward block.
|
||||
|
||||
## Rollout
|
||||
|
||||
This is a backward-compatible schema and UI change. Existing feedback rows keep working with empty reward metadata. The account page can ship before the ops reward action; it will simply show reward details once rows carry those fields.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Growth Milestone Pro Rewards Design
|
||||
|
||||
## Goal
|
||||
|
||||
Reward active paid Pro members with additional membership time when PolyWeather
|
||||
reaches verified-user growth milestones.
|
||||
|
||||
## Confirmed Rules
|
||||
|
||||
- Growth is measured using verified Supabase Auth users.
|
||||
- The historical launch baseline is:
|
||||
- 588 total registered users
|
||||
- 573 verified users
|
||||
- recorded on 2026-06-12
|
||||
- Milestones and rewards:
|
||||
- 600 verified users: +1 Pro day
|
||||
- 750 verified users: +2 Pro days
|
||||
- 1000 verified users: +3 Pro days
|
||||
- every additional 100 verified users after 1000: +3 Pro days
|
||||
- Each milestone settles at most once.
|
||||
- Only users who currently have active membership access and have at least one
|
||||
confirmed real payment are eligible.
|
||||
- Trial-only, manual-grant-only, and reward-only users are excluded.
|
||||
|
||||
## Architecture
|
||||
|
||||
The Telegram Bot process already acts as the single background-job owner. A new
|
||||
growth milestone loop runs there on a low-frequency interval. It reads verified
|
||||
Auth-user counts, writes one daily growth snapshot, checks unsettled milestones,
|
||||
selects eligible paid members, and grants an append-only bonus subscription
|
||||
window.
|
||||
|
||||
SQLite stores daily snapshots, milestone settlement summaries, and per-user
|
||||
payout records. Supabase stores the actual bonus subscription. The bonus source
|
||||
contains the milestone number so a retry can detect an already-issued reward.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- Supabase read failures do not advance or settle a milestone.
|
||||
- Successful user payouts are recorded individually.
|
||||
- Failed payouts are retried on the next loop run.
|
||||
- A milestone is marked settled only when all eligible payouts succeed.
|
||||
- Bonus subscription writes use a milestone-specific source as an additional
|
||||
idempotency guard.
|
||||
|
||||
## Notification
|
||||
|
||||
After a milestone settles, the Bot posts one concise bilingual group
|
||||
announcement when announcements are enabled. It includes the milestone, reward
|
||||
days, and rewarded member count.
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@
|
||||
|
||||
首次建议打开扩展"选项页"并确认:
|
||||
|
||||
- `网站基础地址`:你的前端域名(例如 `https://polyweather-pro.vercel.app`)
|
||||
- `网站基础地址`:你的前端域名(例如 `https://polyweather.top`)
|
||||
- `API 基础地址`:你的后端 API 域名(若同域也可填前端域名)
|
||||
- `Bearer Token`:后端开启鉴权时填写
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
<label class="field">
|
||||
<span id="siteBaseLabel">Site Base URL</span>
|
||||
<input id="siteBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
|
||||
<input id="siteBaseInput" type="text" placeholder="https://polyweather.top" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span id="apiBaseLabel">API Base URL</span>
|
||||
<input id="apiBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
|
||||
<input id="apiBaseInput" type="text" placeholder="https://polyweather.top" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const DEFAULT_CONFIG = {
|
||||
apiBase: "https://polyweather-pro.vercel.app",
|
||||
siteBase: "https://polyweather-pro.vercel.app",
|
||||
apiBase: "https://polyweather.top",
|
||||
siteBase: "https://polyweather.top",
|
||||
authToken: "",
|
||||
selectedCity: ""
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const DEFAULT_CONFIG = {
|
||||
apiBase: "https://polyweather-pro.vercel.app",
|
||||
apiBase: "https://polyweather.top",
|
||||
authToken: "",
|
||||
selectedCity: "",
|
||||
siteBase: "https://polyweather-pro.vercel.app"
|
||||
siteBase: "https://polyweather.top"
|
||||
};
|
||||
const CACHE_VERSION = "v3";
|
||||
const locale = String(navigator.language || "en").toLowerCase().startsWith("zh")
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.next
|
||||
node_modules
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
@@ -17,8 +17,8 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
|
||||
# 生产环境必须设为 https://polyweather.top
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather.top
|
||||
|
||||
# 常用:前端鉴权开关
|
||||
# true: 启用 Supabase 登录
|
||||
@@ -37,7 +37,10 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
# 可选:钱包支付 / Telegram 入口
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
|
||||
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
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# PolyWeather 前端最小配置(本地 / Vercel)
|
||||
# 只部署天气看板时,先填下面 4 项即可。
|
||||
|
||||
# 必填:后端 FastAPI 基础地址
|
||||
# 默认供 Next.js API Route 在服务端代理后端使用。
|
||||
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
|
||||
|
||||
# 可选:浏览器直连后端 FastAPI 基础地址。
|
||||
# 在 Vercel 免费额度下建议配置为 VPS HTTPS 域名,让 AI / METAR / scan 等
|
||||
# 长耗时请求绕过 Vercel Functions / Fluid Compute。
|
||||
# 例如:https://api.example.com
|
||||
# 本地开发时注释掉,让 Next.js API Route 代理请求,避免 CORS 问题
|
||||
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=http://38.54.27.70:8080
|
||||
|
||||
# 必填:Supabase 前端公钥(鉴权开启时必须)
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://bttgfgupldyowkdhriqb.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
|
||||
|
||||
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather.top
|
||||
|
||||
# 常用:前端鉴权开关
|
||||
# true: 启用 Supabase 登录
|
||||
# false: 关闭登录能力,访客模式
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
|
||||
# 常用:是否强制登录
|
||||
# true: middleware 强制登录后才能访问主页面
|
||||
# false: 登录可选,访客可浏览
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
|
||||
# 关闭本地开发鉴权绕过
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
|
||||
|
||||
# 可选:分享式看板访问令牌
|
||||
# 设置后,可通过 /?access_token=<token> 打开受保护看板
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
|
||||
# 可选:前端 API Route 转发到后端时附带的共享令牌
|
||||
# 仅当后端启用了 entitlement / 订阅校验时需要
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
# 可选:钱包支付 / Telegram 入口
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
|
||||
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
|
||||
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
|
||||
@@ -1,5 +0,0 @@
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://bttgfgupldyowkdhriqb.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather.top
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=https://api.polyweather.top
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
|
||||
@@ -1 +1,6 @@
|
||||
.vercel
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -11,12 +11,20 @@ ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG NEXT_PUBLIC_SITE_URL
|
||||
ARG NEXT_PUBLIC_POLYWEATHER_API_BASE_URL
|
||||
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
|
||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||
ENV NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=$NEXT_PUBLIC_POLYWEATHER_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=$NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
|
||||
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
|
||||
|
||||
+30
-33
@@ -3,13 +3,13 @@
|
||||
PolyWeather Pro 的生产前端工程。
|
||||
|
||||
线上地址:
|
||||
- [https://polyweather-pro.vercel.app/](https://polyweather-pro.vercel.app/)
|
||||
- [https://polyweather.top/](https://polyweather.top/)
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Next.js App Router
|
||||
- React + Tailwind
|
||||
- Leaflet + Chart.js
|
||||
- 自研温度图表 + Recharts 运营图表
|
||||
- Supabase Auth
|
||||
- WalletConnect + 浏览器 EVM 钱包
|
||||
|
||||
@@ -21,26 +21,20 @@ PolyWeather Pro 的生产前端工程。
|
||||
|
||||
## 当前前端能力
|
||||
|
||||
- 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心
|
||||
- `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF 和结算来源
|
||||
- 今日日内分析支持:
|
||||
- `锚点状态`
|
||||
- `当前节奏`
|
||||
- `校准模型概率`
|
||||
- `模型区间与分歧`
|
||||
- `专业气象结论条`
|
||||
- `气象证据链 / 失效条件 / 确认条件`
|
||||
- 非香港机场城市的 `TAF` 时段提示与走势图联动
|
||||
- 主站为实时天气决策台,包含 `天气决策 / 训练数据 / 使用指南` 三个主入口
|
||||
- `/docs` 提供公开双语产品文档中心,当前保留简介、图表阅读、实时数据频率、结算站点和浏览器插件说明
|
||||
- 天气决策台支持 1x1 到 3x3 图表槽位,可按区域、搜索和城市选择进行多城市巡检
|
||||
- 终端图表默认展示全天,可切换高温窗口;所有横轴与 tooltip 时间按城市当地时间渲染
|
||||
- 可见终端图表通过 SSE patch 增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
|
||||
- AMSC/AMOS 跑道城市默认展示结算跑道曲线并高亮,辅助跑道弱化展示;单跑道机场不重复展示聚合线
|
||||
- 香港默认展示 CoWIN 6087 参考站 1 分钟曲线,并保留 HKO 10 分钟官方气象层
|
||||
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
|
||||
- 使用指南内置图表阅读顺序、图层含义、常用操作和默认可见性规则
|
||||
- `/ops` 已支持桌面表格 + 手机端卡片化视图
|
||||
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住
|
||||
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
|
||||
- 右侧详情面板在多日预报仍未补齐时会显示同步占位卡,不再把“只有今天一张卡”的中间态伪装成完整数据
|
||||
- 日内分析弹窗在 full detail / market scan 同步时会锁住旧内容并显示刷新状态,避免用户短暂看到旧城市或旧日期的数据
|
||||
- 城市决策卡支持从地图点击城市进入;机会榜和日历仍按 Pro 权限控制,地图探索和城市简报可作为轻量入口
|
||||
- 城市决策卡的 AI 机场报文解读包括最终判断、METAR 解读、推理说明、模型集群备注、风险提示和原始 METAR
|
||||
- AI 机场报文解读按 `city + local_date + locale + METAR signature` 做页面内存缓存和 `localStorage` 最终结果缓存;切换选项卡返回时会优先恢复已有内容
|
||||
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
|
||||
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
|
||||
- 城市 detail 自动识别稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
|
||||
- 市场价格层使用完整 `all_buckets` 匹配温度桶,市场信号作为交易判断层,不替代实测结算源
|
||||
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
|
||||
- 账户中心支付区支持后端下发的多链网络选择;Polygon 继续走 checkout 合约,Ethereum 主网 USDC 走手动直转确认
|
||||
- 缓存桶状态与 summary cache hit/miss
|
||||
|
||||
## 本地开发
|
||||
@@ -52,7 +46,7 @@ npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Vercel 最小部署配置
|
||||
## 最小部署配置
|
||||
|
||||
只跑看板和基础鉴权时,先填这 4 项:
|
||||
|
||||
@@ -90,7 +84,7 @@ 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-pro.vercel.app
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
|
||||
# 社群入口
|
||||
@@ -103,7 +97,7 @@ NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false
|
||||
NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
|
||||
```
|
||||
|
||||
更完整的 Vercel 配置说明见:
|
||||
更完整的部署配置说明见:
|
||||
- [docs/FRONTEND_DEPLOYMENT_ZH.md](/E:/web/PolyWeather/docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
|
||||
## 路由处理器
|
||||
@@ -144,7 +138,7 @@ Ops:
|
||||
|
||||
当前前端已内置轻量管理页:
|
||||
|
||||
- [https://polyweather-pro.vercel.app/ops](https://polyweather-pro.vercel.app/ops)
|
||||
- [https://polyweather.top/ops](https://polyweather.top/ops)
|
||||
|
||||
页面当前支持:
|
||||
|
||||
@@ -161,7 +155,7 @@ Ops:
|
||||
注意:
|
||||
|
||||
- `/ops` 现在是前后端双层管理员限制
|
||||
- Vercel 前端和后端都应配置相同的 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- 前端容器和后端都应配置相同的 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- 前端登录邮箱本身不会自动获得管理员权限
|
||||
|
||||
## 支付安全补充
|
||||
@@ -172,12 +166,13 @@ Ops:
|
||||
2. 若 `receiver_contract` 已更新,先切到最新地址
|
||||
3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付
|
||||
4. 仅允许在 `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` 白名单域名上创建 payment intent
|
||||
5. 支付区会明确显示当前账号、付款钱包和收款合约,避免账号/钱包/地址混淆
|
||||
5. 支付区会明确显示当前账号、付款钱包、支付网络和收款合约/钱包,避免账号/钱包/地址/链混淆
|
||||
6. 多链支付时,前端会把用户选择的 `chain_id` 和 `token_address` 发给后端,由后端按 intent 的链确认交易
|
||||
|
||||
这意味着:
|
||||
|
||||
- 旧标签页风险已明显降低
|
||||
- 但支付地址变更后,仍建议在 Vercel 上 redeploy 当前 production,并清理明显过期 deployment
|
||||
- 支付地址变更后,由于地址在前端镜像构建期注入,需要触发一次 `main` push(或重跑 deploy workflow)发布新镜像;浏览器侧靠 `/api/payments/config` 的运行时校验兜底
|
||||
|
||||
## 缓存行为
|
||||
|
||||
@@ -185,13 +180,15 @@ Ops:
|
||||
- `summary?force_refresh=true`:`no-store`
|
||||
- 支付相关路由:`no-store`
|
||||
- 当 detail 缓存只返回单模型或单日 forecast 时,前端会自动强刷完整 detail,并在补齐前显示同步提示 / 占位卡
|
||||
- 今日日内分析打开时如果正在切换城市、日期或 detail 深度,弹窗会阻断旧内容点击并显示刷新锁
|
||||
- detail 正在切换城市、日期或分辨率时,图表保留已有曲线并显示同步提示,避免把旧数据当作当前状态
|
||||
- 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=按可见城市数动态限制`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新
|
||||
- 前端只消费 HTTP snapshot + SSE patch,不直接感知 Redis;Redis Stream / SQLite event log 都由后端统一封装
|
||||
|
||||
## Vercel 节流建议
|
||||
## 成本与节流建议
|
||||
|
||||
- 生产环境建议关闭 `Web Analytics` 和 `Speed Insights`
|
||||
- 前端与后端以 Docker Compose 部署在同一 VPS,静态资源通过 Cloudflare 缓存(见 `next.config.mjs` 的 `headers()` 和 CI 的 `cloudflare-cache-rules` job)
|
||||
- 建议把自建 `app analytics / web vitals / eager city summaries` 默认保持关闭
|
||||
- 如果你部署在 Vercel,可在 Firewall 中加一条 `WordPress / php scanner` 拦截规则,避免无效扫描白白触发 middleware
|
||||
- 如果扫描流量明显,可在 Cloudflare WAF 中加一条 `WordPress / php scanner` 拦截规则,避免无效扫描白白触发 Nginx / middleware
|
||||
|
||||
## AGPL 与商用边界说明
|
||||
|
||||
@@ -200,4 +197,4 @@ Ops:
|
||||
|
||||
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
|
||||
|
||||
最后更新:`2026-05-23`
|
||||
最后更新:`2026-05-29`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { I18nProvider } from "@/hooks/useI18n";
|
||||
import { AccountEntry } from "@/components/account/AccountEntry";
|
||||
|
||||
@@ -10,7 +11,9 @@ export const metadata: Metadata = {
|
||||
export default function AccountPage() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AccountEntry />
|
||||
<Suspense fallback={null}>
|
||||
<AccountEntry />
|
||||
</Suspense>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,49 +7,118 @@ import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import {
|
||||
createProxyTimer,
|
||||
finishProxyTimedResponse,
|
||||
} from "@/lib/proxy-timing";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const ANALYTICS_ENABLED =
|
||||
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS !== "false";
|
||||
const ANALYTICS_PROXY_TIMEOUT_MS = Math.max(
|
||||
250,
|
||||
Number(process.env.POLYWEATHER_ANALYTICS_PROXY_TIMEOUT_MS || "1500") || 1500,
|
||||
);
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const timer = createProxyTimer(req, "analytics_events");
|
||||
if (!ANALYTICS_ENABLED) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
return finishProxyTimedResponse(
|
||||
new NextResponse(null, { status: 204 }),
|
||||
timer,
|
||||
"disabled",
|
||||
);
|
||||
}
|
||||
|
||||
if (!API_BASE) {
|
||||
return finishProxyTimedResponse(
|
||||
NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
),
|
||||
timer,
|
||||
"missing_api_base",
|
||||
);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), ANALYTICS_PROXY_TIMEOUT_MS);
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const body = await timer.measure("request_read", () => req.json());
|
||||
const payload =
|
||||
body && typeof body.payload === "object" && body.payload != null
|
||||
? body.payload
|
||||
: {};
|
||||
const enrichedBody = {
|
||||
...(body ?? {}),
|
||||
payload: {
|
||||
...payload,
|
||||
cf_country: req.headers.get("cf-ipcountry") || "",
|
||||
user_agent: req.headers.get("user-agent") || "",
|
||||
referer_header: req.headers.get("referer") || "",
|
||||
},
|
||||
};
|
||||
auth = await timer.measure("auth_headers", () =>
|
||||
buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
}),
|
||||
);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/analytics/events`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
const res = await timer.measure("backend_fetch", () =>
|
||||
fetch(`${API_BASE}/api/analytics/events`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(enrichedBody),
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
const backendServerTiming = res.headers.get("server-timing") || "";
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const raw = await timer.measure("backend_read", () => res.text());
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 260,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishProxyTimedResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
`upstream_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
const data = await timer.measure("backend_read", () => res.json());
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishProxyTimedResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
"ok",
|
||||
{ backendServerTiming },
|
||||
);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to track analytics event",
|
||||
});
|
||||
const timedOut = controller.signal.aborted;
|
||||
const response = timedOut
|
||||
? NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
accepted: true,
|
||||
dropped: true,
|
||||
reason: "timeout",
|
||||
},
|
||||
{ status: 202 },
|
||||
)
|
||||
: buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to track analytics event",
|
||||
});
|
||||
const withCookies = auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
return finishProxyTimedResponse(
|
||||
withCookies,
|
||||
timer,
|
||||
timedOut ? "timeout_accepted" : "exception",
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,104 +4,686 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import {
|
||||
getLocalDevAuthPayload,
|
||||
isLocalFullAccessHost,
|
||||
} from "@/lib/local-dev-access";
|
||||
import {
|
||||
applyEntitlementSnapshotCookie,
|
||||
clearEntitlementSnapshotCookie,
|
||||
entitlementSnapshotToAuthPayload,
|
||||
readEntitlementSnapshot,
|
||||
} from "@/lib/entitlement-snapshot";
|
||||
import {
|
||||
buildSubscriptionRequiredAuthProfile,
|
||||
isSubscriptionRequiredBackendResponse,
|
||||
} from "@/lib/auth-profile-proxy";
|
||||
import {
|
||||
hasSupabaseServerEnv,
|
||||
hasSupabaseSessionCookieValues,
|
||||
} from "@/lib/supabase/server";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
type AuthMeTimingStage = {
|
||||
durationMs: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type AuthMeTimer = {
|
||||
hasAuthorization: boolean;
|
||||
hasSupabaseCookie: boolean;
|
||||
measure<T>(name: string, action: () => Promise<T>): Promise<T>;
|
||||
measureSync<T>(name: string, action: () => T): T;
|
||||
preferSnapshot: boolean;
|
||||
stages: AuthMeTimingStage[];
|
||||
totalMs(): number;
|
||||
};
|
||||
|
||||
function authMeNowMs() {
|
||||
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function createAuthMeTimer(req: NextRequest): AuthMeTimer {
|
||||
const startedAt = authMeNowMs();
|
||||
const stages: AuthMeTimingStage[] = [];
|
||||
const recordStage = (name: string, stageStartedAt: number) => {
|
||||
stages.push({
|
||||
durationMs: Math.round((authMeNowMs() - stageStartedAt) * 10) / 10,
|
||||
name,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
hasAuthorization: Boolean(req.headers.get("authorization")),
|
||||
hasSupabaseCookie: hasRequestSupabaseSessionCookie(req),
|
||||
async measure<T>(name: string, action: () => Promise<T>) {
|
||||
const stageStartedAt = authMeNowMs();
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
recordStage(name, stageStartedAt);
|
||||
}
|
||||
},
|
||||
measureSync<T>(name: string, action: () => T) {
|
||||
const stageStartedAt = authMeNowMs();
|
||||
try {
|
||||
return action();
|
||||
} finally {
|
||||
recordStage(name, stageStartedAt);
|
||||
}
|
||||
},
|
||||
preferSnapshot: req.nextUrl.searchParams.get("prefer_snapshot") === "1",
|
||||
stages,
|
||||
totalMs() {
|
||||
return Math.round((authMeNowMs() - startedAt) * 10) / 10;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatServerTiming(stages: AuthMeTimingStage[], totalMs: number) {
|
||||
return [...stages, { durationMs: totalMs, name: "total" }]
|
||||
.map(({ durationMs, name }) => {
|
||||
const safeName = name.replace(/[^A-Za-z0-9_-]/g, "_");
|
||||
return `${safeName};dur=${Math.max(0, durationMs).toFixed(1)}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function buildBackendAuthMeUrl(req: NextRequest) {
|
||||
const url = new URL(`${API_BASE!.replace(/\/+$/, "")}/api/auth/me`);
|
||||
if (req.nextUrl.searchParams.get("scope") === "entitlement") {
|
||||
url.searchParams.set("scope", "entitlement");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function finishAuthMeResponse(
|
||||
response: NextResponse,
|
||||
timer: AuthMeTimer,
|
||||
outcome: string,
|
||||
extra?: { backendServerTiming?: string },
|
||||
) {
|
||||
const total = timer.totalMs();
|
||||
const ownServerTiming = formatServerTiming(timer.stages, total);
|
||||
const backendServerTiming = String(extra?.backendServerTiming || "").trim();
|
||||
response.headers.set("Cache-Control", "no-store");
|
||||
response.headers.set(
|
||||
"Server-Timing",
|
||||
backendServerTiming
|
||||
? `${ownServerTiming}, ${backendServerTiming}`
|
||||
: ownServerTiming,
|
||||
);
|
||||
console.info(
|
||||
"[auth-me-timing]",
|
||||
JSON.stringify({
|
||||
backendServerTiming: backendServerTiming || undefined,
|
||||
hasAuthorization: timer.hasAuthorization,
|
||||
hasSupabaseCookie: timer.hasSupabaseCookie,
|
||||
outcome,
|
||||
preferSnapshot: timer.preferSnapshot,
|
||||
stagesMs: Object.fromEntries(
|
||||
timer.stages.map((stage) => [stage.name, stage.durationMs]),
|
||||
),
|
||||
status: response.status,
|
||||
totalMs: total,
|
||||
}),
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function trackAuthDiagnosticEvent(
|
||||
req: NextRequest,
|
||||
{
|
||||
email,
|
||||
reason,
|
||||
responseMode,
|
||||
userId,
|
||||
}: {
|
||||
email: string | null;
|
||||
reason: string;
|
||||
responseMode: "snapshot" | "degraded" | "anonymous";
|
||||
userId?: string | null;
|
||||
},
|
||||
) {
|
||||
if (!API_BASE) return;
|
||||
const normalizedUserId = String(userId || "").trim();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1200);
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/analytics/events`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
event_type: "degraded_auth_profile",
|
||||
client_id: normalizedUserId ? `auth:${normalizedUserId}` : undefined,
|
||||
payload: {
|
||||
route: "/api/auth/me",
|
||||
reason: String(reason || "unknown").slice(0, 240),
|
||||
response_mode: responseMode,
|
||||
user_id: normalizedUserId || undefined,
|
||||
email_domain: email?.includes("@") ? email.split("@").pop() : undefined,
|
||||
cf_country: req.headers.get("cf-ipcountry") || "",
|
||||
user_agent: req.headers.get("user-agent") || "",
|
||||
referer_header: req.headers.get("referer") || "",
|
||||
captured_at: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics must never block auth/profile fallback responses.
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
type VerifiedBearerIdentity = {
|
||||
email: string | null;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
function extractBearerToken(headerValue: string | null) {
|
||||
if (!headerValue) return "";
|
||||
const parts = headerValue.trim().split(/\s+/);
|
||||
if (parts.length === 2 && parts[0].toLowerCase() === "bearer") {
|
||||
return parts[1];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function getVerifiedBearerIdentity(
|
||||
req: NextRequest,
|
||||
): Promise<VerifiedBearerIdentity | null> {
|
||||
const token = extractBearerToken(req.headers.get("authorization"));
|
||||
if (!token) return null;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
|
||||
if (!supabaseUrl || !anonKey) return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 4000);
|
||||
try {
|
||||
const res = await fetch(`${supabaseUrl.replace(/\/+$/, "")}/auth/v1/user`, {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const user = await res.json();
|
||||
const userId = String(user?.id || "").trim();
|
||||
if (!userId) return null;
|
||||
return {
|
||||
email: String(user?.email || "").trim() || null,
|
||||
userId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function degradedAuthProfileResponse({
|
||||
email,
|
||||
reason,
|
||||
req,
|
||||
response,
|
||||
userId,
|
||||
}: {
|
||||
email: string | null;
|
||||
reason: string;
|
||||
req: NextRequest;
|
||||
response: NextResponse | null;
|
||||
userId: string;
|
||||
}) {
|
||||
const snapshotPayload = entitlementSnapshotToAuthPayload(
|
||||
readEntitlementSnapshot(req, userId),
|
||||
);
|
||||
if (snapshotPayload) {
|
||||
await trackAuthDiagnosticEvent(req, {
|
||||
email: snapshotPayload.email || email,
|
||||
reason,
|
||||
responseMode: "snapshot",
|
||||
userId,
|
||||
});
|
||||
const snapshotResponse = NextResponse.json({
|
||||
...snapshotPayload,
|
||||
email: snapshotPayload.email || email,
|
||||
entitlement_snapshot_reason: reason,
|
||||
});
|
||||
return applyAuthResponseCookies(snapshotResponse, response);
|
||||
}
|
||||
|
||||
await trackAuthDiagnosticEvent(req, {
|
||||
email,
|
||||
reason,
|
||||
responseMode: "degraded",
|
||||
userId,
|
||||
});
|
||||
const degraded = NextResponse.json({
|
||||
authenticated: true,
|
||||
user_id: userId,
|
||||
email,
|
||||
subscription_active: null,
|
||||
subscription_plan_code: null,
|
||||
subscription_expires_at: null,
|
||||
subscription_total_expires_at: null,
|
||||
subscription_queued_days: 0,
|
||||
subscription_queued_count: 0,
|
||||
points: 0,
|
||||
degraded_auth_profile: true,
|
||||
degraded_reason: reason,
|
||||
});
|
||||
return applyAuthResponseCookies(degraded, response);
|
||||
}
|
||||
|
||||
function subscriptionRequiredAuthProfileResponse({
|
||||
email,
|
||||
response,
|
||||
userId,
|
||||
}: {
|
||||
email: string | null;
|
||||
response: NextResponse | null;
|
||||
userId: string;
|
||||
}) {
|
||||
const inactive = NextResponse.json(
|
||||
buildSubscriptionRequiredAuthProfile({ email, userId }),
|
||||
);
|
||||
clearEntitlementSnapshotCookie(inactive);
|
||||
return applyAuthResponseCookies(inactive, response);
|
||||
}
|
||||
|
||||
async function unauthenticatedAuthProfileResponse({
|
||||
reason,
|
||||
req,
|
||||
response,
|
||||
}: {
|
||||
reason: string;
|
||||
req: NextRequest;
|
||||
response: NextResponse | null;
|
||||
}) {
|
||||
await trackAuthDiagnosticEvent(req, {
|
||||
email: null,
|
||||
reason,
|
||||
responseMode: "anonymous",
|
||||
});
|
||||
const anonymous = NextResponse.json(
|
||||
{
|
||||
authenticated: false,
|
||||
subscription_active: false,
|
||||
points: 0,
|
||||
degraded_auth_profile: true,
|
||||
degraded_reason: reason,
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
clearEntitlementSnapshotCookie(anonymous);
|
||||
return applyAuthResponseCookies(anonymous, response);
|
||||
}
|
||||
|
||||
function snapshotAuthProfileResponse({
|
||||
email,
|
||||
reason,
|
||||
req,
|
||||
response,
|
||||
userId,
|
||||
}: {
|
||||
email: string | null;
|
||||
reason: string;
|
||||
req: NextRequest;
|
||||
response: NextResponse | null;
|
||||
userId: string;
|
||||
}) {
|
||||
const snapshotPayload = entitlementSnapshotToAuthPayload(
|
||||
readEntitlementSnapshot(req, userId),
|
||||
);
|
||||
if (!snapshotPayload) return null;
|
||||
const snapshotResponse = NextResponse.json({
|
||||
...snapshotPayload,
|
||||
email: snapshotPayload.email || email,
|
||||
entitlement_snapshot_reason: reason,
|
||||
});
|
||||
return applyAuthResponseCookies(snapshotResponse, response);
|
||||
}
|
||||
|
||||
function applyEntitlementSnapshotFromAuthPayload(
|
||||
response: NextResponse,
|
||||
data: Record<string, unknown>,
|
||||
) {
|
||||
if (data.authenticated === true && data.subscription_active === true) {
|
||||
return applyEntitlementSnapshotCookie(response, data);
|
||||
}
|
||||
if (data.authenticated === false || data.subscription_active === false) {
|
||||
return clearEntitlementSnapshotCookie(response);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function hasRequestSupabaseSessionCookie(req: NextRequest) {
|
||||
return hasSupabaseSessionCookieValues(
|
||||
req.cookies.getAll().map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const timer = createAuthMeTimer(req);
|
||||
const requestHost =
|
||||
req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host;
|
||||
if (
|
||||
isLocalFullAccessHost(requestHost) ||
|
||||
isLocalFullAccessHost(req.nextUrl.hostname)
|
||||
) {
|
||||
return NextResponse.json(getLocalDevAuthPayload(), {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
return finishAuthMeResponse(
|
||||
NextResponse.json(getLocalDevAuthPayload(), {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
}),
|
||||
timer,
|
||||
"local_full_access",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/api/auth/me`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
if (!API_BASE) {
|
||||
return finishAuthMeResponse(
|
||||
NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
),
|
||||
timer,
|
||||
"missing_api_base",
|
||||
);
|
||||
}
|
||||
|
||||
const preferSnapshot = req.nextUrl.searchParams.get("prefer_snapshot") === "1";
|
||||
if (
|
||||
preferSnapshot &&
|
||||
!req.headers.get("authorization") &&
|
||||
hasRequestSupabaseSessionCookie(req)
|
||||
) {
|
||||
const snapshotPayload = timer.measureSync(
|
||||
"snapshot_cookie",
|
||||
() => entitlementSnapshotToAuthPayload(readEntitlementSnapshot(req)),
|
||||
);
|
||||
if (snapshotPayload) {
|
||||
return finishAuthMeResponse(
|
||||
NextResponse.json(
|
||||
{
|
||||
...snapshotPayload,
|
||||
entitlement_snapshot_reason: "prefer_snapshot_fast_path",
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
),
|
||||
timer,
|
||||
"prefer_snapshot_fast_path",
|
||||
);
|
||||
}
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
}
|
||||
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
let bearerIdentity: VerifiedBearerIdentity | null | undefined;
|
||||
const getBearerIdentityOnce = async () => {
|
||||
if (bearerIdentity !== undefined) return bearerIdentity;
|
||||
bearerIdentity = await timer.measure(
|
||||
"bearer_identity",
|
||||
() => getVerifiedBearerIdentity(req),
|
||||
);
|
||||
return bearerIdentity;
|
||||
};
|
||||
try {
|
||||
auth = await timer.measure(
|
||||
"auth_headers",
|
||||
() => buildBackendRequestHeaders(req),
|
||||
);
|
||||
if (
|
||||
hasSupabaseServerEnv() &&
|
||||
!auth.authUserId &&
|
||||
!req.headers.get("authorization")
|
||||
) {
|
||||
const response = NextResponse.json({
|
||||
authenticated: false,
|
||||
subscription_active: false,
|
||||
points: 0,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
if (!preferSnapshot) clearEntitlementSnapshotCookie(response);
|
||||
return finishAuthMeResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
"no_session",
|
||||
);
|
||||
}
|
||||
|
||||
if (preferSnapshot) {
|
||||
const identity =
|
||||
auth.authUserId
|
||||
? { email: auth.authEmail || null, userId: auth.authUserId }
|
||||
: await getBearerIdentityOnce();
|
||||
if (identity?.userId) {
|
||||
const snapshotResponse = snapshotAuthProfileResponse({
|
||||
email: identity.email,
|
||||
reason: "prefer_snapshot",
|
||||
req,
|
||||
response: auth.response,
|
||||
userId: identity.userId,
|
||||
});
|
||||
if (snapshotResponse) {
|
||||
return finishAuthMeResponse(
|
||||
snapshotResponse,
|
||||
timer,
|
||||
"prefer_snapshot",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!auth) throw new Error("auth headers unavailable");
|
||||
const backendAuth = auth;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await timer.measure("backend_fetch", async () =>
|
||||
await fetch(buildBackendAuthMeUrl(req), {
|
||||
headers: backendAuth.headers,
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
const backendServerTiming = res.headers.get("server-timing") || "";
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const raw = await timer.measure("backend_read", () => res.text());
|
||||
const authIdentity = auth.authUserId
|
||||
? { email: auth.authEmail || null, userId: auth.authUserId }
|
||||
: await getBearerIdentityOnce();
|
||||
if (
|
||||
authIdentity?.userId &&
|
||||
isSubscriptionRequiredBackendResponse(res.status, raw)
|
||||
) {
|
||||
return finishAuthMeResponse(
|
||||
subscriptionRequiredAuthProfileResponse({
|
||||
email: authIdentity.email,
|
||||
response: auth.response,
|
||||
userId: authIdentity.userId,
|
||||
}),
|
||||
timer,
|
||||
"subscription_required",
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
if (auth.authUserId) {
|
||||
return finishAuthMeResponse(
|
||||
await degradedAuthProfileResponse({
|
||||
email: auth.authEmail || null,
|
||||
reason: `backend_${res.status}`,
|
||||
req,
|
||||
response: auth.response,
|
||||
userId: auth.authUserId,
|
||||
}),
|
||||
timer,
|
||||
`degraded_backend_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
const identity = await getBearerIdentityOnce();
|
||||
if (identity) {
|
||||
return finishAuthMeResponse(
|
||||
await degradedAuthProfileResponse({
|
||||
email: identity.email,
|
||||
reason: `backend_${res.status}`,
|
||||
req,
|
||||
response: auth.response,
|
||||
userId: identity.userId,
|
||||
}),
|
||||
timer,
|
||||
`degraded_backend_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
const response = NextResponse.json({
|
||||
authenticated: false,
|
||||
subscription_active: false,
|
||||
points: 0,
|
||||
});
|
||||
clearEntitlementSnapshotCookie(response);
|
||||
return finishAuthMeResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
`anonymous_backend_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const raw = await timer.measure("backend_read", () => res.text());
|
||||
if (auth.authUserId) {
|
||||
const response = NextResponse.json({
|
||||
authenticated: true,
|
||||
user_id: auth.authUserId,
|
||||
email: auth.authEmail || null,
|
||||
subscription_active: null,
|
||||
subscription_plan_code: null,
|
||||
subscription_expires_at: null,
|
||||
subscription_total_expires_at: null,
|
||||
subscription_queued_days: 0,
|
||||
subscription_queued_count: 0,
|
||||
points: 0,
|
||||
degraded_auth_profile: true,
|
||||
degraded_reason: `backend_${res.status}`,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishAuthMeResponse(
|
||||
await degradedAuthProfileResponse({
|
||||
email: auth.authEmail || null,
|
||||
reason: `backend_${res.status}`,
|
||||
req,
|
||||
response: auth.response,
|
||||
userId: auth.authUserId,
|
||||
}),
|
||||
timer,
|
||||
`degraded_backend_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
const identity = await getBearerIdentityOnce();
|
||||
if (identity) {
|
||||
return finishAuthMeResponse(
|
||||
await degradedAuthProfileResponse({
|
||||
email: identity.email,
|
||||
reason: `backend_${res.status}`,
|
||||
req,
|
||||
response: auth.response,
|
||||
userId: identity.userId,
|
||||
}),
|
||||
timer,
|
||||
`degraded_backend_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishAuthMeResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
`upstream_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
const data = await timer.measure("backend_read", () => res.json());
|
||||
if (data?.authenticated === true && data?.subscription_active == null) {
|
||||
const userId = String(data.user_id || auth.authUserId || "").trim();
|
||||
if (userId) {
|
||||
const snapshotResponse = snapshotAuthProfileResponse({
|
||||
email: String(data.email || auth.authEmail || "").trim() || null,
|
||||
reason: "subscription_unknown",
|
||||
req,
|
||||
response: auth.response,
|
||||
userId,
|
||||
});
|
||||
if (snapshotResponse) {
|
||||
return finishAuthMeResponse(
|
||||
snapshotResponse,
|
||||
timer,
|
||||
"subscription_unknown_snapshot",
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
applyEntitlementSnapshotFromAuthPayload(response, data);
|
||||
return finishAuthMeResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
"ok",
|
||||
{ backendServerTiming },
|
||||
);
|
||||
} catch (error) {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
if (auth.authUserId) {
|
||||
const response = NextResponse.json({
|
||||
authenticated: true,
|
||||
user_id: auth.authUserId,
|
||||
email: auth.authEmail || null,
|
||||
subscription_active: null,
|
||||
subscription_plan_code: null,
|
||||
subscription_expires_at: null,
|
||||
subscription_total_expires_at: null,
|
||||
subscription_queued_days: 0,
|
||||
subscription_queued_count: 0,
|
||||
points: 0,
|
||||
degraded_auth_profile: true,
|
||||
degraded_reason: String(error),
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
if (auth?.authUserId) {
|
||||
return finishAuthMeResponse(
|
||||
await degradedAuthProfileResponse({
|
||||
email: auth.authEmail || null,
|
||||
reason: String(error),
|
||||
req,
|
||||
response: auth.response,
|
||||
userId: auth.authUserId,
|
||||
}),
|
||||
timer,
|
||||
"exception_degraded",
|
||||
);
|
||||
}
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch auth profile",
|
||||
});
|
||||
const identity = await getBearerIdentityOnce();
|
||||
if (identity) {
|
||||
return finishAuthMeResponse(
|
||||
await degradedAuthProfileResponse({
|
||||
email: identity.email,
|
||||
reason: String(error),
|
||||
req,
|
||||
response: auth?.response || null,
|
||||
userId: identity.userId,
|
||||
}),
|
||||
timer,
|
||||
"exception_degraded",
|
||||
);
|
||||
}
|
||||
const snapshotPayload = timer.measureSync(
|
||||
"snapshot_cookie",
|
||||
() => entitlementSnapshotToAuthPayload(readEntitlementSnapshot(req)),
|
||||
);
|
||||
if (snapshotPayload) {
|
||||
const snapshotResponse = NextResponse.json({
|
||||
...snapshotPayload,
|
||||
entitlement_snapshot_reason: "exception_snapshot",
|
||||
});
|
||||
return finishAuthMeResponse(
|
||||
applyAuthResponseCookies(snapshotResponse, auth?.response || null),
|
||||
timer,
|
||||
"exception_snapshot",
|
||||
);
|
||||
}
|
||||
return finishAuthMeResponse(
|
||||
await unauthenticatedAuthProfileResponse({
|
||||
reason: String(error),
|
||||
req,
|
||||
response: auth?.response || null,
|
||||
}),
|
||||
timer,
|
||||
"exception_anonymous",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
buildJsonBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const body = await req.text();
|
||||
const res = await fetch(`${API_BASE}/api/auth/referral/apply`, {
|
||||
method: "POST",
|
||||
headers: buildJsonBackendRequestHeaders(auth.headers),
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to apply referral code",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import {
|
||||
buildCityDetailProxyCachePolicy,
|
||||
NO_STORE_CACHE_CONTROL,
|
||||
} from "@/lib/proxy-cache-policy";
|
||||
import {
|
||||
createProxyTimer,
|
||||
finishProxyTimedResponse,
|
||||
} from "@/lib/proxy-timing";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const DETAIL_BATCH_PROXY_TIMEOUT_MS = Number(
|
||||
process.env.POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS || "15000",
|
||||
);
|
||||
|
||||
function parseRequestedCities(req: NextRequest) {
|
||||
const requestedLimit = Number(req.nextUrl.searchParams.get("limit") || "12");
|
||||
const limit = Number.isFinite(requestedLimit)
|
||||
? Math.max(1, Math.min(24, requestedLimit))
|
||||
: 12;
|
||||
const seen = new Set<string>();
|
||||
const requestedCities: string[] = [];
|
||||
|
||||
for (const item of (req.nextUrl.searchParams.get("cities") || "").split(
|
||||
",",
|
||||
)) {
|
||||
const city = item.trim();
|
||||
if (!city || seen.has(city)) continue;
|
||||
seen.add(city);
|
||||
requestedCities.push(city);
|
||||
if (requestedCities.length >= limit) break;
|
||||
}
|
||||
|
||||
return requestedCities;
|
||||
}
|
||||
|
||||
function buildCityDetailBatchTimeoutPayload(requestedCities: string[]) {
|
||||
const city_status = Object.fromEntries(
|
||||
requestedCities.map((city) => [
|
||||
city,
|
||||
{
|
||||
status: "proxy_timeout",
|
||||
duration_ms: null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return {
|
||||
cities: requestedCities,
|
||||
details: {},
|
||||
errors: {},
|
||||
missing: requestedCities,
|
||||
partial: true,
|
||||
timeout: true,
|
||||
diagnostics: {
|
||||
version: 1,
|
||||
response_source: "next_proxy_timeout",
|
||||
partial: true,
|
||||
partial_reason: "proxy_timeout",
|
||||
requested_count: requestedCities.length,
|
||||
completed_count: 0,
|
||||
missing_count: requestedCities.length,
|
||||
error_count: 0,
|
||||
proxy_timeout_ms: DETAIL_BATCH_PROXY_TIMEOUT_MS,
|
||||
city_status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const timer = createProxyTimer(req, "city_detail_batch");
|
||||
if (!API_BASE) {
|
||||
return finishProxyTimedResponse(
|
||||
NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
),
|
||||
timer,
|
||||
"missing_api_base",
|
||||
);
|
||||
}
|
||||
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const requestedCities = parseRequestedCities(req);
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh);
|
||||
const searchParams = new URLSearchParams({
|
||||
cities: req.nextUrl.searchParams.get("cities") || "",
|
||||
force_refresh: forceRefresh,
|
||||
limit: req.nextUrl.searchParams.get("limit") || "12",
|
||||
});
|
||||
for (const key of ["market_slug", "target_date", "resolution", "scope"]) {
|
||||
const value = req.nextUrl.searchParams.get(key);
|
||||
if (value) searchParams.set(key, value);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), DETAIL_BATCH_PROXY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
return await proxyBackendJsonGet(req, {
|
||||
cacheControl: cachePolicy.responseCacheControl,
|
||||
cacheControlForData: (data) =>
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
(data as { partial?: unknown }).partial === true
|
||||
? NO_STORE_CACHE_CONTROL
|
||||
: cachePolicy.responseCacheControl,
|
||||
fetchCache: "no-store",
|
||||
includeSupabaseIdentity: true,
|
||||
publicMessage: "Failed to fetch city detail batch",
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
signal: controller.signal,
|
||||
timeoutResponse: () =>
|
||||
NextResponse.json(buildCityDetailBatchTimeoutPayload(requestedCities), {
|
||||
headers: {
|
||||
"Cache-Control": NO_STORE_CACHE_CONTROL,
|
||||
"Cloudflare-CDN-Cache-Control": NO_STORE_CACHE_CONTROL,
|
||||
},
|
||||
status: 200,
|
||||
}),
|
||||
timeoutPublicMessage: "City detail batch request timed out",
|
||||
timing: timer,
|
||||
url: `${API_BASE}/api/cities/detail-batch?${searchParams.toString()}`,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,62 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import {
|
||||
buildCityListCacheControl,
|
||||
buildStaticCityListFallbackCacheControl,
|
||||
} from "@/lib/proxy-cache-policy";
|
||||
import { STATIC_CITY_LIST } from "@/lib/static-cities";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const CITIES_CACHE_CONTROL = buildCityListCacheControl();
|
||||
const STATIC_CITIES_CACHE_CONTROL = buildStaticCityListFallbackCacheControl();
|
||||
const CITIES_BACKEND_TIMEOUT_MS = Number(
|
||||
process.env.POLYWEATHER_CITIES_BACKEND_TIMEOUT_MS || 1000,
|
||||
);
|
||||
|
||||
function staticCitiesFallback(req: NextRequest, reason: string) {
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
{
|
||||
cities: STATIC_CITY_LIST,
|
||||
source: "static_fallback",
|
||||
stale: true,
|
||||
},
|
||||
STATIC_CITIES_CACHE_CONTROL,
|
||||
);
|
||||
response.headers.set("x-polyweather-cities-source", "static-fallback");
|
||||
response.headers.set("x-polyweather-cities-fallback-reason", reason);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
return staticCitiesFallback(req, "missing-api-base");
|
||||
}
|
||||
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "public, max-age=0, s-maxage=60, stale-while-revalidate=300",
|
||||
publicMessage: "Failed to fetch cities",
|
||||
revalidateSeconds: 60,
|
||||
url: `${API_BASE}/api/cities`,
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort(), CITIES_BACKEND_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await proxyBackendJsonGet(req, {
|
||||
cacheControl: CITIES_CACHE_CONTROL,
|
||||
publicMessage: "Failed to fetch cities",
|
||||
revalidateSeconds: 60,
|
||||
signal: abortController.signal,
|
||||
statusOnException: 504,
|
||||
timeoutPublicMessage: "Cities backend timed out",
|
||||
url: `${API_BASE}/api/cities`,
|
||||
});
|
||||
|
||||
if (response.status >= 500) {
|
||||
return staticCitiesFallback(
|
||||
req,
|
||||
response.status === 504 ? "backend-timeout" : "backend-error",
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
import {
|
||||
createProxyTimer,
|
||||
finishProxyTimedResponse,
|
||||
} from "@/lib/proxy-timing";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -34,17 +38,18 @@ export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const timer = createProxyTimer(req, "city_detail");
|
||||
if (!API_BASE) {
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
return finishProxyTimedResponse(response, timer, "missing_api_base");
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
const { name } = await timer.measure("route_params", () => context.params);
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh);
|
||||
const depth = req.nextUrl.searchParams.get("depth");
|
||||
const marketSlug = req.nextUrl.searchParams.get("market_slug");
|
||||
const targetDate = req.nextUrl.searchParams.get("target_date");
|
||||
@@ -67,31 +72,48 @@ export async function GET(
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
headers: auth.headers,
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
|
||||
});
|
||||
const auth = await timer.measure("auth_headers", () =>
|
||||
buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
}),
|
||||
);
|
||||
const res = await timer.measure("backend_fetch", () =>
|
||||
fetch(url, {
|
||||
headers: auth.headers,
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 60 } }),
|
||||
}),
|
||||
);
|
||||
const backendServerTiming = res.headers.get("server-timing") || "";
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const raw = await timer.measure("backend_read", () => res.text());
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishProxyTimedResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
`upstream_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
const data = normalizeCityDetailPayload(await res.json());
|
||||
const data = normalizeCityDetailPayload(
|
||||
await timer.measure("backend_read", () => res.json()),
|
||||
);
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
cachePolicy.responseCacheControl,
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishProxyTimedResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
"ok",
|
||||
{ backendServerTiming },
|
||||
);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch city detail aggregate",
|
||||
});
|
||||
return response;
|
||||
return finishProxyTimedResponse(response, timer, "exception");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { NO_STORE_CACHE_CONTROL } from "@/lib/proxy-cache-policy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": NO_STORE_CACHE_CONTROL,
|
||||
"Cloudflare-CDN-Cache-Control": NO_STORE_CACHE_CONTROL,
|
||||
},
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: NO_STORE_CACHE_CONTROL,
|
||||
fetchCache: "no-store",
|
||||
includeSupabaseIdentity: false,
|
||||
publicMessage: "Failed to fetch live city observation",
|
||||
url: `${API_BASE}/api/city/${encodeURIComponent(name)}/observation`,
|
||||
});
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export async function GET(
|
||||
const { name } = await context.params;
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const depth = req.nextUrl.searchParams.get("depth") ?? "panel";
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh);
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}&depth=${encodeURIComponent(depth)}`;
|
||||
|
||||
try {
|
||||
@@ -150,7 +150,7 @@ export async function GET(
|
||||
headers: auth.headers,
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 60 } }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
@@ -159,7 +159,7 @@ export async function GET(
|
||||
headers: auth.headers,
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: 10 } }),
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 60 } }),
|
||||
});
|
||||
if (summaryRes.ok) {
|
||||
const summaryData = await summaryRes.json();
|
||||
@@ -168,7 +168,7 @@ export async function GET(
|
||||
buildFallbackCityDetail(name, depth, summaryData),
|
||||
cachePolicy.fetchMode === "no-store"
|
||||
? cachePolicy.responseCacheControl
|
||||
: "public, max-age=0, s-maxage=10, stale-while-revalidate=30",
|
||||
: cachePolicy.responseCacheControl,
|
||||
);
|
||||
response.headers.set("X-PolyWeather-Fallback", "summary");
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import {
|
||||
requireTurnstileForRequest,
|
||||
stripTurnstileToken,
|
||||
} from "@/lib/turnstile";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
function emptyFeedbackResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
feedback: [],
|
||||
total: 0,
|
||||
status_counts: {},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
try {
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
if (!auth.authUserId) {
|
||||
return applyAuthResponseCookies(emptyFeedbackResponse(), auth.response);
|
||||
}
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/feedback`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) upstream.searchParams.set("limit", limit);
|
||||
|
||||
const res = await fetch(upstream, {
|
||||
method: "GET",
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return applyAuthResponseCookies(emptyFeedbackResponse(), auth.response);
|
||||
}
|
||||
if (!res.ok) {
|
||||
return applyAuthResponseCookies(
|
||||
buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 260,
|
||||
error: "Feedback status request failed",
|
||||
}),
|
||||
auth.response,
|
||||
);
|
||||
}
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch feedback status",
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
try {
|
||||
const body = await req.json();
|
||||
const turnstileError = await requireTurnstileForRequest(req, "feedback_submit", body);
|
||||
if (turnstileError) return turnstileError;
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/feedback`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(stripTurnstileToken(body)),
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (!res.ok) {
|
||||
return applyAuthResponseCookies(
|
||||
buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 260,
|
||||
error: "Feedback request failed",
|
||||
}),
|
||||
auth.response,
|
||||
);
|
||||
}
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to submit feedback",
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/analytics/funnel`);
|
||||
const days = req.nextUrl.searchParams.get("days");
|
||||
if (days) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/audit-log`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch ops audit log",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/billing-risk`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Billing risk check failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders, buildJsonBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : "";
|
||||
@@ -9,6 +10,9 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
@@ -20,8 +24,11 @@ export async function PUT(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
|
||||
const res = await fetch(BACKEND, { method: "PUT", headers: buildJsonBackendRequestHeaders(auth.headers), body, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ feedbackId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const { feedbackId } = await context.params;
|
||||
const body = await req.json();
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/reward`,
|
||||
{
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
...Object.fromEntries(new Headers(auth.headers).entries()),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to grant feedback reward",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ feedbackId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const { feedbackId } = await context.params;
|
||||
const body = await req.json();
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/status`,
|
||||
{
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
...Object.fromEntries(new Headers(auth.headers).entries()),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to update feedback status",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/feedback`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch ops feedback",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,6 +9,8 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
const url = new URL(`${API_BASE}/api/ops/memberships/growth`);
|
||||
const days = req.nextUrl.searchParams.get("days");
|
||||
if (days) url.searchParams.set("days", days);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
const url = new URL(`${API_BASE}/api/ops/memberships/overview`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
const days = req.nextUrl.searchParams.get("days");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
if (days) url.searchParams.set("days", days);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch memberships overview",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,8 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
const url = new URL(`${API_BASE}/api/ops/memberships`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/observation-collector-status`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Observation collector status check failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,24 +4,46 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
import {
|
||||
createProxyTimer,
|
||||
finishProxyTimedResponse,
|
||||
} from "@/lib/proxy-timing";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const timer = createProxyTimer(req, "ops_online_users");
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
return finishProxyTimedResponse(
|
||||
NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
),
|
||||
timer,
|
||||
"missing_api_base",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/ops/online-users`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const auth = await timer.measure("auth_headers", () =>
|
||||
buildBackendRequestHeaders(req),
|
||||
);
|
||||
const authError = timer.measureSync("ops_auth", () =>
|
||||
requireOpsProxyAuth(req, auth),
|
||||
);
|
||||
if (authError) {
|
||||
return finishProxyTimedResponse(authError, timer, "ops_auth_error");
|
||||
}
|
||||
|
||||
const res = await timer.measure("backend_fetch", () =>
|
||||
fetch(`${API_BASE}/api/ops/online-users`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
}),
|
||||
);
|
||||
const backendServerTiming = res.headers.get("server-timing") || "";
|
||||
const raw = await timer.measure("backend_read", () => res.text());
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
@@ -29,10 +51,19 @@ export async function GET(req: NextRequest) {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishProxyTimedResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
res.ok ? "ok" : `upstream_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch online users",
|
||||
});
|
||||
return finishProxyTimedResponse(
|
||||
buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch online users",
|
||||
}),
|
||||
timer,
|
||||
"exception",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -21,6 +22,9 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const { eventId } = await context.params;
|
||||
const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,9 +18,13 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/payments/incidents`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: auth.headers,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/payments`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch ops payments",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
buildJsonBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ caseId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const resolved = await params;
|
||||
const body = await req.text();
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/ops/refunds/${encodeURIComponent(resolved.caseId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
cache: "no-store",
|
||||
headers: buildJsonBackendRequestHeaders(auth.headers),
|
||||
body,
|
||||
},
|
||||
);
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to update refund case",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
buildJsonBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/refunds`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch refund cases",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const res = await fetch(`${API_BASE}/api/ops/refunds`, {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: buildJsonBackendRequestHeaders(auth.headers),
|
||||
body,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to create refund case",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders, buildJsonBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const BACKEND = API_BASE ? `${API_BASE}/api/ops/sensitive-config` : "";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config fetch failed" }); }
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const res = await fetch(BACKEND, { method: "PUT", headers: buildJsonBackendRequestHeaders(auth.headers), body, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config update failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/source-health`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Source health check failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders, buildJsonBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || "";
|
||||
@@ -9,10 +10,13 @@ export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" };
|
||||
const headers = buildJsonBackendRequestHeaders(auth.headers);
|
||||
if (ENTITLEMENT_TOKEN) {
|
||||
headers.Authorization = `Bearer ${ENTITLEMENT_TOKEN}`;
|
||||
headers.set("Authorization", `Bearer ${ENTITLEMENT_TOKEN}`);
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/ops/subscriptions/extend`, {
|
||||
method: "POST", headers, body, cache: "no-store",
|
||||
|
||||
@@ -2,8 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
buildJsonBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -39,8 +41,25 @@ async function findSupabaseUserIdByEmail(email: string) {
|
||||
.replace(/\/$/, "");
|
||||
const serviceRoleKey = String(process.env.SUPABASE_SERVICE_ROLE_KEY || "").trim();
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
throw new Error("Supabase service role is not configured on Vercel");
|
||||
throw new Error("Supabase service role is not configured on the server");
|
||||
}
|
||||
const profileRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/profiles?select=id&email=eq.${encodeURIComponent(email)}&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
apikey: serviceRoleKey,
|
||||
Authorization: `Bearer ${serviceRoleKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
const profileData = (await profileRes.json().catch(() => [])) as Array<{ id?: string }>;
|
||||
if (profileRes.ok) {
|
||||
const profileUserId = String(profileData?.[0]?.id || "").trim();
|
||||
if (profileUserId) return { supabaseUrl, serviceRoleKey, userId: profileUserId };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`,
|
||||
{
|
||||
@@ -109,7 +128,7 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
|
||||
apikey: serviceRoleKey,
|
||||
Authorization: `Bearer ${serviceRoleKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
Prefer: "return=minimal",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
@@ -138,6 +157,9 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
if (!API_BASE) {
|
||||
return grantSubscriptionDirectly(req, body, auth.authEmail);
|
||||
@@ -145,7 +167,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ops/subscriptions/grant`, {
|
||||
method: "POST",
|
||||
headers: { ...(auth.headers as Record<string, string>), "Content-Type": "application/json" },
|
||||
headers: buildJsonBackendRequestHeaders(auth.headers),
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ops/telegram/members-audit`, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ops/training/accuracy`, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/truth-history`);
|
||||
for (const key of ["city", "date_from", "date_to", "limit"]) {
|
||||
const value = req.nextUrl.searchParams.get(key);
|
||||
|
||||
@@ -2,8 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
buildJsonBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,13 +19,13 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...auth.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: buildJsonBackendRequestHeaders(auth.headers),
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/users`);
|
||||
const q = req.nextUrl.searchParams.get("q");
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/logs`);
|
||||
const level = req.nextUrl.searchParams.get("level");
|
||||
const lines = req.nextUrl.searchParams.get("lines");
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function GET(req: NextRequest) {
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900",
|
||||
detailLimit: 350,
|
||||
includeSupabaseIdentity: true,
|
||||
includeSupabaseIdentity: false,
|
||||
publicMessage: "Failed to fetch payment config",
|
||||
revalidateSeconds: 300,
|
||||
url: `${API_BASE}/api/payments/config`,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -25,7 +25,7 @@ export async function POST(
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user