按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
199 lines
7.6 KiB
Python
199 lines
7.6 KiB
Python
"""管理员监控视图:已落库历史与当前分钟合并,保持无数据与故障语义。"""
|
|
|
|
from collections import defaultdict
|
|
from datetime import timedelta
|
|
from time import monotonic
|
|
|
|
from app.core.exceptions import BadRequestError, NotFoundError
|
|
from app.core.monitoring import SHANGHAI, merge_metrics, minute_of, now_local
|
|
from app.core.response import PageResult
|
|
from app.repositories import monitor_repository as repo
|
|
from app.schemas.monitor import EventOut, OverviewOut
|
|
|
|
|
|
def window(query):
|
|
now = now_local()
|
|
end = query.end or now
|
|
if end.tzinfo:
|
|
end = end.astimezone(SHANGHAI).replace(tzinfo=None)
|
|
if end > now + timedelta(seconds=30) or end < now - timedelta(days=7):
|
|
raise BadRequestError("查询时间须在最近 7 天内")
|
|
return max(end - timedelta(hours=query.hours), now - timedelta(days=7)), end
|
|
|
|
|
|
def overview(session, collector):
|
|
state = collector.current()
|
|
now = now_local()
|
|
current_minute = minute_of(now)
|
|
stored = repo.snapshots(session, current_minute - timedelta(minutes=4), current_minute)
|
|
# 未落库分钟从有界缓冲补齐;仅跳过同实例已持久化的分钟。
|
|
known = {(row.instance_id, row.minute) for row in stored}
|
|
recent = collector.recorder.metrics(current_minute - timedelta(minutes=4))
|
|
values = [row.requests for row in stored]
|
|
values.extend(
|
|
metric
|
|
for minute, endpoints in recent.items()
|
|
if (collector.recorder.instance_id, minute) not in known
|
|
for metric in endpoints.values()
|
|
)
|
|
state["requests"] = merge_metrics(values)
|
|
stale = not state["collected_at"] or (now - state["collected_at"]).total_seconds() > 45
|
|
if stale:
|
|
state.update(
|
|
collection_status="unknown",
|
|
collection_message="采集未启用或已超过 45 秒未更新,当前状态未知。",
|
|
)
|
|
for dependency in state["dependencies"]:
|
|
dependency.update(status="unknown", message="采集已过期,请检查服务日志。")
|
|
persisted_alerts = {
|
|
row.code: EventOut.model_validate(row) for row in repo.active_events(session)
|
|
}
|
|
for item in state["alerts"]:
|
|
persisted_alerts[item["code"]] = EventOut.model_validate(item)
|
|
state["alerts"] = list(persisted_alerts.values())
|
|
status = (
|
|
"unknown"
|
|
if stale
|
|
else "error"
|
|
if any(dep["status"] == "error" for dep in state["dependencies"])
|
|
else "warning"
|
|
if state["alerts"] or state["collection_status"] == "warning"
|
|
else "normal"
|
|
)
|
|
return OverviewOut(
|
|
status=status,
|
|
environment=collector.settings.app_env,
|
|
version="0.1.0",
|
|
instance_id=collector.recorder.instance_id,
|
|
uptime_seconds=int(monotonic() - collector.recorder.started),
|
|
dropped_events=collector.recorder.dropped_events,
|
|
**state,
|
|
)
|
|
|
|
|
|
def trends(session, collector, query):
|
|
start, end = window(query)
|
|
stored = repo.snapshots(session, minute_of(start), end)
|
|
# 24 小时按 5 分钟、7 天按 30 分钟聚合,间断采集保留 null 点。
|
|
step = 1 if query.hours == 1 else 5 if query.hours == 24 else 30
|
|
groups = defaultdict(list)
|
|
for row in stored:
|
|
minute = row.minute.replace(minute=(row.minute.minute // step) * step)
|
|
groups[minute].append(
|
|
{"sample_count": row.sample_count, "resources": row.resources, "requests": row.requests}
|
|
)
|
|
at = minute_of(end)
|
|
recent = collector.recorder.metrics(start, end)
|
|
# 只补尚未持久化的分钟,历史查看不引入窗口外最新资源。
|
|
known = {(row.instance_id, row.minute) for row in stored}
|
|
sample_history = collector.sample_history()
|
|
for minute in set(recent) | set(sample_history):
|
|
if not start <= minute < end or (collector.recorder.instance_id, minute) in known:
|
|
continue
|
|
samples = sample_history.get(minute, [])
|
|
groups[minute.replace(minute=(minute.minute // step) * step)].append(
|
|
{
|
|
"sample_count": len(samples),
|
|
"resources": samples[-1] if samples else {},
|
|
"requests": merge_metrics(recent.get(minute, {}).values()),
|
|
}
|
|
)
|
|
cursor = minute_of(start).replace(minute=(start.minute // step) * step)
|
|
points = []
|
|
while cursor <= at:
|
|
rows = groups.get(cursor, [])
|
|
point = {"at": cursor, "sample_count": sum(row["sample_count"] for row in rows)}
|
|
for key in ("cpu_percent", "memory_percent", "disk_percent"):
|
|
values = [
|
|
(row["resources"].get(key), row["sample_count"])
|
|
for row in rows
|
|
if row["resources"].get(key) is not None and row["sample_count"]
|
|
]
|
|
point[key] = (
|
|
round(
|
|
sum(value * count for value, count in values)
|
|
/ sum(count for _, count in values),
|
|
2,
|
|
)
|
|
if values
|
|
else None
|
|
)
|
|
metric = merge_metrics(row["requests"] for row in rows)
|
|
point.update(
|
|
request_count=metric["count"] if rows else None,
|
|
error_rate=metric["error_rate"] if rows else None,
|
|
p95_ms=metric["p95_ms"],
|
|
)
|
|
points.append(point)
|
|
cursor += timedelta(minutes=step)
|
|
return {
|
|
"start": start,
|
|
"end": end,
|
|
"step_minutes": step,
|
|
"points": points,
|
|
"p95_approximate": True,
|
|
}
|
|
|
|
|
|
def interfaces(session, collector, query):
|
|
start, end = window(query)
|
|
rows = repo.request_minutes(session, minute_of(start), end, query.route)
|
|
truncated = len(rows) > 20000
|
|
rows = rows[:20000]
|
|
grouped, known = defaultdict(list), set()
|
|
for row in rows:
|
|
grouped[(row.method, row.route)].append(row.metrics)
|
|
known.add((row.instance_id, row.minute, row.method, row.route))
|
|
for minute, endpoints in collector.recorder.metrics(minute_of(start), end).items():
|
|
for (method, route), metric in endpoints.items():
|
|
if (not query.route or route == query.route) and (
|
|
collector.recorder.instance_id,
|
|
minute,
|
|
method,
|
|
route,
|
|
) not in known:
|
|
grouped[(method, route)].append(metric)
|
|
records = [
|
|
{"method": method, "route": route, **merge_metrics(items)}
|
|
for (method, route), items in grouped.items()
|
|
]
|
|
records.sort(key=lambda item: (item["p95_ms"] or 0, item["count"]), reverse=True)
|
|
return {
|
|
"start": start,
|
|
"end": end,
|
|
"list": records[:200],
|
|
"truncated": truncated or len(records) > 200,
|
|
"p95_approximate": True,
|
|
}
|
|
|
|
|
|
def events(session, query):
|
|
start, end = window(query)
|
|
rows, total = repo.events(session, query, start, end)
|
|
return PageResult(
|
|
records=[EventOut.model_validate(row) for row in rows],
|
|
total=total,
|
|
page_num=query.page_num,
|
|
page_size=query.page_size,
|
|
)
|
|
|
|
|
|
def event(session, event_id):
|
|
row = repo.event(session, event_id)
|
|
if row is None or (
|
|
row.last_seen < now_local() - timedelta(days=7)
|
|
and not (row.kind == "HEALTH" and row.resolved_at is None)
|
|
):
|
|
raise NotFoundError("诊断事件不存在或已过保留期")
|
|
return EventOut.model_validate(row)
|
|
|
|
|
|
def diagnostic(session, collector, query):
|
|
# 仅导出与页面相同的白名单模型;不读取日志文件、配置、请求正文或工资明细。
|
|
return {
|
|
"generated_at": now_local(),
|
|
"overview": overview(session, collector),
|
|
"events": events(session, query),
|
|
"note": "仅含脱敏运行摘要;完整异常请按请求编号检索服务端日志。",
|
|
}
|