Compare commits
215 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b0506a663 | |||
| efb5e426b7 | |||
| d0e1bea941 | |||
| 346836f561 | |||
| 2b5d2cd801 | |||
| 6139960a9e | |||
| cbb704577a | |||
| 0c8ad54780 | |||
| 98bcfe2a21 | |||
| e32cfd8015 | |||
| 7237278f5a | |||
| 15a452dd49 | |||
| 2007ded86d | |||
| 1ab7db065c | |||
| 436afe121a | |||
| 88b355e9de | |||
| b146c637b6 | |||
| 7caae4dceb | |||
| 28db3ed428 | |||
| 306d701a2d | |||
| 54c93db483 | |||
| 961451aeff | |||
| a584da83f7 | |||
| 443540c27e | |||
| ab5c5cfb23 | |||
| 7de31ec429 | |||
| 7589fd74bf | |||
| fd210ac730 | |||
| 2b0e6a6f67 | |||
| b1ce479909 | |||
| 6d20d51bb9 | |||
| 4e0ddfa771 | |||
| b866ebf6e3 | |||
| afffbb852b | |||
| 69ea686750 | |||
| b7a72bd9ff | |||
| 77f2b6ac07 | |||
| 1dfdd18421 | |||
| 67e691ed95 | |||
| 1461d02488 | |||
| 54acfc05aa | |||
| bbc5dbb8d6 | |||
| 29f6542a72 | |||
| 05184cb2ad | |||
| b365155622 | |||
| fc72b3539d | |||
| 5796d380c5 | |||
| 58a09fe6fe | |||
| 4ac690228e | |||
| 90822c81d4 | |||
| 5e83a5cca3 | |||
| 578e50b2da | |||
| 5ac5caf3ef | |||
| 7adec4e17c | |||
| 354d55f752 | |||
| 6feb4ff489 | |||
| fcaa9d5c7f | |||
| b1102400f6 | |||
| 5e31bc9ea3 | |||
| ff7d77ef06 | |||
| ce22752ccc | |||
| 8a091fd7cc | |||
| 06b1e1b9fe | |||
| 21a42af01c | |||
| ace9c71743 | |||
| 7cdd6102c6 | |||
| f8b17d16ea | |||
| e2c51f5125 | |||
| 60785471e2 | |||
| d2b27b1020 | |||
| 7171192ea6 | |||
| e8a39000b9 | |||
| 4519af0c6c | |||
| 3cb5d2e8fc | |||
| 2ba680954c | |||
| 33385c3dda | |||
| 3c950956e3 | |||
| 2eb46853bf | |||
| c795d90993 | |||
| 7fe89e097b | |||
| bddcd434da | |||
| 6aa3a7dda8 | |||
| dd720582d5 | |||
| adf2924a1c | |||
| e662ef7d3b | |||
| 0425c237b1 | |||
| 13cd176449 | |||
| 49d883e3a2 | |||
| 75c2baf1d1 | |||
| b11836a378 | |||
| f7c5a2f443 | |||
| 290dd2b3cf | |||
| ac03fb74be | |||
| 4413b31396 | |||
| a19925fe7a | |||
| 8959faac91 | |||
| e7e52c0da7 | |||
| 345c56c4f4 | |||
| e4b8d71653 | |||
| ddf909f690 | |||
| 98d36a9174 | |||
| a0b8a3595c | |||
| 7225b8bcc7 | |||
| 43749fff7c | |||
| 6b76290cff | |||
| 48619830a5 | |||
| 74e35b990b | |||
| f6771247a6 | |||
| 3196552c78 | |||
| 03dcb4329b | |||
| 1c84893bed | |||
| 25ab512371 | |||
| 2a19a23e4d | |||
| 6e3bc1ce75 | |||
| 5e88e1480a | |||
| e4cc5b4263 | |||
| dbf10253a8 | |||
| da9b0b36f7 | |||
| f049a69ee3 | |||
| b33a741385 | |||
| 69f24ddc08 | |||
| f0fcb1e379 | |||
| 5c209ceb75 | |||
| 15a2fb91d1 | |||
| f2547e11b6 | |||
| fef3097c36 | |||
| 5057892255 | |||
| 233112cf0a | |||
| 60f83d0905 | |||
| 1ae9b55509 | |||
| ebaa57b5d1 | |||
| 9ac0a13937 | |||
| 508c322c8b | |||
| 5a6d9eeda4 | |||
| 7345b70958 | |||
| 5d2ed68cdf | |||
| 7510e0a1e0 | |||
| 217414408c | |||
| 93f7a852a8 | |||
| 90c998f06d | |||
| 54544e40b9 | |||
| 609fc8914a | |||
| 6a229a592b | |||
| 1ebd07ca31 | |||
| 81604bfe8a | |||
| e7a5347907 | |||
| fd7ec30e58 | |||
| 9bff3b0683 | |||
| 314cad82e6 | |||
| 1c57a6f56e | |||
| 530080a75d | |||
| 6d1f77cac1 | |||
| 2397a7569b | |||
| c1fb52551d | |||
| d1f4a8b236 | |||
| 4bd531bbc9 | |||
| 64f8b37014 | |||
| 02e35a3a5f | |||
| be61c39795 | |||
| af2561d019 | |||
| 4bd97cc5a5 | |||
| db2f22509b | |||
| f27970f157 | |||
| 3d9d4dcd64 | |||
| 786765e48b | |||
| cd0a5d519f | |||
| edf2a37976 | |||
| 7bde49bd5e | |||
| 80acade251 | |||
| ac5c3a2c76 | |||
| 08217deefb | |||
| ac9e537070 | |||
| e780830f76 | |||
| aa98623cab | |||
| f00360ed56 | |||
| e798a4f33a | |||
| 50a2202930 | |||
| c3958c29c7 | |||
| 663d6be506 | |||
| 2de083d53a | |||
| 04fda60fb4 | |||
| c031d9caeb | |||
| 9ba0890ca9 | |||
| fb630b47cb | |||
| 5b7a257087 | |||
| 534eef4197 | |||
| 25f11912dd | |||
| e201856700 | |||
| 74232907a9 | |||
| 024df2dc5b | |||
| 9531cc4b64 | |||
| 990182e089 | |||
| 120f4b1261 | |||
| f2e0940282 | |||
| 3c3ffa1248 | |||
| 18a525af88 | |||
| c0d914eb88 | |||
| 0aaa09370b | |||
| aa3546e2a9 | |||
| 003aff98d6 | |||
| 1e541433e5 | |||
| 0f51780566 | |||
| a5948a35d4 | |||
| 5c3de0dbe0 | |||
| 31ee147734 | |||
| 54c4a26162 | |||
| f7b649bb0a | |||
| d5bbc43b52 | |||
| e62a36e0bc | |||
| bd839d0cbe | |||
| e3bd90d186 | |||
| 9cc9ab93c4 | |||
| 0a869459c4 | |||
| 987aec2fa6 | |||
| f4fea03f35 |
+127
-32
@@ -1,36 +1,125 @@
|
||||
# Telegram Bot
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
TELEGRAM_CHAT_ID=your_chat_id_here
|
||||
# PolyWeather backend/bot minimal reproducible config
|
||||
# Full configuration guide:
|
||||
# docs/CONFIGURATION_ZH.md
|
||||
# Sensitive-only template:
|
||||
# .env.secrets.example
|
||||
|
||||
########################################
|
||||
# 1) Runtime paths and base behavior
|
||||
########################################
|
||||
ENV=production
|
||||
LOG_LEVEL=INFO
|
||||
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
|
||||
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
|
||||
# Optional: host user/group mapping for Docker on Linux.
|
||||
# Windows / macOS can usually keep the defaults.
|
||||
UID=1000
|
||||
GID=1000
|
||||
POLYWEATHER_STATE_STORAGE_MODE=dual
|
||||
|
||||
########################################
|
||||
# 2) Telegram bot minimal
|
||||
########################################
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
TELEGRAM_CHAT_IDS=
|
||||
TELEGRAM_QUERY_TOPIC_CHAT_ID=
|
||||
TELEGRAM_QUERY_TOPIC_ID=
|
||||
TELEGRAM_QUERY_TOPIC_MAP=
|
||||
POLYWEATHER_BOT_GROUP_INVITE_URL=
|
||||
|
||||
########################################
|
||||
# 3) Weather + cache
|
||||
########################################
|
||||
OPEN_METEO_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_ENSEMBLE_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_VERSION=v2
|
||||
OPEN_METEO_RATE_LIMIT_COOLDOWN_SEC=900
|
||||
OPEN_METEO_RATE_CACHE_TTL_SEC=3600
|
||||
OPEN_METEO_MIN_CALL_INTERVAL_SEC=3
|
||||
METAR_CACHE_TTL_SEC=600
|
||||
METEOBLUE_CACHE_TTL_SEC=7200
|
||||
|
||||
########################################
|
||||
# 4) Auth / entitlement
|
||||
########################################
|
||||
POLYWEATHER_AUTH_ENABLED=false
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false
|
||||
POLYWEATHER_REQUIRE_ENTITLEMENT=false
|
||||
SUPABASE_URL=
|
||||
SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
SUPABASE_HTTP_TIMEOUT_SEC=8
|
||||
SUPABASE_AUTH_CACHE_TTL_SEC=30
|
||||
SUPABASE_SUB_CACHE_TTL_SEC=60
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
########################################
|
||||
# 5) Alerts / operations
|
||||
########################################
|
||||
TELEGRAM_ALERT_PUSH_ENABLED=true
|
||||
TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
|
||||
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
|
||||
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
|
||||
TELEGRAM_ALERT_MIN_SEVERITY=medium
|
||||
# Mispricing radar: skip push when YES buy price is above this cap (10c = 0.10)
|
||||
TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY=0.10
|
||||
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
|
||||
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,tel aviv,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
|
||||
|
||||
# AI
|
||||
GROQ_API_KEY=your_groq_api_key_here
|
||||
########################################
|
||||
# 6) Frontend-facing shared values
|
||||
########################################
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
|
||||
# Open-Meteo (forecast data changes ~hourly, no need to refresh more often)
|
||||
OPEN_METEO_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_ENSEMBLE_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC=7200
|
||||
########################################
|
||||
# 7) Optional modules
|
||||
########################################
|
||||
|
||||
# Proxy Setting (optional)
|
||||
HTTPS_PROXY=http://127.0.0.1:7890
|
||||
HTTP_PROXY=http://127.0.0.1:7890
|
||||
# Weekly reward / leaderboard
|
||||
POLYWEATHER_WEEKLY_REWARD_ENABLED=true
|
||||
POLYWEATHER_WEEKLY_REWARD_TIMEZONE=Asia/Shanghai
|
||||
POLYWEATHER_WEEKLY_REWARD_SETTLE_WEEKDAY=1
|
||||
POLYWEATHER_WEEKLY_REWARD_SETTLE_HOUR=0
|
||||
POLYWEATHER_WEEKLY_REWARD_SETTLE_MINUTE=5
|
||||
POLYWEATHER_WEEKLY_REWARD_CHECK_INTERVAL_SEC=300
|
||||
POLYWEATHER_WEEKLY_REWARD_HTTP_TIMEOUT_SEC=10
|
||||
POLYWEATHER_WEEKLY_REWARD_ANNOUNCE_ENABLED=true
|
||||
|
||||
# Other Settings
|
||||
LOG_LEVEL=INFO
|
||||
ENV=production
|
||||
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
|
||||
# Backend entitlement guard (for /api/cities, /api/city/*, /api/history/*)
|
||||
POLYWEATHER_REQUIRE_ENTITLEMENT=false
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
# Group message points
|
||||
POLYWEATHER_BOT_MESSAGE_POINTS=4
|
||||
POLYWEATHER_BOT_MESSAGE_DAILY_CAP=40
|
||||
POLYWEATHER_BOT_MESSAGE_MIN_LENGTH=3
|
||||
POLYWEATHER_BOT_MESSAGE_COOLDOWN_SEC=30
|
||||
POLYWEATHER_BOT_CITY_QUERY_COST=1
|
||||
POLYWEATHER_BOT_DEB_QUERY_COST=1
|
||||
|
||||
# Polymarket P0 Read-Only Market Layer
|
||||
# Payments
|
||||
POLYWEATHER_PAYMENT_ENABLED=false
|
||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
|
||||
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
|
||||
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
|
||||
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
|
||||
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
|
||||
POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC=10
|
||||
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
|
||||
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
|
||||
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_PLAN_CATALOG_JSON=
|
||||
|
||||
# Polymarket market scan
|
||||
POLYMARKET_MARKET_SCAN_ENABLED=true
|
||||
POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com
|
||||
POLYMARKET_CLOB_URL=https://clob.polymarket.com
|
||||
@@ -43,10 +132,10 @@ POLYMARKET_DISCOVERY_LIMIT=200
|
||||
POLYMARKET_SIGNAL_MIN_LIQUIDITY=500
|
||||
POLYMARKET_SIGNAL_EDGE_PCT=2
|
||||
|
||||
# Polygon Wallet Watcher (Single Chain P0)
|
||||
# Polygon watcher
|
||||
POLYGON_WALLET_WATCH_ENABLED=false
|
||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
POLYGON_WALLET_WATCH_ADDRESSES=0x0000000000000000000000000000000000000000
|
||||
POLYGON_WALLET_WATCH_ADDRESSES=
|
||||
POLYGON_WALLET_WATCH_INTERVAL_SEC=8
|
||||
POLYGON_WALLET_WATCH_CONFIRMATIONS=2
|
||||
POLYGON_WALLET_WATCH_MAX_BLOCKS_PER_CYCLE=30
|
||||
@@ -56,17 +145,15 @@ POLYGON_WALLET_WATCH_TX_BASE=https://polygonscan.com/tx
|
||||
POLYGON_WALLET_WATCH_ADDR_BASE=https://polygonscan.com/address
|
||||
POLYGON_WALLET_WATCH_POLYMARKET_ONLY=true
|
||||
POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true
|
||||
# Optional custom Polymarket contracts, format: LABEL:0x...,LABEL2:0x...
|
||||
POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
|
||||
|
||||
# Polymarket Wallet Activity Watcher (all markets, not weather-only)
|
||||
# Polymarket wallet activity
|
||||
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
POLYMARKET_WALLET_ACTIVITY_USERS=0x0000000000000000000000000000000000000000
|
||||
# Optional wallet nicknames:
|
||||
# - CSV: 0xabc...=Whale_A,0xdef...=Main_Account
|
||||
# - JSON: {"0xabc...":"Whale A","0xdef...":"Main Account"}
|
||||
# - Env key: POLYMARKET_WALLET_ACTIVITY_USER_ALIASES
|
||||
# (legacy typo POLYMARKET_WALLET_ACTIVITY_USERS_ALIASES is also accepted)
|
||||
POLYMARKET_WALLET_ACTIVITY_USERS=
|
||||
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=
|
||||
POLYMARKET_WALLET_ACTIVITY_CHAT_IDS=
|
||||
POLYMARKET_WALLET_ACTIVITY_TOPIC_CHAT_ID=
|
||||
POLYMARKET_WALLET_ACTIVITY_TOPIC_ID=
|
||||
POLYMARKET_WALLET_ACTIVITY_USER_ALIASES=
|
||||
POLYMARKET_WALLET_ACTIVITY_DATA_API_URL=https://data-api.polymarket.com
|
||||
POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC=20
|
||||
@@ -85,3 +172,11 @@ POLYMARKET_WALLET_ACTIVITY_UPDATE_DEBOUNCE_SEC=30
|
||||
POLYMARKET_WALLET_ACTIVITY_UPDATE_MAX_HOLD_SEC=120
|
||||
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN=0.01
|
||||
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX=0.99
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_POSITION_VALUE_USD=0
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_VALUE_EXEMPT_USERS=
|
||||
|
||||
########################################
|
||||
# 8) Optional proxies
|
||||
########################################
|
||||
HTTPS_PROXY=
|
||||
HTTP_PROXY=
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# PolyWeather secrets-only template
|
||||
# Copy the required lines into your real `.env` / platform secret manager.
|
||||
# Never commit actual values.
|
||||
|
||||
########################################
|
||||
# Telegram
|
||||
########################################
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
|
||||
########################################
|
||||
# Supabase
|
||||
########################################
|
||||
SUPABASE_URL=
|
||||
SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
########################################
|
||||
# Entitlement / dashboard
|
||||
########################################
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
|
||||
########################################
|
||||
# Meteoblue / third-party APIs
|
||||
########################################
|
||||
METEOBLUE_API_KEY=
|
||||
|
||||
########################################
|
||||
# Wallet / payments
|
||||
########################################
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
|
||||
|
||||
########################################
|
||||
# Optional exchange / market secrets
|
||||
########################################
|
||||
POLYMARKET_API_KEY=
|
||||
POLYMARKET_SECRET_KEY=
|
||||
POLYMARKET_PASSPHRASE=
|
||||
POLYMARKET_WALLET_ADDRESS=
|
||||
@@ -0,0 +1,61 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
python-quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt -r requirements-dev.txt
|
||||
|
||||
- name: Ruff
|
||||
run: python -m ruff check .
|
||||
|
||||
- name: Pytest
|
||||
run: python -m pytest
|
||||
|
||||
frontend-quality:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t polyweather-ci .
|
||||
+12
@@ -2,6 +2,9 @@
|
||||
.env
|
||||
|
||||
# Data and Logs
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
data/*.db.*
|
||||
data/*.json
|
||||
data/logs/
|
||||
data/historical/
|
||||
@@ -30,3 +33,12 @@ Thumbs.db
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
frontend/.vercel/
|
||||
frontend/*.tsbuildinfo
|
||||
|
||||
.npm-cache/
|
||||
.env.local
|
||||
.vercel/
|
||||
|
||||
# Browser extension build artifacts
|
||||
/extension.zip
|
||||
/extension-*.zip
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"css.validate": false,
|
||||
"scss.validate": false,
|
||||
"less.validate": false
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## 1.5.1 - 2026-03-23
|
||||
|
||||
- `/ops` 页面增加管理员守卫,前后端双层限制管理员访问
|
||||
- `/ops` 支持会员列表、支付异常单、用户查询、周榜和手动补分
|
||||
- `/ops` 支付异常单支持按原因筛选、标记已处理,并补充支付异常审计视图
|
||||
- 会员列表支持按 `user_id` 去重,并优先回补 Supabase Auth 邮箱/注册时间
|
||||
- 新增按邮箱补跑订阅恢复脚本 `scripts/reconcile_subscription_by_email.py`
|
||||
- 支付确认失败(如 `receiver_mismatch`)现在会明确落 `failed`,并写入 SQLite 审计事件
|
||||
- 支付前强制重新拉取 `/api/payments/config`,并校验最新地址、允许域名和当前支付上下文
|
||||
- 浏览器钱包选择补齐 EIP-6963 发现、稳定去重和绑定后账户状态即时刷新
|
||||
- 城市详情页新增 `官方参考 / Official Sources` 区块,覆盖主要城市的官方机构/机场/METAR 链接
|
||||
- “今日日内分析”结构解读改为后端同源动态短评,并统一网页与 Bot 解释口径
|
||||
- 台北主结算源切换到 `NOAA RCTP`,按最终质控后的最高整度摄氏值展示和说明
|
||||
- 浏览器插件同步台北 `NOAA RCTP` 结算参考标签和说明
|
||||
- `/ops` 手机端收口为卡片化视图,保留桌面表格
|
||||
- 账户中心补充本周积分显示,`weekly_points` 与周排行同屏展示
|
||||
- Dashboard 历史对账补充“峰值前 12 小时 DEB 参考(近似)”卡片
|
||||
- 历史图不再错误混入 `settlement_history` 实测,历史样本仅按可比较样本统计
|
||||
- 新增 `scripts/backfill_recent_daily_actuals_from_metar.py`,支持为缺失 `daily_records` 的 METAR 城市补最近 14 天 `actual_high`
|
||||
- 历史接口对新接入的 METAR 城市增加自动 bootstrap,避免新增城市历史页整块空白
|
||||
- 香港历史/日内展示继续坚持 `HKO` 官方口径,不再 fallback 到 `VHHH METAR` 连续线
|
||||
- 香港 HKO 当天官方点位不再落单独 JSON,统一写入 runtime state
|
||||
- 今日日内结构信号按城市本地时间与峰值窗口分析,不再只看固定下午时段
|
||||
- 新增高空结构信号:冲高环境、压温风险、午后扰动、冲高效率,并提供中英文说明
|
||||
- 新增交易动作卡:结合高空结构、市场拥挤度与 `edge_percent` 输出 `偏暖侧 / 偏谨慎 / 先观察`
|
||||
- 非香港机场城市新增 `TAF` 接入,支持 `FM / TEMPO / BECMG / PROB30/40` 时间片解析
|
||||
- 温度走势图新增 `TAF 时段 / TAF Timing` 标记,并在 tooltip 中显示对应时段摘要
|
||||
- `TAF` 信号与 `market_signal / edge_percent` 联动进入交易动作,提示更贴近交易语境
|
||||
- `TAF` 展示词已改成普通用户可读版本:`基础时段 / 明确切换 / 临时波动 / 逐步转变`
|
||||
- 日内结构总摘要补充“TAF 未新增压温不等于继续升温”的解释,避免误读
|
||||
- 浏览器插件多日预报改为 `DEB` 优先,基础判断卡补充方向、置信度与原因,并统一引流到主站首页
|
||||
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 1.5.0 - 2026-03-21
|
||||
|
||||
- 运行态状态与缓存支持 SQLite 渐进迁移,新增 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`
|
||||
- 新增 `/healthz`、`/api/system/status`、`/metrics`
|
||||
- 新增支付运行态接口 `/api/payments/runtime`
|
||||
- 支付侧新增 SQLite 审计事件、事件重放脚本与多 RPC 容灾支持
|
||||
- 新增支付静态审计脚本与 V2 合约升级草案
|
||||
- 统一周积分显示口径,`/top` 中“我的状态”改为累计发言/本周排名/本周积分
|
||||
- 文档同步更新为 2026-03-20 当前状态
|
||||
|
||||
## 1.4.0 - 2026-03-14
|
||||
|
||||
- 统一收费阶段产品口径,发布 PolyWeather Pro `v1.4.0`
|
||||
- 前端交付覆盖账户、支付、权限展示与缓存策略
|
||||
- 支付链路支持 intent -> submit -> confirm 与自动补单
|
||||
- 文档统一切换到单一版本源管理
|
||||
+63
-243
@@ -1,262 +1,82 @@
|
||||
# 前端重新设计完成报告
|
||||
# 前端交付与重构报告(v1.5.1)
|
||||
|
||||
## 概述
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
按照 `docs/images/demo_map.png` 的设计,已成功重新设计和实现了 PolyWeather 首页核心布局。
|
||||
## 1. 报告目的
|
||||
|
||||
## 核心设计
|
||||
说明当前线上前端(`frontend/`)在收费阶段的实际交付状态。
|
||||
|
||||
### 三列布局架构
|
||||
## 2. 当前前端架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 顶部导航栏 (高度: 48px) │
|
||||
├──────────────┬──────────────────────────────┬───────────────┤
|
||||
│ │ │ │
|
||||
│ 左侧边栏 │ 中央地图展示区 │ 右侧详情面板 │
|
||||
│ (宽: 192px)│ (Leaflet全球地图) │ (宽: 288px) │
|
||||
│ │ │ │
|
||||
│ • Logo │ • 全球地图视图 │ • 大温度显示 │
|
||||
│ • 搜索框 │ • 彩色圆形标记 │ • 观测信息 │
|
||||
│ • 城市列表 │ • 风险等级标色 │ • 小时趋势图 │
|
||||
│ • 温度显示 │ │ • 概率分布 │
|
||||
│ │ │ • 多模型预报 │
|
||||
│ │ │ • 多日预报 │
|
||||
└──────────────┴──────────────────────────────┴───────────────┘
|
||||
```mermaid
|
||||
flowchart LR
|
||||
B["Browser"] --> N["Next.js App Router (Vercel)"]
|
||||
N --> RH["Route Handlers /api/*"]
|
||||
RH --> F["FastAPI (VPS)"]
|
||||
|
||||
N --> STORE["Dashboard Store"]
|
||||
STORE --> MAP["MapCanvas"]
|
||||
STORE --> SIDEBAR["CitySidebar"]
|
||||
STORE --> PANEL["DetailPanel + Modal"]
|
||||
STORE --> ACCOUNT["Account Center + Pro Overlay"]
|
||||
```
|
||||
|
||||
x
|
||||
## 3. 已落地能力
|
||||
|
||||
## 实现细节
|
||||
### 3.1 信息架构与交互
|
||||
|
||||
### 1. 左侧城市列表面板 (192px)
|
||||
- 风险分组侧栏折叠(持久化)。
|
||||
- 选中城市状态持久化。
|
||||
- 今日分析、历史对账、未来日期分析联动。
|
||||
|
||||
```
|
||||
功能:
|
||||
• Logo 和标题: "PolyWeather"
|
||||
• 搜索输入框: 搜索城市功能
|
||||
• 城市列表:
|
||||
- 显示所有支持的城市
|
||||
- 当前温度显示
|
||||
- 风险等级颜色指示器
|
||||
- 活跃城市高亮显示
|
||||
- 可点击选择切换城市
|
||||
### 3.2 收费相关
|
||||
|
||||
样式:
|
||||
- 背景: slate-900/50
|
||||
- 边框: slate-800
|
||||
- 活跃状态: cyan-500 高亮
|
||||
- 悬停效果: bg-slate-800/30
|
||||
- 账户中心(登录态、积分、订阅状态、钱包管理)。
|
||||
- Pro 解锁浮层(套餐、积分抵扣、FAQ、社群入口)。
|
||||
- 钱包绑定:浏览器扩展钱包 + WalletConnect 扫码。
|
||||
- 支付流程:create intent -> submit -> confirm。
|
||||
- `confirm pending` 时自动轮询 intent 状态,确认后自动刷新订阅态。
|
||||
|
||||
### 3.3 缓存与性能
|
||||
|
||||
- BFF `ETag/304`:`cities` / `summary` / `history`。
|
||||
- `summary?force_refresh=true` => `no-store`。
|
||||
- `sessionStorage` + in-flight 去重。
|
||||
- `localStorage`:选中城市、侧栏折叠状态。
|
||||
|
||||
### 3.4 可访问性与稳定性
|
||||
|
||||
- 详情面板 `inert + blur` 焦点冲突修复。
|
||||
- 关键支付错误文案标准化(用户取消、gas 不足、pending)。
|
||||
|
||||
## 4. 当前明确未做
|
||||
|
||||
- 离线能力(Service Worker / IndexedDB)
|
||||
- 前端级财务报表与退款后台(后端/运营侧)
|
||||
|
||||
## 5. 验收建议
|
||||
|
||||
### 5.1 前端构建
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 2. 中央地图展示区 (Leaflet)
|
||||
### 5.2 缓存验收
|
||||
|
||||
```
|
||||
功能:
|
||||
• Leaflet 全球地图
|
||||
• 动态缩放控制
|
||||
• 多个城市标记
|
||||
|
||||
标记样式:
|
||||
• 圆形标记,半径 24px
|
||||
• 显示当前温度(度数字)
|
||||
• 颜色编码 (基于风险等级):
|
||||
- 高风险: 红色 (#ef4444)
|
||||
- 中风险: 橙色 (#f97316)
|
||||
- 低风险: 绿色 (#10b981)
|
||||
- 默认: 青色 (#06b6d4)
|
||||
• 发光效果: box-shadow
|
||||
• 点击弹窗显示详细信息
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
```
|
||||
|
||||
### 3. 右侧详情面板 (288px)
|
||||
### 5.3 支付验收
|
||||
|
||||
#### a. 大温度显示区域
|
||||
- 绑定钱包
|
||||
- 创建 intent
|
||||
- 发交易
|
||||
- 验证 `intent` 状态从 `submitted -> confirmed`
|
||||
- 校验账户页订阅状态更新
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ │
|
||||
│ 12.0°C │
|
||||
│ (文字大小: 3xl/48px) │
|
||||
│ (颜色: 青色 - cyan-400) │
|
||||
│ │
|
||||
│ 观测时间: 11°C @17:00 │
|
||||
│ │
|
||||
│ ┌────┬────┬────┐ │
|
||||
│ │Obs │DEB │Fcst│ │
|
||||
│ │1.0 │6.6 │5.9 │ (三列参考) │
|
||||
│ └────┴────┴────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
## 6. 结论
|
||||
|
||||
#### b. 小时趋势图
|
||||
|
||||
```
|
||||
简化的柱状图:
|
||||
• 显示最近 12 小时的温度
|
||||
• 动态高度基于温度值
|
||||
• 渐变颜色: cyan-500 → cyan-400
|
||||
• 高度: 80px
|
||||
```
|
||||
|
||||
#### c. 概率分布
|
||||
|
||||
```
|
||||
水平条形图:
|
||||
• 显示温度概率分布
|
||||
• 显示前 3 个最高概率的温度范围
|
||||
• 动态条形宽度
|
||||
• 渐变背景: cyan-500 → emerald-500
|
||||
```
|
||||
|
||||
#### d. 多模型预报
|
||||
|
||||
```
|
||||
模型列表 (GFS, ECMWF, ICON, GEM, Open-Meteo, DEB):
|
||||
• 每个模型显示一条横向进度条
|
||||
• 指示器显示预报值在范围内的位置
|
||||
• 显示精确温度值
|
||||
• 支持动态模型范围计算
|
||||
```
|
||||
|
||||
#### e. 多日预报
|
||||
|
||||
```
|
||||
4 天预报卡片:
|
||||
• 网格布局 (4列)
|
||||
• 显示日期 (Day 0, Day 1, ...)
|
||||
• 显示最高温度
|
||||
• 简洁的卡片设计
|
||||
```
|
||||
|
||||
## 颜色主题
|
||||
|
||||
### 暗色主题 (Dark Mode)
|
||||
|
||||
```
|
||||
背景:
|
||||
- 主背景: slate-950 (#030712)
|
||||
- 面板背景: slate-900/50
|
||||
- 组件背景: slate-800/50
|
||||
|
||||
文本:
|
||||
- 主文本: slate-200
|
||||
- 次文本: slate-400
|
||||
- 强调文本: cyan-400 (#06b6d4)
|
||||
|
||||
边框:
|
||||
- 主边框: slate-800
|
||||
- 次边框: slate-700/50
|
||||
|
||||
强调色:
|
||||
- 主强调: cyan-400 (#06b6d4)
|
||||
- 辅助强调: emerald-500 (#10b981)
|
||||
- 警告: rose-500 (#ef4444)
|
||||
```
|
||||
|
||||
## 文件变更
|
||||
|
||||
### 新建文件
|
||||
|
||||
1. **`frontend/components/dashboard/map-dashboard.tsx`** (新建)
|
||||
- 核心三列布局组件
|
||||
- 城市列表、地图、详情面板的主容器
|
||||
- 包含所有数据处理和渲染逻辑
|
||||
|
||||
### 修改文件
|
||||
|
||||
1. **`frontend/app/page.tsx`**
|
||||
- 重新设计,移除 `TerminalDashboard`
|
||||
- 改用新的 `MapDashboard` 组件
|
||||
- 简化页面结构,减少代码冗余
|
||||
|
||||
2. **`frontend/components/dashboard/map-view.tsx`**
|
||||
- 更新地图标记类型定义
|
||||
- 改用新的 `createTemperatureMarker` 标记样式
|
||||
- 适配新的数据结构 (`color`, `temp` 而非 `value`)
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 关键技术点
|
||||
|
||||
1. **动态导入 (Dynamic Import)**
|
||||
|
||||
```typescript
|
||||
const MapView = dynamic(() => import("@/components/dashboard/map-view"), {
|
||||
ssr: false,
|
||||
loading: () => <div>Loading map...</div>,
|
||||
});
|
||||
```
|
||||
|
||||
- 解决 Leaflet SSR 兼容性问题
|
||||
- 提高首屏加载性能
|
||||
|
||||
2. **响应式布局**
|
||||
- Flex 布局实现三列设计
|
||||
- 固定宽度边栏 (192px, 288px)
|
||||
- 弹性地图中央区域
|
||||
|
||||
3. **数据可视化**
|
||||
- 柱状图 (hourly trend)
|
||||
- 条形图 (probability, models)
|
||||
- 网格布局 (daily forecast)
|
||||
|
||||
4. **交互设计**
|
||||
- 城市列表可点击切换
|
||||
- 地图标记可悬停显示信息
|
||||
- 搜索框输入过滤城市
|
||||
|
||||
## 性能优化
|
||||
|
||||
1. **编译状态**
|
||||
✅ 编译成功
|
||||
✅ 没有 TypeScript 错误
|
||||
✅ 没有构建警告
|
||||
|
||||
2. **打包大小**
|
||||
- 首页大小: 8.29 kB
|
||||
- 首屏 JS: 121 kB
|
||||
- 支持静态预渲染
|
||||
|
||||
## 部署检查清单
|
||||
|
||||
- [x] 前端编译通过
|
||||
- [x] 无 TypeScript 错误
|
||||
- [x] 无构建警告
|
||||
- [x] SSR 兼容性解决
|
||||
- [x] 数据绑定就绪
|
||||
- [x] 响应式设计完成
|
||||
- [x] 颜色主题应用
|
||||
- [x] 交互功能实现
|
||||
|
||||
## 下一步工作
|
||||
|
||||
1. **数据集成**
|
||||
- 从后端 API 动态获取所有城市数据
|
||||
- 实现实时数据更新
|
||||
|
||||
2. **城市级地图**
|
||||
- 为每个城市添加多个测站标记
|
||||
- 显示周边观测点信息
|
||||
|
||||
3. **高级分析**
|
||||
- 实现点击地图标记显示详细分析
|
||||
- 添加时间滑块用于历史数据回放
|
||||
|
||||
4. **市场数据集成**
|
||||
- 在详情面板添加 Polymarket 市场信息
|
||||
- 显示市场价格和概率对比
|
||||
|
||||
## 总结
|
||||
|
||||
✅ **首页设计完全按照 `demo_map.png` 的风格重新实现**
|
||||
|
||||
- 三列布局清晰分工
|
||||
- 暗色主题统一协调
|
||||
- 数据可视化专业美观
|
||||
- 交互流畅直观
|
||||
- 代码结构清晰可维护
|
||||
- 编译部署无误
|
||||
|
||||
前端现已准备就绪,可以与后端 API 进行数据集成测试。
|
||||
前端已具备收费阶段的核心能力(账户、支付、权限展示、状态回收),可支持持续商业迭代。
|
||||
|
||||
@@ -4,6 +4,8 @@ Production weather-intelligence stack for temperature settlement markets.
|
||||
|
||||
Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
|
||||
|
||||
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday signals, TAF, settlement sources, history, and extension).
|
||||
|
||||
## Product Screenshots
|
||||
|
||||
### Global Dashboard
|
||||
@@ -14,121 +16,74 @@ Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.
|
||||
|
||||

|
||||
|
||||
## What This Project Does
|
||||
## Product Status (2026-03-24)
|
||||
|
||||
- Aggregates weather observations and forecasts for monitored cities.
|
||||
- Blends multi-model forecasts with DEB (Dynamic Error Balancing).
|
||||
- Computes settlement-oriented probability buckets (mu-centered distribution).
|
||||
- Maps model view to Polymarket read-only market data for mispricing/risk scan.
|
||||
- Delivers the same core logic to web dashboard and Telegram bot.
|
||||
- Subscription live: `Pro Monthly 5 USDC`.
|
||||
- Points redemption live: `500 points = 1 USDC`, max `3 USDC` off.
|
||||
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e).
|
||||
- Auto-reconciliation live: event listener + periodic confirm loop.
|
||||
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
|
||||
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
|
||||
- Runtime state supports gradual SQLite migration (`file / dual / sqlite`).
|
||||
- EMOS/CRPS pipeline is integrated in `shadow` mode with rollout gating.
|
||||
- Intraday structural signal is now peak-window aware and bilingual (`zh-CN` / `en-US`).
|
||||
- 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`.
|
||||
- Browser extension now uses `DEB` for multi-day forecast and stays positioned as a lightweight lead-in to the main site.
|
||||
|
||||
## Overview Diagram
|
||||
## Open-Core Boundary (Important)
|
||||
|
||||
This repository follows an **Open-Core** strategy:
|
||||
|
||||
- Public in repo: weather aggregation, core analysis, dashboard, bot baseline, standard payment flow.
|
||||
- Private in production: commercial risk rules, operational thresholds, pricing strategy details, internal reconciliation policies, and growth operations tooling.
|
||||
|
||||
See: [Open-Core & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- Aggregates observations and forecasts for 30 monitored cities.
|
||||
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
|
||||
- Generates settlement-oriented probability buckets (`mu` + bucket distribution).
|
||||
- Maps weather view to Polymarket quotes for mispricing scan.
|
||||
- Reuses one analysis core across web dashboard and Telegram bot.
|
||||
- Adds payment audit trails, replay tooling, and incident visibility in ops.
|
||||
- Adds peak-window-oriented intraday structure cards for surface + upper-air analysis.
|
||||
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
|
||||
|
||||
## Reference Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["PolyWeather Pro"]
|
||||
flowchart LR
|
||||
U["Users (Web / Telegram)"] --> FE["Next.js Frontend (Vercel)"]
|
||||
U --> BOT["Telegram Bot (VPS)"]
|
||||
FE --> API["FastAPI /web/app.py"]
|
||||
BOT --> API
|
||||
|
||||
subgraph DL["Data Layer"]
|
||||
DL1["METAR (Aviation Weather / METAR)"]
|
||||
DL2["MGM (Turkey MGM)"]
|
||||
DL3["Station 17130 (Ankara Center 17130)"]
|
||||
DL4["Open-Meteo"]
|
||||
DL5["weather.gov (US cities)"]
|
||||
DL6["Polymarket (P0 Read-only)"]
|
||||
end
|
||||
API --> WX["Weather Collector"]
|
||||
WX --> METAR["Aviation Weather (METAR)"]
|
||||
WX --> TAF["Aviation Weather (TAF)"]
|
||||
WX --> MGM["MGM (Turkey station network)"]
|
||||
WX --> OM["Open-Meteo"]
|
||||
WX --> HKO["HKO / NOAA / Official settlement sources"]
|
||||
|
||||
subgraph AL["Analysis Layer"]
|
||||
AL1["DEB (Dynamic Error Balancing)"]
|
||||
AL2["Probability Engine (mu + buckets)"]
|
||||
AL3["Trend Engine"]
|
||||
AL4["Risk Profiles"]
|
||||
AL5["Mispricing Radar"]
|
||||
end
|
||||
|
||||
subgraph DEL["Delivery Layer"]
|
||||
DEL1["FastAPI"]
|
||||
DEL2["Next.js Dashboard"]
|
||||
DEL3["Telegram Bot"]
|
||||
DEL4["Alert Push"]
|
||||
end
|
||||
|
||||
subgraph OL["Ops Layer"]
|
||||
OL1["Docker Compose (VPS backend + bot)"]
|
||||
OL2["Vercel (frontend)"]
|
||||
OL3["Cache + force_refresh"]
|
||||
OL4["Speed Insights"]
|
||||
end
|
||||
|
||||
A --> DL
|
||||
A --> AL
|
||||
A --> DEL
|
||||
A --> OL
|
||||
API --> ANA["DEB + Trend + Probability + Market Scan"]
|
||||
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
|
||||
ANA --> PM["Polymarket Read-only Layer"]
|
||||
```
|
||||
|
||||
## Architecture
|
||||
## Monitored Cities (30)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User[Web / Telegram User] --> FE[Next.js Frontend on Vercel]
|
||||
User --> Bot[Telegram Bot on VPS]
|
||||
FE --> API[FastAPI Service]
|
||||
Bot --> API
|
||||
|
||||
API --> WX[Weather Data Collector]
|
||||
WX --> METAR[METAR / Aviation Weather]
|
||||
WX --> MGM[MGM API / nearby stations]
|
||||
WX --> OM[Open-Meteo]
|
||||
WX --> NWS[weather.gov]
|
||||
|
||||
API --> DEB[DEB + Trend + Probability Engines]
|
||||
API --> PM[Polymarket Read-only Layer]
|
||||
PM --> Gamma[Gamma API]
|
||||
PM --> CLOB[CLOB / py-clob-client]
|
||||
```
|
||||
|
||||
## Current Source Policy
|
||||
|
||||
| Domain | Source Policy |
|
||||
| :------------------ | :--------------------------------------------------- |
|
||||
| Primary observation | Aviation Weather / METAR |
|
||||
| Ankara enhancement | MGM + nearby stations, lead station fixed to `17130` |
|
||||
| Forecast baseline | Open-Meteo |
|
||||
| US official context | weather.gov |
|
||||
| Market layer | Polymarket P0 read-only discovery + quotes |
|
||||
| Removed source | Meteoblue (fully removed from code and docs) |
|
||||
|
||||
## Recent Changes (2026-03-12)
|
||||
|
||||
- Removed all Meteoblue API integration and references.
|
||||
- Added frontend BFF `ETag + Cache-Control` for:
|
||||
- `/api/cities`
|
||||
- `/api/city/{name}/summary` (`force_refresh=true` keeps `no-store`)
|
||||
- `/api/history/{name}`
|
||||
- Added frontend state persistence:
|
||||
- selected city in `localStorage`
|
||||
- risk-group collapse state in sidebar `localStorage`
|
||||
- background summary revision check to silently refresh stale detail cache
|
||||
- Mispricing radar safety hardening:
|
||||
- skip non-tradable markets (`closed`, inactive, not accepting orders, or past `endDate`)
|
||||
- propagate tradable state in `market_scan.primary_market`
|
||||
- AI decision guard:
|
||||
- peak-window state (`before` / `in_window` / `past`) now explicitly injected into AI context
|
||||
- before-peak state now forbids "locked/confirmed floor" style conclusions
|
||||
- Fixed market top-bucket rendering path by deduplicating repeated temperature buckets.
|
||||
- Added frontend fallback guard when market top buckets collapse to low-quality duplicates.
|
||||
- Fixed detail panel accessibility issue (`aria-hidden` focus conflict) using `inert` + active-element blur.
|
||||
- Added Vercel Speed Insights integration in `frontend/app/layout.tsx`.
|
||||
|
||||
## Repositories and Runtime Paths
|
||||
|
||||
- Frontend: `frontend/` (Next.js App Router)
|
||||
- Backend API: `web/app.py` and `src/`
|
||||
- Telegram runtime: `bot_listener.py` + `src/analysis/*`
|
||||
- Docs: `docs/`
|
||||
- Europe / Middle East: Ankara, London, Paris, Munich, Tel Aviv, Milan, Warsaw, Madrid
|
||||
- APAC: Seoul, Hong Kong, Taipei, Shanghai, Singapore, Tokyo, Wellington
|
||||
- Americas: Toronto, New York, Chicago, Dallas, Miami, Atlanta, Seattle, Buenos Aires, Sao Paulo
|
||||
- South Asia: Lucknow
|
||||
- China extension: Chengdu, Chongqing, Shenzhen, Beijing, Wuhan
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Backend + Bot (VPS / Docker)
|
||||
### Backend + Bot (Docker)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
@@ -142,46 +97,93 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Frontend production build check
|
||||
## Recent Highlights
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
- Taipei settlement is aligned to `NOAA RCTP` and rounded whole-degree Celsius logic.
|
||||
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
|
||||
- Intraday analysis now separates:
|
||||
- `Surface Structure`
|
||||
- `Upper-Air Structure`
|
||||
- `Trade cue`
|
||||
- `TAF` is used as an airport-side confirmation layer, not as the main temperature model.
|
||||
- Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience.
|
||||
|
||||
## Runtime Data (Recommended on VPS)
|
||||
|
||||
Use external runtime storage to avoid SQLite/git conflicts:
|
||||
|
||||
```env
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
```
|
||||
|
||||
## Operations Verification
|
||||
## Ops Verification
|
||||
|
||||
### Validate frontend cache headers (`ETag` / `304` / `force_refresh=no-store`)
|
||||
### Health / system status / metrics
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/healthz
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
curl http://127.0.0.1:8000/metrics
|
||||
```
|
||||
|
||||
### Frontend cache headers
|
||||
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
```
|
||||
|
||||
### Watch mispricing radar push decisions
|
||||
### Payment auto-reconciliation logs
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "market not tradable|trade alert pushed|mispricing cap"
|
||||
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
|
||||
```
|
||||
|
||||
## Command Surface (Telegram)
|
||||
### Payment runtime
|
||||
|
||||
| Command | Purpose |
|
||||
| :------------- | :---------------------------- |
|
||||
| `/city <name>` | City real-time analysis |
|
||||
| `/deb <name>` | DEB historical reconciliation |
|
||||
| `/top` | User leaderboard |
|
||||
| `/help` | Help and command usage |
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/api/payments/runtime
|
||||
```
|
||||
|
||||
### Wallet activity logs
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
|
||||
```
|
||||
|
||||
## 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 |
|
||||
| `/help` | Help and usage |
|
||||
|
||||
## Documentation Index
|
||||
|
||||
- Chinese API guide: `docs/API_ZH.md`
|
||||
- Commercial roadmap: `docs/COMMERCIALIZATION.md`
|
||||
- Tech debt (EN): `docs/TECH_DEBT.md`
|
||||
- Tech debt (ZH): `docs/TECH_DEBT_ZH.md`
|
||||
- Chinese overview: `README_ZH.md`
|
||||
- Chinese overview: [README_ZH.md](README_ZH.md)
|
||||
- Chinese API guide: [docs/API_ZH.md](docs/API_ZH.md)
|
||||
- TAF signal guide (ZH): [docs/TAF_SIGNAL_ZH.md](docs/TAF_SIGNAL_ZH.md)
|
||||
- Commercialization: [docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
|
||||
- Open-Core policy: [docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
|
||||
- Supabase setup (ZH): [docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
||||
- Configuration & secrets (ZH): [docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
|
||||
- Frontend deployment (ZH): [docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
- Tech debt (EN): [docs/TECH_DEBT.md](docs/TECH_DEBT.md)
|
||||
- Tech debt (ZH): [docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- Payment verification: [docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
|
||||
- Payment audit: [docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md)
|
||||
- Payment V2 upgrade: [docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- Ops admin guide: [docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||
- Deep research report: [docs/deep-research-report.md](docs/deep-research-report.md)
|
||||
- Frontend report: [FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
|
||||
- Release process: [RELEASE.md](RELEASE.md)
|
||||
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## Status
|
||||
## Version
|
||||
|
||||
- Version: `v1.3`
|
||||
- Last Updated: `2026-03-12`
|
||||
- Runtime: Stable (web + bot + market read-only layer in production)
|
||||
- Version: `v1.5.1`
|
||||
- Last Updated: `2026-03-24`
|
||||
|
||||
+119
-124
@@ -14,120 +14,68 @@
|
||||
|
||||

|
||||
|
||||
## 这个项目在做什么
|
||||
## 当前产品状态(2026-03-21)
|
||||
|
||||
- 聚合监控城市的实测与预报数据。
|
||||
- 用 DEB(Dynamic Error Balancing)做动态融合预测。
|
||||
- 计算结算导向的温度概率分布(`μ` + 温度桶)。
|
||||
- 将模型概率与 Polymarket 只读市场数据对齐,输出错价/风险信号。
|
||||
- Web 仪表盘与 Telegram 机器人共用同一套核心逻辑。
|
||||
- 已上线订阅制:`Pro 月付 5 USDC`。
|
||||
- 已上线积分抵扣:`500 积分 = 1 USDC`,最多抵扣 `3 USDC`。
|
||||
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。
|
||||
- 已上线自动补单:事件监听 + 周期确认双链路。
|
||||
- 已上线支付运行态与审计接口:`/api/payments/runtime`。
|
||||
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。
|
||||
- 已上线轻量可观测性:`/healthz`、`/api/system/status`、`/metrics`。
|
||||
- 运行态状态与缓存已支持 SQLite 渐进迁移:`file / dual / sqlite`。
|
||||
- 已接入 EMOS/CRPS 校准链路,但当前仍保持 `emos_shadow`。
|
||||
|
||||
## 概览图
|
||||
## 开源边界(重要)
|
||||
|
||||
本项目采用 **Open-Core** 策略:
|
||||
|
||||
- 仓库公开部分:天气聚合、基础分析、前端看板、Bot 基础能力、支付标准流程示例。
|
||||
- 生产私有部分:商业风控规则、运营阈值、收费策略细节、付费用户运营脚本、内部对账与审计策略。
|
||||
|
||||
详细见:[Open-Core 与商用边界](docs/OPEN_CORE_POLICY.md)
|
||||
|
||||
## 核心能力
|
||||
|
||||
- 聚合 30 个监控城市的实测与预报数据。
|
||||
- DEB(Dynamic Error Balancing)融合多模型最高温。
|
||||
- 输出结算导向概率分布(`mu` + 温度桶)。
|
||||
- 将模型观点映射到 Polymarket 行情,做错价扫描。
|
||||
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
|
||||
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
|
||||
|
||||
## 参考架构
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["PolyWeather Pro"]
|
||||
flowchart LR
|
||||
U["用户(Web / Telegram)"] --> FE["Next.js 前端(Vercel)"]
|
||||
U --> BOT["Telegram Bot(VPS)"]
|
||||
FE --> API["FastAPI /web/app.py"]
|
||||
BOT --> API
|
||||
|
||||
subgraph DL["数据层"]
|
||||
DL1["METAR (Aviation Weather / METAR)"]
|
||||
DL2["MGM (土耳其 MGM)"]
|
||||
DL3["安卡拉主站 (17130 Center)"]
|
||||
DL4["Open-Meteo"]
|
||||
DL5["weather.gov (美国城市)"]
|
||||
DL6["Polymarket (P0 只读)"]
|
||||
end
|
||||
API --> WX["Weather Collector"]
|
||||
WX --> METAR["Aviation Weather(METAR)"]
|
||||
WX --> MGM["MGM(土耳其站网)"]
|
||||
WX --> OM["Open-Meteo"]
|
||||
|
||||
subgraph AL["分析层"]
|
||||
AL1["DEB (动态误差平衡)"]
|
||||
AL2["概率引擎 (mu + 桶分布)"]
|
||||
AL3["趋势引擎"]
|
||||
AL4["城市风险档案"]
|
||||
AL5["错价雷达"]
|
||||
end
|
||||
|
||||
subgraph DEL["交付层"]
|
||||
DEL1["FastAPI"]
|
||||
DEL2["Next.js 仪表盘"]
|
||||
DEL3["Telegram Bot"]
|
||||
DEL4["预警推送"]
|
||||
end
|
||||
|
||||
subgraph OL["运维层"]
|
||||
OL1["Docker Compose (VPS)"]
|
||||
OL2["Vercel (前端)"]
|
||||
OL3["缓存 + force_refresh"]
|
||||
OL4["Speed Insights"]
|
||||
end
|
||||
|
||||
A --> DL
|
||||
A --> AL
|
||||
A --> DEL
|
||||
A --> OL
|
||||
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
|
||||
ANA --> PAY["支付状态(Intent + Event + Confirm Loop)"]
|
||||
ANA --> PM["Polymarket 只读层"]
|
||||
API --> OBS["healthz / system status / metrics"]
|
||||
ANA --> STATE["SQLite runtime state + dual fallback"]
|
||||
```
|
||||
|
||||
## 系统架构
|
||||
## 监控城市(30)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User[Web / Telegram 用户] --> FE[Vercel Next.js 前端]
|
||||
User --> Bot[VPS Telegram Bot]
|
||||
FE --> API[FastAPI 服务]
|
||||
Bot --> API
|
||||
|
||||
API --> WX[Weather Collector]
|
||||
WX --> METAR[METAR / Aviation Weather]
|
||||
WX --> MGM[MGM API / 周边站]
|
||||
WX --> OM[Open-Meteo]
|
||||
WX --> NWS[weather.gov]
|
||||
|
||||
API --> DEB[DEB + 趋势 + 概率引擎]
|
||||
API --> PM[Polymarket 只读层]
|
||||
PM --> Gamma[Gamma API]
|
||||
PM --> CLOB[CLOB / py-clob-client]
|
||||
```
|
||||
|
||||
## 当前数据源口径
|
||||
|
||||
| 领域 | 当前口径 |
|
||||
| :------------- | :-------------------------------- |
|
||||
| 主观测源 | Aviation Weather / METAR |
|
||||
| Ankara 增强 | MGM + 周边站,领先站固定 `17130` |
|
||||
| 预报基线 | Open-Meteo |
|
||||
| 美国官方语义层 | weather.gov |
|
||||
| 市场层 | Polymarket P0 只读发现 + 报价 |
|
||||
| 已移除 | Meteoblue(代码与文档已全部移除) |
|
||||
|
||||
## 最近更新(2026-03-12)
|
||||
|
||||
- 完整移除 Meteoblue API 及全部引用。
|
||||
- 前端 BFF 增加 `ETag + Cache-Control`:
|
||||
- `/api/cities`
|
||||
- `/api/city/{name}/summary`(`force_refresh=true` 仍保持 `no-store`)
|
||||
- `/api/history/{name}`
|
||||
- 前端状态持久化优化:
|
||||
- 记住上次选中城市(`localStorage`)
|
||||
- 记住侧边栏风险分组折叠状态(`localStorage`)
|
||||
- 详情命中缓存时做后台 revision 检查,静默更新陈旧数据
|
||||
- 错价雷达安全加固:
|
||||
- 市场 `closed` / 不活跃 / 不接受下单 / 超过 `endDate` 时跳过推送
|
||||
- `market_scan.primary_market` 透传可交易状态字段
|
||||
- AI 决策时段约束:
|
||||
- 上下文显式注入峰值窗口状态(`before` / `in_window` / `past`)
|
||||
- 峰值窗口前禁止“已锁定/已确认底线”结论
|
||||
- 修复市场“最热温度桶”重复温度刷屏问题(后端按温度去重 + 前端兜底去重)。
|
||||
- 修复详情面板可访问性告警(`aria-hidden` 焦点冲突),改为 `inert + blur`。
|
||||
- 集成 Vercel Speed Insights(`frontend/app/layout.tsx`)。
|
||||
|
||||
## 目录说明
|
||||
|
||||
- 前端:`frontend/`(Next.js App Router)
|
||||
- 后端:`web/app.py` 与 `src/`
|
||||
- 机器人:`bot_listener.py` + `src/analysis/*`
|
||||
- 文档:`docs/`
|
||||
- 欧洲/中东:Ankara、London、Paris、Munich、Tel Aviv、Milan、Warsaw、Madrid
|
||||
- 亚太:Seoul、Hong Kong、Taipei、Shanghai、Singapore、Tokyo、Wellington
|
||||
- 美洲:Toronto、New York、Chicago、Dallas、Miami、Atlanta、Seattle、Buenos Aires、Sao Paulo
|
||||
- 南亚:Lucknow
|
||||
- 中国扩展:Chengdu、Chongqing、Shenzhen、Beijing、Wuhan
|
||||
|
||||
## 快速启动
|
||||
|
||||
### 后端 + 机器人(VPS / Docker)
|
||||
### 后端 + Bot(Docker)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
@@ -141,46 +89,93 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 前端构建校验
|
||||
## 运行数据目录(VPS 推荐)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住):
|
||||
|
||||
```env
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
POLYWEATHER_STATE_STORAGE_MODE=dual
|
||||
```
|
||||
|
||||
## 运维验收
|
||||
|
||||
### 验证前端缓存头(`ETag` / `304` / `force_refresh=no-store`)
|
||||
### 健康与系统状态
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/healthz
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
curl http://127.0.0.1:8000/metrics
|
||||
```
|
||||
|
||||
### 前端缓存头
|
||||
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
```
|
||||
|
||||
### 观察错价雷达推送决策日志
|
||||
### 支付自动补单日志
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "market not tradable|trade alert pushed|mispricing cap"
|
||||
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
|
||||
```
|
||||
|
||||
## Telegram 命令
|
||||
### 支付运行态
|
||||
|
||||
| 命令 | 用途 |
|
||||
| :------------- | :----------- |
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/api/payments/runtime
|
||||
```
|
||||
|
||||
### 运营后台
|
||||
|
||||
- 前端入口:`https://polyweather-pro.vercel.app/ops`
|
||||
- 后端需配置:
|
||||
|
||||
```env
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
```
|
||||
|
||||
### 钱包异动监听日志
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
|
||||
```
|
||||
|
||||
## Telegram 指令
|
||||
|
||||
| 指令 | 用途 |
|
||||
| :-- | :-- |
|
||||
| `/city <name>` | 城市实时分析 |
|
||||
| `/deb <name>` | DEB 历史对账 |
|
||||
| `/top` | 用户排行榜 |
|
||||
| `/help` | 帮助说明 |
|
||||
| `/deb <name>` | DEB 历史对账 |
|
||||
| `/top` | 用户积分排行 |
|
||||
| `/id` | 查看聊天 Chat ID |
|
||||
| `/diag` | Bot 启动诊断 |
|
||||
| `/help` | 帮助与用法 |
|
||||
|
||||
## 文档索引
|
||||
|
||||
- API 文档(中文):`docs/API_ZH.md`
|
||||
- 商业化路线:`docs/COMMERCIALIZATION.md`
|
||||
- 技术债(英文):`docs/TECH_DEBT.md`
|
||||
- 技术债(中文):`docs/TECH_DEBT_ZH.md`
|
||||
- 英文总览:`README.md`
|
||||
- 英文总览:[README.md](README.md)
|
||||
- API 文档(中文):[docs/API_ZH.md](docs/API_ZH.md)
|
||||
- 商业化说明:[docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
|
||||
- Open-Core 边界:[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)
|
||||
- EMOS 训练报告:[docs/EMOS_TRAINING_REPORT_ZH.md](docs/EMOS_TRAINING_REPORT_ZH.md)
|
||||
- 概率快照归档:[docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md](docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md)
|
||||
- 技术债(中文镜像):[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- 技术债(主文档):[docs/TECH_DEBT.md](docs/TECH_DEBT.md)
|
||||
- 支付合约验证:[docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
|
||||
- 支付审计说明:[docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md)
|
||||
- 支付 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/deep-research-report.md](docs/deep-research-report.md)
|
||||
- 前端报告:[FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
|
||||
- 发布流程:[RELEASE.md](RELEASE.md)
|
||||
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## 当前状态
|
||||
## 当前版本
|
||||
|
||||
- 版本:`v1.3`
|
||||
- 最后更新:`2026-03-12`
|
||||
- 状态:稳定运行(Web + Bot + 市场只读层)
|
||||
- 版本:`v1.5.1`
|
||||
- 文档最后更新:`2026-03-21`
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
# 版本发布流程
|
||||
|
||||
本项目采用语义化版本号:`MAJOR.MINOR.PATCH`
|
||||
|
||||
当前单一版本源为根目录 [VERSION](E:/web/PolyWeather/VERSION) 文件,所有对外文档与前端版本号都从这里同步。
|
||||
|
||||
## 版本规则
|
||||
|
||||
- `PATCH`:修复缺陷、文档修正、兼容性不变的小改动
|
||||
- `MINOR`:新增能力、接口扩展、向后兼容的功能迭代
|
||||
- `MAJOR`:不兼容变更、核心架构升级、公开接口重大调整
|
||||
|
||||
示例:
|
||||
|
||||
- `1.4.0 -> 1.4.1`:告警逻辑修正、缓存修正、文档修正
|
||||
- `1.4.0 -> 1.5.0`:新增支付能力、新增页面、新增 API
|
||||
- `1.4.0 -> 2.0.0`:接口重构或数据结构不兼容
|
||||
|
||||
## 日常升版步骤
|
||||
|
||||
### 1. 升版本号
|
||||
|
||||
```bash
|
||||
python scripts/bump_version.py patch
|
||||
```
|
||||
|
||||
可选参数:
|
||||
|
||||
```bash
|
||||
python scripts/bump_version.py minor
|
||||
python scripts/bump_version.py major
|
||||
python scripts/bump_version.py 1.5.0
|
||||
```
|
||||
|
||||
### 2. 检查同步结果
|
||||
|
||||
```bash
|
||||
python scripts/sync_version.py
|
||||
git diff
|
||||
```
|
||||
|
||||
### 3. 补充 Changelog
|
||||
|
||||
在 [CHANGELOG.md](E:/web/PolyWeather/CHANGELOG.md) 对应版本下补齐:
|
||||
|
||||
- 新增能力
|
||||
- 修复项
|
||||
- 兼容性说明
|
||||
|
||||
### 4. 验证
|
||||
|
||||
建议至少执行:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
如涉及后端核心逻辑,补充执行:
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
### 5. 提交与打标签
|
||||
|
||||
工作区干净后再打标签:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "release: v1.4.1"
|
||||
git tag v1.4.1
|
||||
```
|
||||
|
||||
### 6. 推送
|
||||
|
||||
```bash
|
||||
git push
|
||||
git push origin v1.4.1
|
||||
```
|
||||
|
||||
## 当前约束
|
||||
|
||||
- 不直接手改多份文档版本号
|
||||
- 不在工作区脏状态下打 release tag
|
||||
- `README.md`、前端 `package.json`、文档标题版本都通过脚本同步
|
||||
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"version": "emos-20260320132525",
|
||||
"trained_at": "2026-03-20T13:25:25.836021+00:00",
|
||||
"global": {
|
||||
"mu": {
|
||||
"intercept": -1.57406048,
|
||||
"raw_mu_coef": 2.80583627,
|
||||
"deb_coef": -0.06819634,
|
||||
"ens_median_coef": -1.81560215,
|
||||
"max_so_far_gap_coef": 0.0
|
||||
},
|
||||
"sigma": {
|
||||
"intercept": 0.67509915,
|
||||
"raw_sigma_coef": 0.14431833,
|
||||
"spread_coef": 0.14431833,
|
||||
"peak_flag_coef": 0.0,
|
||||
"max_so_far_gap_coef": 0.0
|
||||
}
|
||||
},
|
||||
"sigma_constraints": {
|
||||
"min_ratio": 0.85,
|
||||
"max_ratio": 1.35,
|
||||
"absolute_min": 0.25,
|
||||
"absolute_max": 3.0
|
||||
},
|
||||
"selection_guardrails": {
|
||||
"max_mae_increase": 0.02,
|
||||
"max_bucket_hit_drop": 0.01,
|
||||
"max_bucket_brier_increase": 0.05
|
||||
},
|
||||
"blending": {
|
||||
"alpha_mu": 0.0,
|
||||
"alpha_sigma": 0.0
|
||||
},
|
||||
"cities": {
|
||||
"ankara": {
|
||||
"samples": 7,
|
||||
"mu_bias": 0.566273,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.875
|
||||
},
|
||||
"london": {
|
||||
"samples": 6,
|
||||
"mu_bias": 0.489961,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.75
|
||||
},
|
||||
"new york": {
|
||||
"samples": 4,
|
||||
"mu_bias": 1.852451,
|
||||
"sigma_scale": 0.830444,
|
||||
"confidence": 0.5
|
||||
},
|
||||
"paris": {
|
||||
"samples": 7,
|
||||
"mu_bias": 0.308599,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.875
|
||||
},
|
||||
"seoul": {
|
||||
"samples": 6,
|
||||
"mu_bias": -1.486024,
|
||||
"sigma_scale": 1.569585,
|
||||
"confidence": 0.75
|
||||
},
|
||||
"toronto": {
|
||||
"samples": 5,
|
||||
"mu_bias": -0.734395,
|
||||
"sigma_scale": 1.656751,
|
||||
"confidence": 0.625
|
||||
},
|
||||
"buenos aires": {
|
||||
"samples": 5,
|
||||
"mu_bias": -1.753334,
|
||||
"sigma_scale": 1.831739,
|
||||
"confidence": 0.625
|
||||
},
|
||||
"wellington": {
|
||||
"samples": 6,
|
||||
"mu_bias": 0.350757,
|
||||
"sigma_scale": 1.377974,
|
||||
"confidence": 0.75
|
||||
},
|
||||
"chicago": {
|
||||
"samples": 4,
|
||||
"mu_bias": 3.01062,
|
||||
"sigma_scale": 0.825575,
|
||||
"confidence": 0.5
|
||||
},
|
||||
"sao paulo": {
|
||||
"samples": 5,
|
||||
"mu_bias": 1.632457,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.625
|
||||
},
|
||||
"dallas": {
|
||||
"samples": 4,
|
||||
"mu_bias": 3.77714,
|
||||
"sigma_scale": 0.796874,
|
||||
"confidence": 0.5
|
||||
},
|
||||
"miami": {
|
||||
"samples": 5,
|
||||
"mu_bias": -4.868741,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.625
|
||||
},
|
||||
"atlanta": {
|
||||
"samples": 5,
|
||||
"mu_bias": -7.648823,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.625
|
||||
},
|
||||
"seattle": {
|
||||
"samples": 4,
|
||||
"mu_bias": 4.058619,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.5
|
||||
},
|
||||
"lucknow": {
|
||||
"samples": 4,
|
||||
"mu_bias": 3.257609,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.5
|
||||
},
|
||||
"munich": {
|
||||
"samples": 6,
|
||||
"mu_bias": -0.780811,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.75
|
||||
},
|
||||
"hong kong": {
|
||||
"samples": 3,
|
||||
"mu_bias": -0.492675,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.375
|
||||
},
|
||||
"taipei": {
|
||||
"samples": 3,
|
||||
"mu_bias": 1.462462,
|
||||
"sigma_scale": 1.132834,
|
||||
"confidence": 0.375
|
||||
},
|
||||
"milan": {
|
||||
"samples": 3,
|
||||
"mu_bias": -2.729203,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.375
|
||||
},
|
||||
"warsaw": {
|
||||
"samples": 3,
|
||||
"mu_bias": 0.349319,
|
||||
"sigma_scale": 1.337949,
|
||||
"confidence": 0.375
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"sample_count": 105,
|
||||
"mean_crps": 2.923823,
|
||||
"legacy_mean_crps": 2.793938,
|
||||
"legacy_mean_mae": 2.721143,
|
||||
"legacy_bucket_hit_rate": 0.695238,
|
||||
"legacy_bucket_brier": 0.775463,
|
||||
"selected_mean_crps": 2.700275,
|
||||
"selected_mean_mae": 2.721143,
|
||||
"selected_bucket_hit_rate": 0.695238,
|
||||
"selected_bucket_brier": 0.765459,
|
||||
"selected_score": 4.003626,
|
||||
"legacy_score": 4.104792,
|
||||
"filled_actual_from_history": 2,
|
||||
"settlement_history_city_count": 30
|
||||
},
|
||||
"source": "artifacts\\probability_calibration\\default.json"
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
{
|
||||
"summary": {
|
||||
"sample_count": 105,
|
||||
"filled_actual_from_history": 2,
|
||||
"legacy": {
|
||||
"mean_crps": 2.793938,
|
||||
"mean_mae": 2.721143,
|
||||
"bucket_hit_rate": 0.695238
|
||||
},
|
||||
"emos": {
|
||||
"mean_crps": 2.700275,
|
||||
"mean_mae": 2.721143,
|
||||
"bucket_hit_rate": 0.695238
|
||||
},
|
||||
"delta": {
|
||||
"crps": -0.093663,
|
||||
"mae": 0.0,
|
||||
"bucket_hit_rate": 0.0
|
||||
}
|
||||
},
|
||||
"by_city": {
|
||||
"ankara": {
|
||||
"samples": 7,
|
||||
"legacy_mean_crps": 2.023242,
|
||||
"emos_mean_crps": 2.023242,
|
||||
"legacy_mean_mae": 1.984286,
|
||||
"emos_mean_mae": 1.984286,
|
||||
"legacy_bucket_hit_rate": 0.714286,
|
||||
"emos_bucket_hit_rate": 0.714286
|
||||
},
|
||||
"atlanta": {
|
||||
"samples": 5,
|
||||
"legacy_mean_crps": 12.792034,
|
||||
"emos_mean_crps": 12.694543,
|
||||
"legacy_mean_mae": 12.806,
|
||||
"emos_mean_mae": 12.806,
|
||||
"legacy_bucket_hit_rate": 0.6,
|
||||
"emos_bucket_hit_rate": 0.6
|
||||
},
|
||||
"buenos aires": {
|
||||
"samples": 5,
|
||||
"legacy_mean_crps": 3.846144,
|
||||
"emos_mean_crps": 3.846144,
|
||||
"legacy_mean_mae": 4.168,
|
||||
"emos_mean_mae": 4.168,
|
||||
"legacy_bucket_hit_rate": 0.6,
|
||||
"emos_bucket_hit_rate": 0.6
|
||||
},
|
||||
"chicago": {
|
||||
"samples": 4,
|
||||
"legacy_mean_crps": 1.346667,
|
||||
"emos_mean_crps": 0.601765,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.0,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"dallas": {
|
||||
"samples": 4,
|
||||
"legacy_mean_crps": 1.256111,
|
||||
"emos_mean_crps": 0.651425,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.0,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"hong kong": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.261027,
|
||||
"emos_mean_crps": 0.261027,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"emos_mean_mae": 0.1,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 0.666667
|
||||
},
|
||||
"london": {
|
||||
"samples": 6,
|
||||
"legacy_mean_crps": 2.079624,
|
||||
"emos_mean_crps": 2.079624,
|
||||
"legacy_mean_mae": 2.451667,
|
||||
"emos_mean_mae": 2.451667,
|
||||
"legacy_bucket_hit_rate": 0.166667,
|
||||
"emos_bucket_hit_rate": 0.166667
|
||||
},
|
||||
"lucknow": {
|
||||
"samples": 4,
|
||||
"legacy_mean_crps": 1.468528,
|
||||
"emos_mean_crps": 1.468528,
|
||||
"legacy_mean_mae": 1.6025,
|
||||
"emos_mean_mae": 1.6025,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
},
|
||||
"madrid": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 6.27726,
|
||||
"emos_mean_crps": 6.27726,
|
||||
"legacy_mean_mae": 7.33,
|
||||
"emos_mean_mae": 7.33,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"miami": {
|
||||
"samples": 5,
|
||||
"legacy_mean_crps": 11.665378,
|
||||
"emos_mean_crps": 11.665378,
|
||||
"legacy_mean_mae": 12.07,
|
||||
"emos_mean_mae": 12.07,
|
||||
"legacy_bucket_hit_rate": 0.6,
|
||||
"emos_bucket_hit_rate": 0.6
|
||||
},
|
||||
"milan": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 4.401392,
|
||||
"emos_mean_crps": 3.928883,
|
||||
"legacy_mean_mae": 4.06,
|
||||
"emos_mean_mae": 4.06,
|
||||
"legacy_bucket_hit_rate": 0.666667,
|
||||
"emos_bucket_hit_rate": 0.666667
|
||||
},
|
||||
"munich": {
|
||||
"samples": 6,
|
||||
"legacy_mean_crps": 2.988583,
|
||||
"emos_mean_crps": 2.988583,
|
||||
"legacy_mean_mae": 3.143333,
|
||||
"emos_mean_mae": 3.143333,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
},
|
||||
"new york": {
|
||||
"samples": 4,
|
||||
"legacy_mean_crps": 1.861101,
|
||||
"emos_mean_crps": 1.409393,
|
||||
"legacy_mean_mae": 1.3725,
|
||||
"emos_mean_mae": 1.3725,
|
||||
"legacy_bucket_hit_rate": 0.75,
|
||||
"emos_bucket_hit_rate": 0.75
|
||||
},
|
||||
"paris": {
|
||||
"samples": 7,
|
||||
"legacy_mean_crps": 2.430082,
|
||||
"emos_mean_crps": 2.430082,
|
||||
"legacy_mean_mae": 2.518571,
|
||||
"emos_mean_mae": 2.518571,
|
||||
"legacy_bucket_hit_rate": 0.571429,
|
||||
"emos_bucket_hit_rate": 0.571429
|
||||
},
|
||||
"sao paulo": {
|
||||
"samples": 5,
|
||||
"legacy_mean_crps": 2.454756,
|
||||
"emos_mean_crps": 2.454756,
|
||||
"legacy_mean_mae": 2.628,
|
||||
"emos_mean_mae": 2.628,
|
||||
"legacy_bucket_hit_rate": 0.6,
|
||||
"emos_bucket_hit_rate": 0.6
|
||||
},
|
||||
"seattle": {
|
||||
"samples": 4,
|
||||
"legacy_mean_crps": 0.531656,
|
||||
"emos_mean_crps": 0.452784,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.0,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"seoul": {
|
||||
"samples": 6,
|
||||
"legacy_mean_crps": 0.328088,
|
||||
"emos_mean_crps": 0.328088,
|
||||
"legacy_mean_mae": 0.2,
|
||||
"emos_mean_mae": 0.2,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"shanghai": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.250034,
|
||||
"emos_mean_crps": 0.250034,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"emos_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"singapore": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.281993,
|
||||
"emos_mean_crps": 0.281993,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"emos_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"taipei": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.356996,
|
||||
"emos_mean_crps": 0.356996,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"emos_mean_mae": 0.1,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"tel aviv": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.446758,
|
||||
"emos_mean_crps": 0.446758,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"emos_mean_mae": 0.3,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"tokyo": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.450128,
|
||||
"emos_mean_crps": 0.450128,
|
||||
"legacy_mean_mae": 0.25,
|
||||
"emos_mean_mae": 0.25,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"toronto": {
|
||||
"samples": 5,
|
||||
"legacy_mean_crps": 2.647861,
|
||||
"emos_mean_crps": 2.566068,
|
||||
"legacy_mean_mae": 2.532,
|
||||
"emos_mean_mae": 2.532,
|
||||
"legacy_bucket_hit_rate": 0.6,
|
||||
"emos_bucket_hit_rate": 0.6
|
||||
},
|
||||
"warsaw": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 1.618875,
|
||||
"emos_mean_crps": 1.618875,
|
||||
"legacy_mean_mae": 2.056667,
|
||||
"emos_mean_mae": 2.056667,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"emos_bucket_hit_rate": 0.333333
|
||||
},
|
||||
"wellington": {
|
||||
"samples": 6,
|
||||
"legacy_mean_crps": 0.266349,
|
||||
"emos_mean_crps": 0.266349,
|
||||
"legacy_mean_mae": 0.2,
|
||||
"emos_mean_mae": 0.2,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"evaluation_report_path": "E:\\web\\PolyWeather\\artifacts\\probability_calibration\\evaluation_report.json",
|
||||
"shadow_report_path": "E:\\web\\PolyWeather\\artifacts\\probability_calibration\\shadow_report.json",
|
||||
"evaluation_report_exists": true,
|
||||
"shadow_report_exists": true,
|
||||
"decision": {
|
||||
"decision": "hold",
|
||||
"ready_for_primary": false,
|
||||
"summary": "当前指标不足以切换 emos_primary,应继续保持 shadow。",
|
||||
"thresholds": {
|
||||
"evaluation_min_samples": 80,
|
||||
"shadow_min_samples": 50,
|
||||
"max_delta_mae": 0.05,
|
||||
"min_delta_crps": -0.02,
|
||||
"min_delta_bucket_hit_rate": 0.0,
|
||||
"max_delta_bucket_brier_promote": 0.02,
|
||||
"max_delta_bucket_brier_observe": 0.15
|
||||
},
|
||||
"evaluation": {
|
||||
"sample_count": 105,
|
||||
"delta_crps": -0.093663,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0
|
||||
},
|
||||
"shadow": {
|
||||
"sample_count": 103,
|
||||
"delta_mae": 0.012708,
|
||||
"delta_bucket_hit_rate": 0.009709,
|
||||
"delta_bucket_brier": 0.293835
|
||||
},
|
||||
"blocking_reasons": [
|
||||
"shadow bucket brier 退化超限:delta=0.293835"
|
||||
],
|
||||
"worst_shadow_regressions": [
|
||||
{
|
||||
"city": "dallas",
|
||||
"samples": 4,
|
||||
"delta_mae": 0.114807,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.778678
|
||||
},
|
||||
{
|
||||
"city": "chicago",
|
||||
"samples": 4,
|
||||
"delta_mae": 0.075265,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.746156
|
||||
},
|
||||
{
|
||||
"city": "seattle",
|
||||
"samples": 4,
|
||||
"delta_mae": 0.11262,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.692003
|
||||
},
|
||||
{
|
||||
"city": "atlanta",
|
||||
"samples": 4,
|
||||
"delta_mae": 0.293028,
|
||||
"delta_bucket_hit_rate": -0.25,
|
||||
"delta_bucket_brier": 0.601425
|
||||
},
|
||||
{
|
||||
"city": "miami",
|
||||
"samples": 4,
|
||||
"delta_mae": 0.241559,
|
||||
"delta_bucket_hit_rate": -0.5,
|
||||
"delta_bucket_brier": 0.478245
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2
-405
@@ -1,416 +1,13 @@
|
||||
import sys
|
||||
import os
|
||||
import telebot # type: ignore
|
||||
from loguru import logger # type: ignore
|
||||
import sys
|
||||
|
||||
# 确保项目根目录在 sys.path 中
|
||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from src.utils.config_loader import load_config # type: ignore # noqa: E402
|
||||
from src.utils.telegram_push import start_trade_alert_push_loop # type: ignore # noqa: E402
|
||||
from src.onchain.polygon_wallet_watcher import start_polygon_wallet_watch_loop # type: ignore # noqa: E402
|
||||
from src.onchain.polymarket_wallet_activity_watcher import start_polymarket_wallet_activity_loop # type: ignore # noqa: E402
|
||||
from src.data_collection.weather_sources import WeatherDataCollector # type: ignore # noqa: E402
|
||||
from src.database.db_manager import DBManager
|
||||
from src.analysis.city_query_service import (
|
||||
resolve_city_name,
|
||||
build_city_query_report,
|
||||
)
|
||||
|
||||
MESSAGE_POINTS = 4
|
||||
MESSAGE_DAILY_CAP = 50
|
||||
MESSAGE_MIN_LENGTH = 2
|
||||
MESSAGE_COOLDOWN_SEC = 30
|
||||
CITY_QUERY_COST = 1
|
||||
DEB_QUERY_COST = 1
|
||||
|
||||
|
||||
def start_bot():
|
||||
config = load_config()
|
||||
token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
if not token:
|
||||
logger.error("未找到 TELEGRAM_BOT_TOKEN 环境变量")
|
||||
return
|
||||
|
||||
bot = telebot.TeleBot(token)
|
||||
db = DBManager()
|
||||
weather = WeatherDataCollector(config)
|
||||
start_trade_alert_push_loop(bot, config)
|
||||
start_polygon_wallet_watch_loop(bot)
|
||||
start_polymarket_wallet_activity_loop(bot)
|
||||
|
||||
def _display_name(user) -> str:
|
||||
return user.username or user.first_name or f"User_{user.id}"
|
||||
|
||||
def _ensure_query_points(message, cost: int, label: str) -> bool:
|
||||
user = message.from_user
|
||||
db.upsert_user(user.id, _display_name(user))
|
||||
result = db.spend_points(user.id, cost)
|
||||
if result.get("ok"):
|
||||
return True
|
||||
|
||||
balance = int(result.get("balance") or 0)
|
||||
required = int(result.get("required") or cost)
|
||||
missing = max(0, required - balance)
|
||||
bot.reply_to(
|
||||
message,
|
||||
(
|
||||
f"❌ 积分不足,无法执行 <b>{label}</b>\n"
|
||||
f"当前积分: <code>{balance}</code>\n"
|
||||
f"需要积分: <code>{required}</code>\n"
|
||||
f"还差积分: <code>{missing}</code>\n\n"
|
||||
f"积分规则:每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return False
|
||||
|
||||
@bot.message_handler(commands=["start", "help"])
|
||||
def send_welcome(message):
|
||||
welcome_text = (
|
||||
"🚀 <b>PolyWeather 天气查询机器人</b>\n\n"
|
||||
"可用指令:\n"
|
||||
f"/city [城市名] - 查询城市天气预测与实测 (消耗 {CITY_QUERY_COST} 积分)\n"
|
||||
f"/deb [城市名] - 查看 DEB 融合预测准确率 (消耗 {DEB_QUERY_COST} 积分)\n"
|
||||
"/top - 查看积分排行榜\n"
|
||||
"/id - 获取当前聊天的 Chat ID\n\n"
|
||||
"示例: <code>/city 伦敦</code>\n"
|
||||
f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。</i>"
|
||||
)
|
||||
bot.reply_to(message, welcome_text, parse_mode="HTML")
|
||||
|
||||
@bot.message_handler(commands=["id"])
|
||||
def get_chat_id(message):
|
||||
bot.reply_to(
|
||||
message,
|
||||
f"🎯 当前聊天的 Chat ID 是: <code>{message.chat.id}</code>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
@bot.message_handler(commands=["top"])
|
||||
def show_points(message):
|
||||
"""显示当前用户的积分及排行榜"""
|
||||
user = message.from_user
|
||||
db.upsert_user(user.id, _display_name(user))
|
||||
user_info = db.get_user(user.id)
|
||||
|
||||
leaderboard = db.get_leaderboard(limit=5)
|
||||
rank_text = "🏆 <b>PolyWeather 活跃度排行榜</b>\n"
|
||||
rank_text += "────────────────────\n"
|
||||
for i, entry in enumerate(leaderboard):
|
||||
medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " "
|
||||
rank_text += f"{medal} {entry['username'][:12]}: <b>{entry['points']}</b> 点\n"
|
||||
|
||||
if user_info:
|
||||
rank_text += "────────────────────\n"
|
||||
rank_text += (
|
||||
f"👤 <b>我的状态:</b>\n"
|
||||
f"┣ 积分: <code>{user_info['points']}</code>\n"
|
||||
f"┣ 发言: <code>{user_info['message_count']}</code> 次\n"
|
||||
f"┣ 今日发言积分: <code>{user_info.get('daily_points') or 0}/{MESSAGE_DAILY_CAP}</code>\n"
|
||||
f"┗ /city 消耗: <code>{CITY_QUERY_COST}</code> | /deb 消耗: <code>{DEB_QUERY_COST}</code>"
|
||||
)
|
||||
|
||||
bot.send_message(message.chat.id, rank_text, parse_mode="HTML")
|
||||
@bot.message_handler(commands=["deb"])
|
||||
def deb_accuracy(message):
|
||||
"""查询 DEB 融合预测的近 7 天准确率。"""
|
||||
try:
|
||||
parts = message.text.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
bot.reply_to(
|
||||
message,
|
||||
"❌ 用法: <code>/deb ankara</code>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
import os as _os
|
||||
|
||||
from src.analysis.deb_algorithm import (
|
||||
load_history,
|
||||
_is_excluded_model_name,
|
||||
reconcile_recent_actual_highs,
|
||||
)
|
||||
from src.data_collection.city_registry import ALIASES
|
||||
|
||||
city_input = parts[1].strip().lower()
|
||||
city_name = ALIASES.get(city_input, city_input)
|
||||
|
||||
project_root = _os.path.dirname(_os.path.abspath(__file__))
|
||||
history_file = _os.path.join(project_root, "data", "daily_records.json")
|
||||
data = load_history(history_file)
|
||||
|
||||
if city_name not in data or not data[city_name]:
|
||||
bot.reply_to(
|
||||
message,
|
||||
f"❌ 暂无 {city_name} 的历史数据。",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
if not _ensure_query_points(message, DEB_QUERY_COST, "/deb"):
|
||||
return
|
||||
|
||||
reconcile_info = reconcile_recent_actual_highs(city_name, lookback_days=7)
|
||||
# Reload in case reconciliation updated the file
|
||||
data = load_history(history_file)
|
||||
city_data = data[city_name]
|
||||
today = _dt.now().date()
|
||||
today_str = today.strftime("%Y-%m-%d")
|
||||
cutoff_date = today - _td(days=6)
|
||||
|
||||
recent_items = []
|
||||
for date_str, record in city_data.items():
|
||||
try:
|
||||
row_date = _dt.strptime(date_str, "%Y-%m-%d").date()
|
||||
except Exception:
|
||||
continue
|
||||
if row_date >= cutoff_date:
|
||||
recent_items.append((date_str, record, row_date))
|
||||
|
||||
recent_items.sort(key=lambda item: item[0])
|
||||
|
||||
lines = [
|
||||
f"📊 <b>DEB 准确率报告 - {city_name.title()}</b>",
|
||||
"",
|
||||
"📅 <b>近日记录:</b>",
|
||||
]
|
||||
if (
|
||||
isinstance(reconcile_info, dict)
|
||||
and reconcile_info.get("ok")
|
||||
and int(reconcile_info.get("updated") or 0) > 0
|
||||
):
|
||||
lines.extend(
|
||||
[
|
||||
f"🔁 已用 METAR 历史回填修正 {int(reconcile_info.get('updated'))} 天实测最高温",
|
||||
"",
|
||||
]
|
||||
)
|
||||
total_days = 0
|
||||
hits = 0
|
||||
deb_errors = []
|
||||
signed_errors = []
|
||||
model_errors = {}
|
||||
|
||||
for date_str, record, _row_date in recent_items:
|
||||
actual = record.get("actual_high")
|
||||
deb_pred = record.get("deb_prediction")
|
||||
forecasts = record.get("forecasts", {}) or {}
|
||||
|
||||
if actual is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
actual = float(actual)
|
||||
if deb_pred is not None:
|
||||
deb_pred = float(deb_pred)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if deb_pred is None and forecasts:
|
||||
valid_preds = [
|
||||
float(v)
|
||||
for k, v in forecasts.items()
|
||||
if v is not None and not _is_excluded_model_name(k)
|
||||
]
|
||||
if valid_preds:
|
||||
deb_pred = round(sum(valid_preds) / len(valid_preds), 1)
|
||||
|
||||
actual_wu = round(actual)
|
||||
|
||||
if date_str == today_str:
|
||||
lines.append(f" {date_str}: 📍 今天进行中 (实测暂 {actual:.1f})")
|
||||
elif deb_pred is not None:
|
||||
total_days += 1
|
||||
deb_wu = round(deb_pred)
|
||||
hit = deb_wu == actual_wu
|
||||
if hit:
|
||||
hits += 1
|
||||
err = deb_pred - actual
|
||||
deb_errors.append(abs(err))
|
||||
signed_errors.append(err)
|
||||
|
||||
if hit:
|
||||
result_icon = "✅"
|
||||
err_text = f"偏差{abs(err):.1f}°"
|
||||
elif err < 0:
|
||||
result_icon = "❌"
|
||||
err_text = f"低估{abs(err):.1f}°"
|
||||
else:
|
||||
result_icon = "❌"
|
||||
err_text = f"高估{abs(err):.1f}°"
|
||||
|
||||
retro = "≈" if "deb_prediction" not in record else ""
|
||||
lines.append(
|
||||
f" {date_str}: DEB {retro}{deb_pred:.1f}→{deb_wu} vs 实测 {actual:.1f}→{actual_wu} "
|
||||
f"{result_icon} {err_text}"
|
||||
)
|
||||
|
||||
if date_str != today_str and actual is not None:
|
||||
for model, pred in forecasts.items():
|
||||
if _is_excluded_model_name(model):
|
||||
continue
|
||||
if pred is None:
|
||||
continue
|
||||
try:
|
||||
model_errors.setdefault(model, []).append(abs(float(pred) - actual))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if total_days > 0:
|
||||
hit_rate = hits / total_days * 100
|
||||
deb_mae = sum(deb_errors) / len(deb_errors)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"🏁 <b>DEB 总战绩:</b>WU命中 {hits}/{total_days} (<b>{hit_rate:.0f}%</b>) | MAE: {deb_mae:.1f}°"
|
||||
)
|
||||
|
||||
if model_errors:
|
||||
lines.append("")
|
||||
lines.append("📈 <b>模型 MAE 对比:</b>")
|
||||
model_maes = {m: sum(e) / len(e) for m, e in model_errors.items() if e}
|
||||
sorted_models = sorted(model_maes.items(), key=lambda item: item[1])
|
||||
for model, mae in sorted_models:
|
||||
tag = " ⭐" if mae <= deb_mae else ""
|
||||
lines.append(f" {model}: {mae:.1f}°{tag}")
|
||||
lines.append(f" <b>DEB融合: {deb_mae:.1f}°</b>")
|
||||
|
||||
mean_bias = sum(signed_errors) / len(signed_errors)
|
||||
underest = sum(1 for e in signed_errors if e < -0.3)
|
||||
overest = sum(1 for e in signed_errors if e > 0.3)
|
||||
accurate = total_days - underest - overest
|
||||
|
||||
lines.append("")
|
||||
lines.append("🔍 <b>偏差分析:</b>")
|
||||
if abs(mean_bias) > 0.3:
|
||||
bias_label = "系统性低估" if mean_bias < 0 else "系统性高估"
|
||||
lines.append(f" ⚠️ {bias_label}:平均偏差 {mean_bias:+.1f}°")
|
||||
else:
|
||||
lines.append(f" ✅ 整体无明显系统偏差:平均偏差 {mean_bias:+.1f}°")
|
||||
lines.append(f" (低估 {underest} 次 | 高估 {overest} 次 | 准确 {accurate} 次)")
|
||||
|
||||
lines.append("")
|
||||
lines.append("💡 <b>建议:</b>")
|
||||
if underest > overest and abs(mean_bias) > 0.5:
|
||||
lines.append(
|
||||
f" 该城市模型集体低估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值高 "
|
||||
f"{abs(mean_bias):.0f}-{abs(mean_bias) + 0.5:.0f}°。交易时建议适当看高。"
|
||||
)
|
||||
elif overest > underest and abs(mean_bias) > 0.5:
|
||||
lines.append(
|
||||
f" 该城市模型集体高估趋势明显({mean_bias:+.1f}°),实际最高温可能低于 DEB 融合值。交易时注意追高风险。"
|
||||
)
|
||||
elif deb_mae > 1.5:
|
||||
lines.append(f" 近期模型波动较大(MAE {deb_mae:.1f}°),建议降低对单一日预测的信任度。")
|
||||
elif hit_rate >= 60:
|
||||
lines.append(" DEB 近期表现稳定,可继续作为主要参考。")
|
||||
else:
|
||||
lines.append(" 近期准确率一般,建议结合主站实测与周边站点共同判断。")
|
||||
|
||||
lines.append("")
|
||||
lines.append("📝 MAE = 平均绝对误差,越小越准。⭐ = 优于 DEB 融合。")
|
||||
lines.append("📅 统计窗口:近7天滚动样本。")
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append("🔔 近 7 天尚无完整的 DEB 预测记录。")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"💸 本次消耗 <code>{DEB_QUERY_COST}</code> 积分。")
|
||||
bot.reply_to(message, "\n".join(lines), parse_mode="HTML")
|
||||
except Exception as e:
|
||||
bot.reply_to(message, f"❌ 查询失败: {e}")
|
||||
|
||||
@bot.message_handler(commands=["city"])
|
||||
def get_city_info(message):
|
||||
"""查询指定城市的天气详情"""
|
||||
try:
|
||||
parts = message.text.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
bot.reply_to(
|
||||
message,
|
||||
"❌ 请输入城市名称\n\n用法: <code>/city chicago</code>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
city_input = parts[1].strip().lower()
|
||||
city_name, supported_cities = resolve_city_name(city_input)
|
||||
if not city_name:
|
||||
city_list = ", ".join(supported_cities)
|
||||
bot.reply_to(
|
||||
message,
|
||||
f"❌ 未找到城市: <b>{city_input}</b>\n\n支持的城市: {city_list}",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
if not _ensure_query_points(message, CITY_QUERY_COST, "/city"):
|
||||
return
|
||||
|
||||
bot.send_message(
|
||||
message.chat.id, f"🔍 正在查询 {city_name.title()} 的天气数据..."
|
||||
)
|
||||
|
||||
coords = weather.get_coordinates(city_name)
|
||||
if not coords:
|
||||
bot.reply_to(message, f"❌ 未找到城市坐标: {city_name}")
|
||||
return
|
||||
|
||||
weather_data = weather.fetch_all_sources(
|
||||
city_name,
|
||||
lat=coords["lat"],
|
||||
lon=coords["lon"],
|
||||
force_refresh=True,
|
||||
)
|
||||
city_report = build_city_query_report(
|
||||
city_name=city_name,
|
||||
weather_data=weather_data,
|
||||
city_query_cost=CITY_QUERY_COST,
|
||||
)
|
||||
bot.send_message(message.chat.id, city_report, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
logger.error(f"查询失败: {e}\n{traceback.format_exc()}")
|
||||
bot.reply_to(message, f"❌ 查询失败: {e}")
|
||||
|
||||
@bot.message_handler(func=lambda message: True, content_types=['text'])
|
||||
def track_activity(message):
|
||||
"""全量监听消息,用于记录群内发言积分(非指令消息)"""
|
||||
if message.text.startswith('/'):
|
||||
return
|
||||
if message.chat.type not in ("group", "supergroup"):
|
||||
return
|
||||
|
||||
user = message.from_user
|
||||
username = _display_name(user)
|
||||
db.upsert_user(user.id, username)
|
||||
|
||||
result = db.add_message_activity(
|
||||
user.id,
|
||||
text=message.text,
|
||||
points_to_add=MESSAGE_POINTS,
|
||||
cooldown_sec=MESSAGE_COOLDOWN_SEC,
|
||||
daily_cap=MESSAGE_DAILY_CAP,
|
||||
min_text_length=MESSAGE_MIN_LENGTH,
|
||||
)
|
||||
if result.get("awarded"):
|
||||
logger.info(
|
||||
f"message points awarded user={user.id} points=+{MESSAGE_POINTS} "
|
||||
f"daily_points={result.get('daily_points')}/{MESSAGE_DAILY_CAP}"
|
||||
)
|
||||
|
||||
logger.info("🤖 Bot 启动中...")
|
||||
bot.infinity_polling()
|
||||
from src.bot.orchestrator import start_bot # noqa: E402
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
start_bot()
|
||||
|
||||
|
||||
|
||||
+93
-67
@@ -1,71 +1,97 @@
|
||||
# Weather API Configuration
|
||||
weather:
|
||||
timeout: 30
|
||||
|
||||
# Target Cities
|
||||
cities:
|
||||
- id: "london"
|
||||
city: "London"
|
||||
country: "UK"
|
||||
latitude: 51.5074
|
||||
longitude: -0.1278
|
||||
- id: "paris"
|
||||
city: "Paris"
|
||||
country: "France"
|
||||
latitude: 48.8566
|
||||
longitude: 2.3522
|
||||
- id: "ankara"
|
||||
city: "Ankara"
|
||||
country: "Turkey"
|
||||
latitude: 39.9334
|
||||
longitude: 32.8597
|
||||
- id: "new_york"
|
||||
city: "New York"
|
||||
country: "USA"
|
||||
latitude: 40.7128
|
||||
longitude: -74.0060
|
||||
- id: "chicago"
|
||||
city: "Chicago"
|
||||
country: "USA"
|
||||
latitude: 41.8781
|
||||
longitude: -87.6298
|
||||
- id: "lucknow"
|
||||
city: "Lucknow"
|
||||
country: "India"
|
||||
latitude: 26.7606
|
||||
longitude: 80.8893
|
||||
- id: "sao paulo"
|
||||
city: "São Paulo"
|
||||
country: "Brazil"
|
||||
latitude: -23.4356
|
||||
longitude: -46.4731
|
||||
- id: "munich"
|
||||
city: "Munich"
|
||||
country: "Germany"
|
||||
latitude: 48.3538
|
||||
longitude: 11.7861
|
||||
- id: "hong_kong"
|
||||
city: "Hong Kong"
|
||||
country: "China"
|
||||
latitude: 22.3080
|
||||
longitude: 113.9185
|
||||
- id: "shanghai"
|
||||
city: "Shanghai"
|
||||
country: "China"
|
||||
latitude: 31.1434
|
||||
longitude: 121.8052
|
||||
- id: "singapore"
|
||||
city: "Singapore"
|
||||
country: "Singapore"
|
||||
latitude: 1.3644
|
||||
longitude: 103.9915
|
||||
- id: "tokyo"
|
||||
city: "Tokyo"
|
||||
country: "Japan"
|
||||
latitude: 35.5523
|
||||
longitude: 139.7798
|
||||
# Logging
|
||||
- id: london
|
||||
city: London
|
||||
country: UK
|
||||
latitude: 51.5074
|
||||
longitude: -0.1278
|
||||
- id: paris
|
||||
city: Paris
|
||||
country: France
|
||||
latitude: 48.8566
|
||||
longitude: 2.3522
|
||||
- id: ankara
|
||||
city: Ankara
|
||||
country: Turkey
|
||||
latitude: 39.9334
|
||||
longitude: 32.8597
|
||||
- id: new_york
|
||||
city: New York
|
||||
country: USA
|
||||
latitude: 40.7128
|
||||
longitude: -74.006
|
||||
- id: chicago
|
||||
city: Chicago
|
||||
country: USA
|
||||
latitude: 41.8781
|
||||
longitude: -87.6298
|
||||
- id: lucknow
|
||||
city: Lucknow
|
||||
country: India
|
||||
latitude: 26.7606
|
||||
longitude: 80.8893
|
||||
- id: sao paulo
|
||||
city: São Paulo
|
||||
country: Brazil
|
||||
latitude: -23.4356
|
||||
longitude: -46.4731
|
||||
- id: munich
|
||||
city: Munich
|
||||
country: Germany
|
||||
latitude: 48.3538
|
||||
longitude: 11.7861
|
||||
- id: hong_kong
|
||||
city: Hong Kong
|
||||
country: China
|
||||
latitude: 22.3019
|
||||
longitude: 114.1742
|
||||
- id: shanghai
|
||||
city: Shanghai
|
||||
country: China
|
||||
latitude: 31.1434
|
||||
longitude: 121.8052
|
||||
- id: singapore
|
||||
city: Singapore
|
||||
country: Singapore
|
||||
latitude: 1.3644
|
||||
longitude: 103.9915
|
||||
- id: tokyo
|
||||
city: Tokyo
|
||||
country: Japan
|
||||
latitude: 35.5523
|
||||
longitude: 139.7798
|
||||
- id: tel_aviv
|
||||
city: Tel Aviv
|
||||
country: Israel
|
||||
latitude: 32.0114
|
||||
longitude: 34.8867
|
||||
- id: chengdu
|
||||
city: Chengdu
|
||||
country: China
|
||||
latitude: 30.5785
|
||||
longitude: 103.9471
|
||||
- id: chongqing
|
||||
city: Chongqing
|
||||
country: China
|
||||
latitude: 29.7196
|
||||
longitude: 106.6416
|
||||
- id: shenzhen
|
||||
city: Shenzhen
|
||||
country: China
|
||||
latitude: 22.6393
|
||||
longitude: 113.8107
|
||||
- id: beijing
|
||||
city: Beijing
|
||||
country: China
|
||||
latitude: 40.0801
|
||||
longitude: 116.5846
|
||||
- id: wuhan
|
||||
city: Wuhan
|
||||
country: China
|
||||
latitude: 30.7838
|
||||
longitude: 114.2081
|
||||
logging:
|
||||
level: "INFO"
|
||||
rotation: "10 MB"
|
||||
retention: "10 days"
|
||||
level: INFO
|
||||
rotation: 10 MB
|
||||
retention: 10 days
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.24;
|
||||
|
||||
interface IERC20 {
|
||||
function transferFrom(address from, address to, uint256 value) external returns (bool);
|
||||
}
|
||||
|
||||
contract PolyWeatherCheckout {
|
||||
address public owner;
|
||||
address public treasury;
|
||||
mapping(address => bool) public allowedToken;
|
||||
mapping(bytes32 => bool) public paidOrder;
|
||||
|
||||
event OrderPaid(
|
||||
bytes32 indexed orderId,
|
||||
address indexed payer,
|
||||
uint256 indexed planId,
|
||||
address token,
|
||||
uint256 amount
|
||||
);
|
||||
|
||||
modifier onlyOwner() {
|
||||
require(msg.sender == owner, "ONLY_OWNER");
|
||||
_;
|
||||
}
|
||||
|
||||
constructor(address _token, address _treasury) {
|
||||
require(_token != address(0) && _treasury != address(0), "ZERO_ADDR");
|
||||
owner = msg.sender;
|
||||
treasury = _treasury;
|
||||
allowedToken[_token] = true;
|
||||
}
|
||||
|
||||
function setTreasury(address _treasury) external onlyOwner {
|
||||
require(_treasury != address(0), "ZERO_ADDR");
|
||||
treasury = _treasury;
|
||||
}
|
||||
|
||||
function setTokenAllowed(address token, bool allowed) external onlyOwner {
|
||||
require(token != address(0), "ZERO_ADDR");
|
||||
allowedToken[token] = allowed;
|
||||
}
|
||||
|
||||
function pay(bytes32 orderId, uint256 planId, uint256 amount, address token) external {
|
||||
require(allowedToken[token], "TOKEN_NOT_ALLOWED");
|
||||
require(amount > 0, "AMOUNT_ZERO");
|
||||
require(!paidOrder[orderId], "ORDER_PAID");
|
||||
|
||||
paidOrder[orderId] = true;
|
||||
require(IERC20(token).transferFrom(msg.sender, treasury, amount), "TRANSFER_FAILED");
|
||||
|
||||
emit OrderPaid(orderId, msg.sender, planId, token, amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.24;
|
||||
|
||||
interface IERC20 {
|
||||
function transferFrom(address from, address to, uint256 value) external returns (bool);
|
||||
function transfer(address to, uint256 value) external returns (bool);
|
||||
}
|
||||
|
||||
library Address {
|
||||
function functionCall(address target, bytes memory data, string memory errorMessage) internal returns (bytes memory) {
|
||||
(bool success, bytes memory returndata) = target.call(data);
|
||||
require(success, errorMessage);
|
||||
return returndata;
|
||||
}
|
||||
}
|
||||
|
||||
library SafeERC20 {
|
||||
using Address for address;
|
||||
|
||||
function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal {
|
||||
bytes memory returndata = address(token).functionCall(
|
||||
abi.encodeWithSelector(token.transferFrom.selector, from, to, value),
|
||||
"SAFE_TRANSFER_FROM_FAILED"
|
||||
);
|
||||
if (returndata.length > 0) {
|
||||
require(abi.decode(returndata, (bool)), "SAFE_TRANSFER_FROM_FALSE");
|
||||
}
|
||||
}
|
||||
|
||||
function safeTransfer(IERC20 token, address to, uint256 value) internal {
|
||||
bytes memory returndata = address(token).functionCall(
|
||||
abi.encodeWithSelector(token.transfer.selector, to, value),
|
||||
"SAFE_TRANSFER_FAILED"
|
||||
);
|
||||
if (returndata.length > 0) {
|
||||
require(abi.decode(returndata, (bool)), "SAFE_TRANSFER_FALSE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract contract Ownable {
|
||||
address public owner;
|
||||
|
||||
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
|
||||
|
||||
modifier onlyOwner() {
|
||||
require(msg.sender == owner, "ONLY_OWNER");
|
||||
_;
|
||||
}
|
||||
|
||||
constructor(address initialOwner) {
|
||||
require(initialOwner != address(0), "ZERO_OWNER");
|
||||
owner = initialOwner;
|
||||
emit OwnershipTransferred(address(0), initialOwner);
|
||||
}
|
||||
|
||||
function transferOwnership(address newOwner) external onlyOwner {
|
||||
require(newOwner != address(0), "ZERO_OWNER");
|
||||
emit OwnershipTransferred(owner, newOwner);
|
||||
owner = newOwner;
|
||||
}
|
||||
}
|
||||
|
||||
abstract contract Pausable {
|
||||
bool public paused;
|
||||
|
||||
event Paused(address indexed account);
|
||||
event Unpaused(address indexed account);
|
||||
|
||||
modifier whenNotPaused() {
|
||||
require(!paused, "PAUSED");
|
||||
_;
|
||||
}
|
||||
|
||||
function _pause() internal {
|
||||
require(!paused, "PAUSED");
|
||||
paused = true;
|
||||
emit Paused(msg.sender);
|
||||
}
|
||||
|
||||
function _unpause() internal {
|
||||
require(paused, "NOT_PAUSED");
|
||||
paused = false;
|
||||
emit Unpaused(msg.sender);
|
||||
}
|
||||
}
|
||||
|
||||
abstract contract ReentrancyGuard {
|
||||
uint256 private _status = 1;
|
||||
|
||||
modifier nonReentrant() {
|
||||
require(_status == 1, "REENTRANT");
|
||||
_status = 2;
|
||||
_;
|
||||
_status = 1;
|
||||
}
|
||||
}
|
||||
|
||||
contract PolyWeatherCheckoutV2 is Ownable, Pausable, ReentrancyGuard {
|
||||
using SafeERC20 for IERC20;
|
||||
|
||||
struct PlanConfig {
|
||||
uint256 amount;
|
||||
bool active;
|
||||
}
|
||||
|
||||
bytes32 public constant AUTHORIZED_PAYMENT_TYPEHASH =
|
||||
keccak256(
|
||||
"AuthorizedPayment(bytes32 orderId,address payer,uint256 planId,address token,uint256 amount,uint256 nonce,uint256 deadline)"
|
||||
);
|
||||
|
||||
bytes32 public immutable DOMAIN_SEPARATOR;
|
||||
|
||||
address public treasury;
|
||||
address public signer;
|
||||
mapping(address => bool) public allowedToken;
|
||||
mapping(bytes32 => bool) public paidOrder;
|
||||
mapping(uint256 => mapping(address => PlanConfig)) public planConfig;
|
||||
mapping(address => uint256) public payerNonce;
|
||||
|
||||
event OrderPaid(
|
||||
bytes32 indexed orderId,
|
||||
address indexed payer,
|
||||
uint256 indexed planId,
|
||||
address token,
|
||||
uint256 amount
|
||||
);
|
||||
event TreasuryUpdated(address indexed treasury);
|
||||
event SignerUpdated(address indexed signer);
|
||||
event TokenAllowedUpdated(address indexed token, bool allowed);
|
||||
event PlanConfigured(uint256 indexed planId, address indexed token, uint256 amount, bool active);
|
||||
|
||||
constructor(address initialOwner, address initialTreasury, address initialSigner)
|
||||
Ownable(initialOwner)
|
||||
{
|
||||
require(initialTreasury != address(0), "ZERO_TREASURY");
|
||||
treasury = initialTreasury;
|
||||
signer = initialSigner;
|
||||
|
||||
uint256 chainId;
|
||||
assembly {
|
||||
chainId := chainid()
|
||||
}
|
||||
DOMAIN_SEPARATOR = keccak256(
|
||||
abi.encode(
|
||||
keccak256(
|
||||
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
|
||||
),
|
||||
keccak256(bytes("PolyWeatherCheckoutV2")),
|
||||
keccak256(bytes("1")),
|
||||
chainId,
|
||||
address(this)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function setTreasury(address newTreasury) external onlyOwner {
|
||||
require(newTreasury != address(0), "ZERO_ADDR");
|
||||
treasury = newTreasury;
|
||||
emit TreasuryUpdated(newTreasury);
|
||||
}
|
||||
|
||||
function setSigner(address newSigner) external onlyOwner {
|
||||
signer = newSigner;
|
||||
emit SignerUpdated(newSigner);
|
||||
}
|
||||
|
||||
function setTokenAllowed(address token, bool allowed) external onlyOwner {
|
||||
require(token != address(0), "ZERO_ADDR");
|
||||
allowedToken[token] = allowed;
|
||||
emit TokenAllowedUpdated(token, allowed);
|
||||
}
|
||||
|
||||
function setPlan(uint256 planId, address token, uint256 amount, bool active) external onlyOwner {
|
||||
require(planId > 0, "PLAN_ZERO");
|
||||
require(token != address(0), "ZERO_ADDR");
|
||||
require(amount > 0 || !active, "AMOUNT_ZERO");
|
||||
planConfig[planId][token] = PlanConfig({amount: amount, active: active});
|
||||
emit PlanConfigured(planId, token, amount, active);
|
||||
}
|
||||
|
||||
function pause() external onlyOwner {
|
||||
_pause();
|
||||
}
|
||||
|
||||
function unpause() external onlyOwner {
|
||||
_unpause();
|
||||
}
|
||||
|
||||
function payPlan(bytes32 orderId, uint256 planId, address token)
|
||||
external
|
||||
whenNotPaused
|
||||
nonReentrant
|
||||
{
|
||||
require(allowedToken[token], "TOKEN_NOT_ALLOWED");
|
||||
PlanConfig memory config = planConfig[planId][token];
|
||||
require(config.active, "PLAN_NOT_ACTIVE");
|
||||
require(config.amount > 0, "PLAN_AMOUNT_ZERO");
|
||||
_collect(orderId, msg.sender, planId, token, config.amount);
|
||||
}
|
||||
|
||||
function payAuthorized(
|
||||
bytes32 orderId,
|
||||
uint256 planId,
|
||||
address token,
|
||||
uint256 amount,
|
||||
uint256 deadline,
|
||||
bytes calldata signature
|
||||
) external whenNotPaused nonReentrant {
|
||||
require(allowedToken[token], "TOKEN_NOT_ALLOWED");
|
||||
require(amount > 0, "AMOUNT_ZERO");
|
||||
require(deadline >= block.timestamp, "AUTH_EXPIRED");
|
||||
require(signer != address(0), "SIGNER_NOT_SET");
|
||||
|
||||
uint256 nonce = payerNonce[msg.sender];
|
||||
bytes32 structHash = keccak256(
|
||||
abi.encode(
|
||||
AUTHORIZED_PAYMENT_TYPEHASH,
|
||||
orderId,
|
||||
msg.sender,
|
||||
planId,
|
||||
token,
|
||||
amount,
|
||||
nonce,
|
||||
deadline
|
||||
)
|
||||
);
|
||||
bytes32 digest = keccak256(
|
||||
abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)
|
||||
);
|
||||
require(_recover(digest, signature) == signer, "BAD_SIGNATURE");
|
||||
payerNonce[msg.sender] = nonce + 1;
|
||||
|
||||
_collect(orderId, msg.sender, planId, token, amount);
|
||||
}
|
||||
|
||||
function rescueToken(address token, address to, uint256 amount) external onlyOwner nonReentrant {
|
||||
require(token != address(0) && to != address(0), "ZERO_ADDR");
|
||||
IERC20(token).safeTransfer(to, amount);
|
||||
}
|
||||
|
||||
function _collect(bytes32 orderId, address payer, uint256 planId, address token, uint256 amount) internal {
|
||||
require(!paidOrder[orderId], "ORDER_PAID");
|
||||
paidOrder[orderId] = true;
|
||||
IERC20(token).safeTransferFrom(payer, treasury, amount);
|
||||
emit OrderPaid(orderId, payer, planId, token, amount);
|
||||
}
|
||||
|
||||
function _recover(bytes32 digest, bytes calldata signature) internal pure returns (address) {
|
||||
require(signature.length == 65, "BAD_SIG_LEN");
|
||||
bytes32 r;
|
||||
bytes32 s;
|
||||
uint8 v;
|
||||
assembly {
|
||||
r := calldataload(signature.offset)
|
||||
s := calldataload(add(signature.offset, 32))
|
||||
v := byte(0, calldataload(add(signature.offset, 64)))
|
||||
}
|
||||
if (v < 27) {
|
||||
v += 27;
|
||||
}
|
||||
require(v == 27 || v == 28, "BAD_SIG_V");
|
||||
address recovered = ecrecover(digest, v, r, s);
|
||||
require(recovered != address(0), "BAD_SIG");
|
||||
return recovered;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,123 @@
|
||||
{"city": "ankara", "timestamp": "2026-03-20T12:00:00+03:00", "date": "2026-03-20", "temp_symbol": "°C", "raw_mu": 15.2, "raw_sigma": 1.2, "deb_prediction": 15.4, "ensemble": {"p10": 14.8, "median": 15.8, "p90": 17.9}, "multi_model": {"ECMWF": 15.8, "GFS": 14.1, "ICON": 15.9}, "max_so_far": 15.0, "peak_status": "before", "prob_snapshot": [{"v": 15, "p": 0.552}, {"v": 16, "p": 0.377}], "shadow_prob_snapshot": [{"v": 15, "p": 0.324}, {"v": 16, "p": 0.238}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320130245", "calibration_source": "artifacts/probability_calibration/default.json", "calibrated_mu": 15.1, "calibrated_sigma": 1.25}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "hong kong", "timestamp": "2026-03-23T21:10:00+08:00", "date": "2026-03-23", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.6806250000000001, "deb_prediction": 25.2, "ensemble": {"p10": 26.3, "median": 26.4, "p90": 26.6}, "multi_model": {"Open-Meteo": 24.8, "HKO(港天文)": 27.0, "ECMWF": 25.4, "GFS": 25.1, "ICON": 24.8, "GEM": 25.3, "JMA": 23.6}, "max_so_far": 27.4, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "shenzhen", "timestamp": "2026-03-25T08:43:15.528748+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 29.0, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "shenzhen", "timestamp": "2026-03-25T08:57:11.783182+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": 26.7, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 26.7, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [{"v": 27, "p": 1.0}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 26.7, "calibrated_sigma": 0.24322631835937514}
|
||||
{"city": "shenzhen", "timestamp": "2026-03-25T09:32:32+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.16637912326388898, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 28.9, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "shenzhen", "timestamp": "2026-03-25T10:02:35+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.15911458333333342, "deb_prediction": 28.2, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.5, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.5, "GEM": 30.7, "JMA": 26.2}, "max_so_far": 28.9, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
+11
-2
@@ -6,8 +6,14 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data # 挂载数据目录,确保历史数据持久化
|
||||
# Persist runtime data outside git workspace.
|
||||
# Host path defaults to /var/lib/polyweather and can be overridden in .env.
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
# Keep /app/data compatibility for existing cache/state defaults.
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
- ./bot.log:/app/bot.log # 挂载日志文件
|
||||
# UID/GID are mainly useful on Linux hosts to avoid root-owned output files.
|
||||
# Windows / macOS can usually keep the fallback values.
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
|
||||
polyweather_web:
|
||||
@@ -18,7 +24,10 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
# Web service shares the same runtime data directory as bot/state tasks.
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# UID/GID are mainly useful on Linux hosts to avoid root-owned output files.
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
|
||||
+177
-226
@@ -1,291 +1,242 @@
|
||||
# PolyWeather API 文档(v1.3)
|
||||
# PolyWeather API 文档(v1.5.1)
|
||||
|
||||
本文档描述当前后端真实可用接口(`web/app.py`)。
|
||||
前端一般通过 Next.js BFF 路由代理访问这些接口。
|
||||
最后更新:`2026-03-24`
|
||||
|
||||
---
|
||||
本文档描述当前对外可用 API 口径(`web/app.py` + `web/routes.py` + `frontend/app/api/*`)。
|
||||
|
||||
## 1. 基础信息
|
||||
|
||||
- 本地地址:`http://127.0.0.1:8000`
|
||||
- 生产地址:`http://<vps-ip>:8000` 或你绑定的 HTTPS 域名
|
||||
- 后端直连:`http://127.0.0.1:8000`
|
||||
- 前端 BFF:`https://polyweather-pro.vercel.app/api/*`
|
||||
- 返回格式:`application/json`
|
||||
- 缓存策略:
|
||||
- 后端分析缓存:默认 5 分钟(Ankara 特殊口径 60 秒)
|
||||
- 前端详情缓存:5 分钟 + revision 检查 + 后台静默刷新
|
||||
- 前端 BFF HTTP 缓存(Vercel 层):
|
||||
- `/api/cities`:`ETag` + `Cache-Control`(`s-maxage=300`)
|
||||
- `/api/city/{name}/summary`:`ETag` + `Cache-Control`(`s-maxage=20`)
|
||||
- `/api/history/{name}`:`ETag` + `Cache-Control`(`s-maxage=60`)
|
||||
- `summary?force_refresh=true`:`Cache-Control: no-store`
|
||||
- 手动刷新:`force_refresh=true` 强制绕过缓存
|
||||
|
||||
---
|
||||
|
||||
## 2. API 思维导图
|
||||
## 2. 请求链路
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["PolyWeather API"]
|
||||
|
||||
subgraph E["接口分组"]
|
||||
E1["GET /api/cities"]
|
||||
E2["GET /api/city/{name}"]
|
||||
E3["GET /api/city/{name}/summary"]
|
||||
E4["GET /api/city/{name}/detail"]
|
||||
E5["GET /api/history/{name}"]
|
||||
end
|
||||
|
||||
subgraph O["关键对象"]
|
||||
O1["current"]
|
||||
O2["forecast"]
|
||||
O3["probabilities (mu + distribution)"]
|
||||
O4["multi_model / multi_model_daily"]
|
||||
O5["market_scan (P0 只读)"]
|
||||
end
|
||||
|
||||
A --> E
|
||||
A --> O
|
||||
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 --> PAY["Payment Intent + Event + Confirm Loops"]
|
||||
API --> OBS["healthz / system status / metrics"]
|
||||
```
|
||||
|
||||
---
|
||||
## 3. 天气分析接口
|
||||
|
||||
## 3. 接口总览
|
||||
| 接口 | 方法 | 用途 |
|
||||
| :-- | :-- | :-- |
|
||||
| `/api/cities` | GET | 监控城市列表 |
|
||||
| `/api/city/{name}` | GET | 城市主分析 |
|
||||
| `/api/city/{name}/summary` | GET | 轻量摘要 |
|
||||
| `/api/city/{name}/detail` | GET | 聚合详情(含 market_scan) |
|
||||
| `/api/history/{name}` | GET | 历史对账 |
|
||||
|
||||
| 接口 | 方法 | 用途 |
|
||||
| :------------------------- | :--- | :------------------------------------ |
|
||||
| `/api/cities` | GET | 城市清单与地图基础信息 |
|
||||
| `/api/city/{name}` | GET | 城市主分析数据(侧栏/今日分析主来源) |
|
||||
| `/api/city/{name}/summary` | GET | 轻量摘要(首屏预热/低开销更新) |
|
||||
| `/api/city/{name}/detail` | GET | 聚合详情 + Polymarket P0 只读市场层 |
|
||||
| `/api/history/{name}` | GET | 历史对账数据 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键接口详解
|
||||
|
||||
### 4.1 `GET /api/cities`
|
||||
|
||||
返回监控城市列表(地图 Marker 与侧边栏基础数据)。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"cities": [
|
||||
{
|
||||
"name": "ankara",
|
||||
"display_name": "Ankara",
|
||||
"lat": 39.9334,
|
||||
"lon": 32.8597,
|
||||
"risk_level": "medium",
|
||||
"risk_emoji": "🟠",
|
||||
"airport": "Esenboğa",
|
||||
"icao": "LTAC",
|
||||
"temp_unit": "celsius",
|
||||
"is_major": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `GET /api/city/{name}`
|
||||
|
||||
主数据接口,前端详情面板和今日分析最常用。
|
||||
### `GET /api/city/{name}/detail`
|
||||
|
||||
可选参数:
|
||||
|
||||
- `force_refresh=true|false`
|
||||
- `market_slug=<slug>`
|
||||
- `target_date=YYYY-MM-DD`
|
||||
|
||||
核心字段:
|
||||
重点字段:
|
||||
|
||||
- `name`, `display_name`, `local_date`, `local_time`, `temp_symbol`
|
||||
- `risk`
|
||||
- `current`
|
||||
- `forecast`
|
||||
- `mgm`, `mgm_nearby`
|
||||
- `multi_model`, `multi_model_daily`
|
||||
- `deb`
|
||||
- `ensemble`
|
||||
- `probabilities`(`mu` + `distribution`)
|
||||
- `trend`, `peak`
|
||||
- `hourly`, `hourly_next_48h`
|
||||
- `source_forecasts`(当前只保留 `weather_gov`)
|
||||
- `market_scan`
|
||||
- `updated_at`
|
||||
- `market_scan.available`
|
||||
- `market_scan.signal_label`
|
||||
- `market_scan.edge_percent`
|
||||
- `market_scan.anchor_model / anchor_high / anchor_settlement`
|
||||
- `market_scan.yes_buy / no_buy`
|
||||
- `market_scan.primary_market.tradable`
|
||||
- `peak.first_h / peak.last_h / peak.status`
|
||||
- `vertical_profile_signal.heating_setup / suppression_risk / trigger_risk / mixing_strength`
|
||||
- `taf.signal.peak_window / suppression_level / disruption_level / markers`
|
||||
|
||||
说明:
|
||||
### `detail` 新增结构信号说明
|
||||
|
||||
- `current.raw_metar` 是原始 METAR 报文。
|
||||
- Ankara 专项增强使用 MGM 站网,领先站固定 `17130`。
|
||||
- Meteoblue 已彻底移除,不再出现在接口字段中。
|
||||
`/api/city/{name}/detail` 现在会返回一组更偏交易场景的结构字段:
|
||||
|
||||
### 4.3 `GET /api/city/{name}/summary`
|
||||
#### 1. `peak`
|
||||
|
||||
轻量温度摘要,用于地图首屏预热和低成本刷新。
|
||||
- `first_h`:预计峰值窗口起始小时
|
||||
- `last_h`:预计峰值窗口结束小时
|
||||
- `status`:`before_peak | near_peak | after_peak`
|
||||
|
||||
典型字段:
|
||||
这组字段用于让日内结构信号围绕真实峰值窗口分析,而不是固定只看下午。
|
||||
|
||||
- `name`, `display_name`, `icao`
|
||||
- `local_time`, `temp_symbol`
|
||||
- `current.temp`, `current.obs_time`
|
||||
- `deb.prediction`
|
||||
- `risk.level`, `risk.warning`
|
||||
- `updated_at`
|
||||
#### 2. `vertical_profile_signal`
|
||||
|
||||
缓存说明:
|
||||
重点字段:
|
||||
|
||||
- 通过前端 BFF 访问时,默认返回 `ETag` 与可缓存 `Cache-Control`。
|
||||
- 当 `force_refresh=true` 时,BFF 强制 `no-store`,用于人工排障与即时刷新。
|
||||
- `source`
|
||||
- `window`
|
||||
- `cape_max`
|
||||
- `cin_min`
|
||||
- `lifted_index_min`
|
||||
- `boundary_layer_height_max`
|
||||
- `shear_10m_180m_max`
|
||||
- `suppression_risk`
|
||||
- `trigger_risk`
|
||||
- `mixing_strength`
|
||||
- `shear_risk`
|
||||
- `heating_setup`
|
||||
- `heating_score`
|
||||
- `summary_zh`
|
||||
- `summary_en`
|
||||
|
||||
### 4.4 `GET /api/city/{name}/detail`
|
||||
这组字段对应前端“高空结构信号 / Upper-Air Structure”卡片。
|
||||
|
||||
聚合视图接口,包含天气分析和市场只读层。
|
||||
#### 3. `taf.signal`
|
||||
|
||||
可选参数:
|
||||
仅对**非香港机场城市**启用。当前已支持解析:
|
||||
|
||||
- `force_refresh=true|false`
|
||||
- `market_slug=<slug>`(调试/定向市场匹配)
|
||||
- `FM`
|
||||
- `TEMPO`
|
||||
- `BECMG`
|
||||
- `PROB30`
|
||||
- `PROB40`
|
||||
|
||||
关键结构:
|
||||
重点字段:
|
||||
|
||||
- `overview`
|
||||
- `official`
|
||||
- `timeseries`
|
||||
- `models`
|
||||
- `probabilities`
|
||||
- `market_scan`
|
||||
- `risk`
|
||||
- `ai_analysis`
|
||||
- `peak_window`
|
||||
- `segments`
|
||||
- `markers`
|
||||
- `suppression_level`
|
||||
- `disruption_level`
|
||||
- `wind_shift`
|
||||
- `summary_zh`
|
||||
- `summary_en`
|
||||
|
||||
`market_scan`(P0 只读)重点字段:
|
||||
`markers` 会被前端温度走势图拿来做 `TAF 时段 / TAF Timing` 标记。
|
||||
|
||||
- `primary_market`, `selected_condition_id`, `selected_slug`
|
||||
- `yes_token`, `no_token`
|
||||
- `yes_buy`, `yes_sell`, `no_buy`, `no_sell`
|
||||
- `market_price`, `model_probability`, `edge_percent`
|
||||
- `temperature_bucket`
|
||||
- `top_buckets`(前端展示前会再去重)
|
||||
- `signal_label`(`BUY YES` / `BUY NO` / `MONITOR`)
|
||||
- `websocket.asset_ids`, `websocket.condition_ids`(订阅标识,不涉及下单)
|
||||
- `primary_market.tradable`(是否可交易)
|
||||
- `primary_market.tradable_reason`(不可交易原因)
|
||||
- `primary_market.ended_at_utc`(UTC 结束时刻)
|
||||
- `primary_market.accepting_orders`(是否仍接收订单)
|
||||
## 4. 鉴权与账户接口
|
||||
|
||||
注意:
|
||||
| 接口 | 方法 | 用途 |
|
||||
| :-- | :-- | :-- |
|
||||
| `/api/auth/me` | GET | 当前登录态、积分、订阅状态 |
|
||||
|
||||
- 后端已做温度桶去重与方向优先(优先与主市场同方向的 `or higher`/`or lower` 桶)。
|
||||
- 前端还有二次去重兜底,避免重复温度桶刷屏。
|
||||
- 错价雷达推送前会二次校验交易状态,若市场已不可交易(`closed` / inactive / 不接单 / 过 `endDate`)会跳过。
|
||||
`/api/auth/me` 关键字段:
|
||||
|
||||
### 4.5 `GET /api/history/{name}`
|
||||
- `authenticated`
|
||||
- `user_id`, `email`
|
||||
- `points`, `weekly_points`, `weekly_rank`
|
||||
- `subscription_active`, `subscription_plan_code`, `subscription_expires_at`
|
||||
|
||||
历史对账数据来源。
|
||||
## 5. 支付接口
|
||||
|
||||
示例:
|
||||
| 接口 | 方法 | 用途 |
|
||||
| :-- | :-- | :-- |
|
||||
| `/api/payments/config` | GET | 支付配置、代币列表、套餐、积分抵扣规则 |
|
||||
| `/api/payments/runtime` | GET | 支付运行态、RPC 状态、event loop 状态、最近审计事件 |
|
||||
| `/api/payments/wallets` | GET | 当前用户已绑定钱包 |
|
||||
| `/api/payments/wallets/challenge` | POST | 获取绑定签名 challenge |
|
||||
| `/api/payments/wallets/verify` | POST | 提交签名并绑定钱包 |
|
||||
| `/api/payments/intents` | POST | 创建支付意图(intent) |
|
||||
| `/api/payments/intents/{intent_id}` | GET | 查询 intent 最新状态 |
|
||||
| `/api/payments/intents/{intent_id}/submit` | POST | 提交交易哈希 |
|
||||
| `/api/payments/intents/{intent_id}/confirm` | POST | 手动触发确认 |
|
||||
| `/api/payments/reconcile-latest` | POST | 对当前登录用户最近一笔 intent 做恢复性确认 |
|
||||
|
||||
```json
|
||||
{
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-03-07",
|
||||
"actual": 7.0,
|
||||
"deb": 6.5,
|
||||
"mu": 7.2,
|
||||
"mgm": 8.0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
### 支付状态建议
|
||||
|
||||
---
|
||||
前端流程建议:
|
||||
|
||||
## 5. 请求链路(以 `/api/city/{name}` 为例)
|
||||
1. `POST /intents`
|
||||
2. 钱包发链上交易
|
||||
3. `POST /submit`
|
||||
4. `POST /confirm`
|
||||
5. 若 pending,轮询 `GET /intents/{id}` 直到 `confirmed`
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as Frontend
|
||||
participant API as FastAPI
|
||||
participant WX as Weather Collector
|
||||
participant PM as Polymarket RO Layer
|
||||
## 6. 运维与观测接口
|
||||
|
||||
FE->>API: GET /api/city/{name}?force_refresh=...
|
||||
API->>WX: fetch_all_sources(city)
|
||||
WX-->>API: METAR / MGM / Open-Meteo / weather.gov / Multi-model
|
||||
API->>API: DEB + trend + probability
|
||||
API->>PM: build_market_scan(...)
|
||||
PM-->>API: market_scan (read-only)
|
||||
API-->>FE: merged city payload
|
||||
```
|
||||
| 接口 | 方法 | 用途 |
|
||||
| :-- | :-- | :-- |
|
||||
| `/healthz` | GET | 基础健康检查 |
|
||||
| `/api/system/status` | GET | 系统状态、功能开关、rollout 状态、轻量指标摘要 |
|
||||
| `/metrics` | GET | Prometheus 风格指标导出 |
|
||||
|
||||
---
|
||||
`/api/system/status` 当前会包含:
|
||||
|
||||
## 6. 数据口径
|
||||
- `features.state_storage_mode`
|
||||
- `probability.decision`
|
||||
- `probability.ready_for_primary`
|
||||
- `metrics`
|
||||
|
||||
### 6.1 主观测
|
||||
`/metrics` 当前会导出:
|
||||
|
||||
- Aviation Weather / METAR 是全局主观测源。
|
||||
- Ankara:结算主站仍是 `LTAC`,领先信号强化使用 MGM(`17130`)。
|
||||
- `polyweather_http_requests_total`
|
||||
- `polyweather_http_request_duration_ms_*`
|
||||
- `polyweather_source_requests_total`
|
||||
- `polyweather_source_request_duration_ms_*`
|
||||
|
||||
### 6.2 预测源
|
||||
## 7. Ops 管理接口
|
||||
|
||||
- Open-Meteo
|
||||
- weather.gov(美国城市)
|
||||
- 多模型:ECMWF / GFS / ICON / GEM / JMA
|
||||
这些接口主要给 `/ops` 管理后台使用,默认要求:
|
||||
|
||||
### 6.3 概率口径
|
||||
- 已登录
|
||||
- 当前邮箱位于 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
|
||||
- `mu`:动态分布中心,不是固定结算值。
|
||||
- `distribution`:按温度桶输出概率分布,面向结算决策而非通用天气展示。
|
||||
| 接口 | 方法 | 用途 |
|
||||
| :-- | :-- | :-- |
|
||||
| `/api/ops/users` | GET | 按 Telegram ID / 用户名 / 邮箱查询用户 |
|
||||
| `/api/ops/leaderboard/weekly` | GET | 本周积分榜 |
|
||||
| `/api/ops/memberships` | GET | 当前有效会员(已按用户去重,保留最晚到期) |
|
||||
| `/api/ops/users/grant-points` | POST | 手动补分 |
|
||||
| `/api/ops/payments/incidents` | GET | 支付异常单(仅 `payment_intent_failed`) |
|
||||
| `/api/ops/payments/incidents/{event_id}/resolve` | POST | 标记支付异常单已处理 |
|
||||
|
||||
---
|
||||
`/api/ops/payments/incidents` 当前支持:
|
||||
|
||||
## 7. 常见问题
|
||||
- `reason=<receiver_mismatch|sender_mismatch|event_mismatch|tx_reverted>`
|
||||
- 默认不返回已标记处理的记录
|
||||
- 重点用于排查“已付款未开通”“打到旧收款地址”等事故
|
||||
## 8. 缓存策略(当前)
|
||||
|
||||
### 7.1 接口 500
|
||||
- `cities` / `summary` / `history`:BFF 支持 `ETag + 304`
|
||||
- `summary?force_refresh=true`:`Cache-Control: no-store`
|
||||
- 详情接口与支付接口:`no-store`
|
||||
- `METAR` / `TAF` / settlement current 由后端各自维护短 TTL 缓存
|
||||
|
||||
- 先检查容器是否启动:`docker compose ps`
|
||||
- 查看日志:`docker compose logs -f polyweather_web`
|
||||
## 9. 调试示例
|
||||
|
||||
### 7.2 METAR 看起来“延迟”
|
||||
|
||||
优先核对:
|
||||
|
||||
- `current.obs_time`
|
||||
- `current.report_time`
|
||||
- `current.receipt_time`
|
||||
|
||||
通常是上游发布节奏,不一定是本地轮询问题。
|
||||
|
||||
### 7.3 前端仍显示旧内容
|
||||
|
||||
- 确认 Vercel 已部署最新构建
|
||||
- 浏览器强刷(`Ctrl+F5`)
|
||||
- 检查是否命中前端 5 分钟 TTL
|
||||
|
||||
### 7.4 为什么 VPS `:8000` 看不到 `ETag`?
|
||||
|
||||
- `:8000` 是 FastAPI 后端直连口径,主要负责分析与数据聚合。
|
||||
- `ETag/304` 主要由前端 BFF 路由返回(Vercel 域名下的 `/api/*`)。
|
||||
- 验证缓存头请用前端域名,而不是后端直连 IP。
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收脚本
|
||||
|
||||
项目内置缓存验收脚本:
|
||||
### 查询未来日期 market_scan
|
||||
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
curl -s "http://127.0.0.1:8000/api/city/ankara/detail?force_refresh=true&target_date=2026-03-12"
|
||||
```
|
||||
|
||||
输出 `Result: PASS` 代表以下链路均正常:
|
||||
### 校验支付配置
|
||||
|
||||
- `ETag` 返回
|
||||
- `If-None-Match -> 304`
|
||||
- `force_refresh=true -> no-store`
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8000/api/payments/config | python3 -m json.tool
|
||||
```
|
||||
|
||||
---
|
||||
### 查看支付运行态
|
||||
|
||||
最后更新:`2026-03-12`
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8000/api/payments/runtime | python3 -m json.tool
|
||||
```
|
||||
|
||||
### 查看支付异常单
|
||||
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/api/ops/payments/incidents?reason=receiver_mismatch" | python3 -m json.tool
|
||||
```
|
||||
|
||||
### 查看系统状态
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8000/api/system/status | python3 -m json.tool
|
||||
```
|
||||
|
||||
### 观察支付自动补单
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
|
||||
```
|
||||
|
||||
## 10. 开源口径说明
|
||||
|
||||
对外公开文档仅覆盖通用 API 契约。生产商业策略参数不在公开文档披露。
|
||||
|
||||
详见:[Open-Core 与商用边界](OPEN_CORE_POLICY.md)
|
||||
|
||||
+46
-100
@@ -1,120 +1,66 @@
|
||||
# Commercialization Roadmap
|
||||
# 商业化说明(Production)
|
||||
|
||||
Target: make PolyWeather a sustainable paid weather-intelligence product.
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
---
|
||||
## 1. 定位
|
||||
|
||||
## 1. Product Positioning
|
||||
PolyWeather 是面向温度结算场景的气象决策层,不是通用天气应用。
|
||||
|
||||
PolyWeather is not a generic weather app.
|
||||
It is a decision-support layer for temperature-settlement markets:
|
||||
核心价值:
|
||||
|
||||
- observation-first (METAR/MGM),
|
||||
- settlement-aware probability modeling (DEB + mu/buckets),
|
||||
- market mapping (Polymarket read-only) for actionable edge detection.
|
||||
- 观测优先(METAR/MGM)
|
||||
- 结算导向(DEB + 概率桶)
|
||||
- 市场映射(行情对照 + 错价雷达)
|
||||
|
||||
---
|
||||
## 2. 当前收费能力状态
|
||||
|
||||
## 2. Business Overview Diagram
|
||||
| 能力 | 状态 | 备注 |
|
||||
| :-- | :-- | :-- |
|
||||
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
|
||||
| 订阅套餐(Pro 月付) | 已上线 | `5 USDC / 30天` |
|
||||
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
|
||||
| 合约支付 | 已上线 | Polygon,USDC + USDC.e |
|
||||
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
|
||||
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
|
||||
| 私有频道推送 | 已上线 | 可拆分业务频道 |
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["PolyWeather Monetization"]
|
||||
## 3. 权限模型(当前)
|
||||
|
||||
subgraph P["Product"]
|
||||
P1["Telegram Signal Channel"]
|
||||
P2["Web Dashboard"]
|
||||
P3["VIP Bundle"]
|
||||
end
|
||||
- 游客:可查看基础看板与简版信息。
|
||||
- 登录用户:账户中心、钱包绑定、积分同步。
|
||||
- Pro 用户:
|
||||
- 今日日内深度分析(含高温时段)
|
||||
- 历史对账 + 未来日期分析
|
||||
- 全平台智能气象推送
|
||||
|
||||
subgraph R["Pricing"]
|
||||
R1["Entry 1 USD"]
|
||||
R2["Dashboard 5 USD"]
|
||||
R3["Bundle 5.5 USD"]
|
||||
end
|
||||
## 4. 收费与积分规则(默认)
|
||||
|
||||
subgraph AC["Access Control"]
|
||||
AC1["Manual activation (P1)"]
|
||||
AC2["Wallet/USDC detection (P2)"]
|
||||
AC3["Entitlement middleware"]
|
||||
end
|
||||
- 套餐:`pro_monthly`(5 USDC / 30 天)
|
||||
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC
|
||||
- 实付下限:2 USDC(当积分满额时)
|
||||
|
||||
subgraph G["Growth"]
|
||||
G1["Accuracy reports"]
|
||||
G2["Retention analytics"]
|
||||
G3["User preference center"]
|
||||
end
|
||||
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
|
||||
|
||||
A --> P
|
||||
A --> R
|
||||
A --> AC
|
||||
A --> G
|
||||
```
|
||||
## 5. 建议的开源边界
|
||||
|
||||
---
|
||||
请按 Open-Core 执行:
|
||||
|
||||
## 3. Packaging and Pricing
|
||||
- 开源:基础能力与通用支付流程。
|
||||
- 私有:商业风控、营销策略、关键运营参数、内部审计策略。
|
||||
|
||||
| Tier | Price | Value |
|
||||
| :--------------- | :----------- | :---------------------------------------- |
|
||||
| Telegram Channel | $1 / month | Low-noise proactive signal feed |
|
||||
| Web Dashboard | $5 / month | Full multi-model context + reconciliation |
|
||||
| VIP Bundle | $5.5 / month | Dashboard + signal stream |
|
||||
详见:[Open-Core 与商用边界](OPEN_CORE_POLICY.md)
|
||||
|
||||
Payment direction:
|
||||
## 6. 上线检查清单(收费前)
|
||||
|
||||
- Currency: Polygon USDC
|
||||
- Phasing: manual activation first, then automated entitlement sync
|
||||
1. 支付链路:创建 intent、提交 tx、确认入账、订阅开通全链路可回放。
|
||||
2. 权限链路:前端/后端/Bot 对 Pro 权限判定一致。
|
||||
3. 审计能力:支付日志、订阅变更、异常重试可追溯。
|
||||
4. 通知策略:支付成功私发、群内通知降噪。
|
||||
5. 安全边界:敏感配置不进仓库。
|
||||
|
||||
---
|
||||
## 7. 后续路线
|
||||
|
||||
## 4. Execution Phases
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
P1[Phase 1 Manual Beta] --> P2[Phase 2 Payment Automation]
|
||||
P2 --> P3[Phase 3 Growth and B2B]
|
||||
```
|
||||
|
||||
### Phase 1: Manual Beta
|
||||
|
||||
- Keep paid channel small, optimize signal quality first.
|
||||
- Manual payment confirmation + manual entitlement grant.
|
||||
- Invite-gated dashboard while access control hardens.
|
||||
|
||||
### Phase 2: Payment Automation
|
||||
|
||||
- Detect wallet payment events (USDC).
|
||||
- Auto-issue/refresh entitlement.
|
||||
- Enforce route-level and API-level access guards.
|
||||
|
||||
### Phase 3: Growth and Expansion
|
||||
|
||||
- Self-serve billing and subscription panel.
|
||||
- Operator analytics and feature usage telemetry.
|
||||
- Optional B2B API package for quant teams.
|
||||
|
||||
---
|
||||
|
||||
## 5. Technical Dependencies for Revenue
|
||||
|
||||
| Dependency | Why it matters |
|
||||
| :------------------- | :--------------------------------------------------------------- |
|
||||
| Entitlement guard | Prevents unpaid dashboard/API access |
|
||||
| Subscriber store | Persistent paid user state |
|
||||
| Audit trail | Explains why each alert fired |
|
||||
| Observability | Detects degradation before churn |
|
||||
| Frontend performance | Impacts conversion and retention (Speed Insights now integrated) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Immediate Commercial Priorities
|
||||
|
||||
1. Finish robust entitlement middleware in frontend and backend.
|
||||
2. Persist subscriber/payment state in managed DB.
|
||||
3. Publish transparent monthly accuracy and signal-quality reports.
|
||||
4. Add support playbooks for false-alert and stale-data incidents.
|
||||
|
||||
---
|
||||
|
||||
Last Updated: `2026-03-11`
|
||||
- 支持更多链和稳定币。
|
||||
- 引入退款与工单后台。
|
||||
- 建立周/月留存与付费转化看板。
|
||||
- 打通渠道分销与邀请码返利系统。
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
# 配置与密钥管理(中文)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
PolyWeather 的环境变量很多,但不是所有变量都属于同一层级。
|
||||
|
||||
当前推荐做法是把配置拆成三类:
|
||||
|
||||
1. 可复现基础配置
|
||||
放在:[.env.example](/E:/web/PolyWeather/.env.example)
|
||||
|
||||
2. 敏感密钥模板
|
||||
放在:[.env.secrets.example](/E:/web/PolyWeather/.env.secrets.example)
|
||||
|
||||
3. 平台侧真实密钥
|
||||
放在:
|
||||
- VPS / Docker `.env`
|
||||
- Vercel Environment Variables
|
||||
- GitHub Secrets(如需要)
|
||||
|
||||
## 2. 为什么要拆
|
||||
|
||||
如果把所有变量都平铺在一个 `.env` 里,会有三个问题:
|
||||
|
||||
1. 新环境很难知道“最小启动到底需要哪些变量”
|
||||
2. 敏感密钥和普通开关混在一起,容易误泄露
|
||||
3. 调优参数太多时,团队很难区分“必须填”和“保持默认即可”
|
||||
|
||||
所以正确做法不是“减少变量数量”,而是:
|
||||
|
||||
- 保留变量能力
|
||||
- 按职责分层
|
||||
- 给出最小启动路径
|
||||
|
||||
## 3. 文件职责
|
||||
|
||||
### 3.1 根 `.env.example`
|
||||
|
||||
文件:
|
||||
|
||||
- [.env.example](/E:/web/PolyWeather/.env.example)
|
||||
|
||||
用途:
|
||||
|
||||
- 后端 / Bot / Docker 的可复现配置模板
|
||||
- 只放变量名、默认值、开关与非敏感示例
|
||||
|
||||
### 3.2 根 `.env.secrets.example`
|
||||
|
||||
文件:
|
||||
|
||||
- [.env.secrets.example](/E:/web/PolyWeather/.env.secrets.example)
|
||||
|
||||
用途:
|
||||
|
||||
- 只列敏感项
|
||||
- 帮助运维明确哪些值必须从密钥系统注入
|
||||
|
||||
### 3.3 前端 `.env.example`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/.env.example](/E:/web/PolyWeather/frontend/.env.example)
|
||||
|
||||
用途:
|
||||
|
||||
- 前端本地开发与 Vercel 环境变量模板
|
||||
|
||||
## 4. 配置分级
|
||||
|
||||
### 4.1 L1:最小启动必需项
|
||||
|
||||
这是“服务能跑起来”的最小集合。
|
||||
|
||||
后端 / Bot:
|
||||
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `TELEGRAM_CHAT_ID`
|
||||
- `POLYWEATHER_RUNTIME_DATA_DIR`
|
||||
- `POLYWEATHER_DB_PATH`
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE`
|
||||
|
||||
前端:
|
||||
|
||||
- `POLYWEATHER_API_BASE_URL`
|
||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`(如果启用 `/ops` 页面级管理员守卫)
|
||||
|
||||
如果启用登录:
|
||||
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- `SUPABASE_URL`
|
||||
- `SUPABASE_ANON_KEY`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
|
||||
### 4.2 L2:功能开关
|
||||
|
||||
这些变量一般不敏感,但会决定功能是否启用。
|
||||
|
||||
例如:
|
||||
|
||||
- `POLYWEATHER_AUTH_ENABLED`
|
||||
- `POLYWEATHER_AUTH_REQUIRED`
|
||||
- `POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION`
|
||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE`
|
||||
- `POLYWEATHER_PAYMENT_ENABLED`
|
||||
- `POLYMARKET_MARKET_SCAN_ENABLED`
|
||||
- `POLYGON_WALLET_WATCH_ENABLED`
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED`
|
||||
|
||||
### 4.3 L3:运行调优项
|
||||
|
||||
这些一般不需要在第一天就改。
|
||||
|
||||
例如:
|
||||
|
||||
- 各类 `*_TTL_SEC`
|
||||
- 各类 `*_TIMEOUT_SEC`
|
||||
- 各类 `*_COOLDOWN_SEC`
|
||||
- 各类 `*_INTERVAL_SEC`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- `TAF_CACHE_TTL_SEC`
|
||||
|
||||
策略:
|
||||
|
||||
- 先用默认值
|
||||
- 出现性能或运维问题时再调
|
||||
|
||||
### 4.4 L4:敏感项
|
||||
|
||||
这些变量不应写进公开文档截图,也不应提交到仓库。
|
||||
|
||||
例如:
|
||||
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
|
||||
- `METEOBLUE_API_KEY`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
|
||||
- `POLYMARKET_SECRET_KEY`
|
||||
|
||||
## 5. 推荐部署矩阵
|
||||
|
||||
### 5.1 VPS / Docker(后端 + Bot)
|
||||
|
||||
建议放这些:
|
||||
|
||||
- 根 `.env` 的后端项
|
||||
- 所有 secrets
|
||||
- Bot / 支付 / watcher 配置
|
||||
|
||||
### 5.2 Vercel(前端)
|
||||
|
||||
建议只放前端真正需要的变量:
|
||||
|
||||
- `POLYWEATHER_API_BASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- `POLYWEATHER_AUTH_ENABLED`
|
||||
- `POLYWEATHER_AUTH_REQUIRED`
|
||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL`
|
||||
|
||||
说明:
|
||||
|
||||
- `/ops` 现在是前后端双层限制:
|
||||
- 前端页面入口读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- 后端写接口同样读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- 因此,Vercel 和 VPS / Docker 两侧都应配置相同的管理员邮箱白名单。
|
||||
|
||||
不要把后端专用密钥全搬进 Vercel。
|
||||
|
||||
### 5.3 GitHub Actions
|
||||
|
||||
当前 CI 不需要大规模 secrets。
|
||||
|
||||
如果未来要做自动部署,再考虑:
|
||||
|
||||
- `VERCEL_TOKEN`
|
||||
- `VERCEL_ORG_ID`
|
||||
- `VERCEL_PROJECT_ID`
|
||||
|
||||
## 6. 最小部署示例
|
||||
|
||||
### 6.1 前端最小变量
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://your-backend.example.com
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
```
|
||||
|
||||
### 6.2 后端最小变量
|
||||
|
||||
```env
|
||||
TELEGRAM_BOT_TOKEN=...
|
||||
TELEGRAM_CHAT_ID=...
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
POLYWEATHER_STATE_STORAGE_MODE=dual
|
||||
UID=1000
|
||||
GID=1000
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
TAF_CACHE_TTL_SEC=900
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_ANON_KEY=...
|
||||
SUPABASE_SERVICE_ROLE_KEY=...
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=...
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `UID` / `GID` 主要给 Linux Docker 主机用,避免容器把运行文件写成 root 所有。
|
||||
- Windows / macOS 一般可以直接保留默认值。
|
||||
- `POLYWEATHER_RUNTIME_DATA_DIR` 建议放在仓库外,例如 `/var/lib/polyweather`。
|
||||
- `docker-compose.yml` 会把这个目录同时挂载到容器内的 `/var/lib/polyweather` 和 `/app/data`,兼容现有缓存与 SQLite 路径。
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE` 当前推荐先用 `dual`,验证后再切 `sqlite`。
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`。
|
||||
|
||||
## 7. 当前建议的运维规则
|
||||
|
||||
### 7.1 仓库中允许存在
|
||||
|
||||
- `.env.example`
|
||||
- `.env.secrets.example`
|
||||
- `frontend/.env.example`
|
||||
|
||||
### 7.2 仓库中不应提交
|
||||
|
||||
- `.env`
|
||||
- `.env.local`
|
||||
- 任何带真实 token / key 的配置文件
|
||||
|
||||
### 7.3 截图与共享规则
|
||||
|
||||
以下值一旦出现在截图或聊天里,建议视为泄露并轮换:
|
||||
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- 第三方私有 API Key
|
||||
|
||||
## 8. 如何收口配置复杂度
|
||||
|
||||
如果你觉得变量仍然太多,正确的做法不是一刀删掉,而是:
|
||||
|
||||
1. 把“功能开关”和“调优参数”分开看
|
||||
2. 保持 `.env.example` 中:
|
||||
- 最小启动项
|
||||
- 常用功能开关
|
||||
- 默认调优值
|
||||
3. 让不常改的高阶参数继续留默认
|
||||
|
||||
也就是说:
|
||||
|
||||
- 使用者只需要先关心 10-20 个关键变量
|
||||
- 其余变量保持默认即可
|
||||
|
||||
## 9. 当前已经完成的配置治理
|
||||
|
||||
1. 根 `.env.example` 收口
|
||||
2. `.env.secrets.example` 新增
|
||||
3. 前端 `.env.example` 收口
|
||||
4. 运行时配置校验脚本新增
|
||||
5. `/ops` 管理员白名单与前后端职责边界已明确
|
||||
5. 支付运行态与多 RPC 配置支持
|
||||
6. 运行态 SQLite 迁移配置支持
|
||||
|
||||
## 10. 配置校验命令
|
||||
|
||||
在不启动服务的情况下,你可以直接检查配置:
|
||||
|
||||
```bash
|
||||
python scripts/validate_runtime_env.py --component web
|
||||
python scripts/validate_runtime_env.py --component bot
|
||||
```
|
||||
@@ -0,0 +1,311 @@
|
||||
# EMOS 训练报告(2026-03-20)
|
||||
|
||||
## 1. 报告目的
|
||||
|
||||
本文档用于记录当前 PolyWeather 概率校准引擎(EMOS)的训练结果、离线评估结果、线上 shadow 观测结果,以及是否具备切换为主路径的条件。
|
||||
|
||||
当前结论先写在前面:
|
||||
|
||||
- `EMOS` 已完成接入、训练、离线评估、shadow 落盘与滚动报表。
|
||||
- 当前默认运行模式应继续保持 `emos_shadow`。
|
||||
- 现阶段 **不建议切换到 `emos_primary`**。
|
||||
|
||||
## 2. 本次训练版本
|
||||
|
||||
- 校准版本:`emos-20260320130245`
|
||||
- 训练时间:`2026-03-20T13:02:45.903772+00:00`
|
||||
- 参数文件:[default.json](/E:/web/PolyWeather/artifacts/probability_calibration/default.json)
|
||||
- 离线评估报告:[evaluation_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/evaluation_report.json)
|
||||
- 线上 shadow 报表:[shadow_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/shadow_report.json)
|
||||
|
||||
## 3. 训练数据概况
|
||||
|
||||
### 3.1 数据来源
|
||||
|
||||
当前训练主要使用两类数据:
|
||||
|
||||
1. 项目历史日记录
|
||||
文件:[daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
|
||||
|
||||
2. 历史天气 CSV 构建出的结算标签
|
||||
文件:[settlement_history.json](/E:/web/PolyWeather/artifacts/probability_calibration/settlement_history.json)
|
||||
|
||||
### 3.2 样本规模
|
||||
|
||||
- 总训练样本数:`105`
|
||||
- 通过历史天气 CSV 补回的缺失 `actual_high`:`2`
|
||||
- 历史结算标签覆盖城市数:`30`
|
||||
|
||||
说明:
|
||||
|
||||
- 当前样本已覆盖 30 个城市,但有效监督样本量仍偏小。
|
||||
- 部分城市样本数只有 `2-7` 条,城市级参数容易波动。
|
||||
|
||||
## 4. 模型结构
|
||||
|
||||
### 4.1 当前实现
|
||||
|
||||
EMOS 属于统计后处理层,不是数值天气模型本身。当前结构位于:
|
||||
|
||||
- [probability_calibration.py](/E:/web/PolyWeather/src/analysis/probability_calibration.py)
|
||||
|
||||
当前目标是对原有概率引擎输出进行校准:
|
||||
|
||||
- 输入:`raw_mu`、`raw_sigma`、`DEB`、`ensemble median/spread`、`peak_status` 等特征
|
||||
- 输出:校准后的 `mu / sigma / distribution`
|
||||
|
||||
### 4.2 当前运行模式
|
||||
|
||||
支持三种模式:
|
||||
|
||||
- `legacy`
|
||||
- `emos_shadow`
|
||||
- `emos_primary`
|
||||
|
||||
当前建议默认模式:
|
||||
|
||||
- `emos_shadow`
|
||||
|
||||
即:
|
||||
|
||||
- 对外仍展示 legacy 结果
|
||||
- 后台并行计算 EMOS 结果
|
||||
- 用于持续评估,不直接影响用户
|
||||
|
||||
## 5. 本次训练参数摘要
|
||||
|
||||
### 5.1 全局约束
|
||||
|
||||
本次训练已加入两类约束:
|
||||
|
||||
1. `sigma_constraints`
|
||||
- `min_ratio = 0.85`
|
||||
- `max_ratio = 1.35`
|
||||
- `absolute_min = 0.25`
|
||||
- `absolute_max = 3.0`
|
||||
|
||||
2. `selection_guardrails`
|
||||
- `max_mae_increase = 0.02`
|
||||
- `max_bucket_hit_drop = 0.01`
|
||||
- `max_bucket_brier_increase = 0.05`
|
||||
|
||||
这两类约束的目的不是追求“更激进的拟合”,而是防止 EMOS 为了降低 CRPS 而把分布摊得过平,导致业务上更关键的顶桶命中和概率质量变差。
|
||||
|
||||
### 5.2 当前选中的 blending
|
||||
|
||||
本次训练产物中最终选择:
|
||||
|
||||
- `alpha_mu = 0.0`
|
||||
- `alpha_sigma = 0.0`
|
||||
|
||||
含义是:
|
||||
|
||||
- 训练器在护栏约束下,没有找到足够安全的候选方案可以替代 legacy 主路径
|
||||
- 因此当前正式选中的可用结果,本质上仍然锚定在 legacy
|
||||
|
||||
这是一种正确的保护行为,不是失败。说明门禁已经起作用,避免了坏校准进入主路径。
|
||||
|
||||
## 6. 离线评估结果
|
||||
|
||||
评估报告来源:
|
||||
|
||||
- [evaluation_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/evaluation_report.json)
|
||||
|
||||
### 6.1 总体结果
|
||||
|
||||
Legacy:
|
||||
|
||||
- `mean_crps = 2.793938`
|
||||
- `mean_mae = 2.721143`
|
||||
- `bucket_hit_rate = 0.695238`
|
||||
|
||||
EMOS(强制 primary 评估):
|
||||
|
||||
- `mean_crps = 2.650216`
|
||||
- `mean_mae = 2.722829`
|
||||
- `bucket_hit_rate = 0.666667`
|
||||
|
||||
Delta:
|
||||
|
||||
- `CRPS = -0.143722`
|
||||
- `MAE = +0.001686`
|
||||
- `bucket_hit_rate = -0.028571`
|
||||
|
||||
### 6.2 解读
|
||||
|
||||
这组结果说明:
|
||||
|
||||
1. `CRPS` 有改善
|
||||
说明从“分布整体平滑度”角度看,EMOS 有一定价值。
|
||||
|
||||
2. `MAE` 基本持平但略差
|
||||
不是大问题,但也不能算改善。
|
||||
|
||||
3. `bucket_hit_rate` 明显下降
|
||||
这是当前最大阻塞项。对 PolyWeather 这种结算桶业务来说,顶桶命中率比单纯 CRPS 更关键。
|
||||
|
||||
因此,离线结论是:
|
||||
|
||||
- `EMOS` 有研究价值
|
||||
- 但 **离线强切 primary 仍然不合格**
|
||||
|
||||
## 7. 线上 Shadow 观测结果
|
||||
|
||||
线上 shadow 报表来源:
|
||||
|
||||
- [shadow_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/shadow_report.json)
|
||||
|
||||
### 7.1 总体结果
|
||||
|
||||
- `samples = 103`
|
||||
- `legacy_mean_mae = 1.839223`
|
||||
- `shadow_mean_mae = 1.851931`
|
||||
- `delta_mae = +0.012708`
|
||||
|
||||
- `legacy_bucket_hit_rate = 0.669903`
|
||||
- `shadow_bucket_hit_rate = 0.679612`
|
||||
- `delta_bucket_hit_rate = +0.009709`
|
||||
|
||||
- `legacy_bucket_brier = 0.462814`
|
||||
- `shadow_bucket_brier = 0.756649`
|
||||
- `delta_bucket_brier = +0.293835`
|
||||
|
||||
### 7.2 解读
|
||||
|
||||
线上 shadow 结果和离线强制 primary 结果不完全相同,这是正常的。原因是:
|
||||
|
||||
- `shadow_report` 反映的是历史记录中实际落盘的 shadow 输出
|
||||
- `evaluation_report` 反映的是离线脚本在强制 `emos_primary` 下重新计算的效果
|
||||
|
||||
当前线上 shadow 的含义是:
|
||||
|
||||
1. 顶桶命中率略有提升
|
||||
`+0.97%`
|
||||
|
||||
2. 但 `MAE` 轻微变差
|
||||
虽然幅度不大,但没有形成明确优势
|
||||
|
||||
3. `bucket_brier` 明显更差
|
||||
说明 shadow 分布仍然偏“摊平”,概率质量不足
|
||||
|
||||
这是当前最重要的信号:
|
||||
|
||||
- EMOS 在“顶桶命中”上偶尔能赢
|
||||
- 但在“概率质量”上还不够好
|
||||
|
||||
## 8. 城市级观察
|
||||
|
||||
从当前城市级结果看,EMOS 并不是“全城市统一改善”,而是明显分化:
|
||||
|
||||
### 8.1 相对改善较明显的城市
|
||||
|
||||
- `London`
|
||||
- `Hong Kong`
|
||||
- `Tokyo`
|
||||
- `New York`
|
||||
|
||||
这些城市在部分指标上看到一定改善,说明当前校准特征在这些城市上更有效。
|
||||
|
||||
### 8.2 风险较高的城市
|
||||
|
||||
- `Atlanta`
|
||||
- `Miami`
|
||||
- `Chicago`
|
||||
- `Dallas`
|
||||
- `Seattle`
|
||||
|
||||
这些城市常见现象是:
|
||||
|
||||
- 顶桶命中没有显著提高
|
||||
- 或 `bucket_brier` 明显恶化
|
||||
- 或者 `MAE` 出现不必要抬升
|
||||
|
||||
这说明当前 EMOS 还没有形成稳定的全局校准能力,城市间异质性很强。
|
||||
|
||||
## 9. 当前判断
|
||||
|
||||
### 9.1 能不能上线为主路径
|
||||
|
||||
当前答案:
|
||||
|
||||
- **不能**
|
||||
|
||||
原因:
|
||||
|
||||
1. 离线强制 primary 时,`bucket_hit_rate` 下降
|
||||
2. 线上 shadow 时,`bucket_brier` 明显变差
|
||||
3. 样本量依然偏小,城市样本不均衡
|
||||
4. 城市级表现分化明显
|
||||
|
||||
### 9.2 当前应该怎么运行
|
||||
|
||||
当前最合理的运行方式:
|
||||
|
||||
1. 保持 `emos_shadow`
|
||||
2. 继续落盘 `shadow_prob_snapshot`
|
||||
3. 继续维护滚动报表
|
||||
4. 不修改机器人和网页的正式对外概率展示
|
||||
|
||||
## 10. 已完成的工程能力
|
||||
|
||||
目前已经具备以下能力:
|
||||
|
||||
1. 可离线训练
|
||||
脚本:[fit_probability_calibration.py](/E:/web/PolyWeather/scripts/fit_probability_calibration.py)
|
||||
|
||||
2. 可离线评估
|
||||
脚本:[evaluate_probability_calibration.py](/E:/web/PolyWeather/scripts/evaluate_probability_calibration.py)
|
||||
|
||||
3. 可导出训练样本
|
||||
脚本:[export_probability_training_dataset.py](/E:/web/PolyWeather/scripts/export_probability_training_dataset.py)
|
||||
|
||||
4. 可历史回填 shadow 结果
|
||||
脚本:[backfill_probability_shadow_history.py](/E:/web/PolyWeather/scripts/backfill_probability_shadow_history.py)
|
||||
|
||||
5. 可生成滚动 shadow 报表
|
||||
脚本:[build_probability_shadow_report.py](/E:/web/PolyWeather/scripts/build_probability_shadow_report.py)
|
||||
|
||||
6. CI 已接入
|
||||
包含 `ruff / pytest / frontend build / docker build workflow`
|
||||
|
||||
## 11. 下一步建议
|
||||
|
||||
### 11.1 必做
|
||||
|
||||
1. 扩大监督样本量
|
||||
重点不是继续堆原始天气 CSV,而是补更多带 forecast snapshot 的历史样本。
|
||||
|
||||
2. 继续按版本沉淀训练报告
|
||||
每次重训后都更新本报告或新增版本报告,避免只看单次结果。
|
||||
|
||||
3. 保持 `shadow` 连续观测
|
||||
至少持续一段时间观察滚动指标是否稳定。
|
||||
|
||||
### 11.2 再做
|
||||
|
||||
1. 细分城市组建模
|
||||
比如按气候区、结算规则、温度单位分组,而不是完全全局一套参数。
|
||||
|
||||
2. 优化训练目标
|
||||
目前已经把 `bucket_brier` 纳入目标,但仍需进一步靠近 PolyWeather 的业务目标。
|
||||
|
||||
3. 补更严格的切换门槛
|
||||
只有在同时满足以下条件时,才考虑切 `emos_primary`:
|
||||
- `CRPS` 下降
|
||||
- `MAE` 不上升
|
||||
- `bucket_hit_rate` 不下降
|
||||
- `bucket_brier` 不上升
|
||||
|
||||
## 12. 结论
|
||||
|
||||
当前 EMOS 状态可以概括为:
|
||||
|
||||
- 工程上:已经完整接入,具备训练、评估、shadow 观测能力
|
||||
- 模型上:有一定价值,但还不稳定
|
||||
- 产品上:适合继续做 shadow,不适合切主路径
|
||||
|
||||
最终结论:
|
||||
|
||||
- **继续使用 `emos_shadow`**
|
||||
- **暂不切 `emos_primary`**
|
||||
- **继续积累样本并按版本跟踪训练结果**
|
||||
@@ -0,0 +1,211 @@
|
||||
# 前端部署配置(Vercel)
|
||||
|
||||
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。
|
||||
|
||||
## 一、部署目标
|
||||
|
||||
推荐方案:
|
||||
|
||||
1. GitHub Actions 负责 `CI`
|
||||
2. Vercel 负责前端 `CD`
|
||||
3. FastAPI 后端单独部署在 VPS / Docker 主机
|
||||
|
||||
前端本身不直接访问天气源,而是通过 Next Route Handlers 转发到后端:
|
||||
|
||||
1. 浏览器 -> Vercel 上的 Next.js 前端
|
||||
2. Next `/api/*` -> `POLYWEATHER_API_BASE_URL`
|
||||
3. FastAPI 后端 -> 分析 / 支付 / 鉴权服务
|
||||
|
||||
## 二、Vercel 项目设置
|
||||
|
||||
在 Vercel 导入 GitHub 仓库后,使用下面的设置:
|
||||
|
||||
- Framework Preset: `Next.js`
|
||||
- Root Directory: `frontend`
|
||||
- Build Command: `npm run build`
|
||||
- Install Command: `npm install`
|
||||
|
||||
如果仓库已经连接过 Vercel,通常只需要确认 `Root Directory` 仍然是 `frontend`。
|
||||
|
||||
## 三、最小必填环境变量
|
||||
|
||||
只部署天气看板和基础登录时,先填下面 4 项:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
建议显式补:
|
||||
|
||||
```env
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `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. 分享式看板
|
||||
|
||||
```env
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
```
|
||||
|
||||
设置后,可通过 `/?access_token=<token>` 打开带令牌的看板入口。
|
||||
|
||||
### 2. 前后端 entitlement 校验
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
如果不启用钱包支付,可以留空。
|
||||
|
||||
### 4. `/ops` 管理员页面守卫
|
||||
|
||||
```env
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `/ops` 现在不是只有后端接口限制,前端页面入口也会读取管理员邮箱白名单。
|
||||
- 因此前端部署到 Vercel 时,也应配置 `POLYWEATHER_OPS_ADMIN_EMAILS`。
|
||||
|
||||
### 5. Telegram 入口
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
||||
```
|
||||
|
||||
只影响按钮跳转,不影响核心页面加载。
|
||||
|
||||
## 五、支付配置与旧部署治理
|
||||
|
||||
支付区现在有一层额外防护:
|
||||
|
||||
1. 用户点击支付前,前端会重新请求 `/api/payments/config`
|
||||
2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址
|
||||
3. 若后端返回的 `tx_payload.to` 与最新 `receiver_contract` 不一致,会直接阻断支付
|
||||
|
||||
这层防护的目的,是降低以下事故概率:
|
||||
|
||||
- 用户使用长期未刷新的旧标签页
|
||||
- 命中旧 deployment URL
|
||||
- 页面本地状态残留旧收款地址
|
||||
|
||||
如果你变更过支付收款地址,建议同步执行:
|
||||
|
||||
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`
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN` 以外的后端 secret
|
||||
- 支付签名私钥 / 交易私钥 / 任何 bot 凭据
|
||||
|
||||
特别注意:
|
||||
|
||||
- `NEXT_PUBLIC_*` 会暴露给浏览器
|
||||
- 只有明确允许前端公开使用的值,才应加 `NEXT_PUBLIC_`
|
||||
|
||||
## 八、上线前检查
|
||||
|
||||
Vercel 部署前至少确认:
|
||||
|
||||
1. `POLYWEATHER_API_BASE_URL` 指向可访问的后端生产地址
|
||||
2. `frontend/.env.example` 和 Vercel Project Settings 中的实际值一致
|
||||
3. GitHub Actions 中 `frontend-quality` 已通过
|
||||
4. 如果启用鉴权,Supabase redirect URL 已包含前端域名
|
||||
5. `GET /api/payments/config` 返回的是当前最新地址,而不是旧收款合约
|
||||
6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在 Vercel 与后端同时配置
|
||||
|
||||
## 九、常见问题
|
||||
|
||||
### 1. 页面打开后 API 全部 500
|
||||
|
||||
先检查:
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL
|
||||
```
|
||||
|
||||
这是最常见原因。
|
||||
|
||||
### 2. Vercel 构建通过,但登录失败
|
||||
|
||||
先检查:
|
||||
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- Supabase 项目里的站点 URL / redirect URL
|
||||
|
||||
### 3. 钱包入口显示未配置
|
||||
|
||||
先检查:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
|
||||
```
|
||||
|
||||
这是钱包连接的必需项。
|
||||
@@ -0,0 +1,63 @@
|
||||
# Open-Core 与商用边界
|
||||
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在保持社区可用性的前提下,保护商业化阶段的核心经营资产。
|
||||
|
||||
## 2. 仓库公开范围(可开源)
|
||||
|
||||
- 天气数据采集与标准化(METAR / Open-Meteo / MGM 接口层)。
|
||||
- DEB 与基础趋势分析、概率桶计算。
|
||||
- Dashboard 基础体验与 API/BFF 结构。
|
||||
- Telegram Bot 基础命令与基础积分机制。
|
||||
- 合约支付标准流程(钱包绑定、intent、提交、确认、补单)。
|
||||
|
||||
## 3. 生产私有范围(建议不公开)
|
||||
|
||||
- 商业风控参数与规则库:
|
||||
- 错价信号阈值组合、推送阈值、异常检测策略。
|
||||
- 运营策略资产:
|
||||
- 用户分层规则、促销规则、留存策略、活动模板。
|
||||
- 付费系统敏感细节:
|
||||
- 实时对账容错阈值、退款审计策略、内部财务映射规则。
|
||||
- 私有运维资产:
|
||||
- 生产告警路由、内部频道映射、应急脚本与排障手册。
|
||||
|
||||
## 4. 配置与数据安全红线
|
||||
|
||||
- 不提交:`.env`、私钥、API key、机器人 token。
|
||||
- 不提交:生产数据库、运行时状态文件、支付流水快照。
|
||||
- 不提交:用户身份信息、钱包映射、订阅原始审计日志。
|
||||
|
||||
## 5. 推荐发布模式
|
||||
|
||||
### 5.1 Community Edition(开源)
|
||||
|
||||
- 提供基础分析与基础看板。
|
||||
- 可选保留只读市场扫描。
|
||||
- 默认关闭商业化运营规则。
|
||||
|
||||
### 5.2 Production Edition(私有)
|
||||
|
||||
- 启用收费、订阅、积分抵扣、风控、私有监控。
|
||||
- 仅在私有仓库维护运营策略与敏感参数。
|
||||
|
||||
## 6. 文档口径规范
|
||||
|
||||
对外文档仅描述:
|
||||
|
||||
- 能力边界与使用方式。
|
||||
- 可公开的技术架构。
|
||||
- 不包含可被直接复刻的商业参数。
|
||||
|
||||
不对外文档描述:
|
||||
|
||||
- 具体策略阈值、用户分层细则、收益归因规则。
|
||||
|
||||
## 7. 许可证与法务建议(简版)
|
||||
|
||||
- 建议保持仓库代码许可证与商标/品牌授权分离。
|
||||
- 若提供商业服务,建议在官网补充服务条款与隐私政策。
|
||||
- 对“订阅权益”与“可用性”做明确 SLA 与免责边界。
|
||||
@@ -0,0 +1,104 @@
|
||||
# Ops 运营后台说明
|
||||
|
||||
最后更新:`2026-03-21`
|
||||
|
||||
## 1. 入口
|
||||
|
||||
前端入口:
|
||||
|
||||
- `https://polyweather-pro.vercel.app/ops`
|
||||
|
||||
## 2. 权限
|
||||
|
||||
`/ops` 的写接口由后端白名单控制:
|
||||
|
||||
```env
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
```
|
||||
|
||||
可配置多个邮箱,逗号分隔。
|
||||
|
||||
## 3. 当前能力
|
||||
|
||||
### 只读能力
|
||||
|
||||
- 系统健康
|
||||
- SQLite / rollout / metrics 摘要
|
||||
- 支付运行态
|
||||
- 当前会员
|
||||
- 周榜
|
||||
- 支付异常单
|
||||
|
||||
### 写能力
|
||||
|
||||
- 手动补分
|
||||
- 标记支付异常单“已处理”
|
||||
|
||||
## 4. 当前会员
|
||||
|
||||
会员列表来自:
|
||||
|
||||
1. `subscriptions` 中的有效订阅
|
||||
2. 本地 `users` / `supabase_bindings`
|
||||
3. 若本地缺邮箱或注册时间,再回补 Supabase Auth 用户信息
|
||||
|
||||
去重规则:
|
||||
|
||||
- 同一个 `user_id` 只保留最晚到期那条
|
||||
|
||||
## 5. 支付异常单
|
||||
|
||||
当前异常单来源:
|
||||
|
||||
- `payment_audit_events`
|
||||
- 仅筛 `payment_intent_failed`
|
||||
|
||||
当前支持的典型失败原因:
|
||||
|
||||
- `receiver_mismatch`
|
||||
- `sender_mismatch`
|
||||
- `event_mismatch`
|
||||
- `tx_reverted`
|
||||
|
||||
默认只显示未处理项。
|
||||
|
||||
## 6. 典型处理流程
|
||||
|
||||
### 6.1 钱已到账但没开订阅
|
||||
|
||||
先看 `/ops` 的支付异常单:
|
||||
|
||||
- 如果是 `receiver_mismatch`
|
||||
- 优先判定为支付打到了旧收款地址
|
||||
- 不是缓存问题
|
||||
|
||||
然后执行:
|
||||
|
||||
1. 查 `payment_intents`
|
||||
2. 查 `payment_transactions`
|
||||
3. 查 `subscriptions`
|
||||
4. 跑恢复脚本:
|
||||
|
||||
```bash
|
||||
python scripts/reconcile_subscription_by_email.py --email <user_email>
|
||||
```
|
||||
|
||||
如果仍然失败,再人工补订阅。
|
||||
|
||||
### 6.2 已人工处理
|
||||
|
||||
在 `/ops` 里直接点:
|
||||
|
||||
- `标记已处理`
|
||||
|
||||
这不会删除审计事件,只会给原事件写:
|
||||
|
||||
- `resolved_at`
|
||||
- `resolved_by`
|
||||
|
||||
## 7. 备注
|
||||
|
||||
`/ops` 是运营后台最小版,不是完整 Admin 平台。当前目标是:
|
||||
|
||||
- 让会员、积分、支付事故、系统状态可查
|
||||
- 让常见人工操作不必再直接写 SQL
|
||||
@@ -0,0 +1,324 @@
|
||||
# 概率训练样本归档说明(中文)
|
||||
|
||||
## 1. 目的
|
||||
|
||||
这份文档说明两件事:
|
||||
|
||||
1. 为什么 `EMOS` 训练不能只依赖历史实测天气
|
||||
2. 未来如何持续沉淀“历史预测记录”,让概率引擎越训越稳
|
||||
|
||||
一句话结论:
|
||||
|
||||
- 历史实测天气只能补 `actual_high`
|
||||
- 真正决定 `EMOS` 训练质量的是“当时那一刻的预测快照”
|
||||
|
||||
## 2. 什么是“历史预测记录”
|
||||
|
||||
对 PolyWeather 来说,一条可训练的历史预测记录,至少应该包含这些字段:
|
||||
|
||||
- `city`
|
||||
- `timestamp`
|
||||
- `date`
|
||||
- `raw_mu`
|
||||
- `raw_sigma`
|
||||
- `deb_prediction`
|
||||
- `ensemble p10 / p50 / p90`
|
||||
- `multi-model forecasts`
|
||||
- `max_so_far`
|
||||
- `peak_status`
|
||||
- `prob_snapshot`
|
||||
- 当天最终 `actual_high`
|
||||
- 当天最终 `settlement bucket`
|
||||
|
||||
这类记录的核心价值是:
|
||||
|
||||
- 还原“当时系统实际看到什么”
|
||||
- 再对照“后来真实发生了什么”
|
||||
|
||||
只有这两者成对,`EMOS` 才能学习偏差。
|
||||
|
||||
## 3. 为什么不能只用历史天气实测
|
||||
|
||||
历史天气 CSV 只能告诉你:
|
||||
|
||||
- 当天最高温是多少
|
||||
- 某小时温度是多少
|
||||
|
||||
但它不能告诉你:
|
||||
|
||||
- 当天早上 09:00 时,系统的 `mu` 是多少
|
||||
- 当时的 `ensemble spread` 是多少
|
||||
- 当时 `DEB` 怎么看
|
||||
- 当时的 top bucket 是什么
|
||||
|
||||
所以:
|
||||
|
||||
- 历史实测天气是标签
|
||||
- 历史预测记录才是训练输入
|
||||
|
||||
缺少后者,EMOS 只能学到很有限的东西。
|
||||
|
||||
## 4. 当前项目里已经有的基础
|
||||
|
||||
### 4.1 已有历史日记录
|
||||
|
||||
文件:
|
||||
|
||||
- [daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
|
||||
|
||||
当前已经保存了一部分训练相关字段,例如:
|
||||
|
||||
- `forecasts`
|
||||
- `actual_high`
|
||||
- `deb_prediction`
|
||||
- `mu`
|
||||
- `prob_snapshot`
|
||||
- `shadow_prob_snapshot`
|
||||
- `probability_calibration`
|
||||
- `probability_features`
|
||||
|
||||
这已经是“历史预测记录”的雏形。
|
||||
|
||||
### 4.2 已有历史天气 CSV
|
||||
|
||||
目录:
|
||||
|
||||
- [data/historical](/E:/web/PolyWeather/data/historical)
|
||||
|
||||
它们可以帮助补:
|
||||
|
||||
- `actual_high`
|
||||
- `settlement history`
|
||||
|
||||
但不能替代预测快照归档。
|
||||
|
||||
## 5. 未来应该怎么存历史预测记录
|
||||
|
||||
推荐做法是:
|
||||
|
||||
### 5.1 固定时点归档
|
||||
|
||||
每天为每个重点城市固定存几次快照,例如:
|
||||
|
||||
- 当地 `09:00`
|
||||
- 当地 `12:00`
|
||||
- 当地 `15:00`
|
||||
|
||||
这样能确保每个交易日都有稳定可比样本。
|
||||
|
||||
### 5.2 关键变化时补充归档
|
||||
|
||||
除了固定时点,还应该在以下情况额外存一次:
|
||||
|
||||
- `max_so_far` 创新高
|
||||
- `mu` 变化超过阈值
|
||||
- `top bucket` 发生变化
|
||||
- `shadow top bucket` 发生变化
|
||||
|
||||
这样能捕捉真正有训练价值的转折点。
|
||||
|
||||
### 5.3 建议的存储格式
|
||||
|
||||
建议新增一个文件,例如:
|
||||
|
||||
- `data/probability_training_snapshots.jsonl`
|
||||
|
||||
每一行保存一条 JSON 记录。
|
||||
|
||||
优点:
|
||||
|
||||
- 追加写入简单
|
||||
- 后续导出训练集方便
|
||||
- 不容易因为单个大 JSON 文件损坏而全盘受影响
|
||||
|
||||
## 6. 一条建议的快照结构
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"city": "ankara",
|
||||
"timestamp": "2026-03-20T12:00:00+03:00",
|
||||
"date": "2026-03-20",
|
||||
"raw_mu": 15.2,
|
||||
"raw_sigma": 1.2,
|
||||
"deb_prediction": 15.4,
|
||||
"ensemble": {
|
||||
"p10": 14.8,
|
||||
"median": 15.8,
|
||||
"p90": 17.9
|
||||
},
|
||||
"multi_model": {
|
||||
"ECMWF": 15.8,
|
||||
"GFS": 14.1,
|
||||
"ICON": 15.9,
|
||||
"GEM": 16.5,
|
||||
"JMA": 14.5
|
||||
},
|
||||
"max_so_far": 15.0,
|
||||
"peak_status": "before",
|
||||
"prob_snapshot": [
|
||||
{"v": 15, "p": 0.552},
|
||||
{"v": 16, "p": 0.377}
|
||||
],
|
||||
"shadow_prob_snapshot": [
|
||||
{"v": 15, "p": 0.324},
|
||||
{"v": 16, "p": 0.238}
|
||||
],
|
||||
"probability_engine": "legacy",
|
||||
"probability_mode": "emos_shadow",
|
||||
"calibration_version": "emos-20260320130245"
|
||||
}
|
||||
```
|
||||
|
||||
当天结束后,再由后处理脚本回填:
|
||||
|
||||
- `actual_high`
|
||||
- `settlement_bucket`
|
||||
|
||||
## 7. 现阶段你可以执行的命令
|
||||
|
||||
### 7.1 回填历史天气 CSV
|
||||
|
||||
```bash
|
||||
python scripts/backfill_historical_weather.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 补全 30 城市历史天气时序 CSV
|
||||
|
||||
### 7.2 从历史 CSV 构建日级结算标签
|
||||
|
||||
```bash
|
||||
python scripts/build_settlement_history_from_csv.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [settlement_history.json](/E:/web/PolyWeather/artifacts/probability_calibration/settlement_history.json)
|
||||
|
||||
### 7.3 导出当前训练样本
|
||||
|
||||
```bash
|
||||
python scripts/export_probability_training_dataset.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [training_samples.json](/E:/web/PolyWeather/artifacts/probability_calibration/training_samples.json)
|
||||
|
||||
### 7.4 重训 EMOS
|
||||
|
||||
```bash
|
||||
python scripts/fit_probability_calibration.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成新的 [default.json](/E:/web/PolyWeather/artifacts/probability_calibration/default.json)
|
||||
|
||||
### 7.5 离线评估训练效果
|
||||
|
||||
```bash
|
||||
python scripts/evaluate_probability_calibration.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [evaluation_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/evaluation_report.json)
|
||||
|
||||
### 7.6 回填 shadow 结果到历史记录
|
||||
|
||||
```bash
|
||||
python scripts/backfill_probability_shadow_history.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 把 `shadow_prob_snapshot` 和 `probability_calibration` 回填到 [daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
|
||||
|
||||
### 7.7 生成线上 shadow 滚动报表
|
||||
|
||||
```bash
|
||||
python scripts/build_probability_shadow_report.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [shadow_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/shadow_report.json)
|
||||
|
||||
## 8. 推荐的一整套重训流程
|
||||
|
||||
如果过了十天、半个月,想重新训练一次,建议按这个顺序执行:
|
||||
|
||||
```bash
|
||||
python scripts/build_settlement_history_from_csv.py
|
||||
python scripts/export_probability_training_dataset.py
|
||||
python scripts/fit_probability_calibration.py
|
||||
python scripts/evaluate_probability_calibration.py
|
||||
python scripts/backfill_probability_shadow_history.py
|
||||
python scripts/build_probability_shadow_report.py
|
||||
```
|
||||
|
||||
如果历史天气 CSV 还没补全,再先执行:
|
||||
|
||||
```bash
|
||||
python scripts/backfill_historical_weather.py
|
||||
```
|
||||
|
||||
## 9. 怎么判断这次训练有没有进步
|
||||
|
||||
重训后,不要只看一个指标。
|
||||
|
||||
至少看这 4 个:
|
||||
|
||||
1. `CRPS`
|
||||
- 越低越好
|
||||
|
||||
2. `MAE`
|
||||
- 越低越好
|
||||
- 至少不要明显变差
|
||||
|
||||
3. `Bucket Hit Rate`
|
||||
- 越高越好
|
||||
- 这是业务上非常关键的指标
|
||||
|
||||
4. `Bucket Brier`
|
||||
- 越低越好
|
||||
- 反映概率分布质量
|
||||
|
||||
只有同时满足下面条件,才可以说训练效果真的进步:
|
||||
|
||||
- `CRPS` 下降
|
||||
- `MAE` 不上升
|
||||
- `Bucket Hit Rate` 不下降
|
||||
- `Bucket Brier` 不上升
|
||||
|
||||
## 10. 当前最重要的现实判断
|
||||
|
||||
过去的“完整历史预测记录”通常没法完全补出来,除非:
|
||||
|
||||
1. 你之前就存过
|
||||
2. 你接入了支持 forecast archive 的商业数据源
|
||||
|
||||
所以现实里最重要的不是“把过去全补齐”,而是:
|
||||
|
||||
- 从现在开始系统化归档
|
||||
- 每天稳定沉淀可训练样本
|
||||
- 定期离线重训
|
||||
|
||||
## 11. 推荐的下一步
|
||||
|
||||
最值得做的改造是:
|
||||
|
||||
1. 新增 `probability_training_snapshots.jsonl`
|
||||
2. 每次分析时自动追加一条快照
|
||||
3. 当天结束后自动回填 `actual_high`
|
||||
4. 每 1-2 周重新训练一次
|
||||
|
||||
## 12. 总结
|
||||
|
||||
如果只记住一句话,就记这个:
|
||||
|
||||
**EMOS 要想越训越好,关键不是多下载一点历史天气,而是持续保存“当时系统看到的预测快照”。**
|
||||
@@ -0,0 +1,122 @@
|
||||
# Supabase + 登录 + 支付接入说明(v1.5.1)
|
||||
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- 前端支持 Google 一键登录 + 邮箱注册/登录。
|
||||
- 后端支持 Supabase JWT 鉴权。
|
||||
- 支持 Polygon 合约支付(USDC / USDC.e)并自动确认开通订阅。
|
||||
|
||||
## 2. Supabase 控制台配置
|
||||
|
||||
1. `Auth -> Providers` 打开 `Google` 与 `Email`。
|
||||
2. Google Cloud OAuth 回调配置:
|
||||
- `https://<project-ref>.supabase.co/auth/v1/callback`
|
||||
3. `Auth -> URL Configuration` 添加:
|
||||
- 站点 URL(生产域名)
|
||||
- 回调 URL(例如 `https://polyweather-pro.vercel.app/auth/callback`)
|
||||
|
||||
## 3. 数据库脚本
|
||||
|
||||
在 Supabase SQL Editor 执行:
|
||||
|
||||
- `scripts/supabase/schema.sql`
|
||||
|
||||
会创建支付与订阅相关表:
|
||||
|
||||
- `subscriptions`
|
||||
- `payments`
|
||||
- `entitlement_events`
|
||||
- `user_wallets`
|
||||
- `wallet_link_challenges`
|
||||
- `payment_intents`
|
||||
- `payment_transactions`
|
||||
|
||||
## 4. 环境变量
|
||||
|
||||
### 4.1 前端(Vercel / frontend/.env.local)
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
POLYWEATHER_API_BASE_URL=http://<backend-host>:8000
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
# WalletConnect(支持手机钱包扫码)
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
|
||||
# Overlay 跳转
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
||||
```
|
||||
|
||||
### 4.2 后端 / Bot(.env)
|
||||
|
||||
```env
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false
|
||||
|
||||
SUPABASE_URL=
|
||||
SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
SUPABASE_HTTP_TIMEOUT_SEC=8
|
||||
|
||||
POLYWEATHER_PAYMENT_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<receiver_contract>
|
||||
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>"}]
|
||||
|
||||
# 套餐(当前只保留月付)
|
||||
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_POINTS_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
|
||||
|
||||
# 支付自动补单
|
||||
POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
|
||||
```
|
||||
|
||||
## 5. 钱包异动频道拆分(推荐)
|
||||
|
||||
如果要把“钱包异动监控”发到独立频道:
|
||||
|
||||
```env
|
||||
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=-1003821482461
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 设置了 `POLYMARKET_WALLET_ACTIVITY_CHAT_ID(S)` 后,钱包异动推送优先发该频道。
|
||||
- 未设置时,回退到全局 `TELEGRAM_CHAT_IDS/TELEGRAM_CHAT_ID`。
|
||||
|
||||
## 6. 验证步骤
|
||||
|
||||
1. 登录后请求 `/api/auth/me`,确认 `authenticated=true`。
|
||||
2. 请求 `/api/payments/config`,确认 `enabled=true`、`configured=true`。
|
||||
3. 钱包绑定:
|
||||
- `POST /api/payments/wallets/challenge`
|
||||
- `POST /api/payments/wallets/verify`
|
||||
4. 支付流程:
|
||||
- `POST /api/payments/intents`
|
||||
- 发链上交易
|
||||
- `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`。
|
||||
@@ -0,0 +1,286 @@
|
||||
# PolyWeather TAF 信号说明(TAF_SIGNAL_ZH)
|
||||
|
||||
本文档说明 PolyWeather 当前如何把 `TAF`(机场终端预报)接入“今日日内分析”,以及这些信号在交易判断里到底代表什么。
|
||||
|
||||
本文档只描述**当前实现**,不夸大、不脑补。
|
||||
|
||||
---
|
||||
|
||||
## 一、TAF 在项目中的定位
|
||||
|
||||
在 PolyWeather 里,`TAF` 不是主温度模型,也不是结算源。
|
||||
|
||||
它的定位是:
|
||||
|
||||
1. **机场侧确认层**
|
||||
- 用来补充说明机场在峰值窗口附近会不会出现云、雨、雷暴或风向切换。
|
||||
|
||||
2. **压温 / 扰动风险提示层**
|
||||
- 用来判断“机场最高温是否可能因为天气扰动被压低”。
|
||||
|
||||
3. **走势图时间轴联动层**
|
||||
- 用来在温度走势图上标出 `FM / TEMPO / BECMG / PROB30/40` 对应的时段。
|
||||
|
||||
它**不负责**:
|
||||
|
||||
1. 直接提供多模型最高温数值
|
||||
2. 替代 `DEB`
|
||||
3. 替代机场实况 `METAR`
|
||||
4. 替代官方结算源
|
||||
|
||||
---
|
||||
|
||||
## 二、后端当前怎么解析 TAF
|
||||
|
||||
后端入口在:
|
||||
|
||||
- [web/analysis_service.py](/E:/web/PolyWeather/web/analysis_service.py)
|
||||
|
||||
核心函数:
|
||||
|
||||
- `_build_taf_signal(...)`
|
||||
|
||||
当前会解析这些时间片:
|
||||
|
||||
1. `BASE`
|
||||
2. `FM`
|
||||
3. `TEMPO`
|
||||
4. `BECMG`
|
||||
5. `PROB30`
|
||||
6. `PROB40`
|
||||
7. `PROB30 TEMPO`
|
||||
8. `PROB40 TEMPO`
|
||||
|
||||
并且只聚焦于:
|
||||
|
||||
- **峰值窗口前后**
|
||||
|
||||
当前窗口定义是:
|
||||
|
||||
- `peak.first_h - 2h`
|
||||
- 到
|
||||
- `peak.last_h + 1h`
|
||||
|
||||
也就是说,TAF 不是整段报文全量平铺,而是会优先关注**和今天高温兑现最相关的时段**。
|
||||
|
||||
---
|
||||
|
||||
## 三、当前后端真正产出的核心字段
|
||||
|
||||
### 1. `suppression_level`
|
||||
|
||||
表示机场端的**压温风险等级**。
|
||||
|
||||
当前逻辑来自:
|
||||
|
||||
1. 降水 / 雷暴关键词
|
||||
- `TSRA`
|
||||
- `TS`
|
||||
- `VCTS`
|
||||
- `SHRA`
|
||||
- `SHSN`
|
||||
- `SHGS`
|
||||
- `RA`
|
||||
- `DZ`
|
||||
- `SN`
|
||||
|
||||
2. 低云底
|
||||
- 只对 `BKN / OVC` 生效
|
||||
- 如果最低云底 `<= 4000 ft`
|
||||
- 会把原本 `low` 的压温风险至少抬到 `medium`
|
||||
|
||||
注意:
|
||||
|
||||
- 不是所有 `FEW / SCT / BKN / OVC` 都会直接把风险打到 `high`
|
||||
- 当前实现里,**低云主要是把风险从 `low` 抬到 `medium`**
|
||||
- 真正更容易触发 `high` 的,还是阵雨 / 雷暴类关键词
|
||||
|
||||
### 2. `disruption_level`
|
||||
|
||||
表示峰值窗口附近的**扰动程度**。
|
||||
|
||||
当前逻辑:
|
||||
|
||||
1. 这些时间片会至少把扰动抬到 `medium`
|
||||
- `TEMPO`
|
||||
- `BECMG`
|
||||
- `PROB30`
|
||||
- `PROB40`
|
||||
- `PROB30 TEMPO`
|
||||
- `PROB40 TEMPO`
|
||||
|
||||
2. 这些情况会把扰动抬到 `high`
|
||||
- `PROB30 TEMPO`
|
||||
- `PROB40 TEMPO`
|
||||
- 或者该时段本身就出现强降水/雷暴类关键词
|
||||
|
||||
注意:
|
||||
|
||||
- **`TEMPO` 本身不等于 `high`**
|
||||
- **`PROB40` 也不等于“确定发生”**
|
||||
|
||||
---
|
||||
|
||||
## 四、前端怎么展示
|
||||
|
||||
前端主要在:
|
||||
|
||||
- [frontend/components/dashboard/FutureForecastModal.tsx](/E:/web/PolyWeather/frontend/components/dashboard/FutureForecastModal.tsx)
|
||||
- [frontend/lib/dashboard-utils.ts](/E:/web/PolyWeather/frontend/lib/dashboard-utils.ts)
|
||||
|
||||
当前会通过三种方式展示 TAF:
|
||||
|
||||
### 1. 图表时间轴标记
|
||||
|
||||
在日内温度走势图上,当前会显示:
|
||||
|
||||
- `TAF 时段 / TAF Timing`
|
||||
|
||||
tooltip 会显示该时段摘要,例如:
|
||||
|
||||
- `基础时段 13:00-19:00 以稳定为主`
|
||||
- `明确切换 15:00-21:00 以稳定为主`
|
||||
- `临时波动 14:00-17:00 有云雨扰动`
|
||||
|
||||
### 2. 今日日内结构信号里的 `机场预报`
|
||||
|
||||
会显示类似:
|
||||
|
||||
- `防压温`
|
||||
- `看云雨`
|
||||
- `暂稳`
|
||||
|
||||
### 3. 顶部摘要与交易动作
|
||||
|
||||
系统会把:
|
||||
|
||||
1. 近地面结构
|
||||
2. 高空结构
|
||||
3. `TAF`
|
||||
4. `market_signal / edge_percent / bucket crowding`
|
||||
|
||||
合并成更贴近交易的提示,例如:
|
||||
|
||||
- `偏暖侧`
|
||||
- `偏谨慎`
|
||||
- `先观察`
|
||||
|
||||
---
|
||||
|
||||
## 五、TAF 关键词当前在项目里的真实含义
|
||||
|
||||
| TAF 关键词 | 当前展示词 | 当前项目含义 |
|
||||
| :-- | :-- | :-- |
|
||||
| `BASE` | 基础时段 | 在第一个显式变化组出现前的默认背景天气段 |
|
||||
| `FM` | 明确切换 | 从某个明确时刻开始,机场预报进入一套新的天气阶段 |
|
||||
| `TEMPO` | 临时波动 | 一段短时、非整段主导的扰动窗口 |
|
||||
| `BECMG` | 逐步转变 | 天气在该窗口内逐步过渡,不是立刻硬切 |
|
||||
| `PROB30/40` | 30% / 40% 风险窗 | 有概率触发的扰动窗口,不等于确定发生 |
|
||||
|
||||
注意:
|
||||
|
||||
- `FM` 不是由 `valid_match` 触发,它是按 `FMddhhmm` 独立解析出来的明确切换段
|
||||
- `PROB40` 不是“确定性信号”,仍然只是概率窗口
|
||||
|
||||
---
|
||||
|
||||
## 六、怎么理解“机场端压温风险偏高”
|
||||
|
||||
这句话的意思不是:
|
||||
|
||||
- 城区一定更冷
|
||||
- 一定会结算更低
|
||||
|
||||
真正意思是:
|
||||
|
||||
**在机场这个结算相关站点上,峰值窗口附近更容易因为云、阵雨或雷暴,导致最终最高温冲不上去。**
|
||||
|
||||
对于很多按机场报文或机场相关站点结算的市场,这一点很关键。
|
||||
|
||||
一句话:
|
||||
|
||||
- `TAF` 提示压温高
|
||||
- 不代表一定下雨
|
||||
- 但代表“机场高温可能被压低”的概率更值得防
|
||||
|
||||
---
|
||||
|
||||
## 七、怎么和图表一起看
|
||||
|
||||
### 情况 A:模型还偏热,但 TAF 给出压温高
|
||||
|
||||
这表示:
|
||||
|
||||
1. 数值模型仍给出较高高温
|
||||
2. 但机场端预报提示云雨/雷暴会打断峰值兑现
|
||||
|
||||
这种情况下,更适合理解成:
|
||||
|
||||
- **机场侧高温兑现有风险**
|
||||
- 追更高温区间要谨慎
|
||||
|
||||
### 情况 B:TAF 没有新增压温,但总判断仍偏降温
|
||||
|
||||
这表示:
|
||||
|
||||
1. `TAF` 没有提供新的云雨压温利空
|
||||
2. 但近地面窗口本身已经在走弱
|
||||
|
||||
比如:
|
||||
|
||||
- 温度走弱
|
||||
- 风场切换
|
||||
- 气压回升
|
||||
- 露点回落
|
||||
|
||||
所以:
|
||||
|
||||
- `TAF 无压温`
|
||||
- **不等于**
|
||||
- `一定继续升温`
|
||||
|
||||
当前系统已经会在摘要里把这层关系解释清楚。
|
||||
|
||||
---
|
||||
|
||||
## 八、当前实现边界
|
||||
|
||||
这套 `TAF` 逻辑当前是**交易导向的轻量解码**,不是完整航空专业解码器。
|
||||
|
||||
当前做得到:
|
||||
|
||||
1. 识别主要时间片
|
||||
2. 找出峰值窗口附近的扰动
|
||||
3. 识别云雨压温和风向切换
|
||||
4. 联动图表时间轴
|
||||
5. 联动交易动作
|
||||
|
||||
当前还没有做:
|
||||
|
||||
1. 对全部 TAF 语法做完整航空级严格解释
|
||||
2. 对每个时段都做完整的逐字段人工预报学解释
|
||||
3. 把 TAF 当成温度主预测模型
|
||||
|
||||
---
|
||||
|
||||
## 九、产品口径总结
|
||||
|
||||
最重要的一句:
|
||||
|
||||
**在 PolyWeather 里,TAF 是“机场侧扰动确认层”,不是主模型,也不是结算源。**
|
||||
|
||||
它最值钱的地方是:
|
||||
|
||||
- 帮你判断峰值窗口附近,机场高温会不会被云雨、雷暴、低云或风向切换打断。
|
||||
|
||||
对交易来说,更适合把它理解成:
|
||||
|
||||
- “机场这边有没有额外的压温风险”
|
||||
|
||||
而不是:
|
||||
|
||||
- “TAF 直接告诉我今天结算温度是多少”
|
||||
|
||||
---
|
||||
|
||||
_PolyWeather 文档中心_
|
||||
+54
-65
@@ -1,95 +1,84 @@
|
||||
# Technical Debt Backlog
|
||||
# 技术债与工程待办(v1.5.1)
|
||||
|
||||
Purpose: keep engineering debt explicit while shipping production features.
|
||||
最后更新:`2026-03-20`
|
||||
|
||||
---
|
||||
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
|
||||
|
||||
## 1. Debt Landscape
|
||||
## 1. 债务快照
|
||||
|
||||
当前估计:**95% 稳定 / 5% 技术债**。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Tech Debt"]
|
||||
A["技术债"]
|
||||
|
||||
subgraph AR["Architecture"]
|
||||
AR1["Monolithic bot entry"]
|
||||
AR2["Shared runtime coupling"]
|
||||
subgraph P["支付与订阅"]
|
||||
P1["合约 V2 升级(SafeERC20 / Pausable)"]
|
||||
P2["退款与工单流程"]
|
||||
P3["多 RPC 与链上对账面板"]
|
||||
end
|
||||
|
||||
subgraph PI["Product Infra"]
|
||||
PI1["Entitlement hardening"]
|
||||
PI2["Subscription persistence"]
|
||||
subgraph E["权限与运营"]
|
||||
E1["前后端/Bot 权限矩阵回归"]
|
||||
E2["积分来源明细与补分审计"]
|
||||
end
|
||||
|
||||
subgraph Q["Quality"]
|
||||
Q1["Replay harness"]
|
||||
Q2["Broader regression tests"]
|
||||
subgraph O["可观测性"]
|
||||
O1["外部监控抓取与告警阈值"]
|
||||
O2["业务监控看板"]
|
||||
end
|
||||
|
||||
subgraph O["Observability"]
|
||||
O1["Alert evidence trace"]
|
||||
O2["SLO dashboards"]
|
||||
subgraph S["状态与概率"]
|
||||
S1["SQLite dual -> sqlite 切换验收"]
|
||||
S2["EMOS shadow -> primary 门禁稳定化"]
|
||||
end
|
||||
|
||||
A --> AR
|
||||
A --> PI
|
||||
A --> Q
|
||||
A --> P
|
||||
A --> E
|
||||
A --> O
|
||||
A --> S
|
||||
```
|
||||
|
||||
Current system health estimate: **86% stable / 14% debt**.
|
||||
## 2. 近期已关闭
|
||||
|
||||
---
|
||||
- 支付主链路已上线(intent -> submit -> confirm)。
|
||||
- 支付自动补单已上线(Event Loop + Confirm Loop)。
|
||||
- 支付事件重放脚本已补齐。
|
||||
- 支付运行态 API 与 SQLite 审计事件已补齐。
|
||||
- 钱包绑定支持浏览器钱包 + WalletConnect。
|
||||
- 账户中心与 Pro 权限展示链路打通。
|
||||
- 钱包异动支持独立频道路由。
|
||||
- 运行态状态/缓存已支持 SQLite 渐进迁移。
|
||||
- 轻量可观测性已上线(`/healthz`、`/api/system/status`、`/metrics`)。
|
||||
- EMOS/CRPS 校准链路已上线 shadow 模式。
|
||||
|
||||
## 2. Recently Closed (2026-03-12)
|
||||
## 3. 高优先级技术债
|
||||
|
||||
- Meteoblue API path fully removed from backend, frontend, config and docs.
|
||||
- Market top-bucket duplicate temperature issue fixed (backend dedupe + frontend guard).
|
||||
- Detail panel a11y conflict fixed (`aria-hidden` focus conflict resolved with `inert` + blur).
|
||||
- Vercel Speed Insights integrated for frontend performance telemetry.
|
||||
- Frontend BFF `ETag + Cache-Control` landed for cities/summary/history (`force_refresh` keeps `no-store`).
|
||||
- Mispricing radar now hard-skips non-tradable markets (closed/inactive/not accepting orders/past endDate).
|
||||
- AI analysis now includes peak-window hard constraints (before-window cannot claim "locked"/"confirmed floor").
|
||||
|
||||
---
|
||||
|
||||
## 3. High Priority Debt
|
||||
|
||||
| Item | Impact | Suggested Work |
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| Monolithic bot entry (`bot_listener.py`) | Hard to test and safely refactor | Split orchestration, IO and analysis modules |
|
||||
| Entitlement enforcement consistency | Revenue leakage risk | Align frontend middleware and backend enforcement |
|
||||
| Subscriber persistence model | Manual operations do not scale | Move to managed PostgreSQL/Supabase state |
|
||||
| Alert explainability | Operator trust and debugging cost | Standardize evidence payload per alert |
|
||||
| SQLite 主读切换验收 | 仍处于 dual 过渡期 | 线上跑满 24-48 小时后切到 `sqlite` |
|
||||
| EMOS 上线门禁 | 当前 `hold`,不能切 primary | 继续积累样本,重点压 `bucket_brier` |
|
||||
| 外部监控与告警 | 只有轻量指标,无外部抓取 | 接 Prometheus/Grafana 或最小巡检 |
|
||||
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
|
||||
|
||||
---
|
||||
## 4. 中优先级技术债
|
||||
|
||||
## 4. Medium Priority Debt
|
||||
|
||||
| Item | Impact | Suggested Work |
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| Replay simulation harness | Hard to reproduce edge cases | Build deterministic replay over stored records |
|
||||
| Chart/UI regression coverage | Visual regressions can slip | Add snapshot + interaction test coverage |
|
||||
| Config centralization | Threshold changes are error-prone | Consolidate runtime knobs into structured config |
|
||||
| Naming cleanup | Legacy terms reduce clarity | Refactor naming in market/weather boundary layer |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/奖励/手动补分) |
|
||||
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
|
||||
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
|
||||
|
||||
---
|
||||
## 5. 低优先级技术债
|
||||
|
||||
## 5. Low Priority Debt
|
||||
|
||||
| Item | Impact | Suggested Work |
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| Cold-start behavior | First request latency variance | Add warming strategy for top city routes |
|
||||
| Storage abstraction | Local file assumptions remain | Continue moving state to remote services |
|
||||
| 前端离线缓存能力 | 非核心 | 评估 Service Worker + IndexedDB |
|
||||
| 冷启动波动 | 首屏抖动 | 热点城市预热 |
|
||||
|
||||
---
|
||||
## 6. 下阶段里程碑
|
||||
|
||||
## 6. Next Milestones
|
||||
|
||||
1. Entitlement parity: one policy across frontend and backend.
|
||||
2. Subscriber DB integration and migration scripts.
|
||||
3. Alert evidence schema + tooling for quick operator audit.
|
||||
4. Replay runner for weather/market mixed regression scenarios.
|
||||
|
||||
---
|
||||
|
||||
Last Updated: `2026-03-12`
|
||||
1. 完成 SQLite 从 `dual` 到 `sqlite` 的主读切换。
|
||||
2. 稳定 EMOS shadow,达到 rollout `observe/promote` 条件。
|
||||
3. 补外部监控抓取与告警阈值。
|
||||
4. 评估并推进支付合约 V2 升级。
|
||||
|
||||
+47
-58
@@ -1,95 +1,84 @@
|
||||
# 技术债与工程待办
|
||||
# 技术债与工程待办(v1.5.1)
|
||||
|
||||
目标:在持续交付的同时,把关键技术债显式化、可追踪化。
|
||||
最后更新:`2026-03-20`
|
||||
|
||||
---
|
||||
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
|
||||
|
||||
## 1. 技术债全景
|
||||
## 1. 债务快照
|
||||
|
||||
当前估计:**95% 稳定 / 5% 技术债**。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["技术债"]
|
||||
|
||||
subgraph AR["架构层"]
|
||||
AR1["机器人入口过于集中"]
|
||||
AR2["共享运行时耦合"]
|
||||
subgraph P["支付与订阅"]
|
||||
P1["合约 V2 升级(SafeERC20 / Pausable)"]
|
||||
P2["退款与工单流程"]
|
||||
P3["多 RPC 与链上对账面板"]
|
||||
end
|
||||
|
||||
subgraph PI["产品基础设施"]
|
||||
PI1["订阅权限一致性"]
|
||||
PI2["付费用户持久化"]
|
||||
end
|
||||
|
||||
subgraph Q["质量保障"]
|
||||
Q1["回放测试能力"]
|
||||
Q2["UI 回归覆盖不足"]
|
||||
subgraph E["权限与运营"]
|
||||
E1["前后端/Bot 权限矩阵回归"]
|
||||
E2["积分来源明细与补分审计"]
|
||||
end
|
||||
|
||||
subgraph O["可观测性"]
|
||||
O1["告警证据链"]
|
||||
O2["SLO 看板"]
|
||||
O1["外部监控抓取与告警阈值"]
|
||||
O2["业务监控看板"]
|
||||
end
|
||||
|
||||
A --> AR
|
||||
A --> PI
|
||||
A --> Q
|
||||
subgraph S["状态与概率"]
|
||||
S1["SQLite dual -> sqlite 切换验收"]
|
||||
S2["EMOS shadow -> primary 门禁稳定化"]
|
||||
end
|
||||
|
||||
A --> P
|
||||
A --> E
|
||||
A --> O
|
||||
A --> S
|
||||
```
|
||||
|
||||
当前系统健康度估计:**86% 稳定 / 14% 技术债**。
|
||||
## 2. 近期已关闭
|
||||
|
||||
---
|
||||
|
||||
## 2. 最近已关闭项(2026-03-12)
|
||||
|
||||
- Meteoblue API 全链路移除(后端/前端/配置/文档)。
|
||||
- 市场温度桶重复刷屏问题修复(后端去重 + 前端兜底)。
|
||||
- 详情面板可访问性告警修复(`aria-hidden` 焦点冲突改为 `inert + blur`)。
|
||||
- 前端已接入 Vercel Speed Insights。
|
||||
- 前端 BFF 增加 `ETag + Cache-Control`(cities/summary/history)与 `force_refresh=no-store` 语义。
|
||||
- 错价雷达增加“不可交易市场硬拦截”(closed/inactive/不接单/过 endDate)。
|
||||
- AI 分析增加峰值时段硬约束(before 状态禁止“已锁定/已确认底线”)。
|
||||
|
||||
---
|
||||
- 支付主链路已上线(intent -> submit -> confirm)。
|
||||
- 支付自动补单已上线(Event Loop + Confirm Loop)。
|
||||
- 支付事件重放脚本已补齐。
|
||||
- 支付运行态 API 与 SQLite 审计事件已补齐。
|
||||
- 钱包绑定支持浏览器钱包 + WalletConnect。
|
||||
- 账户中心与 Pro 权限展示链路打通。
|
||||
- 钱包异动支持独立频道路由。
|
||||
- 运行态状态/缓存已支持 SQLite 渐进迁移。
|
||||
- 轻量可观测性已上线(`/healthz`、`/api/system/status`、`/metrics`)。
|
||||
- EMOS/CRPS 校准链路已上线 shadow 模式。
|
||||
|
||||
## 3. 高优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 机器人入口单体化(`bot_listener.py`) | 测试和重构风险高 | 拆分为编排层、IO 层、分析层 |
|
||||
| 订阅权限策略不完全统一 | 可能造成付费泄露 | 前后端统一权限校验策略 |
|
||||
| 付费用户状态持久化不足 | 人工运营不可扩展 | 迁移到托管 DB(PostgreSQL/Supabase) |
|
||||
| 告警可解释性不足 | 运维排障成本高 | 统一告警证据字段(Evidence Schema) |
|
||||
|
||||
---
|
||||
| SQLite 主读切换验收 | 仍处于 dual 过渡期 | 线上跑满 24-48 小时后切到 `sqlite` |
|
||||
| EMOS 上线门禁 | 当前 `hold`,不能切 primary | 继续积累样本,重点压 `bucket_brier` |
|
||||
| 外部监控与告警 | 只有轻量指标,无外部抓取 | 接 Prometheus/Grafana 或最小巡检 |
|
||||
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
|
||||
|
||||
## 4. 中优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 回放仿真能力不足 | 边缘场景难复现 | 基于历史记录构建可重复 Replay |
|
||||
| 图表/UI 回归覆盖不足 | 视觉回归风险 | 增加快照与交互自动化测试 |
|
||||
| 阈值配置分散 | 改动成本高且易错 | 统一收口到结构化配置 |
|
||||
| 命名历史包袱 | 认知成本高 | 系统化命名治理 |
|
||||
|
||||
---
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/奖励/手动补分) |
|
||||
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
|
||||
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
|
||||
|
||||
## 5. 低优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 冷启动波动 | 首次请求延迟不稳定 | 热点城市路由预热 |
|
||||
| 本地文件状态依赖 | 云端弹性场景受限 | 持续迁移到远程存储 |
|
||||
|
||||
---
|
||||
| 前端离线缓存能力 | 非核心 | 评估 Service Worker + IndexedDB |
|
||||
| 冷启动波动 | 首屏抖动 | 热点城市预热 |
|
||||
|
||||
## 6. 下阶段里程碑
|
||||
|
||||
1. 完成前后端订阅权限一致化。
|
||||
2. 上线付费用户持久化与迁移脚本。
|
||||
3. 建立告警证据标准并接入运维排障流。
|
||||
4. 落地天气+市场混合回放回归测试。
|
||||
|
||||
---
|
||||
|
||||
最后更新:`2026-03-12`
|
||||
1. 完成 SQLite 从 `dual` 到 `sqlite` 的主读切换。
|
||||
2. 稳定 EMOS shadow,达到 rollout `observe/promote` 条件。
|
||||
3. 补外部监控抓取与告警阈值。
|
||||
4. 评估并推进支付合约 V2 升级。
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
# PolyWeather 深度评估与改进提案报告
|
||||
|
||||
## 执行摘要
|
||||
|
||||
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的“生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution)**,并进一步映射到市场报价完成**错价扫描**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 明确其“Open-Core”边界:仓库公开天气聚合、基础分析、看板、Bot、标准支付流程;生产私有部分包含商业风控、阈值与运营工具等。
|
||||
从工程实现看,截至 `2026-03-21`,项目已经完成一轮明确的工程化收口:多源天气采集仍保持现有业务能力,同时已完成采集层与 Web API 大文件拆分、CI 质量门禁、配置分级(`.env.example` / `.env.secrets.example` / 中文部署文档)、EMOS/CRPS 校准链路、运行态状态与缓存向 SQLite 的渐进迁移,以及基础可观测性接口(`/healthz`、`/api/system/status`、`/metrics`)。
|
||||
这意味着报告里最初最突出的“工程地基缺失”问题,已经有一部分被关闭:`src/data_collection/weather_sources.py` 与 `web/app.py` 不再是原来的超大单文件;GitHub Actions 已覆盖 Python、前端和 Docker build;配置与密钥治理已成体系;运行态状态不再只能依赖 JSON/JSONL 文件;EMOS 也不再只是概念,而是进入了可训练、可评估、可 shadow、可门禁判断的阶段。
|
||||
但项目仍处在“从可用走向稳态”的中段,而不是终局。当前真正的高优先级问题已收敛为三类:第一,**SQLite 迁移仍处于推荐的 dual 过渡模式**,线上真正切主读路径前仍需跑一段时间验证;第二,**可观测性只完成了轻量级指标层**,还没有形成完整的外部监控、阈值告警与趋势面板;第三,**EMOS 仍未达到生产切换标准**,当前门禁结论明确为 `hold`,阻塞原因是 shadow bucket brier 明显退化。支付链路方面,链下审计与容灾已明显增强:事件重放、SQLite 审计事件、RPC 多节点容灾、合约静态检查、`/ops` 支付异常单、按邮箱恢复脚本都已补齐;当前剩余风险主要集中在**链上合约本身仍是最小实现**,尚未升级到 SafeERC20、Pausable、链上套餐绑定等更强防护版本。
|
||||
因此,当前阶段最正确的策略已经不是继续做“大范围基础重构”,而是围绕**迁移验收、可观测性补全、EMOS 上线门禁稳定化**这三条线持续收口。短中期内更高 ROI 的方向依然不是引入新的大模型,而是把现有“采集→后处理→市场映射→支付/订阅”的链路做成**状态一致、指标可见、发布可控、回退明确**的生产平台。
|
||||
## 项目概览
|
||||
|
||||
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
|
||||
项目主功能可归纳为四层:
|
||||
**天气层(数据源/采集)**:聚合 20 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报)、美国 NWS(仅美国城市)、以及部分城市使用官方结算源(香港 HKO、台北 CWA)等。
|
||||
**分析层(DEB/趋势/概率/结算口径)**:
|
||||
DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;同时维护 `daily_records.json` 做历史对账、命中率/MAE 统计,并支持基于 WU(Weather Underground 口径)四舍五入的结算命中评估。
|
||||
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
|
||||
**市场层(Polymarket 行情对照)**:只读模式从 Gamma API 发现市场、从 CLOB(`py-clob-client` 或 REST 回退)读取价格/盘口并计算 edge(模型概率 − 市场概率)生成信号标签。
|
||||
**商业化与支付**:订阅(`Pro Monthly 5 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
|
||||
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
|
||||
**许可证**:仓库根目录 `LICENSE` 为 MIT。 同时 README 强调 Open-Core 策略与生产私有组件边界,意味着“可复现/可审计”的范围以公开部分为准。
|
||||
(插图:项目 README 中包含产品截图,可用于快速理解信息架构与 UI 形态)
|
||||

|
||||
|
||||
## 架构与代码库分析
|
||||
|
||||
### 代码库模块地图
|
||||
|
||||
从 README、Docker/Compose、入口脚本与核心模块引用关系,可以抽象出如下模块地图(按“运行时组件”与“Python 域模块”两层描述):
|
||||
| 层级 | 目录/文件 | 角色定位 | 关键说明 |
|
||||
| ------------- | ------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 运行时组件 | `frontend/` | Next.js 前端(Vercel) | 前端重构报告提到 App Router、Route Handlers(BFF)、缓存策略、支付与账户中心等。 |
|
||||
| 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层。 |
|
||||
| 运行时组件 | `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`、`nws_open_meteo_sources.py`。 |
|
||||
| Python 域模块 | `src/analysis/*` | DEB/趋势/概率/结算口径 | `deb_algorithm.py`、`trend_engine.py`、`settlement_rounding.py`。 |
|
||||
| Python 域模块 | `src/analysis/probability_calibration.py` + `src/analysis/probability_rollout.py` | 概率校准与上线门禁 | 已支持 `legacy / emos_shadow / emos_primary`,并可产出 rollout 判断。 |
|
||||
| Python 域模块 | `src/payments/*` + `contracts/*` | 支付合约 + 事件监听/补单 | Solidity 合约 + Python 侧事件扫描/确认循环 + SQLite 审计事件 + RPC 多节点容灾 + 合约静态检查。 |
|
||||
| Python 域模块 | `src/auth/*`、`docs/SUPABASE_SETUP_ZH.md`、`scripts/supabase/schema.sql` | Supabase 鉴权/订阅/积分 | 使用 `/auth/v1/user` 校验 JWT、`/rest/v1/subscriptions` 查订阅(服务端角色 key 必须保密)。 |
|
||||
| Python 域模块 | `src/database/runtime_state.py` | 运行态状态与缓存仓储 | 已接入 `daily_records`、`telegram_alert_state`、`probability_training_snapshots`、`open_meteo` 持久缓存。 |
|
||||
| 工程与运维 | `docker-compose.yml`、`Dockerfile`、`.github/workflows/ci.yml`、`scripts/*` | 部署/验证脚本 | 现已具备 CI 门禁、迁移脚本、状态校验脚本、配置校验脚本与 rollout 报告脚本。 |
|
||||
|
||||
### 参考架构与关键工作流
|
||||
|
||||
项目 README 给出了一版 mermaid 参考架构图(Web/Telegram→FastAPI→采集→分析→支付/市场层)。 在此基础上,结合 `StartupCoordinator` 的 loop 启动与支付监听逻辑,可补充一个更“运行时视角”的架构图:
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Clients
|
||||
WEB[Next.js Frontend<br/>Vercel]
|
||||
TG[Telegram Bot<br/>TeleBot + Handlers]
|
||||
end
|
||||
|
||||
subgraph API
|
||||
FAST[FastAPI<br/>web/app.py]
|
||||
end
|
||||
|
||||
subgraph Data
|
||||
WX[WeatherDataCollector]
|
||||
CITY[CITY_REGISTRY]
|
||||
HIST[(SQLite runtime state<br/>daily_records / cache / snapshots)]
|
||||
JSON[Legacy JSON files<br/>dual-mode fallback]
|
||||
end
|
||||
|
||||
subgraph ExternalAPIs
|
||||
OM[Open-Meteo Forecast/Ensemble/Multi-model]
|
||||
AW[AviationWeather Data API<br/>METAR]
|
||||
MGM[MGM Turkey]
|
||||
NWS[api.weather.gov]
|
||||
HKO[data.weather.gov.hk]
|
||||
CWA[opendata.cwa.gov.tw]
|
||||
PM_G[Polymarket Gamma API]
|
||||
PM_C[Polymarket CLOB API]
|
||||
SB[Supabase Auth/REST]
|
||||
RPC[Polygon RPC]
|
||||
end
|
||||
|
||||
subgraph Payments
|
||||
SOL[PolyWeatherCheckout.sol]
|
||||
EVT[event_loop<br/>scan logs]
|
||||
CF[confirm_loop<br/>confirm intents]
|
||||
end
|
||||
|
||||
WEB --> FAST
|
||||
TG --> FAST
|
||||
TG -->|StartupCoordinator<br/>starts loops| EVT
|
||||
TG --> CF
|
||||
|
||||
FAST --> WX
|
||||
WX --> OM
|
||||
WX --> AW
|
||||
WX --> MGM
|
||||
WX --> NWS
|
||||
WX --> HKO
|
||||
WX --> CWA
|
||||
|
||||
FAST --> PM_G
|
||||
FAST --> PM_C
|
||||
|
||||
FAST --> SB
|
||||
EVT --> RPC
|
||||
CF --> RPC
|
||||
RPC --> SOL
|
||||
|
||||
WX --> HIST
|
||||
WX --> JSON
|
||||
FAST --> HIST
|
||||
WX --> CITY
|
||||
```
|
||||
|
||||
### 依赖与运行环境
|
||||
|
||||
**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` 是前端依赖来源。
|
||||
### 数据预处理、模型与“训练/推理”管线
|
||||
|
||||
本项目的“模型”主要是统计融合与规则/启发式引擎,而非深度网络训练:
|
||||
**天气数据预处理**:`WeatherDataCollector` 内部做了大量“输入清洗+缓存+退避”的工程处理:
|
||||
包含 Open-Meteo 三类缓存(forecast/ensemble/multi_model)、429 冷却期、最小调用间隔、磁盘持久化缓存文件(重启后避免冷启动打爆 API)、以及 METAR/结算源缓存。
|
||||
**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 消息格式化。
|
||||
**检查点(checkpoints)**:传统 ML checkpoint 不适用;但项目现已形成两类“业务状态 checkpoint”:
|
||||
(a)SQLite 运行态存储(推荐主路径);(b)legacy JSON/JSONL 文件(迁移期回退路径)。当前设计是 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`,建议先以 `dual` 运行,再切到 `sqlite`。
|
||||
### 测试、CI/CD 与运维验证
|
||||
|
||||
**测试**:仓库存在 `tests/test_trend_engine.py`,覆盖 μ 计算、死盘判定、预报崩盘提示、趋势方向等核心逻辑(通过 patch 隔离外部依赖)。
|
||||
**CI/CD**:已补齐 GitHub Actions 工作流,至少覆盖 Python lint/test、前端 build、Docker build 三条门禁;当前缺口不再是“有没有 CI”,而是“是否已在 GitHub 分支保护中强制执行”。
|
||||
**运维验收**:除 `scripts/validate_frontend_cache.sh` 外,现已新增配置校验、运行态迁移/核验、EMOS rollout 判断等脚本,并提供 `/healthz`、`/api/system/status`、`/metrics` 作为基础观测入口。
|
||||
**部署/更新**:Compose 用于启动服务;另有 `update.sh` 通过 `pkill` + `nohup` 重启 bot 与 web。
|
||||
## 优势与薄弱点
|
||||
|
||||
### 优势
|
||||
|
||||
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。
|
||||
**复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。
|
||||
**面向外部 API 的工程防护意识较强**:Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。
|
||||
**支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。
|
||||
### 薄弱点与风险
|
||||
|
||||
**核心文件过大问题已明显缓解,但边界仍需继续稳定**:`WeatherDataCollector` 与 `web/app.py` 的超大文件问题已完成第一阶段拆分;当前风险已从“文件过大”转为“跨模块兼容与边界稳定性”,例如旧调用路径、兼容导出、跨层 helper 仍需持续清理。
|
||||
**可复现性已从“缺模板”进入“模板与生产对齐”的阶段**:`.env.example`、`.env.secrets.example`、中文配置文档、前端部署文档、运行时配置校验器都已存在;当前风险主要在于线上历史 `.env` 与新模板并存、旧变量命名残留、以及密钥轮换与分层是否真正落实。
|
||||
**CI 已建立,但组织级质量门禁未必完全收口**:CI 现已覆盖 Python、前端与 Docker build。当前问题不再是“缺 CI”,而是是否把这些 status check 绑定到 `main` 保护策略,以及是否逐步引入更严格的 pre-merge 审查。
|
||||
**运行态状态/缓存迁移仍处于过渡期**:`daily_records`、`telegram_alert_state`、`probability_training_snapshots`、`open_meteo` 缓存已经支持 SQLite,并有迁移/校验脚本;但在正式切到 `sqlite` 主读路径前,仍需经历一段 dual 双写验证期。这是当前最需要谨慎处理的工程性风险之一。
|
||||
**第三方服务合规与稳定性风险**:
|
||||
项目强依赖外部 API(Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase)。其中 AviationWeather Data API 有明确速率限制;Polymarket 官方说明 Gamma/Data/CLOB 三套 API 分属不同域,CLOB 交易端点需鉴权且策略可能变化;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。
|
||||
**可观测性已起步,但仍不构成完整监控体系**:项目现在已有 `/healthz`、`/api/system/status`、`/metrics`,并为 HTTP 与关键第三方源增加了轻量指标;但仍缺少 Prometheus/Grafana 级别的外部抓取、告警阈值、趋势面板和运行日报。这部分现在属于“已开始,不算完成”。
|
||||
**EMOS 已完成工程接入,但未完成生产发布**:EMOS/CRPS 校准、shadow 观测、rollout report、上线门禁都已实现;当前真实门禁结果为 `hold`,阻塞原因是 shadow bucket brier 明显退化。因此概率引擎标准化并非未做,而是“工程完成、发布未通过”。
|
||||
**许可证/商业使用的潜在冲突点**:仓库自身是 MIT,但如果未来尝试引入外部 AI 预报模型,需要非常谨慎:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
|
||||
## 对标分析
|
||||
|
||||
为满足“至少 3 个相似开源项目或近期论文”对标,本报告选择三类代表:
|
||||
1)**AI 气象预报模型**(GraphCast / FourCastNet / Pangu-Weather):用于评估“若 PolyWeather 未来扩展到更强预测能力”的技术与许可边界;
|
||||
2)**概率后处理方法**(EMOS):作为 PolyWeather 概率引擎的更标准化替代/对照;
|
||||
3)**预测市场 API 客户端生态**(Polymarket/py-clob-client、aiopolymarket):用于评估市场层的工程选型。
|
||||
### 关键对比表
|
||||
|
||||
| 项目/论文 | 解决的问题 | 输出形态 | 性能/效果(公开描述) | 易用性与依赖 | 许可证要点 |
|
||||
| --------------------------------------------------------- | ---------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| **PolyWeather**(本仓库) | 温度结算市场气象情报:多源→概率桶→错价扫描→订阅/支付 | 生产级应用(Web+Bot+API+支付) | 以工程能力为主;内置 DEB、概率桶、死盘判定、市场扫描;覆盖 20 城市。 | 主要依赖外部 API;Docker Compose 一键启动。 | 仓库 MIT;Open-Core(部分生产规则私有)。 |
|
||||
| **GraphCast**(google-deepmind/graphcast) | 10 天全球中期预报(ML 替代/增强 NWP) | 模型代码+权重+notebooks | 论文与介绍提到在大量指标上优于主流确定性系统;仓库提供预训练权重与示例数据入口,并提示 ERA5/HRES 数据条款需另行遵守。 | 完整训练需 ERA5 等;更适合科研/平台级推理,不是产品级 BFF。 | 代码 Apache-2.0;权重 CC BY-NC-SA 4.0(商业限制)。 |
|
||||
| **FourCastNet**(NVlabs/FourCastNet) | 高分辨率 data-driven 全球预报(AFNO/ViT) | 模型训练/推理代码+数据/权重链接 | README 描述:0.25° 分辨率、周尺度推理非常快,并可做大规模集合;适合平台型预报。 | 训练/数据依赖大(ERA5 子集 TB 级);工程集成成本高。 | BSD 3-Clause(代码)。 |
|
||||
| **Pangu-Weather**(198808xc/Pangu-Weather + Nature 论文) | 3D Transformer 架构的中期全球预报 | ONNX 推理代码+预训练模型 | Nature 论文称在 reanalysis 上对比 IFS 有更强确定性预报表现,并强调速度优势;仓库提供 ONNX 推理与 lite 版训练说明。 | 模型文件大(多份 ~GB 级),训练资源需求高;更适合科研推理或内部平台。 | 权重 BY-NC-SA 4.0、明确禁止商业用途。 |
|
||||
| **EMOS**(Gneiting & Raftery 等) | 集合预报校准:纠偏与解决 underdispersion | 统计后处理方法 | 提出用回归形式输出概率分布(常见为高斯),并以 CRPS 等指标拟合,属于成熟的气象概率校准路线。 | 易落地:对 PolyWeather 而言只需“历史库+拟合器”。 | 方法论(论文);可自行实现,无额外许可约束(注意论文版权)。 |
|
||||
| **Polymarket/py-clob-client** | Polymarket CLOB 读写 SDK | Python SDK | 官方 SDK,支持 read-only 与交易接口;协议与端点在官方文档中给出。 | 易用,适合增强 PolyWeather 市场层。 | MIT。 |
|
||||
| **aiopolymarket** | Polymarket APIs 的 async 客户端 | Python async 客户端 | 强调类型安全(Pydantic)、自动分页、重试与 backoff,适合高并发与健壮性诉求。 | 适合替换/补强当前同步 requests 与自定义缓存。 | 以仓库许可为准(此处建议上线前核验)。 |
|
||||
|
||||
**对标结论**:PolyWeather 与这类“全球 AI 预报模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。
|
||||
## 优先级改进建议
|
||||
|
||||
下表按截至 `2026-03-21` 的真实状态重排优先级。已完成项不再继续列为“待做”,只保留当前仍需推进的事项。
|
||||
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 高 | **完成 SQLite 迁移切换与验收**:从 `dual` 过渡到 `sqlite` 主读路径 | 2–5 天 | 真正关闭 JSON/JSONL 并发一致性风险;状态/缓存统一入库 | 迁移校验不充分会导致线上行为漂移 | 1) 线上部署新代码 → 2) 执行迁移与校验脚本 → 3) `dual` 运行至少 24–48 小时 → 4) 校验 `/api/history`、bot 告警、snapshot、缓存都正常 → 5) 再切 `POLYWEATHER_STATE_STORAGE_MODE=sqlite` |
|
||||
| 高 | **把轻量可观测性接入外部监控与告警**:围绕 `/metrics` 建立抓取、阈值与巡检 | 3–7 天 | 不再只靠日志定位问题;可以监控第三方源错误率、缓存命中与 HTTP 延迟 | 指标不分层会导致噪音高、告警无用 | 1) 抓取 `/metrics` → 2) 先围绕 HTTP、Open-Meteo、MGM、METAR 建立最小仪表板 → 3) 为 429/403/error/stale_cache 设阈值 → 4) 增加巡检脚本或告警通道 |
|
||||
| 高 | **稳定 EMOS shadow 并收紧上线门禁** | 1–2 周 | 让概率引擎升级具备明确发布条件,避免拍脑袋切换 | 当前 shadow bucket brier 退化明显,存在误上线风险 | 1) 持续积累 snapshot 样本 → 2) 定期重训与生成 `evaluation_report` / `shadow_report` / `rollout_report` → 3) 重点压 `bucket_brier` 退化 → 4) 只有门禁从 `hold` 进入 `observe/promote` 后才考虑上线 |
|
||||
| 中 | **市场层升级为 async + 类型安全**:引入 `aiopolymarket` 或在现有层加重试/backoff/连接池 | 4–7 天 | 行情层更稳,减少短时网络抖动;更易扩展更多市场/分页 | 依赖升级带来的行为差异 | 1) 把 requests.Session 替换为 aiohttp/httpx → 2) 在 Gamma/CLOB 调用侧实现指数退避 → 3) 引入 typed models,减少解析失败 |
|
||||
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 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) 明确热修例外流程 |
|
||||
| 低 | **引入外部 AI 预报模型作为附加信号**(GraphCast/FourCastNet/Pangu-Weather 等) | 2–6 周(取决于范围) | 可能提升极端/中期预测能力与差异化 | **商业许可限制**(多为 CC BY-NC-SA/禁止商业)与算力成本 | 1) 先做合规评审(权重许可/数据条款)→ 2) 仅在研究/非商业环境评估 → 3) 若要商用,优先选择可商用权重或自研/购买授权 |
|
||||
|
||||
### 文档、测试与贡献流程的具体补强建议(落到仓库层面)
|
||||
|
||||
1)**文档体系**:保留现有中文 API/TechDebt 文档的同时,增加三份“高价值”文档:
|
||||
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级;(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、市场扫描 miss、前端缓存异常等典型故障处理。
|
||||
2)**测试金字塔**:在现有 `trend_engine` 单测基础上,补齐:
|
||||
(a)天气 provider 的“录制回放”测试(VCR 思路:固定响应→确保解析稳定);(b)市场层的契约测试(Gamma/CLOB schema 变更时提前失败);(c)支付链路的本地链集成测试(Hardhat/Anvil + 事件扫描回放)。这些测试能把“外部依赖漂移”尽量转成可控的回归失败。
|
||||
3)**贡献工作流**:引入 `CONTRIBUTING.md`(分支策略、PR 模板、变更日志、版本号策略)、`CODEOWNERS`(核心模块审查人)、`SECURITY.md`(漏洞披露与密钥处理),并把静态检查(ruff/eslint)作为 pre-commit + CI 必过项。
|
||||
## 建议实验与基准
|
||||
|
||||
PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报所有变量。建议建立两类基准:**气象预测基准(结算导向)**与**市场信号基准(交易导向)**。
|
||||
### 气象预测与概率校准基准
|
||||
|
||||
**数据集**(建议从现有生产数据演进)
|
||||
1)`daily_records.json` 的历史快照:已包含多模型预报、`actual_high`、`deb_prediction`、`mu` 与概率快照字段,天然可转成评测数据(建议迁移到 DB 后做版本化导出)。
|
||||
2)观测“真值”统一口径:对 METAR 城市用 AviationWeather Data API;对香港/台北等按结算源(HKO/CWA)作为真值,和项目当前逻辑一致。
|
||||
**指标**
|
||||
1)确定性误差:MAE、RMSE(按城市、按季节、按风险等级分组);
|
||||
2)结算命中率:`WU_round(pred) == WU_round(actual)`(项目已有统计口径);
|
||||
3)概率质量:Brier Score(对离散温度桶),以及建议补充 CRPS(连续变量概率评分,EMOS 体系常用)。
|
||||
4)校准曲线:预测概率分箱的可靠性图(reliability diagram)与 Sharpness(分布集中度)。
|
||||
**基线**
|
||||
|
||||
- Baseline A:Open-Meteo 当日最高温(或 forecast median)作为点预测;
|
||||
- Baseline B:等权平均(DEB 在历史少时也会回退此策略);
|
||||
- Baseline C:当前 DEB;
|
||||
- Baseline D:EMOS(以 ensemble 均值/方差为输入,拟合 μ 与 σ,优化 CRPS)。
|
||||
**预期结果(定性)**
|
||||
|
||||
- 若历史样本足够,DEB 应在“系统性偏差明显”的城市提升 MAE;
|
||||
- EMOS 类方法通常能在概率校准(可靠性与 CRPS)上更稳定,尤其当 ensemble 信息可用(项目已接入 Open-Meteo ensemble/p10/p90)。
|
||||
**算力**:以上评测全部可在 CPU 上完成;数据量按“20 城市 × 180 天”级别,pandas/duckdb 即可。若引入更复杂拟合(如分层贝叶斯/分位数回归),也通常不需要 GPU。
|
||||
### 错价信号与市场有效性基准
|
||||
|
||||
**数据集**
|
||||
|
||||
- 保存每次扫描输出:`date/city/bucket/model_prob/market_price/liquidity/edge`,并加上未来 `settled_bucket` 作为标签;Polymarket 市场发现与报价来自 Gamma/CLOB(官方文档说明三套 API:Gamma/Data/CLOB)。
|
||||
**指标**
|
||||
|
||||
- Signal 覆盖率:能否找到正确 market / bucket;
|
||||
- Edge 稳健性:不同流动性分位的 edge 分布;
|
||||
- 交易模拟(如需):在考虑滑点/手续费/成交概率下的期望收益(即使项目当前只读,也可以离线评估“若执行”会怎样)。
|
||||
**基线**
|
||||
|
||||
- 简单策略:仅用市场中间价(不做模型)作为概率;
|
||||
- 当前策略:模型概率 vs 市场概率 edge 阈值;
|
||||
- 改进策略:引入“流动性/盘口深度/波动”作为信号置信度(aiopolymarket/py-clob-client 提供更完整的盘口读取能力)。
|
||||
**算力**:CPU 即可;关键在于数据采样与回放。
|
||||
## 路线图与风险缓解
|
||||
|
||||
下面给出一个**12 周**(约 3 个月)的建议路线图,按“可稳定交付的工程里程碑”组织;人力以“1 名后端/数据工程 + 1 名前端(可兼职)+ 0.5 名链上工程(按需)”估算。
|
||||
| 时间窗 | 里程碑 | 交付物 | 资源/备注 |
|
||||
| ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
|
||||
| 第 1–2 周 | 工程地基:CI + 规范 + 配置可复现 | GitHub Actions;ruff/eslint;pytest 可一键跑;`.env.example`;敏感项分级说明(尤其 Supabase service role key 不可暴露)。 | 后端为主;前端补 eslint/typecheck |
|
||||
| 第 3–5 周 | 核心模块解耦:采集 Provider 化 + API 分层 | provider 接口与实现;`web/app.py` 拆分路由与服务;核心 schema(Pydantic) | 风险:行为漂移;用回放测试压住 |
|
||||
| 第 6–8 周 | 状态/缓存统一 + 可观测性 | `daily_records/open_meteo_cache` 迁移 DB;指标(请求量/429/延迟/命中率);告警阈值 | 可先用 SQLite/Redis,后续再上 Postgres |
|
||||
| 第 9–10 周 | 评测体系上线 | 离线评测脚本(MAE/RMSE/WU-hit/Brier/CRPS);日报/周报自动生成 | 直接基于项目现有字段扩展 |
|
||||
| 第 11–12 周 | 概率引擎升级(可选)+ 市场层健壮性增强 | EMOS/CRPS 拟合的 shadow 输出;Gamma/CLOB 客户端增强(async、重试、分页) | 以“小步可回滚”为原则,避免一次性替换 |
|
||||
|
||||
### 主要风险与缓解策略
|
||||
|
||||
**外部 API 速率限制/格式变更**:AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用)。
|
||||
**密钥泄露与权限滥用**:Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。
|
||||
**支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。当前项目已经补齐“事件监听 + 确认补单”双路径、事件重放脚本、SQLite 审计事件与多 RPC fallback;现阶段的主要剩余风险不再是“没有防护”,而是链上合约仍为最小实现,owner 为单地址管理,且没有 pause 开关与 SafeERC20。
|
||||
**引入外部 AI 预报模型的商业合规风险**:GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
|
||||
**Open-Core 边界导致的“公开仓库与生产行为不一致”**:README 明确生产存在私有风控与阈值。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
|
||||
## 参考链接
|
||||
|
||||
- PolyWeather 仓库(本次评估对象):https://github.com/yangyuan-zhen/PolyWeather
|
||||
- Polymarket API 文档(Gamma/Data/CLOB):https://docs.polymarket.com/api-reference
|
||||
- AviationWeather Data API(METAR 等):https://aviationweather.gov/data/api/
|
||||
- Open-Meteo Docs(Forecast):https://open-meteo.com/en/docs
|
||||
- Open-Meteo Docs(Ensemble):https://open-meteo.com/en/docs/ensemble-api
|
||||
- Supabase REST API:https://supabase.com/docs/guides/api
|
||||
- Supabase API keys(service_role 风险):https://supabase.com/docs/guides/api/api-keys
|
||||
- GraphCast(代码 Apache-2.0;权重 CC BY-NC-SA):https://github.com/google-deepmind/graphcast
|
||||
- FourCastNet(BSD-3):https://github.com/NVlabs/FourCastNet
|
||||
- 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
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# PolyWeather 支付审计与防护说明
|
||||
|
||||
最后更新:`2026-03-21`
|
||||
|
||||
## 1. 当前已落地的防护
|
||||
|
||||
### 链下运行态
|
||||
|
||||
- 支付事件扫描与确认循环已把运行态写入 SQLite:
|
||||
- `payment_runtime_state`
|
||||
- `payment_audit_events`
|
||||
- 关键循环现在会记录:
|
||||
- `event_loop_started`
|
||||
- `event_loop_cycle`
|
||||
- `event_loop_error`
|
||||
- `confirm_loop_started`
|
||||
- `confirm_loop_cycle`
|
||||
- `confirm_loop_error`
|
||||
|
||||
### 事件确认边界
|
||||
|
||||
- 后端只认链上 `OrderPaid` 事件。
|
||||
- 前端提交 intent 不会直接视为支付完成。
|
||||
- `confirm_loop` 会再次按链上交易与确认数校验 intent。
|
||||
- 若确认失败,当前会明确把 intent / transaction 落为失败态,而不是长期停留在 `submitted`。
|
||||
|
||||
当前已显式识别的失败原因包括:
|
||||
|
||||
- `receiver_mismatch`
|
||||
- `sender_mismatch`
|
||||
- `event_mismatch`
|
||||
- `tx_reverted`
|
||||
|
||||
### RPC 多节点容灾
|
||||
|
||||
- 支持 `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- 格式示例:
|
||||
|
||||
```env
|
||||
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
|
||||
```
|
||||
|
||||
- 启动时按顺序探活。
|
||||
- 当前节点断连或收据查询失败时,会自动切换到下一个可用 RPC。
|
||||
|
||||
### 事件重放
|
||||
|
||||
- 已提供脚本:
|
||||
- [replay_payment_events.py](/E:/web/PolyWeather/scripts/replay_payment_events.py)
|
||||
|
||||
用途:
|
||||
- 审计某个区块范围内的 `OrderPaid`
|
||||
- 事后补查漏单
|
||||
- 排查 RPC 抖动导致的监听遗漏
|
||||
|
||||
命令示例:
|
||||
|
||||
```bash
|
||||
python scripts/replay_payment_events.py --from-block 10000000 --to-block 10001000
|
||||
```
|
||||
|
||||
### 运行态检查
|
||||
|
||||
- 已提供接口:
|
||||
- `GET /api/payments/runtime`
|
||||
|
||||
可查看:
|
||||
- checkout 配置摘要
|
||||
- 当前活跃 RPC
|
||||
- 候选 RPC 列表
|
||||
- event loop 最新状态
|
||||
- 最近审计事件
|
||||
|
||||
### Ops 事故单
|
||||
|
||||
现在 `/ops` 已提供单独的支付异常单列表,默认展示:
|
||||
|
||||
- `payment_intent_failed`
|
||||
|
||||
支持:
|
||||
|
||||
- 按 `reason` 过滤
|
||||
- 标记已处理
|
||||
|
||||
这让下面这类事故不再需要翻日志定位:
|
||||
|
||||
- 已付款但未开通
|
||||
- 打到旧收款地址
|
||||
- 交易事件不匹配
|
||||
|
||||
## 2. 当前合约的授权边界
|
||||
|
||||
合约源码:
|
||||
- [PolyWeatherCheckout.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckout.sol)
|
||||
|
||||
当前边界:
|
||||
|
||||
1. `owner`
|
||||
- 可执行:
|
||||
- `setTreasury`
|
||||
- `setTokenAllowed`
|
||||
|
||||
2. 普通用户
|
||||
- 只能调用:
|
||||
- `pay(orderId, planId, amount, token)`
|
||||
|
||||
3. 代币边界
|
||||
- 只有 `allowedToken[token] == true` 的 token 可支付
|
||||
|
||||
4. 订单边界
|
||||
- 同一个 `orderId` 只能成功支付一次
|
||||
|
||||
## 3. 重入与重复支付判断
|
||||
|
||||
当前合约的 `pay` 逻辑顺序是:
|
||||
|
||||
1. 检查 token allowlist
|
||||
2. 检查 `amount > 0`
|
||||
3. 检查 `paidOrder[orderId] == false`
|
||||
4. 先写入 `paidOrder[orderId] = true`
|
||||
5. 再执行 `transferFrom`
|
||||
6. 发出 `OrderPaid`
|
||||
|
||||
这意味着:
|
||||
|
||||
- 同一 `orderId` 的重复支付会被拦住
|
||||
- 典型“转账外部调用后再回调重复执行同订单”的路径会被 `paidOrder` 状态挡住
|
||||
|
||||
但要注意:
|
||||
|
||||
- 当前合约没有 `Pausable`
|
||||
- 当前合约没有 `SafeERC20`
|
||||
- 当前合约没有在链上校验 `planId -> amount`
|
||||
|
||||
所以它属于:
|
||||
- **最小可用支付合约**
|
||||
- 不是“全功能强防护合约”
|
||||
|
||||
## 4. 当前静态审计结论
|
||||
|
||||
已提供脚本:
|
||||
- [check_payment_contract_security.py](/E:/web/PolyWeather/scripts/check_payment_contract_security.py)
|
||||
|
||||
命令:
|
||||
|
||||
```bash
|
||||
python scripts/check_payment_contract_security.py
|
||||
```
|
||||
|
||||
输出会检查这些项目:
|
||||
|
||||
- 是否有 `onlyOwner`
|
||||
- `setTreasury` / `setTokenAllowed` 是否受 owner 保护
|
||||
- constructor / setter 是否检查零地址
|
||||
- 是否校验 allowlist
|
||||
- 是否校验 `amount > 0`
|
||||
- 是否校验重复订单
|
||||
- 是否在 `transferFrom` 前写入 `paidOrder`
|
||||
- 是否有 pause 开关
|
||||
- 是否使用 SafeERC20
|
||||
- 是否在链上绑定套餐价格
|
||||
|
||||
## 5. 当前主要剩余风险
|
||||
|
||||
1. 单地址 owner
|
||||
- 建议把 `owner` 迁移到多签钱包
|
||||
|
||||
2. 无暂停开关
|
||||
- 发现紧急问题时,无法直接暂停 `pay`
|
||||
|
||||
3. 金额校验主要在链下
|
||||
- 当前 `planId / amount / token` 绑定主要靠后端 intent 和确认逻辑
|
||||
|
||||
4. ERC20 兼容性假设
|
||||
- 当前使用 `IERC20.transferFrom`
|
||||
- 升级版合约更建议改为 OpenZeppelin `SafeERC20`
|
||||
|
||||
## 6. 推荐操作
|
||||
|
||||
### 每次支付配置变更后
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
python scripts/check_payment_contract_security.py
|
||||
python scripts/replay_payment_events.py --from-block <from> --to-block <to>
|
||||
```
|
||||
|
||||
### 线上巡检
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/api/payments/runtime
|
||||
```
|
||||
|
||||
重点看:
|
||||
|
||||
- `rpc.active_rpc_url`
|
||||
- `rpc.configured_rpc_count`
|
||||
- `event_loop_state.last_scanned_block`
|
||||
- `recent_audit_events`
|
||||
|
||||
如果你在 `/ops` 或脚本里看到:
|
||||
|
||||
- `receiver_mismatch`
|
||||
|
||||
其含义通常不是“缓存没刷新”,而是:
|
||||
|
||||
- 用户这笔交易的 `to` 地址不是当前生产收款合约
|
||||
- 常见原因是旧页面、旧 deployment、旧钱包会话,或历史收款地址仍被命中
|
||||
|
||||
此时应优先做:
|
||||
|
||||
1. 确认链上真实 `to` 地址
|
||||
2. 确认当前 `/api/payments/config` 返回的 `receiver_contract`
|
||||
3. 如确已收款,再走人工恢复或补开订阅
|
||||
|
||||
### 按邮箱恢复最近支付
|
||||
|
||||
已提供脚本:
|
||||
|
||||
- [reconcile_subscription_by_email.py](/E:/web/PolyWeather/scripts/reconcile_subscription_by_email.py)
|
||||
|
||||
命令:
|
||||
|
||||
```bash
|
||||
docker compose exec polyweather_web python scripts/reconcile_subscription_by_email.py --email user@example.com
|
||||
```
|
||||
|
||||
适用场景:
|
||||
|
||||
- 用户声称已付费但未开通
|
||||
- 需要快速确认最近一笔 intent 是否能自动恢复
|
||||
|
||||
## 7. 下一版合约建议
|
||||
|
||||
如果后续升级合约,优先级建议:
|
||||
|
||||
1. `Ownable` -> 多签 owner
|
||||
2. `SafeERC20`
|
||||
3. `Pausable`
|
||||
4. 链上 plan/amount/token 绑定
|
||||
5. 必要时增加 rescue/sweep 能力
|
||||
@@ -0,0 +1,197 @@
|
||||
# PolyWeather 支付合约升级方案(V2)
|
||||
|
||||
最后更新:`2026-03-20`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本次 V2 方案对应三个明确目标:
|
||||
|
||||
1. 把 `owner` 迁到多签地址
|
||||
2. 升级到 `SafeERC20 + Pausable + ReentrancyGuard`
|
||||
3. 把“链上 plan 绑定”和“EIP-712 授权支付”都纳入设计,而不是只在链下校验
|
||||
|
||||
合约草案:
|
||||
- [PolyWeatherCheckoutV2.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckoutV2.sol)
|
||||
|
||||
构造参数编码脚本:
|
||||
- [encode_checkout_v2_constructor.py](/E:/web/PolyWeather/scripts/encode_checkout_v2_constructor.py)
|
||||
|
||||
## 2. V2 新增能力
|
||||
|
||||
### 多签 owner
|
||||
|
||||
V2 constructor 不再默认 `msg.sender` 作为唯一 owner,而是显式传入:
|
||||
|
||||
- `initialOwner`
|
||||
- `initialTreasury`
|
||||
- `initialSigner`
|
||||
|
||||
这意味着:
|
||||
- 部署后可直接把多签地址设为 `owner`
|
||||
- 不需要先单签部署再补 transfer
|
||||
|
||||
### SafeERC20
|
||||
|
||||
V2 内置最小 `SafeERC20` 封装:
|
||||
|
||||
- `safeTransferFrom`
|
||||
- `safeTransfer`
|
||||
|
||||
相比直接依赖 `IERC20.transferFrom -> bool`:
|
||||
- 对非标准 ERC20 的兼容性更稳
|
||||
- 出错边界更明确
|
||||
|
||||
### Pausable
|
||||
|
||||
V2 增加:
|
||||
|
||||
- `pause()`
|
||||
- `unpause()`
|
||||
|
||||
支付入口:
|
||||
|
||||
- `payPlan(...)`
|
||||
- `payAuthorized(...)`
|
||||
|
||||
都受 `whenNotPaused` 保护。
|
||||
|
||||
一旦发现:
|
||||
- treasury 配置错误
|
||||
- token allowlist 配置错误
|
||||
- 签名器异常
|
||||
- 链上风控问题
|
||||
|
||||
可以直接暂停支付入口。
|
||||
|
||||
### ReentrancyGuard
|
||||
|
||||
V2 增加 `nonReentrant`,保护:
|
||||
|
||||
- `payPlan`
|
||||
- `payAuthorized`
|
||||
- `rescueToken`
|
||||
|
||||
虽然当前订单去重已经能挡住典型重复支付路径,但 `ReentrancyGuard` 仍然是更稳的防线。
|
||||
|
||||
### 链上套餐绑定
|
||||
|
||||
V2 新增:
|
||||
|
||||
- `setPlan(planId, token, amount, active)`
|
||||
- `planConfig[planId][token]`
|
||||
|
||||
正式支付入口 `payPlan` 会:
|
||||
|
||||
1. 校验 token 已 allowed
|
||||
2. 校验 `planId + token` 的 plan 已 active
|
||||
3. 从链上读取 amount
|
||||
4. 按链上配置收款
|
||||
|
||||
这意味着:
|
||||
- `planId / amount / token` 绑定不再完全依赖链下
|
||||
|
||||
### EIP-712 授权支付
|
||||
|
||||
V2 同时保留第二条入口:
|
||||
|
||||
- `payAuthorized(...)`
|
||||
|
||||
它适合:
|
||||
- 临时折扣
|
||||
- 特殊活动价
|
||||
- 不想每次都上链改 `setPlan`
|
||||
|
||||
校验字段包括:
|
||||
|
||||
- `orderId`
|
||||
- `payer`
|
||||
- `planId`
|
||||
- `token`
|
||||
- `amount`
|
||||
- `nonce`
|
||||
- `deadline`
|
||||
|
||||
签名人地址由:
|
||||
|
||||
- `signer`
|
||||
|
||||
统一控制。
|
||||
|
||||
## 3. 两条支付路径怎么选
|
||||
|
||||
### 路线 A:链上套餐绑定优先
|
||||
|
||||
优点:
|
||||
- 最直观
|
||||
- 合约级约束最强
|
||||
- 更容易审计
|
||||
|
||||
缺点:
|
||||
- 套餐改价需要 owner 交易
|
||||
|
||||
适合:
|
||||
- 月付/季付/年付这类稳定商品
|
||||
|
||||
### 路线 B:EIP-712 授权优先
|
||||
|
||||
优点:
|
||||
- 活动价灵活
|
||||
- 不必每次改链上 plan
|
||||
|
||||
缺点:
|
||||
- 需要管理 signer 密钥
|
||||
- 风险从 owner 单点,部分转移到 signer 运维
|
||||
|
||||
适合:
|
||||
- 促销
|
||||
- 临时折扣
|
||||
- 白名单价格
|
||||
|
||||
### 当前建议
|
||||
|
||||
生产建议不是二选一,而是:
|
||||
|
||||
1. **稳定套餐** 走 `payPlan`
|
||||
2. **特殊场景** 走 `payAuthorized`
|
||||
|
||||
这样:
|
||||
- 主流程更稳
|
||||
- 特殊价仍保留灵活性
|
||||
|
||||
## 4. 推荐迁移步骤
|
||||
|
||||
1. 先部署 V2 到测试环境
|
||||
2. `owner` 直接用多签地址
|
||||
3. 配置 `treasury`
|
||||
4. 配置 `allowedToken`
|
||||
5. 配置 `planId/token/amount`
|
||||
6. 仅在需要活动价时再配置 `signer`
|
||||
7. 用事件重放脚本和运行态接口验证
|
||||
8. 再切生产前端/后端配置到新 `receiver_contract`
|
||||
|
||||
## 5. 构造参数编码
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
python scripts/encode_checkout_v2_constructor.py \
|
||||
--owner 0xYourMultiSig \
|
||||
--treasury 0xYourTreasury \
|
||||
--signer 0xYourBackendSigner
|
||||
```
|
||||
|
||||
## 6. 当前判断
|
||||
|
||||
V2 已经把这三件事做成了明确方案:
|
||||
|
||||
1. 多签 owner
|
||||
2. SafeERC20 + Pausable + ReentrancyGuard
|
||||
3. 链上 plan 绑定 + EIP-712 授权
|
||||
|
||||
它现在是**升级草案**,不是现网已部署合约。
|
||||
|
||||
如果要真正上线,下一步就是:
|
||||
|
||||
1. 做一次测试网或本地链验证
|
||||
2. 更新 PolygonScan 验证文档
|
||||
3. 修改后端 `receiver_contract` 配置
|
||||
@@ -0,0 +1,76 @@
|
||||
# PolyWeatherCheckout PolygonScan 验证(v1.5.1)
|
||||
|
||||
最后更新:`2026-03-20`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
对生产收款合约完成源码验证,降低钱包风控误报并提升用户信任。
|
||||
|
||||
当前说明:
|
||||
|
||||
- **现网合约仍为 V1**:`contracts/PolyWeatherCheckout.sol`
|
||||
- **V2 只是升级草案**:`contracts/PolyWeatherCheckoutV2.sol`
|
||||
- 当前 PolygonScan 验证流程默认针对 V1
|
||||
|
||||
## 2. 当前部署参数(示例)
|
||||
|
||||
- 链:Polygon Mainnet(`chainId=137`)
|
||||
- 合约:`PolyWeatherCheckout`
|
||||
- 编译器:`v0.8.24+commit.e11b9ed9`
|
||||
- 优化器:`Enabled`,`runs=200`
|
||||
|
||||
> 实际地址以线上配置为准:`POLYWEATHER_PAYMENT_RECEIVER_CONTRACT`。
|
||||
|
||||
## 3. 构造参数编码
|
||||
|
||||
使用仓库脚本生成构造参数:
|
||||
|
||||
```bash
|
||||
python scripts/encode_checkout_constructor.py \
|
||||
--token 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 \
|
||||
--treasury 0xe581D578EF101c80e3F32263e97E6eA28A0B170e
|
||||
```
|
||||
|
||||
将输出填入 PolygonScan 的 `Constructor Arguments ABI-encoded`。
|
||||
|
||||
## 4. PolygonScan 操作步骤
|
||||
|
||||
1. 打开合约页 -> `Contract` -> `Verify and Publish`。
|
||||
2. 选择 `Solidity (Single file)`。
|
||||
3. 粘贴 `contracts/PolyWeatherCheckout.sol` 源码。
|
||||
4. 填写编译器/优化器参数。
|
||||
5. 粘贴构造参数并提交。
|
||||
|
||||
## 5. 验证后检查
|
||||
|
||||
- `Read Contract`:可见 `owner / treasury / allowedToken / paidOrder`
|
||||
- `Write Contract`:可见 `pay / setTreasury / setTokenAllowed`
|
||||
- 标签显示 `Contract Source Code Verified`
|
||||
|
||||
## 6. 双币种开启(USDC + USDC.e)
|
||||
|
||||
验证后可通过 `setTokenAllowed` 开启两种代币:
|
||||
|
||||
- USDC.e: `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`
|
||||
- Native USDC: `0x3c499c542cef5e3811e1192ce70d8cc03d5c3359`
|
||||
|
||||
## 7. V2 说明(尚未部署)
|
||||
|
||||
如果后续升级到 V2,请改用:
|
||||
|
||||
```bash
|
||||
python scripts/encode_checkout_v2_constructor.py \
|
||||
--owner 0xYourMultiSig \
|
||||
--treasury 0xYourTreasury \
|
||||
--signer 0xYourBackendSigner
|
||||
```
|
||||
|
||||
V2 相关文档:
|
||||
|
||||
- [PAYMENT_UPGRADE_V2_ZH.md](/E:/web/PolyWeather/docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- [PAYMENT_AUDIT_ZH.md](/E:/web/PolyWeather/docs/payments/PAYMENT_AUDIT_ZH.md)
|
||||
|
||||
## 8. 说明
|
||||
|
||||
- 源码验证能显著降低“欺诈/不可信”误报,但钱包风险缓存更新存在延迟。
|
||||
- 生产商用环境可使用私有升级版合约;公开仓库保留标准实现与验证流程。
|
||||
@@ -0,0 +1,49 @@
|
||||
# PolyWeather 侧边栏插件(MVP)
|
||||
|
||||
这是一个 Chrome / Edge 侧边栏扩展的 MVP,用于把 PolyWeather 右侧城市卡片移植到浏览器侧边栏中。
|
||||
|
||||
## 功能
|
||||
|
||||
- 侧边栏展示:
|
||||
- 城市选择
|
||||
- 风险徽章
|
||||
- 城市档案(结算源 / 距离 / 观测更新时间 / 周边站点)
|
||||
- 今日日内走势(简版 Canvas)
|
||||
- 多日预报(`DEB` 优先)
|
||||
- 基础判断卡(方向 / 置信度 / 原因)
|
||||
- 快捷按钮:
|
||||
- 今日日内分析
|
||||
- 历史对账
|
||||
- 打开网站查看更多
|
||||
- 自动识别城市:
|
||||
- 监听当前激活标签页 URL(例如 Polymarket `.../event/highest-temperature-in-ankara-...`)
|
||||
- 自动将侧边栏城市切换为 URL 对应城市
|
||||
- 设置页可配置:
|
||||
- 网站基础地址
|
||||
- API 基础地址
|
||||
- Bearer Token(可选)
|
||||
|
||||
## 本地安装(开发者模式)
|
||||
|
||||
1. 打开 Chrome/Edge 扩展页面:
|
||||
- Chrome:`chrome://extensions`
|
||||
- Edge:`edge://extensions`
|
||||
2. 打开“开发者模式”。
|
||||
3. 选择“加载已解压的扩展程序”。
|
||||
4. 选择目录:`extension/`。
|
||||
5. 点击扩展图标,侧边栏会打开。
|
||||
|
||||
## 设置
|
||||
|
||||
首次建议打开扩展“选项页”并确认:
|
||||
|
||||
- `网站基础地址`:你的前端域名(例如 `https://polyweather-pro.vercel.app`)
|
||||
- `API 基础地址`:你的后端 API 域名(若同域也可填前端域名)
|
||||
- `Bearer Token`:后端开启鉴权时填写
|
||||
|
||||
## 说明
|
||||
|
||||
- 当前版本仍是轻量 MVP,重点是“监控 + 基础判断 + 导流回站”,未接入支付链路。
|
||||
- 若你的 API 做了严格鉴权,请先在设置页填写 token 再使用。
|
||||
- 台北现在按 `NOAA RCTP` 结算参考展示。
|
||||
- 插件不会承载完整分析;完整结构判断、历史对账和更多信号仍以主站为准。
|
||||
@@ -0,0 +1,6 @@
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.sidePanel
|
||||
.setPanelBehavior({ openPanelOnActionClick: true })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 826 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "PolyWeather Side Panel",
|
||||
"description": "PolyWeather 右侧城市卡片(浏览器侧边栏)",
|
||||
"version": "0.1.5",
|
||||
"icons": {
|
||||
"16": "icon-16.png",
|
||||
"32": "icon-32.png",
|
||||
"48": "icon-48.png",
|
||||
"128": "icon-128.png"
|
||||
},
|
||||
"permissions": ["sidePanel", "storage", "tabs"],
|
||||
"host_permissions": ["https://*/*", "http://*/*"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_icon": {
|
||||
"16": "icon-16.png",
|
||||
"32": "icon-32.png",
|
||||
"48": "icon-48.png"
|
||||
},
|
||||
"default_title": "Open PolyWeather"
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidepanel.html"
|
||||
},
|
||||
"options_page": "options.html"
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0b1225;
|
||||
color: #e5eefb;
|
||||
font-family: "Inter", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 840px;
|
||||
margin: 24px auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 18px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
color: #9fb0c9;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
background: #121c38;
|
||||
color: #f1f5ff;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid rgba(34, 211, 238, 0.4);
|
||||
background: rgba(34, 211, 238, 0.12);
|
||||
color: #ccf7ff;
|
||||
border-radius: 9px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
background: rgba(99, 102, 241, 0.14);
|
||||
color: #dbe4ff;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin-top: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
min-height: 56px;
|
||||
color: #9fb0c9;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PolyWeather Extension Settings</title>
|
||||
<link rel="stylesheet" href="./options.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<h1 id="settingsTitle">PolyWeather 侧边栏设置</h1>
|
||||
|
||||
<label class="field">
|
||||
<span id="siteBaseLabel">Site Base URL</span>
|
||||
<input id="siteBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span id="apiBaseLabel">API Base URL</span>
|
||||
<input id="apiBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span id="tokenLabel">Bearer Token(可选)</span>
|
||||
<textarea
|
||||
id="tokenInput"
|
||||
rows="3"
|
||||
placeholder="公开模式留空即可;仅当后端返回 401 时再填写。"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button id="saveBtn">保存</button>
|
||||
<button id="testBtn" class="ghost">测试 /api/cities</button>
|
||||
</div>
|
||||
|
||||
<pre id="resultBox" class="result"></pre>
|
||||
</main>
|
||||
|
||||
<script src="./options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
const DEFAULT_CONFIG = {
|
||||
apiBase: "https://polyweather-pro.vercel.app",
|
||||
siteBase: "https://polyweather-pro.vercel.app",
|
||||
authToken: "",
|
||||
selectedCity: ""
|
||||
};
|
||||
const locale = String(navigator.language || "en").toLowerCase().startsWith("zh")
|
||||
? "zh"
|
||||
: "en";
|
||||
const I18N = {
|
||||
zh: {
|
||||
settingsTitle: "PolyWeather 侧边栏设置",
|
||||
tokenLabel: "Bearer Token(可选)",
|
||||
tokenPlaceholder: "公开模式留空即可;仅当后端返回 401 时再填写。",
|
||||
save: "保存",
|
||||
test: "测试 /api/cities",
|
||||
saved: "已保存。公开模式下 Token 可留空。",
|
||||
connectOk: "连接成功,返回城市数",
|
||||
tokenOptional: "Token 可留空",
|
||||
testFailed: "测试失败",
|
||||
backendAuthHint: "说明后端仍要求鉴权;若你要公开插件,请先放开 /api/cities 与 /api/city/*/detail。"
|
||||
},
|
||||
en: {
|
||||
settingsTitle: "PolyWeather Side Panel Settings",
|
||||
tokenLabel: "Bearer Token (Optional)",
|
||||
tokenPlaceholder: "Leave empty in public mode; only fill it if the backend returns 401.",
|
||||
save: "Save",
|
||||
test: "Test /api/cities",
|
||||
saved: "Saved. Token can be empty in public mode.",
|
||||
connectOk: "Connected successfully, city count",
|
||||
tokenOptional: "Token can be empty",
|
||||
testFailed: "Test failed",
|
||||
backendAuthHint: "The backend still requires auth. If the extension should be public, allow /api/cities and /api/city/*/detail."
|
||||
}
|
||||
};
|
||||
|
||||
function t(key) {
|
||||
return I18N[locale][key] || I18N.zh[key] || key;
|
||||
}
|
||||
|
||||
const apiBaseInput = document.getElementById("apiBaseInput");
|
||||
const siteBaseInput = document.getElementById("siteBaseInput");
|
||||
const tokenInput = document.getElementById("tokenInput");
|
||||
const resultBox = document.getElementById("resultBox");
|
||||
const settingsTitle = document.getElementById("settingsTitle");
|
||||
const siteBaseLabel = document.getElementById("siteBaseLabel");
|
||||
const apiBaseLabel = document.getElementById("apiBaseLabel");
|
||||
const tokenLabel = document.getElementById("tokenLabel");
|
||||
const saveBtn = document.getElementById("saveBtn");
|
||||
const testBtn = document.getElementById("testBtn");
|
||||
|
||||
function normalizeBase(url) {
|
||||
return String(url || "").trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function writeResult(text) {
|
||||
resultBox.textContent = text;
|
||||
}
|
||||
|
||||
function getStorage() {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.sync.get(DEFAULT_CONFIG, (items) => resolve(items));
|
||||
});
|
||||
}
|
||||
|
||||
function setStorage(values) {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.sync.set(values, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadForm() {
|
||||
const cfg = await getStorage();
|
||||
apiBaseInput.value = cfg.apiBase || DEFAULT_CONFIG.apiBase;
|
||||
siteBaseInput.value = cfg.siteBase || cfg.apiBase || DEFAULT_CONFIG.siteBase;
|
||||
tokenInput.value = cfg.authToken || "";
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
const next = {
|
||||
apiBase: normalizeBase(apiBaseInput.value),
|
||||
siteBase: normalizeBase(siteBaseInput.value || apiBaseInput.value),
|
||||
authToken: String(tokenInput.value || "").trim()
|
||||
};
|
||||
await setStorage(next);
|
||||
writeResult(t("saved"));
|
||||
}
|
||||
|
||||
async function testApi() {
|
||||
const apiBase = normalizeBase(apiBaseInput.value);
|
||||
const authToken = String(tokenInput.value || "").trim();
|
||||
try {
|
||||
const headers = { Accept: "application/json" };
|
||||
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||
|
||||
const res = await fetch(`${apiBase}/api/cities`, {
|
||||
headers,
|
||||
cache: "no-store"
|
||||
});
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch (_e) {
|
||||
data = text;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${typeof data === "string" ? data : JSON.stringify(data)}`);
|
||||
}
|
||||
const count = Array.isArray(data)
|
||||
? data.length
|
||||
: Array.isArray(data?.cities)
|
||||
? data.cities.length
|
||||
: 0;
|
||||
writeResult(`${t("connectOk")}: ${count} (${t("tokenOptional")})`);
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || err || "");
|
||||
if (msg.includes("HTTP 401")) {
|
||||
writeResult(`${t("testFailed")}: ${msg}\n${t("backendAuthHint")}`);
|
||||
return;
|
||||
}
|
||||
writeResult(`${t("testFailed")}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStaticTranslations() {
|
||||
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
|
||||
if (settingsTitle) settingsTitle.textContent = t("settingsTitle");
|
||||
if (tokenLabel) tokenLabel.textContent = t("tokenLabel");
|
||||
if (tokenInput) tokenInput.placeholder = t("tokenPlaceholder");
|
||||
if (saveBtn) saveBtn.textContent = t("save");
|
||||
if (testBtn) testBtn.textContent = t("test");
|
||||
if (siteBaseLabel) siteBaseLabel.textContent = "Site Base URL";
|
||||
if (apiBaseLabel) apiBaseLabel.textContent = "API Base URL";
|
||||
}
|
||||
|
||||
document.getElementById("saveBtn").addEventListener("click", () => {
|
||||
void saveForm();
|
||||
});
|
||||
document.getElementById("testBtn").addEventListener("click", () => {
|
||||
void testApi();
|
||||
});
|
||||
|
||||
applyStaticTranslations();
|
||||
void loadForm();
|
||||
@@ -0,0 +1,410 @@
|
||||
:root {
|
||||
--bg: #070d1f;
|
||||
--panel: #0d152b;
|
||||
--card: rgba(255, 255, 255, 0.03);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--text: #e5eefb;
|
||||
--muted: #8ba0be;
|
||||
--cyan: #22d3ee;
|
||||
--blue: #3b82f6;
|
||||
--green: #34d399;
|
||||
--amber: #f59e0b;
|
||||
--red: #f87171;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: radial-gradient(circle at top, #0c1735, var(--bg) 45%);
|
||||
color: var(--text);
|
||||
font-family: "Inter", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
border-radius: 14px;
|
||||
background: rgba(7, 13, 31, 0.7);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: 3px solid rgba(34, 211, 238, 0.28);
|
||||
border-top-color: #22d3ee;
|
||||
animation: panel-loading-spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #b7dcff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.freshness-hint {
|
||||
margin: -2px 0 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.28);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
color: #fcd34d;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.freshness-hint.stale {
|
||||
border-color: rgba(248, 113, 113, 0.38);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.risk-badge {
|
||||
padding: 5px 9px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.risk-badge.low {
|
||||
color: #86efac;
|
||||
border-color: rgba(52, 211, 153, 0.5);
|
||||
background: rgba(52, 211, 153, 0.14);
|
||||
}
|
||||
|
||||
.risk-badge.medium {
|
||||
color: #fcd34d;
|
||||
border-color: rgba(245, 158, 11, 0.45);
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
}
|
||||
|
||||
.risk-badge.high {
|
||||
color: #fca5a5;
|
||||
border-color: rgba(248, 113, 113, 0.5);
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
}
|
||||
|
||||
.city-picker-wrap {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.city-picker-wrap label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#citySelect {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0f1b35;
|
||||
color: var(--text);
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0f1b35;
|
||||
color: #9cecff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.refresh-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.refresh-btn.spinning svg {
|
||||
animation: refresh-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes refresh-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes panel-loading-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.5);
|
||||
color: #9cecff;
|
||||
background: rgba(34, 211, 238, 0.07);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.section {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--card);
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mini-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.mini-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.mini-card strong {
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
position: relative;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#trendCanvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.decision-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.decision-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.decision-direction {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
color: #f8fbff;
|
||||
}
|
||||
|
||||
.decision-window {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.decision-confidence {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.decision-confidence.high {
|
||||
color: #86efac;
|
||||
border-color: rgba(52, 211, 153, 0.45);
|
||||
background: rgba(52, 211, 153, 0.12);
|
||||
}
|
||||
|
||||
.decision-confidence.medium {
|
||||
color: #fcd34d;
|
||||
border-color: rgba(245, 158, 11, 0.45);
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
.decision-confidence.low,
|
||||
.decision-confidence.neutral {
|
||||
color: #cbd5e1;
|
||||
border-color: rgba(148, 163, 184, 0.28);
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.decision-summary {
|
||||
color: #dbeafe;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.decision-reasons {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.decision-reasons li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.forecast-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.forecast-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 8px;
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.forecast-card.today {
|
||||
border-color: rgba(34, 211, 238, 0.62);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
}
|
||||
|
||||
.f-date {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.f-temp {
|
||||
margin-top: 7px;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.btn-open-full {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #d9ebff;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.open-full-hint {
|
||||
margin-bottom: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 1px solid rgba(248, 113, 113, 0.45);
|
||||
border-radius: 12px;
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
color: #fecaca;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chart-tooltip {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
max-width: 180px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.45);
|
||||
background: rgba(9, 17, 36, 0.92);
|
||||
color: #dff7ff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PolyWeather Panel</title>
|
||||
<link rel="stylesheet" href="./sidepanel.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="panel">
|
||||
<div id="loadingOverlay" class="loading-overlay hidden" aria-live="polite">
|
||||
<div class="loading-spinner" aria-hidden="true"></div>
|
||||
<div id="loadingText" class="loading-text">正在加载温度数据...</div>
|
||||
</div>
|
||||
<header class="topbar">
|
||||
<div id="riskBadge" class="risk-badge medium">中风险</div>
|
||||
<div class="city-picker-wrap">
|
||||
<label id="cityLabel" for="citySelect">城市</label>
|
||||
<select id="citySelect"></select>
|
||||
</div>
|
||||
<button id="refreshBtn" class="refresh-btn" title="刷新数据" aria-label="刷新数据">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div id="freshnessHint" class="freshness-hint hidden"></div>
|
||||
|
||||
<section class="section">
|
||||
<h3 id="profileTitle">城市档案</h3>
|
||||
<div class="grid2">
|
||||
<article class="mini-card">
|
||||
<span class="mini-label" id="settlementLabel">结算站点</span>
|
||||
<strong id="settlementValue">--</strong>
|
||||
</article>
|
||||
<article class="mini-card">
|
||||
<span id="distanceLabel" class="mini-label">站点距离</span>
|
||||
<strong id="distanceValue">--</strong>
|
||||
</article>
|
||||
<article class="mini-card">
|
||||
<span id="obsTimeLabel" class="mini-label">观测更新</span>
|
||||
<strong id="obsTimeValue">--</strong>
|
||||
</article>
|
||||
<article class="mini-card">
|
||||
<span id="nearbyLabel" class="mini-label">周边站点</span>
|
||||
<strong id="nearbyValue">--</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3 id="trendTitle">今日日内走势(简版)</h3>
|
||||
<div class="chart-wrap">
|
||||
<canvas id="trendCanvas" width="560" height="220"></canvas>
|
||||
<div id="chartTooltip" class="chart-tooltip hidden"></div>
|
||||
</div>
|
||||
<div id="chartLegend" class="legend-text">--</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3 id="decisionTitle">方向判断</h3>
|
||||
<div class="decision-card">
|
||||
<div class="decision-top">
|
||||
<div>
|
||||
<div id="decisionDirection" class="decision-direction">--</div>
|
||||
<div id="decisionWindow" class="decision-window">--</div>
|
||||
</div>
|
||||
<div id="decisionConfidence" class="decision-confidence neutral">--</div>
|
||||
</div>
|
||||
<div id="decisionSummary" class="decision-summary">--</div>
|
||||
<ul id="decisionReasons" class="decision-reasons"></ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3 id="forecastTitle">多日预报</h3>
|
||||
<div id="forecastRow" class="forecast-row"></div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div id="openFullHint" class="open-full-hint"></div>
|
||||
<button id="openFullBtn" class="btn btn-open-full">打开完整网站分析</button>
|
||||
</section>
|
||||
|
||||
<section id="errorBox" class="error hidden"></section>
|
||||
</main>
|
||||
|
||||
<script src="./sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
+32
-3
@@ -1,6 +1,35 @@
|
||||
# PolyWeather 前端最小配置(本地 / Vercel)
|
||||
# 只部署天气看板时,先填下面 4 项即可。
|
||||
|
||||
# 必填:后端 FastAPI 基础地址
|
||||
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
|
||||
# Optional dashboard guard (Next.js middleware)
|
||||
# If set, open dashboard with: /?access_token=<token>
|
||||
|
||||
# 必填:Supabase 前端公钥(鉴权开启时必须)
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# 常用:前端鉴权开关
|
||||
# true: 启用 Supabase 登录
|
||||
# false: 关闭登录能力,访客模式
|
||||
POLYWEATHER_AUTH_ENABLED=false
|
||||
|
||||
# 常用:是否强制登录
|
||||
# true: middleware 强制登录后才能访问主页面
|
||||
# false: 登录可选,访客可浏览
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
|
||||
# 可选:分享式看板访问令牌
|
||||
# 设置后,可通过 /?access_token=<token> 打开受保护看板
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
# Shared secret forwarded by Next API routes to backend
|
||||
|
||||
# 可选:前端 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/WeatherQuant_bot
|
||||
|
||||
+145
-43
@@ -1,32 +1,39 @@
|
||||
# PolyWeather Frontend
|
||||
# PolyWeather 前端
|
||||
|
||||
This directory is the only web frontend in production.
|
||||
PolyWeather Pro 的生产前端工程。
|
||||
|
||||
Production URL:
|
||||
- https://polyweather-pro.vercel.app/
|
||||
线上地址:
|
||||
- [https://polyweather-pro.vercel.app/](https://polyweather-pro.vercel.app/)
|
||||
|
||||
## Stack
|
||||
## 技术栈
|
||||
|
||||
- Next.js App Router
|
||||
- React (component-driven dashboard)
|
||||
- Tailwind CSS
|
||||
- Leaflet (map runtime)
|
||||
- Chart.js (charts with manual lifecycle wrapper)
|
||||
- Typed store + typed data client
|
||||
- React + Tailwind
|
||||
- Leaflet + Chart.js
|
||||
- Supabase Auth
|
||||
- WalletConnect + 浏览器 EVM 钱包
|
||||
|
||||
## Production Model
|
||||
## 运行模型
|
||||
|
||||
- Vercel serves the web UI and BFF route handlers
|
||||
- FastAPI on VPS serves weather APIs only
|
||||
- The old FastAPI static website has been removed
|
||||
- The production page shell is React-driven (`components/dashboard/*`), with no runtime dependency on `public/static/app.js`
|
||||
1. 浏览器 -> Next 应用(`frontend`)
|
||||
2. Next Route Handlers(`/api/*`)-> FastAPI 后端
|
||||
3. FastAPI -> 分析服务 / 支付服务
|
||||
|
||||
Current request flow:
|
||||
- Browser -> Vercel frontend
|
||||
- React store/client -> Next route handlers
|
||||
- Next route handlers -> FastAPI API
|
||||
## 当前前端能力
|
||||
|
||||
## Local Development
|
||||
- 主站 Dashboard 支持地图、城市详情、今日日内分析、历史准确率对账和账户中心
|
||||
- `/docs` 已提供公开双语产品文档中心,解释日内结构信号、TAF、结算来源和历史对账
|
||||
- 今日日内分析支持:
|
||||
- 峰值窗口感知的近地面结构信号
|
||||
- 高空结构信号
|
||||
- 交易动作卡
|
||||
- 非香港机场城市的 `TAF` 时段提示与走势图联动
|
||||
- 历史对账支持:
|
||||
- `DEB / 最佳单模型 / 实测最高温` 对比
|
||||
- 峰值前 12 小时 `DEB` 参考(近似)
|
||||
- `/ops` 已支持桌面表格 + 手机端卡片化视图
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
@@ -35,44 +42,139 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Default local URL:
|
||||
- http://localhost:3000
|
||||
## Vercel 最小部署配置
|
||||
|
||||
## Required Environment Variable
|
||||
只跑看板和基础鉴权时,先填这 4 项:
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://<your-fastapi-host>
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://<your-supabase-project>.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your-anon-key>
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `http://38.54.27.70:8000`
|
||||
- `https://api.example.com`
|
||||
建议显式补:
|
||||
|
||||
## Route Handlers
|
||||
```env
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
```
|
||||
|
||||
如果你只是开放游客浏览,可改成:
|
||||
|
||||
```env
|
||||
POLYWEATHER_AUTH_ENABLED=false
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
```
|
||||
|
||||
## 可选环境变量
|
||||
|
||||
仅在对应功能启用时填写:
|
||||
|
||||
```env
|
||||
# 看板分享令牌
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
|
||||
# 前端 API 转发到后端时使用的共享令牌
|
||||
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
|
||||
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/WeatherQuant_bot
|
||||
```
|
||||
|
||||
更完整的 Vercel 配置说明见:
|
||||
- [docs/FRONTEND_DEPLOYMENT_ZH.md](/E:/web/PolyWeather/docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
|
||||
## 路由处理器
|
||||
|
||||
天气:
|
||||
|
||||
Thin BFF routes currently exposed by Next:
|
||||
- `GET /api/cities`
|
||||
- `GET /api/city/[name]`
|
||||
- `GET /api/city/[name]/summary`
|
||||
- `GET /api/city/[name]/detail`
|
||||
- `GET /api/history/[name]`
|
||||
|
||||
Current frontend behavior:
|
||||
- `/` keeps the world overview layout and initial city temperatures preload
|
||||
- Marker click: focus map + open right city card + render nearby stations
|
||||
- Right-card "今日日内分析": opens modal and freezes map motion
|
||||
- Blank-map click: closes right card only, without resetting camera
|
||||
鉴权:
|
||||
|
||||
## Vercel Deployment
|
||||
- `GET /api/auth/me`
|
||||
|
||||
1. Import the repo into Vercel
|
||||
2. Set Root Directory to `frontend`
|
||||
3. Set `POLYWEATHER_API_BASE_URL`
|
||||
4. Deploy
|
||||
支付:
|
||||
|
||||
## Notes
|
||||
- `GET /api/payments/config`
|
||||
- `GET /api/payments/wallets`
|
||||
- `POST /api/payments/wallets/challenge`
|
||||
- `POST /api/payments/wallets/verify`
|
||||
- `POST /api/payments/intents`
|
||||
- `GET /api/payments/intents/[intentId]`
|
||||
- `POST /api/payments/intents/[intentId]/submit`
|
||||
- `POST /api/payments/intents/[intentId]/confirm`
|
||||
|
||||
- Backend CORS must allow `https://polyweather-pro.vercel.app`
|
||||
- City detail cache TTL is 5 minutes with revision probe; manual refresh bypasses cache
|
||||
- UI layout and sizing remain aligned with the legacy visual contract after React migration
|
||||
Ops:
|
||||
|
||||
Last updated: 2026-03-09
|
||||
- `GET /ops`
|
||||
- `GET /api/ops/users`
|
||||
- `GET /api/ops/leaderboard/weekly`
|
||||
- `GET /api/ops/memberships`
|
||||
- `GET /api/ops/payments/incidents`
|
||||
- `POST /api/ops/users/grant-points`
|
||||
- `POST /api/ops/payments/incidents/[eventId]/resolve`
|
||||
|
||||
## Ops 管理后台
|
||||
|
||||
当前前端已内置轻量管理页:
|
||||
|
||||
- [https://polyweather-pro.vercel.app/ops](https://polyweather-pro.vercel.app/ops)
|
||||
|
||||
页面当前支持:
|
||||
|
||||
- 系统状态
|
||||
- SQLite / rollout / 支付运行态
|
||||
- 用户查询
|
||||
- 当前会员
|
||||
- 本周积分榜
|
||||
- 手动补分
|
||||
- 支付异常单筛选与标记已处理
|
||||
- 手机端卡片化视图
|
||||
|
||||
注意:
|
||||
|
||||
- `/ops` 现在是前后端双层管理员限制
|
||||
- Vercel 前端和后端都应配置相同的 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- 前端登录邮箱本身不会自动获得管理员权限
|
||||
|
||||
## 支付安全补充
|
||||
|
||||
为降低“旧页面/旧配置导致打到旧收款地址”的风险,支付区现在会:
|
||||
|
||||
1. 点击支付前重新请求 `/api/payments/config`
|
||||
2. 若 `receiver_contract` 已更新,先切到最新地址
|
||||
3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付
|
||||
4. 仅允许在 `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` 白名单域名上创建 payment intent
|
||||
5. 支付区会明确显示当前账号、付款钱包和收款合约,避免账号/钱包/地址混淆
|
||||
|
||||
这意味着:
|
||||
|
||||
- 旧标签页风险已明显降低
|
||||
- 但支付地址变更后,仍建议在 Vercel 上 redeploy 当前 production,并清理明显过期 deployment
|
||||
|
||||
## 缓存行为
|
||||
|
||||
- `cities` / `summary` / `history`:`ETag + Cache-Control`
|
||||
- `summary?force_refresh=true`:`no-store`
|
||||
- 支付相关路由:`no-store`
|
||||
|
||||
## 开源边界说明
|
||||
|
||||
此前端仓库包含通用产品界面和标准支付体验。
|
||||
商业策略调优、私有运营流程和敏感生产参数不在公开文档范围内。
|
||||
|
||||
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
|
||||
|
||||
最后更新:`2026-03-24`
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { I18nProvider } from "@/hooks/useI18n";
|
||||
import { AccountEntry } from "@/components/account/AccountEntry";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather | Account Center",
|
||||
description: "PolyWeather account center for identity and entitlement status.",
|
||||
};
|
||||
|
||||
export default function AccountPage() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AccountEntry />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-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 res = await fetch(`${API_BASE}/api/auth/me`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const response = NextResponse.json({
|
||||
authenticated: false,
|
||||
subscription_active: false,
|
||||
points: 0,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch auth profile", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
@@ -7,34 +10,39 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/cities`, {
|
||||
headers: buildBackendRequestHeaders(),
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
return buildCachedJsonResponse(
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
"public, max-age=0, s-maxage=300, stale-while-revalidate=1800",
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "Failed to fetch cities", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,10 +11,11 @@ export async function GET(
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
@@ -30,23 +34,27 @@ export async function GET(
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(url, {
|
||||
headers: buildBackendRequestHeaders(),
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "Failed to fetch city detail aggregate", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,10 +11,11 @@ export async function GET(
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
@@ -19,23 +23,27 @@ export async function GET(
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(url, {
|
||||
headers: buildBackendRequestHeaders(),
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "Failed to fetch city detail", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
@@ -9,10 +12,11 @@ export async function GET(
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
@@ -21,34 +25,39 @@ export async function GET(
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(url, {
|
||||
headers: buildBackendRequestHeaders(),
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (bypassCache) {
|
||||
return NextResponse.json(data, {
|
||||
const response = NextResponse.json(data, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
return buildCachedJsonResponse(
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
"public, max-age=0, s-maxage=20, stale-while-revalidate=60",
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "Failed to fetch city summary", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-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 res = await fetch(`${API_BASE}/healthz`, {
|
||||
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 NextResponse.json(
|
||||
{ error: "Failed to fetch healthz", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
@@ -9,37 +12,42 @@ export async function GET(
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
const url = `${API_BASE}/api/history/${encodeURIComponent(name)}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(url, {
|
||||
headers: buildBackendRequestHeaders(),
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
return buildCachedJsonResponse(
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
"public, max-age=0, s-maxage=60, stale-while-revalidate=300",
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ error: "Failed to fetch history", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-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 url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
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 NextResponse.json(
|
||||
{ error: "Failed to fetch weekly leaderboard", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-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 url = new URL(`${API_BASE}/api/ops/memberships`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
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 NextResponse.json(
|
||||
{ error: "Failed to fetch memberships", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ eventId: string }>;
|
||||
};
|
||||
|
||||
export async function POST(req: NextRequest, context: RouteContext) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const { eventId } = await context.params;
|
||||
const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, {
|
||||
method: "POST",
|
||||
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 NextResponse.json(
|
||||
{ error: "Failed to resolve payment incident", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-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 url = new URL(`${API_BASE}/api/ops/payments/incidents`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
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 NextResponse.json(
|
||||
{ error: "Failed to fetch payment incidents", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
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/ops/users/grant-points`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...auth.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body,
|
||||
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 NextResponse.json(
|
||||
{ error: "Failed to grant points", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-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 url = new URL(`${API_BASE}/api/ops/users`);
|
||||
const q = req.nextUrl.searchParams.get("q");
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (q) url.searchParams.set("q", q);
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
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 NextResponse.json(
|
||||
{ error: "Failed to fetch ops users", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-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 res = await fetch(`${API_BASE}/api/payments/config`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch payment config", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ intentId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const { intentId } = await context.params;
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/confirm`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to confirm payment tx", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ intentId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const { intentId } = await context.params;
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch payment intent", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ intentId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const { intentId } = await context.params;
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/submit`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to submit payment tx", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { isPaymentHostAllowed } from "@/lib/payment-host";
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
const requestHost =
|
||||
req.headers.get("x-forwarded-host") ||
|
||||
req.headers.get("host") ||
|
||||
req.nextUrl.hostname;
|
||||
if (!isPaymentHostAllowed(requestHost)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Payments are disabled on this host. Please return to the main production site and retry.",
|
||||
host: requestHost,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/intents`, {
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create payment intent", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
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 res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
|
||||
method: "POST",
|
||||
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; charset=utf-8",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to reconcile latest payment", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-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 res = await fetch(`${API_BASE}/api/payments/runtime`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 500) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch payment runtime", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
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 body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: `Backend returned ${res.status}`,
|
||||
detail: raw.slice(0, 350),
|
||||
proxy_debug: {
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has(
|
||||
"x-polyweather-auth-user-id",
|
||||
),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
},
|
||||
},
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create wallet challenge", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-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 res = await fetch(`${API_BASE}/api/payments/wallets`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch wallets", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = (await req.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
|
||||
method: "DELETE",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (!res.ok) {
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: `Backend returned ${res.status}`,
|
||||
detail: raw.slice(0, 350),
|
||||
proxy_debug: {
|
||||
incoming_has_authorization: Boolean(
|
||||
String(req.headers.get("authorization") || "").trim(),
|
||||
),
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has(
|
||||
"x-polyweather-auth-user-id",
|
||||
),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
},
|
||||
},
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
let data: unknown = { ok: true };
|
||||
if (raw) {
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
data = { ok: true, raw };
|
||||
}
|
||||
}
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to unbind wallet", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
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 body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: `Backend returned ${res.status}`,
|
||||
detail: raw.slice(0, 350),
|
||||
proxy_debug: {
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has(
|
||||
"x-polyweather-auth-user-id",
|
||||
),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
},
|
||||
},
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to verify wallet binding", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-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 res = await fetch(`${API_BASE}/api/system/status`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 500) },
|
||||
{ status: res.status },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch system status", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getVitalsSummary,
|
||||
normalizeMetricName,
|
||||
recordVitalsSample,
|
||||
} from "@/lib/vitals-store";
|
||||
|
||||
type VitalsPayload = {
|
||||
id?: string;
|
||||
metric?: string;
|
||||
navigationType?: string;
|
||||
pathname?: string;
|
||||
rating?: string;
|
||||
value?: number;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const payload = (await request.json()) as VitalsPayload;
|
||||
const metric = normalizeMetricName(payload.metric);
|
||||
if (!metric) {
|
||||
return NextResponse.json({ ok: false, error: "metric is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const pathname = String(payload.pathname || "/");
|
||||
const rating = String(payload.rating || "unknown");
|
||||
const value = Number(payload.value);
|
||||
const navigationType = String(payload.navigationType || "unknown");
|
||||
const id = String(payload.id || "");
|
||||
const ts = Date.now();
|
||||
|
||||
if (!Number.isFinite(value)) {
|
||||
return NextResponse.json({ ok: false, error: "value must be finite" }, { status: 400 });
|
||||
}
|
||||
|
||||
recordVitalsSample({
|
||||
id,
|
||||
metric,
|
||||
navigationType,
|
||||
pathname,
|
||||
rating,
|
||||
timestamp: ts,
|
||||
value,
|
||||
});
|
||||
|
||||
// Keep this lightweight: log for now, can be wired to a persistent sink later.
|
||||
console.info(
|
||||
`[vitals] metric=${metric} path=${pathname} value=${Number.isFinite(value) ? value : "NaN"} rating=${rating} nav=${navigationType} id=${id}`,
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.warn("[vitals] failed to parse payload", error);
|
||||
return NextResponse.json({ ok: false }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const targetRoute = String(searchParams.get("route") || "").trim();
|
||||
const summary = getVitalsSummary();
|
||||
|
||||
if (!targetRoute) {
|
||||
return NextResponse.json({ ok: true, ...summary });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
generatedAt: summary.generatedAt,
|
||||
route: targetRoute,
|
||||
metrics: summary.routes[targetRoute] || {},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
|
||||
|
||||
function normalizeNextPath(input: string | null) {
|
||||
const fallback = "/";
|
||||
const raw = String(input || "").trim();
|
||||
if (!raw) return fallback;
|
||||
if (!raw.startsWith("/")) return fallback;
|
||||
if (raw.startsWith("//")) return fallback;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
|
||||
const redirectUrl = request.nextUrl.clone();
|
||||
redirectUrl.pathname = nextPath;
|
||||
redirectUrl.search = "";
|
||||
|
||||
if (!hasSupabaseServerEnv()) {
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
}
|
||||
|
||||
const response = NextResponse.redirect(redirectUrl);
|
||||
const supabase = createSupabaseRouteClient(request, response);
|
||||
const code = request.nextUrl.searchParams.get("code");
|
||||
if (code) {
|
||||
await supabase.auth.exchangeCodeForSession(code);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { LoginClient } from "@/components/auth/LoginClient";
|
||||
import { I18nProvider } from "@/hooks/useI18n";
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<{ next?: string }>;
|
||||
};
|
||||
|
||||
function normalizeNextPath(input: string | undefined) {
|
||||
const fallback = "/";
|
||||
const raw = String(input || "").trim();
|
||||
if (!raw) return fallback;
|
||||
if (!raw.startsWith("/")) return fallback;
|
||||
if (raw.startsWith("//")) return fallback;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: PageProps) {
|
||||
const params = (await searchParams) || {};
|
||||
const nextPath = normalizeNextPath(params.next);
|
||||
return (
|
||||
<I18nProvider>
|
||||
<LoginClient nextPath={nextPath} />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { DocsScreen } from "@/components/docs/DocsScreen";
|
||||
import { DOCS_PAGES, getDocsPage } from "@/content/docs/docs";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return DOCS_PAGES.map((page) => ({ slug: [page.slug] }));
|
||||
}
|
||||
|
||||
export default async function DocsDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const resolvedParams = await params;
|
||||
if ((resolvedParams.slug?.length || 0) > 1) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const slug = resolvedParams.slug?.[0] || "intro";
|
||||
const page = getDocsPage(slug);
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <DocsScreen page={page} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { I18nProvider } from "@/hooks/useI18n";
|
||||
|
||||
export default function DocsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <I18nProvider>{children}</I18nProvider>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function DocsIndexPage() {
|
||||
redirect("/docs/intro");
|
||||
}
|
||||
@@ -33,9 +33,17 @@ export default async function EntitlementRequiredPage({ searchParams }: Props) {
|
||||
Entitlement Required
|
||||
</h1>
|
||||
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
|
||||
This dashboard is protected. Append{" "}
|
||||
<code>?access_token=<your-token></code> to the URL once, and
|
||||
the session cookie will be set automatically.
|
||||
This dashboard is protected. If Supabase auth is enabled, please go to{" "}
|
||||
<a
|
||||
href={`/auth/login?next=${encodeURIComponent(nextPath)}`}
|
||||
style={{ color: "#8fc5ff" }}
|
||||
>
|
||||
/auth/login
|
||||
</a>{" "}
|
||||
to sign in first.
|
||||
</p>
|
||||
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
|
||||
Legacy mode still supports <code>?access_token=<your-token></code>.
|
||||
</p>
|
||||
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
|
||||
Requested path: <code>{nextPath}</code>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -25,10 +24,6 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="zh-CN" className="dark">
|
||||
<head>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<link
|
||||
@@ -39,7 +34,6 @@ export default function RootLayout({
|
||||
<body className="min-h-screen font-sans antialiased">
|
||||
{children}
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import { OpsDashboard } from "@/components/ops/OpsDashboard";
|
||||
import { requireOpsAdmin } from "@/lib/ops-admin";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather Ops",
|
||||
description: "PolyWeather lightweight operations dashboard.",
|
||||
};
|
||||
|
||||
export default async function OpsPage() {
|
||||
await requireOpsAdmin("/ops");
|
||||
return <OpsDashboard />;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Coins,
|
||||
CreditCard,
|
||||
MessageSquare,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather | 订阅说明",
|
||||
description: "PolyWeather Pro 订阅、积分抵扣与支付方式说明。",
|
||||
};
|
||||
|
||||
const TELEGRAM_GROUP_URL = String(
|
||||
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
|
||||
"https://t.me/+nMG7SjziUKYyZmM1",
|
||||
).trim();
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{
|
||||
q: "Pro 包含哪些功能?",
|
||||
a: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、历史对账 + 未来日期分析、全平台智能气象推送。",
|
||||
},
|
||||
{
|
||||
q: "当前订阅价格是多少?",
|
||||
a: "目前仅提供月付:5 USDC / 30 天。",
|
||||
},
|
||||
{
|
||||
q: "积分如何抵扣?",
|
||||
a: "满 500 积分起兑,每 500 积分抵 1U,单次最多抵 3U。",
|
||||
},
|
||||
{
|
||||
q: "支持哪些钱包和支付方式?",
|
||||
a: "支持 EVM 浏览器钱包(MetaMask / OKX / Rabby / Bitget 等)及 WalletConnect 扫码钱包(Trust Wallet / Binance Web3 Wallet / TokenPocket 等)。",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SubscriptionHelpPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-[#070d1d] px-4 py-10 text-slate-100">
|
||||
<div className="mx-auto w-full max-w-4xl">
|
||||
<Link
|
||||
href="/account"
|
||||
className="mb-5 inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-300 transition hover:bg-white/10"
|
||||
>
|
||||
<ArrowLeft size={15} />
|
||||
返回账户中心
|
||||
</Link>
|
||||
|
||||
<section className="rounded-3xl border border-blue-400/20 bg-gradient-to-b from-[#162541] to-[#0e1730] p-6 md:p-8">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<ShieldCheck className="text-cyan-300" size={22} />
|
||||
<h1 className="text-2xl font-bold md:text-3xl">PolyWeather Pro 订阅说明</h1>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 md:text-base">
|
||||
这里是完整的订阅规则和支付说明。你可以先在页面内绑定钱包,再直接开通 Pro。
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-cyan-300">
|
||||
<CreditCard size={16} />
|
||||
<span className="text-sm font-semibold">订阅价格</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold">5 USDC / 30 天</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-emerald-300">
|
||||
<Coins size={16} />
|
||||
<span className="text-sm font-semibold">积分抵扣</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold">最多抵 3U</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-violet-300">
|
||||
<MessageSquare size={16} />
|
||||
<span className="text-sm font-semibold">社群积分</span>
|
||||
</div>
|
||||
<Link
|
||||
href={TELEGRAM_GROUP_URL}
|
||||
target="_blank"
|
||||
className="text-sm font-semibold text-blue-300 underline decoration-blue-500/50 underline-offset-4"
|
||||
>
|
||||
加入社群即可赚取积分
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6 rounded-3xl border border-white/10 bg-[#0f162a]/80 p-6 md:p-8">
|
||||
<h2 className="mb-4 text-lg font-bold">常见问题</h2>
|
||||
<div className="space-y-4">
|
||||
{FAQ_ITEMS.map((item) => (
|
||||
<article
|
||||
key={item.q}
|
||||
className="rounded-2xl border border-white/10 bg-white/[0.03] p-4"
|
||||
>
|
||||
<h3 className="mb-2 flex items-center gap-2 text-sm font-semibold text-blue-300">
|
||||
<CheckCircle2 size={14} />
|
||||
{item.q}
|
||||
</h3>
|
||||
<p className="text-sm leading-6 text-slate-300">{item.a}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
.page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
padding: 34px 20px 28px;
|
||||
color: #e2ecff;
|
||||
background:
|
||||
radial-gradient(circle at 8% 8%, rgba(56, 189, 248, 0.24), transparent 38%),
|
||||
radial-gradient(circle at 92% 10%, rgba(45, 212, 191, 0.2), transparent 34%),
|
||||
radial-gradient(circle at 50% 120%, rgba(14, 165, 233, 0.26), transparent 40%),
|
||||
#050b16;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.aurora {
|
||||
position: absolute;
|
||||
inset: -10% -5% auto;
|
||||
height: 340px;
|
||||
background: linear-gradient(
|
||||
95deg,
|
||||
rgba(56, 189, 248, 0.2) 0%,
|
||||
rgba(45, 212, 191, 0.12) 46%,
|
||||
rgba(99, 102, 241, 0.08) 100%
|
||||
);
|
||||
filter: blur(58px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gridNoise {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(148, 163, 184, 0.06) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, 0.06) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
mask-image: radial-gradient(circle at 50% 25%, black, transparent 72%);
|
||||
pointer-events: none;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
width: min(1120px, 100%);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.topBar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(15, 23, 42, 0.76);
|
||||
backdrop-filter: blur(14px);
|
||||
border-radius: 16px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.brandBlock {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: clamp(24px, 2.2vw, 30px);
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f8fbff;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #8ea3c9;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ghostBtn,
|
||||
.primaryBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
padding: 0 12px;
|
||||
color: #dce8ff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.22s ease;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghostBtn:hover,
|
||||
.primaryBtn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ghostBtn:hover {
|
||||
border-color: rgba(45, 212, 191, 0.45);
|
||||
color: #f8fcff;
|
||||
background: rgba(45, 212, 191, 0.12);
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
border-color: rgba(56, 189, 248, 0.52);
|
||||
background: linear-gradient(135deg, rgba(14, 116, 144, 0.88), rgba(6, 182, 212, 0.74));
|
||||
color: #f6fcff;
|
||||
box-shadow: 0 12px 26px rgba(8, 47, 73, 0.35);
|
||||
}
|
||||
|
||||
.primaryBtn:hover {
|
||||
border-color: rgba(103, 232, 249, 0.72);
|
||||
box-shadow: 0 16px 30px rgba(14, 116, 144, 0.42);
|
||||
}
|
||||
|
||||
.heroCard {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(56, 189, 248, 0.24);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(14, 116, 144, 0.17) 0%, rgba(15, 23, 42, 0.86) 58%),
|
||||
rgba(15, 23, 42, 0.8);
|
||||
border-radius: 18px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #e7fbff;
|
||||
border: 1px solid rgba(103, 232, 249, 0.45);
|
||||
background: linear-gradient(135deg, rgba(6, 182, 212, 0.65), rgba(59, 130, 246, 0.56));
|
||||
box-shadow: 0 18px 38px rgba(2, 132, 199, 0.28);
|
||||
}
|
||||
|
||||
.heroMain {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.heroMain h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(20px, 1.9vw, 28px);
|
||||
letter-spacing: -0.02em;
|
||||
color: #f8fcff;
|
||||
}
|
||||
|
||||
.heroMain p {
|
||||
margin: 0;
|
||||
color: #a7bcdd;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.badgeWarn,
|
||||
.badgeGhost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 5px 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border: 1px solid rgba(34, 197, 94, 0.45);
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.badgeWarn {
|
||||
border: 1px solid rgba(245, 158, 11, 0.46);
|
||||
background: rgba(245, 158, 11, 0.13);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.badgeGhost {
|
||||
border: 1px solid rgba(148, 163, 184, 0.34);
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.updatedText {
|
||||
justify-self: end;
|
||||
font-size: 12px;
|
||||
color: #8ea3c9;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.noticeRow,
|
||||
.errorRow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 12px;
|
||||
padding: 11px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.noticeRow {
|
||||
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
color: #bae6fd;
|
||||
}
|
||||
|
||||
.errorRow {
|
||||
border: 1px solid rgba(248, 113, 113, 0.44);
|
||||
background: rgba(127, 29, 29, 0.34);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card,
|
||||
.cardWide {
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.84), rgba(15, 23, 42, 0.72)),
|
||||
rgba(10, 15, 30, 0.78);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cardWide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.card h3,
|
||||
.cardWide h3 {
|
||||
margin: 0 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 15px;
|
||||
color: #e6f0ff;
|
||||
}
|
||||
|
||||
.metaList {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metaList > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border-bottom: 1px dashed rgba(148, 163, 184, 0.22);
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
|
||||
.metaList > div:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.metaList dt {
|
||||
font-size: 12px;
|
||||
color: #89a1c8;
|
||||
}
|
||||
|
||||
.metaList dd {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #eff6ff;
|
||||
max-width: 62%;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "Consolas", "SFMono-Regular", "Menlo", monospace;
|
||||
font-size: 12px !important;
|
||||
color: #c7d2fe !important;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: #9ab0d2;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.commandRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.command {
|
||||
flex: 1 1 420px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(103, 232, 249, 0.24);
|
||||
background: rgba(8, 47, 73, 0.32);
|
||||
color: #d1f9ff;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copyBtn {
|
||||
height: 36px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(45, 212, 191, 0.48);
|
||||
background: rgba(20, 184, 166, 0.12);
|
||||
color: #99f6e4;
|
||||
padding: 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.copyBtn:hover {
|
||||
background: rgba(20, 184, 166, 0.22);
|
||||
border-color: rgba(94, 234, 212, 0.78);
|
||||
color: #ecfeff;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topBar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.heroCard {
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.updatedText {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metaList dd {
|
||||
max-width: 56%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.page {
|
||||
padding: 18px 12px 20px;
|
||||
}
|
||||
|
||||
.topBar,
|
||||
.heroCard,
|
||||
.card,
|
||||
.cardWide {
|
||||
border-radius: 12px;
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.ghostBtn,
|
||||
.primaryBtn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ghostBtn,
|
||||
.primaryBtn {
|
||||
min-height: 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.heroCard {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.heroMain h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.updatedText {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.metaList > div {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metaList dd {
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.commandRow {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.command {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.copyBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.noticeRow,
|
||||
.errorRow {
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const AccountCenter = dynamic(
|
||||
() =>
|
||||
import("@/components/account/AccountCenter").then(
|
||||
(module) => module.AccountCenter,
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-300">
|
||||
<div className="mx-auto w-full max-w-6xl px-6 py-10">
|
||||
<div className="h-7 w-48 animate-pulse rounded bg-slate-800/80" />
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="h-32 animate-pulse rounded-3xl bg-slate-800/70 md:col-span-2" />
|
||||
<div className="h-32 animate-pulse rounded-3xl bg-slate-800/70" />
|
||||
</div>
|
||||
<div className="mt-6 h-72 animate-pulse rounded-3xl bg-slate-800/60" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export function AccountEntry() {
|
||||
return <AccountCenter />;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
Chrome,
|
||||
Cloud,
|
||||
CloudRain,
|
||||
Lock,
|
||||
Mail,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getSupabaseBrowserClient,
|
||||
hasSupabasePublicEnv,
|
||||
} from "@/lib/supabase/client";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
type Mode = "login" | "signup";
|
||||
|
||||
type LoginClientProps = {
|
||||
nextPath: string;
|
||||
};
|
||||
|
||||
export function LoginClient({ nextPath }: LoginClientProps) {
|
||||
const router = useRouter();
|
||||
const { locale } = useI18n();
|
||||
const [mode, setMode] = useState<Mode>("login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const [infoText, setInfoText] = useState("");
|
||||
|
||||
const supabaseReady = hasSupabasePublicEnv();
|
||||
const isEn = locale === "en-US";
|
||||
const copy = {
|
||||
backHome: isEn ? "Back to Home" : "返回首页",
|
||||
subtitle: isEn
|
||||
? "Explore weather details from every corner of the world"
|
||||
: "探索世界每一个角落的气象细节",
|
||||
googleOneClick: isEn
|
||||
? "Continue with Google"
|
||||
: "使用 Google 账号一键登录",
|
||||
orEmail: isEn ? "Or continue with email" : "或使用邮箱",
|
||||
login: isEn ? "Sign In" : "登录",
|
||||
signup: isEn ? "Sign Up" : "注册",
|
||||
passwordLoginPlaceholder: isEn ? "Enter password" : "输入密码",
|
||||
passwordSignupPlaceholder: isEn
|
||||
? "Set at least 6 characters"
|
||||
: "设置至少 6 位密码",
|
||||
loginSubmit: isEn ? "Start your weather journey" : "开启天气之旅",
|
||||
signupSubmit: isEn ? "Create account now" : "立即创建账号",
|
||||
loginHint: isEn
|
||||
? "After signing in, your homepage will be personalized."
|
||||
: "登录后将为您个性化定制首页数据",
|
||||
signupHint: isEn
|
||||
? "By signing up, you agree to our Terms of Service."
|
||||
: "注册即代表同意我们的服务条款",
|
||||
realtime: isEn ? "Realtime data" : "实时数据",
|
||||
highPrecision: isEn ? "High-precision forecast" : "高精度预测",
|
||||
supabaseMissing: isEn
|
||||
? "Supabase is not configured. Sign-in is unavailable."
|
||||
: "Supabase 未配置,无法使用登录",
|
||||
needEmailPassword: isEn
|
||||
? "Please enter email and password."
|
||||
: "请输入邮箱和密码",
|
||||
signupCheckEmail: isEn
|
||||
? "Sign-up successful. Please verify your email before signing in."
|
||||
: "注册成功,请检查邮箱并完成验证后登录。",
|
||||
} as const;
|
||||
|
||||
useEffect(() => {
|
||||
if (!supabaseReady) return;
|
||||
const run = async () => {
|
||||
const supabase = getSupabaseBrowserClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (session?.user) {
|
||||
router.replace(nextPath);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
}, [nextPath, router, supabaseReady]);
|
||||
|
||||
const onGoogleSignIn = async () => {
|
||||
setErrorText("");
|
||||
setInfoText("");
|
||||
if (!supabaseReady) {
|
||||
setErrorText(copy.supabaseMissing);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient();
|
||||
const redirectTo = `${window.location.origin}/auth/callback?next=${encodeURIComponent(
|
||||
nextPath,
|
||||
)}`;
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: "google",
|
||||
options: {
|
||||
redirectTo,
|
||||
},
|
||||
});
|
||||
if (error) {
|
||||
setErrorText(error.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onEmailSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorText("");
|
||||
setInfoText("");
|
||||
if (!supabaseReady) {
|
||||
setErrorText(copy.supabaseMissing);
|
||||
return;
|
||||
}
|
||||
if (!email.trim() || !password.trim()) {
|
||||
setErrorText(copy.needEmailPassword);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient();
|
||||
if (mode === "login") {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
if (error) {
|
||||
setErrorText(error.message);
|
||||
return;
|
||||
}
|
||||
router.replace(nextPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const emailRedirectTo = `${window.location.origin}/auth/callback?next=${encodeURIComponent(
|
||||
nextPath,
|
||||
)}`;
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: email.trim(),
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo,
|
||||
},
|
||||
});
|
||||
if (error) {
|
||||
setErrorText(error.message);
|
||||
return;
|
||||
}
|
||||
if (data.session?.user) {
|
||||
router.replace(nextPath);
|
||||
return;
|
||||
}
|
||||
setInfoText(copy.signupCheckEmail);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isLogin = mode === "login";
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen w-full items-center justify-center overflow-hidden bg-[#0f172a] font-sans">
|
||||
<div className="absolute left-[-10%] top-[-10%] h-[40vw] w-[40vw] animate-pulse rounded-full bg-blue-600/20 blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] h-[30vw] w-[30vw] rounded-full bg-indigo-500/20 blur-[100px]" />
|
||||
|
||||
<div className="relative mx-4 w-full max-w-[420px] rounded-[2rem] border border-white/10 bg-white/5 p-8 shadow-2xl backdrop-blur-xl">
|
||||
<Link
|
||||
href="/"
|
||||
className="group absolute left-6 top-6 rounded-full border border-white/10 bg-white/5 p-2 text-slate-400 transition-all hover:bg-white/10 hover:text-white active:scale-90"
|
||||
title={copy.backHome}
|
||||
aria-label={copy.backHome}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5 transition-transform group-hover:-translate-x-0.5" />
|
||||
</Link>
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-tr from-blue-500 to-indigo-400 shadow-lg shadow-blue-500/20">
|
||||
<Cloud className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-white">PolyWeather</h1>
|
||||
<p className="mt-2 text-sm text-slate-400">{copy.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onGoogleSignIn()}
|
||||
disabled={loading}
|
||||
className="mb-6 flex w-full items-center justify-center rounded-xl bg-white px-4 py-3.5 font-semibold text-slate-900 shadow-lg transition-all duration-200 hover:bg-slate-100 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
<Chrome className="mr-3 h-5 w-5" />
|
||||
{copy.googleOneClick}
|
||||
</button>
|
||||
|
||||
<div className="my-6 flex items-center">
|
||||
<div className="h-[1px] flex-grow bg-white/10" />
|
||||
<span className="px-4 text-xs font-medium uppercase tracking-widest text-slate-500">
|
||||
{copy.orEmail}
|
||||
</span>
|
||||
<div className="h-[1px] flex-grow bg-white/10" />
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex rounded-xl bg-black/20 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("login")}
|
||||
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-all ${
|
||||
isLogin
|
||||
? "bg-blue-600 text-white shadow-md"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{copy.login}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("signup")}
|
||||
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-all ${
|
||||
!isLogin
|
||||
? "bg-blue-600 text-white shadow-md"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{copy.signup}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(event) => void onEmailSubmit(event)} className="space-y-4">
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 py-3.5 pl-12 pr-4 text-white placeholder:text-slate-600 transition-all focus:border-blue-500/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={6}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder={
|
||||
isLogin
|
||||
? copy.passwordLoginPlaceholder
|
||||
: copy.passwordSignupPlaceholder
|
||||
}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 py-3.5 pl-12 pr-4 text-white placeholder:text-slate-600 transition-all focus:border-blue-500/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group mt-8 flex w-full items-center justify-center rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 py-3.5 font-bold text-white shadow-xl shadow-blue-600/20 transition-all hover:from-blue-500 hover:to-indigo-500 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{isLogin ? copy.loginSubmit : copy.signupSubmit}
|
||||
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{errorText ? <p className="mt-4 text-sm text-rose-300">{errorText}</p> : null}
|
||||
{infoText ? <p className="mt-4 text-sm text-emerald-300">{infoText}</p> : null}
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-xs text-slate-500">
|
||||
{isLogin ? copy.loginHint : copy.signupHint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!supabaseReady ? (
|
||||
<p className="mt-3 text-center text-sm text-rose-300">{copy.supabaseMissing}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-8 flex items-center gap-4 text-sm text-slate-600">
|
||||
<span className="flex items-center">
|
||||
<Sun className="mr-1 h-4 w-4" /> {copy.realtime}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<CloudRain className="mr-1 h-4 w-4" /> {copy.highPrecision}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
@@ -57,18 +57,19 @@ export function CitySidebar() {
|
||||
Record<RiskGroupKey, boolean>
|
||||
>(DEFAULT_EXPANDED_GROUPS);
|
||||
|
||||
const sortedCities = useMemo(() => [...store.cities].sort((a, b) => {
|
||||
const aSelected = a.name === selectedCity;
|
||||
const bSelected = b.name === selectedCity;
|
||||
if (aSelected !== bSelected) return aSelected ? -1 : 1;
|
||||
const aGroup = toRiskGroup(a.risk_level);
|
||||
const bGroup = toRiskGroup(b.risk_level);
|
||||
return (
|
||||
(riskOrder[aGroup] ?? 3) -
|
||||
(riskOrder[bGroup] ?? 3) ||
|
||||
a.display_name.localeCompare(b.display_name)
|
||||
);
|
||||
}), [store.cities, selectedCity]);
|
||||
const sortedCities = useMemo(
|
||||
() =>
|
||||
[...store.cities].sort((a, b) => {
|
||||
const aGroup = toRiskGroup(a.risk_level);
|
||||
const bGroup = toRiskGroup(b.risk_level);
|
||||
return (
|
||||
(riskOrder[aGroup] ?? 3) -
|
||||
(riskOrder[bGroup] ?? 3) ||
|
||||
a.display_name.localeCompare(b.display_name)
|
||||
);
|
||||
}),
|
||||
[store.cities],
|
||||
);
|
||||
|
||||
const groupedCities = useMemo(() => {
|
||||
const groups: Record<RiskGroupKey, CityListItem[]> = {
|
||||
@@ -164,13 +165,34 @@ export function CitySidebar() {
|
||||
const summary = store.citySummariesByName[city.name];
|
||||
const snapshot = detail || summary;
|
||||
const isActive = store.selectedCity === city.name;
|
||||
const tempSymbol = snapshot?.temp_symbol || "°C";
|
||||
const currentTempText =
|
||||
snapshot?.current?.temp != null
|
||||
? t("sidebar.currentTemp", {
|
||||
temp: `${snapshot.current.temp}${tempSymbol}`,
|
||||
})
|
||||
: t("common.na");
|
||||
const peakTempText =
|
||||
detail?.current?.max_so_far != null &&
|
||||
detail.current.max_temp_time
|
||||
? t("sidebar.peakTempAt", {
|
||||
temp: `${detail.current.max_so_far}${tempSymbol}`,
|
||||
time: detail.current.max_temp_time,
|
||||
})
|
||||
: detail?.current?.max_temp_time
|
||||
? t("sidebar.peakAt", { time: detail.current.max_temp_time })
|
||||
: "";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={city.name}
|
||||
type="button"
|
||||
className={clsx("city-item", isActive && "active")}
|
||||
onClick={() => void store.selectCity(city.name)}
|
||||
onClick={() =>
|
||||
startTransition(() => {
|
||||
void store.selectCity(city.name);
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="city-item-main">
|
||||
<span className={clsx("risk-dot", city.risk_level)} />
|
||||
@@ -181,9 +203,7 @@ export function CitySidebar() {
|
||||
snapshot?.current?.temp != null && "loaded",
|
||||
)}
|
||||
>
|
||||
{snapshot?.current?.temp != null
|
||||
? `${snapshot.current.temp}${snapshot.temp_symbol || "°C"}`
|
||||
: t("common.na")}
|
||||
{currentTempText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -191,11 +211,7 @@ export function CitySidebar() {
|
||||
<span className="city-local-time">
|
||||
{snapshot?.local_time ? `🕒 ${snapshot.local_time}` : ""}
|
||||
</span>
|
||||
<span className="city-max-info">
|
||||
{detail?.current?.max_temp_time
|
||||
? t("sidebar.peakAt", { time: detail.current.max_temp_time })
|
||||
: ""}
|
||||
</span>
|
||||
<span className="city-max-info">{peakTempText}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { ChartConfiguration } from "chart.js/auto";
|
||||
import type { ChartConfiguration } from "chart.js";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ForecastTable } from "@/components/dashboard/PanelSections";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
|
||||
import { getCityScenery } from "@/lib/dashboard-scenery";
|
||||
import { CityDetail } from "@/lib/dashboard-types";
|
||||
import {
|
||||
@@ -17,100 +19,102 @@ import {
|
||||
|
||||
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
|
||||
const { locale, t } = useI18n();
|
||||
const chartData = getTemperatureChartData(detail, locale);
|
||||
|
||||
const canvasRef = useChart(
|
||||
() => {
|
||||
if (!chartData) {
|
||||
return {
|
||||
data: { datasets: [], labels: [] },
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
|
||||
const forecastPoints = chartData.datasets.hasMgmHourly
|
||||
? chartData.datasets.mgmHourlyPoints
|
||||
: chartData.datasets.debPast.map(
|
||||
(value, index) => value ?? chartData.datasets.debFuture[index],
|
||||
);
|
||||
const chartData = useMemo(
|
||||
() => getTemperatureChartData(detail, locale),
|
||||
[detail, locale],
|
||||
);
|
||||
|
||||
const canvasRef = useChart(() => {
|
||||
if (!chartData) {
|
||||
return {
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
borderColor: chartData.datasets.hasMgmHourly
|
||||
? "rgba(250, 204, 21, 0.92)"
|
||||
: "rgba(52, 211, 153, 0.86)",
|
||||
borderWidth: 1.8,
|
||||
data: forecastPoints,
|
||||
fill: false,
|
||||
label: chartData.datasets.hasMgmHourly
|
||||
? locale === "en-US"
|
||||
? "MGM Forecast"
|
||||
: "MGM 预测"
|
||||
: locale === "en-US"
|
||||
? "DEB Forecast"
|
||||
: "DEB 预测",
|
||||
pointRadius: 0,
|
||||
spanGaps: true,
|
||||
tension: 0.28,
|
||||
},
|
||||
{
|
||||
backgroundColor: "#22d3ee",
|
||||
borderColor: "#22d3ee",
|
||||
borderWidth: 0,
|
||||
data: chartData.datasets.metarPoints,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 3.8,
|
||||
showLine: false,
|
||||
},
|
||||
],
|
||||
labels: chartData.times,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.95)",
|
||||
borderColor: "rgba(34, 211, 238, 0.25)",
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.03)" },
|
||||
ticks: {
|
||||
callback: (_value, index) =>
|
||||
typeof index === "number" && index % 4 === 0
|
||||
? chartData.times[index]
|
||||
: "",
|
||||
color: "#64748b",
|
||||
font: { size: 10 },
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.03)" },
|
||||
max: chartData.max,
|
||||
min: chartData.min,
|
||||
ticks: {
|
||||
callback: (value) => `${value}${detail.temp_symbol || "°C"}`,
|
||||
color: "#64748b",
|
||||
font: { size: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
data: { datasets: [], labels: [] },
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
},
|
||||
[chartData, detail.temp_symbol, locale],
|
||||
);
|
||||
}
|
||||
|
||||
const forecastPoints = chartData.datasets.hasMgmHourly
|
||||
? chartData.datasets.mgmHourlyPoints
|
||||
: chartData.datasets.debPast.map(
|
||||
(value, index) => value ?? chartData.datasets.debFuture[index],
|
||||
);
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
borderColor: chartData.datasets.hasMgmHourly
|
||||
? "rgba(250, 204, 21, 0.92)"
|
||||
: "rgba(52, 211, 153, 0.86)",
|
||||
borderWidth: 1.8,
|
||||
data: forecastPoints,
|
||||
fill: false,
|
||||
label: chartData.datasets.hasMgmHourly
|
||||
? locale === "en-US"
|
||||
? "MGM Forecast"
|
||||
: "MGM 预测"
|
||||
: locale === "en-US"
|
||||
? "DEB Forecast"
|
||||
: "DEB 预测",
|
||||
pointRadius: 0,
|
||||
spanGaps: true,
|
||||
tension: 0.28,
|
||||
},
|
||||
{
|
||||
backgroundColor: "#22d3ee",
|
||||
borderColor: "#22d3ee",
|
||||
borderWidth: 0,
|
||||
data: chartData.datasets.metarPoints,
|
||||
fill: false,
|
||||
label:
|
||||
chartData.observationLabel ||
|
||||
(locale === "en-US" ? "METAR Observation" : "METAR 实况"),
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 3.8,
|
||||
showLine: false,
|
||||
},
|
||||
],
|
||||
labels: chartData.times,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.95)",
|
||||
borderColor: "rgba(34, 211, 238, 0.25)",
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.03)" },
|
||||
ticks: {
|
||||
callback: (_value, index) =>
|
||||
typeof index === "number" && index % 4 === 0
|
||||
? chartData.times[index]
|
||||
: "",
|
||||
color: "#64748b",
|
||||
font: { size: 10 },
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.03)" },
|
||||
max: chartData.max,
|
||||
min: chartData.min,
|
||||
ticks: {
|
||||
callback: (value) => `${value}${detail.temp_symbol || "°C"}`,
|
||||
color: "#64748b",
|
||||
font: { size: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}, [chartData, detail.temp_symbol, locale]);
|
||||
|
||||
return (
|
||||
<div className="detail-mini-chart-wrap">
|
||||
@@ -127,19 +131,42 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
|
||||
export function DetailPanel() {
|
||||
const store = useDashboardStore();
|
||||
const { locale, t } = useI18n();
|
||||
const router = useRouter();
|
||||
const detail = store.selectedDetail;
|
||||
const selectedCityItem = useMemo(
|
||||
() =>
|
||||
store.selectedCity
|
||||
? store.cities.find((city) => city.name === store.selectedCity) || null
|
||||
: null,
|
||||
[store.cities, store.selectedCity],
|
||||
);
|
||||
const isPro = store.proAccess.subscriptionActive;
|
||||
const isAuthenticated = store.proAccess.authenticated;
|
||||
const panelRef = useRef<HTMLElement | null>(null);
|
||||
const [heavyContentReady, setHeavyContentReady] = useState(false);
|
||||
const isOverlayOpen =
|
||||
Boolean(store.futureModalDate) ||
|
||||
store.historyState.isOpen ||
|
||||
store.isGuideOpen;
|
||||
store.historyState.isOpen;
|
||||
const isVisible =
|
||||
store.isPanelOpen &&
|
||||
Boolean(store.selectedCity) &&
|
||||
Boolean(detail) &&
|
||||
!store.loadingState.cityDetail &&
|
||||
!isOverlayOpen;
|
||||
const profileStats = detail ? getCityProfileStats(detail, locale) : [];
|
||||
const panelDisplayName =
|
||||
detail?.display_name ||
|
||||
selectedCityItem?.display_name ||
|
||||
store.selectedCity ||
|
||||
"...";
|
||||
const panelRiskLevel = detail?.risk?.level || selectedCityItem?.risk_level || "low";
|
||||
const profileStats = useMemo(
|
||||
() => (detail ? getCityProfileStats(detail, locale) : []),
|
||||
[detail, locale],
|
||||
);
|
||||
const officialLinks = useMemo(
|
||||
() => (detail ? getOfficialSourceLinks(detail) : []),
|
||||
[detail],
|
||||
);
|
||||
const scenery = getCityScenery(detail?.name);
|
||||
const blurActiveElement = () => {
|
||||
if (typeof document === "undefined") return;
|
||||
@@ -148,6 +175,29 @@ export function DetailPanel() {
|
||||
active.blur();
|
||||
}
|
||||
};
|
||||
const handleFeatureAccess = (feature: "today" | "history") => {
|
||||
blurActiveElement();
|
||||
|
||||
if (isPro) {
|
||||
if (feature === "today") {
|
||||
void store.openTodayModal();
|
||||
return;
|
||||
}
|
||||
void store.openHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
router.push("/account");
|
||||
return;
|
||||
}
|
||||
|
||||
if (feature === "today") {
|
||||
void store.openTodayModal();
|
||||
return;
|
||||
}
|
||||
void store.openHistory();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
@@ -170,6 +220,42 @@ export function DetailPanel() {
|
||||
panel.removeAttribute("inert");
|
||||
}, [isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !detail) {
|
||||
setHeavyContentReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let canceled = false;
|
||||
let timeoutId: number | null = null;
|
||||
let idleId: number | null = null;
|
||||
const win = typeof window !== "undefined" ? (window as any) : null;
|
||||
|
||||
const markReady = () => {
|
||||
if (!canceled) {
|
||||
setHeavyContentReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (win && typeof win.requestIdleCallback === "function") {
|
||||
idleId = win.requestIdleCallback(markReady, { timeout: 180 });
|
||||
} else if (typeof window !== "undefined") {
|
||||
timeoutId = window.setTimeout(markReady, 80);
|
||||
} else {
|
||||
setHeavyContentReady(true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
if (win && idleId != null && typeof win.cancelIdleCallback === "function") {
|
||||
win.cancelIdleCallback(idleId);
|
||||
}
|
||||
if (timeoutId != null && typeof window !== "undefined") {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [detail, isVisible]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
@@ -188,40 +274,39 @@ export function DetailPanel() {
|
||||
×
|
||||
</button>
|
||||
<div className="panel-title-area">
|
||||
<h2>{detail?.display_name?.toUpperCase() || "..."}</h2>
|
||||
<h2>{panelDisplayName.toUpperCase()}</h2>
|
||||
<div className="panel-meta">
|
||||
<span className={clsx("risk-badge", detail?.risk?.level || "low")}>
|
||||
{getRiskBadgeLabel(detail?.risk?.level, locale)}
|
||||
<span className={clsx("risk-badge", panelRiskLevel)}>
|
||||
{getRiskBadgeLabel(panelRiskLevel, locale)}
|
||||
</span>
|
||||
<span className="local-time">
|
||||
{detail
|
||||
? `${detail.local_date} ${detail.local_time}`
|
||||
: t("detail.waitSelect")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="history-btn"
|
||||
title={t("detail.todayAnalysis")}
|
||||
onClick={() => {
|
||||
blurActiveElement();
|
||||
void store.openTodayModal();
|
||||
}}
|
||||
disabled={!detail}
|
||||
>
|
||||
{t("detail.todayAnalysis")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="history-btn"
|
||||
title={t("detail.history")}
|
||||
onClick={() => {
|
||||
blurActiveElement();
|
||||
void store.openHistory();
|
||||
}}
|
||||
disabled={!detail}
|
||||
>
|
||||
{t("detail.history")}
|
||||
</button>
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("history-btn", !isPro && "pro-locked")}
|
||||
title={
|
||||
isPro
|
||||
? t("detail.todayAnalysis")
|
||||
: `${t("detail.todayAnalysis")} (Pro)`
|
||||
}
|
||||
onClick={() => handleFeatureAccess("today")}
|
||||
disabled={!store.selectedCity}
|
||||
>
|
||||
{isPro
|
||||
? t("detail.todayAnalysis")
|
||||
: `${t("detail.todayAnalysis")} · Pro`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("history-btn", !isPro && "pro-locked")}
|
||||
title={
|
||||
isPro ? t("detail.history") : `${t("detail.history")} (Pro)`
|
||||
}
|
||||
onClick={() => handleFeatureAccess("history")}
|
||||
disabled={!store.selectedCity}
|
||||
>
|
||||
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,12 +328,12 @@ export function DetailPanel() {
|
||||
<img
|
||||
className="detail-scenery-image"
|
||||
src={scenery.imageUrl}
|
||||
alt={t("detail.sceneryAlt", { city: detail.display_name })}
|
||||
alt={t("detail.sceneryAlt", { city: detail?.display_name || "" })}
|
||||
/>
|
||||
<div className="detail-scenery-overlay">
|
||||
<div className="detail-scenery-copy">
|
||||
<span className="detail-scenery-kicker">
|
||||
{detail.display_name}
|
||||
{detail?.display_name}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
@@ -263,7 +348,9 @@ export function DetailPanel() {
|
||||
</>
|
||||
) : (
|
||||
<div className="detail-scenery-fallback">
|
||||
<span className="detail-scenery-kicker">{detail.display_name}</span>
|
||||
<span className="detail-scenery-kicker">
|
||||
{detail?.display_name}
|
||||
</span>
|
||||
<strong className="detail-scenery-title">
|
||||
{t("detail.sceneryTitle")}
|
||||
</strong>
|
||||
@@ -286,12 +373,43 @@ export function DetailPanel() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="detail-section">
|
||||
{officialLinks.length > 0 ? (
|
||||
<section className="detail-section">
|
||||
<h3>{locale === "en-US" ? "Official Sources" : "官方参考"}</h3>
|
||||
<p className="detail-source-note">
|
||||
{locale === "en-US"
|
||||
? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page."
|
||||
: "AGENCY = 国家气象机构,METAR = 机场实测报文,AIRPORT = 机场官网页面。"}
|
||||
</p>
|
||||
<div className="detail-source-list">
|
||||
{officialLinks.map((link) => (
|
||||
<a
|
||||
key={`${link.label}-${link.href}`}
|
||||
className="detail-source-link"
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span className="detail-source-kind">
|
||||
{link.kind.toUpperCase()}
|
||||
</span>
|
||||
<span className="detail-source-label">{link.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="detail-section rounded-2xl">
|
||||
<h3>{t("detail.todayMiniTrend")}</h3>
|
||||
<DetailMiniTemperatureChart detail={detail} />
|
||||
{heavyContentReady ? (
|
||||
<DetailMiniTemperatureChart detail={detail!} />
|
||||
) : (
|
||||
<div className="detail-mini-meta">{t("detail.loading")}</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ForecastTable />
|
||||
{heavyContentReady ? <ForecastTable /> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user