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_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
|
||||
########################################
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
||||
cd /root/PolyWeather
|
||||
git pull origin main
|
||||
git fetch origin main && git reset --hard origin/main
|
||||
docker compose up -d --build
|
||||
sleep 10
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
@@ -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/data_collection/` | Weather sources (METAR, TAF, Open-Meteo, JMA, KMA, MGM, NMC, Russia stations, settlement sources), city registry (52 cities), Polymarket readonly layer. Also: `madis_sources.py` (NOAA 5-min NetCDF), `amos_station_sources.py` (Korean runway sensors), `country_networks.py` (per-country provider routing + `_airport_primary_from_raw`) |
|
||||
| `src/data_mining/` | Historical data fetch utilities |
|
||||
| `src/onchain/` | Polygon wallet watcher, Polymarket wallet activity watcher |
|
||||
| `src/onchain/` | Polygon wallet watcher |
|
||||
| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit |
|
||||
| `src/strategy/` | Trading strategy 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)
|
||||
|
||||
## Product Status (2026-05-11)
|
||||
## Product Status (2026-05-23)
|
||||
|
||||
- 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.
|
||||
@@ -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.
|
||||
- Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules.
|
||||
- Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives.
|
||||
- City decision cards now include the AI airport read: METAR, DEB, model cluster, and the AI expected-high center are resolved before mapping the result to Polymarket temperature buckets.
|
||||
- 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.
|
||||
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
|
||||
- The card label “model-market difference” means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
|
||||
- Calibrated model probability is now the primary probability panel. It shows the active production probability engine; EMOS/LGBM are surfaced only when evaluated or shadowed, while model consensus and market prices remain secondary references.
|
||||
- Calibrated model probability is now the primary probability panel. It shows the active production probability engine (legacy Gaussian or EMOS), while model consensus remains a secondary reference.
|
||||
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
|
||||
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
|
||||
- Trade cue now combines upper-air structure, `TAF`, market crowding, and `edge_percent`.
|
||||
- Browser extension now uses `DEB` for multi-day forecast and stays positioned as a lightweight lead-in to the main site.
|
||||
- Official nearby-network layer now covers `MGM` (Turkey), `CMA/NMC` (Mainland China), `JMA AMeDAS` (Japan), `AMOS` (Korea, runway-level, Seoul/Busan), `HKO` (Hong Kong), and `CWA` (Taiwan).
|
||||
- Tokyo now ingests Haneda `JMA AMeDAS` 10-minute temperature as the official enhancement layer.
|
||||
- Dashboard prewarm is now supported through a dedicated worker / cron path, with runtime status exposed in `/api/system/status` and `/ops`.
|
||||
- `/ops` now exposes cache bucket counts, summary cache hit / miss rate, and prewarm runtime heartbeat.
|
||||
- Intraday commentary can optionally use `Groq` as a bilingual rewrite layer, while rule-based commentary remains the fallback.
|
||||
- Vercel frontend guidance now includes cost controls for analytics, eager fetches, and edge-side scanner blocking.
|
||||
- Frontend design system overhauled: unified CSS token system, eliminated `!important` abuse (68→6 in light theme), consolidated breakpoints (18→10), migrated hardcoded colors to CSS variables, added ARIA attributes and focus-visible keyboard navigation. See `docs/frontend-ui-design-review.md` for the full audit trail.
|
||||
- Frontend design system overhauled: unified CSS token system, eliminated `!important` abuse (134→49 in light theme), consolidated breakpoints (18→10), migrated hardcoded colors to CSS variables, added ARIA attributes and focus-visible keyboard navigation. See `docs/frontend-ui-design-review.md` for the full audit trail.
|
||||
|
||||
## License & Commercial Boundary
|
||||
|
||||
@@ -66,15 +62,13 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
|
||||
- Aggregates observations and forecasts for 51 monitored cities.
|
||||
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
|
||||
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution), with `LGBM` metadata surfaced when the calibrated engine is active.
|
||||
- Maps weather view to Polymarket quotes for mispricing scan.
|
||||
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via legacy Gaussian or EMOS/CRPS calibration.
|
||||
- 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.
|
||||
- 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 airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
|
||||
- Adds official nearby-network and runway-level enhancement layers for China, Japan, Korea (AMOS runway sensors for Seoul/Busan), Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
|
||||
- Adds optional dashboard prewarm worker so hot cities can be refreshed before user clicks.
|
||||
|
||||
## Reference Architecture
|
||||
|
||||
@@ -96,9 +90,7 @@ flowchart LR
|
||||
|
||||
API --> ANA["DEB + Trend + Probability + Market Scan"]
|
||||
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
|
||||
ANA --> PM["Polymarket Read-only Layer"]
|
||||
ANA --> LLM["Optional Groq Commentary Rewrite"]
|
||||
API --> PREWARM["Dashboard Prewarm API / Worker"]
|
||||
ANA --> STATE["SQLite runtime state"]
|
||||
```
|
||||
|
||||
## 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.
|
||||
- Intraday analysis now separates meteorology conclusion, evidence chain, invalidation rules, confirmation rules, calibrated probability, and market reference.
|
||||
- `TAF` is used as an airport-side confirmation layer, not as the main temperature model.
|
||||
- `LGBM` can power the calibrated probability panel; model vote counts remain an explanatory consensus line, not the final probability.
|
||||
- Calibrated probability uses legacy Gaussian (default) or EMOS/CRPS when evaluated; model vote counts remain an explanatory consensus line, not the final probability.
|
||||
- Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
### Dashboard prewarm worker
|
||||
|
||||
```bash
|
||||
docker compose --profile workers up -d polyweather_prewarm
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
- `prewarm.thread_alive`
|
||||
- `prewarm.runtime.cycle_count`
|
||||
- `cache.analysis.hit_rate`
|
||||
- `cache.open_meteo_forecast_entries`
|
||||
|
||||
### Frontend cache headers
|
||||
|
||||
```bash
|
||||
@@ -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
|
||||
```
|
||||
|
||||
### Wallet activity logs
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
|
||||
```
|
||||
|
||||
## Telegram Commands
|
||||
|
||||
| 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)
|
||||
- TAF signal guide (ZH): [docs/TAF_SIGNAL_ZH.md](docs/TAF_SIGNAL_ZH.md)
|
||||
- Model stack & DEB (ZH): [docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- EMOS + LGBM system (ZH): [docs/EMOS_LGBM_SYSTEM_ZH.md](docs/EMOS_LGBM_SYSTEM_ZH.md)
|
||||
- Commercialization: [docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
|
||||
- AGPL-3.0 policy: [docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
|
||||
- Supabase setup (ZH): [docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
||||
- Configuration & secrets (ZH): [docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
|
||||
- LightGBM daily-high model (ZH): [docs/LGBM_DAILY_HIGH_ZH.md](docs/LGBM_DAILY_HIGH_ZH.md)
|
||||
- Frontend deployment (ZH): [docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
- Tech debt (EN): [docs/TECH_DEBT.md](docs/TECH_DEBT.md)
|
||||
- Tech debt (ZH): [docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- Airport realtime sources: [docs/AIRPORT_REALTIME_SOURCES.md](docs/AIRPORT_REALTIME_SOURCES.md)
|
||||
- Airport market monitor (ZH): [docs/AIRPORT_MARKET_MONITOR_ZH.md](docs/AIRPORT_MARKET_MONITOR_ZH.md)
|
||||
- Services overview (ZH): [docs/SERVICES_ZH.md](docs/SERVICES_ZH.md)
|
||||
- Payment verification: [docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
|
||||
- Payment audit: [docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md)
|
||||
- Payment V2 upgrade: [docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- Ops admin guide: [docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||
- Monitoring guide (ZH): [docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
|
||||
- Deep research report: [docs/deep-research-report.md](docs/deep-research-report.md)
|
||||
- Frontend report: [FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
|
||||
- Release process: [RELEASE.md](RELEASE.md)
|
||||
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## Version
|
||||
|
||||
- Version: `v1.7.0`
|
||||
- Last Updated: `2026-05-11`
|
||||
- Last Updated: `2026-05-23`
|
||||
|
||||
+11
-41
@@ -14,7 +14,7 @@
|
||||
|
||||

|
||||
|
||||
## 当前产品状态(2026-05-11)
|
||||
## 当前产品状态(2026-05-23)
|
||||
|
||||
- 已上线订阅制:`Pro 月付 10 USDC`。
|
||||
- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖。
|
||||
@@ -40,13 +40,12 @@
|
||||
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与 prewarm heartbeat。
|
||||
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
|
||||
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
|
||||
- 城市决策卡已接入 AI 机场报文解读:先用 METAR、DEB、多模型集群和 AI 最高温中枢判断天气路径,再映射到 Polymarket 温度桶。
|
||||
- 城市决策卡已接入 AI 机场报文解读:先用 METAR、DEB、多模型集群和 AI 最高温中枢判断天气路径,再映射到温度桶。
|
||||
- AI 机场报文解读现在同时使用页面内存缓存、浏览器 `localStorage` 和后端短 TTL 缓存;从其他选项卡切回决策卡时会优先恢复已有流式内容或最终结果。
|
||||
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
|
||||
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
||||
- 概率区已改为“校准模型概率”;默认展示生产概率引擎输出,EMOS/LGBM 只在通过评估或作为 shadow 时进入解释层。
|
||||
- 今日日内结构解读已支持可选 `Groq` 改写层,失败时自动回退规则文案。
|
||||
- 前端部署文档已补充 Vercel 节流建议,包括 analytics 关闭、eager fetch 开关与扫描流量防火墙规则。
|
||||
- 概率区已改为”校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS),模型共识作为辅助参考。
|
||||
- 今日日内结构解读已支持可选 Groq 改写层,失败时自动回退规则文案。
|
||||
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`。
|
||||
|
||||
## 许可证与商用边界(重要)
|
||||
@@ -63,13 +62,11 @@
|
||||
|
||||
- 聚合 51 个监控城市的实测与预报数据。
|
||||
- DEB(Dynamic Error Balancing)融合多模型最高温。
|
||||
- 输出结算导向校准概率分布(`mu` + 温度桶),并在 LGBM 生效时展示校准引擎元数据。
|
||||
- 将模型观点映射到 Polymarket 行情,做错价扫描。
|
||||
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。
|
||||
- 地图城市决策卡把 AI 机场报文解读、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示。
|
||||
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
|
||||
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
|
||||
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
|
||||
- 支持后台预热热点城市,降低用户点击城市后的冷启动成本。
|
||||
|
||||
## 参考架构
|
||||
|
||||
@@ -90,10 +87,6 @@ flowchart LR
|
||||
|
||||
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
|
||||
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"]
|
||||
```
|
||||
|
||||
@@ -158,21 +151,7 @@ curl http://127.0.0.1:8000/api/system/status
|
||||
curl http://127.0.0.1:8000/metrics
|
||||
```
|
||||
|
||||
### Dashboard 预热 Worker
|
||||
|
||||
```bash
|
||||
docker compose --profile workers up -d polyweather_prewarm
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
```
|
||||
|
||||
重点关注:
|
||||
|
||||
- `prewarm.thread_alive`
|
||||
- `prewarm.runtime.cycle_count`
|
||||
- `prewarm.runtime.last_summary_ok`
|
||||
- `cache.analysis.hit_rate`
|
||||
|
||||
### 前端缓存头
|
||||
### 外部监控栈
|
||||
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
@@ -215,12 +194,6 @@ curl http://127.0.0.1:8000/api/payments/runtime
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
```
|
||||
|
||||
### 钱包异动监听日志
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
|
||||
```
|
||||
|
||||
## Telegram 指令
|
||||
|
||||
| 指令 | 用途 |
|
||||
@@ -241,24 +214,21 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
|
||||
- Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
||||
- 配置与密钥管理:[docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
|
||||
- 前端部署(Vercel):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
- EMOS 训练报告:[docs/EMOS_TRAINING_REPORT_ZH.md](docs/EMOS_TRAINING_REPORT_ZH.md)
|
||||
- 概率快照归档:[docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md](docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md)
|
||||
- 技术债(中文镜像):[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- 技术债(主文档):[docs/TECH_DEBT.md](docs/TECH_DEBT.md)
|
||||
- 技术债:[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- 机场实时数据源:[docs/AIRPORT_REALTIME_SOURCES.md](docs/AIRPORT_REALTIME_SOURCES.md)
|
||||
- 机场市场监控(中文):[docs/AIRPORT_MARKET_MONITOR_ZH.md](docs/AIRPORT_MARKET_MONITOR_ZH.md)
|
||||
- 外部服务总览:[docs/SERVICES_ZH.md](docs/SERVICES_ZH.md)
|
||||
- 支付合约验证:[docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
|
||||
- 支付审计说明:[docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md)
|
||||
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
|
||||
- 模型栈与 DEB:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- LightGBM 日最高温模型:[docs/LGBM_DAILY_HIGH_ZH.md](docs/LGBM_DAILY_HIGH_ZH.md)
|
||||
- EMOS + LGBM 系统:[docs/EMOS_LGBM_SYSTEM_ZH.md](docs/EMOS_LGBM_SYSTEM_ZH.md)
|
||||
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
|
||||
- 前端报告:[FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
|
||||
- 发布流程:[RELEASE.md](RELEASE.md)
|
||||
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## 当前版本
|
||||
|
||||
- 版本:`v1.7.0`
|
||||
- 文档最后更新:`2026-05-11`
|
||||
- 文档最后更新:`2026-05-23`
|
||||
|
||||
+7
-7
@@ -12,9 +12,9 @@
|
||||
|
||||
示例:
|
||||
|
||||
- `1.4.0 -> 1.4.1`:告警逻辑修正、缓存修正、文档修正
|
||||
- `1.4.0 -> 1.5.0`:新增支付能力、新增页面、新增 API
|
||||
- `1.4.0 -> 2.0.0`:接口重构或数据结构不兼容
|
||||
- `1.7.0 -> 1.7.1`:告警逻辑修正、缓存修正、文档修正
|
||||
- `1.7.0 -> 1.8.0`:新增能力、接口扩展、向后兼容的功能迭代
|
||||
- `1.7.0 -> 2.0.0`:不兼容变更、核心架构升级
|
||||
|
||||
## 日常升版步骤
|
||||
|
||||
@@ -29,7 +29,7 @@ python scripts/bump_version.py patch
|
||||
```bash
|
||||
python scripts/bump_version.py minor
|
||||
python scripts/bump_version.py major
|
||||
python scripts/bump_version.py 1.5.0
|
||||
python scripts/bump_version.py 1.8.0
|
||||
```
|
||||
|
||||
### 2. 检查同步结果
|
||||
@@ -68,15 +68,15 @@ python -m pytest
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "release: v1.4.1"
|
||||
git tag v1.4.1
|
||||
git commit -m "release: v1.7.1"
|
||||
git tag v1.7.1
|
||||
```
|
||||
|
||||
### 6. 推送
|
||||
|
||||
```bash
|
||||
git push
|
||||
git push origin v1.4.1
|
||||
git push origin v1.7.1
|
||||
```
|
||||
|
||||
## 当前约束
|
||||
|
||||
@@ -105,12 +105,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE`
|
||||
- `POLYWEATHER_PAYMENT_ENABLED`
|
||||
- `POLYMARKET_MARKET_SCAN_ENABLED`
|
||||
- `POLYGON_WALLET_WATCH_ENABLED`
|
||||
- `TELEGRAM_ALERT_PUSH_ENABLED`
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED`(已退役,建议保持 `false`)
|
||||
- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED`
|
||||
|
||||
### 4.3 L3:运行调优项
|
||||
|
||||
@@ -130,26 +127,12 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- `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:敏感项
|
||||
|
||||
这些变量不应写进公开文档截图,也不应提交到仓库。
|
||||
@@ -160,9 +143,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
|
||||
- `METEOBLUE_API_KEY`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
|
||||
- `POLYMARKET_SECRET_KEY`
|
||||
|
||||
## 5. 推荐部署矩阵
|
||||
|
||||
@@ -254,12 +235,6 @@ TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
||||
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true
|
||||
POLYWEATHER_PREWARM_INTERVAL_SEC=300
|
||||
POLYWEATHER_PREWARM_JITTER_SEC=20
|
||||
POLYWEATHER_PREWARM_INCLUDE_DETAIL=true
|
||||
POLYWEATHER_PREWARM_INCLUDE_MARKET=true
|
||||
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
|
||||
POLYWEATHER_SCAN_AI_ENABLED=false
|
||||
POLYWEATHER_SCAN_AI_API_KEY=...
|
||||
@@ -281,29 +256,6 @@ POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
|
||||
- 机器人市场监控包含 `关键提醒` 与 `关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
|
||||
- `POLYWEATHER_SCAN_AI_*` 走 OpenAI-compatible `/chat/completions`;当前临时默认 MiMo,可用 `POLYWEATHER_SCAN_AI_BASE_URL` 与 `POLYWEATHER_SCAN_AI_MODEL` 随时切回其他兼容 provider。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED` 已退役,保留为 `false` 即可,不建议再启用钱包异动监听。
|
||||
- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true` 时,建议同时启用独立 worker 或 bot 内嵌预热线程。
|
||||
|
||||
### 6.3 Dashboard 预热 worker 推荐变量
|
||||
|
||||
```env
|
||||
POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true
|
||||
POLYWEATHER_PREWARM_INTERVAL_SEC=300
|
||||
POLYWEATHER_PREWARM_JITTER_SEC=20
|
||||
POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid
|
||||
POLYWEATHER_PREWARM_INCLUDE_DETAIL=true
|
||||
POLYWEATHER_PREWARM_INCLUDE_MARKET=true
|
||||
POLYWEATHER_PREWARM_FORCE_REFRESH=false
|
||||
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 这组变量用于后台定向预热热点城市,避免用户点击城市时才冷启动拉 detail。
|
||||
|
||||
|
||||
```env
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
@@ -311,7 +263,7 @@ POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
|
||||
|
||||
### 6.5 机器人市场监控建议配置
|
||||
|
||||
这套配置用于替代旧的钱包异动监听,围绕市场本身做两类推送:
|
||||
这套配置围绕市场本身做两类推送:
|
||||
|
||||
- `关键提醒`:实时错价/触发条件满足时发送
|
||||
- `关注清单`:按亚洲时区定时推送当日重点市场摘要
|
||||
@@ -329,7 +281,6 @@ TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
||||
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
```
|
||||
|
||||
说明:
|
||||
@@ -337,7 +288,6 @@ POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
- `TELEGRAM_ALERT_MISPRICING_ONLY=true` 表示关键提醒优先围绕错价/市场触发,不把机器人做成泛通知器。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800` 表示频道每 30 分钟主动推送一轮全局机会清单;每轮会先扫描完整 `TELEGRAM_ALERT_CITIES`,再选 Top N。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5` 建议先保持较小,避免机器人一次推太多城市。
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED=false` 表示停用旧的钱包异动监听,统一收敛到市场监控。
|
||||
|
||||
## 7. 当前建议的运维规则
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
```
|
||||
|
||||
只影响按钮跳转,不影响核心页面加载。
|
||||
|
||||
@@ -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`
|
||||
- `metar`
|
||||
|
||||
+7
-10
@@ -1,8 +1,8 @@
|
||||
# 外部服务依赖总览
|
||||
|
||||
最后更新:`2026-05-19`
|
||||
最后更新:`2026-05-23`
|
||||
|
||||
项目调用了 24 个外部服务,按状态分为三类。
|
||||
项目调用了 20 个外部服务,按状态分为三类。
|
||||
|
||||
## 核心(必须有,挂了服务不可用)
|
||||
|
||||
@@ -13,10 +13,7 @@
|
||||
| MADIS (NOAA) | 美国 5 分钟高频观测 | ✅ |
|
||||
| Supabase | 用户认证 + 订阅 | ✅ |
|
||||
| Telegram Bot API | Bot 消息 + 群成员检查 | ✅ |
|
||||
| KNMI | Amsterdam 10 分钟观测 | ✅ |
|
||||
| Polymarket Gamma/CLOB | 市场数据读取 | ✅ |
|
||||
|
||||
## 国家气象源(特定城市必须)
|
||||
| KNMI | Amsterdam 10 分钟观测 | ✅ |## 国家气象源(特定城市必须)
|
||||
|
||||
| 服务 | 城市 | 状态 |
|
||||
| -------------------- | --------------------- | ----------- |
|
||||
@@ -30,7 +27,7 @@
|
||||
| NMC (中国) | 国内城市 fallback | ✅ |
|
||||
| Singapore MSS | Singapore | ✅ |
|
||||
| IMGW (波兰) | Warsaw | ⚠️ 未配 key |
|
||||
| Russia pogodaiklimat | Moscow | ✅ |
|
||||
| Russia pogodaiklimat | Moscow | ❌ 已移除 |
|
||||
|
||||
## 可选 / 已禁用
|
||||
|
||||
@@ -38,7 +35,7 @@
|
||||
| -------------- | ------------- | ----------- |
|
||||
| OpenWeatherMap | 天气 fallback | ⚠️ 未配 key |
|
||||
| VisualCrossing | 历史天气 | ⚠️ 未配 key |
|
||||
| Meteoblue | 天气预报 | ⚠️ 未配 key |
|
||||
| Meteoblue | 天气预报 | ❌ 已移除 |
|
||||
| SynopticData | 美国站点观测 | ⚠️ 未配 key |
|
||||
|
||||
## AI / 其他
|
||||
@@ -47,10 +44,10 @@
|
||||
| ----------------- | ---------------- | ----------- |
|
||||
| MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 |
|
||||
| DeepSeek | AI fallback | - 备用 |
|
||||
| Groq | AI commentary | ⚠️ 未配 key |
|
||||
| Groq | AI commentary | ❌ 已移除 |
|
||||
| Polygon RPC | 链上支付 | ✅ |
|
||||
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key |
|
||||
|
||||
## 合计
|
||||
|
||||
18 个在用,6 个可选/未配置。
|
||||
15 个在用,3 个可选/未配置,3 个已移除。
|
||||
|
||||
@@ -92,20 +92,7 @@ POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
|
||||
```
|
||||
|
||||
## 5. 钱包异动频道拆分(推荐)
|
||||
|
||||
如果要把“钱包异动监控”发到独立频道:
|
||||
|
||||
```env
|
||||
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=-1003821482461
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 设置了 `POLYMARKET_WALLET_ACTIVITY_CHAT_ID(S)` 后,钱包异动推送优先发该频道。
|
||||
- 未设置时,回退到全局 `TELEGRAM_CHAT_IDS/TELEGRAM_CHAT_ID`。
|
||||
|
||||
## 6. 验证步骤
|
||||
## 5. 验证步骤
|
||||
|
||||
1. 登录后请求 `/api/auth/me`,确认 `authenticated=true`。
|
||||
2. 请求 `/api/payments/config`,确认 `enabled=true`、`configured=true`。
|
||||
|
||||
@@ -46,7 +46,6 @@ flowchart TD
|
||||
- 支付运行态 API 与 SQLite 审计事件已补齐。
|
||||
- 钱包绑定支持浏览器钱包 + WalletConnect。
|
||||
- 账户中心与 Pro 权限展示链路打通。
|
||||
- 钱包异动支持独立频道路由。
|
||||
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口。
|
||||
- 轻量可观测性已上线(`/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”。
|
||||
@@ -12,7 +14,7 @@ PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为
|
||||
DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;运行态仍维护近 14 天 `daily_records` 缓存做当前对账,但长期监督真值与训练特征已经迁到 SQLite 永久表中,并支持基于 WU(Weather Underground 口径)四舍五入的结算命中评估。
|
||||
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
|
||||
**城市决策层(Scan Terminal / AI 机场报文解读)**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR,并通过 `/api/scan/terminal/ai-city/stream` 生成城市级 AI 解读。该解读由 `final_judgment`、`metar_read`、`reasoning`、`model_cluster_note`、`risks` 与原始 METAR 证据组成;最高温中枢优先使用 AI `predicted_max`,再回退到 DEB、多模型中心、日内 pace 或当前实测。
|
||||
**市场层(Polymarket 行情对照)**:只读模式从 Gamma API 发现市场、从 CLOB(`py-clob-client` 或 REST 回退)读取价格/盘口,并用完整 `all_buckets` 对目标温度桶做 exact/range/“or higher”/“or lower” 严格匹配,计算“模型-市场差”(模型概率 − 市场隐含概率)生成信号标签。
|
||||
**市场层(Polymarket 行情对照)**:*[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。
|
||||
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
|
||||
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
|
||||
**许可证**:仓库根目录 `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 小并发队列与完整市场桶映射。 |
|
||||
| 运行时组件 | `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。 |
|
||||
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 + 市场读取 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py`、`settlement_sources.py`、`metar_sources.py`、`mgm_sources.py`、`amos_station_sources.py`、`jma_amedas_sources.py`、`nws_open_meteo_sources.py`。 |
|
||||
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py`、`settlement_sources.py`、`metar_sources.py`、`mgm_sources.py`、`amos_station_sources.py`、`jma_amedas_sources.py`、`nws_open_meteo_sources.py`、`country_networks.py` 等。v1.7.0 已移除 NMC、pogodaiklimat、Meteoblue 数据源。 |
|
||||
| Python 域模块 | `src/analysis/*` | DEB/趋势/概率/结算口径 | `deb_algorithm.py`、`trend_engine.py`、`settlement_rounding.py`。 |
|
||||
| Python 域模块 | `src/payments/*` + `contracts/*` | 支付合约 + 事件监听/补单 | Solidity 合约 + Python 侧事件扫描/确认循环 + SQLite 审计事件 + RPC 多节点容灾 + 合约静态检查。 |
|
||||
| Python 域模块 | `src/auth/*`、`docs/SUPABASE_SETUP_ZH.md`、`scripts/supabase/schema.sql` | Supabase 鉴权/订阅/积分 | 使用 `/auth/v1/user` 校验 JWT、`/rest/v1/subscriptions` 查订阅(服务端角色 key 必须保密)。 |
|
||||
@@ -68,8 +70,6 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
||||
NWS[api.weather.gov]
|
||||
HKO[data.weather.gov.hk]
|
||||
CWA[opendata.cwa.gov.tw]
|
||||
PM_G[Polymarket Gamma API]
|
||||
PM_C[Polymarket CLOB API]
|
||||
SB[Supabase Auth/REST]
|
||||
RPC[Polygon RPC]
|
||||
end
|
||||
@@ -96,8 +96,6 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
||||
WX --> HKO
|
||||
WX --> CWA
|
||||
|
||||
FAST --> PM_G
|
||||
FAST --> PM_C
|
||||
FAST --> LLM
|
||||
|
||||
FAST --> SB
|
||||
@@ -161,13 +159,15 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
**CI 已建立,但组织级质量门禁未必完全收口**:CI 现已覆盖 Python、前端与 Docker build。当前问题不再是“缺 CI”,而是是否把这些 status check 绑定到 `main` 保护策略,以及是否逐步引入更严格的 pre-merge 审查。
|
||||
**运行态状态/缓存与核心离线链路的 SQLite 收口已完成**:`daily_records`、`telegram_alert_state`、`probability_training_snapshots`、`open_meteo` 缓存已经支持并在生产中主读 SQLite,迁移/校验脚本可用;进一步地,在临时移除 `data/*.json` / `data/*.jsonl` 后,训练集导出、概率拟合、评估报告、shadow report 和关键 backfill 脚本已验证仍可运行。当前 legacy 文件路径主要是显式回退入口,而不再是默认主输入。
|
||||
**第三方服务合规与稳定性风险**:
|
||||
项目强依赖外部 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 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
|
||||
## 对标分析
|
||||
|
||||
为满足“至少 3 个相似开源项目或近期论文”对标,本报告选择三类代表:
|
||||
1)**AI 气象预报模型**(GraphCast / FourCastNet / Pangu-Weather):用于评估“若 PolyWeather 未来扩展到更强预测能力”的技术与许可边界;
|
||||
3)**预测市场 API 客户端生态**(Polymarket/py-clob-client、aiopolymarket):用于评估市场层的工程选型。
|
||||
3)**预测市场 API 客户端生态**(Polymarket/py-clob-client、aiopolymarket):*[v1.7.0 后已不适用]* 市场价格拉取层已移除,此对标仅作历史参考。
|
||||
### 关键对比表
|
||||
|
||||
| 项目/论文 | 解决的问题 | 输出形态 | 性能/效果(公开描述) | 易用性与依赖 | 许可证要点 |
|
||||
@@ -175,18 +175,18 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
| **GraphCast**(google-deepmind/graphcast) | 10 天全球中期预报(ML 替代/增强 NWP) | 模型代码+权重+notebooks | 论文与介绍提到在大量指标上优于主流确定性系统;仓库提供预训练权重与示例数据入口,并提示 ERA5/HRES 数据条款需另行遵守。 | 完整训练需 ERA5 等;更适合科研/平台级推理,不是产品级 BFF。 | 代码 Apache-2.0;权重 CC BY-NC-SA 4.0(商业限制)。 |
|
||||
| **FourCastNet**(NVlabs/FourCastNet) | 高分辨率 data-driven 全球预报(AFNO/ViT) | 模型训练/推理代码+数据/权重链接 | README 描述:0.25° 分辨率、周尺度推理非常快,并可做大规模集合;适合平台型预报。 | 训练/数据依赖大(ERA5 子集 TB 级);工程集成成本高。 | BSD 3-Clause(代码)。 |
|
||||
| **Pangu-Weather**(198808xc/Pangu-Weather + Nature 论文) | 3D Transformer 架构的中期全球预报 | ONNX 推理代码+预训练模型 | Nature 论文称在 reanalysis 上对比 IFS 有更强确定性预报表现,并强调速度优势;仓库提供 ONNX 推理与 lite 版训练说明。 | 模型文件大(多份 ~GB 级),训练资源需求高;更适合科研推理或内部平台。 | 权重 BY-NC-SA 4.0、明确禁止商业用途。 |
|
||||
| **Polymarket/py-clob-client** | Polymarket CLOB 读写 SDK | Python SDK | 官方 SDK,支持 read-only 与交易接口;协议与端点在官方文档中给出。 | 易用,适合增强 PolyWeather 市场层。 | MIT。 |
|
||||
| **aiopolymarket** | Polymarket APIs 的 async 客户端 | Python async 客户端 | 强调类型安全(Pydantic)、自动分页、重试与 backoff,适合高并发与健壮性诉求。 | 适合替换/补强当前同步 requests 与自定义缓存。 | 以仓库许可为准(此处建议上线前核验)。 |
|
||||
| **Polymarket/py-clob-client** | Polymarket CLOB 读写 SDK | Python SDK | *[2026-05 起不再使用]* 官方 SDK,支持 read-only 与交易接口。 | 曾用作 PolyWeather 市场层参考。 | MIT。 |
|
||||
| **aiopolymarket** | Polymarket APIs 的 async 客户端 | Python async 客户端 | *[2026-05 起不再使用]* 类型安全(Pydantic)、自动分页、重试与 backoff。 | 曾用作市场层升级候选。 | 以仓库许可为准。 |
|
||||
|
||||
**对标结论**:PolyWeather 与这类“全球神经天气模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。
|
||||
## 优先级改进建议
|
||||
|
||||
下表按截至 `2026-05-11` 的真实状态重排优先级。已完成项不再继续列为“待做”,只保留当前仍需推进的事项。
|
||||
下表按截至 `2026-05-23` 的真实状态重排优先级。已完成项不再继续列为”待做”,只保留当前仍需推进的事项。
|
||||
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 中 | **把最小外部监控继续补深**:从“可告警”提升到“可运营” | 3–7 天 | 不再只知道服务坏没坏,还能看资源趋势、来源 SLA 和支付波动 | 指标过多会带来维护噪音 | 1) 增加节点 CPU/内存/磁盘 → 2) 增加 SQLite/支付体积与事件趋势 → 3) 把 HTTP/来源指标细分到城市/来源维度 → 4) 增加日报或异常摘要 |
|
||||
| 中 | **城市决策卡 AI 解读可观测性与回放测试** | 3–5 天 | 降低第三/第四/第五城市 AI 解读失败,验证缓存与队列是否真正生效 | 外部 AI stream 仍可能超时或输出截断,若无指标很难复盘 | 1) 记录 city-ai stream status/duration/retry/degraded/cache-hit/queue-depth → 2) 增加固定 METAR + detail + all_buckets fixture → 3) 回归断言 bucket 匹配、模型-市场差、温度单位与缓存 key → 4) 将生产 env 建议同步进部署文档 |
|
||||
| 中 | **市场层升级为 async + 类型安全**:引入 `aiopolymarket` 或在现有层加重试/backoff/连接池 | 4–7 天 | 行情层更稳,减少短时网络抖动;更易扩展更多市场/分页 | 依赖升级带来的行为差异 | 1) 把 requests.Session 替换为 aiohttp/httpx → 2) 在 Gamma/CLOB 调用侧实现指数退避 → 3) 引入 typed models,减少解析失败 |
|
||||
| - | ~~市场层升级为 async + 类型安全~~ | N/A | *[v1.7.0 已移除]* 市场价格拉取层已删除,此改进项不再适用 | - | - |
|
||||
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 1–2 周 | 在已完成的链下审计与容灾之上,进一步收紧链上授权边界 | 合约升级需要重新部署、迁移配置并再次验证 | 1) 维持现有事件重放、SQLite 审计、多 RPC fallback → 2) 升级合约到 SafeERC20 + Pausable → 3) 评估链上 plan/amount/token 绑定或 EIP-712 签名校验 → 4) 迁移后更新 PolygonScan 验证与支付审计文档 |
|
||||
| 中 | **将 CI 与分支保护/发布流程真正绑定** | 1–3 天 | 让现有 CI 从“存在”变成“强制门禁” | 历史分支/热修流程可能受影响 | 1) GitHub `main` 开启 required checks → 2) 把 release/tag 流程绑定 CI → 3) 明确热修例外流程 |
|
||||
| 低 | **引入外部神经天气模型作为附加信号**(GraphCast/FourCastNet/Pangu-Weather 等) | 2–6 周(取决于范围) | 可能提升极端/中期预测能力与差异化 | **商业许可限制**(多为 CC BY-NC-SA/禁止商业)与算力成本 | 1) 先做合规评审(权重许可/数据条款)→ 2) 仅在研究/非商业环境评估 → 3) 若要商用,优先选择可商用权重或自研/购买授权 |
|
||||
@@ -194,7 +194,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
### 文档、测试与贡献流程的具体补强建议(落到仓库层面)
|
||||
|
||||
1)**文档体系**:保留现有中文 API/TechDebt 文档的同时,增加三份“高价值”文档:
|
||||
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级,并明确城市 AI 推荐配置(`POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30`、`POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900`、`POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`);(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、市场扫描 miss、城市 AI stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。
|
||||
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级,并明确城市 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` 单测基础上,补齐:
|
||||
(a)天气 provider 的“录制回放”测试(VCR 思路:固定响应→确保解析稳定);(b)市场层的契约测试(Gamma/CLOB schema 变更时提前失败);(c)城市决策卡 fixture 测试(固定 `detail/market_scan/all_buckets/METAR` → 断言 bucket mapping、模型-市场差、温度单位、AI 缓存 key 与排队提示);(d)支付链路的本地链集成测试(Hardhat/Anvil + 事件扫描回放)。这些测试能把“外部依赖漂移”尽量转成可控的回归失败。
|
||||
3)**贡献工作流**:引入 `CONTRIBUTING.md`(分支策略、PR 模板、变更日志、版本号策略)、`CODEOWNERS`(核心模块审查人)、`SECURITY.md`(漏洞披露与密钥处理),并把静态检查(ruff/eslint)作为 pre-commit + CI 必过项。
|
||||
@@ -219,21 +219,23 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
||||
|
||||
- 若历史样本足够,DEB 应在“系统性偏差明显”的城市提升 MAE;
|
||||
**算力**:以上评测全部可在 CPU 上完成;数据量按“51 城市 × 180 天”级别,pandas/duckdb 即可。若引入更复杂拟合(如分层贝叶斯/分位数回归),也通常不需要 GPU。
|
||||
### 错价信号与市场有效性基准
|
||||
### 错价信号与市场有效性基准 *[v1.7.0 已暂停]*
|
||||
|
||||
> **2026-05-23 更新**:Polymarket 价格拉取层与市场扫描(`market_scan`)已于 v1.7.0 移除。本节基准评测方案暂不适用,留待未来若重新引入市场数据层时参考。
|
||||
|
||||
**数据集**
|
||||
|
||||
- 保存每次扫描输出:`date/city/bucket/bucket_label/bucket_direction/model_probability/market_implied/model_market_diff/yes_buy/quote_source/liquidity/matching_reason`,并加上未来 `settled_bucket` 作为标签;Polymarket 市场发现与报价来自 Gamma/CLOB(官方文档说明三套 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;
|
||||
- Edge 稳健性:不同流动性分位的 edge 分布;
|
||||
- 交易模拟(如需):在考虑滑点/手续费/成交概率下的期望收益(即使项目当前只读,也可以离线评估“若执行”会怎样)。
|
||||
- 交易模拟(如需):在考虑滑点/手续费/成交概率下的期望收益。
|
||||
**基线**
|
||||
|
||||
- 简单策略:仅用市场中间价(不做模型)作为概率;
|
||||
- 当前策略:模型概率 vs 市场概率 edge 阈值;
|
||||
- 改进策略:引入“流动性/盘口深度/波动”作为信号置信度(aiopolymarket/py-clob-client 提供更完整的盘口读取能力)。
|
||||
- 改进策略:引入”流动性/盘口深度/波动”作为信号置信度。
|
||||
**算力**:CPU 即可;关键在于数据采样与回放。
|
||||
## 路线图与风险缓解
|
||||
|
||||
@@ -254,6 +256,8 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
||||
**代码公开与生产私有资产边界导致的“公开仓库与生产行为不一致”**:README 明确品牌、商标、生产私有数据与运营阈值不在代码许可证授权范围内。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
|
||||
## 参考链接
|
||||
|
||||
> **v1.7.0 注**:以下 Polymarket 相关链接已不再被项目使用,保留作为历史参考。
|
||||
|
||||
- PolyWeather 仓库(本次评估对象):https://github.com/yangyuan-zhen/PolyWeather
|
||||
- Polymarket API 文档(Gamma/Data/CLOB):https://docs.polymarket.com/api-reference
|
||||
- AviationWeather Data API(METAR 等):https://aviationweather.gov/data/api/
|
||||
|
||||
@@ -15,6 +15,11 @@ NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
|
||||
|
||||
# 常用:前端鉴权开关
|
||||
# true: 启用 Supabase 登录
|
||||
# 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
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
||||
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=WeatherQuant_bot
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
|
||||
|
||||
+7
-2
@@ -15,6 +15,11 @@ NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
|
||||
|
||||
# 常用:前端鉴权开关
|
||||
# true: 启用 Supabase 登录
|
||||
# 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
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
||||
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=WeatherQuant_bot
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
|
||||
|
||||
+4
-5
@@ -40,8 +40,8 @@ PolyWeather Pro 的生产前端工程。
|
||||
- 城市决策卡的 AI 机场报文解读包括最终判断、METAR 解读、推理说明、模型集群备注、风险提示和原始 METAR
|
||||
- AI 机场报文解读按 `city + local_date + locale + METAR signature` 做页面内存缓存和 `localStorage` 最终结果缓存;切换选项卡返回时会优先恢复已有内容
|
||||
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
|
||||
- 概率区展示当前生产概率引擎输出;EMOS / LGBM 只在评估通过或 shadow 对照时进入解释层,模型共识和市场价格只作为辅助说明
|
||||
- `/ops` 现已展示 prewarm worker 运行态、缓存桶状态与 summary cache hit/miss
|
||||
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
|
||||
- 缓存桶状态与 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_BOT_URL=https://t.me/WeatherQuant_bot
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
|
||||
# 推荐默认关闭的前端观测 / 预热开关
|
||||
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
|
||||
@@ -150,7 +150,6 @@ Ops:
|
||||
|
||||
- 系统状态
|
||||
- SQLite / rollout / 支付运行态
|
||||
- prewarm worker 运行态
|
||||
- 缓存桶状态与 summary cache hit/miss
|
||||
- 用户查询
|
||||
- 当前会员
|
||||
@@ -201,4 +200,4 @@ Ops:
|
||||
|
||||
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
|
||||
|
||||
最后更新:`2026-04-19`
|
||||
最后更新:`2026-05-23`
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -24,6 +25,8 @@ export async function POST(
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -24,6 +25,8 @@ export async function POST(
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -24,6 +25,8 @@ export async function POST(
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -35,6 +36,8 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/intents`, {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
@@ -17,6 +18,8 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -20,6 +21,8 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -44,6 +45,8 @@ export async function DELETE(req: NextRequest) {
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -20,6 +21,8 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
|
||||
import { getConfiguredSiteUrl } from "@/lib/site-url";
|
||||
|
||||
function normalizeNextPath(input: string | null) {
|
||||
const fallback = "/";
|
||||
@@ -11,6 +12,16 @@ function normalizeNextPath(input: string | null) {
|
||||
}
|
||||
|
||||
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 redirectUrl = request.nextUrl.clone();
|
||||
redirectUrl.pathname = nextPath;
|
||||
@@ -29,4 +40,3 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
+61
-110
@@ -9,28 +9,28 @@
|
||||
@layer base {
|
||||
:root {
|
||||
/* ── Background Scale ── */
|
||||
--color-bg-base: #0B1220;
|
||||
--color-bg-raised: #111A2E;
|
||||
--color-bg-overlay: #16213A;
|
||||
--color-bg-card: rgba(17, 26, 46, 0.88);
|
||||
--color-bg-input: rgba(22, 33, 58, 0.72);
|
||||
--color-bg-base: #f4f7fb;
|
||||
--color-bg-raised: #ffffff;
|
||||
--color-bg-overlay: #ffffff;
|
||||
--color-bg-card: rgba(255, 255, 255, 0.95);
|
||||
--color-bg-input: rgba(241, 245, 249, 0.88);
|
||||
|
||||
/* ── Text Scale ── */
|
||||
--color-text-primary: #E6EDF3;
|
||||
--color-text-secondary: #9FB2C7;
|
||||
--color-text-muted: #7D8FA3;
|
||||
--color-text-disabled: #7D8FA3;
|
||||
--color-text-primary: #0F172A;
|
||||
--color-text-secondary: #334155;
|
||||
--color-text-muted: #475569;
|
||||
--color-text-disabled: #94A3B8;
|
||||
|
||||
/* ── Accent Colors ── */
|
||||
--color-accent-primary: #4DA3FF;
|
||||
--color-accent-secondary: #6FB7FF;
|
||||
--color-accent-tertiary: #93C5FD;
|
||||
--color-accent-primary: #2563EB;
|
||||
--color-accent-secondary: #3B82F6;
|
||||
--color-accent-tertiary: #60A5FA;
|
||||
|
||||
/* ── Signal / Semantic Colors ── */
|
||||
--color-signal-success: #22C55E;
|
||||
--color-signal-warning: #F59E0B;
|
||||
--color-signal-danger: #EF4444;
|
||||
--color-signal-info: #4DA3FF;
|
||||
--color-signal-success: #00897b;
|
||||
--color-signal-warning: #d97706;
|
||||
--color-signal-danger: #dc2626;
|
||||
--color-signal-info: #2563eb;
|
||||
|
||||
/* ── Risk Colors (aliased from signal) ── */
|
||||
--color-risk-high: var(--color-signal-danger);
|
||||
@@ -38,16 +38,16 @@
|
||||
--color-risk-low: var(--color-signal-success);
|
||||
|
||||
/* ── Border ── */
|
||||
--color-border-default: rgba(159, 178, 199, 0.16);
|
||||
--color-border-hover: rgba(77, 163, 255, 0.38);
|
||||
--color-border-subtle: rgba(159, 178, 199, 0.08);
|
||||
--color-border-default: #d8e0ec;
|
||||
--color-border-hover: #b8c4d6;
|
||||
--color-border-subtle: #e8edf5;
|
||||
|
||||
/* ── Shadow / Elevation ── */
|
||||
--shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-elevation-2: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
--shadow-elevation-3: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
--shadow-glow-accent: 0 0 20px rgba(77, 163, 255, 0.24);
|
||||
--shadow-glow-secondary: 0 0 20px rgba(111, 183, 255, 0.22);
|
||||
--shadow-elevation-1: 0 1px 3px rgba(15, 23, 42, 0.05);
|
||||
--shadow-elevation-2: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
--shadow-elevation-3: 0 20px 60px rgba(15, 23, 42, 0.08);
|
||||
--shadow-glow-accent: 0 0 20px rgba(37, 99, 235, 0.08);
|
||||
--shadow-glow-secondary: 0 0 20px rgba(96, 165, 250, 0.08);
|
||||
|
||||
/* ── Typography ── */
|
||||
--font-data: var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
@@ -66,19 +66,19 @@
|
||||
--space-12: 48px;
|
||||
|
||||
/* ── Border Radius ── */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
--radius-xl: 14px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ── Glass / Blur ── */
|
||||
--glass-blur-1: blur(10px);
|
||||
--glass-blur-2: blur(16px);
|
||||
--glass-blur-3: blur(24px);
|
||||
--glass-opacity-1: 0.72;
|
||||
--glass-opacity-2: 0.85;
|
||||
--glass-opacity-3: 0.92;
|
||||
--glass-opacity-1: 0.86;
|
||||
--glass-opacity-2: 0.92;
|
||||
--glass-opacity-3: 0.96;
|
||||
|
||||
/* ── Layout ── */
|
||||
--header-height: 52px;
|
||||
@@ -91,8 +91,7 @@
|
||||
--transition-slow: 400ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--transition: var(--transition-base);
|
||||
|
||||
/* ── Legacy Variable Aliases (bridge for older CSS modules) ── */
|
||||
/* These map old shorthand names → current design token names. */
|
||||
/* ── Legacy Variable Aliases ── */
|
||||
--accent-cyan: var(--color-accent-primary);
|
||||
--accent-blue: var(--color-accent-secondary);
|
||||
--accent-green: var(--color-signal-success);
|
||||
@@ -111,68 +110,36 @@
|
||||
--shadow-lg: var(--shadow-elevation-2);
|
||||
--glass-blur: 10px;
|
||||
|
||||
/* ── shadcn/ui Tokens (used by Tailwind @apply border-border) ── */
|
||||
--background: 223 53% 4%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 223 46% 8%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--primary: 159 100% 44%;
|
||||
--primary-foreground: 222 47% 8%;
|
||||
--secondary: 224 30% 14%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--accent: 217 30% 18%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--border: 221 38% 22%;
|
||||
/* ── shadcn/ui Tokens ── */
|
||||
--background: 210 40% 98%;
|
||||
--foreground: 222 47% 12%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 12%;
|
||||
--primary: 221 83% 53%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222 47% 12%;
|
||||
--accent: 210 40% 96%;
|
||||
--accent-foreground: 222 47% 12%;
|
||||
--border: 214 32% 91%;
|
||||
}
|
||||
|
||||
/* ── Light Theme Token Overrides ── */
|
||||
html.light,
|
||||
html[data-theme="light"] {
|
||||
--color-bg-base: #F7F9FC;
|
||||
--color-bg-raised: #EEF2F7;
|
||||
--color-bg-overlay: #FFFFFF;
|
||||
--color-bg-card: rgba(255, 255, 255, 0.92);
|
||||
--color-bg-input: rgba(238, 242, 247, 0.88);
|
||||
|
||||
--color-text-primary: #0F172A;
|
||||
--color-text-secondary: #334155;
|
||||
--color-text-muted: #475569;
|
||||
--color-text-disabled: #94A3B8;
|
||||
|
||||
--color-accent-primary: #2563EB;
|
||||
--color-accent-secondary: #3B82F6;
|
||||
--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);
|
||||
/* ── Monospaced numbers & data globally for professional feel ── */
|
||||
.font-mono,
|
||||
.nearby-temp,
|
||||
.nearby-wind,
|
||||
.nearby-time,
|
||||
.nearby-marker,
|
||||
.marker-bubble,
|
||||
.map-pill,
|
||||
[class*="temp"],
|
||||
[class*="value"],
|
||||
[class*="price"],
|
||||
[class*="number"],
|
||||
[class*="stat"],
|
||||
[class*="score"],
|
||||
[class*="time-"] {
|
||||
font-family: var(--font-mono) !important;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -207,23 +174,7 @@
|
||||
|
||||
body {
|
||||
font-family: var(--font-data);
|
||||
background:
|
||||
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);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
className={`${inter.variable} ${jetbrainsMono.variable} dark`}
|
||||
className={`${inter.variable} ${jetbrainsMono.variable} light`}
|
||||
>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
+22
-1
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { DashboardEntry } from "@/components/dashboard/DashboardEntry";
|
||||
|
||||
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.",
|
||||
};
|
||||
|
||||
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 />;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
const TELEGRAM_GROUP_URL = String(
|
||||
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
|
||||
"https://t.me/+nMG7SjziUKYyZmM1",
|
||||
"https://t.me/+Io5H9oVHFmVjOTQ5",
|
||||
).trim();
|
||||
|
||||
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 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 appAnalyticsSource = fs.readFileSync(
|
||||
path.join(projectRoot, "lib", "app-analytics.ts"),
|
||||
@@ -64,18 +105,18 @@ export function runTests() {
|
||||
);
|
||||
assert(
|
||||
!/label\.toLowerCase\(\)\.includes\(["']binance["']\)\)\s*return/.test(
|
||||
accountCenterSource,
|
||||
accountFeatureSource,
|
||||
),
|
||||
"Binance Web3 Wallet injected provider must remain available for browser-extension binding",
|
||||
);
|
||||
assert(
|
||||
accountCenterSource.includes("Binance 扩展已绑定") &&
|
||||
accountCenterSource.includes("如支付卡住,请优先使用 WalletConnect 扫码支付"),
|
||||
accountFeatureSource.includes("Binance 扩展已绑定") &&
|
||||
accountFeatureSource.includes("如支付卡住,请优先使用 WalletConnect 扫码支付"),
|
||||
"Binance extension binding must show a WalletConnect fallback hint for payment stability",
|
||||
);
|
||||
assert(
|
||||
accountCenterSource.includes("钱包里需要少量 POL 作为 gas 手续费") &&
|
||||
accountCenterSource.includes("只有 USDC 可能无法完成授权或支付"),
|
||||
accountFeatureSource.includes("钱包里需要少量 POL 作为 gas 手续费") &&
|
||||
accountFeatureSource.includes("只有 USDC 可能无法完成授权或支付"),
|
||||
"payment wallet tab must warn users that Polygon POL gas is required in addition to USDC",
|
||||
);
|
||||
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,
|
||||
hasSupabasePublicEnv,
|
||||
} from "@/lib/supabase/client";
|
||||
import { getConfiguredSiteUrl, PRODUCTION_SITE_URL } from "@/lib/site-url";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
type Mode = "login" | "signup";
|
||||
@@ -37,6 +38,9 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
||||
const [resetSent, setResetSent] = useState(false);
|
||||
|
||||
const supabaseReady = hasSupabasePublicEnv();
|
||||
const siteOrigin =
|
||||
getConfiguredSiteUrl() ||
|
||||
(typeof window !== "undefined" ? window.location.origin : PRODUCTION_SITE_URL);
|
||||
const isEn = locale === "en-US";
|
||||
const copy = {
|
||||
backHome: isEn ? "Back to Home" : "返回首页",
|
||||
@@ -99,7 +103,11 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
||||
setLoading(true);
|
||||
try {
|
||||
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) {
|
||||
setErrorText(error.message);
|
||||
return;
|
||||
@@ -136,7 +144,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient();
|
||||
const redirectTo = `${window.location.origin}/auth/callback?next=${encodeURIComponent(
|
||||
const redirectTo = `${siteOrigin}/auth/callback?next=${encodeURIComponent(
|
||||
nextPath,
|
||||
)}`;
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
@@ -182,7 +190,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emailRedirectTo = `${window.location.origin}/auth/callback?next=${encodeURIComponent(
|
||||
const emailRedirectTo = `${siteOrigin}/auth/callback?next=${encodeURIComponent(
|
||||
nextPath,
|
||||
)}`;
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
RotateCw,
|
||||
BookOpen,
|
||||
MoreHorizontal,
|
||||
Moon,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDashboardStore, useProAccess } from "@/hooks/useDashboardStore";
|
||||
@@ -42,7 +40,6 @@ export function HeaderBar({
|
||||
const { proAccess } = useProAccess();
|
||||
const { locale, t, toggleLocale } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||
const isAuthenticated = proAccess.authenticated;
|
||||
const docsHref = "/docs/intro";
|
||||
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)} 天`;
|
||||
|
||||
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 (
|
||||
<header className="header">
|
||||
@@ -165,19 +144,6 @@ export function HeaderBar({
|
||||
<RotateCw size={16} strokeWidth={2} />
|
||||
</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
|
||||
href={docsHref}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { trackAppEvent } from "@/lib/app-analytics";
|
||||
|
||||
const TELEGRAM_GROUP_URL = String(
|
||||
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
|
||||
"https://t.me/+nMG7SjziUKYyZmM1",
|
||||
"https://t.me/+Io5H9oVHFmVjOTQ5",
|
||||
).trim();
|
||||
const SUBSCRIPTION_HELP_HREF = "/subscription-help";
|
||||
|
||||
|
||||
@@ -122,19 +122,7 @@ export function ScanTerminalTopBar({
|
||||
{isEn ? "Sign in" : "登录"}
|
||||
</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 ? (
|
||||
<Link
|
||||
href={accountHref}
|
||||
|
||||
@@ -20,6 +20,16 @@ const aiCityForecastStateCache = new Map<
|
||||
string,
|
||||
{ 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) {
|
||||
const source = String(
|
||||
@@ -100,6 +110,7 @@ export function writeCachedAiForecastState(
|
||||
state,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
trimAiForecastCache();
|
||||
}
|
||||
|
||||
export function readReadyCachedAiForecastState(cacheKey: string, refreshToken: number) {
|
||||
|
||||
@@ -20,30 +20,13 @@ export function useUserLocalClock() {
|
||||
}
|
||||
|
||||
export function useScanTerminalTheme() {
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
||||
const [themeMode] = useState<ThemeMode>("light");
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const hadLight = root.classList.contains("light");
|
||||
const hadDark = root.classList.contains("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");
|
||||
}
|
||||
root.classList.add("light");
|
||||
root.classList.remove("dark");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem("polyweather_scan_theme", themeMode);
|
||||
}, [themeMode]);
|
||||
|
||||
return { setThemeMode, themeMode };
|
||||
return { setThemeMode: () => {}, themeMode };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RefreshCcw } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -117,7 +118,7 @@ export function AnalyticsPageClient() {
|
||||
<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} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0" }}
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value) => [`${value} 人`, "数量"]}
|
||||
/>
|
||||
<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 { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Cell,
|
||||
@@ -109,7 +110,7 @@ export function HealthPageClient() {
|
||||
<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} />
|
||||
<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`, "延迟"]}
|
||||
/>
|
||||
<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 { opsApi } from "@/lib/ops-api";
|
||||
import type { MembershipEntry } from "@/types/ops";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Legend, Area, AreaChart,
|
||||
@@ -170,7 +171,7 @@ export function MembershipsPageClient() {
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<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="累计" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -184,7 +185,7 @@ export function MembershipsPageClient() {
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<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 }} />
|
||||
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e60" 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 Link from "next/link";
|
||||
import type { SystemStatusPayload, MembershipsPayload, MembershipEntry } from "@/types/ops";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import {
|
||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||
@@ -164,7 +165,7 @@ export function OverviewPageClient() {
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<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="累计" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -178,7 +179,7 @@ export function OverviewPageClient() {
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<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 }} />
|
||||
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e40" 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)" />
|
||||
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
|
||||
<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" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -228,7 +229,7 @@ export function OverviewPageClient() {
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
|
||||
<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" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -271,7 +272,7 @@ export function OverviewPageClient() {
|
||||
<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} />))}
|
||||
</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>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -317,7 +318,7 @@ export function OverviewPageClient() {
|
||||
<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} />))}
|
||||
</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>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { PaymentRuntimePayload, PaymentIncident, PaymentRecord } from "@/types/ops";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import {
|
||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||
} from "recharts";
|
||||
@@ -119,7 +120,7 @@ export function PaymentsPageClient() {
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</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>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { SystemStatusPayload } from "@/types/ops";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
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} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||
<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)}%`, "命中率"]}
|
||||
/>
|
||||
<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} />
|
||||
<YAxis tick={{ fill: "#94a3b8", fontSize: 11 }} unit="°" />
|
||||
<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"]}
|
||||
/>
|
||||
<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} />
|
||||
<YAxis domain={[0, 0.5]} tick={{ fill: "#94a3b8", fontSize: 11 }} />
|
||||
<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"]}
|
||||
/>
|
||||
<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} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||
<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)}%`, "命中率"]}
|
||||
/>
|
||||
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import s from "./UnlockProOverlay.module.css";
|
||||
|
||||
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 = {
|
||||
pointsEnabled: boolean;
|
||||
|
||||
@@ -659,6 +659,42 @@ export function DashboardStoreProvider({
|
||||
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
|
||||
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 [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
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 cityName = (row.city || row.city_display_name || row.display_name || "").trim();
|
||||
if (!cityName || findCachedCityDetail(cityDetailsByName, cityName)) return;
|
||||
touchCityLru(cityName);
|
||||
// Pre-populate cache from scan terminal row so detail panel shows data immediately
|
||||
const now = Date.now();
|
||||
const currentTemp = Number(row.current_temp ?? row.current_max_so_far);
|
||||
@@ -837,6 +874,7 @@ export function DashboardStoreProvider({
|
||||
force = false,
|
||||
depth: CityDetailDepth = "panel",
|
||||
) => {
|
||||
touchCityLru(cityName);
|
||||
const cached = findCachedCityDetail(cityDetailsByName, cityName);
|
||||
const cachedMeta = cityDetailMetaByName[cityName];
|
||||
const marketTargetDate =
|
||||
@@ -1146,6 +1184,7 @@ export function DashboardStoreProvider({
|
||||
}, []);
|
||||
|
||||
const ensureCitySummary = async (cityName: string, force = false) => {
|
||||
touchCityLru(cityName);
|
||||
const existing = citySummariesRef.current[cityName];
|
||||
if (!force && existing) {
|
||||
return existing;
|
||||
@@ -1346,7 +1385,9 @@ export function DashboardStoreProvider({
|
||||
const refreshAll = async () => {
|
||||
dashboardClient.clearCityDetailCache();
|
||||
setCityDetailsByName({});
|
||||
setCitySummariesByName({});
|
||||
setCityDetailMetaByName({});
|
||||
cityLruRef.current = [];
|
||||
if (!citiesRef.current.length) {
|
||||
await loadCities();
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ const MAP_TILE_URLS = {
|
||||
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",
|
||||
} as const;
|
||||
const TILE_RETRY_LIMIT = 2;
|
||||
const TILE_RETRY_DELAY_BASE_MS = 500;
|
||||
const CITY_MARKER_DISPLAY_OFFSETS: Record<
|
||||
string,
|
||||
{ x: number; y: number; zIndexOffset?: number }
|
||||
@@ -528,6 +530,21 @@ export function useLeafletMap({
|
||||
subdomains: "abcd",
|
||||
}).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);
|
||||
mapRef.current = map;
|
||||
tileLayerRef.current = tileLayer;
|
||||
@@ -601,8 +618,13 @@ export function useLeafletMap({
|
||||
const tileLayer = tileLayerRef.current;
|
||||
if (!tileLayer || typeof MutationObserver === "undefined") return;
|
||||
|
||||
let lastTileUrl = "";
|
||||
|
||||
const syncMapTheme = () => {
|
||||
tileLayer.setUrl(getMapTileUrl(containerRef.current));
|
||||
const nextUrl = getMapTileUrl(containerRef.current);
|
||||
if (nextUrl === lastTileUrl) return;
|
||||
lastTileUrl = nextUrl;
|
||||
tileLayer.setUrl(nextUrl);
|
||||
};
|
||||
syncMapTheme();
|
||||
|
||||
@@ -611,11 +633,12 @@ export function useLeafletMap({
|
||||
attributeFilter: ["class"],
|
||||
attributes: true,
|
||||
});
|
||||
if (document.body) {
|
||||
observer.observe(document.body, {
|
||||
|
||||
const scanTerminal = document.querySelector(".scan-terminal");
|
||||
if (scanTerminal) {
|
||||
observer.observe(scanTerminal, {
|
||||
attributeFilter: ["class"],
|
||||
attributes: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -94,4 +94,13 @@ export function applyAuthResponseCookies(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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)) {
|
||||
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({
|
||||
request: {
|
||||
|
||||
Generated
+2
-24
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "polyweather-frontend",
|
||||
"version": "1.5.5",
|
||||
"version": "1.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "polyweather-frontend",
|
||||
"version": "1.5.5",
|
||||
"version": "1.7.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@supabase/ssr": "^0.5.2",
|
||||
@@ -20,7 +20,6 @@
|
||||
"next": "^15.3.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
"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": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
@@ -3302,18 +3292,6 @@
|
||||
"license": "MIT",
|
||||
"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": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.3.0.tgz",
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"next": "^15.3.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
"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()
|
||||
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
|
||||
CHAT_ID = -1003965137823
|
||||
CHAT_ID = -1003927451869
|
||||
|
||||
CITIES: list[tuple[str, str]] = [
|
||||
("🇨🇳 Beijing", "beijing"),
|
||||
|
||||
@@ -25,7 +25,7 @@ def _load_env() -> None:
|
||||
|
||||
_load_env()
|
||||
TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
|
||||
CHAT_ID = -1003965137823
|
||||
CHAT_ID = -1003927451869
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
|
||||
@@ -27,7 +27,7 @@ def _load_env() -> None:
|
||||
|
||||
_load_env()
|
||||
TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
|
||||
CHAT_ID = -1003965137823
|
||||
CHAT_ID = -1003927451869
|
||||
|
||||
|
||||
def _try_get_topic(tid: int) -> dict | None:
|
||||
|
||||
@@ -98,6 +98,4 @@ class CommandGuard:
|
||||
return True
|
||||
|
||||
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)
|
||||
|
||||
+2
-3
@@ -203,10 +203,9 @@ class BotIOLayer:
|
||||
"/diag - 查看 Bot 启动诊断\n\n"
|
||||
"/bind - 绑定 Supabase 账号(可选)\n"
|
||||
"/unbind - 解除当前 Telegram 与网页账号绑定\n\n"
|
||||
"🔗 机器人: <a href=\"https://t.me/WeatherQuant_bot\">@WeatherQuant_bot</a>\n"
|
||||
"👥 社群: <a href=\"https://t.me/+nMG7SjziUKYyZmM1\">加入 Telegram 群组</a>\n\n"
|
||||
"🔗 机器人: <a href=\"https://t.me/polyyuanbot\">@polyyuanbot</a>\n"
|
||||
"👥 社群: <a href=\"https://t.me/+Io5H9oVHFmVjOTQ5\">加入 Telegram 群组</a>\n\n"
|
||||
"📌 <i>私有频道用于接收自动推送;手动查看市场概览请私聊机器人发送 <code>/markets</code>。</i>\n\n"
|
||||
"🔐 <i>/city 与 /deb 仅限官方群成员使用。</i>\n\n"
|
||||
"示例: <code>/city 伦敦</code> 或 <code>/pwcity 伦敦</code>\n"
|
||||
f"💡 <i>提示: 群内有效发言(满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
|
||||
|
||||
@@ -81,8 +81,8 @@ def start_bot() -> None:
|
||||
startup_coordinator = StartupCoordinator(
|
||||
bot=bot,
|
||||
config=config,
|
||||
command_access_mode="group_member_only",
|
||||
protected_commands=["/city", "/deb"],
|
||||
command_access_mode="public",
|
||||
protected_commands=[],
|
||||
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)
|
||||
|
||||
logger.info(
|
||||
"🤖 Bot 启动中... access=group-member-only protected_commands=/city,/deb loops_started={}/{}",
|
||||
"🤖 Bot 启动中... access=public protected_commands=none loops_started={}/{}",
|
||||
started_count,
|
||||
len(runtime_status.loops),
|
||||
)
|
||||
|
||||
@@ -85,7 +85,6 @@ class StartupCoordinator:
|
||||
loops = [
|
||||
self._start_airport_high_freq_loop(),
|
||||
self._start_polygon_wallet_loop(),
|
||||
self._start_polymarket_wallet_activity_loop(),
|
||||
self._start_weekly_reward_loop(),
|
||||
self._start_payment_event_loop(),
|
||||
self._start_payment_confirm_loop(),
|
||||
@@ -219,18 +218,6 @@ class StartupCoordinator:
|
||||
).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:
|
||||
enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", True)
|
||||
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",
|
||||
]
|
||||
|
||||
FORUM_CHAT_ID = "-1003965137823"
|
||||
FORUM_CHAT_ID = "-1003927451869"
|
||||
|
||||
CITY_NAME_ZH: Dict[str, str] = {
|
||||
"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__)))),
|
||||
"data", "city_thread_ids.json",
|
||||
)
|
||||
_FORUM_CHAT_ID = "-1003965137823"
|
||||
_FORUM_CHAT_ID = "-1003927451869"
|
||||
_city_thread_ids: dict = {}
|
||||
|
||||
|
||||
@@ -917,12 +917,12 @@ def _build_airport_status_message(
|
||||
time_suffix = f" · {local_time}" if local_time else ""
|
||||
|
||||
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_temps = runway_data.get("temperatures") or []
|
||||
point_temps = runway_data.get("point_temperatures") or []
|
||||
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, "")
|
||||
settlement_pair = _settlement_runway_for_city(city)
|
||||
|
||||
@@ -953,7 +953,10 @@ def _build_airport_status_message(
|
||||
station_temp = row.get("temp")
|
||||
break
|
||||
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:
|
||||
station_temp = (city_weather.get("current") or {}).get("temp")
|
||||
display_temp = station_temp
|
||||
@@ -1210,7 +1213,20 @@ def _process_airport_city(
|
||||
airport_row = row
|
||||
break
|
||||
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
|
||||
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():
|
||||
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():
|
||||
def test_guard_charges_points():
|
||||
fake_bot = SimpleNamespace(
|
||||
reply_to=Mock(),
|
||||
get_chat_member=Mock(return_value=SimpleNamespace(status="member")),
|
||||
|
||||
@@ -7,7 +7,6 @@ class DummyBot:
|
||||
|
||||
def test_startup_coordinator_respects_disable_flags(monkeypatch):
|
||||
monkeypatch.setenv("POLYGON_WALLET_WATCH_ENABLED", "false")
|
||||
monkeypatch.setenv("POLYMARKET_WALLET_ACTIVITY_ENABLED", "false")
|
||||
monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False)
|
||||
|
||||
coordinator = StartupCoordinator(
|
||||
@@ -21,7 +20,6 @@ def test_startup_coordinator_respects_disable_flags(monkeypatch):
|
||||
loop_map = runtime.loop_map()
|
||||
|
||||
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():
|
||||
|
||||
@@ -112,7 +112,10 @@ def test_high_freq_airport_push_forces_analysis_refresh(monkeypatch):
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
+71
-59
@@ -14,25 +14,23 @@ from web.services.city_api import (
|
||||
|
||||
router = APIRouter(tags=["city"])
|
||||
|
||||
_MODEL_RANGE_CITIES: List[str] = [
|
||||
"beijing",
|
||||
"shanghai",
|
||||
"guangzhou",
|
||||
"chengdu",
|
||||
"chongqing",
|
||||
"qingdao",
|
||||
"wuhan",
|
||||
]
|
||||
def _all_city_keys() -> List[str]:
|
||||
from src.data_collection.city_registry import CITY_REGISTRY
|
||||
|
||||
_MODEL_RANGE_NAMES: Dict[str, str] = {
|
||||
"beijing": "北京 (ZBAA)",
|
||||
"shanghai": "上海 (ZSPD)",
|
||||
"guangzhou": "广州 (ZGGG)",
|
||||
"chengdu": "成都 (ZUUU)",
|
||||
"chongqing": "重庆 (ZUCK)",
|
||||
"qingdao": "青岛 (ZSQD)",
|
||||
"wuhan": "武汉 (ZHHH)",
|
||||
}
|
||||
return sorted(CITY_REGISTRY.keys())
|
||||
|
||||
|
||||
def _city_display_name(city: str) -> str:
|
||||
from src.data_collection.city_registry import CITY_REGISTRY
|
||||
|
||||
meta = CITY_REGISTRY.get(city) or {}
|
||||
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")
|
||||
@@ -40,55 +38,69 @@ async def list_cities(request: 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")
|
||||
async def cities_model_range(
|
||||
request: Request,
|
||||
force_refresh: bool = Query(False),
|
||||
):
|
||||
"""Return DEB prediction and model range for monitored Chinese cities."""
|
||||
from web.app import _analyze
|
||||
|
||||
"""Return DEB prediction and model range for all monitored cities."""
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for city in _MODEL_RANGE_CITIES:
|
||||
try:
|
||||
result = _analyze(city, force_refresh=force_refresh)
|
||||
except Exception:
|
||||
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,
|
||||
}
|
||||
)
|
||||
row = _extract_city_model_range(city, force_refresh)
|
||||
if row is not None:
|
||||
rows.append(row)
|
||||
|
||||
rows.sort(key=lambda r: str(r.get("id") or ""))
|
||||
return {"cities": rows}
|
||||
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ def create_telegram_bot_bind_link(request: Request) -> Dict[str, Any]:
|
||||
bot_username = str(
|
||||
legacy_routes.os.getenv("TELEGRAM_BOT_USERNAME")
|
||||
or legacy_routes.os.getenv("NEXT_PUBLIC_TELEGRAM_BOT_USERNAME")
|
||||
or "WeatherQuant_bot"
|
||||
or "polyyuanbot"
|
||||
).strip().lstrip("@")
|
||||
bot_url = f"https://t.me/{bot_username}?start={start_param}"
|
||||
return {
|
||||
|
||||
@@ -28,7 +28,11 @@ def _raise_payment_error(exc: Exception) -> None:
|
||||
|
||||
def _require_payment_identity(request: Request) -> Dict[str, Any]:
|
||||
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]:
|
||||
|
||||
Reference in New Issue
Block a user