Files

191 lines
6.6 KiB
Python
Raw Permalink 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.
"""审计日志服务(迭代第 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])