Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 961c9bd37c | |||
| b0fec20370 | |||
| 8f6925108c | |||
| 96dde3b50e | |||
| 505b75d6ce | |||
| 956cb8a8e3 | |||
| 721173aea3 | |||
| ec84243fb9 | |||
| e1054e684a | |||
| 58a2881fc8 | |||
| 4f4617ba0a | |||
| 26775693e7 | |||
| 139e5dbb06 | |||
| bbe3871b7c | |||
| 4389d4a40d | |||
| c3f9b974c3 | |||
| 39026344dc | |||
| 41e819735d | |||
| 12935f502f | |||
| c4b47a81a7 | |||
| 27e4f1efe0 | |||
| f66127da44 | |||
| fb4a144fcf | |||
| 3951735259 | |||
| a40b1ba137 | |||
| 555d3fa68f | |||
| f7625218ae | |||
| a5de604d85 | |||
| 11fd482378 | |||
| 524ae7c8cf | |||
| 98b69b180a | |||
| d354fb12dd | |||
| 2f5e4a40dd | |||
| 7a38e3588a | |||
| c2f9bc5994 | |||
| 89f77b8db4 | |||
| 2766b6463c | |||
| 2a6c826f50 | |||
| 09979f3bb5 | |||
| e6a1d14b09 | |||
| 2af1cf9cd8 | |||
| c3fd0582ad | |||
| 00f96150c5 | |||
| 92307a9f97 | |||
| a440f3d2b5 | |||
| afffacb529 | |||
| 137b0d8b3a | |||
| 876417c5ec | |||
| 35a93cbd9e | |||
| 9d136a337c | |||
| fc0a8b8ff5 | |||
| 8fc9f3ae1a | |||
| f1e577ea8a | |||
| 2fd8027a41 | |||
| 6407d0a29d | |||
| 573353421b | |||
| ac5b512a72 | |||
| 31fb1b3f28 | |||
| 49cb0bb42d | |||
| fb6c354317 | |||
| fee402145e | |||
| d1633331bc | |||
| 6e00160897 | |||
| 6bc53c6e5d | |||
| 7625a99020 | |||
| 3447a043ff | |||
| 10cf557b11 | |||
| eaf17c7a32 | |||
| 14526d6ce8 | |||
| 9a0d7ae444 | |||
| de5083ef18 | |||
| 2424b68532 | |||
| ff2a3da100 | |||
| 7040598e26 | |||
| 22f4ac1f26 | |||
| 3e5ff4ea8f | |||
| 39c7c1903c | |||
| eecaa48ec2 | |||
| d911af1225 | |||
| 38c6cefb27 | |||
| a3df2e00dc | |||
| fde69123cd | |||
| 2d963c16f3 | |||
| 071d749ef0 | |||
| 8d552a9279 | |||
| 9c5a08dc1e | |||
| ada5f274d3 | |||
| 78ea0326a5 | |||
| d3f444dbf6 | |||
| 8d26afdec0 | |||
| 5083b7c433 | |||
| 668f4d9bd3 | |||
| a4253c224a | |||
| 46effcd45f | |||
| 2a6d8748f4 | |||
| ca72072da0 | |||
| bf0fbe1b1d | |||
| 30e9513ce2 | |||
| aec9dd6a54 | |||
| 6222f60573 | |||
| a48a7bcca5 | |||
| 74a2d71644 | |||
| 9c239c0394 | |||
| 383dfe4d0e | |||
| 52f92d8650 | |||
| d9eea721ef | |||
| aa583e7440 | |||
| e00405df8a | |||
| 9bcfd9d1eb | |||
| 3fcda3f3cd | |||
| 345691ad71 | |||
| e864181c3c | |||
| 097971f107 | |||
| 90bc895000 | |||
| c0b20ed1bf | |||
| 5470da6b3a | |||
| a03b8095ee | |||
| 91896b4dad | |||
| 44638760b7 | |||
| c0b1edaed0 | |||
| 853a652e9b | |||
| c79619898a | |||
| 1c250906ed | |||
| ed8c898e0e | |||
| 57240e02ee | |||
| c26f89f134 | |||
| b3a63aa9af | |||
| 53268560ec | |||
| 68416b058e | |||
| 95c2258db0 | |||
| 8507afedd6 | |||
| 3e24080466 | |||
| 17ddd835e6 | |||
| 5228d2b3fd | |||
| 2f039abb2c | |||
| 522e35de7f | |||
| f8f5035225 | |||
| 23b5fafb25 | |||
| 2269aaefd7 | |||
| 9e3a4e2f45 | |||
| 8f52b1d80c | |||
| 1d9f0033a6 | |||
| 618bac8b57 | |||
| f2e90fbcda | |||
| 3cc2251b9b | |||
| c2fe8caddf | |||
| 784b27a954 | |||
| eef611adb4 | |||
| f56c604677 | |||
| 79b82a34cb | |||
| d83a0f0eef | |||
| 12d911f356 | |||
| be549daf50 | |||
| fb4a9d7e09 | |||
| 2e6349cbc6 | |||
| 4c68fe696b | |||
| b239ad131f | |||
| 651a93cacf | |||
| 5d784f78b9 | |||
| 725727d763 | |||
| 1a7d2a487f | |||
| 14141314d7 | |||
| 8609376c59 | |||
| 4d22191a2d | |||
| fb7e27d9ed | |||
| 7ff1d21b95 | |||
| 573768846e |
+23
-4
@@ -9,7 +9,7 @@
|
||||
########################################
|
||||
ENV=production
|
||||
LOG_LEVEL=INFO
|
||||
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
|
||||
POLYWEATHER_MAP_URL=https://polyweather.top/
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json
|
||||
@@ -19,9 +19,16 @@ UVICORN_WORKERS=1
|
||||
UID=1000
|
||||
GID=1000
|
||||
POLYWEATHER_STATE_STORAGE_MODE=sqlite
|
||||
# Realtime chart event store. Production should use Redis Stream; local/single-process
|
||||
# development can set POLYWEATHER_EVENT_STORE=sqlite.
|
||||
POLYWEATHER_EVENT_STORE=redis
|
||||
POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
|
||||
POLYWEATHER_REDIS_STREAM_KEY=stream:city_observation
|
||||
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
|
||||
POLYWEATHER_REDIS_REQUIRED=true
|
||||
# Backend CORS allowlist. Add your Vercel production/preview domains when
|
||||
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL points browsers directly at this backend.
|
||||
WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather-pro.vercel.app
|
||||
WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather.top,https://www.polyweather.top,https://api.polyweather.top
|
||||
|
||||
########################################
|
||||
# 2) Telegram bot minimal
|
||||
@@ -39,7 +46,7 @@ TELEGRAM_QUERY_TOPIC_CHAT_ID=
|
||||
TELEGRAM_QUERY_TOPIC_ID=
|
||||
TELEGRAM_QUERY_TOPIC_MAP=
|
||||
POLYWEATHER_BOT_GROUP_INVITE_URL=
|
||||
POLYWEATHER_APP_URL=https://polyweather-pro.vercel.app
|
||||
POLYWEATHER_APP_URL=https://polyweather.top
|
||||
# Global Telegram auto-push copy: both, en, or zh. Module-specific vars can override it.
|
||||
TELEGRAM_PUSH_LANGUAGE=both
|
||||
# High-frequency airport push loop. Keep workers at 1 on shared VPS.
|
||||
@@ -164,13 +171,24 @@ POLYWEATHER_BOT_DEB_QUERY_COST=1
|
||||
|
||||
# Payments
|
||||
POLYWEATHER_PAYMENT_ENABLED=false
|
||||
# Default / legacy checkout chain. Keep Polygon as the default because the
|
||||
# deployed checkout contract currently lives there.
|
||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com
|
||||
# Optional multi-chain RPC map. Required when accepting non-default chain
|
||||
# transfers such as Ethereum mainnet USDC.
|
||||
# Example:
|
||||
# POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com"],"1":["https://ethereum-rpc.example"]}
|
||||
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
|
||||
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359
|
||||
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
|
||||
# Multi-token / multi-chain payment routes. Token rows can include:
|
||||
# chain_id, chain_code, chain_name, receiver_contract, direct_receiver_address,
|
||||
# supports_contract_checkout, supports_direct_transfer, confirmations,
|
||||
# explorer_tx_url.
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
|
||||
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
|
||||
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
|
||||
@@ -182,7 +200,8 @@ POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8}
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
|
||||
POLYMARKET_SIGNAL_EDGE_PCT=2
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ METEOBLUE_API_KEY=
|
||||
# Wallet / payments
|
||||
########################################
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON=
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
|
||||
|
||||
+41
-35
@@ -73,9 +73,6 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -83,52 +80,61 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push backend
|
||||
if: matrix.image.name == 'backend'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.image.context }}
|
||||
file: ${{ matrix.image.file }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ matrix.image.tag }}:latest
|
||||
${{ matrix.image.tag }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Build and push image
|
||||
env:
|
||||
IMAGE_NAME: ${{ matrix.image.name }}
|
||||
IMAGE_CONTEXT: ${{ matrix.image.context }}
|
||||
IMAGE_FILE: ${{ matrix.image.file }}
|
||||
IMAGE_TAG: ${{ matrix.image.tag }}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
- name: Build and push frontend
|
||||
if: matrix.image.name == 'frontend'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.image.context }}
|
||||
file: ${{ matrix.image.file }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ matrix.image.tag }}:latest
|
||||
${{ matrix.image.tag }}:${{ github.sha }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
NEXT_PUBLIC_SITE_URL=${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags=(-t "${IMAGE_TAG}:latest" -t "${IMAGE_TAG}:${GITHUB_SHA}")
|
||||
build_args=()
|
||||
|
||||
if [ "${IMAGE_NAME}" = "frontend" ]; then
|
||||
build_args=(
|
||||
--build-arg "NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}"
|
||||
--build-arg "NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}"
|
||||
--build-arg "NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}"
|
||||
--build-arg "NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL}"
|
||||
--build-arg "NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false"
|
||||
--build-arg "NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID}"
|
||||
--build-arg "NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL}"
|
||||
--build-arg "NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS}"
|
||||
)
|
||||
fi
|
||||
|
||||
docker build -f "${IMAGE_FILE}" "${tags[@]}" "${build_args[@]}" "${IMAGE_CONTEXT}"
|
||||
docker push "${IMAGE_TAG}:latest"
|
||||
docker push "${IMAGE_TAG}:${GITHUB_SHA}"
|
||||
|
||||
deploy:
|
||||
needs: [build-and-push]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: polyweather-production-deploy
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to VPS
|
||||
env:
|
||||
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
scp -o StrictHostKeyChecking=accept-new deploy.sh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/deploy.sh
|
||||
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
||||
bash /tmp/deploy.sh '${{ secrets.GHCR_PAT }}' '${{ github.sha }}'
|
||||
printf '%s\n' "$GHCR_PAT" | ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
||||
bash /tmp/deploy.sh '${{ github.sha }}'
|
||||
"
|
||||
|
||||
@@ -9,6 +9,7 @@ data/*.db
|
||||
data/*.db-*
|
||||
data/*.db.*
|
||||
data/*.json
|
||||
data/*backtest*.csv
|
||||
data/logs/
|
||||
data/historical/
|
||||
data/cache/
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# PolyWeather Agent Instructions
|
||||
|
||||
## 语言和沟通
|
||||
|
||||
- 默认用中文回复用户。
|
||||
- 直接说明正在做什么、查到了什么、下一步是什么;不要写空泛客套话。
|
||||
- 如果用户要求“提交推送”“部署”“看日志”,必须在本地验证后再提交、推送,并检查 GitHub Actions 或线上状态。
|
||||
- 不要把多个不相关任务混在一个结论里;遇到新方向时,建议用户在同一个 Project 下开新 Thread。
|
||||
|
||||
## 项目和线程使用
|
||||
|
||||
- 一个 Project 对应 PolyWeather 这个共享代码库和长期方向。
|
||||
- 每个具体任务使用一条独立 Thread / Chat,例如:
|
||||
- 落地页与产品包装
|
||||
- 观测数据采集与 SSE patch
|
||||
- Telegram 推送
|
||||
- 付款与会员
|
||||
- 部署、CI、服务器状态
|
||||
- 同一个 Project 下的 Thread 共享文件夹和 `AGENTS.md`,但上下文分开,避免旧问题影响新任务判断。
|
||||
|
||||
## 代码工作原则
|
||||
|
||||
- 先读现有代码和配置,再改动;优先沿用项目已有模式。
|
||||
- 使用 `rg` / `rg --files` 查找文件和文本。
|
||||
- 手动编辑文件使用 `apply_patch`。
|
||||
- 不要回滚用户或其他 agent 已经做过的无关改动。
|
||||
- 只改和当前任务直接相关的文件,避免顺手重构。
|
||||
- 新增复杂逻辑时补充聚焦测试;窄改动保持验证范围匹配风险。
|
||||
|
||||
## 前端约定
|
||||
|
||||
- 前端位于 `frontend/`,使用 Next.js、React、TypeScript。
|
||||
- UI 修改必须关注移动端响应式、文本不重叠、按钮和标签不溢出。
|
||||
- 图表、终端、详情面板等工作界面应保持信息密度和可扫描性,避免营销式装饰。
|
||||
- 常用验证:
|
||||
- `cd frontend && npm run test:business`
|
||||
- `cd frontend && npm run typecheck`
|
||||
- 必要时启动本地预览并用浏览器检查桌面和移动视口;检查完关闭本地端口。
|
||||
|
||||
## 后端和数据约定
|
||||
|
||||
- Python 代码主要位于 `src/`、`web/`、`tests/`。
|
||||
- 观测数据刷新应以数据源原生频率为准,避免 Web、collector、Telegram 同时强刷同一外部源。
|
||||
- Telegram 默认只读最新缓存/DB;除非完全没有缓存,才允许兜底刷新。
|
||||
- 对外部源调用要考虑 singleflight、冷却、缓存和失败降级,避免 502/408 或 Supabase/磁盘 IO 压力。
|
||||
- 常用验证:
|
||||
- `python -m ruff check .`
|
||||
- `python -m pytest`
|
||||
|
||||
## CI、提交和部署
|
||||
|
||||
- `main` push 会触发 `.github/workflows/ci.yml`:
|
||||
- `python-quality`
|
||||
- `frontend-quality`
|
||||
- `build-and-push`
|
||||
- `deploy`
|
||||
- 提交前至少运行和改动相关的验证;推送前确认 `git status --short`。
|
||||
- 推送后检查 GitHub Actions 最新 run;如果失败,先定位失败 job 和 step,再修改。
|
||||
- 线上 smoke check 优先检查:
|
||||
- `https://api.polyweather.top/healthz`
|
||||
- `https://polyweather.top/`
|
||||
- 相关页面或 API 路径
|
||||
|
||||
## 产品方向备忘
|
||||
|
||||
- PolyWeather 当前重点不是售卖 API。
|
||||
- 核心差异化是结算源优先、实时观测源、跑道/城市细粒度温度、SSE patch、Telegram 缓存读取和面向交易/预测市场的解释能力。
|
||||
- 公开包装、教育内容、图表变量完整度和付费分层可以加强,但不要把产品定位改成通用天气 API。
|
||||
|
||||
## Memory 说明
|
||||
|
||||
- `AGENTS.md` 是项目内显式规则,跟随仓库和 Project。
|
||||
- Memory 是用户账号级偏好设置,agent 不能代替用户开启。
|
||||
- 建议在 Codex / ChatGPT 设置中开启 Memory,并保存长期偏好,例如:
|
||||
- PolyWeather 项目默认中文回复。
|
||||
- 每个任务开独立 Thread。
|
||||
- 修改后优先验证、提交、推送并检查部署状态。
|
||||
- 不要主动把 PolyWeather 包装成 API 售卖产品。
|
||||
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## 1.8.1 - 2026-05-28
|
||||
|
||||
### 文档与发布
|
||||
- README / README_ZH 改用 `frontend/public/static/web.png` 与 `frontend/public/static/tel.png` 作为产品截图,并移除旧 `docs/images` README 截图引用。
|
||||
- 同步版本源到 `1.8.1`,刷新 API、Supabase、技术债、PolygonScan 验证等文档标题版本。
|
||||
- 更新前端、实时事件、数据源、模型栈与服务文档,补齐 Redis Stream + SSE Patch、DEB hourly consensus、城市当地时间图表、legacy 高斯图表叠加、跑道/CoWIN 曲线和中英文 Telegram 推送口径。
|
||||
|
||||
### 当前线上口径确认
|
||||
- 生产实时层为“HTTP snapshot + SSE patch + replayable event store”;前端只消费 `/api/events`,不直接连接 Redis。
|
||||
- `deb_hourly_consensus.v1` 是峰值窗口与 DEB 曲线展示的优先小时路径;DEB 不作为实测来源。
|
||||
- AMSC/AMOS 跑道曲线和香港 CoWIN 6087 参考站曲线按城市当地时间展示,结算跑道高亮,辅助跑道弱化。
|
||||
|
||||
|
||||
## 1.8.0 - 2026-05-27
|
||||
|
||||
### 新增与重构
|
||||
|
||||
@@ -2,43 +2,51 @@
|
||||
|
||||
Production weather-intelligence stack for temperature settlement markets.
|
||||
|
||||
Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
|
||||
Official dashboard: [polyweather.top](https://polyweather.top/)
|
||||
中文说明: [README_ZH.md](README_ZH.md)
|
||||
|
||||
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday analysis, calibrated probability, model stack, TAF, settlement sources, history, and extension).
|
||||
Public docs center: `/docs/intro` on the main site (bilingual product documentation for the current terminal, chart reading, realtime source cadence, settlement stations, and the browser extension).
|
||||
|
||||
## Product Screenshots
|
||||
|
||||
### Global Dashboard
|
||||
### Realtime Terminal
|
||||
|
||||

|
||||

|
||||
|
||||
### City Analysis (Ankara)
|
||||
### Telegram Runway Alerts
|
||||
|
||||

|
||||

|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#yangyuan-zhen/PolyWeather&Date)
|
||||
|
||||
## Product Status (2026-05-23)
|
||||
## Product Status (2026-06-07)
|
||||
|
||||
- Subscription live: `Pro Monthly 10 USDC`.
|
||||
- Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards.
|
||||
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, max `3 USDC`).
|
||||
- Weekly leaderboard rewards restructured: smaller point bonuses for winners (200/100/50), all active users receive participation rewards.
|
||||
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e).
|
||||
- Subscription live: `Pro Monthly 29.9 USDC / 30 days` and `Pro Quarterly 79.9 USDC / 90 days`.
|
||||
- Referral pricing live: invited users can get the first monthly Pro at `20 USDC`; inviters receive `3500` points after a valid first Pro payment, capped at 10 paid invites per month.
|
||||
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, monthly max `3 USDC`, quarterly max `8 USDC`). Useful user feedback can also receive manual point rewards through ops.
|
||||
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e) plus Ethereum mainnet USDC direct-transfer confirmation.
|
||||
- Auto-reconciliation live: event listener + periodic confirm loop.
|
||||
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
|
||||
- Ops dashboard live: `/ops` for memberships, leaderboard, user feedback triage, manual point grants, and payment incident triage.
|
||||
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
|
||||
- Realtime terminal live: visible city charts subscribe through `/api/events?cities=...&since_revision=...`, receive `city_observation_patch.v1` SSE patches, and replay short gaps from Redis Stream in production or SQLite fallback in local/single-node mode.
|
||||
- Chart refresh is observation-driven: live patches merge into the current chart without a loading overlay; only visible charts run a 60s no-patch fallback, and returning from a background browser tab triggers a foreground catch-up refresh.
|
||||
- Temperature charts default to All Day, keep an optional Peak window derived from the DEB hourly path, and render all timestamps in the selected city's local time.
|
||||
- The chart core has been split into focused logic/canvas/state modules; Recharts now receives explicit measured dimensions to avoid 0x0 rendering and disappearing curves.
|
||||
- DEB hourly consensus (`deb_hourly_consensus.v1`) is now the preferred hourly forecast path for peak-window detection and chart overlays; DEB remains a forecast curve, never an observation source.
|
||||
- Legacy Gaussian probability stays out of the default temperature chart surface; hover tooltips show `Gaussian μ` plus the full bucket distribution by temperature range.
|
||||
- Settlement runway curves are visible by default for AMSC/AMOS cities; the configured settlement runway is highlighted and auxiliary runways are shown as secondary context.
|
||||
- Hong Kong uses CoWIN station `6087` (Po Leung Kuk Choi Kai Yau School) as the 1-minute reference-station curve, with HKO 10-minute observations kept as the official meteorological layer.
|
||||
- Telegram airport/runway pushes are bilingual by default and use settlement-endpoint runway temperatures for slope/current/summary copy.
|
||||
- Runtime state, cache, and core offline training/backfill flows now use SQLite as the primary path; legacy JSON/JSONL files remain only for migration, export, and explicit fallback input.
|
||||
- EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout.
|
||||
- Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules.
|
||||
- Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives.
|
||||
- City decision cards now include the AI airport read: METAR, DEB, model cluster, and the AI expected-high center are resolved before mapping the result to temperature buckets.
|
||||
- AI airport reads now use in-page memory cache, browser `localStorage`, and backend short-TTL cache; returning from another dashboard tab restores existing stream text or final results before any new request is needed.
|
||||
- Terminal chart/detail workflow now combines settlement observations, DEB hourly consensus, model context, probability distribution tooltips, and market-bucket mapping without blocking the chart on AI text generation.
|
||||
- Terminal data uses page memory cache, browser `localStorage`, backend short-TTL cache, SSE patch replay, and foreground refresh so returning from another tab restores the latest visible chart state quickly.
|
||||
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
|
||||
- The card label “model-market difference” means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
|
||||
- The market-signal difference means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
|
||||
- Calibrated model probability is now the primary probability panel. It shows the active production probability engine (legacy Gaussian or EMOS), while model consensus remains a secondary reference.
|
||||
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
|
||||
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
|
||||
@@ -62,10 +70,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.
|
||||
- Builds a DEB-weighted hourly consensus path for peak-window logic and chart display.
|
||||
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via legacy Gaussian or EMOS/CRPS calibration.
|
||||
- Adds city decision cards that combine AI airport reads, expected-high centers, full market-bucket mapping, and model-market difference in one view.
|
||||
- Adds terminal chart/detail workflows that combine live observations, DEB-centered high-temperature context, market-bucket mapping, and model-market difference.
|
||||
- Shows calibrated Gaussian context in chart tooltips as `mu` plus the full temperature-range probability distribution, without reintroducing probability bands into the main temperature view.
|
||||
- Reuses one analysis core across web dashboard and Telegram bot.
|
||||
- Adds payment audit trails, replay tooling, and incident visibility in ops.
|
||||
- Adds an in-app feedback loop with chart context, user-visible feedback status, ops triage, and manual point rewards for useful reports and suggestions.
|
||||
- Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules.
|
||||
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
|
||||
- Adds official nearby-network and runway-level enhancement layers for China, Japan, Korea (AMOS runway sensors for Seoul/Busan), Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
|
||||
@@ -88,8 +99,11 @@ flowchart LR
|
||||
WX --> AMOS["AMOS runway sensors (Korea)"]
|
||||
WX --> HKO["HKO / CWA / NOAA / Official settlement sources"]
|
||||
|
||||
API --> ANA["DEB + Trend + Probability + Market Scan"]
|
||||
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
|
||||
API --> ANA["DEB + Hourly Consensus + Probability + Market Scan"]
|
||||
API --> SSE["SSE /api/events"]
|
||||
WX --> SSE
|
||||
SSE --> EVENT["Redis Stream / SQLite Event Log"]
|
||||
ANA --> PAY["Payment State (Multi-chain Intent + Event + Confirm Loop)"]
|
||||
ANA --> STATE["SQLite runtime state"]
|
||||
```
|
||||
|
||||
@@ -118,6 +132,8 @@ npm run dev
|
||||
|
||||
## Recent Highlights
|
||||
|
||||
- Gaussian probability tooltip now lists the full temperature-range distribution instead of only the highest-probability bucket, while the main chart remains focused on observations and forecasts.
|
||||
- User feedback is now a product loop: terminal submissions attach chart context, users can track status in-app, and ops can reward useful feedback with points.
|
||||
- Airport-linked contracts use the METAR / airport primary observing site as the settlement anchor. Wunderground pages are reference/history pages, not stations.
|
||||
- Taipei and Shenzhen retain their explicitly configured station history pages for reconciliation, but the docs avoid describing Wunderground itself as a physical station.
|
||||
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
|
||||
@@ -125,6 +141,8 @@ npm run dev
|
||||
- `TAF` is used as an airport-side confirmation layer, not as the main temperature model.
|
||||
- Calibrated probability uses legacy Gaussian (default) or EMOS/CRPS when evaluated; model vote counts remain an explanatory consensus line, not the final probability.
|
||||
- Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience.
|
||||
- Realtime terminal charts use SSE patches plus replayable event storage; full HTTP detail remains the authoritative snapshot.
|
||||
- Chart observations are shown in the city's local time, not the browser timezone.
|
||||
|
||||
## Runtime Data (Recommended on VPS)
|
||||
|
||||
@@ -134,8 +152,14 @@ Use external runtime storage to avoid SQLite/git conflicts:
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
POLYWEATHER_STATE_STORAGE_MODE=sqlite
|
||||
POLYWEATHER_EVENT_STORE=redis
|
||||
POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
|
||||
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
|
||||
POLYWEATHER_REDIS_REQUIRED=true
|
||||
```
|
||||
|
||||
For local development or a strict single-process fallback, keep `POLYWEATHER_EVENT_STORE=sqlite`.
|
||||
|
||||
## EMOS Local Training
|
||||
|
||||
Do not run full EMOS retraining on a small VPS. The VPS should collect data and load approved calibration files; training should run on a local/dev machine using a copied production SQLite database:
|
||||
@@ -162,7 +186,7 @@ curl http://127.0.0.1:8000/metrics
|
||||
### Frontend cache headers
|
||||
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather.top"
|
||||
```
|
||||
|
||||
### Payment auto-reconciliation logs
|
||||
@@ -177,6 +201,10 @@ docker compose logs -f polyweather | egrep "payment event loop started|payment c
|
||||
curl http://127.0.0.1:8000/api/payments/runtime
|
||||
```
|
||||
|
||||
### Payment chains
|
||||
|
||||
Production payment routes are configured by the backend. Polygon remains the default checkout-contract chain, while Ethereum mainnet USDC can be enabled as a direct-transfer route so users who pay on their wallet default network are still confirmed by `intent.chain_id`.
|
||||
|
||||
## Telegram Commands
|
||||
|
||||
| Command | Purpose |
|
||||
@@ -214,5 +242,5 @@ curl http://127.0.0.1:8000/api/payments/runtime
|
||||
|
||||
## Version
|
||||
|
||||
- Version: `v1.8.0`
|
||||
- Last Updated: `2026-05-23`
|
||||
- Version: `v1.8.1`
|
||||
- Last Updated: `2026-06-07`
|
||||
|
||||
+50
-24
@@ -2,30 +2,39 @@
|
||||
|
||||
面向温度结算市场的生产级气象情报系统。
|
||||
|
||||
官方看板:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
|
||||
官方看板:[polyweather.top](https://polyweather.top/)
|
||||
|
||||
## 产品截图
|
||||
|
||||
### 全球看板
|
||||
### 实时终端
|
||||
|
||||

|
||||

|
||||
|
||||
### 城市分析(Ankara)
|
||||
### Telegram 跑道推送
|
||||
|
||||

|
||||

|
||||
|
||||
## 当前产品状态(2026-05-23)
|
||||
## 当前产品状态(2026-06-07)
|
||||
|
||||
- 已上线订阅制:`Pro 月付 10 USDC`。
|
||||
- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖。
|
||||
- `/city` 与 `/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,最多抵 `3 USDC`)。
|
||||
- 周榜奖励已改造:降低赢家积分加成 (200/100/50),所有周活跃用户均享参与奖。
|
||||
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。
|
||||
- 已上线订阅制:`Pro 月付 29.9 USDC / 30 天`,`Pro 季度 79.9 USDC / 90 天`。
|
||||
- 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分。
|
||||
- `/city` 与 `/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。真实、有上下文、有价值的用户反馈也可通过运营后台人工奖励积分。
|
||||
- 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励。
|
||||
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)+ Ethereum 主网 USDC 直转确认。
|
||||
- 已上线自动补单:事件监听 + 周期确认双链路。
|
||||
- 已上线支付运行态与审计接口:`/api/payments/runtime`。
|
||||
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。
|
||||
- 已上线轻量运营后台:`/ops`(会员、用户反馈处理、积分、补分、支付异常单)。
|
||||
- 已上线轻量可观测性:`/healthz`、`/api/system/status`、`/metrics`。
|
||||
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。
|
||||
- 实时终端已切换到可重放事件流:可见城市图表通过 `/api/events?cities=...&since_revision=...` 订阅 `city_observation_patch.v1`,生产环境使用 Redis Stream 做短窗口 replay,本地/单进程可回退 SQLite event log。
|
||||
- 图表刷新由实测事件驱动:SSE patch 直接合并到当前曲线,不弹 loading 遮罩;只有可见图表启用 60 秒无 patch 兜底,浏览器后台返回前台时会主动补齐最新 detail。
|
||||
- 城市图表默认展示“全天”,可选“高温”窗口由 DEB hourly path 推导;所有图表横轴都按城市当地时间展示,不按用户浏览器时区。
|
||||
- 核心图表组件已拆分为逻辑、状态与 canvas 渲染模块;Recharts 使用 `ResizeObserver` 后的明确宽高,规避 0x0 渲染和长时间挂页后曲线消失。
|
||||
- DEB hourly consensus(`deb_hourly_consensus.v1`)已作为峰值窗口和图表 DEB 曲线的优先小时路径;DEB 仍然是预测曲线,不作为实测来源。
|
||||
- legacy 高斯概率不再占用默认温度图主视图;hover tooltip 会展示 `Gaussian μ` 和完整温度区间概率分布。
|
||||
- AMSC/AMOS 城市的结算跑道曲线默认展示并高亮,辅助跑道作为弱化曲线保留;釜山单跑道只展示 `SR/SL` 结算跑道,不再重复显示 AMOS 聚合线。
|
||||
- 香港默认展示 CoWIN `6087`(保良局陈守仁小学)1 分钟参考站曲线,HKO 10 分钟实测保留为官方气象层。
|
||||
- Telegram 机场/跑道推送默认中英文双语,并统一使用结算端点跑道温度计算当前值、15 分钟趋势和文案。
|
||||
- 运行态状态、缓存与核心离线训练/回填链路已完成 SQLite 主路径收口;legacy JSON/JSONL 仅保留给迁移、导出与显式回退输入。
|
||||
- EMOS/CRPS 校准链路已接通,但生产主概率保持 `legacy` 或 `emos_shadow`;`emos_primary` 只在本地离线评估通过并手动灰度后启用。
|
||||
- 官方增强站网已统一接入:
|
||||
@@ -37,15 +46,15 @@
|
||||
- `CWA`(台湾)
|
||||
- 东京现已接入羽田 `JMA AMeDAS` 10 分钟温度作为官方增强层。
|
||||
- 已支持 Dashboard 定向预热 worker / cron 路径,运行态在 `/api/system/status` 与 `/ops` 可见。
|
||||
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与 prewarm heartbeat。
|
||||
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与运行态 heartbeat。
|
||||
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
|
||||
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
|
||||
- 城市决策卡已接入 AI 机场报文解读:先用 METAR、DEB、多模型集群和 AI 最高温中枢判断天气路径,再映射到温度桶。
|
||||
- AI 机场报文解读现在同时使用页面内存缓存、浏览器 `localStorage` 和后端短 TTL 缓存;从其他选项卡切回决策卡时会优先恢复已有流式内容或最终结果。
|
||||
- 终端图表/详情工作流已改为结构化实况 + DEB hourly consensus + 多模型集群 + 概率分布 tooltip + 市场温度桶,不再让图表等待 AI 文案生成。
|
||||
- 终端数据同时使用页面内存缓存、浏览器 `localStorage`、后端短 TTL 缓存、SSE patch replay 和前台恢复刷新;从其他选项卡切回时会优先恢复最新可见图表状态。
|
||||
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
|
||||
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
||||
- 市场信号中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
||||
- 概率区已改为”校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS),模型共识作为辅助参考。
|
||||
- 今日日内结构解读已支持可选 Groq 改写层,失败时自动回退规则文案。
|
||||
- 今日日内结构解读以规则与结构化信号为主,AI 文案只作为可降级辅助层,不替代实测、DEB、TAF 或结算逻辑。
|
||||
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`。
|
||||
|
||||
## 许可证与商用边界(重要)
|
||||
@@ -62,10 +71,13 @@
|
||||
|
||||
- 聚合 51 个监控城市的实测与预报数据。
|
||||
- DEB(Dynamic Error Balancing)融合多模型最高温。
|
||||
- 构建 DEB 加权小时共识曲线,用于峰值窗口判断和图表默认 DEB 展示。
|
||||
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。
|
||||
- 地图城市决策卡把 AI 机场报文解读、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示。
|
||||
- 天气决策台把结构化实况、DEB 高温路径、完整市场温度桶和模型-市场差放进图表/详情工作流。
|
||||
- 图表 tooltip 展示校准高斯上下文:`mu` 加完整温度区间概率分布,不把概率温度带重新放回主图。
|
||||
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
|
||||
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
|
||||
- 已上线站内反馈闭环:提交反馈时自动附带图表上下文,用户可查看处理状态,运营后台可为有价值反馈人工奖励积分。
|
||||
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
|
||||
|
||||
## 参考架构
|
||||
@@ -85,7 +97,10 @@ flowchart LR
|
||||
WX --> OM["Open-Meteo"]
|
||||
WX --> HKO["HKO / CWA / NOAA 等官方结算源"]
|
||||
|
||||
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
|
||||
API --> ANA["DEB + 小时共识 + 概率 + 市场扫描"]
|
||||
API --> SSE["SSE /api/events"]
|
||||
WX --> SSE
|
||||
SSE --> EVENT["Redis Stream / SQLite Event Log"]
|
||||
ANA --> PAY["支付状态(Intent + Event + Confirm Loop)"]
|
||||
ANA --> STATE["SQLite runtime state<br/>legacy files only for migration/export fallback"]
|
||||
```
|
||||
@@ -113,6 +128,11 @@ npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 近期更新
|
||||
|
||||
- 高斯概率 tooltip 已改为展示完整温度区间概率分布,不再只显示最高概率的单个区间;主图继续聚焦实测和预测曲线。
|
||||
- 用户反馈已形成产品闭环:终端提交会自动附带图表上下文,用户可在站内查看处理状态,运营侧可为真实、有建设性的反馈发放积分奖励。
|
||||
|
||||
## 运行数据目录(VPS 推荐)
|
||||
|
||||
建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住):
|
||||
@@ -121,8 +141,14 @@ npm run dev
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
POLYWEATHER_STATE_STORAGE_MODE=sqlite
|
||||
POLYWEATHER_EVENT_STORE=redis
|
||||
POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
|
||||
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
|
||||
POLYWEATHER_REDIS_REQUIRED=true
|
||||
```
|
||||
|
||||
本地开发或严格单进程兜底可使用 `POLYWEATHER_EVENT_STORE=sqlite`。
|
||||
|
||||
## EMOS 本地训练流程
|
||||
|
||||
低配 VPS 只负责采集、服务和加载已通过评估的参数,不建议在 VPS 上跑 EMOS 全量训练。训练前先从 VPS 拉 SQLite 副本到本地:
|
||||
@@ -154,7 +180,7 @@ curl http://127.0.0.1:8000/metrics
|
||||
### 外部监控栈
|
||||
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather.top"
|
||||
```
|
||||
|
||||
### 支付自动补单日志
|
||||
@@ -187,7 +213,7 @@ curl http://127.0.0.1:8000/api/payments/runtime
|
||||
|
||||
### 运营后台
|
||||
|
||||
- 前端入口:`https://polyweather-pro.vercel.app/ops`
|
||||
- 前端入口:`https://polyweather.top/ops`
|
||||
- 后端需配置:
|
||||
|
||||
```env
|
||||
@@ -223,12 +249,12 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
|
||||
- 模型栈与 DEB:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- DEB 模型家族去重规则:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
|
||||
- 发布流程:[RELEASE.md](RELEASE.md)
|
||||
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## 当前版本
|
||||
|
||||
- 版本:`v1.8.0`
|
||||
- 文档最后更新:`2026-05-23`
|
||||
- 版本:`v1.8.1`
|
||||
- 文档最后更新:`2026-06-07`
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@ cities:
|
||||
- id: new_york
|
||||
city: New York
|
||||
country: USA
|
||||
latitude: 40.7128
|
||||
longitude: -74.006
|
||||
latitude: 40.7769
|
||||
longitude: -73.874
|
||||
- id: chicago
|
||||
city: Chicago
|
||||
country: USA
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
GHCR_PAT="$1"
|
||||
NEW_TAG="${2:-latest}"
|
||||
NEW_TAG="${1:-latest}"
|
||||
TAG_FILE="/var/lib/polyweather/.current_tag"
|
||||
COMPOSE_DIR="/root/PolyWeather"
|
||||
LOCK_FILE="${POLYWEATHER_DEPLOY_LOCK_FILE:-/var/lock/polyweather-deploy.lock}"
|
||||
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
|
||||
GHCR_PAT=""
|
||||
if ! IFS= read -r GHCR_PAT && [ -z "$GHCR_PAT" ]; then
|
||||
echo "❌ GHCR token must be provided on stdin"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$GHCR_PAT" ]; then
|
||||
echo "❌ GHCR token must be provided on stdin"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$LOCK_FILE")"
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
echo "❌ Another PolyWeather deploy is already running"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s' "$GHCR_PAT" | docker login ghcr.io -u yangyuan-zhen --password-stdin
|
||||
unset GHCR_PAT
|
||||
|
||||
cd "$COMPOSE_DIR"
|
||||
git fetch origin main && git reset --hard origin/main
|
||||
@@ -17,36 +35,225 @@ if [ -f "$TAG_FILE" ]; then
|
||||
echo "Previous tag: $PREVIOUS_TAG"
|
||||
fi
|
||||
|
||||
export IMAGE_TAG="$NEW_TAG"
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# Wait for backend to be ready (retry up to 150s)
|
||||
echo "Waiting for backend..."
|
||||
for i in $(seq 1 30); do
|
||||
sleep 5
|
||||
if curl -fsSo /dev/null --max-time 5 "https://api.polyweather.top/healthz"; then
|
||||
echo "✅ healthz ready after ${i}x5s"
|
||||
break
|
||||
fi
|
||||
echo " retry $i/30..."
|
||||
done
|
||||
|
||||
FAILED=0
|
||||
curl -fsSo /dev/null --max-time 15 "https://api.polyweather.top/healthz" && echo "✅ healthz" || { echo "❌ healthz"; FAILED=1; }
|
||||
curl -fsSo /dev/null --max-time 10 "https://api.polyweather.top/api/cities" && echo "✅ cities" || { echo "❌ cities"; FAILED=1; }
|
||||
curl -fsSo /dev/null --max-time 10 "https://www.polyweather.top/" && echo "✅ frontend" || { echo "❌ frontend"; FAILED=1; }
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo "❌ Smoke tests failed. Rolling back..."
|
||||
rollback_to_previous() {
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
echo "Rolling back to $PREVIOUS_TAG..."
|
||||
export IMAGE_TAG="$PREVIOUS_TAG"
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
compose_up_retry "rollback" -d
|
||||
echo "✅ Rolled back to $PREVIOUS_TAG"
|
||||
else
|
||||
echo "⚠️ No previous tag to rollback to"
|
||||
fi
|
||||
}
|
||||
|
||||
compose_up_retry() {
|
||||
local name="$1"
|
||||
shift
|
||||
local output=""
|
||||
|
||||
for attempt in $(seq 1 6); do
|
||||
if output=$(docker compose up "$@" 2>&1); then
|
||||
echo "$output"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "$output"
|
||||
if echo "$output" | grep -qi "removal of container .* is already in progress"; then
|
||||
echo "Container removal is still in progress during ${name}; retry ${attempt}/6..."
|
||||
sleep 5
|
||||
continue
|
||||
fi
|
||||
|
||||
return 1
|
||||
done
|
||||
|
||||
echo "❌ docker compose up failed for ${name} after retries"
|
||||
return 1
|
||||
}
|
||||
|
||||
export IMAGE_TAG="$NEW_TAG"
|
||||
export POLYWEATHER_API_BASE_URL="${POLYWEATHER_FRONTEND_INTERNAL_API_BASE_URL:-http://polyweather_web:8000}"
|
||||
pull_ok=0
|
||||
for pull_attempt in $(seq 1 6); do
|
||||
docker compose pull && pull_ok=1 && break
|
||||
echo "Image pull failed or tag not ready, retry ${pull_attempt}/6..."
|
||||
sleep 10
|
||||
done
|
||||
if [ "$pull_ok" != "1" ]; then
|
||||
echo "❌ Image pull failed after retries"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
smoke_check() {
|
||||
local name="$1"
|
||||
local url="$2"
|
||||
local timeout="$3"
|
||||
local attempts="${4:-6}"
|
||||
local delay="${5:-5}"
|
||||
local output=""
|
||||
|
||||
for i in $(seq 1 "$attempts"); do
|
||||
if output=$(curl -fsS -w "http=%{http_code} time=%{time_total}" -o /dev/null --max-time "$timeout" "$url" 2>&1); then
|
||||
echo "✅ $name ($output)"
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" != "$attempts" ]; then
|
||||
echo " $name retry $i/$attempts... ($output)"
|
||||
sleep "$delay"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "❌ $name ($output)"
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_local_service() {
|
||||
local name="$1"
|
||||
local url="$2"
|
||||
local timeout="${3:-5}"
|
||||
local attempts="${4:-30}"
|
||||
local delay="${5:-2}"
|
||||
|
||||
for i in $(seq 1 "$attempts"); do
|
||||
if curl -fsSo /dev/null --max-time "$timeout" "$url"; then
|
||||
echo "✅ $name ready after attempt $i/$attempts"
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" != "$attempts" ]; then
|
||||
echo " $name warming $i/$attempts..."
|
||||
sleep "$delay"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "❌ $name did not become ready"
|
||||
return 1
|
||||
}
|
||||
|
||||
warm_public_route() {
|
||||
local name="$1"
|
||||
local url="$2"
|
||||
local timeout="${3:-15}"
|
||||
local attempts="${4:-3}"
|
||||
local delay="${5:-2}"
|
||||
|
||||
for i in $(seq 1 "$attempts"); do
|
||||
if curl -fsSo /dev/null --max-time "$timeout" "$url"; then
|
||||
echo "✅ warmed $name"
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" != "$attempts" ]; then
|
||||
echo " warm $name retry $i/$attempts..."
|
||||
sleep "$delay"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "⚠️ warm $name failed"
|
||||
return 0
|
||||
}
|
||||
|
||||
read_env_file_value() {
|
||||
local key="$1"
|
||||
if [ ! -f ".env" ]; then
|
||||
return 0
|
||||
fi
|
||||
awk -F= -v key="$key" '
|
||||
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
|
||||
value=$0
|
||||
sub("^[^=]*=", "", value)
|
||||
gsub("^[[:space:]]+|[[:space:]]+$", "", value)
|
||||
gsub(/^["'"'"']|["'"'"']$/, "", value)
|
||||
print value
|
||||
}
|
||||
' .env | tail -n 1
|
||||
}
|
||||
|
||||
validate_frontend_api_base_url() {
|
||||
local api_base="${POLYWEATHER_API_BASE_URL:-}"
|
||||
if [ -z "$api_base" ]; then
|
||||
api_base="$(read_env_file_value "POLYWEATHER_API_BASE_URL")"
|
||||
fi
|
||||
local normalized
|
||||
normalized="$(printf '%s' "$api_base" | tr '[:upper:]' '[:lower:]' | sed 's/[[:space:]]//g; s#/*$##')"
|
||||
case "$normalized" in
|
||||
http://polyweather.top|https://polyweather.top|http://www.polyweather.top|https://www.polyweather.top)
|
||||
echo "❌ POLYWEATHER_API_BASE_URL must not point at the frontend site: $api_base"
|
||||
echo " Use the internal backend URL http://polyweather_web:8000 or the backend API host https://api.polyweather.top."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
PUBLIC_SMOKE_RECHECK_DELAY_SEC="${POLYWEATHER_PUBLIC_SMOKE_RECHECK_DELAY_SEC:-20}"
|
||||
|
||||
run_public_smoke_checks() {
|
||||
local phase="${1:-initial}"
|
||||
local failed=0
|
||||
|
||||
if [ "$phase" = "recheck" ]; then
|
||||
smoke_check "healthz recheck" "https://api.polyweather.top/healthz" 20 6 10 || failed=1
|
||||
smoke_check "frontend cities recheck" "https://polyweather.top/api/cities" 30 8 10 || failed=1
|
||||
smoke_check "frontend recheck" "https://www.polyweather.top/" 20 6 10 || failed=1
|
||||
else
|
||||
smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || failed=1
|
||||
smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5 || failed=1
|
||||
smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || failed=1
|
||||
fi
|
||||
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
validate_frontend_api_base_url
|
||||
|
||||
echo "Updating Redis dependency..."
|
||||
compose_up_retry "redis" -d polyweather_redis
|
||||
|
||||
echo "Updating backend services..."
|
||||
compose_up_retry "backend services" -d --no-deps polyweather_web polyweather
|
||||
|
||||
echo "Waiting for backend..."
|
||||
wait_for_local_service "backend healthz" "http://127.0.0.1:8000/healthz" 5 30 5 || FAILED_BACKEND=1
|
||||
FAILED_BACKEND="${FAILED_BACKEND:-0}"
|
||||
if [ "$FAILED_BACKEND" = "1" ]; then
|
||||
echo "❌ Backend did not become healthy"
|
||||
rollback_to_previous
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updating observation collector..."
|
||||
compose_up_retry "observation collector" -d --no-deps polyweather_collector
|
||||
|
||||
echo "Updating frontend..."
|
||||
compose_up_retry "frontend" -d --no-deps polyweather_frontend
|
||||
|
||||
echo "Waiting for frontend..."
|
||||
wait_for_local_service "frontend root" "http://127.0.0.1:3001/" 5 40 2 || FAILED_FRONTEND=1
|
||||
wait_for_local_service "frontend terminal" "http://127.0.0.1:3001/terminal" 10 20 2 || FAILED_FRONTEND=1
|
||||
FAILED_FRONTEND="${FAILED_FRONTEND:-0}"
|
||||
if [ "$FAILED_FRONTEND" = "1" ]; then
|
||||
echo "❌ Frontend did not become healthy"
|
||||
rollback_to_previous
|
||||
exit 1
|
||||
fi
|
||||
|
||||
warm_public_route "terminal" "https://polyweather.top/terminal" 20 4 3
|
||||
warm_public_route "auth snapshot" "https://polyweather.top/api/auth/me?prefer_snapshot=1" 10 3 2
|
||||
warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?refresh_deb_recent=1" 15 2 2
|
||||
warm_public_route "cities" "https://polyweather.top/api/cities" 20 3 2
|
||||
|
||||
FAILED=0
|
||||
run_public_smoke_checks || FAILED=1
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo "⚠️ Initial public smoke failed; retrying before rollback..."
|
||||
sleep "$PUBLIC_SMOKE_RECHECK_DELAY_SEC"
|
||||
FAILED=0
|
||||
run_public_smoke_checks "recheck" || FAILED=1
|
||||
fi
|
||||
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo "❌ Smoke tests failed. Rolling back..."
|
||||
rollback_to_previous
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
+105
-7
@@ -1,6 +1,24 @@
|
||||
services:
|
||||
polyweather_redis:
|
||||
image: redis:7-alpine
|
||||
container_name: polyweather_redis
|
||||
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy noeviction
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
interval: 10s
|
||||
retries: 5
|
||||
test:
|
||||
- CMD
|
||||
- redis-cli
|
||||
- ping
|
||||
timeout: 5s
|
||||
volumes:
|
||||
- polyweather_redis_data:/data
|
||||
polyweather:
|
||||
container_name: polyweather_bot
|
||||
depends_on:
|
||||
polyweather_redis:
|
||||
condition: service_healthy
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
@@ -10,6 +28,11 @@ services:
|
||||
env_file: &id001
|
||||
- .env
|
||||
environment:
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: bot
|
||||
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC: ${POLYWEATHER_BOT_AIRPORT_PUSH_INTERVAL_SEC:-180}
|
||||
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS: ${POLYWEATHER_BOT_AIRPORT_PUSH_MAX_WORKERS:-1}
|
||||
healthcheck:
|
||||
@@ -42,27 +65,32 @@ services:
|
||||
environment:
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL:-}
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS: 'false'
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS:-polyweather.top,www.polyweather.top}
|
||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://polyweather.top}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||
POLYWEATHER_API_BASE_URL: ${POLYWEATHER_API_BASE_URL:-http://polyweather_web:8000}
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL:-https://polygon-bor-rpc.publicnode.com}
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID:-}
|
||||
POLYWEATHER_API_BASE_URL: http://polyweather_web:8000
|
||||
POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true}
|
||||
POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true}
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS: ${POLYWEATHER_OPS_ADMIN_EMAILS:-}
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- wget
|
||||
- -qO-
|
||||
- http://localhost:3000
|
||||
- CMD-SHELL
|
||||
- wget -qO- http://$(hostname):3000
|
||||
timeout: 5s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest}
|
||||
ports:
|
||||
- 3001:3000
|
||||
- "127.0.0.1:3001:3000"
|
||||
restart: unless-stopped
|
||||
polyweather_web:
|
||||
command: python web/app.py
|
||||
depends_on:
|
||||
polyweather_redis:
|
||||
condition: service_healthy
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
@@ -70,6 +98,23 @@ services:
|
||||
max-file: "3"
|
||||
container_name: polyweather_web
|
||||
env_file: *id001
|
||||
environment:
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-1}
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-1}
|
||||
POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-3000}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-180}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-5}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: web
|
||||
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
retries: 3
|
||||
@@ -81,7 +126,58 @@ services:
|
||||
timeout: 5s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
ports:
|
||||
- 8000:8000
|
||||
- "127.0.0.1:8000:8000"
|
||||
restart: unless-stopped
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
polyweather_collector:
|
||||
command: python -m web.observation_collector_worker
|
||||
container_name: polyweather_collector
|
||||
depends_on:
|
||||
polyweather_redis:
|
||||
condition: service_healthy
|
||||
polyweather_web:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
cpus: ${POLYWEATHER_COLLECTOR_CPUS:-0.75}
|
||||
env_file: *id001
|
||||
environment:
|
||||
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ${POLYWEATHER_COLLECTOR_PATCH_ENDPOINT:-http://polyweather_web:8000/api/internal/collector-patch}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-180}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC:-60}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_HKO_SEC:-600}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-15}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: collector
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
|
||||
c.execute('SELECT 1'); c.close()
|
||||
timeout: 10s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
mem_limit: ${POLYWEATHER_COLLECTOR_MEM_LIMIT:-768m}
|
||||
memswap_limit: ${POLYWEATHER_COLLECTOR_MEMSWAP_LIMIT:-1g}
|
||||
pids_limit: 256
|
||||
restart: unless-stopped
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
@@ -90,3 +186,5 @@ services:
|
||||
x-polyweather-base:
|
||||
env_file: *id001
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
volumes:
|
||||
polyweather_redis_data:
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
# 机场高频实时数据源
|
||||
|
||||
最后更新:`2026-05-28`
|
||||
|
||||
## 已接入城市
|
||||
|
||||
| 城市 | 机场 | ICAO/站点 | 数据源 | 频率 | 类型 | 费用 |
|
||||
|------|------|-----------|--------|------|------|------|
|
||||
| 首尔 | 仁川国际 | RKSI | AMOS (`global.amo.go.kr`) | 1 分钟 | 跑道对温度(2对) | 免费 |
|
||||
| 釜山 | 金海国际 | RKPK | AMOS (`global.amo.go.kr`) | 1 分钟 | 跑道对温度(1对) | 免费 |
|
||||
| 香港 | CoWIN 6087 | 6087 | CoWIN (`cowin.hku.hk`) | 1 分钟 | 参考站温度(保良局陈守仁小学) | 免费 |
|
||||
| 香港 | HKO | HKO | HKO 官方 CSV (`data.weather.gov.hk`) | 10 分钟 | 官方气象站温度 | 免费 |
|
||||
| 台北 | 松山/中央气象署 | 466920 | CWA 开放数据 | 10 分钟 | 官方站点温度 | 免费 |
|
||||
| 北京 | 首都机场 | ZBAA | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 上海 | 浦东机场 | ZSPD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 广州 | 白云机场 | ZGGG | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 成都 | 双流机场 | ZUUU | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 重庆 | 江北机场 | ZUCK | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 武汉 | 天河机场 | ZHHH | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 青岛 | 胶东机场 | ZSQD | AMSC AWOS | 3 分钟 | 跑道端点气温 | 免费 |
|
||||
| 东京 | 羽田 | RJTT | JMA AMeDAS (`jma.go.jp`) | 10 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 安卡拉 | Esenboğa | 17128 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 伊斯坦布尔 | 伊斯坦布尔机场 | 17058 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
|
||||
@@ -36,22 +48,41 @@
|
||||
> 后端通过 `weather_sources.py` 拉取并注入 `airport_primary`,前端市场监控通过
|
||||
> `resolveMonitorTemperature` 优先读取 `airport_primary.temp` 获得小数精度温度。
|
||||
|
||||
## 推送机制
|
||||
> **CoWIN 6087**: 香港图表默认参考站为 HKU CoWIN `6087`(保良局陈守仁小学)。
|
||||
> 该源提供约 1 分钟温度序列,作为 PM 最高温市场的高频参考曲线;HKO 10 分钟数据
|
||||
> 仍作为官方气象层保留。后端通过 `cowin_sources.py` 拉取并写入 `cowin_obs`。
|
||||
|
||||
> **AMSC AWOS**: 中国内地跑道城市读取 AMSC `getWindPlate` 中的 `TDZ_TEMP` /
|
||||
> `MID_TEMP` / `END_TEMP`。这些字段是跑道观测位置气温,不是道面温度。
|
||||
> 结算跑道展示使用配置的结算端点;辅助跑道只作为背景曲线。
|
||||
|
||||
## 独立观测采集器
|
||||
|
||||
- Web/API 进程启动 `observation-collector` 后台线程,按源频率独立采集,不依赖 Telegram 推送循环
|
||||
- 默认频率:AMOS 60s、AMSC AWOS 180s、MADIS HFMETAR 300s、CoWIN 60s、HKO 600s
|
||||
- 每次采集复用 `weather_sources.py` 现有 `_attach_*` 写入逻辑,负责写 `airport_obs_log` / `runway_obs_log` / 今日观测缓存,并通过 `/api/internal/collector-patch` 写 Redis Stream 或 SQLite event log 后广播 SSE
|
||||
- 采集成功后刷新对应城市 `panel` cache;前端继续使用 HTTP snapshot + SSE patch,不需要依赖 Telegram 触发更新
|
||||
- `observation_source_gate.py` 对 AMSC、AMOS、MADIS、HKO、CoWIN 做 per-source/per-city singleflight 和 SQLite cooldown,防止 Web 请求、collector 和兜底分析同时打同一个外部源
|
||||
|
||||
## Telegram 推送机制
|
||||
|
||||
- 每城按原生频率独立推送,不捆绑
|
||||
- 首尔/釜山 60s,其余 600s
|
||||
- 首尔/釜山 60s,中国 AMSC 城市 180s,其余 600s
|
||||
- 循环轮询 60s 以匹配最快频率
|
||||
- Telegram 推送优先读取网站侧 `full`/`panel` 城市缓存;缓存缺失时只做非强制 `panel` 分析兜底,不触发 `force_refresh_observations_only`
|
||||
- 仅当当前温度距 DEB 预测最高 ≤3°C 时推送
|
||||
- 确认过峰值后自动停止
|
||||
|
||||
## 前端实时同步与 SSE Patch 机制
|
||||
## 前端实时同步与 SSE Patch / Redis Stream 机制
|
||||
|
||||
为了向用户提供秒级实况响应并降低服务器负载,系统已从定时轮询架构全面迁移至 **Server-Sent Events (SSE) 增量更新(SSE Patch)** 架构。
|
||||
为了向用户提供接近行情盘的实况响应并降低服务器负载,系统使用 **HTTP snapshot + Server-Sent Events (SSE) Patch + 可重放事件日志** 架构。生产环境推荐 Redis Stream;本地或单进程可回退 SQLite event log。
|
||||
|
||||
### 1. 数据推送链路 (Data Pipeline)
|
||||
1. **Collector 采集端触发**:在 `weather_sources.py` 中,当高频实况源(如 AMOS, CoWIN, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
|
||||
2. **FastAPI SSE 广播**:FastAPI 后端的 `sse_router.py` 接收到 Patch 后,将其推入 `sse_manager` 进行全局广播,事件被包装为 `city_patch` 增量包,包含自增的全局 `revision` 和最新的 `changes`。
|
||||
3. **BFF 代理流**:浏览器前端通过 BFF (Next.js rewrites) 建立与 `/api/events` 的持久连接,从而无需定时轮询。
|
||||
1. **Observation Collector 采集端触发**:`web.observation_collector_service` 按源频率调用采集层;在 `weather_sources.py` 中,当高频实况源(如 AMOS, AMSC, CoWIN, HKO, MADIS 等)采集到温度更新或观测时间变更时,会调用 `_emit_temperature_patch_if_changed` 过滤重复值,并异步向 `/api/internal/collector-patch` 发送 POST 报文。
|
||||
2. **标准化事件**:`realtime_patch_schema.py` 将旧 `city_patch` 或新 payload 统一成 `city_observation_patch.v1`。
|
||||
3. **事件存储**:生产环境写入 Redis Stream(`stream:city_observation`)并生成全局递增 `revision`;SQLite `observation_patch_events` 保留为本地/兜底 replay。
|
||||
4. **FastAPI SSE 广播**:FastAPI 后端的 `sse_router.py` 根据城市订阅集合向匹配连接推送 patch;断线重连时按 `since_revision` replay。
|
||||
5. **BFF 代理流**:浏览器前端通过 BFF 建立与 `/api/events` 的持久连接,从而无需固定整图轮询。
|
||||
|
||||
### 2. 前端消费与刷新规则 (Frontend Freshness Rules)
|
||||
- **扫描列表免轮询更新**:`use-scan-terminal-query.ts` 通过 `useSsePatchVersion` 钩子订阅全局 SSE 版本。当有任何城市产生更新时,列表将触发按需重绘,之前固定的 5 分钟 `setInterval` 定时轮询已被彻底禁用。
|
||||
@@ -62,6 +93,8 @@
|
||||
1. 调用轻量级的 `/api/city/{city}/summary` 快速拉取最新实况温度。
|
||||
2. 调用 `fetchHourlyForecastForCity(city, { ignoreCache: true })` 强刷完整的城市详情数据,确保数据一致性。
|
||||
- **按需加载与 Stagger 优化**:在加载城市详情时,前端会优先加载 Active 状态的图表,而处于 Background/非活动状态的图表则通过 staggered timer (按槽位索引延迟 300ms~1500ms) 异步获取,以分流请求峰值。
|
||||
- **前台恢复补齐**:浏览器标签页长时间在后台时,回来后会主动强刷可见图表 full detail,避免 SSE 被浏览器挂起后曲线落后。
|
||||
- **当地时间**:patch 中保留 `city_timezone` / `observed_at_utc`,前端按城市当地时间绘制横轴。
|
||||
|
||||
## 消息模板
|
||||
|
||||
@@ -83,6 +116,11 @@ Seoul / Incheon 16:03
|
||||
| `TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC` | 循环轮询间隔 | `60` |
|
||||
| `TELEGRAM_AIRPORT_PUSH_LANGUAGE` | 机场推送语言覆盖,可选 `both`/`en`/`zh` | `both` |
|
||||
| `KNMI_API_KEY` | KNMI API 密钥(阿姆斯特丹必填) | — |
|
||||
| `POLYWEATHER_EVENT_STORE` | 实时事件存储,可选 `redis`/`sqlite` | `sqlite` |
|
||||
| `POLYWEATHER_REDIS_URL` | Redis Stream 连接地址 | `redis://127.0.0.1:6379/0` |
|
||||
| `POLYWEATHER_REDIS_STREAM_KEY` | Redis Stream key | `stream:city_observation` |
|
||||
| `POLYWEATHER_REDIS_STREAM_MAXLEN` | Redis Stream 保留长度 | `50000` |
|
||||
| `POLYWEATHER_REDIS_REQUIRED` | Redis 不可用时是否启动失败 | `true` |
|
||||
|
||||
## 未接入城市
|
||||
|
||||
|
||||
+53
-40
@@ -1,6 +1,6 @@
|
||||
# PolyWeather API 文档(v1.8.0)
|
||||
# PolyWeather API 文档(v1.8.1)
|
||||
|
||||
最后更新:`2026-04-27`
|
||||
最后更新:`2026-05-28`
|
||||
|
||||
本文档描述当前对外可用 API 口径(`web/app.py` + `web/routes.py` + `frontend/app/api/*`)。
|
||||
|
||||
@@ -17,7 +17,9 @@ flowchart LR
|
||||
FE["Browser / Dashboard"] --> BFF["Next.js Route Handlers (/api/*)"]
|
||||
BFF --> API["FastAPI (/web/app.py + /web/routes.py)"]
|
||||
API --> WX["Weather Collector"]
|
||||
API --> ANA["DEB + Trend + Probability + Market Scan"]
|
||||
API --> ANA["DEB + Hourly Consensus + Probability + Market Scan"]
|
||||
API --> SSE["Realtime SSE (/api/events)"]
|
||||
SSE --> EVENT["Redis Stream / SQLite Event Log"]
|
||||
API --> PAY["Payment Intent + Event + Confirm Loops"]
|
||||
API --> OBS["healthz / system status / metrics"]
|
||||
```
|
||||
@@ -31,8 +33,27 @@ flowchart LR
|
||||
| `/api/city/{name}/summary` | GET | 轻量摘要 |
|
||||
| `/api/city/{name}/detail` | GET | 聚合详情(含 market_scan) |
|
||||
| `/api/history/{name}` | GET | 历史对账 |
|
||||
| `/api/scan/terminal/ai-city` | POST | 城市决策卡 AI 解读(非流式 JSON) |
|
||||
| `/api/scan/terminal/ai-city/stream` | POST | 城市决策卡 AI 解读(SSE 流式) |
|
||||
| `/api/events` | GET | SSE 实时观测事件流 |
|
||||
| `/api/internal/collector-patch` | POST | 采集器内部写入实时观测 patch |
|
||||
|
||||
### `GET /api/events`
|
||||
|
||||
浏览器实时图表入口,使用 `text/event-stream`。
|
||||
|
||||
参数:
|
||||
|
||||
- `cities=shanghai,hong kong`:可选,逗号分隔城市列表;为空表示订阅全部城市。
|
||||
- `since_revision=<int>`:可选,断线重连后从指定 revision 之后 replay。
|
||||
- `replay_limit=<int>`:可选,默认 `500`,后端会做上限保护。
|
||||
|
||||
事件:
|
||||
|
||||
- `connected`:连接建立,包含当前 `latest_revision`。
|
||||
- `city_observation_patch.v1`:标准实时观测 patch,包含城市、序列、温度、观测 UTC 时间、城市时区、revision。
|
||||
- `resync_required`:replay 窗口不足或事件存储不可用,前端应回到 HTTP snapshot 重建画面。
|
||||
- `heartbeat`:保活。
|
||||
|
||||
生产环境推荐 `POLYWEATHER_EVENT_STORE=redis`,以 Redis Stream 保存短窗口事件并支持多 worker fanout;本地或单进程可使用 SQLite event log。
|
||||
|
||||
### `GET /api/city/{name}/detail`
|
||||
|
||||
@@ -53,6 +74,7 @@ flowchart LR
|
||||
- `probabilities.engine / calibration_mode / calibration_version`
|
||||
- `probabilities.raw_mu / raw_sigma / calibrated_mu / calibrated_sigma`
|
||||
- `probabilities.shadow_distribution`
|
||||
- `deb.hourly_consensus / deb.hourly_path.base_source`
|
||||
- `intraday_meteorology.headline / confidence`
|
||||
- `intraday_meteorology.base_case_bucket / upside_bucket / downside_bucket`
|
||||
- `intraday_meteorology.next_observation_time`
|
||||
@@ -61,40 +83,6 @@ flowchart LR
|
||||
- `vertical_profile_signal.heating_setup / suppression_risk / trigger_risk / mixing_strength`
|
||||
- `taf.signal.peak_window / suppression_level / disruption_level / markers`
|
||||
|
||||
### `POST /api/scan/terminal/ai-city/stream`
|
||||
|
||||
城市决策卡使用该接口生成“AI 机场报文解读”。前端默认请求 SSE 流,流式展示机场报文解读片段,最终以 `final` 事件返回完整 payload。
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"city": "Buenos Aires",
|
||||
"force_refresh": false,
|
||||
"locale": "zh-CN"
|
||||
}
|
||||
```
|
||||
|
||||
SSE 事件:
|
||||
|
||||
- `progress`:阶段性状态,例如开始调用 AI、切换非流式重试。
|
||||
- `preview`:可显示的预览文本。
|
||||
- `delta`:模型流式文本增量,前端会从中提取机场报文解读片段。
|
||||
- `final`:完整 `AiCityForecastPayload`。
|
||||
|
||||
重点字段:
|
||||
|
||||
- `status`:通常为 `ready`;超时或降级时会带 `reason / reason_zh / reason_en`。
|
||||
- `cached`:是否命中后端 AI 缓存。
|
||||
- `degraded`:是否为降级结果。前端不会把 degraded 结果写入长期 localStorage,但会保留页面内存态,避免切换选项卡后空白。
|
||||
- `city_forecast.predicted_max`:AI 给出的预计最高温中枢候选。
|
||||
- `city_forecast.range_low / range_high`:AI 给出的天气区间。
|
||||
- `city_forecast.final_judgment_zh / final_judgment_en`:最终判断。
|
||||
- `city_forecast.metar_read_zh / metar_read_en`:机场报文解读。
|
||||
- `city_forecast.reasoning_zh / reasoning_en`:把 METAR、DEB、多模型集群与日内风险合并后的推理。
|
||||
- `city_forecast.model_cluster_note_zh / model_cluster_note_en`:模型集群说明。
|
||||
- `city_forecast.risks_zh / risks_en`:后续上修或下修触发条件。
|
||||
|
||||
### 城市决策卡市场层口径
|
||||
|
||||
- 前端会请求完整 `market_scan` / `all_buckets`,而不是只取 lite 结果。
|
||||
@@ -126,7 +114,7 @@ SSE 事件:
|
||||
|
||||
#### 2. `probabilities`
|
||||
|
||||
概率层基于 legacy 高斯分桶,以 DEB 融合预测 μ 和 ensemble spread σ 生成 1°C 粒度概率分布。
|
||||
概率层基于 legacy 高斯分桶,以 DEB 融合预测 `mu` 和 ensemble spread `sigma` 生成 1°C 粒度概率分布。前端图表将 legacy 高斯展示为水平概率温度带和 `mu` 参考线,不把概率分布渲染成时间序列曲线。
|
||||
|
||||
概率字段:
|
||||
|
||||
@@ -135,6 +123,22 @@ SSE 事件:
|
||||
- `distribution`:当天合约桶概率分布
|
||||
- `distribution_all`:包含外围桶的完整分布
|
||||
|
||||
#### 2.1 `deb.hourly_consensus`
|
||||
|
||||
DEB hourly consensus 是当前图表与峰值窗口的优先小时路径。
|
||||
|
||||
重点字段:
|
||||
|
||||
- `version`:当前为 `deb_hourly_consensus.v1`
|
||||
- `base_source`:`multi_model_hourly_deb_weights`
|
||||
- `times` / `temps`:城市当地日的小时路径
|
||||
- `model_weights`:DEB 权重折叠后的模型权重
|
||||
|
||||
说明:
|
||||
|
||||
- 该路径是预测曲线,不是实测来源。
|
||||
- 图表默认展示全天;“高温”视图仅根据该路径推导 peak-centric 窗口。
|
||||
|
||||
#### 3. `detail_depth`
|
||||
|
||||
`detail_depth` 用于区分轻量 detail 与完整 detail。前端如果发现:
|
||||
@@ -234,6 +238,13 @@ SSE 事件:
|
||||
|
||||
### 支付状态建议
|
||||
|
||||
`POST /api/payments/intents` 支持的关键字段:
|
||||
|
||||
- `plan_code`:套餐,例如 `pro_monthly`
|
||||
- `payment_mode`:`wallet` 或 `direct`
|
||||
- `chain_id`:可选;多链支付时前端传用户选择的链,例如 Polygon `137` 或 Ethereum `1`
|
||||
- `token_address`:可选;指定该链上的 USDC / USDC.e 合约地址
|
||||
|
||||
前端流程建议:
|
||||
|
||||
1. `POST /intents`
|
||||
@@ -291,6 +302,8 @@ SSE 事件:
|
||||
- `summary?force_refresh=true`:`Cache-Control: no-store`
|
||||
- 详情接口与支付接口:`no-store`
|
||||
- `METAR` / `TAF` / settlement current 由后端各自维护短 TTL 缓存
|
||||
- 实时事件层:`POLYWEATHER_EVENT_STORE=redis` 时使用 Redis Stream;未启用 Redis 时使用 SQLite `observation_patch_events` 作为 replay fallback
|
||||
- 前端终端图表:HTTP detail 仍是完整 snapshot,SSE patch 只做增量观测追加;长连接断开或后台恢复时前端会按 `since_revision` replay 或强刷 detail
|
||||
- 前端打开今日日内分析时,如果 full detail 或 market scan 正在同步,会先显示刷新锁,不展示可交互的旧内容
|
||||
- 城市决策卡 AI 解读前端缓存键为 `city + local_date + locale + METAR signature`;signature 优先使用原始 METAR,缺失时回退到报文时间、观测时间和温度
|
||||
- 城市决策卡 AI 解读使用两层前端缓存:页面内存缓存保存 loading / 流式进度 / 最终 payload,`localStorage` 保存最终成功 payload,默认 TTL 1 小时
|
||||
|
||||
+41
-11
@@ -1,6 +1,6 @@
|
||||
# 城市实时数据源总览
|
||||
|
||||
> 最后更新: 2026-05-26 | 51 城市
|
||||
> 最后更新: 2026-05-28 | 51 城市
|
||||
|
||||
## 数据源分级
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
| hong kong | CoWIN 6087 | ~1 min | cowin.hku.hk, 保良局陳守仁小學,前端图表默认展示 |
|
||||
| hong kong | HKO 官方 CSV | ~10 min | data.weather.gov.hk(文件名虽含 1min,实际 10min 一报) |
|
||||
| singapore | MSS 官方 API | ~1 min | api.data.gov.sg, 站号 S24 |
|
||||
| beijing | AMSC AWOS (ZBAA) | ~1 min | 中国 |
|
||||
| shanghai | AMSC AWOS (ZSPD) | ~1 min | 中国 |
|
||||
| guangzhou | AMSC AWOS (ZGGG) | ~1 min | 中国 |
|
||||
| chengdu | AMSC AWOS (ZUUU) | ~1 min | 中国 |
|
||||
| chongqing | AMSC AWOS (ZUCK) | ~1 min | 中国 |
|
||||
| wuhan | AMSC AWOS (ZHHH) | ~1 min | 中国 |
|
||||
| qingdao | AMSC AWOS (ZSQD) | ~1 min | 中国 |
|
||||
| beijing | AMSC AWOS (ZBAA) | 3 min | 中国 |
|
||||
| shanghai | AMSC AWOS (ZSPD) | 3 min | 中国 |
|
||||
| guangzhou | AMSC AWOS (ZGGG) | 3 min | 中国 |
|
||||
| chengdu | AMSC AWOS (ZUUU) | 3 min | 中国 |
|
||||
| chongqing | AMSC AWOS (ZUCK) | 3 min | 中国 |
|
||||
| wuhan | AMSC AWOS (ZHHH) | 3 min | 中国 |
|
||||
| qingdao | AMSC AWOS (ZSQD) | 3 min | 中国 |
|
||||
|
||||
### Tier 2 — 5 分钟高频 (MADIS)
|
||||
|
||||
@@ -111,11 +111,31 @@ ankara, istanbul, helsinki, amsterdam, paris
|
||||
- **Tier 4 城市**(仅 METAR):修正效果有限,不建议依赖
|
||||
|
||||
|
||||
## 实时事件与图表刷新逻辑
|
||||
|
||||
当前终端图表不是固定整图轮询,而是:
|
||||
|
||||
1. 首屏 / 切换城市时拉取 `/api/city/{city}/detail` 作为完整 snapshot。
|
||||
2. 可见图表连接 `/api/events?cities=...&since_revision=...&replay_limit=500`。
|
||||
3. 采集器产出 `city_observation_patch.v1` 后写入 Redis Stream(生产)或 SQLite event log(本地/兜底),再通过 SSE 推给浏览器。
|
||||
4. 前端把 patch 追加到已有实测序列,不显示 loading 遮罩;只有可见图表 2 分钟无 patch 时才启动 60 秒兜底刷新。
|
||||
5. 浏览器从后台切回前台时,前端会立即补一次 full detail,防止长时间挂页后图表落后。
|
||||
|
||||
频率取决于源头:
|
||||
|
||||
- AMOS / CoWIN / MSS:源头约 1 分钟,图表按 1 分钟粒度追加。
|
||||
- AMSC:中国跑道观测城市按 3 分钟采集,不再强制 60 秒刷新。
|
||||
- MADIS:源头约 5 分钟。
|
||||
- HKO / CWA / JMA / FMI / KNMI:源头约 10 分钟。
|
||||
- METAR-only 城市:按 METAR 可用频率和缓存 TTL,不伪装成 1 分钟实测。
|
||||
|
||||
所有图表横轴和 tooltip 时间均按城市当地时间展示,不按用户浏览器时区。
|
||||
|
||||
## 关于网站终端图表的数据曲线展示逻辑
|
||||
|
||||
### 1. 实测数据(默认全开,突出核心)
|
||||
|
||||
- **跑道全量展示**:北京、上海、广州、成都、重庆、武汉、首尔等城市的跑道实测数据,默认全量开启,无需手动勾选。
|
||||
- **跑道全量展示**:北京、上海、广州、成都、重庆、武汉、青岛、首尔、釜山等城市的跑道实测数据,默认全量开启,无需手动勾选。
|
||||
- **结算跑道高亮**:系统内置了各大机场的官方结算跑道映射。命中的跑道将被**重点强调**(加粗的青色实线 #009688,线宽 2.8),并标记为“[跑道号] 结算跑道”。具体的跑道映射如下:
|
||||
- 北京:19/01
|
||||
- 上海:17L/35R
|
||||
@@ -123,16 +143,26 @@ ankara, istanbul, helsinki, amsterdam, paris
|
||||
- 成都:02L/20R
|
||||
- 重庆:20R/02L
|
||||
- 武汉:04/22
|
||||
- 青岛:16/34
|
||||
- 首尔:15R/33L
|
||||
- 釜山:SR/SL
|
||||
- **辅助跑道弱化**:同一机场下的其他非结算跑道,也会同时展示,但采用较细的虚线(线宽 1.2)以作陪衬区分。
|
||||
- **其他实测展示**:所有城市的 METAR 报文曲线、官方气象站实测(如 Hong Kong / Lau Fau Shan 的香港天文台曲线)均默认展示。
|
||||
- **单跑道机场去重**:釜山只有 `SR/SL` 跑道曲线时,不再额外展示 AMOS 聚合线,避免两条线语义重复。
|
||||
- **香港参考曲线**:Hong Kong 默认展示 CoWIN `6087`(保良局陈守仁小学)1 分钟参考站曲线;HKO 10 分钟实测作为官方气象层保留。
|
||||
- **其他实测展示**:所有城市的 METAR 报文曲线、官方气象站实测(如 Shenzhen / Lau Fau Shan 的 HKO 自动站、Taipei 的 CWA)均默认展示。
|
||||
|
||||
### 2. 核心预测数据(默认展示)
|
||||
|
||||
- **DEB 模型融合**:作为平台核心的高精度智能融合预测曲线,默认始终展示给用户。
|
||||
- **DEB 模型融合**:作为平台核心的智能融合预测曲线,默认始终展示给用户。DEB 是预测,不参与“实测接近峰值”的视觉预警计算。
|
||||
- **DEB hourly consensus**:图表优先使用 `deb_hourly_consensus.v1` 的小时路径展示 DEB 曲线和推导“高温”窗口;如果缺失才回退旧的 hourly + DEB offset 路径。
|
||||
|
||||
### 3. 多模型原始数据(默认隐藏,按需自选)
|
||||
|
||||
- **保持整洁**:为了防止图表线缆过于杂乱,各大原始模型(ECMWF, GFS, ICON, GEM 等)的数据曲线在初次加载时**默认隐藏**。
|
||||
- **特例**:仅针对巴黎(Paris),由于其 AROME HD 是高精度的 15 分钟级临近预报,极具参考价值,因此默认开启。
|
||||
- **自由交互**:用户可通过图表底部的图例交互按钮,随时自由勾选、叠加或隐藏任意所需的数据曲线。
|
||||
|
||||
### 4. 高斯概率图层
|
||||
|
||||
- legacy 高斯概率不会作为时间序列曲线展示。
|
||||
- 图表上只渲染概率温度带和 `mu` 参考线,帮助用户判断当前实测距概率中心和高概率区域的关系。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 商业化说明(Production)
|
||||
|
||||
最后更新:`2026-04-18`
|
||||
最后更新:`2026-05-30`
|
||||
|
||||
## 1. 定位
|
||||
|
||||
@@ -8,8 +8,8 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
|
||||
核心价值:
|
||||
|
||||
- 观测优先(METAR / 机场主站 / 明确官方站点;MGM、NMC、JMA、AMOS 等作为增强层)
|
||||
- 结算导向(DEB + 校准概率桶)
|
||||
- 观测优先(METAR / 机场主站 / 明确官方站点;MGM、JMA、AMOS、AMSC、CoWIN、CWA 等作为增强层)
|
||||
- 结算导向(DEB hourly consensus + 校准概率桶)
|
||||
- 气象判断优先(证据链、失效条件、下一观测点)
|
||||
- 市场映射(行情对照 + 错价雷达),但不把交易建议放在第一层产品承诺
|
||||
|
||||
@@ -18,12 +18,16 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
| 能力 | 状态 | 备注 |
|
||||
| :-- | :-- | :-- |
|
||||
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
|
||||
| 订阅套餐(Pro 月付) | 已上线 | `10 USDC / 30天` |
|
||||
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
|
||||
| 订阅套餐(Pro 月付 / 季度) | 已上线 | `29.9 USDC / 30天`;`79.9 USDC / 90天` |
|
||||
| 邀请积分 | 已上线 | 被邀请人首月 `20U`;邀请人每个有效付费邀请 `+3500` 积分 |
|
||||
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U`;邀请首月价不叠加积分抵扣 |
|
||||
| 合约支付 | 已上线 | Polygon,USDC + USDC.e |
|
||||
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
|
||||
| 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 |
|
||||
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop,按 intent.chain_id 查链 |
|
||||
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
|
||||
| 私有频道推送 | 已上线 | 可拆分业务频道 |
|
||||
| 实时终端 | 已上线 | SSE Patch + Redis Stream replay |
|
||||
| Telegram 双语推送 | 已上线 | 中英文一起推送,结算跑道端点口径 |
|
||||
|
||||
## 3. 权限模型(当前)
|
||||
|
||||
@@ -33,14 +37,17 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
- 今日日内深度分析(含高温时段)
|
||||
- 专业气象结论条、证据链、失效条件、确认条件
|
||||
- 概率分布层(基于 DEB 融合 + 高斯分桶)
|
||||
- 实时终端多城市图表(跑道/官方站点/DEB/概率带)
|
||||
- 历史对账 + 未来日期分析
|
||||
- 全平台智能气象推送
|
||||
|
||||
## 4. 收费与积分规则(默认)
|
||||
|
||||
- 套餐:`pro_monthly`(10 USDC / 30 天)
|
||||
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC
|
||||
- 实付下限:2 USDC(当积分满额时)
|
||||
- 套餐:`pro_monthly`(29.9 USDC / 30 天)、`pro_quarterly`(79.9 USDC / 90 天)
|
||||
- 邀请首月:被邀请人首次月付 20 USDC
|
||||
- 邀请奖励:邀请人 +3500 积分,每月最多 10 个有效付费邀请
|
||||
- 抵扣:500 积分抵 1 USDC;月付最高抵 3 USDC,季度最高抵 8 USDC
|
||||
- 积分来源:仅通过有效付费邀请和后台人工补发,不再通过 Telegram 群发言发放
|
||||
|
||||
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
|
||||
|
||||
@@ -62,6 +69,7 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
5. 安全边界:敏感配置不进仓库。
|
||||
6. 数据口径:机场市场按 METAR / 机场主站解释;Wunderground 只可描述为历史页面或参考入口,不描述成“站”。
|
||||
7. 加载口径:日内分析和右侧详情在 full detail 未补齐前必须显示同步状态,不能把旧缓存伪装成完整付费内容。
|
||||
8. 实时口径:终端图表应优先保留已有曲线并无痛合并 patch,不用 loading 遮罩覆盖已可用数据。
|
||||
|
||||
## 7. 后续路线
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 配置与密钥管理(中文)
|
||||
|
||||
最后更新:`2026-05-28`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
PolyWeather 的环境变量很多,但不是所有变量都属于同一层级。
|
||||
@@ -79,6 +81,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `POLYWEATHER_RUNTIME_DATA_DIR`
|
||||
- `POLYWEATHER_DB_PATH`
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE`
|
||||
- `POLYWEATHER_EVENT_STORE`
|
||||
|
||||
前端:
|
||||
|
||||
@@ -104,7 +107,10 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION`
|
||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE`
|
||||
- `POLYWEATHER_EVENT_STORE`
|
||||
- `POLYWEATHER_REDIS_REQUIRED`
|
||||
- `POLYWEATHER_PAYMENT_ENABLED`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
|
||||
- `POLYGON_WALLET_WATCH_ENABLED`
|
||||
- `TELEGRAM_ALERT_PUSH_ENABLED`
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
|
||||
@@ -126,7 +132,11 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC`
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
|
||||
- `TAF_CACHE_TTL_SEC`
|
||||
- `POLYWEATHER_REDIS_URL`
|
||||
- `POLYWEATHER_REDIS_STREAM_KEY`
|
||||
- `POLYWEATHER_REDIS_STREAM_MAXLEN`
|
||||
|
||||
策略:
|
||||
|
||||
@@ -215,6 +225,10 @@ TELEGRAM_CHAT_ID=...
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
POLYWEATHER_STATE_STORAGE_MODE=sqlite
|
||||
POLYWEATHER_EVENT_STORE=redis
|
||||
POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0
|
||||
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
|
||||
POLYWEATHER_REDIS_REQUIRED=true
|
||||
UID=1000
|
||||
GID=1000
|
||||
POLYWEATHER_AUTH_ENABLED=true
|
||||
@@ -236,13 +250,6 @@ TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
||||
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
|
||||
POLYWEATHER_SCAN_AI_ENABLED=false
|
||||
POLYWEATHER_SCAN_AI_API_KEY=...
|
||||
POLYWEATHER_SCAN_AI_PROVIDER=mimo
|
||||
POLYWEATHER_SCAN_AI_PROVIDER_LABEL=MiMo
|
||||
POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
POLYWEATHER_SCAN_AI_MODEL=mimo-v2.5-pro
|
||||
POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
|
||||
```
|
||||
|
||||
说明:
|
||||
@@ -252,15 +259,43 @@ POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
|
||||
- `POLYWEATHER_RUNTIME_DATA_DIR` 建议放在仓库外,例如 `/var/lib/polyweather`。
|
||||
- `docker-compose.yml` 会把这个目录同时挂载到容器内的 `/var/lib/polyweather` 和 `/app/data`,兼容现有缓存与 SQLite 路径。
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`。
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`。
|
||||
- `POLYWEATHER_EVENT_STORE=redis` 表示实时观测 patch 使用 Redis Stream 做短窗口 replay 和多 worker fanout;本地或单进程可改为 `sqlite`。
|
||||
- `POLYWEATHER_REDIS_REQUIRED=true` 表示 Redis 不可用时后端启动失败,避免生产环境广播不可 replay 的实时事件;开发环境可设为 `false` 允许回退 SQLite。
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持默认链的逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`。
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON` 用于多链支付,例如同时支持 Polygon 和 Ethereum 主网 USDC。
|
||||
- 机器人市场监控包含 `关键提醒` 与 `关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
|
||||
- `POLYWEATHER_SCAN_AI_*` 走 OpenAI-compatible `/chat/completions`;当前临时默认 MiMo,可用 `POLYWEATHER_SCAN_AI_BASE_URL` 与 `POLYWEATHER_SCAN_AI_MODEL` 随时切回其他兼容 provider。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
|
||||
|
||||
说明:
|
||||
|
||||
- 这层只负责把结构化信号改写成短摘要,不替代真实模型、机场锚点和结算逻辑。
|
||||
|
||||
### 6.3 支付多链配置示例
|
||||
|
||||
当前生产推荐:
|
||||
|
||||
- 默认链:Polygon `chain_id=137`,继续承载 checkout 合约支付。
|
||||
- 补充链:Ethereum Mainnet `chain_id=1`,正式支持 USDC 直转确认。
|
||||
- 前端创建支付 intent 时会提交用户选择的 `chain_id`;后端确认时按 intent 的链和 token 查询对应 RPC。
|
||||
|
||||
```env
|
||||
POLYWEATHER_PAYMENT_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
|
||||
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com","https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<polygon_checkout_contract>
|
||||
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=0x<treasury_or_receiver_wallet>
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<polygon_checkout_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>","is_default":true},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x<treasury_or_receiver_wallet>","supports_contract_checkout":false,"supports_direct_transfer":true,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}]
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- `POLYWEATHER_PAYMENT_CHAIN_ID` 只是默认链,不代表只支持这一条链。
|
||||
- `POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON` 里每个 token 必须有明确 `chain_id`。
|
||||
- Ethereum 行如果没有部署 checkout 合约,必须设置 `supports_contract_checkout=false`,前端会显示手动转账并阻止钱包合约支付。
|
||||
- 私有 RPC URL 带 API key 时应放入真实 `.env` 或密钥管理,不要提交。
|
||||
|
||||
### 6.5 机器人市场监控建议配置
|
||||
|
||||
这套配置围绕市场本身做两类推送:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 前端部署配置(Vercel)
|
||||
|
||||
最后更新:`2026-05-29`
|
||||
|
||||
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。
|
||||
|
||||
## 一、部署目标
|
||||
@@ -16,6 +18,12 @@
|
||||
2. Next `/api/*` -> `POLYWEATHER_API_BASE_URL`
|
||||
3. FastAPI 后端 -> 分析 / 支付 / 鉴权服务
|
||||
|
||||
实时图表同样走 Next Route Handler / rewrite:
|
||||
|
||||
1. 浏览器 `EventSource` -> `/api/events?cities=...&since_revision=...`
|
||||
2. Next 转发到 FastAPI `/api/events`
|
||||
3. FastAPI 从 Redis Stream / SQLite event log replay 后进入 live SSE
|
||||
|
||||
## 二、Vercel 项目设置
|
||||
|
||||
在 Vercel 导入 GitHub 仓库后,使用下面的设置:
|
||||
@@ -119,12 +127,15 @@ NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
|
||||
1. 用户点击支付前,前端会重新请求 `/api/payments/config`
|
||||
2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址
|
||||
3. 若后端返回的 `tx_payload.to` 与最新 `receiver_contract` 不一致,会直接阻断支付
|
||||
4. 多链支付时,前端会展示后端返回的网络列表,并把用户选择的 `chain_id` 传给后端创建 intent
|
||||
5. Ethereum 主网 USDC 当前走手动直转确认,前端不会把它当成 Polygon checkout 合约支付
|
||||
|
||||
这层防护的目的,是降低以下事故概率:
|
||||
|
||||
- 用户使用长期未刷新的旧标签页
|
||||
- 命中旧 deployment URL
|
||||
- 页面本地状态残留旧收款地址
|
||||
- 用户在钱包默认网络(例如 Ethereum)付款,但系统按 Polygon intent 查账
|
||||
|
||||
如果你变更过支付收款地址,建议同步执行:
|
||||
|
||||
@@ -193,6 +204,7 @@ Vercel 部署前至少确认:
|
||||
4. 如果启用鉴权,Supabase redirect URL 已包含前端域名
|
||||
5. `GET /api/payments/config` 返回的是当前最新地址,而不是旧收款合约
|
||||
6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在 Vercel 与后端同时配置
|
||||
7. 确认 `/api/events` 没有被 CDN 缓存或压缩成普通 JSON;它必须保持 `text/event-stream`
|
||||
|
||||
## 九、常见问题
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
本文档记录 PolyWeather 当前开放模型接入、区域覆盖差异,以及 DEB 在新增模型后的计权规则。
|
||||
|
||||
最后更新:`2026-04-18`
|
||||
最后更新:`2026-05-28`
|
||||
|
||||
## 1. 接入方式
|
||||
|
||||
@@ -165,11 +165,73 @@ raw current_forecasts
|
||||
-> 按模型家族去重
|
||||
-> 历史 MAE 统计
|
||||
-> MAE 倒数权重
|
||||
-> 输出 blended_high + weights_info
|
||||
-> 输出 raw blended_high
|
||||
-> recent signed-bias correction
|
||||
-> 输出 production prediction + raw_prediction + version
|
||||
```
|
||||
|
||||
当 `weights_info` 出现 `家族去重`,表示当前输入模型数量多于 DEB 实际入模数量,系统已先折叠同家族模型。
|
||||
|
||||
### 5.1 DEB hourly consensus
|
||||
|
||||
当前图表和峰值窗口优先使用 `deb_hourly_consensus.v1`。
|
||||
|
||||
入口:
|
||||
|
||||
- `src/analysis/deb_hourly_consensus.py`
|
||||
- `web/analysis_service.py`
|
||||
- `src/analysis/trend_engine.py`
|
||||
|
||||
处理逻辑:
|
||||
|
||||
```text
|
||||
multi_model hourly curves
|
||||
-> 按 DEB 可用模型和家族去重口径选择候选
|
||||
-> 使用 DEB 权重生成逐小时 consensus
|
||||
-> 输出 deb.hourly_consensus
|
||||
-> 图表 DEB Forecast 与 peak-window 逻辑优先读取该路径
|
||||
```
|
||||
|
||||
关键口径:
|
||||
|
||||
- `deb_hourly_consensus.v1` 是预测曲线,不是实测曲线。
|
||||
- 它只用于日内形状、峰值窗口和 DEB Forecast 展示;视觉预警和“接近峰值”状态必须优先读取实测/跑道/官方站点当前值与今日实测高点。
|
||||
- 如果多模型小时路径缺失,系统才回退旧的 `hourly_plus_deb_offset`。
|
||||
|
||||
### 5.2 版本化预测与偏差校正
|
||||
|
||||
DEB 原始融合逻辑不推倒重写,`calculate_dynamic_weights(...)` 仍作为 raw baseline。线上生产入口使用 `calculate_deb_prediction(...)` 包装 raw baseline,并在有足够历史样本时追加城市级 recent signed-bias correction。
|
||||
|
||||
当前版本:
|
||||
|
||||
- `deb_v1_raw`:原始 DEB,最近模型误差倒数加权后的融合值。
|
||||
- `deb_v1_recent_bias_corrected`:在 raw DEB 上叠加最近已结算样本的有符号偏差校正。偏差使用 shrinkage,样本少时自动收缩,避免单日异常过拟合。
|
||||
|
||||
API `deb` payload 会保留:
|
||||
|
||||
- `prediction`:当前生产使用值。
|
||||
- `raw_prediction`:未经 recent-bias correction 的原始 DEB。
|
||||
- `version`:生产值对应的 DEB 版本。
|
||||
- `bias_adjustment` / `bias_samples`:城市级偏差校正幅度与训练样本数。
|
||||
- `intraday_adjustment`:网页 full detail 中额外的日内观测路径修正,仅用于当前日实时展示。
|
||||
|
||||
版本化回测命令:
|
||||
|
||||
```bash
|
||||
python scripts/backtest_deb_versions.py \
|
||||
--output-json data/deb_backtest_latest.json \
|
||||
--output-csv data/deb_backtest_latest.csv
|
||||
```
|
||||
|
||||
本地 `data/polyweather.db` 于 2026-05-27 的回测样本显示:
|
||||
|
||||
| 版本 | 样本 | MAE | RMSE | Bias | 结算桶命中率 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| `deb_v1_raw` | 1272 | 1.626 | 2.602 | -0.633 | 25.8% |
|
||||
| `deb_v1_recent_bias_corrected` | 1272 | 1.499 | 2.542 | +0.263 | 31.4% |
|
||||
|
||||
该结果只证明当前历史样本上的离线表现改善;后续仍需要持续用版本化回测追踪不同城市、季节与结算源下的漂移。
|
||||
|
||||
## 6. 前端展示
|
||||
|
||||
网页的模型展示读取:
|
||||
@@ -177,6 +239,7 @@ raw current_forecasts
|
||||
- `multi_model`
|
||||
- `multi_model_daily`
|
||||
- `source_forecasts.open_meteo_multi_model.model_metadata`
|
||||
- `deb.hourly_consensus`
|
||||
|
||||
显示分组:
|
||||
|
||||
@@ -197,6 +260,13 @@ raw current_forecasts
|
||||
|
||||
区域模型不覆盖时不显示空模型。
|
||||
|
||||
终端图表默认:
|
||||
|
||||
- 展示“全天”视图,按城市当地日 00:00-23:59 和真实观测时间绘制。
|
||||
- 可选“高温”视图,窗口由 `deb_hourly_consensus.v1` 的峰值区域推导。
|
||||
- DEB Forecast 使用橙色预测曲线;实测/跑道/官方站点曲线独立展示,不把 DEB 当成实测。
|
||||
- legacy 高斯概率显示为水平温度带和 `mu` 参考线,不参与时间序列曲线。
|
||||
|
||||
### 6.1 “来源 Open-Meteo”是什么意思
|
||||
|
||||
前端中的 `来源 - Open-Meteo` 表示本次多模型数据通过 Open-Meteo model API 归一化接入。
|
||||
@@ -228,6 +298,7 @@ raw current_forecasts
|
||||
|
||||
- `tests/test_multi_model_sources.py`
|
||||
- `tests/test_deb_model_family.py`
|
||||
- `tests/test_deb_evaluation_upgrade.py`
|
||||
|
||||
重点覆盖:
|
||||
|
||||
@@ -237,3 +308,4 @@ raw current_forecasts
|
||||
- DEB 家族去重
|
||||
- 历史不足时的去重等权
|
||||
- 有历史 MAE 时的去重动态权重
|
||||
- DEB raw/corrected 版本化评估、recent-bias correction、JSON/CSV 回测输出
|
||||
|
||||
+17
-1
@@ -1,6 +1,6 @@
|
||||
# 外部监控与告警说明
|
||||
|
||||
最后更新:`2026-04-18`
|
||||
最后更新:`2026-05-28`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- Relay 把告警推到运营频道
|
||||
- Grafana 展示趋势面板
|
||||
- 巡检脚本补健康检查
|
||||
- 关注 `/api/events` 长连接与 realtime event store 是否正常 replay
|
||||
|
||||
## 2. 组件
|
||||
|
||||
@@ -89,6 +90,14 @@ POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
|
||||
- Source Requests by Outcome
|
||||
- Source Error Rate (15m)
|
||||
|
||||
实时事件层建议额外观察:
|
||||
|
||||
- Redis Stream latest revision
|
||||
- Redis 连接状态
|
||||
- SQLite fallback 是否被启用
|
||||
- SSE active connection count
|
||||
- `resync_required` 出现频率
|
||||
|
||||
## 7. 巡检脚本
|
||||
|
||||
手动巡检:
|
||||
@@ -102,6 +111,7 @@ python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
|
||||
- `/healthz`
|
||||
- `/api/system/status`
|
||||
- `/metrics`
|
||||
- `/api/events`(手动验证时查看 `connected` / `heartbeat` / replay 事件)
|
||||
|
||||
任何一项失败都会非零退出,适合挂到 crontab 或 systemd timer。
|
||||
|
||||
@@ -130,6 +140,11 @@ python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
|
||||
- 多日预报是否只返回当天单卡
|
||||
- 日内分析 full detail / market scan 是否仍在同步
|
||||
- 右侧详情面板是否正在用同步占位卡提示用户
|
||||
- 实时事件状态:
|
||||
- event store 类型(Redis / SQLite)
|
||||
- latest revision
|
||||
- Redis 是否连通
|
||||
- 是否处于 `degraded_from=redis` fallback
|
||||
|
||||
这意味着:
|
||||
|
||||
@@ -180,3 +195,4 @@ npm run build
|
||||
- 更细粒度支付指标
|
||||
- 按城市/来源拆分的业务 SLA
|
||||
- 按城市拆分的前端补齐耗时与 stale-detail 告警
|
||||
- Redis Stream 长度、内存与 replay gap 告警
|
||||
|
||||
+49
-37
@@ -1,53 +1,65 @@
|
||||
# 外部服务依赖总览
|
||||
|
||||
最后更新:`2026-05-23`
|
||||
最后更新:`2026-05-29`
|
||||
|
||||
项目调用了 20 个外部服务,按状态分为三类。
|
||||
项目调用外部天气、鉴权、支付和实时事件服务。原则是:核心链路必须有明确健康检查;可选数据源不可拖垮已可用城市;实时事件层可从 Redis Stream 降级到 SQLite event log。
|
||||
|
||||
## 核心(必须有,挂了服务不可用)
|
||||
|
||||
| 服务 | 用途 | 状态 |
|
||||
| ---------------------- | --------------------- | ---- |
|
||||
| Open-Meteo | 52 城天气预报 | ✅ |
|
||||
| AviationWeather (NOAA) | METAR/TAF 航空观测 | ✅ |
|
||||
| MADIS (NOAA) | 美国 5 分钟高频观测 | ✅ |
|
||||
| Supabase | 用户认证 + 订阅 | ✅ |
|
||||
| Telegram Bot API | Bot 消息 + 群成员检查 | ✅ |
|
||||
| KNMI | Amsterdam 10 分钟观测 | ✅ |## 国家气象源(特定城市必须)
|
||||
| 服务 | 用途 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| Open-Meteo | 51 城天气预报、多模型小时曲线、DEB hourly consensus 输入 | ✅ |
|
||||
| AviationWeather (NOAA) | METAR / TAF 航空观测 | ✅ |
|
||||
| MADIS (NOAA) | 美国机场 5 分钟高频观测 | ✅ |
|
||||
| Supabase | 用户认证、订阅状态、会员恢复 | ✅ |
|
||||
| Telegram Bot API | Bot 消息、群成员检查、双语跑道推送 | ✅ |
|
||||
| Redis | `city_observation_patch.v1` Stream、SSE replay、多 worker fanout | ✅ |
|
||||
| SQLite | 运行态数据库、支付审计、实时事件 fallback | ✅ |
|
||||
|
||||
| 服务 | 城市 | 状态 |
|
||||
| -------------------- | --------------------- | ----------- |
|
||||
| JMA (日本) | Tokyo | ✅ |
|
||||
| KMA + AMOS (韩国) | Seoul, Busan | ✅ |
|
||||
| AMSC AWOS (中国) | 北京/上海/广州等 6 城 | ✅ |
|
||||
| MGM (土耳其) | Ankara, Istanbul | ✅ |
|
||||
| FMI (芬兰) | Helsinki | ✅ |
|
||||
| HKO (香港) | Hong Kong | ✅ |
|
||||
| CWA (台湾) | Taipei | ✅ |
|
||||
| NMC (中国) | 国内城市 fallback | ✅ |
|
||||
| Singapore MSS | Singapore | ✅ |
|
||||
| IMGW (波兰) | Warsaw | ⚠️ 未配 key |
|
||||
| Russia pogodaiklimat | Moscow | ❌ 已移除 |
|
||||
## 国家气象源(特定城市必须)
|
||||
|
||||
| 服务 | 城市 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| JMA AMeDAS | Tokyo | ✅ |
|
||||
| AMOS (韩国) | Seoul, Busan 跑道传感器 | ✅ |
|
||||
| AMSC AWOS (中国) | 北京、上海、广州、成都、重庆、武汉、青岛跑道端点气温 | ✅ |
|
||||
| MGM (土耳其) | Ankara, Istanbul | ✅ |
|
||||
| FMI (芬兰) | Helsinki | ✅ |
|
||||
| KNMI (荷兰) | Amsterdam | ✅(需 key) |
|
||||
| CoWIN 6087 (香港) | Hong Kong 1 分钟参考站 | ✅ |
|
||||
| HKO (香港) | Hong Kong / Shenzhen / Lau Fau Shan 10 分钟官方气象层 | ✅ |
|
||||
| CWA (台湾) | Taipei | ✅ |
|
||||
| Singapore MSS | Singapore | ✅ |
|
||||
| IMS Lod (以色列) | Tel Aviv | ✅ |
|
||||
| AEROWEB / AROME HD | Paris | ✅ |
|
||||
| NMC (中国) | 国内城市 fallback | ✅ |
|
||||
| IMGW (波兰) | Warsaw | ⚠️ 未配 key |
|
||||
|
||||
## 可选 / 已禁用
|
||||
|
||||
| 服务 | 用途 | 状态 |
|
||||
| -------------- | ------------- | ----------- |
|
||||
| 服务 | 用途 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| OpenWeatherMap | 天气 fallback | ⚠️ 未配 key |
|
||||
| VisualCrossing | 历史天气 | ⚠️ 未配 key |
|
||||
| Meteoblue | 天气预报 | ❌ 已移除 |
|
||||
| SynopticData | 美国站点观测 | ⚠️ 未配 key |
|
||||
| VisualCrossing | 历史天气 | ⚠️ 未配 key |
|
||||
| SynopticData | 美国站点观测 | ⚠️ 未配 key |
|
||||
| Meteoblue | 天气预报 | ❌ 已移除 |
|
||||
| Russia pogodaiklimat | Moscow 历史源 | ❌ 已移除 |
|
||||
| Groq | AI commentary | ❌ 已移除 |
|
||||
|
||||
## AI / 其他
|
||||
## AI / 支付 / 前端
|
||||
|
||||
| 服务 | 用途 | 状态 |
|
||||
| ----------------- | ---------------- | ----------- |
|
||||
| 服务 | 用途 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 |
|
||||
| DeepSeek | AI fallback | - 备用 |
|
||||
| Groq | AI commentary | ❌ 已移除 |
|
||||
| Polygon RPC | 链上支付 | ✅ |
|
||||
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key |
|
||||
| DeepSeek | AI fallback | 备用 |
|
||||
| Polygon RPC | checkout 合约支付、Polygon USDC / USDC.e 自动确认 | ✅ |
|
||||
| Ethereum RPC | Ethereum 主网 USDC 直转确认 | ✅(启用多链支付时必须) |
|
||||
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key 时钱包入口降级 |
|
||||
|
||||
## 合计
|
||||
## 运维口径
|
||||
|
||||
15 个在用,3 个可选/未配置,3 个已移除。
|
||||
- 生产实时事件推荐:`POLYWEATHER_EVENT_STORE=redis` + `POLYWEATHER_REDIS_URL=redis://polyweather_redis:6379/0`。
|
||||
- 本地或单进程兜底:`POLYWEATHER_EVENT_STORE=sqlite`。
|
||||
- Redis 只负责短窗口 replay 与多 worker fanout,不是长期天气历史库。
|
||||
- DEB hourly consensus 依赖 Open-Meteo 多模型小时曲线;若上游限流,图表应保留已有 snapshot 和实测 patch,不把缺失模型误报为实测缺失。
|
||||
- 支付多链确认依赖 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`;如果启用 Ethereum 主网 USDC,必须配置 `chain_id=1` 的 RPC,否则用户提交 Ethereum tx hash 后无法自动确认。
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Supabase + 登录 + 支付接入说明(v1.8.0)
|
||||
# Supabase + 登录 + 支付接入说明(v1.8.1)
|
||||
|
||||
最后更新:`2026-03-14`
|
||||
最后更新:`2026-05-29`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- 前端支持 Google 一键登录 + 邮箱注册/登录。
|
||||
- 后端支持 Supabase JWT 鉴权。
|
||||
- 支持 Polygon 合约支付(USDC / USDC.e)并自动确认开通订阅。
|
||||
- 支持 Polygon 合约支付(USDC / USDC.e)和 Ethereum 主网 USDC 直转支付,并自动确认开通订阅。
|
||||
|
||||
## 2. Supabase 控制台配置
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
在 Supabase SQL Editor 执行:
|
||||
|
||||
- `scripts/supabase/schema.sql`
|
||||
- 既有生产项目遇到 Disk IO Budget 告警时,再执行 `scripts/supabase/io_budget_indexes.sql`
|
||||
|
||||
会创建支付与订阅相关表:
|
||||
|
||||
@@ -33,6 +34,15 @@
|
||||
- `payment_intents`
|
||||
- `payment_transactions`
|
||||
|
||||
### 3.1 Disk IO 告警处理
|
||||
|
||||
如果 Supabase 提示项目正在耗尽 Disk IO Budget,先在 SQL Editor 执行:
|
||||
|
||||
1. `scripts/supabase/io_budget_indexes.sql`
|
||||
2. `scripts/supabase/disk_io_diagnostics.sql`
|
||||
|
||||
第一个脚本会把热查询索引收敛为更低写放大的部分索引,移除已被唯一约束覆盖或无热路径使用的冗余索引,并对相关表执行 `ANALYZE`。第二个脚本用于查看表扫描、dead tuples、索引命中和 `pg_stat_statements` 中的高读块 SQL。生产执行后,继续观察 Supabase daily/hourly Disk IO 图表,确认请求延迟和 IO wait 是否下降。
|
||||
|
||||
## 4. 环境变量
|
||||
|
||||
### 4.1 前端(Vercel / frontend/.env.local)
|
||||
@@ -66,30 +76,38 @@ SUPABASE_SERVICE_ROLE_KEY=
|
||||
SUPABASE_HTTP_TIMEOUT_SEC=8
|
||||
|
||||
POLYWEATHER_PAYMENT_ENABLED=true
|
||||
# 默认链仍是 Polygon,因为当前 checkout 合约部署在 Polygon。
|
||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<receiver_contract>
|
||||
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=0x<treasury_or_receiver_wallet>
|
||||
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
|
||||
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
|
||||
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
|
||||
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
|
||||
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
|
||||
|
||||
# 支持双币种(示例)
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_e","symbol":"USDC.e","name":"USDC.e (PoS)","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver>","is_default":true},{"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver>"}]
|
||||
# 支持多链多币种(示例)
|
||||
# Ethereum 主网 USDC 当前建议只开 direct transfer,不走 Polygon checkout 合约。
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>","is_default":true},{"code":"usdc_e_polygon","symbol":"USDC.e","name":"USDC.e on Polygon","chain_id":137,"chain_code":"polygon","chain_name":"Polygon","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver_contract>","direct_receiver_address":"0x<treasury_or_receiver_wallet>"},{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum","chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet","address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6,"direct_receiver_address":"0x<treasury_or_receiver_wallet>","supports_contract_checkout":false,"supports_direct_transfer":true,"confirmations":2,"explorer_tx_url":"https://etherscan.io/tx/{tx_hash}"}]
|
||||
|
||||
# 套餐(当前只保留月付)
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"5","duration_days":30}}
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
|
||||
# 套餐
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"29.9","duration_days":30},"pro_quarterly":{"plan_id":102,"amount_usdc":"79.9","duration_days":90}}
|
||||
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly,pro_quarterly
|
||||
|
||||
# 积分抵扣
|
||||
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
|
||||
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC_BY_PLAN_JSON={"pro_monthly":3,"pro_quarterly":8}
|
||||
|
||||
# 支付自动补单
|
||||
POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_CONFIRM_LOOP_INTERVAL_SEC=20
|
||||
POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_INTERVAL_SEC=300
|
||||
POLYWEATHER_PAYMENT_CONFIRM_LOOP_IDLE_AFTER_EMPTY_CYCLES=3
|
||||
```
|
||||
|
||||
## 5. 验证步骤
|
||||
@@ -100,10 +118,16 @@ POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
|
||||
- `POST /api/payments/wallets/challenge`
|
||||
- `POST /api/payments/wallets/verify`
|
||||
4. 支付流程:
|
||||
- `POST /api/payments/intents`
|
||||
- `POST /api/payments/intents`;多链时前端会带 `chain_id` 和 `token_address`
|
||||
- 发链上交易
|
||||
- `POST /api/payments/intents/{id}/submit`
|
||||
- `POST /api/payments/intents/{id}/confirm`
|
||||
5. 若前端显示 pending,轮询:
|
||||
- `GET /api/payments/intents/{id}`
|
||||
6. 确认订阅:`/api/auth/me` 返回 `subscription_active=true`。
|
||||
|
||||
## 6. 多链支付口径
|
||||
|
||||
- Polygon 是默认链,仍支持钱包合约支付和手动直转确认。
|
||||
- Ethereum 主网 USDC 是正式支付链路,但当前建议只走手动直转:用户选择 Ethereum 后,前端展示收款钱包、金额、代币合约和 Etherscan 链接,用户提交 tx hash 后后端按 `intent.chain_id=1` 查询 Ethereum RPC。
|
||||
- 不要只依赖前端文案阻止错链付款;后端必须把每笔 intent 的 `chain_id`、`token_address`、`receiver_address` 落库,并在确认时按该链校验 `Transfer` 事件。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 技术债与工程待办(v1.8.0)
|
||||
# 技术债与工程待办(v1.8.1)
|
||||
|
||||
最后更新:`2026-05-10`
|
||||
|
||||
@@ -60,7 +60,7 @@ flowchart TD
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/首次消息奖励/欢迎奖励/周排名奖励/周参与奖/手动补分) |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(有效付费邀请/后台人工补发/积分抵扣消费) |
|
||||
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
|
||||
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 审查日期:2026-06 | 视角:系统架构师 | 范围:完整数据采集→分析→API→前端状态
|
||||
>
|
||||
> **修复状态:8/8 已完成**
|
||||
> **修复状态:9/9 已完成;最后校准:2026-05-28**
|
||||
|
||||
## 一、数据架构总览
|
||||
|
||||
@@ -13,9 +13,8 @@
|
||||
Open-Meteo (预报+多模型) ─┐
|
||||
METAR/TAF (航空气象) ─┤
|
||||
NWS (美国) / MGM (土耳其) ─┤
|
||||
JMA/AMOS/NMC/HKO/CWA ─┤
|
||||
Wunderground / NOAA ─┤
|
||||
Polymarket Gamma/CLOB ─┤
|
||||
JMA/AMOS/AMSC/HKO/CWA ─┤
|
||||
CoWIN / MADIS / NOAA ─┤
|
||||
├─ WeatherDataCollector ├─ dashboard-client.ts
|
||||
│ (内存缓存 + SQLite磁盘缓存) │ (ETag浏览器缓存 + SWR)
|
||||
│ │
|
||||
@@ -27,8 +26,11 @@ Polymarket Gamma/CLOB ─┤
|
||||
│ ├─ ThreadPoolExecutor(4) │ (120s TTL)
|
||||
│ └─ AI 增强层 (DeepSeek) │
|
||||
│ │
|
||||
└─ FastAPI routes └─ API代理 (Next.js rewrites)
|
||||
(36个端点 + ETag 304)
|
||||
├─ FastAPI routes └─ API代理 (Next.js rewrites)
|
||||
│ (HTTP snapshot + ETag 304)
|
||||
│
|
||||
└─ SSE /api/events
|
||||
(Redis Stream / SQLite replay)
|
||||
```
|
||||
|
||||
## 二、数据采集层
|
||||
@@ -43,10 +45,8 @@ Polymarket Gamma/CLOB ─┤
|
||||
| NWS | 国家预报 | 美国 | 按请求 |
|
||||
| MGM | 国家官方 | 土耳其 | 300s |
|
||||
| ECMWF/GFS/ICON/GEM/JMA | 多模型 NWP | 全球 | 300s |
|
||||
| HKO/CWA/NOAA/AMOS/NMC | 结算观测 | 特定国家 | 60s (AMOS) / 300s |
|
||||
| Wunderground | 个人气象站 | 全球备用 | 按请求 |
|
||||
| Polymarket Gamma | 市场发现 | 所有温度市场 | 60s |
|
||||
| Polymarket CLOB | 订单簿 | 匹配市场 | 30s |
|
||||
| HKO/CWA/NOAA/AMOS/AMSC/CoWIN | 结算/参考观测 | 特定国家 | 60s-600s |
|
||||
| Redis Stream | 实时事件 replay / 多 worker fanout | 后端内部 | event-driven |
|
||||
|
||||
### 待改进
|
||||
|
||||
@@ -78,7 +78,7 @@ Polymarket Gamma/CLOB ─┤
|
||||
|
||||
**已修复:TTL 匹配** — `SCAN_TERMINAL_PAYLOAD_TTL_SEC` 30s → 120s,匹配 ThreadPoolExecutor(4)×60 城的实际重算耗时。
|
||||
|
||||
**已实现:SSE 增量推送(SSE Patch)与按需刷新** — 引入 FastAPI SSE 广播通道 (`/api/events`)。数据采集端更新时自动向 `/api/internal/collector-patch` 推送最新温度;前端扫描终端订阅该流,不再执行固定的 5 分钟定时轮询,而是根据 Patch 变化即时更新列表;当前选中图表基于 `useLatestPatch` 实现 1 分钟级温度的增量合并与实时曲线绘制。
|
||||
**已实现:SSE 增量推送(SSE Patch)与可重放事件日志** — 引入 FastAPI SSE 广播通道 (`/api/events`)。数据采集端更新时自动向 `/api/internal/collector-patch` 推送最新温度;后端标准化为 `city_observation_patch.v1`,写入 Redis Stream(生产)或 SQLite event log(兜底)后再广播。前端扫描终端订阅该流,不再执行固定的 5 分钟定时轮询,而是根据 Patch 变化即时更新列表;当前选中图表基于 `useLatestPatch` 实现 1 分钟级温度的增量合并与实时曲线绘制。
|
||||
|
||||
| # | 问题 | 优先级 |
|
||||
|---|------|------|
|
||||
@@ -94,7 +94,7 @@ Polymarket Gamma/CLOB ─┤
|
||||
|
||||
**已修复:扫描数据复用** — `preloadCityFromRow()` 从扫描终端行预填充城市详情缓存,选城市后详情面板立即显示。
|
||||
|
||||
**已实现:SSE 订阅与 2 分钟无 Patch 兜底机制** — 引入 `useLatestPatch` 与 `useSsePatchVersion` 管理实况数据的准实时合并。若长连接中断或 2 分钟内未收到任何增量 Patch,可见图表自动触发 60s 降级轮询(从 `/api/city/{city}/summary` 获取最新实况,并以 ignoreCache 强刷 full detail)。
|
||||
**已实现:SSE 订阅、replay 与 2 分钟无 Patch 兜底机制** — 引入 `useLatestPatch` 与 `useSsePatchVersion` 管理实况数据的准实时合并。若长连接中断,前端使用 `since_revision` replay 缺失事件;若 2 分钟内未收到任何增量 Patch,可见图表自动触发 60s 降级轮询(从 `/api/city/{city}/summary` 获取最新实况,并以 ignoreCache 强刷 full detail)。浏览器后台恢复时会主动刷新可见图表 full detail。
|
||||
|
||||
## 六、待办
|
||||
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
|
||||
## 执行摘要
|
||||
|
||||
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution)**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
|
||||
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution)**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、Ethereum 主网 USDC 直转确认、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
|
||||
|
||||
> **2026-05-23 更新(v1.7.0)**:Polymarket 价格拉取与 UI 层(MarketDecisionLine)已删除,`market_scan` 当前返回空;LGBM 已完全移除,概率引擎仅保留 legacy 高斯 + EMOS/CRPS;Groq、Meteoblue、NMC、pogodaiklimat 数据源和 prewarm 预热系统已移除。
|
||||
> **2026-05-29 更新**:支付 intent 已支持多链 `chain_id`,Polygon 继续承载 checkout 合约,Ethereum 主网 USDC 作为直转确认通道;终端图表继续使用 HTTP snapshot + SSE Patch + Redis Stream/SQLite replay 架构。
|
||||
## 项目概览
|
||||
|
||||
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
|
||||
项目主功能可归纳为五层:
|
||||
**天气层(数据源/采集)**:聚合 51 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、韩国 AMOS 跑道级观测(首尔/釜山)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报)、美国 NWS(仅美国城市)、以及部分城市使用明确官方站点或历史页面入口(香港 HKO、台湾/深圳相关历史页面等)等。机场类市场仍以 METAR / 机场主站为结算锚点,Wunderground 不描述为物理观测站。
|
||||
**天气层(数据源/采集)**:聚合 51 个城市的实测与预报;支持 AviationWeather METAR/TAF、韩国 AMOS 跑道级观测(首尔/釜山)、中国 AMSC AWOS 跑道端点气温、香港 CoWIN 6087 / HKO、土耳其 MGM、Open-Meteo 多模型与集合预报、美国 MADIS HFMETAR 等。机场类市场仍以 METAR / 机场主站或明确官方结算源为锚点,Wunderground 不描述为物理观测站。
|
||||
**分析层(DEB/趋势/概率/结算口径)**:
|
||||
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 或当前实测。
|
||||
**城市决策层(Scan Terminal / 结构化实况层)**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR/官方站点/跑道观测,并通过 `/api/city/{name}/detail` 生成城市级结构化数据。终端图表使用 HTTP snapshot + SSE patch + Redis/SQLite replay,最高温中枢优先使用 DEB hourly consensus,再结合多模型中心、日内 pace 和当前实测。
|
||||
**市场层(Polymarket 行情对照)**:*[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。
|
||||
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
|
||||
**商业化与支付**:订阅(`Pro Monthly 29.9 USDC / 30 天`、`Pro Quarterly 79.9 USDC / 90 天`)、邀请积分抵扣、Polygon 链上收款合约(USDC/USDC.e)、Ethereum 主网 USDC 直转确认,并提供“事件监听 + 周期确认”的自动补单机制。
|
||||
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
|
||||
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。
|
||||
(插图:项目 README 中包含产品截图,可用于快速理解信息架构与 UI 形态)
|
||||

|
||||
(插图:项目 README 中包含产品截图,可用于快速理解实时终端与 Telegram 推送形态)
|
||||

|
||||
|
||||
## 架构与代码库分析
|
||||
|
||||
@@ -28,8 +28,8 @@ DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
|
||||
从 README、Docker/Compose、入口脚本与核心模块引用关系,可以抽象出如下模块地图(按“运行时组件”与“Python 域模块”两层描述):
|
||||
| 层级 | 目录/文件 | 角色定位 | 关键说明 |
|
||||
| ------------- | ------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 运行时组件 | `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 的非流式重试。 |
|
||||
| 运行时组件 | `frontend/` | Next.js 前端(Vercel) | 前端重构报告提到 App Router、Route Handlers(BFF)、缓存策略、支付与账户中心等;Scan Terminal 已新增城市决策卡、结构化实况层、页面内存/localStorage 双层缓存、structured detail 小并发队列与完整市场桶映射。 |
|
||||
| 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` + `web/scan_terminal_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层;Scan Terminal 侧提供 `/api/city/{name}/detail`,城市结构化分析 默认 30s 超时并支持 stream parse failure 的非流式重试。 |
|
||||
| 运行时组件 | `bot_listener.py` + `src/bot/*` | Telegram Bot | 入口 `bot_listener.py` 调 `start_bot()`,并由 `StartupCoordinator` 启动多个后台 loop。 |
|
||||
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py`、`settlement_sources.py`、`metar_sources.py`、`mgm_sources.py`、`amos_station_sources.py`、`jma_amedas_sources.py`、`nws_open_meteo_sources.py`、`country_networks.py` 等。v1.7.0 已移除 NMC、pogodaiklimat、Meteoblue 数据源。 |
|
||||
| Python 域模块 | `src/analysis/*` | DEB/趋势/概率/结算口径 | `deb_algorithm.py`、`trend_engine.py`、`settlement_rounding.py`。 |
|
||||
@@ -45,13 +45,13 @@ DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
|
||||
flowchart TB
|
||||
subgraph Clients
|
||||
WEB[Next.js Frontend<br/>Vercel]
|
||||
CITYCARD[Scan Terminal City Cards<br/>AI airport read + bucket mapping]
|
||||
CITYCARD[Scan Terminal City Cards<br/>structured observations + probability mapping]
|
||||
TG[Telegram Bot<br/>TeleBot + Handlers]
|
||||
end
|
||||
|
||||
subgraph API
|
||||
FAST[FastAPI<br/>web/app.py]
|
||||
LLM[City AI stream<br/>OpenAI-compatible provider]
|
||||
LLM[City structured detail<br/>OpenAI-compatible provider]
|
||||
end
|
||||
|
||||
subgraph Data
|
||||
@@ -71,7 +71,7 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
||||
HKO[data.weather.gov.hk]
|
||||
CWA[opendata.cwa.gov.tw]
|
||||
SB[Supabase Auth/REST]
|
||||
RPC[Polygon RPC]
|
||||
RPC[Polygon / Ethereum RPC]
|
||||
end
|
||||
|
||||
subgraph Payments
|
||||
@@ -118,8 +118,8 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
||||
Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解释”的前端决策层:
|
||||
|
||||
1. **地图点击与 pinned city**:免费/付费入口都会先把城市加入决策卡;未付费用户若权限不足,仍应保留卡片承载升级/限制提示,而不是点击后无反馈。
|
||||
2. **full detail hydration**:卡片请求城市 full detail,拿到 DEB、当前/历史实测、多模型区间与最新 METAR。现阶段 detail hydration 仍偏保守串行,优先保障后端数据源稳定;真正消耗 LLM 的 AI 解读另行限流。
|
||||
3. **AI 机场报文解读**:前端最多同时保留 2 条城市 AI stream,第三个及以后城市会进入队列并展示排队提示,避免多个 provider stream 同时竞争导致第三城/第四城解析失败。当前临时使用 MiMo:`POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1`、`POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro`;其他后端城市 AI 配置建议为 `POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30`、`POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900`、`POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`。
|
||||
2. **full detail hydration**:卡片请求城市 full detail,拿到 DEB、当前/历史实测、多模型区间与最新 METAR。现阶段 detail hydration 仍偏保守串行,优先保障后端数据源稳定;真正消耗 LLM 的 结构化解读另行限流。
|
||||
3. **结构化实况层**:前端最多同时保留 2 条城市结构化分析 stream,第三个及以后城市会进入队列并展示排队提示,避免多个 provider stream 同时竞争导致第三城/第四城解析失败。当前临时使用 MiMo:`POLYWEATHER_API_BASE_URL=<backend>`、`POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=300`;其他后端城市结构化分析 配置建议为 `POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=120`、`POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=8`、`POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=300`。
|
||||
4. **缓存策略**:页面内存缓存保留 loading/stream/final 状态,切换选项卡返回时不应空白重拉;localStorage 持久化最终成功、非 degraded 的 payload;后端 city AI cache key 已移除当前 `local_time` 干扰,主要按城市、日期与 METAR signature 失效。
|
||||
5. **市场桶匹配**:城市市场扫描必须使用 full `all_buckets`,按温度 exact/range/“or higher”/“or lower” 方向严格匹配;不再用宽松 ±8°C fallback,以避免拿到 16°C 之类错误桶。前端展示统一使用“模型-市场差”,即 `model_probability - market_implied_probability`,并修复温度单位重复渲染(如 `31°°C`)。
|
||||
|
||||
@@ -127,7 +127,7 @@ Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解
|
||||
|
||||
**Python 依赖**:`requirements.txt` 包含 `requests`、`loguru`、`pyTelegramBotAPI`、`python-dotenv`、`numpy`、`web3`、`fastapi`、`uvicorn` 等,符合“采集+bot+api+链上交互”的需求。
|
||||
**容器环境**:`Dockerfile` 基于 `python:3.11-slim`,默认启动 bot;`docker-compose.yml` 通过不同 command 分别启动 bot 与 web(`python bot_listener.py` / `python web/app.py`),并挂载运行态数据目录。
|
||||
**前端依赖**:前端 README 描述 Next.js、Leaflet、Chart.js、Supabase Auth、WalletConnect 等;`frontend/package.json` 是前端依赖来源。
|
||||
**前端依赖**:前端 README 描述 Next.js、Leaflet、Recharts、Supabase Auth、WalletConnect 等;`frontend/package.json` 是前端依赖来源。
|
||||
### 数据预处理、模型与“训练/推理”管线
|
||||
|
||||
本项目的“模型”主要是统计融合与规则/启发式引擎,而非深度网络训练:
|
||||
@@ -136,7 +136,7 @@ Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解
|
||||
**DEB(Dynamic Error Balancing)**:以最近 N 天各模型的 MAE 计算倒数权重并做加权融合;同时将 `forecasts / actual_high / deb_prediction / mu / prob_snapshot` 写入 `data/daily_records.json`,并提供命中率/MAE/Brier 等统计口径。
|
||||
**概率引擎**:`trend_engine.py` 以集合预报的 p10/p90 推 σ(并考虑历史 MAE floor、风向/云量/压强的 shock_score、以及峰值窗口 time-decay),再用正态近似把连续分布映射为 WU 整数“温度桶概率”。
|
||||
**推理流水线(在线)**:
|
||||
Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引擎输出结构化结果(μ、概率桶、趋势、死盘/窗口判定、DEB 预测、市场扫描)→ 前端渲染或 bot 消息格式化。对城市决策卡而言,在线推理还会叠加“latest METAR + 多模型区间 + AI city stream + full all_buckets 市场匹配”,最终输出最高温中枢、AI 机场报文解读和模型-市场差。
|
||||
Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引擎输出结构化结果(μ、概率桶、趋势、死盘/窗口判定、DEB 预测、市场扫描)→ 前端渲染或 bot 消息格式化。对城市决策卡而言,在线推理还会叠加“latest METAR + 多模型区间 + structured city detail + full all_buckets 市场匹配”,最终输出最高温中枢、结构化实况层和模型-市场差。
|
||||
**检查点(checkpoints)**:传统 ML checkpoint 不适用;但项目现已形成两类“业务状态 checkpoint”:
|
||||
(a)SQLite 运行态存储(当前线上与核心离线链路主路径);(b)SQLite 永久真值/训练特征表(当前监督真值与训练样本长期主存);(c)legacy JSON/JSONL 文件(主要保留给迁移回滚、导出比对与显式回退输入)。当前设计仍支持 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`,但对线上部署与离线训练/回填而言,推荐目标状态都已经是 `sqlite`。
|
||||
### 测试、CI/CD 与运维验证
|
||||
@@ -148,7 +148,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
|
||||
### 优势
|
||||
|
||||
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),新增首次发言欢迎奖励与每日首条消息奖励,周奖励降低赢家积分差距并增加全员参与奖。
|
||||
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),积分获取切换为有效付费邀请奖励;Telegram 群发言不再发放积分。
|
||||
**复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。前端城市决策卡在此基础上补足“机场报文解释 + 市场桶动作口径”,让用户从地图点击可以直接进入可解释决策。
|
||||
**面向外部 API 的工程防护意识较强**:Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。
|
||||
**支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。
|
||||
@@ -159,7 +159,7 @@ 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、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。
|
||||
项目强依赖外部 API(Open-Meteo、AviationWeather、global.amo.go.kr AMOS、NWS、HKO、CWA、Supabase)以及城市结构化分析 provider(OpenAI-compatible stream,当前使用 MiMo)。其中 AviationWeather Data API 有明确速率限制;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市结构化分析 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
|
||||
|
||||
> **v1.7.0 更新**:Polymarket(Gamma/CLOB)API 依赖已随市场价格拉取层一并移除。
|
||||
**许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部神经天气模型,仍需单独核验第三方代码与权重的商用条件:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
|
||||
@@ -181,11 +181,11 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
**对标结论**:PolyWeather 与这类“全球神经天气模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。
|
||||
## 优先级改进建议
|
||||
|
||||
下表按截至 `2026-05-23` 的真实状态重排优先级。已完成项不再继续列为”待做”,只保留当前仍需推进的事项。
|
||||
下表按截至 `2026-05-28` 的真实状态重排优先级。已完成项不再继续列为”待做”,只保留当前仍需推进的事项。
|
||||
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 中 | **把最小外部监控继续补深**:从“可告警”提升到“可运营” | 3–7 天 | 不再只知道服务坏没坏,还能看资源趋势、来源 SLA 和支付波动 | 指标过多会带来维护噪音 | 1) 增加节点 CPU/内存/磁盘 → 2) 增加 SQLite/支付体积与事件趋势 → 3) 把 HTTP/来源指标细分到城市/来源维度 → 4) 增加日报或异常摘要 |
|
||||
| 中 | **城市决策卡 AI 解读可观测性与回放测试** | 3–5 天 | 降低第三/第四/第五城市 AI 解读失败,验证缓存与队列是否真正生效 | 外部 AI stream 仍可能超时或输出截断,若无指标很难复盘 | 1) 记录 city-ai stream status/duration/retry/degraded/cache-hit/queue-depth → 2) 增加固定 METAR + detail + all_buckets fixture → 3) 回归断言 bucket 匹配、模型-市场差、温度单位与缓存 key → 4) 将生产 env 建议同步进部署文档 |
|
||||
| 中 | **城市决策卡 结构化解读可观测性与回放测试** | 3–5 天 | 降低第三/第四/第五城市结构化分析 解读失败,验证缓存与队列是否真正生效 | 外部 structured detail 仍可能超时或输出截断,若无指标很难复盘 | 1) 记录 city-ai stream status/duration/retry/degraded/cache-hit/queue-depth → 2) 增加固定 METAR + detail + all_buckets fixture → 3) 回归断言 bucket 匹配、模型-市场差、温度单位与缓存 key → 4) 将生产 env 建议同步进部署文档 |
|
||||
| - | ~~市场层升级为 async + 类型安全~~ | N/A | *[v1.7.0 已移除]* 市场价格拉取层已删除,此改进项不再适用 | - | - |
|
||||
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 1–2 周 | 在已完成的链下审计与容灾之上,进一步收紧链上授权边界 | 合约升级需要重新部署、迁移配置并再次验证 | 1) 维持现有事件重放、SQLite 审计、多 RPC fallback → 2) 升级合约到 SafeERC20 + Pausable → 3) 评估链上 plan/amount/token 绑定或 EIP-712 签名校验 → 4) 迁移后更新 PolygonScan 验证与支付审计文档 |
|
||||
| 中 | **将 CI 与分支保护/发布流程真正绑定** | 1–3 天 | 让现有 CI 从“存在”变成“强制门禁” | 历史分支/热修流程可能受影响 | 1) GitHub `main` 开启 required checks → 2) 把 release/tag 流程绑定 CI → 3) 明确热修例外流程 |
|
||||
@@ -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、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、城市 AI stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。
|
||||
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级,并明确城市结构化分析 推荐配置(`POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=120`、`POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=8`、`POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=300`);(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、城市结构化分析 stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。
|
||||
2)**测试金字塔**:在现有 `trend_engine` 单测基础上,补齐:
|
||||
(a)天气 provider 的“录制回放”测试(VCR 思路:固定响应→确保解析稳定);(b)市场层的契约测试(Gamma/CLOB schema 变更时提前失败);(c)城市决策卡 fixture 测试(固定 `detail/market_scan/all_buckets/METAR` → 断言 bucket mapping、模型-市场差、温度单位、AI 缓存 key 与排队提示);(d)支付链路的本地链集成测试(Hardhat/Anvil + 事件扫描回放)。这些测试能把“外部依赖漂移”尽量转成可控的回归失败。
|
||||
3)**贡献工作流**:引入 `CONTRIBUTING.md`(分支策略、PR 模板、变更日志、版本号策略)、`CODEOWNERS`(核心模块审查人)、`SECURITY.md`(漏洞披露与密钥处理),并把静态检查(ruff/eslint)作为 pre-commit + CI 必过项。
|
||||
@@ -243,13 +243,13 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
||||
| 时间窗 | 里程碑 | 交付物 | 资源/备注 |
|
||||
| ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
|
||||
| 第 1 周 | 城市决策卡稳定性补强 | city-ai stream/cache/queue 指标;固定 METAR + `all_buckets` fixture;温度桶匹配与模型-市场差回归测试;生产 env 文档化 | 前端为主,后端补指标 |
|
||||
| 第 2 周 | 市场层与 Scan Terminal 数据回放 | 保存 `bucket_label/bucket_direction/model_market_diff/matching_reason`;支持回放第三/第四/第五城市 AI 解读失败案例 | 用真实失败样本压回归 |
|
||||
| 第 4–5 周 | 监控深挖与运维日报 | 来源 SLA、城市维度延迟、AI stream 状态、SQLite 体积、支付事件趋势、异常摘要 | 避免指标过多,先覆盖高频故障 |
|
||||
| 第 2 周 | 市场层与 Scan Terminal 数据回放 | 保存 `bucket_label/bucket_direction/model_market_diff/matching_reason`;支持回放第三/第四/第五城市结构化分析 解读失败案例 | 用真实失败样本压回归 |
|
||||
| 第 4–5 周 | 监控深挖与运维日报 | 来源 SLA、城市维度延迟、structured detail 状态、SQLite 体积、支付事件趋势、异常摘要 | 避免指标过多,先覆盖高频故障 |
|
||||
| 第 6 周 | 支付合约与发布门禁升级 | SafeERC20/Pausable 方案评审;CI required checks 与 release/tag 流程绑定;热修例外流程 | 合约升级需单独部署验证 |
|
||||
|
||||
### 主要风险与缓解策略
|
||||
|
||||
**外部 API / AI provider 速率限制与格式变更**:AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化;OpenAI-compatible city AI stream 可能出现 timeout、stream JSON 截断或并发竞争。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用);城市 AI 保持小并发队列、30s timeout、stream parse retry、页面内存缓存与 degraded fallback。
|
||||
**外部 API / AI provider 速率限制与格式变更**:AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化;OpenAI-compatible city structured detail 可能出现 timeout、stream JSON 截断或并发竞争。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用);城市结构化分析 保持小并发队列、30s timeout、stream parse retry、页面内存缓存与 degraded fallback。
|
||||
**密钥泄露与权限滥用**:Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。
|
||||
**支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。当前项目已经补齐“事件监听 + 确认补单”双路径、事件重放脚本、SQLite 审计事件与多 RPC fallback;现阶段的主要剩余风险不再是“没有防护”,而是链上合约仍为最小实现,owner 为单地址管理,且没有 pause 开关与 SafeERC20。
|
||||
**引入外部神经天气模型的商业合规风险**:GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
|
||||
@@ -270,5 +270,3 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
||||
- Pangu-Weather(权重 BY-NC-SA,禁商用):https://github.com/198808xc/Pangu-Weather
|
||||
- Polymarket 官方 Python CLOB SDK(MIT):https://github.com/Polymarket/py-clob-client
|
||||
- aiopolymarket(async、类型安全):https://github.com/the-odds-company/aiopolymarket
|
||||
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
|
||||
2. **Tab 下划线指示器不够明显**:`2px` 高度 + `opacity: 0.8` 的蓝色下划线容易被忽略。
|
||||
|
||||
3. **按钮层级不够清晰**:`.scan-primary-button`(蓝紫渐变)、`.scan-ai-button`(青绿渐变)、`.scan-ai-city-icon-button`(蓝色边框半透明)、`.scan-theme-button`(无边框无背景)四种视觉权重混在一起,用户难以判断优先级。
|
||||
3. **按钮层级不够清晰**:`.scan-primary-button`(蓝紫渐变)、`.scan-ai-button`(青绿渐变)、`.scan-city-icon-button`(蓝色边框半透明)、`.scan-theme-button`(无边框无背景)四种视觉权重混在一起,用户难以判断优先级。
|
||||
|
||||
4. **空状态/加载状态设计不一致**:
|
||||
- 地图加载有精美的云/雷达/热力动画
|
||||
@@ -233,7 +233,7 @@
|
||||
- Tab 切换缺少 `role="tablist"`/`role="tab"`/`aria-selected`
|
||||
- 折叠按钮缺少 `aria-expanded`
|
||||
|
||||
2. **焦点指示器不可见**:自定义按钮(如 `scan-theme-button`、`scan-ai-city-icon-button`)没有 focus-visible 样式
|
||||
2. **焦点指示器不可见**:自定义按钮(如 `scan-theme-button`、`scan-city-icon-button`)没有 focus-visible 样式
|
||||
|
||||
3. **颜色不是唯一的信息传达方式**:风险等级、Market decision 的色彩编码缺少对应的文字标签或图标补充
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 261 KiB |
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
# PolyWeather 支付审计与防护说明
|
||||
|
||||
最后更新:`2026-03-21`
|
||||
最后更新:`2026-05-29`
|
||||
|
||||
## 1. 当前已落地的防护
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
|
||||
### 事件确认边界
|
||||
|
||||
- 后端只认链上 `OrderPaid` 事件。
|
||||
- 后端只认链上可验证事件:checkout 合约路径认 `OrderPaid`,直转路径认对应链上 USDC `Transfer`。
|
||||
- 前端提交 intent 不会直接视为支付完成。
|
||||
- `confirm_loop` 会再次按链上交易与确认数校验 intent。
|
||||
- `confirm_loop` 会再次按 intent 的 `chain_id`、`token_address`、收款地址、金额与确认数校验链上交易。
|
||||
- 若确认失败,当前会明确把 intent / transaction 落为失败态,而不是长期停留在 `submitted`。
|
||||
|
||||
当前已显式识别的失败原因包括:
|
||||
@@ -29,19 +29,23 @@
|
||||
- `receiver_mismatch`
|
||||
- `sender_mismatch`
|
||||
- `event_mismatch`
|
||||
- `token_mismatch`
|
||||
- `tx_reverted`
|
||||
|
||||
### RPC 多节点容灾
|
||||
|
||||
- 支持 `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- 支持默认链 `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- 支持多链 `POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON`
|
||||
- 格式示例:
|
||||
|
||||
```env
|
||||
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
|
||||
POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON={"137":["https://polygon-rpc.com","https://polygon-bor-rpc.publicnode.com"],"1":["https://ethereum-rpc.example"]}
|
||||
```
|
||||
|
||||
- 启动时按顺序探活。
|
||||
- 当前节点断连或收据查询失败时,会自动切换到下一个可用 RPC。
|
||||
- 多链支付确认不会用默认链硬查交易;每笔 intent 会按自己的 `chain_id` 选择 RPC。
|
||||
|
||||
### 事件重放
|
||||
|
||||
@@ -87,8 +91,24 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
|
||||
- 已付款但未开通
|
||||
- 打到旧收款地址
|
||||
- 交易事件不匹配
|
||||
- 用户在钱包默认 Ethereum 网络付款,但旧系统只按 Polygon intent 查账
|
||||
|
||||
## 2. 当前合约的授权边界
|
||||
## 2. 当前支付链路边界
|
||||
|
||||
当前支持两类链路:
|
||||
|
||||
1. Polygon checkout 合约
|
||||
- 前端钱包支付会先切到 Polygon。
|
||||
- 后端创建 intent 后给出合约 `tx_payload`。
|
||||
- 确认时校验 `OrderPaid(orderId, payer, planId, amount, token)`。
|
||||
|
||||
2. Ethereum 主网 USDC 直转
|
||||
- 前端展示 Ethereum 网络、USDC 合约、收款钱包和金额。
|
||||
- 用户提交 tx hash 后,后端按 `intent.chain_id=1` 查询 Ethereum RPC。
|
||||
- 确认时校验 USDC `Transfer(from, to, amount)` 的 `to`、`token_address` 和金额。
|
||||
- 这条链路不依赖 Polygon checkout 合约,适合处理“用户钱包默认网络付款”的真实行为。
|
||||
|
||||
## 3. 当前合约的授权边界
|
||||
|
||||
合约源码:
|
||||
- [PolyWeatherCheckout.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckout.sol)
|
||||
@@ -110,7 +130,7 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
|
||||
4. 订单边界
|
||||
- 同一个 `orderId` 只能成功支付一次
|
||||
|
||||
## 3. 重入与重复支付判断
|
||||
## 4. 重入与重复支付判断
|
||||
|
||||
当前合约的 `pay` 逻辑顺序是:
|
||||
|
||||
@@ -136,7 +156,7 @@ python scripts/replay_payment_events.py --from-block 10000000 --to-block 1000100
|
||||
- **最小可用支付合约**
|
||||
- 不是“全功能强防护合约”
|
||||
|
||||
## 4. 当前静态审计结论
|
||||
## 5. 当前静态审计结论
|
||||
|
||||
已提供脚本:
|
||||
- [check_payment_contract_security.py](/E:/web/PolyWeather/scripts/check_payment_contract_security.py)
|
||||
@@ -160,7 +180,7 @@ python scripts/check_payment_contract_security.py
|
||||
- 是否使用 SafeERC20
|
||||
- 是否在链上绑定套餐价格
|
||||
|
||||
## 5. 当前主要剩余风险
|
||||
## 6. 当前主要剩余风险
|
||||
|
||||
1. 单地址 owner
|
||||
- 建议把 `owner` 迁移到多签钱包
|
||||
@@ -175,7 +195,7 @@ python scripts/check_payment_contract_security.py
|
||||
- 当前使用 `IERC20.transferFrom`
|
||||
- 升级版合约更建议改为 OpenZeppelin `SafeERC20`
|
||||
|
||||
## 6. 推荐操作
|
||||
## 7. 推荐操作
|
||||
|
||||
### 每次支付配置变更后
|
||||
|
||||
@@ -233,7 +253,7 @@ docker compose exec polyweather_web python scripts/reconcile_subscription_by_ema
|
||||
- 用户声称已付费但未开通
|
||||
- 需要快速确认最近一笔 intent 是否能自动恢复
|
||||
|
||||
## 7. 下一版合约建议
|
||||
## 8. 下一版合约建议
|
||||
|
||||
如果后续升级合约,优先级建议:
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# PolyWeatherCheckout PolygonScan 验证(v1.8.0)
|
||||
# PolyWeatherCheckout PolygonScan 验证(v1.8.1)
|
||||
|
||||
最后更新:`2026-03-20`
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# PolyWeather 前端产品审查报告
|
||||
|
||||
> 审查日期:2026-06 | 视角:产品经理 | 范围:`frontend/` 全部页面、组件、用户流程
|
||||
> 审查日期:2026-06 | 视角:产品经理 | 范围:`frontend/` 全部页面、组件、用户流程 | 最后校准:2026-05-28
|
||||
|
||||
## 一、产品概览
|
||||
|
||||
PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核心价值主张:**结合多模型气象预报 + AI 机场报文解读 + Polymarket 市场价格,为交易决策提供一站式证据链。**
|
||||
PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核心价值主张:**结合多模型气象预报 + 结构化实况层 + Polymarket 市场价格,为交易决策提供一站式证据链。**
|
||||
|
||||
### 产品分层
|
||||
|
||||
@@ -12,13 +12,13 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|
||||
|------|------|------|
|
||||
| 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 |
|
||||
| Pro 试用 | 3 天全功能 | 注册后自动获得 |
|
||||
| Pro 订阅 | 城市决策卡(AI 机场报文 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 10 USDC/月(积分抵扣最多 3 USDC) |
|
||||
| Pro 订阅 | 城市决策卡(结构化实况 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 月付 29.9 USDC / 30 天,季度 79.9 USDC / 90 天(月付最多抵 3 USDC,季度最多抵 8 USDC) |
|
||||
|
||||
### 页面结构(9 个路由)
|
||||
|
||||
| 路由 | 功能 | 是否必需登录 |
|
||||
|------|------|-------------|
|
||||
| `/` | 主看板 — AI 天气决策台 | 否 |
|
||||
| `/` | 主看板 — 实时天气决策台 | 否 |
|
||||
| `/account` | 账户中心 — 身份/订阅/钱包/积分/Bot 绑定 | 是 |
|
||||
| `/auth/login` | 登录页(Google OAuth + 邮箱密码) | 否 |
|
||||
| `/docs/[...slug]` | 产品文档中心(8 篇双语文档) | 否 |
|
||||
@@ -37,7 +37,7 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|
||||
│ 点击城市 → 自动添加到决策卡工作区 + 切换到卡片视图 │
|
||||
│ 免费用户和 Pro 用户均可使用 │
|
||||
├─ 决策卡(分析) ──────────────────────────────┤
|
||||
│ 钉选的城市卡片:AI 机场解读 + 模型集群 + 市场层 + 图表 │
|
||||
│ 钉选的城市卡片:结构化实况 + 模型集群 + 市场层 + 实时图表 │
|
||||
│ 需要 Pro 订阅 │
|
||||
└──────────────────────────────────────────────┘
|
||||
└── 右侧栏:城市简报面板(始终可见,免费可用)
|
||||
@@ -46,14 +46,14 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|
||||
### 关键用户路径
|
||||
|
||||
1. **新用户落地** → 看到 3 步引导 → 地图 + 城市列表 → 点击城市 → 看到城市简报 → 想深入分析 → 遇到 Pro 付费墙(含功能说明)
|
||||
2. **Pro 用户工作流** → 地图选城市 → 自动钉选到决策卡 → 展开卡片 → 阅读 AI 报文解读 → 查看市场层 → 判断交易方向
|
||||
2. **Pro 用户工作流** → 地图选城市 → 自动钉选到决策卡 → 展开卡片 → 查看实测/DEB/概率/市场层 → 判断交易方向
|
||||
|
||||
## 三、做得好的地方
|
||||
|
||||
1. **地图 → 决策卡的自动流转设计** — 点击地图城市自动钉选到分析工作区并切换视图,"零步骤发现"
|
||||
2. **双语覆盖完整** — 所有 UI 文案、文档、AI 解读都有中英文对照,覆盖率接近 100%
|
||||
2. **双语覆盖完整** — 所有 UI 文案、文档、结构化解读都有中英文对照,覆盖率接近 100%
|
||||
3. **数据新鲜度可视化** — DataFreshnessBar 让用户一眼看到 METAR/模型/市场数据的新鲜度
|
||||
4. **AI 解读的产品化程度高** — 分层展示:快速判断 → 完整解读 → 证据链 → 风险提示
|
||||
4. **结构化解读的产品化程度高** — 分层展示:快速判断 → 完整解读 → 证据链 → 风险提示
|
||||
5. **免费层有实际价值** — 地图 + 城市简报不是"空壳",用户可以看真实气象数据
|
||||
6. **支付链路完整** — 从钱包绑定到链上签约到支付恢复,处理了多种异常情况
|
||||
7. **空状态有引导文字** — "Click a city on the map" 告诉用户下一步做什么
|
||||
@@ -67,4 +67,3 @@ PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核
|
||||
| 1 | **Docs 无搜索** — 8 篇文档没有搜索功能,用户必须逐篇浏览 | 🟢 待做 |
|
||||
| 2 | **Ops 面板无审计日志** — 管理员补发积分等操作没有审计记录 | 🟢 需后端 |
|
||||
| 3 | **注册后邮件验证引导** — 未验证邮箱的用户反复登录失败 | 🟡 需后端 |
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Redis Stream Realtime Event Architecture Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add Redis Stream as the production realtime event store and live fanout source while preserving the existing SSE patch protocol.
|
||||
|
||||
**Implementation status (2026-05-28):** completed in `v1.8.1`. Production uses Redis Stream when `POLYWEATHER_EVENT_STORE=redis`; SQLite remains the local/fallback event log. Browser API and frontend contract stayed on `/api/events` + `city_observation_patch.v1`.
|
||||
|
||||
**Architecture:** Keep `city_observation_patch.v1` and numeric `revision` as the browser contract. Add a Redis-backed event store beside the existing SQLite store, select it through an event-store factory, and let Redis-backed deployments fan out live events through a Redis subscriber loop instead of direct in-process ingest broadcast.
|
||||
|
||||
**Tech Stack:** FastAPI, redis-py, Redis Stream, SQLite fallback, pytest, existing frontend EventSource hook.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Redis Store Contract
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/test_redis_realtime_event_store.py`
|
||||
- Create: `web/redis_realtime_event_store.py`
|
||||
|
||||
- [x] **Step 1: Write failing tests**
|
||||
|
||||
Cover append, replay by city, replay gap, and fallback-independent event shape.
|
||||
|
||||
- [x] **Step 2: Verify tests fail**
|
||||
|
||||
Run: `python -m pytest tests/test_redis_realtime_event_store.py -q`
|
||||
|
||||
Expected: fails because `web.redis_realtime_event_store` does not exist.
|
||||
|
||||
- [x] **Step 3: Implement Redis store**
|
||||
|
||||
Add `RedisRealtimeEventStore` with `append_event`, `latest_revision`, `replay_events`, `replay_requires_resync`, and idempotent `start_live_subscription`.
|
||||
|
||||
- [x] **Step 4: Verify tests pass**
|
||||
|
||||
Run: `python -m pytest tests/test_redis_realtime_event_store.py -q`
|
||||
|
||||
### Task 2: Store Factory And SSE Router
|
||||
|
||||
**Files:**
|
||||
- Create: `web/realtime_event_store_factory.py`
|
||||
- Modify: `web/routers/sse_router.py`
|
||||
- Test: `tests/test_sse_replay.py`
|
||||
|
||||
- [x] **Step 1: Write failing tests**
|
||||
|
||||
Cover `POLYWEATHER_EVENT_STORE=redis` selecting Redis, SQLite fallback when Redis is not configured, and Redis ingest not doing direct local broadcast.
|
||||
|
||||
- [x] **Step 2: Verify tests fail**
|
||||
|
||||
Run: `python -m pytest tests/test_sse_replay.py tests/test_realtime_event_store_factory.py -q`
|
||||
|
||||
- [x] **Step 3: Implement factory and router integration**
|
||||
|
||||
Use Redis store when configured; otherwise keep existing SQLite store. Ingest broadcasts directly only for stores that do not provide external live fanout.
|
||||
|
||||
- [x] **Step 4: Verify tests pass**
|
||||
|
||||
Run: `python -m pytest tests/test_sse_replay.py tests/test_realtime_event_store_factory.py -q`
|
||||
|
||||
### Task 3: Dependency And Operational Config
|
||||
|
||||
**Files:**
|
||||
- Modify: `requirements.txt`
|
||||
- Modify: `docs/superpowers/specs/2026-05-27-redis-stream-realtime-event-architecture-design.md`
|
||||
|
||||
- [x] **Step 1: Add redis-py dependency**
|
||||
|
||||
Add `redis>=5.0.0` to `requirements.txt`.
|
||||
|
||||
- [x] **Step 2: Add final config notes**
|
||||
|
||||
Ensure the design doc names the runtime env vars used by code.
|
||||
|
||||
### Task 4: Verification
|
||||
|
||||
**Files:**
|
||||
- No production edits unless tests reveal a gap.
|
||||
|
||||
- [x] **Step 1: Run backend realtime tests**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_realtime_patch_schema.py tests/test_realtime_event_store.py tests/test_redis_realtime_event_store.py tests/test_realtime_event_store_factory.py tests/test_sse_replay.py -q
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run broader frontend/backend checks**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run test:business
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
- [x] **Step 3: Inspect diff**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
git status --short
|
||||
```
|
||||
@@ -0,0 +1,345 @@
|
||||
# Feedback Reward Account Detail Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let users see which submitted feedback earned points, how many points were awarded, and the reward reason in the account page.
|
||||
|
||||
**Architecture:** Store reward metadata directly on `user_feedback` rows so the existing feedback list API can expose the reward source without a separate ledger join. The account feedback panel renders reward state inline with each feedback item and stays silent for rows with no reward metadata.
|
||||
|
||||
**Tech Stack:** Python SQLite `DBManager`, FastAPI feedback service, Next.js/React account components, TypeScript source-based business tests.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `src/database/db_manager.py`: add reward columns, serialize them, and add `update_user_feedback_reward()` for future ops reward workflows.
|
||||
- Modify `tests/test_user_feedback.py`: add backend coverage for reward defaults and reward metadata round trip.
|
||||
- Modify `frontend/types/ops.ts`: add optional reward fields to `UserFeedbackEntry`.
|
||||
- Modify `frontend/components/account/AccountFeedbackPanel.tsx`: render reward labels and reasons inside each feedback row.
|
||||
- Modify `frontend/components/account/__tests__/accountFeedbackPanel.test.ts`: assert the panel handles reward display states.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend Feedback Reward Metadata
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_user_feedback.py`
|
||||
- Modify: `src/database/db_manager.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing backend test**
|
||||
|
||||
Add this test to `tests/test_user_feedback.py`:
|
||||
|
||||
```python
|
||||
def test_user_feedback_reward_metadata_round_trip(tmp_path):
|
||||
db = DBManager(str(tmp_path / "polyweather-feedback-reward.db"))
|
||||
|
||||
created = db.append_user_feedback(
|
||||
category="data",
|
||||
message="Hong Kong COWIN reading was stale.",
|
||||
user_id="user-reward",
|
||||
user_email="reward@example.com",
|
||||
)
|
||||
|
||||
assert created["reward_points"] == 0
|
||||
assert created["reward_reason"] == ""
|
||||
assert created["reward_status"] == ""
|
||||
assert created["rewarded_at"] is None
|
||||
|
||||
rewarded = db.update_user_feedback_reward(
|
||||
created["id"],
|
||||
points=300,
|
||||
reason="Valid data freshness report",
|
||||
status="granted",
|
||||
)
|
||||
|
||||
assert rewarded is not None
|
||||
assert rewarded["reward_points"] == 300
|
||||
assert rewarded["reward_reason"] == "Valid data freshness report"
|
||||
assert rewarded["reward_status"] == "granted"
|
||||
assert rewarded["rewarded_at"]
|
||||
|
||||
row = db.list_user_feedback(
|
||||
limit=10,
|
||||
user_id="user-reward",
|
||||
user_email="reward@example.com",
|
||||
)[0]
|
||||
assert row["id"] == created["id"]
|
||||
assert row["reward_points"] == 300
|
||||
assert row["reward_reason"] == "Valid data freshness report"
|
||||
assert row["reward_status"] == "granted"
|
||||
assert row["rewarded_at"] == rewarded["rewarded_at"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the backend test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_user_feedback.py::test_user_feedback_reward_metadata_round_trip -q
|
||||
```
|
||||
|
||||
Expected: failure because `reward_points` is missing or `update_user_feedback_reward` is undefined.
|
||||
|
||||
- [ ] **Step 3: Add reward columns and serialization**
|
||||
|
||||
In `src/database/db_manager.py`, update `user_feedback` schema and migration:
|
||||
|
||||
```python
|
||||
reward_points INTEGER DEFAULT 0,
|
||||
reward_reason TEXT DEFAULT '',
|
||||
rewarded_at TIMESTAMP,
|
||||
reward_status TEXT DEFAULT ''
|
||||
```
|
||||
|
||||
Add `_ensure_column()` calls for the same columns in the existing migration block.
|
||||
|
||||
Update every feedback `SELECT` to include:
|
||||
|
||||
```sql
|
||||
reward_points, reward_reason, rewarded_at, reward_status
|
||||
```
|
||||
|
||||
Update `_feedback_row_to_dict()` to return:
|
||||
|
||||
```python
|
||||
"reward_points": max(0, int(row["reward_points"] or 0)),
|
||||
"reward_reason": str(row["reward_reason"] or ""),
|
||||
"rewarded_at": row["rewarded_at"],
|
||||
"reward_status": str(row["reward_status"] or ""),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add reward update method**
|
||||
|
||||
Add this method near `update_user_feedback_status()` in `src/database/db_manager.py`:
|
||||
|
||||
```python
|
||||
def update_user_feedback_reward(
|
||||
self,
|
||||
feedback_id: int,
|
||||
*,
|
||||
points: int,
|
||||
reason: str = "",
|
||||
status: str = "granted",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
safe_points = max(0, int(points or 0))
|
||||
normalized_reason = str(reason or "").strip()[:500]
|
||||
normalized_status = str(status or "").strip().lower()[:40]
|
||||
if not normalized_status:
|
||||
normalized_status = "granted" if safe_points > 0 else "skipped"
|
||||
now = datetime.now().isoformat()
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE user_feedback
|
||||
SET reward_points = ?,
|
||||
reward_reason = ?,
|
||||
reward_status = ?,
|
||||
rewarded_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
safe_points,
|
||||
normalized_reason,
|
||||
normalized_status,
|
||||
now,
|
||||
now,
|
||||
int(feedback_id),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, category, message, source, status, contact, user_id,
|
||||
user_email, context_json, reward_points, reward_reason,
|
||||
rewarded_at, reward_status, created_at, updated_at
|
||||
FROM user_feedback
|
||||
WHERE id = ?
|
||||
""",
|
||||
(int(feedback_id),),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
return self._feedback_row_to_dict(row) if row else None
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_user_feedback.py -q
|
||||
```
|
||||
|
||||
Expected: all `test_user_feedback.py` tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Account Feedback Reward Display
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/types/ops.ts`
|
||||
- Modify: `frontend/components/account/AccountFeedbackPanel.tsx`
|
||||
- Modify: `frontend/components/account/__tests__/accountFeedbackPanel.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing frontend test**
|
||||
|
||||
Extend the final assertion in `frontend/components/account/__tests__/accountFeedbackPanel.test.ts` or add a new assertion:
|
||||
|
||||
```ts
|
||||
assert(
|
||||
feedbackPanelSource.includes("reward_points") &&
|
||||
feedbackPanelSource.includes("reward_reason") &&
|
||||
feedbackPanelSource.includes("reward_status") &&
|
||||
feedbackPanelSource.includes("formatRewardPoints") &&
|
||||
feedbackPanelSource.includes("renderFeedbackReward") &&
|
||||
feedbackPanelSource.includes("奖励原因"),
|
||||
"account feedback panel must show per-feedback reward points and reward reasons",
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the frontend business test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend; npm run test:business -- accountFeedbackPanel
|
||||
```
|
||||
|
||||
Expected: failure because the panel does not yet reference the reward fields.
|
||||
|
||||
- [ ] **Step 3: Extend frontend type**
|
||||
|
||||
Add optional fields to `UserFeedbackEntry` in `frontend/types/ops.ts`:
|
||||
|
||||
```ts
|
||||
reward_points?: number;
|
||||
reward_reason?: string;
|
||||
rewarded_at?: string | null;
|
||||
reward_status?: string;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add account reward rendering helpers**
|
||||
|
||||
In `AccountFeedbackPanel.tsx`, add helpers:
|
||||
|
||||
```tsx
|
||||
function formatRewardPoints(points?: number) {
|
||||
const value = Math.max(0, Number(points || 0));
|
||||
return `+${value.toLocaleString()} points`;
|
||||
}
|
||||
|
||||
function rewardStatusText(status?: string, isEn = false) {
|
||||
const key = String(status || "").toLowerCase();
|
||||
if (key === "pending") return isEn ? "Reward pending" : "奖励待处理";
|
||||
if (key === "skipped") return isEn ? "No points awarded" : "未发放积分";
|
||||
return isEn ? "Feedback reward" : "反馈奖励";
|
||||
}
|
||||
|
||||
function renderFeedbackReward(entry: UserFeedbackEntry, isEn: boolean) {
|
||||
const points = Math.max(0, Number(entry.reward_points || 0));
|
||||
const rewardStatus = String(entry.reward_status || "").toLowerCase();
|
||||
const reason = String(entry.reward_reason || "").trim();
|
||||
if (points <= 0 && !rewardStatus && !reason) return null;
|
||||
const granted = points > 0 || rewardStatus === "granted";
|
||||
return (
|
||||
<div className={`mt-2 rounded-lg border px-3 py-2 text-xs ${
|
||||
granted
|
||||
? "border-amber-200 bg-amber-50 text-amber-800"
|
||||
: "border-slate-200 bg-slate-50 text-slate-600"
|
||||
}`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-bold">{rewardStatusText(rewardStatus, isEn)}</span>
|
||||
{points > 0 ? <span className="font-mono font-black">{formatRewardPoints(points)}</span> : null}
|
||||
</div>
|
||||
{reason ? (
|
||||
<div className="mt-1 leading-5 text-slate-600">
|
||||
{isEn ? "Reason" : "奖励原因"}: {reason}
|
||||
</div>
|
||||
) : null}
|
||||
{entry.rewarded_at ? (
|
||||
<div className="mt-1 font-mono text-[11px] text-slate-400">
|
||||
{compactDate(entry.rewarded_at)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Render reward detail in each feedback row**
|
||||
|
||||
Inside each feedback row, after the feedback message paragraph, add:
|
||||
|
||||
```tsx
|
||||
{renderFeedbackReward(entry, isEn)}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run frontend business test**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend; npm run test:business -- accountFeedbackPanel
|
||||
```
|
||||
|
||||
Expected: `accountFeedbackPanel` passes.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Verification and Commit
|
||||
|
||||
**Files:**
|
||||
- Verify all touched files.
|
||||
|
||||
- [ ] **Step 1: Run targeted backend verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_user_feedback.py -q
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 2: Run targeted frontend verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend; npm run test:business -- accountFeedbackPanel
|
||||
```
|
||||
|
||||
Expected: `accountFeedbackPanel` test passes.
|
||||
|
||||
- [ ] **Step 3: Run broader low-cost verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m ruff check .
|
||||
cd frontend; npm run typecheck
|
||||
```
|
||||
|
||||
Expected: both commands pass.
|
||||
|
||||
- [ ] **Step 4: Check status and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git diff --check
|
||||
git add docs/superpowers/plans/2026-06-08-feedback-reward-account-detail.md tests/test_user_feedback.py src/database/db_manager.py frontend/types/ops.ts frontend/components/account/AccountFeedbackPanel.tsx frontend/components/account/__tests__/accountFeedbackPanel.test.ts
|
||||
git commit -m "Show feedback reward details in account"
|
||||
```
|
||||
|
||||
Expected: commit succeeds with only planned files staged.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: reward metadata fields, existing API payload, account inline display, missing metadata fallback, and future ops reuse are covered.
|
||||
- Scope control: the plan does not implement automatic reward issuance or external notifications.
|
||||
- Type consistency: frontend fields use `reward_points`, `reward_reason`, `rewarded_at`, and `reward_status`, matching backend serialization.
|
||||
@@ -0,0 +1,408 @@
|
||||
# Redis Stream Realtime Event Architecture Design
|
||||
|
||||
> 日期: 2026-05-27
|
||||
> 范围: PolyWeather 网站图表实时观测事件层
|
||||
> 目标服务器: 2 vCPU / 8 GB RAM / 50 GB 系统盘
|
||||
> 实施状态: 2026-05-28 已落地为 `v1.8.1`
|
||||
|
||||
## 背景
|
||||
|
||||
当前实时层已经具备生产化雏形:
|
||||
|
||||
- 后端已有 `city_observation_patch.v1` schema。
|
||||
- `web/realtime_event_store.py` 使用 SQLite 保存 `observation_patch_events`,支持 `since_revision` replay。
|
||||
- `/api/events?cities=...&since_revision=...&replay_limit=...` 已经是前端 SSE 入口。
|
||||
- 前端 `frontend/hooks/use-sse-patches.ts` 使用 `EventSource`,并按当前可见城市订阅。
|
||||
|
||||
这解决了单进程、单实例下的实时推送和短窗口 replay,但还不够“一步到位”:
|
||||
|
||||
- 多 worker / 多实例时,进程内广播不能共享。
|
||||
- SQLite replay log 可以补数据,但不适合作为跨实例 live fanout。
|
||||
- 服务重启、浏览器后台恢复、SSE 断线后,需要更稳定的事件源。
|
||||
- 后续如果把采集器拆成独立进程,需要一个明确的后端事件总线。
|
||||
|
||||
## 目标
|
||||
|
||||
升级为“Redis Stream 主事件日志 + SSE 浏览器传输 + SQLite fallback”的架构:
|
||||
|
||||
- Redis Stream 是生产环境实时事件主通道。
|
||||
- SSE 仍然是浏览器唯一实时入口。
|
||||
- 前端协议保持 `city_observation_patch.v1`,不让浏览器感知 Redis。
|
||||
- 继续使用 numeric `revision`,兼容现有前端 `since_revision` 逻辑。
|
||||
- 支持按城市订阅、断线 replay、多 worker fanout。
|
||||
- 事件只保留短窗口;当前默认按 Redis Stream `MAXLEN` 控制,建议以约 24 小时 replay 为目标,不做 72 小时保留。
|
||||
|
||||
## 非目标
|
||||
|
||||
这次不做以下事情:
|
||||
|
||||
- 不引入 Kafka、RabbitMQ 或复杂 consumer group。
|
||||
- 不把天气历史数据全部 event sourcing。
|
||||
- 不让浏览器直接连接 Redis。
|
||||
- 不把 DEB 预测、模型曲线、概率分布都改成事件流。
|
||||
- 不做永久行情归档;超过 replay 窗口时继续走 HTTP full detail resync。
|
||||
|
||||
## 推荐架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Collector["WeatherDataCollector / city refresh"] --> Ingest["POST /api/internal/collector-patch"]
|
||||
Ingest --> Normalize["city_observation_patch.v1 normalizer"]
|
||||
Normalize --> Store["RealtimeEventStore interface"]
|
||||
Store --> Redis["Redis Stream: stream:city_observation"]
|
||||
Store --> SQLite["SQLite fallback: observation_patch_events"]
|
||||
Redis --> WorkerSub["Redis XREAD subscriber per API worker"]
|
||||
WorkerSub --> LocalFanout["Local SSE fanout"]
|
||||
SQLite --> ReplayFallback["SQLite replay fallback"]
|
||||
SSE["GET /api/events?cities=...&since_revision=..."] --> Browser["Frontend EventSource"]
|
||||
LocalFanout --> SSE
|
||||
Redis --> SSE
|
||||
ReplayFallback --> SSE
|
||||
Browser --> Chart["Chart applies patch"]
|
||||
Browser -->|resync_required| Detail["HTTP /api/city/{city}/detail"]
|
||||
```
|
||||
|
||||
关键原则:
|
||||
|
||||
- Redis Stream 负责后端事件保存、短窗口 replay、多实例共享。
|
||||
- SSE 负责服务端到浏览器的单向推送。
|
||||
- SQLite 只做 fallback 和本地开发,不再作为生产主事件总线。
|
||||
- 前端只认 patch event,不关心事件来自 Redis 还是 SQLite。
|
||||
|
||||
## 事件模型
|
||||
|
||||
继续使用现有 schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "city_observation_patch.v1",
|
||||
"revision": 12345,
|
||||
"city": "taipei",
|
||||
"source": "cwa",
|
||||
"obs_time": "2026-05-27T10:00:00+08:00",
|
||||
"observed_at_utc": "2026-05-27T02:00:00Z",
|
||||
"observed_at_local": "2026-05-27T10:00:00+08:00",
|
||||
"city_local_date": "2026-05-27",
|
||||
"city_timezone": "Asia/Taipei",
|
||||
"source_cadence_sec": 600,
|
||||
"ts": 1780000000000,
|
||||
"payload": {
|
||||
"temp": 34.2,
|
||||
"max_so_far": 34.2,
|
||||
"station_code": "466920",
|
||||
"station_label": "中央气象署台北站",
|
||||
"series_key": "settlement",
|
||||
"unit": "celsius"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`revision` 仍然是前端公开排序键,必须是全局单调递增整数。
|
||||
|
||||
Redis Stream 自身的 stream ID,例如 `1780000000000-0`,只作为后端内部定位信息,不暴露给前端作为主 revision。
|
||||
|
||||
## Redis Stream 设计
|
||||
|
||||
Stream key:
|
||||
|
||||
```text
|
||||
stream:city_observation
|
||||
```
|
||||
|
||||
Revision key:
|
||||
|
||||
```text
|
||||
counter:city_observation_revision
|
||||
```
|
||||
|
||||
每条 stream entry 字段:
|
||||
|
||||
- `revision`: 全局递增整数,公开给 SSE/前端。
|
||||
- `type`: `city_observation_patch.v1`
|
||||
- `city`: normalized city key。
|
||||
- `source`: source code,例如 `cwa`, `metar`, `amos`, `amsc_awos`。
|
||||
- `obs_time`: 原始观测时间。
|
||||
- `payload_json`: compact JSON。
|
||||
- `created_at_ms`: 写入 Redis 的 UTC epoch ms。
|
||||
- `producer_id`: API worker / collector instance id,便于排查。
|
||||
|
||||
写入必须通过 Lua 脚本原子完成:
|
||||
|
||||
1. `INCR counter:city_observation_revision`
|
||||
2. `XADD stream:city_observation MAXLEN ~ {maxlen} * revision ... payload_json ...`
|
||||
3. 返回 `{revision, stream_id}`
|
||||
|
||||
原因:
|
||||
|
||||
- 避免 `INCR` 成功但 `XADD` 失败后造成非必要 gap。
|
||||
- 让 revision 分配和事件入流成为一个原子操作。
|
||||
- 保持现有前端 numeric `since_revision` 不变。
|
||||
|
||||
## Replay 策略
|
||||
|
||||
`GET /api/events` 收到 `since_revision` 后:
|
||||
|
||||
1. 读取 Redis Stream 最旧 entry,拿到 oldest revision。
|
||||
2. 如果 `since_revision > 0` 且 `since_revision < oldest_revision - 1`,发送 `resync_required`。
|
||||
3. 从 Redis Stream 顺序扫描事件。
|
||||
4. 过滤 `revision > since_revision`。
|
||||
5. 过滤 `city IN subscribed_cities`。
|
||||
6. 返回最多 `replay_limit + 1` 条。
|
||||
7. 如果超过 `replay_limit`,发送 `resync_required`,让前端 HTTP resync。
|
||||
|
||||
当前事件量很小,单个 stream 顺序扫描 24 小时事件是可以接受的:
|
||||
|
||||
- 30 城市 * 1 条/分钟 * 24 小时 = 43,200 条。
|
||||
- replay 通常只发生在断线重连,不是高频查询。
|
||||
|
||||
如果后续城市量扩大,或者确实需要超过 24 小时的 replay,再加 per-city stream 或 Redis sorted index。
|
||||
|
||||
## Live Fanout
|
||||
|
||||
每个 API worker 启动一个 Redis subscriber loop:
|
||||
|
||||
```text
|
||||
XREAD BLOCK 5000 STREAMS stream:city_observation {last_seen_stream_id}
|
||||
```
|
||||
|
||||
读取到事件后:
|
||||
|
||||
1. 解析 `city_observation_patch.v1`。
|
||||
2. 按本进程 SSE 连接的 `cities` subscription 过滤。
|
||||
3. 推送给本进程匹配的浏览器连接。
|
||||
4. 更新 worker 内存中的 `last_seen_stream_id`。
|
||||
|
||||
ingest endpoint 写入 Redis 后不直接广播本地连接,由 subscriber loop 统一 fanout。这样避免同一个 worker 上“写入后本地广播一次、subscriber 又广播一次”的重复事件。
|
||||
|
||||
前端仍然用 revision 去重。即使极端情况下收到重复 revision,也应忽略旧 revision。
|
||||
|
||||
## SQLite Fallback
|
||||
|
||||
保留当前 SQLite store,但角色调整为:
|
||||
|
||||
- 本地开发无需 Redis 时使用。
|
||||
- Redis 不可用且允许降级时使用。
|
||||
- 单实例临时运行时可用。
|
||||
|
||||
新增环境变量:
|
||||
|
||||
```text
|
||||
POLYWEATHER_EVENT_STORE=redis
|
||||
POLYWEATHER_EVENT_STORE_FALLBACK=sqlite
|
||||
POLYWEATHER_REDIS_URL=redis://127.0.0.1:6379/0
|
||||
POLYWEATHER_REDIS_STREAM_KEY=stream:city_observation
|
||||
POLYWEATHER_REDIS_STREAM_MAXLEN=50000
|
||||
POLYWEATHER_PATCH_EVENT_RETENTION_HOURS=24
|
||||
POLYWEATHER_REDIS_REQUIRED=true
|
||||
```
|
||||
|
||||
推荐生产策略:
|
||||
|
||||
- `POLYWEATHER_EVENT_STORE=redis`
|
||||
- `POLYWEATHER_REDIS_REQUIRED=true`
|
||||
- Redis 写入失败时 ingest 返回 503,不广播不可 replay 的事件。
|
||||
|
||||
推荐本地策略:
|
||||
|
||||
- 不配置 Redis,自动使用 SQLite。
|
||||
- 或 `POLYWEATHER_EVENT_STORE=sqlite`。
|
||||
|
||||
## Redis 容量与配置
|
||||
|
||||
当前 VPS 可以跑本机 Redis。
|
||||
|
||||
建议:
|
||||
|
||||
```text
|
||||
bind 127.0.0.1
|
||||
protected-mode yes
|
||||
appendonly yes
|
||||
appendfsync everysec
|
||||
maxmemory 512mb
|
||||
maxmemory-policy noeviction
|
||||
```
|
||||
|
||||
默认 `MAXLEN ~ 50000` 足够约 24 小时事件:
|
||||
|
||||
- 30 城市 * 1/min * 24h = 43,200 条。
|
||||
- payload 约 1-3 KB,实际内存取决于 Redis Stream overhead。
|
||||
- 512 MB Redis maxmemory 对当前事件量足够。
|
||||
|
||||
注意:这个事件流不是永久数据仓库。超过 24 小时窗口后,前端应 HTTP resync 当前城市 detail。
|
||||
|
||||
## 模块边界
|
||||
|
||||
新增/调整后端接口:
|
||||
|
||||
### `web/realtime_event_store.py`
|
||||
|
||||
定义统一接口:
|
||||
|
||||
```python
|
||||
class RealtimeEventStore:
|
||||
def append_event(self, event: dict) -> dict: ...
|
||||
def replay_events(self, *, cities: set[str] | None, since_revision: int, limit: int) -> list[dict]: ...
|
||||
def replay_requires_resync(self, *, cities: set[str] | None, since_revision: int, replay_count: int, limit: int) -> bool: ...
|
||||
def latest_revision(self) -> int: ...
|
||||
```
|
||||
|
||||
保留现有 SQLite 实现,新增 Redis 实现。
|
||||
|
||||
### `web/redis_realtime_event_store.py`
|
||||
|
||||
职责:
|
||||
|
||||
- Redis 连接与健康检查。
|
||||
- Lua append script。
|
||||
- Redis Stream replay。
|
||||
- maxlen trim。
|
||||
- entry 到 SSE event 的反序列化。
|
||||
|
||||
### `web/sse_manager.py`
|
||||
|
||||
职责:
|
||||
|
||||
- 管理本进程 SSE 连接。
|
||||
- 记录每个连接订阅城市集合。
|
||||
- 将 Redis subscriber 读到的 event fanout 给匹配连接。
|
||||
|
||||
### `web/routers/sse_router.py`
|
||||
|
||||
职责:
|
||||
|
||||
- `/api/internal/collector-patch` 规范化并写入 event store。
|
||||
- `/api/events` 处理 connected、replay、heartbeat、live stream。
|
||||
- 当 replay 不完整时发送 `resync_required`。
|
||||
|
||||
### 前端
|
||||
|
||||
前端原则上不需要大改:
|
||||
|
||||
- `use-sse-patches.ts` 继续使用 `EventSource`。
|
||||
- `lastRevision` 仍然是 number。
|
||||
- `resync_required` 继续触发当前可见城市 HTTP detail refresh。
|
||||
- 继续按可见城市列表构建 `cities` 参数。
|
||||
|
||||
## 运行流程
|
||||
|
||||
### 首屏
|
||||
|
||||
1. 前端 HTTP 加载 terminal rows。
|
||||
2. 可见图表 HTTP 加载 full detail。
|
||||
3. 前端连接 `/api/events?cities=...&since_revision=...&replay_limit=500`。
|
||||
4. 后端先 replay Redis Stream 中缺失事件,再进入 live stream。
|
||||
|
||||
### 新观测
|
||||
|
||||
1. 采集器产生 `city_patch` 或 v1 patch。
|
||||
2. ingest endpoint 规范化成 `city_observation_patch.v1`。
|
||||
3. Redis Lua script 写入 stream 并生成 revision。
|
||||
4. 每个 API worker 的 subscriber loop 读到事件。
|
||||
5. worker fanout 给订阅对应城市的 SSE 连接。
|
||||
6. 前端 apply patch,图表无痛追加点。
|
||||
|
||||
### 浏览器后台恢复
|
||||
|
||||
1. EventSource 如果断线,前端按指数退避重连。
|
||||
2. URL 带 `since_revision=lastRevision`。
|
||||
3. Redis replay 补齐后台期间错过的事件。
|
||||
4. 如果 revision 太旧或 replay 超限,前端收到 `resync_required`,HTTP 重拉 full detail。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- Redis append 失败且 `POLYWEATHER_REDIS_REQUIRED=true`:ingest 返回 503,不广播。
|
||||
- Redis append 失败且允许 fallback:写入 SQLite,并在 health 状态标记 degraded。
|
||||
- Redis replay 失败:SSE 发送 `resync_required`,然后继续尝试 live stream。
|
||||
- Redis subscriber loop 断开:指数退避重连;期间 SSE heartbeat 仍可维持连接,但不会有 live patch。
|
||||
- 前端收到 revision 倒退或重复:忽略。
|
||||
- 前端收到未知 event type:忽略并记录 debug。
|
||||
|
||||
## 监控与诊断
|
||||
|
||||
新增健康指标:
|
||||
|
||||
- 当前 event store mode: `redis` / `sqlite` / `degraded_sqlite`
|
||||
- Redis ping latency
|
||||
- latest revision
|
||||
- stream length
|
||||
- oldest revision
|
||||
- subscriber connected
|
||||
- SSE active connection count
|
||||
- dropped/resync_required count
|
||||
|
||||
建议暴露到现有 health endpoint 或日志:
|
||||
|
||||
```json
|
||||
{
|
||||
"realtime": {
|
||||
"store": "redis",
|
||||
"redis_connected": true,
|
||||
"stream_len": 43210,
|
||||
"latest_revision": 123456,
|
||||
"oldest_revision": 80200,
|
||||
"subscriber_connected": true,
|
||||
"sse_connections": 9
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 测试策略
|
||||
|
||||
后端:
|
||||
|
||||
- Redis store append 会生成全局递增 numeric revision。
|
||||
- Redis replay 支持 city 过滤。
|
||||
- Redis replay 支持 `since_revision`。
|
||||
- Redis replay 超出 retention/limit 返回 resync required。
|
||||
- Redis 不可用时,根据 env 严格失败或 fallback SQLite。
|
||||
- SQLite store 现有测试保持通过。
|
||||
- SSE router 在 Redis store 下仍发送 connected、replay、heartbeat。
|
||||
|
||||
前端:
|
||||
|
||||
- `use-sse-patches.ts` 保持 `revision` numeric。
|
||||
- 重连 URL 保留 `since_revision`。
|
||||
- 收到 `resync_required` 会触发 visible city refresh。
|
||||
- 重复 revision 不重复追加图表点。
|
||||
|
||||
验收命令:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_realtime_patch_schema.py tests/test_realtime_event_store.py tests/test_sse_replay.py
|
||||
python -m pytest tests/test_redis_realtime_event_store.py
|
||||
cd frontend
|
||||
npm run test:business
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 迁移步骤
|
||||
|
||||
1. 保留现有 SQLite implementation,抽出 event store factory。
|
||||
2. 新增 Redis implementation 和单元测试。
|
||||
3. 新增 Redis subscriber loop,但先在本地开发环境跑。
|
||||
4. `/api/events` 接入 event store factory。
|
||||
5. 线上安装 Redis,只监听 `127.0.0.1`。
|
||||
6. 先用 `POLYWEATHER_EVENT_STORE=redis`、`POLYWEATHER_REDIS_REQUIRED=false` 灰度。
|
||||
7. 观察 stream length、latest revision、SSE error、resync count。
|
||||
8. 稳定后切换 `POLYWEATHER_REDIS_REQUIRED=true`。
|
||||
9. 保留 SQLite fallback 代码,但生产不主动降级,避免多实例状态分裂。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 断开浏览器网络 1-5 分钟后恢复,图表自动补齐期间 patch。
|
||||
- 后台挂页面再回来,图表不需要等待 full loading 才更新当前点。
|
||||
- 多 worker 下,任意 worker 写入的 patch 都能推到其他 worker 的 SSE 连接。
|
||||
- `/api/events?cities=taipei,shanghai&since_revision=...` 只 replay 指定城市。
|
||||
- Redis 重启后,前端收到 `resync_required` 并 HTTP 重建画面。
|
||||
- 前端无需知道 Redis 存在。
|
||||
- 现有 `city_observation_patch.v1` schema 不破坏。
|
||||
|
||||
## 结论
|
||||
|
||||
这是一种“一步到位但不过度设计”的方案:
|
||||
|
||||
- 一步到位的是事件契约、replay、跨实例 fanout 和生产部署边界。
|
||||
- 不过度设计的是不用 Kafka、不做永久行情库、不重写前端协议。
|
||||
|
||||
对当前产品最关键的是:图表像股票行情一样无痛刷新,断线可补,后台恢复可追上,多实例以后不用再推倒实时层。
|
||||
@@ -0,0 +1,124 @@
|
||||
# Feedback Reward Account Detail Design
|
||||
|
||||
## Goal
|
||||
|
||||
Show users which submitted feedback earned points, how many points were awarded, and why.
|
||||
|
||||
The first implementation focuses on account-page visibility. The later ops workflow for automatic reward issuance should reuse the same fields and display contract.
|
||||
|
||||
## Current Context
|
||||
|
||||
- `user_feedback` already stores each submitted feedback item with `status`, `message`, `user_id`, `user_email`, timestamps, and context JSON.
|
||||
- `/api/feedback` already returns the current user's feedback list.
|
||||
- `AccountFeedbackPanel` already renders the current user's feedback in the account page.
|
||||
- Current points balance is shown through `/api/auth/me`, but account UI has no per-feedback reward source detail.
|
||||
- Existing points grant helpers update `users.points`; feedback-specific rewards are not yet linked back to a feedback row.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- Add feedback reward metadata to feedback rows.
|
||||
- Return reward metadata through the existing current-user feedback API.
|
||||
- Display feedback reward details in the account page next to the matching feedback item.
|
||||
- Keep the data contract ready for future ops automation: a processed feedback item can carry reward points and a human-readable reason.
|
||||
|
||||
Out of scope for this phase:
|
||||
|
||||
- Building the ops action that decides and grants points.
|
||||
- Sending push/email/Telegram notifications after ops processing.
|
||||
- Rebuilding a general points ledger for all point sources.
|
||||
- Merging historical Supabase referral ledger entries into the account page.
|
||||
|
||||
## Data Model
|
||||
|
||||
Extend `user_feedback` with nullable columns:
|
||||
|
||||
- `reward_points INTEGER DEFAULT 0`
|
||||
- `reward_reason TEXT DEFAULT ''`
|
||||
- `rewarded_at TIMESTAMP`
|
||||
- `reward_status TEXT DEFAULT ''`
|
||||
|
||||
Recommended `reward_status` values:
|
||||
|
||||
- empty string: no reward decision recorded
|
||||
- `granted`: points were awarded for this feedback
|
||||
- `skipped`: reviewed but no points awarded
|
||||
- `pending`: reward decision is queued or awaiting processing
|
||||
|
||||
The display should treat `reward_points > 0` as the strongest signal that the user earned points.
|
||||
|
||||
## API Contract
|
||||
|
||||
`DBManager._feedback_row_to_dict()` should include:
|
||||
|
||||
- `reward_points`
|
||||
- `reward_reason`
|
||||
- `rewarded_at`
|
||||
- `reward_status`
|
||||
|
||||
`GET /api/feedback` should continue using the same response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"feedback": [
|
||||
{
|
||||
"id": 123,
|
||||
"status": "resolved",
|
||||
"message": "Hong Kong COWIN reading looked stale",
|
||||
"reward_points": 300,
|
||||
"reward_reason": "Valid data freshness report",
|
||||
"rewarded_at": "2026-06-08T12:00:00",
|
||||
"reward_status": "granted"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The frontend proxy does not need a new route if it transparently forwards the existing payload.
|
||||
|
||||
## Account UI
|
||||
|
||||
`AccountFeedbackPanel` should show reward detail inside each feedback row:
|
||||
|
||||
- Granted reward: `+300 points` plus reward reason.
|
||||
- Skipped reward: a muted note that no points were awarded, with reason if available.
|
||||
- Pending reward: a small pending label.
|
||||
- No reward metadata: render nothing extra to avoid noise.
|
||||
|
||||
The account page should remain dense and scannable. Reward content belongs inside the existing feedback row, not in a separate marketing-style card.
|
||||
|
||||
## Future Ops Workflow
|
||||
|
||||
The later feedback-processing feature can use a single backend operation:
|
||||
|
||||
1. Update feedback status.
|
||||
2. Grant points to the matching user.
|
||||
3. Write `reward_points`, `reward_reason`, `rewarded_at`, and `reward_status = granted` back to the same feedback row.
|
||||
|
||||
If the grant fails, the operation should not mark the feedback as granted. It should either keep `reward_status = pending` or return a clear ops error.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Missing reward fields should default to zero or empty strings in API responses.
|
||||
- If old SQLite databases do not have the new columns, `DBManager` migration should add them with `_ensure_column`.
|
||||
- Account UI should tolerate partial payloads without crashing.
|
||||
- Reward reason should be bounded in storage and display to avoid oversized rows.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests:
|
||||
|
||||
- Existing feedback rows serialize reward fields with defaults.
|
||||
- Updating reward metadata for a feedback row returns the fields in `/api/feedback`.
|
||||
- Old rows without rewards still list normally.
|
||||
|
||||
Frontend tests:
|
||||
|
||||
- Account feedback panel displays `+N points` and reason for rewarded feedback.
|
||||
- Skipped and pending reward states render without implying points were granted.
|
||||
- Rows without reward metadata do not show an empty reward block.
|
||||
|
||||
## Rollout
|
||||
|
||||
This is a backward-compatible schema and UI change. Existing feedback rows keep working with empty reward metadata. The account page can ship before the ops reward action; it will simply show reward details once rows carry those fields.
|
||||
@@ -17,8 +17,8 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
|
||||
# 生产环境必须设为 https://polyweather.top
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather.top
|
||||
|
||||
# 常用:前端鉴权开关
|
||||
# true: 启用 Supabase 登录
|
||||
@@ -37,7 +37,7 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
# 可选:钱包支付 / Telegram 入口
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
|
||||
+3
-3
@@ -18,7 +18,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
|
||||
|
||||
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
||||
# 生产环境必须设为 https://polyweather.top
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather.top
|
||||
|
||||
# 常用:前端鉴权开关
|
||||
@@ -43,9 +43,9 @@ POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
# 可选:钱包支付 / Telegram 入口
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=8ce6332b57a42e606cf28224e82d1e02
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
|
||||
@@ -3,3 +3,6 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather.top
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=https://api.polyweather.top
|
||||
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=8ce6332b57a42e606cf28224e82d1e02
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
|
||||
@@ -11,12 +11,18 @@ ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG NEXT_PUBLIC_SITE_URL
|
||||
ARG NEXT_PUBLIC_POLYWEATHER_API_BASE_URL
|
||||
ARG NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
|
||||
ARG NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
|
||||
ARG NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL
|
||||
ARG NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS
|
||||
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||
ENV NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=$NEXT_PUBLIC_POLYWEATHER_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=$NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
|
||||
ENV NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=$NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
|
||||
ENV NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=$NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL
|
||||
ENV NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=$NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY --from=deps /app/frontend/node_modules ./node_modules
|
||||
|
||||
+23
-26
@@ -3,13 +3,13 @@
|
||||
PolyWeather Pro 的生产前端工程。
|
||||
|
||||
线上地址:
|
||||
- [https://polyweather-pro.vercel.app/](https://polyweather-pro.vercel.app/)
|
||||
- [https://polyweather.top/](https://polyweather.top/)
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Next.js App Router
|
||||
- React + Tailwind
|
||||
- Leaflet + Chart.js
|
||||
- 自研温度图表 + Recharts 运营图表
|
||||
- Supabase Auth
|
||||
- WalletConnect + 浏览器 EVM 钱包
|
||||
|
||||
@@ -21,26 +21,20 @@ PolyWeather Pro 的生产前端工程。
|
||||
|
||||
## 当前前端能力
|
||||
|
||||
- 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心
|
||||
- `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF 和结算来源
|
||||
- 今日日内分析支持:
|
||||
- `锚点状态`
|
||||
- `当前节奏`
|
||||
- `校准模型概率`
|
||||
- `模型区间与分歧`
|
||||
- `专业气象结论条`
|
||||
- `气象证据链 / 失效条件 / 确认条件`
|
||||
- 非香港机场城市的 `TAF` 时段提示与走势图联动
|
||||
- 主站为实时天气决策台,包含 `天气决策 / 训练数据 / 使用指南` 三个主入口
|
||||
- `/docs` 提供公开双语产品文档中心,当前保留简介、图表阅读、实时数据频率、结算站点和浏览器插件说明
|
||||
- 天气决策台支持 1x1 到 3x3 图表槽位,可按区域、搜索和城市选择进行多城市巡检
|
||||
- 终端图表默认展示全天,可切换高温窗口;所有横轴与 tooltip 时间按城市当地时间渲染
|
||||
- 可见终端图表通过 SSE patch 增量刷新,后台切回前台时主动补齐最新 detail,不用 loading 遮罩覆盖已有曲线
|
||||
- AMSC/AMOS 跑道城市默认展示结算跑道曲线并高亮,辅助跑道弱化展示;单跑道机场不重复展示聚合线
|
||||
- 香港默认展示 CoWIN 6087 参考站 1 分钟曲线,并保留 HKO 10 分钟官方气象层
|
||||
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
|
||||
- 使用指南内置图表阅读顺序、图层含义、常用操作和默认可见性规则
|
||||
- `/ops` 已支持桌面表格 + 手机端卡片化视图
|
||||
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住
|
||||
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
|
||||
- 右侧详情面板在多日预报仍未补齐时会显示同步占位卡,不再把“只有今天一张卡”的中间态伪装成完整数据
|
||||
- 日内分析弹窗在 full detail / market scan 同步时会锁住旧内容并显示刷新状态,避免用户短暂看到旧城市或旧日期的数据
|
||||
- 城市决策卡支持从地图点击城市进入;机会榜和日历仍按 Pro 权限控制,地图探索和城市简报可作为轻量入口
|
||||
- 城市决策卡的 AI 机场报文解读包括最终判断、METAR 解读、推理说明、模型集群备注、风险提示和原始 METAR
|
||||
- AI 机场报文解读按 `city + local_date + locale + METAR signature` 做页面内存缓存和 `localStorage` 最终结果缓存;切换选项卡返回时会优先恢复已有内容
|
||||
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
|
||||
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
|
||||
- 城市 detail 自动识别稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
|
||||
- 市场价格层使用完整 `all_buckets` 匹配温度桶,市场信号作为交易判断层,不替代实测结算源
|
||||
- legacy 高斯概率在图表上展示为概率温度带和 `mu` 参考线,不作为时间序列曲线
|
||||
- 账户中心支付区支持后端下发的多链网络选择;Polygon 继续走 checkout 合约,Ethereum 主网 USDC 走手动直转确认
|
||||
- 缓存桶状态与 summary cache hit/miss
|
||||
|
||||
## 本地开发
|
||||
@@ -90,7 +84,7 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
# 钱包支付
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
|
||||
# 社群入口
|
||||
@@ -144,7 +138,7 @@ Ops:
|
||||
|
||||
当前前端已内置轻量管理页:
|
||||
|
||||
- [https://polyweather-pro.vercel.app/ops](https://polyweather-pro.vercel.app/ops)
|
||||
- [https://polyweather.top/ops](https://polyweather.top/ops)
|
||||
|
||||
页面当前支持:
|
||||
|
||||
@@ -172,7 +166,8 @@ Ops:
|
||||
2. 若 `receiver_contract` 已更新,先切到最新地址
|
||||
3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付
|
||||
4. 仅允许在 `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` 白名单域名上创建 payment intent
|
||||
5. 支付区会明确显示当前账号、付款钱包和收款合约,避免账号/钱包/地址混淆
|
||||
5. 支付区会明确显示当前账号、付款钱包、支付网络和收款合约/钱包,避免账号/钱包/地址/链混淆
|
||||
6. 多链支付时,前端会把用户选择的 `chain_id` 和 `token_address` 发给后端,由后端按 intent 的链确认交易
|
||||
|
||||
这意味着:
|
||||
|
||||
@@ -185,7 +180,9 @@ Ops:
|
||||
- `summary?force_refresh=true`:`no-store`
|
||||
- 支付相关路由:`no-store`
|
||||
- 当 detail 缓存只返回单模型或单日 forecast 时,前端会自动强刷完整 detail,并在补齐前显示同步提示 / 占位卡
|
||||
- 今日日内分析打开时如果正在切换城市、日期或 detail 深度,弹窗会阻断旧内容点击并显示刷新锁
|
||||
- detail 正在切换城市、日期或分辨率时,图表保留已有曲线并显示同步提示,避免把旧数据当作当前状态
|
||||
- 终端图表订阅 `/api/events?cities=...&since_revision=...&replay_limit=按可见城市数动态限制`,接收 `city_observation_patch.v1`;无 patch 超过 2 分钟时,可见图表才触发 60 秒兜底刷新
|
||||
- 前端只消费 HTTP snapshot + SSE patch,不直接感知 Redis;Redis Stream / SQLite event log 都由后端统一封装
|
||||
|
||||
## Vercel 节流建议
|
||||
|
||||
@@ -200,4 +197,4 @@ Ops:
|
||||
|
||||
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
|
||||
|
||||
最后更新:`2026-05-23`
|
||||
最后更新:`2026-05-29`
|
||||
|
||||
@@ -7,49 +7,121 @@ import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import {
|
||||
createProxyTimer,
|
||||
finishProxyTimedResponse,
|
||||
} from "@/lib/proxy-timing";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const ANALYTICS_ENABLED =
|
||||
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS !== "false";
|
||||
const ANALYTICS_PROXY_TIMEOUT_MS = Math.max(
|
||||
250,
|
||||
Number(process.env.POLYWEATHER_ANALYTICS_PROXY_TIMEOUT_MS || "1500") || 1500,
|
||||
);
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const timer = createProxyTimer(req, "analytics_events");
|
||||
if (!ANALYTICS_ENABLED) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
return finishProxyTimedResponse(
|
||||
new NextResponse(null, { status: 204 }),
|
||||
timer,
|
||||
"disabled",
|
||||
);
|
||||
}
|
||||
|
||||
if (!API_BASE) {
|
||||
return finishProxyTimedResponse(
|
||||
NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
),
|
||||
timer,
|
||||
"missing_api_base",
|
||||
);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), ANALYTICS_PROXY_TIMEOUT_MS);
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const body = await timer.measure("request_read", () => req.json());
|
||||
const payload =
|
||||
body && typeof body.payload === "object" && body.payload != null
|
||||
? body.payload
|
||||
: {};
|
||||
const enrichedBody = {
|
||||
...(body ?? {}),
|
||||
payload: {
|
||||
...payload,
|
||||
cf_country:
|
||||
req.headers.get("cf-ipcountry") ||
|
||||
req.headers.get("x-vercel-ip-country") ||
|
||||
"",
|
||||
user_agent: req.headers.get("user-agent") || "",
|
||||
referer_header: req.headers.get("referer") || "",
|
||||
},
|
||||
};
|
||||
auth = await timer.measure("auth_headers", () =>
|
||||
buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
}),
|
||||
);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/analytics/events`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
const res = await timer.measure("backend_fetch", () =>
|
||||
fetch(`${API_BASE}/api/analytics/events`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(enrichedBody),
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
const backendServerTiming = res.headers.get("server-timing") || "";
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const raw = await timer.measure("backend_read", () => res.text());
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 260,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishProxyTimedResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
`upstream_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
const data = await timer.measure("backend_read", () => res.json());
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishProxyTimedResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
"ok",
|
||||
{ backendServerTiming },
|
||||
);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to track analytics event",
|
||||
});
|
||||
const timedOut = controller.signal.aborted;
|
||||
const response = timedOut
|
||||
? NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
accepted: true,
|
||||
dropped: true,
|
||||
reason: "timeout",
|
||||
},
|
||||
{ status: 202 },
|
||||
)
|
||||
: buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to track analytics event",
|
||||
});
|
||||
const withCookies = auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
return finishProxyTimedResponse(
|
||||
withCookies,
|
||||
timer,
|
||||
timedOut ? "timeout_accepted" : "exception",
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,104 +4,689 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import {
|
||||
getLocalDevAuthPayload,
|
||||
isLocalFullAccessHost,
|
||||
} from "@/lib/local-dev-access";
|
||||
import {
|
||||
applyEntitlementSnapshotCookie,
|
||||
clearEntitlementSnapshotCookie,
|
||||
entitlementSnapshotToAuthPayload,
|
||||
readEntitlementSnapshot,
|
||||
} from "@/lib/entitlement-snapshot";
|
||||
import {
|
||||
buildSubscriptionRequiredAuthProfile,
|
||||
isSubscriptionRequiredBackendResponse,
|
||||
} from "@/lib/auth-profile-proxy";
|
||||
import {
|
||||
hasSupabaseServerEnv,
|
||||
hasSupabaseSessionCookieValues,
|
||||
} from "@/lib/supabase/server";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
type AuthMeTimingStage = {
|
||||
durationMs: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type AuthMeTimer = {
|
||||
hasAuthorization: boolean;
|
||||
hasSupabaseCookie: boolean;
|
||||
measure<T>(name: string, action: () => Promise<T>): Promise<T>;
|
||||
measureSync<T>(name: string, action: () => T): T;
|
||||
preferSnapshot: boolean;
|
||||
stages: AuthMeTimingStage[];
|
||||
totalMs(): number;
|
||||
};
|
||||
|
||||
function authMeNowMs() {
|
||||
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function createAuthMeTimer(req: NextRequest): AuthMeTimer {
|
||||
const startedAt = authMeNowMs();
|
||||
const stages: AuthMeTimingStage[] = [];
|
||||
const recordStage = (name: string, stageStartedAt: number) => {
|
||||
stages.push({
|
||||
durationMs: Math.round((authMeNowMs() - stageStartedAt) * 10) / 10,
|
||||
name,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
hasAuthorization: Boolean(req.headers.get("authorization")),
|
||||
hasSupabaseCookie: hasRequestSupabaseSessionCookie(req),
|
||||
async measure<T>(name: string, action: () => Promise<T>) {
|
||||
const stageStartedAt = authMeNowMs();
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
recordStage(name, stageStartedAt);
|
||||
}
|
||||
},
|
||||
measureSync<T>(name: string, action: () => T) {
|
||||
const stageStartedAt = authMeNowMs();
|
||||
try {
|
||||
return action();
|
||||
} finally {
|
||||
recordStage(name, stageStartedAt);
|
||||
}
|
||||
},
|
||||
preferSnapshot: req.nextUrl.searchParams.get("prefer_snapshot") === "1",
|
||||
stages,
|
||||
totalMs() {
|
||||
return Math.round((authMeNowMs() - startedAt) * 10) / 10;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatServerTiming(stages: AuthMeTimingStage[], totalMs: number) {
|
||||
return [...stages, { durationMs: totalMs, name: "total" }]
|
||||
.map(({ durationMs, name }) => {
|
||||
const safeName = name.replace(/[^A-Za-z0-9_-]/g, "_");
|
||||
return `${safeName};dur=${Math.max(0, durationMs).toFixed(1)}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function buildBackendAuthMeUrl(req: NextRequest) {
|
||||
const url = new URL(`${API_BASE!.replace(/\/+$/, "")}/api/auth/me`);
|
||||
if (req.nextUrl.searchParams.get("scope") === "entitlement") {
|
||||
url.searchParams.set("scope", "entitlement");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function finishAuthMeResponse(
|
||||
response: NextResponse,
|
||||
timer: AuthMeTimer,
|
||||
outcome: string,
|
||||
extra?: { backendServerTiming?: string },
|
||||
) {
|
||||
const total = timer.totalMs();
|
||||
const ownServerTiming = formatServerTiming(timer.stages, total);
|
||||
const backendServerTiming = String(extra?.backendServerTiming || "").trim();
|
||||
response.headers.set("Cache-Control", "no-store");
|
||||
response.headers.set(
|
||||
"Server-Timing",
|
||||
backendServerTiming
|
||||
? `${ownServerTiming}, ${backendServerTiming}`
|
||||
: ownServerTiming,
|
||||
);
|
||||
console.info(
|
||||
"[auth-me-timing]",
|
||||
JSON.stringify({
|
||||
backendServerTiming: backendServerTiming || undefined,
|
||||
hasAuthorization: timer.hasAuthorization,
|
||||
hasSupabaseCookie: timer.hasSupabaseCookie,
|
||||
outcome,
|
||||
preferSnapshot: timer.preferSnapshot,
|
||||
stagesMs: Object.fromEntries(
|
||||
timer.stages.map((stage) => [stage.name, stage.durationMs]),
|
||||
),
|
||||
status: response.status,
|
||||
totalMs: total,
|
||||
}),
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function trackAuthDiagnosticEvent(
|
||||
req: NextRequest,
|
||||
{
|
||||
email,
|
||||
reason,
|
||||
responseMode,
|
||||
userId,
|
||||
}: {
|
||||
email: string | null;
|
||||
reason: string;
|
||||
responseMode: "snapshot" | "degraded" | "anonymous";
|
||||
userId?: string | null;
|
||||
},
|
||||
) {
|
||||
if (!API_BASE) return;
|
||||
const normalizedUserId = String(userId || "").trim();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1200);
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/analytics/events`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
event_type: "degraded_auth_profile",
|
||||
client_id: normalizedUserId ? `auth:${normalizedUserId}` : undefined,
|
||||
payload: {
|
||||
route: "/api/auth/me",
|
||||
reason: String(reason || "unknown").slice(0, 240),
|
||||
response_mode: responseMode,
|
||||
user_id: normalizedUserId || undefined,
|
||||
email_domain: email?.includes("@") ? email.split("@").pop() : undefined,
|
||||
cf_country:
|
||||
req.headers.get("cf-ipcountry") ||
|
||||
req.headers.get("x-vercel-ip-country") ||
|
||||
"",
|
||||
user_agent: req.headers.get("user-agent") || "",
|
||||
referer_header: req.headers.get("referer") || "",
|
||||
captured_at: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics must never block auth/profile fallback responses.
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
type VerifiedBearerIdentity = {
|
||||
email: string | null;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
function extractBearerToken(headerValue: string | null) {
|
||||
if (!headerValue) return "";
|
||||
const parts = headerValue.trim().split(/\s+/);
|
||||
if (parts.length === 2 && parts[0].toLowerCase() === "bearer") {
|
||||
return parts[1];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function getVerifiedBearerIdentity(
|
||||
req: NextRequest,
|
||||
): Promise<VerifiedBearerIdentity | null> {
|
||||
const token = extractBearerToken(req.headers.get("authorization"));
|
||||
if (!token) return null;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
|
||||
if (!supabaseUrl || !anonKey) return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 4000);
|
||||
try {
|
||||
const res = await fetch(`${supabaseUrl.replace(/\/+$/, "")}/auth/v1/user`, {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const user = await res.json();
|
||||
const userId = String(user?.id || "").trim();
|
||||
if (!userId) return null;
|
||||
return {
|
||||
email: String(user?.email || "").trim() || null,
|
||||
userId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function degradedAuthProfileResponse({
|
||||
email,
|
||||
reason,
|
||||
req,
|
||||
response,
|
||||
userId,
|
||||
}: {
|
||||
email: string | null;
|
||||
reason: string;
|
||||
req: NextRequest;
|
||||
response: NextResponse | null;
|
||||
userId: string;
|
||||
}) {
|
||||
const snapshotPayload = entitlementSnapshotToAuthPayload(
|
||||
readEntitlementSnapshot(req, userId),
|
||||
);
|
||||
if (snapshotPayload) {
|
||||
await trackAuthDiagnosticEvent(req, {
|
||||
email: snapshotPayload.email || email,
|
||||
reason,
|
||||
responseMode: "snapshot",
|
||||
userId,
|
||||
});
|
||||
const snapshotResponse = NextResponse.json({
|
||||
...snapshotPayload,
|
||||
email: snapshotPayload.email || email,
|
||||
entitlement_snapshot_reason: reason,
|
||||
});
|
||||
return applyAuthResponseCookies(snapshotResponse, response);
|
||||
}
|
||||
|
||||
await trackAuthDiagnosticEvent(req, {
|
||||
email,
|
||||
reason,
|
||||
responseMode: "degraded",
|
||||
userId,
|
||||
});
|
||||
const degraded = NextResponse.json({
|
||||
authenticated: true,
|
||||
user_id: userId,
|
||||
email,
|
||||
subscription_active: null,
|
||||
subscription_plan_code: null,
|
||||
subscription_expires_at: null,
|
||||
subscription_total_expires_at: null,
|
||||
subscription_queued_days: 0,
|
||||
subscription_queued_count: 0,
|
||||
points: 0,
|
||||
degraded_auth_profile: true,
|
||||
degraded_reason: reason,
|
||||
});
|
||||
return applyAuthResponseCookies(degraded, response);
|
||||
}
|
||||
|
||||
function subscriptionRequiredAuthProfileResponse({
|
||||
email,
|
||||
response,
|
||||
userId,
|
||||
}: {
|
||||
email: string | null;
|
||||
response: NextResponse | null;
|
||||
userId: string;
|
||||
}) {
|
||||
const inactive = NextResponse.json(
|
||||
buildSubscriptionRequiredAuthProfile({ email, userId }),
|
||||
);
|
||||
clearEntitlementSnapshotCookie(inactive);
|
||||
return applyAuthResponseCookies(inactive, response);
|
||||
}
|
||||
|
||||
async function unauthenticatedAuthProfileResponse({
|
||||
reason,
|
||||
req,
|
||||
response,
|
||||
}: {
|
||||
reason: string;
|
||||
req: NextRequest;
|
||||
response: NextResponse | null;
|
||||
}) {
|
||||
await trackAuthDiagnosticEvent(req, {
|
||||
email: null,
|
||||
reason,
|
||||
responseMode: "anonymous",
|
||||
});
|
||||
const anonymous = NextResponse.json(
|
||||
{
|
||||
authenticated: false,
|
||||
subscription_active: false,
|
||||
points: 0,
|
||||
degraded_auth_profile: true,
|
||||
degraded_reason: reason,
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
clearEntitlementSnapshotCookie(anonymous);
|
||||
return applyAuthResponseCookies(anonymous, response);
|
||||
}
|
||||
|
||||
function snapshotAuthProfileResponse({
|
||||
email,
|
||||
reason,
|
||||
req,
|
||||
response,
|
||||
userId,
|
||||
}: {
|
||||
email: string | null;
|
||||
reason: string;
|
||||
req: NextRequest;
|
||||
response: NextResponse | null;
|
||||
userId: string;
|
||||
}) {
|
||||
const snapshotPayload = entitlementSnapshotToAuthPayload(
|
||||
readEntitlementSnapshot(req, userId),
|
||||
);
|
||||
if (!snapshotPayload) return null;
|
||||
const snapshotResponse = NextResponse.json({
|
||||
...snapshotPayload,
|
||||
email: snapshotPayload.email || email,
|
||||
entitlement_snapshot_reason: reason,
|
||||
});
|
||||
return applyAuthResponseCookies(snapshotResponse, response);
|
||||
}
|
||||
|
||||
function applyEntitlementSnapshotFromAuthPayload(
|
||||
response: NextResponse,
|
||||
data: Record<string, unknown>,
|
||||
) {
|
||||
if (data.authenticated === true && data.subscription_active === true) {
|
||||
return applyEntitlementSnapshotCookie(response, data);
|
||||
}
|
||||
if (data.authenticated === false || data.subscription_active === false) {
|
||||
return clearEntitlementSnapshotCookie(response);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function hasRequestSupabaseSessionCookie(req: NextRequest) {
|
||||
return hasSupabaseSessionCookieValues(
|
||||
req.cookies.getAll().map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const timer = createAuthMeTimer(req);
|
||||
const requestHost =
|
||||
req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host;
|
||||
if (
|
||||
isLocalFullAccessHost(requestHost) ||
|
||||
isLocalFullAccessHost(req.nextUrl.hostname)
|
||||
) {
|
||||
return NextResponse.json(getLocalDevAuthPayload(), {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
return finishAuthMeResponse(
|
||||
NextResponse.json(getLocalDevAuthPayload(), {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
}),
|
||||
timer,
|
||||
"local_full_access",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/api/auth/me`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
if (!API_BASE) {
|
||||
return finishAuthMeResponse(
|
||||
NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
),
|
||||
timer,
|
||||
"missing_api_base",
|
||||
);
|
||||
}
|
||||
|
||||
const preferSnapshot = req.nextUrl.searchParams.get("prefer_snapshot") === "1";
|
||||
if (
|
||||
preferSnapshot &&
|
||||
!req.headers.get("authorization") &&
|
||||
hasRequestSupabaseSessionCookie(req)
|
||||
) {
|
||||
const snapshotPayload = timer.measureSync(
|
||||
"snapshot_cookie",
|
||||
() => entitlementSnapshotToAuthPayload(readEntitlementSnapshot(req)),
|
||||
);
|
||||
if (snapshotPayload) {
|
||||
return finishAuthMeResponse(
|
||||
NextResponse.json(
|
||||
{
|
||||
...snapshotPayload,
|
||||
entitlement_snapshot_reason: "prefer_snapshot_fast_path",
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
),
|
||||
timer,
|
||||
"prefer_snapshot_fast_path",
|
||||
);
|
||||
}
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
}
|
||||
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
let bearerIdentity: VerifiedBearerIdentity | null | undefined;
|
||||
const getBearerIdentityOnce = async () => {
|
||||
if (bearerIdentity !== undefined) return bearerIdentity;
|
||||
bearerIdentity = await timer.measure(
|
||||
"bearer_identity",
|
||||
() => getVerifiedBearerIdentity(req),
|
||||
);
|
||||
return bearerIdentity;
|
||||
};
|
||||
try {
|
||||
auth = await timer.measure(
|
||||
"auth_headers",
|
||||
() => buildBackendRequestHeaders(req),
|
||||
);
|
||||
if (
|
||||
hasSupabaseServerEnv() &&
|
||||
!auth.authUserId &&
|
||||
!req.headers.get("authorization")
|
||||
) {
|
||||
const response = NextResponse.json({
|
||||
authenticated: false,
|
||||
subscription_active: false,
|
||||
points: 0,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
if (!preferSnapshot) clearEntitlementSnapshotCookie(response);
|
||||
return finishAuthMeResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
"no_session",
|
||||
);
|
||||
}
|
||||
|
||||
if (preferSnapshot) {
|
||||
const identity =
|
||||
auth.authUserId
|
||||
? { email: auth.authEmail || null, userId: auth.authUserId }
|
||||
: await getBearerIdentityOnce();
|
||||
if (identity?.userId) {
|
||||
const snapshotResponse = snapshotAuthProfileResponse({
|
||||
email: identity.email,
|
||||
reason: "prefer_snapshot",
|
||||
req,
|
||||
response: auth.response,
|
||||
userId: identity.userId,
|
||||
});
|
||||
if (snapshotResponse) {
|
||||
return finishAuthMeResponse(
|
||||
snapshotResponse,
|
||||
timer,
|
||||
"prefer_snapshot",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!auth) throw new Error("auth headers unavailable");
|
||||
const backendAuth = auth;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await timer.measure("backend_fetch", async () =>
|
||||
await fetch(buildBackendAuthMeUrl(req), {
|
||||
headers: backendAuth.headers,
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
const backendServerTiming = res.headers.get("server-timing") || "";
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const raw = await timer.measure("backend_read", () => res.text());
|
||||
const authIdentity = auth.authUserId
|
||||
? { email: auth.authEmail || null, userId: auth.authUserId }
|
||||
: await getBearerIdentityOnce();
|
||||
if (
|
||||
authIdentity?.userId &&
|
||||
isSubscriptionRequiredBackendResponse(res.status, raw)
|
||||
) {
|
||||
return finishAuthMeResponse(
|
||||
subscriptionRequiredAuthProfileResponse({
|
||||
email: authIdentity.email,
|
||||
response: auth.response,
|
||||
userId: authIdentity.userId,
|
||||
}),
|
||||
timer,
|
||||
"subscription_required",
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
if (auth.authUserId) {
|
||||
return finishAuthMeResponse(
|
||||
await degradedAuthProfileResponse({
|
||||
email: auth.authEmail || null,
|
||||
reason: `backend_${res.status}`,
|
||||
req,
|
||||
response: auth.response,
|
||||
userId: auth.authUserId,
|
||||
}),
|
||||
timer,
|
||||
`degraded_backend_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
const identity = await getBearerIdentityOnce();
|
||||
if (identity) {
|
||||
return finishAuthMeResponse(
|
||||
await degradedAuthProfileResponse({
|
||||
email: identity.email,
|
||||
reason: `backend_${res.status}`,
|
||||
req,
|
||||
response: auth.response,
|
||||
userId: identity.userId,
|
||||
}),
|
||||
timer,
|
||||
`degraded_backend_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
const response = NextResponse.json({
|
||||
authenticated: false,
|
||||
subscription_active: false,
|
||||
points: 0,
|
||||
});
|
||||
clearEntitlementSnapshotCookie(response);
|
||||
return finishAuthMeResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
`anonymous_backend_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const raw = await timer.measure("backend_read", () => res.text());
|
||||
if (auth.authUserId) {
|
||||
const response = NextResponse.json({
|
||||
authenticated: true,
|
||||
user_id: auth.authUserId,
|
||||
email: auth.authEmail || null,
|
||||
subscription_active: null,
|
||||
subscription_plan_code: null,
|
||||
subscription_expires_at: null,
|
||||
subscription_total_expires_at: null,
|
||||
subscription_queued_days: 0,
|
||||
subscription_queued_count: 0,
|
||||
points: 0,
|
||||
degraded_auth_profile: true,
|
||||
degraded_reason: `backend_${res.status}`,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishAuthMeResponse(
|
||||
await degradedAuthProfileResponse({
|
||||
email: auth.authEmail || null,
|
||||
reason: `backend_${res.status}`,
|
||||
req,
|
||||
response: auth.response,
|
||||
userId: auth.authUserId,
|
||||
}),
|
||||
timer,
|
||||
`degraded_backend_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
const identity = await getBearerIdentityOnce();
|
||||
if (identity) {
|
||||
return finishAuthMeResponse(
|
||||
await degradedAuthProfileResponse({
|
||||
email: identity.email,
|
||||
reason: `backend_${res.status}`,
|
||||
req,
|
||||
response: auth.response,
|
||||
userId: identity.userId,
|
||||
}),
|
||||
timer,
|
||||
`degraded_backend_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishAuthMeResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
`upstream_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
const data = await timer.measure("backend_read", () => res.json());
|
||||
if (data?.authenticated === true && data?.subscription_active == null) {
|
||||
const userId = String(data.user_id || auth.authUserId || "").trim();
|
||||
if (userId) {
|
||||
const snapshotResponse = snapshotAuthProfileResponse({
|
||||
email: String(data.email || auth.authEmail || "").trim() || null,
|
||||
reason: "subscription_unknown",
|
||||
req,
|
||||
response: auth.response,
|
||||
userId,
|
||||
});
|
||||
if (snapshotResponse) {
|
||||
return finishAuthMeResponse(
|
||||
snapshotResponse,
|
||||
timer,
|
||||
"subscription_unknown_snapshot",
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
applyEntitlementSnapshotFromAuthPayload(response, data);
|
||||
return finishAuthMeResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
"ok",
|
||||
{ backendServerTiming },
|
||||
);
|
||||
} catch (error) {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
if (auth.authUserId) {
|
||||
const response = NextResponse.json({
|
||||
authenticated: true,
|
||||
user_id: auth.authUserId,
|
||||
email: auth.authEmail || null,
|
||||
subscription_active: null,
|
||||
subscription_plan_code: null,
|
||||
subscription_expires_at: null,
|
||||
subscription_total_expires_at: null,
|
||||
subscription_queued_days: 0,
|
||||
subscription_queued_count: 0,
|
||||
points: 0,
|
||||
degraded_auth_profile: true,
|
||||
degraded_reason: String(error),
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
if (auth?.authUserId) {
|
||||
return finishAuthMeResponse(
|
||||
await degradedAuthProfileResponse({
|
||||
email: auth.authEmail || null,
|
||||
reason: String(error),
|
||||
req,
|
||||
response: auth.response,
|
||||
userId: auth.authUserId,
|
||||
}),
|
||||
timer,
|
||||
"exception_degraded",
|
||||
);
|
||||
}
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch auth profile",
|
||||
});
|
||||
const identity = await getBearerIdentityOnce();
|
||||
if (identity) {
|
||||
return finishAuthMeResponse(
|
||||
await degradedAuthProfileResponse({
|
||||
email: identity.email,
|
||||
reason: String(error),
|
||||
req,
|
||||
response: auth?.response || null,
|
||||
userId: identity.userId,
|
||||
}),
|
||||
timer,
|
||||
"exception_degraded",
|
||||
);
|
||||
}
|
||||
const snapshotPayload = timer.measureSync(
|
||||
"snapshot_cookie",
|
||||
() => entitlementSnapshotToAuthPayload(readEntitlementSnapshot(req)),
|
||||
);
|
||||
if (snapshotPayload) {
|
||||
const snapshotResponse = NextResponse.json({
|
||||
...snapshotPayload,
|
||||
entitlement_snapshot_reason: "exception_snapshot",
|
||||
});
|
||||
return finishAuthMeResponse(
|
||||
applyAuthResponseCookies(snapshotResponse, auth?.response || null),
|
||||
timer,
|
||||
"exception_snapshot",
|
||||
);
|
||||
}
|
||||
return finishAuthMeResponse(
|
||||
await unauthenticatedAuthProfileResponse({
|
||||
reason: String(error),
|
||||
req,
|
||||
response: auth?.response || null,
|
||||
}),
|
||||
timer,
|
||||
"exception_anonymous",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const body = await req.text();
|
||||
const res = await fetch(`${API_BASE}/api/auth/referral/apply`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...auth.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to apply referral code",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
import {
|
||||
createProxyTimer,
|
||||
finishProxyTimedResponse,
|
||||
} from "@/lib/proxy-timing";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const DETAIL_BATCH_PROXY_TIMEOUT_MS = Number(
|
||||
process.env.POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS || "15000",
|
||||
);
|
||||
|
||||
function parseRequestedCities(req: NextRequest) {
|
||||
const requestedLimit = Number(req.nextUrl.searchParams.get("limit") || "12");
|
||||
const limit = Number.isFinite(requestedLimit)
|
||||
? Math.max(1, Math.min(24, requestedLimit))
|
||||
: 12;
|
||||
const seen = new Set<string>();
|
||||
const requestedCities: string[] = [];
|
||||
|
||||
for (const item of (req.nextUrl.searchParams.get("cities") || "").split(
|
||||
",",
|
||||
)) {
|
||||
const city = item.trim();
|
||||
if (!city || seen.has(city)) continue;
|
||||
seen.add(city);
|
||||
requestedCities.push(city);
|
||||
if (requestedCities.length >= limit) break;
|
||||
}
|
||||
|
||||
return requestedCities;
|
||||
}
|
||||
|
||||
function buildCityDetailBatchTimeoutPayload(requestedCities: string[]) {
|
||||
return {
|
||||
cities: requestedCities,
|
||||
details: {},
|
||||
errors: {},
|
||||
missing: requestedCities,
|
||||
partial: true,
|
||||
timeout: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const timer = createProxyTimer(req, "city_detail_batch");
|
||||
if (!API_BASE) {
|
||||
return finishProxyTimedResponse(
|
||||
NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
),
|
||||
timer,
|
||||
"missing_api_base",
|
||||
);
|
||||
}
|
||||
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const requestedCities = parseRequestedCities(req);
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
|
||||
const searchParams = new URLSearchParams({
|
||||
cities: req.nextUrl.searchParams.get("cities") || "",
|
||||
force_refresh: forceRefresh,
|
||||
limit: req.nextUrl.searchParams.get("limit") || "12",
|
||||
});
|
||||
for (const key of ["market_slug", "target_date", "resolution", "scope"]) {
|
||||
const value = req.nextUrl.searchParams.get(key);
|
||||
if (value) searchParams.set(key, value);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), DETAIL_BATCH_PROXY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
return await proxyBackendJsonGet(req, {
|
||||
cacheControl: cachePolicy.responseCacheControl,
|
||||
cacheControlForData: (data) =>
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
(data as { partial?: unknown }).partial === true
|
||||
? "no-store, max-age=0"
|
||||
: cachePolicy.responseCacheControl,
|
||||
fetchCache: "no-store",
|
||||
publicMessage: "Failed to fetch city detail batch",
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
signal: controller.signal,
|
||||
timeoutResponse: () =>
|
||||
NextResponse.json(buildCityDetailBatchTimeoutPayload(requestedCities), {
|
||||
headers: { "Cache-Control": "no-store, max-age=0" },
|
||||
status: 200,
|
||||
}),
|
||||
timeoutPublicMessage: "City detail batch request timed out",
|
||||
timing: timer,
|
||||
url: `${API_BASE}/api/cities/detail-batch?${searchParams.toString()}`,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,59 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import { STATIC_CITY_LIST } from "@/lib/static-cities";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const CITIES_CACHE_CONTROL = "public, max-age=0, s-maxage=60, stale-while-revalidate=300";
|
||||
const STATIC_CITIES_CACHE_CONTROL =
|
||||
"public, max-age=0, s-maxage=300, stale-while-revalidate=3600";
|
||||
const CITIES_BACKEND_TIMEOUT_MS = Number(
|
||||
process.env.POLYWEATHER_CITIES_BACKEND_TIMEOUT_MS || 1000,
|
||||
);
|
||||
|
||||
function staticCitiesFallback(req: NextRequest, reason: string) {
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
{
|
||||
cities: STATIC_CITY_LIST,
|
||||
source: "static_fallback",
|
||||
stale: true,
|
||||
},
|
||||
STATIC_CITIES_CACHE_CONTROL,
|
||||
);
|
||||
response.headers.set("x-polyweather-cities-source", "static-fallback");
|
||||
response.headers.set("x-polyweather-cities-fallback-reason", reason);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
return staticCitiesFallback(req, "missing-api-base");
|
||||
}
|
||||
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "public, max-age=0, s-maxage=60, stale-while-revalidate=300",
|
||||
publicMessage: "Failed to fetch cities",
|
||||
revalidateSeconds: 60,
|
||||
url: `${API_BASE}/api/cities`,
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort(), CITIES_BACKEND_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await proxyBackendJsonGet(req, {
|
||||
cacheControl: CITIES_CACHE_CONTROL,
|
||||
publicMessage: "Failed to fetch cities",
|
||||
revalidateSeconds: 60,
|
||||
signal: abortController.signal,
|
||||
statusOnException: 504,
|
||||
timeoutPublicMessage: "Cities backend timed out",
|
||||
url: `${API_BASE}/api/cities`,
|
||||
});
|
||||
|
||||
if (response.status >= 500) {
|
||||
return staticCitiesFallback(
|
||||
req,
|
||||
response.status === 504 ? "backend-timeout" : "backend-error",
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
import {
|
||||
createProxyTimer,
|
||||
finishProxyTimedResponse,
|
||||
} from "@/lib/proxy-timing";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -34,15 +38,16 @@ export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const timer = createProxyTimer(req, "city_detail");
|
||||
if (!API_BASE) {
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
return finishProxyTimedResponse(response, timer, "missing_api_base");
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
const { name } = await timer.measure("route_params", () => context.params);
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
|
||||
const depth = req.nextUrl.searchParams.get("depth");
|
||||
@@ -67,31 +72,48 @@ export async function GET(
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
headers: auth.headers,
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
|
||||
});
|
||||
const auth = await timer.measure("auth_headers", () =>
|
||||
buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
}),
|
||||
);
|
||||
const res = await timer.measure("backend_fetch", () =>
|
||||
fetch(url, {
|
||||
headers: auth.headers,
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
|
||||
}),
|
||||
);
|
||||
const backendServerTiming = res.headers.get("server-timing") || "";
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const raw = await timer.measure("backend_read", () => res.text());
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishProxyTimedResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
`upstream_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
}
|
||||
const data = normalizeCityDetailPayload(await res.json());
|
||||
const data = normalizeCityDetailPayload(
|
||||
await timer.measure("backend_read", () => res.json()),
|
||||
);
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
cachePolicy.responseCacheControl,
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishProxyTimedResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
"ok",
|
||||
{ backendServerTiming },
|
||||
);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch city detail aggregate",
|
||||
});
|
||||
return response;
|
||||
return finishProxyTimedResponse(response, timer, "exception");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
try {
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/feedback`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) upstream.searchParams.set("limit", limit);
|
||||
|
||||
const res = await fetch(upstream, {
|
||||
method: "GET",
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (!res.ok) {
|
||||
return applyAuthResponseCookies(
|
||||
buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 260,
|
||||
error: "Feedback status request failed",
|
||||
}),
|
||||
auth.response,
|
||||
);
|
||||
}
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch feedback status",
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
try {
|
||||
const body = await req.json();
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/feedback`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (!res.ok) {
|
||||
return applyAuthResponseCookies(
|
||||
buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 260,
|
||||
error: "Feedback request failed",
|
||||
}),
|
||||
auth.response,
|
||||
);
|
||||
}
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to submit feedback",
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/analytics/funnel`);
|
||||
const days = req.nextUrl.searchParams.get("days");
|
||||
if (days) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/billing-risk`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Billing risk check failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : "";
|
||||
@@ -9,6 +10,9 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
@@ -20,6 +24,9 @@ export async function PUT(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ feedbackId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const { feedbackId } = await context.params;
|
||||
const body = await req.json();
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/status`,
|
||||
{
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
...Object.fromEntries(new Headers(auth.headers).entries()),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to update feedback status",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/feedback`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch ops feedback",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,6 +9,8 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
const url = new URL(`${API_BASE}/api/ops/memberships/growth`);
|
||||
const days = req.nextUrl.searchParams.get("days");
|
||||
if (days) url.searchParams.set("days", days);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
const url = new URL(`${API_BASE}/api/ops/memberships/overview`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
const days = req.nextUrl.searchParams.get("days");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
if (days) url.searchParams.set("days", days);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch memberships overview",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,8 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
const url = new URL(`${API_BASE}/api/ops/memberships`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/observation-collector-status`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Observation collector status check failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,24 +4,46 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
import {
|
||||
createProxyTimer,
|
||||
finishProxyTimedResponse,
|
||||
} from "@/lib/proxy-timing";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const timer = createProxyTimer(req, "ops_online_users");
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
return finishProxyTimedResponse(
|
||||
NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
),
|
||||
timer,
|
||||
"missing_api_base",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/ops/online-users`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const auth = await timer.measure("auth_headers", () =>
|
||||
buildBackendRequestHeaders(req),
|
||||
);
|
||||
const authError = timer.measureSync("ops_auth", () =>
|
||||
requireOpsProxyAuth(req, auth),
|
||||
);
|
||||
if (authError) {
|
||||
return finishProxyTimedResponse(authError, timer, "ops_auth_error");
|
||||
}
|
||||
|
||||
const res = await timer.measure("backend_fetch", () =>
|
||||
fetch(`${API_BASE}/api/ops/online-users`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
}),
|
||||
);
|
||||
const backendServerTiming = res.headers.get("server-timing") || "";
|
||||
const raw = await timer.measure("backend_read", () => res.text());
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
@@ -29,10 +51,19 @@ export async function GET(req: NextRequest) {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
return finishProxyTimedResponse(
|
||||
applyAuthResponseCookies(response, auth.response),
|
||||
timer,
|
||||
res.ok ? "ok" : `upstream_${res.status}`,
|
||||
{ backendServerTiming },
|
||||
);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch online users",
|
||||
});
|
||||
return finishProxyTimedResponse(
|
||||
buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch online users",
|
||||
}),
|
||||
timer,
|
||||
"exception",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -21,6 +22,9 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const { eventId } = await context.params;
|
||||
const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/payments/incidents`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/payments`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch ops payments",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const BACKEND = API_BASE ? `${API_BASE}/api/ops/sensitive-config` : "";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config fetch failed" }); }
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config update failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/source-health`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Source health check failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || "";
|
||||
@@ -9,6 +10,9 @@ export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" };
|
||||
if (ENTITLEMENT_TOKEN) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -41,6 +42,23 @@ async function findSupabaseUserIdByEmail(email: string) {
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
throw new Error("Supabase service role is not configured on Vercel");
|
||||
}
|
||||
const profileRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/profiles?select=id&email=eq.${encodeURIComponent(email)}&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
apikey: serviceRoleKey,
|
||||
Authorization: `Bearer ${serviceRoleKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
const profileData = (await profileRes.json().catch(() => [])) as Array<{ id?: string }>;
|
||||
if (profileRes.ok) {
|
||||
const profileUserId = String(profileData?.[0]?.id || "").trim();
|
||||
if (profileUserId) return { supabaseUrl, serviceRoleKey, userId: profileUserId };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`,
|
||||
{
|
||||
@@ -109,7 +127,7 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
|
||||
apikey: serviceRoleKey,
|
||||
Authorization: `Bearer ${serviceRoleKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
Prefer: "return=minimal",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
@@ -138,6 +156,9 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
if (!API_BASE) {
|
||||
return grantSubscriptionDirectly(req, body, auth.authEmail);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ops/telegram/members-audit`, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ops/training/accuracy`, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/truth-history`);
|
||||
for (const key of ["city", "date_from", "date_to", "limit"]) {
|
||||
const value = req.nextUrl.searchParams.get(key);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/users`);
|
||||
const q = req.nextUrl.searchParams.get("q");
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/logs`);
|
||||
const level = req.nextUrl.searchParams.get("level");
|
||||
const lines = req.nextUrl.searchParams.get("lines");
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function GET(req: NextRequest) {
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900",
|
||||
detailLimit: 350,
|
||||
includeSupabaseIdentity: true,
|
||||
includeSupabaseIdentity: false,
|
||||
publicMessage: "Failed to fetch payment config",
|
||||
revalidateSeconds: 300,
|
||||
url: `${API_BASE}/api/payments/config`,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -25,7 +25,7 @@ export async function POST(
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -11,6 +11,35 @@ import {
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
function looksLikeHtmlDocument(value: string) {
|
||||
const text = String(value || "").trim().toLowerCase();
|
||||
return (
|
||||
text.startsWith("<!doctype html") ||
|
||||
text.startsWith("<html") ||
|
||||
/<title>[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || ""))
|
||||
);
|
||||
}
|
||||
|
||||
function submitErrorMessage(raw: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(raw || "")) as {
|
||||
detail?: unknown;
|
||||
error?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const message = [parsed.detail, parsed.error, parsed.message].find(
|
||||
(item) => typeof item === "string" && item.trim(),
|
||||
);
|
||||
if (typeof message === "string") {
|
||||
const trimmed = message.trim();
|
||||
if (!looksLikeHtmlDocument(trimmed)) return trimmed.slice(0, 350);
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON upstream errors are commonly HTML 50x pages; do not expose them.
|
||||
}
|
||||
return "Payment submit upstream failed";
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ intentId: string }> },
|
||||
@@ -25,7 +54,7 @@ export async function POST(
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
@@ -40,14 +69,9 @@ export async function POST(
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
let detail = raw.slice(0, 350);
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.detail) detail = String(parsed.detail).slice(0, 350);
|
||||
} catch {}
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
error: detail || undefined,
|
||||
error: submitErrorMessage(raw),
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -25,7 +25,7 @@ export async function POST(
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -36,7 +36,7 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function GET(req: NextRequest) {
|
||||
conditionalResponse: false,
|
||||
detailLimit: 500,
|
||||
fetchCache: "no-store",
|
||||
includeSupabaseIdentity: true,
|
||||
includeSupabaseIdentity: false,
|
||||
publicMessage: "Failed to fetch payment runtime",
|
||||
url: `${API_BASE}/api/payments/runtime`,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -10,6 +10,42 @@ import {
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const BACKEND_RETRY_DELAY_MS = 250;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchWalletChallengeWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
attempts = 2,
|
||||
) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt >= attempts) break;
|
||||
await sleep(BACKEND_RETRY_DELAY_MS * attempt);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function logWalletChallengeProxyError(
|
||||
error: unknown,
|
||||
authDebug: Record<string, unknown>,
|
||||
) {
|
||||
const source = error as { name?: unknown; message?: unknown; cause?: unknown };
|
||||
console.error("[payment-wallet-challenge-proxy-exception]", {
|
||||
error_name: String(source?.name || "Error"),
|
||||
error_message: String(source?.message || error || "unknown"),
|
||||
error_cause: source?.cause ? String(source.cause) : "",
|
||||
...authDebug,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
@@ -18,31 +54,45 @@ export async function POST(req: NextRequest) {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid wallet challenge request" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
let authDebug: Record<string, unknown> = {};
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
authDebug = {
|
||||
incoming_has_authorization: Boolean(
|
||||
String(req.headers.get("authorization") || "").trim(),
|
||||
),
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has("x-polyweather-auth-user-id"),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
};
|
||||
const res = await fetchWalletChallengeWithRetry(
|
||||
`${API_BASE}/api/payments/wallets/challenge`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
extraDebug: {
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has(
|
||||
"x-polyweather-auth-user-id",
|
||||
),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
},
|
||||
extraDebug: authDebug,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
@@ -50,8 +100,12 @@ export async function POST(req: NextRequest) {
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
logWalletChallengeProxyError(error, authDebug);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to create wallet challenge",
|
||||
status: 502,
|
||||
publicMessage:
|
||||
"Wallet challenge service is temporarily unavailable. Please retry.",
|
||||
extra: { retryable: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -45,7 +45,7 @@ export async function DELETE(req: NextRequest) {
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
requireBackendPaymentAuth,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -21,7 +21,7 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
const authError = requireBackendPaymentAuth(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const SCAN_AI_PROXY_TIMEOUT_MS = Math.max(
|
||||
35_000,
|
||||
Number(process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS || "45000") || 45_000,
|
||||
);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), SCAN_AI_PROXY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
headers.set("Accept", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/scan/terminal/ai`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const timedOut = controller.signal.aborted;
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: timedOut
|
||||
? "Scan AI request timed out"
|
||||
: "Failed to fetch scan AI data",
|
||||
status: timedOut ? 504 : 500,
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,9 @@ export async function POST(req: NextRequest) {
|
||||
const timeoutId = setTimeout(() => controller.abort(), OVERVIEW_PROXY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
import { DASHBOARD_REFRESH_POLICY_SEC } from "@/lib/refresh-policy";
|
||||
import {
|
||||
createProxyTimer,
|
||||
finishProxyTimedResponse,
|
||||
} from "@/lib/proxy-timing";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
|
||||
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "28000",
|
||||
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "35000",
|
||||
);
|
||||
|
||||
export const maxDuration = 30;
|
||||
export const maxDuration = 45;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const timer = createProxyTimer(req, "scan_terminal");
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
return finishProxyTimedResponse(
|
||||
NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
),
|
||||
timer,
|
||||
"missing_api_base",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +51,10 @@ export async function GET(req: NextRequest) {
|
||||
if (tradingRegion != null && tradingRegion !== "") {
|
||||
params.set("region", tradingRegion);
|
||||
}
|
||||
const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 10);
|
||||
const cachePolicy = buildForceRefreshProxyCachePolicy(
|
||||
forceRefresh,
|
||||
DASHBOARD_REFRESH_POLICY_SEC.scanRows,
|
||||
);
|
||||
|
||||
const url = `${API_BASE}/api/scan/terminal?${params.toString()}`;
|
||||
|
||||
@@ -57,6 +70,7 @@ export async function GET(req: NextRequest) {
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
signal: controller.signal,
|
||||
timeoutPublicMessage: "Scan terminal request timed out",
|
||||
timing: timer,
|
||||
url,
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { BACKEND_ENTITLEMENT_HEADER } from "@/lib/backend-auth";
|
||||
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
|
||||
import { getConfiguredSiteUrl } from "@/lib/site-url";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
function normalizeNextPath(input: string | null) {
|
||||
const fallback = "/";
|
||||
const raw = String(input || "").trim();
|
||||
@@ -11,6 +14,59 @@ function normalizeNextPath(input: string | null) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizeAuthError(input: string | null) {
|
||||
const fallback = "Authentication failed. Please sign in again.";
|
||||
const raw = String(input || "")
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.trim();
|
||||
if (!raw) return fallback;
|
||||
return raw.slice(0, 240);
|
||||
}
|
||||
|
||||
function redirectToLoginWithError({
|
||||
baseUrl,
|
||||
error,
|
||||
nextPath,
|
||||
}: {
|
||||
baseUrl: string;
|
||||
error: string | null;
|
||||
nextPath: string;
|
||||
}) {
|
||||
const loginUrl = new URL("/auth/login", baseUrl);
|
||||
loginUrl.searchParams.set("next", nextPath);
|
||||
loginUrl.searchParams.set("auth_error", "1");
|
||||
loginUrl.searchParams.set("error", normalizeAuthError(error));
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
async function warmSignupTrial(accessToken: string) {
|
||||
const token = String(accessToken || "").trim();
|
||||
if (!API_BASE || !token) return;
|
||||
|
||||
const headers = new Headers({
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
});
|
||||
const backendToken = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim();
|
||||
if (backendToken) {
|
||||
headers.set(BACKEND_ENTITLEMENT_HEADER, backendToken);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2500);
|
||||
try {
|
||||
await fetch(`${API_BASE.replace(/\/+$/, "")}/api/auth/me?scope=entitlement`, {
|
||||
cache: "no-store",
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
// The account/terminal bootstrap will retry. Callback must not strand login.
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const siteUrl = getConfiguredSiteUrl();
|
||||
if (siteUrl) {
|
||||
@@ -29,6 +85,16 @@ export async function GET(request: NextRequest) {
|
||||
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
|
||||
const baseUrl = siteUrl || request.nextUrl.origin;
|
||||
const redirectUrl = new URL(nextPath, baseUrl);
|
||||
const callbackError =
|
||||
request.nextUrl.searchParams.get("error_description") ||
|
||||
request.nextUrl.searchParams.get("error");
|
||||
if (callbackError) {
|
||||
return redirectToLoginWithError({
|
||||
baseUrl,
|
||||
error: callbackError,
|
||||
nextPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasSupabaseServerEnv()) {
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
@@ -38,7 +104,25 @@ export async function GET(request: NextRequest) {
|
||||
const supabase = createSupabaseRouteClient(request, response);
|
||||
const code = request.nextUrl.searchParams.get("code");
|
||||
if (code) {
|
||||
await supabase.auth.exchangeCodeForSession(code);
|
||||
const {
|
||||
data: { session },
|
||||
error: exchangeError,
|
||||
} = await supabase.auth.exchangeCodeForSession(code);
|
||||
if (exchangeError) {
|
||||
return redirectToLoginWithError({
|
||||
baseUrl,
|
||||
error: exchangeError.message,
|
||||
nextPath,
|
||||
});
|
||||
}
|
||||
if (!session?.access_token) {
|
||||
return redirectToLoginWithError({
|
||||
baseUrl,
|
||||
error: "Authentication session was not created. Please sign in again.",
|
||||
nextPath,
|
||||
});
|
||||
}
|
||||
await warmSignupTrial(session?.access_token || "");
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LoginClient } from "@/components/auth/LoginClient";
|
||||
import { I18nProvider } from "@/hooks/useI18n";
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<{ next?: string; mode?: string }>;
|
||||
searchParams?: Promise<{ error?: string; mode?: string; next?: string }>;
|
||||
};
|
||||
|
||||
function normalizeNextPath(input: string | undefined) {
|
||||
@@ -19,13 +19,26 @@ function normalizeMode(input: string | undefined): "login" | "signup" {
|
||||
return "login";
|
||||
}
|
||||
|
||||
function normalizeAuthError(input: string | undefined) {
|
||||
const raw = String(input || "")
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.trim();
|
||||
if (!raw) return "";
|
||||
return raw.slice(0, 240);
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: PageProps) {
|
||||
const params = (await searchParams) || {};
|
||||
const nextPath = normalizeNextPath(params.next);
|
||||
const initialMode = normalizeMode(params.mode);
|
||||
const initialError = normalizeAuthError(params.error);
|
||||
return (
|
||||
<I18nProvider>
|
||||
<LoginClient nextPath={nextPath} initialMode={initialMode} />
|
||||
<LoginClient
|
||||
nextPath={nextPath}
|
||||
initialMode={initialMode}
|
||||
initialError={initialError}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ResetPasswordClient } from "@/components/auth/ResetPasswordClient";
|
||||
import { I18nProvider } from "@/hooks/useI18n";
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<{ next?: string }>;
|
||||
};
|
||||
|
||||
function normalizeNextPath(input: string | undefined) {
|
||||
const fallback = "/account";
|
||||
const raw = String(input || "").trim();
|
||||
if (!raw) return fallback;
|
||||
if (!raw.startsWith("/")) return fallback;
|
||||
if (raw.startsWith("//")) return fallback;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export default async function ResetPasswordPage({ searchParams }: PageProps) {
|
||||
const params = (await searchParams) || {};
|
||||
const nextPath = normalizeNextPath(params.next);
|
||||
return (
|
||||
<I18nProvider>
|
||||
<ResetPasswordClient nextPath={nextPath} />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
@@ -285,6 +285,133 @@
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
@keyframes landingRise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes landingFloat {
|
||||
0%,
|
||||
100% {
|
||||
translate: 0 0;
|
||||
}
|
||||
50% {
|
||||
translate: 0 -10px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes landingScan {
|
||||
0% {
|
||||
transform: translateY(-120%);
|
||||
opacity: 0;
|
||||
}
|
||||
18%,
|
||||
72% {
|
||||
opacity: 0.48;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(120%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes landingPulseDot {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.22);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 7px rgba(37, 99, 235, 0);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.landing-rise {
|
||||
opacity: 0;
|
||||
animation: landingRise 720ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.landing-delay-1 {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
|
||||
.landing-delay-2 {
|
||||
animation-delay: 190ms;
|
||||
}
|
||||
|
||||
.landing-delay-3 {
|
||||
animation-delay: 280ms;
|
||||
}
|
||||
|
||||
.landing-float {
|
||||
animation: landingFloat 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.landing-float-slow {
|
||||
animation: landingFloat 9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.landing-hover-lift {
|
||||
transition:
|
||||
transform 220ms ease,
|
||||
border-color 220ms ease,
|
||||
box-shadow 220ms ease;
|
||||
}
|
||||
|
||||
.landing-hover-lift:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(148, 163, 184, 0.78);
|
||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.landing-screen-glow {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.landing-screen-glow::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 44px 8px 8px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(37, 99, 235, 0.12) 48%,
|
||||
transparent 100%
|
||||
);
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0;
|
||||
animation: landingScan 5.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.landing-pulse-dot {
|
||||
animation: landingPulseDot 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.landing-rise,
|
||||
.landing-float,
|
||||
.landing-float-slow,
|
||||
.landing-screen-glow::after,
|
||||
.landing-pulse-dot {
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.landing-hover-lift,
|
||||
.landing-hover-lift:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reduced motion: disable all animations and transitions ── */
|
||||
|
||||
@@ -23,7 +23,7 @@ export const metadata: Metadata = {
|
||||
template: "%s | PolyWeather",
|
||||
},
|
||||
description:
|
||||
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards. Real-time observations for 51 global cities.",
|
||||
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context. Real-time observations for 51 global cities.",
|
||||
manifest: "/site.webmanifest",
|
||||
metadataBase: new URL("https://polyweather.top"),
|
||||
alternates: {
|
||||
@@ -34,7 +34,7 @@ export const metadata: Metadata = {
|
||||
siteName: "PolyWeather",
|
||||
title: "PolyWeather | Institutional Weather Signal Intelligence",
|
||||
description:
|
||||
"Paid professional weather-signal intelligence terminal. METAR evidence, DEB forecast blending, AI decision cards. 51 cities, real-time.",
|
||||
"Paid professional weather-signal intelligence terminal. METAR evidence, DEB forecast blending, structured decision context. 51 cities, real-time.",
|
||||
url: "https://polyweather.top",
|
||||
images: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireOpsAdmin } from "@/lib/ops-admin";
|
||||
import { FeedbackPageClient } from "@/components/ops/feedback/FeedbackPageClient";
|
||||
|
||||
export const metadata: Metadata = { title: "用户反馈 — PolyWeather Ops" };
|
||||
|
||||
export default async function OpsFeedbackPage() {
|
||||
await requireOpsAdmin("/ops/feedback");
|
||||
return <FeedbackPageClient />;
|
||||
}
|
||||
+21
-10
@@ -1,12 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { PreloadTerminalData } from "@/components/landing/PreloadTerminalData";
|
||||
import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLandingPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather | Institutional Weather Signal Intelligence",
|
||||
description:
|
||||
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.",
|
||||
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context.",
|
||||
other: {
|
||||
preconnect: "https://api.polyweather.top",
|
||||
},
|
||||
@@ -38,16 +37,29 @@ export default async function HomePage({
|
||||
"@type": "WebApplication",
|
||||
name: "PolyWeather",
|
||||
description:
|
||||
"Paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.",
|
||||
"Paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context.",
|
||||
url: "https://polyweather.top",
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Web",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "10.00",
|
||||
priceCurrency: "USD",
|
||||
description: "Monthly subscription",
|
||||
},
|
||||
offers: [
|
||||
{
|
||||
"@type": "Offer",
|
||||
name: "Pro monthly",
|
||||
price: "29.90",
|
||||
priceCurrency: "USD",
|
||||
description:
|
||||
"Pro subscription for 30 days. Referral users pay 20.00 USD-equivalent USDC for the first month.",
|
||||
availability: "https://schema.org/InStock",
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
name: "Pro quarterly",
|
||||
price: "79.90",
|
||||
priceCurrency: "USD",
|
||||
description: "Pro subscription for 90 days.",
|
||||
availability: "https://schema.org/InStock",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -56,7 +68,6 @@ export default async function HomePage({
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<PreloadTerminalData />
|
||||
<InstitutionalLandingPage />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,29 +12,24 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
const TELEGRAM_GROUP_URL = String(
|
||||
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
|
||||
"https://t.me/+Io5H9oVHFmVjOTQ5",
|
||||
).trim();
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{
|
||||
q_zh: "Pro 包含哪些功能?",
|
||||
q_en: "What features does Pro include?",
|
||||
a_zh: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、未来日期分析、城市决策卡、全平台智能气象推送。",
|
||||
a_en: "Unlocks: intraday METAR rule analysis (including peak window), future-date analysis, city decision cards, and cross-platform smart weather push.",
|
||||
a_zh: "开通后可解锁:天气决策台、多城市图表巡检、未来日期分析、全平台智能气象推送。",
|
||||
a_en: "Unlocks: weather decision terminal, multi-city chart monitoring, future-date analysis, and cross-platform smart weather push.",
|
||||
},
|
||||
{
|
||||
q_zh: "当前订阅价格是多少?",
|
||||
q_en: "What is the current subscription price?",
|
||||
a_zh: "目前仅提供月付:10 USDC / 30 天。",
|
||||
a_en: "Monthly plan only: 10 USDC / 30 days.",
|
||||
a_zh: "Pro 月付 29.9 USDC / 30 天,Pro 季度 79.9 USDC / 90 天。",
|
||||
a_en: "Pro monthly is 29.9 USDC / 30 days. Pro quarterly is 79.9 USDC / 90 days.",
|
||||
},
|
||||
{
|
||||
q_zh: "积分如何抵扣?",
|
||||
q_en: "How do points work for discounts?",
|
||||
a_zh: "满 500 积分起兑,每 500 积分抵 1U,单次最多抵 3U。",
|
||||
a_en: "500 points minimum — every 500 points = 1 USDC off, up to 3 USDC discount per payment.",
|
||||
a_zh: "满 500 积分起兑,每 500 积分抵 1U。月付最多抵 3U,季度最多抵 8U。",
|
||||
a_en: "500 points minimum: every 500 points = 1 USDC off. Monthly orders can use up to 3 USDC off; quarterly orders can use up to 8 USDC off.",
|
||||
},
|
||||
{
|
||||
q_zh: "支持哪些钱包和支付方式?",
|
||||
@@ -55,11 +50,11 @@ export function SubscriptionHelpClient() {
|
||||
? "Complete subscription rules and payment guide."
|
||||
: "这里是完整的订阅规则和支付说明。你可以先在页面内绑定钱包,再直接开通 Pro。",
|
||||
priceLabel: isEn ? "Price" : "订阅价格",
|
||||
priceText: "10 USDC / 30 " + (isEn ? "Days" : "天"),
|
||||
priceText: isEn ? "29.9 / 30d · 79.9 / 90d" : "29.9 / 30天 · 79.9 / 90天",
|
||||
discountLabel: isEn ? "Points Discount" : "积分抵扣",
|
||||
discountText: isEn ? "Up to 3 USDC off" : "最多抵 3U",
|
||||
communityLabel: isEn ? "Community Points" : "社群积分",
|
||||
communityLink: isEn ? "Join community to earn" : "加入社群即可赚取积分",
|
||||
discountText: isEn ? "Monthly 3U · Quarterly 8U" : "月付 3U · 季度 8U",
|
||||
communityLabel: isEn ? "Telegram Group" : "Telegram 群",
|
||||
communityLink: isEn ? "Open Account Center" : "前往账户中心",
|
||||
faqTitle: isEn ? "FAQ" : "常见问题",
|
||||
}), [isEn]);
|
||||
|
||||
@@ -102,8 +97,7 @@ export function SubscriptionHelpClient() {
|
||||
<span className="text-sm font-semibold">{copy.communityLabel}</span>
|
||||
</div>
|
||||
<Link
|
||||
href={TELEGRAM_GROUP_URL}
|
||||
target="_blank"
|
||||
href="/account"
|
||||
className="inline-flex min-h-9 items-center text-sm font-semibold text-blue-700 underline decoration-blue-500/50 underline-offset-4"
|
||||
>
|
||||
{copy.communityLink}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
|
||||
|
||||
const ScanTerminalDashboard = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/ScanTerminalDashboard").then(
|
||||
(mod) => mod.ScanTerminalDashboard,
|
||||
),
|
||||
{
|
||||
loading: () => <DashboardShellSkeleton />,
|
||||
},
|
||||
);
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather Terminal | Paid Product",
|
||||
description:
|
||||
"Paid PolyWeather decision terminal for weather-signal analysis and city decision cards.",
|
||||
"Paid PolyWeather decision terminal for weather-signal analysis and multi-city chart monitoring.",
|
||||
};
|
||||
|
||||
export default function TerminalPage() {
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
WALLETCONNECT_PROJECT_ID,
|
||||
} from "./constants";
|
||||
import { InfoRow, PlusIcon } from "./AccountInfoRow";
|
||||
import { AccountFeedbackPanel } from "./AccountFeedbackPanel";
|
||||
import {
|
||||
chainIdToDisplayName,
|
||||
clearStoredPaymentRecovery,
|
||||
@@ -84,6 +85,8 @@ export function AccountCenter() {
|
||||
const [updatedAt, setUpdatedAt] = useState<string>("");
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [backend, setBackend] = useState<AuthMeResponse | null>(null);
|
||||
const [referralCodeInput, setReferralCodeInput] = useState("");
|
||||
const [referralApplying, setReferralApplying] = useState(false);
|
||||
|
||||
const supabaseReady = hasSupabasePublicEnv();
|
||||
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
|
||||
@@ -124,6 +127,7 @@ export function AccountCenter() {
|
||||
boundWallets,
|
||||
walletAddress,
|
||||
selectedPlanCode,
|
||||
selectedPaymentChainId,
|
||||
selectedTokenAddress,
|
||||
selectedWallet,
|
||||
providerMode,
|
||||
@@ -133,6 +137,8 @@ export function AccountCenter() {
|
||||
|
||||
// Shared setters
|
||||
setSelectedTokenAddress,
|
||||
setSelectedPaymentChainId,
|
||||
setSelectedPlanCode,
|
||||
setSelectedWallet,
|
||||
setSelectedInjectedProviderKey,
|
||||
setProviderMode,
|
||||
@@ -149,6 +155,8 @@ export function AccountCenter() {
|
||||
selectedPaymentToken,
|
||||
selectedTokenLabel,
|
||||
availableTokenList,
|
||||
availableChainList,
|
||||
selectedPaymentChain,
|
||||
effectivePlanList,
|
||||
resolvedSelectedTokenAddress,
|
||||
paymentReceiverAddress,
|
||||
@@ -189,19 +197,49 @@ export function AccountCenter() {
|
||||
useEffect(() => {
|
||||
if (!authIsAuthenticated || !authUserId) return;
|
||||
const actorKey = authUserId.toLowerCase();
|
||||
const subscriptionPlanCode = String(backend?.subscription_plan_code || "").trim();
|
||||
const subscriptionSource = String(backend?.subscription_source || "").trim();
|
||||
const trialSubscription = Boolean(
|
||||
backend?.subscription_is_trial === true ||
|
||||
subscriptionPlanCode.toLowerCase().includes("trial") ||
|
||||
subscriptionSource.toLowerCase().includes("trial"),
|
||||
);
|
||||
if (markAnalyticsOnce(`signup_completed:${actorKey}`, "local")) {
|
||||
trackAppEvent("signup_completed", {
|
||||
entry: "account_center",
|
||||
user_id: authUserId,
|
||||
});
|
||||
}
|
||||
if (markAnalyticsOnce(`signup_success:${actorKey}`, "local")) {
|
||||
trackAppEvent("signup_success", {
|
||||
entry: "account_center",
|
||||
user_id: authUserId,
|
||||
});
|
||||
}
|
||||
if (markAnalyticsOnce(`dashboard_active:${actorKey}`, "session")) {
|
||||
trackAppEvent("dashboard_active", {
|
||||
entry: "account_center",
|
||||
user_id: authUserId,
|
||||
});
|
||||
}
|
||||
}, [authIsAuthenticated, authUserId]);
|
||||
if (trialSubscription && markAnalyticsOnce(`trial_created:${actorKey}`, "local")) {
|
||||
trackAppEvent("trial_created", {
|
||||
entry: "account_center",
|
||||
user_id: authUserId,
|
||||
subscription_plan_code: subscriptionPlanCode || null,
|
||||
subscription_source: subscriptionSource || null,
|
||||
subscription_expires_at: backend?.subscription_expires_at || null,
|
||||
subscription_is_trial: backend?.subscription_is_trial === true,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
authIsAuthenticated,
|
||||
authUserId,
|
||||
backend?.subscription_expires_at,
|
||||
backend?.subscription_is_trial,
|
||||
backend?.subscription_plan_code,
|
||||
backend?.subscription_source,
|
||||
]);
|
||||
|
||||
// ── Idle callback effect ──────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -286,7 +324,30 @@ export function AccountCenter() {
|
||||
copy.guestUser;
|
||||
const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
|
||||
const joinedAt = formatTime(user?.created_at, locale);
|
||||
const isSubscribed = Boolean(backend?.subscription_active);
|
||||
const backendAuthenticated = backend?.authenticated === true;
|
||||
const localAuthenticated = Boolean(user?.id);
|
||||
const isSubscriptionUnknown = Boolean(
|
||||
localAuthenticated &&
|
||||
(!backend ||
|
||||
backend.subscription_active == null ||
|
||||
backend.authenticated === false),
|
||||
);
|
||||
const isSubscribed = backend?.subscription_active === true;
|
||||
const subscriptionSource = String(backend?.subscription_source || "").trim();
|
||||
const isTrialSubscription = Boolean(
|
||||
backend?.subscription_is_trial === true ||
|
||||
String(backend?.subscription_plan_code || "").toLowerCase().includes("trial") ||
|
||||
subscriptionSource.toLowerCase().includes("trial"),
|
||||
);
|
||||
const subscriptionStatusLabel = isSubscribed
|
||||
? isTrialSubscription
|
||||
? copy.trialBadge
|
||||
: copy.proMember
|
||||
: isSubscriptionUnknown
|
||||
? copy.subscriptionChecking
|
||||
: isEn
|
||||
? "UNSUBSCRIBED"
|
||||
: "未订阅";
|
||||
const planCode = String(backend?.subscription_plan_code || "").trim();
|
||||
const currentExpiryRaw = String(
|
||||
backend?.subscription_expires_at ||
|
||||
@@ -306,7 +367,8 @@ export function AccountCenter() {
|
||||
const hasQueuedExtension = Boolean(
|
||||
isSubscribed && queuedExtensionDays > 0,
|
||||
);
|
||||
const canAccessPaidTelegramGroup = Boolean(isSubscribed);
|
||||
const canAccessPaidTelegramGroup = Boolean(isSubscribed && !isTrialSubscription);
|
||||
const hasTelegramPanel = Boolean(isTrialSubscription || canAccessPaidTelegramGroup);
|
||||
const telegramBound =
|
||||
Number(backend?.telegram_pricing?.telegram_id || 0) > 0;
|
||||
const displayExpiryRaw = isSubscribed
|
||||
@@ -323,6 +385,8 @@ export function AccountCenter() {
|
||||
? expiryFormatted !== "--"
|
||||
? expiryFormatted
|
||||
: displayExpiryRaw || copy.proPendingSync
|
||||
: isSubscriptionUnknown
|
||||
? copy.subscriptionUnknown
|
||||
: copy.noProSubscription;
|
||||
const showExpiringSoon = Boolean(
|
||||
isSubscribed &&
|
||||
@@ -337,6 +401,7 @@ export function AccountCenter() {
|
||||
const paymentFeatureReady = paymentReadyForRecovery;
|
||||
const canOpenCheckoutOverlay = Boolean(
|
||||
paymentFeatureReady &&
|
||||
!isSubscriptionUnknown &&
|
||||
(!isSubscribed || showExpiringSoon || showExpiredReminder),
|
||||
);
|
||||
const subscriptionStatusTitle = showExpiredReminder
|
||||
@@ -362,6 +427,29 @@ export function AccountCenter() {
|
||||
const expiryLabel = hasQueuedExtension
|
||||
? copy.accessUntil
|
||||
: copy.renewalDate;
|
||||
const displayPlanList = effectivePlanList.length
|
||||
? effectivePlanList
|
||||
: [
|
||||
{ plan_code: "pro_monthly", plan_id: 101, amount_usdc: "29.9", duration_days: 30 },
|
||||
{ plan_code: "pro_quarterly", plan_id: 102, amount_usdc: "79.9", duration_days: 90 },
|
||||
];
|
||||
const selectedPlanDurationDays = Number(selectedPlan?.duration_days || 30);
|
||||
const overlayPlanLabel =
|
||||
selectedPlanCode === "pro_quarterly"
|
||||
? copy.quarterlyPlan
|
||||
: copy.monthlyPlan;
|
||||
const overlayPeriodLabel = isEn
|
||||
? `/ ${selectedPlanDurationDays} days`
|
||||
: `/ ${selectedPlanDurationDays} 天`;
|
||||
const referral = backend?.referral;
|
||||
const referralCode = String(referral?.code || "").trim();
|
||||
const appliedReferralCode = String(referral?.applied_code || "").trim();
|
||||
const canApplyReferralCode = Boolean(
|
||||
isAuthenticated &&
|
||||
!isSubscribed &&
|
||||
!appliedReferralCode &&
|
||||
referralCodeInput.trim(),
|
||||
);
|
||||
|
||||
// ── Payment overlay tracking effect ──────────────────────
|
||||
useEffect(() => {
|
||||
@@ -382,20 +470,31 @@ export function AccountCenter() {
|
||||
showOverlay,
|
||||
]);
|
||||
|
||||
// ── Weekly points display (component-only derived) ──────
|
||||
const backendWeeklyPointsRaw = Number(backend?.weekly_points);
|
||||
const metadataWeeklyPointsRaw = Number(
|
||||
user?.user_metadata?.weekly_points ?? 0,
|
||||
);
|
||||
const weeklyPointsRaw = Number.isFinite(backendWeeklyPointsRaw)
|
||||
? backendWeeklyPointsRaw
|
||||
: metadataWeeklyPointsRaw;
|
||||
const weeklyRankRaw =
|
||||
backend?.weekly_rank ?? user?.user_metadata?.weekly_rank;
|
||||
const weeklyPoints = Number.isFinite(weeklyPointsRaw)
|
||||
? Math.max(0, weeklyPointsRaw)
|
||||
// ── Referral points display ────────────────────────────
|
||||
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
|
||||
const referralRewardPoints = Number.isFinite(referralRewardPointsRaw)
|
||||
? Math.max(0, referralRewardPointsRaw)
|
||||
: 3500;
|
||||
const monthlyReferralCountRaw = Number(referral?.monthly_reward_count ?? 0);
|
||||
const monthlyReferralCount = Number.isFinite(monthlyReferralCountRaw)
|
||||
? Math.max(0, monthlyReferralCountRaw)
|
||||
: 0;
|
||||
const weeklyRank = weeklyRankRaw == null ? "--" : String(weeklyRankRaw);
|
||||
const monthlyReferralLimitRaw = Number(referral?.monthly_reward_limit ?? 10);
|
||||
const monthlyReferralLimit = Number.isFinite(monthlyReferralLimitRaw)
|
||||
? Math.max(0, monthlyReferralLimitRaw)
|
||||
: 10;
|
||||
const monthlyReferralPointsRaw = Number(
|
||||
referral?.monthly_reward_points ?? monthlyReferralCount * referralRewardPoints,
|
||||
);
|
||||
const monthlyReferralPoints = Number.isFinite(monthlyReferralPointsRaw)
|
||||
? Math.max(0, monthlyReferralPointsRaw)
|
||||
: 0;
|
||||
const monthlyReferralPointsLimitRaw = Number(
|
||||
referral?.monthly_reward_points_limit ?? monthlyReferralLimit * referralRewardPoints,
|
||||
);
|
||||
const monthlyReferralPointsLimit = Number.isFinite(monthlyReferralPointsLimitRaw)
|
||||
? Math.max(0, monthlyReferralPointsLimitRaw)
|
||||
: monthlyReferralLimit * referralRewardPoints;
|
||||
|
||||
// ── Telegram bind command ──────────────────────────────
|
||||
const bindCommand = userId
|
||||
@@ -410,6 +509,55 @@ export function AccountCenter() {
|
||||
});
|
||||
};
|
||||
|
||||
const applyReferralCode = useCallback(async () => {
|
||||
const code = referralCodeInput.trim();
|
||||
if (!code || referralApplying) return;
|
||||
setReferralApplying(true);
|
||||
setPaymentError("");
|
||||
setPaymentInfo("");
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (supabaseReady) {
|
||||
const {
|
||||
data: { session },
|
||||
} = await getSupabaseBrowserClient().auth.getSession();
|
||||
const token = String(session?.access_token || "").trim();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const res = await fetch("/api/auth/referral/apply", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = (await res.text()).slice(0, 240);
|
||||
throw new Error(raw || copy.referralApplyFailed);
|
||||
}
|
||||
setPaymentInfo(copy.referralApplied);
|
||||
setReferralCodeInput("");
|
||||
await loadSnapshot();
|
||||
await loadPaymentSnapshot();
|
||||
} catch (error) {
|
||||
setPaymentError(
|
||||
error instanceof Error ? error.message : copy.referralApplyFailed,
|
||||
);
|
||||
} finally {
|
||||
setReferralApplying(false);
|
||||
}
|
||||
}, [
|
||||
copy.referralApplied,
|
||||
copy.referralApplyFailed,
|
||||
loadPaymentSnapshot,
|
||||
loadSnapshot,
|
||||
referralApplying,
|
||||
referralCodeInput,
|
||||
setPaymentError,
|
||||
setPaymentInfo,
|
||||
supabaseReady,
|
||||
]);
|
||||
|
||||
// ── Render ────────────────────────────────────────────
|
||||
|
||||
if (loading && !refreshing) {
|
||||
@@ -533,7 +681,13 @@ export function AccountCenter() {
|
||||
{initials}
|
||||
</div>
|
||||
<div
|
||||
className={`absolute -bottom-2 -right-2 rounded-lg border-4 border-white p-1.5 ${isSubscribed ? "bg-amber-400 text-slate-950" : "bg-slate-100 text-slate-400"}`}
|
||||
className={`absolute -bottom-2 -right-2 rounded-lg border-4 border-white p-1.5 ${
|
||||
isSubscribed
|
||||
? "bg-amber-400 text-slate-950"
|
||||
: isSubscriptionUnknown
|
||||
? "bg-blue-100 text-blue-500"
|
||||
: "bg-slate-100 text-slate-400"
|
||||
}`}
|
||||
>
|
||||
<Crown size={16} fill="currentColor" />
|
||||
</div>
|
||||
@@ -542,13 +696,15 @@ export function AccountCenter() {
|
||||
<div className="flex items-center justify-center md:justify-start gap-3 mb-1">
|
||||
<h2 className="text-3xl font-bold text-slate-950">{displayName}</h2>
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-black uppercase ${isSubscribed ? "border-blue-200 bg-blue-50 text-blue-700" : "border-slate-200 bg-slate-50 text-slate-500"}`}
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-black uppercase ${
|
||||
isSubscribed
|
||||
? "border-blue-200 bg-blue-50 text-blue-700"
|
||||
: isSubscriptionUnknown
|
||||
? "border-blue-200 bg-blue-50 text-blue-600"
|
||||
: "border-slate-200 bg-slate-50 text-slate-500"
|
||||
}`}
|
||||
>
|
||||
{isSubscribed
|
||||
? copy.proMember
|
||||
: isEn
|
||||
? "UNSUBSCRIBED"
|
||||
: "未订阅"}
|
||||
{subscriptionStatusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-4 font-mono text-sm text-slate-500">
|
||||
@@ -585,7 +741,7 @@ export function AccountCenter() {
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950">
|
||||
<TrendingUp size={16} className="text-emerald-400" />{" "}
|
||||
{weeklyPoints.toLocaleString()}
|
||||
{monthlyReferralCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-[140px] rounded-xl border border-blue-200 bg-blue-50 px-6 py-4 text-center">
|
||||
@@ -594,13 +750,13 @@ export function AccountCenter() {
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-2 text-xl font-bold text-slate-950">
|
||||
<Trophy size={16} className="text-amber-400" />{" "}
|
||||
{weeklyRank === "--" ? weeklyRank : `#${weeklyRank}`}
|
||||
{monthlyReferralCount}/{monthlyReferralLimit}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly Ranking Motivation */}
|
||||
{/* Referral rewards */}
|
||||
{showSecondarySections ? (
|
||||
<div className="flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:col-span-4">
|
||||
<div>
|
||||
@@ -611,35 +767,29 @@ export function AccountCenter() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<span className="text-sm flex items-center gap-2">
|
||||
<div className="w-5 h-5 bg-yellow-500 rounded text-black font-bold text-[10px] flex items-center justify-center">
|
||||
1
|
||||
</div>{" "}
|
||||
Top 1
|
||||
<Coins size={16} className="text-yellow-500" />{" "}
|
||||
{copy.referralRewardHint}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-amber-600">
|
||||
+200 积分 & 7天Pro
|
||||
+{referralRewardPoints.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<span className="text-sm flex items-center gap-2">
|
||||
<div className="w-5 h-5 bg-slate-300 rounded text-black font-bold text-[10px] flex items-center justify-center">
|
||||
2
|
||||
</div>{" "}
|
||||
Top 2-3
|
||||
<TrendingUp size={16} className="text-emerald-500" />{" "}
|
||||
{copy.weeklyPoints}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-slate-600">
|
||||
+100 积分
|
||||
{monthlyReferralCount}/{monthlyReferralLimit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<span className="text-sm flex items-center gap-2">
|
||||
<div className="w-5 h-5 bg-orange-800 rounded text-white font-bold text-[10px] flex items-center justify-center">
|
||||
4
|
||||
</div>{" "}
|
||||
Top 4-10
|
||||
<Trophy size={16} className="text-blue-500" />{" "}
|
||||
{copy.totalPoints}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-orange-400">
|
||||
+50 积分
|
||||
{monthlyReferralPoints.toLocaleString()}/{monthlyReferralPointsLimit.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -647,8 +797,7 @@ export function AccountCenter() {
|
||||
<div className="mt-6 flex items-start gap-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<Info size={14} className="text-slate-500 mt-0.5 shrink-0" />
|
||||
<p className="text-[10px] text-slate-500 leading-normal italic">
|
||||
积分规则:群内有效发言(自动防刷检测)+
|
||||
每日首条发言额外奖励。每周一零点结算周榜,所有活跃用户均享参与奖。
|
||||
{copy.pointsRule}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -685,19 +834,37 @@ export function AccountCenter() {
|
||||
<InfoRow
|
||||
icon={Zap}
|
||||
label={copy.intradayAnalysis}
|
||||
value={isSubscribed ? copy.deepMode : copy.compactVisible}
|
||||
value={
|
||||
isSubscriptionUnknown
|
||||
? copy.subscriptionChecking
|
||||
: isSubscribed
|
||||
? copy.deepMode
|
||||
: copy.compactVisible
|
||||
}
|
||||
isPrimary={isSubscribed}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={Clock}
|
||||
label={copy.historyFuture}
|
||||
value={isSubscribed ? copy.enabled : copy.locked}
|
||||
value={
|
||||
isSubscriptionUnknown
|
||||
? copy.subscriptionChecking
|
||||
: isSubscribed
|
||||
? copy.enabled
|
||||
: copy.locked
|
||||
}
|
||||
isPrimary={isSubscribed}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={Bot}
|
||||
label={copy.smartPush}
|
||||
value={isSubscribed ? copy.enabled : copy.locked}
|
||||
value={
|
||||
isSubscriptionUnknown
|
||||
? copy.subscriptionChecking
|
||||
: isSubscribed
|
||||
? copy.enabled
|
||||
: copy.locked
|
||||
}
|
||||
isPrimary={isSubscribed}
|
||||
/>
|
||||
</section>
|
||||
@@ -724,7 +891,13 @@ export function AccountCenter() {
|
||||
<InfoRow
|
||||
icon={UserCheck}
|
||||
label={copy.authResult}
|
||||
value={backend?.authenticated ? copy.passed : copy.restricted}
|
||||
value={
|
||||
backendAuthenticated
|
||||
? copy.passed
|
||||
: isSubscriptionUnknown
|
||||
? copy.subscriptionChecking
|
||||
: copy.restricted
|
||||
}
|
||||
/>
|
||||
{queuedExtensionSummary ? (
|
||||
<p className="rounded-xl border border-cyan-200 bg-cyan-50 px-4 py-3 text-xs text-cyan-800">
|
||||
@@ -740,6 +913,8 @@ export function AccountCenter() {
|
||||
<UnlockProOverlay
|
||||
points={totalPoints}
|
||||
planPriceUsd={billing.planAmount}
|
||||
planLabel={overlayPlanLabel}
|
||||
periodLabel={overlayPeriodLabel}
|
||||
usePoints={usePoints}
|
||||
onToggleUsePoints={() => setUsePoints((prev) => !prev)}
|
||||
billing={{
|
||||
@@ -757,10 +932,11 @@ export function AccountCenter() {
|
||||
payBusy={paymentBusy}
|
||||
payLabel={hasPayingWallet ? copy.payNow : copy.connectAndPay}
|
||||
manualPayLabel="手动转账"
|
||||
locale={locale}
|
||||
errorText={paymentError || undefined}
|
||||
infoText={paymentInfo || undefined}
|
||||
txHash={lastTxHash || undefined}
|
||||
chainId={paymentConfig?.chain_id || 137}
|
||||
chainId={selectedPaymentChainId || paymentConfig?.chain_id || 137}
|
||||
paymentTokenLabel={selectedTokenLabel}
|
||||
faqHref={SUBSCRIPTION_HELP_HREF}
|
||||
telegramGroupUrl=""
|
||||
@@ -769,11 +945,47 @@ export function AccountCenter() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AccountFeedbackPanel
|
||||
isEn={isEn}
|
||||
title={copy.accountFeedbackTitle}
|
||||
description={copy.accountFeedbackDescription}
|
||||
refreshLabel={copy.refresh}
|
||||
/>
|
||||
|
||||
{/* Telegram Bot Section & Payment Details */}
|
||||
{showSecondarySections ? (
|
||||
<div className="lg:col-span-12 grid grid-cols-1 md:flex gap-6">
|
||||
<div
|
||||
className={`lg:col-span-12 grid grid-cols-1 items-start gap-6 ${
|
||||
hasTelegramPanel ? "xl:grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)]" : ""
|
||||
}`}
|
||||
>
|
||||
{isTrialSubscription && (
|
||||
<section className="group relative min-w-0 overflow-hidden rounded-2xl border border-amber-200 bg-amber-50 p-8 shadow-sm">
|
||||
<Bot
|
||||
size={140}
|
||||
className="absolute -right-8 -bottom-8 -rotate-12 text-amber-100"
|
||||
/>
|
||||
<div className="relative z-10">
|
||||
<h3 className="mb-2 flex items-center gap-2 text-lg font-bold text-amber-800">
|
||||
<Bot size={22} /> {copy.telegramBind}
|
||||
</h3>
|
||||
<p className="text-sm leading-6 text-amber-900">
|
||||
{copy.trialPaidGroupLocked}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOverlay(true)}
|
||||
disabled={!canOpenCheckoutOverlay}
|
||||
className="mt-5 inline-flex items-center gap-2 rounded-xl border border-amber-700 bg-amber-600 px-4 py-3 text-xs font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Crown size={14} />
|
||||
{copy.upgradePro}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{canAccessPaidTelegramGroup && (
|
||||
<section className="group relative flex-1 overflow-hidden rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<section className="group relative min-w-0 overflow-hidden rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<Bot
|
||||
size={140}
|
||||
className="absolute -right-8 -bottom-8 -rotate-12 text-slate-100 transition-transform duration-1000 group-hover:rotate-0"
|
||||
@@ -851,11 +1063,7 @@ export function AccountCenter() {
|
||||
)}
|
||||
|
||||
{/* Payment Details / Wallet Management */}
|
||||
<section
|
||||
className={`flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-8 shadow-sm ${
|
||||
canAccessPaidTelegramGroup ? "w-full md:w-96" : "w-full"
|
||||
}`}
|
||||
>
|
||||
<section className="w-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:p-7">
|
||||
<div>
|
||||
<h3 className="mb-6 flex items-center gap-2 text-sm font-bold uppercase text-blue-700">
|
||||
<Wallet size={18} /> {copy.paymentMgmt}
|
||||
@@ -889,117 +1097,289 @@ export function AccountCenter() {
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-5 space-y-3">
|
||||
<InfoRow
|
||||
icon={Mail}
|
||||
label={copy.paymentAccount}
|
||||
value={email || "--"}
|
||||
isPrimary
|
||||
/>
|
||||
<InfoRow
|
||||
icon={Wallet}
|
||||
label={copy.paymentWallet}
|
||||
value={shortAddress(paymentWalletLabel) || "--"}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={ShieldCheck}
|
||||
label={copy.paymentReceiver}
|
||||
value={shortAddress(paymentReceiverAddress) || "--"}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={ExternalLink}
|
||||
label={copy.paymentNetwork}
|
||||
value={chainIdToDisplayName(paymentConfig?.chain_id)}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={ExternalLink}
|
||||
label={copy.paymentHost}
|
||||
value={currentPaymentHost || "--"}
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{copy.paymentGuardHint}
|
||||
</p>
|
||||
</div>
|
||||
{availableTokenList.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<p className="mb-2 text-[11px] uppercase text-slate-500">
|
||||
{copy.paymentToken}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{availableTokenList.map((token) => {
|
||||
const active =
|
||||
token.address ===
|
||||
(resolvedSelectedTokenAddress ||
|
||||
token.address);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={token.address}
|
||||
onClick={() =>
|
||||
setSelectedTokenAddress(
|
||||
token.address,
|
||||
)
|
||||
}
|
||||
disabled={paymentBusy}
|
||||
className={`rounded-xl border px-3 py-2 text-left transition-all ${
|
||||
active
|
||||
? "border-blue-300 bg-blue-50 text-blue-900"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-bold">
|
||||
{token.symbol}
|
||||
<div
|
||||
data-testid="payment-management-grid"
|
||||
className={`grid gap-6 lg:items-start ${
|
||||
hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<p className="mb-2 text-[11px] uppercase text-slate-500">
|
||||
{copy.proPlan}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{displayPlanList.map((plan) => {
|
||||
const code = String(plan.plan_code || "");
|
||||
const active = code === selectedPlanCode;
|
||||
const isQuarterly = code === "pro_quarterly";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={code}
|
||||
onClick={() => setSelectedPlanCode(code)}
|
||||
disabled={paymentBusy}
|
||||
className={`rounded-xl border px-3 py-3 text-left transition-all ${
|
||||
active
|
||||
? "border-blue-300 bg-blue-50 text-blue-900"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 text-xs font-bold">
|
||||
<span>
|
||||
{isQuarterly
|
||||
? copy.quarterlyPlan
|
||||
: copy.monthlyPlan}
|
||||
</span>
|
||||
<span>{plan.amount_usdc} USDC</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] opacity-80">
|
||||
{code} · {plan.duration_days} 天
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{appliedReferralCode && selectedPlanCode === "pro_monthly" ? (
|
||||
<p className="mt-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-[11px] text-emerald-800">
|
||||
{copy.referralDiscountHint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-bold uppercase text-slate-700">
|
||||
{copy.referralTitle}
|
||||
</p>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{copy.referralInviteLimit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] uppercase text-slate-500">
|
||||
{copy.referralMyCode}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-xl border border-slate-200 bg-white px-3 py-2 font-mono text-xs text-blue-700">
|
||||
{referralCode || "--"}
|
||||
</code>
|
||||
{referralCode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(referralCode)}
|
||||
className="rounded-xl border border-blue-700 bg-blue-600 px-3 text-xs font-bold text-white hover:bg-blue-700"
|
||||
>
|
||||
{copied ? <CheckCircle2 size={15} /> : <Copy size={15} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] leading-5 text-slate-500">
|
||||
{copy.referralRewardHint}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] uppercase text-slate-500">
|
||||
{copy.referralApplyLabel}
|
||||
</p>
|
||||
{appliedReferralCode ? (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-800">
|
||||
{appliedReferralCode}
|
||||
</div>
|
||||
<div className="text-[10px] opacity-80 truncate">
|
||||
{token.name}
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={referralCodeInput}
|
||||
onChange={(event) => setReferralCodeInput(event.target.value)}
|
||||
placeholder={copy.referralApplyPlaceholder}
|
||||
className="min-w-0 flex-1 rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs text-slate-950 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void applyReferralCode()}
|
||||
disabled={!canApplyReferralCode || referralApplying}
|
||||
className="rounded-xl border border-slate-900 bg-slate-900 px-3 text-xs font-bold text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{referralApplying ? "..." : copy.referralApplyButton}
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
<p className="mt-2 text-[11px] leading-5 text-slate-500">
|
||||
{copy.referralDiscountHint}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="payment-guard-grid"
|
||||
className={`grid gap-3 ${hasTelegramPanel ? "" : "sm:grid-cols-2"}`}
|
||||
>
|
||||
<InfoRow
|
||||
icon={Mail}
|
||||
label={copy.paymentAccount}
|
||||
value={email || "--"}
|
||||
isPrimary
|
||||
/>
|
||||
<InfoRow
|
||||
icon={Wallet}
|
||||
label={copy.paymentWallet}
|
||||
value={shortAddress(paymentWalletLabel) || "--"}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={ShieldCheck}
|
||||
label={copy.paymentReceiver}
|
||||
value={shortAddress(paymentReceiverAddress) || "--"}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={ExternalLink}
|
||||
label={copy.paymentNetwork}
|
||||
value={
|
||||
selectedPaymentChain?.name ||
|
||||
chainIdToDisplayName(selectedPaymentChainId)
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={ExternalLink}
|
||||
label={copy.paymentHost}
|
||||
value={currentPaymentHost || "--"}
|
||||
/>
|
||||
<p className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-[11px] leading-5 text-slate-500 sm:col-span-2">
|
||||
{copy.paymentGuardHint}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Method Tabs */}
|
||||
<div className="mt-6 border-t border-slate-200 pt-6">
|
||||
<p className="mb-3 text-[10px] font-semibold uppercase text-slate-500">
|
||||
{copy.paymentMethodLabel}
|
||||
</p>
|
||||
<div className="mb-5 grid grid-cols-2 gap-2 rounded-xl border border-slate-200 bg-slate-100 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethodTab("wallet")}
|
||||
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
|
||||
paymentMethodTab === "wallet"
|
||||
? "bg-white text-blue-700 shadow-sm"
|
||||
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
<Wallet size={12} />
|
||||
{copy.paymentMethodWallet}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethodTab("manual")}
|
||||
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
|
||||
paymentMethodTab === "manual"
|
||||
? "bg-white text-emerald-700 shadow-sm"
|
||||
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
{copy.paymentMethodManual}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{paymentMethodTab === "wallet" ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[11px] leading-relaxed text-slate-400">
|
||||
{copy.paymentWalletDesc}
|
||||
</p>
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-[11px] leading-relaxed text-amber-800">
|
||||
{copy.paymentGasWarning}
|
||||
<div className="space-y-5">
|
||||
{availableChainList.length > 1 && (
|
||||
<div>
|
||||
<p className="mb-2 text-[11px] uppercase text-slate-500">
|
||||
{copy.paymentNetwork}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{availableChainList.map((chain) => {
|
||||
const active = chain.chain_id === selectedPaymentChainId;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={chain.chain_id}
|
||||
onClick={() => {
|
||||
setSelectedPaymentChainId(chain.chain_id);
|
||||
const nextToken =
|
||||
paymentConfig?.tokens?.find(
|
||||
(token) =>
|
||||
Number(token.chain_id || chain.chain_id) ===
|
||||
chain.chain_id &&
|
||||
token.is_default,
|
||||
) ||
|
||||
paymentConfig?.tokens?.find(
|
||||
(token) =>
|
||||
Number(token.chain_id || chain.chain_id) ===
|
||||
chain.chain_id,
|
||||
);
|
||||
if (nextToken?.address) {
|
||||
setSelectedTokenAddress(
|
||||
String(nextToken.address).toLowerCase(),
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={paymentBusy}
|
||||
className={`rounded-xl border px-3 py-2 text-left transition-all ${
|
||||
active
|
||||
? "border-blue-300 bg-blue-50 text-blue-900"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-bold">
|
||||
{chain.name || chainIdToDisplayName(chain.chain_id)}
|
||||
</div>
|
||||
<div className="text-[10px] opacity-80">
|
||||
{chain.native_currency_symbol || "ETH"} gas
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{availableTokenList.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-[11px] uppercase text-slate-500">
|
||||
{copy.paymentToken}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{availableTokenList.map((token) => {
|
||||
const active =
|
||||
token.address ===
|
||||
(resolvedSelectedTokenAddress ||
|
||||
token.address);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${token.chain_id || selectedPaymentChainId}:${token.address}`}
|
||||
onClick={() =>
|
||||
setSelectedTokenAddress(
|
||||
token.address,
|
||||
)
|
||||
}
|
||||
disabled={paymentBusy}
|
||||
className={`rounded-xl border px-3 py-2 text-left transition-all ${
|
||||
active
|
||||
? "border-blue-300 bg-blue-50 text-blue-900"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-bold">
|
||||
{token.symbol}
|
||||
</div>
|
||||
<div className="truncate text-[10px] opacity-80">
|
||||
{token.name}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Method Tabs */}
|
||||
<div className="border-t border-slate-200 pt-5">
|
||||
<p className="mb-3 text-[10px] font-semibold uppercase text-slate-500">
|
||||
{copy.paymentMethodLabel}
|
||||
</p>
|
||||
<div className="mb-5 grid grid-cols-2 gap-2 rounded-xl border border-slate-200 bg-slate-100 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethodTab("wallet")}
|
||||
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
|
||||
paymentMethodTab === "wallet"
|
||||
? "bg-white text-blue-700 shadow-sm"
|
||||
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
<Wallet size={12} />
|
||||
{copy.paymentMethodWallet}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethodTab("manual")}
|
||||
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
|
||||
paymentMethodTab === "manual"
|
||||
? "bg-white text-emerald-700 shadow-sm"
|
||||
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
{copy.paymentMethodManual}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{paymentMethodTab === "wallet" ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[11px] leading-relaxed text-slate-400">
|
||||
{copy.paymentWalletDesc}
|
||||
</p>
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-[11px] leading-relaxed text-amber-800">
|
||||
{copy.paymentGasWarning}
|
||||
</div>
|
||||
|
||||
{boundWallets.length ? (
|
||||
<div className="space-y-3">
|
||||
@@ -1023,7 +1403,7 @@ export function AccountCenter() {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px]">
|
||||
{copy.polygonChain}
|
||||
{chainIdToDisplayName(w.chain_id)}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
@@ -1248,8 +1628,8 @@ export function AccountCenter() {
|
||||
}
|
||||
disabled={
|
||||
paymentBusy ||
|
||||
(txValidation.checked &&
|
||||
!txValidation.valid)
|
||||
!(txValidation.checked &&
|
||||
txValidation.valid === true)
|
||||
}
|
||||
className="w-full rounded-xl border border-emerald-700 bg-emerald-600 px-3 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
@@ -1262,6 +1642,8 @@ export function AccountCenter() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { MessageSquare, RefreshCw } from "lucide-react";
|
||||
import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops";
|
||||
import {
|
||||
feedbackStatusLabel,
|
||||
feedbackStatusTone,
|
||||
} from "@/components/dashboard/scan-terminal/feedback-status";
|
||||
|
||||
function compactDate(value?: string) {
|
||||
if (!value) return "--";
|
||||
return value.slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
function categoryLabel(value?: string, isEn = false) {
|
||||
const key = String(value || "").toLowerCase();
|
||||
if (key === "bug") return "Bug";
|
||||
if (key === "data") return isEn ? "Data" : "数据";
|
||||
if (key === "idea") return isEn ? "Suggestion" : "建议";
|
||||
if (key === "payment") return isEn ? "Payment" : "支付";
|
||||
if (key === "account") return isEn ? "Account" : "账号";
|
||||
return isEn ? "Other" : "其他";
|
||||
}
|
||||
|
||||
function formatRewardPoints(points?: number, isEn = false) {
|
||||
const raw = Number(points || 0);
|
||||
const value = Number.isFinite(raw) ? Math.max(0, raw) : 0;
|
||||
return isEn
|
||||
? `+${value.toLocaleString()} points`
|
||||
: `+${value.toLocaleString()} 积分`;
|
||||
}
|
||||
|
||||
function rewardStatusText(status?: string, isEn = false) {
|
||||
const key = String(status || "").toLowerCase();
|
||||
if (key === "pending") return isEn ? "Reward pending" : "奖励待处理";
|
||||
if (key === "skipped") return isEn ? "No points awarded" : "未发放积分";
|
||||
return isEn ? "Feedback reward" : "反馈奖励";
|
||||
}
|
||||
|
||||
function renderFeedbackReward(entry: UserFeedbackEntry, isEn: boolean) {
|
||||
const rawPoints = Number(entry.reward_points || 0);
|
||||
const points = Number.isFinite(rawPoints) ? Math.max(0, rawPoints) : 0;
|
||||
const rewardStatus = String(entry.reward_status || "").toLowerCase();
|
||||
const reason = String(entry.reward_reason || "").trim();
|
||||
if (points <= 0 && !rewardStatus && !reason) return null;
|
||||
|
||||
const granted = points > 0 || rewardStatus === "granted";
|
||||
return (
|
||||
<div
|
||||
className={`mt-2 rounded-lg border px-3 py-2 text-xs ${
|
||||
granted
|
||||
? "border-amber-200 bg-amber-50 text-amber-800"
|
||||
: "border-slate-200 bg-slate-50 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-bold">{rewardStatusText(rewardStatus, isEn)}</span>
|
||||
{points > 0 ? (
|
||||
<span className="font-mono font-black">
|
||||
{formatRewardPoints(points, isEn)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{reason ? (
|
||||
<div className="mt-1 leading-5 text-slate-600">
|
||||
{isEn ? "Reason" : "奖励原因"}: {reason}
|
||||
</div>
|
||||
) : null}
|
||||
{entry.rewarded_at ? (
|
||||
<div className="mt-1 font-mono text-[11px] text-slate-400">
|
||||
{compactDate(entry.rewarded_at)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountFeedbackPanel({
|
||||
isEn,
|
||||
title,
|
||||
description,
|
||||
refreshLabel,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
refreshLabel: string;
|
||||
}) {
|
||||
const [entries, setEntries] = useState<UserFeedbackEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [available, setAvailable] = useState(true);
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
if (typeof fetch !== "function") return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/feedback?limit=10", {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
setAvailable(false);
|
||||
setEntries([]);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const payload = (await res.json()) as UserFeedbackPayload;
|
||||
setEntries(Array.isArray(payload.feedback) ? payload.feedback : []);
|
||||
setAvailable(true);
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return;
|
||||
setError(String(err).slice(0, 140));
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
const emptyText = useMemo(() => {
|
||||
if (loading) return isEn ? "Loading feedback..." : "正在加载反馈...";
|
||||
return isEn ? "No submitted feedback yet." : "暂无已提交反馈。";
|
||||
}, [isEn, loading]);
|
||||
|
||||
if (!available) return null;
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:col-span-12">
|
||||
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-sm font-bold uppercase text-slate-700">
|
||||
<MessageSquare size={18} className="text-blue-500" />
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">{description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-3 text-xs font-bold text-slate-600 transition hover:bg-slate-50 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw size={14} className={loading ? "animate-spin" : ""} />
|
||||
{refreshLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
{isEn ? "Failed to load feedback: " : "反馈加载失败:"}{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100 overflow-hidden rounded-xl border border-slate-200">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="grid gap-3 bg-white px-4 py-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded border px-2 py-0.5 text-[10px] font-black ${feedbackStatusTone(entry.status)}`}>
|
||||
{feedbackStatusLabel(entry.status, isEn)}
|
||||
</span>
|
||||
<span className="text-[11px] font-bold text-slate-500">
|
||||
{categoryLabel(entry.category, isEn)}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{compactDate(entry.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-sm font-semibold leading-5 text-slate-900">
|
||||
{entry.message || (isEn ? "Feedback" : "反馈")}
|
||||
</p>
|
||||
{renderFeedbackReward(entry, isEn)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 md:text-right">
|
||||
<div className="font-mono">{compactDate(entry.updated_at)}</div>
|
||||
<div className="mt-1 text-[11px] text-slate-400">
|
||||
{isEn ? "Updated" : "最近更新"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const accountDir = path.join(projectRoot, "components", "account");
|
||||
const accountCenterSource = fs.readFileSync(
|
||||
path.join(accountDir, "AccountCenter.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const feedbackPanelPath = path.join(accountDir, "AccountFeedbackPanel.tsx");
|
||||
|
||||
assert(fs.existsSync(feedbackPanelPath), "account center must ship a My Feedback panel component");
|
||||
|
||||
const feedbackPanelSource = fs.readFileSync(feedbackPanelPath, "utf8");
|
||||
assert(
|
||||
accountCenterSource.includes('import { AccountFeedbackPanel } from "./AccountFeedbackPanel";') &&
|
||||
accountCenterSource.includes("<AccountFeedbackPanel") &&
|
||||
accountCenterSource.includes("accountFeedbackTitle"),
|
||||
"account center must mount the My Feedback panel with localized account copy",
|
||||
);
|
||||
assert(
|
||||
feedbackPanelSource.includes("/api/feedback?limit=10") &&
|
||||
feedbackPanelSource.includes("feedbackStatusLabel") &&
|
||||
feedbackPanelSource.includes("RefreshCw") &&
|
||||
feedbackPanelSource.includes("useEffect") &&
|
||||
!feedbackPanelSource.includes("setInterval"),
|
||||
"account feedback panel must load the current user's feedback once, support manual refresh, and avoid polling",
|
||||
);
|
||||
assert(
|
||||
feedbackPanelSource.includes("reward_points") &&
|
||||
feedbackPanelSource.includes("reward_reason") &&
|
||||
feedbackPanelSource.includes("reward_status") &&
|
||||
feedbackPanelSource.includes("formatRewardPoints") &&
|
||||
feedbackPanelSource.includes("renderFeedbackReward") &&
|
||||
feedbackPanelSource.includes("奖励原因"),
|
||||
"account feedback panel must show per-feedback reward points and reward reasons",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
buildAuthMePath,
|
||||
mergeAccountAuthSnapshot,
|
||||
} from "@/lib/auth-snapshot";
|
||||
import type { AuthSnapshotLike } from "@/lib/auth-snapshot";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
assert(
|
||||
buildAuthMePath({ scope: "entitlement" }) ===
|
||||
"/api/auth/me?scope=entitlement",
|
||||
"account entitlement probe must use the lightweight auth/me scope",
|
||||
);
|
||||
assert(
|
||||
buildAuthMePath({ preferSnapshot: true, scope: "entitlement" }) ===
|
||||
"/api/auth/me?prefer_snapshot=1&scope=entitlement",
|
||||
"terminal auth probe must combine snapshot preference with lightweight entitlement scope",
|
||||
);
|
||||
|
||||
const active: AuthSnapshotLike & { referral?: { code?: string } } = {
|
||||
authenticated: true,
|
||||
user_id: "user-1",
|
||||
subscription_active: true,
|
||||
subscription_plan_code: "pro_monthly",
|
||||
subscription_expires_at: "2026-07-01T00:00:00Z",
|
||||
subscription_total_expires_at: "2026-07-01T00:00:00Z",
|
||||
subscription_queued_days: 0,
|
||||
points: 120,
|
||||
referral: { code: "PW-ABC" },
|
||||
};
|
||||
|
||||
const degraded = mergeAccountAuthSnapshot(active, {
|
||||
authenticated: true,
|
||||
user_id: "user-1",
|
||||
subscription_active: null,
|
||||
subscription_plan_code: null,
|
||||
subscription_expires_at: null,
|
||||
subscription_total_expires_at: null,
|
||||
subscription_queued_days: 0,
|
||||
points: 0,
|
||||
degraded_auth_profile: true,
|
||||
});
|
||||
|
||||
assert(
|
||||
degraded.subscription_active === true &&
|
||||
degraded.subscription_plan_code === "pro_monthly" &&
|
||||
degraded.points === 120 &&
|
||||
degraded.referral?.code === "PW-ABC",
|
||||
"account snapshot merge must preserve confirmed Pro status when a later auth/me response is degraded",
|
||||
);
|
||||
|
||||
const inactive = mergeAccountAuthSnapshot(active, {
|
||||
authenticated: true,
|
||||
user_id: "user-1",
|
||||
subscription_active: false,
|
||||
subscription_plan_code: null,
|
||||
points: 0,
|
||||
});
|
||||
|
||||
assert(
|
||||
inactive.subscription_active === false,
|
||||
"account snapshot merge must still accept a confirmed inactive subscription response",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const accountDir = path.join(projectRoot, "components", "account");
|
||||
const useAccountPaymentSource = fs.readFileSync(
|
||||
path.join(accountDir, "useAccountPayment.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const usePaymentFlowSource = fs.readFileSync(
|
||||
path.join(accountDir, "usePaymentFlow.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const useWalletBindSource = fs.readFileSync(
|
||||
path.join(accountDir, "useWalletBind.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const accountCenterSource = fs.readFileSync(
|
||||
path.join(accountDir, "AccountCenter.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const opsPaymentsSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "payments", "PaymentsPageClient.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
useAccountPaymentSource.includes("selectedPaymentChainId") &&
|
||||
useAccountPaymentSource.includes("setSelectedPaymentChainId"),
|
||||
"account payment state must track the selected payment chain separately from the legacy default chain",
|
||||
);
|
||||
assert(
|
||||
usePaymentFlowSource.includes("chain_id: selectedPaymentChainId") ||
|
||||
usePaymentFlowSource.includes("chain_id: targetChainId"),
|
||||
"payment intent creation must send the selected chain_id to the backend",
|
||||
);
|
||||
assert(
|
||||
!usePaymentFlowSource.includes("请在 Polygon 网络转"),
|
||||
"manual transfer instructions must not hard-code Polygon after Ethereum USDC is supported",
|
||||
);
|
||||
assert(
|
||||
useWalletBindSource.includes("chainName") &&
|
||||
useWalletBindSource.includes("wallet_addEthereumChain") &&
|
||||
useWalletBindSource.includes("Ethereum Mainnet"),
|
||||
"wallet network switching must use chain metadata instead of hard-coded Polygon-only add-network params",
|
||||
);
|
||||
assert(
|
||||
accountCenterSource.includes("availableChainList") &&
|
||||
accountCenterSource.includes("setSelectedPaymentChainId") &&
|
||||
accountCenterSource.includes("paymentNetwork"),
|
||||
"account center must expose a payment network selector when multiple chains are configured",
|
||||
);
|
||||
assert(
|
||||
opsPaymentsSource.includes("etherscan.io") &&
|
||||
opsPaymentsSource.includes("polygonscan.com"),
|
||||
"ops payment tx links must route Ethereum payments to Etherscan and Polygon payments to Polygonscan",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const accountCenter = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "AccountCenter.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const accountCopy = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "account-copy.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const useAccountPayment = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "useAccountPayment.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const usePaymentFlow = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "usePaymentFlow.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const useBilling = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "useBilling.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const unlockProOverlay = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "subscription", "UnlockProOverlay.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const telegramPricing = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "telegram-pricing.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const types = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "types.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
accountCopy.includes("3天试用") &&
|
||||
accountCopy.includes("付费 Telegram 群") &&
|
||||
accountCopy.includes("邀请码"),
|
||||
"account copy must describe trial limits and referral code UI",
|
||||
);
|
||||
assert(
|
||||
accountCenter.includes("copy.trialPaidGroupLocked") &&
|
||||
accountCenter.includes("copy.referralInviteLimit") &&
|
||||
accountCenter.includes("applyReferralCode"),
|
||||
"account center must expose trial paid-group gating and referral controls",
|
||||
);
|
||||
assert(
|
||||
accountCenter.includes("pro_quarterly") &&
|
||||
accountCenter.includes("79.9") &&
|
||||
accountCenter.includes("29.9"),
|
||||
"account center must show monthly and quarterly Pro prices",
|
||||
);
|
||||
assert(
|
||||
accountCopy.includes("20 USDC") &&
|
||||
accountCopy.includes("+3500 积分") &&
|
||||
accountCopy.includes("月付订单最多抵扣 3 USDC") &&
|
||||
accountCopy.includes("季度订单最多抵扣 8 USDC") &&
|
||||
!accountCopy.includes("群内有效发言"),
|
||||
"account copy must describe balanced referral points and remove group-message points",
|
||||
);
|
||||
assert(
|
||||
!useAccountPayment.includes("monthlyPlanList") &&
|
||||
!usePaymentFlow.includes("monthlyPlanList"),
|
||||
"payment hooks must not filter checkout plans down to monthly only",
|
||||
);
|
||||
assert(
|
||||
useAccountPayment.includes("applyTelegramGroupPricingToPlanList") &&
|
||||
useAccountPayment.includes("backend?.telegram_pricing") &&
|
||||
useAccountPayment.includes("isTelegramPrivateGroupPriceEligible") &&
|
||||
telegramPricing.includes("is_private_group_member") &&
|
||||
telegramPricing.includes("telegram_private_group_member") &&
|
||||
!telegramPricing.includes("is_group_member") &&
|
||||
useAccountPayment.includes('=== "pro_monthly"') &&
|
||||
useAccountPayment.includes("amount_usdc: telegramAmountUsdc"),
|
||||
"account payment plan cards must only display the 5 USDC discounted monthly price after verified /bind eligibility",
|
||||
);
|
||||
assert(
|
||||
useBilling.includes("telegramGroupPriceApplies") &&
|
||||
useBilling.includes("isTelegramPrivateGroupPriceEligible") &&
|
||||
useBilling.includes("backend?.telegram_pricing") &&
|
||||
useBilling.includes("!telegramGroupPriceApplies"),
|
||||
"billing must not let referral first-month pricing override the lower verified monthly price",
|
||||
);
|
||||
assert(
|
||||
!accountCenter.includes(["private", "Group", "Monthly", "Plan"].join("")) &&
|
||||
!accountCopy.includes(["Private", "group", "monthly"].join(" ")) &&
|
||||
!accountCopy.includes(["私", "密", "群", "月", "付"].join("")),
|
||||
"account plan card and checkout overlay should not expose a separate discounted monthly label",
|
||||
);
|
||||
assert(
|
||||
accountCenter.includes("overlayPlanLabel") &&
|
||||
accountCenter.includes("overlayPeriodLabel") &&
|
||||
!accountCenter.includes(`copy.${["private", "Group", "Monthly", "Plan"].join("")}`) &&
|
||||
unlockProOverlay.includes("planLabel") &&
|
||||
unlockProOverlay.includes("USDC") &&
|
||||
!unlockProOverlay.includes("<span className={s.price}>${planPriceUsd.toFixed(2)}</span>") &&
|
||||
!unlockProOverlay.includes('<span className={s.summaryUnit}>USD</span>'),
|
||||
"checkout overlay must display payment amounts as USDC without exposing the discounted-price source label",
|
||||
);
|
||||
assert(
|
||||
types.includes("ReferralSummary") &&
|
||||
types.includes("referral?: ReferralSummary | null") &&
|
||||
types.includes("is_private_group_member?: boolean") &&
|
||||
types.includes("duration_days: number") &&
|
||||
types.includes("max_discount_usdc_by_plan"),
|
||||
"account auth and payment types must include referral summary, private Telegram pricing, and plan durations",
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user