Compare commits

..

145 Commits

Author SHA1 Message Date
2569718930@qq.com 961c9bd37c Show feedback reward details in account 2026-06-08 16:25:32 +08:00
2569718930@qq.com b0fec20370 Document feedback reward account details 2026-06-08 16:15:22 +08:00
2569718930@qq.com 8f6925108c Reduce polling and batch observation writes 2026-06-08 15:12:41 +08:00
2569718930@qq.com 96dde3b50e Add observation collector health status 2026-06-08 14:39:49 +08:00
2569718930@qq.com 505b75d6ce Throttle chart stale retry badges 2026-06-08 13:59:38 +08:00
2569718930@qq.com 956cb8a8e3 Use usable recent DEB training rankings 2026-06-08 13:37:20 +08:00
2569718930@qq.com 721173aea3 Isolate observation collector from web API 2026-06-08 13:19:52 +08:00
2569718930@qq.com ec84243fb9 Show usable recent DEB accuracy 2026-06-08 12:27:20 +08:00
2569718930@qq.com e1054e684a Return stale cache for duplicate force refresh 2026-06-08 01:20:59 +08:00
2569718930@qq.com 58a2881fc8 Return stale city panel cache while refreshing 2026-06-08 01:11:11 +08:00
2569718930@qq.com 4f4617ba0a Guard DEB bucket calibration 2026-06-08 00:59:16 +08:00
2569718930@qq.com 26775693e7 Avoid blocking city force refresh 2026-06-08 00:33:57 +08:00
2569718930@qq.com 139e5dbb06 Fix DEB quality daily payload 2026-06-08 00:12:14 +08:00
2569718930@qq.com bbe3871b7c Expose DEB quality guidance 2026-06-07 23:57:47 +08:00
2569718930@qq.com 4389d4a40d Add DEB city trust tiers 2026-06-07 23:32:23 +08:00
2569718930@qq.com c3f9b974c3 Cap DEB training version backtest 2026-06-07 23:05:45 +08:00
2569718930@qq.com 39026344dc Retry CI for DEB calibration update 2026-06-07 22:55:43 +08:00
2569718930@qq.com 41e819735d Improve DEB calibration and training metrics 2026-06-07 22:51:18 +08:00
2569718930@qq.com 12935f502f Fix README terminal image reference 2026-06-07 21:29:49 +08:00
2569718930@qq.com c4b47a81a7 Update README product status 2026-06-07 21:22:29 +08:00
2569718930@qq.com 27e4f1efe0 Show full Gaussian probability tooltip 2026-06-07 13:02:16 +08:00
2569718930@qq.com f66127da44 Refine pricing display and Gaussian context 2026-06-07 01:08:13 +08:00
2569718930@qq.com fb4a144fcf Use static terminal update announcements 2026-06-07 00:32:11 +08:00
2569718930@qq.com 3951735259 Add terminal update announcements 2026-06-07 00:04:08 +08:00
2569718930@qq.com a40b1ba137 feat: add UserFeedbackModal component and supporting unit tests 2026-06-06 23:30:08 +08:00
2569718930@qq.com 555d3fa68f Show user feedback history in account 2026-06-06 23:15:55 +08:00
2569718930@qq.com f7625218ae Add user feedback status notifications 2026-06-06 22:55:15 +08:00
2569718930@qq.com a5de604d85 Refine landing and auth entry design 2026-06-06 22:21:42 +08:00
2569718930@qq.com 11fd482378 Refresh product docs and observation freshness 2026-06-06 21:46:23 +08:00
2569718930@qq.com 524ae7c8cf fix: refine landing and auth entry design 2026-06-06 20:43:31 +08:00
2569718930@qq.com 98b69b180a fix: improve docs page readability 2026-06-06 19:52:27 +08:00
2569718930@qq.com d354fb12dd docs: add codex project instructions 2026-06-06 19:37:03 +08:00
2569718930@qq.com 2f5e4a40dd ci: avoid buildx setup for image publish 2026-06-06 19:22:46 +08:00
2569718930@qq.com 7a38e3588a feat: productize landing and chart education 2026-06-06 19:17:24 +08:00
2569718930@qq.com c2f9bc5994 fix: hide probability bands on temperature chart 2026-06-06 18:00:28 +08:00
2569718930@qq.com 89f77b8db4 fix: align hong kong compact hko stat 2026-06-06 17:01:39 +08:00
2569718930@qq.com 2766b6463c fix: keep chart runway history current 2026-06-06 13:37:00 +08:00
2569718930@qq.com 2a6c826f50 fix: restrict telegram pricing to private group 2026-06-05 17:26:43 +08:00
2569718930@qq.com 09979f3bb5 fix: handle auth callback failures 2026-06-05 16:50:52 +08:00
2569718930@qq.com e6a1d14b09 fix: improve mobile terminal region tabs 2026-06-05 16:08:34 +08:00
2569718930@qq.com 2af1cf9cd8 feat: add observation collector and source gate 2026-06-05 14:38:58 +08:00
2569718930@qq.com c3fd0582ad fix: improve chart tooltip and metar refresh 2026-06-03 11:41:16 +08:00
2569718930@qq.com 00f96150c5 fix: prevent slow chart proxy 504s 2026-06-03 02:34:27 +08:00
2569718930@qq.com 92307a9f97 fix: force frontend proxy to internal backend 2026-06-03 02:13:02 +08:00
2569718930@qq.com a440f3d2b5 fix: hide upstream html payment errors 2026-06-03 01:58:19 +08:00
2569718930@qq.com afffacb529 fix: recheck public smoke before rollback 2026-06-02 21:54:18 +08:00
2569718930@qq.com 137b0d8b3a fix: apply telegram group member monthly price 2026-06-02 21:35:35 +08:00
2569718930@qq.com 876417c5ec fix: raise web container open file limit 2026-06-02 16:19:52 +08:00
2569718930@qq.com 35a93cbd9e fix: allow closing terminal chart slots 2026-06-02 08:45:25 +08:00
2569718930@qq.com 9d136a337c Speed up entitlement auth sync 2026-06-01 22:36:52 +08:00
2569718930@qq.com fc0a8b8ff5 Harden wallet challenge proxy 2026-06-01 22:15:38 +08:00
2569718930@qq.com 8fc9f3ae1a Add terminal feedback workflow 2026-06-01 21:10:37 +08:00
2569718930@qq.com f1e577ea8a 优化终端侧边栏展开性能 2026-06-01 16:15:20 +08:00
2569718930@qq.com 2fd8027a41 Fix deploy stdin token handling 2026-06-01 14:44:53 +08:00
2569718930@qq.com 6407d0a29d Pass deploy GHCR token over stdin 2026-06-01 14:30:41 +08:00
2569718930@qq.com 573353421b Retry deploy compose recreate races 2026-06-01 14:21:43 +08:00
2569718930@qq.com ac5b512a72 Reduce terminal chart update timeouts 2026-06-01 14:07:13 +08:00
2569718930@qq.com 31fb1b3f28 Fix ops payment incident grouping 2026-06-01 13:49:47 +08:00
2569718930@qq.com 49cb0bb42d Prevent terminal auth spinner deadlock 2026-06-01 12:36:29 +08:00
2569718930@qq.com fb6c354317 Avoid terminal full-detail fallback overload 2026-06-01 12:06:46 +08:00
2569718930@qq.com fee402145e Slim terminal chart detail batches 2026-06-01 11:37:16 +08:00
2569718930@qq.com d1633331bc Load terminal chart canvas eagerly 2026-06-01 07:05:08 +08:00
2569718930@qq.com 6e00160897 Version scan terminal cache key 2026-06-01 06:46:34 +08:00
2569718930@qq.com 6bc53c6e5d Slim deferred scan runway history 2026-06-01 06:25:27 +08:00
2569718930@qq.com 7625a99020 Cache terminal city registry fallback 2026-06-01 06:09:44 +08:00
2569718930@qq.com 3447a043ff Preload terminal chart canvas 2026-06-01 05:56:09 +08:00
2569718930@qq.com 10cf557b11 Batch deferred chart detail loads 2026-06-01 02:56:13 +08:00
2569718930@qq.com eaf17c7a32 Align scan prewarm observability test 2026-06-01 02:35:23 +08:00
2569718930@qq.com 14526d6ce8 Prewarm default scan terminal payload 2026-06-01 02:06:40 +08:00
2569718930@qq.com 9a0d7ae444 Persist scan terminal cache in Redis 2026-06-01 01:53:54 +08:00
2569718930@qq.com de5083ef18 Defer secondary chart detail loads 2026-06-01 01:27:53 +08:00
2569718930@qq.com 2424b68532 Compact scan runway history payload 2026-06-01 01:03:33 +08:00
2569718930@qq.com ff2a3da100 Use longer timeout for scan prewarm 2026-06-01 00:30:25 +08:00
2569718930@qq.com 7040598e26 Prewarm terminal scan payload 2026-06-01 00:14:12 +08:00
2569718930@qq.com 22f4ac1f26 Preserve scan cache on timeout 2026-05-31 23:54:35 +08:00
2569718930@qq.com 3e5ff4ea8f Isolate scan prewarm to web service 2026-05-31 23:31:32 +08:00
2569718930@qq.com 39c7c1903c Shorten cold scan terminal timeout 2026-05-31 22:54:43 +08:00
2569718930@qq.com eecaa48ec2 Limit city detail fallback to batch failures 2026-05-31 22:34:28 +08:00
2569718930@qq.com d911af1225 Reduce duplicate terminal detail loads 2026-05-31 22:14:17 +08:00
2569718930@qq.com 38c6cefb27 Direct resync stale SSE clients 2026-05-31 21:48:09 +08:00
2569718930@qq.com a3df2e00dc Clamp SSE replay window on backend 2026-05-31 21:33:35 +08:00
2569718930@qq.com fde69123cd Merge branch 'codex/signup-trial-reconcile' 2026-05-31 21:25:58 +08:00
2569718930@qq.com 2d963c16f3 Coalesce terminal chart detail loads 2026-05-31 21:17:38 +08:00
2569718930@qq.com 071d749ef0 Reduce terminal detail refresh traffic 2026-05-31 21:01:11 +08:00
2569718930@qq.com 8d552a9279 Scale SSE replay window by visible cities 2026-05-31 20:33:04 +08:00
2569718930@qq.com 9c5a08dc1e Reduce false signup trial risk alerts 2026-05-31 20:21:57 +08:00
2569718930@qq.com ada5f274d3 Debounce terminal SSE subscription reconnects 2026-05-31 20:15:39 +08:00
2569718930@qq.com 78ea0326a5 Return partial city detail batches 2026-05-31 19:51:28 +08:00
2569718930@qq.com d3f444dbf6 Bind PolyWeather services to loopback 2026-05-31 19:46:00 +08:00
2569718930@qq.com 8d26afdec0 Reduce terminal auth and analytics stalls 2026-05-31 19:21:53 +08:00
2569718930@qq.com 5083b7c433 Instrument API timing and reduce detail fallbacks 2026-05-31 18:58:57 +08:00
2569718930@qq.com 668f4d9bd3 Optimize terminal batching and cache guards 2026-05-31 18:28:50 +08:00
2569718930@qq.com a4253c224a Add auth profile timing instrumentation 2026-05-31 18:27:04 +08:00
2569718930@qq.com 46effcd45f Optimize terminal detail loading and Redis replay 2026-05-31 04:32:48 +08:00
2569718930@qq.com 2a6d8748f4 Improve ops monitoring and chart loading 2026-05-31 03:21:58 +08:00
2569718930@qq.com ca72072da0 Optimize terminal startup and deployment smoke checks 2026-05-31 01:56:08 +08:00
2569718930@qq.com bf0fbe1b1d Optimize landing server rendering 2026-05-30 23:43:07 +08:00
2569718930@qq.com 30e9513ce2 Update deploy smoke check test 2026-05-30 23:00:48 +08:00
2569718930@qq.com aec9dd6a54 Harden production deploy smoke checks 2026-05-30 22:51:25 +08:00
2569718930@qq.com 6222f60573 Improve terminal chart loading states 2026-05-30 22:37:08 +08:00
2569718930@qq.com a48a7bcca5 Pass ops admin whitelist to frontend 2026-05-30 22:18:48 +08:00
2569718930@qq.com 74a2d71644 Reduce scan terminal origin load 2026-05-30 21:55:45 +08:00
2569718930@qq.com 9c239c0394 Add login button pending feedback 2026-05-30 21:31:13 +08:00
2569718930@qq.com 383dfe4d0e Harden production deploy stability 2026-05-30 21:21:30 +08:00
2569718930@qq.com 52f92d8650 Speed up terminal auth snapshot loading 2026-05-30 21:08:19 +08:00
2569718930@qq.com d9eea721ef feat: implement /api/auth/me proxy route with subscription-required state and fallback entitlement snapshots 2026-05-30 21:01:48 +08:00
2569718930@qq.com aa583e7440 Add ops runtime secret rotation 2026-05-30 20:33:30 +08:00
2569718930@qq.com e00405df8a Tighten AMSC ops health check 2026-05-30 20:10:14 +08:00
2569718930@qq.com 9bcfd9d1eb Improve landing performance and analytics funnel 2026-05-30 19:50:50 +08:00
2569718930@qq.com 3fcda3f3cd Tighten cities fallback timeout 2026-05-30 19:38:31 +08:00
2569718930@qq.com 345691ad71 Add cities static fallback 2026-05-30 19:32:25 +08:00
2569718930@qq.com e864181c3c Improve terminal entitlement resilience 2026-05-30 19:10:19 +08:00
2569718930@qq.com 097971f107 Fix terminal subscription gate on unknown auth state 2026-05-30 18:34:38 +08:00
2569718930@qq.com 90bc895000 Update referral points pricing 2026-05-30 18:04:36 +08:00
2569718930@qq.com c0b20ed1bf feat: implement TemperatureStatsBars component for displaying thermal data with i18n support 2026-05-30 16:42:03 +08:00
2569718930@qq.com 5470da6b3a feat: implement CitySelectorDropdown for ScanTerminalDashboard with multi-filter search and regional categorization 2026-05-30 16:39:23 +08:00
2569718930@qq.com a03b8095ee Refresh entitlement after payment confirmation 2026-05-30 16:26:32 +08:00
2569718930@qq.com 91896b4dad Prevent duplicate payment confirmations 2026-05-30 16:17:15 +08:00
2569718930@qq.com 44638760b7 feat: implement atomic signup trial claims and referral discount logic with Supabase database schema updates 2026-05-30 16:02:02 +08:00
2569718930@qq.com c0b1edaed0 feat: implement payment proxy routes and add security validation tests for wallet and intent operations 2026-05-30 15:41:15 +08:00
2569718930@qq.com 853a652e9b fix: degrade auth profile on backend outage 2026-05-30 15:06:22 +08:00
2569718930@qq.com c79619898a fix: avoid terminal cold-start paywall race 2026-05-30 14:48:09 +08:00
2569718930@qq.com 1c250906ed fix: keep terminal stable across auth refresh 2026-05-30 14:33:26 +08:00
2569718930@qq.com ed8c898e0e fix: retry deploy smoke checks 2026-05-30 14:24:02 +08:00
2569718930@qq.com 57240e02ee fix: require auth token for manual payments 2026-05-30 14:09:57 +08:00
2569718930@qq.com c26f89f134 feat: implement terminal authentication bootstrap and integrate into ScanTerminalDashboard 2026-05-30 00:05:19 +08:00
2569718930@qq.com b3a63aa9af test: add unit tests for temperature series visibility policy and peak glow state logic 2026-05-29 23:21:17 +08:00
2569718930@qq.com 53268560ec test: implement visibility policy and chart logic verification tests for temperature series 2026-05-29 23:09:16 +08:00
2569718930@qq.com 68416b058e feat: implement temperature chart visibility logic with comprehensive unit testing and analysis service support 2026-05-29 22:40:33 +08:00
2569718930@qq.com 95c2258db0 Fix Turkey MGM panel history 2026-05-29 21:56:57 +08:00
2569718930@qq.com 8507afedd6 Fix account payment layout responsiveness 2026-05-29 21:34:36 +08:00
2569718930@qq.com 3e24080466 feat: implement AccountCenter dashboard with subscription and payment management features 2026-05-29 21:23:12 +08:00
2569718930@qq.com 17ddd835e6 Fix landing hero illustration overlap 2026-05-29 20:57:59 +08:00
2569718930@qq.com 5228d2b3fd Update landing and weather observation policies 2026-05-29 20:46:35 +08:00
2569718930@qq.com 2f039abb2c Add event fallback for referral program 2026-05-29 19:34:56 +08:00
2569718930@qq.com 522e35de7f Add trial and referral subscription program 2026-05-29 19:24:46 +08:00
2569718930@qq.com f8f5035225 Fix runway chart collapse and ops cleanup 2026-05-29 18:40:29 +08:00
2569718930@qq.com 23b5fafb25 Reduce idle payment confirm polling 2026-05-29 18:14:59 +08:00
2569718930@qq.com 2269aaefd7 Reduce Supabase disk IO 2026-05-29 17:22:33 +08:00
2569718930@qq.com 9e3a4e2f45 Fix WU historical current observation merge 2026-05-29 17:20:07 +08:00
2569718930@qq.com 8f52b1d80c Add password reset flow 2026-05-29 12:28:08 +08:00
2569718930@qq.com 1d9f0033a6 Tighten payment tx validation before submit 2026-05-29 12:11:19 +08:00
2569718930@qq.com 618bac8b57 feat: add live temperature threshold chart logic and components for runway monitoring 2026-05-29 02:10:07 +08:00
2569718930@qq.com f2e90fbcda feat: support ethereum usdc payment route 2026-05-29 01:57:42 +08:00
2569718930@qq.com 3cc2251b9b fix: translate settlement runway chart labels 2026-05-28 22:44:36 +08:00
312 changed files with 37103 additions and 3884 deletions
+13 -1
View File
@@ -171,13 +171,24 @@ POLYWEATHER_BOT_DEB_QUERY_COST=1
# Payments
POLYWEATHER_PAYMENT_ENABLED=false
# Default / legacy checkout chain. Keep Polygon as the default because the
# deployed checkout contract currently lives there.
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com
# Optional multi-chain RPC map. Required when accepting non-default chain
# transfers such as Ethereum mainnet USDC.
# Example:
# POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
# Multi-token / multi-chain payment routes. Token rows can include:
# chain_id, chain_code, chain_name, receiver_contract, direct_receiver_address,
# supports_contract_checkout, supports_direct_transfer, confirmations,
# explorer_tx_url.
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
@@ -189,7 +200,8 @@ POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8}
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
POLYMARKET_SIGNAL_EDGE_PCT=2
+1
View File
@@ -34,6 +34,7 @@ METEOBLUE_API_KEY=
# Wallet / payments
########################################
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
+41 -38
View File
@@ -73,9 +73,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
@@ -83,55 +80,61 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push backend
if: matrix.image.name == 'backend'
uses: docker/build-push-action@v6
with:
context: ${{ matrix.image.context }}
file: ${{ matrix.image.file }}
push: true
tags: |
${{ matrix.image.tag }}:latest
${{ matrix.image.tag }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push image
env:
IMAGE_NAME: ${{ matrix.image.name }}
IMAGE_CONTEXT: ${{ matrix.image.context }}
IMAGE_FILE: ${{ matrix.image.file }}
IMAGE_TAG: ${{ matrix.image.tag }}
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
run: |
set -euo pipefail
- name: Build and push frontend
if: matrix.image.name == 'frontend'
uses: docker/build-push-action@v6
with:
context: ${{ matrix.image.context }}
file: ${{ matrix.image.file }}
push: true
tags: |
${{ matrix.image.tag }}:latest
${{ matrix.image.tag }}:${{ github.sha }}
build-args: |
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_SITE_URL=${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags=(-t "${IMAGE_TAG}:latest" -t "${IMAGE_TAG}:${GITHUB_SHA}")
build_args=()
if [ "${IMAGE_NAME}" = "frontend" ]; then
build_args=(
--build-arg "NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}"
--build-arg "NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}"
--build-arg "NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}"
--build-arg "NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL}"
--build-arg "NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false"
--build-arg "NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID}"
--build-arg "NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL}"
--build-arg "NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS}"
)
fi
docker build -f "${IMAGE_FILE}" "${tags[@]}" "${build_args[@]}" "${IMAGE_CONTEXT}"
docker push "${IMAGE_TAG}:latest"
docker push "${IMAGE_TAG}:${GITHUB_SHA}"
deploy:
needs: [build-and-push]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
concurrency:
group: polyweather-production-deploy
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to VPS
env:
GHCR_PAT: ${{ secrets.GHCR_PAT }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
scp -o StrictHostKeyChecking=accept-new deploy.sh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/deploy.sh
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
bash /tmp/deploy.sh '${{ secrets.GHCR_PAT }}' '${{ github.sha }}'
printf '%s\n' "$GHCR_PAT" | ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
bash /tmp/deploy.sh '${{ github.sha }}'
"
+78
View File
@@ -0,0 +1,78 @@
# PolyWeather Agent Instructions
## 语言和沟通
- 默认用中文回复用户。
- 直接说明正在做什么、查到了什么、下一步是什么;不要写空泛客套话。
- 如果用户要求“提交推送”“部署”“看日志”,必须在本地验证后再提交、推送,并检查 GitHub Actions 或线上状态。
- 不要把多个不相关任务混在一个结论里;遇到新方向时,建议用户在同一个 Project 下开新 Thread。
## 项目和线程使用
- 一个 Project 对应 PolyWeather 这个共享代码库和长期方向。
- 每个具体任务使用一条独立 Thread / Chat,例如:
- 落地页与产品包装
- 观测数据采集与 SSE patch
- Telegram 推送
- 付款与会员
- 部署、CI、服务器状态
- 同一个 Project 下的 Thread 共享文件夹和 `AGENTS.md`,但上下文分开,避免旧问题影响新任务判断。
## 代码工作原则
- 先读现有代码和配置,再改动;优先沿用项目已有模式。
- 使用 `rg` / `rg --files` 查找文件和文本。
- 手动编辑文件使用 `apply_patch`
- 不要回滚用户或其他 agent 已经做过的无关改动。
- 只改和当前任务直接相关的文件,避免顺手重构。
- 新增复杂逻辑时补充聚焦测试;窄改动保持验证范围匹配风险。
## 前端约定
- 前端位于 `frontend/`,使用 Next.js、React、TypeScript。
- UI 修改必须关注移动端响应式、文本不重叠、按钮和标签不溢出。
- 图表、终端、详情面板等工作界面应保持信息密度和可扫描性,避免营销式装饰。
- 常用验证:
- `cd frontend && npm run test:business`
- `cd frontend && npm run typecheck`
- 必要时启动本地预览并用浏览器检查桌面和移动视口;检查完关闭本地端口。
## 后端和数据约定
- Python 代码主要位于 `src/``web/``tests/`
- 观测数据刷新应以数据源原生频率为准,避免 Web、collector、Telegram 同时强刷同一外部源。
- Telegram 默认只读最新缓存/DB;除非完全没有缓存,才允许兜底刷新。
- 对外部源调用要考虑 singleflight、冷却、缓存和失败降级,避免 502/408 或 Supabase/磁盘 IO 压力。
- 常用验证:
- `python -m ruff check .`
- `python -m pytest`
## CI、提交和部署
- `main` push 会触发 `.github/workflows/ci.yml`
- `python-quality`
- `frontend-quality`
- `build-and-push`
- `deploy`
- 提交前至少运行和改动相关的验证;推送前确认 `git status --short`
- 推送后检查 GitHub Actions 最新 run;如果失败,先定位失败 job 和 step,再修改。
- 线上 smoke check 优先检查:
- `https://api.polyweather.top/healthz`
- `https://polyweather.top/`
- 相关页面或 API 路径
## 产品方向备忘
- PolyWeather 当前重点不是售卖 API。
- 核心差异化是结算源优先、实时观测源、跑道/城市细粒度温度、SSE patch、Telegram 缓存读取和面向交易/预测市场的解释能力。
- 公开包装、教育内容、图表变量完整度和付费分层可以加强,但不要把产品定位改成通用天气 API。
## Memory 说明
- `AGENTS.md` 是项目内显式规则,跟随仓库和 Project。
- Memory 是用户账号级偏好设置,agent 不能代替用户开启。
- 建议在 Codex / ChatGPT 设置中开启 Memory,并保存长期偏好,例如:
- PolyWeather 项目默认中文回复。
- 每个任务开独立 Thread。
- 修改后优先验证、提交、推送并检查部署状态。
- 不要主动把 PolyWeather 包装成 API 售卖产品。
+22 -15
View File
@@ -5,13 +5,13 @@ Production weather-intelligence stack for temperature settlement markets.
Official dashboard: [polyweather.top](https://polyweather.top/)
中文说明: [README_ZH.md](README_ZH.md)
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday analysis, calibrated probability, model stack, TAF, settlement sources, history, and extension).
Public docs center: `/docs/intro` on the main site (bilingual product documentation for the current terminal, chart reading, realtime source cadence, settlement stations, and the browser extension).
## Product Screenshots
### Realtime Terminal
![PolyWeather realtime terminal](frontend/public/static/web.png)
![PolyWeather realtime terminal](frontend/public/static/web.webp)
### Telegram Runway Alerts
@@ -21,22 +21,21 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
[![Star History Chart](https://api.star-history.com/svg?repos=yangyuan-zhen/PolyWeather&type=Date)](https://star-history.com/#yangyuan-zhen/PolyWeather&Date)
## Product Status (2026-05-28)
## Product Status (2026-06-07)
- Subscription live: `Pro Monthly 10 USDC`.
- Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards.
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, max `3 USDC`).
- Weekly leaderboard rewards restructured: smaller point bonuses for winners (200/100/50), all active users receive participation rewards.
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e).
- Subscription live: `Pro Monthly 29.9 USDC / 30 days` and `Pro Quarterly 79.9 USDC / 90 days`.
- Referral pricing live: invited users can get the first monthly Pro at `20 USDC`; inviters receive `3500` points after a valid first Pro payment, capped at 10 paid invites per month.
- `/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.
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e) plus Ethereum mainnet USDC direct-transfer confirmation.
- Auto-reconciliation live: event listener + periodic confirm loop.
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
- Ops dashboard live: `/ops` for memberships, leaderboard, user feedback triage, manual point grants, and payment incident triage.
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
- Realtime terminal live: visible city charts subscribe through `/api/events?cities=...&since_revision=...`, receive `city_observation_patch.v1` SSE patches, and replay short gaps from Redis Stream in production or SQLite fallback in local/single-node mode.
- Chart refresh is observation-driven: live patches merge into the current chart without a loading overlay; only visible charts run a 60s no-patch fallback, and returning from a background browser tab triggers a foreground catch-up refresh.
- Temperature charts default to All Day, keep an optional Peak window derived from the DEB hourly path, and render all timestamps in the selected city's local time.
- The chart core has been split into focused logic/canvas/state modules; Recharts now receives explicit measured dimensions to avoid 0x0 rendering and disappearing curves.
- DEB hourly consensus (`deb_hourly_consensus.v1`) is now the preferred hourly forecast path for peak-window detection and chart overlays; DEB remains a forecast curve, never an observation source.
- Legacy Gaussian probability is rendered as horizontal probability bands and a `mu` reference line on the chart, rather than as a fake time-series curve.
- Legacy Gaussian probability stays out of the default temperature chart surface; hover tooltips show `Gaussian μ` plus the full bucket distribution by temperature range.
- Settlement runway curves are visible by default for AMSC/AMOS cities; the configured settlement runway is highlighted and auxiliary runways are shown as secondary context.
- Hong Kong uses CoWIN station `6087` (Po Leung Kuk Choi Kai Yau School) as the 1-minute reference-station curve, with HKO 10-minute observations kept as the official meteorological layer.
- Telegram airport/runway pushes are bilingual by default and use settlement-endpoint runway temperatures for slope/current/summary copy.
@@ -44,10 +43,10 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
- 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 city cards now combine settlement observations, DEB hourly consensus, model cluster context, calibrated probability, and market-bucket mapping without blocking the chart on AI text generation.
- Terminal chart/detail workflow now combines settlement observations, DEB hourly consensus, model context, probability distribution tooltips, and market-bucket mapping without blocking the chart on AI text generation.
- Terminal data uses page memory cache, browser `localStorage`, backend short-TTL cache, SSE patch replay, and foreground refresh so returning from another tab restores the latest visible chart state quickly.
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
- The card label “model-market difference means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
- 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.
- 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.
@@ -73,9 +72,11 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
- 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.
- Adds city decision cards that combine live observations, expected-high centers, full market-bucket mapping, and model-market difference in one view.
- Adds terminal chart/detail workflows that combine live observations, DEB-centered high-temperature context, market-bucket mapping, and model-market difference.
- Shows calibrated Gaussian context in chart tooltips as `mu` plus the full temperature-range probability distribution, without reintroducing probability bands into the main temperature view.
- Reuses one analysis core across web dashboard and Telegram bot.
- Adds payment audit trails, replay tooling, and incident visibility in ops.
- Adds an in-app feedback loop with chart context, user-visible feedback status, ops triage, and manual point rewards for useful reports and suggestions.
- Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules.
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
- Adds official nearby-network and runway-level enhancement layers for China, Japan, Korea (AMOS runway sensors for Seoul/Busan), Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
@@ -102,7 +103,7 @@ flowchart LR
API --> SSE["SSE /api/events"]
WX --> SSE
SSE --> EVENT["Redis Stream / SQLite Event Log"]
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
ANA --> PAY["Payment State (Multi-chain Intent + Event + Confirm Loop)"]
ANA --> STATE["SQLite runtime state"]
```
@@ -131,6 +132,8 @@ npm run dev
## Recent Highlights
- Gaussian probability tooltip now lists the full temperature-range distribution instead of only the highest-probability bucket, while the main chart remains focused on observations and forecasts.
- User feedback is now a product loop: terminal submissions attach chart context, users can track status in-app, and ops can reward useful feedback with points.
- Airport-linked contracts use the METAR / airport primary observing site as the settlement anchor. Wunderground pages are reference/history pages, not stations.
- Taipei and Shenzhen retain their explicitly configured station history pages for reconciliation, but the docs avoid describing Wunderground itself as a physical station.
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
@@ -198,6 +201,10 @@ docker compose logs -f polyweather | egrep "payment event loop started|payment c
curl http://127.0.0.1:8000/api/payments/runtime
```
### Payment chains
Production payment routes are configured by the backend. Polygon remains the default checkout-contract chain, while Ethereum mainnet USDC can be enabled as a direct-transfer route so users who pay on their wallet default network are still confirmed by `intent.chain_id`.
## Telegram Commands
| Command | Purpose |
@@ -236,4 +243,4 @@ curl http://127.0.0.1:8000/api/payments/runtime
## Version
- Version: `v1.8.1`
- Last Updated: `2026-05-28`
- Last Updated: `2026-06-07`
+21 -14
View File
@@ -8,22 +8,22 @@
### 实时终端
![PolyWeather 实时终端](frontend/public/static/web.png)
![PolyWeather 实时终端](frontend/public/static/web.webp)
### Telegram 跑道推送
![PolyWeather Telegram 跑道推送](frontend/public/static/tel.png)
## 当前产品状态(2026-05-28
## 当前产品状态(2026-06-07
- 已上线订阅制:`Pro 月付 10 USDC`
- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖
- `/city``/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,最多抵 `3 USDC`
- 周榜奖励已改造:降低赢家积分加成 (200/100/50),所有周活跃用户均享参与奖
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。
- 已上线订阅制:`Pro 月付 29.9 USDC / 30 天``Pro 季度 79.9 USDC / 90 天`
- 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分
- `/city``/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。真实、有上下文、有价值的用户反馈也可通过运营后台人工奖励积分
- 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e+ Ethereum 主网 USDC 直转确认
- 已上线自动补单:事件监听 + 周期确认双链路。
- 已上线支付运行态与审计接口:`/api/payments/runtime`
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。
- 已上线轻量运营后台:`/ops`(会员、用户反馈处理、积分、补分、支付异常单)。
- 已上线轻量可观测性:`/healthz``/api/system/status``/metrics`
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。
- 实时终端已切换到可重放事件流:可见城市图表通过 `/api/events?cities=...&since_revision=...` 订阅 `city_observation_patch.v1`,生产环境使用 Redis Stream 做短窗口 replay,本地/单进程可回退 SQLite event log。
@@ -31,7 +31,7 @@
- 城市图表默认展示“全天”,可选“高温”窗口由 DEB hourly path 推导;所有图表横轴都按城市当地时间展示,不按用户浏览器时区。
- 核心图表组件已拆分为逻辑、状态与 canvas 渲染模块;Recharts 使用 `ResizeObserver` 后的明确宽高,规避 0x0 渲染和长时间挂页后曲线消失。
- DEB hourly consensus`deb_hourly_consensus.v1`)已作为峰值窗口和图表 DEB 曲线的优先小时路径;DEB 仍然是预测曲线,不作为实测来源。
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不再伪装成一条时间序列曲线
- legacy 高斯概率不再占用默认温度图主视图;hover tooltip 会展示 `Gaussian μ` 和完整温度区间概率分布
- AMSC/AMOS 城市的结算跑道曲线默认展示并高亮,辅助跑道作为弱化曲线保留;釜山单跑道只展示 `SR/SL` 结算跑道,不再重复显示 AMOS 聚合线。
- 香港默认展示 CoWIN `6087`(保良局陈守仁小学)1 分钟参考站曲线,HKO 10 分钟实测保留为官方气象层。
- Telegram 机场/跑道推送默认中英文双语,并统一使用结算端点跑道温度计算当前值、15 分钟趋势和文案。
@@ -49,10 +49,10 @@
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与运行态 heartbeat。
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
- 终端城市卡已改为结构化实况 + DEB hourly consensus + 多模型集群 + 校准概率 + 市场温度桶,不再让图表等待 AI 文案生成。
- 终端图表/详情工作流已改为结构化实况 + DEB hourly consensus + 多模型集群 + 概率分布 tooltip + 市场温度桶,不再让图表等待 AI 文案生成。
- 终端数据同时使用页面内存缓存、浏览器 `localStorage`、后端短 TTL 缓存、SSE patch replay 和前台恢复刷新;从其他选项卡切回时会优先恢复最新可见图表状态。
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
- 市场信号中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
- 概率区已改为”校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS),模型共识作为辅助参考。
- 今日日内结构解读以规则与结构化信号为主,AI 文案只作为可降级辅助层,不替代实测、DEB、TAF 或结算逻辑。
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`
@@ -73,9 +73,11 @@
- DEBDynamic Error Balancing)融合多模型最高温。
- 构建 DEB 加权小时共识曲线,用于峰值窗口判断和图表默认 DEB 展示。
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。
- 地图城市决策把结构化实况、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示
- 天气决策把结构化实况、DEB 高温路径、完整市场温度桶和模型-市场差放进图表/详情工作流
- 图表 tooltip 展示校准高斯上下文:`mu` 加完整温度区间概率分布,不把概率温度带重新放回主图。
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
- 已上线站内反馈闭环:提交反馈时自动附带图表上下文,用户可查看处理状态,运营后台可为有价值反馈人工奖励积分。
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
## 参考架构
@@ -126,6 +128,11 @@ npm ci
npm run dev
```
## 近期更新
- 高斯概率 tooltip 已改为展示完整温度区间概率分布,不再只显示最高概率的单个区间;主图继续聚焦实测和预测曲线。
- 用户反馈已形成产品闭环:终端提交会自动附带图表上下文,用户可在站内查看处理状态,运营侧可为真实、有建设性的反馈发放积分奖励。
## 运行数据目录(VPS 推荐)
建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住):
@@ -242,7 +249,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
- 模型栈与 DEB[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
- DEB 模型家族去重规则[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
- 发布流程:[RELEASE.md](RELEASE.md)
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
@@ -250,4 +257,4 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
## 当前版本
- 版本:`v1.8.1`
- 文档最后更新:`2026-05-28`
- 文档最后更新:`2026-06-07`
+2 -2
View File
@@ -19,8 +19,8 @@ cities:
- id: new_york
city: New York
country: USA
latitude: 40.7128
longitude: -74.006
latitude: 40.7769
longitude: -73.874
- id: chicago
city: Chicago
country: USA
+222 -24
View File
@@ -1,12 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
GHCR_PAT="$1"
NEW_TAG="${2:-latest}"
NEW_TAG="${1:-latest}"
TAG_FILE="/var/lib/polyweather/.current_tag"
COMPOSE_DIR="/root/PolyWeather"
LOCK_FILE="${POLYWEATHER_DEPLOY_LOCK_FILE:-/var/lock/polyweather-deploy.lock}"
echo "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
GHCR_PAT=""
if ! IFS= read -r GHCR_PAT && [ -z "$GHCR_PAT" ]; then
echo "❌ GHCR token must be provided on stdin"
exit 1
fi
if [ -z "$GHCR_PAT" ]; then
echo "❌ GHCR token must be provided on stdin"
exit 1
fi
mkdir -p "$(dirname "$LOCK_FILE")"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "❌ Another PolyWeather deploy is already running"
exit 1
fi
printf '%s' "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
unset GHCR_PAT
cd "$COMPOSE_DIR"
git fetch origin main && git reset --hard origin/main
@@ -17,7 +35,45 @@ if [ -f "$TAG_FILE" ]; then
echo "Previous tag: $PREVIOUS_TAG"
fi
rollback_to_previous() {
if [ -n "$PREVIOUS_TAG" ]; then
echo "Rolling back to $PREVIOUS_TAG..."
export IMAGE_TAG="$PREVIOUS_TAG"
docker compose pull
compose_up_retry "rollback" -d
echo "✅ Rolled back to $PREVIOUS_TAG"
else
echo "⚠️ No previous tag to rollback to"
fi
}
compose_up_retry() {
local name="$1"
shift
local output=""
for attempt in $(seq 1 6); do
if output=$(docker compose up "$@" 2>&1); then
echo "$output"
return 0
fi
echo "$output"
if echo "$output" | grep -qi "removal of container .* is already in progress"; then
echo "Container removal is still in progress during ${name}; retry ${attempt}/6..."
sleep 5
continue
fi
return 1
done
echo "❌ docker compose up failed for ${name} after retries"
return 1
}
export IMAGE_TAG="$NEW_TAG"
export POLYWEATHER_API_BASE_URL="${POLYWEATHER_FRONTEND_INTERNAL_API_BASE_URL:-http://polyweather_web:8000}"
pull_ok=0
for pull_attempt in $(seq 1 6); do
docker compose pull && pull_ok=1 && break
@@ -28,34 +84,176 @@ if [ "$pull_ok" != "1" ]; then
echo "❌ Image pull failed after retries"
exit 1
fi
docker compose up -d
# Wait for backend to be ready (retry up to 150s)
echo "Waiting for backend..."
for i in $(seq 1 30); do
sleep 5
if curl -fsSo /dev/null --max-time 5 "https://api.polyweather.top/healthz"; then
echo "✅ healthz ready after ${i}x5s"
break
smoke_check() {
local name="$1"
local url="$2"
local timeout="$3"
local attempts="${4:-6}"
local delay="${5:-5}"
local output=""
for i in $(seq 1 "$attempts"); do
if output=$(curl -fsS -w "http=%{http_code} time=%{time_total}" -o /dev/null --max-time "$timeout" "$url" 2>&1); then
echo "$name ($output)"
return 0
fi
if [ "$i" != "$attempts" ]; then
echo " $name retry $i/$attempts... ($output)"
sleep "$delay"
fi
done
echo "$name ($output)"
return 1
}
wait_for_local_service() {
local name="$1"
local url="$2"
local timeout="${3:-5}"
local attempts="${4:-30}"
local delay="${5:-2}"
for i in $(seq 1 "$attempts"); do
if curl -fsSo /dev/null --max-time "$timeout" "$url"; then
echo "$name ready after attempt $i/$attempts"
return 0
fi
if [ "$i" != "$attempts" ]; then
echo " $name warming $i/$attempts..."
sleep "$delay"
fi
done
echo "$name did not become ready"
return 1
}
warm_public_route() {
local name="$1"
local url="$2"
local timeout="${3:-15}"
local attempts="${4:-3}"
local delay="${5:-2}"
for i in $(seq 1 "$attempts"); do
if curl -fsSo /dev/null --max-time "$timeout" "$url"; then
echo "✅ warmed $name"
return 0
fi
if [ "$i" != "$attempts" ]; then
echo " warm $name retry $i/$attempts..."
sleep "$delay"
fi
done
echo "⚠️ warm $name failed"
return 0
}
read_env_file_value() {
local key="$1"
if [ ! -f ".env" ]; then
return 0
fi
echo " retry $i/30..."
done
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
}
validate_frontend_api_base_url() {
local api_base="${POLYWEATHER_API_BASE_URL:-}"
if [ -z "$api_base" ]; then
api_base="$(read_env_file_value "POLYWEATHER_API_BASE_URL")"
fi
local normalized
normalized="$(printf '%s' "$api_base" | tr '[:upper:]' '[:lower:]' | sed 's/[[:space:]]//g; s#/*$##')"
case "$normalized" in
http://polyweather.top|https://polyweather.top|http://www.polyweather.top|https://www.polyweather.top)
echo "❌ POLYWEATHER_API_BASE_URL must not point at the frontend site: $api_base"
echo " Use the internal backend URL http://polyweather_web:8000 or the backend API host https://api.polyweather.top."
exit 1
;;
esac
}
PUBLIC_SMOKE_RECHECK_DELAY_SEC="${POLYWEATHER_PUBLIC_SMOKE_RECHECK_DELAY_SEC:-20}"
run_public_smoke_checks() {
local phase="${1:-initial}"
local failed=0
if [ "$phase" = "recheck" ]; then
smoke_check "healthz recheck" "https://api.polyweather.top/healthz" 20 6 10 || failed=1
smoke_check "frontend cities recheck" "https://polyweather.top/api/cities" 30 8 10 || failed=1
smoke_check "frontend recheck" "https://www.polyweather.top/" 20 6 10 || failed=1
else
smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || failed=1
smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5 || failed=1
smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || failed=1
fi
return "$failed"
}
validate_frontend_api_base_url
echo "Updating Redis dependency..."
compose_up_retry "redis" -d polyweather_redis
echo "Updating backend services..."
compose_up_retry "backend services" -d --no-deps polyweather_web polyweather
echo "Waiting for backend..."
wait_for_local_service "backend healthz" "http://127.0.0.1:8000/healthz" 5 30 5 || FAILED_BACKEND=1
FAILED_BACKEND="${FAILED_BACKEND:-0}"
if [ "$FAILED_BACKEND" = "1" ]; then
echo "❌ Backend did not become healthy"
rollback_to_previous
exit 1
fi
echo "Updating observation collector..."
compose_up_retry "observation collector" -d --no-deps polyweather_collector
echo "Updating 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
FAILED_FRONTEND="${FAILED_FRONTEND:-0}"
if [ "$FAILED_FRONTEND" = "1" ]; then
echo "❌ Frontend did not become healthy"
rollback_to_previous
exit 1
fi
warm_public_route "terminal" "https://polyweather.top/terminal" 20 4 3
warm_public_route "auth snapshot" "https://polyweather.top/api/auth/me?prefer_snapshot=1" 10 3 2
warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?refresh_deb_recent=1" 15 2 2
warm_public_route "cities" "https://polyweather.top/api/cities" 20 3 2
FAILED=0
curl -fsSo /dev/null --max-time 15 "https://api.polyweather.top/healthz" && echo "✅ healthz" || { echo "❌ healthz"; FAILED=1; }
curl -fsSo /dev/null --max-time 10 "https://api.polyweather.top/api/cities" && echo "✅ cities" || { echo "❌ cities"; FAILED=1; }
curl -fsSo /dev/null --max-time 10 "https://www.polyweather.top/" && echo "✅ frontend" || { echo "❌ frontend"; FAILED=1; }
run_public_smoke_checks || FAILED=1
if [ "$FAILED" = "1" ]; then
echo "⚠️ Initial public smoke failed; retrying before rollback..."
sleep "$PUBLIC_SMOKE_RECHECK_DELAY_SEC"
FAILED=0
run_public_smoke_checks "recheck" || FAILED=1
fi
if [ "$FAILED" = "1" ]; then
echo "❌ Smoke tests failed. Rolling back..."
if [ -n "$PREVIOUS_TAG" ]; then
export IMAGE_TAG="$PREVIOUS_TAG"
docker compose pull
docker compose up -d
echo "✅ Rolled back to $PREVIOUS_TAG"
else
echo "⚠️ No previous tag to rollback to"
fi
rollback_to_previous
exit 1
fi
+77 -3
View File
@@ -28,6 +28,11 @@ services:
env_file: &id001
- .env
environment:
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
POLYWEATHER_SERVICE_ROLE: bot
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC: ${POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC:-180}
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS: ${POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS:-1}
healthcheck:
@@ -66,9 +71,10 @@ services:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL:-https://polygon-bor-rpc.publicnode.com}
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID:-}
POLYWEATHER_API_BASE_URL: ${POLYWEATHER_API_BASE_URL:-http://polyweather_web:8000}
POLYWEATHER_API_BASE_URL: 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:-}
healthcheck:
interval: 30s
retries: 3
@@ -78,7 +84,7 @@ services:
timeout: 5s
image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}
ports:
- 3001:3000
- "127.0.0.1:3001:3000"
restart: unless-stopped
polyweather_web:
command: python web/app.py
@@ -92,6 +98,23 @@ services:
max-file: "3"
container_name: polyweather_web
env_file: *id001
environment:
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
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_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-180}
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_SERVICE_ROLE: web
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
healthcheck:
interval: 30s
retries: 3
@@ -103,7 +126,58 @@ services:
timeout: 5s
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
ports:
- 8000:8000
- "127.0.0.1:8000:8000"
restart: unless-stopped
ulimits:
nofile:
soft: 65535
hard: 65535
user: ${UID:-1000}:${GID:-1000}
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
polyweather_collector:
command: python -m web.observation_collector_worker
container_name: polyweather_collector
depends_on:
polyweather_redis:
condition: service_healthy
polyweather_web:
condition: service_started
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
cpus: ${POLYWEATHER_COLLECTOR_CPUS:-0.75}
env_file: *id001
environment:
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ${POLYWEATHER_COLLECTOR_PATCH_ENDPOINT:-http://polyweather_web:8000/api/internal/collector-patch}
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-180}
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_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
POLYWEATHER_SERVICE_ROLE: collector
healthcheck:
interval: 60s
retries: 3
test:
- CMD
- python
- -c
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
c.execute('SELECT 1'); c.close()
timeout: 10s
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
mem_limit: ${POLYWEATHER_COLLECTOR_MEM_LIMIT:-768m}
memswap_limit: ${POLYWEATHER_COLLECTOR_MEMSWAP_LIMIT:-1g}
pids_limit: 256
restart: unless-stopped
user: ${UID:-1000}:${GID:-1000}
volumes:
+19 -10
View File
@@ -11,13 +11,13 @@
| 香港 | CoWIN 6087 | 6087 | CoWIN (`cowin.hku.hk`) | 1 分钟 | 参考站温度(保良局陈守仁小学) | 免费 |
| 香港 | HKO | HKO | HKO 官方 CSV (`data.weather.gov.hk`) | 10 分钟 | 官方气象站温度 | 免费 |
| 台北 | 松山/中央气象署 | 466920 | CWA 开放数据 | 10 分钟 | 官方站点温度 | 免费 |
| 北京 | 首都机场 | ZBAA | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 上海 | 浦东机场 | ZSPD | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 广州 | 白云机场 | ZGGG | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 成都 | 双流机场 | ZUUU | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 重庆 | 江北机场 | ZUCK | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 武汉 | 天河机场 | ZHHH | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 青岛 | 胶东机场 | ZSQD | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 北京 | 首都机场 | ZBAA | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 上海 | 浦东机场 | ZSPD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 广州 | 白云机场 | ZGGG | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 成都 | 双流机场 | ZUUU | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 重庆 | 江北机场 | ZUCK | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 武汉 | 天河机场 | ZHHH | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 青岛 | 胶东机场 | ZSQD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 东京 | 羽田 | RJTT | JMA AMeDAS (`jma.go.jp`) | 10 分钟 | 机场站点实时温度 | 免费 |
| 安卡拉 | Esenboğa | 17128 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
| 伊斯坦布尔 | 伊斯坦布尔机场 | 17058 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
@@ -56,11 +56,20 @@
> `MID_TEMP` / `END_TEMP`。这些字段是跑道观测位置气温,不是道面温度。
> 结算跑道展示使用配置的结算端点;辅助跑道只作为背景曲线。
## 推送机制
## 独立观测采集器
- Web/API 进程启动 `observation-collector` 后台线程,按源频率独立采集,不依赖 Telegram 推送循环
- 默认频率:AMOS 60s、AMSC AWOS 180s、MADIS HFMETAR 300s、CoWIN 60s、HKO 600s
- 每次采集复用 `weather_sources.py` 现有 `_attach_*` 写入逻辑,负责写 `airport_obs_log` / `runway_obs_log` / 今日观测缓存,并通过 `/api/internal/collector-patch` 写 Redis Stream 或 SQLite event log 后广播 SSE
- 采集成功后刷新对应城市 `panel` cache;前端继续使用 HTTP snapshot + SSE patch,不需要依赖 Telegram 触发更新
- `observation_source_gate.py` 对 AMSC、AMOS、MADIS、HKO、CoWIN 做 per-source/per-city singleflight 和 SQLite cooldown,防止 Web 请求、collector 和兜底分析同时打同一个外部源
## Telegram 推送机制
- 每城按原生频率独立推送,不捆绑
- 首尔/釜山 60s,其余 600s
- 首尔/釜山 60s中国 AMSC 城市 180s其余 600s
- 循环轮询 60s 以匹配最快频率
- Telegram 推送优先读取网站侧 `full`/`panel` 城市缓存;缓存缺失时只做非强制 `panel` 分析兜底,不触发 `force_refresh_observations_only`
- 仅当当前温度距 DEB 预测最高 ≤3°C 时推送
- 确认过峰值后自动停止
@@ -69,7 +78,7 @@
为了向用户提供接近行情盘的实况响应并降低服务器负载,系统使用 **HTTP snapshot + Server-Sent Events (SSE) Patch + 可重放事件日志** 架构。生产环境推荐 Redis Stream;本地或单进程可回退 SQLite event log。
### 1. 数据推送链路 (Data Pipeline)
1. **Collector 采集端触发**:在 `weather_sources.py` 中,当高频实况源(如 AMOS, CoWIN, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
1. **Observation Collector 采集端触发**`web.observation_collector_service` 按源频率调用采集层;`weather_sources.py` 中,当高频实况源(如 AMOS, AMSC, CoWIN, HKO, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
2. **标准化事件**`realtime_patch_schema.py` 将旧 `city_patch` 或新 payload 统一成 `city_observation_patch.v1`
3. **事件存储**:生产环境写入 Redis Stream`stream:city_observation`)并生成全局递增 `revision`SQLite `observation_patch_events` 保留为本地/兜底 replay。
4. **FastAPI SSE 广播**FastAPI 后端的 `sse_router.py` 根据城市订阅集合向匹配连接推送 patch;断线重连时按 `since_revision` replay。
+7
View File
@@ -238,6 +238,13 @@ DEB hourly consensus 是当前图表与峰值窗口的优先小时路径。
### 支付状态建议
`POST /api/payments/intents` 支持的关键字段:
- `plan_code`:套餐,例如 `pro_monthly`
- `payment_mode``wallet``direct`
- `chain_id`:可选;多链支付时前端传用户选择的链,例如 Polygon `137` 或 Ethereum `1`
- `token_address`:可选;指定该链上的 USDC / USDC.e 合约地址
前端流程建议:
1. `POST /intents`
+9 -8
View File
@@ -13,13 +13,13 @@
| hong kong | CoWIN 6087 | ~1 min | cowin.hku.hk, 保良局陳守仁小學,前端图表默认展示 |
| hong kong | HKO 官方 CSV | ~10 min | data.weather.gov.hk(文件名虽含 1min,实际 10min 一报) |
| singapore | MSS 官方 API | ~1 min | api.data.gov.sg, 站号 S24 |
| beijing | AMSC AWOS (ZBAA) | ~1 min | 中国 |
| shanghai | AMSC AWOS (ZSPD) | ~1 min | 中国 |
| guangzhou | AMSC AWOS (ZGGG) | ~1 min | 中国 |
| chengdu | AMSC AWOS (ZUUU) | ~1 min | 中国 |
| chongqing | AMSC AWOS (ZUCK) | ~1 min | 中国 |
| wuhan | AMSC AWOS (ZHHH) | ~1 min | 中国 |
| qingdao | AMSC AWOS (ZSQD) | ~1 min | 中国 |
| beijing | AMSC AWOS (ZBAA) | 3 min | 中国 |
| shanghai | AMSC AWOS (ZSPD) | 3 min | 中国 |
| guangzhou | AMSC AWOS (ZGGG) | 3 min | 中国 |
| chengdu | AMSC AWOS (ZUUU) | 3 min | 中国 |
| chongqing | AMSC AWOS (ZUCK) | 3 min | 中国 |
| wuhan | AMSC AWOS (ZHHH) | 3 min | 中国 |
| qingdao | AMSC AWOS (ZSQD) | 3 min | 中国 |
### Tier 2 — 5 分钟高频 (MADIS)
@@ -123,7 +123,8 @@ ankara, istanbul, helsinki, amsterdam, paris
频率取决于源头:
- AMSC / AMOS / CoWIN / MSS:源头约 1 分钟,图表按 1 分钟粒度追加。
- AMOS / CoWIN / MSS:源头约 1 分钟,图表按 1 分钟粒度追加。
- AMSC:中国跑道观测城市按 3 分钟采集,不再强制 60 秒刷新。
- MADIS:源头约 5 分钟。
- HKO / CWA / JMA / FMI / KNMI:源头约 10 分钟。
- METAR-only 城市:按 METAR 可用频率和缓存 TTL,不伪装成 1 分钟实测。
+11 -7
View File
@@ -1,6 +1,6 @@
# 商业化说明(Production
最后更新:`2026-05-28`
最后更新:`2026-05-30`
## 1. 定位
@@ -18,10 +18,12 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
| 能力 | 状态 | 备注 |
| :-- | :-- | :-- |
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
| 订阅套餐(Pro 月付) | 已上线 | `10 USDC / 30天` |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
| 订阅套餐(Pro 月付 / 季度 | 已上线 | `29.9 USDC / 30天``79.9 USDC / 90天` |
| 邀请积分 | 已上线 | 被邀请人首月 `20U`;邀请人每个有效付费邀请 `+3500` 积分 |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U`;邀请首月价不叠加积分抵扣 |
| 合约支付 | 已上线 | PolygonUSDC + USDC.e |
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
| 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 |
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop,按 intent.chain_id 查链 |
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
| 私有频道推送 | 已上线 | 可拆分业务频道 |
| 实时终端 | 已上线 | SSE Patch + Redis Stream replay |
@@ -41,9 +43,11 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
## 4. 收费与积分规则(默认)
- 套餐:`pro_monthly`10 USDC / 30 天)
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC
- 实付下限:2 USDC(当积分满额时)
- 套餐:`pro_monthly`29.9 USDC / 30 天)`pro_quarterly`79.9 USDC / 90 天)
- 邀请首月:被邀请人首次月付 20 USDC
- 邀请奖励:邀请人 +3500 积分,每月最多 10 个有效付费邀请
- 抵扣:500 积分抵 1 USDC;月付最高抵 3 USDC,季度最高抵 8 USDC
- 积分来源:仅通过有效付费邀请和后台人工补发,不再通过 Telegram 群发言发放
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
+30 -1
View File
@@ -110,6 +110,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `POLYWEATHER_EVENT_STORE`
- `POLYWEATHER_REDIS_REQUIRED`
- `POLYWEATHER_PAYMENT_ENABLED`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- `POLYGON_WALLET_WATCH_ENABLED`
- `TELEGRAM_ALERT_PUSH_ENABLED`
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
@@ -131,6 +132,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC`
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
- `POLYWEATHER_PAYMENT_RPC_URLS`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- `TAF_CACHE_TTL_SEC`
- `POLYWEATHER_REDIS_URL`
- `POLYWEATHER_REDIS_STREAM_KEY`
@@ -259,7 +261,8 @@ POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`
- `POLYWEATHER_EVENT_STORE=redis` 表示实时观测 patch 使用 Redis Stream 做短窗口 replay 和多 worker fanout;本地或单进程可改为 `sqlite`
- `POLYWEATHER_REDIS_REQUIRED=true` 表示 Redis 不可用时后端启动失败,避免生产环境广播不可 replay 的实时事件;开发环境可设为 `false` 允许回退 SQLite。
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持默认链的逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON` 用于多链支付,例如同时支持 Polygon 和 Ethereum 主网 USDC。
- 机器人市场监控包含 `关键提醒``关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
@@ -267,6 +270,32 @@ POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
- 这层只负责把结构化信号改写成短摘要,不替代真实模型、机场锚点和结算逻辑。
### 6.3 支付多链配置示例
当前生产推荐:
- 默认链:Polygon `chain_id=137`,继续承载 checkout 合约支付。
- 补充链:Ethereum Mainnet `chain_id=1`,正式支持 USDC 直转确认。
- 前端创建支付 intent 时会提交用户选择的 `chain_id`;后端确认时按 intent 的链和 token 查询对应 RPC。
```env
POLYWEATHER_PAYMENT_ENABLED=true
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com","https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<polygon_checkout_contract>
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=0x<treasury_or_receiver_wallet>
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<polygon_checkout_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>","is_default":true},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x<treasury_or_receiver_wallet>","supports_contract_checkout":false,"supports_direct_transfer":true,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}]
```
注意:
- `POLYWEATHER_PAYMENT_CHAIN_ID` 只是默认链,不代表只支持这一条链。
- `POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON` 里每个 token 必须有明确 `chain_id`
- Ethereum 行如果没有部署 checkout 合约,必须设置 `supports_contract_checkout=false`,前端会显示手动转账并阻止钱包合约支付。
- 私有 RPC URL 带 API key 时应放入真实 `.env` 或密钥管理,不要提交。
### 6.5 机器人市场监控建议配置
这套配置围绕市场本身做两类推送:
+4 -1
View File
@@ -1,6 +1,6 @@
# 前端部署配置(Vercel
最后更新:`2026-05-28`
最后更新:`2026-05-29`
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。
@@ -127,12 +127,15 @@ NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
1. 用户点击支付前,前端会重新请求 `/api/payments/config`
2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址
3. 若后端返回的 `tx_payload.to` 与最新 `receiver_contract` 不一致,会直接阻断支付
4. 多链支付时,前端会展示后端返回的网络列表,并把用户选择的 `chain_id` 传给后端创建 intent
5. Ethereum 主网 USDC 当前走手动直转确认,前端不会把它当成 Polygon checkout 合约支付
这层防护的目的,是降低以下事故概率:
- 用户使用长期未刷新的旧标签页
- 命中旧 deployment URL
- 页面本地状态残留旧收款地址
- 用户在钱包默认网络(例如 Ethereum)付款,但系统按 Polygon intent 查账
如果你变更过支付收款地址,建议同步执行:
+4 -2
View File
@@ -1,6 +1,6 @@
# 外部服务依赖总览
最后更新:`2026-05-28`
最后更新:`2026-05-29`
项目调用外部天气、鉴权、支付和实时事件服务。原则是:核心链路必须有明确健康检查;可选数据源不可拖垮已可用城市;实时事件层可从 Redis Stream 降级到 SQLite event log。
@@ -52,7 +52,8 @@
| --- | --- | --- |
| MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 |
| DeepSeek | AI fallback | 备用 |
| Polygon RPC | 链上支付、自动确认 | ✅ |
| Polygon RPC | checkout 合约支付、Polygon USDC / USDC.e 自动确认 | ✅ |
| Ethereum RPC | Ethereum 主网 USDC 直转确认 | ✅(启用多链支付时必须) |
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key 时钱包入口降级 |
## 运维口径
@@ -61,3 +62,4 @@
- 本地或单进程兜底:`POLYWEATHER_EVENT_STORE=sqlite`
- Redis 只负责短窗口 replay 与多 worker fanout,不是长期天气历史库。
- DEB hourly consensus 依赖 Open-Meteo 多模型小时曲线;若上游限流,图表应保留已有 snapshot 和实测 patch,不把缺失模型误报为实测缺失。
- 支付多链确认依赖 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`;如果启用 Ethereum 主网 USDC,必须配置 `chain_id=1` 的 RPC,否则用户提交 Ethereum tx hash 后无法自动确认。
+32 -8
View File
@@ -1,12 +1,12 @@
# Supabase + 登录 + 支付接入说明(v1.8.1)
最后更新:`2026-03-14`
最后更新:`2026-05-29`
## 1. 目标
- 前端支持 Google 一键登录 + 邮箱注册/登录。
- 后端支持 Supabase JWT 鉴权。
- 支持 Polygon 合约支付(USDC / USDC.e)并自动确认开通订阅。
- 支持 Polygon 合约支付(USDC / USDC.e和 Ethereum 主网 USDC 直转支付,并自动确认开通订阅。
## 2. Supabase 控制台配置
@@ -22,6 +22,7 @@
在 Supabase SQL Editor 执行:
- `scripts/supabase/schema.sql`
- 既有生产项目遇到 Disk IO Budget 告警时,再执行 `scripts/supabase/io_budget_indexes.sql`
会创建支付与订阅相关表:
@@ -33,6 +34,15 @@
- `payment_intents`
- `payment_transactions`
### 3.1 Disk IO 告警处理
如果 Supabase 提示项目正在耗尽 Disk IO Budget,先在 SQL Editor 执行:
1. `scripts/supabase/io_budget_indexes.sql`
2. `scripts/supabase/disk_io_diagnostics.sql`
第一个脚本会把热查询索引收敛为更低写放大的部分索引,移除已被唯一约束覆盖或无热路径使用的冗余索引,并对相关表执行 `ANALYZE`。第二个脚本用于查看表扫描、dead tuples、索引命中和 `pg_stat_statements` 中的高读块 SQL。生产执行后,继续观察 Supabase daily/hourly Disk IO 图表,确认请求延迟和 IO wait 是否下降。
## 4. 环境变量
### 4.1 前端(Vercel / frontend/.env.local
@@ -66,30 +76,38 @@ SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_HTTP_TIMEOUT_SEC=8
POLYWEATHER_PAYMENT_ENABLED=true
# 默认链仍是 Polygon,因为当前 checkout 合约部署在 Polygon。
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<receiver_contract>
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=0x<treasury_or_receiver_wallet>
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
# 支持币种(示例)
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_e","symbol":"USDC.e","name":"USDC.e (PoS)","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver>","is_default":true},{"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver>"}]
# 支持多链多币种(示例)
# Ethereum 主网 USDC 当前建议只开 direct transfer,不走 Polygon checkout 合约。
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>","is_default":true},{"code":"usdc_e_polygon","symbol":"USDC.e","name":"USDC.e on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>"},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x<treasury_or_receiver_wallet>","supports_contract_checkout":false,"supports_direct_transfer":true,"confirmations":2,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}]
# 套餐(当前只保留月付)
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"5","duration_days":30}}
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
# 套餐
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"29.9","duration_days":30},"pro_quarterly":{"plan_id":102,"amount_usdc":"79.9","duration_days":90}}
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly
# 积分抵扣
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8}
# 支付自动补单
POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true
POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC=20
POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_INTERVAL_SEC=300
POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_AFTER_EMPTY_CYCLES=3
```
## 5. 验证步骤
@@ -100,10 +118,16 @@ POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
- `POST /api/payments/wallets/challenge`
- `POST /api/payments/wallets/verify`
4. 支付流程:
- `POST /api/payments/intents`
- `POST /api/payments/intents`;多链时前端会带 `chain_id``token_address`
- 发链上交易
- `POST /api/payments/intents/{id}/submit`
- `POST /api/payments/intents/{id}/confirm`
5. 若前端显示 pending,轮询:
- `GET /api/payments/intents/{id}`
6. 确认订阅:`/api/auth/me` 返回 `subscription_active=true`
## 6. 多链支付口径
- Polygon 是默认链,仍支持钱包合约支付和手动直转确认。
- Ethereum 主网 USDC 是正式支付链路,但当前建议只走手动直转:用户选择 Ethereum 后,前端展示收款钱包、金额、代币合约和 Etherscan 链接,用户提交 tx hash 后后端按 `intent.chain_id=1` 查询 Ethereum RPC。
- 不要只依赖前端文案阻止错链付款;后端必须把每笔 intent 的 `chain_id``token_address``receiver_address` 落库,并在确认时按该链校验 `Transfer` 事件。
+1 -1
View File
@@ -60,7 +60,7 @@ flowchart TD
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/首次消息奖励/欢迎奖励/周排名奖励/周参与奖/手动补分 |
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(有效付费邀请/后台人工补发/积分抵扣消费 |
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
+5 -5
View File
@@ -2,9 +2,9 @@
## 执行摘要
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、Ethereum 主网 USDC 直转确认、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
> **2026-05-28 更新(v1.8.1**:终端图表已切换为 HTTP snapshot + SSE Patch + Redis Stream/SQLite replay 架构;DEB hourly consensus (`deb_hourly_consensus.v1`) 成为峰值窗口和 DEB 曲线展示的优先小时路径;README 产品截图迁移到 `frontend/public/static`
> **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”。
@@ -15,7 +15,7 @@ DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
**城市决策层(Scan Terminal / 结构化实况层)**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR/官方站点/跑道观测,并通过 `/api/city/{name}/detail` 生成城市级结构化数据。终端图表使用 HTTP snapshot + SSE patch + Redis/SQLite replay,最高温中枢优先使用 DEB hourly consensus,再结合多模型中心、日内 pace 和当前实测。
**市场层(Polymarket 行情对照)***[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
**商业化与支付**:订阅(`Pro Monthly 29.9 USDC / 30 天``Pro Quarterly 79.9 USDC / 90 天`)、邀请积分抵扣、Polygon 链上收款合约(USDC/USDC.e、Ethereum 主网 USDC 直转确认,并提供“事件监听 + 周期确认”的自动补单机制。
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。
(插图:项目 README 中包含产品截图,可用于快速理解实时终端与 Telegram 推送形态)
@@ -71,7 +71,7 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
HKO[data.weather.gov.hk]
CWA[opendata.cwa.gov.tw]
SB[Supabase Auth/REST]
RPC[Polygon RPC]
RPC[Polygon / Ethereum RPC]
end
subgraph Payments
@@ -148,7 +148,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
### 优势
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),新增首次发言欢迎奖励与每日首条消息奖励,周奖励降低赢家积分差距并增加全员参与奖
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),积分获取切换为有效付费邀请奖励;Telegram 群发言不再发放积分
**复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。前端城市决策卡在此基础上补足“机场报文解释 + 市场桶动作口径”,让用户从地图点击可以直接进入可解释决策。
**面向外部 API 的工程防护意识较强**Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。
**支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。
+30 -10
View File
@@ -1,6 +1,6 @@
# PolyWeather 支付审计与防护说明
最后更新:`2026-03-21`
最后更新:`2026-05-29`
## 1. 当前已落地的防护
@@ -19,9 +19,9 @@
### 事件确认边界
- 后端只认链上 `OrderPaid` 事件
- 后端只认链上可验证事件:checkout 合约路径认 `OrderPaid`,直转路径认对应链上 USDC `Transfer`
- 前端提交 intent 不会直接视为支付完成。
- `confirm_loop` 会再次按链上交易与确认数校验 intent
- `confirm_loop` 会再次按 intent 的 `chain_id``token_address`、收款地址、金额与确认数校验链上交易。
- 若确认失败,当前会明确把 intent / transaction 落为失败态,而不是长期停留在 `submitted`
当前已显式识别的失败原因包括:
@@ -29,19 +29,23 @@
- `receiver_mismatch`
- `sender_mismatch`
- `event_mismatch`
- `token_mismatch`
- `tx_reverted`
### RPC 多节点容灾
- 支持 `POLYWEATHER_PAYMENT_RPC_URLS`
- 支持默认链 `POLYWEATHER_PAYMENT_RPC_URLS`
- 支持多链 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- 格式示例:
```env
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com","https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
```
- 启动时按顺序探活。
- 当前节点断连或收据查询失败时,会自动切换到下一个可用 RPC。
- 多链支付确认不会用默认链硬查交易;每笔 intent 会按自己的 `chain_id` 选择 RPC。
### 事件重放
@@ -87,8 +91,24 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
- 已付款但未开通
- 打到旧收款地址
- 交易事件不匹配
- 用户在钱包默认 Ethereum 网络付款,但旧系统只按 Polygon intent 查账
## 2. 当前合约的授权边界
## 2. 当前支付链路边界
当前支持两类链路:
1. Polygon checkout 合约
- 前端钱包支付会先切到 Polygon。
- 后端创建 intent 后给出合约 `tx_payload`
- 确认时校验 `OrderPaid(orderId, payer, planId, amount, token)`
2. Ethereum 主网 USDC 直转
- 前端展示 Ethereum 网络、USDC 合约、收款钱包和金额。
- 用户提交 tx hash 后,后端按 `intent.chain_id=1` 查询 Ethereum RPC。
- 确认时校验 USDC `Transfer(from, to, amount)``to``token_address` 和金额。
- 这条链路不依赖 Polygon checkout 合约,适合处理“用户钱包默认网络付款”的真实行为。
## 3. 当前合约的授权边界
合约源码:
- [PolyWeatherCheckout.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckout.sol)
@@ -110,7 +130,7 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
4. 订单边界
- 同一个 `orderId` 只能成功支付一次
## 3. 重入与重复支付判断
## 4. 重入与重复支付判断
当前合约的 `pay` 逻辑顺序是:
@@ -136,7 +156,7 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
- **最小可用支付合约**
- 不是“全功能强防护合约”
## 4. 当前静态审计结论
## 5. 当前静态审计结论
已提供脚本:
- [check_payment_contract_security.py](/E:/web/PolyWeather/scripts/check_payment_contract_security.py)
@@ -160,7 +180,7 @@ python scripts/check_payment_contract_security.py
- 是否使用 SafeERC20
- 是否在链上绑定套餐价格
## 5. 当前主要剩余风险
## 6. 当前主要剩余风险
1. 单地址 owner
- 建议把 `owner` 迁移到多签钱包
@@ -175,7 +195,7 @@ python scripts/check_payment_contract_security.py
- 当前使用 `IERC20.transferFrom`
- 升级版合约更建议改为 OpenZeppelin `SafeERC20`
## 6. 推荐操作
## 7. 推荐操作
### 每次支付配置变更后
@@ -233,7 +253,7 @@ docker compose exec polyweather_web python scripts/reconcile_subscription_by_ema
- 用户声称已付费但未开通
- 需要快速确认最近一笔 intent 是否能自动恢复
## 7. 下一版合约建议
## 8. 下一版合约建议
如果后续升级合约,优先级建议:
+1 -1
View File
@@ -12,7 +12,7 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|------|------|------|
| 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 |
| Pro 试用 | 3 天全功能 | 注册后自动获得 |
| Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 10 USDC/月(积分抵扣最多 3 USDC |
| Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 月付 29.9 USDC / 30 天,季度 79.9 USDC / 90 天(月付最多抵 3 USDC,季度最多抵 8 USDC |
### 页面结构(9 个路由)
@@ -0,0 +1,345 @@
# Feedback Reward Account Detail Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users see which submitted feedback earned points, how many points were awarded, and the reward reason in the account page.
**Architecture:** Store reward metadata directly on `user_feedback` rows so the existing feedback list API can expose the reward source without a separate ledger join. The account feedback panel renders reward state inline with each feedback item and stays silent for rows with no reward metadata.
**Tech Stack:** Python SQLite `DBManager`, FastAPI feedback service, Next.js/React account components, TypeScript source-based business tests.
---
## File Structure
- Modify `src/database/db_manager.py`: add reward columns, serialize them, and add `update_user_feedback_reward()` for future ops reward workflows.
- Modify `tests/test_user_feedback.py`: add backend coverage for reward defaults and reward metadata round trip.
- Modify `frontend/types/ops.ts`: add optional reward fields to `UserFeedbackEntry`.
- Modify `frontend/components/account/AccountFeedbackPanel.tsx`: render reward labels and reasons inside each feedback row.
- Modify `frontend/components/account/__tests__/accountFeedbackPanel.test.ts`: assert the panel handles reward display states.
---
### Task 1: Backend Feedback Reward Metadata
**Files:**
- Modify: `tests/test_user_feedback.py`
- Modify: `src/database/db_manager.py`
- [ ] **Step 1: Write the failing backend test**
Add this test to `tests/test_user_feedback.py`:
```python
def test_user_feedback_reward_metadata_round_trip(tmp_path):
db = DBManager(str(tmp_path / "polyweather-feedback-reward.db"))
created = db.append_user_feedback(
category="data",
message="Hong Kong COWIN reading was stale.",
user_id="user-reward",
user_email="reward@example.com",
)
assert created["reward_points"] == 0
assert created["reward_reason"] == ""
assert created["reward_status"] == ""
assert created["rewarded_at"] is None
rewarded = db.update_user_feedback_reward(
created["id"],
points=300,
reason="Valid data freshness report",
status="granted",
)
assert rewarded is not None
assert rewarded["reward_points"] == 300
assert rewarded["reward_reason"] == "Valid data freshness report"
assert rewarded["reward_status"] == "granted"
assert rewarded["rewarded_at"]
row = db.list_user_feedback(
limit=10,
user_id="user-reward",
user_email="reward@example.com",
)[0]
assert row["id"] == created["id"]
assert row["reward_points"] == 300
assert row["reward_reason"] == "Valid data freshness report"
assert row["reward_status"] == "granted"
assert row["rewarded_at"] == rewarded["rewarded_at"]
```
- [ ] **Step 2: Run the backend test and verify it fails**
Run:
```powershell
python -m pytest tests/test_user_feedback.py::test_user_feedback_reward_metadata_round_trip -q
```
Expected: failure because `reward_points` is missing or `update_user_feedback_reward` is undefined.
- [ ] **Step 3: Add reward columns and serialization**
In `src/database/db_manager.py`, update `user_feedback` schema and migration:
```python
reward_points INTEGER DEFAULT 0,
reward_reason TEXT DEFAULT '',
rewarded_at TIMESTAMP,
reward_status TEXT DEFAULT ''
```
Add `_ensure_column()` calls for the same columns in the existing migration block.
Update every feedback `SELECT` to include:
```sql
reward_points, reward_reason, rewarded_at, reward_status
```
Update `_feedback_row_to_dict()` to return:
```python
"reward_points": max(0, int(row["reward_points"] or 0)),
"reward_reason": str(row["reward_reason"] or ""),
"rewarded_at": row["rewarded_at"],
"reward_status": str(row["reward_status"] or ""),
```
- [ ] **Step 4: Add reward update method**
Add this method near `update_user_feedback_status()` in `src/database/db_manager.py`:
```python
def update_user_feedback_reward(
self,
feedback_id: int,
*,
points: int,
reason: str = "",
status: str = "granted",
) -> Optional[Dict[str, Any]]:
safe_points = max(0, int(points or 0))
normalized_reason = str(reason or "").strip()[:500]
normalized_status = str(status or "").strip().lower()[:40]
if not normalized_status:
normalized_status = "granted" if safe_points > 0 else "skipped"
now = datetime.now().isoformat()
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
conn.execute(
"""
UPDATE user_feedback
SET reward_points = ?,
reward_reason = ?,
reward_status = ?,
rewarded_at = ?,
updated_at = ?
WHERE id = ?
""",
(
safe_points,
normalized_reason,
normalized_status,
now,
now,
int(feedback_id),
),
)
row = conn.execute(
"""
SELECT id, category, message, source, status, contact, user_id,
user_email, context_json, reward_points, reward_reason,
rewarded_at, reward_status, created_at, updated_at
FROM user_feedback
WHERE id = ?
""",
(int(feedback_id),),
).fetchone()
conn.commit()
return self._feedback_row_to_dict(row) if row else None
```
- [ ] **Step 5: Run backend tests**
Run:
```powershell
python -m pytest tests/test_user_feedback.py -q
```
Expected: all `test_user_feedback.py` tests pass.
---
### Task 2: Account Feedback Reward Display
**Files:**
- Modify: `frontend/types/ops.ts`
- Modify: `frontend/components/account/AccountFeedbackPanel.tsx`
- Modify: `frontend/components/account/__tests__/accountFeedbackPanel.test.ts`
- [ ] **Step 1: Write the failing frontend test**
Extend the final assertion in `frontend/components/account/__tests__/accountFeedbackPanel.test.ts` or add a new assertion:
```ts
assert(
feedbackPanelSource.includes("reward_points") &&
feedbackPanelSource.includes("reward_reason") &&
feedbackPanelSource.includes("reward_status") &&
feedbackPanelSource.includes("formatRewardPoints") &&
feedbackPanelSource.includes("renderFeedbackReward") &&
feedbackPanelSource.includes("奖励原因"),
"account feedback panel must show per-feedback reward points and reward reasons",
);
```
- [ ] **Step 2: Run the frontend business test and verify it fails**
Run:
```powershell
cd frontend; npm run test:business -- accountFeedbackPanel
```
Expected: failure because the panel does not yet reference the reward fields.
- [ ] **Step 3: Extend frontend type**
Add optional fields to `UserFeedbackEntry` in `frontend/types/ops.ts`:
```ts
reward_points?: number;
reward_reason?: string;
rewarded_at?: string | null;
reward_status?: string;
```
- [ ] **Step 4: Add account reward rendering helpers**
In `AccountFeedbackPanel.tsx`, add helpers:
```tsx
function formatRewardPoints(points?: number) {
const value = Math.max(0, Number(points || 0));
return `+${value.toLocaleString()} points`;
}
function rewardStatusText(status?: string, isEn = false) {
const key = String(status || "").toLowerCase();
if (key === "pending") return isEn ? "Reward pending" : "奖励待处理";
if (key === "skipped") return isEn ? "No points awarded" : "未发放积分";
return isEn ? "Feedback reward" : "反馈奖励";
}
function renderFeedbackReward(entry: UserFeedbackEntry, isEn: boolean) {
const points = Math.max(0, Number(entry.reward_points || 0));
const rewardStatus = String(entry.reward_status || "").toLowerCase();
const reason = String(entry.reward_reason || "").trim();
if (points <= 0 && !rewardStatus && !reason) return null;
const granted = points > 0 || rewardStatus === "granted";
return (
<div className={`mt-2 rounded-lg border px-3 py-2 text-xs ${
granted
? "border-amber-200 bg-amber-50 text-amber-800"
: "border-slate-200 bg-slate-50 text-slate-600"
}`}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-bold">{rewardStatusText(rewardStatus, isEn)}</span>
{points > 0 ? <span className="font-mono font-black">{formatRewardPoints(points)}</span> : null}
</div>
{reason ? (
<div className="mt-1 leading-5 text-slate-600">
{isEn ? "Reason" : "奖励原因"}: {reason}
</div>
) : null}
{entry.rewarded_at ? (
<div className="mt-1 font-mono text-[11px] text-slate-400">
{compactDate(entry.rewarded_at)}
</div>
) : null}
</div>
);
}
```
- [ ] **Step 5: Render reward detail in each feedback row**
Inside each feedback row, after the feedback message paragraph, add:
```tsx
{renderFeedbackReward(entry, isEn)}
```
- [ ] **Step 6: Run frontend business test**
Run:
```powershell
cd frontend; npm run test:business -- accountFeedbackPanel
```
Expected: `accountFeedbackPanel` passes.
---
### Task 3: Verification and Commit
**Files:**
- Verify all touched files.
- [ ] **Step 1: Run targeted backend verification**
Run:
```powershell
python -m pytest tests/test_user_feedback.py -q
```
Expected: all tests pass.
- [ ] **Step 2: Run targeted frontend verification**
Run:
```powershell
cd frontend; npm run test:business -- accountFeedbackPanel
```
Expected: `accountFeedbackPanel` test passes.
- [ ] **Step 3: Run broader low-cost verification**
Run:
```powershell
python -m ruff check .
cd frontend; npm run typecheck
```
Expected: both commands pass.
- [ ] **Step 4: Check status and commit**
Run:
```powershell
git status --short
git diff --check
git add docs/superpowers/plans/2026-06-08-feedback-reward-account-detail.md tests/test_user_feedback.py src/database/db_manager.py frontend/types/ops.ts frontend/components/account/AccountFeedbackPanel.tsx frontend/components/account/__tests__/accountFeedbackPanel.test.ts
git commit -m "Show feedback reward details in account"
```
Expected: commit succeeds with only planned files staged.
---
## Self-Review
- Spec coverage: reward metadata fields, existing API payload, account inline display, missing metadata fallback, and future ops reuse are covered.
- Scope control: the plan does not implement automatic reward issuance or external notifications.
- Type consistency: frontend fields use `reward_points`, `reward_reason`, `rewarded_at`, and `reward_status`, matching backend serialization.
@@ -0,0 +1,124 @@
# Feedback Reward Account Detail Design
## Goal
Show users which submitted feedback earned points, how many points were awarded, and why.
The first implementation focuses on account-page visibility. The later ops workflow for automatic reward issuance should reuse the same fields and display contract.
## Current Context
- `user_feedback` already stores each submitted feedback item with `status`, `message`, `user_id`, `user_email`, timestamps, and context JSON.
- `/api/feedback` already returns the current user's feedback list.
- `AccountFeedbackPanel` already renders the current user's feedback in the account page.
- Current points balance is shown through `/api/auth/me`, but account UI has no per-feedback reward source detail.
- Existing points grant helpers update `users.points`; feedback-specific rewards are not yet linked back to a feedback row.
## Scope
In scope:
- Add feedback reward metadata to feedback rows.
- Return reward metadata through the existing current-user feedback API.
- Display feedback reward details in the account page next to the matching feedback item.
- Keep the data contract ready for future ops automation: a processed feedback item can carry reward points and a human-readable reason.
Out of scope for this phase:
- Building the ops action that decides and grants points.
- Sending push/email/Telegram notifications after ops processing.
- Rebuilding a general points ledger for all point sources.
- Merging historical Supabase referral ledger entries into the account page.
## Data Model
Extend `user_feedback` with nullable columns:
- `reward_points INTEGER DEFAULT 0`
- `reward_reason TEXT DEFAULT ''`
- `rewarded_at TIMESTAMP`
- `reward_status TEXT DEFAULT ''`
Recommended `reward_status` values:
- empty string: no reward decision recorded
- `granted`: points were awarded for this feedback
- `skipped`: reviewed but no points awarded
- `pending`: reward decision is queued or awaiting processing
The display should treat `reward_points > 0` as the strongest signal that the user earned points.
## API Contract
`DBManager._feedback_row_to_dict()` should include:
- `reward_points`
- `reward_reason`
- `rewarded_at`
- `reward_status`
`GET /api/feedback` should continue using the same response shape:
```json
{
"feedback": [
{
"id": 123,
"status": "resolved",
"message": "Hong Kong COWIN reading looked stale",
"reward_points": 300,
"reward_reason": "Valid data freshness report",
"rewarded_at": "2026-06-08T12:00:00",
"reward_status": "granted"
}
]
}
```
The frontend proxy does not need a new route if it transparently forwards the existing payload.
## Account UI
`AccountFeedbackPanel` should show reward detail inside each feedback row:
- Granted reward: `+300 points` plus reward reason.
- Skipped reward: a muted note that no points were awarded, with reason if available.
- Pending reward: a small pending label.
- No reward metadata: render nothing extra to avoid noise.
The account page should remain dense and scannable. Reward content belongs inside the existing feedback row, not in a separate marketing-style card.
## Future Ops Workflow
The later feedback-processing feature can use a single backend operation:
1. Update feedback status.
2. Grant points to the matching user.
3. Write `reward_points`, `reward_reason`, `rewarded_at`, and `reward_status = granted` back to the same feedback row.
If the grant fails, the operation should not mark the feedback as granted. It should either keep `reward_status = pending` or return a clear ops error.
## Error Handling
- Missing reward fields should default to zero or empty strings in API responses.
- If old SQLite databases do not have the new columns, `DBManager` migration should add them with `_ensure_column`.
- Account UI should tolerate partial payloads without crashing.
- Reward reason should be bounded in storage and display to avoid oversized rows.
## Testing
Backend tests:
- Existing feedback rows serialize reward fields with defaults.
- Updating reward metadata for a feedback row returns the fields in `/api/feedback`.
- Old rows without rewards still list normally.
Frontend tests:
- Account feedback panel displays `+N points` and reason for rewarded feedback.
- Skipped and pending reward states render without implying points were granted.
- Rows without reward metadata do not show an empty reward block.
## Rollout
This is a backward-compatible schema and UI change. Existing feedback rows keep working with empty reward metadata. The account page can ship before the ops reward action; it will simply show reward details once rows carry those fields.
+15 -24
View File
@@ -9,7 +9,7 @@ PolyWeather Pro 的生产前端工程。
- Next.js App Router
- React + Tailwind
- Leaflet + Recharts
- 自研温度图表 + Recharts 运营图表
- Supabase Auth
- WalletConnect + 浏览器 EVM 钱包
@@ -21,30 +21,20 @@ PolyWeather Pro 的生产前端工程。
## 当前前端能力
- 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心
- `/docs` 提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF 和结算来源
- 主站为实时天气决策台,包含 `天气决策 / 训练数据 / 使用指南` 三个主入口
- `/docs` 提供公开双语产品文档中心,当前保留简介、图表阅读、实时数据频率、结算站点和浏览器插件说明
- 天气决策台支持 1x1 到 3x3 图表槽位,可按区域、搜索和城市选择进行多城市巡检
- 终端图表默认展示全天,可切换高温窗口;所有横轴与 tooltip 时间按城市当地时间渲染
- 可见终端图表通过 SSE patch 无痛增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
- 可见终端图表通过 SSE patch 增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
- AMSC/AMOS 跑道城市默认展示结算跑道曲线并高亮,辅助跑道弱化展示;单跑道机场不重复展示聚合线
- 香港默认展示 CoWIN 6087 参考站 1 分钟曲线,并保留 HKO 10 分钟官方气象层
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
- 今日日内分析支持:
- `锚点状态`
- `当前节奏`
- `校准模型概率`
- `模型区间与分歧`
- `专业气象结论条`
- `气象证据链 / 失效条件 / 确认条件`
- 非香港机场城市的 `TAF` 时段提示与走势图联动
- 使用指南内置图表阅读顺序、图层含义、常用操作和默认可见性规则
- `/ops` 已支持桌面表格 + 手机端卡片化视图
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
- 右侧详情面板在多日预报仍未补齐时会显示同步占位卡,不再把“只有今天一张卡”的中间态伪装成完整数据
- 日内分析弹窗在 full detail / market scan 同步时会锁住旧内容并显示刷新状态,避免用户短暂看到旧城市或旧日期的数据
- 城市决策卡支持从地图点击城市进入;机会榜和日历仍按 Pro 权限控制,地图探索和城市简报可作为轻量入口
- 城市决策卡展示结构化实况、模型区间、市场温度桶和模型-市场差,不再请求 AI 解读
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
- 城市 detail 自动识别稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
- 市场价格层使用完整 `all_buckets` 匹配温度桶,市场信号作为交易判断层,不替代实测结算源
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
- 账户中心支付区支持后端下发的多链网络选择;Polygon 继续走 checkout 合约,Ethereum 主网 USDC 走手动直转确认
- 缓存桶状态与 summary cache hit/miss
## 本地开发
@@ -176,7 +166,8 @@ Ops
2. 若 `receiver_contract` 已更新,先切到最新地址
3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付
4. 仅允许在 `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` 白名单域名上创建 payment intent
5. 支付区会明确显示当前账号、付款钱包和收款合约,避免账号/钱包/地址混淆
5. 支付区会明确显示当前账号、付款钱包、支付网络和收款合约/钱包,避免账号/钱包/地址/链混淆
6. 多链支付时,前端会把用户选择的 `chain_id``token_address` 发给后端,由后端按 intent 的链确认交易
这意味着:
@@ -189,8 +180,8 @@ Ops
- `summary?force_refresh=true``no-store`
- 支付相关路由:`no-store`
- 当 detail 缓存只返回单模型或单日 forecast 时,前端会自动强刷完整 detail,并在补齐前显示同步提示 / 占位卡
- 今日日内分析打开时如果正在切换城市、日期或 detail 深度,弹窗会阻断旧内容点击并显示刷新锁
- 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=500`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新
- detail 正在切换城市、日期或分辨率时,图表保留已有曲线并显示同步提示,避免把旧数据当作当前状态
- 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=按可见城市数动态限制`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新
- 前端只消费 HTTP snapshot + SSE patch,不直接感知 RedisRedis Stream / SQLite event log 都由后端统一封装
## Vercel 节流建议
@@ -206,4 +197,4 @@ Ops
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
最后更新:`2026-05-28`
最后更新:`2026-05-29`
+96 -24
View File
@@ -7,49 +7,121 @@ import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const ANALYTICS_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS !== "false";
const ANALYTICS_PROXY_TIMEOUT_MS = Math.max(
250,
Number(process.env.POLYWEATHER_ANALYTICS_PROXY_TIMEOUT_MS || "1500") || 1500,
);
export async function POST(req: NextRequest) {
const timer = createProxyTimer(req, "analytics_events");
if (!ANALYTICS_ENABLED) {
return new NextResponse(null, { status: 204 });
}
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
return finishProxyTimedResponse(
new NextResponse(null, { status: 204 }),
timer,
"disabled",
);
}
if (!API_BASE) {
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ANALYTICS_PROXY_TIMEOUT_MS);
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const body = await timer.measure("request_read", () => req.json());
const payload =
body && typeof body.payload === "object" && body.payload != null
? body.payload
: {};
const enrichedBody = {
...(body ?? {}),
payload: {
...payload,
cf_country:
req.headers.get("cf-ipcountry") ||
req.headers.get("x-vercel-ip-country") ||
"",
user_agent: req.headers.get("user-agent") || "",
referer_header: req.headers.get("referer") || "",
},
};
auth = await timer.measure("auth_headers", () =>
buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
}),
);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers,
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
const res = await timer.measure("backend_fetch", () =>
fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers,
body: JSON.stringify(enrichedBody),
cache: "no-store",
signal: controller.signal,
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
if (!res.ok) {
const raw = await res.text();
const raw = await timer.measure("backend_read", () => res.text());
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 260,
});
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
}
const data = await res.json();
const data = await timer.measure("backend_read", () => res.json());
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to track analytics event",
});
const timedOut = controller.signal.aborted;
const response = timedOut
? NextResponse.json(
{
ok: false,
accepted: true,
dropped: true,
reason: "timeout",
},
{ status: 202 },
)
: buildProxyExceptionResponse(error, {
publicMessage: "Failed to track analytics event",
});
const withCookies = auth ? applyAuthResponseCookies(response, auth.response) : response;
return finishProxyTimedResponse(
withCookies,
timer,
timedOut ? "timeout_accepted" : "exception",
);
} finally {
clearTimeout(timeoutId);
}
}
+649 -81
View File
@@ -4,121 +4,689 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
import {
getLocalDevAuthPayload,
isLocalFullAccessHost,
} from "@/lib/local-dev-access";
import {
applyEntitlementSnapshotCookie,
clearEntitlementSnapshotCookie,
entitlementSnapshotToAuthPayload,
readEntitlementSnapshot,
} from "@/lib/entitlement-snapshot";
import {
buildSubscriptionRequiredAuthProfile,
isSubscriptionRequiredBackendResponse,
} from "@/lib/auth-profile-proxy";
import {
hasSupabaseServerEnv,
hasSupabaseSessionCookieValues,
} from "@/lib/supabase/server";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
type AuthMeTimingStage = {
durationMs: number;
name: string;
};
type AuthMeTimer = {
hasAuthorization: boolean;
hasSupabaseCookie: boolean;
measure<T>(name: string, action: () => Promise<T>): Promise<T>;
measureSync<T>(name: string, action: () => T): T;
preferSnapshot: boolean;
stages: AuthMeTimingStage[];
totalMs(): number;
};
function authMeNowMs() {
return typeof performance !== "undefined" ? performance.now() : Date.now();
}
function createAuthMeTimer(req: NextRequest): AuthMeTimer {
const startedAt = authMeNowMs();
const stages: AuthMeTimingStage[] = [];
const recordStage = (name: string, stageStartedAt: number) => {
stages.push({
durationMs: Math.round((authMeNowMs() - stageStartedAt) * 10) / 10,
name,
});
};
return {
hasAuthorization: Boolean(req.headers.get("authorization")),
hasSupabaseCookie: hasRequestSupabaseSessionCookie(req),
async measure<T>(name: string, action: () => Promise<T>) {
const stageStartedAt = authMeNowMs();
try {
return await action();
} finally {
recordStage(name, stageStartedAt);
}
},
measureSync<T>(name: string, action: () => T) {
const stageStartedAt = authMeNowMs();
try {
return action();
} finally {
recordStage(name, stageStartedAt);
}
},
preferSnapshot: req.nextUrl.searchParams.get("prefer_snapshot") === "1",
stages,
totalMs() {
return Math.round((authMeNowMs() - startedAt) * 10) / 10;
},
};
}
function formatServerTiming(stages: AuthMeTimingStage[], totalMs: number) {
return [...stages, { durationMs: totalMs, name: "total" }]
.map(({ durationMs, name }) => {
const safeName = name.replace(/[^A-Za-z0-9_-]/g, "_");
return `${safeName};dur=${Math.max(0, durationMs).toFixed(1)}`;
})
.join(", ");
}
function buildBackendAuthMeUrl(req: NextRequest) {
const url = new URL(`${API_BASE!.replace(/\/+$/, "")}/api/auth/me`);
if (req.nextUrl.searchParams.get("scope") === "entitlement") {
url.searchParams.set("scope", "entitlement");
}
return url.toString();
}
function finishAuthMeResponse(
response: NextResponse,
timer: AuthMeTimer,
outcome: string,
extra?: { backendServerTiming?: string },
) {
const total = timer.totalMs();
const ownServerTiming = formatServerTiming(timer.stages, total);
const backendServerTiming = String(extra?.backendServerTiming || "").trim();
response.headers.set("Cache-Control", "no-store");
response.headers.set(
"Server-Timing",
backendServerTiming
? `${ownServerTiming}, ${backendServerTiming}`
: ownServerTiming,
);
console.info(
"[auth-me-timing]",
JSON.stringify({
backendServerTiming: backendServerTiming || undefined,
hasAuthorization: timer.hasAuthorization,
hasSupabaseCookie: timer.hasSupabaseCookie,
outcome,
preferSnapshot: timer.preferSnapshot,
stagesMs: Object.fromEntries(
timer.stages.map((stage) => [stage.name, stage.durationMs]),
),
status: response.status,
totalMs: total,
}),
);
return response;
}
async function trackAuthDiagnosticEvent(
req: NextRequest,
{
email,
reason,
responseMode,
userId,
}: {
email: string | null;
reason: string;
responseMode: "snapshot" | "degraded" | "anonymous";
userId?: string | null;
},
) {
if (!API_BASE) return;
const normalizedUserId = String(userId || "").trim();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1200);
try {
await fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event_type: "degraded_auth_profile",
client_id: normalizedUserId ? `auth:${normalizedUserId}` : undefined,
payload: {
route: "/api/auth/me",
reason: String(reason || "unknown").slice(0, 240),
response_mode: responseMode,
user_id: normalizedUserId || undefined,
email_domain: email?.includes("@") ? email.split("@").pop() : undefined,
cf_country:
req.headers.get("cf-ipcountry") ||
req.headers.get("x-vercel-ip-country") ||
"",
user_agent: req.headers.get("user-agent") || "",
referer_header: req.headers.get("referer") || "",
captured_at: new Date().toISOString(),
},
}),
cache: "no-store",
signal: controller.signal,
});
} catch {
// Diagnostics must never block auth/profile fallback responses.
} finally {
clearTimeout(timeoutId);
}
}
type VerifiedBearerIdentity = {
email: string | null;
userId: string;
};
function extractBearerToken(headerValue: string | null) {
if (!headerValue) return "";
const parts = headerValue.trim().split(/\s+/);
if (parts.length === 2 && parts[0].toLowerCase() === "bearer") {
return parts[1];
}
return "";
}
async function getVerifiedBearerIdentity(
req: NextRequest,
): Promise<VerifiedBearerIdentity | null> {
const token = extractBearerToken(req.headers.get("authorization"));
if (!token) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
if (!supabaseUrl || !anonKey) return null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 4000);
try {
const res = await fetch(`${supabaseUrl.replace(/\/+$/, "")}/auth/v1/user`, {
cache: "no-store",
headers: {
apikey: anonKey,
Authorization: `Bearer ${token}`,
},
signal: controller.signal,
});
if (!res.ok) return null;
const user = await res.json();
const userId = String(user?.id || "").trim();
if (!userId) return null;
return {
email: String(user?.email || "").trim() || null,
userId,
};
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
}
async function degradedAuthProfileResponse({
email,
reason,
req,
response,
userId,
}: {
email: string | null;
reason: string;
req: NextRequest;
response: NextResponse | null;
userId: string;
}) {
const snapshotPayload = entitlementSnapshotToAuthPayload(
readEntitlementSnapshot(req, userId),
);
if (snapshotPayload) {
await trackAuthDiagnosticEvent(req, {
email: snapshotPayload.email || email,
reason,
responseMode: "snapshot",
userId,
});
const snapshotResponse = NextResponse.json({
...snapshotPayload,
email: snapshotPayload.email || email,
entitlement_snapshot_reason: reason,
});
return applyAuthResponseCookies(snapshotResponse, response);
}
await trackAuthDiagnosticEvent(req, {
email,
reason,
responseMode: "degraded",
userId,
});
const degraded = NextResponse.json({
authenticated: true,
user_id: userId,
email,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: reason,
});
return applyAuthResponseCookies(degraded, response);
}
function subscriptionRequiredAuthProfileResponse({
email,
response,
userId,
}: {
email: string | null;
response: NextResponse | null;
userId: string;
}) {
const inactive = NextResponse.json(
buildSubscriptionRequiredAuthProfile({ email, userId }),
);
clearEntitlementSnapshotCookie(inactive);
return applyAuthResponseCookies(inactive, response);
}
async function unauthenticatedAuthProfileResponse({
reason,
req,
response,
}: {
reason: string;
req: NextRequest;
response: NextResponse | null;
}) {
await trackAuthDiagnosticEvent(req, {
email: null,
reason,
responseMode: "anonymous",
});
const anonymous = NextResponse.json(
{
authenticated: false,
subscription_active: false,
points: 0,
degraded_auth_profile: true,
degraded_reason: reason,
},
{ headers: { "Cache-Control": "no-store" } },
);
clearEntitlementSnapshotCookie(anonymous);
return applyAuthResponseCookies(anonymous, response);
}
function snapshotAuthProfileResponse({
email,
reason,
req,
response,
userId,
}: {
email: string | null;
reason: string;
req: NextRequest;
response: NextResponse | null;
userId: string;
}) {
const snapshotPayload = entitlementSnapshotToAuthPayload(
readEntitlementSnapshot(req, userId),
);
if (!snapshotPayload) return null;
const snapshotResponse = NextResponse.json({
...snapshotPayload,
email: snapshotPayload.email || email,
entitlement_snapshot_reason: reason,
});
return applyAuthResponseCookies(snapshotResponse, response);
}
function applyEntitlementSnapshotFromAuthPayload(
response: NextResponse,
data: Record<string, unknown>,
) {
if (data.authenticated === true && data.subscription_active === true) {
return applyEntitlementSnapshotCookie(response, data);
}
if (data.authenticated === false || data.subscription_active === false) {
return clearEntitlementSnapshotCookie(response);
}
return response;
}
function hasRequestSupabaseSessionCookie(req: NextRequest) {
return hasSupabaseSessionCookieValues(
req.cookies.getAll().map((item) => ({
name: item.name,
value: item.value,
})),
);
}
export async function GET(req: NextRequest) {
const timer = createAuthMeTimer(req);
const requestHost =
req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host;
if (
isLocalFullAccessHost(requestHost) ||
isLocalFullAccessHost(req.nextUrl.hostname)
) {
return NextResponse.json(getLocalDevAuthPayload(), {
headers: { "Cache-Control": "no-store" },
});
}
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
return finishAuthMeResponse(
NextResponse.json(getLocalDevAuthPayload(), {
headers: { "Cache-Control": "no-store" },
}),
timer,
"local_full_access",
);
}
if (!API_BASE) {
return finishAuthMeResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
const preferSnapshot = req.nextUrl.searchParams.get("prefer_snapshot") === "1";
if (
preferSnapshot &&
!req.headers.get("authorization") &&
hasRequestSupabaseSessionCookie(req)
) {
const snapshotPayload = timer.measureSync(
"snapshot_cookie",
() => entitlementSnapshotToAuthPayload(readEntitlementSnapshot(req)),
);
if (snapshotPayload) {
return finishAuthMeResponse(
NextResponse.json(
{
...snapshotPayload,
entitlement_snapshot_reason: "prefer_snapshot_fast_path",
},
{ headers: { "Cache-Control": "no-store" } },
),
timer,
"prefer_snapshot_fast_path",
);
}
}
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
let bearerIdentity: VerifiedBearerIdentity | null | undefined;
const getBearerIdentityOnce = async () => {
if (bearerIdentity !== undefined) return bearerIdentity;
bearerIdentity = await timer.measure(
"bearer_identity",
() => getVerifiedBearerIdentity(req),
);
return bearerIdentity;
};
try {
const auth = await buildBackendRequestHeaders(req);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 6000);
let res: Response;
try {
res = await fetch(`${API_BASE}/api/auth/me`, {
headers: auth.headers,
cache: "no-store",
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
if ((res.status === 401 || res.status === 403) && auth.authUserId) {
const response = NextResponse.json({
authenticated: true,
user_id: auth.authUserId,
email: auth.authEmail || null,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: `backend_${res.status}`,
});
return applyAuthResponseCookies(response, auth.response);
}
if (res.status === 401 || res.status === 403) {
auth = await timer.measure(
"auth_headers",
() => buildBackendRequestHeaders(req),
);
if (
hasSupabaseServerEnv() &&
!auth.authUserId &&
!req.headers.get("authorization")
) {
const response = NextResponse.json({
authenticated: false,
subscription_active: false,
points: 0,
});
return applyAuthResponseCookies(response, auth.response);
if (!preferSnapshot) clearEntitlementSnapshotCookie(response);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"no_session",
);
}
if (preferSnapshot) {
const identity =
auth.authUserId
? { email: auth.authEmail || null, userId: auth.authUserId }
: await getBearerIdentityOnce();
if (identity?.userId) {
const snapshotResponse = snapshotAuthProfileResponse({
email: identity.email,
reason: "prefer_snapshot",
req,
response: auth.response,
userId: identity.userId,
});
if (snapshotResponse) {
return finishAuthMeResponse(
snapshotResponse,
timer,
"prefer_snapshot",
);
}
}
}
if (!auth) throw new Error("auth headers unavailable");
const backendAuth = auth;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 6000);
let res: Response;
try {
res = await timer.measure("backend_fetch", async () =>
await fetch(buildBackendAuthMeUrl(req), {
headers: backendAuth.headers,
cache: "no-store",
signal: controller.signal,
}),
);
} finally {
clearTimeout(timeoutId);
}
const backendServerTiming = res.headers.get("server-timing") || "";
if (res.status === 401 || res.status === 403) {
const raw = await timer.measure("backend_read", () => res.text());
const authIdentity = auth.authUserId
? { email: auth.authEmail || null, userId: auth.authUserId }
: await getBearerIdentityOnce();
if (
authIdentity?.userId &&
isSubscriptionRequiredBackendResponse(res.status, raw)
) {
return finishAuthMeResponse(
subscriptionRequiredAuthProfileResponse({
email: authIdentity.email,
response: auth.response,
userId: authIdentity.userId,
}),
timer,
"subscription_required",
{ backendServerTiming },
);
}
if (auth.authUserId) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: auth.authEmail || null,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: auth.authUserId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const identity = await getBearerIdentityOnce();
if (identity) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: identity.email,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: identity.userId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const response = NextResponse.json({
authenticated: false,
subscription_active: false,
points: 0,
});
clearEntitlementSnapshotCookie(response);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`anonymous_backend_${res.status}`,
{ backendServerTiming },
);
}
if (!res.ok) {
const raw = await res.text();
const raw = await timer.measure("backend_read", () => res.text());
if (auth.authUserId) {
const response = NextResponse.json({
authenticated: true,
user_id: auth.authUserId,
email: auth.authEmail || null,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: `backend_${res.status}`,
});
return applyAuthResponseCookies(response, auth.response);
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: auth.authEmail || null,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: auth.authUserId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const identity = await getBearerIdentityOnce();
if (identity) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: identity.email,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: identity.userId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
}
const data = await timer.measure("backend_read", () => res.json());
if (data?.authenticated === true && data?.subscription_active == null) {
const userId = String(data.user_id || auth.authUserId || "").trim();
if (userId) {
const snapshotResponse = snapshotAuthProfileResponse({
email: String(data.email || auth.authEmail || "").trim() || null,
reason: "subscription_unknown",
req,
response: auth.response,
userId,
});
if (snapshotResponse) {
return finishAuthMeResponse(
snapshotResponse,
timer,
"subscription_unknown_snapshot",
{ backendServerTiming },
);
}
}
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
applyEntitlementSnapshotFromAuthPayload(response, data);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) {
const auth = await buildBackendRequestHeaders(req);
if (auth.authUserId) {
const response = NextResponse.json({
authenticated: true,
user_id: auth.authUserId,
email: auth.authEmail || null,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: String(error),
});
return applyAuthResponseCookies(response, auth.response);
if (auth?.authUserId) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: auth.authEmail || null,
reason: String(error),
req,
response: auth.response,
userId: auth.authUserId,
}),
timer,
"exception_degraded",
);
}
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch auth profile",
});
const identity = await getBearerIdentityOnce();
if (identity) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: identity.email,
reason: String(error),
req,
response: auth?.response || null,
userId: identity.userId,
}),
timer,
"exception_degraded",
);
}
const snapshotPayload = timer.measureSync(
"snapshot_cookie",
() => entitlementSnapshotToAuthPayload(readEntitlementSnapshot(req)),
);
if (snapshotPayload) {
const snapshotResponse = NextResponse.json({
...snapshotPayload,
entitlement_snapshot_reason: "exception_snapshot",
});
return finishAuthMeResponse(
applyAuthResponseCookies(snapshotResponse, auth?.response || null),
timer,
"exception_snapshot",
);
}
return finishAuthMeResponse(
await unauthenticatedAuthProfileResponse({
reason: String(error),
req,
response: auth?.response || null,
}),
timer,
"exception_anonymous",
);
}
}
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const body = await req.text();
const res = await fetch(`${API_BASE}/api/auth/referral/apply`, {
method: "POST",
headers: {
...auth.headers,
"Content-Type": "application/json",
},
body,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to apply referral code",
});
}
}
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const DETAIL_BATCH_PROXY_TIMEOUT_MS = Number(
process.env.POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS || "15000",
);
function parseRequestedCities(req: NextRequest) {
const requestedLimit = Number(req.nextUrl.searchParams.get("limit") || "12");
const limit = Number.isFinite(requestedLimit)
? Math.max(1, Math.min(24, requestedLimit))
: 12;
const seen = new Set<string>();
const requestedCities: string[] = [];
for (const item of (req.nextUrl.searchParams.get("cities") || "").split(
",",
)) {
const city = item.trim();
if (!city || seen.has(city)) continue;
seen.add(city);
requestedCities.push(city);
if (requestedCities.length >= limit) break;
}
return requestedCities;
}
function buildCityDetailBatchTimeoutPayload(requestedCities: string[]) {
return {
cities: requestedCities,
details: {},
errors: {},
missing: requestedCities,
partial: true,
timeout: true,
};
}
export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "city_detail_batch");
if (!API_BASE) {
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const requestedCities = parseRequestedCities(req);
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
const searchParams = new URLSearchParams({
cities: req.nextUrl.searchParams.get("cities") || "",
force_refresh: forceRefresh,
limit: req.nextUrl.searchParams.get("limit") || "12",
});
for (const key of ["market_slug", "target_date", "resolution", "scope"]) {
const value = req.nextUrl.searchParams.get(key);
if (value) searchParams.set(key, value);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DETAIL_BATCH_PROXY_TIMEOUT_MS);
try {
return await proxyBackendJsonGet(req, {
cacheControl: cachePolicy.responseCacheControl,
cacheControlForData: (data) =>
data &&
typeof data === "object" &&
(data as { partial?: unknown }).partial === true
? "no-store, max-age=0"
: cachePolicy.responseCacheControl,
fetchCache: "no-store",
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" },
status: 200,
}),
timeoutPublicMessage: "City detail batch request timed out",
timing: timer,
url: `${API_BASE}/api/cities/detail-batch?${searchParams.toString()}`,
});
} finally {
clearTimeout(timeoutId);
}
}
+50 -12
View File
@@ -1,21 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { NextRequest } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildCachedJsonResponse } from "@/lib/http-cache";
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_BACKEND_TIMEOUT_MS = Number(
process.env.POLYWEATHER_CITIES_BACKEND_TIMEOUT_MS || 1000,
);
function staticCitiesFallback(req: NextRequest, reason: string) {
const response = buildCachedJsonResponse(
req,
{
cities: STATIC_CITY_LIST,
source: "static_fallback",
stale: true,
},
STATIC_CITIES_CACHE_CONTROL,
);
response.headers.set("x-polyweather-cities-source", "static-fallback");
response.headers.set("x-polyweather-cities-fallback-reason", reason);
return response;
}
export async function GET(req: NextRequest) {
if (!API_BASE) {
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
return staticCitiesFallback(req, "missing-api-base");
}
return proxyBackendJsonGet(req, {
cacheControl: "public, max-age=0, s-maxage=60, stale-while-revalidate=300",
publicMessage: "Failed to fetch cities",
revalidateSeconds: 60,
url: `${API_BASE}/api/cities`,
});
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), CITIES_BACKEND_TIMEOUT_MS);
try {
const response = await proxyBackendJsonGet(req, {
cacheControl: CITIES_CACHE_CONTROL,
publicMessage: "Failed to fetch cities",
revalidateSeconds: 60,
signal: abortController.signal,
statusOnException: 504,
timeoutPublicMessage: "Cities backend timed out",
url: `${API_BASE}/api/cities`,
});
if (response.status >= 500) {
return staticCitiesFallback(
req,
response.status === 504 ? "backend-timeout" : "backend-error",
);
}
return response;
} finally {
clearTimeout(timeout);
}
}
+38 -16
View File
@@ -9,6 +9,10 @@ import {
} from "@/lib/api-proxy";
import { buildCachedJsonResponse } from "@/lib/http-cache";
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -34,15 +38,16 @@ export async function GET(
req: NextRequest,
context: { params: Promise<{ name: string }> },
) {
const timer = createProxyTimer(req, "city_detail");
if (!API_BASE) {
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
return finishProxyTimedResponse(response, timer, "missing_api_base");
}
const { name } = await context.params;
const { name } = await timer.measure("route_params", () => context.params);
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
const depth = req.nextUrl.searchParams.get("depth");
@@ -67,31 +72,48 @@ export async function GET(
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
try {
const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const res = await fetch(url, {
headers: auth.headers,
...(cachePolicy.fetchMode === "no-store"
? { cache: "no-store" as const }
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
});
const auth = await timer.measure("auth_headers", () =>
buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
}),
);
const res = await timer.measure("backend_fetch", () =>
fetch(url, {
headers: auth.headers,
...(cachePolicy.fetchMode === "no-store"
? { cache: "no-store" as const }
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
if (!res.ok) {
const raw = await res.text();
const raw = await timer.measure("backend_read", () => res.text());
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
}
const data = normalizeCityDetailPayload(await res.json());
const data = normalizeCityDetailPayload(
await timer.measure("backend_read", () => res.json()),
);
const response = buildCachedJsonResponse(
req,
data,
cachePolicy.responseCacheControl,
);
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) {
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch city detail aggregate",
});
return response;
return finishProxyTimedResponse(response, timer, "exception");
}
}
+108
View File
@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
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 },
);
}
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try {
auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/feedback`);
const limit = req.nextUrl.searchParams.get("limit");
if (limit) upstream.searchParams.set("limit", limit);
const res = await fetch(upstream, {
method: "GET",
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
if (!res.ok) {
return applyAuthResponseCookies(
buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 260,
error: "Feedback status request failed",
}),
auth.response,
);
}
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch feedback status",
});
return auth ? applyAuthResponseCookies(response, auth.response) : response;
}
}
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try {
const body = await req.json();
auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/feedback`, {
method: "POST",
headers,
body: JSON.stringify(body),
cache: "no-store",
});
const raw = await res.text();
if (!res.ok) {
return applyAuthResponseCookies(
buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 260,
error: "Feedback request failed",
}),
auth.response,
);
}
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to submit feedback",
});
return auth ? applyAuthResponseCookies(response, auth.response) : response;
}
}
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/analytics/funnel`);
const days = req.nextUrl.searchParams.get("days");
if (days) {
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/billing-risk`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Billing risk check failed",
});
}
}
+7
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : "";
@@ -9,6 +10,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -20,6 +24,9 @@ export async function PUT(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
const raw = await res.text();
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(
req: NextRequest,
context: { params: Promise<{ feedbackId: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const { feedbackId } = await context.params;
const body = await req.json();
const res = await fetch(
`${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/status`,
{
method: "POST",
cache: "no-store",
headers: {
...Object.fromEntries(new Headers(auth.headers).entries()),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
);
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to update feedback status",
});
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/feedback`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch ops feedback",
});
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`);
const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit);
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,8 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/memberships/growth`);
const days = req.nextUrl.searchParams.get("days");
if (days) url.searchParams.set("days", days);
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/memberships/overview`);
const limit = req.nextUrl.searchParams.get("limit");
const days = req.nextUrl.searchParams.get("days");
if (limit) url.searchParams.set("limit", limit);
if (days) url.searchParams.set("days", days);
const res = await fetch(url.toString(), {
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch memberships overview",
});
}
}
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,8 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/memberships`);
const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit);
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/observation-collector-status`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Observation collector status check failed",
});
}
}
+44 -13
View File
@@ -4,24 +4,46 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "ops_online_users");
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/ops/online-users`, {
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
const auth = await timer.measure("auth_headers", () =>
buildBackendRequestHeaders(req),
);
const authError = timer.measureSync("ops_auth", () =>
requireOpsProxyAuth(req, auth),
);
if (authError) {
return finishProxyTimedResponse(authError, timer, "ops_auth_error");
}
const res = await timer.measure("backend_fetch", () =>
fetch(`${API_BASE}/api/ops/online-users`, {
headers: auth.headers,
cache: "no-store",
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
const raw = await timer.measure("backend_read", () => res.text());
const response = new NextResponse(raw, {
status: res.status,
headers: {
@@ -29,10 +51,19 @@ export async function GET(req: NextRequest) {
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
res.ok ? "ok" : `upstream_${res.status}`,
{ backendServerTiming },
);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch online users",
});
return finishProxyTimedResponse(
buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch online users",
}),
timer,
"exception",
);
}
}
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -21,6 +22,9 @@ export async function POST(req: NextRequest, context: RouteContext) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const { eventId } = await context.params;
const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, {
method: "POST",
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/payments/incidents`);
const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit);
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/payments`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch ops payments",
});
}
}
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND = API_BASE ? `${API_BASE}/api/ops/sensitive-config` : "";
export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config fetch failed" }); }
}
export async function PUT(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config update failed" }); }
}
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/source-health`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Source health check failed",
});
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || "";
@@ -9,6 +10,9 @@ export async function POST(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" };
if (ENTITLEMENT_TOKEN) {
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -41,6 +42,23 @@ async function findSupabaseUserIdByEmail(email: string) {
if (!supabaseUrl || !serviceRoleKey) {
throw new Error("Supabase service role is not configured on Vercel");
}
const profileRes = await fetch(
`${supabaseUrl}/rest/v1/profiles?select=id&email=eq.${encodeURIComponent(email)}&limit=1`,
{
headers: {
apikey: serviceRoleKey,
Authorization: `Bearer ${serviceRoleKey}`,
Accept: "application/json",
},
cache: "no-store",
},
);
const profileData = (await profileRes.json().catch(() => [])) as Array<{ id?: string }>;
if (profileRes.ok) {
const profileUserId = String(profileData?.[0]?.id || "").trim();
if (profileUserId) return { supabaseUrl, serviceRoleKey, userId: profileUserId };
}
const res = await fetch(
`${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`,
{
@@ -109,7 +127,7 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
apikey: serviceRoleKey,
Authorization: `Bearer ${serviceRoleKey}`,
"Content-Type": "application/json",
Prefer: "return=representation",
Prefer: "return=minimal",
},
body: JSON.stringify(payload),
cache: "no-store",
@@ -138,6 +156,9 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
export async function POST(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
if (!API_BASE) {
return grantSubscriptionDirectly(req, body, auth.authEmail);
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/telegram/members-audit`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/training/accuracy`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/truth-history`);
for (const key of ["city", "date_from", "date_to", "limit"]) {
const value = req.nextUrl.searchParams.get(key);
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function POST(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, {
method: "POST",
+4
View File
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/users`);
const q = req.nextUrl.searchParams.get("q");
const limit = req.nextUrl.searchParams.get("limit");
+4
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/logs`);
const level = req.nextUrl.searchParams.get("level");
const lines = req.nextUrl.searchParams.get("lines");
+1 -1
View File
@@ -13,7 +13,7 @@ export async function GET(req: NextRequest) {
return proxyBackendJsonGet(req, {
cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900",
detailLimit: 350,
includeSupabaseIdentity: true,
includeSupabaseIdentity: false,
publicMessage: "Failed to fetch payment config",
revalidateSeconds: 300,
url: `${API_BASE}/api/payments/config`,
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -25,7 +25,7 @@ export async function POST(
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -11,6 +11,35 @@ import {
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
function looksLikeHtmlDocument(value: string) {
const text = String(value || "").trim().toLowerCase();
return (
text.startsWith("<!doctype html") ||
text.startsWith("<html") ||
/<title>[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || ""))
);
}
function submitErrorMessage(raw: string) {
try {
const parsed = JSON.parse(String(raw || "")) as {
detail?: unknown;
error?: unknown;
message?: unknown;
};
const message = [parsed.detail, parsed.error, parsed.message].find(
(item) => typeof item === "string" && item.trim(),
);
if (typeof message === "string") {
const trimmed = message.trim();
if (!looksLikeHtmlDocument(trimmed)) return trimmed.slice(0, 350);
}
} catch {
// Non-JSON upstream errors are commonly HTML 50x pages; do not expose them.
}
return "Payment submit upstream failed";
}
export async function POST(
req: NextRequest,
context: { params: Promise<{ intentId: string }> },
@@ -25,7 +54,7 @@ export async function POST(
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
@@ -40,14 +69,9 @@ export async function POST(
);
if (!res.ok) {
const raw = await res.text();
let detail = raw.slice(0, 350);
try {
const parsed = JSON.parse(raw);
if (parsed.detail) detail = String(parsed.detail).slice(0, 350);
} catch {}
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350,
error: detail || undefined,
error: submitErrorMessage(raw),
});
return applyAuthResponseCookies(response, auth.response);
}
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -25,7 +25,7 @@ export async function POST(
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
+2 -2
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -36,7 +36,7 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
@@ -18,7 +18,7 @@ export async function POST(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
method: "POST",
+1 -1
View File
@@ -16,7 +16,7 @@ export async function GET(req: NextRequest) {
conditionalResponse: false,
detailLimit: 500,
fetchCache: "no-store",
includeSupabaseIdentity: true,
includeSupabaseIdentity: false,
publicMessage: "Failed to fetch payment runtime",
url: `${API_BASE}/api/payments/runtime`,
});
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -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 fetchWalletChallengeWithRetry(
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 logWalletChallengeProxyError(
error: unknown,
authDebug: Record<string, unknown>,
) {
const source = error as { name?: unknown; message?: unknown; cause?: unknown };
console.error("[payment-wallet-challenge-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 challenge request" },
{ status: 400 },
);
}
let authDebug: Record<string, unknown> = {};
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
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 fetchWalletChallengeWithRetry(
`${API_BASE}/api/payments/wallets/challenge`,
{
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
},
);
if (!res.ok) {
const raw = await res.text();
const response = 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) {
logWalletChallengeProxyError(error, authDebug);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to create wallet challenge",
status: 502,
publicMessage:
"Wallet challenge service is temporarily unavailable. Please retry.",
extra: { retryable: true },
});
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -45,7 +45,7 @@ export async function DELETE(req: NextRequest) {
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -21,7 +21,7 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
@@ -37,7 +37,9 @@ export async function POST(req: NextRequest) {
const timeoutId = setTimeout(() => controller.abort(), OVERVIEW_PROXY_TIMEOUT_MS);
try {
auth = await buildBackendRequestHeaders(req);
auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
headers.set("Accept", "application/json");
+19 -5
View File
@@ -1,19 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy";
import { DASHBOARD_REFRESH_POLICY_SEC } from "@/lib/refresh-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "40000",
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "35000",
);
export const maxDuration = 45;
export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "scan_terminal");
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
@@ -41,7 +51,10 @@ export async function GET(req: NextRequest) {
if (tradingRegion != null && tradingRegion !== "") {
params.set("region", tradingRegion);
}
const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 10);
const cachePolicy = buildForceRefreshProxyCachePolicy(
forceRefresh,
DASHBOARD_REFRESH_POLICY_SEC.scanRows,
);
const url = `${API_BASE}/api/scan/terminal?${params.toString()}`;
@@ -57,6 +70,7 @@ export async function GET(req: NextRequest) {
revalidateSeconds: cachePolicy.revalidateSeconds,
signal: controller.signal,
timeoutPublicMessage: "Scan terminal request timed out",
timing: timer,
url,
});
} finally {
+85 -1
View File
@@ -1,7 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { BACKEND_ENTITLEMENT_HEADER } from "@/lib/backend-auth";
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
import { getConfiguredSiteUrl } from "@/lib/site-url";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
function normalizeNextPath(input: string | null) {
const fallback = "/";
const raw = String(input || "").trim();
@@ -11,6 +14,59 @@ function normalizeNextPath(input: string | null) {
return raw;
}
function normalizeAuthError(input: string | null) {
const fallback = "Authentication failed. Please sign in again.";
const raw = String(input || "")
.replace(/[\r\n]+/g, " ")
.trim();
if (!raw) return fallback;
return raw.slice(0, 240);
}
function redirectToLoginWithError({
baseUrl,
error,
nextPath,
}: {
baseUrl: string;
error: string | null;
nextPath: string;
}) {
const loginUrl = new URL("/auth/login", baseUrl);
loginUrl.searchParams.set("next", nextPath);
loginUrl.searchParams.set("auth_error", "1");
loginUrl.searchParams.set("error", normalizeAuthError(error));
return NextResponse.redirect(loginUrl);
}
async function warmSignupTrial(accessToken: string) {
const token = String(accessToken || "").trim();
if (!API_BASE || !token) return;
const headers = new Headers({
Accept: "application/json",
Authorization: `Bearer ${token}`,
});
const backendToken = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim();
if (backendToken) {
headers.set(BACKEND_ENTITLEMENT_HEADER, backendToken);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2500);
try {
await fetch(`${API_BASE.replace(/\/+$/, "")}/api/auth/me?scope=entitlement`, {
cache: "no-store",
headers,
signal: controller.signal,
});
} catch {
// The account/terminal bootstrap will retry. Callback must not strand login.
} finally {
clearTimeout(timeoutId);
}
}
export async function GET(request: NextRequest) {
const siteUrl = getConfiguredSiteUrl();
if (siteUrl) {
@@ -29,6 +85,16 @@ export async function GET(request: NextRequest) {
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
const baseUrl = siteUrl || request.nextUrl.origin;
const redirectUrl = new URL(nextPath, baseUrl);
const callbackError =
request.nextUrl.searchParams.get("error_description") ||
request.nextUrl.searchParams.get("error");
if (callbackError) {
return redirectToLoginWithError({
baseUrl,
error: callbackError,
nextPath,
});
}
if (!hasSupabaseServerEnv()) {
return NextResponse.redirect(redirectUrl);
@@ -38,7 +104,25 @@ export async function GET(request: NextRequest) {
const supabase = createSupabaseRouteClient(request, response);
const code = request.nextUrl.searchParams.get("code");
if (code) {
await supabase.auth.exchangeCodeForSession(code);
const {
data: { session },
error: exchangeError,
} = await supabase.auth.exchangeCodeForSession(code);
if (exchangeError) {
return redirectToLoginWithError({
baseUrl,
error: exchangeError.message,
nextPath,
});
}
if (!session?.access_token) {
return redirectToLoginWithError({
baseUrl,
error: "Authentication session was not created. Please sign in again.",
nextPath,
});
}
await warmSignupTrial(session?.access_token || "");
}
return response;
+15 -2
View File
@@ -2,7 +2,7 @@ import { LoginClient } from "@/components/auth/LoginClient";
import { I18nProvider } from "@/hooks/useI18n";
type PageProps = {
searchParams?: Promise<{ next?: string; mode?: string }>;
searchParams?: Promise<{ error?: string; mode?: string; next?: string }>;
};
function normalizeNextPath(input: string | undefined) {
@@ -19,13 +19,26 @@ function normalizeMode(input: string | undefined): "login" | "signup" {
return "login";
}
function normalizeAuthError(input: string | undefined) {
const raw = String(input || "")
.replace(/[\r\n]+/g, " ")
.trim();
if (!raw) return "";
return raw.slice(0, 240);
}
export default async function LoginPage({ searchParams }: PageProps) {
const params = (await searchParams) || {};
const nextPath = normalizeNextPath(params.next);
const initialMode = normalizeMode(params.mode);
const initialError = normalizeAuthError(params.error);
return (
<I18nProvider>
<LoginClient nextPath={nextPath} initialMode={initialMode} />
<LoginClient
nextPath={nextPath}
initialMode={initialMode}
initialError={initialError}
/>
</I18nProvider>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { ResetPasswordClient } from "@/components/auth/ResetPasswordClient";
import { I18nProvider } from "@/hooks/useI18n";
type PageProps = {
searchParams?: Promise<{ next?: string }>;
};
function normalizeNextPath(input: string | undefined) {
const fallback = "/account";
const raw = String(input || "").trim();
if (!raw) return fallback;
if (!raw.startsWith("/")) return fallback;
if (raw.startsWith("//")) return fallback;
return raw;
}
export default async function ResetPasswordPage({ searchParams }: PageProps) {
const params = (await searchParams) || {};
const nextPath = normalizeNextPath(params.next);
return (
<I18nProvider>
<ResetPasswordClient nextPath={nextPath} />
</I18nProvider>
);
}
+127
View File
@@ -285,6 +285,133 @@
0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; }
}
@keyframes landingRise {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes landingFloat {
0%,
100% {
translate: 0 0;
}
50% {
translate: 0 -10px;
}
}
@keyframes landingScan {
0% {
transform: translateY(-120%);
opacity: 0;
}
18%,
72% {
opacity: 0.48;
}
100% {
transform: translateY(120%);
opacity: 0;
}
}
@keyframes landingPulseDot {
0%,
100% {
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.22);
transform: scale(1);
}
50% {
box-shadow: 0 0 0 7px rgba(37, 99, 235, 0);
transform: scale(1.08);
}
}
.landing-rise {
opacity: 0;
animation: landingRise 720ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.landing-delay-1 {
animation-delay: 100ms;
}
.landing-delay-2 {
animation-delay: 190ms;
}
.landing-delay-3 {
animation-delay: 280ms;
}
.landing-float {
animation: landingFloat 7s ease-in-out infinite;
}
.landing-float-slow {
animation: landingFloat 9s ease-in-out infinite;
}
.landing-hover-lift {
transition:
transform 220ms ease,
border-color 220ms ease,
box-shadow 220ms ease;
}
.landing-hover-lift:hover {
transform: translateY(-4px);
border-color: rgba(148, 163, 184, 0.78);
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.1);
}
.landing-screen-glow {
position: relative;
}
.landing-screen-glow::after {
content: "";
pointer-events: none;
position: absolute;
inset: 44px 8px 8px;
border-radius: 6px;
background: linear-gradient(
180deg,
transparent 0%,
rgba(37, 99, 235, 0.12) 48%,
transparent 100%
);
mix-blend-mode: multiply;
opacity: 0;
animation: landingScan 5.6s ease-in-out infinite;
}
.landing-pulse-dot {
animation: landingPulseDot 2.6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.landing-rise,
.landing-float,
.landing-float-slow,
.landing-screen-glow::after,
.landing-pulse-dot {
opacity: 1;
animation: none;
}
.landing-hover-lift,
.landing-hover-lift:hover {
transform: none;
}
}
}
/* ── Reduced motion: disable all animations and transitions ── */
+2 -2
View File
@@ -23,7 +23,7 @@ export const metadata: Metadata = {
template: "%s | PolyWeather",
},
description:
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards. Real-time observations for 51 global cities.",
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context. Real-time observations for 51 global cities.",
manifest: "/site.webmanifest",
metadataBase: new URL("https://polyweather.top"),
alternates: {
@@ -34,7 +34,7 @@ export const metadata: Metadata = {
siteName: "PolyWeather",
title: "PolyWeather | Institutional Weather Signal Intelligence",
description:
"Paid professional weather-signal intelligence terminal. METAR evidence, DEB forecast blending, AI decision cards. 51 cities, real-time.",
"Paid professional weather-signal intelligence terminal. METAR evidence, DEB forecast blending, structured decision context. 51 cities, real-time.",
url: "https://polyweather.top",
images: [
{
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import { requireOpsAdmin } from "@/lib/ops-admin";
import { FeedbackPageClient } from "@/components/ops/feedback/FeedbackPageClient";
export const metadata: Metadata = { title: "用户反馈 — PolyWeather Ops" };
export default async function OpsFeedbackPage() {
await requireOpsAdmin("/ops/feedback");
return <FeedbackPageClient />;
}
+21 -10
View File
@@ -1,12 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { PreloadTerminalData } from "@/components/landing/PreloadTerminalData";
import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLandingPage";
export const metadata: Metadata = {
title: "PolyWeather | Institutional Weather Signal Intelligence",
description:
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.",
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context.",
other: {
preconnect: "https://api.polyweather.top",
},
@@ -38,16 +37,29 @@ export default async function HomePage({
"@type": "WebApplication",
name: "PolyWeather",
description:
"Paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.",
"Paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context.",
url: "https://polyweather.top",
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
offers: {
"@type": "Offer",
price: "10.00",
priceCurrency: "USD",
description: "Monthly subscription",
},
offers: [
{
"@type": "Offer",
name: "Pro monthly",
price: "29.90",
priceCurrency: "USD",
description:
"Pro subscription for 30 days. Referral users pay 20.00 USD-equivalent USDC for the first month.",
availability: "https://schema.org/InStock",
},
{
"@type": "Offer",
name: "Pro quarterly",
price: "79.90",
priceCurrency: "USD",
description: "Pro subscription for 90 days.",
availability: "https://schema.org/InStock",
},
],
};
return (
@@ -56,7 +68,6 @@ export default async function HomePage({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<PreloadTerminalData />
<InstitutionalLandingPage />
</>
);
@@ -12,29 +12,24 @@ import {
} from "lucide-react";
import { useI18n } from "@/hooks/useI18n";
const TELEGRAM_GROUP_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
"https://t.me/+Io5H9oVHFmVjOTQ5",
).trim();
const FAQ_ITEMS = [
{
q_zh: "Pro 包含哪些功能?",
q_en: "What features does Pro include?",
a_zh: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、未来日期分析、城市决策卡、全平台智能气象推送。",
a_en: "Unlocks: intraday METAR rule analysis (including peak window), future-date analysis, city decision cards, and cross-platform smart weather push.",
a_zh: "开通后可解锁:天气决策台、多城市图表巡检、未来日期分析、全平台智能气象推送。",
a_en: "Unlocks: weather decision terminal, multi-city chart monitoring, future-date analysis, and cross-platform smart weather push.",
},
{
q_zh: "当前订阅价格是多少?",
q_en: "What is the current subscription price?",
a_zh: "目前仅提供月付:10 USDC / 30 天。",
a_en: "Monthly plan only: 10 USDC / 30 days.",
a_zh: "Pro 月付 29.9 USDC / 30 天,Pro 季度 79.9 USDC / 90 天。",
a_en: "Pro monthly is 29.9 USDC / 30 days. Pro quarterly is 79.9 USDC / 90 days.",
},
{
q_zh: "积分如何抵扣?",
q_en: "How do points work for discounts?",
a_zh: "满 500 积分起兑,每 500 积分抵 1U,单次最多抵 3U。",
a_en: "500 points minimum every 500 points = 1 USDC off, up to 3 USDC discount per payment.",
a_zh: "满 500 积分起兑,每 500 积分抵 1U。月付最多抵 3U,季度最多抵 8U。",
a_en: "500 points minimum: every 500 points = 1 USDC off. Monthly orders can use up to 3 USDC off; quarterly orders can use up to 8 USDC off.",
},
{
q_zh: "支持哪些钱包和支付方式?",
@@ -55,11 +50,11 @@ export function SubscriptionHelpClient() {
? "Complete subscription rules and payment guide."
: "这里是完整的订阅规则和支付说明。你可以先在页面内绑定钱包,再直接开通 Pro。",
priceLabel: isEn ? "Price" : "订阅价格",
priceText: "10 USDC / 30 " + (isEn ? "Days" : "天"),
priceText: isEn ? "29.9 / 30d · 79.9 / 90d" : "29.9 / 30天 · 79.9 / 90天",
discountLabel: isEn ? "Points Discount" : "积分抵扣",
discountText: isEn ? "Up to 3 USDC off" : "最多抵 3U",
communityLabel: isEn ? "Community Points" : "社群积分",
communityLink: isEn ? "Join community to earn" : "加入社群即可赚取积分",
discountText: isEn ? "Monthly 3U · Quarterly 8U" : "月付 3U · 季度 8U",
communityLabel: isEn ? "Telegram Group" : "Telegram 群",
communityLink: isEn ? "Open Account Center" : "前往账户中心",
faqTitle: isEn ? "FAQ" : "常见问题",
}), [isEn]);
@@ -102,8 +97,7 @@ export function SubscriptionHelpClient() {
<span className="text-sm font-semibold">{copy.communityLabel}</span>
</div>
<Link
href={TELEGRAM_GROUP_URL}
target="_blank"
href="/account"
className="inline-flex min-h-9 items-center text-sm font-semibold text-blue-700 underline decoration-blue-500/50 underline-offset-4"
>
{copy.communityLink}
+13 -2
View File
@@ -1,10 +1,21 @@
import type { Metadata } from "next";
import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard";
import dynamic from "next/dynamic";
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
const ScanTerminalDashboard = dynamic(
() =>
import("@/components/dashboard/ScanTerminalDashboard").then(
(mod) => mod.ScanTerminalDashboard,
),
{
loading: () => <DashboardShellSkeleton />,
},
);
export const metadata: Metadata = {
title: "PolyWeather Terminal | Paid Product",
description:
"Paid PolyWeather decision terminal for weather-signal analysis and city decision cards.",
"Paid PolyWeather decision terminal for weather-signal analysis and multi-city chart monitoring.",
};
export default function TerminalPage() {
+485 -153
View File
@@ -52,6 +52,7 @@ import {
WALLETCONNECT_PROJECT_ID,
} from "./constants";
import { InfoRow, PlusIcon } from "./AccountInfoRow";
import { AccountFeedbackPanel } from "./AccountFeedbackPanel";
import {
chainIdToDisplayName,
clearStoredPaymentRecovery,
@@ -84,6 +85,8 @@ export function AccountCenter() {
const [updatedAt, setUpdatedAt] = useState<string>("");
const [user, setUser] = useState<User | null>(null);
const [backend, setBackend] = useState<AuthMeResponse | null>(null);
const [referralCodeInput, setReferralCodeInput] = useState("");
const [referralApplying, setReferralApplying] = useState(false);
const supabaseReady = hasSupabasePublicEnv();
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
@@ -124,6 +127,7 @@ export function AccountCenter() {
boundWallets,
walletAddress,
selectedPlanCode,
selectedPaymentChainId,
selectedTokenAddress,
selectedWallet,
providerMode,
@@ -133,6 +137,8 @@ export function AccountCenter() {
// Shared setters
setSelectedTokenAddress,
setSelectedPaymentChainId,
setSelectedPlanCode,
setSelectedWallet,
setSelectedInjectedProviderKey,
setProviderMode,
@@ -149,6 +155,8 @@ export function AccountCenter() {
selectedPaymentToken,
selectedTokenLabel,
availableTokenList,
availableChainList,
selectedPaymentChain,
effectivePlanList,
resolvedSelectedTokenAddress,
paymentReceiverAddress,
@@ -189,19 +197,49 @@ export function AccountCenter() {
useEffect(() => {
if (!authIsAuthenticated || !authUserId) return;
const actorKey = authUserId.toLowerCase();
const subscriptionPlanCode = String(backend?.subscription_plan_code || "").trim();
const subscriptionSource = String(backend?.subscription_source || "").trim();
const trialSubscription = Boolean(
backend?.subscription_is_trial === true ||
subscriptionPlanCode.toLowerCase().includes("trial") ||
subscriptionSource.toLowerCase().includes("trial"),
);
if (markAnalyticsOnce(`signup_completed:${actorKey}`, "local")) {
trackAppEvent("signup_completed", {
entry: "account_center",
user_id: authUserId,
});
}
if (markAnalyticsOnce(`signup_success:${actorKey}`, "local")) {
trackAppEvent("signup_success", {
entry: "account_center",
user_id: authUserId,
});
}
if (markAnalyticsOnce(`dashboard_active:${actorKey}`, "session")) {
trackAppEvent("dashboard_active", {
entry: "account_center",
user_id: authUserId,
});
}
}, [authIsAuthenticated, authUserId]);
if (trialSubscription && markAnalyticsOnce(`trial_created:${actorKey}`, "local")) {
trackAppEvent("trial_created", {
entry: "account_center",
user_id: authUserId,
subscription_plan_code: subscriptionPlanCode || null,
subscription_source: subscriptionSource || null,
subscription_expires_at: backend?.subscription_expires_at || null,
subscription_is_trial: backend?.subscription_is_trial === true,
});
}
}, [
authIsAuthenticated,
authUserId,
backend?.subscription_expires_at,
backend?.subscription_is_trial,
backend?.subscription_plan_code,
backend?.subscription_source,
]);
// ── Idle callback effect ──────────────────────────────
useEffect(() => {
@@ -295,8 +333,16 @@ export function AccountCenter() {
backend.authenticated === false),
);
const isSubscribed = backend?.subscription_active === true;
const subscriptionSource = String(backend?.subscription_source || "").trim();
const isTrialSubscription = Boolean(
backend?.subscription_is_trial === true ||
String(backend?.subscription_plan_code || "").toLowerCase().includes("trial") ||
subscriptionSource.toLowerCase().includes("trial"),
);
const subscriptionStatusLabel = isSubscribed
? copy.proMember
? isTrialSubscription
? copy.trialBadge
: copy.proMember
: isSubscriptionUnknown
? copy.subscriptionChecking
: isEn
@@ -321,7 +367,8 @@ export function AccountCenter() {
const hasQueuedExtension = Boolean(
isSubscribed && queuedExtensionDays > 0,
);
const canAccessPaidTelegramGroup = Boolean(isSubscribed);
const canAccessPaidTelegramGroup = Boolean(isSubscribed && !isTrialSubscription);
const hasTelegramPanel = Boolean(isTrialSubscription || canAccessPaidTelegramGroup);
const telegramBound =
Number(backend?.telegram_pricing?.telegram_id || 0) > 0;
const displayExpiryRaw = isSubscribed
@@ -380,6 +427,29 @@ export function AccountCenter() {
const expiryLabel = hasQueuedExtension
? copy.accessUntil
: copy.renewalDate;
const displayPlanList = effectivePlanList.length
? effectivePlanList
: [
{ plan_code: "pro_monthly", plan_id: 101, amount_usdc: "29.9", duration_days: 30 },
{ plan_code: "pro_quarterly", plan_id: 102, amount_usdc: "79.9", duration_days: 90 },
];
const selectedPlanDurationDays = Number(selectedPlan?.duration_days || 30);
const overlayPlanLabel =
selectedPlanCode === "pro_quarterly"
? copy.quarterlyPlan
: copy.monthlyPlan;
const overlayPeriodLabel = isEn
? `/ ${selectedPlanDurationDays} days`
: `/ ${selectedPlanDurationDays}`;
const referral = backend?.referral;
const referralCode = String(referral?.code || "").trim();
const appliedReferralCode = String(referral?.applied_code || "").trim();
const canApplyReferralCode = Boolean(
isAuthenticated &&
!isSubscribed &&
!appliedReferralCode &&
referralCodeInput.trim(),
);
// ── Payment overlay tracking effect ──────────────────────
useEffect(() => {
@@ -400,20 +470,31 @@ export function AccountCenter() {
showOverlay,
]);
// ── Weekly points display (component-only derived) ──────
const backendWeeklyPointsRaw = Number(backend?.weekly_points);
const metadataWeeklyPointsRaw = Number(
user?.user_metadata?.weekly_points ?? 0,
);
const weeklyPointsRaw = Number.isFinite(backendWeeklyPointsRaw)
? backendWeeklyPointsRaw
: metadataWeeklyPointsRaw;
const weeklyRankRaw =
backend?.weekly_rank ?? user?.user_metadata?.weekly_rank;
const weeklyPoints = Number.isFinite(weeklyPointsRaw)
? Math.max(0, weeklyPointsRaw)
// ── Referral points display ────────────────────────────
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
const referralRewardPoints = Number.isFinite(referralRewardPointsRaw)
? Math.max(0, referralRewardPointsRaw)
: 3500;
const monthlyReferralCountRaw = Number(referral?.monthly_reward_count ?? 0);
const monthlyReferralCount = Number.isFinite(monthlyReferralCountRaw)
? Math.max(0, monthlyReferralCountRaw)
: 0;
const weeklyRank = weeklyRankRaw == null ? "--" : String(weeklyRankRaw);
const monthlyReferralLimitRaw = Number(referral?.monthly_reward_limit ?? 10);
const monthlyReferralLimit = Number.isFinite(monthlyReferralLimitRaw)
? Math.max(0, monthlyReferralLimitRaw)
: 10;
const monthlyReferralPointsRaw = Number(
referral?.monthly_reward_points ?? monthlyReferralCount * referralRewardPoints,
);
const monthlyReferralPoints = Number.isFinite(monthlyReferralPointsRaw)
? Math.max(0, monthlyReferralPointsRaw)
: 0;
const monthlyReferralPointsLimitRaw = Number(
referral?.monthly_reward_points_limit ?? monthlyReferralLimit * referralRewardPoints,
);
const monthlyReferralPointsLimit = Number.isFinite(monthlyReferralPointsLimitRaw)
? Math.max(0, monthlyReferralPointsLimitRaw)
: monthlyReferralLimit * referralRewardPoints;
// ── Telegram bind command ──────────────────────────────
const bindCommand = userId
@@ -428,6 +509,55 @@ export function AccountCenter() {
});
};
const applyReferralCode = useCallback(async () => {
const code = referralCodeInput.trim();
if (!code || referralApplying) return;
setReferralApplying(true);
setPaymentError("");
setPaymentInfo("");
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (supabaseReady) {
const {
data: { session },
} = await getSupabaseBrowserClient().auth.getSession();
const token = String(session?.access_token || "").trim();
if (token) headers.Authorization = `Bearer ${token}`;
}
const res = await fetch("/api/auth/referral/apply", {
method: "POST",
headers,
body: JSON.stringify({ code }),
});
if (!res.ok) {
const raw = (await res.text()).slice(0, 240);
throw new Error(raw || copy.referralApplyFailed);
}
setPaymentInfo(copy.referralApplied);
setReferralCodeInput("");
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
setPaymentError(
error instanceof Error ? error.message : copy.referralApplyFailed,
);
} finally {
setReferralApplying(false);
}
}, [
copy.referralApplied,
copy.referralApplyFailed,
loadPaymentSnapshot,
loadSnapshot,
referralApplying,
referralCodeInput,
setPaymentError,
setPaymentInfo,
supabaseReady,
]);
// ── Render ────────────────────────────────────────────
if (loading && !refreshing) {
@@ -611,7 +741,7 @@ export function AccountCenter() {
</p>
<p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950">
<TrendingUp size={16} className="text-emerald-400" />{" "}
{weeklyPoints.toLocaleString()}
{monthlyReferralCount.toLocaleString()}
</p>
</div>
<div className="min-w-[140px] rounded-xl border border-blue-200 bg-blue-50 px-6 py-4 text-center">
@@ -620,13 +750,13 @@ export function AccountCenter() {
</p>
<p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950">
<Trophy size={16} className="text-amber-400" />{" "}
{weeklyRank === "--" ? weeklyRank : `#${weeklyRank}`}
{monthlyReferralCount}/{monthlyReferralLimit}
</p>
</div>
</div>
</div>
{/* Weekly Ranking Motivation */}
{/* Referral rewards */}
{showSecondarySections ? (
<div className="flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:col-span-4">
<div>
@@ -637,35 +767,29 @@ export function AccountCenter() {
<div className="space-y-3">
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
<span className="text-sm flex items-center gap-2">
<div className="w-5 h-5 bg-yellow-500 rounded text-black font-bold text-[10px] flex items-center justify-center">
1
</div>{" "}
Top 1
<Coins size={16} className="text-yellow-500" />{" "}
{copy.referralRewardHint}
</span>
<span className="text-xs font-bold text-amber-600">
+200 & 7Pro
+{referralRewardPoints.toLocaleString()}
</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
<span className="text-sm flex items-center gap-2">
<div className="w-5 h-5 bg-slate-300 rounded text-black font-bold text-[10px] flex items-center justify-center">
2
</div>{" "}
Top 2-3
<TrendingUp size={16} className="text-emerald-500" />{" "}
{copy.weeklyPoints}
</span>
<span className="text-xs font-bold text-slate-600">
+100
{monthlyReferralCount}/{monthlyReferralLimit}
</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
<span className="text-sm flex items-center gap-2">
<div className="w-5 h-5 bg-orange-800 rounded text-white font-bold text-[10px] flex items-center justify-center">
4
</div>{" "}
Top 4-10
<Trophy size={16} className="text-blue-500" />{" "}
{copy.totalPoints}
</span>
<span className="text-xs font-bold text-orange-400">
+50
{monthlyReferralPoints.toLocaleString()}/{monthlyReferralPointsLimit.toLocaleString()}
</span>
</div>
</div>
@@ -673,8 +797,7 @@ export function AccountCenter() {
<div className="mt-6 flex items-start gap-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
<Info size={14} className="text-slate-500 mt-0.5 shrink-0" />
<p className="text-[10px] text-slate-500 leading-normal italic">
+
{copy.pointsRule}
</p>
</div>
</div>
@@ -790,6 +913,8 @@ export function AccountCenter() {
<UnlockProOverlay
points={totalPoints}
planPriceUsd={billing.planAmount}
planLabel={overlayPlanLabel}
periodLabel={overlayPeriodLabel}
usePoints={usePoints}
onToggleUsePoints={() => setUsePoints((prev) => !prev)}
billing={{
@@ -807,10 +932,11 @@ export function AccountCenter() {
payBusy={paymentBusy}
payLabel={hasPayingWallet ? copy.payNow : copy.connectAndPay}
manualPayLabel="手动转账"
locale={locale}
errorText={paymentError || undefined}
infoText={paymentInfo || undefined}
txHash={lastTxHash || undefined}
chainId={paymentConfig?.chain_id || 137}
chainId={selectedPaymentChainId || paymentConfig?.chain_id || 137}
paymentTokenLabel={selectedTokenLabel}
faqHref={SUBSCRIPTION_HELP_HREF}
telegramGroupUrl=""
@@ -819,11 +945,47 @@ export function AccountCenter() {
)}
</div>
<AccountFeedbackPanel
isEn={isEn}
title={copy.accountFeedbackTitle}
description={copy.accountFeedbackDescription}
refreshLabel={copy.refresh}
/>
{/* Telegram Bot Section & Payment Details */}
{showSecondarySections ? (
<div className="lg:col-span-12 grid grid-cols-1 md:flex gap-6">
<div
className={`lg:col-span-12 grid grid-cols-1 items-start gap-6 ${
hasTelegramPanel ? "xl:grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)]" : ""
}`}
>
{isTrialSubscription && (
<section className="group relative min-w-0 overflow-hidden rounded-2xl border border-amber-200 bg-amber-50 p-8 shadow-sm">
<Bot
size={140}
className="absolute -right-8 -bottom-8 -rotate-12 text-amber-100"
/>
<div className="relative z-10">
<h3 className="mb-2 flex items-center gap-2 text-lg font-bold text-amber-800">
<Bot size={22} /> {copy.telegramBind}
</h3>
<p className="text-sm leading-6 text-amber-900">
{copy.trialPaidGroupLocked}
</p>
<button
type="button"
onClick={() => setShowOverlay(true)}
disabled={!canOpenCheckoutOverlay}
className="mt-5 inline-flex items-center gap-2 rounded-xl border border-amber-700 bg-amber-600 px-4 py-3 text-xs font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Crown size={14} />
{copy.upgradePro}
</button>
</div>
</section>
)}
{canAccessPaidTelegramGroup && (
<section className="group relative flex-1 overflow-hidden rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<section className="group relative min-w-0 overflow-hidden rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<Bot
size={140}
className="absolute -right-8 -bottom-8 -rotate-12 text-slate-100 transition-transform duration-1000 group-hover:rotate-0"
@@ -901,11 +1063,7 @@ export function AccountCenter() {
)}
{/* Payment Details / Wallet Management */}
<section
className={`flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-8 shadow-sm ${
canAccessPaidTelegramGroup ? "w-full md:w-96" : "w-full"
}`}
>
<section className="w-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:p-7">
<div>
<h3 className="mb-6 flex items-center gap-2 text-sm font-bold uppercase text-blue-700">
<Wallet size={18} /> {copy.paymentMgmt}
@@ -939,117 +1097,289 @@ export function AccountCenter() {
)}
</div>
) : null}
<div className="mb-5 space-y-3">
<InfoRow
icon={Mail}
label={copy.paymentAccount}
value={email || "--"}
isPrimary
/>
<InfoRow
icon={Wallet}
label={copy.paymentWallet}
value={shortAddress(paymentWalletLabel) || "--"}
/>
<InfoRow
icon={ShieldCheck}
label={copy.paymentReceiver}
value={shortAddress(paymentReceiverAddress) || "--"}
/>
<InfoRow
icon={ExternalLink}
label={copy.paymentNetwork}
value={chainIdToDisplayName(paymentConfig?.chain_id)}
/>
<InfoRow
icon={ExternalLink}
label={copy.paymentHost}
value={currentPaymentHost || "--"}
/>
<p className="text-[11px] text-slate-500">
{copy.paymentGuardHint}
</p>
</div>
{availableTokenList.length > 0 && (
<div className="mb-5">
<p className="mb-2 text-[11px] uppercase text-slate-500">
{copy.paymentToken}
</p>
<div className="grid grid-cols-2 gap-2">
{availableTokenList.map((token) => {
const active =
token.address ===
(resolvedSelectedTokenAddress ||
token.address);
return (
<button
type="button"
key={token.address}
onClick={() =>
setSelectedTokenAddress(
token.address,
)
}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-2 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="text-xs font-bold">
{token.symbol}
<div
data-testid="payment-management-grid"
className={`grid gap-6 lg:items-start ${
hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"
}`}
>
<div className="space-y-5">
<div>
<p className="mb-2 text-[11px] uppercase text-slate-500">
{copy.proPlan}
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{displayPlanList.map((plan) => {
const code = String(plan.plan_code || "");
const active = code === selectedPlanCode;
const isQuarterly = code === "pro_quarterly";
return (
<button
type="button"
key={code}
onClick={() => setSelectedPlanCode(code)}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-3 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="flex items-center justify-between gap-2 text-xs font-bold">
<span>
{isQuarterly
? copy.quarterlyPlan
: copy.monthlyPlan}
</span>
<span>{plan.amount_usdc} USDC</span>
</div>
<div className="mt-1 text-[10px] opacity-80">
{code} · {plan.duration_days}
</div>
</button>
);
})}
</div>
{appliedReferralCode && selectedPlanCode === "pro_monthly" ? (
<p className="mt-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-[11px] text-emerald-800">
{copy.referralDiscountHint}
</p>
) : null}
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-bold uppercase text-slate-700">
{copy.referralTitle}
</p>
<span className="text-[10px] text-slate-500">
{copy.referralInviteLimit}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
<div>
<p className="mb-1 text-[10px] uppercase text-slate-500">
{copy.referralMyCode}
</p>
<div className="flex gap-2">
<code className="min-w-0 flex-1 truncate rounded-xl border border-slate-200 bg-white px-3 py-2 font-mono text-xs text-blue-700">
{referralCode || "--"}
</code>
{referralCode ? (
<button
type="button"
onClick={() => handleCopy(referralCode)}
className="rounded-xl border border-blue-700 bg-blue-600 px-3 text-xs font-bold text-white hover:bg-blue-700"
>
{copied ? <CheckCircle2 size={15} /> : <Copy size={15} />}
</button>
) : null}
</div>
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{copy.referralRewardHint}
</p>
</div>
<div>
<p className="mb-1 text-[10px] uppercase text-slate-500">
{copy.referralApplyLabel}
</p>
{appliedReferralCode ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-800">
{appliedReferralCode}
</div>
<div className="text-[10px] opacity-80 truncate">
{token.name}
) : (
<div className="flex gap-2">
<input
value={referralCodeInput}
onChange={(event) => setReferralCodeInput(event.target.value)}
placeholder={copy.referralApplyPlaceholder}
className="min-w-0 flex-1 rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs text-slate-950 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
/>
<button
type="button"
onClick={() => void applyReferralCode()}
disabled={!canApplyReferralCode || referralApplying}
className="rounded-xl border border-slate-900 bg-slate-900 px-3 text-xs font-bold text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{referralApplying ? "..." : copy.referralApplyButton}
</button>
</div>
</button>
);
})}
)}
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{copy.referralDiscountHint}
</p>
</div>
</div>
</div>
<div
data-testid="payment-guard-grid"
className={`grid gap-3 ${hasTelegramPanel ? "" : "sm:grid-cols-2"}`}
>
<InfoRow
icon={Mail}
label={copy.paymentAccount}
value={email || "--"}
isPrimary
/>
<InfoRow
icon={Wallet}
label={copy.paymentWallet}
value={shortAddress(paymentWalletLabel) || "--"}
/>
<InfoRow
icon={ShieldCheck}
label={copy.paymentReceiver}
value={shortAddress(paymentReceiverAddress) || "--"}
/>
<InfoRow
icon={ExternalLink}
label={copy.paymentNetwork}
value={
selectedPaymentChain?.name ||
chainIdToDisplayName(selectedPaymentChainId)
}
/>
<InfoRow
icon={ExternalLink}
label={copy.paymentHost}
value={currentPaymentHost || "--"}
/>
<p className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-[11px] leading-5 text-slate-500 sm:col-span-2">
{copy.paymentGuardHint}
</p>
</div>
</div>
)}
{/* Payment Method Tabs */}
<div className="mt-6 border-t border-slate-200 pt-6">
<p className="mb-3 text-[10px] font-semibold uppercase text-slate-500">
{copy.paymentMethodLabel}
</p>
<div className="mb-5 grid grid-cols-2 gap-2 rounded-xl border border-slate-200 bg-slate-100 p-1">
<button
type="button"
onClick={() => setPaymentMethodTab("wallet")}
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
paymentMethodTab === "wallet"
? "bg-white text-blue-700 shadow-sm"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
}`}
>
<Wallet size={12} />
{copy.paymentMethodWallet}
</button>
<button
type="button"
onClick={() => setPaymentMethodTab("manual")}
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
paymentMethodTab === "manual"
? "bg-white text-emerald-700 shadow-sm"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
}`}
>
<ExternalLink size={12} />
{copy.paymentMethodManual}
</button>
</div>
{paymentMethodTab === "wallet" ? (
<div className="space-y-4">
<p className="text-[11px] leading-relaxed text-slate-400">
{copy.paymentWalletDesc}
</p>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-[11px] leading-relaxed text-amber-800">
{copy.paymentGasWarning}
<div className="space-y-5">
{availableChainList.length > 1 && (
<div>
<p className="mb-2 text-[11px] uppercase text-slate-500">
{copy.paymentNetwork}
</p>
<div className="grid grid-cols-2 gap-2">
{availableChainList.map((chain) => {
const active = chain.chain_id === selectedPaymentChainId;
return (
<button
type="button"
key={chain.chain_id}
onClick={() => {
setSelectedPaymentChainId(chain.chain_id);
const nextToken =
paymentConfig?.tokens?.find(
(token) =>
Number(token.chain_id || chain.chain_id) ===
chain.chain_id &&
token.is_default,
) ||
paymentConfig?.tokens?.find(
(token) =>
Number(token.chain_id || chain.chain_id) ===
chain.chain_id,
);
if (nextToken?.address) {
setSelectedTokenAddress(
String(nextToken.address).toLowerCase(),
);
}
}}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-2 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="text-xs font-bold">
{chain.name || chainIdToDisplayName(chain.chain_id)}
</div>
<div className="text-[10px] opacity-80">
{chain.native_currency_symbol || "ETH"} gas
</div>
</button>
);
})}
</div>
</div>
)}
{availableTokenList.length > 0 && (
<div>
<p className="mb-2 text-[11px] uppercase text-slate-500">
{copy.paymentToken}
</p>
<div className="grid grid-cols-2 gap-2">
{availableTokenList.map((token) => {
const active =
token.address ===
(resolvedSelectedTokenAddress ||
token.address);
return (
<button
type="button"
key={`${token.chain_id || selectedPaymentChainId}:${token.address}`}
onClick={() =>
setSelectedTokenAddress(
token.address,
)
}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-2 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="text-xs font-bold">
{token.symbol}
</div>
<div className="truncate text-[10px] opacity-80">
{token.name}
</div>
</button>
);
})}
</div>
</div>
)}
{/* Payment Method Tabs */}
<div className="border-t border-slate-200 pt-5">
<p className="mb-3 text-[10px] font-semibold uppercase text-slate-500">
{copy.paymentMethodLabel}
</p>
<div className="mb-5 grid grid-cols-2 gap-2 rounded-xl border border-slate-200 bg-slate-100 p-1">
<button
type="button"
onClick={() => setPaymentMethodTab("wallet")}
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
paymentMethodTab === "wallet"
? "bg-white text-blue-700 shadow-sm"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
}`}
>
<Wallet size={12} />
{copy.paymentMethodWallet}
</button>
<button
type="button"
onClick={() => setPaymentMethodTab("manual")}
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
paymentMethodTab === "manual"
? "bg-white text-emerald-700 shadow-sm"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
}`}
>
<ExternalLink size={12} />
{copy.paymentMethodManual}
</button>
</div>
{paymentMethodTab === "wallet" ? (
<div className="space-y-4">
<p className="text-[11px] leading-relaxed text-slate-400">
{copy.paymentWalletDesc}
</p>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-[11px] leading-relaxed text-amber-800">
{copy.paymentGasWarning}
</div>
{boundWallets.length ? (
<div className="space-y-3">
@@ -1073,7 +1403,7 @@ export function AccountCenter() {
)}
</div>
<div className="text-[10px]">
{copy.polygonChain}
{chainIdToDisplayName(w.chain_id)}
</div>
<div className="mt-2 flex justify-end">
<button
@@ -1298,8 +1628,8 @@ export function AccountCenter() {
}
disabled={
paymentBusy ||
(txValidation.checked &&
!txValidation.valid)
!(txValidation.checked &&
txValidation.valid === true)
}
className="w-full rounded-xl border border-emerald-700 bg-emerald-600 px-3 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-700 disabled:opacity-50"
>
@@ -1312,6 +1642,8 @@ export function AccountCenter() {
)}
</div>
</div>
</div>
</div>
</section>
</div>
) : (
@@ -0,0 +1,199 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { MessageSquare, RefreshCw } from "lucide-react";
import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops";
import {
feedbackStatusLabel,
feedbackStatusTone,
} from "@/components/dashboard/scan-terminal/feedback-status";
function compactDate(value?: string) {
if (!value) return "--";
return value.slice(0, 16).replace("T", " ");
}
function categoryLabel(value?: string, isEn = false) {
const key = String(value || "").toLowerCase();
if (key === "bug") return "Bug";
if (key === "data") return isEn ? "Data" : "数据";
if (key === "idea") return isEn ? "Suggestion" : "建议";
if (key === "payment") return isEn ? "Payment" : "支付";
if (key === "account") return isEn ? "Account" : "账号";
return isEn ? "Other" : "其他";
}
function formatRewardPoints(points?: number, isEn = false) {
const raw = Number(points || 0);
const value = Number.isFinite(raw) ? Math.max(0, raw) : 0;
return isEn
? `+${value.toLocaleString()} points`
: `+${value.toLocaleString()} 积分`;
}
function rewardStatusText(status?: string, isEn = false) {
const key = String(status || "").toLowerCase();
if (key === "pending") return isEn ? "Reward pending" : "奖励待处理";
if (key === "skipped") return isEn ? "No points awarded" : "未发放积分";
return isEn ? "Feedback reward" : "反馈奖励";
}
function renderFeedbackReward(entry: UserFeedbackEntry, isEn: boolean) {
const rawPoints = Number(entry.reward_points || 0);
const points = Number.isFinite(rawPoints) ? Math.max(0, rawPoints) : 0;
const rewardStatus = String(entry.reward_status || "").toLowerCase();
const reason = String(entry.reward_reason || "").trim();
if (points <= 0 && !rewardStatus && !reason) return null;
const granted = points > 0 || rewardStatus === "granted";
return (
<div
className={`mt-2 rounded-lg border px-3 py-2 text-xs ${
granted
? "border-amber-200 bg-amber-50 text-amber-800"
: "border-slate-200 bg-slate-50 text-slate-600"
}`}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-bold">{rewardStatusText(rewardStatus, isEn)}</span>
{points > 0 ? (
<span className="font-mono font-black">
{formatRewardPoints(points, isEn)}
</span>
) : null}
</div>
{reason ? (
<div className="mt-1 leading-5 text-slate-600">
{isEn ? "Reason" : "奖励原因"}: {reason}
</div>
) : null}
{entry.rewarded_at ? (
<div className="mt-1 font-mono text-[11px] text-slate-400">
{compactDate(entry.rewarded_at)}
</div>
) : null}
</div>
);
}
export function AccountFeedbackPanel({
isEn,
title,
description,
refreshLabel,
}: {
isEn: boolean;
title: string;
description: string;
refreshLabel: string;
}) {
const [entries, setEntries] = useState<UserFeedbackEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [available, setAvailable] = useState(true);
const load = useCallback(async (signal?: AbortSignal) => {
if (typeof fetch !== "function") return;
setLoading(true);
setError("");
try {
const res = await fetch("/api/feedback?limit=10", {
cache: "no-store",
headers: { Accept: "application/json" },
signal,
});
if (res.status === 401 || res.status === 403) {
setAvailable(false);
setEntries([]);
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = (await res.json()) as UserFeedbackPayload;
setEntries(Array.isArray(payload.feedback) ? payload.feedback : []);
setAvailable(true);
} catch (err) {
if (signal?.aborted) return;
setError(String(err).slice(0, 140));
} finally {
if (!signal?.aborted) setLoading(false);
}
}, []);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load]);
const emptyText = useMemo(() => {
if (loading) return isEn ? "Loading feedback..." : "正在加载反馈...";
return isEn ? "No submitted feedback yet." : "暂无已提交反馈。";
}, [isEn, loading]);
if (!available) return null;
return (
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:col-span-12">
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="flex items-center gap-2 text-sm font-bold uppercase text-slate-700">
<MessageSquare size={18} className="text-blue-500" />
{title}
</h3>
<p className="mt-1 text-xs leading-5 text-slate-500">{description}</p>
</div>
<button
type="button"
onClick={() => void load()}
disabled={loading}
className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-3 text-xs font-bold text-slate-600 transition hover:bg-slate-50 disabled:cursor-wait disabled:opacity-60"
>
<RefreshCw size={14} className={loading ? "animate-spin" : ""} />
{refreshLabel}
</button>
</div>
{error && (
<div className="mb-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
{isEn ? "Failed to load feedback: " : "反馈加载失败:"}{error}
</div>
)}
{entries.length === 0 ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500">
{emptyText}
</div>
) : (
<div className="divide-y divide-slate-100 overflow-hidden rounded-xl border border-slate-200">
{entries.map((entry) => (
<div key={entry.id} className="grid gap-3 bg-white px-4 py-3 md:grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded border px-2 py-0.5 text-[10px] font-black ${feedbackStatusTone(entry.status)}`}>
{feedbackStatusLabel(entry.status, isEn)}
</span>
<span className="text-[11px] font-bold text-slate-500">
{categoryLabel(entry.category, isEn)}
</span>
<span className="text-[11px] text-slate-400">
{compactDate(entry.created_at)}
</span>
</div>
<p className="mt-2 line-clamp-2 text-sm font-semibold leading-5 text-slate-900">
{entry.message || (isEn ? "Feedback" : "反馈")}
</p>
{renderFeedbackReward(entry, isEn)}
</div>
<div className="text-xs text-slate-500 md:text-right">
<div className="font-mono">{compactDate(entry.updated_at)}</div>
<div className="mt-1 text-[11px] text-slate-400">
{isEn ? "Updated" : "最近更新"}
</div>
</div>
</div>
))}
</div>
)}
</section>
);
}
@@ -0,0 +1,43 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const accountDir = path.join(projectRoot, "components", "account");
const accountCenterSource = fs.readFileSync(
path.join(accountDir, "AccountCenter.tsx"),
"utf8",
);
const feedbackPanelPath = path.join(accountDir, "AccountFeedbackPanel.tsx");
assert(fs.existsSync(feedbackPanelPath), "account center must ship a My Feedback panel component");
const feedbackPanelSource = fs.readFileSync(feedbackPanelPath, "utf8");
assert(
accountCenterSource.includes('import { AccountFeedbackPanel } from "./AccountFeedbackPanel";') &&
accountCenterSource.includes("<AccountFeedbackPanel") &&
accountCenterSource.includes("accountFeedbackTitle"),
"account center must mount the My Feedback panel with localized account copy",
);
assert(
feedbackPanelSource.includes("/api/feedback?limit=10") &&
feedbackPanelSource.includes("feedbackStatusLabel") &&
feedbackPanelSource.includes("RefreshCw") &&
feedbackPanelSource.includes("useEffect") &&
!feedbackPanelSource.includes("setInterval"),
"account feedback panel must load the current user's feedback once, support manual refresh, and avoid polling",
);
assert(
feedbackPanelSource.includes("reward_points") &&
feedbackPanelSource.includes("reward_reason") &&
feedbackPanelSource.includes("reward_status") &&
feedbackPanelSource.includes("formatRewardPoints") &&
feedbackPanelSource.includes("renderFeedbackReward") &&
feedbackPanelSource.includes("奖励原因"),
"account feedback panel must show per-feedback reward points and reward reasons",
);
}
@@ -0,0 +1,67 @@
import {
buildAuthMePath,
mergeAccountAuthSnapshot,
} from "@/lib/auth-snapshot";
import type { AuthSnapshotLike } from "@/lib/auth-snapshot";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
assert(
buildAuthMePath({ scope: "entitlement" }) ===
"/api/auth/me?scope=entitlement",
"account entitlement probe must use the lightweight auth/me scope",
);
assert(
buildAuthMePath({ preferSnapshot: true, scope: "entitlement" }) ===
"/api/auth/me?prefer_snapshot=1&scope=entitlement",
"terminal auth probe must combine snapshot preference with lightweight entitlement scope",
);
const active: AuthSnapshotLike & { referral?: { code?: string } } = {
authenticated: true,
user_id: "user-1",
subscription_active: true,
subscription_plan_code: "pro_monthly",
subscription_expires_at: "2026-07-01T00:00:00Z",
subscription_total_expires_at: "2026-07-01T00:00:00Z",
subscription_queued_days: 0,
points: 120,
referral: { code: "PW-ABC" },
};
const degraded = mergeAccountAuthSnapshot(active, {
authenticated: true,
user_id: "user-1",
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
points: 0,
degraded_auth_profile: true,
});
assert(
degraded.subscription_active === true &&
degraded.subscription_plan_code === "pro_monthly" &&
degraded.points === 120 &&
degraded.referral?.code === "PW-ABC",
"account snapshot merge must preserve confirmed Pro status when a later auth/me response is degraded",
);
const inactive = mergeAccountAuthSnapshot(active, {
authenticated: true,
user_id: "user-1",
subscription_active: false,
subscription_plan_code: null,
points: 0,
});
assert(
inactive.subscription_active === false,
"account snapshot merge must still accept a confirmed inactive subscription response",
);
}
@@ -0,0 +1,63 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const accountDir = path.join(projectRoot, "components", "account");
const useAccountPaymentSource = fs.readFileSync(
path.join(accountDir, "useAccountPayment.ts"),
"utf8",
);
const usePaymentFlowSource = fs.readFileSync(
path.join(accountDir, "usePaymentFlow.ts"),
"utf8",
);
const useWalletBindSource = fs.readFileSync(
path.join(accountDir, "useWalletBind.ts"),
"utf8",
);
const accountCenterSource = fs.readFileSync(
path.join(accountDir, "AccountCenter.tsx"),
"utf8",
);
const opsPaymentsSource = fs.readFileSync(
path.join(projectRoot, "components", "ops", "payments", "PaymentsPageClient.tsx"),
"utf8",
);
assert(
useAccountPaymentSource.includes("selectedPaymentChainId") &&
useAccountPaymentSource.includes("setSelectedPaymentChainId"),
"account payment state must track the selected payment chain separately from the legacy default chain",
);
assert(
usePaymentFlowSource.includes("chain_id: selectedPaymentChainId") ||
usePaymentFlowSource.includes("chain_id: targetChainId"),
"payment intent creation must send the selected chain_id to the backend",
);
assert(
!usePaymentFlowSource.includes("请在 Polygon 网络转"),
"manual transfer instructions must not hard-code Polygon after Ethereum USDC is supported",
);
assert(
useWalletBindSource.includes("chainName") &&
useWalletBindSource.includes("wallet_addEthereumChain") &&
useWalletBindSource.includes("Ethereum Mainnet"),
"wallet network switching must use chain metadata instead of hard-coded Polygon-only add-network params",
);
assert(
accountCenterSource.includes("availableChainList") &&
accountCenterSource.includes("setSelectedPaymentChainId") &&
accountCenterSource.includes("paymentNetwork"),
"account center must expose a payment network selector when multiple chains are configured",
);
assert(
opsPaymentsSource.includes("etherscan.io") &&
opsPaymentsSource.includes("polygonscan.com"),
"ops payment tx links must route Ethereum payments to Etherscan and Polygon payments to Polygonscan",
);
}
@@ -0,0 +1,116 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const accountCenter = fs.readFileSync(
path.join(projectRoot, "components", "account", "AccountCenter.tsx"),
"utf8",
);
const accountCopy = fs.readFileSync(
path.join(projectRoot, "components", "account", "account-copy.ts"),
"utf8",
);
const useAccountPayment = fs.readFileSync(
path.join(projectRoot, "components", "account", "useAccountPayment.ts"),
"utf8",
);
const usePaymentFlow = fs.readFileSync(
path.join(projectRoot, "components", "account", "usePaymentFlow.ts"),
"utf8",
);
const useBilling = fs.readFileSync(
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",
);
const types = fs.readFileSync(
path.join(projectRoot, "components", "account", "types.ts"),
"utf8",
);
assert(
accountCopy.includes("3天试用") &&
accountCopy.includes("付费 Telegram 群") &&
accountCopy.includes("邀请码"),
"account copy must describe trial limits and referral code UI",
);
assert(
accountCenter.includes("copy.trialPaidGroupLocked") &&
accountCenter.includes("copy.referralInviteLimit") &&
accountCenter.includes("applyReferralCode"),
"account center must expose trial paid-group gating and referral controls",
);
assert(
accountCenter.includes("pro_quarterly") &&
accountCenter.includes("79.9") &&
accountCenter.includes("29.9"),
"account center must show monthly and quarterly Pro prices",
);
assert(
accountCopy.includes("20 USDC") &&
accountCopy.includes("+3500 积分") &&
accountCopy.includes("月付订单最多抵扣 3 USDC") &&
accountCopy.includes("季度订单最多抵扣 8 USDC") &&
!accountCopy.includes("群内有效发言"),
"account copy must describe balanced referral points and remove group-message points",
);
assert(
!useAccountPayment.includes("monthlyPlanList") &&
!usePaymentFlow.includes("monthlyPlanList"),
"payment hooks must not filter checkout plans down to monthly only",
);
assert(
useAccountPayment.includes("applyTelegramGroupPricingToPlanList") &&
useAccountPayment.includes("backend?.telegram_pricing") &&
useAccountPayment.includes("isTelegramPrivateGroupPriceEligible") &&
telegramPricing.includes("is_private_group_member") &&
telegramPricing.includes("telegram_private_group_member") &&
!telegramPricing.includes("is_group_member") &&
useAccountPayment.includes('=== "pro_monthly"') &&
useAccountPayment.includes("amount_usdc: telegramAmountUsdc"),
"account payment plan cards must only display the 5 USDC discounted monthly price after verified /bind eligibility",
);
assert(
useBilling.includes("telegramGroupPriceApplies") &&
useBilling.includes("isTelegramPrivateGroupPriceEligible") &&
useBilling.includes("backend?.telegram_pricing") &&
useBilling.includes("!telegramGroupPriceApplies"),
"billing must not let referral first-month pricing override the lower verified monthly price",
);
assert(
!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",
);
assert(
accountCenter.includes("overlayPlanLabel") &&
accountCenter.includes("overlayPeriodLabel") &&
!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",
);
assert(
types.includes("ReferralSummary") &&
types.includes("referral?: ReferralSummary | null") &&
types.includes("is_private_group_member?: boolean") &&
types.includes("duration_days: number") &&
types.includes("max_discount_usdc_by_plan"),
"account auth and payment types must include referral summary, private Telegram pricing, and plan durations",
);
}
@@ -44,6 +44,10 @@ export function runTests() {
);
const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8");
const walletBindSource = fs.readFileSync(
path.join(projectRoot, "components", "account", "useWalletBind.ts"),
"utf8",
);
const hookPath = path.join(
projectRoot,
"components",
@@ -62,6 +66,10 @@ export function runTests() {
const paymentFlowSource = fs.existsSync(paymentFlowPath)
? fs.readFileSync(paymentFlowPath, "utf8")
: "";
const paymentUtilsSource = fs.readFileSync(
path.join(projectRoot, "components", "account", "payment-utils.ts"),
"utf8",
);
// The receiver validation now lives in the extracted hook file (called
// from createManualPaymentIntent and createIntentAndPay).
@@ -71,6 +79,23 @@ export function runTests() {
paymentFlowSource.includes("assertExpectedPaymentReceiver"),
"AccountCenter must validate backend-returned manual payment receiver before displaying it",
);
assert(
/!\(\s*txValidation\.checked\s*&&\s*txValidation\.valid === true\s*\)/.test(
accountCenterSource,
),
"manual payment submit button must require checked && valid === true",
);
assert(
paymentFlowSource.includes("validateTxHash") &&
paymentFlowSource.includes("/validate"),
"manual payment flow must validate tx hashes with the backend before submission",
);
assert(
paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") &&
paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") <
paymentFlowSource.indexOf("const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`"),
"wallet payment flow must wait for the payment tx receipt before submitting tx hash to the backend",
);
assert(
accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") ||
hookSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") ||
@@ -83,6 +108,11 @@ export function runTests() {
backendAuthSource.includes("requireBackendAuthUser"),
"backend auth helper must expose a real-user requirement for payment mutations",
);
assert(
backendAuthSource.includes("requireBackendPaymentAuth") &&
backendAuthSource.includes("hasBearerAuth"),
"backend auth helper must allow bearer-backed payment mutations to reach the backend verifier",
);
const middlewareSource = fs.readFileSync(middlewarePath, "utf8");
assert(
@@ -90,12 +120,164 @@ export function runTests() {
!middlewareSource.includes("return NextResponse.next();\n }\n }\n\n const response = NextResponse.next"),
"middleware must not treat the mere presence of a bearer token as authenticated",
);
assert(
middlewareSource.includes("function hasSupabaseSessionCookie") &&
middlewareSource.includes("request.cookies.getAll()") &&
middlewareSource.includes("hasSupabaseSessionCookieValues"),
"middleware must detect non-empty Supabase session cookies locally via the shared helper before refreshing auth",
);
assert(
middlewareSource.includes("redirectToLogin(request, pathname)") &&
middlewareSource.indexOf("if (!hasSupabaseSessionCookie(request))") <
middlewareSource.indexOf("await refreshMiddlewareSession(request)"),
"middleware must redirect no-cookie page requests without calling Supabase auth",
);
const terminalGateSource = middlewareSource.slice(
middlewareSource.indexOf("async function handleTerminalGate"),
middlewareSource.indexOf("async function handleSupabaseAuthGate"),
);
assert(
terminalGateSource.includes("return response;") &&
!terminalGateSource.includes("return redirectToLogin(request, pathname);\n}"),
"terminal middleware must not navigate long-lived dashboards away when a session cookie exists but claims refresh is transiently unavailable",
);
assert(
middlewareSource.includes("unauthorizedSupabaseSessionResponse()"),
"middleware must reject no-cookie protected API requests without calling Supabase auth",
);
const authMeRouteSource = fs.readFileSync(
path.join(projectRoot, "app", "api", "auth", "me", "route.ts"),
"utf8",
);
assert(
authMeRouteSource.includes("/auth/v1/user") &&
authMeRouteSource.includes("getVerifiedBearerIdentity") &&
authMeRouteSource.includes("degraded_auth_profile: true"),
"/api/auth/me must verify bearer tokens directly and return a degraded authenticated profile when the backend auth profile is transiently unavailable",
);
assert(
authMeRouteSource.includes("const identity = await getBearerIdentityOnce();") &&
!authMeRouteSource.includes("return buildProxyExceptionResponse(error"),
"/api/auth/me must try bearer identity fallback before returning a proxy exception",
);
assert(
authMeRouteSource.includes("exception_snapshot") &&
authMeRouteSource.includes("unauthenticatedAuthProfileResponse") &&
!authMeRouteSource.includes("return buildProxyExceptionResponse(error"),
"/api/auth/me must serve a snapshot or anonymous auth profile before surfacing proxy exceptions to long-lived clients",
);
for (const route of [
"app/api/ops/analytics/funnel/route.ts",
"app/api/ops/config/route.ts",
"app/api/ops/health-check/route.ts",
"app/api/ops/leaderboard/weekly/route.ts",
"app/api/ops/memberships/route.ts",
"app/api/ops/memberships/growth/route.ts",
"app/api/ops/memberships/overview/route.ts",
"app/api/ops/online-users/route.ts",
"app/api/ops/payments/incidents/route.ts",
"app/api/ops/payments/incidents/[eventId]/resolve/route.ts",
"app/api/ops/subscriptions/extend/route.ts",
"app/api/ops/subscriptions/grant/route.ts",
"app/api/ops/telegram/members-audit/route.ts",
"app/api/ops/training/accuracy/route.ts",
"app/api/ops/truth-history/route.ts",
"app/api/ops/users/route.ts",
"app/api/ops/users/grant-points/route.ts",
"app/api/ops/view-logs/route.ts",
]) {
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
assert(
routeSource.includes("requireOpsProxyAuth(req, auth)") &&
routeSource.indexOf("requireOpsProxyAuth(req, auth)") >
routeSource.indexOf("buildBackendRequestHeaders(req"),
`${route} must reject requests without Supabase identity before forwarding the backend entitlement token`,
);
}
assert(
middlewareSource.includes('pathname === "/api/payments/config"'),
"middleware must treat public payment config as public API so cached config requests do not refresh Supabase sessions",
);
const optionalRefreshFunction = middlewareSource.slice(
middlewareSource.indexOf("function shouldRefreshOptionalSupabaseSession"),
middlewareSource.indexOf("function hasSupabaseSessionCookie"),
);
assert(
!optionalRefreshFunction.includes('pathname.startsWith("/api/ops/")') &&
!optionalRefreshFunction.includes('pathname.startsWith("/api/payments/")'),
"optional Supabase middleware refresh must not pre-read sessions for API routes that already build backend auth headers",
);
const optionalRefreshIndex = middlewareSource.indexOf(
"function shouldRefreshOptionalSupabaseSession",
);
const systemStatusPublicIndex = middlewareSource.indexOf(
'pathname === "/api/system/status"',
);
assert(
systemStatusPublicIndex >= 0 &&
optionalRefreshIndex >= 0 &&
systemStatusPublicIndex < optionalRefreshIndex,
"middleware must treat public system status as public API instead of optional Supabase session refresh",
);
for (const route of paymentRoutes) {
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
assert(
routeSource.includes("requireBackendAuthUser"),
`${route} must reject payment mutations without a real Supabase user`,
routeSource.includes("requireBackendPaymentAuth") &&
!routeSource.includes("requireBackendAuthUser(auth)"),
`${route} must allow bearer-backed payment mutations while still rejecting requests with no auth context`,
);
}
const walletChallengeRouteSource = fs.readFileSync(
path.join(projectRoot, "app/api/payments/wallets/challenge/route.ts"),
"utf8",
);
assert(
walletChallengeRouteSource.includes("fetchWalletChallengeWithRetry"),
"wallet challenge proxy must retry a transient backend connection failure before blocking payment",
);
assert(
walletChallengeRouteSource.includes("Invalid wallet challenge request"),
"wallet challenge proxy must return a client error for malformed JSON instead of a generic payment failure",
);
assert(
walletChallengeRouteSource.includes("retryable: true"),
"wallet challenge 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(
paymentUtilsSource.includes("looksLikeHtmlDocument") &&
paymentUtilsSource.includes("Payment service is temporarily unavailable") &&
paymentUtilsSource.includes("支付服务暂时不可用"),
"payment API error parsing must collapse upstream HTML/Cloudflare 50x pages into a user-safe message",
);
assert(
paymentFlowSource.includes("readPaymentApiErrorMessage") &&
!paymentFlowSource.includes("(await submitRes.text()).slice(0, 350)") &&
!paymentFlowSource.includes("(await confirmRes.text()).slice(0, 350)"),
"payment submit/confirm failures must use sanitized API error messages instead of raw response text",
);
const submitRouteSource = fs.readFileSync(
path.join(
projectRoot,
"app",
"api",
"payments",
"intents",
"[intentId]",
"submit",
"route.ts",
),
"utf8",
);
assert(
submitRouteSource.includes("Payment submit upstream failed") &&
!submitRouteSource.includes("error: detail || undefined"),
"payment submit proxy must not copy raw upstream HTML into the public error field",
);
}
@@ -58,6 +58,10 @@ export function runTests() {
const hookSource = fs.existsSync(hookPath)
? fs.readFileSync(hookPath, "utf8")
: "";
const paymentFlowSource = fs.readFileSync(
path.join(accountDir, "usePaymentFlow.ts"),
"utf8",
);
assert(
(accountCenterSource.includes('import { usePaymentState } from "./usePaymentState";') ||
hookSource.includes('import { usePaymentState } from "./usePaymentState";')) &&
@@ -92,6 +96,26 @@ export function runTests() {
path.join(projectRoot, "app", "api", "auth", "me", "route.ts"),
"utf8",
);
const paymentConfigRouteSource = fs.readFileSync(
path.join(projectRoot, "app", "api", "payments", "config", "route.ts"),
"utf8",
);
const paymentRuntimeRouteSource = fs.readFileSync(
path.join(projectRoot, "app", "api", "payments", "runtime", "route.ts"),
"utf8",
);
const backendAuthSource = fs.readFileSync(
path.join(projectRoot, "lib", "backend-auth.ts"),
"utf8",
);
const opsAdminSource = fs.readFileSync(
path.join(projectRoot, "lib", "ops-admin.ts"),
"utf8",
);
const supabaseServerSource = fs.readFileSync(
path.join(projectRoot, "lib", "supabase", "server.ts"),
"utf8",
);
const subscriptionsPageSource = fs.readFileSync(
path.join(
projectRoot,
@@ -102,6 +126,26 @@ export function runTests() {
),
"utf8",
);
const membershipsPageSource = fs.readFileSync(
path.join(
projectRoot,
"components",
"ops",
"memberships",
"MembershipsPageClient.tsx",
),
"utf8",
);
const opsOverviewSource = fs.readFileSync(
path.join(
projectRoot,
"components",
"ops",
"overview",
"OverviewPageClient.tsx",
),
"utf8",
);
assert(
accountCenterSource.includes(
@@ -133,6 +177,22 @@ export function runTests() {
accountFeatureSource.includes("只有 USDC 可能无法完成授权或支付"),
"payment wallet tab must warn users that Polygon POL gas is required in addition to USDC",
);
assert(
!accountCenterSource.includes("md:w-96"),
"account payment management must not use a narrow fixed sidebar that creates an overlong column",
);
assert(
accountCenterSource.includes("items-start") &&
accountCenterSource.includes("xl:grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)]"),
"account secondary sections must align cards to the top and give payment management a wider responsive column",
);
assert(
accountCenterSource.includes("data-testid=\"payment-management-grid\"") &&
accountCenterSource.includes('hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"') &&
accountCenterSource.includes("data-testid=\"payment-guard-grid\"") &&
accountCenterSource.includes('hasTelegramPanel ? "" : "sm:grid-cols-2"'),
"payment management must only split into internal columns when it is not already sharing the row with a Telegram panel",
);
assert(
!appAnalyticsSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"') &&
!analyticsRouteSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"'),
@@ -143,6 +203,27 @@ export function runTests() {
accountCenterSource.includes('trackAppEvent("dashboard_active"'),
"account center must emit signup_completed and dashboard_active so the ops funnel has top-of-funnel data",
);
assert(
appAnalyticsSource.includes('| "landing_view"') &&
appAnalyticsSource.includes('| "enter_terminal"') &&
appAnalyticsSource.includes('| "login_start"') &&
appAnalyticsSource.includes('| "signup_success"') &&
appAnalyticsSource.includes('| "trial_created"') &&
appAnalyticsSource.includes('| "payment_start"') &&
appAnalyticsSource.includes('| "payment_success"'),
"app analytics must expose the standard growth funnel event names",
);
assert(
accountCenterSource.includes('trackAppEvent("signup_success"') &&
accountCenterSource.includes('trackAppEvent("trial_created"') &&
accountCenterSource.includes("subscription_is_trial"),
"account center must emit signup_success and trial_created for the standard funnel",
);
assert(
paymentFlowSource.includes('trackAppEvent("payment_start"') &&
paymentFlowSource.includes('trackAppEvent("payment_success"'),
"payment flow must emit payment_start and payment_success alongside legacy checkout events",
);
assert(
accountCenterSource.includes("isSubscriptionUnknown") &&
accountCenterSource.includes("subscriptionStatusLabel") &&
@@ -157,6 +238,18 @@ export function runTests() {
hookSource.includes("retriedBackendJson"),
"account snapshot loader must retry with a refreshed Supabase token when local user exists but /api/auth/me reports unauthenticated",
);
assert(
hookSource.includes("refreshEntitlementAfterPayment") &&
paymentFlowSource.includes("refreshEntitlementAfterPayment") &&
paymentFlowSource.includes("await refreshEntitlementAfterPayment();") &&
hookSource.includes("subscription_active === true"),
"successful payment flows must automatically poll /api/auth/me until the paid subscription is visible instead of requiring logout or manual refresh",
);
assert(
!hookSource.includes(".auth.getUser()") &&
hookSource.includes(".auth.getSession()"),
"account snapshot loader must use the local Supabase session instead of calling getUser before /api/auth/me validates the bearer",
);
assert(
subscriptionsPageSource.includes("getSupabaseBrowserClient") &&
subscriptionsPageSource.includes("refreshSession") &&
@@ -166,15 +259,129 @@ export function runTests() {
"ops manual subscription grant/extend must send the Supabase bearer token to avoid 401 when route cookies are stale",
);
assert(
grantRouteSource.includes("grantSubscriptionDirectly") &&
grantRouteSource.includes("res.status === 404") &&
grantRouteSource.includes('"status": "active"'),
"ops subscription grant route must fall back to direct Supabase grant when the VPS backend route is missing",
membershipsPageSource.includes("opsApi.membershipsOverview") &&
!membershipsPageSource.includes('fetch("/api/ops/memberships/growth?days=90"') &&
!membershipsPageSource.includes("Promise.all"),
"ops memberships page must load memberships and growth through one overview proxy request to avoid duplicate Supabase session/subscription reads",
);
assert(
authMeRouteSource.includes("if ((res.status === 401 || res.status === 403) && auth.authUserId)") &&
authMeRouteSource.includes("degraded_reason: `backend_${res.status}`") &&
opsOverviewSource.includes("opsApi.membershipsOverview(200, 30)") &&
!opsOverviewSource.includes("opsApi.memberships()") &&
!opsOverviewSource.includes("opsApi.membershipsGrowth(30)"),
"ops overview page must reuse membershipsOverview for table and growth data instead of issuing separate membership/growth proxy requests",
);
assert(
grantRouteSource.includes("grantSubscriptionDirectly") &&
grantRouteSource.includes("res.status === 404") &&
grantRouteSource.includes('"status": "active"') &&
grantRouteSource.includes("/rest/v1/profiles") &&
grantRouteSource.includes("select=id&email=eq.") &&
grantRouteSource.indexOf("/rest/v1/profiles") <
grantRouteSource.indexOf("/auth/v1/admin/users") &&
grantRouteSource.includes('Prefer: "return=minimal"'),
"ops subscription grant route must fall back to direct Supabase grant and resolve users via indexed profiles before Auth Admin",
);
assert(
authMeRouteSource.includes("isSubscriptionRequiredBackendResponse(res.status, raw)") &&
authMeRouteSource.includes("subscriptionRequiredAuthProfileResponse") &&
authMeRouteSource.includes("degradedAuthProfileResponse") &&
authMeRouteSource.includes("reason: `backend_${res.status}`") &&
authMeRouteSource.includes("subscription_active: null"),
"auth profile proxy must preserve authenticated identity with unknown subscription on backend 401/403 instead of forcing a false paywall",
"auth profile proxy must only convert explicit subscription-required 403 responses to inactive access while preserving unrelated backend 401/403 responses as unknown subscription",
);
assert(
(authMeRouteSource.match(/buildBackendRequestHeaders\(req\)/g) || []).length === 1 &&
authMeRouteSource.includes("let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null"),
"auth profile proxy must build backend auth headers once and reuse them on timeout/error fallback",
);
assert(
authMeRouteSource.includes("hasSupabaseServerEnv()") &&
authMeRouteSource.includes("!auth.authUserId") &&
authMeRouteSource.includes('req.headers.get("authorization")') &&
authMeRouteSource.indexOf("authenticated: false") <
authMeRouteSource.indexOf("await fetch(buildBackendAuthMeUrl(req)"),
"auth profile proxy must return unauthenticated locally for no-session Supabase requests instead of forwarding the backend entitlement token",
);
assert(
authMeRouteSource.includes("function buildBackendAuthMeUrl") &&
authMeRouteSource.includes('url.searchParams.set("scope", "entitlement")'),
"auth profile proxy must forward the lightweight entitlement scope to the backend auth/me endpoint",
);
assert(
authMeRouteSource.includes('reason: "prefer_snapshot_fast_path"') &&
authMeRouteSource.indexOf('reason: "prefer_snapshot_fast_path"') <
authMeRouteSource.indexOf("buildBackendRequestHeaders(req)"),
"auth profile proxy must return a valid entitlement snapshot before reading Supabase session cookies on terminal cold start",
);
assert(
backendAuthSource.includes("if (incomingAuth) {") &&
backendAuthSource.indexOf("if (incomingAuth) {") <
backendAuthSource.indexOf("const supabase = createSupabaseRouteClient"),
"backend proxy must forward caller bearer tokens before creating a Supabase route client to avoid duplicate getUser calls",
);
assert(
backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") &&
backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_EMAIL_HEADER") &&
backendAuthSource.indexOf("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") >
backendAuthSource.indexOf("const sessionUser = session?.user"),
"backend proxy must forward Supabase session user id/email with the backend token so Python can skip duplicate /auth/v1/user validation",
);
assert(
backendAuthSource.includes("function hasSupabaseSessionCookie") &&
backendAuthSource.includes("String(cookie.value || \"\").trim()") &&
backendAuthSource.includes("if (!hasSupabaseSessionCookie(request))") &&
backendAuthSource.indexOf("if (!hasSupabaseSessionCookie(request))") <
backendAuthSource.indexOf("const supabase = createSupabaseRouteClient"),
"backend proxy must skip Supabase route client/session reads when no auth cookie is present",
);
assert(
!backendAuthSource.includes(".auth.getUser()") &&
backendAuthSource.includes(".auth.getSession()"),
"backend proxy must not call Supabase getUser before forwarding a bearer token that the backend validates again",
);
assert(
supabaseServerSource.includes("export function hasSupabaseSessionCookieValues") &&
supabaseServerSource.includes('name === "supabase-auth-token"') &&
supabaseServerSource.includes('name.startsWith("sb-")') &&
supabaseServerSource.indexOf("if (!hasSupabaseSessionCookieValues") <
supabaseServerSource.indexOf("const supabase = createSupabaseServerClient"),
"Supabase server helpers must expose one cookie detector and skip refresh getUser calls when no session cookie exists",
);
assert(
supabaseServerSource.includes(".auth.getClaims()") &&
!supabaseServerSource.includes(".auth.getUser()"),
"Supabase middleware refresh must validate JWTs with getClaims so asymmetric JWT projects avoid per-request /auth/v1/user reads",
);
assert(
opsAdminSource.includes("hasSupabaseSessionCookieValues") &&
opsAdminSource.indexOf("if (!hasSupabaseSessionCookieValues") <
opsAdminSource.indexOf("const supabase = createSupabaseServerClient"),
"ops admin page gate must redirect before creating a Supabase client/getUser call when no session cookie exists",
);
assert(
opsAdminSource.includes(".auth.getClaims()") &&
!opsAdminSource.includes(".auth.getUser()") &&
opsAdminSource.includes("claims?.email"),
"ops admin page gate must use verified JWT claims instead of a per-page getUser auth lookup",
);
assert(
paymentConfigRouteSource.includes("includeSupabaseIdentity: false"),
"payment config proxy must not read Supabase session cookies for public cached config",
);
assert(
paymentRuntimeRouteSource.includes("includeSupabaseIdentity: false"),
"payment runtime proxy must not read Supabase session cookies because backend entitlement token already protects the runtime status payload",
);
assert(
(paymentFlowSource.match(/await buildAuthedHeaders\(true, true\);/g) || [])
.length >= 3,
"manual payment mutations must require a valid Supabase bearer token instead of forwarding unauthenticated requests that surface raw backend JSON",
);
assert(
paymentFlowSource.includes("verifyPaymentAuthReady") &&
paymentFlowSource.indexOf("await verifyPaymentAuthReady()") <
paymentFlowSource.indexOf("resolvePaymentProvider(") &&
paymentFlowSource.includes("auth_confirmed_at"),
"wallet checkout must confirm a fresh Supabase bearer before opening wallet prompts or creating payment intents",
);
}
+40 -11
View File
@@ -10,17 +10,21 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
guestUser: isEn ? "Signed-out account" : "未登录账户",
joinedAt: isEn ? "Joined" : "加入时间",
totalPoints: isEn ? "Total Points" : "总积分 (荣誉)",
weeklyPoints: isEn ? "Weekly Points" : "本周积分 (竞技)",
weeklyRank: isEn ? "Weekly Rank" : "周排行 (竞技)",
weeklyRewards: isEn ? "Weekly Rewards" : "周榜奖励",
weeklyPoints: isEn ? "This Month Invites" : "本月有效邀请",
weeklyRank: isEn ? "Monthly Cap" : "月度上限",
weeklyRewards: isEn ? "Referral Rewards" : "邀请奖励",
membershipDetails: isEn ? "Membership Details" : "会员权限详情",
identityStatus: isEn ? "Identity Status" : "身份状态",
accountFeedbackTitle: isEn ? "My Feedback" : "我的反馈",
accountFeedbackDescription: isEn
? "Recent reports you submitted and their current handling status."
: "你最近提交过的反馈及当前处理状态。",
authMode: isEn ? "Auth Mode" : "鉴权模式",
weatherEngine: isEn ? "Weather Engine" : "气象引擎",
intradayAnalysis: isEn ? "Intraday Analysis" : "今日内分析",
historyFuture: isEn
? "Future-date + Decision Card Analysis"
: "未来日期分析 + 城市决策卡",
? "Future-date + Multi-city Chart Monitoring"
: "未来日期分析 + 城市图表巡检",
smartPush: isEn
? "Cross-platform Smart Weather Push"
: "全平台智能气象查询",
@@ -57,6 +61,31 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
: "城市实测温度群",
copyCommand: isEn ? "Copy fallback command" : "复制兜底命令",
paymentMgmt: isEn ? "Payment Management" : "支付管理",
proPlan: isEn ? "Pro Plan" : "Pro 套餐",
monthlyPlan: isEn ? "Monthly" : "月付",
quarterlyPlan: isEn ? "Quarterly" : "季度",
trialBadge: isEn ? "3-day trial" : "3天试用",
trialPaidGroupLocked: isEn
? "Trial users can use the core product experience, but the paid Telegram group is available to full Pro subscriptions only."
: "3天试用用户可体验核心产品,但无法进入付费 Telegram 群;付费群仅对正式 Pro 开放。",
referralTitle: isEn ? "Referral Code" : "邀请码",
referralMyCode: isEn ? "My invite code" : "我的邀请码",
referralApplyLabel: isEn ? "Use invite code" : "使用邀请码",
referralApplyPlaceholder: isEn ? "Enter invite code" : "输入邀请码",
referralApplyButton: isEn ? "Apply" : "应用",
referralApplied: isEn
? "Referral code applied. Your first monthly payment is now discounted."
: "邀请码已应用,首月 Pro 将按邀请价结算。",
referralApplyFailed: isEn ? "Failed to apply referral code" : "邀请码应用失败",
referralDiscountHint: isEn
? "Invite discount: first monthly Pro is 20 USDC."
: "邀请首月价:Pro 月付 20 USDC。",
referralRewardHint: isEn
? "When an invited user pays for Pro, you receive +3500 points."
: "被邀请人成功付费后,邀请人获得 +3500 积分。",
referralInviteLimit: isEn
? "Monthly referral reward cap: 10 paid invites, up to +35000 points."
: "每月最多 10 个有效付费邀请奖励,最高 +35000 积分。",
paymentToken: isEn ? "Payment Token" : "支付币种",
paymentAccount: isEn ? "Subscription Account" : "订阅归属账号",
paymentWallet: isEn ? "Paying Wallet" : "付款钱包",
@@ -218,8 +247,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
? "Current receiver contract: {address}"
: "当前收款合约: {address}",
manualOrderCreated: isEn
? "Manual transfer order created. Please send {amount} USDC to the receiver on Polygon network. Then submit your tx hash below."
: "手动转账订单已创建:请在 Polygon 网络转 {amount} USDC 到收款地址。完成后在下方提交 tx hash。",
? "Manual transfer order created. Send {amount} {symbol} on {chain} to {receiver}, then submit your tx hash below."
: "手动转账订单已创建:请在 {chain} 转 {amount} {symbol} 到 {receiver}完成后在下方提交 tx hash。",
manualOrderRequired: isEn
? "Please create a manual transfer order first."
: "请先创建手动转账订单。",
@@ -246,8 +275,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
verifyUnknown: isEn ? "Unknown error" : "未知错误",
// ── Telegram bind messages ────────────────────────────────────────
telegramVerifySuccess: isEn
? "Telegram group membership verified. Current membership price: {amount} U."
: "Telegram 群成员验证成功,当前会员价 {amount}U。",
? "Telegram group membership verified. Member monthly price {amount} USDC is active."
: "Telegram 群成员验证成功,群友月付价 {amount} USDC 已生效。",
telegramBindClickHint: isEn
? "Open the Telegram Bot, click Start, and confirm binding. Then refresh this page to request group entry."
: "已打开 Telegram Bot,请在 Bot 内点击 Start 并确认绑定;完成后刷新本页再申请入群。",
@@ -309,8 +338,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
: "加载支付配置失败: {raw}",
// ── Points / Footer ────────────────────────────────────────────────
pointsRule: isEn
? "Points rule: active messages in group (anti-spam) + daily first message bonus. Weekly leaderboard settles every Monday midnight. All active members receive participation awards."
: "积分规则:群内有效发言(自动防刷检测)+ 每日首条发言额外奖励。每周一零点结算周榜,所有活跃用户均享参与奖。",
? "Points rule: points are earned through successful paid referrals only. 500 points = 1 USDC. Monthly orders can use up to 3 USDC off; quarterly orders can use up to 8 USDC off."
: "积分规则:积分仅通过有效付费邀请获得。500 积分 = 1 USDC。月付订单最多抵扣 3 USDC,季度订单最多抵扣 8 USDC。",
pointsDiscount: isEn
? "Available: {points} points discounting ${amount}. Applied automatically on renewal."
: "当前可用 {points} 积分抵扣 ${amount},续费时会自动生效。",
@@ -7,6 +7,12 @@ export function chainIdToDisplayName(chainId: number | undefined | null): string
return "Polygon";
}
export function chainIdToExplorerBase(chainId: number | undefined | null): string {
if (chainId === 1) return "https://etherscan.io";
if (chainId === 137) return "https://polygonscan.com";
return "";
}
export function formatTime(value: string | undefined | null, locale: string) {
if (!value) return "--";
try {
@@ -68,6 +68,54 @@ export type NormalizedPaymentError = {
userRejected: boolean;
};
export function looksLikeHtmlDocument(value: string) {
const text = String(value || "").trim().toLowerCase();
return (
text.startsWith("<!doctype html") ||
text.startsWith("<html") ||
/<title>[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || ""))
);
}
function containsCjk(value: string) {
return /[\u3400-\u9fff]/.test(value);
}
function safePaymentServiceUnavailableMessage(fallback: string) {
return containsCjk(fallback)
? "支付服务暂时不可用,请稍后重试;如果已经付款,请保存 Tx Hash 联系管理员。"
: "Payment service is temporarily unavailable. Please retry shortly; if you already paid, keep the Tx Hash and contact support.";
}
export async function readPaymentApiErrorMessage(
response: Response,
fallback = "Request failed",
limit = 300,
) {
const raw = await response.text();
const trimmed = String(raw || "").trim();
if (!trimmed) return fallback;
let message = "";
try {
const parsed = JSON.parse(trimmed) as {
error?: unknown;
detail?: unknown;
message?: unknown;
};
const parsedMessage = [parsed.error, parsed.detail, parsed.message].find(
(item) => typeof item === "string" && item.trim(),
);
if (typeof parsedMessage === "string") message = parsedMessage.trim();
} catch {
message = trimmed;
}
const candidate = message || trimmed;
if (looksLikeHtmlDocument(candidate)) {
return safePaymentServiceUnavailableMessage(fallback);
}
return candidate.slice(0, limit);
}
export function normalizePaymentError(error: unknown): NormalizedPaymentError {
const source = error as any;
const code = Number(
@@ -0,0 +1,19 @@
import type { TelegramPricing } from "./types";
export function isTelegramPrivateGroupPriceEligible(
pricing?: TelegramPricing | null,
) {
return Boolean(
pricing?.is_private_group_member ||
pricing?.pricing_source === "telegram_private_group_member",
);
}
export function telegramPrivateGroupAmountUsdc(
pricing?: TelegramPricing | null,
) {
if (!isTelegramPrivateGroupPriceEligible(pricing)) return "";
const amount = String(pricing?.amount_usdc || "").trim();
const numeric = Number(amount);
return Number.isFinite(numeric) && numeric > 0 ? amount : "";
}
+46
View File
@@ -10,12 +10,35 @@ export type AuthMeResponse = {
subscription_required?: boolean;
subscription_active?: boolean | null;
subscription_plan_code?: string | null;
subscription_source?: string | null;
subscription_is_trial?: boolean | null;
subscription_starts_at?: string | null;
subscription_expires_at?: string | null;
subscription_total_expires_at?: string | null;
subscription_queued_days?: number | null;
subscription_queued_count?: number | null;
telegram_pricing?: TelegramPricing | null;
referral?: ReferralSummary | null;
degraded_auth_profile?: boolean | null;
degraded_reason?: string | null;
entitlement_snapshot?: boolean | null;
entitlement_snapshot_reason?: string | null;
};
export type ReferralSummary = {
code?: string;
discount_usdc?: string;
discounted_monthly_amount_usdc?: string;
reward_days?: number;
reward_points?: number;
monthly_reward_limit?: number;
monthly_reward_days_limit?: number;
monthly_reward_points_limit?: number;
monthly_reward_count?: number;
monthly_reward_days?: number;
monthly_reward_points?: number;
applied_code?: string;
attribution_status?: string;
};
export type TelegramPricing = {
@@ -23,6 +46,7 @@ export type TelegramPricing = {
telegram_id?: number | null;
telegram_status?: string | null;
is_group_member?: boolean;
is_private_group_member?: boolean;
amount_usdc?: string;
pricing_source?: string;
};
@@ -40,7 +64,24 @@ export type PaymentTokenOption = {
name: string;
address: string;
decimals: number;
chain_id?: number;
chain_code?: string;
chain_name?: string;
receiver_contract?: string;
direct_receiver_address?: string;
explorer_tx_url?: string;
supports_contract_checkout?: boolean;
supports_direct_transfer?: boolean;
is_default?: boolean;
};
export type PaymentChainOption = {
chain_id: number;
code?: string;
name?: string;
native_currency_symbol?: string;
block_explorer_url?: string;
explorer_tx_url?: string;
is_default?: boolean;
};
@@ -48,15 +89,18 @@ export type PointsRedemptionConfig = {
enabled?: boolean;
points_per_usdc?: number;
max_discount_usdc?: number;
max_discount_usdc_by_plan?: Record<string, number>;
};
export type PaymentConfig = {
enabled?: boolean;
configured?: boolean;
chain_id?: number;
default_chain_id?: number;
token_address?: string;
token_decimals?: number;
default_token_address?: string;
chains?: PaymentChainOption[];
tokens?: PaymentTokenOption[];
receiver_contract?: string;
confirmations?: number;
@@ -93,6 +137,7 @@ export type CreatedIntent = {
direct_payment?: {
chain_id: number;
chain?: string;
chain_name?: string;
token_symbol?: string;
token_address: string;
token_decimals?: number;
@@ -101,6 +146,7 @@ export type CreatedIntent = {
amount_usdc: string;
intent_id: string;
expires_at: string;
explorer_tx_url?: string;
};
};
+165 -37
View File
@@ -1,20 +1,45 @@
"use client";
import { useCallback, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { User } from "@supabase/supabase-js";
import {
buildAuthMePath,
mergeAccountAuthSnapshot,
} from "@/lib/auth-snapshot";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
import type {
AuthMeResponse,
BoundWallet,
PaymentConfig,
PaymentPlan,
ProviderMode,
InjectedProviderOption,
TelegramPricing,
} from "./types";
import { usePaymentState } from "./usePaymentState";
import { useWalletBind } from "./useWalletBind";
import { usePaymentFlow } from "./usePaymentFlow";
import { useBilling } from "./useBilling";
import {
isTelegramPrivateGroupPriceEligible,
telegramPrivateGroupAmountUsdc,
} from "./telegram-pricing";
function applyTelegramGroupPricingToPlanList(
plans: PaymentPlan[],
pricing?: TelegramPricing | null,
): PaymentPlan[] {
if (!isTelegramPrivateGroupPriceEligible(pricing)) return plans;
const telegramAmountUsdc = telegramPrivateGroupAmountUsdc(pricing);
if (!telegramAmountUsdc) return plans;
return plans.map((plan) =>
String(plan.plan_code || "").toLowerCase() === "pro_monthly"
? { ...plan, amount_usdc: telegramAmountUsdc }
: plan,
);
}
// ============================================================
export interface UseAccountPaymentParams {
@@ -25,7 +50,7 @@ export interface UseAccountPaymentParams {
backend: AuthMeResponse | null;
user: User | null;
setUser: (user: User | null) => void;
setBackend: (backend: AuthMeResponse | null) => void;
setBackend: Dispatch<SetStateAction<AuthMeResponse | null>>;
setErrorText: (text: string) => void;
setUpdatedAt: (text: string) => void;
showOverlay: boolean;
@@ -146,32 +171,58 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
[supabaseReady, getValidAccessToken],
);
// ── loadSnapshot ──────────────────────────────────────────
const loadSnapshot = useCallback(async () => {
setErrorText("");
const attempt = async (retry: boolean): Promise<void> => {
// ── Auth snapshot refresh ────────────────────────────────
const fetchAuthSnapshot = useCallback(
async (retry: boolean): Promise<AuthMeResponse> => {
const userPromise = supabaseReady
? getSupabaseBrowserClient().auth.getUser()
: Promise.resolve({ data: { user: null as User | null } });
const authHeadersPromise = buildAuthedHeaders(false);
const backendPromise = authHeadersPromise.then((headers) =>
fetch("/api/auth/me", { cache: "no-store", headers }),
);
const [userResult, backendResult] = await Promise.all([userPromise, backendPromise]);
setUser(userResult.data?.user ?? null);
if (!backendResult.ok) {
if (retry && backendResult.status === 401) {
await new Promise((r) => setTimeout(r, 1200));
return attempt(false);
? getSupabaseBrowserClient()
.auth.getSession()
.then(({ data: { session } }) => session?.user ?? null)
.catch(() => null as User | null)
: Promise.resolve(null as User | null);
const [localUser, authHeaders] = await Promise.all([
userPromise,
buildAuthedHeaders(false),
]);
setUser(localUser);
const readAuthSnapshot = async (
headers: Record<string, string>,
options: Parameters<typeof buildAuthMePath>[0] = {},
) => {
const response = await fetch(buildAuthMePath(options), {
cache: "no-store",
headers,
});
if (!response.ok) {
const raw = (await response.text()).slice(0, 260);
const message = copy.httpError
.replace("{status}", String(response.status))
.replace("{raw}", raw);
const error = new Error(message) as Error & { status?: number };
error.status = response.status;
throw error;
}
const raw = (await backendResult.text()).slice(0, 260);
throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw));
return (await response.json()) as AuthMeResponse;
};
let latestHeaders = authHeaders;
let backendJson: AuthMeResponse;
try {
backendJson = await readAuthSnapshot(authHeaders, {
scope: "entitlement",
});
} catch (error) {
if (retry && (error as { status?: number }).status === 401) {
await new Promise((r) => setTimeout(r, 1200));
return fetchAuthSnapshot(false);
}
throw error;
}
let backendJson = (await backendResult.json()) as AuthMeResponse;
if (
retry &&
supabaseReady &&
userResult.data?.user &&
localUser &&
backendJson.authenticated === false
) {
try {
@@ -182,27 +233,66 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
refreshedSession?.access_token || "",
).trim();
if (refreshedToken) {
const retriedBackendResult = await fetch("/api/auth/me", {
cache: "no-store",
headers: { Authorization: `Bearer ${refreshedToken}` },
latestHeaders = { Authorization: `Bearer ${refreshedToken}` };
const retriedBackendJson = await readAuthSnapshot(latestHeaders, {
scope: "entitlement",
});
if (retriedBackendResult.ok) {
const retriedBackendJson =
(await retriedBackendResult.json()) as AuthMeResponse;
backendJson = retriedBackendJson;
}
backendJson = retriedBackendJson;
}
} catch {
// Keep the first response; the UI treats a logged-in local user with
// an unauthenticated backend snapshot as a temporary sync state.
}
}
setBackend(backendJson);
setBackend((previous) => mergeAccountAuthSnapshot(previous, backendJson));
setUpdatedAt(new Date().toISOString());
};
try { await attempt(true); }
if (backendJson.authenticated !== false) {
void readAuthSnapshot(latestHeaders)
.then((fullJson) => {
setBackend((previous) =>
mergeAccountAuthSnapshot(previous, fullJson),
);
setUpdatedAt(new Date().toISOString());
})
.catch(() => {
// The lightweight entitlement snapshot already resolved the UI.
});
}
return backendJson;
},
[
buildAuthedHeaders,
copy.httpError,
setBackend,
setUpdatedAt,
setUser,
supabaseReady,
],
);
// ── loadSnapshot ──────────────────────────────────────────
const loadSnapshot = useCallback(async () => {
setErrorText("");
try { await fetchAuthSnapshot(true); }
catch (error) { setErrorText(String(error)); }
}, [buildAuthedHeaders, supabaseReady, copy.httpError]);
}, [fetchAuthSnapshot, setErrorText]);
const refreshEntitlementAfterPayment = useCallback(async () => {
setErrorText("");
const maxAttempts = 5;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const backendJson = await fetchAuthSnapshot(true);
if (backendJson.subscription_active === true) return;
} catch (error) {
if (attempt === maxAttempts - 1) {
setErrorText(String(error));
return;
}
}
await new Promise((resolve) => setTimeout(resolve, 1200));
}
}, [fetchAuthSnapshot, setErrorText]);
// ── Shared state for sub-hooks ───────────────────────────
// These state variables are managed in the master hook and passed
@@ -211,6 +301,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]);
const [walletAddress, setWalletAddress] = useState("");
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
const [selectedPaymentChainId, setSelectedPaymentChainId] = useState<number | null>(null);
const [selectedTokenAddress, setSelectedTokenAddress] = useState("");
const [selectedWallet, setSelectedWallet] = useState("");
const [providerMode, setProviderMode] = useState<ProviderMode>("auto");
@@ -218,7 +309,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] = useState("");
// ── Chain ID derived from payment config ────────────────
const chainId = paymentConfig?.chain_id ?? 137;
const chainId = selectedPaymentChainId || paymentConfig?.default_chain_id || paymentConfig?.chain_id || 137;
// ── loadPaymentSnapshot ──────────────────────────────────
// Defined in master because it sets state across multiple sub-hook domains.
@@ -243,8 +334,35 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
const tokenOptions = Array.isArray(configJson.tokens)
? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x"))
: [];
const chainOptions = Array.isArray(configJson.chains)
? configJson.chains.filter((row) => Number(row?.chain_id) > 0)
: [];
const defaultChainId = Number(
configJson.default_chain_id ||
chainOptions.find((row) => row.is_default)?.chain_id ||
configJson.chain_id ||
tokenOptions.find((row) => row.is_default)?.chain_id ||
137,
);
const supportedChainIds = new Set(
(chainOptions.length ? chainOptions : [{ chain_id: defaultChainId }])
.map((row) => Number(row.chain_id))
.filter((value) => Number.isFinite(value) && value > 0),
);
setSelectedPaymentChainId((prev) =>
prev && supportedChainIds.has(prev) ? prev : defaultChainId,
);
const activeChainId =
selectedPaymentChainId && supportedChainIds.has(selectedPaymentChainId)
? selectedPaymentChainId
: defaultChainId;
const tokenOptionsForChain = tokenOptions.filter(
(row) => Number(row.chain_id || activeChainId) === activeChainId,
);
const defaultTokenAddress = String(
configJson.default_token_address ||
tokenOptionsForChain.find((row) => row.is_default)?.address ||
tokenOptionsForChain[0]?.address ||
tokenOptions.find((row) => row.is_default)?.address ||
tokenOptions[0]?.address ||
configJson.token_address || "",
@@ -283,15 +401,16 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
backend?.authenticated,
buildAuthedHeaders,
selectedPlanCode,
selectedPaymentChainId,
selectedWallet,
walletAddress,
]);
// ── Selected plan (derived, shared across sub-hooks) ───
const monthlyPlanList = (paymentConfig?.plans || []).filter(
(p) => String(p.plan_code || "").trim().toLowerCase() === "pro_monthly",
const effectivePlanList = applyTelegramGroupPricingToPlanList(
paymentConfig?.plans || [],
backend?.telegram_pricing,
);
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : (paymentConfig?.plans || []);
const selectedPlan = effectivePlanList.find((p) => p.plan_code === selectedPlanCode) || effectivePlanList[0];
// ── useWalletBind ──────────────────────────────────────
@@ -354,6 +473,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
getValidAccessToken,
buildAuthedHeaders,
loadSnapshot,
refreshEntitlementAfterPayment,
loadPaymentSnapshot: loadPaymentSnapshotImpl,
user,
});
@@ -371,6 +491,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
setPaymentConfig,
selectedPlanCode,
setSelectedPlanCode,
selectedPaymentChainId: chainId,
setSelectedPaymentChainId,
selectedTokenAddress,
setSelectedTokenAddress,
boundWallets,
@@ -407,6 +529,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
getValidAccessToken,
buildAuthedHeaders,
loadSnapshot,
refreshEntitlementAfterPayment,
loadPaymentSnapshot: loadPaymentSnapshotImpl,
waitForReceipt: walletBind.waitForReceipt,
ensureTargetChain: walletBind.ensureTargetChain,
@@ -451,6 +574,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
boundWallets,
walletAddress,
selectedPlanCode,
selectedPaymentChainId: chainId,
selectedTokenAddress,
selectedWallet,
providerMode,
@@ -460,6 +584,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
// Setters for shared state
setSelectedTokenAddress,
setSelectedPaymentChainId,
setSelectedPlanCode,
setSelectedWallet,
setSelectedInjectedProviderKey,
setProviderMode,
@@ -476,6 +602,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
selectedPaymentToken: paymentFlow.selectedPaymentToken,
selectedTokenLabel: paymentFlow.selectedTokenLabel,
availableTokenList: paymentFlow.availableTokenList,
availableChainList: paymentFlow.availableChainList,
selectedPaymentChain: paymentFlow.selectedPaymentChain,
effectivePlanList,
resolvedSelectedTokenAddress: paymentFlow.resolvedSelectedTokenAddress,
paymentReceiverAddress: paymentFlow.paymentReceiverAddress,
+54 -14
View File
@@ -19,6 +19,7 @@ import {
} from "./constants";
import { clearStoredPaymentRecovery, shortAddress } from "./formatters";
import { normalizePaymentError } from "./payment-utils";
import { isTelegramPrivateGroupPriceEligible } from "./telegram-pricing";
import { trackAppEvent } from "@/lib/app-analytics";
// ============================================================
@@ -53,6 +54,7 @@ export interface UseBillingParams {
getValidAccessToken: () => Promise<string>;
buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>;
loadSnapshot: () => Promise<void>;
refreshEntitlementAfterPayment: () => Promise<void>;
loadPaymentSnapshot: () => Promise<void>;
// User for metadata points
@@ -86,6 +88,7 @@ export function useBilling(params: UseBillingParams) {
getValidAccessToken,
buildAuthedHeaders,
loadSnapshot,
refreshEntitlementAfterPayment,
loadPaymentSnapshot,
user,
} = params;
@@ -116,10 +119,36 @@ export function useBilling(params: UseBillingParams) {
// ── Billing ──────────────────────────────────────────────
const billing = useMemo(() => {
const parsedPlanAmount = Number(
backend?.telegram_pricing?.amount_usdc ?? selectedPlan?.amount_usdc ?? 10,
const listAmountRaw = Number(selectedPlan?.amount_usdc ?? 29.9);
const listAmount =
Number.isFinite(listAmountRaw) && listAmountRaw > 0 ? listAmountRaw : 29.9;
const selectedPlanCode = String(selectedPlan?.plan_code || "").toLowerCase();
const telegramGroupPriceApplies = Boolean(
selectedPlanCode === "pro_monthly" &&
isTelegramPrivateGroupPriceEligible(backend?.telegram_pricing),
);
const planAmount = Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0 ? parsedPlanAmount : 10;
const referral = backend?.referral;
const referralPending = Boolean(
referral?.applied_code ||
String(referral?.attribution_status || "").toLowerCase() === "pending",
);
const referralDiscountRaw = Number(referral?.discount_usdc ?? 0);
const referralDiscount = Number.isFinite(referralDiscountRaw)
? Math.max(0, referralDiscountRaw)
: 0;
const discountedMonthlyRaw = Number(
referral?.discounted_monthly_amount_usdc ?? 0,
);
const referralApplies =
selectedPlanCode === "pro_monthly" &&
referralPending &&
!telegramGroupPriceApplies &&
backend?.subscription_active !== true;
const planAmount = referralApplies
? Number.isFinite(discountedMonthlyRaw) && discountedMonthlyRaw > 0
? discountedMonthlyRaw
: Math.max(0, listAmount - referralDiscount)
: listAmount;
const pointsCfg = paymentConfig?.points_redemption || {};
const pointsEnabled = pointsCfg.enabled !== false;
@@ -127,7 +156,10 @@ export function useBilling(params: UseBillingParams) {
const pointsPerUsdc =
Number.isFinite(pointsPerUsdcRaw) && pointsPerUsdcRaw > 0 ? Math.floor(pointsPerUsdcRaw) : 500;
const maxDiscountRaw = Number(pointsCfg.max_discount_usdc ?? 3);
const maxDiscountByPlan = pointsCfg.max_discount_usdc_by_plan || {};
const planMaxDiscountRaw =
maxDiscountByPlan[selectedPlanCode] ?? pointsCfg.max_discount_usdc ?? 3;
const maxDiscountRaw = Number(planMaxDiscountRaw);
const maxDiscountUsdc = Math.max(
0,
Math.min(
@@ -137,14 +169,18 @@ export function useBilling(params: UseBillingParams) {
);
const maxRedeemablePoints = pointsPerUsdc * maxDiscountUsdc;
const actualRedeem = pointsEnabled ? Math.min(totalPoints, maxRedeemablePoints) : 0;
const pointsCanApply = pointsEnabled && !referralApplies;
const actualRedeem = pointsCanApply ? Math.min(totalPoints, maxRedeemablePoints) : 0;
const discountUnits = Math.floor(actualRedeem / pointsPerUsdc);
const pointsUsed = discountUnits * pointsPerUsdc;
const canRedeem = pointsEnabled && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc;
const canRedeem = pointsCanApply && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc;
const applyDiscount = usePoints && canRedeem && pointsUsed > 0;
return {
planAmount,
listAmount,
referralApplied: referralApplies,
referralDiscountAmount: referralApplies ? listAmount - planAmount : 0,
pointsEnabled,
pointsPerUsdc,
maxDiscountUsdc,
@@ -155,7 +191,10 @@ export function useBilling(params: UseBillingParams) {
};
}, [
paymentConfig?.points_redemption,
backend?.telegram_pricing?.amount_usdc,
backend?.referral,
backend?.subscription_active,
backend?.telegram_pricing,
selectedPlan?.plan_code,
selectedPlan?.amount_usdc,
totalPoints,
usePoints,
@@ -177,7 +216,7 @@ export function useBilling(params: UseBillingParams) {
if (json.ok) {
setPaymentInfo(copy.walletRecoveryDone);
setPaymentError("");
await loadSnapshot();
await refreshEntitlementAfterPayment();
await loadPaymentSnapshot();
return true;
}
@@ -189,7 +228,7 @@ export function useBilling(params: UseBillingParams) {
}
}, [
authIsAuthenticated, buildAuthedHeaders, copy.walletRecoveryDone,
loadPaymentSnapshot, loadSnapshot, reconcileBusy,
loadPaymentSnapshot, refreshEntitlementAfterPayment, reconcileBusy,
]);
// ── handleSubmit409 ────────────────────────────────────
@@ -200,7 +239,7 @@ export function useBilling(params: UseBillingParams) {
const ok = await reconcileLatestPayment();
if (ok) return;
setPaymentInfo(copy.orderAlreadyPaid);
await loadSnapshot();
await refreshEntitlementAfterPayment();
await loadPaymentSnapshot();
return;
}
@@ -220,7 +259,7 @@ export function useBilling(params: UseBillingParams) {
const txHash = intentJson.intent?.tx_hash || txHashNorm;
setPaymentInfo(`支付已确认,交易: ${shortAddress(txHash)}`);
setPaymentError("");
await loadSnapshot();
await refreshEntitlementAfterPayment();
await loadPaymentSnapshot();
return;
}
@@ -231,7 +270,7 @@ export function useBilling(params: UseBillingParams) {
}
throw new Error(copy.submitTxFailed.replace("{raw}", raw));
},
[buildAuthedHeaders, copy, loadPaymentSnapshot, loadSnapshot, reconcileLatestPayment],
[buildAuthedHeaders, copy, loadPaymentSnapshot, refreshEntitlementAfterPayment, reconcileLatestPayment],
);
// ── openTelegramBotBindLink ──────────────────────────────
@@ -367,8 +406,9 @@ export function useBilling(params: UseBillingParams) {
throw new Error(copy.bindFailed.replace("{raw}", raw));
}
const data = (await res.json()) as { telegram_pricing?: TelegramPricing | null };
if (data.telegram_pricing?.is_group_member) {
const amount = data.telegram_pricing.amount_usdc || "10";
const telegramPricing = data.telegram_pricing;
if (isTelegramPrivateGroupPriceEligible(telegramPricing)) {
const amount = telegramPricing?.amount_usdc || "10";
setPaymentInfo(copy.telegramVerifySuccess.replace("{amount}", amount));
}
await loadSnapshot();
+224 -48
View File
@@ -8,6 +8,7 @@ import type {
CreatedIntent,
EvmProvider,
IntentStatusResponse,
PaymentChainOption,
PaymentConfig,
PaymentTokenOption,
ProviderMode,
@@ -16,16 +17,18 @@ import type {
import {
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
} from "./constants";
import { clearStoredPaymentRecovery, shortAddress } from "./formatters";
import { chainIdToDisplayName, clearStoredPaymentRecovery, shortAddress } from "./formatters";
import {
buildAllowanceCalldata,
buildApproveCalldata,
buildBalanceOfCalldata,
formatTokenUnits,
normalizePaymentError,
readPaymentApiErrorMessage,
requestWalletWithTimeout,
} from "./payment-utils";
import { trackAppEvent } from "@/lib/app-analytics";
import { buildAuthMePath } from "@/lib/auth-snapshot";
import {
assertExpectedPaymentReceiver,
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
@@ -43,6 +46,8 @@ export interface UsePaymentFlowParams {
setPaymentConfig: React.Dispatch<React.SetStateAction<PaymentConfig | null>>;
selectedPlanCode: string;
setSelectedPlanCode: React.Dispatch<React.SetStateAction<string>>;
selectedPaymentChainId: number;
setSelectedPaymentChainId: React.Dispatch<React.SetStateAction<number | null>>;
selectedTokenAddress: string;
setSelectedTokenAddress: React.Dispatch<React.SetStateAction<string>>;
@@ -98,9 +103,10 @@ export interface UsePaymentFlowParams {
getValidAccessToken: () => Promise<string>;
buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>;
loadSnapshot: () => Promise<void>;
refreshEntitlementAfterPayment: () => Promise<void>;
loadPaymentSnapshot: () => Promise<void>;
waitForReceipt: (txHash: string, provider?: EvmProvider, timeoutMs?: number, pollMs?: number) => Promise<any>;
ensureTargetChain: (eth: EvmProvider, targetChainId: number) => Promise<void>;
ensureTargetChain: (eth: EvmProvider, targetChainId: number, chain?: PaymentChainOption) => Promise<void>;
// Ref-wrapped cross-hook callbacks
connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode, options?: ConnectBindOptions) => Promise<boolean>) | null>;
@@ -120,6 +126,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentConfig,
selectedPlanCode,
setSelectedPlanCode,
selectedPaymentChainId,
setSelectedPaymentChainId,
selectedTokenAddress,
setSelectedTokenAddress,
boundWallets,
@@ -155,6 +163,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
getValidAccessToken,
buildAuthedHeaders,
loadSnapshot,
refreshEntitlementAfterPayment,
loadPaymentSnapshot,
waitForReceipt,
ensureTargetChain,
@@ -165,11 +174,81 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
// ── Derived payment values ──────────────────────────────
const planList = paymentConfig?.plans || [];
const monthlyPlanList = planList.filter(
(plan) => String(plan.plan_code || "").trim().toLowerCase() === "pro_monthly",
);
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList;
const effectivePlanList = planList;
const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0];
const trackPaymentStart = useCallback((payload: Record<string, unknown>) => {
trackAppEvent("checkout_started", payload);
trackAppEvent("payment_start", payload);
}, []);
const trackPaymentSuccess = useCallback((payload: Record<string, unknown>) => {
trackAppEvent("checkout_succeeded", payload);
trackAppEvent("payment_success", payload);
}, []);
const verifyPaymentAuthReady = useCallback(async () => {
const accessToken = await getValidAccessToken();
const authHeaders: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
};
const authRes = await fetch(buildAuthMePath({ scope: "entitlement" }), {
cache: "no-store",
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
});
if (!authRes.ok) {
const raw = (await authRes.text().catch(() => "")).slice(0, 240);
throw new Error(
isEn
? `Authentication check failed before payment (${authRes.status}). ${raw}`
: `支付前登录态校验失败 (${authRes.status})。${raw}`,
);
}
const profile = (await authRes.json()) as AuthMeResponse;
if (profile.authenticated !== true) {
throw new Error(copy.loginBeforePay);
}
return {
authHeaders,
auth_confirmed_at: new Date().toISOString(),
profile,
};
}, [copy.loginBeforePay, getValidAccessToken, isEn]);
const availableChainList: PaymentChainOption[] = useMemo(() => {
const configured = Array.isArray(paymentConfig?.chains) ? paymentConfig?.chains || [] : [];
const clean = configured
.map((row) => ({
...row,
chain_id: Number(row.chain_id),
name: String(row.name || chainIdToDisplayName(Number(row.chain_id))),
}))
.filter((row) => Number.isFinite(row.chain_id) && row.chain_id > 0);
if (clean.length) return clean;
const chainIds = new Set<number>();
const defaultChainId = Number(paymentConfig?.default_chain_id || paymentConfig?.chain_id || 137);
if (Number.isFinite(defaultChainId) && defaultChainId > 0) chainIds.add(defaultChainId);
(paymentConfig?.tokens || []).forEach((token) => {
const chainId = Number(token.chain_id || defaultChainId);
if (Number.isFinite(chainId) && chainId > 0) chainIds.add(chainId);
});
return Array.from(chainIds).sort((a, b) => a - b).map((chainId) => ({
chain_id: chainId,
name: chainIdToDisplayName(chainId),
is_default: chainId === defaultChainId,
}));
}, [paymentConfig]);
const selectedPaymentChain =
availableChainList.find((chain) => chain.chain_id === selectedPaymentChainId) ||
availableChainList.find((chain) => chain.is_default) ||
availableChainList[0];
const effectivePaymentChainId = Number(
selectedPaymentChain?.chain_id ||
selectedPaymentChainId ||
paymentConfig?.default_chain_id ||
paymentConfig?.chain_id ||
137,
);
const availableTokenList: PaymentTokenOption[] = useMemo(() => {
const configured = Array.isArray(paymentConfig?.tokens) ? paymentConfig?.tokens || [] : [];
@@ -181,22 +260,32 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
symbol: String(row.symbol || "USDC"),
name: String(row.name || row.symbol || "USDC"),
code: String(row.code || "usdc"),
chain_id: Number(row.chain_id || effectivePaymentChainId),
decimals: Number.isFinite(Number(row.decimals))
? Number(row.decimals)
: Number(paymentConfig?.token_decimals ?? 6),
}));
}))
.filter((row) => Number(row.chain_id) === effectivePaymentChainId);
if (clean.length) return clean;
const fallbackAddress = String(paymentConfig?.token_address || "").toLowerCase();
if (!fallbackAddress.startsWith("0x")) return [];
return [{
code: "usdc", symbol: "USDC", name: "USDC", address: fallbackAddress,
chain_id: effectivePaymentChainId,
decimals: Number(paymentConfig?.token_decimals ?? 6),
receiver_contract: paymentConfig?.receiver_contract, is_default: true,
}];
}, [paymentConfig]);
}, [effectivePaymentChainId, paymentConfig]);
const resolvedSelectedTokenAddress = String(
selectedTokenAddress || paymentConfig?.default_token_address ||
(
selectedTokenAddress &&
availableTokenList.some((row) => row.address === String(selectedTokenAddress).toLowerCase())
? selectedTokenAddress
: ""
) ||
availableTokenList.find((row) => row.is_default)?.address ||
paymentConfig?.default_token_address ||
availableTokenList.find((row) => row.is_default)?.address ||
availableTokenList[0]?.address || paymentConfig?.token_address || "",
).toLowerCase();
@@ -216,7 +305,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const headers = authHeaders || (await buildAuthedHeaders(false));
const configRes = await fetch("/api/payments/config", { cache: "no-store", headers });
if (!configRes.ok) {
const raw = (await configRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
configRes,
isEn ? "Payment config request failed." : "支付配置请求失败。",
350,
);
throw new Error(copy.loadConfigFailed.replace("{raw}", raw));
}
const configJson = (await configRes.json()) as PaymentConfig;
@@ -225,21 +318,50 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
if (!selectedPlanCode && configJson.plans?.length) {
setSelectedPlanCode(configJson.plans[0].plan_code);
}
const chainOptions = Array.isArray(configJson.chains)
? configJson.chains.filter((row) => Number(row?.chain_id) > 0)
: [];
const defaultChainId = Number(
configJson.default_chain_id ||
chainOptions.find((row) => row.is_default)?.chain_id ||
configJson.chain_id ||
137,
);
const supportedChainIds = new Set(
(chainOptions.length ? chainOptions : [{ chain_id: defaultChainId }])
.map((row) => Number(row.chain_id))
.filter((value) => Number.isFinite(value) && value > 0),
);
setSelectedPaymentChainId((prev) =>
prev && supportedChainIds.has(prev) ? prev : defaultChainId,
);
const activeChainId =
selectedPaymentChainId && supportedChainIds.has(selectedPaymentChainId)
? selectedPaymentChainId
: defaultChainId;
const tokenOptions = Array.isArray(configJson.tokens)
? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x"))
: [];
const tokenOptionsForChain = tokenOptions.filter(
(row) => Number(row.chain_id || activeChainId) === activeChainId,
);
const defaultTokenAddress = String(
configJson.default_token_address ||
tokenOptionsForChain.find((row) => row.is_default)?.address ||
tokenOptionsForChain[0]?.address ||
tokenOptions.find((row) => row.is_default)?.address ||
tokenOptions[0]?.address || configJson.token_address || "",
).toLowerCase();
if (defaultTokenAddress) {
setSelectedTokenAddress((prev: string) => prev || defaultTokenAddress);
const tokenSet = new Set(tokenOptionsForChain.map((row) => String(row.address).toLowerCase()));
setSelectedTokenAddress((prev: string) =>
prev && tokenSet.has(String(prev).toLowerCase()) ? prev : defaultTokenAddress,
);
}
}
return configJson;
},
[buildAuthedHeaders, selectedPlanCode],
[buildAuthedHeaders, selectedPaymentChainId, selectedPlanCode],
);
// ── pollIntentUntilConfirmed ────────────────────────────
@@ -256,7 +378,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
await new Promise((resolve) => setTimeout(resolve, pollMs));
continue;
}
const raw = (await statusRes.text()).slice(0, 260);
const raw = await readPaymentApiErrorMessage(
statusRes,
isEn ? "Payment status request failed." : "支付状态请求失败。",
260,
);
throw new Error(copy.queryIntentFailed.replace("{raw}", raw));
}
const statusJson = (await statusRes.json()) as IntentStatusResponse;
@@ -266,13 +392,13 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
if (status === "confirmed") {
setPaymentError("");
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHash)));
trackAppEvent("checkout_succeeded", {
trackPaymentSuccess({
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
tx_hash: txHash || null,
});
await loadSnapshot();
await refreshEntitlementAfterPayment();
await loadPaymentSnapshot();
return;
}
@@ -284,7 +410,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
}
throw new Error(copy.paymentPendingTimeout);
},
[loadPaymentSnapshot, loadSnapshot, selectedPlan?.plan_code],
[loadPaymentSnapshot, refreshEntitlementAfterPayment, selectedPlan?.plan_code, trackPaymentSuccess],
);
// ── createIntentAndPay ──────────────────────────────────
@@ -314,6 +440,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentBusy(true);
let approvedInThisRun = false;
try {
const authReady = await verifyPaymentAuthReady();
const authHeaders = authReady.authHeaders;
const providerSelection = await resolvePaymentProvider(providerMode, selectedInjectedProviderKey);
const eth = providerSelection.provider;
const activeAccounts = await requestWalletWithTimeout<string[]>(
@@ -331,21 +459,32 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setSelectedWallet(payingWallet);
setProviderMode(providerSelection.mode);
let accessToken: string;
try { accessToken = await getValidAccessToken(); }
catch (tokenErr) {
setPaymentError(normalizePaymentError(tokenErr).message);
setPaymentBusy(false);
return;
}
const authHeaders: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
};
const latestConfig = await fetchLatestPaymentConfig(authHeaders, true);
if (!latestConfig?.enabled || !latestConfig?.configured) throw new Error(copy.payNotReady);
const expectedReceiver = String(latestConfig.receiver_contract || "").toLowerCase();
const targetChainId = Number(selectedPaymentChainId || latestConfig.default_chain_id || latestConfig.chain_id || 137);
const latestChains = Array.isArray(latestConfig.chains) ? latestConfig.chains : [];
const targetChain =
latestChains.find((chain) => Number(chain.chain_id) === targetChainId) ||
selectedPaymentChain;
const latestTokens = Array.isArray(latestConfig.tokens) ? latestConfig.tokens : [];
const selectedLatestToken =
latestTokens.find(
(token) =>
Number(token.chain_id || targetChainId) === targetChainId &&
String(token.address || "").toLowerCase() === resolvedSelectedTokenAddress,
) ||
latestTokens.find(
(token) => Number(token.chain_id || targetChainId) === targetChainId && token.is_default,
) ||
latestTokens.find((token) => Number(token.chain_id || targetChainId) === targetChainId);
if (selectedLatestToken?.supports_contract_checkout === false) {
throw new Error(
isEn
? `${selectedLatestToken.chain_name || chainIdToDisplayName(targetChainId)} ${selectedLatestToken.symbol || "USDC"} supports manual transfer only.`
: `${selectedLatestToken.chain_name || chainIdToDisplayName(targetChainId)} ${selectedLatestToken.symbol || "USDC"} 仅支持手动转账。`,
);
}
const expectedReceiver = String(selectedLatestToken?.receiver_contract || latestConfig.receiver_contract || "").toLowerCase();
assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract");
if (paymentConfig?.receiver_contract && String(paymentConfig.receiver_contract).toLowerCase() !== expectedReceiver) {
setPaymentInfo(copy.paymentConfigUpdated.replace("{address}", shortAddress(expectedReceiver)));
@@ -353,8 +492,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentInfo(copy.currentReceiver.replace("{address}", shortAddress(expectedReceiver)));
}
const targetChainId = Number(latestConfig.chain_id || 137);
await ensureTargetChain(eth, targetChainId);
await ensureTargetChain(eth, targetChainId, targetChain);
const createRes = await fetch("/api/payments/intents", {
method: "POST",
@@ -363,18 +501,24 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
plan_code: selectedPlan?.plan_code || "pro_monthly",
payment_mode: "strict",
allowed_wallet: payingWallet,
token_address: resolvedSelectedTokenAddress || undefined,
chain_id: targetChainId,
token_address: String(selectedLatestToken?.address || resolvedSelectedTokenAddress || "").toLowerCase() || undefined,
use_points: billing.canRedeem && usePoints,
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
metadata: {
source: "account_center",
frontend_host: currentPaymentHost || null,
account_email: backend?.email || null,
auth_confirmed_at: authReady.auth_confirmed_at,
},
}),
});
if (!createRes.ok) {
const raw = (await createRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
createRes,
isEn ? "Payment order request failed." : "支付订单请求失败。",
350,
);
throw new Error(copy.createIntentFailed.replace("{raw}", raw));
}
@@ -382,7 +526,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const intentId = String(created.intent?.intent_id || "");
const txPayload = created.tx_payload;
if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error(copy.intentPayloadInvalid);
trackAppEvent("checkout_started", {
trackPaymentStart({
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
@@ -446,12 +590,18 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const txHashNorm = String(txHash || "").toLowerCase();
setLastTxHash(txHashNorm);
setLastPaymentStartedAt(Date.now());
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上回执...`);
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 }),
});
if (!submitRes.ok) {
const raw = (await submitRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
submitRes,
isEn ? "Payment submit request failed." : "支付提交请求失败。",
350,
);
if (submitRes.status === 409) {
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentId, txHashNorm, raw);
return;
@@ -463,7 +613,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
});
if (!confirmRes.ok) {
const raw = (await confirmRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
confirmRes,
isEn ? "Payment confirm request failed." : "支付确认请求失败。",
350,
);
const lowerRaw = raw.toLowerCase();
const maybePending =
(confirmRes.status === 404 && !lowerRaw.includes("payment intent not found")) ||
@@ -478,13 +632,13 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
}
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm)));
trackAppEvent("checkout_succeeded", {
trackPaymentSuccess({
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
tx_hash: txHashNorm,
});
await loadSnapshot();
await refreshEntitlementAfterPayment();
await loadPaymentSnapshot();
} catch (error) {
const normalized = normalizePaymentError(error);
@@ -530,13 +684,14 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentBusy(true);
try {
const authHeaders = await buildAuthedHeaders(true, false);
const authHeaders = await buildAuthedHeaders(true, true);
const createRes = await fetch("/api/payments/intents", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
plan_code: selectedPlan?.plan_code || "pro_monthly",
payment_mode: "direct",
chain_id: effectivePaymentChainId,
token_address: resolvedSelectedTokenAddress || undefined,
use_points: billing.canRedeem && usePoints,
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
@@ -548,7 +703,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
}),
});
if (!createRes.ok) {
const raw = (await createRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
createRes,
isEn ? "Manual payment order request failed." : "手动转账订单请求失败。",
350,
);
throw new Error(copy.createManualIntentFailed.replace("{raw}", raw));
}
@@ -563,8 +722,15 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setManualPayment(direct);
setPaymentMethodTab("manual");
setShowOverlay(false);
setPaymentInfo(`手动转账订单已创建:请在 Polygon 网络转 ${direct.amount_usdc} ${direct.token_symbol || selectedTokenLabel}${direct.receiver_address},请在下方【手动转账】面板中查看详情并复制地址,完成后提交 tx hash。`);
trackAppEvent("checkout_started", {
const chainName = direct.chain_name || chainIdToDisplayName(direct.chain_id);
setPaymentInfo(
copy.manualOrderCreated
.replace("{amount}", direct.amount_usdc)
.replace("{symbol}", direct.token_symbol || selectedTokenLabel)
.replace("{chain}", chainName)
.replace("{receiver}", shortAddress(direct.receiver_address)),
);
trackPaymentStart({
entry: "account_center_manual_transfer",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
@@ -594,12 +760,16 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentBusy(true);
setPaymentError("");
try {
const authHeaders = await buildAuthedHeaders(true, false);
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 }),
});
if (!submitRes.ok) {
const raw = (await submitRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
submitRes,
isEn ? "Payment submit request failed." : "支付提交请求失败。",
350,
);
if (submitRes.status === 409) {
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentIdVal, txHashNorm, raw);
return;
@@ -610,7 +780,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
});
if (!confirmRes.ok) {
const raw = (await confirmRes.text()).slice(0, 350);
const raw = await readPaymentApiErrorMessage(
confirmRes,
isEn ? "Payment confirm request failed." : "支付确认请求失败。",
350,
);
const lowerRaw = raw.toLowerCase();
const maybePending =
confirmRes.status === 408 ||
@@ -627,13 +801,13 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setManualPayment(null);
setManualTxHash("");
setTxValidation({ loading: false, checked: false });
trackAppEvent("checkout_succeeded", {
trackPaymentSuccess({
entry: "account_center_manual_transfer",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentIdVal,
tx_hash: txHashNorm,
});
await loadSnapshot();
await refreshEntitlementAfterPayment();
await loadPaymentSnapshot();
} catch (error) {
setPaymentError(normalizePaymentError(error).message);
@@ -652,7 +826,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
}
setTxValidation({ loading: true, checked: false });
try {
const headers = await buildAuthedHeaders(true, false);
const headers = await buildAuthedHeaders(true, true);
const res = await fetch(`/api/payments/intents/${intentId}/validate`, {
method: "POST", headers, body: JSON.stringify({ tx_hash: hashNorm }),
});
@@ -700,6 +874,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
validateTxHash,
handleOverlayCheckout,
availableTokenList,
availableChainList,
selectedPaymentChain,
resolvedSelectedTokenAddress,
selectedPaymentToken,
selectedTokenLabel,
+45 -15
View File
@@ -7,6 +7,7 @@ import type {
Eip6963ProviderDetail,
EvmProvider,
InjectedProviderOption,
PaymentChainOption,
ProviderMode,
ProviderSelection,
} from "./types";
@@ -15,7 +16,11 @@ import {
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
} from "./constants";
import { shortAddress } from "./formatters";
import { normalizePaymentError, requestWalletWithTimeout } from "./payment-utils";
import {
normalizePaymentError,
readPaymentApiErrorMessage,
requestWalletWithTimeout,
} from "./payment-utils";
import {
eip6963Providers,
getEvmProvider,
@@ -190,12 +195,30 @@ export function useWalletBind(params: UseWalletBindParams) {
}
};
const ensureTargetChain = async (eth: EvmProvider, targetChainId: number): Promise<void> => {
const ensureTargetChain = async (
eth: EvmProvider,
targetChainId: number,
chain?: PaymentChainOption,
): Promise<void> => {
const currentChainIdHex = String(
(await requestWalletWithTimeout<string>(eth, { method: "eth_chainId" }, copy.chainReadError)) || "",
);
const targetChainHex = `0x${targetChainId.toString(16)}`;
if (currentChainIdHex.toLowerCase() === targetChainHex.toLowerCase()) return;
const chainName =
String(chain?.name || "").trim() ||
(targetChainId === 1 ? "Ethereum Mainnet" : targetChainId === 137 ? "Polygon Mainnet" : `Chain ${targetChainId}`);
const nativeSymbol =
String(chain?.native_currency_symbol || "").trim() ||
(targetChainId === 137 ? "POL" : "ETH");
const explorerBase = String(chain?.block_explorer_url || "").trim() ||
(targetChainId === 1 ? "https://etherscan.io" : targetChainId === 137 ? "https://polygonscan.com" : "");
const defaultRpc =
targetChainId === 137
? "https://polygon-rpc.com"
: targetChainId === 1
? "https://ethereum-rpc.publicnode.com"
: "";
try {
await requestWalletWithTimeout(
eth,
@@ -206,24 +229,31 @@ export function useWalletBind(params: UseWalletBindParams) {
const code = Number(err?.code);
if (code === 4902 || targetChainId === 137) {
try {
const addParams: Record<string, any> = {
chainId: targetChainHex,
chainName,
nativeCurrency: { name: nativeSymbol, symbol: nativeSymbol, decimals: 18 },
};
if (defaultRpc) addParams.rpcUrls = [defaultRpc];
if (explorerBase) addParams.blockExplorerUrls = [explorerBase];
await requestWalletWithTimeout(
eth,
{
method: "wallet_addEthereumChain",
params: [{
chainId: "0x89",
chainName: "Polygon Mainnet",
nativeCurrency: { name: "POL", symbol: "POL", decimals: 18 },
rpcUrls: ["https://polygon-rpc.com"],
blockExplorerUrls: ["https://polygonscan.com"],
}],
params: [addParams],
},
copy.chainAddPolygon,
isEn ? `Add ${chainName}` : `添加 ${chainName}`,
);
return;
} catch (addErr: any) { err = addErr; }
}
throw new Error(`${copy.chainSwitchPrompt} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`);
throw new Error(
`${
isEn
? `Please manually switch to ${chainName} in your wallet and try again.`
: `请在钱包中手动切换到 ${chainName} 后再试。`
} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`,
);
}
};
@@ -274,8 +304,8 @@ export function useWalletBind(params: UseWalletBindParams) {
method: "POST", headers: authHeaders, body: JSON.stringify({ address }),
});
if (!challengeRes.ok) {
const raw = (await challengeRes.text()).slice(0, 300);
throw new Error(copy.challengeFailed.replace("{raw}", raw));
const message = await readPaymentApiErrorMessage(challengeRes);
throw new Error(copy.challengeFailed.replace("{raw}", message));
}
const challengeJson = (await challengeRes.json()) as { nonce?: string; message?: string };
@@ -288,8 +318,8 @@ export function useWalletBind(params: UseWalletBindParams) {
method: "POST", headers: authHeaders, body: JSON.stringify({ address, nonce, signature }),
});
if (!verifyRes.ok) {
const raw = (await verifyRes.text()).slice(0, 300);
throw new Error(copy.verifyFailedRaw.replace("{raw}", raw));
const message = await readPaymentApiErrorMessage(verifyRes);
throw new Error(copy.verifyFailedRaw.replace("{raw}", message));
}
setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`);
+205 -212
View File
@@ -7,11 +7,8 @@ import {
ArrowRight,
ChevronLeft,
Chrome,
CloudRain,
CloudSun,
Lock,
Mail,
Sun,
Eye,
EyeOff,
} from "lucide-react";
@@ -26,17 +23,18 @@ type Mode = "login" | "signup";
type LoginClientProps = {
nextPath: string;
initialError?: string;
initialMode?: Mode;
};
export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
export function LoginClient({ nextPath, initialError, initialMode }: LoginClientProps) {
const router = useRouter();
const { locale } = useI18n();
const [mode, setMode] = useState<Mode>(initialMode ?? "login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [errorText, setErrorText] = useState("");
const [errorText, setErrorText] = useState(initialError || "");
const [infoText, setInfoText] = useState("");
const [resetSent, setResetSent] = useState(false);
const [showPassword, setShowPassword] = useState(false);
@@ -50,21 +48,27 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
const copy = {
backHome: isEn ? "Back to Home" : "返回首页",
subtitle: isEn
? "Explore weather details from every corner of the world"
: "探索世界每一个角落的气象细节",
loginSubtitle: isEn
? "Sign in to continue to your weather decision terminal."
: "登录后进入你的天气决策终端。",
signupSubtitle: isEn
? "Create an account and get a one-time 3-day trial. No payment required first."
: "创建账号后自动开启一次 3 天试用,无需先付款。",
googleOneClick: isEn
? "Continue with Google"
: "使用 Google 账号一键登录",
orEmail: isEn ? "Or continue with email" : "或使用邮箱",
orGoogle: isEn ? "Or continue with Google" : "或使用 Google",
login: isEn ? "Sign In" : "登录",
signup: isEn ? "Sign Up" : "注册",
passwordLoginPlaceholder: isEn ? "Enter password" : "输入密码",
passwordSignupPlaceholder: isEn
? "Set at least 6 characters"
: "设置至少 6 位密码",
loginSubmit: isEn ? "Start your weather decision journey" : "开启气象决策之旅",
signupSubmit: isEn ? "Create account now" : "立即创建账号",
loginSubmit: isEn ? "Enter PolyWeather Terminal" : "进入 PolyWeather 终端",
loginSubmitting: isEn ? "Signing in..." : "正在登录...",
signupSubmit: isEn ? "Create account and start trial" : "创建账号并领取试用",
signupSubmitting: isEn ? "Creating account..." : "正在创建账号...",
googleSubmitting: isEn ? "Connecting Google..." : "正在连接 Google...",
loginHint: isEn
? "After signing in, your homepage will be personalized."
: "登录后将为您个性化定制首页数据",
@@ -93,22 +97,47 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
loginFailedHint: isEn
? "If you just signed up, please verify your email first. Check your inbox or spam folder."
: "如果刚注册,请先点击邮箱中的验证链接。检查收件箱或垃圾邮件。",
// New translations for Koyfin-style layouts
workEmail: isEn ? "Work email" : "工作邮箱",
password: isEn ? "Password" : "密码",
welcomeBack: isEn ? "Welcome Back" : "欢迎回来",
signUpTitle: isEn ? "Sign up for your PolyWeather account" : "注册您的 PolyWeather 账",
welcomeBack: isEn ? "Sign in to PolyWeather" : "登录 PolyWeather",
signUpTitle: isEn ? "Create your PolyWeather account" : "创建 PolyWeather 账",
newToPoly: isEn ? "New to PolyWeather?" : "还没有 PolyWeather 账号?",
alreadyHave: isEn ? "Already have an account?" : "已经有账号了?",
termsAgreement: isEn
? "By proceeding, you agree to the Privacy Policy and Terms & Conditions."
: "继续操作即代表您同意隐私政策与服务条款。",
desc: isEn
? "Access robust METAR observations, advanced DEB forecast blends, and real-time AI decision cards that bring clarity to your weather risk analyses."
: "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和实时 AI 决策卡片,助您理清气象风险脉络。",
? "Use the same terminal palette as the product: live temperature evidence, DEB paths, and settlement-source context in one calm workspace."
: "沿用终端界面的配色和信息密度:实时温度证据、DEB 路径和结算源背景放在一个安静工作台里。",
trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖",
} as const;
const submittingLabel = isLogin ? copy.loginSubmitting : copy.signupSubmitting;
const googleSubmittingLabel = copy.googleSubmitting;
const formSubtitle = isLogin ? copy.loginSubtitle : copy.signupSubtitle;
const accessHighlights = isEn
? ["Live temperature charts", "DEB forecast path", "Runway and settlement alerts"]
: ["实时温度图表", "DEB 预测路径", "跑道与结算提醒"];
const sideStats = isEn
? [
{ label: "Trial", value: "3 days" },
{ label: "Access", value: "Terminal" },
{ label: "Signals", value: "Runway" },
]
: [
{ label: "试用", value: "3 天" },
{ label: "入口", value: "终端" },
{ label: "提醒", value: "跑道" },
];
const loadingSpinner = (
<span
aria-hidden="true"
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
);
useEffect(() => {
setErrorText(initialError || "");
}, [initialError]);
const onResetPassword = async () => {
setErrorText("");
@@ -126,7 +155,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
const supabase = getSupabaseBrowserClient();
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
redirectTo: `${siteOrigin}/auth/callback?next=${encodeURIComponent(
"/account",
`/auth/reset-password?next=${encodeURIComponent(nextPath || "/account")}`,
)}`,
});
if (error) {
@@ -236,167 +265,112 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
};
return (
<div className="flex min-h-screen w-full bg-[#f8fafc] font-sans text-slate-900">
{/* Left Column (Shared Dark Column with Illustrative Widget) */}
<div className="relative hidden lg:flex lg:w-[460px] xl:w-[500px] 2xl:w-[560px] flex-col justify-between bg-gradient-to-br from-[#060913] via-[#0f1527] to-[#040815] p-10 text-white shrink-0 overflow-hidden border-r border-white/5">
{/* Ambient Glows */}
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-blue-600/10 blur-[130px] pointer-events-none" />
<div className="absolute -right-20 -bottom-20 h-[360px] w-[360px] rounded-full bg-purple-500/10 blur-[120px] pointer-events-none" />
<div className="absolute top-1/2 right-0 h-[240px] w-[240px] -translate-y-1/2 rounded-full bg-amber-500/5 blur-[100px] pointer-events-none" />
{/* Grid overlay */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(255,255,255,0.015)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.015)_1px,transparent_1px)] bg-[size:32px_32px] pointer-events-none" />
<div className="flex min-h-screen w-full bg-[#e9edf3] font-sans text-slate-950">
<aside className="hidden w-[56px] shrink-0 flex-col items-center justify-between bg-[#171d24] py-5 text-white lg:flex">
<Link
href="/"
aria-label="PolyWeather"
className="grid h-9 w-9 place-items-center rounded-lg border border-white/10 bg-white/8 text-[11px] font-black text-sky-200"
>
PW
</Link>
<div className="flex flex-col items-center gap-3">
<span className="h-2 w-2 rounded-full bg-[#00897b]" />
<span className="h-2 w-2 rounded-full bg-[#2563eb]" />
<span className="h-2 w-2 rounded-full bg-slate-500" />
</div>
<Link
href="/"
className="grid h-9 w-9 place-items-center rounded-lg text-slate-400 transition hover:bg-white/8 hover:text-white"
aria-label={copy.backHome}
>
<ChevronLeft size={18} />
</Link>
</aside>
<div className="relative z-10 flex flex-col gap-14">
<Link href="/" className="flex items-center hover:opacity-90 transition-opacity">
<img src="/logo.png" alt="PolyWeather" className="h-8 w-auto object-contain brightness-0 invert" />
<section className="relative hidden min-h-screen w-[44vw] max-w-[620px] shrink-0 overflow-hidden border-r border-[#d8e0ec] bg-[#171d24] p-6 text-white lg:flex lg:flex-col">
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(255,255,255,0.035)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:34px_34px]" />
<div className="relative z-10 flex items-center justify-between">
<Link
href="/"
className="inline-flex items-center gap-2 text-base font-black tracking-tight text-white transition hover:text-sky-100"
>
<span>PolyWeather</span>
</Link>
<span className="inline-flex items-center gap-2 rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1 text-[11px] font-black uppercase tracking-[0.16em] text-emerald-200">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300" />
Terminal
</span>
</div>
<div className="relative z-10 mt-12 max-w-md">
<p className="text-xs font-black uppercase tracking-[0.18em] text-sky-200">
{isEn ? "Access Gate" : "终端入口"}
</p>
<h2 className="mt-4 text-3xl font-black leading-tight tracking-tight text-white">
{isEn
? "Sign in where the terminal starts."
: "从这里进入天气决策终端。"}
</h2>
<p className="mt-4 text-sm leading-7 text-slate-300">
{copy.desc}
</p>
</div>
<div className="relative z-10 mt-10 overflow-hidden rounded-xl border border-white/10 bg-[#0b1220] shadow-[0_24px_60px_rgba(0,0,0,0.28)]">
<div className="flex h-11 items-center gap-2 border-b border-white/10 bg-[#111827] px-4">
<span className="h-2.5 w-2.5 rounded-full bg-[#ff5f56]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#ffbd2e]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#27c93f]" />
<span className="ml-2 font-mono text-[10px] text-slate-400">
polyweather.top/terminal
</span>
</div>
<img
src="/static/web.webp"
width="680"
height="340"
alt={isEn ? "PolyWeather terminal preview" : "PolyWeather 终端预览"}
className="aspect-[16/9] w-full object-cover object-top"
decoding="async"
/>
</div>
<div className="relative z-10 mt-5 grid grid-cols-3 gap-2">
{sideStats.map((item) => (
<div
key={item.label}
className="rounded-lg border border-white/10 bg-white/[0.06] px-3 py-3"
>
<div className="font-mono text-sm font-black text-white">{item.value}</div>
<div className="mt-1 text-[10px] font-bold text-slate-400">{item.label}</div>
</div>
))}
</div>
<div className="relative z-10 mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-[11px] text-slate-400">
<span>{isEn ? "Live charts" : "实时图表"}</span>
<span>{isEn ? "DEB path" : "DEB 路径"}</span>
<span>{isEn ? "Runway alerts" : "跑道提醒"}</span>
</div>
</section>
<div className="relative flex min-h-screen flex-1 flex-col justify-between overflow-hidden bg-[#e9edf3] p-4 sm:p-6 lg:p-8">
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_right,rgba(15,23,42,0.045)_1px,transparent_1px),linear-gradient(to_bottom,rgba(15,23,42,0.035)_1px,transparent_1px)] bg-[size:32px_32px]" />
<div className="relative z-10 flex items-center justify-between gap-3 lg:justify-end">
<Link
href="/"
className="inline-flex items-center gap-2 text-sm font-black tracking-tight text-slate-950 transition-opacity hover:opacity-80 lg:hidden"
>
<span className="grid h-8 w-8 place-items-center rounded-lg border border-slate-300 bg-white text-[10px] font-black text-blue-700 shadow-sm">
PW
</span>
<span className="hidden min-[360px]:inline">PolyWeather</span>
</Link>
<div className="space-y-6">
<h2 className="text-3xl font-black leading-[1.25] tracking-tight text-white animate-fade-up [animation-delay:150ms] opacity-0">
{isEn ? (
<>
Weather intelligence and risk management{" "}
<span className="inline-block px-2.5 py-0.5 mt-1 rounded bg-gradient-to-r from-blue-600 to-indigo-500 text-white font-bold text-[0.9em] shadow-lg shadow-blue-600/25 animate-gradient bg-[length:200%_auto]">
simplified.
</span>
</>
) : (
<>
{" "}
<span className="inline-block px-2.5 py-0.5 mt-1 rounded bg-gradient-to-r from-blue-600 to-indigo-500 text-white font-bold text-[0.9em] shadow-lg shadow-blue-600/25 animate-gradient bg-[length:200%_auto]">
</span>
</>
)}
</h2>
<p className="text-sm leading-7 text-slate-400 max-w-md animate-fade-up [animation-delay:300ms] opacity-0">
{copy.desc}
</p>
</div>
</div>
{/* High-Fidelity Mock Terminal Preview Widget */}
<div className="relative z-10 my-auto p-[1px] bg-gradient-to-b from-white/15 to-transparent rounded-2xl shadow-2xl overflow-hidden hover:scale-[1.01] hover:shadow-blue-500/10 transition-all duration-500 max-w-[420px] w-full animate-fade-up [animation-delay:450ms] opacity-0">
<div className="bg-[#0b0f19]/80 backdrop-blur-xl rounded-2xl p-6">
{/* Terminal Top Window Controls */}
<div className="flex items-center gap-1.5 mb-5">
<span className="h-2.5 w-2.5 rounded-full bg-[#ff5f56]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#ffbd2e]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#27c93f]" />
<span className="ml-2 font-mono text-[9px] text-slate-500 tracking-wider">POLYWEATHER_CONSOLE_v1.7</span>
</div>
<div className="flex items-center justify-between border-b border-white/5 pb-3.5 mb-4">
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="font-mono text-[10px] uppercase tracking-wider text-slate-300">
{isEn ? "Runway 02L Consensus" : "跑道 02L 实测校验"}
</span>
</div>
<span className="font-mono text-[9px] font-black text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 tracking-wider">LIVE</span>
</div>
{/* Terminal metrics grid */}
<div className="grid grid-cols-2 gap-3 mb-5">
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Current Temp" : "当前温度"}</span>
<span className="font-mono text-base font-bold text-white tracking-tight">28.8°C</span>
</div>
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Target Threshold" : "监控阈值"}</span>
<span className="font-mono text-base font-bold text-rose-400 tracking-tight">30.0°C</span>
</div>
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Model Blend" : "模型融合"}</span>
<span className="font-mono text-base font-bold text-blue-400 tracking-tight">88.5%</span>
</div>
<div className="p-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04]">
<span className="block text-[9px] uppercase tracking-wider text-slate-400 mb-0.5">{isEn ? "Observed Peak" : "今日最高"}</span>
<span className="font-mono text-base font-bold text-emerald-400 tracking-tight">29.2°C</span>
</div>
</div>
{/* SVG Interactive Line Chart Preview */}
<div className="mb-2">
<div className="flex items-center justify-between text-[9px] text-slate-400 mb-2 font-mono">
<span>{isEn ? "TEMP TREND (24H)" : "气温趋势 (24小时)"}</span>
<span className="text-blue-400 font-bold">Blend vs Obs</span>
</div>
<div className="relative h-28 w-full bg-slate-950/60 rounded-xl p-2 border border-white/5">
<svg className="w-full h-full" viewBox="0 0 340 100" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Grid Lines */}
<line x1="0" y1="20" x2="340" y2="20" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
<line x1="0" y1="50" x2="340" y2="50" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
<line x1="0" y1="80" x2="340" y2="80" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
<line x1="85" y1="0" x2="85" y2="100" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
<line x1="170" y1="0" x2="170" y2="100" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
<line x1="255" y1="0" x2="255" y2="100" stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
{/* Threshold Line (30.0°C) */}
<line x1="0" y1="40" x2="340" y2="40" stroke="#f43f5e" strokeWidth="1" strokeDasharray="3 3" opacity="0.8" />
<text x="5" y="36" fill="#f43f5e" className="text-[8px] font-mono font-semibold">30.0°C Target</text>
{/* Gradient Area under Forecast */}
<path
d="M 0 85 Q 40 75 85 60 T 170 35 T 255 45 T 340 55 L 340 100 L 0 100 Z"
fill="url(#chartGradient)"
opacity="0.15"
/>
{/* Forecast Line (Blue) */}
<path
d="M 0 85 Q 40 75 85 60 T 170 35 T 255 45 T 340 55"
stroke="#3b82f6"
strokeWidth="2"
strokeLinecap="round"
/>
{/* Observation Line (Green Solid, ending at current 170px) */}
<path
d="M 0 87 Q 40 78 85 63 T 170 33"
stroke="#10b981"
strokeWidth="2"
strokeLinecap="round"
/>
{/* Current Temp Point */}
<circle cx="170" cy="33" r="4" fill="#10b981" />
<circle cx="170" cy="33" r="8" stroke="#10b981" strokeWidth="1.5" className="animate-ping" opacity="0.5" />
{/* Definitions for Gradients */}
<defs>
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="relative z-10 text-[10px] text-slate-500 font-mono">
{isEn ? "PolyWeather institutional analytics suite" : "PolyWeather 机构版天气决策系统"}
</div>
</div>
{/* Right Column (Forms) */}
<div className="flex flex-1 flex-col justify-between p-6 sm:p-10 bg-gradient-to-br from-[#f8fafc] via-[#ffffff] to-[#eff4f9] min-h-screen relative overflow-hidden">
{/* Subtle mesh background */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(15,23,42,0.01)_1px,transparent_1px),linear-gradient(to_bottom,rgba(15,23,42,0.01)_1px,transparent_1px)] bg-[size:24px_24px] pointer-events-none animate-pulse" style={{ animationDuration: '4s' }} />
{/* Top Header Switch */}
<div className="relative z-10 flex justify-between lg:justify-end items-center gap-3">
{/* Logo on top-left for mobile only */}
<Link href="/" className="flex items-center hover:opacity-90 transition-opacity lg:hidden">
<img src="/logo.png" alt="PolyWeather" className="h-7 w-auto object-contain" />
</Link>
<div className="flex items-center gap-3">
<span className="text-xs text-slate-500">
<span className="hidden text-xs text-slate-500 sm:inline">
{isLogin ? copy.newToPoly : copy.alreadyHave}
</span>
<button
@@ -406,28 +380,40 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
setInfoText("");
setMode(isLogin ? "signup" : "login");
}}
className="rounded-xl border border-slate-200 bg-white px-4 py-2 text-xs font-bold text-slate-700 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 active:scale-[0.98]"
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]"
>
{isLogin ? copy.signup : copy.login}
</button>
</div>
</div>
{/* Center Form Card */}
<div className="relative z-10 flex flex-1 items-center justify-center my-10">
<div className="w-full max-w-[440px] bg-white/90 backdrop-blur-xl border border-slate-200/50 rounded-2xl p-6 sm:p-10 shadow-[0_24px_60px_rgba(8,16,36,0.06)] animate-fade-up [animation-delay:200ms] opacity-0 transition-transform hover:-translate-y-1 hover:shadow-[0_32px_80px_rgba(8,16,36,0.08)] duration-500">
<div className="relative z-10 flex flex-1 items-start justify-center pt-10 sm:pt-12 lg:my-10 lg:items-center lg:pt-0">
<div className="w-full max-w-[440px] rounded-xl border border-[#d8e0ec] bg-white p-6 shadow-[0_18px_50px_rgba(15,23,42,0.08)] sm:p-8">
<div className="mb-6">
<h1 className="text-2xl font-black tracking-tight text-slate-900 mb-2">
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black uppercase tracking-[0.12em] text-emerald-700">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-600" />
{isLogin ? copy.loginSubmit : isEn ? "3-day trial" : "3 天试用"}
</div>
<h1 className="mb-2 text-2xl font-black tracking-tight text-slate-950">
{isLogin ? copy.welcomeBack : copy.signUpTitle}
</h1>
<p className="text-xs text-slate-500 leading-relaxed">
{copy.subtitle}
<p className="text-sm leading-6 text-slate-600">
{formSubtitle}
</p>
</div>
<form onSubmit={(event) => void onEmailSubmit(event)} className="space-y-5">
<div className="space-y-2 animate-fade-up [animation-delay:350ms] opacity-0">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block">
<div className="mb-6 grid gap-2">
{accessHighlights.map((item) => (
<div key={item} className="flex items-center gap-2 text-xs font-semibold text-slate-600">
<span className="h-1.5 w-1.5 rounded-full bg-blue-600" />
{item}
</div>
))}
</div>
<form onSubmit={(event) => void onEmailSubmit(event)} className="space-y-4">
<div className="space-y-2">
<label className="block text-[11px] font-black uppercase tracking-[0.12em] text-slate-500">
{copy.workEmail}
</label>
<div className="relative">
@@ -438,14 +424,14 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="yourname@email.com"
className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-3 pl-11 pr-4 text-sm text-slate-900 placeholder:text-slate-400 transition-all duration-200 focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10"
className="w-full rounded-lg border border-slate-300 bg-[#f8fafc] py-3 pl-11 pr-4 text-sm text-slate-950 placeholder:text-slate-400 transition focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10"
/>
</div>
</div>
<div className="space-y-2 animate-fade-up [animation-delay:450ms] opacity-0">
<div className="flex justify-between items-center">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block">
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<label className="block text-[11px] font-black uppercase tracking-[0.12em] text-slate-500">
{copy.password}
</label>
{isLogin && !resetSent ? (
@@ -453,7 +439,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
type="button"
onClick={() => void onResetPassword()}
disabled={loading}
className="text-xs font-semibold text-blue-600 hover:text-blue-700 transition-colors"
className="text-xs font-bold text-blue-600 transition hover:text-blue-700"
>
{copy.reset}
</button>
@@ -468,66 +454,73 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder={isLogin ? copy.passwordLoginPlaceholder : copy.passwordSignupPlaceholder}
className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-3 pl-11 pr-11 text-sm text-slate-900 placeholder:text-slate-400 transition-all duration-200 focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10"
className="w-full rounded-lg border border-slate-300 bg-[#f8fafc] py-3 pl-11 pr-11 text-sm text-slate-950 placeholder:text-slate-400 transition focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-slate-700"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{!isLogin && (
<p className="text-[11px] leading-relaxed text-slate-400">
{!isLogin ? (
<p className="text-[11px] leading-relaxed text-slate-500">
{copy.termsAgreement}
</p>
)}
) : null}
<button
type="submit"
disabled={loading}
className="w-full py-3.5 px-4 rounded-xl bg-gradient-to-r from-slate-900 to-slate-800 hover:from-blue-600 hover:to-indigo-600 text-sm font-bold text-white shadow-lg shadow-slate-950/10 hover:shadow-blue-600/25 active:scale-[0.98] transition-all duration-300 disabled:opacity-50 mt-8 flex items-center justify-center gap-2 group animate-fade-up [animation-delay:550ms] opacity-0"
aria-busy={loading}
className="group mt-6 flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-3.5 text-sm font-black text-white shadow-[0_10px_22px_rgba(37,99,235,0.22)] transition hover:bg-blue-700 active:scale-[0.99] disabled:opacity-50"
>
<span>{isLogin ? copy.loginSubmit : copy.signupSubmit}</span>
<ArrowRight size={16} className="transition-transform group-hover:translate-x-1" />
{loading ? loadingSpinner : null}
<span>{loading ? submittingLabel : (isLogin ? copy.loginSubmit : copy.signupSubmit)}</span>
{!loading ? <ArrowRight size={16} className="transition-transform group-hover:translate-x-1" /> : null}
</button>
</form>
<div className="my-6 flex items-center">
<div className="h-px flex-grow bg-slate-200/60" />
<span className="px-3 text-[10px] font-bold uppercase tracking-wider text-slate-400">
{isEn ? "or" : "或"}
<div className="h-px flex-grow bg-slate-200" />
<span className="px-3 text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">
{copy.orGoogle}
</span>
<div className="h-px flex-grow bg-slate-200/60" />
<div className="h-px flex-grow bg-slate-200" />
</div>
<button
type="button"
onClick={() => void onGoogleSignIn()}
disabled={loading}
className="w-full py-3 px-4 rounded-xl border border-slate-200 bg-white text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 active:scale-[0.99] transition-all duration-150 flex items-center justify-center gap-2 disabled:opacity-50"
aria-busy={loading}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-3 text-sm font-bold text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700 active:scale-[0.99] disabled:opacity-50"
>
<Chrome className="h-4 w-4 text-blue-600" />
<span>{copy.googleOneClick}</span>
{loading ? loadingSpinner : <Chrome className="h-4 w-4 text-blue-600" />}
<span>{loading ? googleSubmittingLabel : copy.googleOneClick}</span>
</button>
{errorText ? <p className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2.5 text-xs text-rose-700 leading-normal">{errorText}</p> : null}
{infoText ? <p className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-xs text-emerald-700 leading-normal">{infoText}</p> : null}
{errorText ? (
<p className="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2.5 text-xs leading-normal text-rose-700">{errorText}</p>
) : null}
{infoText ? (
<p className="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-xs leading-normal text-emerald-700">{infoText}</p>
) : null}
{errorText && isLogin && errorText.includes("Invalid login") ? (
<p className="mt-2 text-center text-xs text-slate-500 leading-relaxed">{copy.loginFailedHint}</p>
<p className="mt-2 text-center text-xs leading-relaxed text-slate-500">{copy.loginFailedHint}</p>
) : null}
{infoText === copy.signupCheckEmail ? (
<p className="mt-2 text-center text-xs text-slate-500 leading-relaxed">{copy.resendVerify}</p>
<p className="mt-2 text-center text-xs leading-relaxed text-slate-500">{copy.resendVerify}</p>
) : null}
</div>
</div>
{/* Footer */}
<div className="relative z-10 text-[10px] text-slate-400 text-center font-mono">
© {new Date().getFullYear()} PolyWeather. All rights reserved.
<div className="relative z-10 text-center font-mono text-[10px] text-slate-500">
© {new Date().getFullYear()} PolyWeather
</div>
</div>
</div>
@@ -0,0 +1,227 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AlertCircle, ArrowRight, CheckCircle2, Eye, EyeOff, Lock } from "lucide-react";
import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
import { useI18n } from "@/hooks/useI18n";
type ResetPasswordClientProps = {
nextPath: string;
};
export function ResetPasswordClient({ nextPath }: ResetPasswordClientProps) {
const router = useRouter();
const { locale } = useI18n();
const isEn = locale === "en-US";
const supabaseReady = hasSupabasePublicEnv();
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [checkingSession, setCheckingSession] = useState(true);
const [hasSession, setHasSession] = useState(false);
const [loading, setLoading] = useState(false);
const [errorText, setErrorText] = useState("");
const [infoText, setInfoText] = useState("");
const copy = {
title: isEn ? "Set a new password" : "设置新密码",
subtitle: isEn
? "Create a password for your PolyWeather account."
: "为您的 PolyWeather 账户创建一个新密码。",
password: isEn ? "New password" : "新密码",
confirmPassword: isEn ? "Confirm password" : "确认密码",
passwordPlaceholder: isEn ? "At least 6 characters" : "至少 6 位字符",
confirmPlaceholder: isEn ? "Enter it again" : "再次输入新密码",
submit: isEn ? "Update password" : "更新密码",
checking: isEn ? "Checking reset link..." : "正在检查重置链接...",
expired: isEn
? "This reset link is expired or invalid. Please request a new password reset email."
: "重置链接已过期或无效,请重新发送密码重置邮件。",
supabaseMissing: isEn
? "Supabase is not configured. Password reset is unavailable."
: "Supabase 未配置,无法重置密码。",
passwordTooShort: isEn
? "Password must be at least 6 characters."
: "密码至少需要 6 位字符。",
passwordMismatch: isEn
? "The two passwords do not match."
: "两次输入的密码不一致。",
success: isEn
? "Password updated. Redirecting..."
: "密码已更新,正在跳转...",
backLogin: isEn ? "Back to sign in" : "返回登录",
} as const;
useEffect(() => {
if (!supabaseReady) {
setErrorText(copy.supabaseMissing);
setCheckingSession(false);
return;
}
const run = async () => {
const supabase = getSupabaseBrowserClient();
const {
data: { session },
} = await supabase.auth.getSession();
const hasRecoverySession = Boolean(session?.user);
setHasSession(hasRecoverySession);
if (!hasRecoverySession) {
setErrorText(copy.expired);
}
setCheckingSession(false);
};
void run();
}, [copy.expired, copy.supabaseMissing, supabaseReady]);
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorText("");
setInfoText("");
if (!hasSession) {
setErrorText(copy.expired);
return;
}
if (password.length < 6) {
setErrorText(copy.passwordTooShort);
return;
}
if (password !== confirmPassword) {
setErrorText(copy.passwordMismatch);
return;
}
setLoading(true);
try {
const supabase = getSupabaseBrowserClient();
const { error } = await supabase.auth.updateUser({ password });
if (error) {
setErrorText(error.message);
return;
}
setInfoText(copy.success);
window.setTimeout(() => router.replace(nextPath), 700);
} finally {
setLoading(false);
}
};
return (
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-5 py-10 text-slate-900">
<div className="w-full max-w-[420px] rounded-2xl border border-slate-200 bg-white p-6 shadow-[0_24px_60px_rgba(8,16,36,0.08)] sm:p-8">
<Link href="/" className="mb-8 inline-flex items-center">
<img src="/logo.png" alt="PolyWeather" className="h-8 w-auto object-contain" />
</Link>
<div className="mb-6 space-y-2">
<h1 className="text-2xl font-black tracking-tight">{copy.title}</h1>
<p className="text-sm leading-6 text-slate-500">{copy.subtitle}</p>
</div>
{checkingSession ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-600">
{copy.checking}
</div>
) : (
<form onSubmit={(event) => void onSubmit(event)} className="space-y-5">
<div className="space-y-2">
<label className="block text-[10px] font-bold uppercase tracking-wider text-slate-400">
{copy.password}
</label>
<div className="relative">
<Lock className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type={showPassword ? "text" : "password"}
minLength={6}
required
disabled={!hasSession || loading}
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder={copy.passwordPlaceholder}
className="w-full rounded-xl border border-slate-200 bg-slate-50/70 py-3 pl-11 pr-11 text-sm text-slate-900 placeholder:text-slate-400 transition-all focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
onClick={() => setShowPassword((value) => !value)}
disabled={!hasSession || loading}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 transition-colors hover:text-slate-600 disabled:cursor-not-allowed disabled:opacity-60"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div className="space-y-2">
<label className="block text-[10px] font-bold uppercase tracking-wider text-slate-400">
{copy.confirmPassword}
</label>
<div className="relative">
<Lock className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type={showConfirmPassword ? "text" : "password"}
minLength={6}
required
disabled={!hasSession || loading}
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
placeholder={copy.confirmPlaceholder}
className="w-full rounded-xl border border-slate-200 bg-slate-50/70 py-3 pl-11 pr-11 text-sm text-slate-900 placeholder:text-slate-400 transition-all focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
onClick={() => setShowConfirmPassword((value) => !value)}
disabled={!hasSession || loading}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 transition-colors hover:text-slate-600 disabled:cursor-not-allowed disabled:opacity-60"
aria-label={showConfirmPassword ? "Hide password" : "Show password"}
>
{showConfirmPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<button
type="submit"
disabled={!hasSession || loading}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-slate-900 px-4 py-3.5 text-sm font-bold text-white shadow-lg shadow-slate-950/10 transition-all hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<span>{copy.submit}</span>
<ArrowRight size={16} />
</button>
</form>
)}
{errorText ? (
<div className="mt-4 flex gap-2 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2.5 text-xs leading-5 text-rose-700">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{errorText}</span>
</div>
) : null}
{infoText ? (
<div className="mt-4 flex gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-xs leading-5 text-emerald-700">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<span>{infoText}</span>
</div>
) : null}
<Link
href="/auth/login"
className="mt-5 inline-flex text-xs font-semibold text-blue-600 transition-colors hover:text-blue-700"
>
{copy.backLogin}
</Link>
</div>
</main>
);
}
@@ -0,0 +1,42 @@
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",
);
assert(
source.includes("登录 PolyWeather") &&
source.includes("创建 PolyWeather 账号") &&
source.includes("创建账号并领取试用"),
"auth entry copy must be product-specific and explain the signup trial path",
);
assert(
source.includes("PW") && source.includes("PolyWeather"),
"auth pages must show a readable PolyWeather brand mark",
);
assert(
!source.includes("brightness-0 invert"),
"auth dark panel must not invert the square logo image into a white block",
);
assert(
source.includes("items-start justify-center pt-10") &&
source.includes("lg:items-center lg:pt-0"),
"auth form must sit higher on mobile while staying centered on desktop",
);
assert(
!source.includes("hover:-translate-y-1"),
"auth form card must not shift position on hover",
);
assert(
source.includes("hidden text-xs text-slate-500 sm:inline") &&
source.includes("whitespace-nowrap rounded-xl"),
"auth mode switch prompt must not crowd or wrap the mobile header",
);
}
@@ -0,0 +1,109 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const loginClientPath = path.join(
projectRoot,
"components",
"auth",
"LoginClient.tsx",
);
const resetPagePath = path.join(
projectRoot,
"app",
"auth",
"reset-password",
"page.tsx",
);
const resetClientPath = path.join(
projectRoot,
"components",
"auth",
"ResetPasswordClient.tsx",
);
const authCallbackPath = path.join(
projectRoot,
"app",
"auth",
"callback",
"route.ts",
);
const loginPagePath = path.join(
projectRoot,
"app",
"auth",
"login",
"page.tsx",
);
const loginClientSource = fs.readFileSync(loginClientPath, "utf8");
const loginPageSource = fs.readFileSync(loginPagePath, "utf8");
assert(
loginClientSource.includes("/auth/reset-password") &&
loginClientSource.indexOf("resetPasswordForEmail") <
loginClientSource.indexOf("/auth/reset-password"),
"forgot-password emails must land on a password reset page after auth callback",
);
assert(
fs.existsSync(resetPagePath),
"password reset page must exist at app/auth/reset-password/page.tsx",
);
assert(
fs.existsSync(resetClientPath),
"password reset page must use a dedicated client component",
);
assert(
loginClientSource.includes("loadingSpinner") &&
loginClientSource.includes("submittingLabel") &&
loginClientSource.includes("googleSubmittingLabel") &&
loginClientSource.includes('aria-busy={loading}') &&
loginClientSource.includes("animate-spin"),
"login submit and Google sign-in buttons must show a visible loading spinner and pending label",
);
const resetClientSource = fs.readFileSync(resetClientPath, "utf8");
assert(
resetClientSource.includes("supabase.auth.updateUser") &&
resetClientSource.includes("password"),
"password reset client must update the authenticated user's password",
);
assert(
resetClientSource.includes("getSession") &&
resetClientSource.includes("expired"),
"password reset client must detect an expired or invalid recovery session",
);
const authCallbackSource = fs.readFileSync(authCallbackPath, "utf8");
assert(
authCallbackSource.includes("warmSignupTrial") &&
authCallbackSource.includes("exchangeCodeForSession") &&
authCallbackSource.includes("/api/auth/me") &&
authCallbackSource.includes("Authorization") &&
authCallbackSource.includes("Bearer"),
"auth callback must warm /api/auth/me with the exchanged Supabase session so new users receive the signup trial immediately",
);
assert(
loginPageSource.includes("error?: string") &&
loginPageSource.includes("normalizeAuthError") &&
loginPageSource.includes("params.error") &&
loginPageSource.includes("initialError={initialError}") &&
loginClientSource.includes("initialError?: string") &&
loginClientSource.includes("useState(initialError || \"\")"),
"login page must surface auth callback errors in the login form instead of silently returning users to the terminal",
);
assert(
authCallbackSource.includes("redirectToLoginWithError") &&
authCallbackSource.includes('request.nextUrl.searchParams.get("error_description")') &&
authCallbackSource.includes('request.nextUrl.searchParams.get("error")') &&
authCallbackSource.includes("exchangeError") &&
authCallbackSource.includes("exchangeCodeForSession(code)") &&
authCallbackSource.includes("auth_error"),
"auth callback must redirect failed OAuth/session exchanges back to login with an error message",
);
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { REGIONS, getCityRegion } from "./continent-grouping";
@@ -113,6 +113,9 @@ const getCityCode = (city: string): string => {
return CITY_IATA_MAP[normalized] || normalized.substring(0, 3).toUpperCase();
};
const MIN_DROPDOWN_TOP_PX = 56;
const DROPDOWN_VIEWPORT_PADDING_PX = 12;
export function CitySelectorDropdown({
isEn,
rows,
@@ -123,7 +126,9 @@ export function CitySelectorDropdown({
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = useState("");
const deferredSearchQuery = useDeferredValue(searchQuery);
const [activeTab, setActiveTab] = useState<string>("all");
const [viewportNudgeY, setViewportNudgeY] = useState(0);
// Auto-focus input on mount
useEffect(() => {
@@ -166,7 +171,7 @@ export function CitySelectorDropdown({
// Filter rows
const filteredRows = useMemo(() => {
const q = searchQuery.toLowerCase().trim();
const q = deferredSearchQuery.toLowerCase().trim();
return rows.filter((row) => {
// 1. Region filter
if (activeTab !== "all") {
@@ -191,7 +196,36 @@ export function CitySelectorDropdown({
.map((s) => s!.toLowerCase());
return haystack.some((s) => s.includes(q));
});
}, [rows, searchQuery, activeTab]);
}, [rows, deferredSearchQuery, activeTab]);
useEffect(() => {
let frame = 0;
const updatePosition = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
let next = 0;
if (rect.top < MIN_DROPDOWN_TOP_PX) {
next = MIN_DROPDOWN_TOP_PX - rect.top;
} else if (rect.bottom > window.innerHeight - DROPDOWN_VIEWPORT_PADDING_PX) {
next = Math.max(
MIN_DROPDOWN_TOP_PX - rect.top,
window.innerHeight - DROPDOWN_VIEWPORT_PADDING_PX - rect.bottom,
);
}
setViewportNudgeY((prev) => (Math.abs(prev - next) < 1 ? prev : next));
});
};
updatePosition();
window.addEventListener("resize", updatePosition);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", updatePosition);
};
}, [filteredRows.length]);
const getRegionLabel = (regionKey: string): string => {
const match = REGIONS.find((r) => r.key === regionKey);
@@ -203,9 +237,10 @@ export function CitySelectorDropdown({
<div
ref={containerRef}
className={clsx(
"flex flex-col bg-white border border-slate-300 rounded shadow-2xl overflow-hidden text-xs text-[#202833] animate-in fade-in-50 zoom-in-95 duration-100",
"flex max-h-[calc(100vh-72px)] min-h-0 flex-col bg-white border border-slate-300 rounded shadow-2xl overflow-hidden text-xs text-[#202833] animate-in fade-in-50 zoom-in-95 duration-100",
className
)}
style={viewportNudgeY ? { marginTop: viewportNudgeY } : undefined}
onClick={(e) => e.stopPropagation()} // Prevent triggering slot clicks
>
{/* Search Input Area */}
@@ -243,7 +278,7 @@ export function CitySelectorDropdown({
</div>
{/* Results List */}
<div className="flex-1 overflow-y-auto max-h-[380px] divide-y divide-slate-100">
<div className="min-h-0 flex-1 overflow-y-auto max-h-[380px] divide-y divide-slate-100">
{filteredRows.length === 0 ? (
<div className="p-4 text-center text-slate-400 font-medium">
{isEn ? "No matching cities" : "无匹配城市"}

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