765 lines
25 KiB
Python
765 lines
25 KiB
Python
"""Ops config / subscriptions / logs / telegram service functions."""
|
||
from __future__ import annotations
|
||
|
||
import concurrent.futures
|
||
import os
|
||
import sqlite3
|
||
import subprocess
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
import requests as _requests
|
||
from fastapi import HTTPException, Request
|
||
|
||
from src.utils.runtime_secrets import get_runtime_secret_status
|
||
|
||
|
||
def _get_db():
|
||
from src.database.db_manager import DBManager as _DBManager
|
||
return _DBManager()
|
||
|
||
|
||
# ── Config key definitions ──────────────────────────────────────────
|
||
|
||
_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)",
|
||
"POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS": "手动转账收款钱包地址",
|
||
}
|
||
|
||
_SENSITIVE_CONFIG_KEYS: dict[str, dict[str, str]] = {
|
||
"POLYWEATHER_AMSC_SESSION_ID": {
|
||
"label": "AMSC AWOS sessionId",
|
||
"description": "中国跑道观测接口 sessionId,用于上海/北京/广州等 AMSC AWOS 数据源。",
|
||
},
|
||
}
|
||
|
||
|
||
# ── Helpers ─────────────────────────────────────────────────────────
|
||
|
||
|
||
def _require_ops(request: Request) -> Dict[str, Any] | None:
|
||
from web.services.ops_api import _require_ops as _real
|
||
return _real(request)
|
||
|
||
|
||
def _parse_iso_datetime(value: Any) -> Optional[datetime]:
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
return None
|
||
try:
|
||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||
except Exception:
|
||
return None
|
||
if parsed.tzinfo is None:
|
||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||
return parsed.astimezone(timezone.utc)
|
||
|
||
|
||
def _to_utc_iso(value: datetime) -> str:
|
||
if value.tzinfo is None:
|
||
value = value.replace(tzinfo=timezone.utc)
|
||
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||
|
||
|
||
def _supabase_rest_rows(
|
||
table: str,
|
||
params: Dict[str, Any],
|
||
*,
|
||
timeout: int = 10,
|
||
) -> List[Dict[str, Any]]:
|
||
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")
|
||
|
||
headers = {
|
||
"apikey": service_role_key,
|
||
"Authorization": f"Bearer {service_role_key}",
|
||
}
|
||
resp = _requests.get(
|
||
f"{supabase_url}/rest/v1/{table}",
|
||
headers=headers,
|
||
params=params,
|
||
timeout=timeout,
|
||
)
|
||
if not resp.ok:
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail=f"Supabase query failed for {table}: {resp.status_code}",
|
||
)
|
||
rows = resp.json() if resp.content else []
|
||
if not isinstance(rows, list):
|
||
return []
|
||
return [row for row in rows if isinstance(row, dict)]
|
||
|
||
|
||
def _supabase_service_headers(
|
||
service_role_key: str,
|
||
*,
|
||
prefer: str | None = None,
|
||
) -> dict[str, str]:
|
||
headers = {
|
||
"apikey": service_role_key,
|
||
"Authorization": f"Bearer {service_role_key}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
if prefer:
|
||
headers["Prefer"] = prefer
|
||
return headers
|
||
|
||
|
||
def _lookup_supabase_user_id_by_email(
|
||
supabase_url: str,
|
||
service_role_key: str,
|
||
email: str,
|
||
) -> str:
|
||
normalized_email = str(email or "").strip().lower()
|
||
if not normalized_email:
|
||
return ""
|
||
base = str(supabase_url or "").strip().rstrip("/")
|
||
headers = _supabase_service_headers(service_role_key)
|
||
|
||
profile_resp = _requests.get(
|
||
f"{base}/rest/v1/profiles",
|
||
headers=headers,
|
||
params={
|
||
"select": "id",
|
||
"email": f"eq.{normalized_email}",
|
||
"limit": "1",
|
||
},
|
||
timeout=10,
|
||
)
|
||
if profile_resp.ok:
|
||
profiles = profile_resp.json() if profile_resp.content else []
|
||
if isinstance(profiles, list) and profiles:
|
||
user_id = str((profiles[0] or {}).get("id") or "").strip()
|
||
if user_id:
|
||
return user_id
|
||
|
||
user_resp = _requests.get(
|
||
f"{base}/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 []
|
||
return str(users[0].get("id") or "").strip() if users else ""
|
||
|
||
|
||
# ── Config ──────────────────────────────────────────────────────────
|
||
|
||
|
||
def get_ops_config(request: Request) -> dict[str, Any]:
|
||
_require_ops(request)
|
||
|
||
configs: list[dict[str, Any]] = []
|
||
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)
|
||
|
||
normalized_key = str(key or "").strip()
|
||
if normalized_key not in _EDITABLE_CONFIG_KEYS:
|
||
raise HTTPException(
|
||
status_code=400, detail=f"config key '{normalized_key}' is not editable"
|
||
)
|
||
os.environ[normalized_key] = str(value)
|
||
return {
|
||
"key": normalized_key,
|
||
"value": value,
|
||
"ok": True,
|
||
}
|
||
|
||
|
||
def _sensitive_config_payload(key: str) -> dict[str, Any]:
|
||
definition = _SENSITIVE_CONFIG_KEYS.get(key) or {}
|
||
metadata = get_runtime_secret_status(key)
|
||
return {
|
||
"key": key,
|
||
"label": definition.get("label") or key,
|
||
"description": definition.get("description") or "",
|
||
"configured": bool(metadata.get("configured")),
|
||
"masked": str(metadata.get("masked") or ""),
|
||
"length": int(metadata.get("length") or 0),
|
||
"updated_at": str(metadata.get("updated_at") or ""),
|
||
"updated_by": str(metadata.get("updated_by") or ""),
|
||
"source": str(metadata.get("source") or "runtime_store"),
|
||
}
|
||
|
||
|
||
def get_ops_sensitive_config(request: Request) -> dict[str, Any]:
|
||
_require_ops(request)
|
||
return {
|
||
"configs": [
|
||
_sensitive_config_payload(key)
|
||
for key in _SENSITIVE_CONFIG_KEYS
|
||
]
|
||
}
|
||
|
||
|
||
def update_ops_sensitive_config(
|
||
request: Request,
|
||
key: str,
|
||
value: str,
|
||
) -> dict[str, Any]:
|
||
admin = _require_ops(request) or {}
|
||
normalized_key = str(key or "").strip()
|
||
if normalized_key not in _SENSITIVE_CONFIG_KEYS:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"sensitive config key '{normalized_key}' is not editable",
|
||
)
|
||
secret_value = str(value or "").strip()
|
||
if not 12 <= len(secret_value) <= 256 or any(ch.isspace() for ch in secret_value):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="sessionId must be 12-256 non-whitespace characters",
|
||
)
|
||
|
||
db = _get_db()
|
||
try:
|
||
config = db.set_runtime_secret(
|
||
normalized_key,
|
||
secret_value,
|
||
updated_by=str(admin.get("email") or ""),
|
||
)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
os.environ[normalized_key] = secret_value
|
||
response_config = _sensitive_config_payload(normalized_key)
|
||
response_config.update(
|
||
{
|
||
"configured": bool(config.get("configured")),
|
||
"masked": str(config.get("masked") or ""),
|
||
"length": int(config.get("length") or 0),
|
||
"updated_at": str(config.get("updated_at") or ""),
|
||
"updated_by": str(config.get("updated_by") or ""),
|
||
"source": str(config.get("source") or "runtime_store"),
|
||
}
|
||
)
|
||
# Lazy import to avoid circular dependency with ops_api
|
||
from web.services.ops_api import _check_amsc_awos_health
|
||
health = (
|
||
_check_amsc_awos_health(timeout=8)
|
||
if normalized_key == "POLYWEATHER_AMSC_SESSION_ID"
|
||
else None
|
||
)
|
||
return {"ok": True, "config": response_config, "health": health}
|
||
|
||
|
||
# ── Subscriptions ───────────────────────────────────────────────────
|
||
|
||
|
||
def grant_ops_subscription(
|
||
request: Request,
|
||
email: str,
|
||
plan_code: str = "pro_monthly",
|
||
days: int = 30,
|
||
deduct_points: int = 0,
|
||
) -> dict[str, Any]:
|
||
admin = _require_ops(request) or {}
|
||
actor_email = str(admin.get("email") or "").strip().lower()
|
||
from datetime import datetime
|
||
|
||
import web.routes as legacy_routes # lazy – avoid circular import
|
||
|
||
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"}
|
||
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)))
|
||
safe_deduct = max(0, int(deduct_points or 0))
|
||
normalized_email = str(email or "").strip().lower()
|
||
if not normalized_email:
|
||
raise HTTPException(status_code=400, detail="email is required")
|
||
|
||
user_id = _lookup_supabase_user_id_by_email(
|
||
supabase_url,
|
||
service_role_key,
|
||
normalized_email,
|
||
)
|
||
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=_supabase_service_headers(service_role_key, prefer="return=minimal"),
|
||
json=payload,
|
||
timeout=10,
|
||
)
|
||
if not resp.ok:
|
||
raise HTTPException(
|
||
status_code=500, detail=f"Supabase insert failed: {resp.text[:200]}"
|
||
)
|
||
legacy_routes.SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
|
||
|
||
result: dict[str, Any] = {
|
||
"ok": True,
|
||
"user_id": user_id,
|
||
"plan_code": plan_code,
|
||
"days": safe_days,
|
||
"expires_at": expires_at,
|
||
}
|
||
|
||
# Optionally deduct points from the user (manual Pro grant with points payment)
|
||
if safe_deduct > 0:
|
||
db = _get_db()
|
||
deduct_result = db.deduct_points_by_supabase_email(
|
||
normalized_email,
|
||
safe_deduct,
|
||
source="ops_subscription_deduction",
|
||
actor_email=actor_email,
|
||
reference_type="subscription",
|
||
reference_id=user_id,
|
||
metadata={"plan_code": plan_code, "days": safe_days},
|
||
)
|
||
result["points_deducted"] = safe_deduct
|
||
result["points_result"] = deduct_result
|
||
|
||
try:
|
||
_get_db().append_ops_audit_event(
|
||
action="subscription_manual_grant",
|
||
actor_email=actor_email,
|
||
target_user_id=user_id,
|
||
target_email=normalized_email,
|
||
target_type="subscription",
|
||
payload={
|
||
"plan_code": plan_code,
|
||
"days": safe_days,
|
||
"expires_at": expires_at,
|
||
"deduct_points": safe_deduct,
|
||
},
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
return result
|
||
|
||
|
||
def extend_ops_subscription(
|
||
request: Request,
|
||
email: str,
|
||
additional_days: int = 30,
|
||
) -> dict[str, Any]:
|
||
admin = _require_ops(request) or {}
|
||
actor_email = str(admin.get("email") or "").strip().lower()
|
||
from datetime import datetime
|
||
|
||
import web.routes as legacy_routes # lazy – avoid circular import
|
||
|
||
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 = _supabase_service_headers(service_role_key)
|
||
|
||
user_id = _lookup_supabase_user_id_by_email(
|
||
supabase_url,
|
||
service_role_key,
|
||
normalized_email,
|
||
)
|
||
if not user_id:
|
||
raise HTTPException(
|
||
status_code=404, detail=f"user not found: {normalized_email}"
|
||
)
|
||
|
||
# Find latest active subscription
|
||
subs_resp = _requests.get(
|
||
f"{supabase_url}/rest/v1/subscriptions",
|
||
headers=headers,
|
||
params={
|
||
"select": "id,expires_at",
|
||
"user_id": f"eq.{user_id}",
|
||
"status": "eq.active",
|
||
"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=_supabase_service_headers(service_role_key, prefer="return=minimal"),
|
||
json={"expires_at": new_expiry},
|
||
timeout=10,
|
||
)
|
||
if patch_resp.ok:
|
||
legacy_routes.SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
|
||
try:
|
||
_get_db().append_ops_audit_event(
|
||
action="subscription_manual_extend",
|
||
actor_email=actor_email,
|
||
target_user_id=user_id,
|
||
target_email=normalized_email,
|
||
target_type="subscription",
|
||
target_id=str(sub.get("id") or ""),
|
||
payload={
|
||
"additional_days": safe_days,
|
||
"previous_expires_at": current_expiry,
|
||
"new_expires_at": new_expiry,
|
||
},
|
||
)
|
||
except Exception:
|
||
pass
|
||
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]}"
|
||
)
|
||
|
||
|
||
def get_ops_user_subscriptions(
|
||
request: Request,
|
||
email: str,
|
||
) -> dict[str, Any]:
|
||
"""Return ALL subscription rows for a user (by email), regardless of status."""
|
||
_require_ops(request)
|
||
|
||
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")
|
||
|
||
normalized_email = str(email or "").strip().lower()
|
||
if not normalized_email:
|
||
raise HTTPException(status_code=400, detail="email is required")
|
||
|
||
headers = _supabase_service_headers(service_role_key)
|
||
|
||
user_id = _lookup_supabase_user_id_by_email(
|
||
supabase_url,
|
||
service_role_key,
|
||
normalized_email,
|
||
)
|
||
if not user_id:
|
||
raise HTTPException(
|
||
status_code=404, detail=f"user not found: {normalized_email}"
|
||
)
|
||
|
||
# Fetch all subscription rows for this user (no status filter)
|
||
subs_resp = _requests.get(
|
||
f"{supabase_url}/rest/v1/subscriptions",
|
||
headers=headers,
|
||
params={
|
||
"select": "id,user_id,status,plan_code,source,starts_at,expires_at,created_at,updated_at",
|
||
"user_id": f"eq.{user_id}",
|
||
"order": "created_at.desc",
|
||
"limit": "50",
|
||
},
|
||
timeout=10,
|
||
)
|
||
rows = subs_resp.json() if subs_resp.ok and subs_resp.content else []
|
||
if not isinstance(rows, list):
|
||
rows = []
|
||
|
||
return {
|
||
"email": normalized_email,
|
||
"user_id": user_id,
|
||
"subscriptions": rows,
|
||
"count": len(rows),
|
||
}
|
||
|
||
|
||
# ── Logs ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def get_ops_logs(
|
||
request: Request,
|
||
level: str = "",
|
||
lines: int = 100,
|
||
) -> dict[str, Any]:
|
||
_require_ops(request)
|
||
|
||
safe_lines = max(10, min(1000, int(lines or 100)))
|
||
log_text = ""
|
||
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:
|
||
pass
|
||
|
||
# Fallback to local log file if docker logs returns empty
|
||
if not log_text.strip():
|
||
log_file = "data/logs/polyweather.log"
|
||
if os.path.exists(log_file):
|
||
try:
|
||
with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
|
||
all_lines = f.readlines()
|
||
log_text = "".join(all_lines[-safe_lines:])
|
||
except Exception:
|
||
pass
|
||
|
||
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),
|
||
}
|
||
|
||
|
||
# ── Telegram audit ──────────────────────────────────────────────────
|
||
|
||
|
||
def get_ops_telegram_audit(request: Request) -> Dict[str, Any]:
|
||
_require_ops(request)
|
||
|
||
|
||
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
|
||
# Lazy imports to avoid circular dependency with ops_api
|
||
import web.routes as legacy_routes
|
||
from web.services.ops.payments import _list_active_subscriptions_with_windows
|
||
|
||
db = _get_db()
|
||
|
||
# 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
|
||
|
||
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 = []
|
||
|
||
active_subs, _, used_active_window_query = _list_active_subscriptions_with_windows(
|
||
limit=5000
|
||
)
|
||
if not used_active_window_query:
|
||
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),
|
||
}
|