Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a5bab9fad | |||
| 0fef948fac | |||
| 07c5451f1d | |||
| 51b0e41c0f | |||
| 0a46933f6a | |||
| 004439b736 | |||
| b8a1950932 | |||
| 221edb480c | |||
| ba2db42eaa | |||
| 0c76874418 | |||
| 24415ee427 | |||
| 567e41ba2a | |||
| 566c47e683 | |||
| 4b4fb92070 | |||
| 6279b8ac32 | |||
| 75eb4eb35d | |||
| 0f65591931 | |||
| 9aa6653364 | |||
| e1376108f9 | |||
| 491415427c | |||
| 53cf5b556b | |||
| 8edcedb300 | |||
| 3931d21637 | |||
| d3e76ecac9 | |||
| 4627a110bf | |||
| 1aa4fab6d8 | |||
| 135198e161 | |||
| f1d9487017 | |||
| 482dc3c9e6 | |||
| 20d64a880a | |||
| ba59aebf87 | |||
| 6943f637ad | |||
| a2d9051be1 | |||
| 2a38a19f18 | |||
| aad917ccb6 | |||
| a9ed45a3d3 | |||
| 153595ccfc | |||
| 2d019d051f | |||
| 9dc0d598a0 | |||
| f6fbff9a28 | |||
| 3bc8e73ab6 | |||
| fbea6df175 | |||
| 1e1a78e946 | |||
| 1afd3c6976 | |||
| 14e356c3c9 | |||
| f9d3fc2d96 | |||
| 7756b137a2 | |||
| a94e6ecaf9 | |||
| e896ffa7ac | |||
| 382bc99f0c | |||
| 9d61d9443d | |||
| 555de2f415 | |||
| 316540e8f4 | |||
| eb5579dbbd | |||
| 6d7f213cd9 | |||
| dca4f2d618 | |||
| ed0447f408 | |||
| 3984e12c44 | |||
| 5075cf40b8 | |||
| fd9d768c6d | |||
| c0b050d2a7 | |||
| 55cfb202da | |||
| 95b55d4ab3 | |||
| 07e28481e8 | |||
| 383011f9fe | |||
| 7443383720 | |||
| 9e91e8576c | |||
| 4ff334aec4 | |||
| 7a9573c214 | |||
| 791b2d115f | |||
| 56f397bb2e | |||
| 05a36f857b | |||
| 3f8ae44149 | |||
| 690acf1154 | |||
| 15e0cb09b8 | |||
| 6b9caf8dc2 | |||
| 39977e4431 | |||
| 4210f5f753 | |||
| c79e0f36cc | |||
| 717786b1d8 | |||
| f98ab4a9fe | |||
| 59f19407cb | |||
| 8bf7cdd687 | |||
| e6ea72c458 | |||
| 4e38659a10 | |||
| 3b34ddbf12 | |||
| d1124ae17e | |||
| ddca698406 | |||
| 3120883bb8 | |||
| 2e11e3fd56 | |||
| ef21a5808e | |||
| e909f1f4c9 | |||
| 0e2b076535 | |||
| 7fb82c5eba | |||
| a9e9eed2f1 | |||
| 8bbe8f74c1 | |||
| c0a5912246 | |||
| 9cb794655b | |||
| 43aeb6047e | |||
| 00b163a8ec | |||
| d0c8d4c11b | |||
| ccc8866229 | |||
| 3e1e06d8e1 | |||
| e111031119 | |||
| a098a5ab20 | |||
| e2ebf5132d | |||
| ad1e203adf | |||
| abe08de769 | |||
| b1edb1b7e1 | |||
| 0f497ab822 | |||
| 715d79c9b5 | |||
| 2781a97d9d | |||
| 56f31c0aa2 | |||
| 450fa43e5d | |||
| 0f00b00b5e | |||
| 1664925607 | |||
| e6a5fd2dda | |||
| e783f35a4f | |||
| 0084235b57 | |||
| b4a23578f2 | |||
| 6142a9cb65 | |||
| e74dfb4953 | |||
| e6c99c946a | |||
| 25408797f1 | |||
| 4009a26431 | |||
| b53f8a3eee | |||
| 2752156473 | |||
| 5d0f2d4505 | |||
| 8e775e0429 | |||
| c4cf69c67f | |||
| 285191a8be | |||
| 00da22e594 | |||
| f071ac504d | |||
| 7547b0caec | |||
| efef09b625 | |||
| 1ceeb7e7e0 | |||
| e0cbe57071 | |||
| 9d120d7215 | |||
| be1d93fc09 | |||
| f0b1b57ae2 | |||
| f115be1fd3 | |||
| cf35e55da4 | |||
| 33fbf17c18 | |||
| 568d22ff1d | |||
| 63038cfaea | |||
| 43cc917861 | |||
| 8e4a9ae552 | |||
| 87bf7c3575 | |||
| c5e5e73ce1 | |||
| 9a7257008e | |||
| 5fa3fb8999 | |||
| d314aa04d7 | |||
| 5b65da6049 | |||
| dd58729607 | |||
| 5d57a5e597 | |||
| 5adf397a1e | |||
| 1eed84777a | |||
| c77f5fce0c | |||
| 0d9b8446ef | |||
| e085333a03 | |||
| 05ec1fd993 | |||
| 25bd077b25 | |||
| 1494db8e09 | |||
| 081bf9cc05 | |||
| 7e17ab8a1b | |||
| 35bfd0670c | |||
| f171049f60 | |||
| 5a7d302d11 | |||
| ba012736c9 | |||
| df40e6c2ca | |||
| c43d916680 | |||
| efd66ca80c | |||
| 93fe7a9b69 | |||
| 98026e510a | |||
| afdfe03174 | |||
| 658f29c655 | |||
| 24c4c2ebf6 | |||
| 2cd2125d62 | |||
| 772f8d85f5 | |||
| 3a1b2dfd6d | |||
| bd88f717fb | |||
| 45397f1780 | |||
| 7b965ac60d | |||
| c98ff34c1e | |||
| 471cdb9fa0 | |||
| d0c0bf7034 | |||
| f320d07e03 | |||
| 84fea5fcc9 | |||
| f32a8d990a | |||
| 6c7f3ef67f | |||
| 6273921005 | |||
| 7d1bfea8da |
+20
-1
@@ -26,6 +26,14 @@ POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
|
||||
POLYWEATHER_REDIS_STREAM_KEY=stream:city_observation
|
||||
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
|
||||
POLYWEATHER_REDIS_REQUIRED=true
|
||||
# Optional Cloudflare R2 cold archive for realtime SSE/event snapshots.
|
||||
POLYWEATHER_R2_ARCHIVE_SOURCE=redis
|
||||
POLYWEATHER_R2_ACCOUNT_ID=
|
||||
POLYWEATHER_R2_BUCKET=
|
||||
POLYWEATHER_R2_ENDPOINT_URL=
|
||||
POLYWEATHER_R2_REGION=auto
|
||||
POLYWEATHER_R2_ACCESS_KEY_ID=
|
||||
POLYWEATHER_R2_SECRET_ACCESS_KEY=
|
||||
# Backend CORS allowlist. Add your Vercel production/preview domains when
|
||||
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL points browsers directly at this backend.
|
||||
WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather.top,https://www.polyweather.top,https://api.polyweather.top
|
||||
@@ -59,7 +67,7 @@ TELEGRAM_AIRPORT_PUSH_LANGUAGE=both
|
||||
POLYWEATHER_BOT_CPUS=0.75
|
||||
POLYWEATHER_BOT_MEM_LIMIT=768m
|
||||
POLYWEATHER_BOT_MEMSWAP_LIMIT=1g
|
||||
POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC=180
|
||||
POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC=60
|
||||
POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS=1
|
||||
|
||||
########################################
|
||||
@@ -75,6 +83,7 @@ OPEN_METEO_MIN_CALL_INTERVAL_SEC=5
|
||||
POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=1
|
||||
POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=600
|
||||
POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=45
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS=3000
|
||||
POLYWEATHER_HTTP_TIMEOUT_SEC=8
|
||||
POLYWEATHER_HTTP_RETRY_COUNT=0
|
||||
POLYWEATHER_HTTP_RETRY_BACKOFF_SEC=0.2
|
||||
@@ -123,6 +132,10 @@ POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
|
||||
########################################
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
POLYWEATHER_TURNSTILE_BYPASS=false
|
||||
POLYWEATHER_TURNSTILE_ENFORCE_ACTION=false
|
||||
POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT=false
|
||||
# Optional: disable homepage city summary preloading. Default is enabled.
|
||||
NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES=false
|
||||
# Optional: browser-visible FastAPI base URL for Vercel deployments.
|
||||
@@ -161,6 +174,12 @@ POLYWEATHER_WEEKLY_REWARD_CHECK_INTERVAL_SEC=300
|
||||
POLYWEATHER_WEEKLY_REWARD_HTTP_TIMEOUT_SEC=10
|
||||
POLYWEATHER_WEEKLY_REWARD_ANNOUNCE_ENABLED=true
|
||||
|
||||
# Verified-user growth milestone rewards
|
||||
POLYWEATHER_GROWTH_REWARD_ENABLED=false
|
||||
POLYWEATHER_GROWTH_REWARD_CHECK_INTERVAL_SEC=21600
|
||||
POLYWEATHER_GROWTH_REWARD_HTTP_TIMEOUT_SEC=15
|
||||
POLYWEATHER_GROWTH_REWARD_ANNOUNCE_ENABLED=true
|
||||
|
||||
# Group message points
|
||||
POLYWEATHER_BOT_MESSAGE_POINTS=4
|
||||
POLYWEATHER_BOT_MESSAGE_DAILY_CAP=40
|
||||
|
||||
@@ -19,6 +19,12 @@ SUPABASE_SERVICE_ROLE_KEY=
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
########################################
|
||||
# Cloudflare Turnstile
|
||||
########################################
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
POLYWEATHER_TURNSTILE_SECRET_KEY=
|
||||
|
||||
########################################
|
||||
# Entitlement / dashboard
|
||||
########################################
|
||||
@@ -40,6 +46,14 @@ POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
|
||||
|
||||
########################################
|
||||
# Cloudflare R2 archive
|
||||
########################################
|
||||
POLYWEATHER_R2_ACCOUNT_ID=
|
||||
POLYWEATHER_R2_BUCKET=
|
||||
POLYWEATHER_R2_ACCESS_KEY_ID=
|
||||
POLYWEATHER_R2_SECRET_ACCESS_KEY=
|
||||
|
||||
########################################
|
||||
# Optional exchange / market secrets
|
||||
########################################
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt -r requirements-dev.txt
|
||||
pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.lock -r requirements-dev.lock
|
||||
|
||||
- name: Ruff
|
||||
run: python -m ruff check .
|
||||
@@ -93,6 +93,7 @@ jobs:
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${{ secrets.NEXT_PUBLIC_TURNSTILE_SITE_KEY || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -109,6 +110,7 @@ jobs:
|
||||
--build-arg "NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID}"
|
||||
--build-arg "NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL}"
|
||||
--build-arg "NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS}"
|
||||
--build-arg "NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY}"
|
||||
)
|
||||
fi
|
||||
|
||||
@@ -116,6 +118,29 @@ jobs:
|
||||
docker push "${IMAGE_TAG}:latest"
|
||||
docker push "${IMAGE_TAG}:${GITHUB_SHA}"
|
||||
|
||||
cloudflare-cache-rules:
|
||||
needs: [python-quality]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Apply Cloudflare cache rules
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
run: |
|
||||
if [ -z "${CLOUDFLARE_API_TOKEN}" ]; then
|
||||
echo "CLOUDFLARE_API_TOKEN is not configured; skipping Cache Rules sync"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "${CLOUDFLARE_ZONE_ID}" ]; then
|
||||
echo "CLOUDFLARE_ZONE_ID is not configured; skipping Cache Rules sync"
|
||||
exit 0
|
||||
fi
|
||||
python scripts/configure_cloudflare_free.py --apply
|
||||
|
||||
deploy:
|
||||
needs: [build-and-push]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
+7
-1
@@ -9,6 +9,7 @@ data/*.db
|
||||
data/*.db-*
|
||||
data/*.db.*
|
||||
data/*.json
|
||||
!data/city_thread_ids.json
|
||||
data/*backtest*.csv
|
||||
data/logs/
|
||||
data/historical/
|
||||
@@ -43,6 +44,11 @@ Thumbs.db
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
frontend/.vercel/
|
||||
frontend/.env
|
||||
frontend/.env.local
|
||||
frontend/.env.production
|
||||
frontend/.env.*
|
||||
!frontend/.env.example
|
||||
frontend/*.tsbuildinfo
|
||||
frontend/.codex-next-dev*.log
|
||||
frontend/.codex-next-start*.log
|
||||
@@ -70,6 +76,6 @@ frontend/.next-start.log
|
||||
tmp_apikey.js
|
||||
tmp_obs.js
|
||||
tmp_rctp.html
|
||||
playwright-home-check.png
|
||||
.codex-backend-*.log
|
||||
frontend-next-*.log
|
||||
*.stackdump
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@
|
||||
- 一键部署脚本:deploy.sh + deploy.ps1
|
||||
|
||||
### 移除
|
||||
- 删除 LGBM 全部代码和模型文件,EMOS 简化为纯 legacy 高斯分桶
|
||||
- 删除 LGBM 全部代码和模型文件,概率路径收口为 legacy 高斯分桶
|
||||
- 删除 Polymarket 价格拉取与 UI 层(MarketDecisionLine)
|
||||
- 删除 Groq、Meteoblue、NMC、俄罗斯 pogodaiklimat 数据源
|
||||
- 删除预热(prewarm)系统
|
||||
|
||||
@@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
PolyWeather Pro — a paid institutional weather-intelligence terminal. 50 monitored cities with real-time METAR/AMOS/MADIS observations, DEB multi-model temperature blending, Mu probability calibration, and intraday bias correction. Pure meteorological decision workspace; no market/price layer. Next.js 15 + React 19 (Vercel) frontend, FastAPI backend (VPS), Telegram bot.
|
||||
PolyWeather Pro — a paid institutional weather-intelligence terminal. 50 monitored cities with real-time METAR/AMOS/MADIS observations, DEB multi-model temperature blending, Mu probability calibration, and intraday bias correction. Pure meteorological decision workspace; no market/price layer. Next.js 15 + React 19 frontend (Docker / VPS, behind Cloudflare + Nginx), FastAPI backend (VPS), Telegram bot.
|
||||
|
||||
**Business model**: Paid-only, $10/month, no free tier, no trial. Landing page is public; `/terminal` requires login + active subscription.
|
||||
**Business model**: Paid-only, 29.9 USDC/month or 79.9 USDC/quarter, referral first month 20 USDC. New users get a one-time 3-day trial. Landing page is public; `/terminal` requires login + active subscription.
|
||||
|
||||
## Environment & Preferences
|
||||
|
||||
@@ -47,10 +47,12 @@ docker compose down && docker compose up -d --build
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Users → Next.js (Vercel) → FastAPI :8000 (VPS)
|
||||
/terminal (paid gate) Weather Collector
|
||||
/ (landing page) Analysis (DEB + Mu)
|
||||
Payment Layer (USDC on Polygon)
|
||||
Users → Cloudflare → Nginx → Docker Compose (VPS)
|
||||
├── Next.js frontend → FastAPI :8000
|
||||
│ /terminal (paid gate) Weather Collector
|
||||
│ / (landing page) Analysis (DEB + Mu)
|
||||
│ Payment Layer (USDC on Polygon)
|
||||
└── Redis (SSE event store)
|
||||
Telegram Bot → bot_listener.py
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -15,10 +15,10 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
gcc libhdf5-dev libnetcdf-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
COPY requirements.lock .
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --prefer-binary -r requirements.txt
|
||||
pip install --prefer-binary --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.lock
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
|
||||
- Subscription live: `Pro Monthly 29.9 USDC / 30 days` and `Pro Quarterly 79.9 USDC / 90 days`.
|
||||
- Referral pricing live: invited users can get the first monthly Pro at `20 USDC`; inviters receive `3500` points after a valid first Pro payment, capped at 10 paid invites per month.
|
||||
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, monthly max `3 USDC`, quarterly max `8 USDC`). Useful user feedback can also receive manual point rewards through ops.
|
||||
- Points are redeemable for payment discounts (`500 pts = 1 USDC`, monthly max `3 USDC`, quarterly max `8 USDC`). Useful user feedback can also receive manual point rewards through ops.
|
||||
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e) plus Ethereum mainnet USDC direct-transfer confirmation.
|
||||
- Auto-reconciliation live: event listener + periodic confirm loop.
|
||||
- Ops dashboard live: `/ops` for memberships, leaderboard, user feedback triage, manual point grants, and payment incident triage.
|
||||
@@ -40,14 +40,13 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
- Hong Kong uses CoWIN station `6087` (Po Leung Kuk Choi Kai Yau School) as the 1-minute reference-station curve, with HKO 10-minute observations kept as the official meteorological layer.
|
||||
- Telegram airport/runway pushes are bilingual by default and use settlement-endpoint runway temperatures for slope/current/summary copy.
|
||||
- Runtime state, cache, and core offline training/backfill flows now use SQLite as the primary path; legacy JSON/JSONL files remain only for migration, export, and explicit fallback input.
|
||||
- EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout.
|
||||
- Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules.
|
||||
- Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives.
|
||||
- Terminal chart/detail workflow now combines settlement observations, DEB hourly consensus, model context, probability distribution tooltips, and market-bucket mapping without blocking the chart on AI text generation.
|
||||
- Terminal data uses page memory cache, browser `localStorage`, backend short-TTL cache, SSE patch replay, and foreground refresh so returning from another tab restores the latest visible chart state quickly.
|
||||
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
|
||||
- The market-signal difference means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
|
||||
- Calibrated model probability is now the primary probability panel. It shows the active 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 legacy Gaussian probability engine, while model consensus remains a secondary reference.
|
||||
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
|
||||
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
|
||||
- Trade cue now combines upper-air structure, `TAF`, market crowding, and `edge_percent`.
|
||||
@@ -71,7 +70,7 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
- Aggregates observations and forecasts for 51 monitored cities.
|
||||
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
|
||||
- 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 the legacy Gaussian calibration path.
|
||||
- Adds terminal chart/detail workflows that combine live observations, DEB-centered high-temperature context, market-bucket mapping, and model-market difference.
|
||||
- Shows calibrated Gaussian context in chart tooltips as `mu` plus the full temperature-range probability distribution, without reintroducing probability bands into the main temperature view.
|
||||
- Reuses one analysis core across web dashboard and Telegram bot.
|
||||
@@ -85,7 +84,7 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["Users (Web / Telegram)"] --> FE["Next.js Frontend (Vercel)"]
|
||||
U["Users (Web / Telegram)"] --> FE["Next.js Frontend (Docker / VPS)"]
|
||||
U --> BOT["Telegram Bot (VPS)"]
|
||||
FE --> API["FastAPI /web/app.py"]
|
||||
BOT --> API
|
||||
@@ -139,7 +138,7 @@ npm run dev
|
||||
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
|
||||
- Intraday analysis now separates meteorology conclusion, evidence chain, invalidation rules, confirmation rules, calibrated probability, and market reference.
|
||||
- `TAF` is used as an airport-side confirmation layer, not as the main temperature model.
|
||||
- Calibrated probability uses legacy Gaussian (default) or EMOS/CRPS when evaluated; model vote counts remain an explanatory consensus line, not the final probability.
|
||||
- Calibrated probability uses the legacy Gaussian path; model vote counts remain an explanatory consensus line, not the final probability.
|
||||
- Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience.
|
||||
- Realtime terminal charts use SSE patches plus replayable event storage; full HTTP detail remains the authoritative snapshot.
|
||||
- Chart observations are shown in the city's local time, not the browser timezone.
|
||||
@@ -160,19 +159,6 @@ POLYWEATHER_REDIS_REQUIRED=true
|
||||
|
||||
For local development or a strict single-process fallback, keep `POLYWEATHER_EVENT_STORE=sqlite`.
|
||||
|
||||
## EMOS Local Training
|
||||
|
||||
Do not run full EMOS retraining on a small VPS. The VPS should collect data and load approved calibration files; training should run on a local/dev machine using a copied production SQLite database:
|
||||
|
||||
```powershell
|
||||
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
|
||||
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
|
||||
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
|
||||
```
|
||||
|
||||
Promote a generated `default.json` only when `auto_retrain_report.json` has `ready_for_promotion=true`, and prefer `emos_shadow` before enabling `emos_primary`.
|
||||
|
||||
## Ops Verification
|
||||
|
||||
### Health / system status / metrics
|
||||
@@ -209,8 +195,6 @@ Production payment routes are configured by the backend. Polygon remains the def
|
||||
|
||||
| Command | Purpose |
|
||||
| :-- | :-- |
|
||||
| `/city <name>` | City real-time analysis |
|
||||
| `/deb <name>` | DEB historical reconciliation |
|
||||
| `/top` | User leaderboard |
|
||||
| `/id` | Show current chat ID |
|
||||
| `/diag` | Startup diagnostics |
|
||||
|
||||
+5
-26
@@ -18,7 +18,7 @@
|
||||
|
||||
- 已上线订阅制:`Pro 月付 29.9 USDC / 30 天`,`Pro 季度 79.9 USDC / 90 天`。
|
||||
- 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分。
|
||||
- `/city` 与 `/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。真实、有上下文、有价值的用户反馈也可通过运营后台人工奖励积分。
|
||||
- 积分可用于支付抵扣(`500 分 = 1 USDC`,月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。真实、有上下文、有价值的用户反馈也可通过运营后台人工奖励积分。
|
||||
- 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励。
|
||||
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)+ Ethereum 主网 USDC 直转确认。
|
||||
- 已上线自动补单:事件监听 + 周期确认双链路。
|
||||
@@ -36,7 +36,6 @@
|
||||
- 香港默认展示 CoWIN `6087`(保良局陈守仁小学)1 分钟参考站曲线,HKO 10 分钟实测保留为官方气象层。
|
||||
- Telegram 机场/跑道推送默认中英文双语,并统一使用结算端点跑道温度计算当前值、15 分钟趋势和文案。
|
||||
- 运行态状态、缓存与核心离线训练/回填链路已完成 SQLite 主路径收口;legacy JSON/JSONL 仅保留给迁移、导出与显式回退输入。
|
||||
- EMOS/CRPS 校准链路已接通,但生产主概率保持 `legacy` 或 `emos_shadow`;`emos_primary` 只在本地离线评估通过并手动灰度后启用。
|
||||
- 官方增强站网已统一接入:
|
||||
- `MGM`(土耳其)
|
||||
- `CMA/NMC`(中国内地)
|
||||
@@ -53,7 +52,7 @@
|
||||
- 终端数据同时使用页面内存缓存、浏览器 `localStorage`、后端短 TTL 缓存、SSE patch replay 和前台恢复刷新;从其他选项卡切回时会优先恢复最新可见图表状态。
|
||||
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
|
||||
- 市场信号中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
||||
- 概率区已改为”校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS),模型共识作为辅助参考。
|
||||
- 概率区已改为“校准模型概率”;默认展示 legacy 高斯概率引擎输出,模型共识作为辅助参考。
|
||||
- 今日日内结构解读以规则与结构化信号为主,AI 文案只作为可降级辅助层,不替代实测、DEB、TAF 或结算逻辑。
|
||||
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`。
|
||||
|
||||
@@ -72,7 +71,7 @@
|
||||
- 聚合 51 个监控城市的实测与预报数据。
|
||||
- DEB(Dynamic Error Balancing)融合多模型最高温。
|
||||
- 构建 DEB 加权小时共识曲线,用于峰值窗口判断和图表默认 DEB 展示。
|
||||
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。
|
||||
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯校准路径。
|
||||
- 天气决策台把结构化实况、DEB 高温路径、完整市场温度桶和模型-市场差放进图表/详情工作流。
|
||||
- 图表 tooltip 展示校准高斯上下文:`mu` 加完整温度区间概率分布,不把概率温度带重新放回主图。
|
||||
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
|
||||
@@ -84,7 +83,7 @@
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["用户(Web / Telegram)"] --> FE["Next.js 前端(Vercel)"]
|
||||
U["用户(Web / Telegram)"] --> FE["Next.js 前端(Docker / VPS)"]
|
||||
U --> BOT["Telegram Bot(VPS)"]
|
||||
FE --> API["FastAPI /web/app.py"]
|
||||
BOT --> API
|
||||
@@ -149,24 +148,6 @@ POLYWEATHER_REDIS_REQUIRED=true
|
||||
|
||||
本地开发或严格单进程兜底可使用 `POLYWEATHER_EVENT_STORE=sqlite`。
|
||||
|
||||
## EMOS 本地训练流程
|
||||
|
||||
低配 VPS 只负责采集、服务和加载已通过评估的参数,不建议在 VPS 上跑 EMOS 全量训练。训练前先从 VPS 拉 SQLite 副本到本地:
|
||||
|
||||
```powershell
|
||||
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
|
||||
```
|
||||
|
||||
本地训练:
|
||||
|
||||
```powershell
|
||||
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
|
||||
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
|
||||
```
|
||||
|
||||
只有 `auto_retrain_report.json` 中 `ready_for_promotion=true` 时,才允许把候选 `default.json` 传回 VPS,并优先以 `emos_shadow` 观察。
|
||||
|
||||
## 运维验收
|
||||
|
||||
### 健康与系统状态
|
||||
@@ -224,8 +205,6 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
|
||||
| 指令 | 用途 |
|
||||
| :-- | :-- |
|
||||
| `/city <name>` | 城市实时分析 |
|
||||
| `/deb <name>` | DEB 历史对账 |
|
||||
| `/top` | 用户积分排行 |
|
||||
| `/id` | 查看聊天 Chat ID |
|
||||
| `/diag` | Bot 启动诊断 |
|
||||
@@ -239,7 +218,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
- AGPL-3.0 边界:[docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
|
||||
- Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
||||
- 配置与密钥管理:[docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
|
||||
- 前端部署(Vercel):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
- 前端部署(Docker / VPS):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
- 技术债:[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- 机场实时数据源:[docs/AIRPORT_REALTIME_SOURCES.md](docs/AIRPORT_REALTIME_SOURCES.md)
|
||||
- 机场市场监控(中文):[docs/AIRPORT_MARKET_MONITOR_ZH.md](docs/AIRPORT_MARKET_MONITOR_ZH.md)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"amsterdam": 116398,
|
||||
"ankara": 116394,
|
||||
"atlanta": 13115,
|
||||
"austin": 13120,
|
||||
"beijing": 116383,
|
||||
"busan": 116381,
|
||||
"chengdu": 116388,
|
||||
"chicago": 13113,
|
||||
"chongqing": 116389,
|
||||
"dallas": 13119,
|
||||
"denver": 13114,
|
||||
"guangzhou": 116385,
|
||||
"helsinki": 116397,
|
||||
"hong kong": 116391,
|
||||
"houston": 13118,
|
||||
"istanbul": 116395,
|
||||
"los angeles": 13112,
|
||||
"miami": 13116,
|
||||
"new york": 13111,
|
||||
"paris": 116399,
|
||||
"qingdao": 116387,
|
||||
"san francisco": 13117,
|
||||
"seattle": 13122,
|
||||
"seoul": 116380,
|
||||
"shanghai": 116384,
|
||||
"shenzhen": 116386,
|
||||
"singapore": 116393,
|
||||
"taipei": 116392,
|
||||
"tel aviv": 116396,
|
||||
"tokyo": 116382,
|
||||
"wuhan": 116390
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
$VPS = "root@38.54.27.70"
|
||||
$VPS = "root@172.245.214.111"
|
||||
$PROJECT = "/root/PolyWeather"
|
||||
|
||||
Write-Host "🚀 Deploying to $VPS..." -ForegroundColor Cyan
|
||||
|
||||
@@ -29,6 +29,61 @@ unset GHCR_PAT
|
||||
cd "$COMPOSE_DIR"
|
||||
git fetch origin main && git reset --hard origin/main
|
||||
|
||||
sync_city_thread_ids() {
|
||||
local runtime_dir="${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}"
|
||||
local repo_file="$COMPOSE_DIR/data/city_thread_ids.json"
|
||||
local target_file="$runtime_dir/city_thread_ids.json"
|
||||
|
||||
if [ ! -f "$repo_file" ]; then
|
||||
echo "No repository city_thread_ids.json to sync"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$runtime_dir"
|
||||
REPO_CITY_THREAD_IDS_FILE="$repo_file" TARGET_CITY_THREAD_IDS_FILE="$target_file" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
repo_file = os.environ["REPO_CITY_THREAD_IDS_FILE"]
|
||||
target_file = os.environ["TARGET_CITY_THREAD_IDS_FILE"]
|
||||
|
||||
with open(repo_file, "r", encoding="utf-8") as f:
|
||||
repo_data = json.load(f)
|
||||
if not isinstance(repo_data, dict):
|
||||
raise SystemExit("repository city_thread_ids.json must contain an object")
|
||||
|
||||
target_data = {}
|
||||
if os.path.isfile(target_file) and os.path.getsize(target_file) > 0:
|
||||
try:
|
||||
with open(target_file, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, dict):
|
||||
target_data = loaded
|
||||
else:
|
||||
raise ValueError("target file is not an object")
|
||||
except Exception as exc:
|
||||
backup = f"{target_file}.invalid.{int(time.time())}"
|
||||
os.replace(target_file, backup)
|
||||
print(f"Backed up invalid city_thread_ids.json to {backup}: {exc}")
|
||||
|
||||
merged = dict(repo_data)
|
||||
merged.update(target_data)
|
||||
|
||||
if merged != target_data:
|
||||
tmp_file = f"{target_file}.tmp"
|
||||
with open(tmp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(merged, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.replace(tmp_file, target_file)
|
||||
print(f"Synced city_thread_ids.json: {len(target_data)} -> {len(merged)} cities")
|
||||
else:
|
||||
print(f"city_thread_ids.json already up to date: {len(target_data)} cities")
|
||||
PY
|
||||
}
|
||||
|
||||
sync_city_thread_ids
|
||||
|
||||
PREVIOUS_TAG=""
|
||||
if [ -f "$TAG_FILE" ]; then
|
||||
PREVIOUS_TAG=$(cat "$TAG_FILE")
|
||||
@@ -72,8 +127,54 @@ compose_up_retry() {
|
||||
return 1
|
||||
}
|
||||
|
||||
read_env_file_value() {
|
||||
local key="$1"
|
||||
if [ ! -f ".env" ]; then
|
||||
return 0
|
||||
fi
|
||||
awk -F= -v key="$key" '
|
||||
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
|
||||
value=$0
|
||||
sub("^[^=]*=", "", value)
|
||||
gsub("^[[:space:]]+|[[:space:]]+$", "", value)
|
||||
gsub(/^["'"'"']|["'"'"']$/, "", value)
|
||||
print value
|
||||
}
|
||||
' .env | tail -n 1
|
||||
}
|
||||
|
||||
resolve_env_value() {
|
||||
local primary_key="$1"
|
||||
local fallback_key="${2:-}"
|
||||
local value="${!primary_key:-}"
|
||||
|
||||
if [ -z "$value" ]; then
|
||||
value="$(read_env_file_value "$primary_key")"
|
||||
fi
|
||||
if [ -z "$value" ] && [ -n "$fallback_key" ]; then
|
||||
value="${!fallback_key:-}"
|
||||
if [ -z "$value" ]; then
|
||||
value="$(read_env_file_value "$fallback_key")"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
export IMAGE_TAG="$NEW_TAG"
|
||||
export POLYWEATHER_API_BASE_URL="${POLYWEATHER_FRONTEND_INTERNAL_API_BASE_URL:-http://polyweather_web:8000}"
|
||||
resolved_supabase_url="$(resolve_env_value "SUPABASE_URL" "NEXT_PUBLIC_SUPABASE_URL")"
|
||||
resolved_supabase_anon_key="$(resolve_env_value "SUPABASE_ANON_KEY" "NEXT_PUBLIC_SUPABASE_ANON_KEY")"
|
||||
if [ -n "$resolved_supabase_url" ]; then
|
||||
export SUPABASE_URL="$resolved_supabase_url"
|
||||
else
|
||||
unset SUPABASE_URL
|
||||
fi
|
||||
if [ -n "$resolved_supabase_anon_key" ]; then
|
||||
export SUPABASE_ANON_KEY="$resolved_supabase_anon_key"
|
||||
else
|
||||
unset SUPABASE_ANON_KEY
|
||||
fi
|
||||
pull_ok=0
|
||||
for pull_attempt in $(seq 1 6); do
|
||||
docker compose pull && pull_ok=1 && break
|
||||
@@ -152,20 +253,43 @@ warm_public_route() {
|
||||
return 0
|
||||
}
|
||||
|
||||
read_env_file_value() {
|
||||
local key="$1"
|
||||
if [ ! -f ".env" ]; then
|
||||
return 0
|
||||
fi
|
||||
awk -F= -v key="$key" '
|
||||
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
|
||||
value=$0
|
||||
sub("^[^=]*=", "", value)
|
||||
gsub("^[[:space:]]+|[[:space:]]+$", "", value)
|
||||
gsub(/^["'"'"']|["'"'"']$/, "", value)
|
||||
print value
|
||||
}
|
||||
' .env | tail -n 1
|
||||
wait_for_scan_terminal_snapshot() {
|
||||
local name="$1"
|
||||
local url="$2"
|
||||
local timeout="${3:-35}"
|
||||
local attempts="${4:-8}"
|
||||
local delay="${5:-5}"
|
||||
local output=""
|
||||
local body=""
|
||||
local compact=""
|
||||
local http_status=""
|
||||
local status=""
|
||||
|
||||
for i in $(seq 1 "$attempts"); do
|
||||
if output=$(curl -sS -w "\nhttp=%{http_code}" --max-time "$timeout" "$url" 2>&1); then
|
||||
http_status="$(printf '%s\n' "$output" | sed -n 's/^http=//p' | tail -n 1)"
|
||||
body="$(printf '%s\n' "$output" | sed '$d')"
|
||||
if [ "$http_status" = "401" ]; then
|
||||
echo "✅ $name protected after attempt $i/$attempts (http=401)"
|
||||
return 0
|
||||
fi
|
||||
compact="$(printf '%s' "$body" | tr -d '\n\r\t ')"
|
||||
if printf '%s' "$compact" | grep -q '"status":"ready"'; then
|
||||
echo "✅ $name ready after attempt $i/$attempts"
|
||||
return 0
|
||||
fi
|
||||
status="$(printf '%s' "$compact" | sed -n 's/.*"status":"\([^"]*\)".*/\1/p' | head -n 1)"
|
||||
echo " $name not ready attempt $i/$attempts http=${http_status:-unknown} status=${status:-unknown}"
|
||||
else
|
||||
echo " $name request failed attempt $i/$attempts ($output)"
|
||||
fi
|
||||
if [ "$i" != "$attempts" ]; then
|
||||
sleep "$delay"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "❌ $name did not return status=ready or http=401"
|
||||
return 1
|
||||
}
|
||||
|
||||
validate_frontend_api_base_url() {
|
||||
@@ -223,12 +347,19 @@ fi
|
||||
echo "Updating observation collector..."
|
||||
compose_up_retry "observation collector" -d --no-deps polyweather_collector
|
||||
|
||||
echo "Updating cache warmer..."
|
||||
compose_up_retry "cache warmer" -d --no-deps polyweather_warmer
|
||||
|
||||
echo "Updating training settlement worker..."
|
||||
compose_up_retry "training settlement" -d --no-deps polyweather_training_settlement
|
||||
|
||||
echo "Updating frontend..."
|
||||
compose_up_retry "frontend" -d --no-deps polyweather_frontend
|
||||
|
||||
echo "Waiting for frontend..."
|
||||
wait_for_local_service "frontend root" "http://127.0.0.1:3001/" 5 40 2 || FAILED_FRONTEND=1
|
||||
wait_for_local_service "frontend terminal" "http://127.0.0.1:3001/terminal" 10 20 2 || FAILED_FRONTEND=1
|
||||
wait_for_scan_terminal_snapshot "scan terminal snapshot" "http://127.0.0.1:3001/api/scan/terminal" 35 8 5 || FAILED_FRONTEND=1
|
||||
FAILED_FRONTEND="${FAILED_FRONTEND:-0}"
|
||||
if [ "$FAILED_FRONTEND" = "1" ]; then
|
||||
echo "❌ Frontend did not become healthy"
|
||||
@@ -237,6 +368,7 @@ if [ "$FAILED_FRONTEND" = "1" ]; then
|
||||
fi
|
||||
|
||||
warm_public_route "terminal" "https://polyweather.top/terminal" 20 4 3
|
||||
warm_public_route "scan terminal" "https://polyweather.top/api/scan/terminal" 35 3 2
|
||||
warm_public_route "auth snapshot" "https://polyweather.top/api/auth/me?prefer_snapshot=1" 10 3 2
|
||||
warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?refresh_deb_recent=1" 15 2 2
|
||||
warm_public_route "cities" "https://polyweather.top/api/cities" 20 3 2
|
||||
|
||||
@@ -9,6 +9,19 @@ server {
|
||||
proxy_buffers 8 16k;
|
||||
proxy_busy_buffers_size 32k;
|
||||
|
||||
location /api/events {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_set_header Connection '';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
+121
-8
@@ -2,7 +2,7 @@ services:
|
||||
polyweather_redis:
|
||||
image: redis:7-alpine
|
||||
container_name: polyweather_redis
|
||||
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy noeviction
|
||||
command: redis-server --appendonly yes --maxmemory ${POLYWEATHER_REDIS_MAXMEMORY:-512mb} --maxmemory-policy noeviction
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
interval: 10s
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: bot
|
||||
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC: ${POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC:-180}
|
||||
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC: ${POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC:-60}
|
||||
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS: ${POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS:-1}
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
@@ -69,12 +69,18 @@ services:
|
||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://polyweather.top}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-}
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL:-https://polygon-bor-rpc.publicnode.com}
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID:-}
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN: ${POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN}
|
||||
POLYWEATHER_API_BASE_URL: http://polyweather_web:8000
|
||||
POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true}
|
||||
POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true}
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS: ${POLYWEATHER_OPS_ADMIN_EMAILS:-}
|
||||
POLYWEATHER_TURNSTILE_BYPASS: ${POLYWEATHER_TURNSTILE_BYPASS:-false}
|
||||
POLYWEATHER_TURNSTILE_ENFORCE_ACTION: ${POLYWEATHER_TURNSTILE_ENFORCE_ACTION:-false}
|
||||
POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT: ${POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT:-false}
|
||||
POLYWEATHER_TURNSTILE_SECRET_KEY: ${POLYWEATHER_TURNSTILE_SECRET_KEY:-}
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
retries: 3
|
||||
@@ -100,20 +106,38 @@ services:
|
||||
env_file: *id001
|
||||
environment:
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_R2_ACCESS_KEY_ID: ${POLYWEATHER_R2_ACCESS_KEY_ID:-}
|
||||
POLYWEATHER_R2_ACCOUNT_ID: ${POLYWEATHER_R2_ACCOUNT_ID:-}
|
||||
POLYWEATHER_R2_ARCHIVE_SOURCE: ${POLYWEATHER_R2_ARCHIVE_SOURCE:-redis}
|
||||
POLYWEATHER_R2_BUCKET: ${POLYWEATHER_R2_BUCKET:-}
|
||||
POLYWEATHER_R2_ENDPOINT_URL: ${POLYWEATHER_R2_ENDPOINT_URL:-}
|
||||
POLYWEATHER_R2_REGION: ${POLYWEATHER_R2_REGION:-auto}
|
||||
POLYWEATHER_R2_SECRET_ACCESS_KEY: ${POLYWEATHER_R2_SECRET_ACCESS_KEY:-}
|
||||
POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}
|
||||
POLYWEATHER_REDIS_REQUIRED: ${POLYWEATHER_REDIS_REQUIRED:-true}
|
||||
POLYWEATHER_REDIS_STREAM_MAXLEN: ${POLYWEATHER_REDIS_STREAM_MAXLEN:-100000}
|
||||
POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_ENABLED: ${POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_ENABLED:-true}
|
||||
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-1}
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-1}
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-3000}
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-3}
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-3}
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS:-3000}
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-8000}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-180}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-5}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC: '30'
|
||||
POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS: ${POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS:-4}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: ${POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED:-false}
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN: ${POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN}
|
||||
POLYWEATHER_SERVICE_ROLE: web
|
||||
SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
|
||||
SUPABASE_URL: ${SUPABASE_URL}
|
||||
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
@@ -154,13 +178,14 @@ services:
|
||||
environment:
|
||||
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ${POLYWEATHER_COLLECTOR_PATCH_ENDPOINT:-http://polyweather_web:8000/api/internal/collector-patch}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-180}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-15}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_CACHE_REFRESH_WORKERS: ${POLYWEATHER_OBSERVATION_COLLECTOR_CACHE_REFRESH_WORKERS:-2}
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: collector
|
||||
@@ -183,6 +208,94 @@ services:
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
polyweather_warmer:
|
||||
command: python -m web.cache_warmer_worker
|
||||
container_name: polyweather_warmer
|
||||
depends_on:
|
||||
polyweather_redis:
|
||||
condition: service_healthy
|
||||
polyweather_web:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
cpus: ${POLYWEATHER_WARMER_CPUS:-0.75}
|
||||
env_file: *id001
|
||||
environment:
|
||||
POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: warmer
|
||||
POLYWEATHER_WARMER_CITY_BATCH_SIZE: ${POLYWEATHER_WARMER_CITY_BATCH_SIZE:-16}
|
||||
POLYWEATHER_WARMER_CITY_INTERVAL_SEC: ${POLYWEATHER_WARMER_CITY_INTERVAL_SEC:-30}
|
||||
POLYWEATHER_WARMER_ENABLED: ${POLYWEATHER_WARMER_ENABLED:-true}
|
||||
POLYWEATHER_WARMER_SCAN_INTERVAL_SEC: ${POLYWEATHER_WARMER_SCAN_INTERVAL_SEC:-120}
|
||||
POLYWEATHER_WARMER_TICK_SEC: ${POLYWEATHER_WARMER_TICK_SEC:-30}
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
|
||||
c.execute('SELECT 1'); c.close()
|
||||
timeout: 10s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
mem_limit: ${POLYWEATHER_WARMER_MEM_LIMIT:-768m}
|
||||
memswap_limit: ${POLYWEATHER_WARMER_MEMSWAP_LIMIT:-1g}
|
||||
pids_limit: 256
|
||||
restart: unless-stopped
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
polyweather_training_settlement:
|
||||
command: python -m web.training_settlement_worker
|
||||
container_name: polyweather_training_settlement
|
||||
depends_on:
|
||||
polyweather_redis:
|
||||
condition: service_healthy
|
||||
polyweather_web:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
cpus: ${POLYWEATHER_TRAINING_SETTLEMENT_CPUS:-0.50}
|
||||
env_file: *id001
|
||||
environment:
|
||||
POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: training_settlement
|
||||
POLYWEATHER_TRAINING_SETTLEMENT_INITIAL_DELAY_SEC: ${POLYWEATHER_TRAINING_SETTLEMENT_INITIAL_DELAY_SEC:-60}
|
||||
POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC: ${POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC:-21600}
|
||||
POLYWEATHER_TRAINING_SETTLEMENT_LOOKBACK_DAYS: ${POLYWEATHER_TRAINING_SETTLEMENT_LOOKBACK_DAYS:-10}
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
|
||||
c.execute('SELECT 1'); c.close()
|
||||
timeout: 10s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
mem_limit: ${POLYWEATHER_TRAINING_SETTLEMENT_MEM_LIMIT:-768m}
|
||||
memswap_limit: ${POLYWEATHER_TRAINING_SETTLEMENT_MEMSWAP_LIMIT:-1g}
|
||||
pids_limit: 256
|
||||
restart: unless-stopped
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
x-polyweather-base:
|
||||
env_file: *id001
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
## 1. 基础信息
|
||||
|
||||
- 后端直连:`http://127.0.0.1:8000`
|
||||
- 前端 BFF:`https://polyweather-pro.vercel.app/api/*`
|
||||
- 前端 BFF:`https://polyweather.top/api/*`
|
||||
- 返回格式:`application/json`
|
||||
|
||||
## 2. 请求链路
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# Cloudflare 免费版缓存配置
|
||||
|
||||
目标:让 Cloudflare 承担公开页面、公开 API 和静态资源的重复读取,降低 VPS CPU、Next.js worker 和后端 detail-batch 压力,同时避免缓存账户、支付、反馈和实时连接。
|
||||
|
||||
代码已通过 `Cache-Control` 与 `Cloudflare-CDN-Cache-Control` 声明 TTL。由于免费版会严格遵守源站的 `max-age=0`,稳定公开路径的 Cache Rules 额外按状态码覆盖 Edge TTL;可能返回 partial、stale 或 failed 的接口继续尊重源站缓存头。不要给所有 `/api/*` 统一启用 Cache Everything。
|
||||
|
||||
缓存规则只允许作用于前端域名 `polyweather.top`。`api.polyweather.top` 是带服务令牌和会员校验的后端源站,必须绕过 Cloudflare 缓存,避免缓存命中绕过后端权限检查。
|
||||
|
||||
## 已接入的免费能力
|
||||
|
||||
- Cache Rules:仓库脚本 `scripts/configure_cloudflare_free.py` 同步公开缓存规则,`scripts/validate_frontend_cache.sh` 可检查 `CF-Cache-Status`。
|
||||
- Turnstile:登录/注册经 Supabase Auth captcha token;反馈提交和支付创建订单在 Next API Route 校验后再转发给后端。
|
||||
- R2:`scripts/archive_realtime_events_to_r2.py` 可把 Redis/SQLite 中的 SSE 事件按天归档为 JSONL,上传到 Cloudflare R2。
|
||||
|
||||
暂不建议把 Tunnel 作为本轮替代 Nginx:当前 Nginx 还承担 TLS、回源隔离和本地 compose 端口映射,Tunnel 需要单独灰度和回滚方案。
|
||||
|
||||
## 基础设置
|
||||
|
||||
- `Caching > Configuration > Browser Cache TTL`:Respect Existing Headers
|
||||
- `Caching > Configuration > Development Mode`:Off
|
||||
- `Caching > Configuration > Always Online`:On
|
||||
- `Caching > Tiered Cache`:Smart Tiered Cache,若当前免费套餐控制台提供则开启
|
||||
- `Speed > Optimization > Content Optimization > Brotli`:On
|
||||
- `Speed > Optimization > Content Optimization > Early Hints`:On
|
||||
- `Network > HTTP/3 (with QUIC)`:On
|
||||
- `Network > WebSockets`:On
|
||||
- 确保 `api.polyweather.top` 与 `polyweather.top` 代理状态为 Proxied
|
||||
|
||||
## Cache Rules
|
||||
|
||||
按以下顺序创建。Cloudflare 同一阶段最后匹配的规则生效,因此绕过规则必须放在公开缓存规则之后。免费版规则数量有限,因此使用路径集合合并表达式。
|
||||
|
||||
也可以使用仓库内脚本自动创建或更新规则。脚本会保留非 PolyWeather 规则,并把绕过规则放在最后。
|
||||
|
||||
Cloudflare 新版 Token UI 中,文档里的 Cache Rules 权限通常显示为:
|
||||
|
||||
- 权限策略资源:`指定域名` -> `polyweather.top`
|
||||
- `Cache & Performance` -> `Cache Settings` -> `Edit`
|
||||
|
||||
为了避免 Token 还需要列出 Zone,请同时在 GitHub Secrets 配置 `CLOUDFLARE_ZONE_ID`。Zone ID 在 Cloudflare 进入 `polyweather.top` 后,右侧 API 区域或 Overview 页面可以复制。
|
||||
|
||||
GitHub Actions 需要两个仓库 Secret:
|
||||
|
||||
- `CLOUDFLARE_API_TOKEN`
|
||||
- `CLOUDFLARE_ZONE_ID`
|
||||
|
||||
```powershell
|
||||
$env:CLOUDFLARE_API_TOKEN="<具有 Cache Settings Edit 权限的 token>"
|
||||
$env:CLOUDFLARE_ZONE_ID="<polyweather.top 的 Zone ID>"
|
||||
python scripts/configure_cloudflare_free.py
|
||||
python scripts/configure_cloudflare_free.py --apply
|
||||
```
|
||||
|
||||
第一条命令只输出计划;只有带 `--apply` 才会修改 Cloudflare。
|
||||
|
||||
部署流水线也会执行同一脚本。给 GitHub 仓库增加 `CLOUDFLARE_API_TOKEN` 和 `CLOUDFLARE_ZONE_ID` 两个 Secret 后,后续每次成功部署都会同步 Cache Rules;未配置时流水线会明确跳过,不影响主站部署。
|
||||
|
||||
### 1. 缓存公开内容
|
||||
|
||||
脚本会创建 5 条互不重叠的公开缓存规则。动作均为 Eligible for cache:
|
||||
|
||||
- 静态资源:2xx Edge TTL 覆盖为 1 年。
|
||||
- 首页和公开文档页:2xx Edge TTL 覆盖为 10 分钟。
|
||||
- `/api/cities`:2xx Edge TTL 覆盖为 5 分钟。
|
||||
- 城市详情与 detail-batch:尊重源站成功、partial、stale 和错误缓存策略。
|
||||
- `/api/scan/terminal` 与 `/api/system/status`:尊重源站成功、busy、failed 和错误缓存策略。
|
||||
|
||||
所有公开缓存规则都显式设置 Browser TTL 为 Respect origin,避免 Cloudflare 免费版默认 Browser Cache TTL 把 HTML 或数据接口改成 4 小时本地缓存。
|
||||
|
||||
### 2. 最后绕过后端域名、动态和敏感请求
|
||||
|
||||
动作:Bypass cache
|
||||
|
||||
表达式:
|
||||
|
||||
```text
|
||||
http.host eq "api.polyweather.top"
|
||||
or (http.request.method ne "GET" and http.request.method ne "HEAD")
|
||||
or starts_with(http.request.uri.path, "/api/auth/")
|
||||
or starts_with(http.request.uri.path, "/api/feedback")
|
||||
or starts_with(http.request.uri.path, "/api/events")
|
||||
or starts_with(http.request.uri.path, "/api/internal/")
|
||||
or starts_with(http.request.uri.path, "/api/ops/")
|
||||
or starts_with(http.request.uri.path, "/api/payments/")
|
||||
or starts_with(http.request.uri.path, "/account")
|
||||
or starts_with(http.request.uri.path, "/auth")
|
||||
or starts_with(http.request.uri.path, "/ops")
|
||||
or starts_with(http.request.uri.path, "/terminal")
|
||||
or http.request.uri.query contains "force_refresh=true"
|
||||
```
|
||||
|
||||
若 Dashboard 支持请求头或 Cookie 条件,再加入:
|
||||
|
||||
```text
|
||||
or any(http.request.headers["authorization"][*] ne "")
|
||||
or http.cookie contains "sb-"
|
||||
```
|
||||
|
||||
## 缓存 TTL
|
||||
|
||||
### 静态资源
|
||||
|
||||
动作:Eligible for cache;2xx Edge TTL 覆盖为一年。
|
||||
|
||||
表达式:
|
||||
|
||||
```text
|
||||
http.host eq "polyweather.top"
|
||||
and (
|
||||
starts_with(http.request.uri.path, "/_next/static/")
|
||||
or lower(http.request.uri.path.extension) in {
|
||||
"js" "css" "woff" "woff2" "png" "jpg" "jpeg" "webp" "avif" "svg" "ico"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
源站仍声明一年 immutable,Cloudflare Edge TTL 同样固定为一年。
|
||||
|
||||
### 公开页面
|
||||
|
||||
动作:Eligible for cache;2xx Edge TTL 覆盖为 10 分钟。
|
||||
|
||||
表达式:
|
||||
|
||||
```text
|
||||
http.host eq "polyweather.top"
|
||||
and (
|
||||
http.request.uri.path eq "/"
|
||||
or starts_with(http.request.uri.path, "/docs/")
|
||||
or starts_with(http.request.uri.path, "/modern/")
|
||||
or starts_with(http.request.uri.path, "/probabilities/")
|
||||
or starts_with(http.request.uri.path, "/subscription-help/")
|
||||
)
|
||||
```
|
||||
|
||||
源站 TTL:10 分钟,过期后允许后台刷新 1 小时。
|
||||
|
||||
### 公开数据接口
|
||||
|
||||
动作:Eligible for cache;城市列表的 2xx Edge TTL 覆盖为 5 分钟,详情、扫描和系统状态尊重源站缓存头。
|
||||
|
||||
表达式:
|
||||
|
||||
```text
|
||||
http.host eq "polyweather.top"
|
||||
and (
|
||||
http.request.uri.path eq "/api/cities"
|
||||
or http.request.uri.path eq "/api/cities/detail-batch"
|
||||
or starts_with(http.request.uri.path, "/api/city/")
|
||||
or http.request.uri.path eq "/api/scan/terminal"
|
||||
or http.request.uri.path eq "/api/system/status"
|
||||
)
|
||||
```
|
||||
|
||||
接口 TTL:
|
||||
|
||||
- `/api/cities`:5 分钟,过期后可复用 1 小时。
|
||||
- 城市详情与 detail-batch:60 秒,过期后可复用 5 分钟。
|
||||
- `/api/scan/terminal`:5 分钟,过期后可复用 15 分钟。
|
||||
- partial、busy、timeout、stale、force refresh 和错误响应:源站为 `no-store`,详情和扫描规则不会覆盖;force refresh 由最后一条绕过规则排除。
|
||||
|
||||
## 验证
|
||||
|
||||
每次规则变更后连续请求同一 URL,检查响应头:
|
||||
|
||||
```powershell
|
||||
curl.exe -sS -D - -o NUL "https://polyweather.top/api/cities"
|
||||
curl.exe -sS -D - -o NUL "https://polyweather.top/api/scan/terminal?limit=1"
|
||||
```
|
||||
|
||||
正常命中应看到 `CF-Cache-Status: HIT` 和递增的 `Age`。首次请求通常是 `MISS`;动态或明确 `no-store` 的请求应保持 `DYNAMIC` 或 `BYPASS`。
|
||||
|
||||
也可以用脚本:
|
||||
|
||||
```bash
|
||||
scripts/validate_frontend_cache.sh https://polyweather.top
|
||||
REQUIRE_CF_CACHE=true scripts/validate_frontend_cache.sh https://polyweather.top
|
||||
```
|
||||
|
||||
第二条会把缺失 `CF-Cache-Status` 或持续 `MISS` 作为失败处理,适合 Cache Rules 同步后的线上检查。
|
||||
|
||||
## Turnstile
|
||||
|
||||
Cloudflare 控制台创建 Turnstile site 后,配置:
|
||||
|
||||
- GitHub Secrets:`NEXT_PUBLIC_TURNSTILE_SITE_KEY`
|
||||
- VPS / Docker `.env`:`NEXT_PUBLIC_TURNSTILE_SITE_KEY`
|
||||
- VPS / Docker `.env`:`POLYWEATHER_TURNSTILE_SECRET_KEY`
|
||||
|
||||
`NEXT_PUBLIC_TURNSTILE_SITE_KEY` 属于前端构建期变量,改动后必须重新构建前端镜像。`POLYWEATHER_TURNSTILE_SECRET_KEY` 只在服务端使用,不要提交仓库。
|
||||
|
||||
## R2 事件归档
|
||||
|
||||
R2 用于冷归档,不替代 Redis Stream / SQLite 的热路径。推荐先用 dry-run 看事件量:
|
||||
|
||||
```bash
|
||||
python scripts/archive_realtime_events_to_r2.py --source redis --date 2026-06-16 --dry-run
|
||||
python scripts/archive_realtime_events_to_r2.py --source redis --date 2026-06-16
|
||||
```
|
||||
|
||||
对象 key 形如:
|
||||
|
||||
```text
|
||||
sse-events/2026/06/16/city_observation_patch.redis.jsonl
|
||||
```
|
||||
@@ -20,6 +20,7 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
|
||||
| 订阅套餐(Pro 月付 / 季度) | 已上线 | `29.9 USDC / 30天`;`79.9 USDC / 90天` |
|
||||
| 邀请积分 | 已上线 | 被邀请人首月 `20U`;邀请人每个有效付费邀请 `+3500` 积分 |
|
||||
| 用户增长奖励 | 已上线 | 按已验证用户里程碑,为当前有效且有 confirmed 入账记录的付费会员延长 Pro |
|
||||
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U`;邀请首月价不叠加积分抵扣 |
|
||||
| 合约支付 | 已上线 | Polygon,USDC + USDC.e |
|
||||
| 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 |
|
||||
@@ -39,13 +40,14 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
- 概率分布层(基于 DEB 融合 + 高斯分桶)
|
||||
- 实时终端多城市图表(跑道/官方站点/DEB/概率带)
|
||||
- 历史对账 + 未来日期分析
|
||||
- 全平台智能气象推送
|
||||
- Telegram 缓存推送与业务频道通知
|
||||
|
||||
## 4. 收费与积分规则(默认)
|
||||
|
||||
- 套餐:`pro_monthly`(29.9 USDC / 30 天)、`pro_quarterly`(79.9 USDC / 90 天)
|
||||
- 邀请首月:被邀请人首次月付 20 USDC
|
||||
- 邀请奖励:邀请人 +3500 积分,每月最多 10 个有效付费邀请
|
||||
- 用户增长奖励:600 已验证用户 `+1天`、750 `+2天`、1000 `+3天`,此后每增长 100 人 `+3天`
|
||||
- 抵扣:500 积分抵 1 USDC;月付最高抵 3 USDC,季度最高抵 8 USDC
|
||||
- 积分来源:仅通过有效付费邀请和后台人工补发,不再通过 Telegram 群发言发放
|
||||
|
||||
|
||||
+52
-14
@@ -17,7 +17,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
3. 平台侧真实密钥
|
||||
放在:
|
||||
- VPS / Docker `.env`
|
||||
- Vercel Environment Variables
|
||||
- GitHub Secrets(构建期 `NEXT_PUBLIC_*` 通过 build-arg 注入前端镜像)
|
||||
- GitHub Secrets(如需要)
|
||||
|
||||
## 2. 为什么要拆
|
||||
@@ -66,7 +66,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
|
||||
用途:
|
||||
|
||||
- 前端本地开发与 Vercel 环境变量模板
|
||||
- 前端本地开发与容器运行时环境变量模板
|
||||
|
||||
## 4. 配置分级
|
||||
|
||||
@@ -112,6 +112,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `POLYWEATHER_PAYMENT_ENABLED`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
|
||||
- `POLYGON_WALLET_WATCH_ENABLED`
|
||||
- `POLYWEATHER_TURNSTILE_BYPASS`
|
||||
- `POLYWEATHER_TURNSTILE_ENFORCE_ACTION`
|
||||
- `POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT`
|
||||
- `TELEGRAM_ALERT_PUSH_ENABLED`
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
|
||||
|
||||
@@ -154,6 +157,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
|
||||
- `POLYWEATHER_TURNSTILE_SECRET_KEY`
|
||||
- `POLYWEATHER_R2_ACCESS_KEY_ID`
|
||||
- `POLYWEATHER_R2_SECRET_ACCESS_KEY`
|
||||
|
||||
## 5. 推荐部署矩阵
|
||||
|
||||
@@ -165,20 +171,28 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- 所有 secrets
|
||||
- Bot / 支付 / watcher 配置
|
||||
|
||||
### 5.2 Vercel(前端)
|
||||
### 5.2 前端容器(Docker Compose)
|
||||
|
||||
建议只放前端真正需要的变量:
|
||||
前端与后端一起以 Docker Compose 部署,环境变量分两类来源:
|
||||
|
||||
- `POLYWEATHER_API_BASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
**运行时变量**(`.env` 或 compose `environment` 块):
|
||||
|
||||
- `POLYWEATHER_API_BASE_URL`(容器内使用 `http://polyweather_web:8000`)
|
||||
- `POLYWEATHER_AUTH_ENABLED`
|
||||
- `POLYWEATHER_AUTH_REQUIRED`
|
||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
|
||||
**构建期变量**(GitHub Secrets → CI `build-and-push` → `frontend/Dockerfile` 的 `ARG`,改了必须重新构建镜像):
|
||||
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- `NEXT_PUBLIC_SITE_URL`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL`
|
||||
- `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS`
|
||||
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY`
|
||||
- `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS`
|
||||
- `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS`
|
||||
- `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES`
|
||||
@@ -188,19 +202,43 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `/ops` 现在是前后端双层限制:
|
||||
- 前端页面入口读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- 后端写接口同样读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- 因此,Vercel 和 VPS / Docker 两侧都应配置相同的管理员邮箱白名单。
|
||||
- 因此,前端容器和后端容器两侧都应配置相同的管理员邮箱白名单。
|
||||
|
||||
不要把后端专用密钥全搬进 Vercel。
|
||||
不要把后端专用密钥全搬进前端容器。
|
||||
|
||||
### 5.3 GitHub Actions
|
||||
|
||||
当前 CI 不需要大规模 secrets。
|
||||
当前 CI 已配置自动部署,需要的 secrets 见 `.github/workflows/ci.yml`:
|
||||
|
||||
如果未来要做自动部署,再考虑:
|
||||
- `VPS_SSH_KEY` / `VPS_HOST` / `VPS_USER` / `GHCR_PAT`(SSH 部署到 VPS)
|
||||
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ZONE_ID`(同步 Cloudflare Cache Rules)
|
||||
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY`(构建期注入前端镜像)
|
||||
- 前端构建期 `NEXT_PUBLIC_*`(注入前端镜像)
|
||||
|
||||
- `VERCEL_TOKEN`
|
||||
- `VERCEL_ORG_ID`
|
||||
- `VERCEL_PROJECT_ID`
|
||||
### 5.4 Cloudflare 免费能力
|
||||
|
||||
Turnstile:
|
||||
|
||||
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY` 是浏览器可见的 site key,属于前端构建期变量;改动后需要重新构建前端镜像。
|
||||
- `POLYWEATHER_TURNSTILE_SECRET_KEY` 只放 VPS / Docker `.env`,用于 Next API Route 服务端校验。
|
||||
- `POLYWEATHER_TURNSTILE_BYPASS=true` 可在排障时临时关闭校验。
|
||||
- 支付 tx 提交默认不强制二次 Turnstile,因为 Cloudflare token 是一次性校验;订单创建已做校验。只有确认 UX 能支持二次挑战时,才设置 `POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT=true`。
|
||||
|
||||
R2:
|
||||
|
||||
- `POLYWEATHER_R2_ACCOUNT_ID`
|
||||
- `POLYWEATHER_R2_BUCKET`
|
||||
- `POLYWEATHER_R2_ACCESS_KEY_ID`
|
||||
- `POLYWEATHER_R2_SECRET_ACCESS_KEY`
|
||||
- `POLYWEATHER_R2_REGION=auto`
|
||||
- `POLYWEATHER_R2_ARCHIVE_SOURCE=redis`
|
||||
|
||||
归档脚本只读 Redis Stream 或 SQLite,不删除热路径数据:
|
||||
|
||||
```bash
|
||||
python scripts/archive_realtime_events_to_r2.py --date 2026-06-16 --dry-run
|
||||
python scripts/archive_realtime_events_to_r2.py --date 2026-06-16
|
||||
```
|
||||
|
||||
## 6. 最小部署示例
|
||||
|
||||
|
||||
+102
-146
@@ -1,65 +1,97 @@
|
||||
# 前端部署配置(Vercel)
|
||||
# 前端部署配置(Docker / VPS)
|
||||
|
||||
最后更新:`2026-05-29`
|
||||
最后更新:`2026-06-14`
|
||||
|
||||
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。
|
||||
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。前端当前不再使用 Vercel,统一与后端一起以 Docker Compose 形式部署在同一台 VPS 上,前面挂 Cloudflare + Nginx。
|
||||
|
||||
## 一、部署目标
|
||||
|
||||
推荐方案:
|
||||
当前方案:
|
||||
|
||||
1. GitHub Actions 负责 `CI`
|
||||
2. Vercel 负责前端 `CD`
|
||||
3. FastAPI 后端单独部署在 VPS / Docker 主机
|
||||
1. GitHub Actions 负责 `CI`(`python-quality` + `frontend-quality`)
|
||||
2. `build-and-push` job 把前端构建为 Docker 镜像 `ghcr.io/yangyuan-zhen/polyweather-frontend`
|
||||
3. `deploy` job 通过 SSH 把 `deploy.sh` 推到 VPS,由 VPS 拉取新镜像并滚动更新
|
||||
|
||||
前端本身不直接访问天气源,而是通过 Next Route Handlers 转发到后端:
|
||||
|
||||
1. 浏览器 -> Vercel 上的 Next.js 前端
|
||||
2. Next `/api/*` -> `POLYWEATHER_API_BASE_URL`
|
||||
3. FastAPI 后端 -> 分析 / 支付 / 鉴权服务
|
||||
1. 浏览器 → Cloudflare → Nginx → 前端容器(Next.js standalone)
|
||||
2. Next `/api/*` → `POLYWEATHER_API_BASE_URL`(容器内默认 `http://polyweather_web:8000`)
|
||||
3. FastAPI 后端 → 分析 / 支付 / 鉴权服务
|
||||
|
||||
实时图表同样走 Next Route Handler / rewrite:
|
||||
实时图表同样走 Next Route Handler:
|
||||
|
||||
1. 浏览器 `EventSource` -> `/api/events?cities=...&since_revision=...`
|
||||
1. 浏览器 `EventSource` → `/api/events?cities=...&since_revision=...`
|
||||
2. Next 转发到 FastAPI `/api/events`
|
||||
3. FastAPI 从 Redis Stream / SQLite event log replay 后进入 live SSE
|
||||
|
||||
## 二、Vercel 项目设置
|
||||
## 二、镜像与构建
|
||||
|
||||
在 Vercel 导入 GitHub 仓库后,使用下面的设置:
|
||||
前端镜像定义在 `frontend/Dockerfile`,三阶段构建:
|
||||
|
||||
- Framework Preset: `Next.js`
|
||||
- Root Directory: `frontend`
|
||||
- Build Command: `npm run build`
|
||||
- Install Command: `npm ci`
|
||||
- `deps`:`npm ci` 安装依赖
|
||||
- `builder`:通过 `ARG` 注入 `NEXT_PUBLIC_*` 变量后执行 `npm run build`,产出 standalone 产物
|
||||
- `runner`:只拷贝 `.next/standalone`、`.next/static`、`public`,以 `node server.js` 启动
|
||||
|
||||
如果仓库已经连接过 Vercel,通常只需要确认 `Root Directory` 仍然是 `frontend`。
|
||||
`NEXT_PUBLIC_*` 变量是在 **构建期** 注入的(见 `frontend/Dockerfile` 的 `ARG` 块),CI 在 `.github/workflows/ci.yml` 的 `build-and-push` job 里从 GitHub Secrets 读取并作为 `--build-arg` 传入。修改这类变量必须重新构建镜像,仅改运行时环境无效。
|
||||
|
||||
## 三、最小必填环境变量
|
||||
## 三、Compose 服务
|
||||
|
||||
只部署天气看板和基础登录时,先填下面 4 项:
|
||||
前端在 `docker-compose.yml` 中对应 `polyweather_frontend` 服务:
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://<your-fastapi-host>
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://<your-project>.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your-anon-key>
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
- 镜像:`ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}`
|
||||
- 容器内监听 `:3000`,映射到宿主 `127.0.0.1:3001`
|
||||
- 健康检查:`wget -qO- http://$(hostname):3000`
|
||||
- 运行时环境变量(非 `NEXT_PUBLIC_*` 的那部分)通过 compose `environment` 注入,例如:
|
||||
- `POLYWEATHER_API_BASE_URL=http://polyweather_web:8000`(容器内走后端服务名)
|
||||
- `POLYWEATHER_AUTH_ENABLED` / `POLYWEATHER_AUTH_REQUIRED`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
|
||||
`POLYWEATHER_API_BASE_URL` **禁止** 指向前端站点自身(`polyweather.top`),否则会形成回环。`deploy.sh` 里的 `validate_frontend_api_base_url` 会在部署前拦截。容器内应使用 `http://polyweather_web:8000`。
|
||||
|
||||
## 四、部署流程(`deploy.sh`)
|
||||
|
||||
生产部署由 GitHub Actions 在 `main` push 时触发,关键步骤:
|
||||
|
||||
1. SSH 登录 VPS,用 GHCR PAT 登录镜像仓库
|
||||
2. `git fetch origin main && git reset --hard origin/main` 同步仓库(含 `docker-compose.yml`)
|
||||
3. 同步 `data/city_thread_ids.json` 到运行态目录
|
||||
4. `docker compose pull` 拉取新镜像(带重试)
|
||||
5. 按顺序滚动更新:`redis` → `web` + `bot` → `collector` → `warmer` → `frontend`
|
||||
6. 每步后做本地健康检查;前端额外等待 `/terminal` 和 `/api/scan/terminal` 就绪
|
||||
7. 公网 smoke check:`https://api.polyweather.top/healthz`、`https://polyweather.top/api/cities`、`https://www.polyweather.top/`
|
||||
8. 任意一步失败自动回滚到上一个镜像 tag(记录在 `/var/lib/polyweather/.current_tag`)
|
||||
|
||||
部署失败时优先看 `deploy.sh` 输出里哪一步打了 `❌`,并检查 `docker compose logs polyweather_frontend`。
|
||||
|
||||
## 五、最小必填配置
|
||||
|
||||
只部署天气看板和基础登录时,至少需要:
|
||||
|
||||
构建期(CI Secrets,对应 `frontend/Dockerfile` 的 `ARG`):
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_SUPABASE_URL
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather.top
|
||||
```
|
||||
|
||||
建议显式补:
|
||||
运行期(`.env` 或 compose `environment`):
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=http://polyweather_web:8000
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=<与后端共享>
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `POLYWEATHER_API_BASE_URL`:前端所有 `/api/*` Route Handler 转发时依赖它,没填会直接返回 500。
|
||||
- `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY`:Supabase 客户端依赖它们。
|
||||
- `POLYWEATHER_AUTH_ENABLED`:关闭时,前端不会启用登录能力。
|
||||
- `POLYWEATHER_AUTH_REQUIRED`:控制 middleware 是否强制登录。
|
||||
- `POLYWEATHER_API_BASE_URL`:前端所有 `/api/*` Route Handler 转发时依赖它,没填或填错会直接返回 500。
|
||||
- `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY`:Supabase 客户端依赖它们,构建期注入。
|
||||
- `POLYWEATHER_AUTH_ENABLED` / `POLYWEATHER_AUTH_REQUIRED`:控制 middleware 是否强制登录。
|
||||
|
||||
## 四、按功能启用的可选环境变量
|
||||
## 六、按功能启用的可选环境变量
|
||||
|
||||
### 1. 分享式看板
|
||||
|
||||
@@ -69,60 +101,45 @@ POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
|
||||
设置后,可通过 `/?access_token=<token>` 打开带令牌的看板入口。
|
||||
|
||||
### 2. 前后端 entitlement 校验
|
||||
### 2. 钱包支付(构建期)
|
||||
|
||||
```env
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
```
|
||||
|
||||
仅当后端开启 entitlement / 订阅校验时需要。
|
||||
|
||||
### 3. 钱包支付
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
|
||||
```
|
||||
|
||||
如果不启用钱包支付,可以留空。
|
||||
|
||||
### 4. `/ops` 管理员页面守卫
|
||||
### 3. `/ops` 管理员页面守卫
|
||||
|
||||
```env
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
```
|
||||
|
||||
说明:
|
||||
`/ops` 页面入口会读取管理员邮箱白名单,前端和后端容器都应配置相同的值。
|
||||
|
||||
- `/ops` 现在不是只有后端接口限制,前端页面入口也会读取管理员邮箱白名单。
|
||||
- 因此前端部署到 Vercel 时,也应配置 `POLYWEATHER_OPS_ADMIN_EMAILS`。
|
||||
### 4. Telegram 入口(构建期)
|
||||
|
||||
### 5. Telegram 入口
|
||||
|
||||
```env
|
||||
```
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
|
||||
```
|
||||
|
||||
只影响按钮跳转,不影响核心页面加载。
|
||||
|
||||
### 6. 前端观测与预热开关(推荐默认关闭)
|
||||
### 5. 前端观测与预热开关(推荐默认关闭)
|
||||
|
||||
```env
|
||||
```
|
||||
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
|
||||
NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false
|
||||
NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
|
||||
```
|
||||
|
||||
说明:
|
||||
## 七、支付配置与旧镜像治理
|
||||
|
||||
- `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false`:关闭前端自建埋点。
|
||||
- `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false`:关闭前端 Web Vitals 上报。
|
||||
- `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false`:关闭首页全量城市 summary 预热,避免白白消耗 Vercel function / edge 成本。
|
||||
|
||||
## 五、支付配置与旧部署治理
|
||||
|
||||
支付区现在有一层额外防护:
|
||||
支付区有一层额外防护:
|
||||
|
||||
1. 用户点击支付前,前端会重新请求 `/api/payments/config`
|
||||
2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址
|
||||
@@ -130,61 +147,19 @@ NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
|
||||
4. 多链支付时,前端会展示后端返回的网络列表,并把用户选择的 `chain_id` 传给后端创建 intent
|
||||
5. Ethereum 主网 USDC 当前走手动直转确认,前端不会把它当成 Polygon checkout 合约支付
|
||||
|
||||
这层防护的目的,是降低以下事故概率:
|
||||
这层防护降低以下事故概率:
|
||||
|
||||
- 用户使用长期未刷新的旧标签页
|
||||
- 命中旧 deployment URL
|
||||
- 页面本地状态残留旧收款地址
|
||||
- 用户在钱包默认网络(例如 Ethereum)付款,但系统按 Polygon intent 查账
|
||||
|
||||
如果你变更过支付收款地址,建议同步执行:
|
||||
如果变更过支付收款地址,由于前端镜像是构建期注入地址,需要触发一次 `main` push(或重新运行 deploy workflow)来发布新镜像;浏览器侧靠 `/api/payments/config` 的运行时校验兜底,无需等待所有用户刷新。
|
||||
|
||||
1. 在 Vercel 对当前 production 做一次 redeploy
|
||||
2. 删除明显过期、可能还带旧支付配置的旧 deployment
|
||||
3. 在 `Settings -> Security -> Deployment Retention Policy` 中收紧旧部署保留周期
|
||||
## 八、不要放进前端容器的变量
|
||||
|
||||
## 六、推荐的三套配置口径
|
||||
这些属于后端私密配置,不应该放到前端服务:
|
||||
|
||||
### 1. 公开游客模式
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://api.example.com
|
||||
POLYWEATHER_AUTH_ENABLED=false
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
```
|
||||
|
||||
适合公开演示站。
|
||||
|
||||
### 2. 正常登录模式
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://api.example.com
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
```
|
||||
|
||||
适合正式前端站点。
|
||||
|
||||
### 3. 登录 + entitlement 联动
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL=https://api.example.com
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=<shared-token>
|
||||
```
|
||||
|
||||
适合前后端都启用了会员/订阅保护的生产环境。
|
||||
|
||||
## 七、不要放进 Vercel 的变量
|
||||
|
||||
这些属于后端私密配置,不应该放到前端项目:
|
||||
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`(除非前端 Route Handler 明确需要,且仅以非 `NEXT_PUBLIC_` 形式注入容器)
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN` 以外的后端 secret
|
||||
- 支付签名私钥 / 交易私钥 / 任何 bot 凭据
|
||||
@@ -194,74 +169,55 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=<shared-token>
|
||||
- `NEXT_PUBLIC_*` 会暴露给浏览器
|
||||
- 只有明确允许前端公开使用的值,才应加 `NEXT_PUBLIC_`
|
||||
|
||||
## 八、上线前检查
|
||||
## 九、上线前检查
|
||||
|
||||
Vercel 部署前至少确认:
|
||||
部署前至少确认:
|
||||
|
||||
1. `POLYWEATHER_API_BASE_URL` 指向可访问的后端生产地址
|
||||
2. `frontend/.env.example` 和 Vercel Project Settings 中的实际值一致
|
||||
1. `POLYWEATHER_API_BASE_URL` 指向容器内后端服务名 `http://polyweather_web:8000`,**不是** `polyweather.top`
|
||||
2. CI Secrets 中的 `NEXT_PUBLIC_*` 值与预期一致(构建期注入,改了要重新构建镜像)
|
||||
3. GitHub Actions 中 `frontend-quality` 已通过
|
||||
4. 如果启用鉴权,Supabase redirect URL 已包含前端域名
|
||||
5. `GET /api/payments/config` 返回的是当前最新地址,而不是旧收款合约
|
||||
6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在 Vercel 与后端同时配置
|
||||
7. 确认 `/api/events` 没有被 CDN 缓存或压缩成普通 JSON;它必须保持 `text/event-stream`
|
||||
6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在前端与后端容器同时配置
|
||||
7. 确认 `/api/events` 没有被 Cloudflare / Nginx 缓存或压缩成普通 JSON;它必须保持 `text/event-stream`
|
||||
|
||||
## 九、常见问题
|
||||
## 十、常见问题
|
||||
|
||||
### 1. 页面打开后 API 全部 500
|
||||
|
||||
先检查:
|
||||
先检查容器内 `POLYWEATHER_API_BASE_URL` 是否指向 `http://polyweather_web:8000`,以及 `polyweather_web` 容器是否健康。
|
||||
|
||||
```env
|
||||
POLYWEATHER_API_BASE_URL
|
||||
```
|
||||
|
||||
这是最常见原因。
|
||||
|
||||
### 2. Vercel 构建通过,但登录失败
|
||||
### 2. 构建通过,但登录失败
|
||||
|
||||
先检查:
|
||||
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- Supabase 项目里的站点 URL / redirect URL
|
||||
- 构建期注入的 `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- Supabase 项目里的站点 URL / redirect URL 是否包含前端域名
|
||||
|
||||
### 3. 钱包入口显示未配置
|
||||
|
||||
先检查:
|
||||
检查构建期 `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` 是否注入(前端镜像需要重新构建)。
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
|
||||
```
|
||||
### 4. 改了 `NEXT_PUBLIC_*` 但线上没生效
|
||||
|
||||
这是钱包连接的必需项。
|
||||
这类变量是构建期注入的。仅改 CI Secrets 不会更新已部署镜像,需要重新触发 `build-and-push` + `deploy`(即一次 `main` push,或手动重跑 deploy workflow)。
|
||||
|
||||
## 十、Vercel 成本与节流建议
|
||||
## 十一、成本与节流建议
|
||||
|
||||
### 1. 建议先关闭的项目级能力
|
||||
### 1. Cloudflare 缓存规则
|
||||
|
||||
- `Web Analytics`
|
||||
- `Speed Insights`
|
||||
前端通过 `next.config.mjs` 的 `headers()` 为静态资源(`_next/static`、图片、字体)设置 `Cache-Control: public, max-age=31536000, immutable`,并为公共页面设置 `s-maxage=600, stale-while-revalidate=3600`。CI 的 `cloudflare-cache-rules` job 会同步 Cloudflare Cache Rules(见 `scripts/configure_cloudflare_free.py`)。
|
||||
|
||||
它们对排查前端体验有价值,但在 Hobby / 低预算阶段会额外消耗数据点和边缘资源。
|
||||
### 2. Cloudflare WAF 规则
|
||||
|
||||
### 2. 建议加的 Firewall 自定义规则
|
||||
|
||||
如果你的 Next.js 项目根本不提供 WordPress / PHP 路径,建议在 Vercel Firewall 里先 `Log` 再 `Deny` 这条规则:
|
||||
如果发现大量 WordPress / PHP 扫描流量命中 Next.js(实际并不提供这些路径),建议在 Cloudflare WAF 中先 `Log` 再 `Deny` 这条规则:
|
||||
|
||||
```regex
|
||||
(^/(wp-admin|wp-includes|wp-content|wp-login|wordpress|xmlrpc\.php))|\.php($|\?)
|
||||
```
|
||||
|
||||
目的:
|
||||
目的:在边缘层提前拦截扫描流量,避免无效请求继续触发 Nginx、Next.js middleware 与 route handler。
|
||||
|
||||
- 在边缘层提前拦截 WordPress / PHP 扫描流量
|
||||
- 避免无效请求继续触发 middleware 与 route handler
|
||||
### 3. SSE 路径不要进缓存
|
||||
|
||||
### 3. 建议的上线前检查
|
||||
|
||||
除了功能本身,额外确认:
|
||||
|
||||
1. `Web Analytics` 和 `Speed Insights` 是否真的关闭
|
||||
2. `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS` / `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS` / `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES` 是否保持关闭
|
||||
3. Firewall 自定义规则是否已从 `Log` 切到 `Deny`
|
||||
`/api/events` 必须保持 `text/event-stream`,Cloudflare 和 Nginx 都不应缓存或压缩它。检查 Nginx 配置(`deploy/nginx/polyweather.conf`)中对 `/api/events` 的 `proxy_buffering off`。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
前端入口:
|
||||
|
||||
- `https://polyweather-pro.vercel.app/ops`
|
||||
- `https://polyweather.top/ops`
|
||||
|
||||
## 2. 权限
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
- `https://<project-ref>.supabase.co/auth/v1/callback`
|
||||
3. `Auth -> URL Configuration` 添加:
|
||||
- 站点 URL(生产域名)
|
||||
- 回调 URL(例如 `https://polyweather-pro.vercel.app/auth/callback`)
|
||||
- 回调 URL(例如 `https://polyweather.top/auth/callback`)
|
||||
|
||||
## 3. 数据库脚本
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
## 4. 环境变量
|
||||
|
||||
### 4.1 前端(Vercel / frontend/.env.local)
|
||||
### 4.1 前端(Docker 容器 / frontend/.env.local)
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
|
||||
@@ -7,7 +7,7 @@ PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类
|
||||
> **2026-05-29 更新**:支付 intent 已支持多链 `chain_id`,Polygon 继续承载 checkout 合约,Ethereum 主网 USDC 作为直转确认通道;终端图表继续使用 HTTP snapshot + SSE Patch + Redis Stream/SQLite replay 架构。
|
||||
## 项目概览
|
||||
|
||||
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
|
||||
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Docker/VPS 前端)+ VPS 后端 + Telegram Bot”。
|
||||
项目主功能可归纳为五层:
|
||||
**天气层(数据源/采集)**:聚合 51 个城市的实测与预报;支持 AviationWeather METAR/TAF、韩国 AMOS 跑道级观测(首尔/釜山)、中国 AMSC AWOS 跑道端点气温、香港 CoWIN 6087 / HKO、土耳其 MGM、Open-Meteo 多模型与集合预报、美国 MADIS HFMETAR 等。机场类市场仍以 METAR / 机场主站或明确官方结算源为锚点,Wunderground 不描述为物理观测站。
|
||||
**分析层(DEB/趋势/概率/结算口径)**:
|
||||
@@ -28,7 +28,7 @@ DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
|
||||
从 README、Docker/Compose、入口脚本与核心模块引用关系,可以抽象出如下模块地图(按“运行时组件”与“Python 域模块”两层描述):
|
||||
| 层级 | 目录/文件 | 角色定位 | 关键说明 |
|
||||
| ------------- | ------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 运行时组件 | `frontend/` | Next.js 前端(Vercel) | 前端重构报告提到 App Router、Route Handlers(BFF)、缓存策略、支付与账户中心等;Scan Terminal 已新增城市决策卡、结构化实况层、页面内存/localStorage 双层缓存、structured detail 小并发队列与完整市场桶映射。 |
|
||||
| 运行时组件 | `frontend/` | Next.js 前端(Docker / VPS) | 前端重构报告提到 App Router、Route Handlers(BFF)、缓存策略、支付与账户中心等;Scan Terminal 已新增城市决策卡、结构化实况层、页面内存/localStorage 双层缓存、structured detail 小并发队列与完整市场桶映射。 |
|
||||
| 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` + `web/scan_terminal_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层;Scan Terminal 侧提供 `/api/city/{name}/detail`,城市结构化分析 默认 30s 超时并支持 stream parse failure 的非流式重试。 |
|
||||
| 运行时组件 | `bot_listener.py` + `src/bot/*` | Telegram Bot | 入口 `bot_listener.py` 调 `start_bot()`,并由 `StartupCoordinator` 启动多个后台 loop。 |
|
||||
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py`、`settlement_sources.py`、`metar_sources.py`、`mgm_sources.py`、`amos_station_sources.py`、`jma_amedas_sources.py`、`nws_open_meteo_sources.py`、`country_networks.py` 等。v1.7.0 已移除 NMC、pogodaiklimat、Meteoblue 数据源。 |
|
||||
@@ -44,7 +44,7 @@ DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Clients
|
||||
WEB[Next.js Frontend<br/>Vercel]
|
||||
WEB[Next.js Frontend<br/>Docker / VPS]
|
||||
CITYCARD[Scan Terminal City Cards<br/>structured observations + probability mapping]
|
||||
TG[Telegram Bot<br/>TeleBot + Handlers]
|
||||
end
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"recorded_at": "2026-06-12T23:32:49+08:00",
|
||||
"total_registered_users": 588,
|
||||
"verified_users": 573,
|
||||
"ever_signed_in_users": 561,
|
||||
"source": "supabase_auth_admin",
|
||||
"note": "Baseline recorded before verified-user growth milestone rewards launched."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# PolyWeather Growth Rewards
|
||||
|
||||
PolyWeather has reached **588 registered users**.
|
||||
|
||||
That number matters because every new user helps us improve the product: more
|
||||
feedback, more real-world usage, and more pressure to make weather observations
|
||||
faster, clearer, and more reliable for prediction-market decisions.
|
||||
|
||||
We are introducing **PolyWeather Growth Rewards**.
|
||||
|
||||
From now on, verified-user milestones will unlock additional Pro time for every
|
||||
active paid member:
|
||||
|
||||
- 600 verified users: **+1 Pro day**
|
||||
- 750 verified users: **+2 Pro days**
|
||||
- 1,000 verified users: **+3 Pro days**
|
||||
- Every additional 100 verified users after 1,000: **+3 Pro days**
|
||||
|
||||
Rewards are issued automatically to active paid members when each milestone is
|
||||
reached. Every milestone can only be claimed once.
|
||||
|
||||
This is separate from our referral program. Referrals still reward the people
|
||||
who directly help PolyWeather grow, while Growth Rewards let all paying members
|
||||
share in the progress of the community.
|
||||
|
||||
We currently have **588 registered users**, including **573 verified users**.
|
||||
The first Growth Reward unlocks at 600 verified users.
|
||||
|
||||
Grow together. Earn more Pro time.
|
||||
|
||||
https://polyweather.top
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,68 @@
|
||||
# Growth Milestone Pro Rewards Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Automatically extend active paid Pro memberships when verified-user growth milestones are reached.
|
||||
|
||||
**Architecture:** A low-frequency Bot-owned loop reads Supabase Auth and payment data, stores daily growth history in SQLite, and grants idempotent milestone-specific bonus subscriptions. SQLite settlement and payout records prevent duplicate processing and support retries.
|
||||
|
||||
**Tech Stack:** Python, SQLite, Supabase REST/Auth Admin API, Telegram Bot runtime, pytest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Define milestone and eligibility policy
|
||||
|
||||
**Files:**
|
||||
- Create: `src/bot/growth_milestone_reward_loop.py`
|
||||
- Test: `tests/test_growth_milestone_reward_loop.py`
|
||||
|
||||
- [ ] Write failing tests for the 600/750/1000/100-step milestone schedule and paid-member eligibility intersection.
|
||||
- [ ] Run `python -m pytest tests/test_growth_milestone_reward_loop.py -q` and confirm failure because the module is missing.
|
||||
- [ ] Implement pure policy helpers and Supabase query helpers.
|
||||
- [ ] Re-run the focused tests and confirm they pass.
|
||||
|
||||
### Task 2: Persist history and settlement state
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/database/db_manager.py`
|
||||
- Test: `tests/test_growth_milestone_reward_loop.py`
|
||||
|
||||
- [ ] Write failing tests for daily snapshot upsert, payout idempotency, and milestone settlement.
|
||||
- [ ] Run the focused tests and confirm the new DB methods are missing.
|
||||
- [ ] Add `user_growth_snapshots`, `growth_milestone_runs`, and `growth_milestone_payouts`.
|
||||
- [ ] Re-run the focused tests and confirm they pass.
|
||||
|
||||
### Task 3: Run and announce automatic settlement
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/bot/growth_milestone_reward_loop.py`
|
||||
- Modify: `src/bot/runtime_coordinator.py`
|
||||
- Modify: `src/utils/config_validation.py`
|
||||
- Modify: `.env.example`
|
||||
- Test: `tests/test_growth_milestone_reward_loop.py`
|
||||
- Test: `tests/test_bot_runtime_coordinator.py`
|
||||
|
||||
- [ ] Write failing tests for idempotent bonus grants and runtime-loop registration.
|
||||
- [ ] Run focused tests and confirm the expected failures.
|
||||
- [ ] Implement the settlement loop, retry behavior, and bilingual announcement.
|
||||
- [ ] Re-run focused tests and confirm they pass.
|
||||
|
||||
### Task 4: Record baseline and publishable announcement
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/operations/user-growth-history.json`
|
||||
- Create: `docs/social/2026-06-12-growth-rewards-x-post.md`
|
||||
- Create: `docs/social/assets/polyweather-588-growth-rewards.png`
|
||||
|
||||
- [ ] Record the verified 2026-06-12 baseline.
|
||||
- [ ] Write the English X announcement with accurate total-user and verified-user wording.
|
||||
- [ ] Save the generated social image in the repository.
|
||||
|
||||
### Task 5: Verify and deploy
|
||||
|
||||
- [ ] Run focused backend tests.
|
||||
- [ ] Run `python -m ruff check src/bot/growth_milestone_reward_loop.py src/bot/runtime_coordinator.py src/database/db_manager.py tests/test_growth_milestone_reward_loop.py tests/test_bot_runtime_coordinator.py`.
|
||||
- [ ] Run the broader relevant test suite.
|
||||
- [ ] Commit and push to `main`.
|
||||
- [ ] Check GitHub Actions and production health.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# DEB Training Settlement Worker Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Restore automatic DEB training data freshness after the realtime collector/canonical-cache split.
|
||||
|
||||
**Architecture:** Add a dedicated low-frequency training settlement service that runs city analysis for forecast/DEB snapshots and reconciles recent settled actual highs. Keep it separate from the high-frequency observation collector so user-facing realtime updates stay light.
|
||||
|
||||
**Tech Stack:** Python, FastAPI service modules, Docker Compose, pytest, existing `update_daily_record()` and `reconcile_recent_actual_highs()` paths.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Training Settlement Service
|
||||
|
||||
**Files:**
|
||||
- Create: `web/training_settlement_service.py`
|
||||
- Create: `tests/test_training_settlement_service.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing service test**
|
||||
|
||||
```python
|
||||
def test_training_settlement_cycle_runs_analysis_and_reconciles_supported_cities():
|
||||
calls = {"analysis": [], "reconcile": []}
|
||||
|
||||
def analysis_runner(city):
|
||||
calls["analysis"].append(city)
|
||||
return {"city": city, "deb": {"prediction": 31.2}}
|
||||
|
||||
def reconciler(city, *, lookback_days):
|
||||
calls["reconcile"].append((city, lookback_days))
|
||||
return {"ok": True, "updated": 1}
|
||||
|
||||
result = run_training_settlement_cycle(
|
||||
city_registry={
|
||||
"shanghai": {"icao": "ZSSS", "settlement_source": "metar"},
|
||||
"legacy": {"settlement_source": "wunderground"},
|
||||
},
|
||||
analysis_runner=analysis_runner,
|
||||
actual_reconciler=reconciler,
|
||||
lookback_days=9,
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["processed"] == 1
|
||||
assert calls["analysis"] == ["shanghai"]
|
||||
assert calls["reconcile"] == [("shanghai", 9)]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `python -m pytest tests/test_training_settlement_service.py::test_training_settlement_cycle_runs_analysis_and_reconciles_supported_cities -q`
|
||||
|
||||
Expected: FAIL because `web.training_settlement_service` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the service**
|
||||
|
||||
Create `run_training_settlement_cycle()` with injectable `city_registry`, `analysis_runner`, and `actual_reconciler`. Default analysis runner calls `web.analysis_service._analyze(city, force_refresh=False, detail_mode="panel")`; default reconciler calls `src.analysis.deb_algorithm.reconcile_recent_actual_highs()`.
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `python -m pytest tests/test_training_settlement_service.py -q`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Worker Entrypoint And Compose
|
||||
|
||||
**Files:**
|
||||
- Create: `web/training_settlement_worker.py`
|
||||
- Modify: `docker-compose.yml`
|
||||
- Modify: `tests/test_deployment_runtime_config.py`
|
||||
|
||||
- [ ] **Step 1: Write failing deployment tests**
|
||||
|
||||
Assert the compose file contains `polyweather_training_settlement`, command `python -m web.training_settlement_worker`, role `training_settlement`, and conservative interval/lookback environment variables.
|
||||
|
||||
- [ ] **Step 2: Run the deployment test to verify it fails**
|
||||
|
||||
Run: `python -m pytest tests/test_deployment_runtime_config.py::test_runtime_compose_splits_realtime_workers -q`
|
||||
|
||||
Expected: FAIL because the worker service is absent.
|
||||
|
||||
- [ ] **Step 3: Implement worker and compose service**
|
||||
|
||||
The worker loops `run_training_settlement_cycle()` every `POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC` seconds, with initial delay and lookback from environment.
|
||||
|
||||
- [ ] **Step 4: Run focused tests**
|
||||
|
||||
Run: `python -m pytest tests/test_training_settlement_service.py tests/test_deployment_runtime_config.py -q`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Stale Monitoring
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/diagnostics/health.py`
|
||||
- Modify: `web/services/system_api.py`
|
||||
- Modify: `tests/test_web_observability.py`
|
||||
|
||||
- [ ] **Step 1: Write failing observability tests**
|
||||
|
||||
Assert system status training summaries include `stale_days` for daily/truth/features, and Prometheus exports stale gauges for training data.
|
||||
|
||||
- [ ] **Step 2: Run focused observability tests to verify failure**
|
||||
|
||||
Run: `python -m pytest tests/test_web_observability.py::test_system_status_includes_training_data tests/test_web_observability.py::test_metrics_endpoint_returns_prometheus_payload_for_ops_admin -q`
|
||||
|
||||
Expected: FAIL because stale fields/gauges are absent.
|
||||
|
||||
- [ ] **Step 3: Implement stale summary and gauges**
|
||||
|
||||
Add date-diff calculation against UTC today. Export `polyweather_daily_records_stale_days`, `polyweather_truth_records_stale_days`, `polyweather_training_features_stale_days`, and `polyweather_training_data_stale`.
|
||||
|
||||
- [ ] **Step 4: Run focused observability tests**
|
||||
|
||||
Run: `python -m pytest tests/test_web_observability.py::test_system_status_includes_training_data tests/test_web_observability.py::test_metrics_endpoint_returns_prometheus_payload_for_ops_admin -q`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 4: Verification And Backfill
|
||||
|
||||
**Files:**
|
||||
- Optional create: `scripts/backfill_training_settlement.py`
|
||||
|
||||
- [ ] **Step 1: Run backend checks**
|
||||
|
||||
Run: `python -m ruff check .`
|
||||
|
||||
Run: `python -m pytest tests/test_training_settlement_service.py tests/test_deployment_runtime_config.py tests/test_web_observability.py -q`
|
||||
|
||||
- [ ] **Step 2: Run local one-shot cycle**
|
||||
|
||||
Run: `python -m web.training_settlement_worker --once --lookback-days 10 --cities shanghai`
|
||||
|
||||
Expected: JSON-like log output with `ok=True`; local DB should get a fresh row for the current local target date if analysis succeeds.
|
||||
|
||||
- [ ] **Step 3: Production note**
|
||||
|
||||
Missed forecast snapshots from 2026-06-15 to 2026-06-22 cannot be reconstructed honestly unless archived city analysis payloads exist. The worker restores forward automatic samples; actual-high truth can still be reconciled for supported settlement sources.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Growth Milestone Pro Rewards Design
|
||||
|
||||
## Goal
|
||||
|
||||
Reward active paid Pro members with additional membership time when PolyWeather
|
||||
reaches verified-user growth milestones.
|
||||
|
||||
## Confirmed Rules
|
||||
|
||||
- Growth is measured using verified Supabase Auth users.
|
||||
- The historical launch baseline is:
|
||||
- 588 total registered users
|
||||
- 573 verified users
|
||||
- recorded on 2026-06-12
|
||||
- Milestones and rewards:
|
||||
- 600 verified users: +1 Pro day
|
||||
- 750 verified users: +2 Pro days
|
||||
- 1000 verified users: +3 Pro days
|
||||
- every additional 100 verified users after 1000: +3 Pro days
|
||||
- Each milestone settles at most once.
|
||||
- Only users who currently have active membership access and have at least one
|
||||
confirmed real payment are eligible.
|
||||
- Trial-only, manual-grant-only, and reward-only users are excluded.
|
||||
|
||||
## Architecture
|
||||
|
||||
The Telegram Bot process already acts as the single background-job owner. A new
|
||||
growth milestone loop runs there on a low-frequency interval. It reads verified
|
||||
Auth-user counts, writes one daily growth snapshot, checks unsettled milestones,
|
||||
selects eligible paid members, and grants an append-only bonus subscription
|
||||
window.
|
||||
|
||||
SQLite stores daily snapshots, milestone settlement summaries, and per-user
|
||||
payout records. Supabase stores the actual bonus subscription. The bonus source
|
||||
contains the milestone number so a retry can detect an already-issued reward.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- Supabase read failures do not advance or settle a milestone.
|
||||
- Successful user payouts are recorded individually.
|
||||
- Failed payouts are retried on the next loop run.
|
||||
- A milestone is marked settled only when all eligible payouts succeed.
|
||||
- Bonus subscription writes use a milestone-specific source as an additional
|
||||
idempotency guard.
|
||||
|
||||
## Notification
|
||||
|
||||
After a milestone settles, the Bot posts one concise bilingual group
|
||||
announcement when announcements are enabled. It includes the milestone, reward
|
||||
days, and rewarded member count.
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@
|
||||
|
||||
首次建议打开扩展"选项页"并确认:
|
||||
|
||||
- `网站基础地址`:你的前端域名(例如 `https://polyweather-pro.vercel.app`)
|
||||
- `网站基础地址`:你的前端域名(例如 `https://polyweather.top`)
|
||||
- `API 基础地址`:你的后端 API 域名(若同域也可填前端域名)
|
||||
- `Bearer Token`:后端开启鉴权时填写
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
<label class="field">
|
||||
<span id="siteBaseLabel">Site Base URL</span>
|
||||
<input id="siteBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
|
||||
<input id="siteBaseInput" type="text" placeholder="https://polyweather.top" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span id="apiBaseLabel">API Base URL</span>
|
||||
<input id="apiBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
|
||||
<input id="apiBaseInput" type="text" placeholder="https://polyweather.top" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const DEFAULT_CONFIG = {
|
||||
apiBase: "https://polyweather-pro.vercel.app",
|
||||
siteBase: "https://polyweather-pro.vercel.app",
|
||||
apiBase: "https://polyweather.top",
|
||||
siteBase: "https://polyweather.top",
|
||||
authToken: "",
|
||||
selectedCity: ""
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const DEFAULT_CONFIG = {
|
||||
apiBase: "https://polyweather-pro.vercel.app",
|
||||
apiBase: "https://polyweather.top",
|
||||
authToken: "",
|
||||
selectedCity: "",
|
||||
siteBase: "https://polyweather-pro.vercel.app"
|
||||
siteBase: "https://polyweather.top"
|
||||
};
|
||||
const CACHE_VERSION = "v3";
|
||||
const locale = String(navigator.language || "en").toLowerCase().startsWith("zh")
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.next
|
||||
node_modules
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
@@ -38,6 +38,9 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
POLYWEATHER_TURNSTILE_SECRET_KEY=
|
||||
POLYWEATHER_TURNSTILE_BYPASS=false
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# PolyWeather 前端最小配置(本地 / Vercel)
|
||||
# 只部署天气看板时,先填下面 4 项即可。
|
||||
|
||||
# 必填:后端 FastAPI 基础地址
|
||||
# 默认供 Next.js API Route 在服务端代理后端使用。
|
||||
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
|
||||
|
||||
# 可选:浏览器直连后端 FastAPI 基础地址。
|
||||
# 在 Vercel 免费额度下建议配置为 VPS HTTPS 域名,让 AI / METAR / scan 等
|
||||
# 长耗时请求绕过 Vercel Functions / Fluid Compute。
|
||||
# 例如:https://api.example.com
|
||||
# 本地开发时注释掉,让 Next.js API Route 代理请求,避免 CORS 问题
|
||||
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=http://38.54.27.70:8080
|
||||
|
||||
# 必填:Supabase 前端公钥(鉴权开启时必须)
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://bttgfgupldyowkdhriqb.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
|
||||
|
||||
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||
# 生产环境必须设为 https://polyweather.top
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather.top
|
||||
|
||||
# 常用:前端鉴权开关
|
||||
# true: 启用 Supabase 登录
|
||||
# false: 关闭登录能力,访客模式
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
|
||||
# 常用:是否强制登录
|
||||
# true: middleware 强制登录后才能访问主页面
|
||||
# false: 登录可选,访客可浏览
|
||||
POLYWEATHER_AUTH_REQUIRED=true
|
||||
|
||||
# 关闭本地开发鉴权绕过
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
|
||||
|
||||
# 可选:分享式看板访问令牌
|
||||
# 设置后,可通过 /?access_token=<token> 打开受保护看板
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
|
||||
# 可选:前端 API Route 转发到后端时附带的共享令牌
|
||||
# 仅当后端启用了 entitlement / 订阅校验时需要
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
# 可选:钱包支付 / Telegram 入口
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=8ce6332b57a42e606cf28224e82d1e02
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
|
||||
@@ -1,8 +0,0 @@
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://bttgfgupldyowkdhriqb.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather.top
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=https://api.polyweather.top
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
|
||||
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
|
||||
@@ -1 +1,6 @@
|
||||
.vercel
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -14,6 +14,7 @@ ARG NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
|
||||
ARG NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
|
||||
ARG NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL
|
||||
ARG NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS
|
||||
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
@@ -23,6 +24,7 @@ ENV NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=$NEXT_PUBLIC_POLYWEATHER_LOCAL_FUL
|
||||
ENV NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=$NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
|
||||
ENV NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=$NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL
|
||||
ENV NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=$NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS
|
||||
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY --from=deps /app/frontend/node_modules ./node_modules
|
||||
|
||||
+7
-7
@@ -46,7 +46,7 @@ npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Vercel 最小部署配置
|
||||
## 最小部署配置
|
||||
|
||||
只跑看板和基础鉴权时,先填这 4 项:
|
||||
|
||||
@@ -97,7 +97,7 @@ NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false
|
||||
NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
|
||||
```
|
||||
|
||||
更完整的 Vercel 配置说明见:
|
||||
更完整的部署配置说明见:
|
||||
- [docs/FRONTEND_DEPLOYMENT_ZH.md](/E:/web/PolyWeather/docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
|
||||
## 路由处理器
|
||||
@@ -155,7 +155,7 @@ Ops:
|
||||
注意:
|
||||
|
||||
- `/ops` 现在是前后端双层管理员限制
|
||||
- Vercel 前端和后端都应配置相同的 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- 前端容器和后端都应配置相同的 `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- 前端登录邮箱本身不会自动获得管理员权限
|
||||
|
||||
## 支付安全补充
|
||||
@@ -172,7 +172,7 @@ Ops:
|
||||
这意味着:
|
||||
|
||||
- 旧标签页风险已明显降低
|
||||
- 但支付地址变更后,仍建议在 Vercel 上 redeploy 当前 production,并清理明显过期 deployment
|
||||
- 支付地址变更后,由于地址在前端镜像构建期注入,需要触发一次 `main` push(或重跑 deploy workflow)发布新镜像;浏览器侧靠 `/api/payments/config` 的运行时校验兜底
|
||||
|
||||
## 缓存行为
|
||||
|
||||
@@ -184,11 +184,11 @@ Ops:
|
||||
- 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=按可见城市数动态限制`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新
|
||||
- 前端只消费 HTTP snapshot + SSE patch,不直接感知 Redis;Redis Stream / SQLite event log 都由后端统一封装
|
||||
|
||||
## Vercel 节流建议
|
||||
## 成本与节流建议
|
||||
|
||||
- 生产环境建议关闭 `Web Analytics` 和 `Speed Insights`
|
||||
- 前端与后端以 Docker Compose 部署在同一 VPS,静态资源通过 Cloudflare 缓存(见 `next.config.mjs` 的 `headers()` 和 CI 的 `cloudflare-cache-rules` job)
|
||||
- 建议把自建 `app analytics / web vitals / eager city summaries` 默认保持关闭
|
||||
- 如果你部署在 Vercel,可在 Firewall 中加一条 `WordPress / php scanner` 拦截规则,避免无效扫描白白触发 middleware
|
||||
- 如果扫描流量明显,可在 Cloudflare WAF 中加一条 `WordPress / php scanner` 拦截规则,避免无效扫描白白触发 Nginx / middleware
|
||||
|
||||
## AGPL 与商用边界说明
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { I18nProvider } from "@/hooks/useI18n";
|
||||
import { AccountEntry } from "@/components/account/AccountEntry";
|
||||
|
||||
@@ -10,7 +11,9 @@ export const metadata: Metadata = {
|
||||
export default function AccountPage() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AccountEntry />
|
||||
<Suspense fallback={null}>
|
||||
<AccountEntry />
|
||||
</Suspense>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,10 +55,7 @@ export async function POST(req: NextRequest) {
|
||||
...(body ?? {}),
|
||||
payload: {
|
||||
...payload,
|
||||
cf_country:
|
||||
req.headers.get("cf-ipcountry") ||
|
||||
req.headers.get("x-vercel-ip-country") ||
|
||||
"",
|
||||
cf_country: req.headers.get("cf-ipcountry") || "",
|
||||
user_agent: req.headers.get("user-agent") || "",
|
||||
referer_header: req.headers.get("referer") || "",
|
||||
},
|
||||
|
||||
@@ -165,10 +165,7 @@ async function trackAuthDiagnosticEvent(
|
||||
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") ||
|
||||
"",
|
||||
cf_country: req.headers.get("cf-ipcountry") || "",
|
||||
user_agent: req.headers.get("user-agent") || "",
|
||||
referer_header: req.headers.get("referer") || "",
|
||||
captured_at: new Date().toISOString(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
buildJsonBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -22,10 +23,7 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
const res = await fetch(`${API_BASE}/api/auth/referral/apply`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...auth.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: buildJsonBackendRequestHeaders(auth.headers),
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
import {
|
||||
buildCityDetailProxyCachePolicy,
|
||||
NO_STORE_CACHE_CONTROL,
|
||||
} from "@/lib/proxy-cache-policy";
|
||||
import {
|
||||
createProxyTimer,
|
||||
finishProxyTimedResponse,
|
||||
@@ -33,6 +36,15 @@ function parseRequestedCities(req: NextRequest) {
|
||||
}
|
||||
|
||||
function buildCityDetailBatchTimeoutPayload(requestedCities: string[]) {
|
||||
const city_status = Object.fromEntries(
|
||||
requestedCities.map((city) => [
|
||||
city,
|
||||
{
|
||||
status: "proxy_timeout",
|
||||
duration_ms: null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return {
|
||||
cities: requestedCities,
|
||||
details: {},
|
||||
@@ -40,6 +52,18 @@ function buildCityDetailBatchTimeoutPayload(requestedCities: string[]) {
|
||||
missing: requestedCities,
|
||||
partial: true,
|
||||
timeout: true,
|
||||
diagnostics: {
|
||||
version: 1,
|
||||
response_source: "next_proxy_timeout",
|
||||
partial: true,
|
||||
partial_reason: "proxy_timeout",
|
||||
requested_count: requestedCities.length,
|
||||
completed_count: 0,
|
||||
missing_count: requestedCities.length,
|
||||
error_count: 0,
|
||||
proxy_timeout_ms: DETAIL_BATCH_PROXY_TIMEOUT_MS,
|
||||
city_status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,7 +82,7 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const requestedCities = parseRequestedCities(req);
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh);
|
||||
const searchParams = new URLSearchParams({
|
||||
cities: req.nextUrl.searchParams.get("cities") || "",
|
||||
force_refresh: forceRefresh,
|
||||
@@ -79,15 +103,19 @@ export async function GET(req: NextRequest) {
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
(data as { partial?: unknown }).partial === true
|
||||
? "no-store, max-age=0"
|
||||
? NO_STORE_CACHE_CONTROL
|
||||
: cachePolicy.responseCacheControl,
|
||||
fetchCache: "no-store",
|
||||
includeSupabaseIdentity: true,
|
||||
publicMessage: "Failed to fetch city detail batch",
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
signal: controller.signal,
|
||||
timeoutResponse: () =>
|
||||
NextResponse.json(buildCityDetailBatchTimeoutPayload(requestedCities), {
|
||||
headers: { "Cache-Control": "no-store, max-age=0" },
|
||||
headers: {
|
||||
"Cache-Control": NO_STORE_CACHE_CONTROL,
|
||||
"Cloudflare-CDN-Cache-Control": NO_STORE_CACHE_CONTROL,
|
||||
},
|
||||
status: 200,
|
||||
}),
|
||||
timeoutPublicMessage: "City detail batch request timed out",
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import {
|
||||
buildCityListCacheControl,
|
||||
buildStaticCityListFallbackCacheControl,
|
||||
} from "@/lib/proxy-cache-policy";
|
||||
import { STATIC_CITY_LIST } from "@/lib/static-cities";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const CITIES_CACHE_CONTROL = "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_CACHE_CONTROL = buildCityListCacheControl();
|
||||
const STATIC_CITIES_CACHE_CONTROL = buildStaticCityListFallbackCacheControl();
|
||||
const CITIES_BACKEND_TIMEOUT_MS = Number(
|
||||
process.env.POLYWEATHER_CITIES_BACKEND_TIMEOUT_MS || 1000,
|
||||
);
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function GET(
|
||||
|
||||
const { name } = await timer.measure("route_params", () => context.params);
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh);
|
||||
const depth = req.nextUrl.searchParams.get("depth");
|
||||
const marketSlug = req.nextUrl.searchParams.get("market_slug");
|
||||
const targetDate = req.nextUrl.searchParams.get("target_date");
|
||||
@@ -82,7 +82,7 @@ export async function GET(
|
||||
headers: auth.headers,
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 60 } }),
|
||||
}),
|
||||
);
|
||||
const backendServerTiming = res.headers.get("server-timing") || "";
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { NO_STORE_CACHE_CONTROL } from "@/lib/proxy-cache-policy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": NO_STORE_CACHE_CONTROL,
|
||||
"Cloudflare-CDN-Cache-Control": NO_STORE_CACHE_CONTROL,
|
||||
},
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: NO_STORE_CACHE_CONTROL,
|
||||
fetchCache: "no-store",
|
||||
includeSupabaseIdentity: false,
|
||||
publicMessage: "Failed to fetch live city observation",
|
||||
url: `${API_BASE}/api/city/${encodeURIComponent(name)}/observation`,
|
||||
});
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export async function GET(
|
||||
const { name } = await context.params;
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const depth = req.nextUrl.searchParams.get("depth") ?? "panel";
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh);
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}&depth=${encodeURIComponent(depth)}`;
|
||||
|
||||
try {
|
||||
@@ -150,7 +150,7 @@ export async function GET(
|
||||
headers: auth.headers,
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 60 } }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
@@ -159,7 +159,7 @@ export async function GET(
|
||||
headers: auth.headers,
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: 10 } }),
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 60 } }),
|
||||
});
|
||||
if (summaryRes.ok) {
|
||||
const summaryData = await summaryRes.json();
|
||||
@@ -168,7 +168,7 @@ export async function GET(
|
||||
buildFallbackCityDetail(name, depth, summaryData),
|
||||
cachePolicy.fetchMode === "no-store"
|
||||
? cachePolicy.responseCacheControl
|
||||
: "public, max-age=0, s-maxage=10, stale-while-revalidate=30",
|
||||
: cachePolicy.responseCacheControl,
|
||||
);
|
||||
response.headers.set("X-PolyWeather-Fallback", "summary");
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
|
||||
@@ -2,15 +2,33 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import {
|
||||
requireTurnstileForRequest,
|
||||
stripTurnstileToken,
|
||||
} from "@/lib/turnstile";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
function emptyFeedbackResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
feedback: [],
|
||||
total: 0,
|
||||
status_counts: {},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
@@ -22,8 +40,9 @@ export async function GET(req: NextRequest) {
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
try {
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
if (!auth.authUserId) {
|
||||
return applyAuthResponseCookies(emptyFeedbackResponse(), auth.response);
|
||||
}
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/feedback`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
@@ -35,6 +54,9 @@ export async function GET(req: NextRequest) {
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return applyAuthResponseCookies(emptyFeedbackResponse(), auth.response);
|
||||
}
|
||||
if (!res.ok) {
|
||||
return applyAuthResponseCookies(
|
||||
buildUpstreamErrorResponse(res.status, raw, {
|
||||
@@ -71,6 +93,8 @@ export async function POST(req: NextRequest) {
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
try {
|
||||
const body = await req.json();
|
||||
const turnstileError = await requireTurnstileForRequest(req, "feedback_submit", body);
|
||||
if (turnstileError) return turnstileError;
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
@@ -78,7 +102,7 @@ export async function POST(req: NextRequest) {
|
||||
const res = await fetch(`${API_BASE}/api/feedback`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(stripTurnstileToken(body)),
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/audit-log`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch ops audit log",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders, buildJsonBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function PUT(req: NextRequest) {
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
|
||||
const res = await fetch(BACKEND, { method: "PUT", headers: buildJsonBackendRequestHeaders(auth.headers), body, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ feedbackId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const { feedbackId } = await context.params;
|
||||
const body = await req.json();
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/reward`,
|
||||
{
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
...Object.fromEntries(new Headers(auth.headers).entries()),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to grant feedback reward",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,9 @@ export async function GET(req: NextRequest) {
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/payments/incidents`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: auth.headers,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
buildJsonBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ caseId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const resolved = await params;
|
||||
const body = await req.text();
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/ops/refunds/${encodeURIComponent(resolved.caseId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
cache: "no-store",
|
||||
headers: buildJsonBackendRequestHeaders(auth.headers),
|
||||
body,
|
||||
},
|
||||
);
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to update refund case",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
buildJsonBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/refunds`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch refund cases",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const res = await fetch(`${API_BASE}/api/ops/refunds`, {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: buildJsonBackendRequestHeaders(auth.headers),
|
||||
body,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to create refund case",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders, buildJsonBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function PUT(req: NextRequest) {
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
|
||||
const res = await fetch(BACKEND, { method: "PUT", headers: buildJsonBackendRequestHeaders(auth.headers), body, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders, buildJsonBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
@@ -14,9 +14,9 @@ export async function POST(req: NextRequest) {
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" };
|
||||
const headers = buildJsonBackendRequestHeaders(auth.headers);
|
||||
if (ENTITLEMENT_TOKEN) {
|
||||
headers.Authorization = `Bearer ${ENTITLEMENT_TOKEN}`;
|
||||
headers.set("Authorization", `Bearer ${ENTITLEMENT_TOKEN}`);
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/ops/subscriptions/extend`, {
|
||||
method: "POST", headers, body, cache: "no-store",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
buildJsonBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
@@ -40,7 +41,7 @@ async function findSupabaseUserIdByEmail(email: string) {
|
||||
.replace(/\/$/, "");
|
||||
const serviceRoleKey = String(process.env.SUPABASE_SERVICE_ROLE_KEY || "").trim();
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
throw new Error("Supabase service role is not configured on Vercel");
|
||||
throw new Error("Supabase service role is not configured on the server");
|
||||
}
|
||||
const profileRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/profiles?select=id&email=eq.${encodeURIComponent(email)}&limit=1`,
|
||||
@@ -166,7 +167,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ops/subscriptions/grant`, {
|
||||
method: "POST",
|
||||
headers: { ...(auth.headers as Record<string, string>), "Content-Type": "application/json" },
|
||||
headers: buildJsonBackendRequestHeaders(auth.headers),
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
buildJsonBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
@@ -24,10 +25,7 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...auth.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: buildJsonBackendRequestHeaders(auth.headers),
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import {
|
||||
requireTurnstileForRequest,
|
||||
stripTurnstileToken,
|
||||
} from "@/lib/turnstile";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -53,6 +57,10 @@ export async function POST(
|
||||
const { intentId } = await context.params;
|
||||
try {
|
||||
const body = await req.json();
|
||||
if (process.env.POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT === "true") {
|
||||
const turnstileError = await requireTurnstileForRequest(req, "payment_tx_submit", body);
|
||||
if (turnstileError) return turnstileError;
|
||||
}
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
@@ -63,7 +71,7 @@ export async function POST(
|
||||
{
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
body: JSON.stringify(stripTurnstileToken(body ?? {})),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { isPaymentHostAllowed } from "@/lib/payment-host";
|
||||
import {
|
||||
requireTurnstileForRequest,
|
||||
stripTurnstileToken,
|
||||
} from "@/lib/turnstile";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -35,6 +39,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
try {
|
||||
const body = await req.json();
|
||||
const turnstileError = await requireTurnstileForRequest(req, "payment_intent_create", body);
|
||||
if (turnstileError) return turnstileError;
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
@@ -43,7 +49,7 @@ export async function POST(req: NextRequest) {
|
||||
const res = await fetch(`${API_BASE}/api/payments/intents`, {
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
body: JSON.stringify(stripTurnstileToken(body ?? {})),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -10,6 +10,42 @@ import {
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const BACKEND_RETRY_DELAY_MS = 250;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchWalletVerifyWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
attempts = 2,
|
||||
) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt >= attempts) break;
|
||||
await sleep(BACKEND_RETRY_DELAY_MS * attempt);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function logWalletVerifyProxyError(
|
||||
error: unknown,
|
||||
authDebug: Record<string, unknown>,
|
||||
) {
|
||||
const source = error as { name?: unknown; message?: unknown; cause?: unknown };
|
||||
console.error("[payment-wallet-verify-proxy-exception]", {
|
||||
error_name: String(source?.name || "Error"),
|
||||
error_message: String(source?.message || error || "unknown"),
|
||||
error_cause: source?.cause ? String(source.cause) : "",
|
||||
...authDebug,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
@@ -18,31 +54,45 @@ export async function POST(req: NextRequest) {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid wallet verify request" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
let authDebug: Record<string, unknown> = {};
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
authDebug = {
|
||||
incoming_has_authorization: Boolean(
|
||||
String(req.headers.get("authorization") || "").trim(),
|
||||
),
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has("x-polyweather-auth-user-id"),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
};
|
||||
const res = await fetchWalletVerifyWithRetry(
|
||||
`${API_BASE}/api/payments/wallets/verify`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
extraDebug: {
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has(
|
||||
"x-polyweather-auth-user-id",
|
||||
),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
},
|
||||
extraDebug: authDebug,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
@@ -50,8 +100,12 @@ export async function POST(req: NextRequest) {
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
logWalletVerifyProxyError(error, authDebug);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to verify wallet binding",
|
||||
status: 502,
|
||||
publicMessage:
|
||||
"Wallet verify service is temporarily unavailable. Please retry.",
|
||||
extra: { retryable: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
import {
|
||||
buildForceRefreshProxyCachePolicy,
|
||||
buildScanTerminalResponseCacheControl,
|
||||
} from "@/lib/proxy-cache-policy";
|
||||
import { DASHBOARD_REFRESH_POLICY_SEC } from "@/lib/refresh-policy";
|
||||
import {
|
||||
createProxyTimer,
|
||||
@@ -40,6 +43,8 @@ export async function GET(req: NextRequest) {
|
||||
"time_range",
|
||||
"limit",
|
||||
"force_refresh",
|
||||
"diff",
|
||||
"since_snapshot_id",
|
||||
"timezone_offset_seconds",
|
||||
]) {
|
||||
const value = req.nextUrl.searchParams.get(key);
|
||||
@@ -64,10 +69,15 @@ export async function GET(req: NextRequest) {
|
||||
try {
|
||||
return await proxyBackendJsonGet(req, {
|
||||
cacheControl: cachePolicy.responseCacheControl,
|
||||
fetchCache:
|
||||
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined,
|
||||
cacheControlForData: (data) =>
|
||||
buildScanTerminalResponseCacheControl(
|
||||
data,
|
||||
cachePolicy.responseCacheControl,
|
||||
),
|
||||
fetchCache: "no-store",
|
||||
publicMessage: "Failed to fetch scan terminal data",
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
includeSupabaseIdentity: true,
|
||||
signal: controller.signal,
|
||||
timeoutPublicMessage: "Scan terminal request timed out",
|
||||
timing: timer,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
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;
|
||||
|
||||
@@ -11,11 +16,28 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "public, max-age=0, s-maxage=30, stale-while-revalidate=120",
|
||||
detailLimit: 500,
|
||||
publicMessage: "Failed to fetch system status",
|
||||
revalidateSeconds: 30,
|
||||
url: `${API_BASE}/api/system/status`,
|
||||
});
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/system/status`, {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Cloudflare-CDN-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 system status",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/
|
||||
import { getConfiguredSiteUrl } from "@/lib/site-url";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const TERMINAL_CHECKOUT_PATH = "/account?checkout=1";
|
||||
|
||||
function normalizeNextPath(input: string | null) {
|
||||
const fallback = "/";
|
||||
@@ -23,6 +24,14 @@ function normalizeAuthError(input: string | null) {
|
||||
return raw.slice(0, 240);
|
||||
}
|
||||
|
||||
function isTerminalNextPath(pathname: string) {
|
||||
return pathname === "/terminal" || pathname.startsWith("/terminal/");
|
||||
}
|
||||
|
||||
type AuthProfilePayload = {
|
||||
subscription_active?: boolean | null;
|
||||
};
|
||||
|
||||
function redirectToLoginWithError({
|
||||
baseUrl,
|
||||
error,
|
||||
@@ -39,9 +48,9 @@ function redirectToLoginWithError({
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
async function warmSignupTrial(accessToken: string) {
|
||||
async function warmSignupTrial(accessToken: string): Promise<AuthProfilePayload | null> {
|
||||
const token = String(accessToken || "").trim();
|
||||
if (!API_BASE || !token) return;
|
||||
if (!API_BASE || !token) return null;
|
||||
|
||||
const headers = new Headers({
|
||||
Accept: "application/json",
|
||||
@@ -55,18 +64,42 @@ async function warmSignupTrial(accessToken: string) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2500);
|
||||
try {
|
||||
await fetch(`${API_BASE.replace(/\/+$/, "")}/api/auth/me?scope=entitlement`, {
|
||||
const response = await fetch(`${API_BASE.replace(/\/+$/, "")}/api/auth/me?scope=entitlement`, {
|
||||
cache: "no-store",
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { subscription_active: false };
|
||||
}
|
||||
return await response.json();
|
||||
} catch {
|
||||
// The account/terminal bootstrap will retry. Callback must not strand login.
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePostAuthRedirect({
|
||||
accessToken,
|
||||
baseUrl,
|
||||
nextPath,
|
||||
}: {
|
||||
accessToken: string;
|
||||
baseUrl: string;
|
||||
nextPath: string;
|
||||
}) {
|
||||
const profile = await warmSignupTrial(accessToken);
|
||||
if (!isTerminalNextPath(nextPath)) {
|
||||
return new URL(nextPath, baseUrl);
|
||||
}
|
||||
|
||||
const redirectPath =
|
||||
profile?.subscription_active === true ? nextPath : TERMINAL_CHECKOUT_PATH;
|
||||
return new URL(redirectPath, baseUrl);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const siteUrl = getConfiguredSiteUrl();
|
||||
if (siteUrl) {
|
||||
@@ -122,7 +155,12 @@ export async function GET(request: NextRequest) {
|
||||
nextPath,
|
||||
});
|
||||
}
|
||||
await warmSignupTrial(session?.access_token || "");
|
||||
const finalRedirectUrl = await resolvePostAuthRedirect({
|
||||
accessToken: session.access_token,
|
||||
baseUrl,
|
||||
nextPath,
|
||||
});
|
||||
response.headers.set("Location", finalRedirectUrl.toString());
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { BriefDetailPageView } from "@/components/public-content/PublicContentPages";
|
||||
import {
|
||||
LANDING_LOCALE_COOKIE,
|
||||
LANDING_LOCALE_QUERY_PARAM,
|
||||
pickLandingLocale,
|
||||
type LandingLocale,
|
||||
} from "@/components/landing/landingLocale";
|
||||
import {
|
||||
PUBLIC_BRIEFS,
|
||||
absolutePublicUrl,
|
||||
briefPath,
|
||||
getBrief,
|
||||
localizeBrief,
|
||||
} from "@/content/public-content";
|
||||
|
||||
type BriefPageParams = {
|
||||
city: string;
|
||||
date: string;
|
||||
};
|
||||
type BriefSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
async function resolvePublicContentLocale(searchParams: BriefSearchParams): Promise<LandingLocale> {
|
||||
const params = await searchParams;
|
||||
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
|
||||
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
|
||||
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
|
||||
return pickLandingLocale(
|
||||
queryLocale,
|
||||
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
|
||||
headerStore.get("accept-language"),
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return PUBLIC_BRIEFS.map((brief) => ({
|
||||
city: brief.city,
|
||||
date: brief.date,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<BriefPageParams>;
|
||||
searchParams: BriefSearchParams;
|
||||
}): Promise<Metadata> {
|
||||
const [{ city, date }, locale] = await Promise.all([
|
||||
params,
|
||||
resolvePublicContentLocale(searchParams),
|
||||
]);
|
||||
const brief = getBrief(city, date);
|
||||
|
||||
if (!brief) {
|
||||
return {
|
||||
title: "Brief not found",
|
||||
};
|
||||
}
|
||||
|
||||
const localizedBrief = localizeBrief(brief, locale);
|
||||
const pathname = briefPath(localizedBrief);
|
||||
|
||||
return {
|
||||
title: localizedBrief.title,
|
||||
description: localizedBrief.description,
|
||||
alternates: {
|
||||
canonical: pathname,
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: localizedBrief.title,
|
||||
description: localizedBrief.description,
|
||||
url: pathname,
|
||||
publishedTime: localizedBrief.publishedAt,
|
||||
modifiedTime: localizedBrief.updatedAt,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: localizedBrief.title,
|
||||
description: localizedBrief.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BriefDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<BriefPageParams>;
|
||||
searchParams: BriefSearchParams;
|
||||
}) {
|
||||
const [{ city, date }, locale] = await Promise.all([
|
||||
params,
|
||||
resolvePublicContentLocale(searchParams),
|
||||
]);
|
||||
const brief = getBrief(city, date);
|
||||
|
||||
if (!brief) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const localizedBrief = localizeBrief(brief, locale);
|
||||
const pathname = briefPath(localizedBrief);
|
||||
const jsonLd = [
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
headline: localizedBrief.title,
|
||||
description: localizedBrief.description,
|
||||
datePublished: localizedBrief.publishedAt,
|
||||
dateModified: localizedBrief.updatedAt,
|
||||
mainEntityOfPage: absolutePublicUrl(pathname),
|
||||
author: {
|
||||
"@type": "Organization",
|
||||
name: "PolyWeather",
|
||||
url: "https://polyweather.top",
|
||||
},
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "PolyWeather",
|
||||
url: "https://polyweather.top",
|
||||
},
|
||||
about: [
|
||||
localizedBrief.cityName,
|
||||
localizedBrief.market,
|
||||
localizedBrief.settlementSource,
|
||||
"DEB forecast methodology",
|
||||
],
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Briefs",
|
||||
item: absolutePublicUrl("/briefs"),
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: localizedBrief.cityName,
|
||||
item: absolutePublicUrl(pathname),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<BriefDetailPageView brief={brief} locale={locale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { BriefsIndexPageView } from "@/components/public-content/PublicContentPages";
|
||||
import {
|
||||
LANDING_LOCALE_COOKIE,
|
||||
LANDING_LOCALE_QUERY_PARAM,
|
||||
pickLandingLocale,
|
||||
type LandingLocale,
|
||||
} from "@/components/landing/landingLocale";
|
||||
import { PUBLIC_CONTENT_COPY } from "@/content/public-content";
|
||||
|
||||
type BriefsSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
async function resolvePublicContentLocale(searchParams: BriefsSearchParams): Promise<LandingLocale> {
|
||||
const params = await searchParams;
|
||||
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
|
||||
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
|
||||
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
|
||||
return pickLandingLocale(
|
||||
queryLocale,
|
||||
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
|
||||
headerStore.get("accept-language"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: BriefsSearchParams;
|
||||
}): Promise<Metadata> {
|
||||
const locale = await resolvePublicContentLocale(searchParams);
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
return {
|
||||
title: copy.briefIndexEyebrow,
|
||||
description: copy.briefIndexDescription,
|
||||
alternates: {
|
||||
canonical: "/briefs",
|
||||
},
|
||||
openGraph: {
|
||||
title: `Weather Market Brief | PolyWeather`,
|
||||
description: copy.briefIndexDescription,
|
||||
url: "/briefs",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BriefsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: BriefsSearchParams;
|
||||
}) {
|
||||
const locale = await resolvePublicContentLocale(searchParams);
|
||||
return <BriefsIndexPageView locale={locale} />;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||
import { RegisterSW } from "@/components/dashboard/RegisterSW";
|
||||
import { MicrosoftClarity } from "@/components/observability/MicrosoftClarity";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
@@ -89,6 +90,7 @@ export default function RootLayout({
|
||||
</a>
|
||||
<main id="main-content">{children}</main>
|
||||
<RegisterSW />
|
||||
<MicrosoftClarity />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { MethodologyDetailPageView } from "@/components/public-content/PublicContentPages";
|
||||
import {
|
||||
METHODOLOGY_PAGES,
|
||||
absolutePublicUrl,
|
||||
getMethodologyPage,
|
||||
methodologyPath,
|
||||
} from "@/content/public-content";
|
||||
|
||||
type MethodologyPageParams = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return METHODOLOGY_PAGES.map((page) => ({ slug: page.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<MethodologyPageParams>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const page = getMethodologyPage(slug);
|
||||
|
||||
if (!page) {
|
||||
return {
|
||||
title: "Methodology not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
alternates: {
|
||||
canonical: methodologyPath(page),
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
url: methodologyPath(page),
|
||||
modifiedTime: page.updatedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function MethodologyDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<MethodologyPageParams>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const page = getMethodologyPage(slug);
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const pathname = methodologyPath(page);
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "TechArticle",
|
||||
headline: page.title,
|
||||
description: page.description,
|
||||
dateModified: page.updatedAt,
|
||||
mainEntityOfPage: absolutePublicUrl(pathname),
|
||||
author: {
|
||||
"@type": "Organization",
|
||||
name: "PolyWeather",
|
||||
url: "https://polyweather.top",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<MethodologyDetailPageView page={page} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { MethodologyIndexPageView } from "@/components/public-content/PublicContentPages";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Methodology",
|
||||
description:
|
||||
"PolyWeather public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.",
|
||||
alternates: {
|
||||
canonical: "/methodology",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Methodology | PolyWeather",
|
||||
description:
|
||||
"Public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.",
|
||||
url: "/methodology",
|
||||
},
|
||||
};
|
||||
|
||||
export default function MethodologyPage() {
|
||||
return <MethodologyIndexPageView />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AuditLogPageClient } from "@/components/ops/audit/AuditLogPageClient";
|
||||
|
||||
export default function OpsAuditLogPage() {
|
||||
return <AuditLogPageClient />;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLandingPage";
|
||||
import { LANDING_LOCALE_QUERY_PARAM } from "@/components/landing/landingLocale";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather | Institutional Weather Signal Intelligence",
|
||||
@@ -32,6 +33,8 @@ export default async function HomePage({
|
||||
const qs = usp.toString();
|
||||
redirect(`/auth/callback${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
const rawLandingLocale = params[LANDING_LOCALE_QUERY_PARAM];
|
||||
const landingLocale = Array.isArray(rawLandingLocale) ? rawLandingLocale[0] : rawLandingLocale;
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
@@ -68,7 +71,7 @@ export default async function HomePage({
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<InstitutionalLandingPage />
|
||||
<InstitutionalLandingPage queryLocale={landingLocale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+44
-2
@@ -1,20 +1,62 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import {
|
||||
METHODOLOGY_PAGES,
|
||||
PUBLIC_BRIEFS,
|
||||
SOURCE_PAGES,
|
||||
} from "@/content/public-content";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = "https://polyweather.top";
|
||||
const now = new Date();
|
||||
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: "weekly",
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/auth/login`,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.6,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/briefs`,
|
||||
lastModified: now,
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/methodology`,
|
||||
lastModified: now,
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/sources`,
|
||||
lastModified: now,
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.8,
|
||||
},
|
||||
...PUBLIC_BRIEFS.map((brief) => ({
|
||||
url: `${baseUrl}/briefs/${brief.city}/${brief.date}`,
|
||||
lastModified: new Date(brief.updatedAt),
|
||||
changeFrequency: "daily" as const,
|
||||
priority: 0.85,
|
||||
})),
|
||||
...METHODOLOGY_PAGES.map((page) => ({
|
||||
url: `${baseUrl}/methodology/${page.slug}`,
|
||||
lastModified: new Date(page.updatedAt),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.75,
|
||||
})),
|
||||
...SOURCE_PAGES.map((source) => ({
|
||||
url: `${baseUrl}/sources/${source.slug}`,
|
||||
lastModified: new Date(source.updatedAt),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.7,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SourceDetailPageView } from "@/components/public-content/PublicContentPages";
|
||||
import {
|
||||
SOURCE_PAGES,
|
||||
absolutePublicUrl,
|
||||
getSourcePage,
|
||||
sourcePath,
|
||||
} from "@/content/public-content";
|
||||
|
||||
type SourcePageParams = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return SOURCE_PAGES.map((source) => ({ slug: source.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<SourcePageParams>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const source = getSourcePage(slug);
|
||||
|
||||
if (!source) {
|
||||
return {
|
||||
title: "Source not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: source.title,
|
||||
description: source.description,
|
||||
alternates: {
|
||||
canonical: sourcePath(source),
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: source.title,
|
||||
description: source.description,
|
||||
url: sourcePath(source),
|
||||
modifiedTime: source.updatedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function SourceDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<SourcePageParams>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const source = getSourcePage(slug);
|
||||
|
||||
if (!source) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const pathname = sourcePath(source);
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Dataset",
|
||||
name: source.title,
|
||||
description: source.description,
|
||||
url: absolutePublicUrl(pathname),
|
||||
dateModified: source.updatedAt,
|
||||
creator: {
|
||||
"@type": "Organization",
|
||||
name: source.operator,
|
||||
},
|
||||
includedInDataCatalog: {
|
||||
"@type": "DataCatalog",
|
||||
name: "PolyWeather source notes",
|
||||
url: absolutePublicUrl("/sources"),
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<SourceDetailPageView source={source} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { SourcesIndexPageView } from "@/components/public-content/PublicContentPages";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Weather Sources",
|
||||
description:
|
||||
"PolyWeather public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.",
|
||||
alternates: {
|
||||
canonical: "/sources",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Weather Sources | PolyWeather",
|
||||
description:
|
||||
"Public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.",
|
||||
url: "/sources",
|
||||
},
|
||||
};
|
||||
|
||||
export default function SourcesPage() {
|
||||
return <SourcesIndexPageView />;
|
||||
}
|
||||
@@ -16,8 +16,8 @@ const FAQ_ITEMS = [
|
||||
{
|
||||
q_zh: "Pro 包含哪些功能?",
|
||||
q_en: "What features does Pro include?",
|
||||
a_zh: "开通后可解锁:天气决策台、多城市图表巡检、未来日期分析、全平台智能气象推送。",
|
||||
a_en: "Unlocks: weather decision terminal, multi-city chart monitoring, future-date analysis, and cross-platform smart weather push.",
|
||||
a_zh: "开通后可解锁:结算源优先终端、多城市图表巡检、未来日期分析、Telegram 缓存推送。",
|
||||
a_en: "Unlocks: settlement-source-first terminal, multi-city chart monitoring, future-date analysis, and Telegram cached alerts.",
|
||||
},
|
||||
{
|
||||
q_zh: "当前订阅价格是多少?",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
import {
|
||||
User as UserIcon,
|
||||
@@ -41,11 +41,9 @@ import {
|
||||
} from "@/lib/supabase/client";
|
||||
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
|
||||
|
||||
import type { AuthMeResponse } from "./types";
|
||||
import {
|
||||
SUBSCRIPTION_HELP_HREF,
|
||||
TELEGRAM_BOT_URL,
|
||||
TELEGRAM_GROUP_URL,
|
||||
TELEGRAM_TOPICS_GROUP_URL,
|
||||
@@ -53,6 +51,7 @@ import {
|
||||
} from "./constants";
|
||||
import { InfoRow, PlusIcon } from "./AccountInfoRow";
|
||||
import { AccountFeedbackPanel } from "./AccountFeedbackPanel";
|
||||
import { TurnstileWidget } from "@/components/security/TurnstileWidget";
|
||||
import {
|
||||
chainIdToDisplayName,
|
||||
clearStoredPaymentRecovery,
|
||||
@@ -63,11 +62,28 @@ import {
|
||||
import { createAccountCopy } from "./account-copy";
|
||||
import { resetWalletConnectProvider } from "./wallet";
|
||||
import { useAccountPayment } from "./useAccountPayment";
|
||||
import {
|
||||
buildTrialValueReplaySummary,
|
||||
readTrialValueReplay,
|
||||
} from "@/lib/trial-value-replay";
|
||||
import { getTurnstileTokenForAction } from "@/lib/turnstile-client";
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
function pointSourceLabel(source?: string, isEn = false) {
|
||||
const key = String(source || "").trim().toLowerCase();
|
||||
if (key === "feedback_reward") return isEn ? "Feedback reward" : "反馈奖励";
|
||||
if (key === "ops_manual_grant") return isEn ? "Ops manual grant" : "后台补发";
|
||||
if (key === "paid_referral") return isEn ? "Paid referral" : "有效付费邀请";
|
||||
if (key === "growth_milestone_reward") return isEn ? "Growth reward" : "增长奖励";
|
||||
if (key === "points_redemption") return isEn ? "Payment redemption" : "支付抵扣";
|
||||
if (key === "ops_subscription_deduction") return isEn ? "Ops deduction" : "后台订阅扣分";
|
||||
return key || (isEn ? "Unknown source" : "未知来源");
|
||||
}
|
||||
|
||||
export function AccountCenter() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
const copy = useMemo(() => createAccountCopy(isEn), [isEn]);
|
||||
@@ -77,9 +93,9 @@ export function AccountCenter() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showSecondarySections, setShowSecondarySections] = useState(false);
|
||||
const [trialValueReplay, setTrialValueReplay] = useState(() => readTrialValueReplay());
|
||||
|
||||
// ── Shared state (declared in component, written by hook via setters) ─
|
||||
const [showOverlay, setShowOverlay] = useState(false);
|
||||
const [usePoints, setUsePoints] = useState(true);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const [updatedAt, setUpdatedAt] = useState<string>("");
|
||||
@@ -87,9 +103,25 @@ export function AccountCenter() {
|
||||
const [backend, setBackend] = useState<AuthMeResponse | null>(null);
|
||||
const [referralCodeInput, setReferralCodeInput] = useState("");
|
||||
const [referralApplying, setReferralApplying] = useState(false);
|
||||
const [paymentTurnstileToken, setPaymentTurnstileToken] = useState("");
|
||||
const [paymentTurnstileResetKey, setPaymentTurnstileResetKey] = useState(0);
|
||||
|
||||
const supabaseReady = hasSupabasePublicEnv();
|
||||
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
|
||||
const resetPaymentTurnstile = useCallback(() => {
|
||||
setPaymentTurnstileToken("");
|
||||
setPaymentTurnstileResetKey((value) => value + 1);
|
||||
}, []);
|
||||
const getPaymentTurnstileToken = useCallback(
|
||||
(
|
||||
action: "payment_intent_create" | "payment_tx_submit",
|
||||
options?: { optional?: boolean },
|
||||
) => {
|
||||
if (options?.optional && !paymentTurnstileToken) return undefined;
|
||||
return getTurnstileTokenForAction(paymentTurnstileToken, action);
|
||||
},
|
||||
[paymentTurnstileToken],
|
||||
);
|
||||
|
||||
// ── Hook ────────────────────────────────────────────────
|
||||
const {
|
||||
@@ -98,10 +130,10 @@ export function AccountCenter() {
|
||||
paymentInfo,
|
||||
paymentError,
|
||||
lastIntentId,
|
||||
lastTxHash,
|
||||
lastPaymentStartedAt,
|
||||
telegramBindOpening,
|
||||
telegramBindUrl,
|
||||
telegramBindCommand,
|
||||
manualPayment,
|
||||
manualTxHash,
|
||||
txValidation,
|
||||
@@ -151,7 +183,6 @@ export function AccountCenter() {
|
||||
allowedPaymentHosts,
|
||||
currentPaymentHost,
|
||||
paymentHostAllowed,
|
||||
selectedPlan,
|
||||
selectedPaymentToken,
|
||||
selectedTokenLabel,
|
||||
availableTokenList,
|
||||
@@ -161,7 +192,6 @@ export function AccountCenter() {
|
||||
resolvedSelectedTokenAddress,
|
||||
paymentReceiverAddress,
|
||||
paymentWalletLabel,
|
||||
hasPayingWallet,
|
||||
totalPoints,
|
||||
billing,
|
||||
|
||||
@@ -170,11 +200,10 @@ export function AccountCenter() {
|
||||
loadPaymentSnapshot,
|
||||
connectAndBindWallet,
|
||||
handleUnbindWallet,
|
||||
createIntentAndPay,
|
||||
createManualPaymentIntent,
|
||||
submitManualPaymentTx,
|
||||
validateTxHash,
|
||||
handleOverlayCheckout,
|
||||
createTelegramBotBindCommand,
|
||||
openTelegramBotBindLink,
|
||||
} = useAccountPayment({
|
||||
isEn,
|
||||
@@ -187,10 +216,10 @@ export function AccountCenter() {
|
||||
setBackend,
|
||||
setErrorText,
|
||||
setUpdatedAt,
|
||||
showOverlay,
|
||||
setShowOverlay,
|
||||
usePoints,
|
||||
setUsePoints,
|
||||
getPaymentTurnstileToken,
|
||||
resetPaymentTurnstile,
|
||||
});
|
||||
|
||||
// ── Auth analytics effect ──────────────────────────────
|
||||
@@ -399,10 +428,10 @@ export function AccountCenter() {
|
||||
!isSubscribed && expiryInfo && expiryInfo.expired,
|
||||
);
|
||||
const paymentFeatureReady = paymentReadyForRecovery;
|
||||
const canOpenCheckoutOverlay = Boolean(
|
||||
const canTrialUpgrade = Boolean(isSubscribed && isTrialSubscription);
|
||||
const canStartPayment = Boolean(
|
||||
paymentFeatureReady &&
|
||||
!isSubscriptionUnknown &&
|
||||
(!isSubscribed || showExpiringSoon || showExpiredReminder),
|
||||
(canTrialUpgrade || !isSubscribed || showExpiringSoon || showExpiredReminder),
|
||||
);
|
||||
const subscriptionStatusTitle = showExpiredReminder
|
||||
? copy.proExpiredTitle
|
||||
@@ -433,14 +462,14 @@ export function AccountCenter() {
|
||||
{ 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 selectedPlanDurationDays = Number(selectedPlan?.duration_days || 30);
|
||||
const overlayPlanLabel =
|
||||
selectedPlanCode === "pro_quarterly"
|
||||
? copy.quarterlyPlan
|
||||
: copy.monthlyPlan;
|
||||
const overlayPeriodLabel = isEn
|
||||
? `/ ${selectedPlanDurationDays} days`
|
||||
: `/ ${selectedPlanDurationDays} 天`;
|
||||
const manualTxHashReady = /^0x[a-fA-F0-9]{64}$/.test(manualTxHash.trim());
|
||||
const trialValueReplaySummary = useMemo(
|
||||
() => buildTrialValueReplaySummary(trialValueReplay, {
|
||||
isEn,
|
||||
trialExpiresAt: displayExpiryRaw,
|
||||
}),
|
||||
[displayExpiryRaw, isEn, trialValueReplay],
|
||||
);
|
||||
const referral = backend?.referral;
|
||||
const referralCode = String(referral?.code || "").trim();
|
||||
const appliedReferralCode = String(referral?.applied_code || "").trim();
|
||||
@@ -451,9 +480,7 @@ export function AccountCenter() {
|
||||
referralCodeInput.trim(),
|
||||
);
|
||||
|
||||
// ── Payment overlay tracking effect ──────────────────────
|
||||
useEffect(() => {
|
||||
if (!showOverlay || !canOpenCheckoutOverlay) return;
|
||||
const focusPaymentManagement = useCallback(() => {
|
||||
trackAppEvent("paywall_viewed", {
|
||||
entry: "account_center",
|
||||
user_state: isAuthenticated ? "logged_in" : "guest",
|
||||
@@ -461,15 +488,39 @@ export function AccountCenter() {
|
||||
expiring_soon: showExpiringSoon,
|
||||
subscription_plan_code: planCode || null,
|
||||
});
|
||||
const paymentSection = document.getElementById("payment-management");
|
||||
paymentSection?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, [
|
||||
isAuthenticated,
|
||||
canOpenCheckoutOverlay,
|
||||
planCode,
|
||||
showExpiredReminder,
|
||||
showExpiringSoon,
|
||||
showOverlay,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("checkout") === "1") {
|
||||
window.setTimeout(focusPaymentManagement, 0);
|
||||
}
|
||||
}, [focusPaymentManagement, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTrialSubscription) return;
|
||||
setTrialValueReplay(readTrialValueReplay());
|
||||
}, [authUserId, isTrialSubscription]);
|
||||
|
||||
const openTrialValueReplayCheckout = useCallback(() => {
|
||||
trackAppEvent("paywall_feature_clicked", {
|
||||
entry: "account_center",
|
||||
feature: "trial_value_replay_upgrade",
|
||||
user_id: authUserId || null,
|
||||
replay_cities: trialValueReplay.citiesViewed.length,
|
||||
replay_terminal_visits: trialValueReplay.terminalVisits,
|
||||
replay_rows_available: trialValueReplay.rowsAvailableMax,
|
||||
subscription_plan_code: planCode || null,
|
||||
});
|
||||
focusPaymentManagement();
|
||||
}, [authUserId, focusPaymentManagement, planCode, trialValueReplay]);
|
||||
|
||||
// ── Referral points display ────────────────────────────
|
||||
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
|
||||
const referralRewardPoints = Number.isFinite(referralRewardPointsRaw)
|
||||
@@ -495,11 +546,12 @@ export function AccountCenter() {
|
||||
const monthlyReferralPointsLimit = Number.isFinite(monthlyReferralPointsLimitRaw)
|
||||
? Math.max(0, monthlyReferralPointsLimitRaw)
|
||||
: monthlyReferralLimit * referralRewardPoints;
|
||||
const pointsLedger = backend?.points_ledger;
|
||||
const pointSourceRows = Object.entries(pointsLedger?.by_source ?? {});
|
||||
const recentPointEvents = pointsLedger?.recent ?? [];
|
||||
|
||||
// ── Telegram bind command ──────────────────────────────
|
||||
const bindCommand = userId
|
||||
? `/bind ${userId}${email ? ` ${email}` : ""}`
|
||||
: "/bind <supabase_user_id> <email>";
|
||||
const bindCommand = telegramBindCommand || copy.telegramBindCommandPlaceholder;
|
||||
|
||||
// ── Copy handler ──────────────────────────────────────
|
||||
const handleCopy = (text: string) => {
|
||||
@@ -509,6 +561,13 @@ export function AccountCenter() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyTelegramBindCommand = async () => {
|
||||
if (!isAuthenticated || telegramBindOpening) return;
|
||||
const command = await createTelegramBotBindCommand();
|
||||
if (!command) return;
|
||||
handleCopy(command);
|
||||
};
|
||||
|
||||
const applyReferralCode = useCallback(async () => {
|
||||
const code = referralCodeInput.trim();
|
||||
if (!code || referralApplying) return;
|
||||
@@ -596,9 +655,10 @@ export function AccountCenter() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!showOverlay && canOpenCheckoutOverlay && (
|
||||
{canStartPayment && (
|
||||
<button
|
||||
onClick={() => setShowOverlay(true)}
|
||||
type="button"
|
||||
onClick={focusPaymentManagement}
|
||||
className="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-700 transition-all hover:bg-amber-100"
|
||||
>
|
||||
<Crown size={16} />{" "}
|
||||
@@ -629,7 +689,7 @@ export function AccountCenter() {
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/auth/login?next=%2Faccount"
|
||||
href="/auth/login?next=%2Faccount%3Fcheckout%3D1"
|
||||
className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-sm font-semibold text-blue-700 transition-all hover:bg-blue-100"
|
||||
>
|
||||
<LogIn size={16} /> {copy.signIn}
|
||||
@@ -664,7 +724,7 @@ export function AccountCenter() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOverlay(true)}
|
||||
onClick={focusPaymentManagement}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-800 transition-all hover:bg-amber-100"
|
||||
>
|
||||
<Crown size={16} />
|
||||
@@ -674,6 +734,45 @@ export function AccountCenter() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTrialSubscription && canStartPayment && (
|
||||
<section className="lg:col-span-12 overflow-hidden rounded-2xl border border-amber-200 bg-white shadow-sm">
|
||||
<div className="grid gap-0 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div className="min-w-0 p-6">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs font-black uppercase text-amber-700">
|
||||
<Sparkles size={15} />
|
||||
<span>{isEn ? "Trial value replay" : "试用价值回放"}</span>
|
||||
{trialValueReplaySummary.hasUsageEvidence ? (
|
||||
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] text-amber-800">
|
||||
{isEn ? "Based on your trial" : "基于你的试用"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="max-w-3xl text-base font-bold leading-7 text-slate-950">
|
||||
{trialValueReplaySummary.headline}
|
||||
</p>
|
||||
<div className="mt-4 grid gap-2 text-sm text-slate-600 md:grid-cols-3">
|
||||
{trialValueReplaySummary.bullets.map((bullet) => (
|
||||
<div key={bullet} className="flex min-w-0 items-start gap-2">
|
||||
<CheckCircle2 size={15} className="mt-0.5 shrink-0 text-emerald-500" />
|
||||
<span className="min-w-0 leading-5">{bullet}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center border-t border-amber-100 bg-amber-50 p-6 md:border-l md:border-t-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openTrialValueReplayCheckout}
|
||||
className="inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-xl bg-amber-600 px-5 py-3 text-sm font-black text-white shadow-sm transition hover:bg-amber-700 md:w-auto"
|
||||
>
|
||||
<Crown size={16} />
|
||||
{trialValueReplaySummary.primaryCta}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* User Card */}
|
||||
<div className="flex flex-col items-center gap-8 rounded-2xl border border-slate-200 bg-white p-8 shadow-sm lg:col-span-8 md:flex-row">
|
||||
<div className="relative">
|
||||
@@ -812,10 +911,68 @@ export function AccountCenter() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="lg:col-span-12 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<h3 className="flex items-center gap-2 text-lg font-bold text-slate-950">
|
||||
<Coins size={20} className="text-yellow-500" />
|
||||
{isEn ? "Point Sources" : "积分来源"}
|
||||
</h3>
|
||||
<span className="text-xs font-semibold text-slate-500">
|
||||
{isEn ? "Current balance" : "当前余额"} {Number(pointsLedger?.balance ?? totalPoints).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{pointSourceRows.length === 0 && recentPointEvents.length === 0 ? (
|
||||
<p className="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-500">
|
||||
{copy.pointsRule}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{pointSourceRows.map(([source, item]) => (
|
||||
<div key={source} className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-bold text-slate-600">
|
||||
{pointSourceLabel(source, isEn)}
|
||||
</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-950">
|
||||
{Number(item?.points ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-slate-500">
|
||||
{Number(item?.count ?? 0).toLocaleString()} {isEn ? "events" : "笔记录"} · {source}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200">
|
||||
<div className="border-b border-slate-200 px-4 py-2 text-xs font-bold uppercase text-slate-500">
|
||||
{isEn ? "Recent point ledger" : "最近积分流水"}
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{recentPointEvents.slice(0, 5).map((event, index) => (
|
||||
<div key={`${event.id ?? index}-${event.source}`} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-slate-800">
|
||||
{pointSourceLabel(event.source, isEn)}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-slate-500">
|
||||
{event.reference_type || "ledger"} · {event.created_at ? formatTime(event.created_at, locale) : "--"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`font-mono font-bold ${Number(event.delta_points ?? 0) >= 0 ? "text-emerald-600" : "text-red-600"}`}>
|
||||
{Number(event.delta_points ?? 0) >= 0 ? "+" : ""}
|
||||
{Number(event.delta_points ?? 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Subscription Info & Paywall */}
|
||||
<div className="lg:col-span-12 relative">
|
||||
<div
|
||||
className={`grid grid-cols-1 md:grid-cols-2 gap-6 transition-all duration-700 ${canOpenCheckoutOverlay && showOverlay ? "blur-md grayscale-[0.3] opacity-30 select-none pointer-events-none" : ""}`}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-6 transition-all duration-700"
|
||||
>
|
||||
<section className="space-y-3 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h3 className="mb-4 text-sm font-bold uppercase text-blue-700">
|
||||
@@ -906,43 +1063,6 @@ export function AccountCenter() {
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Paywall Mask */}
|
||||
{canOpenCheckoutOverlay && showOverlay && (
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center p-4">
|
||||
<UnlockProOverlay
|
||||
points={totalPoints}
|
||||
planPriceUsd={billing.planAmount}
|
||||
planLabel={overlayPlanLabel}
|
||||
periodLabel={overlayPeriodLabel}
|
||||
usePoints={usePoints}
|
||||
onToggleUsePoints={() => setUsePoints((prev) => !prev)}
|
||||
billing={{
|
||||
pointsEnabled: billing.pointsEnabled,
|
||||
isEligible: billing.canRedeem,
|
||||
pointsUsed: billing.pointsUsed,
|
||||
discountAmount: billing.discountAmount,
|
||||
finalPrice: billing.payAmount,
|
||||
maxDiscountUsd: billing.maxDiscountUsdc,
|
||||
pointsPerUsd: billing.pointsPerUsdc,
|
||||
}}
|
||||
onPay={() => void handleOverlayCheckout()}
|
||||
onManualPay={() => void createManualPaymentIntent()}
|
||||
onClose={() => setShowOverlay(false)}
|
||||
payBusy={paymentBusy}
|
||||
payLabel={hasPayingWallet ? copy.payNow : copy.connectAndPay}
|
||||
manualPayLabel="手动转账"
|
||||
locale={locale}
|
||||
errorText={paymentError || undefined}
|
||||
infoText={paymentInfo || undefined}
|
||||
txHash={lastTxHash || undefined}
|
||||
chainId={selectedPaymentChainId || paymentConfig?.chain_id || 137}
|
||||
paymentTokenLabel={selectedTokenLabel}
|
||||
faqHref={SUBSCRIPTION_HELP_HREF}
|
||||
telegramGroupUrl=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AccountFeedbackPanel
|
||||
@@ -974,8 +1094,8 @@ export function AccountCenter() {
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOverlay(true)}
|
||||
disabled={!canOpenCheckoutOverlay}
|
||||
onClick={focusPaymentManagement}
|
||||
disabled={!canStartPayment}
|
||||
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} />
|
||||
@@ -1040,8 +1160,9 @@ export function AccountCenter() {
|
||||
: copy.telegramBotBindLink}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCopy(bindCommand)}
|
||||
className="rounded-xl border border-blue-700 bg-blue-600 p-4 text-white shadow-sm transition-all hover:bg-blue-700"
|
||||
onClick={() => void handleCopyTelegramBindCommand()}
|
||||
disabled={telegramBindOpening || !isAuthenticated}
|
||||
className="rounded-xl border border-blue-700 bg-blue-600 p-4 text-white shadow-sm transition-all hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={copy.copyCommand}
|
||||
aria-label={copy.copyCommand}
|
||||
>
|
||||
@@ -1098,6 +1219,7 @@ export function AccountCenter() {
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
id="payment-management"
|
||||
data-testid="payment-management-grid"
|
||||
className={`grid gap-6 lg:items-start ${
|
||||
hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"
|
||||
@@ -1340,6 +1462,12 @@ export function AccountCenter() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TurnstileWidget
|
||||
action="payment_intent_create"
|
||||
onToken={setPaymentTurnstileToken}
|
||||
resetKey={paymentTurnstileResetKey}
|
||||
/>
|
||||
|
||||
{/* Payment Method Tabs */}
|
||||
<div className="border-t border-slate-200 pt-5">
|
||||
<p className="mb-3 text-[10px] font-semibold uppercase text-slate-500">
|
||||
@@ -1609,6 +1737,10 @@ export function AccountCenter() {
|
||||
"amount_insufficient"
|
||||
? copy
|
||||
.verifyAmountLow
|
||||
: txValidation.reason ===
|
||||
"direct_self_transfer"
|
||||
? copy
|
||||
.verifySelfTransfer
|
||||
: txValidation.reason ===
|
||||
"tx_reverted"
|
||||
? copy
|
||||
@@ -1626,11 +1758,7 @@ export function AccountCenter() {
|
||||
onClick={() =>
|
||||
void submitManualPaymentTx()
|
||||
}
|
||||
disabled={
|
||||
paymentBusy ||
|
||||
!(txValidation.checked &&
|
||||
txValidation.valid === true)
|
||||
}
|
||||
disabled={paymentBusy || !manualTxHashReady}
|
||||
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"
|
||||
>
|
||||
{copy.paymentManualSubmit}
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
feedbackStatusLabel,
|
||||
feedbackStatusTone,
|
||||
} from "@/components/dashboard/scan-terminal/feedback-status";
|
||||
import {
|
||||
getSupabaseBrowserClient,
|
||||
hasSupabasePublicEnv,
|
||||
} from "@/lib/supabase/client";
|
||||
|
||||
function compactDate(value?: string) {
|
||||
if (!value) return "--";
|
||||
@@ -97,9 +101,27 @@ export function AccountFeedbackPanel({
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
if (!hasSupabasePublicEnv()) {
|
||||
setAvailable(false);
|
||||
setEntries([]);
|
||||
return;
|
||||
}
|
||||
const {
|
||||
data: { session },
|
||||
} = await getSupabaseBrowserClient().auth.getSession();
|
||||
if (signal?.aborted) return;
|
||||
const accessToken = String(session?.access_token || "").trim();
|
||||
if (!accessToken) {
|
||||
setAvailable(false);
|
||||
setEntries([]);
|
||||
return;
|
||||
}
|
||||
const res = await fetch("/api/feedback?limit=10", {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
|
||||
@@ -31,6 +31,20 @@ export function runTests() {
|
||||
!feedbackPanelSource.includes("setInterval"),
|
||||
"account feedback panel must load the current user's feedback once, support manual refresh, and avoid polling",
|
||||
);
|
||||
const sessionLookupIndex = feedbackPanelSource.indexOf(".auth.getSession()");
|
||||
const feedbackFetchIndex = feedbackPanelSource.indexOf("/api/feedback?limit=10");
|
||||
assert(
|
||||
feedbackPanelSource.includes("getSupabaseBrowserClient") &&
|
||||
feedbackPanelSource.includes("hasSupabasePublicEnv") &&
|
||||
sessionLookupIndex >= 0 &&
|
||||
feedbackFetchIndex >= 0 &&
|
||||
sessionLookupIndex < feedbackFetchIndex &&
|
||||
feedbackPanelSource.includes("accessToken") &&
|
||||
feedbackPanelSource.includes("Authorization: `Bearer ${accessToken}`") &&
|
||||
feedbackPanelSource.includes("if (!accessToken)") &&
|
||||
feedbackPanelSource.includes("setAvailable(false)"),
|
||||
"account feedback panel must avoid unauthenticated 401 requests by checking the browser session before fetching feedback",
|
||||
);
|
||||
assert(
|
||||
feedbackPanelSource.includes("reward_points") &&
|
||||
feedbackPanelSource.includes("reward_reason") &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
buildAuthMePath,
|
||||
mergeAccountAuthSnapshot,
|
||||
shouldResolveAccountUnknownWithFullProfile,
|
||||
} from "@/lib/auth-snapshot";
|
||||
import type { AuthSnapshotLike } from "@/lib/auth-snapshot";
|
||||
|
||||
@@ -64,4 +65,33 @@ export function runTests() {
|
||||
inactive.subscription_active === false,
|
||||
"account snapshot merge must still accept a confirmed inactive subscription response",
|
||||
);
|
||||
|
||||
assert(
|
||||
shouldResolveAccountUnknownWithFullProfile(
|
||||
{ authenticated: true, subscription_active: null },
|
||||
true,
|
||||
),
|
||||
"account center must resolve an unknown local-user subscription snapshot with the full auth profile",
|
||||
);
|
||||
assert(
|
||||
!shouldResolveAccountUnknownWithFullProfile(
|
||||
{ authenticated: true, subscription_active: false },
|
||||
true,
|
||||
),
|
||||
"account center must not re-resolve an explicitly inactive subscription snapshot",
|
||||
);
|
||||
assert(
|
||||
!shouldResolveAccountUnknownWithFullProfile(
|
||||
{ authenticated: false, subscription_active: null },
|
||||
true,
|
||||
),
|
||||
"account center must not re-resolve an unauthenticated backend snapshot",
|
||||
);
|
||||
assert(
|
||||
!shouldResolveAccountUnknownWithFullProfile(
|
||||
{ authenticated: true, subscription_active: null },
|
||||
false,
|
||||
),
|
||||
"account center must only resolve unknown subscription snapshots when a local Supabase user exists",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,10 +27,6 @@ export function runTests() {
|
||||
path.join(projectRoot, "components", "account", "useBilling.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const unlockProOverlay = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "subscription", "UnlockProOverlay.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const telegramPricing = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "telegram-pricing.ts"),
|
||||
"utf8",
|
||||
@@ -93,17 +89,16 @@ export function runTests() {
|
||||
!accountCenter.includes(["private", "Group", "Monthly", "Plan"].join("")) &&
|
||||
!accountCopy.includes(["Private", "group", "monthly"].join(" ")) &&
|
||||
!accountCopy.includes(["私", "密", "群", "月", "付"].join("")),
|
||||
"account plan card and checkout overlay should not expose a separate discounted monthly label",
|
||||
"account plan card should not expose a separate discounted monthly label",
|
||||
);
|
||||
assert(
|
||||
accountCenter.includes("overlayPlanLabel") &&
|
||||
accountCenter.includes("overlayPeriodLabel") &&
|
||||
accountCenter.includes("displayPlanList.map") &&
|
||||
accountCenter.includes("plan.amount_usdc") &&
|
||||
accountCenter.includes("USDC") &&
|
||||
!accountCenter.includes(`copy.${["private", "Group", "Monthly", "Plan"].join("")}`) &&
|
||||
unlockProOverlay.includes("planLabel") &&
|
||||
unlockProOverlay.includes("USDC") &&
|
||||
!unlockProOverlay.includes("<span className={s.price}>${planPriceUsd.toFixed(2)}</span>") &&
|
||||
!unlockProOverlay.includes('<span className={s.summaryUnit}>USD</span>'),
|
||||
"checkout overlay must display payment amounts as USDC without exposing the discounted-price source label",
|
||||
!accountCenter.includes("overlayPlanLabel") &&
|
||||
!accountCenter.includes("overlayPeriodLabel"),
|
||||
"payment management must display payment amounts as USDC without relying on the removed checkout overlay",
|
||||
);
|
||||
assert(
|
||||
types.includes("ReferralSummary") &&
|
||||
|
||||
@@ -80,16 +80,26 @@ export function runTests() {
|
||||
"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",
|
||||
accountCenterSource.includes("manualTxHashReady") &&
|
||||
accountCenterSource.includes("/^0x[a-fA-F0-9]{64}$/") &&
|
||||
!/!\(\s*txValidation\.checked\s*&&\s*txValidation\.valid === true\s*\)/.test(
|
||||
accountCenterSource,
|
||||
) &&
|
||||
paymentFlowSource.includes("const submitRes = await fetch(`/api/payments/intents/${intentIdVal}/submit`") &&
|
||||
paymentFlowSource.includes("const confirmRes = await fetch(`/api/payments/intents/${intentIdVal}/confirm`"),
|
||||
"manual payment submit button may proceed with a well-formed tx hash, but backend submit/confirm must remain the authority",
|
||||
);
|
||||
assert(
|
||||
paymentFlowSource.includes("validateTxHash") &&
|
||||
paymentFlowSource.includes("/validate"),
|
||||
"manual payment flow must validate tx hashes with the backend before submission",
|
||||
);
|
||||
assert(
|
||||
paymentFlowSource.includes("confirmRes.status === 503") &&
|
||||
paymentFlowSource.includes('lowerRaw.includes("cannot connect payment rpc")') &&
|
||||
paymentFlowSource.includes('lowerRaw.includes("payment rpc chain mismatch")'),
|
||||
"manual payment confirm must treat transient payment RPC failures as pending after tx hash submission",
|
||||
);
|
||||
assert(
|
||||
paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") &&
|
||||
paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") <
|
||||
@@ -186,6 +196,7 @@ export function runTests() {
|
||||
"app/api/ops/users/route.ts",
|
||||
"app/api/ops/users/grant-points/route.ts",
|
||||
"app/api/ops/view-logs/route.ts",
|
||||
"app/api/system/status/route.ts",
|
||||
]) {
|
||||
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
|
||||
assert(
|
||||
@@ -211,14 +222,14 @@ export function runTests() {
|
||||
const optionalRefreshIndex = middlewareSource.indexOf(
|
||||
"function shouldRefreshOptionalSupabaseSession",
|
||||
);
|
||||
const systemStatusPublicIndex = middlewareSource.indexOf(
|
||||
'pathname === "/api/system/status"',
|
||||
const publicApiFunction = middlewareSource.slice(
|
||||
middlewareSource.indexOf("function isPublicApi"),
|
||||
middlewareSource.indexOf("function shouldRefreshOptionalSupabaseSession"),
|
||||
);
|
||||
assert(
|
||||
systemStatusPublicIndex >= 0 &&
|
||||
optionalRefreshIndex >= 0 &&
|
||||
systemStatusPublicIndex < optionalRefreshIndex,
|
||||
"middleware must treat public system status as public API instead of optional Supabase session refresh",
|
||||
optionalRefreshIndex >= 0 &&
|
||||
!publicApiFunction.includes('pathname === "/api/system/status"'),
|
||||
"middleware must not treat system status as public after it becomes an ops-only API",
|
||||
);
|
||||
|
||||
for (const route of paymentRoutes) {
|
||||
@@ -246,10 +257,30 @@ export function runTests() {
|
||||
walletChallengeRouteSource.includes("retryable: true"),
|
||||
"wallet challenge proxy exception response must mark transient failures as retryable",
|
||||
);
|
||||
const walletVerifyRouteSource = fs.readFileSync(
|
||||
path.join(projectRoot, "app/api/payments/wallets/verify/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
assert(
|
||||
walletVerifyRouteSource.includes("fetchWalletVerifyWithRetry"),
|
||||
"wallet verify proxy must retry a transient backend connection failure before blocking wallet binding",
|
||||
);
|
||||
assert(
|
||||
walletVerifyRouteSource.includes("Invalid wallet verify request"),
|
||||
"wallet verify proxy must return a client error for malformed JSON instead of a generic payment failure",
|
||||
);
|
||||
assert(
|
||||
walletVerifyRouteSource.includes("retryable: true"),
|
||||
"wallet verify proxy exception response must mark transient failures as retryable",
|
||||
);
|
||||
assert(
|
||||
walletBindSource.includes("readPaymentApiErrorMessage"),
|
||||
"wallet binding errors must show the API error message instead of raw JSON",
|
||||
);
|
||||
assert(
|
||||
walletBindSource.includes("钱包验证服务暂时不可用"),
|
||||
"wallet binding HTML/50x fallback should stay localized in the Chinese account UI",
|
||||
);
|
||||
assert(
|
||||
paymentUtilsSource.includes("looksLikeHtmlDocument") &&
|
||||
paymentUtilsSource.includes("Payment service is temporarily unavailable") &&
|
||||
|
||||
@@ -62,6 +62,10 @@ export function runTests() {
|
||||
path.join(accountDir, "usePaymentFlow.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const useBillingSource = fs.readFileSync(
|
||||
path.join(accountDir, "useBilling.ts"),
|
||||
"utf8",
|
||||
);
|
||||
assert(
|
||||
(accountCenterSource.includes('import { usePaymentState } from "./usePaymentState";') ||
|
||||
hookSource.includes('import { usePaymentState } from "./usePaymentState";')) &&
|
||||
@@ -148,14 +152,10 @@ export function runTests() {
|
||||
);
|
||||
|
||||
assert(
|
||||
accountCenterSource.includes(
|
||||
'import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";',
|
||||
),
|
||||
"checkout overlay must be in the account bundle, not lazy-loaded after the user clicks pay",
|
||||
);
|
||||
assert(
|
||||
!/const\s+UnlockProOverlay\s*=\s*dynamic\s*\(/.test(accountCenterSource),
|
||||
"checkout overlay must not be dynamically imported; stale deployments can make the lazy chunk fail at pay time",
|
||||
!accountCenterSource.includes("UnlockProOverlay") &&
|
||||
!accountCenterSource.includes("showOverlay") &&
|
||||
accountCenterSource.includes("focusPaymentManagement"),
|
||||
"account page must use the payment management section as the only checkout surface instead of a fixed Pro overlay",
|
||||
);
|
||||
assert(
|
||||
!/STATIC_ASSETS\s*=\s*\[[^\]]*["']\/_next\//s.test(serviceWorkerSource),
|
||||
@@ -193,6 +193,23 @@ export function runTests() {
|
||||
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(
|
||||
!accountCenterSource.includes("`/bind ${userId}${email ? ` ${email}` : \"\"}`") &&
|
||||
!accountCenterSource.includes("/bind <supabase_user_id> <email>"),
|
||||
"Telegram fallback copy must not expose the legacy /bind supabase_user_id command",
|
||||
);
|
||||
assert(
|
||||
accountCenterSource.includes("telegramBindCommand") &&
|
||||
accountCenterSource.includes("createTelegramBotBindCommand") &&
|
||||
accountCenterSource.includes("handleCopyTelegramBindCommand"),
|
||||
"account Telegram fallback copy must generate a fresh one-time /start bind token before copying",
|
||||
);
|
||||
assert(
|
||||
useBillingSource.includes("bot_command") &&
|
||||
useBillingSource.includes("createTelegramBotBindCommand") &&
|
||||
useBillingSource.includes("telegramBindCommand"),
|
||||
"billing hook must persist the backend /start bind token command for Telegram fallback copy",
|
||||
);
|
||||
assert(
|
||||
!appAnalyticsSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"') &&
|
||||
!analyticsRouteSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"'),
|
||||
@@ -232,12 +249,30 @@ export function runTests() {
|
||||
),
|
||||
"account center must distinguish unknown subscription sync state from a confirmed unsubscribed account",
|
||||
);
|
||||
assert(
|
||||
!accountCenterSource.includes("paymentFeatureReady &&\n !isSubscriptionUnknown") &&
|
||||
!accountCenterSource.includes("paymentFeatureReady &&\r\n !isSubscriptionUnknown"),
|
||||
"account center must not block checkout when an authenticated user's subscription status is temporarily unknown",
|
||||
);
|
||||
assert(
|
||||
accountFeatureSource.includes("状态待确认") &&
|
||||
accountFeatureSource.includes("刷新或直接续费") &&
|
||||
!accountFeatureSource.includes("订阅状态正在同步,请稍后刷新。"),
|
||||
"account unknown subscription copy must tell users to refresh or renew instead of waiting indefinitely",
|
||||
);
|
||||
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("shouldResolveAccountUnknownWithFullProfile") &&
|
||||
hookSource.includes("backendJson = await readAuthSnapshot(latestHeaders);") &&
|
||||
hookSource.indexOf("shouldResolveAccountUnknownWithFullProfile") <
|
||||
hookSource.indexOf("setBackend((previous) => mergeAccountAuthSnapshot(previous, backendJson))"),
|
||||
"account snapshot loader must resolve unknown entitlement-scope results through full auth/me before rendering account status",
|
||||
);
|
||||
assert(
|
||||
hookSource.includes("refreshEntitlementAfterPayment") &&
|
||||
paymentFlowSource.includes("refreshEntitlementAfterPayment") &&
|
||||
@@ -322,7 +357,7 @@ export function runTests() {
|
||||
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.lastIndexOf("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",
|
||||
);
|
||||
@@ -377,6 +412,12 @@ export function runTests() {
|
||||
.length >= 3,
|
||||
"manual payment mutations must require a valid Supabase bearer token instead of forwarding unauthenticated requests that surface raw backend JSON",
|
||||
);
|
||||
assert(
|
||||
accountCenterSource.includes("manualTxHashReady") &&
|
||||
accountCenterSource.includes("/^0x[a-fA-F0-9]{64}$/") &&
|
||||
!accountCenterSource.includes("txValidation.valid === true)"),
|
||||
"manual transfer submit button must allow a well-formed tx hash even when advisory pre-validation is unavailable",
|
||||
);
|
||||
assert(
|
||||
paymentFlowSource.includes("verifyPaymentAuthReady") &&
|
||||
paymentFlowSource.indexOf("await verifyPaymentAuthReady()") <
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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 types = fs.readFileSync(path.join(projectRoot, "components", "account", "types.ts"), "utf8");
|
||||
const accountCenter = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "AccountCenter.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
types.includes("points_ledger") &&
|
||||
types.includes("PointsLedgerSummary") &&
|
||||
types.includes("PointsLedgerEntry"),
|
||||
"auth/me account types must include points ledger summary and entries",
|
||||
);
|
||||
assert(
|
||||
accountCenter.includes("points_ledger") &&
|
||||
accountCenter.includes("积分来源") &&
|
||||
accountCenter.includes("feedback_reward") &&
|
||||
accountCenter.includes("ops_manual_grant"),
|
||||
"account center must display point source explainability from auth/me",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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 accountCenter = fs.readFileSync(path.join(accountDir, "AccountCenter.tsx"), "utf8");
|
||||
const paymentFlow = fs.readFileSync(path.join(accountDir, "usePaymentFlow.ts"), "utf8");
|
||||
const accountPayment = fs.readFileSync(path.join(accountDir, "useAccountPayment.ts"), "utf8");
|
||||
const landingPage = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "landing", "InstitutionalLandingPage.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const landingAuthActions = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "landing", "LandingAuthActions.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const productAccess = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "ProductAccessRequired.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
accountCenter.includes("canTrialUpgrade") &&
|
||||
accountCenter.includes("isTrialSubscription") &&
|
||||
accountCenter.includes("canTrialUpgrade || !isSubscribed"),
|
||||
"trial subscribers must be allowed to enter payment management before expiry",
|
||||
);
|
||||
|
||||
assert(
|
||||
accountCenter.includes("useSearchParams") &&
|
||||
accountCenter.includes('searchParams.get("checkout") === "1"') &&
|
||||
accountCenter.includes("focusPaymentManagement"),
|
||||
"account page must support /account?checkout=1 by focusing payment management",
|
||||
);
|
||||
|
||||
assert(
|
||||
accountCenter.includes('href="/auth/login?next=%2Faccount%3Fcheckout%3D1"'),
|
||||
"account page sign-in button must preserve the checkout entry for unauthenticated subscription recovery",
|
||||
);
|
||||
|
||||
assert(
|
||||
landingAuthActions.includes('href="/account?checkout=1"') &&
|
||||
landingPage.includes('href="/account?checkout=1"') &&
|
||||
!/href="\/account"(?![/?#])/.test(landingAuthActions) &&
|
||||
!/href="\/account"(?![/?#])/.test(landingPage),
|
||||
"landing subscription/account CTAs must open the checkout account entry instead of a generic account page",
|
||||
);
|
||||
|
||||
assert(
|
||||
productAccess.includes('href="/account?checkout=1"') &&
|
||||
productAccess.includes("Renew and restore access") &&
|
||||
productAccess.includes("续费并恢复访问"),
|
||||
"expired terminal gate must send users directly to the checkout entry with renewal recovery copy, not a generic account page",
|
||||
);
|
||||
|
||||
assert(
|
||||
accountPayment.includes("authUserId,") &&
|
||||
paymentFlow.includes("authUserId: string") &&
|
||||
paymentFlow.includes("user_id: authUserId || null"),
|
||||
"payment_start and payment_success analytics must carry the authenticated user id for trial-to-paid attribution",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { buildTrialValueReplaySummary } from "@/lib/trial-value-replay";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const replayPath = path.join(projectRoot, "lib", "trial-value-replay.ts");
|
||||
const accountCenter = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "AccountCenter.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const dashboardSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(fs.existsSync(replayPath), "trial value replay helper must exist");
|
||||
const replaySource = fs.readFileSync(replayPath, "utf8");
|
||||
const accountReplaySurface = `${accountCenter}\n${replaySource}`;
|
||||
|
||||
assert(
|
||||
replaySource.includes("TRIAL_VALUE_REPLAY_STORAGE_KEY") &&
|
||||
replaySource.includes("recordTrialValueReplay") &&
|
||||
replaySource.includes("readTrialValueReplay") &&
|
||||
replaySource.includes("buildTrialValueReplaySummary"),
|
||||
"trial value replay must persist and summarize local trial usage evidence",
|
||||
);
|
||||
|
||||
assert(
|
||||
dashboardSource.includes("recordTrialValueReplay") &&
|
||||
dashboardSource.includes("isTrialTerminalAccess") &&
|
||||
dashboardSource.includes("selectedRow"),
|
||||
"terminal trial users must record real terminal usage for later value replay",
|
||||
);
|
||||
|
||||
assert(
|
||||
accountCenter.includes("buildTrialValueReplaySummary") &&
|
||||
accountCenter.includes("trialValueReplay") &&
|
||||
accountReplaySurface.includes("你本次试用已经") &&
|
||||
accountReplaySurface.includes("保持访问") &&
|
||||
accountReplaySurface.includes("Keep access"),
|
||||
"account trial upgrade trigger must replay the user's own trial value before opening checkout",
|
||||
);
|
||||
|
||||
assert(
|
||||
accountCenter.includes('trackAppEvent("paywall_feature_clicked"') &&
|
||||
accountCenter.includes('feature: "trial_value_replay_upgrade"'),
|
||||
"trial value replay upgrade CTA must be tracked before checkout opens",
|
||||
);
|
||||
|
||||
const zhSummary = buildTrialValueReplaySummary(
|
||||
{
|
||||
firstActiveAt: "2026-06-09T00:00:00.000Z",
|
||||
lastActiveAt: "2026-06-09T00:20:00.000Z",
|
||||
lastCityName: "Taipei",
|
||||
lastSignalLabel: "Hot",
|
||||
lastUserId: "user-1",
|
||||
terminalVisits: 2,
|
||||
rowsAvailableMax: 50,
|
||||
signalsViewed: 2,
|
||||
citiesViewed: ["Taipei", "New York"],
|
||||
},
|
||||
{ isEn: false },
|
||||
);
|
||||
|
||||
assert(
|
||||
zhSummary.headline === "你本次试用已经查看 2 个天气信号。" &&
|
||||
zhSummary.bullets.includes("已记录 2 次终端使用。") &&
|
||||
zhSummary.bullets.includes("Pro 终端本次提供了 50 个城市机会。") &&
|
||||
zhSummary.primaryCta === "保持访问",
|
||||
"trial value replay summary must turn usage evidence into concrete upgrade copy",
|
||||
);
|
||||
|
||||
const expirySummary = buildTrialValueReplaySummary(
|
||||
{
|
||||
firstActiveAt: null,
|
||||
lastActiveAt: null,
|
||||
lastCityName: null,
|
||||
lastSignalLabel: null,
|
||||
lastUserId: null,
|
||||
terminalVisits: 0,
|
||||
rowsAvailableMax: 0,
|
||||
signalsViewed: 0,
|
||||
citiesViewed: [],
|
||||
},
|
||||
{ isEn: false, trialExpiresAt: "2099-01-01T00:00:00.000Z" },
|
||||
);
|
||||
assert(
|
||||
expirySummary.bullets.some((bullet) => bullet.includes("试用剩余")),
|
||||
"trial value replay summary must include trial time remaining when expiry is known",
|
||||
);
|
||||
}
|
||||
@@ -46,8 +46,14 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
? "Use one-click Telegram binding first to sync notifications and access. After binding, refresh this page and submit your Telegram group join request."
|
||||
: "优先使用「一键绑定 Telegram Bot」同步通知与权限。绑定完成后刷新本页,再提交 Telegram 群组入群申请。",
|
||||
telegramFallbackHint: isEn
|
||||
? "Fallback copy method: only use this if one-click binding does not open Telegram correctly. Copy the command below and send it to @polyyuanbot. After binding, refresh this page to show the group entry."
|
||||
: "兜底复制方式:仅在一键绑定无法正常打开 Telegram 时使用。请复制下方命令并发送给 @polyyuanbot。绑定完成后刷新本页,即可显示入群入口。",
|
||||
? "Fallback copy method: click Copy to generate a 10-minute one-time /start bind command, send it to @polyyuanbot, then confirm binding in the Bot and refresh this page."
|
||||
: "兜底复制方式:点击复制会生成 10 分钟有效的一次性 /start bind 命令。请把它发送给 @polyyuanbot,并在 Bot 内确认绑定后刷新本页。",
|
||||
telegramBindCommandPlaceholder: isEn
|
||||
? "Click Copy to generate a one-time /start bind command"
|
||||
: "点击复制生成一次性 /start bind 命令",
|
||||
telegramBindCommandCopied: isEn
|
||||
? "One-time Telegram bind command generated and copied. Send it to @polyyuanbot, then confirm binding in the Bot."
|
||||
: "一次性 Telegram 绑定命令已生成并复制。请发送给 @polyyuanbot,然后在 Bot 内确认绑定。",
|
||||
paymentManualSupport: isEn
|
||||
? "If payment succeeds but Pro is still not activated, email yhrsc30@gmail.com. This project is currently maintained by one developer, so manual recovery may be needed in edge cases."
|
||||
: "如果付款成功后 Pro 仍未开通,请发邮件到 yhrsc30@gmail.com。当前项目由我一人维护,极少数边缘情况可能需要人工补开。给你带来的不便,敬请谅解!",
|
||||
@@ -159,10 +165,10 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
: "钱包已绑定,正在创建订单并发起支付...",
|
||||
proMember: "PRO MEMBER",
|
||||
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
|
||||
subscriptionChecking: isEn ? "Checking subscription" : "订阅同步中",
|
||||
subscriptionChecking: isEn ? "Status pending" : "状态待确认",
|
||||
subscriptionUnknown: isEn
|
||||
? "Subscription status is syncing. Please refresh shortly."
|
||||
: "订阅状态正在同步,请稍后刷新。",
|
||||
? "Subscription status is not confirmed yet. Refresh or renew directly."
|
||||
: "订阅状态暂未确认,可刷新或直接续费。",
|
||||
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
|
||||
proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期",
|
||||
proEndsSoonBody: isEn
|
||||
@@ -187,8 +193,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
? "Your wallet needs a small amount of POL for gas fees; USDC alone may not complete authorization or payment. Please confirm your wallet is on Polygon network and keep some POL before paying."
|
||||
: "钱包里需要少量 POL 作为 gas 手续费;只有 USDC 可能无法完成授权或支付。请确认当前钱包在 Polygon 网络,并预留一点 POL 后再支付。",
|
||||
paymentManualDesc: isEn
|
||||
? "Option 2: Transfer directly to the platform's receiver contract without binding a wallet. After the transfer, submit the transaction hash (Tx Hash) and the system will verify and activate Pro automatically."
|
||||
: "方式二:无需将钱包绑定到账号,直接向平台收款合约转账。转账完成后提交交易哈希(Tx Hash)系统会自动验签并开通 Pro。",
|
||||
? "Option 2: Transfer directly to the platform's receiver address without binding a wallet. After the transfer, submit the transaction hash (Tx Hash) and the system will verify and activate Pro automatically."
|
||||
: "方式二:无需将钱包绑定到账号,直接向平台收款地址转账。转账完成后提交交易哈希(Tx Hash)系统会自动验签并开通 Pro。",
|
||||
paymentManualTitle: isEn
|
||||
? "Manual Transfer (No Wallet Binding)"
|
||||
: "手动转账(无需绑定钱包)",
|
||||
@@ -256,6 +262,9 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
? "Please enter a valid tx hash."
|
||||
: "请输入有效的 tx hash。",
|
||||
verifying: isEn ? "Verifying..." : "验证中...",
|
||||
verifyUnavailable: isEn
|
||||
? "Pre-check is temporarily unavailable. You can still submit; the backend will verify on-chain before activation."
|
||||
: "预验证暂时不可用。你仍可提交,后端会在开通前再次链上校验。",
|
||||
verifyAddressMatch: isEn
|
||||
? "Receiver address and amount match"
|
||||
: "收款地址和金额匹配",
|
||||
@@ -271,6 +280,9 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
verifyTxReverted: isEn
|
||||
? "This transaction was reverted."
|
||||
: "该交易已回滚",
|
||||
verifySelfTransfer: isEn
|
||||
? "This is a receiver self-transfer, not a user payment. Please transfer from your own wallet to the receiver address."
|
||||
: "这笔是收款地址自转,不是用户付款。请从你自己的钱包转到收款地址。",
|
||||
verifyFailed: isEn ? "Verification failed: " : "验证失败: ",
|
||||
verifyUnknown: isEn ? "Unknown error" : "未知错误",
|
||||
// ── Telegram bind messages ────────────────────────────────────────
|
||||
|
||||
@@ -3,6 +3,7 @@ export type AuthMeResponse = {
|
||||
user_id?: string | null;
|
||||
email?: string | null;
|
||||
points?: number;
|
||||
points_ledger?: PointsLedgerSummary;
|
||||
weekly_points?: number;
|
||||
weekly_rank?: number | string | null;
|
||||
entitlement_mode?: string | null;
|
||||
@@ -25,6 +26,24 @@ export type AuthMeResponse = {
|
||||
entitlement_snapshot_reason?: string | null;
|
||||
};
|
||||
|
||||
export type PointsLedgerEntry = {
|
||||
id?: number;
|
||||
source?: string;
|
||||
delta_points?: number;
|
||||
balance_after?: number;
|
||||
actor_email?: string;
|
||||
reference_type?: string;
|
||||
reference_id?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type PointsLedgerSummary = {
|
||||
balance?: number;
|
||||
recent?: PointsLedgerEntry[];
|
||||
by_source?: Record<string, { points?: number; count?: number }>;
|
||||
};
|
||||
|
||||
export type ReferralSummary = {
|
||||
code?: string;
|
||||
discount_usdc?: string;
|
||||
@@ -208,10 +227,6 @@ export type Eip6963ProviderDetail = {
|
||||
provider: EvmProvider;
|
||||
};
|
||||
|
||||
export type ConnectBindOptions = {
|
||||
openOverlayAfterBind?: boolean;
|
||||
};
|
||||
|
||||
export type PaymentRecoveryState = {
|
||||
intentId: string;
|
||||
txHash: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { User } from "@supabase/supabase-js";
|
||||
import {
|
||||
buildAuthMePath,
|
||||
mergeAccountAuthSnapshot,
|
||||
shouldResolveAccountUnknownWithFullProfile,
|
||||
} from "@/lib/auth-snapshot";
|
||||
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
||||
|
||||
@@ -53,10 +54,13 @@ export interface UseAccountPaymentParams {
|
||||
setBackend: Dispatch<SetStateAction<AuthMeResponse | null>>;
|
||||
setErrorText: (text: string) => void;
|
||||
setUpdatedAt: (text: string) => void;
|
||||
showOverlay: boolean;
|
||||
setShowOverlay: (v: boolean) => void;
|
||||
usePoints: boolean;
|
||||
setUsePoints: (v: boolean) => void;
|
||||
getPaymentTurnstileToken?: (
|
||||
action: "payment_intent_create" | "payment_tx_submit",
|
||||
options?: { optional?: boolean },
|
||||
) => string | undefined;
|
||||
resetPaymentTurnstile?: () => void;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -72,10 +76,10 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
setBackend,
|
||||
setErrorText,
|
||||
setUpdatedAt,
|
||||
showOverlay,
|
||||
setShowOverlay,
|
||||
usePoints,
|
||||
setUsePoints,
|
||||
getPaymentTurnstileToken,
|
||||
resetPaymentTurnstile,
|
||||
} = params;
|
||||
|
||||
// ── Base payment state (from usePaymentState) ──────────────
|
||||
@@ -244,9 +248,19 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
// an unauthenticated backend snapshot as a temporary sync state.
|
||||
}
|
||||
}
|
||||
let resolvedWithFullProfile = false;
|
||||
if (shouldResolveAccountUnknownWithFullProfile(backendJson, Boolean(localUser))) {
|
||||
try {
|
||||
backendJson = await readAuthSnapshot(latestHeaders);
|
||||
resolvedWithFullProfile = true;
|
||||
} catch {
|
||||
// Keep the explicit unknown state only when the full account profile
|
||||
// cannot be read; the refresh button can retry the same path.
|
||||
}
|
||||
}
|
||||
setBackend((previous) => mergeAccountAuthSnapshot(previous, backendJson));
|
||||
setUpdatedAt(new Date().toISOString());
|
||||
if (backendJson.authenticated !== false) {
|
||||
if (backendJson.authenticated !== false && !resolvedWithFullProfile) {
|
||||
void readAuthSnapshot(latestHeaders)
|
||||
.then((fullJson) => {
|
||||
setBackend((previous) =>
|
||||
@@ -434,7 +448,6 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
setPaymentBusy,
|
||||
setPaymentInfo,
|
||||
setPaymentError,
|
||||
setShowOverlay,
|
||||
clearPaymentMessages,
|
||||
authIsAuthenticated,
|
||||
getValidAccessToken,
|
||||
@@ -487,6 +500,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
isEn,
|
||||
copy,
|
||||
backend,
|
||||
authUserId,
|
||||
paymentConfig,
|
||||
setPaymentConfig,
|
||||
selectedPlanCode,
|
||||
@@ -509,7 +523,6 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
setLastIntentId,
|
||||
setLastTxHash,
|
||||
setLastPaymentStartedAt,
|
||||
setShowOverlay,
|
||||
setManualPayment,
|
||||
setManualTxHash,
|
||||
setTxValidation,
|
||||
@@ -526,6 +539,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
allowedPaymentHosts: billing.allowedPaymentHosts,
|
||||
authIsAuthenticated,
|
||||
hasPayingWallet: walletBind.hasPayingWallet,
|
||||
getPaymentTurnstileToken,
|
||||
resetPaymentTurnstile,
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
@@ -549,6 +564,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
lastPaymentStartedAt,
|
||||
telegramBindOpening,
|
||||
telegramBindUrl: billing.telegramBindUrl,
|
||||
telegramBindCommand: billing.telegramBindCommand,
|
||||
manualPayment,
|
||||
manualTxHash,
|
||||
txValidation,
|
||||
@@ -621,7 +637,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
createManualPaymentIntent: paymentFlow.createManualPaymentIntent,
|
||||
submitManualPaymentTx: paymentFlow.submitManualPaymentTx,
|
||||
validateTxHash: paymentFlow.validateTxHash,
|
||||
handleOverlayCheckout: paymentFlow.handleOverlayCheckout,
|
||||
createTelegramBotBindCommand: billing.createTelegramBotBindCommand,
|
||||
openTelegramBotBindLink: billing.openTelegramBotBindLink,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ import { isTelegramPrivateGroupPriceEligible } from "./telegram-pricing";
|
||||
import { trackAppEvent } from "@/lib/app-analytics";
|
||||
|
||||
// ============================================================
|
||||
type TelegramBotBindPayload = {
|
||||
bot_command?: string;
|
||||
bot_url?: string;
|
||||
start_param?: string;
|
||||
};
|
||||
|
||||
export interface UseBillingParams {
|
||||
isEn: boolean;
|
||||
copy: Record<string, string>;
|
||||
@@ -96,6 +102,7 @@ export function useBilling(params: UseBillingParams) {
|
||||
// ── Billing-specific state ────────────────────────────────
|
||||
const [reconcileBusy, setReconcileBusy] = useState(false);
|
||||
const [telegramBindUrl, setTelegramBindUrl] = useState("");
|
||||
const [telegramBindCommand, setTelegramBindCommand] = useState("");
|
||||
|
||||
// ── Derived values ──────────────────────────────────────
|
||||
const paymentReadyForRecovery = Boolean(paymentConfig?.enabled && paymentConfig?.configured);
|
||||
@@ -273,33 +280,70 @@ export function useBilling(params: UseBillingParams) {
|
||||
[buildAuthedHeaders, copy, loadPaymentSnapshot, refreshEntitlementAfterPayment, reconcileLatestPayment],
|
||||
);
|
||||
|
||||
// ── openTelegramBotBindLink ──────────────────────────────
|
||||
// ── Telegram bot bind helpers ─────────────────────────────
|
||||
const requestTelegramBotBindPayload = useCallback(async () => {
|
||||
const authHeaders = await buildAuthedHeaders(true, false);
|
||||
const res = await fetch("/api/auth/telegram/bot-bind-link", {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = (await res.text()).slice(0, 300);
|
||||
throw new Error(raw || copy.telegramBindFailed);
|
||||
}
|
||||
const data = (await res.json()) as TelegramBotBindPayload;
|
||||
const botUrl = String(data.bot_url || "").trim();
|
||||
const startParam = String(data.start_param || "").trim();
|
||||
const botCommand = String(
|
||||
data.bot_command || (startParam ? `/start ${startParam}` : ""),
|
||||
).trim();
|
||||
if (!botUrl && !botCommand) throw new Error(copy.telegramBindLinkMissing);
|
||||
setTelegramBindUrl(botUrl);
|
||||
setTelegramBindCommand(botCommand);
|
||||
return { botCommand, botUrl };
|
||||
}, [
|
||||
buildAuthedHeaders,
|
||||
copy.telegramBindFailed,
|
||||
copy.telegramBindLinkMissing,
|
||||
]);
|
||||
|
||||
const createTelegramBotBindCommand = async () => {
|
||||
setTelegramBindOpening(true);
|
||||
setPaymentError("");
|
||||
setTelegramBindUrl("");
|
||||
setTelegramBindCommand("");
|
||||
try {
|
||||
const { botCommand } = await requestTelegramBotBindPayload();
|
||||
if (!botCommand) throw new Error(copy.telegramBindLinkMissing);
|
||||
setPaymentInfo(copy.telegramBindCommandCopied);
|
||||
return botCommand;
|
||||
} catch (error) {
|
||||
setPaymentError(normalizePaymentError(error).message);
|
||||
return "";
|
||||
} finally {
|
||||
setTelegramBindOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openTelegramBotBindLink = async () => {
|
||||
setTelegramBindOpening(true);
|
||||
setPaymentError("");
|
||||
setTelegramBindUrl("");
|
||||
const popup = window.open("about:blank", "_blank", "noopener,noreferrer");
|
||||
setTelegramBindCommand("");
|
||||
try {
|
||||
const authHeaders = await buildAuthedHeaders(true, false);
|
||||
const res = await fetch("/api/auth/telegram/bot-bind-link", {
|
||||
method: "POST", headers: authHeaders,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = (await res.text()).slice(0, 300);
|
||||
throw new Error(raw || copy.telegramBindFailed);
|
||||
}
|
||||
const data = (await res.json()) as { bot_url?: string };
|
||||
const botUrl = String(data.bot_url || "").trim();
|
||||
const { botUrl, botCommand } = await requestTelegramBotBindPayload();
|
||||
if (!botUrl) throw new Error(copy.telegramBindLinkMissing);
|
||||
if (popup && !popup.closed) {
|
||||
popup.location.href = botUrl;
|
||||
// Open directly — skip about:blank to avoid browser popup blockers
|
||||
const win = window.open(botUrl, "_blank", "noopener,noreferrer");
|
||||
if (win) {
|
||||
setPaymentInfo(copy.telegramBindClickHint);
|
||||
} else {
|
||||
// Popup blocked — show fallback link
|
||||
setPaymentInfo(copy.telegramPopupBlocked);
|
||||
setTelegramBindUrl(botUrl);
|
||||
setTelegramBindCommand(botCommand);
|
||||
}
|
||||
} catch (error) {
|
||||
if (popup && !popup.closed) popup.close();
|
||||
setPaymentError(normalizePaymentError(error).message);
|
||||
} finally {
|
||||
setTelegramBindOpening(false);
|
||||
@@ -423,9 +467,11 @@ export function useBilling(params: UseBillingParams) {
|
||||
return {
|
||||
reconcileBusy,
|
||||
telegramBindUrl,
|
||||
telegramBindCommand,
|
||||
setTelegramBindUrl,
|
||||
reconcileLatestPayment,
|
||||
handleSubmit409,
|
||||
createTelegramBotBindCommand,
|
||||
openTelegramBotBindLink,
|
||||
paymentReadyForRecovery,
|
||||
hasRecentPaymentRecovery,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useCallback, useMemo } from "react";
|
||||
import type {
|
||||
AuthMeResponse,
|
||||
BoundWallet,
|
||||
ConnectBindOptions,
|
||||
CreatedIntent,
|
||||
EvmProvider,
|
||||
IntentStatusResponse,
|
||||
@@ -33,13 +32,17 @@ import {
|
||||
assertExpectedPaymentReceiver,
|
||||
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
|
||||
} from "@/lib/payment-receiver";
|
||||
import { getTurnstileTokenForAction } from "@/lib/turnstile-client";
|
||||
import type { PaymentTxValidationState } from "./usePaymentState";
|
||||
|
||||
// ============================================================
|
||||
type PaymentTurnstileAction = "payment_intent_create" | "payment_tx_submit";
|
||||
|
||||
export interface UsePaymentFlowParams {
|
||||
isEn: boolean;
|
||||
copy: Record<string, string>;
|
||||
backend: AuthMeResponse | null;
|
||||
authUserId: string;
|
||||
|
||||
// Shared payment config state
|
||||
paymentConfig: PaymentConfig | null;
|
||||
@@ -68,7 +71,6 @@ export interface UsePaymentFlowParams {
|
||||
setLastIntentId: (v: string) => void;
|
||||
setLastTxHash: (v: string) => void;
|
||||
setLastPaymentStartedAt: (v: number) => void;
|
||||
setShowOverlay: (v: boolean) => void;
|
||||
setManualPayment: (v: CreatedIntent["direct_payment"] | null) => void;
|
||||
setManualTxHash: (v: string) => void;
|
||||
setTxValidation: (v: PaymentTxValidationState) => void;
|
||||
@@ -98,6 +100,11 @@ export interface UsePaymentFlowParams {
|
||||
allowedPaymentHosts: string[];
|
||||
authIsAuthenticated: boolean;
|
||||
hasPayingWallet: boolean;
|
||||
getPaymentTurnstileToken?: (
|
||||
action: PaymentTurnstileAction,
|
||||
options?: { optional?: boolean },
|
||||
) => string | undefined;
|
||||
resetPaymentTurnstile?: () => void;
|
||||
|
||||
// Callbacks from master
|
||||
getValidAccessToken: () => Promise<string>;
|
||||
@@ -109,7 +116,7 @@ export interface UsePaymentFlowParams {
|
||||
ensureTargetChain: (eth: EvmProvider, targetChainId: number, chain?: PaymentChainOption) => Promise<void>;
|
||||
|
||||
// Ref-wrapped cross-hook callbacks
|
||||
connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode, options?: ConnectBindOptions) => Promise<boolean>) | null>;
|
||||
connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode) => Promise<boolean>) | null>;
|
||||
handleSubmit409Ref: React.MutableRefObject<((intentId: string, txHashNorm: string, raw: string) => Promise<void>) | null>;
|
||||
|
||||
// Wallet provider helpers
|
||||
@@ -122,6 +129,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
isEn,
|
||||
copy,
|
||||
backend,
|
||||
authUserId,
|
||||
paymentConfig,
|
||||
setPaymentConfig,
|
||||
selectedPlanCode,
|
||||
@@ -141,7 +149,6 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
setLastIntentId,
|
||||
setLastTxHash,
|
||||
setLastPaymentStartedAt,
|
||||
setShowOverlay,
|
||||
setManualPayment,
|
||||
setManualTxHash,
|
||||
setTxValidation,
|
||||
@@ -160,6 +167,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
allowedPaymentHosts,
|
||||
authIsAuthenticated,
|
||||
hasPayingWallet,
|
||||
getPaymentTurnstileToken,
|
||||
resetPaymentTurnstile,
|
||||
getValidAccessToken,
|
||||
buildAuthedHeaders,
|
||||
loadSnapshot,
|
||||
@@ -185,6 +194,16 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
trackAppEvent("payment_success", payload);
|
||||
}, []);
|
||||
|
||||
const buildPaymentTurnstilePayload = useCallback(
|
||||
(action: PaymentTurnstileAction, optional = false) => {
|
||||
const turnstile_token =
|
||||
getPaymentTurnstileToken?.(action, { optional }) ||
|
||||
(optional ? undefined : getTurnstileTokenForAction("", action));
|
||||
return turnstile_token ? { turnstile_token } : {};
|
||||
},
|
||||
[getPaymentTurnstileToken],
|
||||
);
|
||||
|
||||
const verifyPaymentAuthReady = useCallback(async () => {
|
||||
const accessToken = await getValidAccessToken();
|
||||
const authHeaders: Record<string, string> = {
|
||||
@@ -478,11 +497,13 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
) ||
|
||||
latestTokens.find((token) => Number(token.chain_id || targetChainId) === targetChainId);
|
||||
if (selectedLatestToken?.supports_contract_checkout === false) {
|
||||
throw new Error(
|
||||
setPaymentInfo(
|
||||
isEn
|
||||
? `${selectedLatestToken.chain_name || chainIdToDisplayName(targetChainId)} ${selectedLatestToken.symbol || "USDC"} supports manual transfer only.`
|
||||
: `${selectedLatestToken.chain_name || chainIdToDisplayName(targetChainId)} ${selectedLatestToken.symbol || "USDC"} 仅支持手动转账。`,
|
||||
: `${selectedLatestToken.chain_name || chainIdToDisplayName(targetChainId)} ${selectedLatestToken.symbol || "USDC"} 当前仅支持手动转账。`,
|
||||
);
|
||||
await createManualPaymentIntent();
|
||||
return;
|
||||
}
|
||||
const expectedReceiver = String(selectedLatestToken?.receiver_contract || latestConfig.receiver_contract || "").toLowerCase();
|
||||
assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract");
|
||||
@@ -498,6 +519,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
...buildPaymentTurnstilePayload("payment_intent_create"),
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
payment_mode: "strict",
|
||||
allowed_wallet: payingWallet,
|
||||
@@ -513,6 +535,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
},
|
||||
}),
|
||||
});
|
||||
resetPaymentTurnstile?.();
|
||||
if (!createRes.ok) {
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
createRes,
|
||||
@@ -528,6 +551,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error(copy.intentPayloadInvalid);
|
||||
trackPaymentStart({
|
||||
entry: "account_center",
|
||||
user_id: authUserId || null,
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
intent_id: intentId,
|
||||
use_points: billing.canRedeem && usePoints,
|
||||
@@ -594,7 +618,13 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
await waitForReceipt(txHashNorm, eth);
|
||||
|
||||
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({
|
||||
...buildPaymentTurnstilePayload("payment_tx_submit", true),
|
||||
tx_hash: txHashNorm,
|
||||
from_address: payingWallet,
|
||||
}),
|
||||
});
|
||||
if (!submitRes.ok) {
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
@@ -634,6 +664,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm)));
|
||||
trackPaymentSuccess({
|
||||
entry: "account_center",
|
||||
user_id: authUserId || null,
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
intent_id: intentId,
|
||||
tx_hash: txHashNorm,
|
||||
@@ -689,6 +720,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
...buildPaymentTurnstilePayload("payment_intent_create"),
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
payment_mode: "direct",
|
||||
chain_id: effectivePaymentChainId,
|
||||
@@ -702,6 +734,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
},
|
||||
}),
|
||||
});
|
||||
resetPaymentTurnstile?.();
|
||||
if (!createRes.ok) {
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
createRes,
|
||||
@@ -721,7 +754,6 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
setLastIntentId(intentId);
|
||||
setManualPayment(direct);
|
||||
setPaymentMethodTab("manual");
|
||||
setShowOverlay(false);
|
||||
const chainName = direct.chain_name || chainIdToDisplayName(direct.chain_id);
|
||||
setPaymentInfo(
|
||||
copy.manualOrderCreated
|
||||
@@ -732,6 +764,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
);
|
||||
trackPaymentStart({
|
||||
entry: "account_center_manual_transfer",
|
||||
user_id: authUserId || null,
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
intent_id: intentId,
|
||||
payment_mode: "direct",
|
||||
@@ -762,7 +795,12 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
try {
|
||||
const authHeaders = await buildAuthedHeaders(true, true);
|
||||
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({
|
||||
...buildPaymentTurnstilePayload("payment_tx_submit", true),
|
||||
tx_hash: txHashNorm,
|
||||
}),
|
||||
});
|
||||
if (!submitRes.ok) {
|
||||
const raw = await readPaymentApiErrorMessage(
|
||||
@@ -788,6 +826,9 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
const lowerRaw = raw.toLowerCase();
|
||||
const maybePending =
|
||||
confirmRes.status === 408 ||
|
||||
(confirmRes.status === 503 &&
|
||||
(lowerRaw.includes("cannot connect payment rpc") ||
|
||||
lowerRaw.includes("payment rpc chain mismatch"))) ||
|
||||
(confirmRes.status === 409 && (lowerRaw.includes("confirmations not enough") || lowerRaw.includes("tx indexed partially")));
|
||||
if (maybePending) {
|
||||
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`);
|
||||
@@ -803,6 +844,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
setTxValidation({ loading: false, checked: false });
|
||||
trackPaymentSuccess({
|
||||
entry: "account_center_manual_transfer",
|
||||
user_id: authUserId || null,
|
||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||
intent_id: intentIdVal,
|
||||
tx_hash: txHashNorm,
|
||||
@@ -825,45 +867,35 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
return;
|
||||
}
|
||||
setTxValidation({ loading: true, checked: false });
|
||||
const controller = new AbortController();
|
||||
const timeoutId = globalThis.setTimeout(() => controller.abort(), 12000);
|
||||
try {
|
||||
const headers = await buildAuthedHeaders(true, true);
|
||||
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 }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const json = (await res.json()) as {
|
||||
valid?: boolean; reason?: string; detail?: string; checks?: Record<string, unknown>;
|
||||
};
|
||||
setTxValidation({ loading: false, checked: true, valid: Boolean(json.valid), reason: json.reason, detail: json.detail, checks: json.checks });
|
||||
} catch {
|
||||
setTxValidation({ loading: false, checked: false });
|
||||
setTxValidation({
|
||||
loading: false,
|
||||
checked: true,
|
||||
valid: false,
|
||||
reason: "validation_unavailable",
|
||||
detail: copy.verifyUnavailable,
|
||||
});
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
}
|
||||
},
|
||||
[buildAuthedHeaders],
|
||||
[buildAuthedHeaders, copy.verifyUnavailable],
|
||||
);
|
||||
|
||||
// ── handleOverlayCheckout ──────────────────────────────
|
||||
const handleOverlayCheckout = async () => {
|
||||
if (!paymentHostAllowed) {
|
||||
setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather.top"));
|
||||
return;
|
||||
}
|
||||
if (!authIsAuthenticated) {
|
||||
setPaymentError(copy.loginBeforePay);
|
||||
return;
|
||||
}
|
||||
if (!hasPayingWallet) {
|
||||
setPaymentInfo(copy.openBindFlow);
|
||||
if (connectAndBindWalletRef.current) {
|
||||
const bound = await connectAndBindWalletRef.current(providerMode, { openOverlayAfterBind: true });
|
||||
if (!bound) return;
|
||||
}
|
||||
setPaymentInfo(copy.walletBoundCreatingOrder);
|
||||
await createIntentAndPay();
|
||||
return;
|
||||
}
|
||||
await createIntentAndPay();
|
||||
};
|
||||
|
||||
// ==========================================================
|
||||
return {
|
||||
fetchLatestPaymentConfig,
|
||||
@@ -872,7 +904,6 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
createManualPaymentIntent,
|
||||
submitManualPaymentTx,
|
||||
validateTxHash,
|
||||
handleOverlayCheckout,
|
||||
availableTokenList,
|
||||
availableChainList,
|
||||
selectedPaymentChain,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import type {
|
||||
BoundWallet,
|
||||
ConnectBindOptions,
|
||||
Eip6963ProviderDetail,
|
||||
EvmProvider,
|
||||
InjectedProviderOption,
|
||||
@@ -56,7 +55,6 @@ export interface UseWalletBindParams {
|
||||
setPaymentBusy: (v: boolean) => void;
|
||||
setPaymentInfo: (v: string) => void;
|
||||
setPaymentError: (v: string) => void;
|
||||
setShowOverlay: (v: boolean) => void;
|
||||
clearPaymentMessages: () => void;
|
||||
|
||||
// Derived values from master
|
||||
@@ -91,7 +89,6 @@ export function useWalletBind(params: UseWalletBindParams) {
|
||||
setPaymentBusy,
|
||||
setPaymentInfo,
|
||||
setPaymentError,
|
||||
setShowOverlay,
|
||||
clearPaymentMessages,
|
||||
authIsAuthenticated,
|
||||
getValidAccessToken,
|
||||
@@ -258,7 +255,7 @@ export function useWalletBind(params: UseWalletBindParams) {
|
||||
};
|
||||
|
||||
// ── connectAndBindWallet ────────────────────────────────
|
||||
const connectAndBindWallet = async (mode: ProviderMode = "auto", options: ConnectBindOptions = {}): Promise<boolean> => {
|
||||
const connectAndBindWallet = async (mode: ProviderMode = "auto"): Promise<boolean> => {
|
||||
clearPaymentMessages();
|
||||
if (!authIsAuthenticated) {
|
||||
setPaymentError(copy.loginBeforeBind);
|
||||
@@ -292,9 +289,8 @@ export function useWalletBind(params: UseWalletBindParams) {
|
||||
if (existingWallet) {
|
||||
setWalletAddress(address);
|
||||
setSelectedWallet(address);
|
||||
setPaymentInfo(`${walletLabel} 已绑定: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`);
|
||||
setPaymentInfo(`${walletLabel} 已绑定: ${shortAddress(address)}。${binanceBindHint || "可在支付管理继续支付。"}`);
|
||||
await Promise.all([loadSnapshot(), loadPaymentSnapshot()]);
|
||||
if (options.openOverlayAfterBind) setShowOverlay(true);
|
||||
setPaymentBusy(false);
|
||||
return true;
|
||||
}
|
||||
@@ -304,7 +300,12 @@ export function useWalletBind(params: UseWalletBindParams) {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ address }),
|
||||
});
|
||||
if (!challengeRes.ok) {
|
||||
const message = await readPaymentApiErrorMessage(challengeRes);
|
||||
const message = await readPaymentApiErrorMessage(
|
||||
challengeRes,
|
||||
isEn
|
||||
? "Wallet challenge service is temporarily unavailable."
|
||||
: "钱包验证服务暂时不可用。",
|
||||
);
|
||||
throw new Error(copy.challengeFailed.replace("{raw}", message));
|
||||
}
|
||||
|
||||
@@ -318,13 +319,17 @@ export function useWalletBind(params: UseWalletBindParams) {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify({ address, nonce, signature }),
|
||||
});
|
||||
if (!verifyRes.ok) {
|
||||
const message = await readPaymentApiErrorMessage(verifyRes);
|
||||
const message = await readPaymentApiErrorMessage(
|
||||
verifyRes,
|
||||
isEn
|
||||
? "Wallet verify service is temporarily unavailable."
|
||||
: "钱包验证服务暂时不可用。",
|
||||
);
|
||||
throw new Error(copy.verifyFailedRaw.replace("{raw}", message));
|
||||
}
|
||||
|
||||
setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`);
|
||||
setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}。${binanceBindHint || "可在支付管理继续支付。"}`);
|
||||
setProviderMode(providerSelection.mode);
|
||||
if (options.openOverlayAfterBind) setShowOverlay(true);
|
||||
await Promise.all([loadSnapshot(), loadPaymentSnapshot()]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from "@/lib/supabase/client";
|
||||
import { getConfiguredSiteUrl, PRODUCTION_SITE_URL } from "@/lib/site-url";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { TurnstileWidget } from "@/components/security/TurnstileWidget";
|
||||
import { getTurnstileTokenForAction } from "@/lib/turnstile-client";
|
||||
|
||||
type Mode = "login" | "signup";
|
||||
|
||||
@@ -27,6 +29,40 @@ type LoginClientProps = {
|
||||
initialMode?: Mode;
|
||||
};
|
||||
|
||||
const TERMINAL_CHECKOUT_PATH = "/account?checkout=1";
|
||||
|
||||
function isTerminalNextPath(pathname: string) {
|
||||
return pathname === "/terminal" || pathname.startsWith("/terminal/");
|
||||
}
|
||||
|
||||
async function resolvePostLoginRedirect({
|
||||
accessToken,
|
||||
nextPath,
|
||||
}: {
|
||||
accessToken?: string | null;
|
||||
nextPath: string;
|
||||
}) {
|
||||
if (!isTerminalNextPath(nextPath)) return nextPath;
|
||||
const token = String(accessToken || "").trim();
|
||||
if (!token || typeof fetch !== "function") return TERMINAL_CHECKOUT_PATH;
|
||||
try {
|
||||
const response = await fetch("/api/auth/me?scope=entitlement", {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) return TERMINAL_CHECKOUT_PATH;
|
||||
const payload = await response.json();
|
||||
return payload?.subscription_active === true
|
||||
? nextPath
|
||||
: TERMINAL_CHECKOUT_PATH;
|
||||
} catch {
|
||||
return TERMINAL_CHECKOUT_PATH;
|
||||
}
|
||||
}
|
||||
|
||||
export function LoginClient({ nextPath, initialError, initialMode }: LoginClientProps) {
|
||||
const router = useRouter();
|
||||
const { locale } = useI18n();
|
||||
@@ -38,6 +74,8 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
|
||||
const [infoText, setInfoText] = useState("");
|
||||
const [resetSent, setResetSent] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
const [turnstileResetKey, setTurnstileResetKey] = useState(0);
|
||||
|
||||
const supabaseReady = hasSupabasePublicEnv();
|
||||
const isLogin = mode === "login";
|
||||
@@ -139,6 +177,11 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
|
||||
setErrorText(initialError || "");
|
||||
}, [initialError]);
|
||||
|
||||
const resetTurnstile = useCallback(() => {
|
||||
setTurnstileToken("");
|
||||
setTurnstileResetKey((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const onResetPassword = async () => {
|
||||
setErrorText("");
|
||||
setInfoText("");
|
||||
@@ -169,20 +212,6 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!supabaseReady) return;
|
||||
const run = async () => {
|
||||
const supabase = getSupabaseBrowserClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (session?.user) {
|
||||
router.replace(nextPath);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
}, [nextPath, router, supabaseReady]);
|
||||
|
||||
const onGoogleSignIn = async () => {
|
||||
setErrorText("");
|
||||
setInfoText("");
|
||||
@@ -227,16 +256,26 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
|
||||
setLoading(true);
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient();
|
||||
const captchaToken = getTurnstileTokenForAction(
|
||||
turnstileToken,
|
||||
isLogin ? "login" : "signup",
|
||||
);
|
||||
if (mode === "login") {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email: email.trim(),
|
||||
password,
|
||||
options: captchaToken ? { captchaToken } : undefined,
|
||||
});
|
||||
if (error) {
|
||||
setErrorText(error.message);
|
||||
resetTurnstile();
|
||||
return;
|
||||
}
|
||||
router.replace(nextPath);
|
||||
const redirectPath = await resolvePostLoginRedirect({
|
||||
accessToken: data.session?.access_token,
|
||||
nextPath,
|
||||
});
|
||||
router.replace(redirectPath);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -248,17 +287,27 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo,
|
||||
...(captchaToken ? { captchaToken } : {}),
|
||||
},
|
||||
});
|
||||
if (error) {
|
||||
setErrorText(error.message);
|
||||
resetTurnstile();
|
||||
return;
|
||||
}
|
||||
if (data.session?.user) {
|
||||
router.replace(nextPath);
|
||||
const redirectPath = await resolvePostLoginRedirect({
|
||||
accessToken: data.session.access_token,
|
||||
nextPath,
|
||||
});
|
||||
router.replace(redirectPath);
|
||||
return;
|
||||
}
|
||||
setInfoText(copy.signupCheckEmail);
|
||||
resetTurnstile();
|
||||
} catch (error) {
|
||||
setErrorText(error instanceof Error ? error.message : String(error));
|
||||
resetTurnstile();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -379,6 +428,7 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
|
||||
setErrorText("");
|
||||
setInfoText("");
|
||||
setMode(isLogin ? "signup" : "login");
|
||||
resetTurnstile();
|
||||
}}
|
||||
className="whitespace-nowrap rounded-xl border border-slate-300 bg-white px-4 py-2 text-xs font-bold text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700 active:scale-[0.98]"
|
||||
>
|
||||
@@ -467,6 +517,12 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TurnstileWidget
|
||||
action={isLogin ? "login" : "signup"}
|
||||
onToken={setTurnstileToken}
|
||||
resetKey={turnstileResetKey}
|
||||
/>
|
||||
|
||||
{!isLogin ? (
|
||||
<p className="text-[11px] leading-relaxed text-slate-500">
|
||||
{copy.termsAgreement}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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 source = fs.readFileSync(
|
||||
path.join(process.cwd(), "components", "auth", "LoginClient.tsx"),
|
||||
"utf8",
|
||||
).replace(/\r\n/g, "\n");
|
||||
|
||||
const start = source.indexOf("const onResetPassword");
|
||||
const end = source.indexOf("const onGoogleSignIn");
|
||||
|
||||
assert(
|
||||
start >= 0 && end > start,
|
||||
"login page pre-submit auth initialization block must be detectable by this regression test",
|
||||
);
|
||||
|
||||
const loginMountEffect = source.slice(start, end);
|
||||
|
||||
assert(
|
||||
!loginMountEffect.includes("router.replace(nextPath)"),
|
||||
"login page must not auto-return to nextPath from a cached Supabase session before the user submits auth",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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 loginClientSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "auth", "LoginClient.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const authCallbackSource = fs.readFileSync(
|
||||
path.join(projectRoot, "app", "auth", "callback", "route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
loginClientSource.includes("TERMINAL_CHECKOUT_PATH") &&
|
||||
loginClientSource.includes('"/account?checkout=1"') &&
|
||||
loginClientSource.includes("resolvePostLoginRedirect") &&
|
||||
loginClientSource.includes("subscription_active === true") &&
|
||||
loginClientSource.includes("return TERMINAL_CHECKOUT_PATH") &&
|
||||
loginClientSource.includes("router.replace(redirectPath)") &&
|
||||
!loginClientSource.includes("if (!response.ok) return nextPath;") &&
|
||||
!loginClientSource.includes("catch {\n return nextPath;") &&
|
||||
!loginClientSource.includes("router.replace(nextPath);\n return;"),
|
||||
"email/password terminal login must only enter /terminal after confirmed active subscription; unknown or inactive users go to checkout",
|
||||
);
|
||||
|
||||
assert(
|
||||
authCallbackSource.includes("resolvePostAuthRedirect") &&
|
||||
authCallbackSource.includes("TERMINAL_CHECKOUT_PATH") &&
|
||||
authCallbackSource.includes('"/account?checkout=1"') &&
|
||||
authCallbackSource.includes("subscription_active === true") &&
|
||||
authCallbackSource.includes("NextResponse.redirect(redirectUrl)") &&
|
||||
authCallbackSource.includes("await resolvePostAuthRedirect"),
|
||||
"OAuth terminal callback must only enter /terminal after confirmed active subscription; unknown or inactive users go to checkout",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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 turnstileLibPath = path.join(projectRoot, "lib", "turnstile.ts");
|
||||
const widgetPath = path.join(projectRoot, "components", "security", "TurnstileWidget.tsx");
|
||||
const loginPath = path.join(projectRoot, "components", "auth", "LoginClient.tsx");
|
||||
const feedbackModalPath = path.join(
|
||||
projectRoot,
|
||||
"components",
|
||||
"dashboard",
|
||||
"scan-terminal",
|
||||
"UserFeedbackModal.tsx",
|
||||
);
|
||||
const feedbackRoutePath = path.join(projectRoot, "app", "api", "feedback", "route.ts");
|
||||
const paymentIntentRoutePath = path.join(projectRoot, "app", "api", "payments", "intents", "route.ts");
|
||||
const paymentSubmitRoutePath = path.join(
|
||||
projectRoot,
|
||||
"app",
|
||||
"api",
|
||||
"payments",
|
||||
"intents",
|
||||
"[intentId]",
|
||||
"submit",
|
||||
"route.ts",
|
||||
);
|
||||
const paymentFlowPath = path.join(projectRoot, "components", "account", "usePaymentFlow.ts");
|
||||
|
||||
assert(fs.existsSync(turnstileLibPath), "shared Turnstile verifier module must exist");
|
||||
assert(fs.existsSync(widgetPath), "Turnstile widget component must exist");
|
||||
|
||||
const turnstileLib = fs.readFileSync(turnstileLibPath, "utf8");
|
||||
const widgetSource = fs.readFileSync(widgetPath, "utf8");
|
||||
const loginSource = fs.readFileSync(loginPath, "utf8");
|
||||
const feedbackModalSource = fs.readFileSync(feedbackModalPath, "utf8");
|
||||
const feedbackRouteSource = fs.readFileSync(feedbackRoutePath, "utf8");
|
||||
const paymentIntentRouteSource = fs.readFileSync(paymentIntentRoutePath, "utf8");
|
||||
const paymentSubmitRouteSource = fs.readFileSync(paymentSubmitRoutePath, "utf8");
|
||||
const paymentFlowSource = fs.readFileSync(paymentFlowPath, "utf8");
|
||||
|
||||
assert(
|
||||
turnstileLib.includes("POLYWEATHER_TURNSTILE_SECRET_KEY") &&
|
||||
turnstileLib.includes("NEXT_PUBLIC_TURNSTILE_SITE_KEY") &&
|
||||
turnstileLib.includes("https://challenges.cloudflare.com/turnstile/v0/siteverify") &&
|
||||
turnstileLib.includes("requireTurnstileForRequest") &&
|
||||
turnstileLib.includes("POLYWEATHER_TURNSTILE_BYPASS"),
|
||||
"Turnstile verifier must be optional, use Cloudflare siteverify, and expose a request guard",
|
||||
);
|
||||
assert(
|
||||
widgetSource.includes("NEXT_PUBLIC_TURNSTILE_SITE_KEY") &&
|
||||
widgetSource.includes("challenges.cloudflare.com/turnstile/v0/api.js") &&
|
||||
widgetSource.includes("window.turnstile.render") &&
|
||||
widgetSource.includes("data-turnstile-action"),
|
||||
"Turnstile widget must render Cloudflare Turnstile only when a site key is configured",
|
||||
);
|
||||
assert(
|
||||
loginSource.includes("TurnstileWidget") &&
|
||||
loginSource.includes("getTurnstileTokenForAction") &&
|
||||
loginSource.includes("captchaToken") &&
|
||||
loginSource.includes("signInWithPassword") &&
|
||||
loginSource.includes("signUp"),
|
||||
"login/signup must attach a Turnstile captchaToken to Supabase auth calls",
|
||||
);
|
||||
assert(
|
||||
feedbackModalSource.includes("TurnstileWidget") &&
|
||||
feedbackModalSource.includes("getTurnstileTokenForAction") &&
|
||||
feedbackModalSource.includes("turnstile_token"),
|
||||
"feedback modal must include a Turnstile token in feedback submissions",
|
||||
);
|
||||
assert(
|
||||
feedbackRouteSource.includes('requireTurnstileForRequest(req, "feedback_submit"') &&
|
||||
feedbackRouteSource.indexOf('requireTurnstileForRequest(req, "feedback_submit"') <
|
||||
feedbackRouteSource.indexOf("fetch(`${API_BASE}/api/feedback`"),
|
||||
"feedback POST proxy must verify Turnstile before forwarding user feedback",
|
||||
);
|
||||
assert(
|
||||
paymentIntentRouteSource.includes('requireTurnstileForRequest(req, "payment_intent_create"') &&
|
||||
paymentSubmitRouteSource.includes('requireTurnstileForRequest(req, "payment_tx_submit"'),
|
||||
"payment mutation proxies must verify Turnstile before forwarding create and submit requests",
|
||||
);
|
||||
assert(
|
||||
paymentFlowSource.includes("getTurnstileTokenForAction") &&
|
||||
paymentFlowSource.includes("turnstile_token") &&
|
||||
paymentFlowSource.includes("payment_intent_create") &&
|
||||
paymentFlowSource.includes("payment_tx_submit"),
|
||||
"payment flow must include Turnstile tokens in intent creation and tx submit payloads",
|
||||
);
|
||||
}
|
||||
@@ -518,7 +518,7 @@
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
SCAN TERMINAL OVERRIDES — EMOS DESK
|
||||
SCAN TERMINAL OVERRIDES
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.root :global(.scan-terminal) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Crown,
|
||||
GraduationCap,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
@@ -79,6 +80,7 @@ import {
|
||||
} from "@/components/dashboard/scan-terminal/city-fallback-rows";
|
||||
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
|
||||
import { STATIC_CITY_LIST } from "@/lib/static-cities";
|
||||
import { recordTrialValueReplay } from "@/lib/trial-value-replay";
|
||||
|
||||
const TrainingDashboard = dynamic(
|
||||
() =>
|
||||
@@ -269,29 +271,38 @@ const TERM = {
|
||||
closed: { en: "Closed", zh: "已关闭" },
|
||||
} as const;
|
||||
|
||||
const MAX_TERMINAL_GRID_SIDE = 3;
|
||||
const MAX_TERMINAL_CHARTS = 9;
|
||||
const MAX_TERMINAL_GRID_COLS = 3;
|
||||
const MAX_TERMINAL_GRID_ROWS = 2;
|
||||
const MAX_TERMINAL_CHARTS = 6;
|
||||
const MOBILE_TERMINAL_CHARTS = 1;
|
||||
const DEFAULT_TERMINAL_GRID_SIDE = 2;
|
||||
const TERMINAL_SLOTS_STORAGE_KEY = "polyweather_terminal_slots";
|
||||
|
||||
function clampGridSide(value: number) {
|
||||
function clampGridDimension(value: number, max: number) {
|
||||
if (!Number.isFinite(value)) return DEFAULT_TERMINAL_GRID_SIDE;
|
||||
return Math.max(1, Math.min(MAX_TERMINAL_GRID_SIDE, Math.floor(value)));
|
||||
return Math.max(1, Math.min(max, Math.floor(value)));
|
||||
}
|
||||
|
||||
function getStoredGridSide(key: string) {
|
||||
function clampGridCols(value: number) {
|
||||
return clampGridDimension(value, MAX_TERMINAL_GRID_COLS);
|
||||
}
|
||||
|
||||
function clampGridRows(value: number) {
|
||||
return clampGridDimension(value, MAX_TERMINAL_GRID_ROWS);
|
||||
}
|
||||
|
||||
function getStoredGridSide(key: string, max: number) {
|
||||
if (typeof window === "undefined") return DEFAULT_TERMINAL_GRID_SIDE;
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return DEFAULT_TERMINAL_GRID_SIDE;
|
||||
return clampGridSide(parseInt(raw, 10));
|
||||
return clampGridDimension(parseInt(raw, 10), max);
|
||||
} catch {}
|
||||
return DEFAULT_TERMINAL_GRID_SIDE;
|
||||
}
|
||||
|
||||
function getSlotCount(cols: number, rows: number) {
|
||||
return Math.min(MAX_TERMINAL_CHARTS, clampGridSide(cols) * clampGridSide(rows));
|
||||
return Math.min(MAX_TERMINAL_CHARTS, clampGridCols(cols) * clampGridRows(rows));
|
||||
}
|
||||
|
||||
function normalizeSlotList(slots: Array<string | null>, totalSlots: number) {
|
||||
@@ -488,6 +499,64 @@ function EmptySlotCard({
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSlotCard({
|
||||
city,
|
||||
isActive,
|
||||
isEn,
|
||||
onSelectSlot,
|
||||
}: {
|
||||
city: string;
|
||||
isActive: boolean;
|
||||
isEn: boolean;
|
||||
onSelectSlot: () => void;
|
||||
}) {
|
||||
const cityLabel = city
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ") || city;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelectSlot}
|
||||
className={clsx(
|
||||
"relative flex h-full min-h-0 flex-col overflow-hidden rounded-[4px] border bg-white cursor-default",
|
||||
isActive ? "border-blue-500 ring-2 ring-blue-500/20 shadow-md z-10" : "border-[#d2d9e2]",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-9 shrink-0 items-center justify-between border-b border-slate-200 px-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-[11px] font-bold text-slate-600">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-blue-500" />
|
||||
<span className="truncate">{cityLabel}</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="text-slate-400">{isEn ? "Loading chart" : "加载图表"}</span>
|
||||
</div>
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-slate-200 border-t-blue-500" />
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden bg-white">
|
||||
<div className="absolute inset-x-3 bottom-7 top-5 rounded-sm border border-slate-100">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<span
|
||||
key={`h-${index}`}
|
||||
className="absolute left-0 right-0 border-t border-dashed border-sky-100"
|
||||
style={{ top: `${(index / 5) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<span
|
||||
key={`v-${index}`}
|
||||
className="absolute bottom-0 top-0 border-l border-dashed border-sky-100"
|
||||
style={{ left: `${(index / 6) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
<div className="absolute left-8 right-8 top-1/3 h-10 animate-pulse rounded bg-gradient-to-r from-slate-100 via-blue-100 to-slate-100" />
|
||||
<div className="absolute inset-y-0 -left-1/3 w-1/3 animate-[pulse_1.2s_ease-in-out_infinite] bg-gradient-to-r from-transparent via-white/80 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TerminalSidebar = memo(function TerminalSidebar({
|
||||
activeNavKey,
|
||||
isEn,
|
||||
@@ -606,12 +675,16 @@ function PolyWeatherTerminal({
|
||||
generatedText,
|
||||
isEn,
|
||||
locale,
|
||||
onTerminalActivated,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
rows,
|
||||
selectedRow,
|
||||
setSelectedRow,
|
||||
toggleLocale,
|
||||
isTrialTerminalAccess,
|
||||
trialSubscriptionExpiresAt,
|
||||
trialUserId,
|
||||
userLocalTime,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
@@ -622,16 +695,21 @@ function PolyWeatherTerminal({
|
||||
setSelectedRegionKey,
|
||||
visibleRegions,
|
||||
toggleRegion,
|
||||
terminalActivationRefreshKey,
|
||||
}: {
|
||||
generatedText: string;
|
||||
isEn: boolean;
|
||||
locale: "zh-CN" | "en-US";
|
||||
onTerminalActivated: () => void;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
rows: ScanOpportunityRow[];
|
||||
selectedRow: ScanOpportunityRow | null;
|
||||
setSelectedRow: (row: ScanOpportunityRow) => void;
|
||||
toggleLocale: () => void;
|
||||
isTrialTerminalAccess: boolean;
|
||||
trialSubscriptionExpiresAt: string | null;
|
||||
trialUserId: string | null;
|
||||
userLocalTime: string;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (val: string) => void;
|
||||
@@ -642,6 +720,7 @@ function PolyWeatherTerminal({
|
||||
setSelectedRegionKey: (key: string) => void;
|
||||
visibleRegions: Set<string>;
|
||||
toggleRegion: (key: string) => void;
|
||||
terminalActivationRefreshKey: number;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -665,9 +744,21 @@ function PolyWeatherTerminal({
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [searchInputRef, setSearchQuery]);
|
||||
const [activeNavKey, setActiveNavKey] = useState<string>("thresholds");
|
||||
const previousActiveNavKeyRef = useRef(activeNavKey);
|
||||
const [onlineCount, setOnlineCount] = useState<number | null>(null);
|
||||
const [feedbackDraft, setFeedbackDraft] = useState<FeedbackDraft | null>(null);
|
||||
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
|
||||
const trialExpiryMs = Date.parse(String(trialSubscriptionExpiresAt || ""));
|
||||
const trialHoursLeft = Number.isFinite(trialExpiryMs)
|
||||
? Math.max(0, Math.ceil((trialExpiryMs - Date.now()) / 3_600_000))
|
||||
: null;
|
||||
const trialUpgradeLabel = isEn
|
||||
? trialHoursLeft != null
|
||||
? `Trial ${trialHoursLeft}h left`
|
||||
: "Trial access"
|
||||
: trialHoursLeft != null
|
||||
? `试用剩 ${trialHoursLeft} 小时`
|
||||
: "试用中";
|
||||
const handleSelectNav = useCallback((key: string) => {
|
||||
setActiveNavKey(key);
|
||||
}, []);
|
||||
@@ -677,6 +768,13 @@ function PolyWeatherTerminal({
|
||||
void preloadTemperatureChartCanvas();
|
||||
}, [activeNavKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousActiveNavKey = previousActiveNavKeyRef.current;
|
||||
previousActiveNavKeyRef.current = activeNavKey;
|
||||
if (activeNavKey !== "thresholds" || previousActiveNavKey === "thresholds") return;
|
||||
onTerminalActivated();
|
||||
}, [activeNavKey, onTerminalActivated]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOnline = () => {
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||
@@ -698,11 +796,11 @@ function PolyWeatherTerminal({
|
||||
}, []);
|
||||
|
||||
const [gridCols, setGridCols] = useState<number>(() => {
|
||||
return getStoredGridSide("polyweather_terminal_grid_cols");
|
||||
return getStoredGridSide("polyweather_terminal_grid_cols", MAX_TERMINAL_GRID_COLS);
|
||||
});
|
||||
|
||||
const [gridRows, setGridRows] = useState<number>(() => {
|
||||
return getStoredGridSide("polyweather_terminal_grid_rows");
|
||||
return getStoredGridSide("polyweather_terminal_grid_rows", MAX_TERMINAL_GRID_ROWS);
|
||||
});
|
||||
|
||||
const totalSlots = getSlotCount(gridCols, gridRows);
|
||||
@@ -764,8 +862,8 @@ function PolyWeatherTerminal({
|
||||
}, []);
|
||||
|
||||
const handleSetGridSize = (cols: number, rows: number) => {
|
||||
const safeCols = clampGridSide(cols);
|
||||
const safeRows = clampGridSide(rows);
|
||||
const safeCols = clampGridCols(cols);
|
||||
const safeRows = clampGridRows(rows);
|
||||
const nextTotalSlots = getSlotCount(safeCols, safeRows);
|
||||
|
||||
setGridCols(safeCols);
|
||||
@@ -866,6 +964,23 @@ function PolyWeatherTerminal({
|
||||
const selectedSignal = selectedRow ? getSignalState(selectedRow) : "data" as const;
|
||||
const selectedLabel = selectedRow ? getSignalLabel(selectedSignal, isEn) : "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTrialTerminalAccess || !selectedRow) return;
|
||||
recordTrialValueReplay({
|
||||
userId: trialUserId,
|
||||
cityName: rowName(selectedRow),
|
||||
signalLabel: selectedLabel,
|
||||
rowsAvailable: filteredRegionRows.length || rows.length,
|
||||
});
|
||||
}, [
|
||||
filteredRegionRows.length,
|
||||
isTrialTerminalAccess,
|
||||
rows.length,
|
||||
selectedLabel,
|
||||
selectedRow,
|
||||
trialUserId,
|
||||
]);
|
||||
|
||||
const continentGroups = useMemo(
|
||||
() => buildContinentGroups(filteredRegionRows, isEn),
|
||||
[filteredRegionRows, isEn]
|
||||
@@ -957,6 +1072,17 @@ function PolyWeatherTerminal({
|
||||
<div className="hidden lg:block">
|
||||
<UpdateAnnouncementButton isEn={isEn} />
|
||||
</div>
|
||||
{isTrialTerminalAccess && (
|
||||
<Link
|
||||
href="/account?checkout=1"
|
||||
className="inline-flex h-7 items-center gap-2 rounded border border-amber-300 bg-amber-50 px-2.5 text-[11px] font-black text-amber-800 transition hover:bg-amber-100"
|
||||
title={isEn ? "Upgrade to Pro" : "升级 Pro"}
|
||||
>
|
||||
<Crown size={12} />
|
||||
<span className="hidden sm:inline">{trialUpgradeLabel}</span>
|
||||
<span>{isEn ? "Upgrade" : "升级"}</span>
|
||||
</Link>
|
||||
)}
|
||||
{onlineCount != null && (
|
||||
<div className="hidden items-center gap-1 text-[10px] font-medium text-slate-400 lg:flex">
|
||||
<Users size={12} />
|
||||
@@ -1022,6 +1148,7 @@ function PolyWeatherTerminal({
|
||||
row={mobileChartRow}
|
||||
allRows={filteredRegionRows}
|
||||
compact={false}
|
||||
activationRefreshKey={terminalActivationRefreshKey}
|
||||
disableClose={true}
|
||||
onReportIssue={openChartFeedback}
|
||||
/>
|
||||
@@ -1077,6 +1204,7 @@ function PolyWeatherTerminal({
|
||||
row={filteredRegionRows.find((r) => String(r.city || "").toLowerCase() === visibleSlots[maximizedSlotIndex]) || null}
|
||||
allRows={filteredRegionRows}
|
||||
compact={false}
|
||||
activationRefreshKey={terminalActivationRefreshKey}
|
||||
onSearchClick={() => setActiveSearchSlotIndex(maximizedSlotIndex)}
|
||||
onMaximize={() => setMaximizedSlotIndex(null)}
|
||||
onClose={() => {
|
||||
@@ -1116,6 +1244,19 @@ function PolyWeatherTerminal({
|
||||
(r) => String(r.city || "").toLowerCase() === cityInSlot
|
||||
) || null
|
||||
: null;
|
||||
const isSavedSlotLoading = Boolean(cityInSlot && !rowForSlot && refreshing);
|
||||
|
||||
if (isSavedSlotLoading && cityInSlot) {
|
||||
return (
|
||||
<LoadingSlotCard
|
||||
key={slotIndex}
|
||||
city={cityInSlot}
|
||||
isActive={isSlotActive}
|
||||
isEn={isEn}
|
||||
onSelectSlot={() => setActiveSlotIndex(slotIndex)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cityInSlot || !rowForSlot) {
|
||||
return (
|
||||
@@ -1161,6 +1302,7 @@ function PolyWeatherTerminal({
|
||||
row={rowForSlot}
|
||||
allRows={filteredRegionRows}
|
||||
compact={true}
|
||||
activationRefreshKey={terminalActivationRefreshKey}
|
||||
isActive={isSlotActive}
|
||||
slotIndex={slotIndex}
|
||||
onSearchClick={() => setActiveSearchSlotIndex(slotIndex)}
|
||||
@@ -1583,11 +1725,16 @@ function ScanTerminalScreen() {
|
||||
setUseLocalTimezoneDefault(false);
|
||||
setSelectedRegionKey(key);
|
||||
}, []);
|
||||
const [terminalActivationRefreshKey, setTerminalActivationRefreshKey] = useState(0);
|
||||
const handleTerminalActivated = useCallback(() => {
|
||||
setTerminalActivationRefreshKey((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const { refreshScanTerminalManually, scanLoading, terminalData } =
|
||||
useScanTerminalQuery({
|
||||
isPro,
|
||||
proAccessLoading: !hydrated || (proAccess.loading && !canUseLocalFullAccess),
|
||||
terminalActivationRefreshKey,
|
||||
timezoneOffsetSeconds: useLocalTimezoneDefault ? localTimezoneOffsetSeconds : null,
|
||||
tradingRegion: selectedRegionKey,
|
||||
});
|
||||
@@ -1630,6 +1777,10 @@ function ScanTerminalScreen() {
|
||||
},
|
||||
[cityFallbackRows, terminalData?.rows],
|
||||
);
|
||||
const isTrialTerminalAccess = Boolean(
|
||||
isPro &&
|
||||
String(proAccess.subscriptionPlanCode || "").toLowerCase().includes("signup_trial"),
|
||||
);
|
||||
|
||||
const fallbackFetchedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -1727,6 +1878,9 @@ function ScanTerminalScreen() {
|
||||
<ProductAccessRequired
|
||||
isAuthenticated={isAuthenticated}
|
||||
isEn={isEn}
|
||||
subscriptionExpiresAt={
|
||||
proAccess.subscriptionTotalExpiresAt || proAccess.subscriptionExpiresAt
|
||||
}
|
||||
userLocalTime={userLocalTime}
|
||||
/>
|
||||
);
|
||||
@@ -1737,12 +1891,18 @@ function ScanTerminalScreen() {
|
||||
generatedText={generatedText || ""}
|
||||
isEn={isEn}
|
||||
locale={locale}
|
||||
onTerminalActivated={handleTerminalActivated}
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={scanLoading}
|
||||
rows={filteredRows}
|
||||
selectedRow={selectedRow}
|
||||
setSelectedRow={handleSelectRow}
|
||||
toggleLocale={toggleLocale}
|
||||
isTrialTerminalAccess={isTrialTerminalAccess}
|
||||
trialSubscriptionExpiresAt={
|
||||
proAccess.subscriptionTotalExpiresAt || proAccess.subscriptionExpiresAt
|
||||
}
|
||||
trialUserId={proAccess.userId}
|
||||
userLocalTime={userLocalTime}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
@@ -1753,6 +1913,7 @@ function ScanTerminalScreen() {
|
||||
setSelectedRegionKey={selectRegionManually}
|
||||
visibleRegions={visibleRegions}
|
||||
toggleRegion={toggleRegion}
|
||||
terminalActivationRefreshKey={terminalActivationRefreshKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function GridLayoutSelector({
|
||||
setHoveredRows(0);
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3].map((r) =>
|
||||
{[1, 2].map((r) =>
|
||||
[1, 2, 3].map((c) => {
|
||||
const isHighlighted = c <= previewCols && r <= previewRows;
|
||||
return (
|
||||
|
||||
@@ -15,12 +15,12 @@ import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
|
||||
|
||||
import {
|
||||
HOURLY_CACHE_TTL_MS,
|
||||
_hourlyCache,
|
||||
buildChartDomain,
|
||||
buildFullDayChartData,
|
||||
buildIntDegreeTicks,
|
||||
buildRunwayPlates,
|
||||
fetchHourlyForecastForCity,
|
||||
fetchFullChartDetailForCity,
|
||||
fetchLiveObservationForCity,
|
||||
getActiveTemperatureSeries,
|
||||
getDebPeakWindowRange,
|
||||
getPeakGlowState,
|
||||
@@ -28,16 +28,24 @@ import {
|
||||
getObservationDisplayMetrics,
|
||||
getVisibleTemperatureSeries,
|
||||
isTemperatureSeriesVisibleByDefault,
|
||||
mergeHourlyWithLiveObservations,
|
||||
mergeObservationSnapshotIntoHourly,
|
||||
mergePatchIntoHourly,
|
||||
mergeRowObservationIntoHourly,
|
||||
normObs,
|
||||
prefersHighFrequencyRunwayResolution,
|
||||
readSessionCache,
|
||||
readCachedHourlyForInitialRow,
|
||||
readCityDetailBatchDiagnostics,
|
||||
readHourlyDetailSnapshot,
|
||||
readHourlyDetailSnapshotAgeMs,
|
||||
selectCompactSecondaryTemp,
|
||||
selectDisplayRunwayTemp,
|
||||
seedHourlyForecastFromRow,
|
||||
selectInitialHourlyForRowChange,
|
||||
seedChartRenderStateFromRow,
|
||||
shouldPollLiveChart,
|
||||
validNumber,
|
||||
type HourlyForecast,
|
||||
type ChartRenderState,
|
||||
type ObservationSnapshot,
|
||||
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
||||
export { clearCityDetailCache } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
||||
|
||||
@@ -57,10 +65,10 @@ const PEAK_GLOW_BADGE_CLASS = {
|
||||
cooling: "border-slate-200 bg-slate-100 text-slate-500",
|
||||
} as const;
|
||||
|
||||
const PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000;
|
||||
const FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS = 90_000;
|
||||
const NO_PATCH_FULL_DETAIL_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
|
||||
const LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback;
|
||||
const DETAIL_LOAD_BATCH_DELAY_MS = 0;
|
||||
const TRANSIENT_DETAIL_RETRY_DELAY_MS = 3_000;
|
||||
const INITIAL_DETAIL_LOAD_SLOTS = 3;
|
||||
const DEFERRED_DETAIL_LOAD_DELAY_MS = 1_200;
|
||||
const DEFERRED_DETAIL_LOAD_GROUP_SIZE = 3;
|
||||
@@ -96,18 +104,171 @@ function peakGlowTitle(
|
||||
|
||||
function formatCityLocalDate(tzOffsetSeconds: number | null | undefined) {
|
||||
const cityOffsetMs = (tzOffsetSeconds ?? 0) * 1000;
|
||||
const cityNow = new Date(Date.now() + cityOffsetMs + new Date().getTimezoneOffset() * 60_000);
|
||||
const y = cityNow.getFullYear();
|
||||
const m = String(cityNow.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(cityNow.getDate()).padStart(2, "0");
|
||||
const cityNow = new Date(Date.now() + cityOffsetMs);
|
||||
const y = cityNow.getUTCFullYear();
|
||||
const m = String(cityNow.getUTCMonth() + 1).padStart(2, "0");
|
||||
const d = String(cityNow.getUTCDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function getLiveTempFromHourly(data: HourlyForecast) {
|
||||
function formatCityLocalDateTime(tzOffsetSeconds: number | null | undefined) {
|
||||
const nowUtc = Date.now();
|
||||
const cityOffsetMs = (tzOffsetSeconds ?? 0) * 1000;
|
||||
const cityNow = new Date(nowUtc + cityOffsetMs);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const y = cityNow.getUTCFullYear();
|
||||
const mo = pad(cityNow.getUTCMonth() + 1);
|
||||
const d = pad(cityNow.getUTCDate());
|
||||
const hh = pad(cityNow.getUTCHours());
|
||||
const mm = pad(cityNow.getUTCMinutes());
|
||||
const ss = pad(cityNow.getUTCSeconds());
|
||||
return `${y}-${mo}-${d} ${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function getLiveTempFromHourly(data: ChartRenderState) {
|
||||
return validNumber(data?.airportCurrent?.temp) ?? validNumber(data?.airportPrimary?.temp) ?? null;
|
||||
}
|
||||
|
||||
function getWundergroundDailyHigh(hourly: HourlyForecast) {
|
||||
function rowObservationSignature(row: ScanOpportunityRow | null) {
|
||||
if (!row) return "";
|
||||
const metarContext = row.metar_context || null;
|
||||
const pieces = [
|
||||
row.city,
|
||||
row.current_temp,
|
||||
row.current_max_so_far,
|
||||
row.local_time,
|
||||
(row as any).sse_revision,
|
||||
metarContext?.airport_current_temp,
|
||||
metarContext?.airport_max_so_far,
|
||||
metarContext?.airport_obs_time,
|
||||
metarContext?.last_temp,
|
||||
metarContext?.last_time,
|
||||
metarContext?.last_observation_time,
|
||||
metarContext?.source,
|
||||
metarContext?.station_label,
|
||||
];
|
||||
const hasObservation =
|
||||
validNumber(row.current_temp) !== null ||
|
||||
validNumber(metarContext?.airport_current_temp) !== null ||
|
||||
validNumber(metarContext?.last_temp) !== null;
|
||||
return hasObservation ? pieces.map((piece) => String(piece ?? "")).join("|") : "";
|
||||
}
|
||||
|
||||
type ChartDetailStatus = "idle" | "loading" | "fresh" | "stale_cache" | "degraded";
|
||||
type ChartDetailSource =
|
||||
| "none"
|
||||
| "memory_cache"
|
||||
| "session_cache"
|
||||
| "network"
|
||||
| "force_refresh"
|
||||
| "partial_or_timeout";
|
||||
|
||||
type ChartFreshnessState = {
|
||||
rowAppliedAtMs: number | null;
|
||||
rowObservationTime: string | null;
|
||||
ssePatchAppliedAtMs: number | null;
|
||||
sseObservedAt: string | null;
|
||||
sseRevision: number | null;
|
||||
sseEmittedAtMs: number | null;
|
||||
sseServerToClientLatencySec: number | null;
|
||||
sseCollectorToClientLatencySec: number | null;
|
||||
sseSourceToCollectorLatencySec: number | null;
|
||||
detailRequestedAtMs: number | null;
|
||||
detailResolvedAtMs: number | null;
|
||||
detailErrorAtMs: number | null;
|
||||
detailStatus: ChartDetailStatus;
|
||||
detailSource: ChartDetailSource;
|
||||
};
|
||||
|
||||
function createChartFreshnessState(
|
||||
overrides: Partial<ChartFreshnessState> = {},
|
||||
): ChartFreshnessState {
|
||||
return {
|
||||
rowAppliedAtMs: null,
|
||||
rowObservationTime: null,
|
||||
ssePatchAppliedAtMs: null,
|
||||
sseObservedAt: null,
|
||||
sseRevision: null,
|
||||
sseEmittedAtMs: null,
|
||||
sseServerToClientLatencySec: null,
|
||||
sseCollectorToClientLatencySec: null,
|
||||
sseSourceToCollectorLatencySec: null,
|
||||
detailRequestedAtMs: null,
|
||||
detailResolvedAtMs: null,
|
||||
detailErrorAtMs: null,
|
||||
detailStatus: "idle",
|
||||
detailSource: "none",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function ageSeconds(fromMs: number | null | undefined, nowMs: number) {
|
||||
if (!Number.isFinite(fromMs)) return null;
|
||||
return Math.max(0, Math.round((nowMs - Number(fromMs)) / 1000));
|
||||
}
|
||||
|
||||
function buildChartFreshnessContext(state: ChartFreshnessState, nowMs = Date.now()) {
|
||||
const rowAgeSec = ageSeconds(state.rowAppliedAtMs, nowMs);
|
||||
const sseAgeSec = ageSeconds(state.ssePatchAppliedAtMs, nowMs);
|
||||
let livePath: "none" | "row" | "sse" = "none";
|
||||
let liveAgeSec: number | null = null;
|
||||
if (rowAgeSec !== null) {
|
||||
livePath = "row";
|
||||
liveAgeSec = rowAgeSec;
|
||||
}
|
||||
if (sseAgeSec !== null && (liveAgeSec === null || sseAgeSec <= liveAgeSec)) {
|
||||
livePath = "sse";
|
||||
liveAgeSec = sseAgeSec;
|
||||
}
|
||||
|
||||
return {
|
||||
live_path: livePath,
|
||||
live_age_sec: liveAgeSec,
|
||||
row_applied_age_sec: rowAgeSec,
|
||||
row_observation_time: state.rowObservationTime,
|
||||
sse_patch_age_sec: sseAgeSec,
|
||||
sse_observed_at: state.sseObservedAt,
|
||||
sse_revision: state.sseRevision,
|
||||
sse_emitted_at_ms: state.sseEmittedAtMs,
|
||||
sse_server_to_client_latency_sec: state.sseServerToClientLatencySec,
|
||||
sse_collector_to_client_latency_sec: state.sseCollectorToClientLatencySec,
|
||||
sse_source_to_collector_latency_sec: state.sseSourceToCollectorLatencySec,
|
||||
detail_status: state.detailStatus,
|
||||
detail_source: state.detailSource,
|
||||
detail_request_age_sec: ageSeconds(state.detailRequestedAtMs, nowMs),
|
||||
detail_age_sec: ageSeconds(state.detailResolvedAtMs, nowMs),
|
||||
detail_error_age_sec: ageSeconds(state.detailErrorAtMs, nowMs),
|
||||
};
|
||||
}
|
||||
|
||||
function rowObservationTimeForFreshness(row: ScanOpportunityRow | null) {
|
||||
if (!row) return null;
|
||||
const metarContext = row.metar_context || null;
|
||||
return String(
|
||||
metarContext?.airport_obs_time ||
|
||||
metarContext?.last_observation_time ||
|
||||
metarContext?.last_time ||
|
||||
row.local_time ||
|
||||
"",
|
||||
).trim() || null;
|
||||
}
|
||||
|
||||
function observationSnapshotTimeForFreshness(snapshot: ObservationSnapshot) {
|
||||
const block = snapshot.airport_current || snapshot.airport_primary || snapshot.current || {};
|
||||
return String(block.obs_time || block.observed_at || block.observation_time || snapshot.local_time || "").trim() || null;
|
||||
}
|
||||
|
||||
function patchObservationTimeForFreshness(patch: { changes?: Record<string, unknown> } | null | undefined) {
|
||||
const changes = patch?.changes || {};
|
||||
return String(
|
||||
changes.observed_at_utc ||
|
||||
changes.observed_at_local ||
|
||||
changes.obs_time ||
|
||||
"",
|
||||
).trim() || null;
|
||||
}
|
||||
|
||||
function getWundergroundDailyHigh(hourly: ChartRenderState) {
|
||||
return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null;
|
||||
}
|
||||
|
||||
@@ -158,7 +319,7 @@ function sourceStatusLabel(status: string | null | undefined, isEn: boolean) {
|
||||
|
||||
function buildSourceCadenceSummary(
|
||||
row: ScanOpportunityRow | null,
|
||||
hourly: HourlyForecast,
|
||||
hourly: ChartRenderState,
|
||||
isEn: boolean,
|
||||
): SourceCadenceSummary | null {
|
||||
const primary = hourly?.airportPrimary || hourly?.airportCurrent || null;
|
||||
@@ -194,7 +355,7 @@ function buildSourceCadenceSummary(
|
||||
|
||||
function buildAdvancedWeatherVariableItems(
|
||||
row: ScanOpportunityRow | null,
|
||||
hourly: HourlyForecast,
|
||||
hourly: ChartRenderState,
|
||||
isEn: boolean,
|
||||
): AdvancedWeatherVariableItem[] {
|
||||
const primary = hourly?.airportPrimary || hourly?.airportCurrent || null;
|
||||
@@ -390,6 +551,84 @@ function getInitialDetailLoadDelayMs({
|
||||
return DEFERRED_DETAIL_LOAD_DELAY_MS + deferredWave * DEFERRED_DETAIL_LOAD_WAVE_STEP_MS;
|
||||
}
|
||||
|
||||
type HourlyDetailFetchOptions = {
|
||||
ignoreCache?: boolean;
|
||||
bypassLocalCache?: boolean;
|
||||
};
|
||||
|
||||
type HourlyDetailFetchRequest = {
|
||||
source: ChartDetailSource;
|
||||
fetchOptions?: HourlyDetailFetchOptions;
|
||||
applyOptions?: { updateLiveTemp?: boolean };
|
||||
showUserError?: boolean;
|
||||
isCancelled?: () => boolean;
|
||||
onEmpty?: () => void;
|
||||
onError?: () => void;
|
||||
onSettled?: () => void;
|
||||
};
|
||||
|
||||
function useHourlyDetailFetcher({
|
||||
city,
|
||||
targetResolution,
|
||||
markDetailRequest,
|
||||
markDetailDegraded,
|
||||
applySuccessfulHourlyDetail,
|
||||
}: {
|
||||
city: string;
|
||||
targetResolution: string;
|
||||
markDetailRequest: (source: ChartDetailSource) => void;
|
||||
markDetailDegraded: (options?: { showUserError?: boolean }) => void;
|
||||
applySuccessfulHourlyDetail: (data: ChartRenderState, options?: { updateLiveTemp?: boolean }) => void;
|
||||
}) {
|
||||
return useCallback(
|
||||
async ({
|
||||
source,
|
||||
fetchOptions = {},
|
||||
applyOptions,
|
||||
showUserError = false,
|
||||
isCancelled,
|
||||
onEmpty,
|
||||
onError,
|
||||
onSettled,
|
||||
}: HourlyDetailFetchRequest) => {
|
||||
const cancelled = () => Boolean(isCancelled?.());
|
||||
if (!city || cancelled()) return null;
|
||||
|
||||
markDetailRequest(source);
|
||||
try {
|
||||
const data = await fetchFullChartDetailForCity(city, {
|
||||
...fetchOptions,
|
||||
resolution: targetResolution,
|
||||
});
|
||||
if (cancelled()) return null;
|
||||
if (!data) {
|
||||
if (onEmpty) {
|
||||
onEmpty();
|
||||
} else {
|
||||
markDetailDegraded({ showUserError });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
applySuccessfulHourlyDetail(data, applyOptions);
|
||||
return data;
|
||||
} catch {
|
||||
if (!cancelled()) {
|
||||
if (onError) {
|
||||
onError();
|
||||
} else {
|
||||
markDetailDegraded({ showUserError });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (!cancelled()) onSettled?.();
|
||||
}
|
||||
},
|
||||
[city, targetResolution, markDetailRequest, markDetailDegraded, applySuccessfulHourlyDetail],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────
|
||||
|
||||
export function LiveTemperatureThresholdChart({
|
||||
@@ -404,6 +643,7 @@ export function LiveTemperatureThresholdChart({
|
||||
isMaximized = false,
|
||||
disableClose = false,
|
||||
isActive = !compact,
|
||||
activationRefreshKey = 0,
|
||||
slotIndex = 0,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
@@ -417,9 +657,10 @@ export function LiveTemperatureThresholdChart({
|
||||
isMaximized?: boolean;
|
||||
disableClose?: boolean;
|
||||
isActive?: boolean;
|
||||
activationRefreshKey?: number;
|
||||
slotIndex?: number;
|
||||
}) {
|
||||
const [hourly, setHourly] = useState<HourlyForecast>(null);
|
||||
const [hourly, setHourly] = useState<ChartRenderState>(null);
|
||||
const city = String(row?.city || "").toLowerCase().trim();
|
||||
const latestPatch = useLatestPatch(city);
|
||||
const resyncVersion = useSseResyncVersion();
|
||||
@@ -431,12 +672,19 @@ export function LiveTemperatureThresholdChart({
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [detailRetryNonce, setDetailRetryNonce] = useState(0);
|
||||
const [showingStaleDetail, setShowingStaleDetail] = useState(false);
|
||||
const [chartFreshness, setChartFreshness] = useState<ChartFreshnessState>(() =>
|
||||
createChartFreshnessState(),
|
||||
);
|
||||
const latestRowRef = useRef<ScanOpportunityRow | null>(row);
|
||||
latestRowRef.current = row;
|
||||
const getLatestRowSnapshot = useCallback(() => latestRowRef.current, []);
|
||||
const hasLoadedHourlyDetailRef = useRef(false);
|
||||
const hourlyCityRef = useRef<string>("");
|
||||
const chartVisibilityRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastPatchAtRef = useRef<number>(Date.now());
|
||||
const lastAppliedPatchRevisionRef = useRef<number>(0);
|
||||
const lastProbabilityRefreshAtRef = useRef<number>(0);
|
||||
const lastForegroundRefreshAtRef = useRef<number>(0);
|
||||
const lastRowObservationSignatureRef = useRef<string>("");
|
||||
const localDayRolloverFetchDateRef = useRef<string>("");
|
||||
const [isChartVisible, setIsChartVisible] = useState(
|
||||
() => typeof IntersectionObserver === "undefined",
|
||||
@@ -457,24 +705,64 @@ export function LiveTemperatureThresholdChart({
|
||||
const [currentCityLocalDate, setCurrentCityLocalDate] = useState(() =>
|
||||
formatCityLocalDate(row?.tz_offset_seconds),
|
||||
);
|
||||
const currentRowObservationSignature = useMemo(() => rowObservationSignature(row), [row]);
|
||||
const markDetailRequest = useCallback((source: ChartDetailSource) => {
|
||||
setChartFreshness((prev) => ({
|
||||
...prev,
|
||||
detailRequestedAtMs: Date.now(),
|
||||
detailStatus: "loading",
|
||||
detailSource: source,
|
||||
}));
|
||||
}, []);
|
||||
const markDetailDegraded = useCallback((options?: { showUserError?: boolean }) => {
|
||||
const detailErrorMessage = options?.showUserError
|
||||
? (isEn ? "Detail temporarily unavailable." : "详情暂不可用")
|
||||
: null;
|
||||
setDetailError(detailErrorMessage);
|
||||
setChartFreshness((prev) => ({
|
||||
...prev,
|
||||
detailErrorAtMs: Date.now(),
|
||||
detailStatus: "degraded",
|
||||
detailSource: "partial_or_timeout",
|
||||
}));
|
||||
}, [isEn]);
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
const previousCity = hourlyCityRef.current;
|
||||
hourlyCityRef.current = city;
|
||||
const nextTargetResolution = prefersHighFrequencyRunwayResolution(row, null) ? "1m" : "10m";
|
||||
const cachedHourly = readCachedHourlyForInitialRow(city, nextTargetResolution);
|
||||
setUserToggledKeys({});
|
||||
setZoomRange(null);
|
||||
setViewMode("full");
|
||||
setShowRunwayDetails(true);
|
||||
setTargetResolution(prefersHighFrequencyRunwayResolution(row, null) ? "1m" : "10m");
|
||||
setHourly(seedHourlyForecastFromRow(row));
|
||||
setTargetResolution(nextTargetResolution);
|
||||
setHourly((prev) =>
|
||||
selectInitialHourlyForRowChange({
|
||||
cachedHourly,
|
||||
previousCity,
|
||||
previousHourly: prev,
|
||||
row,
|
||||
}),
|
||||
);
|
||||
setLiveTemp(null);
|
||||
setIsHourlyLoading(Boolean(city) && detailLoadDelayMs === 0);
|
||||
setDetailError(null);
|
||||
setDetailRetryNonce(0);
|
||||
setShowingStaleDetail(false);
|
||||
setChartFreshness(createChartFreshnessState({
|
||||
rowAppliedAtMs: currentRowObservationSignature ? now : null,
|
||||
rowObservationTime: rowObservationTimeForFreshness(row),
|
||||
detailRequestedAtMs: city && detailLoadDelayMs === 0 ? now : null,
|
||||
detailStatus: city && detailLoadDelayMs === 0 ? "loading" : "idle",
|
||||
detailSource: city && detailLoadDelayMs === 0 ? "network" : "none",
|
||||
}));
|
||||
hasLoadedHourlyDetailRef.current = false;
|
||||
lastPatchAtRef.current = Date.now();
|
||||
lastPatchAtRef.current = now;
|
||||
lastAppliedPatchRevisionRef.current = 0;
|
||||
lastProbabilityRefreshAtRef.current = 0;
|
||||
lastForegroundRefreshAtRef.current = 0;
|
||||
lastRowObservationSignatureRef.current = "";
|
||||
localDayRolloverFetchDateRef.current = "";
|
||||
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
|
||||
}, [city, detailLoadDelayMs]);
|
||||
@@ -519,21 +807,92 @@ export function LiveTemperatureThresholdChart({
|
||||
return () => clearInterval(id);
|
||||
}, [row?.tz_offset_seconds]);
|
||||
|
||||
const commitHourlySnapshot = useCallback((buildNext: (previous: ChartRenderState) => ChartRenderState) => {
|
||||
setHourly((prev) => buildNext(prev));
|
||||
}, []);
|
||||
|
||||
const applySuccessfulHourlyDetail = useCallback((data: ChartRenderState, options?: { updateLiveTemp?: boolean }) => {
|
||||
if (!data) return;
|
||||
const loadedAtMs = Date.now();
|
||||
const latestRow = getLatestRowSnapshot();
|
||||
const rowSeed = seedChartRenderStateFromRow(latestRow);
|
||||
const dataWithCurrentRow = mergeHourlyWithLiveObservations(data, rowSeed, latestRow);
|
||||
hasLoadedHourlyDetailRef.current = true;
|
||||
if (options?.updateLiveTemp) {
|
||||
const temp = getLiveTempFromHourly(dataWithCurrentRow);
|
||||
if (temp !== null) setLiveTemp(temp);
|
||||
}
|
||||
commitHourlySnapshot((prev) => {
|
||||
const mergedHourly = mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, latestRow);
|
||||
return mergedHourly;
|
||||
});
|
||||
setDetailError(null);
|
||||
setShowingStaleDetail(false);
|
||||
setChartFreshness((prev) => ({
|
||||
...prev,
|
||||
detailResolvedAtMs: loadedAtMs,
|
||||
detailErrorAtMs: null,
|
||||
detailStatus: "fresh",
|
||||
detailSource:
|
||||
prev.detailSource === "none" || prev.detailSource === "partial_or_timeout"
|
||||
? "network"
|
||||
: prev.detailSource,
|
||||
}));
|
||||
}, [commitHourlySnapshot, getLatestRowSnapshot]);
|
||||
|
||||
const runHourlyDetailFetch = useHourlyDetailFetcher({
|
||||
city,
|
||||
targetResolution,
|
||||
markDetailRequest,
|
||||
markDetailDegraded,
|
||||
applySuccessfulHourlyDetail,
|
||||
});
|
||||
|
||||
const applyLiveObservationSnapshot = useCallback((snapshot: ObservationSnapshot) => {
|
||||
if (!snapshot || typeof snapshot !== "object") return;
|
||||
const appliedAtMs = Date.now();
|
||||
const condition = snapshot.airport_current || snapshot.airport_primary || snapshot.current || {};
|
||||
const temp = validNumber(condition.temp);
|
||||
if (temp !== null) setLiveTemp(temp);
|
||||
if (typeof snapshot.local_date === "string" && snapshot.local_date) {
|
||||
setCurrentCityLocalDate(snapshot.local_date);
|
||||
}
|
||||
commitHourlySnapshot((prev) =>
|
||||
mergeObservationSnapshotIntoHourly(
|
||||
prev ?? seedChartRenderStateFromRow(getLatestRowSnapshot()),
|
||||
snapshot,
|
||||
),
|
||||
);
|
||||
setChartFreshness((prev) => ({
|
||||
...prev,
|
||||
rowAppliedAtMs: appliedAtMs,
|
||||
rowObservationTime: observationSnapshotTimeForFreshness(snapshot),
|
||||
}));
|
||||
}, [commitHourlySnapshot, getLatestRowSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!city || !currentRowObservationSignature) return;
|
||||
if (lastRowObservationSignatureRef.current === currentRowObservationSignature) return;
|
||||
const now = Date.now();
|
||||
lastRowObservationSignatureRef.current = currentRowObservationSignature;
|
||||
const rowSeed = seedChartRenderStateFromRow(row);
|
||||
const temp = getLiveTempFromHourly(rowSeed);
|
||||
if (temp !== null) setLiveTemp(temp);
|
||||
commitHourlySnapshot((prev) => mergeRowObservationIntoHourly(prev, row));
|
||||
setChartFreshness((prev) => ({
|
||||
...prev,
|
||||
rowAppliedAtMs: now,
|
||||
rowObservationTime: rowObservationTimeForFreshness(row),
|
||||
}));
|
||||
}, [city, currentRowObservationSignature, row, commitHourlySnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!city) {
|
||||
setIsHourlyLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `${city}:${targetResolution}`;
|
||||
let cached = _hourlyCache.get(cacheKey);
|
||||
if (!cached || Date.now() - Number(cached.ts || 0) >= HOURLY_CACHE_TTL_MS) {
|
||||
const sessionEntry = readSessionCache(cacheKey, { allowStale: true });
|
||||
if (sessionEntry) {
|
||||
cached = sessionEntry;
|
||||
_hourlyCache.set(cacheKey, sessionEntry);
|
||||
}
|
||||
}
|
||||
const cached = readHourlyDetailSnapshot(city, targetResolution, { allowStale: true });
|
||||
const cacheAge = cached ? Date.now() - Number(cached.ts || 0) : Number.POSITIVE_INFINITY;
|
||||
const hasFreshCache = cached && cacheAge >= 0 && cacheAge < HOURLY_CACHE_TTL_MS;
|
||||
|
||||
@@ -541,6 +900,12 @@ export function LiveTemperatureThresholdChart({
|
||||
hasLoadedHourlyDetailRef.current = true;
|
||||
setHourly(cached.data);
|
||||
setShowingStaleDetail(!hasFreshCache);
|
||||
setChartFreshness((prev) => ({
|
||||
...prev,
|
||||
detailResolvedAtMs: Number(cached.ts || Date.now()),
|
||||
detailStatus: hasFreshCache ? "fresh" : "stale_cache",
|
||||
detailSource: cached.source,
|
||||
}));
|
||||
}
|
||||
|
||||
if (hasFreshCache) {
|
||||
@@ -549,6 +914,9 @@ export function LiveTemperatureThresholdChart({
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Stale-while-revalidate: show cached data, refresh in background ──
|
||||
const hasStaleCache = cached && !hasFreshCache;
|
||||
|
||||
if (
|
||||
!shouldFetchCityDetailForChart({
|
||||
city,
|
||||
@@ -567,40 +935,52 @@ export function LiveTemperatureThresholdChart({
|
||||
}
|
||||
|
||||
if (!cached && !hasLoadedHourlyDetailRef.current) {
|
||||
setHourly(seedHourlyForecastFromRow(row));
|
||||
commitHourlySnapshot((prev) => mergeRowObservationIntoHourly(prev, getLatestRowSnapshot()));
|
||||
setShowingStaleDetail(false);
|
||||
}
|
||||
setIsHourlyLoading(true);
|
||||
|
||||
// Stale cache: don't show loading spinner, refresh silently
|
||||
if (!hasStaleCache) {
|
||||
setIsHourlyLoading(true);
|
||||
}
|
||||
let cancelled = false;
|
||||
let retryScheduled = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const scheduleTransientDetailRetry = () => {
|
||||
retryScheduled = true;
|
||||
markDetailDegraded();
|
||||
retryTimer = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
void runHourlyDetailFetch({
|
||||
source: "network",
|
||||
fetchOptions: { bypassLocalCache: true },
|
||||
showUserError: true,
|
||||
isCancelled: () => cancelled,
|
||||
onSettled: () => setIsHourlyLoading(false),
|
||||
});
|
||||
}, TRANSIENT_DETAIL_RETRY_DELAY_MS);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
fetchHourlyForecastForCity(city, { resolution: targetResolution })
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
if (!data) {
|
||||
setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用");
|
||||
return;
|
||||
}
|
||||
hasLoadedHourlyDetailRef.current = true;
|
||||
setHourly(data);
|
||||
setDetailError(null);
|
||||
setShowingStaleDetail(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsHourlyLoading(false);
|
||||
});
|
||||
void runHourlyDetailFetch({
|
||||
source: "network",
|
||||
showUserError: true,
|
||||
isCancelled: () => cancelled,
|
||||
onEmpty: scheduleTransientDetailRetry,
|
||||
onSettled: () => {
|
||||
if (!retryScheduled) setIsHourlyLoading(false);
|
||||
},
|
||||
});
|
||||
}, DETAIL_LOAD_BATCH_DELAY_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
if (retryTimer !== null) clearTimeout(retryTimer);
|
||||
};
|
||||
}, [
|
||||
city,
|
||||
row,
|
||||
targetResolution,
|
||||
isChartVisible,
|
||||
compact,
|
||||
@@ -609,97 +989,114 @@ export function LiveTemperatureThresholdChart({
|
||||
slotIndex,
|
||||
detailLoadReady,
|
||||
detailRetryNonce,
|
||||
isEn,
|
||||
getLatestRowSnapshot,
|
||||
commitHourlySnapshot,
|
||||
markDetailDegraded,
|
||||
runHourlyDetailFetch,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return;
|
||||
const patchAppliedAtMs = Date.now();
|
||||
lastAppliedPatchRevisionRef.current = latestPatch.revision;
|
||||
lastPatchAtRef.current = Date.now();
|
||||
lastPatchAtRef.current = patchAppliedAtMs;
|
||||
const tempValue = validNumber(latestPatch.changes.temp);
|
||||
if (tempValue !== null) setLiveTemp(tempValue);
|
||||
setHourly((prev) => mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(row), latestPatch));
|
||||
commitHourlySnapshot((prev) => {
|
||||
const mergedHourly = mergePatchIntoHourly(prev ?? seedChartRenderStateFromRow(getLatestRowSnapshot()), latestPatch);
|
||||
return mergedHourly;
|
||||
});
|
||||
setChartFreshness((prev) => ({
|
||||
...prev,
|
||||
ssePatchAppliedAtMs: patchAppliedAtMs,
|
||||
sseObservedAt: patchObservationTimeForFreshness(latestPatch),
|
||||
sseRevision: latestPatch.revision,
|
||||
sseEmittedAtMs: latestPatch.delivery?.sse_emitted_at_ms ?? null,
|
||||
sseServerToClientLatencySec: latestPatch.delivery?.server_to_client_latency_sec ?? null,
|
||||
sseCollectorToClientLatencySec: latestPatch.delivery?.collector_to_client_latency_sec ?? null,
|
||||
sseSourceToCollectorLatencySec: latestPatch.delivery?.source_to_collector_latency_sec ?? null,
|
||||
}));
|
||||
|
||||
const hasObservationChange =
|
||||
tempValue !== null ||
|
||||
Array.isArray(latestPatch.changes.runway_points) ||
|
||||
Boolean(latestPatch.changes.amos);
|
||||
if (!hasObservationChange || !shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastProbabilityRefreshAtRef.current < PROBABILITY_REFRESH_AFTER_PATCH_MS) return;
|
||||
lastProbabilityRefreshAtRef.current = now;
|
||||
|
||||
let cancelled = false;
|
||||
const refreshProbabilityOverlayAfterPatch = () => {
|
||||
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
hasLoadedHourlyDetailRef.current = true;
|
||||
setHourly(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
refreshProbabilityOverlayAfterPatch();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [latestPatch, row, city, targetResolution, compact, isActive, isMaximized]);
|
||||
}, [latestPatch, getLatestRowSnapshot, commitHourlySnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resyncVersion || !city) return;
|
||||
let cancelled = false;
|
||||
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
hasLoadedHourlyDetailRef.current = true;
|
||||
setHourly(data);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsHourlyLoading(false);
|
||||
});
|
||||
void fetchLiveObservationForCity(city).then((snapshot) => {
|
||||
if (cancelled || !snapshot) return;
|
||||
applyLiveObservationSnapshot(snapshot);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resyncVersion, city, targetResolution]);
|
||||
}, [resyncVersion, city, applyLiveObservationSnapshot]);
|
||||
|
||||
// ── SSE fallback: only full-fetch if a visible chart has seen no patch for one METAR cadence ──
|
||||
// ── SSE fallback: visible charts merge no-store observations if patches stop. ──
|
||||
useEffect(() => {
|
||||
if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
|
||||
let cancelled = false;
|
||||
|
||||
const refreshFullDetail = () => {
|
||||
lastPatchAtRef.current = Date.now();
|
||||
const refreshLiveObservation = () => {
|
||||
const now = Date.now();
|
||||
lastPatchAtRef.current = now;
|
||||
|
||||
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
hasLoadedHourlyDetailRef.current = true;
|
||||
const temp = getLiveTempFromHourly(data);
|
||||
if (temp !== null) setLiveTemp(temp);
|
||||
setHourly(data);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsHourlyLoading(false);
|
||||
});
|
||||
void fetchLiveObservationForCity(city).then((snapshot) => {
|
||||
if (cancelled || !snapshot) return;
|
||||
applyLiveObservationSnapshot(snapshot);
|
||||
});
|
||||
};
|
||||
|
||||
const checkFallback = () => {
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||
if (Date.now() - lastPatchAtRef.current < NO_PATCH_FULL_DETAIL_FALLBACK_MS) return;
|
||||
if (Date.now() - lastPatchAtRef.current < LIVE_OBSERVATION_FALLBACK_MS) return;
|
||||
|
||||
refreshFullDetail();
|
||||
refreshLiveObservation();
|
||||
};
|
||||
|
||||
refreshLiveObservation();
|
||||
const id = setInterval(checkFallback, 60_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [city, compact, isActive, isMaximized, targetResolution]);
|
||||
}, [city, compact, isActive, isMaximized, applyLiveObservationSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activationRefreshKey) return;
|
||||
if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
|
||||
if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const refreshActivatedCachedDetail = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastForegroundRefreshAtRef.current < 10_000) return;
|
||||
|
||||
lastForegroundRefreshAtRef.current = now;
|
||||
lastPatchAtRef.current = now;
|
||||
|
||||
void runHourlyDetailFetch({
|
||||
source: "network",
|
||||
fetchOptions: { bypassLocalCache: true },
|
||||
applyOptions: { updateLiveTemp: true },
|
||||
isCancelled: () => cancelled,
|
||||
onSettled: () => setIsHourlyLoading(false),
|
||||
});
|
||||
};
|
||||
|
||||
refreshActivatedCachedDetail();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
activationRefreshKey,
|
||||
city,
|
||||
compact,
|
||||
isActive,
|
||||
isMaximized,
|
||||
targetResolution,
|
||||
runHourlyDetailFetch,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
|
||||
@@ -707,9 +1104,7 @@ export function LiveTemperatureThresholdChart({
|
||||
|
||||
const refreshForegroundFullDetail = () => {
|
||||
const now = Date.now();
|
||||
const cacheKey = `${city}:${targetResolution}`;
|
||||
const cached = _hourlyCache.get(cacheKey);
|
||||
const cacheAge = cached ? now - Number(cached.ts || 0) : Number.POSITIVE_INFINITY;
|
||||
const cacheAge = readHourlyDetailSnapshotAgeMs(city, targetResolution);
|
||||
if (
|
||||
now - lastForegroundRefreshAtRef.current < FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS ||
|
||||
(cacheAge >= 0 && cacheAge < FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS)
|
||||
@@ -720,15 +1115,12 @@ export function LiveTemperatureThresholdChart({
|
||||
lastForegroundRefreshAtRef.current = now;
|
||||
lastPatchAtRef.current = now;
|
||||
|
||||
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
hasLoadedHourlyDetailRef.current = true;
|
||||
const temp = getLiveTempFromHourly(data);
|
||||
if (temp !== null) setLiveTemp(temp);
|
||||
setHourly(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
void runHourlyDetailFetch({
|
||||
source: "network",
|
||||
fetchOptions: { bypassLocalCache: true },
|
||||
applyOptions: { updateLiveTemp: true },
|
||||
isCancelled: () => cancelled,
|
||||
});
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
@@ -743,7 +1135,7 @@ export function LiveTemperatureThresholdChart({
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("focus", refreshForegroundFullDetail);
|
||||
};
|
||||
}, [city, compact, isActive, isMaximized, targetResolution]);
|
||||
}, [city, compact, isActive, isMaximized, targetResolution, runHourlyDetailFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!city || !currentCityLocalDate) return;
|
||||
@@ -753,22 +1145,22 @@ export function LiveTemperatureThresholdChart({
|
||||
|
||||
localDayRolloverFetchDateRef.current = currentCityLocalDate;
|
||||
let cancelled = false;
|
||||
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
hasLoadedHourlyDetailRef.current = true;
|
||||
setHourly(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) localDayRolloverFetchDateRef.current = "";
|
||||
});
|
||||
void runHourlyDetailFetch({
|
||||
source: "force_refresh",
|
||||
fetchOptions: { ignoreCache: true },
|
||||
isCancelled: () => cancelled,
|
||||
onError: () => {
|
||||
localDayRolloverFetchDateRef.current = "";
|
||||
markDetailDegraded();
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [city, currentCityLocalDate, hourly?.localDate, row?.local_date, targetResolution]);
|
||||
}, [city, currentCityLocalDate, hourly?.localDate, row?.local_date, targetResolution, markDetailDegraded, runHourlyDetailFetch]);
|
||||
|
||||
const chartHourly = useMemo<HourlyForecast>(() => {
|
||||
const chartHourly = useMemo<ChartRenderState>(() => {
|
||||
if (!hourly) return hourly;
|
||||
const loadedLocalDate = hourly.localDate || row?.local_date || "";
|
||||
if (currentCityLocalDate && currentCityLocalDate !== loadedLocalDate) {
|
||||
@@ -914,17 +1306,7 @@ export function LiveTemperatureThresholdChart({
|
||||
);
|
||||
|
||||
const formattedUpdateTime = useMemo(() => {
|
||||
const nowUtc = Date.now();
|
||||
const cityOffsetMs = (row?.tz_offset_seconds ?? 0) * 1000;
|
||||
const cityNow = new Date(nowUtc + cityOffsetMs + new Date().getTimezoneOffset() * 60_000);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const y = cityNow.getFullYear();
|
||||
const mo = pad(cityNow.getMonth() + 1);
|
||||
const d = pad(cityNow.getDate());
|
||||
const hh = pad(cityNow.getHours());
|
||||
const mm = pad(cityNow.getMinutes());
|
||||
const ss = pad(cityNow.getSeconds());
|
||||
return `${y}-${mo}-${d} ${hh}:${mm}:${ss}`;
|
||||
return formatCityLocalDateTime(row?.tz_offset_seconds);
|
||||
}, [row]);
|
||||
|
||||
const cityThresholds = useMemo(() => {
|
||||
@@ -969,6 +1351,14 @@ export function LiveTemperatureThresholdChart({
|
||||
|
||||
const subtitle = row ? (isEn ? "Live & Forecast" : "实测与预测") : "";
|
||||
const showDetailErrorBadge = !compact || isActive || isMaximized;
|
||||
const chartFreshnessContext = useMemo(
|
||||
() => buildChartFreshnessContext(chartFreshness),
|
||||
[chartFreshness],
|
||||
);
|
||||
const detailBatchDiagnostics = useMemo(
|
||||
() => readCityDetailBatchDiagnostics(city, targetResolution),
|
||||
[city, targetResolution, chartFreshness.detailRequestedAtMs, chartFreshness.detailErrorAtMs, chartFreshness.detailResolvedAtMs],
|
||||
);
|
||||
|
||||
const handleZoomReset = useCallback(() => {
|
||||
setZoomRange(null);
|
||||
@@ -1029,8 +1419,9 @@ export function LiveTemperatureThresholdChart({
|
||||
const handleRetryDetail = useCallback(() => {
|
||||
setDetailError(null);
|
||||
setIsHourlyLoading(true);
|
||||
markDetailRequest("network");
|
||||
setDetailRetryNonce((value) => value + 1);
|
||||
}, []);
|
||||
}, [markDetailRequest]);
|
||||
|
||||
const handleReportIssue = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
@@ -1044,6 +1435,12 @@ export function LiveTemperatureThresholdChart({
|
||||
is_active: isActive,
|
||||
is_maximized: isMaximized,
|
||||
detail_error: detailError,
|
||||
freshness: chartFreshnessContext,
|
||||
detail_batch_diagnostics: detailBatchDiagnostics,
|
||||
live_path: chartFreshnessContext.live_path,
|
||||
live_age_sec: chartFreshnessContext.live_age_sec,
|
||||
detail_status: chartFreshnessContext.detail_status,
|
||||
detail_source: chartFreshnessContext.detail_source,
|
||||
is_hourly_loading: isHourlyLoading,
|
||||
showing_stale_detail: showingStaleDetail,
|
||||
target_resolution: targetResolution,
|
||||
@@ -1061,11 +1458,13 @@ export function LiveTemperatureThresholdChart({
|
||||
}, [
|
||||
activeSeries,
|
||||
chartLocalDate,
|
||||
chartFreshnessContext,
|
||||
chartSeries,
|
||||
city,
|
||||
compact,
|
||||
currentMetarTemp,
|
||||
currentRunwayTemp,
|
||||
detailBatchDiagnostics,
|
||||
detailError,
|
||||
hasRunwayData,
|
||||
isActive,
|
||||
@@ -1261,6 +1660,7 @@ export function LiveTemperatureThresholdChart({
|
||||
showRunwayDetails={showRunwayDetails}
|
||||
isHourlyLoading={isHourlyLoading}
|
||||
detailError={detailError}
|
||||
detailStatus={chartFreshness.detailStatus}
|
||||
showingStaleDetail={showingStaleDetail}
|
||||
showDetailErrorBadge={showDetailErrorBadge}
|
||||
refAreaLeft={refAreaLeft}
|
||||
@@ -1281,7 +1681,7 @@ export function LiveTemperatureThresholdChart({
|
||||
|
||||
export function __buildTemperatureChartDataForTest(
|
||||
row: ScanOpportunityRow | null,
|
||||
hourly: HourlyForecast,
|
||||
hourly: ChartRenderState,
|
||||
_timeframe = "1D",
|
||||
isEn = false,
|
||||
) {
|
||||
@@ -1296,9 +1696,12 @@ export const __getLiveObservationLabelsForTest = getLiveObservationLabels;
|
||||
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
|
||||
export const __getPeakGlowStateForTest = getPeakGlowState;
|
||||
export const __getWundergroundDailyHighForTest = getWundergroundDailyHigh;
|
||||
export const __formatCityLocalDateForTest = formatCityLocalDate;
|
||||
export const __formatCityLocalDateTimeForTest = formatCityLocalDateTime;
|
||||
export const __getInitialDetailLoadDelayMsForTest = getInitialDetailLoadDelayMs;
|
||||
export const __shouldFetchCityDetailForChartForTest = shouldFetchCityDetailForChart;
|
||||
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
|
||||
export const __buildChartFreshnessContextForTest = buildChartFreshnessContext;
|
||||
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
|
||||
export const __selectCompactSecondaryTempForTest = selectCompactSecondaryTemp;
|
||||
export const __selectDisplayRunwayTempForTest = selectDisplayRunwayTemp;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user