PayrollManagementSystem/backend/app/services/monitor_collector.py
liuwenxin 1461a8d113 feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
2026-11-03 11:20:55 +08:00

478 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""单应用进程的独立采集线程;探测有超时,写入异常不会进入业务请求。"""
import logging
import threading
from collections import OrderedDict
from copy import deepcopy
from datetime import timedelta
from pathlib import Path
from time import monotonic, perf_counter
import psutil
from redis import Redis
from sqlalchemy import create_engine, delete, select, text
from sqlalchemy.orm import sessionmaker
from app.core.config import BACKEND_DIR
from app.core.monitoring import (
REQUEST_ID,
Recorder,
fingerprint,
merge_metrics,
minute_of,
now_local,
)
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
from app.repositories import monitor_repository as repo
from app.services.tplus import automation
logger = logging.getLogger(__name__)
class RuntimeErrorHandler(logging.Handler):
"""后台错误只采集固定摘要;HTTP 错误由中间件记录,避免同一异常重复。"""
def __init__(self, recorder):
super().__init__(logging.ERROR)
self.recorder = recorder
def emit(self, record):
if (
record.name.startswith("app.")
and ".monitor" not in record.name
and REQUEST_ID.get() == "-"
and getattr(record, "request_id", "-") == "-"
):
self.recorder.runtime_error(record.name)
class MonitorCollector:
def __init__(self, settings, recorder: Recorder):
self.settings, self.recorder = settings, recorder
self.recorder.slow_ms = settings.monitor_slow_request_ms
self.lock = threading.Lock()
self.stopped = threading.Event()
self.thread = None
self.engine = None
self.redis = None
self.process = None
self.samples = OrderedDict()
self.conditions = {}
self.unknown_codes = set()
self.pending_incidents = {}
self.since = {}
self.persisted = set()
self.last_log = 0
self.handler = RuntimeErrorHandler(recorder)
self.state = {
"collected_at": None,
"last_persisted_at": None,
"resources": {},
"dependencies": [],
"tasks": {},
"alerts": [],
"collection_status": "unknown",
"collection_message": "正在等待首次采集。"
if settings.monitor_enabled
else "后台采集未启用。",
}
def current(self):
with self.lock:
return deepcopy(self.state)
def sample_history(self):
with self.lock:
return deepcopy(self.samples)
def start(self):
if not self.settings.monitor_enabled or self.thread:
return
self.stopped.clear()
logging.getLogger().addHandler(self.handler)
self.thread = threading.Thread(target=self._run, name="system-monitor", daemon=True)
self.thread.start()
def stop(self):
self.stopped.set()
if self.thread:
self.thread.join(timeout=15)
if self.thread.is_alive():
logger.warning("监控采集线程未在关闭窗口内结束")
return
logging.getLogger().removeHandler(self.handler)
if self.redis:
self.redis.close()
if self.engine:
self.engine.dispose()
self.thread = None
def _factory(self):
if self.engine is None:
self.engine = create_engine(
self.settings.database_url,
pool_size=1,
max_overflow=0,
pool_timeout=2,
pool_pre_ping=True,
connect_args={"connect_timeout": 2, "read_timeout": 2, "write_timeout": 2},
)
return sessionmaker(self.engine, expire_on_commit=False)
def _resources(self):
first = self.process is None
if first:
self.process = psutil.Process()
psutil.cpu_percent(None)
self.process.cpu_percent(None)
memory = psutil.virtual_memory()
disk = psutil.disk_usage(str(BACKEND_DIR.anchor))
return {
"cpu_percent": None if first else psutil.cpu_percent(None),
"memory_percent": memory.percent,
"disk_percent": disk.percent,
"memory_total_mb": round(memory.total / 1024**2),
"disk_free_gb": round(disk.free / 1024**3, 2),
"process_cpu_percent": None
if first
else round(self.process.cpu_percent(None) / (psutil.cpu_count() or 1), 2),
"process_memory_mb": round(self.process.memory_info().rss / 1024**2, 2),
"scope": "进程可见系统资源,未换算容器限额"
if Path("/.dockerenv").exists()
else "应用所在系统的可见资源",
"process_cpu_scope": "占可见 CPU 总量的比例",
}
def _dependencies(self, at):
dependencies = [
{
"name": "应用",
"status": "normal",
"message": "采集线程正在运行。",
"checked_at": at,
"duration_ms": None,
}
]
tasks = {"status": "unknown", "message": "尚无采集记录。", "alerts": []}
for name in ("MySQL", "Redis"):
started = perf_counter()
try:
if name == "MySQL":
with self._factory()() as session:
session.execute(text("SELECT 1"))
else:
if self.redis is None:
self.redis = Redis.from_url(
self.settings.redis_url, socket_connect_timeout=2, socket_timeout=2
)
self.redis.ping()
status, message = "normal", "连接正常。"
except Exception:
status, message = "error", "连接检查失败,请核对服务和网络。"
dependencies.append(
{
"name": name,
"status": status,
"message": message,
"checked_at": at,
"duration_ms": round((perf_counter() - started) * 1000, 2),
}
)
if dependencies[1]["status"] == "normal":
try:
with self._factory()() as session:
tasks = automation.overview(session, settings=self.settings, now=at)
source_configured = bool(
self.settings.t_plus_enabled or self.settings.t_plus_connection_file
)
if not source_configured:
status, message = "unknown", "T+ 未配置。"
elif tasks.get("stop_requested"):
status, message = "unknown", "已主动请求停止采集。"
elif not tasks["enabled"]:
status, message = "unknown", "自动采集未启用,可在原页面查看手动任务。"
else:
status = "warning" if tasks["alerts"] else "normal"
message = (
"存在采集异常,请查看任务与同步。"
if tasks["alerts"]
else "周期采集状态正常。"
)
tasks.update(status=status, message=message)
except Exception:
tasks = {
"status": "unknown",
"message": "任务状态读取失败,请检查迁移和服务日志。",
"alerts": [],
}
dependencies.append(
{
"name": "T+ 采集进程",
"status": tasks["status"],
"message": tasks["message"],
"checked_at": at,
"duration_ms": None,
}
)
return dependencies, tasks
def _evaluate(self, at, resources, dependencies, tasks):
active = {}
self.unknown_codes = set()
for dependency in dependencies:
if dependency["status"] == "error":
code = "MYSQL_UNAVAILABLE" if dependency["name"] == "MySQL" else "REDIS_UNAVAILABLE"
active[code] = ("error", dependency["name"], dependency["message"], None)
for field, threshold, code, title in (
("cpu_percent", self.settings.monitor_cpu_threshold, "CPU_HIGH", "CPU 占用持续偏高"),
(
"memory_percent",
self.settings.monitor_memory_threshold,
"MEMORY_HIGH",
"内存占用持续偏高",
),
("disk_percent", self.settings.monitor_disk_threshold, "DISK_HIGH", "磁盘空间不足"),
):
value = resources.get(field)
if value is None:
self.unknown_codes.add(code)
self.since.pop(code, None)
# 采集未知时不将仍存在的问题标为恢复。
if code in self.conditions:
previous = self.conditions[code]
active[code] = (
previous["level"],
previous["component"],
previous["message"],
None,
)
continue
if value >= threshold:
since = self.since.setdefault(code, at)
# 重启后的持续时间尚未建立时,不关闭历史同类告警。
self.unknown_codes.add(code)
if (
field == "disk_percent"
or (at - since).total_seconds()
>= self.settings.monitor_resource_duration_seconds
):
active[code] = ("warning", "resources", title + ",请检查资源趋势。", None)
else:
self.since.pop(code, None)
recent = self.recorder.metrics(minute_of(at) - timedelta(minutes=4))
metric = merge_metrics(m for endpoints in recent.values() for m in endpoints.values())
if (
metric["count"] >= self.settings.monitor_error_min_requests
and metric["error_rate"] >= self.settings.monitor_error_rate_threshold
):
active["HTTP_ERROR_RATE"] = (
"warning",
"http",
"近 5 分钟服务端错误率偏高,请查看失败接口。",
None,
)
if tasks.get("status") == "unknown" and "任务状态读取失败" in tasks.get("message", ""):
active["TASK_STATUS_UNKNOWN"] = ("warning", "tplus", tasks["message"], None)
for alert in tasks.get("alerts", []):
active[alert["code"]] = ("warning", "tplus", alert["message"], alert.get("batch_no"))
if "enabled" not in tasks:
self.unknown_codes.update(
{"WORKER_OFFLINE", "QUEUE_OVERDUE", "SYNC_STALE", "SYNC_REQUIRES_ATTENTION"}
)
for code, previous in self.conditions.items():
if code in self.unknown_codes:
active[code] = (
previous["level"],
previous["component"],
previous["message"],
previous.get("batch_no"),
)
for code, (level, component, message, batch) in active.items():
previous = self.conditions.get(code)
incident = previous or {
"id": 0,
"instance_id": self.recorder.instance_id,
"kind": "HEALTH",
"first_seen": at,
"occurrences": 0,
"code": code,
"fingerprint": fingerprint(code),
"resolved_at": None,
}
incident.update(
last_seen=at,
occurrences=incident["occurrences"] + 1,
level=level,
component=component,
message=message,
batch_no=batch,
)
self.conditions[code] = incident
self.pending_incidents[code] = deepcopy(incident)
for code in set(self.conditions) - set(active):
incident = self.conditions.pop(code)
incident.update(resolved_at=at)
self.pending_incidents[code] = deepcopy(incident)
def _persist(self, at, *, final=False):
pending = self.recorder.take_events()
try:
with self._factory()() as session:
# 首次采集复用历史未恢复事件,跨应用重启保留故障起点。
historical = {item.code: item for item in repo.active_events(session)}
for code, item in self.pending_incidents.items():
row = historical.get(code)
fields = {key: value for key, value in item.items() if key != "id"}
if row is None:
row = MonitorEvent(**fields)
session.add(row)
else:
for key in (
"last_seen",
"resolved_at",
"level",
"message",
"batch_no",
"instance_id",
):
setattr(row, key, fields.get(key))
row.occurrences = (
max(row.occurrences, fields["occurrences"])
if item.get("id") == row.id
else row.occurrences + fields["occurrences"]
)
session.flush()
if code in self.conditions:
self.conditions[code].update(
id=row.id, first_seen=row.first_seen, occurrences=row.occurrences
)
for code, row in historical.items():
if (
code not in self.conditions
and code not in self.pending_incidents
and code not in self.unknown_codes
):
# 只在本次完整采集后确认历史异常已恢复。
row.resolved_at = at
for item in pending:
session.add(MonitorEvent(instance_id=self.recorder.instance_id, **item))
completed = minute_of(at) + (timedelta(minutes=1) if final else timedelta())
metrics = self.recorder.metrics(end=completed)
written = []
for minute in sorted(set(self.samples) | set(metrics)):
if minute >= completed or minute in self.persisted:
continue
samples = self.samples.get(minute, [])
endpoints = metrics.get(minute, {})
resources = dict(samples[-1]) if samples else {}
for key in (
"cpu_percent",
"memory_percent",
"disk_percent",
"process_cpu_percent",
"process_memory_mb",
):
numbers = [sample[key] for sample in samples if sample.get(key) is not None]
resources[key] = round(sum(numbers) / len(numbers), 2) if numbers else None
session.add(
MonitorSnapshot(
instance_id=self.recorder.instance_id,
minute=minute,
sample_count=len(samples),
resources=resources,
requests=merge_metrics(endpoints.values()),
)
)
for (method, route), values in endpoints.items():
session.add(
MonitorRequestMinute(
instance_id=self.recorder.instance_id,
minute=minute,
method=method,
route=route,
metrics=values,
)
)
written.append(minute)
# 每次最多删除每表 1000 条,限定监控表及过期时间,避免长事务。
cutoff = at - timedelta(days=7)
for model, column in (
(MonitorSnapshot, MonitorSnapshot.minute),
(MonitorRequestMinute, MonitorRequestMinute.minute),
(MonitorEvent, MonitorEvent.last_seen),
):
expired = select(model.id).where(column < cutoff)
if model is MonitorEvent:
expired = expired.where(
(MonitorEvent.kind != "HEALTH") | MonitorEvent.resolved_at.is_not(None)
)
ids = session.scalars(expired.order_by(column).limit(1000)).all()
if ids:
session.execute(delete(model).where(model.id.in_(ids)))
session.commit()
self.persisted.update(written)
self.persisted = {
minute
for minute in self.persisted
if minute >= minute_of(at) - timedelta(minutes=10)
}
self.pending_incidents.clear()
with self.lock:
self.state.update(
last_persisted_at=at,
collection_status="normal",
collection_message="采集正常,分钟历史保留 7 天。",
)
except Exception:
self.recorder.restore_events(pending)
with self.lock:
self.state.update(
collection_status="warning",
collection_message="历史写入失败,部分数据可能缺失;请检查数据库迁移和服务日志。",
)
if monotonic() - self.last_log >= 60:
logger.warning("监控历史写入失败,请检查迁移及数据库", exc_info=True)
self.last_log = monotonic()
def tick(self):
at = now_local()
try:
resources = self._resources()
except Exception:
resources = {"scope": "资源采集失败"}
logger.warning("系统资源采集失败", exc_info=True)
dependencies, tasks = self._dependencies(at)
with self.lock:
self.samples.setdefault(minute_of(at), []).append(resources)
while len(self.samples) > 10:
self.samples.popitem(last=False)
self._evaluate(at, resources, dependencies, tasks)
with self.lock:
self.state.update(
collected_at=at, resources=resources, dependencies=dependencies, tasks=tasks
)
self._persist(at)
with self.lock:
self.state["alerts"] = deepcopy(list(self.conditions.values()))
if not resources.get("memory_percent"):
self.state.update(
collection_status="warning",
collection_message="资源采集失败,当前资源指标未知。",
)
def _run(self):
while not self.stopped.is_set():
started = monotonic()
try:
self.tick()
except Exception:
logger.warning("监控采集周期失败", exc_info=True)
with self.lock:
self.state.update(
collection_status="warning",
collection_message="采集周期失败,请检查服务日志。",
)
self.stopped.wait(max(0, 15 - (monotonic() - started)))
if self.engine is not None:
self._persist(now_local(), final=True)