From 67701a47150362d92b2562c121c1a44b6274d4fa Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 18 May 2026 20:18:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=AE=A1=E7=90=86=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=90=8E=E7=AB=AF=20API=EF=BC=9A=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=BC=96=E8=BE=91=E3=80=81=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E7=AE=A1=E7=90=86=E3=80=81=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=EF=BC=88logs=20=E7=9B=AE=E5=BD=95=E9=81=BF?= =?UTF-8?q?=E5=BC=80=20gitignore=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/api/ops/config/route.ts | 29 +++ .../app/api/ops/subscriptions/extend/route.ts | 19 ++ .../app/api/ops/subscriptions/grant/route.ts | 19 ++ frontend/app/api/ops/view-logs/route.ts | 21 ++ frontend/app/ops/view-logs/page.tsx | 10 + .../components/ops/layout/AdminSidebar.tsx | 2 +- .../ops/view-logs/LogsPageClient.tsx | 120 +++++++++++ web/routers/ops.py | 59 +++++ web/services/ops_api.py | 203 ++++++++++++++++++ 9 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 frontend/app/api/ops/config/route.ts create mode 100644 frontend/app/api/ops/subscriptions/extend/route.ts create mode 100644 frontend/app/api/ops/subscriptions/grant/route.ts create mode 100644 frontend/app/api/ops/view-logs/route.ts create mode 100644 frontend/app/ops/view-logs/page.tsx create mode 100644 frontend/components/ops/view-logs/LogsPageClient.tsx diff --git a/frontend/app/api/ops/config/route.ts b/frontend/app/api/ops/config/route.ts new file mode 100644 index 00000000..f0113f58 --- /dev/null +++ b/frontend/app/api/ops/config/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; +const BACKEND = API_BASE ? `${API_BASE}/api/ops/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 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: "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 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: "Config update failed" }); } +} diff --git a/frontend/app/api/ops/subscriptions/extend/route.ts b/frontend/app/api/ops/subscriptions/extend/route.ts new file mode 100644 index 00000000..d3d59045 --- /dev/null +++ b/frontend/app/api/ops/subscriptions/extend/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } 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: "API_BASE not configured" }, { status: 500 }); + try { + const auth = await buildBackendRequestHeaders(req); + const body = await req.text(); + const res = await fetch(`${API_BASE}/api/ops/subscriptions/extend`, { + method: "POST", 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: "Subscription extend failed" }); } +} diff --git a/frontend/app/api/ops/subscriptions/grant/route.ts b/frontend/app/api/ops/subscriptions/grant/route.ts new file mode 100644 index 00000000..a580f7d0 --- /dev/null +++ b/frontend/app/api/ops/subscriptions/grant/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } 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: "API_BASE not configured" }, { status: 500 }); + try { + const auth = await buildBackendRequestHeaders(req); + const body = await req.text(); + const res = await fetch(`${API_BASE}/api/ops/subscriptions/grant`, { + method: "POST", 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: "Subscription grant failed" }); } +} diff --git a/frontend/app/api/ops/view-logs/route.ts b/frontend/app/api/ops/view-logs/route.ts new file mode 100644 index 00000000..41528525 --- /dev/null +++ b/frontend/app/api/ops/view-logs/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } 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: "API_BASE not configured" }, { status: 500 }); + try { + const auth = await buildBackendRequestHeaders(req); + const url = new URL(`${API_BASE}/api/ops/logs`); + const level = req.nextUrl.searchParams.get("level"); + const lines = req.nextUrl.searchParams.get("lines"); + if (level) url.searchParams.set("level", level); + if (lines) url.searchParams.set("lines", lines); + 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": "application/json", "Cache-Control": "no-store" } }); + return applyAuthResponseCookies(response, auth.response); + } catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Log fetch failed" }); } +} diff --git a/frontend/app/ops/view-logs/page.tsx b/frontend/app/ops/view-logs/page.tsx new file mode 100644 index 00000000..de303a7c --- /dev/null +++ b/frontend/app/ops/view-logs/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { requireOpsAdmin } from "@/lib/ops-admin"; +import { LogsPageClient } from "@/components/ops/view-logs/LogsPageClient"; + +export const metadata: Metadata = { title: "日志查看 — PolyWeather Ops" }; + +export default async function LogsPage() { + await requireOpsAdmin("/ops/view-logs"); + return ; +} diff --git a/frontend/components/ops/layout/AdminSidebar.tsx b/frontend/components/ops/layout/AdminSidebar.tsx index 2024050f..de63a656 100644 --- a/frontend/components/ops/layout/AdminSidebar.tsx +++ b/frontend/components/ops/layout/AdminSidebar.tsx @@ -38,7 +38,7 @@ const navGroups = [ items: [ { href: "/ops/config", icon: Settings, label: "系统配置" }, { href: "/ops/subscriptions", icon: ScrollText, label: "订阅操作" }, - { href: "/ops/logs", icon: FileText, label: "日志查看" }, + { href: "/ops/view-logs", icon: FileText, label: "日志查看" }, ], }, { diff --git a/frontend/components/ops/view-logs/LogsPageClient.tsx b/frontend/components/ops/view-logs/LogsPageClient.tsx new file mode 100644 index 00000000..1dd09a4c --- /dev/null +++ b/frontend/components/ops/view-logs/LogsPageClient.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { RefreshCcw, Pause, Play } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +export function LogsPageClient() { + const [lines, setLines] = useState([]); + const [level, setLevel] = useState(""); + const [limit, setLimit] = useState(100); + const [autoRefresh, setAutoRefresh] = useState(false); + const [error, setError] = useState(""); + const timerRef = useRef | null>(null); + + const load = useCallback(async () => { + try { + const params = new URLSearchParams({ lines: String(limit) }); + if (level) params.set("level", level); + const res = await fetch(`/api/ops/view-logs?${params}`); + if (res.ok) { + const data = (await res.json()) as { lines?: string[] }; + setLines(data.lines ?? []); + setError(""); + } else { + setError(await res.text().catch(() => "fetch failed")); + } + } catch { + setError("日志 API 尚未就绪(需要后端支持)"); + } + }, [level, limit]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (autoRefresh) { + timerRef.current = setInterval(load, 5000); + } else { + if (timerRef.current) clearInterval(timerRef.current); + } + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [autoRefresh, load]); + + const levelColor = (line: string) => { + if (line.includes("ERROR")) return "text-red-400"; + if (line.includes("WARNING")) return "text-amber-400"; + if (line.includes("INFO")) return "text-slate-300"; + return "text-slate-500"; + }; + + return ( +
+
+

日志查看

+
+ + + + +
+
+ + + + {error ? ( +
{error}
+ ) : ( +
+
+ {lines.map((line, i) => ( +
+ {line} +
+ ))} + {lines.length === 0 && ( +
暂无日志
+ )} +
+
+ )} +
+
+
+ ); +} diff --git a/web/routers/ops.py b/web/routers/ops.py index 40f40d99..d1e680d1 100644 --- a/web/routers/ops.py +++ b/web/routers/ops.py @@ -4,14 +4,19 @@ from fastapi import APIRouter, Request from web.core import GrantPointsRequest from web.services.ops_api import ( + extend_ops_subscription, get_ops_analytics_funnel, + get_ops_config, + get_ops_logs, get_ops_truth_history, get_ops_weekly_leaderboard, grant_ops_points, + grant_ops_subscription, list_ops_memberships, list_ops_payment_incidents, resolve_ops_payment_incident, search_ops_users, + update_ops_config, ) router = APIRouter(tags=["ops"]) @@ -77,3 +82,57 @@ async def ops_truth_history( date_to=date_to, limit=limit, ) + + +# ── Config ────────────────────────────────────────────────────────── + +@router.get("/api/ops/config") +async def ops_config(request: Request): + return get_ops_config(request) + + +@router.put("/api/ops/config") +async def ops_update_config(request: Request): + import json as _json + body_bytes = await request.body() + body = _json.loads(body_bytes.decode("utf-8")) + key = str(body.get("key") or "").strip() + value = str(body.get("value") or "") + if not key: + from fastapi import HTTPException + raise HTTPException(status_code=400, detail="key is required") + return update_ops_config(request, key, value) + + +# ── Subscriptions ─────────────────────────────────────────────────── + +@router.post("/api/ops/subscriptions/grant") +async def ops_subscription_grant(request: Request): + import json as _json + body_bytes = await request.body() + body = _json.loads(body_bytes.decode("utf-8")) + email = str(body.get("email") or "").strip() + plan_code = str(body.get("plan_code") or "pro_monthly").strip() + days = int(body.get("days") or 30) + return grant_ops_subscription(request, email=email, plan_code=plan_code, days=days) + + +@router.post("/api/ops/subscriptions/extend") +async def ops_subscription_extend(request: Request): + import json as _json + body_bytes = await request.body() + body = _json.loads(body_bytes.decode("utf-8")) + email = str(body.get("email") or "").strip() + days = int(body.get("additional_days") or 30) + return extend_ops_subscription(request, email=email, additional_days=days) + + +# ── Logs ──────────────────────────────────────────────────────────── + +@router.get("/api/ops/logs") +async def ops_logs( + request: Request, + level: str = "", + lines: int = 100, +): + return get_ops_logs(request, level=level, lines=lines) diff --git a/web/services/ops_api.py b/web/services/ops_api.py index 94033c9e..f5efb511 100644 --- a/web/services/ops_api.py +++ b/web/services/ops_api.py @@ -226,3 +226,206 @@ def get_ops_truth_history( }, "filtered_count": filtered_count, } + + +# ── Config ────────────────────────────────────────────────────────── + +_EDITABLE_CONFIG_KEYS: dict[str, str] = { + "POLYWEATHER_AUTH_REQUIRED": "是否强制要求 Supabase 登录访问 API", + "POLYWEATHER_PAYMENT_ENABLED": "是否启用支付功能", + "POLYWEATHER_PAYMENT_POINTS_ENABLED": "是否启用积分抵扣", + "POLYWEATHER_TELEGRAM_ALERT_PUSH_ENABLED": "是否启用 Telegram 告警推送", + "POLYWEATHER_GROUP_MEMBER_PRICE_USDC": "群成员月费 (USDC)", + "POLYWEATHER_PUBLIC_PRICE_USDC": "公开月费 (USDC)", + "POLYWEATHER_PAYMENT_POINTS_PER_USDC": "积分兑换汇率 (积分/USDC)", + "POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC": "积分最高抵扣金额 (USDC)", +} + + +def get_ops_config(request: Request) -> dict[str, Any]: + _require_ops(request) + import os + configs: list[dict[str, str]] = [] + for key, desc in _EDITABLE_CONFIG_KEYS.items(): + configs.append({ + "key": key, + "value": os.getenv(key) or "", + "description": desc, + }) + return {"configs": configs} + + +def update_ops_config(request: Request, key: str, value: str) -> dict[str, Any]: + _require_ops(request) + import os + if key not in _EDITABLE_CONFIG_KEYS: + raise HTTPException(status_code=400, detail=f"config key '{key}' is not editable") + os.environ[key] = str(value) + return {"key": key, "value": value, "ok": True} + + +# ── Subscriptions ─────────────────────────────────────────────────── + +def grant_ops_subscription( + request: Request, + email: str, + plan_code: str = "pro_monthly", + days: int = 30, +) -> dict[str, Any]: + _require_ops(request) + import os + from datetime import datetime, timedelta + import requests as _requests + + supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") + service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip() + if not supabase_url or not service_role_key: + raise HTTPException(status_code=503, detail="Supabase not configured") + + allowed_plans = {"pro_monthly", "pro_quarterly", "pro_yearly"} + if plan_code not in allowed_plans: + raise HTTPException(status_code=400, detail=f"invalid plan_code, allowed: {allowed_plans}") + + safe_days = max(1, min(365, int(days or 30))) + normalized_email = str(email or "").strip().lower() + if not normalized_email: + raise HTTPException(status_code=400, detail="email is required") + + headers = { + "apikey": service_role_key, + "Authorization": f"Bearer {service_role_key}", + "Content-Type": "application/json", + "Prefer": "return=representation", + } + + # Look up user by email + user_resp = _requests.get( + f"{supabase_url}/auth/v1/admin/users", + headers=headers, + params={"filter": f"email.eq.{normalized_email}"}, + timeout=10, + ) + users = user_resp.json().get("users", []) if user_resp.ok else [] + user_id = users[0].get("id") if users else None + if not user_id: + raise HTTPException(status_code=404, detail=f"user not found: {normalized_email}") + + now = datetime.utcnow() + starts_at = now.isoformat() + "Z" + expires_at = (now + timedelta(days=safe_days)).isoformat() + "Z" + + payload = { + "user_id": user_id, + "email": normalized_email, + "plan_code": plan_code, + "starts_at": starts_at, + "expires_at": expires_at, + "source": "ops_manual_grant", + "created_at": now.isoformat() + "Z", + } + + resp = _requests.post( + f"{supabase_url}/rest/v1/subscriptions", + headers=headers, + json=payload, + timeout=10, + ) + if resp.ok: + return {"ok": True, "user_id": user_id, "plan_code": plan_code, "days": safe_days, "expires_at": expires_at} + raise HTTPException(status_code=500, detail=f"Supabase insert failed: {resp.text[:200]}") + + +def extend_ops_subscription( + request: Request, + email: str, + additional_days: int = 30, +) -> dict[str, Any]: + _require_ops(request) + import os + from datetime import datetime, timedelta + import requests as _requests + + supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") + service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip() + if not supabase_url or not service_role_key: + raise HTTPException(status_code=503, detail="Supabase not configured") + + safe_days = max(1, min(365, int(additional_days or 30))) + normalized_email = str(email or "").strip().lower() + if not normalized_email: + raise HTTPException(status_code=400, detail="email is required") + + headers = { + "apikey": service_role_key, + "Authorization": f"Bearer {service_role_key}", + "Content-Type": "application/json", + "Prefer": "return=representation", + } + + # Find latest active subscription + subs_resp = _requests.get( + f"{supabase_url}/rest/v1/subscriptions", + headers=headers, + params={ + "select": "*", + "email": f"eq.{normalized_email}", + "order": "expires_at.desc", + "limit": "1", + }, + timeout=10, + ) + subs = subs_resp.json() if subs_resp.ok else [] + if not subs: + raise HTTPException(status_code=404, detail=f"no subscription found for {normalized_email}") + + sub = subs[0] + current_expiry = sub.get("expires_at", "") + try: + dt = datetime.fromisoformat(current_expiry.replace("Z", "+00:00")) + new_expiry = (dt + timedelta(days=safe_days)).isoformat() + except Exception: + new_expiry = (datetime.utcnow() + timedelta(days=safe_days)).isoformat() + "Z" + + patch_resp = _requests.patch( + f"{supabase_url}/rest/v1/subscriptions?id=eq.{sub['id']}", + headers=headers, + json={"expires_at": new_expiry}, + timeout=10, + ) + if patch_resp.ok: + return {"ok": True, "email": normalized_email, "additional_days": safe_days, "new_expires_at": new_expiry} + raise HTTPException(status_code=500, detail=f"Supabase update failed: {patch_resp.text[:200]}") + + +# ── Logs ──────────────────────────────────────────────────────────── + +def get_ops_logs( + request: Request, + level: str = "", + lines: int = 100, +) -> dict[str, Any]: + _require_ops(request) + import subprocess + safe_lines = max(10, min(1000, int(lines or 100))) + try: + # Read from Docker logs + result = subprocess.run( + ["docker", "logs", "--tail", str(safe_lines), "polyweather_bot"], + capture_output=True, + text=True, + timeout=10, + ) + log_text = result.stdout or result.stderr or "" + except Exception: + log_text = "" + + log_lines = log_text.strip().split("\n") if log_text.strip() else [] + + if level: + level_upper = level.upper() + log_lines = [line for line in log_lines if level_upper in line.upper()] + + return { + "lines": log_lines[-safe_lines:], + "total": len(log_lines), + }