diff --git a/frontend/app/api/ops/memberships/route.ts b/frontend/app/api/ops/memberships/route.ts new file mode 100644 index 00000000..77d62618 --- /dev/null +++ b/frontend/app/api/ops/memberships/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-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 url = new URL(`${API_BASE}/api/ops/memberships`); + const limit = req.nextUrl.searchParams.get("limit"); + if (limit) url.searchParams.set("limit", limit); + + 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 NextResponse.json( + { error: "Failed to fetch memberships", detail: String(error) }, + { status: 500 }, + ); + } +} diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 29b10d9e..e9441acb 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -2260,6 +2260,58 @@ padding: 12px; } +.root :global(.detail-source-list) { + display: grid; + grid-template-columns: 1fr; + gap: 10px; +} + +.root :global(.detail-source-note) { + margin: 8px 0 12px; + color: var(--text-muted); + font-size: 12px; + line-height: 1.6; +} + +.root :global(.detail-source-link) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid var(--border-subtle); + background: rgba(255, 255, 255, 0.03); + color: var(--text-main); + text-decoration: none; + transition: + background 0.18s ease, + border-color 0.18s ease, + transform 0.18s ease; +} + +.root :global(.detail-source-link:hover) { + background: rgba(34, 211, 238, 0.08); + border-color: rgba(34, 211, 238, 0.28); + transform: translateY(-1px); +} + +.root :global(.detail-source-kind) { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + color: var(--accent-cyan); + text-transform: uppercase; + flex-shrink: 0; +} + +.root :global(.detail-source-label) { + font-size: 13px; + font-weight: 600; + color: var(--text-main); + text-align: right; +} + .root :global(.detail-label) { display: block; color: var(--text-muted); diff --git a/frontend/components/dashboard/DetailPanel.tsx b/frontend/components/dashboard/DetailPanel.tsx index c5e1c492..b4aadd0e 100644 --- a/frontend/components/dashboard/DetailPanel.tsx +++ b/frontend/components/dashboard/DetailPanel.tsx @@ -7,6 +7,7 @@ import { ForecastTable } from "@/components/dashboard/PanelSections"; import { useChart } from "@/hooks/useChart"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; +import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources"; import { getCityScenery } from "@/lib/dashboard-scenery"; import { CityDetail } from "@/lib/dashboard-types"; import { @@ -147,6 +148,10 @@ export function DetailPanel() { () => (detail ? getCityProfileStats(detail, locale) : []), [detail, locale], ); + const officialLinks = useMemo( + () => (detail ? getOfficialSourceLinks(detail) : []), + [detail], + ); const scenery = getCityScenery(detail?.name); const blurActiveElement = () => { if (typeof document === "undefined") return; @@ -336,6 +341,33 @@ export function DetailPanel() { + {officialLinks.length > 0 ? ( +
+

{locale === "en-US" ? "Official Sources" : "官方参考"}

+

+ {locale === "en-US" + ? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page." + : "AGENCY = 国家气象机构,METAR = 机场实测报文,AIRPORT = 机场官网页面。"} +

+
+ {officialLinks.map((link) => ( + + + {link.kind.toUpperCase()} + + {link.label} + + ))} +
+
+ ) : null} +

{t("detail.todayMiniTrend")}

{heavyContentReady ? ( diff --git a/frontend/components/ops/OpsDashboard.tsx b/frontend/components/ops/OpsDashboard.tsx index 0e861fbc..05473a40 100644 --- a/frontend/components/ops/OpsDashboard.tsx +++ b/frontend/components/ops/OpsDashboard.tsx @@ -86,6 +86,17 @@ type WeeklyLeaderboardEntry = { weekly_points?: number; }; +type MembershipEntry = { + user_id: string; + email?: string | null; + telegram_id?: number | null; + username?: string | null; + registered_at?: string | null; + plan_code?: string | null; + starts_at?: string | null; + expires_at?: string | null; +}; + function formatDateTime(value?: string | null) { if (!value) return "-"; const date = new Date(value); @@ -118,9 +129,11 @@ export function OpsDashboard() { const [refreshedAt, setRefreshedAt] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [users, setUsers] = useState([]); + const [memberships, setMemberships] = useState([]); const [usersLoading, setUsersLoading] = useState(false); const [usersError, setUsersError] = useState(null); const [leaderboard, setLeaderboard] = useState([]); + const [membershipsLoading, setMembershipsLoading] = useState(false); const [grantEmail, setGrantEmail] = useState(""); const [grantPoints, setGrantPoints] = useState("300"); const [grantStatus, setGrantStatus] = useState(null); @@ -183,10 +196,25 @@ export function OpsDashboard() { } }, []); + const loadMemberships = useCallback(async () => { + setMembershipsLoading(true); + try { + const data = await readJson<{ memberships?: MembershipEntry[] }>( + "/api/ops/memberships?limit=200", + ); + setMemberships(data.memberships || []); + } catch { + setMemberships([]); + } finally { + setMembershipsLoading(false); + } + }, []); + useEffect(() => { void loadUsers(""); void loadLeaderboard(); - }, [loadLeaderboard, loadUsers]); + void loadMemberships(); + }, [loadLeaderboard, loadMemberships, loadUsers]); const rolloutDecision = status?.probability?.rollout?.decision; @@ -227,12 +255,13 @@ export function OpsDashboard() { ); await loadUsers(searchQuery); await loadLeaderboard(); + await loadMemberships(); } catch (submitError) { setGrantError(String(submitError)); } finally { setGrantLoading(false); } - }, [grantEmail, grantPoints, loadLeaderboard, loadUsers, searchQuery]); + }, [grantEmail, grantPoints, loadLeaderboard, loadMemberships, loadUsers, searchQuery]); return (
@@ -400,6 +429,52 @@ export function OpsDashboard() {
+ + + 当前会员 + 当前有效订阅用户、注册时间和到期时间。 + + +
+
+ 当前有效会员数:{memberships.length} +
+ +
+
+ + + + + + + + + + + {memberships.map((item) => ( + + + + + + + ))} + {!memberships.length ? ( + + + + ) : null} + +
邮箱用户名注册时间到期时间
{item.email || "-"}{item.username || "-"}{formatDateTime(item.registered_at)}{formatDateTime(item.expires_at)}
+ 暂无有效会员 +
+
+
+
+
diff --git a/frontend/lib/dashboard-official-sources.ts b/frontend/lib/dashboard-official-sources.ts new file mode 100644 index 00000000..ae958d6d --- /dev/null +++ b/frontend/lib/dashboard-official-sources.ts @@ -0,0 +1,402 @@ +import type { CityDetail } from "@/lib/dashboard-types"; + +export type OfficialSourceLink = { + label: string; + href: string; + kind: "agency" | "airport" | "metar"; +}; + +const CITY_SPECIFIC_SOURCES: Record = { + singapore: [ + { + label: "MSS 官方天气", + href: "https://www.weather.gov.sg/", + kind: "agency", + }, + { + label: "樟宜机场", + href: "https://www.changiairport.com/", + kind: "airport", + }, + { + label: "WSSS METAR", + href: "https://aviationweather.gov/data/metar/?id=WSSS&decoded=1&taf=1", + kind: "metar", + }, + ], + wellington: [ + { + label: "MetService", + href: "https://www.metservice.com/", + kind: "agency", + }, + { + label: "Wellington Airport", + href: "https://www.wellingtonairport.co.nz/", + kind: "airport", + }, + { + label: "NZWN METAR", + href: "https://aviationweather.gov/data/metar/?id=NZWN&decoded=1&taf=1", + kind: "metar", + }, + ], + "hong kong": [ + { + label: "香港天文台", + href: "https://www.hko.gov.hk/en/index.html", + kind: "agency", + }, + { + label: "香港国际机场", + href: "https://www.hongkongairport.com/", + kind: "airport", + }, + { + label: "VHHH METAR", + href: "https://aviationweather.gov/data/metar/?id=VHHH&decoded=1&taf=1", + kind: "metar", + }, + ], + taipei: [ + { + label: "CWA 中央气象署", + href: "https://www.cwa.gov.tw/V8/E/", + kind: "agency", + }, + { + label: "桃园机场", + href: "https://www.taoyuan-airport.com/", + kind: "airport", + }, + { + label: "RCSS METAR", + href: "https://aviationweather.gov/data/metar/?id=RCSS&decoded=1&taf=1", + kind: "metar", + }, + ], + london: [ + { + label: "Met Office", + href: "https://www.metoffice.gov.uk/", + kind: "agency", + }, + { + label: "EGLC METAR", + href: "https://aviationweather.gov/data/metar/?id=EGLC&decoded=1&taf=1", + kind: "metar", + }, + ], + "new york": [ + { + label: "NWS", + href: "https://www.weather.gov/", + kind: "agency", + }, + { + label: "KLGA METAR", + href: "https://aviationweather.gov/data/metar/?id=KLGA&decoded=1&taf=1", + kind: "metar", + }, + ], + ankara: [ + { + label: "MGM", + href: "https://www.mgm.gov.tr/", + kind: "agency", + }, + { + label: "LTAC METAR", + href: "https://aviationweather.gov/data/metar/?id=LTAC&decoded=1&taf=1", + kind: "metar", + }, + ], + paris: [ + { + label: "Météo-France", + href: "https://meteofrance.com/", + kind: "agency", + }, + { + label: "LFPG METAR", + href: "https://aviationweather.gov/data/metar/?id=LFPG&decoded=1&taf=1", + kind: "metar", + }, + ], + seoul: [ + { + label: "KMA", + href: "https://www.weather.go.kr/", + kind: "agency", + }, + { + label: "RKSI METAR", + href: "https://aviationweather.gov/data/metar/?id=RKSI&decoded=1&taf=1", + kind: "metar", + }, + ], + shanghai: [ + { + label: "中国天气网", + href: "https://www.weather.com.cn/", + kind: "agency", + }, + { + label: "ZSPD METAR", + href: "https://aviationweather.gov/data/metar/?id=ZSPD&decoded=1&taf=1", + kind: "metar", + }, + ], + tokyo: [ + { + label: "JMA", + href: "https://www.jma.go.jp/jma/indexe.html", + kind: "agency", + }, + { + label: "RJTT METAR", + href: "https://aviationweather.gov/data/metar/?id=RJTT&decoded=1&taf=1", + kind: "metar", + }, + ], + "tel aviv": [ + { + label: "IMS", + href: "https://ims.gov.il/en", + kind: "agency", + }, + { + label: "LLBG METAR", + href: "https://aviationweather.gov/data/metar/?id=LLBG&decoded=1&taf=1", + kind: "metar", + }, + ], + toronto: [ + { + label: "Environment Canada", + href: "https://weather.gc.ca/", + kind: "agency", + }, + { + label: "CYYZ METAR", + href: "https://aviationweather.gov/data/metar/?id=CYYZ&decoded=1&taf=1", + kind: "metar", + }, + ], + "buenos aires": [ + { + label: "SMN", + href: "https://www.smn.gob.ar/", + kind: "agency", + }, + { + label: "SAEZ METAR", + href: "https://aviationweather.gov/data/metar/?id=SAEZ&decoded=1&taf=1", + kind: "metar", + }, + ], + chicago: [ + { + label: "NWS Chicago", + href: "https://www.weather.gov/lot/", + kind: "agency", + }, + { + label: "KORD METAR", + href: "https://aviationweather.gov/data/metar/?id=KORD&decoded=1&taf=1", + kind: "metar", + }, + ], + dallas: [ + { + label: "NWS Fort Worth", + href: "https://www.weather.gov/fwd/", + kind: "agency", + }, + { + label: "KDAL METAR", + href: "https://aviationweather.gov/data/metar/?id=KDAL&decoded=1&taf=1", + kind: "metar", + }, + ], + miami: [ + { + label: "NWS Miami", + href: "https://www.weather.gov/mfl/", + kind: "agency", + }, + { + label: "KMIA METAR", + href: "https://aviationweather.gov/data/metar/?id=KMIA&decoded=1&taf=1", + kind: "metar", + }, + ], + atlanta: [ + { + label: "NWS Peachtree City", + href: "https://www.weather.gov/ffc/", + kind: "agency", + }, + { + label: "KATL METAR", + href: "https://aviationweather.gov/data/metar/?id=KATL&decoded=1&taf=1", + kind: "metar", + }, + ], + seattle: [ + { + label: "NWS Seattle", + href: "https://www.weather.gov/sew/", + kind: "agency", + }, + { + label: "KSEA METAR", + href: "https://aviationweather.gov/data/metar/?id=KSEA&decoded=1&taf=1", + kind: "metar", + }, + ], + lucknow: [ + { + label: "IMD", + href: "https://mausam.imd.gov.in/", + kind: "agency", + }, + { + label: "VILK METAR", + href: "https://aviationweather.gov/data/metar/?id=VILK&decoded=1&taf=1", + kind: "metar", + }, + ], + "sao paulo": [ + { + label: "INMET", + href: "https://portal.inmet.gov.br/", + kind: "agency", + }, + { + label: "SBGR METAR", + href: "https://aviationweather.gov/data/metar/?id=SBGR&decoded=1&taf=1", + kind: "metar", + }, + ], + munich: [ + { + label: "DWD", + href: "https://www.dwd.de/EN/Home/home_node.html", + kind: "agency", + }, + { + label: "EDDM METAR", + href: "https://aviationweather.gov/data/metar/?id=EDDM&decoded=1&taf=1", + kind: "metar", + }, + ], + milan: [ + { + label: "MeteoAM", + href: "https://www.meteoam.it/en/home", + kind: "agency", + }, + { + label: "LIMC METAR", + href: "https://aviationweather.gov/data/metar/?id=LIMC&decoded=1&taf=1", + kind: "metar", + }, + ], + warsaw: [ + { + label: "IMGW", + href: "https://meteo.imgw.pl/", + kind: "agency", + }, + { + label: "EPWA METAR", + href: "https://aviationweather.gov/data/metar/?id=EPWA&decoded=1&taf=1", + kind: "metar", + }, + ], + madrid: [ + { + label: "AEMET", + href: "https://www.aemet.es/en/portada", + kind: "agency", + }, + { + label: "LEMD METAR", + href: "https://aviationweather.gov/data/metar/?id=LEMD&decoded=1&taf=1", + kind: "metar", + }, + ], + chengdu: [ + { + label: "中国天气网", + href: "https://www.weather.com.cn/", + kind: "agency", + }, + { + label: "ZUUU METAR", + href: "https://aviationweather.gov/data/metar/?id=ZUUU&decoded=1&taf=1", + kind: "metar", + }, + ], + chongqing: [ + { + label: "中国天气网", + href: "https://www.weather.com.cn/", + kind: "agency", + }, + { + label: "ZUCK METAR", + href: "https://aviationweather.gov/data/metar/?id=ZUCK&decoded=1&taf=1", + kind: "metar", + }, + ], + shenzhen: [ + { + label: "中国天气网", + href: "https://www.weather.com.cn/", + kind: "agency", + }, + { + label: "ZGSZ METAR", + href: "https://aviationweather.gov/data/metar/?id=ZGSZ&decoded=1&taf=1", + kind: "metar", + }, + ], + beijing: [ + { + label: "中国天气网", + href: "https://www.weather.com.cn/", + kind: "agency", + }, + { + label: "ZBAA METAR", + href: "https://aviationweather.gov/data/metar/?id=ZBAA&decoded=1&taf=1", + kind: "metar", + }, + ], + wuhan: [ + { + label: "中国天气网", + href: "https://www.weather.com.cn/", + kind: "agency", + }, + { + label: "ZHHH METAR", + href: "https://aviationweather.gov/data/metar/?id=ZHHH&decoded=1&taf=1", + kind: "metar", + }, + ], +}; + +export function getOfficialSourceLinks(detail: CityDetail): OfficialSourceLink[] { + const cityKey = String(detail.name || "").trim().toLowerCase(); + const links = [...(CITY_SPECIFIC_SOURCES[cityKey] || [])]; + const seen = new Set(); + return links.filter((link) => { + const key = `${link.label}|${link.href}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/src/auth/supabase_entitlement.py b/src/auth/supabase_entitlement.py index 0de1fac3..7b4463c1 100644 --- a/src/auth/supabase_entitlement.py +++ b/src/auth/supabase_entitlement.py @@ -5,7 +5,7 @@ import threading import time from dataclasses import dataclass from datetime import datetime, timezone -from typing import Dict, Optional +from typing import Dict, List, Optional import requests from loguru import logger @@ -231,5 +231,39 @@ class SupabaseEntitlementService: return True return self._query_active_subscription(user_id) + def list_active_subscriptions(self, limit: int = 200) -> List[Dict[str, object]]: + if not self.service_role_key: + logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing") + return [] + try: + safe_limit = max(1, min(int(limit or 200), 1000)) + now_iso = datetime.now(timezone.utc).isoformat() + params = { + "select": "id,user_id,status,plan_code,starts_at,expires_at", + "status": "eq.active", + "expires_at": f"gt.{now_iso}", + "order": "expires_at.asc", + "limit": str(safe_limit), + } + response = requests.get( + self._subscription_endpoint(), + headers=self._request_headers_for_service_role(), + params=params, + timeout=self.timeout_sec, + ) + if response.status_code != 200: + logger.warning( + "supabase active subscriptions query failed status={}", + response.status_code, + ) + return [] + data = response.json() if response.content else [] + if not isinstance(data, list): + return [] + return [row for row in data if isinstance(row, dict)] + except Exception as exc: + logger.warning(f"supabase active subscriptions query error: {exc}") + return [] + SUPABASE_ENTITLEMENT = SupabaseEntitlementService() diff --git a/src/database/db_manager.py b/src/database/db_manager.py index 9a1f63da..60a9f0c2 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -406,6 +406,42 @@ class DBManager: ).fetchall() return [dict(row) for row in rows] + def get_users_by_supabase_user_ids( + self, + supabase_user_ids: List[str], + ) -> Dict[str, Dict[str, Any]]: + keys = [ + str(item or "").strip().lower() + for item in (supabase_user_ids or []) + if str(item or "").strip() + ] + if not keys: + return {} + placeholders = ",".join("?" for _ in keys) + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + f""" + SELECT + lower(trim(COALESCE(supabase_user_id, ''))) AS supabase_user_id, + telegram_id, + username, + supabase_email, + created_at, + points, + weekly_points, + message_count + FROM users + WHERE lower(trim(COALESCE(supabase_user_id, ''))) IN ({placeholders}) + """, + tuple(keys), + ).fetchall() + return { + str(row["supabase_user_id"] or "").strip().lower(): dict(row) + for row in rows + if str(row["supabase_user_id"] or "").strip() + } + def get_points_by_supabase_user_id(self, supabase_user_id: str) -> int: user = self.get_user_by_supabase_user_id(supabase_user_id) if not user: diff --git a/web/routes.py b/web/routes.py index cf28f475..ee76358e 100644 --- a/web/routes.py +++ b/web/routes.py @@ -248,6 +248,36 @@ async def ops_weekly_leaderboard(request: Request, limit: int = 20): return {"leaderboard": db.get_weekly_leaderboard(limit=limit)} +@router.get("/api/ops/memberships") +async def ops_memberships(request: Request, limit: int = 200): + _assert_entitlement(request) + _require_ops_admin(request) + from src.database.db_manager import DBManager + + db = DBManager() + subscriptions = SUPABASE_ENTITLEMENT.list_active_subscriptions(limit=limit) + user_map = db.get_users_by_supabase_user_ids( + [str(item.get("user_id") or "") for item in subscriptions] + ) + rows = [] + for item in subscriptions: + user_id = str(item.get("user_id") or "").strip().lower() + local_user = user_map.get(user_id, {}) + rows.append( + { + "user_id": user_id, + "email": str(local_user.get("supabase_email") or ""), + "telegram_id": local_user.get("telegram_id"), + "username": local_user.get("username"), + "registered_at": local_user.get("created_at"), + "plan_code": item.get("plan_code"), + "starts_at": item.get("starts_at"), + "expires_at": item.get("expires_at"), + } + ) + return {"memberships": rows} + + @router.post("/api/ops/users/grant-points") async def ops_grant_points(request: Request, body: GrantPointsRequest): _assert_entitlement(request)