添加后台 Telegram 审计面板

This commit is contained in:
2569718930@qq.com
2026-05-20 17:12:32 +08:00
parent 66512d2623
commit 6f7e847b01
6 changed files with 554 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
import { TelegramAuditPageClient } from "@/components/ops/telegram-audit/TelegramAuditPageClient";
export default function TelegramAuditPage() {
return <TelegramAuditPageClient />;
}
@@ -14,6 +14,7 @@ import {
FileText,
ScrollText,
Activity,
ShieldAlert,
} from "lucide-react";
const navGroups = [
@@ -32,6 +33,7 @@ const navGroups = [
items: [
{ href: "/ops/payments", icon: CreditCard, label: "支付管理" },
{ href: "/ops/memberships", icon: UserCheck, label: "会员订阅" },
{ href: "/ops/telegram-audit", icon: ShieldAlert, label: "电报清理" },
{ href: "/ops/users", icon: Users, label: "用户积分" },
],
},
@@ -0,0 +1,351 @@
"use client";
import { useEffect, useState } from "react";
import { opsApi } from "@/lib/ops-api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
RefreshCcw,
ShieldAlert,
Copy,
Check,
Search,
UserX,
Mail,
Calendar,
AlertTriangle,
} from "lucide-react";
interface TelegramAnomaly {
telegram_id: number;
username: string;
chat_id: string;
status: string;
anomaly_type: "unbound" | "expired" | "trial_only";
reason: string;
email: string | null;
expires_at: string | null;
}
export function TelegramAuditPageClient() {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<{
anomalies: TelegramAnomaly[];
valid_count: number;
anomaly_count: number;
error?: string;
} | null>(null);
const [filterType, setFilterType] = useState<string>("all");
const [searchQuery, setSearchQuery] = useState("");
const [copiedId, setCopiedId] = useState<number | null>(null);
const loadData = async () => {
setLoading(true);
try {
const res = await opsApi.telegramAudit();
setData(res);
} catch (err: any) {
setData({
anomalies: [],
valid_count: 0,
anomaly_count: 0,
error: err.message || "获取审核数据失败",
});
}
setLoading(false);
};
useEffect(() => {
void loadData();
}, []);
const handleCopy = (id: number) => {
void navigator.clipboard.writeText(String(id));
setCopiedId(id);
setTimeout(() => setCopiedId(null), 1500);
};
if (loading) {
return (
<div className="flex h-[400px] flex-col items-center justify-center gap-2">
<RefreshCcw className="h-8 w-8 animate-spin text-cyan-400" />
<p className="text-sm text-slate-400">...</p>
</div>
);
}
const anomalies = data?.anomalies ?? [];
const error = data?.error;
const filtered = anomalies.filter((item) => {
const matchesFilter = filterType === "all" || item.anomaly_type === filterType;
const matchesSearch =
searchQuery.trim() === "" ||
String(item.telegram_id).includes(searchQuery) ||
(item.username && item.username.toLowerCase().includes(searchQuery.toLowerCase())) ||
(item.email && item.email.toLowerCase().includes(searchQuery.toLowerCase())) ||
(item.reason && item.reason.toLowerCase().includes(searchQuery.toLowerCase()));
return matchesFilter && matchesSearch;
});
const countUnbound = anomalies.filter((x) => x.anomaly_type === "unbound").length;
const countExpired = anomalies.filter((x) => x.anomaly_type === "expired").length;
const countTrial = anomalies.filter((x) => x.anomaly_type === "trial_only").length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-sm text-slate-400 mt-1">
Bot Telegram
</p>
</div>
<Button variant="outline" size="sm" onClick={loadData} className="gap-1.5 bg-slate-900 border-white/10 hover:bg-slate-800 text-slate-200">
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
{error && (
<Card className="border-red-500/20 bg-red-500/5">
<CardContent className="flex items-start gap-3 pt-6">
<AlertTriangle className="h-5 w-5 text-red-400 shrink-0 mt-0.5" />
<div>
<h4 className="font-semibold text-red-200"></h4>
<p className="text-sm text-red-300/80 mt-1">{error}</p>
</div>
</CardContent>
</Card>
)}
{/* Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card className="bg-slate-900/60 border-white/5">
<CardHeader className="pb-2">
<CardTitle className="text-xs font-semibold text-slate-400 uppercase"> ()</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-400">{data?.valid_count ?? 0} </div>
<p className="text-[11px] text-slate-500 mt-1"></p>
</CardContent>
</Card>
<Card className="bg-slate-900/60 border-white/5">
<CardHeader className="pb-2">
<CardTitle className="text-xs font-semibold text-slate-400 uppercase"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-400">{countUnbound} </div>
<p className="text-[11px] text-slate-500 mt-1"> TG </p>
</CardContent>
</Card>
<Card className="bg-slate-900/60 border-white/5">
<CardHeader className="pb-2">
<CardTitle className="text-xs font-semibold text-slate-400 uppercase"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-amber-500">{countExpired} </div>
<p className="text-[11px] text-slate-500 mt-1"></p>
</CardContent>
</Card>
<Card className="bg-slate-900/60 border-white/5">
<CardHeader className="pb-2">
<CardTitle className="text-xs font-semibold text-slate-400 uppercase"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-400">{countTrial} </div>
<p className="text-[11px] text-slate-500 mt-1"></p>
</CardContent>
</Card>
</div>
{/* Filter and Search Bar */}
<Card className="bg-slate-900/60 border-white/5">
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row gap-4 justify-between items-center">
{/* Filter Buttons */}
<div className="flex flex-wrap gap-2">
<Button
variant={filterType === "all" ? "default" : "outline"}
size="sm"
onClick={() => setFilterType("all")}
className="text-xs rounded-lg"
>
({anomalies.length})
</Button>
<Button
variant={filterType === "unbound" ? "default" : "outline"}
size="sm"
onClick={() => setFilterType("unbound")}
className="text-xs rounded-lg"
>
({countUnbound})
</Button>
<Button
variant={filterType === "expired" ? "default" : "outline"}
size="sm"
onClick={() => setFilterType("expired")}
className={`text-xs rounded-lg ${filterType === "expired" ? "bg-amber-500 hover:bg-amber-600 text-white" : ""}`}
>
({countExpired})
</Button>
<Button
variant={filterType === "trial_only" ? "default" : "outline"}
size="sm"
onClick={() => setFilterType("trial_only")}
className={`text-xs rounded-lg ${filterType === "trial_only" ? "bg-yellow-500 hover:bg-yellow-600 text-slate-900" : ""}`}
>
({countTrial})
</Button>
</div>
{/* Search Input */}
<div className="relative w-full md:w-72">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<Input
placeholder="搜索用户名/电报ID/邮箱..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-950 border-white/10 text-xs rounded-lg text-slate-100 placeholder:text-slate-500"
/>
</div>
</div>
</CardContent>
</Card>
{/* Main Table */}
<Card className="bg-slate-900/60 border-white/5">
<CardHeader className="pb-3 border-b border-white/5">
<CardTitle className="text-sm font-semibold text-slate-300"></CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left text-slate-300">
<thead className="text-xs uppercase bg-slate-950/40 text-slate-400">
<tr>
<th scope="col" className="px-6 py-3.5"> & ID</th>
<th scope="col" className="px-6 py-3.5 text-center"></th>
<th scope="col" className="px-6 py-3.5"></th>
<th scope="col" className="px-6 py-3.5"></th>
<th scope="col" className="px-6 py-3.5"></th>
<th scope="col" className="px-6 py-3.5 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filtered.length > 0 ? (
filtered.map((row) => (
<tr key={`${row.telegram_id}-${row.chat_id}`} className="hover:bg-white/5 transition-colors">
<td className="px-6 py-4">
<div className="font-semibold text-white flex items-center gap-1.5">
{row.username.startsWith("@") ? (
<a
href={`https://t.me/${row.username.substring(1)}`}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:underline"
>
{row.username}
</a>
) : (
<span>{row.username}</span>
)}
</div>
<div className="text-xs text-slate-500 font-mono mt-0.5 flex items-center gap-1.5">
<span>{row.telegram_id}</span>
<button
onClick={() => handleCopy(row.telegram_id)}
className="text-slate-600 hover:text-slate-400 p-0.5 rounded transition-colors"
title="复制电报 ID"
>
{copiedId === row.telegram_id ? (
<Check className="h-3 w-3 text-emerald-400" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
</td>
<td className="px-6 py-4 text-center">
{row.anomaly_type === "unbound" ? (
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-500/15 text-red-400 border border-red-500/20">
</span>
) : row.anomaly_type === "expired" ? (
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/20">
</span>
) : (
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-yellow-500/15 text-yellow-400 border border-yellow-500/20">
</span>
)}
</td>
<td className="px-6 py-4 text-slate-300 text-xs">
{row.reason}
</td>
<td className="px-6 py-4">
{row.email ? (
<div className="flex items-center gap-1.5 text-xs">
<Mail className="h-3.5 w-3.5 text-slate-500 shrink-0" />
<span className="truncate max-w-[160px]" title={row.email}>
{row.email}
</span>
</div>
) : (
<span className="text-slate-600"></span>
)}
</td>
<td className="px-6 py-4">
{row.expires_at ? (
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<Calendar className="h-3.5 w-3.5 text-slate-500 shrink-0" />
<span>{row.expires_at.split("T")[0]}</span>
</div>
) : (
<span className="text-slate-600"></span>
)}
</td>
<td className="px-6 py-4 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleCopy(row.telegram_id)}
className="h-7 px-2 text-[11px] gap-1 bg-slate-950/40 hover:bg-slate-800 border border-white/5 text-slate-300 hover:text-white rounded-lg transition-colors"
>
<UserX className="h-3.5 w-3.5 text-red-400/80" />
ID
</Button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-slate-500 text-xs">
{searchQuery ? "未找到符合搜索条件的异常成员" : "当前群内没有检测出任何已过期的异常成员 🎉"}
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card className="border-cyan-500/10 bg-cyan-950/10">
<CardContent className="pt-6 text-xs text-slate-400 leading-relaxed">
<p className="font-semibold text-cyan-300 mb-1">💡 </p>
<ol className="list-decimal pl-4 space-y-1">
<li> <code className="bg-slate-950/60 px-1 py-0.5 rounded text-cyan-400"> ID</code> </li>
<li> Telegram </li>
<li> ID </li>
<li> <code className="text-red-400">Remove from Group ()</code> </li>
</ol>
</CardContent>
</Card>
</div>
);
}
+17
View File
@@ -126,4 +126,21 @@ export const opsApi = {
}>;
}>("/api/ops/training/accuracy");
},
telegramAudit() {
return opsFetch<{
anomalies: Array<{
telegram_id: number;
username: string;
chat_id: string;
status: string;
anomaly_type: "unbound" | "expired" | "trial_only";
reason: string;
email: string | null;
expires_at: string | null;
}>;
valid_count: number;
anomaly_count: number;
error?: string;
}>("/api/ops/telegram/members-audit");
},
};
+7
View File
@@ -22,6 +22,7 @@ from web.services.ops_api import (
search_ops_users,
update_ops_config,
get_ops_training_accuracy,
get_ops_telegram_audit,
)
router = APIRouter(tags=["ops"])
@@ -167,3 +168,9 @@ async def ops_health_check(request: Request):
async def ops_training_accuracy(request: Request):
return get_ops_training_accuracy(request)
@router.get("/api/ops/telegram/members-audit")
async def ops_telegram_audit(request: Request):
return get_ops_telegram_audit(request)
+172
View File
@@ -836,3 +836,175 @@ def get_ops_training_accuracy(request: Request) -> Dict[str, Any]:
return {"accuracy": accuracy_data}
def get_ops_telegram_audit(request: Request) -> Dict[str, Any]:
_require_ops(request)
import concurrent.futures
import os
import sqlite3
import requests
from src.database.db_manager import DBManager
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
db = DBManager()
# 1. Fetch all distinct telegram users from database
with db._get_connection() as conn:
conn.row_factory = sqlite3.Row
users_rows = conn.execute("SELECT telegram_id, username FROM users").fetchall()
bindings_rows = conn.execute("SELECT telegram_id, supabase_user_id, supabase_email FROM supabase_bindings").fetchall()
user_info = {}
for r in users_rows:
tid = int(r["telegram_id"])
user_info[tid] = {
"telegram_id": tid,
"username": r["username"] or f"ID: {tid}",
"supabase_user_id": None,
"supabase_email": None,
"is_bound": False
}
for r in bindings_rows:
tid = int(r["telegram_id"])
if tid not in user_info:
user_info[tid] = {
"telegram_id": tid,
"username": f"ID: {tid}",
"supabase_user_id": r["supabase_user_id"],
"supabase_email": r["supabase_email"],
"is_bound": True
}
else:
user_info[tid]["supabase_user_id"] = r["supabase_user_id"]
user_info[tid]["supabase_email"] = r["supabase_email"]
user_info[tid]["is_bound"] = True
# 2. Get Telegram Bot settings
bot_token = str(os.getenv("TELEGRAM_BOT_TOKEN") or "").strip()
chat_ids = get_telegram_chat_ids_from_env()
# Add other group IDs if configured
for env_name in ["POLYWEATHER_TELEGRAM_GROUP_ID", "POLYWEATHER_TELEGRAM_TOPICS_GROUP_ID"]:
val = str(os.getenv(env_name) or "").strip()
if val and val not in chat_ids:
chat_ids.append(val)
if not bot_token or not chat_ids:
return {"error": "Telegram Bot Token or Chat IDs not configured", "anomalies": []}
# 3. Check membership status for all users in parallel
results = []
def check_user_chat(tg_id, chat_id):
try:
resp = requests.get(
f"https://api.telegram.org/bot{bot_token}/getChatMember",
params={"chat_id": chat_id, "user_id": tg_id},
timeout=5
)
if resp.status_code == 200:
data = resp.json()
if data.get("ok"):
res_status = data["result"].get("status")
return chat_id, res_status
return chat_id, None
except Exception:
return chat_id, None
all_tasks = []
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = {}
for tg_id in user_info.keys():
for c_id in chat_ids:
f = executor.submit(check_user_chat, tg_id, c_id)
futures[f] = (tg_id, c_id)
for f in concurrent.futures.as_completed(futures):
tg_id, c_id = futures[f]
try:
_, status = f.result()
if status in {"creator", "administrator", "member"}:
results.append((tg_id, c_id, status))
except Exception:
pass
# 4. Filter and categorize members
anomalies = []
valid_members = []
from web.services.ops_api import legacy_routes
active_subs = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(limit=5000)
active_subs_map = {}
for sub in active_subs:
uid = str(sub.get("user_id") or "").strip().lower()
if uid:
active_subs_map[uid] = sub
for tg_id, chat_id, status in results:
info = user_info[tg_id]
if not info["is_bound"]:
anomalies.append({
"telegram_id": tg_id,
"username": info["username"],
"chat_id": chat_id,
"status": status,
"anomaly_type": "unbound",
"reason": "未绑定网页账号",
"email": None,
"expires_at": None,
})
else:
uid = str(info["supabase_user_id"]).strip().lower()
sub = active_subs_map.get(uid)
is_paid = False
plan_code = ""
expires_at = None
if sub:
plan_code = str(sub.get("plan_code") or "").strip().lower()
source = str(sub.get("source") or "").strip().lower()
is_paid = "trial" not in plan_code and "trial" not in source
expires_at = sub.get("expires_at")
if not sub:
anomalies.append({
"telegram_id": tg_id,
"username": info["username"],
"chat_id": chat_id,
"status": status,
"anomaly_type": "expired",
"reason": "没有有效的会员订阅",
"email": info["supabase_email"],
"expires_at": None,
})
elif not is_paid:
anomalies.append({
"telegram_id": tg_id,
"username": info["username"],
"chat_id": chat_id,
"status": status,
"anomaly_type": "trial_only",
"reason": f"仅拥有试用会员 ({plan_code})",
"email": info["supabase_email"],
"expires_at": expires_at,
})
else:
valid_members.append({
"telegram_id": tg_id,
"username": info["username"],
"chat_id": chat_id,
"status": status,
"email": info["supabase_email"],
"plan_code": plan_code,
"expires_at": expires_at,
})
return {
"anomalies": anomalies,
"valid_count": len(valid_members),
"anomaly_count": len(anomalies),
}