Compare commits

...

279 Commits

Author SHA1 Message Date
2569718930@qq.com 07c5451f1d Allow manual payment tx submission during transient validation 2026-06-24 17:03:45 +08:00
2569718930@qq.com 51b0e41c0f Track public content acquisition events 2026-06-24 16:45:47 +08:00
2569718930@qq.com 0a46933f6a Add public market brief content assets 2026-06-24 16:41:13 +08:00
2569718930@qq.com 004439b736 Add Microsoft Clarity tracking 2026-06-24 16:25:49 +08:00
2569718930@qq.com b8a1950932 Fix Ankara chart stale MGM observations 2026-06-23 19:02:31 +08:00
2569718930@qq.com 221edb480c Bootstrap missing training actual history 2026-06-23 18:27:32 +08:00
2569718930@qq.com ba2db42eaa Restore DEB training settlement worker 2026-06-23 18:16:46 +08:00
2569718930@qq.com 0c76874418 Implement ops operational closure 2026-06-23 17:07:25 +08:00
2569718930@qq.com 24415ee427 Track forwarded users in online presence 2026-06-22 12:08:20 +08:00
2569718930@qq.com 567e41ba2a Fix chart forecast floor for live observations 2026-06-21 16:28:37 +08:00
2569718930@qq.com 566c47e683 修复图表多模型旧日期缓存 2026-06-17 21:52:14 +08:00
2569718930@qq.com 4b4fb92070 修复图表多模型缓存兜底 2026-06-17 21:26:10 +08:00
2569718930@qq.com 6279b8ac32 给终端更新公告加未读提醒 2026-06-17 02:18:49 +08:00
2569718930@qq.com 75eb4eb35d 修复 AROME HD 旧预测片段渲染 2026-06-17 01:43:34 +08:00
2569718930@qq.com 0f65591931 更新终端公告文案 2026-06-17 01:14:15 +08:00
2569718930@qq.com 9aa6653364 修复 Taipei CWA 旧缓存覆盖判定 2026-06-17 00:19:41 +08:00
2569718930@qq.com e1376108f9 重命名图表渲染态类型 2026-06-17 00:14:02 +08:00
2569718930@qq.com 491415427c 强类型隔离图表观测和详情数据 2026-06-16 23:30:13 +08:00
2569718930@qq.com 53cf5b556b 补齐实时观测采集源覆盖 2026-06-16 23:01:29 +08:00
2569718930@qq.com 8edcedb300 修复 Telegram 绑定按钮开空白页:去掉 about:blank 跳转 2026-06-16 22:59:52 +08:00
2569718930@qq.com 3931d21637 拆分图表实时观测和模型缓存 2026-06-16 22:42:01 +08:00
2569718930@qq.com d3e76ecac9 修复图表观测缓存阻塞详情加载 2026-06-16 21:58:14 +08:00
2569718930@qq.com 4627a110bf 修复图表预测曲线和机场观测标签 2026-06-16 21:18:22 +08:00
2569718930@qq.com 1aa4fab6d8 修正非美国机场观测标签回退 2026-06-16 20:40:49 +08:00
2569718930@qq.com 135198e161 拆分图表实时观测兜底 2026-06-16 18:29:37 +08:00
2569718930@qq.com f1d9487017 修复台北 CWA 旧缓存图表回退 2026-06-16 17:36:11 +08:00
2569718930@qq.com 482dc3c9e6 补充 CWA 官方日内观测兜底 2026-06-16 17:08:31 +08:00
2569718930@qq.com 20d64a880a 统一城市详情实时观测叠加路径 2026-06-16 16:57:51 +08:00
2569718930@qq.com ba59aebf87 优先使用本地官方观测修复图表旧值 2026-06-16 16:43:15 +08:00
2569718930@qq.com 6943f637ad 修复图表实时观测叠加缓存降级 2026-06-16 16:29:09 +08:00
2569718930@qq.com a2d9051be1 新增 CWA 台北图表叠加 2026-06-16 15:27:53 +08:00
2569718930@qq.com 2a38a19f18 图表叠加超时 500→3000ms,避免 SQLite 锁竞争下的误跳过 2026-06-16 15:17:47 +08:00
2569718930@qq.com aad917ccb6 修复图表叠加超时 500ms→2000ms 导致 overlay 被跳过 2026-06-16 15:17:11 +08:00
2569718930@qq.com a9ed45a3d3 修复图表 reference-only 加载动画 2026-06-16 05:43:19 +08:00
2569718930@qq.com 153595ccfc 新增 HKO(香港/深圳)图表叠加 2026-06-16 05:31:04 +08:00
2569718930@qq.com 2d019d051f 新增 AMOS(首尔/釜山)和 MGM(安卡拉/伊斯坦布尔)图表叠加
- overlay_latest_amos_observation: 从 raw_observation_latest 读取韩国跑道传感器数据
- overlay_latest_mgm_observation: 读取土耳其 MGM 官方观测
- 接入 _get_city_chart_data 的 force_refresh 和缓存两条路径
2026-06-16 05:26:18 +08:00
2569718930@qq.com 9dc0d598a0 修复图表时区计算和 stale 缓存展示
- formatCityLocalDate 改为纯 UTC+cityOffset+getUTC*,不再受浏览器时区影响
- 新增 formatCityLocalDateTime 统一更新时间的时区处理
- 缓存过期时静默后台刷新,不转 loading spinner
- showingStaleDetail 现在不依赖 detailError 就显示橙色标签
2026-06-16 05:18:23 +08:00
2569718930@qq.com f6fbff9a28 修复东京图表旧 JMA 观测叠加 2026-06-16 05:11:49 +08:00
2569718930@qq.com 3bc8e73ab6 收敛终端图表缓存状态 2026-06-16 04:57:26 +08:00
2569718930@qq.com fbea6df175 收敛终端图表详情刷新入口 2026-06-16 04:46:48 +08:00
2569718930@qq.com 1e1a78e946 修正终端 METAR 当前温度显示 2026-06-16 04:34:49 +08:00
2569718930@qq.com 1afd3c6976 修复终端首屏图表加载动画 2026-06-16 04:29:24 +08:00
2569718930@qq.com 14e356c3c9 修正跑道高点缺历史时的回退来源 2026-06-16 04:18:30 +08:00
2569718930@qq.com f9d3fc2d96 接入 Cloudflare 免费能力并修正跑道日高 2026-06-16 03:59:01 +08:00
2569718930@qq.com 7756b137a2 触发 CI 重建 2026-06-16 03:53:19 +08:00
2569718930@qq.com a94e6ecaf9 提交 Turnstile 集成源文件改动 2026-06-16 03:49:49 +08:00
2569718930@qq.com e896ffa7ac 修复跑道高点测试:校验值改为实际跑道传感器高点 2026-06-16 03:46:52 +08:00
2569718930@qq.com 382bc99f0c 集成 Turnstile 人机验证 + Cloudflare R2 事件归档 2026-06-16 03:40:47 +08:00
2569718930@qq.com 9d61d9443d 修复电报推送跑道高点使用机场METAR而非跑道传感器数据
_get_airport_daily_high 对 AMSC/AMOS 跑道城市优先从
runway_plate_history 取结算跑道的历史最高温
2026-06-16 03:08:29 +08:00
2569718930@qq.com 555de2f415 添加 AMSC 跑道数据诊断脚本 2026-06-16 02:57:06 +08:00
2569718930@qq.com 316540e8f4 修复 ruff E402 和依赖策略测试 2026-06-16 02:09:54 +08:00
2569718930@qq.com eb5579dbbd 修复 torch 安装:使用 extra-index-url 替代直接 URL 2026-06-16 02:04:52 +08:00
2569718930@qq.com 6d7f213cd9 修复 lint 警告及测试 monkeypatch 引用 2026-06-16 02:03:47 +08:00
2569718930@qq.com dca4f2d618 架构重构:拆分 core/ops/DBManager,统一 SQLite 锁,DEB 改进,新增注意力模型
- web/core.py 858→236行,拆出 schemas/middleware/auth/diagnostics
- ops_api.py 2876→4个 domain 模块 (users/payments/health/config)
- DBManager Supabase HTTP 调用提取到 SupabaseAdminClient
- 新增 LockedSQLiteConnection 统一多进程读写锁
- 新增 WeatherCacheManager 替代 12 个独立缓存字典
- METAR 缓存迁移至统一缓存管理器
- analysis_service 提取 _build_intraday_meteorology 到独立模块
- DEB 改进:偏差惩罚、分歧回退、自适应 lookback (MAE ↓12.6%)
- 新增 PyTorch 注意力模型 deb_attention.py (数据积累后启用)
- 新增 torch 到 requirements.lock
2026-06-16 02:00:22 +08:00
2569718930@qq.com ed0447f408 Drop stale observation fields during overlay 2026-06-15 23:32:47 +08:00
2569718930@qq.com 3984e12c44 Clear stale Ankara source fields 2026-06-15 23:25:50 +08:00
2569718930@qq.com 5075cf40b8 Fix Ankara stale chart date overlay 2026-06-15 23:12:56 +08:00
2569718930@qq.com fd9d768c6d Fix Ankara MGM observation freshness 2026-06-15 22:47:13 +08:00
2569718930@qq.com c0b050d2a7 Serialize SQLite write connections 2026-06-15 22:23:29 +08:00
2569718930@qq.com 55cfb202da Label Turkey station observations as MGM 2026-06-15 21:02:44 +08:00
2569718930@qq.com 95b55d4ab3 Replace stale runway history on raw backfill 2026-06-15 20:49:37 +08:00
2569718930@qq.com 07e28481e8 Require recent runway history coverage 2026-06-15 20:40:57 +08:00
2569718930@qq.com 383011f9fe Filter runway history by observation time 2026-06-15 20:20:31 +08:00
2569718930@qq.com 7443383720 Avoid raw runway history rebuild on hot path 2026-06-15 20:08:13 +08:00
2569718930@qq.com 9e91e8576c Avoid repeated runway history copies 2026-06-15 19:58:04 +08:00
2569718930@qq.com 4ff334aec4 Use indexed raw observation history lookup 2026-06-15 19:47:17 +08:00
2569718930@qq.com 7a9573c214 Build runway history from raw observations 2026-06-15 19:35:17 +08:00
2569718930@qq.com 791b2d115f Backfill runway history from latest AMSC overlay 2026-06-15 19:20:22 +08:00
2569718930@qq.com 56f397bb2e Persist AMSC runway history from collector 2026-06-15 18:59:41 +08:00
2569718930@qq.com 05a36f857b Preserve runway history during chart refresh 2026-06-15 18:47:25 +08:00
2569718930@qq.com 3f8ae44149 Accept entitlement token as service bearer 2026-06-15 18:17:38 +08:00
2569718930@qq.com 690acf1154 Stabilize chart detail refresh against live rows 2026-06-15 15:21:31 +08:00
2569718930@qq.com 15e0cb09b8 Fix payment confirm response with runtime intent fields 2026-06-15 14:56:37 +08:00
2569718930@qq.com 6b9caf8dc2 Stabilize AMSC chart detail overlays 2026-06-15 03:13:08 +08:00
2569718930@qq.com 39977e4431 Restore detail chart 23:30 slot 2026-06-15 02:45:20 +08:00
2569718930@qq.com 4210f5f753 Preserve chart models during live updates 2026-06-15 02:04:55 +08:00
2569718930@qq.com c79e0f36cc Improve scan terminal freshness and AMSC overlay consistency 2026-06-15 01:39:19 +08:00
2569718930@qq.com 717786b1d8 Align stored AMSC payload timestamps 2026-06-15 01:11:26 +08:00
2569718930@qq.com f98ab4a9fe Prefer AMSC sessionId over stale cookie 2026-06-15 00:57:10 +08:00
2569718930@qq.com 59f19407cb Use latest AMSC runway timestamp for overlays 2026-06-15 00:43:07 +08:00
2569718930@qq.com 8bf7cdd687 Overlay latest AMSC observations in city and Telegram payloads 2026-06-15 00:29:43 +08:00
2569718930@qq.com e6ea72c458 Prefer latest AMSC data for Telegram runway pushes 2026-06-15 00:09:12 +08:00
2569718930@qq.com 4e38659a10 Add Windows tzdata dependency 2026-06-14 23:53:38 +08:00
2569718930@qq.com 3b34ddbf12 Strip Wunderground data from city cache responses 2026-06-14 23:47:25 +08:00
2569718930@qq.com d1124ae17e Lock Python dependencies and ignore frontend env files 2026-06-14 23:35:22 +08:00
2569718930@qq.com ddca698406 Disable default Wunderground fetching 2026-06-14 23:32:34 +08:00
2569718930@qq.com 3120883bb8 chore: remove dead code directories and files 2026-06-14 23:26:46 +08:00
2569718930@qq.com 2e11e3fd56 Refresh VPS deployment docs 2026-06-14 23:21:03 +08:00
2569718930@qq.com ef21a5808e Update production deployment references 2026-06-14 23:13:37 +08:00
2569718930@qq.com e909f1f4c9 Remove sync city refresh fetch escape hatch 2026-06-14 23:09:31 +08:00
2569718930@qq.com 0e2b076535 fix ops admin frontend fallback 2026-06-14 22:57:10 +08:00
2569718930@qq.com 7fb82c5eba Stop legacy city refreshes from fetching sources 2026-06-14 22:51:15 +08:00
2569718930@qq.com a9e9eed2f1 fix ops local admin redirects 2026-06-14 22:44:02 +08:00
2569718930@qq.com 8bbe8f74c1 Extract forecast source bundle from collector 2026-06-14 22:27:05 +08:00
2569718930@qq.com c0a5912246 Preserve terminal chart detail during refresh 2026-06-14 20:11:08 +08:00
2569718930@qq.com 9cb794655b Emit realtime events from canonical updates 2026-06-14 19:51:34 +08:00
2569718930@qq.com 43aeb6047e Select canonical temperature from raw observations 2026-06-14 19:33:09 +08:00
2569718930@qq.com 00b163a8ec Introduce observation source adapters 2026-06-14 19:20:43 +08:00
2569718930@qq.com d0c8d4c11b Track observation source health states 2026-06-14 19:06:03 +08:00
2569718930@qq.com ccc8866229 Stop collector from triggering full weather refreshes 2026-06-14 18:44:49 +08:00
2569718930@qq.com 3e1e06d8e1 Queue stale weather refreshes from user paths 2026-06-14 18:21:53 +08:00
2569718930@qq.com e111031119 Decouple live weather reads from source collection 2026-06-14 17:53:24 +08:00
2569718930@qq.com a098a5ab20 Prevent stale chart auto refresh rollback 2026-06-14 08:39:08 +08:00
2569718930@qq.com e2ebf5132d Refresh terminal charts on activation 2026-06-14 08:08:49 +08:00
2569718930@qq.com ad1e203adf Retry transient chart detail misses 2026-06-14 07:42:18 +08:00
2569718930@qq.com abe08de769 Forward Supabase identity for terminal data proxies 2026-06-14 07:26:15 +08:00
2569718930@qq.com b1edb1b7e1 Resolve backend Supabase env during deploy 2026-06-14 07:07:46 +08:00
2569718930@qq.com 0f497ab822 Simplify account payment flow and harden direct transfers 2026-06-14 06:58:45 +08:00
2569718930@qq.com 715d79c9b5 Update deploy stability test for protected scan smoke 2026-06-14 06:56:11 +08:00
2569718930@qq.com 2781a97d9d Allow protected scan terminal deploy smoke 2026-06-14 06:49:28 +08:00
2569718930@qq.com 56f31c0aa2 Add Telegram webhook command entrypoint 2026-06-14 06:32:58 +08:00
2569718930@qq.com 450fa43e5d Clarify Pro checkout overlay copy 2026-06-14 06:24:21 +08:00
2569718930@qq.com 0f00b00b5e Fix terminal auth bootstrap and account checkout gate 2026-06-14 05:52:44 +08:00
2569718930@qq.com 1664925607 Use python3 in deploy topic sync 2026-06-14 05:51:51 +08:00
2569718930@qq.com e6a5fd2dda Fix Telegram forum topic routing 2026-06-14 05:45:07 +08:00
2569718930@qq.com e783f35a4f Resolve account subscription sync state 2026-06-14 05:28:48 +08:00
2569718930@qq.com 0084235b57 Fix landing locale toggle cache path 2026-06-14 05:11:37 +08:00
2569718930@qq.com b4a23578f2 Add Telegram forum topic sync script 2026-06-14 05:01:14 +08:00
2569718930@qq.com 6142a9cb65 Fix feedback auth for expired users 2026-06-14 04:57:42 +08:00
2569718930@qq.com e74dfb4953 Add Telegram topic binding command 2026-06-14 04:48:58 +08:00
2569718930@qq.com e6c99c946a Fix optional feedback auth fallback 2026-06-14 04:40:02 +08:00
2569718930@qq.com 25408797f1 Add production cache warmer 2026-06-14 04:23:15 +08:00
2569718930@qq.com 4009a26431 Avoid Telegram forum fallback to general 2026-06-14 03:58:40 +08:00
2569718930@qq.com b53f8a3eee Guard Telegram forum topic routing 2026-06-14 03:49:18 +08:00
2569718930@qq.com 2752156473 Forward verified bearer identity to backend 2026-06-14 03:29:52 +08:00
2569718930@qq.com 5d0f2d4505 Route landing account sign-ins to checkout 2026-06-14 03:14:10 +08:00
2569718930@qq.com 8e775e0429 Avoid account feedback 401 before auth 2026-06-14 03:01:12 +08:00
2569718930@qq.com c4cf69c67f Redirect terminal non-members to checkout after login 2026-06-14 02:49:18 +08:00
2569718930@qq.com 285191a8be Strengthen expired subscription gate 2026-06-14 02:34:08 +08:00
2569718930@qq.com 00da22e594 Fix auth login redirect loop 2026-06-14 02:20:20 +08:00
2569718930@qq.com f071ac504d Fix terminal auth fallback navigation 2026-06-14 02:08:20 +08:00
2569718930@qq.com 7547b0caec chore: recheck VPS deploy key 2026-06-14 01:52:04 +08:00
2569718930@qq.com efef09b625 chore: verify VPS deploy key 2026-06-14 01:43:29 +08:00
2569718930@qq.com 1ceeb7e7e0 fix wallet verify proxy resilience 2026-06-13 22:09:11 +08:00
2569718930@qq.com e0cbe57071 Add lightweight bot DEB endpoint 2026-06-13 17:57:24 +08:00
2569718930@qq.com 9d120d7215 Restrict observability endpoints to ops admins 2026-06-13 02:35:30 +08:00
2569718930@qq.com be1d93fc09 Reduce detail concurrency for two-core VPS 2026-06-13 02:11:34 +08:00
2569718930@qq.com f0b1b57ae2 Tune production concurrency for small VPS 2026-06-13 01:59:28 +08:00
2569718930@qq.com f115be1fd3 fix chart refresh and bot command surface 2026-06-13 01:34:09 +08:00
2569718930@qq.com cf35e55da4 feat: reward paid members at user growth milestones 2026-06-12 23:52:56 +08:00
2569718930@qq.com 33fbf17c18 support feedback rewards for unbound users 2026-06-12 23:17:34 +08:00
2569718930@qq.com 568d22ff1d retry CI after backend image build failure 2026-06-11 20:34:39 +08:00
2569718930@qq.com 63038cfaea fix multi-model chart time axis 2026-06-11 20:31:07 +08:00
2569718930@qq.com 43cc917861 retry CI after backend image build failure 2026-06-11 16:24:38 +08:00
2569718930@qq.com 8e4a9ae552 remove Shenzhen airport Telegram push 2026-06-11 16:08:33 +08:00
2569718930@qq.com 87bf7c3575 fallback missing Telegram city topics 2026-06-11 15:58:13 +08:00
2569718930@qq.com c5e5e73ce1 prefer newest observation for Telegram pushes 2026-06-11 15:47:58 +08:00
2569718930@qq.com 9a7257008e speed up China airport Telegram pushes 2026-06-11 15:38:05 +08:00
2569718930@qq.com 5fa3fb8999 use lightweight MADIS health probe 2026-06-11 14:29:56 +08:00
2569718930@qq.com d314aa04d7 preserve auth headers in json proxies 2026-06-11 14:04:17 +08:00
2569718930@qq.com 5b65da6049 refresh visible charts without force refresh 2026-06-10 23:36:00 +08:00
2569718930@qq.com dd58729607 decouple Cloudflare cache sync from deploy 2026-06-10 21:05:23 +08:00
2569718930@qq.com 5d57a5e597 respect origin browser TTL in Cloudflare rules 2026-06-10 21:00:48 +08:00
2569718930@qq.com 5adf397a1e retry Cloudflare cache rules sync 2026-06-10 20:52:42 +08:00
2569718930@qq.com 1eed84777a sync Cloudflare cache rules 2026-06-10 20:49:05 +08:00
2569718930@qq.com c77f5fce0c rerun CI after Cloudflare token docs 2026-06-10 20:32:28 +08:00
2569718930@qq.com 0d9b8446ef document Cloudflare cache token setup 2026-06-10 20:29:28 +08:00
2569718930@qq.com e085333a03 maximize safe Cloudflare edge caching 2026-06-10 20:04:33 +08:00
2569718930@qq.com 05ec1fd993 avoid caching initializing scan payloads 2026-06-10 19:49:27 +08:00
2569718930@qq.com 25bd077b25 sync Cloudflare rules and cache scan snapshots 2026-06-10 19:40:54 +08:00
2569718930@qq.com 1494db8e09 maximize Cloudflare edge caching 2026-06-10 19:11:58 +08:00
2569718930@qq.com 081bf9cc05 speed up entitlement auth sync 2026-06-10 16:49:18 +08:00
2569718930@qq.com 7e17ab8a1b fix chart detail batch cache fallback 2026-06-10 16:18:59 +08:00
2569718930@qq.com 35bfd0670c fix payment checkout fallback to manual transfer 2026-06-10 15:58:36 +08:00
2569718930@qq.com f171049f60 Limit terminal grid to 3x2 2026-06-10 15:52:23 +08:00
2569718930@qq.com 5a7d302d11 Add chart freshness diagnostics 2026-06-10 15:34:34 +08:00
2569718930@qq.com ba012736c9 Tune city detail batch limits 2026-06-10 14:34:13 +08:00
2569718930@qq.com df40e6c2ca Guard charts from stale scan rows 2026-06-10 14:00:43 +08:00
2569718930@qq.com c43d916680 Rerun CI for live chart observation fix 2026-06-10 13:39:06 +08:00
2569718930@qq.com efd66ca80c Keep live chart observations current 2026-06-10 13:35:23 +08:00
2569718930@qq.com 93fe7a9b69 Plot live chart observations immediately 2026-06-10 12:56:43 +08:00
2569718930@qq.com 98026e510a Route main SSE traffic to backend 2026-06-10 12:31:14 +08:00
2569718930@qq.com afdfe03174 Clear chart stale retry state 2026-06-10 00:42:07 +08:00
2569718930@qq.com 658f29c655 Remove stale EMOS documentation 2026-06-10 00:40:58 +08:00
2569718930@qq.com 24c4c2ebf6 Reduce scan startup pressure 2026-06-09 23:26:53 +08:00
2569718930@qq.com 2cd2125d62 Avoid blocking cold scan requests 2026-06-09 23:14:11 +08:00
2569718930@qq.com 772f8d85f5 Increase scan terminal build budget 2026-06-09 22:59:17 +08:00
2569718930@qq.com 3a1b2dfd6d Avoid caching failed scan snapshots 2026-06-09 22:47:23 +08:00
2569718930@qq.com bd88f717fb Reduce observation update latency 2026-06-09 22:36:23 +08:00
2569718930@qq.com 45397f1780 Add trial value replay upgrade trigger 2026-06-09 19:00:31 +08:00
2569718930@qq.com 7b965ac60d Improve trial upgrade conversion 2026-06-09 18:17:19 +08:00
2569718930@qq.com c98ff34c1e Fix Telegram fallback binding group gate 2026-06-09 14:23:34 +08:00
2569718930@qq.com 471cdb9fa0 Add landing contact links 2026-06-09 13:14:57 +08:00
2569718930@qq.com d0c0bf7034 Fix Telegram bot binding fallback 2026-06-09 12:57:37 +08:00
2569718930@qq.com f320d07e03 Merge remote-tracking branch 'origin/codex/ops-weekly-leaderboard-ranking' 2026-06-09 12:17:26 +08:00
2569718930@qq.com 84fea5fcc9 Add landing supported cities section 2026-06-08 23:43:35 +08:00
2569718930@qq.com f32a8d990a Fix ops weekly leaderboard ranking 2026-06-08 23:27:13 +08:00
2569718930@qq.com 6c7f3ef67f Use fixed feedback reward amounts 2026-06-08 22:20:35 +08:00
2569718930@qq.com 6273921005 Add ops feedback reward grants 2026-06-08 20:54:20 +08:00
2569718930@qq.com 7d1bfea8da Add ops feedback reward guide 2026-06-08 18:54:43 +08:00
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
390 changed files with 50009 additions and 7274 deletions
+20 -1
View File
@@ -26,6 +26,14 @@ POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
POLYWEATHER_REDIS_STREAM_KEY=stream:city_observation POLYWEATHER_REDIS_STREAM_KEY=stream:city_observation
POLYWEATHER_REDIS_STREAM_MAXLEN=50000 POLYWEATHER_REDIS_STREAM_MAXLEN=50000
POLYWEATHER_REDIS_REQUIRED=true POLYWEATHER_REDIS_REQUIRED=true
# Optional Cloudflare R2 cold archive for realtime SSE/event snapshots.
POLYWEATHER_R2_ARCHIVE_SOURCE=redis
POLYWEATHER_R2_ACCOUNT_ID=
POLYWEATHER_R2_BUCKET=
POLYWEATHER_R2_ENDPOINT_URL=
POLYWEATHER_R2_REGION=auto
POLYWEATHER_R2_ACCESS_KEY_ID=
POLYWEATHER_R2_SECRET_ACCESS_KEY=
# Backend CORS allowlist. Add your Vercel production/preview domains when # Backend CORS allowlist. Add your Vercel production/preview domains when
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL points browsers directly at this backend. # NEXT_PUBLIC_POLYWEATHER_API_BASE_URL points browsers directly at this backend.
WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather.top,https://www.polyweather.top,https://api.polyweather.top WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather.top,https://www.polyweather.top,https://api.polyweather.top
@@ -59,7 +67,7 @@ TELEGRAM_AIRPORT_PUSH_LANGUAGE=both
POLYWEATHER_BOT_CPUS=0.75 POLYWEATHER_BOT_CPUS=0.75
POLYWEATHER_BOT_MEM_LIMIT=768m POLYWEATHER_BOT_MEM_LIMIT=768m
POLYWEATHER_BOT_MEMSWAP_LIMIT=1g POLYWEATHER_BOT_MEMSWAP_LIMIT=1g
POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC=180 POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC=60
POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS=1 POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS=1
######################################## ########################################
@@ -75,6 +83,7 @@ OPEN_METEO_MIN_CALL_INTERVAL_SEC=5
POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=1 POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=1
POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=600 POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=600
POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=45 POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=45
POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS=3000
POLYWEATHER_HTTP_TIMEOUT_SEC=8 POLYWEATHER_HTTP_TIMEOUT_SEC=8
POLYWEATHER_HTTP_RETRY_COUNT=0 POLYWEATHER_HTTP_RETRY_COUNT=0
POLYWEATHER_HTTP_RETRY_BACKOFF_SEC=0.2 POLYWEATHER_HTTP_RETRY_BACKOFF_SEC=0.2
@@ -123,6 +132,10 @@ POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
######################################## ########################################
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
POLYWEATHER_TURNSTILE_BYPASS=false
POLYWEATHER_TURNSTILE_ENFORCE_ACTION=false
POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT=false
# Optional: disable homepage city summary preloading. Default is enabled. # Optional: disable homepage city summary preloading. Default is enabled.
NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES=false NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES=false
# Optional: browser-visible FastAPI base URL for Vercel deployments. # Optional: browser-visible FastAPI base URL for Vercel deployments.
@@ -161,6 +174,12 @@ POLYWEATHER_WEEKLY_REWARD_CHECK_INTERVAL_SEC=300
POLYWEATHER_WEEKLY_REWARD_HTTP_TIMEOUT_SEC=10 POLYWEATHER_WEEKLY_REWARD_HTTP_TIMEOUT_SEC=10
POLYWEATHER_WEEKLY_REWARD_ANNOUNCE_ENABLED=true POLYWEATHER_WEEKLY_REWARD_ANNOUNCE_ENABLED=true
# Verified-user growth milestone rewards
POLYWEATHER_GROWTH_REWARD_ENABLED=false
POLYWEATHER_GROWTH_REWARD_CHECK_INTERVAL_SEC=21600
POLYWEATHER_GROWTH_REWARD_HTTP_TIMEOUT_SEC=15
POLYWEATHER_GROWTH_REWARD_ANNOUNCE_ENABLED=true
# Group message points # Group message points
POLYWEATHER_BOT_MESSAGE_POINTS=4 POLYWEATHER_BOT_MESSAGE_POINTS=4
POLYWEATHER_BOT_MESSAGE_DAILY_CAP=40 POLYWEATHER_BOT_MESSAGE_DAILY_CAP=40
+14
View File
@@ -19,6 +19,12 @@ SUPABASE_SERVICE_ROLE_KEY=
NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_PUBLIC_SUPABASE_ANON_KEY=
########################################
# Cloudflare Turnstile
########################################
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
POLYWEATHER_TURNSTILE_SECRET_KEY=
######################################## ########################################
# Entitlement / dashboard # Entitlement / dashboard
######################################## ########################################
@@ -40,6 +46,14 @@ POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON= POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON= POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
########################################
# Cloudflare R2 archive
########################################
POLYWEATHER_R2_ACCOUNT_ID=
POLYWEATHER_R2_BUCKET=
POLYWEATHER_R2_ACCESS_KEY_ID=
POLYWEATHER_R2_SECRET_ACCESS_KEY=
######################################## ########################################
# Optional exchange / market secrets # Optional exchange / market secrets
######################################## ########################################
+64 -39
View File
@@ -21,7 +21,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-dev.txt pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.lock -r requirements-dev.lock
- name: Ruff - name: Ruff
run: python -m ruff check . run: python -m ruff check .
@@ -73,9 +73,6 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR - name: Login to GHCR
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -83,40 +80,66 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push backend - name: Build and push image
if: matrix.image.name == 'backend' env:
uses: docker/build-push-action@v6 IMAGE_NAME: ${{ matrix.image.name }}
with: IMAGE_CONTEXT: ${{ matrix.image.context }}
context: ${{ matrix.image.context }} IMAGE_FILE: ${{ matrix.image.file }}
file: ${{ matrix.image.file }} IMAGE_TAG: ${{ matrix.image.tag }}
push: true NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
tags: | NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
${{ matrix.image.tag }}:latest NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
${{ matrix.image.tag }}:${{ github.sha }} NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
cache-from: type=gha NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
cache-to: type=gha,mode=max NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${{ secrets.NEXT_PUBLIC_TURNSTILE_SITE_KEY || '' }}
run: |
set -euo pipefail
- name: Build and push frontend tags=(-t "${IMAGE_TAG}:latest" -t "${IMAGE_TAG}:${GITHUB_SHA}")
if: matrix.image.name == 'frontend' build_args=()
uses: docker/build-push-action@v6
with: if [ "${IMAGE_NAME}" = "frontend" ]; then
context: ${{ matrix.image.context }} build_args=(
file: ${{ matrix.image.file }} --build-arg "NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}"
push: true --build-arg "NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}"
tags: | --build-arg "NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}"
${{ matrix.image.tag }}:latest --build-arg "NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL}"
${{ matrix.image.tag }}:${{ github.sha }} --build-arg "NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false"
build-args: | --build-arg "NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID}"
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} --build-arg "NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL}"
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} --build-arg "NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS}"
NEXT_PUBLIC_SITE_URL=${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }} --build-arg "NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY}"
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }} )
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false fi
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' }} docker build -f "${IMAGE_FILE}" "${tags[@]}" "${build_args[@]}" "${IMAGE_CONTEXT}"
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }} docker push "${IMAGE_TAG}:latest"
cache-from: type=gha docker push "${IMAGE_TAG}:${GITHUB_SHA}"
cache-to: type=gha,mode=max
cloudflare-cache-rules:
needs: [python-quality]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Apply Cloudflare cache rules
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
run: |
if [ -z "${CLOUDFLARE_API_TOKEN}" ]; then
echo "CLOUDFLARE_API_TOKEN is not configured; skipping Cache Rules sync"
exit 0
fi
if [ -z "${CLOUDFLARE_ZONE_ID}" ]; then
echo "CLOUDFLARE_ZONE_ID is not configured; skipping Cache Rules sync"
exit 0
fi
python scripts/configure_cloudflare_free.py --apply
deploy: deploy:
needs: [build-and-push] needs: [build-and-push]
@@ -130,11 +153,13 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Deploy to VPS - name: Deploy to VPS
env:
GHCR_PAT: ${{ secrets.GHCR_PAT }}
run: | run: |
mkdir -p ~/.ssh mkdir -p ~/.ssh
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa
scp -o StrictHostKeyChecking=accept-new deploy.sh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/deploy.sh 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 }} " printf '%s\n' "$GHCR_PAT" | ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
bash /tmp/deploy.sh '${{ secrets.GHCR_PAT }}' '${{ github.sha }}' bash /tmp/deploy.sh '${{ github.sha }}'
" "
+7 -1
View File
@@ -9,6 +9,7 @@ data/*.db
data/*.db-* data/*.db-*
data/*.db.* data/*.db.*
data/*.json data/*.json
!data/city_thread_ids.json
data/*backtest*.csv data/*backtest*.csv
data/logs/ data/logs/
data/historical/ data/historical/
@@ -43,6 +44,11 @@ Thumbs.db
frontend/node_modules/ frontend/node_modules/
frontend/.next/ frontend/.next/
frontend/.vercel/ frontend/.vercel/
frontend/.env
frontend/.env.local
frontend/.env.production
frontend/.env.*
!frontend/.env.example
frontend/*.tsbuildinfo frontend/*.tsbuildinfo
frontend/.codex-next-dev*.log frontend/.codex-next-dev*.log
frontend/.codex-next-start*.log frontend/.codex-next-start*.log
@@ -70,6 +76,6 @@ frontend/.next-start.log
tmp_apikey.js tmp_apikey.js
tmp_obs.js tmp_obs.js
tmp_rctp.html tmp_rctp.html
playwright-home-check.png
.codex-backend-*.log .codex-backend-*.log
frontend-next-*.log frontend-next-*.log
*.stackdump
+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 售卖产品。
+1 -1
View File
@@ -49,7 +49,7 @@
- 一键部署脚本:deploy.sh + deploy.ps1 - 一键部署脚本:deploy.sh + deploy.ps1
### 移除 ### 移除
- 删除 LGBM 全部代码和模型文件,EMOS 简化为纯 legacy 高斯分桶 - 删除 LGBM 全部代码和模型文件,概率路径收口为 legacy 高斯分桶
- 删除 Polymarket 价格拉取与 UI 层(MarketDecisionLine - 删除 Polymarket 价格拉取与 UI 层(MarketDecisionLine
- 删除 Groq、Meteoblue、NMC、俄罗斯 pogodaiklimat 数据源 - 删除 Groq、Meteoblue、NMC、俄罗斯 pogodaiklimat 数据源
- 删除预热(prewarm)系统 - 删除预热(prewarm)系统
+8 -6
View File
@@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
PolyWeather Pro — a paid institutional weather-intelligence terminal. 50 monitored cities with real-time METAR/AMOS/MADIS observations, DEB multi-model temperature blending, Mu probability calibration, and intraday bias correction. Pure meteorological decision workspace; no market/price layer. Next.js 15 + React 19 (Vercel) frontend, FastAPI backend (VPS), Telegram bot. PolyWeather Pro — a paid institutional weather-intelligence terminal. 50 monitored cities with real-time METAR/AMOS/MADIS observations, DEB multi-model temperature blending, Mu probability calibration, and intraday bias correction. Pure meteorological decision workspace; no market/price layer. Next.js 15 + React 19 frontend (Docker / VPS, behind Cloudflare + Nginx), FastAPI backend (VPS), Telegram bot.
**Business model**: Paid-only, $10/month, no free tier, no trial. Landing page is public; `/terminal` requires login + active subscription. **Business model**: Paid-only, 29.9 USDC/month or 79.9 USDC/quarter, referral first month 20 USDC. New users get a one-time 3-day trial. Landing page is public; `/terminal` requires login + active subscription.
## Environment & Preferences ## Environment & Preferences
@@ -47,10 +47,12 @@ docker compose down && docker compose up -d --build
## Architecture ## Architecture
``` ```
Users → Next.js (Vercel) → FastAPI :8000 (VPS) Users → Cloudflare → Nginx → Docker Compose (VPS)
/terminal (paid gate) Weather Collector ├── Next.js frontend → FastAPI :8000
/ (landing page) Analysis (DEB + Mu) │ /terminal (paid gate) Weather Collector
Payment Layer (USDC on Polygon) │ / (landing page) Analysis (DEB + Mu)
│ Payment Layer (USDC on Polygon)
└── Redis (SSE event store)
Telegram Bot → bot_listener.py Telegram Bot → bot_listener.py
``` ```
+2 -2
View File
@@ -15,10 +15,10 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
gcc libhdf5-dev libnetcdf-dev && \ gcc libhdf5-dev libnetcdf-dev && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip \ RUN --mount=type=cache,target=/root/.cache/pip \
pip install --prefer-binary -r requirements.txt pip install --prefer-binary --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.lock
COPY . . COPY . .
+20 -33
View File
@@ -5,13 +5,13 @@ Production weather-intelligence stack for temperature settlement markets.
Official dashboard: [polyweather.top](https://polyweather.top/) Official dashboard: [polyweather.top](https://polyweather.top/)
中文说明: [README_ZH.md](README_ZH.md) 中文说明: [README_ZH.md](README_ZH.md)
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday analysis, calibrated probability, model stack, TAF, settlement sources, history, and extension). Public docs center: `/docs/intro` on the main site (bilingual product documentation for the current terminal, chart reading, realtime source cadence, settlement stations, and the browser extension).
## Product Screenshots ## Product Screenshots
### Realtime Terminal ### Realtime Terminal
![PolyWeather realtime terminal](frontend/public/static/web.png) ![PolyWeather realtime terminal](frontend/public/static/web.webp)
### Telegram Runway Alerts ### Telegram Runway Alerts
@@ -21,34 +21,32 @@ 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) [![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`. - Subscription live: `Pro Monthly 29.9 USDC / 30 days` and `Pro Quarterly 79.9 USDC / 90 days`.
- Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards. - 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`, max `3 USDC`). - Points are redeemable for payment discounts (`500 pts = 1 USDC`, monthly max `3 USDC`, quarterly max `8 USDC`). Useful user feedback can also receive manual point rewards through ops.
- 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) plus Ethereum mainnet USDC direct-transfer confirmation. - Onchain checkout live: Polygon contract checkout (USDC / USDC.e) plus Ethereum mainnet USDC direct-transfer confirmation.
- Auto-reconciliation live: event listener + periodic confirm loop. - Auto-reconciliation live: event listener + periodic confirm loop.
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage. - Ops dashboard live: `/ops` for memberships, leaderboard, user feedback triage, manual point grants, and payment incident triage.
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`. - Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
- Realtime terminal live: visible city charts subscribe through `/api/events?cities=...&since_revision=...`, receive `city_observation_patch.v1` SSE patches, and replay short gaps from Redis Stream in production or SQLite fallback in local/single-node mode. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - Telegram airport/runway pushes are bilingual by default and use settlement-endpoint runway temperatures for slope/current/summary copy.
- Runtime state, cache, and core offline training/backfill flows now use SQLite as the primary path; legacy JSON/JSONL files remain only for migration, export, and explicit fallback input. - Runtime state, cache, and core offline training/backfill flows now use SQLite as the primary path; legacy JSON/JSONL files remain only for migration, export, and explicit fallback input.
- EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout.
- Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules. - Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules.
- Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives. - Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives.
- 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. - Terminal data uses page memory cache, browser `localStorage`, backend short-TTL cache, SSE patch replay, and foreground refresh so returning from another tab restores the latest visible chart state quickly.
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets. - Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
- The card label “model-market difference means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully. - The market-signal difference means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
- Calibrated model probability is now the primary probability panel. It shows the active production probability engine (legacy Gaussian or EMOS), while model consensus remains a secondary reference. - Calibrated model probability is now the primary probability panel. It shows the active legacy Gaussian probability engine, while model consensus remains a secondary reference.
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`. - 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. - Temperature chart now overlays `TAF Timing` markers near the expected peak window.
- Trade cue now combines upper-air structure, `TAF`, market crowding, and `edge_percent`. - Trade cue now combines upper-air structure, `TAF`, market crowding, and `edge_percent`.
@@ -72,10 +70,12 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
- Aggregates observations and forecasts for 51 monitored cities. - Aggregates observations and forecasts for 51 monitored cities.
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs. - Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
- Builds a DEB-weighted hourly consensus path for peak-window logic and chart display. - Builds a DEB-weighted hourly consensus path for peak-window logic and chart display.
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via legacy Gaussian or EMOS/CRPS calibration. - Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via the legacy Gaussian calibration path.
- Adds 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. - Reuses one analysis core across web dashboard and Telegram bot.
- Adds payment audit trails, replay tooling, and incident visibility in ops. - Adds payment audit trails, replay tooling, and incident visibility in ops.
- Adds 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 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 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. - 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.
@@ -84,7 +84,7 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
```mermaid ```mermaid
flowchart LR flowchart LR
U["Users (Web / Telegram)"] --> FE["Next.js Frontend (Vercel)"] U["Users (Web / Telegram)"] --> FE["Next.js Frontend (Docker / VPS)"]
U --> BOT["Telegram Bot (VPS)"] U --> BOT["Telegram Bot (VPS)"]
FE --> API["FastAPI /web/app.py"] FE --> API["FastAPI /web/app.py"]
BOT --> API BOT --> API
@@ -131,12 +131,14 @@ npm run dev
## Recent Highlights ## 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. - 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. - 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. - Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
- Intraday analysis now separates meteorology conclusion, evidence chain, invalidation rules, confirmation rules, calibrated probability, and market reference. - Intraday analysis now separates meteorology conclusion, evidence chain, invalidation rules, confirmation rules, calibrated probability, and market reference.
- `TAF` is used as an airport-side confirmation layer, not as the main temperature model. - `TAF` is used as an airport-side confirmation layer, not as the main temperature model.
- Calibrated probability uses legacy Gaussian (default) or EMOS/CRPS when evaluated; model vote counts remain an explanatory consensus line, not the final probability. - Calibrated probability uses the legacy Gaussian path; model vote counts remain an explanatory consensus line, not the final probability.
- Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience. - Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience.
- Realtime terminal charts use SSE patches plus replayable event storage; full HTTP detail remains the authoritative snapshot. - Realtime terminal charts use SSE patches plus replayable event storage; full HTTP detail remains the authoritative snapshot.
- Chart observations are shown in the city's local time, not the browser timezone. - Chart observations are shown in the city's local time, not the browser timezone.
@@ -157,19 +159,6 @@ POLYWEATHER_REDIS_REQUIRED=true
For local development or a strict single-process fallback, keep `POLYWEATHER_EVENT_STORE=sqlite`. For local development or a strict single-process fallback, keep `POLYWEATHER_EVENT_STORE=sqlite`.
## EMOS Local Training
Do not run full EMOS retraining on a small VPS. The VPS should collect data and load approved calibration files; training should run on a local/dev machine using a copied production SQLite database:
```powershell
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
```
Promote a generated `default.json` only when `auto_retrain_report.json` has `ready_for_promotion=true`, and prefer `emos_shadow` before enabling `emos_primary`.
## Ops Verification ## Ops Verification
### Health / system status / metrics ### Health / system status / metrics
@@ -206,8 +195,6 @@ Production payment routes are configured by the backend. Polygon remains the def
| Command | Purpose | | Command | Purpose |
| :-- | :-- | | :-- | :-- |
| `/city <name>` | City real-time analysis |
| `/deb <name>` | DEB historical reconciliation |
| `/top` | User leaderboard | | `/top` | User leaderboard |
| `/id` | Show current chat ID | | `/id` | Show current chat ID |
| `/diag` | Startup diagnostics | | `/diag` | Startup diagnostics |
@@ -240,4 +227,4 @@ Production payment routes are configured by the backend. Polygon remains the def
## Version ## Version
- Version: `v1.8.1` - Version: `v1.8.1`
- Last Updated: `2026-05-28` - Last Updated: `2026-06-07`
+21 -35
View File
@@ -8,22 +8,22 @@
### 实时终端 ### 实时终端
![PolyWeather 实时终端](frontend/public/static/web.png) ![PolyWeather 实时终端](frontend/public/static/web.webp)
### Telegram 跑道推送 ### Telegram 跑道推送
![PolyWeather Telegram 跑道推送](frontend/public/static/tel.png) ![PolyWeather Telegram 跑道推送](frontend/public/static/tel.png)
## 当前产品状态(2026-05-30 ## 当前产品状态(2026-06-07
- 已上线订阅制:`Pro 月付 29.9 USDC / 30 天``Pro 季度 79.9 USDC / 90 天` - 已上线订阅制:`Pro 月付 29.9 USDC / 30 天``Pro 季度 79.9 USDC / 90 天`
- 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分。 - 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分。
- `/city``/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。 - 积分可用于支付抵扣(`500 分 = 1 USDC`,月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。真实、有上下文、有价值的用户反馈也可通过运营后台人工奖励积分。
- 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励。 - 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励。
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e+ Ethereum 主网 USDC 直转确认。 - 已上线链上支付:Polygon 合约支付(USDC / USDC.e+ Ethereum 主网 USDC 直转确认。
- 已上线自动补单:事件监听 + 周期确认双链路。 - 已上线自动补单:事件监听 + 周期确认双链路。
- 已上线支付运行态与审计接口:`/api/payments/runtime` - 已上线支付运行态与审计接口:`/api/payments/runtime`
- 已上线轻量运营后台:`/ops`(会员、积分、补分、支付异常单)。 - 已上线轻量运营后台:`/ops`(会员、用户反馈处理、积分、补分、支付异常单)。
- 已上线轻量可观测性:`/healthz``/api/system/status``/metrics` - 已上线轻量可观测性:`/healthz``/api/system/status``/metrics`
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。 - 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。
- 实时终端已切换到可重放事件流:可见城市图表通过 `/api/events?cities=...&since_revision=...` 订阅 `city_observation_patch.v1`,生产环境使用 Redis Stream 做短窗口 replay,本地/单进程可回退 SQLite event log。 - 实时终端已切换到可重放事件流:可见城市图表通过 `/api/events?cities=...&since_revision=...` 订阅 `city_observation_patch.v1`,生产环境使用 Redis Stream 做短窗口 replay,本地/单进程可回退 SQLite event log。
@@ -31,12 +31,11 @@
- 城市图表默认展示“全天”,可选“高温”窗口由 DEB hourly path 推导;所有图表横轴都按城市当地时间展示,不按用户浏览器时区。 - 城市图表默认展示“全天”,可选“高温”窗口由 DEB hourly path 推导;所有图表横轴都按城市当地时间展示,不按用户浏览器时区。
- 核心图表组件已拆分为逻辑、状态与 canvas 渲染模块;Recharts 使用 `ResizeObserver` 后的明确宽高,规避 0x0 渲染和长时间挂页后曲线消失。 - 核心图表组件已拆分为逻辑、状态与 canvas 渲染模块;Recharts 使用 `ResizeObserver` 后的明确宽高,规避 0x0 渲染和长时间挂页后曲线消失。
- DEB hourly consensus`deb_hourly_consensus.v1`)已作为峰值窗口和图表 DEB 曲线的优先小时路径;DEB 仍然是预测曲线,不作为实测来源。 - DEB hourly consensus`deb_hourly_consensus.v1`)已作为峰值窗口和图表 DEB 曲线的优先小时路径;DEB 仍然是预测曲线,不作为实测来源。
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不再伪装成一条时间序列曲线 - legacy 高斯概率不再占用默认温度图主视图;hover tooltip 会展示 `Gaussian μ` 和完整温度区间概率分布
- AMSC/AMOS 城市的结算跑道曲线默认展示并高亮,辅助跑道作为弱化曲线保留;釜山单跑道只展示 `SR/SL` 结算跑道,不再重复显示 AMOS 聚合线。 - AMSC/AMOS 城市的结算跑道曲线默认展示并高亮,辅助跑道作为弱化曲线保留;釜山单跑道只展示 `SR/SL` 结算跑道,不再重复显示 AMOS 聚合线。
- 香港默认展示 CoWIN `6087`(保良局陈守仁小学)1 分钟参考站曲线,HKO 10 分钟实测保留为官方气象层。 - 香港默认展示 CoWIN `6087`(保良局陈守仁小学)1 分钟参考站曲线,HKO 10 分钟实测保留为官方气象层。
- Telegram 机场/跑道推送默认中英文双语,并统一使用结算端点跑道温度计算当前值、15 分钟趋势和文案。 - Telegram 机场/跑道推送默认中英文双语,并统一使用结算端点跑道温度计算当前值、15 分钟趋势和文案。
- 运行态状态、缓存与核心离线训练/回填链路已完成 SQLite 主路径收口;legacy JSON/JSONL 仅保留给迁移、导出与显式回退输入。 - 运行态状态、缓存与核心离线训练/回填链路已完成 SQLite 主路径收口;legacy JSON/JSONL 仅保留给迁移、导出与显式回退输入。
- EMOS/CRPS 校准链路已接通,但生产主概率保持 `legacy``emos_shadow``emos_primary` 只在本地离线评估通过并手动灰度后启用。
- 官方增强站网已统一接入: - 官方增强站网已统一接入:
- `MGM`(土耳其) - `MGM`(土耳其)
- `CMA/NMC`(中国内地) - `CMA/NMC`(中国内地)
@@ -49,11 +48,11 @@
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与运行态 heartbeat。 - `/ops` 现已展示缓存桶数量、summary cache hit/miss 与运行态 heartbeat。
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。 - 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。 - 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
- 终端城市卡已改为结构化实况 + DEB hourly consensus + 多模型集群 + 校准概率 + 市场温度桶,不再让图表等待 AI 文案生成。 - 终端图表/详情工作流已改为结构化实况 + DEB hourly consensus + 多模型集群 + 概率分布 tooltip + 市场温度桶,不再让图表等待 AI 文案生成。
- 终端数据同时使用页面内存缓存、浏览器 `localStorage`、后端短 TTL 缓存、SSE patch replay 和前台恢复刷新;从其他选项卡切回时会优先恢复最新可见图表状态。 - 终端数据同时使用页面内存缓存、浏览器 `localStorage`、后端短 TTL 缓存、SSE patch replay 和前台恢复刷新;从其他选项卡切回时会优先恢复最新可见图表状态。
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。 - 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。 - 市场信号中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
- 概率区已改为校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS,模型共识作为辅助参考。 - 概率区已改为校准模型概率”;默认展示 legacy 高斯概率引擎输出,模型共识作为辅助参考。
- 今日日内结构解读以规则与结构化信号为主,AI 文案只作为可降级辅助层,不替代实测、DEB、TAF 或结算逻辑。 - 今日日内结构解读以规则与结构化信号为主,AI 文案只作为可降级辅助层,不替代实测、DEB、TAF 或结算逻辑。
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md` - 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`
@@ -72,17 +71,19 @@
- 聚合 51 个监控城市的实测与预报数据。 - 聚合 51 个监控城市的实测与预报数据。
- DEBDynamic Error Balancing)融合多模型最高温。 - DEBDynamic Error Balancing)融合多模型最高温。
- 构建 DEB 加权小时共识曲线,用于峰值窗口判断和图表默认 DEB 展示。 - 构建 DEB 加权小时共识曲线,用于峰值窗口判断和图表默认 DEB 展示。
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎 - 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯校准路径
- 地图城市决策把结构化实况、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示 - 天气决策把结构化实况、DEB 高温路径、完整市场温度桶和模型-市场差放进图表/详情工作流
- 图表 tooltip 展示校准高斯上下文:`mu` 加完整温度区间概率分布,不把概率温度带重新放回主图。
- Web 仪表盘与 Telegram Bot 复用同一分析内核。 - Web 仪表盘与 Telegram Bot 复用同一分析内核。
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。 - 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
- 已上线站内反馈闭环:提交反馈时自动附带图表上下文,用户可查看处理状态,运营后台可为有价值反馈人工奖励积分。
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。 - 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
## 参考架构 ## 参考架构
```mermaid ```mermaid
flowchart LR flowchart LR
U["用户(Web / Telegram"] --> FE["Next.js 前端(Vercel"] U["用户(Web / Telegram"] --> FE["Next.js 前端(Docker / VPS"]
U --> BOT["Telegram BotVPS"] U --> BOT["Telegram BotVPS"]
FE --> API["FastAPI /web/app.py"] FE --> API["FastAPI /web/app.py"]
BOT --> API BOT --> API
@@ -126,6 +127,11 @@ npm ci
npm run dev npm run dev
``` ```
## 近期更新
- 高斯概率 tooltip 已改为展示完整温度区间概率分布,不再只显示最高概率的单个区间;主图继续聚焦实测和预测曲线。
- 用户反馈已形成产品闭环:终端提交会自动附带图表上下文,用户可在站内查看处理状态,运营侧可为真实、有建设性的反馈发放积分奖励。
## 运行数据目录(VPS 推荐) ## 运行数据目录(VPS 推荐)
建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住): 建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住):
@@ -142,24 +148,6 @@ POLYWEATHER_REDIS_REQUIRED=true
本地开发或严格单进程兜底可使用 `POLYWEATHER_EVENT_STORE=sqlite` 本地开发或严格单进程兜底可使用 `POLYWEATHER_EVENT_STORE=sqlite`
## EMOS 本地训练流程
低配 VPS 只负责采集、服务和加载已通过评估的参数,不建议在 VPS 上跑 EMOS 全量训练。训练前先从 VPS 拉 SQLite 副本到本地:
```powershell
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
```
本地训练:
```powershell
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
```
只有 `auto_retrain_report.json``ready_for_promotion=true` 时,才允许把候选 `default.json` 传回 VPS,并优先以 `emos_shadow` 观察。
## 运维验收 ## 运维验收
### 健康与系统状态 ### 健康与系统状态
@@ -217,8 +205,6 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
| 指令 | 用途 | | 指令 | 用途 |
| :-- | :-- | | :-- | :-- |
| `/city <name>` | 城市实时分析 |
| `/deb <name>` | DEB 历史对账 |
| `/top` | 用户积分排行 | | `/top` | 用户积分排行 |
| `/id` | 查看聊天 Chat ID | | `/id` | 查看聊天 Chat ID |
| `/diag` | Bot 启动诊断 | | `/diag` | Bot 启动诊断 |
@@ -232,7 +218,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
- AGPL-3.0 边界:[docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md) - AGPL-3.0 边界:[docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
- Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md) - Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
- 配置与密钥管理:[docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md) - 配置与密钥管理:[docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
- 前端部署(Vercel):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md) - 前端部署(Docker / VPS):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
- 技术债:[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md) - 技术债:[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
- 机场实时数据源:[docs/AIRPORT_REALTIME_SOURCES.md](docs/AIRPORT_REALTIME_SOURCES.md) - 机场实时数据源:[docs/AIRPORT_REALTIME_SOURCES.md](docs/AIRPORT_REALTIME_SOURCES.md)
- 机场市场监控(中文):[docs/AIRPORT_MARKET_MONITOR_ZH.md](docs/AIRPORT_MARKET_MONITOR_ZH.md) - 机场市场监控(中文):[docs/AIRPORT_MARKET_MONITOR_ZH.md](docs/AIRPORT_MARKET_MONITOR_ZH.md)
@@ -242,7 +228,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md) - 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md) - 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_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) - 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
- 发布流程:[RELEASE.md](RELEASE.md) - 发布流程:[RELEASE.md](RELEASE.md)
- 变更记录:[CHANGELOG.md](CHANGELOG.md) - 变更记录:[CHANGELOG.md](CHANGELOG.md)
@@ -250,4 +236,4 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
## 当前版本 ## 当前版本
- 版本:`v1.8.1` - 版本:`v1.8.1`
- 文档最后更新:`2026-05-28` - 文档最后更新:`2026-06-07`
+2 -2
View File
@@ -19,8 +19,8 @@ cities:
- id: new_york - id: new_york
city: New York city: New York
country: USA country: USA
latitude: 40.7128 latitude: 40.7769
longitude: -74.006 longitude: -73.874
- id: chicago - id: chicago
city: Chicago city: Chicago
country: USA country: USA
+33
View File
@@ -0,0 +1,33 @@
{
"amsterdam": 116398,
"ankara": 116394,
"atlanta": 13115,
"austin": 13120,
"beijing": 116383,
"busan": 116381,
"chengdu": 116388,
"chicago": 13113,
"chongqing": 116389,
"dallas": 13119,
"denver": 13114,
"guangzhou": 116385,
"helsinki": 116397,
"hong kong": 116391,
"houston": 13118,
"istanbul": 116395,
"los angeles": 13112,
"miami": 13116,
"new york": 13111,
"paris": 116399,
"qingdao": 116387,
"san francisco": 13117,
"seattle": 13122,
"seoul": 116380,
"shanghai": 116384,
"shenzhen": 116386,
"singapore": 116393,
"taipei": 116392,
"tel aviv": 116396,
"tokyo": 116382,
"wuhan": 116390
}
+1 -1
View File
@@ -1,4 +1,4 @@
$VPS = "root@38.54.27.70" $VPS = "root@172.245.214.111"
$PROJECT = "/root/PolyWeather" $PROJECT = "/root/PolyWeather"
Write-Host "🚀 Deploying to $VPS..." -ForegroundColor Cyan Write-Host "🚀 Deploying to $VPS..." -ForegroundColor Cyan
+239 -10
View File
@@ -1,12 +1,21 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
GHCR_PAT="$1" NEW_TAG="${1:-latest}"
NEW_TAG="${2:-latest}"
TAG_FILE="/var/lib/polyweather/.current_tag" TAG_FILE="/var/lib/polyweather/.current_tag"
COMPOSE_DIR="/root/PolyWeather" COMPOSE_DIR="/root/PolyWeather"
LOCK_FILE="${POLYWEATHER_DEPLOY_LOCK_FILE:-/var/lock/polyweather-deploy.lock}" LOCK_FILE="${POLYWEATHER_DEPLOY_LOCK_FILE:-/var/lock/polyweather-deploy.lock}"
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")" mkdir -p "$(dirname "$LOCK_FILE")"
exec 9>"$LOCK_FILE" exec 9>"$LOCK_FILE"
if ! flock -n 9; then if ! flock -n 9; then
@@ -14,11 +23,67 @@ if ! flock -n 9; then
exit 1 exit 1
fi fi
echo "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin printf '%s' "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
unset GHCR_PAT
cd "$COMPOSE_DIR" cd "$COMPOSE_DIR"
git fetch origin main && git reset --hard origin/main git fetch origin main && git reset --hard origin/main
sync_city_thread_ids() {
local runtime_dir="${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}"
local repo_file="$COMPOSE_DIR/data/city_thread_ids.json"
local target_file="$runtime_dir/city_thread_ids.json"
if [ ! -f "$repo_file" ]; then
echo "No repository city_thread_ids.json to sync"
return 0
fi
mkdir -p "$runtime_dir"
REPO_CITY_THREAD_IDS_FILE="$repo_file" TARGET_CITY_THREAD_IDS_FILE="$target_file" python3 - <<'PY'
import json
import os
import time
repo_file = os.environ["REPO_CITY_THREAD_IDS_FILE"]
target_file = os.environ["TARGET_CITY_THREAD_IDS_FILE"]
with open(repo_file, "r", encoding="utf-8") as f:
repo_data = json.load(f)
if not isinstance(repo_data, dict):
raise SystemExit("repository city_thread_ids.json must contain an object")
target_data = {}
if os.path.isfile(target_file) and os.path.getsize(target_file) > 0:
try:
with open(target_file, "r", encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
target_data = loaded
else:
raise ValueError("target file is not an object")
except Exception as exc:
backup = f"{target_file}.invalid.{int(time.time())}"
os.replace(target_file, backup)
print(f"Backed up invalid city_thread_ids.json to {backup}: {exc}")
merged = dict(repo_data)
merged.update(target_data)
if merged != target_data:
tmp_file = f"{target_file}.tmp"
with open(tmp_file, "w", encoding="utf-8") as f:
json.dump(merged, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
os.replace(tmp_file, target_file)
print(f"Synced city_thread_ids.json: {len(target_data)} -> {len(merged)} cities")
else:
print(f"city_thread_ids.json already up to date: {len(target_data)} cities")
PY
}
sync_city_thread_ids
PREVIOUS_TAG="" PREVIOUS_TAG=""
if [ -f "$TAG_FILE" ]; then if [ -f "$TAG_FILE" ]; then
PREVIOUS_TAG=$(cat "$TAG_FILE") PREVIOUS_TAG=$(cat "$TAG_FILE")
@@ -30,14 +95,86 @@ rollback_to_previous() {
echo "Rolling back to $PREVIOUS_TAG..." echo "Rolling back to $PREVIOUS_TAG..."
export IMAGE_TAG="$PREVIOUS_TAG" export IMAGE_TAG="$PREVIOUS_TAG"
docker compose pull docker compose pull
docker compose up -d compose_up_retry "rollback" -d
echo "✅ Rolled back to $PREVIOUS_TAG" echo "✅ Rolled back to $PREVIOUS_TAG"
else else
echo "⚠️ No previous tag to rollback to" echo "⚠️ No previous tag to rollback to"
fi 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
}
read_env_file_value() {
local key="$1"
if [ ! -f ".env" ]; then
return 0
fi
awk -F= -v key="$key" '
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
value=$0
sub("^[^=]*=", "", value)
gsub("^[[:space:]]+|[[:space:]]+$", "", value)
gsub(/^["'"'"']|["'"'"']$/, "", value)
print value
}
' .env | tail -n 1
}
resolve_env_value() {
local primary_key="$1"
local fallback_key="${2:-}"
local value="${!primary_key:-}"
if [ -z "$value" ]; then
value="$(read_env_file_value "$primary_key")"
fi
if [ -z "$value" ] && [ -n "$fallback_key" ]; then
value="${!fallback_key:-}"
if [ -z "$value" ]; then
value="$(read_env_file_value "$fallback_key")"
fi
fi
printf '%s' "$value"
}
export IMAGE_TAG="$NEW_TAG" export IMAGE_TAG="$NEW_TAG"
export POLYWEATHER_API_BASE_URL="${POLYWEATHER_FRONTEND_INTERNAL_API_BASE_URL:-http://polyweather_web:8000}"
resolved_supabase_url="$(resolve_env_value "SUPABASE_URL" "NEXT_PUBLIC_SUPABASE_URL")"
resolved_supabase_anon_key="$(resolve_env_value "SUPABASE_ANON_KEY" "NEXT_PUBLIC_SUPABASE_ANON_KEY")"
if [ -n "$resolved_supabase_url" ]; then
export SUPABASE_URL="$resolved_supabase_url"
else
unset SUPABASE_URL
fi
if [ -n "$resolved_supabase_anon_key" ]; then
export SUPABASE_ANON_KEY="$resolved_supabase_anon_key"
else
unset SUPABASE_ANON_KEY
fi
pull_ok=0 pull_ok=0
for pull_attempt in $(seq 1 6); do for pull_attempt in $(seq 1 6); do
docker compose pull && pull_ok=1 && break docker compose pull && pull_ok=1 && break
@@ -116,11 +253,87 @@ warm_public_route() {
return 0 return 0
} }
wait_for_scan_terminal_snapshot() {
local name="$1"
local url="$2"
local timeout="${3:-35}"
local attempts="${4:-8}"
local delay="${5:-5}"
local output=""
local body=""
local compact=""
local http_status=""
local status=""
for i in $(seq 1 "$attempts"); do
if output=$(curl -sS -w "\nhttp=%{http_code}" --max-time "$timeout" "$url" 2>&1); then
http_status="$(printf '%s\n' "$output" | sed -n 's/^http=//p' | tail -n 1)"
body="$(printf '%s\n' "$output" | sed '$d')"
if [ "$http_status" = "401" ]; then
echo "$name protected after attempt $i/$attempts (http=401)"
return 0
fi
compact="$(printf '%s' "$body" | tr -d '\n\r\t ')"
if printf '%s' "$compact" | grep -q '"status":"ready"'; then
echo "$name ready after attempt $i/$attempts"
return 0
fi
status="$(printf '%s' "$compact" | sed -n 's/.*"status":"\([^"]*\)".*/\1/p' | head -n 1)"
echo " $name not ready attempt $i/$attempts http=${http_status:-unknown} status=${status:-unknown}"
else
echo " $name request failed attempt $i/$attempts ($output)"
fi
if [ "$i" != "$attempts" ]; then
sleep "$delay"
fi
done
echo "$name did not return status=ready or http=401"
return 1
}
validate_frontend_api_base_url() {
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..." echo "Updating Redis dependency..."
docker compose up -d polyweather_redis compose_up_retry "redis" -d polyweather_redis
echo "Updating backend services..." echo "Updating backend services..."
docker compose up -d --no-deps polyweather_web polyweather compose_up_retry "backend services" -d --no-deps polyweather_web polyweather
echo "Waiting for backend..." echo "Waiting for backend..."
wait_for_local_service "backend healthz" "http://127.0.0.1:8000/healthz" 5 30 5 || FAILED_BACKEND=1 wait_for_local_service "backend healthz" "http://127.0.0.1:8000/healthz" 5 30 5 || FAILED_BACKEND=1
@@ -131,12 +344,22 @@ if [ "$FAILED_BACKEND" = "1" ]; then
exit 1 exit 1
fi fi
echo "Updating observation collector..."
compose_up_retry "observation collector" -d --no-deps polyweather_collector
echo "Updating cache warmer..."
compose_up_retry "cache warmer" -d --no-deps polyweather_warmer
echo "Updating training settlement worker..."
compose_up_retry "training settlement" -d --no-deps polyweather_training_settlement
echo "Updating frontend..." echo "Updating frontend..."
docker compose up -d --no-deps polyweather_frontend compose_up_retry "frontend" -d --no-deps polyweather_frontend
echo "Waiting for 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 root" "http://127.0.0.1:3001/" 5 40 2 || FAILED_FRONTEND=1
wait_for_local_service "frontend terminal" "http://127.0.0.1:3001/terminal" 10 20 2 || FAILED_FRONTEND=1 wait_for_local_service "frontend terminal" "http://127.0.0.1:3001/terminal" 10 20 2 || FAILED_FRONTEND=1
wait_for_scan_terminal_snapshot "scan terminal snapshot" "http://127.0.0.1:3001/api/scan/terminal" 35 8 5 || FAILED_FRONTEND=1
FAILED_FRONTEND="${FAILED_FRONTEND:-0}" FAILED_FRONTEND="${FAILED_FRONTEND:-0}"
if [ "$FAILED_FRONTEND" = "1" ]; then if [ "$FAILED_FRONTEND" = "1" ]; then
echo "❌ Frontend did not become healthy" echo "❌ Frontend did not become healthy"
@@ -145,14 +368,20 @@ if [ "$FAILED_FRONTEND" = "1" ]; then
fi fi
warm_public_route "terminal" "https://polyweather.top/terminal" 20 4 3 warm_public_route "terminal" "https://polyweather.top/terminal" 20 4 3
warm_public_route "scan terminal" "https://polyweather.top/api/scan/terminal" 35 3 2
warm_public_route "auth snapshot" "https://polyweather.top/api/auth/me?prefer_snapshot=1" 10 3 2 warm_public_route "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 "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 warm_public_route "cities" "https://polyweather.top/api/cities" 20 3 2
FAILED=0 FAILED=0
smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || FAILED=1 run_public_smoke_checks || FAILED=1
smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5 || FAILED=1
smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || FAILED=1 if [ "$FAILED" = "1" ]; then
echo "⚠️ 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 if [ "$FAILED" = "1" ]; then
echo "❌ Smoke tests failed. Rolling back..." echo "❌ Smoke tests failed. Rolling back..."
+13
View File
@@ -9,6 +9,19 @@ server {
proxy_buffers 8 16k; proxy_buffers 8 16k;
proxy_busy_buffers_size 32k; proxy_busy_buffers_size 32k;
location /api/events {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
proxy_set_header Connection '';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / { location / {
proxy_pass http://127.0.0.1:3001; proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1; proxy_http_version 1.1;
+191 -5
View File
@@ -2,7 +2,7 @@ services:
polyweather_redis: polyweather_redis:
image: redis:7-alpine image: redis:7-alpine
container_name: polyweather_redis container_name: polyweather_redis
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy noeviction command: redis-server --appendonly yes --maxmemory ${POLYWEATHER_REDIS_MAXMEMORY:-512mb} --maxmemory-policy noeviction
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
interval: 10s interval: 10s
@@ -28,7 +28,12 @@ services:
env_file: &id001 env_file: &id001
- .env - .env
environment: environment:
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC: ${POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC:-180} 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:-60}
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS: ${POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS:-1} TELEGRAM_AIRPORT_PUSH_MAX_WORKERS: ${POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS:-1}
healthcheck: healthcheck:
interval: 60s interval: 60s
@@ -64,12 +69,18 @@ services:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://polyweather.top} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://polyweather.top}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL:-https://polygon-bor-rpc.publicnode.com} NEXT_PUBLIC_WALLETCONNECT_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:-} NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID:-}
POLYWEATHER_API_BASE_URL: ${POLYWEATHER_API_BASE_URL:-http://polyweather_web:8000} POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN: ${POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN}
POLYWEATHER_API_BASE_URL: http://polyweather_web:8000
POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true} POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true}
POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true} POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true}
POLYWEATHER_OPS_ADMIN_EMAILS: ${POLYWEATHER_OPS_ADMIN_EMAILS:-} POLYWEATHER_OPS_ADMIN_EMAILS: ${POLYWEATHER_OPS_ADMIN_EMAILS:-}
POLYWEATHER_TURNSTILE_BYPASS: ${POLYWEATHER_TURNSTILE_BYPASS:-false}
POLYWEATHER_TURNSTILE_ENFORCE_ACTION: ${POLYWEATHER_TURNSTILE_ENFORCE_ACTION:-false}
POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT: ${POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT:-false}
POLYWEATHER_TURNSTILE_SECRET_KEY: ${POLYWEATHER_TURNSTILE_SECRET_KEY:-}
healthcheck: healthcheck:
interval: 30s interval: 30s
retries: 3 retries: 3
@@ -79,7 +90,7 @@ services:
timeout: 5s timeout: 5s
image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest} image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}
ports: ports:
- 3001:3000 - "127.0.0.1:3001:3000"
restart: unless-stopped restart: unless-stopped
polyweather_web: polyweather_web:
command: python web/app.py command: python web/app.py
@@ -93,6 +104,41 @@ services:
max-file: "3" max-file: "3"
container_name: polyweather_web container_name: polyweather_web
env_file: *id001 env_file: *id001
environment:
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_R2_ACCESS_KEY_ID: ${POLYWEATHER_R2_ACCESS_KEY_ID:-}
POLYWEATHER_R2_ACCOUNT_ID: ${POLYWEATHER_R2_ACCOUNT_ID:-}
POLYWEATHER_R2_ARCHIVE_SOURCE: ${POLYWEATHER_R2_ARCHIVE_SOURCE:-redis}
POLYWEATHER_R2_BUCKET: ${POLYWEATHER_R2_BUCKET:-}
POLYWEATHER_R2_ENDPOINT_URL: ${POLYWEATHER_R2_ENDPOINT_URL:-}
POLYWEATHER_R2_REGION: ${POLYWEATHER_R2_REGION:-auto}
POLYWEATHER_R2_SECRET_ACCESS_KEY: ${POLYWEATHER_R2_SECRET_ACCESS_KEY:-}
POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}
POLYWEATHER_REDIS_REQUIRED: ${POLYWEATHER_REDIS_REQUIRED:-true}
POLYWEATHER_REDIS_STREAM_MAXLEN: ${POLYWEATHER_REDIS_STREAM_MAXLEN:-100000}
POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_ENABLED: ${POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_ENABLED:-true}
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-3}
POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-3}
POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS:-3000}
POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-8000}
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-5}
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC: '30'
POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS: ${POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS:-4}
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: ${POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED:-false}
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN: ${POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN}
POLYWEATHER_SERVICE_ROLE: web
SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
SUPABASE_URL: ${SUPABASE_URL}
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
healthcheck: healthcheck:
interval: 30s interval: 30s
retries: 3 retries: 3
@@ -104,7 +150,147 @@ services:
timeout: 5s timeout: 5s
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest} image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
ports: 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:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-15}
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
POLYWEATHER_OBSERVATION_COLLECTOR_CACHE_REFRESH_WORKERS: ${POLYWEATHER_OBSERVATION_COLLECTOR_CACHE_REFRESH_WORKERS:-2}
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
POLYWEATHER_SERVICE_ROLE: collector
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:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
polyweather_warmer:
command: python -m web.cache_warmer_worker
container_name: polyweather_warmer
depends_on:
polyweather_redis:
condition: service_healthy
polyweather_web:
condition: service_started
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
cpus: ${POLYWEATHER_WARMER_CPUS:-0.75}
env_file: *id001
environment:
POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
POLYWEATHER_SERVICE_ROLE: warmer
POLYWEATHER_WARMER_CITY_BATCH_SIZE: ${POLYWEATHER_WARMER_CITY_BATCH_SIZE:-16}
POLYWEATHER_WARMER_CITY_INTERVAL_SEC: ${POLYWEATHER_WARMER_CITY_INTERVAL_SEC:-30}
POLYWEATHER_WARMER_ENABLED: ${POLYWEATHER_WARMER_ENABLED:-true}
POLYWEATHER_WARMER_SCAN_INTERVAL_SEC: ${POLYWEATHER_WARMER_SCAN_INTERVAL_SEC:-120}
POLYWEATHER_WARMER_TICK_SEC: ${POLYWEATHER_WARMER_TICK_SEC:-30}
healthcheck:
interval: 60s
retries: 3
test:
- CMD
- python
- -c
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
c.execute('SELECT 1'); c.close()
timeout: 10s
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
mem_limit: ${POLYWEATHER_WARMER_MEM_LIMIT:-768m}
memswap_limit: ${POLYWEATHER_WARMER_MEMSWAP_LIMIT:-1g}
pids_limit: 256
restart: unless-stopped
user: ${UID:-1000}:${GID:-1000}
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
polyweather_training_settlement:
command: python -m web.training_settlement_worker
container_name: polyweather_training_settlement
depends_on:
polyweather_redis:
condition: service_healthy
polyweather_web:
condition: service_started
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
cpus: ${POLYWEATHER_TRAINING_SETTLEMENT_CPUS:-0.50}
env_file: *id001
environment:
POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
POLYWEATHER_SERVICE_ROLE: training_settlement
POLYWEATHER_TRAINING_SETTLEMENT_INITIAL_DELAY_SEC: ${POLYWEATHER_TRAINING_SETTLEMENT_INITIAL_DELAY_SEC:-60}
POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC: ${POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC:-21600}
POLYWEATHER_TRAINING_SETTLEMENT_LOOKBACK_DAYS: ${POLYWEATHER_TRAINING_SETTLEMENT_LOOKBACK_DAYS:-10}
healthcheck:
interval: 60s
retries: 3
test:
- CMD
- python
- -c
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
c.execute('SELECT 1'); c.close()
timeout: 10s
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
mem_limit: ${POLYWEATHER_TRAINING_SETTLEMENT_MEM_LIMIT:-768m}
memswap_limit: ${POLYWEATHER_TRAINING_SETTLEMENT_MEMSWAP_LIMIT:-1g}
pids_limit: 256
restart: unless-stopped restart: unless-stopped
user: ${UID:-1000}:${GID:-1000} user: ${UID:-1000}:${GID:-1000}
volumes: volumes:
+19 -10
View File
@@ -11,13 +11,13 @@
| 香港 | CoWIN 6087 | 6087 | CoWIN (`cowin.hku.hk`) | 1 分钟 | 参考站温度(保良局陈守仁小学) | 免费 | | 香港 | CoWIN 6087 | 6087 | CoWIN (`cowin.hku.hk`) | 1 分钟 | 参考站温度(保良局陈守仁小学) | 免费 |
| 香港 | HKO | HKO | HKO 官方 CSV (`data.weather.gov.hk`) | 10 分钟 | 官方气象站温度 | 免费 | | 香港 | HKO | HKO | HKO 官方 CSV (`data.weather.gov.hk`) | 10 分钟 | 官方气象站温度 | 免费 |
| 台北 | 松山/中央气象署 | 466920 | CWA 开放数据 | 10 分钟 | 官方站点温度 | 免费 | | 台北 | 松山/中央气象署 | 466920 | CWA 开放数据 | 10 分钟 | 官方站点温度 | 免费 |
| 北京 | 首都机场 | ZBAA | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 | | 北京 | 首都机场 | ZBAA | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 上海 | 浦东机场 | ZSPD | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 | | 上海 | 浦东机场 | ZSPD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 广州 | 白云机场 | ZGGG | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 | | 广州 | 白云机场 | ZGGG | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 成都 | 双流机场 | ZUUU | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 | | 成都 | 双流机场 | ZUUU | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 重庆 | 江北机场 | ZUCK | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 | | 重庆 | 江北机场 | ZUCK | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 武汉 | 天河机场 | ZHHH | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 | | 武汉 | 天河机场 | ZHHH | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 青岛 | 胶东机场 | ZSQD | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 | | 青岛 | 胶东机场 | ZSQD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 东京 | 羽田 | RJTT | JMA AMeDAS (`jma.go.jp`) | 10 分钟 | 机场站点实时温度 | 免费 | | 东京 | 羽田 | RJTT | JMA AMeDAS (`jma.go.jp`) | 10 分钟 | 机场站点实时温度 | 免费 |
| 安卡拉 | Esenboğa | 17128 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 | | 安卡拉 | Esenboğa | 17128 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
| 伊斯坦布尔 | 伊斯坦布尔机场 | 17058 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 | | 伊斯坦布尔 | 伊斯坦布尔机场 | 17058 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
@@ -56,11 +56,20 @@
> `MID_TEMP` / `END_TEMP`。这些字段是跑道观测位置气温,不是道面温度。 > `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 以匹配最快频率 - 循环轮询 60s 以匹配最快频率
- Telegram 推送优先读取网站侧 `full`/`panel` 城市缓存;缓存缺失时只做非强制 `panel` 分析兜底,不触发 `force_refresh_observations_only`
- 仅当当前温度距 DEB 预测最高 ≤3°C 时推送 - 仅当当前温度距 DEB 预测最高 ≤3°C 时推送
- 确认过峰值后自动停止 - 确认过峰值后自动停止
@@ -69,7 +78,7 @@
为了向用户提供接近行情盘的实况响应并降低服务器负载,系统使用 **HTTP snapshot + Server-Sent Events (SSE) Patch + 可重放事件日志** 架构。生产环境推荐 Redis Stream;本地或单进程可回退 SQLite event log。 为了向用户提供接近行情盘的实况响应并降低服务器负载,系统使用 **HTTP snapshot + Server-Sent Events (SSE) Patch + 可重放事件日志** 架构。生产环境推荐 Redis Stream;本地或单进程可回退 SQLite event log。
### 1. 数据推送链路 (Data Pipeline) ### 1. 数据推送链路 (Data Pipeline)
1. **Collector 采集端触发**:在 `weather_sources.py` 中,当高频实况源(如 AMOS, CoWIN, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。 1. **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` 2. **标准化事件**`realtime_patch_schema.py` 将旧 `city_patch` 或新 payload 统一成 `city_observation_patch.v1`
3. **事件存储**:生产环境写入 Redis Stream`stream:city_observation`)并生成全局递增 `revision`SQLite `observation_patch_events` 保留为本地/兜底 replay。 3. **事件存储**:生产环境写入 Redis Stream`stream:city_observation`)并生成全局递增 `revision`SQLite `observation_patch_events` 保留为本地/兜底 replay。
4. **FastAPI SSE 广播**FastAPI 后端的 `sse_router.py` 根据城市订阅集合向匹配连接推送 patch;断线重连时按 `since_revision` replay。 4. **FastAPI SSE 广播**FastAPI 后端的 `sse_router.py` 根据城市订阅集合向匹配连接推送 patch;断线重连时按 `since_revision` replay。
+1 -1
View File
@@ -7,7 +7,7 @@
## 1. 基础信息 ## 1. 基础信息
- 后端直连:`http://127.0.0.1:8000` - 后端直连:`http://127.0.0.1:8000`
- 前端 BFF`https://polyweather-pro.vercel.app/api/*` - 前端 BFF`https://polyweather.top/api/*`
- 返回格式:`application/json` - 返回格式:`application/json`
## 2. 请求链路 ## 2. 请求链路
+9 -8
View File
@@ -13,13 +13,13 @@
| hong kong | CoWIN 6087 | ~1 min | cowin.hku.hk, 保良局陳守仁小學,前端图表默认展示 | | hong kong | CoWIN 6087 | ~1 min | cowin.hku.hk, 保良局陳守仁小學,前端图表默认展示 |
| hong kong | HKO 官方 CSV | ~10 min | data.weather.gov.hk(文件名虽含 1min,实际 10min 一报) | | hong kong | HKO 官方 CSV | ~10 min | data.weather.gov.hk(文件名虽含 1min,实际 10min 一报) |
| singapore | MSS 官方 API | ~1 min | api.data.gov.sg, 站号 S24 | | singapore | MSS 官方 API | ~1 min | api.data.gov.sg, 站号 S24 |
| beijing | AMSC AWOS (ZBAA) | ~1 min | 中国 | | beijing | AMSC AWOS (ZBAA) | 3 min | 中国 |
| shanghai | AMSC AWOS (ZSPD) | ~1 min | 中国 | | shanghai | AMSC AWOS (ZSPD) | 3 min | 中国 |
| guangzhou | AMSC AWOS (ZGGG) | ~1 min | 中国 | | guangzhou | AMSC AWOS (ZGGG) | 3 min | 中国 |
| chengdu | AMSC AWOS (ZUUU) | ~1 min | 中国 | | chengdu | AMSC AWOS (ZUUU) | 3 min | 中国 |
| chongqing | AMSC AWOS (ZUCK) | ~1 min | 中国 | | chongqing | AMSC AWOS (ZUCK) | 3 min | 中国 |
| wuhan | AMSC AWOS (ZHHH) | ~1 min | 中国 | | wuhan | AMSC AWOS (ZHHH) | 3 min | 中国 |
| qingdao | AMSC AWOS (ZSQD) | ~1 min | 中国 | | qingdao | AMSC AWOS (ZSQD) | 3 min | 中国 |
### Tier 2 — 5 分钟高频 (MADIS) ### 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 分钟。 - MADIS:源头约 5 分钟。
- HKO / CWA / JMA / FMI / KNMI:源头约 10 分钟。 - HKO / CWA / JMA / FMI / KNMI:源头约 10 分钟。
- METAR-only 城市:按 METAR 可用频率和缓存 TTL,不伪装成 1 分钟实测。 - METAR-only 城市:按 METAR 可用频率和缓存 TTL,不伪装成 1 分钟实测。
+205
View File
@@ -0,0 +1,205 @@
# Cloudflare 免费版缓存配置
目标:让 Cloudflare 承担公开页面、公开 API 和静态资源的重复读取,降低 VPS CPU、Next.js worker 和后端 detail-batch 压力,同时避免缓存账户、支付、反馈和实时连接。
代码已通过 `Cache-Control``Cloudflare-CDN-Cache-Control` 声明 TTL。由于免费版会严格遵守源站的 `max-age=0`,稳定公开路径的 Cache Rules 额外按状态码覆盖 Edge TTL;可能返回 partial、stale 或 failed 的接口继续尊重源站缓存头。不要给所有 `/api/*` 统一启用 Cache Everything。
缓存规则只允许作用于前端域名 `polyweather.top``api.polyweather.top` 是带服务令牌和会员校验的后端源站,必须绕过 Cloudflare 缓存,避免缓存命中绕过后端权限检查。
## 已接入的免费能力
- Cache Rules:仓库脚本 `scripts/configure_cloudflare_free.py` 同步公开缓存规则,`scripts/validate_frontend_cache.sh` 可检查 `CF-Cache-Status`
- Turnstile:登录/注册经 Supabase Auth captcha token;反馈提交和支付创建订单在 Next API Route 校验后再转发给后端。
- R2`scripts/archive_realtime_events_to_r2.py` 可把 Redis/SQLite 中的 SSE 事件按天归档为 JSONL,上传到 Cloudflare R2。
暂不建议把 Tunnel 作为本轮替代 Nginx:当前 Nginx 还承担 TLS、回源隔离和本地 compose 端口映射,Tunnel 需要单独灰度和回滚方案。
## 基础设置
- `Caching > Configuration > Browser Cache TTL`Respect Existing Headers
- `Caching > Configuration > Development Mode`Off
- `Caching > Configuration > Always Online`On
- `Caching > Tiered Cache`Smart Tiered Cache,若当前免费套餐控制台提供则开启
- `Speed > Optimization > Content Optimization > Brotli`On
- `Speed > Optimization > Content Optimization > Early Hints`On
- `Network > HTTP/3 (with QUIC)`On
- `Network > WebSockets`On
- 确保 `api.polyweather.top``polyweather.top` 代理状态为 Proxied
## Cache Rules
按以下顺序创建。Cloudflare 同一阶段最后匹配的规则生效,因此绕过规则必须放在公开缓存规则之后。免费版规则数量有限,因此使用路径集合合并表达式。
也可以使用仓库内脚本自动创建或更新规则。脚本会保留非 PolyWeather 规则,并把绕过规则放在最后。
Cloudflare 新版 Token UI 中,文档里的 Cache Rules 权限通常显示为:
- 权限策略资源:`指定域名` -> `polyweather.top`
- `Cache & Performance` -> `Cache Settings` -> `Edit`
为了避免 Token 还需要列出 Zone,请同时在 GitHub Secrets 配置 `CLOUDFLARE_ZONE_ID`。Zone ID 在 Cloudflare 进入 `polyweather.top` 后,右侧 API 区域或 Overview 页面可以复制。
GitHub Actions 需要两个仓库 Secret
- `CLOUDFLARE_API_TOKEN`
- `CLOUDFLARE_ZONE_ID`
```powershell
$env:CLOUDFLARE_API_TOKEN="<具有 Cache Settings Edit 权限的 token>"
$env:CLOUDFLARE_ZONE_ID="<polyweather.top 的 Zone ID>"
python scripts/configure_cloudflare_free.py
python scripts/configure_cloudflare_free.py --apply
```
第一条命令只输出计划;只有带 `--apply` 才会修改 Cloudflare。
部署流水线也会执行同一脚本。给 GitHub 仓库增加 `CLOUDFLARE_API_TOKEN``CLOUDFLARE_ZONE_ID` 两个 Secret 后,后续每次成功部署都会同步 Cache Rules;未配置时流水线会明确跳过,不影响主站部署。
### 1. 缓存公开内容
脚本会创建 5 条互不重叠的公开缓存规则。动作均为 Eligible for cache
- 静态资源:2xx Edge TTL 覆盖为 1 年。
- 首页和公开文档页:2xx Edge TTL 覆盖为 10 分钟。
- `/api/cities`2xx Edge TTL 覆盖为 5 分钟。
- 城市详情与 detail-batch:尊重源站成功、partial、stale 和错误缓存策略。
- `/api/scan/terminal``/api/system/status`:尊重源站成功、busy、failed 和错误缓存策略。
所有公开缓存规则都显式设置 Browser TTL 为 Respect origin,避免 Cloudflare 免费版默认 Browser Cache TTL 把 HTML 或数据接口改成 4 小时本地缓存。
### 2. 最后绕过后端域名、动态和敏感请求
动作:Bypass cache
表达式:
```text
http.host eq "api.polyweather.top"
or (http.request.method ne "GET" and http.request.method ne "HEAD")
or starts_with(http.request.uri.path, "/api/auth/")
or starts_with(http.request.uri.path, "/api/feedback")
or starts_with(http.request.uri.path, "/api/events")
or starts_with(http.request.uri.path, "/api/internal/")
or starts_with(http.request.uri.path, "/api/ops/")
or starts_with(http.request.uri.path, "/api/payments/")
or starts_with(http.request.uri.path, "/account")
or starts_with(http.request.uri.path, "/auth")
or starts_with(http.request.uri.path, "/ops")
or starts_with(http.request.uri.path, "/terminal")
or http.request.uri.query contains "force_refresh=true"
```
若 Dashboard 支持请求头或 Cookie 条件,再加入:
```text
or any(http.request.headers["authorization"][*] ne "")
or http.cookie contains "sb-"
```
## 缓存 TTL
### 静态资源
动作:Eligible for cache2xx Edge TTL 覆盖为一年。
表达式:
```text
http.host eq "polyweather.top"
and (
starts_with(http.request.uri.path, "/_next/static/")
or lower(http.request.uri.path.extension) in {
"js" "css" "woff" "woff2" "png" "jpg" "jpeg" "webp" "avif" "svg" "ico"
}
)
```
源站仍声明一年 immutableCloudflare Edge TTL 同样固定为一年。
### 公开页面
动作:Eligible for cache2xx Edge TTL 覆盖为 10 分钟。
表达式:
```text
http.host eq "polyweather.top"
and (
http.request.uri.path eq "/"
or starts_with(http.request.uri.path, "/docs/")
or starts_with(http.request.uri.path, "/modern/")
or starts_with(http.request.uri.path, "/probabilities/")
or starts_with(http.request.uri.path, "/subscription-help/")
)
```
源站 TTL:10 分钟,过期后允许后台刷新 1 小时。
### 公开数据接口
动作:Eligible for cache;城市列表的 2xx Edge TTL 覆盖为 5 分钟,详情、扫描和系统状态尊重源站缓存头。
表达式:
```text
http.host eq "polyweather.top"
and (
http.request.uri.path eq "/api/cities"
or http.request.uri.path eq "/api/cities/detail-batch"
or starts_with(http.request.uri.path, "/api/city/")
or http.request.uri.path eq "/api/scan/terminal"
or http.request.uri.path eq "/api/system/status"
)
```
接口 TTL
- `/api/cities`5 分钟,过期后可复用 1 小时。
- 城市详情与 detail-batch:60 秒,过期后可复用 5 分钟。
- `/api/scan/terminal`5 分钟,过期后可复用 15 分钟。
- partial、busy、timeout、stale、force refresh 和错误响应:源站为 `no-store`,详情和扫描规则不会覆盖;force refresh 由最后一条绕过规则排除。
## 验证
每次规则变更后连续请求同一 URL,检查响应头:
```powershell
curl.exe -sS -D - -o NUL "https://polyweather.top/api/cities"
curl.exe -sS -D - -o NUL "https://polyweather.top/api/scan/terminal?limit=1"
```
正常命中应看到 `CF-Cache-Status: HIT` 和递增的 `Age`。首次请求通常是 `MISS`;动态或明确 `no-store` 的请求应保持 `DYNAMIC``BYPASS`
也可以用脚本:
```bash
scripts/validate_frontend_cache.sh https://polyweather.top
REQUIRE_CF_CACHE=true scripts/validate_frontend_cache.sh https://polyweather.top
```
第二条会把缺失 `CF-Cache-Status` 或持续 `MISS` 作为失败处理,适合 Cache Rules 同步后的线上检查。
## Turnstile
Cloudflare 控制台创建 Turnstile site 后,配置:
- GitHub Secrets`NEXT_PUBLIC_TURNSTILE_SITE_KEY`
- VPS / Docker `.env``NEXT_PUBLIC_TURNSTILE_SITE_KEY`
- VPS / Docker `.env``POLYWEATHER_TURNSTILE_SECRET_KEY`
`NEXT_PUBLIC_TURNSTILE_SITE_KEY` 属于前端构建期变量,改动后必须重新构建前端镜像。`POLYWEATHER_TURNSTILE_SECRET_KEY` 只在服务端使用,不要提交仓库。
## R2 事件归档
R2 用于冷归档,不替代 Redis Stream / SQLite 的热路径。推荐先用 dry-run 看事件量:
```bash
python scripts/archive_realtime_events_to_r2.py --source redis --date 2026-06-16 --dry-run
python scripts/archive_realtime_events_to_r2.py --source redis --date 2026-06-16
```
对象 key 形如:
```text
sse-events/2026/06/16/city_observation_patch.redis.jsonl
```
+3 -1
View File
@@ -20,6 +20,7 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 | | 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
| 订阅套餐(Pro 月付 / 季度) | 已上线 | `29.9 USDC / 30天``79.9 USDC / 90天` | | 订阅套餐(Pro 月付 / 季度) | 已上线 | `29.9 USDC / 30天``79.9 USDC / 90天` |
| 邀请积分 | 已上线 | 被邀请人首月 `20U`;邀请人每个有效付费邀请 `+3500` 积分 | | 邀请积分 | 已上线 | 被邀请人首月 `20U`;邀请人每个有效付费邀请 `+3500` 积分 |
| 用户增长奖励 | 已上线 | 按已验证用户里程碑,为当前有效且有 confirmed 入账记录的付费会员延长 Pro |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U`;邀请首月价不叠加积分抵扣 | | 积分抵扣 | 已上线 | `500分=1U`,最多 `3U`;邀请首月价不叠加积分抵扣 |
| 合约支付 | 已上线 | PolygonUSDC + USDC.e | | 合约支付 | 已上线 | PolygonUSDC + USDC.e |
| 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 | | 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 |
@@ -39,13 +40,14 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
- 概率分布层(基于 DEB 融合 + 高斯分桶) - 概率分布层(基于 DEB 融合 + 高斯分桶)
- 实时终端多城市图表(跑道/官方站点/DEB/概率带) - 实时终端多城市图表(跑道/官方站点/DEB/概率带)
- 历史对账 + 未来日期分析 - 历史对账 + 未来日期分析
- 全平台智能气象推送 - Telegram 缓存推送与业务频道通知
## 4. 收费与积分规则(默认) ## 4. 收费与积分规则(默认)
- 套餐:`pro_monthly`29.9 USDC / 30 天)、`pro_quarterly`79.9 USDC / 90 天) - 套餐:`pro_monthly`29.9 USDC / 30 天)、`pro_quarterly`79.9 USDC / 90 天)
- 邀请首月:被邀请人首次月付 20 USDC - 邀请首月:被邀请人首次月付 20 USDC
- 邀请奖励:邀请人 +3500 积分,每月最多 10 个有效付费邀请 - 邀请奖励:邀请人 +3500 积分,每月最多 10 个有效付费邀请
- 用户增长奖励:600 已验证用户 `+1天`、750 `+2天`、1000 `+3天`,此后每增长 100 人 `+3天`
- 抵扣:500 积分抵 1 USDC;月付最高抵 3 USDC,季度最高抵 8 USDC - 抵扣:500 积分抵 1 USDC;月付最高抵 3 USDC,季度最高抵 8 USDC
- 积分来源:仅通过有效付费邀请和后台人工补发,不再通过 Telegram 群发言发放 - 积分来源:仅通过有效付费邀请和后台人工补发,不再通过 Telegram 群发言发放
+52 -14
View File
@@ -17,7 +17,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
3. 平台侧真实密钥 3. 平台侧真实密钥
放在: 放在:
- VPS / Docker `.env` - VPS / Docker `.env`
- Vercel Environment Variables - GitHub Secrets(构建期 `NEXT_PUBLIC_*` 通过 build-arg 注入前端镜像)
- GitHub Secrets(如需要) - GitHub Secrets(如需要)
## 2. 为什么要拆 ## 2. 为什么要拆
@@ -66,7 +66,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
用途: 用途:
- 前端本地开发与 Vercel 环境变量模板 - 前端本地开发与容器运行时环境变量模板
## 4. 配置分级 ## 4. 配置分级
@@ -112,6 +112,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `POLYWEATHER_PAYMENT_ENABLED` - `POLYWEATHER_PAYMENT_ENABLED`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON` - `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- `POLYGON_WALLET_WATCH_ENABLED` - `POLYGON_WALLET_WATCH_ENABLED`
- `POLYWEATHER_TURNSTILE_BYPASS`
- `POLYWEATHER_TURNSTILE_ENFORCE_ACTION`
- `POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT`
- `TELEGRAM_ALERT_PUSH_ENABLED` - `TELEGRAM_ALERT_PUSH_ENABLED`
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED` - `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
@@ -154,6 +157,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN` - `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN` - `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` - `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
- `POLYWEATHER_TURNSTILE_SECRET_KEY`
- `POLYWEATHER_R2_ACCESS_KEY_ID`
- `POLYWEATHER_R2_SECRET_ACCESS_KEY`
## 5. 推荐部署矩阵 ## 5. 推荐部署矩阵
@@ -165,20 +171,28 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- 所有 secrets - 所有 secrets
- Bot / 支付 / watcher 配置 - Bot / 支付 / watcher 配置
### 5.2 Vercel前端) ### 5.2 前端容器(Docker Compose
建议只放前端真正需要的变量 前端与后端一起以 Docker Compose 部署,环境变量分两类来源
- `POLYWEATHER_API_BASE_URL` **运行时变量**`.env` 或 compose `environment` 块):
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY` - `POLYWEATHER_API_BASE_URL`(容器内使用 `http://polyweather_web:8000`
- `POLYWEATHER_AUTH_ENABLED` - `POLYWEATHER_AUTH_ENABLED`
- `POLYWEATHER_AUTH_REQUIRED` - `POLYWEATHER_AUTH_REQUIRED`
- `POLYWEATHER_OPS_ADMIN_EMAILS` - `POLYWEATHER_OPS_ADMIN_EMAILS`
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN` - `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN` - `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
**构建期变量**GitHub Secrets → CI `build-and-push``frontend/Dockerfile``ARG`,改了必须重新构建镜像):
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `NEXT_PUBLIC_SITE_URL`
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` - `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
- `NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL` - `NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL`
- `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS`
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY`
- `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS` - `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS`
- `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS` - `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS`
- `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES` - `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES`
@@ -188,19 +202,43 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `/ops` 现在是前后端双层限制: - `/ops` 现在是前后端双层限制:
- 前端页面入口读取 `POLYWEATHER_OPS_ADMIN_EMAILS` - 前端页面入口读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
- 后端写接口同样读取 `POLYWEATHER_OPS_ADMIN_EMAILS` - 后端写接口同样读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
- 因此,Vercel 和 VPS / Docker 两侧都应配置相同的管理员邮箱白名单。 - 因此,前端容器和后端容器两侧都应配置相同的管理员邮箱白名单。
不要把后端专用密钥全搬进 Vercel 不要把后端专用密钥全搬进前端容器
### 5.3 GitHub Actions ### 5.3 GitHub Actions
当前 CI 不需要大规模 secrets。 当前 CI 已配置自动部署,需要的 secrets 见 `.github/workflows/ci.yml`
如果未来要做自动部署,再考虑: - `VPS_SSH_KEY` / `VPS_HOST` / `VPS_USER` / `GHCR_PAT`SSH 部署到 VPS
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ZONE_ID`(同步 Cloudflare Cache Rules
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY`(构建期注入前端镜像)
- 前端构建期 `NEXT_PUBLIC_*`(注入前端镜像)
- `VERCEL_TOKEN` ### 5.4 Cloudflare 免费能力
- `VERCEL_ORG_ID`
- `VERCEL_PROJECT_ID` Turnstile
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY` 是浏览器可见的 site key,属于前端构建期变量;改动后需要重新构建前端镜像。
- `POLYWEATHER_TURNSTILE_SECRET_KEY` 只放 VPS / Docker `.env`,用于 Next API Route 服务端校验。
- `POLYWEATHER_TURNSTILE_BYPASS=true` 可在排障时临时关闭校验。
- 支付 tx 提交默认不强制二次 Turnstile,因为 Cloudflare token 是一次性校验;订单创建已做校验。只有确认 UX 能支持二次挑战时,才设置 `POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT=true`
R2
- `POLYWEATHER_R2_ACCOUNT_ID`
- `POLYWEATHER_R2_BUCKET`
- `POLYWEATHER_R2_ACCESS_KEY_ID`
- `POLYWEATHER_R2_SECRET_ACCESS_KEY`
- `POLYWEATHER_R2_REGION=auto`
- `POLYWEATHER_R2_ARCHIVE_SOURCE=redis`
归档脚本只读 Redis Stream 或 SQLite,不删除热路径数据:
```bash
python scripts/archive_realtime_events_to_r2.py --date 2026-06-16 --dry-run
python scripts/archive_realtime_events_to_r2.py --date 2026-06-16
```
## 6. 最小部署示例 ## 6. 最小部署示例
+102 -146
View File
@@ -1,65 +1,97 @@
# 前端部署配置(Vercel # 前端部署配置(Docker / VPS
最后更新:`2026-05-29` 最后更新:`2026-06-14`
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。 本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。前端当前不再使用 Vercel,统一与后端一起以 Docker Compose 形式部署在同一台 VPS 上,前面挂 Cloudflare + Nginx。
## 一、部署目标 ## 一、部署目标
推荐方案: 当前方案:
1. GitHub Actions 负责 `CI` 1. GitHub Actions 负责 `CI``python-quality` + `frontend-quality`
2. Vercel 负责前端 `CD` 2. `build-and-push` job 把前端构建为 Docker 镜像 `ghcr.io/yangyuan-zhen/polyweather-frontend`
3. FastAPI 后端单独部署在 VPS / Docker 主机 3. `deploy` job 通过 SSH 把 `deploy.sh` 推到 VPS,由 VPS 拉取新镜像并滚动更新
前端本身不直接访问天气源,而是通过 Next Route Handlers 转发到后端: 前端本身不直接访问天气源,而是通过 Next Route Handlers 转发到后端:
1. 浏览器 -> Vercel 上的 Next.js 前端 1. 浏览器 → Cloudflare → Nginx → 前端容器(Next.js standalone
2. Next `/api/*` -> `POLYWEATHER_API_BASE_URL` 2. Next `/api/*` `POLYWEATHER_API_BASE_URL`(容器内默认 `http://polyweather_web:8000`
3. FastAPI 后端 -> 分析 / 支付 / 鉴权服务 3. FastAPI 后端 分析 / 支付 / 鉴权服务
实时图表同样走 Next Route Handler / rewrite 实时图表同样走 Next Route Handler
1. 浏览器 `EventSource` -> `/api/events?cities=...&since_revision=...` 1. 浏览器 `EventSource` `/api/events?cities=...&since_revision=...`
2. Next 转发到 FastAPI `/api/events` 2. Next 转发到 FastAPI `/api/events`
3. FastAPI 从 Redis Stream / SQLite event log replay 后进入 live SSE 3. FastAPI 从 Redis Stream / SQLite event log replay 后进入 live SSE
## 二、Vercel 项目设置 ## 二、镜像与构建
在 Vercel 导入 GitHub 仓库后,使用下面的设置 前端镜像定义在 `frontend/Dockerfile`,三阶段构建
- Framework Preset: `Next.js` - `deps``npm ci` 安装依赖
- Root Directory: `frontend` - `builder`:通过 `ARG` 注入 `NEXT_PUBLIC_*` 变量后执行 `npm run build`,产出 standalone 产物
- Build Command: `npm run build` - `runner`:只拷贝 `.next/standalone``.next/static``public`,以 `node server.js` 启动
- Install Command: `npm ci`
如果仓库已经连接过 Vercel,通常只需要确认 `Root Directory` 仍然是 `frontend` `NEXT_PUBLIC_*` 变量是在 **构建期** 注入的(见 `frontend/Dockerfile``ARG` 块),CI 在 `.github/workflows/ci.yml``build-and-push` job 里从 GitHub Secrets 读取并作为 `--build-arg` 传入。修改这类变量必须重新构建镜像,仅改运行时环境无效
## 三、最小必填环境变量 ## 三、Compose 服务
只部署天气看板和基础登录时,先填下面 4 项 前端在 `docker-compose.yml` 中对应 `polyweather_frontend` 服务
```env - 镜像:`ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}`
POLYWEATHER_API_BASE_URL=https://<your-fastapi-host> - 容器内监听 `:3000`,映射到宿主 `127.0.0.1:3001`
NEXT_PUBLIC_SUPABASE_URL=https://<your-project>.supabase.co - 健康检查:`wget -qO- http://$(hostname):3000`
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your-anon-key> - 运行时环境变量(非 `NEXT_PUBLIC_*` 的那部分)通过 compose `environment` 注入,例如:
POLYWEATHER_AUTH_ENABLED=true - `POLYWEATHER_API_BASE_URL=http://polyweather_web:8000`(容器内走后端服务名)
- `POLYWEATHER_AUTH_ENABLED` / `POLYWEATHER_AUTH_REQUIRED`
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
- `POLYWEATHER_OPS_ADMIN_EMAILS`
`POLYWEATHER_API_BASE_URL` **禁止** 指向前端站点自身(`polyweather.top`),否则会形成回环。`deploy.sh` 里的 `validate_frontend_api_base_url` 会在部署前拦截。容器内应使用 `http://polyweather_web:8000`
## 四、部署流程(`deploy.sh`
生产部署由 GitHub Actions 在 `main` push 时触发,关键步骤:
1. SSH 登录 VPS,用 GHCR PAT 登录镜像仓库
2. `git fetch origin main && git reset --hard origin/main` 同步仓库(含 `docker-compose.yml`
3. 同步 `data/city_thread_ids.json` 到运行态目录
4. `docker compose pull` 拉取新镜像(带重试)
5. 按顺序滚动更新:`redis``web` + `bot``collector``warmer``frontend`
6. 每步后做本地健康检查;前端额外等待 `/terminal``/api/scan/terminal` 就绪
7. 公网 smoke check`https://api.polyweather.top/healthz``https://polyweather.top/api/cities``https://www.polyweather.top/`
8. 任意一步失败自动回滚到上一个镜像 tag(记录在 `/var/lib/polyweather/.current_tag`
部署失败时优先看 `deploy.sh` 输出里哪一步打了 `❌`,并检查 `docker compose logs polyweather_frontend`
## 五、最小必填配置
只部署天气看板和基础登录时,至少需要:
构建期(CI Secrets,对应 `frontend/Dockerfile``ARG`):
```
NEXT_PUBLIC_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY
NEXT_PUBLIC_SITE_URL=https://polyweather.top
``` ```
建议显式补 运行期(`.env` 或 compose `environment`
```env ```env
POLYWEATHER_API_BASE_URL=http://polyweather_web:8000
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=true POLYWEATHER_AUTH_REQUIRED=true
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=<与后端共享>
``` ```
说明: 说明:
- `POLYWEATHER_API_BASE_URL`:前端所有 `/api/*` Route Handler 转发时依赖它,没填会直接返回 500。 - `POLYWEATHER_API_BASE_URL`:前端所有 `/api/*` Route Handler 转发时依赖它,没填或填错会直接返回 500。
- `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY`Supabase 客户端依赖它们。 - `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY`Supabase 客户端依赖它们,构建期注入
- `POLYWEATHER_AUTH_ENABLED`:关闭时,前端不会启用登录能力 - `POLYWEATHER_AUTH_ENABLED` / `POLYWEATHER_AUTH_REQUIRED`:控制 middleware 是否强制登录
- `POLYWEATHER_AUTH_REQUIRED`:控制 middleware 是否强制登录。
## 、按功能启用的可选环境变量 ## 、按功能启用的可选环境变量
### 1. 分享式看板 ### 1. 分享式看板
@@ -69,60 +101,45 @@ POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
设置后,可通过 `/?access_token=<token>` 打开带令牌的看板入口。 设置后,可通过 `/?access_token=<token>` 打开带令牌的看板入口。
### 2. 前后端 entitlement 校验 ### 2. 钱包支付(构建期)
```env
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
``` ```
仅当后端开启 entitlement / 订阅校验时需要。
### 3. 钱包支付
```env
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
``` ```
如果不启用钱包支付,可以留空。 如果不启用钱包支付,可以留空。
### 4. `/ops` 管理员页面守卫 ### 3. `/ops` 管理员页面守卫
```env ```env
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
``` ```
说明: `/ops` 页面入口会读取管理员邮箱白名单,前端和后端容器都应配置相同的值。
- `/ops` 现在不是只有后端接口限制,前端页面入口也会读取管理员邮箱白名单。 ### 4. Telegram 入口(构建期)
- 因此前端部署到 Vercel 时,也应配置 `POLYWEATHER_OPS_ADMIN_EMAILS`
### 5. Telegram 入口 ```
```env
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group> NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
``` ```
只影响按钮跳转,不影响核心页面加载。 只影响按钮跳转,不影响核心页面加载。
### 6. 前端观测与预热开关(推荐默认关闭) ### 5. 前端观测与预热开关(推荐默认关闭)
```env ```
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false
NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
``` ```
说明: ## 七、支付配置与旧镜像治理
- `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false`:关闭前端自建埋点。 支付区有一层额外防护:
- `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false`:关闭前端 Web Vitals 上报。
- `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false`:关闭首页全量城市 summary 预热,避免白白消耗 Vercel function / edge 成本。
## 五、支付配置与旧部署治理
支付区现在有一层额外防护:
1. 用户点击支付前,前端会重新请求 `/api/payments/config` 1. 用户点击支付前,前端会重新请求 `/api/payments/config`
2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址 2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址
@@ -130,61 +147,19 @@ NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
4. 多链支付时,前端会展示后端返回的网络列表,并把用户选择的 `chain_id` 传给后端创建 intent 4. 多链支付时,前端会展示后端返回的网络列表,并把用户选择的 `chain_id` 传给后端创建 intent
5. Ethereum 主网 USDC 当前走手动直转确认,前端不会把它当成 Polygon checkout 合约支付 5. Ethereum 主网 USDC 当前走手动直转确认,前端不会把它当成 Polygon checkout 合约支付
这层防护的目的,是降低以下事故概率: 这层防护降低以下事故概率:
- 用户使用长期未刷新的旧标签页 - 用户使用长期未刷新的旧标签页
- 命中旧 deployment URL
- 页面本地状态残留旧收款地址 - 页面本地状态残留旧收款地址
- 用户在钱包默认网络(例如 Ethereum)付款,但系统按 Polygon intent 查账 - 用户在钱包默认网络(例如 Ethereum)付款,但系统按 Polygon intent 查账
如果变更过支付收款地址,建议同步执行: 如果变更过支付收款地址,由于前端镜像是构建期注入地址,需要触发一次 `main` push(或重新运行 deploy workflow)来发布新镜像;浏览器侧靠 `/api/payments/config` 的运行时校验兜底,无需等待所有用户刷新。
1. 在 Vercel 对当前 production 做一次 redeploy ## 八、不要放进前端容器的变量
2. 删除明显过期、可能还带旧支付配置的旧 deployment
3. 在 `Settings -> Security -> Deployment Retention Policy` 中收紧旧部署保留周期
## 六、推荐的三套配置口径 这些属于后端私密配置,不应该放到前端服务:
### 1. 公开游客模式 - `SUPABASE_SERVICE_ROLE_KEY`(除非前端 Route Handler 明确需要,且仅以非 `NEXT_PUBLIC_` 形式注入容器)
```env
POLYWEATHER_API_BASE_URL=https://api.example.com
POLYWEATHER_AUTH_ENABLED=false
POLYWEATHER_AUTH_REQUIRED=false
```
适合公开演示站。
### 2. 正常登录模式
```env
POLYWEATHER_API_BASE_URL=https://api.example.com
NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=true
```
适合正式前端站点。
### 3. 登录 + entitlement 联动
```env
POLYWEATHER_API_BASE_URL=https://api.example.com
NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=true
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=<shared-token>
```
适合前后端都启用了会员/订阅保护的生产环境。
## 七、不要放进 Vercel 的变量
这些属于后端私密配置,不应该放到前端项目:
- `SUPABASE_SERVICE_ROLE_KEY`
- `TELEGRAM_BOT_TOKEN` - `TELEGRAM_BOT_TOKEN`
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN` 以外的后端 secret - `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN` 以外的后端 secret
- 支付签名私钥 / 交易私钥 / 任何 bot 凭据 - 支付签名私钥 / 交易私钥 / 任何 bot 凭据
@@ -194,74 +169,55 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=<shared-token>
- `NEXT_PUBLIC_*` 会暴露给浏览器 - `NEXT_PUBLIC_*` 会暴露给浏览器
- 只有明确允许前端公开使用的值,才应加 `NEXT_PUBLIC_` - 只有明确允许前端公开使用的值,才应加 `NEXT_PUBLIC_`
## 、上线前检查 ## 、上线前检查
Vercel 部署前至少确认: 部署前至少确认:
1. `POLYWEATHER_API_BASE_URL` 指向可访问的后端生产地址 1. `POLYWEATHER_API_BASE_URL` 指向容器内后端服务名 `http://polyweather_web:8000`**不是** `polyweather.top`
2. `frontend/.env.example` 和 Vercel Project Settings 中的实际值一致 2. CI Secrets 中的 `NEXT_PUBLIC_*` 值与预期一致(构建期注入,改了要重新构建镜像)
3. GitHub Actions 中 `frontend-quality` 已通过 3. GitHub Actions 中 `frontend-quality` 已通过
4. 如果启用鉴权,Supabase redirect URL 已包含前端域名 4. 如果启用鉴权,Supabase redirect URL 已包含前端域名
5. `GET /api/payments/config` 返回的是当前最新地址,而不是旧收款合约 5. `GET /api/payments/config` 返回的是当前最新地址,而不是旧收款合约
6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在 Vercel 与后端同时配置 6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在前端与后端容器同时配置
7. 确认 `/api/events` 没有被 CDN 缓存或压缩成普通 JSON;它必须保持 `text/event-stream` 7. 确认 `/api/events` 没有被 Cloudflare / Nginx 缓存或压缩成普通 JSON;它必须保持 `text/event-stream`
## 、常见问题 ## 、常见问题
### 1. 页面打开后 API 全部 500 ### 1. 页面打开后 API 全部 500
先检查 先检查容器内 `POLYWEATHER_API_BASE_URL` 是否指向 `http://polyweather_web:8000`,以及 `polyweather_web` 容器是否健康。
```env ### 2. 构建通过,但登录失败
POLYWEATHER_API_BASE_URL
```
这是最常见原因。
### 2. Vercel 构建通过,但登录失败
先检查: 先检查:
- `NEXT_PUBLIC_SUPABASE_URL` - 构建期注入的 `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY` - Supabase 项目里的站点 URL / redirect URL 是否包含前端域名
- Supabase 项目里的站点 URL / redirect URL
### 3. 钱包入口显示未配置 ### 3. 钱包入口显示未配置
检查 检查构建期 `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` 是否注入(前端镜像需要重新构建)。
```env ### 4. 改了 `NEXT_PUBLIC_*` 但线上没生效
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
```
是钱包连接的必需项 类变量是构建期注入的。仅改 CI Secrets 不会更新已部署镜像,需要重新触发 `build-and-push` + `deploy`(即一次 `main` push,或手动重跑 deploy workflow
## 十、Vercel 成本与节流建议 ## 十、成本与节流建议
### 1. 建议先关闭的项目级能力 ### 1. Cloudflare 缓存规则
- `Web Analytics` 前端通过 `next.config.mjs``headers()` 为静态资源(`_next/static`、图片、字体)设置 `Cache-Control: public, max-age=31536000, immutable`,并为公共页面设置 `s-maxage=600, stale-while-revalidate=3600`。CI 的 `cloudflare-cache-rules` job 会同步 Cloudflare Cache Rules(见 `scripts/configure_cloudflare_free.py`)。
- `Speed Insights`
它们对排查前端体验有价值,但在 Hobby / 低预算阶段会额外消耗数据点和边缘资源。 ### 2. Cloudflare WAF 规则
### 2. 建议加的 Firewall 自定义规则 如果发现大量 WordPress / PHP 扫描流量命中 Next.js(实际并不提供这些路径),建议在 Cloudflare WAF 中先 `Log``Deny` 这条规则
如果你的 Next.js 项目根本不提供 WordPress / PHP 路径,建议在 Vercel Firewall 里先 `Log``Deny` 这条规则:
```regex ```regex
(^/(wp-admin|wp-includes|wp-content|wp-login|wordpress|xmlrpc\.php))|\.php($|\?) (^/(wp-admin|wp-includes|wp-content|wp-login|wordpress|xmlrpc\.php))|\.php($|\?)
``` ```
目的: 目的:在边缘层提前拦截扫描流量,避免无效请求继续触发 Nginx、Next.js middleware 与 route handler。
- 在边缘层提前拦截 WordPress / PHP 扫描流量 ### 3. SSE 路径不要进缓存
- 避免无效请求继续触发 middleware 与 route handler
### 3. 建议的上线前检查 `/api/events` 必须保持 `text/event-stream`Cloudflare 和 Nginx 都不应缓存或压缩它。检查 Nginx 配置(`deploy/nginx/polyweather.conf`)中对 `/api/events``proxy_buffering off`
除了功能本身,额外确认:
1. `Web Analytics``Speed Insights` 是否真的关闭
2. `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS` / `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS` / `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES` 是否保持关闭
3. Firewall 自定义规则是否已从 `Log` 切到 `Deny`
+1 -1
View File
@@ -6,7 +6,7 @@
前端入口: 前端入口:
- `https://polyweather-pro.vercel.app/ops` - `https://polyweather.top/ops`
## 2. 权限 ## 2. 权限
+2 -2
View File
@@ -15,7 +15,7 @@
- `https://<project-ref>.supabase.co/auth/v1/callback` - `https://<project-ref>.supabase.co/auth/v1/callback`
3. `Auth -> URL Configuration` 添加: 3. `Auth -> URL Configuration` 添加:
- 站点 URL(生产域名) - 站点 URL(生产域名)
- 回调 URL(例如 `https://polyweather-pro.vercel.app/auth/callback` - 回调 URL(例如 `https://polyweather.top/auth/callback`
## 3. 数据库脚本 ## 3. 数据库脚本
@@ -45,7 +45,7 @@
## 4. 环境变量 ## 4. 环境变量
### 4.1 前端(Vercel / frontend/.env.local ### 4.1 前端(Docker 容器 / frontend/.env.local
```env ```env
NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_URL=
+3 -3
View File
@@ -7,7 +7,7 @@ PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类
> **2026-05-29 更新**:支付 intent 已支持多链 `chain_id`Polygon 继续承载 checkout 合约,Ethereum 主网 USDC 作为直转确认通道;终端图表继续使用 HTTP snapshot + SSE Patch + Redis Stream/SQLite replay 架构。 > **2026-05-29 更新**:支付 intent 已支持多链 `chain_id`Polygon 继续承载 checkout 合约,Ethereum 主网 USDC 作为直转确认通道;终端图表继续使用 HTTP snapshot + SSE Patch + Redis Stream/SQLite replay 架构。
## 项目概览 ## 项目概览
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。 PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Docker/VPS 前端)+ VPS 后端 + Telegram Bot”。
项目主功能可归纳为五层: 项目主功能可归纳为五层:
**天气层(数据源/采集)**:聚合 51 个城市的实测与预报;支持 AviationWeather METAR/TAF、韩国 AMOS 跑道级观测(首尔/釜山)、中国 AMSC AWOS 跑道端点气温、香港 CoWIN 6087 / HKO、土耳其 MGM、Open-Meteo 多模型与集合预报、美国 MADIS HFMETAR 等。机场类市场仍以 METAR / 机场主站或明确官方结算源为锚点,Wunderground 不描述为物理观测站。 **天气层(数据源/采集)**:聚合 51 个城市的实测与预报;支持 AviationWeather METAR/TAF、韩国 AMOS 跑道级观测(首尔/釜山)、中国 AMSC AWOS 跑道端点气温、香港 CoWIN 6087 / HKO、土耳其 MGM、Open-Meteo 多模型与集合预报、美国 MADIS HFMETAR 等。机场类市场仍以 METAR / 机场主站或明确官方结算源为锚点,Wunderground 不描述为物理观测站。
**分析层(DEB/趋势/概率/结算口径)**: **分析层(DEB/趋势/概率/结算口径)**:
@@ -28,7 +28,7 @@ DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
从 README、Docker/Compose、入口脚本与核心模块引用关系,可以抽象出如下模块地图(按“运行时组件”与“Python 域模块”两层描述): 从 README、Docker/Compose、入口脚本与核心模块引用关系,可以抽象出如下模块地图(按“运行时组件”与“Python 域模块”两层描述):
| 层级 | 目录/文件 | 角色定位 | 关键说明 | | 层级 | 目录/文件 | 角色定位 | 关键说明 |
| ------------- | ------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------- | ------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 运行时组件 | `frontend/` | Next.js 前端(Vercel) | 前端重构报告提到 App Router、Route HandlersBFF)、缓存策略、支付与账户中心等;Scan Terminal 已新增城市决策卡、结构化实况层、页面内存/localStorage 双层缓存、structured detail 小并发队列与完整市场桶映射。 | | 运行时组件 | `frontend/` | Next.js 前端(Docker / VPS) | 前端重构报告提到 App Router、Route HandlersBFF)、缓存策略、支付与账户中心等;Scan Terminal 已新增城市决策卡、结构化实况层、页面内存/localStorage 双层缓存、structured detail 小并发队列与完整市场桶映射。 |
| 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` + `web/scan_terminal_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层;Scan Terminal 侧提供 `/api/city/{name}/detail`,城市结构化分析 默认 30s 超时并支持 stream parse failure 的非流式重试。 | | 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` + `web/scan_terminal_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层;Scan Terminal 侧提供 `/api/city/{name}/detail`,城市结构化分析 默认 30s 超时并支持 stream parse failure 的非流式重试。 |
| 运行时组件 | `bot_listener.py` + `src/bot/*` | Telegram Bot | 入口 `bot_listener.py``start_bot()`,并由 `StartupCoordinator` 启动多个后台 loop。 | | 运行时组件 | `bot_listener.py` + `src/bot/*` | Telegram Bot | 入口 `bot_listener.py``start_bot()`,并由 `StartupCoordinator` 启动多个后台 loop。 |
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py``settlement_sources.py``metar_sources.py``mgm_sources.py``amos_station_sources.py``jma_amedas_sources.py``nws_open_meteo_sources.py``country_networks.py` 等。v1.7.0 已移除 NMC、pogodaiklimat、Meteoblue 数据源。 | | Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py``settlement_sources.py``metar_sources.py``mgm_sources.py``amos_station_sources.py``jma_amedas_sources.py``nws_open_meteo_sources.py``country_networks.py` 等。v1.7.0 已移除 NMC、pogodaiklimat、Meteoblue 数据源。 |
@@ -44,7 +44,7 @@ DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
```mermaid ```mermaid
flowchart TB flowchart TB
subgraph Clients subgraph Clients
WEB[Next.js Frontend<br/>Vercel] WEB[Next.js Frontend<br/>Docker / VPS]
CITYCARD[Scan Terminal City Cards<br/>structured observations + probability mapping] CITYCARD[Scan Terminal City Cards<br/>structured observations + probability mapping]
TG[Telegram Bot<br/>TeleBot + Handlers] TG[Telegram Bot<br/>TeleBot + Handlers]
end end
+10
View File
@@ -0,0 +1,10 @@
[
{
"recorded_at": "2026-06-12T23:32:49+08:00",
"total_registered_users": 588,
"verified_users": 573,
"ever_signed_in_users": 561,
"source": "supabase_auth_admin",
"note": "Baseline recorded before verified-user growth milestone rewards launched."
}
]
@@ -0,0 +1,32 @@
# PolyWeather Growth Rewards
PolyWeather has reached **588 registered users**.
That number matters because every new user helps us improve the product: more
feedback, more real-world usage, and more pressure to make weather observations
faster, clearer, and more reliable for prediction-market decisions.
We are introducing **PolyWeather Growth Rewards**.
From now on, verified-user milestones will unlock additional Pro time for every
active paid member:
- 600 verified users: **+1 Pro day**
- 750 verified users: **+2 Pro days**
- 1,000 verified users: **+3 Pro days**
- Every additional 100 verified users after 1,000: **+3 Pro days**
Rewards are issued automatically to active paid members when each milestone is
reached. Every milestone can only be claimed once.
This is separate from our referral program. Referrals still reward the people
who directly help PolyWeather grow, while Growth Rewards let all paying members
share in the progress of the community.
We currently have **588 registered users**, including **573 verified users**.
The first Growth Reward unlocks at 600 verified users.
Grow together. Earn more Pro time.
https://polyweather.top
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -0,0 +1,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,68 @@
# Growth Milestone Pro Rewards Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Automatically extend active paid Pro memberships when verified-user growth milestones are reached.
**Architecture:** A low-frequency Bot-owned loop reads Supabase Auth and payment data, stores daily growth history in SQLite, and grants idempotent milestone-specific bonus subscriptions. SQLite settlement and payout records prevent duplicate processing and support retries.
**Tech Stack:** Python, SQLite, Supabase REST/Auth Admin API, Telegram Bot runtime, pytest
---
### Task 1: Define milestone and eligibility policy
**Files:**
- Create: `src/bot/growth_milestone_reward_loop.py`
- Test: `tests/test_growth_milestone_reward_loop.py`
- [ ] Write failing tests for the 600/750/1000/100-step milestone schedule and paid-member eligibility intersection.
- [ ] Run `python -m pytest tests/test_growth_milestone_reward_loop.py -q` and confirm failure because the module is missing.
- [ ] Implement pure policy helpers and Supabase query helpers.
- [ ] Re-run the focused tests and confirm they pass.
### Task 2: Persist history and settlement state
**Files:**
- Modify: `src/database/db_manager.py`
- Test: `tests/test_growth_milestone_reward_loop.py`
- [ ] Write failing tests for daily snapshot upsert, payout idempotency, and milestone settlement.
- [ ] Run the focused tests and confirm the new DB methods are missing.
- [ ] Add `user_growth_snapshots`, `growth_milestone_runs`, and `growth_milestone_payouts`.
- [ ] Re-run the focused tests and confirm they pass.
### Task 3: Run and announce automatic settlement
**Files:**
- Modify: `src/bot/growth_milestone_reward_loop.py`
- Modify: `src/bot/runtime_coordinator.py`
- Modify: `src/utils/config_validation.py`
- Modify: `.env.example`
- Test: `tests/test_growth_milestone_reward_loop.py`
- Test: `tests/test_bot_runtime_coordinator.py`
- [ ] Write failing tests for idempotent bonus grants and runtime-loop registration.
- [ ] Run focused tests and confirm the expected failures.
- [ ] Implement the settlement loop, retry behavior, and bilingual announcement.
- [ ] Re-run focused tests and confirm they pass.
### Task 4: Record baseline and publishable announcement
**Files:**
- Create: `docs/operations/user-growth-history.json`
- Create: `docs/social/2026-06-12-growth-rewards-x-post.md`
- Create: `docs/social/assets/polyweather-588-growth-rewards.png`
- [ ] Record the verified 2026-06-12 baseline.
- [ ] Write the English X announcement with accurate total-user and verified-user wording.
- [ ] Save the generated social image in the repository.
### Task 5: Verify and deploy
- [ ] Run focused backend tests.
- [ ] Run `python -m ruff check src/bot/growth_milestone_reward_loop.py src/bot/runtime_coordinator.py src/database/db_manager.py tests/test_growth_milestone_reward_loop.py tests/test_bot_runtime_coordinator.py`.
- [ ] Run the broader relevant test suite.
- [ ] Commit and push to `main`.
- [ ] Check GitHub Actions and production health.
@@ -0,0 +1,138 @@
# DEB Training Settlement Worker Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Restore automatic DEB training data freshness after the realtime collector/canonical-cache split.
**Architecture:** Add a dedicated low-frequency training settlement service that runs city analysis for forecast/DEB snapshots and reconciles recent settled actual highs. Keep it separate from the high-frequency observation collector so user-facing realtime updates stay light.
**Tech Stack:** Python, FastAPI service modules, Docker Compose, pytest, existing `update_daily_record()` and `reconcile_recent_actual_highs()` paths.
---
### Task 1: Training Settlement Service
**Files:**
- Create: `web/training_settlement_service.py`
- Create: `tests/test_training_settlement_service.py`
- [ ] **Step 1: Write the failing service test**
```python
def test_training_settlement_cycle_runs_analysis_and_reconciles_supported_cities():
calls = {"analysis": [], "reconcile": []}
def analysis_runner(city):
calls["analysis"].append(city)
return {"city": city, "deb": {"prediction": 31.2}}
def reconciler(city, *, lookback_days):
calls["reconcile"].append((city, lookback_days))
return {"ok": True, "updated": 1}
result = run_training_settlement_cycle(
city_registry={
"shanghai": {"icao": "ZSSS", "settlement_source": "metar"},
"legacy": {"settlement_source": "wunderground"},
},
analysis_runner=analysis_runner,
actual_reconciler=reconciler,
lookback_days=9,
)
assert result["ok"] is True
assert result["processed"] == 1
assert calls["analysis"] == ["shanghai"]
assert calls["reconcile"] == [("shanghai", 9)]
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `python -m pytest tests/test_training_settlement_service.py::test_training_settlement_cycle_runs_analysis_and_reconciles_supported_cities -q`
Expected: FAIL because `web.training_settlement_service` does not exist.
- [ ] **Step 3: Implement the service**
Create `run_training_settlement_cycle()` with injectable `city_registry`, `analysis_runner`, and `actual_reconciler`. Default analysis runner calls `web.analysis_service._analyze(city, force_refresh=False, detail_mode="panel")`; default reconciler calls `src.analysis.deb_algorithm.reconcile_recent_actual_highs()`.
- [ ] **Step 4: Run the test to verify it passes**
Run: `python -m pytest tests/test_training_settlement_service.py -q`
Expected: PASS.
### Task 2: Worker Entrypoint And Compose
**Files:**
- Create: `web/training_settlement_worker.py`
- Modify: `docker-compose.yml`
- Modify: `tests/test_deployment_runtime_config.py`
- [ ] **Step 1: Write failing deployment tests**
Assert the compose file contains `polyweather_training_settlement`, command `python -m web.training_settlement_worker`, role `training_settlement`, and conservative interval/lookback environment variables.
- [ ] **Step 2: Run the deployment test to verify it fails**
Run: `python -m pytest tests/test_deployment_runtime_config.py::test_runtime_compose_splits_realtime_workers -q`
Expected: FAIL because the worker service is absent.
- [ ] **Step 3: Implement worker and compose service**
The worker loops `run_training_settlement_cycle()` every `POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC` seconds, with initial delay and lookback from environment.
- [ ] **Step 4: Run focused tests**
Run: `python -m pytest tests/test_training_settlement_service.py tests/test_deployment_runtime_config.py -q`
Expected: PASS.
### Task 3: Stale Monitoring
**Files:**
- Modify: `web/diagnostics/health.py`
- Modify: `web/services/system_api.py`
- Modify: `tests/test_web_observability.py`
- [ ] **Step 1: Write failing observability tests**
Assert system status training summaries include `stale_days` for daily/truth/features, and Prometheus exports stale gauges for training data.
- [ ] **Step 2: Run focused observability tests to verify failure**
Run: `python -m pytest tests/test_web_observability.py::test_system_status_includes_training_data tests/test_web_observability.py::test_metrics_endpoint_returns_prometheus_payload_for_ops_admin -q`
Expected: FAIL because stale fields/gauges are absent.
- [ ] **Step 3: Implement stale summary and gauges**
Add date-diff calculation against UTC today. Export `polyweather_daily_records_stale_days`, `polyweather_truth_records_stale_days`, `polyweather_training_features_stale_days`, and `polyweather_training_data_stale`.
- [ ] **Step 4: Run focused observability tests**
Run: `python -m pytest tests/test_web_observability.py::test_system_status_includes_training_data tests/test_web_observability.py::test_metrics_endpoint_returns_prometheus_payload_for_ops_admin -q`
Expected: PASS.
### Task 4: Verification And Backfill
**Files:**
- Optional create: `scripts/backfill_training_settlement.py`
- [ ] **Step 1: Run backend checks**
Run: `python -m ruff check .`
Run: `python -m pytest tests/test_training_settlement_service.py tests/test_deployment_runtime_config.py tests/test_web_observability.py -q`
- [ ] **Step 2: Run local one-shot cycle**
Run: `python -m web.training_settlement_worker --once --lookback-days 10 --cities shanghai`
Expected: JSON-like log output with `ok=True`; local DB should get a fresh row for the current local target date if analysis succeeds.
- [ ] **Step 3: Production note**
Missed forecast snapshots from 2026-06-15 to 2026-06-22 cannot be reconstructed honestly unless archived city analysis payloads exist. The worker restores forward automatic samples; actual-high truth can still be reconciled for supported settlement sources.
@@ -0,0 +1,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.
@@ -0,0 +1,51 @@
# Growth Milestone Pro Rewards Design
## Goal
Reward active paid Pro members with additional membership time when PolyWeather
reaches verified-user growth milestones.
## Confirmed Rules
- Growth is measured using verified Supabase Auth users.
- The historical launch baseline is:
- 588 total registered users
- 573 verified users
- recorded on 2026-06-12
- Milestones and rewards:
- 600 verified users: +1 Pro day
- 750 verified users: +2 Pro days
- 1000 verified users: +3 Pro days
- every additional 100 verified users after 1000: +3 Pro days
- Each milestone settles at most once.
- Only users who currently have active membership access and have at least one
confirmed real payment are eligible.
- Trial-only, manual-grant-only, and reward-only users are excluded.
## Architecture
The Telegram Bot process already acts as the single background-job owner. A new
growth milestone loop runs there on a low-frequency interval. It reads verified
Auth-user counts, writes one daily growth snapshot, checks unsettled milestones,
selects eligible paid members, and grants an append-only bonus subscription
window.
SQLite stores daily snapshots, milestone settlement summaries, and per-user
payout records. Supabase stores the actual bonus subscription. The bonus source
contains the milestone number so a retry can detect an already-issued reward.
## Failure Handling
- Supabase read failures do not advance or settle a milestone.
- Successful user payouts are recorded individually.
- Failed payouts are retried on the next loop run.
- A milestone is marked settled only when all eligible payouts succeed.
- Bonus subscription writes use a milestone-specific source as an additional
idempotency guard.
## Notification
After a milestone settles, the Bot posts one concise bilingual group
announcement when announcements are enabled. It includes the milestone, reward
days, and rewarded member count.
+1 -1
View File
@@ -43,7 +43,7 @@
首次建议打开扩展"选项页"并确认: 首次建议打开扩展"选项页"并确认:
- `网站基础地址`:你的前端域名(例如 `https://polyweather-pro.vercel.app` - `网站基础地址`:你的前端域名(例如 `https://polyweather.top`
- `API 基础地址`:你的后端 API 域名(若同域也可填前端域名) - `API 基础地址`:你的后端 API 域名(若同域也可填前端域名)
- `Bearer Token`:后端开启鉴权时填写 - `Bearer Token`:后端开启鉴权时填写
+2 -2
View File
@@ -12,12 +12,12 @@
<label class="field"> <label class="field">
<span id="siteBaseLabel">Site Base URL</span> <span id="siteBaseLabel">Site Base URL</span>
<input id="siteBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" /> <input id="siteBaseInput" type="text" placeholder="https://polyweather.top" />
</label> </label>
<label class="field"> <label class="field">
<span id="apiBaseLabel">API Base URL</span> <span id="apiBaseLabel">API Base URL</span>
<input id="apiBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" /> <input id="apiBaseInput" type="text" placeholder="https://polyweather.top" />
</label> </label>
<label class="field"> <label class="field">
+2 -2
View File
@@ -1,6 +1,6 @@
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
apiBase: "https://polyweather-pro.vercel.app", apiBase: "https://polyweather.top",
siteBase: "https://polyweather-pro.vercel.app", siteBase: "https://polyweather.top",
authToken: "", authToken: "",
selectedCity: "" selectedCity: ""
}; };
+2 -2
View File
@@ -1,8 +1,8 @@
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
apiBase: "https://polyweather-pro.vercel.app", apiBase: "https://polyweather.top",
authToken: "", authToken: "",
selectedCity: "", selectedCity: "",
siteBase: "https://polyweather-pro.vercel.app" siteBase: "https://polyweather.top"
}; };
const CACHE_VERSION = "v3"; const CACHE_VERSION = "v3";
const locale = String(navigator.language || "en").toLowerCase().startsWith("zh") const locale = String(navigator.language || "en").toLowerCase().startsWith("zh")
+12
View File
@@ -0,0 +1,12 @@
.git
.next
node_modules
.env
.env.local
.env.production
.env.*
!.env.example
*.log
*.tsbuildinfo
+3
View File
@@ -38,6 +38,9 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
POLYWEATHER_TURNSTILE_SECRET_KEY=
POLYWEATHER_TURNSTILE_BYPASS=false
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
-52
View File
@@ -1,52 +0,0 @@
# PolyWeather 前端最小配置(本地 / Vercel)
# 只部署天气看板时,先填下面 4 项即可。
# 必填:后端 FastAPI 基础地址
# 默认供 Next.js API Route 在服务端代理后端使用。
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
# 可选:浏览器直连后端 FastAPI 基础地址。
# 在 Vercel 免费额度下建议配置为 VPS HTTPS 域名,让 AI / METAR / scan 等
# 长耗时请求绕过 Vercel Functions / Fluid Compute。
# 例如:https://api.example.com
# 本地开发时注释掉,让 Next.js API Route 代理请求,避免 CORS 问题
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=http://38.54.27.70:8080
# 必填:Supabase 前端公钥(鉴权开启时必须)
NEXT_PUBLIC_SUPABASE_URL=https://bttgfgupldyowkdhriqb.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
# 生产环境必须设为 https://polyweather.top
NEXT_PUBLIC_SITE_URL=https://polyweather.top
# 常用:前端鉴权开关
# true: 启用 Supabase 登录
# false: 关闭登录能力,访客模式
POLYWEATHER_AUTH_ENABLED=true
# 常用:是否强制登录
# true: middleware 强制登录后才能访问主页面
# false: 登录可选,访客可浏览
POLYWEATHER_AUTH_REQUIRED=true
# 关闭本地开发鉴权绕过
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
# 可选:分享式看板访问令牌
# 设置后,可通过 /?access_token=<token> 打开受保护看板
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
# 可选:前端 API Route 转发到后端时附带的共享令牌
# 仅当后端启用了 entitlement / 订阅校验时需要
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# 可选:钱包支付 / Telegram 入口
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=8ce6332b57a42e606cf28224e82d1e02
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
-8
View File
@@ -1,8 +0,0 @@
NEXT_PUBLIC_SUPABASE_URL=https://bttgfgupldyowkdhriqb.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
NEXT_PUBLIC_SITE_URL=https://polyweather.top
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=https://api.polyweather.top
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=8ce6332b57a42e606cf28224e82d1e02
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
+5
View File
@@ -1 +1,6 @@
.vercel .vercel
.env
.env.local
.env.production
.env.*
!.env.example
+2
View File
@@ -14,6 +14,7 @@ ARG NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
ARG NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID ARG NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
ARG NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL ARG NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL
ARG NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS ARG NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
@@ -23,6 +24,7 @@ ENV NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=$NEXT_PUBLIC_POLYWEATHER_LOCAL_FUL
ENV NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=$NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID ENV NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=$NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
ENV NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=$NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL ENV NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=$NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL
ENV NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=$NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS ENV NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=$NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
WORKDIR /app/frontend WORKDIR /app/frontend
COPY --from=deps /app/frontend/node_modules ./node_modules COPY --from=deps /app/frontend/node_modules ./node_modules
+18 -29
View File
@@ -9,7 +9,7 @@ PolyWeather Pro 的生产前端工程。
- Next.js App Router - Next.js App Router
- React + Tailwind - React + Tailwind
- Leaflet + Recharts - 自研温度图表 + Recharts 运营图表
- Supabase Auth - Supabase Auth
- WalletConnect + 浏览器 EVM 钱包 - WalletConnect + 浏览器 EVM 钱包
@@ -21,30 +21,19 @@ PolyWeather Pro 的生产前端工程。
## 当前前端能力 ## 当前前端能力
- 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心 - 主站为实时天气决策台,包含 `天气决策 / 训练数据 / 使用指南` 三个主入口
- `/docs` 提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF 和结算来源 - `/docs` 提供公开双语产品文档中心,当前保留简介、图表阅读、实时数据频率、结算站点和浏览器插件说明
- 天气决策台支持 1x1 到 3x3 图表槽位,可按区域、搜索和城市选择进行多城市巡检
- 终端图表默认展示全天,可切换高温窗口;所有横轴与 tooltip 时间按城市当地时间渲染 - 终端图表默认展示全天,可切换高温窗口;所有横轴与 tooltip 时间按城市当地时间渲染
- 可见终端图表通过 SSE patch 无痛增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线 - 可见终端图表通过 SSE patch 增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
- AMSC/AMOS 跑道城市默认展示结算跑道曲线并高亮,辅助跑道弱化展示;单跑道机场不重复展示聚合线 - AMSC/AMOS 跑道城市默认展示结算跑道曲线并高亮,辅助跑道弱化展示;单跑道机场不重复展示聚合线
- 香港默认展示 CoWIN 6087 参考站 1 分钟曲线,并保留 HKO 10 分钟官方气象层 - 香港默认展示 CoWIN 6087 参考站 1 分钟曲线,并保留 HKO 10 分钟官方气象层
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线 - legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
- 今日日内分析支持: - 使用指南内置图表阅读顺序、图层含义、常用操作和默认可见性规则
- `锚点状态`
- `当前节奏`
- `校准模型概率`
- `模型区间与分歧`
- `专业气象结论条`
- `气象证据链 / 失效条件 / 确认条件`
- 非香港机场城市的 `TAF` 时段提示与走势图联动
- `/ops` 已支持桌面表格 + 手机端卡片化视图 - `/ops` 已支持桌面表格 + 手机端卡片化视图
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住 - 城市 detail 自动识别稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果 - 市场价格层使用完整 `all_buckets` 匹配温度桶,市场信号作为交易判断层,不替代实测结算源
- 右侧详情面板在多日预报仍未补齐时会显示同步占位卡,不再把“只有今天一张卡”的中间态伪装成完整数据 - legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
- 日内分析弹窗在 full detail / market scan 同步时会锁住旧内容并显示刷新状态,避免用户短暂看到旧城市或旧日期的数据
- 城市决策卡支持从地图点击城市进入;机会榜和日历仍按 Pro 权限控制,地图探索和城市简报可作为轻量入口
- 城市决策卡展示结构化实况、模型区间、市场温度桶和模型-市场差,不再请求 AI 解读
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
- 账户中心支付区支持后端下发的多链网络选择;Polygon 继续走 checkout 合约,Ethereum 主网 USDC 走手动直转确认 - 账户中心支付区支持后端下发的多链网络选择;Polygon 继续走 checkout 合约,Ethereum 主网 USDC 走手动直转确认
- 缓存桶状态与 summary cache hit/miss - 缓存桶状态与 summary cache hit/miss
@@ -57,7 +46,7 @@ npm ci
npm run dev npm run dev
``` ```
## Vercel 最小部署配置 ## 最小部署配置
只跑看板和基础鉴权时,先填这 4 项: 只跑看板和基础鉴权时,先填这 4 项:
@@ -108,7 +97,7 @@ NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false
NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
``` ```
更完整的 Vercel 配置说明见: 更完整的部署配置说明见:
- [docs/FRONTEND_DEPLOYMENT_ZH.md](/E:/web/PolyWeather/docs/FRONTEND_DEPLOYMENT_ZH.md) - [docs/FRONTEND_DEPLOYMENT_ZH.md](/E:/web/PolyWeather/docs/FRONTEND_DEPLOYMENT_ZH.md)
## 路由处理器 ## 路由处理器
@@ -166,7 +155,7 @@ Ops
注意: 注意:
- `/ops` 现在是前后端双层管理员限制 - `/ops` 现在是前后端双层管理员限制
- Vercel 前端和后端都应配置相同的 `POLYWEATHER_OPS_ADMIN_EMAILS` - 前端容器和后端都应配置相同的 `POLYWEATHER_OPS_ADMIN_EMAILS`
- 前端登录邮箱本身不会自动获得管理员权限 - 前端登录邮箱本身不会自动获得管理员权限
## 支付安全补充 ## 支付安全补充
@@ -183,7 +172,7 @@ Ops
这意味着: 这意味着:
- 旧标签页风险已明显降低 - 旧标签页风险已明显降低
- 支付地址变更后,仍建议在 Vercel 上 redeploy 当前 production,并清理明显过期 deployment - 支付地址变更后,由于地址在前端镜像构建期注入,需要触发一次 `main` push(或重跑 deploy workflow)发布新镜像;浏览器侧靠 `/api/payments/config` 的运行时校验兜底
## 缓存行为 ## 缓存行为
@@ -191,15 +180,15 @@ Ops
- `summary?force_refresh=true``no-store` - `summary?force_refresh=true``no-store`
- 支付相关路由:`no-store` - 支付相关路由:`no-store`
- 当 detail 缓存只返回单模型或单日 forecast 时,前端会自动强刷完整 detail,并在补齐前显示同步提示 / 占位卡 - 当 detail 缓存只返回单模型或单日 forecast 时,前端会自动强刷完整 detail,并在补齐前显示同步提示 / 占位卡
- 今日日内分析打开时如果正在切换城市、日期或 detail 深度,弹窗会阻断旧内容点击并显示刷新锁 - detail 正在切换城市、日期或分辨率时,图表保留已有曲线并显示同步提示,避免把旧数据当作当前状态
- 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=500`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新 - 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=按可见城市数动态限制`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新
- 前端只消费 HTTP snapshot + SSE patch,不直接感知 RedisRedis Stream / SQLite event log 都由后端统一封装 - 前端只消费 HTTP snapshot + SSE patch,不直接感知 RedisRedis Stream / SQLite event log 都由后端统一封装
## Vercel 节流建议 ## 成本与节流建议
- 生产环境建议关闭 `Web Analytics` `Speed Insights` - 前端与后端以 Docker Compose 部署在同一 VPS,静态资源通过 Cloudflare 缓存(见 `next.config.mjs` `headers()` 和 CI 的 `cloudflare-cache-rules` job
- 建议把自建 `app analytics / web vitals / eager city summaries` 默认保持关闭 - 建议把自建 `app analytics / web vitals / eager city summaries` 默认保持关闭
- 如果你部署在 Vercel,可在 Firewall 中加一条 `WordPress / php scanner` 拦截规则,避免无效扫描白白触发 middleware - 如果扫描流量明显,可在 Cloudflare WAF 中加一条 `WordPress / php scanner` 拦截规则,避免无效扫描白白触发 Nginx / middleware
## AGPL 与商用边界说明 ## AGPL 与商用边界说明
+3
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Suspense } from "react";
import { I18nProvider } from "@/hooks/useI18n"; import { I18nProvider } from "@/hooks/useI18n";
import { AccountEntry } from "@/components/account/AccountEntry"; import { AccountEntry } from "@/components/account/AccountEntry";
@@ -10,7 +11,9 @@ export const metadata: Metadata = {
export default function AccountPage() { export default function AccountPage() {
return ( return (
<I18nProvider> <I18nProvider>
<Suspense fallback={null}>
<AccountEntry /> <AccountEntry />
</Suspense>
</I18nProvider> </I18nProvider>
); );
} }
+1 -4
View File
@@ -55,10 +55,7 @@ export async function POST(req: NextRequest) {
...(body ?? {}), ...(body ?? {}),
payload: { payload: {
...payload, ...payload,
cf_country: cf_country: req.headers.get("cf-ipcountry") || "",
req.headers.get("cf-ipcountry") ||
req.headers.get("x-vercel-ip-country") ||
"",
user_agent: req.headers.get("user-agent") || "", user_agent: req.headers.get("user-agent") || "",
referer_header: req.headers.get("referer") || "", referer_header: req.headers.get("referer") || "",
}, },
+11 -5
View File
@@ -92,6 +92,14 @@ function formatServerTiming(stages: AuthMeTimingStage[], totalMs: number) {
.join(", "); .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( function finishAuthMeResponse(
response: NextResponse, response: NextResponse,
timer: AuthMeTimer, timer: AuthMeTimer,
@@ -101,6 +109,7 @@ function finishAuthMeResponse(
const total = timer.totalMs(); const total = timer.totalMs();
const ownServerTiming = formatServerTiming(timer.stages, total); const ownServerTiming = formatServerTiming(timer.stages, total);
const backendServerTiming = String(extra?.backendServerTiming || "").trim(); const backendServerTiming = String(extra?.backendServerTiming || "").trim();
response.headers.set("Cache-Control", "no-store");
response.headers.set( response.headers.set(
"Server-Timing", "Server-Timing",
backendServerTiming backendServerTiming
@@ -156,10 +165,7 @@ async function trackAuthDiagnosticEvent(
response_mode: responseMode, response_mode: responseMode,
user_id: normalizedUserId || undefined, user_id: normalizedUserId || undefined,
email_domain: email?.includes("@") ? email.split("@").pop() : undefined, email_domain: email?.includes("@") ? email.split("@").pop() : undefined,
cf_country: cf_country: req.headers.get("cf-ipcountry") || "",
req.headers.get("cf-ipcountry") ||
req.headers.get("x-vercel-ip-country") ||
"",
user_agent: req.headers.get("user-agent") || "", user_agent: req.headers.get("user-agent") || "",
referer_header: req.headers.get("referer") || "", referer_header: req.headers.get("referer") || "",
captured_at: new Date().toISOString(), captured_at: new Date().toISOString(),
@@ -486,7 +492,7 @@ export async function GET(req: NextRequest) {
let res: Response; let res: Response;
try { try {
res = await timer.measure("backend_fetch", async () => res = await timer.measure("backend_fetch", async () =>
await fetch(`${API_BASE}/api/auth/me`, { await fetch(buildBackendAuthMeUrl(req), {
headers: backendAuth.headers, headers: backendAuth.headers,
cache: "no-store", cache: "no-store",
signal: controller.signal, signal: controller.signal,
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
buildJsonBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
@@ -22,10 +23,7 @@ export async function POST(req: NextRequest) {
const body = await req.text(); const body = await req.text();
const res = await fetch(`${API_BASE}/api/auth/referral/apply`, { const res = await fetch(`${API_BASE}/api/auth/referral/apply`, {
method: "POST", method: "POST",
headers: { headers: buildJsonBackendRequestHeaders(auth.headers),
...auth.headers,
"Content-Type": "application/json",
},
body, body,
cache: "no-store", cache: "no-store",
}); });
+77 -6
View File
@@ -1,6 +1,9 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy"; import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy"; import {
buildCityDetailProxyCachePolicy,
NO_STORE_CACHE_CONTROL,
} from "@/lib/proxy-cache-policy";
import { import {
createProxyTimer, createProxyTimer,
finishProxyTimedResponse, finishProxyTimedResponse,
@@ -8,9 +11,62 @@ import {
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const DETAIL_BATCH_PROXY_TIMEOUT_MS = Number( const DETAIL_BATCH_PROXY_TIMEOUT_MS = Number(
process.env.POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS || "12000", 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[]) {
const city_status = Object.fromEntries(
requestedCities.map((city) => [
city,
{
status: "proxy_timeout",
duration_ms: null,
},
]),
);
return {
cities: requestedCities,
details: {},
errors: {},
missing: requestedCities,
partial: true,
timeout: true,
diagnostics: {
version: 1,
response_source: "next_proxy_timeout",
partial: true,
partial_reason: "proxy_timeout",
requested_count: requestedCities.length,
completed_count: 0,
missing_count: requestedCities.length,
error_count: 0,
proxy_timeout_ms: DETAIL_BATCH_PROXY_TIMEOUT_MS,
city_status,
},
};
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "city_detail_batch"); const timer = createProxyTimer(req, "city_detail_batch");
if (!API_BASE) { if (!API_BASE) {
@@ -25,13 +81,14 @@ export async function GET(req: NextRequest) {
} }
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15); const requestedCities = parseRequestedCities(req);
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh);
const searchParams = new URLSearchParams({ const searchParams = new URLSearchParams({
cities: req.nextUrl.searchParams.get("cities") || "", cities: req.nextUrl.searchParams.get("cities") || "",
force_refresh: forceRefresh, force_refresh: forceRefresh,
limit: req.nextUrl.searchParams.get("limit") || "12", limit: req.nextUrl.searchParams.get("limit") || "12",
}); });
for (const key of ["market_slug", "target_date", "resolution"]) { for (const key of ["market_slug", "target_date", "resolution", "scope"]) {
const value = req.nextUrl.searchParams.get(key); const value = req.nextUrl.searchParams.get(key);
if (value) searchParams.set(key, value); if (value) searchParams.set(key, value);
} }
@@ -42,11 +99,25 @@ export async function GET(req: NextRequest) {
try { try {
return await proxyBackendJsonGet(req, { return await proxyBackendJsonGet(req, {
cacheControl: cachePolicy.responseCacheControl, cacheControl: cachePolicy.responseCacheControl,
fetchCache: cacheControlForData: (data) =>
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined, data &&
typeof data === "object" &&
(data as { partial?: unknown }).partial === true
? NO_STORE_CACHE_CONTROL
: cachePolicy.responseCacheControl,
fetchCache: "no-store",
includeSupabaseIdentity: true,
publicMessage: "Failed to fetch city detail batch", publicMessage: "Failed to fetch city detail batch",
revalidateSeconds: cachePolicy.revalidateSeconds, revalidateSeconds: cachePolicy.revalidateSeconds,
signal: controller.signal, signal: controller.signal,
timeoutResponse: () =>
NextResponse.json(buildCityDetailBatchTimeoutPayload(requestedCities), {
headers: {
"Cache-Control": NO_STORE_CACHE_CONTROL,
"Cloudflare-CDN-Cache-Control": NO_STORE_CACHE_CONTROL,
},
status: 200,
}),
timeoutPublicMessage: "City detail batch request timed out", timeoutPublicMessage: "City detail batch request timed out",
timing: timer, timing: timer,
url: `${API_BASE}/api/cities/detail-batch?${searchParams.toString()}`, url: `${API_BASE}/api/cities/detail-batch?${searchParams.toString()}`,
+6 -3
View File
@@ -1,12 +1,15 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy"; import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildCachedJsonResponse } from "@/lib/http-cache"; import { buildCachedJsonResponse } from "@/lib/http-cache";
import {
buildCityListCacheControl,
buildStaticCityListFallbackCacheControl,
} from "@/lib/proxy-cache-policy";
import { STATIC_CITY_LIST } from "@/lib/static-cities"; import { STATIC_CITY_LIST } from "@/lib/static-cities";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const CITIES_CACHE_CONTROL = "public, max-age=0, s-maxage=60, stale-while-revalidate=300"; const CITIES_CACHE_CONTROL = buildCityListCacheControl();
const STATIC_CITIES_CACHE_CONTROL = const STATIC_CITIES_CACHE_CONTROL = buildStaticCityListFallbackCacheControl();
"public, max-age=0, s-maxage=300, stale-while-revalidate=3600";
const CITIES_BACKEND_TIMEOUT_MS = Number( const CITIES_BACKEND_TIMEOUT_MS = Number(
process.env.POLYWEATHER_CITIES_BACKEND_TIMEOUT_MS || 1000, process.env.POLYWEATHER_CITIES_BACKEND_TIMEOUT_MS || 1000,
); );
+2 -2
View File
@@ -49,7 +49,7 @@ export async function GET(
const { name } = await timer.measure("route_params", () => context.params); const { name } = await timer.measure("route_params", () => context.params);
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15); const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh);
const depth = req.nextUrl.searchParams.get("depth"); const depth = req.nextUrl.searchParams.get("depth");
const marketSlug = req.nextUrl.searchParams.get("market_slug"); const marketSlug = req.nextUrl.searchParams.get("market_slug");
const targetDate = req.nextUrl.searchParams.get("target_date"); const targetDate = req.nextUrl.searchParams.get("target_date");
@@ -82,7 +82,7 @@ export async function GET(
headers: auth.headers, headers: auth.headers,
...(cachePolicy.fetchMode === "no-store" ...(cachePolicy.fetchMode === "no-store"
? { cache: "no-store" as const } ? { cache: "no-store" as const }
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }), : { next: { revalidate: cachePolicy.revalidateSeconds ?? 60 } }),
}), }),
); );
const backendServerTiming = res.headers.get("server-timing") || ""; const backendServerTiming = res.headers.get("server-timing") || "";
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { NO_STORE_CACHE_CONTROL } from "@/lib/proxy-cache-policy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(
req: NextRequest,
context: { params: Promise<{ name: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{
headers: {
"Cache-Control": NO_STORE_CACHE_CONTROL,
"Cloudflare-CDN-Cache-Control": NO_STORE_CACHE_CONTROL,
},
status: 500,
},
);
}
const { name } = await context.params;
return proxyBackendJsonGet(req, {
cacheControl: NO_STORE_CACHE_CONTROL,
fetchCache: "no-store",
includeSupabaseIdentity: false,
publicMessage: "Failed to fetch live city observation",
url: `${API_BASE}/api/city/${encodeURIComponent(name)}/observation`,
});
}
+4 -4
View File
@@ -139,7 +139,7 @@ export async function GET(
const { name } = await context.params; const { name } = await context.params;
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const depth = req.nextUrl.searchParams.get("depth") ?? "panel"; const depth = req.nextUrl.searchParams.get("depth") ?? "panel";
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15); const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh);
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}&depth=${encodeURIComponent(depth)}`; const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}&depth=${encodeURIComponent(depth)}`;
try { try {
@@ -150,7 +150,7 @@ export async function GET(
headers: auth.headers, headers: auth.headers,
...(cachePolicy.fetchMode === "no-store" ...(cachePolicy.fetchMode === "no-store"
? { cache: "no-store" as const } ? { cache: "no-store" as const }
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }), : { next: { revalidate: cachePolicy.revalidateSeconds ?? 60 } }),
}); });
if (!res.ok) { if (!res.ok) {
const raw = await res.text(); const raw = await res.text();
@@ -159,7 +159,7 @@ export async function GET(
headers: auth.headers, headers: auth.headers,
...(cachePolicy.fetchMode === "no-store" ...(cachePolicy.fetchMode === "no-store"
? { cache: "no-store" as const } ? { cache: "no-store" as const }
: { next: { revalidate: 10 } }), : { next: { revalidate: cachePolicy.revalidateSeconds ?? 60 } }),
}); });
if (summaryRes.ok) { if (summaryRes.ok) {
const summaryData = await summaryRes.json(); const summaryData = await summaryRes.json();
@@ -168,7 +168,7 @@ export async function GET(
buildFallbackCityDetail(name, depth, summaryData), buildFallbackCityDetail(name, depth, summaryData),
cachePolicy.fetchMode === "no-store" cachePolicy.fetchMode === "no-store"
? cachePolicy.responseCacheControl ? cachePolicy.responseCacheControl
: "public, max-age=0, s-maxage=10, stale-while-revalidate=30", : cachePolicy.responseCacheControl,
); );
response.headers.set("X-PolyWeather-Fallback", "summary"); response.headers.set("X-PolyWeather-Fallback", "summary");
return applyAuthResponseCookies(response, auth.response); return applyAuthResponseCookies(response, auth.response);
+132
View File
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
import {
requireTurnstileForRequest,
stripTurnstileToken,
} from "@/lib/turnstile";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
function emptyFeedbackResponse() {
return NextResponse.json(
{
feedback: [],
total: 0,
status_counts: {},
},
{
headers: {
"Cache-Control": "no-store",
},
},
);
}
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try {
auth = await buildBackendRequestHeaders(req);
if (!auth.authUserId) {
return applyAuthResponseCookies(emptyFeedbackResponse(), auth.response);
}
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.status === 401 || res.status === 403) {
return applyAuthResponseCookies(emptyFeedbackResponse(), auth.response);
}
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();
const turnstileError = await requireTurnstileForRequest(req, "feedback_submit", body);
if (turnstileError) return turnstileError;
auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/feedback`, {
method: "POST",
headers,
body: JSON.stringify(stripTurnstileToken(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;
}
}
+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/audit-log`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch ops audit log",
});
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders, buildJsonBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
@@ -28,7 +28,7 @@ export async function PUT(req: NextRequest) {
if (authError) return authError; if (authError) return authError;
const body = await req.text(); const body = await req.text();
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" }); const res = await fetch(BACKEND, { method: "PUT", headers: buildJsonBackendRequestHeaders(auth.headers), body, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response); return applyAuthResponseCookies(response, auth.response);
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(
req: NextRequest,
context: { params: Promise<{ feedbackId: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const { feedbackId } = await context.params;
const body = await req.json();
const res = await fetch(
`${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/reward`,
{
method: "POST",
cache: "no-store",
headers: {
...Object.fromEntries(new Headers(auth.headers).entries()),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
);
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to grant feedback reward",
});
}
}
@@ -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",
});
}
}
@@ -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",
});
}
}
@@ -22,8 +22,9 @@ export async function GET(req: NextRequest) {
if (authError) return authError; if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/payments/incidents`); const url = new URL(`${API_BASE}/api/ops/payments/incidents`);
const limit = req.nextUrl.searchParams.get("limit"); req.nextUrl.searchParams.forEach((value, key) => {
if (limit) url.searchParams.set("limit", limit); url.searchParams.set(key, value);
});
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
headers: auth.headers, headers: auth.headers,
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
buildJsonBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ caseId: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const resolved = await params;
const body = await req.text();
const res = await fetch(
`${API_BASE}/api/ops/refunds/${encodeURIComponent(resolved.caseId)}`,
{
method: "PATCH",
cache: "no-store",
headers: buildJsonBackendRequestHeaders(auth.headers),
body,
},
);
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to update refund case",
});
}
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
buildJsonBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/refunds`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch refund cases",
});
}
}
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
const res = await fetch(`${API_BASE}/api/ops/refunds`, {
method: "POST",
cache: "no-store",
headers: buildJsonBackendRequestHeaders(auth.headers),
body,
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to create refund case",
});
}
}
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders, buildJsonBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
@@ -28,7 +28,7 @@ export async function PUT(req: NextRequest) {
if (authError) return authError; if (authError) return authError;
const body = await req.text(); const body = await req.text();
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" }); const res = await fetch(BACKEND, { method: "PUT", headers: buildJsonBackendRequestHeaders(auth.headers), body, cache: "no-store" });
const raw = await res.text(); const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response); return applyAuthResponseCookies(response, auth.response);
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; import { applyAuthResponseCookies, buildBackendRequestHeaders, buildJsonBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
@@ -14,9 +14,9 @@ export async function POST(req: NextRequest) {
if (authError) return authError; if (authError) return authError;
const body = await req.text(); const body = await req.text();
const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" }; const headers = buildJsonBackendRequestHeaders(auth.headers);
if (ENTITLEMENT_TOKEN) { if (ENTITLEMENT_TOKEN) {
headers.Authorization = `Bearer ${ENTITLEMENT_TOKEN}`; headers.set("Authorization", `Bearer ${ENTITLEMENT_TOKEN}`);
} }
const res = await fetch(`${API_BASE}/api/ops/subscriptions/extend`, { const res = await fetch(`${API_BASE}/api/ops/subscriptions/extend`, {
method: "POST", headers, body, cache: "no-store", method: "POST", headers, body, cache: "no-store",
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
buildJsonBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
@@ -40,7 +41,7 @@ async function findSupabaseUserIdByEmail(email: string) {
.replace(/\/$/, ""); .replace(/\/$/, "");
const serviceRoleKey = String(process.env.SUPABASE_SERVICE_ROLE_KEY || "").trim(); const serviceRoleKey = String(process.env.SUPABASE_SERVICE_ROLE_KEY || "").trim();
if (!supabaseUrl || !serviceRoleKey) { if (!supabaseUrl || !serviceRoleKey) {
throw new Error("Supabase service role is not configured on Vercel"); throw new Error("Supabase service role is not configured on the server");
} }
const profileRes = await fetch( const profileRes = await fetch(
`${supabaseUrl}/rest/v1/profiles?select=id&email=eq.${encodeURIComponent(email)}&limit=1`, `${supabaseUrl}/rest/v1/profiles?select=id&email=eq.${encodeURIComponent(email)}&limit=1`,
@@ -166,7 +167,7 @@ export async function POST(req: NextRequest) {
const res = await fetch(`${API_BASE}/api/ops/subscriptions/grant`, { const res = await fetch(`${API_BASE}/api/ops/subscriptions/grant`, {
method: "POST", method: "POST",
headers: { ...(auth.headers as Record<string, string>), "Content-Type": "application/json" }, headers: buildJsonBackendRequestHeaders(auth.headers),
body, body,
cache: "no-store", cache: "no-store",
}); });
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
applyAuthResponseCookies, applyAuthResponseCookies,
buildBackendRequestHeaders, buildBackendRequestHeaders,
buildJsonBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
@@ -24,10 +25,7 @@ export async function POST(req: NextRequest) {
const body = await req.text(); const body = await req.text();
const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, { const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, {
method: "POST", method: "POST",
headers: { headers: buildJsonBackendRequestHeaders(auth.headers),
...auth.headers,
"Content-Type": "application/json",
},
body, body,
cache: "no-store", cache: "no-store",
}); });
@@ -8,9 +8,42 @@ import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
buildUpstreamErrorResponse, buildUpstreamErrorResponse,
} from "@/lib/api-proxy"; } from "@/lib/api-proxy";
import {
requireTurnstileForRequest,
stripTurnstileToken,
} from "@/lib/turnstile";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; 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( export async function POST(
req: NextRequest, req: NextRequest,
context: { params: Promise<{ intentId: string }> }, context: { params: Promise<{ intentId: string }> },
@@ -24,6 +57,10 @@ export async function POST(
const { intentId } = await context.params; const { intentId } = await context.params;
try { try {
const body = await req.json(); const body = await req.json();
if (process.env.POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT === "true") {
const turnstileError = await requireTurnstileForRequest(req, "payment_tx_submit", body);
if (turnstileError) return turnstileError;
}
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
@@ -34,20 +71,15 @@ export async function POST(
{ {
method: "POST", method: "POST",
headers: proxiedHeaders, headers: proxiedHeaders,
body: JSON.stringify(body ?? {}), body: JSON.stringify(stripTurnstileToken(body ?? {})),
cache: "no-store", cache: "no-store",
}, },
); );
if (!res.ok) { if (!res.ok) {
const raw = await res.text(); 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, { const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350, detailLimit: 350,
error: detail || undefined, error: submitErrorMessage(raw),
}); });
return applyAuthResponseCookies(response, auth.response); return applyAuthResponseCookies(response, auth.response);
} }
+7 -1
View File
@@ -9,6 +9,10 @@ import {
buildUpstreamErrorResponse, buildUpstreamErrorResponse,
} from "@/lib/api-proxy"; } from "@/lib/api-proxy";
import { isPaymentHostAllowed } from "@/lib/payment-host"; import { isPaymentHostAllowed } from "@/lib/payment-host";
import {
requireTurnstileForRequest,
stripTurnstileToken,
} from "@/lib/turnstile";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -35,6 +39,8 @@ export async function POST(req: NextRequest) {
} }
try { try {
const body = await req.json(); const body = await req.json();
const turnstileError = await requireTurnstileForRequest(req, "payment_intent_create", body);
if (turnstileError) return turnstileError;
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
@@ -43,7 +49,7 @@ export async function POST(req: NextRequest) {
const res = await fetch(`${API_BASE}/api/payments/intents`, { const res = await fetch(`${API_BASE}/api/payments/intents`, {
method: "POST", method: "POST",
headers: proxiedHeaders, headers: proxiedHeaders,
body: JSON.stringify(body ?? {}), body: JSON.stringify(stripTurnstileToken(body ?? {})),
cache: "no-store", cache: "no-store",
}); });
if (!res.ok) { if (!res.ok) {
@@ -10,6 +10,42 @@ import {
} from "@/lib/api-proxy"; } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; 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) { export async function POST(req: NextRequest) {
if (!API_BASE) { if (!API_BASE) {
@@ -18,31 +54,45 @@ export async function POST(req: NextRequest) {
{ status: 500 }, { 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 { try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, { 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", method: "POST",
headers: proxiedHeaders, headers: proxiedHeaders,
body: JSON.stringify(body ?? {}), body: JSON.stringify(body ?? {}),
cache: "no-store", cache: "no-store",
}); },
);
if (!res.ok) { if (!res.ok) {
const raw = await res.text(); const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw, { const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350, detailLimit: 350,
extraDebug: { extraDebug: authDebug,
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"),
},
}); });
return applyAuthResponseCookies(response, auth.response); return applyAuthResponseCookies(response, auth.response);
} }
@@ -50,8 +100,12 @@ export async function POST(req: NextRequest) {
const response = NextResponse.json(data); const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response); return applyAuthResponseCookies(response, auth.response);
} catch (error) { } catch (error) {
logWalletChallengeProxyError(error, authDebug);
return buildProxyExceptionResponse(error, { return buildProxyExceptionResponse(error, {
publicMessage: "Failed to create wallet challenge", status: 502,
publicMessage:
"Wallet challenge service is temporarily unavailable. Please retry.",
extra: { retryable: true },
}); });
} }
} }
@@ -10,6 +10,42 @@ import {
} from "@/lib/api-proxy"; } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND_RETRY_DELAY_MS = 250;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWalletVerifyWithRetry(
url: string,
init: RequestInit,
attempts = 2,
) {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await fetch(url, init);
} catch (error) {
lastError = error;
if (attempt >= attempts) break;
await sleep(BACKEND_RETRY_DELAY_MS * attempt);
}
}
throw lastError;
}
function logWalletVerifyProxyError(
error: unknown,
authDebug: Record<string, unknown>,
) {
const source = error as { name?: unknown; message?: unknown; cause?: unknown };
console.error("[payment-wallet-verify-proxy-exception]", {
error_name: String(source?.name || "Error"),
error_message: String(source?.message || error || "unknown"),
error_cause: source?.cause ? String(source.cause) : "",
...authDebug,
});
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
if (!API_BASE) { if (!API_BASE) {
@@ -18,31 +54,45 @@ export async function POST(req: NextRequest) {
{ status: 500 }, { status: 500 },
); );
} }
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json(
{ error: "Invalid wallet verify request" },
{ status: 400 },
);
}
let authDebug: Record<string, unknown> = {};
try { try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers); const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json"); proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, { authDebug = {
incoming_has_authorization: Boolean(
String(req.headers.get("authorization") || "").trim(),
),
has_authorization: proxiedHeaders.has("authorization"),
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
has_forwarded_user_id: proxiedHeaders.has("x-polyweather-auth-user-id"),
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
};
const res = await fetchWalletVerifyWithRetry(
`${API_BASE}/api/payments/wallets/verify`,
{
method: "POST", method: "POST",
headers: proxiedHeaders, headers: proxiedHeaders,
body: JSON.stringify(body ?? {}), body: JSON.stringify(body ?? {}),
cache: "no-store", cache: "no-store",
}); },
);
if (!res.ok) { if (!res.ok) {
const raw = await res.text(); const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw, { const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350, detailLimit: 350,
extraDebug: { extraDebug: authDebug,
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"),
},
}); });
return applyAuthResponseCookies(response, auth.response); return applyAuthResponseCookies(response, auth.response);
} }
@@ -50,8 +100,12 @@ export async function POST(req: NextRequest) {
const response = NextResponse.json(data); const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response); return applyAuthResponseCookies(response, auth.response);
} catch (error) { } catch (error) {
logWalletVerifyProxyError(error, authDebug);
return buildProxyExceptionResponse(error, { return buildProxyExceptionResponse(error, {
publicMessage: "Failed to verify wallet binding", status: 502,
publicMessage:
"Wallet verify service is temporarily unavailable. Please retry.",
extra: { retryable: true },
}); });
} }
} }
+14 -4
View File
@@ -1,6 +1,9 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy"; import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy"; import {
buildForceRefreshProxyCachePolicy,
buildScanTerminalResponseCacheControl,
} from "@/lib/proxy-cache-policy";
import { DASHBOARD_REFRESH_POLICY_SEC } from "@/lib/refresh-policy"; import { DASHBOARD_REFRESH_POLICY_SEC } from "@/lib/refresh-policy";
import { import {
createProxyTimer, createProxyTimer,
@@ -9,7 +12,7 @@ import {
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number( const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "40000", process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "35000",
); );
export const maxDuration = 45; export const maxDuration = 45;
@@ -40,6 +43,8 @@ export async function GET(req: NextRequest) {
"time_range", "time_range",
"limit", "limit",
"force_refresh", "force_refresh",
"diff",
"since_snapshot_id",
"timezone_offset_seconds", "timezone_offset_seconds",
]) { ]) {
const value = req.nextUrl.searchParams.get(key); const value = req.nextUrl.searchParams.get(key);
@@ -64,10 +69,15 @@ export async function GET(req: NextRequest) {
try { try {
return await proxyBackendJsonGet(req, { return await proxyBackendJsonGet(req, {
cacheControl: cachePolicy.responseCacheControl, cacheControl: cachePolicy.responseCacheControl,
fetchCache: cacheControlForData: (data) =>
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined, buildScanTerminalResponseCacheControl(
data,
cachePolicy.responseCacheControl,
),
fetchCache: "no-store",
publicMessage: "Failed to fetch scan terminal data", publicMessage: "Failed to fetch scan terminal data",
revalidateSeconds: cachePolicy.revalidateSeconds, revalidateSeconds: cachePolicy.revalidateSeconds,
includeSupabaseIdentity: true,
signal: controller.signal, signal: controller.signal,
timeoutPublicMessage: "Scan terminal request timed out", timeoutPublicMessage: "Scan terminal request timed out",
timing: timer, timing: timer,
+28 -6
View File
@@ -1,5 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy"; import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -11,11 +16,28 @@ export async function GET(req: NextRequest) {
); );
} }
return proxyBackendJsonGet(req, { try {
cacheControl: "public, max-age=0, s-maxage=30, stale-while-revalidate=120", const auth = await buildBackendRequestHeaders(req);
detailLimit: 500, const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/system/status`, {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Cloudflare-CDN-Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch system status", publicMessage: "Failed to fetch system status",
revalidateSeconds: 30,
url: `${API_BASE}/api/system/status`,
}); });
} }
}
+92 -4
View File
@@ -4,6 +4,7 @@ import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/
import { getConfiguredSiteUrl } from "@/lib/site-url"; import { getConfiguredSiteUrl } from "@/lib/site-url";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const TERMINAL_CHECKOUT_PATH = "/account?checkout=1";
function normalizeNextPath(input: string | null) { function normalizeNextPath(input: string | null) {
const fallback = "/"; const fallback = "/";
@@ -14,9 +15,42 @@ function normalizeNextPath(input: string | null) {
return raw; return raw;
} }
async function warmSignupTrial(accessToken: string) { 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 isTerminalNextPath(pathname: string) {
return pathname === "/terminal" || pathname.startsWith("/terminal/");
}
type AuthProfilePayload = {
subscription_active?: boolean | null;
};
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): Promise<AuthProfilePayload | null> {
const token = String(accessToken || "").trim(); const token = String(accessToken || "").trim();
if (!API_BASE || !token) return; if (!API_BASE || !token) return null;
const headers = new Headers({ const headers = new Headers({
Accept: "application/json", Accept: "application/json",
@@ -30,18 +64,42 @@ async function warmSignupTrial(accessToken: string) {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2500); const timeoutId = setTimeout(() => controller.abort(), 2500);
try { try {
await fetch(`${API_BASE}/api/auth/me`, { const response = await fetch(`${API_BASE.replace(/\/+$/, "")}/api/auth/me?scope=entitlement`, {
cache: "no-store", cache: "no-store",
headers, headers,
signal: controller.signal, signal: controller.signal,
}); });
if (!response.ok) {
return { subscription_active: false };
}
return await response.json();
} catch { } catch {
// The account/terminal bootstrap will retry. Callback must not strand login. // The account/terminal bootstrap will retry. Callback must not strand login.
return null;
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
} }
async function resolvePostAuthRedirect({
accessToken,
baseUrl,
nextPath,
}: {
accessToken: string;
baseUrl: string;
nextPath: string;
}) {
const profile = await warmSignupTrial(accessToken);
if (!isTerminalNextPath(nextPath)) {
return new URL(nextPath, baseUrl);
}
const redirectPath =
profile?.subscription_active === true ? nextPath : TERMINAL_CHECKOUT_PATH;
return new URL(redirectPath, baseUrl);
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const siteUrl = getConfiguredSiteUrl(); const siteUrl = getConfiguredSiteUrl();
if (siteUrl) { if (siteUrl) {
@@ -60,6 +118,16 @@ export async function GET(request: NextRequest) {
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next")); const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
const baseUrl = siteUrl || request.nextUrl.origin; const baseUrl = siteUrl || request.nextUrl.origin;
const redirectUrl = new URL(nextPath, baseUrl); 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()) { if (!hasSupabaseServerEnv()) {
return NextResponse.redirect(redirectUrl); return NextResponse.redirect(redirectUrl);
@@ -71,8 +139,28 @@ export async function GET(request: NextRequest) {
if (code) { if (code) {
const { const {
data: { session }, data: { session },
error: exchangeError,
} = await supabase.auth.exchangeCodeForSession(code); } = await supabase.auth.exchangeCodeForSession(code);
await warmSignupTrial(session?.access_token || ""); 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,
});
}
const finalRedirectUrl = await resolvePostAuthRedirect({
accessToken: session.access_token,
baseUrl,
nextPath,
});
response.headers.set("Location", finalRedirectUrl.toString());
} }
return response; return response;
+15 -2
View File
@@ -2,7 +2,7 @@ import { LoginClient } from "@/components/auth/LoginClient";
import { I18nProvider } from "@/hooks/useI18n"; import { I18nProvider } from "@/hooks/useI18n";
type PageProps = { type PageProps = {
searchParams?: Promise<{ next?: string; mode?: string }>; searchParams?: Promise<{ error?: string; mode?: string; next?: string }>;
}; };
function normalizeNextPath(input: string | undefined) { function normalizeNextPath(input: string | undefined) {
@@ -19,13 +19,26 @@ function normalizeMode(input: string | undefined): "login" | "signup" {
return "login"; 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) { export default async function LoginPage({ searchParams }: PageProps) {
const params = (await searchParams) || {}; const params = (await searchParams) || {};
const nextPath = normalizeNextPath(params.next); const nextPath = normalizeNextPath(params.next);
const initialMode = normalizeMode(params.mode); const initialMode = normalizeMode(params.mode);
const initialError = normalizeAuthError(params.error);
return ( return (
<I18nProvider> <I18nProvider>
<LoginClient nextPath={nextPath} initialMode={initialMode} /> <LoginClient
nextPath={nextPath}
initialMode={initialMode}
initialError={initialError}
/>
</I18nProvider> </I18nProvider>
); );
} }
+129
View File
@@ -0,0 +1,129 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { BriefDetailPageView } from "@/components/public-content/PublicContentPages";
import {
PUBLIC_BRIEFS,
absolutePublicUrl,
briefPath,
getBrief,
} from "@/content/public-content";
type BriefPageParams = {
city: string;
date: string;
};
export function generateStaticParams() {
return PUBLIC_BRIEFS.map((brief) => ({
city: brief.city,
date: brief.date,
}));
}
export async function generateMetadata({
params,
}: {
params: Promise<BriefPageParams>;
}): Promise<Metadata> {
const { city, date } = await params;
const brief = getBrief(city, date);
if (!brief) {
return {
title: "Brief not found",
};
}
const pathname = briefPath(brief);
return {
title: brief.title,
description: brief.description,
alternates: {
canonical: pathname,
},
openGraph: {
type: "article",
title: brief.title,
description: brief.description,
url: pathname,
publishedTime: brief.publishedAt,
modifiedTime: brief.updatedAt,
},
twitter: {
card: "summary",
title: brief.title,
description: brief.description,
},
};
}
export default async function BriefDetailPage({
params,
}: {
params: Promise<BriefPageParams>;
}) {
const { city, date } = await params;
const brief = getBrief(city, date);
if (!brief) {
notFound();
}
const pathname = briefPath(brief);
const jsonLd = [
{
"@context": "https://schema.org",
"@type": "Article",
headline: brief.title,
description: brief.description,
datePublished: brief.publishedAt,
dateModified: brief.updatedAt,
mainEntityOfPage: absolutePublicUrl(pathname),
author: {
"@type": "Organization",
name: "PolyWeather",
url: "https://polyweather.top",
},
publisher: {
"@type": "Organization",
name: "PolyWeather",
url: "https://polyweather.top",
},
about: [
brief.cityName,
brief.market,
brief.settlementSource,
"DEB forecast methodology",
],
},
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Briefs",
item: absolutePublicUrl("/briefs"),
},
{
"@type": "ListItem",
position: 2,
name: brief.cityName,
item: absolutePublicUrl(pathname),
},
],
},
];
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<BriefDetailPageView brief={brief} />
</>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { BriefsIndexPageView } from "@/components/public-content/PublicContentPages";
export const metadata: Metadata = {
title: "Weather Market Brief",
description:
"Public PolyWeather market briefs for temperature judgment, settlement-source checks, DEB context, and source freshness notes.",
alternates: {
canonical: "/briefs",
},
openGraph: {
title: "Weather Market Brief | PolyWeather",
description:
"Public market briefs for temperature judgment, settlement-source checks, DEB context, and source freshness notes.",
url: "/briefs",
},
};
export default function BriefsPage() {
return <BriefsIndexPageView />;
}
+127
View File
@@ -285,6 +285,133 @@
0%, 100% { opacity: 0.3; } 0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; } 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 ── */ /* ── Reduced motion: disable all animations and transitions ── */
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google"; import { Inter, JetBrains_Mono } from "next/font/google";
import { RegisterSW } from "@/components/dashboard/RegisterSW"; import { RegisterSW } from "@/components/dashboard/RegisterSW";
import { MicrosoftClarity } from "@/components/observability/MicrosoftClarity";
import "./globals.css"; import "./globals.css";
const inter = Inter({ const inter = Inter({
@@ -89,6 +90,7 @@ export default function RootLayout({
</a> </a>
<main id="main-content">{children}</main> <main id="main-content">{children}</main>
<RegisterSW /> <RegisterSW />
<MicrosoftClarity />
</body> </body>
</html> </html>
); );
+85
View File
@@ -0,0 +1,85 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { MethodologyDetailPageView } from "@/components/public-content/PublicContentPages";
import {
METHODOLOGY_PAGES,
absolutePublicUrl,
getMethodologyPage,
methodologyPath,
} from "@/content/public-content";
type MethodologyPageParams = {
slug: string;
};
export function generateStaticParams() {
return METHODOLOGY_PAGES.map((page) => ({ slug: page.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<MethodologyPageParams>;
}): Promise<Metadata> {
const { slug } = await params;
const page = getMethodologyPage(slug);
if (!page) {
return {
title: "Methodology not found",
};
}
return {
title: page.title,
description: page.description,
alternates: {
canonical: methodologyPath(page),
},
openGraph: {
type: "article",
title: page.title,
description: page.description,
url: methodologyPath(page),
modifiedTime: page.updatedAt,
},
};
}
export default async function MethodologyDetailPage({
params,
}: {
params: Promise<MethodologyPageParams>;
}) {
const { slug } = await params;
const page = getMethodologyPage(slug);
if (!page) {
notFound();
}
const pathname = methodologyPath(page);
const jsonLd = {
"@context": "https://schema.org",
"@type": "TechArticle",
headline: page.title,
description: page.description,
dateModified: page.updatedAt,
mainEntityOfPage: absolutePublicUrl(pathname),
author: {
"@type": "Organization",
name: "PolyWeather",
url: "https://polyweather.top",
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<MethodologyDetailPageView page={page} />
</>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { MethodologyIndexPageView } from "@/components/public-content/PublicContentPages";
export const metadata: Metadata = {
title: "Methodology",
description:
"PolyWeather public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.",
alternates: {
canonical: "/methodology",
},
openGraph: {
title: "Methodology | PolyWeather",
description:
"Public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.",
url: "/methodology",
},
};
export default function MethodologyPage() {
return <MethodologyIndexPageView />;
}
+5
View File
@@ -0,0 +1,5 @@
import { AuditLogPageClient } from "@/components/ops/audit/AuditLogPageClient";
export default function OpsAuditLogPage() {
return <AuditLogPageClient />;
}
+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 />;
}
+4 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLandingPage"; import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLandingPage";
import { LANDING_LOCALE_QUERY_PARAM } from "@/components/landing/landingLocale";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PolyWeather | Institutional Weather Signal Intelligence", title: "PolyWeather | Institutional Weather Signal Intelligence",
@@ -32,6 +33,8 @@ export default async function HomePage({
const qs = usp.toString(); const qs = usp.toString();
redirect(`/auth/callback${qs ? `?${qs}` : ""}`); redirect(`/auth/callback${qs ? `?${qs}` : ""}`);
} }
const rawLandingLocale = params[LANDING_LOCALE_QUERY_PARAM];
const landingLocale = Array.isArray(rawLandingLocale) ? rawLandingLocale[0] : rawLandingLocale;
const jsonLd = { const jsonLd = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebApplication", "@type": "WebApplication",
@@ -68,7 +71,7 @@ export default async function HomePage({
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
<InstitutionalLandingPage /> <InstitutionalLandingPage queryLocale={landingLocale} />
</> </>
); );
} }
+44 -2
View File
@@ -1,20 +1,62 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import {
METHODOLOGY_PAGES,
PUBLIC_BRIEFS,
SOURCE_PAGES,
} from "@/content/public-content";
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = "https://polyweather.top"; const baseUrl = "https://polyweather.top";
const now = new Date();
return [ return [
{ {
url: baseUrl, url: baseUrl,
lastModified: new Date(), lastModified: now,
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 1.0, priority: 1.0,
}, },
{ {
url: `${baseUrl}/auth/login`, url: `${baseUrl}/auth/login`,
lastModified: new Date(), lastModified: now,
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.6, priority: 0.6,
}, },
{
url: `${baseUrl}/briefs`,
lastModified: now,
changeFrequency: "weekly",
priority: 0.9,
},
{
url: `${baseUrl}/methodology`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${baseUrl}/sources`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
...PUBLIC_BRIEFS.map((brief) => ({
url: `${baseUrl}/briefs/${brief.city}/${brief.date}`,
lastModified: new Date(brief.updatedAt),
changeFrequency: "daily" as const,
priority: 0.85,
})),
...METHODOLOGY_PAGES.map((page) => ({
url: `${baseUrl}/methodology/${page.slug}`,
lastModified: new Date(page.updatedAt),
changeFrequency: "monthly" as const,
priority: 0.75,
})),
...SOURCE_PAGES.map((source) => ({
url: `${baseUrl}/sources/${source.slug}`,
lastModified: new Date(source.updatedAt),
changeFrequency: "monthly" as const,
priority: 0.7,
})),
]; ];
} }
+89
View File
@@ -0,0 +1,89 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { SourceDetailPageView } from "@/components/public-content/PublicContentPages";
import {
SOURCE_PAGES,
absolutePublicUrl,
getSourcePage,
sourcePath,
} from "@/content/public-content";
type SourcePageParams = {
slug: string;
};
export function generateStaticParams() {
return SOURCE_PAGES.map((source) => ({ slug: source.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<SourcePageParams>;
}): Promise<Metadata> {
const { slug } = await params;
const source = getSourcePage(slug);
if (!source) {
return {
title: "Source not found",
};
}
return {
title: source.title,
description: source.description,
alternates: {
canonical: sourcePath(source),
},
openGraph: {
type: "article",
title: source.title,
description: source.description,
url: sourcePath(source),
modifiedTime: source.updatedAt,
},
};
}
export default async function SourceDetailPage({
params,
}: {
params: Promise<SourcePageParams>;
}) {
const { slug } = await params;
const source = getSourcePage(slug);
if (!source) {
notFound();
}
const pathname = sourcePath(source);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Dataset",
name: source.title,
description: source.description,
url: absolutePublicUrl(pathname),
dateModified: source.updatedAt,
creator: {
"@type": "Organization",
name: source.operator,
},
includedInDataCatalog: {
"@type": "DataCatalog",
name: "PolyWeather source notes",
url: absolutePublicUrl("/sources"),
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<SourceDetailPageView source={source} />
</>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { SourcesIndexPageView } from "@/components/public-content/PublicContentPages";
export const metadata: Metadata = {
title: "Weather Sources",
description:
"PolyWeather public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.",
alternates: {
canonical: "/sources",
},
openGraph: {
title: "Weather Sources | PolyWeather",
description:
"Public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.",
url: "/sources",
},
};
export default function SourcesPage() {
return <SourcesIndexPageView />;
}
@@ -16,8 +16,8 @@ const FAQ_ITEMS = [
{ {
q_zh: "Pro 包含哪些功能?", q_zh: "Pro 包含哪些功能?",
q_en: "What features does Pro include?", q_en: "What features does Pro include?",
a_zh: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、未来日期分析、城市决策卡、全平台智能气象推送。", a_zh: "开通后可解锁:结算源优先终端、多城市图表巡检、未来日期分析、Telegram 缓存推送。",
a_en: "Unlocks: intraday METAR rule analysis (including peak window), future-date analysis, city decision cards, and cross-platform smart weather push.", a_en: "Unlocks: settlement-source-first terminal, multi-city chart monitoring, future-date analysis, and Telegram cached alerts.",
}, },
{ {
q_zh: "当前订阅价格是多少?", q_zh: "当前订阅价格是多少?",
+1 -1
View File
@@ -15,7 +15,7 @@ const ScanTerminalDashboard = dynamic(
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PolyWeather Terminal | Paid Product", title: "PolyWeather Terminal | Paid Product",
description: 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() { export default function TerminalPage() {
+219 -70
View File
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import type { User } from "@supabase/supabase-js"; import type { User } from "@supabase/supabase-js";
import { import {
User as UserIcon, User as UserIcon,
@@ -41,17 +41,17 @@ import {
} from "@/lib/supabase/client"; } from "@/lib/supabase/client";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics"; import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
import { useI18n } from "@/hooks/useI18n"; import { useI18n } from "@/hooks/useI18n";
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
import type { AuthMeResponse } from "./types"; import type { AuthMeResponse } from "./types";
import { import {
SUBSCRIPTION_HELP_HREF,
TELEGRAM_BOT_URL, TELEGRAM_BOT_URL,
TELEGRAM_GROUP_URL, TELEGRAM_GROUP_URL,
TELEGRAM_TOPICS_GROUP_URL, TELEGRAM_TOPICS_GROUP_URL,
WALLETCONNECT_PROJECT_ID, WALLETCONNECT_PROJECT_ID,
} from "./constants"; } from "./constants";
import { InfoRow, PlusIcon } from "./AccountInfoRow"; import { InfoRow, PlusIcon } from "./AccountInfoRow";
import { AccountFeedbackPanel } from "./AccountFeedbackPanel";
import { TurnstileWidget } from "@/components/security/TurnstileWidget";
import { import {
chainIdToDisplayName, chainIdToDisplayName,
clearStoredPaymentRecovery, clearStoredPaymentRecovery,
@@ -62,11 +62,28 @@ import {
import { createAccountCopy } from "./account-copy"; import { createAccountCopy } from "./account-copy";
import { resetWalletConnectProvider } from "./wallet"; import { resetWalletConnectProvider } from "./wallet";
import { useAccountPayment } from "./useAccountPayment"; import { useAccountPayment } from "./useAccountPayment";
import {
buildTrialValueReplaySummary,
readTrialValueReplay,
} from "@/lib/trial-value-replay";
import { getTurnstileTokenForAction } from "@/lib/turnstile-client";
// --- Main Component --- // --- Main Component ---
function pointSourceLabel(source?: string, isEn = false) {
const key = String(source || "").trim().toLowerCase();
if (key === "feedback_reward") return isEn ? "Feedback reward" : "反馈奖励";
if (key === "ops_manual_grant") return isEn ? "Ops manual grant" : "后台补发";
if (key === "paid_referral") return isEn ? "Paid referral" : "有效付费邀请";
if (key === "growth_milestone_reward") return isEn ? "Growth reward" : "增长奖励";
if (key === "points_redemption") return isEn ? "Payment redemption" : "支付抵扣";
if (key === "ops_subscription_deduction") return isEn ? "Ops deduction" : "后台订阅扣分";
return key || (isEn ? "Unknown source" : "未知来源");
}
export function AccountCenter() { export function AccountCenter() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const { locale } = useI18n(); const { locale } = useI18n();
const isEn = locale === "en-US"; const isEn = locale === "en-US";
const copy = useMemo(() => createAccountCopy(isEn), [isEn]); const copy = useMemo(() => createAccountCopy(isEn), [isEn]);
@@ -76,9 +93,9 @@ export function AccountCenter() {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [showSecondarySections, setShowSecondarySections] = useState(false); const [showSecondarySections, setShowSecondarySections] = useState(false);
const [trialValueReplay, setTrialValueReplay] = useState(() => readTrialValueReplay());
// ── Shared state (declared in component, written by hook via setters) ─ // ── Shared state (declared in component, written by hook via setters) ─
const [showOverlay, setShowOverlay] = useState(false);
const [usePoints, setUsePoints] = useState(true); const [usePoints, setUsePoints] = useState(true);
const [errorText, setErrorText] = useState(""); const [errorText, setErrorText] = useState("");
const [updatedAt, setUpdatedAt] = useState<string>(""); const [updatedAt, setUpdatedAt] = useState<string>("");
@@ -86,9 +103,25 @@ export function AccountCenter() {
const [backend, setBackend] = useState<AuthMeResponse | null>(null); const [backend, setBackend] = useState<AuthMeResponse | null>(null);
const [referralCodeInput, setReferralCodeInput] = useState(""); const [referralCodeInput, setReferralCodeInput] = useState("");
const [referralApplying, setReferralApplying] = useState(false); const [referralApplying, setReferralApplying] = useState(false);
const [paymentTurnstileToken, setPaymentTurnstileToken] = useState("");
const [paymentTurnstileResetKey, setPaymentTurnstileResetKey] = useState(0);
const supabaseReady = hasSupabasePublicEnv(); const supabaseReady = hasSupabasePublicEnv();
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID); const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
const resetPaymentTurnstile = useCallback(() => {
setPaymentTurnstileToken("");
setPaymentTurnstileResetKey((value) => value + 1);
}, []);
const getPaymentTurnstileToken = useCallback(
(
action: "payment_intent_create" | "payment_tx_submit",
options?: { optional?: boolean },
) => {
if (options?.optional && !paymentTurnstileToken) return undefined;
return getTurnstileTokenForAction(paymentTurnstileToken, action);
},
[paymentTurnstileToken],
);
// ── Hook ──────────────────────────────────────────────── // ── Hook ────────────────────────────────────────────────
const { const {
@@ -97,10 +130,10 @@ export function AccountCenter() {
paymentInfo, paymentInfo,
paymentError, paymentError,
lastIntentId, lastIntentId,
lastTxHash,
lastPaymentStartedAt, lastPaymentStartedAt,
telegramBindOpening, telegramBindOpening,
telegramBindUrl, telegramBindUrl,
telegramBindCommand,
manualPayment, manualPayment,
manualTxHash, manualTxHash,
txValidation, txValidation,
@@ -150,7 +183,6 @@ export function AccountCenter() {
allowedPaymentHosts, allowedPaymentHosts,
currentPaymentHost, currentPaymentHost,
paymentHostAllowed, paymentHostAllowed,
selectedPlan,
selectedPaymentToken, selectedPaymentToken,
selectedTokenLabel, selectedTokenLabel,
availableTokenList, availableTokenList,
@@ -160,7 +192,6 @@ export function AccountCenter() {
resolvedSelectedTokenAddress, resolvedSelectedTokenAddress,
paymentReceiverAddress, paymentReceiverAddress,
paymentWalletLabel, paymentWalletLabel,
hasPayingWallet,
totalPoints, totalPoints,
billing, billing,
@@ -169,11 +200,10 @@ export function AccountCenter() {
loadPaymentSnapshot, loadPaymentSnapshot,
connectAndBindWallet, connectAndBindWallet,
handleUnbindWallet, handleUnbindWallet,
createIntentAndPay,
createManualPaymentIntent, createManualPaymentIntent,
submitManualPaymentTx, submitManualPaymentTx,
validateTxHash, validateTxHash,
handleOverlayCheckout, createTelegramBotBindCommand,
openTelegramBotBindLink, openTelegramBotBindLink,
} = useAccountPayment({ } = useAccountPayment({
isEn, isEn,
@@ -186,10 +216,10 @@ export function AccountCenter() {
setBackend, setBackend,
setErrorText, setErrorText,
setUpdatedAt, setUpdatedAt,
showOverlay,
setShowOverlay,
usePoints, usePoints,
setUsePoints, setUsePoints,
getPaymentTurnstileToken,
resetPaymentTurnstile,
}); });
// ── Auth analytics effect ────────────────────────────── // ── Auth analytics effect ──────────────────────────────
@@ -398,10 +428,10 @@ export function AccountCenter() {
!isSubscribed && expiryInfo && expiryInfo.expired, !isSubscribed && expiryInfo && expiryInfo.expired,
); );
const paymentFeatureReady = paymentReadyForRecovery; const paymentFeatureReady = paymentReadyForRecovery;
const canOpenCheckoutOverlay = Boolean( const canTrialUpgrade = Boolean(isSubscribed && isTrialSubscription);
const canStartPayment = Boolean(
paymentFeatureReady && paymentFeatureReady &&
!isSubscriptionUnknown && (canTrialUpgrade || !isSubscribed || showExpiringSoon || showExpiredReminder),
(!isSubscribed || showExpiringSoon || showExpiredReminder),
); );
const subscriptionStatusTitle = showExpiredReminder const subscriptionStatusTitle = showExpiredReminder
? copy.proExpiredTitle ? copy.proExpiredTitle
@@ -432,6 +462,14 @@ export function AccountCenter() {
{ plan_code: "pro_monthly", plan_id: 101, amount_usdc: "29.9", duration_days: 30 }, { 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 }, { plan_code: "pro_quarterly", plan_id: 102, amount_usdc: "79.9", duration_days: 90 },
]; ];
const manualTxHashReady = /^0x[a-fA-F0-9]{64}$/.test(manualTxHash.trim());
const trialValueReplaySummary = useMemo(
() => buildTrialValueReplaySummary(trialValueReplay, {
isEn,
trialExpiresAt: displayExpiryRaw,
}),
[displayExpiryRaw, isEn, trialValueReplay],
);
const referral = backend?.referral; const referral = backend?.referral;
const referralCode = String(referral?.code || "").trim(); const referralCode = String(referral?.code || "").trim();
const appliedReferralCode = String(referral?.applied_code || "").trim(); const appliedReferralCode = String(referral?.applied_code || "").trim();
@@ -442,9 +480,7 @@ export function AccountCenter() {
referralCodeInput.trim(), referralCodeInput.trim(),
); );
// ── Payment overlay tracking effect ────────────────────── const focusPaymentManagement = useCallback(() => {
useEffect(() => {
if (!showOverlay || !canOpenCheckoutOverlay) return;
trackAppEvent("paywall_viewed", { trackAppEvent("paywall_viewed", {
entry: "account_center", entry: "account_center",
user_state: isAuthenticated ? "logged_in" : "guest", user_state: isAuthenticated ? "logged_in" : "guest",
@@ -452,15 +488,39 @@ export function AccountCenter() {
expiring_soon: showExpiringSoon, expiring_soon: showExpiringSoon,
subscription_plan_code: planCode || null, subscription_plan_code: planCode || null,
}); });
const paymentSection = document.getElementById("payment-management");
paymentSection?.scrollIntoView({ behavior: "smooth", block: "start" });
}, [ }, [
isAuthenticated, isAuthenticated,
canOpenCheckoutOverlay,
planCode, planCode,
showExpiredReminder, showExpiredReminder,
showExpiringSoon, showExpiringSoon,
showOverlay,
]); ]);
useEffect(() => {
if (searchParams.get("checkout") === "1") {
window.setTimeout(focusPaymentManagement, 0);
}
}, [focusPaymentManagement, searchParams]);
useEffect(() => {
if (!isTrialSubscription) return;
setTrialValueReplay(readTrialValueReplay());
}, [authUserId, isTrialSubscription]);
const openTrialValueReplayCheckout = useCallback(() => {
trackAppEvent("paywall_feature_clicked", {
entry: "account_center",
feature: "trial_value_replay_upgrade",
user_id: authUserId || null,
replay_cities: trialValueReplay.citiesViewed.length,
replay_terminal_visits: trialValueReplay.terminalVisits,
replay_rows_available: trialValueReplay.rowsAvailableMax,
subscription_plan_code: planCode || null,
});
focusPaymentManagement();
}, [authUserId, focusPaymentManagement, planCode, trialValueReplay]);
// ── Referral points display ──────────────────────────── // ── Referral points display ────────────────────────────
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500); const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
const referralRewardPoints = Number.isFinite(referralRewardPointsRaw) const referralRewardPoints = Number.isFinite(referralRewardPointsRaw)
@@ -486,11 +546,12 @@ export function AccountCenter() {
const monthlyReferralPointsLimit = Number.isFinite(monthlyReferralPointsLimitRaw) const monthlyReferralPointsLimit = Number.isFinite(monthlyReferralPointsLimitRaw)
? Math.max(0, monthlyReferralPointsLimitRaw) ? Math.max(0, monthlyReferralPointsLimitRaw)
: monthlyReferralLimit * referralRewardPoints; : monthlyReferralLimit * referralRewardPoints;
const pointsLedger = backend?.points_ledger;
const pointSourceRows = Object.entries(pointsLedger?.by_source ?? {});
const recentPointEvents = pointsLedger?.recent ?? [];
// ── Telegram bind command ────────────────────────────── // ── Telegram bind command ──────────────────────────────
const bindCommand = userId const bindCommand = telegramBindCommand || copy.telegramBindCommandPlaceholder;
? `/bind ${userId}${email ? ` ${email}` : ""}`
: "/bind <supabase_user_id> <email>";
// ── Copy handler ────────────────────────────────────── // ── Copy handler ──────────────────────────────────────
const handleCopy = (text: string) => { const handleCopy = (text: string) => {
@@ -500,6 +561,13 @@ export function AccountCenter() {
}); });
}; };
const handleCopyTelegramBindCommand = async () => {
if (!isAuthenticated || telegramBindOpening) return;
const command = await createTelegramBotBindCommand();
if (!command) return;
handleCopy(command);
};
const applyReferralCode = useCallback(async () => { const applyReferralCode = useCallback(async () => {
const code = referralCodeInput.trim(); const code = referralCodeInput.trim();
if (!code || referralApplying) return; if (!code || referralApplying) return;
@@ -587,9 +655,10 @@ export function AccountCenter() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!showOverlay && canOpenCheckoutOverlay && ( {canStartPayment && (
<button <button
onClick={() => setShowOverlay(true)} type="button"
onClick={focusPaymentManagement}
className="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-700 transition-all hover:bg-amber-100" className="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-700 transition-all hover:bg-amber-100"
> >
<Crown size={16} />{" "} <Crown size={16} />{" "}
@@ -620,7 +689,7 @@ export function AccountCenter() {
</button> </button>
) : ( ) : (
<Link <Link
href="/auth/login?next=%2Faccount" href="/auth/login?next=%2Faccount%3Fcheckout%3D1"
className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-sm font-semibold text-blue-700 transition-all hover:bg-blue-100" className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-sm font-semibold text-blue-700 transition-all hover:bg-blue-100"
> >
<LogIn size={16} /> {copy.signIn} <LogIn size={16} /> {copy.signIn}
@@ -655,7 +724,7 @@ export function AccountCenter() {
</div> </div>
<button <button
type="button" type="button"
onClick={() => setShowOverlay(true)} onClick={focusPaymentManagement}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-800 transition-all hover:bg-amber-100" className="inline-flex items-center justify-center gap-2 rounded-lg border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-800 transition-all hover:bg-amber-100"
> >
<Crown size={16} /> <Crown size={16} />
@@ -665,6 +734,45 @@ export function AccountCenter() {
</div> </div>
)} )}
{isTrialSubscription && canStartPayment && (
<section className="lg:col-span-12 overflow-hidden rounded-2xl border border-amber-200 bg-white shadow-sm">
<div className="grid gap-0 md:grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0 p-6">
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs font-black uppercase text-amber-700">
<Sparkles size={15} />
<span>{isEn ? "Trial value replay" : "试用价值回放"}</span>
{trialValueReplaySummary.hasUsageEvidence ? (
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] text-amber-800">
{isEn ? "Based on your trial" : "基于你的试用"}
</span>
) : null}
</div>
<p className="max-w-3xl text-base font-bold leading-7 text-slate-950">
{trialValueReplaySummary.headline}
</p>
<div className="mt-4 grid gap-2 text-sm text-slate-600 md:grid-cols-3">
{trialValueReplaySummary.bullets.map((bullet) => (
<div key={bullet} className="flex min-w-0 items-start gap-2">
<CheckCircle2 size={15} className="mt-0.5 shrink-0 text-emerald-500" />
<span className="min-w-0 leading-5">{bullet}</span>
</div>
))}
</div>
</div>
<div className="flex items-center border-t border-amber-100 bg-amber-50 p-6 md:border-l md:border-t-0">
<button
type="button"
onClick={openTrialValueReplayCheckout}
className="inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-xl bg-amber-600 px-5 py-3 text-sm font-black text-white shadow-sm transition hover:bg-amber-700 md:w-auto"
>
<Crown size={16} />
{trialValueReplaySummary.primaryCta}
</button>
</div>
</div>
</section>
)}
{/* User Card */} {/* User Card */}
<div className="flex flex-col items-center gap-8 rounded-2xl border border-slate-200 bg-white p-8 shadow-sm lg:col-span-8 md:flex-row"> <div className="flex flex-col items-center gap-8 rounded-2xl border border-slate-200 bg-white p-8 shadow-sm lg:col-span-8 md:flex-row">
<div className="relative"> <div className="relative">
@@ -803,10 +911,68 @@ export function AccountCenter() {
</div> </div>
)} )}
<section className="lg:col-span-12 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<h3 className="flex items-center gap-2 text-lg font-bold text-slate-950">
<Coins size={20} className="text-yellow-500" />
{isEn ? "Point Sources" : "积分来源"}
</h3>
<span className="text-xs font-semibold text-slate-500">
{isEn ? "Current balance" : "当前余额"} {Number(pointsLedger?.balance ?? totalPoints).toLocaleString()}
</span>
</div>
{pointSourceRows.length === 0 && recentPointEvents.length === 0 ? (
<p className="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-500">
{copy.pointsRule}
</p>
) : (
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div className="grid gap-2 sm:grid-cols-2">
{pointSourceRows.map(([source, item]) => (
<div key={source} className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="text-xs font-bold text-slate-600">
{pointSourceLabel(source, isEn)}
</div>
<div className="mt-1 text-lg font-black text-slate-950">
{Number(item?.points ?? 0).toLocaleString()}
</div>
<div className="mt-0.5 text-[11px] text-slate-500">
{Number(item?.count ?? 0).toLocaleString()} {isEn ? "events" : "笔记录"} · {source}
</div>
</div>
))}
</div>
<div className="rounded-xl border border-slate-200">
<div className="border-b border-slate-200 px-4 py-2 text-xs font-bold uppercase text-slate-500">
{isEn ? "Recent point ledger" : "最近积分流水"}
</div>
<div className="divide-y divide-slate-100">
{recentPointEvents.slice(0, 5).map((event, index) => (
<div key={`${event.id ?? index}-${event.source}`} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-2 text-sm">
<div className="min-w-0">
<div className="truncate font-semibold text-slate-800">
{pointSourceLabel(event.source, isEn)}
</div>
<div className="mt-0.5 truncate text-[11px] text-slate-500">
{event.reference_type || "ledger"} · {event.created_at ? formatTime(event.created_at, locale) : "--"}
</div>
</div>
<div className={`font-mono font-bold ${Number(event.delta_points ?? 0) >= 0 ? "text-emerald-600" : "text-red-600"}`}>
{Number(event.delta_points ?? 0) >= 0 ? "+" : ""}
{Number(event.delta_points ?? 0).toLocaleString()}
</div>
</div>
))}
</div>
</div>
</div>
)}
</section>
{/* Subscription Info & Paywall */} {/* Subscription Info & Paywall */}
<div className="lg:col-span-12 relative"> <div className="lg:col-span-12 relative">
<div <div
className={`grid grid-cols-1 md:grid-cols-2 gap-6 transition-all duration-700 ${canOpenCheckoutOverlay && showOverlay ? "blur-md grayscale-[0.3] opacity-30 select-none pointer-events-none" : ""}`} className="grid grid-cols-1 md:grid-cols-2 gap-6 transition-all duration-700"
> >
<section className="space-y-3 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"> <section className="space-y-3 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h3 className="mb-4 text-sm font-bold uppercase text-blue-700"> <h3 className="mb-4 text-sm font-bold uppercase text-blue-700">
@@ -897,41 +1063,14 @@ export function AccountCenter() {
) : null} ) : null}
</section> </section>
</div> </div>
</div>
{/* Paywall Mask */} <AccountFeedbackPanel
{canOpenCheckoutOverlay && showOverlay && ( isEn={isEn}
<div className="absolute inset-0 z-30 flex items-center justify-center p-4"> title={copy.accountFeedbackTitle}
<UnlockProOverlay description={copy.accountFeedbackDescription}
points={totalPoints} refreshLabel={copy.refresh}
planPriceUsd={billing.planAmount}
usePoints={usePoints}
onToggleUsePoints={() => setUsePoints((prev) => !prev)}
billing={{
pointsEnabled: billing.pointsEnabled,
isEligible: billing.canRedeem,
pointsUsed: billing.pointsUsed,
discountAmount: billing.discountAmount,
finalPrice: billing.payAmount,
maxDiscountUsd: billing.maxDiscountUsdc,
pointsPerUsd: billing.pointsPerUsdc,
}}
onPay={() => void handleOverlayCheckout()}
onManualPay={() => void createManualPaymentIntent()}
onClose={() => setShowOverlay(false)}
payBusy={paymentBusy}
payLabel={hasPayingWallet ? copy.payNow : copy.connectAndPay}
manualPayLabel="手动转账"
errorText={paymentError || undefined}
infoText={paymentInfo || undefined}
txHash={lastTxHash || undefined}
chainId={selectedPaymentChainId || paymentConfig?.chain_id || 137}
paymentTokenLabel={selectedTokenLabel}
faqHref={SUBSCRIPTION_HELP_HREF}
telegramGroupUrl=""
/> />
</div>
)}
</div>
{/* Telegram Bot Section & Payment Details */} {/* Telegram Bot Section & Payment Details */}
{showSecondarySections ? ( {showSecondarySections ? (
@@ -955,8 +1094,8 @@ export function AccountCenter() {
</p> </p>
<button <button
type="button" type="button"
onClick={() => setShowOverlay(true)} onClick={focusPaymentManagement}
disabled={!canOpenCheckoutOverlay} disabled={!canStartPayment}
className="mt-5 inline-flex items-center gap-2 rounded-xl border border-amber-700 bg-amber-600 px-4 py-3 text-xs font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50" 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} /> <Crown size={14} />
@@ -1021,8 +1160,9 @@ export function AccountCenter() {
: copy.telegramBotBindLink} : copy.telegramBotBindLink}
</button> </button>
<button <button
onClick={() => handleCopy(bindCommand)} onClick={() => void handleCopyTelegramBindCommand()}
className="rounded-xl border border-blue-700 bg-blue-600 p-4 text-white shadow-sm transition-all hover:bg-blue-700" disabled={telegramBindOpening || !isAuthenticated}
className="rounded-xl border border-blue-700 bg-blue-600 p-4 text-white shadow-sm transition-all hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
title={copy.copyCommand} title={copy.copyCommand}
aria-label={copy.copyCommand} aria-label={copy.copyCommand}
> >
@@ -1079,6 +1219,7 @@ export function AccountCenter() {
</div> </div>
) : null} ) : null}
<div <div
id="payment-management"
data-testid="payment-management-grid" data-testid="payment-management-grid"
className={`grid gap-6 lg:items-start ${ className={`grid gap-6 lg:items-start ${
hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]" hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"
@@ -1108,7 +1249,9 @@ export function AccountCenter() {
> >
<div className="flex items-center justify-between gap-2 text-xs font-bold"> <div className="flex items-center justify-between gap-2 text-xs font-bold">
<span> <span>
{isQuarterly ? copy.quarterlyPlan : copy.monthlyPlan} {isQuarterly
? copy.quarterlyPlan
: copy.monthlyPlan}
</span> </span>
<span>{plan.amount_usdc} USDC</span> <span>{plan.amount_usdc} USDC</span>
</div> </div>
@@ -1319,6 +1462,12 @@ export function AccountCenter() {
</div> </div>
)} )}
<TurnstileWidget
action="payment_intent_create"
onToken={setPaymentTurnstileToken}
resetKey={paymentTurnstileResetKey}
/>
{/* Payment Method Tabs */} {/* Payment Method Tabs */}
<div className="border-t border-slate-200 pt-5"> <div className="border-t border-slate-200 pt-5">
<p className="mb-3 text-[10px] font-semibold uppercase text-slate-500"> <p className="mb-3 text-[10px] font-semibold uppercase text-slate-500">
@@ -1588,6 +1737,10 @@ export function AccountCenter() {
"amount_insufficient" "amount_insufficient"
? copy ? copy
.verifyAmountLow .verifyAmountLow
: txValidation.reason ===
"direct_self_transfer"
? copy
.verifySelfTransfer
: txValidation.reason === : txValidation.reason ===
"tx_reverted" "tx_reverted"
? copy ? copy
@@ -1605,11 +1758,7 @@ export function AccountCenter() {
onClick={() => onClick={() =>
void submitManualPaymentTx() void submitManualPaymentTx()
} }
disabled={ disabled={paymentBusy || !manualTxHashReady}
paymentBusy ||
!(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" className="w-full rounded-xl border border-emerald-700 bg-emerald-600 px-3 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-700 disabled:opacity-50"
> >
{copy.paymentManualSubmit} {copy.paymentManualSubmit}
@@ -0,0 +1,221 @@
"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";
import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
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 {
if (!hasSupabasePublicEnv()) {
setAvailable(false);
setEntries([]);
return;
}
const {
data: { session },
} = await getSupabaseBrowserClient().auth.getSession();
if (signal?.aborted) return;
const accessToken = String(session?.access_token || "").trim();
if (!accessToken) {
setAvailable(false);
setEntries([]);
return;
}
const res = await fetch("/api/feedback?limit=10", {
cache: "no-store",
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
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,57 @@
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",
);
const sessionLookupIndex = feedbackPanelSource.indexOf(".auth.getSession()");
const feedbackFetchIndex = feedbackPanelSource.indexOf("/api/feedback?limit=10");
assert(
feedbackPanelSource.includes("getSupabaseBrowserClient") &&
feedbackPanelSource.includes("hasSupabasePublicEnv") &&
sessionLookupIndex >= 0 &&
feedbackFetchIndex >= 0 &&
sessionLookupIndex < feedbackFetchIndex &&
feedbackPanelSource.includes("accessToken") &&
feedbackPanelSource.includes("Authorization: `Bearer ${accessToken}`") &&
feedbackPanelSource.includes("if (!accessToken)") &&
feedbackPanelSource.includes("setAvailable(false)"),
"account feedback panel must avoid unauthenticated 401 requests by checking the browser session before fetching feedback",
);
assert(
feedbackPanelSource.includes("reward_points") &&
feedbackPanelSource.includes("reward_reason") &&
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,97 @@
import {
buildAuthMePath,
mergeAccountAuthSnapshot,
shouldResolveAccountUnknownWithFullProfile,
} 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",
);
assert(
shouldResolveAccountUnknownWithFullProfile(
{ authenticated: true, subscription_active: null },
true,
),
"account center must resolve an unknown local-user subscription snapshot with the full auth profile",
);
assert(
!shouldResolveAccountUnknownWithFullProfile(
{ authenticated: true, subscription_active: false },
true,
),
"account center must not re-resolve an explicitly inactive subscription snapshot",
);
assert(
!shouldResolveAccountUnknownWithFullProfile(
{ authenticated: false, subscription_active: null },
true,
),
"account center must not re-resolve an unauthenticated backend snapshot",
);
assert(
!shouldResolveAccountUnknownWithFullProfile(
{ authenticated: true, subscription_active: null },
false,
),
"account center must only resolve unknown subscription snapshots when a local Supabase user exists",
);
}
@@ -23,6 +23,14 @@ export function runTests() {
path.join(projectRoot, "components", "account", "usePaymentFlow.ts"), path.join(projectRoot, "components", "account", "usePaymentFlow.ts"),
"utf8", "utf8",
); );
const useBilling = fs.readFileSync(
path.join(projectRoot, "components", "account", "useBilling.ts"),
"utf8",
);
const telegramPricing = fs.readFileSync(
path.join(projectRoot, "components", "account", "telegram-pricing.ts"),
"utf8",
);
const types = fs.readFileSync( const types = fs.readFileSync(
path.join(projectRoot, "components", "account", "types.ts"), path.join(projectRoot, "components", "account", "types.ts"),
"utf8", "utf8",
@@ -59,11 +67,45 @@ export function runTests() {
!usePaymentFlow.includes("monthlyPlanList"), !usePaymentFlow.includes("monthlyPlanList"),
"payment hooks must not filter checkout plans down to monthly only", "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 should not expose a separate discounted monthly label",
);
assert(
accountCenter.includes("displayPlanList.map") &&
accountCenter.includes("plan.amount_usdc") &&
accountCenter.includes("USDC") &&
!accountCenter.includes(`copy.${["private", "Group", "Monthly", "Plan"].join("")}`) &&
!accountCenter.includes("overlayPlanLabel") &&
!accountCenter.includes("overlayPeriodLabel"),
"payment management must display payment amounts as USDC without relying on the removed checkout overlay",
);
assert( assert(
types.includes("ReferralSummary") && types.includes("ReferralSummary") &&
types.includes("referral?: ReferralSummary | null") && types.includes("referral?: ReferralSummary | null") &&
types.includes("is_private_group_member?: boolean") &&
types.includes("duration_days: number") && types.includes("duration_days: number") &&
types.includes("max_discount_usdc_by_plan"), types.includes("max_discount_usdc_by_plan"),
"account auth and payment types must include referral summary and plan durations", "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 accountCenterSource = fs.readFileSync(accountCenterPath, "utf8");
const walletBindSource = fs.readFileSync(
path.join(projectRoot, "components", "account", "useWalletBind.ts"),
"utf8",
);
const hookPath = path.join( const hookPath = path.join(
projectRoot, projectRoot,
"components", "components",
@@ -62,6 +66,10 @@ export function runTests() {
const paymentFlowSource = fs.existsSync(paymentFlowPath) const paymentFlowSource = fs.existsSync(paymentFlowPath)
? fs.readFileSync(paymentFlowPath, "utf8") ? 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 // The receiver validation now lives in the extracted hook file (called
// from createManualPaymentIntent and createIntentAndPay). // from createManualPaymentIntent and createIntentAndPay).
@@ -72,16 +80,26 @@ export function runTests() {
"AccountCenter must validate backend-returned manual payment receiver before displaying it", "AccountCenter must validate backend-returned manual payment receiver before displaying it",
); );
assert( assert(
/!\(\s*txValidation\.checked\s*&&\s*txValidation\.valid === true\s*\)/.test( accountCenterSource.includes("manualTxHashReady") &&
accountCenterSource.includes("/^0x[a-fA-F0-9]{64}$/") &&
!/!\(\s*txValidation\.checked\s*&&\s*txValidation\.valid === true\s*\)/.test(
accountCenterSource, accountCenterSource,
), ) &&
"manual payment submit button must require checked && valid === true", paymentFlowSource.includes("const submitRes = await fetch(`/api/payments/intents/${intentIdVal}/submit`") &&
paymentFlowSource.includes("const confirmRes = await fetch(`/api/payments/intents/${intentIdVal}/confirm`"),
"manual payment submit button may proceed with a well-formed tx hash, but backend submit/confirm must remain the authority",
); );
assert( assert(
paymentFlowSource.includes("validateTxHash") && paymentFlowSource.includes("validateTxHash") &&
paymentFlowSource.includes("/validate"), paymentFlowSource.includes("/validate"),
"manual payment flow must validate tx hashes with the backend before submission", "manual payment flow must validate tx hashes with the backend before submission",
); );
assert(
paymentFlowSource.includes("confirmRes.status === 503") &&
paymentFlowSource.includes('lowerRaw.includes("cannot connect payment rpc")') &&
paymentFlowSource.includes('lowerRaw.includes("payment rpc chain mismatch")'),
"manual payment confirm must treat transient payment RPC failures as pending after tx hash submission",
);
assert( assert(
paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") && paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") &&
paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") < paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") <
@@ -178,6 +196,7 @@ export function runTests() {
"app/api/ops/users/route.ts", "app/api/ops/users/route.ts",
"app/api/ops/users/grant-points/route.ts", "app/api/ops/users/grant-points/route.ts",
"app/api/ops/view-logs/route.ts", "app/api/ops/view-logs/route.ts",
"app/api/system/status/route.ts",
]) { ]) {
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8"); const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
assert( assert(
@@ -203,14 +222,14 @@ export function runTests() {
const optionalRefreshIndex = middlewareSource.indexOf( const optionalRefreshIndex = middlewareSource.indexOf(
"function shouldRefreshOptionalSupabaseSession", "function shouldRefreshOptionalSupabaseSession",
); );
const systemStatusPublicIndex = middlewareSource.indexOf( const publicApiFunction = middlewareSource.slice(
'pathname === "/api/system/status"', middlewareSource.indexOf("function isPublicApi"),
middlewareSource.indexOf("function shouldRefreshOptionalSupabaseSession"),
); );
assert( assert(
systemStatusPublicIndex >= 0 &&
optionalRefreshIndex >= 0 && optionalRefreshIndex >= 0 &&
systemStatusPublicIndex < optionalRefreshIndex, !publicApiFunction.includes('pathname === "/api/system/status"'),
"middleware must treat public system status as public API instead of optional Supabase session refresh", "middleware must not treat system status as public after it becomes an ops-only API",
); );
for (const route of paymentRoutes) { for (const route of paymentRoutes) {
@@ -221,4 +240,75 @@ export function runTests() {
`${route} must allow bearer-backed payment mutations while still rejecting requests with no auth context`, `${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",
);
const walletVerifyRouteSource = fs.readFileSync(
path.join(projectRoot, "app/api/payments/wallets/verify/route.ts"),
"utf8",
);
assert(
walletVerifyRouteSource.includes("fetchWalletVerifyWithRetry"),
"wallet verify proxy must retry a transient backend connection failure before blocking wallet binding",
);
assert(
walletVerifyRouteSource.includes("Invalid wallet verify request"),
"wallet verify proxy must return a client error for malformed JSON instead of a generic payment failure",
);
assert(
walletVerifyRouteSource.includes("retryable: true"),
"wallet verify proxy exception response must mark transient failures as retryable",
);
assert(
walletBindSource.includes("readPaymentApiErrorMessage"),
"wallet binding errors must show the API error message instead of raw JSON",
);
assert(
walletBindSource.includes("钱包验证服务暂时不可用"),
"wallet binding HTML/50x fallback should stay localized in the Chinese account UI",
);
assert(
paymentUtilsSource.includes("looksLikeHtmlDocument") &&
paymentUtilsSource.includes("Payment service is temporarily unavailable") &&
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",
);
} }
@@ -62,6 +62,10 @@ export function runTests() {
path.join(accountDir, "usePaymentFlow.ts"), path.join(accountDir, "usePaymentFlow.ts"),
"utf8", "utf8",
); );
const useBillingSource = fs.readFileSync(
path.join(accountDir, "useBilling.ts"),
"utf8",
);
assert( assert(
(accountCenterSource.includes('import { usePaymentState } from "./usePaymentState";') || (accountCenterSource.includes('import { usePaymentState } from "./usePaymentState";') ||
hookSource.includes('import { usePaymentState } from "./usePaymentState";')) && hookSource.includes('import { usePaymentState } from "./usePaymentState";')) &&
@@ -148,14 +152,10 @@ export function runTests() {
); );
assert( assert(
accountCenterSource.includes( !accountCenterSource.includes("UnlockProOverlay") &&
'import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";', !accountCenterSource.includes("showOverlay") &&
), accountCenterSource.includes("focusPaymentManagement"),
"checkout overlay must be in the account bundle, not lazy-loaded after the user clicks pay", "account page must use the payment management section as the only checkout surface instead of a fixed Pro overlay",
);
assert(
!/const\s+UnlockProOverlay\s*=\s*dynamic\s*\(/.test(accountCenterSource),
"checkout overlay must not be dynamically imported; stale deployments can make the lazy chunk fail at pay time",
); );
assert( assert(
!/STATIC_ASSETS\s*=\s*\[[^\]]*["']\/_next\//s.test(serviceWorkerSource), !/STATIC_ASSETS\s*=\s*\[[^\]]*["']\/_next\//s.test(serviceWorkerSource),
@@ -193,6 +193,23 @@ export function runTests() {
accountCenterSource.includes('hasTelegramPanel ? "" : "sm:grid-cols-2"'), 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", "payment management must only split into internal columns when it is not already sharing the row with a Telegram panel",
); );
assert(
!accountCenterSource.includes("`/bind ${userId}${email ? ` ${email}` : \"\"}`") &&
!accountCenterSource.includes("/bind <supabase_user_id> <email>"),
"Telegram fallback copy must not expose the legacy /bind supabase_user_id command",
);
assert(
accountCenterSource.includes("telegramBindCommand") &&
accountCenterSource.includes("createTelegramBotBindCommand") &&
accountCenterSource.includes("handleCopyTelegramBindCommand"),
"account Telegram fallback copy must generate a fresh one-time /start bind token before copying",
);
assert(
useBillingSource.includes("bot_command") &&
useBillingSource.includes("createTelegramBotBindCommand") &&
useBillingSource.includes("telegramBindCommand"),
"billing hook must persist the backend /start bind token command for Telegram fallback copy",
);
assert( assert(
!appAnalyticsSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"') && !appAnalyticsSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"') &&
!analyticsRouteSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"'), !analyticsRouteSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"'),
@@ -232,12 +249,30 @@ export function runTests() {
), ),
"account center must distinguish unknown subscription sync state from a confirmed unsubscribed account", "account center must distinguish unknown subscription sync state from a confirmed unsubscribed account",
); );
assert(
!accountCenterSource.includes("paymentFeatureReady &&\n !isSubscriptionUnknown") &&
!accountCenterSource.includes("paymentFeatureReady &&\r\n !isSubscriptionUnknown"),
"account center must not block checkout when an authenticated user's subscription status is temporarily unknown",
);
assert(
accountFeatureSource.includes("状态待确认") &&
accountFeatureSource.includes("刷新或直接续费") &&
!accountFeatureSource.includes("订阅状态正在同步,请稍后刷新。"),
"account unknown subscription copy must tell users to refresh or renew instead of waiting indefinitely",
);
assert( assert(
hookSource.includes("backendJson.authenticated === false") && hookSource.includes("backendJson.authenticated === false") &&
hookSource.includes("refreshSession()") && hookSource.includes("refreshSession()") &&
hookSource.includes("retriedBackendJson"), hookSource.includes("retriedBackendJson"),
"account snapshot loader must retry with a refreshed Supabase token when local user exists but /api/auth/me reports unauthenticated", "account snapshot loader must retry with a refreshed Supabase token when local user exists but /api/auth/me reports unauthenticated",
); );
assert(
hookSource.includes("shouldResolveAccountUnknownWithFullProfile") &&
hookSource.includes("backendJson = await readAuthSnapshot(latestHeaders);") &&
hookSource.indexOf("shouldResolveAccountUnknownWithFullProfile") <
hookSource.indexOf("setBackend((previous) => mergeAccountAuthSnapshot(previous, backendJson))"),
"account snapshot loader must resolve unknown entitlement-scope results through full auth/me before rendering account status",
);
assert( assert(
hookSource.includes("refreshEntitlementAfterPayment") && hookSource.includes("refreshEntitlementAfterPayment") &&
paymentFlowSource.includes("refreshEntitlementAfterPayment") && paymentFlowSource.includes("refreshEntitlementAfterPayment") &&
@@ -299,9 +334,14 @@ export function runTests() {
authMeRouteSource.includes("!auth.authUserId") && authMeRouteSource.includes("!auth.authUserId") &&
authMeRouteSource.includes('req.headers.get("authorization")') && authMeRouteSource.includes('req.headers.get("authorization")') &&
authMeRouteSource.indexOf("authenticated: false") < authMeRouteSource.indexOf("authenticated: false") <
authMeRouteSource.indexOf("await fetch(`${API_BASE}/api/auth/me`"), 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", "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( assert(
authMeRouteSource.includes('reason: "prefer_snapshot_fast_path"') && authMeRouteSource.includes('reason: "prefer_snapshot_fast_path"') &&
authMeRouteSource.indexOf('reason: "prefer_snapshot_fast_path"') < authMeRouteSource.indexOf('reason: "prefer_snapshot_fast_path"') <
@@ -317,7 +357,7 @@ export function runTests() {
assert( assert(
backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") && backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") &&
backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_EMAIL_HEADER") && backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_EMAIL_HEADER") &&
backendAuthSource.indexOf("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") > backendAuthSource.lastIndexOf("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") >
backendAuthSource.indexOf("const sessionUser = session?.user"), 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", "backend proxy must forward Supabase session user id/email with the backend token so Python can skip duplicate /auth/v1/user validation",
); );
@@ -372,6 +412,12 @@ export function runTests() {
.length >= 3, .length >= 3,
"manual payment mutations must require a valid Supabase bearer token instead of forwarding unauthenticated requests that surface raw backend JSON", "manual payment mutations must require a valid Supabase bearer token instead of forwarding unauthenticated requests that surface raw backend JSON",
); );
assert(
accountCenterSource.includes("manualTxHashReady") &&
accountCenterSource.includes("/^0x[a-fA-F0-9]{64}$/") &&
!accountCenterSource.includes("txValidation.valid === true)"),
"manual transfer submit button must allow a well-formed tx hash even when advisory pre-validation is unavailable",
);
assert( assert(
paymentFlowSource.includes("verifyPaymentAuthReady") && paymentFlowSource.includes("verifyPaymentAuthReady") &&
paymentFlowSource.indexOf("await verifyPaymentAuthReady()") < paymentFlowSource.indexOf("await verifyPaymentAuthReady()") <
@@ -0,0 +1,29 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const types = fs.readFileSync(path.join(projectRoot, "components", "account", "types.ts"), "utf8");
const accountCenter = fs.readFileSync(
path.join(projectRoot, "components", "account", "AccountCenter.tsx"),
"utf8",
);
assert(
types.includes("points_ledger") &&
types.includes("PointsLedgerSummary") &&
types.includes("PointsLedgerEntry"),
"auth/me account types must include points ledger summary and entries",
);
assert(
accountCenter.includes("points_ledger") &&
accountCenter.includes("积分来源") &&
accountCenter.includes("feedback_reward") &&
accountCenter.includes("ops_manual_grant"),
"account center must display point source explainability from auth/me",
);
}
@@ -0,0 +1,67 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const accountDir = path.join(projectRoot, "components", "account");
const accountCenter = fs.readFileSync(path.join(accountDir, "AccountCenter.tsx"), "utf8");
const paymentFlow = fs.readFileSync(path.join(accountDir, "usePaymentFlow.ts"), "utf8");
const accountPayment = fs.readFileSync(path.join(accountDir, "useAccountPayment.ts"), "utf8");
const landingPage = fs.readFileSync(
path.join(projectRoot, "components", "landing", "InstitutionalLandingPage.tsx"),
"utf8",
);
const landingAuthActions = fs.readFileSync(
path.join(projectRoot, "components", "landing", "LandingAuthActions.tsx"),
"utf8",
);
const productAccess = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "ProductAccessRequired.tsx"),
"utf8",
);
assert(
accountCenter.includes("canTrialUpgrade") &&
accountCenter.includes("isTrialSubscription") &&
accountCenter.includes("canTrialUpgrade || !isSubscribed"),
"trial subscribers must be allowed to enter payment management before expiry",
);
assert(
accountCenter.includes("useSearchParams") &&
accountCenter.includes('searchParams.get("checkout") === "1"') &&
accountCenter.includes("focusPaymentManagement"),
"account page must support /account?checkout=1 by focusing payment management",
);
assert(
accountCenter.includes('href="/auth/login?next=%2Faccount%3Fcheckout%3D1"'),
"account page sign-in button must preserve the checkout entry for unauthenticated subscription recovery",
);
assert(
landingAuthActions.includes('href="/account?checkout=1"') &&
landingPage.includes('href="/account?checkout=1"') &&
!/href="\/account"(?![/?#])/.test(landingAuthActions) &&
!/href="\/account"(?![/?#])/.test(landingPage),
"landing subscription/account CTAs must open the checkout account entry instead of a generic account page",
);
assert(
productAccess.includes('href="/account?checkout=1"') &&
productAccess.includes("Renew and restore access") &&
productAccess.includes("续费并恢复访问"),
"expired terminal gate must send users directly to the checkout entry with renewal recovery copy, not a generic account page",
);
assert(
accountPayment.includes("authUserId,") &&
paymentFlow.includes("authUserId: string") &&
paymentFlow.includes("user_id: authUserId || null"),
"payment_start and payment_success analytics must carry the authenticated user id for trial-to-paid attribution",
);
}
@@ -0,0 +1,96 @@
import fs from "node:fs";
import path from "node:path";
import { buildTrialValueReplaySummary } from "@/lib/trial-value-replay";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const replayPath = path.join(projectRoot, "lib", "trial-value-replay.ts");
const accountCenter = fs.readFileSync(
path.join(projectRoot, "components", "account", "AccountCenter.tsx"),
"utf8",
);
const dashboardSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
"utf8",
);
assert(fs.existsSync(replayPath), "trial value replay helper must exist");
const replaySource = fs.readFileSync(replayPath, "utf8");
const accountReplaySurface = `${accountCenter}\n${replaySource}`;
assert(
replaySource.includes("TRIAL_VALUE_REPLAY_STORAGE_KEY") &&
replaySource.includes("recordTrialValueReplay") &&
replaySource.includes("readTrialValueReplay") &&
replaySource.includes("buildTrialValueReplaySummary"),
"trial value replay must persist and summarize local trial usage evidence",
);
assert(
dashboardSource.includes("recordTrialValueReplay") &&
dashboardSource.includes("isTrialTerminalAccess") &&
dashboardSource.includes("selectedRow"),
"terminal trial users must record real terminal usage for later value replay",
);
assert(
accountCenter.includes("buildTrialValueReplaySummary") &&
accountCenter.includes("trialValueReplay") &&
accountReplaySurface.includes("你本次试用已经") &&
accountReplaySurface.includes("保持访问") &&
accountReplaySurface.includes("Keep access"),
"account trial upgrade trigger must replay the user's own trial value before opening checkout",
);
assert(
accountCenter.includes('trackAppEvent("paywall_feature_clicked"') &&
accountCenter.includes('feature: "trial_value_replay_upgrade"'),
"trial value replay upgrade CTA must be tracked before checkout opens",
);
const zhSummary = buildTrialValueReplaySummary(
{
firstActiveAt: "2026-06-09T00:00:00.000Z",
lastActiveAt: "2026-06-09T00:20:00.000Z",
lastCityName: "Taipei",
lastSignalLabel: "Hot",
lastUserId: "user-1",
terminalVisits: 2,
rowsAvailableMax: 50,
signalsViewed: 2,
citiesViewed: ["Taipei", "New York"],
},
{ isEn: false },
);
assert(
zhSummary.headline === "你本次试用已经查看 2 个天气信号。" &&
zhSummary.bullets.includes("已记录 2 次终端使用。") &&
zhSummary.bullets.includes("Pro 终端本次提供了 50 个城市机会。") &&
zhSummary.primaryCta === "保持访问",
"trial value replay summary must turn usage evidence into concrete upgrade copy",
);
const expirySummary = buildTrialValueReplaySummary(
{
firstActiveAt: null,
lastActiveAt: null,
lastCityName: null,
lastSignalLabel: null,
lastUserId: null,
terminalVisits: 0,
rowsAvailableMax: 0,
signalsViewed: 0,
citiesViewed: [],
},
{ isEn: false, trialExpiresAt: "2099-01-01T00:00:00.000Z" },
);
assert(
expirySummary.bullets.some((bullet) => bullet.includes("试用剩余")),
"trial value replay summary must include trial time remaining when expiry is known",
);
}

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