feat: implement Telegram account binding and update project structure in documentation

This commit is contained in:
2569718930@qq.com
2026-05-18 17:10:44 +08:00
parent 1b2731ed12
commit 78b23ef361
8 changed files with 247 additions and 99 deletions
+22 -14
View File
@@ -38,8 +38,8 @@ Users (Web / Telegram) → Next.js Frontend (Vercel) → FastAPI /web/app.py
Payment Layer (Intent + Event + Confirm Loop)
```
- **Backend**: FastAPI on port 8000 (`web/app.py``web/core.py` + `web/routes.py` + `web/analysis_service.py`)
- **Frontend**: Next.js 15 + React 19 + TypeScript + Tailwind CSS 3 on port 3000 (dev)
- **Backend**: FastAPI on port 8000 (`web/app.py``web/app_factory.py` `web/routers/` (8 route modules) + `web/services/` (14 service modules) + `web/core.py`)
- **Frontend**: Next.js 15 + React 19 + TypeScript + Tailwind CSS 3 + shadcn/ui (new-york style) on port 3000 (dev)
- **Bot**: Telegram bot via `bot_listener.py``src/bot/`
- **Shared analysis core** in `src/` is used by both web API and bot
- **Scan Terminal**: Real-time city opportunity scanning (`web/scan_terminal_service.py` and `frontend/components/dashboard/scan-terminal/`)
@@ -54,9 +54,12 @@ Users (Web / Telegram) → Next.js Frontend (Vercel) → FastAPI /web/app.py
```bash
cd frontend
npm ci
npm run dev # Next.js dev server
npm run build # Production build
npm run lint # ESLint via next lint
npm run dev # Next.js dev server (runs sync-next-server-chunks.mjs first)
npm run build # Production build (runs sync-next-server-chunks.mjs after)
npm run start # Production server
npm run lint # ESLint via next lint
npm run typecheck # tsc --noEmit
npm run test:business # Business state tests (also runs in CI)
```
### Backend (dev on port 8000)
@@ -73,10 +76,9 @@ python run.py
### Docker (production-like stack)
```bash
docker compose up -d --build # bot + web API
docker compose --profile workers up -d # + prewarm worker
docker compose --profile monitoring up -d # + Prometheus/Grafana/Alertmanager
docker compose up -d --build # bot + web API (polyweather + polyweather_web)
```
The compose file defines two services: `polyweather` (bot) and `polyweather_web` (FastAPI on :8000). Prewarm worker and monitoring profiles were removed in v1.6.0.
### Python tests
```bash
@@ -101,21 +103,26 @@ curl http://127.0.0.1:8000/metrics
| Directory | Purpose |
|-----------|---------|
| `src/data_collection/` | Weather sources (METAR, TAF, Open-Meteo, JMA, KMA, MGM, NMC, Russia stations, settlement sources), city registry (52 cities), Polymarket readonly layer. Also: `madis_sources.py` (NOAA 5-min NetCDF), `amos_station_sources.py` (Korean runway sensors), `country_networks.py` (per-country provider routing + `_airport_primary_from_raw`) |
| `src/analysis/` | DEB algorithm, trend engine, probability calibration (EMOS/LGBM), market alert engine, settlement rounding |
| `src/models/` | LightGBM daily-high model training and feature engineering |
| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit |
| `src/auth/` | Supabase entitlement checks, Telegram group pricing |
| `src/bot/` | Telegram bot handlers and orchestrator |
| `src/database/` | SQLite-based runtime state, DB manager, daily/truth/training feature repositories |
| `web/` | FastAPI app, routes (~65K), analysis service (~130K), scan terminal service (~56K), AI scan modules |
| `src/data_collection/` | Weather sources (METAR, TAF, Open-Meteo, JMA, KMA, MGM, NMC, Russia stations, settlement sources), city registry (52 cities), Polymarket readonly layer. Also: `madis_sources.py` (NOAA 5-min NetCDF), `amos_station_sources.py` (Korean runway sensors), `country_networks.py` (per-country provider routing + `_airport_primary_from_raw`) |
| `src/data_mining/` | Historical data fetch utilities |
| `src/models/` | LightGBM daily-high model training and feature engineering |
| `src/onchain/` | Polygon wallet watcher, Polymarket wallet activity watcher |
| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit |
| `src/strategy/` | Trading strategy modules |
| `src/trading/` | Trading execution modules |
| `src/utils/` | Shared utilities: config loader, logging, metrics, Telegram push, chat ID helpers |
| `web/` | FastAPI app (`app.py``app_factory.py`), `routers/` (8 route modules), `services/` (14 service modules), `core.py`, scan terminal modules (AI fallback, AI prompts, METAR gate, city rows, ranker, cache) |
| `frontend/app/` | Next.js App Router pages (dashboard, account, auth, docs, ops, probabilities, scan) |
| `frontend/components/dashboard/` | Dashboard UI components (map, sidebar, detail panel, modals, charts, scan terminal). `scan-root-styles.ts` is the CSS Module barrel, combining 22 module roots into one pre-composed className. `monitoring/` subdirectory: `MonitorPanel`, `monitor-temperature.ts` (temp resolution chain), `monitor-refresh-policy.ts`. |
| `frontend/lib/` | Shared client logic: types (`dashboard-types.ts`, including `AirportCurrentConditions`, `CityDetail`), API client, chart utils, i18n, `source-freshness.ts` (per-source freshness with `expected_next_update_at`), dashboard utils |
| `frontend/hooks/` | React hooks: dashboard store (global state), Leaflet map, chart helper |
| `scripts/` | Operational scripts: probability calibration training, backfills, payment reconciliation, prewarm worker |
| `scripts/` | Operational scripts: probability calibration training, backfills, payment reconciliation. `supabase/` subdirectory: DB schema and migration SQL. |
| `config/` | YAML config (city list, weather settings, logging) |
| `docs/` | Bilingual product & technical docs |
| `monitoring/` | Prometheus/Grafana/Alertmanager configs |
## Key Technical Details
@@ -124,6 +131,7 @@ curl http://127.0.0.1:8000/metrics
- **Frontend package manager**: npm
- **State storage**: SQLite primary path (set via `POLYWEATHER_STATE_STORAGE_MODE=sqlite` + `POLYWEATHER_DB_PATH`). Legacy JSON/JSONL files are migration/fallback only.
- **Runtime data**: External dir recommended (`POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather`) to avoid git conflicts
- **Configuration**: `.env.example` is the comprehensive reference (8 config sections: runtime, Telegram, weather cache, auth, ops, frontend, optional modules, Polygon monitor). Copy to `.env` and fill in secrets.
- **Auth gating** (frontend middleware): Token-based (`POLYWEATHER_DASHBOARD_ACCESS_TOKEN`) or Supabase session-based (`POLYWEATHER_AUTH_ENABLED`). Local dev hosts bypass auth.
- **CORS**: Allowed origins from `WEB_CORS_ORIGINS` env var (defaults: localhost:3000, polyweather-pro.vercel.app)
- **EMOS/CRPS calibration**: Trainable but production should use `legacy` or `emos_shadow` engine; `emos_primary` only after local evaluation + manual rollout
@@ -0,0 +1,44 @@
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 body = await req.text();
const auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/auth/telegram/bind-by-token`, {
method: "POST",
headers,
body,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to bind Telegram account",
});
}
}
+32 -76
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { User } from "@supabase/supabase-js";
@@ -80,16 +80,6 @@ type TelegramPricing = {
pricing_source?: string;
};
type TelegramAuthPayload = {
id: number;
first_name?: string;
last_name?: string;
username?: string;
photo_url?: string;
auth_date: number;
hash: string;
};
type PaymentPlan = {
plan_code: string;
plan_id: number;
@@ -250,11 +240,6 @@ const TELEGRAM_GROUP_URL = String(
const TELEGRAM_BOT_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_BOT_URL || "https://t.me/WeatherQuant_bot",
).trim();
const TELEGRAM_LOGIN_BOT_USERNAME = String(
process.env.NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME || "WeatherQuant_bot",
)
.replace(/^@/, "")
.trim();
const TELEGRAM_MARKET_CHANNEL_URL = "https://t.me/+hGAk7JsjtdhiOTUx";
const TELEGRAM_TOPICS_GROUP_URL = "https://t.me/+8vel7rwjZagxODUx";
const SUBSCRIPTION_HELP_HREF = "/subscription-help";
@@ -898,8 +883,6 @@ export function AccountCenter() {
const [lastTxHash, setLastTxHash] = useState("");
const [manualPayment, setManualPayment] = useState<CreatedIntent["direct_payment"] | null>(null);
const [manualTxHash, setManualTxHash] = useState("");
const [telegramLoginBusy, setTelegramLoginBusy] = useState(false);
const telegramLoginWidgetRef = useRef<HTMLDivElement | null>(null);
const [lastPaymentStartedAt, setLastPaymentStartedAt] = useState(0);
const [showSecondarySections, setShowSecondarySections] = useState(false);
const [reconcileBusy, setReconcileBusy] = useState(false);
@@ -1510,64 +1493,44 @@ export function AccountCenter() {
const userId = backend?.user_id || user?.id || "";
const isAuthenticated = Boolean(userId);
const email = backend?.email || user?.email || "";
// Handle ?bind_token=xxx from Telegram bot /bind deep link
useEffect(() => {
const container = telegramLoginWidgetRef.current;
if (!container || !TELEGRAM_LOGIN_BOT_USERNAME || !isAuthenticated) return;
container.innerHTML = "";
const callbackName = "onPolyWeatherTelegramAuth";
(window as unknown as Record<string, unknown>)[callbackName] = async (
payload: TelegramAuthPayload,
) => {
setTelegramLoginBusy(true);
if (!isAuthenticated) return;
const params = new URLSearchParams(window.location.search);
const token = params.get("bind_token");
if (!token) return;
// Remove token from URL so refresh doesn't retry
const url = new URL(window.location.href);
url.searchParams.delete("bind_token");
window.history.replaceState(null, "", url.toString());
(async () => {
setPaymentError("");
setPaymentInfo("");
try {
const authHeaders = await buildAuthedHeaders(true, false);
const res = await fetch("/api/auth/telegram/login", {
const res = await fetch("/api/auth/telegram/bind-by-token", {
method: "POST",
headers: authHeaders,
body: JSON.stringify(payload),
body: JSON.stringify({ token }),
});
if (!res.ok) {
const raw = (await res.text()).slice(0, 350);
throw new Error(`telegram login failed: ${raw}`);
throw new Error(`bind failed: ${raw}`);
}
const data = (await res.json()) as {
telegram_pricing?: TelegramPricing | null;
};
const amount = data.telegram_pricing?.amount_usdc || "10";
setPaymentInfo(
data.telegram_pricing?.is_group_member
? `Telegram 群成员验证成功,当前会员价 ${amount}U。`
: `Telegram 已登录,但未检测到群成员身份,当前价格 ${amount}U。`,
);
if (data.telegram_pricing?.is_group_member) {
const amount = data.telegram_pricing.amount_usdc || "5";
setPaymentInfo(`Telegram 群成员验证成功,当前会员价 ${amount}U。`);
}
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
setPaymentError(normalizePaymentError(error).message);
} finally {
setTelegramLoginBusy(false);
}
};
const script = document.createElement("script");
script.src = "https://telegram.org/js/telegram-widget.js?22";
script.async = true;
script.setAttribute("data-telegram-login", TELEGRAM_LOGIN_BOT_USERNAME);
script.setAttribute("data-size", "large");
script.setAttribute("data-radius", "12");
script.setAttribute("data-userpic", "false");
script.setAttribute("data-request-access", "write");
script.setAttribute("data-onauth", `${callbackName}(user)`);
container.appendChild(script);
return () => {
container.innerHTML = "";
};
}, [
buildAuthedHeaders,
isAuthenticated,
loadPaymentSnapshot,
loadSnapshot,
]);
})();
}, [isAuthenticated, buildAuthedHeaders, loadPaymentSnapshot, loadSnapshot]);
const displayName =
String(user?.user_metadata?.full_name || "").trim() ||
(email ? String(email).split("@")[0] : "") ||
@@ -3011,28 +2974,21 @@ export function AccountCenter() {
<p className="text-slate-400 text-sm mb-6">
{copy.telegramHint}
</p>
<div className="mb-5 rounded-2xl border border-emerald-400/25 bg-emerald-500/8 px-4 py-3">
<p className="text-xs font-bold text-emerald-200">
Telegram
</p>
<p className="mt-1 text-[11px] leading-5 text-emerald-100/75">
Telegram 5U 10U
</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<div ref={telegramLoginWidgetRef} />
{telegramLoginBusy ? (
<span className="text-[11px] text-cyan-200">
Telegram...
</span>
) : null}
{backend?.telegram_pricing?.amount_usdc ? (
{backend?.telegram_pricing?.is_group_member ? (
<div className="mb-5 rounded-2xl border border-emerald-400/25 bg-emerald-500/8 px-4 py-3">
<p className="text-xs font-bold text-emerald-200">
Telegram
</p>
<p className="mt-1 text-[11px] leading-5 text-emerald-100/75">
{backend.telegram_pricing.amount_usdc ?? "5"}U
</p>
<div className="mt-3">
<span className="rounded-full border border-white/10 bg-black/25 px-3 py-1.5 text-[11px] font-bold text-white">
: {backend.telegram_pricing.amount_usdc}U
{backend.telegram_pricing.is_group_member ? " · 群成员" : " · 普通价"}
: {backend.telegram_pricing.amount_usdc ?? "5"}U ·
</span>
) : null}
</div>
</div>
</div>
) : null}
{/* Channel Upgrade / Migration Notice — Pro users only */}
{isSubscribed ? (
+30 -7
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import os
from typing import Any
from typing import Callable
@@ -7,6 +8,7 @@ from src.bot.command_parser import extract_command_name
from src.bot.io_layer import BotIOLayer
from src.bot.observability import CommandTrace
from src.bot.runtime_coordinator import RuntimeStatus, render_runtime_status_html
from src.auth.telegram_group_pricing import TelegramGroupPricing, TELEGRAM_MEMBER_STATUSES
_BASIC_COMMANDS = {"start", "help", "id", "top", "diag", "bind", "unbind"}
_BASIC_COMMANDS = {"start", "help", "id", "top", "diag", "bind", "unbind", "markets"}
@@ -140,27 +142,48 @@ class BasicCommandHandler:
trace = CommandTrace("/bind", message)
try:
parts = (message.text or "").split(maxsplit=2)
user = message.from_user
# No-args mode: generate a one-time bind token for group members
if len(parts) < 2:
pricing = TelegramGroupPricing()
if not pricing.configured:
self.bot.reply_to(
message,
"⚠️ 机器人未配置群组定价,请联系管理员。",
)
trace.set_status("error", "pricing_not_configured")
return
member_status = pricing.get_member_status(user.id)
if not member_status or member_status not in TELEGRAM_MEMBER_STATUSES:
self.bot.reply_to(
message,
"🔒 此功能仅限内部群成员使用。",
)
trace.set_status("blocked", f"not_group_member:{member_status or 'none'}")
return
token = self.io_layer.db.create_bind_token(user.id, ttl_minutes=10)
app_url = str(os.getenv("POLYWEATHER_APP_URL") or "https://polyweather-pro.vercel.app").rstrip("/")
bind_url = f"{app_url}/account?bind_token={token}"
self.bot.reply_to(
message,
(
"❌ 用法:\n"
"<code>/bind &lt;supabase_user_id&gt; [email]</code>\n\n"
"示例:\n"
"<code>/bind 11111111-2222-3333-4444-555555555555 user@example.com</code>"
"🔗 点击以下链接绑定网页账户(10 分钟内有效):\n"
f"{bind_url}\n\n"
"打开链接后系统将自动验证群成员身份并绑定 Telegram。"
),
parse_mode="HTML",
disable_web_page_preview=False,
)
trace.set_status("bad_request", "missing_supabase_user_id")
trace.set_status("ok", "token_generated")
return
# Args mode: manual bind with supabase_user_id
supabase_user_id = str(parts[1] or "").strip()
if len(supabase_user_id) < 8:
self.bot.reply_to(message, "❌ supabase_user_id 格式不正确。")
trace.set_status("bad_request", "invalid_supabase_user_id")
return
supabase_email = str(parts[2] or "").strip() if len(parts) >= 3 else ""
user = message.from_user
self.io_layer.db.upsert_user(user.id, self.io_layer.display_name(user))
result = self.io_layer.db.bind_supabase_identity(
telegram_id=user.id,
+64
View File
@@ -2,6 +2,7 @@ import sqlite3
import os
import hashlib
import json
import secrets
import threading
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List, Set
@@ -364,6 +365,17 @@ class DBManager:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_supabase_bindings_telegram_id ON supabase_bindings(telegram_id)"
)
conn.execute("""
CREATE TABLE IF NOT EXISTS telegram_bind_tokens (
token TEXT PRIMARY KEY,
telegram_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_telegram_bind_tokens_expires ON telegram_bind_tokens(expires_at)"
)
conn.execute("""
CREATE TABLE IF NOT EXISTS airport_obs_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1368,6 +1380,58 @@ class DBManager:
)
return {"ok": True, "reason": "unbound", "previous_supabase_user_id": current_uid}
def create_bind_token(self, telegram_id: int, ttl_minutes: int = 10) -> str:
token = secrets.token_urlsafe(16)
now = datetime.now()
expires_at = now + timedelta(minutes=max(1, int(ttl_minutes)))
with self._get_connection() as conn:
conn.execute(
"""
DELETE FROM telegram_bind_tokens
WHERE telegram_id = ? OR expires_at < ?
""",
(int(telegram_id), now.isoformat()),
)
conn.execute(
"""
INSERT INTO telegram_bind_tokens (token, telegram_id, expires_at)
VALUES (?, ?, ?)
""",
(token, int(telegram_id), expires_at.isoformat()),
)
conn.commit()
return token
def consume_bind_token(self, token: str) -> Optional[int]:
token = str(token or "").strip()
if not token:
return None
now = datetime.now()
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"""
SELECT telegram_id, expires_at
FROM telegram_bind_tokens
WHERE token = ?
LIMIT 1
""",
(token,),
).fetchone()
if not row:
return None
try:
expires_at = datetime.fromisoformat(row["expires_at"])
except Exception:
expires_at = now
if now > expires_at:
conn.execute("DELETE FROM telegram_bind_tokens WHERE token = ?", (token,))
conn.commit()
return None
conn.execute("DELETE FROM telegram_bind_tokens WHERE token = ?", (token,))
conn.commit()
return int(row["telegram_id"])
def add_message_activity(
self,
telegram_id: int,
+4
View File
@@ -411,6 +411,10 @@ class TelegramLoginRequest(BaseModel):
hash: str = Field(..., min_length=10)
class TelegramBindTokenRequest(BaseModel):
token: str = Field(..., min_length=8)
class AnalyticsEventRequest(BaseModel):
event_type: str = Field(..., min_length=3, max_length=64)
client_id: Optional[str] = Field(default=None, max_length=128)
+11 -2
View File
@@ -2,8 +2,12 @@
from fastapi import APIRouter, Request
from web.core import TelegramLoginRequest
from web.services.auth_api import get_auth_me_payload, login_with_telegram
from web.core import TelegramBindTokenRequest, TelegramLoginRequest
from web.services.auth_api import (
bind_telegram_by_token,
get_auth_me_payload,
login_with_telegram,
)
router = APIRouter(tags=["auth"])
@@ -16,3 +20,8 @@ async def auth_me(request: Request):
@router.post("/api/auth/telegram/login")
async def auth_telegram_login(request: Request, body: TelegramLoginRequest):
return login_with_telegram(request, body)
@router.post("/api/auth/telegram/bind-by-token")
async def auth_telegram_bind_by_token(request: Request, body: TelegramBindTokenRequest):
return bind_telegram_by_token(request, body)
+40
View File
@@ -157,3 +157,43 @@ def login_with_telegram(request: Request, body: TelegramLoginRequest) -> Dict[st
"binding": bind_result,
"telegram_pricing": price,
}
def bind_telegram_by_token(request: Request, body) -> Dict[str, Any]:
"""Bind Telegram identity using a one-time token from the bot /bind command."""
legacy_routes._assert_entitlement(request)
identity = legacy_routes._require_supabase_identity(request)
token = str(getattr(body, "token", "") or "").strip()
if not token:
raise HTTPException(status_code=400, detail="bind_token is required")
db = DBManager()
telegram_id = db.consume_bind_token(token)
if telegram_id is None:
raise HTTPException(status_code=400, detail="invalid or expired bind token")
pricing = TelegramGroupPricing()
member_status = pricing.get_member_status(telegram_id) if pricing.configured else None
if not member_status or member_status not in ("creator", "administrator", "member"):
raise HTTPException(status_code=403, detail="not a group member")
db.upsert_user(telegram_id, "")
bind_result = db.bind_supabase_identity(
telegram_id=telegram_id,
supabase_user_id=identity["user_id"],
supabase_email=identity.get("email") or "",
)
if not bind_result.get("ok"):
raise HTTPException(
status_code=409,
detail=str(bind_result.get("reason") or "telegram bind failed"),
)
price = pricing.resolve_price_for_telegram_id(telegram_id)
return {
"ok": True,
"telegram_id": telegram_id,
"binding": bind_result,
"telegram_pricing": price,
}