Add ops membership list and official source links
This commit is contained in:
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2260,6 +2260,58 @@
|
|||||||
padding: 12px;
|
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) {
|
.root :global(.detail-label) {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ForecastTable } from "@/components/dashboard/PanelSections";
|
|||||||
import { useChart } from "@/hooks/useChart";
|
import { useChart } from "@/hooks/useChart";
|
||||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
|
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
|
||||||
import { getCityScenery } from "@/lib/dashboard-scenery";
|
import { getCityScenery } from "@/lib/dashboard-scenery";
|
||||||
import { CityDetail } from "@/lib/dashboard-types";
|
import { CityDetail } from "@/lib/dashboard-types";
|
||||||
import {
|
import {
|
||||||
@@ -147,6 +148,10 @@ export function DetailPanel() {
|
|||||||
() => (detail ? getCityProfileStats(detail, locale) : []),
|
() => (detail ? getCityProfileStats(detail, locale) : []),
|
||||||
[detail, locale],
|
[detail, locale],
|
||||||
);
|
);
|
||||||
|
const officialLinks = useMemo(
|
||||||
|
() => (detail ? getOfficialSourceLinks(detail) : []),
|
||||||
|
[detail],
|
||||||
|
);
|
||||||
const scenery = getCityScenery(detail?.name);
|
const scenery = getCityScenery(detail?.name);
|
||||||
const blurActiveElement = () => {
|
const blurActiveElement = () => {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
@@ -336,6 +341,33 @@ export function DetailPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{officialLinks.length > 0 ? (
|
||||||
|
<section className="detail-section">
|
||||||
|
<h3>{locale === "en-US" ? "Official Sources" : "官方参考"}</h3>
|
||||||
|
<p className="detail-source-note">
|
||||||
|
{locale === "en-US"
|
||||||
|
? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page."
|
||||||
|
: "AGENCY = 国家气象机构,METAR = 机场实测报文,AIRPORT = 机场官网页面。"}
|
||||||
|
</p>
|
||||||
|
<div className="detail-source-list">
|
||||||
|
{officialLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={`${link.label}-${link.href}`}
|
||||||
|
className="detail-source-link"
|
||||||
|
href={link.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<span className="detail-source-kind">
|
||||||
|
{link.kind.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="detail-source-label">{link.label}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="detail-section rounded-2xl">
|
<section className="detail-section rounded-2xl">
|
||||||
<h3>{t("detail.todayMiniTrend")}</h3>
|
<h3>{t("detail.todayMiniTrend")}</h3>
|
||||||
{heavyContentReady ? (
|
{heavyContentReady ? (
|
||||||
|
|||||||
@@ -86,6 +86,17 @@ type WeeklyLeaderboardEntry = {
|
|||||||
weekly_points?: number;
|
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) {
|
function formatDateTime(value?: string | null) {
|
||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@@ -118,9 +129,11 @@ export function OpsDashboard() {
|
|||||||
const [refreshedAt, setRefreshedAt] = useState<string | null>(null);
|
const [refreshedAt, setRefreshedAt] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [users, setUsers] = useState<OpsUser[]>([]);
|
const [users, setUsers] = useState<OpsUser[]>([]);
|
||||||
|
const [memberships, setMemberships] = useState<MembershipEntry[]>([]);
|
||||||
const [usersLoading, setUsersLoading] = useState(false);
|
const [usersLoading, setUsersLoading] = useState(false);
|
||||||
const [usersError, setUsersError] = useState<string | null>(null);
|
const [usersError, setUsersError] = useState<string | null>(null);
|
||||||
const [leaderboard, setLeaderboard] = useState<WeeklyLeaderboardEntry[]>([]);
|
const [leaderboard, setLeaderboard] = useState<WeeklyLeaderboardEntry[]>([]);
|
||||||
|
const [membershipsLoading, setMembershipsLoading] = useState(false);
|
||||||
const [grantEmail, setGrantEmail] = useState("");
|
const [grantEmail, setGrantEmail] = useState("");
|
||||||
const [grantPoints, setGrantPoints] = useState("300");
|
const [grantPoints, setGrantPoints] = useState("300");
|
||||||
const [grantStatus, setGrantStatus] = useState<string | null>(null);
|
const [grantStatus, setGrantStatus] = useState<string | null>(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(() => {
|
useEffect(() => {
|
||||||
void loadUsers("");
|
void loadUsers("");
|
||||||
void loadLeaderboard();
|
void loadLeaderboard();
|
||||||
}, [loadLeaderboard, loadUsers]);
|
void loadMemberships();
|
||||||
|
}, [loadLeaderboard, loadMemberships, loadUsers]);
|
||||||
|
|
||||||
const rolloutDecision = status?.probability?.rollout?.decision;
|
const rolloutDecision = status?.probability?.rollout?.decision;
|
||||||
|
|
||||||
@@ -227,12 +255,13 @@ export function OpsDashboard() {
|
|||||||
);
|
);
|
||||||
await loadUsers(searchQuery);
|
await loadUsers(searchQuery);
|
||||||
await loadLeaderboard();
|
await loadLeaderboard();
|
||||||
|
await loadMemberships();
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setGrantError(String(submitError));
|
setGrantError(String(submitError));
|
||||||
} finally {
|
} finally {
|
||||||
setGrantLoading(false);
|
setGrantLoading(false);
|
||||||
}
|
}
|
||||||
}, [grantEmail, grantPoints, loadLeaderboard, loadUsers, searchQuery]);
|
}, [grantEmail, grantPoints, loadLeaderboard, loadMemberships, loadUsers, searchQuery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-slate-950 px-4 py-8 text-slate-100 sm:px-6 lg:px-8">
|
<main className="min-h-screen bg-slate-950 px-4 py-8 text-slate-100 sm:px-6 lg:px-8">
|
||||||
@@ -400,6 +429,52 @@ export function OpsDashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>当前会员</CardTitle>
|
||||||
|
<CardDescription>当前有效订阅用户、注册时间和到期时间。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 text-sm text-slate-300">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
当前有效会员数:{memberships.length}
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={() => void loadMemberships()} disabled={membershipsLoading}>
|
||||||
|
{membershipsLoading ? "加载中" : "刷新会员"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/70">
|
||||||
|
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
|
||||||
|
<thead className="bg-slate-900/80 text-xs uppercase tracking-[0.14em] text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">邮箱</th>
|
||||||
|
<th className="px-4 py-3">用户名</th>
|
||||||
|
<th className="px-4 py-3">注册时间</th>
|
||||||
|
<th className="px-4 py-3">到期时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{memberships.map((item) => (
|
||||||
|
<tr key={`${item.user_id}-${item.expires_at || ""}`}>
|
||||||
|
<td className="px-4 py-3">{item.email || "-"}</td>
|
||||||
|
<td className="px-4 py-3">{item.username || "-"}</td>
|
||||||
|
<td className="px-4 py-3">{formatDateTime(item.registered_at)}</td>
|
||||||
|
<td className="px-4 py-3">{formatDateTime(item.expires_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!memberships.length ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-4 text-slate-500" colSpan={4}>
|
||||||
|
暂无有效会员
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<section className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
|
<section className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -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<string, OfficialSourceLink[]> = {
|
||||||
|
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<string>();
|
||||||
|
return links.filter((link) => {
|
||||||
|
const key = `${link.label}|${link.href}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -231,5 +231,39 @@ class SupabaseEntitlementService:
|
|||||||
return True
|
return True
|
||||||
return self._query_active_subscription(user_id)
|
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()
|
SUPABASE_ENTITLEMENT = SupabaseEntitlementService()
|
||||||
|
|||||||
@@ -406,6 +406,42 @@ class DBManager:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(row) for row in rows]
|
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:
|
def get_points_by_supabase_user_id(self, supabase_user_id: str) -> int:
|
||||||
user = self.get_user_by_supabase_user_id(supabase_user_id)
|
user = self.get_user_by_supabase_user_id(supabase_user_id)
|
||||||
if not user:
|
if not user:
|
||||||
|
|||||||
@@ -248,6 +248,36 @@ async def ops_weekly_leaderboard(request: Request, limit: int = 20):
|
|||||||
return {"leaderboard": db.get_weekly_leaderboard(limit=limit)}
|
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")
|
@router.post("/api/ops/users/grant-points")
|
||||||
async def ops_grant_points(request: Request, body: GrantPointsRequest):
|
async def ops_grant_points(request: Request, body: GrantPointsRequest):
|
||||||
_assert_entitlement(request)
|
_assert_entitlement(request)
|
||||||
|
|||||||
Reference in New Issue
Block a user