baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
"""审计日志服务(迭代第 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])