Compare commits

..

334 Commits

Author SHA1 Message Date
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
2569718930@qq.com 8d26afdec0 Reduce terminal auth and analytics stalls 2026-05-31 19:21:53 +08:00
2569718930@qq.com 5083b7c433 Instrument API timing and reduce detail fallbacks 2026-05-31 18:58:57 +08:00
2569718930@qq.com 668f4d9bd3 Optimize terminal batching and cache guards 2026-05-31 18:28:50 +08:00
2569718930@qq.com a4253c224a Add auth profile timing instrumentation 2026-05-31 18:27:04 +08:00
2569718930@qq.com 46effcd45f Optimize terminal detail loading and Redis replay 2026-05-31 04:32:48 +08:00
2569718930@qq.com 2a6d8748f4 Improve ops monitoring and chart loading 2026-05-31 03:21:58 +08:00
2569718930@qq.com ca72072da0 Optimize terminal startup and deployment smoke checks 2026-05-31 01:56:08 +08:00
2569718930@qq.com bf0fbe1b1d Optimize landing server rendering 2026-05-30 23:43:07 +08:00
2569718930@qq.com 30e9513ce2 Update deploy smoke check test 2026-05-30 23:00:48 +08:00
2569718930@qq.com aec9dd6a54 Harden production deploy smoke checks 2026-05-30 22:51:25 +08:00
2569718930@qq.com 6222f60573 Improve terminal chart loading states 2026-05-30 22:37:08 +08:00
2569718930@qq.com a48a7bcca5 Pass ops admin whitelist to frontend 2026-05-30 22:18:48 +08:00
2569718930@qq.com 74a2d71644 Reduce scan terminal origin load 2026-05-30 21:55:45 +08:00
2569718930@qq.com 9c239c0394 Add login button pending feedback 2026-05-30 21:31:13 +08:00
2569718930@qq.com 383dfe4d0e Harden production deploy stability 2026-05-30 21:21:30 +08:00
2569718930@qq.com 52f92d8650 Speed up terminal auth snapshot loading 2026-05-30 21:08:19 +08:00
2569718930@qq.com d9eea721ef feat: implement /api/auth/me proxy route with subscription-required state and fallback entitlement snapshots 2026-05-30 21:01:48 +08:00
2569718930@qq.com aa583e7440 Add ops runtime secret rotation 2026-05-30 20:33:30 +08:00
2569718930@qq.com e00405df8a Tighten AMSC ops health check 2026-05-30 20:10:14 +08:00
2569718930@qq.com 9bcfd9d1eb Improve landing performance and analytics funnel 2026-05-30 19:50:50 +08:00
2569718930@qq.com 3fcda3f3cd Tighten cities fallback timeout 2026-05-30 19:38:31 +08:00
2569718930@qq.com 345691ad71 Add cities static fallback 2026-05-30 19:32:25 +08:00
2569718930@qq.com e864181c3c Improve terminal entitlement resilience 2026-05-30 19:10:19 +08:00
2569718930@qq.com 097971f107 Fix terminal subscription gate on unknown auth state 2026-05-30 18:34:38 +08:00
2569718930@qq.com 90bc895000 Update referral points pricing 2026-05-30 18:04:36 +08:00
2569718930@qq.com c0b20ed1bf feat: implement TemperatureStatsBars component for displaying thermal data with i18n support 2026-05-30 16:42:03 +08:00
2569718930@qq.com 5470da6b3a feat: implement CitySelectorDropdown for ScanTerminalDashboard with multi-filter search and regional categorization 2026-05-30 16:39:23 +08:00
2569718930@qq.com a03b8095ee Refresh entitlement after payment confirmation 2026-05-30 16:26:32 +08:00
2569718930@qq.com 91896b4dad Prevent duplicate payment confirmations 2026-05-30 16:17:15 +08:00
2569718930@qq.com 44638760b7 feat: implement atomic signup trial claims and referral discount logic with Supabase database schema updates 2026-05-30 16:02:02 +08:00
2569718930@qq.com c0b1edaed0 feat: implement payment proxy routes and add security validation tests for wallet and intent operations 2026-05-30 15:41:15 +08:00
2569718930@qq.com 853a652e9b fix: degrade auth profile on backend outage 2026-05-30 15:06:22 +08:00
2569718930@qq.com c79619898a fix: avoid terminal cold-start paywall race 2026-05-30 14:48:09 +08:00
2569718930@qq.com 1c250906ed fix: keep terminal stable across auth refresh 2026-05-30 14:33:26 +08:00
2569718930@qq.com ed8c898e0e fix: retry deploy smoke checks 2026-05-30 14:24:02 +08:00
2569718930@qq.com 57240e02ee fix: require auth token for manual payments 2026-05-30 14:09:57 +08:00
2569718930@qq.com c26f89f134 feat: implement terminal authentication bootstrap and integrate into ScanTerminalDashboard 2026-05-30 00:05:19 +08:00
2569718930@qq.com b3a63aa9af test: add unit tests for temperature series visibility policy and peak glow state logic 2026-05-29 23:21:17 +08:00
2569718930@qq.com 53268560ec test: implement visibility policy and chart logic verification tests for temperature series 2026-05-29 23:09:16 +08:00
2569718930@qq.com 68416b058e feat: implement temperature chart visibility logic with comprehensive unit testing and analysis service support 2026-05-29 22:40:33 +08:00
2569718930@qq.com 95c2258db0 Fix Turkey MGM panel history 2026-05-29 21:56:57 +08:00
2569718930@qq.com 8507afedd6 Fix account payment layout responsiveness 2026-05-29 21:34:36 +08:00
2569718930@qq.com 3e24080466 feat: implement AccountCenter dashboard with subscription and payment management features 2026-05-29 21:23:12 +08:00
2569718930@qq.com 17ddd835e6 Fix landing hero illustration overlap 2026-05-29 20:57:59 +08:00
2569718930@qq.com 5228d2b3fd Update landing and weather observation policies 2026-05-29 20:46:35 +08:00
2569718930@qq.com 2f039abb2c Add event fallback for referral program 2026-05-29 19:34:56 +08:00
2569718930@qq.com 522e35de7f Add trial and referral subscription program 2026-05-29 19:24:46 +08:00
2569718930@qq.com f8f5035225 Fix runway chart collapse and ops cleanup 2026-05-29 18:40:29 +08:00
2569718930@qq.com 23b5fafb25 Reduce idle payment confirm polling 2026-05-29 18:14:59 +08:00
2569718930@qq.com 2269aaefd7 Reduce Supabase disk IO 2026-05-29 17:22:33 +08:00
2569718930@qq.com 9e3a4e2f45 Fix WU historical current observation merge 2026-05-29 17:20:07 +08:00
2569718930@qq.com 8f52b1d80c Add password reset flow 2026-05-29 12:28:08 +08:00
2569718930@qq.com 1d9f0033a6 Tighten payment tx validation before submit 2026-05-29 12:11:19 +08:00
2569718930@qq.com 618bac8b57 feat: add live temperature threshold chart logic and components for runway monitoring 2026-05-29 02:10:07 +08:00
2569718930@qq.com f2e90fbcda feat: support ethereum usdc payment route 2026-05-29 01:57:42 +08:00
2569718930@qq.com 3cc2251b9b fix: translate settlement runway chart labels 2026-05-28 22:44:36 +08:00
479 changed files with 70682 additions and 7926 deletions
+33 -2
View File
@@ -26,6 +26,14 @@ POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
POLYWEATHER_REDIS_STREAM_KEY=stream:city_observation
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
POLYWEATHER_REDIS_REQUIRED=true
# Optional Cloudflare R2 cold archive for realtime SSE/event snapshots.
POLYWEATHER_R2_ARCHIVE_SOURCE=redis
POLYWEATHER_R2_ACCOUNT_ID=
POLYWEATHER_R2_BUCKET=
POLYWEATHER_R2_ENDPOINT_URL=
POLYWEATHER_R2_REGION=auto
POLYWEATHER_R2_ACCESS_KEY_ID=
POLYWEATHER_R2_SECRET_ACCESS_KEY=
# Backend CORS allowlist. Add your Vercel production/preview domains when
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL points browsers directly at this backend.
WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather.top,https://www.polyweather.top,https://api.polyweather.top
@@ -59,7 +67,7 @@ TELEGRAM_AIRPORT_PUSH_LANGUAGE=both
POLYWEATHER_BOT_CPUS=0.75
POLYWEATHER_BOT_MEM_LIMIT=768m
POLYWEATHER_BOT_MEMSWAP_LIMIT=1g
POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC=180
POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC=60
POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS=1
########################################
@@ -75,6 +83,7 @@ OPEN_METEO_MIN_CALL_INTERVAL_SEC=5
POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=1
POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=600
POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=45
POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS=3000
POLYWEATHER_HTTP_TIMEOUT_SEC=8
POLYWEATHER_HTTP_RETRY_COUNT=0
POLYWEATHER_HTTP_RETRY_BACKOFF_SEC=0.2
@@ -123,6 +132,10 @@ POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
########################################
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
POLYWEATHER_TURNSTILE_BYPASS=false
POLYWEATHER_TURNSTILE_ENFORCE_ACTION=false
POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT=false
# Optional: disable homepage city summary preloading. Default is enabled.
NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES=false
# Optional: browser-visible FastAPI base URL for Vercel deployments.
@@ -161,6 +174,12 @@ POLYWEATHER_WEEKLY_REWARD_CHECK_INTERVAL_SEC=300
POLYWEATHER_WEEKLY_REWARD_HTTP_TIMEOUT_SEC=10
POLYWEATHER_WEEKLY_REWARD_ANNOUNCE_ENABLED=true
# Verified-user growth milestone rewards
POLYWEATHER_GROWTH_REWARD_ENABLED=false
POLYWEATHER_GROWTH_REWARD_CHECK_INTERVAL_SEC=21600
POLYWEATHER_GROWTH_REWARD_HTTP_TIMEOUT_SEC=15
POLYWEATHER_GROWTH_REWARD_ANNOUNCE_ENABLED=true
# Group message points
POLYWEATHER_BOT_MESSAGE_POINTS=4
POLYWEATHER_BOT_MESSAGE_DAILY_CAP=40
@@ -171,13 +190,24 @@ POLYWEATHER_BOT_DEB_QUERY_COST=1
# Payments
POLYWEATHER_PAYMENT_ENABLED=false
# Default / legacy checkout chain. Keep Polygon as the default because the
# deployed checkout contract currently lives there.
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com
# Optional multi-chain RPC map. Required when accepting non-default chain
# transfers such as Ethereum mainnet USDC.
# Example:
# POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
# Multi-token / multi-chain payment routes. Token rows can include:
# chain_id, chain_code, chain_name, receiver_contract, direct_receiver_address,
# supports_contract_checkout, supports_direct_transfer, confirmations,
# explorer_tx_url.
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
@@ -189,7 +219,8 @@ POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8}
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
POLYMARKET_SIGNAL_EDGE_PCT=2
+15
View File
@@ -19,6 +19,12 @@ SUPABASE_SERVICE_ROLE_KEY=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
########################################
# Cloudflare Turnstile
########################################
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
POLYWEATHER_TURNSTILE_SECRET_KEY=
########################################
# Entitlement / dashboard
########################################
@@ -34,11 +40,20 @@ METEOBLUE_API_KEY=
# Wallet / payments
########################################
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
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
########################################
+69 -41
View File
@@ -21,7 +21,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-dev.txt
pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.lock -r requirements-dev.lock
- name: Ruff
run: python -m ruff check .
@@ -73,9 +73,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
@@ -83,55 +80,86 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push backend
if: matrix.image.name == 'backend'
uses: docker/build-push-action@v6
with:
context: ${{ matrix.image.context }}
file: ${{ matrix.image.file }}
push: true
tags: |
${{ matrix.image.tag }}:latest
${{ matrix.image.tag }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push image
env:
IMAGE_NAME: ${{ matrix.image.name }}
IMAGE_CONTEXT: ${{ matrix.image.context }}
IMAGE_FILE: ${{ matrix.image.file }}
IMAGE_TAG: ${{ matrix.image.tag }}
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${{ secrets.NEXT_PUBLIC_TURNSTILE_SITE_KEY || '' }}
run: |
set -euo pipefail
- name: Build and push frontend
if: matrix.image.name == 'frontend'
uses: docker/build-push-action@v6
with:
context: ${{ matrix.image.context }}
file: ${{ matrix.image.file }}
push: true
tags: |
${{ matrix.image.tag }}:latest
${{ matrix.image.tag }}:${{ github.sha }}
build-args: |
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_SITE_URL=${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags=(-t "${IMAGE_TAG}:latest" -t "${IMAGE_TAG}:${GITHUB_SHA}")
build_args=()
deploy:
needs: [build-and-push]
if [ "${IMAGE_NAME}" = "frontend" ]; then
build_args=(
--build-arg "NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}"
--build-arg "NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}"
--build-arg "NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}"
--build-arg "NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL}"
--build-arg "NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false"
--build-arg "NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID}"
--build-arg "NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL}"
--build-arg "NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS}"
--build-arg "NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY}"
)
fi
docker build -f "${IMAGE_FILE}" "${tags[@]}" "${build_args[@]}" "${IMAGE_CONTEXT}"
docker push "${IMAGE_TAG}:latest"
docker push "${IMAGE_TAG}:${GITHUB_SHA}"
cloudflare-cache-rules:
needs: [python-quality]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Apply Cloudflare cache rules
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
run: |
if [ -z "${CLOUDFLARE_API_TOKEN}" ]; then
echo "CLOUDFLARE_API_TOKEN is not configured; skipping Cache Rules sync"
exit 0
fi
if [ -z "${CLOUDFLARE_ZONE_ID}" ]; then
echo "CLOUDFLARE_ZONE_ID is not configured; skipping Cache Rules sync"
exit 0
fi
python scripts/configure_cloudflare_free.py --apply
deploy:
needs: [build-and-push]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
concurrency:
group: polyweather-production-deploy
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to VPS
env:
GHCR_PAT: ${{ secrets.GHCR_PAT }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
scp -o StrictHostKeyChecking=accept-new deploy.sh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/deploy.sh
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
bash /tmp/deploy.sh '${{ secrets.GHCR_PAT }}' '${{ github.sha }}'
printf '%s\n' "$GHCR_PAT" | ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
bash /tmp/deploy.sh '${{ github.sha }}'
"
+7 -1
View File
@@ -9,6 +9,7 @@ data/*.db
data/*.db-*
data/*.db.*
data/*.json
!data/city_thread_ids.json
data/*backtest*.csv
data/logs/
data/historical/
@@ -43,6 +44,11 @@ Thumbs.db
frontend/node_modules/
frontend/.next/
frontend/.vercel/
frontend/.env
frontend/.env.local
frontend/.env.production
frontend/.env.*
!frontend/.env.example
frontend/*.tsbuildinfo
frontend/.codex-next-dev*.log
frontend/.codex-next-start*.log
@@ -70,6 +76,6 @@ frontend/.next-start.log
tmp_apikey.js
tmp_obs.js
tmp_rctp.html
playwright-home-check.png
.codex-backend-*.log
frontend-next-*.log
*.stackdump
+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
### 移除
- 删除 LGBM 全部代码和模型文件,EMOS 简化为纯 legacy 高斯分桶
- 删除 LGBM 全部代码和模型文件,概率路径收口为 legacy 高斯分桶
- 删除 Polymarket 价格拉取与 UI 层(MarketDecisionLine
- 删除 Groq、Meteoblue、NMC、俄罗斯 pogodaiklimat 数据源
- 删除预热(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
PolyWeather Pro — a paid institutional weather-intelligence terminal. 50 monitored cities with real-time METAR/AMOS/MADIS observations, DEB multi-model temperature blending, Mu probability calibration, and intraday bias correction. Pure meteorological decision workspace; no market/price layer. Next.js 15 + React 19 (Vercel) frontend, FastAPI backend (VPS), Telegram bot.
PolyWeather Pro — a paid institutional weather-intelligence terminal. 50 monitored cities with real-time METAR/AMOS/MADIS observations, DEB multi-model temperature blending, Mu probability calibration, and intraday bias correction. Pure meteorological decision workspace; no market/price layer. Next.js 15 + React 19 frontend (Docker / VPS, behind Cloudflare + Nginx), FastAPI backend (VPS), Telegram bot.
**Business model**: Paid-only, $10/month, no free tier, no trial. Landing page is public; `/terminal` requires login + active subscription.
**Business model**: Paid-only, 29.9 USDC/month or 79.9 USDC/quarter, referral first month 20 USDC. New users get a one-time 3-day trial. Landing page is public; `/terminal` requires login + active subscription.
## Environment & Preferences
@@ -47,10 +47,12 @@ docker compose down && docker compose up -d --build
## Architecture
```
Users → Next.js (Vercel) → FastAPI :8000 (VPS)
/terminal (paid gate) Weather Collector
/ (landing page) Analysis (DEB + Mu)
Payment Layer (USDC on Polygon)
Users → Cloudflare → Nginx → Docker Compose (VPS)
├── Next.js frontend → FastAPI :8000
│ /terminal (paid gate) Weather Collector
│ / (landing page) Analysis (DEB + Mu)
│ Payment Layer (USDC on Polygon)
└── Redis (SSE event store)
Telegram Bot → bot_listener.py
```
+2 -2
View File
@@ -15,10 +15,10 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
gcc libhdf5-dev libnetcdf-dev && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --prefer-binary -r requirements.txt
pip install --prefer-binary --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.lock
COPY . .
+26 -35
View File
@@ -5,13 +5,13 @@ Production weather-intelligence stack for temperature settlement markets.
Official dashboard: [polyweather.top](https://polyweather.top/)
中文说明: [README_ZH.md](README_ZH.md)
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday analysis, calibrated probability, model stack, TAF, settlement sources, history, and extension).
Public docs center: `/docs/intro` on the main site (bilingual product documentation for the current terminal, chart reading, realtime source cadence, settlement stations, and the browser extension).
## Product Screenshots
### Realtime Terminal
![PolyWeather realtime terminal](frontend/public/static/web.png)
![PolyWeather realtime terminal](frontend/public/static/web.webp)
### Telegram Runway Alerts
@@ -21,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)
## Product Status (2026-05-28)
## Product Status (2026-06-07)
- Subscription live: `Pro Monthly 10 USDC`.
- Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards.
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, max `3 USDC`).
- Weekly leaderboard rewards restructured: smaller point bonuses for winners (200/100/50), all active users receive participation rewards.
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e).
- Subscription live: `Pro Monthly 29.9 USDC / 30 days` and `Pro Quarterly 79.9 USDC / 90 days`.
- Referral pricing live: invited users can get the first monthly Pro at `20 USDC`; inviters receive `3500` points after a valid first Pro payment, capped at 10 paid invites per month.
- Points are redeemable for payment discounts (`500 pts = 1 USDC`, monthly max `3 USDC`, quarterly max `8 USDC`). Useful user feedback can also receive manual point rewards through ops.
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e) plus Ethereum mainnet USDC direct-transfer confirmation.
- Auto-reconciliation live: event listener + periodic confirm loop.
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
- Ops dashboard live: `/ops` for memberships, leaderboard, user feedback triage, manual point grants, and payment incident triage.
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
- Realtime terminal live: visible city charts subscribe through `/api/events?cities=...&since_revision=...`, receive `city_observation_patch.v1` SSE patches, and replay short gaps from Redis Stream in production or SQLite fallback in local/single-node mode.
- Chart refresh is observation-driven: live patches merge into the current chart without a loading overlay; only visible charts run a 60s no-patch fallback, and returning from a background browser tab triggers a foreground catch-up refresh.
- Temperature charts default to All Day, keep an optional Peak window derived from the DEB hourly path, and render all timestamps in the selected city's local time.
- The chart core has been split into focused logic/canvas/state modules; Recharts now receives explicit measured dimensions to avoid 0x0 rendering and disappearing curves.
- DEB hourly consensus (`deb_hourly_consensus.v1`) is now the preferred hourly forecast path for peak-window detection and chart overlays; DEB remains a forecast curve, never an observation source.
- Legacy Gaussian probability is rendered as horizontal probability bands and a `mu` reference line on the chart, rather than as a fake time-series curve.
- Legacy Gaussian probability stays out of the default temperature chart surface; hover tooltips show `Gaussian μ` plus the full bucket distribution by temperature range.
- Settlement runway curves are visible by default for AMSC/AMOS cities; the configured settlement runway is highlighted and auxiliary runways are shown as secondary context.
- Hong Kong uses CoWIN station `6087` (Po Leung Kuk Choi Kai Yau School) as the 1-minute reference-station curve, with HKO 10-minute observations kept as the official meteorological layer.
- Telegram airport/runway pushes are bilingual by default and use settlement-endpoint runway temperatures for slope/current/summary copy.
- Runtime state, cache, and core offline training/backfill flows now use SQLite as the primary path; legacy JSON/JSONL files remain only for migration, export, and explicit fallback input.
- EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout.
- Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules.
- Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives.
- Terminal city cards now combine settlement observations, DEB hourly consensus, model cluster context, calibrated probability, and market-bucket mapping without blocking the chart on AI text generation.
- Terminal chart/detail workflow now combines settlement observations, DEB hourly consensus, model context, probability distribution tooltips, and market-bucket mapping without blocking the chart on AI text generation.
- Terminal data uses page memory cache, browser `localStorage`, backend short-TTL cache, SSE patch replay, and foreground refresh so returning from another tab restores the latest visible chart state quickly.
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
- The card label “model-market difference means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
- 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.
- 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 legacy Gaussian probability engine, while model consensus remains a secondary reference.
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
- Trade cue now combines upper-air structure, `TAF`, market crowding, and `edge_percent`.
@@ -72,10 +70,12 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
- Aggregates observations and forecasts for 51 monitored cities.
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
- Builds a DEB-weighted hourly consensus path for peak-window logic and chart display.
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via legacy Gaussian or EMOS/CRPS calibration.
- Adds city decision cards that combine live observations, expected-high centers, full market-bucket mapping, and model-market difference in one view.
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via the legacy Gaussian calibration path.
- Adds terminal chart/detail workflows that combine live observations, DEB-centered high-temperature context, market-bucket mapping, and model-market difference.
- Shows calibrated Gaussian context in chart tooltips as `mu` plus the full temperature-range probability distribution, without reintroducing probability bands into the main temperature view.
- Reuses one analysis core across web dashboard and Telegram bot.
- Adds payment audit trails, replay tooling, and incident visibility in ops.
- Adds an in-app feedback loop with chart context, user-visible feedback status, ops triage, and manual point rewards for useful reports and suggestions.
- Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules.
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
- Adds official nearby-network and runway-level enhancement layers for China, Japan, Korea (AMOS runway sensors for Seoul/Busan), Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
@@ -84,7 +84,7 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
```mermaid
flowchart LR
U["Users (Web / Telegram)"] --> FE["Next.js Frontend (Vercel)"]
U["Users (Web / Telegram)"] --> FE["Next.js Frontend (Docker / VPS)"]
U --> BOT["Telegram Bot (VPS)"]
FE --> API["FastAPI /web/app.py"]
BOT --> API
@@ -102,7 +102,7 @@ flowchart LR
API --> SSE["SSE /api/events"]
WX --> SSE
SSE --> EVENT["Redis Stream / SQLite Event Log"]
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
ANA --> PAY["Payment State (Multi-chain Intent + Event + Confirm Loop)"]
ANA --> STATE["SQLite runtime state"]
```
@@ -131,12 +131,14 @@ npm run dev
## Recent Highlights
- Gaussian probability tooltip now lists the full temperature-range distribution instead of only the highest-probability bucket, while the main chart remains focused on observations and forecasts.
- User feedback is now a product loop: terminal submissions attach chart context, users can track status in-app, and ops can reward useful feedback with points.
- Airport-linked contracts use the METAR / airport primary observing site as the settlement anchor. Wunderground pages are reference/history pages, not stations.
- Taipei and Shenzhen retain their explicitly configured station history pages for reconciliation, but the docs avoid describing Wunderground itself as a physical station.
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
- Intraday analysis now separates meteorology conclusion, evidence chain, invalidation rules, confirmation rules, calibrated probability, and market reference.
- `TAF` is used as an airport-side confirmation layer, not as the main temperature model.
- Calibrated probability uses legacy Gaussian (default) or EMOS/CRPS when evaluated; model vote counts remain an explanatory consensus line, not the final probability.
- Calibrated probability uses the legacy Gaussian path; model vote counts remain an explanatory consensus line, not the final probability.
- Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience.
- Realtime terminal charts use SSE patches plus replayable event storage; full HTTP detail remains the authoritative snapshot.
- Chart observations are shown in the city's local time, not the browser timezone.
@@ -157,19 +159,6 @@ POLYWEATHER_REDIS_REQUIRED=true
For local development or a strict single-process fallback, keep `POLYWEATHER_EVENT_STORE=sqlite`.
## EMOS Local Training
Do not run full EMOS retraining on a small VPS. The VPS should collect data and load approved calibration files; training should run on a local/dev machine using a copied production SQLite database:
```powershell
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
```
Promote a generated `default.json` only when `auto_retrain_report.json` has `ready_for_promotion=true`, and prefer `emos_shadow` before enabling `emos_primary`.
## Ops Verification
### Health / system status / metrics
@@ -198,12 +187,14 @@ docker compose logs -f polyweather | egrep "payment event loop started|payment c
curl http://127.0.0.1:8000/api/payments/runtime
```
### Payment chains
Production payment routes are configured by the backend. Polygon remains the default checkout-contract chain, while Ethereum mainnet USDC can be enabled as a direct-transfer route so users who pay on their wallet default network are still confirmed by `intent.chain_id`.
## Telegram Commands
| Command | Purpose |
| :-- | :-- |
| `/city <name>` | City real-time analysis |
| `/deb <name>` | DEB historical reconciliation |
| `/top` | User leaderboard |
| `/id` | Show current chat ID |
| `/diag` | Startup diagnostics |
@@ -236,4 +227,4 @@ curl http://127.0.0.1:8000/api/payments/runtime
## Version
- Version: `v1.8.1`
- Last Updated: `2026-05-28`
- Last Updated: `2026-06-07`
+25 -39
View File
@@ -8,22 +8,22 @@
### 实时终端
![PolyWeather 实时终端](frontend/public/static/web.png)
![PolyWeather 实时终端](frontend/public/static/web.webp)
### Telegram 跑道推送
![PolyWeather Telegram 跑道推送](frontend/public/static/tel.png)
## 当前产品状态(2026-05-28
## 当前产品状态(2026-06-07
- 已上线订阅制:`Pro 月付 10 USDC`
- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖
- `/city``/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,最多抵 `3 USDC`
- 周榜奖励已改造:降低赢家积分加成 (200/100/50),所有周活跃用户均享参与奖
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。
- 已上线订阅制:`Pro 月付 29.9 USDC / 30 天``Pro 季度 79.9 USDC / 90 天`
- 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分
- 积分可用于支付抵扣(`500 分 = 1 USDC`月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。真实、有上下文、有价值的用户反馈也可通过运营后台人工奖励积分
- 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e+ Ethereum 主网 USDC 直转确认
- 已上线自动补单:事件监听 + 周期确认双链路。
- 已上线支付运行态与审计接口:`/api/payments/runtime`
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。
- 已上线轻量运营后台:`/ops`(会员、用户反馈处理、积分、补分、支付异常单)。
- 已上线轻量可观测性:`/healthz``/api/system/status``/metrics`
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。
- 实时终端已切换到可重放事件流:可见城市图表通过 `/api/events?cities=...&since_revision=...` 订阅 `city_observation_patch.v1`,生产环境使用 Redis Stream 做短窗口 replay,本地/单进程可回退 SQLite event log。
@@ -31,12 +31,11 @@
- 城市图表默认展示“全天”,可选“高温”窗口由 DEB hourly path 推导;所有图表横轴都按城市当地时间展示,不按用户浏览器时区。
- 核心图表组件已拆分为逻辑、状态与 canvas 渲染模块;Recharts 使用 `ResizeObserver` 后的明确宽高,规避 0x0 渲染和长时间挂页后曲线消失。
- DEB hourly consensus`deb_hourly_consensus.v1`)已作为峰值窗口和图表 DEB 曲线的优先小时路径;DEB 仍然是预测曲线,不作为实测来源。
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不再伪装成一条时间序列曲线
- legacy 高斯概率不再占用默认温度图主视图;hover tooltip 会展示 `Gaussian μ` 和完整温度区间概率分布
- AMSC/AMOS 城市的结算跑道曲线默认展示并高亮,辅助跑道作为弱化曲线保留;釜山单跑道只展示 `SR/SL` 结算跑道,不再重复显示 AMOS 聚合线。
- 香港默认展示 CoWIN `6087`(保良局陈守仁小学)1 分钟参考站曲线,HKO 10 分钟实测保留为官方气象层。
- Telegram 机场/跑道推送默认中英文双语,并统一使用结算端点跑道温度计算当前值、15 分钟趋势和文案。
- 运行态状态、缓存与核心离线训练/回填链路已完成 SQLite 主路径收口;legacy JSON/JSONL 仅保留给迁移、导出与显式回退输入。
- EMOS/CRPS 校准链路已接通,但生产主概率保持 `legacy``emos_shadow``emos_primary` 只在本地离线评估通过并手动灰度后启用。
- 官方增强站网已统一接入:
- `MGM`(土耳其)
- `CMA/NMC`(中国内地)
@@ -49,11 +48,11 @@
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与运行态 heartbeat。
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
- 终端城市卡已改为结构化实况 + DEB hourly consensus + 多模型集群 + 校准概率 + 市场温度桶,不再让图表等待 AI 文案生成。
- 终端图表/详情工作流已改为结构化实况 + DEB hourly consensus + 多模型集群 + 概率分布 tooltip + 市场温度桶,不再让图表等待 AI 文案生成。
- 终端数据同时使用页面内存缓存、浏览器 `localStorage`、后端短 TTL 缓存、SSE patch replay 和前台恢复刷新;从其他选项卡切回时会优先恢复最新可见图表状态。
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
- 概率区已改为校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS,模型共识作为辅助参考。
- 市场信号中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
- 概率区已改为校准模型概率”;默认展示 legacy 高斯概率引擎输出,模型共识作为辅助参考。
- 今日日内结构解读以规则与结构化信号为主,AI 文案只作为可降级辅助层,不替代实测、DEB、TAF 或结算逻辑。
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`
@@ -72,17 +71,19 @@
- 聚合 51 个监控城市的实测与预报数据。
- DEBDynamic Error Balancing)融合多模型最高温。
- 构建 DEB 加权小时共识曲线,用于峰值窗口判断和图表默认 DEB 展示。
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎
- 地图城市决策把结构化实况、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯校准路径
- 天气决策把结构化实况、DEB 高温路径、完整市场温度桶和模型-市场差放进图表/详情工作流
- 图表 tooltip 展示校准高斯上下文:`mu` 加完整温度区间概率分布,不把概率温度带重新放回主图。
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
- 已上线站内反馈闭环:提交反馈时自动附带图表上下文,用户可查看处理状态,运营后台可为有价值反馈人工奖励积分。
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
## 参考架构
```mermaid
flowchart LR
U["用户(Web / Telegram"] --> FE["Next.js 前端(Vercel"]
U["用户(Web / Telegram"] --> FE["Next.js 前端(Docker / VPS"]
U --> BOT["Telegram BotVPS"]
FE --> API["FastAPI /web/app.py"]
BOT --> API
@@ -126,6 +127,11 @@ npm ci
npm run dev
```
## 近期更新
- 高斯概率 tooltip 已改为展示完整温度区间概率分布,不再只显示最高概率的单个区间;主图继续聚焦实测和预测曲线。
- 用户反馈已形成产品闭环:终端提交会自动附带图表上下文,用户可在站内查看处理状态,运营侧可为真实、有建设性的反馈发放积分奖励。
## 运行数据目录(VPS 推荐)
建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住):
@@ -142,24 +148,6 @@ POLYWEATHER_REDIS_REQUIRED=true
本地开发或严格单进程兜底可使用 `POLYWEATHER_EVENT_STORE=sqlite`
## EMOS 本地训练流程
低配 VPS 只负责采集、服务和加载已通过评估的参数,不建议在 VPS 上跑 EMOS 全量训练。训练前先从 VPS 拉 SQLite 副本到本地:
```powershell
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
```
本地训练:
```powershell
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
```
只有 `auto_retrain_report.json``ready_for_promotion=true` 时,才允许把候选 `default.json` 传回 VPS,并优先以 `emos_shadow` 观察。
## 运维验收
### 健康与系统状态
@@ -217,8 +205,6 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
| 指令 | 用途 |
| :-- | :-- |
| `/city <name>` | 城市实时分析 |
| `/deb <name>` | DEB 历史对账 |
| `/top` | 用户积分排行 |
| `/id` | 查看聊天 Chat ID |
| `/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)
- Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
- 配置与密钥管理:[docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
- 前端部署(Vercel):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
- 前端部署(Docker / VPS):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
- 技术债:[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
- 机场实时数据源:[docs/AIRPORT_REALTIME_SOURCES.md](docs/AIRPORT_REALTIME_SOURCES.md)
- 机场市场监控(中文):[docs/AIRPORT_MARKET_MONITOR_ZH.md](docs/AIRPORT_MARKET_MONITOR_ZH.md)
@@ -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)
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
- 模型栈与 DEB[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
- DEB 模型家族去重规则[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
- 发布流程:[RELEASE.md](RELEASE.md)
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
@@ -250,4 +236,4 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
## 当前版本
- 版本:`v1.8.1`
- 文档最后更新:`2026-05-28`
- 文档最后更新:`2026-06-07`
+2 -2
View File
@@ -19,8 +19,8 @@ cities:
- id: new_york
city: New York
country: USA
latitude: 40.7128
longitude: -74.006
latitude: 40.7769
longitude: -73.874
- id: chicago
city: Chicago
country: USA
+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"
Write-Host "🚀 Deploying to $VPS..." -ForegroundColor Cyan
+354 -24
View File
@@ -1,23 +1,180 @@
#!/usr/bin/env bash
set -euo pipefail
GHCR_PAT="$1"
NEW_TAG="${2:-latest}"
NEW_TAG="${1:-latest}"
TAG_FILE="/var/lib/polyweather/.current_tag"
COMPOSE_DIR="/root/PolyWeather"
LOCK_FILE="${POLYWEATHER_DEPLOY_LOCK_FILE:-/var/lock/polyweather-deploy.lock}"
echo "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
GHCR_PAT=""
if ! IFS= read -r GHCR_PAT && [ -z "$GHCR_PAT" ]; then
echo "❌ GHCR token must be provided on stdin"
exit 1
fi
if [ -z "$GHCR_PAT" ]; then
echo "❌ GHCR token must be provided on stdin"
exit 1
fi
mkdir -p "$(dirname "$LOCK_FILE")"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "❌ Another PolyWeather deploy is already running"
exit 1
fi
printf '%s' "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
unset GHCR_PAT
cd "$COMPOSE_DIR"
git fetch origin main && git reset --hard origin/main
sync_city_thread_ids() {
local runtime_dir="${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}"
local repo_file="$COMPOSE_DIR/data/city_thread_ids.json"
local target_file="$runtime_dir/city_thread_ids.json"
if [ ! -f "$repo_file" ]; then
echo "No repository city_thread_ids.json to sync"
return 0
fi
mkdir -p "$runtime_dir"
REPO_CITY_THREAD_IDS_FILE="$repo_file" TARGET_CITY_THREAD_IDS_FILE="$target_file" python3 - <<'PY'
import json
import os
import time
repo_file = os.environ["REPO_CITY_THREAD_IDS_FILE"]
target_file = os.environ["TARGET_CITY_THREAD_IDS_FILE"]
with open(repo_file, "r", encoding="utf-8") as f:
repo_data = json.load(f)
if not isinstance(repo_data, dict):
raise SystemExit("repository city_thread_ids.json must contain an object")
target_data = {}
if os.path.isfile(target_file) and os.path.getsize(target_file) > 0:
try:
with open(target_file, "r", encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
target_data = loaded
else:
raise ValueError("target file is not an object")
except Exception as exc:
backup = f"{target_file}.invalid.{int(time.time())}"
os.replace(target_file, backup)
print(f"Backed up invalid city_thread_ids.json to {backup}: {exc}")
merged = dict(repo_data)
merged.update(target_data)
if merged != target_data:
tmp_file = f"{target_file}.tmp"
with open(tmp_file, "w", encoding="utf-8") as f:
json.dump(merged, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
os.replace(tmp_file, target_file)
print(f"Synced city_thread_ids.json: {len(target_data)} -> {len(merged)} cities")
else:
print(f"city_thread_ids.json already up to date: {len(target_data)} cities")
PY
}
sync_city_thread_ids
PREVIOUS_TAG=""
if [ -f "$TAG_FILE" ]; then
PREVIOUS_TAG=$(cat "$TAG_FILE")
echo "Previous tag: $PREVIOUS_TAG"
fi
rollback_to_previous() {
if [ -n "$PREVIOUS_TAG" ]; then
echo "Rolling back to $PREVIOUS_TAG..."
export IMAGE_TAG="$PREVIOUS_TAG"
docker compose pull
compose_up_retry "rollback" -d
echo "✅ Rolled back to $PREVIOUS_TAG"
else
echo "⚠️ No previous tag to rollback to"
fi
}
compose_up_retry() {
local name="$1"
shift
local output=""
for attempt in $(seq 1 6); do
if output=$(docker compose up "$@" 2>&1); then
echo "$output"
return 0
fi
echo "$output"
if echo "$output" | grep -qi "removal of container .* is already in progress"; then
echo "Container removal is still in progress during ${name}; retry ${attempt}/6..."
sleep 5
continue
fi
return 1
done
echo "❌ docker compose up failed for ${name} after retries"
return 1
}
read_env_file_value() {
local key="$1"
if [ ! -f ".env" ]; then
return 0
fi
awk -F= -v key="$key" '
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
value=$0
sub("^[^=]*=", "", value)
gsub("^[[:space:]]+|[[:space:]]+$", "", value)
gsub(/^["'"'"']|["'"'"']$/, "", value)
print value
}
' .env | tail -n 1
}
resolve_env_value() {
local primary_key="$1"
local fallback_key="${2:-}"
local value="${!primary_key:-}"
if [ -z "$value" ]; then
value="$(read_env_file_value "$primary_key")"
fi
if [ -z "$value" ] && [ -n "$fallback_key" ]; then
value="${!fallback_key:-}"
if [ -z "$value" ]; then
value="$(read_env_file_value "$fallback_key")"
fi
fi
printf '%s' "$value"
}
export IMAGE_TAG="$NEW_TAG"
export POLYWEATHER_API_BASE_URL="${POLYWEATHER_FRONTEND_INTERNAL_API_BASE_URL:-http://polyweather_web:8000}"
resolved_supabase_url="$(resolve_env_value "SUPABASE_URL" "NEXT_PUBLIC_SUPABASE_URL")"
resolved_supabase_anon_key="$(resolve_env_value "SUPABASE_ANON_KEY" "NEXT_PUBLIC_SUPABASE_ANON_KEY")"
if [ -n "$resolved_supabase_url" ]; then
export SUPABASE_URL="$resolved_supabase_url"
else
unset SUPABASE_URL
fi
if [ -n "$resolved_supabase_anon_key" ]; then
export SUPABASE_ANON_KEY="$resolved_supabase_anon_key"
else
unset SUPABASE_ANON_KEY
fi
pull_ok=0
for pull_attempt in $(seq 1 6); do
docker compose pull && pull_ok=1 && break
@@ -28,34 +185,207 @@ if [ "$pull_ok" != "1" ]; then
echo "❌ Image pull failed after retries"
exit 1
fi
docker compose up -d
# Wait for backend to be ready (retry up to 150s)
echo "Waiting for backend..."
for i in $(seq 1 30); do
sleep 5
if curl -fsSo /dev/null --max-time 5 "https://api.polyweather.top/healthz"; then
echo "✅ healthz ready after ${i}x5s"
break
smoke_check() {
local name="$1"
local url="$2"
local timeout="$3"
local attempts="${4:-6}"
local delay="${5:-5}"
local output=""
for i in $(seq 1 "$attempts"); do
if output=$(curl -fsS -w "http=%{http_code} time=%{time_total}" -o /dev/null --max-time "$timeout" "$url" 2>&1); then
echo "$name ($output)"
return 0
fi
if [ "$i" != "$attempts" ]; then
echo " $name retry $i/$attempts... ($output)"
sleep "$delay"
fi
done
echo "$name ($output)"
return 1
}
wait_for_local_service() {
local name="$1"
local url="$2"
local timeout="${3:-5}"
local attempts="${4:-30}"
local delay="${5:-2}"
for i in $(seq 1 "$attempts"); do
if curl -fsSo /dev/null --max-time "$timeout" "$url"; then
echo "$name ready after attempt $i/$attempts"
return 0
fi
if [ "$i" != "$attempts" ]; then
echo " $name warming $i/$attempts..."
sleep "$delay"
fi
done
echo "$name did not become ready"
return 1
}
warm_public_route() {
local name="$1"
local url="$2"
local timeout="${3:-15}"
local attempts="${4:-3}"
local delay="${5:-2}"
for i in $(seq 1 "$attempts"); do
if curl -fsSo /dev/null --max-time "$timeout" "$url"; then
echo "✅ warmed $name"
return 0
fi
if [ "$i" != "$attempts" ]; then
echo " warm $name retry $i/$attempts..."
sleep "$delay"
fi
done
echo "⚠️ warm $name failed"
return 0
}
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
echo " retry $i/30..."
done
local normalized
normalized="$(printf '%s' "$api_base" | tr '[:upper:]' '[:lower:]' | sed 's/[[:space:]]//g; s#/*$##')"
case "$normalized" in
http://polyweather.top|https://polyweather.top|http://www.polyweather.top|https://www.polyweather.top)
echo "❌ POLYWEATHER_API_BASE_URL must not point at the frontend site: $api_base"
echo " Use the internal backend URL http://polyweather_web:8000 or the backend API host https://api.polyweather.top."
exit 1
;;
esac
}
PUBLIC_SMOKE_RECHECK_DELAY_SEC="${POLYWEATHER_PUBLIC_SMOKE_RECHECK_DELAY_SEC:-20}"
run_public_smoke_checks() {
local phase="${1:-initial}"
local failed=0
if [ "$phase" = "recheck" ]; then
smoke_check "healthz recheck" "https://api.polyweather.top/healthz" 20 6 10 || failed=1
smoke_check "frontend cities recheck" "https://polyweather.top/api/cities" 30 8 10 || failed=1
smoke_check "frontend recheck" "https://www.polyweather.top/" 20 6 10 || failed=1
else
smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || failed=1
smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5 || failed=1
smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || failed=1
fi
return "$failed"
}
validate_frontend_api_base_url
echo "Updating Redis dependency..."
compose_up_retry "redis" -d polyweather_redis
echo "Updating backend services..."
compose_up_retry "backend services" -d --no-deps polyweather_web polyweather
echo "Waiting for backend..."
wait_for_local_service "backend healthz" "http://127.0.0.1:8000/healthz" 5 30 5 || FAILED_BACKEND=1
FAILED_BACKEND="${FAILED_BACKEND:-0}"
if [ "$FAILED_BACKEND" = "1" ]; then
echo "❌ Backend did not become healthy"
rollback_to_previous
exit 1
fi
echo "Updating observation collector..."
compose_up_retry "observation collector" -d --no-deps polyweather_collector
echo "Updating cache warmer..."
compose_up_retry "cache warmer" -d --no-deps polyweather_warmer
echo "Updating training settlement worker..."
compose_up_retry "training settlement" -d --no-deps polyweather_training_settlement
echo "Updating frontend..."
compose_up_retry "frontend" -d --no-deps polyweather_frontend
echo "Waiting for frontend..."
wait_for_local_service "frontend root" "http://127.0.0.1:3001/" 5 40 2 || FAILED_FRONTEND=1
wait_for_local_service "frontend terminal" "http://127.0.0.1:3001/terminal" 10 20 2 || FAILED_FRONTEND=1
wait_for_scan_terminal_snapshot "scan terminal snapshot" "http://127.0.0.1:3001/api/scan/terminal" 35 8 5 || FAILED_FRONTEND=1
FAILED_FRONTEND="${FAILED_FRONTEND:-0}"
if [ "$FAILED_FRONTEND" = "1" ]; then
echo "❌ Frontend did not become healthy"
rollback_to_previous
exit 1
fi
warm_public_route "terminal" "https://polyweather.top/terminal" 20 4 3
warm_public_route "scan terminal" "https://polyweather.top/api/scan/terminal" 35 3 2
warm_public_route "auth snapshot" "https://polyweather.top/api/auth/me?prefer_snapshot=1" 10 3 2
warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?refresh_deb_recent=1" 15 2 2
warm_public_route "cities" "https://polyweather.top/api/cities" 20 3 2
FAILED=0
curl -fsSo /dev/null --max-time 15 "https://api.polyweather.top/healthz" && echo "✅ healthz" || { echo "❌ healthz"; FAILED=1; }
curl -fsSo /dev/null --max-time 10 "https://api.polyweather.top/api/cities" && echo "✅ cities" || { echo "❌ cities"; FAILED=1; }
curl -fsSo /dev/null --max-time 10 "https://www.polyweather.top/" && echo "✅ frontend" || { echo "❌ frontend"; FAILED=1; }
run_public_smoke_checks || FAILED=1
if [ "$FAILED" = "1" ]; then
echo "⚠️ Initial public smoke failed; retrying before rollback..."
sleep "$PUBLIC_SMOKE_RECHECK_DELAY_SEC"
FAILED=0
run_public_smoke_checks "recheck" || FAILED=1
fi
if [ "$FAILED" = "1" ]; then
echo "❌ Smoke tests failed. Rolling back..."
if [ -n "$PREVIOUS_TAG" ]; then
export IMAGE_TAG="$PREVIOUS_TAG"
docker compose pull
docker compose up -d
echo "✅ Rolled back to $PREVIOUS_TAG"
else
echo "⚠️ No previous tag to rollback to"
fi
rollback_to_previous
exit 1
fi
+13
View File
@@ -9,6 +9,19 @@ server {
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
location /api/events {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
proxy_set_header Connection '';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
+192 -5
View File
@@ -2,7 +2,7 @@ services:
polyweather_redis:
image: redis:7-alpine
container_name: polyweather_redis
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy noeviction
command: redis-server --appendonly yes --maxmemory ${POLYWEATHER_REDIS_MAXMEMORY:-512mb} --maxmemory-policy noeviction
restart: unless-stopped
healthcheck:
interval: 10s
@@ -28,7 +28,12 @@ services:
env_file: &id001
- .env
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}
healthcheck:
interval: 60s
@@ -64,11 +69,18 @@ services:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://polyweather.top}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL:-https://polygon-bor-rpc.publicnode.com}
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID:-}
POLYWEATHER_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_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true}
POLYWEATHER_OPS_ADMIN_EMAILS: ${POLYWEATHER_OPS_ADMIN_EMAILS:-}
POLYWEATHER_TURNSTILE_BYPASS: ${POLYWEATHER_TURNSTILE_BYPASS:-false}
POLYWEATHER_TURNSTILE_ENFORCE_ACTION: ${POLYWEATHER_TURNSTILE_ENFORCE_ACTION:-false}
POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT: ${POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT:-false}
POLYWEATHER_TURNSTILE_SECRET_KEY: ${POLYWEATHER_TURNSTILE_SECRET_KEY:-}
healthcheck:
interval: 30s
retries: 3
@@ -78,7 +90,7 @@ services:
timeout: 5s
image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}
ports:
- 3001:3000
- "127.0.0.1:3001:3000"
restart: unless-stopped
polyweather_web:
command: python web/app.py
@@ -92,6 +104,41 @@ services:
max-file: "3"
container_name: polyweather_web
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:
interval: 30s
retries: 3
@@ -103,7 +150,147 @@ services:
timeout: 5s
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
ports:
- 8000:8000
- "127.0.0.1:8000:8000"
restart: unless-stopped
ulimits:
nofile:
soft: 65535
hard: 65535
user: ${UID:-1000}:${GID:-1000}
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
polyweather_collector:
command: python -m web.observation_collector_worker
container_name: polyweather_collector
depends_on:
polyweather_redis:
condition: service_healthy
polyweather_web:
condition: service_started
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
cpus: ${POLYWEATHER_COLLECTOR_CPUS:-0.75}
env_file: *id001
environment:
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ${POLYWEATHER_COLLECTOR_PATCH_ENDPOINT:-http://polyweather_web:8000/api/internal/collector-patch}
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-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
user: ${UID:-1000}:${GID:-1000}
volumes:
+19 -10
View File
@@ -11,13 +11,13 @@
| 香港 | CoWIN 6087 | 6087 | CoWIN (`cowin.hku.hk`) | 1 分钟 | 参考站温度(保良局陈守仁小学) | 免费 |
| 香港 | HKO | HKO | HKO 官方 CSV (`data.weather.gov.hk`) | 10 分钟 | 官方气象站温度 | 免费 |
| 台北 | 松山/中央气象署 | 466920 | CWA 开放数据 | 10 分钟 | 官方站点温度 | 免费 |
| 北京 | 首都机场 | ZBAA | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 上海 | 浦东机场 | ZSPD | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 广州 | 白云机场 | ZGGG | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 成都 | 双流机场 | ZUUU | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 重庆 | 江北机场 | ZUCK | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 武汉 | 天河机场 | ZHHH | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 青岛 | 胶东机场 | ZSQD | AMSC AWOS | 1 分钟 | 跑道端点气温 | 免费 |
| 北京 | 首都机场 | ZBAA | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 上海 | 浦东机场 | ZSPD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 广州 | 白云机场 | ZGGG | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 成都 | 双流机场 | ZUUU | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 重庆 | 江北机场 | ZUCK | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 武汉 | 天河机场 | ZHHH | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 青岛 | 胶东机场 | ZSQD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
| 东京 | 羽田 | RJTT | JMA AMeDAS (`jma.go.jp`) | 10 分钟 | 机场站点实时温度 | 免费 |
| 安卡拉 | Esenboğa | 17128 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
| 伊斯坦布尔 | 伊斯坦布尔机场 | 17058 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
@@ -56,11 +56,20 @@
> `MID_TEMP` / `END_TEMP`。这些字段是跑道观测位置气温,不是道面温度。
> 结算跑道展示使用配置的结算端点;辅助跑道只作为背景曲线。
## 推送机制
## 独立观测采集器
- Web/API 进程启动 `observation-collector` 后台线程,按源频率独立采集,不依赖 Telegram 推送循环
- 默认频率:AMOS 60s、AMSC AWOS 180s、MADIS HFMETAR 300s、CoWIN 60s、HKO 600s
- 每次采集复用 `weather_sources.py` 现有 `_attach_*` 写入逻辑,负责写 `airport_obs_log` / `runway_obs_log` / 今日观测缓存,并通过 `/api/internal/collector-patch` 写 Redis Stream 或 SQLite event log 后广播 SSE
- 采集成功后刷新对应城市 `panel` cache;前端继续使用 HTTP snapshot + SSE patch,不需要依赖 Telegram 触发更新
- `observation_source_gate.py` 对 AMSC、AMOS、MADIS、HKO、CoWIN 做 per-source/per-city singleflight 和 SQLite cooldown,防止 Web 请求、collector 和兜底分析同时打同一个外部源
## Telegram 推送机制
- 每城按原生频率独立推送,不捆绑
- 首尔/釜山 60s,其余 600s
- 首尔/釜山 60s中国 AMSC 城市 180s其余 600s
- 循环轮询 60s 以匹配最快频率
- Telegram 推送优先读取网站侧 `full`/`panel` 城市缓存;缓存缺失时只做非强制 `panel` 分析兜底,不触发 `force_refresh_observations_only`
- 仅当当前温度距 DEB 预测最高 ≤3°C 时推送
- 确认过峰值后自动停止
@@ -69,7 +78,7 @@
为了向用户提供接近行情盘的实况响应并降低服务器负载,系统使用 **HTTP snapshot + Server-Sent Events (SSE) Patch + 可重放事件日志** 架构。生产环境推荐 Redis Stream;本地或单进程可回退 SQLite event log。
### 1. 数据推送链路 (Data Pipeline)
1. **Collector 采集端触发**:在 `weather_sources.py` 中,当高频实况源(如 AMOS, CoWIN, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
1. **Observation Collector 采集端触发**`web.observation_collector_service` 按源频率调用采集层;`weather_sources.py` 中,当高频实况源(如 AMOS, AMSC, CoWIN, HKO, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
2. **标准化事件**`realtime_patch_schema.py` 将旧 `city_patch` 或新 payload 统一成 `city_observation_patch.v1`
3. **事件存储**:生产环境写入 Redis Stream`stream:city_observation`)并生成全局递增 `revision`SQLite `observation_patch_events` 保留为本地/兜底 replay。
4. **FastAPI SSE 广播**FastAPI 后端的 `sse_router.py` 根据城市订阅集合向匹配连接推送 patch;断线重连时按 `since_revision` replay。
+8 -1
View File
@@ -7,7 +7,7 @@
## 1. 基础信息
- 后端直连:`http://127.0.0.1:8000`
- 前端 BFF`https://polyweather-pro.vercel.app/api/*`
- 前端 BFF`https://polyweather.top/api/*`
- 返回格式:`application/json`
## 2. 请求链路
@@ -238,6 +238,13 @@ DEB hourly consensus 是当前图表与峰值窗口的优先小时路径。
### 支付状态建议
`POST /api/payments/intents` 支持的关键字段:
- `plan_code`:套餐,例如 `pro_monthly`
- `payment_mode``wallet``direct`
- `chain_id`:可选;多链支付时前端传用户选择的链,例如 Polygon `137` 或 Ethereum `1`
- `token_address`:可选;指定该链上的 USDC / USDC.e 合约地址
前端流程建议:
1. `POST /intents`
+9 -8
View File
@@ -13,13 +13,13 @@
| hong kong | CoWIN 6087 | ~1 min | cowin.hku.hk, 保良局陳守仁小學,前端图表默认展示 |
| hong kong | HKO 官方 CSV | ~10 min | data.weather.gov.hk(文件名虽含 1min,实际 10min 一报) |
| singapore | MSS 官方 API | ~1 min | api.data.gov.sg, 站号 S24 |
| beijing | AMSC AWOS (ZBAA) | ~1 min | 中国 |
| shanghai | AMSC AWOS (ZSPD) | ~1 min | 中国 |
| guangzhou | AMSC AWOS (ZGGG) | ~1 min | 中国 |
| chengdu | AMSC AWOS (ZUUU) | ~1 min | 中国 |
| chongqing | AMSC AWOS (ZUCK) | ~1 min | 中国 |
| wuhan | AMSC AWOS (ZHHH) | ~1 min | 中国 |
| qingdao | AMSC AWOS (ZSQD) | ~1 min | 中国 |
| beijing | AMSC AWOS (ZBAA) | 3 min | 中国 |
| shanghai | AMSC AWOS (ZSPD) | 3 min | 中国 |
| guangzhou | AMSC AWOS (ZGGG) | 3 min | 中国 |
| chengdu | AMSC AWOS (ZUUU) | 3 min | 中国 |
| chongqing | AMSC AWOS (ZUCK) | 3 min | 中国 |
| wuhan | AMSC AWOS (ZHHH) | 3 min | 中国 |
| qingdao | AMSC AWOS (ZSQD) | 3 min | 中国 |
### Tier 2 — 5 分钟高频 (MADIS)
@@ -123,7 +123,8 @@ ankara, istanbul, helsinki, amsterdam, paris
频率取决于源头:
- AMSC / AMOS / CoWIN / MSS:源头约 1 分钟,图表按 1 分钟粒度追加。
- AMOS / CoWIN / MSS:源头约 1 分钟,图表按 1 分钟粒度追加。
- AMSC:中国跑道观测城市按 3 分钟采集,不再强制 60 秒刷新。
- MADIS:源头约 5 分钟。
- HKO / CWA / JMA / FMI / KNMI:源头约 10 分钟。
- METAR-only 城市:按 METAR 可用频率和缓存 TTL,不伪装成 1 分钟实测。
+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
```
+14 -8
View File
@@ -1,6 +1,6 @@
# 商业化说明(Production
最后更新:`2026-05-28`
最后更新:`2026-05-30`
## 1. 定位
@@ -18,10 +18,13 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
| 能力 | 状态 | 备注 |
| :-- | :-- | :-- |
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
| 订阅套餐(Pro 月付) | 已上线 | `10 USDC / 30天` |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
| 订阅套餐(Pro 月付 / 季度 | 已上线 | `29.9 USDC / 30天``79.9 USDC / 90天` |
| 邀请积分 | 已上线 | 被邀请人首月 `20U`;邀请人每个有效付费邀请 `+3500` 积分 |
| 用户增长奖励 | 已上线 | 按已验证用户里程碑,为当前有效且有 confirmed 入账记录的付费会员延长 Pro |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U`;邀请首月价不叠加积分抵扣 |
| 合约支付 | 已上线 | PolygonUSDC + USDC.e |
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
| 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 |
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop,按 intent.chain_id 查链 |
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
| 私有频道推送 | 已上线 | 可拆分业务频道 |
| 实时终端 | 已上线 | SSE Patch + Redis Stream replay |
@@ -37,13 +40,16 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
- 概率分布层(基于 DEB 融合 + 高斯分桶)
- 实时终端多城市图表(跑道/官方站点/DEB/概率带)
- 历史对账 + 未来日期分析
- 全平台智能气象推送
- Telegram 缓存推送与业务频道通知
## 4. 收费与积分规则(默认)
- 套餐:`pro_monthly`10 USDC / 30 天)
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC
- 实付下限:2 USDC(当积分满额时)
- 套餐:`pro_monthly`29.9 USDC / 30 天)`pro_quarterly`79.9 USDC / 90 天)
- 邀请首月:被邀请人首次月付 20 USDC
- 邀请奖励:邀请人 +3500 积分,每月最多 10 个有效付费邀请
- 用户增长奖励:600 已验证用户 `+1天`、750 `+2天`、1000 `+3天`,此后每增长 100 人 `+3天`
- 抵扣:500 积分抵 1 USDC;月付最高抵 3 USDC,季度最高抵 8 USDC
- 积分来源:仅通过有效付费邀请和后台人工补发,不再通过 Telegram 群发言发放
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
+82 -15
View File
@@ -17,7 +17,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
3. 平台侧真实密钥
放在:
- VPS / Docker `.env`
- Vercel Environment Variables
- GitHub Secrets(构建期 `NEXT_PUBLIC_*` 通过 build-arg 注入前端镜像)
- GitHub Secrets(如需要)
## 2. 为什么要拆
@@ -66,7 +66,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
用途:
- 前端本地开发与 Vercel 环境变量模板
- 前端本地开发与容器运行时环境变量模板
## 4. 配置分级
@@ -110,7 +110,11 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `POLYWEATHER_EVENT_STORE`
- `POLYWEATHER_REDIS_REQUIRED`
- `POLYWEATHER_PAYMENT_ENABLED`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- `POLYGON_WALLET_WATCH_ENABLED`
- `POLYWEATHER_TURNSTILE_BYPASS`
- `POLYWEATHER_TURNSTILE_ENFORCE_ACTION`
- `POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT`
- `TELEGRAM_ALERT_PUSH_ENABLED`
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
@@ -131,6 +135,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC`
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
- `POLYWEATHER_PAYMENT_RPC_URLS`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- `TAF_CACHE_TTL_SEC`
- `POLYWEATHER_REDIS_URL`
- `POLYWEATHER_REDIS_STREAM_KEY`
@@ -152,6 +157,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
- `POLYWEATHER_TURNSTILE_SECRET_KEY`
- `POLYWEATHER_R2_ACCESS_KEY_ID`
- `POLYWEATHER_R2_SECRET_ACCESS_KEY`
## 5. 推荐部署矩阵
@@ -163,20 +171,28 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- 所有 secrets
- Bot / 支付 / watcher 配置
### 5.2 Vercel前端)
### 5.2 前端容器(Docker Compose
建议只放前端真正需要的变量
前端与后端一起以 Docker Compose 部署,环境变量分两类来源
- `POLYWEATHER_API_BASE_URL`
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
**运行时变量**`.env` 或 compose `environment` 块):
- `POLYWEATHER_API_BASE_URL`(容器内使用 `http://polyweather_web:8000`
- `POLYWEATHER_AUTH_ENABLED`
- `POLYWEATHER_AUTH_REQUIRED`
- `POLYWEATHER_OPS_ADMIN_EMAILS`
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
**构建期变量**GitHub Secrets → CI `build-and-push``frontend/Dockerfile``ARG`,改了必须重新构建镜像):
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `NEXT_PUBLIC_SITE_URL`
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
- `NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL`
- `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS`
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY`
- `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS`
- `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS`
- `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES`
@@ -186,19 +202,43 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `/ops` 现在是前后端双层限制:
- 前端页面入口读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
- 后端写接口同样读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
- 因此,Vercel 和 VPS / Docker 两侧都应配置相同的管理员邮箱白名单。
- 因此,前端容器和后端容器两侧都应配置相同的管理员邮箱白名单。
不要把后端专用密钥全搬进 Vercel
不要把后端专用密钥全搬进前端容器
### 5.3 GitHub Actions
当前 CI 不需要大规模 secrets。
当前 CI 已配置自动部署,需要的 secrets 见 `.github/workflows/ci.yml`
如果未来要做自动部署,再考虑:
- `VPS_SSH_KEY` / `VPS_HOST` / `VPS_USER` / `GHCR_PAT`SSH 部署到 VPS
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ZONE_ID`(同步 Cloudflare Cache Rules
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY`(构建期注入前端镜像)
- 前端构建期 `NEXT_PUBLIC_*`(注入前端镜像)
- `VERCEL_TOKEN`
- `VERCEL_ORG_ID`
- `VERCEL_PROJECT_ID`
### 5.4 Cloudflare 免费能力
Turnstile
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY` 是浏览器可见的 site key,属于前端构建期变量;改动后需要重新构建前端镜像。
- `POLYWEATHER_TURNSTILE_SECRET_KEY` 只放 VPS / Docker `.env`,用于 Next API Route 服务端校验。
- `POLYWEATHER_TURNSTILE_BYPASS=true` 可在排障时临时关闭校验。
- 支付 tx 提交默认不强制二次 Turnstile,因为 Cloudflare token 是一次性校验;订单创建已做校验。只有确认 UX 能支持二次挑战时,才设置 `POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT=true`
R2
- `POLYWEATHER_R2_ACCOUNT_ID`
- `POLYWEATHER_R2_BUCKET`
- `POLYWEATHER_R2_ACCESS_KEY_ID`
- `POLYWEATHER_R2_SECRET_ACCESS_KEY`
- `POLYWEATHER_R2_REGION=auto`
- `POLYWEATHER_R2_ARCHIVE_SOURCE=redis`
归档脚本只读 Redis Stream 或 SQLite,不删除热路径数据:
```bash
python scripts/archive_realtime_events_to_r2.py --date 2026-06-16 --dry-run
python scripts/archive_realtime_events_to_r2.py --date 2026-06-16
```
## 6. 最小部署示例
@@ -259,7 +299,8 @@ POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`
- `POLYWEATHER_EVENT_STORE=redis` 表示实时观测 patch 使用 Redis Stream 做短窗口 replay 和多 worker fanout;本地或单进程可改为 `sqlite`
- `POLYWEATHER_REDIS_REQUIRED=true` 表示 Redis 不可用时后端启动失败,避免生产环境广播不可 replay 的实时事件;开发环境可设为 `false` 允许回退 SQLite。
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持默认链的逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON` 用于多链支付,例如同时支持 Polygon 和 Ethereum 主网 USDC。
- 机器人市场监控包含 `关键提醒``关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
@@ -267,6 +308,32 @@ POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
- 这层只负责把结构化信号改写成短摘要,不替代真实模型、机场锚点和结算逻辑。
### 6.3 支付多链配置示例
当前生产推荐:
- 默认链:Polygon `chain_id=137`,继续承载 checkout 合约支付。
- 补充链:Ethereum Mainnet `chain_id=1`,正式支持 USDC 直转确认。
- 前端创建支付 intent 时会提交用户选择的 `chain_id`;后端确认时按 intent 的链和 token 查询对应 RPC。
```env
POLYWEATHER_PAYMENT_ENABLED=true
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com","https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<polygon_checkout_contract>
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=0x<treasury_or_receiver_wallet>
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<polygon_checkout_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>","is_default":true},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x<treasury_or_receiver_wallet>","supports_contract_checkout":false,"supports_direct_transfer":true,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}]
```
注意:
- `POLYWEATHER_PAYMENT_CHAIN_ID` 只是默认链,不代表只支持这一条链。
- `POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON` 里每个 token 必须有明确 `chain_id`
- Ethereum 行如果没有部署 checkout 合约,必须设置 `supports_contract_checkout=false`,前端会显示手动转账并阻止钱包合约支付。
- 私有 RPC URL 带 API key 时应放入真实 `.env` 或密钥管理,不要提交。
### 6.5 机器人市场监控建议配置
这套配置围绕市场本身做两类推送:
+105 -146
View File
@@ -1,65 +1,97 @@
# 前端部署配置(Vercel
# 前端部署配置(Docker / VPS
最后更新:`2026-05-28`
最后更新:`2026-06-14`
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。前端当前不再使用 Vercel,统一与后端一起以 Docker Compose 形式部署在同一台 VPS 上,前面挂 Cloudflare + Nginx。
## 一、部署目标
推荐方案:
当前方案:
1. GitHub Actions 负责 `CI`
2. Vercel 负责前端 `CD`
3. FastAPI 后端单独部署在 VPS / Docker 主机
1. GitHub Actions 负责 `CI``python-quality` + `frontend-quality`
2. `build-and-push` job 把前端构建为 Docker 镜像 `ghcr.io/yangyuan-zhen/polyweather-frontend`
3. `deploy` job 通过 SSH 把 `deploy.sh` 推到 VPS,由 VPS 拉取新镜像并滚动更新
前端本身不直接访问天气源,而是通过 Next Route Handlers 转发到后端:
1. 浏览器 -> Vercel 上的 Next.js 前端
2. Next `/api/*` -> `POLYWEATHER_API_BASE_URL`
3. FastAPI 后端 -> 分析 / 支付 / 鉴权服务
1. 浏览器 → Cloudflare → Nginx → 前端容器(Next.js standalone
2. Next `/api/*` `POLYWEATHER_API_BASE_URL`(容器内默认 `http://polyweather_web:8000`
3. FastAPI 后端 分析 / 支付 / 鉴权服务
实时图表同样走 Next Route Handler / rewrite
实时图表同样走 Next Route Handler
1. 浏览器 `EventSource` -> `/api/events?cities=...&since_revision=...`
1. 浏览器 `EventSource` `/api/events?cities=...&since_revision=...`
2. Next 转发到 FastAPI `/api/events`
3. FastAPI 从 Redis Stream / SQLite event log replay 后进入 live SSE
## 二、Vercel 项目设置
## 二、镜像与构建
在 Vercel 导入 GitHub 仓库后,使用下面的设置
前端镜像定义在 `frontend/Dockerfile`,三阶段构建
- Framework Preset: `Next.js`
- Root Directory: `frontend`
- Build Command: `npm run build`
- Install Command: `npm ci`
- `deps``npm ci` 安装依赖
- `builder`:通过 `ARG` 注入 `NEXT_PUBLIC_*` 变量后执行 `npm run build`,产出 standalone 产物
- `runner`:只拷贝 `.next/standalone``.next/static``public`,以 `node server.js` 启动
如果仓库已经连接过 Vercel,通常只需要确认 `Root Directory` 仍然是 `frontend`
`NEXT_PUBLIC_*` 变量是在 **构建期** 注入的(见 `frontend/Dockerfile``ARG` 块),CI 在 `.github/workflows/ci.yml``build-and-push` job 里从 GitHub Secrets 读取并作为 `--build-arg` 传入。修改这类变量必须重新构建镜像,仅改运行时环境无效
## 三、最小必填环境变量
## 三、Compose 服务
只部署天气看板和基础登录时,先填下面 4 项
前端在 `docker-compose.yml` 中对应 `polyweather_frontend` 服务
```env
POLYWEATHER_API_BASE_URL=https://<your-fastapi-host>
NEXT_PUBLIC_SUPABASE_URL=https://<your-project>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your-anon-key>
POLYWEATHER_AUTH_ENABLED=true
- 镜像:`ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}`
- 容器内监听 `:3000`,映射到宿主 `127.0.0.1:3001`
- 健康检查:`wget -qO- http://$(hostname):3000`
- 运行时环境变量(非 `NEXT_PUBLIC_*` 的那部分)通过 compose `environment` 注入,例如:
- `POLYWEATHER_API_BASE_URL=http://polyweather_web:8000`(容器内走后端服务名)
- `POLYWEATHER_AUTH_ENABLED` / `POLYWEATHER_AUTH_REQUIRED`
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
- `POLYWEATHER_OPS_ADMIN_EMAILS`
`POLYWEATHER_API_BASE_URL` **禁止** 指向前端站点自身(`polyweather.top`),否则会形成回环。`deploy.sh` 里的 `validate_frontend_api_base_url` 会在部署前拦截。容器内应使用 `http://polyweather_web:8000`
## 四、部署流程(`deploy.sh`
生产部署由 GitHub Actions 在 `main` push 时触发,关键步骤:
1. SSH 登录 VPS,用 GHCR PAT 登录镜像仓库
2. `git fetch origin main && git reset --hard origin/main` 同步仓库(含 `docker-compose.yml`
3. 同步 `data/city_thread_ids.json` 到运行态目录
4. `docker compose pull` 拉取新镜像(带重试)
5. 按顺序滚动更新:`redis``web` + `bot``collector``warmer``frontend`
6. 每步后做本地健康检查;前端额外等待 `/terminal``/api/scan/terminal` 就绪
7. 公网 smoke check`https://api.polyweather.top/healthz``https://polyweather.top/api/cities``https://www.polyweather.top/`
8. 任意一步失败自动回滚到上一个镜像 tag(记录在 `/var/lib/polyweather/.current_tag`
部署失败时优先看 `deploy.sh` 输出里哪一步打了 `❌`,并检查 `docker compose logs polyweather_frontend`
## 五、最小必填配置
只部署天气看板和基础登录时,至少需要:
构建期(CI Secrets,对应 `frontend/Dockerfile``ARG`):
```
NEXT_PUBLIC_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY
NEXT_PUBLIC_SITE_URL=https://polyweather.top
```
建议显式补
运行期(`.env` 或 compose `environment`
```env
POLYWEATHER_API_BASE_URL=http://polyweather_web:8000
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=true
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=<与后端共享>
```
说明:
- `POLYWEATHER_API_BASE_URL`:前端所有 `/api/*` Route Handler 转发时依赖它,没填会直接返回 500。
- `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY`Supabase 客户端依赖它们。
- `POLYWEATHER_AUTH_ENABLED`:关闭时,前端不会启用登录能力
- `POLYWEATHER_AUTH_REQUIRED`:控制 middleware 是否强制登录。
- `POLYWEATHER_API_BASE_URL`:前端所有 `/api/*` Route Handler 转发时依赖它,没填或填错会直接返回 500。
- `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY`Supabase 客户端依赖它们,构建期注入
- `POLYWEATHER_AUTH_ENABLED` / `POLYWEATHER_AUTH_REQUIRED`:控制 middleware 是否强制登录
## 、按功能启用的可选环境变量
## 、按功能启用的可选环境变量
### 1. 分享式看板
@@ -69,119 +101,65 @@ POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
设置后,可通过 `/?access_token=<token>` 打开带令牌的看板入口。
### 2. 前后端 entitlement 校验
### 2. 钱包支付(构建期)
```env
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
```
仅当后端开启 entitlement / 订阅校验时需要。
### 3. 钱包支付
```env
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
```
如果不启用钱包支付,可以留空。
### 4. `/ops` 管理员页面守卫
### 3. `/ops` 管理员页面守卫
```env
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
```
说明:
`/ops` 页面入口会读取管理员邮箱白名单,前端和后端容器都应配置相同的值。
- `/ops` 现在不是只有后端接口限制,前端页面入口也会读取管理员邮箱白名单。
- 因此前端部署到 Vercel 时,也应配置 `POLYWEATHER_OPS_ADMIN_EMAILS`
### 4. Telegram 入口(构建期)
### 5. Telegram 入口
```env
```
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
```
只影响按钮跳转,不影响核心页面加载。
### 6. 前端观测与预热开关(推荐默认关闭)
### 5. 前端观测与预热开关(推荐默认关闭)
```env
```
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false
NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
```
说明:
## 七、支付配置与旧镜像治理
- `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false`:关闭前端自建埋点。
- `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false`:关闭前端 Web Vitals 上报。
- `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false`:关闭首页全量城市 summary 预热,避免白白消耗 Vercel function / edge 成本。
## 五、支付配置与旧部署治理
支付区现在有一层额外防护:
支付区有一层额外防护:
1. 用户点击支付前,前端会重新请求 `/api/payments/config`
2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址
3. 若后端返回的 `tx_payload.to` 与最新 `receiver_contract` 不一致,会直接阻断支付
4. 多链支付时,前端会展示后端返回的网络列表,并把用户选择的 `chain_id` 传给后端创建 intent
5. Ethereum 主网 USDC 当前走手动直转确认,前端不会把它当成 Polygon checkout 合约支付
这层防护的目的,是降低以下事故概率:
这层防护降低以下事故概率:
- 用户使用长期未刷新的旧标签页
- 命中旧 deployment URL
- 页面本地状态残留旧收款地址
- 用户在钱包默认网络(例如 Ethereum)付款,但系统按 Polygon intent 查账
如果变更过支付收款地址,建议同步执行:
如果变更过支付收款地址,由于前端镜像是构建期注入地址,需要触发一次 `main` push(或重新运行 deploy workflow)来发布新镜像;浏览器侧靠 `/api/payments/config` 的运行时校验兜底,无需等待所有用户刷新。
1. 在 Vercel 对当前 production 做一次 redeploy
2. 删除明显过期、可能还带旧支付配置的旧 deployment
3. 在 `Settings -> Security -> Deployment Retention Policy` 中收紧旧部署保留周期
## 八、不要放进前端容器的变量
## 六、推荐的三套配置口径
这些属于后端私密配置,不应该放到前端服务:
### 1. 公开游客模式
```env
POLYWEATHER_API_BASE_URL=https://api.example.com
POLYWEATHER_AUTH_ENABLED=false
POLYWEATHER_AUTH_REQUIRED=false
```
适合公开演示站。
### 2. 正常登录模式
```env
POLYWEATHER_API_BASE_URL=https://api.example.com
NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=true
```
适合正式前端站点。
### 3. 登录 + entitlement 联动
```env
POLYWEATHER_API_BASE_URL=https://api.example.com
NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=true
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=<shared-token>
```
适合前后端都启用了会员/订阅保护的生产环境。
## 七、不要放进 Vercel 的变量
这些属于后端私密配置,不应该放到前端项目:
- `SUPABASE_SERVICE_ROLE_KEY`
- `SUPABASE_SERVICE_ROLE_KEY`(除非前端 Route Handler 明确需要,且仅以非 `NEXT_PUBLIC_` 形式注入容器)
- `TELEGRAM_BOT_TOKEN`
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN` 以外的后端 secret
- 支付签名私钥 / 交易私钥 / 任何 bot 凭据
@@ -191,74 +169,55 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=<shared-token>
- `NEXT_PUBLIC_*` 会暴露给浏览器
- 只有明确允许前端公开使用的值,才应加 `NEXT_PUBLIC_`
## 、上线前检查
## 、上线前检查
Vercel 部署前至少确认:
部署前至少确认:
1. `POLYWEATHER_API_BASE_URL` 指向可访问的后端生产地址
2. `frontend/.env.example` 和 Vercel Project Settings 中的实际值一致
1. `POLYWEATHER_API_BASE_URL` 指向容器内后端服务名 `http://polyweather_web:8000`**不是** `polyweather.top`
2. CI Secrets 中的 `NEXT_PUBLIC_*` 值与预期一致(构建期注入,改了要重新构建镜像)
3. GitHub Actions 中 `frontend-quality` 已通过
4. 如果启用鉴权,Supabase redirect URL 已包含前端域名
5. `GET /api/payments/config` 返回的是当前最新地址,而不是旧收款合约
6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在 Vercel 与后端同时配置
7. 确认 `/api/events` 没有被 CDN 缓存或压缩成普通 JSON;它必须保持 `text/event-stream`
6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在前端与后端容器同时配置
7. 确认 `/api/events` 没有被 Cloudflare / Nginx 缓存或压缩成普通 JSON;它必须保持 `text/event-stream`
## 、常见问题
## 、常见问题
### 1. 页面打开后 API 全部 500
先检查
先检查容器内 `POLYWEATHER_API_BASE_URL` 是否指向 `http://polyweather_web:8000`,以及 `polyweather_web` 容器是否健康。
```env
POLYWEATHER_API_BASE_URL
```
这是最常见原因。
### 2. Vercel 构建通过,但登录失败
### 2. 构建通过,但登录失败
先检查:
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- Supabase 项目里的站点 URL / redirect URL
- 构建期注入的 `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- Supabase 项目里的站点 URL / redirect URL 是否包含前端域名
### 3. 钱包入口显示未配置
检查
检查构建期 `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` 是否注入(前端镜像需要重新构建)。
```env
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
```
### 4. 改了 `NEXT_PUBLIC_*` 但线上没生效
是钱包连接的必需项
类变量是构建期注入的。仅改 CI Secrets 不会更新已部署镜像,需要重新触发 `build-and-push` + `deploy`(即一次 `main` push,或手动重跑 deploy workflow
## 十、Vercel 成本与节流建议
## 十、成本与节流建议
### 1. 建议先关闭的项目级能力
### 1. Cloudflare 缓存规则
- `Web Analytics`
- `Speed Insights`
前端通过 `next.config.mjs``headers()` 为静态资源(`_next/static`、图片、字体)设置 `Cache-Control: public, max-age=31536000, immutable`,并为公共页面设置 `s-maxage=600, stale-while-revalidate=3600`。CI 的 `cloudflare-cache-rules` job 会同步 Cloudflare Cache Rules(见 `scripts/configure_cloudflare_free.py`)。
它们对排查前端体验有价值,但在 Hobby / 低预算阶段会额外消耗数据点和边缘资源。
### 2. Cloudflare WAF 规则
### 2. 建议加的 Firewall 自定义规则
如果你的 Next.js 项目根本不提供 WordPress / PHP 路径,建议在 Vercel Firewall 里先 `Log``Deny` 这条规则:
如果发现大量 WordPress / PHP 扫描流量命中 Next.js(实际并不提供这些路径),建议在 Cloudflare WAF 中先 `Log``Deny` 这条规则
```regex
(^/(wp-admin|wp-includes|wp-content|wp-login|wordpress|xmlrpc\.php))|\.php($|\?)
```
目的:
目的:在边缘层提前拦截扫描流量,避免无效请求继续触发 Nginx、Next.js middleware 与 route handler。
- 在边缘层提前拦截 WordPress / PHP 扫描流量
- 避免无效请求继续触发 middleware 与 route handler
### 3. SSE 路径不要进缓存
### 3. 建议的上线前检查
除了功能本身,额外确认:
1. `Web Analytics``Speed Insights` 是否真的关闭
2. `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS` / `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS` / `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES` 是否保持关闭
3. Firewall 自定义规则是否已从 `Log` 切到 `Deny`
`/api/events` 必须保持 `text/event-stream`Cloudflare 和 Nginx 都不应缓存或压缩它。检查 Nginx 配置(`deploy/nginx/polyweather.conf`)中对 `/api/events``proxy_buffering off`
+1 -1
View File
@@ -6,7 +6,7 @@
前端入口:
- `https://polyweather-pro.vercel.app/ops`
- `https://polyweather.top/ops`
## 2. 权限
+4 -2
View File
@@ -1,6 +1,6 @@
# 外部服务依赖总览
最后更新:`2026-05-28`
最后更新:`2026-05-29`
项目调用外部天气、鉴权、支付和实时事件服务。原则是:核心链路必须有明确健康检查;可选数据源不可拖垮已可用城市;实时事件层可从 Redis Stream 降级到 SQLite event log。
@@ -52,7 +52,8 @@
| --- | --- | --- |
| MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 |
| DeepSeek | AI fallback | 备用 |
| Polygon RPC | 链上支付、自动确认 | ✅ |
| Polygon RPC | checkout 合约支付、Polygon USDC / USDC.e 自动确认 | ✅ |
| Ethereum RPC | Ethereum 主网 USDC 直转确认 | ✅(启用多链支付时必须) |
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key 时钱包入口降级 |
## 运维口径
@@ -61,3 +62,4 @@
- 本地或单进程兜底:`POLYWEATHER_EVENT_STORE=sqlite`
- Redis 只负责短窗口 replay 与多 worker fanout,不是长期天气历史库。
- DEB hourly consensus 依赖 Open-Meteo 多模型小时曲线;若上游限流,图表应保留已有 snapshot 和实测 patch,不把缺失模型误报为实测缺失。
- 支付多链确认依赖 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`;如果启用 Ethereum 主网 USDC,必须配置 `chain_id=1` 的 RPC,否则用户提交 Ethereum tx hash 后无法自动确认。
+34 -10
View File
@@ -1,12 +1,12 @@
# Supabase + 登录 + 支付接入说明(v1.8.1)
最后更新:`2026-03-14`
最后更新:`2026-05-29`
## 1. 目标
- 前端支持 Google 一键登录 + 邮箱注册/登录。
- 后端支持 Supabase JWT 鉴权。
- 支持 Polygon 合约支付(USDC / USDC.e)并自动确认开通订阅。
- 支持 Polygon 合约支付(USDC / USDC.e和 Ethereum 主网 USDC 直转支付,并自动确认开通订阅。
## 2. Supabase 控制台配置
@@ -15,13 +15,14 @@
- `https://<project-ref>.supabase.co/auth/v1/callback`
3. `Auth -> URL Configuration` 添加:
- 站点 URL(生产域名)
- 回调 URL(例如 `https://polyweather-pro.vercel.app/auth/callback`
- 回调 URL(例如 `https://polyweather.top/auth/callback`
## 3. 数据库脚本
在 Supabase SQL Editor 执行:
- `scripts/supabase/schema.sql`
- 既有生产项目遇到 Disk IO Budget 告警时,再执行 `scripts/supabase/io_budget_indexes.sql`
会创建支付与订阅相关表:
@@ -33,9 +34,18 @@
- `payment_intents`
- `payment_transactions`
### 3.1 Disk IO 告警处理
如果 Supabase 提示项目正在耗尽 Disk IO Budget,先在 SQL Editor 执行:
1. `scripts/supabase/io_budget_indexes.sql`
2. `scripts/supabase/disk_io_diagnostics.sql`
第一个脚本会把热查询索引收敛为更低写放大的部分索引,移除已被唯一约束覆盖或无热路径使用的冗余索引,并对相关表执行 `ANALYZE`。第二个脚本用于查看表扫描、dead tuples、索引命中和 `pg_stat_statements` 中的高读块 SQL。生产执行后,继续观察 Supabase daily/hourly Disk IO 图表,确认请求延迟和 IO wait 是否下降。
## 4. 环境变量
### 4.1 前端(Vercel / frontend/.env.local
### 4.1 前端(Docker 容器 / frontend/.env.local
```env
NEXT_PUBLIC_SUPABASE_URL=
@@ -66,30 +76,38 @@ SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_HTTP_TIMEOUT_SEC=8
POLYWEATHER_PAYMENT_ENABLED=true
# 默认链仍是 Polygon,因为当前 checkout 合约部署在 Polygon。
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<receiver_contract>
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=0x<treasury_or_receiver_wallet>
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
# 支持币种(示例)
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_e","symbol":"USDC.e","name":"USDC.e (PoS)","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver>","is_default":true},{"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver>"}]
# 支持多链多币种(示例)
# Ethereum 主网 USDC 当前建议只开 direct transfer,不走 Polygon checkout 合约。
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>","is_default":true},{"code":"usdc_e_polygon","symbol":"USDC.e","name":"USDC.e on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>"},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x<treasury_or_receiver_wallet>","supports_contract_checkout":false,"supports_direct_transfer":true,"confirmations":2,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}]
# 套餐(当前只保留月付)
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"5","duration_days":30}}
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
# 套餐
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"29.9","duration_days":30},"pro_quarterly":{"plan_id":102,"amount_usdc":"79.9","duration_days":90}}
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly
# 积分抵扣
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8}
# 支付自动补单
POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true
POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC=20
POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_INTERVAL_SEC=300
POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_AFTER_EMPTY_CYCLES=3
```
## 5. 验证步骤
@@ -100,10 +118,16 @@ POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
- `POST /api/payments/wallets/challenge`
- `POST /api/payments/wallets/verify`
4. 支付流程:
- `POST /api/payments/intents`
- `POST /api/payments/intents`;多链时前端会带 `chain_id``token_address`
- 发链上交易
- `POST /api/payments/intents/{id}/submit`
- `POST /api/payments/intents/{id}/confirm`
5. 若前端显示 pending,轮询:
- `GET /api/payments/intents/{id}`
6. 确认订阅:`/api/auth/me` 返回 `subscription_active=true`
## 6. 多链支付口径
- Polygon 是默认链,仍支持钱包合约支付和手动直转确认。
- Ethereum 主网 USDC 是正式支付链路,但当前建议只走手动直转:用户选择 Ethereum 后,前端展示收款钱包、金额、代币合约和 Etherscan 链接,用户提交 tx hash 后后端按 `intent.chain_id=1` 查询 Ethereum RPC。
- 不要只依赖前端文案阻止错链付款;后端必须把每笔 intent 的 `chain_id``token_address``receiver_address` 落库,并在确认时按该链校验 `Transfer` 事件。
+1 -1
View File
@@ -60,7 +60,7 @@ flowchart TD
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/首次消息奖励/欢迎奖励/周排名奖励/周参与奖/手动补分 |
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(有效付费邀请/后台人工补发/积分抵扣消费 |
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
+8 -8
View File
@@ -2,12 +2,12 @@
## 执行摘要
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、Ethereum 主网 USDC 直转确认、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
> **2026-05-28 更新v1.8.1**:终端图表已切换为 HTTP snapshot + SSE Patch + Redis Stream/SQLite replay 架构;DEB hourly consensus (`deb_hourly_consensus.v1`) 成为峰值窗口和 DEB 曲线展示的优先小时路径;README 产品截图迁移到 `frontend/public/static`
> **2026-05-29 更新**:支付 intent 已支持多链 `chain_id`Polygon 继续承载 checkout 合约,Ethereum 主网 USDC 作为直转确认通道;终端图表继续使用 HTTP snapshot + SSE Patch + Redis Stream/SQLite replay 架构
## 项目概览
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Docker/VPS 前端)+ VPS 后端 + Telegram Bot”。
项目主功能可归纳为五层:
**天气层(数据源/采集)**:聚合 51 个城市的实测与预报;支持 AviationWeather METAR/TAF、韩国 AMOS 跑道级观测(首尔/釜山)、中国 AMSC AWOS 跑道端点气温、香港 CoWIN 6087 / HKO、土耳其 MGM、Open-Meteo 多模型与集合预报、美国 MADIS HFMETAR 等。机场类市场仍以 METAR / 机场主站或明确官方结算源为锚点,Wunderground 不描述为物理观测站。
**分析层(DEB/趋势/概率/结算口径)**:
@@ -15,7 +15,7 @@ DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
**城市决策层(Scan Terminal / 结构化实况层)**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR/官方站点/跑道观测,并通过 `/api/city/{name}/detail` 生成城市级结构化数据。终端图表使用 HTTP snapshot + SSE patch + Redis/SQLite replay,最高温中枢优先使用 DEB hourly consensus,再结合多模型中心、日内 pace 和当前实测。
**市场层(Polymarket 行情对照)***[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
**商业化与支付**:订阅(`Pro Monthly 29.9 USDC / 30 天``Pro Quarterly 79.9 USDC / 90 天`)、邀请积分抵扣、Polygon 链上收款合约(USDC/USDC.e、Ethereum 主网 USDC 直转确认,并提供“事件监听 + 周期确认”的自动补单机制。
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。
(插图:项目 README 中包含产品截图,可用于快速理解实时终端与 Telegram 推送形态)
@@ -28,7 +28,7 @@ DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
从 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 的非流式重试。 |
| 运行时组件 | `bot_listener.py` + `src/bot/*` | Telegram Bot | 入口 `bot_listener.py``start_bot()`,并由 `StartupCoordinator` 启动多个后台 loop。 |
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py``settlement_sources.py``metar_sources.py``mgm_sources.py``amos_station_sources.py``jma_amedas_sources.py``nws_open_meteo_sources.py``country_networks.py` 等。v1.7.0 已移除 NMC、pogodaiklimat、Meteoblue 数据源。 |
@@ -44,7 +44,7 @@ DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
```mermaid
flowchart TB
subgraph Clients
WEB[Next.js Frontend<br/>Vercel]
WEB[Next.js Frontend<br/>Docker / VPS]
CITYCARD[Scan Terminal City Cards<br/>structured observations + probability mapping]
TG[Telegram Bot<br/>TeleBot + Handlers]
end
@@ -71,7 +71,7 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
HKO[data.weather.gov.hk]
CWA[opendata.cwa.gov.tw]
SB[Supabase Auth/REST]
RPC[Polygon RPC]
RPC[Polygon / Ethereum RPC]
end
subgraph Payments
@@ -148,7 +148,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
### 优势
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),新增首次发言欢迎奖励与每日首条消息奖励,周奖励降低赢家积分差距并增加全员参与奖
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),积分获取切换为有效付费邀请奖励;Telegram 群发言不再发放积分
**复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。前端城市决策卡在此基础上补足“机场报文解释 + 市场桶动作口径”,让用户从地图点击可以直接进入可解释决策。
**面向外部 API 的工程防护意识较强**:Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。
**支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。
+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."
}
]
+30 -10
View File
@@ -1,6 +1,6 @@
# PolyWeather 支付审计与防护说明
最后更新:`2026-03-21`
最后更新:`2026-05-29`
## 1. 当前已落地的防护
@@ -19,9 +19,9 @@
### 事件确认边界
- 后端只认链上 `OrderPaid` 事件
- 后端只认链上可验证事件:checkout 合约路径认 `OrderPaid`,直转路径认对应链上 USDC `Transfer`
- 前端提交 intent 不会直接视为支付完成。
- `confirm_loop` 会再次按链上交易与确认数校验 intent
- `confirm_loop` 会再次按 intent 的 `chain_id``token_address`、收款地址、金额与确认数校验链上交易。
- 若确认失败,当前会明确把 intent / transaction 落为失败态,而不是长期停留在 `submitted`
当前已显式识别的失败原因包括:
@@ -29,19 +29,23 @@
- `receiver_mismatch`
- `sender_mismatch`
- `event_mismatch`
- `token_mismatch`
- `tx_reverted`
### RPC 多节点容灾
- 支持 `POLYWEATHER_PAYMENT_RPC_URLS`
- 支持默认链 `POLYWEATHER_PAYMENT_RPC_URLS`
- 支持多链 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- 格式示例:
```env
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com","https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
```
- 启动时按顺序探活。
- 当前节点断连或收据查询失败时,会自动切换到下一个可用 RPC。
- 多链支付确认不会用默认链硬查交易;每笔 intent 会按自己的 `chain_id` 选择 RPC。
### 事件重放
@@ -87,8 +91,24 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
- 已付款但未开通
- 打到旧收款地址
- 交易事件不匹配
- 用户在钱包默认 Ethereum 网络付款,但旧系统只按 Polygon intent 查账
## 2. 当前合约的授权边界
## 2. 当前支付链路边界
当前支持两类链路:
1. Polygon checkout 合约
- 前端钱包支付会先切到 Polygon。
- 后端创建 intent 后给出合约 `tx_payload`
- 确认时校验 `OrderPaid(orderId, payer, planId, amount, token)`
2. Ethereum 主网 USDC 直转
- 前端展示 Ethereum 网络、USDC 合约、收款钱包和金额。
- 用户提交 tx hash 后,后端按 `intent.chain_id=1` 查询 Ethereum RPC。
- 确认时校验 USDC `Transfer(from, to, amount)``to``token_address` 和金额。
- 这条链路不依赖 Polygon checkout 合约,适合处理“用户钱包默认网络付款”的真实行为。
## 3. 当前合约的授权边界
合约源码:
- [PolyWeatherCheckout.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckout.sol)
@@ -110,7 +130,7 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
4. 订单边界
- 同一个 `orderId` 只能成功支付一次
## 3. 重入与重复支付判断
## 4. 重入与重复支付判断
当前合约的 `pay` 逻辑顺序是:
@@ -136,7 +156,7 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
- **最小可用支付合约**
- 不是“全功能强防护合约”
## 4. 当前静态审计结论
## 5. 当前静态审计结论
已提供脚本:
- [check_payment_contract_security.py](/E:/web/PolyWeather/scripts/check_payment_contract_security.py)
@@ -160,7 +180,7 @@ python scripts/check_payment_contract_security.py
- 是否使用 SafeERC20
- 是否在链上绑定套餐价格
## 5. 当前主要剩余风险
## 6. 当前主要剩余风险
1. 单地址 owner
- 建议把 `owner` 迁移到多签钱包
@@ -175,7 +195,7 @@ python scripts/check_payment_contract_security.py
- 当前使用 `IERC20.transferFrom`
- 升级版合约更建议改为 OpenZeppelin `SafeERC20`
## 6. 推荐操作
## 7. 推荐操作
### 每次支付配置变更后
@@ -233,7 +253,7 @@ docker compose exec polyweather_web python scripts/reconcile_subscription_by_ema
- 用户声称已付费但未开通
- 需要快速确认最近一笔 intent 是否能自动恢复
## 7. 下一版合约建议
## 8. 下一版合约建议
如果后续升级合约,优先级建议:
+1 -1
View File
@@ -12,7 +12,7 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|------|------|------|
| 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 |
| Pro 试用 | 3 天全功能 | 注册后自动获得 |
| Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 10 USDC/月(积分抵扣最多 3 USDC |
| Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 月付 29.9 USDC / 30 天,季度 79.9 USDC / 90 天(月付最多抵 3 USDC,季度最多抵 8 USDC |
### 页面结构(9 个路由)
@@ -0,0 +1,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 域名(若同域也可填前端域名)
- `Bearer Token`:后端开启鉴权时填写
+2 -2
View File
@@ -12,12 +12,12 @@
<label class="field">
<span id="siteBaseLabel">Site Base URL</span>
<input id="siteBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
<input id="siteBaseInput" type="text" placeholder="https://polyweather.top" />
</label>
<label class="field">
<span id="apiBaseLabel">API Base URL</span>
<input id="apiBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
<input id="apiBaseInput" type="text" placeholder="https://polyweather.top" />
</label>
<label class="field">
+2 -2
View File
@@ -1,6 +1,6 @@
const DEFAULT_CONFIG = {
apiBase: "https://polyweather-pro.vercel.app",
siteBase: "https://polyweather-pro.vercel.app",
apiBase: "https://polyweather.top",
siteBase: "https://polyweather.top",
authToken: "",
selectedCity: ""
};
+2 -2
View File
@@ -1,8 +1,8 @@
const DEFAULT_CONFIG = {
apiBase: "https://polyweather-pro.vercel.app",
apiBase: "https://polyweather.top",
authToken: "",
selectedCity: "",
siteBase: "https://polyweather-pro.vercel.app"
siteBase: "https://polyweather.top"
};
const CACHE_VERSION = "v3";
const locale = String(navigator.language || "en").toLowerCase().startsWith("zh")
+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_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
POLYWEATHER_TURNSTILE_SECRET_KEY=
POLYWEATHER_TURNSTILE_BYPASS=false
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
-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
.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_POLYGON_RPC_URL
ARG NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
@@ -23,6 +24,7 @@ ENV NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=$NEXT_PUBLIC_POLYWEATHER_LOCAL_FUL
ENV NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=$NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
ENV NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=$NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL
ENV NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=$NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
WORKDIR /app/frontend
COPY --from=deps /app/frontend/node_modules ./node_modules
+22 -31
View File
@@ -9,7 +9,7 @@ PolyWeather Pro 的生产前端工程。
- Next.js App Router
- React + Tailwind
- Leaflet + Recharts
- 自研温度图表 + Recharts 运营图表
- Supabase Auth
- WalletConnect + 浏览器 EVM 钱包
@@ -21,30 +21,20 @@ PolyWeather Pro 的生产前端工程。
## 当前前端能力
- 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心
- `/docs` 提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF 和结算来源
- 主站为实时天气决策台,包含 `天气决策 / 训练数据 / 使用指南` 三个主入口
- `/docs` 提供公开双语产品文档中心,当前保留简介、图表阅读、实时数据频率、结算站点和浏览器插件说明
- 天气决策台支持 1x1 到 3x3 图表槽位,可按区域、搜索和城市选择进行多城市巡检
- 终端图表默认展示全天,可切换高温窗口;所有横轴与 tooltip 时间按城市当地时间渲染
- 可见终端图表通过 SSE patch 无痛增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
- 可见终端图表通过 SSE patch 增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
- AMSC/AMOS 跑道城市默认展示结算跑道曲线并高亮,辅助跑道弱化展示;单跑道机场不重复展示聚合线
- 香港默认展示 CoWIN 6087 参考站 1 分钟曲线,并保留 HKO 10 分钟官方气象层
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
- 今日日内分析支持:
- `锚点状态`
- `当前节奏`
- `校准模型概率`
- `模型区间与分歧`
- `专业气象结论条`
- `气象证据链 / 失效条件 / 确认条件`
- 非香港机场城市的 `TAF` 时段提示与走势图联动
- 使用指南内置图表阅读顺序、图层含义、常用操作和默认可见性规则
- `/ops` 已支持桌面表格 + 手机端卡片化视图
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
- 右侧详情面板在多日预报仍未补齐时会显示同步占位卡,不再把“只有今天一张卡”的中间态伪装成完整数据
- 日内分析弹窗在 full detail / market scan 同步时会锁住旧内容并显示刷新状态,避免用户短暂看到旧城市或旧日期的数据
- 城市决策卡支持从地图点击城市进入;机会榜和日历仍按 Pro 权限控制,地图探索和城市简报可作为轻量入口
- 城市决策卡展示结构化实况、模型区间、市场温度桶和模型-市场差,不再请求 AI 解读
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
- 城市 detail 自动识别稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
- 市场价格层使用完整 `all_buckets` 匹配温度桶,市场信号作为交易判断层,不替代实测结算源
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
- 账户中心支付区支持后端下发的多链网络选择;Polygon 继续走 checkout 合约,Ethereum 主网 USDC 走手动直转确认
- 缓存桶状态与 summary cache hit/miss
## 本地开发
@@ -56,7 +46,7 @@ npm ci
npm run dev
```
## Vercel 最小部署配置
## 最小部署配置
只跑看板和基础鉴权时,先填这 4 项:
@@ -107,7 +97,7 @@ NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false
NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
```
更完整的 Vercel 配置说明见:
更完整的部署配置说明见:
- [docs/FRONTEND_DEPLOYMENT_ZH.md](/E:/web/PolyWeather/docs/FRONTEND_DEPLOYMENT_ZH.md)
## 路由处理器
@@ -165,7 +155,7 @@ Ops
注意:
- `/ops` 现在是前后端双层管理员限制
- Vercel 前端和后端都应配置相同的 `POLYWEATHER_OPS_ADMIN_EMAILS`
- 前端容器和后端都应配置相同的 `POLYWEATHER_OPS_ADMIN_EMAILS`
- 前端登录邮箱本身不会自动获得管理员权限
## 支付安全补充
@@ -176,12 +166,13 @@ Ops
2. 若 `receiver_contract` 已更新,先切到最新地址
3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付
4. 仅允许在 `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` 白名单域名上创建 payment intent
5. 支付区会明确显示当前账号、付款钱包和收款合约,避免账号/钱包/地址混淆
5. 支付区会明确显示当前账号、付款钱包、支付网络和收款合约/钱包,避免账号/钱包/地址/链混淆
6. 多链支付时,前端会把用户选择的 `chain_id``token_address` 发给后端,由后端按 intent 的链确认交易
这意味着:
- 旧标签页风险已明显降低
- 支付地址变更后,仍建议在 Vercel 上 redeploy 当前 production,并清理明显过期 deployment
- 支付地址变更后,由于地址在前端镜像构建期注入,需要触发一次 `main` push(或重跑 deploy workflow)发布新镜像;浏览器侧靠 `/api/payments/config` 的运行时校验兜底
## 缓存行为
@@ -189,15 +180,15 @@ Ops
- `summary?force_refresh=true``no-store`
- 支付相关路由:`no-store`
- 当 detail 缓存只返回单模型或单日 forecast 时,前端会自动强刷完整 detail,并在补齐前显示同步提示 / 占位卡
- 今日日内分析打开时如果正在切换城市、日期或 detail 深度,弹窗会阻断旧内容点击并显示刷新锁
- 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=500`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新
- detail 正在切换城市、日期或分辨率时,图表保留已有曲线并显示同步提示,避免把旧数据当作当前状态
- 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=按可见城市数动态限制`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新
- 前端只消费 HTTP snapshot + SSE patch,不直接感知 RedisRedis Stream / SQLite event log 都由后端统一封装
## Vercel 节流建议
## 成本与节流建议
- 生产环境建议关闭 `Web Analytics` `Speed Insights`
- 前端与后端以 Docker Compose 部署在同一 VPS,静态资源通过 Cloudflare 缓存(见 `next.config.mjs` `headers()` 和 CI 的 `cloudflare-cache-rules` job
- 建议把自建 `app analytics / web vitals / eager city summaries` 默认保持关闭
- 如果你部署在 Vercel,可在 Firewall 中加一条 `WordPress / php scanner` 拦截规则,避免无效扫描白白触发 middleware
- 如果扫描流量明显,可在 Cloudflare WAF 中加一条 `WordPress / php scanner` 拦截规则,避免无效扫描白白触发 Nginx / middleware
## AGPL 与商用边界说明
@@ -206,4 +197,4 @@ Ops
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
最后更新:`2026-05-28`
最后更新:`2026-05-29`
+4 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { I18nProvider } from "@/hooks/useI18n";
import { AccountEntry } from "@/components/account/AccountEntry";
@@ -10,7 +11,9 @@ export const metadata: Metadata = {
export default function AccountPage() {
return (
<I18nProvider>
<AccountEntry />
<Suspense fallback={null}>
<AccountEntry />
</Suspense>
</I18nProvider>
);
}
+93 -24
View File
@@ -7,49 +7,118 @@ import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const ANALYTICS_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS !== "false";
const ANALYTICS_PROXY_TIMEOUT_MS = Math.max(
250,
Number(process.env.POLYWEATHER_ANALYTICS_PROXY_TIMEOUT_MS || "1500") || 1500,
);
export async function POST(req: NextRequest) {
const timer = createProxyTimer(req, "analytics_events");
if (!ANALYTICS_ENABLED) {
return new NextResponse(null, { status: 204 });
}
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
return finishProxyTimedResponse(
new NextResponse(null, { status: 204 }),
timer,
"disabled",
);
}
if (!API_BASE) {
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ANALYTICS_PROXY_TIMEOUT_MS);
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const body = await timer.measure("request_read", () => req.json());
const payload =
body && typeof body.payload === "object" && body.payload != null
? body.payload
: {};
const enrichedBody = {
...(body ?? {}),
payload: {
...payload,
cf_country: req.headers.get("cf-ipcountry") || "",
user_agent: req.headers.get("user-agent") || "",
referer_header: req.headers.get("referer") || "",
},
};
auth = await timer.measure("auth_headers", () =>
buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
}),
);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers,
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
const res = await timer.measure("backend_fetch", () =>
fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers,
body: JSON.stringify(enrichedBody),
cache: "no-store",
signal: controller.signal,
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
if (!res.ok) {
const raw = await res.text();
const raw = await timer.measure("backend_read", () => res.text());
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 260,
});
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
}
const data = await res.json();
const data = await timer.measure("backend_read", () => res.json());
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to track analytics event",
});
const timedOut = controller.signal.aborted;
const response = timedOut
? NextResponse.json(
{
ok: false,
accepted: true,
dropped: true,
reason: "timeout",
},
{ status: 202 },
)
: buildProxyExceptionResponse(error, {
publicMessage: "Failed to track analytics event",
});
const withCookies = auth ? applyAuthResponseCookies(response, auth.response) : response;
return finishProxyTimedResponse(
withCookies,
timer,
timedOut ? "timeout_accepted" : "exception",
);
} finally {
clearTimeout(timeoutId);
}
}
+646 -81
View File
@@ -4,121 +4,686 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
import {
getLocalDevAuthPayload,
isLocalFullAccessHost,
} from "@/lib/local-dev-access";
import {
applyEntitlementSnapshotCookie,
clearEntitlementSnapshotCookie,
entitlementSnapshotToAuthPayload,
readEntitlementSnapshot,
} from "@/lib/entitlement-snapshot";
import {
buildSubscriptionRequiredAuthProfile,
isSubscriptionRequiredBackendResponse,
} from "@/lib/auth-profile-proxy";
import {
hasSupabaseServerEnv,
hasSupabaseSessionCookieValues,
} from "@/lib/supabase/server";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
type AuthMeTimingStage = {
durationMs: number;
name: string;
};
type AuthMeTimer = {
hasAuthorization: boolean;
hasSupabaseCookie: boolean;
measure<T>(name: string, action: () => Promise<T>): Promise<T>;
measureSync<T>(name: string, action: () => T): T;
preferSnapshot: boolean;
stages: AuthMeTimingStage[];
totalMs(): number;
};
function authMeNowMs() {
return typeof performance !== "undefined" ? performance.now() : Date.now();
}
function createAuthMeTimer(req: NextRequest): AuthMeTimer {
const startedAt = authMeNowMs();
const stages: AuthMeTimingStage[] = [];
const recordStage = (name: string, stageStartedAt: number) => {
stages.push({
durationMs: Math.round((authMeNowMs() - stageStartedAt) * 10) / 10,
name,
});
};
return {
hasAuthorization: Boolean(req.headers.get("authorization")),
hasSupabaseCookie: hasRequestSupabaseSessionCookie(req),
async measure<T>(name: string, action: () => Promise<T>) {
const stageStartedAt = authMeNowMs();
try {
return await action();
} finally {
recordStage(name, stageStartedAt);
}
},
measureSync<T>(name: string, action: () => T) {
const stageStartedAt = authMeNowMs();
try {
return action();
} finally {
recordStage(name, stageStartedAt);
}
},
preferSnapshot: req.nextUrl.searchParams.get("prefer_snapshot") === "1",
stages,
totalMs() {
return Math.round((authMeNowMs() - startedAt) * 10) / 10;
},
};
}
function formatServerTiming(stages: AuthMeTimingStage[], totalMs: number) {
return [...stages, { durationMs: totalMs, name: "total" }]
.map(({ durationMs, name }) => {
const safeName = name.replace(/[^A-Za-z0-9_-]/g, "_");
return `${safeName};dur=${Math.max(0, durationMs).toFixed(1)}`;
})
.join(", ");
}
function buildBackendAuthMeUrl(req: NextRequest) {
const url = new URL(`${API_BASE!.replace(/\/+$/, "")}/api/auth/me`);
if (req.nextUrl.searchParams.get("scope") === "entitlement") {
url.searchParams.set("scope", "entitlement");
}
return url.toString();
}
function finishAuthMeResponse(
response: NextResponse,
timer: AuthMeTimer,
outcome: string,
extra?: { backendServerTiming?: string },
) {
const total = timer.totalMs();
const ownServerTiming = formatServerTiming(timer.stages, total);
const backendServerTiming = String(extra?.backendServerTiming || "").trim();
response.headers.set("Cache-Control", "no-store");
response.headers.set(
"Server-Timing",
backendServerTiming
? `${ownServerTiming}, ${backendServerTiming}`
: ownServerTiming,
);
console.info(
"[auth-me-timing]",
JSON.stringify({
backendServerTiming: backendServerTiming || undefined,
hasAuthorization: timer.hasAuthorization,
hasSupabaseCookie: timer.hasSupabaseCookie,
outcome,
preferSnapshot: timer.preferSnapshot,
stagesMs: Object.fromEntries(
timer.stages.map((stage) => [stage.name, stage.durationMs]),
),
status: response.status,
totalMs: total,
}),
);
return response;
}
async function trackAuthDiagnosticEvent(
req: NextRequest,
{
email,
reason,
responseMode,
userId,
}: {
email: string | null;
reason: string;
responseMode: "snapshot" | "degraded" | "anonymous";
userId?: string | null;
},
) {
if (!API_BASE) return;
const normalizedUserId = String(userId || "").trim();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1200);
try {
await fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event_type: "degraded_auth_profile",
client_id: normalizedUserId ? `auth:${normalizedUserId}` : undefined,
payload: {
route: "/api/auth/me",
reason: String(reason || "unknown").slice(0, 240),
response_mode: responseMode,
user_id: normalizedUserId || undefined,
email_domain: email?.includes("@") ? email.split("@").pop() : undefined,
cf_country: req.headers.get("cf-ipcountry") || "",
user_agent: req.headers.get("user-agent") || "",
referer_header: req.headers.get("referer") || "",
captured_at: new Date().toISOString(),
},
}),
cache: "no-store",
signal: controller.signal,
});
} catch {
// Diagnostics must never block auth/profile fallback responses.
} finally {
clearTimeout(timeoutId);
}
}
type VerifiedBearerIdentity = {
email: string | null;
userId: string;
};
function extractBearerToken(headerValue: string | null) {
if (!headerValue) return "";
const parts = headerValue.trim().split(/\s+/);
if (parts.length === 2 && parts[0].toLowerCase() === "bearer") {
return parts[1];
}
return "";
}
async function getVerifiedBearerIdentity(
req: NextRequest,
): Promise<VerifiedBearerIdentity | null> {
const token = extractBearerToken(req.headers.get("authorization"));
if (!token) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
if (!supabaseUrl || !anonKey) return null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 4000);
try {
const res = await fetch(`${supabaseUrl.replace(/\/+$/, "")}/auth/v1/user`, {
cache: "no-store",
headers: {
apikey: anonKey,
Authorization: `Bearer ${token}`,
},
signal: controller.signal,
});
if (!res.ok) return null;
const user = await res.json();
const userId = String(user?.id || "").trim();
if (!userId) return null;
return {
email: String(user?.email || "").trim() || null,
userId,
};
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
}
async function degradedAuthProfileResponse({
email,
reason,
req,
response,
userId,
}: {
email: string | null;
reason: string;
req: NextRequest;
response: NextResponse | null;
userId: string;
}) {
const snapshotPayload = entitlementSnapshotToAuthPayload(
readEntitlementSnapshot(req, userId),
);
if (snapshotPayload) {
await trackAuthDiagnosticEvent(req, {
email: snapshotPayload.email || email,
reason,
responseMode: "snapshot",
userId,
});
const snapshotResponse = NextResponse.json({
...snapshotPayload,
email: snapshotPayload.email || email,
entitlement_snapshot_reason: reason,
});
return applyAuthResponseCookies(snapshotResponse, response);
}
await trackAuthDiagnosticEvent(req, {
email,
reason,
responseMode: "degraded",
userId,
});
const degraded = NextResponse.json({
authenticated: true,
user_id: userId,
email,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: reason,
});
return applyAuthResponseCookies(degraded, response);
}
function subscriptionRequiredAuthProfileResponse({
email,
response,
userId,
}: {
email: string | null;
response: NextResponse | null;
userId: string;
}) {
const inactive = NextResponse.json(
buildSubscriptionRequiredAuthProfile({ email, userId }),
);
clearEntitlementSnapshotCookie(inactive);
return applyAuthResponseCookies(inactive, response);
}
async function unauthenticatedAuthProfileResponse({
reason,
req,
response,
}: {
reason: string;
req: NextRequest;
response: NextResponse | null;
}) {
await trackAuthDiagnosticEvent(req, {
email: null,
reason,
responseMode: "anonymous",
});
const anonymous = NextResponse.json(
{
authenticated: false,
subscription_active: false,
points: 0,
degraded_auth_profile: true,
degraded_reason: reason,
},
{ headers: { "Cache-Control": "no-store" } },
);
clearEntitlementSnapshotCookie(anonymous);
return applyAuthResponseCookies(anonymous, response);
}
function snapshotAuthProfileResponse({
email,
reason,
req,
response,
userId,
}: {
email: string | null;
reason: string;
req: NextRequest;
response: NextResponse | null;
userId: string;
}) {
const snapshotPayload = entitlementSnapshotToAuthPayload(
readEntitlementSnapshot(req, userId),
);
if (!snapshotPayload) return null;
const snapshotResponse = NextResponse.json({
...snapshotPayload,
email: snapshotPayload.email || email,
entitlement_snapshot_reason: reason,
});
return applyAuthResponseCookies(snapshotResponse, response);
}
function applyEntitlementSnapshotFromAuthPayload(
response: NextResponse,
data: Record<string, unknown>,
) {
if (data.authenticated === true && data.subscription_active === true) {
return applyEntitlementSnapshotCookie(response, data);
}
if (data.authenticated === false || data.subscription_active === false) {
return clearEntitlementSnapshotCookie(response);
}
return response;
}
function hasRequestSupabaseSessionCookie(req: NextRequest) {
return hasSupabaseSessionCookieValues(
req.cookies.getAll().map((item) => ({
name: item.name,
value: item.value,
})),
);
}
export async function GET(req: NextRequest) {
const timer = createAuthMeTimer(req);
const requestHost =
req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host;
if (
isLocalFullAccessHost(requestHost) ||
isLocalFullAccessHost(req.nextUrl.hostname)
) {
return NextResponse.json(getLocalDevAuthPayload(), {
headers: { "Cache-Control": "no-store" },
});
}
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
return finishAuthMeResponse(
NextResponse.json(getLocalDevAuthPayload(), {
headers: { "Cache-Control": "no-store" },
}),
timer,
"local_full_access",
);
}
if (!API_BASE) {
return finishAuthMeResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
const preferSnapshot = req.nextUrl.searchParams.get("prefer_snapshot") === "1";
if (
preferSnapshot &&
!req.headers.get("authorization") &&
hasRequestSupabaseSessionCookie(req)
) {
const snapshotPayload = timer.measureSync(
"snapshot_cookie",
() => entitlementSnapshotToAuthPayload(readEntitlementSnapshot(req)),
);
if (snapshotPayload) {
return finishAuthMeResponse(
NextResponse.json(
{
...snapshotPayload,
entitlement_snapshot_reason: "prefer_snapshot_fast_path",
},
{ headers: { "Cache-Control": "no-store" } },
),
timer,
"prefer_snapshot_fast_path",
);
}
}
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
let bearerIdentity: VerifiedBearerIdentity | null | undefined;
const getBearerIdentityOnce = async () => {
if (bearerIdentity !== undefined) return bearerIdentity;
bearerIdentity = await timer.measure(
"bearer_identity",
() => getVerifiedBearerIdentity(req),
);
return bearerIdentity;
};
try {
const auth = await buildBackendRequestHeaders(req);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 6000);
let res: Response;
try {
res = await fetch(`${API_BASE}/api/auth/me`, {
headers: auth.headers,
cache: "no-store",
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
if ((res.status === 401 || res.status === 403) && auth.authUserId) {
const response = NextResponse.json({
authenticated: true,
user_id: auth.authUserId,
email: auth.authEmail || null,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: `backend_${res.status}`,
});
return applyAuthResponseCookies(response, auth.response);
}
if (res.status === 401 || res.status === 403) {
auth = await timer.measure(
"auth_headers",
() => buildBackendRequestHeaders(req),
);
if (
hasSupabaseServerEnv() &&
!auth.authUserId &&
!req.headers.get("authorization")
) {
const response = NextResponse.json({
authenticated: false,
subscription_active: false,
points: 0,
});
return applyAuthResponseCookies(response, auth.response);
if (!preferSnapshot) clearEntitlementSnapshotCookie(response);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"no_session",
);
}
if (preferSnapshot) {
const identity =
auth.authUserId
? { email: auth.authEmail || null, userId: auth.authUserId }
: await getBearerIdentityOnce();
if (identity?.userId) {
const snapshotResponse = snapshotAuthProfileResponse({
email: identity.email,
reason: "prefer_snapshot",
req,
response: auth.response,
userId: identity.userId,
});
if (snapshotResponse) {
return finishAuthMeResponse(
snapshotResponse,
timer,
"prefer_snapshot",
);
}
}
}
if (!auth) throw new Error("auth headers unavailable");
const backendAuth = auth;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 6000);
let res: Response;
try {
res = await timer.measure("backend_fetch", async () =>
await fetch(buildBackendAuthMeUrl(req), {
headers: backendAuth.headers,
cache: "no-store",
signal: controller.signal,
}),
);
} finally {
clearTimeout(timeoutId);
}
const backendServerTiming = res.headers.get("server-timing") || "";
if (res.status === 401 || res.status === 403) {
const raw = await timer.measure("backend_read", () => res.text());
const authIdentity = auth.authUserId
? { email: auth.authEmail || null, userId: auth.authUserId }
: await getBearerIdentityOnce();
if (
authIdentity?.userId &&
isSubscriptionRequiredBackendResponse(res.status, raw)
) {
return finishAuthMeResponse(
subscriptionRequiredAuthProfileResponse({
email: authIdentity.email,
response: auth.response,
userId: authIdentity.userId,
}),
timer,
"subscription_required",
{ backendServerTiming },
);
}
if (auth.authUserId) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: auth.authEmail || null,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: auth.authUserId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const identity = await getBearerIdentityOnce();
if (identity) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: identity.email,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: identity.userId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const response = NextResponse.json({
authenticated: false,
subscription_active: false,
points: 0,
});
clearEntitlementSnapshotCookie(response);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`anonymous_backend_${res.status}`,
{ backendServerTiming },
);
}
if (!res.ok) {
const raw = await res.text();
const raw = await timer.measure("backend_read", () => res.text());
if (auth.authUserId) {
const response = NextResponse.json({
authenticated: true,
user_id: auth.authUserId,
email: auth.authEmail || null,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: `backend_${res.status}`,
});
return applyAuthResponseCookies(response, auth.response);
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: auth.authEmail || null,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: auth.authUserId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const identity = await getBearerIdentityOnce();
if (identity) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: identity.email,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: identity.userId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
}
const data = await timer.measure("backend_read", () => res.json());
if (data?.authenticated === true && data?.subscription_active == null) {
const userId = String(data.user_id || auth.authUserId || "").trim();
if (userId) {
const snapshotResponse = snapshotAuthProfileResponse({
email: String(data.email || auth.authEmail || "").trim() || null,
reason: "subscription_unknown",
req,
response: auth.response,
userId,
});
if (snapshotResponse) {
return finishAuthMeResponse(
snapshotResponse,
timer,
"subscription_unknown_snapshot",
{ backendServerTiming },
);
}
}
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
applyEntitlementSnapshotFromAuthPayload(response, data);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) {
const auth = await buildBackendRequestHeaders(req);
if (auth.authUserId) {
const response = NextResponse.json({
authenticated: true,
user_id: auth.authUserId,
email: auth.authEmail || null,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: String(error),
});
return applyAuthResponseCookies(response, auth.response);
if (auth?.authUserId) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: auth.authEmail || null,
reason: String(error),
req,
response: auth.response,
userId: auth.authUserId,
}),
timer,
"exception_degraded",
);
}
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch auth profile",
});
const identity = await getBearerIdentityOnce();
if (identity) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: identity.email,
reason: String(error),
req,
response: auth?.response || null,
userId: identity.userId,
}),
timer,
"exception_degraded",
);
}
const snapshotPayload = timer.measureSync(
"snapshot_cookie",
() => entitlementSnapshotToAuthPayload(readEntitlementSnapshot(req)),
);
if (snapshotPayload) {
const snapshotResponse = NextResponse.json({
...snapshotPayload,
entitlement_snapshot_reason: "exception_snapshot",
});
return finishAuthMeResponse(
applyAuthResponseCookies(snapshotResponse, auth?.response || null),
timer,
"exception_snapshot",
);
}
return finishAuthMeResponse(
await unauthenticatedAuthProfileResponse({
reason: String(error),
req,
response: auth?.response || null,
}),
timer,
"exception_anonymous",
);
}
}
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
buildJsonBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const body = await req.text();
const res = await fetch(`${API_BASE}/api/auth/referral/apply`, {
method: "POST",
headers: buildJsonBackendRequestHeaders(auth.headers),
body,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to apply referral code",
});
}
}
@@ -0,0 +1,128 @@
import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
import {
buildCityDetailProxyCachePolicy,
NO_STORE_CACHE_CONTROL,
} from "@/lib/proxy-cache-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const DETAIL_BATCH_PROXY_TIMEOUT_MS = Number(
process.env.POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS || "15000",
);
function parseRequestedCities(req: NextRequest) {
const requestedLimit = Number(req.nextUrl.searchParams.get("limit") || "12");
const limit = Number.isFinite(requestedLimit)
? Math.max(1, Math.min(24, requestedLimit))
: 12;
const seen = new Set<string>();
const requestedCities: string[] = [];
for (const item of (req.nextUrl.searchParams.get("cities") || "").split(
",",
)) {
const city = item.trim();
if (!city || seen.has(city)) continue;
seen.add(city);
requestedCities.push(city);
if (requestedCities.length >= limit) break;
}
return requestedCities;
}
function buildCityDetailBatchTimeoutPayload(requestedCities: string[]) {
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) {
const timer = createProxyTimer(req, "city_detail_batch");
if (!API_BASE) {
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const requestedCities = parseRequestedCities(req);
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh);
const searchParams = new URLSearchParams({
cities: req.nextUrl.searchParams.get("cities") || "",
force_refresh: forceRefresh,
limit: req.nextUrl.searchParams.get("limit") || "12",
});
for (const key of ["market_slug", "target_date", "resolution", "scope"]) {
const value = req.nextUrl.searchParams.get(key);
if (value) searchParams.set(key, value);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DETAIL_BATCH_PROXY_TIMEOUT_MS);
try {
return await proxyBackendJsonGet(req, {
cacheControl: cachePolicy.responseCacheControl,
cacheControlForData: (data) =>
data &&
typeof data === "object" &&
(data as { partial?: unknown }).partial === true
? NO_STORE_CACHE_CONTROL
: cachePolicy.responseCacheControl,
fetchCache: "no-store",
includeSupabaseIdentity: true,
publicMessage: "Failed to fetch city detail batch",
revalidateSeconds: cachePolicy.revalidateSeconds,
signal: controller.signal,
timeoutResponse: () =>
NextResponse.json(buildCityDetailBatchTimeoutPayload(requestedCities), {
headers: {
"Cache-Control": NO_STORE_CACHE_CONTROL,
"Cloudflare-CDN-Cache-Control": NO_STORE_CACHE_CONTROL,
},
status: 200,
}),
timeoutPublicMessage: "City detail batch request timed out",
timing: timer,
url: `${API_BASE}/api/cities/detail-batch?${searchParams.toString()}`,
});
} finally {
clearTimeout(timeoutId);
}
}
+53 -12
View File
@@ -1,21 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { NextRequest } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildCachedJsonResponse } from "@/lib/http-cache";
import {
buildCityListCacheControl,
buildStaticCityListFallbackCacheControl,
} from "@/lib/proxy-cache-policy";
import { STATIC_CITY_LIST } from "@/lib/static-cities";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const CITIES_CACHE_CONTROL = buildCityListCacheControl();
const STATIC_CITIES_CACHE_CONTROL = buildStaticCityListFallbackCacheControl();
const CITIES_BACKEND_TIMEOUT_MS = Number(
process.env.POLYWEATHER_CITIES_BACKEND_TIMEOUT_MS || 1000,
);
function staticCitiesFallback(req: NextRequest, reason: string) {
const response = buildCachedJsonResponse(
req,
{
cities: STATIC_CITY_LIST,
source: "static_fallback",
stale: true,
},
STATIC_CITIES_CACHE_CONTROL,
);
response.headers.set("x-polyweather-cities-source", "static-fallback");
response.headers.set("x-polyweather-cities-fallback-reason", reason);
return response;
}
export async function GET(req: NextRequest) {
if (!API_BASE) {
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
return staticCitiesFallback(req, "missing-api-base");
}
return proxyBackendJsonGet(req, {
cacheControl: "public, max-age=0, s-maxage=60, stale-while-revalidate=300",
publicMessage: "Failed to fetch cities",
revalidateSeconds: 60,
url: `${API_BASE}/api/cities`,
});
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), CITIES_BACKEND_TIMEOUT_MS);
try {
const response = await proxyBackendJsonGet(req, {
cacheControl: CITIES_CACHE_CONTROL,
publicMessage: "Failed to fetch cities",
revalidateSeconds: 60,
signal: abortController.signal,
statusOnException: 504,
timeoutPublicMessage: "Cities backend timed out",
url: `${API_BASE}/api/cities`,
});
if (response.status >= 500) {
return staticCitiesFallback(
req,
response.status === 504 ? "backend-timeout" : "backend-error",
);
}
return response;
} finally {
clearTimeout(timeout);
}
}
+39 -17
View File
@@ -9,6 +9,10 @@ import {
} from "@/lib/api-proxy";
import { buildCachedJsonResponse } from "@/lib/http-cache";
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -34,17 +38,18 @@ export async function GET(
req: NextRequest,
context: { params: Promise<{ name: string }> },
) {
const timer = createProxyTimer(req, "city_detail");
if (!API_BASE) {
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
return finishProxyTimedResponse(response, timer, "missing_api_base");
}
const { name } = await context.params;
const { name } = await timer.measure("route_params", () => context.params);
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh);
const depth = req.nextUrl.searchParams.get("depth");
const marketSlug = req.nextUrl.searchParams.get("market_slug");
const targetDate = req.nextUrl.searchParams.get("target_date");
@@ -67,31 +72,48 @@ export async function GET(
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
try {
const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const res = await fetch(url, {
headers: auth.headers,
...(cachePolicy.fetchMode === "no-store"
? { cache: "no-store" as const }
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
});
const auth = await timer.measure("auth_headers", () =>
buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
}),
);
const res = await timer.measure("backend_fetch", () =>
fetch(url, {
headers: auth.headers,
...(cachePolicy.fetchMode === "no-store"
? { cache: "no-store" as const }
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 60 } }),
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
if (!res.ok) {
const raw = await res.text();
const raw = await timer.measure("backend_read", () => res.text());
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
}
const data = normalizeCityDetailPayload(await res.json());
const data = normalizeCityDetailPayload(
await timer.measure("backend_read", () => res.json()),
);
const response = buildCachedJsonResponse(
req,
data,
cachePolicy.responseCacheControl,
);
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) {
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch city detail aggregate",
});
return response;
return finishProxyTimedResponse(response, timer, "exception");
}
}
@@ -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 forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const depth = req.nextUrl.searchParams.get("depth") ?? "panel";
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh);
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}&depth=${encodeURIComponent(depth)}`;
try {
@@ -150,7 +150,7 @@ export async function GET(
headers: auth.headers,
...(cachePolicy.fetchMode === "no-store"
? { cache: "no-store" as const }
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 60 } }),
});
if (!res.ok) {
const raw = await res.text();
@@ -159,7 +159,7 @@ export async function GET(
headers: auth.headers,
...(cachePolicy.fetchMode === "no-store"
? { cache: "no-store" as const }
: { next: { revalidate: 10 } }),
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 60 } }),
});
if (summaryRes.ok) {
const summaryData = await summaryRes.json();
@@ -168,7 +168,7 @@ export async function GET(
buildFallbackCityDetail(name, depth, summaryData),
cachePolicy.fetchMode === "no-store"
? cachePolicy.responseCacheControl
: "public, max-age=0, s-maxage=10, stale-while-revalidate=30",
: cachePolicy.responseCacheControl,
);
response.headers.set("X-PolyWeather-Fallback", "summary");
return applyAuthResponseCookies(response, auth.response);
+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;
}
}
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/analytics/funnel`);
const days = req.nextUrl.searchParams.get("days");
if (days) {
+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",
});
}
}
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/billing-risk`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Billing risk check failed",
});
}
}
+9 -2
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { applyAuthResponseCookies, buildBackendRequestHeaders, buildJsonBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : "";
@@ -9,6 +10,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -20,8 +24,11 @@ export async function PUT(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
const res = await fetch(BACKEND, { method: "PUT", headers: buildJsonBackendRequestHeaders(auth.headers), body, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(
req: NextRequest,
context: { params: Promise<{ feedbackId: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const { feedbackId } = await context.params;
const body = await req.json();
const res = await fetch(
`${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/reward`,
{
method: "POST",
cache: "no-store",
headers: {
...Object.fromEntries(new Headers(auth.headers).entries()),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
);
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to grant feedback reward",
});
}
}
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(
req: NextRequest,
context: { params: Promise<{ feedbackId: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const { feedbackId } = await context.params;
const body = await req.json();
const res = await fetch(
`${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/status`,
{
method: "POST",
cache: "no-store",
headers: {
...Object.fromEntries(new Headers(auth.headers).entries()),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
);
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to update feedback status",
});
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/feedback`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch ops feedback",
});
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`);
const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit);
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,8 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/memberships/growth`);
const days = req.nextUrl.searchParams.get("days");
if (days) url.searchParams.set("days", days);
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/memberships/overview`);
const limit = req.nextUrl.searchParams.get("limit");
const days = req.nextUrl.searchParams.get("days");
if (limit) url.searchParams.set("limit", limit);
if (days) url.searchParams.set("days", days);
const res = await fetch(url.toString(), {
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch memberships overview",
});
}
}
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,8 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/memberships`);
const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit);
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/observation-collector-status`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Observation collector status check failed",
});
}
}
+44 -13
View File
@@ -4,24 +4,46 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "ops_online_users");
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/ops/online-users`, {
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
const auth = await timer.measure("auth_headers", () =>
buildBackendRequestHeaders(req),
);
const authError = timer.measureSync("ops_auth", () =>
requireOpsProxyAuth(req, auth),
);
if (authError) {
return finishProxyTimedResponse(authError, timer, "ops_auth_error");
}
const res = await timer.measure("backend_fetch", () =>
fetch(`${API_BASE}/api/ops/online-users`, {
headers: auth.headers,
cache: "no-store",
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
const raw = await timer.measure("backend_read", () => res.text());
const response = new NextResponse(raw, {
status: res.status,
headers: {
@@ -29,10 +51,19 @@ export async function GET(req: NextRequest) {
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
res.ok ? "ok" : `upstream_${res.status}`,
{ backendServerTiming },
);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch online users",
});
return finishProxyTimedResponse(
buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch online users",
}),
timer,
"exception",
);
}
}
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -21,6 +22,9 @@ export async function POST(req: NextRequest, context: RouteContext) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const { eventId } = await context.params;
const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, {
method: "POST",
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,9 +18,13 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/payments/incidents`);
const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit);
req.nextUrl.searchParams.forEach((value, key) => {
url.searchParams.set(key, value);
});
const res = await fetch(url.toString(), {
headers: auth.headers,
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/payments`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch ops payments",
});
}
}
@@ -0,0 +1,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",
});
}
}
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders, buildJsonBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND = API_BASE ? `${API_BASE}/api/ops/sensitive-config` : "";
export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config fetch failed" }); }
}
export async function PUT(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
const res = await fetch(BACKEND, { method: "PUT", headers: buildJsonBackendRequestHeaders(auth.headers), body, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config update failed" }); }
}
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/source-health`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Source health check failed",
});
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { applyAuthResponseCookies, buildBackendRequestHeaders, buildJsonBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || "";
@@ -9,10 +10,13 @@ export async function POST(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" };
const headers = buildJsonBackendRequestHeaders(auth.headers);
if (ENTITLEMENT_TOKEN) {
headers.Authorization = `Bearer ${ENTITLEMENT_TOKEN}`;
headers.set("Authorization", `Bearer ${ENTITLEMENT_TOKEN}`);
}
const res = await fetch(`${API_BASE}/api/ops/subscriptions/extend`, {
method: "POST", headers, body, cache: "no-store",
@@ -2,8 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
buildJsonBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -39,8 +41,25 @@ async function findSupabaseUserIdByEmail(email: string) {
.replace(/\/$/, "");
const serviceRoleKey = String(process.env.SUPABASE_SERVICE_ROLE_KEY || "").trim();
if (!supabaseUrl || !serviceRoleKey) {
throw new Error("Supabase service role is not configured on Vercel");
throw new Error("Supabase service role is not configured on the server");
}
const profileRes = await fetch(
`${supabaseUrl}/rest/v1/profiles?select=id&email=eq.${encodeURIComponent(email)}&limit=1`,
{
headers: {
apikey: serviceRoleKey,
Authorization: `Bearer ${serviceRoleKey}`,
Accept: "application/json",
},
cache: "no-store",
},
);
const profileData = (await profileRes.json().catch(() => [])) as Array<{ id?: string }>;
if (profileRes.ok) {
const profileUserId = String(profileData?.[0]?.id || "").trim();
if (profileUserId) return { supabaseUrl, serviceRoleKey, userId: profileUserId };
}
const res = await fetch(
`${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`,
{
@@ -109,7 +128,7 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
apikey: serviceRoleKey,
Authorization: `Bearer ${serviceRoleKey}`,
"Content-Type": "application/json",
Prefer: "return=representation",
Prefer: "return=minimal",
},
body: JSON.stringify(payload),
cache: "no-store",
@@ -138,6 +157,9 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
export async function POST(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
if (!API_BASE) {
return grantSubscriptionDirectly(req, body, auth.authEmail);
@@ -145,7 +167,7 @@ export async function POST(req: NextRequest) {
const res = await fetch(`${API_BASE}/api/ops/subscriptions/grant`, {
method: "POST",
headers: { ...(auth.headers as Record<string, string>), "Content-Type": "application/json" },
headers: buildJsonBackendRequestHeaders(auth.headers),
body,
cache: "no-store",
});
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/telegram/members-audit`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/training/accuracy`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/truth-history`);
for (const key of ["city", "date_from", "date_to", "limit"]) {
const value = req.nextUrl.searchParams.get(key);
@@ -2,8 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
buildJsonBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,13 +19,13 @@ export async function POST(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, {
method: "POST",
headers: {
...auth.headers,
"Content-Type": "application/json",
},
headers: buildJsonBackendRequestHeaders(auth.headers),
body,
cache: "no-store",
});
+4
View File
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/users`);
const q = req.nextUrl.searchParams.get("q");
const limit = req.nextUrl.searchParams.get("limit");
+4
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/logs`);
const level = req.nextUrl.searchParams.get("level");
const lines = req.nextUrl.searchParams.get("lines");
+1 -1
View File
@@ -13,7 +13,7 @@ export async function GET(req: NextRequest) {
return proxyBackendJsonGet(req, {
cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900",
detailLimit: 350,
includeSupabaseIdentity: true,
includeSupabaseIdentity: false,
publicMessage: "Failed to fetch payment config",
revalidateSeconds: 300,
url: `${API_BASE}/api/payments/config`,
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -25,7 +25,7 @@ export async function POST(
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
@@ -2,15 +2,48 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
import {
requireTurnstileForRequest,
stripTurnstileToken,
} from "@/lib/turnstile";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
function looksLikeHtmlDocument(value: string) {
const text = String(value || "").trim().toLowerCase();
return (
text.startsWith("<!doctype html") ||
text.startsWith("<html") ||
/<title>[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || ""))
);
}
function submitErrorMessage(raw: string) {
try {
const parsed = JSON.parse(String(raw || "")) as {
detail?: unknown;
error?: unknown;
message?: unknown;
};
const message = [parsed.detail, parsed.error, parsed.message].find(
(item) => typeof item === "string" && item.trim(),
);
if (typeof message === "string") {
const trimmed = message.trim();
if (!looksLikeHtmlDocument(trimmed)) return trimmed.slice(0, 350);
}
} catch {
// Non-JSON upstream errors are commonly HTML 50x pages; do not expose them.
}
return "Payment submit upstream failed";
}
export async function POST(
req: NextRequest,
context: { params: Promise<{ intentId: string }> },
@@ -24,8 +57,12 @@ export async function POST(
const { intentId } = await context.params;
try {
const body = await req.json();
if (process.env.POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT === "true") {
const turnstileError = await requireTurnstileForRequest(req, "payment_tx_submit", body);
if (turnstileError) return turnstileError;
}
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
@@ -34,20 +71,15 @@ export async function POST(
{
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
body: JSON.stringify(stripTurnstileToken(body ?? {})),
cache: "no-store",
},
);
if (!res.ok) {
const raw = await res.text();
let detail = raw.slice(0, 350);
try {
const parsed = JSON.parse(raw);
if (parsed.detail) detail = String(parsed.detail).slice(0, 350);
} catch {}
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350,
error: detail || undefined,
error: submitErrorMessage(raw),
});
return applyAuthResponseCookies(response, auth.response);
}
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -25,7 +25,7 @@ export async function POST(
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
+9 -3
View File
@@ -2,13 +2,17 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
import { isPaymentHostAllowed } from "@/lib/payment-host";
import {
requireTurnstileForRequest,
stripTurnstileToken,
} from "@/lib/turnstile";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -35,15 +39,17 @@ export async function POST(req: NextRequest) {
}
try {
const body = await req.json();
const turnstileError = await requireTurnstileForRequest(req, "payment_intent_create", body);
if (turnstileError) return turnstileError;
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/intents`, {
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
body: JSON.stringify(stripTurnstileToken(body ?? {})),
cache: "no-store",
});
if (!res.ok) {
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
@@ -18,7 +18,7 @@ export async function POST(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
method: "POST",
+1 -1
View File
@@ -16,7 +16,7 @@ export async function GET(req: NextRequest) {
conditionalResponse: false,
detailLimit: 500,
fetchCache: "no-store",
includeSupabaseIdentity: true,
includeSupabaseIdentity: false,
publicMessage: "Failed to fetch payment runtime",
url: `${API_BASE}/api/payments/runtime`,
});
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -10,6 +10,42 @@ import {
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND_RETRY_DELAY_MS = 250;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWalletChallengeWithRetry(
url: string,
init: RequestInit,
attempts = 2,
) {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await fetch(url, init);
} catch (error) {
lastError = error;
if (attempt >= attempts) break;
await sleep(BACKEND_RETRY_DELAY_MS * attempt);
}
}
throw lastError;
}
function logWalletChallengeProxyError(
error: unknown,
authDebug: Record<string, unknown>,
) {
const source = error as { name?: unknown; message?: unknown; cause?: unknown };
console.error("[payment-wallet-challenge-proxy-exception]", {
error_name: String(source?.name || "Error"),
error_message: String(source?.message || error || "unknown"),
error_cause: source?.cause ? String(source.cause) : "",
...authDebug,
});
}
export async function POST(req: NextRequest) {
if (!API_BASE) {
@@ -18,31 +54,45 @@ export async function POST(req: NextRequest) {
{ status: 500 },
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json(
{ error: "Invalid wallet challenge request" },
{ status: 400 },
);
}
let authDebug: Record<string, unknown> = {};
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
authDebug = {
incoming_has_authorization: Boolean(
String(req.headers.get("authorization") || "").trim(),
),
has_authorization: proxiedHeaders.has("authorization"),
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
has_forwarded_user_id: proxiedHeaders.has("x-polyweather-auth-user-id"),
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
};
const res = await fetchWalletChallengeWithRetry(
`${API_BASE}/api/payments/wallets/challenge`,
{
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
},
);
if (!res.ok) {
const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350,
extraDebug: {
has_authorization: proxiedHeaders.has("authorization"),
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
has_forwarded_user_id: proxiedHeaders.has(
"x-polyweather-auth-user-id",
),
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
},
extraDebug: authDebug,
});
return applyAuthResponseCookies(response, auth.response);
}
@@ -50,8 +100,12 @@ export async function POST(req: NextRequest) {
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
logWalletChallengeProxyError(error, authDebug);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to create wallet challenge",
status: 502,
publicMessage:
"Wallet challenge service is temporarily unavailable. Please retry.",
extra: { retryable: true },
});
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -45,7 +45,7 @@ export async function DELETE(req: NextRequest) {
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendAuthUser,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -10,6 +10,42 @@ import {
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND_RETRY_DELAY_MS = 250;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWalletVerifyWithRetry(
url: string,
init: RequestInit,
attempts = 2,
) {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await fetch(url, init);
} catch (error) {
lastError = error;
if (attempt >= attempts) break;
await sleep(BACKEND_RETRY_DELAY_MS * attempt);
}
}
throw lastError;
}
function logWalletVerifyProxyError(
error: unknown,
authDebug: Record<string, unknown>,
) {
const source = error as { name?: unknown; message?: unknown; cause?: unknown };
console.error("[payment-wallet-verify-proxy-exception]", {
error_name: String(source?.name || "Error"),
error_message: String(source?.message || error || "unknown"),
error_cause: source?.cause ? String(source.cause) : "",
...authDebug,
});
}
export async function POST(req: NextRequest) {
if (!API_BASE) {
@@ -18,31 +54,45 @@ export async function POST(req: NextRequest) {
{ status: 500 },
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json(
{ error: "Invalid wallet verify request" },
{ status: 400 },
);
}
let authDebug: Record<string, unknown> = {};
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendAuthUser(auth);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
authDebug = {
incoming_has_authorization: Boolean(
String(req.headers.get("authorization") || "").trim(),
),
has_authorization: proxiedHeaders.has("authorization"),
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
has_forwarded_user_id: proxiedHeaders.has("x-polyweather-auth-user-id"),
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
};
const res = await fetchWalletVerifyWithRetry(
`${API_BASE}/api/payments/wallets/verify`,
{
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
},
);
if (!res.ok) {
const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350,
extraDebug: {
has_authorization: proxiedHeaders.has("authorization"),
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
has_forwarded_user_id: proxiedHeaders.has(
"x-polyweather-auth-user-id",
),
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
},
extraDebug: authDebug,
});
return applyAuthResponseCookies(response, auth.response);
}
@@ -50,8 +100,12 @@ export async function POST(req: NextRequest) {
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
logWalletVerifyProxyError(error, authDebug);
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to verify wallet binding",
status: 502,
publicMessage:
"Wallet verify service is temporarily unavailable. Please retry.",
extra: { retryable: true },
});
}
}
@@ -37,7 +37,9 @@ export async function POST(req: NextRequest) {
const timeoutId = setTimeout(() => controller.abort(), OVERVIEW_PROXY_TIMEOUT_MS);
try {
auth = await buildBackendRequestHeaders(req);
auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
headers.set("Accept", "application/json");
+32 -8
View File
@@ -1,19 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy";
import {
buildForceRefreshProxyCachePolicy,
buildScanTerminalResponseCacheControl,
} from "@/lib/proxy-cache-policy";
import { DASHBOARD_REFRESH_POLICY_SEC } from "@/lib/refresh-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "40000",
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "35000",
);
export const maxDuration = 45;
export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "scan_terminal");
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
@@ -30,6 +43,8 @@ export async function GET(req: NextRequest) {
"time_range",
"limit",
"force_refresh",
"diff",
"since_snapshot_id",
"timezone_offset_seconds",
]) {
const value = req.nextUrl.searchParams.get(key);
@@ -41,7 +56,10 @@ export async function GET(req: NextRequest) {
if (tradingRegion != null && tradingRegion !== "") {
params.set("region", tradingRegion);
}
const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 10);
const cachePolicy = buildForceRefreshProxyCachePolicy(
forceRefresh,
DASHBOARD_REFRESH_POLICY_SEC.scanRows,
);
const url = `${API_BASE}/api/scan/terminal?${params.toString()}`;
@@ -51,12 +69,18 @@ export async function GET(req: NextRequest) {
try {
return await proxyBackendJsonGet(req, {
cacheControl: cachePolicy.responseCacheControl,
fetchCache:
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined,
cacheControlForData: (data) =>
buildScanTerminalResponseCacheControl(
data,
cachePolicy.responseCacheControl,
),
fetchCache: "no-store",
publicMessage: "Failed to fetch scan terminal data",
revalidateSeconds: cachePolicy.revalidateSeconds,
includeSupabaseIdentity: true,
signal: controller.signal,
timeoutPublicMessage: "Scan terminal request timed out",
timing: timer,
url,
});
} finally {

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