Initial commit

This commit is contained in:
liumangmang
2026-05-12 17:51:53 +08:00
commit b564ca4797
55 changed files with 6407 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# app package
+20
View File
@@ -0,0 +1,20 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
admin_email: str = "admin@smartup.local"
admin_password: str = "changeme"
jwt_secret: str = "change-me-in-production"
jwt_expire_hours: int = 24
database_url: str = "sqlite:////app/data/app.db"
tz: str = "Asia/Shanghai"
# consecutive failures before upstream goes unhealthy
unhealthy_threshold: int = 3
model_config = {"env_file": ".env", "case_sensitive": False}
@lru_cache
def get_settings() -> Settings:
return Settings()
+30
View File
@@ -0,0 +1,30 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.config import get_settings
settings = get_settings()
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""Create all tables."""
# import models so SQLAlchemy registers them
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page # noqa: F401
Base.metadata.create_all(bind=engine)
+93
View File
@@ -0,0 +1,93 @@
"""SmartUp FastAPI application entry point."""
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from app.config import get_settings
from app.database import init_db
from app.models.admin_user import AdminUser
from app.database import SessionLocal
from app.utils.auth import hash_password
from app.services.scheduler import start_scheduler, stop_scheduler
from app.routers import auth, upstreams, webhooks, logs, custom_pages
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
logger = logging.getLogger(__name__)
def _init_admin() -> None:
settings = get_settings()
if not settings.admin_password:
logger.warning("ADMIN_PASSWORD not set, skip admin init")
return
db = SessionLocal()
try:
exists = db.query(AdminUser).filter(AdminUser.email == settings.admin_email).first()
if not exists:
user = AdminUser(
email=settings.admin_email,
password_hash=hash_password(settings.admin_password),
)
db.add(user)
db.commit()
logger.info("admin user created: %s", settings.admin_email)
else:
logger.info("admin user already exists: %s", settings.admin_email)
finally:
db.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
_init_admin()
start_scheduler()
yield
stop_scheduler()
app = FastAPI(
title="SmartUp",
description="API 上游管理与 Webhook 通知系统",
version="1.0.0",
lifespan=lifespan,
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API routers
app.include_router(auth.router)
app.include_router(upstreams.router)
app.include_router(webhooks.router)
app.include_router(logs.router)
app.include_router(custom_pages.router)
@app.get("/healthz")
def health():
return {"status": "ok"}
# Serve frontend static files
STATIC_DIR = Path(__file__).parent.parent / "static"
if STATIC_DIR.exists():
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets")
@app.get("/{full_path:path}")
def serve_spa(full_path: str):
index = STATIC_DIR / "index.html"
return FileResponse(str(index))
+1
View File
@@ -0,0 +1 @@
# models package
+16
View File
@@ -0,0 +1,16 @@
from datetime import datetime, timezone
from sqlalchemy import Integer, String, DateTime
from sqlalchemy.orm import mapped_column, Mapped
from app.database import Base
class AdminUser(Base):
__tablename__ = "admin_users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
)
+25
View File
@@ -0,0 +1,25 @@
"""Custom embedded pages model."""
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import Integer, String, Boolean, DateTime, Text
from sqlalchemy.orm import mapped_column, Mapped
from app.database import Base
class CustomPage(Base):
__tablename__ = "custom_pages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
url: Mapped[str] = mapped_column(String(2048), nullable=False)
icon: Mapped[str] = mapped_column(String(64), default="Link")
sort_order: Mapped[int] = mapped_column(Integer, default=0)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
use_proxy: Mapped[bool] = mapped_column(Boolean, default=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import Integer, String, DateTime, Text, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped
from app.database import Base
class NotificationLog(Base):
__tablename__ = "notification_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
webhook_config_id: Mapped[int] = mapped_column(
Integer, ForeignKey("webhook_configs.id", ondelete="CASCADE"), index=True
)
webhook_name: Mapped[str] = mapped_column(String(255), default="")
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
payload_json: Mapped[str] = mapped_column(Text, default="{}")
# success | failed
status: Mapped[str] = mapped_column(String(16), nullable=False)
response_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
+13
View File
@@ -0,0 +1,13 @@
from datetime import datetime, timezone
from sqlalchemy import Integer, Text, DateTime, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped
from app.database import Base
class UpstreamRateSnapshot(Base):
__tablename__ = "upstream_rate_snapshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
upstream_id: Mapped[int] = mapped_column(Integer, ForeignKey("upstreams.id", ondelete="CASCADE"), index=True)
snapshot_json: Mapped[str] = mapped_column(Text, nullable=False)
captured_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import Integer, String, Boolean, DateTime, Text
from sqlalchemy.orm import mapped_column, Mapped
from app.database import Base
class Upstream(Base):
__tablename__ = "upstreams"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
base_url: Mapped[str] = mapped_column(String(512), nullable=False)
api_prefix: Mapped[str] = mapped_column(String(128), default="/api/v1")
# none | bearer | api_key | login_password
auth_type: Mapped[str] = mapped_column(String(32), default="login_password")
# JSON: {"email":"..","password":".."} or {"token":".."} etc.
auth_config_json: Mapped[str] = mapped_column(Text, default="{}")
rate_endpoint: Mapped[str] = mapped_column(String(256), default="/groups/rates")
groups_endpoint: Mapped[str] = mapped_column(String(256), default="/groups/available")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
check_interval_seconds: Mapped[int] = mapped_column(Integer, default=600)
timeout_seconds: Mapped[int] = mapped_column(Integer, default=30)
# unknown | healthy | unhealthy
last_status: Mapped[str] = mapped_column(String(32), default="unknown")
last_checked_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
consecutive_failures: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
)
+22
View File
@@ -0,0 +1,22 @@
from datetime import datetime, timezone
from sqlalchemy import Integer, String, Boolean, DateTime, Text
from sqlalchemy.orm import mapped_column, Mapped
from app.database import Base
class WebhookConfig(Base):
__tablename__ = "webhook_configs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
# generic | dingtalk
type: Mapped[str] = mapped_column(String(32), default="generic")
url: Mapped[str] = mapped_column(String(1024), nullable=False)
secret: Mapped[str] = mapped_column(String(512), default="")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
# JSON array: ["upstream_rate_changed","upstream_unhealthy","upstream_recovered"]
events_json: Mapped[str] = mapped_column(Text, default='["upstream_rate_changed"]')
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
)
+1
View File
@@ -0,0 +1 @@
# routers package
+28
View File
@@ -0,0 +1,28 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.admin_user import AdminUser
from app.schemas.auth import LoginRequest, TokenResponse, UserInfo
from app.utils.auth import verify_password, create_access_token, get_current_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
def login(req: LoginRequest, db: Session = Depends(get_db)):
user = db.query(AdminUser).filter(AdminUser.email == req.email).first()
if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
token = create_access_token(user.email)
return TokenResponse(access_token=token)
@router.get("/me", response_model=UserInfo)
def me(current_user: AdminUser = Depends(get_current_user)):
return UserInfo(email=current_user.email)
@router.post("/logout")
def logout():
# JWT is stateless — client discards token
return {"message": "logged out"}
+178
View File
@@ -0,0 +1,178 @@
"""Custom pages CRUD router + transparent iframe proxy."""
from __future__ import annotations
import re
from datetime import datetime, timezone
from typing import List, Optional
from urllib.parse import urljoin, urlparse, urlencode, quote
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.custom_page import CustomPage
from app.utils.auth import get_current_user, get_user_from_token_param
router = APIRouter(prefix="/api/custom-pages", tags=["custom-pages"])
# Headers that prevent iframe embedding — strip them from proxied responses
_STRIP_RESPONSE_HEADERS = {
"x-frame-options",
"content-security-policy",
"content-security-policy-report-only",
}
# Headers we should NOT forward to the upstream (hop-by-hop + host)
_STRIP_REQUEST_HEADERS = {"host", "connection", "transfer-encoding", "te",
"trailers", "upgrade", "proxy-authorization"}
# ---- Schemas ----
class CustomPageCreate(BaseModel):
name: str
url: str
icon: str = "Link"
sort_order: int = 0
enabled: bool = True
use_proxy: bool = False
description: Optional[str] = None
class CustomPageUpdate(BaseModel):
name: Optional[str] = None
url: Optional[str] = None
icon: Optional[str] = None
sort_order: Optional[int] = None
enabled: Optional[bool] = None
use_proxy: Optional[bool] = None
description: Optional[str] = None
class CustomPageResponse(BaseModel):
id: int
name: str
url: str
icon: str
sort_order: int
enabled: bool
use_proxy: bool
description: Optional[str]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# ---- CRUD Endpoints ----
@router.get("", response_model=List[CustomPageResponse])
def list_pages(db: Session = Depends(get_db), _=Depends(get_current_user)):
return db.query(CustomPage).order_by(CustomPage.sort_order, CustomPage.id).all()
@router.post("", response_model=CustomPageResponse, status_code=201)
def create_page(body: CustomPageCreate, db: Session = Depends(get_db), _=Depends(get_current_user)):
page = CustomPage(**body.model_dump())
db.add(page)
db.commit()
db.refresh(page)
return page
@router.put("/{pid}", response_model=CustomPageResponse)
def update_page(pid: int, body: CustomPageUpdate, db: Session = Depends(get_db), _=Depends(get_current_user)):
page = db.query(CustomPage).filter(CustomPage.id == pid).first()
if not page:
raise HTTPException(404, "page not found")
for k, v in body.model_dump(exclude_none=True).items():
setattr(page, k, v)
page.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(page)
return page
@router.delete("/{pid}", status_code=204)
def delete_page(pid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
page = db.query(CustomPage).filter(CustomPage.id == pid).first()
if not page:
raise HTTPException(404, "page not found")
db.delete(page)
db.commit()
# ---- Frame Proxy (simple: strip X-Frame-Options / CSP, pass through content) ----
_STRIP_RESP = {
"x-frame-options",
"content-security-policy",
"content-security-policy-report-only",
}
_STRIP_REQ = {
"host", "connection", "transfer-encoding", "te",
"trailers", "upgrade", "proxy-authorization", "authorization",
}
@router.api_route("/frame-proxy", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"])
async def frame_proxy(
request: Request,
url: str = Query(..., description="Target URL to proxy"),
token: Optional[str] = Query(default=None),
_=Depends(get_user_from_token_param),
):
"""
Simple transparent proxy: strips X-Frame-Options and CSP headers so the
response can be embedded in an iframe.
NOTE: For full SPA (React/Vue) sites, install the 'Requestly' browser
extension and set a rule to remove X-Frame-Options on the target domain —
that works reliably without any server-side complexity.
"""
if not url.startswith(("http://", "https://")):
raise HTTPException(400, "Only http/https URLs are allowed")
# Forward browser headers (cookies, language, accept, etc.)
fwd: dict[str, str] = {}
for k, v in request.headers.items():
if k.lower() in _STRIP_REQ or k.lower().startswith("x-forwarded"):
continue
fwd[k] = v
fwd["user-agent"] = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
fwd.setdefault("accept", "text/html,application/xhtml+xml,*/*;q=0.8")
body = await request.body() if request.method not in ("GET", "HEAD") else None
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=30) as client:
resp = await client.request(
method=request.method,
url=url,
headers=fwd,
content=body,
)
except httpx.RequestError as exc:
raise HTTPException(502, f"Proxy error: {exc}")
# Pass through content unchanged — just strip the iframe-blocking headers
out: dict[str, str] = {}
for k, v in resp.headers.items():
kl = k.lower()
if kl in _STRIP_RESP:
continue
if kl in ("content-encoding", "transfer-encoding", "content-length"):
continue
out[k] = v
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type"),
headers=out,
)
+46
View File
@@ -0,0 +1,46 @@
"""Notification logs listing with filters."""
from __future__ import annotations
import json
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.notification_log import NotificationLog
from app.schemas.log import NotificationLogResponse
from app.utils.auth import get_current_user
router = APIRouter(prefix="/api/notification-logs", tags=["logs"])
def _to_response(log: NotificationLog) -> NotificationLogResponse:
return NotificationLogResponse(
id=log.id,
webhook_config_id=log.webhook_config_id,
webhook_name=log.webhook_name,
event_type=log.event_type,
payload=json.loads(log.payload_json or "{}"),
status=log.status,
response_text=log.response_text,
created_at=log.created_at,
)
@router.get("", response_model=List[NotificationLogResponse])
def list_logs(
status: Optional[str] = Query(None),
event_type: Optional[str] = Query(None),
limit: int = Query(100, le=500),
offset: int = Query(0),
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
q = db.query(NotificationLog)
if status:
q = q.filter(NotificationLog.status == status)
if event_type:
q = q.filter(NotificationLog.event_type == event_type)
logs = q.order_by(NotificationLog.created_at.desc()).offset(offset).limit(limit).all()
return [_to_response(log) for log in logs]
+282
View File
@@ -0,0 +1,282 @@
"""Upstream management CRUD + test + check-now + snapshots."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.admin_user import AdminUser
from app.models.upstream import Upstream
from app.models.snapshot import UpstreamRateSnapshot
from app.schemas.upstream import (
UpstreamCreate, UpstreamUpdate, UpstreamResponse, SnapshotResponse, TestResult
)
from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot
from app.services.snapshot_service import diff_snapshots
from app.services import scheduler as sched_svc
from app.services import webhook_service
from app.utils.auth import get_current_user
router = APIRouter(prefix="/api/upstreams", tags=["upstreams"])
MASK = "***"
SECRET_KEYS = {"password", "token", "key", "secret"}
def _mask_auth_config(auth_type: str, cfg: dict) -> dict:
masked = {}
for k, v in cfg.items():
if k.lower() in SECRET_KEYS and v:
masked[k] = MASK
else:
masked[k] = v
return masked
def _to_response(u: Upstream) -> UpstreamResponse:
cfg = json.loads(u.auth_config_json or "{}")
return UpstreamResponse(
id=u.id,
name=u.name,
base_url=u.base_url,
api_prefix=u.api_prefix,
auth_type=u.auth_type,
auth_config_masked=_mask_auth_config(u.auth_type, cfg),
rate_endpoint=u.rate_endpoint,
groups_endpoint=u.groups_endpoint,
enabled=u.enabled,
check_interval_seconds=u.check_interval_seconds,
timeout_seconds=u.timeout_seconds,
last_status=u.last_status,
last_checked_at=u.last_checked_at,
last_error=u.last_error,
created_at=u.created_at,
updated_at=u.updated_at,
)
@router.get("", response_model=List[UpstreamResponse])
def list_upstreams(db: Session = Depends(get_db), _=Depends(get_current_user)):
return [_to_response(u) for u in db.query(Upstream).order_by(Upstream.id).all()]
@router.post("", response_model=UpstreamResponse, status_code=201)
def create_upstream(
body: UpstreamCreate,
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
u = Upstream(
name=body.name,
base_url=body.base_url.rstrip("/"),
api_prefix=body.api_prefix,
auth_type=body.auth_type,
auth_config_json=json.dumps(body.auth_config, ensure_ascii=False),
rate_endpoint=body.rate_endpoint,
groups_endpoint=body.groups_endpoint,
enabled=body.enabled,
check_interval_seconds=body.check_interval_seconds,
timeout_seconds=body.timeout_seconds,
)
db.add(u)
db.commit()
db.refresh(u)
sched_svc.refresh_upstream(u.id, u.check_interval_seconds, u.enabled)
return _to_response(u)
@router.get("/{uid}", response_model=UpstreamResponse)
def get_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
u = db.query(Upstream).filter(Upstream.id == uid).first()
if not u:
raise HTTPException(404, "upstream not found")
return _to_response(u)
@router.put("/{uid}", response_model=UpstreamResponse)
def update_upstream(
uid: int,
body: UpstreamUpdate,
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
u = db.query(Upstream).filter(Upstream.id == uid).first()
if not u:
raise HTTPException(404, "upstream not found")
data = body.model_dump(exclude_none=True)
if "auth_config" in data:
# merge with existing config to avoid overwriting masked fields
existing = json.loads(u.auth_config_json or "{}")
incoming = data.pop("auth_config")
for k, v in incoming.items():
if v != MASK: # don't overwrite with mask placeholder
existing[k] = v
u.auth_config_json = json.dumps(existing, ensure_ascii=False)
if "base_url" in data:
data["base_url"] = data["base_url"].rstrip("/")
for k, v in data.items():
setattr(u, k, v)
u.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(u)
sched_svc.refresh_upstream(u.id, u.check_interval_seconds, u.enabled)
return _to_response(u)
@router.delete("/{uid}", status_code=204)
def delete_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
u = db.query(Upstream).filter(Upstream.id == uid).first()
if not u:
raise HTTPException(404, "upstream not found")
sched_svc.refresh_upstream(uid, 0, False) # remove job
db.delete(u)
db.commit()
@router.post("/{uid}/test", response_model=TestResult)
def test_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
u = db.query(Upstream).filter(Upstream.id == uid).first()
if not u:
raise HTTPException(404, "upstream not found")
auth_config = json.loads(u.auth_config_json or "{}")
client = UpstreamClient(
base_url=u.base_url,
api_prefix=u.api_prefix,
auth_type=u.auth_type,
auth_config=auth_config,
timeout=float(u.timeout_seconds),
)
try:
client.login()
groups = client.get_available_groups(u.groups_endpoint)
return TestResult(success=True, message=f"连接成功,获取到 {len(groups)} 个分组")
except Exception as exc:
return TestResult(success=False, message="连接失败", detail=str(exc))
@router.post("/{uid}/check-now", response_model=TestResult)
def check_now(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
u = db.query(Upstream).filter(Upstream.id == uid).first()
if not u:
raise HTTPException(404, "upstream not found")
auth_config = json.loads(u.auth_config_json or "{}")
client = UpstreamClient(
base_url=u.base_url,
api_prefix=u.api_prefix,
auth_type=u.auth_type,
auth_config=auth_config,
timeout=float(u.timeout_seconds),
)
try:
client.login()
groups = client.get_available_groups(u.groups_endpoint)
raw_rates = client.get_group_rates(u.rate_endpoint)
snapshot = build_snapshot(u.id, u.base_url, u.api_prefix, groups, raw_rates)
except Exception as exc:
u.consecutive_failures = (u.consecutive_failures or 0) + 1
u.last_error = str(exc)
u.last_checked_at = datetime.now(timezone.utc)
db.commit()
return TestResult(success=False, message="检测失败", detail=str(exc))
prev_row = (
db.query(UpstreamRateSnapshot)
.filter(UpstreamRateSnapshot.upstream_id == uid)
.order_by(UpstreamRateSnapshot.captured_at.desc())
.first()
)
previous = json.loads(prev_row.snapshot_json) if prev_row else None
changes = diff_snapshots(previous, snapshot)
new_row = UpstreamRateSnapshot(
upstream_id=uid,
snapshot_json=json.dumps(snapshot, ensure_ascii=False),
captured_at=datetime.now(timezone.utc),
)
db.add(new_row)
was_unhealthy = u.last_status == "unhealthy"
u.last_status = "healthy"
u.last_checked_at = datetime.now(timezone.utc)
u.last_error = None
u.consecutive_failures = 0
db.commit()
if was_unhealthy:
webhook_service.send_status_event(db, u.id, u.name, u.base_url, "upstream_recovered")
if changes:
webhook_service.send_rate_changed(db, u.id, u.name, u.base_url, changes)
msg = f"检测成功,{len(groups)} 个分组"
if changes:
msg += f",发现 {len(changes)} 处倍率变化"
elif previous is None:
msg += ",初始化快照完成"
else:
msg += ",无变化"
return TestResult(success=True, message=msg)
@router.get("/{uid}/snapshots/latest", response_model=SnapshotResponse)
def latest_snapshot(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
row = (
db.query(UpstreamRateSnapshot)
.filter(UpstreamRateSnapshot.upstream_id == uid)
.order_by(UpstreamRateSnapshot.captured_at.desc())
.first()
)
if not row:
raise HTTPException(404, "no snapshot found")
return SnapshotResponse(
id=row.id,
upstream_id=row.upstream_id,
snapshot=json.loads(row.snapshot_json),
captured_at=row.captured_at,
)
from fastapi import Query as QueryParam
@router.get("/{uid}/snapshots", response_model=List[SnapshotResponse])
def list_snapshots(
uid: int,
limit: int = QueryParam(20, le=100),
offset: int = QueryParam(0),
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
"""Return paginated snapshot history with diff vs previous snapshot embedded."""
rows = (
db.query(UpstreamRateSnapshot)
.filter(UpstreamRateSnapshot.upstream_id == uid)
.order_by(UpstreamRateSnapshot.captured_at.desc())
.offset(offset)
.limit(limit + 1) # fetch one extra to get the "previous" for diffing
.all()
)
# We need the snapshot just before each one to compute changes count.
# rows are desc order; rows[i+1] is older than rows[i]
results = []
for i, row in enumerate(rows[:limit]):
snap = json.loads(row.snapshot_json)
# try to diff against the next row (which is older)
changes_count: int | None = None
if i + 1 < len(rows):
older = json.loads(rows[i + 1].snapshot_json)
from app.services.snapshot_service import diff_snapshots
ch = diff_snapshots(older, snap)
changes_count = len(ch)
groups_count = len(snap.get("groups", {}))
# embed lightweight summary into snapshot dict so frontend can display it
snap["_groups_count"] = groups_count
snap["_changes_count"] = changes_count # None means first ever snapshot
results.append(SnapshotResponse(
id=row.id,
upstream_id=row.upstream_id,
snapshot=snap,
captured_at=row.captured_at,
))
return results
+110
View File
@@ -0,0 +1,110 @@
"""Webhook configuration CRUD + test notification."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.webhook_config import WebhookConfig
from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse
from app.services.webhook_service import send_test_notification
from app.utils.auth import get_current_user
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
MASK = "***"
def _to_response(w: WebhookConfig) -> WebhookResponse:
return WebhookResponse(
id=w.id,
name=w.name,
type=w.type,
url=w.url,
secret_masked=MASK if w.secret else "",
enabled=w.enabled,
events=json.loads(w.events_json or "[]"),
created_at=w.created_at,
updated_at=w.updated_at,
)
@router.get("", response_model=List[WebhookResponse])
def list_webhooks(db: Session = Depends(get_db), _=Depends(get_current_user)):
return [_to_response(w) for w in db.query(WebhookConfig).order_by(WebhookConfig.id).all()]
@router.post("", response_model=WebhookResponse, status_code=201)
def create_webhook(
body: WebhookCreate,
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
w = WebhookConfig(
name=body.name,
type=body.type,
url=body.url,
secret=body.secret,
enabled=body.enabled,
events_json=json.dumps(body.events, ensure_ascii=False),
)
db.add(w)
db.commit()
db.refresh(w)
return _to_response(w)
@router.get("/{wid}", response_model=WebhookResponse)
def get_webhook(wid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first()
if not w:
raise HTTPException(404, "webhook not found")
return _to_response(w)
@router.put("/{wid}", response_model=WebhookResponse)
def update_webhook(
wid: int,
body: WebhookUpdate,
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first()
if not w:
raise HTTPException(404, "webhook not found")
data = body.model_dump(exclude_none=True)
if "events" in data:
w.events_json = json.dumps(data.pop("events"), ensure_ascii=False)
if "secret" in data:
if data["secret"] != MASK: # only update if not mask placeholder
w.secret = data.pop("secret")
else:
data.pop("secret")
for k, v in data.items():
setattr(w, k, v)
w.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(w)
return _to_response(w)
@router.delete("/{wid}", status_code=204)
def delete_webhook(wid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first()
if not w:
raise HTTPException(404, "webhook not found")
db.delete(w)
db.commit()
@router.post("/{wid}/test")
def test_webhook(wid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first()
if not w:
raise HTTPException(404, "webhook not found")
ok, msg = send_test_notification(db, w)
return {"success": ok, "message": msg}
+1
View File
@@ -0,0 +1 @@
# schemas package
+15
View File
@@ -0,0 +1,15 @@
from pydantic import BaseModel, EmailStr
class LoginRequest(BaseModel):
email: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserInfo(BaseModel):
email: str
+16
View File
@@ -0,0 +1,16 @@
from datetime import datetime
from typing import Optional, Any
from pydantic import BaseModel
class NotificationLogResponse(BaseModel):
id: int
webhook_config_id: int
webhook_name: str
event_type: str
payload: dict[str, Any]
status: str
response_text: Optional[str]
created_at: datetime
model_config = {"from_attributes": True}
+80
View File
@@ -0,0 +1,80 @@
from datetime import datetime
from typing import Optional, Any
from pydantic import BaseModel
class AuthConfigBearer(BaseModel):
token: str = ""
class AuthConfigApiKey(BaseModel):
key: str = ""
header: str = "Authorization"
class AuthConfigLoginPassword(BaseModel):
email: str = ""
password: str = ""
login_path: str = "/auth/login"
class UpstreamCreate(BaseModel):
name: str
base_url: str
api_prefix: str = "/api/v1"
auth_type: str = "login_password" # none | bearer | api_key | login_password
auth_config: dict[str, Any] = {}
rate_endpoint: str = "/groups/rates"
groups_endpoint: str = "/groups/available"
enabled: bool = True
check_interval_seconds: int = 600
timeout_seconds: int = 30
class UpstreamUpdate(BaseModel):
name: Optional[str] = None
base_url: Optional[str] = None
api_prefix: Optional[str] = None
auth_type: Optional[str] = None
auth_config: Optional[dict[str, Any]] = None
rate_endpoint: Optional[str] = None
groups_endpoint: Optional[str] = None
enabled: Optional[bool] = None
check_interval_seconds: Optional[int] = None
timeout_seconds: Optional[int] = None
class UpstreamResponse(BaseModel):
id: int
name: str
base_url: str
api_prefix: str
auth_type: str
auth_config_masked: dict[str, Any] # secrets replaced with ***
rate_endpoint: str
groups_endpoint: str
enabled: bool
check_interval_seconds: int
timeout_seconds: int
last_status: str
last_checked_at: Optional[datetime]
last_error: Optional[str]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class SnapshotResponse(BaseModel):
id: int
upstream_id: int
snapshot: dict[str, Any]
captured_at: datetime
model_config = {"from_attributes": True}
class TestResult(BaseModel):
success: bool
message: str
detail: Optional[str] = None
+37
View File
@@ -0,0 +1,37 @@
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel
ALLOWED_EVENTS = ["upstream_rate_changed", "upstream_unhealthy", "upstream_recovered"]
class WebhookCreate(BaseModel):
name: str
type: str = "generic" # generic | dingtalk
url: str
secret: str = ""
enabled: bool = True
events: List[str] = ["upstream_rate_changed"]
class WebhookUpdate(BaseModel):
name: Optional[str] = None
type: Optional[str] = None
url: Optional[str] = None
secret: Optional[str] = None
enabled: Optional[bool] = None
events: Optional[List[str]] = None
class WebhookResponse(BaseModel):
id: int
name: str
type: str
url: str
secret_masked: str # always "***" if set, else ""
enabled: bool
events: List[str]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+1
View File
@@ -0,0 +1 @@
# services package
+156
View File
@@ -0,0 +1,156 @@
"""APScheduler background scheduler for upstream checks."""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy.orm import Session
from app.database import SessionLocal
from app.models.upstream import Upstream
from app.models.snapshot import UpstreamRateSnapshot
from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot
from app.services.snapshot_service import diff_snapshots
from app.services import webhook_service
from app.config import get_settings
logger = logging.getLogger(__name__)
_scheduler = BackgroundScheduler(timezone="UTC")
def get_scheduler() -> BackgroundScheduler:
return _scheduler
def _check_upstream(upstream_id: int) -> None:
"""Full upstream check executed by scheduler (runs in thread)."""
settings = get_settings()
db: Session = SessionLocal()
try:
upstream = db.query(Upstream).filter(Upstream.id == upstream_id).first()
if not upstream or not upstream.enabled:
_remove_job(upstream_id)
return
auth_config = json.loads(upstream.auth_config_json or "{}")
client = UpstreamClient(
base_url=upstream.base_url,
api_prefix=upstream.api_prefix,
auth_type=upstream.auth_type,
auth_config=auth_config,
timeout=float(upstream.timeout_seconds),
)
was_unhealthy = upstream.last_status == "unhealthy"
try:
client.login()
groups = client.get_available_groups(upstream.groups_endpoint)
raw_rates = client.get_group_rates(upstream.rate_endpoint)
snapshot = build_snapshot(
upstream.id, upstream.base_url, upstream.api_prefix, groups, raw_rates
)
except Exception as exc:
# failure path
upstream.consecutive_failures = (upstream.consecutive_failures or 0) + 1
upstream.last_error = str(exc)
upstream.last_checked_at = datetime.now(timezone.utc)
threshold = settings.unhealthy_threshold
if upstream.consecutive_failures >= threshold and upstream.last_status != "unhealthy":
upstream.last_status = "unhealthy"
db.commit()
webhook_service.send_status_event(
db, upstream.id, upstream.name, upstream.base_url,
"upstream_unhealthy", str(exc)
)
else:
db.commit()
logger.warning("upstream %s check failed: %s", upstream.name, exc)
return
# success path
prev_snapshot_row = (
db.query(UpstreamRateSnapshot)
.filter(UpstreamRateSnapshot.upstream_id == upstream_id)
.order_by(UpstreamRateSnapshot.captured_at.desc())
.first()
)
previous = json.loads(prev_snapshot_row.snapshot_json) if prev_snapshot_row else None
changes = diff_snapshots(previous, snapshot)
# save new snapshot
new_row = UpstreamRateSnapshot(
upstream_id=upstream_id,
snapshot_json=json.dumps(snapshot, ensure_ascii=False),
captured_at=datetime.now(timezone.utc),
)
db.add(new_row)
# update upstream status
upstream.last_status = "healthy"
upstream.last_checked_at = datetime.now(timezone.utc)
upstream.last_error = None
upstream.consecutive_failures = 0
db.commit()
if was_unhealthy:
webhook_service.send_status_event(
db, upstream.id, upstream.name, upstream.base_url, "upstream_recovered"
)
if changes:
webhook_service.send_rate_changed(
db, upstream.id, upstream.name, upstream.base_url, changes
)
logger.info("upstream %s: %d rate change(s)", upstream.name, len(changes))
else:
logger.debug("upstream %s: no changes", upstream.name)
finally:
db.close()
def _remove_job(upstream_id: int) -> None:
job_id = f"upstream_{upstream_id}"
if _scheduler.get_job(job_id):
_scheduler.remove_job(job_id)
def refresh_upstream(upstream_id: int, interval_seconds: int = 0, enabled: bool = True) -> None:
"""Add/update/remove a scheduler job for the given upstream."""
job_id = f"upstream_{upstream_id}"
if not enabled or interval_seconds <= 0:
_remove_job(upstream_id)
return
_scheduler.add_job(
_check_upstream,
"interval",
seconds=interval_seconds,
id=job_id,
args=[upstream_id],
replace_existing=True,
coalesce=True,
max_instances=1,
)
logger.info("scheduler job %s set to %ds interval", job_id, interval_seconds)
def start_scheduler() -> None:
"""Start scheduler and load all enabled upstreams."""
_scheduler.start()
db: Session = SessionLocal()
try:
upstreams = db.query(Upstream).filter(Upstream.enabled == True).all()
for u in upstreams:
refresh_upstream(u.id, u.check_interval_seconds, u.enabled)
logger.info("scheduler started with %d upstream job(s)", len(upstreams))
finally:
db.close()
def stop_scheduler() -> None:
if _scheduler.running:
_scheduler.shutdown(wait=False)
+39
View File
@@ -0,0 +1,39 @@
"""Snapshot diff logic."""
from typing import Any, Optional
def diff_snapshots(
previous: Optional[dict[str, Any]],
current: dict[str, Any],
) -> list[dict[str, Any]]:
"""Return list of rate changes between previous and current snapshots.
Returns empty list if previous is None (first check)."""
if not previous:
return []
old_groups: dict[str, Any] = previous.get("groups") or {}
new_groups: dict[str, Any] = current.get("groups") or {}
changes: list[dict[str, Any]] = []
for gid, new_g in sorted(new_groups.items()):
if not isinstance(new_g, dict):
continue
old_g = old_groups.get(gid)
old_rate = old_g.get("rate") if isinstance(old_g, dict) else None
new_rate = new_g.get("rate")
if old_rate != new_rate:
changes.append({
"group_id": gid,
"group_name": new_g.get("group_name", ""),
"platform": new_g.get("platform", ""),
"old_rate": old_rate,
"new_rate": new_rate,
})
for gid, old_g in sorted(old_groups.items()):
if gid not in new_groups and isinstance(old_g, dict):
changes.append({
"group_id": gid,
"group_name": old_g.get("group_name", ""),
"platform": old_g.get("platform", ""),
"old_rate": old_g.get("rate"),
"new_rate": None,
})
return changes
+217
View File
@@ -0,0 +1,217 @@
"""Upstream HTTP client — ported from monitor_ai98pro_group_rates.py."""
from __future__ import annotations
import json
from decimal import Decimal, InvalidOperation
from typing import Any, Optional
from urllib.parse import urljoin
import httpx
class UpstreamError(RuntimeError):
pass
def _find_token(value: Any) -> str:
if isinstance(value, str) and value.count(".") >= 2:
return value
if isinstance(value, dict):
for key in ("token", "access_token", "accessToken", "jwt", "auth_token", "authToken"):
candidate = value.get(key)
if isinstance(candidate, str) and candidate:
return candidate
for key in ("data", "result", "user", "session"):
tok = _find_token(value.get(key))
if tok:
return tok
return ""
def _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]:
if isinstance(value, list):
return [i for i in value if isinstance(i, dict)]
if isinstance(value, dict):
for key in ("data", "items", "groups", "available_groups", "availableGroups"):
nested = value.get(key)
if isinstance(nested, list):
return [i for i in nested if isinstance(i, dict)]
return None
def _decimal_str(value: Any) -> str:
if value is None or value == "":
return ""
try:
d = Decimal(str(value))
except (InvalidOperation, ValueError):
return str(value)
n = d.normalize()
if n == n.to_integral():
return str(n.quantize(Decimal("1")))
return format(n, "f")
def _group_id(group: dict[str, Any]) -> str:
for key in ("id", "group_id", "groupId"):
v = group.get(key)
if v is not None:
return str(v)
name = str(group.get("name") or group.get("group_name") or "")
platform = str(group.get("platform") or "")
return f"{platform}:{name}"
def _rate_from_group(group: dict[str, Any]) -> str:
for key in (
"user_rate_multiplier", "userRateMultiplier",
"effective_rate_multiplier", "effectiveRateMultiplier",
"rate_multiplier", "rateMultiplier",
):
r = _decimal_str(group.get(key))
if r:
return r
return ""
def _extract_rates_map(raw: Any) -> dict[str, str]:
if raw is None:
return {}
if isinstance(raw, dict):
candidates = raw
for key in ("data", "rates", "group_rates", "groupRates"):
nested = raw.get(key)
if isinstance(nested, dict):
candidates = nested
break
result: dict[str, str] = {}
for k, v in candidates.items():
if isinstance(v, dict):
r = _decimal_str(
v.get("rate_multiplier") or v.get("rateMultiplier")
or v.get("user_rate_multiplier") or v.get("userRateMultiplier")
)
else:
r = _decimal_str(v)
if r:
result[str(k)] = r
return result
if isinstance(raw, list):
result = {}
for item in raw:
if not isinstance(item, dict):
continue
gid = _group_id(item)
rate = _rate_from_group(item)
if gid and rate:
result[gid] = rate
return result
return {}
def build_snapshot(upstream_id: int, base_url: str, api_prefix: str,
groups: list[dict[str, Any]], raw_rates: Any) -> dict[str, Any]:
from datetime import datetime, timezone
override_rates = _extract_rates_map(raw_rates)
entries: dict[str, dict[str, Any]] = {}
for g in groups:
gid = _group_id(g)
default_rate = _rate_from_group(g)
effective_rate = override_rates.get(gid, default_rate)
entries[gid] = {
"group_id": gid,
"group_name": g.get("name") or g.get("group_name") or "",
"platform": g.get("platform") or "",
"rate": effective_rate,
"default_rate": default_rate,
"override_rate": override_rates.get(gid, ""),
}
return {
"upstream_id": upstream_id,
"base_url": base_url.rstrip("/"),
"api_prefix": api_prefix,
"captured_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"groups": entries,
}
class UpstreamClient:
"""Sync HTTP client that handles all auth types."""
def __init__(
self,
base_url: str,
api_prefix: str,
auth_type: str,
auth_config: dict[str, Any],
timeout: float = 30.0,
) -> None:
self.base_url = base_url.rstrip("/")
self.api_prefix = api_prefix.strip("/")
self.auth_type = auth_type
self.auth_config = auth_config
self.timeout = timeout
self._token: str = ""
def _url(self, path: str) -> str:
prefix = f"/{self.api_prefix}" if self.api_prefix else ""
return f"{self.base_url}{prefix}/{path.lstrip('/')}"
def _headers(self, auth: bool = True) -> dict[str, str]:
headers: dict[str, str] = {
"Accept": "application/json",
"User-Agent": "SmartUp/1.0",
}
if not auth:
return headers
if self.auth_type == "bearer":
token = self.auth_config.get("token", "")
if token:
headers["Authorization"] = f"Bearer {token}"
elif self.auth_type == "api_key":
key = self.auth_config.get("key", "")
header = self.auth_config.get("header", "Authorization")
if key:
headers[header] = key
elif self.auth_type == "login_password" and self._token:
headers["Authorization"] = f"Bearer {self._token}"
return headers
def _request(self, method: str, path: str, body: Any = None, auth: bool = True) -> Any:
url = self._url(path)
with httpx.Client(timeout=self.timeout) as client:
if body is not None:
resp = client.request(method, url, json=body, headers=self._headers(auth))
else:
resp = client.request(method, url, headers=self._headers(auth))
resp.raise_for_status()
ct = resp.headers.get("content-type", "")
if not resp.content:
return None
text = resp.text
if "application/json" not in ct and text.lstrip().startswith("<"):
raise UpstreamError(f"{method} {path} returned HTML, not JSON")
return resp.json()
def login(self) -> None:
if self.auth_type != "login_password":
return
email = self.auth_config.get("email", "")
password = self.auth_config.get("password", "")
login_path = self.auth_config.get("login_path", "/auth/login")
if not email or not password:
raise UpstreamError("login_password auth requires email and password in auth_config")
resp = self._request("POST", login_path, {"email": email, "password": password}, auth=False)
token = _find_token(resp)
if not token:
raise UpstreamError("login succeeded but no token found in response")
self._token = token
def get_available_groups(self, endpoint: str) -> list[dict[str, Any]]:
resp = self._request("GET", endpoint)
groups = _unwrap_list(resp)
if groups is None:
raise UpstreamError(f"{endpoint} did not return a list")
return groups
def get_group_rates(self, endpoint: str) -> Any:
return self._request("GET", endpoint)
+158
View File
@@ -0,0 +1,158 @@
"""Send webhook notifications and write notification logs."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
import httpx
from sqlalchemy.orm import Session
from app.models.webhook_config import WebhookConfig
from app.models.notification_log import NotificationLog
from app.utils.dingtalk import (
dingtalk_signed_url,
format_dingtalk_rate_changed,
format_dingtalk_status,
)
def _now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def _log(
db: Session,
webhook: WebhookConfig,
event_type: str,
payload: dict[str, Any],
status: str,
response_text: str,
) -> None:
entry = NotificationLog(
webhook_config_id=webhook.id,
webhook_name=webhook.name,
event_type=event_type,
payload_json=json.dumps(payload, ensure_ascii=False),
status=status,
response_text=response_text[:2000] if response_text else None,
)
db.add(entry)
db.commit()
def _send_generic(url: str, payload: dict[str, Any], timeout: float = 15.0) -> str:
resp = httpx.post(
url,
json=payload,
headers={"Content-Type": "application/json", "User-Agent": "SmartUp/1.0"},
timeout=timeout,
)
resp.raise_for_status()
return resp.text[:500]
def _send_dingtalk(url: str, secret: str, payload: dict[str, Any], timeout: float = 15.0) -> str:
signed = dingtalk_signed_url(url, secret) if secret else url
resp = httpx.post(
signed,
json=payload,
headers={"Content-Type": "application/json", "User-Agent": "SmartUp/1.0"},
timeout=timeout,
)
resp.raise_for_status()
result = resp.json()
if result.get("errcode", 0) != 0:
raise RuntimeError(f"DingTalk error: {resp.text[:300]}")
return resp.text[:500]
def send_rate_changed(
db: Session,
upstream_id: int,
upstream_name: str,
base_url: str,
changes: list[dict[str, Any]],
) -> None:
webhooks = (
db.query(WebhookConfig)
.filter(WebhookConfig.enabled == True)
.all()
)
changed_at = _now_iso()
generic_payload = {
"event": "upstream_rate_changed",
"upstream": {"id": upstream_id, "name": upstream_name, "base_url": base_url},
"changed_at": changed_at,
"changes": changes,
}
for wh in webhooks:
events = json.loads(wh.events_json or "[]")
if "upstream_rate_changed" not in events:
continue
try:
if wh.type == "dingtalk":
msg = format_dingtalk_rate_changed(upstream_name, changed_at, changes)
resp_text = _send_dingtalk(wh.url, wh.secret, msg)
else:
resp_text = _send_generic(wh.url, generic_payload)
_log(db, wh, "upstream_rate_changed", generic_payload, "success", resp_text)
except Exception as exc:
_log(db, wh, "upstream_rate_changed", generic_payload, "failed", str(exc))
def send_status_event(
db: Session,
upstream_id: int,
upstream_name: str,
base_url: str,
event: str,
error: str = "",
) -> None:
webhooks = (
db.query(WebhookConfig)
.filter(WebhookConfig.enabled == True)
.all()
)
changed_at = _now_iso()
generic_payload = {
"event": event,
"upstream": {"id": upstream_id, "name": upstream_name, "base_url": base_url},
"changed_at": changed_at,
"error": error,
}
for wh in webhooks:
events = json.loads(wh.events_json or "[]")
if event not in events:
continue
try:
if wh.type == "dingtalk":
msg = format_dingtalk_status(upstream_name, event, changed_at, error)
resp_text = _send_dingtalk(wh.url, wh.secret, msg)
else:
resp_text = _send_generic(wh.url, generic_payload)
_log(db, wh, event, generic_payload, "success", resp_text)
except Exception as exc:
_log(db, wh, event, generic_payload, "failed", str(exc))
def send_test_notification(db: Session, webhook: WebhookConfig) -> tuple[bool, str]:
payload = {
"event": "test",
"message": "SmartUp webhook test notification",
"sent_at": _now_iso(),
}
try:
if webhook.type == "dingtalk":
msg = {
"msgtype": "text",
"text": {"content": "✅ SmartUp webhook 测试通知\n配置正常,连接成功。"},
}
resp_text = _send_dingtalk(webhook.url, webhook.secret, msg)
else:
resp_text = _send_generic(webhook.url, payload)
_log(db, webhook, "test", payload, "success", resp_text)
return True, "发送成功"
except Exception as exc:
_log(db, webhook, "test", payload, "failed", str(exc))
return False, str(exc)
+1
View File
@@ -0,0 +1 @@
# utils package
+74
View File
@@ -0,0 +1,74 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
import bcrypt
from fastapi import Depends, HTTPException, Query, Request, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.config import get_settings
from app.database import get_db
from app.models.admin_user import AdminUser
ALGORITHM = "HS256"
bearer_scheme = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
pw = password.encode("utf-8")[:72]
return bcrypt.hashpw(pw, bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
pw = plain.encode("utf-8")[:72]
return bcrypt.checkpw(pw, hashed.encode("utf-8"))
def create_access_token(email: str, expires_hours: Optional[int] = None) -> str:
settings = get_settings()
hours = expires_hours or settings.jwt_expire_hours
expire = datetime.now(timezone.utc) + timedelta(hours=hours)
data = {"sub": email, "exp": expire}
return jwt.encode(data, settings.jwt_secret, algorithm=ALGORITHM)
def decode_token(token: str) -> Optional[str]:
settings = get_settings()
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
return payload.get("sub")
except JWTError:
return None
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> AdminUser:
token = credentials.credentials if credentials else None
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
email = decode_token(token)
if not email:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.query(AdminUser).filter(AdminUser.email == email).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
def get_user_from_token_param(
token: Optional[str] = Query(default=None),
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> AdminUser:
"""Accept JWT from ?token= query param (for iframe src) OR Authorization header."""
raw = token or (credentials.credentials if credentials else None)
if not raw:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
email = decode_token(raw)
if not email:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.query(AdminUser).filter(AdminUser.email == email).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
+59
View File
@@ -0,0 +1,59 @@
"""DingTalk webhook signing and message formatting (ported from monitor script)."""
import base64
import hashlib
import hmac
import json
import time
from typing import Any
from urllib.parse import quote_plus
def dingtalk_signed_url(webhook_url: str, secret: str) -> str:
timestamp = str(int(time.time() * 1000))
string_to_sign = f"{timestamp}\n{secret}".encode("utf-8")
digest = hmac.new(secret.encode("utf-8"), string_to_sign, hashlib.sha256).digest()
sign = quote_plus(base64.b64encode(digest).decode("utf-8"))
sep = "&" if "?" in webhook_url else "?"
return f"{webhook_url}{sep}timestamp={timestamp}&sign={sign}"
def format_dingtalk_rate_changed(upstream_name: str, changed_at: str, changes: list[dict[str, Any]]) -> dict[str, Any]:
lines = [
f"### 📊 {upstream_name} 分组倍率变更",
"",
f"- **时间**{changed_at}",
f"- **变化数量**{len(changes)}",
"",
]
for ch in changes:
name = ch.get("group_name") or ch.get("group_id") or "unknown"
platform = ch.get("platform") or "-"
old = ch.get("old_rate")
new = ch.get("new_rate")
lines.append(f"- `{name}` ({platform})`{old}` → `{new}`")
return {
"msgtype": "markdown",
"markdown": {
"title": f"{upstream_name} 分组倍率变更",
"text": "\n".join(lines),
},
}
def format_dingtalk_status(upstream_name: str, event: str, changed_at: str, error: str = "") -> dict[str, Any]:
emoji = "🔴" if event == "upstream_unhealthy" else "🟢"
label = "服务异常" if event == "upstream_unhealthy" else "服务恢复"
lines = [
f"### {emoji} {upstream_name} {label}",
"",
f"- **时间**{changed_at}",
]
if error:
lines.append(f"- **错误**{error}")
return {
"msgtype": "markdown",
"markdown": {
"title": f"{upstream_name} {label}",
"text": "\n".join(lines),
},
}