Compare commits
179 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+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,16 @@ 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 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 +365,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;
|
||||
|
||||
+78
-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,51 @@ 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
|
||||
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,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);
|
||||
|
||||
@@ -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,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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,10 +80,14 @@ 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") &&
|
||||
@@ -186,6 +190,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 +216,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 +251,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(
|
||||
@@ -803,6 +841,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 +864,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 +901,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;
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { LockKeyhole, CreditCard, LogIn } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CreditCard,
|
||||
LogIn,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
const ACCESS_TERM = {
|
||||
signInToContinue: { en: "Sign in to continue", zh: "请先登录" },
|
||||
proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" },
|
||||
proAccessRequired: {
|
||||
en: "Subscription expired",
|
||||
zh: "订阅已过期",
|
||||
},
|
||||
month: { en: "/ 30 days", zh: "/ 30 天" },
|
||||
subscribeNow: { en: "View Pro plans", zh: "查看订阅方案" },
|
||||
subscribeNow: {
|
||||
en: "Renew and restore access",
|
||||
zh: "续费并恢复访问",
|
||||
},
|
||||
backToProduct: { en: "Back to product overview", zh: "返回产品介绍页" },
|
||||
} as const;
|
||||
|
||||
@@ -15,8 +26,37 @@ function t(key: keyof typeof ACCESS_TERM, isEn: boolean) {
|
||||
return isEn ? ACCESS_TERM[key].en : ACCESS_TERM[key].zh;
|
||||
}
|
||||
|
||||
function isExpiredSubscription(expiresAt?: string | null) {
|
||||
if (!expiresAt) return false;
|
||||
const timestamp = Date.parse(expiresAt);
|
||||
return Number.isFinite(timestamp) && timestamp <= Date.now();
|
||||
}
|
||||
|
||||
function formatSubscriptionDate(expiresAt: string | null | undefined, isEn: boolean) {
|
||||
if (!expiresAt) return "";
|
||||
const timestamp = Date.parse(expiresAt);
|
||||
if (!Number.isFinite(timestamp)) return "";
|
||||
try {
|
||||
return new Intl.DateTimeFormat(isEn ? "en-US" : "zh-CN", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(new Date(timestamp));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Layer 2: Authenticated but no active subscription ───────────────────────
|
||||
function SubscriptionGate({ isEn }: { isEn: boolean }) {
|
||||
function SubscriptionGate({
|
||||
isEn,
|
||||
subscriptionExpiresAt,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
subscriptionExpiresAt?: string | null;
|
||||
}) {
|
||||
const expired = isExpiredSubscription(subscriptionExpiresAt);
|
||||
const expiryLabel = formatSubscriptionDate(subscriptionExpiresAt, isEn);
|
||||
const features = isEn
|
||||
? [
|
||||
"Real-time station, METAR, and runway signals",
|
||||
@@ -33,25 +73,57 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-[#e9edf3] p-6">
|
||||
<div className="w-full max-w-lg">
|
||||
<div className="w-full max-w-xl">
|
||||
<div className="mb-6 flex items-center justify-center">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-bold uppercase tracking-wider text-amber-700">
|
||||
<LockKeyhole size={12} />
|
||||
{t("proAccessRequired", isEn)}
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-amber-300 bg-amber-50 px-3 py-1 text-xs font-black uppercase tracking-wider text-amber-800">
|
||||
<AlertTriangle size={13} />
|
||||
{expired
|
||||
? t("proAccessRequired", isEn)
|
||||
: isEn
|
||||
? "No active subscription"
|
||||
: "无有效订阅"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg">
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-700 px-8 py-6 text-white">
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-8 py-5 text-amber-950">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.16em] text-amber-700">
|
||||
{isEn ? "Access paused" : "终端访问已暂停"}
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-black tracking-tight text-slate-950">
|
||||
{expired
|
||||
? isEn
|
||||
? "Your subscription has expired"
|
||||
: "订阅已过期,终端访问已暂停"
|
||||
: isEn
|
||||
? "No active Pro subscription"
|
||||
: "未检测到有效订阅"}
|
||||
</h1>
|
||||
</div>
|
||||
<span className="rounded-lg border border-amber-300 bg-white px-3 py-2 text-xs font-black text-amber-800">
|
||||
{expiryLabel
|
||||
? isEn
|
||||
? `Expired ${expiryLabel}`
|
||||
: `到期日 ${expiryLabel}`
|
||||
: isEn
|
||||
? "Action required"
|
||||
: "需要处理"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8 pt-6">
|
||||
<h1 className="text-xl font-black tracking-tight">
|
||||
{isEn
|
||||
? "Unlock the Weather Terminal"
|
||||
: "解锁天气决策台"}
|
||||
? "Renew to restore the Weather Terminal"
|
||||
: "续费后恢复天气决策台访问"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-blue-100">
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{isEn
|
||||
? "Your account is verified. One step away from full access."
|
||||
: "账号已验证,只差一步即可获得完整访问权限。"}
|
||||
? "Your account is verified, but it does not currently have an active subscription. Renewing restores live temperature evidence, DEB paths, runway alerts, and paid Telegram/API access."
|
||||
: "你的账号已验证,但当前没有有效订阅。续费后会立即恢复实时温度、DEB 路径、跑道提醒和付费 Telegram/API 权益。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -77,17 +149,41 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
|
||||
</ul>
|
||||
|
||||
<Link
|
||||
href="/account"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 py-3.5 text-sm font-black text-white shadow-sm transition hover:bg-blue-700"
|
||||
href="/account?checkout=1"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-slate-950 py-3.5 text-sm font-black text-white shadow-sm transition hover:bg-slate-800"
|
||||
>
|
||||
<CreditCard size={16} />
|
||||
{t("subscribeNow", isEn)}
|
||||
</Link>
|
||||
|
||||
<div className="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p className="text-xs font-black text-slate-700">
|
||||
{isEn
|
||||
? "Already paid but still blocked?"
|
||||
: "已有付款但未恢复?"}
|
||||
</p>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
className="flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-xs font-black text-slate-700 transition hover:border-blue-300 hover:text-blue-700"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
{isEn ? "Refresh access" : "刷新权限"}
|
||||
</button>
|
||||
<Link
|
||||
href="/account"
|
||||
className="flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-xs font-black text-slate-700 transition hover:border-blue-300 hover:text-blue-700"
|
||||
>
|
||||
{isEn ? "Open account center" : "账户中心"}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-[11px] text-slate-400">
|
||||
{isEn
|
||||
? "Cancel anytime · No hidden fees · Instant access after payment"
|
||||
: "随时取消 · 无隐藏费用 · 付款后立即解锁"}
|
||||
? "Access resumes automatically after payment confirmation"
|
||||
: "付款确认后会自动恢复访问"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,25 +233,25 @@ function UnauthenticatedGate({
|
||||
? "The weather terminal is for verified subscribers only."
|
||||
: "天气决策台仅对已验证的付费用户开放。"}
|
||||
</p>
|
||||
<Link
|
||||
<a
|
||||
href="/auth/login?next=%2Fterminal"
|
||||
className="mt-6 flex items-center justify-center gap-2 rounded-xl bg-slate-900 py-3 text-sm font-black text-white transition hover:bg-slate-700"
|
||||
>
|
||||
<LogIn size={15} />
|
||||
{isEn ? "Log in" : "登录"}
|
||||
</Link>
|
||||
<Link
|
||||
</a>
|
||||
<a
|
||||
href="/auth/login?next=%2Fterminal&mode=signup"
|
||||
className="mt-3 flex items-center justify-center gap-2 rounded-xl border border-slate-300 py-3 text-sm font-black text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
{isEn ? "Create an account" : "注册账号"}
|
||||
</Link>
|
||||
<Link
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
className="mt-4 block text-xs text-slate-400 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
{isEn ? "← Learn about PolyWeather" : "← 了解 PolyWeather"}
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -166,10 +262,12 @@ function UnauthenticatedGate({
|
||||
export function ProductAccessRequired({
|
||||
isAuthenticated,
|
||||
isEn,
|
||||
subscriptionExpiresAt,
|
||||
userLocalTime,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
isEn: boolean;
|
||||
subscriptionExpiresAt?: string | null;
|
||||
userLocalTime: string;
|
||||
}) {
|
||||
if (!isAuthenticated) {
|
||||
@@ -186,7 +284,10 @@ export function ProductAccessRequired({
|
||||
</Link>
|
||||
<div className="font-mono text-sm text-slate-300">{userLocalTime}</div>
|
||||
</header>
|
||||
<SubscriptionGate isEn={isEn} />
|
||||
<SubscriptionGate
|
||||
isEn={isEn}
|
||||
subscriptionExpiresAt={subscriptionExpiresAt}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,6 +50,26 @@ function hasDrawableTemperatureChartContent({
|
||||
);
|
||||
}
|
||||
|
||||
function isFallbackReferenceSeries(series: EvidenceSeries) {
|
||||
return series.key === "current" && series.label === "Current reference";
|
||||
}
|
||||
|
||||
function hasDrawablePrimaryTemperatureChartContent({
|
||||
activeSeries,
|
||||
probabilityOverlay,
|
||||
zoomedData,
|
||||
}: {
|
||||
activeSeries: EvidenceSeries[];
|
||||
probabilityOverlay: ProbabilityOverlay | null;
|
||||
zoomedData: Array<Record<string, any>>;
|
||||
}) {
|
||||
return hasDrawableTemperatureChartContent({
|
||||
activeSeries: activeSeries.filter((series) => !isFallbackReferenceSeries(series)),
|
||||
probabilityOverlay,
|
||||
zoomedData,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldKeepTemperatureChartLoading({
|
||||
row,
|
||||
isHourlyLoading,
|
||||
@@ -65,7 +85,7 @@ function shouldKeepTemperatureChartLoading({
|
||||
}) {
|
||||
if (!row?.city) return false;
|
||||
if (!isHourlyLoading) return false;
|
||||
return !hasDrawableTemperatureChartContent({ activeSeries, probabilityOverlay, zoomedData });
|
||||
return !hasDrawablePrimaryTemperatureChartContent({ activeSeries, probabilityOverlay, zoomedData });
|
||||
}
|
||||
|
||||
function TemperatureChartSkeleton({ compact }: { compact: boolean }) {
|
||||
@@ -89,7 +109,8 @@ function TemperatureChartSkeleton({ compact }: { compact: boolean }) {
|
||||
style={{ left: `${(index / Math.max(1, verticalLines - 1)) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
<div className="absolute inset-x-10 top-1/3 h-10 rounded bg-slate-100/60" />
|
||||
<div className="absolute inset-x-10 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 bg-gradient-to-r from-transparent via-white/80 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -111,6 +132,7 @@ function TemperatureChartCanvasComponent({
|
||||
showRunwayDetails,
|
||||
isHourlyLoading,
|
||||
detailError,
|
||||
detailStatus,
|
||||
showingStaleDetail,
|
||||
showDetailErrorBadge = true,
|
||||
refAreaLeft,
|
||||
@@ -139,6 +161,7 @@ function TemperatureChartCanvasComponent({
|
||||
showRunwayDetails: boolean;
|
||||
isHourlyLoading: boolean;
|
||||
detailError?: string | null;
|
||||
detailStatus?: string | null;
|
||||
showingStaleDetail?: boolean;
|
||||
showDetailErrorBadge?: boolean;
|
||||
refAreaLeft: number | null;
|
||||
@@ -222,10 +245,15 @@ function TemperatureChartCanvasComponent({
|
||||
});
|
||||
const shouldRenderChart = canRenderChart && hasDrawableChartContent;
|
||||
const shouldShowEmptyState = Boolean(row?.city) && !isHourlyLoading && !hasDrawableChartContent;
|
||||
const shouldShowBackgroundRefresh = isHourlyLoading && hasDrawableChartContent;
|
||||
const shouldShowBackgroundRefresh = isHourlyLoading && hasDrawableChartContent && !shouldShowChartLoading;
|
||||
const shouldShowUnavailableState = Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && !hasDrawableChartContent;
|
||||
const shouldShowBackgroundError =
|
||||
showDetailErrorBadge && Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && hasDrawableChartContent;
|
||||
showDetailErrorBadge && Boolean(row?.city) && !isHourlyLoading && hasDrawableChartContent &&
|
||||
(Boolean(detailError) || showingStaleDetail || detailStatus === "stale_cache");
|
||||
const backgroundErrorLabel =
|
||||
showingStaleDetail || detailStatus === "stale_cache"
|
||||
? (isEn ? "Detail cache" : "详情缓存")
|
||||
: (isEn ? "Detail degraded" : "详情降级");
|
||||
|
||||
return (
|
||||
<div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}>
|
||||
@@ -389,7 +417,7 @@ function TemperatureChartCanvasComponent({
|
||||
{shouldShowUnavailableState && (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center px-4 text-center">
|
||||
<div className="max-w-[260px] rounded border border-amber-200 bg-amber-50/95 px-3 py-2 text-[11px] font-semibold text-amber-700 shadow-sm">
|
||||
<div>{isEn ? "Data temporarily unavailable" : "数据暂不可用"}</div>
|
||||
<div>{isEn ? "Detail temporarily unavailable" : "详情暂不可用"}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetryDetail}
|
||||
@@ -410,7 +438,7 @@ function TemperatureChartCanvasComponent({
|
||||
</div>
|
||||
{shouldShowBackgroundError && (
|
||||
<div className="absolute right-3 top-12 z-10 inline-flex items-center gap-1.5 rounded border border-amber-200 bg-amber-50/95 px-2 py-1 text-[10px] font-semibold text-amber-700 shadow-sm">
|
||||
<span>{showingStaleDetail ? (isEn ? "Showing cache" : "显示缓存") : (isEn ? "Update failed" : "更新失败")}</span>
|
||||
<span>{backgroundErrorLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetryDetail}
|
||||
|
||||
@@ -20,24 +20,26 @@ type UpdateAnnouncementButtonProps = {
|
||||
isEn: boolean;
|
||||
};
|
||||
|
||||
const UPDATE_ANNOUNCEMENT_SEEN_KEY = "polyweather_update_announcement_seen_v1";
|
||||
|
||||
const STATIC_UPDATE_ANNOUNCEMENTS: StaticUpdateAnnouncement[] = [
|
||||
{
|
||||
id: "feedback-status-2026-06",
|
||||
publishedAt: "2026-06-07T00:00:00+08:00",
|
||||
expiresAt: "2026-07-15T00:00:00+08:00",
|
||||
id: "live-observation-chart-2026-06",
|
||||
publishedAt: "2026-06-17T00:00:00+08:00",
|
||||
expiresAt: "2026-07-31T00:00:00+08:00",
|
||||
zh: {
|
||||
title: "更新公告:终端新增公告与反馈状态",
|
||||
title: "更新公告:实时观测和图表稳定性升级",
|
||||
body:
|
||||
"PolyWeather 天气决策台新增“更新公告”入口,后续产品更新、数据源调整和重要说明会在这里同步。\n\n" +
|
||||
"用户反馈系统也已升级:提交反馈时会自动附带相关图表上下文,用户可以在终端右上角通知入口和账户页查看自己反馈的处理状态,包括已收到、已确认、处理中、已解决和已关闭。\n\n" +
|
||||
"我们也在考虑对真实、可复现、有建设性价值的反馈加入积分或 Pro 天数激励。",
|
||||
"实时观测链路已和模型缓存拆分:SSE 到达会立即更新图表;如果 SSE 断线,终端会每 3 分钟拉取一次轻量观测兜底。DEB、模型曲线、概率和历史数据继续走独立缓存,不再跟着实时温度强刷。\n\n" +
|
||||
"这次也补齐了更多官方观测覆盖,包括东京 JMA、韩国 AMOS、土耳其 MGM、台北 CWA 等,并修正 NOAA MADIS 只应用于美国城市,避免非美国城市串台。\n\n" +
|
||||
"图表侧同步修复了预测曲线缺段、首屏 loading 不明显、旧缓存覆盖最新观测等问题。刷新终端后即可使用新逻辑。",
|
||||
},
|
||||
en: {
|
||||
title: "Update: announcements and feedback status are now live",
|
||||
title: "Update: live observations and chart stability",
|
||||
body:
|
||||
"PolyWeather Terminal now has an update announcement entry. Future product updates, data-source changes, and important notes will be shared here.\n\n" +
|
||||
"The feedback system has also been upgraded. When users submit feedback, PolyWeather automatically attaches the relevant chart context. Users can now track their own feedback status from the terminal notification entry and the account page: received, confirmed, in progress, resolved, or closed.\n\n" +
|
||||
"We are also considering small rewards such as points or Pro days for real, reproducible, and constructive feedback.",
|
||||
"Live observations are now separated from cached model detail. SSE patches update charts immediately; if SSE is unavailable, the terminal falls back to a lightweight observation fetch every 3-minute interval. DEB, model curves, probabilities, and historical data stay on their own cache path instead of refreshing with every live temperature update.\n\n" +
|
||||
"Official observation coverage has also expanded, including Tokyo JMA, Korea AMOS, Turkey MGM, and Taipei CWA. NOAA MADIS is now restricted to US cities to avoid cross-region source leakage.\n\n" +
|
||||
"Charts also include fixes for truncated forecast curves, first-load chart loading visibility, and stale cache overriding newer observations. Refresh the terminal to use the new flow.",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -71,10 +73,40 @@ function formatUpdatedAt(value: string | undefined, isEn: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
function loadSeenAnnouncementIds() {
|
||||
if (typeof window === "undefined") return new Set<string>();
|
||||
try {
|
||||
const raw = window.localStorage.getItem(UPDATE_ANNOUNCEMENT_SEEN_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return new Set(Array.isArray(parsed) ? parsed.map(String) : []);
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
function saveSeenAnnouncementIds(ids: Set<string>) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
UPDATE_ANNOUNCEMENT_SEEN_KEY,
|
||||
JSON.stringify(Array.from(ids).slice(-50)),
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage failures; the unread dot may reappear but the announcement remains usable.
|
||||
}
|
||||
}
|
||||
|
||||
export function UpdateAnnouncementButton({ isEn }: UpdateAnnouncementButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [seenIds, setSeenIds] = useState<Set<string>>(new Set());
|
||||
const [seenLoaded, setSeenLoaded] = useState(false);
|
||||
const shellRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSeenIds(loadSeenAnnouncementIds());
|
||||
setSeenLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
@@ -98,6 +130,17 @@ export function UpdateAnnouncementButton({ isEn }: UpdateAnnouncementButtonProps
|
||||
() => formatUpdatedAt(announcement?.publishedAt, isEn),
|
||||
[announcement?.publishedAt, isEn],
|
||||
);
|
||||
const hasUnread = Boolean(seenLoaded && announcement?.id && !seenIds.has(announcement.id));
|
||||
|
||||
const markAnnouncementAsSeen = (announcementId: string) => {
|
||||
setSeenIds((current) => {
|
||||
if (current.has(announcementId)) return current;
|
||||
const next = new Set(current);
|
||||
next.add(announcementId);
|
||||
saveSeenAnnouncementIds(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!announcement || (!text.title && !text.body)) return null;
|
||||
|
||||
@@ -105,13 +148,34 @@ export function UpdateAnnouncementButton({ isEn }: UpdateAnnouncementButtonProps
|
||||
<div ref={shellRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded border border-blue-200 bg-blue-50 px-2 text-[10px] font-bold uppercase tracking-wide text-blue-700 transition-colors hover:border-blue-300 hover:bg-blue-100"
|
||||
onClick={() => {
|
||||
setOpen((value) => {
|
||||
const next = !value;
|
||||
if (next) markAnnouncementAsSeen(announcement.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="relative inline-flex h-7 items-center gap-1.5 rounded border border-blue-200 bg-blue-50 px-2 text-[10px] font-bold uppercase tracking-wide text-blue-700 transition-colors hover:border-blue-300 hover:bg-blue-100"
|
||||
title={isEn ? "Update announcement" : "更新公告"}
|
||||
aria-label={
|
||||
hasUnread
|
||||
? isEn
|
||||
? "Update announcement, unread"
|
||||
: "更新公告,有未读内容"
|
||||
: isEn
|
||||
? "Update announcement"
|
||||
: "更新公告"
|
||||
}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Megaphone size={12} />
|
||||
{isEn ? "Updates" : "更新公告"}
|
||||
{hasUnread && (
|
||||
<span
|
||||
className="scan-update-announcement-unread absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-rose-500 ring-2 ring-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 top-8 z-50 w-[min(360px,calc(100vw-32px))] rounded-md border border-slate-200 bg-white p-3 text-left shadow-lg">
|
||||
|
||||
@@ -83,13 +83,13 @@ const legendItems: Record<"zh" | "en", GuideCopy[]> = {
|
||||
|
||||
const operations: Record<"zh" | "en", GuideCopy[]> = {
|
||||
zh: [
|
||||
{ title: "布局", body: "右上角可切换 1x1 到 3x3,适合从单城复盘切到多城巡检。" },
|
||||
{ title: "布局", body: "右上角可切换 1x1 到 3x2,适合从单城复盘切到多城巡检。" },
|
||||
{ title: "换城市", body: "点击图表标题栏城市名,在当前卡片内搜索并替换城市。" },
|
||||
{ title: "高温模式", body: "卡片右上角高温按钮用于聚焦最高温兑现窗口。" },
|
||||
{ title: "曲线显隐", body: "图例可自定义显示机场报文、模型线和跑道明细。" },
|
||||
],
|
||||
en: [
|
||||
{ title: "Layout", body: "Switch from 1x1 to 3x3 in the top-right control for review or multi-city scanning." },
|
||||
{ title: "Layout", body: "Switch from 1x1 to 3x2 in the top-right control for review or multi-city scanning." },
|
||||
{ title: "Change city", body: "Click the city name in a chart header to search and replace that slot." },
|
||||
{ title: "High mode", body: "Use the High button to focus the chart on the high-temperature payoff window." },
|
||||
{ title: "Layer toggles", body: "Use the legend to customize airport reports, model lines, and runway details." },
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
getSupabaseBrowserClient,
|
||||
hasSupabasePublicEnv,
|
||||
} from "@/lib/supabase/client";
|
||||
import { TurnstileWidget } from "@/components/security/TurnstileWidget";
|
||||
import { getTurnstileTokenForAction, isTurnstileEnabled } from "@/lib/turnstile-client";
|
||||
import type { UserFeedbackEntry } from "@/types/ops";
|
||||
|
||||
export type FeedbackCategory = "bug" | "data" | "idea" | "payment" | "account" | "other";
|
||||
@@ -49,6 +51,49 @@ function buildRuntimeContext(extra: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
function looksLikeHtmlDocument(value: string) {
|
||||
const text = String(value || "").trim().toLowerCase();
|
||||
return (
|
||||
text.startsWith("<!doctype html") ||
|
||||
text.startsWith("<html") ||
|
||||
text.includes("<html ") ||
|
||||
text.includes("cloudflare")
|
||||
);
|
||||
}
|
||||
|
||||
function extractFeedbackErrorMessage(raw: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(raw || "")) as {
|
||||
detail?: unknown;
|
||||
error?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const value = [parsed.detail, parsed.error, parsed.message].find((item) => {
|
||||
return typeof item === "string" && item.trim();
|
||||
});
|
||||
if (typeof value !== "string") return "";
|
||||
const message = value.replace(/\s+/g, " ").trim();
|
||||
return looksLikeHtmlDocument(message) ? "" : message;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatFeedbackSubmitError(status: number, raw: string, isEn: boolean) {
|
||||
const jsonMessage = extractFeedbackErrorMessage(raw);
|
||||
if (jsonMessage) return jsonMessage.slice(0, 180);
|
||||
|
||||
if (looksLikeHtmlDocument(raw)) {
|
||||
return isEn
|
||||
? `Service is temporarily unavailable. Please retry in a moment. (HTTP ${status})`
|
||||
: `服务暂时不可用,请稍后重试。(HTTP ${status})`;
|
||||
}
|
||||
|
||||
const message = String(raw || "").replace(/\s+/g, " ").trim();
|
||||
if (message) return `${message.slice(0, 180)} (HTTP ${status})`;
|
||||
return `HTTP ${status}`;
|
||||
}
|
||||
|
||||
export function UserFeedbackModal({
|
||||
draft,
|
||||
isEn,
|
||||
@@ -68,6 +113,8 @@ export function UserFeedbackModal({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
const [turnstileResetKey, setTurnstileResetKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -77,6 +124,8 @@ export function UserFeedbackModal({
|
||||
setLoginEmailContact("");
|
||||
setError("");
|
||||
setSubmitted(false);
|
||||
setTurnstileToken("");
|
||||
setTurnstileResetKey((value) => value + 1);
|
||||
}, [open, draft?.category]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -115,13 +164,20 @@ export function UserFeedbackModal({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const canSubmit = message.trim().length >= 3 && !submitting;
|
||||
const canSubmit =
|
||||
message.trim().length >= 3 &&
|
||||
!submitting &&
|
||||
(!isTurnstileEnabled() || Boolean(turnstileToken));
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const turnstile_token = getTurnstileTokenForAction(
|
||||
turnstileToken,
|
||||
"feedback_submit",
|
||||
);
|
||||
const res = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -131,11 +187,12 @@ export function UserFeedbackModal({
|
||||
contact: contact.trim() || undefined,
|
||||
source: draft?.source || "terminal",
|
||||
context: buildRuntimeContext(draft?.context || {}),
|
||||
turnstile_token,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.text().catch(() => "")).slice(0, 200);
|
||||
throw new Error(detail || `HTTP ${res.status}`);
|
||||
const raw = await res.text().catch(() => "");
|
||||
throw new Error(formatFeedbackSubmitError(res.status, raw, isEn));
|
||||
}
|
||||
const payload = (await res.json().catch(() => null)) as {
|
||||
feedback?: UserFeedbackEntry;
|
||||
@@ -143,7 +200,10 @@ export function UserFeedbackModal({
|
||||
setSubmitted(true);
|
||||
onSubmitted?.(payload?.feedback);
|
||||
} catch (err) {
|
||||
setError(String(err).slice(0, 220));
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message.slice(0, 220));
|
||||
setTurnstileToken("");
|
||||
setTurnstileResetKey((value) => value + 1);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -252,6 +312,12 @@ export function UserFeedbackModal({
|
||||
: "会自动附带终端上下文:城市、槽位、数据源状态、浏览器和会话诊断信息。"}
|
||||
</div>
|
||||
|
||||
<TurnstileWidget
|
||||
action="feedback_submit"
|
||||
onToken={setTurnstileToken}
|
||||
resetKey={turnstileResetKey}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{isEn ? "Submit failed: " : "提交失败:"}{error}
|
||||
|
||||
@@ -28,11 +28,19 @@ export function runTests() {
|
||||
deployScript.includes("http://127.0.0.1:3001/terminal"),
|
||||
"deploy script must wait for the local frontend before relying on Cloudflare/public smoke checks",
|
||||
);
|
||||
assert(
|
||||
deployScript.includes("wait_for_scan_terminal_snapshot") &&
|
||||
deployScript.includes("http://127.0.0.1:3001/api/scan/terminal") &&
|
||||
deployScript.includes('"status":"ready"') &&
|
||||
deployScript.includes("http=401"),
|
||||
"deploy script must wait for the scan terminal snapshot to be ready or protected before public warmup",
|
||||
);
|
||||
assert(
|
||||
deployScript.includes("warm_public_route") &&
|
||||
deployScript.includes("https://polyweather.top/terminal") &&
|
||||
deployScript.includes("https://polyweather.top/api/scan/terminal") &&
|
||||
deployScript.includes("https://polyweather.top/api/auth/me?prefer_snapshot=1"),
|
||||
"deploy script must warm terminal and auth snapshot routes after container replacement",
|
||||
"deploy script must warm terminal, scan terminal, and auth snapshot routes after container replacement",
|
||||
);
|
||||
assert(
|
||||
deployScript.includes("validate_frontend_api_base_url") &&
|
||||
|
||||
@@ -57,12 +57,25 @@ export function runTests() {
|
||||
modalSource.includes("type=\"textarea\"") === false,
|
||||
"feedback modal must submit to the feedback API, lock contact to the login email when available, notify the dashboard after success, and attach client/session diagnostics without using invalid textarea input types",
|
||||
);
|
||||
assert(
|
||||
modalSource.includes("formatFeedbackSubmitError") &&
|
||||
modalSource.includes("looksLikeHtmlDocument") &&
|
||||
!modalSource.includes("throw new Error(detail || `HTTP ${res.status}`)"),
|
||||
"feedback modal must not expose raw Cloudflare/HTML error pages to users",
|
||||
);
|
||||
assert(
|
||||
feedbackRouteSource.includes("export async function GET") &&
|
||||
feedbackRouteSource.includes("method: \"GET\"") &&
|
||||
feedbackRouteSource.includes("limit"),
|
||||
"feedback API proxy must expose a GET endpoint for the current user's feedback status list",
|
||||
);
|
||||
assert(
|
||||
feedbackRouteSource.includes("emptyFeedbackResponse") &&
|
||||
feedbackRouteSource.includes("if (!auth.authUserId)") &&
|
||||
feedbackRouteSource.includes("if (res.status === 401 || res.status === 403)") &&
|
||||
!feedbackRouteSource.includes("const authError = requireBackendPaymentAuth(auth);"),
|
||||
"feedback GET proxy must return an empty optional list instead of surfacing a 401 when no Supabase user identity is verified",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes("<UserFeedbackStatusButton") &&
|
||||
dashboardSource.includes("feedbackRefreshKey") &&
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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",
|
||||
"dashboard",
|
||||
"scan-terminal",
|
||||
"ProductAccessRequired.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const unauthenticatedGate = source.slice(
|
||||
source.indexOf("function UnauthenticatedGate"),
|
||||
source.indexOf("export function ProductAccessRequired"),
|
||||
);
|
||||
const subscriptionGate = source.slice(
|
||||
source.indexOf("function SubscriptionGate"),
|
||||
source.indexOf("// ─── Layer 1 fallback"),
|
||||
);
|
||||
|
||||
const accessCard = unauthenticatedGate.slice(
|
||||
unauthenticatedGate.indexOf('<section className="grid flex-1'),
|
||||
);
|
||||
|
||||
assert(
|
||||
!accessCard.includes("<Link"),
|
||||
"signed-out terminal gate must use native anchors so stale client router state cannot block login navigation",
|
||||
);
|
||||
assert(
|
||||
accessCard.includes('href="/auth/login?next=%2Fterminal"') &&
|
||||
accessCard.includes(
|
||||
'href="/auth/login?next=%2Fterminal&mode=signup"',
|
||||
) &&
|
||||
accessCard.includes('href="/"'),
|
||||
"signed-out terminal gate must keep hard navigation targets for login, signup, and product overview",
|
||||
);
|
||||
assert(
|
||||
subscriptionGate.includes("订阅已过期") &&
|
||||
subscriptionGate.includes("终端访问已暂停") &&
|
||||
subscriptionGate.includes("已有付款但未恢复") &&
|
||||
subscriptionGate.includes("刷新权限") &&
|
||||
subscriptionGate.includes("账户中心") &&
|
||||
source.includes("续费并恢复访问"),
|
||||
"authenticated expired subscription gate must clearly say access is paused and show renewal/recovery actions",
|
||||
);
|
||||
assert(
|
||||
source.includes("subscriptionExpiresAt") &&
|
||||
source.includes("isExpiredSubscription"),
|
||||
"subscription gate must distinguish expired users from generic inactive accounts when an expiry timestamp is available",
|
||||
);
|
||||
}
|
||||
@@ -40,10 +40,10 @@ export function runTests() {
|
||||
|
||||
const allDocsZh = publicDocSlugs.map((slug) => pageText(slug, "zh-CN")).join("\n");
|
||||
const allDocsEn = publicDocSlugs.map((slug) => pageText(slug, "en-US")).join("\n");
|
||||
for (const staleTerm of ["从地图进入", "机会榜", "日历", "EMOS", "LGBM", "城市决策卡", "付费判断台", "刷新锁"]) {
|
||||
for (const staleTerm of ["从地图进入", "机会榜", "日历", "LGBM", "城市决策卡", "付费判断台", "刷新锁"]) {
|
||||
assert(!allDocsZh.includes(staleTerm), `public Chinese docs should not expose stale term: ${staleTerm}`);
|
||||
}
|
||||
for (const staleTerm of ["map-launched", "opportunity board", "calendar", "EMOS", "LGBM", "city decision card", "paid decision workspace", "refresh lock"]) {
|
||||
for (const staleTerm of ["map-launched", "opportunity board", "calendar", "LGBM", "city decision card", "paid decision workspace", "refresh lock"]) {
|
||||
assert(!allDocsEn.includes(staleTerm), `public English docs should not expose stale term: ${staleTerm}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,13 @@ import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildCityListCacheControl,
|
||||
buildScanTerminalResponseCacheControl,
|
||||
buildCityDetailProxyCachePolicy,
|
||||
buildForceRefreshProxyCachePolicy,
|
||||
buildPublicEdgeCacheControl,
|
||||
isForceRefreshValue,
|
||||
NO_STORE_CACHE_CONTROL,
|
||||
} from "@/lib/proxy-cache-policy";
|
||||
|
||||
export function runTests() {
|
||||
@@ -17,15 +21,43 @@ export function runTests() {
|
||||
assert.match(forced.responseCacheControl, /no-store/);
|
||||
assert.equal(forced.revalidateSeconds, undefined);
|
||||
|
||||
const cached = buildCityDetailProxyCachePolicy("false", 15);
|
||||
const cached = buildCityDetailProxyCachePolicy("false");
|
||||
assert.equal(cached.fetchMode, "revalidate");
|
||||
assert.equal(cached.revalidateSeconds, 15);
|
||||
assert.match(cached.responseCacheControl, /max-age=15/);
|
||||
assert.match(cached.responseCacheControl, /s-maxage=15/);
|
||||
assert.equal(cached.revalidateSeconds, 60);
|
||||
assert.match(cached.responseCacheControl, /max-age=30/);
|
||||
assert.match(cached.responseCacheControl, /s-maxage=60/);
|
||||
assert.match(cached.responseCacheControl, /stale-while-revalidate=300/);
|
||||
|
||||
assert.equal(
|
||||
buildPublicEdgeCacheControl(60, 300),
|
||||
"public, max-age=0, s-maxage=60, stale-while-revalidate=300",
|
||||
);
|
||||
assert.equal(
|
||||
buildCityListCacheControl(),
|
||||
"public, max-age=0, s-maxage=300, stale-while-revalidate=3600",
|
||||
);
|
||||
|
||||
const scanForced = buildForceRefreshProxyCachePolicy("true", 10);
|
||||
assert.equal(scanForced.fetchMode, "no-store");
|
||||
|
||||
const normalScanCache = "public, max-age=0, s-maxage=300, stale-while-revalidate=900";
|
||||
assert.equal(
|
||||
buildScanTerminalResponseCacheControl({ status: "ready", stale: false }, normalScanCache),
|
||||
normalScanCache,
|
||||
);
|
||||
assert.equal(
|
||||
buildScanTerminalResponseCacheControl({ status: "failed", stale: false }, normalScanCache),
|
||||
NO_STORE_CACHE_CONTROL,
|
||||
);
|
||||
assert.equal(
|
||||
buildScanTerminalResponseCacheControl({ status: "partial", stale: false }, normalScanCache),
|
||||
NO_STORE_CACHE_CONTROL,
|
||||
);
|
||||
assert.equal(
|
||||
buildScanTerminalResponseCacheControl({ status: "ready", stale: true }, normalScanCache),
|
||||
NO_STORE_CACHE_CONTROL,
|
||||
);
|
||||
|
||||
const scanTerminalProxySource = fs.readFileSync(
|
||||
path.join(process.cwd(), "app", "api", "scan", "terminal", "route.ts"),
|
||||
"utf8",
|
||||
@@ -40,6 +72,21 @@ export function runTests() {
|
||||
/buildForceRefreshProxyCachePolicy\(forceRefresh,\s*10\)/,
|
||||
"scan terminal proxy must not use the old 10 second edge cache because it over-drives the slow scan endpoint",
|
||||
);
|
||||
assert.match(
|
||||
scanTerminalProxySource,
|
||||
/cacheControlForData:\s*\(data\)\s*=>\s*buildScanTerminalResponseCacheControl/,
|
||||
"scan terminal proxy must not CDN-cache failed, stale, or partial business payloads",
|
||||
);
|
||||
assert.match(
|
||||
scanTerminalProxySource,
|
||||
/fetchCache:\s*"no-store"/,
|
||||
"scan terminal proxy must not put failed or initializing payloads into the Next data cache",
|
||||
);
|
||||
assert.match(
|
||||
scanTerminalProxySource,
|
||||
/"since_snapshot_id"/,
|
||||
"scan terminal proxy should forward incremental scan diff snapshot ids to the backend",
|
||||
);
|
||||
|
||||
const scanTerminalClientSource = fs.readFileSync(
|
||||
path.join(
|
||||
@@ -66,6 +113,11 @@ export function runTests() {
|
||||
/params\.set\("_v",\s*SCAN_TERMINAL_PAYLOAD_VERSION\)/,
|
||||
"scan terminal client should vary the CDN cache key without changing backend scan filters",
|
||||
);
|
||||
assert.match(
|
||||
scanTerminalClientSource,
|
||||
/params\.set\("diff",\s*"true"\)/,
|
||||
"scan terminal client should request incremental scan diffs when it has a previous snapshot",
|
||||
);
|
||||
|
||||
const overviewProxySource = fs.readFileSync(
|
||||
path.join(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user