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