system_management_service.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. """系统管理服务(契约1.23):资源监控、系统信息、健康检查与维护操作。
  2. 对齐 Java 版 com.dms.system.SystemManagementService:
  3. - MetricSampler 后台线程按固定间隔采集 CPU/内存写 sys_metric_sample;
  4. - 查询类接口实时读取(psutil);psutil 缺失时监控接口返回 METRIC_UNAVAILABLE;
  5. - 维护操作(预览缓存清理/强制下线)由 API 层记审计。
  6. """
  7. from __future__ import annotations
  8. import logging
  9. import os
  10. import platform
  11. import shutil
  12. import sys
  13. import threading
  14. from collections import deque
  15. from datetime import datetime, timedelta, timezone
  16. from pathlib import Path
  17. from typing import Any
  18. import sqlalchemy as sa
  19. from flask import current_app
  20. from dms.common.errors import MetricUnavailableError
  21. from dms.extensions import db
  22. from dms.models import MetricSample
  23. from dms.storage.paths import STORAGE_SUBDIRECTORIES
  24. logger = logging.getLogger(__name__)
  25. API_CONTRACT_VERSION = "1.30.0"
  26. BACKEND_KIND = "PYTHON"
  27. try:
  28. import psutil
  29. PSUTIL_AVAILABLE = True
  30. except ImportError: # pragma: no cover - 部署环境缺依赖时优雅降级
  31. psutil = None # type: ignore[assignment]
  32. PSUTIL_AVAILABLE = False
  33. # ─── 采样器 ──────────────────────────────────────────────────────────────────
  34. class MetricSampler:
  35. """CPU/内存周期采样线程(30 秒间隔,7 天保留,物理删除过期行)。"""
  36. def __init__(
  37. self,
  38. app,
  39. interval_seconds: int = 30,
  40. retention_days: int = 7,
  41. ) -> None:
  42. self._app = app
  43. self._interval_seconds = max(10, interval_seconds)
  44. self._retention_days = max(1, retention_days)
  45. self._stop_event = threading.Event()
  46. self._thread: threading.Thread | None = None
  47. @property
  48. def running(self) -> bool:
  49. return self._thread is not None and self._thread.is_alive()
  50. def start(self) -> None:
  51. if not PSUTIL_AVAILABLE:
  52. logger.warning("psutil 未安装,资源采样器未启动")
  53. return
  54. if self._thread is not None and self._thread.is_alive():
  55. return
  56. # 预热 CPU 百分比基线(首次调用返回 0.0 无意义,丢弃)
  57. try:
  58. psutil.cpu_percent(interval=None)
  59. except Exception: # noqa: BLE001
  60. pass
  61. self._stop_event.clear()
  62. self._thread = threading.Thread(
  63. target=self._run_loop, name="dms-metric-sampler", daemon=True
  64. )
  65. self._thread.start()
  66. def stop(self) -> None:
  67. self._stop_event.set()
  68. def _run_loop(self) -> None:
  69. while not self._stop_event.wait(self._interval_seconds):
  70. try:
  71. self._tick_once()
  72. except Exception: # noqa: BLE001 - 单次采样失败不终止线程
  73. logger.warning("资源采样失败", exc_info=True)
  74. def _tick_once(self) -> None:
  75. cpu_percent = psutil.cpu_percent(interval=None)
  76. memory = psutil.virtual_memory()
  77. with self._app.app_context():
  78. db.session.add(
  79. MetricSample(
  80. sampled_at=datetime.now(timezone.utc).replace(tzinfo=None),
  81. cpu_percent=round(cpu_percent, 2),
  82. mem_used_bytes=memory.used,
  83. mem_total_bytes=memory.total,
  84. )
  85. )
  86. threshold = datetime.now(timezone.utc).replace(
  87. tzinfo=None
  88. ) - timedelta(days=self._retention_days)
  89. db.session.execute(
  90. sa.delete(MetricSample).where(MetricSample.sampled_at < threshold)
  91. )
  92. db.session.commit()
  93. # ─── 查询类 ──────────────────────────────────────────────────────────────────
  94. def _require_psutil() -> None:
  95. if not PSUTIL_AVAILABLE:
  96. raise MetricUnavailableError()
  97. def _iso(moment: datetime) -> str:
  98. return (
  99. moment.replace(tzinfo=timezone.utc)
  100. .isoformat(timespec="milliseconds")
  101. .replace("+00:00", "Z")
  102. )
  103. def current_metrics() -> dict[str, Any]:
  104. """实时资源快照:CPU/内存/交换分区/磁盘分区/开机时长。"""
  105. _require_psutil()
  106. disks: list[dict[str, Any]] = []
  107. for partition in psutil.disk_partitions():
  108. try:
  109. usage = psutil.disk_usage(partition.mountpoint)
  110. except (PermissionError, OSError):
  111. continue # Windows 无介质光驱等不可读分区
  112. disks.append(
  113. {
  114. "device": partition.device,
  115. "mountpoint": partition.mountpoint,
  116. "fstype": partition.fstype,
  117. "totalBytes": usage.total,
  118. "usedBytes": usage.used,
  119. "freeBytes": usage.free,
  120. "usedPercent": round(usage.percent, 2),
  121. }
  122. )
  123. memory = psutil.virtual_memory()
  124. swap = psutil.swap_memory()
  125. boot_time = datetime.fromtimestamp(psutil.boot_time(), tz=timezone.utc)
  126. return {
  127. "cpu": {
  128. "percent": round(psutil.cpu_percent(interval=0.3), 2),
  129. "coreCount": psutil.cpu_count(logical=True) or 0,
  130. "loadAvgOneMinute": (
  131. round(os.getloadavg()[0], 2) if hasattr(os, "getloadavg") else None
  132. ),
  133. },
  134. "memory": {
  135. "totalBytes": memory.total,
  136. "usedBytes": memory.used,
  137. "availableBytes": memory.available,
  138. "usedPercent": round(memory.percent, 2),
  139. },
  140. "swap": {
  141. "totalBytes": swap.total,
  142. "usedBytes": swap.used,
  143. "usedPercent": round(swap.percent, 2),
  144. },
  145. "disks": disks,
  146. "bootAt": _iso(boot_time.replace(tzinfo=None)),
  147. }
  148. def metric_history(hours: int) -> dict[str, Any]:
  149. """近 N 小时采样序列(升序,供趋势图)。"""
  150. _require_psutil()
  151. threshold = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(
  152. hours=hours
  153. )
  154. samples = db.session.scalars(
  155. sa.select(MetricSample)
  156. .where(MetricSample.sampled_at >= threshold)
  157. .order_by(MetricSample.sampled_at.asc())
  158. .limit(24 * 3600) # 防御性上限(30秒间隔×24小时=2880)
  159. ).all()
  160. return {
  161. "hours": hours,
  162. "entries": [
  163. {
  164. "sampledAt": _iso(sample.sampled_at),
  165. "cpuPercent": sample.cpu_percent,
  166. "memUsedBytes": sample.mem_used_bytes,
  167. "memTotalBytes": sample.mem_total_bytes,
  168. }
  169. for sample in samples
  170. ],
  171. }
  172. def process_metrics() -> dict[str, Any]:
  173. """应用进程监控:CPU/内存/线程数/运行时长。"""
  174. _require_psutil()
  175. process = psutil.Process()
  176. memory_info = process.memory_info()
  177. started = datetime.fromtimestamp(process.create_time(), tz=timezone.utc)
  178. return {
  179. "pid": process.pid,
  180. "cpuPercent": round(
  181. sum(process.cpu_percent(interval=0.3) for _ in range(1)) / max(
  182. 1, (psutil.cpu_count(logical=True) or 1)
  183. ),
  184. 2,
  185. ),
  186. "memoryRssBytes": memory_info.rss,
  187. "memoryVmsBytes": getattr(memory_info, "vms", 0),
  188. "threadCount": process.num_threads(),
  189. "startedAt": _iso(started.replace(tzinfo=None)),
  190. "uptimeSeconds": max(
  191. 0, int((datetime.now(tz=timezone.utc) - started).total_seconds())
  192. ),
  193. "pythonVersion": platform.python_version(),
  194. }
  195. def storage_usage() -> list[dict[str, Any]]:
  196. """七个存储目录的大小与文件数统计(实时遍历)。"""
  197. root = Path(current_app.config["DMS_STORAGE_ROOT"])
  198. entries: list[dict[str, Any]] = []
  199. for name in STORAGE_SUBDIRECTORIES:
  200. directory = root / name
  201. total_bytes = 0
  202. file_count = 0
  203. if directory.is_dir():
  204. for dirpath, _dirnames, filenames in os.walk(directory):
  205. for filename in filenames:
  206. try:
  207. total_bytes += (Path(dirpath) / filename).stat().st_size
  208. file_count += 1
  209. except OSError:
  210. continue
  211. entries.append(
  212. {
  213. "directory": name,
  214. "totalBytes": total_bytes,
  215. "fileCount": file_count,
  216. }
  217. )
  218. return entries
  219. def converter_status() -> dict[str, Any]:
  220. """LibreOffice 转换器状态(存活/并发占用/累计计数)。"""
  221. converter = current_app.extensions["dms_document_converter"]
  222. snapshot = (
  223. converter.status_snapshot()
  224. if hasattr(converter, "status_snapshot")
  225. else {"available": getattr(converter, "available", False)}
  226. )
  227. snapshot["previewEnabled"] = current_app.config.get(
  228. "DMS_OFFICE_PREVIEW_ENABLED", True
  229. )
  230. snapshot["timeoutSeconds"] = current_app.config.get(
  231. "DMS_LIBREOFFICE_TIMEOUT_SECONDS", 60
  232. )
  233. return snapshot
  234. def health_check() -> list[dict[str, Any]]:
  235. """一键体检:数据库/存储可写/转换器/备份目标/信封加密。"""
  236. items: list[dict[str, Any]] = []
  237. database = {"item": "DATABASE", "ok": False, "detail": ""}
  238. try:
  239. db.session.execute(sa.text("SELECT 1"))
  240. database["ok"] = True
  241. except Exception as exc: # noqa: BLE001
  242. database["detail"] = str(exc)[:200]
  243. items.append(database)
  244. storage = {"item": "STORAGE_WRITABLE", "ok": False, "detail": ""}
  245. try:
  246. root = Path(current_app.config["DMS_STORAGE_ROOT"])
  247. probe = root / ".dms-health-probe"
  248. probe.write_text("ok", encoding="utf-8")
  249. probe.unlink(missing_ok=True)
  250. storage["ok"] = True
  251. except OSError as exc:
  252. storage["detail"] = str(exc)[:200]
  253. items.append(storage)
  254. converter = current_app.extensions["dms_document_converter"]
  255. items.append(
  256. {
  257. "item": "LIBREOFFICE",
  258. "ok": bool(getattr(converter, "available", False)),
  259. "detail": "",
  260. }
  261. )
  262. backup = {"item": "BACKUP_TARGET", "ok": False, "detail": ""}
  263. try:
  264. engine = current_app.extensions["dms_backup_engine"]
  265. target = engine.build_target(engine.load_config())
  266. target.check_writable()
  267. backup["ok"] = True
  268. except Exception as exc: # noqa: BLE001
  269. backup["detail"] = str(exc)[:200]
  270. items.append(backup)
  271. kek_configured = bool(current_app.config.get("DMS_STORAGE_KEK_BYTES"))
  272. items.append(
  273. {
  274. "item": "STORAGE_ENCRYPTION",
  275. "ok": True,
  276. "detail": "KEK已配置" if kek_configured else "KEK未配置(明文落盘)",
  277. }
  278. )
  279. return items
  280. def system_info() -> dict[str, Any]:
  281. """系统信息卡片:版本/后端类型/运行环境/数据库/启动时间。"""
  282. from dms.config import resolve_backup_config_path, resolve_env_path
  283. database_url = str(current_app.config["SQLALCHEMY_DATABASE_URI"])
  284. if database_url.startswith("dm"):
  285. database_type = "DM (达梦)"
  286. elif database_url.startswith("mysql"):
  287. database_type = "MySQL"
  288. else:
  289. database_type = database_url.split(":", 1)[0]
  290. uptime_seconds = 0
  291. started_at = ""
  292. if PSUTIL_AVAILABLE:
  293. process = psutil.Process()
  294. started = datetime.fromtimestamp(process.create_time(), tz=timezone.utc)
  295. started_at = _iso(started.replace(tzinfo=None))
  296. uptime_seconds = max(
  297. 0, int((datetime.now(tz=timezone.utc) - started).total_seconds())
  298. )
  299. return {
  300. "contractVersion": API_CONTRACT_VERSION,
  301. "backendKind": BACKEND_KIND,
  302. "runtimeVersion": platform.python_version(),
  303. "operatingSystem": platform.platform(),
  304. "hostname": platform.node(),
  305. "machineArch": platform.machine(),
  306. "databaseType": database_type,
  307. "startedAt": started_at,
  308. "uptimeSeconds": uptime_seconds,
  309. "workingDirectory": os.getcwd(),
  310. "storageRoot": current_app.config["DMS_STORAGE_ROOT"],
  311. "backupConfigPath": str(resolve_backup_config_path()),
  312. "envFilePath": str(resolve_env_path()),
  313. "logFilePath": str(log_file_path()) if log_file_path() else "",
  314. # 契约1.26:监控信息展示开关(false 时前端隐藏维护操作之上的监控区块)
  315. "monitoringEnabled": bool(
  316. current_app.config.get("DMS_SYSTEM_MONITOR_ENABLED", False)
  317. ),
  318. }
  319. def runtime_config() -> list[dict[str, Any]]:
  320. """运行参数只读总览(脱敏:密钥类仅显示是否已配置)。"""
  321. def item(
  322. category: str, key: str, value: Any, unit: str = ""
  323. ) -> dict[str, Any]:
  324. return {"category": category, "key": key, "value": value, "unit": unit}
  325. config = current_app.config
  326. cors_configured = bool(os.environ.get("DMS_CORS_ORIGINS", "").strip())
  327. return [
  328. item(
  329. "认证",
  330. "访问令牌有效期",
  331. config.get("DMS_JWT_EXPIRES_SECONDS", 3600),
  332. "秒",
  333. ),
  334. item("认证", "保持登录有效期", 604800, "秒"),
  335. item("认证", "JWT密钥", "已配置" if config.get("DMS_JWT_SECRET") else "未配置"),
  336. item("存储", "存储根目录", config["DMS_STORAGE_ROOT"]),
  337. item(
  338. "存储",
  339. "信封加密KEK",
  340. "已配置" if config.get("DMS_STORAGE_KEK_BYTES") else "未配置(明文落盘)",
  341. ),
  342. item(
  343. "上传",
  344. "单请求上限",
  345. config.get("DMS_MAX_UPLOAD_SIZE", 0),
  346. "字节",
  347. ),
  348. item("上传", "单文件上限", config.get("DMS_MAX_FILE_SIZE", 0), "字节"),
  349. item("上传", "批量导入上限", config.get("DMS_BATCH_MAX_FILES", 0), "个"),
  350. item("业务", "业务时区", config.get("DMS_BUSINESS_TIMEZONE", "")),
  351. item(
  352. "转换",
  353. "LibreOffice预览",
  354. "启用" if config.get("DMS_OFFICE_PREVIEW_ENABLED", True) else "停用",
  355. ),
  356. item(
  357. "转换",
  358. "LibreOffice路径",
  359. config.get("DMS_LIBREOFFICE_EXECUTABLE", "") or "未配置",
  360. ),
  361. item(
  362. "转换",
  363. "转换超时",
  364. config.get("DMS_LIBREOFFICE_TIMEOUT_SECONDS", 60),
  365. "秒",
  366. ),
  367. item(
  368. "转换",
  369. "转换并发上限",
  370. config.get("DMS_LIBREOFFICE_MAX_CONCURRENCY", 2),
  371. "",
  372. ),
  373. item("安全", "CORS跨域白名单", "已配置" if cors_configured else "未配置(同源)"),
  374. item("安全", "日志文件", str(log_file_path()) if log_file_path() else "未启用"),
  375. item(
  376. "系统",
  377. "监控信息展示",
  378. "启用" if config.get("DMS_SYSTEM_MONITOR_ENABLED", False) else "停用",
  379. ),
  380. ]
  381. # ─── 维护操作 ────────────────────────────────────────────────────────────────
  382. def clear_preview_cache() -> dict[str, int]:
  383. """清空预览缓存目录(可重建),返回删除文件数与释放字节数。"""
  384. root = Path(current_app.config["DMS_STORAGE_ROOT"])
  385. preview_root = root / "preview"
  386. removed_files = 0
  387. removed_bytes = 0
  388. if preview_root.is_dir():
  389. for dirpath, _dirnames, filenames in os.walk(preview_root):
  390. for filename in filenames:
  391. target = Path(dirpath) / filename
  392. try:
  393. removed_bytes += target.stat().st_size
  394. target.unlink()
  395. removed_files += 1
  396. except OSError:
  397. continue
  398. # 清理空的前缀分层目录
  399. for child in preview_root.iterdir():
  400. if child.is_dir():
  401. try:
  402. child.rmdir()
  403. except OSError:
  404. pass
  405. return {"removedFiles": removed_files, "removedBytes": removed_bytes}
  406. # ─── 运行日志 ────────────────────────────────────────────────────────────────
  407. def log_file_path() -> Path | None:
  408. """运行日志文件路径(app.py 已配置文件 handler 时存在)。"""
  409. configured = current_app.config.get("DMS_LOG_FILE", "") if current_app else ""
  410. value = configured or os.environ.get("DMS_LOG_FILE", "").strip()
  411. if not value or value.upper() in {"OFF", "NONE"}:
  412. return None
  413. return Path(value)
  414. _LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
  415. def read_log_tail(lines: int = 500, level: str | None = None) -> dict[str, Any]:
  416. """读取运行日志尾部 N 行,按级别过滤([LEVEL] 标记匹配)。"""
  417. path = log_file_path()
  418. if path is None or not path.is_file():
  419. return {"file": str(path) if path else "", "lines": [], "truncated": False}
  420. normalized_level = (level or "").strip().upper()
  421. if normalized_level and normalized_level not in _LOG_LEVELS:
  422. normalized_level = None
  423. collected: deque[str] = deque(maxlen=lines)
  424. truncated = False
  425. try:
  426. with path.open("r", encoding="utf-8", errors="replace") as handle:
  427. if normalized_level:
  428. marker = f"[{normalized_level}]"
  429. for raw_line in handle:
  430. if marker in raw_line:
  431. collected.append(raw_line.rstrip("\r\n"))
  432. truncated = True # 级别过滤为全量扫描后再截尾
  433. else:
  434. for raw_line in handle:
  435. collected.append(raw_line.rstrip("\r\n"))
  436. except OSError as exc:
  437. return {"file": str(path), "lines": [], "truncated": False, "error": str(exc)}
  438. return {
  439. "file": str(path),
  440. "lines": list(collected)[-lines:],
  441. "truncated": truncated,
  442. }