Compare commits

...

78 Commits

Author SHA1 Message Date
2569718930@qq.com 8d26afdec0 Reduce terminal auth and analytics stalls 2026-05-31 19:21:53 +08:00
2569718930@qq.com 5083b7c433 Instrument API timing and reduce detail fallbacks 2026-05-31 18:58:57 +08:00
2569718930@qq.com 668f4d9bd3 Optimize terminal batching and cache guards 2026-05-31 18:28:50 +08:00
2569718930@qq.com a4253c224a Add auth profile timing instrumentation 2026-05-31 18:27:04 +08:00
2569718930@qq.com 46effcd45f Optimize terminal detail loading and Redis replay 2026-05-31 04:32:48 +08:00
2569718930@qq.com 2a6d8748f4 Improve ops monitoring and chart loading 2026-05-31 03:21:58 +08:00
2569718930@qq.com ca72072da0 Optimize terminal startup and deployment smoke checks 2026-05-31 01:56:08 +08:00
2569718930@qq.com bf0fbe1b1d Optimize landing server rendering 2026-05-30 23:43:07 +08:00
2569718930@qq.com 30e9513ce2 Update deploy smoke check test 2026-05-30 23:00:48 +08:00
2569718930@qq.com aec9dd6a54 Harden production deploy smoke checks 2026-05-30 22:51:25 +08:00
2569718930@qq.com 6222f60573 Improve terminal chart loading states 2026-05-30 22:37:08 +08:00
2569718930@qq.com a48a7bcca5 Pass ops admin whitelist to frontend 2026-05-30 22:18:48 +08:00
2569718930@qq.com 74a2d71644 Reduce scan terminal origin load 2026-05-30 21:55:45 +08:00
2569718930@qq.com 9c239c0394 Add login button pending feedback 2026-05-30 21:31:13 +08:00
2569718930@qq.com 383dfe4d0e Harden production deploy stability 2026-05-30 21:21:30 +08:00
2569718930@qq.com 52f92d8650 Speed up terminal auth snapshot loading 2026-05-30 21:08:19 +08:00
2569718930@qq.com d9eea721ef feat: implement /api/auth/me proxy route with subscription-required state and fallback entitlement snapshots 2026-05-30 21:01:48 +08:00
2569718930@qq.com aa583e7440 Add ops runtime secret rotation 2026-05-30 20:33:30 +08:00
2569718930@qq.com e00405df8a Tighten AMSC ops health check 2026-05-30 20:10:14 +08:00
2569718930@qq.com 9bcfd9d1eb Improve landing performance and analytics funnel 2026-05-30 19:50:50 +08:00
2569718930@qq.com 3fcda3f3cd Tighten cities fallback timeout 2026-05-30 19:38:31 +08:00
2569718930@qq.com 345691ad71 Add cities static fallback 2026-05-30 19:32:25 +08:00
2569718930@qq.com e864181c3c Improve terminal entitlement resilience 2026-05-30 19:10:19 +08:00
2569718930@qq.com 097971f107 Fix terminal subscription gate on unknown auth state 2026-05-30 18:34:38 +08:00
2569718930@qq.com 90bc895000 Update referral points pricing 2026-05-30 18:04:36 +08:00
2569718930@qq.com c0b20ed1bf feat: implement TemperatureStatsBars component for displaying thermal data with i18n support 2026-05-30 16:42:03 +08:00
2569718930@qq.com 5470da6b3a feat: implement CitySelectorDropdown for ScanTerminalDashboard with multi-filter search and regional categorization 2026-05-30 16:39:23 +08:00
2569718930@qq.com a03b8095ee Refresh entitlement after payment confirmation 2026-05-30 16:26:32 +08:00
2569718930@qq.com 91896b4dad Prevent duplicate payment confirmations 2026-05-30 16:17:15 +08:00
2569718930@qq.com 44638760b7 feat: implement atomic signup trial claims and referral discount logic with Supabase database schema updates 2026-05-30 16:02:02 +08:00
2569718930@qq.com c0b1edaed0 feat: implement payment proxy routes and add security validation tests for wallet and intent operations 2026-05-30 15:41:15 +08:00
2569718930@qq.com 853a652e9b fix: degrade auth profile on backend outage 2026-05-30 15:06:22 +08:00
2569718930@qq.com c79619898a fix: avoid terminal cold-start paywall race 2026-05-30 14:48:09 +08:00
2569718930@qq.com 1c250906ed fix: keep terminal stable across auth refresh 2026-05-30 14:33:26 +08:00
2569718930@qq.com ed8c898e0e fix: retry deploy smoke checks 2026-05-30 14:24:02 +08:00
2569718930@qq.com 57240e02ee fix: require auth token for manual payments 2026-05-30 14:09:57 +08:00
2569718930@qq.com c26f89f134 feat: implement terminal authentication bootstrap and integrate into ScanTerminalDashboard 2026-05-30 00:05:19 +08:00
2569718930@qq.com b3a63aa9af test: add unit tests for temperature series visibility policy and peak glow state logic 2026-05-29 23:21:17 +08:00
2569718930@qq.com 53268560ec test: implement visibility policy and chart logic verification tests for temperature series 2026-05-29 23:09:16 +08:00
2569718930@qq.com 68416b058e feat: implement temperature chart visibility logic with comprehensive unit testing and analysis service support 2026-05-29 22:40:33 +08:00
2569718930@qq.com 95c2258db0 Fix Turkey MGM panel history 2026-05-29 21:56:57 +08:00
2569718930@qq.com 8507afedd6 Fix account payment layout responsiveness 2026-05-29 21:34:36 +08:00
2569718930@qq.com 3e24080466 feat: implement AccountCenter dashboard with subscription and payment management features 2026-05-29 21:23:12 +08:00
2569718930@qq.com 17ddd835e6 Fix landing hero illustration overlap 2026-05-29 20:57:59 +08:00
2569718930@qq.com 5228d2b3fd Update landing and weather observation policies 2026-05-29 20:46:35 +08:00
2569718930@qq.com 2f039abb2c Add event fallback for referral program 2026-05-29 19:34:56 +08:00
2569718930@qq.com 522e35de7f Add trial and referral subscription program 2026-05-29 19:24:46 +08:00
2569718930@qq.com f8f5035225 Fix runway chart collapse and ops cleanup 2026-05-29 18:40:29 +08:00
2569718930@qq.com 23b5fafb25 Reduce idle payment confirm polling 2026-05-29 18:14:59 +08:00
2569718930@qq.com 2269aaefd7 Reduce Supabase disk IO 2026-05-29 17:22:33 +08:00
2569718930@qq.com 9e3a4e2f45 Fix WU historical current observation merge 2026-05-29 17:20:07 +08:00
2569718930@qq.com 8f52b1d80c Add password reset flow 2026-05-29 12:28:08 +08:00
2569718930@qq.com 1d9f0033a6 Tighten payment tx validation before submit 2026-05-29 12:11:19 +08:00
2569718930@qq.com 618bac8b57 feat: add live temperature threshold chart logic and components for runway monitoring 2026-05-29 02:10:07 +08:00
2569718930@qq.com f2e90fbcda feat: support ethereum usdc payment route 2026-05-29 01:57:42 +08:00
2569718930@qq.com 3cc2251b9b fix: translate settlement runway chart labels 2026-05-28 22:44:36 +08:00
2569718930@qq.com c2fe8caddf docs: refresh v1.8.1 release docs 2026-05-28 20:46:35 +08:00
2569718930@qq.com 784b27a954 fix: preserve terminal access during auth sync 2026-05-28 12:18:43 +08:00
2569718930@qq.com eef611adb4 fix: align telegram runway slope with settlement endpoint 2026-05-28 11:05:50 +08:00
2569718930@qq.com f56c604677 feat: implement analysis service and TTL configuration for regional city-specific cache management 2026-05-28 10:59:54 +08:00
2569718930@qq.com 79b82a34cb fix: use settlement runway endpoint temperatures 2026-05-28 10:55:32 +08:00
2569718930@qq.com d83a0f0eef feat: build DEB hourly consensus for peak windows 2026-05-28 10:44:48 +08:00
2569718930@qq.com 12d911f356 ci: retry image pulls during deploy 2026-05-28 10:16:31 +08:00
2569718930@qq.com be549daf50 fix: use multi-model peak window for gaussian probability 2026-05-28 10:03:29 +08:00
2569718930@qq.com fb4a9d7e09 fix: align gaussian probability with local forecast date 2026-05-28 09:44:42 +08:00
2569718930@qq.com 2e6349cbc6 fix: align scan terminal proxy and build timeouts 2026-05-28 09:23:01 +08:00
2569718930@qq.com 4c68fe696b fix: keep scan build timeout below proxy limit 2026-05-28 09:16:01 +08:00
2569718930@qq.com b239ad131f fix: keep terminal charts visible during scan refresh 2026-05-28 09:07:09 +08:00
2569718930@qq.com 651a93cacf fix: remove unused city runtime imports 2026-05-28 08:29:57 +08:00
2569718930@qq.com 5d784f78b9 feat: implement scan terminal dashboard system and supporting services 2026-05-28 08:24:50 +08:00
2569718930@qq.com 725727d763 fix: restore Hong Kong CoWIN reference curve 2026-05-28 08:14:27 +08:00
2569718930@qq.com 1a7d2a487f feat: add hourly peak correction for DEB charts 2026-05-27 22:41:16 +08:00
2569718930@qq.com 14141314d7 fix: preserve DEB metadata in city summary 2026-05-27 21:50:28 +08:00
2569718930@qq.com 8609376c59 feat: add versioned DEB bias backtesting 2026-05-27 21:40:26 +08:00
2569718930@qq.com 4d22191a2d Fix city selector fallback coverage 2026-05-27 21:02:08 +08:00
2569718930@qq.com fb7e27d9ed fix: recover account subscription state 2026-05-27 12:14:07 +08:00
2569718930@qq.com 7ff1d21b95 fix: stabilize realtime chart axis and redis deployment 2026-05-27 11:51:22 +08:00
2569718930@qq.com 573768846e feat: implement real-time SSE event architecture with Redis stream integration and add associated validation tests 2026-05-27 11:03:04 +08:00
291 changed files with 28038 additions and 3943 deletions
+23 -4
View File
@@ -9,7 +9,7 @@
######################################## ########################################
ENV=production ENV=production
LOG_LEVEL=INFO LOG_LEVEL=INFO
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/ POLYWEATHER_MAP_URL=https://polyweather.top/
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json
@@ -19,9 +19,16 @@ UVICORN_WORKERS=1
UID=1000 UID=1000
GID=1000 GID=1000
POLYWEATHER_STATE_STORAGE_MODE=sqlite POLYWEATHER_STATE_STORAGE_MODE=sqlite
# Realtime chart event store. Production should use Redis Stream; local/single-process
# development can set POLYWEATHER_EVENT_STORE=sqlite.
POLYWEATHER_EVENT_STORE=redis
POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
POLYWEATHER_REDIS_STREAM_KEY=stream:city_observation
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
POLYWEATHER_REDIS_REQUIRED=true
# Backend CORS allowlist. Add your Vercel production/preview domains when # Backend CORS allowlist. Add your Vercel production/preview domains when
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL points browsers directly at this backend. # NEXT_PUBLIC_POLYWEATHER_API_BASE_URL points browsers directly at this backend.
WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather-pro.vercel.app WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather.top,https://www.polyweather.top,https://api.polyweather.top
######################################## ########################################
# 2) Telegram bot minimal # 2) Telegram bot minimal
@@ -39,7 +46,7 @@ TELEGRAM_QUERY_TOPIC_CHAT_ID=
TELEGRAM_QUERY_TOPIC_ID= TELEGRAM_QUERY_TOPIC_ID=
TELEGRAM_QUERY_TOPIC_MAP= TELEGRAM_QUERY_TOPIC_MAP=
POLYWEATHER_BOT_GROUP_INVITE_URL= POLYWEATHER_BOT_GROUP_INVITE_URL=
POLYWEATHER_APP_URL=https://polyweather-pro.vercel.app POLYWEATHER_APP_URL=https://polyweather.top
# Global Telegram auto-push copy: both, en, or zh. Module-specific vars can override it. # Global Telegram auto-push copy: both, en, or zh. Module-specific vars can override it.
TELEGRAM_PUSH_LANGUAGE=both TELEGRAM_PUSH_LANGUAGE=both
# High-frequency airport push loop. Keep workers at 1 on shared VPS. # High-frequency airport push loop. Keep workers at 1 on shared VPS.
@@ -164,13 +171,24 @@ POLYWEATHER_BOT_DEB_QUERY_COST=1
# Payments # Payments
POLYWEATHER_PAYMENT_ENABLED=false POLYWEATHER_PAYMENT_ENABLED=false
# Default / legacy checkout chain. Keep Polygon as the default because the
# deployed checkout contract currently lives there.
POLYWEATHER_PAYMENT_CHAIN_ID=137 POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com
# Optional multi-chain RPC map. Required when accepting non-default chain
# transfers such as Ethereum mainnet USDC.
# Example:
# POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT= POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS= POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359 POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6 POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
# Multi-token / multi-chain payment routes. Token rows can include:
# chain_id, chain_code, chain_name, receiver_contract, direct_receiver_address,
# supports_contract_checkout, supports_direct_transfer, confirmations,
# explorer_tx_url.
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON= POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
POLYWEATHER_PAYMENT_CONFIRMATIONS=2 POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800 POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
@@ -182,7 +200,8 @@ POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_ENABLED=true POLYWEATHER_PAYMENT_POINTS_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500 POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3 POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8}
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON= POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
POLYMARKET_SIGNAL_EDGE_PCT=2 POLYMARKET_SIGNAL_EDGE_PCT=2
+1
View File
@@ -34,6 +34,7 @@ METEOBLUE_API_KEY=
# Wallet / payments # Wallet / payments
######################################## ########################################
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT= POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS= POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON= POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
+6
View File
@@ -112,6 +112,9 @@ jobs:
NEXT_PUBLIC_SITE_URL=${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }} NEXT_PUBLIC_SITE_URL=${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }} NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
@@ -119,6 +122,9 @@ jobs:
needs: [build-and-push] needs: [build-and-push]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
concurrency:
group: polyweather-production-deploy
cancel-in-progress: false
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
+1
View File
@@ -9,6 +9,7 @@ data/*.db
data/*.db-* data/*.db-*
data/*.db.* data/*.db.*
data/*.json data/*.json
data/*backtest*.csv
data/logs/ data/logs/
data/historical/ data/historical/
data/cache/ data/cache/
+13
View File
@@ -1,5 +1,18 @@
# Changelog # Changelog
## 1.8.1 - 2026-05-28
### 文档与发布
- README / README_ZH 改用 `frontend/public/static/web.png``frontend/public/static/tel.png` 作为产品截图,并移除旧 `docs/images` README 截图引用。
- 同步版本源到 `1.8.1`,刷新 API、Supabase、技术债、PolygonScan 验证等文档标题版本。
- 更新前端、实时事件、数据源、模型栈与服务文档,补齐 Redis Stream + SSE Patch、DEB hourly consensus、城市当地时间图表、legacy 高斯图表叠加、跑道/CoWIN 曲线和中英文 Telegram 推送口径。
### 当前线上口径确认
- 生产实时层为“HTTP snapshot + SSE patch + replayable event store”;前端只消费 `/api/events`,不直接连接 Redis。
- `deb_hourly_consensus.v1` 是峰值窗口与 DEB 曲线展示的优先小时路径;DEB 不作为实测来源。
- AMSC/AMOS 跑道曲线和香港 CoWIN 6087 参考站曲线按城市当地时间展示,结算跑道高亮,辅助跑道弱化。
## 1.8.0 - 2026-05-27 ## 1.8.0 - 2026-05-27
### 新增与重构 ### 新增与重构
+40 -15
View File
@@ -2,41 +2,50 @@
Production weather-intelligence stack for temperature settlement markets. Production weather-intelligence stack for temperature settlement markets.
Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/) Official dashboard: [polyweather.top](https://polyweather.top/)
中文说明: [README_ZH.md](README_ZH.md) 中文说明: [README_ZH.md](README_ZH.md)
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday analysis, calibrated probability, model stack, TAF, settlement sources, history, and extension). Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday analysis, calibrated probability, model stack, TAF, settlement sources, history, and extension).
## Product Screenshots ## Product Screenshots
### Global Dashboard ### Realtime Terminal
![PolyWeather global dashboard](docs/images/demo_map.png) ![PolyWeather realtime terminal](frontend/public/static/web.png)
### City Analysis (Ankara) ### Telegram Runway Alerts
![PolyWeather Ankara analysis](docs/images/demo_ankara.png) ![PolyWeather Telegram runway alerts](frontend/public/static/tel.png)
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=yangyuan-zhen/PolyWeather&type=Date)](https://star-history.com/#yangyuan-zhen/PolyWeather&Date) [![Star History Chart](https://api.star-history.com/svg?repos=yangyuan-zhen/PolyWeather&type=Date)](https://star-history.com/#yangyuan-zhen/PolyWeather&Date)
## Product Status (2026-05-23) ## Product Status (2026-05-28)
- Subscription live: `Pro Monthly 10 USDC`. - Subscription live: `Pro Monthly 10 USDC`.
- Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards. - Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards.
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, max `3 USDC`). - `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, max `3 USDC`).
- Weekly leaderboard rewards restructured: smaller point bonuses for winners (200/100/50), all active users receive participation rewards. - Weekly leaderboard rewards restructured: smaller point bonuses for winners (200/100/50), all active users receive participation rewards.
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e). - Onchain checkout live: Polygon contract checkout (USDC / USDC.e) plus Ethereum mainnet USDC direct-transfer confirmation.
- Auto-reconciliation live: event listener + periodic confirm loop. - Auto-reconciliation live: event listener + periodic confirm loop.
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage. - Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`. - Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
- Realtime terminal live: visible city charts subscribe through `/api/events?cities=...&since_revision=...`, receive `city_observation_patch.v1` SSE patches, and replay short gaps from Redis Stream in production or SQLite fallback in local/single-node mode.
- Chart refresh is observation-driven: live patches merge into the current chart without a loading overlay; only visible charts run a 60s no-patch fallback, and returning from a background browser tab triggers a foreground catch-up refresh.
- Temperature charts default to All Day, keep an optional Peak window derived from the DEB hourly path, and render all timestamps in the selected city's local time.
- The chart core has been split into focused logic/canvas/state modules; Recharts now receives explicit measured dimensions to avoid 0x0 rendering and disappearing curves.
- DEB hourly consensus (`deb_hourly_consensus.v1`) is now the preferred hourly forecast path for peak-window detection and chart overlays; DEB remains a forecast curve, never an observation source.
- Legacy Gaussian probability is rendered as horizontal probability bands and a `mu` reference line on the chart, rather than as a fake time-series curve.
- Settlement runway curves are visible by default for AMSC/AMOS cities; the configured settlement runway is highlighted and auxiliary runways are shown as secondary context.
- Hong Kong uses CoWIN station `6087` (Po Leung Kuk Choi Kai Yau School) as the 1-minute reference-station curve, with HKO 10-minute observations kept as the official meteorological layer.
- Telegram airport/runway pushes are bilingual by default and use settlement-endpoint runway temperatures for slope/current/summary copy.
- Runtime state, cache, and core offline training/backfill flows now use SQLite as the primary path; legacy JSON/JSONL files remain only for migration, export, and explicit fallback input. - Runtime state, cache, and core offline training/backfill flows now use SQLite as the primary path; legacy JSON/JSONL files remain only for migration, export, and explicit fallback input.
- EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout. - EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout.
- Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules. - Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules.
- Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives. - Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives.
- City decision cards now include the AI airport read: METAR, DEB, model cluster, and the AI expected-high center are resolved before mapping the result to temperature buckets. - Terminal city cards now combine settlement observations, DEB hourly consensus, model cluster context, calibrated probability, and market-bucket mapping without blocking the chart on AI text generation.
- AI airport reads now use in-page memory cache, browser `localStorage`, and backend short-TTL cache; returning from another dashboard tab restores existing stream text or final results before any new request is needed. - Terminal data uses page memory cache, browser `localStorage`, backend short-TTL cache, SSE patch replay, and foreground refresh so returning from another tab restores the latest visible chart state quickly.
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets. - Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
- The card label “model-market difference” means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully. - The card label “model-market difference” means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
- Calibrated model probability is now the primary probability panel. It shows the active production probability engine (legacy Gaussian or EMOS), while model consensus remains a secondary reference. - Calibrated model probability is now the primary probability panel. It shows the active production probability engine (legacy Gaussian or EMOS), while model consensus remains a secondary reference.
@@ -62,8 +71,9 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
- Aggregates observations and forecasts for 51 monitored cities. - Aggregates observations and forecasts for 51 monitored cities.
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs. - Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
- Builds a DEB-weighted hourly consensus path for peak-window logic and chart display.
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via legacy Gaussian or EMOS/CRPS calibration. - Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via legacy Gaussian or EMOS/CRPS calibration.
- Adds city decision cards that combine AI airport reads, expected-high centers, full market-bucket mapping, and model-market difference in one view. - Adds city decision cards that combine live observations, expected-high centers, full market-bucket mapping, and model-market difference in one view.
- Reuses one analysis core across web dashboard and Telegram bot. - Reuses one analysis core across web dashboard and Telegram bot.
- Adds payment audit trails, replay tooling, and incident visibility in ops. - Adds payment audit trails, replay tooling, and incident visibility in ops.
- Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules. - Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules.
@@ -88,8 +98,11 @@ flowchart LR
WX --> AMOS["AMOS runway sensors (Korea)"] WX --> AMOS["AMOS runway sensors (Korea)"]
WX --> HKO["HKO / CWA / NOAA / Official settlement sources"] WX --> HKO["HKO / CWA / NOAA / Official settlement sources"]
API --> ANA["DEB + Trend + Probability + Market Scan"] API --> ANA["DEB + Hourly Consensus + Probability + Market Scan"]
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"] API --> SSE["SSE /api/events"]
WX --> SSE
SSE --> EVENT["Redis Stream / SQLite Event Log"]
ANA --> PAY["Payment State (Multi-chain Intent + Event + Confirm Loop)"]
ANA --> STATE["SQLite runtime state"] ANA --> STATE["SQLite runtime state"]
``` ```
@@ -125,6 +138,8 @@ npm run dev
- `TAF` is used as an airport-side confirmation layer, not as the main temperature model. - `TAF` is used as an airport-side confirmation layer, not as the main temperature model.
- Calibrated probability uses legacy Gaussian (default) or EMOS/CRPS when evaluated; model vote counts remain an explanatory consensus line, not the final probability. - Calibrated probability uses legacy Gaussian (default) or EMOS/CRPS when evaluated; model vote counts remain an explanatory consensus line, not the final probability.
- Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience. - Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience.
- Realtime terminal charts use SSE patches plus replayable event storage; full HTTP detail remains the authoritative snapshot.
- Chart observations are shown in the city's local time, not the browser timezone.
## Runtime Data (Recommended on VPS) ## Runtime Data (Recommended on VPS)
@@ -134,8 +149,14 @@ Use external runtime storage to avoid SQLite/git conflicts:
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
POLYWEATHER_STATE_STORAGE_MODE=sqlite POLYWEATHER_STATE_STORAGE_MODE=sqlite
POLYWEATHER_EVENT_STORE=redis
POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
POLYWEATHER_REDIS_REQUIRED=true
``` ```
For local development or a strict single-process fallback, keep `POLYWEATHER_EVENT_STORE=sqlite`.
## EMOS Local Training ## EMOS Local Training
Do not run full EMOS retraining on a small VPS. The VPS should collect data and load approved calibration files; training should run on a local/dev machine using a copied production SQLite database: Do not run full EMOS retraining on a small VPS. The VPS should collect data and load approved calibration files; training should run on a local/dev machine using a copied production SQLite database:
@@ -162,7 +183,7 @@ curl http://127.0.0.1:8000/metrics
### Frontend cache headers ### Frontend cache headers
```bash ```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app" ./scripts/validate_frontend_cache.sh "https://polyweather.top"
``` ```
### Payment auto-reconciliation logs ### Payment auto-reconciliation logs
@@ -177,6 +198,10 @@ docker compose logs -f polyweather | egrep "payment event loop started|payment c
curl http://127.0.0.1:8000/api/payments/runtime curl http://127.0.0.1:8000/api/payments/runtime
``` ```
### Payment chains
Production payment routes are configured by the backend. Polygon remains the default checkout-contract chain, while Ethereum mainnet USDC can be enabled as a direct-transfer route so users who pay on their wallet default network are still confirmed by `intent.chain_id`.
## Telegram Commands ## Telegram Commands
| Command | Purpose | | Command | Purpose |
@@ -214,5 +239,5 @@ curl http://127.0.0.1:8000/api/payments/runtime
## Version ## Version
- Version: `v1.8.0` - Version: `v1.8.1`
- Last Updated: `2026-05-23` - Last Updated: `2026-05-28`
+41 -22
View File
@@ -2,30 +2,39 @@
面向温度结算市场的生产级气象情报系统。 面向温度结算市场的生产级气象情报系统。
官方看板:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/) 官方看板:[polyweather.top](https://polyweather.top/)
## 产品截图 ## 产品截图
### 全球看板 ### 实时终端
![PolyWeather 全球地图看板](docs/images/demo_map.png) ![PolyWeather 实时终端](frontend/public/static/web.png)
### 城市分析(Ankara ### Telegram 跑道推送
![PolyWeather Ankara 分析页](docs/images/demo_ankara.png) ![PolyWeather Telegram 跑道推送](frontend/public/static/tel.png)
## 当前产品状态(2026-05-23 ## 当前产品状态(2026-05-30
- 已上线订阅制:`Pro 月付 10 USDC` - 已上线订阅制:`Pro 月付 29.9 USDC / 30 天``Pro 季度 79.9 USDC / 90 天`
- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖 - 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分
- `/city``/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,最多抵 `3 USDC`)。 - `/city``/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。
- 周榜奖励已改造:降低赢家积分加成 (200/100/50),所有周活跃用户均享参与奖 - 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。 - 已上线链上支付:Polygon 合约支付(USDC / USDC.e+ Ethereum 主网 USDC 直转确认
- 已上线自动补单:事件监听 + 周期确认双链路。 - 已上线自动补单:事件监听 + 周期确认双链路。
- 已上线支付运行态与审计接口:`/api/payments/runtime` - 已上线支付运行态与审计接口:`/api/payments/runtime`
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。 - 已上线轻量运营后台:`/ops`(会员、积分、补分、支付异常单)。
- 已上线轻量可观测性:`/healthz``/api/system/status``/metrics` - 已上线轻量可观测性:`/healthz``/api/system/status``/metrics`
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。 - 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。
- 实时终端已切换到可重放事件流:可见城市图表通过 `/api/events?cities=...&since_revision=...` 订阅 `city_observation_patch.v1`,生产环境使用 Redis Stream 做短窗口 replay,本地/单进程可回退 SQLite event log。
- 图表刷新由实测事件驱动:SSE patch 直接合并到当前曲线,不弹 loading 遮罩;只有可见图表启用 60 秒无 patch 兜底,浏览器后台返回前台时会主动补齐最新 detail。
- 城市图表默认展示“全天”,可选“高温”窗口由 DEB hourly path 推导;所有图表横轴都按城市当地时间展示,不按用户浏览器时区。
- 核心图表组件已拆分为逻辑、状态与 canvas 渲染模块;Recharts 使用 `ResizeObserver` 后的明确宽高,规避 0x0 渲染和长时间挂页后曲线消失。
- DEB hourly consensus`deb_hourly_consensus.v1`)已作为峰值窗口和图表 DEB 曲线的优先小时路径;DEB 仍然是预测曲线,不作为实测来源。
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不再伪装成一条时间序列曲线。
- AMSC/AMOS 城市的结算跑道曲线默认展示并高亮,辅助跑道作为弱化曲线保留;釜山单跑道只展示 `SR/SL` 结算跑道,不再重复显示 AMOS 聚合线。
- 香港默认展示 CoWIN `6087`(保良局陈守仁小学)1 分钟参考站曲线,HKO 10 分钟实测保留为官方气象层。
- Telegram 机场/跑道推送默认中英文双语,并统一使用结算端点跑道温度计算当前值、15 分钟趋势和文案。
- 运行态状态、缓存与核心离线训练/回填链路已完成 SQLite 主路径收口;legacy JSON/JSONL 仅保留给迁移、导出与显式回退输入。 - 运行态状态、缓存与核心离线训练/回填链路已完成 SQLite 主路径收口;legacy JSON/JSONL 仅保留给迁移、导出与显式回退输入。
- EMOS/CRPS 校准链路已接通,但生产主概率保持 `legacy``emos_shadow``emos_primary` 只在本地离线评估通过并手动灰度后启用。 - EMOS/CRPS 校准链路已接通,但生产主概率保持 `legacy``emos_shadow``emos_primary` 只在本地离线评估通过并手动灰度后启用。
- 官方增强站网已统一接入: - 官方增强站网已统一接入:
@@ -37,15 +46,15 @@
- `CWA`(台湾) - `CWA`(台湾)
- 东京现已接入羽田 `JMA AMeDAS` 10 分钟温度作为官方增强层。 - 东京现已接入羽田 `JMA AMeDAS` 10 分钟温度作为官方增强层。
- 已支持 Dashboard 定向预热 worker / cron 路径,运行态在 `/api/system/status``/ops` 可见。 - 已支持 Dashboard 定向预热 worker / cron 路径,运行态在 `/api/system/status``/ops` 可见。
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与 prewarm heartbeat。 - `/ops` 现已展示缓存桶数量、summary cache hit/miss 与运行态 heartbeat。
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。 - 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。 - 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
- 城市决策卡已接入 AI 机场报文解读:先用 METAR、DEB、多模型集群和 AI 最高温中枢判断天气路径,再映射到温度桶 - 终端城市卡已改为结构化实况 + DEB hourly consensus + 多模型集群 + 校准概率 + 市场温度桶,不再让图表等待 AI 文案生成
- AI 机场报文解读现在同时使用页面内存缓存、浏览器 `localStorage`后端短 TTL 缓存;从其他选项卡切回决策卡时会优先恢复已有流式内容或最终结果 - 终端数据同时使用页面内存缓存、浏览器 `localStorage`后端短 TTL 缓存、SSE patch replay 和前台恢复刷新;从其他选项卡切回时会优先恢复最新可见图表状态
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。 - 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。 - 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
- 概率区已改为”校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS),模型共识作为辅助参考。 - 概率区已改为”校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS),模型共识作为辅助参考。
- 今日日内结构解读已支持可选 Groq 改写层,失败时自动回退规则文案 - 今日日内结构解读以规则与结构化信号为主,AI 文案只作为可降级辅助层,不替代实测、DEB、TAF 或结算逻辑
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md` - 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`
## 许可证与商用边界(重要) ## 许可证与商用边界(重要)
@@ -62,8 +71,9 @@
- 聚合 51 个监控城市的实测与预报数据。 - 聚合 51 个监控城市的实测与预报数据。
- DEBDynamic Error Balancing)融合多模型最高温。 - DEBDynamic Error Balancing)融合多模型最高温。
- 构建 DEB 加权小时共识曲线,用于峰值窗口判断和图表默认 DEB 展示。
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。 - 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。
- 地图城市决策卡把 AI 机场报文解读、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示。 - 地图城市决策卡把结构化实况、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示。
- Web 仪表盘与 Telegram Bot 复用同一分析内核。 - Web 仪表盘与 Telegram Bot 复用同一分析内核。
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。 - 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。 - 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
@@ -85,7 +95,10 @@ flowchart LR
WX --> OM["Open-Meteo"] WX --> OM["Open-Meteo"]
WX --> HKO["HKO / CWA / NOAA 等官方结算源"] WX --> HKO["HKO / CWA / NOAA 等官方结算源"]
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"] API --> ANA["DEB + 小时共识 + 概率 + 市场扫描"]
API --> SSE["SSE /api/events"]
WX --> SSE
SSE --> EVENT["Redis Stream / SQLite Event Log"]
ANA --> PAY["支付状态(Intent + Event + Confirm Loop"] ANA --> PAY["支付状态(Intent + Event + Confirm Loop"]
ANA --> STATE["SQLite runtime state<br/>legacy files only for migration/export fallback"] ANA --> STATE["SQLite runtime state<br/>legacy files only for migration/export fallback"]
``` ```
@@ -121,8 +134,14 @@ npm run dev
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
POLYWEATHER_STATE_STORAGE_MODE=sqlite POLYWEATHER_STATE_STORAGE_MODE=sqlite
POLYWEATHER_EVENT_STORE=redis
POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
POLYWEATHER_REDIS_REQUIRED=true
``` ```
本地开发或严格单进程兜底可使用 `POLYWEATHER_EVENT_STORE=sqlite`
## EMOS 本地训练流程 ## EMOS 本地训练流程
低配 VPS 只负责采集、服务和加载已通过评估的参数,不建议在 VPS 上跑 EMOS 全量训练。训练前先从 VPS 拉 SQLite 副本到本地: 低配 VPS 只负责采集、服务和加载已通过评估的参数,不建议在 VPS 上跑 EMOS 全量训练。训练前先从 VPS 拉 SQLite 副本到本地:
@@ -154,7 +173,7 @@ curl http://127.0.0.1:8000/metrics
### 外部监控栈 ### 外部监控栈
```bash ```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app" ./scripts/validate_frontend_cache.sh "https://polyweather.top"
``` ```
### 支付自动补单日志 ### 支付自动补单日志
@@ -187,7 +206,7 @@ curl http://127.0.0.1:8000/api/payments/runtime
### 运营后台 ### 运营后台
- 前端入口:`https://polyweather-pro.vercel.app/ops` - 前端入口:`https://polyweather.top/ops`
- 后端需配置: - 后端需配置:
```env ```env
@@ -230,5 +249,5 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
## 当前版本 ## 当前版本
- 版本:`v1.8.0` - 版本:`v1.8.1`
- 文档最后更新:`2026-05-23` - 文档最后更新:`2026-05-28`
+1 -1
View File
@@ -1 +1 @@
1.8.0 1.8.1
+132 -22
View File
@@ -5,6 +5,14 @@ GHCR_PAT="$1"
NEW_TAG="${2:-latest}" NEW_TAG="${2:-latest}"
TAG_FILE="/var/lib/polyweather/.current_tag" TAG_FILE="/var/lib/polyweather/.current_tag"
COMPOSE_DIR="/root/PolyWeather" COMPOSE_DIR="/root/PolyWeather"
LOCK_FILE="${POLYWEATHER_DEPLOY_LOCK_FILE:-/var/lock/polyweather-deploy.lock}"
mkdir -p "$(dirname "$LOCK_FILE")"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "❌ Another PolyWeather deploy is already running"
exit 1
fi
echo "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin echo "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
@@ -17,29 +25,9 @@ if [ -f "$TAG_FILE" ]; then
echo "Previous tag: $PREVIOUS_TAG" echo "Previous tag: $PREVIOUS_TAG"
fi fi
export IMAGE_TAG="$NEW_TAG" rollback_to_previous() {
docker compose pull
docker compose up -d
# Wait for backend to be ready (retry up to 150s)
echo "Waiting for backend..."
for i in $(seq 1 30); do
sleep 5
if curl -fsSo /dev/null --max-time 5 "https://api.polyweather.top/healthz"; then
echo "✅ healthz ready after ${i}x5s"
break
fi
echo " retry $i/30..."
done
FAILED=0
curl -fsSo /dev/null --max-time 15 "https://api.polyweather.top/healthz" && echo "✅ healthz" || { echo "❌ healthz"; FAILED=1; }
curl -fsSo /dev/null --max-time 10 "https://api.polyweather.top/api/cities" && echo "✅ cities" || { echo "❌ cities"; FAILED=1; }
curl -fsSo /dev/null --max-time 10 "https://www.polyweather.top/" && echo "✅ frontend" || { echo "❌ frontend"; FAILED=1; }
if [ "$FAILED" = "1" ]; then
echo "❌ Smoke tests failed. Rolling back..."
if [ -n "$PREVIOUS_TAG" ]; then if [ -n "$PREVIOUS_TAG" ]; then
echo "Rolling back to $PREVIOUS_TAG..."
export IMAGE_TAG="$PREVIOUS_TAG" export IMAGE_TAG="$PREVIOUS_TAG"
docker compose pull docker compose pull
docker compose up -d docker compose up -d
@@ -47,6 +35,128 @@ if [ "$FAILED" = "1" ]; then
else else
echo "⚠️ No previous tag to rollback to" echo "⚠️ No previous tag to rollback to"
fi fi
}
export IMAGE_TAG="$NEW_TAG"
pull_ok=0
for pull_attempt in $(seq 1 6); do
docker compose pull && pull_ok=1 && break
echo "Image pull failed or tag not ready, retry ${pull_attempt}/6..."
sleep 10
done
if [ "$pull_ok" != "1" ]; then
echo "❌ Image pull failed after retries"
exit 1
fi
smoke_check() {
local name="$1"
local url="$2"
local timeout="$3"
local attempts="${4:-6}"
local delay="${5:-5}"
local output=""
for i in $(seq 1 "$attempts"); do
if output=$(curl -fsS -w "http=%{http_code} time=%{time_total}" -o /dev/null --max-time "$timeout" "$url" 2>&1); then
echo "$name ($output)"
return 0
fi
if [ "$i" != "$attempts" ]; then
echo " $name retry $i/$attempts... ($output)"
sleep "$delay"
fi
done
echo "$name ($output)"
return 1
}
wait_for_local_service() {
local name="$1"
local url="$2"
local timeout="${3:-5}"
local attempts="${4:-30}"
local delay="${5:-2}"
for i in $(seq 1 "$attempts"); do
if curl -fsSo /dev/null --max-time "$timeout" "$url"; then
echo "$name ready after attempt $i/$attempts"
return 0
fi
if [ "$i" != "$attempts" ]; then
echo " $name warming $i/$attempts..."
sleep "$delay"
fi
done
echo "$name did not become ready"
return 1
}
warm_public_route() {
local name="$1"
local url="$2"
local timeout="${3:-15}"
local attempts="${4:-3}"
local delay="${5:-2}"
for i in $(seq 1 "$attempts"); do
if curl -fsSo /dev/null --max-time "$timeout" "$url"; then
echo "✅ warmed $name"
return 0
fi
if [ "$i" != "$attempts" ]; then
echo " warm $name retry $i/$attempts..."
sleep "$delay"
fi
done
echo "⚠️ warm $name failed"
return 0
}
echo "Updating Redis dependency..."
docker compose up -d polyweather_redis
echo "Updating backend services..."
docker compose up -d --no-deps polyweather_web polyweather
echo "Waiting for backend..."
wait_for_local_service "backend healthz" "http://127.0.0.1:8000/healthz" 5 30 5 || FAILED_BACKEND=1
FAILED_BACKEND="${FAILED_BACKEND:-0}"
if [ "$FAILED_BACKEND" = "1" ]; then
echo "❌ Backend did not become healthy"
rollback_to_previous
exit 1
fi
echo "Updating frontend..."
docker compose up -d --no-deps polyweather_frontend
echo "Waiting for frontend..."
wait_for_local_service "frontend root" "http://127.0.0.1:3001/" 5 40 2 || FAILED_FRONTEND=1
wait_for_local_service "frontend terminal" "http://127.0.0.1:3001/terminal" 10 20 2 || FAILED_FRONTEND=1
FAILED_FRONTEND="${FAILED_FRONTEND:-0}"
if [ "$FAILED_FRONTEND" = "1" ]; then
echo "❌ Frontend did not become healthy"
rollback_to_previous
exit 1
fi
warm_public_route "terminal" "https://polyweather.top/terminal" 20 4 3
warm_public_route "auth snapshot" "https://polyweather.top/api/auth/me?prefer_snapshot=1" 10 3 2
warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?refresh_deb_recent=1" 15 2 2
warm_public_route "cities" "https://polyweather.top/api/cities" 20 3 2
FAILED=0
smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || FAILED=1
smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5 || FAILED=1
smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || FAILED=1
if [ "$FAILED" = "1" ]; then
echo "❌ Smoke tests failed. Rolling back..."
rollback_to_previous
exit 1 exit 1
fi fi
+29 -4
View File
@@ -1,6 +1,24 @@
services: services:
polyweather_redis:
image: redis:7-alpine
container_name: polyweather_redis
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy noeviction
restart: unless-stopped
healthcheck:
interval: 10s
retries: 5
test:
- CMD
- redis-cli
- ping
timeout: 5s
volumes:
- polyweather_redis_data:/data
polyweather: polyweather:
container_name: polyweather_bot container_name: polyweather_bot
depends_on:
polyweather_redis:
condition: service_healthy
logging: logging:
driver: "json-file" driver: "json-file"
options: options:
@@ -42,20 +60,22 @@ services:
environment: environment:
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL:-} NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL:-}
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS: 'false' NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS: 'false'
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS:-polyweather.top,www.polyweather.top}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://polyweather.top} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://polyweather.top}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL:-https://polygon-bor-rpc.publicnode.com}
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID:-}
POLYWEATHER_API_BASE_URL: ${POLYWEATHER_API_BASE_URL:-http://polyweather_web:8000} POLYWEATHER_API_BASE_URL: ${POLYWEATHER_API_BASE_URL:-http://polyweather_web:8000}
POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true} POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true}
POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true} POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true}
POLYWEATHER_OPS_ADMIN_EMAILS: ${POLYWEATHER_OPS_ADMIN_EMAILS:-}
healthcheck: healthcheck:
interval: 30s interval: 30s
retries: 3 retries: 3
test: test:
- CMD - CMD-SHELL
- wget - wget -qO- http://$(hostname):3000
- -qO-
- http://localhost:3000
timeout: 5s timeout: 5s
image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest} image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}
ports: ports:
@@ -63,6 +83,9 @@ services:
restart: unless-stopped restart: unless-stopped
polyweather_web: polyweather_web:
command: python web/app.py command: python web/app.py
depends_on:
polyweather_redis:
condition: service_healthy
logging: logging:
driver: "json-file" driver: "json-file"
options: options:
@@ -90,3 +113,5 @@ services:
x-polyweather-base: x-polyweather-base:
env_file: *id001 env_file: *id001
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest} image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
volumes:
polyweather_redis_data:
+33 -4
View File
@@ -1,11 +1,23 @@
# 机场高频实时数据源 # 机场高频实时数据源
最后更新:`2026-05-28`
## 已接入城市 ## 已接入城市
| 城市 | 机场 | ICAO/站点 | 数据源 | 频率 | 类型 | 费用 | | 城市 | 机场 | ICAO/站点 | 数据源 | 频率 | 类型 | 费用 |
|------|------|-----------|--------|------|------|------| |------|------|-----------|--------|------|------|------|
| 首尔 | 仁川国际 | RKSI | AMOS (`global.amo.go.kr`) | 1 分钟 | 跑道对温度(2对) | 免费 | | 首尔 | 仁川国际 | RKSI | AMOS (`global.amo.go.kr`) | 1 分钟 | 跑道对温度(2对) | 免费 |
| 釜山 | 金海国际 | RKPK | AMOS (`global.amo.go.kr`) | 1 分钟 | 跑道对温度(1对) | 免费 | | 釜山 | 金海国际 | RKPK | AMOS (`global.amo.go.kr`) | 1 分钟 | 跑道对温度(1对) | 免费 |
| 香港 | CoWIN 6087 | 6087 | CoWIN (`cowin.hku.hk`) | 1 分钟 | 参考站温度(保良局陈守仁小学) | 免费 |
| 香港 | HKO | HKO | HKO 官方 CSV (`data.weather.gov.hk`) | 10 分钟 | 官方气象站温度 | 免费 |
| 台北 | 松山/中央气象署 | 466920 | CWA 开放数据 | 10 分钟 | 官方站点温度 | 免费 |
| 北京 | 首都机场 | ZBAA | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 上海 | 浦东机场 | ZSPD | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 广州 | 白云机场 | ZGGG | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 成都 | 双流机场 | ZUUU | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 重庆 | 江北机场 | ZUCK | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 武汉 | 天河机场 | ZHHH | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 青岛 | 胶东机场 | ZSQD | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 东京 | 羽田 | RJTT | JMA AMeDAS (`jma.go.jp`) | 10 分钟 | 机场站点实时温度 | 免费 | | 东京 | 羽田 | RJTT | JMA AMeDAS (`jma.go.jp`) | 10 分钟 | 机场站点实时温度 | 免费 |
| 安卡拉 | Esenboğa | 17128 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 | | 安卡拉 | Esenboğa | 17128 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
| 伊斯坦布尔 | 伊斯坦布尔机场 | 17058 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 | | 伊斯坦布尔 | 伊斯坦布尔机场 | 17058 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
@@ -36,6 +48,14 @@
> 后端通过 `weather_sources.py` 拉取并注入 `airport_primary`,前端市场监控通过 > 后端通过 `weather_sources.py` 拉取并注入 `airport_primary`,前端市场监控通过
> `resolveMonitorTemperature` 优先读取 `airport_primary.temp` 获得小数精度温度。 > `resolveMonitorTemperature` 优先读取 `airport_primary.temp` 获得小数精度温度。
> **CoWIN 6087**: 香港图表默认参考站为 HKU CoWIN `6087`(保良局陈守仁小学)。
> 该源提供约 1 分钟温度序列,作为 PM 最高温市场的高频参考曲线;HKO 10 分钟数据
> 仍作为官方气象层保留。后端通过 `cowin_sources.py` 拉取并写入 `cowin_obs`。
> **AMSC AWOS**: 中国内地跑道城市读取 AMSC `getWindPlate` 中的 `TDZ_TEMP` /
> `MID_TEMP` / `END_TEMP`。这些字段是跑道观测位置气温,不是道面温度。
> 结算跑道展示使用配置的结算端点;辅助跑道只作为背景曲线。
## 推送机制 ## 推送机制
- 每城按原生频率独立推送,不捆绑 - 每城按原生频率独立推送,不捆绑
@@ -44,14 +64,16 @@
- 仅当当前温度距 DEB 预测最高 ≤3°C 时推送 - 仅当当前温度距 DEB 预测最高 ≤3°C 时推送
- 确认过峰值后自动停止 - 确认过峰值后自动停止
## 前端实时同步与 SSE Patch 机制 ## 前端实时同步与 SSE Patch / Redis Stream 机制
为了向用户提供秒级实况响应并降低服务器负载,系统已从定时轮询架构全面迁移至 **Server-Sent Events (SSE) 增量更新(SSE Patch** 架构 为了向用户提供接近行情盘的实况响应并降低服务器负载,系统使用 **HTTP snapshot + Server-Sent Events (SSE) Patch + 可重放事件日志** 架构。生产环境推荐 Redis Stream;本地或单进程可回退 SQLite event log
### 1. 数据推送链路 (Data Pipeline) ### 1. 数据推送链路 (Data Pipeline)
1. **Collector 采集端触发**:在 `weather_sources.py` 中,当高频实况源(如 AMOS, CoWIN, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。 1. **Collector 采集端触发**:在 `weather_sources.py` 中,当高频实况源(如 AMOS, CoWIN, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
2. **FastAPI SSE 广播**FastAPI 后端的 `sse_router.py` 接收到 Patch 后,将其推入 `sse_manager` 进行全局广播,事件被包装为 `city_patch` 增量包,包含自增的全局 `revision` 和最新的 `changes` 2. **标准化事件**`realtime_patch_schema.py` 将旧 `city_patch` 或新 payload 统一成 `city_observation_patch.v1`
3. **BFF 代理流**:浏览器前端通过 BFF (Next.js rewrites) 建立与 `/api/events` 的持久连接,从而无需定时轮询 3. **事件存储**:生产环境写入 Redis Stream`stream:city_observation`)并生成全局递增 `revision`SQLite `observation_patch_events` 保留为本地/兜底 replay
4. **FastAPI SSE 广播**FastAPI 后端的 `sse_router.py` 根据城市订阅集合向匹配连接推送 patch;断线重连时按 `since_revision` replay。
5. **BFF 代理流**:浏览器前端通过 BFF 建立与 `/api/events` 的持久连接,从而无需固定整图轮询。
### 2. 前端消费与刷新规则 (Frontend Freshness Rules) ### 2. 前端消费与刷新规则 (Frontend Freshness Rules)
- **扫描列表免轮询更新**`use-scan-terminal-query.ts` 通过 `useSsePatchVersion` 钩子订阅全局 SSE 版本。当有任何城市产生更新时,列表将触发按需重绘,之前固定的 5 分钟 `setInterval` 定时轮询已被彻底禁用。 - **扫描列表免轮询更新**`use-scan-terminal-query.ts` 通过 `useSsePatchVersion` 钩子订阅全局 SSE 版本。当有任何城市产生更新时,列表将触发按需重绘,之前固定的 5 分钟 `setInterval` 定时轮询已被彻底禁用。
@@ -62,6 +84,8 @@
1. 调用轻量级的 `/api/city/{city}/summary` 快速拉取最新实况温度。 1. 调用轻量级的 `/api/city/{city}/summary` 快速拉取最新实况温度。
2. 调用 `fetchHourlyForecastForCity(city, { ignoreCache: true })` 强刷完整的城市详情数据,确保数据一致性。 2. 调用 `fetchHourlyForecastForCity(city, { ignoreCache: true })` 强刷完整的城市详情数据,确保数据一致性。
- **按需加载与 Stagger 优化**:在加载城市详情时,前端会优先加载 Active 状态的图表,而处于 Background/非活动状态的图表则通过 staggered timer (按槽位索引延迟 300ms~1500ms) 异步获取,以分流请求峰值。 - **按需加载与 Stagger 优化**:在加载城市详情时,前端会优先加载 Active 状态的图表,而处于 Background/非活动状态的图表则通过 staggered timer (按槽位索引延迟 300ms~1500ms) 异步获取,以分流请求峰值。
- **前台恢复补齐**:浏览器标签页长时间在后台时,回来后会主动强刷可见图表 full detail,避免 SSE 被浏览器挂起后曲线落后。
- **当地时间**patch 中保留 `city_timezone` / `observed_at_utc`,前端按城市当地时间绘制横轴。
## 消息模板 ## 消息模板
@@ -83,6 +107,11 @@ Seoul / Incheon 16:03
| `TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC` | 循环轮询间隔 | `60` | | `TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC` | 循环轮询间隔 | `60` |
| `TELEGRAM_AIRPORT_PUSH_LANGUAGE` | 机场推送语言覆盖,可选 `both`/`en`/`zh` | `both` | | `TELEGRAM_AIRPORT_PUSH_LANGUAGE` | 机场推送语言覆盖,可选 `both`/`en`/`zh` | `both` |
| `KNMI_API_KEY` | KNMI API 密钥(阿姆斯特丹必填) | — | | `KNMI_API_KEY` | KNMI API 密钥(阿姆斯特丹必填) | — |
| `POLYWEATHER_EVENT_STORE` | 实时事件存储,可选 `redis`/`sqlite` | `sqlite` |
| `POLYWEATHER_REDIS_URL` | Redis Stream 连接地址 | `redis://127.0.0.1:6379/0` |
| `POLYWEATHER_REDIS_STREAM_KEY` | Redis Stream key | `stream:city_observation` |
| `POLYWEATHER_REDIS_STREAM_MAXLEN` | Redis Stream 保留长度 | `50000` |
| `POLYWEATHER_REDIS_REQUIRED` | Redis 不可用时是否启动失败 | `true` |
## 未接入城市 ## 未接入城市
+53 -40
View File
@@ -1,6 +1,6 @@
# PolyWeather API 文档(v1.8.0 # PolyWeather API 文档(v1.8.1
最后更新:`2026-04-27` 最后更新:`2026-05-28`
本文档描述当前对外可用 API 口径(`web/app.py` + `web/routes.py` + `frontend/app/api/*`)。 本文档描述当前对外可用 API 口径(`web/app.py` + `web/routes.py` + `frontend/app/api/*`)。
@@ -17,7 +17,9 @@ flowchart LR
FE["Browser / Dashboard"] --> BFF["Next.js Route Handlers (/api/*)"] FE["Browser / Dashboard"] --> BFF["Next.js Route Handlers (/api/*)"]
BFF --> API["FastAPI (/web/app.py + /web/routes.py)"] BFF --> API["FastAPI (/web/app.py + /web/routes.py)"]
API --> WX["Weather Collector"] API --> WX["Weather Collector"]
API --> ANA["DEB + Trend + Probability + Market Scan"] API --> ANA["DEB + Hourly Consensus + Probability + Market Scan"]
API --> SSE["Realtime SSE (/api/events)"]
SSE --> EVENT["Redis Stream / SQLite Event Log"]
API --> PAY["Payment Intent + Event + Confirm Loops"] API --> PAY["Payment Intent + Event + Confirm Loops"]
API --> OBS["healthz / system status / metrics"] API --> OBS["healthz / system status / metrics"]
``` ```
@@ -31,8 +33,27 @@ flowchart LR
| `/api/city/{name}/summary` | GET | 轻量摘要 | | `/api/city/{name}/summary` | GET | 轻量摘要 |
| `/api/city/{name}/detail` | GET | 聚合详情(含 market_scan | | `/api/city/{name}/detail` | GET | 聚合详情(含 market_scan |
| `/api/history/{name}` | GET | 历史对账 | | `/api/history/{name}` | GET | 历史对账 |
| `/api/scan/terminal/ai-city` | POST | 城市决策卡 AI 解读(非流式 JSON) | | `/api/events` | GET | SSE 实时观测事件流 |
| `/api/scan/terminal/ai-city/stream` | POST | 城市决策卡 AI 解读(SSE 流式) | | `/api/internal/collector-patch` | POST | 采集器内部写入实时观测 patch |
### `GET /api/events`
浏览器实时图表入口,使用 `text/event-stream`
参数:
- `cities=shanghai,hong kong`:可选,逗号分隔城市列表;为空表示订阅全部城市。
- `since_revision=<int>`:可选,断线重连后从指定 revision 之后 replay。
- `replay_limit=<int>`:可选,默认 `500`,后端会做上限保护。
事件:
- `connected`:连接建立,包含当前 `latest_revision`
- `city_observation_patch.v1`:标准实时观测 patch,包含城市、序列、温度、观测 UTC 时间、城市时区、revision。
- `resync_required`:replay 窗口不足或事件存储不可用,前端应回到 HTTP snapshot 重建画面。
- `heartbeat`:保活。
生产环境推荐 `POLYWEATHER_EVENT_STORE=redis`,以 Redis Stream 保存短窗口事件并支持多 worker fanout;本地或单进程可使用 SQLite event log。
### `GET /api/city/{name}/detail` ### `GET /api/city/{name}/detail`
@@ -53,6 +74,7 @@ flowchart LR
- `probabilities.engine / calibration_mode / calibration_version` - `probabilities.engine / calibration_mode / calibration_version`
- `probabilities.raw_mu / raw_sigma / calibrated_mu / calibrated_sigma` - `probabilities.raw_mu / raw_sigma / calibrated_mu / calibrated_sigma`
- `probabilities.shadow_distribution` - `probabilities.shadow_distribution`
- `deb.hourly_consensus / deb.hourly_path.base_source`
- `intraday_meteorology.headline / confidence` - `intraday_meteorology.headline / confidence`
- `intraday_meteorology.base_case_bucket / upside_bucket / downside_bucket` - `intraday_meteorology.base_case_bucket / upside_bucket / downside_bucket`
- `intraday_meteorology.next_observation_time` - `intraday_meteorology.next_observation_time`
@@ -61,40 +83,6 @@ flowchart LR
- `vertical_profile_signal.heating_setup / suppression_risk / trigger_risk / mixing_strength` - `vertical_profile_signal.heating_setup / suppression_risk / trigger_risk / mixing_strength`
- `taf.signal.peak_window / suppression_level / disruption_level / markers` - `taf.signal.peak_window / suppression_level / disruption_level / markers`
### `POST /api/scan/terminal/ai-city/stream`
城市决策卡使用该接口生成“AI 机场报文解读”。前端默认请求 SSE 流,流式展示机场报文解读片段,最终以 `final` 事件返回完整 payload。
请求体:
```json
{
"city": "Buenos Aires",
"force_refresh": false,
"locale": "zh-CN"
}
```
SSE 事件:
- `progress`:阶段性状态,例如开始调用 AI、切换非流式重试。
- `preview`:可显示的预览文本。
- `delta`:模型流式文本增量,前端会从中提取机场报文解读片段。
- `final`:完整 `AiCityForecastPayload`
重点字段:
- `status`:通常为 `ready`;超时或降级时会带 `reason / reason_zh / reason_en`
- `cached`:是否命中后端 AI 缓存。
- `degraded`:是否为降级结果。前端不会把 degraded 结果写入长期 localStorage,但会保留页面内存态,避免切换选项卡后空白。
- `city_forecast.predicted_max`AI 给出的预计最高温中枢候选。
- `city_forecast.range_low / range_high`AI 给出的天气区间。
- `city_forecast.final_judgment_zh / final_judgment_en`:最终判断。
- `city_forecast.metar_read_zh / metar_read_en`:机场报文解读。
- `city_forecast.reasoning_zh / reasoning_en`:把 METAR、DEB、多模型集群与日内风险合并后的推理。
- `city_forecast.model_cluster_note_zh / model_cluster_note_en`:模型集群说明。
- `city_forecast.risks_zh / risks_en`:后续上修或下修触发条件。
### 城市决策卡市场层口径 ### 城市决策卡市场层口径
- 前端会请求完整 `market_scan` / `all_buckets`,而不是只取 lite 结果。 - 前端会请求完整 `market_scan` / `all_buckets`,而不是只取 lite 结果。
@@ -126,7 +114,7 @@ SSE 事件:
#### 2. `probabilities` #### 2. `probabilities`
概率层基于 legacy 高斯分桶,以 DEB 融合预测 μ 和 ensemble spread σ 生成 1°C 粒度概率分布。 概率层基于 legacy 高斯分桶,以 DEB 融合预测 `mu` 和 ensemble spread `sigma` 生成 1°C 粒度概率分布。前端图表将 legacy 高斯展示为水平概率温度带和 `mu` 参考线,不把概率分布渲染成时间序列曲线。
概率字段: 概率字段:
@@ -135,6 +123,22 @@ SSE 事件:
- `distribution`:当天合约桶概率分布 - `distribution`:当天合约桶概率分布
- `distribution_all`:包含外围桶的完整分布 - `distribution_all`:包含外围桶的完整分布
#### 2.1 `deb.hourly_consensus`
DEB hourly consensus 是当前图表与峰值窗口的优先小时路径。
重点字段:
- `version`:当前为 `deb_hourly_consensus.v1`
- `base_source``multi_model_hourly_deb_weights`
- `times` / `temps`:城市当地日的小时路径
- `model_weights`DEB 权重折叠后的模型权重
说明:
- 该路径是预测曲线,不是实测来源。
- 图表默认展示全天;“高温”视图仅根据该路径推导 peak-centric 窗口。
#### 3. `detail_depth` #### 3. `detail_depth`
`detail_depth` 用于区分轻量 detail 与完整 detail。前端如果发现: `detail_depth` 用于区分轻量 detail 与完整 detail。前端如果发现:
@@ -234,6 +238,13 @@ SSE 事件:
### 支付状态建议 ### 支付状态建议
`POST /api/payments/intents` 支持的关键字段:
- `plan_code`:套餐,例如 `pro_monthly`
- `payment_mode``wallet``direct`
- `chain_id`:可选;多链支付时前端传用户选择的链,例如 Polygon `137` 或 Ethereum `1`
- `token_address`:可选;指定该链上的 USDC / USDC.e 合约地址
前端流程建议: 前端流程建议:
1. `POST /intents` 1. `POST /intents`
@@ -291,6 +302,8 @@ SSE 事件:
- `summary?force_refresh=true``Cache-Control: no-store` - `summary?force_refresh=true``Cache-Control: no-store`
- 详情接口与支付接口:`no-store` - 详情接口与支付接口:`no-store`
- `METAR` / `TAF` / settlement current 由后端各自维护短 TTL 缓存 - `METAR` / `TAF` / settlement current 由后端各自维护短 TTL 缓存
- 实时事件层:`POLYWEATHER_EVENT_STORE=redis` 时使用 Redis Stream;未启用 Redis 时使用 SQLite `observation_patch_events` 作为 replay fallback
- 前端终端图表:HTTP detail 仍是完整 snapshotSSE patch 只做增量观测追加;长连接断开或后台恢复时前端会按 `since_revision` replay 或强刷 detail
- 前端打开今日日内分析时,如果 full detail 或 market scan 正在同步,会先显示刷新锁,不展示可交互的旧内容 - 前端打开今日日内分析时,如果 full detail 或 market scan 正在同步,会先显示刷新锁,不展示可交互的旧内容
- 城市决策卡 AI 解读前端缓存键为 `city + local_date + locale + METAR signature`signature 优先使用原始 METAR,缺失时回退到报文时间、观测时间和温度 - 城市决策卡 AI 解读前端缓存键为 `city + local_date + locale + METAR signature`signature 优先使用原始 METAR,缺失时回退到报文时间、观测时间和温度
- 城市决策卡 AI 解读使用两层前端缓存:页面内存缓存保存 loading / 流式进度 / 最终 payload`localStorage` 保存最终成功 payload,默认 TTL 1 小时 - 城市决策卡 AI 解读使用两层前端缓存:页面内存缓存保存 loading / 流式进度 / 最终 payload`localStorage` 保存最终成功 payload,默认 TTL 1 小时
+33 -4
View File
@@ -1,6 +1,6 @@
# 城市实时数据源总览 # 城市实时数据源总览
> 最后更新: 2026-05-26 | 51 城市 > 最后更新: 2026-05-28 | 51 城市
## 数据源分级 ## 数据源分级
@@ -111,11 +111,30 @@ ankara, istanbul, helsinki, amsterdam, paris
- **Tier 4 城市**(仅 METAR):修正效果有限,不建议依赖 - **Tier 4 城市**(仅 METAR):修正效果有限,不建议依赖
## 实时事件与图表刷新逻辑
当前终端图表不是固定整图轮询,而是:
1. 首屏 / 切换城市时拉取 `/api/city/{city}/detail` 作为完整 snapshot。
2. 可见图表连接 `/api/events?cities=...&since_revision=...&replay_limit=500`
3. 采集器产出 `city_observation_patch.v1` 后写入 Redis Stream(生产)或 SQLite event log(本地/兜底),再通过 SSE 推给浏览器。
4. 前端把 patch 追加到已有实测序列,不显示 loading 遮罩;只有可见图表 2 分钟无 patch 时才启动 60 秒兜底刷新。
5. 浏览器从后台切回前台时,前端会立即补一次 full detail,防止长时间挂页后图表落后。
频率取决于源头:
- AMSC / AMOS / CoWIN / MSS:源头约 1 分钟,图表按 1 分钟粒度追加。
- MADIS:源头约 5 分钟。
- HKO / CWA / JMA / FMI / KNMI:源头约 10 分钟。
- METAR-only 城市:按 METAR 可用频率和缓存 TTL,不伪装成 1 分钟实测。
所有图表横轴和 tooltip 时间均按城市当地时间展示,不按用户浏览器时区。
## 关于网站终端图表的数据曲线展示逻辑 ## 关于网站终端图表的数据曲线展示逻辑
### 1. 实测数据(默认全开,突出核心) ### 1. 实测数据(默认全开,突出核心)
- **跑道全量展示**:北京、上海、广州、成都、重庆、武汉、首尔等城市的跑道实测数据,默认全量开启,无需手动勾选。 - **跑道全量展示**:北京、上海、广州、成都、重庆、武汉、青岛、首尔、釜山等城市的跑道实测数据,默认全量开启,无需手动勾选。
- **结算跑道高亮**:系统内置了各大机场的官方结算跑道映射。命中的跑道将被**重点强调**(加粗的青色实线 #009688,线宽 2.8),并标记为“[跑道号] 结算跑道”。具体的跑道映射如下: - **结算跑道高亮**:系统内置了各大机场的官方结算跑道映射。命中的跑道将被**重点强调**(加粗的青色实线 #009688,线宽 2.8),并标记为“[跑道号] 结算跑道”。具体的跑道映射如下:
- 北京:19/01 - 北京:19/01
- 上海:17L/35R - 上海:17L/35R
@@ -123,16 +142,26 @@ ankara, istanbul, helsinki, amsterdam, paris
- 成都:02L/20R - 成都:02L/20R
- 重庆:20R/02L - 重庆:20R/02L
- 武汉:04/22 - 武汉:04/22
- 青岛:16/34
- 首尔:15R/33L - 首尔:15R/33L
- 釜山:SR/SL
- **辅助跑道弱化**:同一机场下的其他非结算跑道,也会同时展示,但采用较细的虚线(线宽 1.2)以作陪衬区分。 - **辅助跑道弱化**:同一机场下的其他非结算跑道,也会同时展示,但采用较细的虚线(线宽 1.2)以作陪衬区分。
- **其他实测展示**:所有城市的 METAR 报文曲线、官方气象站实测(如 Hong Kong / Lau Fau Shan 的香港天文台曲线)均默认展示 - **单跑道机场去重**:釜山只有 `SR/SL` 跑道曲线时,不再额外展示 AMOS 聚合线,避免两条线语义重复
- **香港参考曲线**Hong Kong 默认展示 CoWIN `6087`(保良局陈守仁小学)1 分钟参考站曲线;HKO 10 分钟实测作为官方气象层保留。
- **其他实测展示**:所有城市的 METAR 报文曲线、官方气象站实测(如 Shenzhen / Lau Fau Shan 的 HKO 自动站、Taipei 的 CWA)均默认展示。
### 2. 核心预测数据(默认展示) ### 2. 核心预测数据(默认展示)
- **DEB 模型融合**:作为平台核心的高精度智能融合预测曲线,默认始终展示给用户。 - **DEB 模型融合**:作为平台核心的智能融合预测曲线,默认始终展示给用户。DEB 是预测,不参与“实测接近峰值”的视觉预警计算。
- **DEB hourly consensus**:图表优先使用 `deb_hourly_consensus.v1` 的小时路径展示 DEB 曲线和推导“高温”窗口;如果缺失才回退旧的 hourly + DEB offset 路径。
### 3. 多模型原始数据(默认隐藏,按需自选) ### 3. 多模型原始数据(默认隐藏,按需自选)
- **保持整洁**:为了防止图表线缆过于杂乱,各大原始模型(ECMWF, GFS, ICON, GEM 等)的数据曲线在初次加载时**默认隐藏**。 - **保持整洁**:为了防止图表线缆过于杂乱,各大原始模型(ECMWF, GFS, ICON, GEM 等)的数据曲线在初次加载时**默认隐藏**。
- **特例**:仅针对巴黎(Paris),由于其 AROME HD 是高精度的 15 分钟级临近预报,极具参考价值,因此默认开启。 - **特例**:仅针对巴黎(Paris),由于其 AROME HD 是高精度的 15 分钟级临近预报,极具参考价值,因此默认开启。
- **自由交互**:用户可通过图表底部的图例交互按钮,随时自由勾选、叠加或隐藏任意所需的数据曲线。 - **自由交互**:用户可通过图表底部的图例交互按钮,随时自由勾选、叠加或隐藏任意所需的数据曲线。
### 4. 高斯概率图层
- legacy 高斯概率不会作为时间序列曲线展示。
- 图表上只渲染概率温度带和 `mu` 参考线,帮助用户判断当前实测距概率中心和高概率区域的关系。
+17 -9
View File
@@ -1,6 +1,6 @@
# 商业化说明(Production # 商业化说明(Production
最后更新:`2026-04-18` 最后更新:`2026-05-30`
## 1. 定位 ## 1. 定位
@@ -8,8 +8,8 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
核心价值: 核心价值:
- 观测优先(METAR / 机场主站 / 明确官方站点;MGM、NMC、JMA、AMOS 等作为增强层) - 观测优先(METAR / 机场主站 / 明确官方站点;MGM、JMA、AMOS、AMSC、CoWIN、CWA 等作为增强层)
- 结算导向(DEB + 校准概率桶) - 结算导向(DEB hourly consensus + 校准概率桶)
- 气象判断优先(证据链、失效条件、下一观测点) - 气象判断优先(证据链、失效条件、下一观测点)
- 市场映射(行情对照 + 错价雷达),但不把交易建议放在第一层产品承诺 - 市场映射(行情对照 + 错价雷达),但不把交易建议放在第一层产品承诺
@@ -18,12 +18,16 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
| 能力 | 状态 | 备注 | | 能力 | 状态 | 备注 |
| :-- | :-- | :-- | | :-- | :-- | :-- |
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 | | 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
| 订阅套餐(Pro 月付) | 已上线 | `10 USDC / 30天` | | 订阅套餐(Pro 月付 / 季度 | 已上线 | `29.9 USDC / 30天``79.9 USDC / 90天` |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` | | 邀请积分 | 已上线 | 被邀请人首月 `20U`;邀请人每个有效付费邀请 `+3500` 积分 |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U`;邀请首月价不叠加积分抵扣 |
| 合约支付 | 已上线 | PolygonUSDC + USDC.e | | 合约支付 | 已上线 | PolygonUSDC + USDC.e |
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop | | 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 |
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop,按 intent.chain_id 查链 |
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect | | 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
| 私有频道推送 | 已上线 | 可拆分业务频道 | | 私有频道推送 | 已上线 | 可拆分业务频道 |
| 实时终端 | 已上线 | SSE Patch + Redis Stream replay |
| Telegram 双语推送 | 已上线 | 中英文一起推送,结算跑道端点口径 |
## 3. 权限模型(当前) ## 3. 权限模型(当前)
@@ -33,14 +37,17 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
- 今日日内深度分析(含高温时段) - 今日日内深度分析(含高温时段)
- 专业气象结论条、证据链、失效条件、确认条件 - 专业气象结论条、证据链、失效条件、确认条件
- 概率分布层(基于 DEB 融合 + 高斯分桶) - 概率分布层(基于 DEB 融合 + 高斯分桶)
- 实时终端多城市图表(跑道/官方站点/DEB/概率带)
- 历史对账 + 未来日期分析 - 历史对账 + 未来日期分析
- 全平台智能气象推送 - 全平台智能气象推送
## 4. 收费与积分规则(默认) ## 4. 收费与积分规则(默认)
- 套餐:`pro_monthly`10 USDC / 30 天) - 套餐:`pro_monthly`29.9 USDC / 30 天)`pro_quarterly`79.9 USDC / 90 天)
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC - 邀请首月:被邀请人首次月付 20 USDC
- 实付下限:2 USDC(当积分满额时) - 邀请奖励:邀请人 +3500 积分,每月最多 10 个有效付费邀请
- 抵扣:500 积分抵 1 USDC;月付最高抵 3 USDC,季度最高抵 8 USDC
- 积分来源:仅通过有效付费邀请和后台人工补发,不再通过 Telegram 群发言发放
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。 > 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
@@ -62,6 +69,7 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
5. 安全边界:敏感配置不进仓库。 5. 安全边界:敏感配置不进仓库。
6. 数据口径:机场市场按 METAR / 机场主站解释;Wunderground 只可描述为历史页面或参考入口,不描述成“站”。 6. 数据口径:机场市场按 METAR / 机场主站解释;Wunderground 只可描述为历史页面或参考入口,不描述成“站”。
7. 加载口径:日内分析和右侧详情在 full detail 未补齐前必须显示同步状态,不能把旧缓存伪装成完整付费内容。 7. 加载口径:日内分析和右侧详情在 full detail 未补齐前必须显示同步状态,不能把旧缓存伪装成完整付费内容。
8. 实时口径:终端图表应优先保留已有曲线并无痛合并 patch,不用 loading 遮罩覆盖已可用数据。
## 7. 后续路线 ## 7. 后续路线
+44 -9
View File
@@ -1,5 +1,7 @@
# 配置与密钥管理(中文) # 配置与密钥管理(中文)
最后更新:`2026-05-28`
## 1. 目标 ## 1. 目标
PolyWeather 的环境变量很多,但不是所有变量都属于同一层级。 PolyWeather 的环境变量很多,但不是所有变量都属于同一层级。
@@ -79,6 +81,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `POLYWEATHER_RUNTIME_DATA_DIR` - `POLYWEATHER_RUNTIME_DATA_DIR`
- `POLYWEATHER_DB_PATH` - `POLYWEATHER_DB_PATH`
- `POLYWEATHER_STATE_STORAGE_MODE` - `POLYWEATHER_STATE_STORAGE_MODE`
- `POLYWEATHER_EVENT_STORE`
前端: 前端:
@@ -104,7 +107,10 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION` - `POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION`
- `POLYWEATHER_OPS_ADMIN_EMAILS` - `POLYWEATHER_OPS_ADMIN_EMAILS`
- `POLYWEATHER_STATE_STORAGE_MODE` - `POLYWEATHER_STATE_STORAGE_MODE`
- `POLYWEATHER_EVENT_STORE`
- `POLYWEATHER_REDIS_REQUIRED`
- `POLYWEATHER_PAYMENT_ENABLED` - `POLYWEATHER_PAYMENT_ENABLED`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- `POLYGON_WALLET_WATCH_ENABLED` - `POLYGON_WALLET_WATCH_ENABLED`
- `TELEGRAM_ALERT_PUSH_ENABLED` - `TELEGRAM_ALERT_PUSH_ENABLED`
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED` - `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
@@ -126,7 +132,11 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` - `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC`
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N` - `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
- `POLYWEATHER_PAYMENT_RPC_URLS` - `POLYWEATHER_PAYMENT_RPC_URLS`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- `TAF_CACHE_TTL_SEC` - `TAF_CACHE_TTL_SEC`
- `POLYWEATHER_REDIS_URL`
- `POLYWEATHER_REDIS_STREAM_KEY`
- `POLYWEATHER_REDIS_STREAM_MAXLEN`
策略: 策略:
@@ -215,6 +225,10 @@ TELEGRAM_CHAT_ID=...
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
POLYWEATHER_STATE_STORAGE_MODE=sqlite POLYWEATHER_STATE_STORAGE_MODE=sqlite
POLYWEATHER_EVENT_STORE=redis
POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
POLYWEATHER_REDIS_REQUIRED=true
UID=1000 UID=1000
GID=1000 GID=1000
POLYWEATHER_AUTH_ENABLED=true POLYWEATHER_AUTH_ENABLED=true
@@ -236,13 +250,6 @@ TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800 TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5 TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000 POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
POLYWEATHER_SCAN_AI_ENABLED=false
POLYWEATHER_SCAN_AI_API_KEY=...
POLYWEATHER_SCAN_AI_PROVIDER=mimo
POLYWEATHER_SCAN_AI_PROVIDER_LABEL=MiMo
POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
POLYWEATHER_SCAN_AI_MODEL=mimo-v2.5-pro
POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
``` ```
说明: 说明:
@@ -252,15 +259,43 @@ POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
- `POLYWEATHER_RUNTIME_DATA_DIR` 建议放在仓库外,例如 `/var/lib/polyweather` - `POLYWEATHER_RUNTIME_DATA_DIR` 建议放在仓库外,例如 `/var/lib/polyweather`
- `docker-compose.yml` 会把这个目录同时挂载到容器内的 `/var/lib/polyweather``/app/data`,兼容现有缓存与 SQLite 路径。 - `docker-compose.yml` 会把这个目录同时挂载到容器内的 `/var/lib/polyweather``/app/data`,兼容现有缓存与 SQLite 路径。
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite` - `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL` - `POLYWEATHER_EVENT_STORE=redis` 表示实时观测 patch 使用 Redis Stream 做短窗口 replay 和多 worker fanout;本地或单进程可改为 `sqlite`
- `POLYWEATHER_REDIS_REQUIRED=true` 表示 Redis 不可用时后端启动失败,避免生产环境广播不可 replay 的实时事件;开发环境可设为 `false` 允许回退 SQLite。
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持默认链的逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON` 用于多链支付,例如同时支持 Polygon 和 Ethereum 主网 USDC。
- 机器人市场监控包含 `关键提醒``关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。 - 机器人市场监控包含 `关键提醒``关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
- `POLYWEATHER_SCAN_AI_*` 走 OpenAI-compatible `/chat/completions`;当前临时默认 MiMo,可用 `POLYWEATHER_SCAN_AI_BASE_URL``POLYWEATHER_SCAN_AI_MODEL` 随时切回其他兼容 provider。
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。 - `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
说明: 说明:
- 这层只负责把结构化信号改写成短摘要,不替代真实模型、机场锚点和结算逻辑。 - 这层只负责把结构化信号改写成短摘要,不替代真实模型、机场锚点和结算逻辑。
### 6.3 支付多链配置示例
当前生产推荐:
- 默认链:Polygon `chain_id=137`,继续承载 checkout 合约支付。
- 补充链:Ethereum Mainnet `chain_id=1`,正式支持 USDC 直转确认。
- 前端创建支付 intent 时会提交用户选择的 `chain_id`;后端确认时按 intent 的链和 token 查询对应 RPC。
```env
POLYWEATHER_PAYMENT_ENABLED=true
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com","https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<polygon_checkout_contract>
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=0x<treasury_or_receiver_wallet>
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<polygon_checkout_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>","is_default":true},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x<treasury_or_receiver_wallet>","supports_contract_checkout":false,"supports_direct_transfer":true,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}]
```
注意:
- `POLYWEATHER_PAYMENT_CHAIN_ID` 只是默认链,不代表只支持这一条链。
- `POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON` 里每个 token 必须有明确 `chain_id`
- Ethereum 行如果没有部署 checkout 合约,必须设置 `supports_contract_checkout=false`,前端会显示手动转账并阻止钱包合约支付。
- 私有 RPC URL 带 API key 时应放入真实 `.env` 或密钥管理,不要提交。
### 6.5 机器人市场监控建议配置 ### 6.5 机器人市场监控建议配置
这套配置围绕市场本身做两类推送: 这套配置围绕市场本身做两类推送:
+12
View File
@@ -1,5 +1,7 @@
# 前端部署配置(Vercel # 前端部署配置(Vercel
最后更新:`2026-05-29`
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。 本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。
## 一、部署目标 ## 一、部署目标
@@ -16,6 +18,12 @@
2. Next `/api/*` -> `POLYWEATHER_API_BASE_URL` 2. Next `/api/*` -> `POLYWEATHER_API_BASE_URL`
3. FastAPI 后端 -> 分析 / 支付 / 鉴权服务 3. FastAPI 后端 -> 分析 / 支付 / 鉴权服务
实时图表同样走 Next Route Handler / rewrite
1. 浏览器 `EventSource` -> `/api/events?cities=...&since_revision=...`
2. Next 转发到 FastAPI `/api/events`
3. FastAPI 从 Redis Stream / SQLite event log replay 后进入 live SSE
## 二、Vercel 项目设置 ## 二、Vercel 项目设置
在 Vercel 导入 GitHub 仓库后,使用下面的设置: 在 Vercel 导入 GitHub 仓库后,使用下面的设置:
@@ -119,12 +127,15 @@ NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
1. 用户点击支付前,前端会重新请求 `/api/payments/config` 1. 用户点击支付前,前端会重新请求 `/api/payments/config`
2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址 2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址
3. 若后端返回的 `tx_payload.to` 与最新 `receiver_contract` 不一致,会直接阻断支付 3. 若后端返回的 `tx_payload.to` 与最新 `receiver_contract` 不一致,会直接阻断支付
4. 多链支付时,前端会展示后端返回的网络列表,并把用户选择的 `chain_id` 传给后端创建 intent
5. Ethereum 主网 USDC 当前走手动直转确认,前端不会把它当成 Polygon checkout 合约支付
这层防护的目的,是降低以下事故概率: 这层防护的目的,是降低以下事故概率:
- 用户使用长期未刷新的旧标签页 - 用户使用长期未刷新的旧标签页
- 命中旧 deployment URL - 命中旧 deployment URL
- 页面本地状态残留旧收款地址 - 页面本地状态残留旧收款地址
- 用户在钱包默认网络(例如 Ethereum)付款,但系统按 Polygon intent 查账
如果你变更过支付收款地址,建议同步执行: 如果你变更过支付收款地址,建议同步执行:
@@ -193,6 +204,7 @@ Vercel 部署前至少确认:
4. 如果启用鉴权,Supabase redirect URL 已包含前端域名 4. 如果启用鉴权,Supabase redirect URL 已包含前端域名
5. `GET /api/payments/config` 返回的是当前最新地址,而不是旧收款合约 5. `GET /api/payments/config` 返回的是当前最新地址,而不是旧收款合约
6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在 Vercel 与后端同时配置 6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在 Vercel 与后端同时配置
7. 确认 `/api/events` 没有被 CDN 缓存或压缩成普通 JSON;它必须保持 `text/event-stream`
## 九、常见问题 ## 九、常见问题
+74 -2
View File
@@ -2,7 +2,7 @@
本文档记录 PolyWeather 当前开放模型接入、区域覆盖差异,以及 DEB 在新增模型后的计权规则。 本文档记录 PolyWeather 当前开放模型接入、区域覆盖差异,以及 DEB 在新增模型后的计权规则。
最后更新:`2026-04-18` 最后更新:`2026-05-28`
## 1. 接入方式 ## 1. 接入方式
@@ -165,11 +165,73 @@ raw current_forecasts
-> 按模型家族去重 -> 按模型家族去重
-> 历史 MAE 统计 -> 历史 MAE 统计
-> MAE 倒数权重 -> MAE 倒数权重
-> 输出 blended_high + weights_info -> 输出 raw blended_high
-> recent signed-bias correction
-> 输出 production prediction + raw_prediction + version
``` ```
`weights_info` 出现 `家族去重`,表示当前输入模型数量多于 DEB 实际入模数量,系统已先折叠同家族模型。 `weights_info` 出现 `家族去重`,表示当前输入模型数量多于 DEB 实际入模数量,系统已先折叠同家族模型。
### 5.1 DEB hourly consensus
当前图表和峰值窗口优先使用 `deb_hourly_consensus.v1`
入口:
- `src/analysis/deb_hourly_consensus.py`
- `web/analysis_service.py`
- `src/analysis/trend_engine.py`
处理逻辑:
```text
multi_model hourly curves
-> 按 DEB 可用模型和家族去重口径选择候选
-> 使用 DEB 权重生成逐小时 consensus
-> 输出 deb.hourly_consensus
-> 图表 DEB Forecast 与 peak-window 逻辑优先读取该路径
```
关键口径:
- `deb_hourly_consensus.v1` 是预测曲线,不是实测曲线。
- 它只用于日内形状、峰值窗口和 DEB Forecast 展示;视觉预警和“接近峰值”状态必须优先读取实测/跑道/官方站点当前值与今日实测高点。
- 如果多模型小时路径缺失,系统才回退旧的 `hourly_plus_deb_offset`
### 5.2 版本化预测与偏差校正
DEB 原始融合逻辑不推倒重写,`calculate_dynamic_weights(...)` 仍作为 raw baseline。线上生产入口使用 `calculate_deb_prediction(...)` 包装 raw baseline,并在有足够历史样本时追加城市级 recent signed-bias correction。
当前版本:
- `deb_v1_raw`:原始 DEB,最近模型误差倒数加权后的融合值。
- `deb_v1_recent_bias_corrected`:在 raw DEB 上叠加最近已结算样本的有符号偏差校正。偏差使用 shrinkage,样本少时自动收缩,避免单日异常过拟合。
API `deb` payload 会保留:
- `prediction`:当前生产使用值。
- `raw_prediction`:未经 recent-bias correction 的原始 DEB。
- `version`:生产值对应的 DEB 版本。
- `bias_adjustment` / `bias_samples`:城市级偏差校正幅度与训练样本数。
- `intraday_adjustment`:网页 full detail 中额外的日内观测路径修正,仅用于当前日实时展示。
版本化回测命令:
```bash
python scripts/backtest_deb_versions.py \
--output-json data/deb_backtest_latest.json \
--output-csv data/deb_backtest_latest.csv
```
本地 `data/polyweather.db` 于 2026-05-27 的回测样本显示:
| 版本 | 样本 | MAE | RMSE | Bias | 结算桶命中率 |
| --- | ---: | ---: | ---: | ---: | ---: |
| `deb_v1_raw` | 1272 | 1.626 | 2.602 | -0.633 | 25.8% |
| `deb_v1_recent_bias_corrected` | 1272 | 1.499 | 2.542 | +0.263 | 31.4% |
该结果只证明当前历史样本上的离线表现改善;后续仍需要持续用版本化回测追踪不同城市、季节与结算源下的漂移。
## 6. 前端展示 ## 6. 前端展示
网页的模型展示读取: 网页的模型展示读取:
@@ -177,6 +239,7 @@ raw current_forecasts
- `multi_model` - `multi_model`
- `multi_model_daily` - `multi_model_daily`
- `source_forecasts.open_meteo_multi_model.model_metadata` - `source_forecasts.open_meteo_multi_model.model_metadata`
- `deb.hourly_consensus`
显示分组: 显示分组:
@@ -197,6 +260,13 @@ raw current_forecasts
区域模型不覆盖时不显示空模型。 区域模型不覆盖时不显示空模型。
终端图表默认:
- 展示“全天”视图,按城市当地日 00:00-23:59 和真实观测时间绘制。
- 可选“高温”视图,窗口由 `deb_hourly_consensus.v1` 的峰值区域推导。
- DEB Forecast 使用橙色预测曲线;实测/跑道/官方站点曲线独立展示,不把 DEB 当成实测。
- legacy 高斯概率显示为水平温度带和 `mu` 参考线,不参与时间序列曲线。
### 6.1 “来源 Open-Meteo”是什么意思 ### 6.1 “来源 Open-Meteo”是什么意思
前端中的 `来源 - Open-Meteo` 表示本次多模型数据通过 Open-Meteo model API 归一化接入。 前端中的 `来源 - Open-Meteo` 表示本次多模型数据通过 Open-Meteo model API 归一化接入。
@@ -228,6 +298,7 @@ raw current_forecasts
- `tests/test_multi_model_sources.py` - `tests/test_multi_model_sources.py`
- `tests/test_deb_model_family.py` - `tests/test_deb_model_family.py`
- `tests/test_deb_evaluation_upgrade.py`
重点覆盖: 重点覆盖:
@@ -237,3 +308,4 @@ raw current_forecasts
- DEB 家族去重 - DEB 家族去重
- 历史不足时的去重等权 - 历史不足时的去重等权
- 有历史 MAE 时的去重动态权重 - 有历史 MAE 时的去重动态权重
- DEB raw/corrected 版本化评估、recent-bias correction、JSON/CSV 回测输出
+17 -1
View File
@@ -1,6 +1,6 @@
# 外部监控与告警说明 # 外部监控与告警说明
最后更新:`2026-04-18` 最后更新:`2026-05-28`
## 1. 目标 ## 1. 目标
@@ -11,6 +11,7 @@
- Relay 把告警推到运营频道 - Relay 把告警推到运营频道
- Grafana 展示趋势面板 - Grafana 展示趋势面板
- 巡检脚本补健康检查 - 巡检脚本补健康检查
- 关注 `/api/events` 长连接与 realtime event store 是否正常 replay
## 2. 组件 ## 2. 组件
@@ -89,6 +90,14 @@ POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
- Source Requests by Outcome - Source Requests by Outcome
- Source Error Rate (15m) - Source Error Rate (15m)
实时事件层建议额外观察:
- Redis Stream latest revision
- Redis 连接状态
- SQLite fallback 是否被启用
- SSE active connection count
- `resync_required` 出现频率
## 7. 巡检脚本 ## 7. 巡检脚本
手动巡检: 手动巡检:
@@ -102,6 +111,7 @@ python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
- `/healthz` - `/healthz`
- `/api/system/status` - `/api/system/status`
- `/metrics` - `/metrics`
- `/api/events`(手动验证时查看 `connected` / `heartbeat` / replay 事件)
任何一项失败都会非零退出,适合挂到 crontab 或 systemd timer。 任何一项失败都会非零退出,适合挂到 crontab 或 systemd timer。
@@ -130,6 +140,11 @@ python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
- 多日预报是否只返回当天单卡 - 多日预报是否只返回当天单卡
- 日内分析 full detail / market scan 是否仍在同步 - 日内分析 full detail / market scan 是否仍在同步
- 右侧详情面板是否正在用同步占位卡提示用户 - 右侧详情面板是否正在用同步占位卡提示用户
- 实时事件状态:
- event store 类型(Redis / SQLite
- latest revision
- Redis 是否连通
- 是否处于 `degraded_from=redis` fallback
这意味着: 这意味着:
@@ -180,3 +195,4 @@ npm run build
- 更细粒度支付指标 - 更细粒度支付指标
- 按城市/来源拆分的业务 SLA - 按城市/来源拆分的业务 SLA
- 按城市拆分的前端补齐耗时与 stale-detail 告警 - 按城市拆分的前端补齐耗时与 stale-detail 告警
- Redis Stream 长度、内存与 replay gap 告警
+49 -37
View File
@@ -1,53 +1,65 @@
# 外部服务依赖总览 # 外部服务依赖总览
最后更新:`2026-05-23` 最后更新:`2026-05-29`
项目调用了 20 个外部服务,按状态分为三类 项目调用外部天气、鉴权、支付和实时事件服务。原则是:核心链路必须有明确健康检查;可选数据源不可拖垮已可用城市;实时事件层可从 Redis Stream 降级到 SQLite event log
## 核心(必须有,挂了服务不可用) ## 核心(必须有,挂了服务不可用)
| 服务 | 用途 | 状态 | | 服务 | 用途 | 状态 |
| ---------------------- | --------------------- | ---- | | --- | --- | --- |
| Open-Meteo | 52 城天气预报 | ✅ | | Open-Meteo | 51 城天气预报、多模型小时曲线、DEB hourly consensus 输入 | ✅ |
| AviationWeather (NOAA) | METAR/TAF 航空观测 | ✅ | | AviationWeather (NOAA) | METAR / TAF 航空观测 | ✅ |
| MADIS (NOAA) | 美国 5 分钟高频观测 | ✅ | | MADIS (NOAA) | 美国机场 5 分钟高频观测 | ✅ |
| Supabase | 用户认证 + 订阅 | ✅ | | Supabase | 用户认证、订阅状态、会员恢复 | ✅ |
| Telegram Bot API | Bot 消息 + 群成员检查 | ✅ | | Telegram Bot API | Bot 消息群成员检查、双语跑道推送 | ✅ |
| KNMI | Amsterdam 10 分钟观测 | ✅ |## 国家气象源(特定城市必须) | Redis | `city_observation_patch.v1` Stream、SSE replay、多 worker fanout | ✅ |
| SQLite | 运行态数据库、支付审计、实时事件 fallback | ✅ |
| 服务 | 城市 | 状态 | ## 国家气象源(特定城市必须)
| -------------------- | --------------------- | ----------- |
| JMA (日本) | Tokyo | ✅ | | 服务 | 城市 | 状态 |
| KMA + AMOS (韩国) | Seoul, Busan | ✅ | | --- | --- | --- |
| AMSC AWOS (中国) | 北京/上海/广州等 6 城 | ✅ | | JMA AMeDAS | Tokyo | ✅ |
| MGM (土耳其) | Ankara, Istanbul | ✅ | | AMOS (韩国) | Seoul, Busan 跑道传感器 | ✅ |
| FMI (芬兰) | Helsinki | ✅ | | AMSC AWOS (中国) | 北京、上海、广州、成都、重庆、武汉、青岛跑道端点气温 | ✅ |
| HKO (香港) | Hong Kong | ✅ | | MGM (土耳其) | Ankara, Istanbul | ✅ |
| CWA (台湾) | Taipei | ✅ | | FMI (芬兰) | Helsinki | ✅ |
| NMC (中国) | 国内城市 fallback | ✅ | | KNMI (荷兰) | Amsterdam | ✅(需 key |
| Singapore MSS | Singapore | ✅ | | CoWIN 6087 (香港) | Hong Kong 1 分钟参考站 | ✅ |
| IMGW (波兰) | Warsaw | ⚠️ 未配 key | | HKO (香港) | Hong Kong / Shenzhen / Lau Fau Shan 10 分钟官方气象层 | ✅ |
| Russia pogodaiklimat | Moscow | ❌ 已移除 | | CWA (台湾) | Taipei | ✅ |
| Singapore MSS | Singapore | ✅ |
| IMS Lod (以色列) | Tel Aviv | ✅ |
| AEROWEB / AROME HD | Paris | ✅ |
| NMC (中国) | 国内城市 fallback | ✅ |
| IMGW (波兰) | Warsaw | ⚠️ 未配 key |
## 可选 / 已禁用 ## 可选 / 已禁用
| 服务 | 用途 | 状态 | | 服务 | 用途 | 状态 |
| -------------- | ------------- | ----------- | | --- | --- | --- |
| OpenWeatherMap | 天气 fallback | ⚠️ 未配 key | | OpenWeatherMap | 天气 fallback | ⚠️ 未配 key |
| VisualCrossing | 历史天气 | ⚠️ 未配 key | | VisualCrossing | 历史天气 | ⚠️ 未配 key |
| Meteoblue | 天气预报 | ❌ 已移除 | | SynopticData | 美国站点观测 | ⚠️ 未配 key |
| SynopticData | 美国站点观测 | ⚠️ 未配 key | | Meteoblue | 天气预报 | ❌ 已移除 |
| Russia pogodaiklimat | Moscow 历史源 | ❌ 已移除 |
| Groq | AI commentary | ❌ 已移除 |
## AI / 其他 ## AI / 支付 / 前端
| 服务 | 用途 | 状态 | | 服务 | 用途 | 状态 |
| ----------------- | ---------------- | ----------- | | --- | --- | --- |
| MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 | | MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 |
| DeepSeek | AI fallback | - 备用 | | DeepSeek | AI fallback | 备用 |
| Groq | AI commentary | ❌ 已移除 | | Polygon RPC | checkout 合约支付、Polygon USDC / USDC.e 自动确认 | ✅ |
| Polygon RPC | 链上支付 | ✅ | | Ethereum RPC | Ethereum 主网 USDC 直转确认 | ✅(启用多链支付时必须) |
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key | | WalletConnect | 前端钱包连接 | ⚠️ 未配 key 时钱包入口降级 |
## 合计 ## 运维口径
15 个在用,3 个可选/未配置,3 个已移除 - 生产实时事件推荐:`POLYWEATHER_EVENT_STORE=redis` + `POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0`
- 本地或单进程兜底:`POLYWEATHER_EVENT_STORE=sqlite`
- Redis 只负责短窗口 replay 与多 worker fanout,不是长期天气历史库。
- DEB hourly consensus 依赖 Open-Meteo 多模型小时曲线;若上游限流,图表应保留已有 snapshot 和实测 patch,不把缺失模型误报为实测缺失。
- 支付多链确认依赖 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`;如果启用 Ethereum 主网 USDC,必须配置 `chain_id=1` 的 RPC,否则用户提交 Ethereum tx hash 后无法自动确认。
+33 -9
View File
@@ -1,12 +1,12 @@
# Supabase + 登录 + 支付接入说明(v1.8.0 # Supabase + 登录 + 支付接入说明(v1.8.1
最后更新:`2026-03-14` 最后更新:`2026-05-29`
## 1. 目标 ## 1. 目标
- 前端支持 Google 一键登录 + 邮箱注册/登录。 - 前端支持 Google 一键登录 + 邮箱注册/登录。
- 后端支持 Supabase JWT 鉴权。 - 后端支持 Supabase JWT 鉴权。
- 支持 Polygon 合约支付(USDC / USDC.e)并自动确认开通订阅。 - 支持 Polygon 合约支付(USDC / USDC.e和 Ethereum 主网 USDC 直转支付,并自动确认开通订阅。
## 2. Supabase 控制台配置 ## 2. Supabase 控制台配置
@@ -22,6 +22,7 @@
在 Supabase SQL Editor 执行: 在 Supabase SQL Editor 执行:
- `scripts/supabase/schema.sql` - `scripts/supabase/schema.sql`
- 既有生产项目遇到 Disk IO Budget 告警时,再执行 `scripts/supabase/io_budget_indexes.sql`
会创建支付与订阅相关表: 会创建支付与订阅相关表:
@@ -33,6 +34,15 @@
- `payment_intents` - `payment_intents`
- `payment_transactions` - `payment_transactions`
### 3.1 Disk IO 告警处理
如果 Supabase 提示项目正在耗尽 Disk IO Budget,先在 SQL Editor 执行:
1. `scripts/supabase/io_budget_indexes.sql`
2. `scripts/supabase/disk_io_diagnostics.sql`
第一个脚本会把热查询索引收敛为更低写放大的部分索引,移除已被唯一约束覆盖或无热路径使用的冗余索引,并对相关表执行 `ANALYZE`。第二个脚本用于查看表扫描、dead tuples、索引命中和 `pg_stat_statements` 中的高读块 SQL。生产执行后,继续观察 Supabase daily/hourly Disk IO 图表,确认请求延迟和 IO wait 是否下降。
## 4. 环境变量 ## 4. 环境变量
### 4.1 前端(Vercel / frontend/.env.local ### 4.1 前端(Vercel / frontend/.env.local
@@ -66,30 +76,38 @@ SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_HTTP_TIMEOUT_SEC=8 SUPABASE_HTTP_TIMEOUT_SEC=8
POLYWEATHER_PAYMENT_ENABLED=true POLYWEATHER_PAYMENT_ENABLED=true
# 默认链仍是 Polygon,因为当前 checkout 合约部署在 Polygon。
POLYWEATHER_PAYMENT_CHAIN_ID=137 POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-bor-rpc.publicnode.com POLYWEATHER_PAYMENT_RPC_URL=https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<receiver_contract> POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<receiver_contract>
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=0x<treasury_or_receiver_wallet>
POLYWEATHER_PAYMENT_CONFIRMATIONS=2 POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800 POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600 POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4 POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50 POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
# 支持币种(示例) # 支持多链多币种(示例)
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_e","symbol":"USDC.e","name":"USDC.e (PoS)","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver>","is_default":true},{"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver>"}] # Ethereum 主网 USDC 当前建议只开 direct transfer,不走 Polygon checkout 合约。
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>","is_default":true},{"code":"usdc_e_polygon","symbol":"USDC.e","name":"USDC.e on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>"},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x<treasury_or_receiver_wallet>","supports_contract_checkout":false,"supports_direct_transfer":true,"confirmations":2,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}]
# 套餐(当前只保留月付) # 套餐
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"5","duration_days":30}} POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"29.9","duration_days":30},"pro_quarterly":{"plan_id":102,"amount_usdc":"79.9","duration_days":90}}
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly
# 积分抵扣 # 积分抵扣
POLYWEATHER_PAYMENT_POINTS_ENABLED=true POLYWEATHER_PAYMENT_POINTS_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500 POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3 POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8}
# 支付自动补单 # 支付自动补单
POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true
POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC=20
POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_INTERVAL_SEC=300
POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_AFTER_EMPTY_CYCLES=3
``` ```
## 5. 验证步骤 ## 5. 验证步骤
@@ -100,10 +118,16 @@ POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
- `POST /api/payments/wallets/challenge` - `POST /api/payments/wallets/challenge`
- `POST /api/payments/wallets/verify` - `POST /api/payments/wallets/verify`
4. 支付流程: 4. 支付流程:
- `POST /api/payments/intents` - `POST /api/payments/intents`;多链时前端会带 `chain_id``token_address`
- 发链上交易 - 发链上交易
- `POST /api/payments/intents/{id}/submit` - `POST /api/payments/intents/{id}/submit`
- `POST /api/payments/intents/{id}/confirm` - `POST /api/payments/intents/{id}/confirm`
5. 若前端显示 pending,轮询: 5. 若前端显示 pending,轮询:
- `GET /api/payments/intents/{id}` - `GET /api/payments/intents/{id}`
6. 确认订阅:`/api/auth/me` 返回 `subscription_active=true` 6. 确认订阅:`/api/auth/me` 返回 `subscription_active=true`
## 6. 多链支付口径
- Polygon 是默认链,仍支持钱包合约支付和手动直转确认。
- Ethereum 主网 USDC 是正式支付链路,但当前建议只走手动直转:用户选择 Ethereum 后,前端展示收款钱包、金额、代币合约和 Etherscan 链接,用户提交 tx hash 后后端按 `intent.chain_id=1` 查询 Ethereum RPC。
- 不要只依赖前端文案阻止错链付款;后端必须把每笔 intent 的 `chain_id``token_address``receiver_address` 落库,并在确认时按该链校验 `Transfer` 事件。
+2 -2
View File
@@ -1,4 +1,4 @@
# 技术债与工程待办(v1.8.0 # 技术债与工程待办(v1.8.1
最后更新:`2026-05-10` 最后更新:`2026-05-10`
@@ -60,7 +60,7 @@ flowchart TD
| 项目 | 影响 | 建议动作 | | 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- | | :-- | :-- | :-- |
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/首次消息奖励/欢迎奖励/周排名奖励/周参与奖/手动补分 | | 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(有效付费邀请/后台人工补发/积分抵扣消费 |
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 | | 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 | | 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
+12 -12
View File
@@ -2,7 +2,7 @@
> 审查日期:2026-06 | 视角:系统架构师 | 范围:完整数据采集→分析→API→前端状态 > 审查日期:2026-06 | 视角:系统架构师 | 范围:完整数据采集→分析→API→前端状态
> >
> **修复状态:8/8 已完成** > **修复状态:9/9 已完成;最后校准:2026-05-28**
## 一、数据架构总览 ## 一、数据架构总览
@@ -13,9 +13,8 @@
Open-Meteo (预报+多模型) ─┐ Open-Meteo (预报+多模型) ─┐
METAR/TAF (航空气象) ─┤ METAR/TAF (航空气象) ─┤
NWS (美国) / MGM (土耳其) ─┤ NWS (美国) / MGM (土耳其) ─┤
JMA/AMOS/NMC/HKO/CWA ─┤ JMA/AMOS/AMSC/HKO/CWA ─┤
Wunderground / NOAA ─┤ CoWIN / MADIS / NOAA ─┤
Polymarket Gamma/CLOB ─┤
├─ WeatherDataCollector ├─ dashboard-client.ts ├─ WeatherDataCollector ├─ dashboard-client.ts
│ (内存缓存 + SQLite磁盘缓存) │ (ETag浏览器缓存 + SWR) │ (内存缓存 + SQLite磁盘缓存) │ (ETag浏览器缓存 + SWR)
│ │ │ │
@@ -27,8 +26,11 @@ Polymarket Gamma/CLOB ─┤
│ ├─ ThreadPoolExecutor(4) │ (120s TTL) │ ├─ ThreadPoolExecutor(4) │ (120s TTL)
│ └─ AI 增强层 (DeepSeek) │ │ └─ AI 增强层 (DeepSeek) │
│ │ │ │
─ FastAPI routes └─ API代理 (Next.js rewrites) ─ FastAPI routes └─ API代理 (Next.js rewrites)
(36个端点 + ETag 304) (HTTP snapshot + ETag 304)
└─ SSE /api/events
(Redis Stream / SQLite replay)
``` ```
## 二、数据采集层 ## 二、数据采集层
@@ -43,10 +45,8 @@ Polymarket Gamma/CLOB ─┤
| NWS | 国家预报 | 美国 | 按请求 | | NWS | 国家预报 | 美国 | 按请求 |
| MGM | 国家官方 | 土耳其 | 300s | | MGM | 国家官方 | 土耳其 | 300s |
| ECMWF/GFS/ICON/GEM/JMA | 多模型 NWP | 全球 | 300s | | ECMWF/GFS/ICON/GEM/JMA | 多模型 NWP | 全球 | 300s |
| HKO/CWA/NOAA/AMOS/NMC | 结算观测 | 特定国家 | 60s (AMOS) / 300s | | HKO/CWA/NOAA/AMOS/AMSC/CoWIN | 结算/参考观测 | 特定国家 | 60s-600s |
| Wunderground | 个人气象站 | 全球备用 | 按请求 | | Redis Stream | 实时事件 replay / 多 worker fanout | 后端内部 | event-driven |
| Polymarket Gamma | 市场发现 | 所有温度市场 | 60s |
| Polymarket CLOB | 订单簿 | 匹配市场 | 30s |
### 待改进 ### 待改进
@@ -78,7 +78,7 @@ Polymarket Gamma/CLOB ─┤
**已修复:TTL 匹配**`SCAN_TERMINAL_PAYLOAD_TTL_SEC` 30s → 120s,匹配 ThreadPoolExecutor(4)×60 城的实际重算耗时。 **已修复:TTL 匹配**`SCAN_TERMINAL_PAYLOAD_TTL_SEC` 30s → 120s,匹配 ThreadPoolExecutor(4)×60 城的实际重算耗时。
**已实现:SSE 增量推送(SSE Patch)与按需刷新** — 引入 FastAPI SSE 广播通道 (`/api/events`)。数据采集端更新时自动向 `/api/internal/collector-patch` 推送最新温度;前端扫描终端订阅该流,不再执行固定的 5 分钟定时轮询,而是根据 Patch 变化即时更新列表;当前选中图表基于 `useLatestPatch` 实现 1 分钟级温度的增量合并与实时曲线绘制。 **已实现:SSE 增量推送(SSE Patch)与可重放事件日志** — 引入 FastAPI SSE 广播通道 (`/api/events`)。数据采集端更新时自动向 `/api/internal/collector-patch` 推送最新温度;后端标准化为 `city_observation_patch.v1`,写入 Redis Stream(生产)或 SQLite event log(兜底)后再广播。前端扫描终端订阅该流,不再执行固定的 5 分钟定时轮询,而是根据 Patch 变化即时更新列表;当前选中图表基于 `useLatestPatch` 实现 1 分钟级温度的增量合并与实时曲线绘制。
| # | 问题 | 优先级 | | # | 问题 | 优先级 |
|---|------|------| |---|------|------|
@@ -94,7 +94,7 @@ Polymarket Gamma/CLOB ─┤
**已修复:扫描数据复用**`preloadCityFromRow()` 从扫描终端行预填充城市详情缓存,选城市后详情面板立即显示。 **已修复:扫描数据复用**`preloadCityFromRow()` 从扫描终端行预填充城市详情缓存,选城市后详情面板立即显示。
**已实现:SSE 订阅与 2 分钟无 Patch 兜底机制** — 引入 `useLatestPatch``useSsePatchVersion` 管理实况数据的准实时合并。若长连接中断 2 分钟内未收到任何增量 Patch,可见图表自动触发 60s 降级轮询(从 `/api/city/{city}/summary` 获取最新实况,并以 ignoreCache 强刷 full detail)。 **已实现:SSE 订阅、replay 与 2 分钟无 Patch 兜底机制** — 引入 `useLatestPatch``useSsePatchVersion` 管理实况数据的准实时合并。若长连接中断,前端使用 `since_revision` replay 缺失事件;若 2 分钟内未收到任何增量 Patch,可见图表自动触发 60s 降级轮询(从 `/api/city/{city}/summary` 获取最新实况,并以 ignoreCache 强刷 full detail)。浏览器后台恢复时会主动刷新可见图表 full detail。
## 六、待办 ## 六、待办
+24 -26
View File
@@ -2,24 +2,24 @@
## 执行摘要 ## 执行摘要
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。 PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、Ethereum 主网 USDC 直转确认、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
> **2026-05-23 更新v1.7.0**Polymarket 价格拉取与 UI 层(MarketDecisionLine)已删除,`market_scan` 当前返回空;LGBM 已完全移除,概率引擎仅保留 legacy 高斯 + EMOS/CRPSGroq、Meteoblue、NMC、pogodaiklimat 数据源和 prewarm 预热系统已移除 > **2026-05-29 更新**:支付 intent 已支持多链 `chain_id`Polygon 继续承载 checkout 合约,Ethereum 主网 USDC 作为直转确认通道;终端图表继续使用 HTTP snapshot + SSE Patch + Redis Stream/SQLite replay 架构
## 项目概览 ## 项目概览
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。 PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
项目主功能可归纳为五层: 项目主功能可归纳为五层:
**天气层(数据源/采集)**:聚合 51 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、韩国 AMOS 跑道级观测(首尔/釜山)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报、美国 NWS(仅美国城市)、以及部分城市使用明确官方站点或历史页面入口(香港 HKO、台湾/深圳相关历史页面等)等。机场类市场仍以 METAR / 机场主站为结算锚点,Wunderground 不描述为物理观测站。 **天气层(数据源/采集)**:聚合 51 个城市的实测与预报;支持 AviationWeather METAR/TAF、韩国 AMOS 跑道级观测(首尔/釜山)、中国 AMSC AWOS 跑道端点气温、香港 CoWIN 6087 / HKO、土耳其 MGM、Open-Meteo 多模型与集合预报、美国 MADIS HFMETAR 等。机场类市场仍以 METAR / 机场主站或明确官方结算源为锚点,Wunderground 不描述为物理观测站。
**分析层(DEB/趋势/概率/结算口径)** **分析层(DEB/趋势/概率/结算口径)**
DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;运行态仍维护近 14 天 `daily_records` 缓存做当前对账,但长期监督真值与训练特征已经迁到 SQLite 永久表中,并支持基于 WUWeather Underground 口径)四舍五入的结算命中评估。 DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;运行态仍维护近 14 天 `daily_records` 缓存做当前对账,但长期监督真值与训练特征已经迁到 SQLite 永久表中,并支持基于 WUWeather Underground 口径)四舍五入的结算命中评估。
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。 趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
**城市决策层(Scan Terminal / AI 机场报文解读**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR,并通过 `/api/scan/terminal/ai-city/stream` 生成城市级 AI 解读。该解读由 `final_judgment``metar_read``reasoning``model_cluster_note``risks` 与原始 METAR 证据组成;最高温中枢优先使用 AI `predicted_max`,再回退到 DEB、多模型中心、日内 pace 当前实测。 **城市决策层(Scan Terminal / 结构化实况层**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR/官方站点/跑道观测,并通过 `/api/city/{name}/detail` 生成城市级结构化数据。终端图表使用 HTTP snapshot + SSE patch + Redis/SQLite replay,最高温中枢优先使用 DEB hourly consensus,再结合多模型中心、日内 pace 当前实测。
**市场层(Polymarket 行情对照)***[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。 **市场层(Polymarket 行情对照)***[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。 **商业化与支付**:订阅(`Pro Monthly 29.9 USDC / 30 天``Pro Quarterly 79.9 USDC / 90 天`)、邀请积分抵扣、Polygon 链上收款合约(USDC/USDC.e、Ethereum 主网 USDC 直转确认,并提供“事件监听 + 周期确认”的自动补单机制。
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。 **支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。 **许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。
(插图:项目 README 中包含产品截图,可用于快速理解信息架构与 UI 形态) (插图:项目 README 中包含产品截图,可用于快速理解实时终端与 Telegram 推送形态)
![PolyWeather demo map](https://raw.githubusercontent.com/yangyuan-zhen/PolyWeather/main/docs/images/demo_map.png) ![PolyWeather realtime terminal](https://raw.githubusercontent.com/yangyuan-zhen/PolyWeather/main/frontend/public/static/web.png)
## 架构与代码库分析 ## 架构与代码库分析
@@ -28,8 +28,8 @@ DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
从 README、Docker/Compose、入口脚本与核心模块引用关系,可以抽象出如下模块地图(按“运行时组件”与“Python 域模块”两层描述): 从 README、Docker/Compose、入口脚本与核心模块引用关系,可以抽象出如下模块地图(按“运行时组件”与“Python 域模块”两层描述):
| 层级 | 目录/文件 | 角色定位 | 关键说明 | | 层级 | 目录/文件 | 角色定位 | 关键说明 |
| ------------- | ------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------- | ------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 运行时组件 | `frontend/` | Next.js 前端(Vercel | 前端重构报告提到 App Router、Route HandlersBFF)、缓存策略、支付与账户中心等;Scan Terminal 已新增城市决策卡、AI 机场报文解读、页面内存/localStorage 双层缓存、AI stream 小并发队列与完整市场桶映射。 | | 运行时组件 | `frontend/` | Next.js 前端(Vercel | 前端重构报告提到 App Router、Route HandlersBFF)、缓存策略、支付与账户中心等;Scan Terminal 已新增城市决策卡、结构化实况层、页面内存/localStorage 双层缓存、structured detail 小并发队列与完整市场桶映射。 |
| 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` + `web/scan_terminal_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层;Scan Terminal 侧提供 `/api/scan/terminal/ai-city/stream`,城市 AI 默认 30s 超时并支持 stream parse failure 的非流式重试。 | | 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` + `web/scan_terminal_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层;Scan Terminal 侧提供 `/api/city/{name}/detail`,城市结构化分析 默认 30s 超时并支持 stream parse failure 的非流式重试。 |
| 运行时组件 | `bot_listener.py` + `src/bot/*` | Telegram Bot | 入口 `bot_listener.py``start_bot()`,并由 `StartupCoordinator` 启动多个后台 loop。 | | 运行时组件 | `bot_listener.py` + `src/bot/*` | Telegram Bot | 入口 `bot_listener.py``start_bot()`,并由 `StartupCoordinator` 启动多个后台 loop。 |
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py``settlement_sources.py``metar_sources.py``mgm_sources.py``amos_station_sources.py``jma_amedas_sources.py``nws_open_meteo_sources.py``country_networks.py` 等。v1.7.0 已移除 NMC、pogodaiklimat、Meteoblue 数据源。 | | Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py``settlement_sources.py``metar_sources.py``mgm_sources.py``amos_station_sources.py``jma_amedas_sources.py``nws_open_meteo_sources.py``country_networks.py` 等。v1.7.0 已移除 NMC、pogodaiklimat、Meteoblue 数据源。 |
| Python 域模块 | `src/analysis/*` | DEB/趋势/概率/结算口径 | `deb_algorithm.py``trend_engine.py``settlement_rounding.py`。 | | Python 域模块 | `src/analysis/*` | DEB/趋势/概率/结算口径 | `deb_algorithm.py``trend_engine.py``settlement_rounding.py`。 |
@@ -45,13 +45,13 @@ DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
flowchart TB flowchart TB
subgraph Clients subgraph Clients
WEB[Next.js Frontend<br/>Vercel] WEB[Next.js Frontend<br/>Vercel]
CITYCARD[Scan Terminal City Cards<br/>AI airport read + bucket mapping] CITYCARD[Scan Terminal City Cards<br/>structured observations + probability mapping]
TG[Telegram Bot<br/>TeleBot + Handlers] TG[Telegram Bot<br/>TeleBot + Handlers]
end end
subgraph API subgraph API
FAST[FastAPI<br/>web/app.py] FAST[FastAPI<br/>web/app.py]
LLM[City AI stream<br/>OpenAI-compatible provider] LLM[City structured detail<br/>OpenAI-compatible provider]
end end
subgraph Data subgraph Data
@@ -71,7 +71,7 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
HKO[data.weather.gov.hk] HKO[data.weather.gov.hk]
CWA[opendata.cwa.gov.tw] CWA[opendata.cwa.gov.tw]
SB[Supabase Auth/REST] SB[Supabase Auth/REST]
RPC[Polygon RPC] RPC[Polygon / Ethereum RPC]
end end
subgraph Payments subgraph Payments
@@ -118,8 +118,8 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解释”的前端决策层: Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解释”的前端决策层:
1. **地图点击与 pinned city**:免费/付费入口都会先把城市加入决策卡;未付费用户若权限不足,仍应保留卡片承载升级/限制提示,而不是点击后无反馈。 1. **地图点击与 pinned city**:免费/付费入口都会先把城市加入决策卡;未付费用户若权限不足,仍应保留卡片承载升级/限制提示,而不是点击后无反馈。
2. **full detail hydration**:卡片请求城市 full detail,拿到 DEB、当前/历史实测、多模型区间与最新 METAR。现阶段 detail hydration 仍偏保守串行,优先保障后端数据源稳定;真正消耗 LLM 的 AI 解读另行限流。 2. **full detail hydration**:卡片请求城市 full detail,拿到 DEB、当前/历史实测、多模型区间与最新 METAR。现阶段 detail hydration 仍偏保守串行,优先保障后端数据源稳定;真正消耗 LLM 的 结构化解读另行限流。
3. **AI 机场报文解读**:前端最多同时保留 2 条城市 AI stream,第三个及以后城市会进入队列并展示排队提示,避免多个 provider stream 同时竞争导致第三城/第四城解析失败。当前临时使用 MiMo:`POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1``POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro`;其他后端城市 AI 配置建议为 `POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30``POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900``POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true` 3. **结构化实况层**:前端最多同时保留 2 条城市结构化分析 stream,第三个及以后城市会进入队列并展示排队提示,避免多个 provider stream 同时竞争导致第三城/第四城解析失败。当前临时使用 MiMo:`POLYWEATHER_API_BASE_URL=<backend>``POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=300`;其他后端城市结构化分析 配置建议为 `POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=120``POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=8``POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=300`
4. **缓存策略**:页面内存缓存保留 loading/stream/final 状态,切换选项卡返回时不应空白重拉;localStorage 持久化最终成功、非 degraded 的 payload;后端 city AI cache key 已移除当前 `local_time` 干扰,主要按城市、日期与 METAR signature 失效。 4. **缓存策略**:页面内存缓存保留 loading/stream/final 状态,切换选项卡返回时不应空白重拉;localStorage 持久化最终成功、非 degraded 的 payload;后端 city AI cache key 已移除当前 `local_time` 干扰,主要按城市、日期与 METAR signature 失效。
5. **市场桶匹配**:城市市场扫描必须使用 full `all_buckets`,按温度 exact/range/“or higher”/“or lower” 方向严格匹配;不再用宽松 ±8°C fallback,以避免拿到 16°C 之类错误桶。前端展示统一使用“模型-市场差”,即 `model_probability - market_implied_probability`,并修复温度单位重复渲染(如 `31°°C`)。 5. **市场桶匹配**:城市市场扫描必须使用 full `all_buckets`,按温度 exact/range/“or higher”/“or lower” 方向严格匹配;不再用宽松 ±8°C fallback,以避免拿到 16°C 之类错误桶。前端展示统一使用“模型-市场差”,即 `model_probability - market_implied_probability`,并修复温度单位重复渲染(如 `31°°C`)。
@@ -127,7 +127,7 @@ Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解
**Python 依赖**`requirements.txt` 包含 `requests``loguru``pyTelegramBotAPI``python-dotenv``numpy``web3``fastapi``uvicorn` 等,符合“采集+bot+api+链上交互”的需求。 **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`),并挂载运行态数据目录。 **容器环境**`Dockerfile` 基于 `python:3.11-slim`,默认启动 bot`docker-compose.yml` 通过不同 command 分别启动 bot 与 web`python bot_listener.py` / `python web/app.py`),并挂载运行态数据目录。
**前端依赖**:前端 README 描述 Next.js、Leaflet、Chart.js、Supabase Auth、WalletConnect 等;`frontend/package.json` 是前端依赖来源。 **前端依赖**:前端 README 描述 Next.js、Leaflet、Recharts、Supabase Auth、WalletConnect 等;`frontend/package.json` 是前端依赖来源。
### 数据预处理、模型与“训练/推理”管线 ### 数据预处理、模型与“训练/推理”管线
本项目的“模型”主要是统计融合与规则/启发式引擎,而非深度网络训练: 本项目的“模型”主要是统计融合与规则/启发式引擎,而非深度网络训练:
@@ -136,7 +136,7 @@ Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解
**DEBDynamic Error Balancing**:以最近 N 天各模型的 MAE 计算倒数权重并做加权融合;同时将 `forecasts / actual_high / deb_prediction / mu / prob_snapshot` 写入 `data/daily_records.json`,并提供命中率/MAE/Brier 等统计口径。 **DEBDynamic 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 整数“温度桶概率”。 **概率引擎**`trend_engine.py` 以集合预报的 p10/p90 推 σ(并考虑历史 MAE floor、风向/云量/压强的 shock_score、以及峰值窗口 time-decay),再用正态近似把连续分布映射为 WU 整数“温度桶概率”。
**推理流水线(在线)** **推理流水线(在线)**
Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引擎输出结构化结果(μ、概率桶、趋势、死盘/窗口判定、DEB 预测、市场扫描)→ 前端渲染或 bot 消息格式化。对城市决策卡而言,在线推理还会叠加“latest METAR + 多模型区间 + AI city stream + full all_buckets 市场匹配”,最终输出最高温中枢、AI 机场报文解读和模型-市场差。 Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引擎输出结构化结果(μ、概率桶、趋势、死盘/窗口判定、DEB 预测、市场扫描)→ 前端渲染或 bot 消息格式化。对城市决策卡而言,在线推理还会叠加“latest METAR + 多模型区间 + structured city detail + full all_buckets 市场匹配”,最终输出最高温中枢、结构化实况层和模型-市场差。
**检查点(checkpoints**:传统 ML checkpoint 不适用;但项目现已形成两类“业务状态 checkpoint”: **检查点(checkpoints**:传统 ML checkpoint 不适用;但项目现已形成两类“业务状态 checkpoint”:
(a)SQLite 运行态存储(当前线上与核心离线链路主路径);(b)SQLite 永久真值/训练特征表(当前监督真值与训练样本长期主存);(c)legacy JSON/JSONL 文件(主要保留给迁移回滚、导出比对与显式回退输入)。当前设计仍支持 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`,但对线上部署与离线训练/回填而言,推荐目标状态都已经是 `sqlite` (a)SQLite 运行态存储(当前线上与核心离线链路主路径);(b)SQLite 永久真值/训练特征表(当前监督真值与训练样本长期主存);(c)legacy JSON/JSONL 文件(主要保留给迁移回滚、导出比对与显式回退输入)。当前设计仍支持 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`,但对线上部署与离线训练/回填而言,推荐目标状态都已经是 `sqlite`
### 测试、CI/CD 与运维验证 ### 测试、CI/CD 与运维验证
@@ -148,7 +148,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
### 优势 ### 优势
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),新增首次发言欢迎奖励与每日首条消息奖励,周奖励降低赢家积分差距并增加全员参与奖 **产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),积分获取切换为有效付费邀请奖励;Telegram 群发言不再发放积分
**复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。前端城市决策卡在此基础上补足“机场报文解释 + 市场桶动作口径”,让用户从地图点击可以直接进入可解释决策。 **复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。前端城市决策卡在此基础上补足“机场报文解释 + 市场桶动作口径”,让用户从地图点击可以直接进入可解释决策。
**面向外部 API 的工程防护意识较强**Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。 **面向外部 API 的工程防护意识较强**Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。
**支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。 **支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。
@@ -159,7 +159,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
**CI 已建立,但组织级质量门禁未必完全收口**:CI 现已覆盖 Python、前端与 Docker build。当前问题不再是“缺 CI”,而是是否把这些 status check 绑定到 `main` 保护策略,以及是否逐步引入更严格的 pre-merge 审查。 **CI 已建立,但组织级质量门禁未必完全收口**:CI 现已覆盖 Python、前端与 Docker build。当前问题不再是“缺 CI”,而是是否把这些 status check 绑定到 `main` 保护策略,以及是否逐步引入更严格的 pre-merge 审查。
**运行态状态/缓存与核心离线链路的 SQLite 收口已完成**`daily_records``telegram_alert_state``probability_training_snapshots``open_meteo` 缓存已经支持并在生产中主读 SQLite,迁移/校验脚本可用;进一步地,在临时移除 `data/*.json` / `data/*.jsonl` 后,训练集导出、概率拟合、评估报告、shadow report 和关键 backfill 脚本已验证仍可运行。当前 legacy 文件路径主要是显式回退入口,而不再是默认主输入。 **运行态状态/缓存与核心离线链路的 SQLite 收口已完成**`daily_records``telegram_alert_state``probability_training_snapshots``open_meteo` 缓存已经支持并在生产中主读 SQLite,迁移/校验脚本可用;进一步地,在临时移除 `data/*.json` / `data/*.jsonl` 后,训练集导出、概率拟合、评估报告、shadow report 和关键 backfill 脚本已验证仍可运行。当前 legacy 文件路径主要是显式回退入口,而不再是默认主输入。
**第三方服务合规与稳定性风险** **第三方服务合规与稳定性风险**
项目强依赖外部 APIOpen-Meteo、AviationWeather、global.amo.go.kr AMOS、NWS、HKO、CWA、Supabase)以及城市 AI providerOpenAI-compatible stream,当前使用 MiMo)。其中 AviationWeather Data API 有明确速率限制;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市 AI 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。 项目强依赖外部 APIOpen-Meteo、AviationWeather、global.amo.go.kr AMOS、NWS、HKO、CWA、Supabase)以及城市结构化分析 providerOpenAI-compatible stream,当前使用 MiMo)。其中 AviationWeather Data API 有明确速率限制;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市结构化分析 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
> **v1.7.0 更新**PolymarketGamma/CLOBAPI 依赖已随市场价格拉取层一并移除。 > **v1.7.0 更新**PolymarketGamma/CLOBAPI 依赖已随市场价格拉取层一并移除。
**许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部神经天气模型,仍需单独核验第三方代码与权重的商用条件:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。 **许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部神经天气模型,仍需单独核验第三方代码与权重的商用条件:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
@@ -181,11 +181,11 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
**对标结论**PolyWeather 与这类“全球神经天气模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。 **对标结论**PolyWeather 与这类“全球神经天气模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。
## 优先级改进建议 ## 优先级改进建议
下表按截至 `2026-05-23` 的真实状态重排优先级。已完成项不再继续列为”待做”,只保留当前仍需推进的事项。 下表按截至 `2026-05-28` 的真实状态重排优先级。已完成项不再继续列为”待做”,只保留当前仍需推进的事项。
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) | | 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 中 | **把最小外部监控继续补深**:从“可告警”提升到“可运营” | 3–7 天 | 不再只知道服务坏没坏,还能看资源趋势、来源 SLA 和支付波动 | 指标过多会带来维护噪音 | 1) 增加节点 CPU/内存/磁盘 → 2) 增加 SQLite/支付体积与事件趋势 → 3) 把 HTTP/来源指标细分到城市/来源维度 → 4) 增加日报或异常摘要 | | 中 | **把最小外部监控继续补深**:从“可告警”提升到“可运营” | 3–7 天 | 不再只知道服务坏没坏,还能看资源趋势、来源 SLA 和支付波动 | 指标过多会带来维护噪音 | 1) 增加节点 CPU/内存/磁盘 → 2) 增加 SQLite/支付体积与事件趋势 → 3) 把 HTTP/来源指标细分到城市/来源维度 → 4) 增加日报或异常摘要 |
| 中 | **城市决策卡 AI 解读可观测性与回放测试** | 3–5 天 | 降低第三/第四/第五城市 AI 解读失败,验证缓存与队列是否真正生效 | 外部 AI stream 仍可能超时或输出截断,若无指标很难复盘 | 1) 记录 city-ai stream status/duration/retry/degraded/cache-hit/queue-depth → 2) 增加固定 METAR + detail + all_buckets fixture → 3) 回归断言 bucket 匹配、模型-市场差、温度单位与缓存 key → 4) 将生产 env 建议同步进部署文档 | | 中 | **城市决策卡 结构化解读可观测性与回放测试** | 3–5 天 | 降低第三/第四/第五城市结构化分析 解读失败,验证缓存与队列是否真正生效 | 外部 structured detail 仍可能超时或输出截断,若无指标很难复盘 | 1) 记录 city-ai stream status/duration/retry/degraded/cache-hit/queue-depth → 2) 增加固定 METAR + detail + all_buckets fixture → 3) 回归断言 bucket 匹配、模型-市场差、温度单位与缓存 key → 4) 将生产 env 建议同步进部署文档 |
| - | ~~市场层升级为 async + 类型安全~~ | N/A | *[v1.7.0 已移除]* 市场价格拉取层已删除,此改进项不再适用 | - | - | | - | ~~市场层升级为 async + 类型安全~~ | N/A | *[v1.7.0 已移除]* 市场价格拉取层已删除,此改进项不再适用 | - | - |
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 1–2 周 | 在已完成的链下审计与容灾之上,进一步收紧链上授权边界 | 合约升级需要重新部署、迁移配置并再次验证 | 1) 维持现有事件重放、SQLite 审计、多 RPC fallback → 2) 升级合约到 SafeERC20 + Pausable → 3) 评估链上 plan/amount/token 绑定或 EIP-712 签名校验 → 4) 迁移后更新 PolygonScan 验证与支付审计文档 | | 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 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) 明确热修例外流程 | | 中 | **将 CI 与分支保护/发布流程真正绑定** | 1–3 天 | 让现有 CI 从“存在”变成“强制门禁” | 历史分支/热修流程可能受影响 | 1) GitHub `main` 开启 required checks → 2) 把 release/tag 流程绑定 CI → 3) 明确热修例外流程 |
@@ -194,7 +194,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
### 文档、测试与贡献流程的具体补强建议(落到仓库层面) ### 文档、测试与贡献流程的具体补强建议(落到仓库层面)
1)**文档体系**:保留现有中文 API/TechDebt 文档的同时,增加三份“高价值”文档: 1)**文档体系**:保留现有中文 API/TechDebt 文档的同时,增加三份“高价值”文档:
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级,并明确城市 AI 推荐配置(`POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30``POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900``POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`);(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、城市 AI stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。 (a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级,并明确城市结构化分析 推荐配置(`POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=120``POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=8``POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=300`);(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、城市结构化分析 stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。
2**测试金字塔**:在现有 `trend_engine` 单测基础上,补齐: 2**测试金字塔**:在现有 `trend_engine` 单测基础上,补齐:
(a)天气 provider 的“录制回放”测试(VCR 思路:固定响应→确保解析稳定);(b)市场层的契约测试(Gamma/CLOB schema 变更时提前失败);(c)城市决策卡 fixture 测试(固定 `detail/market_scan/all_buckets/METAR` → 断言 bucket mapping、模型-市场差、温度单位、AI 缓存 key 与排队提示);(d)支付链路的本地链集成测试(Hardhat/Anvil + 事件扫描回放)。这些测试能把“外部依赖漂移”尽量转成可控的回归失败。 (a)天气 provider 的“录制回放”测试(VCR 思路:固定响应→确保解析稳定);(b)市场层的契约测试(Gamma/CLOB schema 变更时提前失败);(c)城市决策卡 fixture 测试(固定 `detail/market_scan/all_buckets/METAR` → 断言 bucket mapping、模型-市场差、温度单位、AI 缓存 key 与排队提示);(d)支付链路的本地链集成测试(Hardhat/Anvil + 事件扫描回放)。这些测试能把“外部依赖漂移”尽量转成可控的回归失败。
3**贡献工作流**:引入 `CONTRIBUTING.md`(分支策略、PR 模板、变更日志、版本号策略)、`CODEOWNERS`(核心模块审查人)、`SECURITY.md`(漏洞披露与密钥处理),并把静态检查(ruff/eslint)作为 pre-commit + CI 必过项。 3**贡献工作流**:引入 `CONTRIBUTING.md`(分支策略、PR 模板、变更日志、版本号策略)、`CODEOWNERS`(核心模块审查人)、`SECURITY.md`(漏洞披露与密钥处理),并把静态检查(ruff/eslint)作为 pre-commit + CI 必过项。
@@ -243,13 +243,13 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
| 时间窗 | 里程碑 | 交付物 | 资源/备注 | | 时间窗 | 里程碑 | 交付物 | 资源/备注 |
| ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| 第 1 周 | 城市决策卡稳定性补强 | city-ai stream/cache/queue 指标;固定 METAR + `all_buckets` fixture;温度桶匹配与模型-市场差回归测试;生产 env 文档化 | 前端为主,后端补指标 | | 第 1 周 | 城市决策卡稳定性补强 | city-ai stream/cache/queue 指标;固定 METAR + `all_buckets` fixture;温度桶匹配与模型-市场差回归测试;生产 env 文档化 | 前端为主,后端补指标 |
| 第 2 周 | 市场层与 Scan Terminal 数据回放 | 保存 `bucket_label/bucket_direction/model_market_diff/matching_reason`;支持回放第三/第四/第五城市 AI 解读失败案例 | 用真实失败样本压回归 | | 第 2 周 | 市场层与 Scan Terminal 数据回放 | 保存 `bucket_label/bucket_direction/model_market_diff/matching_reason`;支持回放第三/第四/第五城市结构化分析 解读失败案例 | 用真实失败样本压回归 |
| 第 4–5 周 | 监控深挖与运维日报 | 来源 SLA、城市维度延迟、AI stream 状态、SQLite 体积、支付事件趋势、异常摘要 | 避免指标过多,先覆盖高频故障 | | 第 4–5 周 | 监控深挖与运维日报 | 来源 SLA、城市维度延迟、structured detail 状态、SQLite 体积、支付事件趋势、异常摘要 | 避免指标过多,先覆盖高频故障 |
| 第 6 周 | 支付合约与发布门禁升级 | SafeERC20/Pausable 方案评审;CI required checks 与 release/tag 流程绑定;热修例外流程 | 合约升级需单独部署验证 | | 第 6 周 | 支付合约与发布门禁升级 | SafeERC20/Pausable 方案评审;CI required checks 与 release/tag 流程绑定;热修例外流程 | 合约升级需单独部署验证 |
### 主要风险与缓解策略 ### 主要风险与缓解策略
**外部 API / AI provider 速率限制与格式变更**AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化;OpenAI-compatible city AI stream 可能出现 timeout、stream JSON 截断或并发竞争。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用);城市 AI 保持小并发队列、30s timeout、stream parse retry、页面内存缓存与 degraded fallback。 **外部 API / AI provider 速率限制与格式变更**AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化;OpenAI-compatible city structured detail 可能出现 timeout、stream JSON 截断或并发竞争。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用);城市结构化分析 保持小并发队列、30s timeout、stream parse retry、页面内存缓存与 degraded fallback。
**密钥泄露与权限滥用**Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。 **密钥泄露与权限滥用**Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。
**支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。当前项目已经补齐“事件监听 + 确认补单”双路径、事件重放脚本、SQLite 审计事件与多 RPC fallback;现阶段的主要剩余风险不再是“没有防护”,而是链上合约仍为最小实现,owner 为单地址管理,且没有 pause 开关与 SafeERC20。 **支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。当前项目已经补齐“事件监听 + 确认补单”双路径、事件重放脚本、SQLite 审计事件与多 RPC fallback;现阶段的主要剩余风险不再是“没有防护”,而是链上合约仍为最小实现,owner 为单地址管理,且没有 pause 开关与 SafeERC20。
**引入外部神经天气模型的商业合规风险**GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。 **引入外部神经天气模型的商业合规风险**GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
@@ -270,5 +270,3 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
- Pangu-Weather(权重 BY-NC-SA,禁商用):https://github.com/198808xc/Pangu-Weather - Pangu-Weather(权重 BY-NC-SA,禁商用):https://github.com/198808xc/Pangu-Weather
- Polymarket 官方 Python CLOB SDKMIT):https://github.com/Polymarket/py-clob-client - Polymarket 官方 Python CLOB SDKMIT):https://github.com/Polymarket/py-clob-client
- aiopolymarketasync、类型安全):https://github.com/the-odds-company/aiopolymarket - aiopolymarketasync、类型安全):https://github.com/the-odds-company/aiopolymarket
+2 -2
View File
@@ -134,7 +134,7 @@
2. **Tab 下划线指示器不够明显**:`2px` 高度 + `opacity: 0.8` 的蓝色下划线容易被忽略。 2. **Tab 下划线指示器不够明显**:`2px` 高度 + `opacity: 0.8` 的蓝色下划线容易被忽略。
3. **按钮层级不够清晰**`.scan-primary-button`(蓝紫渐变)、`.scan-ai-button`(青绿渐变)、`.scan-ai-city-icon-button`(蓝色边框半透明)、`.scan-theme-button`(无边框无背景)四种视觉权重混在一起,用户难以判断优先级。 3. **按钮层级不够清晰**`.scan-primary-button`(蓝紫渐变)、`.scan-ai-button`(青绿渐变)、`.scan-city-icon-button`(蓝色边框半透明)、`.scan-theme-button`(无边框无背景)四种视觉权重混在一起,用户难以判断优先级。
4. **空状态/加载状态设计不一致**: 4. **空状态/加载状态设计不一致**:
- 地图加载有精美的云/雷达/热力动画 - 地图加载有精美的云/雷达/热力动画
@@ -233,7 +233,7 @@
- Tab 切换缺少 `role="tablist"`/`role="tab"`/`aria-selected` - Tab 切换缺少 `role="tablist"`/`role="tab"`/`aria-selected`
- 折叠按钮缺少 `aria-expanded` - 折叠按钮缺少 `aria-expanded`
2. **焦点指示器不可见**:自定义按钮(如 `scan-theme-button`、`scan-ai-city-icon-button`)没有 focus-visible 样式 2. **焦点指示器不可见**:自定义按钮(如 `scan-theme-button`、`scan-city-icon-button`)没有 focus-visible 样式
3. **颜色不是唯一的信息传达方式**:风险等级、Market decision 的色彩编码缺少对应的文字标签或图标补充 3. **颜色不是唯一的信息传达方式**:风险等级、Market decision 的色彩编码缺少对应的文字标签或图标补充
Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.
+30 -10
View File
@@ -1,6 +1,6 @@
# PolyWeather 支付审计与防护说明 # PolyWeather 支付审计与防护说明
最后更新:`2026-03-21` 最后更新:`2026-05-29`
## 1. 当前已落地的防护 ## 1. 当前已落地的防护
@@ -19,9 +19,9 @@
### 事件确认边界 ### 事件确认边界
- 后端只认链上 `OrderPaid` 事件 - 后端只认链上可验证事件:checkout 合约路径认 `OrderPaid`,直转路径认对应链上 USDC `Transfer`
- 前端提交 intent 不会直接视为支付完成。 - 前端提交 intent 不会直接视为支付完成。
- `confirm_loop` 会再次按链上交易与确认数校验 intent - `confirm_loop` 会再次按 intent 的 `chain_id``token_address`、收款地址、金额与确认数校验链上交易。
- 若确认失败,当前会明确把 intent / transaction 落为失败态,而不是长期停留在 `submitted` - 若确认失败,当前会明确把 intent / transaction 落为失败态,而不是长期停留在 `submitted`
当前已显式识别的失败原因包括: 当前已显式识别的失败原因包括:
@@ -29,19 +29,23 @@
- `receiver_mismatch` - `receiver_mismatch`
- `sender_mismatch` - `sender_mismatch`
- `event_mismatch` - `event_mismatch`
- `token_mismatch`
- `tx_reverted` - `tx_reverted`
### RPC 多节点容灾 ### RPC 多节点容灾
- 支持 `POLYWEATHER_PAYMENT_RPC_URLS` - 支持默认链 `POLYWEATHER_PAYMENT_RPC_URLS`
- 支持多链 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- 格式示例: - 格式示例:
```env ```env
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com","https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
``` ```
- 启动时按顺序探活。 - 启动时按顺序探活。
- 当前节点断连或收据查询失败时,会自动切换到下一个可用 RPC。 - 当前节点断连或收据查询失败时,会自动切换到下一个可用 RPC。
- 多链支付确认不会用默认链硬查交易;每笔 intent 会按自己的 `chain_id` 选择 RPC。
### 事件重放 ### 事件重放
@@ -87,8 +91,24 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
- 已付款但未开通 - 已付款但未开通
- 打到旧收款地址 - 打到旧收款地址
- 交易事件不匹配 - 交易事件不匹配
- 用户在钱包默认 Ethereum 网络付款,但旧系统只按 Polygon intent 查账
## 2. 当前合约的授权边界 ## 2. 当前支付链路边界
当前支持两类链路:
1. Polygon checkout 合约
- 前端钱包支付会先切到 Polygon。
- 后端创建 intent 后给出合约 `tx_payload`
- 确认时校验 `OrderPaid(orderId, payer, planId, amount, token)`
2. Ethereum 主网 USDC 直转
- 前端展示 Ethereum 网络、USDC 合约、收款钱包和金额。
- 用户提交 tx hash 后,后端按 `intent.chain_id=1` 查询 Ethereum RPC。
- 确认时校验 USDC `Transfer(from, to, amount)``to``token_address` 和金额。
- 这条链路不依赖 Polygon checkout 合约,适合处理“用户钱包默认网络付款”的真实行为。
## 3. 当前合约的授权边界
合约源码: 合约源码:
- [PolyWeatherCheckout.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckout.sol) - [PolyWeatherCheckout.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckout.sol)
@@ -110,7 +130,7 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
4. 订单边界 4. 订单边界
- 同一个 `orderId` 只能成功支付一次 - 同一个 `orderId` 只能成功支付一次
## 3. 重入与重复支付判断 ## 4. 重入与重复支付判断
当前合约的 `pay` 逻辑顺序是: 当前合约的 `pay` 逻辑顺序是:
@@ -136,7 +156,7 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
- **最小可用支付合约** - **最小可用支付合约**
- 不是“全功能强防护合约” - 不是“全功能强防护合约”
## 4. 当前静态审计结论 ## 5. 当前静态审计结论
已提供脚本: 已提供脚本:
- [check_payment_contract_security.py](/E:/web/PolyWeather/scripts/check_payment_contract_security.py) - [check_payment_contract_security.py](/E:/web/PolyWeather/scripts/check_payment_contract_security.py)
@@ -160,7 +180,7 @@ python scripts/check_payment_contract_security.py
- 是否使用 SafeERC20 - 是否使用 SafeERC20
- 是否在链上绑定套餐价格 - 是否在链上绑定套餐价格
## 5. 当前主要剩余风险 ## 6. 当前主要剩余风险
1. 单地址 owner 1. 单地址 owner
- 建议把 `owner` 迁移到多签钱包 - 建议把 `owner` 迁移到多签钱包
@@ -175,7 +195,7 @@ python scripts/check_payment_contract_security.py
- 当前使用 `IERC20.transferFrom` - 当前使用 `IERC20.transferFrom`
- 升级版合约更建议改为 OpenZeppelin `SafeERC20` - 升级版合约更建议改为 OpenZeppelin `SafeERC20`
## 6. 推荐操作 ## 7. 推荐操作
### 每次支付配置变更后 ### 每次支付配置变更后
@@ -233,7 +253,7 @@ docker compose exec polyweather_web python scripts/reconcile_subscription_by_ema
- 用户声称已付费但未开通 - 用户声称已付费但未开通
- 需要快速确认最近一笔 intent 是否能自动恢复 - 需要快速确认最近一笔 intent 是否能自动恢复
## 7. 下一版合约建议 ## 8. 下一版合约建议
如果后续升级合约,优先级建议: 如果后续升级合约,优先级建议:
+1 -1
View File
@@ -1,4 +1,4 @@
# PolyWeatherCheckout PolygonScan 验证(v1.8.0 # PolyWeatherCheckout PolygonScan 验证(v1.8.1
最后更新:`2026-03-20` 最后更新:`2026-03-20`
+8 -9
View File
@@ -1,10 +1,10 @@
# PolyWeather 前端产品审查报告 # PolyWeather 前端产品审查报告
> 审查日期:2026-06 | 视角:产品经理 | 范围:`frontend/` 全部页面、组件、用户流程 > 审查日期:2026-06 | 视角:产品经理 | 范围:`frontend/` 全部页面、组件、用户流程 | 最后校准:2026-05-28
## 一、产品概览 ## 一、产品概览
PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核心价值主张:**结合多模型气象预报 + AI 机场报文解读 + Polymarket 市场价格,为交易决策提供一站式证据链。** PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核心价值主张:**结合多模型气象预报 + 结构化实况层 + Polymarket 市场价格,为交易决策提供一站式证据链。**
### 产品分层 ### 产品分层
@@ -12,13 +12,13 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|------|------|------| |------|------|------|
| 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 | | 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 |
| Pro 试用 | 3 天全功能 | 注册后自动获得 | | Pro 试用 | 3 天全功能 | 注册后自动获得 |
| Pro 订阅 | 城市决策卡(AI 机场报文 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 10 USDC/月(积分抵扣最多 3 USDC | | Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 月付 29.9 USDC / 30 天,季度 79.9 USDC / 90 天(月付最多抵 3 USDC,季度最多抵 8 USDC |
### 页面结构(9 个路由) ### 页面结构(9 个路由)
| 路由 | 功能 | 是否必需登录 | | 路由 | 功能 | 是否必需登录 |
|------|------|-------------| |------|------|-------------|
| `/` | 主看板 — AI 天气决策台 | 否 | | `/` | 主看板 — 实时天气决策台 | 否 |
| `/account` | 账户中心 — 身份/订阅/钱包/积分/Bot 绑定 | 是 | | `/account` | 账户中心 — 身份/订阅/钱包/积分/Bot 绑定 | 是 |
| `/auth/login` | 登录页(Google OAuth + 邮箱密码) | 否 | | `/auth/login` | 登录页(Google OAuth + 邮箱密码) | 否 |
| `/docs/[...slug]` | 产品文档中心(8 篇双语文档) | 否 | | `/docs/[...slug]` | 产品文档中心(8 篇双语文档) | 否 |
@@ -37,7 +37,7 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
│ 点击城市 → 自动添加到决策卡工作区 + 切换到卡片视图 │ │ 点击城市 → 自动添加到决策卡工作区 + 切换到卡片视图 │
│ 免费用户和 Pro 用户均可使用 │ │ 免费用户和 Pro 用户均可使用 │
├─ 决策卡(分析) ──────────────────────────────┤ ├─ 决策卡(分析) ──────────────────────────────┤
│ 钉选的城市卡片:AI 机场解读 + 模型集群 + 市场层 + 图表 │ 钉选的城市卡片:结构化实况 + 模型集群 + 市场层 + 实时图表 │
│ 需要 Pro 订阅 │ │ 需要 Pro 订阅 │
└──────────────────────────────────────────────┘ └──────────────────────────────────────────────┘
└── 右侧栏:城市简报面板(始终可见,免费可用) └── 右侧栏:城市简报面板(始终可见,免费可用)
@@ -46,14 +46,14 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
### 关键用户路径 ### 关键用户路径
1. **新用户落地** → 看到 3 步引导 → 地图 + 城市列表 → 点击城市 → 看到城市简报 → 想深入分析 → 遇到 Pro 付费墙(含功能说明) 1. **新用户落地** → 看到 3 步引导 → 地图 + 城市列表 → 点击城市 → 看到城市简报 → 想深入分析 → 遇到 Pro 付费墙(含功能说明)
2. **Pro 用户工作流** → 地图选城市 → 自动钉选到决策卡 → 展开卡片 → 阅读 AI 报文解读 → 查看市场层 → 判断交易方向 2. **Pro 用户工作流** → 地图选城市 → 自动钉选到决策卡 → 展开卡片 → 查看实测/DEB/概率/市场层 → 判断交易方向
## 三、做得好的地方 ## 三、做得好的地方
1. **地图 → 决策卡的自动流转设计** — 点击地图城市自动钉选到分析工作区并切换视图,"零步骤发现" 1. **地图 → 决策卡的自动流转设计** — 点击地图城市自动钉选到分析工作区并切换视图,"零步骤发现"
2. **双语覆盖完整** — 所有 UI 文案、文档、AI 解读都有中英文对照,覆盖率接近 100% 2. **双语覆盖完整** — 所有 UI 文案、文档、结构化解读都有中英文对照,覆盖率接近 100%
3. **数据新鲜度可视化** — DataFreshnessBar 让用户一眼看到 METAR/模型/市场数据的新鲜度 3. **数据新鲜度可视化** — DataFreshnessBar 让用户一眼看到 METAR/模型/市场数据的新鲜度
4. **AI 解读的产品化程度高** — 分层展示:快速判断 → 完整解读 → 证据链 → 风险提示 4. **结构化解读的产品化程度高** — 分层展示:快速判断 → 完整解读 → 证据链 → 风险提示
5. **免费层有实际价值** — 地图 + 城市简报不是"空壳",用户可以看真实气象数据 5. **免费层有实际价值** — 地图 + 城市简报不是"空壳",用户可以看真实气象数据
6. **支付链路完整** — 从钱包绑定到链上签约到支付恢复,处理了多种异常情况 6. **支付链路完整** — 从钱包绑定到链上签约到支付恢复,处理了多种异常情况
7. **空状态有引导文字** — "Click a city on the map" 告诉用户下一步做什么 7. **空状态有引导文字** — "Click a city on the map" 告诉用户下一步做什么
@@ -67,4 +67,3 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
| 1 | **Docs 无搜索** — 8 篇文档没有搜索功能,用户必须逐篇浏览 | 🟢 待做 | | 1 | **Docs 无搜索** — 8 篇文档没有搜索功能,用户必须逐篇浏览 | 🟢 待做 |
| 2 | **Ops 面板无审计日志** — 管理员补发积分等操作没有审计记录 | 🟢 需后端 | | 2 | **Ops 面板无审计日志** — 管理员补发积分等操作没有审计记录 | 🟢 需后端 |
| 3 | **注册后邮件验证引导** — 未验证邮箱的用户反复登录失败 | 🟡 需后端 | | 3 | **注册后邮件验证引导** — 未验证邮箱的用户反复登录失败 | 🟡 需后端 |
@@ -0,0 +1,107 @@
# Redis Stream Realtime Event Architecture Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add Redis Stream as the production realtime event store and live fanout source while preserving the existing SSE patch protocol.
**Implementation status (2026-05-28):** completed in `v1.8.1`. Production uses Redis Stream when `POLYWEATHER_EVENT_STORE=redis`; SQLite remains the local/fallback event log. Browser API and frontend contract stayed on `/api/events` + `city_observation_patch.v1`.
**Architecture:** Keep `city_observation_patch.v1` and numeric `revision` as the browser contract. Add a Redis-backed event store beside the existing SQLite store, select it through an event-store factory, and let Redis-backed deployments fan out live events through a Redis subscriber loop instead of direct in-process ingest broadcast.
**Tech Stack:** FastAPI, redis-py, Redis Stream, SQLite fallback, pytest, existing frontend EventSource hook.
---
### Task 1: Redis Store Contract
**Files:**
- Create: `tests/test_redis_realtime_event_store.py`
- Create: `web/redis_realtime_event_store.py`
- [x] **Step 1: Write failing tests**
Cover append, replay by city, replay gap, and fallback-independent event shape.
- [x] **Step 2: Verify tests fail**
Run: `python -m pytest tests/test_redis_realtime_event_store.py -q`
Expected: fails because `web.redis_realtime_event_store` does not exist.
- [x] **Step 3: Implement Redis store**
Add `RedisRealtimeEventStore` with `append_event`, `latest_revision`, `replay_events`, `replay_requires_resync`, and idempotent `start_live_subscription`.
- [x] **Step 4: Verify tests pass**
Run: `python -m pytest tests/test_redis_realtime_event_store.py -q`
### Task 2: Store Factory And SSE Router
**Files:**
- Create: `web/realtime_event_store_factory.py`
- Modify: `web/routers/sse_router.py`
- Test: `tests/test_sse_replay.py`
- [x] **Step 1: Write failing tests**
Cover `POLYWEATHER_EVENT_STORE=redis` selecting Redis, SQLite fallback when Redis is not configured, and Redis ingest not doing direct local broadcast.
- [x] **Step 2: Verify tests fail**
Run: `python -m pytest tests/test_sse_replay.py tests/test_realtime_event_store_factory.py -q`
- [x] **Step 3: Implement factory and router integration**
Use Redis store when configured; otherwise keep existing SQLite store. Ingest broadcasts directly only for stores that do not provide external live fanout.
- [x] **Step 4: Verify tests pass**
Run: `python -m pytest tests/test_sse_replay.py tests/test_realtime_event_store_factory.py -q`
### Task 3: Dependency And Operational Config
**Files:**
- Modify: `requirements.txt`
- Modify: `docs/superpowers/specs/2026-05-27-redis-stream-realtime-event-architecture-design.md`
- [x] **Step 1: Add redis-py dependency**
Add `redis>=5.0.0` to `requirements.txt`.
- [x] **Step 2: Add final config notes**
Ensure the design doc names the runtime env vars used by code.
### Task 4: Verification
**Files:**
- No production edits unless tests reveal a gap.
- [x] **Step 1: Run backend realtime tests**
Run:
```powershell
python -m pytest tests/test_realtime_patch_schema.py tests/test_realtime_event_store.py tests/test_redis_realtime_event_store.py tests/test_realtime_event_store_factory.py tests/test_sse_replay.py -q
```
- [x] **Step 2: Run broader frontend/backend checks**
Run:
```powershell
cd frontend
npm run test:business
npm run typecheck
npm run build
```
- [x] **Step 3: Inspect diff**
Run:
```powershell
git diff --check
git status --short
```
@@ -0,0 +1,408 @@
# Redis Stream Realtime Event Architecture Design
> 日期: 2026-05-27
> 范围: PolyWeather 网站图表实时观测事件层
> 目标服务器: 2 vCPU / 8 GB RAM / 50 GB 系统盘
> 实施状态: 2026-05-28 已落地为 `v1.8.1`
## 背景
当前实时层已经具备生产化雏形:
- 后端已有 `city_observation_patch.v1` schema。
- `web/realtime_event_store.py` 使用 SQLite 保存 `observation_patch_events`,支持 `since_revision` replay。
- `/api/events?cities=...&since_revision=...&replay_limit=...` 已经是前端 SSE 入口。
- 前端 `frontend/hooks/use-sse-patches.ts` 使用 `EventSource`,并按当前可见城市订阅。
这解决了单进程、单实例下的实时推送和短窗口 replay,但还不够“一步到位”:
- 多 worker / 多实例时,进程内广播不能共享。
- SQLite replay log 可以补数据,但不适合作为跨实例 live fanout。
- 服务重启、浏览器后台恢复、SSE 断线后,需要更稳定的事件源。
- 后续如果把采集器拆成独立进程,需要一个明确的后端事件总线。
## 目标
升级为“Redis Stream 主事件日志 + SSE 浏览器传输 + SQLite fallback”的架构:
- Redis Stream 是生产环境实时事件主通道。
- SSE 仍然是浏览器唯一实时入口。
- 前端协议保持 `city_observation_patch.v1`,不让浏览器感知 Redis。
- 继续使用 numeric `revision`,兼容现有前端 `since_revision` 逻辑。
- 支持按城市订阅、断线 replay、多 worker fanout。
- 事件只保留短窗口;当前默认按 Redis Stream `MAXLEN` 控制,建议以约 24 小时 replay 为目标,不做 72 小时保留。
## 非目标
这次不做以下事情:
- 不引入 Kafka、RabbitMQ 或复杂 consumer group。
- 不把天气历史数据全部 event sourcing。
- 不让浏览器直接连接 Redis。
- 不把 DEB 预测、模型曲线、概率分布都改成事件流。
- 不做永久行情归档;超过 replay 窗口时继续走 HTTP full detail resync。
## 推荐架构
```mermaid
flowchart LR
Collector["WeatherDataCollector / city refresh"] --> Ingest["POST /api/internal/collector-patch"]
Ingest --> Normalize["city_observation_patch.v1 normalizer"]
Normalize --> Store["RealtimeEventStore interface"]
Store --> Redis["Redis Stream: stream:city_observation"]
Store --> SQLite["SQLite fallback: observation_patch_events"]
Redis --> WorkerSub["Redis XREAD subscriber per API worker"]
WorkerSub --> LocalFanout["Local SSE fanout"]
SQLite --> ReplayFallback["SQLite replay fallback"]
SSE["GET /api/events?cities=...&since_revision=..."] --> Browser["Frontend EventSource"]
LocalFanout --> SSE
Redis --> SSE
ReplayFallback --> SSE
Browser --> Chart["Chart applies patch"]
Browser -->|resync_required| Detail["HTTP /api/city/{city}/detail"]
```
关键原则:
- Redis Stream 负责后端事件保存、短窗口 replay、多实例共享。
- SSE 负责服务端到浏览器的单向推送。
- SQLite 只做 fallback 和本地开发,不再作为生产主事件总线。
- 前端只认 patch event,不关心事件来自 Redis 还是 SQLite。
## 事件模型
继续使用现有 schema
```json
{
"type": "city_observation_patch.v1",
"revision": 12345,
"city": "taipei",
"source": "cwa",
"obs_time": "2026-05-27T10:00:00+08:00",
"observed_at_utc": "2026-05-27T02:00:00Z",
"observed_at_local": "2026-05-27T10:00:00+08:00",
"city_local_date": "2026-05-27",
"city_timezone": "Asia/Taipei",
"source_cadence_sec": 600,
"ts": 1780000000000,
"payload": {
"temp": 34.2,
"max_so_far": 34.2,
"station_code": "466920",
"station_label": "中央气象署台北站",
"series_key": "settlement",
"unit": "celsius"
}
}
```
`revision` 仍然是前端公开排序键,必须是全局单调递增整数。
Redis Stream 自身的 stream ID,例如 `1780000000000-0`,只作为后端内部定位信息,不暴露给前端作为主 revision。
## Redis Stream 设计
Stream key:
```text
stream:city_observation
```
Revision key:
```text
counter:city_observation_revision
```
每条 stream entry 字段:
- `revision`: 全局递增整数,公开给 SSE/前端。
- `type`: `city_observation_patch.v1`
- `city`: normalized city key。
- `source`: source code,例如 `cwa`, `metar`, `amos`, `amsc_awos`
- `obs_time`: 原始观测时间。
- `payload_json`: compact JSON。
- `created_at_ms`: 写入 Redis 的 UTC epoch ms。
- `producer_id`: API worker / collector instance id,便于排查。
写入必须通过 Lua 脚本原子完成:
1. `INCR counter:city_observation_revision`
2. `XADD stream:city_observation MAXLEN ~ {maxlen} * revision ... payload_json ...`
3. 返回 `{revision, stream_id}`
原因:
- 避免 `INCR` 成功但 `XADD` 失败后造成非必要 gap。
- 让 revision 分配和事件入流成为一个原子操作。
- 保持现有前端 numeric `since_revision` 不变。
## Replay 策略
`GET /api/events` 收到 `since_revision` 后:
1. 读取 Redis Stream 最旧 entry,拿到 oldest revision。
2. 如果 `since_revision > 0``since_revision < oldest_revision - 1`,发送 `resync_required`
3. 从 Redis Stream 顺序扫描事件。
4. 过滤 `revision > since_revision`
5. 过滤 `city IN subscribed_cities`
6. 返回最多 `replay_limit + 1` 条。
7. 如果超过 `replay_limit`,发送 `resync_required`,让前端 HTTP resync。
当前事件量很小,单个 stream 顺序扫描 24 小时事件是可以接受的:
- 30 城市 * 1 条/分钟 * 24 小时 = 43,200 条。
- replay 通常只发生在断线重连,不是高频查询。
如果后续城市量扩大,或者确实需要超过 24 小时的 replay,再加 per-city stream 或 Redis sorted index。
## Live Fanout
每个 API worker 启动一个 Redis subscriber loop
```text
XREAD BLOCK 5000 STREAMS stream:city_observation {last_seen_stream_id}
```
读取到事件后:
1. 解析 `city_observation_patch.v1`
2. 按本进程 SSE 连接的 `cities` subscription 过滤。
3. 推送给本进程匹配的浏览器连接。
4. 更新 worker 内存中的 `last_seen_stream_id`
ingest endpoint 写入 Redis 后不直接广播本地连接,由 subscriber loop 统一 fanout。这样避免同一个 worker 上“写入后本地广播一次、subscriber 又广播一次”的重复事件。
前端仍然用 revision 去重。即使极端情况下收到重复 revision,也应忽略旧 revision。
## SQLite Fallback
保留当前 SQLite store,但角色调整为:
- 本地开发无需 Redis 时使用。
- Redis 不可用且允许降级时使用。
- 单实例临时运行时可用。
新增环境变量:
```text
POLYWEATHER_EVENT_STORE=redis
POLYWEATHER_EVENT_STORE_FALLBACK=sqlite
POLYWEATHER_REDIS_URL=redis://127.0.0.1:6379/0
POLYWEATHER_REDIS_STREAM_KEY=stream:city_observation
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
POLYWEATHER_PATCH_EVENT_RETENTION_HOURS=24
POLYWEATHER_REDIS_REQUIRED=true
```
推荐生产策略:
- `POLYWEATHER_EVENT_STORE=redis`
- `POLYWEATHER_REDIS_REQUIRED=true`
- Redis 写入失败时 ingest 返回 503,不广播不可 replay 的事件。
推荐本地策略:
- 不配置 Redis,自动使用 SQLite。
-`POLYWEATHER_EVENT_STORE=sqlite`
## Redis 容量与配置
当前 VPS 可以跑本机 Redis。
建议:
```text
bind 127.0.0.1
protected-mode yes
appendonly yes
appendfsync everysec
maxmemory 512mb
maxmemory-policy noeviction
```
默认 `MAXLEN ~ 50000` 足够约 24 小时事件:
- 30 城市 * 1/min * 24h = 43,200 条。
- payload 约 1-3 KB,实际内存取决于 Redis Stream overhead。
- 512 MB Redis maxmemory 对当前事件量足够。
注意:这个事件流不是永久数据仓库。超过 24 小时窗口后,前端应 HTTP resync 当前城市 detail。
## 模块边界
新增/调整后端接口:
### `web/realtime_event_store.py`
定义统一接口:
```python
class RealtimeEventStore:
def append_event(self, event: dict) -> dict: ...
def replay_events(self, *, cities: set[str] | None, since_revision: int, limit: int) -> list[dict]: ...
def replay_requires_resync(self, *, cities: set[str] | None, since_revision: int, replay_count: int, limit: int) -> bool: ...
def latest_revision(self) -> int: ...
```
保留现有 SQLite 实现,新增 Redis 实现。
### `web/redis_realtime_event_store.py`
职责:
- Redis 连接与健康检查。
- Lua append script。
- Redis Stream replay。
- maxlen trim。
- entry 到 SSE event 的反序列化。
### `web/sse_manager.py`
职责:
- 管理本进程 SSE 连接。
- 记录每个连接订阅城市集合。
- 将 Redis subscriber 读到的 event fanout 给匹配连接。
### `web/routers/sse_router.py`
职责:
- `/api/internal/collector-patch` 规范化并写入 event store。
- `/api/events` 处理 connected、replay、heartbeat、live stream。
- 当 replay 不完整时发送 `resync_required`
### 前端
前端原则上不需要大改:
- `use-sse-patches.ts` 继续使用 `EventSource`
- `lastRevision` 仍然是 number。
- `resync_required` 继续触发当前可见城市 HTTP detail refresh。
- 继续按可见城市列表构建 `cities` 参数。
## 运行流程
### 首屏
1. 前端 HTTP 加载 terminal rows。
2. 可见图表 HTTP 加载 full detail。
3. 前端连接 `/api/events?cities=...&since_revision=...&replay_limit=500`
4. 后端先 replay Redis Stream 中缺失事件,再进入 live stream。
### 新观测
1. 采集器产生 `city_patch` 或 v1 patch。
2. ingest endpoint 规范化成 `city_observation_patch.v1`
3. Redis Lua script 写入 stream 并生成 revision。
4. 每个 API worker 的 subscriber loop 读到事件。
5. worker fanout 给订阅对应城市的 SSE 连接。
6. 前端 apply patch,图表无痛追加点。
### 浏览器后台恢复
1. EventSource 如果断线,前端按指数退避重连。
2. URL 带 `since_revision=lastRevision`
3. Redis replay 补齐后台期间错过的事件。
4. 如果 revision 太旧或 replay 超限,前端收到 `resync_required`HTTP 重拉 full detail。
## 错误处理
- Redis append 失败且 `POLYWEATHER_REDIS_REQUIRED=true`ingest 返回 503,不广播。
- Redis append 失败且允许 fallback:写入 SQLite,并在 health 状态标记 degraded。
- Redis replay 失败:SSE 发送 `resync_required`,然后继续尝试 live stream。
- Redis subscriber loop 断开:指数退避重连;期间 SSE heartbeat 仍可维持连接,但不会有 live patch。
- 前端收到 revision 倒退或重复:忽略。
- 前端收到未知 event type:忽略并记录 debug。
## 监控与诊断
新增健康指标:
- 当前 event store mode: `redis` / `sqlite` / `degraded_sqlite`
- Redis ping latency
- latest revision
- stream length
- oldest revision
- subscriber connected
- SSE active connection count
- dropped/resync_required count
建议暴露到现有 health endpoint 或日志:
```json
{
"realtime": {
"store": "redis",
"redis_connected": true,
"stream_len": 43210,
"latest_revision": 123456,
"oldest_revision": 80200,
"subscriber_connected": true,
"sse_connections": 9
}
}
```
## 测试策略
后端:
- Redis store append 会生成全局递增 numeric revision。
- Redis replay 支持 city 过滤。
- Redis replay 支持 `since_revision`
- Redis replay 超出 retention/limit 返回 resync required。
- Redis 不可用时,根据 env 严格失败或 fallback SQLite。
- SQLite store 现有测试保持通过。
- SSE router 在 Redis store 下仍发送 connected、replay、heartbeat。
前端:
- `use-sse-patches.ts` 保持 `revision` numeric。
- 重连 URL 保留 `since_revision`
- 收到 `resync_required` 会触发 visible city refresh。
- 重复 revision 不重复追加图表点。
验收命令:
```powershell
python -m pytest tests/test_realtime_patch_schema.py tests/test_realtime_event_store.py tests/test_sse_replay.py
python -m pytest tests/test_redis_realtime_event_store.py
cd frontend
npm run test:business
npm run typecheck
npm run build
```
## 迁移步骤
1. 保留现有 SQLite implementation,抽出 event store factory。
2. 新增 Redis implementation 和单元测试。
3. 新增 Redis subscriber loop,但先在本地开发环境跑。
4. `/api/events` 接入 event store factory。
5. 线上安装 Redis,只监听 `127.0.0.1`
6. 先用 `POLYWEATHER_EVENT_STORE=redis``POLYWEATHER_REDIS_REQUIRED=false` 灰度。
7. 观察 stream length、latest revision、SSE error、resync count。
8. 稳定后切换 `POLYWEATHER_REDIS_REQUIRED=true`
9. 保留 SQLite fallback 代码,但生产不主动降级,避免多实例状态分裂。
## 验收标准
- 断开浏览器网络 1-5 分钟后恢复,图表自动补齐期间 patch。
- 后台挂页面再回来,图表不需要等待 full loading 才更新当前点。
- 多 worker 下,任意 worker 写入的 patch 都能推到其他 worker 的 SSE 连接。
- `/api/events?cities=taipei,shanghai&since_revision=...` 只 replay 指定城市。
- Redis 重启后,前端收到 `resync_required` 并 HTTP 重建画面。
- 前端无需知道 Redis 存在。
- 现有 `city_observation_patch.v1` schema 不破坏。
## 结论
这是一种“一步到位但不过度设计”的方案:
- 一步到位的是事件契约、replay、跨实例 fanout 和生产部署边界。
- 不过度设计的是不用 Kafka、不做永久行情库、不重写前端协议。
对当前产品最关键的是:图表像股票行情一样无痛刷新,断线可补,后台恢复可追上,多实例以后不用再推倒实时层。
+3 -3
View File
@@ -17,8 +17,8 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名) # 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。 # 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
# 生产环境必须设为 https://polyweather-pro.vercel.app # 生产环境必须设为 https://polyweather.top
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app NEXT_PUBLIC_SITE_URL=https://polyweather.top
# 常用:前端鉴权开关 # 常用:前端鉴权开关
# true: 启用 Supabase 登录 # true: 启用 Supabase 登录
@@ -37,7 +37,7 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# 可选:钱包支付 / Telegram 入口 # 可选:钱包支付 / Telegram 入口
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
+3 -3
View File
@@ -18,7 +18,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名) # 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。 # 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
# 生产环境必须设为 https://polyweather-pro.vercel.app # 生产环境必须设为 https://polyweather.top
NEXT_PUBLIC_SITE_URL=https://polyweather.top NEXT_PUBLIC_SITE_URL=https://polyweather.top
# 常用:前端鉴权开关 # 常用:前端鉴权开关
@@ -43,9 +43,9 @@ POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# 可选:钱包支付 / Telegram 入口 # 可选:钱包支付 / Telegram 入口
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=8ce6332b57a42e606cf28224e82d1e02
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
+3
View File
@@ -3,3 +3,6 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
NEXT_PUBLIC_SITE_URL=https://polyweather.top NEXT_PUBLIC_SITE_URL=https://polyweather.top
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=https://api.polyweather.top NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=https://api.polyweather.top
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=8ce6332b57a42e606cf28224e82d1e02
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
+6
View File
@@ -11,12 +11,18 @@ ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG NEXT_PUBLIC_SITE_URL ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_POLYWEATHER_API_BASE_URL ARG NEXT_PUBLIC_POLYWEATHER_API_BASE_URL
ARG NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS ARG NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
ARG NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
ARG NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL
ARG NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=$NEXT_PUBLIC_POLYWEATHER_API_BASE_URL ENV NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=$NEXT_PUBLIC_POLYWEATHER_API_BASE_URL
ENV NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=$NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS ENV NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=$NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
ENV NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=$NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
ENV NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=$NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL
ENV NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=$NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS
WORKDIR /app/frontend WORKDIR /app/frontend
COPY --from=deps /app/frontend/node_modules ./node_modules COPY --from=deps /app/frontend/node_modules ./node_modules
+16 -8
View File
@@ -3,13 +3,13 @@
PolyWeather Pro 的生产前端工程。 PolyWeather Pro 的生产前端工程。
线上地址: 线上地址:
- [https://polyweather-pro.vercel.app/](https://polyweather-pro.vercel.app/) - [https://polyweather.top/](https://polyweather.top/)
## 技术栈 ## 技术栈
- Next.js App Router - Next.js App Router
- React + Tailwind - React + Tailwind
- Leaflet + Chart.js - Leaflet + Recharts
- Supabase Auth - Supabase Auth
- WalletConnect + 浏览器 EVM 钱包 - WalletConnect + 浏览器 EVM 钱包
@@ -23,6 +23,11 @@ PolyWeather Pro 的生产前端工程。
- 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心 - 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心
- `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF 和结算来源 - `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF 和结算来源
- 终端图表默认展示全天,可切换高温窗口;所有横轴与 tooltip 时间按城市当地时间渲染
- 可见终端图表通过 SSE patch 无痛增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
- AMSC/AMOS 跑道城市默认展示结算跑道曲线并高亮,辅助跑道弱化展示;单跑道机场不重复展示聚合线
- 香港默认展示 CoWIN 6087 参考站 1 分钟曲线,并保留 HKO 10 分钟官方气象层
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
- 今日日内分析支持: - 今日日内分析支持:
- `锚点状态` - `锚点状态`
- `当前节奏` - `当前节奏`
@@ -37,10 +42,10 @@ PolyWeather Pro 的生产前端工程。
- 右侧详情面板在多日预报仍未补齐时会显示同步占位卡,不再把“只有今天一张卡”的中间态伪装成完整数据 - 右侧详情面板在多日预报仍未补齐时会显示同步占位卡,不再把“只有今天一张卡”的中间态伪装成完整数据
- 日内分析弹窗在 full detail / market scan 同步时会锁住旧内容并显示刷新状态,避免用户短暂看到旧城市或旧日期的数据 - 日内分析弹窗在 full detail / market scan 同步时会锁住旧内容并显示刷新状态,避免用户短暂看到旧城市或旧日期的数据
- 城市决策卡支持从地图点击城市进入;机会榜和日历仍按 Pro 权限控制,地图探索和城市简报可作为轻量入口 - 城市决策卡支持从地图点击城市进入;机会榜和日历仍按 Pro 权限控制,地图探索和城市简报可作为轻量入口
- 城市决策卡的 AI 机场报文解读包括最终判断、METAR 解读、推理说明、模型集群备注、风险提示和原始 METAR - 城市决策卡展示结构化实况、模型区间、市场温度桶和模型-市场差,不再请求 AI 解读
- AI 机场报文解读按 `city + local_date + locale + METAR signature` 做页面内存缓存和 `localStorage` 最终结果缓存;切换选项卡返回时会优先恢复已有内容
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率` - 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考 - 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
- 账户中心支付区支持后端下发的多链网络选择;Polygon 继续走 checkout 合约,Ethereum 主网 USDC 走手动直转确认
- 缓存桶状态与 summary cache hit/miss - 缓存桶状态与 summary cache hit/miss
## 本地开发 ## 本地开发
@@ -90,7 +95,7 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# 钱包支付 # 钱包支付
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
# 社群入口 # 社群入口
@@ -144,7 +149,7 @@ Ops
当前前端已内置轻量管理页: 当前前端已内置轻量管理页:
- [https://polyweather-pro.vercel.app/ops](https://polyweather-pro.vercel.app/ops) - [https://polyweather.top/ops](https://polyweather.top/ops)
页面当前支持: 页面当前支持:
@@ -172,7 +177,8 @@ Ops
2.`receiver_contract` 已更新,先切到最新地址 2.`receiver_contract` 已更新,先切到最新地址
3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付 3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付
4. 仅允许在 `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` 白名单域名上创建 payment intent 4. 仅允许在 `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` 白名单域名上创建 payment intent
5. 支付区会明确显示当前账号、付款钱包和收款合约,避免账号/钱包/地址混淆 5. 支付区会明确显示当前账号、付款钱包、支付网络和收款合约/钱包,避免账号/钱包/地址/链混淆
6. 多链支付时,前端会把用户选择的 `chain_id``token_address` 发给后端,由后端按 intent 的链确认交易
这意味着: 这意味着:
@@ -186,6 +192,8 @@ Ops
- 支付相关路由:`no-store` - 支付相关路由:`no-store`
- 当 detail 缓存只返回单模型或单日 forecast 时,前端会自动强刷完整 detail,并在补齐前显示同步提示 / 占位卡 - 当 detail 缓存只返回单模型或单日 forecast 时,前端会自动强刷完整 detail,并在补齐前显示同步提示 / 占位卡
- 今日日内分析打开时如果正在切换城市、日期或 detail 深度,弹窗会阻断旧内容点击并显示刷新锁 - 今日日内分析打开时如果正在切换城市、日期或 detail 深度,弹窗会阻断旧内容点击并显示刷新锁
- 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=500`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新
- 前端只消费 HTTP snapshot + SSE patch,不直接感知 RedisRedis Stream / SQLite event log 都由后端统一封装
## Vercel 节流建议 ## Vercel 节流建议
@@ -200,4 +208,4 @@ Ops
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md` 详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
最后更新:`2026-05-23` 最后更新:`2026-05-29`
+96 -24
View File
@@ -7,49 +7,121 @@ import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
buildUpstreamErrorResponse, buildUpstreamErrorResponse,
} from "@/lib/api-proxy"; } from "@/lib/api-proxy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const ANALYTICS_ENABLED = const ANALYTICS_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS !== "false"; process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS !== "false";
const ANALYTICS_PROXY_TIMEOUT_MS = Math.max(
250,
Number(process.env.POLYWEATHER_ANALYTICS_PROXY_TIMEOUT_MS || "1500") || 1500,
);
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const timer = createProxyTimer(req, "analytics_events");
if (!ANALYTICS_ENABLED) { if (!ANALYTICS_ENABLED) {
return new NextResponse(null, { status: 204 }); return finishProxyTimedResponse(
} new NextResponse(null, { status: 204 }),
timer,
if (!API_BASE) { "disabled",
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
); );
} }
if (!API_BASE) {
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ANALYTICS_PROXY_TIMEOUT_MS);
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try { try {
const body = await req.json(); const body = await timer.measure("request_read", () => req.json());
const auth = await buildBackendRequestHeaders(req, { const payload =
includeSupabaseIdentity: false, body && typeof body.payload === "object" && body.payload != null
}); ? body.payload
: {};
const enrichedBody = {
...(body ?? {}),
payload: {
...payload,
cf_country:
req.headers.get("cf-ipcountry") ||
req.headers.get("x-vercel-ip-country") ||
"",
user_agent: req.headers.get("user-agent") || "",
referer_header: req.headers.get("referer") || "",
},
};
auth = await timer.measure("auth_headers", () =>
buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
}),
);
const headers = new Headers(auth.headers); const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/analytics/events`, { const res = await timer.measure("backend_fetch", () =>
method: "POST", fetch(`${API_BASE}/api/analytics/events`, {
headers, method: "POST",
body: JSON.stringify(body ?? {}), headers,
cache: "no-store", body: JSON.stringify(enrichedBody),
}); cache: "no-store",
signal: controller.signal,
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
if (!res.ok) { if (!res.ok) {
const raw = await res.text(); const raw = await timer.measure("backend_read", () => res.text());
const response = buildUpstreamErrorResponse(res.status, raw, { const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 260, detailLimit: 260,
}); });
return applyAuthResponseCookies(response, auth.response); return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
} }
const data = await res.json(); const data = await timer.measure("backend_read", () => res.json());
const response = NextResponse.json(data); const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response); return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) { } catch (error) {
return buildProxyExceptionResponse(error, { const timedOut = controller.signal.aborted;
publicMessage: "Failed to track analytics event", const response = timedOut
}); ? NextResponse.json(
{
ok: false,
accepted: true,
dropped: true,
reason: "timeout",
},
{ status: 202 },
)
: buildProxyExceptionResponse(error, {
publicMessage: "Failed to track analytics event",
});
const withCookies = auth ? applyAuthResponseCookies(response, auth.response) : response;
return finishProxyTimedResponse(
withCookies,
timer,
timedOut ? "timeout_accepted" : "exception",
);
} finally {
clearTimeout(timeoutId);
} }
} }
+640 -64
View File
@@ -4,104 +4,680 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse, buildUpstreamErrorResponse,
} from "@/lib/api-proxy"; } from "@/lib/api-proxy";
import { import {
getLocalDevAuthPayload, getLocalDevAuthPayload,
isLocalFullAccessHost, isLocalFullAccessHost,
} from "@/lib/local-dev-access"; } from "@/lib/local-dev-access";
import {
applyEntitlementSnapshotCookie,
clearEntitlementSnapshotCookie,
entitlementSnapshotToAuthPayload,
readEntitlementSnapshot,
} from "@/lib/entitlement-snapshot";
import {
buildSubscriptionRequiredAuthProfile,
isSubscriptionRequiredBackendResponse,
} from "@/lib/auth-profile-proxy";
import {
hasSupabaseServerEnv,
hasSupabaseSessionCookieValues,
} from "@/lib/supabase/server";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
type AuthMeTimingStage = {
durationMs: number;
name: string;
};
type AuthMeTimer = {
hasAuthorization: boolean;
hasSupabaseCookie: boolean;
measure<T>(name: string, action: () => Promise<T>): Promise<T>;
measureSync<T>(name: string, action: () => T): T;
preferSnapshot: boolean;
stages: AuthMeTimingStage[];
totalMs(): number;
};
function authMeNowMs() {
return typeof performance !== "undefined" ? performance.now() : Date.now();
}
function createAuthMeTimer(req: NextRequest): AuthMeTimer {
const startedAt = authMeNowMs();
const stages: AuthMeTimingStage[] = [];
const recordStage = (name: string, stageStartedAt: number) => {
stages.push({
durationMs: Math.round((authMeNowMs() - stageStartedAt) * 10) / 10,
name,
});
};
return {
hasAuthorization: Boolean(req.headers.get("authorization")),
hasSupabaseCookie: hasRequestSupabaseSessionCookie(req),
async measure<T>(name: string, action: () => Promise<T>) {
const stageStartedAt = authMeNowMs();
try {
return await action();
} finally {
recordStage(name, stageStartedAt);
}
},
measureSync<T>(name: string, action: () => T) {
const stageStartedAt = authMeNowMs();
try {
return action();
} finally {
recordStage(name, stageStartedAt);
}
},
preferSnapshot: req.nextUrl.searchParams.get("prefer_snapshot") === "1",
stages,
totalMs() {
return Math.round((authMeNowMs() - startedAt) * 10) / 10;
},
};
}
function formatServerTiming(stages: AuthMeTimingStage[], totalMs: number) {
return [...stages, { durationMs: totalMs, name: "total" }]
.map(({ durationMs, name }) => {
const safeName = name.replace(/[^A-Za-z0-9_-]/g, "_");
return `${safeName};dur=${Math.max(0, durationMs).toFixed(1)}`;
})
.join(", ");
}
function finishAuthMeResponse(
response: NextResponse,
timer: AuthMeTimer,
outcome: string,
extra?: { backendServerTiming?: string },
) {
const total = timer.totalMs();
const ownServerTiming = formatServerTiming(timer.stages, total);
const backendServerTiming = String(extra?.backendServerTiming || "").trim();
response.headers.set(
"Server-Timing",
backendServerTiming
? `${ownServerTiming}, ${backendServerTiming}`
: ownServerTiming,
);
console.info(
"[auth-me-timing]",
JSON.stringify({
backendServerTiming: backendServerTiming || undefined,
hasAuthorization: timer.hasAuthorization,
hasSupabaseCookie: timer.hasSupabaseCookie,
outcome,
preferSnapshot: timer.preferSnapshot,
stagesMs: Object.fromEntries(
timer.stages.map((stage) => [stage.name, stage.durationMs]),
),
status: response.status,
totalMs: total,
}),
);
return response;
}
async function trackAuthDiagnosticEvent(
req: NextRequest,
{
email,
reason,
responseMode,
userId,
}: {
email: string | null;
reason: string;
responseMode: "snapshot" | "degraded" | "anonymous";
userId?: string | null;
},
) {
if (!API_BASE) return;
const normalizedUserId = String(userId || "").trim();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1200);
try {
await fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event_type: "degraded_auth_profile",
client_id: normalizedUserId ? `auth:${normalizedUserId}` : undefined,
payload: {
route: "/api/auth/me",
reason: String(reason || "unknown").slice(0, 240),
response_mode: responseMode,
user_id: normalizedUserId || undefined,
email_domain: email?.includes("@") ? email.split("@").pop() : undefined,
cf_country:
req.headers.get("cf-ipcountry") ||
req.headers.get("x-vercel-ip-country") ||
"",
user_agent: req.headers.get("user-agent") || "",
referer_header: req.headers.get("referer") || "",
captured_at: new Date().toISOString(),
},
}),
cache: "no-store",
signal: controller.signal,
});
} catch {
// Diagnostics must never block auth/profile fallback responses.
} finally {
clearTimeout(timeoutId);
}
}
type VerifiedBearerIdentity = {
email: string | null;
userId: string;
};
function extractBearerToken(headerValue: string | null) {
if (!headerValue) return "";
const parts = headerValue.trim().split(/\s+/);
if (parts.length === 2 && parts[0].toLowerCase() === "bearer") {
return parts[1];
}
return "";
}
async function getVerifiedBearerIdentity(
req: NextRequest,
): Promise<VerifiedBearerIdentity | null> {
const token = extractBearerToken(req.headers.get("authorization"));
if (!token) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
if (!supabaseUrl || !anonKey) return null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 4000);
try {
const res = await fetch(`${supabaseUrl.replace(/\/+$/, "")}/auth/v1/user`, {
cache: "no-store",
headers: {
apikey: anonKey,
Authorization: `Bearer ${token}`,
},
signal: controller.signal,
});
if (!res.ok) return null;
const user = await res.json();
const userId = String(user?.id || "").trim();
if (!userId) return null;
return {
email: String(user?.email || "").trim() || null,
userId,
};
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
}
async function degradedAuthProfileResponse({
email,
reason,
req,
response,
userId,
}: {
email: string | null;
reason: string;
req: NextRequest;
response: NextResponse | null;
userId: string;
}) {
const snapshotPayload = entitlementSnapshotToAuthPayload(
readEntitlementSnapshot(req, userId),
);
if (snapshotPayload) {
await trackAuthDiagnosticEvent(req, {
email: snapshotPayload.email || email,
reason,
responseMode: "snapshot",
userId,
});
const snapshotResponse = NextResponse.json({
...snapshotPayload,
email: snapshotPayload.email || email,
entitlement_snapshot_reason: reason,
});
return applyAuthResponseCookies(snapshotResponse, response);
}
await trackAuthDiagnosticEvent(req, {
email,
reason,
responseMode: "degraded",
userId,
});
const degraded = NextResponse.json({
authenticated: true,
user_id: userId,
email,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: reason,
});
return applyAuthResponseCookies(degraded, response);
}
function subscriptionRequiredAuthProfileResponse({
email,
response,
userId,
}: {
email: string | null;
response: NextResponse | null;
userId: string;
}) {
const inactive = NextResponse.json(
buildSubscriptionRequiredAuthProfile({ email, userId }),
);
clearEntitlementSnapshotCookie(inactive);
return applyAuthResponseCookies(inactive, response);
}
async function unauthenticatedAuthProfileResponse({
reason,
req,
response,
}: {
reason: string;
req: NextRequest;
response: NextResponse | null;
}) {
await trackAuthDiagnosticEvent(req, {
email: null,
reason,
responseMode: "anonymous",
});
const anonymous = NextResponse.json(
{
authenticated: false,
subscription_active: false,
points: 0,
degraded_auth_profile: true,
degraded_reason: reason,
},
{ headers: { "Cache-Control": "no-store" } },
);
clearEntitlementSnapshotCookie(anonymous);
return applyAuthResponseCookies(anonymous, response);
}
function snapshotAuthProfileResponse({
email,
reason,
req,
response,
userId,
}: {
email: string | null;
reason: string;
req: NextRequest;
response: NextResponse | null;
userId: string;
}) {
const snapshotPayload = entitlementSnapshotToAuthPayload(
readEntitlementSnapshot(req, userId),
);
if (!snapshotPayload) return null;
const snapshotResponse = NextResponse.json({
...snapshotPayload,
email: snapshotPayload.email || email,
entitlement_snapshot_reason: reason,
});
return applyAuthResponseCookies(snapshotResponse, response);
}
function applyEntitlementSnapshotFromAuthPayload(
response: NextResponse,
data: Record<string, unknown>,
) {
if (data.authenticated === true && data.subscription_active === true) {
return applyEntitlementSnapshotCookie(response, data);
}
if (data.authenticated === false || data.subscription_active === false) {
return clearEntitlementSnapshotCookie(response);
}
return response;
}
function hasRequestSupabaseSessionCookie(req: NextRequest) {
return hasSupabaseSessionCookieValues(
req.cookies.getAll().map((item) => ({
name: item.name,
value: item.value,
})),
);
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const timer = createAuthMeTimer(req);
const requestHost = const requestHost =
req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host; req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host;
if ( if (
isLocalFullAccessHost(requestHost) || isLocalFullAccessHost(requestHost) ||
isLocalFullAccessHost(req.nextUrl.hostname) isLocalFullAccessHost(req.nextUrl.hostname)
) { ) {
return NextResponse.json(getLocalDevAuthPayload(), { return finishAuthMeResponse(
headers: { "Cache-Control": "no-store" }, NextResponse.json(getLocalDevAuthPayload(), {
}); headers: { "Cache-Control": "no-store" },
} }),
timer,
if (!API_BASE) { "local_full_access",
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
); );
} }
try { if (!API_BASE) {
const auth = await buildBackendRequestHeaders(req); return finishAuthMeResponse(
const controller = new AbortController(); NextResponse.json(
const timeoutId = setTimeout(() => controller.abort(), 6000); { error: "POLYWEATHER_API_BASE_URL is not configured" },
let res: Response; { status: 500 },
try { ),
res = await fetch(`${API_BASE}/api/auth/me`, { timer,
headers: auth.headers, "missing_api_base",
cache: "no-store", );
signal: controller.signal, }
});
} finally { const preferSnapshot = req.nextUrl.searchParams.get("prefer_snapshot") === "1";
clearTimeout(timeoutId); if (
preferSnapshot &&
!req.headers.get("authorization") &&
hasRequestSupabaseSessionCookie(req)
) {
const snapshotPayload = timer.measureSync(
"snapshot_cookie",
() => entitlementSnapshotToAuthPayload(readEntitlementSnapshot(req)),
);
if (snapshotPayload) {
return finishAuthMeResponse(
NextResponse.json(
{
...snapshotPayload,
entitlement_snapshot_reason: "prefer_snapshot_fast_path",
},
{ headers: { "Cache-Control": "no-store" } },
),
timer,
"prefer_snapshot_fast_path",
);
} }
if (res.status === 401 || res.status === 403) { }
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
let bearerIdentity: VerifiedBearerIdentity | null | undefined;
const getBearerIdentityOnce = async () => {
if (bearerIdentity !== undefined) return bearerIdentity;
bearerIdentity = await timer.measure(
"bearer_identity",
() => getVerifiedBearerIdentity(req),
);
return bearerIdentity;
};
try {
auth = await timer.measure(
"auth_headers",
() => buildBackendRequestHeaders(req),
);
if (
hasSupabaseServerEnv() &&
!auth.authUserId &&
!req.headers.get("authorization")
) {
const response = NextResponse.json({ const response = NextResponse.json({
authenticated: false, authenticated: false,
subscription_active: false, subscription_active: false,
points: 0, points: 0,
}); });
return applyAuthResponseCookies(response, auth.response); if (!preferSnapshot) clearEntitlementSnapshotCookie(response);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"no_session",
);
}
if (preferSnapshot) {
const identity =
auth.authUserId
? { email: auth.authEmail || null, userId: auth.authUserId }
: await getBearerIdentityOnce();
if (identity?.userId) {
const snapshotResponse = snapshotAuthProfileResponse({
email: identity.email,
reason: "prefer_snapshot",
req,
response: auth.response,
userId: identity.userId,
});
if (snapshotResponse) {
return finishAuthMeResponse(
snapshotResponse,
timer,
"prefer_snapshot",
);
}
}
}
if (!auth) throw new Error("auth headers unavailable");
const backendAuth = auth;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 6000);
let res: Response;
try {
res = await timer.measure("backend_fetch", async () =>
await fetch(`${API_BASE}/api/auth/me`, {
headers: backendAuth.headers,
cache: "no-store",
signal: controller.signal,
}),
);
} finally {
clearTimeout(timeoutId);
}
const backendServerTiming = res.headers.get("server-timing") || "";
if (res.status === 401 || res.status === 403) {
const raw = await timer.measure("backend_read", () => res.text());
const authIdentity = auth.authUserId
? { email: auth.authEmail || null, userId: auth.authUserId }
: await getBearerIdentityOnce();
if (
authIdentity?.userId &&
isSubscriptionRequiredBackendResponse(res.status, raw)
) {
return finishAuthMeResponse(
subscriptionRequiredAuthProfileResponse({
email: authIdentity.email,
response: auth.response,
userId: authIdentity.userId,
}),
timer,
"subscription_required",
{ backendServerTiming },
);
}
if (auth.authUserId) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: auth.authEmail || null,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: auth.authUserId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const identity = await getBearerIdentityOnce();
if (identity) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: identity.email,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: identity.userId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const response = NextResponse.json({
authenticated: false,
subscription_active: false,
points: 0,
});
clearEntitlementSnapshotCookie(response);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`anonymous_backend_${res.status}`,
{ backendServerTiming },
);
} }
if (!res.ok) { if (!res.ok) {
const raw = await res.text(); const raw = await timer.measure("backend_read", () => res.text());
if (auth.authUserId) { if (auth.authUserId) {
const response = NextResponse.json({ return finishAuthMeResponse(
authenticated: true, await degradedAuthProfileResponse({
user_id: auth.authUserId, email: auth.authEmail || null,
email: auth.authEmail || null, reason: `backend_${res.status}`,
subscription_active: null, req,
subscription_plan_code: null, response: auth.response,
subscription_expires_at: null, userId: auth.authUserId,
subscription_total_expires_at: null, }),
subscription_queued_days: 0, timer,
subscription_queued_count: 0, `degraded_backend_${res.status}`,
points: 0, { backendServerTiming },
degraded_auth_profile: true, );
degraded_reason: `backend_${res.status}`, }
}); const identity = await getBearerIdentityOnce();
return applyAuthResponseCookies(response, auth.response); if (identity) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: identity.email,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: identity.userId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
} }
const response = buildUpstreamErrorResponse(res.status, raw); const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response); return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
}
const data = await timer.measure("backend_read", () => res.json());
if (data?.authenticated === true && data?.subscription_active == null) {
const userId = String(data.user_id || auth.authUserId || "").trim();
if (userId) {
const snapshotResponse = snapshotAuthProfileResponse({
email: String(data.email || auth.authEmail || "").trim() || null,
reason: "subscription_unknown",
req,
response: auth.response,
userId,
});
if (snapshotResponse) {
return finishAuthMeResponse(
snapshotResponse,
timer,
"subscription_unknown_snapshot",
{ backendServerTiming },
);
}
}
} }
const data = await res.json();
const response = NextResponse.json(data); const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response); applyEntitlementSnapshotFromAuthPayload(response, data);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) { } catch (error) {
const auth = await buildBackendRequestHeaders(req); if (auth?.authUserId) {
if (auth.authUserId) { return finishAuthMeResponse(
const response = NextResponse.json({ await degradedAuthProfileResponse({
authenticated: true, email: auth.authEmail || null,
user_id: auth.authUserId, reason: String(error),
email: auth.authEmail || null, req,
subscription_active: null, response: auth.response,
subscription_plan_code: null, userId: auth.authUserId,
subscription_expires_at: null, }),
subscription_total_expires_at: null, timer,
subscription_queued_days: 0, "exception_degraded",
subscription_queued_count: 0, );
points: 0,
degraded_auth_profile: true,
degraded_reason: String(error),
});
return applyAuthResponseCookies(response, auth.response);
} }
return buildProxyExceptionResponse(error, { const identity = await getBearerIdentityOnce();
publicMessage: "Failed to fetch auth profile", if (identity) {
}); return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: identity.email,
reason: String(error),
req,
response: auth?.response || null,
userId: identity.userId,
}),
timer,
"exception_degraded",
);
}
const snapshotPayload = timer.measureSync(
"snapshot_cookie",
() => entitlementSnapshotToAuthPayload(readEntitlementSnapshot(req)),
);
if (snapshotPayload) {
const snapshotResponse = NextResponse.json({
...snapshotPayload,
entitlement_snapshot_reason: "exception_snapshot",
});
return finishAuthMeResponse(
applyAuthResponseCookies(snapshotResponse, auth?.response || null),
timer,
"exception_snapshot",
);
}
return finishAuthMeResponse(
await unauthenticatedAuthProfileResponse({
reason: String(error),
req,
response: auth?.response || null,
}),
timer,
"exception_anonymous",
);
} }
} }
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const body = await req.text();
const res = await fetch(`${API_BASE}/api/auth/referral/apply`, {
method: "POST",
headers: {
...auth.headers,
"Content-Type": "application/json",
},
body,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to apply referral code",
});
}
}
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const DETAIL_BATCH_PROXY_TIMEOUT_MS = Number(
process.env.POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS || "12000",
);
export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "city_detail_batch");
if (!API_BASE) {
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
const searchParams = new URLSearchParams({
cities: req.nextUrl.searchParams.get("cities") || "",
force_refresh: forceRefresh,
limit: req.nextUrl.searchParams.get("limit") || "12",
});
for (const key of ["market_slug", "target_date", "resolution"]) {
const value = req.nextUrl.searchParams.get(key);
if (value) searchParams.set(key, value);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DETAIL_BATCH_PROXY_TIMEOUT_MS);
try {
return await proxyBackendJsonGet(req, {
cacheControl: cachePolicy.responseCacheControl,
fetchCache:
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined,
publicMessage: "Failed to fetch city detail batch",
revalidateSeconds: cachePolicy.revalidateSeconds,
signal: controller.signal,
timeoutPublicMessage: "City detail batch request timed out",
timing: timer,
url: `${API_BASE}/api/cities/detail-batch?${searchParams.toString()}`,
});
} finally {
clearTimeout(timeoutId);
}
}
+50 -12
View File
@@ -1,21 +1,59 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy"; import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildCachedJsonResponse } from "@/lib/http-cache";
import { STATIC_CITY_LIST } from "@/lib/static-cities";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const CITIES_CACHE_CONTROL = "public, max-age=0, s-maxage=60, stale-while-revalidate=300";
const STATIC_CITIES_CACHE_CONTROL =
"public, max-age=0, s-maxage=300, stale-while-revalidate=3600";
const CITIES_BACKEND_TIMEOUT_MS = Number(
process.env.POLYWEATHER_CITIES_BACKEND_TIMEOUT_MS || 1000,
);
function staticCitiesFallback(req: NextRequest, reason: string) {
const response = buildCachedJsonResponse(
req,
{
cities: STATIC_CITY_LIST,
source: "static_fallback",
stale: true,
},
STATIC_CITIES_CACHE_CONTROL,
);
response.headers.set("x-polyweather-cities-source", "static-fallback");
response.headers.set("x-polyweather-cities-fallback-reason", reason);
return response;
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
if (!API_BASE) { if (!API_BASE) {
const response = NextResponse.json( return staticCitiesFallback(req, "missing-api-base");
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
} }
return proxyBackendJsonGet(req, { const abortController = new AbortController();
cacheControl: "public, max-age=0, s-maxage=60, stale-while-revalidate=300", const timeout = setTimeout(() => abortController.abort(), CITIES_BACKEND_TIMEOUT_MS);
publicMessage: "Failed to fetch cities",
revalidateSeconds: 60, try {
url: `${API_BASE}/api/cities`, const response = await proxyBackendJsonGet(req, {
}); cacheControl: CITIES_CACHE_CONTROL,
publicMessage: "Failed to fetch cities",
revalidateSeconds: 60,
signal: abortController.signal,
statusOnException: 504,
timeoutPublicMessage: "Cities backend timed out",
url: `${API_BASE}/api/cities`,
});
if (response.status >= 500) {
return staticCitiesFallback(
req,
response.status === 504 ? "backend-timeout" : "backend-error",
);
}
return response;
} finally {
clearTimeout(timeout);
}
} }
+38 -16
View File
@@ -9,6 +9,10 @@ import {
} from "@/lib/api-proxy"; } from "@/lib/api-proxy";
import { buildCachedJsonResponse } from "@/lib/http-cache"; import { buildCachedJsonResponse } from "@/lib/http-cache";
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy"; import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -34,15 +38,16 @@ export async function GET(
req: NextRequest, req: NextRequest,
context: { params: Promise<{ name: string }> }, context: { params: Promise<{ name: string }> },
) { ) {
const timer = createProxyTimer(req, "city_detail");
if (!API_BASE) { if (!API_BASE) {
const response = NextResponse.json( const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" }, { error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 }, { status: 500 },
); );
return response; return finishProxyTimedResponse(response, timer, "missing_api_base");
} }
const { name } = await context.params; const { name } = await timer.measure("route_params", () => context.params);
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15); const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
const depth = req.nextUrl.searchParams.get("depth"); const depth = req.nextUrl.searchParams.get("depth");
@@ -67,31 +72,48 @@ export async function GET(
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`; const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
try { try {
const auth = await buildBackendRequestHeaders(req, { const auth = await timer.measure("auth_headers", () =>
includeSupabaseIdentity: false, buildBackendRequestHeaders(req, {
}); includeSupabaseIdentity: false,
const res = await fetch(url, { }),
headers: auth.headers, );
...(cachePolicy.fetchMode === "no-store" const res = await timer.measure("backend_fetch", () =>
? { cache: "no-store" as const } fetch(url, {
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }), headers: auth.headers,
}); ...(cachePolicy.fetchMode === "no-store"
? { cache: "no-store" as const }
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
if (!res.ok) { if (!res.ok) {
const raw = await res.text(); const raw = await timer.measure("backend_read", () => res.text());
const response = buildUpstreamErrorResponse(res.status, raw); const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response); return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
} }
const data = normalizeCityDetailPayload(await res.json()); const data = normalizeCityDetailPayload(
await timer.measure("backend_read", () => res.json()),
);
const response = buildCachedJsonResponse( const response = buildCachedJsonResponse(
req, req,
data, data,
cachePolicy.responseCacheControl, cachePolicy.responseCacheControl,
); );
return applyAuthResponseCookies(response, auth.response); return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) { } catch (error) {
const response = buildProxyExceptionResponse(error, { const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch city detail aggregate", publicMessage: "Failed to fetch city detail aggregate",
}); });
return response; return finishProxyTimedResponse(response, timer, "exception");
} }
} }
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/analytics/funnel`); const url = new URL(`${API_BASE}/api/ops/analytics/funnel`);
const days = req.nextUrl.searchParams.get("days"); const days = req.nextUrl.searchParams.get("days");
if (days) { if (days) {
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/billing-risk`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Billing risk check failed",
});
}
}
+7
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : ""; const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : "";
@@ -9,6 +10,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" }); const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -20,6 +24,9 @@ export async function PUT(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text(); const body = await req.text();
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" }); const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" }); const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`); const url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`);
const limit = req.nextUrl.searchParams.get("limit"); const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit); if (limit) url.searchParams.set("limit", limit);
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,8 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/memberships/growth`); const url = new URL(`${API_BASE}/api/ops/memberships/growth`);
const days = req.nextUrl.searchParams.get("days"); const days = req.nextUrl.searchParams.get("days");
if (days) url.searchParams.set("days", days); if (days) url.searchParams.set("days", days);
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/memberships/overview`);
const limit = req.nextUrl.searchParams.get("limit");
const days = req.nextUrl.searchParams.get("days");
if (limit) url.searchParams.set("limit", limit);
if (days) url.searchParams.set("days", days);
const res = await fetch(url.toString(), {
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch memberships overview",
});
}
}
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,8 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/memberships`); const url = new URL(`${API_BASE}/api/ops/memberships`);
const limit = req.nextUrl.searchParams.get("limit"); const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit); if (limit) url.searchParams.set("limit", limit);
+44 -13
View File
@@ -4,24 +4,46 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "ops_online_users");
if (!API_BASE) { if (!API_BASE) {
return NextResponse.json( return finishProxyTimedResponse(
{ error: "POLYWEATHER_API_BASE_URL is not configured" }, NextResponse.json(
{ status: 500 }, { error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
); );
} }
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await timer.measure("auth_headers", () =>
const res = await fetch(`${API_BASE}/api/ops/online-users`, { buildBackendRequestHeaders(req),
headers: auth.headers, );
cache: "no-store", const authError = timer.measureSync("ops_auth", () =>
}); requireOpsProxyAuth(req, auth),
const raw = await res.text(); );
if (authError) {
return finishProxyTimedResponse(authError, timer, "ops_auth_error");
}
const res = await timer.measure("backend_fetch", () =>
fetch(`${API_BASE}/api/ops/online-users`, {
headers: auth.headers,
cache: "no-store",
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
const raw = await timer.measure("backend_read", () => res.text());
const response = new NextResponse(raw, { const response = new NextResponse(raw, {
status: res.status, status: res.status,
headers: { headers: {
@@ -29,10 +51,19 @@ export async function GET(req: NextRequest) {
"Cache-Control": "no-store", "Cache-Control": "no-store",
}, },
}); });
return applyAuthResponseCookies(response, auth.response); return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
res.ok ? "ok" : `upstream_${res.status}`,
{ backendServerTiming },
);
} catch (error) { } catch (error) {
return buildProxyExceptionResponse(error, { return finishProxyTimedResponse(
publicMessage: "Failed to fetch online users", buildProxyExceptionResponse(error, {
}); publicMessage: "Failed to fetch online users",
}),
timer,
"exception",
);
} }
} }
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -21,6 +22,9 @@ export async function POST(req: NextRequest, context: RouteContext) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const { eventId } = await context.params; const { eventId } = await context.params;
const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, { const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, {
method: "POST", method: "POST",
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/payments/incidents`); const url = new URL(`${API_BASE}/api/ops/payments/incidents`);
const limit = req.nextUrl.searchParams.get("limit"); const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit); if (limit) url.searchParams.set("limit", limit);
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/payments`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch ops payments",
});
}
}
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND = API_BASE ? `${API_BASE}/api/ops/sensitive-config` : "";
export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config fetch failed" }); }
}
export async function PUT(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
const res = await fetch(BACKEND, { method: "PUT", headers: { ...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": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config update failed" }); }
}
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/source-health`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Source health check failed",
});
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || ""; const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || "";
@@ -9,6 +10,9 @@ export async function POST(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text(); const body = await req.text();
const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" }; const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" };
if (ENTITLEMENT_TOKEN) { if (ENTITLEMENT_TOKEN) {
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -41,6 +42,23 @@ async function findSupabaseUserIdByEmail(email: string) {
if (!supabaseUrl || !serviceRoleKey) { if (!supabaseUrl || !serviceRoleKey) {
throw new Error("Supabase service role is not configured on Vercel"); throw new Error("Supabase service role is not configured on Vercel");
} }
const profileRes = await fetch(
`${supabaseUrl}/rest/v1/profiles?select=id&email=eq.${encodeURIComponent(email)}&limit=1`,
{
headers: {
apikey: serviceRoleKey,
Authorization: `Bearer ${serviceRoleKey}`,
Accept: "application/json",
},
cache: "no-store",
},
);
const profileData = (await profileRes.json().catch(() => [])) as Array<{ id?: string }>;
if (profileRes.ok) {
const profileUserId = String(profileData?.[0]?.id || "").trim();
if (profileUserId) return { supabaseUrl, serviceRoleKey, userId: profileUserId };
}
const res = await fetch( const res = await fetch(
`${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`, `${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`,
{ {
@@ -109,7 +127,7 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
apikey: serviceRoleKey, apikey: serviceRoleKey,
Authorization: `Bearer ${serviceRoleKey}`, Authorization: `Bearer ${serviceRoleKey}`,
"Content-Type": "application/json", "Content-Type": "application/json",
Prefer: "return=representation", Prefer: "return=minimal",
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
cache: "no-store", cache: "no-store",
@@ -138,6 +156,9 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text(); const body = await req.text();
if (!API_BASE) { if (!API_BASE) {
return grantSubscriptionDirectly(req, body, auth.authEmail); return grantSubscriptionDirectly(req, body, auth.authEmail);
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/telegram/members-audit`, { headers: auth.headers, cache: "no-store" }); const res = await fetch(`${API_BASE}/api/ops/telegram/members-audit`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/training/accuracy`, { headers: auth.headers, cache: "no-store" }); const res = await fetch(`${API_BASE}/api/ops/training/accuracy`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/truth-history`); const url = new URL(`${API_BASE}/api/ops/truth-history`);
for (const key of ["city", "date_from", "date_to", "limit"]) { for (const key of ["city", "date_from", "date_to", "limit"]) {
const value = req.nextUrl.searchParams.get(key); const value = req.nextUrl.searchParams.get(key);
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function POST(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text(); const body = await req.text();
const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, { const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, {
method: "POST", method: "POST",
+4
View File
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/users`); const url = new URL(`${API_BASE}/api/ops/users`);
const q = req.nextUrl.searchParams.get("q"); const q = req.nextUrl.searchParams.get("q");
const limit = req.nextUrl.searchParams.get("limit"); const limit = req.nextUrl.searchParams.get("limit");
+4
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/logs`); const url = new URL(`${API_BASE}/api/ops/logs`);
const level = req.nextUrl.searchParams.get("level"); const level = req.nextUrl.searchParams.get("level");
const lines = req.nextUrl.searchParams.get("lines"); const lines = req.nextUrl.searchParams.get("lines");
+1 -1
View File
@@ -13,7 +13,7 @@ export async function GET(req: NextRequest) {
return proxyBackendJsonGet(req, { return proxyBackendJsonGet(req, {
cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900", cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900",
detailLimit: 350, detailLimit: 350,
includeSupabaseIdentity: true, includeSupabaseIdentity: false,
publicMessage: "Failed to fetch payment config", publicMessage: "Failed to fetch payment config",
revalidateSeconds: 300, revalidateSeconds: 300,
url: `${API_BASE}/api/payments/config`, url: `${API_BASE}/api/payments/config`,
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
@@ -25,7 +25,7 @@ export async function POST(
try { try {
const body = await req.json(); const body = await req.json();
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
@@ -25,7 +25,7 @@ export async function POST(
try { try {
const body = await req.json(); const body = await req.json();
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
@@ -25,7 +25,7 @@ export async function POST(
try { try {
const body = await req.json(); const body = await req.json();
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
+2 -2
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
@@ -36,7 +36,7 @@ export async function POST(req: NextRequest) {
try { try {
const body = await req.json(); const body = await req.json();
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
@@ -18,7 +18,7 @@ export async function POST(req: NextRequest) {
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, { const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
method: "POST", method: "POST",
+1 -1
View File
@@ -16,7 +16,7 @@ export async function GET(req: NextRequest) {
conditionalResponse: false, conditionalResponse: false,
detailLimit: 500, detailLimit: 500,
fetchCache: "no-store", fetchCache: "no-store",
includeSupabaseIdentity: true, includeSupabaseIdentity: false,
publicMessage: "Failed to fetch payment runtime", publicMessage: "Failed to fetch payment runtime",
url: `${API_BASE}/api/payments/runtime`, url: `${API_BASE}/api/payments/runtime`,
}); });
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
@@ -21,7 +21,7 @@ export async function POST(req: NextRequest) {
try { try {
const body = await req.json(); const body = await req.json();
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
+2 -2
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
@@ -45,7 +45,7 @@ export async function DELETE(req: NextRequest) {
} }
try { try {
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
requireBackendAuthUser, requireBackendPaymentAuth,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
@@ -21,7 +21,7 @@ export async function POST(req: NextRequest) {
try { try {
const body = await req.json(); const body = await req.json();
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
@@ -1,75 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const SCAN_AI_PROXY_TIMEOUT_MS = Math.max(
35_000,
Number(process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS || "45000") || 45_000,
);
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let body: unknown = {};
try {
body = await req.json();
} catch {
body = {};
}
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), SCAN_AI_PROXY_TIMEOUT_MS);
try {
auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
headers.set("Accept", "application/json");
const res = await fetch(`${API_BASE}/api/scan/terminal/ai`, {
method: "POST",
headers,
cache: "no-store",
signal: controller.signal,
body: JSON.stringify(body || {}),
});
if (!res.ok) {
const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data, {
headers: {
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const timedOut = controller.signal.aborted;
const response = buildProxyExceptionResponse(error, {
publicMessage: timedOut
? "Scan AI request timed out"
: "Failed to fetch scan AI data",
status: timedOut ? 504 : 500,
});
return auth ? applyAuthResponseCookies(response, auth.response) : response;
} finally {
clearTimeout(timeoutId);
}
}
@@ -37,7 +37,9 @@ export async function POST(req: NextRequest) {
const timeoutId = setTimeout(() => controller.abort(), OVERVIEW_PROXY_TIMEOUT_MS); const timeoutId = setTimeout(() => controller.abort(), OVERVIEW_PROXY_TIMEOUT_MS);
try { try {
auth = await buildBackendRequestHeaders(req); auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const headers = new Headers(auth.headers); const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
headers.set("Accept", "application/json"); headers.set("Accept", "application/json");
+20 -6
View File
@@ -1,19 +1,29 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy"; import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy"; import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy";
import { DASHBOARD_REFRESH_POLICY_SEC } from "@/lib/refresh-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number( const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "28000", process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "40000",
); );
export const maxDuration = 30; export const maxDuration = 45;
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "scan_terminal");
if (!API_BASE) { if (!API_BASE) {
return NextResponse.json( return finishProxyTimedResponse(
{ error: "POLYWEATHER_API_BASE_URL is not configured" }, NextResponse.json(
{ status: 500 }, { error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
); );
} }
@@ -41,7 +51,10 @@ export async function GET(req: NextRequest) {
if (tradingRegion != null && tradingRegion !== "") { if (tradingRegion != null && tradingRegion !== "") {
params.set("region", tradingRegion); params.set("region", tradingRegion);
} }
const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 10); const cachePolicy = buildForceRefreshProxyCachePolicy(
forceRefresh,
DASHBOARD_REFRESH_POLICY_SEC.scanRows,
);
const url = `${API_BASE}/api/scan/terminal?${params.toString()}`; const url = `${API_BASE}/api/scan/terminal?${params.toString()}`;
@@ -57,6 +70,7 @@ export async function GET(req: NextRequest) {
revalidateSeconds: cachePolicy.revalidateSeconds, revalidateSeconds: cachePolicy.revalidateSeconds,
signal: controller.signal, signal: controller.signal,
timeoutPublicMessage: "Scan terminal request timed out", timeoutPublicMessage: "Scan terminal request timed out",
timing: timer,
url, url,
}); });
} finally { } finally {
+35 -1
View File
@@ -1,7 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { BACKEND_ENTITLEMENT_HEADER } from "@/lib/backend-auth";
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server"; import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
import { getConfiguredSiteUrl } from "@/lib/site-url"; import { getConfiguredSiteUrl } from "@/lib/site-url";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
function normalizeNextPath(input: string | null) { function normalizeNextPath(input: string | null) {
const fallback = "/"; const fallback = "/";
const raw = String(input || "").trim(); const raw = String(input || "").trim();
@@ -11,6 +14,34 @@ function normalizeNextPath(input: string | null) {
return raw; return raw;
} }
async function warmSignupTrial(accessToken: string) {
const token = String(accessToken || "").trim();
if (!API_BASE || !token) return;
const headers = new Headers({
Accept: "application/json",
Authorization: `Bearer ${token}`,
});
const backendToken = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim();
if (backendToken) {
headers.set(BACKEND_ENTITLEMENT_HEADER, backendToken);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2500);
try {
await fetch(`${API_BASE}/api/auth/me`, {
cache: "no-store",
headers,
signal: controller.signal,
});
} catch {
// The account/terminal bootstrap will retry. Callback must not strand login.
} finally {
clearTimeout(timeoutId);
}
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const siteUrl = getConfiguredSiteUrl(); const siteUrl = getConfiguredSiteUrl();
if (siteUrl) { if (siteUrl) {
@@ -38,7 +69,10 @@ export async function GET(request: NextRequest) {
const supabase = createSupabaseRouteClient(request, response); const supabase = createSupabaseRouteClient(request, response);
const code = request.nextUrl.searchParams.get("code"); const code = request.nextUrl.searchParams.get("code");
if (code) { if (code) {
await supabase.auth.exchangeCodeForSession(code); const {
data: { session },
} = await supabase.auth.exchangeCodeForSession(code);
await warmSignupTrial(session?.access_token || "");
} }
return response; return response;
+25
View File
@@ -0,0 +1,25 @@
import { ResetPasswordClient } from "@/components/auth/ResetPasswordClient";
import { I18nProvider } from "@/hooks/useI18n";
type PageProps = {
searchParams?: Promise<{ next?: string }>;
};
function normalizeNextPath(input: string | undefined) {
const fallback = "/account";
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 ResetPasswordPage({ searchParams }: PageProps) {
const params = (await searchParams) || {};
const nextPath = normalizeNextPath(params.next);
return (
<I18nProvider>
<ResetPasswordClient nextPath={nextPath} />
</I18nProvider>
);
}
+2 -2
View File
@@ -23,7 +23,7 @@ export const metadata: Metadata = {
template: "%s | PolyWeather", template: "%s | PolyWeather",
}, },
description: description:
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards. Real-time observations for 51 global cities.", "PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context. Real-time observations for 51 global cities.",
manifest: "/site.webmanifest", manifest: "/site.webmanifest",
metadataBase: new URL("https://polyweather.top"), metadataBase: new URL("https://polyweather.top"),
alternates: { alternates: {
@@ -34,7 +34,7 @@ export const metadata: Metadata = {
siteName: "PolyWeather", siteName: "PolyWeather",
title: "PolyWeather | Institutional Weather Signal Intelligence", title: "PolyWeather | Institutional Weather Signal Intelligence",
description: description:
"Paid professional weather-signal intelligence terminal. METAR evidence, DEB forecast blending, AI decision cards. 51 cities, real-time.", "Paid professional weather-signal intelligence terminal. METAR evidence, DEB forecast blending, structured decision context. 51 cities, real-time.",
url: "https://polyweather.top", url: "https://polyweather.top",
images: [ images: [
{ {
+21 -10
View File
@@ -1,12 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { PreloadTerminalData } from "@/components/landing/PreloadTerminalData";
import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLandingPage"; import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLandingPage";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PolyWeather | Institutional Weather Signal Intelligence", title: "PolyWeather | Institutional Weather Signal Intelligence",
description: description:
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.", "PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context.",
other: { other: {
preconnect: "https://api.polyweather.top", preconnect: "https://api.polyweather.top",
}, },
@@ -38,16 +37,29 @@ export default async function HomePage({
"@type": "WebApplication", "@type": "WebApplication",
name: "PolyWeather", name: "PolyWeather",
description: description:
"Paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.", "Paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context.",
url: "https://polyweather.top", url: "https://polyweather.top",
applicationCategory: "BusinessApplication", applicationCategory: "BusinessApplication",
operatingSystem: "Web", operatingSystem: "Web",
offers: { offers: [
"@type": "Offer", {
price: "10.00", "@type": "Offer",
priceCurrency: "USD", name: "Pro monthly",
description: "Monthly subscription", price: "29.90",
}, priceCurrency: "USD",
description:
"Pro subscription for 30 days. Referral users pay 20.00 USD-equivalent USDC for the first month.",
availability: "https://schema.org/InStock",
},
{
"@type": "Offer",
name: "Pro quarterly",
price: "79.90",
priceCurrency: "USD",
description: "Pro subscription for 90 days.",
availability: "https://schema.org/InStock",
},
],
}; };
return ( return (
@@ -56,7 +68,6 @@ export default async function HomePage({
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
<PreloadTerminalData />
<InstitutionalLandingPage /> <InstitutionalLandingPage />
</> </>
); );
@@ -12,11 +12,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useI18n } from "@/hooks/useI18n"; import { useI18n } from "@/hooks/useI18n";
const TELEGRAM_GROUP_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
"https://t.me/+Io5H9oVHFmVjOTQ5",
).trim();
const FAQ_ITEMS = [ const FAQ_ITEMS = [
{ {
q_zh: "Pro 包含哪些功能?", q_zh: "Pro 包含哪些功能?",
@@ -27,14 +22,14 @@ const FAQ_ITEMS = [
{ {
q_zh: "当前订阅价格是多少?", q_zh: "当前订阅价格是多少?",
q_en: "What is the current subscription price?", q_en: "What is the current subscription price?",
a_zh: "目前仅提供月付:10 USDC / 30 天。", a_zh: "Pro 月付 29.9 USDC / 30 天,Pro 季度 79.9 USDC / 90 天。",
a_en: "Monthly plan only: 10 USDC / 30 days.", a_en: "Pro monthly is 29.9 USDC / 30 days. Pro quarterly is 79.9 USDC / 90 days.",
}, },
{ {
q_zh: "积分如何抵扣?", q_zh: "积分如何抵扣?",
q_en: "How do points work for discounts?", q_en: "How do points work for discounts?",
a_zh: "满 500 积分起兑,每 500 积分抵 1U,单次最多抵 3U。", a_zh: "满 500 积分起兑,每 500 积分抵 1U。月付最多抵 3U,季度最多抵 8U。",
a_en: "500 points minimum every 500 points = 1 USDC off, up to 3 USDC discount per payment.", a_en: "500 points minimum: every 500 points = 1 USDC off. Monthly orders can use up to 3 USDC off; quarterly orders can use up to 8 USDC off.",
}, },
{ {
q_zh: "支持哪些钱包和支付方式?", q_zh: "支持哪些钱包和支付方式?",
@@ -55,11 +50,11 @@ export function SubscriptionHelpClient() {
? "Complete subscription rules and payment guide." ? "Complete subscription rules and payment guide."
: "这里是完整的订阅规则和支付说明。你可以先在页面内绑定钱包,再直接开通 Pro。", : "这里是完整的订阅规则和支付说明。你可以先在页面内绑定钱包,再直接开通 Pro。",
priceLabel: isEn ? "Price" : "订阅价格", priceLabel: isEn ? "Price" : "订阅价格",
priceText: "10 USDC / 30 " + (isEn ? "Days" : "天"), priceText: isEn ? "29.9 / 30d · 79.9 / 90d" : "29.9 / 30天 · 79.9 / 90天",
discountLabel: isEn ? "Points Discount" : "积分抵扣", discountLabel: isEn ? "Points Discount" : "积分抵扣",
discountText: isEn ? "Up to 3 USDC off" : "最多抵 3U", discountText: isEn ? "Monthly 3U · Quarterly 8U" : "月付 3U · 季度 8U",
communityLabel: isEn ? "Community Points" : "社群积分", communityLabel: isEn ? "Telegram Group" : "Telegram 群",
communityLink: isEn ? "Join community to earn" : "加入社群即可赚取积分", communityLink: isEn ? "Open Account Center" : "前往账户中心",
faqTitle: isEn ? "FAQ" : "常见问题", faqTitle: isEn ? "FAQ" : "常见问题",
}), [isEn]); }), [isEn]);
@@ -102,8 +97,7 @@ export function SubscriptionHelpClient() {
<span className="text-sm font-semibold">{copy.communityLabel}</span> <span className="text-sm font-semibold">{copy.communityLabel}</span>
</div> </div>
<Link <Link
href={TELEGRAM_GROUP_URL} href="/account"
target="_blank"
className="inline-flex min-h-9 items-center text-sm font-semibold text-blue-700 underline decoration-blue-500/50 underline-offset-4" className="inline-flex min-h-9 items-center text-sm font-semibold text-blue-700 underline decoration-blue-500/50 underline-offset-4"
> >
{copy.communityLink} {copy.communityLink}
+12 -1
View File
@@ -1,5 +1,16 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard"; import dynamic from "next/dynamic";
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
const ScanTerminalDashboard = dynamic(
() =>
import("@/components/dashboard/ScanTerminalDashboard").then(
(mod) => mod.ScanTerminalDashboard,
),
{
loading: () => <DashboardShellSkeleton />,
},
);
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PolyWeather Terminal | Paid Product", title: "PolyWeather Terminal | Paid Product",
+525 -164
View File
@@ -84,6 +84,8 @@ export function AccountCenter() {
const [updatedAt, setUpdatedAt] = useState<string>(""); const [updatedAt, setUpdatedAt] = useState<string>("");
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [backend, setBackend] = useState<AuthMeResponse | null>(null); const [backend, setBackend] = useState<AuthMeResponse | null>(null);
const [referralCodeInput, setReferralCodeInput] = useState("");
const [referralApplying, setReferralApplying] = useState(false);
const supabaseReady = hasSupabasePublicEnv(); const supabaseReady = hasSupabasePublicEnv();
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID); const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
@@ -124,6 +126,7 @@ export function AccountCenter() {
boundWallets, boundWallets,
walletAddress, walletAddress,
selectedPlanCode, selectedPlanCode,
selectedPaymentChainId,
selectedTokenAddress, selectedTokenAddress,
selectedWallet, selectedWallet,
providerMode, providerMode,
@@ -133,6 +136,8 @@ export function AccountCenter() {
// Shared setters // Shared setters
setSelectedTokenAddress, setSelectedTokenAddress,
setSelectedPaymentChainId,
setSelectedPlanCode,
setSelectedWallet, setSelectedWallet,
setSelectedInjectedProviderKey, setSelectedInjectedProviderKey,
setProviderMode, setProviderMode,
@@ -149,6 +154,8 @@ export function AccountCenter() {
selectedPaymentToken, selectedPaymentToken,
selectedTokenLabel, selectedTokenLabel,
availableTokenList, availableTokenList,
availableChainList,
selectedPaymentChain,
effectivePlanList, effectivePlanList,
resolvedSelectedTokenAddress, resolvedSelectedTokenAddress,
paymentReceiverAddress, paymentReceiverAddress,
@@ -189,19 +196,49 @@ export function AccountCenter() {
useEffect(() => { useEffect(() => {
if (!authIsAuthenticated || !authUserId) return; if (!authIsAuthenticated || !authUserId) return;
const actorKey = authUserId.toLowerCase(); const actorKey = authUserId.toLowerCase();
const subscriptionPlanCode = String(backend?.subscription_plan_code || "").trim();
const subscriptionSource = String(backend?.subscription_source || "").trim();
const trialSubscription = Boolean(
backend?.subscription_is_trial === true ||
subscriptionPlanCode.toLowerCase().includes("trial") ||
subscriptionSource.toLowerCase().includes("trial"),
);
if (markAnalyticsOnce(`signup_completed:${actorKey}`, "local")) { if (markAnalyticsOnce(`signup_completed:${actorKey}`, "local")) {
trackAppEvent("signup_completed", { trackAppEvent("signup_completed", {
entry: "account_center", entry: "account_center",
user_id: authUserId, user_id: authUserId,
}); });
} }
if (markAnalyticsOnce(`signup_success:${actorKey}`, "local")) {
trackAppEvent("signup_success", {
entry: "account_center",
user_id: authUserId,
});
}
if (markAnalyticsOnce(`dashboard_active:${actorKey}`, "session")) { if (markAnalyticsOnce(`dashboard_active:${actorKey}`, "session")) {
trackAppEvent("dashboard_active", { trackAppEvent("dashboard_active", {
entry: "account_center", entry: "account_center",
user_id: authUserId, user_id: authUserId,
}); });
} }
}, [authIsAuthenticated, authUserId]); if (trialSubscription && markAnalyticsOnce(`trial_created:${actorKey}`, "local")) {
trackAppEvent("trial_created", {
entry: "account_center",
user_id: authUserId,
subscription_plan_code: subscriptionPlanCode || null,
subscription_source: subscriptionSource || null,
subscription_expires_at: backend?.subscription_expires_at || null,
subscription_is_trial: backend?.subscription_is_trial === true,
});
}
}, [
authIsAuthenticated,
authUserId,
backend?.subscription_expires_at,
backend?.subscription_is_trial,
backend?.subscription_plan_code,
backend?.subscription_source,
]);
// ── Idle callback effect ────────────────────────────── // ── Idle callback effect ──────────────────────────────
useEffect(() => { useEffect(() => {
@@ -286,7 +323,30 @@ export function AccountCenter() {
copy.guestUser; copy.guestUser;
const initials = (displayName.slice(0, 2) || "PW").toUpperCase(); const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
const joinedAt = formatTime(user?.created_at, locale); const joinedAt = formatTime(user?.created_at, locale);
const isSubscribed = Boolean(backend?.subscription_active); const backendAuthenticated = backend?.authenticated === true;
const localAuthenticated = Boolean(user?.id);
const isSubscriptionUnknown = Boolean(
localAuthenticated &&
(!backend ||
backend.subscription_active == null ||
backend.authenticated === false),
);
const isSubscribed = backend?.subscription_active === true;
const subscriptionSource = String(backend?.subscription_source || "").trim();
const isTrialSubscription = Boolean(
backend?.subscription_is_trial === true ||
String(backend?.subscription_plan_code || "").toLowerCase().includes("trial") ||
subscriptionSource.toLowerCase().includes("trial"),
);
const subscriptionStatusLabel = isSubscribed
? isTrialSubscription
? copy.trialBadge
: copy.proMember
: isSubscriptionUnknown
? copy.subscriptionChecking
: isEn
? "UNSUBSCRIBED"
: "未订阅";
const planCode = String(backend?.subscription_plan_code || "").trim(); const planCode = String(backend?.subscription_plan_code || "").trim();
const currentExpiryRaw = String( const currentExpiryRaw = String(
backend?.subscription_expires_at || backend?.subscription_expires_at ||
@@ -306,7 +366,8 @@ export function AccountCenter() {
const hasQueuedExtension = Boolean( const hasQueuedExtension = Boolean(
isSubscribed && queuedExtensionDays > 0, isSubscribed && queuedExtensionDays > 0,
); );
const canAccessPaidTelegramGroup = Boolean(isSubscribed); const canAccessPaidTelegramGroup = Boolean(isSubscribed && !isTrialSubscription);
const hasTelegramPanel = Boolean(isTrialSubscription || canAccessPaidTelegramGroup);
const telegramBound = const telegramBound =
Number(backend?.telegram_pricing?.telegram_id || 0) > 0; Number(backend?.telegram_pricing?.telegram_id || 0) > 0;
const displayExpiryRaw = isSubscribed const displayExpiryRaw = isSubscribed
@@ -323,6 +384,8 @@ export function AccountCenter() {
? expiryFormatted !== "--" ? expiryFormatted !== "--"
? expiryFormatted ? expiryFormatted
: displayExpiryRaw || copy.proPendingSync : displayExpiryRaw || copy.proPendingSync
: isSubscriptionUnknown
? copy.subscriptionUnknown
: copy.noProSubscription; : copy.noProSubscription;
const showExpiringSoon = Boolean( const showExpiringSoon = Boolean(
isSubscribed && isSubscribed &&
@@ -337,6 +400,7 @@ export function AccountCenter() {
const paymentFeatureReady = paymentReadyForRecovery; const paymentFeatureReady = paymentReadyForRecovery;
const canOpenCheckoutOverlay = Boolean( const canOpenCheckoutOverlay = Boolean(
paymentFeatureReady && paymentFeatureReady &&
!isSubscriptionUnknown &&
(!isSubscribed || showExpiringSoon || showExpiredReminder), (!isSubscribed || showExpiringSoon || showExpiredReminder),
); );
const subscriptionStatusTitle = showExpiredReminder const subscriptionStatusTitle = showExpiredReminder
@@ -362,6 +426,21 @@ export function AccountCenter() {
const expiryLabel = hasQueuedExtension const expiryLabel = hasQueuedExtension
? copy.accessUntil ? copy.accessUntil
: copy.renewalDate; : copy.renewalDate;
const displayPlanList = effectivePlanList.length
? effectivePlanList
: [
{ plan_code: "pro_monthly", plan_id: 101, amount_usdc: "29.9", duration_days: 30 },
{ plan_code: "pro_quarterly", plan_id: 102, amount_usdc: "79.9", duration_days: 90 },
];
const referral = backend?.referral;
const referralCode = String(referral?.code || "").trim();
const appliedReferralCode = String(referral?.applied_code || "").trim();
const canApplyReferralCode = Boolean(
isAuthenticated &&
!isSubscribed &&
!appliedReferralCode &&
referralCodeInput.trim(),
);
// ── Payment overlay tracking effect ────────────────────── // ── Payment overlay tracking effect ──────────────────────
useEffect(() => { useEffect(() => {
@@ -382,20 +461,31 @@ export function AccountCenter() {
showOverlay, showOverlay,
]); ]);
// ── Weekly points display (component-only derived) ────── // ── Referral points display ────────────────────────────
const backendWeeklyPointsRaw = Number(backend?.weekly_points); const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
const metadataWeeklyPointsRaw = Number( const referralRewardPoints = Number.isFinite(referralRewardPointsRaw)
user?.user_metadata?.weekly_points ?? 0, ? Math.max(0, referralRewardPointsRaw)
); : 3500;
const weeklyPointsRaw = Number.isFinite(backendWeeklyPointsRaw) const monthlyReferralCountRaw = Number(referral?.monthly_reward_count ?? 0);
? backendWeeklyPointsRaw const monthlyReferralCount = Number.isFinite(monthlyReferralCountRaw)
: metadataWeeklyPointsRaw; ? Math.max(0, monthlyReferralCountRaw)
const weeklyRankRaw =
backend?.weekly_rank ?? user?.user_metadata?.weekly_rank;
const weeklyPoints = Number.isFinite(weeklyPointsRaw)
? Math.max(0, weeklyPointsRaw)
: 0; : 0;
const weeklyRank = weeklyRankRaw == null ? "--" : String(weeklyRankRaw); const monthlyReferralLimitRaw = Number(referral?.monthly_reward_limit ?? 10);
const monthlyReferralLimit = Number.isFinite(monthlyReferralLimitRaw)
? Math.max(0, monthlyReferralLimitRaw)
: 10;
const monthlyReferralPointsRaw = Number(
referral?.monthly_reward_points ?? monthlyReferralCount * referralRewardPoints,
);
const monthlyReferralPoints = Number.isFinite(monthlyReferralPointsRaw)
? Math.max(0, monthlyReferralPointsRaw)
: 0;
const monthlyReferralPointsLimitRaw = Number(
referral?.monthly_reward_points_limit ?? monthlyReferralLimit * referralRewardPoints,
);
const monthlyReferralPointsLimit = Number.isFinite(monthlyReferralPointsLimitRaw)
? Math.max(0, monthlyReferralPointsLimitRaw)
: monthlyReferralLimit * referralRewardPoints;
// ── Telegram bind command ────────────────────────────── // ── Telegram bind command ──────────────────────────────
const bindCommand = userId const bindCommand = userId
@@ -410,6 +500,55 @@ export function AccountCenter() {
}); });
}; };
const applyReferralCode = useCallback(async () => {
const code = referralCodeInput.trim();
if (!code || referralApplying) return;
setReferralApplying(true);
setPaymentError("");
setPaymentInfo("");
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (supabaseReady) {
const {
data: { session },
} = await getSupabaseBrowserClient().auth.getSession();
const token = String(session?.access_token || "").trim();
if (token) headers.Authorization = `Bearer ${token}`;
}
const res = await fetch("/api/auth/referral/apply", {
method: "POST",
headers,
body: JSON.stringify({ code }),
});
if (!res.ok) {
const raw = (await res.text()).slice(0, 240);
throw new Error(raw || copy.referralApplyFailed);
}
setPaymentInfo(copy.referralApplied);
setReferralCodeInput("");
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
setPaymentError(
error instanceof Error ? error.message : copy.referralApplyFailed,
);
} finally {
setReferralApplying(false);
}
}, [
copy.referralApplied,
copy.referralApplyFailed,
loadPaymentSnapshot,
loadSnapshot,
referralApplying,
referralCodeInput,
setPaymentError,
setPaymentInfo,
supabaseReady,
]);
// ── Render ──────────────────────────────────────────── // ── Render ────────────────────────────────────────────
if (loading && !refreshing) { if (loading && !refreshing) {
@@ -533,7 +672,13 @@ export function AccountCenter() {
{initials} {initials}
</div> </div>
<div <div
className={`absolute -bottom-2 -right-2 rounded-lg border-4 border-white p-1.5 ${isSubscribed ? "bg-amber-400 text-slate-950" : "bg-slate-100 text-slate-400"}`} className={`absolute -bottom-2 -right-2 rounded-lg border-4 border-white p-1.5 ${
isSubscribed
? "bg-amber-400 text-slate-950"
: isSubscriptionUnknown
? "bg-blue-100 text-blue-500"
: "bg-slate-100 text-slate-400"
}`}
> >
<Crown size={16} fill="currentColor" /> <Crown size={16} fill="currentColor" />
</div> </div>
@@ -542,13 +687,15 @@ export function AccountCenter() {
<div className="flex items-center justify-center md:justify-start gap-3 mb-1"> <div className="flex items-center justify-center md:justify-start gap-3 mb-1">
<h2 className="text-3xl font-bold text-slate-950">{displayName}</h2> <h2 className="text-3xl font-bold text-slate-950">{displayName}</h2>
<span <span
className={`rounded-full border px-2 py-0.5 text-[10px] font-black uppercase ${isSubscribed ? "border-blue-200 bg-blue-50 text-blue-700" : "border-slate-200 bg-slate-50 text-slate-500"}`} className={`rounded-full border px-2 py-0.5 text-[10px] font-black uppercase ${
isSubscribed
? "border-blue-200 bg-blue-50 text-blue-700"
: isSubscriptionUnknown
? "border-blue-200 bg-blue-50 text-blue-600"
: "border-slate-200 bg-slate-50 text-slate-500"
}`}
> >
{isSubscribed {subscriptionStatusLabel}
? copy.proMember
: isEn
? "UNSUBSCRIBED"
: "未订阅"}
</span> </span>
</div> </div>
<p className="mb-4 font-mono text-sm text-slate-500"> <p className="mb-4 font-mono text-sm text-slate-500">
@@ -585,7 +732,7 @@ export function AccountCenter() {
</p> </p>
<p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950"> <p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950">
<TrendingUp size={16} className="text-emerald-400" />{" "} <TrendingUp size={16} className="text-emerald-400" />{" "}
{weeklyPoints.toLocaleString()} {monthlyReferralCount.toLocaleString()}
</p> </p>
</div> </div>
<div className="min-w-[140px] rounded-xl border border-blue-200 bg-blue-50 px-6 py-4 text-center"> <div className="min-w-[140px] rounded-xl border border-blue-200 bg-blue-50 px-6 py-4 text-center">
@@ -594,13 +741,13 @@ export function AccountCenter() {
</p> </p>
<p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950"> <p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950">
<Trophy size={16} className="text-amber-400" />{" "} <Trophy size={16} className="text-amber-400" />{" "}
{weeklyRank === "--" ? weeklyRank : `#${weeklyRank}`} {monthlyReferralCount}/{monthlyReferralLimit}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* Weekly Ranking Motivation */} {/* Referral rewards */}
{showSecondarySections ? ( {showSecondarySections ? (
<div className="flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:col-span-4"> <div className="flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:col-span-4">
<div> <div>
@@ -611,35 +758,29 @@ export function AccountCenter() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3"> <div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
<span className="text-sm flex items-center gap-2"> <span className="text-sm flex items-center gap-2">
<div className="w-5 h-5 bg-yellow-500 rounded text-black font-bold text-[10px] flex items-center justify-center"> <Coins size={16} className="text-yellow-500" />{" "}
1 {copy.referralRewardHint}
</div>{" "}
Top 1
</span> </span>
<span className="text-xs font-bold text-amber-600"> <span className="text-xs font-bold text-amber-600">
+200 & 7Pro +{referralRewardPoints.toLocaleString()}
</span> </span>
</div> </div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3"> <div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
<span className="text-sm flex items-center gap-2"> <span className="text-sm flex items-center gap-2">
<div className="w-5 h-5 bg-slate-300 rounded text-black font-bold text-[10px] flex items-center justify-center"> <TrendingUp size={16} className="text-emerald-500" />{" "}
2 {copy.weeklyPoints}
</div>{" "}
Top 2-3
</span> </span>
<span className="text-xs font-bold text-slate-600"> <span className="text-xs font-bold text-slate-600">
+100 {monthlyReferralCount}/{monthlyReferralLimit}
</span> </span>
</div> </div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3"> <div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
<span className="text-sm flex items-center gap-2"> <span className="text-sm flex items-center gap-2">
<div className="w-5 h-5 bg-orange-800 rounded text-white font-bold text-[10px] flex items-center justify-center"> <Trophy size={16} className="text-blue-500" />{" "}
4 {copy.totalPoints}
</div>{" "}
Top 4-10
</span> </span>
<span className="text-xs font-bold text-orange-400"> <span className="text-xs font-bold text-orange-400">
+50 {monthlyReferralPoints.toLocaleString()}/{monthlyReferralPointsLimit.toLocaleString()}
</span> </span>
</div> </div>
</div> </div>
@@ -647,8 +788,7 @@ export function AccountCenter() {
<div className="mt-6 flex items-start gap-2 rounded-xl border border-slate-200 bg-slate-50 p-3"> <div className="mt-6 flex items-start gap-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
<Info size={14} className="text-slate-500 mt-0.5 shrink-0" /> <Info size={14} className="text-slate-500 mt-0.5 shrink-0" />
<p className="text-[10px] text-slate-500 leading-normal italic"> <p className="text-[10px] text-slate-500 leading-normal italic">
+ {copy.pointsRule}
</p> </p>
</div> </div>
</div> </div>
@@ -685,19 +825,37 @@ export function AccountCenter() {
<InfoRow <InfoRow
icon={Zap} icon={Zap}
label={copy.intradayAnalysis} label={copy.intradayAnalysis}
value={isSubscribed ? copy.deepMode : copy.compactVisible} value={
isSubscriptionUnknown
? copy.subscriptionChecking
: isSubscribed
? copy.deepMode
: copy.compactVisible
}
isPrimary={isSubscribed} isPrimary={isSubscribed}
/> />
<InfoRow <InfoRow
icon={Clock} icon={Clock}
label={copy.historyFuture} label={copy.historyFuture}
value={isSubscribed ? copy.enabled : copy.locked} value={
isSubscriptionUnknown
? copy.subscriptionChecking
: isSubscribed
? copy.enabled
: copy.locked
}
isPrimary={isSubscribed} isPrimary={isSubscribed}
/> />
<InfoRow <InfoRow
icon={Bot} icon={Bot}
label={copy.smartPush} label={copy.smartPush}
value={isSubscribed ? copy.enabled : copy.locked} value={
isSubscriptionUnknown
? copy.subscriptionChecking
: isSubscribed
? copy.enabled
: copy.locked
}
isPrimary={isSubscribed} isPrimary={isSubscribed}
/> />
</section> </section>
@@ -724,7 +882,13 @@ export function AccountCenter() {
<InfoRow <InfoRow
icon={UserCheck} icon={UserCheck}
label={copy.authResult} label={copy.authResult}
value={backend?.authenticated ? copy.passed : copy.restricted} value={
backendAuthenticated
? copy.passed
: isSubscriptionUnknown
? copy.subscriptionChecking
: copy.restricted
}
/> />
{queuedExtensionSummary ? ( {queuedExtensionSummary ? (
<p className="rounded-xl border border-cyan-200 bg-cyan-50 px-4 py-3 text-xs text-cyan-800"> <p className="rounded-xl border border-cyan-200 bg-cyan-50 px-4 py-3 text-xs text-cyan-800">
@@ -760,7 +924,7 @@ export function AccountCenter() {
errorText={paymentError || undefined} errorText={paymentError || undefined}
infoText={paymentInfo || undefined} infoText={paymentInfo || undefined}
txHash={lastTxHash || undefined} txHash={lastTxHash || undefined}
chainId={paymentConfig?.chain_id || 137} chainId={selectedPaymentChainId || paymentConfig?.chain_id || 137}
paymentTokenLabel={selectedTokenLabel} paymentTokenLabel={selectedTokenLabel}
faqHref={SUBSCRIPTION_HELP_HREF} faqHref={SUBSCRIPTION_HELP_HREF}
telegramGroupUrl="" telegramGroupUrl=""
@@ -771,9 +935,38 @@ export function AccountCenter() {
{/* Telegram Bot Section & Payment Details */} {/* Telegram Bot Section & Payment Details */}
{showSecondarySections ? ( {showSecondarySections ? (
<div className="lg:col-span-12 grid grid-cols-1 md:flex gap-6"> <div
className={`lg:col-span-12 grid grid-cols-1 items-start gap-6 ${
hasTelegramPanel ? "xl:grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)]" : ""
}`}
>
{isTrialSubscription && (
<section className="group relative min-w-0 overflow-hidden rounded-2xl border border-amber-200 bg-amber-50 p-8 shadow-sm">
<Bot
size={140}
className="absolute -right-8 -bottom-8 -rotate-12 text-amber-100"
/>
<div className="relative z-10">
<h3 className="mb-2 flex items-center gap-2 text-lg font-bold text-amber-800">
<Bot size={22} /> {copy.telegramBind}
</h3>
<p className="text-sm leading-6 text-amber-900">
{copy.trialPaidGroupLocked}
</p>
<button
type="button"
onClick={() => setShowOverlay(true)}
disabled={!canOpenCheckoutOverlay}
className="mt-5 inline-flex items-center gap-2 rounded-xl border border-amber-700 bg-amber-600 px-4 py-3 text-xs font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Crown size={14} />
{copy.upgradePro}
</button>
</div>
</section>
)}
{canAccessPaidTelegramGroup && ( {canAccessPaidTelegramGroup && (
<section className="group relative flex-1 overflow-hidden rounded-2xl border border-slate-200 bg-white p-8 shadow-sm"> <section className="group relative min-w-0 overflow-hidden rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<Bot <Bot
size={140} size={140}
className="absolute -right-8 -bottom-8 -rotate-12 text-slate-100 transition-transform duration-1000 group-hover:rotate-0" className="absolute -right-8 -bottom-8 -rotate-12 text-slate-100 transition-transform duration-1000 group-hover:rotate-0"
@@ -851,11 +1044,7 @@ export function AccountCenter() {
)} )}
{/* Payment Details / Wallet Management */} {/* Payment Details / Wallet Management */}
<section <section className="w-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:p-7">
className={`flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-8 shadow-sm ${
canAccessPaidTelegramGroup ? "w-full md:w-96" : "w-full"
}`}
>
<div> <div>
<h3 className="mb-6 flex items-center gap-2 text-sm font-bold uppercase text-blue-700"> <h3 className="mb-6 flex items-center gap-2 text-sm font-bold uppercase text-blue-700">
<Wallet size={18} /> {copy.paymentMgmt} <Wallet size={18} /> {copy.paymentMgmt}
@@ -889,117 +1078,287 @@ export function AccountCenter() {
)} )}
</div> </div>
) : null} ) : null}
<div className="mb-5 space-y-3"> <div
<InfoRow data-testid="payment-management-grid"
icon={Mail} className={`grid gap-6 lg:items-start ${
label={copy.paymentAccount} hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"
value={email || "--"} }`}
isPrimary >
/> <div className="space-y-5">
<InfoRow <div>
icon={Wallet} <p className="mb-2 text-[11px] uppercase text-slate-500">
label={copy.paymentWallet} {copy.proPlan}
value={shortAddress(paymentWalletLabel) || "--"} </p>
/> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<InfoRow {displayPlanList.map((plan) => {
icon={ShieldCheck} const code = String(plan.plan_code || "");
label={copy.paymentReceiver} const active = code === selectedPlanCode;
value={shortAddress(paymentReceiverAddress) || "--"} const isQuarterly = code === "pro_quarterly";
/> return (
<InfoRow <button
icon={ExternalLink} type="button"
label={copy.paymentNetwork} key={code}
value={chainIdToDisplayName(paymentConfig?.chain_id)} onClick={() => setSelectedPlanCode(code)}
/> disabled={paymentBusy}
<InfoRow className={`rounded-xl border px-3 py-3 text-left transition-all ${
icon={ExternalLink} active
label={copy.paymentHost} ? "border-blue-300 bg-blue-50 text-blue-900"
value={currentPaymentHost || "--"} : "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
/> }`}
<p className="text-[11px] text-slate-500"> >
{copy.paymentGuardHint} <div className="flex items-center justify-between gap-2 text-xs font-bold">
</p> <span>
</div> {isQuarterly ? copy.quarterlyPlan : copy.monthlyPlan}
{availableTokenList.length > 0 && ( </span>
<div className="mb-5"> <span>{plan.amount_usdc} USDC</span>
<p className="mb-2 text-[11px] uppercase text-slate-500"> </div>
{copy.paymentToken} <div className="mt-1 text-[10px] opacity-80">
</p> {code} · {plan.duration_days}
<div className="grid grid-cols-2 gap-2"> </div>
{availableTokenList.map((token) => { </button>
const active = );
token.address === })}
(resolvedSelectedTokenAddress || </div>
token.address); {appliedReferralCode && selectedPlanCode === "pro_monthly" ? (
return ( <p className="mt-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-[11px] text-emerald-800">
<button {copy.referralDiscountHint}
type="button" </p>
key={token.address} ) : null}
onClick={() => </div>
setSelectedTokenAddress( <div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
token.address, <div className="mb-3 flex items-center justify-between gap-3">
) <p className="text-xs font-bold uppercase text-slate-700">
} {copy.referralTitle}
disabled={paymentBusy} </p>
className={`rounded-xl border px-3 py-2 text-left transition-all ${ <span className="text-[10px] text-slate-500">
active {copy.referralInviteLimit}
? "border-blue-300 bg-blue-50 text-blue-900" </span>
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50" </div>
}`} <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
> <div>
<div className="text-xs font-bold"> <p className="mb-1 text-[10px] uppercase text-slate-500">
{token.symbol} {copy.referralMyCode}
</p>
<div className="flex gap-2">
<code className="min-w-0 flex-1 truncate rounded-xl border border-slate-200 bg-white px-3 py-2 font-mono text-xs text-blue-700">
{referralCode || "--"}
</code>
{referralCode ? (
<button
type="button"
onClick={() => handleCopy(referralCode)}
className="rounded-xl border border-blue-700 bg-blue-600 px-3 text-xs font-bold text-white hover:bg-blue-700"
>
{copied ? <CheckCircle2 size={15} /> : <Copy size={15} />}
</button>
) : null}
</div>
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{copy.referralRewardHint}
</p>
</div>
<div>
<p className="mb-1 text-[10px] uppercase text-slate-500">
{copy.referralApplyLabel}
</p>
{appliedReferralCode ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-800">
{appliedReferralCode}
</div> </div>
<div className="text-[10px] opacity-80 truncate"> ) : (
{token.name} <div className="flex gap-2">
<input
value={referralCodeInput}
onChange={(event) => setReferralCodeInput(event.target.value)}
placeholder={copy.referralApplyPlaceholder}
className="min-w-0 flex-1 rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs text-slate-950 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
/>
<button
type="button"
onClick={() => void applyReferralCode()}
disabled={!canApplyReferralCode || referralApplying}
className="rounded-xl border border-slate-900 bg-slate-900 px-3 text-xs font-bold text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{referralApplying ? "..." : copy.referralApplyButton}
</button>
</div> </div>
</button> )}
); <p className="mt-2 text-[11px] leading-5 text-slate-500">
})} {copy.referralDiscountHint}
</p>
</div>
</div>
</div>
<div
data-testid="payment-guard-grid"
className={`grid gap-3 ${hasTelegramPanel ? "" : "sm:grid-cols-2"}`}
>
<InfoRow
icon={Mail}
label={copy.paymentAccount}
value={email || "--"}
isPrimary
/>
<InfoRow
icon={Wallet}
label={copy.paymentWallet}
value={shortAddress(paymentWalletLabel) || "--"}
/>
<InfoRow
icon={ShieldCheck}
label={copy.paymentReceiver}
value={shortAddress(paymentReceiverAddress) || "--"}
/>
<InfoRow
icon={ExternalLink}
label={copy.paymentNetwork}
value={
selectedPaymentChain?.name ||
chainIdToDisplayName(selectedPaymentChainId)
}
/>
<InfoRow
icon={ExternalLink}
label={copy.paymentHost}
value={currentPaymentHost || "--"}
/>
<p className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-[11px] leading-5 text-slate-500 sm:col-span-2">
{copy.paymentGuardHint}
</p>
</div> </div>
</div> </div>
)} <div className="space-y-5">
{availableChainList.length > 1 && (
{/* Payment Method Tabs */} <div>
<div className="mt-6 border-t border-slate-200 pt-6"> <p className="mb-2 text-[11px] uppercase text-slate-500">
<p className="mb-3 text-[10px] font-semibold uppercase text-slate-500"> {copy.paymentNetwork}
{copy.paymentMethodLabel} </p>
</p> <div className="grid grid-cols-2 gap-2">
<div className="mb-5 grid grid-cols-2 gap-2 rounded-xl border border-slate-200 bg-slate-100 p-1"> {availableChainList.map((chain) => {
<button const active = chain.chain_id === selectedPaymentChainId;
type="button" return (
onClick={() => setPaymentMethodTab("wallet")} <button
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${ type="button"
paymentMethodTab === "wallet" key={chain.chain_id}
? "bg-white text-blue-700 shadow-sm" onClick={() => {
: "text-slate-500 hover:bg-white/70 hover:text-slate-900" setSelectedPaymentChainId(chain.chain_id);
}`} const nextToken =
> paymentConfig?.tokens?.find(
<Wallet size={12} /> (token) =>
{copy.paymentMethodWallet} Number(token.chain_id || chain.chain_id) ===
</button> chain.chain_id &&
<button token.is_default,
type="button" ) ||
onClick={() => setPaymentMethodTab("manual")} paymentConfig?.tokens?.find(
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${ (token) =>
paymentMethodTab === "manual" Number(token.chain_id || chain.chain_id) ===
? "bg-white text-emerald-700 shadow-sm" chain.chain_id,
: "text-slate-500 hover:bg-white/70 hover:text-slate-900" );
}`} if (nextToken?.address) {
> setSelectedTokenAddress(
<ExternalLink size={12} /> String(nextToken.address).toLowerCase(),
{copy.paymentMethodManual} );
</button> }
</div> }}
disabled={paymentBusy}
{paymentMethodTab === "wallet" ? ( className={`rounded-xl border px-3 py-2 text-left transition-all ${
<div className="space-y-4"> active
<p className="text-[11px] leading-relaxed text-slate-400"> ? "border-blue-300 bg-blue-50 text-blue-900"
{copy.paymentWalletDesc} : "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
</p> }`}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-[11px] leading-relaxed text-amber-800"> >
{copy.paymentGasWarning} <div className="text-xs font-bold">
{chain.name || chainIdToDisplayName(chain.chain_id)}
</div>
<div className="text-[10px] opacity-80">
{chain.native_currency_symbol || "ETH"} gas
</div>
</button>
);
})}
</div>
</div> </div>
)}
{availableTokenList.length > 0 && (
<div>
<p className="mb-2 text-[11px] uppercase text-slate-500">
{copy.paymentToken}
</p>
<div className="grid grid-cols-2 gap-2">
{availableTokenList.map((token) => {
const active =
token.address ===
(resolvedSelectedTokenAddress ||
token.address);
return (
<button
type="button"
key={`${token.chain_id || selectedPaymentChainId}:${token.address}`}
onClick={() =>
setSelectedTokenAddress(
token.address,
)
}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-2 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="text-xs font-bold">
{token.symbol}
</div>
<div className="truncate text-[10px] opacity-80">
{token.name}
</div>
</button>
);
})}
</div>
</div>
)}
{/* Payment Method Tabs */}
<div className="border-t border-slate-200 pt-5">
<p className="mb-3 text-[10px] font-semibold uppercase text-slate-500">
{copy.paymentMethodLabel}
</p>
<div className="mb-5 grid grid-cols-2 gap-2 rounded-xl border border-slate-200 bg-slate-100 p-1">
<button
type="button"
onClick={() => setPaymentMethodTab("wallet")}
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
paymentMethodTab === "wallet"
? "bg-white text-blue-700 shadow-sm"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
}`}
>
<Wallet size={12} />
{copy.paymentMethodWallet}
</button>
<button
type="button"
onClick={() => setPaymentMethodTab("manual")}
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
paymentMethodTab === "manual"
? "bg-white text-emerald-700 shadow-sm"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
}`}
>
<ExternalLink size={12} />
{copy.paymentMethodManual}
</button>
</div>
{paymentMethodTab === "wallet" ? (
<div className="space-y-4">
<p className="text-[11px] leading-relaxed text-slate-400">
{copy.paymentWalletDesc}
</p>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-[11px] leading-relaxed text-amber-800">
{copy.paymentGasWarning}
</div>
{boundWallets.length ? ( {boundWallets.length ? (
<div className="space-y-3"> <div className="space-y-3">
@@ -1023,7 +1382,7 @@ export function AccountCenter() {
)} )}
</div> </div>
<div className="text-[10px]"> <div className="text-[10px]">
{copy.polygonChain} {chainIdToDisplayName(w.chain_id)}
</div> </div>
<div className="mt-2 flex justify-end"> <div className="mt-2 flex justify-end">
<button <button
@@ -1248,8 +1607,8 @@ export function AccountCenter() {
} }
disabled={ disabled={
paymentBusy || paymentBusy ||
(txValidation.checked && !(txValidation.checked &&
!txValidation.valid) txValidation.valid === true)
} }
className="w-full rounded-xl border border-emerald-700 bg-emerald-600 px-3 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-700 disabled:opacity-50" className="w-full rounded-xl border border-emerald-700 bg-emerald-600 px-3 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-700 disabled:opacity-50"
> >
@@ -1262,6 +1621,8 @@ export function AccountCenter() {
)} )}
</div> </div>
</div> </div>
</div>
</div>
</section> </section>
</div> </div>
) : ( ) : (
@@ -0,0 +1,63 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const accountDir = path.join(projectRoot, "components", "account");
const useAccountPaymentSource = fs.readFileSync(
path.join(accountDir, "useAccountPayment.ts"),
"utf8",
);
const usePaymentFlowSource = fs.readFileSync(
path.join(accountDir, "usePaymentFlow.ts"),
"utf8",
);
const useWalletBindSource = fs.readFileSync(
path.join(accountDir, "useWalletBind.ts"),
"utf8",
);
const accountCenterSource = fs.readFileSync(
path.join(accountDir, "AccountCenter.tsx"),
"utf8",
);
const opsPaymentsSource = fs.readFileSync(
path.join(projectRoot, "components", "ops", "payments", "PaymentsPageClient.tsx"),
"utf8",
);
assert(
useAccountPaymentSource.includes("selectedPaymentChainId") &&
useAccountPaymentSource.includes("setSelectedPaymentChainId"),
"account payment state must track the selected payment chain separately from the legacy default chain",
);
assert(
usePaymentFlowSource.includes("chain_id: selectedPaymentChainId") ||
usePaymentFlowSource.includes("chain_id: targetChainId"),
"payment intent creation must send the selected chain_id to the backend",
);
assert(
!usePaymentFlowSource.includes("请在 Polygon 网络转"),
"manual transfer instructions must not hard-code Polygon after Ethereum USDC is supported",
);
assert(
useWalletBindSource.includes("chainName") &&
useWalletBindSource.includes("wallet_addEthereumChain") &&
useWalletBindSource.includes("Ethereum Mainnet"),
"wallet network switching must use chain metadata instead of hard-coded Polygon-only add-network params",
);
assert(
accountCenterSource.includes("availableChainList") &&
accountCenterSource.includes("setSelectedPaymentChainId") &&
accountCenterSource.includes("paymentNetwork"),
"account center must expose a payment network selector when multiple chains are configured",
);
assert(
opsPaymentsSource.includes("etherscan.io") &&
opsPaymentsSource.includes("polygonscan.com"),
"ops payment tx links must route Ethereum payments to Etherscan and Polygon payments to Polygonscan",
);
}
@@ -0,0 +1,69 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const accountCenter = fs.readFileSync(
path.join(projectRoot, "components", "account", "AccountCenter.tsx"),
"utf8",
);
const accountCopy = fs.readFileSync(
path.join(projectRoot, "components", "account", "account-copy.ts"),
"utf8",
);
const useAccountPayment = fs.readFileSync(
path.join(projectRoot, "components", "account", "useAccountPayment.ts"),
"utf8",
);
const usePaymentFlow = fs.readFileSync(
path.join(projectRoot, "components", "account", "usePaymentFlow.ts"),
"utf8",
);
const types = fs.readFileSync(
path.join(projectRoot, "components", "account", "types.ts"),
"utf8",
);
assert(
accountCopy.includes("3天试用") &&
accountCopy.includes("付费 Telegram 群") &&
accountCopy.includes("邀请码"),
"account copy must describe trial limits and referral code UI",
);
assert(
accountCenter.includes("copy.trialPaidGroupLocked") &&
accountCenter.includes("copy.referralInviteLimit") &&
accountCenter.includes("applyReferralCode"),
"account center must expose trial paid-group gating and referral controls",
);
assert(
accountCenter.includes("pro_quarterly") &&
accountCenter.includes("79.9") &&
accountCenter.includes("29.9"),
"account center must show monthly and quarterly Pro prices",
);
assert(
accountCopy.includes("20 USDC") &&
accountCopy.includes("+3500 积分") &&
accountCopy.includes("月付订单最多抵扣 3 USDC") &&
accountCopy.includes("季度订单最多抵扣 8 USDC") &&
!accountCopy.includes("群内有效发言"),
"account copy must describe balanced referral points and remove group-message points",
);
assert(
!useAccountPayment.includes("monthlyPlanList") &&
!usePaymentFlow.includes("monthlyPlanList"),
"payment hooks must not filter checkout plans down to monthly only",
);
assert(
types.includes("ReferralSummary") &&
types.includes("referral?: ReferralSummary | null") &&
types.includes("duration_days: number") &&
types.includes("max_discount_usdc_by_plan"),
"account auth and payment types must include referral summary and plan durations",
);
}
@@ -71,6 +71,23 @@ export function runTests() {
paymentFlowSource.includes("assertExpectedPaymentReceiver"), paymentFlowSource.includes("assertExpectedPaymentReceiver"),
"AccountCenter must validate backend-returned manual payment receiver before displaying it", "AccountCenter must validate backend-returned manual payment receiver before displaying it",
); );
assert(
/!\(\s*txValidation\.checked\s*&&\s*txValidation\.valid === true\s*\)/.test(
accountCenterSource,
),
"manual payment submit button must require checked && valid === true",
);
assert(
paymentFlowSource.includes("validateTxHash") &&
paymentFlowSource.includes("/validate"),
"manual payment flow must validate tx hashes with the backend before submission",
);
assert(
paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") &&
paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") <
paymentFlowSource.indexOf("const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`"),
"wallet payment flow must wait for the payment tx receipt before submitting tx hash to the backend",
);
assert( assert(
accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") || accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") ||
hookSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") || hookSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") ||
@@ -83,6 +100,11 @@ export function runTests() {
backendAuthSource.includes("requireBackendAuthUser"), backendAuthSource.includes("requireBackendAuthUser"),
"backend auth helper must expose a real-user requirement for payment mutations", "backend auth helper must expose a real-user requirement for payment mutations",
); );
assert(
backendAuthSource.includes("requireBackendPaymentAuth") &&
backendAuthSource.includes("hasBearerAuth"),
"backend auth helper must allow bearer-backed payment mutations to reach the backend verifier",
);
const middlewareSource = fs.readFileSync(middlewarePath, "utf8"); const middlewareSource = fs.readFileSync(middlewarePath, "utf8");
assert( assert(
@@ -90,12 +112,113 @@ export function runTests() {
!middlewareSource.includes("return NextResponse.next();\n }\n }\n\n const response = NextResponse.next"), !middlewareSource.includes("return NextResponse.next();\n }\n }\n\n const response = NextResponse.next"),
"middleware must not treat the mere presence of a bearer token as authenticated", "middleware must not treat the mere presence of a bearer token as authenticated",
); );
assert(
middlewareSource.includes("function hasSupabaseSessionCookie") &&
middlewareSource.includes("request.cookies.getAll()") &&
middlewareSource.includes("hasSupabaseSessionCookieValues"),
"middleware must detect non-empty Supabase session cookies locally via the shared helper before refreshing auth",
);
assert(
middlewareSource.includes("redirectToLogin(request, pathname)") &&
middlewareSource.indexOf("if (!hasSupabaseSessionCookie(request))") <
middlewareSource.indexOf("await refreshMiddlewareSession(request)"),
"middleware must redirect no-cookie page requests without calling Supabase auth",
);
const terminalGateSource = middlewareSource.slice(
middlewareSource.indexOf("async function handleTerminalGate"),
middlewareSource.indexOf("async function handleSupabaseAuthGate"),
);
assert(
terminalGateSource.includes("return response;") &&
!terminalGateSource.includes("return redirectToLogin(request, pathname);\n}"),
"terminal middleware must not navigate long-lived dashboards away when a session cookie exists but claims refresh is transiently unavailable",
);
assert(
middlewareSource.includes("unauthorizedSupabaseSessionResponse()"),
"middleware must reject no-cookie protected API requests without calling Supabase auth",
);
const authMeRouteSource = fs.readFileSync(
path.join(projectRoot, "app", "api", "auth", "me", "route.ts"),
"utf8",
);
assert(
authMeRouteSource.includes("/auth/v1/user") &&
authMeRouteSource.includes("getVerifiedBearerIdentity") &&
authMeRouteSource.includes("degraded_auth_profile: true"),
"/api/auth/me must verify bearer tokens directly and return a degraded authenticated profile when the backend auth profile is transiently unavailable",
);
assert(
authMeRouteSource.includes("const identity = await getBearerIdentityOnce();") &&
!authMeRouteSource.includes("return buildProxyExceptionResponse(error"),
"/api/auth/me must try bearer identity fallback before returning a proxy exception",
);
assert(
authMeRouteSource.includes("exception_snapshot") &&
authMeRouteSource.includes("unauthenticatedAuthProfileResponse") &&
!authMeRouteSource.includes("return buildProxyExceptionResponse(error"),
"/api/auth/me must serve a snapshot or anonymous auth profile before surfacing proxy exceptions to long-lived clients",
);
for (const route of [
"app/api/ops/analytics/funnel/route.ts",
"app/api/ops/config/route.ts",
"app/api/ops/health-check/route.ts",
"app/api/ops/leaderboard/weekly/route.ts",
"app/api/ops/memberships/route.ts",
"app/api/ops/memberships/growth/route.ts",
"app/api/ops/memberships/overview/route.ts",
"app/api/ops/online-users/route.ts",
"app/api/ops/payments/incidents/route.ts",
"app/api/ops/payments/incidents/[eventId]/resolve/route.ts",
"app/api/ops/subscriptions/extend/route.ts",
"app/api/ops/subscriptions/grant/route.ts",
"app/api/ops/telegram/members-audit/route.ts",
"app/api/ops/training/accuracy/route.ts",
"app/api/ops/truth-history/route.ts",
"app/api/ops/users/route.ts",
"app/api/ops/users/grant-points/route.ts",
"app/api/ops/view-logs/route.ts",
]) {
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
assert(
routeSource.includes("requireOpsProxyAuth(req, auth)") &&
routeSource.indexOf("requireOpsProxyAuth(req, auth)") >
routeSource.indexOf("buildBackendRequestHeaders(req"),
`${route} must reject requests without Supabase identity before forwarding the backend entitlement token`,
);
}
assert(
middlewareSource.includes('pathname === "/api/payments/config"'),
"middleware must treat public payment config as public API so cached config requests do not refresh Supabase sessions",
);
const optionalRefreshFunction = middlewareSource.slice(
middlewareSource.indexOf("function shouldRefreshOptionalSupabaseSession"),
middlewareSource.indexOf("function hasSupabaseSessionCookie"),
);
assert(
!optionalRefreshFunction.includes('pathname.startsWith("/api/ops/")') &&
!optionalRefreshFunction.includes('pathname.startsWith("/api/payments/")'),
"optional Supabase middleware refresh must not pre-read sessions for API routes that already build backend auth headers",
);
const optionalRefreshIndex = middlewareSource.indexOf(
"function shouldRefreshOptionalSupabaseSession",
);
const systemStatusPublicIndex = middlewareSource.indexOf(
'pathname === "/api/system/status"',
);
assert(
systemStatusPublicIndex >= 0 &&
optionalRefreshIndex >= 0 &&
systemStatusPublicIndex < optionalRefreshIndex,
"middleware must treat public system status as public API instead of optional Supabase session refresh",
);
for (const route of paymentRoutes) { for (const route of paymentRoutes) {
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8"); const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
assert( assert(
routeSource.includes("requireBackendAuthUser"), routeSource.includes("requireBackendPaymentAuth") &&
`${route} must reject payment mutations without a real Supabase user`, !routeSource.includes("requireBackendAuthUser(auth)"),
`${route} must allow bearer-backed payment mutations while still rejecting requests with no auth context`,
); );
} }
} }
@@ -58,6 +58,10 @@ export function runTests() {
const hookSource = fs.existsSync(hookPath) const hookSource = fs.existsSync(hookPath)
? fs.readFileSync(hookPath, "utf8") ? fs.readFileSync(hookPath, "utf8")
: ""; : "";
const paymentFlowSource = fs.readFileSync(
path.join(accountDir, "usePaymentFlow.ts"),
"utf8",
);
assert( assert(
(accountCenterSource.includes('import { usePaymentState } from "./usePaymentState";') || (accountCenterSource.includes('import { usePaymentState } from "./usePaymentState";') ||
hookSource.includes('import { usePaymentState } from "./usePaymentState";')) && hookSource.includes('import { usePaymentState } from "./usePaymentState";')) &&
@@ -88,6 +92,30 @@ export function runTests() {
), ),
"utf8", "utf8",
); );
const authMeRouteSource = fs.readFileSync(
path.join(projectRoot, "app", "api", "auth", "me", "route.ts"),
"utf8",
);
const paymentConfigRouteSource = fs.readFileSync(
path.join(projectRoot, "app", "api", "payments", "config", "route.ts"),
"utf8",
);
const paymentRuntimeRouteSource = fs.readFileSync(
path.join(projectRoot, "app", "api", "payments", "runtime", "route.ts"),
"utf8",
);
const backendAuthSource = fs.readFileSync(
path.join(projectRoot, "lib", "backend-auth.ts"),
"utf8",
);
const opsAdminSource = fs.readFileSync(
path.join(projectRoot, "lib", "ops-admin.ts"),
"utf8",
);
const supabaseServerSource = fs.readFileSync(
path.join(projectRoot, "lib", "supabase", "server.ts"),
"utf8",
);
const subscriptionsPageSource = fs.readFileSync( const subscriptionsPageSource = fs.readFileSync(
path.join( path.join(
projectRoot, projectRoot,
@@ -98,6 +126,26 @@ export function runTests() {
), ),
"utf8", "utf8",
); );
const membershipsPageSource = fs.readFileSync(
path.join(
projectRoot,
"components",
"ops",
"memberships",
"MembershipsPageClient.tsx",
),
"utf8",
);
const opsOverviewSource = fs.readFileSync(
path.join(
projectRoot,
"components",
"ops",
"overview",
"OverviewPageClient.tsx",
),
"utf8",
);
assert( assert(
accountCenterSource.includes( accountCenterSource.includes(
@@ -129,6 +177,22 @@ export function runTests() {
accountFeatureSource.includes("只有 USDC 可能无法完成授权或支付"), accountFeatureSource.includes("只有 USDC 可能无法完成授权或支付"),
"payment wallet tab must warn users that Polygon POL gas is required in addition to USDC", "payment wallet tab must warn users that Polygon POL gas is required in addition to USDC",
); );
assert(
!accountCenterSource.includes("md:w-96"),
"account payment management must not use a narrow fixed sidebar that creates an overlong column",
);
assert(
accountCenterSource.includes("items-start") &&
accountCenterSource.includes("xl:grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)]"),
"account secondary sections must align cards to the top and give payment management a wider responsive column",
);
assert(
accountCenterSource.includes("data-testid=\"payment-management-grid\"") &&
accountCenterSource.includes('hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"') &&
accountCenterSource.includes("data-testid=\"payment-guard-grid\"") &&
accountCenterSource.includes('hasTelegramPanel ? "" : "sm:grid-cols-2"'),
"payment management must only split into internal columns when it is not already sharing the row with a Telegram panel",
);
assert( assert(
!appAnalyticsSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"') && !appAnalyticsSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"') &&
!analyticsRouteSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"'), !analyticsRouteSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"'),
@@ -139,6 +203,53 @@ export function runTests() {
accountCenterSource.includes('trackAppEvent("dashboard_active"'), accountCenterSource.includes('trackAppEvent("dashboard_active"'),
"account center must emit signup_completed and dashboard_active so the ops funnel has top-of-funnel data", "account center must emit signup_completed and dashboard_active so the ops funnel has top-of-funnel data",
); );
assert(
appAnalyticsSource.includes('| "landing_view"') &&
appAnalyticsSource.includes('| "enter_terminal"') &&
appAnalyticsSource.includes('| "login_start"') &&
appAnalyticsSource.includes('| "signup_success"') &&
appAnalyticsSource.includes('| "trial_created"') &&
appAnalyticsSource.includes('| "payment_start"') &&
appAnalyticsSource.includes('| "payment_success"'),
"app analytics must expose the standard growth funnel event names",
);
assert(
accountCenterSource.includes('trackAppEvent("signup_success"') &&
accountCenterSource.includes('trackAppEvent("trial_created"') &&
accountCenterSource.includes("subscription_is_trial"),
"account center must emit signup_success and trial_created for the standard funnel",
);
assert(
paymentFlowSource.includes('trackAppEvent("payment_start"') &&
paymentFlowSource.includes('trackAppEvent("payment_success"'),
"payment flow must emit payment_start and payment_success alongside legacy checkout events",
);
assert(
accountCenterSource.includes("isSubscriptionUnknown") &&
accountCenterSource.includes("subscriptionStatusLabel") &&
!accountCenterSource.includes(
'backend?.subscription_active);',
),
"account center must distinguish unknown subscription sync state from a confirmed unsubscribed account",
);
assert(
hookSource.includes("backendJson.authenticated === false") &&
hookSource.includes("refreshSession()") &&
hookSource.includes("retriedBackendJson"),
"account snapshot loader must retry with a refreshed Supabase token when local user exists but /api/auth/me reports unauthenticated",
);
assert(
hookSource.includes("refreshEntitlementAfterPayment") &&
paymentFlowSource.includes("refreshEntitlementAfterPayment") &&
paymentFlowSource.includes("await refreshEntitlementAfterPayment();") &&
hookSource.includes("subscription_active === true"),
"successful payment flows must automatically poll /api/auth/me until the paid subscription is visible instead of requiring logout or manual refresh",
);
assert(
!hookSource.includes(".auth.getUser()") &&
hookSource.includes(".auth.getSession()"),
"account snapshot loader must use the local Supabase session instead of calling getUser before /api/auth/me validates the bearer",
);
assert( assert(
subscriptionsPageSource.includes("getSupabaseBrowserClient") && subscriptionsPageSource.includes("getSupabaseBrowserClient") &&
subscriptionsPageSource.includes("refreshSession") && subscriptionsPageSource.includes("refreshSession") &&
@@ -147,10 +258,125 @@ export function runTests() {
subscriptionsPageSource.includes("/api/ops/subscriptions/extend"), subscriptionsPageSource.includes("/api/ops/subscriptions/extend"),
"ops manual subscription grant/extend must send the Supabase bearer token to avoid 401 when route cookies are stale", "ops manual subscription grant/extend must send the Supabase bearer token to avoid 401 when route cookies are stale",
); );
assert(
membershipsPageSource.includes("opsApi.membershipsOverview") &&
!membershipsPageSource.includes('fetch("/api/ops/memberships/growth?days=90"') &&
!membershipsPageSource.includes("Promise.all"),
"ops memberships page must load memberships and growth through one overview proxy request to avoid duplicate Supabase session/subscription reads",
);
assert(
opsOverviewSource.includes("opsApi.membershipsOverview(200, 30)") &&
!opsOverviewSource.includes("opsApi.memberships()") &&
!opsOverviewSource.includes("opsApi.membershipsGrowth(30)"),
"ops overview page must reuse membershipsOverview for table and growth data instead of issuing separate membership/growth proxy requests",
);
assert( assert(
grantRouteSource.includes("grantSubscriptionDirectly") && grantRouteSource.includes("grantSubscriptionDirectly") &&
grantRouteSource.includes("res.status === 404") && grantRouteSource.includes("res.status === 404") &&
grantRouteSource.includes('"status": "active"'), grantRouteSource.includes('"status": "active"') &&
"ops subscription grant route must fall back to direct Supabase grant when the VPS backend route is missing", grantRouteSource.includes("/rest/v1/profiles") &&
grantRouteSource.includes("select=id&email=eq.") &&
grantRouteSource.indexOf("/rest/v1/profiles") <
grantRouteSource.indexOf("/auth/v1/admin/users") &&
grantRouteSource.includes('Prefer: "return=minimal"'),
"ops subscription grant route must fall back to direct Supabase grant and resolve users via indexed profiles before Auth Admin",
);
assert(
authMeRouteSource.includes("isSubscriptionRequiredBackendResponse(res.status, raw)") &&
authMeRouteSource.includes("subscriptionRequiredAuthProfileResponse") &&
authMeRouteSource.includes("degradedAuthProfileResponse") &&
authMeRouteSource.includes("reason: `backend_${res.status}`") &&
authMeRouteSource.includes("subscription_active: null"),
"auth profile proxy must only convert explicit subscription-required 403 responses to inactive access while preserving unrelated backend 401/403 responses as unknown subscription",
);
assert(
(authMeRouteSource.match(/buildBackendRequestHeaders\(req\)/g) || []).length === 1 &&
authMeRouteSource.includes("let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null"),
"auth profile proxy must build backend auth headers once and reuse them on timeout/error fallback",
);
assert(
authMeRouteSource.includes("hasSupabaseServerEnv()") &&
authMeRouteSource.includes("!auth.authUserId") &&
authMeRouteSource.includes('req.headers.get("authorization")') &&
authMeRouteSource.indexOf("authenticated: false") <
authMeRouteSource.indexOf("await fetch(`${API_BASE}/api/auth/me`"),
"auth profile proxy must return unauthenticated locally for no-session Supabase requests instead of forwarding the backend entitlement token",
);
assert(
authMeRouteSource.includes('reason: "prefer_snapshot_fast_path"') &&
authMeRouteSource.indexOf('reason: "prefer_snapshot_fast_path"') <
authMeRouteSource.indexOf("buildBackendRequestHeaders(req)"),
"auth profile proxy must return a valid entitlement snapshot before reading Supabase session cookies on terminal cold start",
);
assert(
backendAuthSource.includes("if (incomingAuth) {") &&
backendAuthSource.indexOf("if (incomingAuth) {") <
backendAuthSource.indexOf("const supabase = createSupabaseRouteClient"),
"backend proxy must forward caller bearer tokens before creating a Supabase route client to avoid duplicate getUser calls",
);
assert(
backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") &&
backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_EMAIL_HEADER") &&
backendAuthSource.indexOf("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") >
backendAuthSource.indexOf("const sessionUser = session?.user"),
"backend proxy must forward Supabase session user id/email with the backend token so Python can skip duplicate /auth/v1/user validation",
);
assert(
backendAuthSource.includes("function hasSupabaseSessionCookie") &&
backendAuthSource.includes("String(cookie.value || \"\").trim()") &&
backendAuthSource.includes("if (!hasSupabaseSessionCookie(request))") &&
backendAuthSource.indexOf("if (!hasSupabaseSessionCookie(request))") <
backendAuthSource.indexOf("const supabase = createSupabaseRouteClient"),
"backend proxy must skip Supabase route client/session reads when no auth cookie is present",
);
assert(
!backendAuthSource.includes(".auth.getUser()") &&
backendAuthSource.includes(".auth.getSession()"),
"backend proxy must not call Supabase getUser before forwarding a bearer token that the backend validates again",
);
assert(
supabaseServerSource.includes("export function hasSupabaseSessionCookieValues") &&
supabaseServerSource.includes('name === "supabase-auth-token"') &&
supabaseServerSource.includes('name.startsWith("sb-")') &&
supabaseServerSource.indexOf("if (!hasSupabaseSessionCookieValues") <
supabaseServerSource.indexOf("const supabase = createSupabaseServerClient"),
"Supabase server helpers must expose one cookie detector and skip refresh getUser calls when no session cookie exists",
);
assert(
supabaseServerSource.includes(".auth.getClaims()") &&
!supabaseServerSource.includes(".auth.getUser()"),
"Supabase middleware refresh must validate JWTs with getClaims so asymmetric JWT projects avoid per-request /auth/v1/user reads",
);
assert(
opsAdminSource.includes("hasSupabaseSessionCookieValues") &&
opsAdminSource.indexOf("if (!hasSupabaseSessionCookieValues") <
opsAdminSource.indexOf("const supabase = createSupabaseServerClient"),
"ops admin page gate must redirect before creating a Supabase client/getUser call when no session cookie exists",
);
assert(
opsAdminSource.includes(".auth.getClaims()") &&
!opsAdminSource.includes(".auth.getUser()") &&
opsAdminSource.includes("claims?.email"),
"ops admin page gate must use verified JWT claims instead of a per-page getUser auth lookup",
);
assert(
paymentConfigRouteSource.includes("includeSupabaseIdentity: false"),
"payment config proxy must not read Supabase session cookies for public cached config",
);
assert(
paymentRuntimeRouteSource.includes("includeSupabaseIdentity: false"),
"payment runtime proxy must not read Supabase session cookies because backend entitlement token already protects the runtime status payload",
);
assert(
(paymentFlowSource.match(/await buildAuthedHeaders\(true, true\);/g) || [])
.length >= 3,
"manual payment mutations must require a valid Supabase bearer token instead of forwarding unauthenticated requests that surface raw backend JSON",
);
assert(
paymentFlowSource.includes("verifyPaymentAuthReady") &&
paymentFlowSource.indexOf("await verifyPaymentAuthReady()") <
paymentFlowSource.indexOf("resolvePaymentProvider(") &&
paymentFlowSource.includes("auth_confirmed_at"),
"wallet checkout must confirm a fresh Supabase bearer before opening wallet prompts or creating payment intents",
); );
} }
+38 -9
View File
@@ -10,9 +10,9 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
guestUser: isEn ? "Signed-out account" : "未登录账户", guestUser: isEn ? "Signed-out account" : "未登录账户",
joinedAt: isEn ? "Joined" : "加入时间", joinedAt: isEn ? "Joined" : "加入时间",
totalPoints: isEn ? "Total Points" : "总积分 (荣誉)", totalPoints: isEn ? "Total Points" : "总积分 (荣誉)",
weeklyPoints: isEn ? "Weekly Points" : "本周积分 (竞技)", weeklyPoints: isEn ? "This Month Invites" : "本月有效邀请",
weeklyRank: isEn ? "Weekly Rank" : "周排行 (竞技)", weeklyRank: isEn ? "Monthly Cap" : "月度上限",
weeklyRewards: isEn ? "Weekly Rewards" : "周榜奖励", weeklyRewards: isEn ? "Referral Rewards" : "邀请奖励",
membershipDetails: isEn ? "Membership Details" : "会员权限详情", membershipDetails: isEn ? "Membership Details" : "会员权限详情",
identityStatus: isEn ? "Identity Status" : "身份状态", identityStatus: isEn ? "Identity Status" : "身份状态",
authMode: isEn ? "Auth Mode" : "鉴权模式", authMode: isEn ? "Auth Mode" : "鉴权模式",
@@ -57,6 +57,31 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
: "城市实测温度群", : "城市实测温度群",
copyCommand: isEn ? "Copy fallback command" : "复制兜底命令", copyCommand: isEn ? "Copy fallback command" : "复制兜底命令",
paymentMgmt: isEn ? "Payment Management" : "支付管理", paymentMgmt: isEn ? "Payment Management" : "支付管理",
proPlan: isEn ? "Pro Plan" : "Pro 套餐",
monthlyPlan: isEn ? "Monthly" : "月付",
quarterlyPlan: isEn ? "Quarterly" : "季度",
trialBadge: isEn ? "3-day trial" : "3天试用",
trialPaidGroupLocked: isEn
? "Trial users can use the core product experience, but the paid Telegram group is available to full Pro subscriptions only."
: "3天试用用户可体验核心产品,但无法进入付费 Telegram 群;付费群仅对正式 Pro 开放。",
referralTitle: isEn ? "Referral Code" : "邀请码",
referralMyCode: isEn ? "My invite code" : "我的邀请码",
referralApplyLabel: isEn ? "Use invite code" : "使用邀请码",
referralApplyPlaceholder: isEn ? "Enter invite code" : "输入邀请码",
referralApplyButton: isEn ? "Apply" : "应用",
referralApplied: isEn
? "Referral code applied. Your first monthly payment is now discounted."
: "邀请码已应用,首月 Pro 将按邀请价结算。",
referralApplyFailed: isEn ? "Failed to apply referral code" : "邀请码应用失败",
referralDiscountHint: isEn
? "Invite discount: first monthly Pro is 20 USDC."
: "邀请首月价:Pro 月付 20 USDC。",
referralRewardHint: isEn
? "When an invited user pays for Pro, you receive +3500 points."
: "被邀请人成功付费后,邀请人获得 +3500 积分。",
referralInviteLimit: isEn
? "Monthly referral reward cap: 10 paid invites, up to +35000 points."
: "每月最多 10 个有效付费邀请奖励,最高 +35000 积分。",
paymentToken: isEn ? "Payment Token" : "支付币种", paymentToken: isEn ? "Payment Token" : "支付币种",
paymentAccount: isEn ? "Subscription Account" : "订阅归属账号", paymentAccount: isEn ? "Subscription Account" : "订阅归属账号",
paymentWallet: isEn ? "Paying Wallet" : "付款钱包", paymentWallet: isEn ? "Paying Wallet" : "付款钱包",
@@ -130,6 +155,10 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
: "钱包已绑定,正在创建订单并发起支付...", : "钱包已绑定,正在创建订单并发起支付...",
proMember: "PRO MEMBER", proMember: "PRO MEMBER",
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)", proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
subscriptionChecking: isEn ? "Checking subscription" : "订阅同步中",
subscriptionUnknown: isEn
? "Subscription status is syncing. Please refresh shortly."
: "订阅状态正在同步,请稍后刷新。",
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅", noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期", proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期",
proEndsSoonBody: isEn proEndsSoonBody: isEn
@@ -214,8 +243,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
? "Current receiver contract: {address}" ? "Current receiver contract: {address}"
: "当前收款合约: {address}", : "当前收款合约: {address}",
manualOrderCreated: isEn manualOrderCreated: isEn
? "Manual transfer order created. Please send {amount} USDC to the receiver on Polygon network. Then submit your tx hash below." ? "Manual transfer order created. Send {amount} {symbol} on {chain} to {receiver}, then submit your tx hash below."
: "手动转账订单已创建:请在 Polygon 网络转 {amount} USDC 到收款地址。完成后在下方提交 tx hash。", : "手动转账订单已创建:请在 {chain} 转 {amount} {symbol} 到 {receiver}完成后在下方提交 tx hash。",
manualOrderRequired: isEn manualOrderRequired: isEn
? "Please create a manual transfer order first." ? "Please create a manual transfer order first."
: "请先创建手动转账订单。", : "请先创建手动转账订单。",
@@ -242,8 +271,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
verifyUnknown: isEn ? "Unknown error" : "未知错误", verifyUnknown: isEn ? "Unknown error" : "未知错误",
// ── Telegram bind messages ──────────────────────────────────────── // ── Telegram bind messages ────────────────────────────────────────
telegramVerifySuccess: isEn telegramVerifySuccess: isEn
? "Telegram group membership verified. Current membership price: {amount} U." ? "Telegram group membership verified. Checkout follows the selected Pro plan."
: "Telegram 群成员验证成功,当前会员价 {amount}U。", : "Telegram 群成员验证成功,结算金额以当前选择的 Pro 套餐为准。",
telegramBindClickHint: isEn telegramBindClickHint: isEn
? "Open the Telegram Bot, click Start, and confirm binding. Then refresh this page to request group entry." ? "Open the Telegram Bot, click Start, and confirm binding. Then refresh this page to request group entry."
: "已打开 Telegram Bot,请在 Bot 内点击 Start 并确认绑定;完成后刷新本页再申请入群。", : "已打开 Telegram Bot,请在 Bot 内点击 Start 并确认绑定;完成后刷新本页再申请入群。",
@@ -305,8 +334,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
: "加载支付配置失败: {raw}", : "加载支付配置失败: {raw}",
// ── Points / Footer ──────────────────────────────────────────────── // ── Points / Footer ────────────────────────────────────────────────
pointsRule: isEn pointsRule: isEn
? "Points rule: active messages in group (anti-spam) + daily first message bonus. Weekly leaderboard settles every Monday midnight. All active members receive participation awards." ? "Points rule: points are earned through successful paid referrals only. 500 points = 1 USDC. Monthly orders can use up to 3 USDC off; quarterly orders can use up to 8 USDC off."
: "积分规则:群内有效发言(自动防刷检测)+ 每日首条发言额外奖励。每周一零点结算周榜,所有活跃用户均享参与奖。", : "积分规则:积分仅通过有效付费邀请获得。500 积分 = 1 USDC。月付订单最多抵扣 3 USDC,季度订单最多抵扣 8 USDC。",
pointsDiscount: isEn pointsDiscount: isEn
? "Available: {points} points discounting ${amount}. Applied automatically on renewal." ? "Available: {points} points discounting ${amount}. Applied automatically on renewal."
: "当前可用 {points} 积分抵扣 ${amount},续费时会自动生效。", : "当前可用 {points} 积分抵扣 ${amount},续费时会自动生效。",
@@ -7,6 +7,12 @@ export function chainIdToDisplayName(chainId: number | undefined | null): string
return "Polygon"; return "Polygon";
} }
export function chainIdToExplorerBase(chainId: number | undefined | null): string {
if (chainId === 1) return "https://etherscan.io";
if (chainId === 137) return "https://polygonscan.com";
return "";
}
export function formatTime(value: string | undefined | null, locale: string) { export function formatTime(value: string | undefined | null, locale: string) {
if (!value) return "--"; if (!value) return "--";
try { try {
+41
View File
@@ -10,12 +10,31 @@ export type AuthMeResponse = {
subscription_required?: boolean; subscription_required?: boolean;
subscription_active?: boolean | null; subscription_active?: boolean | null;
subscription_plan_code?: string | null; subscription_plan_code?: string | null;
subscription_source?: string | null;
subscription_is_trial?: boolean | null;
subscription_starts_at?: string | null; subscription_starts_at?: string | null;
subscription_expires_at?: string | null; subscription_expires_at?: string | null;
subscription_total_expires_at?: string | null; subscription_total_expires_at?: string | null;
subscription_queued_days?: number | null; subscription_queued_days?: number | null;
subscription_queued_count?: number | null; subscription_queued_count?: number | null;
telegram_pricing?: TelegramPricing | null; telegram_pricing?: TelegramPricing | null;
referral?: ReferralSummary | null;
};
export type ReferralSummary = {
code?: string;
discount_usdc?: string;
discounted_monthly_amount_usdc?: string;
reward_days?: number;
reward_points?: number;
monthly_reward_limit?: number;
monthly_reward_days_limit?: number;
monthly_reward_points_limit?: number;
monthly_reward_count?: number;
monthly_reward_days?: number;
monthly_reward_points?: number;
applied_code?: string;
attribution_status?: string;
}; };
export type TelegramPricing = { export type TelegramPricing = {
@@ -40,7 +59,24 @@ export type PaymentTokenOption = {
name: string; name: string;
address: string; address: string;
decimals: number; decimals: number;
chain_id?: number;
chain_code?: string;
chain_name?: string;
receiver_contract?: string; receiver_contract?: string;
direct_receiver_address?: string;
explorer_tx_url?: string;
supports_contract_checkout?: boolean;
supports_direct_transfer?: boolean;
is_default?: boolean;
};
export type PaymentChainOption = {
chain_id: number;
code?: string;
name?: string;
native_currency_symbol?: string;
block_explorer_url?: string;
explorer_tx_url?: string;
is_default?: boolean; is_default?: boolean;
}; };
@@ -48,15 +84,18 @@ export type PointsRedemptionConfig = {
enabled?: boolean; enabled?: boolean;
points_per_usdc?: number; points_per_usdc?: number;
max_discount_usdc?: number; max_discount_usdc?: number;
max_discount_usdc_by_plan?: Record<string, number>;
}; };
export type PaymentConfig = { export type PaymentConfig = {
enabled?: boolean; enabled?: boolean;
configured?: boolean; configured?: boolean;
chain_id?: number; chain_id?: number;
default_chain_id?: number;
token_address?: string; token_address?: string;
token_decimals?: number; token_decimals?: number;
default_token_address?: string; default_token_address?: string;
chains?: PaymentChainOption[];
tokens?: PaymentTokenOption[]; tokens?: PaymentTokenOption[];
receiver_contract?: string; receiver_contract?: string;
confirmations?: number; confirmations?: number;
@@ -93,6 +132,7 @@ export type CreatedIntent = {
direct_payment?: { direct_payment?: {
chain_id: number; chain_id: number;
chain?: string; chain?: string;
chain_name?: string;
token_symbol?: string; token_symbol?: string;
token_address: string; token_address: string;
token_decimals?: number; token_decimals?: number;
@@ -101,6 +141,7 @@ export type CreatedIntent = {
amount_usdc: string; amount_usdc: string;
intent_id: string; intent_id: string;
expires_at: string; expires_at: string;
explorer_tx_url?: string;
}; };
}; };
+115 -18
View File
@@ -146,34 +146,96 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
[supabaseReady, getValidAccessToken], [supabaseReady, getValidAccessToken],
); );
// ── loadSnapshot ────────────────────────────────────────── // ── Auth snapshot refresh ────────────────────────────────
const loadSnapshot = useCallback(async () => { const fetchAuthSnapshot = useCallback(
setErrorText(""); async (retry: boolean): Promise<AuthMeResponse> => {
const attempt = async (retry: boolean): Promise<void> => {
const userPromise = supabaseReady const userPromise = supabaseReady
? getSupabaseBrowserClient().auth.getUser() ? getSupabaseBrowserClient()
: Promise.resolve({ data: { user: null as User | null } }); .auth.getSession()
.then(({ data: { session } }) => session?.user ?? null)
.catch(() => null as User | null)
: Promise.resolve(null as User | null);
const authHeadersPromise = buildAuthedHeaders(false); const authHeadersPromise = buildAuthedHeaders(false);
const backendPromise = authHeadersPromise.then((headers) => const backendPromise = authHeadersPromise.then((headers) =>
fetch("/api/auth/me", { cache: "no-store", headers }), fetch("/api/auth/me", { cache: "no-store", headers }),
); );
const [userResult, backendResult] = await Promise.all([userPromise, backendPromise]); const [localUser, backendResult] = await Promise.all([userPromise, backendPromise]);
setUser(userResult.data?.user ?? null); setUser(localUser);
if (!backendResult.ok) { if (!backendResult.ok) {
if (retry && backendResult.status === 401) { if (retry && backendResult.status === 401) {
await new Promise((r) => setTimeout(r, 1200)); await new Promise((r) => setTimeout(r, 1200));
return attempt(false); return fetchAuthSnapshot(false);
} }
const raw = (await backendResult.text()).slice(0, 260); const raw = (await backendResult.text()).slice(0, 260);
throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw)); throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw));
} }
const backendJson = (await backendResult.json()) as AuthMeResponse; let backendJson = (await backendResult.json()) as AuthMeResponse;
if (
retry &&
supabaseReady &&
localUser &&
backendJson.authenticated === false
) {
try {
const {
data: { session: refreshedSession },
} = await getSupabaseBrowserClient().auth.refreshSession();
const refreshedToken = String(
refreshedSession?.access_token || "",
).trim();
if (refreshedToken) {
const retriedBackendResult = await fetch("/api/auth/me", {
cache: "no-store",
headers: { Authorization: `Bearer ${refreshedToken}` },
});
if (retriedBackendResult.ok) {
const retriedBackendJson =
(await retriedBackendResult.json()) as AuthMeResponse;
backendJson = retriedBackendJson;
}
}
} catch {
// Keep the first response; the UI treats a logged-in local user with
// an unauthenticated backend snapshot as a temporary sync state.
}
}
setBackend(backendJson); setBackend(backendJson);
setUpdatedAt(new Date().toISOString()); setUpdatedAt(new Date().toISOString());
}; return backendJson;
try { await attempt(true); } },
[
buildAuthedHeaders,
copy.httpError,
setBackend,
setUpdatedAt,
setUser,
supabaseReady,
],
);
// ── loadSnapshot ──────────────────────────────────────────
const loadSnapshot = useCallback(async () => {
setErrorText("");
try { await fetchAuthSnapshot(true); }
catch (error) { setErrorText(String(error)); } catch (error) { setErrorText(String(error)); }
}, [buildAuthedHeaders, supabaseReady, copy.httpError]); }, [fetchAuthSnapshot, setErrorText]);
const refreshEntitlementAfterPayment = useCallback(async () => {
setErrorText("");
const maxAttempts = 5;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const backendJson = await fetchAuthSnapshot(true);
if (backendJson.subscription_active === true) return;
} catch (error) {
if (attempt === maxAttempts - 1) {
setErrorText(String(error));
return;
}
}
await new Promise((resolve) => setTimeout(resolve, 1200));
}
}, [fetchAuthSnapshot, setErrorText]);
// ── Shared state for sub-hooks ─────────────────────────── // ── Shared state for sub-hooks ───────────────────────────
// These state variables are managed in the master hook and passed // These state variables are managed in the master hook and passed
@@ -182,6 +244,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]); const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]);
const [walletAddress, setWalletAddress] = useState(""); const [walletAddress, setWalletAddress] = useState("");
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly"); const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
const [selectedPaymentChainId, setSelectedPaymentChainId] = useState<number | null>(null);
const [selectedTokenAddress, setSelectedTokenAddress] = useState(""); const [selectedTokenAddress, setSelectedTokenAddress] = useState("");
const [selectedWallet, setSelectedWallet] = useState(""); const [selectedWallet, setSelectedWallet] = useState("");
const [providerMode, setProviderMode] = useState<ProviderMode>("auto"); const [providerMode, setProviderMode] = useState<ProviderMode>("auto");
@@ -189,7 +252,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] = useState(""); const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] = useState("");
// ── Chain ID derived from payment config ──────────────── // ── Chain ID derived from payment config ────────────────
const chainId = paymentConfig?.chain_id ?? 137; const chainId = selectedPaymentChainId || paymentConfig?.default_chain_id || paymentConfig?.chain_id || 137;
// ── loadPaymentSnapshot ────────────────────────────────── // ── loadPaymentSnapshot ──────────────────────────────────
// Defined in master because it sets state across multiple sub-hook domains. // Defined in master because it sets state across multiple sub-hook domains.
@@ -214,8 +277,35 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
const tokenOptions = Array.isArray(configJson.tokens) const tokenOptions = Array.isArray(configJson.tokens)
? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x")) ? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x"))
: []; : [];
const chainOptions = Array.isArray(configJson.chains)
? configJson.chains.filter((row) => Number(row?.chain_id) > 0)
: [];
const defaultChainId = Number(
configJson.default_chain_id ||
chainOptions.find((row) => row.is_default)?.chain_id ||
configJson.chain_id ||
tokenOptions.find((row) => row.is_default)?.chain_id ||
137,
);
const supportedChainIds = new Set(
(chainOptions.length ? chainOptions : [{ chain_id: defaultChainId }])
.map((row) => Number(row.chain_id))
.filter((value) => Number.isFinite(value) && value > 0),
);
setSelectedPaymentChainId((prev) =>
prev && supportedChainIds.has(prev) ? prev : defaultChainId,
);
const activeChainId =
selectedPaymentChainId && supportedChainIds.has(selectedPaymentChainId)
? selectedPaymentChainId
: defaultChainId;
const tokenOptionsForChain = tokenOptions.filter(
(row) => Number(row.chain_id || activeChainId) === activeChainId,
);
const defaultTokenAddress = String( const defaultTokenAddress = String(
configJson.default_token_address || configJson.default_token_address ||
tokenOptionsForChain.find((row) => row.is_default)?.address ||
tokenOptionsForChain[0]?.address ||
tokenOptions.find((row) => row.is_default)?.address || tokenOptions.find((row) => row.is_default)?.address ||
tokenOptions[0]?.address || tokenOptions[0]?.address ||
configJson.token_address || "", configJson.token_address || "",
@@ -254,15 +344,13 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
backend?.authenticated, backend?.authenticated,
buildAuthedHeaders, buildAuthedHeaders,
selectedPlanCode, selectedPlanCode,
selectedPaymentChainId,
selectedWallet, selectedWallet,
walletAddress, walletAddress,
]); ]);
// ── Selected plan (derived, shared across sub-hooks) ─── // ── Selected plan (derived, shared across sub-hooks) ───
const monthlyPlanList = (paymentConfig?.plans || []).filter( const effectivePlanList = paymentConfig?.plans || [];
(p) => String(p.plan_code || "").trim().toLowerCase() === "pro_monthly",
);
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : (paymentConfig?.plans || []);
const selectedPlan = effectivePlanList.find((p) => p.plan_code === selectedPlanCode) || effectivePlanList[0]; const selectedPlan = effectivePlanList.find((p) => p.plan_code === selectedPlanCode) || effectivePlanList[0];
// ── useWalletBind ────────────────────────────────────── // ── useWalletBind ──────────────────────────────────────
@@ -325,6 +413,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
getValidAccessToken, getValidAccessToken,
buildAuthedHeaders, buildAuthedHeaders,
loadSnapshot, loadSnapshot,
refreshEntitlementAfterPayment,
loadPaymentSnapshot: loadPaymentSnapshotImpl, loadPaymentSnapshot: loadPaymentSnapshotImpl,
user, user,
}); });
@@ -342,6 +431,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
setPaymentConfig, setPaymentConfig,
selectedPlanCode, selectedPlanCode,
setSelectedPlanCode, setSelectedPlanCode,
selectedPaymentChainId: chainId,
setSelectedPaymentChainId,
selectedTokenAddress, selectedTokenAddress,
setSelectedTokenAddress, setSelectedTokenAddress,
boundWallets, boundWallets,
@@ -378,6 +469,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
getValidAccessToken, getValidAccessToken,
buildAuthedHeaders, buildAuthedHeaders,
loadSnapshot, loadSnapshot,
refreshEntitlementAfterPayment,
loadPaymentSnapshot: loadPaymentSnapshotImpl, loadPaymentSnapshot: loadPaymentSnapshotImpl,
waitForReceipt: walletBind.waitForReceipt, waitForReceipt: walletBind.waitForReceipt,
ensureTargetChain: walletBind.ensureTargetChain, ensureTargetChain: walletBind.ensureTargetChain,
@@ -422,6 +514,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
boundWallets, boundWallets,
walletAddress, walletAddress,
selectedPlanCode, selectedPlanCode,
selectedPaymentChainId: chainId,
selectedTokenAddress, selectedTokenAddress,
selectedWallet, selectedWallet,
providerMode, providerMode,
@@ -431,6 +524,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
// Setters for shared state // Setters for shared state
setSelectedTokenAddress, setSelectedTokenAddress,
setSelectedPaymentChainId,
setSelectedPlanCode,
setSelectedWallet, setSelectedWallet,
setSelectedInjectedProviderKey, setSelectedInjectedProviderKey,
setProviderMode, setProviderMode,
@@ -447,6 +542,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
selectedPaymentToken: paymentFlow.selectedPaymentToken, selectedPaymentToken: paymentFlow.selectedPaymentToken,
selectedTokenLabel: paymentFlow.selectedTokenLabel, selectedTokenLabel: paymentFlow.selectedTokenLabel,
availableTokenList: paymentFlow.availableTokenList, availableTokenList: paymentFlow.availableTokenList,
availableChainList: paymentFlow.availableChainList,
selectedPaymentChain: paymentFlow.selectedPaymentChain,
effectivePlanList, effectivePlanList,
resolvedSelectedTokenAddress: paymentFlow.resolvedSelectedTokenAddress, resolvedSelectedTokenAddress: paymentFlow.resolvedSelectedTokenAddress,
paymentReceiverAddress: paymentFlow.paymentReceiverAddress, paymentReceiverAddress: paymentFlow.paymentReceiverAddress,
+44 -12
View File
@@ -53,6 +53,7 @@ export interface UseBillingParams {
getValidAccessToken: () => Promise<string>; getValidAccessToken: () => Promise<string>;
buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>; buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>;
loadSnapshot: () => Promise<void>; loadSnapshot: () => Promise<void>;
refreshEntitlementAfterPayment: () => Promise<void>;
loadPaymentSnapshot: () => Promise<void>; loadPaymentSnapshot: () => Promise<void>;
// User for metadata points // User for metadata points
@@ -86,6 +87,7 @@ export function useBilling(params: UseBillingParams) {
getValidAccessToken, getValidAccessToken,
buildAuthedHeaders, buildAuthedHeaders,
loadSnapshot, loadSnapshot,
refreshEntitlementAfterPayment,
loadPaymentSnapshot, loadPaymentSnapshot,
user, user,
} = params; } = params;
@@ -116,10 +118,31 @@ export function useBilling(params: UseBillingParams) {
// ── Billing ────────────────────────────────────────────── // ── Billing ──────────────────────────────────────────────
const billing = useMemo(() => { const billing = useMemo(() => {
const parsedPlanAmount = Number( const listAmountRaw = Number(selectedPlan?.amount_usdc ?? 29.9);
backend?.telegram_pricing?.amount_usdc ?? selectedPlan?.amount_usdc ?? 10, const listAmount =
Number.isFinite(listAmountRaw) && listAmountRaw > 0 ? listAmountRaw : 29.9;
const selectedPlanCode = String(selectedPlan?.plan_code || "").toLowerCase();
const referral = backend?.referral;
const referralPending = Boolean(
referral?.applied_code ||
String(referral?.attribution_status || "").toLowerCase() === "pending",
); );
const planAmount = Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0 ? parsedPlanAmount : 10; const referralDiscountRaw = Number(referral?.discount_usdc ?? 0);
const referralDiscount = Number.isFinite(referralDiscountRaw)
? Math.max(0, referralDiscountRaw)
: 0;
const discountedMonthlyRaw = Number(
referral?.discounted_monthly_amount_usdc ?? 0,
);
const referralApplies =
selectedPlanCode === "pro_monthly" &&
referralPending &&
backend?.subscription_active !== true;
const planAmount = referralApplies
? Number.isFinite(discountedMonthlyRaw) && discountedMonthlyRaw > 0
? discountedMonthlyRaw
: Math.max(0, listAmount - referralDiscount)
: listAmount;
const pointsCfg = paymentConfig?.points_redemption || {}; const pointsCfg = paymentConfig?.points_redemption || {};
const pointsEnabled = pointsCfg.enabled !== false; const pointsEnabled = pointsCfg.enabled !== false;
@@ -127,7 +150,10 @@ export function useBilling(params: UseBillingParams) {
const pointsPerUsdc = const pointsPerUsdc =
Number.isFinite(pointsPerUsdcRaw) && pointsPerUsdcRaw > 0 ? Math.floor(pointsPerUsdcRaw) : 500; Number.isFinite(pointsPerUsdcRaw) && pointsPerUsdcRaw > 0 ? Math.floor(pointsPerUsdcRaw) : 500;
const maxDiscountRaw = Number(pointsCfg.max_discount_usdc ?? 3); const maxDiscountByPlan = pointsCfg.max_discount_usdc_by_plan || {};
const planMaxDiscountRaw =
maxDiscountByPlan[selectedPlanCode] ?? pointsCfg.max_discount_usdc ?? 3;
const maxDiscountRaw = Number(planMaxDiscountRaw);
const maxDiscountUsdc = Math.max( const maxDiscountUsdc = Math.max(
0, 0,
Math.min( Math.min(
@@ -137,14 +163,18 @@ export function useBilling(params: UseBillingParams) {
); );
const maxRedeemablePoints = pointsPerUsdc * maxDiscountUsdc; const maxRedeemablePoints = pointsPerUsdc * maxDiscountUsdc;
const actualRedeem = pointsEnabled ? Math.min(totalPoints, maxRedeemablePoints) : 0; const pointsCanApply = pointsEnabled && !referralApplies;
const actualRedeem = pointsCanApply ? Math.min(totalPoints, maxRedeemablePoints) : 0;
const discountUnits = Math.floor(actualRedeem / pointsPerUsdc); const discountUnits = Math.floor(actualRedeem / pointsPerUsdc);
const pointsUsed = discountUnits * pointsPerUsdc; const pointsUsed = discountUnits * pointsPerUsdc;
const canRedeem = pointsEnabled && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc; const canRedeem = pointsCanApply && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc;
const applyDiscount = usePoints && canRedeem && pointsUsed > 0; const applyDiscount = usePoints && canRedeem && pointsUsed > 0;
return { return {
planAmount, planAmount,
listAmount,
referralApplied: referralApplies,
referralDiscountAmount: referralApplies ? listAmount - planAmount : 0,
pointsEnabled, pointsEnabled,
pointsPerUsdc, pointsPerUsdc,
maxDiscountUsdc, maxDiscountUsdc,
@@ -155,7 +185,9 @@ export function useBilling(params: UseBillingParams) {
}; };
}, [ }, [
paymentConfig?.points_redemption, paymentConfig?.points_redemption,
backend?.telegram_pricing?.amount_usdc, backend?.referral,
backend?.subscription_active,
selectedPlan?.plan_code,
selectedPlan?.amount_usdc, selectedPlan?.amount_usdc,
totalPoints, totalPoints,
usePoints, usePoints,
@@ -177,7 +209,7 @@ export function useBilling(params: UseBillingParams) {
if (json.ok) { if (json.ok) {
setPaymentInfo(copy.walletRecoveryDone); setPaymentInfo(copy.walletRecoveryDone);
setPaymentError(""); setPaymentError("");
await loadSnapshot(); await refreshEntitlementAfterPayment();
await loadPaymentSnapshot(); await loadPaymentSnapshot();
return true; return true;
} }
@@ -189,7 +221,7 @@ export function useBilling(params: UseBillingParams) {
} }
}, [ }, [
authIsAuthenticated, buildAuthedHeaders, copy.walletRecoveryDone, authIsAuthenticated, buildAuthedHeaders, copy.walletRecoveryDone,
loadPaymentSnapshot, loadSnapshot, reconcileBusy, loadPaymentSnapshot, refreshEntitlementAfterPayment, reconcileBusy,
]); ]);
// ── handleSubmit409 ──────────────────────────────────── // ── handleSubmit409 ────────────────────────────────────
@@ -200,7 +232,7 @@ export function useBilling(params: UseBillingParams) {
const ok = await reconcileLatestPayment(); const ok = await reconcileLatestPayment();
if (ok) return; if (ok) return;
setPaymentInfo(copy.orderAlreadyPaid); setPaymentInfo(copy.orderAlreadyPaid);
await loadSnapshot(); await refreshEntitlementAfterPayment();
await loadPaymentSnapshot(); await loadPaymentSnapshot();
return; return;
} }
@@ -220,7 +252,7 @@ export function useBilling(params: UseBillingParams) {
const txHash = intentJson.intent?.tx_hash || txHashNorm; const txHash = intentJson.intent?.tx_hash || txHashNorm;
setPaymentInfo(`支付已确认,交易: ${shortAddress(txHash)}`); setPaymentInfo(`支付已确认,交易: ${shortAddress(txHash)}`);
setPaymentError(""); setPaymentError("");
await loadSnapshot(); await refreshEntitlementAfterPayment();
await loadPaymentSnapshot(); await loadPaymentSnapshot();
return; return;
} }
@@ -231,7 +263,7 @@ export function useBilling(params: UseBillingParams) {
} }
throw new Error(copy.submitTxFailed.replace("{raw}", raw)); throw new Error(copy.submitTxFailed.replace("{raw}", raw));
}, },
[buildAuthedHeaders, copy, loadPaymentSnapshot, loadSnapshot, reconcileLatestPayment], [buildAuthedHeaders, copy, loadPaymentSnapshot, refreshEntitlementAfterPayment, reconcileLatestPayment],
); );
// ── openTelegramBotBindLink ────────────────────────────── // ── openTelegramBotBindLink ──────────────────────────────
+182 -40
View File
@@ -8,6 +8,7 @@ import type {
CreatedIntent, CreatedIntent,
EvmProvider, EvmProvider,
IntentStatusResponse, IntentStatusResponse,
PaymentChainOption,
PaymentConfig, PaymentConfig,
PaymentTokenOption, PaymentTokenOption,
ProviderMode, ProviderMode,
@@ -16,7 +17,7 @@ import type {
import { import {
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS, WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
} from "./constants"; } from "./constants";
import { clearStoredPaymentRecovery, shortAddress } from "./formatters"; import { chainIdToDisplayName, clearStoredPaymentRecovery, shortAddress } from "./formatters";
import { import {
buildAllowanceCalldata, buildAllowanceCalldata,
buildApproveCalldata, buildApproveCalldata,
@@ -43,6 +44,8 @@ export interface UsePaymentFlowParams {
setPaymentConfig: React.Dispatch<React.SetStateAction<PaymentConfig | null>>; setPaymentConfig: React.Dispatch<React.SetStateAction<PaymentConfig | null>>;
selectedPlanCode: string; selectedPlanCode: string;
setSelectedPlanCode: React.Dispatch<React.SetStateAction<string>>; setSelectedPlanCode: React.Dispatch<React.SetStateAction<string>>;
selectedPaymentChainId: number;
setSelectedPaymentChainId: React.Dispatch<React.SetStateAction<number | null>>;
selectedTokenAddress: string; selectedTokenAddress: string;
setSelectedTokenAddress: React.Dispatch<React.SetStateAction<string>>; setSelectedTokenAddress: React.Dispatch<React.SetStateAction<string>>;
@@ -98,9 +101,10 @@ export interface UsePaymentFlowParams {
getValidAccessToken: () => Promise<string>; getValidAccessToken: () => Promise<string>;
buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>; buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>;
loadSnapshot: () => Promise<void>; loadSnapshot: () => Promise<void>;
refreshEntitlementAfterPayment: () => Promise<void>;
loadPaymentSnapshot: () => Promise<void>; loadPaymentSnapshot: () => Promise<void>;
waitForReceipt: (txHash: string, provider?: EvmProvider, timeoutMs?: number, pollMs?: number) => Promise<any>; waitForReceipt: (txHash: string, provider?: EvmProvider, timeoutMs?: number, pollMs?: number) => Promise<any>;
ensureTargetChain: (eth: EvmProvider, targetChainId: number) => Promise<void>; ensureTargetChain: (eth: EvmProvider, targetChainId: number, chain?: PaymentChainOption) => Promise<void>;
// Ref-wrapped cross-hook callbacks // Ref-wrapped cross-hook callbacks
connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode, options?: ConnectBindOptions) => Promise<boolean>) | null>; connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode, options?: ConnectBindOptions) => Promise<boolean>) | null>;
@@ -120,6 +124,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentConfig, setPaymentConfig,
selectedPlanCode, selectedPlanCode,
setSelectedPlanCode, setSelectedPlanCode,
selectedPaymentChainId,
setSelectedPaymentChainId,
selectedTokenAddress, selectedTokenAddress,
setSelectedTokenAddress, setSelectedTokenAddress,
boundWallets, boundWallets,
@@ -155,6 +161,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
getValidAccessToken, getValidAccessToken,
buildAuthedHeaders, buildAuthedHeaders,
loadSnapshot, loadSnapshot,
refreshEntitlementAfterPayment,
loadPaymentSnapshot, loadPaymentSnapshot,
waitForReceipt, waitForReceipt,
ensureTargetChain, ensureTargetChain,
@@ -165,11 +172,81 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
// ── Derived payment values ────────────────────────────── // ── Derived payment values ──────────────────────────────
const planList = paymentConfig?.plans || []; const planList = paymentConfig?.plans || [];
const monthlyPlanList = planList.filter( const effectivePlanList = planList;
(plan) => String(plan.plan_code || "").trim().toLowerCase() === "pro_monthly",
);
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList;
const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0]; const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0];
const trackPaymentStart = useCallback((payload: Record<string, unknown>) => {
trackAppEvent("checkout_started", payload);
trackAppEvent("payment_start", payload);
}, []);
const trackPaymentSuccess = useCallback((payload: Record<string, unknown>) => {
trackAppEvent("checkout_succeeded", payload);
trackAppEvent("payment_success", payload);
}, []);
const verifyPaymentAuthReady = useCallback(async () => {
const accessToken = await getValidAccessToken();
const authHeaders: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
};
const authRes = await fetch("/api/auth/me", {
cache: "no-store",
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
});
if (!authRes.ok) {
const raw = (await authRes.text().catch(() => "")).slice(0, 240);
throw new Error(
isEn
? `Authentication check failed before payment (${authRes.status}). ${raw}`
: `支付前登录态校验失败 (${authRes.status})。${raw}`,
);
}
const profile = (await authRes.json()) as AuthMeResponse;
if (profile.authenticated !== true) {
throw new Error(copy.loginBeforePay);
}
return {
authHeaders,
auth_confirmed_at: new Date().toISOString(),
profile,
};
}, [copy.loginBeforePay, getValidAccessToken, isEn]);
const availableChainList: PaymentChainOption[] = useMemo(() => {
const configured = Array.isArray(paymentConfig?.chains) ? paymentConfig?.chains || [] : [];
const clean = configured
.map((row) => ({
...row,
chain_id: Number(row.chain_id),
name: String(row.name || chainIdToDisplayName(Number(row.chain_id))),
}))
.filter((row) => Number.isFinite(row.chain_id) && row.chain_id > 0);
if (clean.length) return clean;
const chainIds = new Set<number>();
const defaultChainId = Number(paymentConfig?.default_chain_id || paymentConfig?.chain_id || 137);
if (Number.isFinite(defaultChainId) && defaultChainId > 0) chainIds.add(defaultChainId);
(paymentConfig?.tokens || []).forEach((token) => {
const chainId = Number(token.chain_id || defaultChainId);
if (Number.isFinite(chainId) && chainId > 0) chainIds.add(chainId);
});
return Array.from(chainIds).sort((a, b) => a - b).map((chainId) => ({
chain_id: chainId,
name: chainIdToDisplayName(chainId),
is_default: chainId === defaultChainId,
}));
}, [paymentConfig]);
const selectedPaymentChain =
availableChainList.find((chain) => chain.chain_id === selectedPaymentChainId) ||
availableChainList.find((chain) => chain.is_default) ||
availableChainList[0];
const effectivePaymentChainId = Number(
selectedPaymentChain?.chain_id ||
selectedPaymentChainId ||
paymentConfig?.default_chain_id ||
paymentConfig?.chain_id ||
137,
);
const availableTokenList: PaymentTokenOption[] = useMemo(() => { const availableTokenList: PaymentTokenOption[] = useMemo(() => {
const configured = Array.isArray(paymentConfig?.tokens) ? paymentConfig?.tokens || [] : []; const configured = Array.isArray(paymentConfig?.tokens) ? paymentConfig?.tokens || [] : [];
@@ -181,22 +258,32 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
symbol: String(row.symbol || "USDC"), symbol: String(row.symbol || "USDC"),
name: String(row.name || row.symbol || "USDC"), name: String(row.name || row.symbol || "USDC"),
code: String(row.code || "usdc"), code: String(row.code || "usdc"),
chain_id: Number(row.chain_id || effectivePaymentChainId),
decimals: Number.isFinite(Number(row.decimals)) decimals: Number.isFinite(Number(row.decimals))
? Number(row.decimals) ? Number(row.decimals)
: Number(paymentConfig?.token_decimals ?? 6), : Number(paymentConfig?.token_decimals ?? 6),
})); }))
.filter((row) => Number(row.chain_id) === effectivePaymentChainId);
if (clean.length) return clean; if (clean.length) return clean;
const fallbackAddress = String(paymentConfig?.token_address || "").toLowerCase(); const fallbackAddress = String(paymentConfig?.token_address || "").toLowerCase();
if (!fallbackAddress.startsWith("0x")) return []; if (!fallbackAddress.startsWith("0x")) return [];
return [{ return [{
code: "usdc", symbol: "USDC", name: "USDC", address: fallbackAddress, code: "usdc", symbol: "USDC", name: "USDC", address: fallbackAddress,
chain_id: effectivePaymentChainId,
decimals: Number(paymentConfig?.token_decimals ?? 6), decimals: Number(paymentConfig?.token_decimals ?? 6),
receiver_contract: paymentConfig?.receiver_contract, is_default: true, receiver_contract: paymentConfig?.receiver_contract, is_default: true,
}]; }];
}, [paymentConfig]); }, [effectivePaymentChainId, paymentConfig]);
const resolvedSelectedTokenAddress = String( const resolvedSelectedTokenAddress = String(
selectedTokenAddress || paymentConfig?.default_token_address || (
selectedTokenAddress &&
availableTokenList.some((row) => row.address === String(selectedTokenAddress).toLowerCase())
? selectedTokenAddress
: ""
) ||
availableTokenList.find((row) => row.is_default)?.address ||
paymentConfig?.default_token_address ||
availableTokenList.find((row) => row.is_default)?.address || availableTokenList.find((row) => row.is_default)?.address ||
availableTokenList[0]?.address || paymentConfig?.token_address || "", availableTokenList[0]?.address || paymentConfig?.token_address || "",
).toLowerCase(); ).toLowerCase();
@@ -225,21 +312,50 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
if (!selectedPlanCode && configJson.plans?.length) { if (!selectedPlanCode && configJson.plans?.length) {
setSelectedPlanCode(configJson.plans[0].plan_code); setSelectedPlanCode(configJson.plans[0].plan_code);
} }
const chainOptions = Array.isArray(configJson.chains)
? configJson.chains.filter((row) => Number(row?.chain_id) > 0)
: [];
const defaultChainId = Number(
configJson.default_chain_id ||
chainOptions.find((row) => row.is_default)?.chain_id ||
configJson.chain_id ||
137,
);
const supportedChainIds = new Set(
(chainOptions.length ? chainOptions : [{ chain_id: defaultChainId }])
.map((row) => Number(row.chain_id))
.filter((value) => Number.isFinite(value) && value > 0),
);
setSelectedPaymentChainId((prev) =>
prev && supportedChainIds.has(prev) ? prev : defaultChainId,
);
const activeChainId =
selectedPaymentChainId && supportedChainIds.has(selectedPaymentChainId)
? selectedPaymentChainId
: defaultChainId;
const tokenOptions = Array.isArray(configJson.tokens) const tokenOptions = Array.isArray(configJson.tokens)
? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x")) ? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x"))
: []; : [];
const tokenOptionsForChain = tokenOptions.filter(
(row) => Number(row.chain_id || activeChainId) === activeChainId,
);
const defaultTokenAddress = String( const defaultTokenAddress = String(
configJson.default_token_address || configJson.default_token_address ||
tokenOptionsForChain.find((row) => row.is_default)?.address ||
tokenOptionsForChain[0]?.address ||
tokenOptions.find((row) => row.is_default)?.address || tokenOptions.find((row) => row.is_default)?.address ||
tokenOptions[0]?.address || configJson.token_address || "", tokenOptions[0]?.address || configJson.token_address || "",
).toLowerCase(); ).toLowerCase();
if (defaultTokenAddress) { if (defaultTokenAddress) {
setSelectedTokenAddress((prev: string) => prev || defaultTokenAddress); const tokenSet = new Set(tokenOptionsForChain.map((row) => String(row.address).toLowerCase()));
setSelectedTokenAddress((prev: string) =>
prev && tokenSet.has(String(prev).toLowerCase()) ? prev : defaultTokenAddress,
);
} }
} }
return configJson; return configJson;
}, },
[buildAuthedHeaders, selectedPlanCode], [buildAuthedHeaders, selectedPaymentChainId, selectedPlanCode],
); );
// ── pollIntentUntilConfirmed ──────────────────────────── // ── pollIntentUntilConfirmed ────────────────────────────
@@ -266,13 +382,13 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
if (status === "confirmed") { if (status === "confirmed") {
setPaymentError(""); setPaymentError("");
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHash))); setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHash)));
trackAppEvent("checkout_succeeded", { trackPaymentSuccess({
entry: "account_center", entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId, intent_id: intentId,
tx_hash: txHash || null, tx_hash: txHash || null,
}); });
await loadSnapshot(); await refreshEntitlementAfterPayment();
await loadPaymentSnapshot(); await loadPaymentSnapshot();
return; return;
} }
@@ -284,7 +400,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
} }
throw new Error(copy.paymentPendingTimeout); throw new Error(copy.paymentPendingTimeout);
}, },
[loadPaymentSnapshot, loadSnapshot, selectedPlan?.plan_code], [loadPaymentSnapshot, refreshEntitlementAfterPayment, selectedPlan?.plan_code, trackPaymentSuccess],
); );
// ── createIntentAndPay ────────────────────────────────── // ── createIntentAndPay ──────────────────────────────────
@@ -314,6 +430,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentBusy(true); setPaymentBusy(true);
let approvedInThisRun = false; let approvedInThisRun = false;
try { try {
const authReady = await verifyPaymentAuthReady();
const authHeaders = authReady.authHeaders;
const providerSelection = await resolvePaymentProvider(providerMode, selectedInjectedProviderKey); const providerSelection = await resolvePaymentProvider(providerMode, selectedInjectedProviderKey);
const eth = providerSelection.provider; const eth = providerSelection.provider;
const activeAccounts = await requestWalletWithTimeout<string[]>( const activeAccounts = await requestWalletWithTimeout<string[]>(
@@ -331,21 +449,32 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setSelectedWallet(payingWallet); setSelectedWallet(payingWallet);
setProviderMode(providerSelection.mode); setProviderMode(providerSelection.mode);
let accessToken: string;
try { accessToken = await getValidAccessToken(); }
catch (tokenErr) {
setPaymentError(normalizePaymentError(tokenErr).message);
setPaymentBusy(false);
return;
}
const authHeaders: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
};
const latestConfig = await fetchLatestPaymentConfig(authHeaders, true); const latestConfig = await fetchLatestPaymentConfig(authHeaders, true);
if (!latestConfig?.enabled || !latestConfig?.configured) throw new Error(copy.payNotReady); if (!latestConfig?.enabled || !latestConfig?.configured) throw new Error(copy.payNotReady);
const expectedReceiver = String(latestConfig.receiver_contract || "").toLowerCase(); const targetChainId = Number(selectedPaymentChainId || latestConfig.default_chain_id || latestConfig.chain_id || 137);
const latestChains = Array.isArray(latestConfig.chains) ? latestConfig.chains : [];
const targetChain =
latestChains.find((chain) => Number(chain.chain_id) === targetChainId) ||
selectedPaymentChain;
const latestTokens = Array.isArray(latestConfig.tokens) ? latestConfig.tokens : [];
const selectedLatestToken =
latestTokens.find(
(token) =>
Number(token.chain_id || targetChainId) === targetChainId &&
String(token.address || "").toLowerCase() === resolvedSelectedTokenAddress,
) ||
latestTokens.find(
(token) => Number(token.chain_id || targetChainId) === targetChainId && token.is_default,
) ||
latestTokens.find((token) => Number(token.chain_id || targetChainId) === targetChainId);
if (selectedLatestToken?.supports_contract_checkout === false) {
throw new Error(
isEn
? `${selectedLatestToken.chain_name || chainIdToDisplayName(targetChainId)} ${selectedLatestToken.symbol || "USDC"} supports manual transfer only.`
: `${selectedLatestToken.chain_name || chainIdToDisplayName(targetChainId)} ${selectedLatestToken.symbol || "USDC"} 仅支持手动转账。`,
);
}
const expectedReceiver = String(selectedLatestToken?.receiver_contract || latestConfig.receiver_contract || "").toLowerCase();
assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract"); assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract");
if (paymentConfig?.receiver_contract && String(paymentConfig.receiver_contract).toLowerCase() !== expectedReceiver) { if (paymentConfig?.receiver_contract && String(paymentConfig.receiver_contract).toLowerCase() !== expectedReceiver) {
setPaymentInfo(copy.paymentConfigUpdated.replace("{address}", shortAddress(expectedReceiver))); setPaymentInfo(copy.paymentConfigUpdated.replace("{address}", shortAddress(expectedReceiver)));
@@ -353,8 +482,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentInfo(copy.currentReceiver.replace("{address}", shortAddress(expectedReceiver))); setPaymentInfo(copy.currentReceiver.replace("{address}", shortAddress(expectedReceiver)));
} }
const targetChainId = Number(latestConfig.chain_id || 137); await ensureTargetChain(eth, targetChainId, targetChain);
await ensureTargetChain(eth, targetChainId);
const createRes = await fetch("/api/payments/intents", { const createRes = await fetch("/api/payments/intents", {
method: "POST", method: "POST",
@@ -363,13 +491,15 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
payment_mode: "strict", payment_mode: "strict",
allowed_wallet: payingWallet, allowed_wallet: payingWallet,
token_address: resolvedSelectedTokenAddress || undefined, chain_id: targetChainId,
token_address: String(selectedLatestToken?.address || resolvedSelectedTokenAddress || "").toLowerCase() || undefined,
use_points: billing.canRedeem && usePoints, use_points: billing.canRedeem && usePoints,
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0, points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
metadata: { metadata: {
source: "account_center", source: "account_center",
frontend_host: currentPaymentHost || null, frontend_host: currentPaymentHost || null,
account_email: backend?.email || null, account_email: backend?.email || null,
auth_confirmed_at: authReady.auth_confirmed_at,
}, },
}), }),
}); });
@@ -382,7 +512,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const intentId = String(created.intent?.intent_id || ""); const intentId = String(created.intent?.intent_id || "");
const txPayload = created.tx_payload; const txPayload = created.tx_payload;
if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error(copy.intentPayloadInvalid); if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error(copy.intentPayloadInvalid);
trackAppEvent("checkout_started", { trackPaymentStart({
entry: "account_center", entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId, intent_id: intentId,
@@ -446,6 +576,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const txHashNorm = String(txHash || "").toLowerCase(); const txHashNorm = String(txHash || "").toLowerCase();
setLastTxHash(txHashNorm); setLastTxHash(txHashNorm);
setLastPaymentStartedAt(Date.now()); setLastPaymentStartedAt(Date.now());
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上回执...`);
await waitForReceipt(txHashNorm, eth);
const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, { const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }), method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }),
@@ -478,13 +610,13 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
} }
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm))); setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm)));
trackAppEvent("checkout_succeeded", { trackPaymentSuccess({
entry: "account_center", entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId, intent_id: intentId,
tx_hash: txHashNorm, tx_hash: txHashNorm,
}); });
await loadSnapshot(); await refreshEntitlementAfterPayment();
await loadPaymentSnapshot(); await loadPaymentSnapshot();
} catch (error) { } catch (error) {
const normalized = normalizePaymentError(error); const normalized = normalizePaymentError(error);
@@ -530,13 +662,14 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentBusy(true); setPaymentBusy(true);
try { try {
const authHeaders = await buildAuthedHeaders(true, false); const authHeaders = await buildAuthedHeaders(true, true);
const createRes = await fetch("/api/payments/intents", { const createRes = await fetch("/api/payments/intents", {
method: "POST", method: "POST",
headers: authHeaders, headers: authHeaders,
body: JSON.stringify({ body: JSON.stringify({
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
payment_mode: "direct", payment_mode: "direct",
chain_id: effectivePaymentChainId,
token_address: resolvedSelectedTokenAddress || undefined, token_address: resolvedSelectedTokenAddress || undefined,
use_points: billing.canRedeem && usePoints, use_points: billing.canRedeem && usePoints,
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0, points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
@@ -563,8 +696,15 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setManualPayment(direct); setManualPayment(direct);
setPaymentMethodTab("manual"); setPaymentMethodTab("manual");
setShowOverlay(false); setShowOverlay(false);
setPaymentInfo(`手动转账订单已创建:请在 Polygon 网络转 ${direct.amount_usdc} ${direct.token_symbol || selectedTokenLabel}${direct.receiver_address},请在下方【手动转账】面板中查看详情并复制地址,完成后提交 tx hash。`); const chainName = direct.chain_name || chainIdToDisplayName(direct.chain_id);
trackAppEvent("checkout_started", { setPaymentInfo(
copy.manualOrderCreated
.replace("{amount}", direct.amount_usdc)
.replace("{symbol}", direct.token_symbol || selectedTokenLabel)
.replace("{chain}", chainName)
.replace("{receiver}", shortAddress(direct.receiver_address)),
);
trackPaymentStart({
entry: "account_center_manual_transfer", entry: "account_center_manual_transfer",
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId, intent_id: intentId,
@@ -594,7 +734,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentBusy(true); setPaymentBusy(true);
setPaymentError(""); setPaymentError("");
try { try {
const authHeaders = await buildAuthedHeaders(true, false); const authHeaders = await buildAuthedHeaders(true, true);
const submitRes = await fetch(`/api/payments/intents/${intentIdVal}/submit`, { const submitRes = await fetch(`/api/payments/intents/${intentIdVal}/submit`, {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }), method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
}); });
@@ -627,13 +767,13 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setManualPayment(null); setManualPayment(null);
setManualTxHash(""); setManualTxHash("");
setTxValidation({ loading: false, checked: false }); setTxValidation({ loading: false, checked: false });
trackAppEvent("checkout_succeeded", { trackPaymentSuccess({
entry: "account_center_manual_transfer", entry: "account_center_manual_transfer",
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentIdVal, intent_id: intentIdVal,
tx_hash: txHashNorm, tx_hash: txHashNorm,
}); });
await loadSnapshot(); await refreshEntitlementAfterPayment();
await loadPaymentSnapshot(); await loadPaymentSnapshot();
} catch (error) { } catch (error) {
setPaymentError(normalizePaymentError(error).message); setPaymentError(normalizePaymentError(error).message);
@@ -652,7 +792,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
} }
setTxValidation({ loading: true, checked: false }); setTxValidation({ loading: true, checked: false });
try { try {
const headers = await buildAuthedHeaders(true, false); const headers = await buildAuthedHeaders(true, true);
const res = await fetch(`/api/payments/intents/${intentId}/validate`, { const res = await fetch(`/api/payments/intents/${intentId}/validate`, {
method: "POST", headers, body: JSON.stringify({ tx_hash: hashNorm }), method: "POST", headers, body: JSON.stringify({ tx_hash: hashNorm }),
}); });
@@ -700,6 +840,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
validateTxHash, validateTxHash,
handleOverlayCheckout, handleOverlayCheckout,
availableTokenList, availableTokenList,
availableChainList,
selectedPaymentChain,
resolvedSelectedTokenAddress, resolvedSelectedTokenAddress,
selectedPaymentToken, selectedPaymentToken,
selectedTokenLabel, selectedTokenLabel,
+36 -10
View File
@@ -7,6 +7,7 @@ import type {
Eip6963ProviderDetail, Eip6963ProviderDetail,
EvmProvider, EvmProvider,
InjectedProviderOption, InjectedProviderOption,
PaymentChainOption,
ProviderMode, ProviderMode,
ProviderSelection, ProviderSelection,
} from "./types"; } from "./types";
@@ -190,12 +191,30 @@ export function useWalletBind(params: UseWalletBindParams) {
} }
}; };
const ensureTargetChain = async (eth: EvmProvider, targetChainId: number): Promise<void> => { const ensureTargetChain = async (
eth: EvmProvider,
targetChainId: number,
chain?: PaymentChainOption,
): Promise<void> => {
const currentChainIdHex = String( const currentChainIdHex = String(
(await requestWalletWithTimeout<string>(eth, { method: "eth_chainId" }, copy.chainReadError)) || "", (await requestWalletWithTimeout<string>(eth, { method: "eth_chainId" }, copy.chainReadError)) || "",
); );
const targetChainHex = `0x${targetChainId.toString(16)}`; const targetChainHex = `0x${targetChainId.toString(16)}`;
if (currentChainIdHex.toLowerCase() === targetChainHex.toLowerCase()) return; if (currentChainIdHex.toLowerCase() === targetChainHex.toLowerCase()) return;
const chainName =
String(chain?.name || "").trim() ||
(targetChainId === 1 ? "Ethereum Mainnet" : targetChainId === 137 ? "Polygon Mainnet" : `Chain ${targetChainId}`);
const nativeSymbol =
String(chain?.native_currency_symbol || "").trim() ||
(targetChainId === 137 ? "POL" : "ETH");
const explorerBase = String(chain?.block_explorer_url || "").trim() ||
(targetChainId === 1 ? "https://etherscan.io" : targetChainId === 137 ? "https://polygonscan.com" : "");
const defaultRpc =
targetChainId === 137
? "https://polygon-rpc.com"
: targetChainId === 1
? "https://ethereum-rpc.publicnode.com"
: "";
try { try {
await requestWalletWithTimeout( await requestWalletWithTimeout(
eth, eth,
@@ -206,24 +225,31 @@ export function useWalletBind(params: UseWalletBindParams) {
const code = Number(err?.code); const code = Number(err?.code);
if (code === 4902 || targetChainId === 137) { if (code === 4902 || targetChainId === 137) {
try { try {
const addParams: Record<string, any> = {
chainId: targetChainHex,
chainName,
nativeCurrency: { name: nativeSymbol, symbol: nativeSymbol, decimals: 18 },
};
if (defaultRpc) addParams.rpcUrls = [defaultRpc];
if (explorerBase) addParams.blockExplorerUrls = [explorerBase];
await requestWalletWithTimeout( await requestWalletWithTimeout(
eth, eth,
{ {
method: "wallet_addEthereumChain", method: "wallet_addEthereumChain",
params: [{ params: [addParams],
chainId: "0x89",
chainName: "Polygon Mainnet",
nativeCurrency: { name: "POL", symbol: "POL", decimals: 18 },
rpcUrls: ["https://polygon-rpc.com"],
blockExplorerUrls: ["https://polygonscan.com"],
}],
}, },
copy.chainAddPolygon, isEn ? `Add ${chainName}` : `添加 ${chainName}`,
); );
return; return;
} catch (addErr: any) { err = addErr; } } catch (addErr: any) { err = addErr; }
} }
throw new Error(`${copy.chainSwitchPrompt} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`); throw new Error(
`${
isEn
? `Please manually switch to ${chainName} in your wallet and try again.`
: `请在钱包中手动切换到 ${chainName} 后再试。`
} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`,
);
} }
}; };
+21 -7
View File
@@ -64,7 +64,10 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
? "Set at least 6 characters" ? "Set at least 6 characters"
: "设置至少 6 位密码", : "设置至少 6 位密码",
loginSubmit: isEn ? "Start your weather decision journey" : "开启气象决策之旅", loginSubmit: isEn ? "Start your weather decision journey" : "开启气象决策之旅",
loginSubmitting: isEn ? "Signing in..." : "正在登录...",
signupSubmit: isEn ? "Create account now" : "立即创建账号", signupSubmit: isEn ? "Create account now" : "立即创建账号",
signupSubmitting: isEn ? "Creating account..." : "正在创建账号...",
googleSubmitting: isEn ? "Connecting Google..." : "正在连接 Google...",
loginHint: isEn loginHint: isEn
? "After signing in, your homepage will be personalized." ? "After signing in, your homepage will be personalized."
: "登录后将为您个性化定制首页数据", : "登录后将为您个性化定制首页数据",
@@ -105,10 +108,18 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
? "By proceeding, you agree to the Privacy Policy and Terms & Conditions." ? "By proceeding, you agree to the Privacy Policy and Terms & Conditions."
: "继续操作即代表您同意隐私政策与服务条款。", : "继续操作即代表您同意隐私政策与服务条款。",
desc: isEn desc: isEn
? "Access robust METAR observations, advanced DEB forecast blends, and real-time AI decision cards that bring clarity to your weather risk analyses." ? "Access robust METAR observations, advanced DEB forecast blends, and structured decision context for weather risk analysis."
: "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和实时 AI 决策卡片,助您理清气象风险脉络。", : "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和结构化决策背景,助您理清气象风险脉络。",
trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖", trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖",
} as const; } as const;
const submittingLabel = isLogin ? copy.loginSubmitting : copy.signupSubmitting;
const googleSubmittingLabel = copy.googleSubmitting;
const loadingSpinner = (
<span
aria-hidden="true"
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
);
const onResetPassword = async () => { const onResetPassword = async () => {
setErrorText(""); setErrorText("");
@@ -126,7 +137,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
const supabase = getSupabaseBrowserClient(); const supabase = getSupabaseBrowserClient();
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), { const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
redirectTo: `${siteOrigin}/auth/callback?next=${encodeURIComponent( redirectTo: `${siteOrigin}/auth/callback?next=${encodeURIComponent(
"/account", `/auth/reset-password?next=${encodeURIComponent(nextPath || "/account")}`,
)}`, )}`,
}); });
if (error) { if (error) {
@@ -489,10 +500,12 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
aria-busy={loading}
className="w-full py-3.5 px-4 rounded-xl bg-gradient-to-r from-slate-900 to-slate-800 hover:from-blue-600 hover:to-indigo-600 text-sm font-bold text-white shadow-lg shadow-slate-950/10 hover:shadow-blue-600/25 active:scale-[0.98] transition-all duration-300 disabled:opacity-50 mt-8 flex items-center justify-center gap-2 group animate-fade-up [animation-delay:550ms] opacity-0" className="w-full py-3.5 px-4 rounded-xl bg-gradient-to-r from-slate-900 to-slate-800 hover:from-blue-600 hover:to-indigo-600 text-sm font-bold text-white shadow-lg shadow-slate-950/10 hover:shadow-blue-600/25 active:scale-[0.98] transition-all duration-300 disabled:opacity-50 mt-8 flex items-center justify-center gap-2 group animate-fade-up [animation-delay:550ms] opacity-0"
> >
<span>{isLogin ? copy.loginSubmit : copy.signupSubmit}</span> {loading ? loadingSpinner : null}
<ArrowRight size={16} className="transition-transform group-hover:translate-x-1" /> <span>{loading ? submittingLabel : (isLogin ? copy.loginSubmit : copy.signupSubmit)}</span>
{!loading && <ArrowRight size={16} className="transition-transform group-hover:translate-x-1" />}
</button> </button>
</form> </form>
@@ -508,10 +521,11 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
type="button" type="button"
onClick={() => void onGoogleSignIn()} onClick={() => void onGoogleSignIn()}
disabled={loading} disabled={loading}
aria-busy={loading}
className="w-full py-3 px-4 rounded-xl border border-slate-200 bg-white text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 active:scale-[0.99] transition-all duration-150 flex items-center justify-center gap-2 disabled:opacity-50" className="w-full py-3 px-4 rounded-xl border border-slate-200 bg-white text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 active:scale-[0.99] transition-all duration-150 flex items-center justify-center gap-2 disabled:opacity-50"
> >
<Chrome className="h-4 w-4 text-blue-600" /> {loading ? loadingSpinner : <Chrome className="h-4 w-4 text-blue-600" />}
<span>{copy.googleOneClick}</span> <span>{loading ? googleSubmittingLabel : copy.googleOneClick}</span>
</button> </button>
{errorText ? <p className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2.5 text-xs text-rose-700 leading-normal">{errorText}</p> : null} {errorText ? <p className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2.5 text-xs text-rose-700 leading-normal">{errorText}</p> : null}
@@ -0,0 +1,227 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AlertCircle, ArrowRight, CheckCircle2, Eye, EyeOff, Lock } from "lucide-react";
import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
import { useI18n } from "@/hooks/useI18n";
type ResetPasswordClientProps = {
nextPath: string;
};
export function ResetPasswordClient({ nextPath }: ResetPasswordClientProps) {
const router = useRouter();
const { locale } = useI18n();
const isEn = locale === "en-US";
const supabaseReady = hasSupabasePublicEnv();
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [checkingSession, setCheckingSession] = useState(true);
const [hasSession, setHasSession] = useState(false);
const [loading, setLoading] = useState(false);
const [errorText, setErrorText] = useState("");
const [infoText, setInfoText] = useState("");
const copy = {
title: isEn ? "Set a new password" : "设置新密码",
subtitle: isEn
? "Create a password for your PolyWeather account."
: "为您的 PolyWeather 账户创建一个新密码。",
password: isEn ? "New password" : "新密码",
confirmPassword: isEn ? "Confirm password" : "确认密码",
passwordPlaceholder: isEn ? "At least 6 characters" : "至少 6 位字符",
confirmPlaceholder: isEn ? "Enter it again" : "再次输入新密码",
submit: isEn ? "Update password" : "更新密码",
checking: isEn ? "Checking reset link..." : "正在检查重置链接...",
expired: isEn
? "This reset link is expired or invalid. Please request a new password reset email."
: "重置链接已过期或无效,请重新发送密码重置邮件。",
supabaseMissing: isEn
? "Supabase is not configured. Password reset is unavailable."
: "Supabase 未配置,无法重置密码。",
passwordTooShort: isEn
? "Password must be at least 6 characters."
: "密码至少需要 6 位字符。",
passwordMismatch: isEn
? "The two passwords do not match."
: "两次输入的密码不一致。",
success: isEn
? "Password updated. Redirecting..."
: "密码已更新,正在跳转...",
backLogin: isEn ? "Back to sign in" : "返回登录",
} as const;
useEffect(() => {
if (!supabaseReady) {
setErrorText(copy.supabaseMissing);
setCheckingSession(false);
return;
}
const run = async () => {
const supabase = getSupabaseBrowserClient();
const {
data: { session },
} = await supabase.auth.getSession();
const hasRecoverySession = Boolean(session?.user);
setHasSession(hasRecoverySession);
if (!hasRecoverySession) {
setErrorText(copy.expired);
}
setCheckingSession(false);
};
void run();
}, [copy.expired, copy.supabaseMissing, supabaseReady]);
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorText("");
setInfoText("");
if (!hasSession) {
setErrorText(copy.expired);
return;
}
if (password.length < 6) {
setErrorText(copy.passwordTooShort);
return;
}
if (password !== confirmPassword) {
setErrorText(copy.passwordMismatch);
return;
}
setLoading(true);
try {
const supabase = getSupabaseBrowserClient();
const { error } = await supabase.auth.updateUser({ password });
if (error) {
setErrorText(error.message);
return;
}
setInfoText(copy.success);
window.setTimeout(() => router.replace(nextPath), 700);
} finally {
setLoading(false);
}
};
return (
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-5 py-10 text-slate-900">
<div className="w-full max-w-[420px] rounded-2xl border border-slate-200 bg-white p-6 shadow-[0_24px_60px_rgba(8,16,36,0.08)] sm:p-8">
<Link href="/" className="mb-8 inline-flex items-center">
<img src="/logo.png" alt="PolyWeather" className="h-8 w-auto object-contain" />
</Link>
<div className="mb-6 space-y-2">
<h1 className="text-2xl font-black tracking-tight">{copy.title}</h1>
<p className="text-sm leading-6 text-slate-500">{copy.subtitle}</p>
</div>
{checkingSession ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-600">
{copy.checking}
</div>
) : (
<form onSubmit={(event) => void onSubmit(event)} className="space-y-5">
<div className="space-y-2">
<label className="block text-[10px] font-bold uppercase tracking-wider text-slate-400">
{copy.password}
</label>
<div className="relative">
<Lock className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type={showPassword ? "text" : "password"}
minLength={6}
required
disabled={!hasSession || loading}
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder={copy.passwordPlaceholder}
className="w-full rounded-xl border border-slate-200 bg-slate-50/70 py-3 pl-11 pr-11 text-sm text-slate-900 placeholder:text-slate-400 transition-all focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
onClick={() => setShowPassword((value) => !value)}
disabled={!hasSession || loading}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 transition-colors hover:text-slate-600 disabled:cursor-not-allowed disabled:opacity-60"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div className="space-y-2">
<label className="block text-[10px] font-bold uppercase tracking-wider text-slate-400">
{copy.confirmPassword}
</label>
<div className="relative">
<Lock className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type={showConfirmPassword ? "text" : "password"}
minLength={6}
required
disabled={!hasSession || loading}
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
placeholder={copy.confirmPlaceholder}
className="w-full rounded-xl border border-slate-200 bg-slate-50/70 py-3 pl-11 pr-11 text-sm text-slate-900 placeholder:text-slate-400 transition-all focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
onClick={() => setShowConfirmPassword((value) => !value)}
disabled={!hasSession || loading}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 transition-colors hover:text-slate-600 disabled:cursor-not-allowed disabled:opacity-60"
aria-label={showConfirmPassword ? "Hide password" : "Show password"}
>
{showConfirmPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<button
type="submit"
disabled={!hasSession || loading}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-slate-900 px-4 py-3.5 text-sm font-bold text-white shadow-lg shadow-slate-950/10 transition-all hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<span>{copy.submit}</span>
<ArrowRight size={16} />
</button>
</form>
)}
{errorText ? (
<div className="mt-4 flex gap-2 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2.5 text-xs leading-5 text-rose-700">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{errorText}</span>
</div>
) : null}
{infoText ? (
<div className="mt-4 flex gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-xs leading-5 text-emerald-700">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<span>{infoText}</span>
</div>
) : null}
<Link
href="/auth/login"
className="mt-5 inline-flex text-xs font-semibold text-blue-600 transition-colors hover:text-blue-700"
>
{copy.backLogin}
</Link>
</div>
</main>
);
}
@@ -0,0 +1,83 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const loginClientPath = path.join(
projectRoot,
"components",
"auth",
"LoginClient.tsx",
);
const resetPagePath = path.join(
projectRoot,
"app",
"auth",
"reset-password",
"page.tsx",
);
const resetClientPath = path.join(
projectRoot,
"components",
"auth",
"ResetPasswordClient.tsx",
);
const authCallbackPath = path.join(
projectRoot,
"app",
"auth",
"callback",
"route.ts",
);
const loginClientSource = fs.readFileSync(loginClientPath, "utf8");
assert(
loginClientSource.includes("/auth/reset-password") &&
loginClientSource.indexOf("resetPasswordForEmail") <
loginClientSource.indexOf("/auth/reset-password"),
"forgot-password emails must land on a password reset page after auth callback",
);
assert(
fs.existsSync(resetPagePath),
"password reset page must exist at app/auth/reset-password/page.tsx",
);
assert(
fs.existsSync(resetClientPath),
"password reset page must use a dedicated client component",
);
assert(
loginClientSource.includes("loadingSpinner") &&
loginClientSource.includes("submittingLabel") &&
loginClientSource.includes("googleSubmittingLabel") &&
loginClientSource.includes('aria-busy={loading}') &&
loginClientSource.includes("animate-spin"),
"login submit and Google sign-in buttons must show a visible loading spinner and pending label",
);
const resetClientSource = fs.readFileSync(resetClientPath, "utf8");
assert(
resetClientSource.includes("supabase.auth.updateUser") &&
resetClientSource.includes("password"),
"password reset client must update the authenticated user's password",
);
assert(
resetClientSource.includes("getSession") &&
resetClientSource.includes("expired"),
"password reset client must detect an expired or invalid recovery session",
);
const authCallbackSource = fs.readFileSync(authCallbackPath, "utf8");
assert(
authCallbackSource.includes("warmSignupTrial") &&
authCallbackSource.includes("exchangeCodeForSession") &&
authCallbackSource.includes("/api/auth/me") &&
authCallbackSource.includes("Authorization") &&
authCallbackSource.includes("Bearer"),
"auth callback must warm /api/auth/me with the exchanged Supabase session so new users receive the signup trial immediately",
);
}
@@ -1026,41 +1026,41 @@
color: #64748b; color: #64748b;
} }
.root :global(.scan-terminal.light .scan-ai-city-card), .root :global(.scan-terminal.light .scan-city-card),
.root :global(.scan-terminal.light .scan-ai-city-hero) { .root :global(.scan-terminal.light .scan-city-hero) {
background: var(--bg-card); background: var(--bg-card);
border-color: var(--border-glass); border-color: var(--border-glass);
} }
.root :global(.scan-terminal.light .scan-ai-city-section), .root :global(.scan-terminal.light .scan-city-section),
.root :global(.scan-terminal.light .scan-ai-decision-band), .root :global(.scan-terminal.light .scan-ai-decision-band),
.root :global(.scan-terminal.light .scan-ai-market-decision), .root :global(.scan-terminal.light .scan-ai-market-decision),
.root :global(.scan-terminal.light .scan-ai-market-decision-stats small), .root :global(.scan-terminal.light .scan-ai-market-decision-stats small),
.root :global(.scan-terminal.light .scan-ai-decision-metrics span), .root :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root :global(.scan-terminal.light .scan-ai-market-bucket), .root :global(.scan-terminal.light .scan-ai-market-bucket),
.root :global(.scan-terminal.light .scan-ai-city-pills span), .root :global(.scan-terminal.light .scan-city-pills span),
.root :global(.scan-terminal.light .scan-ai-city-freshness span), .root :global(.scan-terminal.light .scan-city-freshness span),
.root :global(.scan-terminal.light .scan-ai-city-metrics > span) { .root :global(.scan-terminal.light .scan-city-metrics > span) {
background: var(--bg-secondary); background: var(--bg-secondary);
border-color: var(--border-glass); border-color: var(--border-glass);
} }
.root :global(.scan-terminal.light .scan-ai-city-metrics > span.primary) { .root :global(.scan-terminal.light .scan-city-metrics > span.primary) {
background: #dbeafe; background: #dbeafe;
border-color: rgba(59, 130, 246, 0.28); border-color: rgba(59, 130, 246, 0.28);
} }
.root :global(.scan-terminal.light .scan-ai-city-metrics > span.primary b) { .root :global(.scan-terminal.light .scan-city-metrics > span.primary b) {
color: #1d4ed8; color: #1d4ed8;
} }
.root :global(.scan-terminal.light .scan-ai-city-metrics small) { .root :global(.scan-terminal.light .scan-city-metrics small) {
color: #64748b; color: #64748b;
} }
.root :global(.scan-terminal.light .scan-ai-workspace-head strong), .root :global(.scan-terminal.light .scan-ai-workspace-head strong),
.root :global(.scan-terminal.light .scan-ai-city-hero h3), .root :global(.scan-terminal.light .scan-city-hero h3),
.root :global(.scan-terminal.light .scan-ai-city-metrics b), .root :global(.scan-terminal.light .scan-city-metrics b),
.root :global(.scan-terminal.light .scan-ai-decision-band strong), .root :global(.scan-terminal.light .scan-ai-decision-band strong),
.root :global(.scan-terminal.light .scan-ai-decision-metrics b), .root :global(.scan-terminal.light .scan-ai-decision-metrics b),
.root :global(.scan-terminal.light .scan-ai-market-bucket strong), .root :global(.scan-terminal.light .scan-ai-market-bucket strong),
@@ -1071,18 +1071,18 @@
} }
.root :global(.scan-terminal.light .scan-ai-workspace-head p), .root :global(.scan-terminal.light .scan-ai-workspace-head p),
.root :global(.scan-terminal.light .scan-ai-city-section p), .root :global(.scan-terminal.light .scan-city-section p),
.root :global(.scan-terminal.light .scan-ai-decision-band p), .root :global(.scan-terminal.light .scan-ai-decision-band p),
.root :global(.scan-terminal.light .scan-ai-city-pills span), .root :global(.scan-terminal.light .scan-city-pills span),
.root :global(.scan-terminal.light .scan-ai-city-freshness), .root :global(.scan-terminal.light .scan-city-freshness),
.root :global(.scan-terminal.light .scan-ai-decision-metrics span), .root :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root :global(.scan-terminal.light .scan-ai-market-bucket span), .root :global(.scan-terminal.light .scan-ai-market-bucket span),
.root :global(.scan-terminal.light .scan-ai-market-decision span), .root :global(.scan-terminal.light .scan-ai-market-decision span),
.root :global(.scan-terminal.light .scan-ai-market-decision p), .root :global(.scan-terminal.light .scan-ai-market-decision p),
.root :global(.scan-terminal.light .scan-ai-market-decision-stats small), .root :global(.scan-terminal.light .scan-ai-market-decision-stats small),
.root :global(.scan-terminal.light .scan-ai-city-muted), .root :global(.scan-terminal.light .scan-city-muted),
.root :global(.scan-terminal.light .scan-ai-city-loading), .root :global(.scan-terminal.light .scan-city-loading),
.root :global(.scan-terminal.light .scan-ai-city-chart-legend), .root :global(.scan-terminal.light .scan-city-chart-legend),
.root :global(.scan-terminal.light .scan-ai-weather-bullets) { .root :global(.scan-terminal.light .scan-ai-weather-bullets) {
color: var(--text-muted); color: var(--text-muted);
} }
@@ -1092,7 +1092,7 @@
color: #64748b; color: #64748b;
} }
.root :global(.scan-terminal.light .scan-ai-city-chart-placeholder) { .root :global(.scan-terminal.light .scan-city-chart-placeholder) {
border-color: #cbd5e1; border-color: #cbd5e1;
background: #f8fafc; background: #f8fafc;
color: #64748b; color: #64748b;
@@ -1152,25 +1152,25 @@
} }
.root :global(.scan-terminal.light .scan-ai-summary-card), .root :global(.scan-terminal.light .scan-ai-summary-card),
.root :global(.scan-terminal.light .scan-ai-city-card) { .root :global(.scan-terminal.light .scan-city-card) {
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
border-color: rgba(148, 163, 184, 0.28); border-color: rgba(148, 163, 184, 0.28);
} }
.root :global(.scan-terminal.light .scan-ai-summary-card strong), .root :global(.scan-terminal.light .scan-ai-summary-card strong),
.root :global(.scan-terminal.light .scan-ai-city-head strong), .root :global(.scan-terminal.light .scan-city-head strong),
.root :global(.scan-terminal.light .scan-ai-contract b) { .root :global(.scan-terminal.light .scan-ai-contract b) {
color: var(--text-primary); color: var(--text-primary);
} }
.root :global(.scan-terminal.light .scan-ai-summary-card p), .root :global(.scan-terminal.light .scan-ai-summary-card p),
.root :global(.scan-terminal.light .scan-ai-city-head p), .root :global(.scan-terminal.light .scan-city-head p),
.root :global(.scan-terminal.light .scan-ai-contract p), .root :global(.scan-terminal.light .scan-ai-contract p),
.root :global(.scan-terminal.light .scan-ai-contract small) { .root :global(.scan-terminal.light .scan-ai-contract small) {
color: var(--text-muted); color: var(--text-muted);
} }
.root :global(.scan-terminal.light .scan-ai-city-head) { .root :global(.scan-terminal.light .scan-city-head) {
background: #f8fafc; background: #f8fafc;
} }
@@ -1334,57 +1334,57 @@
} }
/* ── AI city card light overrides ── */ /* ── AI city card light overrides ── */
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-card), :global(html.light) .root :global(.scan-terminal.light .scan-city-card),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-section), :global(html.light) .root :global(.scan-terminal.light .scan-city-section),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-band), :global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-band),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-market-decision), :global(html.light) .root :global(.scan-terminal.light .scan-ai-market-decision),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-metrics span), :global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-metrics span),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-pills span), :global(html.light) .root :global(.scan-terminal.light .scan-city-pills span),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-freshness span), :global(html.light) .root :global(.scan-terminal.light .scan-city-freshness span),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-metrics > span), :global(html.light) .root :global(.scan-terminal.light .scan-city-metrics > span),
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-metrics span), :global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-metrics span),
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-reason) { :global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-reason) {
background: #eef7ff; background: #eef7ff;
border-color: rgba(37, 99, 235, 0.16); border-color: rgba(37, 99, 235, 0.16);
} }
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-metrics > span.primary) { :global(html.light) .root :global(.scan-terminal.light .scan-city-metrics > span.primary) {
background: #dbeafe; background: #dbeafe;
border-color: rgba(59, 130, 246, 0.34); border-color: rgba(59, 130, 246, 0.34);
} }
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-metrics b) { :global(html.light) .root :global(.scan-terminal.light .scan-city-metrics b) {
color: var(--text-primary); color: var(--text-primary);
} }
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-metrics > span.primary b) { :global(html.light) .root :global(.scan-terminal.light .scan-city-metrics > span.primary b) {
color: #1d4ed8; color: #1d4ed8;
} }
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-metrics small) { :global(html.light) .root :global(.scan-terminal.light .scan-city-metrics small) {
color: #64748b; color: #64748b;
} }
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-card p), :global(html.light) .root :global(.scan-terminal.light .scan-city-card p),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-card li), :global(html.light) .root :global(.scan-terminal.light .scan-city-card li),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-card small), :global(html.light) .root :global(.scan-terminal.light .scan-city-card small),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-card span:not(.scan-ai-city-kicker)) { :global(html.light) .root :global(.scan-terminal.light .scan-city-card span:not(.scan-city-kicker)) {
color: var(--text-secondary); color: var(--text-secondary);
} }
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-status-tag.green) { :global(html.light) .root :global(.scan-terminal.light .scan-city-status-tag.green) {
color: #047857; color: #047857;
} }
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-status-tag.blue) { :global(html.light) .root :global(.scan-terminal.light .scan-city-status-tag.blue) {
color: #1d4ed8; color: #1d4ed8;
} }
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-status-tag.amber) { :global(html.light) .root :global(.scan-terminal.light .scan-city-status-tag.amber) {
color: #b45309; color: #b45309;
} }
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-status-tag.red) { :global(html.light) .root :global(.scan-terminal.light .scan-city-status-tag.red) {
color: #b91c1c; color: #b91c1c;
} }
@@ -1398,7 +1398,7 @@
} }
:global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement-copy strong), :global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement-copy strong),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-mobile-priority b), :global(html.light) .root :global(.scan-terminal.light .scan-city-mobile-priority b),
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-metrics b), :global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-metrics b),
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-reason), :global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-reason),
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-head h3), :global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-head h3),
@@ -1408,7 +1408,7 @@
:global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement-copy p), :global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement-copy p),
:global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement li), :global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement li),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-mobile-priority small), :global(html.light) .root :global(.scan-terminal.light .scan-city-mobile-priority small),
:global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-metrics small), :global(html.light) .root :global(.scan-terminal.light .scan-mobile-decision-metrics small),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-market-mobile-line span) { :global(html.light) .root :global(.scan-terminal.light .scan-ai-market-mobile-line span) {
color: var(--text-secondary); color: var(--text-secondary);
@@ -1419,7 +1419,7 @@
} }
:global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement li), :global(html.light) .root :global(.scan-terminal.light .scan-upgrade-announcement li),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-mobile-priority span), :global(html.light) .root :global(.scan-terminal.light .scan-city-mobile-priority span),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-why), :global(html.light) .root :global(.scan-terminal.light .scan-ai-decision-why),
:global(html.light) .root :global(.scan-terminal.light .scan-ai-market-mobile-line) { :global(html.light) .root :global(.scan-terminal.light .scan-ai-market-mobile-line) {
background: #eef7ff; background: #eef7ff;
@@ -1431,7 +1431,7 @@
} }
/* ── Section titles ── */ /* ── Section titles ── */
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-section-title) { :global(html.light) .root :global(.scan-terminal.light .scan-city-section-title) {
color: #1d4ed8; color: #1d4ed8;
} }
@@ -1448,11 +1448,11 @@
} }
/* ── Scrollbar light overrides ── */ /* ── Scrollbar light overrides ── */
:global(html.light) .root :global(.scan-terminal.light .scan-ai-city-body) { :global(html.light) .root :global(.scan-terminal.light .scan-city-body) {
scrollbar-color: rgba(37, 99, 235, 0.18) transparent; scrollbar-color: rgba(37, 99, 235, 0.18) transparent;
} }
:global(html.light) .root :global(.scan-ai-city-body::-webkit-scrollbar-thumb) { :global(html.light) .root :global(.scan-city-body::-webkit-scrollbar-thumb) {
background: rgba(37, 99, 235, 0.22); background: rgba(37, 99, 235, 0.22);
} }
@@ -517,20 +517,20 @@
margin: 0; margin: 0;
} }
.root :global(.scan-ai-city-list) { .root :global(.scan-city-list) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
.root :global(.scan-ai-city-card) { .root :global(.scan-city-card) {
border: 1px solid rgba(68, 100, 150, 0.2); border: 1px solid rgba(68, 100, 150, 0.2);
border-radius: 18px; border-radius: 18px;
overflow: hidden; overflow: hidden;
background: rgba(10, 24, 42, 0.82); background: rgba(10, 24, 42, 0.82);
} }
.root :global(.scan-ai-city-head) { .root :global(.scan-city-head) {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
@@ -539,13 +539,13 @@
background: rgba(15, 34, 57, 0.9); background: rgba(15, 34, 57, 0.9);
} }
.root :global(.scan-ai-city-head strong) { .root :global(.scan-city-head strong) {
color: #f2f8ff; color: #f2f8ff;
font-size: 17px; font-size: 17px;
font-weight: 900; font-weight: 900;
} }
.root :global(.scan-ai-city-head p) { .root :global(.scan-city-head p) {
margin: 6px 0 0; margin: 6px 0 0;
color: #9fb4d2; color: #9fb4d2;
font-size: 13px; font-size: 13px;
@@ -553,7 +553,7 @@
line-height: 1.45; line-height: 1.45;
} }
.root :global(.scan-ai-city-head span) { .root :global(.scan-city-head span) {
flex: 0 0 auto; flex: 0 0 auto;
padding: 5px 9px; padding: 5px 9px;
border-radius: 999px; border-radius: 999px;

Some files were not shown because too many files have changed in this diff Show More