feat: 综合平台services多服务支持+SW网络优先缓存修复+适配层/定时任务完善
This commit is contained in:
+35
-5
@@ -100,11 +100,41 @@ class OpenAIAdapter(_AIBase):
|
||||
|
||||
@register("minimax-api")
|
||||
class MinimaxAdapter(_AIBase):
|
||||
# Minimax 需 group_id,余额接口因账号类型而异,此处为骨架待完善
|
||||
required_config = ["api_key", "group_id"]
|
||||
"""Minimax 适配器(余额查询)
|
||||
|
||||
接口:GET https://api.minimax.chat/v1/balance
|
||||
认证:Bearer api_key(group_id 仅部分旧接口需要,余额查询非必需)
|
||||
响应示例:{"balance": 123.45, "currency": "CNY", ...}
|
||||
注:Minimax 国内版端点为 api.minimaxi.com,国际版为 api.minimax.chat,
|
||||
两者 API Key 不通用;默认使用国际版端点,可通过 config["base_url"] 覆盖。
|
||||
"""
|
||||
|
||||
required_config = ["api_key"]
|
||||
BASE = "https://api.minimax.chat/v1"
|
||||
|
||||
def _base_url(self) -> str:
|
||||
return (self.config.get("base_url") or self.BASE).rstrip("/")
|
||||
|
||||
def _balance(self) -> AccountInfo:
|
||||
data = self._get(self._base_url() + "/balance")
|
||||
balance = data.get("balance")
|
||||
try:
|
||||
balance = float(balance) if balance is not None else None
|
||||
except (TypeError, ValueError):
|
||||
balance = None
|
||||
currency = data.get("currency") or "CNY"
|
||||
return AccountInfo(balance=balance, currency=currency, raw=data)
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
if not self.config.get("group_id"):
|
||||
return {"ok": False, "message": "Minimax 需配置 group_id(适配器余额接口待完善)"}
|
||||
return {"ok": False, "message": "Minimax 适配器余额接口待完善(请提供具体接口文档)"}
|
||||
try:
|
||||
acc = self._balance()
|
||||
if acc.balance is not None:
|
||||
return {"ok": True, "message": f"连接成功,余额 {acc.balance} {acc.currency}"}
|
||||
return {"ok": True, "message": "连接成功(未返回余额字段)"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return self._http_error(e)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
def get_account(self) -> AccountInfo:
|
||||
return self._balance()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Cloudflare 适配器(REST API v4,Bearer Token)
|
||||
|
||||
API 文档:https://developers.cloudflare.com/api/
|
||||
所需配置:{"api_token": "..."}(建议用 API Token,权限含 Zone:Read / Account:Read)
|
||||
所需配置:{"api_token": "..."}(建议用 API Token,权限含 Zone:Read / Account:Read / Registrar:Read)
|
||||
Cloudflare 无传统 VPS,主要同步托管域名(zones);Workers/R2/Tunnel 子资产留待后续阶段。
|
||||
域名到期日通过 Registrar API(/accounts/{id}/registrar/domains)补充获取。
|
||||
"""
|
||||
|
||||
import httpx
|
||||
@@ -42,16 +43,21 @@ class CloudflareAdapter(BaseAdapter):
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
def list_domains(self) -> list:
|
||||
# 先拉取 Registrar 域名到期日映射(domain_name -> expires_at ISO 日期)
|
||||
expiry_map = self._registrar_expiry_map()
|
||||
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._get(f"/zones?per_page=50&page={page}")
|
||||
for z in data.get("result", []):
|
||||
name = z.get("name")
|
||||
result.append(
|
||||
NormalizedDomain(
|
||||
external_id=z.get("id"),
|
||||
domain_name=z.get("name"),
|
||||
domain_name=name,
|
||||
registrar="cloudflare",
|
||||
expiry_date=expiry_map.get(name),
|
||||
status="active" if z.get("status") == "active" else (z.get("status") or "unknown"),
|
||||
raw=z,
|
||||
)
|
||||
@@ -61,3 +67,31 @@ class CloudflareAdapter(BaseAdapter):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
def _registrar_expiry_map(self) -> dict:
|
||||
"""拉取 Cloudflare Registrar 域名到期日映射 {domain_name: YYYY-MM-DD}
|
||||
|
||||
流程:/accounts → 对每个 account 调 /accounts/{id}/registrar/domains。
|
||||
若 Token 无 Registrar 权限或账号无 Registrar 域名,静默返回空映射。
|
||||
"""
|
||||
expiry = {}
|
||||
try:
|
||||
accounts = self._get("/accounts?per_page=50").get("result", [])
|
||||
except Exception: # noqa: BLE001
|
||||
return expiry
|
||||
for acc in accounts:
|
||||
acc_id = acc.get("id")
|
||||
if not acc_id:
|
||||
continue
|
||||
try:
|
||||
domains = self._get(f"/accounts/{acc_id}/registrar/domains").get("result", [])
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for d in domains:
|
||||
# Registrar API 返回的 id 是域名本身(如 example.com)
|
||||
name = d.get("id") or d.get("domain_name") or ""
|
||||
expires_at = d.get("expires_at")
|
||||
if name and expires_at:
|
||||
# expires_at 为 ISO8601 时间戳,截取日期部分
|
||||
expiry[name] = expires_at[:10]
|
||||
return expiry
|
||||
|
||||
@@ -39,5 +39,25 @@ class Settings:
|
||||
# ---- 续费提醒 ----
|
||||
RENEWAL_THRESHOLD_DAYS: int = int(os.getenv("RENEWAL_THRESHOLD_DAYS", "30"))
|
||||
|
||||
# ---- 监控数据保留策略(自动清理)----
|
||||
# MetricPoint 时序数据保留天数(Agent 高频上报,默认 30 天)
|
||||
METRICS_RETENTION_DAYS: int = int(os.getenv("METRICS_RETENTION_DAYS", "30"))
|
||||
# SecurityCheck 安全检查历史保留天数(默认 90 天)
|
||||
SECURITY_RETENTION_DAYS: int = int(os.getenv("SECURITY_RETENTION_DAYS", "90"))
|
||||
# EventLog 事件日志保留天数(默认 180 天)
|
||||
EVENT_LOG_RETENTION_DAYS: int = int(os.getenv("EVENT_LOG_RETENTION_DAYS", "180"))
|
||||
# 自动清理间隔(小时),0 表示禁用后台自动清理
|
||||
CLEANUP_INTERVAL_HOURS: int = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24"))
|
||||
|
||||
# ---- Agent 上报频率限制 ----
|
||||
# 同一 asset_id 两次上报的最小间隔(秒),0 表示不限制
|
||||
AGENT_REPORT_MIN_INTERVAL: int = int(os.getenv("AGENT_REPORT_MIN_INTERVAL", "30"))
|
||||
|
||||
# ---- CORS 允许来源(逗号分隔),默认本地 + Tailscale ----
|
||||
CORS_ORIGINS: str = os.getenv(
|
||||
"CORS_ORIGINS",
|
||||
"http://127.0.0.1:8000,http://localhost:8000,https://dify.taile5765c.ts.net",
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
+3
-1
@@ -4,6 +4,7 @@
|
||||
MASTER_KEY 从 .env 读取,不入库。
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
@@ -11,8 +12,9 @@ from cryptography.fernet import Fernet, InvalidToken
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_fernet() -> Fernet:
|
||||
"""获取 Fernet 实例(MASTER_KEY 未配置时抛错)"""
|
||||
"""获取 Fernet 实例(进程内缓存,避免每次加解密重建;MASTER_KEY 未配置时抛错)"""
|
||||
key = settings.MASTER_KEY
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
|
||||
+27
-6
@@ -10,57 +10,78 @@ from app.models.provider import Provider
|
||||
|
||||
PRESET_PROVIDERS = [
|
||||
# ---- VPS / 云服务商 ----
|
||||
# 综合平台:services 列出可提供的服务,卡片上会显示多标签
|
||||
{"slug": "aliyun", "name": "阿里云", "name_en": "Aliyun", "category": "vps",
|
||||
"services": "vps,domain,ssl_cert,cdn,dns",
|
||||
"website": "https://aliyun.com", "console_url": "https://ecs.console.aliyun.com", "sdk_type": "aliyun-sdk"},
|
||||
{"slug": "tencent", "name": "腾讯云", "name_en": "Tencent Cloud", "category": "vps",
|
||||
"services": "vps,domain,ssl_cert,cdn,dns",
|
||||
"website": "https://cloud.tencent.com", "console_url": "https://console.cloud.tencent.com/cvm", "sdk_type": "tencent-sdk"},
|
||||
{"slug": "aliyun-intl", "name": "阿里国际", "name_en": "Alibaba Cloud", "category": "vps",
|
||||
"services": "vps,domain,ssl_cert,cdn,dns",
|
||||
"website": "https://alibabacloud.com", "console_url": "https://ecs.console.alibabacloud.com", "sdk_type": "alibabacloud-sdk"},
|
||||
{"slug": "tencent-intl", "name": "腾讯国际", "name_en": "Tencent Cloud Intl", "category": "vps",
|
||||
"services": "vps,domain,ssl_cert,cdn,dns",
|
||||
"website": "https://intl.cloud.tencent.com", "console_url": "https://console.intl.cloud.tencent.com", "sdk_type": "tencent-intl-sdk"},
|
||||
{"slug": "vultr", "name": "Vultr", "name_en": "Vultr", "category": "vps",
|
||||
"services": "vps",
|
||||
"website": "https://vultr.com", "console_url": "https://my.vultr.com", "sdk_type": "vultr-api"},
|
||||
{"slug": "digitalocean", "name": "DigitalOcean", "name_en": "DigitalOcean", "category": "vps",
|
||||
"services": "vps,domain",
|
||||
"website": "https://digitalocean.com", "console_url": "https://cloud.digitalocean.com", "sdk_type": "do-api"},
|
||||
{"slug": "linode", "name": "Linode", "name_en": "Akamai Linode", "category": "vps",
|
||||
"services": "vps",
|
||||
"website": "https://linode.com", "console_url": "https://cloud.linode.com", "sdk_type": "linode-api"},
|
||||
{"slug": "cloudcone", "name": "CloudCone", "name_en": "CloudCone", "category": "vps",
|
||||
"services": "vps",
|
||||
"website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": "cloudcone-api"},
|
||||
{"slug": "zeabur", "name": "Zeabur", "name_en": "Zeabur", "category": "vps",
|
||||
"services": "vps,domain",
|
||||
"website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": "zeabur-api"},
|
||||
# ---- 域名注册商 ----
|
||||
{"slug": "namesilo", "name": "Namesilo", "name_en": "Namesilo", "category": "domain",
|
||||
"services": "domain,dns",
|
||||
"website": "https://namesilo.com", "console_url": "https://www.namesilo.com/account_domains.php", "sdk_type": "namesilo-api"},
|
||||
{"slug": "xinwang", "name": "新网", "name_en": "Xinnet", "category": "domain",
|
||||
"services": "domain,dns",
|
||||
"website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": None},
|
||||
{"slug": "dynadot", "name": "Dynadot", "name_en": "Dynadot", "category": "domain",
|
||||
"services": "domain,dns",
|
||||
"website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": "dynadot-api"},
|
||||
# ---- AI 服务商 ----
|
||||
{"slug": "openai", "name": "OpenAI", "name_en": "OpenAI", "category": "ai_agent",
|
||||
"services": "ai_agent",
|
||||
"website": "https://openai.com", "console_url": "https://platform.openai.com", "sdk_type": "openai-api"},
|
||||
{"slug": "minimax", "name": "Minimax", "name_en": "Minimax", "category": "ai_agent",
|
||||
"services": "ai_agent",
|
||||
"website": "https://minimax.io", "console_url": "https://platform.minimaxi.com", "sdk_type": "minimax-api"},
|
||||
{"slug": "kimi", "name": "Kimi", "name_en": "Moonshot", "category": "ai_agent",
|
||||
"services": "ai_agent",
|
||||
"website": "https://moonshot.cn", "console_url": "https://platform.moonshot.cn", "sdk_type": "moonshot-api"},
|
||||
{"slug": "agnes", "name": "Agnes", "name_en": "Agnes", "category": "ai_agent",
|
||||
"services": "ai_agent",
|
||||
"website": None, "console_url": None, "sdk_type": None},
|
||||
{"slug": "deepseek", "name": "DeepSeek", "name_en": "DeepSeek", "category": "ai_agent",
|
||||
"services": "ai_agent",
|
||||
"website": "https://deepseek.com", "console_url": "https://platform.deepseek.com", "sdk_type": "deepseek-api"},
|
||||
# ---- Cloudflare ----
|
||||
{"slug": "cloudflare", "name": "Cloudflare", "name_en": "Cloudflare", "category": "cloudflare",
|
||||
"services": "cloudflare,dns,ssl_cert,cdn",
|
||||
"website": "https://cloudflare.com", "console_url": "https://dash.cloudflare.com", "sdk_type": "cloudflare-api"},
|
||||
]
|
||||
|
||||
|
||||
def seed_providers(session: Session) -> int:
|
||||
"""初始化预设平台(已存在的 slug 跳过),返回新增数量"""
|
||||
"""初始化预设平台(已存在的 slug 跳过),返回新增数量
|
||||
|
||||
单次查询获取全部已存在 slug,避免逐条 SELECT 的 N 次往返。
|
||||
"""
|
||||
existing_slugs = set(session.exec(select(Provider.slug)).all())
|
||||
added = 0
|
||||
for data in PRESET_PROVIDERS:
|
||||
existing = session.exec(
|
||||
select(Provider).where(Provider.slug == data["slug"])
|
||||
).first()
|
||||
if not existing:
|
||||
if data["slug"] not in existing_slugs:
|
||||
session.add(Provider(**data))
|
||||
added += 1
|
||||
session.commit()
|
||||
if added:
|
||||
session.commit()
|
||||
return added
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""UTC 时间工具(替代已弃用的 datetime.utcnow)
|
||||
|
||||
Python 3.12+ 起 datetime.utcnow() 被标记为弃用,
|
||||
统一使用 datetime.now(timezone.utc) 的快捷封装。
|
||||
注意:SQLite 不保存时区信息,为兼容既有数据与比较逻辑,
|
||||
默认返回 naive UTC 时间(与 utcnow 行为一致,但来源非弃用 API)。
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""返回当前 UTC 时间(naive,与 datetime.utcnow() 行为一致)"""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def utcnow_iso() -> str:
|
||||
"""返回当前 UTC 时间的 ISO 格式字符串(带 Z 后缀标识 UTC)"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
+80
-3
@@ -17,12 +17,30 @@ ASSETS_DB_URL = f"sqlite:///{DATA_DIR / 'assets.db'}"
|
||||
METRICS_DB_URL = f"sqlite:///{DATA_DIR / 'metrics.db'}"
|
||||
|
||||
assets_engine = create_engine(
|
||||
ASSETS_DB_URL, echo=False, connect_args={"check_same_thread": False}
|
||||
ASSETS_DB_URL,
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
metrics_engine = create_engine(
|
||||
METRICS_DB_URL, echo=False, connect_args={"check_same_thread": False}
|
||||
METRICS_DB_URL,
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
|
||||
def _enable_wal(engine) -> None:
|
||||
"""启用 WAL 模式,提升 SQLite 并发读写能力"""
|
||||
import sqlalchemy as sa
|
||||
with engine.connect() as conn:
|
||||
conn.execute(sa.text("PRAGMA journal_mode=WAL"))
|
||||
conn.execute(sa.text("PRAGMA busy_timeout=5000"))
|
||||
|
||||
|
||||
_enable_wal(assets_engine)
|
||||
_enable_wal(metrics_engine)
|
||||
|
||||
# 兼容旧代码:默认 engine 指向资产库
|
||||
engine = assets_engine
|
||||
|
||||
@@ -37,8 +55,9 @@ def init_db() -> None:
|
||||
ServerInfo,
|
||||
)
|
||||
from app.models.provider import Provider # noqa: F401
|
||||
from app.models.ssl import SiteCert, Subdomain # noqa: F401
|
||||
|
||||
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail]
|
||||
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert]
|
||||
metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
|
||||
|
||||
SQLModel.metadata.create_all(
|
||||
@@ -48,6 +67,7 @@ def init_db() -> None:
|
||||
metrics_engine, tables=[m.__table__ for m in metric_models]
|
||||
)
|
||||
_migrate_assets_db()
|
||||
_migrate_indexes()
|
||||
|
||||
|
||||
def _migrate_assets_db() -> None:
|
||||
@@ -68,10 +88,67 @@ def _migrate_assets_db() -> None:
|
||||
cols = {c["name"] for c in insp.get_columns("providers")}
|
||||
if "last_synced_at" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE providers ADD COLUMN last_synced_at DATETIME"))
|
||||
if "services" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE providers ADD COLUMN services VARCHAR DEFAULT ''"))
|
||||
# 为已有预设平台补充 services(仅补空值,用户自定义行不动)
|
||||
_backfill_provider_services(conn)
|
||||
if insp.has_table("ai_accounts"):
|
||||
cols = {c["name"] for c in insp.get_columns("ai_accounts")}
|
||||
if "api_key_encrypted" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE ai_accounts ADD COLUMN api_key_encrypted VARCHAR"))
|
||||
# 迁移:将明文 api_key 加密后存入 api_key_encrypted,并清空原字段
|
||||
_migrate_plaintext_api_keys(conn)
|
||||
|
||||
|
||||
def _migrate_indexes() -> None:
|
||||
"""为已有数据库补充复合索引(create_all 不会为已存在的表补索引,IF NOT EXISTS 幂等)"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
stmts = [
|
||||
(assets_engine, "CREATE INDEX IF NOT EXISTS ix_assets_provider_ext_type ON assets (provider_id, external_id, asset_type)"),
|
||||
(metrics_engine, "CREATE INDEX IF NOT EXISTS ix_metric_points_asset_ts ON metric_points (asset_id, ts)"),
|
||||
(metrics_engine, "CREATE INDEX IF NOT EXISTS ix_security_checks_asset_ts ON security_checks (asset_id, ts)"),
|
||||
]
|
||||
for eng, sql in stmts:
|
||||
with eng.begin() as conn:
|
||||
conn.execute(sa.text(sql))
|
||||
|
||||
|
||||
def _migrate_plaintext_api_keys(conn) -> None:
|
||||
"""一次性迁移:将 ai_accounts 中残留的明文 api_key 加密后存入 api_key_encrypted,并清空原字段"""
|
||||
import sqlalchemy as sa
|
||||
from app.core.crypto import encrypt
|
||||
|
||||
rows = conn.execute(
|
||||
sa.text("SELECT id, api_key FROM ai_accounts WHERE api_key IS NOT NULL AND api_key != ''")
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return
|
||||
for row in rows:
|
||||
encrypted = encrypt(row[1])
|
||||
if encrypted:
|
||||
conn.execute(
|
||||
sa.text("UPDATE ai_accounts SET api_key_encrypted = :enc, api_key = NULL WHERE id = :id"),
|
||||
{"enc": encrypted, "id": row[0]},
|
||||
)
|
||||
|
||||
|
||||
def _backfill_provider_services(conn) -> None:
|
||||
"""为已有预设平台补充 services 服务列表(只更新 services 为空的预设 slug)"""
|
||||
import sqlalchemy as sa
|
||||
from app.core.seed import PRESET_PROVIDERS
|
||||
|
||||
for data in PRESET_PROVIDERS:
|
||||
services = data.get("services")
|
||||
if not services:
|
||||
continue
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE providers SET services = :services "
|
||||
"WHERE slug = :slug AND (services IS NULL OR services = '')"
|
||||
),
|
||||
{"services": services, "slug": data["slug"]},
|
||||
)
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
|
||||
+53
-10
@@ -8,44 +8,69 @@
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.staticfiles import StaticFiles as StarletteStaticFiles
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.seed import seed_providers
|
||||
from app.database import assets_engine, init_db
|
||||
from app.routers import agent, assets, monitor, notify, providers, stats, sync
|
||||
from app.routers import agent, assets, monitor, notify, providers, ssl, stats, sync
|
||||
from app.services import cleanup_service
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
STATIC_DIR = BASE_DIR / "static"
|
||||
TEMPLATE_DIR = BASE_DIR / "app" / "templates"
|
||||
STATIC_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _asset_version() -> str:
|
||||
"""静态资源版本号:取 static 目录内最新文件 mtime,代码更新后自动变更。
|
||||
|
||||
用于前端引用 ?v= 参数,绕开浏览器启发式缓存(旧响应无 Cache-Control
|
||||
时存下的条目会被视为新鲜而不再回源验证)。
|
||||
"""
|
||||
latest = 0
|
||||
for p in STATIC_DIR.rglob("*"):
|
||||
if p.is_file():
|
||||
latest = max(latest, int(p.stat().st_mtime))
|
||||
return str(latest)
|
||||
|
||||
# 全局日志配置:统一格式,便于生产环境排查
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
)
|
||||
|
||||
# 直接使用 Jinja2 Environment 渲染(规避 Starlette Jinja2Templates 在 Python 3.14 下的缓存兼容问题)
|
||||
jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
"""应用启动时自动建表并初始化预设平台"""
|
||||
"""应用启动时自动建表、初始化预设平台,并启动监控数据定期清理任务"""
|
||||
init_db()
|
||||
with Session(assets_engine) as session:
|
||||
seed_providers(session)
|
||||
cleanup_task = asyncio.create_task(cleanup_service.cleanup_loop())
|
||||
yield
|
||||
cleanup_task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title=settings.APP_NAME, version="0.2.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "X-API-Key", "X-Agent-Key"],
|
||||
allow_credentials=True,
|
||||
)
|
||||
|
||||
app.include_router(assets.router)
|
||||
@@ -55,24 +80,42 @@ app.include_router(agent.router)
|
||||
app.include_router(monitor.router)
|
||||
app.include_router(sync.router)
|
||||
app.include_router(notify.router)
|
||||
app.include_router(ssl.router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
# 静态资源:no-cache(每次回源校验 ETag)。避免启发式缓存导致旧版本残留,
|
||||
# 与 SW 的 no-store 回源配合,保证代码更新后立即生效。
|
||||
class NoCacheStaticFiles(StarletteStaticFiles):
|
||||
def file_response(self, *args, **kwargs):
|
||||
resp = super().file_response(*args, **kwargs)
|
||||
resp.headers.setdefault("Cache-Control", "no-cache")
|
||||
return resp
|
||||
|
||||
app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def index() -> HTMLResponse:
|
||||
"""前端 SPA 入口"""
|
||||
html = jinja_env.get_template("index.html").render(app_name=settings.APP_NAME)
|
||||
html = jinja_env.get_template("index.html").render(
|
||||
app_name=settings.APP_NAME, asset_version=_asset_version()
|
||||
)
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.get("/sw.js", include_in_schema=False)
|
||||
def service_worker() -> FileResponse:
|
||||
"""Service Worker(置于根路径以使 scope 覆盖全站)"""
|
||||
"""Service Worker(置于根路径以使 scope 覆盖全站)
|
||||
|
||||
no-cache:保证浏览器每次导航都校验 SW 是否有更新,
|
||||
否则浏览器可能长时间持有旧版 SW(默认更新检查间隔长)。
|
||||
"""
|
||||
return FileResponse(
|
||||
STATIC_DIR / "sw.js",
|
||||
media_type="application/javascript",
|
||||
headers={"Service-Worker-Allowed": "/"},
|
||||
headers={
|
||||
"Service-Worker-Allowed": "/",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
+8
-3
@@ -11,8 +11,11 @@ from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Index
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from app.core.timeutils import utcnow
|
||||
|
||||
|
||||
class AssetType(str, Enum):
|
||||
"""资产类型"""
|
||||
@@ -38,6 +41,8 @@ class Asset(SQLModel, table=True):
|
||||
"""资产主表:所有资产在此统一登记,用于续费提醒与状态总览"""
|
||||
|
||||
__tablename__ = "assets"
|
||||
# SDK 同步去重查询:provider_id + external_id + asset_type
|
||||
__table_args__ = (Index("ix_assets_provider_ext_type", "provider_id", "external_id", "asset_type"),)
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str = Field(index=True, description="资产名称,如:博客主站 VPS")
|
||||
@@ -72,10 +77,10 @@ class Asset(SQLModel, table=True):
|
||||
default=False, description="已归档:不再使用但可能仍在续费,需重点排查"
|
||||
)
|
||||
remark: Optional[str] = Field(default=None, description="备注")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
|
||||
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow,
|
||||
sa_column_kwargs={"onupdate": datetime.utcnow},
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
description="更新时间",
|
||||
)
|
||||
|
||||
|
||||
@@ -9,17 +9,21 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Index
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from app.core.timeutils import utcnow
|
||||
|
||||
|
||||
class MetricPoint(SQLModel, table=True):
|
||||
"""资源监控时序数据"""
|
||||
|
||||
__tablename__ = "metric_points"
|
||||
__table_args__ = (Index("ix_metric_points_asset_ts", "asset_id", "ts"),)
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
asset_id: int = Field(index=True, description="关联资产 ID")
|
||||
ts: datetime = Field(default_factory=datetime.utcnow, index=True, description="采集时间")
|
||||
ts: datetime = Field(default_factory=utcnow, index=True, description="采集时间")
|
||||
cpu_pct: Optional[float] = Field(default=None, description="CPU 使用率 %")
|
||||
mem_pct: Optional[float] = Field(default=None, description="内存使用率 %")
|
||||
disk_pct: Optional[float] = Field(default=None, description="磁盘使用率 %")
|
||||
@@ -46,17 +50,18 @@ class ServerInfo(SQLModel, table=True):
|
||||
public_ip: Optional[str] = Field(default=None)
|
||||
tailscale_ip: Optional[str] = Field(default=None)
|
||||
status: Optional[str] = Field(default="online", description="online/offline")
|
||||
last_seen: datetime = Field(default_factory=datetime.utcnow, description="最近上报时间")
|
||||
last_seen: datetime = Field(default_factory=utcnow, description="最近上报时间")
|
||||
|
||||
|
||||
class SecurityCheck(SQLModel, table=True):
|
||||
"""安全检查项结果"""
|
||||
|
||||
__tablename__ = "security_checks"
|
||||
__table_args__ = (Index("ix_security_checks_asset_ts", "asset_id", "ts"),)
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
asset_id: int = Field(index=True)
|
||||
ts: datetime = Field(default_factory=datetime.utcnow, index=True)
|
||||
ts: datetime = Field(default_factory=utcnow, index=True)
|
||||
check_item: str = Field(description="检查项,如 ssh_config/firewall/updates")
|
||||
status: str = Field(default="unknown", description="pass/warn/fail/unknown")
|
||||
detail: Optional[str] = Field(default=None, description="检查详情")
|
||||
@@ -69,7 +74,7 @@ class EventLog(SQLModel, table=True):
|
||||
__tablename__ = "event_logs"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
ts: datetime = Field(default_factory=datetime.utcnow, index=True)
|
||||
ts: datetime = Field(default_factory=utcnow, index=True)
|
||||
level: str = Field(default="info", index=True, description="info/warning/error")
|
||||
source: str = Field(default="system", description="来源,如 agent/api/backup")
|
||||
asset_id: Optional[int] = Field(default=None, index=True)
|
||||
|
||||
@@ -10,6 +10,8 @@ from typing import Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from app.core.timeutils import utcnow
|
||||
|
||||
|
||||
class ProviderCategory(str, Enum):
|
||||
"""平台分类"""
|
||||
@@ -31,6 +33,9 @@ class Provider(SQLModel, table=True):
|
||||
name: str = Field(index=True, description="显示名,如 阿里云")
|
||||
name_en: Optional[str] = Field(default=None, description="英文名")
|
||||
category: ProviderCategory = Field(index=True, description="平台分类")
|
||||
services: str = Field(
|
||||
default="", description="提供的服务列表(逗号分隔):vps/domain/ai_agent/cloudflare/ssl_cert/cdn/dns/other,综合平台可多项"
|
||||
)
|
||||
website: Optional[str] = Field(default=None, description="官网")
|
||||
console_url: Optional[str] = Field(default=None, description="管理面板 URL")
|
||||
sdk_type: Optional[str] = Field(
|
||||
@@ -44,4 +49,4 @@ class Provider(SQLModel, table=True):
|
||||
last_synced_at: Optional[datetime] = Field(
|
||||
default=None, description="最近一次 SDK 同步时间"
|
||||
)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
|
||||
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""SSL 相关数据库模型
|
||||
|
||||
- Subdomain: 域名资产下的子域名记录(关联域名资产 Asset)
|
||||
- SiteCert: 站点证书监控记录(探测 https 站点证书的到期情况)
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from app.core.timeutils import utcnow
|
||||
|
||||
|
||||
class Subdomain(SQLModel, table=True):
|
||||
"""子域名记录(挂在域名资产下)"""
|
||||
|
||||
__tablename__ = "subdomains"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
asset_id: int = Field(
|
||||
foreign_key="assets.id", index=True, description="所属域名资产 ID"
|
||||
)
|
||||
host: str = Field(index=True, description="子域名主机名,如 www / api / blog(@ 表示根域名)")
|
||||
record_type: Optional[str] = Field(
|
||||
default=None, description="DNS 记录类型:A/CNAME/AAAA/MX/TXT"
|
||||
)
|
||||
record_value: Optional[str] = Field(
|
||||
default=None, description="DNS 记录值(IP 或目标域名)"
|
||||
)
|
||||
is_active: bool = Field(default=True, description="是否启用/解析中")
|
||||
note: Optional[str] = Field(default=None, description="备注(用途)")
|
||||
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
|
||||
updated_at: datetime = Field(
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
description="更新时间",
|
||||
)
|
||||
|
||||
|
||||
class SiteCert(SQLModel, table=True):
|
||||
"""站点证书监控记录(探测 https 站点证书到期情况)"""
|
||||
|
||||
__tablename__ = "site_certs"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
hostname: str = Field(index=True, description="探测目标主机名,如 www.example.com")
|
||||
port: int = Field(default=443, description="探测端口")
|
||||
asset_id: Optional[int] = Field(
|
||||
default=None, foreign_key="assets.id", index=True, description="关联资产 ID(可选)"
|
||||
)
|
||||
issuer: Optional[str] = Field(default=None, description="签发机构 CN")
|
||||
subject_cn: Optional[str] = Field(default=None, description="证书主体 CN")
|
||||
valid_from: Optional[date] = Field(default=None, description="证书生效日期")
|
||||
valid_to: Optional[date] = Field(default=None, description="证书到期日期")
|
||||
fingerprint: Optional[str] = Field(default=None, description="证书指纹 SHA-256")
|
||||
status: str = Field(
|
||||
default="unknown",
|
||||
index=True,
|
||||
description="状态:valid/expiring/expired/error/unknown",
|
||||
)
|
||||
error: Optional[str] = Field(default=None, description="探测失败原因")
|
||||
last_checked_at: Optional[datetime] = Field(default=None, description="最近探测时间")
|
||||
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
|
||||
updated_at: datetime = Field(
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
description="更新时间",
|
||||
)
|
||||
+43
-4
@@ -1,17 +1,53 @@
|
||||
"""Agent 上报接收路由(数据写入 metrics.db)"""
|
||||
|
||||
from datetime import datetime
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import require_agent_key
|
||||
from app.database import get_metrics_session
|
||||
from app.core.timeutils import utcnow
|
||||
from app.database import assets_engine, get_metrics_session
|
||||
from app.models.asset import Asset
|
||||
from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo
|
||||
from app.schemas.agent import AgentReport
|
||||
|
||||
router = APIRouter(prefix="/api/agent", tags=["agent"])
|
||||
|
||||
# 内存级频率限制:{asset_id: 上次上报的 monotonic 时间戳}
|
||||
# 同步路由运行在线程池,多线程并发读写需加锁保证“读-判断-写”原子性
|
||||
_last_report_ts: dict = {}
|
||||
_rate_limit_lock = threading.Lock()
|
||||
|
||||
|
||||
def _check_rate_limit(asset_id: int) -> None:
|
||||
"""限制同一资产的上报频率,防止配置错误的 Agent 高频写入填满数据库"""
|
||||
interval = settings.AGENT_REPORT_MIN_INTERVAL
|
||||
if interval <= 0:
|
||||
return
|
||||
now = time.monotonic()
|
||||
with _rate_limit_lock:
|
||||
last = _last_report_ts.get(asset_id)
|
||||
if last is not None and (now - last) < interval:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"上报过于频繁,同一资产最小间隔 {interval} 秒",
|
||||
)
|
||||
_last_report_ts[asset_id] = now
|
||||
|
||||
|
||||
def _validate_asset_id(asset_id: int) -> None:
|
||||
"""校验上报的 asset_id 在资产库中真实存在,防止脏数据写入"""
|
||||
with Session(assets_engine) as session:
|
||||
asset = session.get(Asset, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"asset_id={asset_id} 不存在,请先在资产列表中登记该服务器",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/report",
|
||||
@@ -19,6 +55,9 @@ router = APIRouter(prefix="/api/agent", tags=["agent"])
|
||||
dependencies=[Depends(require_agent_key)],
|
||||
)
|
||||
def report(data: AgentReport, session: Session = Depends(get_metrics_session)) -> dict:
|
||||
_validate_asset_id(data.asset_id)
|
||||
_check_rate_limit(data.asset_id)
|
||||
|
||||
# 资源监控时序
|
||||
if data.metrics:
|
||||
session.add(MetricPoint(asset_id=data.asset_id, **data.metrics.model_dump()))
|
||||
@@ -26,7 +65,7 @@ def report(data: AgentReport, session: Session = Depends(get_metrics_session)) -
|
||||
# 服务器信息快照(每资产一条,覆盖更新)
|
||||
if data.server_info:
|
||||
info = data.server_info.model_dump()
|
||||
info["last_seen"] = datetime.utcnow()
|
||||
info["last_seen"] = utcnow()
|
||||
existing = session.exec(
|
||||
select(ServerInfo).where(ServerInfo.asset_id == data.asset_id)
|
||||
).first()
|
||||
|
||||
@@ -32,7 +32,7 @@ def list_assets(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export", summary="导出所有资产(数据迁移/备份)")
|
||||
@router.get("/export", summary="导出所有资产(数据迁移/备份)", dependencies=[Depends(require_api_key)])
|
||||
def export_assets(session: Session = Depends(get_session)) -> dict:
|
||||
return asset_service.export_assets(session)
|
||||
|
||||
|
||||
+15
-1
@@ -5,9 +5,10 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.security import require_api_key
|
||||
from app.database import get_metrics_session
|
||||
from app.models.monitor import MetricPoint, SecurityCheck, ServerInfo
|
||||
from app.services import security_service
|
||||
from app.services import cleanup_service, security_service
|
||||
|
||||
router = APIRouter(prefix="/api/monitor", tags=["monitor"])
|
||||
|
||||
@@ -17,6 +18,19 @@ def security_overview(session: Session = Depends(get_metrics_session)):
|
||||
return security_service.get_security_overview(session)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cleanup",
|
||||
summary="手动触发监控数据清理(按保留周期删除过期数据)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def cleanup(
|
||||
metrics_days: Optional[int] = Query(default=None, description="时序数据保留天数(默认取配置)"),
|
||||
security_days: Optional[int] = Query(default=None, description="安全检查保留天数(默认取配置)"),
|
||||
event_log_days: Optional[int] = Query(default=None, description="事件日志保留天数(默认取配置)"),
|
||||
):
|
||||
return cleanup_service.cleanup_metrics(metrics_days, security_days, event_log_days)
|
||||
|
||||
|
||||
@router.get("/{asset_id}/metrics", summary="资源监控时序(倒序,最新在前)")
|
||||
def metrics(
|
||||
asset_id: int,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""SSL 监控路由:子域名管理与站点证书监控
|
||||
|
||||
- /api/subdomains:域名资产下的子域名 CRUD
|
||||
- /api/site-certs:https 站点证书的探测与监控
|
||||
写操作(POST/PUT/DELETE)受 API Key 保护。
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.security import require_api_key
|
||||
from app.database import get_session
|
||||
from app.schemas.ssl import SiteCertCreate, SubdomainCreate, SubdomainUpdate
|
||||
from app.services import ssl_service
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["ssl"])
|
||||
|
||||
|
||||
def _guard(exc: ValueError) -> HTTPException:
|
||||
"""service 抛出的 ValueError 统一转 404"""
|
||||
return HTTPException(status_code=404, detail=str(exc))
|
||||
|
||||
|
||||
# ---------------- 子域名 ----------------
|
||||
|
||||
@router.get("/subdomains", summary="子域名列表(可按域名资产筛选)")
|
||||
def list_subdomains(
|
||||
asset_id: Optional[int] = Query(default=None, description="按域名资产 ID 筛选"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list:
|
||||
return ssl_service.list_subdomains(session, asset_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subdomains",
|
||||
status_code=201,
|
||||
summary="创建子域名",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def create_subdomain(data: SubdomainCreate, session: Session = Depends(get_session)) -> dict:
|
||||
sub = ssl_service.create_subdomain(session, data.model_dump())
|
||||
return {
|
||||
"id": sub.id,
|
||||
"asset_id": sub.asset_id,
|
||||
"host": sub.host,
|
||||
"record_type": sub.record_type,
|
||||
"record_value": sub.record_value,
|
||||
"is_active": sub.is_active,
|
||||
"note": sub.note,
|
||||
}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/subdomains/{sub_id}",
|
||||
summary="更新子域名",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def update_subdomain(sub_id: int, data: SubdomainUpdate, session: Session = Depends(get_session)) -> dict:
|
||||
try:
|
||||
sub = ssl_service.update_subdomain(session, sub_id, data.model_dump(exclude_unset=True))
|
||||
except ValueError as e:
|
||||
raise _guard(e) from e
|
||||
return {
|
||||
"id": sub.id,
|
||||
"asset_id": sub.asset_id,
|
||||
"host": sub.host,
|
||||
"record_type": sub.record_type,
|
||||
"record_value": sub.record_value,
|
||||
"is_active": sub.is_active,
|
||||
"note": sub.note,
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/subdomains/{sub_id}",
|
||||
status_code=204,
|
||||
summary="删除子域名",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def delete_subdomain(sub_id: int, session: Session = Depends(get_session)):
|
||||
try:
|
||||
ssl_service.delete_subdomain(session, sub_id)
|
||||
except ValueError as e:
|
||||
raise _guard(e) from e
|
||||
|
||||
|
||||
# ---------------- 站点证书监控 ----------------
|
||||
|
||||
@router.get("/site-certs", summary="站点证书监控列表(可按状态/资产筛选)")
|
||||
def list_site_certs(
|
||||
status: Optional[str] = Query(default=None, description="按状态筛选"),
|
||||
asset_id: Optional[int] = Query(default=None, description="按关联资产筛选"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list:
|
||||
return ssl_service.list_site_certs(session, status, asset_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/site-certs",
|
||||
status_code=201,
|
||||
summary="创建证书探测目标(创建后立即探测一次)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def create_site_cert(data: SiteCertCreate, session: Session = Depends(get_session)) -> dict:
|
||||
try:
|
||||
cert = ssl_service.create_site_cert(
|
||||
session, data.hostname, data.port, data.asset_id
|
||||
)
|
||||
except ValueError as e:
|
||||
raise _guard(e) from e
|
||||
return ssl_service._to_dict(cert)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/site-certs/check-all",
|
||||
summary="全量重新探测所有站点证书",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def check_all_site_certs(session: Session = Depends(get_session)) -> dict:
|
||||
return ssl_service.check_all_site_certs(session)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/site-certs/{cert_id}/check",
|
||||
summary="重新探测单条站点证书",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def check_one_site_cert(cert_id: int, session: Session = Depends(get_session)) -> dict:
|
||||
cert = session.get(ssl_service.SiteCert, cert_id)
|
||||
if not cert:
|
||||
raise HTTPException(status_code=404, detail=f"证书监控记录不存在(id={cert_id})")
|
||||
ssl_service.check_one(session, cert)
|
||||
return ssl_service._to_dict(cert)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/site-certs/{cert_id}",
|
||||
status_code=204,
|
||||
summary="删除证书探测目标",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def delete_site_cert(cert_id: int, session: Session = Depends(get_session)):
|
||||
try:
|
||||
ssl_service.delete_site_cert(session, cert_id)
|
||||
except ValueError as e:
|
||||
raise _guard(e) from e
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
@@ -40,4 +41,5 @@ class AgentReport(SQLModel):
|
||||
asset_id: int
|
||||
metrics: Optional[MetricsIn] = None
|
||||
server_info: Optional[ServerInfoIn] = None
|
||||
security: Optional[List[SecurityCheckIn]] = None
|
||||
# 限制单次上报的检查项数量,防止恶意/异常 Agent 发送超大数组撑爆内存
|
||||
security: Optional[List[SecurityCheckIn]] = Field(default=None, max_length=100)
|
||||
|
||||
@@ -13,6 +13,7 @@ class ProviderBase(SQLModel):
|
||||
name: str
|
||||
name_en: Optional[str] = None
|
||||
category: ProviderCategory
|
||||
services: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
console_url: Optional[str] = None
|
||||
sdk_type: Optional[str] = None
|
||||
@@ -31,6 +32,7 @@ class ProviderUpdate(SQLModel):
|
||||
name: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
category: Optional[ProviderCategory] = None
|
||||
services: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
console_url: Optional[str] = None
|
||||
sdk_type: Optional[str] = None
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""SSL 相关请求模型"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SubdomainCreate(BaseModel):
|
||||
asset_id: int = Field(description="所属域名资产 ID")
|
||||
host: str = Field(min_length=1, max_length=63, description="子域名主机名,如 www/api(@ 表示根域名)")
|
||||
record_type: Optional[str] = Field(default=None, description="DNS 记录类型")
|
||||
record_value: Optional[str] = Field(default=None, description="DNS 记录值")
|
||||
is_active: bool = Field(default=True, description="是否启用")
|
||||
note: Optional[str] = Field(default=None, description="备注")
|
||||
|
||||
|
||||
class SubdomainUpdate(BaseModel):
|
||||
host: Optional[str] = Field(default=None, min_length=1, max_length=63)
|
||||
record_type: Optional[str] = Field(default=None)
|
||||
record_value: Optional[str] = Field(default=None)
|
||||
is_active: Optional[bool] = Field(default=None)
|
||||
note: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class SiteCertCreate(BaseModel):
|
||||
hostname: str = Field(min_length=1, max_length=253, description="探测目标主机名")
|
||||
port: int = Field(default=443, ge=1, le=65535, description="探测端口")
|
||||
asset_id: Optional[int] = Field(default=None, description="关联资产 ID(可选)")
|
||||
+146
-61
@@ -3,7 +3,7 @@
|
||||
统一处理 Asset 主表与其一对一详情表(VPSDetail/DomainDetail/AIAccount)的联动。
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
@@ -30,6 +30,10 @@ from app.schemas.asset import (
|
||||
VPSDetailRead,
|
||||
)
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("vps-manager.assets")
|
||||
|
||||
# 资产类型 -> (AssetCreate/Update 中的字段名, 详情表模型)
|
||||
DETAIL_MAP = {
|
||||
AssetType.VPS: ("vps_detail", VPSDetail),
|
||||
@@ -140,11 +144,49 @@ def _apply_detail_update(asset_type: AssetType, existing, detail_in) -> None:
|
||||
setattr(existing, key, value)
|
||||
|
||||
|
||||
def _get_details_batch(session: Session, assets: list) -> dict:
|
||||
"""批量预加载所有资产的详情和 Provider 名,避免 N+1 查询"""
|
||||
if not assets:
|
||||
return {}
|
||||
asset_ids = [a.id for a in assets]
|
||||
# 批量查 Provider 名
|
||||
provider_ids = {a.provider_id for a in assets if a.provider_id}
|
||||
provider_map = {}
|
||||
if provider_ids:
|
||||
for p in session.exec(select(Provider).where(Provider.id.in_(provider_ids))).all():
|
||||
provider_map[p.id] = p.name
|
||||
# 批量查各类型详情
|
||||
detail_map = {}
|
||||
for asset_type, (_, model) in DETAIL_MAP.items():
|
||||
typed_ids = [a.id for a in assets if a.asset_type == asset_type]
|
||||
if typed_ids:
|
||||
for d in session.exec(select(model).where(model.asset_id.in_(typed_ids))).all():
|
||||
detail_map[d.asset_id] = d
|
||||
return {"providers": provider_map, "details": detail_map}
|
||||
|
||||
|
||||
def _to_read_batch(session: Session, asset: Asset, batch: dict) -> AssetRead:
|
||||
"""用批量预加载的数据组装 AssetRead(避免逐个查询)"""
|
||||
read = AssetRead.model_validate(asset)
|
||||
read.days_to_expiry = _days_to_expiry(asset.expiry_date)
|
||||
read.provider_name = batch["providers"].get(asset.provider_id)
|
||||
detail = batch["details"].get(asset.id)
|
||||
if isinstance(detail, VPSDetail):
|
||||
read.vps_detail = _detail_to_read(detail)
|
||||
elif isinstance(detail, DomainDetail):
|
||||
read.domain_detail = _detail_to_read(detail)
|
||||
elif isinstance(detail, AIAccount):
|
||||
read.ai_detail = _detail_to_read(detail)
|
||||
elif isinstance(detail, CloudflareDetail):
|
||||
read.cloudflare_detail = _detail_to_read(detail)
|
||||
return read
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CRUD
|
||||
# --------------------------------------------------------------------------- #
|
||||
def create_asset(session: Session, data: AssetCreate) -> AssetRead:
|
||||
"""创建资产及其详情"""
|
||||
def _create_asset_no_commit(session: Session, data: AssetCreate) -> Asset:
|
||||
"""创建资产及详情(不 commit,由调用方统一提交)"""
|
||||
item = DETAIL_MAP.get(data.asset_type)
|
||||
detail_in = None
|
||||
model = None
|
||||
@@ -162,17 +204,21 @@ def create_asset(session: Session, data: AssetCreate) -> AssetRead:
|
||||
)
|
||||
asset = Asset(**asset_data)
|
||||
session.add(asset)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
session.flush() # 获取 asset.id,但不提交
|
||||
|
||||
detail = None
|
||||
if model is not None and detail_in is not None:
|
||||
detail = _build_detail(data.asset_type, asset.id, detail_in)
|
||||
session.add(detail)
|
||||
session.commit()
|
||||
session.refresh(detail)
|
||||
return asset
|
||||
|
||||
return _to_read(session, asset, detail)
|
||||
|
||||
def create_asset(session: Session, data: AssetCreate) -> AssetRead:
|
||||
"""创建资产及其详情(单次事务提交)"""
|
||||
asset = _create_asset_no_commit(session, data)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
logger.info("创建资产 id=%s name=%s type=%s", asset.id, asset.name, asset.asset_type)
|
||||
return _to_read(session, asset, _get_detail(session, asset))
|
||||
|
||||
|
||||
def get_asset(session: Session, asset_id: int) -> AssetRead:
|
||||
@@ -183,8 +229,8 @@ def get_asset(session: Session, asset_id: int) -> AssetRead:
|
||||
return _to_read(session, asset, _get_detail(session, asset))
|
||||
|
||||
|
||||
def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead:
|
||||
"""更新资产主表及详情(仅更新传入字段)"""
|
||||
def _update_asset_no_commit(session: Session, asset_id: int, data: AssetUpdate) -> Asset:
|
||||
"""更新资产主表及详情(不 commit,由调用方统一提交)"""
|
||||
asset = session.get(Asset, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
|
||||
@@ -197,7 +243,6 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
|
||||
session.add(asset)
|
||||
|
||||
# 详情表:以更新后的 asset_type 为准
|
||||
detail = None
|
||||
item = DETAIL_MAP.get(asset.asset_type)
|
||||
if item:
|
||||
field, model = item
|
||||
@@ -209,22 +254,22 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
|
||||
if existing:
|
||||
_apply_detail_update(asset.asset_type, existing, detail_in)
|
||||
session.add(existing)
|
||||
detail = existing
|
||||
else:
|
||||
detail = _build_detail(asset.asset_type, asset.id, detail_in)
|
||||
session.add(detail)
|
||||
else:
|
||||
detail = existing
|
||||
return asset
|
||||
|
||||
|
||||
def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead:
|
||||
"""更新资产主表及详情(单次事务提交)"""
|
||||
asset = _update_asset_no_commit(session, asset_id, data)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
if detail is not None:
|
||||
session.refresh(detail)
|
||||
return _to_read(session, asset, detail)
|
||||
return _to_read(session, asset, _get_detail(session, asset))
|
||||
|
||||
|
||||
def delete_asset(session: Session, asset_id: int) -> None:
|
||||
"""删除资产及其详情"""
|
||||
"""删除资产及其详情,并清理 metrics.db 中的关联监控数据(避免孤儿数据)"""
|
||||
asset = session.get(Asset, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
|
||||
@@ -233,46 +278,72 @@ def delete_asset(session: Session, asset_id: int) -> None:
|
||||
session.delete(detail)
|
||||
session.delete(asset)
|
||||
session.commit()
|
||||
logger.info("删除资产 id=%s name=%s", asset_id, asset.name)
|
||||
_cleanup_metrics_for_asset(asset_id)
|
||||
|
||||
|
||||
def _cleanup_metrics_for_asset(asset_id: int) -> None:
|
||||
"""清理 metrics.db 中该资产的 MetricPoint/ServerInfo/SecurityCheck/EventLog"""
|
||||
from app.database import metrics_engine
|
||||
from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo
|
||||
|
||||
with Session(metrics_engine) as ms:
|
||||
for model in (MetricPoint, ServerInfo, SecurityCheck, EventLog):
|
||||
for row in ms.exec(select(model).where(model.asset_id == asset_id)).all():
|
||||
ms.delete(row)
|
||||
ms.commit()
|
||||
|
||||
|
||||
def export_assets(session: Session) -> dict:
|
||||
"""导出所有资产(含 detail)为 JSON,用于数据迁移/备份"""
|
||||
assets = session.exec(select(Asset)).all()
|
||||
batch = _get_details_batch(session, assets)
|
||||
result = []
|
||||
for asset in assets:
|
||||
read = _to_read(session, asset, _get_detail(session, asset))
|
||||
read = _to_read_batch(session, asset, batch)
|
||||
result.append(read.model_dump(mode="json"))
|
||||
return {
|
||||
"count": len(result),
|
||||
"exported_at": datetime.utcnow().isoformat(),
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"assets": result,
|
||||
}
|
||||
|
||||
|
||||
def import_assets(session: Session, assets_data: list) -> dict:
|
||||
"""从导出数据导入资产(按 name+asset_type+provider 去重:存在则更新,不存在则创建)"""
|
||||
"""从导出数据导入资产(按 name+asset_type+provider 去重:存在则更新,不存在则创建)
|
||||
|
||||
整个导入在单个事务中完成:全部成功才提交,任何一条失败则回滚,避免部分导入。
|
||||
"""
|
||||
created = 0
|
||||
updated = 0
|
||||
errors = []
|
||||
skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name"}
|
||||
for item in assets_data:
|
||||
try:
|
||||
payload = {k: v for k, v in item.items() if k not in skip_fields}
|
||||
existing = session.exec(
|
||||
select(Asset).where(
|
||||
Asset.name == payload.get("name"),
|
||||
Asset.asset_type == payload.get("asset_type"),
|
||||
Asset.provider == payload.get("provider"),
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
update_asset(session, existing.id, AssetUpdate(**payload))
|
||||
updated += 1
|
||||
else:
|
||||
create_asset(session, AssetCreate(**payload))
|
||||
created += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(f"{item.get('name', '?')}: {e}")
|
||||
try:
|
||||
for item in assets_data:
|
||||
try:
|
||||
payload = {k: v for k, v in item.items() if k not in skip_fields}
|
||||
existing = session.exec(
|
||||
select(Asset).where(
|
||||
Asset.name == payload.get("name"),
|
||||
Asset.asset_type == payload.get("asset_type"),
|
||||
Asset.provider == payload.get("provider"),
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
_update_asset_no_commit(session, existing.id, AssetUpdate(**payload))
|
||||
updated += 1
|
||||
else:
|
||||
_create_asset_no_commit(session, AssetCreate(**payload))
|
||||
created += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(f"{item.get('name', '?')}: {e}")
|
||||
if errors:
|
||||
session.rollback()
|
||||
return {"created": 0, "updated": 0, "errors": errors}
|
||||
session.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
session.rollback()
|
||||
raise
|
||||
return {"created": created, "updated": updated, "errors": errors}
|
||||
|
||||
|
||||
@@ -301,15 +372,20 @@ def list_assets(
|
||||
stmt = stmt.order_by(sort_col.desc() if order == "desc" else sort_col.asc())
|
||||
|
||||
assets = session.exec(stmt).all()
|
||||
return [_to_read(session, a, _get_detail(session, a)) for a in assets]
|
||||
batch = _get_details_batch(session, assets)
|
||||
return [_to_read_batch(session, a, batch) for a in assets]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 统计
|
||||
# --------------------------------------------------------------------------- #
|
||||
def get_overview(session: Session) -> dict:
|
||||
"""资产总览:数量分布、异常数、到期预警、支出合计"""
|
||||
assets = session.exec(select(Asset)).all()
|
||||
"""资产总览:数量分布、异常数、到期预警、支出合计
|
||||
|
||||
性能优化:仅查询统计所需的列,避免加载完整 Asset 实体。
|
||||
"""
|
||||
stmt = select(Asset.asset_type, Asset.status, Asset.expiry_date, Asset.cost)
|
||||
rows = session.exec(stmt).all()
|
||||
today = date.today()
|
||||
|
||||
by_type: dict = {}
|
||||
@@ -319,22 +395,24 @@ def get_overview(session: Session) -> dict:
|
||||
year_cost = 0.0
|
||||
month_cost = 0.0
|
||||
|
||||
for a in assets:
|
||||
by_type[a.asset_type.value] = by_type.get(a.asset_type.value, 0) + 1
|
||||
by_status[a.status.value] = by_status.get(a.status.value, 0) + 1
|
||||
if a.status in (AssetStatus.STOPPED, AssetStatus.EXPIRED):
|
||||
for asset_type, status_val, expiry_date, cost in rows:
|
||||
tval = asset_type.value if hasattr(asset_type, "value") else asset_type
|
||||
sval = status_val.value if hasattr(status_val, "value") else status_val
|
||||
by_type[tval] = by_type.get(tval, 0) + 1
|
||||
by_status[sval] = by_status.get(sval, 0) + 1
|
||||
if status_val in (AssetStatus.STOPPED, AssetStatus.EXPIRED):
|
||||
abnormal += 1
|
||||
if a.expiry_date:
|
||||
days = (a.expiry_date - today).days
|
||||
if expiry_date:
|
||||
days = (expiry_date - today).days
|
||||
if 0 <= days <= 30:
|
||||
expiring_30 += 1
|
||||
if a.expiry_date.year == today.year:
|
||||
year_cost += a.cost
|
||||
if a.expiry_date.month == today.month:
|
||||
month_cost += a.cost
|
||||
if expiry_date.year == today.year:
|
||||
year_cost += cost
|
||||
if expiry_date.month == today.month:
|
||||
month_cost += cost
|
||||
|
||||
return {
|
||||
"total": len(assets),
|
||||
"total": len(rows),
|
||||
"by_type": by_type,
|
||||
"by_status": by_status,
|
||||
"abnormal_count": abnormal,
|
||||
@@ -345,13 +423,20 @@ def get_overview(session: Session) -> dict:
|
||||
|
||||
|
||||
def get_expiring(session: Session, days: int = 30) -> List[AssetRead]:
|
||||
"""N 天内到期资产列表(按剩余天数升序)"""
|
||||
"""N 天内到期资产列表(按剩余天数升序)
|
||||
|
||||
性能优化:过滤条件下推到 SQL 层,仅查询 [today, today+days] 区间内的资产。
|
||||
"""
|
||||
today = date.today()
|
||||
assets = session.exec(select(Asset).where(Asset.expiry_date.is_not(None))).all()
|
||||
result = []
|
||||
for a in assets:
|
||||
delta = (a.expiry_date - today).days
|
||||
if 0 <= delta <= days:
|
||||
result.append(_to_read(session, a, _get_detail(session, a)))
|
||||
deadline = today + timedelta(days=days)
|
||||
assets = session.exec(
|
||||
select(Asset).where(
|
||||
Asset.expiry_date.is_not(None),
|
||||
Asset.expiry_date >= today,
|
||||
Asset.expiry_date <= deadline,
|
||||
)
|
||||
).all()
|
||||
batch = _get_details_batch(session, assets)
|
||||
result = [_to_read_batch(session, a, batch) for a in assets]
|
||||
result.sort(key=lambda x: x.days_to_expiry if x.days_to_expiry is not None else 10**9)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""监控数据自动清理服务
|
||||
|
||||
按配置的保留周期清理 metrics.db 中的历史数据,防止 SQLite 无限膨胀:
|
||||
- MetricPoint: 高频时序数据,默认保留 30 天
|
||||
- SecurityCheck: 安全检查历史,默认保留 90 天
|
||||
- EventLog: 事件日志,默认保留 180 天
|
||||
|
||||
提供两种触发方式:
|
||||
1. 应用启动后由 asyncio 后台任务按 CLEANUP_INTERVAL_HOURS 周期执行
|
||||
2. 通过 API 手动触发(POST /api/monitor/cleanup)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlmodel import Session, delete, select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.timeutils import utcnow
|
||||
from app.database import metrics_engine
|
||||
from app.models.monitor import EventLog, MetricPoint, SecurityCheck
|
||||
|
||||
logger = logging.getLogger("vps-manager.cleanup")
|
||||
|
||||
# 单批删除行数上限:避免大表单条 DELETE 长时间持有写锁,阻塞 Agent 上报
|
||||
_BATCH_SIZE = 1000
|
||||
|
||||
|
||||
def _delete_in_batches(session: Session, model, cutoff, batch_size: int = _BATCH_SIZE) -> int:
|
||||
"""分批删除指定模型的过期数据(ts < cutoff),返回总删除行数
|
||||
|
||||
SQLite 不支持 DELETE ... LIMIT,用子查询 SELECT id ... LIMIT 实现分批。
|
||||
"""
|
||||
total = 0
|
||||
while True:
|
||||
subq = select(model.id).where(model.ts < cutoff).limit(batch_size)
|
||||
ids = [row[0] if isinstance(row, tuple) else row for row in session.exec(subq).all()]
|
||||
if not ids:
|
||||
break
|
||||
session.exec(delete(model).where(model.id.in_(ids)))
|
||||
session.commit() # 每批独立提交,缩短写锁持有时间
|
||||
total += len(ids)
|
||||
if len(ids) < batch_size:
|
||||
break
|
||||
return total
|
||||
|
||||
|
||||
def cleanup_metrics(
|
||||
metrics_days: int | None = None,
|
||||
security_days: int | None = None,
|
||||
event_log_days: int | None = None,
|
||||
) -> dict:
|
||||
"""按保留天数清理过期监控数据,返回各类删除条数"""
|
||||
metrics_days = metrics_days if metrics_days is not None else settings.METRICS_RETENTION_DAYS
|
||||
security_days = security_days if security_days is not None else settings.SECURITY_RETENTION_DAYS
|
||||
event_log_days = event_log_days if event_log_days is not None else settings.EVENT_LOG_RETENTION_DAYS
|
||||
|
||||
now = utcnow()
|
||||
result = {"metric_points": 0, "security_checks": 0, "event_logs": 0}
|
||||
|
||||
with Session(metrics_engine) as session:
|
||||
if metrics_days > 0:
|
||||
cutoff = now - timedelta(days=metrics_days)
|
||||
result["metric_points"] = _delete_in_batches(session, MetricPoint, cutoff)
|
||||
|
||||
if security_days > 0:
|
||||
cutoff = now - timedelta(days=security_days)
|
||||
result["security_checks"] = _delete_in_batches(session, SecurityCheck, cutoff)
|
||||
|
||||
if event_log_days > 0:
|
||||
cutoff = now - timedelta(days=event_log_days)
|
||||
result["event_logs"] = _delete_in_batches(session, EventLog, cutoff)
|
||||
|
||||
logger.info(
|
||||
"监控数据清理完成:metric_points=%s, security_checks=%s, event_logs=%s",
|
||||
result["metric_points"],
|
||||
result["security_checks"],
|
||||
result["event_logs"],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def cleanup_loop() -> None:
|
||||
"""后台周期清理任务(CLEANUP_INTERVAL_HOURS=0 时不启动)"""
|
||||
interval_hours = settings.CLEANUP_INTERVAL_HOURS
|
||||
if interval_hours <= 0:
|
||||
logger.info("监控数据自动清理已禁用(CLEANUP_INTERVAL_HOURS=0)")
|
||||
return
|
||||
interval_sec = interval_hours * 3600
|
||||
logger.info("监控数据自动清理已启动,间隔 %s 小时", interval_hours)
|
||||
while True:
|
||||
try:
|
||||
# 在线程池执行同步 DB 操作,避免阻塞事件循环
|
||||
await asyncio.to_thread(cleanup_metrics)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("监控数据自动清理执行失败")
|
||||
await asyncio.sleep(interval_sec)
|
||||
@@ -6,6 +6,7 @@ from fastapi import HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core import crypto
|
||||
from app.models.asset import Asset
|
||||
from app.models.provider import Provider, ProviderCategory
|
||||
from app.schemas.provider import ProviderCreate, ProviderRead, ProviderUpdate
|
||||
|
||||
@@ -73,6 +74,15 @@ def delete_provider(session: Session, provider_id: int) -> None:
|
||||
provider = session.get(Provider, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
|
||||
# 数据完整性:存在关联资产时禁止删除,避免 provider_id 悬空引用
|
||||
linked = session.exec(
|
||||
select(Asset.id).where(Asset.provider_id == provider_id).limit(1)
|
||||
).first()
|
||||
if linked is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="该平台下仍有关联资产,请先迁移或删除相关资产",
|
||||
)
|
||||
session.delete(provider)
|
||||
session.commit()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
检查即将到期的资产,生成提醒消息并通过通知渠道发送。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
@@ -16,16 +16,21 @@ _SKIP_STATUS = {AssetStatus.CANCELLED}
|
||||
|
||||
|
||||
def get_expiring_assets(session: Session, threshold_days: int) -> list:
|
||||
"""返回 threshold_days 天内到期的资产列表 [(asset, days), ...],按剩余天数升序"""
|
||||
"""返回 threshold_days 天内到期的资产列表 [(asset, days), ...],按剩余天数升序
|
||||
|
||||
性能优化:日期范围与状态过滤均下推到 SQL 层。
|
||||
"""
|
||||
today = date.today()
|
||||
assets = session.exec(select(Asset).where(Asset.expiry_date.is_not(None))).all()
|
||||
expiring = []
|
||||
for asset in assets:
|
||||
if asset.status in _SKIP_STATUS:
|
||||
continue
|
||||
days = (asset.expiry_date - today).days
|
||||
if 0 <= days <= threshold_days:
|
||||
expiring.append((asset, days))
|
||||
deadline = today + timedelta(days=threshold_days)
|
||||
assets = session.exec(
|
||||
select(Asset).where(
|
||||
Asset.expiry_date.is_not(None),
|
||||
Asset.expiry_date >= today,
|
||||
Asset.expiry_date <= deadline,
|
||||
Asset.status.notin_(_SKIP_STATUS),
|
||||
)
|
||||
).all()
|
||||
expiring = [(asset, (asset.expiry_date - today).days) for asset in assets]
|
||||
expiring.sort(key=lambda x: x[1])
|
||||
return expiring
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
评分按检查项加权:pass 满分、warn 半分、fail/unknown 零分。
|
||||
"""
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models.monitor import SecurityCheck
|
||||
@@ -70,8 +71,42 @@ def get_asset_security(session: Session, asset_id: int) -> dict:
|
||||
|
||||
|
||||
def get_security_overview(session: Session) -> list:
|
||||
"""所有有安全检查数据的服务器评分总览(按评分升序,风险高的在前)"""
|
||||
asset_ids = session.exec(select(SecurityCheck.asset_id).distinct()).all()
|
||||
result = [get_asset_security(session, aid) for aid in asset_ids]
|
||||
"""所有有安全检查数据的服务器评分总览(按评分升序,风险高的在前)
|
||||
|
||||
性能优化:用子查询取每个 (asset_id, check_item) 的最新 ts,仅拉取最新记录,
|
||||
避免全表扫描(历史数据量大时内存可控)。
|
||||
"""
|
||||
latest_ts = (
|
||||
select(
|
||||
SecurityCheck.asset_id,
|
||||
SecurityCheck.check_item,
|
||||
func.max(SecurityCheck.ts).label("max_ts"),
|
||||
)
|
||||
.group_by(SecurityCheck.asset_id, SecurityCheck.check_item)
|
||||
.subquery()
|
||||
)
|
||||
stmt = select(SecurityCheck).join(
|
||||
latest_ts,
|
||||
(SecurityCheck.asset_id == latest_ts.c.asset_id)
|
||||
& (SecurityCheck.check_item == latest_ts.c.check_item)
|
||||
& (SecurityCheck.ts == latest_ts.c.max_ts),
|
||||
)
|
||||
all_checks = session.exec(stmt).all()
|
||||
|
||||
# 按 asset_id 分组
|
||||
latest_by_asset: dict = {}
|
||||
for check in all_checks:
|
||||
latest_by_asset.setdefault(check.asset_id, []).append(check)
|
||||
|
||||
result = []
|
||||
for asset_id, checks in latest_by_asset.items():
|
||||
score = compute_security_score(checks)
|
||||
result.append({
|
||||
"asset_id": asset_id,
|
||||
"score": score,
|
||||
"level": score_level(score),
|
||||
"checks_count": len(checks),
|
||||
"suggestions": [c.suggestion for c in checks if c.suggestion],
|
||||
})
|
||||
result.sort(key=lambda x: (x["score"] is None, x["score"] if x["score"] is not None else 0))
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""SSL 监控服务:站点证书探测 + 子域名管理
|
||||
|
||||
- probe_site_cert: 通过 ssl socket 探测站点证书信息(不校验证书链,仅读取到期信息)
|
||||
- create/check/check_all: 站点证书的创建与刷新
|
||||
- 子域名 CRUD:list/create/update/delete
|
||||
"""
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.timeutils import utcnow
|
||||
from app.models.asset import Asset
|
||||
from app.models.ssl import SiteCert, Subdomain
|
||||
|
||||
PROBE_TIMEOUT = 8 # 探测连接超时(秒)
|
||||
EXPIRING_THRESHOLD = 30 # 到期提醒阈值(天)
|
||||
|
||||
|
||||
def days_until(d: Optional[date]) -> Optional[int]:
|
||||
"""计算距今天数(负数为已过期)"""
|
||||
if d is None:
|
||||
return None
|
||||
return (d - date.today()).days
|
||||
|
||||
|
||||
def _judge_status(days: Optional[int]) -> str:
|
||||
"""按剩余天数定级:expired / expiring / valid"""
|
||||
if days is None:
|
||||
return "unknown"
|
||||
if days < 0:
|
||||
return "expired"
|
||||
if days <= EXPIRING_THRESHOLD:
|
||||
return "expiring"
|
||||
return "valid"
|
||||
|
||||
|
||||
def probe_site_cert(hostname: str, port: int = 443) -> dict:
|
||||
"""探测目标站点证书信息(探测失败时抛异常)
|
||||
|
||||
返回字段:subject_cn / issuer / valid_from / valid_to / fingerprint / san_list
|
||||
"""
|
||||
ctx = ssl.create_default_context()
|
||||
# 不校验证书链:即使证书已过期/自签名也能读到到期信息
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
with socket.create_connection((hostname, port), timeout=PROBE_TIMEOUT) as sock:
|
||||
with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:
|
||||
der = ssock.getpeercert(binary_form=True)
|
||||
cert = x509.load_der_x509_certificate(der)
|
||||
try:
|
||||
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||||
san_list = san.value.get_values_for_type(x509.DNSName)
|
||||
except x509.ExtensionNotFound:
|
||||
san_list = []
|
||||
return {
|
||||
"subject_cn": cert.subject.rfc4514_string() or None,
|
||||
"issuer": cert.issuer.rfc4514_string() or None,
|
||||
"valid_from": cert.not_valid_before_utc.date(),
|
||||
"valid_to": cert.not_valid_after_utc.date(),
|
||||
"fingerprint": cert.fingerprint(hashes.SHA256()).hex(),
|
||||
"san_list": san_list,
|
||||
}
|
||||
|
||||
|
||||
def _to_dict(cert: SiteCert, asset_name: Optional[str] = None) -> dict:
|
||||
"""SiteCert 模型转 API 返回结构(含动态计算的剩余天数)"""
|
||||
days = days_until(cert.valid_to)
|
||||
return {
|
||||
"id": cert.id,
|
||||
"hostname": cert.hostname,
|
||||
"port": cert.port,
|
||||
"asset_id": cert.asset_id,
|
||||
"asset_name": asset_name,
|
||||
"issuer": cert.issuer,
|
||||
"subject_cn": cert.subject_cn,
|
||||
"valid_from": cert.valid_from.isoformat() if cert.valid_from else None,
|
||||
"valid_to": cert.valid_to.isoformat() if cert.valid_to else None,
|
||||
"days_to_expiry": days,
|
||||
"status": cert.status,
|
||||
"error": cert.error,
|
||||
"last_checked_at": cert.last_checked_at.isoformat() if cert.last_checked_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------- 子域名 CRUD ----------------
|
||||
|
||||
def list_subdomains(session: Session, asset_id: Optional[int] = None) -> list:
|
||||
stmt = select(Subdomain).order_by(Subdomain.host)
|
||||
if asset_id is not None:
|
||||
stmt = stmt.where(Subdomain.asset_id == asset_id)
|
||||
subs = session.exec(stmt).all()
|
||||
# 带上所属域名,便于前端展示全名
|
||||
assets = session.exec(select(Asset).where(Asset.id.in_({s.asset_id for s in subs}))).all()
|
||||
name_map = {a.id: a.name for a in assets}
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"asset_id": s.asset_id,
|
||||
"asset_name": name_map.get(s.asset_id),
|
||||
"host": s.host,
|
||||
"record_type": s.record_type,
|
||||
"record_value": s.record_value,
|
||||
"is_active": s.is_active,
|
||||
"note": s.note,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
}
|
||||
for s in subs
|
||||
]
|
||||
|
||||
|
||||
def create_subdomain(session: Session, data: dict) -> Subdomain:
|
||||
sub = Subdomain(
|
||||
asset_id=data["asset_id"],
|
||||
host=data["host"].strip().lower(),
|
||||
record_type=data.get("record_type"),
|
||||
record_value=data.get("record_value"),
|
||||
is_active=data.get("is_active", True),
|
||||
note=data.get("note"),
|
||||
)
|
||||
session.add(sub)
|
||||
session.commit()
|
||||
session.refresh(sub)
|
||||
return sub
|
||||
|
||||
|
||||
def update_subdomain(session: Session, sub_id: int, data: dict) -> Subdomain:
|
||||
sub = session.get(Subdomain, sub_id)
|
||||
if not sub:
|
||||
raise ValueError(f"子域名记录不存在(id={sub_id})")
|
||||
if "host" in data and data["host"]:
|
||||
sub.host = data["host"].strip().lower()
|
||||
for key in ("record_type", "record_value", "note"):
|
||||
if key in data:
|
||||
setattr(sub, key, data[key])
|
||||
if "is_active" in data:
|
||||
sub.is_active = bool(data["is_active"])
|
||||
session.add(sub)
|
||||
session.commit()
|
||||
session.refresh(sub)
|
||||
return sub
|
||||
|
||||
|
||||
def delete_subdomain(session: Session, sub_id: int) -> None:
|
||||
sub = session.get(Subdomain, sub_id)
|
||||
if not sub:
|
||||
raise ValueError(f"子域名记录不存在(id={sub_id})")
|
||||
session.delete(sub)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ---------------- 站点证书监控 ----------------
|
||||
|
||||
def _apply_probe(cert: SiteCert, info: dict) -> SiteCert:
|
||||
"""把探测结果写入模型并定级"""
|
||||
cert.subject_cn = info["subject_cn"]
|
||||
cert.issuer = info["issuer"]
|
||||
cert.valid_from = info["valid_from"]
|
||||
cert.valid_to = info["valid_to"]
|
||||
cert.fingerprint = info["fingerprint"]
|
||||
cert.error = None
|
||||
cert.status = _judge_status(days_until(info["valid_to"]))
|
||||
cert.last_checked_at = utcnow()
|
||||
return cert
|
||||
|
||||
|
||||
def check_one(session: Session, cert: SiteCert) -> SiteCert:
|
||||
"""重新探测单条证书记录(失败则标记 error,保留旧到期信息)"""
|
||||
try:
|
||||
info = probe_site_cert(cert.hostname, cert.port)
|
||||
_apply_probe(cert, info)
|
||||
except Exception as e: # noqa: BLE001
|
||||
cert.status = "error"
|
||||
cert.error = str(e)[:200]
|
||||
cert.last_checked_at = utcnow()
|
||||
session.add(cert)
|
||||
session.commit()
|
||||
session.refresh(cert)
|
||||
return cert
|
||||
|
||||
|
||||
def create_site_cert(session: Session, hostname: str, port: int = 443, asset_id: Optional[int] = None) -> SiteCert:
|
||||
"""创建探测目标并立即探测一次;hostname+port 已存在则复用并刷新"""
|
||||
hostname = hostname.strip().lower()
|
||||
existing = session.exec(
|
||||
select(SiteCert).where(SiteCert.hostname == hostname, SiteCert.port == port)
|
||||
).first()
|
||||
if existing:
|
||||
if asset_id is not None:
|
||||
existing.asset_id = asset_id
|
||||
return check_one(session, existing)
|
||||
cert = SiteCert(hostname=hostname, port=port, asset_id=asset_id)
|
||||
session.add(cert)
|
||||
session.commit()
|
||||
session.refresh(cert)
|
||||
return check_one(session, cert)
|
||||
|
||||
|
||||
def list_site_certs(session: Session, status: Optional[str] = None, asset_id: Optional[int] = None) -> list:
|
||||
stmt = select(SiteCert)
|
||||
if status:
|
||||
stmt = stmt.where(SiteCert.status == status)
|
||||
if asset_id is not None:
|
||||
stmt = stmt.where(SiteCert.asset_id == asset_id)
|
||||
certs = session.exec(stmt.order_by(SiteCert.id)).all()
|
||||
ids = {c.asset_id for c in certs if c.asset_id}
|
||||
assets = session.exec(select(Asset).where(Asset.id.in_(ids))).all() if ids else []
|
||||
name_map = {a.id: a.name for a in assets}
|
||||
return [_to_dict(c, name_map.get(c.asset_id)) for c in certs]
|
||||
|
||||
|
||||
def delete_site_cert(session: Session, cert_id: int) -> None:
|
||||
cert = session.get(SiteCert, cert_id)
|
||||
if not cert:
|
||||
raise ValueError(f"证书监控记录不存在(id={cert_id})")
|
||||
session.delete(cert)
|
||||
session.commit()
|
||||
|
||||
|
||||
def check_all_site_certs(session: Session) -> dict:
|
||||
"""全量刷新所有站点证书,返回统计与异常清单"""
|
||||
certs = session.exec(select(SiteCert)).all()
|
||||
stats = {"total": len(certs), "ok": 0, "error": 0, "expiring": 0, "expired": 0}
|
||||
problems = []
|
||||
for cert in certs:
|
||||
check_one(session, cert)
|
||||
if cert.status == "error":
|
||||
stats["error"] += 1
|
||||
problems.append({"hostname": cert.hostname, "detail": cert.error})
|
||||
elif cert.status == "expired":
|
||||
stats["expired"] += 1
|
||||
problems.append(
|
||||
{"hostname": cert.hostname, "detail": f"证书已过期 {abs(days_until(cert.valid_to))} 天"}
|
||||
)
|
||||
elif cert.status == "expiring":
|
||||
stats["expiring"] += 1
|
||||
problems.append(
|
||||
{"hostname": cert.hostname, "detail": f"{days_until(cert.valid_to)} 天后到期"}
|
||||
)
|
||||
else:
|
||||
stats["ok"] += 1
|
||||
return {"stats": stats, "problems": problems}
|
||||
|
||||
|
||||
def build_cert_message(cert: SiteCert) -> str:
|
||||
"""生成单条证书提醒文案"""
|
||||
days = days_until(cert.valid_to)
|
||||
if days is None:
|
||||
return f"- {cert.hostname}:证书信息未知"
|
||||
if days < 0:
|
||||
return f"- ⚠️ {cert.hostname}:证书已过期 {abs(days)} 天"
|
||||
return f"- {cert.hostname}:{days} 天后到期({cert.valid_to})"
|
||||
@@ -6,15 +6,18 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
logger = logging.getLogger("vps-manager.sync")
|
||||
|
||||
from app.adapters import registry
|
||||
from app.adapters.base import BaseAdapter
|
||||
from app.core import crypto
|
||||
from app.core.timeutils import utcnow
|
||||
from app.models.asset import (
|
||||
AIAccount,
|
||||
Asset,
|
||||
@@ -130,8 +133,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic
|
||||
currency=vps.currency,
|
||||
)
|
||||
session.add(asset)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
session.flush() # 获取 asset.id,统一在循环外提交
|
||||
session.add(
|
||||
VPSDetail(
|
||||
asset_id=asset.id,
|
||||
@@ -176,8 +178,7 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) ->
|
||||
expiry_date=dom.expiry_date,
|
||||
)
|
||||
session.add(asset)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
session.flush() # 获取 asset.id,统一在循环外提交
|
||||
session.add(
|
||||
DomainDetail(
|
||||
asset_id=asset.id,
|
||||
@@ -219,10 +220,11 @@ def sync_provider(session: Session, provider_id: int) -> dict:
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["account_error"] = str(e)
|
||||
|
||||
provider.last_synced_at = datetime.utcnow()
|
||||
provider.last_synced_at = utcnow()
|
||||
session.add(provider)
|
||||
session.commit()
|
||||
result["last_synced_at"] = provider.last_synced_at.isoformat()
|
||||
logger.info("同步平台 provider=%s result=%s", provider.slug, result)
|
||||
return result
|
||||
|
||||
|
||||
@@ -262,7 +264,7 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict:
|
||||
detail=f"无法确定 AI 适配器({ai.provider})",
|
||||
)
|
||||
|
||||
api_key = crypto.decrypt(ai.api_key_encrypted) or ai.api_key
|
||||
api_key = crypto.decrypt(ai.api_key_encrypted)
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="未配置 API Key"
|
||||
@@ -286,7 +288,7 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict:
|
||||
if acc.balance is not None:
|
||||
ai.balance = acc.balance
|
||||
ai.currency = acc.currency
|
||||
ai.last_synced_at = datetime.utcnow()
|
||||
ai.last_synced_at = utcnow()
|
||||
session.add(ai)
|
||||
session.commit()
|
||||
session.refresh(ai)
|
||||
@@ -320,5 +322,5 @@ def sync_all_ai_balances(session: Session) -> dict:
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"errors": errors,
|
||||
"synced_at": datetime.utcnow().isoformat(),
|
||||
"synced_at": utcnow().isoformat(),
|
||||
}
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>{{ app_name }}</title>
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="manifest" href="/static/manifest.json?v={{ asset_version }}">
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png?v={{ asset_version }}">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="资产管理">
|
||||
<script src="/static/js/tailwind.js"></script>
|
||||
<script src="/static/js/tailwind.js?v={{ asset_version }}"></script>
|
||||
<script>tailwind.config = { darkMode: 'class' };</script>
|
||||
<script src="/static/js/vue.global.prod.js"></script>
|
||||
<script src="/static/js/chart.umd.js"></script>
|
||||
<script src="/static/js/vue.global.prod.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/chart.umd.js?v={{ asset_version }}"></script>
|
||||
<script>window.APP_CONFIG = { appName: "{{ app_name }}" };</script>
|
||||
<style>
|
||||
html { -webkit-tap-highlight-color: transparent; }
|
||||
@@ -24,8 +24,20 @@
|
||||
</head>
|
||||
<body class="bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-200 antialiased">
|
||||
<div id="app"></div>
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/api.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/store.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/dashboard.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/assets.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/subscriptions.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/cloudflare.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/providers.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/servers.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/domains.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/ai.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/monitor.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/settings.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/modals.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/app.js?v={{ asset_version }}"></script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
|
||||
Reference in New Issue
Block a user