fix+perf: SQLite连接busy_timeout修复并发锁/静态版本号缓存/删除资产批量清理/安全检查最新记录子查询修正/AI余额与SSL探测并发化/密钥常量时间比较

This commit is contained in:
gouki
2026-08-05 22:49:01 +00:00
parent 4f855ba0da
commit 4dba505d82
8 changed files with 121 additions and 58 deletions
+10 -2
View File
@@ -5,6 +5,7 @@
- 若已配置,则要求请求头携带正确的 X-API-Key,否则返回 401。
"""
import hmac
from typing import Optional
from fastapi import Header, HTTPException, status
@@ -12,13 +13,20 @@ from fastapi import Header, HTTPException, status
from app.core.config import settings
def _key_matches(provided: Optional[str], expected: str) -> bool:
"""常量时间比较密钥,避免时序旁路泄露密钥长度/前缀信息"""
if not provided:
return False
return hmac.compare_digest(provided.encode(), expected.encode())
async def require_api_key(
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
) -> None:
"""校验 API Key(可选启用)"""
if not settings.API_KEY:
return
if x_api_key != settings.API_KEY:
if not _key_matches(x_api_key, settings.API_KEY):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API Key 无效或缺失",
@@ -31,7 +39,7 @@ async def require_agent_key(
"""校验 Agent 上报 Key(可选启用)"""
if not settings.AGENT_KEY:
return
if x_agent_key != settings.AGENT_KEY:
if not _key_matches(x_agent_key, settings.AGENT_KEY):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Agent Key 无效或缺失",
+13 -9
View File
@@ -16,26 +16,26 @@ DATA_DIR.mkdir(parents=True, exist_ok=True)
ASSETS_DB_URL = f"sqlite:///{DATA_DIR / 'assets.db'}"
METRICS_DB_URL = f"sqlite:///{DATA_DIR / 'metrics.db'}"
# timeoutsqlite3 内置 busy 等待(秒),随连接创建生效。
# SQLite 默认使用 NullPool(每请求新建连接),PRAGMA busy_timeout 只在单连接有效,
# 必须通过 connect_args 传递,否则并发写入(Agent 上报 + 清理任务)会报 database is locked。
assets_engine = create_engine(
ASSETS_DB_URL,
echo=False,
connect_args={"check_same_thread": False},
pool_pre_ping=True,
connect_args={"check_same_thread": False, "timeout": 30},
)
metrics_engine = create_engine(
METRICS_DB_URL,
echo=False,
connect_args={"check_same_thread": False},
pool_pre_ping=True,
connect_args={"check_same_thread": False, "timeout": 30},
)
def _enable_wal(engine) -> None:
"""启用 WAL 模式,提升 SQLite 并发读写能力"""
"""启用 WAL 模式,提升 SQLite 并发读写能力journal_mode 持久化到库文件,设置一次即可)"""
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)
@@ -152,12 +152,16 @@ def _backfill_provider_services(conn) -> None:
def get_session() -> Generator[Session, None, None]:
"""资产库会话(默认)"""
with Session(assets_engine) as session:
"""资产库会话(默认)
expire_on_commit=Falsecommit 后不失效对象属性,避免后续访问触发隐式
重新加载查询;需要最新值的场景由调用方显式 session.refresh()。
"""
with Session(assets_engine, expire_on_commit=False) as session:
yield session
def get_metrics_session() -> Generator[Session, None, None]:
"""监控 / 日志库会话"""
with Session(metrics_engine) as session:
with Session(metrics_engine, expire_on_commit=False) as session:
yield session
+13 -5
View File
@@ -35,12 +35,20 @@ def _asset_version() -> str:
用于前端引用 ?v= 参数,绕开浏览器启发式缓存(旧响应无 Cache-Control
时存下的条目会被视为新鲜而不再回源验证)。
启动时计算一次并缓存:静态资源只在代码更新时变化,而更新后服务会重启,
避免每次页面请求都遍历 stat 整个 static 目录。
"""
latest = 0
for p in STATIC_DIR.rglob("*"):
if p.is_file():
latest = max(latest, int(p.stat().st_mtime))
return str(latest)
global _ASSET_VERSION_CACHE
if _ASSET_VERSION_CACHE is None:
latest = 0
for p in STATIC_DIR.rglob("*"):
if p.is_file():
latest = max(latest, int(p.stat().st_mtime))
_ASSET_VERSION_CACHE = str(latest)
return _ASSET_VERSION_CACHE
_ASSET_VERSION_CACHE: str | None = None
# 全局日志配置:统一格式,便于生产环境排查
logging.basicConfig(
+2 -13
View File
@@ -7,7 +7,7 @@ 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.models.monitor import MetricPoint, ServerInfo
from app.services import cleanup_service, security_service
router = APIRouter(prefix="/api/monitor", tags=["monitor"])
@@ -55,18 +55,7 @@ def info(asset_id: int, session: Session = Depends(get_metrics_session)) -> Opti
@router.get("/{asset_id}/security", summary="安全检查项(每项最新一条)")
def security(asset_id: int, session: Session = Depends(get_metrics_session)):
stmt = (
select(SecurityCheck)
.where(SecurityCheck.asset_id == asset_id)
.order_by(SecurityCheck.ts.desc())
.limit(50)
)
checks = session.exec(stmt).all()
latest_by_item = {}
for check in checks:
if check.check_item not in latest_by_item:
latest_by_item[check.check_item] = check
return list(latest_by_item.values())
return security_service.latest_checks(session, asset_id)
@router.get("/{asset_id}/security-score", summary="单个服务器安全评分与加固建议")
+8 -3
View File
@@ -283,14 +283,19 @@ def delete_asset(session: Session, asset_id: int) -> None:
def _cleanup_metrics_for_asset(asset_id: int) -> None:
"""清理 metrics.db 中该资产的 MetricPoint/ServerInfo/SecurityCheck/EventLog"""
"""清理 metrics.db 中该资产的 MetricPoint/ServerInfo/SecurityCheck/EventLog
使用批量 DELETE(而非逐行加载后删除):监控时序数据可能上万行,
逐行删除会全部载入内存且产生数万次 ORM 操作。
"""
from sqlmodel import delete
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.exec(delete(model).where(model.asset_id == asset_id))
ms.commit()
+18 -11
View File
@@ -15,19 +15,26 @@ DEFAULT_WEIGHT = 20
def latest_checks(session: Session, asset_id: int) -> list:
"""取每个检查项的最新一条"""
stmt = (
select(SecurityCheck)
"""取每个检查项的最新一条
用 GROUP BY max(ts) 子查询精确取每项最新记录:若用 limit(50) 后去重,
当某个检查项连续上报超过 50 次时会把其他项的最新记录挤出,导致评分失真。
"""
latest_ts = (
select(
SecurityCheck.check_item,
func.max(SecurityCheck.ts).label("max_ts"),
)
.where(SecurityCheck.asset_id == asset_id)
.order_by(SecurityCheck.ts.desc())
.limit(50)
.group_by(SecurityCheck.check_item)
.subquery()
)
checks = session.exec(stmt).all()
latest = {}
for check in checks:
if check.check_item not in latest:
latest[check.check_item] = check
return list(latest.values())
stmt = select(SecurityCheck).where(SecurityCheck.asset_id == asset_id).join(
latest_ts,
(SecurityCheck.check_item == latest_ts.c.check_item)
& (SecurityCheck.ts == latest_ts.c.max_ts),
)
return list(session.exec(stmt).all())
def compute_security_score(checks: list):
+26 -2
View File
@@ -224,12 +224,36 @@ def delete_site_cert(session: Session, cert_id: int) -> None:
def check_all_site_certs(session: Session) -> dict:
"""全量刷新所有站点证书,返回统计与异常清单"""
"""全量刷新所有站点证书,返回统计与异常清单
探测为纯网络 IO(单次最长 8s),用线程池并发探测后统一写库:
串行时 N 个站点最坏耗时 N×8s,并发后接近单站点耗时;
写库集中在主线程一次 commit(原来逐条 commit 产生 N 次事务)。
"""
from concurrent.futures import ThreadPoolExecutor
certs = session.exec(select(SiteCert)).all()
stats = {"total": len(certs), "ok": 0, "error": 0, "expiring": 0, "expired": 0}
problems = []
if not certs:
return {"stats": stats, "problems": problems}
# 并发探测(不碰数据库,线程安全)
with ThreadPoolExecutor(max_workers=min(8, len(certs))) as pool:
futures = {pool.submit(probe_site_cert, c.hostname, c.port): c for c in certs}
for future in futures:
cert = futures[future]
try:
_apply_probe(cert, future.result())
except Exception as e: # noqa: BLE001
cert.status = "error"
cert.error = str(e)[:200]
cert.last_checked_at = utcnow()
session.add(cert)
session.commit()
for cert in certs:
check_one(session, cert)
session.refresh(cert)
if cert.status == "error":
stats["error"] += 1
problems.append({"hostname": cert.hostname, "detail": cert.error})
+31 -13
View File
@@ -299,26 +299,44 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict:
}
def sync_all_ai_balances(session: Session) -> dict:
"""遍历所有 AI 账号资产,逐个刷新余额(单个失败不中断整体)"""
def sync_all_ai_balances(session: Session, max_workers: int = 5) -> dict:
"""并发刷新所有 AI 账号余额(单个失败不中断整体)
每个账号需调用外部 API(单次最长 30s),串行时总耗时随账号数线性增长;
改为线程池并发后显著提速。注意:SQLite Session 不能跨线程共享,
每个 worker 使用独立 Session(WAL 模式下多连接读写安全)。
"""
from concurrent.futures import ThreadPoolExecutor
from app.database import assets_engine
ai_assets = session.exec(
select(Asset).where(Asset.asset_type == AssetType.AI_AGENT)
).all()
asset_ids = [(a.id, a.name) for a in ai_assets]
def _refresh_one(asset_id: int):
with Session(assets_engine) as worker_session:
refresh_ai_balance(worker_session, asset_id)
success = 0
failed = 0
errors = []
for asset in ai_assets:
try:
refresh_ai_balance(session, asset.id)
success += 1
except HTTPException as e:
failed += 1
errors.append(f"{asset.name}: {e.detail}")
except Exception as e: # noqa: BLE001
failed += 1
errors.append(f"{asset.name}: {e}")
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(_refresh_one, aid): name for aid, name in asset_ids}
for future in futures:
name = futures[future]
try:
future.result()
success += 1
except HTTPException as e:
failed += 1
errors.append(f"{name}: {e.detail}")
except Exception as e: # noqa: BLE001
failed += 1
errors.append(f"{name}: {e}")
return {
"total": len(ai_assets),
"total": len(asset_ids),
"success": success,
"failed": failed,
"errors": errors,