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)