Compare commits

..

476 Commits

Author SHA1 Message Date
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
2569718930@qq.com c2fe8caddf docs: refresh v1.8.1 release docs 2026-05-28 20:46:35 +08:00
2569718930@qq.com 784b27a954 fix: preserve terminal access during auth sync 2026-05-28 12:18:43 +08:00
2569718930@qq.com eef611adb4 fix: align telegram runway slope with settlement endpoint 2026-05-28 11:05:50 +08:00
2569718930@qq.com f56c604677 feat: implement analysis service and TTL configuration for regional city-specific cache management 2026-05-28 10:59:54 +08:00
2569718930@qq.com 79b82a34cb fix: use settlement runway endpoint temperatures 2026-05-28 10:55:32 +08:00
2569718930@qq.com d83a0f0eef feat: build DEB hourly consensus for peak windows 2026-05-28 10:44:48 +08:00
2569718930@qq.com 12d911f356 ci: retry image pulls during deploy 2026-05-28 10:16:31 +08:00
2569718930@qq.com be549daf50 fix: use multi-model peak window for gaussian probability 2026-05-28 10:03:29 +08:00
2569718930@qq.com fb4a9d7e09 fix: align gaussian probability with local forecast date 2026-05-28 09:44:42 +08:00
2569718930@qq.com 2e6349cbc6 fix: align scan terminal proxy and build timeouts 2026-05-28 09:23:01 +08:00
2569718930@qq.com 4c68fe696b fix: keep scan build timeout below proxy limit 2026-05-28 09:16:01 +08:00
2569718930@qq.com b239ad131f fix: keep terminal charts visible during scan refresh 2026-05-28 09:07:09 +08:00
2569718930@qq.com 651a93cacf fix: remove unused city runtime imports 2026-05-28 08:29:57 +08:00
2569718930@qq.com 5d784f78b9 feat: implement scan terminal dashboard system and supporting services 2026-05-28 08:24:50 +08:00
2569718930@qq.com 725727d763 fix: restore Hong Kong CoWIN reference curve 2026-05-28 08:14:27 +08:00
2569718930@qq.com 1a7d2a487f feat: add hourly peak correction for DEB charts 2026-05-27 22:41:16 +08:00
2569718930@qq.com 14141314d7 fix: preserve DEB metadata in city summary 2026-05-27 21:50:28 +08:00
2569718930@qq.com 8609376c59 feat: add versioned DEB bias backtesting 2026-05-27 21:40:26 +08:00
2569718930@qq.com 4d22191a2d Fix city selector fallback coverage 2026-05-27 21:02:08 +08:00
2569718930@qq.com fb7e27d9ed fix: recover account subscription state 2026-05-27 12:14:07 +08:00
2569718930@qq.com 7ff1d21b95 fix: stabilize realtime chart axis and redis deployment 2026-05-27 11:51:22 +08:00
2569718930@qq.com 573768846e feat: implement real-time SSE event architecture with Redis stream integration and add associated validation tests 2026-05-27 11:03:04 +08:00
2569718930@qq.com 820dabfbf3 feat: implement real-time observation patch normalization and live temperature threshold visualization logic 2026-05-27 10:17:33 +08:00
2569718930@qq.com e6a673e27d feat: add multiple weather data source scrapers and integrate dashboard temperature chart logic components 2026-05-27 09:31:24 +08:00
2569718930@qq.com 05010d20e2 feat: implement AMOS real-time weather data collection and add corresponding unit and frontend tests 2026-05-27 09:07:48 +08:00
2569718930@qq.com bef3c610b7 feat: implement Telegram push utility and temperature threshold visualization components 2026-05-27 08:53:36 +08:00
2569718930@qq.com bbd7c768f8 feat: implement live temperature threshold charting component with SSE patch support and data collection logic 2026-05-27 08:42:08 +08:00
2569718930@qq.com 65fe2d7361 feat: implement runway temperature charting logic and UI components for scan terminal dashboard 2026-05-27 08:07:49 +08:00
2569718930@qq.com 79d94bed5f feat: add weekly reward loop, i18n support, and scan terminal dashboard components 2026-05-27 07:50:05 +08:00
2569718930@qq.com 2670e2f8ee feat: implement interactive temperature chart dashboard with runway trend visualization and multi-source data collection support 2026-05-27 01:16:49 +08:00
2569718930@qq.com c4119c1aab feat: add LiveTemperatureThresholdChart component and corresponding SSE architecture tests 2026-05-27 00:53:53 +08:00
2569718930@qq.com 40dcd6e8f0 feat: implement core logic and visualization components for runway temperature monitoring with SSE patching support 2026-05-27 00:44:38 +08:00
2569718930@qq.com 6b2c99cea4 feat: implement SSE architecture for real-time temperature updates with componentized dashboard charts and validation tests 2026-05-27 00:24:19 +08:00
2569718930@qq.com 50ac181b8d feat: add default visibility policies and chart data logic for temperature scan terminal 2026-05-27 00:05:55 +08:00
2569718930@qq.com 91ccb061a7 feat: implement LiveTemperatureThresholdChart and associated data processing logic with comprehensive unit tests 2026-05-26 23:56:42 +08:00
2569718930@qq.com b3496bed2d feat: add LiveTemperatureThresholdChart component with SSE-driven data synchronization and automated polling fallback 2026-05-26 23:28:43 +08:00
2569718930@qq.com b6194b4756 feat: implement live temperature threshold chart logic with auto-visibility policies and snapshot testing 2026-05-26 23:12:48 +08:00
2569718930@qq.com 645e304b3e feat: implement SSE-based real-time event distribution system with replay support and heartbeat functionality 2026-05-26 22:27:55 +08:00
2569718930@qq.com 5c6ffa4742 docs: shorten realtime patch replay retention 2026-05-26 22:10:15 +08:00
2569718930@qq.com ff104cfc31 docs: design production realtime sse patches 2026-05-26 22:08:44 +08:00
2569718930@qq.com 7a9dc35c63 Settlement 标签改为显示当地官方站点名称
- HourlyForecast 新增 settlementStationLabel 字段
- 从 detail JSON settlement_station.settlement_station_label 解析
- 台北显示 中央气象署台北站,其他城市有官方站名的也会显示
2026-05-26 21:30:33 +08:00
2569718930@qq.com a17ba0efb9 docker-compose 加 json-file 日志上限 50m×3,防止容器日志吃光磁盘 2026-05-26 20:58:30 +08:00
2569718930@qq.com d029d5c4e7 Auth session 持久化 + 多分辨率跑道图表 + 拖拽缩放
- middleware: refreshMiddlewareSession 前置刷新 Supabase token
- backend-auth: getUser() 优先触发 refresh,再 getSession 拿新 token
- Dashboard: onAuthStateChange 实时更新 proAccess + 15min getSession 心跳
- detail proxy: 新增 city/[name]/detail 代理,透传 resolution 参数
- city_payloads: aggregate_runway_history + build_runway_band_history 跑道聚合
- chart: 10m→1m 自适应分辨率 + zoom 拖拽 + 跑道温区 Area + Runway Details 开关
2026-05-26 20:37:00 +08:00
2569718930@qq.com 88b80d66e8 全局替换旧域名 polyweather-pro.vercel.app → polyweather.top
- payment-host: 移除 vercel 域名,加 www.polyweather.top
- CORS: 移除 vercel,加 www
- bot: 绑定链接 + 市场概览提示
- weather_sources: User-Agent
2026-05-26 19:53:47 +08:00
2569718930@qq.com dd585ac83b 模型区间数据修复:scan row 优先 daily models,fallback 到 multi forecasts + hourly
- 后端 _build_quick_row: model_cluster_sources 先从 multi_model_daily 取 daily models
- 前端 chart: modelValues 优先 row.model_cluster_sources,缺失时从 hourly.multiModelDaily 补
2026-05-26 11:51:27 +08:00
2569718930@qq.com bf1ee43ffe 台北 Settlement 标签从 跑道实测 改为 CWA 2026-05-26 11:36:00 +08:00
2569718930@qq.com 72d2f360c6 feat: add LiveTemperatureThresholdChart component for real-time runway temperature monitoring 2026-05-26 11:28:33 +08:00
2569718930@qq.com 4ad1014108 修复全局 CORS:NEXT_PUBLIC_API_BASE_URL 默认改为空,前端请求走 Next.js 代理 2026-05-26 11:23:12 +08:00
2569718930@qq.com c43f92cdeb CI build-and-push 前后端并行构建,matrix 策略砍半墙钟时间 2026-05-26 11:18:18 +08:00
2569718930@qq.com 777b7465b7 SSE CORS 修复:EventSource 跨域 + preflight OPTIONS
- sse_router: 显式加 Access-Control-Allow-Origin + Allow-Credentials,新增 OPTIONS
- use-sse-patches: CORS 失败自动切 BFF 代理降级
2026-05-26 11:15:41 +08:00
2569718930@qq.com a4891d58cd feat: implement SSE patch synchronization with frontend hooks and backend infrastructure 2026-05-26 11:12:48 +08:00
2569718930@qq.com 55439977f6 文档更新 + SSE hook 修复 2026-05-26 11:02:33 +08:00
2569718930@qq.com 7045e11082 修复 weather_sources.py MADIS cache 清理中未定义的 now 变量 2026-05-26 10:40:00 +08:00
2569718930@qq.com 23882ac1ef SSE Patch 增量推送架构全链路实现
- web/sse_manager.py: asyncio.Queue 连接池 + broadcast + event_stream
- web/routers/sse_router.py: GET /api/events + POST /api/internal/collector-patch
- weather_sources.py: 采集后 POST patch 给 web
- Nginx polyweather.conf: /api/events proxy_buffering off
- frontend/api/events/route.ts: Next.js SSE 代理
- frontend/hooks/use-sse-patches.ts: EventSource + useLatestPatch + 重连
- LiveTemperatureThresholdChart: mergePatchIntoHourly 增量合并
- scan-terminal-query: 接入 SSE patch 更新行数据
- 新增 ssePatchArchitecture.test.ts 架构测试
2026-05-26 10:39:17 +08:00
2569718930@qq.com 14252915ab 移除图表点击时的毛玻璃 loading 蒙层,改为静默后台更新 2026-05-26 09:54:16 +08:00
2569718930@qq.com fbd00df72f 移除图表 Brush 缩放拖拽组件,简化交互 2026-05-26 09:52:09 +08:00
2569718930@qq.com 4d103a7a38 test: add unit tests for country network snapshots, city time zone mappings, and METAR data source logic 2026-05-26 09:48:46 +08:00
2569718930@qq.com dd72908e0e 60s 轮询扩展:跑道曲线 + 站点观测同步刷新
原来只轮询 summary 拿 current.temp 更新数字。
现在同时 fetch detail?depth=full 合并 amos/runway_plate_history/
airport_primary_today_obs 等字段,跑道曲线和站点线每 60s 刷新。
后端 CITY_FULL_CACHE_TTL_SEC=60 保证轻量命中缓存。
2026-05-26 09:46:36 +08:00
2569718930@qq.com 6e6d27f323 莫斯科数据源简化:移除 METAR 集群 + NOAA 结算,仅保留 UUWW 单站
- city_registry: settlement_source noaa → metar
- weather_sources: 移除 Moscow METAR cluster 5 站配置
- country_networks: 删除 RussiaStationWebNetworkProvider 类
- 文档: Tier 4 备注更新
2026-05-26 09:20:49 +08:00
2569718930@qq.com 0fffc0faa1 修复 weather_sources.py 缺少 timezone 导入 2026-05-26 09:14:34 +08:00
2569718930@qq.com 45365df694 CoWIN 6087 历史持久化 + 跑道曲线聚合 + 模型区间修复 + HKO 曲线对调 + 毛玻璃加载层
- weather_sources: CoWIN 1min 观测写入 official_intraday_observations_store
- country_networks: cowin_obs 源从 DB 回读 1min 历史,不复用 VHHH METAR
- analysis_service: 跑道实测 36h 历史按跑道分组,透传至前端
- scan_terminal_city_row: model_cluster_sources fallback 修复 + runway_plate_history
- 前端: CoWIN 6087 升级为主轴实线,HKO 退为虚线,VHHH METAR 轻虚线
- 前端: Loading 升级为毛玻璃全卡蒙层
2026-05-26 09:13:39 +08:00
2569718930@qq.com 09a8e1bfbe SEO 全链路:OG/Twitter 标签 + robots.txt + sitemap.xml + JSON-LD 结构化数据
- layout metadata: Open Graph + Twitter Card + canonical + robots 指令
- app/robots.ts: 允许首页/auth,禁止 /terminal/account/ops/api
- app/sitemap.ts: 首页 1.0 + /auth/login 0.6
- 首页 JSON-LD: WebApplication schema,含 $10/mo 定价
2026-05-26 08:43:55 +08:00
2569718930@qq.com c88d313f60 终端 header 显示当前在线人数
- src/utils/online_tracker.py:5 分钟滑动窗口,thread-safe 内存追踪
- web/core.py:认证成功时 record_activity(user_id)
- web/routers/ops.py:GET /api/ops/online-users 返回 {online: N}
- 前端 header 每 60s 轮询,Users 图标 + 数字
2026-05-26 08:40:17 +08:00
2569718930@qq.com 23203f6ebb 图表 stats bar 温度数字 60 秒级轻量轮询
liveTemp state 独立于 hourly 缓存,每 60s fetch /api/city/{city}/summary。
只取 current.temp,不触发 _analyze() 重算。
图表曲线保持 5min TTL,顶部数字最快 1min 级更新。
2026-05-26 08:30:33 +08:00
2569718930@qq.com 5b19a49ffb 首屏优化:scan terminal 加 s-maxage 缓存 + 落地页预加载 API
1. /api/scan/terminal 返回 Cache-Control: public, s-maxage=30, stale-while-revalidate=120
   → pre-warm 期间也能返回缓存,白屏 20s 变 1s
2. 落地页 2s 后静默 prefetch /api/cities,预热后端进程和连接池
3. metadata 加 preconnect api.polyweather.top
2026-05-26 08:23:33 +08:00
2569718930@qq.com 4bc352519a 文档更新:香港 HKO 改 10min + CoWIN 6087 补入优先级链 + 深圳移入 Tier 3 2026-05-26 08:16:42 +08:00
2569718930@qq.com 5d10d60dcb 缓存 TTL 对齐:图表内存/会话缓存统一降为 5 分钟(metar),与大表刷新同步
- HOURLY_CACHE_TTL_MS: model(30min) → metar(5min)
- SESSION_CACHE_TTL_MS: 10min → metar(5min)
- 消除大表已刷新但折线图仍显示旧温度的时差问题
2026-05-26 08:13:37 +08:00
2569718930@qq.com 8a534160f0 修复 MADIS 测试:HH:MM 时间戳改为 ISO 格式,避免日期回退导致 slot 错位
normObs 对 HH:MM 格式调用 getCityLocalUtcTimestamp 时未传 referenceLocalDate,
导致取当前真实日期而非测试数据日期,所有观测挤到最后一个 slot。
2026-05-26 08:11:13 +08:00
2569718930@qq.com 18b5e9cfed 修正深圳 HKO 站名:Shenzhen (LFS) → Lau Fau Shan 匹配天文台 CSV 2026-05-26 08:01:04 +08:00
2569718930@qq.com c331c89289 修复 fallback fetch 依赖:rows.length 改为 useRef 标记,避免 terminalData 刷新时无意义 re-run 2026-05-26 07:59:55 +08:00
2569718930@qq.com 598cd17c0b 城市图表增加 LoadingSignal 组件:detail 数据加载中显示进度指示 2026-05-26 07:57:38 +08:00
2569718930@qq.com 20d53e1ae2 新增 CoWIN 6087 数据源:香港保良局陳守仁小學 1 分钟参考站
- cowin_sources.py:从 cowin.hku.hk API 抓取站 6087 的逐分钟温度
- weather_sources.py:CowinSourceMixin 接入采集链,优先级高于 HKO
- country_networks.py:_airport_primary_from_raw 加入 COWIN 优先检查
  airport_primary.source_label 显示为 CoWIN 6087,前端图表默认可见
2026-05-26 07:52:49 +08:00
2569718930@qq.com 00e3007949 feat: add LiveTemperatureThresholdChart component to dashboard scan-terminal 2026-05-26 07:32:02 +08:00
2569718930@qq.com 04dcbcd6ea 移除图表跑道系列的 AMOS/AMSC source 标签 2026-05-26 06:31:55 +08:00
2569718930@qq.com 3a82c11e78 feat: implement LiveTemperatureThresholdChart and add visibility and refresh policy unit tests 2026-05-26 06:13:18 +08:00
2569718930@qq.com 636a81d6df test: add unit tests for temperature chart data and visibility policy, and document city data sources 2026-05-26 06:02:47 +08:00
2569718930@qq.com e1417f607d feat: implement LiveTemperatureThresholdChart and add visibility policy unit tests 2026-05-26 05:46:45 +08:00
2569718930@qq.com 8ca9ce4223 feat: add scan terminal dashboard, fallback city mapping, and Nginx deployment configuration 2026-05-26 05:20:36 +08:00
2569718930@qq.com 1ac41dde33 Callback 修复补丁:redirect URL 改用 siteUrl 构造,避免跳转到 localhost:3000 2026-05-26 04:48:07 +08:00
2569718930@qq.com a7076f48a6 deploy.sh 成功后自动清理旧镜像,防止磁盘积满 2026-05-26 04:36:43 +08:00
2569718930@qq.com f884b016cc 修复 callback 无限重定向:origin 比较改为 host header 比较
nextUrl.origin 在 route handler 中不认 x-forwarded-* headers,
导致 canonical origin 检查始终失败,callback 无限重定向到自身。
改用 request.headers.get(host) 比较。
2026-05-26 04:27:37 +08:00
2569718930@qq.com ecf9481dfe smoke test 修复:scan terminal 换 api/cities,避免 pre-warm 阻塞超时 2026-05-26 04:07:33 +08:00
2569718930@qq.com ee3d489d8b 部署修复:healthz 重试从 60s 扩大到 150s,适配 pre-warm 125s 2026-05-26 03:57:22 +08:00
2569718930@qq.com f5aefb27e3 部署修复:sleep 5 改重试循环,等 backend 真正 ready 再 smoke test
backend Python imports 需要 10-15s,固定 sleep 5 不够。
2026-05-26 03:55:21 +08:00
2569718930@qq.com e2f11d580e CI 修复:添加 setup-buildx 解决 GHA cache 导出报错
默认 docker driver 不支持 cache-to type=gha,需要 buildx driver。
2026-05-26 03:46:01 +08:00
2569718930@qq.com 91eb4307bd 部署加固:去重构建 + SHA tag 部署 + smoke test + 自动回滚
- frontend-quality 移除重复 npm run build
- docker-compose 镜像标签改用 ${IMAGE_TAG:-latest}
- deploy.sh:存旧 tag → SHA 部署 → smoke test → 失败回滚
- CI deploy job 改为 SCP deploy.sh 远程执行
2026-05-26 03:42:48 +08:00
2569718930@qq.com 28ad77c517 部署迁移:VPS 本机构建 → GitHub Actions 构建镜像推送 ghcr.io
CI 新增 build-and-push job,测试通过后构建 backend/frontend 镜像推送 ghcr.io。
VPS 部署改为 docker compose pull + up -d,不再本地编译。
2026-05-26 03:38:09 +08:00
2569718930@qq.com 6cc83ab165 placeholder 2026-05-26 03:21:11 +08:00
2569718930@qq.com 468e53f959 CI 简化:移除 SCP artifact,Dockerfile 内 npm run build 2026-05-26 03:19:28 +08:00
2569718930@qq.com 2e8349a2c9 CI 修复:简化 SSH 命令为 && 链,确保 mkdir 在 SCP 前执行 2026-05-26 03:11:10 +08:00
2569718930@qq.com e1bad1bcfa CI 部署顺序修复:先 git pull 建目录,再 SCP 上传 .next 2026-05-26 02:57:40 +08:00
2569718930@qq.com 2b5d5545e2 城市选择器弹框尺寸放大:280→380px,结果列表 240→380px 2026-05-26 02:53:05 +08:00
2569718930@qq.com 67460685e8 添加 .env.production:CI 构建时可读取 NEXT_PUBLIC 变量 2026-05-26 02:48:47 +08:00
2569718930@qq.com 5fb59b1b21 docker-compose 前端改用 prod.Dockerfile(CI 预构建,跳过 VPS 二次 build) 2026-05-26 02:42:33 +08:00
2569718930@qq.com d088ff389a CI/CD 优化:前端构建产物上传 artifact,VPS 跳过二次 npm run build 2026-05-26 02:39:02 +08:00
2569718930@qq.com 58cc82f520 feat: implement LoginClient component and add institutional landing page layout 2026-05-26 02:32:15 +08:00
2569718930@qq.com 98458c0c05 前端 Dockerfile 支持 build args,docker-compose 注入 NEXT_PUBLIC 变量 2026-05-26 02:25:41 +08:00
2569718930@qq.com e852b9bc89 feat: initialize landing page, payment logic, bot coordination, and configuration validation systems 2026-05-26 02:11:27 +08:00
2569718930@qq.com b68772f031 站点域名从 sslip.io 切换到 polyweather.top 2026-05-26 01:25:44 +08:00
2569718930@qq.com 5b97de4892 frontend 端口改为 3001 避免与 polymarket-bot 冲突 2026-05-26 01:18:46 +08:00
2569718930@qq.com f2ce23d7de VPS 增加 frontend Docker 服务替代 Vercel 2026-05-26 01:04:20 +08:00
2569718930@qq.com cf84b8336f 扫描 worker 默认值 6→8,上限 8→12(适配 8GB 内存) 2026-05-26 01:01:07 +08:00
2569718930@qq.com fedfebc76d 清理残留 .pyc 和 .env.example 中 Polymarket 环境变量 2026-05-26 00:46:43 +08:00
2569718930@qq.com a13dff84ba LoginClient 细节调整 2026-05-26 00:37:18 +08:00
2569718930@qq.com d76c57466d 卸载 leaflet 地图依赖包 2026-05-26 00:33:43 +08:00
2569718930@qq.com 617d3c910e 支付域名识别更新:Vercel → polyweather.top
payment-host.ts 新增 polyweather.top。site-url.ts/wallet.ts/usePaymentFlow/AccountCenter 硬编码域名替换。PRODUCTION_SITE_URL 改为 https://polyweather.top
2026-05-26 00:33:02 +08:00
2569718930@qq.com 3d0d099cb9 修复 _analyze() 移除 include_llm_commentary 参数后的残留调用 2026-05-26 00:29:45 +08:00
2569718930@qq.com 8cb2beb956 恢复美国城市电报推送和子话题 2026-05-26 00:25:35 +08:00
2569718930@qq.com 9545725d7f 清理旧决策卡片、市场总览、跑道面板等已废弃组件 2026-05-25 23:52:44 +08:00
2569718930@qq.com 723c6dafc6 终端布局和搜索框清理,新增网格策略业务测试 2026-05-25 23:44:02 +08:00
2569718930@qq.com 26b6f3ae9b 删除 AI 机场解读 12 个失效测试 2026-05-25 23:14:48 +08:00
2569718930@qq.com a6367ab7c2 修复 marketOverview 移除后的导入错误 2026-05-25 23:09:37 +08:00
2569718930@qq.com c2bb9d3901 电报推送移除美国城市;修复 market_overview_api 死引用 2026-05-25 23:07:32 +08:00
2569718930@qq.com b3aabe61ce 清理市场概览残留与 refresh_policy 中的 marketOverview 条目 2026-05-25 23:06:44 +08:00
2569718930@qq.com d1aa3550ba 电报推送移除全部美国城市,降低服务器负载 2026-05-25 23:05:46 +08:00
2569718930@qq.com ea5c6ec71e 清理市场概览残留:删除 MarketOverviewBanner+refresh_policy 中的 marketOverview 条目 2026-05-25 23:04:40 +08:00
2569718930@qq.com a3db39909d 术语统一:合约/市场/交易 → 阈值/信号/实况路径/多模型预测 2026-05-25 22:51:53 +08:00
2569718930@qq.com a80280732c 限制 bot 容器资源占用 2026-05-25 22:31:42 +08:00
2569718930@qq.com 5d0d1e2673 删除 AI 预报系统全部死代码:11 个文件,扫描客户端 AI 流、状态管理、类型定义 2026-05-25 22:27:02 +08:00
2569718930@qq.com 9291ab9b3d 更新 CLAUDE.md:移除已删除的 PM 文件引用,补充新服务模块和组件 2026-05-25 22:21:00 +08:00
2569718930@qq.com de8e92c985 feat: implement ScanTerminalDashboard and LiveTemperatureThresholdChart components 2026-05-25 22:06:54 +08:00
2569718930@qq.com 368388899d 移除左侧导航栏「市场概览」按钮 2026-05-25 21:57:10 +08:00
2569718930@qq.com b7c5623161 移除终端搜索框 2026-05-25 21:54:49 +08:00
2569718930@qq.com 10dcf20077 巴黎推送按新口径重构:当前实况始终为 AEROWEB,AROME 仅为模型参考 2026-05-25 21:48:20 +08:00
2569718930@qq.com c5e80a56b4 巴黎推送同时展示 AEROWEB 实况和 AROME 临近预报两条温度 2026-05-25 21:41:48 +08:00
2569718930@qq.com 5202ed06ff 巴黎 AROME 仅在 AEROWEB 为主源时作对比展示,避免与当前温度重复 2026-05-25 21:38:10 +08:00
2569718930@qq.com b8b99a0fc5 巴黎推送增加 AROME HD 15分钟临近预报温度行 2026-05-25 21:35:39 +08:00
2569718930@qq.com 0e81b974e7 巴黎推送标明数据源:AEROWEB 实况或 AROME HD 临近预报 2026-05-25 21:28:17 +08:00
2569718930@qq.com 7a10f4b474 统一 METAR 获取:非 HK/深圳城市走同一条 NOAA 路径,前端移除所有流浮山字样 2026-05-25 21:15:14 +08:00
2569718930@qq.com 87643699e7 缓存TTL 2min→10min,空闲时预加载相邻区域数据 2026-05-25 21:06:57 +08:00
2569718930@qq.com 25bcc35063 温度链增加 settlement_current 兜底:CWA/HKO 等官方结算站温度优先于 METAR 2026-05-25 20:58:18 +08:00
2569718930@qq.com 7b8826dfa1 清理最后一批 Polymarket 注释+死代码 WeatherDecisionBand 2026-05-25 20:53:03 +08:00
2569718930@qq.com 11a901d22e 修复 scan_terminal_filters 残留语法错误 2026-05-25 20:28:48 +08:00
2569718930@qq.com 64ab74f7fc 移除区域标签栏和自选时区面板 2026-05-25 20:28:09 +08:00
2569718930@qq.com 7f0506b897 清理残留 skip_polymarket 参数和 PM 注释 2026-05-25 20:26:26 +08:00
2569718930@qq.com 2cd43b607d 清理时区逻辑:TRADING_REGIONS→REGIONS,移除 detectLocalRegion/TZ fallback,用 CITY_REGION 硬编码替代 2026-05-25 20:23:44 +08:00
2569718930@qq.com 798e2d6cc2 清理 web 层 PM 代码:city_payloads 移除 market_scan/market-scan/holders 路由
city_payloads: 移除 _get_polymarket_layer/_top_probability_bucket/build_city_market_scan_payload。city.py: 移除 /market-scan 和 /holders 路由。scan_terminal_city_row: 旧 PM 路径删除,quick 模式为唯一路径。
2026-05-25 20:18:11 +08:00
2569718930@qq.com 9c9924cc5a 扫描缓存键按区域隔离,修复切换区域时显示旧区域数据的问题 2026-05-25 20:16:50 +08:00
2569718930@qq.com c16a8cb7a5 修复 Polymarket 清理后的导入错误:analysis_service 移除 city_payloads market_scan 依赖 2026-05-25 20:16:02 +08:00
2569718930@qq.com e3aa612bf3 清理 Polymarket 市场扫描相关代码 2026-05-25 20:13:02 +08:00
2569718930@qq.com 96b8165be1 移除图表 Polymarket 跳转链接,保留城市名称标签 2026-05-25 20:10:56 +08:00
2569718930@qq.com e0ab26a01e 城市列表移除合约数显示,仅保留城市名和当地时间 2026-05-25 20:09:36 +08:00
2569718930@qq.com ef8d89e72e 删除 Polymarket 核心文件:readonly 层+WS 缓存 2026-05-25 20:07:32 +08:00
2569718930@qq.com d3c8058178 更新 CLAUDE.md:Polymarket 已全部移除,终端仅气象数据 2026-05-25 20:03:06 +08:00
2569718930@qq.com 3bc1a4a1ed 后端深度清理 Polymarket:删除 readonly 层+WS 缓存+market_scan API
删除 src/data_collection/polymarket_readonly.py(2500行)和 polymarket_ws_cache.py。city_payloads 移除 _get_polymarket_layer/_top_probability_bucket/build_city_market_scan_payload。city.py 移除 /market-scan 和 /holders 路由。scan_terminal 移除 skip_polymarket。
2026-05-25 20:02:30 +08:00
2569718930@qq.com 66038e060c 更新 CLAUDE.md:Polymarket 已全部移除,终端仅气象数据 2026-05-25 19:59:24 +08:00
2569718930@qq.com 3d4ab83bdb 更新 CLAUDE.md:WS-only 价格管线、skip_polymarket、偏差修正、WAL 模式 2026-05-25 19:55:56 +08:00
2569718930@qq.com 2d9e99760f 深度清理 Polymarket 代码:删除10个文件+3个API路由+客户端引用
删除 MarketOverviewView、MarketDecisionLine、market-scan-state、use-city-market-scan、AnalyticsPanel、TerminalDashboard、polymarket-market-links、lib/types、market-scan 路由、use-ai-city-card-data。scan-terminal-client 移除 getMarketScan 和 skip_polymarket。
2026-05-25 19:50:28 +08:00
2569718930@qq.com a3c4fd522b Y 轴改为整度刻度 + 右侧显示,砍掉 Polymarket 市场温度选项,formatPrice 兜底 2026-05-25 19:42:44 +08:00
2569718930@qq.com cdd3cd25ae 气象情报终端:移除 Polymarket 市场数据列和 MarketOverviewView
GroupedMarketTable 9列→6列(删Market/Edge/SprLiq)。continent-grouping 删 formatPrice/formatSpreadLiquidity,getGapColor 纯温度语义。KoyfinRowsTable 精简为城市列表。MobileCityCard 市场价格替换为本地时间。侧边栏移除市场概览入口。
2026-05-25 19:42:06 +08:00
2569718930@qq.com 2e2670491f 走势图重构:全天 48 槽位 X 轴、交互图例切换、Brush 缩放 2026-05-25 19:27:37 +08:00
2569718930@qq.com ee1859a118 修复 LiveTemperatureThresholdChart 缺失常量+Legend 导入 2026-05-25 19:25:05 +08:00
2569718930@qq.com 262ce600fa 移除 CityContractDetail 组件:城市详情面板不再内嵌合约查询
删除 105 行组件定义+按需拉取 Polymarket 市场数据逻辑。selectedCity 状态保留给 CityRegionList 使用。
2026-05-25 19:23:52 +08:00
2569718930@qq.com cf415a1150 时区Tab支持用户自选:localStorage持久化,至少保留一个
visibleRegions Set + toggleRegion guard(size<=1)。齿轮按钮弹出checkbox选择器。地图Tab仅显示已选时区。
2026-05-25 19:09:58 +08:00
2569718930@qq.com 659cf2e119 城市列表移除百分比显示,新增当地时'间 2026-05-25 19:07:40 +08:00
2569718930@qq.com bd5b4b32da Panel 容器移除 overflow-auto,内容区不滚动 2026-05-25 19:04:03 +08:00
2569718930@qq.com 13d818faf2 走势图智能分流:有跑道数据时图表展示跑道+多模型列表,无跑道时图表展示多模型曲线 2026-05-25 19:01:37 +08:00
2569718930@qq.com 6a8eda8628 城市列表移除滚动和高度限制,直接全部铺开 2026-05-25 18:57:33 +08:00
2569718930@qq.com adae35d016 城市列表隐藏滚动条,保留滚动能力 2026-05-25 18:55:02 +08:00
2569718930@qq.com 1a8733ced6 修复 multi_model 被重写为纯数字字典导致逐时曲线数据丢失 2026-05-25 18:44:03 +08:00
2569718930@qq.com acf3e10ea2 多模型逐时数据兜底:analysis 缓存无 hourly 时直接调 fetch_multi_model 2026-05-25 18:39:54 +08:00
2569718930@qq.com 887148a85e 图表改用 depth=full 获取多模型逐时曲线 2026-05-25 18:32:21 +08:00
2569718930@qq.com 608befe748 稳定 Open-Meteo 冷却缓存测试 2026-05-25 18:28:00 +08:00
2569718930@qq.com 60d684def9 修复 panel 模式遗漏多模型逐时数据:include_multi_model 不再受 is_panel_mode 限制 2026-05-25 18:18:48 +08:00
2569718930@qq.com de78934444 城市列表快速加载 + 点击城市按需拉取 Polymarket 价格 2026-05-25 18:04:47 +08:00
2569718930@qq.com d306c1ff29 扫描终端加 localStorage 缓存,二次进入城市列表瞬间加载 2026-05-25 18:01:01 +08:00
2569718930@qq.com c7cfd2cb59 修复 analysis_service.py 语法错误 + scan.py Python 3.8 兼容 2026-05-25 17:54:44 +08:00
2569718930@qq.com 617b55f24c scan.py 添加 from __future__ import annotations 修复 Python 3.8 兼容 2026-05-25 17:54:23 +08:00
2569718930@qq.com 715ffa4566 恢复误删的缓存状态变量并完成工具函数迁移 2026-05-25 17:53:25 +08:00
2569718930@qq.com 7d609e40f6 打开 Polymarket 市场扫描:skip_polymarket 从 true 改为 false 2026-05-25 17:52:20 +08:00
2569718930@qq.com 6b7a4575ad 拆分 analysis_service:剩余工具函数全部移至 analysis_utils.py
提取 _mgm_hourly_high/_dedupe_forecast_daily/_format_observation_time_local/_parse_local_hour/_parse_utc_datetime/_metar_is_current_local_day/_is_plausible_city_temp。统一 parse_utc_datetime 副本。analysis_service 2082→1966 行。
2026-05-25 17:51:10 +08:00
2569718930@qq.com 812a4b2d32 拆分 analysis_service:时钟工具和概率桶函数移至 analysis_utils.py
抽取 clock_minutes/format_clock_minutes/next_observation_clock 和 bucket_label/top_probability_bucket/add_signal 到独立模块。analysis_service 2152→2080 行。
2026-05-25 17:40:50 +08:00
2569718930@qq.com c82c33ec53 拆分 scan_terminal_service:AI 配置常量移至 scan_ai_config.py
提取 ~140 行配置+env helpers 到新模块。scan_terminal_service 1534→1474 行。
2026-05-25 17:37:38 +08:00
2569718930@qq.com 623ec7c5fa 拆分 analysis_service:观测源配置与新鲜度工具函数移至 observation_freshness.py
抽取 _OBSERVATION_SOURCE_PROFILES、parse_utc_datetime、observation_age_min、canonical_observation_source_code、build_observation_freshness 到独立模块。analysis_service.py 加 10 行导入替代原 ~180 行内联定义。
2026-05-25 17:31:26 +08:00
2569718930@qq.com 3732dafb95 删除 _unused_CityGroupedTable(137行) + KoyfinRowsTable 改为外部导入
ScanTerminalDashboard 1060→841 行。
2026-05-25 17:23:36 +08:00
2569718930@qq.com dc5b747385 抽离 KoyfinRowsTable 为独立组件 2026-05-25 17:16:58 +08:00
2569718930@qq.com f38f0893df 清理旧 Dashboard 全栈死代码:useDashboardStore、dashboardClient、22个关联组件
删除 6650+ 行死代码:useDashboardStore.tsx(1666)、dashboardClient.ts(576)、CitySidebar/DetailPanel/PanelSections/ProbabilityDistribution 及关联 CSS、9个 opportunity-* 模块、ModelEvidencePanel/AiPinnedCityCard/MobileDecisionCard/AiPinnedForecastView/useAiPinnedCityWorkspace。scan-root-styles 清理 6 个无用 CSS 导入。
2026-05-25 17:11:12 +08:00
2569718930@qq.com 80729274bc 修复业务测试:MarketTable→CityRegionList 组件名同步 2026-05-25 17:07:58 +08:00
2569718930@qq.com 56ddce4be0 清理临时测试脚本 2026-05-25 17:00:09 +08:00
2569718930@qq.com 75e6f58a56 SQLite 开启 WAL 模式 + busy_timeout 解决并行扫描写入锁冲突 2026-05-25 16:53:44 +08:00
2569718930@qq.com 045121a457 feat: implement scan terminal dashboard with real-time market data services and interactive visual components 2026-05-25 16:48:19 +08:00
2569718930@qq.com 21a375a198 修复 WS 事件识别:无 type envelope 的价格消息也处理 2026-05-25 07:56:45 +08:00
2569718930@qq.com 7273891794 更新 CLAUDE.md,清理死 import,图表与 API 细节调整 2026-05-25 07:52:31 +08:00
2569718930@qq.com e6d24c308f WS 新增临时调试日志查看实际接收消息 2026-05-25 07:52:18 +08:00
2569718930@qq.com 5566894e84 修复 WS 订阅格式:type=subscribe channel=market 对齐官方文档 2026-05-25 07:49:55 +08:00
2569718930@qq.com c2c7d428bf 移除 REST 价格获取,纯 WebSocket 报价;默认开启 WS 价格 2026-05-25 07:42:46 +08:00
2569718930@qq.com 1ef209ce2f 移除 REST 价格获取,纯 WebSocket 报价;默认开启 WS 价格;修复图表 TS 错误 2026-05-25 07:38:35 +08:00
2569718930@qq.com 81f61370fe _batch_get_token_market_data 批量 WS 预热订阅避免全冷 REST 查询 2026-05-25 07:35:19 +08:00
2569718930@qq.com 93174babbc 点击城市自动选中首条合约并加载走势图 2026-05-25 07:20:24 +08:00
2569718930@qq.com e5575c96a0 修复 DEB 预报曲线因 API 结构不匹配而永不渲染
P0: detail?depth=panel 返回 timeseries.hourly 嵌套结构,图表原来直接读 json.hourly 始终 undefined。改为 json.hourly ?? json.timeseries?.hourly fallback。models_hourly 同理。
2026-05-25 07:14:39 +08:00
2569718930@qq.com 734d8a05d5 修复 selectedCity 解构遗漏 2026-05-25 07:11:08 +08:00
2569718930@qq.com f66f6e53d6 修复 region 过滤透传和 PolyWeatherTerminal selectedCity 类型错误 2026-05-25 07:10:03 +08:00
2569718930@qq.com 17dc79cd56 normalize_scan_terminal_filters 透传 trading_region 参数以支持区域过滤 2026-05-25 07:08:45 +08:00
2569718930@qq.com 10112d2f58 LiveTemperatureThresholdChart 第二轮修复:hourlyCache模块化、去闪烁、数据修复
P0: hourlyCache 从组件体移到模块作用域+移除 setHourly(null)闪烁。P1: normObs 过滤无时间戳数据点。seriesStats 标签 15m→Δ15。buildMarketTemperatureOptions 停止从 market_question 文本暴力提取数字。
2026-05-25 07:04:59 +08:00
2569718930@qq.com c74be88cf9 扫描终端支持 region 参数按区域懒加载 2026-05-25 07:00:59 +08:00
2569718930@qq.com 699ef065b7 大洲分组逻辑微调 2026-05-25 06:57:28 +08:00
2569718930@qq.com 41e322642a 扫描终端超时从 60s 提高到 120s,确保所有 51 城完成处理 2026-05-25 06:52:27 +08:00
2569718930@qq.com bb60630fd7 LiveTemperatureThresholdChart 修复:移除跑道死代码+人造时间戳,修复跨午夜 toTimestamp
删除 RunwayObsPayload 类型和 runway_obs 读数分支(ScanOpportunityRow 无此字段,死代码)。toTimestamp 感知跨午夜:若解析时间超前当前>2h则回溯一天。
2026-05-25 06:51:45 +08:00
2569718930@qq.com 85452fdf56 移除未使用的 CITIES 导入 2026-05-25 06:44:04 +08:00
2569718930@qq.com 9978ac3e01 新增实时滚动温度走势图:API端点+前端组件
后端 city_realtime_stream.py:循环缓冲区(deque maxlen=1440),best_temp() METAR优先。路由 /api/city/{name}/realtime-stream 返回 {points, thresholds}。前端 RealtimeScrollChart:每30秒轮询,一条温度线+多条阈值横线,横轴随时间推进。
2026-05-25 06:43:42 +08:00
2569718930@qq.com 414708794d 终端与时区分组逻辑微调 2026-05-25 06:38:05 +08:00
2569718930@qq.com 1c97f0add1 气温走势图重构:滑动时间窗口替代固定半小时间隔
废弃 HALF_HOUR_SLOTS/parseTimeSlot 槽位映射。新设计:真实时间戳+滑动窗口(MAX_OBS_POINTS=1440)。横坐标随时间推进滚动,阈值线固定。观测数据按原始时间戳对齐,DEB 和多模型曲线按预报时刻对准。XAxis interval 自适应数据密度。
2026-05-25 06:35:33 +08:00
2569718930@qq.com d64a92fd24 扫描终端按需加载:后端支持 trading_region 过滤 + skip_polymarket 快速通道
scan_terminal_service:trading_region 参数预过滤城市列表。scan_terminal_city_row:快速模式跳过 Polymarket RPC。前端 hook 支持按区域查询。
2026-05-25 06:32:42 +08:00
2569718930@qq.com dd8f1c2d73 扫描终端快速模式:skip_polymarket 跳过市场匹配,秒出城市列表
后端 _scan_city_terminal_rows_quick 返回缓存分析数据(Obs/DEB/概率),跳过 Polymarket RPC。前端 getTerminal 默认传 skip_polymarket=true,点击城市时再拉取市场数据。
2026-05-25 06:26:51 +08:00
2569718930@qq.com 56b630029f 移除走势图 UMA 前缀,清理未使用变量 metar_ctx 2026-05-25 06:26:15 +08:00
2569718930@qq.com c9339c1bca 移除走势图 ReferenceLine 的 UMA 前缀,仅显示温度值 2026-05-25 06:25:53 +08:00
2569718930@qq.com dc5bd9a496 预热线程增加 I/O 异常保护,避免进程关闭时 file closed 报错 2026-05-25 06:18:55 +08:00
2569718930@qq.com ccc21b7e9d 修复 lau fau shan → shenzhen 重命名导致的 3 个测试失败 2026-05-25 06:17:10 +08:00
2569718930@qq.com 804b720b3c 扫描终端优化:增加并发到6、超时60s、启动预热全城缓存
MAX_WORKERS 2→6,BUILD_TIMEOUT 22→60s。新增 start_scan_terminal_prewarm:服务启动时后台跑全城 _analyze(),后续扫描直接命中缓存。app_factory 在路由注册后触发热身。
2026-05-25 06:12:17 +08:00
2569718930@qq.com 33f0ee48b2 修复 shenzhen 重复键,移除旧 ZGSZ 条目 2026-05-25 06:12:05 +08:00
2569718930@qq.com bf4be0436b 完成 lau fau shan → shenzhen 全量重命名,前端库和文档同步更新 2026-05-25 06:10:54 +08:00
2569718930@qq.com 341397bc45 lau fau shan 重命名为 shenzhen,移除旧 ZGSZ 深圳条目,统一使用流浮山天文台 HKO 数据 2026-05-25 06:09:50 +08:00
2569718930@qq.com 9670d2bc77 移除无市场城市观察行 fallback,所有城市确认有 Polymarket 合约数据 2026-05-25 06:04:29 +08:00
2569718930@qq.com 0affe08798 扫描终端缓存缺失概率分布时自动强制刷新,确保所有城市产生市场扫描行 2026-05-25 06:01:35 +08:00
2569718930@qq.com 90f9beb139 CI 部署前先 docker compose down 避免容器名称冲突 2026-05-25 05:59:41 +08:00
2569718930@qq.com ce667ba1b6 气温走势图:多模型逐小时预测曲线 + API 新增 models_hourly 字段
city_payloads 新增 models_hourly 包含 per-model hourly_forecasts。LiveTemperatureThresholdChart 渲染多模型曲线替代点预测卡片。CityDetail 类型新增 models_hourly。
2026-05-25 05:58:15 +08:00
2569718930@qq.com e478a1f08d 无 Polymarket 市场的城市也生成仅观测行,终端覆盖所有 51 城 2026-05-25 05:51:53 +08:00
2569718930@qq.com b5d5e96f0b 气温走势图 X 轴从固定24h改为半小时间隔的时间滑动窗口
横坐标由 00:00~23:00 硬编码改为半小时间隔槽位,parseTimeSlot 替代 parseHourOfDay。数据映射粒度从小时提升到半小时,窗口随当前时间滑动。
2026-05-25 05:49:40 +08:00
2569718930@qq.com 4c92a1ad4d 点击城市主行同时选中并展开/收起温度档位子列表 2026-05-25 05:45:21 +08:00
2569718930@qq.com 28e0cf8f55 模型点预测卡片只显示温度值,移除无意义的 max/15m 2026-05-25 05:44:28 +08:00
2569718930@qq.com ffbb2d9765 Settlement runway 改名 Settlement station 2026-05-25 05:37:25 +08:00
2569718930@qq.com f32ce52428 移除天气合约表城市前的复选框占位列 2026-05-25 05:35:43 +08:00
2569718930@qq.com 3b288dc4c4 气温走势图 Y 轴改用市场合约温度选项刻度
纵坐标从 distribution_full 提取温度分桶作为 ticks,domain 动态计算而非硬编码。
2026-05-25 05:34:05 +08:00
2569718930@qq.com d631ae08ad 修复 Polymarket 链接:市场 slug 剥除温度后缀转为事件 URL 2026-05-25 05:31:01 +08:00
2569718930@qq.com 17c6c13d4f 气温走势图 Y 轴和 UMA 阈值标签从右侧移至左侧 2026-05-25 05:29:46 +08:00
2569718930@qq.com 945c2c4e20 清理旧 FutureForecast 组件链和 scan-root-styles 死引用 2026-05-25 05:28:36 +08:00
2569718930@qq.com d808d89fdd 清理旧 dashboard 组件和未使用的模块 2026-05-25 05:27:16 +08:00
2569718930@qq.com a4529e0409 合约表新增模型概率列,Edge/Model/Live/DEB/盘口/价差流动性完整决策视图 2026-05-25 05:24:32 +08:00
2569718930@qq.com 29e8c6f06a 合约表子行 Mkt/Liq 改为真实盘口价格和价差流动性 2026-05-25 05:22:14 +08:00
2569718930@qq.com 68e3744650 天气合约市场改为按城市分组折叠,展开显示所有温度档位 2026-05-25 05:19:44 +08:00
2569718930@qq.com a2da66ca5e 移除 AI prompt 中 EMOS/CRPS 禁令文本
EMOS/CRPS 从未在项目中实现,仅存在于 AI prompt 的禁止列表和历史文档中。
2026-05-25 05:18:26 +08:00
2569718930@qq.com 058fb1008b 修复 MarketOverviewView 缺失导致的构建失败 2026-05-25 05:10:24 +08:00
2569718930@qq.com 472ef9fa3b 气温走势图底部恢复 Polymarket 市场链接 2026-05-25 05:07:25 +08:00
2569718930@qq.com c1cc144b8e 气温走势图底部恢复 Polymarket 市场链接 2026-05-25 05:06:32 +08:00
2569718930@qq.com cd08ac24e9 移除终端右侧面板列,气温走势图铺满剩余宽度 2026-05-25 05:04:38 +08:00
2569718930@qq.com 6df18ec3f0 移除天气合约市场的 Ticker/代码列及 ticker 函数 2026-05-25 05:02:52 +08:00
2569718930@qq.com 140a0f16ae 全局字体加大:body 15px,终端密集文字 +1px,text-xs/text-sm 上调 2026-05-25 05:00:16 +08:00
2569718930@qq.com ece77d7595 DEB预报曲线改为平滑monotone渲染,时间轴每2小时一个刻度;修复TrainingDashboard类型错误 2026-05-25 04:57:03 +08:00
2569718930@qq.com 0edcc11d3f 实时气温走势图改用 DEB 小时预报曲线替代平直虚线,选中城市时拉取 hourly 数据 2026-05-25 04:53:28 +08:00
2569718930@qq.com 685d65a36a 训练数据独立为侧边栏专属页面:独立文件+管理仪表盘风格
TrainingDashboard 抽到单独文件,从 Panel 内嵌升级为全宽管理后台页面:4 统计卡片 + 进度条命中率表。点击侧边栏「训练数据」时替换 3 列布局为独立视图。
2026-05-25 04:49:28 +08:00
2569718930@qq.com 931a59d893 终端新增区域筛选标签栏,选中区域后下方所有面板联动过滤 2026-05-25 04:48:23 +08:00
2569718930@qq.com 5aacf99cda 终端默认仅展示东亚合约:移除地区筛选按钮,硬编码 east_asia 过滤 2026-05-25 04:45:28 +08:00
2569718930@qq.com a5eed410d3 feat: add LiveTemperatureThresholdChart component and supporting scan terminal city row utility 2026-05-25 04:39:24 +08:00
2569718930@qq.com 317c57ccd1 巨鲸盯盘接入 Polymarket Data API /holders 端点,展示真实持仓者数据 2026-05-25 04:38:45 +08:00
2569718930@qq.com c2a69cb94e 终端新增巨鲸盯盘面板:按区域展示 Polymarket 成交量最大的城市和温度合约 2026-05-25 04:34:58 +08:00
2569718930@qq.com f394a44bdc 移除终端天气交易因子和区域天气收益率面板 2026-05-25 04:30:12 +08:00
2569718930@qq.com 75f180a568 用 is_primary_signal 替代城市名去重,移除气温走势图的多城市Tab栏
filteredRegionRows 改为保留 is_primary_signal 主信号合约。NormalizedPerformancePanel 移除顶部横向滚动城市切换Tab。
2026-05-25 04:28:45 +08:00
2569718930@qq.com 52ca7d3efa 移除天气市场新闻面板,市场列支持点击跳转 Polymarket
删除 WeatherNewsPanel 组件。GroupedMarketTable 市场价格列支持 ExternalLink 跳转到 Polymarket。城市数据去重,每城只保留排名最高的合约。
2026-05-25 04:25:22 +08:00
2569718930@qq.com baede6a485 终端侧边栏新增训练数据面板,展示各城市 DEB 命中率/MAE 准确率 2026-05-25 04:24:44 +08:00
2569718930@qq.com 92af51b87f feat: implement ScanTerminalDashboard component for weather market monitoring and visualization 2026-05-25 04:17:36 +08:00
2569718930@qq.com 78ff3c159b 移除已确认信号面板:左列仅保留天气合约表格+时区Tab过滤
删除 activeNavKey='signals' 分支、approveRows 计算、Approved Signals 面板和 TERM key。时区Tab切换直接过滤下方城市列表。
2026-05-25 03:59:45 +08:00
2569718930@qq.com 390df86a79 feat: implement ScanTerminalDashboard component for weather market intelligence 2026-05-25 03:53:46 +08:00
2569718930@qq.com 6de29ed2e1 清理调试日志:移除 BIAS_DEBUG/SCAN_DEBUG 临时日志,保留核心修正逻辑 2026-05-25 03:52:07 +08:00
2569718930@qq.com 446e303743 GroupedMarketTable 单选时区时隐藏分组标题行
groups.length === 1 时直接展示扁平表格,多时区(ALL)时保留可折叠分组。
2026-05-25 03:50:59 +08:00
2569718930@qq.com 43231d4474 新增城市实时数据源分级总览文档,梳理 51 城数据源与日内修正影响 2026-05-25 03:49:15 +08:00
2569718930@qq.com baafd4fd81 终端搜索扩至全字段匹配:city key、中英文 region、market question 等 11 个字段 2026-05-25 03:45:04 +08:00
2569718930@qq.com a764beedd6 移除终端顶栏语言切换和刷新按钮 2026-05-25 03:40:49 +08:00
2569718930@qq.com 074ad1c36c 撤除终端内联支付:订阅按钮统一跳转 /account
删除 useTerminalPay.ts、billing-utils.ts,SubscriptionGate 恢复为 <Link href=/account>,全站支付入口统一到账户页。
2026-05-25 03:38:15 +08:00
2569718930@qq.com e64d252081 修复 React 310 错误:refreshAuth useCallback 移至所有 early return 之前 2026-05-25 03:33:04 +08:00
2569718930@qq.com 5d6f906adb bias correction 改用已解析的 cur_temp/max_so_far 变量替代错误的 raw 读取 2026-05-25 03:29:04 +08:00
2569718930@qq.com 9e74883fe7 终端左侧新增可收缩导航面板:折叠/展开切换,中英文标签
Menu 按钮切换展开/收起,52px 折叠态图标栏 → 172px 展开态带标签面板。6 个导航入口:天气合约、交易信号、分析图表、自选监控、实时预警、市场概览。
2026-05-25 03:25:52 +08:00
2569718930@qq.com d7903f4387 bias correction 新增 BIAS_PRE 日志排查条件入口 2026-05-25 03:24:22 +08:00
2569718930@qq.com 89497bdf69 修复构建冲突:移除本地重复 Panel 定义,清理 .next 缓存,新增终端子组件模块 2026-05-25 03:23:10 +08:00
2569718930@qq.com 54b03321cb bias correction 新增 BIAS_DEBUG 日志排查修正是否生效 2026-05-25 03:22:41 +08:00
2569718930@qq.com c0df56e7b8 新增日内偏差修正:用实况与模型小时预报偏差动态修正 DEB 和概率分布
_analyze 在小时数据组装后,对比当前实测温度与模型预报偏差,
按日内时段权重修正 DEB 高温预测和概率分布 mu。
修正量上限 5°F/3°C,上午权重轻、峰值窗口加重、峰值后最强。
同时将 max-so-far 突破 DEB 预测作为辅助趋势修正信号。
2026-05-25 03:18:00 +08:00
2569718930@qq.com b8985dc52e 清理旧地图仪表盘 CSS 模块残留:DashboardHomeIntelligence、DashboardMap 2026-05-25 03:09:46 +08:00
2569718930@qq.com 53f17ac4f2 进一步拆分 useAccountPayment:提取 useWalletBind、usePaymentFlow、useBilling 三个子 hook 2026-05-25 03:05:39 +08:00
2569718930@qq.com 59dc74b5e1 移除 decisionClass 死函数,Koyfin → PolyWeather 品牌重命名 2026-05-25 03:01:17 +08:00
2569718930@qq.com f8133d6003 清理死代码:移除 14 个孤立组件/模块,Koyfin 品牌改为 PolyWeather
删除的文件:
- ContinentGroupHeader, AiPinnedForecastView, DataFreshnessBar, MarketDecisionLine
- opportunity-ai-meta/evidence-summary/v4-decision/v4-risk
- HeaderBar, IntradaySignalScene, MapCanvas, PolyWeatherDashboard
- ScanFilterPanel, WeatherAuraLayer

修改:
- decisionClass 死函数移除
- KoyfinWeatherTerminal → PolyWeatherTerminal
- Koyfin-style grid → Multi-panel grid
- 欢迎覆盖层 CSS 清理
2026-05-25 02:59:47 +08:00
2569718930@qq.com 8849ef8fe6 放宽扫描终端硬过滤默认值,适配 Polymarket 低流动性长尾桶
min_price 0.05→0.001,max_price 0.95→0.999,min_liquidity 500→50,
max_spread 0.03→0.2,edge 过滤从定向改为绝对值以捕获空头信号。
2026-05-25 02:58:41 +08:00
2569718930@qq.com 852010b8d4 _passes_hard_filters 新增过滤原因日志排查扫描行被全部筛掉问题 2026-05-25 02:54:20 +08:00
2569718930@qq.com d2a9e9a9b3 修复概率引擎与 Polymarket 桶不匹配导致 model_p 始终为 None 的问题
_distribution_probability_for_market 新增高斯 CDF fallback:
当离散概率分布桶与 Polymarket 市场桶无精确重叠时(如 NYC 分布 56-59F
但市场桶为 51-70F),用分布的 mu/sigma 构建正态分布计算 CDF 概率,
不再直接返回 None,保证 scan_rows 正常生成。
2026-05-25 02:51:58 +08:00
2569718930@qq.com 032d46ec1d Polymarket 市场扫描新增 SCAN_DEBUG 日志排查终端无数据问题
在 _build_distribution_scan_pack / _row_from_entry 关键路径
添加 logger.info 跟踪 related_markets 数量、批量报价结果、
行过滤原因和最终结果,便于定位 scan_rows 始终为空的原因。
2026-05-25 02:46:05 +08:00
2569718930@qq.com 4844af2273 feat: add institutional landing page with i18n support and subscription-gated access logic 2026-05-25 02:40:36 +08:00
2569718930@qq.com 7f8dca2066 拆分 AccountCenter:提取支付与钱包逻辑至 useAccountPayment hook,主组件从 2917 行减至 1280 行 2026-05-25 02:38:20 +08:00
2569718930@qq.com 22872f26e7 移除旧 AI Weather Decision Terminal 品牌名,统一为 PolyWeather Terminal
删除未使用的 ScanTerminalTopBar 和 ScanTerminalContentView 类型。
ScanTerminalLoadingScreen 顶栏文字改用 PolyWeather 品牌名。
2026-05-25 02:33:53 +08:00
2569718930@qq.com f7eeca1021 LoadingSignal 升级:换用新品牌 logo,明亮简洁蓝色旋转动画
logo 改用 apple-touch-icon,spinner 改为蓝色渐变环 + 微光晕
+ cubic-bezier 缓动曲线,整体更轻盈现代。
同时 ScanTerminalScreen 加载态接入 ScanTerminalLoadingScreen。
2026-05-25 02:29:04 +08:00
2569718930@qq.com ad936bfd91 Telegram 高频推送内存优化:LRU 缓存限制、TTL 驱逐、速率控制、连接复用
core.py: _cache 改用 LRUDict(maxsize=256) + threading.Lock 线程安全读写
analysis_service.py: _CACHE_LOCK 保护读写,_SUMMARY_CACHE 改用 LRUDict(maxsize=128)
weather_sources.py: 新增 _maybe_trim_caches() 定期清理 11 个过期缓存条目,防止无限增长
telegram_push.py: requests.Session 连接复用替代裸 requests.get,ThreadPoolExecutor 跨周期复用,
新增 _rate_limited_send 消息发送速率控制防止 API 限流堆积

Tested: ruff check 通过, 182 pytest 通过
2026-05-25 02:20:15 +08:00
2569718930@qq.com 3534f759d0 终端内直接弹出 UnlockProOverlay:订阅支付无需跳转账户页
新增 useTerminalPay hook 和 billing-utils 纯函数,SubscriptionGate 集成支付弹窗。
用户点击订阅后原地弹窗 → 连接钱包 → 链上支付 → 自动刷新进入终端。
2026-05-25 02:14:21 +08:00
2569718930@qq.com 5ee416bd0a 从最新 logo 重新生成所有尺寸 favicon 和 PWA 图标 2026-05-25 02:11:06 +08:00
2569718930@qq.com 29365f10a5 UnlockProOverlay 硬编码字符串翻译:off/减免、查看链上交易
discountSuffix "off" 改为 isEn 切换,链上交易链接跟随语言。
Standard Pro 为品牌名保留原文。

Tested: npm run build 通过
2026-05-25 02:04:00 +08:00
2569718930@qq.com d05d11148e 删除 ProFeaturePaywall 组件,模态框内改为简洁订阅提示 2026-05-25 02:02:05 +08:00
2569718930@qq.com b6725ea36d 全站 i18n 收尾:账户中心状态消息、权限提示页、终端因子面板
账户中心 account-copy.ts 新增 60+ 条状态/错误翻译 key,AccountCenter.tsx
将所有硬编码中英文支付提示改为 copy.xxx 引用。
entitlement-required 页拆分为 Client 组件接入 I18nProvider。
account/error.tsx 接入 useI18n 双语言支持。
终端 Global Weather Factors / Terminal Status 面板和大陆分组头
接入 t() 翻译,Heat/Active/Tradable/Primary/Closed 等标签跟随语言切换。

Tested: tsc --noEmit 通过, npm run build 通过
2026-05-25 01:59:31 +08:00
2569718930@qq.com ed8e4e0b67 终端右侧面板中文化:全球天气因子与终端状态标签接入 t() 翻译
Global Weather Factors 和 Terminal Status 面板中的英文硬编码替换为 i18n key。
2026-05-25 01:58:26 +08:00
2569718930@qq.com 7e61d1a7e5 移除试用系统和 Legacy Token Gate:删除 signup trial 授权、ensure_signup_trial、handleLegacyTokenGate、entitlement-required 页面及相关配置 2026-05-25 01:56:17 +08:00
2569718930@qq.com 9a49ff3f5e 新增终端大洲分组与移动端卡片 CSS 模块
包含分组标题行、移动端 Tab 隐藏滚动条、信号卡片样式及浅色主题适配。
2026-05-25 01:55:55 +08:00
2569718930@qq.com 0b47f4b487 终端表格重构:9列时区分组、移动端Tab+卡片流响应式布局 2026-05-25 01:55:24 +08:00
2569718930@qq.com 6f3fef4a3c 新增终端移动端城市卡片组件 2026-05-25 01:50:56 +08:00
2569718930@qq.com 3e0725f680 新增终端移动端大洲 Tab 栏组件 2026-05-25 01:50:41 +08:00
2569718930@qq.com 933cae74b4 新增终端大洲分组标题行组件 2026-05-25 01:50:30 +08:00
2569718930@qq.com ad14a007e7 continent-grouping 审查修复:localTimeRange 排序、key 类型收窄 2026-05-25 01:49:28 +08:00
2569718930@qq.com f0dec1e65e 终端全面中文化:表格、面板、统计、图表均接入语言切换
新增 TERM 翻译字典(28 个 key),通过 t() 函数统一取词。
MarketTable/SparkArea/ProbabilityDistributionChart 接收 isEn 参数。
所有 Panel 标题、表头、统计标签、详情卡片、搜索提示、门控组件
均跟随语言切换实时生效。

Tested: tsc --noEmit 通过, npm run build 通过
2026-05-25 01:42:02 +08:00
2569718930@qq.com c79dfda495 新增终端大洲分组逻辑模块 2026-05-25 01:41:43 +08:00
2569718930@qq.com 8fbbe54c46 新增终端大洲分组实施计划 — 9个任务,从continent-grouping逻辑到响应式UI 2026-05-25 01:39:34 +08:00
2569718930@qq.com 6672f02989 新增终端城市大洲分组设计文档
覆盖桌面端 9 列分区分组表格、移动端 Tab+卡片流布局方案。所有数据字段已就绪,无需后端改动。
2026-05-25 01:36:00 +08:00
2569718930@qq.com 637b580bbd @
新增终端城市大洲分组设计文档

覆盖桌面端 9 列分区分组表格、移动端 Tab+卡片流布局方案。
所有数据字段已就绪,无需后端改动。
@
2026-05-25 01:35:07 +08:00
2569718930@qq.com 7e9d7a4b59 落地页登录态感知:已登录用户隐藏登录/注册按钮
InstitutionalLandingPage 挂载时检测 Supabase session,
用户已登录则仅显示"进入产品"直达 /terminal,未登录则保留完整按钮组。

Tested: npm run build 通过
2026-05-25 01:33:30 +08:00
2569718930@qq.com bdb314d1a7 终端顶栏新增中英文语言切换按钮
KoyfinWeatherTerminal 顶栏右侧添加 EN/中文 切换组件,样式与落地页一致。
Refresh 按钮文字同步支持国际化。

Tested: npm run build 通过
2026-05-25 01:26:03 +08:00
2569718930@qq.com 0574b0cc20 双层鉴权架构:中间件终端门控 + 落地页按钮直连登录
LandingPage "进入产品"按钮改为 /auth/login?next=/terminal,消除未登录直达终端
再跳登录的视觉卡顿。Middleware 新增 handleTerminalGate(Layer 1),
在 /terminal 路由独立拦截未认证用户并 302 到登录页。
ScanTerminalDashboard 内 ProductAccessRequired 重构为 SubscriptionGate(Layer 2),
专注展示升级订阅提示。

Constraint: 双层顺序为认证优先 → 订阅检查在后
Tested: tsc --noEmit 通过, npm run build 通过
2026-05-25 01:21:44 +08:00
2569718930@qq.com af97173eed 登录页 logo 替换为图片,终端及落地页样式微调
LoginClient 双布局 logo 从 CloudSun 图标+文字改为 /logo.png 图片。
ScanTerminalState 样式精简,LoadingSignal 调整,落地页细节优化。

Tested: tsc --noEmit 通过, npm run build 通过
2026-05-25 01:10:26 +08:00
2569718930@qq.com 8b93f8ff54 仪表盘顶栏品牌图标替换为新 logo
HeaderBar 用 apple-touch-icon 替代 CloudSun 图标,新增 static 静态资源目录。
2026-05-25 01:09:10 +08:00
2569718930@qq.com 759ee7a8e4 登录页 mode 参数改为服务端直传,消除登录/注册表单切换闪烁
LoginPage 从 searchParams 提取 mode 作为 initialMode prop 传给 LoginClient,
替代客户端 useEffect 从 window.location.search 读取的方式,首次渲染即显示目标表单。

Tested: tsc --noEmit 通过, npm run build 通过
2026-05-25 01:03:38 +08:00
2569718930@qq.com 8957185062 登录页与落地页细节调整 2026-05-25 00:57:55 +08:00
2569718930@qq.com e36fb1e97b 清理机构落地页中未使用的 useState 状态
移除 billingCycle 状态及对应 import。
2026-05-25 00:54:02 +08:00
2569718930@qq.com c9cff88452 更新浏览器图标为新品牌 logo
基于 public/logo.png 重新生成所有尺寸的 favicon 和 PWA 图标。
Tested: npm run build 通过
2026-05-25 00:53:39 +08:00
2569718930@qq.com a4d8c1aa39 终端升级:MiniSparkline 替换为 Recharts 交互图表,新增概率分布柱状对比图 2026-05-25 00:45:50 +08:00
2569718930@qq.com e9cc9a5eb1 修复机构落地页构建失败并恢复 Polymarket 市场扫描
InstitutionalLandingPage.tsx 补充 "use client" 指令以通过 Next.js 15 构建。
polymarket_readonly.py 集成 WebSocket 报价缓存加速价格获取。
city_payloads.py 复用 Polymarket 层构建真实市场扫描数据替代空返回。

Constraint: 市场扫描需在无 Polymarket 价格 UI 的前提下提供数据
Tested: npm run build 通过
2026-05-25 00:41:56 +08:00
2569718930@qq.com b6d7b07708 完善机构终端页面布局与登录页细节调整 2026-05-25 00:31:11 +08:00
2569718930@qq.com fd2531341d 更新终端重构后的业务状态测试 2026-05-25 00:23:44 +08:00
2569718930@qq.com e4d123d94d 重构首页为机构落地页,新增终端路由,时区感知 Polymarket 市场发现 2026-05-25 00:16:53 +08:00
2569718930@qq.com 45e9c28437 feat: implement account management and subscription payment center components 2026-05-24 23:09:13 +08:00
2569718930@qq.com 79a652e8a9 版本号更新至 1.7.1 2026-05-24 23:09:13 +08:00
2569718930@qq.com 36c27ea243 @
CI 部署步骤改为 force pull:fetch + reset --hard 覆盖本地脏修改
@
2026-05-24 22:40:43 +08:00
2569718930@qq.com 32a3ba0ea4 feat: implement HeaderBar and ScanTerminal UI layout components for dashboard navigation and decision workspace 2026-05-24 22:38:43 +08:00
2569718930@qq.com 4832678d72 修复 Telegram 绑定弹窗拦截:拦截时展示可点击链接供手动跳转 2026-05-24 18:55:52 +08:00
2569718930@qq.com 20c8395c0b 全局配置更新:OAuth 回调修复、支付安全加固、站点 URL 工具
- 新增 NEXT_PUBLIC_SITE_URL 支持及 site-url.ts 工具模块
- 修复 OAuth 回调域名:import.meta.env 统一读取站点 URL
- 支付 API 路由新增收款地址校验
- 后端支付服务更新
- middleware 清理
- 新增 paymentSecurity 测试
2026-05-24 18:33:47 +08:00
2569718930@qq.com 2be0b71018 修复 Telegram 绑定按钮被浏览器 popup 拦截:先同步打开窗口再设置跳转 2026-05-24 18:12:07 +08:00
2569718930@qq.com 5db20061eb 移除 command_guard 中的群成员检查,更新对应测试 2026-05-24 17:59:32 +08:00
2569718930@qq.com 2415167132 修复 OAuth 回调域名:优先使用 NEXT_PUBLIC_SITE_URL 并添加根路径 code 兜底 2026-05-24 17:55:32 +08:00
2569718930@qq.com 5aa2a9e384 更换 Telegram Bot 为新账号 polyyuanbot,更新群链接和推送配置
- Bot token/用户名全局替换:WeatherQuant_bot → polyyuanbot
- 群 ID 更新为新群 polyweather售后群(-1003927451869)
- 群邀请链接更新为 https://t.me/+Io5H9oVHFmVjOTQ5
- 修复机场推送:Paris/Taipei/Denver/Tel Aviv 支持 airport_primary/current 回退
- 修复跑道数据展示:has_runway 直接检测数据而非依赖 source 字段
- 创建新群 30 城 Forum Topics(data/city_thread_ids.json)
- 配置 AMSC AWOS 数据源 URL

Constraint: 旧 Telegram 账号已注销,Bot 完全重建
Tested: VPS 部署验证,所有城市推送正常,Topic 路由生效
2026-05-24 16:03:29 +08:00
2569718930@qq.com 89394e12ef 拆分 AccountCenter 超大组件:提取类型、常量、钱包、支付等独立模块
- AccountCenter.tsx 从 3565 行缩减约 1000 行
- 新增 8 个模块:types / constants / formatters / account-copy / wallet / payment-utils / usePaymentState / AccountInfoRow
- 同步更新 paymentShell 测试

Confidence: high
2026-05-23 23:30:48 +08:00
2569718930@qq.com b2dd758977 扩展模型区间端点至首尔和釜山,并做前端小幅清理
- GET /api/cities/model-range 新增 seoul/busan,总计 9 城
- 移除未使用的 react-leaflet 依赖
- 提取 chart Tooltip contentStyle 为共享常量 CHART_TOOLTIP_STYLE,消除 6 个文件中 15 处重复内联样式

Tested: npx tsc --noEmit pass
Confidence: high
2026-05-23 22:59:50 +08:00
2569718930@qq.com 0012eddc81 修复电报推送偶发高温:目标机场站缺失时不再冒用 mgm_nearby[0]
Constraint: fetch_mgm_nearby_stations 并发抓取,17128/17058 偶发超时导致回退到市区站(热岛 +2-3°C)
Tested: ruff OK, pytest 184 passed
2026-05-23 22:04:09 +08:00
2569718930@qq.com e79bc2d291 清理废弃代码并同步全部文档:删除钱包异动监控模块,移除已废弃功能的文档引用
- 删除 polymarket_wallet_activity_watcher.py(914行)及 runtime_coordinator 调度
- 移除 LGBM / Polymarket 价格层 / Groq / prewarm 等 v1.7.0 已废弃功能的文档引用
- 清理 6 个死文档链接,更新文档索引和版本日期
- 移除 POLYMARKET_WALLET_ACTIVITY_* / PREWARM_* 等废弃环境变量

Directive: 文档与代码同步清理,无功能变更
Tested: pytest tests/test_bot_runtime_coordinator.py pass
Confidence: high
2026-05-23 21:42:46 +08:00
2569718930@qq.com 864b1fa1f9 修复前端长时间挂机内存累积:城市缓存 LRU 逐出 + AI 预测缓存上限
Constraint: cityDetailsByName/citySummariesByName/cityDetailMetaByName 只增不减,52 城全量可达 ~5MB+
Constraint: aiCityForecastStateCache 无上限,随城市×日期组合膨胀
Tested: tsc --noEmit OK, ruff OK, pytest 184 passed
2026-05-23 21:18:32 +08:00
2569718930@qq.com 3bad968844 修复地图瓦片长时间挂机后加载失败:Observer 去重 + tileerror 重试
Constraint: MutationObserver subtree=true 导致任意 class 变化触发全量瓦片重载,长期运行触发 CDN 限流
Tested: tsc --noEmit OK, ruff OK, pytest 184 passed
2026-05-23 21:02:54 +08:00
2569718930@qq.com 4c97314805 修复测试:high_freq_airport_push 测试适配全城市覆盖
Tested: pytest 184 passed
Directive: 测试断言从单城市精确匹配改为验证 force_refresh_observations_only 参数 + qingdao 在调用列表中
2026-05-23 20:44:47 +08:00
567 changed files with 62149 additions and 47143 deletions
+8 -1
View File
@@ -3,7 +3,14 @@
"allow": [
"Bash(npm --prefix \"/e/web/PolyWeather/frontend\" run build)",
"mcp__Claude_Preview__preview_start",
"Bash(git:*)"
"Bash(git:*)",
"Bash(python -c \"from web.services.city_payloads import build_city_detail_payload; print\\('OK'\\)\")",
"Bash(python -m ruff check web/services/city_runtime.py web/services/city_api.py)",
"Bash(python -m ruff check .)",
"Bash(python -m pytest tests/ -q)",
"Bash(python -m ruff check . --fix)",
"Bash(ssh *)",
"Bash(curl *)"
]
}
}
+34 -76
View File
@@ -9,7 +9,7 @@
########################################
ENV=production
LOG_LEVEL=INFO
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
POLYWEATHER_MAP_URL=https://polyweather.top/
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json
@@ -19,9 +19,16 @@ UVICORN_WORKERS=1
UID=1000
GID=1000
POLYWEATHER_STATE_STORAGE_MODE=sqlite
# Realtime chart event store. Production should use Redis Stream; local/single-process
# development can set POLYWEATHER_EVENT_STORE=sqlite.
POLYWEATHER_EVENT_STORE=redis
POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
POLYWEATHER_REDIS_STREAM_KEY=stream:city_observation
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
POLYWEATHER_REDIS_REQUIRED=true
# 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-pro.vercel.app
WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather.top,https://www.polyweather.top,https://api.polyweather.top
########################################
# 2) Telegram bot minimal
@@ -39,11 +46,21 @@ TELEGRAM_QUERY_TOPIC_CHAT_ID=
TELEGRAM_QUERY_TOPIC_ID=
TELEGRAM_QUERY_TOPIC_MAP=
POLYWEATHER_BOT_GROUP_INVITE_URL=
POLYWEATHER_APP_URL=https://polyweather-pro.vercel.app
# High-frequency airport push loop. Keep this at 1 on shared 1CPU VPS.
POLYWEATHER_APP_URL=https://polyweather.top
# Global Telegram auto-push copy: both, en, or zh. Module-specific vars can override it.
TELEGRAM_PUSH_LANGUAGE=both
# High-frequency airport push loop. Keep workers at 1 on shared VPS.
TELEGRAM_AIRPORT_PUSH_ENABLED=true
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC=60
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS=1
# Optional airport-only override.
TELEGRAM_AIRPORT_PUSH_LANGUAGE=both
# Docker-only safety limits for the bot service.
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_MAX_WORKERS=1
########################################
# 3) Weather + cache
@@ -94,7 +111,6 @@ SUPABASE_HTTP_TIMEOUT_SEC=8
SUPABASE_AUTH_CACHE_TTL_SEC=30
SUPABASE_SUB_CACHE_TTL_SEC=60
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
POLYWEATHER_SIGNUP_TRIAL_ENABLED=false
POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION=decline
########################################
@@ -129,28 +145,6 @@ KNMI_API_KEY=
########################################
# 8) Optional modules
########################################
# Optional OpenAI-compatible market scan review for Pro users
# Temporary default provider: MiMo via https://token-plan-cn.xiaomimimo.com/v1.
POLYWEATHER_SCAN_AI_ENABLED=false
POLYWEATHER_SCAN_AI_API_KEY=
POLYWEATHER_SCAN_AI_PROVIDER=mimo
POLYWEATHER_SCAN_AI_PROVIDER_LABEL=MiMo
POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
POLYWEATHER_SCAN_AI_MODEL=mimo-v2.5-pro
POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
# Backward-compatible legacy DeepSeek variables are still read if the generic
# POLYWEATHER_SCAN_AI_* variables are unset.
# POLYWEATHER_DEEPSEEK_API_KEY=
# POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com
POLYWEATHER_SCAN_AI_TIMEOUT_SEC=18
POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30
POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=false
POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=1800
POLYWEATHER_SCAN_AI_MAX_ROWS=40
POLYWEATHER_SCAN_AI_MAX_TOKENS=3200
POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900
POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS=55000
POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC=1800
POLYWEATHER_CITY_PANEL_CACHE_TTL_SEC=1800
POLYWEATHER_CITY_NEARBY_CACHE_TTL_SEC=1800
@@ -177,13 +171,24 @@ POLYWEATHER_BOT_DEB_QUERY_COST=1
# Payments
POLYWEATHER_PAYMENT_ENABLED=false
# Default / legacy checkout chain. Keep Polygon as the default because the
# deployed checkout contract currently lives there.
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com
# Optional multi-chain RPC map. Required when accepting non-default chain
# transfers such as Ethereum mainnet USDC.
# Example:
# POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com"],"1":["https://ethereum-rpc.example"]}
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
# Multi-token / multi-chain payment routes. Token rows can include:
# chain_id, chain_code, chain_name, receiver_contract, direct_receiver_address,
# supports_contract_checkout, supports_direct_transfer, confirmations,
# explorer_tx_url.
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
@@ -195,28 +200,9 @@ 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 market scan
POLYMARKET_MARKET_SCAN_ENABLED=true
POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com
POLYMARKET_CLOB_URL=https://clob.polymarket.com
POLYMARKET_CHAIN_ID=137
POLYMARKET_HTTP_TIMEOUT_SEC=20
POLYMARKET_MARKET_CACHE_TTL_SEC=60
POLYMARKET_PRICE_CACHE_TTL_SEC=30
# false = fetch CLOB book/depth for orderbook analysis; true = price-only, lighter but no book levels
POLYMARKET_FAST_PRICE_ONLY=false
POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC=30
POLYMARKET_WS_PRICE_ENABLED=false
POLYMARKET_WS_MARKET_URL=wss://ws-subscriptions-clob.polymarket.com/ws/market
POLYMARKET_WS_QUOTE_TTL_SEC=8
POLYMARKET_WS_MAX_ASSETS=256
POLYMARKET_WS_RECONNECT_DELAY_SEC=3
POLYMARKET_DISCOVERY_PAGES=6
POLYMARKET_DISCOVERY_LIMIT=200
POLYMARKET_SIGNAL_MIN_LIQUIDITY=500
POLYMARKET_SIGNAL_EDGE_PCT=2
# Polygon watcher
@@ -234,34 +220,6 @@ POLYGON_WALLET_WATCH_POLYMARKET_ONLY=true
POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true
POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
# Polymarket wallet activity (retired; replaced by market monitor digests + critical alerts)
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
POLYMARKET_WALLET_ACTIVITY_USERS=
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=
POLYMARKET_WALLET_ACTIVITY_CHAT_IDS=
POLYMARKET_WALLET_ACTIVITY_TOPIC_CHAT_ID=
POLYMARKET_WALLET_ACTIVITY_TOPIC_ID=
POLYMARKET_WALLET_ACTIVITY_USER_ALIASES=
POLYMARKET_WALLET_ACTIVITY_DATA_API_URL=https://data-api.polymarket.com
POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC=20
POLYMARKET_WALLET_ACTIVITY_TIMEOUT_SEC=10
POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_ABS=0.001
POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_DELTA=0.001
POLYMARKET_WALLET_ACTIVITY_MIN_AVG_PRICE_DELTA=0.002
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_ON_SIZE_DELTA=true
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_SIZE_DELTA_MIN=0.001
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_COOLDOWN_SEC=20
POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG=5
POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED=false
POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT=false
POLYMARKET_WALLET_ACTIVITY_LINK_PREVIEW=true
POLYMARKET_WALLET_ACTIVITY_UPDATE_DEBOUNCE_SEC=30
POLYMARKET_WALLET_ACTIVITY_UPDATE_MAX_HOLD_SEC=120
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN=0.01
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX=0.99
POLYMARKET_WALLET_ACTIVITY_MIN_POSITION_VALUE_USD=0
POLYMARKET_WALLET_ACTIVITY_MIN_VALUE_EXEMPT_USERS=
########################################
# 8) Optional proxies
########################################
+1
View File
@@ -34,6 +34,7 @@ METEOBLUE_API_KEY=
# Wallet / payments
########################################
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
+71 -13
View File
@@ -51,32 +51,90 @@ jobs:
- name: Business state tests
run: npm run test:business
- name: Build
run: npm run build
docker-build:
build-and-push:
needs: [python-quality, frontend-quality]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
strategy:
matrix:
image:
- name: backend
context: .
file: Dockerfile
tag: ghcr.io/yangyuan-zhen/polyweather-backend
- name: frontend
context: ./frontend
file: ./frontend/Dockerfile
tag: ghcr.io/yangyuan-zhen/polyweather-frontend
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t polyweather-ci .
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
env:
IMAGE_NAME: ${{ matrix.image.name }}
IMAGE_CONTEXT: ${{ matrix.image.context }}
IMAGE_FILE: ${{ matrix.image.file }}
IMAGE_TAG: ${{ matrix.image.tag }}
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
run: |
set -euo pipefail
tags=(-t "${IMAGE_TAG}:latest" -t "${IMAGE_TAG}:${GITHUB_SHA}")
build_args=()
if [ "${IMAGE_NAME}" = "frontend" ]; then
build_args=(
--build-arg "NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}"
--build-arg "NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}"
--build-arg "NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}"
--build-arg "NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL}"
--build-arg "NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false"
--build-arg "NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID}"
--build-arg "NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL}"
--build-arg "NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS}"
)
fi
docker build -f "${IMAGE_FILE}" "${tags[@]}" "${build_args[@]}" "${IMAGE_CONTEXT}"
docker push "${IMAGE_TAG}:latest"
docker push "${IMAGE_TAG}:${GITHUB_SHA}"
deploy:
needs: [python-quality, frontend-quality, docker-build]
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
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
cd /root/PolyWeather
git pull origin main
docker compose up -d --build
sleep 10
curl -s http://localhost:8000/healthz
scp -o StrictHostKeyChecking=accept-new deploy.sh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/deploy.sh
printf '%s\n' "$GHCR_PAT" | ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
bash /tmp/deploy.sh '${{ github.sha }}'
"
+1
View File
@@ -9,6 +9,7 @@ data/*.db
data/*.db-*
data/*.db.*
data/*.json
data/*backtest*.csv
data/logs/
data/historical/
data/cache/
+4 -1
View File
@@ -1,5 +1,8 @@
{
"css.validate": false,
"scss.validate": false,
"less.validate": false
"less.validate": false,
"python.analysis.extraPaths": [
"./"
]
}
+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 售卖产品。
+31
View File
@@ -1,5 +1,36 @@
# Changelog
## 1.8.1 - 2026-05-28
### 文档与发布
- README / README_ZH 改用 `frontend/public/static/web.png``frontend/public/static/tel.png` 作为产品截图,并移除旧 `docs/images` README 截图引用。
- 同步版本源到 `1.8.1`,刷新 API、Supabase、技术债、PolygonScan 验证等文档标题版本。
- 更新前端、实时事件、数据源、模型栈与服务文档,补齐 Redis Stream + SSE Patch、DEB hourly consensus、城市当地时间图表、legacy 高斯图表叠加、跑道/CoWIN 曲线和中英文 Telegram 推送口径。
### 当前线上口径确认
- 生产实时层为“HTTP snapshot + SSE patch + replayable event store”;前端只消费 `/api/events`,不直接连接 Redis。
- `deb_hourly_consensus.v1` 是峰值窗口与 DEB 曲线展示的优先小时路径;DEB 不作为实测来源。
- AMSC/AMOS 跑道曲线和香港 CoWIN 6087 参考站曲线按城市当地时间展示,结算跑道高亮,辅助跑道弱化。
## 1.8.0 - 2026-05-27
### 新增与重构
- **终端大洲区域过滤与分组**:终端重构支持按大洲/区域过滤与分组,添加移动端大洲 Tab 与卡片流响应式布局。
- **巨鲸盯盘面板**:对接 Polymarket Data API `/holders`,按区域展示 Polymarket 成交量最大的城市、温度合约及真实巨鲸持仓数据。
- **气温走势图升级**:使用 Recharts 交互式图表,支持双向概率分布对比柱状图,并在图表底部渲染 Polymarket 市场点击直达链接。
- **日内偏差动态修正**:引入实时偏差修正算法,用实况观测与多模型小时预报的偏差来动态修正 DEB 预报中枢以及 Mu 概率分布,极大提高了预报和校准的精度。
- **多数据源气温监控图表**:引入 `LiveTemperatureThresholdChart` 组件,展示实时跑道观测、DEB 预报中枢、多模型区间及目标阈值。
- **全站中文化与多语言 (i18n)**:全站支持中英文一键切换,硬编码字符串彻底清理并接入翻译词条。
- **机构落地页与鉴权优化**:首页重构为专业的机构落地页,添加了基于中间件的双层终端门控(/terminal 路由和 landing page 登录态感知)。
- **超大组件拆分与解耦**`AccountCenter` 组件彻底重构拆分为多个细粒度 Hook(`useWalletBind``usePaymentFlow``useBilling`),主组件代码缩减 60%,提升可维护性。
- **Telegram 高频推送与内存优化**:机场观测推送重构,限制 LRU 缓存避免内存膨胀,并针对 Bot 动作和 API 接入进行连接复用与速率限制。
### 修复与优化
- **类型异常修复**:修复在 `_in_peak_time_window` 决策卡时间窗口计算中 `last_h``None` 导致 `NoneType` 异常报错的问题。
- **清理冗余类型转换**:移除 `src/utils/telegram_push.py` 中 8 处冗余的 `str()` 显式包装,精简 Python 代码。
## 1.7.0 - 2026-05-23
### 新增能力
+109 -136
View File
@@ -4,168 +4,141 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
PolyWeather Pro — a production weather-intelligence stack for temperature settlement markets. Aggregates observations and forecasts for 52 monitored cities globally, blends multi-model highs using DEB (Dynamic Error Balancing), generates calibrated probability buckets for settlement, maps weather to Polymarket quotes for mispricing scans, and serves both a Next.js dashboard (Vercel) and a 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 (Vercel) frontend, FastAPI backend (VPS), Telegram bot.
## Environment & Preferences (ALWAYS follow)
**Business model**: Paid-only, $10/month, no free tier, no trial. Landing page is public; `/terminal` requires login + active subscription.
### Working Directory
- All commands run from the repo root
- Python virtual env: `venv\Scripts\activate` (Windows) / `source venv/bin/activate` (Linux/macOS)
- Frontend dev server: `cd frontend && npm run dev` → http://localhost:3000
- Backend API server: `uvicorn web.app:app --reload --host 0.0.0.0 --port 8000` → http://localhost:8000
- When I say "start the server", assume the working directory is the repo root
## Environment & Preferences
### Git Conventions
- **Commit language: Chinese (简体中文) ONLY**
- Format: Lore Commit Protocol — intent line in Chinese, trailers in English
- Examples: `重构城市决策卡 hero 布局` or `统一 DEB 数据源为单一计算路径`
- **NEVER** use English for commit subject lines
### Tooling
- Package manager: **npm** (not yarn/pnpm)
- Working directory: repo root
- Python: `python` (not python3), venv at `venv/`
- Lint: `ruff check .` (Python) + `npx tsc --noEmit` (TypeScript)
- NEVER ask me about these preferences again — commit to memory
- Frontend: `cd frontend && npm run dev` → localhost:3000
- Backend: `uvicorn web.app:app --reload --host 0.0.0.0 --port 8000`
- Package manager: **npm** (not yarn/pnpm)
- **Commit language: Chinese (简体中文) ONLY**
- **NEVER start commit messages with `@`** — Chinese directly, no prefix
## Commands
```bash
# Frontend
cd frontend
npm run dev # dev server :3000
npm run build # production build
npm run typecheck # tsc --noEmit
npm run test:business # 19 business state tests
# Backend
uvicorn web.app:app --reload --host 0.0.0.0 --port 8000
python bot_listener.py # Telegram bot
# Python tests
python -m pytest tests/
python -m pytest tests/test_supabase_entitlement.py
# Lint
ruff check .
ruff format .
# Docker (VPS)
docker compose down && docker compose up -d --build
```
## Architecture
```
Users (Web / Telegram) → Next.js Frontend (Vercel) → FastAPI /web/app.py
Weather Collector (METAR, TAF, Open-Meteo, country networks)
Analysis (DEB + Trend + Probability + Market Scan)
Payment Layer (Intent + Event + Confirm Loop)
Users → Next.js (Vercel) → FastAPI :8000 (VPS)
/terminal (paid gate) Weather Collector
/ (landing page) Analysis (DEB + Mu)
Payment Layer (USDC on Polygon)
Telegram Bot → bot_listener.py
```
- **Backend**: FastAPI on port 8000 (`web/app.py``web/app_factory.py``web/routers/` (8 route modules: `system`, `city`, `auth`, `analytics`, `scan`, `payments`, `ops`, `routes` (legacy)) + `web/services/` (14 service modules) + `web/core.py`)
- **Frontend**: Next.js 15 + React 19 + TypeScript + Tailwind CSS 3 + shadcn/ui (new-york style) on port 3000 (dev)
- **Bot**: Telegram bot via `bot_listener.py``src/bot/`
- **Shared analysis core** in `src/` is used by both web API and bot
- **Scan Terminal**: Real-time city opportunity scanning (`web/scan_terminal_service.py` and `frontend/components/dashboard/scan-terminal/`)
- **Dashboard**: Main dashboard with interactive map, city sidebar, detail panels, and probability views
- **Market Monitor** (`MonitorPanel`): Real-time temperature monitoring board for 22 trading cities. Uses a temperature resolution chain (AMOS runway → AMOS → `airport_primary``airport_current``current`) defined in `frontend/components/dashboard/monitoring/monitor-temperature.ts`. Per-city refresh decisions driven by source-aware freshness (`source-freshness.ts`) instead of uniform `obs_age_min`. Seoul/Busan display runway surface temperature from AMOS; US cities get 5-min MADIS HFMETAR via `airport_primary`; others fall back to METAR.
- **High-Freq Airport Pipeline**: 19 of 22 monitor cities have dedicated realtime sources (AMOS, MADIS, JMA, MGM, FMI, KNMI, AROME). Data flows: `weather_sources.py` (fetch) → `country_networks.py` (`_airport_primary_from_raw`, per-country providers) → API `airport_primary` field. Plain METAR stays in `airport_current`. Documented in `docs/AIRPORT_REALTIME_SOURCES.md`.
- **Country Network Providers**: `country_networks.py` routes per-city to the right provider (Turkey→MGM, Korea→KMA, Japan→JMA, etc.) via `get_country_network_provider()`. Each provider controls `airport_primary_current`, `official_nearby_current`, and `official_network_status`. US cities use the default `GlobalMetarNetworkProvider` but get MADIS overrides injected via `results["madis_hfmetar_current"]`.
### Frontend Structure
## Commands
| Path | Purpose |
|------|---------|
| `app/page.tsx` | Landing page (`InstitutionalLandingPage`) |
| `app/terminal/page.tsx` | Paid terminal (`ScanTerminalDashboard`) |
| `app/account/` | Account center with payment/subscription |
| `app/auth/` | Supabase login/signup |
| `components/dashboard/scan-terminal/` | Terminal sub-components |
| `components/account/` | Account + payment hooks |
| `components/landing/` | Institutional landing page |
| `components/subscription/` | `UnlockProOverlay` payment overlay |
| `lib/dashboard-types.ts` | All TypeScript types |
### Frontend (dev on port 3000)
```bash
cd frontend
npm ci
npm run dev # Next.js dev server (runs sync-next-server-chunks.mjs first)
npm run build # Production build (runs sync-next-server-chunks.mjs after)
npm run start # Production server
npm run lint # ESLint via next lint
npm run typecheck # tsc --noEmit
npm run test:business # Business state tests via scripts/run-business-state-tests.mjs (also runs in CI)
```
### Terminal Component Map
### Backend (dev on port 8000)
```bash
uvicorn web.app:app --reload --host 0.0.0.0 --port 8000
```
- `ScanTerminalDashboard.tsx` — entry, auth gate, `ProductAccessRequired`
- `PolyWeatherTerminal` — main layout: sidebar + region tabs + 2-column grid
- `CityRegionList` — city list panel (left top)
- `CityContractDetail` — contract table panel (left bottom)
- `LiveTemperatureThresholdChart` — multi-source overlay: obs + DEB + model curves + thresholds
- `RealtimeScrollChart` — lightweight realtime scrolling temperature + threshold bars
- `TrainingDashboard` — DEB + Mu accuracy charts (sidebar "训练数据" tab)
- `continent-grouping.ts` — 7 trading regions (`TRADING_REGIONS`), city-to-region fallback (`CITY_REGION_FALLBACK`), timezone detection (`detectLocalRegion`)
### Telegram Bot
```bash
python bot_listener.py
# or via wrapper:
python run.py
```
### Account Module
### Docker (production-like stack)
```bash
docker compose up -d --build # bot + web API (polyweather + polyweather_web)
```
The compose file defines two services: `polyweather` (bot) and `polyweather_web` (FastAPI on :8000). Prewarm worker and monitoring profiles were removed in v1.6.0.
- `AccountCenter.tsx` (~1280 lines) — main component
- `useAccountPayment.ts` — master payment hook, composes sub-hooks
- `useWalletBind.ts` — EVM/WalletConnect binding
- `usePaymentFlow.ts` — intent creation, payment, confirmation
- `useBilling.ts` — subscription recovery, billing computation
### Python tests
```bash
python -m pytest tests/ # all tests
python -m pytest tests/test_web_observability.py # single test file
```
### Backend Key Files
### Version bump (see RELEASE.md)
```bash
python scripts/bump_version.py patch # or minor / major / 1.5.0
python scripts/sync_version.py # verify sync across files
```
`VERSION` file is the single source of truth; frontend `package.json` and docs sync from it.
| Path | Purpose |
|------|---------|
| `web/routers/city.py` | City detail/summary/realtime-stream endpoints |
| `web/routers/scan.py` | Scan terminal aggregation |
| `web/services/city_payloads.py` | City detail and summary payload builders |
| `web/scan_terminal_city_row.py` | Builds terminal rows from analysis data |
| `src/data_collection/city_registry.py` | 50-city registry with tz_offset |
| `src/analysis/deb_algorithm.py` | DEB prediction + Mu calibration + accuracy |
| `web/services/analysis_utils.py` | Clock helpers, bucket labeling, time parsing |
| `web/services/observation_freshness.py` | Source profiles and freshness computation |
| `web/services/scan_ai_config.py` | Scan terminal and AI configuration constants |
### Lint & Format
```bash
ruff check . # Python lint (pycodestyle + Pyflakes, line-length 88)
ruff format . # Python format (Black-compatible, double quotes)
```
## Auth Gating
### Health & Ops checks
```bash
curl http://127.0.0.1:8000/healthz
curl http://127.0.0.1:8000/api/system/status
curl http://127.0.0.1:8000/metrics
```
Middleware (`middleware.ts`) handles two layers:
1. **Terminal gate** (`handleTerminalGate`): `/terminal/*` → redirect to `/auth/login` if no Supabase session
2. **Global auth** (`handleSupabaseAuthGate`): enforced when `POLYWEATHER_AUTH_REQUIRED=true`
## Key Directories
Client-side gate (`ProductAccessRequired`): `/terminal` checks auth + subscription via `/api/auth/me`, shows paywall if needed.
| Directory | Purpose |
|-----------|---------|
| `src/analysis/` | DEB algorithm, trend engine, market alert engine, settlement rounding |
| `src/auth/` | Supabase entitlement checks, Telegram group pricing |
| `src/bot/` | Telegram bot handlers and orchestrator |
| `src/database/` | SQLite-based runtime state, DB manager, daily/truth/training feature repositories |
| `src/data_collection/` | Weather sources (METAR, TAF, Open-Meteo, JMA, KMA, MGM, NMC, Russia stations, settlement sources), city registry (52 cities), Polymarket readonly layer. Also: `madis_sources.py` (NOAA 5-min NetCDF), `amos_station_sources.py` (Korean runway sensors), `country_networks.py` (per-country provider routing + `_airport_primary_from_raw`) |
| `src/data_mining/` | Historical data fetch utilities |
| `src/onchain/` | Polygon wallet watcher, Polymarket wallet activity watcher |
| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit |
| `src/strategy/` | Trading strategy modules |
| `src/trading/` | Trading execution modules |
| `src/utils/` | Shared utilities: config loader, logging, metrics, Telegram push, chat ID helpers |
| `web/` | FastAPI app (`app.py``app_factory.py`), `routers/` (8 route modules), `services/` (14 service modules), `core.py`, scan terminal modules (AI fallback, AI prompts, METAR gate, city rows, ranker, cache) |
| `frontend/app/` | Next.js App Router pages (dashboard, account, auth, docs, ops, probabilities, scan) |
| `frontend/components/dashboard/` | Dashboard UI components (map, sidebar, detail panel, modals, charts, scan terminal). `scan-root-styles.ts` is the CSS Module barrel, combining 22 module roots into one pre-composed className. `monitoring/` subdirectory: `MonitorPanel`, `monitor-temperature.ts` (temp resolution chain), `monitor-refresh-policy.ts`. |
| `frontend/lib/` | Shared client logic: types (`dashboard-types.ts`, including `AirportCurrentConditions`, `CityDetail`), API client, chart utils, i18n, `source-freshness.ts` (per-source freshness with `expected_next_update_at`), dashboard utils |
| `frontend/hooks/` | React hooks: dashboard store (global state), Leaflet map, chart helper |
| `scripts/` | Operational scripts: backfills, payment reconciliation. `supabase/` subdirectory: DB schema and migration SQL. |
| `config/` | YAML config (city list, weather settings, logging) |
| `docs/` | Bilingual product & technical docs |
Local dev bypass: set `NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false` to test auth locally.
## Key Technical Details
## Polymarket Integration
- **Python version**: 3.11 (target), type hints use `from __future__ import annotations` in most modules
- **Package manager**: pip (requirements.txt) + uv cache is present but not the primary tool; no pyproject.toml build system defined
- **Frontend package manager**: npm
- **State storage**: SQLite primary path (set via `POLYWEATHER_STATE_STORAGE_MODE=sqlite` + `POLYWEATHER_DB_PATH`). Legacy JSON/JSONL files are migration/fallback only.
- **Runtime data**: External dir recommended (`POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather`) to avoid git conflicts
- **Configuration**: `.env.example` is the comprehensive reference (8 config sections: runtime, Telegram, weather cache, auth, ops, frontend, optional modules, Polygon monitor). Copy to `.env` and fill in secrets.
- **Auth gating** (frontend middleware): Three-tier priority in `middleware.ts` — (1) local dev hosts (localhost / 127.0.0.1 / ::1) bypass auth entirely, (2) Supabase session-based when `POLYWEATHER_AUTH_ENABLED=true` via `handleSupabaseAuthGate` or `handleSupabaseOptionalSession`, (3) legacy token fallback via `POLYWEATHER_DASHBOARD_ACCESS_TOKEN` cookie/query-param. Public pages (`/`, `/docs`, `/auth/*`, `/entitlement-required`) and public API routes are always accessible.
- **CORS**: Allowed origins from `WEB_CORS_ORIGINS` env var (defaults: localhost:3000, polyweather-pro.vercel.app)
- **API proxy**: Frontend uses Next.js rewrites to proxy `/api/*` to the FastAPI backend; see `frontend/lib/api-proxy.ts` and `frontend/lib/backend-api.ts`
**Removed.** No Polymarket price fetching, no market scan, no WS cache. Terminal operates on weather data only (Live observations + DEB predictions + model probabilities). All `polymarket_readonly.py`, `polymarket_ws_cache.py`, and market-scan API routes have been deleted.
## Commit Convention
## Trading Regions
This repo uses the **Lore Commit Protocol** — structured decision records with git trailers (`Constraint:`, `Rejected:`, `Confidence:`, `Scope-risk:`, `Directive:`, `Tested:`, `Not-tested:`). Intent line first (why, not what).
7 regions: east_asia, southeast_asia, central_asia, west_asia, europe_africa, south_america, north_america. Mappings in `continent-grouping.ts` (`CITY_REGION_FALLBACK` — all 50 cities hardcoded) and `scan_terminal_filters.py` (`market_region_from_tz_offset`). Default region auto-detected from browser timezone.
- Always write git commit messages in **Chinese (简体中文)**.
## Scan Terminal Performance
- **Region lazy-loading**: `region=east_asia` filters cities server-side before scanning (see `_market_region_from_tz_offset`)
- **Weather-only**: Terminal returns 1 row per city with Live/DEB/probability data; no market contract matching
- **DB**: SQLite WAL mode + `busy_timeout=5000` enabled in `db_manager.py` (fixes "database is locked" with parallel workers)
- **VPS env**: `POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=2`, `POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=180`
- **Caching**: `_cache` is `LRUDict(256)` with `_CACHE_LOCK`; `_SUMMARY_CACHE` is `LRUDict(128)`; weather caches trimmed every 200 writes
## Intraday Bias Correction
`analysis_service.py:_analyze()` applies intraday correction after probability generation:
- Compares current observed temp vs model hourly forecast for current hour
- Time-of-day weight: 0.15↗0.35 pre-peak, 0.40↗0.75 during peak, 0.80 post-peak
- Also checks if max-so-far already exceeds DEB prediction (strong upward nudge)
- Correction capped at ±5°F / ±3°C, applied to both `deb_val` and `mu`
## Code Style
- Never use Unicode escape sequences (`\uXXXX`) in source code; write characters directly in UTF-8 encoding.
- When modifying UI components, update both **dark-mode and light-mode CSS files** in the same edit batch.
- **CSS Variables First**: Prefer `var(--color-*)` / `var(--color-signal-*)` tokens over hardcoded hex values. The token system is defined in `globals.css` with light-theme overrides under `html.light`.
- **Avoid `!important`**: Only use it for Leaflet map overrides (inline style conflict) and chart canvas sizing. For light-theme overrides, use `html.light .root` prefix for higher specificity.
- **Monitoring CSS note**: `MonitorPanel.module.css` scopes its light-theme overrides to `.scan-terminal.light` (the terminal's built-in toggle), NOT `html.light`. When adding light styles for monitoring components, match this scoping.
- **New CSS Modules**: Add the module root class to `scan-root-styles.ts` barrel file instead of importing it separately in `ScanTerminalDashboard.tsx`.
## Quality Gates (MANDATORY)
Before marking any task as complete, you MUST:
1. **Type check** — Run `npx tsc --noEmit` (frontend) or `python -m ruff check .` (backend) on modified files
2. **No Unicode escapes** — Verify that NO `\uXXXX` sequences were introduced; if found, revert and fix
3. **Dual-theme CSS** — For any UI change, confirm BOTH dark and light styles. Most components need `ScanTerminalLightTheme.module.css` updated; monitoring components (`MonitorPanel.module.css`) contain their own `.scan-terminal.light` blocks inline.
4. **No new hardcoded palette colors** — Use `var(--color-*)` token references instead of `#4DA3FF` / `#E6EDF3` / `#9FB2C7` / `#6B7A90` hex values
5. **Show the diff** — Output `git diff --stat` and test results before declaring success
If any gate fails, fix it BEFORE reporting success.
- No `\uXXXX` escapes — write characters directly in UTF-8
- Use `var(--color-*)` CSS tokens, not hardcoded hex
- Minimum font size: 10px (`text-[10px]`)
- Avoid `!important` except Leaflet map overrides
- Remove dead code immediately when features are removed
+56 -57
View File
@@ -2,55 +2,59 @@
Production weather-intelligence stack for temperature settlement markets.
Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
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
### Global Dashboard
### Realtime Terminal
![PolyWeather global dashboard](docs/images/demo_map.png)
![PolyWeather realtime terminal](frontend/public/static/web.webp)
### City Analysis (Ankara)
### Telegram Runway Alerts
![PolyWeather Ankara analysis](docs/images/demo_ankara.png)
![PolyWeather Telegram runway alerts](frontend/public/static/tel.png)
## Star History
[![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-11)
## Product Status (2026-06-07)
- Subscription live: `Pro Monthly 10 USDC`.
- Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards.
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, max `3 USDC`).
- Weekly leaderboard rewards restructured: smaller point bonuses for winners (200/100/50), all active users receive participation rewards.
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e).
- Subscription live: `Pro Monthly 29.9 USDC / 30 days` and `Pro Quarterly 79.9 USDC / 90 days`.
- Referral pricing live: invited users can get the first monthly Pro at `20 USDC`; inviters receive `3500` points after a valid first Pro payment, capped at 10 paid invites per month.
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, monthly max `3 USDC`, quarterly max `8 USDC`). Useful user feedback can also receive manual point rewards through ops.
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e) plus Ethereum mainnet USDC direct-transfer confirmation.
- Auto-reconciliation live: event listener + periodic confirm loop.
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
- Ops dashboard live: `/ops` for memberships, leaderboard, user feedback triage, manual point grants, and payment incident triage.
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
- Realtime terminal live: visible city charts subscribe through `/api/events?cities=...&since_revision=...`, receive `city_observation_patch.v1` SSE patches, and replay short gaps from Redis Stream in production or SQLite fallback in local/single-node mode.
- Chart refresh is observation-driven: live patches merge into the current chart without a loading overlay; only visible charts run a 60s no-patch fallback, and returning from a background browser tab triggers a foreground catch-up refresh.
- Temperature charts default to All Day, keep an optional Peak window derived from the DEB hourly path, and render all timestamps in the selected city's local time.
- The chart core has been split into focused logic/canvas/state modules; Recharts now receives explicit measured dimensions to avoid 0x0 rendering and disappearing curves.
- DEB hourly consensus (`deb_hourly_consensus.v1`) is now the preferred hourly forecast path for peak-window detection and chart overlays; DEB remains a forecast curve, never an observation source.
- Legacy Gaussian probability 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.
- City decision cards now include the AI airport read: METAR, DEB, model cluster, and the AI expected-high center are resolved before mapping the result to Polymarket temperature buckets.
- AI airport reads now use in-page memory cache, browser `localStorage`, and backend short-TTL cache; returning from another dashboard tab restores existing stream text or final results before any new request is needed.
- 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; EMOS/LGBM are surfaced only when evaluated or shadowed, while model consensus and market prices remain secondary references.
- The market-signal difference means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
- Calibrated model probability is now the primary probability panel. It shows the active production probability engine (legacy Gaussian or EMOS), while model consensus remains a secondary reference.
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
- Trade cue now combines upper-air structure, `TAF`, market crowding, and `edge_percent`.
- Browser extension now uses `DEB` for multi-day forecast and stays positioned as a lightweight lead-in to the main site.
- Official nearby-network layer now covers `MGM` (Turkey), `CMA/NMC` (Mainland China), `JMA AMeDAS` (Japan), `AMOS` (Korea, runway-level, Seoul/Busan), `HKO` (Hong Kong), and `CWA` (Taiwan).
- Tokyo now ingests Haneda `JMA AMeDAS` 10-minute temperature as the official enhancement layer.
- Dashboard prewarm is now supported through a dedicated worker / cron path, with runtime status exposed in `/api/system/status` and `/ops`.
- `/ops` now exposes cache bucket counts, summary cache hit / miss rate, and prewarm runtime heartbeat.
- Intraday commentary can optionally use `Groq` as a bilingual rewrite layer, while rule-based commentary remains the fallback.
- Vercel frontend guidance now includes cost controls for analytics, eager fetches, and edge-side scanner blocking.
- Frontend design system overhauled: unified CSS token system, eliminated `!important` abuse (68→6 in light theme), consolidated breakpoints (18→10), migrated hardcoded colors to CSS variables, added ARIA attributes and focus-visible keyboard navigation. See `docs/frontend-ui-design-review.md` for the full audit trail.
- Frontend design system overhauled: unified CSS token system, eliminated `!important` abuse (134→49 in light theme), consolidated breakpoints (18→10), migrated hardcoded colors to CSS variables, added ARIA attributes and focus-visible keyboard navigation. See `docs/frontend-ui-design-review.md` for the full audit trail.
## License & Commercial Boundary
@@ -66,15 +70,16 @@ 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.
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution), with `LGBM` metadata surfaced when the calibrated engine is active.
- Maps weather view to Polymarket quotes for mispricing scan.
- Adds city decision cards that combine AI airport reads, expected-high centers, full market-bucket mapping, and model-market difference in one view.
- 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 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.
- Adds optional dashboard prewarm worker so hot cities can be refreshed before user clicks.
## Reference Architecture
@@ -94,11 +99,12 @@ flowchart LR
WX --> AMOS["AMOS runway sensors (Korea)"]
WX --> HKO["HKO / CWA / NOAA / Official settlement sources"]
API --> ANA["DEB + Trend + Probability + Market Scan"]
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
ANA --> PM["Polymarket Read-only Layer"]
ANA --> LLM["Optional Groq Commentary Rewrite"]
API --> PREWARM["Dashboard Prewarm API / Worker"]
API --> ANA["DEB + Hourly Consensus + Probability + Market Scan"]
API --> SSE["SSE /api/events"]
WX --> SSE
SSE --> EVENT["Redis Stream / SQLite Event Log"]
ANA --> PAY["Payment State (Multi-chain Intent + Event + Confirm Loop)"]
ANA --> STATE["SQLite runtime state"]
```
## Monitored Cities (51)
@@ -126,13 +132,17 @@ 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.
- `LGBM` can power the calibrated probability panel; model vote counts remain an explanatory consensus line, not the final probability.
- Calibrated probability uses legacy Gaussian (default) or EMOS/CRPS when evaluated; 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.
## Runtime Data (Recommended on VPS)
@@ -142,8 +152,14 @@ Use external runtime storage to avoid SQLite/git conflicts:
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
POLYWEATHER_STATE_STORAGE_MODE=sqlite
POLYWEATHER_EVENT_STORE=redis
POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
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:
@@ -167,24 +183,10 @@ curl http://127.0.0.1:8000/api/system/status
curl http://127.0.0.1:8000/metrics
```
### Dashboard prewarm worker
```bash
docker compose --profile workers up -d polyweather_prewarm
curl http://127.0.0.1:8000/api/system/status
```
Check:
- `prewarm.thread_alive`
- `prewarm.runtime.cycle_count`
- `cache.analysis.hit_rate`
- `cache.open_meteo_forecast_entries`
### Frontend cache headers
```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
./scripts/validate_frontend_cache.sh "https://polyweather.top"
```
### Payment auto-reconciliation logs
@@ -199,11 +201,9 @@ docker compose logs -f polyweather | egrep "payment event loop started|payment c
curl http://127.0.0.1:8000/api/payments/runtime
```
### Wallet activity logs
### Payment chains
```bash
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
```
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
@@ -222,26 +222,25 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
- Chinese API guide: [docs/API_ZH.md](docs/API_ZH.md)
- TAF signal guide (ZH): [docs/TAF_SIGNAL_ZH.md](docs/TAF_SIGNAL_ZH.md)
- Model stack & DEB (ZH): [docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
- EMOS + LGBM system (ZH): [docs/EMOS_LGBM_SYSTEM_ZH.md](docs/EMOS_LGBM_SYSTEM_ZH.md)
- Commercialization: [docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
- AGPL-3.0 policy: [docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
- Supabase setup (ZH): [docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
- Configuration & secrets (ZH): [docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
- LightGBM daily-high model (ZH): [docs/LGBM_DAILY_HIGH_ZH.md](docs/LGBM_DAILY_HIGH_ZH.md)
- Frontend deployment (ZH): [docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
- Tech debt (EN): [docs/TECH_DEBT.md](docs/TECH_DEBT.md)
- Tech debt (ZH): [docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
- Airport realtime sources: [docs/AIRPORT_REALTIME_SOURCES.md](docs/AIRPORT_REALTIME_SOURCES.md)
- Airport market monitor (ZH): [docs/AIRPORT_MARKET_MONITOR_ZH.md](docs/AIRPORT_MARKET_MONITOR_ZH.md)
- Services overview (ZH): [docs/SERVICES_ZH.md](docs/SERVICES_ZH.md)
- Payment verification: [docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
- Payment audit: [docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md)
- Payment V2 upgrade: [docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
- Ops admin guide: [docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
- Monitoring guide (ZH): [docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
- Deep research report: [docs/deep-research-report.md](docs/deep-research-report.md)
- Frontend report: [FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
- Release process: [RELEASE.md](RELEASE.md)
- Changelog: [CHANGELOG.md](CHANGELOG.md)
## Version
- Version: `v1.7.0`
- Last Updated: `2026-05-11`
- Version: `v1.8.1`
- Last Updated: `2026-06-07`
+57 -61
View File
@@ -2,30 +2,39 @@
面向温度结算市场的生产级气象情报系统。
官方看板:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
官方看板:[polyweather.top](https://polyweather.top/)
## 产品截图
### 全球看板
### 实时终端
![PolyWeather 全球地图看板](docs/images/demo_map.png)
![PolyWeather 实时终端](frontend/public/static/web.webp)
### 城市分析(Ankara
### Telegram 跑道推送
![PolyWeather Ankara 分析页](docs/images/demo_ankara.png)
![PolyWeather Telegram 跑道推送](frontend/public/static/tel.png)
## 当前产品状态(2026-05-11
## 当前产品状态(2026-06-07
- 已上线订阅制:`Pro 月付 10 USDC`
- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖
- `/city``/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,最多抵 `3 USDC`
- 周榜奖励已改造:降低赢家积分加成 (200/100/50),所有周活跃用户均享参与奖
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。
- 已上线订阅制:`Pro 月付 29.9 USDC / 30 天``Pro 季度 79.9 USDC / 90 天`
- 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分
- `/city``/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。真实、有上下文、有价值的用户反馈也可通过运营后台人工奖励积分
- 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e+ Ethereum 主网 USDC 直转确认
- 已上线自动补单:事件监听 + 周期确认双链路。
- 已上线支付运行态与审计接口:`/api/payments/runtime`
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。
- 已上线轻量运营后台:`/ops`(会员、用户反馈处理、积分、补分、支付异常单)。
- 已上线轻量可观测性:`/healthz``/api/system/status``/metrics`
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。
- 实时终端已切换到可重放事件流:可见城市图表通过 `/api/events?cities=...&since_revision=...` 订阅 `city_observation_patch.v1`,生产环境使用 Redis Stream 做短窗口 replay,本地/单进程可回退 SQLite event log。
- 图表刷新由实测事件驱动:SSE patch 直接合并到当前曲线,不弹 loading 遮罩;只有可见图表启用 60 秒无 patch 兜底,浏览器后台返回前台时会主动补齐最新 detail。
- 城市图表默认展示“全天”,可选“高温”窗口由 DEB hourly path 推导;所有图表横轴都按城市当地时间展示,不按用户浏览器时区。
- 核心图表组件已拆分为逻辑、状态与 canvas 渲染模块;Recharts 使用 `ResizeObserver` 后的明确宽高,规避 0x0 渲染和长时间挂页后曲线消失。
- DEB hourly consensus`deb_hourly_consensus.v1`)已作为峰值窗口和图表 DEB 曲线的优先小时路径;DEB 仍然是预测曲线,不作为实测来源。
- 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` 只在本地离线评估通过并手动灰度后启用。
- 官方增强站网已统一接入:
@@ -37,16 +46,15 @@
- `CWA`(台湾)
- 东京现已接入羽田 `JMA AMeDAS` 10 分钟温度作为官方增强层。
- 已支持 Dashboard 定向预热 worker / cron 路径,运行态在 `/api/system/status``/ops` 可见。
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与 prewarm heartbeat。
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与运行态 heartbeat。
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
- 城市决策卡已接入 AI 机场报文解读:先用 METAR、DEB、多模型集群和 AI 最高温中枢判断天气路径,再映射到 Polymarket 温度桶
- AI 机场报文解读现在同时使用页面内存缓存、浏览器 `localStorage`后端短 TTL 缓存;从其他选项卡切回决策卡时会优先恢复已有流式内容或最终结果
- 终端图表/详情工作流已改为结构化实况 + DEB hourly consensus + 多模型集群 + 概率分布 tooltip + 市场温度桶,不再让图表等待 AI 文案生成
- 终端数据同时使用页面内存缓存、浏览器 `localStorage`后端短 TTL 缓存、SSE patch replay 和前台恢复刷新;从其他选项卡切回时会优先恢复最新可见图表状态
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
- 概率区已改为校准模型概率”;默认展示生产概率引擎输出,EMOS/LGBM 只在通过评估或作为 shadow 时进入解释层
- 今日日内结构解读已支持可选 `Groq` 改写层,失败时自动回退规则文案
- 前端部署文档已补充 Vercel 节流建议,包括 analytics 关闭、eager fetch 开关与扫描流量防火墙规则。
- 市场信号中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
- 概率区已改为校准模型概率”;默认展示生产概率引擎输出legacy 高斯或 EMOS),模型共识作为辅助参考
- 今日日内结构解读以规则与结构化信号为主,AI 文案只作为可降级辅助层,不替代实测、DEB、TAF 或结算逻辑
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`
## 许可证与商用边界(重要)
@@ -63,13 +71,14 @@
- 聚合 51 个监控城市的实测与预报数据。
- DEBDynamic Error Balancing)融合多模型最高温。
- 输出结算导向校准概率分布(`mu` + 温度桶),并在 LGBM 生效时展示校准引擎元数据
- 将模型观点映射到 Polymarket 行情,做错价扫描
- 地图城市决策卡把 AI 机场报文解读、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示
- 构建 DEB 加权小时共识曲线,用于峰值窗口判断和图表默认 DEB 展示
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎
- 天气决策台把结构化实况、DEB 高温路径、完整市场温度桶和模型-市场差放进图表/详情工作流
- 图表 tooltip 展示校准高斯上下文:`mu` 加完整温度区间概率分布,不把概率温度带重新放回主图。
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
- 已上线站内反馈闭环:提交反馈时自动附带图表上下文,用户可查看处理状态,运营后台可为有价值反馈人工奖励积分。
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
- 支持后台预热热点城市,降低用户点击城市后的冷启动成本。
## 参考架构
@@ -88,12 +97,11 @@ flowchart LR
WX --> OM["Open-Meteo"]
WX --> HKO["HKO / CWA / NOAA 等官方结算源"]
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
API --> ANA["DEB + 小时共识 + 概率 + 市场扫描"]
API --> SSE["SSE /api/events"]
WX --> SSE
SSE --> EVENT["Redis Stream / SQLite Event Log"]
ANA --> PAY["支付状态(Intent + Event + Confirm Loop"]
ANA --> PM["Polymarket 只读层"]
API --> OBS["healthz / system status / metrics"]
API --> PREWARM["Dashboard 预热接口 / Worker"]
ANA --> LLM["可选 Groq 文案改写层"]
ANA --> STATE["SQLite runtime state<br/>legacy files only for migration/export fallback"]
```
@@ -120,6 +128,11 @@ npm ci
npm run dev
```
## 近期更新
- 高斯概率 tooltip 已改为展示完整温度区间概率分布,不再只显示最高概率的单个区间;主图继续聚焦实测和预测曲线。
- 用户反馈已形成产品闭环:终端提交会自动附带图表上下文,用户可在站内查看处理状态,运营侧可为真实、有建设性的反馈发放积分奖励。
## 运行数据目录(VPS 推荐)
建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住):
@@ -128,8 +141,14 @@ npm run dev
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
POLYWEATHER_STATE_STORAGE_MODE=sqlite
POLYWEATHER_EVENT_STORE=redis
POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
POLYWEATHER_REDIS_REQUIRED=true
```
本地开发或严格单进程兜底可使用 `POLYWEATHER_EVENT_STORE=sqlite`
## EMOS 本地训练流程
低配 VPS 只负责采集、服务和加载已通过评估的参数,不建议在 VPS 上跑 EMOS 全量训练。训练前先从 VPS 拉 SQLite 副本到本地:
@@ -158,24 +177,10 @@ curl http://127.0.0.1:8000/api/system/status
curl http://127.0.0.1:8000/metrics
```
### Dashboard 预热 Worker
### 外部监控栈
```bash
docker compose --profile workers up -d polyweather_prewarm
curl http://127.0.0.1:8000/api/system/status
```
重点关注:
- `prewarm.thread_alive`
- `prewarm.runtime.cycle_count`
- `prewarm.runtime.last_summary_ok`
- `cache.analysis.hit_rate`
### 前端缓存头
```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
./scripts/validate_frontend_cache.sh "https://polyweather.top"
```
### 支付自动补单日志
@@ -208,19 +213,13 @@ curl http://127.0.0.1:8000/api/payments/runtime
### 运营后台
- 前端入口:`https://polyweather-pro.vercel.app/ops`
- 前端入口:`https://polyweather.top/ops`
- 后端需配置:
```env
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
```
### 钱包异动监听日志
```bash
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
```
## Telegram 指令
| 指令 | 用途 |
@@ -241,24 +240,21 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
- 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)
- EMOS 训练报告:[docs/EMOS_TRAINING_REPORT_ZH.md](docs/EMOS_TRAINING_REPORT_ZH.md)
- 概率快照归档[docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md](docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md)
- 技术债(中文镜像):[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
- 技术债(主文档):[docs/TECH_DEBT.md](docs/TECH_DEBT.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)
- 外部服务总览:[docs/SERVICES_ZH.md](docs/SERVICES_ZH.md)
- 支付合约验证:[docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
- 支付审计说明:[docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md)
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
- 模型栈与 DEB[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
- LightGBM 日最高温模型:[docs/LGBM_DAILY_HIGH_ZH.md](docs/LGBM_DAILY_HIGH_ZH.md)
- EMOS + LGBM 系统:[docs/EMOS_LGBM_SYSTEM_ZH.md](docs/EMOS_LGBM_SYSTEM_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)
- 前端报告:[FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
- 发布流程:[RELEASE.md](RELEASE.md)
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
## 当前版本
- 版本:`v1.7.0`
- 文档最后更新:`2026-05-11`
- 版本:`v1.8.1`
- 文档最后更新:`2026-06-07`
+7 -7
View File
@@ -12,9 +12,9 @@
示例:
- `1.4.0 -> 1.4.1`:告警逻辑修正、缓存修正、文档修正
- `1.4.0 -> 1.5.0`:新增支付能力、新增页面、新增 API
- `1.4.0 -> 2.0.0`接口重构或数据结构不兼容
- `1.7.0 -> 1.7.1`:告警逻辑修正、缓存修正、文档修正
- `1.7.0 -> 1.8.0`:新增能力、接口扩展、向后兼容的功能迭代
- `1.7.0 -> 2.0.0`:不兼容变更、核心架构升级
## 日常升版步骤
@@ -29,7 +29,7 @@ python scripts/bump_version.py patch
```bash
python scripts/bump_version.py minor
python scripts/bump_version.py major
python scripts/bump_version.py 1.5.0
python scripts/bump_version.py 1.8.0
```
### 2. 检查同步结果
@@ -68,15 +68,15 @@ python -m pytest
```bash
git add .
git commit -m "release: v1.4.1"
git tag v1.4.1
git commit -m "release: v1.7.1"
git tag v1.7.1
```
### 6. 推送
```bash
git push
git push origin v1.4.1
git push origin v1.7.1
```
## 当前约束
+1 -1
View File
@@ -1 +1 @@
1.7.0
1.8.1
+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
+260 -9
View File
@@ -1,12 +1,263 @@
#!/bin/bash
set -e
VPS="root@38.54.27.70"
PROJECT="/root/PolyWeather"
#!/usr/bin/env bash
set -euo pipefail
echo "🚀 Deploying to $VPS..."
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}"
ssh "$VPS" "cd $PROJECT && git pull && docker compose up -d --build"
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
echo "✅ Deploy complete. Checking health..."
sleep 8
ssh "$VPS" "curl -s http://localhost:8000/healthz"
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
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
}
export IMAGE_TAG="$NEW_TAG"
export POLYWEATHER_API_BASE_URL="${POLYWEATHER_FRONTEND_INTERNAL_API_BASE_URL:-http://polyweather_web:8000}"
pull_ok=0
for pull_attempt in $(seq 1 6); do
docker compose pull && pull_ok=1 && break
echo "Image pull failed or tag not ready, retry ${pull_attempt}/6..."
sleep 10
done
if [ "$pull_ok" != "1" ]; then
echo "❌ Image pull failed after retries"
exit 1
fi
smoke_check() {
local name="$1"
local url="$2"
local timeout="$3"
local attempts="${4:-6}"
local delay="${5:-5}"
local output=""
for i in $(seq 1 "$attempts"); do
if output=$(curl -fsS -w "http=%{http_code} time=%{time_total}" -o /dev/null --max-time "$timeout" "$url" 2>&1); then
echo "$name ($output)"
return 0
fi
if [ "$i" != "$attempts" ]; then
echo " $name retry $i/$attempts... ($output)"
sleep "$delay"
fi
done
echo "$name ($output)"
return 1
}
wait_for_local_service() {
local name="$1"
local url="$2"
local timeout="${3:-5}"
local attempts="${4:-30}"
local delay="${5:-2}"
for i in $(seq 1 "$attempts"); do
if curl -fsSo /dev/null --max-time "$timeout" "$url"; then
echo "$name ready after attempt $i/$attempts"
return 0
fi
if [ "$i" != "$attempts" ]; then
echo " $name warming $i/$attempts..."
sleep "$delay"
fi
done
echo "$name did not become ready"
return 1
}
warm_public_route() {
local name="$1"
local url="$2"
local timeout="${3:-15}"
local attempts="${4:-3}"
local delay="${5:-2}"
for i in $(seq 1 "$attempts"); do
if curl -fsSo /dev/null --max-time "$timeout" "$url"; then
echo "✅ warmed $name"
return 0
fi
if [ "$i" != "$attempts" ]; then
echo " warm $name retry $i/$attempts..."
sleep "$delay"
fi
done
echo "⚠️ warm $name failed"
return 0
}
read_env_file_value() {
local key="$1"
if [ ! -f ".env" ]; then
return 0
fi
awk -F= -v key="$key" '
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
value=$0
sub("^[^=]*=", "", value)
gsub("^[[:space:]]+|[[:space:]]+$", "", value)
gsub(/^["'"'"']|["'"'"']$/, "", value)
print value
}
' .env | tail -n 1
}
validate_frontend_api_base_url() {
local api_base="${POLYWEATHER_API_BASE_URL:-}"
if [ -z "$api_base" ]; then
api_base="$(read_env_file_value "POLYWEATHER_API_BASE_URL")"
fi
local normalized
normalized="$(printf '%s' "$api_base" | tr '[:upper:]' '[:lower:]' | sed 's/[[:space:]]//g; s#/*$##')"
case "$normalized" in
http://polyweather.top|https://polyweather.top|http://www.polyweather.top|https://www.polyweather.top)
echo "❌ POLYWEATHER_API_BASE_URL must not point at the frontend site: $api_base"
echo " Use the internal backend URL http://polyweather_web:8000 or the backend API host https://api.polyweather.top."
exit 1
;;
esac
}
PUBLIC_SMOKE_RECHECK_DELAY_SEC="${POLYWEATHER_PUBLIC_SMOKE_RECHECK_DELAY_SEC:-20}"
run_public_smoke_checks() {
local phase="${1:-initial}"
local failed=0
if [ "$phase" = "recheck" ]; then
smoke_check "healthz recheck" "https://api.polyweather.top/healthz" 20 6 10 || failed=1
smoke_check "frontend cities recheck" "https://polyweather.top/api/cities" 30 8 10 || failed=1
smoke_check "frontend recheck" "https://www.polyweather.top/" 20 6 10 || failed=1
else
smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || failed=1
smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5 || failed=1
smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || failed=1
fi
return "$failed"
}
validate_frontend_api_base_url
echo "Updating Redis dependency..."
compose_up_retry "redis" -d polyweather_redis
echo "Updating backend services..."
compose_up_retry "backend services" -d --no-deps polyweather_web polyweather
echo "Waiting for backend..."
wait_for_local_service "backend healthz" "http://127.0.0.1:8000/healthz" 5 30 5 || FAILED_BACKEND=1
FAILED_BACKEND="${FAILED_BACKEND:-0}"
if [ "$FAILED_BACKEND" = "1" ]; then
echo "❌ Backend did not become healthy"
rollback_to_previous
exit 1
fi
echo "Updating observation collector..."
compose_up_retry "observation collector" -d --no-deps polyweather_collector
echo "Updating frontend..."
compose_up_retry "frontend" -d --no-deps polyweather_frontend
echo "Waiting for frontend..."
wait_for_local_service "frontend root" "http://127.0.0.1:3001/" 5 40 2 || FAILED_FRONTEND=1
wait_for_local_service "frontend terminal" "http://127.0.0.1:3001/terminal" 10 20 2 || FAILED_FRONTEND=1
FAILED_FRONTEND="${FAILED_FRONTEND:-0}"
if [ "$FAILED_FRONTEND" = "1" ]; then
echo "❌ Frontend did not become healthy"
rollback_to_previous
exit 1
fi
warm_public_route "terminal" "https://polyweather.top/terminal" 20 4 3
warm_public_route "auth snapshot" "https://polyweather.top/api/auth/me?prefer_snapshot=1" 10 3 2
warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?refresh_deb_recent=1" 15 2 2
warm_public_route "cities" "https://polyweather.top/api/cities" 20 3 2
FAILED=0
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..."
rollback_to_previous
exit 1
fi
mkdir -p "$(dirname "$TAG_FILE")"
echo "$NEW_TAG" > "$TAG_FILE"
docker image prune -af
echo "✅ Deployed $NEW_TAG"
+53
View File
@@ -0,0 +1,53 @@
server {
listen 443 ssl http2;
server_name polyweather.top www.polyweather.top;
# Supabase auth can set multiple chunked session cookies during OAuth
# callback. Nginx defaults are too small and can raise:
# "upstream sent too big header while reading response header from upstream".
proxy_buffer_size 16k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
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;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
server {
listen 443 ssl http2;
server_name api.polyweather.top;
proxy_buffer_size 16k;
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:8000;
proxy_http_version 1.1;
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;
}
}
+185 -34
View File
@@ -1,39 +1,190 @@
x-polyweather-base: &polyweather-base
build: .
image: polyweather-app:latest
env_file:
- .env
services:
polyweather:
<<: *polyweather-base
container_name: polyweather_bot
polyweather_redis:
image: redis:7-alpine
container_name: polyweather_redis
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy noeviction
restart: unless-stopped
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
- ./bot.log:/app/bot.log
user: "${UID:-1000}:${GID:-1000}"
healthcheck:
test: ["CMD", "python", "-c", "import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db'); c.execute('SELECT 1'); c.close()"]
interval: 60s
timeout: 10s
retries: 3
polyweather_web:
<<: *polyweather-base
container_name: polyweather_web
restart: unless-stopped
command: python web/app.py
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
ports:
- "8000:8000"
user: "${UID:-1000}:${GID:-1000}"
healthcheck:
test: ["CMD", "python", "-c", "from urllib.request import urlopen; urlopen('http://localhost:8000/healthz')"]
interval: 30s
interval: 10s
retries: 5
test:
- CMD
- redis-cli
- ping
timeout: 5s
volumes:
- polyweather_redis_data:/data
polyweather:
container_name: polyweather_bot
depends_on:
polyweather_redis:
condition: service_healthy
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
cpus: ${POLYWEATHER_BOT_CPUS:-0.75}
env_file: &id001
- .env
environment:
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
POLYWEATHER_SERVICE_ROLE: bot
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC: ${POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC:-180}
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS: ${POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS:-1}
healthcheck:
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_BOT_MEM_LIMIT:-768m}
memswap_limit: ${POLYWEATHER_BOT_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
- ./bot.log:/app/bot.log
polyweather_frontend:
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
container_name: polyweather_frontend
environment:
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL:-}
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS: 'false'
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS:-polyweather.top,www.polyweather.top}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://polyweather.top}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL:-https://polygon-bor-rpc.publicnode.com}
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID:-}
POLYWEATHER_API_BASE_URL: http://polyweather_web:8000
POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true}
POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true}
POLYWEATHER_OPS_ADMIN_EMAILS: ${POLYWEATHER_OPS_ADMIN_EMAILS:-}
healthcheck:
interval: 30s
retries: 3
test:
- CMD-SHELL
- wget -qO- http://$(hostname):3000
timeout: 5s
image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}
ports:
- "127.0.0.1:3001:3000"
restart: unless-stopped
polyweather_web:
command: python web/app.py
depends_on:
polyweather_redis:
condition: service_healthy
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
container_name: polyweather_web
env_file: *id001
environment:
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-1}
POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-1}
POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-3000}
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-180}
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-5}
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
POLYWEATHER_SERVICE_ROLE: web
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
healthcheck:
interval: 30s
retries: 3
test:
- CMD
- python
- -c
- from urllib.request import urlopen; urlopen('http://localhost:8000/healthz')
timeout: 5s
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
ports:
- "127.0.0.1:8000:8000"
restart: unless-stopped
ulimits:
nofile:
soft: 65535
hard: 65535
user: ${UID:-1000}:${GID:-1000}
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
polyweather_collector:
command: python -m web.observation_collector_worker
container_name: polyweather_collector
depends_on:
polyweather_redis:
condition: service_healthy
polyweather_web:
condition: service_started
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
cpus: ${POLYWEATHER_COLLECTOR_CPUS:-0.75}
env_file: *id001
environment:
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ${POLYWEATHER_COLLECTOR_PATCH_ENDPOINT:-http://polyweather_web:8000/api/internal/collector-patch}
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-180}
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-15}
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
POLYWEATHER_SERVICE_ROLE: collector
healthcheck:
interval: 60s
retries: 3
test:
- CMD
- python
- -c
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
c.execute('SELECT 1'); c.close()
timeout: 10s
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
mem_limit: ${POLYWEATHER_COLLECTOR_MEM_LIMIT:-768m}
memswap_limit: ${POLYWEATHER_COLLECTOR_MEMSWAP_LIMIT:-1g}
pids_limit: 256
restart: unless-stopped
user: ${UID:-1000}:${GID:-1000}
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
x-polyweather-base:
env_file: *id001
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
volumes:
polyweather_redis_data:
+57 -25
View File
@@ -1,11 +1,23 @@
# 机场高频实时数据源
最后更新:`2026-05-28`
## 已接入城市
| 城市 | 机场 | ICAO/站点 | 数据源 | 频率 | 类型 | 费用 |
|------|------|-----------|--------|------|------|------|
| 首尔 | 仁川国际 | RKSI | AMOS (`global.amo.go.kr`) | 1 分钟 | 跑道对温度(2对) | 免费 |
| 釜山 | 金海国际 | RKPK | AMOS (`global.amo.go.kr`) | 1 分钟 | 跑道对温度(1对) | 免费 |
| 香港 | CoWIN 6087 | 6087 | CoWIN (`cowin.hku.hk`) | 1 分钟 | 参考站温度(保良局陈守仁小学) | 免费 |
| 香港 | HKO | HKO | HKO 官方 CSV (`data.weather.gov.hk`) | 10 分钟 | 官方气象站温度 | 免费 |
| 台北 | 松山/中央气象署 | 466920 | CWA 开放数据 | 10 分钟 | 官方站点温度 | 免费 |
| 北京 | 首都机场 | 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 分钟 | 机场站点实时温度 | 免费 |
@@ -36,40 +48,53 @@
> 后端通过 `weather_sources.py` 拉取并注入 `airport_primary`,前端市场监控通过
> `resolveMonitorTemperature` 优先读取 `airport_primary.temp` 获得小数精度温度。
## 推送机制
> **CoWIN 6087**: 香港图表默认参考站为 HKU CoWIN `6087`(保良局陈守仁小学)。
> 该源提供约 1 分钟温度序列,作为 PM 最高温市场的高频参考曲线;HKO 10 分钟数据
> 仍作为官方气象层保留。后端通过 `cowin_sources.py` 拉取并写入 `cowin_obs`。
> **AMSC AWOS**: 中国内地跑道城市读取 AMSC `getWindPlate` 中的 `TDZ_TEMP` /
> `MID_TEMP` / `END_TEMP`。这些字段是跑道观测位置气温,不是道面温度。
> 结算跑道展示使用配置的结算端点;辅助跑道只作为背景曲线。
## 独立观测采集器
- Web/API 进程启动 `observation-collector` 后台线程,按源频率独立采集,不依赖 Telegram 推送循环
- 默认频率:AMOS 60s、AMSC AWOS 180s、MADIS HFMETAR 300s、CoWIN 60s、HKO 600s
- 每次采集复用 `weather_sources.py` 现有 `_attach_*` 写入逻辑,负责写 `airport_obs_log` / `runway_obs_log` / 今日观测缓存,并通过 `/api/internal/collector-patch` 写 Redis Stream 或 SQLite event log 后广播 SSE
- 采集成功后刷新对应城市 `panel` cache;前端继续使用 HTTP snapshot + SSE patch,不需要依赖 Telegram 触发更新
- `observation_source_gate.py` 对 AMSC、AMOS、MADIS、HKO、CoWIN 做 per-source/per-city singleflight 和 SQLite cooldown,防止 Web 请求、collector 和兜底分析同时打同一个外部源
## Telegram 推送机制
- 每城按原生频率独立推送,不捆绑
- 首尔/釜山 60s,其余 600s
- 首尔/釜山 60s中国 AMSC 城市 180s其余 600s
- 循环轮询 60s 以匹配最快频率
- Telegram 推送优先读取网站侧 `full`/`panel` 城市缓存;缓存缺失时只做非强制 `panel` 分析兜底,不触发 `force_refresh_observations_only`
- 仅当当前温度距 DEB 预测最高 ≤3°C 时推送
- 确认过峰值后自动停止
## 前端市场监控 freshness 契约
## 前端实时同步与 SSE Patch / Redis Stream 机制
后端城市详情接口会在 `current.freshness` / `airport_current.freshness` 返回源感知更新时间信息,前端市场监控不再用统一的 `obs_age_min` 判断所有城市
为了向用户提供接近行情盘的实况响应并降低服务器负载,系统使用 **HTTP snapshot + Server-Sent Events (SSE) Patch + 可重放事件日志** 架构。生产环境推荐 Redis Stream;本地或单进程可回退 SQLite event log
关键字段:
### 1. 数据推送链路 (Data Pipeline)
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。
5. **BFF 代理流**:浏览器前端通过 BFF 建立与 `/api/events` 的持久连接,从而无需固定整图轮询。
```json
{
"source_code": "amos",
"source_label": "AMOS",
"observed_at": "2026-05-14T11:59:10+00:00",
"observed_at_local": "20:59",
"native_update_interval_sec": 60,
"expected_next_update_at": "2026-05-14T12:00:10+00:00",
"freshness_status": "fresh",
"freshness_reason": "within_native_fresh_window",
"age_sec": 50
}
```
前端刷新规则:
- 首次进入市场监控:强制刷新全部城市,绕过 30 分钟前端缓存。
- 定时轮询:仍以 60s tick 检查,但只刷新已到 `expected_next_update_at``delayed``stale` 或缺失的城市。
- BFF 代理:`force_refresh=true` 时使用 `no-store`,避免 Next fetch revalidate 缓存吞掉强刷。
- 展示:卡片 tooltip 显示源端名称、原生更新间隔和当前 freshness 状态。
### 2. 前端消费与刷新规则 (Frontend Freshness Rules)
- **扫描列表免轮询更新**`use-scan-terminal-query.ts` 通过 `useSsePatchVersion` 钩子订阅全局 SSE 版本。当有任何城市产生更新时,列表将触发按需重绘,之前固定的 5 分钟 `setInterval` 定时轮询已被彻底禁用。
- **详情图表增量合并**`LiveTemperatureThresholdChart.tsx` 使用 `useLatestPatch(city)` 钩子订阅当前选中城市的增量 Patch。当收到 Patch 时,前端会将最新温度与时间戳以增量形式直接合并(Merge)入本地的 `hourly` 状态中,避免重新加载完整的 City Detail JSON。
- **双重降级兜底 (Safe Fallback Guard)**
- **无 Patch 轮询兜底**:为了防止 SSE 连接断开或长时间无 patch 导致界面卡死,所有**可见图表**(即 active 槽位、compact 栅格槽位或 maximized 视图)会启动一个 60 秒的检测定时器。
- **触发条件**:若当前可见城市在连续 **2 分钟** 内没有收到任何 SSE patch,前端将自动发起主动请求:
1. 调用轻量级的 `/api/city/{city}/summary` 快速拉取最新实况温度。
2. 调用 `fetchHourlyForecastForCity(city, { ignoreCache: true })` 强刷完整的城市详情数据,确保数据一致性。
- **按需加载与 Stagger 优化**:在加载城市详情时,前端会优先加载 Active 状态的图表,而处于 Background/非活动状态的图表则通过 staggered timer (按槽位索引延迟 300ms~1500ms) 异步获取,以分流请求峰值。
- **前台恢复补齐**:浏览器标签页长时间在后台时,回来后会主动强刷可见图表 full detail,避免 SSE 被浏览器挂起后曲线落后。
- **当地时间**patch 中保留 `city_timezone` / `observed_at_utc`,前端按城市当地时间绘制横轴。
## 消息模板
@@ -86,9 +111,16 @@ Seoul / Incheon 16:03
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `TELEGRAM_PUSH_LANGUAGE` | Telegram 自动推送的全局语言,可选 `both`/`en`/`zh` | `both` |
| `TELEGRAM_AIRPORT_PUSH_ENABLED` | 启用机场推送 | `true` |
| `TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC` | 循环轮询间隔 | `60` |
| `TELEGRAM_AIRPORT_PUSH_LANGUAGE` | 机场推送语言覆盖,可选 `both`/`en`/`zh` | `both` |
| `KNMI_API_KEY` | KNMI API 密钥(阿姆斯特丹必填) | — |
| `POLYWEATHER_EVENT_STORE` | 实时事件存储,可选 `redis`/`sqlite` | `sqlite` |
| `POLYWEATHER_REDIS_URL` | Redis Stream 连接地址 | `redis://127.0.0.1:6379/0` |
| `POLYWEATHER_REDIS_STREAM_KEY` | Redis Stream key | `stream:city_observation` |
| `POLYWEATHER_REDIS_STREAM_MAXLEN` | Redis Stream 保留长度 | `50000` |
| `POLYWEATHER_REDIS_REQUIRED` | Redis 不可用时是否启动失败 | `true` |
## 未接入城市
+53 -40
View File
@@ -1,6 +1,6 @@
# PolyWeather API 文档(v1.7.0
# PolyWeather API 文档(v1.8.1
最后更新:`2026-04-27`
最后更新:`2026-05-28`
本文档描述当前对外可用 API 口径(`web/app.py` + `web/routes.py` + `frontend/app/api/*`)。
@@ -17,7 +17,9 @@ flowchart LR
FE["Browser / Dashboard"] --> BFF["Next.js Route Handlers (/api/*)"]
BFF --> API["FastAPI (/web/app.py + /web/routes.py)"]
API --> WX["Weather Collector"]
API --> ANA["DEB + Trend + Probability + Market Scan"]
API --> ANA["DEB + Hourly Consensus + Probability + Market Scan"]
API --> SSE["Realtime SSE (/api/events)"]
SSE --> EVENT["Redis Stream / SQLite Event Log"]
API --> PAY["Payment Intent + Event + Confirm Loops"]
API --> OBS["healthz / system status / metrics"]
```
@@ -31,8 +33,27 @@ flowchart LR
| `/api/city/{name}/summary` | GET | 轻量摘要 |
| `/api/city/{name}/detail` | GET | 聚合详情(含 market_scan |
| `/api/history/{name}` | GET | 历史对账 |
| `/api/scan/terminal/ai-city` | POST | 城市决策卡 AI 解读(非流式 JSON) |
| `/api/scan/terminal/ai-city/stream` | POST | 城市决策卡 AI 解读(SSE 流式) |
| `/api/events` | GET | SSE 实时观测事件流 |
| `/api/internal/collector-patch` | POST | 采集器内部写入实时观测 patch |
### `GET /api/events`
浏览器实时图表入口,使用 `text/event-stream`
参数:
- `cities=shanghai,hong kong`:可选,逗号分隔城市列表;为空表示订阅全部城市。
- `since_revision=<int>`:可选,断线重连后从指定 revision 之后 replay。
- `replay_limit=<int>`:可选,默认 `500`,后端会做上限保护。
事件:
- `connected`:连接建立,包含当前 `latest_revision`
- `city_observation_patch.v1`:标准实时观测 patch,包含城市、序列、温度、观测 UTC 时间、城市时区、revision。
- `resync_required`:replay 窗口不足或事件存储不可用,前端应回到 HTTP snapshot 重建画面。
- `heartbeat`:保活。
生产环境推荐 `POLYWEATHER_EVENT_STORE=redis`,以 Redis Stream 保存短窗口事件并支持多 worker fanout;本地或单进程可使用 SQLite event log。
### `GET /api/city/{name}/detail`
@@ -53,6 +74,7 @@ flowchart LR
- `probabilities.engine / calibration_mode / calibration_version`
- `probabilities.raw_mu / raw_sigma / calibrated_mu / calibrated_sigma`
- `probabilities.shadow_distribution`
- `deb.hourly_consensus / deb.hourly_path.base_source`
- `intraday_meteorology.headline / confidence`
- `intraday_meteorology.base_case_bucket / upside_bucket / downside_bucket`
- `intraday_meteorology.next_observation_time`
@@ -61,40 +83,6 @@ flowchart LR
- `vertical_profile_signal.heating_setup / suppression_risk / trigger_risk / mixing_strength`
- `taf.signal.peak_window / suppression_level / disruption_level / markers`
### `POST /api/scan/terminal/ai-city/stream`
城市决策卡使用该接口生成“AI 机场报文解读”。前端默认请求 SSE 流,流式展示机场报文解读片段,最终以 `final` 事件返回完整 payload。
请求体:
```json
{
"city": "Buenos Aires",
"force_refresh": false,
"locale": "zh-CN"
}
```
SSE 事件:
- `progress`:阶段性状态,例如开始调用 AI、切换非流式重试。
- `preview`:可显示的预览文本。
- `delta`:模型流式文本增量,前端会从中提取机场报文解读片段。
- `final`:完整 `AiCityForecastPayload`
重点字段:
- `status`:通常为 `ready`;超时或降级时会带 `reason / reason_zh / reason_en`
- `cached`:是否命中后端 AI 缓存。
- `degraded`:是否为降级结果。前端不会把 degraded 结果写入长期 localStorage,但会保留页面内存态,避免切换选项卡后空白。
- `city_forecast.predicted_max`AI 给出的预计最高温中枢候选。
- `city_forecast.range_low / range_high`AI 给出的天气区间。
- `city_forecast.final_judgment_zh / final_judgment_en`:最终判断。
- `city_forecast.metar_read_zh / metar_read_en`:机场报文解读。
- `city_forecast.reasoning_zh / reasoning_en`:把 METAR、DEB、多模型集群与日内风险合并后的推理。
- `city_forecast.model_cluster_note_zh / model_cluster_note_en`:模型集群说明。
- `city_forecast.risks_zh / risks_en`:后续上修或下修触发条件。
### 城市决策卡市场层口径
- 前端会请求完整 `market_scan` / `all_buckets`,而不是只取 lite 结果。
@@ -126,7 +114,7 @@ SSE 事件:
#### 2. `probabilities`
概率层基于 legacy 高斯分桶,以 DEB 融合预测 μ 和 ensemble spread σ 生成 1°C 粒度概率分布。
概率层基于 legacy 高斯分桶,以 DEB 融合预测 `mu` 和 ensemble spread `sigma` 生成 1°C 粒度概率分布。前端图表将 legacy 高斯展示为水平概率温度带和 `mu` 参考线,不把概率分布渲染成时间序列曲线。
概率字段:
@@ -135,6 +123,22 @@ SSE 事件:
- `distribution`:当天合约桶概率分布
- `distribution_all`:包含外围桶的完整分布
#### 2.1 `deb.hourly_consensus`
DEB hourly consensus 是当前图表与峰值窗口的优先小时路径。
重点字段:
- `version`:当前为 `deb_hourly_consensus.v1`
- `base_source``multi_model_hourly_deb_weights`
- `times` / `temps`:城市当地日的小时路径
- `model_weights`DEB 权重折叠后的模型权重
说明:
- 该路径是预测曲线,不是实测来源。
- 图表默认展示全天;“高温”视图仅根据该路径推导 peak-centric 窗口。
#### 3. `detail_depth`
`detail_depth` 用于区分轻量 detail 与完整 detail。前端如果发现:
@@ -234,6 +238,13 @@ SSE 事件:
### 支付状态建议
`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`
@@ -291,6 +302,8 @@ SSE 事件:
- `summary?force_refresh=true``Cache-Control: no-store`
- 详情接口与支付接口:`no-store`
- `METAR` / `TAF` / settlement current 由后端各自维护短 TTL 缓存
- 实时事件层:`POLYWEATHER_EVENT_STORE=redis` 时使用 Redis Stream;未启用 Redis 时使用 SQLite `observation_patch_events` 作为 replay fallback
- 前端终端图表:HTTP detail 仍是完整 snapshotSSE patch 只做增量观测追加;长连接断开或后台恢复时前端会按 `since_revision` replay 或强刷 detail
- 前端打开今日日内分析时,如果 full detail 或 market scan 正在同步,会先显示刷新锁,不展示可交互的旧内容
- 城市决策卡 AI 解读前端缓存键为 `city + local_date + locale + METAR signature`signature 优先使用原始 METAR,缺失时回退到报文时间、观测时间和温度
- 城市决策卡 AI 解读使用两层前端缓存:页面内存缓存保存 loading / 流式进度 / 最终 payload`localStorage` 保存最终成功 payload,默认 TTL 1 小时
+168
View File
@@ -0,0 +1,168 @@
# 城市实时数据源总览
> 最后更新: 2026-05-28 | 51 城市
## 数据源分级
### Tier 1 — ≤1 分钟高频
| 城市 | 来源 | 频率 | 备注 |
|------|------|------|------|
| seoul | AMOS 跑道传感器 (RKSI) | ~1 min | global.amo.go.kr, 站号 113 |
| busan | AMOS 跑道传感器 (RKPK) | ~1 min | global.amo.go.kr, 站号 153 |
| 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) | 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)
| 城市 | 来源 | 频率 | 备注 |
|------|------|------|------|
| new york | MADIS HFMETAR (KLGA) | 5 min | madis-data.ncep.noaa.gov |
| los angeles | MADIS HFMETAR (KLAX) | 5 min | |
| san francisco | MADIS HFMETAR (KSFO) | 5 min | |
| denver | MADIS HFMETAR (KBKF) | 5 min | |
| austin | MADIS HFMETAR (KAUS) | 5 min | |
| houston | MADIS HFMETAR (KHOU) | 5 min | |
| chicago | MADIS HFMETAR (KORD) | 5 min | |
| dallas | MADIS HFMETAR (KDAL) | 5 min | |
| miami | MADIS HFMETAR (KMIA) | 5 min | |
| atlanta | MADIS HFMETAR (KATL) | 5 min | |
| seattle | MADIS HFMETAR (KSEA) | 5 min | |
### Tier 3 — 准实时国家级站网
| 城市 | 来源 | 频率 | 国家/地区 |
|------|------|------|------|
| tokyo | JMA AMeDAS (44166) | 10 min | 日本 |
| ankara | MGM (17128) | 5-15 min | 土耳其 |
| istanbul | MGM (17058) | 5-15 min | 土耳其 |
| helsinki | FMI 开放数据 | 10 min | 芬兰 |
| amsterdam | KNMI 数据平台 | 10 min | 荷兰 |
| shenzhen | HKO 官方 CSV (LFS) | ~10 min | 香港天文台流浮山自动站 |
| taipei | CWA 开放数据 (466920) | ~10 min | 台湾 |
| tel aviv | IMS Lod (225) | 实时 | 以色列 |
| paris | AEROWEB 实况 / AROME HD | 实时/15min | 法国 (AROME是15分钟临近预报) |
### Tier 4 — 仅 METAR10 分钟缓存)
| 城市 | ICAO | 备注 |
|------|------|------|
| london | EGLC | Met Office 仅 1 小时更新 |
| jeddah | OEJN | NCM 数据源目前不可用 |
| moscow | UUWW | 仅 UUWW METAR 单站 |
| shenzhen | ZGSZ | 已接入 HKO 流浮山 10 分钟数据,见 Tier 3 |
| munich | EDDM | DWD 延迟约 1 小时 |
| milan | LIMC | 无已知实时源 |
| warsaw | EPWA | 含 IMGW 附近站 |
| madrid | LEMD | AEMET 注册已失效 |
| toronto | CYYZ | |
| mexico city | MMMX | |
| buenos aires | SAEZ | |
| sao paulo | SBGR | |
| panama city | MPMG | |
| kuala lumpur | WMKK | |
| jakarta | WIHH | |
| manila | RPLL | |
| karachi | OPKC | |
| lucknow | VILK | |
| wellington | NZWN | |
| cape town | FACT | |
## 高频推送覆盖
31 个城市在 `HIGH_FREQ_AIRPORT_CITIES`Telegram 推送循环):
所有 Tier 1-3 城市 + shenzhen
19 个城市在 `HIGH_FREQ_AIRPORT_ANALYSIS_CITIES`(日内分析):
seoul, busan, hong kong, lau fau shan, singapore, beijing, shanghai,
guangzhou, chengdu, chongqing, wuhan, qingdao, shenzhen, tokyo,
ankara, istanbul, helsinki, amsterdam, paris
## 温度观测优先级链
`country_networks.py:_airport_primary_from_raw()` 按以下顺序解析:
1. MADIS HFMETAR(美国 11 城)
2. AMOS 跑道传感器(首尔/釜山)
3. MGM current(安卡拉/伊斯坦布尔)
4. JMA AMeDAS current(东京)
5. FMI current(赫尔辛基)
6. KNMI current(阿姆斯特丹)
7. CoWIN 6087(香港 1min 参考站)
8. AEROWEB current(巴黎)
9. IMS current(特拉维夫)
10. NCM current(吉达)
11. Singapore MSS current(新加坡)
12. 纯 METAR(默认兜底)
## 对日内偏差修正的影响
- **Tier 1 城市**(1 分钟级):修正权重可以更激进,数据噪声低
- **Tier 2 城市**(5 分钟级):修正效果良好,MADIS 更新稳定
- **Tier 3 城市**(10-15 分钟级):修正可用但滞后较大
- **Tier 4 城市**(仅 METAR):修正效果有限,不建议依赖
## 实时事件与图表刷新逻辑
当前终端图表不是固定整图轮询,而是:
1. 首屏 / 切换城市时拉取 `/api/city/{city}/detail` 作为完整 snapshot。
2. 可见图表连接 `/api/events?cities=...&since_revision=...&replay_limit=500`
3. 采集器产出 `city_observation_patch.v1` 后写入 Redis Stream(生产)或 SQLite event log(本地/兜底),再通过 SSE 推给浏览器。
4. 前端把 patch 追加到已有实测序列,不显示 loading 遮罩;只有可见图表 2 分钟无 patch 时才启动 60 秒兜底刷新。
5. 浏览器从后台切回前台时,前端会立即补一次 full detail,防止长时间挂页后图表落后。
频率取决于源头:
- AMOS / CoWIN / MSS:源头约 1 分钟,图表按 1 分钟粒度追加。
- AMSC:中国跑道观测城市按 3 分钟采集,不再强制 60 秒刷新。
- MADIS:源头约 5 分钟。
- HKO / CWA / JMA / FMI / KNMI:源头约 10 分钟。
- METAR-only 城市:按 METAR 可用频率和缓存 TTL,不伪装成 1 分钟实测。
所有图表横轴和 tooltip 时间均按城市当地时间展示,不按用户浏览器时区。
## 关于网站终端图表的数据曲线展示逻辑
### 1. 实测数据(默认全开,突出核心)
- **跑道全量展示**:北京、上海、广州、成都、重庆、武汉、青岛、首尔、釜山等城市的跑道实测数据,默认全量开启,无需手动勾选。
- **结算跑道高亮**:系统内置了各大机场的官方结算跑道映射。命中的跑道将被**重点强调**(加粗的青色实线 #009688,线宽 2.8),并标记为“[跑道号] 结算跑道”。具体的跑道映射如下:
- 北京:19/01
- 上海:17L/35R
- 广州:02L/20R
- 成都:02L/20R
- 重庆:20R/02L
- 武汉:04/22
- 青岛:16/34
- 首尔:15R/33L
- 釜山:SR/SL
- **辅助跑道弱化**:同一机场下的其他非结算跑道,也会同时展示,但采用较细的虚线(线宽 1.2)以作陪衬区分。
- **单跑道机场去重**:釜山只有 `SR/SL` 跑道曲线时,不再额外展示 AMOS 聚合线,避免两条线语义重复。
- **香港参考曲线**Hong Kong 默认展示 CoWIN `6087`(保良局陈守仁小学)1 分钟参考站曲线;HKO 10 分钟实测作为官方气象层保留。
- **其他实测展示**:所有城市的 METAR 报文曲线、官方气象站实测(如 Shenzhen / Lau Fau Shan 的 HKO 自动站、Taipei 的 CWA)均默认展示。
### 2. 核心预测数据(默认展示)
- **DEB 模型融合**:作为平台核心的智能融合预测曲线,默认始终展示给用户。DEB 是预测,不参与“实测接近峰值”的视觉预警计算。
- **DEB hourly consensus**:图表优先使用 `deb_hourly_consensus.v1` 的小时路径展示 DEB 曲线和推导“高温”窗口;如果缺失才回退旧的 hourly + DEB offset 路径。
### 3. 多模型原始数据(默认隐藏,按需自选)
- **保持整洁**:为了防止图表线缆过于杂乱,各大原始模型(ECMWF, GFS, ICON, GEM 等)的数据曲线在初次加载时**默认隐藏**。
- **特例**:仅针对巴黎(Paris),由于其 AROME HD 是高精度的 15 分钟级临近预报,极具参考价值,因此默认开启。
- **自由交互**:用户可通过图表底部的图例交互按钮,随时自由勾选、叠加或隐藏任意所需的数据曲线。
### 4. 高斯概率图层
- legacy 高斯概率不会作为时间序列曲线展示。
- 图表上只渲染概率温度带和 `mu` 参考线,帮助用户判断当前实测距概率中心和高概率区域的关系。
+17 -9
View File
@@ -1,6 +1,6 @@
# 商业化说明(Production
最后更新:`2026-04-18`
最后更新:`2026-05-30`
## 1. 定位
@@ -8,8 +8,8 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
核心价值:
- 观测优先(METAR / 机场主站 / 明确官方站点;MGM、NMC、JMA、AMOS 等作为增强层)
- 结算导向(DEB + 校准概率桶)
- 观测优先(METAR / 机场主站 / 明确官方站点;MGM、JMA、AMOS、AMSC、CoWIN、CWA 等作为增强层)
- 结算导向(DEB hourly consensus + 校准概率桶)
- 气象判断优先(证据链、失效条件、下一观测点)
- 市场映射(行情对照 + 错价雷达),但不把交易建议放在第一层产品承诺
@@ -18,12 +18,16 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
| 能力 | 状态 | 备注 |
| :-- | :-- | :-- |
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
| 订阅套餐(Pro 月付) | 已上线 | `10 USDC / 30天` |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
| 订阅套餐(Pro 月付 / 季度 | 已上线 | `29.9 USDC / 30天``79.9 USDC / 90天` |
| 邀请积分 | 已上线 | 被邀请人首月 `20U`;邀请人每个有效付费邀请 `+3500` 积分 |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U`;邀请首月价不叠加积分抵扣 |
| 合约支付 | 已上线 | PolygonUSDC + USDC.e |
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
| 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 |
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop,按 intent.chain_id 查链 |
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
| 私有频道推送 | 已上线 | 可拆分业务频道 |
| 实时终端 | 已上线 | SSE Patch + Redis Stream replay |
| Telegram 双语推送 | 已上线 | 中英文一起推送,结算跑道端点口径 |
## 3. 权限模型(当前)
@@ -33,14 +37,17 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
- 今日日内深度分析(含高温时段)
- 专业气象结论条、证据链、失效条件、确认条件
- 概率分布层(基于 DEB 融合 + 高斯分桶)
- 实时终端多城市图表(跑道/官方站点/DEB/概率带)
- 历史对账 + 未来日期分析
- 全平台智能气象推送
## 4. 收费与积分规则(默认)
- 套餐:`pro_monthly`10 USDC / 30 天)
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC
- 实付下限:2 USDC(当积分满额时)
- 套餐:`pro_monthly`29.9 USDC / 30 天)`pro_quarterly`79.9 USDC / 90 天)
- 邀请首月:被邀请人首次月付 20 USDC
- 邀请奖励:邀请人 +3500 积分,每月最多 10 个有效付费邀请
- 抵扣:500 积分抵 1 USDC;月付最高抵 3 USDC,季度最高抵 8 USDC
- 积分来源:仅通过有效付费邀请和后台人工补发,不再通过 Telegram 群发言发放
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
@@ -62,6 +69,7 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
5. 安全边界:敏感配置不进仓库。
6. 数据口径:机场市场按 METAR / 机场主站解释;Wunderground 只可描述为历史页面或参考入口,不描述成“站”。
7. 加载口径:日内分析和右侧详情在 full detail 未补齐前必须显示同步状态,不能把旧缓存伪装成完整付费内容。
8. 实时口径:终端图表应优先保留已有曲线并无痛合并 patch,不用 loading 遮罩覆盖已可用数据。
## 7. 后续路线
+45 -60
View File
@@ -1,5 +1,7 @@
# 配置与密钥管理(中文)
最后更新:`2026-05-28`
## 1. 目标
PolyWeather 的环境变量很多,但不是所有变量都属于同一层级。
@@ -79,6 +81,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `POLYWEATHER_RUNTIME_DATA_DIR`
- `POLYWEATHER_DB_PATH`
- `POLYWEATHER_STATE_STORAGE_MODE`
- `POLYWEATHER_EVENT_STORE`
前端:
@@ -104,13 +107,13 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION`
- `POLYWEATHER_OPS_ADMIN_EMAILS`
- `POLYWEATHER_STATE_STORAGE_MODE`
- `POLYWEATHER_EVENT_STORE`
- `POLYWEATHER_REDIS_REQUIRED`
- `POLYWEATHER_PAYMENT_ENABLED`
- `POLYMARKET_MARKET_SCAN_ENABLED`
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
- `POLYGON_WALLET_WATCH_ENABLED`
- `TELEGRAM_ALERT_PUSH_ENABLED`
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
- `POLYMARKET_WALLET_ACTIVITY_ENABLED`(已退役,建议保持 `false`
- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED`
### 4.3 L3:运行调优项
@@ -129,27 +132,17 @@ 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_PREWARM_INTERVAL_SEC`
- `POLYWEATHER_PREWARM_JITTER_SEC`
- `POLYWEATHER_PREWARM_CITIES`
- `POLYWEATHER_PREWARM_INCLUDE_DETAIL`
- `POLYWEATHER_PREWARM_INCLUDE_MARKET`
- `POLYWEATHER_PREWARM_FORCE_REFRESH`
- `POLYWEATHER_REDIS_URL`
- `POLYWEATHER_REDIS_STREAM_KEY`
- `POLYWEATHER_REDIS_STREAM_MAXLEN`
策略:
- 先用默认值
- 出现性能或运维问题时再调
当前默认预热名单优先覆盖:
- 亚洲:Shanghai、Beijing、Shenzhen、Wuhan、Chengdu、Chongqing、Hong Kong、Taipei、Singapore、Tokyo、Seoul、Busan
- 中东:Ankara、Istanbul
- 欧洲:London、Paris、Madrid
默认不再包含美国城市;如果线上 `.env` 已手动设置 `POLYWEATHER_PREWARM_CITIES`,则会以你的显式配置为准。
### 4.4 L4:敏感项
这些变量不应写进公开文档截图,也不应提交到仓库。
@@ -160,9 +153,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `SUPABASE_SERVICE_ROLE_KEY`
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
- `METEOBLUE_API_KEY`
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
- `POLYMARKET_SECRET_KEY`
## 5. 推荐部署矩阵
@@ -234,6 +225,10 @@ TELEGRAM_CHAT_ID=...
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
POLYWEATHER_STATE_STORAGE_MODE=sqlite
POLYWEATHER_EVENT_STORE=redis
POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
POLYWEATHER_REDIS_REQUIRED=true
UID=1000
GID=1000
POLYWEATHER_AUTH_ENABLED=true
@@ -254,20 +249,7 @@ TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true
POLYWEATHER_PREWARM_INTERVAL_SEC=300
POLYWEATHER_PREWARM_JITTER_SEC=20
POLYWEATHER_PREWARM_INCLUDE_DETAIL=true
POLYWEATHER_PREWARM_INCLUDE_MARKET=true
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
POLYWEATHER_SCAN_AI_ENABLED=false
POLYWEATHER_SCAN_AI_API_KEY=...
POLYWEATHER_SCAN_AI_PROVIDER=mimo
POLYWEATHER_SCAN_AI_PROVIDER_LABEL=MiMo
POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
POLYWEATHER_SCAN_AI_MODEL=mimo-v2.5-pro
POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
```
说明:
@@ -277,41 +259,46 @@ POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
- `POLYWEATHER_RUNTIME_DATA_DIR` 建议放在仓库外,例如 `/var/lib/polyweather`
- `docker-compose.yml` 会把这个目录同时挂载到容器内的 `/var/lib/polyweather``/app/data`,兼容现有缓存与 SQLite 路径。
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`
- `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_BY_CHAIN_JSON` 用于多链支付,例如同时支持 Polygon 和 Ethereum 主网 USDC。
- 机器人市场监控包含 `关键提醒``关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
- `POLYWEATHER_SCAN_AI_*` 走 OpenAI-compatible `/chat/completions`;当前临时默认 MiMo,可用 `POLYWEATHER_SCAN_AI_BASE_URL``POLYWEATHER_SCAN_AI_MODEL` 随时切回其他兼容 provider。
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
- `POLYMARKET_WALLET_ACTIVITY_ENABLED` 已退役,保留为 `false` 即可,不建议再启用钱包异动监听。
- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true` 时,建议同时启用独立 worker 或 bot 内嵌预热线程。
### 6.3 Dashboard 预热 worker 推荐变量
```env
POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true
POLYWEATHER_PREWARM_INTERVAL_SEC=300
POLYWEATHER_PREWARM_JITTER_SEC=20
POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid
POLYWEATHER_PREWARM_INCLUDE_DETAIL=true
POLYWEATHER_PREWARM_INCLUDE_MARKET=true
POLYWEATHER_PREWARM_FORCE_REFRESH=false
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
```
说明:
- 这组变量用于后台定向预热热点城市,避免用户点击城市时才冷启动拉 detail。
```env
```
说明:
- 这层只负责把结构化信号改写成短摘要,不替代真实模型、机场锚点和结算逻辑。
### 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 机器人市场监控建议配置
这套配置用于替代旧的钱包异动监听,围绕市场本身做两类推送:
这套配置围绕市场本身做两类推送:
- `关键提醒`:实时错价/触发条件满足时发送
- `关注清单`:按亚洲时区定时推送当日重点市场摘要
@@ -329,7 +316,6 @@ TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
```
说明:
@@ -337,7 +323,6 @@ POLYMARKET_WALLET_ACTIVITY_ENABLED=false
- `TELEGRAM_ALERT_MISPRICING_ONLY=true` 表示关键提醒优先围绕错价/市场触发,不把机器人做成泛通知器。
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800` 表示频道每 30 分钟主动推送一轮全局机会清单;每轮会先扫描完整 `TELEGRAM_ALERT_CITIES`,再选 Top N。
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5` 建议先保持较小,避免机器人一次推太多城市。
- `POLYMARKET_WALLET_ACTIVITY_ENABLED=false` 表示停用旧的钱包异动监听,统一收敛到市场监控。
## 7. 当前建议的运维规则
+13 -1
View File
@@ -1,5 +1,7 @@
# 前端部署配置(Vercel
最后更新:`2026-05-29`
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。
## 一、部署目标
@@ -16,6 +18,12 @@
2. Next `/api/*` -> `POLYWEATHER_API_BASE_URL`
3. FastAPI 后端 -> 分析 / 支付 / 鉴权服务
实时图表同样走 Next Route Handler / rewrite
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 仓库后,使用下面的设置:
@@ -93,7 +101,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
```env
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
```
只影响按钮跳转,不影响核心页面加载。
@@ -119,12 +127,15 @@ NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
1. 用户点击支付前,前端会重新请求 `/api/payments/config`
2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址
3. 若后端返回的 `tx_payload.to` 与最新 `receiver_contract` 不一致,会直接阻断支付
4. 多链支付时,前端会展示后端返回的网络列表,并把用户选择的 `chain_id` 传给后端创建 intent
5. Ethereum 主网 USDC 当前走手动直转确认,前端不会把它当成 Polygon checkout 合约支付
这层防护的目的,是降低以下事故概率:
- 用户使用长期未刷新的旧标签页
- 命中旧 deployment URL
- 页面本地状态残留旧收款地址
- 用户在钱包默认网络(例如 Ethereum)付款,但系统按 Polygon intent 查账
如果你变更过支付收款地址,建议同步执行:
@@ -193,6 +204,7 @@ Vercel 部署前至少确认:
4. 如果启用鉴权,Supabase redirect URL 已包含前端域名
5. `GET /api/payments/config` 返回的是当前最新地址,而不是旧收款合约
6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在 Vercel 与后端同时配置
7. 确认 `/api/events` 没有被 CDN 缓存或压缩成普通 JSON;它必须保持 `text/event-stream`
## 九、常见问题
+74 -2
View File
@@ -2,7 +2,7 @@
本文档记录 PolyWeather 当前开放模型接入、区域覆盖差异,以及 DEB 在新增模型后的计权规则。
最后更新:`2026-04-18`
最后更新:`2026-05-28`
## 1. 接入方式
@@ -165,11 +165,73 @@ raw current_forecasts
-> 按模型家族去重
-> 历史 MAE 统计
-> MAE 倒数权重
-> 输出 blended_high + weights_info
-> 输出 raw blended_high
-> recent signed-bias correction
-> 输出 production prediction + raw_prediction + version
```
`weights_info` 出现 `家族去重`,表示当前输入模型数量多于 DEB 实际入模数量,系统已先折叠同家族模型。
### 5.1 DEB hourly consensus
当前图表和峰值窗口优先使用 `deb_hourly_consensus.v1`
入口:
- `src/analysis/deb_hourly_consensus.py`
- `web/analysis_service.py`
- `src/analysis/trend_engine.py`
处理逻辑:
```text
multi_model hourly curves
-> 按 DEB 可用模型和家族去重口径选择候选
-> 使用 DEB 权重生成逐小时 consensus
-> 输出 deb.hourly_consensus
-> 图表 DEB Forecast 与 peak-window 逻辑优先读取该路径
```
关键口径:
- `deb_hourly_consensus.v1` 是预测曲线,不是实测曲线。
- 它只用于日内形状、峰值窗口和 DEB Forecast 展示;视觉预警和“接近峰值”状态必须优先读取实测/跑道/官方站点当前值与今日实测高点。
- 如果多模型小时路径缺失,系统才回退旧的 `hourly_plus_deb_offset`
### 5.2 版本化预测与偏差校正
DEB 原始融合逻辑不推倒重写,`calculate_dynamic_weights(...)` 仍作为 raw baseline。线上生产入口使用 `calculate_deb_prediction(...)` 包装 raw baseline,并在有足够历史样本时追加城市级 recent signed-bias correction。
当前版本:
- `deb_v1_raw`:原始 DEB,最近模型误差倒数加权后的融合值。
- `deb_v1_recent_bias_corrected`:在 raw DEB 上叠加最近已结算样本的有符号偏差校正。偏差使用 shrinkage,样本少时自动收缩,避免单日异常过拟合。
API `deb` payload 会保留:
- `prediction`:当前生产使用值。
- `raw_prediction`:未经 recent-bias correction 的原始 DEB。
- `version`:生产值对应的 DEB 版本。
- `bias_adjustment` / `bias_samples`:城市级偏差校正幅度与训练样本数。
- `intraday_adjustment`:网页 full detail 中额外的日内观测路径修正,仅用于当前日实时展示。
版本化回测命令:
```bash
python scripts/backtest_deb_versions.py \
--output-json data/deb_backtest_latest.json \
--output-csv data/deb_backtest_latest.csv
```
本地 `data/polyweather.db` 于 2026-05-27 的回测样本显示:
| 版本 | 样本 | MAE | RMSE | Bias | 结算桶命中率 |
| --- | ---: | ---: | ---: | ---: | ---: |
| `deb_v1_raw` | 1272 | 1.626 | 2.602 | -0.633 | 25.8% |
| `deb_v1_recent_bias_corrected` | 1272 | 1.499 | 2.542 | +0.263 | 31.4% |
该结果只证明当前历史样本上的离线表现改善;后续仍需要持续用版本化回测追踪不同城市、季节与结算源下的漂移。
## 6. 前端展示
网页的模型展示读取:
@@ -177,6 +239,7 @@ raw current_forecasts
- `multi_model`
- `multi_model_daily`
- `source_forecasts.open_meteo_multi_model.model_metadata`
- `deb.hourly_consensus`
显示分组:
@@ -197,6 +260,13 @@ raw current_forecasts
区域模型不覆盖时不显示空模型。
终端图表默认:
- 展示“全天”视图,按城市当地日 00:00-23:59 和真实观测时间绘制。
- 可选“高温”视图,窗口由 `deb_hourly_consensus.v1` 的峰值区域推导。
- DEB Forecast 使用橙色预测曲线;实测/跑道/官方站点曲线独立展示,不把 DEB 当成实测。
- legacy 高斯概率显示为水平温度带和 `mu` 参考线,不参与时间序列曲线。
### 6.1 “来源 Open-Meteo”是什么意思
前端中的 `来源 - Open-Meteo` 表示本次多模型数据通过 Open-Meteo model API 归一化接入。
@@ -228,6 +298,7 @@ raw current_forecasts
- `tests/test_multi_model_sources.py`
- `tests/test_deb_model_family.py`
- `tests/test_deb_evaluation_upgrade.py`
重点覆盖:
@@ -237,3 +308,4 @@ raw current_forecasts
- DEB 家族去重
- 历史不足时的去重等权
- 有历史 MAE 时的去重动态权重
- DEB raw/corrected 版本化评估、recent-bias correction、JSON/CSV 回测输出
+17 -7
View File
@@ -1,6 +1,6 @@
# 外部监控与告警说明
最后更新:`2026-04-18`
最后更新:`2026-05-28`
## 1. 目标
@@ -11,6 +11,7 @@
- Relay 把告警推到运营频道
- Grafana 展示趋势面板
- 巡检脚本补健康检查
- 关注 `/api/events` 长连接与 realtime event store 是否正常 replay
## 2. 组件
@@ -89,6 +90,14 @@ POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
- Source Requests by Outcome
- Source Error Rate (15m)
实时事件层建议额外观察:
- Redis Stream latest revision
- Redis 连接状态
- SQLite fallback 是否被启用
- SSE active connection count
- `resync_required` 出现频率
## 7. 巡检脚本
手动巡检:
@@ -102,6 +111,7 @@ python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
- `/healthz`
- `/api/system/status`
- `/metrics`
- `/api/events`(手动验证时查看 `connected` / `heartbeat` / replay 事件)
任何一项失败都会非零退出,适合挂到 crontab 或 systemd timer。
@@ -114,12 +124,6 @@ python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
目前已覆盖:
- `prewarm` worker 是否启用、线程 / heartbeat 是否活着
- 最近一轮 prewarm 的:
- `cycle_count`
- `success_count / failure_count`
- `last_started_at / last_finished_at`
- `last_summary_ok / last_detail_ok / last_market_ok`
- 缓存桶条目数:
- `api_cache`
- `metar`
@@ -136,6 +140,11 @@ python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
- 多日预报是否只返回当天单卡
- 日内分析 full detail / market scan 是否仍在同步
- 右侧详情面板是否正在用同步占位卡提示用户
- 实时事件状态:
- event store 类型(Redis / SQLite
- latest revision
- Redis 是否连通
- 是否处于 `degraded_from=redis` fallback
这意味着:
@@ -186,3 +195,4 @@ npm run build
- 更细粒度支付指标
- 按城市/来源拆分的业务 SLA
- 按城市拆分的前端补齐耗时与 stale-detail 告警
- Redis Stream 长度、内存与 replay gap 告警
+47 -38
View File
@@ -1,56 +1,65 @@
# 外部服务依赖总览
最后更新:`2026-05-19`
最后更新:`2026-05-29`
项目调用了 24 个外部服务,按状态分为三类
项目调用外部天气、鉴权、支付和实时事件服务。原则是:核心链路必须有明确健康检查;可选数据源不可拖垮已可用城市;实时事件层可从 Redis Stream 降级到 SQLite event log
## 核心(必须有,挂了服务不可用)
| 服务 | 用途 | 状态 |
| ---------------------- | --------------------- | ---- |
| Open-Meteo | 52 城天气预报 | ✅ |
| AviationWeather (NOAA) | METAR/TAF 航空观测 | ✅ |
| MADIS (NOAA) | 美国 5 分钟高频观测 | ✅ |
| Supabase | 用户认证 + 订阅 | ✅ |
| Telegram Bot API | Bot 消息 + 群成员检查 | ✅ |
| KNMI | Amsterdam 10 分钟观测 | ✅ |
| Polymarket Gamma/CLOB | 市场数据读取 | ✅ |
| 服务 | 用途 | 状态 |
| --- | --- | --- |
| Open-Meteo | 51 城天气预报、多模型小时曲线、DEB hourly consensus 输入 | ✅ |
| AviationWeather (NOAA) | METAR / TAF 航空观测 | ✅ |
| MADIS (NOAA) | 美国机场 5 分钟高频观测 | ✅ |
| Supabase | 用户认证、订阅状态、会员恢复 | ✅ |
| Telegram Bot API | Bot 消息群成员检查、双语跑道推送 | ✅ |
| Redis | `city_observation_patch.v1` Stream、SSE replay、多 worker fanout | ✅ |
| SQLite | 运行态数据库、支付审计、实时事件 fallback | ✅ |
## 国家气象源(特定城市必须)
| 服务 | 城市 | 状态 |
| -------------------- | --------------------- | ----------- |
| JMA (日本) | Tokyo | ✅ |
| KMA + AMOS (韩国) | Seoul, Busan | ✅ |
| AMSC AWOS (中国) | 北京/上海/广州等 6 城 | ✅ |
| MGM (土耳其) | Ankara, Istanbul | ✅ |
| FMI (芬兰) | Helsinki | ✅ |
| HKO (香港) | Hong Kong | ✅ |
| CWA (台湾) | Taipei | ✅ |
| NMC (中国) | 国内城市 fallback | ✅ |
| Singapore MSS | Singapore | ✅ |
| IMGW (波兰) | Warsaw | ⚠️ 未配 key |
| Russia pogodaiklimat | Moscow | ✅ |
| 服务 | 城市 | 状态 |
| --- | --- | --- |
| JMA AMeDAS | Tokyo | ✅ |
| AMOS (韩国) | Seoul, Busan 跑道传感器 | ✅ |
| AMSC AWOS (中国) | 北京上海广州、成都、重庆、武汉、青岛跑道端点气温 | ✅ |
| MGM (土耳其) | Ankara, Istanbul | ✅ |
| FMI (芬兰) | Helsinki | ✅ |
| KNMI (荷兰) | Amsterdam | ✅(需 key |
| CoWIN 6087 (香港) | Hong Kong 1 分钟参考站 | ✅ |
| HKO (香港) | Hong Kong / Shenzhen / Lau Fau Shan 10 分钟官方气象层 | ✅ |
| CWA (台湾) | Taipei | ✅ |
| Singapore MSS | Singapore | ✅ |
| IMS Lod (以色列) | Tel Aviv | ✅ |
| AEROWEB / AROME HD | Paris | ✅ |
| NMC (中国) | 国内城市 fallback | ✅ |
| IMGW (波兰) | Warsaw | ⚠️ 未配 key |
## 可选 / 已禁用
| 服务 | 用途 | 状态 |
| -------------- | ------------- | ----------- |
| 服务 | 用途 | 状态 |
| --- | --- | --- |
| OpenWeatherMap | 天气 fallback | ⚠️ 未配 key |
| VisualCrossing | 历史天气 | ⚠️ 未配 key |
| Meteoblue | 天气预报 | ⚠️ 未配 key |
| SynopticData | 美国站点观测 | ⚠️ 未配 key |
| VisualCrossing | 历史天气 | ⚠️ 未配 key |
| SynopticData | 美国站点观测 | ⚠️ 未配 key |
| Meteoblue | 天气预报 | ❌ 已移除 |
| Russia pogodaiklimat | Moscow 历史源 | ❌ 已移除 |
| Groq | AI commentary | ❌ 已移除 |
## AI / 其他
## AI / 支付 / 前端
| 服务 | 用途 | 状态 |
| ----------------- | ---------------- | ----------- |
| 服务 | 用途 | 状态 |
| --- | --- | --- |
| MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 |
| DeepSeek | AI fallback | - 备用 |
| Groq | AI commentary | ⚠️ 未配 key |
| Polygon RPC | 链上支付 | ✅ |
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key |
| DeepSeek | AI fallback | 备用 |
| Polygon RPC | checkout 合约支付、Polygon USDC / USDC.e 自动确认 | ✅ |
| Ethereum RPC | Ethereum 主网 USDC 直转确认 | ✅(启用多链支付时必须) |
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key 时钱包入口降级 |
## 合计
## 运维口径
18 个在用,6 个可选/未配置
- 生产实时事件推荐:`POLYWEATHER_EVENT_STORE=redis` + `POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0`
- 本地或单进程兜底:`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 -23
View File
@@ -1,12 +1,12 @@
# Supabase + 登录 + 支付接入说明(v1.7.0
# Supabase + 登录 + 支付接入说明(v1.8.1
最后更新:`2026-03-14`
最后更新:`2026-05-29`
## 1. 目标
- 前端支持 Google 一键登录 + 邮箱注册/登录。
- 后端支持 Supabase JWT 鉴权。
- 支持 Polygon 合约支付(USDC / USDC.e)并自动确认开通订阅。
- 支持 Polygon 合约支付(USDC / USDC.e和 Ethereum 主网 USDC 直转支付,并自动确认开通订阅。
## 2. Supabase 控制台配置
@@ -22,6 +22,7 @@
在 Supabase SQL Editor 执行:
- `scripts/supabase/schema.sql`
- 既有生产项目遇到 Disk IO Budget 告警时,再执行 `scripts/supabase/io_budget_indexes.sql`
会创建支付与订阅相关表:
@@ -33,6 +34,15 @@
- `payment_intents`
- `payment_transactions`
### 3.1 Disk IO 告警处理
如果 Supabase 提示项目正在耗尽 Disk IO Budget,先在 SQL Editor 执行:
1. `scripts/supabase/io_budget_indexes.sql`
2. `scripts/supabase/disk_io_diagnostics.sql`
第一个脚本会把热查询索引收敛为更低写放大的部分索引,移除已被唯一约束覆盖或无热路径使用的冗余索引,并对相关表执行 `ANALYZE`。第二个脚本用于查看表扫描、dead tuples、索引命中和 `pg_stat_statements` 中的高读块 SQL。生产执行后,继续观察 Supabase daily/hourly Disk IO 图表,确认请求延迟和 IO wait 是否下降。
## 4. 环境变量
### 4.1 前端(Vercel / frontend/.env.local
@@ -66,46 +76,41 @@ 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. 钱包异动频道拆分(推荐)
如果要把“钱包异动监控”发到独立频道:
```env
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=-1003821482461
```
说明:
- 设置了 `POLYMARKET_WALLET_ACTIVITY_CHAT_ID(S)` 后,钱包异动推送优先发该频道。
- 未设置时,回退到全局 `TELEGRAM_CHAT_IDS/TELEGRAM_CHAT_ID`
## 6. 验证步骤
## 5. 验证步骤
1. 登录后请求 `/api/auth/me`,确认 `authenticated=true`
2. 请求 `/api/payments/config`,确认 `enabled=true``configured=true`
@@ -113,10 +118,16 @@ POLYMARKET_WALLET_ACTIVITY_CHAT_ID=-1003821482461
- `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` 事件。
+2 -3
View File
@@ -1,4 +1,4 @@
# 技术债与工程待办(v1.7.0
# 技术债与工程待办(v1.8.1
最后更新:`2026-05-10`
@@ -46,7 +46,6 @@ flowchart TD
- 支付运行态 API 与 SQLite 审计事件已补齐。
- 钱包绑定支持浏览器钱包 + WalletConnect。
- 账户中心与 Pro 权限展示链路打通。
- 钱包异动支持独立频道路由。
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口。
- 轻量可观测性已上线(`/healthz``/api/system/status``/metrics`)。
@@ -61,7 +60,7 @@ flowchart TD
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/首次消息奖励/欢迎奖励/周排名奖励/周参与奖/手动补分 |
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(有效付费邀请/后台人工补发/积分抵扣消费 |
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
+14 -10
View File
@@ -2,7 +2,7 @@
> 审查日期:2026-06 | 视角:系统架构师 | 范围:完整数据采集→分析→API→前端状态
>
> **修复状态:8/8 已完成**
> **修复状态:9/9 已完成;最后校准:2026-05-28**
## 一、数据架构总览
@@ -13,9 +13,8 @@
Open-Meteo (预报+多模型) ─┐
METAR/TAF (航空气象) ─┤
NWS (美国) / MGM (土耳其) ─┤
JMA/AMOS/NMC/HKO/CWA ─┤
Wunderground / NOAA ─┤
Polymarket Gamma/CLOB ─┤
JMA/AMOS/AMSC/HKO/CWA ─┤
CoWIN / MADIS / NOAA ─┤
├─ WeatherDataCollector ├─ dashboard-client.ts
│ (内存缓存 + SQLite磁盘缓存) │ (ETag浏览器缓存 + SWR)
│ │
@@ -27,8 +26,11 @@ Polymarket Gamma/CLOB ─┤
│ ├─ ThreadPoolExecutor(4) │ (120s TTL)
│ └─ AI 增强层 (DeepSeek) │
│ │
─ FastAPI routes └─ API代理 (Next.js rewrites)
(36个端点 + ETag 304)
─ FastAPI routes └─ API代理 (Next.js rewrites)
(HTTP snapshot + ETag 304)
└─ SSE /api/events
(Redis Stream / SQLite replay)
```
## 二、数据采集层
@@ -43,10 +45,8 @@ Polymarket Gamma/CLOB ─┤
| NWS | 国家预报 | 美国 | 按请求 |
| MGM | 国家官方 | 土耳其 | 300s |
| ECMWF/GFS/ICON/GEM/JMA | 多模型 NWP | 全球 | 300s |
| HKO/CWA/NOAA/AMOS/NMC | 结算观测 | 特定国家 | 60s (AMOS) / 300s |
| Wunderground | 个人气象站 | 全球备用 | 按请求 |
| Polymarket Gamma | 市场发现 | 所有温度市场 | 60s |
| Polymarket CLOB | 订单簿 | 匹配市场 | 30s |
| HKO/CWA/NOAA/AMOS/AMSC/CoWIN | 结算/参考观测 | 特定国家 | 60s-600s |
| Redis Stream | 实时事件 replay / 多 worker fanout | 后端内部 | event-driven |
### 待改进
@@ -78,6 +78,8 @@ Polymarket Gamma/CLOB ─┤
**已修复:TTL 匹配** — `SCAN_TERMINAL_PAYLOAD_TTL_SEC` 30s → 120s,匹配 ThreadPoolExecutor(4)×60 城的实际重算耗时。
**已实现:SSE 增量推送(SSE Patch)与可重放事件日志** — 引入 FastAPI SSE 广播通道 (`/api/events`)。数据采集端更新时自动向 `/api/internal/collector-patch` 推送最新温度;后端标准化为 `city_observation_patch.v1`,写入 Redis Stream(生产)或 SQLite event log(兜底)后再广播。前端扫描终端订阅该流,不再执行固定的 5 分钟定时轮询,而是根据 Patch 变化即时更新列表;当前选中图表基于 `useLatestPatch` 实现 1 分钟级温度的增量合并与实时曲线绘制。
| # | 问题 | 优先级 |
|---|------|------|
| 5 | 缓存键过粗(city::mode),微小变化也触发完整重算 | 🟡 |
@@ -92,6 +94,8 @@ Polymarket Gamma/CLOB ─┤
**已修复:扫描数据复用** — `preloadCityFromRow()` 从扫描终端行预填充城市详情缓存,选城市后详情面板立即显示。
**已实现:SSE 订阅、replay 与 2 分钟无 Patch 兜底机制** — 引入 `useLatestPatch``useSsePatchVersion` 管理实况数据的准实时合并。若长连接中断,前端使用 `since_revision` replay 缺失事件;若 2 分钟内未收到任何增量 Patch,可见图表自动触发 60s 降级轮询(从 `/api/city/{city}/summary` 获取最新实况,并以 ignoreCache 强刷 full detail)。浏览器后台恢复时会主动刷新可见图表 full detail。
## 六、待办
| # | 问题 | 优先级 | 说明 |
+41 -39
View File
@@ -2,22 +2,24 @@
## 执行摘要
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-29 更新**:支付 intent 已支持多链 `chain_id`Polygon 继续承载 checkout 合约,Ethereum 主网 USDC 作为直转确认通道;终端图表继续使用 HTTP snapshot + SSE Patch + Redis Stream/SQLite replay 架构。
## 项目概览
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
项目主功能可归纳为五层:
**天气层(数据源/采集)**:聚合 51 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、韩国 AMOS 跑道级观测(首尔/釜山)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报、美国 NWS(仅美国城市)、以及部分城市使用明确官方站点或历史页面入口(香港 HKO、台湾/深圳相关历史页面等)等。机场类市场仍以 METAR / 机场主站为结算锚点,Wunderground 不描述为物理观测站。
**天气层(数据源/采集)**:聚合 51 个城市的实测与预报;支持 AviationWeather METAR/TAF、韩国 AMOS 跑道级观测(首尔/釜山)、中国 AMSC AWOS 跑道端点气温、香港 CoWIN 6087 / HKO、土耳其 MGM、Open-Meteo 多模型与集合预报、美国 MADIS HFMETAR 等。机场类市场仍以 METAR / 机场主站或明确官方结算源为锚点,Wunderground 不描述为物理观测站。
**分析层(DEB/趋势/概率/结算口径)**:
DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;运行态仍维护近 14 天 `daily_records` 缓存做当前对账,但长期监督真值与训练特征已经迁到 SQLite 永久表中,并支持基于 WUWeather Underground 口径)四舍五入的结算命中评估。
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
**城市决策层(Scan Terminal / AI 机场报文解读)**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR,并通过 `/api/scan/terminal/ai-city/stream` 生成城市级 AI 解读。该解读由 `final_judgment``metar_read``reasoning``model_cluster_note``risks` 与原始 METAR 证据组成;最高温中枢优先使用 AI `predicted_max`,再回退到 DEB、多模型中心、日内 pace 当前实测。
**市场层(Polymarket 行情对照)**只读模式从 Gamma API 发现市场、从 CLOB`py-clob-client` 或 REST 回退)读取价格/盘口,并用完整 `all_buckets` 对目标温度桶做 exact/range/“or higher”/“or lower” 严格匹配,计算“模型-市场差”(模型概率 − 市场隐含概率)生成信号标签
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
**城市决策层(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 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 中包含产品截图,可用于快速理解信息架构与 UI 形态)
![PolyWeather demo map](https://raw.githubusercontent.com/yangyuan-zhen/PolyWeather/main/docs/images/demo_map.png)
(插图:项目 README 中包含产品截图,可用于快速理解实时终端与 Telegram 推送形态)
![PolyWeather realtime terminal](https://raw.githubusercontent.com/yangyuan-zhen/PolyWeather/main/frontend/public/static/web.png)
## 架构与代码库分析
@@ -26,10 +28,10 @@ DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
从 README、Docker/Compose、入口脚本与核心模块引用关系,可以抽象出如下模块地图(按“运行时组件”与“Python 域模块”两层描述):
| 层级 | 目录/文件 | 角色定位 | 关键说明 |
| ------------- | ------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 运行时组件 | `frontend/` | Next.js 前端(Vercel | 前端重构报告提到 App Router、Route HandlersBFF)、缓存策略、支付与账户中心等;Scan Terminal 已新增城市决策卡、AI 机场报文解读、页面内存/localStorage 双层缓存、AI stream 小并发队列与完整市场桶映射。 |
| 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` + `web/scan_terminal_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层;Scan Terminal 侧提供 `/api/scan/terminal/ai-city/stream`,城市 AI 默认 30s 超时并支持 stream parse failure 的非流式重试。 |
| 运行时组件 | `frontend/` | Next.js 前端(Vercel | 前端重构报告提到 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`。 |
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py``settlement_sources.py``metar_sources.py``mgm_sources.py``amos_station_sources.py``jma_amedas_sources.py``nws_open_meteo_sources.py``country_networks.py` 等。v1.7.0 已移除 NMC、pogodaiklimat、Meteoblue 数据源。 |
| Python 域模块 | `src/analysis/*` | DEB/趋势/概率/结算口径 | `deb_algorithm.py``trend_engine.py``settlement_rounding.py`。 |
| Python 域模块 | `src/payments/*` + `contracts/*` | 支付合约 + 事件监听/补单 | Solidity 合约 + Python 侧事件扫描/确认循环 + SQLite 审计事件 + RPC 多节点容灾 + 合约静态检查。 |
| Python 域模块 | `src/auth/*``docs/SUPABASE_SETUP_ZH.md``scripts/supabase/schema.sql` | Supabase 鉴权/订阅/积分 | 使用 `/auth/v1/user` 校验 JWT、`/rest/v1/subscriptions` 查订阅(服务端角色 key 必须保密)。 |
@@ -43,13 +45,13 @@ DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
flowchart TB
subgraph Clients
WEB[Next.js Frontend<br/>Vercel]
CITYCARD[Scan Terminal City Cards<br/>AI airport read + bucket mapping]
CITYCARD[Scan Terminal City Cards<br/>structured observations + probability mapping]
TG[Telegram Bot<br/>TeleBot + Handlers]
end
subgraph API
FAST[FastAPI<br/>web/app.py]
LLM[City AI stream<br/>OpenAI-compatible provider]
LLM[City structured detail<br/>OpenAI-compatible provider]
end
subgraph Data
@@ -68,10 +70,8 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
NWS[api.weather.gov]
HKO[data.weather.gov.hk]
CWA[opendata.cwa.gov.tw]
PM_G[Polymarket Gamma API]
PM_C[Polymarket CLOB API]
SB[Supabase Auth/REST]
RPC[Polygon RPC]
RPC[Polygon / Ethereum RPC]
end
subgraph Payments
@@ -96,8 +96,6 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
WX --> HKO
WX --> CWA
FAST --> PM_G
FAST --> PM_C
FAST --> LLM
FAST --> SB
@@ -120,8 +118,8 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解释”的前端决策层:
1. **地图点击与 pinned city**:免费/付费入口都会先把城市加入决策卡;未付费用户若权限不足,仍应保留卡片承载升级/限制提示,而不是点击后无反馈。
2. **full detail hydration**:卡片请求城市 full detail,拿到 DEB、当前/历史实测、多模型区间与最新 METAR。现阶段 detail hydration 仍偏保守串行,优先保障后端数据源稳定;真正消耗 LLM 的 AI 解读另行限流。
3. **AI 机场报文解读**:前端最多同时保留 2 条城市 AI stream,第三个及以后城市会进入队列并展示排队提示,避免多个 provider stream 同时竞争导致第三城/第四城解析失败。当前临时使用 MiMo:`POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1``POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro`;其他后端城市 AI 配置建议为 `POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30``POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900``POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`
2. **full detail hydration**:卡片请求城市 full detail,拿到 DEB、当前/历史实测、多模型区间与最新 METAR。现阶段 detail hydration 仍偏保守串行,优先保障后端数据源稳定;真正消耗 LLM 的 结构化解读另行限流。
3. **结构化实况层**:前端最多同时保留 2 条城市结构化分析 stream,第三个及以后城市会进入队列并展示排队提示,避免多个 provider stream 同时竞争导致第三城/第四城解析失败。当前临时使用 MiMo:`POLYWEATHER_API_BASE_URL=<backend>``POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=300`;其他后端城市结构化分析 配置建议为 `POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=120``POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=8``POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=300`
4. **缓存策略**:页面内存缓存保留 loading/stream/final 状态,切换选项卡返回时不应空白重拉;localStorage 持久化最终成功、非 degraded 的 payload;后端 city AI cache key 已移除当前 `local_time` 干扰,主要按城市、日期与 METAR signature 失效。
5. **市场桶匹配**:城市市场扫描必须使用 full `all_buckets`,按温度 exact/range/“or higher”/“or lower” 方向严格匹配;不再用宽松 ±8°C fallback,以避免拿到 16°C 之类错误桶。前端展示统一使用“模型-市场差”,即 `model_probability - market_implied_probability`,并修复温度单位重复渲染(如 `31°°C`)。
@@ -129,7 +127,7 @@ Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解
**Python 依赖**`requirements.txt` 包含 `requests``loguru``pyTelegramBotAPI``python-dotenv``numpy``web3``fastapi``uvicorn` 等,符合“采集+bot+api+链上交互”的需求。
**容器环境**`Dockerfile` 基于 `python:3.11-slim`,默认启动 bot`docker-compose.yml` 通过不同 command 分别启动 bot 与 web`python bot_listener.py` / `python web/app.py`),并挂载运行态数据目录。
**前端依赖**:前端 README 描述 Next.js、Leaflet、Chart.js、Supabase Auth、WalletConnect 等;`frontend/package.json` 是前端依赖来源。
**前端依赖**:前端 README 描述 Next.js、Leaflet、Recharts、Supabase Auth、WalletConnect 等;`frontend/package.json` 是前端依赖来源。
### 数据预处理、模型与“训练/推理”管线
本项目的“模型”主要是统计融合与规则/启发式引擎,而非深度网络训练:
@@ -138,7 +136,7 @@ Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解
**DEBDynamic Error Balancing**:以最近 N 天各模型的 MAE 计算倒数权重并做加权融合;同时将 `forecasts / actual_high / deb_prediction / mu / prob_snapshot` 写入 `data/daily_records.json`,并提供命中率/MAE/Brier 等统计口径。
**概率引擎**`trend_engine.py` 以集合预报的 p10/p90 推 σ(并考虑历史 MAE floor、风向/云量/压强的 shock_score、以及峰值窗口 time-decay),再用正态近似把连续分布映射为 WU 整数“温度桶概率”。
**推理流水线(在线)**
Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引擎输出结构化结果(μ、概率桶、趋势、死盘/窗口判定、DEB 预测、市场扫描)→ 前端渲染或 bot 消息格式化。对城市决策卡而言,在线推理还会叠加“latest METAR + 多模型区间 + AI city stream + full all_buckets 市场匹配”,最终输出最高温中枢、AI 机场报文解读和模型-市场差。
Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引擎输出结构化结果(μ、概率桶、趋势、死盘/窗口判定、DEB 预测、市场扫描)→ 前端渲染或 bot 消息格式化。对城市决策卡而言,在线推理还会叠加“latest METAR + 多模型区间 + structured city detail + full all_buckets 市场匹配”,最终输出最高温中枢、结构化实况层和模型-市场差。
**检查点(checkpoints**:传统 ML checkpoint 不适用;但项目现已形成两类“业务状态 checkpoint”:
(a)SQLite 运行态存储(当前线上与核心离线链路主路径);(b)SQLite 永久真值/训练特征表(当前监督真值与训练样本长期主存);(c)legacy JSON/JSONL 文件(主要保留给迁移回滚、导出比对与显式回退输入)。当前设计仍支持 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`,但对线上部署与离线训练/回填而言,推荐目标状态都已经是 `sqlite`
### 测试、CI/CD 与运维验证
@@ -150,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 双机制提升最终一致性。
@@ -161,13 +159,15 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
**CI 已建立,但组织级质量门禁未必完全收口**:CI 现已覆盖 Python、前端与 Docker build。当前问题不再是“缺 CI”,而是是否把这些 status check 绑定到 `main` 保护策略,以及是否逐步引入更严格的 pre-merge 审查。
**运行态状态/缓存与核心离线链路的 SQLite 收口已完成**:`daily_records``telegram_alert_state``probability_training_snapshots``open_meteo` 缓存已经支持并在生产中主读 SQLite,迁移/校验脚本可用;进一步地,在临时移除 `data/*.json` / `data/*.jsonl` 后,训练集导出、概率拟合、评估报告、shadow report 和关键 backfill 脚本已验证仍可运行。当前 legacy 文件路径主要是显式回退入口,而不再是默认主输入。
**第三方服务合规与稳定性风险**
项目强依赖外部 APIOpen-Meteo、AviationWeather、global.amo.go.kr AMOS、NWS、HKO、CWA、Polymarket、Supabase)以及城市 AI providerOpenAI-compatible stream,当前临时 MiMo)。其中 AviationWeather Data API 有明确速率限制;Polymarket 官方说明 Gamma/Data/CLOB 三套 API 分属不同域,CLOB 交易端点需鉴权且策略可能变化;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市 AI 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
项目强依赖外部 APIOpen-Meteo、AviationWeather、global.amo.go.kr AMOS、NWS、HKO、CWA、Supabase)以及城市结构化分析 providerOpenAI-compatible stream,当前使用 MiMo)。其中 AviationWeather Data API 有明确速率限制;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市结构化分析 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
> **v1.7.0 更新**PolymarketGamma/CLOBAPI 依赖已随市场价格拉取层一并移除。
**许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部神经天气模型,仍需单独核验第三方代码与权重的商用条件:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
## 对标分析
为满足“至少 3 个相似开源项目或近期论文”对标,本报告选择三类代表:
1**AI 气象预报模型**GraphCast / FourCastNet / Pangu-Weather):用于评估“若 PolyWeather 未来扩展到更强预测能力”的技术与许可边界;
3)**预测市场 API 客户端生态**Polymarket/py-clob-client、aiopolymarket):用于评估市场层的工程选型
3)**预测市场 API 客户端生态**Polymarket/py-clob-client、aiopolymarket):*[v1.7.0 后已不适用]* 市场价格拉取层已移除,此对标仅作历史参考
### 关键对比表
| 项目/论文 | 解决的问题 | 输出形态 | 性能/效果(公开描述) | 易用性与依赖 | 许可证要点 |
@@ -175,18 +175,18 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
| **GraphCast**google-deepmind/graphcast | 10 天全球中期预报(ML 替代/增强 NWP) | 模型代码+权重+notebooks | 论文与介绍提到在大量指标上优于主流确定性系统;仓库提供预训练权重与示例数据入口,并提示 ERA5/HRES 数据条款需另行遵守。 | 完整训练需 ERA5 等;更适合科研/平台级推理,不是产品级 BFF。 | 代码 Apache-2.0;权重 CC BY-NC-SA 4.0(商业限制)。 |
| **FourCastNet**NVlabs/FourCastNet | 高分辨率 data-driven 全球预报(AFNO/ViT) | 模型训练/推理代码+数据/权重链接 | README 描述:0.25° 分辨率、周尺度推理非常快,并可做大规模集合;适合平台型预报。 | 训练/数据依赖大(ERA5 子集 TB 级);工程集成成本高。 | BSD 3-Clause(代码)。 |
| **Pangu-Weather**198808xc/Pangu-Weather + Nature 论文) | 3D Transformer 架构的中期全球预报 | ONNX 推理代码+预训练模型 | Nature 论文称在 reanalysis 上对比 IFS 有更强确定性预报表现,并强调速度优势;仓库提供 ONNX 推理与 lite 版训练说明。 | 模型文件大(多份 ~GB 级),训练资源需求高;更适合科研推理或内部平台。 | 权重 BY-NC-SA 4.0、明确禁止商业用途。 |
| **Polymarket/py-clob-client** | Polymarket CLOB 读写 SDK | Python SDK | 官方 SDK,支持 read-only 与交易接口;协议与端点在官方文档中给出。 | 易用,适合增强 PolyWeather 市场层。 | MIT。 |
| **aiopolymarket** | Polymarket APIs 的 async 客户端 | Python async 客户端 | 强调类型安全(Pydantic)、自动分页、重试与 backoff,适合高并发与健壮性诉求。 | 适合替换/补强当前同步 requests 与自定义缓存。 | 以仓库许可为准(此处建议上线前核验)。 |
| **Polymarket/py-clob-client** | Polymarket CLOB 读写 SDK | Python SDK | *[2026-05 起不再使用]* 官方 SDK,支持 read-only 与交易接口。 | 曾用作 PolyWeather 市场层参考。 | MIT。 |
| **aiopolymarket** | Polymarket APIs 的 async 客户端 | Python async 客户端 | *[2026-05 起不再使用]* 类型安全(Pydantic)、自动分页、重试与 backoff。 | 曾用作市场层升级候选。 | 以仓库许可为准。 |
**对标结论**PolyWeather 与这类“全球神经天气模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。
## 优先级改进建议
下表按截至 `2026-05-11` 的真实状态重排优先级。已完成项不再继续列为待做”,只保留当前仍需推进的事项。
下表按截至 `2026-05-28` 的真实状态重排优先级。已完成项不再继续列为待做”,只保留当前仍需推进的事项。
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 中 | **把最小外部监控继续补深**:从“可告警”提升到“可运营” | 3–7 天 | 不再只知道服务坏没坏,还能看资源趋势、来源 SLA 和支付波动 | 指标过多会带来维护噪音 | 1) 增加节点 CPU/内存/磁盘 → 2) 增加 SQLite/支付体积与事件趋势 → 3) 把 HTTP/来源指标细分到城市/来源维度 → 4) 增加日报或异常摘要 |
| 中 | **城市决策卡 AI 解读可观测性与回放测试** | 3–5 天 | 降低第三/第四/第五城市 AI 解读失败,验证缓存与队列是否真正生效 | 外部 AI stream 仍可能超时或输出截断,若无指标很难复盘 | 1) 记录 city-ai stream status/duration/retry/degraded/cache-hit/queue-depth → 2) 增加固定 METAR + detail + all_buckets fixture → 3) 回归断言 bucket 匹配、模型-市场差、温度单位与缓存 key → 4) 将生产 env 建议同步进部署文档 |
| | **市场层升级为 async + 类型安全**:引入 `aiopolymarket` 或在现有层加重试/backoff/连接池 | 4–7 天 | 行情层更稳,减少短时网络抖动;更易扩展更多市场/分页 | 依赖升级带来的行为差异 | 1) 把 requests.Session 替换为 aiohttp/httpx → 2) 在 Gamma/CLOB 调用侧实现指数退避 → 3) 引入 typed models,减少解析失败 |
| 中 | **城市决策卡 结构化解读可观测性与回放测试** | 3–5 天 | 降低第三/第四/第五城市结构化分析 解读失败,验证缓存与队列是否真正生效 | 外部 structured detail 仍可能超时或输出截断,若无指标很难复盘 | 1) 记录 city-ai stream status/duration/retry/degraded/cache-hit/queue-depth → 2) 增加固定 METAR + detail + all_buckets fixture → 3) 回归断言 bucket 匹配、模型-市场差、温度单位与缓存 key → 4) 将生产 env 建议同步进部署文档 |
| - | ~~市场层升级为 async + 类型安全~~ | N/A | *[v1.7.0 已移除]* 市场价格拉取层已删除,此改进项不再适用 | - | - |
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 1–2 周 | 在已完成的链下审计与容灾之上,进一步收紧链上授权边界 | 合约升级需要重新部署、迁移配置并再次验证 | 1) 维持现有事件重放、SQLite 审计、多 RPC fallback → 2) 升级合约到 SafeERC20 + Pausable → 3) 评估链上 plan/amount/token 绑定或 EIP-712 签名校验 → 4) 迁移后更新 PolygonScan 验证与支付审计文档 |
| 中 | **将 CI 与分支保护/发布流程真正绑定** | 1–3 天 | 让现有 CI 从“存在”变成“强制门禁” | 历史分支/热修流程可能受影响 | 1) GitHub `main` 开启 required checks → 2) 把 release/tag 流程绑定 CI → 3) 明确热修例外流程 |
| 低 | **引入外部神经天气模型作为附加信号**GraphCast/FourCastNet/Pangu-Weather 等) | 2–6 周(取决于范围) | 可能提升极端/中期预测能力与差异化 | **商业许可限制**(多为 CC BY-NC-SA/禁止商业)与算力成本 | 1) 先做合规评审(权重许可/数据条款)→ 2) 仅在研究/非商业环境评估 → 3) 若要商用,优先选择可商用权重或自研/购买授权 |
@@ -194,7 +194,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
### 文档、测试与贡献流程的具体补强建议(落到仓库层面)
1)**文档体系**:保留现有中文 API/TechDebt 文档的同时,增加三份“高价值”文档:
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级,并明确城市 AI 推荐配置(`POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30``POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900``POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`);(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、市场扫描 miss、城市 AI stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级,并明确城市结构化分析 推荐配置(`POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=120``POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=8``POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=300`);(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、城市结构化分析 stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。
2**测试金字塔**:在现有 `trend_engine` 单测基础上,补齐:
(a)天气 provider 的“录制回放”测试(VCR 思路:固定响应→确保解析稳定);(b)市场层的契约测试(Gamma/CLOB schema 变更时提前失败);(c)城市决策卡 fixture 测试(固定 `detail/market_scan/all_buckets/METAR` → 断言 bucket mapping、模型-市场差、温度单位、AI 缓存 key 与排队提示);(d)支付链路的本地链集成测试(Hardhat/Anvil + 事件扫描回放)。这些测试能把“外部依赖漂移”尽量转成可控的回归失败。
3**贡献工作流**:引入 `CONTRIBUTING.md`(分支策略、PR 模板、变更日志、版本号策略)、`CODEOWNERS`(核心模块审查人)、`SECURITY.md`(漏洞披露与密钥处理),并把静态检查(ruff/eslint)作为 pre-commit + CI 必过项。
@@ -219,21 +219,23 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
- 若历史样本足够,DEB 应在“系统性偏差明显”的城市提升 MAE;
**算力**:以上评测全部可在 CPU 上完成;数据量按“51 城市 × 180 天”级别,pandas/duckdb 即可。若引入更复杂拟合(如分层贝叶斯/分位数回归),也通常不需要 GPU。
### 错价信号与市场有效性基准
### 错价信号与市场有效性基准 *[v1.7.0 已暂停]*
> **2026-05-23 更新**Polymarket 价格拉取层与市场扫描(`market_scan`)已于 v1.7.0 移除。本节基准评测方案暂不适用,留待未来若重新引入市场数据层时参考。
**数据集**
- 保存每次扫描输出:`date/city/bucket/bucket_label/bucket_direction/model_probability/market_implied/model_market_diff/yes_buy/quote_source/liquidity/matching_reason`,并加上未来 `settled_bucket` 作为标签Polymarket 市场发现与报价来自 Gamma/CLOB(官方文档说明三套 APIGamma/Data/CLOB
- 若恢复:保存每次扫描输出:`date/city/bucket/bucket_label/bucket_direction/model_probability/market_implied/model_market_diff/yes_buy/quote_source/liquidity/matching_reason`,并加上未来 `settled_bucket` 作为标签。
**指标**
- Signal 覆盖率:能否找到正确 market / bucket
- Edge 稳健性:不同流动性分位的 edge 分布;
- 交易模拟(如需):在考虑滑点/手续费/成交概率下的期望收益(即使项目当前只读,也可以离线评估“若执行”会怎样)
- 交易模拟(如需):在考虑滑点/手续费/成交概率下的期望收益。
**基线**
- 简单策略:仅用市场中间价(不做模型)作为概率;
- 当前策略:模型概率 vs 市场概率 edge 阈值;
- 改进策略:引入流动性/盘口深度/波动”作为信号置信度aiopolymarket/py-clob-client 提供更完整的盘口读取能力)
- 改进策略:引入流动性/盘口深度/波动”作为信号置信度。
**算力**:CPU 即可;关键在于数据采样与回放。
## 路线图与风险缓解
@@ -241,19 +243,21 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
| 时间窗 | 里程碑 | 交付物 | 资源/备注 |
| ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| 第 1 周 | 城市决策卡稳定性补强 | city-ai stream/cache/queue 指标;固定 METAR + `all_buckets` fixture;温度桶匹配与模型-市场差回归测试;生产 env 文档化 | 前端为主,后端补指标 |
| 第 2 周 | 市场层与 Scan Terminal 数据回放 | 保存 `bucket_label/bucket_direction/model_market_diff/matching_reason`;支持回放第三/第四/第五城市 AI 解读失败案例 | 用真实失败样本压回归 |
| 第 4–5 周 | 监控深挖与运维日报 | 来源 SLA、城市维度延迟、AI stream 状态、SQLite 体积、支付事件趋势、异常摘要 | 避免指标过多,先覆盖高频故障 |
| 第 2 周 | 市场层与 Scan Terminal 数据回放 | 保存 `bucket_label/bucket_direction/model_market_diff/matching_reason`;支持回放第三/第四/第五城市结构化分析 解读失败案例 | 用真实失败样本压回归 |
| 第 4–5 周 | 监控深挖与运维日报 | 来源 SLA、城市维度延迟、structured detail 状态、SQLite 体积、支付事件趋势、异常摘要 | 避免指标过多,先覆盖高频故障 |
| 第 6 周 | 支付合约与发布门禁升级 | SafeERC20/Pausable 方案评审;CI required checks 与 release/tag 流程绑定;热修例外流程 | 合约升级需单独部署验证 |
### 主要风险与缓解策略
**外部 API / AI provider 速率限制与格式变更**AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化;OpenAI-compatible city AI stream 可能出现 timeout、stream JSON 截断或并发竞争。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用);城市 AI 保持小并发队列、30s timeout、stream parse retry、页面内存缓存与 degraded fallback。
**外部 API / AI provider 速率限制与格式变更**AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化;OpenAI-compatible city structured detail 可能出现 timeout、stream JSON 截断或并发竞争。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用);城市结构化分析 保持小并发队列、30s timeout、stream parse retry、页面内存缓存与 degraded fallback。
**密钥泄露与权限滥用**:Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。
**支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。当前项目已经补齐“事件监听 + 确认补单”双路径、事件重放脚本、SQLite 审计事件与多 RPC fallback;现阶段的主要剩余风险不再是“没有防护”,而是链上合约仍为最小实现,owner 为单地址管理,且没有 pause 开关与 SafeERC20。
**引入外部神经天气模型的商业合规风险**GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
**代码公开与生产私有资产边界导致的“公开仓库与生产行为不一致”**:README 明确品牌、商标、生产私有数据与运营阈值不在代码许可证授权范围内。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
## 参考链接
> **v1.7.0 注**:以下 Polymarket 相关链接已不再被项目使用,保留作为历史参考。
- PolyWeather 仓库(本次评估对象):https://github.com/yangyuan-zhen/PolyWeather
- Polymarket API 文档(Gamma/Data/CLOB):https://docs.polymarket.com/api-reference
- AviationWeather Data APIMETAR 等):https://aviationweather.gov/data/api/
@@ -266,5 +270,3 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
- Pangu-Weather(权重 BY-NC-SA,禁商用):https://github.com/198808xc/Pangu-Weather
- Polymarket 官方 Python CLOB SDKMIT):https://github.com/Polymarket/py-clob-client
- aiopolymarketasync、类型安全):https://github.com/the-odds-company/aiopolymarket
+2 -2
View File
@@ -134,7 +134,7 @@
2. **Tab 下划线指示器不够明显**`2px` 高度 + `opacity: 0.8` 的蓝色下划线容易被忽略。
3. **按钮层级不够清晰**`.scan-primary-button`(蓝紫渐变)、`.scan-ai-button`(青绿渐变)、`.scan-ai-city-icon-button`(蓝色边框半透明)、`.scan-theme-button`(无边框无背景)四种视觉权重混在一起,用户难以判断优先级。
3. **按钮层级不够清晰**`.scan-primary-button`(蓝紫渐变)、`.scan-ai-button`(青绿渐变)、`.scan-city-icon-button`(蓝色边框半透明)、`.scan-theme-button`(无边框无背景)四种视觉权重混在一起,用户难以判断优先级。
4. **空状态/加载状态设计不一致**
- 地图加载有精美的云/雷达/热力动画
@@ -233,7 +233,7 @@
- Tab 切换缺少 `role="tablist"`/`role="tab"`/`aria-selected`
- 折叠按钮缺少 `aria-expanded`
2. **焦点指示器不可见**:自定义按钮(如 `scan-theme-button``scan-ai-city-icon-button`)没有 focus-visible 样式
2. **焦点指示器不可见**:自定义按钮(如 `scan-theme-button``scan-city-icon-button`)没有 focus-visible 样式
3. **颜色不是唯一的信息传达方式**:风险等级、Market decision 的色彩编码缺少对应的文字标签或图标补充
Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.
+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
@@ -1,4 +1,4 @@
# PolyWeatherCheckout PolygonScan 验证(v1.7.0
# PolyWeatherCheckout PolygonScan 验证(v1.8.1
最后更新:`2026-03-20`
+8 -9
View File
@@ -1,10 +1,10 @@
# PolyWeather 前端产品审查报告
> 审查日期:2026-06 | 视角:产品经理 | 范围:`frontend/` 全部页面、组件、用户流程
> 审查日期:2026-06 | 视角:产品经理 | 范围:`frontend/` 全部页面、组件、用户流程 | 最后校准:2026-05-28
## 一、产品概览
PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核心价值主张:**结合多模型气象预报 + AI 机场报文解读 + Polymarket 市场价格,为交易决策提供一站式证据链。**
PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核心价值主张:**结合多模型气象预报 + 结构化实况层 + Polymarket 市场价格,为交易决策提供一站式证据链。**
### 产品分层
@@ -12,13 +12,13 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|------|------|------|
| 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 |
| Pro 试用 | 3 天全功能 | 注册后自动获得 |
| Pro 订阅 | 城市决策卡(AI 机场报文 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 10 USDC/月(积分抵扣最多 3 USDC |
| Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 月付 29.9 USDC / 30 天,季度 79.9 USDC / 90 天(月付最多抵 3 USDC,季度最多抵 8 USDC |
### 页面结构(9 个路由)
| 路由 | 功能 | 是否必需登录 |
|------|------|-------------|
| `/` | 主看板 — AI 天气决策台 | 否 |
| `/` | 主看板 — 实时天气决策台 | 否 |
| `/account` | 账户中心 — 身份/订阅/钱包/积分/Bot 绑定 | 是 |
| `/auth/login` | 登录页(Google OAuth + 邮箱密码) | 否 |
| `/docs/[...slug]` | 产品文档中心(8 篇双语文档) | 否 |
@@ -37,7 +37,7 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
│ 点击城市 → 自动添加到决策卡工作区 + 切换到卡片视图 │
│ 免费用户和 Pro 用户均可使用 │
├─ 决策卡(分析) ──────────────────────────────┤
│ 钉选的城市卡片:AI 机场解读 + 模型集群 + 市场层 + 图表
│ 钉选的城市卡片:结构化实况 + 模型集群 + 市场层 + 实时图表 │
│ 需要 Pro 订阅 │
└──────────────────────────────────────────────┘
└── 右侧栏:城市简报面板(始终可见,免费可用)
@@ -46,14 +46,14 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
### 关键用户路径
1. **新用户落地** → 看到 3 步引导 → 地图 + 城市列表 → 点击城市 → 看到城市简报 → 想深入分析 → 遇到 Pro 付费墙(含功能说明)
2. **Pro 用户工作流** → 地图选城市 → 自动钉选到决策卡 → 展开卡片 → 阅读 AI 报文解读 → 查看市场层 → 判断交易方向
2. **Pro 用户工作流** → 地图选城市 → 自动钉选到决策卡 → 展开卡片 → 查看实测/DEB/概率/市场层 → 判断交易方向
## 三、做得好的地方
1. **地图 → 决策卡的自动流转设计** — 点击地图城市自动钉选到分析工作区并切换视图,"零步骤发现"
2. **双语覆盖完整** — 所有 UI 文案、文档、AI 解读都有中英文对照,覆盖率接近 100%
2. **双语覆盖完整** — 所有 UI 文案、文档、结构化解读都有中英文对照,覆盖率接近 100%
3. **数据新鲜度可视化** — DataFreshnessBar 让用户一眼看到 METAR/模型/市场数据的新鲜度
4. **AI 解读的产品化程度高** — 分层展示:快速判断 → 完整解读 → 证据链 → 风险提示
4. **结构化解读的产品化程度高** — 分层展示:快速判断 → 完整解读 → 证据链 → 风险提示
5. **免费层有实际价值** — 地图 + 城市简报不是"空壳",用户可以看真实气象数据
6. **支付链路完整** — 从钱包绑定到链上签约到支付恢复,处理了多种异常情况
7. **空状态有引导文字** — "Click a city on the map" 告诉用户下一步做什么
@@ -67,4 +67,3 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
| 1 | **Docs 无搜索** — 8 篇文档没有搜索功能,用户必须逐篇浏览 | 🟢 待做 |
| 2 | **Ops 面板无审计日志** — 管理员补发积分等操作没有审计记录 | 🟢 需后端 |
| 3 | **注册后邮件验证引导** — 未验证邮箱的用户反复登录失败 | 🟡 需后端 |
@@ -0,0 +1,929 @@
# 终端城市表格按大洲分组 — 实施计划
> **For agentic workers:** 使用 superpowers:subagent-driven-development 或 superpowers:executing-plans 按任务逐步实施。
**目标:** 将扫描终端的平铺城市表格重构为按时区分组 + Active Signals 虚拟分组的金融终端风格,桌面端 9 列可折叠分组表格,移动端 Tab 切换 + 卡片流。
**架构:** 抽取分组逻辑到 `continent-grouping.ts`,新增 `ContinentGroupHeader``MobileCityCard``MobileRegionTabs` 三个子组件,重构 `ScanTerminalDashboard.tsx` 中的 `KoyfinWeatherTerminal``MarketTable`。数据字段全部就绪,后端零改动。
**技术栈:** React 19 + TypeScript + Tailwind CSS 3 + CSS Modules
---
### Task 1: 创建 continent-grouping.ts 分组逻辑
**文件:**
- 创建: `frontend/components/dashboard/scan-terminal/continent-grouping.ts`
这个文件负责所有分组逻辑:按 `trading_region` 分桶、Active Signals 筛选、折叠状态管理、Gap 颜色映射。
- [ ] **Step 1: 写入 continent-grouping.ts**
```typescript
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
// 7 trading regions from backend scan_terminal_filters.py
export const TRADING_REGIONS = [
{ key: "east_asia", labelEn: "East Asia", labelZh: "东亚", sort: 1 },
{ key: "southeast_asia", labelEn: "Southeast Asia", labelZh: "东南亚", sort: 2 },
{ key: "central_asia", labelEn: "Central / South Asia", labelZh: "中亚 / 南亚", sort: 3 },
{ key: "west_asia", labelEn: "West Asia / Middle East", labelZh: "西亚 / 中东", sort: 4 },
{ key: "europe_africa", labelEn: "Europe / Africa", labelZh: "欧洲 / 非洲", sort: 5 },
{ key: "south_america", labelEn: "Latin America", labelZh: "拉美", sort: 6 },
{ key: "north_america", labelEn: "North America", labelZh: "北美", sort: 7 },
] as const;
export type TradingRegionKey = (typeof TRADING_REGIONS)[number]["key"];
export interface ContinentGroup {
key: string; // "active_signals" | TradingRegionKey
labelEn: string;
labelZh: string;
sort: number;
rows: ScanOpportunityRow[];
activeCount: number;
watchCount: number;
hotCity: string | null;
localTimeRange: string | null;
}
export function isActiveSignal(row: ScanOpportunityRow): boolean {
const decision = String(row.ai_decision || row.v4_metar_decision || "").toLowerCase();
if (decision.includes("approve")) return true;
if (row.tradable && row.active) return true;
return false;
}
export function isWatchSignal(row: ScanOpportunityRow): boolean {
const decision = String(row.ai_decision || row.v4_metar_decision || row.signal_status || "").toLowerCase();
if (decision.includes("watch")) return true;
if (decision.includes("monitor")) return true;
if (!row.tradable && row.active) return true;
return false;
}
export function isDeadSignal(row: ScanOpportunityRow): boolean {
if (row.closed) return true;
const decision = String(row.ai_decision || row.v4_metar_decision || "").toLowerCase();
if (decision.includes("veto")) return true;
return false;
}
export function getSignalState(row: ScanOpportunityRow): "active" | "watch" | "closed" | "data" {
if (isDeadSignal(row)) return "closed";
if (isActiveSignal(row)) return "active";
if (isWatchSignal(row)) return "watch";
return "data";
}
export function getSignalLabel(state: ReturnType<typeof getSignalState>, isEn: boolean): string {
switch (state) {
case "active": return isEn ? "◆ Active" : "◆ 活跃";
case "watch": return isEn ? "● Watch" : "● 观察";
case "closed": return isEn ? "○ Closed" : "○ 关闭";
case "data": return isEn ? "! Data" : "! 数据";
}
}
export type GapColor = "green" | "orange" | "slate" | "gray" | "red";
export function getGapColor(row: ScanOpportunityRow): GapColor {
const gap = Number(row.signed_gap ?? row.gap_to_target);
const edge = Number(row.edge_percent || 0);
const spread = Number(row.spread || 0);
const liq = Number(row.book_liquidity || row.market_liquidity || 0);
if (!Number.isFinite(gap)) return "gray";
if (liq <= 0 || spread > 20) return "red";
if (gap >= 2) return "green";
if (gap >= 0 && edge > 5) return "orange";
if (gap >= 0) return "slate";
if (gap < -5 || edge < -10) return "gray";
return "slate";
}
export const GAP_COLOR_MAP: Record<GapColor, string> = {
green: "text-emerald-600",
orange: "text-amber-600",
slate: "text-slate-500",
gray: "text-slate-400",
red: "text-red-500",
};
export function formatPrice(midpoint?: number | null, ask?: number | null, bid?: number | null): string {
const m = Number(midpoint);
if (Number.isFinite(m) && m > 0) {
const cents = Math.round(m * 100);
return `Y ${cents}¢`;
}
const a = Number(ask);
if (Number.isFinite(a) && a > 0) {
const cents = Math.round(a * 100);
return `Y ${cents}¢`;
}
return "--";
}
export function formatSpreadLiquidity(spread?: number | null, liquidity?: number | null): string {
const sp = Number(spread);
const liq = Number(liquidity);
const spStr = Number.isFinite(sp) ? `${Math.round(sp)}¢` : "--";
const liqStr = Number.isFinite(liq)
? liq >= 1000
? `$${(liq / 1000).toFixed(1)}K`
: `$${Math.round(liq)}`
: "--";
return `${spStr} / ${liqStr}`;
}
export function buildContinentGroups(rows: ScanOpportunityRow[], isEn: boolean): ContinentGroup[] {
const regionMap = new Map<string, ScanOpportunityRow[]>();
for (const row of rows) {
const region = String(row.trading_region || "unknown").toLowerCase();
if (!regionMap.has(region)) regionMap.set(region, []);
regionMap.get(region)!.push(row);
}
const groups: ContinentGroup[] = [];
// Active Signals virtual group
const activeRows = rows.filter((r) => isActiveSignal(r));
if (activeRows.length > 0) {
const hotRow = activeRows.reduce((best, r) =>
Number(r.edge_percent || 0) > Number(best.edge_percent || 0) ? r : best
);
groups.push({
key: "active_signals",
labelEn: "Active Signals",
labelZh: "活跃信号",
sort: 0,
rows: activeRows,
activeCount: activeRows.filter((r) => isActiveSignal(r)).length,
watchCount: activeRows.filter((r) => isWatchSignal(r)).length,
hotCity: hotRow?.city_display_name || hotRow?.city || null,
localTimeRange: null,
});
}
for (const region of TRADING_REGIONS) {
const regionRows = regionMap.get(region.key) || [];
if (regionRows.length === 0) continue;
const activeCount = regionRows.filter((r) => isActiveSignal(r)).length;
const watchCount = regionRows.filter((r) => isWatchSignal(r)).length;
const sorted = [...regionRows].sort((a, b) =>
Number(b.final_score || 0) - Number(a.final_score || 0)
);
const hotCity = sorted[0]?.city_display_name || sorted[0]?.city || null;
// Compute local time range for this region
const times = regionRows.map((r) => String(r.local_time || "").trim()).filter(Boolean);
const ltRange = times.length >= 2
? `${times[0]}-${times[times.length - 1]}`
: times[0] || null;
groups.push({
key: region.key,
labelEn: region.labelEn,
labelZh: region.labelZh,
sort: region.sort,
rows: regionRows,
activeCount,
watchCount,
hotCity,
localTimeRange: ltRange,
});
}
// Sort: active_signals first, then by trading_region_sort
groups.sort((a, b) => a.sort - b.sort);
return groups;
}
export function getDefaultExpanded(groups: ContinentGroup[]): Set<string> {
const expanded = new Set<string>();
for (const g of groups) {
if (g.key === "active_signals") {
expanded.add(g.key);
} else if (g.activeCount > 0 || g.watchCount > 0) {
expanded.add(g.key);
}
}
return expanded;
}
```
- [ ] **Step 2: 验证 TypeScript 类型**
```bash
cd frontend && npx tsc --noEmit src/components/dashboard/scan-terminal/continent-grouping.ts 2>&1 || true
```
预期:类型通过(可能有模块路径的错误,这些在组件集成后解决)。
- [ ] **Step 3: 提交**
```bash
git add frontend/components/dashboard/scan-terminal/continent-grouping.ts
git commit -m "新增终端大洲分组逻辑模块"
```
---
### Task 2: 创建 ContinentGroupHeader.tsx 分组标题行组件
**文件:**
- 创建: `frontend/components/dashboard/scan-terminal/ContinentGroupHeader.tsx`
- [ ] **Step 1: 写入组件**
```tsx
"use client";
import { ChevronDown, ChevronRight } from "lucide-react";
import type { ContinentGroup } from "@/components/dashboard/scan-terminal/continent-grouping";
export function ContinentGroupHeader({
group,
isExpanded,
isEn,
onToggle,
}: {
group: ContinentGroup;
isExpanded: boolean;
isEn: boolean;
onToggle: () => void;
}) {
const label = isEn ? group.labelEn : group.labelZh;
const parts: string[] = [`${group.rows.length}`];
if (group.activeCount > 0) {
parts.push(isEn ? `Active ${group.activeCount}` : `活跃 ${group.activeCount}`);
}
if (group.watchCount > 0) {
parts.push(isEn ? `Watch ${group.watchCount}` : `观察 ${group.watchCount}`);
}
if (group.localTimeRange) {
parts.push(`LT ${group.localTimeRange}`);
}
if (group.hotCity) {
parts.push(isEn ? `Hot: ${group.hotCity}` : `热门: ${group.hotCity}`);
}
return (
<button
type="button"
onClick={onToggle}
className="group flex w-full items-center gap-2 border-b border-slate-200 bg-[#eef2f6] px-3 py-2 text-left hover:bg-[#e2e8f0] transition-colors"
>
<span className="grid h-5 w-5 shrink-0 place-items-center text-slate-400 group-hover:text-slate-600">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span className="text-xs font-black uppercase tracking-wide text-slate-600">
{label}
</span>
<span className="text-[11px] text-slate-400">
{parts.join(" · ")}
</span>
</button>
);
}
```
- [ ] **Step 2: 提交**
```bash
git add frontend/components/dashboard/scan-terminal/ContinentGroupHeader.tsx
git commit -m "新增终端大洲分组标题行组件"
```
---
### Task 3: 创建 MobileCityCard.tsx 移动端卡片组件
**文件:**
- 创建: `frontend/components/dashboard/scan-terminal/MobileCityCard.tsx`
- [ ] **Step 1: 写入组件**
```tsx
"use client";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import {
formatPrice,
formatSpreadLiquidity,
GAP_COLOR_MAP,
getGapColor,
getSignalLabel,
getSignalState,
} from "@/components/dashboard/scan-terminal/continent-grouping";
function rowName(row: ScanOpportunityRow) {
return row.city_display_name || row.display_name || row.city || "--";
}
function tempVal(value?: number | null, symbol?: string | null) {
const n = Number(value);
if (!Number.isFinite(n)) return "--";
return `${n.toFixed(1)}${symbol || "°"}`;
}
export function MobileCityCard({
isEn,
onClick,
row,
}: {
isEn: boolean;
onClick: (row: ScanOpportunityRow) => void;
row: ScanOpportunityRow;
}) {
const signal = getSignalState(row);
const gapColor = GAP_COLOR_MAP[getGapColor(row)];
return (
<button
type="button"
onClick={() => onClick(row)}
className="w-full rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm hover:bg-blue-50/70 transition-colors"
>
{/* Row 1: City + Signal */}
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-bold text-slate-900">
{rowName(row)}
</span>
<span className="shrink-0 text-xs font-black">
<span className={signal === "active" ? "text-emerald-600" : signal === "watch" ? "text-amber-600" : signal === "closed" ? "text-slate-400" : "text-red-500"}>
{getSignalLabel(signal, isEn)}
</span>
</span>
</div>
{/* Row 2: Obs · Gap · Market */}
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="font-mono font-bold">
Obs {tempVal(row.current_temp, row.temp_symbol)}
</span>
<span className={gapColor}>
Gap {tempVal(row.signed_gap ?? row.gap_to_target, row.temp_symbol)}
</span>
<span className="text-slate-600">
{formatPrice(row.midpoint, row.ask, row.bid)}
</span>
</div>
{/* Row 3: High · DEB */}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500">
<span>
High {tempVal(row.current_max_so_far, row.temp_symbol)}
</span>
<span>
DEB {tempVal(row.deb_prediction, row.temp_symbol)}
</span>
</div>
</button>
);
}
```
- [ ] **Step 2: 提交**
```bash
git add frontend/components/dashboard/scan-terminal/MobileCityCard.tsx
git commit -m "新增终端移动端城市卡片组件"
```
---
### Task 4: 创建 MobileRegionTabs.tsx 移动端 Tab 栏
**文件:**
- 创建: `frontend/components/dashboard/scan-terminal/MobileRegionTabs.tsx`
- [ ] **Step 1: 写入组件**
```tsx
"use client";
import clsx from "clsx";
import type { ContinentGroup } from "@/components/dashboard/scan-terminal/continent-grouping";
export function MobileRegionTabs({
activeTab,
groups,
isEn,
onSelectTab,
}: {
activeTab: string;
groups: ContinentGroup[];
isEn: boolean;
onSelectTab: (key: string) => void;
}) {
return (
<div className="flex overflow-x-auto border-b border-slate-200 bg-white px-2 no-scrollbar">
{groups.map((g) => {
const label = isEn ? g.labelEn : g.labelZh;
const isActive = activeTab === g.key;
return (
<button
key={g.key}
type="button"
onClick={() => onSelectTab(g.key)}
className={clsx(
"shrink-0 px-3 py-2.5 text-xs font-bold whitespace-nowrap border-b-2 transition-colors",
isActive
? "border-blue-600 text-blue-700"
: "border-transparent text-slate-500 hover:text-slate-700"
)}
>
{label}
<span className="ml-1 text-[10px] text-slate-400">
{g.activeCount > 0 ? `${g.activeCount}A` : g.rows.length}
</span>
</button>
);
})}
</div>
);
}
```
- [ ] **Step 2: 提交**
```bash
git add frontend/components/dashboard/scan-terminal/MobileRegionTabs.tsx
git commit -m "新增终端移动端大洲 Tab 栏组件"
```
---
### Task 5: 重构 ScanTerminalDashboard.tsx — 重写 MarketTable 为分组表格
**文件:**
- 修改: `frontend/components/dashboard/ScanTerminalDashboard.tsx` (替换 `MarketTable` 函数)
这个步骤将现有的 `MarketTable` 替换为支持分组标题行的新版本 `GroupedMarketTable`
- [ ] **Step 1: 替换 MarketTable 组件 (第 245-323 行)**
删除旧 `MarketTable`,替换为:
```tsx
import { ChevronDown, ChevronRight } from "lucide-react";
import {
ContinentGroup,
buildContinentGroups,
formatPrice,
formatSpreadLiquidity,
GAP_COLOR_MAP,
getDefaultExpanded,
getGapColor,
getSignalLabel,
getSignalState,
} from "@/components/dashboard/scan-terminal/continent-grouping";
function GroupedMarketTable({
groups,
isEn,
onSelect,
selectedId,
}: {
groups: ContinentGroup[];
isEn: boolean;
onSelect: (row: ScanOpportunityRow) => void;
selectedId?: string | null;
}) {
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
const c = new Set<string>();
const defaultExpanded = getDefaultExpanded(groups);
for (const g of groups) {
if (!defaultExpanded.has(g.key)) c.add(g.key);
}
return c;
});
const toggleGroup = (key: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
return (
<div className="overflow-auto">
<table className="w-full min-w-[800px] border-collapse text-[13px]">
<thead>
<tr className="border-b border-slate-200 bg-[#f5f7fa] text-left text-[11px] uppercase text-slate-500">
<th className="px-3 py-2 font-black">City</th>
<th className="px-2 py-2 text-right font-black">Obs</th>
<th className="px-2 py-2 text-right font-black">High</th>
<th className="px-2 py-2 text-right font-black">DEB</th>
<th className="px-2 py-2 text-right font-black">Gap</th>
<th className="px-2 py-2 text-right font-black">Market</th>
<th className="px-2 py-2 text-right font-black">Edge</th>
<th className="px-2 py-2 text-right font-black">Spr/Liq</th>
<th className="px-3 py-2 font-black">Signal</th>
</tr>
</thead>
<tbody>
{groups.map((group) => {
const isExpanded = !collapsed.has(group.key);
const label = isEn ? group.labelEn : group.labelZh;
return (
<Fragment key={group.key}>
{/* Group header row */}
<tr className="border-b border-slate-200 bg-[#eef2f6]">
<td colSpan={9} className="p-0">
<button
type="button"
onClick={() => toggleGroup(group.key)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-[#e2e8f0] transition-colors"
>
<span className="grid h-4 w-4 place-items-center text-slate-400">
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
<span className="text-[11px] font-black uppercase tracking-wide text-slate-600">
{label}
</span>
<span className="text-[10px] text-slate-400">
{group.rows.length} · {isEn ? "Active" : "活跃"} {group.activeCount} · {isEn ? "Watch" : "观察"} {group.watchCount}
{group.localTimeRange ? ` · LT ${group.localTimeRange}` : ""}
{group.hotCity ? ` · Hot: ${group.hotCity}` : ""}
</span>
</button>
</td>
</tr>
{/* Data rows */}
{isExpanded &&
group.rows.map((row) => {
const signal = getSignalState(row);
const gapColor = GAP_COLOR_MAP[getGapColor(row)];
return (
<tr
key={row.id}
className={clsx(
"cursor-pointer border-b border-slate-100 hover:bg-blue-50/70",
selectedId === row.id && "bg-blue-50"
)}
onClick={() => onSelect(row)}
>
<td className="px-3 py-2">
<div className="font-bold text-slate-900">{rowName(row)}</div>
<div className="truncate text-[11px] text-slate-500">
{row.airport || ""}{row.local_time ? ` · ${row.local_time}` : ""}
</div>
</td>
<td className="px-2 py-2 text-right font-mono font-bold">
{temp(row.current_temp, row.temp_symbol)}
</td>
<td className="px-2 py-2 text-right font-mono">
{temp(row.current_max_so_far, row.temp_symbol)}
</td>
<td className="px-2 py-2 text-right font-mono">
{temp(row.deb_prediction, row.temp_symbol)}
</td>
<td className={clsx("px-2 py-2 text-right font-mono font-bold", gapColor)}>
{temp(row.signed_gap ?? row.gap_to_target, row.temp_symbol)}
</td>
<td className="px-2 py-2 text-right font-mono">
{formatPrice(row.midpoint, row.ask, row.bid)}
</td>
<td className={clsx("px-2 py-2 text-right font-mono font-bold", edgeClass(row.edge_percent))}>
{pct(row.edge_percent)}
</td>
<td className="px-2 py-2 text-right font-mono text-[11px]">
{formatSpreadLiquidity(row.spread, row.book_liquidity ?? row.market_liquidity)}
</td>
<td className="px-3 py-2">
<span className={clsx(
"text-[11px] font-black",
signal === "active" ? "text-emerald-600" :
signal === "watch" ? "text-amber-600" :
signal === "closed" ? "text-slate-400" : "text-red-500"
)}>
{getSignalLabel(signal, isEn)}
</span>
</td>
</tr>
);
})}
</Fragment>
);
})}
</tbody>
</table>
</div>
);
}
```
需要在本文件顶部新增 `import { Fragment, ... }` from react。
- [ ] **Step 2: 更新 import 语句 (文件第 1 行)**
`import { useEffect, useMemo, useState } from "react";` 改为:
```tsx
import { Fragment, useEffect, useMemo, useState } from "react";
```
- [ ] **Step 3: 验证 TypeScript**
```bash
cd frontend && npx tsc --noEmit 2>&1 | head -30
```
- [ ] **Step 4: 提交**
```bash
git add frontend/components/dashboard/ScanTerminalDashboard.tsx
git commit -m "终端表格重构为9列时区分组布局:新增 City/Obs/High/DEB/Gap/Market/Edge/SprLiq/Signal 列"
```
---
### Task 6: 重构 KoyfinWeatherTerminal — 接入分组逻辑
**文件:**
- 修改: `frontend/components/dashboard/ScanTerminalDashboard.tsx` (修改 `KoyfinWeatherTerminal`)
`KoyfinWeatherTerminal` 中引入 `buildContinentGroups`,将左列 `MarketTable` 替换为 `GroupedMarketTable`,中列 `MarketTable` 替换为 `GroupedMarketTable`,右列 Watchlist 保留列表展示。
- [ ] **Step 1: 修改 KoyfinWeatherTerminal 中的左列表格部分 (第 460-491 行)**
找到左列 `<Panel title="Weather Contracts">` 内的 `<MarketTable>`,替换为:
```tsx
<Panel title={isEn ? "Weather Contracts" : "天气合约"}>
<div className="grid grid-cols-3 border-b border-slate-200 text-center">
<div className="p-3">
<div className="text-[11px] font-black uppercase text-slate-500">
{isEn ? "Rows" : "行数"}
</div>
<div className="font-mono text-xl font-black">{rows.length}</div>
</div>
<div className="border-x border-slate-200 p-3">
<div className="text-[11px] font-black uppercase text-slate-500">
Avg Edge
</div>
<div className={clsx("font-mono text-xl font-black", edgeClass(avgEdge))}>
{pct(avgEdge)}
</div>
</div>
<div className="p-3">
<div className="text-[11px] font-black uppercase text-slate-500">
Liquidity
</div>
<div className="font-mono text-xl font-black">
{money(totalLiquidity)}
</div>
</div>
</div>
<GroupedMarketTable
groups={continentGroups}
isEn={isEn}
selectedId={selectedRow?.id}
onSelect={setSelectedRow}
/>
</Panel>
```
- [ ] **Step 2: 替换中列 MarketTable (第 600-606 行)**
```tsx
<Panel title={isEn ? "All Contracts" : "全部合约"}>
<GroupedMarketTable
groups={continentGroups}
isEn={isEn}
selectedId={selectedRow?.id}
onSelect={setSelectedRow}
/>
</Panel>
```
- [ ] **Step 3: 在 KoyfinWeatherTerminal 中计算 continentGroups**
在组件内部,紧接 `const selectedLabel = ...` 之后添加:
```tsx
const continentGroups = useMemo(
() => buildContinentGroups(rows, isEn),
[rows, isEn]
);
```
- [ ] **Step 4: 提交**
```bash
git add frontend/components/dashboard/ScanTerminalDashboard.tsx
git commit -m "终端三列布局接入大洲分组数据"
```
---
### Task 7: 实现移动端响应式布局 — Tab + 卡片流
**文件:**
- 修改: `frontend/components/dashboard/ScanTerminalDashboard.tsx` (修改 `KoyfinWeatherTerminal` main 区域)
`<main>` 内,用 `useMediaQuery` 或 Tailwind 响应式类区分桌面/移动端布局。移动端显示 `MobileRegionTabs` + `MobileCityCard` 列表。
- [ ] **Step 1: 在 KoyfinWeatherTerminal 中添加移动端状态和渲染**
在组件顶部添加:
```tsx
const [mobileTab, setMobileTab] = useState<string>("active_signals");
```
`continentGroups` 计算后添加:
```tsx
const mobileActiveGroup = useMemo(
() => continentGroups.find((g) => g.key === mobileTab) || continentGroups[0],
[continentGroups, mobileTab]
);
useEffect(() => {
if (continentGroups.length > 0 && !continentGroups.find((g) => g.key === mobileTab)) {
setMobileTab(continentGroups[0].key);
}
}, [continentGroups, mobileTab]);
```
- [ ] **Step 2: 修改 main 区域为响应式双布局 (第 458-673 行)**
`<main>` 中包裹条件渲染:
```tsx
<main className="min-h-0 flex-1 overflow-auto p-2">
{/* Mobile layout */}
<div className="flex flex-col gap-2 lg:hidden">
<MobileRegionTabs
activeTab={mobileTab}
groups={continentGroups}
isEn={isEn}
onSelectTab={setMobileTab}
/>
<div className="space-y-2 px-1">
{mobileActiveGroup?.rows.map((row) => (
<MobileCityCard
key={row.id}
row={row}
isEn={isEn}
onClick={setSelectedRow}
/>
))}
</div>
</div>
{/* Desktop layout (existing 3-column grid) */}
<div className="hidden min-h-full grid-cols-1 gap-2 lg:grid xl:grid-cols-[1.12fr_1.6fr_1.1fr]">
{/* ... existing 3 columns unchanged ... */}
</div>
</main>
```
现有的三列布局代码放入 `{/* Desktop layout */}` 块中,用 `hidden lg:grid` 控制显示。
- [ ] **Step 3: 验证 TypeScript**
```bash
cd frontend && npx tsc --noEmit 2>&1 | head -40
```
- [ ] **Step 4: 提交**
```bash
git add frontend/components/dashboard/ScanTerminalDashboard.tsx
git commit -m "终端新增移动端 Tab+卡片流响应式布局"
```
---
### Task 8: 添加 CSS 样式 — 分组行和移动端卡片
**文件:**
- 创建: `frontend/components/dashboard/ScanTerminalContinent.module.css`
- 修改: `frontend/components/dashboard/scan-root-styles.ts`
- [ ] **Step 1: 写入 CSS Module**
```css
/* ScanTerminalContinent.module.css */
.root {
--gap-green: #16a34a;
--gap-orange: #ea580c;
--gap-slate: #475569;
--gap-gray: #94a3b8;
--gap-red: #dc2626;
}
/* Group header */
.groupHeader {
background: #eef2f6;
border-bottom: 1px solid #cbd5e1;
}
.groupHeader:hover {
background: #e2e8f0;
}
/* Mobile: hide scrollbar on tab bar */
.mobileTabs {
scrollbar-width: none;
-ms-overflow-style: none;
}
.mobileTabs::-webkit-scrollbar {
display: none;
}
/* Mobile card */
.mobileCard {
border: 1px solid #e2e8f0;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.mobileCard:hover {
background: #f0f7ff;
}
/* Signal badge */
.signalActive {
color: #059669;
}
.signalWatch {
color: #d97706;
}
.signalClosed {
color: #94a3b8;
}
.signalData {
color: #dc2626;
}
/* Light theme */
:global(html.light) .groupHeader {
background: #f8fafc;
border-bottom-color: #e2e8f0;
}
:global(html.light) .groupHeader:hover {
background: #f1f5f9;
}
:global(html.light) .mobileCard {
background: #ffffff;
border-color: #e2e8f0;
}
```
- [ ] **Step 2: 注册到 scan-root-styles.ts barrel**
读取 `frontend/components/dashboard/scan-root-styles.ts`,追加:
```typescript
import sContinent from "@/components/dashboard/ScanTerminalContinent.module.css";
// ... add sContinent.root to the barrel export
```
- [ ] **Step 3: 提交**
```bash
git add frontend/components/dashboard/ScanTerminalContinent.module.css frontend/components/dashboard/scan-root-styles.ts
git commit -m "新增终端大洲分组与移动端卡片样式"
```
---
### Task 9: 最终验证与构建
- [ ] **Step 1: TypeScript 类型检查**
```bash
cd frontend && npx tsc --noEmit
```
预期:0 errors。
- [ ] **Step 2: 生产构建**
```bash
cd frontend && npm run build
```
预期:✓ Compiled successfully66 pages generated。
- [ ] **Step 3: 启动 dev server 验证 UI**
```bash
cd frontend && npm run dev
```
打开 http://localhost:3000/terminal 验证:
- 桌面端:Active Signals 展开显示,时区分组折叠/展开,9 列表格
- 移动端:Tab 切换流畅,卡片流显示正常
- 双主题切换无样式错误
- [ ] **Step 4: 提交**
```bash
git add -A
git commit -m "终端大洲分组功能验证通过"
```
@@ -0,0 +1,26 @@
# Live Temperature Chart Split 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:** Split `LiveTemperatureThresholdChart.tsx` so chart data generation and SSE patch merge logic live in focused pure modules, while the visible chart behavior stays unchanged.
**Architecture:** Keep the React component as the orchestration/rendering layer. Move types, constants, time helpers, series builders, fallback rules, and patch merge into adjacent modules under `frontend/components/dashboard/scan-terminal/`. Preserve existing test exports through the component file during the first split to avoid changing test callers.
**Tech Stack:** Next.js/React, TypeScript, Recharts, existing business-state test runner.
---
## Files
- Create: `frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts`
- Modify: `frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx`
- Modify: `frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts`
- Verify: `frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts`
## Tasks
- [ ] Add a failing architecture test that rejects keeping all chart data builders inside `LiveTemperatureThresholdChart.tsx`.
- [ ] Extract pure chart logic into `temperature-chart-logic.ts`.
- [ ] Import extracted functions/types back into `LiveTemperatureThresholdChart.tsx`.
- [ ] Keep the current test-only exports stable from `LiveTemperatureThresholdChart.tsx`.
- [ ] Run `npm run test:business`, `npm run typecheck`, and `npm run build`.
@@ -0,0 +1,71 @@
# Production Realtime SSE Patch 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:** Upgrade PolyWeather terminal charts from in-process best-effort SSE patches to a replayable, city-scoped, versioned realtime observation stream for PM highest-temperature prediction workflows.
**Architecture:** Keep HTTP APIs as the full snapshot/source-of-truth layer. Add a short-window SQLite event log for SSE replay, version observations as `city_observation_patch.v1`, fan out live events through the existing SSE manager, and let the frontend subscribe only to visible cities with `since_revision` reconnect replay.
**Tech Stack:** FastAPI, SQLite/WAL via `DBManager`, in-process `asyncio.Queue` SSE fanout, React/Next.js `EventSource`, TypeScript external store hooks.
---
## Constraints
- Default replay retention is 6 hours because this event log is not the business history store.
- The retention can be raised with `POLYWEATHER_PATCH_EVENT_RETENTION_HOURS`, but the product does not need all-day patch retention.
- First production step is SQLite-only; Redis/Postgres pub/sub remains a later multi-instance extension.
- Existing legacy `city_patch` ingest and frontend handling must keep working during rollout.
## Tasks
- [ ] Backend schema tests
- Add tests proving legacy collector payloads normalize to `city_observation_patch.v1`.
- Cover runway point conversion from `amos.runway_obs.point_temperatures`.
- Cover invalid payload rejection when city and useful observation data are missing.
- [ ] Backend schema implementation
- Add `web/realtime_patch_schema.py`.
- Normalize city/source/obs time/temp/max/runway payload fields.
- Keep payload small and JSON-serializable.
- [ ] Event store tests
- Add tests for monotonic SQLite revisions.
- Add city-filtered replay tests.
- Add retention cleanup tests for stale replay rows.
- [ ] Event store implementation
- Add `observation_patch_events` table and indexes in `DBManager`.
- Add `web/realtime_event_store.py` for append, replay, latest revision, and cleanup.
- Use `POLYWEATHER_PATCH_EVENT_RETENTION_HOURS=6` as the default.
- [ ] SSE replay tests
- Add tests for `/api/events?cities=...&since_revision=...`.
- Verify replay only returns subscribed cities.
- Verify replay over limit emits `resync_required`.
- [ ] SSE implementation
- Update router to parse `cities`, `since_revision`, and bounded `replay_limit`.
- Write normalized events to SQLite before broadcasting.
- Update manager to track per-connection city subscriptions while keeping heartbeat behavior.
- [ ] Frontend SSE tests
- Extend architecture tests for v1 schema, `cities`, `since_revision`, replay/resync handling.
- Add chart merge coverage for v1 runway point payloads.
- [ ] Frontend SSE implementation
- Update `use-sse-patches.ts` to normalize v1 and legacy patch events.
- Track global `lastRevision`.
- Reconnect with visible-city `cities` and `since_revision`.
- Expose a resync signal for charts when the server cannot replay.
- [ ] Chart/list integration
- Ensure visible charts register their city subscription.
- Keep terminal row patching lightweight: current temp, current max, local time, revision.
- Append v1 temp/runway points into existing chart series without forcing full-detail polling.
- [ ] Verification
- Run targeted backend pytest files.
- Run `npm run test:business`.
- Run `npm run typecheck`.
- Run `npm run build`.
@@ -0,0 +1,107 @@
# Redis Stream Realtime Event Architecture 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:** Add Redis Stream as the production realtime event store and live fanout source while preserving the existing SSE patch protocol.
**Implementation status (2026-05-28):** completed in `v1.8.1`. Production uses Redis Stream when `POLYWEATHER_EVENT_STORE=redis`; SQLite remains the local/fallback event log. Browser API and frontend contract stayed on `/api/events` + `city_observation_patch.v1`.
**Architecture:** Keep `city_observation_patch.v1` and numeric `revision` as the browser contract. Add a Redis-backed event store beside the existing SQLite store, select it through an event-store factory, and let Redis-backed deployments fan out live events through a Redis subscriber loop instead of direct in-process ingest broadcast.
**Tech Stack:** FastAPI, redis-py, Redis Stream, SQLite fallback, pytest, existing frontend EventSource hook.
---
### Task 1: Redis Store Contract
**Files:**
- Create: `tests/test_redis_realtime_event_store.py`
- Create: `web/redis_realtime_event_store.py`
- [x] **Step 1: Write failing tests**
Cover append, replay by city, replay gap, and fallback-independent event shape.
- [x] **Step 2: Verify tests fail**
Run: `python -m pytest tests/test_redis_realtime_event_store.py -q`
Expected: fails because `web.redis_realtime_event_store` does not exist.
- [x] **Step 3: Implement Redis store**
Add `RedisRealtimeEventStore` with `append_event`, `latest_revision`, `replay_events`, `replay_requires_resync`, and idempotent `start_live_subscription`.
- [x] **Step 4: Verify tests pass**
Run: `python -m pytest tests/test_redis_realtime_event_store.py -q`
### Task 2: Store Factory And SSE Router
**Files:**
- Create: `web/realtime_event_store_factory.py`
- Modify: `web/routers/sse_router.py`
- Test: `tests/test_sse_replay.py`
- [x] **Step 1: Write failing tests**
Cover `POLYWEATHER_EVENT_STORE=redis` selecting Redis, SQLite fallback when Redis is not configured, and Redis ingest not doing direct local broadcast.
- [x] **Step 2: Verify tests fail**
Run: `python -m pytest tests/test_sse_replay.py tests/test_realtime_event_store_factory.py -q`
- [x] **Step 3: Implement factory and router integration**
Use Redis store when configured; otherwise keep existing SQLite store. Ingest broadcasts directly only for stores that do not provide external live fanout.
- [x] **Step 4: Verify tests pass**
Run: `python -m pytest tests/test_sse_replay.py tests/test_realtime_event_store_factory.py -q`
### Task 3: Dependency And Operational Config
**Files:**
- Modify: `requirements.txt`
- Modify: `docs/superpowers/specs/2026-05-27-redis-stream-realtime-event-architecture-design.md`
- [x] **Step 1: Add redis-py dependency**
Add `redis>=5.0.0` to `requirements.txt`.
- [x] **Step 2: Add final config notes**
Ensure the design doc names the runtime env vars used by code.
### Task 4: Verification
**Files:**
- No production edits unless tests reveal a gap.
- [x] **Step 1: Run backend realtime tests**
Run:
```powershell
python -m pytest tests/test_realtime_patch_schema.py tests/test_realtime_event_store.py tests/test_redis_realtime_event_store.py tests/test_realtime_event_store_factory.py tests/test_sse_replay.py -q
```
- [x] **Step 2: Run broader frontend/backend checks**
Run:
```powershell
cd frontend
npm run test:business
npm run typecheck
npm run build
```
- [x] **Step 3: Inspect diff**
Run:
```powershell
git diff --check
git status --short
```
@@ -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,137 @@
# 终端城市表格按大洲分组 — 设计文档
日期:2026-05-25
状态:已确认
## 目标
将扫描终端(Scan Terminal)的城市列表从平铺表格改造为按时区分组、按交易信号优先的金融终端风格布局。
## 数据
所有字段已在 `/api/scan/terminal` 响应的 `ScanOpportunityRow` 中就绪,无需后端改动。
### 列 → 字段映射
| 列名 | 字段 | 类型 |
|------|------|------|
| City | `city`, `airport`, `local_time` | `string` |
| Obs | `current_temp` | `number \| null` |
| High | `current_max_so_far` | `number \| null` |
| DEB | `deb_prediction` | `number \| null` |
| Gap | `signed_gap` / `gap_to_target` | `number \| null` |
| Market | `midpoint`, `bid`, `ask` | `number \| null` |
| Edge | `edge_percent` | `number \| null` |
| Spr/Liq | `spread`, `book_liquidity` | `number \| null` |
| Signal | `signal_status`, `ai_decision` | `string \| null` |
## 时区分组
后端 `scan_terminal_filters.py` 按 UTC 偏移量划分 7 个交易区域:
| 排序 | 区域键 | 英文 | 中文 | UTC 条件 |
|------|--------|------|------|----------|
| 1 | east_asia | East Asia | 东亚 | >= +8 |
| 2 | southeast_asia | Southeast Asia | 东南亚 | >= +7 |
| 3 | central_asia | Central / South Asia | 中亚/南亚 | >= +5 |
| 4 | west_asia | West Asia / Middle East | 西亚/中东 | >= +3 |
| 5 | europe_africa | Europe / Africa | 欧洲/非洲 | >= 0 |
| 6 | south_america | Latin America | 拉美 | >= -5 |
| 7 | north_america | North America | 北美 | < -5 |
## 桌面端布局(≥ 768px
### 虚拟分组:"Active Signals"
在 7 个时区分组上方插入一个虚拟分组,聚合当前最重要的信号:
- 筛选条件:`ai_decision === "approve"``tradable === true``peak``rising``超预期`
- 标题行显示:城市数量、Hot 城市、更新时间
- 格式:`▼ Active Signals 活跃信号 6 · Hot: Tokyo · Updated 14:32`
### 时区分组标题行
- 显示:折叠箭头、中英文名、城市数、active 数、watch 数、本地时间
- 热门城市标注
- 格式:`▼ East Asia 东亚 5 · Active 2 · Watch 1 · LT 14:20`
- 可折叠,默认规则:
- Active Signals 始终展开
- 包含 active/watch 的时区展开
- 无机会的时区折叠
### 表格行
- 9 列:City | Obs | High | DEB | Gap | Market | Edge | Spr/Liq | Signal
- Market 列显示价格格式:`Y 72¢` / `N 28¢`
- Signal 列显示:`◆ Active` / `● Watch` / `○ Closed` (文字+图标)
### Gap 列颜色语义
| 状态 | 颜色 | 含义 |
|------|------|------|
| 已突破/超预期 | 绿色 `#22c55e` | 对市场方向有利 |
| 接近目标未突破 | 橙色 `#f59e0b` | Peak Watch |
| 明显低于目标 | 蓝灰 `#64748b` | 尚不到位 |
| 追不上/时间不够 | 灰色 `#94a3b8` | 机会减弱 |
| 数据异常/风险 | 红色 `#ef4444` | 需关注 |
## 移动端布局(< 768px
### Tab 栏
横向滚动,包含:`All | Active | 东亚 | 东南亚 | 中亚南亚 | 西亚 | 欧洲非洲 | 北美 | 拉美`
当前时区 Tab 默认选中。
### 信号卡片流
每张卡片两行信息:
```
┌──────────────────────────────┐
│ Tokyo · ◆ Active │
│ Obs 28.1°C · Gap -0.8°C · Y 72¢ │
│ High 29.3°C · DEB 30.0°C │
└──────────────────────────────┘
```
卡片字段:City、Signal、Obs、Gap、Market(第一行),High、DEB(第二行)
## 文件变更范围
### 主要文件
| 文件 | 变更 | 说明 |
|------|------|------|
| `components/dashboard/ScanTerminalDashboard.tsx` | 重构 | 拆分 `MarketTable``KoyfinWeatherTerminal`,新增分组和卡片组件 |
| `components/dashboard/scan-terminal/` | 新增 | `ContinentGroupHeader.tsx``ActiveSignalsGroup.tsx``MobileCityCard.tsx``MobileRegionTabs.tsx` |
| `components/dashboard/scan-terminal/continent-grouping.ts` | 新增 | 分组逻辑:按 `trading_region` 分桶、Active Signals 筛选、折叠状态管理 |
| `components/dashboard/scan-terminal/decision-utils.ts` | 修改 | 补充排序逻辑,Active Signals 置顶 |
| `components/dashboard/ScanTerminalDashboard.module.css` | 修改 | 新增分组标题行、卡片流、Tab 栏样式 |
| `components/dashboard/ScanTerminalLightTheme.module.css` | 新增/修改 | 浅色主题适配 |
### 不改的文件
- 后端 API:所有字段已就绪
- `ScanOpportunityRow` 类型定义:字段充足
- 数据获取 hooks`useScanTerminalQuery` 保持不变
## 信号状态枚举
```
◆ Active — approve / tradable + active
● Watch — watch / monitor / peak_watch
○ Closed — veto / closed / inactive
! Data — 数据异常/缺失/延迟
```
## 验收标准
1. 桌面端:7 时区 + Active Signals 分组,可折叠,9 列表格
2. 移动端:Tab 切换 + 卡片流
3. Gap 颜色按语义映射,非简单正负色
4. Market 列显示价格格式(Y XX¢)
5. 默认折叠规则:Active Signals 展开、有机遇展开、无机遇折叠
6. 双主题(dark/lightCSS 适配
7. TypeScript 类型检查通过
8. `npm run build` 通过
@@ -0,0 +1,319 @@
# Production Realtime SSE Patch Design
> 日期: 2026-05-26
> 范围: PolyWeather 网站终端图表的生产级实时观测增量层
> 目标服务器: 2 vCPU / 8 GB RAM / 50 GB 系统盘
## 背景
当前网站已经有 SSE patch 通道,但它更像一个进程内即时广播层:
- 后端 `web/sse_manager.py` 用进程内 `asyncio.Queue` 管连接。
- 采集器通过 `/api/internal/collector-patch` 推送城市温度变化。
- 前端 `use-sse-patches.ts``EventSource` 接收 patch,并把温度合并到终端列表和图表。
这个结构能做轻量实时更新,但还不是生产级“像股票行情一样”的实时架构。主要缺口是:
- 断线后没有 replay event log。
- 连接不能按城市订阅,只能广播后让前端筛选。
- patch schema 没有版本化,后续扩展跑道、站点、最高温字段风险较高。
- 多实例共享 pub/sub 还没有抽象边界。
## 目标
把当前实时层升级为“HTTP 快照基线 + SSE 增量观测事件 + 可重放事件日志”的架构:
- 页面首屏仍通过 HTTP snapshot 加载完整 terminal rows / city detail。
- 实测温度、站点观测、跑道观测通过 SSE 增量追加到图表。
- 浏览器断线重连后可以通过 `since_revision` 补发错过的事件。
- 前端只订阅当前可见城市,例如 2x2 图表中的四个城市。
- patch payload 明确使用 `city_observation_patch.v1` schema。
- 第一版用 SQLite event log 适配当前服务器规格,预留 Redis/Postgres event bus 接口。
## 非目标
第一版不做这些事:
- 不把全部 terminal state 改成 event sourcing。
- 不用 SSE 取代 `/api/scan/terminal``/api/city/{name}/detail`
- 不在第一版强依赖 Redis。
- 不把 DEB、多模型、概率分布改成分钟级实时更新。
- 不做 WebSocket;继续使用 SSE,因为浏览器端简单、代理友好、适合单向行情流。
## 推荐架构
```mermaid
flowchart LR
Collector["WeatherDataCollector"] --> Ingest["POST /api/internal/collector-patch"]
Ingest --> Normalize["Patch schema normalizer"]
Normalize --> Log["SQLite observation_patch_events"]
Normalize --> Bus["PatchEventBus"]
Bus --> SSE["GET /api/events?cities=...&since_revision=..."]
API["HTTP snapshot APIs"] --> FE["Frontend terminal/chart state"]
SSE --> FE
FE -->|gap / stale / reconnect| API
```
## 事件存储
新增 SQLite 表 `observation_patch_events`,由 `DBManager` 初始化。
字段:
- `revision INTEGER PRIMARY KEY AUTOINCREMENT`
- `schema_type TEXT NOT NULL`
- `schema_version INTEGER NOT NULL`
- `city TEXT NOT NULL`
- `source TEXT NOT NULL`
- `obs_time TEXT`
- `payload_json TEXT NOT NULL`
- `created_at TEXT NOT NULL`
索引:
- `idx_observation_patch_events_city_revision(city, revision)`
- `idx_observation_patch_events_created_at(created_at)`
保留策略:
- 默认保留 6 小时。
- event log 只用于 SSE 断线 replay,不作为日内历史曲线归档。
- 完整日内曲线仍由 `/api/city/{name}/detail` 从现有观测存储和 city cache 构建。
- 6 小时窗口覆盖浏览器短线重连、页面休眠恢复和用户午后交易窗口内的 replay;超过窗口直接触发 HTTP resync。
- 每次写入后低频触发清理,避免每条事件都扫表。
- 环境变量:`POLYWEATHER_PATCH_EVENT_RETENTION_HOURS=6`
## Patch Schema v1
事件类型固定为:
```json
{
"type": "city_observation_patch.v1",
"revision": 12345,
"city": "shanghai",
"source": "amsc_awos",
"obs_time": "2026-05-26T10:02:00Z",
"ts": 1780000000000,
"payload": {
"temp": 30.4,
"max_so_far": 31.0,
"station_code": "ZSPD",
"station_label": "Shanghai Pudong",
"series_key": "airport_primary",
"unit": "celsius",
"runway_points": [
{
"runway": "17L/35R",
"temp": 30.8,
"tdz_temp": 30.5,
"mid_temp": 30.6,
"end_temp": 30.8,
"is_settlement": true
}
]
}
}
```
兼容要求:
- 前端仍接受旧 `city_patch` 一段时间,避免部署顺序问题。
- 新事件统一转换成前端内部 `CityPatch`
- 后端 ingest 接受旧字段 `changes`,但写入 event log 时规范化成 v1。
## SSE API
`GET /api/events`
查询参数:
- `cities`: 逗号分隔城市 key,例如 `shanghai,hong kong,taipei`
- `since_revision`: 客户端上次处理过的最大 revision
- `replay_limit`: 最大补发数量,默认 500,上限 2000
连接行为:
1. 建立连接。
2. 返回 `connected` event,包含当前 server revision。
3. 如果传入 `since_revision`,先从 SQLite 补发匹配城市的历史事件。
4. 进入 live stream,只推送匹配城市的事件。
5. 每 30 秒发送 heartbeat。
gap 处理:
- 如果 `since_revision` 太旧,超过保留窗口或补发上限,服务端发送:
```json
{
"type": "resync_required",
"reason": "replay_window_exceeded",
"latest_revision": 12345
}
```
- 前端收到后对当前可见城市调用 HTTP full detail resync。
## 后端模块边界
新增或调整模块:
- `web/realtime_patch_schema.py`
- 负责规范化旧 `city_patch` 输入为 `city_observation_patch.v1`
- 负责校验城市、温度、时间、source。
- `web/realtime_event_store.py`
- 封装 SQLite event log 写入、查询、保留清理。
- 使用 `DBManager` 的 DB path。
- `web/sse_manager.py`
- 从纯进程队列升级为基于 `PatchEventBus` 的连接管理。
- 保留进程内 live fanout。
- 每个连接记录订阅城市集合。
- `web/routers/sse_router.py`
- 解析 `cities``since_revision``replay_limit`
- ingest 后先写 event log,再广播 live event。
第一版 `PatchEventBus`
- `SQLitePatchEventBus`
- 写入 SQLite event log。
- 进程内广播给当前 worker 的连接。
后续可新增:
- `RedisPatchEventBus`
- `PostgresPatchEventBus`
## 前端模块边界
调整:
- `frontend/hooks/use-sse-patches.ts`
- 支持 `city_observation_patch.v1`
- 记录 `lastRevision`
- 支持按可见城市集合建立 SSE URL。
- 收到 `resync_required` 时通知图表重拉。
- `frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts`
- 继续负责 terminal rows 的 HTTP snapshot。
- 消费 patch 时只更新轻量字段:当前温度、最高温、观测时间。
- `frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx`
- 消费 patch 时追加分钟级观测点。
- 对 runway patch 更新对应 runway series。
- resync required 或长时间无 patch 时重拉 `/api/city/{name}/detail`
城市订阅策略:
- 2x2 / 3x3 图表只订阅当前 slots 中的城市。
- 移动端只订阅当前主图城市。
- 切换布局或城市后重建 SSE 连接。
## 数据流
### 首屏加载
1. 前端调用 `/api/scan/terminal` 得到 terminal rows。
2. 每个可见图表调用 `/api/city/{name}/detail` 得到完整基线曲线。
3. 前端用可见城市列表连接 `/api/events?cities=...&since_revision=...`
### 新观测进入
1. `WeatherDataCollector` 发现温度或跑道观测变化。
2. POST `/api/internal/collector-patch`
3. 后端规范化为 `city_observation_patch.v1`
4. 写入 SQLite event log,生成 revision。
5. 广播给订阅该城市的 SSE 连接。
6. 前端追加图表点并更新 terminal row。
### 断线重连
1. 前端保留 `lastRevision`
2. EventSource 断线后指数退避重连。
3. 重连 URL 带 `since_revision=lastRevision`
4. 后端 replay missed events。
5. 如果 replay 不完整,返回 `resync_required`,前端 HTTP 重拉。
## 错误处理
- ingest payload 无 city 或无有效 temp/runway point:返回 400。
- event log 写入失败:返回 500,不广播,避免前端看到不可 replay 的事件。
- SSE replay 查询失败:发送 `resync_required`,然后继续 heartbeat/live stream。
- 前端收到未知 schema:忽略并记录 debug log。
- 前端 revision 倒退:忽略。
- 前端 revision 跳号:标记 gap,并触发 HTTP resync。
## 测试策略
后端:
- `tests/test_realtime_patch_schema.py`
- 旧 `city_patch` 输入能规范化成 `city_observation_patch.v1`
- 跑道 payload 保留 runway point。
- 无效 city/temp 返回校验失败。
- `tests/test_realtime_event_store.py`
- append event 生成单调 revision。
- replay 按 city 过滤。
- retention cleanup 删除旧事件。
- `tests/test_sse_replay.py`
- `/api/events?cities=...&since_revision=...` 只 replay 匹配城市。
- replay window 超限返回 `resync_required`
前端:
- `frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts`
- 确认 v1 schema、city subscription、since_revision URL 存在。
- `frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts`
- 确认 patch 追加分钟级观测点。
- 确认 runway patch 更新对应 runway series。
验收命令:
```powershell
pytest tests/test_realtime_patch_schema.py tests/test_realtime_event_store.py tests/test_sse_replay.py
cd frontend
npm run test:business
npm run typecheck
npm run build
```
## 部署与容量
当前服务器 2 vCPU / 8 GB / 50 GB 足够第一版 SQLite-first
- 30 个高频城市,每分钟 1 条事件,约 43,200 条/天。
- 默认只保留 6 小时,约 10,800 条事件。
- payload 控制在 1-3 KBSQLite event log 体积预计维持在几十 MB 内。
- PM 最高温预测市场不需要把当天所有 patch 都保留在 event log;超过 replay 窗口时用 HTTP detail 重建当前画面。
运行建议:
- SQLite 使用 WAL。
- event log 与现有 `POLYWEATHER_DB_PATH` 同库。
- 先保持单 backend worker,避免进程内 fanout 跨 worker 不一致。
- 多实例或多 worker 时再启用 Redis bus。
## 后续 Redis 扩展
当部署变成多实例或多 worker 后,新增 `RedisPatchEventBus`
- SQLite 仍作为 replay log。
- Redis Pub/Sub 或 Redis Stream 负责跨实例 live fanout。
- 每个 worker 从 Redis 订阅 live event,再投递给本进程 SSE 连接。
这个扩展不改变前端协议。
## 验收标准
- 页面加载后可见图表只订阅当前城市。
- 高频城市新观测进入后,图表在无需手动刷新下追加新点。
- 浏览器断线重连后,能补齐断线期间的事件。
- replay 超窗时,前端自动 HTTP resync。
- 未订阅城市的事件不会推送到该连接。
- 旧 `city_patch` 输入在迁移期仍可用。
- 所有新增行为有自动化测试覆盖。
@@ -0,0 +1,408 @@
# Redis Stream Realtime Event Architecture Design
> 日期: 2026-05-27
> 范围: PolyWeather 网站图表实时观测事件层
> 目标服务器: 2 vCPU / 8 GB RAM / 50 GB 系统盘
> 实施状态: 2026-05-28 已落地为 `v1.8.1`
## 背景
当前实时层已经具备生产化雏形:
- 后端已有 `city_observation_patch.v1` schema。
- `web/realtime_event_store.py` 使用 SQLite 保存 `observation_patch_events`,支持 `since_revision` replay。
- `/api/events?cities=...&since_revision=...&replay_limit=...` 已经是前端 SSE 入口。
- 前端 `frontend/hooks/use-sse-patches.ts` 使用 `EventSource`,并按当前可见城市订阅。
这解决了单进程、单实例下的实时推送和短窗口 replay,但还不够“一步到位”:
- 多 worker / 多实例时,进程内广播不能共享。
- SQLite replay log 可以补数据,但不适合作为跨实例 live fanout。
- 服务重启、浏览器后台恢复、SSE 断线后,需要更稳定的事件源。
- 后续如果把采集器拆成独立进程,需要一个明确的后端事件总线。
## 目标
升级为“Redis Stream 主事件日志 + SSE 浏览器传输 + SQLite fallback”的架构:
- Redis Stream 是生产环境实时事件主通道。
- SSE 仍然是浏览器唯一实时入口。
- 前端协议保持 `city_observation_patch.v1`,不让浏览器感知 Redis。
- 继续使用 numeric `revision`,兼容现有前端 `since_revision` 逻辑。
- 支持按城市订阅、断线 replay、多 worker fanout。
- 事件只保留短窗口;当前默认按 Redis Stream `MAXLEN` 控制,建议以约 24 小时 replay 为目标,不做 72 小时保留。
## 非目标
这次不做以下事情:
- 不引入 Kafka、RabbitMQ 或复杂 consumer group。
- 不把天气历史数据全部 event sourcing。
- 不让浏览器直接连接 Redis。
- 不把 DEB 预测、模型曲线、概率分布都改成事件流。
- 不做永久行情归档;超过 replay 窗口时继续走 HTTP full detail resync。
## 推荐架构
```mermaid
flowchart LR
Collector["WeatherDataCollector / city refresh"] --> Ingest["POST /api/internal/collector-patch"]
Ingest --> Normalize["city_observation_patch.v1 normalizer"]
Normalize --> Store["RealtimeEventStore interface"]
Store --> Redis["Redis Stream: stream:city_observation"]
Store --> SQLite["SQLite fallback: observation_patch_events"]
Redis --> WorkerSub["Redis XREAD subscriber per API worker"]
WorkerSub --> LocalFanout["Local SSE fanout"]
SQLite --> ReplayFallback["SQLite replay fallback"]
SSE["GET /api/events?cities=...&since_revision=..."] --> Browser["Frontend EventSource"]
LocalFanout --> SSE
Redis --> SSE
ReplayFallback --> SSE
Browser --> Chart["Chart applies patch"]
Browser -->|resync_required| Detail["HTTP /api/city/{city}/detail"]
```
关键原则:
- Redis Stream 负责后端事件保存、短窗口 replay、多实例共享。
- SSE 负责服务端到浏览器的单向推送。
- SQLite 只做 fallback 和本地开发,不再作为生产主事件总线。
- 前端只认 patch event,不关心事件来自 Redis 还是 SQLite。
## 事件模型
继续使用现有 schema
```json
{
"type": "city_observation_patch.v1",
"revision": 12345,
"city": "taipei",
"source": "cwa",
"obs_time": "2026-05-27T10:00:00+08:00",
"observed_at_utc": "2026-05-27T02:00:00Z",
"observed_at_local": "2026-05-27T10:00:00+08:00",
"city_local_date": "2026-05-27",
"city_timezone": "Asia/Taipei",
"source_cadence_sec": 600,
"ts": 1780000000000,
"payload": {
"temp": 34.2,
"max_so_far": 34.2,
"station_code": "466920",
"station_label": "中央气象署台北站",
"series_key": "settlement",
"unit": "celsius"
}
}
```
`revision` 仍然是前端公开排序键,必须是全局单调递增整数。
Redis Stream 自身的 stream ID,例如 `1780000000000-0`,只作为后端内部定位信息,不暴露给前端作为主 revision。
## Redis Stream 设计
Stream key:
```text
stream:city_observation
```
Revision key:
```text
counter:city_observation_revision
```
每条 stream entry 字段:
- `revision`: 全局递增整数,公开给 SSE/前端。
- `type`: `city_observation_patch.v1`
- `city`: normalized city key。
- `source`: source code,例如 `cwa`, `metar`, `amos`, `amsc_awos`
- `obs_time`: 原始观测时间。
- `payload_json`: compact JSON。
- `created_at_ms`: 写入 Redis 的 UTC epoch ms。
- `producer_id`: API worker / collector instance id,便于排查。
写入必须通过 Lua 脚本原子完成:
1. `INCR counter:city_observation_revision`
2. `XADD stream:city_observation MAXLEN ~ {maxlen} * revision ... payload_json ...`
3. 返回 `{revision, stream_id}`
原因:
- 避免 `INCR` 成功但 `XADD` 失败后造成非必要 gap。
- 让 revision 分配和事件入流成为一个原子操作。
- 保持现有前端 numeric `since_revision` 不变。
## Replay 策略
`GET /api/events` 收到 `since_revision` 后:
1. 读取 Redis Stream 最旧 entry,拿到 oldest revision。
2. 如果 `since_revision > 0``since_revision < oldest_revision - 1`,发送 `resync_required`
3. 从 Redis Stream 顺序扫描事件。
4. 过滤 `revision > since_revision`
5. 过滤 `city IN subscribed_cities`
6. 返回最多 `replay_limit + 1` 条。
7. 如果超过 `replay_limit`,发送 `resync_required`,让前端 HTTP resync。
当前事件量很小,单个 stream 顺序扫描 24 小时事件是可以接受的:
- 30 城市 * 1 条/分钟 * 24 小时 = 43,200 条。
- replay 通常只发生在断线重连,不是高频查询。
如果后续城市量扩大,或者确实需要超过 24 小时的 replay,再加 per-city stream 或 Redis sorted index。
## Live Fanout
每个 API worker 启动一个 Redis subscriber loop
```text
XREAD BLOCK 5000 STREAMS stream:city_observation {last_seen_stream_id}
```
读取到事件后:
1. 解析 `city_observation_patch.v1`
2. 按本进程 SSE 连接的 `cities` subscription 过滤。
3. 推送给本进程匹配的浏览器连接。
4. 更新 worker 内存中的 `last_seen_stream_id`
ingest endpoint 写入 Redis 后不直接广播本地连接,由 subscriber loop 统一 fanout。这样避免同一个 worker 上“写入后本地广播一次、subscriber 又广播一次”的重复事件。
前端仍然用 revision 去重。即使极端情况下收到重复 revision,也应忽略旧 revision。
## SQLite Fallback
保留当前 SQLite store,但角色调整为:
- 本地开发无需 Redis 时使用。
- Redis 不可用且允许降级时使用。
- 单实例临时运行时可用。
新增环境变量:
```text
POLYWEATHER_EVENT_STORE=redis
POLYWEATHER_EVENT_STORE_FALLBACK=sqlite
POLYWEATHER_REDIS_URL=redis://127.0.0.1:6379/0
POLYWEATHER_REDIS_STREAM_KEY=stream:city_observation
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
POLYWEATHER_PATCH_EVENT_RETENTION_HOURS=24
POLYWEATHER_REDIS_REQUIRED=true
```
推荐生产策略:
- `POLYWEATHER_EVENT_STORE=redis`
- `POLYWEATHER_REDIS_REQUIRED=true`
- Redis 写入失败时 ingest 返回 503,不广播不可 replay 的事件。
推荐本地策略:
- 不配置 Redis,自动使用 SQLite。
- 或 `POLYWEATHER_EVENT_STORE=sqlite`
## Redis 容量与配置
当前 VPS 可以跑本机 Redis。
建议:
```text
bind 127.0.0.1
protected-mode yes
appendonly yes
appendfsync everysec
maxmemory 512mb
maxmemory-policy noeviction
```
默认 `MAXLEN ~ 50000` 足够约 24 小时事件:
- 30 城市 * 1/min * 24h = 43,200 条。
- payload 约 1-3 KB,实际内存取决于 Redis Stream overhead。
- 512 MB Redis maxmemory 对当前事件量足够。
注意:这个事件流不是永久数据仓库。超过 24 小时窗口后,前端应 HTTP resync 当前城市 detail。
## 模块边界
新增/调整后端接口:
### `web/realtime_event_store.py`
定义统一接口:
```python
class RealtimeEventStore:
def append_event(self, event: dict) -> dict: ...
def replay_events(self, *, cities: set[str] | None, since_revision: int, limit: int) -> list[dict]: ...
def replay_requires_resync(self, *, cities: set[str] | None, since_revision: int, replay_count: int, limit: int) -> bool: ...
def latest_revision(self) -> int: ...
```
保留现有 SQLite 实现,新增 Redis 实现。
### `web/redis_realtime_event_store.py`
职责:
- Redis 连接与健康检查。
- Lua append script。
- Redis Stream replay。
- maxlen trim。
- entry 到 SSE event 的反序列化。
### `web/sse_manager.py`
职责:
- 管理本进程 SSE 连接。
- 记录每个连接订阅城市集合。
- 将 Redis subscriber 读到的 event fanout 给匹配连接。
### `web/routers/sse_router.py`
职责:
- `/api/internal/collector-patch` 规范化并写入 event store。
- `/api/events` 处理 connected、replay、heartbeat、live stream。
- 当 replay 不完整时发送 `resync_required`
### 前端
前端原则上不需要大改:
- `use-sse-patches.ts` 继续使用 `EventSource`
- `lastRevision` 仍然是 number。
- `resync_required` 继续触发当前可见城市 HTTP detail refresh。
- 继续按可见城市列表构建 `cities` 参数。
## 运行流程
### 首屏
1. 前端 HTTP 加载 terminal rows。
2. 可见图表 HTTP 加载 full detail。
3. 前端连接 `/api/events?cities=...&since_revision=...&replay_limit=500`
4. 后端先 replay Redis Stream 中缺失事件,再进入 live stream。
### 新观测
1. 采集器产生 `city_patch` 或 v1 patch。
2. ingest endpoint 规范化成 `city_observation_patch.v1`
3. Redis Lua script 写入 stream 并生成 revision。
4. 每个 API worker 的 subscriber loop 读到事件。
5. worker fanout 给订阅对应城市的 SSE 连接。
6. 前端 apply patch,图表无痛追加点。
### 浏览器后台恢复
1. EventSource 如果断线,前端按指数退避重连。
2. URL 带 `since_revision=lastRevision`
3. Redis replay 补齐后台期间错过的事件。
4. 如果 revision 太旧或 replay 超限,前端收到 `resync_required`HTTP 重拉 full detail。
## 错误处理
- Redis append 失败且 `POLYWEATHER_REDIS_REQUIRED=true`ingest 返回 503,不广播。
- Redis append 失败且允许 fallback:写入 SQLite,并在 health 状态标记 degraded。
- Redis replay 失败:SSE 发送 `resync_required`,然后继续尝试 live stream。
- Redis subscriber loop 断开:指数退避重连;期间 SSE heartbeat 仍可维持连接,但不会有 live patch。
- 前端收到 revision 倒退或重复:忽略。
- 前端收到未知 event type:忽略并记录 debug。
## 监控与诊断
新增健康指标:
- 当前 event store mode: `redis` / `sqlite` / `degraded_sqlite`
- Redis ping latency
- latest revision
- stream length
- oldest revision
- subscriber connected
- SSE active connection count
- dropped/resync_required count
建议暴露到现有 health endpoint 或日志:
```json
{
"realtime": {
"store": "redis",
"redis_connected": true,
"stream_len": 43210,
"latest_revision": 123456,
"oldest_revision": 80200,
"subscriber_connected": true,
"sse_connections": 9
}
}
```
## 测试策略
后端:
- Redis store append 会生成全局递增 numeric revision。
- Redis replay 支持 city 过滤。
- Redis replay 支持 `since_revision`
- Redis replay 超出 retention/limit 返回 resync required。
- Redis 不可用时,根据 env 严格失败或 fallback SQLite。
- SQLite store 现有测试保持通过。
- SSE router 在 Redis store 下仍发送 connected、replay、heartbeat。
前端:
- `use-sse-patches.ts` 保持 `revision` numeric。
- 重连 URL 保留 `since_revision`
- 收到 `resync_required` 会触发 visible city refresh。
- 重复 revision 不重复追加图表点。
验收命令:
```powershell
python -m pytest tests/test_realtime_patch_schema.py tests/test_realtime_event_store.py tests/test_sse_replay.py
python -m pytest tests/test_redis_realtime_event_store.py
cd frontend
npm run test:business
npm run typecheck
npm run build
```
## 迁移步骤
1. 保留现有 SQLite implementation,抽出 event store factory。
2. 新增 Redis implementation 和单元测试。
3. 新增 Redis subscriber loop,但先在本地开发环境跑。
4. `/api/events` 接入 event store factory。
5. 线上安装 Redis,只监听 `127.0.0.1`
6. 先用 `POLYWEATHER_EVENT_STORE=redis``POLYWEATHER_REDIS_REQUIRED=false` 灰度。
7. 观察 stream length、latest revision、SSE error、resync count。
8. 稳定后切换 `POLYWEATHER_REDIS_REQUIRED=true`
9. 保留 SQLite fallback 代码,但生产不主动降级,避免多实例状态分裂。
## 验收标准
- 断开浏览器网络 1-5 分钟后恢复,图表自动补齐期间 patch。
- 后台挂页面再回来,图表不需要等待 full loading 才更新当前点。
- 多 worker 下,任意 worker 写入的 patch 都能推到其他 worker 的 SSE 连接。
- `/api/events?cities=taipei,shanghai&since_revision=...` 只 replay 指定城市。
- Redis 重启后,前端收到 `resync_required` 并 HTTP 重建画面。
- 前端无需知道 Redis 存在。
- 现有 `city_observation_patch.v1` schema 不破坏。
## 结论
这是一种“一步到位但不过度设计”的方案:
- 一步到位的是事件契约、replay、跨实例 fanout 和生产部署边界。
- 不过度设计的是不用 Kafka、不做永久行情库、不重写前端协议。
对当前产品最关键的是:图表像股票行情一样无痛刷新,断线可补,后台恢复可追上,多实例以后不用再推倒实时层。
@@ -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.
+8 -7
View File
@@ -15,6 +15,11 @@ NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
# 生产环境必须设为 https://polyweather.top
NEXT_PUBLIC_SITE_URL=https://polyweather.top
# 常用:前端鉴权开关
# true: 启用 Supabase 登录
# false: 关闭登录能力,访客模式
@@ -25,10 +30,6 @@ POLYWEATHER_AUTH_ENABLED=false
# false: 登录可选,访客可浏览
POLYWEATHER_AUTH_REQUIRED=false
# 可选:分享式看板访问令牌
# 设置后,可通过 /?access_token=<token> 打开受保护看板
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
# 可选:前端 API Route 转发到后端时附带的共享令牌
# 仅当后端启用了 entitlement / 订阅校验时需要
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
@@ -36,8 +37,8 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# 可选:钱包支付 / Telegram 入口
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
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/WeatherQuant_bot
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=WeatherQuant_bot
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
+18 -9
View File
@@ -9,21 +9,30 @@ POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
# 在 Vercel 免费额度下建议配置为 VPS HTTPS 域名,让 AI / METAR / scan 等
# 长耗时请求绕过 Vercel Functions / Fluid Compute。
# 例如:https://api.example.com
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
# 本地开发时注释掉,让 Next.js API Route 代理请求,避免 CORS 问题
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=http://38.54.27.70:8080
# 必填:Supabase 前端公钥(鉴权开启时必须)
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
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=false
POLYWEATHER_AUTH_ENABLED=true
# 常用:是否强制登录
# true: middleware 强制登录后才能访问主页面
# false: 登录可选,访客可浏览
POLYWEATHER_AUTH_REQUIRED=false
POLYWEATHER_AUTH_REQUIRED=true
# 关闭本地开发鉴权绕过
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
# 可选:分享式看板访问令牌
# 设置后,可通过 /?access_token=<token> 打开受保护看板
@@ -34,10 +43,10 @@ POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# 可选:钱包支付 / Telegram 入口
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=8ce6332b57a42e606cf28224e82d1e02
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
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/WeatherQuant_bot
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=WeatherQuant_bot
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
+8
View File
@@ -0,0 +1,8 @@
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
+42
View File
@@ -0,0 +1,42 @@
FROM node:20-alpine AS deps
WORKDIR /app/frontend
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS builder
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_POLYWEATHER_API_BASE_URL
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
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=$NEXT_PUBLIC_POLYWEATHER_API_BASE_URL
ENV NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=$NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
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
WORKDIR /app/frontend
COPY --from=deps /app/frontend/node_modules ./node_modules
COPY . ./
RUN npm run build && npm prune --omit=dev
FROM node:20-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app/frontend
COPY --from=builder /app/frontend/public ./public
COPY --from=builder /app/frontend/.next/standalone ./
COPY --from=builder /app/frontend/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
+25 -29
View File
@@ -3,13 +3,13 @@
PolyWeather Pro 的生产前端工程。
线上地址:
- [https://polyweather-pro.vercel.app/](https://polyweather-pro.vercel.app/)
- [https://polyweather.top/](https://polyweather.top/)
## 技术栈
- Next.js App Router
- React + Tailwind
- Leaflet + Chart.js
- 自研温度图表 + Recharts 运营图表
- Supabase Auth
- WalletConnect + 浏览器 EVM 钱包
@@ -21,27 +21,21 @@ PolyWeather Pro 的生产前端工程。
## 当前前端能力
- 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心
- `/docs` 提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF 和结算来源
- 今日日内分析支持:
- `锚点状态`
- `当前节奏`
- `校准模型概率`
- `模型区间与分歧`
- `专业气象结论条`
- `气象证据链 / 失效条件 / 确认条件`
- 非香港机场城市的 `TAF` 时段提示与走势图联动
- 主站为实时天气决策台,包含 `天气决策 / 训练数据 / 使用指南` 三个主入口
- `/docs` 提供公开双语产品文档中心,当前保留简介、图表阅读、实时数据频率、结算站点和浏览器插件说明
- 天气决策台支持 1x1 到 3x3 图表槽位,可按区域、搜索和城市选择进行多城市巡检
- 终端图表默认展示全天,可切换高温窗口;所有横轴与 tooltip 时间按城市当地时间渲染
- 可见终端图表通过 SSE patch 增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
- AMSC/AMOS 跑道城市默认展示结算跑道曲线并高亮,辅助跑道弱化展示;单跑道机场不重复展示聚合线
- 香港默认展示 CoWIN 6087 参考站 1 分钟曲线,并保留 HKO 10 分钟官方气象层
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
- 使用指南内置图表阅读顺序、图层含义、常用操作和默认可见性规则
- `/ops` 已支持桌面表格 + 手机端卡片化视图
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
- 右侧详情面板在多日预报仍未补齐时会显示同步占位卡,不再把“只有今天一张卡”的中间态伪装成完整数据
- 日内分析弹窗在 full detail / market scan 同步时会锁住旧内容并显示刷新状态,避免用户短暂看到旧城市或旧日期的数据
- 城市决策卡支持从地图点击城市进入;机会榜和日历仍按 Pro 权限控制,地图探索和城市简报可作为轻量入口
- 城市决策卡的 AI 机场报文解读包括最终判断、METAR 解读、推理说明、模型集群备注、风险提示和原始 METAR
- AI 机场报文解读按 `city + local_date + locale + METAR signature` 做页面内存缓存和 `localStorage` 最终结果缓存;切换选项卡返回时会优先恢复已有内容
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
- 概率区展示当前生产概率引擎输出;EMOS / LGBM 只在评估通过或 shadow 对照时进入解释层,模型共识和市场价格只作为辅助说明
- `/ops` 现已展示 prewarm worker 运行态、缓存桶状态与 summary cache hit/miss
- 城市 detail 自动识别稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
- 市场价格层使用完整 `all_buckets` 匹配温度桶,市场信号作为交易判断层,不替代实测结算源
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
- 账户中心支付区支持后端下发的多链网络选择;Polygon 继续走 checkout 合约,Ethereum 主网 USDC 走手动直转确认
- 缓存桶状态与 summary cache hit/miss
## 本地开发
@@ -90,12 +84,12 @@ 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-pro.vercel.app
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/WeatherQuant_bot
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
# 推荐默认关闭的前端观测 / 预热开关
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
@@ -144,13 +138,12 @@ Ops
当前前端已内置轻量管理页:
- [https://polyweather-pro.vercel.app/ops](https://polyweather-pro.vercel.app/ops)
- [https://polyweather.top/ops](https://polyweather.top/ops)
页面当前支持:
- 系统状态
- SQLite / rollout / 支付运行态
- prewarm worker 运行态
- 缓存桶状态与 summary cache hit/miss
- 用户查询
- 当前会员
@@ -173,7 +166,8 @@ Ops
2. 若 `receiver_contract` 已更新,先切到最新地址
3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付
4. 仅允许在 `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` 白名单域名上创建 payment intent
5. 支付区会明确显示当前账号、付款钱包和收款合约,避免账号/钱包/地址混淆
5. 支付区会明确显示当前账号、付款钱包、支付网络和收款合约/钱包,避免账号/钱包/地址/链混淆
6. 多链支付时,前端会把用户选择的 `chain_id``token_address` 发给后端,由后端按 intent 的链确认交易
这意味着:
@@ -186,7 +180,9 @@ Ops
- `summary?force_refresh=true``no-store`
- 支付相关路由:`no-store`
- 当 detail 缓存只返回单模型或单日 forecast 时,前端会自动强刷完整 detail,并在补齐前显示同步提示 / 占位卡
- 今日日内分析打开时如果正在切换城市、日期或 detail 深度,弹窗会阻断旧内容点击并显示刷新锁
- 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 节流建议
@@ -201,4 +197,4 @@ Ops
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
最后更新:`2026-04-19`
最后更新:`2026-05-29`
+24 -18
View File
@@ -2,14 +2,18 @@
import { RefreshCw } from "lucide-react";
import { useEffect } from "react";
import { I18nProvider, useI18n } from "@/hooks/useI18n";
export default function AccountErrorPage({
function AccountErrorContent({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const { locale } = useI18n();
const isEn = locale === "en-US";
useEffect(() => {
console.error("Account page error:", error);
}, [error]);
@@ -38,7 +42,7 @@ export default function AccountErrorPage({
color: "var(--color-accent-primary, #4DA3FF)",
}}
>
{isEn ? "Account Page Error" : "账户页面出错"}
</h1>
<p
style={{
@@ -49,21 +53,9 @@ export default function AccountErrorPage({
lineHeight: 1.7,
}}
>
MetaMask
Rabby
</p>
<p
style={{
color: "var(--color-text-muted, #7D8FA3)",
fontSize: "0.8rem",
margin: 0,
maxWidth: 420,
lineHeight: 1.6,
}}
>
If this happened during payment or wallet binding, the most common cause is
conflicting wallet extensions. Try disabling other wallet extensions (e.g.
MetaMask + Rabby) and refresh.
{isEn
? "If this happened during payment or wallet binding, the most common cause is conflicting wallet extensions (e.g. MetaMask and Rabby open at the same time). Try disabling other wallet extensions and refresh."
: "如果是在支付或绑定钱包时出现此问题,常见原因是钱包插件冲突(例如同时开启了 MetaMask 和 Rabby)。请尝试关闭其他钱包插件后刷新页面重试。"}
</p>
<button
type="button"
@@ -84,8 +76,22 @@ export default function AccountErrorPage({
}}
>
<RefreshCw size={14} />
{isEn ? "Retry" : "重试"}
</button>
</div>
);
}
export default function AccountErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<I18nProvider>
<AccountErrorContent error={error} reset={reset} />
</I18nProvider>
);
}
+96 -24
View File
@@ -7,49 +7,121 @@ import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const ANALYTICS_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS !== "false";
const ANALYTICS_PROXY_TIMEOUT_MS = Math.max(
250,
Number(process.env.POLYWEATHER_ANALYTICS_PROXY_TIMEOUT_MS || "1500") || 1500,
);
export async function POST(req: NextRequest) {
const timer = createProxyTimer(req, "analytics_events");
if (!ANALYTICS_ENABLED) {
return new NextResponse(null, { status: 204 });
}
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
return finishProxyTimedResponse(
new NextResponse(null, { status: 204 }),
timer,
"disabled",
);
}
if (!API_BASE) {
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ANALYTICS_PROXY_TIMEOUT_MS);
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const body = await timer.measure("request_read", () => req.json());
const payload =
body && typeof body.payload === "object" && body.payload != null
? body.payload
: {};
const enrichedBody = {
...(body ?? {}),
payload: {
...payload,
cf_country:
req.headers.get("cf-ipcountry") ||
req.headers.get("x-vercel-ip-country") ||
"",
user_agent: req.headers.get("user-agent") || "",
referer_header: req.headers.get("referer") || "",
},
};
auth = await timer.measure("auth_headers", () =>
buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
}),
);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers,
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
const res = await timer.measure("backend_fetch", () =>
fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers,
body: JSON.stringify(enrichedBody),
cache: "no-store",
signal: controller.signal,
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
if (!res.ok) {
const raw = await res.text();
const raw = await timer.measure("backend_read", () => res.text());
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 260,
});
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
}
const data = await res.json();
const data = await timer.measure("backend_read", () => res.json());
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to track analytics event",
});
const timedOut = controller.signal.aborted;
const response = timedOut
? NextResponse.json(
{
ok: false,
accepted: true,
dropped: true,
reason: "timeout",
},
{ status: 202 },
)
: buildProxyExceptionResponse(error, {
publicMessage: "Failed to track analytics event",
});
const withCookies = auth ? applyAuthResponseCookies(response, auth.response) : response;
return finishProxyTimedResponse(
withCookies,
timer,
timedOut ? "timeout_accepted" : "exception",
);
} finally {
clearTimeout(timeoutId);
}
}
+649 -64
View File
@@ -4,104 +4,689 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
import {
getLocalDevAuthPayload,
isLocalFullAccessHost,
} from "@/lib/local-dev-access";
import {
applyEntitlementSnapshotCookie,
clearEntitlementSnapshotCookie,
entitlementSnapshotToAuthPayload,
readEntitlementSnapshot,
} from "@/lib/entitlement-snapshot";
import {
buildSubscriptionRequiredAuthProfile,
isSubscriptionRequiredBackendResponse,
} from "@/lib/auth-profile-proxy";
import {
hasSupabaseServerEnv,
hasSupabaseSessionCookieValues,
} from "@/lib/supabase/server";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
type AuthMeTimingStage = {
durationMs: number;
name: string;
};
type AuthMeTimer = {
hasAuthorization: boolean;
hasSupabaseCookie: boolean;
measure<T>(name: string, action: () => Promise<T>): Promise<T>;
measureSync<T>(name: string, action: () => T): T;
preferSnapshot: boolean;
stages: AuthMeTimingStage[];
totalMs(): number;
};
function authMeNowMs() {
return typeof performance !== "undefined" ? performance.now() : Date.now();
}
function createAuthMeTimer(req: NextRequest): AuthMeTimer {
const startedAt = authMeNowMs();
const stages: AuthMeTimingStage[] = [];
const recordStage = (name: string, stageStartedAt: number) => {
stages.push({
durationMs: Math.round((authMeNowMs() - stageStartedAt) * 10) / 10,
name,
});
};
return {
hasAuthorization: Boolean(req.headers.get("authorization")),
hasSupabaseCookie: hasRequestSupabaseSessionCookie(req),
async measure<T>(name: string, action: () => Promise<T>) {
const stageStartedAt = authMeNowMs();
try {
return await action();
} finally {
recordStage(name, stageStartedAt);
}
},
measureSync<T>(name: string, action: () => T) {
const stageStartedAt = authMeNowMs();
try {
return action();
} finally {
recordStage(name, stageStartedAt);
}
},
preferSnapshot: req.nextUrl.searchParams.get("prefer_snapshot") === "1",
stages,
totalMs() {
return Math.round((authMeNowMs() - startedAt) * 10) / 10;
},
};
}
function formatServerTiming(stages: AuthMeTimingStage[], totalMs: number) {
return [...stages, { durationMs: totalMs, name: "total" }]
.map(({ durationMs, name }) => {
const safeName = name.replace(/[^A-Za-z0-9_-]/g, "_");
return `${safeName};dur=${Math.max(0, durationMs).toFixed(1)}`;
})
.join(", ");
}
function buildBackendAuthMeUrl(req: NextRequest) {
const url = new URL(`${API_BASE!.replace(/\/+$/, "")}/api/auth/me`);
if (req.nextUrl.searchParams.get("scope") === "entitlement") {
url.searchParams.set("scope", "entitlement");
}
return url.toString();
}
function finishAuthMeResponse(
response: NextResponse,
timer: AuthMeTimer,
outcome: string,
extra?: { backendServerTiming?: string },
) {
const total = timer.totalMs();
const ownServerTiming = formatServerTiming(timer.stages, total);
const backendServerTiming = String(extra?.backendServerTiming || "").trim();
response.headers.set("Cache-Control", "no-store");
response.headers.set(
"Server-Timing",
backendServerTiming
? `${ownServerTiming}, ${backendServerTiming}`
: ownServerTiming,
);
console.info(
"[auth-me-timing]",
JSON.stringify({
backendServerTiming: backendServerTiming || undefined,
hasAuthorization: timer.hasAuthorization,
hasSupabaseCookie: timer.hasSupabaseCookie,
outcome,
preferSnapshot: timer.preferSnapshot,
stagesMs: Object.fromEntries(
timer.stages.map((stage) => [stage.name, stage.durationMs]),
),
status: response.status,
totalMs: total,
}),
);
return response;
}
async function trackAuthDiagnosticEvent(
req: NextRequest,
{
email,
reason,
responseMode,
userId,
}: {
email: string | null;
reason: string;
responseMode: "snapshot" | "degraded" | "anonymous";
userId?: string | null;
},
) {
if (!API_BASE) return;
const normalizedUserId = String(userId || "").trim();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1200);
try {
await fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event_type: "degraded_auth_profile",
client_id: normalizedUserId ? `auth:${normalizedUserId}` : undefined,
payload: {
route: "/api/auth/me",
reason: String(reason || "unknown").slice(0, 240),
response_mode: responseMode,
user_id: normalizedUserId || undefined,
email_domain: email?.includes("@") ? email.split("@").pop() : undefined,
cf_country:
req.headers.get("cf-ipcountry") ||
req.headers.get("x-vercel-ip-country") ||
"",
user_agent: req.headers.get("user-agent") || "",
referer_header: req.headers.get("referer") || "",
captured_at: new Date().toISOString(),
},
}),
cache: "no-store",
signal: controller.signal,
});
} catch {
// Diagnostics must never block auth/profile fallback responses.
} finally {
clearTimeout(timeoutId);
}
}
type VerifiedBearerIdentity = {
email: string | null;
userId: string;
};
function extractBearerToken(headerValue: string | null) {
if (!headerValue) return "";
const parts = headerValue.trim().split(/\s+/);
if (parts.length === 2 && parts[0].toLowerCase() === "bearer") {
return parts[1];
}
return "";
}
async function getVerifiedBearerIdentity(
req: NextRequest,
): Promise<VerifiedBearerIdentity | null> {
const token = extractBearerToken(req.headers.get("authorization"));
if (!token) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
if (!supabaseUrl || !anonKey) return null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 4000);
try {
const res = await fetch(`${supabaseUrl.replace(/\/+$/, "")}/auth/v1/user`, {
cache: "no-store",
headers: {
apikey: anonKey,
Authorization: `Bearer ${token}`,
},
signal: controller.signal,
});
if (!res.ok) return null;
const user = await res.json();
const userId = String(user?.id || "").trim();
if (!userId) return null;
return {
email: String(user?.email || "").trim() || null,
userId,
};
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
}
async function degradedAuthProfileResponse({
email,
reason,
req,
response,
userId,
}: {
email: string | null;
reason: string;
req: NextRequest;
response: NextResponse | null;
userId: string;
}) {
const snapshotPayload = entitlementSnapshotToAuthPayload(
readEntitlementSnapshot(req, userId),
);
if (snapshotPayload) {
await trackAuthDiagnosticEvent(req, {
email: snapshotPayload.email || email,
reason,
responseMode: "snapshot",
userId,
});
const snapshotResponse = NextResponse.json({
...snapshotPayload,
email: snapshotPayload.email || email,
entitlement_snapshot_reason: reason,
});
return applyAuthResponseCookies(snapshotResponse, response);
}
await trackAuthDiagnosticEvent(req, {
email,
reason,
responseMode: "degraded",
userId,
});
const degraded = NextResponse.json({
authenticated: true,
user_id: userId,
email,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: reason,
});
return applyAuthResponseCookies(degraded, response);
}
function subscriptionRequiredAuthProfileResponse({
email,
response,
userId,
}: {
email: string | null;
response: NextResponse | null;
userId: string;
}) {
const inactive = NextResponse.json(
buildSubscriptionRequiredAuthProfile({ email, userId }),
);
clearEntitlementSnapshotCookie(inactive);
return applyAuthResponseCookies(inactive, response);
}
async function unauthenticatedAuthProfileResponse({
reason,
req,
response,
}: {
reason: string;
req: NextRequest;
response: NextResponse | null;
}) {
await trackAuthDiagnosticEvent(req, {
email: null,
reason,
responseMode: "anonymous",
});
const anonymous = NextResponse.json(
{
authenticated: false,
subscription_active: false,
points: 0,
degraded_auth_profile: true,
degraded_reason: reason,
},
{ headers: { "Cache-Control": "no-store" } },
);
clearEntitlementSnapshotCookie(anonymous);
return applyAuthResponseCookies(anonymous, response);
}
function snapshotAuthProfileResponse({
email,
reason,
req,
response,
userId,
}: {
email: string | null;
reason: string;
req: NextRequest;
response: NextResponse | null;
userId: string;
}) {
const snapshotPayload = entitlementSnapshotToAuthPayload(
readEntitlementSnapshot(req, userId),
);
if (!snapshotPayload) return null;
const snapshotResponse = NextResponse.json({
...snapshotPayload,
email: snapshotPayload.email || email,
entitlement_snapshot_reason: reason,
});
return applyAuthResponseCookies(snapshotResponse, response);
}
function applyEntitlementSnapshotFromAuthPayload(
response: NextResponse,
data: Record<string, unknown>,
) {
if (data.authenticated === true && data.subscription_active === true) {
return applyEntitlementSnapshotCookie(response, data);
}
if (data.authenticated === false || data.subscription_active === false) {
return clearEntitlementSnapshotCookie(response);
}
return response;
}
function hasRequestSupabaseSessionCookie(req: NextRequest) {
return hasSupabaseSessionCookieValues(
req.cookies.getAll().map((item) => ({
name: item.name,
value: item.value,
})),
);
}
export async function GET(req: NextRequest) {
const timer = createAuthMeTimer(req);
const requestHost =
req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host;
if (
isLocalFullAccessHost(requestHost) ||
isLocalFullAccessHost(req.nextUrl.hostname)
) {
return NextResponse.json(getLocalDevAuthPayload(), {
headers: { "Cache-Control": "no-store" },
});
}
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
return finishAuthMeResponse(
NextResponse.json(getLocalDevAuthPayload(), {
headers: { "Cache-Control": "no-store" },
}),
timer,
"local_full_access",
);
}
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 (!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",
);
}
if (res.status === 401 || res.status === 403) {
}
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 {
auth = await timer.measure(
"auth_headers",
() => buildBackendRequestHeaders(req),
);
if (
hasSupabaseServerEnv() &&
!auth.authUserId &&
!req.headers.get("authorization")
) {
const response = NextResponse.json({
authenticated: false,
subscription_active: false,
points: 0,
});
return applyAuthResponseCookies(response, auth.response);
if (!preferSnapshot) clearEntitlementSnapshotCookie(response);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"no_session",
);
}
if (preferSnapshot) {
const identity =
auth.authUserId
? { email: auth.authEmail || null, userId: auth.authUserId }
: await getBearerIdentityOnce();
if (identity?.userId) {
const snapshotResponse = snapshotAuthProfileResponse({
email: identity.email,
reason: "prefer_snapshot",
req,
response: auth.response,
userId: identity.userId,
});
if (snapshotResponse) {
return finishAuthMeResponse(
snapshotResponse,
timer,
"prefer_snapshot",
);
}
}
}
if (!auth) throw new Error("auth headers unavailable");
const backendAuth = auth;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 6000);
let res: Response;
try {
res = await timer.measure("backend_fetch", async () =>
await fetch(buildBackendAuthMeUrl(req), {
headers: backendAuth.headers,
cache: "no-store",
signal: controller.signal,
}),
);
} finally {
clearTimeout(timeoutId);
}
const backendServerTiming = res.headers.get("server-timing") || "";
if (res.status === 401 || res.status === 403) {
const raw = await timer.measure("backend_read", () => res.text());
const authIdentity = auth.authUserId
? { email: auth.authEmail || null, userId: auth.authUserId }
: await getBearerIdentityOnce();
if (
authIdentity?.userId &&
isSubscriptionRequiredBackendResponse(res.status, raw)
) {
return finishAuthMeResponse(
subscriptionRequiredAuthProfileResponse({
email: authIdentity.email,
response: auth.response,
userId: authIdentity.userId,
}),
timer,
"subscription_required",
{ backendServerTiming },
);
}
if (auth.authUserId) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: auth.authEmail || null,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: auth.authUserId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const identity = await getBearerIdentityOnce();
if (identity) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: identity.email,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: identity.userId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const response = NextResponse.json({
authenticated: false,
subscription_active: false,
points: 0,
});
clearEntitlementSnapshotCookie(response);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`anonymous_backend_${res.status}`,
{ backendServerTiming },
);
}
if (!res.ok) {
const raw = await res.text();
const raw = await timer.measure("backend_read", () => res.text());
if (auth.authUserId) {
const response = NextResponse.json({
authenticated: true,
user_id: auth.authUserId,
email: auth.authEmail || null,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: `backend_${res.status}`,
});
return applyAuthResponseCookies(response, auth.response);
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: auth.authEmail || null,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: auth.authUserId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const identity = await getBearerIdentityOnce();
if (identity) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: identity.email,
reason: `backend_${res.status}`,
req,
response: auth.response,
userId: identity.userId,
}),
timer,
`degraded_backend_${res.status}`,
{ backendServerTiming },
);
}
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
}
const data = await timer.measure("backend_read", () => res.json());
if (data?.authenticated === true && data?.subscription_active == null) {
const userId = String(data.user_id || auth.authUserId || "").trim();
if (userId) {
const snapshotResponse = snapshotAuthProfileResponse({
email: String(data.email || auth.authEmail || "").trim() || null,
reason: "subscription_unknown",
req,
response: auth.response,
userId,
});
if (snapshotResponse) {
return finishAuthMeResponse(
snapshotResponse,
timer,
"subscription_unknown_snapshot",
{ backendServerTiming },
);
}
}
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
applyEntitlementSnapshotFromAuthPayload(response, data);
return finishAuthMeResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) {
const auth = await buildBackendRequestHeaders(req);
if (auth.authUserId) {
const response = NextResponse.json({
authenticated: true,
user_id: auth.authUserId,
email: auth.authEmail || null,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: String(error),
});
return applyAuthResponseCookies(response, auth.response);
if (auth?.authUserId) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: auth.authEmail || null,
reason: String(error),
req,
response: auth.response,
userId: auth.authUserId,
}),
timer,
"exception_degraded",
);
}
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch auth profile",
});
const identity = await getBearerIdentityOnce();
if (identity) {
return finishAuthMeResponse(
await degradedAuthProfileResponse({
email: identity.email,
reason: String(error),
req,
response: auth?.response || null,
userId: identity.userId,
}),
timer,
"exception_degraded",
);
}
const snapshotPayload = timer.measureSync(
"snapshot_cookie",
() => entitlementSnapshotToAuthPayload(readEntitlementSnapshot(req)),
);
if (snapshotPayload) {
const snapshotResponse = NextResponse.json({
...snapshotPayload,
entitlement_snapshot_reason: "exception_snapshot",
});
return finishAuthMeResponse(
applyAuthResponseCookies(snapshotResponse, auth?.response || null),
timer,
"exception_snapshot",
);
}
return finishAuthMeResponse(
await unauthenticatedAuthProfileResponse({
reason: String(error),
req,
response: auth?.response || null,
}),
timer,
"exception_anonymous",
);
}
}
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const body = await req.text();
const res = await fetch(`${API_BASE}/api/auth/referral/apply`, {
method: "POST",
headers: {
...auth.headers,
"Content-Type": "application/json",
},
body,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to apply referral code",
});
}
}
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
import {
createProxyTimer,
finishProxyTimedResponse,
} from "@/lib/proxy-timing";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const DETAIL_BATCH_PROXY_TIMEOUT_MS = Number(
process.env.POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS || "15000",
);
function parseRequestedCities(req: NextRequest) {
const requestedLimit = Number(req.nextUrl.searchParams.get("limit") || "12");
const limit = Number.isFinite(requestedLimit)
? Math.max(1, Math.min(24, requestedLimit))
: 12;
const seen = new Set<string>();
const requestedCities: string[] = [];
for (const item of (req.nextUrl.searchParams.get("cities") || "").split(
",",
)) {
const city = item.trim();
if (!city || seen.has(city)) continue;
seen.add(city);
requestedCities.push(city);
if (requestedCities.length >= limit) break;
}
return requestedCities;
}
function buildCityDetailBatchTimeoutPayload(requestedCities: string[]) {
return {
cities: requestedCities,
details: {},
errors: {},
missing: requestedCities,
partial: true,
timeout: true,
};
}
export async function GET(req: NextRequest) {
const timer = createProxyTimer(req, "city_detail_batch");
if (!API_BASE) {
return finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const requestedCities = parseRequestedCities(req);
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
const searchParams = new URLSearchParams({
cities: req.nextUrl.searchParams.get("cities") || "",
force_refresh: forceRefresh,
limit: req.nextUrl.searchParams.get("limit") || "12",
});
for (const key of ["market_slug", "target_date", "resolution", "scope"]) {
const value = req.nextUrl.searchParams.get(key);
if (value) searchParams.set(key, value);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DETAIL_BATCH_PROXY_TIMEOUT_MS);
try {
return await proxyBackendJsonGet(req, {
cacheControl: cachePolicy.responseCacheControl,
cacheControlForData: (data) =>
data &&
typeof data === "object" &&
(data as { partial?: unknown }).partial === true
? "no-store, max-age=0"
: cachePolicy.responseCacheControl,
fetchCache: "no-store",
publicMessage: "Failed to fetch city detail batch",
revalidateSeconds: cachePolicy.revalidateSeconds,
signal: controller.signal,
timeoutResponse: () =>
NextResponse.json(buildCityDetailBatchTimeoutPayload(requestedCities), {
headers: { "Cache-Control": "no-store, max-age=0" },
status: 200,
}),
timeoutPublicMessage: "City detail batch request timed out",
timing: timer,
url: `${API_BASE}/api/cities/detail-batch?${searchParams.toString()}`,
});
} finally {
clearTimeout(timeoutId);
}
}
+50 -12
View File
@@ -1,21 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { NextRequest } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildCachedJsonResponse } from "@/lib/http-cache";
import { STATIC_CITY_LIST } from "@/lib/static-cities";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const CITIES_CACHE_CONTROL = "public, max-age=0, s-maxage=60, stale-while-revalidate=300";
const STATIC_CITIES_CACHE_CONTROL =
"public, max-age=0, s-maxage=300, stale-while-revalidate=3600";
const CITIES_BACKEND_TIMEOUT_MS = Number(
process.env.POLYWEATHER_CITIES_BACKEND_TIMEOUT_MS || 1000,
);
function staticCitiesFallback(req: NextRequest, reason: string) {
const response = buildCachedJsonResponse(
req,
{
cities: STATIC_CITY_LIST,
source: "static_fallback",
stale: true,
},
STATIC_CITIES_CACHE_CONTROL,
);
response.headers.set("x-polyweather-cities-source", "static-fallback");
response.headers.set("x-polyweather-cities-fallback-reason", reason);
return response;
}
export async function GET(req: NextRequest) {
if (!API_BASE) {
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
return staticCitiesFallback(req, "missing-api-base");
}
return proxyBackendJsonGet(req, {
cacheControl: "public, max-age=0, s-maxage=60, stale-while-revalidate=300",
publicMessage: "Failed to fetch cities",
revalidateSeconds: 60,
url: `${API_BASE}/api/cities`,
});
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), CITIES_BACKEND_TIMEOUT_MS);
try {
const response = await proxyBackendJsonGet(req, {
cacheControl: CITIES_CACHE_CONTROL,
publicMessage: "Failed to fetch cities",
revalidateSeconds: 60,
signal: abortController.signal,
statusOnException: 504,
timeoutPublicMessage: "Cities backend timed out",
url: `${API_BASE}/api/cities`,
});
if (response.status >= 500) {
return staticCitiesFallback(
req,
response.status === 504 ? "backend-timeout" : "backend-error",
);
}
return response;
} finally {
clearTimeout(timeout);
}
}
+83 -11
View File
@@ -1,27 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} 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;
function normalizeCityDetailPayload(data: unknown) {
if (!data || typeof data !== "object") return data;
const payload = data as Record<string, any>;
// Backend v2 nests hourly under timeseries; chart expects it at top level.
if (!payload.hourly && payload.timeseries?.hourly) {
payload.hourly = payload.timeseries.hourly;
}
if (!payload.market_scan && payload.market_scan_payload) {
return {
...payload,
market_scan: payload.market_scan_payload,
};
}
return payload;
}
export async function GET(
req: NextRequest,
context: { params: Promise<{ name: string }> },
) {
const timer = createProxyTimer(req, "city_detail");
if (!API_BASE) {
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
return finishProxyTimedResponse(response, timer, "missing_api_base");
}
const { name } = await context.params;
const { name } = await timer.measure("route_params", () => context.params);
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
const depth = req.nextUrl.searchParams.get("depth");
const marketSlug = req.nextUrl.searchParams.get("market_slug");
const targetDate = req.nextUrl.searchParams.get("target_date");
const resolution = req.nextUrl.searchParams.get("resolution");
const searchParams = new URLSearchParams({
force_refresh: forceRefresh,
});
@@ -34,14 +66,54 @@ export async function GET(
if (targetDate) {
searchParams.set("target_date", targetDate);
}
if (resolution) {
searchParams.set("resolution", resolution);
}
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
return proxyBackendJsonGet(req, {
cacheControl: cachePolicy.responseCacheControl,
fetchCache:
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined,
publicMessage: "Failed to fetch city detail aggregate",
revalidateSeconds: cachePolicy.revalidateSeconds,
url,
});
try {
const auth = await timer.measure("auth_headers", () =>
buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
}),
);
const res = await timer.measure("backend_fetch", () =>
fetch(url, {
headers: auth.headers,
...(cachePolicy.fetchMode === "no-store"
? { cache: "no-store" as const }
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
}),
);
const backendServerTiming = res.headers.get("server-timing") || "";
if (!res.ok) {
const raw = await timer.measure("backend_read", () => res.text());
const response = buildUpstreamErrorResponse(res.status, raw);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
`upstream_${res.status}`,
{ backendServerTiming },
);
}
const data = normalizeCityDetailPayload(
await timer.measure("backend_read", () => res.json()),
);
const response = buildCachedJsonResponse(
req,
data,
cachePolicy.responseCacheControl,
);
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
"ok",
{ backendServerTiming },
);
} catch (error) {
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch city detail aggregate",
});
return finishProxyTimedResponse(response, timer, "exception");
}
}
@@ -1,53 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
import { buildForceRefreshProxyCachePolicy } 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) {
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
}
const { name } = await context.params;
const params = new URLSearchParams();
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
params.set("force_refresh", forceRefresh);
const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 20);
const targetDate = req.nextUrl.searchParams.get("target_date");
if (targetDate) {
params.set("target_date", targetDate);
}
const marketSlug = req.nextUrl.searchParams.get("market_slug");
if (marketSlug) {
params.set("market_slug", marketSlug);
}
const lite = req.nextUrl.searchParams.get("lite");
if (lite) {
params.set("lite", lite);
}
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/market-scan?${params.toString()}`;
return proxyBackendJsonGet(req, {
cacheControl: cachePolicy.responseCacheControl,
detailLimit: 800,
error: "Backend city market scan failed",
fetchCache:
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined,
publicMessage: "Failed to fetch city market scan",
revalidateSeconds: cachePolicy.revalidateSeconds,
statusOnException: 502,
url,
});
}
+45
View File
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
const upstreamUrl = new URL(`${API_BASE.replace(/\/+$/, "")}/api/events`);
req.nextUrl.searchParams.forEach((value, key) => {
upstreamUrl.searchParams.append(key, value);
});
const upstream = await fetch(upstreamUrl.toString(), {
cache: "no-store",
headers: {
Accept: "text/event-stream",
Cookie: req.headers.get("cookie") || "",
},
});
if (!upstream.ok || !upstream.body) {
return NextResponse.json(
{ error: `SSE upstream failed with HTTP ${upstream.status}` },
{ status: upstream.status || 502 },
);
}
return new Response(upstream.body, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
+108
View File
@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try {
auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/feedback`);
const limit = req.nextUrl.searchParams.get("limit");
if (limit) upstream.searchParams.set("limit", limit);
const res = await fetch(upstream, {
method: "GET",
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
if (!res.ok) {
return applyAuthResponseCookies(
buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 260,
error: "Feedback status request failed",
}),
auth.response,
);
}
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch feedback status",
});
return auth ? applyAuthResponseCookies(response, auth.response) : response;
}
}
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try {
const body = await req.json();
auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/feedback`, {
method: "POST",
headers,
body: JSON.stringify(body),
cache: "no-store",
});
const raw = await res.text();
if (!res.ok) {
return applyAuthResponseCookies(
buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 260,
error: "Feedback request failed",
}),
auth.response,
);
}
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to submit feedback",
});
return auth ? applyAuthResponseCookies(response, auth.response) : response;
}
}
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/analytics/funnel`);
const days = req.nextUrl.searchParams.get("days");
if (days) {
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/billing-risk`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Billing risk check failed",
});
}
}
+7
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : "";
@@ -9,6 +10,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -20,6 +24,9 @@ export async function PUT(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
const raw = await res.text();
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(
req: NextRequest,
context: { params: Promise<{ feedbackId: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const { feedbackId } = await context.params;
const body = await req.json();
const res = await fetch(
`${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/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",
});
}
}
@@ -0,0 +1,69 @@
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";
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 finishProxyTimedResponse(
NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
),
timer,
"missing_api_base",
);
}
try {
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: {
"Content-Type": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return finishProxyTimedResponse(
applyAuthResponseCookies(response, auth.response),
timer,
res.ok ? "ok" : `upstream_${res.status}`,
{ backendServerTiming },
);
} catch (error) {
return finishProxyTimedResponse(
buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch online users",
}),
timer,
"exception",
);
}
}
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -21,6 +22,9 @@ export async function POST(req: NextRequest, context: RouteContext) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const { eventId } = await context.params;
const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, {
method: "POST",
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/payments/incidents`);
const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit);
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/payments`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch ops payments",
});
}
}
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const BACKEND = API_BASE ? `${API_BASE}/api/ops/sensitive-config` : "";
export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config fetch failed" }); }
}
export async function PUT(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config update failed" }); }
}
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/source-health`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Source health check failed",
});
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || "";
@@ -9,6 +10,9 @@ export async function POST(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" };
if (ENTITLEMENT_TOKEN) {
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -41,6 +42,23 @@ async function findSupabaseUserIdByEmail(email: string) {
if (!supabaseUrl || !serviceRoleKey) {
throw new Error("Supabase service role is not configured on Vercel");
}
const profileRes = await fetch(
`${supabaseUrl}/rest/v1/profiles?select=id&email=eq.${encodeURIComponent(email)}&limit=1`,
{
headers: {
apikey: serviceRoleKey,
Authorization: `Bearer ${serviceRoleKey}`,
Accept: "application/json",
},
cache: "no-store",
},
);
const profileData = (await profileRes.json().catch(() => [])) as Array<{ id?: string }>;
if (profileRes.ok) {
const profileUserId = String(profileData?.[0]?.id || "").trim();
if (profileUserId) return { supabaseUrl, serviceRoleKey, userId: profileUserId };
}
const res = await fetch(
`${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`,
{
@@ -109,7 +127,7 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
apikey: serviceRoleKey,
Authorization: `Bearer ${serviceRoleKey}`,
"Content-Type": "application/json",
Prefer: "return=representation",
Prefer: "return=minimal",
},
body: JSON.stringify(payload),
cache: "no-store",
@@ -138,6 +156,9 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
export async function POST(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
if (!API_BASE) {
return grantSubscriptionDirectly(req, body, auth.authEmail);
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/telegram/members-audit`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/ops/training/accuracy`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/truth-history`);
for (const key of ["city", "date_from", "date_to", "limit"]) {
const value = req.nextUrl.searchParams.get(key);
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function POST(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const body = await req.text();
const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, {
method: "POST",
+4
View File
@@ -4,6 +4,7 @@ import {
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/users`);
const q = req.nextUrl.searchParams.get("q");
const limit = req.nextUrl.searchParams.get("limit");
+4
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/logs`);
const level = req.nextUrl.searchParams.get("level");
const lines = req.nextUrl.searchParams.get("lines");
+1 -1
View File
@@ -13,7 +13,7 @@ export async function GET(req: NextRequest) {
return proxyBackendJsonGet(req, {
cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900",
detailLimit: 350,
includeSupabaseIdentity: true,
includeSupabaseIdentity: false,
publicMessage: "Failed to fetch payment config",
revalidateSeconds: 300,
url: `${API_BASE}/api/payments/config`,
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -24,6 +25,8 @@ export async function POST(
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -10,6 +11,35 @@ import {
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
function looksLikeHtmlDocument(value: string) {
const text = String(value || "").trim().toLowerCase();
return (
text.startsWith("<!doctype html") ||
text.startsWith("<html") ||
/<title>[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || ""))
);
}
function submitErrorMessage(raw: string) {
try {
const parsed = JSON.parse(String(raw || "")) as {
detail?: unknown;
error?: unknown;
message?: unknown;
};
const message = [parsed.detail, parsed.error, parsed.message].find(
(item) => typeof item === "string" && item.trim(),
);
if (typeof message === "string") {
const trimmed = message.trim();
if (!looksLikeHtmlDocument(trimmed)) return trimmed.slice(0, 350);
}
} catch {
// Non-JSON upstream errors are commonly HTML 50x pages; do not expose them.
}
return "Payment submit upstream failed";
}
export async function POST(
req: NextRequest,
context: { params: Promise<{ intentId: string }> },
@@ -24,6 +54,8 @@ export async function POST(
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(
@@ -37,14 +69,9 @@ export async function POST(
);
if (!res.ok) {
const raw = await res.text();
let detail = raw.slice(0, 350);
try {
const parsed = JSON.parse(raw);
if (parsed.detail) detail = String(parsed.detail).slice(0, 350);
} catch {}
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: 350,
error: detail || undefined,
error: submitErrorMessage(raw),
});
return applyAuthResponseCookies(response, auth.response);
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -24,6 +25,8 @@ export async function POST(
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -35,6 +36,8 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/intents`, {
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
@@ -17,6 +18,8 @@ export async function POST(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
method: "POST",
headers: auth.headers,
+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,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -9,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) {
@@ -17,29 +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 = 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);
}
@@ -47,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,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -44,6 +45,8 @@ export async function DELETE(req: NextRequest) {
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
requireBackendPaymentAuth,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
@@ -20,6 +21,8 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth);
if (authError) return authError;
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
@@ -1,112 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const AI_CITY_GATEWAY_TIMEOUT_MS = Math.max(
10_000,
Number(
process.env.POLYWEATHER_SCAN_AI_GATEWAY_TIMEOUT_MS ||
process.env.POLYWEATHER_AI_CITY_GATEWAY_TIMEOUT_MS ||
process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS ||
"55000",
) || 55_000,
);
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let body: unknown = {};
try {
body = await req.json();
} catch {
body = {};
}
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
const controller = new AbortController();
const startedAt = Date.now();
const timeoutId = setTimeout(() => controller.abort(), AI_CITY_GATEWAY_TIMEOUT_MS);
const requestBody = body && typeof body === "object" ? body as Record<string, unknown> : {};
try {
auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
headers.set("Accept", "application/json");
console.info("[scan-ai-city] gateway request", {
city: requestBody.city,
force_refresh: requestBody.force_refresh === true,
locale: requestBody.locale,
timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS,
});
const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city`, {
method: "POST",
headers,
cache: "no-store",
signal: controller.signal,
body: JSON.stringify(body || {}),
});
if (!res.ok) {
const raw = await res.text();
console.warn("[scan-ai-city] backend returned non-ok", {
status: res.status,
detail: raw.slice(0, 180),
});
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
console.info("[scan-ai-city] gateway complete", {
status: data?.status,
city: data?.city,
model: data?.model,
cached: data?.cached === true,
elapsed_ms: Date.now() - startedAt,
});
const response = NextResponse.json(data, {
headers: {
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const timedOut = controller.signal.aborted;
const elapsedMs = Date.now() - startedAt;
console.warn("[scan-ai-city] gateway failed", {
city: requestBody.city,
timed_out: timedOut,
elapsed_ms: elapsedMs,
timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS,
error: String(error),
});
const response = buildProxyExceptionResponse(error, {
publicMessage: timedOut
? "City AI gateway timed out before backend responded"
: "Failed to fetch city AI data",
status: timedOut ? 504 : 500,
extra: {
elapsed_ms: elapsedMs,
timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS,
city: requestBody.city,
},
});
return auth ? applyAuthResponseCookies(response, auth.response) : response;
} finally {
clearTimeout(timeoutId);
}
}
@@ -1,67 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export const dynamic = "force-dynamic";
export const maxDuration = 90;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let body: unknown = {};
try {
body = await req.json();
} catch {
body = {};
}
const requestBody =
body && typeof body === "object" ? (body as Record<string, unknown>) : {};
const auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
headers.set("Accept", "text/event-stream");
try {
const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city/stream`, {
method: "POST",
headers,
cache: "no-store",
body: JSON.stringify(requestBody),
});
if (!res.ok || !res.body) {
const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const response = new NextResponse(res.body, {
status: 200,
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-store, no-transform",
"X-Accel-Buffering": "no",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = buildProxyExceptionResponse(error, {
publicMessage: "Failed to stream city AI data",
extra: { city: requestBody.city },
});
return applyAuthResponseCookies(response, auth.response);
}
}
@@ -1,75 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const SCAN_AI_PROXY_TIMEOUT_MS = Math.max(
35_000,
Number(process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS || "45000") || 45_000,
);
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let body: unknown = {};
try {
body = await req.json();
} catch {
body = {};
}
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), SCAN_AI_PROXY_TIMEOUT_MS);
try {
auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
headers.set("Accept", "application/json");
const res = await fetch(`${API_BASE}/api/scan/terminal/ai`, {
method: "POST",
headers,
cache: "no-store",
signal: controller.signal,
body: JSON.stringify(body || {}),
});
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, {
headers: {
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const timedOut = controller.signal.aborted;
const response = buildProxyExceptionResponse(error, {
publicMessage: timedOut
? "Scan AI request timed out"
: "Failed to fetch scan AI data",
status: timedOut ? 504 : 500,
});
return auth ? applyAuthResponseCookies(response, auth.response) : response;
} finally {
clearTimeout(timeoutId);
}
}
@@ -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");

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