177 lines
7.1 KiB
Python
177 lines
7.1 KiB
Python
import json
|
|
from datetime import datetime, timedelta
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models import AdminUser, OperationLog
|
|
|
|
LOG_RETENTION_DAYS = 180
|
|
HIGH_RISK_LOG_RETENTION_DAYS = 365
|
|
|
|
ACTION_LABELS = {
|
|
"create_project": "\u65b0\u589e\u9879\u76ee",
|
|
"update_project": "\u4fee\u6539\u9879\u76ee",
|
|
"create_learner": "\u65b0\u589e\u5b66\u5458",
|
|
"update_learner": "\u4fee\u6539\u5b66\u5458",
|
|
"delete_learner": "\u5220\u9664\u5b66\u5458",
|
|
"create_certificate": "\u65b0\u589e\u8bc1\u4e66",
|
|
"start_pdf_pregeneration": "\u542f\u52a8PDF\u6279\u91cf\u9884\u751f\u6210",
|
|
"void_certificate": "\u4f5c\u5e9f\u8bc1\u4e66",
|
|
"regenerate_public_token": "\u91cd\u7f6e\u8bc1\u4e66\u94fe\u63a5",
|
|
"upload_import_file": "\u4e0a\u4f20\u5bfc\u5165\u6587\u4ef6",
|
|
"confirm_import_batch": "\u786e\u8ba4\u5bfc\u5165",
|
|
"delete_import_batch": "\u5220\u9664\u5bfc\u5165\u6279\u6b21",
|
|
"export_certificates": "\u5bfc\u51fa\u8bc1\u4e66",
|
|
"create_database_backup": "\u521b\u5efa\u6570\u636e\u5e93\u5907\u4efd",
|
|
"restore_database_backup": "\u56de\u6eda\u6570\u636e\u5e93\u5907\u4efd",
|
|
"update_pdf_settings": "\u4fee\u6539PDF\u751f\u6210\u8bbe\u7f6e",
|
|
"public_search_certificate": "\u516c\u5f00\u67e5\u8be2\u8bc1\u4e66",
|
|
"public_download_certificate": "\u516c\u5f00\u4e0b\u8f7d\u8bc1\u4e66",
|
|
}
|
|
OBJECT_LABELS = {
|
|
"project": "\u9879\u76ee",
|
|
"project_course": "\u9879\u76ee",
|
|
"learner": "\u5b66\u5458",
|
|
"certificate": "\u8bc1\u4e66",
|
|
"import_batch": "\u5bfc\u5165\u6279\u6b21",
|
|
"database_backup": "\u6570\u636e\u5e93\u5907\u4efd",
|
|
"system_setting": "\u7cfb\u7edf\u8bbe\u7f6e",
|
|
"public_access": "\u516c\u5f00\u8bbf\u95ee",
|
|
}
|
|
HIGH_RISK_ACTIONS = {
|
|
"void_certificate",
|
|
"delete_import_batch",
|
|
"regenerate_public_token",
|
|
"delete_learner",
|
|
"restore_database_backup",
|
|
}
|
|
MEDIUM_RISK_ACTIONS = {
|
|
"update_project",
|
|
"update_learner",
|
|
"confirm_import_batch",
|
|
"create_certificate",
|
|
"start_pdf_pregeneration",
|
|
"create_database_backup",
|
|
"update_pdf_settings",
|
|
}
|
|
|
|
|
|
def log_action(
|
|
db: Session,
|
|
admin: AdminUser | None,
|
|
action: str,
|
|
object_type: str,
|
|
object_id: int | str | None = None,
|
|
detail: dict[str, object] | None = None,
|
|
) -> None:
|
|
db.add(
|
|
OperationLog(
|
|
admin_user_id=admin.id if admin else None,
|
|
action=action,
|
|
object_type=object_type,
|
|
object_id=str(object_id) if object_id is not None else None,
|
|
detail_json=json.dumps(detail, ensure_ascii=False, default=str) if detail else None,
|
|
)
|
|
)
|
|
|
|
|
|
def mask_phone(phone: object) -> str:
|
|
raw = "".join(ch for ch in str(phone or "") if ch.isdigit())
|
|
if len(raw) >= 7:
|
|
return raw[:3] + "****" + raw[-4:]
|
|
return raw or "-"
|
|
|
|
|
|
def log_risk(action: str) -> str:
|
|
if action in HIGH_RISK_ACTIONS:
|
|
return "high"
|
|
if action in MEDIUM_RISK_ACTIONS:
|
|
return "medium"
|
|
return "low"
|
|
|
|
|
|
def log_risk_text(risk: str) -> str:
|
|
return {"high": "\u9ad8\u98ce\u9669", "medium": "\u4e2d\u98ce\u9669", "low": "\u4f4e\u98ce\u9669"}.get(risk, risk)
|
|
|
|
|
|
def diff_values(before: dict[str, object], after: dict[str, object], fields: list[str]) -> dict[str, dict[str, object]]:
|
|
changes: dict[str, dict[str, object]] = {}
|
|
for field in fields:
|
|
if before.get(field) != after.get(field):
|
|
changes[field] = {"before": before.get(field), "after": after.get(field)}
|
|
return changes
|
|
|
|
|
|
def log_summary(action: str, object_type: str, object_id: object, detail: dict[str, object]) -> str:
|
|
label = ACTION_LABELS.get(action, action)
|
|
if action in {"create_project", "update_project"}:
|
|
return f"{label}\uff1a{detail.get('name') or detail.get('code') or object_id}"
|
|
if action in {"create_learner", "update_learner", "delete_learner"}:
|
|
phone = detail.get("phone")
|
|
return f"{label}\uff1a{detail.get('name') or object_id}" + (f"\uff08{phone}\uff09" if phone else "")
|
|
if action in {"create_certificate", "void_certificate", "regenerate_public_token"}:
|
|
learner_name = detail.get("learner_name")
|
|
return f"{label}\uff1a{detail.get('certificate_no') or object_id}" + (f"\uff08{learner_name}\uff09" if learner_name else "")
|
|
if action == "start_pdf_pregeneration":
|
|
return f"{label}\uff1a{detail.get('total', 0)} \u5f20\uff0c\u5e76\u53d1 {detail.get('concurrency_limit')}"
|
|
if action in {"upload_import_file", "delete_import_batch"}:
|
|
return f"{label}\uff1a{detail.get('filename') or object_id}"
|
|
if action == "confirm_import_batch":
|
|
count = detail.get("imported_rows", detail.get("valid_rows", 0))
|
|
return f"{label}\uff1a\u6279\u6b21 {object_id}\uff0c\u5bfc\u5165 {count} \u884c"
|
|
if action in {"create_database_backup", "restore_database_backup"}:
|
|
filename = detail.get("filename") or object_id
|
|
size_label = detail.get("size_label")
|
|
return f"{label}\uff1a{filename}" + (f"\uff08{size_label}\uff09" if size_label else "")
|
|
if action == "update_pdf_settings":
|
|
return f"{label}\uff1a\u5e76\u53d1\u6570 {detail.get('before')} -> {detail.get('after')}"
|
|
if action == "public_search_certificate":
|
|
mode = "\u8bc1\u4e66\u7f16\u53f7" if detail.get("mode") == "certificate_no" else "\u624b\u673a\u53f7"
|
|
return f"{label}\uff1a{detail.get('name') or '-'} \u7528{mode}\u67e5\u8be2\uff0c\u5339\u914d {detail.get('matched_count', 0)} \u6761"
|
|
if action == "public_download_certificate":
|
|
return f"{label}\uff1a{detail.get('certificate_no') or object_id}\uff08{detail.get('learner_name') or '-'}\uff09"
|
|
return f"{label}\uff1a{OBJECT_LABELS.get(object_type, object_type)} {object_id or ''}".strip()
|
|
|
|
|
|
def format_log(log: OperationLog) -> dict[str, object]:
|
|
detail: dict[str, object] = {}
|
|
if log.detail_json:
|
|
try:
|
|
detail = json.loads(log.detail_json)
|
|
except Exception:
|
|
detail = {"raw": log.detail_json}
|
|
risk = log_risk(log.action)
|
|
return {
|
|
"id": log.id,
|
|
"admin_user_id": log.admin_user_id,
|
|
"action": log.action,
|
|
"action_label": ACTION_LABELS.get(log.action, log.action),
|
|
"object_type": log.object_type,
|
|
"object_label": OBJECT_LABELS.get(log.object_type, log.object_type),
|
|
"object_id": log.object_id,
|
|
"ip_address": log.ip_address,
|
|
"detail_json": log.detail_json,
|
|
"created_at": log.created_at,
|
|
"risk": risk,
|
|
"risk_label": log_risk_text(risk),
|
|
"summary": log_summary(log.action, log.object_type, log.object_id, detail),
|
|
}
|
|
|
|
|
|
def cleanup_expired_logs(db: Session) -> int:
|
|
normal_before = datetime.utcnow() - timedelta(days=LOG_RETENTION_DAYS)
|
|
high_before = datetime.utcnow() - timedelta(days=HIGH_RISK_LOG_RETENTION_DAYS)
|
|
normal_deleted = (
|
|
db.query(OperationLog)
|
|
.filter(OperationLog.created_at < normal_before)
|
|
.filter(OperationLog.action.notin_(HIGH_RISK_ACTIONS))
|
|
.delete(synchronize_session=False)
|
|
)
|
|
high_deleted = (
|
|
db.query(OperationLog)
|
|
.filter(OperationLog.created_at < high_before)
|
|
.filter(OperationLog.action.in_(HIGH_RISK_ACTIONS))
|
|
.delete(synchronize_session=False)
|
|
)
|
|
return int(normal_deleted or 0) + int(high_deleted or 0)
|