架构重构:拆分 core/ops/DBManager,统一 SQLite 锁,DEB 改进,新增注意力模型
- web/core.py 858→236行,拆出 schemas/middleware/auth/diagnostics - ops_api.py 2876→4个 domain 模块 (users/payments/health/config) - DBManager Supabase HTTP 调用提取到 SupabaseAdminClient - 新增 LockedSQLiteConnection 统一多进程读写锁 - 新增 WeatherCacheManager 替代 12 个独立缓存字典 - METAR 缓存迁移至统一缓存管理器 - analysis_service 提取 _build_intraday_meteorology 到独立模块 - DEB 改进:偏差惩罚、分歧回退、自适应 lookback (MAE ↓12.6%) - 新增 PyTorch 注意力模型 deb_attention.py (数据积累后启用) - 新增 torch 到 requirements.lock
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
"""Intraday meteorology — paid-product signal layer.
|
||||
|
||||
Reads existing analysis layers (current, probabilities, DEB, peak, deviation,
|
||||
TAF, vertical profile, station network) and produces a structured meteorology
|
||||
read with headline, confidence, signals, and invalidation/confirmation rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from web.services.analysis_utils import (
|
||||
add_signal as _add_signal,
|
||||
bucket_label as _bucket_label,
|
||||
bucket_label_from_value as _bucket_label_from_value,
|
||||
format_clock_minutes as _format_clock_minutes,
|
||||
next_observation_clock as _next_observation_clock,
|
||||
top_probability_bucket as _top_probability_bucket,
|
||||
)
|
||||
|
||||
|
||||
def _sf(v: Any) -> Any:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build a paid-product intraday meteorology read from existing layers."""
|
||||
current = data.get("current") or {}
|
||||
probabilities = data.get("probabilities") or {}
|
||||
distribution = probabilities.get("distribution") or []
|
||||
top_bucket = _top_probability_bucket(distribution)
|
||||
unit = str(data.get("temp_symbol") or "°C")
|
||||
deb = data.get("deb") or {}
|
||||
peak = data.get("peak") or {}
|
||||
deviation = data.get("deviation_monitor") or {}
|
||||
taf_signal = (
|
||||
((data.get("taf") or {}).get("signal") or {})
|
||||
if isinstance(data.get("taf"), dict)
|
||||
else {}
|
||||
)
|
||||
vertical = data.get("vertical_profile_signal") or {}
|
||||
|
||||
current_temp = _sf(current.get("temp"))
|
||||
max_so_far = _sf(current.get("max_so_far"))
|
||||
deb_prediction = _sf(deb.get("prediction"))
|
||||
base_value = _sf(top_bucket.get("value")) if isinstance(top_bucket, dict) else None
|
||||
if base_value is None:
|
||||
base_value = deb_prediction
|
||||
if base_value is None:
|
||||
base_value = max_so_far if max_so_far is not None else current_temp
|
||||
|
||||
base_case_bucket = _bucket_label(top_bucket, unit) or _bucket_label_from_value(base_value, unit)
|
||||
upside_bucket = _bucket_label_from_value(base_value + 1.0, unit) if base_value is not None else None
|
||||
downside_bucket = _bucket_label_from_value(base_value - 1.0, unit) if base_value is not None else None
|
||||
|
||||
signals: list = []
|
||||
support_score = 0
|
||||
suppress_score = 0
|
||||
available_layers = 0
|
||||
|
||||
direction = str(deviation.get("direction") or "").lower()
|
||||
severity = str(deviation.get("severity") or "normal").lower()
|
||||
delta = _sf(deviation.get("current_delta"))
|
||||
if direction:
|
||||
available_layers += 1
|
||||
strength = "strong" if severity == "strong" else ("medium" if severity == "light" else "weak")
|
||||
if direction == "hot":
|
||||
support_score += 2 if strength == "strong" else 1
|
||||
_add_signal(
|
||||
signals,
|
||||
label="日内节奏",
|
||||
label_en="Intraday pace",
|
||||
direction="support",
|
||||
strength=strength,
|
||||
summary=f"实测较预期路径偏高 {abs(delta or 0):.1f}{unit},峰值仍有上修空间。",
|
||||
summary_en=f"Observed temperature is running {abs(delta or 0):.1f}{unit} above the expected path; the peak still has upside room.",
|
||||
)
|
||||
elif direction == "cold":
|
||||
suppress_score += 2 if strength == "strong" else 1
|
||||
_add_signal(
|
||||
signals,
|
||||
label="日内节奏",
|
||||
label_en="Intraday pace",
|
||||
direction="suppress",
|
||||
strength=strength,
|
||||
summary=f"实测较预期路径偏低 {abs(delta or 0):.1f}{unit},追更高温档需要等待后续观测确认。",
|
||||
summary_en=f"Observed temperature is running {abs(delta or 0):.1f}{unit} below the expected path; higher buckets need confirmation from later observations.",
|
||||
)
|
||||
else:
|
||||
_add_signal(
|
||||
signals,
|
||||
label="日内节奏",
|
||||
label_en="Intraday pace",
|
||||
direction="neutral",
|
||||
strength="weak",
|
||||
summary="实测大体贴近当前预期路径,下一步主要看峰值窗口内是否继续抬升。",
|
||||
summary_en="Observed temperature is broadly tracking the expected path; the next question is whether it keeps lifting through the peak window.",
|
||||
)
|
||||
|
||||
heating_setup = str(vertical.get("heating_setup") or "").lower()
|
||||
suppression_risk = str(vertical.get("suppression_risk") or "").lower()
|
||||
if heating_setup or suppression_risk:
|
||||
available_layers += 1
|
||||
if heating_setup == "supportive":
|
||||
support_score += 2
|
||||
_add_signal(
|
||||
signals,
|
||||
label="边界层结构",
|
||||
label_en="Boundary-layer setup",
|
||||
direction="support",
|
||||
strength="strong",
|
||||
summary=str(vertical.get("summary_zh") or "边界层结构支持白天继续混合升温。"),
|
||||
summary_en=str(vertical.get("summary_en") or "The boundary-layer setup supports continued daytime mixing and warming."),
|
||||
)
|
||||
elif heating_setup == "suppressed" or suppression_risk == "high":
|
||||
suppress_score += 2
|
||||
_add_signal(
|
||||
signals,
|
||||
label="边界层结构",
|
||||
label_en="Boundary-layer setup",
|
||||
direction="suppress",
|
||||
strength="strong",
|
||||
summary=str(vertical.get("summary_zh") or "边界层或云雨结构对午后峰值形成压制。"),
|
||||
summary_en=str(vertical.get("summary_en") or "Boundary-layer or cloud/rain structure is capping the afternoon peak."),
|
||||
)
|
||||
else:
|
||||
_add_signal(
|
||||
signals,
|
||||
label="边界层结构",
|
||||
label_en="Boundary-layer setup",
|
||||
direction="neutral",
|
||||
strength="medium",
|
||||
summary=str(vertical.get("summary_zh") or "边界层结构暂未给出单边信号。"),
|
||||
summary_en=str(vertical.get("summary_en") or "The boundary-layer setup does not yet provide a one-sided signal."),
|
||||
)
|
||||
|
||||
taf_suppression = str(taf_signal.get("suppression_level") or "").lower()
|
||||
taf_disruption = str(taf_signal.get("disruption_level") or "").lower()
|
||||
taf_has_cloud_rain_cap = taf_suppression in {"medium", "high"} or taf_disruption in {
|
||||
"medium",
|
||||
"high",
|
||||
}
|
||||
|
||||
structural_cap = False
|
||||
if taf_signal.get("available") or taf_suppression:
|
||||
available_layers += 1
|
||||
if taf_suppression == "high" or taf_disruption == "high":
|
||||
suppress_score += 2
|
||||
direction_value = "suppress"
|
||||
strength = "strong"
|
||||
elif taf_suppression == "medium" or taf_disruption == "medium":
|
||||
suppress_score += 1
|
||||
direction_value = "suppress"
|
||||
strength = "medium"
|
||||
else:
|
||||
support_score += 1
|
||||
direction_value = "support"
|
||||
strength = "weak"
|
||||
_add_signal(
|
||||
signals,
|
||||
label="TAF 云雨扰动",
|
||||
label_en="TAF cloud/rain disruption",
|
||||
direction=direction_value,
|
||||
strength=strength,
|
||||
summary=str(taf_signal.get("summary_zh") or "TAF 暂未提示强云雨压温信号。"),
|
||||
summary_en=str(taf_signal.get("summary_en") or "TAF does not yet flag a strong cloud/rain temperature cap."),
|
||||
)
|
||||
|
||||
airport_delta = _sf(data.get("airport_vs_network_delta"))
|
||||
lead_signal = data.get("network_lead_signal") or {}
|
||||
if airport_delta is not None:
|
||||
available_layers += 1
|
||||
leader = str(lead_signal.get("leader_station_label") or lead_signal.get("leader_station_code") or "").strip()
|
||||
sync_status = str(lead_signal.get("leader_sync_status") or "").strip().lower()
|
||||
sync_delta = _sf(lead_signal.get("leader_time_delta_vs_anchor_minutes"))
|
||||
sync_suffix_zh = ""
|
||||
sync_suffix_en = ""
|
||||
if sync_status in {"near_realtime", "lagged"} and sync_delta is not None:
|
||||
sync_suffix_zh = f";但与机场锚点约差 {sync_delta:.0f} 分钟,作为降权信号处理"
|
||||
sync_suffix_en = f"; timing differs from the airport anchor by about {sync_delta:.0f} minutes, so this signal is down-weighted"
|
||||
elif sync_status == "unknown":
|
||||
sync_suffix_zh = ";周边站观测时间不可完全校验,作为弱参考"
|
||||
sync_suffix_en = "; station timing is not fully verified, so this is treated as a weak reference"
|
||||
if airport_delta <= -0.4:
|
||||
support_score += 1
|
||||
_add_signal(
|
||||
signals,
|
||||
label="站网对比",
|
||||
label_en="Station-network comparison",
|
||||
direction="support",
|
||||
strength="weak" if sync_suffix_zh else "medium",
|
||||
summary=f"周边站网较机场锚点偏热 {abs(airport_delta):.1f}{unit}{f',领先点位 {leader}' if leader else ''}{sync_suffix_zh}。",
|
||||
summary_en=f"Nearby stations are {abs(airport_delta):.1f}{unit} warmer than the airport anchor{f'; leading site: {leader}' if leader else ''}{sync_suffix_en}.",
|
||||
)
|
||||
elif airport_delta >= 0.4:
|
||||
suppress_score += 1
|
||||
_add_signal(
|
||||
signals,
|
||||
label="站网对比",
|
||||
label_en="Station-network comparison",
|
||||
direction="suppress",
|
||||
strength="weak" if sync_suffix_zh else "medium",
|
||||
summary=f"机场锚点较周边站网偏热 {abs(airport_delta):.1f}{unit},继续上修需要机场自身后续报文确认{sync_suffix_zh}。",
|
||||
summary_en=f"The airport anchor is {abs(airport_delta):.1f}{unit} warmer than nearby stations; further upside needs confirmation from later airport reports{sync_suffix_en}.",
|
||||
)
|
||||
else:
|
||||
_add_signal(
|
||||
signals,
|
||||
label="站网对比",
|
||||
label_en="Station-network comparison",
|
||||
direction="neutral",
|
||||
strength="weak",
|
||||
summary="机场锚点与周边站网基本同步,暂不构成单独上修或下修理由。",
|
||||
summary_en="The airport anchor and nearby station network are broadly aligned, so this layer does not independently argue for upside or downside.",
|
||||
)
|
||||
|
||||
peak_status = str(peak.get("status") or "").lower()
|
||||
first_h = _sf(peak.get("first_h"))
|
||||
last_h = _sf(peak.get("last_h"))
|
||||
peak_window = (
|
||||
f"{int(first_h):02d}:00-{int(last_h):02d}:59"
|
||||
if first_h is not None and last_h is not None
|
||||
else "--"
|
||||
)
|
||||
if peak_status == "past":
|
||||
headline = "峰值窗口已过,后续更偏向确认最终高点而非继续上修。"
|
||||
headline_en = "The peak window has passed; the read now shifts toward confirming the final high rather than chasing further upside."
|
||||
confidence = "high" if available_layers >= 2 else "medium"
|
||||
elif suppress_score >= support_score + 2:
|
||||
structural_cap = any(
|
||||
signal.get("direction") == "suppress"
|
||||
and signal.get("label") in {"边界层结构", "站网对比", "日内节奏"}
|
||||
for signal in signals
|
||||
)
|
||||
if taf_has_cloud_rain_cap and structural_cap:
|
||||
headline = "峰值同时存在 TAF 云雨扰动和结构压制,当前更偏防守高温上修。"
|
||||
headline_en = "Both TAF cloud/rain disruption and structural signals are capping the peak; defend against aggressive high-temperature upside for now."
|
||||
elif taf_has_cloud_rain_cap:
|
||||
headline = "TAF 提示峰值窗口有云雨扰动,当前更偏防守高温上修。"
|
||||
headline_en = "TAF flags cloud/rain disruption near the peak window; defend against aggressive high-temperature upside for now."
|
||||
else:
|
||||
headline = "峰值主要受结构信号压制,TAF 云雨层暂未构成主压温理由。"
|
||||
headline_en = "The peak is mainly capped by structural signals; TAF cloud/rain is not the primary suppression reason for now."
|
||||
confidence = "high" if available_layers >= 3 else "medium"
|
||||
elif support_score >= suppress_score + 2:
|
||||
headline = "峰值仍有上修空间,后续重点看峰值窗口内报文能否继续抬升。"
|
||||
headline_en = "The peak still has upside room; the next check is whether reports keep lifting through the peak window."
|
||||
confidence = "high" if available_layers >= 3 else "medium"
|
||||
elif available_layers == 0:
|
||||
headline = "关键日内层仍在补齐,先以观测锚点和下一次报文为主。"
|
||||
headline_en = "Key intraday layers are still filling in; anchor the read on observations and the next report."
|
||||
confidence = "low"
|
||||
else:
|
||||
headline = "当前处于分歧判断区,峰值窗口内的下一组观测将决定方向。"
|
||||
headline_en = "The setup is in a split-decision zone; the next observations inside the peak window should decide direction."
|
||||
confidence = "medium" if available_layers >= 2 else "low"
|
||||
|
||||
next_observation = _next_observation_clock(data.get("local_time") or current.get("obs_time"))
|
||||
threshold = base_value
|
||||
invalidation_rules = []
|
||||
invalidation_rules_en = []
|
||||
confirmation_rules = []
|
||||
confirmation_rules_en = []
|
||||
if peak_status == "past":
|
||||
invalidation_rules.append("若后续官方结算源补录更高值,以结算源最终高点为准。")
|
||||
invalidation_rules_en.append("If the official settlement source later backfills a higher reading, defer to the final settlement-source high.")
|
||||
confirmation_rules.append("若峰值窗口后连续两次观测不再创新高,当前高点基本确认。")
|
||||
confirmation_rules_en.append("If two consecutive post-peak observations fail to make a new high, the current high is broadly confirmed.")
|
||||
else:
|
||||
watch_clock = _format_clock_minutes(int(first_h or 13) * 60 + 30)
|
||||
if threshold is not None:
|
||||
invalidation_rules.append(f"{watch_clock} 前若仍未接近 {threshold:.0f}{unit},上修路径降级。")
|
||||
invalidation_rules_en.append(f"If observations are still not near {threshold:.0f}{unit} before {watch_clock}, downgrade the upside path.")
|
||||
confirmation_rules.append(f"峰值窗口内任一结算源观测触达或超过 {threshold:.0f}{unit},基准路径确认度上升。")
|
||||
confirmation_rules_en.append(f"If any settlement-source observation reaches or exceeds {threshold:.0f}{unit} inside the peak window, confidence in the base path rises.")
|
||||
invalidation_rules.append("若 TAF 或实况报文出现阵雨、雷暴或低云/云雨压制,高温上沿需要下调。")
|
||||
invalidation_rules_en.append("If TAF or live reports show showers, thunderstorms, or low-cloud/cloud-rain suppression, lower the upper temperature bound.")
|
||||
confirmation_rules.append("若实测继续贴近 DEB 曲线且云雨信号不增强,维持当前主路径。")
|
||||
confirmation_rules_en.append("If observations keep tracking the DEB curve and cloud/rain signals do not strengthen, maintain the current main path.")
|
||||
|
||||
if not signals:
|
||||
_add_signal(
|
||||
signals,
|
||||
label="数据完整性",
|
||||
label_en="Data completeness",
|
||||
direction="neutral",
|
||||
strength="weak",
|
||||
summary="当前缺少足够的日内结构层,等待下一次观测刷新后再提高判断权重。",
|
||||
summary_en="There are not enough intraday structure layers yet; wait for the next observation refresh before raising confidence.",
|
||||
)
|
||||
|
||||
return {
|
||||
"headline": headline,
|
||||
"headline_en": headline_en,
|
||||
"confidence": confidence,
|
||||
"base_case_bucket": base_case_bucket,
|
||||
"upside_bucket": upside_bucket,
|
||||
"downside_bucket": downside_bucket,
|
||||
"next_observation_time": next_observation,
|
||||
"peak_window": peak_window,
|
||||
"invalidation_rules": invalidation_rules[:4],
|
||||
"invalidation_rules_en": invalidation_rules_en[:4],
|
||||
"confirmation_rules": confirmation_rules[:3],
|
||||
"confirmation_rules_en": confirmation_rules_en[:3],
|
||||
"signal_contributions": signals[:5],
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Ops service sub-package."""
|
||||
@@ -0,0 +1,724 @@
|
||||
"""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.database.db_manager import DBManager # type hints
|
||||
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]:
|
||||
_require_ops(request)
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
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
|
||||
)
|
||||
result["points_deducted"] = safe_deduct
|
||||
result["points_result"] = deduct_result
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def extend_ops_subscription(
|
||||
request: Request,
|
||||
email: str,
|
||||
additional_days: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
_require_ops(request)
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
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)
|
||||
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),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
"""Ops users / feedback / points / analytics service functions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
|
||||
from web.core import GrantPointsRequest
|
||||
import web.routes as legacy_routes
|
||||
|
||||
|
||||
def _get_db():
|
||||
from web.services.ops_api import DBManager
|
||||
return DBManager()
|
||||
|
||||
# _require_ops is a lightweight auth guard duplicated in each ops submodule
|
||||
# to avoid circular imports with the ops_api re-export hub.
|
||||
def _require_ops(request: Request):
|
||||
from web.services.ops_api import _require_ops as _real
|
||||
return _real(request)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Internal helpers
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _sf(value: Any) -> Optional[float]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _round_metric(value: Optional[float], digits: int = 1) -> Optional[float]:
|
||||
return None if value is None else round(float(value), digits)
|
||||
|
||||
|
||||
def _app_analytics_actor_key(row: Dict[str, Any]) -> str:
|
||||
payload = row.get("payload")
|
||||
payload = payload if isinstance(payload, dict) else {}
|
||||
user_id = str(row.get("user_id") or payload.get("user_id") or "").strip().lower()
|
||||
client_id = str(row.get("client_id") or "").strip()
|
||||
session_id = str(row.get("session_id") or "").strip()
|
||||
if user_id:
|
||||
return f"user:{user_id}"
|
||||
if client_id:
|
||||
return f"client:{client_id}"
|
||||
if session_id:
|
||||
return f"session:{session_id}"
|
||||
return f"event:{row.get('id')}"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Users
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def search_ops_users(request: Request, q: str = "", limit: int = 20) -> Dict[str, Any]:
|
||||
_require_ops(request)
|
||||
db = _get_db()
|
||||
return {"users": db.search_users(q, limit=limit)}
|
||||
|
||||
|
||||
def get_ops_weekly_leaderboard(request: Request, limit: int = 20) -> Dict[str, Any]:
|
||||
_require_ops(request)
|
||||
db = _get_db()
|
||||
return {"leaderboard": db.get_weekly_leaderboard(limit=limit)}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Points
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def grant_ops_points(request: Request, body: GrantPointsRequest) -> Dict[str, Any]:
|
||||
admin = _require_ops(request) or {}
|
||||
db = _get_db()
|
||||
result = db.grant_points_by_supabase_email(body.email, body.points)
|
||||
result["operator_email"] = admin.get("email")
|
||||
if not result.get("ok"):
|
||||
reason = str(result.get("reason") or "grant_points_failed")
|
||||
status_code = 404 if reason == "user_not_found" else 400
|
||||
raise HTTPException(status_code=status_code, detail=result)
|
||||
return result
|
||||
|
||||
|
||||
def transfer_ops_points(
|
||||
request: Request,
|
||||
from_email: str = "",
|
||||
to_email: str = "",
|
||||
amount: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Transfer points from one user to another."""
|
||||
admin = _require_ops(request) or {}
|
||||
from_email = str(from_email or "").strip()
|
||||
to_email = str(to_email or "").strip()
|
||||
amount = int(amount or 0)
|
||||
if not from_email or not to_email:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="from_email and to_email are required"
|
||||
)
|
||||
if amount <= 0:
|
||||
raise HTTPException(status_code=400, detail="amount must be positive")
|
||||
db = _get_db()
|
||||
result = db.transfer_points_by_email(from_email, to_email, amount)
|
||||
result["operator_email"] = admin.get("email")
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=400, detail=result)
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Analytics
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def get_ops_analytics_funnel(request: Request, days: int = 30) -> Dict[str, Any]:
|
||||
_require_ops(request)
|
||||
db = _get_db()
|
||||
return db.get_app_analytics_funnel_summary(days=days)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Feedback
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def list_ops_feedback(
|
||||
request: Request,
|
||||
*,
|
||||
limit: int = 100,
|
||||
status: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
_require_ops(request)
|
||||
db = _get_db()
|
||||
rows = db.list_user_feedback(limit=limit, status=status or None)
|
||||
status_counts: Dict[str, int] = {}
|
||||
recent_rows = db.list_user_feedback(limit=500)
|
||||
for row in recent_rows:
|
||||
key = str(row.get("status") or "unknown")
|
||||
status_counts[key] = status_counts.get(key, 0) + 1
|
||||
return {
|
||||
"feedback": rows,
|
||||
"total": len(rows),
|
||||
"status_counts": status_counts,
|
||||
}
|
||||
|
||||
|
||||
def update_ops_feedback_status(
|
||||
request: Request,
|
||||
*,
|
||||
feedback_id: int,
|
||||
status: str,
|
||||
) -> Dict[str, Any]:
|
||||
_require_ops(request)
|
||||
normalized = str(status or "").strip().lower()
|
||||
allowed = {"open", "triaged", "investigating", "resolved", "closed"}
|
||||
if normalized not in allowed:
|
||||
raise HTTPException(status_code=400, detail="unsupported feedback status")
|
||||
updated = _get_db().update_user_feedback_status(feedback_id, status=normalized)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="feedback not found")
|
||||
return {"ok": True, "feedback": updated}
|
||||
|
||||
|
||||
def grant_ops_feedback_reward(
|
||||
request: Request,
|
||||
*,
|
||||
feedback_id: int,
|
||||
points: int,
|
||||
reason: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
admin = _require_ops(request) or {}
|
||||
db = _get_db()
|
||||
result = db.grant_feedback_reward(
|
||||
feedback_id,
|
||||
points=points,
|
||||
reason=reason,
|
||||
)
|
||||
if not result.get("ok") and str(result.get("reason") or "") == "user_not_found":
|
||||
feedback = result.get("feedback") if isinstance(result.get("feedback"), dict) else {}
|
||||
reward_status = str(feedback.get("reward_status") or "").strip().lower()
|
||||
reward_points = int(feedback.get("reward_points") or 0)
|
||||
supabase_user_id = str(feedback.get("user_id") or "").strip().lower()
|
||||
if supabase_user_id and not (reward_status == "granted" and reward_points > 0):
|
||||
try:
|
||||
fallback = legacy_routes.SUPABASE_ENTITLEMENT.grant_points_to_user(
|
||||
supabase_user_id,
|
||||
points,
|
||||
)
|
||||
except Exception as exc:
|
||||
fallback = {"ok": False, "reason": f"supabase_points_grant_failed:{exc}"}
|
||||
if fallback.get("ok"):
|
||||
updated_feedback = db.update_user_feedback_reward(
|
||||
feedback_id,
|
||||
points=points,
|
||||
reason=reason,
|
||||
status="granted",
|
||||
)
|
||||
result = {
|
||||
**fallback,
|
||||
"ok": True,
|
||||
"feedback_id": int(feedback_id),
|
||||
"supabase_user_id": supabase_user_id,
|
||||
"feedback": updated_feedback,
|
||||
}
|
||||
result["operator_email"] = admin.get("email")
|
||||
if not result.get("ok"):
|
||||
reason_code = str(result.get("reason") or "feedback_reward_failed")
|
||||
status_code = 404 if reason_code in {"feedback_not_found", "user_not_found"} else 400
|
||||
if reason_code == "already_rewarded":
|
||||
status_code = 409
|
||||
raise HTTPException(status_code=status_code, detail=result)
|
||||
return result
|
||||
+76
-2868
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user