Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 022d4b8743 | |||
| 58e6404987 | |||
| 32a3ba0ea4 | |||
| 4832678d72 | |||
| 20c8395c0b | |||
| 2be0b71018 | |||
| 5db20061eb | |||
| 2415167132 | |||
| 5aa2a9e384 | |||
| 89394e12ef | |||
| b2dd758977 | |||
| 0012eddc81 | |||
| e79bc2d291 | |||
| 864b1fa1f9 | |||
| 3bad968844 | |||
| 4c97314805 |
@@ -234,34 +234,6 @@ POLYGON_WALLET_WATCH_POLYMARKET_ONLY=true
|
|||||||
POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true
|
POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true
|
||||||
POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
|
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
|
# 8) Optional proxies
|
||||||
########################################
|
########################################
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ jobs:
|
|||||||
chmod 600 ~/.ssh/id_rsa
|
chmod 600 ~/.ssh/id_rsa
|
||||||
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
||||||
cd /root/PolyWeather
|
cd /root/PolyWeather
|
||||||
git pull origin main
|
git fetch origin main && git reset --hard origin/main
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
sleep 10
|
sleep 10
|
||||||
curl -s http://localhost:8000/healthz
|
curl -s http://localhost:8000/healthz
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## 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 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, and serves both a Next.js dashboard (Vercel) and a Telegram bot.
|
||||||
|
|
||||||
## Environment & Preferences (ALWAYS follow)
|
## Environment & Preferences (ALWAYS follow)
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ curl http://127.0.0.1:8000/metrics
|
|||||||
| `src/database/` | SQLite-based runtime state, DB manager, daily/truth/training feature repositories |
|
| `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_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/data_mining/` | Historical data fetch utilities |
|
||||||
| `src/onchain/` | Polygon wallet watcher, Polymarket wallet activity watcher |
|
| `src/onchain/` | Polygon wallet watcher |
|
||||||
| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit |
|
| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit |
|
||||||
| `src/strategy/` | Trading strategy modules |
|
| `src/strategy/` | Trading strategy modules |
|
||||||
| `src/trading/` | Trading execution modules |
|
| `src/trading/` | Trading execution modules |
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
|||||||
|
|
||||||
[](https://star-history.com/#yangyuan-zhen/PolyWeather&Date)
|
[](https://star-history.com/#yangyuan-zhen/PolyWeather&Date)
|
||||||
|
|
||||||
## Product Status (2026-05-11)
|
## Product Status (2026-05-23)
|
||||||
|
|
||||||
- Subscription live: `Pro Monthly 10 USDC`.
|
- 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.
|
- Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards.
|
||||||
@@ -35,22 +35,18 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
|||||||
- EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout.
|
- EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout.
|
||||||
- Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules.
|
- Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules.
|
||||||
- Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives.
|
- Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives.
|
||||||
- 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.
|
- 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 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.
|
- 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.
|
||||||
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
|
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
|
||||||
- The card label “model-market difference” means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
|
- The 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.
|
- 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`.
|
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
|
||||||
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
|
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
|
||||||
- Trade cue now combines upper-air structure, `TAF`, market crowding, and `edge_percent`.
|
- Trade cue now combines upper-air structure, `TAF`, market crowding, and `edge_percent`.
|
||||||
- Browser extension now uses `DEB` for multi-day forecast and stays positioned as a lightweight lead-in to the main site.
|
- 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).
|
- 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.
|
- 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`.
|
- 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.
|
||||||
- `/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.
|
|
||||||
|
|
||||||
## License & Commercial Boundary
|
## License & Commercial Boundary
|
||||||
|
|
||||||
@@ -66,15 +62,13 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
|||||||
|
|
||||||
- Aggregates observations and forecasts for 51 monitored cities.
|
- Aggregates observations and forecasts for 51 monitored cities.
|
||||||
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
|
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
|
||||||
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution), with `LGBM` metadata surfaced when the calibrated engine is active.
|
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via legacy Gaussian or EMOS/CRPS calibration.
|
||||||
- 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.
|
- Adds city decision cards that combine AI airport reads, expected-high centers, full market-bucket mapping, and model-market difference in one view.
|
||||||
- Reuses one analysis core across web dashboard and Telegram bot.
|
- Reuses one analysis core across web dashboard and Telegram bot.
|
||||||
- Adds payment audit trails, replay tooling, and incident visibility in ops.
|
- Adds payment audit trails, replay tooling, and incident visibility in ops.
|
||||||
- Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules.
|
- Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules.
|
||||||
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
|
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
|
||||||
- Adds official nearby-network and runway-level enhancement layers for China, Japan, Korea (AMOS runway sensors for Seoul/Busan), Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
|
- Adds official nearby-network and runway-level enhancement layers for China, Japan, Korea (AMOS runway sensors for Seoul/Busan), Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
|
||||||
- Adds optional dashboard prewarm worker so hot cities can be refreshed before user clicks.
|
|
||||||
|
|
||||||
## Reference Architecture
|
## Reference Architecture
|
||||||
|
|
||||||
@@ -96,9 +90,7 @@ flowchart LR
|
|||||||
|
|
||||||
API --> ANA["DEB + Trend + Probability + Market Scan"]
|
API --> ANA["DEB + Trend + Probability + Market Scan"]
|
||||||
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
|
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
|
||||||
ANA --> PM["Polymarket Read-only Layer"]
|
ANA --> STATE["SQLite runtime state"]
|
||||||
ANA --> LLM["Optional Groq Commentary Rewrite"]
|
|
||||||
API --> PREWARM["Dashboard Prewarm API / Worker"]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Monitored Cities (51)
|
## Monitored Cities (51)
|
||||||
@@ -131,7 +123,7 @@ npm run dev
|
|||||||
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
|
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
|
||||||
- Intraday analysis now separates meteorology conclusion, evidence chain, invalidation rules, confirmation rules, calibrated probability, and market reference.
|
- Intraday analysis now separates meteorology conclusion, evidence chain, invalidation rules, confirmation rules, calibrated probability, and market reference.
|
||||||
- `TAF` is used as an airport-side confirmation layer, not as the main temperature model.
|
- `TAF` is used as an airport-side confirmation layer, not as the main temperature model.
|
||||||
- `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.
|
- Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience.
|
||||||
|
|
||||||
## Runtime Data (Recommended on VPS)
|
## Runtime Data (Recommended on VPS)
|
||||||
@@ -167,20 +159,6 @@ curl http://127.0.0.1:8000/api/system/status
|
|||||||
curl http://127.0.0.1:8000/metrics
|
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
|
### Frontend cache headers
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -199,12 +177,6 @@ docker compose logs -f polyweather | egrep "payment event loop started|payment c
|
|||||||
curl http://127.0.0.1:8000/api/payments/runtime
|
curl http://127.0.0.1:8000/api/payments/runtime
|
||||||
```
|
```
|
||||||
|
|
||||||
### Wallet activity logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Telegram Commands
|
## Telegram Commands
|
||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
@@ -222,26 +194,25 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
|
|||||||
- Chinese API guide: [docs/API_ZH.md](docs/API_ZH.md)
|
- 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)
|
- 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)
|
- 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)
|
- Commercialization: [docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
|
||||||
- AGPL-3.0 policy: [docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.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)
|
- Supabase setup (ZH): [docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
||||||
- Configuration & secrets (ZH): [docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_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)
|
- 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)
|
- 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 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 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)
|
- 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)
|
- Ops admin guide: [docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||||
- Monitoring guide (ZH): [docs/MONITORING_ZH.md](docs/MONITORING_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)
|
- 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)
|
- Release process: [RELEASE.md](RELEASE.md)
|
||||||
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
- Version: `v1.7.0`
|
- Version: `v1.7.0`
|
||||||
- Last Updated: `2026-05-11`
|
- Last Updated: `2026-05-23`
|
||||||
|
|||||||
+11
-41
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 当前产品状态(2026-05-11)
|
## 当前产品状态(2026-05-23)
|
||||||
|
|
||||||
- 已上线订阅制:`Pro 月付 10 USDC`。
|
- 已上线订阅制:`Pro 月付 10 USDC`。
|
||||||
- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖。
|
- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖。
|
||||||
@@ -40,13 +40,12 @@
|
|||||||
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与 prewarm heartbeat。
|
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与 prewarm heartbeat。
|
||||||
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
|
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
|
||||||
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
|
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
|
||||||
- 城市决策卡已接入 AI 机场报文解读:先用 METAR、DEB、多模型集群和 AI 最高温中枢判断天气路径,再映射到 Polymarket 温度桶。
|
- 城市决策卡已接入 AI 机场报文解读:先用 METAR、DEB、多模型集群和 AI 最高温中枢判断天气路径,再映射到温度桶。
|
||||||
- AI 机场报文解读现在同时使用页面内存缓存、浏览器 `localStorage` 和后端短 TTL 缓存;从其他选项卡切回决策卡时会优先恢复已有流式内容或最终结果。
|
- AI 机场报文解读现在同时使用页面内存缓存、浏览器 `localStorage` 和后端短 TTL 缓存;从其他选项卡切回决策卡时会优先恢复已有流式内容或最终结果。
|
||||||
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
|
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
|
||||||
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
||||||
- 概率区已改为“校准模型概率”;默认展示生产概率引擎输出,EMOS/LGBM 只在通过评估或作为 shadow 时进入解释层。
|
- 概率区已改为”校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS),模型共识作为辅助参考。
|
||||||
- 今日日内结构解读已支持可选 `Groq` 改写层,失败时自动回退规则文案。
|
- 今日日内结构解读已支持可选 Groq 改写层,失败时自动回退规则文案。
|
||||||
- 前端部署文档已补充 Vercel 节流建议,包括 analytics 关闭、eager fetch 开关与扫描流量防火墙规则。
|
|
||||||
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`。
|
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`。
|
||||||
|
|
||||||
## 许可证与商用边界(重要)
|
## 许可证与商用边界(重要)
|
||||||
@@ -63,13 +62,11 @@
|
|||||||
|
|
||||||
- 聚合 51 个监控城市的实测与预报数据。
|
- 聚合 51 个监控城市的实测与预报数据。
|
||||||
- DEB(Dynamic Error Balancing)融合多模型最高温。
|
- DEB(Dynamic Error Balancing)融合多模型最高温。
|
||||||
- 输出结算导向校准概率分布(`mu` + 温度桶),并在 LGBM 生效时展示校准引擎元数据。
|
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。
|
||||||
- 将模型观点映射到 Polymarket 行情,做错价扫描。
|
|
||||||
- 地图城市决策卡把 AI 机场报文解读、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示。
|
- 地图城市决策卡把 AI 机场报文解读、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示。
|
||||||
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
|
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
|
||||||
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
|
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
|
||||||
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
|
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
|
||||||
- 支持后台预热热点城市,降低用户点击城市后的冷启动成本。
|
|
||||||
|
|
||||||
## 参考架构
|
## 参考架构
|
||||||
|
|
||||||
@@ -90,10 +87,6 @@ flowchart LR
|
|||||||
|
|
||||||
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
|
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
|
||||||
ANA --> PAY["支付状态(Intent + Event + Confirm Loop)"]
|
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"]
|
ANA --> STATE["SQLite runtime state<br/>legacy files only for migration/export fallback"]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -158,21 +151,7 @@ curl http://127.0.0.1:8000/api/system/status
|
|||||||
curl http://127.0.0.1:8000/metrics
|
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
|
```bash
|
||||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||||
@@ -215,12 +194,6 @@ curl http://127.0.0.1:8000/api/payments/runtime
|
|||||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||||
```
|
```
|
||||||
|
|
||||||
### 钱包异动监听日志
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Telegram 指令
|
## Telegram 指令
|
||||||
|
|
||||||
| 指令 | 用途 |
|
| 指令 | 用途 |
|
||||||
@@ -241,24 +214,21 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
|
|||||||
- Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
- Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
||||||
- 配置与密钥管理:[docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
|
- 配置与密钥管理:[docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
|
||||||
- 前端部署(Vercel):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
|
- 前端部署(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/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||||
- 概率快照归档:[docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md](docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md)
|
- 机场实时数据源:[docs/AIRPORT_REALTIME_SOURCES.md](docs/AIRPORT_REALTIME_SOURCES.md)
|
||||||
- 技术债(中文镜像):[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
- 机场市场监控(中文):[docs/AIRPORT_MARKET_MONITOR_ZH.md](docs/AIRPORT_MARKET_MONITOR_ZH.md)
|
||||||
- 技术债(主文档):[docs/TECH_DEBT.md](docs/TECH_DEBT.md)
|
- 外部服务总览:[docs/SERVICES_ZH.md](docs/SERVICES_ZH.md)
|
||||||
- 支付合约验证:[docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
|
- 支付合约验证:[docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
|
||||||
- 支付审计说明:[docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.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)
|
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||||
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||||
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
|
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
|
||||||
- 模型栈与 DEB:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
- 模型栈与 DEB:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||||
- 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)
|
|
||||||
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
|
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
|
||||||
- 前端报告:[FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
|
|
||||||
- 发布流程:[RELEASE.md](RELEASE.md)
|
- 发布流程:[RELEASE.md](RELEASE.md)
|
||||||
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
|
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
|
||||||
|
|
||||||
## 当前版本
|
## 当前版本
|
||||||
|
|
||||||
- 版本:`v1.7.0`
|
- 版本:`v1.7.0`
|
||||||
- 文档最后更新:`2026-05-11`
|
- 文档最后更新:`2026-05-23`
|
||||||
|
|||||||
+7
-7
@@ -12,9 +12,9 @@
|
|||||||
|
|
||||||
示例:
|
示例:
|
||||||
|
|
||||||
- `1.4.0 -> 1.4.1`:告警逻辑修正、缓存修正、文档修正
|
- `1.7.0 -> 1.7.1`:告警逻辑修正、缓存修正、文档修正
|
||||||
- `1.4.0 -> 1.5.0`:新增支付能力、新增页面、新增 API
|
- `1.7.0 -> 1.8.0`:新增能力、接口扩展、向后兼容的功能迭代
|
||||||
- `1.4.0 -> 2.0.0`:接口重构或数据结构不兼容
|
- `1.7.0 -> 2.0.0`:不兼容变更、核心架构升级
|
||||||
|
|
||||||
## 日常升版步骤
|
## 日常升版步骤
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ python scripts/bump_version.py patch
|
|||||||
```bash
|
```bash
|
||||||
python scripts/bump_version.py minor
|
python scripts/bump_version.py minor
|
||||||
python scripts/bump_version.py major
|
python scripts/bump_version.py major
|
||||||
python scripts/bump_version.py 1.5.0
|
python scripts/bump_version.py 1.8.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 检查同步结果
|
### 2. 检查同步结果
|
||||||
@@ -68,15 +68,15 @@ python -m pytest
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add .
|
git add .
|
||||||
git commit -m "release: v1.4.1"
|
git commit -m "release: v1.7.1"
|
||||||
git tag v1.4.1
|
git tag v1.7.1
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. 推送
|
### 6. 推送
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git push
|
git push
|
||||||
git push origin v1.4.1
|
git push origin v1.7.1
|
||||||
```
|
```
|
||||||
|
|
||||||
## 当前约束
|
## 当前约束
|
||||||
|
|||||||
@@ -105,12 +105,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
|||||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`
|
- `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||||
- `POLYWEATHER_STATE_STORAGE_MODE`
|
- `POLYWEATHER_STATE_STORAGE_MODE`
|
||||||
- `POLYWEATHER_PAYMENT_ENABLED`
|
- `POLYWEATHER_PAYMENT_ENABLED`
|
||||||
- `POLYMARKET_MARKET_SCAN_ENABLED`
|
|
||||||
- `POLYGON_WALLET_WATCH_ENABLED`
|
- `POLYGON_WALLET_WATCH_ENABLED`
|
||||||
- `TELEGRAM_ALERT_PUSH_ENABLED`
|
- `TELEGRAM_ALERT_PUSH_ENABLED`
|
||||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
|
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
|
||||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED`(已退役,建议保持 `false`)
|
|
||||||
- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED`
|
|
||||||
|
|
||||||
### 4.3 L3:运行调优项
|
### 4.3 L3:运行调优项
|
||||||
|
|
||||||
@@ -130,26 +127,12 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
|||||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
|
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
|
||||||
- `POLYWEATHER_PAYMENT_RPC_URLS`
|
- `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||||
- `TAF_CACHE_TTL_SEC`
|
- `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`
|
|
||||||
|
|
||||||
策略:
|
策略:
|
||||||
|
|
||||||
- 先用默认值
|
- 先用默认值
|
||||||
- 出现性能或运维问题时再调
|
- 出现性能或运维问题时再调
|
||||||
|
|
||||||
当前默认预热名单优先覆盖:
|
|
||||||
|
|
||||||
- 亚洲: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:敏感项
|
### 4.4 L4:敏感项
|
||||||
|
|
||||||
这些变量不应写进公开文档截图,也不应提交到仓库。
|
这些变量不应写进公开文档截图,也不应提交到仓库。
|
||||||
@@ -160,9 +143,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
|||||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||||
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
|
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
|
||||||
- `METEOBLUE_API_KEY`
|
|
||||||
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
|
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
|
||||||
- `POLYMARKET_SECRET_KEY`
|
|
||||||
|
|
||||||
## 5. 推荐部署矩阵
|
## 5. 推荐部署矩阵
|
||||||
|
|
||||||
@@ -254,12 +235,6 @@ TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
|
|||||||
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||||
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
||||||
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
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_BACKEND_URL=http://polyweather_web:8000
|
||||||
POLYWEATHER_SCAN_AI_ENABLED=false
|
POLYWEATHER_SCAN_AI_ENABLED=false
|
||||||
POLYWEATHER_SCAN_AI_API_KEY=...
|
POLYWEATHER_SCAN_AI_API_KEY=...
|
||||||
@@ -281,29 +256,6 @@ POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
|
|||||||
- 机器人市场监控包含 `关键提醒` 与 `关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
|
- 机器人市场监控包含 `关键提醒` 与 `关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
|
||||||
- `POLYWEATHER_SCAN_AI_*` 走 OpenAI-compatible `/chat/completions`;当前临时默认 MiMo,可用 `POLYWEATHER_SCAN_AI_BASE_URL` 与 `POLYWEATHER_SCAN_AI_MODEL` 随时切回其他兼容 provider。
|
- `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 分钟)。
|
- `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
|
|
||||||
```
|
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
@@ -311,7 +263,7 @@ POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
|
|||||||
|
|
||||||
### 6.5 机器人市场监控建议配置
|
### 6.5 机器人市场监控建议配置
|
||||||
|
|
||||||
这套配置用于替代旧的钱包异动监听,围绕市场本身做两类推送:
|
这套配置围绕市场本身做两类推送:
|
||||||
|
|
||||||
- `关键提醒`:实时错价/触发条件满足时发送
|
- `关键提醒`:实时错价/触发条件满足时发送
|
||||||
- `关注清单`:按亚洲时区定时推送当日重点市场摘要
|
- `关注清单`:按亚洲时区定时推送当日重点市场摘要
|
||||||
@@ -329,7 +281,6 @@ TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
|
|||||||
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||||
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
||||||
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
||||||
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
|
||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
@@ -337,7 +288,6 @@ POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
|||||||
- `TELEGRAM_ALERT_MISPRICING_ONLY=true` 表示关键提醒优先围绕错价/市场触发,不把机器人做成泛通知器。
|
- `TELEGRAM_ALERT_MISPRICING_ONLY=true` 表示关键提醒优先围绕错价/市场触发,不把机器人做成泛通知器。
|
||||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800` 表示频道每 30 分钟主动推送一轮全局机会清单;每轮会先扫描完整 `TELEGRAM_ALERT_CITIES`,再选 Top N。
|
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800` 表示频道每 30 分钟主动推送一轮全局机会清单;每轮会先扫描完整 `TELEGRAM_ALERT_CITIES`,再选 Top N。
|
||||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5` 建议先保持较小,避免机器人一次推太多城市。
|
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5` 建议先保持较小,避免机器人一次推太多城市。
|
||||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED=false` 表示停用旧的钱包异动监听,统一收敛到市场监控。
|
|
||||||
|
|
||||||
## 7. 当前建议的运维规则
|
## 7. 当前建议的运维规则
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
||||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||||
```
|
```
|
||||||
|
|
||||||
只影响按钮跳转,不影响核心页面加载。
|
只影响按钮跳转,不影响核心页面加载。
|
||||||
|
|||||||
@@ -114,12 +114,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`
|
- `api_cache`
|
||||||
- `metar`
|
- `metar`
|
||||||
|
|||||||
+7
-10
@@ -1,8 +1,8 @@
|
|||||||
# 外部服务依赖总览
|
# 外部服务依赖总览
|
||||||
|
|
||||||
最后更新:`2026-05-19`
|
最后更新:`2026-05-23`
|
||||||
|
|
||||||
项目调用了 24 个外部服务,按状态分为三类。
|
项目调用了 20 个外部服务,按状态分为三类。
|
||||||
|
|
||||||
## 核心(必须有,挂了服务不可用)
|
## 核心(必须有,挂了服务不可用)
|
||||||
|
|
||||||
@@ -13,10 +13,7 @@
|
|||||||
| MADIS (NOAA) | 美国 5 分钟高频观测 | ✅ |
|
| MADIS (NOAA) | 美国 5 分钟高频观测 | ✅ |
|
||||||
| Supabase | 用户认证 + 订阅 | ✅ |
|
| Supabase | 用户认证 + 订阅 | ✅ |
|
||||||
| Telegram Bot API | Bot 消息 + 群成员检查 | ✅ |
|
| Telegram Bot API | Bot 消息 + 群成员检查 | ✅ |
|
||||||
| KNMI | Amsterdam 10 分钟观测 | ✅ |
|
| KNMI | Amsterdam 10 分钟观测 | ✅ |## 国家气象源(特定城市必须)
|
||||||
| Polymarket Gamma/CLOB | 市场数据读取 | ✅ |
|
|
||||||
|
|
||||||
## 国家气象源(特定城市必须)
|
|
||||||
|
|
||||||
| 服务 | 城市 | 状态 |
|
| 服务 | 城市 | 状态 |
|
||||||
| -------------------- | --------------------- | ----------- |
|
| -------------------- | --------------------- | ----------- |
|
||||||
@@ -30,7 +27,7 @@
|
|||||||
| NMC (中国) | 国内城市 fallback | ✅ |
|
| NMC (中国) | 国内城市 fallback | ✅ |
|
||||||
| Singapore MSS | Singapore | ✅ |
|
| Singapore MSS | Singapore | ✅ |
|
||||||
| IMGW (波兰) | Warsaw | ⚠️ 未配 key |
|
| IMGW (波兰) | Warsaw | ⚠️ 未配 key |
|
||||||
| Russia pogodaiklimat | Moscow | ✅ |
|
| Russia pogodaiklimat | Moscow | ❌ 已移除 |
|
||||||
|
|
||||||
## 可选 / 已禁用
|
## 可选 / 已禁用
|
||||||
|
|
||||||
@@ -38,7 +35,7 @@
|
|||||||
| -------------- | ------------- | ----------- |
|
| -------------- | ------------- | ----------- |
|
||||||
| OpenWeatherMap | 天气 fallback | ⚠️ 未配 key |
|
| OpenWeatherMap | 天气 fallback | ⚠️ 未配 key |
|
||||||
| VisualCrossing | 历史天气 | ⚠️ 未配 key |
|
| VisualCrossing | 历史天气 | ⚠️ 未配 key |
|
||||||
| Meteoblue | 天气预报 | ⚠️ 未配 key |
|
| Meteoblue | 天气预报 | ❌ 已移除 |
|
||||||
| SynopticData | 美国站点观测 | ⚠️ 未配 key |
|
| SynopticData | 美国站点观测 | ⚠️ 未配 key |
|
||||||
|
|
||||||
## AI / 其他
|
## AI / 其他
|
||||||
@@ -47,10 +44,10 @@
|
|||||||
| ----------------- | ---------------- | ----------- |
|
| ----------------- | ---------------- | ----------- |
|
||||||
| MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 |
|
| MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 |
|
||||||
| DeepSeek | AI fallback | - 备用 |
|
| DeepSeek | AI fallback | - 备用 |
|
||||||
| Groq | AI commentary | ⚠️ 未配 key |
|
| Groq | AI commentary | ❌ 已移除 |
|
||||||
| Polygon RPC | 链上支付 | ✅ |
|
| Polygon RPC | 链上支付 | ✅ |
|
||||||
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key |
|
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key |
|
||||||
|
|
||||||
## 合计
|
## 合计
|
||||||
|
|
||||||
18 个在用,6 个可选/未配置。
|
15 个在用,3 个可选/未配置,3 个已移除。
|
||||||
|
|||||||
@@ -92,20 +92,7 @@ POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true
|
|||||||
POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
|
POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. 钱包异动频道拆分(推荐)
|
## 5. 验证步骤
|
||||||
|
|
||||||
如果要把“钱包异动监控”发到独立频道:
|
|
||||||
|
|
||||||
```env
|
|
||||||
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=-1003821482461
|
|
||||||
```
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 设置了 `POLYMARKET_WALLET_ACTIVITY_CHAT_ID(S)` 后,钱包异动推送优先发该频道。
|
|
||||||
- 未设置时,回退到全局 `TELEGRAM_CHAT_IDS/TELEGRAM_CHAT_ID`。
|
|
||||||
|
|
||||||
## 6. 验证步骤
|
|
||||||
|
|
||||||
1. 登录后请求 `/api/auth/me`,确认 `authenticated=true`。
|
1. 登录后请求 `/api/auth/me`,确认 `authenticated=true`。
|
||||||
2. 请求 `/api/payments/config`,确认 `enabled=true`、`configured=true`。
|
2. 请求 `/api/payments/config`,确认 `enabled=true`、`configured=true`。
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ flowchart TD
|
|||||||
- 支付运行态 API 与 SQLite 审计事件已补齐。
|
- 支付运行态 API 与 SQLite 审计事件已补齐。
|
||||||
- 钱包绑定支持浏览器钱包 + WalletConnect。
|
- 钱包绑定支持浏览器钱包 + WalletConnect。
|
||||||
- 账户中心与 Pro 权限展示链路打通。
|
- 账户中心与 Pro 权限展示链路打通。
|
||||||
- 钱包异动支持独立频道路由。
|
|
||||||
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口。
|
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口。
|
||||||
- 轻量可观测性已上线(`/healthz`、`/api/system/status`、`/metrics`)。
|
- 轻量可观测性已上线(`/healthz`、`/api/system/status`、`/metrics`)。
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
## 执行摘要
|
## 执行摘要
|
||||||
|
|
||||||
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的“生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution)**,并进一步映射到市场报价完成**错价扫描**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
|
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution)**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
|
||||||
|
|
||||||
|
> **2026-05-23 更新(v1.7.0)**:Polymarket 价格拉取与 UI 层(MarketDecisionLine)已删除,`market_scan` 当前返回空;LGBM 已完全移除,概率引擎仅保留 legacy 高斯 + EMOS/CRPS;Groq、Meteoblue、NMC、pogodaiklimat 数据源和 prewarm 预热系统已移除。
|
||||||
## 项目概览
|
## 项目概览
|
||||||
|
|
||||||
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
|
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
|
||||||
@@ -12,7 +14,7 @@ PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为
|
|||||||
DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;运行态仍维护近 14 天 `daily_records` 缓存做当前对账,但长期监督真值与训练特征已经迁到 SQLite 永久表中,并支持基于 WU(Weather Underground 口径)四舍五入的结算命中评估。
|
DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;运行态仍维护近 14 天 `daily_records` 缓存做当前对账,但长期监督真值与训练特征已经迁到 SQLite 永久表中,并支持基于 WU(Weather Underground 口径)四舍五入的结算命中评估。
|
||||||
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
|
趋势/概率引擎在 `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 或当前实测。
|
**城市决策层(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” 严格匹配,计算“模型-市场差”(模型概率 − 市场隐含概率)生成信号标签。
|
**市场层(Polymarket 行情对照)**:*[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。
|
||||||
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
|
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
|
||||||
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
|
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
|
||||||
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。
|
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。
|
||||||
@@ -29,7 +31,7 @@ DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
|
|||||||
| 运行时组件 | `frontend/` | Next.js 前端(Vercel) | 前端重构报告提到 App Router、Route Handlers(BFF)、缓存策略、支付与账户中心等;Scan Terminal 已新增城市决策卡、AI 机场报文解读、页面内存/localStorage 双层缓存、AI stream 小并发队列与完整市场桶映射。 |
|
| 运行时组件 | `frontend/` | Next.js 前端(Vercel) | 前端重构报告提到 App Router、Route Handlers(BFF)、缓存策略、支付与账户中心等;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 的非流式重试。 |
|
| 运行时组件 | `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 的非流式重试。 |
|
||||||
| 运行时组件 | `bot_listener.py` + `src/bot/*` | Telegram Bot | 入口 `bot_listener.py` 调 `start_bot()`,并由 `StartupCoordinator` 启动多个后台 loop。 |
|
| 运行时组件 | `bot_listener.py` + `src/bot/*` | Telegram Bot | 入口 `bot_listener.py` 调 `start_bot()`,并由 `StartupCoordinator` 启动多个后台 loop。 |
|
||||||
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 + 市场读取 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py`、`settlement_sources.py`、`metar_sources.py`、`mgm_sources.py`、`amos_station_sources.py`、`jma_amedas_sources.py`、`nws_open_meteo_sources.py`。 |
|
| 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/analysis/*` | DEB/趋势/概率/结算口径 | `deb_algorithm.py`、`trend_engine.py`、`settlement_rounding.py`。 |
|
||||||
| Python 域模块 | `src/payments/*` + `contracts/*` | 支付合约 + 事件监听/补单 | Solidity 合约 + Python 侧事件扫描/确认循环 + SQLite 审计事件 + RPC 多节点容灾 + 合约静态检查。 |
|
| 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 必须保密)。 |
|
| Python 域模块 | `src/auth/*`、`docs/SUPABASE_SETUP_ZH.md`、`scripts/supabase/schema.sql` | Supabase 鉴权/订阅/积分 | 使用 `/auth/v1/user` 校验 JWT、`/rest/v1/subscriptions` 查订阅(服务端角色 key 必须保密)。 |
|
||||||
@@ -68,8 +70,6 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
|||||||
NWS[api.weather.gov]
|
NWS[api.weather.gov]
|
||||||
HKO[data.weather.gov.hk]
|
HKO[data.weather.gov.hk]
|
||||||
CWA[opendata.cwa.gov.tw]
|
CWA[opendata.cwa.gov.tw]
|
||||||
PM_G[Polymarket Gamma API]
|
|
||||||
PM_C[Polymarket CLOB API]
|
|
||||||
SB[Supabase Auth/REST]
|
SB[Supabase Auth/REST]
|
||||||
RPC[Polygon RPC]
|
RPC[Polygon RPC]
|
||||||
end
|
end
|
||||||
@@ -96,8 +96,6 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
|||||||
WX --> HKO
|
WX --> HKO
|
||||||
WX --> CWA
|
WX --> CWA
|
||||||
|
|
||||||
FAST --> PM_G
|
|
||||||
FAST --> PM_C
|
|
||||||
FAST --> LLM
|
FAST --> LLM
|
||||||
|
|
||||||
FAST --> SB
|
FAST --> SB
|
||||||
@@ -161,13 +159,15 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
|||||||
**CI 已建立,但组织级质量门禁未必完全收口**:CI 现已覆盖 Python、前端与 Docker build。当前问题不再是“缺 CI”,而是是否把这些 status check 绑定到 `main` 保护策略,以及是否逐步引入更严格的 pre-merge 审查。
|
**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 文件路径主要是显式回退入口,而不再是默认主输入。
|
**运行态状态/缓存与核心离线链路的 SQLite 收口已完成**:`daily_records`、`telegram_alert_state`、`probability_training_snapshots`、`open_meteo` 缓存已经支持并在生产中主读 SQLite,迁移/校验脚本可用;进一步地,在临时移除 `data/*.json` / `data/*.jsonl` 后,训练集导出、概率拟合、评估报告、shadow report 和关键 backfill 脚本已验证仍可运行。当前 legacy 文件路径主要是显式回退入口,而不再是默认主输入。
|
||||||
**第三方服务合规与稳定性风险**:
|
**第三方服务合规与稳定性风险**:
|
||||||
项目强依赖外部 API(Open-Meteo、AviationWeather、global.amo.go.kr AMOS、NWS、HKO、CWA、Polymarket、Supabase)以及城市 AI provider(OpenAI-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。
|
项目强依赖外部 API(Open-Meteo、AviationWeather、global.amo.go.kr AMOS、NWS、HKO、CWA、Supabase)以及城市 AI provider(OpenAI-compatible stream,当前使用 MiMo)。其中 AviationWeather Data API 有明确速率限制;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市 AI 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
|
||||||
|
|
||||||
|
> **v1.7.0 更新**:Polymarket(Gamma/CLOB)API 依赖已随市场价格拉取层一并移除。
|
||||||
**许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部神经天气模型,仍需单独核验第三方代码与权重的商用条件:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
|
**许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部神经天气模型,仍需单独核验第三方代码与权重的商用条件:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
|
||||||
## 对标分析
|
## 对标分析
|
||||||
|
|
||||||
为满足“至少 3 个相似开源项目或近期论文”对标,本报告选择三类代表:
|
为满足“至少 3 个相似开源项目或近期论文”对标,本报告选择三类代表:
|
||||||
1)**AI 气象预报模型**(GraphCast / FourCastNet / Pangu-Weather):用于评估“若 PolyWeather 未来扩展到更强预测能力”的技术与许可边界;
|
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(商业限制)。 |
|
| **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(代码)。 |
|
| **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、明确禁止商业用途。 |
|
| **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。 |
|
| **Polymarket/py-clob-client** | Polymarket CLOB 读写 SDK | Python SDK | *[2026-05 起不再使用]* 官方 SDK,支持 read-only 与交易接口。 | 曾用作 PolyWeather 市场层参考。 | MIT。 |
|
||||||
| **aiopolymarket** | Polymarket APIs 的 async 客户端 | Python async 客户端 | 强调类型安全(Pydantic)、自动分页、重试与 backoff,适合高并发与健壮性诉求。 | 适合替换/补强当前同步 requests 与自定义缓存。 | 以仓库许可为准(此处建议上线前核验)。 |
|
| **aiopolymarket** | Polymarket APIs 的 async 客户端 | Python async 客户端 | *[2026-05 起不再使用]* 类型安全(Pydantic)、自动分页、重试与 backoff。 | 曾用作市场层升级候选。 | 以仓库许可为准。 |
|
||||||
|
|
||||||
**对标结论**:PolyWeather 与这类“全球神经天气模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。
|
**对标结论**:PolyWeather 与这类“全球神经天气模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。
|
||||||
## 优先级改进建议
|
## 优先级改进建议
|
||||||
|
|
||||||
下表按截至 `2026-05-11` 的真实状态重排优先级。已完成项不再继续列为“待做”,只保留当前仍需推进的事项。
|
下表按截至 `2026-05-23` 的真实状态重排优先级。已完成项不再继续列为”待做”,只保留当前仍需推进的事项。
|
||||||
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
|
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
|
||||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 中 | **把最小外部监控继续补深**:从“可告警”提升到“可运营” | 3–7 天 | 不再只知道服务坏没坏,还能看资源趋势、来源 SLA 和支付波动 | 指标过多会带来维护噪音 | 1) 增加节点 CPU/内存/磁盘 → 2) 增加 SQLite/支付体积与事件趋势 → 3) 把 HTTP/来源指标细分到城市/来源维度 → 4) 增加日报或异常摘要 |
|
| 中 | **把最小外部监控继续补深**:从“可告警”提升到“可运营” | 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 建议同步进部署文档 |
|
| 中 | **城市决策卡 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,减少解析失败 |
|
| - | ~~市场层升级为 async + 类型安全~~ | N/A | *[v1.7.0 已移除]* 市场价格拉取层已删除,此改进项不再适用 | - | - |
|
||||||
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 1–2 周 | 在已完成的链下审计与容灾之上,进一步收紧链上授权边界 | 合约升级需要重新部署、迁移配置并再次验证 | 1) 维持现有事件重放、SQLite 审计、多 RPC fallback → 2) 升级合约到 SafeERC20 + Pausable → 3) 评估链上 plan/amount/token 绑定或 EIP-712 签名校验 → 4) 迁移后更新 PolygonScan 验证与支付审计文档 |
|
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 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) 明确热修例外流程 |
|
| 中 | **将 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) 若要商用,优先选择可商用权重或自研/购买授权 |
|
| 低 | **引入外部神经天气模型作为附加信号**(GraphCast/FourCastNet/Pangu-Weather 等) | 2–6 周(取决于范围) | 可能提升极端/中期预测能力与差异化 | **商业许可限制**(多为 CC BY-NC-SA/禁止商业)与算力成本 | 1) 先做合规评审(权重许可/数据条款)→ 2) 仅在研究/非商业环境评估 → 3) 若要商用,优先选择可商用权重或自研/购买授权 |
|
||||||
@@ -194,7 +194,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
|||||||
### 文档、测试与贡献流程的具体补强建议(落到仓库层面)
|
### 文档、测试与贡献流程的具体补强建议(落到仓库层面)
|
||||||
|
|
||||||
1)**文档体系**:保留现有中文 API/TechDebt 文档的同时,增加三份“高价值”文档:
|
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/生产)列必需变量、默认值、敏感等级,并明确城市 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、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、城市 AI stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。
|
||||||
2)**测试金字塔**:在现有 `trend_engine` 单测基础上,补齐:
|
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 + 事件扫描回放)。这些测试能把“外部依赖漂移”尽量转成可控的回归失败。
|
(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 必过项。
|
3)**贡献工作流**:引入 `CONTRIBUTING.md`(分支策略、PR 模板、变更日志、版本号策略)、`CODEOWNERS`(核心模块审查人)、`SECURITY.md`(漏洞披露与密钥处理),并把静态检查(ruff/eslint)作为 pre-commit + CI 必过项。
|
||||||
@@ -219,21 +219,23 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
|||||||
|
|
||||||
- 若历史样本足够,DEB 应在“系统性偏差明显”的城市提升 MAE;
|
- 若历史样本足够,DEB 应在“系统性偏差明显”的城市提升 MAE;
|
||||||
**算力**:以上评测全部可在 CPU 上完成;数据量按“51 城市 × 180 天”级别,pandas/duckdb 即可。若引入更复杂拟合(如分层贝叶斯/分位数回归),也通常不需要 GPU。
|
**算力**:以上评测全部可在 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(官方文档说明三套 API:Gamma/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;
|
- Signal 覆盖率:能否找到正确 market / bucket;
|
||||||
- Edge 稳健性:不同流动性分位的 edge 分布;
|
- Edge 稳健性:不同流动性分位的 edge 分布;
|
||||||
- 交易模拟(如需):在考虑滑点/手续费/成交概率下的期望收益(即使项目当前只读,也可以离线评估“若执行”会怎样)。
|
- 交易模拟(如需):在考虑滑点/手续费/成交概率下的期望收益。
|
||||||
**基线**
|
**基线**
|
||||||
|
|
||||||
- 简单策略:仅用市场中间价(不做模型)作为概率;
|
- 简单策略:仅用市场中间价(不做模型)作为概率;
|
||||||
- 当前策略:模型概率 vs 市场概率 edge 阈值;
|
- 当前策略:模型概率 vs 市场概率 edge 阈值;
|
||||||
- 改进策略:引入“流动性/盘口深度/波动”作为信号置信度(aiopolymarket/py-clob-client 提供更完整的盘口读取能力)。
|
- 改进策略:引入”流动性/盘口深度/波动”作为信号置信度。
|
||||||
**算力**:CPU 即可;关键在于数据采样与回放。
|
**算力**:CPU 即可;关键在于数据采样与回放。
|
||||||
## 路线图与风险缓解
|
## 路线图与风险缓解
|
||||||
|
|
||||||
@@ -254,6 +256,8 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
|||||||
**代码公开与生产私有资产边界导致的“公开仓库与生产行为不一致”**:README 明确品牌、商标、生产私有数据与运营阈值不在代码许可证授权范围内。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
|
**代码公开与生产私有资产边界导致的“公开仓库与生产行为不一致”**:README 明确品牌、商标、生产私有数据与运营阈值不在代码许可证授权范围内。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
|
||||||
## 参考链接
|
## 参考链接
|
||||||
|
|
||||||
|
> **v1.7.0 注**:以下 Polymarket 相关链接已不再被项目使用,保留作为历史参考。
|
||||||
|
|
||||||
- PolyWeather 仓库(本次评估对象):https://github.com/yangyuan-zhen/PolyWeather
|
- PolyWeather 仓库(本次评估对象):https://github.com/yangyuan-zhen/PolyWeather
|
||||||
- Polymarket API 文档(Gamma/Data/CLOB):https://docs.polymarket.com/api-reference
|
- Polymarket API 文档(Gamma/Data/CLOB):https://docs.polymarket.com/api-reference
|
||||||
- AviationWeather Data API(METAR 等):https://aviationweather.gov/data/api/
|
- AviationWeather Data API(METAR 等):https://aviationweather.gov/data/api/
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
|
|||||||
NEXT_PUBLIC_SUPABASE_URL=
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||||
|
|
||||||
|
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||||
|
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||||
|
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
||||||
|
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
|
||||||
|
|
||||||
# 常用:前端鉴权开关
|
# 常用:前端鉴权开关
|
||||||
# true: 启用 Supabase 登录
|
# true: 启用 Supabase 登录
|
||||||
# false: 关闭登录能力,访客模式
|
# false: 关闭登录能力,访客模式
|
||||||
@@ -39,5 +44,5 @@ 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-pro.vercel.app
|
||||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
||||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||||
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=WeatherQuant_bot
|
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
|
||||||
|
|||||||
+7
-2
@@ -15,6 +15,11 @@ NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
|
|||||||
NEXT_PUBLIC_SUPABASE_URL=
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||||
|
|
||||||
|
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||||
|
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||||
|
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
||||||
|
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
|
||||||
|
|
||||||
# 常用:前端鉴权开关
|
# 常用:前端鉴权开关
|
||||||
# true: 启用 Supabase 登录
|
# true: 启用 Supabase 登录
|
||||||
# false: 关闭登录能力,访客模式
|
# false: 关闭登录能力,访客模式
|
||||||
@@ -39,5 +44,5 @@ 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-pro.vercel.app
|
||||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
||||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||||
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=WeatherQuant_bot
|
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
|
||||||
|
|||||||
+4
-5
@@ -40,8 +40,8 @@ PolyWeather Pro 的生产前端工程。
|
|||||||
- 城市决策卡的 AI 机场报文解读包括最终判断、METAR 解读、推理说明、模型集群备注、风险提示和原始 METAR
|
- 城市决策卡的 AI 机场报文解读包括最终判断、METAR 解读、推理说明、模型集群备注、风险提示和原始 METAR
|
||||||
- AI 机场报文解读按 `city + local_date + locale + METAR signature` 做页面内存缓存和 `localStorage` 最终结果缓存;切换选项卡返回时会优先恢复已有内容
|
- AI 机场报文解读按 `city + local_date + locale + METAR signature` 做页面内存缓存和 `localStorage` 最终结果缓存;切换选项卡返回时会优先恢复已有内容
|
||||||
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
|
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
|
||||||
- 概率区展示当前生产概率引擎输出;EMOS / LGBM 只在评估通过或 shadow 对照时进入解释层,模型共识和市场价格只作为辅助说明
|
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
|
||||||
- `/ops` 现已展示 prewarm worker 运行态、缓存桶状态与 summary cache hit/miss
|
- 缓存桶状态与 summary cache hit/miss
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
|||||||
|
|
||||||
# 社群入口
|
# 社群入口
|
||||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
||||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||||
|
|
||||||
# 推荐默认关闭的前端观测 / 预热开关
|
# 推荐默认关闭的前端观测 / 预热开关
|
||||||
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
|
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
|
||||||
@@ -150,7 +150,6 @@ Ops:
|
|||||||
|
|
||||||
- 系统状态
|
- 系统状态
|
||||||
- SQLite / rollout / 支付运行态
|
- SQLite / rollout / 支付运行态
|
||||||
- prewarm worker 运行态
|
|
||||||
- 缓存桶状态与 summary cache hit/miss
|
- 缓存桶状态与 summary cache hit/miss
|
||||||
- 用户查询
|
- 用户查询
|
||||||
- 当前会员
|
- 当前会员
|
||||||
@@ -201,4 +200,4 @@ Ops:
|
|||||||
|
|
||||||
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
|
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
|
||||||
|
|
||||||
最后更新:`2026-04-19`
|
最后更新:`2026-05-23`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import {
|
import {
|
||||||
buildProxyExceptionResponse,
|
buildProxyExceptionResponse,
|
||||||
@@ -24,6 +25,8 @@ export async function POST(
|
|||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const proxiedHeaders = new Headers(auth.headers);
|
const proxiedHeaders = new Headers(auth.headers);
|
||||||
proxiedHeaders.set("Content-Type", "application/json");
|
proxiedHeaders.set("Content-Type", "application/json");
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import {
|
import {
|
||||||
buildProxyExceptionResponse,
|
buildProxyExceptionResponse,
|
||||||
@@ -24,6 +25,8 @@ export async function POST(
|
|||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const proxiedHeaders = new Headers(auth.headers);
|
const proxiedHeaders = new Headers(auth.headers);
|
||||||
proxiedHeaders.set("Content-Type", "application/json");
|
proxiedHeaders.set("Content-Type", "application/json");
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import {
|
import {
|
||||||
buildProxyExceptionResponse,
|
buildProxyExceptionResponse,
|
||||||
@@ -24,6 +25,8 @@ export async function POST(
|
|||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const proxiedHeaders = new Headers(auth.headers);
|
const proxiedHeaders = new Headers(auth.headers);
|
||||||
proxiedHeaders.set("Content-Type", "application/json");
|
proxiedHeaders.set("Content-Type", "application/json");
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import {
|
import {
|
||||||
buildProxyExceptionResponse,
|
buildProxyExceptionResponse,
|
||||||
@@ -35,6 +36,8 @@ export async function POST(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const proxiedHeaders = new Headers(auth.headers);
|
const proxiedHeaders = new Headers(auth.headers);
|
||||||
proxiedHeaders.set("Content-Type", "application/json");
|
proxiedHeaders.set("Content-Type", "application/json");
|
||||||
const res = await fetch(`${API_BASE}/api/payments/intents`, {
|
const res = await fetch(`${API_BASE}/api/payments/intents`, {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
|
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: auth.headers,
|
headers: auth.headers,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import {
|
import {
|
||||||
buildProxyExceptionResponse,
|
buildProxyExceptionResponse,
|
||||||
@@ -20,6 +21,8 @@ export async function POST(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const proxiedHeaders = new Headers(auth.headers);
|
const proxiedHeaders = new Headers(auth.headers);
|
||||||
proxiedHeaders.set("Content-Type", "application/json");
|
proxiedHeaders.set("Content-Type", "application/json");
|
||||||
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
|
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import {
|
import {
|
||||||
buildProxyExceptionResponse,
|
buildProxyExceptionResponse,
|
||||||
@@ -44,6 +45,8 @@ export async function DELETE(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const proxiedHeaders = new Headers(auth.headers);
|
const proxiedHeaders = new Headers(auth.headers);
|
||||||
proxiedHeaders.set("Content-Type", "application/json");
|
proxiedHeaders.set("Content-Type", "application/json");
|
||||||
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
|
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import {
|
import {
|
||||||
buildProxyExceptionResponse,
|
buildProxyExceptionResponse,
|
||||||
@@ -20,6 +21,8 @@ export async function POST(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const proxiedHeaders = new Headers(auth.headers);
|
const proxiedHeaders = new Headers(auth.headers);
|
||||||
proxiedHeaders.set("Content-Type", "application/json");
|
proxiedHeaders.set("Content-Type", "application/json");
|
||||||
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
|
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
|
||||||
|
import { getConfiguredSiteUrl } from "@/lib/site-url";
|
||||||
|
|
||||||
function normalizeNextPath(input: string | null) {
|
function normalizeNextPath(input: string | null) {
|
||||||
const fallback = "/";
|
const fallback = "/";
|
||||||
@@ -11,6 +12,16 @@ function normalizeNextPath(input: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
const configuredSiteUrl = getConfiguredSiteUrl();
|
||||||
|
if (configuredSiteUrl) {
|
||||||
|
const canonicalOrigin = new URL(configuredSiteUrl).origin;
|
||||||
|
if (request.nextUrl.origin !== canonicalOrigin) {
|
||||||
|
const canonicalCallbackUrl = new URL(request.nextUrl.pathname, canonicalOrigin);
|
||||||
|
canonicalCallbackUrl.search = request.nextUrl.search;
|
||||||
|
return NextResponse.redirect(canonicalCallbackUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
|
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
|
||||||
const redirectUrl = request.nextUrl.clone();
|
const redirectUrl = request.nextUrl.clone();
|
||||||
redirectUrl.pathname = nextPath;
|
redirectUrl.pathname = nextPath;
|
||||||
@@ -29,4 +40,3 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+61
-110
@@ -9,28 +9,28 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
/* ── Background Scale ── */
|
/* ── Background Scale ── */
|
||||||
--color-bg-base: #0B1220;
|
--color-bg-base: #f4f7fb;
|
||||||
--color-bg-raised: #111A2E;
|
--color-bg-raised: #ffffff;
|
||||||
--color-bg-overlay: #16213A;
|
--color-bg-overlay: #ffffff;
|
||||||
--color-bg-card: rgba(17, 26, 46, 0.88);
|
--color-bg-card: rgba(255, 255, 255, 0.95);
|
||||||
--color-bg-input: rgba(22, 33, 58, 0.72);
|
--color-bg-input: rgba(241, 245, 249, 0.88);
|
||||||
|
|
||||||
/* ── Text Scale ── */
|
/* ── Text Scale ── */
|
||||||
--color-text-primary: #E6EDF3;
|
--color-text-primary: #0F172A;
|
||||||
--color-text-secondary: #9FB2C7;
|
--color-text-secondary: #334155;
|
||||||
--color-text-muted: #7D8FA3;
|
--color-text-muted: #475569;
|
||||||
--color-text-disabled: #7D8FA3;
|
--color-text-disabled: #94A3B8;
|
||||||
|
|
||||||
/* ── Accent Colors ── */
|
/* ── Accent Colors ── */
|
||||||
--color-accent-primary: #4DA3FF;
|
--color-accent-primary: #2563EB;
|
||||||
--color-accent-secondary: #6FB7FF;
|
--color-accent-secondary: #3B82F6;
|
||||||
--color-accent-tertiary: #93C5FD;
|
--color-accent-tertiary: #60A5FA;
|
||||||
|
|
||||||
/* ── Signal / Semantic Colors ── */
|
/* ── Signal / Semantic Colors ── */
|
||||||
--color-signal-success: #22C55E;
|
--color-signal-success: #00897b;
|
||||||
--color-signal-warning: #F59E0B;
|
--color-signal-warning: #d97706;
|
||||||
--color-signal-danger: #EF4444;
|
--color-signal-danger: #dc2626;
|
||||||
--color-signal-info: #4DA3FF;
|
--color-signal-info: #2563eb;
|
||||||
|
|
||||||
/* ── Risk Colors (aliased from signal) ── */
|
/* ── Risk Colors (aliased from signal) ── */
|
||||||
--color-risk-high: var(--color-signal-danger);
|
--color-risk-high: var(--color-signal-danger);
|
||||||
@@ -38,16 +38,16 @@
|
|||||||
--color-risk-low: var(--color-signal-success);
|
--color-risk-low: var(--color-signal-success);
|
||||||
|
|
||||||
/* ── Border ── */
|
/* ── Border ── */
|
||||||
--color-border-default: rgba(159, 178, 199, 0.16);
|
--color-border-default: #d8e0ec;
|
||||||
--color-border-hover: rgba(77, 163, 255, 0.38);
|
--color-border-hover: #b8c4d6;
|
||||||
--color-border-subtle: rgba(159, 178, 199, 0.08);
|
--color-border-subtle: #e8edf5;
|
||||||
|
|
||||||
/* ── Shadow / Elevation ── */
|
/* ── Shadow / Elevation ── */
|
||||||
--shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.3);
|
--shadow-elevation-1: 0 1px 3px rgba(15, 23, 42, 0.05);
|
||||||
--shadow-elevation-2: 0 8px 24px rgba(0, 0, 0, 0.45);
|
--shadow-elevation-2: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||||
--shadow-elevation-3: 0 20px 60px rgba(0, 0, 0, 0.6);
|
--shadow-elevation-3: 0 20px 60px rgba(15, 23, 42, 0.08);
|
||||||
--shadow-glow-accent: 0 0 20px rgba(77, 163, 255, 0.24);
|
--shadow-glow-accent: 0 0 20px rgba(37, 99, 235, 0.08);
|
||||||
--shadow-glow-secondary: 0 0 20px rgba(111, 183, 255, 0.22);
|
--shadow-glow-secondary: 0 0 20px rgba(96, 165, 250, 0.08);
|
||||||
|
|
||||||
/* ── Typography ── */
|
/* ── Typography ── */
|
||||||
--font-data: var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
--font-data: var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
@@ -66,19 +66,19 @@
|
|||||||
--space-12: 48px;
|
--space-12: 48px;
|
||||||
|
|
||||||
/* ── Border Radius ── */
|
/* ── Border Radius ── */
|
||||||
--radius-sm: 6px;
|
--radius-sm: 4px;
|
||||||
--radius-md: 10px;
|
--radius-md: 6px;
|
||||||
--radius-lg: 14px;
|
--radius-lg: 10px;
|
||||||
--radius-xl: 20px;
|
--radius-xl: 14px;
|
||||||
--radius-full: 9999px;
|
--radius-full: 9999px;
|
||||||
|
|
||||||
/* ── Glass / Blur ── */
|
/* ── Glass / Blur ── */
|
||||||
--glass-blur-1: blur(10px);
|
--glass-blur-1: blur(10px);
|
||||||
--glass-blur-2: blur(16px);
|
--glass-blur-2: blur(16px);
|
||||||
--glass-blur-3: blur(24px);
|
--glass-blur-3: blur(24px);
|
||||||
--glass-opacity-1: 0.72;
|
--glass-opacity-1: 0.86;
|
||||||
--glass-opacity-2: 0.85;
|
--glass-opacity-2: 0.92;
|
||||||
--glass-opacity-3: 0.92;
|
--glass-opacity-3: 0.96;
|
||||||
|
|
||||||
/* ── Layout ── */
|
/* ── Layout ── */
|
||||||
--header-height: 52px;
|
--header-height: 52px;
|
||||||
@@ -91,8 +91,7 @@
|
|||||||
--transition-slow: 400ms cubic-bezier(0.16, 1, 0.3, 1);
|
--transition-slow: 400ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
--transition: var(--transition-base);
|
--transition: var(--transition-base);
|
||||||
|
|
||||||
/* ── Legacy Variable Aliases (bridge for older CSS modules) ── */
|
/* ── Legacy Variable Aliases ── */
|
||||||
/* These map old shorthand names → current design token names. */
|
|
||||||
--accent-cyan: var(--color-accent-primary);
|
--accent-cyan: var(--color-accent-primary);
|
||||||
--accent-blue: var(--color-accent-secondary);
|
--accent-blue: var(--color-accent-secondary);
|
||||||
--accent-green: var(--color-signal-success);
|
--accent-green: var(--color-signal-success);
|
||||||
@@ -111,68 +110,36 @@
|
|||||||
--shadow-lg: var(--shadow-elevation-2);
|
--shadow-lg: var(--shadow-elevation-2);
|
||||||
--glass-blur: 10px;
|
--glass-blur: 10px;
|
||||||
|
|
||||||
/* ── shadcn/ui Tokens (used by Tailwind @apply border-border) ── */
|
/* ── shadcn/ui Tokens ── */
|
||||||
--background: 223 53% 4%;
|
--background: 210 40% 98%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 222 47% 12%;
|
||||||
--card: 223 46% 8%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 222 47% 12%;
|
||||||
--primary: 159 100% 44%;
|
--primary: 221 83% 53%;
|
||||||
--primary-foreground: 222 47% 8%;
|
--primary-foreground: 210 40% 98%;
|
||||||
--secondary: 224 30% 14%;
|
--secondary: 210 40% 96%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 222 47% 12%;
|
||||||
--accent: 217 30% 18%;
|
--accent: 210 40% 96%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: 222 47% 12%;
|
||||||
--border: 221 38% 22%;
|
--border: 214 32% 91%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Light Theme Token Overrides ── */
|
/* ── Monospaced numbers & data globally for professional feel ── */
|
||||||
html.light,
|
.font-mono,
|
||||||
html[data-theme="light"] {
|
.nearby-temp,
|
||||||
--color-bg-base: #F7F9FC;
|
.nearby-wind,
|
||||||
--color-bg-raised: #EEF2F7;
|
.nearby-time,
|
||||||
--color-bg-overlay: #FFFFFF;
|
.nearby-marker,
|
||||||
--color-bg-card: rgba(255, 255, 255, 0.92);
|
.marker-bubble,
|
||||||
--color-bg-input: rgba(238, 242, 247, 0.88);
|
.map-pill,
|
||||||
|
[class*="temp"],
|
||||||
--color-text-primary: #0F172A;
|
[class*="value"],
|
||||||
--color-text-secondary: #334155;
|
[class*="price"],
|
||||||
--color-text-muted: #475569;
|
[class*="number"],
|
||||||
--color-text-disabled: #94A3B8;
|
[class*="stat"],
|
||||||
|
[class*="score"],
|
||||||
--color-accent-primary: #2563EB;
|
[class*="time-"] {
|
||||||
--color-accent-secondary: #3B82F6;
|
font-family: var(--font-mono) !important;
|
||||||
--color-accent-tertiary: #60A5FA;
|
|
||||||
|
|
||||||
--color-border-default: rgba(148, 163, 184, 0.24);
|
|
||||||
--color-border-hover: rgba(37, 99, 235, 0.38);
|
|
||||||
--color-border-subtle: rgba(148, 163, 184, 0.12);
|
|
||||||
|
|
||||||
--shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
--shadow-elevation-2: 0 8px 24px rgba(40, 70, 110, 0.12);
|
|
||||||
--shadow-elevation-3: 0 20px 60px rgba(40, 70, 110, 0.15);
|
|
||||||
--shadow-glow-accent: 0 0 20px rgba(37, 99, 235, 0.14);
|
|
||||||
--shadow-glow-secondary: 0 0 20px rgba(96, 165, 250, 0.12);
|
|
||||||
|
|
||||||
--glass-blur-1: blur(10px);
|
|
||||||
--glass-blur-2: blur(16px);
|
|
||||||
--glass-blur-3: blur(24px);
|
|
||||||
--glass-opacity-1: 0.86;
|
|
||||||
--glass-opacity-2: 0.92;
|
|
||||||
--glass-opacity-3: 0.96;
|
|
||||||
|
|
||||||
/* ── Legacy alias overrides for light theme ── */
|
|
||||||
--accent-cyan: var(--color-accent-primary);
|
|
||||||
--accent-blue: var(--color-accent-secondary);
|
|
||||||
--bg-primary: var(--color-bg-base);
|
|
||||||
--bg-secondary: var(--color-bg-raised);
|
|
||||||
--bg-card: var(--color-bg-card);
|
|
||||||
--bg-glass: var(--color-bg-card);
|
|
||||||
--border-glass: var(--color-border-default);
|
|
||||||
--border-subtle: var(--color-border-subtle);
|
|
||||||
--text-primary: var(--color-text-primary);
|
|
||||||
--text-secondary: var(--color-text-secondary);
|
|
||||||
--text-muted: var(--color-text-muted);
|
|
||||||
--shadow-lg: var(--shadow-elevation-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -207,23 +174,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-data);
|
font-family: var(--font-data);
|
||||||
background:
|
background: var(--color-bg-base);
|
||||||
radial-gradient(
|
|
||||||
circle at 10% -10%,
|
|
||||||
rgba(0, 224, 164, 0.1),
|
|
||||||
transparent 40%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
circle at 90% 0%,
|
|
||||||
rgba(123, 97, 255, 0.08),
|
|
||||||
transparent 36%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
circle at 80% 100%,
|
|
||||||
rgba(0, 224, 164, 0.06),
|
|
||||||
transparent 48%
|
|
||||||
),
|
|
||||||
var(--color-bg-base);
|
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="zh-CN"
|
lang="zh-CN"
|
||||||
className={`${inter.variable} ${jetbrainsMono.variable} dark`}
|
className={`${inter.variable} ${jetbrainsMono.variable} light`}
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
+22
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { DashboardEntry } from "@/components/dashboard/DashboardEntry";
|
import { DashboardEntry } from "@/components/dashboard/DashboardEntry";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -7,6 +8,26 @@ export const metadata: Metadata = {
|
|||||||
"PolyWeather dashboard with METAR, MGM, DEB fusion forecast, multi-model comparison, and AI weather decision cards.",
|
"PolyWeather dashboard with METAR, MGM, DEB fusion forecast, multi-model comparison, and AI weather decision cards.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function HomePage() {
|
export default async function HomePage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const code = params.code;
|
||||||
|
if (typeof code === "string" && code.trim()) {
|
||||||
|
const usp = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value != null) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const v of value) usp.append(key, v);
|
||||||
|
} else {
|
||||||
|
usp.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const qs = usp.toString();
|
||||||
|
redirect(`/auth/callback${qs ? `?${qs}` : ""}`);
|
||||||
|
}
|
||||||
return <DashboardEntry />;
|
return <DashboardEntry />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useI18n } from "@/hooks/useI18n";
|
|||||||
|
|
||||||
const TELEGRAM_GROUP_URL = String(
|
const TELEGRAM_GROUP_URL = String(
|
||||||
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
|
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
|
||||||
"https://t.me/+nMG7SjziUKYyZmM1",
|
"https://t.me/+Io5H9oVHFmVjOTQ5",
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
const FAQ_ITEMS = [
|
const FAQ_ITEMS = [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
type InfoRowProps = {
|
||||||
|
icon?: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
isPrimary?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InfoRow = ({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
isPrimary = false,
|
||||||
|
}: InfoRowProps) => (
|
||||||
|
<div className="flex min-w-0 flex-col gap-3 p-4 bg-white/5 rounded-2xl border border-white/5 hover:bg-white/10 transition-all group sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="shrink-0 p-2 bg-slate-800 rounded-lg text-slate-400 group-hover:text-blue-400 transition-colors">
|
||||||
|
{Icon && <Icon size={18} />}
|
||||||
|
</div>
|
||||||
|
<span className="min-w-0 text-slate-400 text-sm font-medium leading-5">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`min-w-0 break-all text-left text-sm font-semibold font-mono sm:text-right ${isPrimary ? "text-blue-400" : "text-slate-200"}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
export function PlusIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPECTED_RECEIVER = "0x351a1bca5f49dd0046a7cf0bafa7e12fa6441c3a";
|
||||||
|
|
||||||
|
export function runTests() {
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
const receiverModulePath = path.join(projectRoot, "lib", "payment-receiver.ts");
|
||||||
|
const backendAuthPath = path.join(projectRoot, "lib", "backend-auth.ts");
|
||||||
|
const middlewarePath = path.join(projectRoot, "middleware.ts");
|
||||||
|
const accountCenterPath = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"components",
|
||||||
|
"account",
|
||||||
|
"AccountCenter.tsx",
|
||||||
|
);
|
||||||
|
const paymentRoutes = [
|
||||||
|
"app/api/payments/wallets/challenge/route.ts",
|
||||||
|
"app/api/payments/wallets/verify/route.ts",
|
||||||
|
"app/api/payments/wallets/route.ts",
|
||||||
|
"app/api/payments/intents/route.ts",
|
||||||
|
"app/api/payments/intents/[intentId]/submit/route.ts",
|
||||||
|
"app/api/payments/intents/[intentId]/confirm/route.ts",
|
||||||
|
"app/api/payments/intents/[intentId]/validate/route.ts",
|
||||||
|
"app/api/payments/reconcile-latest/route.ts",
|
||||||
|
];
|
||||||
|
|
||||||
|
assert(
|
||||||
|
fs.existsSync(receiverModulePath),
|
||||||
|
"payment receiver guard module must exist",
|
||||||
|
);
|
||||||
|
const receiverSource = fs.readFileSync(receiverModulePath, "utf8");
|
||||||
|
assert(
|
||||||
|
receiverSource.includes(EXPECTED_RECEIVER),
|
||||||
|
"payment receiver guard must pin the production receiver address",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
receiverSource.includes("assertExpectedPaymentReceiver"),
|
||||||
|
"payment receiver guard must expose an assertion helper",
|
||||||
|
);
|
||||||
|
|
||||||
|
const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8");
|
||||||
|
assert(
|
||||||
|
accountCenterSource.includes("assertExpectedPaymentReceiver"),
|
||||||
|
"AccountCenter must validate backend-returned manual payment receiver before displaying it",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS"),
|
||||||
|
"AccountCenter must show the pinned payment receiver address in its payment guard",
|
||||||
|
);
|
||||||
|
|
||||||
|
const backendAuthSource = fs.readFileSync(backendAuthPath, "utf8");
|
||||||
|
assert(
|
||||||
|
backendAuthSource.includes("requireBackendAuthUser"),
|
||||||
|
"backend auth helper must expose a real-user requirement for payment mutations",
|
||||||
|
);
|
||||||
|
|
||||||
|
const middlewareSource = fs.readFileSync(middlewarePath, "utf8");
|
||||||
|
assert(
|
||||||
|
!middlewareSource.includes("/^bearer\\s+\\S+/i.test(authHeader)") &&
|
||||||
|
!middlewareSource.includes("return NextResponse.next();\n }\n }\n\n const response = NextResponse.next"),
|
||||||
|
"middleware must not treat the mere presence of a bearer token as authenticated",
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const route of paymentRoutes) {
|
||||||
|
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
|
||||||
|
assert(
|
||||||
|
routeSource.includes("requireBackendAuthUser"),
|
||||||
|
`${route} must reject payment mutations without a real Supabase user`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,47 @@ export function runTests() {
|
|||||||
const serviceWorkerPath = path.join(projectRoot, "public", "sw.js");
|
const serviceWorkerPath = path.join(projectRoot, "public", "sw.js");
|
||||||
|
|
||||||
const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8");
|
const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8");
|
||||||
|
const accountDir = path.dirname(accountCenterPath);
|
||||||
|
const accountSplitFiles = [
|
||||||
|
"types.ts",
|
||||||
|
"constants.ts",
|
||||||
|
"formatters.ts",
|
||||||
|
"wallet.ts",
|
||||||
|
"payment-utils.ts",
|
||||||
|
"AccountInfoRow.tsx",
|
||||||
|
"account-copy.ts",
|
||||||
|
"usePaymentState.ts",
|
||||||
|
];
|
||||||
|
for (const file of accountSplitFiles) {
|
||||||
|
assert(
|
||||||
|
fs.existsSync(path.join(accountDir, file)),
|
||||||
|
`AccountCenter split file must exist: components/account/${file}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const accountFeatureSource = [
|
||||||
|
"AccountCenter.tsx",
|
||||||
|
"account-copy.ts",
|
||||||
|
"wallet.ts",
|
||||||
|
"payment-utils.ts",
|
||||||
|
"usePaymentState.ts",
|
||||||
|
]
|
||||||
|
.map((file) => fs.readFileSync(path.join(accountDir, file), "utf8"))
|
||||||
|
.join("\n");
|
||||||
|
assert(
|
||||||
|
accountCenterSource.split(/\r?\n/).length < 3200,
|
||||||
|
"AccountCenter.tsx must stay below 3200 lines after extracting account helpers",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
accountCenterSource.includes('import { createAccountCopy } from "./account-copy";') &&
|
||||||
|
accountCenterSource.includes('const copy = useMemo(() => createAccountCopy(isEn), [isEn]);'),
|
||||||
|
"AccountCenter copy text must be centralized in account-copy.ts instead of an inline 170+ line object",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
accountCenterSource.includes('import { usePaymentState } from "./usePaymentState";') &&
|
||||||
|
accountCenterSource.includes("clearPaymentState") &&
|
||||||
|
accountCenterSource.includes("clearPaymentMessages"),
|
||||||
|
"payment UI state reset/message helpers must be centralized in usePaymentState.ts",
|
||||||
|
);
|
||||||
const serviceWorkerSource = fs.readFileSync(serviceWorkerPath, "utf8");
|
const serviceWorkerSource = fs.readFileSync(serviceWorkerPath, "utf8");
|
||||||
const appAnalyticsSource = fs.readFileSync(
|
const appAnalyticsSource = fs.readFileSync(
|
||||||
path.join(projectRoot, "lib", "app-analytics.ts"),
|
path.join(projectRoot, "lib", "app-analytics.ts"),
|
||||||
@@ -64,18 +105,18 @@ export function runTests() {
|
|||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!/label\.toLowerCase\(\)\.includes\(["']binance["']\)\)\s*return/.test(
|
!/label\.toLowerCase\(\)\.includes\(["']binance["']\)\)\s*return/.test(
|
||||||
accountCenterSource,
|
accountFeatureSource,
|
||||||
),
|
),
|
||||||
"Binance Web3 Wallet injected provider must remain available for browser-extension binding",
|
"Binance Web3 Wallet injected provider must remain available for browser-extension binding",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
accountCenterSource.includes("Binance 扩展已绑定") &&
|
accountFeatureSource.includes("Binance 扩展已绑定") &&
|
||||||
accountCenterSource.includes("如支付卡住,请优先使用 WalletConnect 扫码支付"),
|
accountFeatureSource.includes("如支付卡住,请优先使用 WalletConnect 扫码支付"),
|
||||||
"Binance extension binding must show a WalletConnect fallback hint for payment stability",
|
"Binance extension binding must show a WalletConnect fallback hint for payment stability",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
accountCenterSource.includes("钱包里需要少量 POL 作为 gas 手续费") &&
|
accountFeatureSource.includes("钱包里需要少量 POL 作为 gas 手续费") &&
|
||||||
accountCenterSource.includes("只有 USDC 可能无法完成授权或支付"),
|
accountFeatureSource.includes("只有 USDC 可能无法完成授权或支付"),
|
||||||
"payment wallet tab must warn users that Polygon POL gas is required in addition to USDC",
|
"payment wallet tab must warn users that Polygon POL gas is required in addition to USDC",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||||
|
return {
|
||||||
|
backHome: isEn ? "Back to Home" : "返回首页",
|
||||||
|
accountCenter: isEn ? "Account Center" : "账户中心",
|
||||||
|
loadingAccount: isEn ? "Loading account info..." : "加载账户信息中...",
|
||||||
|
refresh: isEn ? "Refresh" : "刷新",
|
||||||
|
signOut: isEn ? "Sign Out" : "退出",
|
||||||
|
signIn: isEn ? "Sign In" : "登录",
|
||||||
|
upgradePro: isEn ? "Upgrade Pro" : "升级 Pro",
|
||||||
|
guestUser: isEn ? "Guest User" : "游客用户",
|
||||||
|
joinedAt: isEn ? "Joined" : "加入时间",
|
||||||
|
totalPoints: isEn ? "Total Points" : "总积分 (荣誉)",
|
||||||
|
weeklyPoints: isEn ? "Weekly Points" : "本周积分 (竞技)",
|
||||||
|
weeklyRank: isEn ? "Weekly Rank" : "周排行 (竞技)",
|
||||||
|
weeklyRewards: isEn ? "Weekly Rewards" : "周榜奖励",
|
||||||
|
membershipDetails: isEn ? "Membership Details" : "会员权限详情",
|
||||||
|
identityStatus: isEn ? "Identity Status" : "身份状态",
|
||||||
|
authMode: isEn ? "Auth Mode" : "鉴权模式",
|
||||||
|
weatherEngine: isEn ? "Weather Engine" : "气象引擎",
|
||||||
|
intradayAnalysis: isEn ? "Intraday Analysis" : "今日内分析",
|
||||||
|
historyFuture: isEn
|
||||||
|
? "Future-date + Decision Card Analysis"
|
||||||
|
: "未来日期分析 + 城市决策卡",
|
||||||
|
smartPush: isEn
|
||||||
|
? "Cross-platform Smart Weather Push"
|
||||||
|
: "全平台智能气象查询",
|
||||||
|
deepMode: isEn
|
||||||
|
? "Deep mode (incl. high-temp window)"
|
||||||
|
: "深度版(含高温时段)",
|
||||||
|
compactVisible: isEn ? "Compact visible" : "简版可见",
|
||||||
|
enabled: isEn ? "Enabled" : "已开启",
|
||||||
|
locked: isEn ? "Locked" : "锁定",
|
||||||
|
boundEmail: isEn ? "Bound Email" : "绑定邮箱",
|
||||||
|
loginMethod: isEn ? "Sign-in Method" : "登录方式",
|
||||||
|
renewalDate: isEn ? "Renewal Date" : "续费日期",
|
||||||
|
accessUntil: isEn ? "Access Until" : "可用至",
|
||||||
|
authResult: isEn ? "Auth Result" : "鉴权结果",
|
||||||
|
passed: isEn ? "Passed" : "通过",
|
||||||
|
restricted: isEn ? "Restricted" : "受限",
|
||||||
|
telegramBind: isEn ? "Telegram Bot Binding" : "Telegram Bot 绑定",
|
||||||
|
telegramHint: isEn
|
||||||
|
? "Use one-click Telegram binding first to sync notifications and access. After binding, refresh this page and submit your Telegram group join request."
|
||||||
|
: "优先使用「一键绑定 Telegram Bot」同步通知与权限。绑定完成后刷新本页,再提交 Telegram 群组入群申请。",
|
||||||
|
telegramFallbackHint: isEn
|
||||||
|
? "Fallback copy method: only use this if one-click binding does not open Telegram correctly. Copy the command below and send it to @polyyuanbot. After binding, refresh this page to show the group entry."
|
||||||
|
: "兜底复制方式:仅在一键绑定无法正常打开 Telegram 时使用。请复制下方命令并发送给 @polyyuanbot。绑定完成后刷新本页,即可显示入群入口。",
|
||||||
|
paymentManualSupport: isEn
|
||||||
|
? "If payment succeeds but Pro is still not activated, email yhrsc30@gmail.com. This project is currently maintained by one developer, so manual recovery may be needed in edge cases."
|
||||||
|
: "如果付款成功后 Pro 仍未开通,请发邮件到 yhrsc30@gmail.com。当前项目由我一人维护,极少数边缘情况可能需要人工补开。给你带来的不便,敬请谅解!",
|
||||||
|
telegramBotLink: isEn
|
||||||
|
? "Open Bot (@polyyuanbot)"
|
||||||
|
: "打开机器人 (@polyyuanbot)",
|
||||||
|
telegramBotBindLink: isEn ? "One-click Telegram Binding" : "一键绑定 Telegram Bot",
|
||||||
|
telegramGroupLink: isEn ? "Join Telegram Group" : "加入 Telegram 群组",
|
||||||
|
telegramTopicsGroupLink: isEn
|
||||||
|
? "Real-time Weather Updates"
|
||||||
|
: "城市实测温度群",
|
||||||
|
copyCommand: isEn ? "Copy fallback command" : "复制兜底命令",
|
||||||
|
paymentMgmt: isEn ? "Payment Management" : "支付管理",
|
||||||
|
paymentToken: isEn ? "Payment Token" : "支付币种",
|
||||||
|
paymentAccount: isEn ? "Subscription Account" : "订阅归属账号",
|
||||||
|
paymentWallet: isEn ? "Paying Wallet" : "付款钱包",
|
||||||
|
paymentReceiver: isEn ? "Receiver Contract" : "当前收款合约",
|
||||||
|
paymentNetwork: isEn ? "Payment Network" : "支付网络",
|
||||||
|
paymentHost: isEn ? "Payment Host" : "支付域名",
|
||||||
|
primary: "Primary",
|
||||||
|
polygonChain: isEn ? "Polygon Network" : "Polygon 网络",
|
||||||
|
noWallet: isEn ? "No payment wallet bound yet." : "未绑定任何付款钱包",
|
||||||
|
bindExt: isEn
|
||||||
|
? "Bind Browser Wallet (EVM Extension)"
|
||||||
|
: "绑定浏览器钱包(EVM扩展)",
|
||||||
|
bindQr: isEn
|
||||||
|
? "Bind via QR (WalletConnect)"
|
||||||
|
: "扫码绑定(WalletConnect)",
|
||||||
|
walletConnectMissing: isEn
|
||||||
|
? "WalletConnect disabled: please configure"
|
||||||
|
: "未启用 WalletConnect:请配置",
|
||||||
|
walletExtensionDetected: isEn
|
||||||
|
? "Detected browser wallets"
|
||||||
|
: "检测到的浏览器钱包",
|
||||||
|
walletExtensionChoose: isEn
|
||||||
|
? "Choose extension wallet"
|
||||||
|
: "选择浏览器钱包",
|
||||||
|
walletRecoveryBusy: isEn
|
||||||
|
? "Recovering Pro entitlement after on-chain payment..."
|
||||||
|
: "正在根据链上支付恢复 Pro 权限...",
|
||||||
|
walletRecoveryDone: isEn
|
||||||
|
? "Pro entitlement recovered."
|
||||||
|
: "Pro 权限已恢复。",
|
||||||
|
walletRecoveryFailed: isEn
|
||||||
|
? "A recent on-chain payment is still syncing to your subscription. Please refresh in a minute or contact support."
|
||||||
|
: "检测到最近的链上支付流程,但订阅状态仍在同步中。请稍后刷新,或联系管理员处理。",
|
||||||
|
unbind: isEn ? "Unbind" : "解绑",
|
||||||
|
unbindConfirm: isEn
|
||||||
|
? "Unbind wallet {address}? You can bind it again later."
|
||||||
|
: "确认解绑钱包 {address}?后续可重新绑定。",
|
||||||
|
unbindDone: isEn ? "Wallet unbound." : "钱包已解绑。",
|
||||||
|
unbindDonePrimary: isEn
|
||||||
|
? "Wallet unbound. New primary: {address}"
|
||||||
|
: "钱包已解绑,新的主钱包:{address}",
|
||||||
|
unbindFailed: isEn ? "Failed to unbind wallet" : "解绑钱包失败",
|
||||||
|
authExpired: isEn
|
||||||
|
? "Session expired. Please sign out and sign in again."
|
||||||
|
: "登录会话已失效,请退出后重新登录。",
|
||||||
|
payNow: isEn ? "Subscribe & Activate" : "立即订阅并激活服务",
|
||||||
|
connectAndPay: isEn ? "Connect Wallet & Pay" : "连接钱包并支付",
|
||||||
|
loginBeforeBind: isEn
|
||||||
|
? "Please sign in before binding wallet."
|
||||||
|
: "请先登录后再绑定钱包。",
|
||||||
|
loginBeforePay: isEn
|
||||||
|
? "Please sign in before payment."
|
||||||
|
: "请先登录后再支付。",
|
||||||
|
bindFirstBeforePay: isEn
|
||||||
|
? "Please bind a wallet first."
|
||||||
|
: "请先绑定钱包。",
|
||||||
|
payNotReady: isEn
|
||||||
|
? "Payment service is not fully configured."
|
||||||
|
: "支付服务未配置完成。",
|
||||||
|
paymentHostBlocked: isEn
|
||||||
|
? "Payments are disabled on this host. Please return to the production site: {host}"
|
||||||
|
: "当前域名不允许发起支付,请回到主站后重试:{host}",
|
||||||
|
paymentGuardHint: isEn
|
||||||
|
? "Payment will be credited to the current account and bound wallet shown below."
|
||||||
|
: "支付将记入下方显示的当前账号和绑定钱包,请先核对。",
|
||||||
|
openBindFlow: isEn
|
||||||
|
? "Please bind a wallet first. Opening bind flow..."
|
||||||
|
: "请先完成钱包绑定,正在拉起绑定流程...",
|
||||||
|
walletBoundCreatingOrder: isEn
|
||||||
|
? "Wallet bound. Creating order and sending payment..."
|
||||||
|
: "钱包已绑定,正在创建订单并发起支付...",
|
||||||
|
proMember: "PRO MEMBER",
|
||||||
|
freeTier: "FREE TIER",
|
||||||
|
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
|
||||||
|
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
|
||||||
|
proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期",
|
||||||
|
proEndsSoonBody: isEn
|
||||||
|
? "Your Pro membership will expire soon. Renew now to avoid interruption."
|
||||||
|
: "你的 Pro 会员即将到期。现在续费可避免权限中断。",
|
||||||
|
proExpiredTitle: isEn ? "Pro expired" : "Pro 已到期",
|
||||||
|
proExpiredBody: isEn
|
||||||
|
? "Your Pro membership has expired. Renew now to restore premium access."
|
||||||
|
: "你的 Pro 会员已到期。立即续费可恢复高级权限。",
|
||||||
|
renewNow: isEn ? "Renew Now" : "立即续费",
|
||||||
|
daysLeft: isEn ? "{days} days left" : "剩余 {days} 天",
|
||||||
|
queuedExtensionSummary: isEn
|
||||||
|
? "Current plan until {current}. Queued extension: +{days} days. Total access until {total}."
|
||||||
|
: "当前订阅至 {current},已排队延长 +{days} 天,总可用至 {total}。",
|
||||||
|
paymentMethodLabel: isEn ? "Payment Method" : "请选择支付方式",
|
||||||
|
paymentMethodWallet: isEn ? "Wallet Quick Pay" : "钱包快捷支付",
|
||||||
|
paymentMethodManual: isEn ? "Manual On-chain Transfer" : "手动链上转账",
|
||||||
|
paymentWalletDesc: isEn
|
||||||
|
? "Option 1: Bind your EVM wallet (e.g. MetaMask extension or WalletConnect QR scan) to sign and pay via smart contract. Credits are credited instantly."
|
||||||
|
: "方式一:绑定您的 EVM 钱包(如 MetaMask 扩展或 WalletConnect 扫码),通过智能合约自动签名付款,额度即时到账。",
|
||||||
|
paymentGasWarning: isEn
|
||||||
|
? "Your wallet needs a small amount of POL for gas fees; USDC alone may not complete authorization or payment. Please confirm your wallet is on Polygon network and keep some POL before paying."
|
||||||
|
: "钱包里需要少量 POL 作为 gas 手续费;只有 USDC 可能无法完成授权或支付。请确认当前钱包在 Polygon 网络,并预留一点 POL 后再支付。",
|
||||||
|
paymentManualDesc: isEn
|
||||||
|
? "Option 2: Transfer directly to the platform's receiver contract without binding a wallet. After the transfer, submit the transaction hash (Tx Hash) and the system will verify and activate Pro automatically."
|
||||||
|
: "方式二:无需将钱包绑定到账号,直接向平台收款合约转账。转账完成后提交交易哈希(Tx Hash)系统会自动验签并开通 Pro。",
|
||||||
|
paymentManualTitle: isEn
|
||||||
|
? "Manual Transfer (No Wallet Binding)"
|
||||||
|
: "手动转账(无需绑定钱包)",
|
||||||
|
paymentManualHint: isEn
|
||||||
|
? "Create an order first, transfer to the specified receiver address, then submit the tx hash here. Do not mix with the wallet payment channel."
|
||||||
|
: "先创建订单,向指定收款地址转账,完成后在此处提交 tx hash。请勿与钱包付款通道重复混用。",
|
||||||
|
paymentManualCreate: isEn ? "Create Transfer Order" : "创建转账订单",
|
||||||
|
paymentAmount: isEn ? "Amount" : "金额",
|
||||||
|
paymentReceiverLabel: isEn ? "Receiver" : "收款地址",
|
||||||
|
paymentTxHash: "Tx Hash",
|
||||||
|
paymentCopyAddress: isEn ? "Copy" : "复制",
|
||||||
|
paymentManualSubmit: isEn
|
||||||
|
? "Submit Tx Hash & Confirm"
|
||||||
|
: "提交 tx hash 并自动确认",
|
||||||
|
chainReadError: isEn ? "Reading wallet network" : "读取钱包网络",
|
||||||
|
chainSwitchError: isEn ? "Switch wallet network" : "切换钱包网络",
|
||||||
|
chainAddPolygon: isEn ? "Add Polygon network" : "添加 Polygon 网络",
|
||||||
|
chainSwitchPrompt: isEn
|
||||||
|
? "Please manually switch to Polygon network in your wallet and try again."
|
||||||
|
: "请在钱包中手动切换到 Polygon 网络后再试。",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const WALLETCONNECT_PROJECT_ID = String(
|
||||||
|
process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "",
|
||||||
|
).trim();
|
||||||
|
export const WALLETCONNECT_POLYGON_RPC_URL = String(
|
||||||
|
process.env.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL ||
|
||||||
|
"https://polygon-bor-rpc.publicnode.com",
|
||||||
|
).trim();
|
||||||
|
export const TELEGRAM_GROUP_URL = "https://t.me/+Io5H9oVHFmVjOTQ5";
|
||||||
|
export const TELEGRAM_BOT_URL = String(
|
||||||
|
process.env.NEXT_PUBLIC_TELEGRAM_BOT_URL || "https://t.me/polyyuanbot",
|
||||||
|
).trim();
|
||||||
|
export const TELEGRAM_TOPICS_GROUP_URL = TELEGRAM_GROUP_URL;
|
||||||
|
export const SUBSCRIPTION_HELP_HREF = "/subscription-help";
|
||||||
|
export const PAYMENT_RECOVERY_STORAGE_KEY = "polyweather:lastPaymentRecovery";
|
||||||
|
export const PAYMENT_RECOVERY_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
export const WALLET_REQUEST_TIMEOUT_MS = 60_000;
|
||||||
|
export const WALLET_TRANSACTION_REQUEST_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { PAYMENT_RECOVERY_STORAGE_KEY } from "./constants";
|
||||||
|
|
||||||
|
export function chainIdToDisplayName(chainId: number | undefined | null): string {
|
||||||
|
if (chainId === 137) return "Polygon";
|
||||||
|
if (chainId === 1) return "Ethereum Mainnet";
|
||||||
|
if (chainId) return `Chain ID ${chainId}`;
|
||||||
|
return "Polygon";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(value: string | undefined | null, locale: string) {
|
||||||
|
if (!value) return "--";
|
||||||
|
try {
|
||||||
|
const dt = new Date(value);
|
||||||
|
if (Number.isNaN(dt.getTime())) return "--";
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).format(dt);
|
||||||
|
} catch {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSubscriptionExpiry(value: string | undefined | null) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const dt = new Date(raw);
|
||||||
|
if (Number.isNaN(dt.getTime())) return null;
|
||||||
|
const diffMs = dt.getTime() - Date.now();
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
date: dt,
|
||||||
|
expired: diffMs <= 0,
|
||||||
|
daysLeft: Math.ceil(diffMs / 86_400_000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortAddress(address: string) {
|
||||||
|
const text = String(address || "");
|
||||||
|
if (!text.startsWith("0x") || text.length < 12) return text || "--";
|
||||||
|
return `${text.slice(0, 8)}...${text.slice(-6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredPaymentRecovery() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.sessionStorage.removeItem(PAYMENT_RECOVERY_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import type { EvmProvider } from "./types";
|
||||||
|
import { WALLET_REQUEST_TIMEOUT_MS } from "./constants";
|
||||||
|
import { isWalletConnectResetError } from "./wallet";
|
||||||
|
|
||||||
|
export function toPaddedHex(value: bigint) {
|
||||||
|
return value.toString(16).padStart(64, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPaddedAddress(address: string) {
|
||||||
|
return String(address || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^0x/, "")
|
||||||
|
.padStart(64, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAllowanceCalldata(owner: string, spender: string) {
|
||||||
|
return `0xdd62ed3e${toPaddedAddress(owner)}${toPaddedAddress(spender)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildApproveCalldata(spender: string, amount: bigint) {
|
||||||
|
return `0x095ea7b3${toPaddedAddress(spender)}${toPaddedHex(amount)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBalanceOfCalldata(owner: string) {
|
||||||
|
return `0x70a08231${toPaddedAddress(owner)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestWalletWithTimeout<T>(
|
||||||
|
provider: EvmProvider,
|
||||||
|
args: { method: string; params?: unknown[] },
|
||||||
|
actionLabel = "钱包操作",
|
||||||
|
timeoutMs = WALLET_REQUEST_TIMEOUT_MS,
|
||||||
|
): Promise<T> {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
return (await Promise.race([
|
||||||
|
provider.request(args),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`${actionLabel}长时间无响应,请确认钱包弹窗是否被拦截;如使用 Binance Web3 Wallet,请回到钱包确认或重新连接后再试。`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, timeoutMs);
|
||||||
|
}),
|
||||||
|
])) as T;
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTokenUnits(amount: bigint, decimals: number) {
|
||||||
|
const safeDecimals =
|
||||||
|
Number.isFinite(decimals) && decimals >= 0 ? Math.floor(decimals) : 6;
|
||||||
|
const base = 10n ** BigInt(safeDecimals);
|
||||||
|
const whole = amount / base;
|
||||||
|
const fraction = amount % base;
|
||||||
|
if (fraction === 0n) return whole.toString();
|
||||||
|
const rawFraction = fraction.toString().padStart(safeDecimals, "0");
|
||||||
|
const trimmed = rawFraction.replace(/0+$/, "");
|
||||||
|
return `${whole.toString()}.${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NormalizedPaymentError = {
|
||||||
|
message: string;
|
||||||
|
pending: boolean;
|
||||||
|
userRejected: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizePaymentError(error: unknown): NormalizedPaymentError {
|
||||||
|
const source = error as any;
|
||||||
|
const code = Number(
|
||||||
|
source?.code ??
|
||||||
|
source?.error?.code ??
|
||||||
|
source?.data?.code ??
|
||||||
|
source?.cause?.code ??
|
||||||
|
NaN,
|
||||||
|
);
|
||||||
|
const messageCandidates = [
|
||||||
|
source?.shortMessage,
|
||||||
|
source?.message,
|
||||||
|
source?.reason,
|
||||||
|
source?.data?.message,
|
||||||
|
source?.cause?.message,
|
||||||
|
source?.error?.message,
|
||||||
|
error instanceof Error ? error.message : "",
|
||||||
|
typeof error === "string" ? error : "",
|
||||||
|
];
|
||||||
|
const rawMessage = messageCandidates
|
||||||
|
.find(
|
||||||
|
(item) =>
|
||||||
|
typeof item === "string" &&
|
||||||
|
item.trim() &&
|
||||||
|
item.trim().toLowerCase() !== "[object object]",
|
||||||
|
)
|
||||||
|
?.trim();
|
||||||
|
const lower = String(rawMessage || "").toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
lower.includes("confirm pending") ||
|
||||||
|
lower.includes("payment pending timeout")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
message: "链上交易已提交,正在确认中,请稍后刷新查看状态。",
|
||||||
|
pending: true,
|
||||||
|
userRejected: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWalletConnectResetError(error)) {
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
"WalletConnect 连接已重置,请重新扫码连接;若仍失败,请先在钱包里断开旧连接后再试。",
|
||||||
|
pending: false,
|
||||||
|
userRejected: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRejected =
|
||||||
|
code === 4001 ||
|
||||||
|
/user rejected|user denied|rejected request|cancelled|canceled|拒绝|取消|签名请求已拒绝/.test(
|
||||||
|
lower,
|
||||||
|
);
|
||||||
|
if (userRejected) {
|
||||||
|
return {
|
||||||
|
message: "你已取消钱包操作。",
|
||||||
|
pending: false,
|
||||||
|
userRejected: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const insufficientGas =
|
||||||
|
(code === -32000 &&
|
||||||
|
/insufficient funds/.test(lower) &&
|
||||||
|
/(gas|fee|native|pol|matic)/.test(lower)) ||
|
||||||
|
/not enough pol|insufficient (pol|matic)|insufficient funds for gas|network fee|网络费|手续费/.test(
|
||||||
|
lower,
|
||||||
|
);
|
||||||
|
if (insufficientGas) {
|
||||||
|
return {
|
||||||
|
message: "钱包 POL 不足,无法支付链上手续费,请先充值少量 POL 后重试。",
|
||||||
|
pending: false,
|
||||||
|
userRejected: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawMessage) {
|
||||||
|
return {
|
||||||
|
message: rawMessage,
|
||||||
|
pending: false,
|
||||||
|
userRejected: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
message: JSON.stringify(error),
|
||||||
|
pending: false,
|
||||||
|
userRejected: false,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
message: "发生未知错误,请稍后重试。",
|
||||||
|
pending: false,
|
||||||
|
userRejected: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
export type AuthMeResponse = {
|
||||||
|
authenticated?: boolean;
|
||||||
|
user_id?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
points?: number;
|
||||||
|
weekly_points?: number;
|
||||||
|
weekly_rank?: number | string | null;
|
||||||
|
entitlement_mode?: string | null;
|
||||||
|
auth_required?: boolean;
|
||||||
|
subscription_required?: boolean;
|
||||||
|
subscription_active?: boolean | null;
|
||||||
|
subscription_plan_code?: string | null;
|
||||||
|
subscription_starts_at?: string | null;
|
||||||
|
subscription_expires_at?: string | null;
|
||||||
|
subscription_total_expires_at?: string | null;
|
||||||
|
subscription_queued_days?: number | null;
|
||||||
|
subscription_queued_count?: number | null;
|
||||||
|
telegram_pricing?: TelegramPricing | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TelegramPricing = {
|
||||||
|
configured?: boolean;
|
||||||
|
telegram_id?: number | null;
|
||||||
|
telegram_status?: string | null;
|
||||||
|
is_group_member?: boolean;
|
||||||
|
amount_usdc?: string;
|
||||||
|
pricing_source?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaymentPlan = {
|
||||||
|
plan_code: string;
|
||||||
|
plan_id: number;
|
||||||
|
amount_usdc: string;
|
||||||
|
duration_days: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaymentTokenOption = {
|
||||||
|
code: string;
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
decimals: number;
|
||||||
|
receiver_contract?: string;
|
||||||
|
is_default?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PointsRedemptionConfig = {
|
||||||
|
enabled?: boolean;
|
||||||
|
points_per_usdc?: number;
|
||||||
|
max_discount_usdc?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaymentConfig = {
|
||||||
|
enabled?: boolean;
|
||||||
|
configured?: boolean;
|
||||||
|
chain_id?: number;
|
||||||
|
token_address?: string;
|
||||||
|
token_decimals?: number;
|
||||||
|
default_token_address?: string;
|
||||||
|
tokens?: PaymentTokenOption[];
|
||||||
|
receiver_contract?: string;
|
||||||
|
confirmations?: number;
|
||||||
|
points_redemption?: PointsRedemptionConfig;
|
||||||
|
plans?: PaymentPlan[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BoundWallet = {
|
||||||
|
chain_id: number;
|
||||||
|
address: string;
|
||||||
|
status: string;
|
||||||
|
is_primary: boolean;
|
||||||
|
verified_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatedIntent = {
|
||||||
|
intent?: {
|
||||||
|
intent_id: string;
|
||||||
|
order_id_hex: string;
|
||||||
|
plan_code: string;
|
||||||
|
amount_usdc: string;
|
||||||
|
allowed_wallet?: string | null;
|
||||||
|
};
|
||||||
|
tx_payload?: {
|
||||||
|
chain_id: number;
|
||||||
|
to: string;
|
||||||
|
data: string;
|
||||||
|
value: string;
|
||||||
|
amount_units: string;
|
||||||
|
token_address: string;
|
||||||
|
token_symbol?: string;
|
||||||
|
token_decimals?: number;
|
||||||
|
};
|
||||||
|
direct_payment?: {
|
||||||
|
chain_id: number;
|
||||||
|
chain?: string;
|
||||||
|
token_symbol?: string;
|
||||||
|
token_address: string;
|
||||||
|
token_decimals?: number;
|
||||||
|
receiver_address: string;
|
||||||
|
amount_units: string;
|
||||||
|
amount_usdc: string;
|
||||||
|
intent_id: string;
|
||||||
|
expires_at: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IntentStatusResponse = {
|
||||||
|
intent?: {
|
||||||
|
intent_id?: string;
|
||||||
|
status?: string;
|
||||||
|
tx_hash?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
ethereum?: EvmProvider;
|
||||||
|
okxwallet?: {
|
||||||
|
ethereum?: EvmProvider;
|
||||||
|
};
|
||||||
|
okexchain?: EvmProvider;
|
||||||
|
rabby?: EvmProvider;
|
||||||
|
bitkeep?: {
|
||||||
|
ethereum?: EvmProvider;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EvmProvider = {
|
||||||
|
request: (args: { method: string; params?: any[] | object }) => Promise<any>;
|
||||||
|
providers?: EvmProvider[];
|
||||||
|
connect?: (args?: any) => Promise<void>;
|
||||||
|
disconnect?: () => Promise<void>;
|
||||||
|
session?: unknown;
|
||||||
|
isMetaMask?: boolean;
|
||||||
|
isRabby?: boolean;
|
||||||
|
isOkxWallet?: boolean;
|
||||||
|
isBitKeep?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderMode = "auto" | "walletconnect";
|
||||||
|
|
||||||
|
export type ProviderSelection = {
|
||||||
|
provider: EvmProvider;
|
||||||
|
label: string;
|
||||||
|
mode: ProviderMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InjectedProviderOption = ProviderSelection & {
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Eip6963ProviderInfo = {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
rdns: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Eip6963ProviderDetail = {
|
||||||
|
info: Eip6963ProviderInfo;
|
||||||
|
provider: EvmProvider;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConnectBindOptions = {
|
||||||
|
openOverlayAfterBind?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaymentRecoveryState = {
|
||||||
|
intentId: string;
|
||||||
|
txHash: string;
|
||||||
|
userId: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import type { CreatedIntent } from "./types";
|
||||||
|
|
||||||
|
export type PaymentTxValidationState = {
|
||||||
|
loading: boolean;
|
||||||
|
checked: boolean;
|
||||||
|
valid?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
detail?: string;
|
||||||
|
checks?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePaymentState() {
|
||||||
|
const [paymentBusy, setPaymentBusy] = useState(false);
|
||||||
|
const [paymentInfo, setPaymentInfo] = useState("");
|
||||||
|
const [paymentError, setPaymentError] = useState("");
|
||||||
|
const [lastIntentId, setLastIntentId] = useState("");
|
||||||
|
const [lastTxHash, setLastTxHash] = useState("");
|
||||||
|
const [telegramBindOpening, setTelegramBindOpening] = useState(false);
|
||||||
|
const [manualPayment, setManualPayment] = useState<
|
||||||
|
CreatedIntent["direct_payment"] | null
|
||||||
|
>(null);
|
||||||
|
const [manualTxHash, setManualTxHash] = useState("");
|
||||||
|
const [txValidation, setTxValidation] = useState<PaymentTxValidationState>({
|
||||||
|
loading: false,
|
||||||
|
checked: false,
|
||||||
|
});
|
||||||
|
const [paymentMethodTab, setPaymentMethodTab] = useState<"wallet" | "manual">(
|
||||||
|
"wallet",
|
||||||
|
);
|
||||||
|
const [lastPaymentStartedAt, setLastPaymentStartedAt] = useState(0);
|
||||||
|
|
||||||
|
const clearPaymentMessages = useCallback(() => {
|
||||||
|
setPaymentError("");
|
||||||
|
setPaymentInfo("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearPaymentState = useCallback(() => {
|
||||||
|
setLastIntentId("");
|
||||||
|
setLastTxHash("");
|
||||||
|
setManualPayment(null);
|
||||||
|
setManualTxHash("");
|
||||||
|
setLastPaymentStartedAt(0);
|
||||||
|
setTxValidation({ loading: false, checked: false });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentBusy,
|
||||||
|
setPaymentBusy,
|
||||||
|
paymentInfo,
|
||||||
|
setPaymentInfo,
|
||||||
|
paymentError,
|
||||||
|
setPaymentError,
|
||||||
|
lastIntentId,
|
||||||
|
setLastIntentId,
|
||||||
|
lastTxHash,
|
||||||
|
setLastTxHash,
|
||||||
|
telegramBindOpening,
|
||||||
|
setTelegramBindOpening,
|
||||||
|
manualPayment,
|
||||||
|
setManualPayment,
|
||||||
|
manualTxHash,
|
||||||
|
setManualTxHash,
|
||||||
|
txValidation,
|
||||||
|
setTxValidation,
|
||||||
|
paymentMethodTab,
|
||||||
|
setPaymentMethodTab,
|
||||||
|
lastPaymentStartedAt,
|
||||||
|
setLastPaymentStartedAt,
|
||||||
|
clearPaymentMessages,
|
||||||
|
clearPaymentState,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import type { Eip6963ProviderDetail, EvmProvider, InjectedProviderOption } from "./types";
|
||||||
|
import { WALLETCONNECT_POLYGON_RPC_URL, WALLETCONNECT_PROJECT_ID } from "./constants";
|
||||||
|
|
||||||
|
let walletConnectProviderCache: EvmProvider | null = null;
|
||||||
|
let walletConnectProviderChainId: number | null = null;
|
||||||
|
export const eip6963Providers = new Map<string, Eip6963ProviderDetail>();
|
||||||
|
|
||||||
|
export function isWalletConnectResetError(error: unknown): boolean {
|
||||||
|
const source = error as any;
|
||||||
|
const message = String(
|
||||||
|
source?.shortMessage ||
|
||||||
|
source?.message ||
|
||||||
|
source?.reason ||
|
||||||
|
source?.data?.message ||
|
||||||
|
source?.cause?.message ||
|
||||||
|
source?.error?.message ||
|
||||||
|
(error instanceof Error ? error.message : "") ||
|
||||||
|
(typeof error === "string" ? error : ""),
|
||||||
|
).toLowerCase();
|
||||||
|
return (
|
||||||
|
message.includes("connection request reset") ||
|
||||||
|
message.includes("pairing aborted") ||
|
||||||
|
message.includes("pairing attempt") ||
|
||||||
|
message.includes("unable to connect")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetWalletConnectProvider(): Promise<void> {
|
||||||
|
if (walletConnectProviderCache?.disconnect) {
|
||||||
|
try {
|
||||||
|
await walletConnectProviderCache.disconnect();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walletConnectProviderCache = null;
|
||||||
|
walletConnectProviderChainId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEvmProvider(): EvmProvider | null {
|
||||||
|
return listInjectedProviders()[0]?.provider || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEip6963Providers(): Eip6963ProviderDetail[] {
|
||||||
|
return Array.from(eip6963Providers.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectWalletLabel(
|
||||||
|
provider: EvmProvider | null,
|
||||||
|
detail?: Eip6963ProviderDetail,
|
||||||
|
): string {
|
||||||
|
if (!provider && !detail) return "EVM 钱包";
|
||||||
|
const announcedName = String(detail?.info?.name || "").trim();
|
||||||
|
const announcedRdns = String(detail?.info?.rdns || "").toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
provider?.isOkxWallet ||
|
||||||
|
announcedName.toLowerCase().includes("okx") ||
|
||||||
|
announcedRdns.includes("okx")
|
||||||
|
) {
|
||||||
|
return "OKX Wallet";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider?.isRabby ||
|
||||||
|
announcedName.toLowerCase().includes("rabby") ||
|
||||||
|
announcedRdns.includes("rabby")
|
||||||
|
) {
|
||||||
|
return "Rabby";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider?.isBitKeep ||
|
||||||
|
announcedName.toLowerCase().includes("bitget") ||
|
||||||
|
announcedRdns.includes("bitkeep") ||
|
||||||
|
announcedRdns.includes("bitget")
|
||||||
|
) {
|
||||||
|
return "Bitget Wallet";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(provider as any)?.isBinance ||
|
||||||
|
(provider as any)?.bnbSign ||
|
||||||
|
announcedName.toLowerCase().includes("binance") ||
|
||||||
|
announcedRdns.includes("binance")
|
||||||
|
) {
|
||||||
|
return "Binance Web3 Wallet";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider?.isMetaMask ||
|
||||||
|
announcedName.toLowerCase().includes("metamask") ||
|
||||||
|
announcedRdns.includes("metamask")
|
||||||
|
) {
|
||||||
|
return "MetaMask";
|
||||||
|
}
|
||||||
|
if (announcedName) return announcedName;
|
||||||
|
return "EVM 钱包";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectInjectedProviders(): EvmProvider[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
const out: EvmProvider[] = [];
|
||||||
|
const seen = new Set<EvmProvider>();
|
||||||
|
|
||||||
|
const push = (provider: unknown) => {
|
||||||
|
if (!provider || typeof provider !== "object") return;
|
||||||
|
const candidate = provider as EvmProvider;
|
||||||
|
if (typeof candidate.request !== "function") return;
|
||||||
|
if (seen.has(candidate)) return;
|
||||||
|
seen.add(candidate);
|
||||||
|
out.push(candidate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const root = window.ethereum;
|
||||||
|
if (Array.isArray(root?.providers)) {
|
||||||
|
root.providers.forEach(push);
|
||||||
|
}
|
||||||
|
push(root);
|
||||||
|
push(window.okxwallet?.ethereum);
|
||||||
|
push(window.okexchain);
|
||||||
|
push(window.rabby);
|
||||||
|
push(window.bitkeep?.ethereum);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInjectedProviderStableId(
|
||||||
|
provider: EvmProvider,
|
||||||
|
index: number,
|
||||||
|
detail?: Eip6963ProviderDetail,
|
||||||
|
): string {
|
||||||
|
const rdns = String(detail?.info?.rdns || "").toLowerCase();
|
||||||
|
const announcedName = String(detail?.info?.name || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
if (rdns) return `rdns:${rdns}`;
|
||||||
|
if (announcedName) return `name:${announcedName}`;
|
||||||
|
if (provider.isOkxWallet || rdns.includes("okx")) return `okx:${index}`;
|
||||||
|
if (provider.isMetaMask || rdns.includes("metamask"))
|
||||||
|
return `metamask:${index}`;
|
||||||
|
if (provider.isRabby || rdns.includes("rabby")) return `rabby:${index}`;
|
||||||
|
if (
|
||||||
|
provider.isBitKeep ||
|
||||||
|
rdns.includes("bitkeep") ||
|
||||||
|
rdns.includes("bitget")
|
||||||
|
) {
|
||||||
|
return `bitget:${index}`;
|
||||||
|
}
|
||||||
|
return `evm:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listInjectedProviders(): InjectedProviderOption[] {
|
||||||
|
const detailByProvider = new Map<EvmProvider, Eip6963ProviderDetail>();
|
||||||
|
getEip6963Providers().forEach((detail) => {
|
||||||
|
if (detail?.provider && typeof detail.provider.request === "function") {
|
||||||
|
detailByProvider.set(detail.provider, detail);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const candidates = collectInjectedProviders();
|
||||||
|
detailByProvider.forEach((_detail, provider) => {
|
||||||
|
if (!candidates.includes(provider)) {
|
||||||
|
candidates.push(provider);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const seenLabels = new Set<string>();
|
||||||
|
const out: InjectedProviderOption[] = [];
|
||||||
|
candidates.forEach((provider, index) => {
|
||||||
|
const detail = detailByProvider.get(provider);
|
||||||
|
const label = detectWalletLabel(provider, detail);
|
||||||
|
const key = getInjectedProviderStableId(provider, index, detail);
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
const normalizedLabel = label.trim().toLowerCase();
|
||||||
|
if (normalizedLabel && seenLabels.has(normalizedLabel)) return;
|
||||||
|
seen.add(key);
|
||||||
|
if (normalizedLabel) seenLabels.add(normalizedLabel);
|
||||||
|
out.push({
|
||||||
|
key,
|
||||||
|
provider,
|
||||||
|
label,
|
||||||
|
mode: "auto",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEvmWalletLabel(provider: EvmProvider | null): string {
|
||||||
|
return detectWalletLabel(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWalletConnectProvider(
|
||||||
|
chainId: number,
|
||||||
|
rpcUrl: string,
|
||||||
|
): Promise<EvmProvider> {
|
||||||
|
if (!WALLETCONNECT_PROJECT_ID) {
|
||||||
|
throw new Error(
|
||||||
|
"WalletConnect 未配置:缺少 NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (walletConnectProviderCache && walletConnectProviderChainId === chainId) {
|
||||||
|
return walletConnectProviderCache;
|
||||||
|
}
|
||||||
|
const { EthereumProvider } = await import("@walletconnect/ethereum-provider");
|
||||||
|
const rpcMap: Record<number, string> = {
|
||||||
|
[chainId]: rpcUrl || WALLETCONNECT_POLYGON_RPC_URL,
|
||||||
|
};
|
||||||
|
const origin =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.location.origin
|
||||||
|
: "https://polyweather-pro.vercel.app";
|
||||||
|
const provider = (await EthereumProvider.init({
|
||||||
|
projectId: WALLETCONNECT_PROJECT_ID,
|
||||||
|
chains: [chainId],
|
||||||
|
optionalChains: [chainId],
|
||||||
|
showQrModal: true,
|
||||||
|
methods: [
|
||||||
|
"eth_sendTransaction",
|
||||||
|
"personal_sign",
|
||||||
|
"eth_signTypedData",
|
||||||
|
"eth_signTypedData_v4",
|
||||||
|
"eth_sign",
|
||||||
|
"eth_call",
|
||||||
|
"eth_chainId",
|
||||||
|
"eth_accounts",
|
||||||
|
"eth_requestAccounts",
|
||||||
|
],
|
||||||
|
events: ["accountsChanged", "chainChanged", "disconnect"],
|
||||||
|
rpcMap,
|
||||||
|
metadata: {
|
||||||
|
name: "PolyWeather",
|
||||||
|
description: "PolyWeather Pro checkout",
|
||||||
|
url: origin,
|
||||||
|
icons: [`${origin}/favicon.ico`],
|
||||||
|
},
|
||||||
|
})) as unknown as EvmProvider;
|
||||||
|
walletConnectProviderCache = provider;
|
||||||
|
walletConnectProviderChainId = chainId;
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
getSupabaseBrowserClient,
|
getSupabaseBrowserClient,
|
||||||
hasSupabasePublicEnv,
|
hasSupabasePublicEnv,
|
||||||
} from "@/lib/supabase/client";
|
} from "@/lib/supabase/client";
|
||||||
|
import { getConfiguredSiteUrl, PRODUCTION_SITE_URL } from "@/lib/site-url";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
|
|
||||||
type Mode = "login" | "signup";
|
type Mode = "login" | "signup";
|
||||||
@@ -37,6 +38,9 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
const [resetSent, setResetSent] = useState(false);
|
const [resetSent, setResetSent] = useState(false);
|
||||||
|
|
||||||
const supabaseReady = hasSupabasePublicEnv();
|
const supabaseReady = hasSupabasePublicEnv();
|
||||||
|
const siteOrigin =
|
||||||
|
getConfiguredSiteUrl() ||
|
||||||
|
(typeof window !== "undefined" ? window.location.origin : PRODUCTION_SITE_URL);
|
||||||
const isEn = locale === "en-US";
|
const isEn = locale === "en-US";
|
||||||
const copy = {
|
const copy = {
|
||||||
backHome: isEn ? "Back to Home" : "返回首页",
|
backHome: isEn ? "Back to Home" : "返回首页",
|
||||||
@@ -99,7 +103,11 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const supabase = getSupabaseBrowserClient();
|
const supabase = getSupabaseBrowserClient();
|
||||||
const { error } = await supabase.auth.resetPasswordForEmail(email.trim());
|
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
|
||||||
|
redirectTo: `${siteOrigin}/auth/callback?next=${encodeURIComponent(
|
||||||
|
"/account",
|
||||||
|
)}`,
|
||||||
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
setErrorText(error.message);
|
setErrorText(error.message);
|
||||||
return;
|
return;
|
||||||
@@ -136,7 +144,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const supabase = getSupabaseBrowserClient();
|
const supabase = getSupabaseBrowserClient();
|
||||||
const redirectTo = `${window.location.origin}/auth/callback?next=${encodeURIComponent(
|
const redirectTo = `${siteOrigin}/auth/callback?next=${encodeURIComponent(
|
||||||
nextPath,
|
nextPath,
|
||||||
)}`;
|
)}`;
|
||||||
const { error } = await supabase.auth.signInWithOAuth({
|
const { error } = await supabase.auth.signInWithOAuth({
|
||||||
@@ -182,7 +190,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailRedirectTo = `${window.location.origin}/auth/callback?next=${encodeURIComponent(
|
const emailRedirectTo = `${siteOrigin}/auth/callback?next=${encodeURIComponent(
|
||||||
nextPath,
|
nextPath,
|
||||||
)}`;
|
)}`;
|
||||||
const { data, error } = await supabase.auth.signUp({
|
const { data, error } = await supabase.auth.signUp({
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import {
|
|||||||
RotateCw,
|
RotateCw,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Moon,
|
|
||||||
Sun,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useDashboardStore, useProAccess } from "@/hooks/useDashboardStore";
|
import { useDashboardStore, useProAccess } from "@/hooks/useDashboardStore";
|
||||||
@@ -42,7 +40,6 @@ export function HeaderBar({
|
|||||||
const { proAccess } = useProAccess();
|
const { proAccess } = useProAccess();
|
||||||
const { locale, t, toggleLocale } = useI18n();
|
const { locale, t, toggleLocale } = useI18n();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
|
||||||
const isAuthenticated = proAccess.authenticated;
|
const isAuthenticated = proAccess.authenticated;
|
||||||
const docsHref = "/docs/intro";
|
const docsHref = "/docs/intro";
|
||||||
const docsActive = pathname?.startsWith("/docs");
|
const docsActive = pathname?.startsWith("/docs");
|
||||||
@@ -94,24 +91,6 @@ export function HeaderBar({
|
|||||||
? `Pro ${Math.max(expiryInfo?.daysLeft || 0, 0)}d left`
|
? `Pro ${Math.max(expiryInfo?.daysLeft || 0, 0)}d left`
|
||||||
: `Pro 还剩 ${Math.max(expiryInfo?.daysLeft || 0, 0)} 天`;
|
: `Pro 还剩 ${Math.max(expiryInfo?.daysLeft || 0, 0)} 天`;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const savedTheme =
|
|
||||||
typeof window !== "undefined"
|
|
||||||
? window.localStorage.getItem("polyweather_theme")
|
|
||||||
: null;
|
|
||||||
const nextTheme = savedTheme === "light" ? "light" : "dark";
|
|
||||||
setTheme(nextTheme);
|
|
||||||
document.documentElement.classList.toggle("dark", nextTheme === "dark");
|
|
||||||
document.documentElement.classList.toggle("light", nextTheme === "light");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
|
||||||
setTheme(nextTheme);
|
|
||||||
document.documentElement.classList.toggle("dark", nextTheme === "dark");
|
|
||||||
document.documentElement.classList.toggle("light", nextTheme === "light");
|
|
||||||
window.localStorage.setItem("polyweather_theme", nextTheme);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="header">
|
<header className="header">
|
||||||
@@ -165,19 +144,6 @@ export function HeaderBar({
|
|||||||
<RotateCw size={16} strokeWidth={2} />
|
<RotateCw size={16} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="header-utility-btn"
|
|
||||||
aria-label={theme === "dark" ? "切换到明亮模式" : "切换到暗黑模式"}
|
|
||||||
title={theme === "dark" ? "明亮模式" : "暗黑模式"}
|
|
||||||
onClick={toggleTheme}
|
|
||||||
>
|
|
||||||
{theme === "dark" ? (
|
|
||||||
<Sun size={15} strokeWidth={2} />
|
|
||||||
) : (
|
|
||||||
<Moon size={15} strokeWidth={2} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={docsHref}
|
href={docsHref}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { trackAppEvent } from "@/lib/app-analytics";
|
|||||||
|
|
||||||
const TELEGRAM_GROUP_URL = String(
|
const TELEGRAM_GROUP_URL = String(
|
||||||
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
|
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
|
||||||
"https://t.me/+nMG7SjziUKYyZmM1",
|
"https://t.me/+Io5H9oVHFmVjOTQ5",
|
||||||
).trim();
|
).trim();
|
||||||
const SUBSCRIPTION_HELP_HREF = "/subscription-help";
|
const SUBSCRIPTION_HELP_HREF = "/subscription-help";
|
||||||
|
|
||||||
|
|||||||
@@ -122,19 +122,7 @@ export function ScanTerminalTopBar({
|
|||||||
{isEn ? "Sign in" : "登录"}
|
{isEn ? "Sign in" : "登录"}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="scan-theme-button"
|
|
||||||
aria-label={
|
|
||||||
themeMode === "light" ? "切换到暗色模式" : "切换到明亮模式"
|
|
||||||
}
|
|
||||||
title={themeMode === "light" ? "切换到暗色模式" : "切换到明亮模式"}
|
|
||||||
onClick={() =>
|
|
||||||
setThemeMode((current) => (current === "light" ? "dark" : "light"))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{themeMode === "light" ? <Moon size={15} /> : <Sun size={15} />}
|
|
||||||
</button>
|
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<Link
|
<Link
|
||||||
href={accountHref}
|
href={accountHref}
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ const aiCityForecastStateCache = new Map<
|
|||||||
string,
|
string,
|
||||||
{ state: AiCityForecastState; updatedAt: number }
|
{ state: AiCityForecastState; updatedAt: number }
|
||||||
>();
|
>();
|
||||||
|
const MAX_AI_FORECAST_CACHE_SIZE = 40;
|
||||||
|
|
||||||
|
function trimAiForecastCache() {
|
||||||
|
if (aiCityForecastStateCache.size <= MAX_AI_FORECAST_CACHE_SIZE) return;
|
||||||
|
const excess = aiCityForecastStateCache.size - MAX_AI_FORECAST_CACHE_SIZE;
|
||||||
|
const keys = Array.from(aiCityForecastStateCache.keys());
|
||||||
|
for (let i = 0; i < excess && i < keys.length; i++) {
|
||||||
|
aiCityForecastStateCache.delete(keys[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isHkoObservationCity(detail?: CityDetail | null) {
|
function isHkoObservationCity(detail?: CityDetail | null) {
|
||||||
const source = String(
|
const source = String(
|
||||||
@@ -100,6 +110,7 @@ export function writeCachedAiForecastState(
|
|||||||
state,
|
state,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
trimAiForecastCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readReadyCachedAiForecastState(cacheKey: string, refreshToken: number) {
|
export function readReadyCachedAiForecastState(cacheKey: string, refreshToken: number) {
|
||||||
|
|||||||
@@ -20,30 +20,13 @@ export function useUserLocalClock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useScanTerminalTheme() {
|
export function useScanTerminalTheme() {
|
||||||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
const [themeMode] = useState<ThemeMode>("light");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const hadLight = root.classList.contains("light");
|
root.classList.add("light");
|
||||||
const hadDark = root.classList.contains("dark");
|
root.classList.remove("dark");
|
||||||
root.classList.toggle("light", themeMode === "light");
|
|
||||||
root.classList.toggle("dark", themeMode === "dark");
|
|
||||||
return () => {
|
|
||||||
root.classList.toggle("light", hadLight);
|
|
||||||
root.classList.toggle("dark", hadDark);
|
|
||||||
};
|
|
||||||
}, [themeMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = window.localStorage.getItem("polyweather_scan_theme");
|
|
||||||
if (stored === "light") {
|
|
||||||
setThemeMode("light");
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
return { setThemeMode: () => {}, themeMode };
|
||||||
window.localStorage.setItem("polyweather_scan_theme", themeMode);
|
|
||||||
}, [themeMode]);
|
|
||||||
|
|
||||||
return { setThemeMode, themeMode };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { RefreshCcw } from "lucide-react";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { opsApi } from "@/lib/ops-api";
|
import { opsApi } from "@/lib/ops-api";
|
||||||
|
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
Bar,
|
Bar,
|
||||||
@@ -117,7 +118,7 @@ export function AnalyticsPageClient() {
|
|||||||
<XAxis type="number" stroke="rgba(255,255,255,0.3)" tick={{ fill: "#94a3b8", fontSize: 12 }} />
|
<XAxis type="number" stroke="rgba(255,255,255,0.3)" tick={{ fill: "#94a3b8", fontSize: 12 }} />
|
||||||
<YAxis type="category" dataKey="name" stroke="rgba(255,255,255,0.3)" tick={{ fill: "#e2e8f0", fontSize: 13 }} width={130} />
|
<YAxis type="category" dataKey="name" stroke="rgba(255,255,255,0.3)" tick={{ fill: "#e2e8f0", fontSize: 13 }} width={130} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0" }}
|
contentStyle={CHART_TOOLTIP_STYLE}
|
||||||
formatter={(value) => [`${value} 人`, "数量"]}
|
formatter={(value) => [`${value} 人`, "数量"]}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="count" radius={[0, 6, 6, 0]}>
|
<Bar dataKey="count" radius={[0, 6, 6, 0]}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { RefreshCcw, CheckCircle2, XCircle, AlertTriangle } from "lucide-react";
|
import { RefreshCcw, CheckCircle2, XCircle, AlertTriangle } from "lucide-react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
ResponsiveContainer, Cell,
|
ResponsiveContainer, Cell,
|
||||||
@@ -109,7 +110,7 @@ export function HealthPageClient() {
|
|||||||
<XAxis type="number" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#64748b", fontSize: 10 }} />
|
<XAxis type="number" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#64748b", fontSize: 10 }} />
|
||||||
<YAxis type="category" dataKey="name" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#94a3b8", fontSize: 11 }} width={120} />
|
<YAxis type="category" dataKey="name" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#94a3b8", fontSize: 11 }} width={120} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }}
|
contentStyle={CHART_TOOLTIP_STYLE}
|
||||||
formatter={(value) => [`${value} ms`, "延迟"]}
|
formatter={(value) => [`${value} ms`, "延迟"]}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="latency" radius={[0, 4, 4, 0]}>
|
<Bar dataKey="latency" radius={[0, 4, 4, 0]}>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { opsApi } from "@/lib/ops-api";
|
import { opsApi } from "@/lib/ops-api";
|
||||||
import type { MembershipEntry } from "@/types/ops";
|
import type { MembershipEntry } from "@/types/ops";
|
||||||
|
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||||
import {
|
import {
|
||||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
ResponsiveContainer, Legend, Area, AreaChart,
|
ResponsiveContainer, Legend, Area, AreaChart,
|
||||||
@@ -170,7 +171,7 @@ export function MembershipsPageClient() {
|
|||||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={45} />
|
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={45} />
|
||||||
<Tooltip contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }} />
|
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||||
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fill="#06b6d420" name="累计" />
|
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fill="#06b6d420" name="累计" />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -184,7 +185,7 @@ export function MembershipsPageClient() {
|
|||||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={30} />
|
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={30} />
|
||||||
<Tooltip contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }} />
|
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e60" name="付费" />
|
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e60" name="付费" />
|
||||||
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b60" name="体验" />
|
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b60" name="体验" />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { opsApi } from "@/lib/ops-api";
|
import { opsApi } from "@/lib/ops-api";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { SystemStatusPayload, MembershipsPayload, MembershipEntry } from "@/types/ops";
|
import type { SystemStatusPayload, MembershipsPayload, MembershipEntry } from "@/types/ops";
|
||||||
|
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||||
import {
|
import {
|
||||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||||
@@ -164,7 +165,7 @@ export function OverviewPageClient() {
|
|||||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={35} />
|
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={35} />
|
||||||
<Tooltip contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }} />
|
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||||
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fillOpacity={1} fill="url(#colorCumulative)" name="累计" />
|
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fillOpacity={1} fill="url(#colorCumulative)" name="累计" />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -178,7 +179,7 @@ export function OverviewPageClient() {
|
|||||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={25} />
|
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={25} />
|
||||||
<Tooltip contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }} />
|
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e40" name="付费" />
|
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e40" name="付费" />
|
||||||
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b40" name="体验" />
|
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b40" name="体验" />
|
||||||
@@ -208,7 +209,7 @@ export function OverviewPageClient() {
|
|||||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||||
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
|
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
|
||||||
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={75} />
|
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={75} />
|
||||||
<Tooltip contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }} />
|
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||||
<Bar dataKey="count" radius={[0, 4, 4, 0]} fill="#06b6d4" />
|
<Bar dataKey="count" radius={[0, 4, 4, 0]} fill="#06b6d4" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -228,7 +229,7 @@ export function OverviewPageClient() {
|
|||||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||||
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
|
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
|
||||||
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={45} />
|
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={45} />
|
||||||
<Tooltip contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }} />
|
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} fill="#6366f1" />
|
<Bar dataKey="value" radius={[0, 4, 4, 0]} fill="#6366f1" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -271,7 +272,7 @@ export function OverviewPageClient() {
|
|||||||
<Pie data={memberPie} cx="50%" cy="50%" innerRadius={40} outerRadius={60} paddingAngle={2} dataKey="value">
|
<Pie data={memberPie} cx="50%" cy="50%" innerRadius={40} outerRadius={60} paddingAngle={2} dataKey="value">
|
||||||
{memberPie.map((d, i) => (<Cell key={i} fill={d.color} />))}
|
{memberPie.map((d, i) => (<Cell key={i} fill={d.color} />))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }} />
|
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,7 +318,7 @@ export function OverviewPageClient() {
|
|||||||
<Pie data={cachePie} cx="50%" cy="50%" innerRadius={36} outerRadius={58} paddingAngle={2} dataKey="value">
|
<Pie data={cachePie} cx="50%" cy="50%" innerRadius={36} outerRadius={58} paddingAngle={2} dataKey="value">
|
||||||
{cachePie.map((d, i) => (<Cell key={i} fill={d.color} />))}
|
{cachePie.map((d, i) => (<Cell key={i} fill={d.color} />))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }} />
|
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { opsApi } from "@/lib/ops-api";
|
import { opsApi } from "@/lib/ops-api";
|
||||||
import type { PaymentRuntimePayload, PaymentIncident, PaymentRecord } from "@/types/ops";
|
import type { PaymentRuntimePayload, PaymentIncident, PaymentRecord } from "@/types/ops";
|
||||||
|
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||||
import {
|
import {
|
||||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
@@ -119,7 +120,7 @@ export function PaymentsPageClient() {
|
|||||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 11 }} />
|
<Tooltip contentStyle={{ ...CHART_TOOLTIP_STYLE, fontSize: 11 }} />
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { opsApi } from "@/lib/ops-api";
|
import { opsApi } from "@/lib/ops-api";
|
||||||
import type { SystemStatusPayload } from "@/types/ops";
|
import type { SystemStatusPayload } from "@/types/ops";
|
||||||
|
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
@@ -233,7 +234,7 @@ export function TrainingPageClient() {
|
|||||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||||
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }}
|
contentStyle={CHART_TOOLTIP_STYLE}
|
||||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||||
@@ -258,7 +259,7 @@ export function TrainingPageClient() {
|
|||||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||||
<YAxis tick={{ fill: "#94a3b8", fontSize: 11 }} unit="°" />
|
<YAxis tick={{ fill: "#94a3b8", fontSize: 11 }} unit="°" />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }}
|
contentStyle={CHART_TOOLTIP_STYLE}
|
||||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}°`, "MAE"]}
|
formatter={(value: unknown) => [`${Number(value).toFixed(1)}°`, "MAE"]}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="mae" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
<Bar dataKey="mae" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||||
@@ -288,7 +289,7 @@ export function TrainingPageClient() {
|
|||||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||||
<YAxis domain={[0, 0.5]} tick={{ fill: "#94a3b8", fontSize: 11 }} />
|
<YAxis domain={[0, 0.5]} tick={{ fill: "#94a3b8", fontSize: 11 }} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }}
|
contentStyle={CHART_TOOLTIP_STYLE}
|
||||||
formatter={(value: unknown) => [Number(value).toFixed(4), "Brier Score"]}
|
formatter={(value: unknown) => [Number(value).toFixed(4), "Brier Score"]}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="brierScore" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
<Bar dataKey="brierScore" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||||
@@ -313,7 +314,7 @@ export function TrainingPageClient() {
|
|||||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||||
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }}
|
contentStyle={CHART_TOOLTIP_STYLE}
|
||||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
import s from "./UnlockProOverlay.module.css";
|
import s from "./UnlockProOverlay.module.css";
|
||||||
|
|
||||||
const DEFAULT_FAQ_HREF = "/subscription-help";
|
const DEFAULT_FAQ_HREF = "/subscription-help";
|
||||||
const DEFAULT_TELEGRAM_GROUP_URL = "https://t.me/+nMG7SjziUKYyZmM1";
|
const DEFAULT_TELEGRAM_GROUP_URL = "https://t.me/+Io5H9oVHFmVjOTQ5";
|
||||||
|
|
||||||
export type UnlockProBilling = {
|
export type UnlockProBilling = {
|
||||||
pointsEnabled: boolean;
|
pointsEnabled: boolean;
|
||||||
|
|||||||
@@ -659,6 +659,42 @@ export function DashboardStoreProvider({
|
|||||||
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
|
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
|
||||||
Record<string, { cachedAt: number; revision: string }>
|
Record<string, { cachedAt: number; revision: string }>
|
||||||
>({});
|
>({});
|
||||||
|
const MAX_CACHED_CITIES = 20;
|
||||||
|
const cityLruRef = useRef<string[]>([]);
|
||||||
|
|
||||||
|
const touchCityLru = (cityName: string) => {
|
||||||
|
cityLruRef.current = [
|
||||||
|
...cityLruRef.current.filter((c) => c !== cityName),
|
||||||
|
cityName,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Evict LRU cities when cache exceeds limit
|
||||||
|
useEffect(() => {
|
||||||
|
const detailsCount = Object.keys(cityDetailsByName).length;
|
||||||
|
if (detailsCount <= MAX_CACHED_CITIES) return;
|
||||||
|
const lru = cityLruRef.current;
|
||||||
|
const excess = detailsCount - MAX_CACHED_CITIES;
|
||||||
|
const toEvict = lru.slice(0, excess);
|
||||||
|
if (!toEvict.length) return;
|
||||||
|
cityLruRef.current = lru.slice(excess);
|
||||||
|
setCityDetailsByName((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
toEvict.forEach((c) => delete next[c]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setCitySummariesByName((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
toEvict.forEach((c) => delete next[c]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setCityDetailMetaByName((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
toEvict.forEach((c) => delete next[c]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [cityDetailsByName]);
|
||||||
|
|
||||||
const [selectedCity, setSelectedCity] = useState<string | null>(null);
|
const [selectedCity, setSelectedCity] = useState<string | null>(null);
|
||||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||||
const [selectedForecastDate, setSelectedForecastDate] = useState<
|
const [selectedForecastDate, setSelectedForecastDate] = useState<
|
||||||
@@ -766,6 +802,7 @@ export function DashboardStoreProvider({
|
|||||||
const preloadCityFromRow = (row: { city?: string | null; city_display_name?: string | null; display_name?: string | null; [key: string]: unknown }) => {
|
const preloadCityFromRow = (row: { city?: string | null; city_display_name?: string | null; display_name?: string | null; [key: string]: unknown }) => {
|
||||||
const cityName = (row.city || row.city_display_name || row.display_name || "").trim();
|
const cityName = (row.city || row.city_display_name || row.display_name || "").trim();
|
||||||
if (!cityName || findCachedCityDetail(cityDetailsByName, cityName)) return;
|
if (!cityName || findCachedCityDetail(cityDetailsByName, cityName)) return;
|
||||||
|
touchCityLru(cityName);
|
||||||
// Pre-populate cache from scan terminal row so detail panel shows data immediately
|
// Pre-populate cache from scan terminal row so detail panel shows data immediately
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const currentTemp = Number(row.current_temp ?? row.current_max_so_far);
|
const currentTemp = Number(row.current_temp ?? row.current_max_so_far);
|
||||||
@@ -837,6 +874,7 @@ export function DashboardStoreProvider({
|
|||||||
force = false,
|
force = false,
|
||||||
depth: CityDetailDepth = "panel",
|
depth: CityDetailDepth = "panel",
|
||||||
) => {
|
) => {
|
||||||
|
touchCityLru(cityName);
|
||||||
const cached = findCachedCityDetail(cityDetailsByName, cityName);
|
const cached = findCachedCityDetail(cityDetailsByName, cityName);
|
||||||
const cachedMeta = cityDetailMetaByName[cityName];
|
const cachedMeta = cityDetailMetaByName[cityName];
|
||||||
const marketTargetDate =
|
const marketTargetDate =
|
||||||
@@ -1146,6 +1184,7 @@ export function DashboardStoreProvider({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const ensureCitySummary = async (cityName: string, force = false) => {
|
const ensureCitySummary = async (cityName: string, force = false) => {
|
||||||
|
touchCityLru(cityName);
|
||||||
const existing = citySummariesRef.current[cityName];
|
const existing = citySummariesRef.current[cityName];
|
||||||
if (!force && existing) {
|
if (!force && existing) {
|
||||||
return existing;
|
return existing;
|
||||||
@@ -1346,7 +1385,9 @@ export function DashboardStoreProvider({
|
|||||||
const refreshAll = async () => {
|
const refreshAll = async () => {
|
||||||
dashboardClient.clearCityDetailCache();
|
dashboardClient.clearCityDetailCache();
|
||||||
setCityDetailsByName({});
|
setCityDetailsByName({});
|
||||||
|
setCitySummariesByName({});
|
||||||
setCityDetailMetaByName({});
|
setCityDetailMetaByName({});
|
||||||
|
cityLruRef.current = [];
|
||||||
if (!citiesRef.current.length) {
|
if (!citiesRef.current.length) {
|
||||||
await loadCities();
|
await loadCities();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ const MAP_TILE_URLS = {
|
|||||||
dark: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
|
dark: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
|
||||||
light: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
|
light: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
|
||||||
} as const;
|
} as const;
|
||||||
|
const TILE_RETRY_LIMIT = 2;
|
||||||
|
const TILE_RETRY_DELAY_BASE_MS = 500;
|
||||||
const CITY_MARKER_DISPLAY_OFFSETS: Record<
|
const CITY_MARKER_DISPLAY_OFFSETS: Record<
|
||||||
string,
|
string,
|
||||||
{ x: number; y: number; zIndexOffset?: number }
|
{ x: number; y: number; zIndexOffset?: number }
|
||||||
@@ -528,6 +530,21 @@ export function useLeafletMap({
|
|||||||
subdomains: "abcd",
|
subdomains: "abcd",
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Retry failed tiles (CartoCDN may throttle long-running sessions)
|
||||||
|
const tileRetries = new Map<string, number>();
|
||||||
|
tileLayer.on("tileerror", (ev: { tile: HTMLElement; coords: L.Coords }) => {
|
||||||
|
const { tile, coords } = ev;
|
||||||
|
const key = `${coords.z}/${coords.x}/${coords.y}`;
|
||||||
|
const attempts = tileRetries.get(key) ?? 0;
|
||||||
|
if (attempts >= TILE_RETRY_LIMIT) return;
|
||||||
|
tileRetries.set(key, attempts + 1);
|
||||||
|
const delay = TILE_RETRY_DELAY_BASE_MS * 2 ** attempts;
|
||||||
|
setTimeout(() => {
|
||||||
|
(tile as HTMLImageElement).src = tileLayer.getTileUrl(coords);
|
||||||
|
}, delay);
|
||||||
|
});
|
||||||
|
map.on("moveend zoomend", () => tileRetries.clear());
|
||||||
|
|
||||||
const nearbyLayer = L.layerGroup().addTo(map);
|
const nearbyLayer = L.layerGroup().addTo(map);
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
tileLayerRef.current = tileLayer;
|
tileLayerRef.current = tileLayer;
|
||||||
@@ -601,8 +618,13 @@ export function useLeafletMap({
|
|||||||
const tileLayer = tileLayerRef.current;
|
const tileLayer = tileLayerRef.current;
|
||||||
if (!tileLayer || typeof MutationObserver === "undefined") return;
|
if (!tileLayer || typeof MutationObserver === "undefined") return;
|
||||||
|
|
||||||
|
let lastTileUrl = "";
|
||||||
|
|
||||||
const syncMapTheme = () => {
|
const syncMapTheme = () => {
|
||||||
tileLayer.setUrl(getMapTileUrl(containerRef.current));
|
const nextUrl = getMapTileUrl(containerRef.current);
|
||||||
|
if (nextUrl === lastTileUrl) return;
|
||||||
|
lastTileUrl = nextUrl;
|
||||||
|
tileLayer.setUrl(nextUrl);
|
||||||
};
|
};
|
||||||
syncMapTheme();
|
syncMapTheme();
|
||||||
|
|
||||||
@@ -611,11 +633,12 @@ export function useLeafletMap({
|
|||||||
attributeFilter: ["class"],
|
attributeFilter: ["class"],
|
||||||
attributes: true,
|
attributes: true,
|
||||||
});
|
});
|
||||||
if (document.body) {
|
|
||||||
observer.observe(document.body, {
|
const scanTerminal = document.querySelector(".scan-terminal");
|
||||||
|
if (scanTerminal) {
|
||||||
|
observer.observe(scanTerminal, {
|
||||||
attributeFilter: ["class"],
|
attributeFilter: ["class"],
|
||||||
attributes: true,
|
attributes: true,
|
||||||
subtree: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,4 +94,13 @@ export function applyAuthResponseCookies(
|
|||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requireBackendAuthUser(auth: HeaderBuildResult) {
|
||||||
|
if (auth.authUserId) return null;
|
||||||
|
return applyAuthResponseCookies(
|
||||||
|
NextResponse.json(
|
||||||
|
{ error: "Authentication required", detail: "Supabase user required" },
|
||||||
|
{ status: 401 },
|
||||||
|
),
|
||||||
|
auth.response,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -664,3 +664,11 @@ export function getTemperatureChartData(
|
|||||||
yTickStep,
|
yTickStep,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CHART_TOOLTIP_STYLE: React.CSSProperties = {
|
||||||
|
background: "#1e293b",
|
||||||
|
border: "1px solid rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: 8,
|
||||||
|
color: "#e2e8f0",
|
||||||
|
fontSize: 12,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export const EXPECTED_PAYMENT_RECEIVER_ADDRESS =
|
||||||
|
"0x351a1bca5f49dd0046a7cf0bafa7e12fa6441c3a";
|
||||||
|
|
||||||
|
export function normalizePaymentReceiver(address: string | null | undefined) {
|
||||||
|
return String(address || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertExpectedPaymentReceiver(
|
||||||
|
address: string | null | undefined,
|
||||||
|
label = "payment receiver",
|
||||||
|
) {
|
||||||
|
const normalized = normalizePaymentReceiver(address);
|
||||||
|
if (normalized !== EXPECTED_PAYMENT_RECEIVER_ADDRESS) {
|
||||||
|
throw new Error(
|
||||||
|
`${label} mismatch: expected ${EXPECTED_PAYMENT_RECEIVER_ADDRESS}, got ${normalized || "empty"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const PRODUCTION_SITE_URL = "https://polyweather-pro.vercel.app";
|
||||||
|
|
||||||
|
export function getConfiguredSiteUrl() {
|
||||||
|
const configured = process.env.NEXT_PUBLIC_SITE_URL?.trim();
|
||||||
|
if (configured) return configured;
|
||||||
|
return process.env.NODE_ENV === "production" ? PRODUCTION_SITE_URL : "";
|
||||||
|
}
|
||||||
@@ -106,12 +106,6 @@ async function handleSupabaseAuthGate(request: NextRequest) {
|
|||||||
if (isPublicPage(pathname) || isPublicApi(pathname)) {
|
if (isPublicPage(pathname) || isPublicApi(pathname)) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
if (pathname.startsWith("/api/")) {
|
|
||||||
const authHeader = String(request.headers.get("authorization") || "").trim();
|
|
||||||
if (/^bearer\s+\S+/i.test(authHeader)) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = NextResponse.next({
|
const response = NextResponse.next({
|
||||||
request: {
|
request: {
|
||||||
|
|||||||
Generated
+2
-24
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "polyweather-frontend",
|
"name": "polyweather-frontend",
|
||||||
"version": "1.5.5",
|
"version": "1.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "polyweather-frontend",
|
"name": "polyweather-frontend",
|
||||||
"version": "1.5.5",
|
"version": "1.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@supabase/ssr": "^0.5.2",
|
"@supabase/ssr": "^0.5.2",
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
"next": "^15.3.2",
|
"next": "^15.3.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-leaflet": "^5.0.0",
|
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"tailwind-merge": "^3.3.0"
|
"tailwind-merge": "^3.3.0"
|
||||||
},
|
},
|
||||||
@@ -362,15 +361,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-leaflet/core": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"license": "Hippocratic-2.1",
|
|
||||||
"peerDependencies": {
|
|
||||||
"leaflet": "^1.9.0",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@reduxjs/toolkit": {
|
"node_modules/@reduxjs/toolkit": {
|
||||||
"version": "2.12.0",
|
"version": "2.12.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||||
@@ -3302,18 +3292,6 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/react-leaflet": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"license": "Hippocratic-2.1",
|
|
||||||
"dependencies": {
|
|
||||||
"@react-leaflet/core": "^3.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"leaflet": "^1.9.0",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.3.0.tgz",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
"next": "^15.3.2",
|
"next": "^15.3.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-leaflet": "^5.0.0",
|
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"tailwind-merge": "^3.3.0"
|
"tailwind-merge": "^3.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
Stack trace:
|
||||||
|
Frame Function Args
|
||||||
|
000FFFFBCA0 00180062F57 (000FFFFBEA8, 00000000002, 00000000002, 000FFFFDE50)
|
||||||
|
00000000000 00180065045 (00000000064, 00000000000, 00000000188, 00000000000)
|
||||||
|
000FFFFC3B0 001801369F8 (00000000000, 00000000000, 00000000000, 00000000000)
|
||||||
|
000000000C1 0018013202B (00000000000, 00000000000, 00000000000, 000007E39A0)
|
||||||
|
000FFFFC7D0 00180132435 (00000000000, 00800000000, 000000000B5, 00100437000)
|
||||||
|
000FFFFC7D0 001802195E8 (00000000000, 00800000030, 00000000000, 00000000000)
|
||||||
|
000FFFFC7D0 00100429F25 (001800DB6FE, 0007FFE0358, 7FFEDD6B00F3, 000002432EE)
|
||||||
|
000FFFFC840 00100404B11 (0000000003F, 00000000040, 00000000040, 00000000000)
|
||||||
|
000FFFFCA30 001004294B0 (000FFFFCBD0, 00180235040, 00000000000, 04EC36FD370)
|
||||||
|
000FFFFCD30 00180049B91 (00000000000, 00000000000, 00000000000, 00000000000)
|
||||||
|
000FFFFFFF0 00180047716 (00000000000, 00000000000, 00000000000, 00000000000)
|
||||||
|
000FFFFFFF0 001800477C4 (00000000000, 00000000000, 00000000000, 00000000000)
|
||||||
|
End of stack trace
|
||||||
@@ -38,7 +38,7 @@ def _load_env() -> None:
|
|||||||
|
|
||||||
_load_env()
|
_load_env()
|
||||||
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
|
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
|
||||||
CHAT_ID = -1003965137823
|
CHAT_ID = -1003927451869
|
||||||
|
|
||||||
CITIES: list[tuple[str, str]] = [
|
CITIES: list[tuple[str, str]] = [
|
||||||
("🇨🇳 Beijing", "beijing"),
|
("🇨🇳 Beijing", "beijing"),
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def _load_env() -> None:
|
|||||||
|
|
||||||
_load_env()
|
_load_env()
|
||||||
TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
|
TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
|
||||||
CHAT_ID = -1003965137823
|
CHAT_ID = -1003927451869
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def _load_env() -> None:
|
|||||||
|
|
||||||
_load_env()
|
_load_env()
|
||||||
TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
|
TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
|
||||||
CHAT_ID = -1003965137823
|
CHAT_ID = -1003927451869
|
||||||
|
|
||||||
|
|
||||||
def _try_get_topic(tid: int) -> dict | None:
|
def _try_get_topic(tid: int) -> dict | None:
|
||||||
|
|||||||
@@ -98,6 +98,4 @@ class CommandGuard:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def ensure_access_and_points(self, message: Any, cost: int, command_label: str) -> bool:
|
def ensure_access_and_points(self, message: Any, cost: int, command_label: str) -> bool:
|
||||||
if not self.ensure_group_member(message, command_label):
|
|
||||||
return False
|
|
||||||
return self.io_layer.ensure_query_points(message, cost, command_label)
|
return self.io_layer.ensure_query_points(message, cost, command_label)
|
||||||
|
|||||||
+2
-3
@@ -203,10 +203,9 @@ class BotIOLayer:
|
|||||||
"/diag - 查看 Bot 启动诊断\n\n"
|
"/diag - 查看 Bot 启动诊断\n\n"
|
||||||
"/bind - 绑定 Supabase 账号(可选)\n"
|
"/bind - 绑定 Supabase 账号(可选)\n"
|
||||||
"/unbind - 解除当前 Telegram 与网页账号绑定\n\n"
|
"/unbind - 解除当前 Telegram 与网页账号绑定\n\n"
|
||||||
"🔗 机器人: <a href=\"https://t.me/WeatherQuant_bot\">@WeatherQuant_bot</a>\n"
|
"🔗 机器人: <a href=\"https://t.me/polyyuanbot\">@polyyuanbot</a>\n"
|
||||||
"👥 社群: <a href=\"https://t.me/+nMG7SjziUKYyZmM1\">加入 Telegram 群组</a>\n\n"
|
"👥 社群: <a href=\"https://t.me/+Io5H9oVHFmVjOTQ5\">加入 Telegram 群组</a>\n\n"
|
||||||
"📌 <i>私有频道用于接收自动推送;手动查看市场概览请私聊机器人发送 <code>/markets</code>。</i>\n\n"
|
"📌 <i>私有频道用于接收自动推送;手动查看市场概览请私聊机器人发送 <code>/markets</code>。</i>\n\n"
|
||||||
"🔐 <i>/city 与 /deb 仅限官方群成员使用。</i>\n\n"
|
|
||||||
"示例: <code>/city 伦敦</code> 或 <code>/pwcity 伦敦</code>\n"
|
"示例: <code>/city 伦敦</code> 或 <code>/pwcity 伦敦</code>\n"
|
||||||
f"💡 <i>提示: 群内有效发言(满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
f"💡 <i>提示: 群内有效发言(满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
|
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ def start_bot() -> None:
|
|||||||
startup_coordinator = StartupCoordinator(
|
startup_coordinator = StartupCoordinator(
|
||||||
bot=bot,
|
bot=bot,
|
||||||
config=config,
|
config=config,
|
||||||
command_access_mode="group_member_only",
|
command_access_mode="public",
|
||||||
protected_commands=["/city", "/deb"],
|
protected_commands=[],
|
||||||
required_group_chat_id=",".join(get_telegram_chat_ids_from_env()),
|
required_group_chat_id=",".join(get_telegram_chat_ids_from_env()),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ def start_bot() -> None:
|
|||||||
started_count = sum(1 for loop in runtime_status.loops if loop.started)
|
started_count = sum(1 for loop in runtime_status.loops if loop.started)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"🤖 Bot 启动中... access=group-member-only protected_commands=/city,/deb loops_started={}/{}",
|
"🤖 Bot 启动中... access=public protected_commands=none loops_started={}/{}",
|
||||||
started_count,
|
started_count,
|
||||||
len(runtime_status.loops),
|
len(runtime_status.loops),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ class StartupCoordinator:
|
|||||||
loops = [
|
loops = [
|
||||||
self._start_airport_high_freq_loop(),
|
self._start_airport_high_freq_loop(),
|
||||||
self._start_polygon_wallet_loop(),
|
self._start_polygon_wallet_loop(),
|
||||||
self._start_polymarket_wallet_activity_loop(),
|
|
||||||
self._start_weekly_reward_loop(),
|
self._start_weekly_reward_loop(),
|
||||||
self._start_payment_event_loop(),
|
self._start_payment_event_loop(),
|
||||||
self._start_payment_confirm_loop(),
|
self._start_payment_confirm_loop(),
|
||||||
@@ -219,18 +218,6 @@ class StartupCoordinator:
|
|||||||
).start_polygon_wallet_watch_loop(self.bot),
|
).start_polygon_wallet_watch_loop(self.bot),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _start_polymarket_wallet_activity_loop(self) -> LoopStatus:
|
|
||||||
return LoopStatus(
|
|
||||||
key="polymarket_wallet_activity",
|
|
||||||
label="Polymarket 钱包异动监听(已停用)",
|
|
||||||
configured_enabled=False,
|
|
||||||
started=False,
|
|
||||||
reason="retired_replaced_by_market_monitor",
|
|
||||||
details={
|
|
||||||
"note": "wallet activity watcher retired",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _start_weekly_reward_loop(self) -> LoopStatus:
|
def _start_weekly_reward_loop(self) -> LoopStatus:
|
||||||
enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", True)
|
enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", True)
|
||||||
chat_ids = get_telegram_chat_ids_from_env()
|
chat_ids = get_telegram_chat_ids_from_env()
|
||||||
|
|||||||
@@ -1,914 +0,0 @@
|
|||||||
import json
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from src.utils.telegram_chat_ids import (
|
|
||||||
get_polymarket_wallet_activity_chat_ids_from_env,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
|
||||||
raw = os.getenv(name)
|
|
||||||
if raw is None:
|
|
||||||
return default
|
|
||||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
def _env_int(name: str, default: int) -> int:
|
|
||||||
raw = os.getenv(name)
|
|
||||||
if raw is None:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return int(raw)
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _env_float(name: str, default: float) -> float:
|
|
||||||
raw = os.getenv(name)
|
|
||||||
if raw is None:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return float(raw)
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(value: Any, default: float = 0.0) -> float:
|
|
||||||
try:
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
return float(value)
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_addr(value: Any) -> str:
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
text = str(value).strip().lower()
|
|
||||||
if text.startswith("0x") and len(text) == 42:
|
|
||||||
return text
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _short(addr: str, left: int = 6, right: int = 4) -> str:
|
|
||||||
if not addr:
|
|
||||||
return "unknown"
|
|
||||||
if len(addr) <= left + right + 2:
|
|
||||||
return addr
|
|
||||||
return f"{addr[:left + 2]}...{addr[-right:]}"
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_addresses(raw: Optional[str]) -> List[str]:
|
|
||||||
out: List[str] = []
|
|
||||||
if not raw:
|
|
||||||
return out
|
|
||||||
seen = set()
|
|
||||||
for part in raw.split(","):
|
|
||||||
addr = _normalize_addr(part)
|
|
||||||
if addr and addr not in seen:
|
|
||||||
out.append(addr)
|
|
||||||
seen.add(addr)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_address_set(raw: Optional[str]) -> set[str]:
|
|
||||||
return set(_parse_addresses(raw))
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_address_aliases(raw: Optional[str]) -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
Parse wallet aliases from either:
|
|
||||||
- JSON object: {"0xabc...": "Whale A", "0xdef...": "Whale B"}
|
|
||||||
- CSV pairs: 0xabc...=Whale A,0xdef...=Whale B
|
|
||||||
"""
|
|
||||||
out: Dict[str, str] = {}
|
|
||||||
if not raw:
|
|
||||||
return out
|
|
||||||
|
|
||||||
text = str(raw).strip()
|
|
||||||
if not text:
|
|
||||||
return out
|
|
||||||
|
|
||||||
if text.startswith("{"):
|
|
||||||
try:
|
|
||||||
parsed = json.loads(text)
|
|
||||||
if isinstance(parsed, dict):
|
|
||||||
for k, v in parsed.items():
|
|
||||||
addr = _normalize_addr(k)
|
|
||||||
alias = str(v or "").strip()
|
|
||||||
if addr and alias:
|
|
||||||
out[addr] = alias
|
|
||||||
return out
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
for part in text.split(","):
|
|
||||||
row = part.strip()
|
|
||||||
if not row:
|
|
||||||
continue
|
|
||||||
if "=" in row:
|
|
||||||
left, right = row.split("=", 1)
|
|
||||||
elif ":" in row:
|
|
||||||
left, right = row.split(":", 1)
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
addr = _normalize_addr(left)
|
|
||||||
alias = str(right or "").strip()
|
|
||||||
if addr and alias:
|
|
||||||
out[addr] = alias
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _state_file() -> str:
|
|
||||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
return os.path.join(root, "data", "polymarket_wallet_activity_state.json")
|
|
||||||
|
|
||||||
|
|
||||||
def _load_state(path: str) -> Dict[str, Any]:
|
|
||||||
if not os.path.exists(path):
|
|
||||||
return {"users": {}}
|
|
||||||
try:
|
|
||||||
with open(path, "r", encoding="utf-8") as fh:
|
|
||||||
data = json.load(fh)
|
|
||||||
if isinstance(data, dict):
|
|
||||||
data.setdefault("users", {})
|
|
||||||
return data
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(f"failed to load wallet activity state: {exc}")
|
|
||||||
return {"users": {}}
|
|
||||||
|
|
||||||
|
|
||||||
def _save_state(path: str, state: Dict[str, Any]) -> None:
|
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
||||||
tmp = f"{path}.tmp"
|
|
||||||
with open(tmp, "w", encoding="utf-8") as fh:
|
|
||||||
json.dump(state, fh, ensure_ascii=False, indent=2)
|
|
||||||
os.replace(tmp, path)
|
|
||||||
|
|
||||||
|
|
||||||
def _market_url(position: Dict[str, Any]) -> str:
|
|
||||||
slug = str(position.get("slug") or "").strip()
|
|
||||||
event_slug = str(position.get("event_slug") or "").strip()
|
|
||||||
|
|
||||||
# Prefer event-level URL first: Telegram preview is usually more stable.
|
|
||||||
if event_slug:
|
|
||||||
return f"https://polymarket.com/event/{event_slug}"
|
|
||||||
if slug:
|
|
||||||
return f"https://polymarket.com/market/{slug}"
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _position_key(position: Dict[str, Any]) -> str:
|
|
||||||
asset = str(position.get("asset") or "").strip().lower()
|
|
||||||
condition_id = str(position.get("condition_id") or "").strip().lower()
|
|
||||||
outcome = str(position.get("outcome") or "").strip().lower()
|
|
||||||
if asset:
|
|
||||||
return f"asset:{asset}"
|
|
||||||
return f"condition:{condition_id}|outcome:{outcome}"
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_position(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
title = (
|
|
||||||
str(
|
|
||||||
row.get("title")
|
|
||||||
or row.get("question")
|
|
||||||
or row.get("market")
|
|
||||||
or row.get("name")
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
.strip()
|
|
||||||
)
|
|
||||||
outcome = str(row.get("outcome") or row.get("side") or "").strip()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"proxy_wallet": _normalize_addr(row.get("proxyWallet") or row.get("proxy_wallet")),
|
|
||||||
"asset": str(row.get("asset") or row.get("assetId") or "").strip(),
|
|
||||||
"condition_id": str(row.get("conditionId") or row.get("condition_id") or "").strip(),
|
|
||||||
"title": title,
|
|
||||||
"slug": str(row.get("slug") or row.get("marketSlug") or "").strip(),
|
|
||||||
"event_slug": str(row.get("eventSlug") or row.get("event_slug") or "").strip(),
|
|
||||||
"outcome": outcome,
|
|
||||||
"size": _safe_float(row.get("size") or row.get("shares")),
|
|
||||||
"avg_price": _safe_float(row.get("avgPrice") or row.get("avg_price")),
|
|
||||||
"position_value": _safe_float(row.get("currentValue") or row.get("positionValue")),
|
|
||||||
"cash_pnl": _safe_float(row.get("cashPnl") or row.get("realizedPnl") or row.get("pnl")),
|
|
||||||
"percent_pnl": _safe_float(
|
|
||||||
row.get("percentPnl")
|
|
||||||
or row.get("pnlPercent")
|
|
||||||
or row.get("pnl_pct")
|
|
||||||
),
|
|
||||||
"cur_price": _safe_float(row.get("curPrice") or row.get("lastPrice")),
|
|
||||||
"updated_at": int(time.time()),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_positions(
|
|
||||||
session: requests.Session,
|
|
||||||
base_url: str,
|
|
||||||
user: str,
|
|
||||||
timeout_sec: int,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
url = f"{base_url.rstrip('/')}/positions"
|
|
||||||
resp = session.get(url, params={"user": user}, timeout=timeout_sec)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
if isinstance(data, list):
|
|
||||||
return [row for row in data if isinstance(row, dict)]
|
|
||||||
|
|
||||||
if isinstance(data, dict):
|
|
||||||
for key in ("positions", "data", "results", "items"):
|
|
||||||
maybe = data.get(key)
|
|
||||||
if isinstance(maybe, list):
|
|
||||||
return [row for row in maybe if isinstance(row, dict)]
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _build_snapshot(
|
|
||||||
rows: List[Dict[str, Any]],
|
|
||||||
min_size_abs: float,
|
|
||||||
) -> Dict[str, Dict[str, Any]]:
|
|
||||||
snap: Dict[str, Dict[str, Any]] = {}
|
|
||||||
for row in rows:
|
|
||||||
pos = _normalize_position(row)
|
|
||||||
if abs(pos["size"]) < min_size_abs:
|
|
||||||
continue
|
|
||||||
key = _position_key(pos)
|
|
||||||
snap[key] = pos
|
|
||||||
return snap
|
|
||||||
|
|
||||||
|
|
||||||
def _diff_positions(
|
|
||||||
previous: Dict[str, Dict[str, Any]],
|
|
||||||
current: Dict[str, Dict[str, Any]],
|
|
||||||
min_size_delta: float,
|
|
||||||
notify_closed: bool,
|
|
||||||
min_price: float = 0.0,
|
|
||||||
max_price: float = 1.0,
|
|
||||||
min_avg_price_delta: float = 0.002,
|
|
||||||
) -> List[Tuple[str, Dict[str, Any]]]:
|
|
||||||
changes: List[Tuple[str, Dict[str, Any]]] = []
|
|
||||||
min_avg_price_delta = max(0.0, min_avg_price_delta)
|
|
||||||
|
|
||||||
for key, now_pos in current.items():
|
|
||||||
# 价格过滤:如果设置了价格区间,不符合的直接跳过
|
|
||||||
price = _safe_float(now_pos.get("avg_price"))
|
|
||||||
if price < min_price or price > max_price:
|
|
||||||
logger.debug(f"skipped position due to price range limit: market={now_pos.get('title')} price={price}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
old_pos = previous.get(key)
|
|
||||||
if old_pos is None:
|
|
||||||
changes.append(("new", now_pos))
|
|
||||||
continue
|
|
||||||
|
|
||||||
size_delta = now_pos["size"] - old_pos.get("size", 0.0)
|
|
||||||
avg_delta = now_pos["avg_price"] - old_pos.get("avg_price", 0.0)
|
|
||||||
|
|
||||||
if abs(size_delta) >= min_size_delta or abs(avg_delta) >= min_avg_price_delta:
|
|
||||||
merged = {**now_pos}
|
|
||||||
merged["size_delta"] = size_delta
|
|
||||||
merged["old_size"] = old_pos.get("size", 0.0)
|
|
||||||
merged["old_avg_price"] = old_pos.get("avg_price", 0.0)
|
|
||||||
changes.append(("update", merged))
|
|
||||||
|
|
||||||
if notify_closed:
|
|
||||||
for key, old_pos in previous.items():
|
|
||||||
if key not in current:
|
|
||||||
changes.append(("closed", old_pos))
|
|
||||||
|
|
||||||
return changes
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_pending_update(
|
|
||||||
pending_updates: Dict[str, Dict[str, Any]],
|
|
||||||
pos_key: str,
|
|
||||||
pos: Dict[str, Any],
|
|
||||||
now_ts: int,
|
|
||||||
) -> None:
|
|
||||||
entry = pending_updates.get(pos_key)
|
|
||||||
size_delta = _safe_float(pos.get("size_delta"))
|
|
||||||
old_size = _safe_float(pos.get("old_size"))
|
|
||||||
new_size = _safe_float(pos.get("size"))
|
|
||||||
old_avg = _safe_float(pos.get("old_avg_price"))
|
|
||||||
new_avg = _safe_float(pos.get("avg_price"))
|
|
||||||
|
|
||||||
if entry is None:
|
|
||||||
pending_updates[pos_key] = {
|
|
||||||
"count": 1,
|
|
||||||
"first_ts": now_ts,
|
|
||||||
"last_ts": now_ts,
|
|
||||||
"title": pos.get("title"),
|
|
||||||
"slug": pos.get("slug"),
|
|
||||||
"event_slug": pos.get("event_slug"),
|
|
||||||
"outcome": pos.get("outcome"),
|
|
||||||
"asset": pos.get("asset"),
|
|
||||||
"condition_id": pos.get("condition_id"),
|
|
||||||
"old_size": old_size,
|
|
||||||
"size": new_size,
|
|
||||||
"size_delta": size_delta,
|
|
||||||
"old_avg_price": old_avg,
|
|
||||||
"avg_price": new_avg,
|
|
||||||
"position_value": _safe_float(pos.get("position_value")),
|
|
||||||
"cash_pnl": _safe_float(pos.get("cash_pnl")),
|
|
||||||
"percent_pnl": _safe_float(pos.get("percent_pnl")),
|
|
||||||
}
|
|
||||||
return
|
|
||||||
|
|
||||||
entry["count"] = int(entry.get("count", 1)) + 1
|
|
||||||
entry["last_ts"] = now_ts
|
|
||||||
entry["size_delta"] = _safe_float(entry.get("size_delta")) + size_delta
|
|
||||||
entry["size"] = new_size
|
|
||||||
entry["avg_price"] = new_avg
|
|
||||||
entry["position_value"] = _safe_float(pos.get("position_value"))
|
|
||||||
entry["cash_pnl"] = _safe_float(pos.get("cash_pnl"))
|
|
||||||
entry["percent_pnl"] = _safe_float(pos.get("percent_pnl"))
|
|
||||||
pending_updates[pos_key] = entry
|
|
||||||
|
|
||||||
|
|
||||||
def _finalize_pending_update(
|
|
||||||
pending_entry: Dict[str, Any],
|
|
||||||
now_ts: int,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
first_ts = int(pending_entry.get("first_ts") or now_ts)
|
|
||||||
last_ts = int(pending_entry.get("last_ts") or now_ts)
|
|
||||||
return {
|
|
||||||
"title": pending_entry.get("title") or "",
|
|
||||||
"slug": pending_entry.get("slug") or "",
|
|
||||||
"event_slug": pending_entry.get("event_slug") or "",
|
|
||||||
"outcome": pending_entry.get("outcome") or "",
|
|
||||||
"asset": pending_entry.get("asset") or "",
|
|
||||||
"condition_id": pending_entry.get("condition_id") or "",
|
|
||||||
"old_size": _safe_float(pending_entry.get("old_size")),
|
|
||||||
"size": _safe_float(pending_entry.get("size")),
|
|
||||||
"size_delta": _safe_float(pending_entry.get("size_delta")),
|
|
||||||
"old_avg_price": _safe_float(pending_entry.get("old_avg_price")),
|
|
||||||
"avg_price": _safe_float(pending_entry.get("avg_price")),
|
|
||||||
"position_value": _safe_float(pending_entry.get("position_value")),
|
|
||||||
"cash_pnl": _safe_float(pending_entry.get("cash_pnl")),
|
|
||||||
"percent_pnl": _safe_float(pending_entry.get("percent_pnl")),
|
|
||||||
"agg_count": int(pending_entry.get("count") or 1),
|
|
||||||
"agg_span_sec": max(0, last_ts - first_ts),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _flush_ready_pending_updates(
|
|
||||||
pending_updates: Dict[str, Dict[str, Any]],
|
|
||||||
now_ts: int,
|
|
||||||
debounce_sec: int,
|
|
||||||
max_hold_sec: int,
|
|
||||||
force_keys: Optional[set] = None,
|
|
||||||
blocked_keys: Optional[set] = None,
|
|
||||||
) -> List[Tuple[str, Dict[str, Any]]]:
|
|
||||||
out: List[Tuple[str, Dict[str, Any]]] = []
|
|
||||||
keys = list(pending_updates.keys())
|
|
||||||
for key in keys:
|
|
||||||
entry = pending_updates.get(key)
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
pending_updates.pop(key, None)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if blocked_keys and key in blocked_keys:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if force_keys and key in force_keys:
|
|
||||||
out.append(("update", _finalize_pending_update(entry, now_ts)))
|
|
||||||
pending_updates.pop(key, None)
|
|
||||||
continue
|
|
||||||
|
|
||||||
first_ts = int(entry.get("first_ts") or now_ts)
|
|
||||||
last_ts = int(entry.get("last_ts") or now_ts)
|
|
||||||
quiet_enough = (now_ts - last_ts) >= debounce_sec
|
|
||||||
held_too_long = (now_ts - first_ts) >= max_hold_sec
|
|
||||||
if quiet_enough or held_too_long:
|
|
||||||
out.append(("update", _finalize_pending_update(entry, now_ts)))
|
|
||||||
pending_updates.pop(key, None)
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_suppressed_update_keys(
|
|
||||||
new_push_meta: Dict[str, Any],
|
|
||||||
now_ts: int,
|
|
||||||
suppress_sec: int,
|
|
||||||
) -> set[str]:
|
|
||||||
if suppress_sec <= 0:
|
|
||||||
return set()
|
|
||||||
out: set[str] = set()
|
|
||||||
stale: List[str] = []
|
|
||||||
for key, ts_raw in new_push_meta.items():
|
|
||||||
ts = int(ts_raw or 0)
|
|
||||||
if ts <= 0:
|
|
||||||
stale.append(str(key))
|
|
||||||
continue
|
|
||||||
age = now_ts - ts
|
|
||||||
if age < suppress_sec:
|
|
||||||
out.add(str(key))
|
|
||||||
elif age >= suppress_sec * 3:
|
|
||||||
stale.append(str(key))
|
|
||||||
for key in stale:
|
|
||||||
new_push_meta.pop(key, None)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_pct(value: float) -> str:
|
|
||||||
# Data API may return either ratio (0.12) or percent (12.0).
|
|
||||||
display = value * 100.0 if abs(value) <= 1.5 else value
|
|
||||||
return f"{display:.1f}%"
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_usd(value: float) -> str:
|
|
||||||
return f"${value:.2f}"
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_price(value: float) -> str:
|
|
||||||
return f"{value:.3f}"
|
|
||||||
|
|
||||||
|
|
||||||
def _should_show_avg_price(avg_price: float) -> bool:
|
|
||||||
# Extreme prices near 0/1 are usually not informative for activity alerts.
|
|
||||||
min_show = max(
|
|
||||||
0.0,
|
|
||||||
min(1.0, _env_float("POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN", 0.01)),
|
|
||||||
)
|
|
||||||
max_show = max(
|
|
||||||
0.0,
|
|
||||||
min(1.0, _env_float("POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX", 0.99)),
|
|
||||||
)
|
|
||||||
if min_show > max_show:
|
|
||||||
min_show, max_show = max_show, min_show
|
|
||||||
return min_show <= avg_price <= max_show
|
|
||||||
|
|
||||||
|
|
||||||
def _format_change_block(
|
|
||||||
change_type: str,
|
|
||||||
wallet: str,
|
|
||||||
wallet_alias: Optional[str],
|
|
||||||
pos: Dict[str, Any],
|
|
||||||
now_utc: str,
|
|
||||||
) -> str:
|
|
||||||
title = pos.get("title") or "Unknown market"
|
|
||||||
outcome = pos.get("outcome") or "Unknown"
|
|
||||||
market_url = _market_url(pos)
|
|
||||||
|
|
||||||
lines: List[str] = []
|
|
||||||
if change_type == "new":
|
|
||||||
lines.append("🆕 新开仓位")
|
|
||||||
elif change_type == "closed":
|
|
||||||
lines.append("❌ 仓位关闭")
|
|
||||||
else:
|
|
||||||
agg_count = int(pos.get("agg_count") or 1)
|
|
||||||
if agg_count > 1:
|
|
||||||
lines.append("🔁 连续仓位变动汇总")
|
|
||||||
else:
|
|
||||||
lines.append("🔄 仓位更新")
|
|
||||||
|
|
||||||
wallet_label = _short(wallet)
|
|
||||||
if wallet_alias:
|
|
||||||
wallet_label = f"{wallet_alias} ({wallet_label})"
|
|
||||||
lines.append(f"钱包: {wallet_label}")
|
|
||||||
lines.append(f"市场: {title}")
|
|
||||||
if market_url:
|
|
||||||
# Keep raw URL on its own line so Telegram can generate link preview.
|
|
||||||
lines.append(f"链接: {market_url}")
|
|
||||||
|
|
||||||
lines.append(f"买入方向: {outcome}")
|
|
||||||
|
|
||||||
if change_type == "update":
|
|
||||||
old_size = _safe_float(pos.get("old_size"))
|
|
||||||
now_size = _safe_float(pos.get("size"))
|
|
||||||
delta = _safe_float(pos.get("size_delta"))
|
|
||||||
lines.append(f"持有数量: {old_size:.3f} -> {now_size:.3f} (Δ {delta:+.3f})")
|
|
||||||
agg_count = int(pos.get("agg_count") or 1)
|
|
||||||
if agg_count > 1:
|
|
||||||
span_sec = int(_safe_float(pos.get("agg_span_sec")))
|
|
||||||
lines.append(f"变动次数: {agg_count} 次 | 聚合窗口: {span_sec}s")
|
|
||||||
else:
|
|
||||||
lines.append(f"持有数量: {_safe_float(pos.get('size')):.3f}")
|
|
||||||
|
|
||||||
avg_price = _safe_float(pos.get("avg_price"))
|
|
||||||
old_avg_price = _safe_float(pos.get("old_avg_price"))
|
|
||||||
agg_count = int(pos.get("agg_count") or 1)
|
|
||||||
if change_type == "update" and agg_count > 1:
|
|
||||||
if _should_show_avg_price(old_avg_price) or _should_show_avg_price(avg_price):
|
|
||||||
lines.append(f"建仓均价: {_fmt_price(old_avg_price)} -> {_fmt_price(avg_price)}")
|
|
||||||
elif _should_show_avg_price(avg_price):
|
|
||||||
lines.append(f"建仓均价: {_fmt_price(avg_price)}")
|
|
||||||
lines.append(f"当前价值: {_fmt_usd(_safe_float(pos.get('position_value')))}")
|
|
||||||
|
|
||||||
pnl = _safe_float(pos.get("cash_pnl"))
|
|
||||||
pnl_pct = _safe_float(pos.get("percent_pnl"))
|
|
||||||
lines.append(f"盈亏: {_fmt_usd(pnl)} ({_fmt_pct(pnl_pct)})")
|
|
||||||
lines.append(f"时间: {now_utc}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_message(
|
|
||||||
wallet: str,
|
|
||||||
changes: List[Tuple[str, Dict[str, Any]]],
|
|
||||||
max_changes: int,
|
|
||||||
wallet_alias: Optional[str] = None,
|
|
||||||
) -> str:
|
|
||||||
now_bj = (
|
|
||||||
datetime.now(timezone.utc)
|
|
||||||
.astimezone(ZoneInfo("Asia/Shanghai"))
|
|
||||||
.strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
)
|
|
||||||
shown = changes[:max_changes]
|
|
||||||
lines = [f"🚨 钱包异动监控 ({len(changes)} 个异动):", ""]
|
|
||||||
|
|
||||||
for idx, (change_type, pos) in enumerate(shown):
|
|
||||||
lines.append(
|
|
||||||
_format_change_block(
|
|
||||||
change_type,
|
|
||||||
wallet,
|
|
||||||
wallet_alias,
|
|
||||||
pos,
|
|
||||||
f"{now_bj} 北京时间",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if idx != len(shown) - 1:
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
if len(changes) > max_changes:
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"... 以及其他 {len(changes) - max_changes} 个异动")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_changes_by_position_value(
|
|
||||||
*,
|
|
||||||
wallet: str,
|
|
||||||
changes: List[Tuple[str, Dict[str, Any]]],
|
|
||||||
min_position_value_usd: float,
|
|
||||||
exempt_wallets: set[str],
|
|
||||||
) -> List[Tuple[str, Dict[str, Any]]]:
|
|
||||||
if min_position_value_usd <= 0:
|
|
||||||
return changes
|
|
||||||
if wallet in exempt_wallets:
|
|
||||||
return changes
|
|
||||||
|
|
||||||
out: List[Tuple[str, Dict[str, Any]]] = []
|
|
||||||
for change_type, pos in changes:
|
|
||||||
value = _safe_float(pos.get("position_value"))
|
|
||||||
if value >= min_position_value_usd:
|
|
||||||
out.append((change_type, pos))
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
"wallet activity skipped by position value floor user={} change={} value={:.2f} floor={:.2f} market={}",
|
|
||||||
wallet,
|
|
||||||
change_type,
|
|
||||||
value,
|
|
||||||
min_position_value_usd,
|
|
||||||
str(pos.get("title") or ""),
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread]:
|
|
||||||
enabled = _env_bool("POLYMARKET_WALLET_ACTIVITY_ENABLED", False)
|
|
||||||
chat_ids = get_polymarket_wallet_activity_chat_ids_from_env()
|
|
||||||
topic_chat_id = str(
|
|
||||||
os.getenv("POLYMARKET_WALLET_ACTIVITY_TOPIC_CHAT_ID") or ""
|
|
||||||
).strip()
|
|
||||||
topic_thread_id = max(
|
|
||||||
0,
|
|
||||||
_env_int("POLYMARKET_WALLET_ACTIVITY_TOPIC_ID", 0),
|
|
||||||
)
|
|
||||||
if topic_chat_id and topic_chat_id not in chat_ids:
|
|
||||||
# Mirror wallet activity push to a forum topic without replacing existing channels.
|
|
||||||
chat_ids = [*chat_ids, topic_chat_id]
|
|
||||||
users = _parse_addresses(os.getenv("POLYMARKET_WALLET_ACTIVITY_USERS"))
|
|
||||||
user_aliases = _parse_address_aliases(
|
|
||||||
os.getenv("POLYMARKET_WALLET_ACTIVITY_USER_ALIASES")
|
|
||||||
or os.getenv("POLYMARKET_WALLET_ACTIVITY_USERS_ALIASES")
|
|
||||||
)
|
|
||||||
exempt_wallets = _parse_address_set(
|
|
||||||
os.getenv("POLYMARKET_WALLET_ACTIVITY_MIN_VALUE_EXEMPT_USERS")
|
|
||||||
)
|
|
||||||
|
|
||||||
if not enabled:
|
|
||||||
logger.info("polymarket wallet activity watcher disabled")
|
|
||||||
return None
|
|
||||||
if not chat_ids:
|
|
||||||
logger.warning(
|
|
||||||
"polymarket wallet activity watcher skipped: "
|
|
||||||
"POLYMARKET_WALLET_ACTIVITY_CHAT_IDS/CHAT_ID and TELEGRAM_CHAT_IDS/CHAT_ID are empty"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
if not users:
|
|
||||||
logger.warning("polymarket wallet activity watcher skipped: POLYMARKET_WALLET_ACTIVITY_USERS is empty")
|
|
||||||
return None
|
|
||||||
|
|
||||||
data_api_url = str(
|
|
||||||
os.getenv("POLYMARKET_WALLET_ACTIVITY_DATA_API_URL", "https://data-api.polymarket.com")
|
|
||||||
).strip()
|
|
||||||
poll_sec = max(5, _env_int("POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC", 20))
|
|
||||||
timeout_sec = max(5, _env_int("POLYMARKET_WALLET_ACTIVITY_TIMEOUT_SEC", 10))
|
|
||||||
min_size_abs = max(0.0, _env_float("POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_ABS", 0.001))
|
|
||||||
min_size_delta = max(0.0, _env_float("POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_DELTA", 0.001))
|
|
||||||
min_avg_price_delta = max(
|
|
||||||
0.0,
|
|
||||||
_env_float("POLYMARKET_WALLET_ACTIVITY_MIN_AVG_PRICE_DELTA", 0.002),
|
|
||||||
)
|
|
||||||
immediate_on_size_delta = _env_bool(
|
|
||||||
"POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_ON_SIZE_DELTA",
|
|
||||||
True,
|
|
||||||
)
|
|
||||||
immediate_size_delta_min = max(
|
|
||||||
min_size_delta,
|
|
||||||
_env_float(
|
|
||||||
"POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_SIZE_DELTA_MIN",
|
|
||||||
min_size_delta,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
immediate_cooldown_sec = max(
|
|
||||||
0,
|
|
||||||
_env_int("POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_COOLDOWN_SEC", 20),
|
|
||||||
)
|
|
||||||
max_changes = max(1, _env_int("POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG", 5))
|
|
||||||
min_position_value_usd = max(
|
|
||||||
0.0, _env_float("POLYMARKET_WALLET_ACTIVITY_MIN_POSITION_VALUE_USD", 0.0)
|
|
||||||
)
|
|
||||||
notify_closed = _env_bool("POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED", False)
|
|
||||||
bootstrap_alert = _env_bool("POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT", False)
|
|
||||||
link_preview = _env_bool("POLYMARKET_WALLET_ACTIVITY_LINK_PREVIEW", True)
|
|
||||||
default_debounce_sec = max(poll_sec, 30)
|
|
||||||
update_debounce_sec = max(
|
|
||||||
poll_sec,
|
|
||||||
_env_int("POLYMARKET_WALLET_ACTIVITY_UPDATE_DEBOUNCE_SEC", default_debounce_sec),
|
|
||||||
)
|
|
||||||
default_update_max_hold_sec = max(update_debounce_sec, 120)
|
|
||||||
update_max_hold_sec = max(
|
|
||||||
update_debounce_sec,
|
|
||||||
_env_int("POLYMARKET_WALLET_ACTIVITY_UPDATE_MAX_HOLD_SEC", default_update_max_hold_sec),
|
|
||||||
)
|
|
||||||
new_update_suppress_sec = max(
|
|
||||||
0,
|
|
||||||
_env_int(
|
|
||||||
"POLYMARKET_WALLET_ACTIVITY_NEW_POSITION_UPDATE_SUPPRESS_SEC",
|
|
||||||
180,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 价格过滤范围配置
|
|
||||||
min_price = _env_float("POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN", 0.0)
|
|
||||||
max_price = _env_float("POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX", 1.0)
|
|
||||||
|
|
||||||
state_path = _state_file()
|
|
||||||
session = requests.Session()
|
|
||||||
|
|
||||||
def _runner() -> None:
|
|
||||||
state = _load_state(state_path)
|
|
||||||
users_state = state.setdefault("users", {})
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"polymarket wallet activity watcher started users={len(users)} "
|
|
||||||
f"poll={poll_sec}s data_api={data_api_url} price_filter={min_price}-{max_price} "
|
|
||||||
f"min_position_value_usd={min_position_value_usd} "
|
|
||||||
f"min_value_exempt_users={len(exempt_wallets)} "
|
|
||||||
f"chat_targets={len(chat_ids)} "
|
|
||||||
f"topic_chat_id={topic_chat_id or '-'} topic_id={topic_thread_id} "
|
|
||||||
f"aliases={len(user_aliases)} link_preview={link_preview} "
|
|
||||||
f"min_avg_price_delta={min_avg_price_delta} "
|
|
||||||
f"immediate_on_size_delta={immediate_on_size_delta} "
|
|
||||||
f"immediate_size_delta_min={immediate_size_delta_min} "
|
|
||||||
f"immediate_cooldown={immediate_cooldown_sec}s "
|
|
||||||
f"update_debounce={update_debounce_sec}s update_max_hold={update_max_hold_sec}s "
|
|
||||||
f"new_update_suppress={new_update_suppress_sec}s"
|
|
||||||
)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
cycle_started = time.time()
|
|
||||||
touched = False
|
|
||||||
for user in users:
|
|
||||||
try:
|
|
||||||
now_ts = int(time.time())
|
|
||||||
rows = _fetch_positions(
|
|
||||||
session=session,
|
|
||||||
base_url=data_api_url,
|
|
||||||
user=user,
|
|
||||||
timeout_sec=timeout_sec,
|
|
||||||
)
|
|
||||||
current = _build_snapshot(rows, min_size_abs=min_size_abs)
|
|
||||||
|
|
||||||
user_state = users_state.get(user) if isinstance(users_state.get(user), dict) else {}
|
|
||||||
prev = (user_state.get("positions") if isinstance(user_state, dict) else {}) or {}
|
|
||||||
if not isinstance(prev, dict):
|
|
||||||
prev = {}
|
|
||||||
pending_updates = (
|
|
||||||
user_state.get("pending_updates") if isinstance(user_state, dict) else {}
|
|
||||||
) or {}
|
|
||||||
if not isinstance(pending_updates, dict):
|
|
||||||
pending_updates = {}
|
|
||||||
update_push_meta = (
|
|
||||||
user_state.get("update_push_meta") if isinstance(user_state, dict) else {}
|
|
||||||
) or {}
|
|
||||||
if not isinstance(update_push_meta, dict):
|
|
||||||
update_push_meta = {}
|
|
||||||
new_push_meta = (
|
|
||||||
user_state.get("new_push_meta") if isinstance(user_state, dict) else {}
|
|
||||||
) or {}
|
|
||||||
if not isinstance(new_push_meta, dict):
|
|
||||||
new_push_meta = {}
|
|
||||||
initialized = bool(user_state.get("initialized")) if isinstance(user_state, dict) else False
|
|
||||||
|
|
||||||
# First cycle for each wallet only initializes baseline unless bootstrap alert is enabled.
|
|
||||||
if not initialized:
|
|
||||||
if not bootstrap_alert:
|
|
||||||
users_state[user] = {
|
|
||||||
"positions": current,
|
|
||||||
"pending_updates": {},
|
|
||||||
"update_push_meta": {},
|
|
||||||
"new_push_meta": {},
|
|
||||||
"initialized": True,
|
|
||||||
"updated_at": now_ts,
|
|
||||||
}
|
|
||||||
touched = True
|
|
||||||
continue
|
|
||||||
prev = {}
|
|
||||||
|
|
||||||
changes = _diff_positions(
|
|
||||||
previous=prev,
|
|
||||||
current=current,
|
|
||||||
min_size_delta=min_size_delta,
|
|
||||||
notify_closed=notify_closed,
|
|
||||||
min_price=min_price,
|
|
||||||
max_price=max_price,
|
|
||||||
min_avg_price_delta=min_avg_price_delta,
|
|
||||||
)
|
|
||||||
suppressed_update_keys = _collect_suppressed_update_keys(
|
|
||||||
new_push_meta=new_push_meta,
|
|
||||||
now_ts=now_ts,
|
|
||||||
suppress_sec=new_update_suppress_sec,
|
|
||||||
)
|
|
||||||
|
|
||||||
outgoing: List[Tuple[str, Dict[str, Any]]] = []
|
|
||||||
for change_type, pos in changes:
|
|
||||||
pos_key = _position_key(pos)
|
|
||||||
if change_type == "update":
|
|
||||||
if pos_key in suppressed_update_keys:
|
|
||||||
_merge_pending_update(
|
|
||||||
pending_updates=pending_updates,
|
|
||||||
pos_key=pos_key,
|
|
||||||
pos=pos,
|
|
||||||
now_ts=now_ts,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
size_delta_abs = abs(_safe_float(pos.get("size_delta")))
|
|
||||||
if (
|
|
||||||
immediate_on_size_delta
|
|
||||||
and size_delta_abs >= immediate_size_delta_min
|
|
||||||
):
|
|
||||||
last_push_ts = int(update_push_meta.get(pos_key) or 0)
|
|
||||||
if now_ts - last_push_ts >= immediate_cooldown_sec:
|
|
||||||
outgoing.extend(
|
|
||||||
_flush_ready_pending_updates(
|
|
||||||
pending_updates=pending_updates,
|
|
||||||
now_ts=now_ts,
|
|
||||||
debounce_sec=update_debounce_sec,
|
|
||||||
max_hold_sec=update_max_hold_sec,
|
|
||||||
force_keys={pos_key},
|
|
||||||
blocked_keys=suppressed_update_keys,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
outgoing.append((change_type, pos))
|
|
||||||
update_push_meta[pos_key] = now_ts
|
|
||||||
continue
|
|
||||||
|
|
||||||
_merge_pending_update(
|
|
||||||
pending_updates=pending_updates,
|
|
||||||
pos_key=pos_key,
|
|
||||||
pos=pos,
|
|
||||||
now_ts=now_ts,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
outgoing.extend(
|
|
||||||
_flush_ready_pending_updates(
|
|
||||||
pending_updates=pending_updates,
|
|
||||||
now_ts=now_ts,
|
|
||||||
debounce_sec=update_debounce_sec,
|
|
||||||
max_hold_sec=update_max_hold_sec,
|
|
||||||
force_keys={pos_key},
|
|
||||||
blocked_keys=suppressed_update_keys,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
outgoing.append((change_type, pos))
|
|
||||||
|
|
||||||
# If a key disappeared from snapshot, flush pending summary now.
|
|
||||||
missing_keys = {k for k in pending_updates.keys() if k not in current}
|
|
||||||
if missing_keys:
|
|
||||||
outgoing.extend(
|
|
||||||
_flush_ready_pending_updates(
|
|
||||||
pending_updates=pending_updates,
|
|
||||||
now_ts=now_ts,
|
|
||||||
debounce_sec=update_debounce_sec,
|
|
||||||
max_hold_sec=update_max_hold_sec,
|
|
||||||
force_keys=missing_keys,
|
|
||||||
blocked_keys=suppressed_update_keys,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for missing_key in missing_keys:
|
|
||||||
update_push_meta.pop(missing_key, None)
|
|
||||||
new_push_meta.pop(missing_key, None)
|
|
||||||
|
|
||||||
outgoing.extend(
|
|
||||||
_flush_ready_pending_updates(
|
|
||||||
pending_updates=pending_updates,
|
|
||||||
now_ts=now_ts,
|
|
||||||
debounce_sec=update_debounce_sec,
|
|
||||||
max_hold_sec=update_max_hold_sec,
|
|
||||||
blocked_keys=suppressed_update_keys,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
outgoing = _filter_changes_by_position_value(
|
|
||||||
wallet=user,
|
|
||||||
changes=outgoing,
|
|
||||||
min_position_value_usd=min_position_value_usd,
|
|
||||||
exempt_wallets=exempt_wallets,
|
|
||||||
)
|
|
||||||
|
|
||||||
if outgoing:
|
|
||||||
msg = _build_message(
|
|
||||||
user,
|
|
||||||
outgoing,
|
|
||||||
max_changes=max_changes,
|
|
||||||
wallet_alias=user_aliases.get(user),
|
|
||||||
)
|
|
||||||
sent_count = 0
|
|
||||||
for chat_id in chat_ids:
|
|
||||||
try:
|
|
||||||
send_kwargs = {
|
|
||||||
"disable_web_page_preview": not link_preview,
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
topic_thread_id > 0
|
|
||||||
and topic_chat_id
|
|
||||||
and str(chat_id).strip() == topic_chat_id
|
|
||||||
):
|
|
||||||
send_kwargs["message_thread_id"] = topic_thread_id
|
|
||||||
bot.send_message(
|
|
||||||
chat_id,
|
|
||||||
msg,
|
|
||||||
**send_kwargs,
|
|
||||||
)
|
|
||||||
sent_count += 1
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"wallet activity push failed user={} chat_id={} error={}",
|
|
||||||
user,
|
|
||||||
chat_id,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
if sent_count <= 0:
|
|
||||||
continue
|
|
||||||
for change_type, pos in outgoing:
|
|
||||||
if change_type == "new":
|
|
||||||
new_push_meta[_position_key(pos)] = now_ts
|
|
||||||
logger.info(
|
|
||||||
f"wallet activity pushed user={user} changes={len(outgoing)} chat_targets={sent_count}"
|
|
||||||
)
|
|
||||||
|
|
||||||
users_state[user] = {
|
|
||||||
"positions": current,
|
|
||||||
"pending_updates": pending_updates,
|
|
||||||
"update_push_meta": update_push_meta,
|
|
||||||
"new_push_meta": new_push_meta,
|
|
||||||
"initialized": True,
|
|
||||||
"updated_at": now_ts,
|
|
||||||
}
|
|
||||||
touched = True
|
|
||||||
except Exception:
|
|
||||||
logger.exception(f"wallet activity cycle failed user={user}")
|
|
||||||
|
|
||||||
if touched:
|
|
||||||
try:
|
|
||||||
_save_state(state_path, state)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("failed to save wallet activity state")
|
|
||||||
|
|
||||||
elapsed = time.time() - cycle_started
|
|
||||||
sleep_sec = max(0.0, poll_sec - elapsed)
|
|
||||||
time.sleep(sleep_sec)
|
|
||||||
|
|
||||||
thread = threading.Thread(
|
|
||||||
target=_runner,
|
|
||||||
name="polymarket-wallet-activity-watcher",
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
thread.start()
|
|
||||||
return thread
|
|
||||||
@@ -33,7 +33,7 @@ TARGET_CITIES: List[str] = [
|
|||||||
"qingdao",
|
"qingdao",
|
||||||
]
|
]
|
||||||
|
|
||||||
FORUM_CHAT_ID = "-1003965137823"
|
FORUM_CHAT_ID = "-1003927451869"
|
||||||
|
|
||||||
CITY_NAME_ZH: Dict[str, str] = {
|
CITY_NAME_ZH: Dict[str, str] = {
|
||||||
"beijing": "北京",
|
"beijing": "北京",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ _CITY_THREAD_IDS_PATH = os.path.join(
|
|||||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
"data", "city_thread_ids.json",
|
"data", "city_thread_ids.json",
|
||||||
)
|
)
|
||||||
_FORUM_CHAT_ID = "-1003965137823"
|
_FORUM_CHAT_ID = "-1003927451869"
|
||||||
_city_thread_ids: dict = {}
|
_city_thread_ids: dict = {}
|
||||||
|
|
||||||
|
|
||||||
@@ -917,12 +917,12 @@ def _build_airport_status_message(
|
|||||||
time_suffix = f" · {local_time}" if local_time else ""
|
time_suffix = f" · {local_time}" if local_time else ""
|
||||||
|
|
||||||
amos = city_weather.get("amos") or {}
|
amos = city_weather.get("amos") or {}
|
||||||
runway_data = (amos.get("runway_obs") or {}) if amos else {}
|
runway_data = amos.get("runway_obs") or {}
|
||||||
runway_pairs = runway_data.get("runway_pairs") or []
|
runway_pairs = runway_data.get("runway_pairs") or []
|
||||||
runway_temps = runway_data.get("temperatures") or []
|
runway_temps = runway_data.get("temperatures") or []
|
||||||
point_temps = runway_data.get("point_temperatures") or []
|
point_temps = runway_data.get("point_temperatures") or []
|
||||||
is_amsc = amos.get("source") in ("amsc_awos", "amos")
|
is_amsc = amos.get("source") in ("amsc_awos", "amos")
|
||||||
has_runway = bool(is_amsc and (point_temps or runway_temps))
|
has_runway = bool(runway_pairs and (runway_temps or point_temps))
|
||||||
amos_icao = amos.get("icao") or HIGH_FREQ_AIRPORT_ICAO.get(city, "")
|
amos_icao = amos.get("icao") or HIGH_FREQ_AIRPORT_ICAO.get(city, "")
|
||||||
settlement_pair = _settlement_runway_for_city(city)
|
settlement_pair = _settlement_runway_for_city(city)
|
||||||
|
|
||||||
@@ -953,7 +953,10 @@ def _build_airport_status_message(
|
|||||||
station_temp = row.get("temp")
|
station_temp = row.get("temp")
|
||||||
break
|
break
|
||||||
if station_temp is None and mgm_nearby:
|
if station_temp is None and mgm_nearby:
|
||||||
station_temp = mgm_nearby[0].get("temp")
|
logger.warning(
|
||||||
|
"airport message fallback city={}: station {} not found in mgm_nearby, falling back to current.temp",
|
||||||
|
city, airport_icao,
|
||||||
|
)
|
||||||
if station_temp is None:
|
if station_temp is None:
|
||||||
station_temp = (city_weather.get("current") or {}).get("temp")
|
station_temp = (city_weather.get("current") or {}).get("temp")
|
||||||
display_temp = station_temp
|
display_temp = station_temp
|
||||||
@@ -1210,7 +1213,20 @@ def _process_airport_city(
|
|||||||
airport_row = row
|
airport_row = row
|
||||||
break
|
break
|
||||||
if not airport_row:
|
if not airport_row:
|
||||||
airport_row = mgm_nearby[0] if mgm_nearby else {}
|
airport_primary = city_weather.get("airport_primary") or {}
|
||||||
|
if airport_primary.get("temp") is not None:
|
||||||
|
airport_row = airport_primary
|
||||||
|
else:
|
||||||
|
current_fallback = city_weather.get("current") or {}
|
||||||
|
if current_fallback.get("temp") is not None:
|
||||||
|
airport_row = current_fallback
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"airport push skipped city={}: station {} not found in mgm_nearby, "
|
||||||
|
"airport_primary, or current (mgm={} rows)",
|
||||||
|
city, airport_icao, len(mgm_nearby),
|
||||||
|
)
|
||||||
|
return None
|
||||||
station_temp = airport_row.get("temp") if airport_row else None
|
station_temp = airport_row.get("temp") if airport_row else None
|
||||||
current_obs_time = str(airport_row.get("obs_time") or "")
|
current_obs_time = str(airport_row.get("obs_time") or "")
|
||||||
|
|
||||||
|
|||||||
@@ -11,22 +11,7 @@ def _message():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_guard_blocks_non_member_before_points_charge():
|
def test_guard_charges_points():
|
||||||
fake_bot = SimpleNamespace(
|
|
||||||
reply_to=Mock(),
|
|
||||||
get_chat_member=Mock(return_value=SimpleNamespace(status="left")),
|
|
||||||
)
|
|
||||||
io_layer = SimpleNamespace(bot=fake_bot, ensure_query_points=Mock(return_value=True))
|
|
||||||
guard = CommandGuard(io_layer=io_layer, group_chat_id="-100123")
|
|
||||||
|
|
||||||
ok = guard.ensure_access_and_points(_message(), 1, "/city")
|
|
||||||
|
|
||||||
assert ok is False
|
|
||||||
assert io_layer.ensure_query_points.call_count == 0
|
|
||||||
assert fake_bot.reply_to.call_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_guard_allows_member_then_charges_points():
|
|
||||||
fake_bot = SimpleNamespace(
|
fake_bot = SimpleNamespace(
|
||||||
reply_to=Mock(),
|
reply_to=Mock(),
|
||||||
get_chat_member=Mock(return_value=SimpleNamespace(status="member")),
|
get_chat_member=Mock(return_value=SimpleNamespace(status="member")),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ class DummyBot:
|
|||||||
|
|
||||||
def test_startup_coordinator_respects_disable_flags(monkeypatch):
|
def test_startup_coordinator_respects_disable_flags(monkeypatch):
|
||||||
monkeypatch.setenv("POLYGON_WALLET_WATCH_ENABLED", "false")
|
monkeypatch.setenv("POLYGON_WALLET_WATCH_ENABLED", "false")
|
||||||
monkeypatch.setenv("POLYMARKET_WALLET_ACTIVITY_ENABLED", "false")
|
|
||||||
monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False)
|
monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False)
|
||||||
|
|
||||||
coordinator = StartupCoordinator(
|
coordinator = StartupCoordinator(
|
||||||
@@ -21,7 +20,6 @@ def test_startup_coordinator_respects_disable_flags(monkeypatch):
|
|||||||
loop_map = runtime.loop_map()
|
loop_map = runtime.loop_map()
|
||||||
|
|
||||||
assert loop_map["polygon_wallet_watch"].reason == "disabled_by_env"
|
assert loop_map["polygon_wallet_watch"].reason == "disabled_by_env"
|
||||||
assert loop_map["polymarket_wallet_activity"].reason == "retired_replaced_by_market_monitor"
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_runtime_status_html_contains_key_fields():
|
def test_render_runtime_status_html_contains_key_fields():
|
||||||
|
|||||||
@@ -112,7 +112,10 @@ def test_high_freq_airport_push_forces_analysis_refresh(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert sent is True
|
assert sent is True
|
||||||
assert calls == [("qingdao", False, True)]
|
# All cities processed with force_refresh_observations_only=True
|
||||||
|
assert len(calls) >= 1
|
||||||
|
assert all(c[2] is True for c in calls)
|
||||||
|
assert ("qingdao", False, True) in calls
|
||||||
assert bot.messages
|
assert bot.messages
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+71
-59
@@ -14,25 +14,23 @@ from web.services.city_api import (
|
|||||||
|
|
||||||
router = APIRouter(tags=["city"])
|
router = APIRouter(tags=["city"])
|
||||||
|
|
||||||
_MODEL_RANGE_CITIES: List[str] = [
|
def _all_city_keys() -> List[str]:
|
||||||
"beijing",
|
from src.data_collection.city_registry import CITY_REGISTRY
|
||||||
"shanghai",
|
|
||||||
"guangzhou",
|
|
||||||
"chengdu",
|
|
||||||
"chongqing",
|
|
||||||
"qingdao",
|
|
||||||
"wuhan",
|
|
||||||
]
|
|
||||||
|
|
||||||
_MODEL_RANGE_NAMES: Dict[str, str] = {
|
return sorted(CITY_REGISTRY.keys())
|
||||||
"beijing": "北京 (ZBAA)",
|
|
||||||
"shanghai": "上海 (ZSPD)",
|
|
||||||
"guangzhou": "广州 (ZGGG)",
|
def _city_display_name(city: str) -> str:
|
||||||
"chengdu": "成都 (ZUUU)",
|
from src.data_collection.city_registry import CITY_REGISTRY
|
||||||
"chongqing": "重庆 (ZUCK)",
|
|
||||||
"qingdao": "青岛 (ZSQD)",
|
meta = CITY_REGISTRY.get(city) or {}
|
||||||
"wuhan": "武汉 (ZHHH)",
|
icao = str(meta.get("icao") or "").strip()
|
||||||
}
|
display = str(meta.get("display_name") or city).strip()
|
||||||
|
return f"{display} ({icao})" if icao else display
|
||||||
|
|
||||||
|
|
||||||
|
_MODEL_RANGE_CITIES: List[str] = _all_city_keys()
|
||||||
|
_MODEL_RANGE_NAMES: Dict[str, str] = {c: _city_display_name(c) for c in _MODEL_RANGE_CITIES}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/cities")
|
@router.get("/api/cities")
|
||||||
@@ -40,55 +38,69 @@ async def list_cities(request: Request):
|
|||||||
return await list_cities_payload(request)
|
return await list_cities_payload(request)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_city_model_range(city: str, _force_refresh: bool) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Extract cached model range data without triggering fresh analysis."""
|
||||||
|
from web.analysis_service import _cache, _analysis_cache_key
|
||||||
|
|
||||||
|
for detail_mode in ("full", "panel", "nearby", "market"):
|
||||||
|
cache_key = _analysis_cache_key(city, detail_mode)
|
||||||
|
cached = _cache.get(cache_key)
|
||||||
|
if cached and isinstance(cached.get("d"), dict):
|
||||||
|
result = cached["d"]
|
||||||
|
if isinstance(result.get("multi_model"), dict) and result["multi_model"]:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
deb = result.get("deb") if isinstance(result, dict) else None
|
||||||
|
deb_pred = deb.get("prediction") if isinstance(deb, dict) else None
|
||||||
|
|
||||||
|
models = result.get("multi_model") if isinstance(result, dict) else {}
|
||||||
|
model_min: Optional[float] = None
|
||||||
|
model_max: Optional[float] = None
|
||||||
|
spread: Optional[float] = None
|
||||||
|
spread_label: str = ""
|
||||||
|
|
||||||
|
if isinstance(models, dict):
|
||||||
|
vals = sorted([v for v in models.values() if isinstance(v, (int, float))])
|
||||||
|
if len(vals) >= 2:
|
||||||
|
model_min = vals[0]
|
||||||
|
model_max = vals[-1]
|
||||||
|
spread = model_max - model_min
|
||||||
|
if spread <= 2.0:
|
||||||
|
spread_label = "低分歧"
|
||||||
|
elif spread <= 4.0:
|
||||||
|
spread_label = "中等分歧"
|
||||||
|
else:
|
||||||
|
spread_label = "高分歧"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": city,
|
||||||
|
"name": _MODEL_RANGE_NAMES.get(city, city),
|
||||||
|
"deb": round(deb_pred, 1) if deb_pred is not None else None,
|
||||||
|
"model_min": round(model_min, 1) if model_min is not None else None,
|
||||||
|
"model_max": round(model_max, 1) if model_max is not None else None,
|
||||||
|
"spread": round(spread, 1) if spread is not None else None,
|
||||||
|
"spread_label": spread_label,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/cities/model-range")
|
@router.get("/api/cities/model-range")
|
||||||
async def cities_model_range(
|
async def cities_model_range(
|
||||||
request: Request,
|
request: Request,
|
||||||
force_refresh: bool = Query(False),
|
force_refresh: bool = Query(False),
|
||||||
):
|
):
|
||||||
"""Return DEB prediction and model range for monitored Chinese cities."""
|
"""Return DEB prediction and model range for all monitored cities."""
|
||||||
from web.app import _analyze
|
|
||||||
|
|
||||||
rows: List[Dict[str, Any]] = []
|
rows: List[Dict[str, Any]] = []
|
||||||
for city in _MODEL_RANGE_CITIES:
|
for city in _MODEL_RANGE_CITIES:
|
||||||
try:
|
row = _extract_city_model_range(city, force_refresh)
|
||||||
result = _analyze(city, force_refresh=force_refresh)
|
if row is not None:
|
||||||
except Exception:
|
rows.append(row)
|
||||||
continue
|
|
||||||
|
|
||||||
deb = result.get("deb") if isinstance(result, dict) else None
|
|
||||||
deb_pred = deb.get("prediction") if isinstance(deb, dict) else None
|
|
||||||
|
|
||||||
models = result.get("multi_model") if isinstance(result, dict) else {}
|
|
||||||
model_min: Optional[float] = None
|
|
||||||
model_max: Optional[float] = None
|
|
||||||
spread: Optional[float] = None
|
|
||||||
spread_label: str = ""
|
|
||||||
|
|
||||||
if isinstance(models, dict):
|
|
||||||
vals = sorted([v for v in models.values() if isinstance(v, (int, float))])
|
|
||||||
if len(vals) >= 2:
|
|
||||||
model_min = vals[0]
|
|
||||||
model_max = vals[-1]
|
|
||||||
spread = model_max - model_min
|
|
||||||
if spread <= 2.0:
|
|
||||||
spread_label = "低分歧"
|
|
||||||
elif spread <= 4.0:
|
|
||||||
spread_label = "中等分歧"
|
|
||||||
else:
|
|
||||||
spread_label = "高分歧"
|
|
||||||
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"id": city,
|
|
||||||
"name": _MODEL_RANGE_NAMES.get(city, city),
|
|
||||||
"deb": round(deb_pred, 1) if deb_pred is not None else None,
|
|
||||||
"model_min": round(model_min, 1) if model_min is not None else None,
|
|
||||||
"model_max": round(model_max, 1) if model_max is not None else None,
|
|
||||||
"spread": round(spread, 1) if spread is not None else None,
|
|
||||||
"spread_label": spread_label,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: str(r.get("id") or ""))
|
||||||
return {"cities": rows}
|
return {"cities": rows}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ def create_telegram_bot_bind_link(request: Request) -> Dict[str, Any]:
|
|||||||
bot_username = str(
|
bot_username = str(
|
||||||
legacy_routes.os.getenv("TELEGRAM_BOT_USERNAME")
|
legacy_routes.os.getenv("TELEGRAM_BOT_USERNAME")
|
||||||
or legacy_routes.os.getenv("NEXT_PUBLIC_TELEGRAM_BOT_USERNAME")
|
or legacy_routes.os.getenv("NEXT_PUBLIC_TELEGRAM_BOT_USERNAME")
|
||||||
or "WeatherQuant_bot"
|
or "polyyuanbot"
|
||||||
).strip().lstrip("@")
|
).strip().lstrip("@")
|
||||||
bot_url = f"https://t.me/{bot_username}?start={start_param}"
|
bot_url = f"https://t.me/{bot_username}?start={start_param}"
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ def _raise_payment_error(exc: Exception) -> None:
|
|||||||
|
|
||||||
def _require_payment_identity(request: Request) -> Dict[str, Any]:
|
def _require_payment_identity(request: Request) -> Dict[str, Any]:
|
||||||
legacy_routes._assert_entitlement(request)
|
legacy_routes._assert_entitlement(request)
|
||||||
return legacy_routes._require_supabase_identity(request)
|
identity = legacy_routes._require_supabase_identity(request)
|
||||||
|
user_id = str(identity.get("user_id") or "").strip()
|
||||||
|
if not user_id or user_id == "entitlement" or user_id.startswith("admin:"):
|
||||||
|
raise HTTPException(status_code=401, detail="Supabase user required")
|
||||||
|
return identity
|
||||||
|
|
||||||
|
|
||||||
def get_payment_config(request: Request) -> Dict[str, Any]:
|
def get_payment_config(request: Request) -> Dict[str, Any]:
|
||||||
|
|||||||
Reference in New Issue
Block a user