"""审计日志服务(迭代第 3 轮)。 **为什么需要**:`AuditLog` 模型在阶段 1 就定义了,但**全项目没有写入过一条**—— 意味着过账、强制放行、改价这些关键操作没有任何追溯记录。对财务/合规场景这是硬缺口 (客户问"这张单谁改的价"时答不上来)。 设计原则: 1. **记录"有后果"的操作**,不是所有 CRUD(否则日志淹没在噪音里): - 单据状态变更(过账/作废) - 越过管控(信用超限强制放行、低于最低售价放行、超配额) - 金额相关变更(改价、抹零) - 权限相关(API Key 签发、商城账号开通、套餐变更) 2. **写入失败绝不影响主业务**(审计是旁路,不能因为日志问题让开单失败); 3. 携带足够的上下文以便事后追责:谁、何时、对什么、改了什么、从什么到什么。 """ from __future__ import annotations import logging from typing import Optional from .models import AuditLog logger = logging.getLogger("dealerhub.audit") # 业务动作常量(比通用 CRUD 更精确,便于按动作筛选) ACTION_POST = "post" # 过账/确认 ACTION_CANCEL = "cancel" # 作废/取消 ACTION_FORCE = "force" # 越过管控放行 ACTION_PRICE_OVERRIDE = "price_override" # 改价 ACTION_QUOTA_EXCEEDED = "quota_exceeded" # 配额超限尝试 ACTION_ISSUE = "issue" # 签发(API Key / 商城账号) ACTION_PLAN_CHANGE = "plan_change" # 套餐变更 ACTION_SEED = "seed" # 数据初始化 # 为了复用 AuditLog.ACTION_CHOICES 的既有选择,把上述动作归入既有类别 _ACTION_TO_CHOICE = { ACTION_POST: "update", ACTION_CANCEL: "update", ACTION_FORCE: "update", ACTION_PRICE_OVERRIDE: "update", ACTION_QUOTA_EXCEEDED: "view", ACTION_ISSUE: "create", ACTION_PLAN_CHANGE: "update", ACTION_SEED: "create", } def log( *, tenant, action: str, target_type: str, target_id: str = "", detail: Optional[dict] = None, user=None, request=None, ip: str = "", user_agent: str = "", ) -> Optional[AuditLog]: """写一条审计日志。**任何异常都被吞掉**(旁路,不影响主业务)。 request 可选:给了就自动提取 user / ip / user_agent。 """ try: # 服务层通常没有 request:从 contextvar 兜底取(中间件注入) if request is None: try: from .context import current_request request = current_request() except Exception: request = None if request is not None: req_user = getattr(request, "user", None) if user is None and req_user is not None and getattr(req_user, "is_authenticated", False): user = req_user if not ip: xff = request.META.get("HTTP_X_FORWARDED_FOR", "") ip = (xff.split(",")[0].strip() if xff else request.META.get("REMOTE_ADDR", "")) or "" if not user_agent: user_agent = request.META.get("HTTP_USER_AGENT", "")[:255] return AuditLog.objects.create( tenant=tenant, user=user if (user is not None and getattr(user, "pk", None)) else None, action=_ACTION_TO_CHOICE.get(action, "update"), target_type=target_type[:64], target_id=str(target_id)[:64], detail={"business_action": action, **(detail or {})}, ip=ip or None, user_agent=user_agent or "", ) except Exception as exc: # 审计失败不能影响业务 logger.warning("写审计日志失败(已忽略): %s · action=%s target=%s", exc, action, target_type) return None # ------------------------------------------------------------ # 便捷包装(业务侧调用这些,语义更清晰) # ------------------------------------------------------------ def log_bill_posted(*, tenant, bill, user=None, request=None, extra: Optional[dict] = None) -> None: """单据过账。""" log( tenant=tenant, action=ACTION_POST, target_type=bill.__class__.__name__, target_id=bill.bill_no, user=user, request=request, detail={ "bill_no": bill.bill_no, "amount": str(getattr(bill, "total_amount", "")), "customer": getattr(getattr(bill, "customer", None), "code", ""), **(extra or {}), }, ) def log_bill_cancelled(*, tenant, bill, reason: str = "", user=None, request=None) -> None: """单据作废/取消。""" log( tenant=tenant, action=ACTION_CANCEL, target_type=bill.__class__.__name__, target_id=getattr(bill, "bill_no", str(bill.pk)), user=user, request=request, detail={"reason": reason}, ) def log_force_release(*, tenant, kind: str, target: str, detail: dict, user=None, request=None) -> None: """越过管控放行(信用超限 / 低于最低售价 / 超配额)。""" log( tenant=tenant, action=ACTION_FORCE, target_type=kind, target_id=target, user=user, request=request, detail=detail, ) def log_price_override(*, tenant, product_code: str, from_price, to_price, bill_no: str = "", user=None, request=None) -> None: """改价(记录原价与新价)。""" log( tenant=tenant, action=ACTION_PRICE_OVERRIDE, target_type="Product", target_id=product_code, user=user, request=request, detail={"bill_no": bill_no, "from": str(from_price), "to": str(to_price)}, ) def log_plan_change(*, tenant, from_plan: str, to_plan: str, user=None, request=None) -> None: log( tenant=tenant, action=ACTION_PLAN_CHANGE, target_type="Subscription", target_id=tenant.code if hasattr(tenant, "code") else str(tenant), user=user, request=request, detail={"from": from_plan, "to": to_plan}, ) def query_logs(tenant, *, target_type: str = "", target_id: str = "", action: str = "", limit: int = 100) -> list: """按目标查审计轨迹("这张单都发生了什么")。""" qs = AuditLog.objects.filter(tenant=tenant) if target_type: qs = qs.filter(target_type=target_type) if target_id: qs = qs.filter(target_id=str(target_id)) if action: qs = qs.filter(detail__business_action=action) return list(qs.order_by("-created_at")[:limit])