164 lines
4.8 KiB
Python
164 lines
4.8 KiB
Python
"""Webhook 审计日志 (T8.1)
|
||
|
||
记录每一次 Webhook 接收的处理决策:
|
||
|
||
- decision: 'accepted' | 'rejected' | 'parse_error' | 'duplicate'
|
||
- reject_reason: 拒绝原因(短字符串,便于排查)
|
||
- raw_body_hash: SHA-256(body),不存原始 body 避免敏感信息二次落地
|
||
- order_id: 映射到的内部订单号(若已有)
|
||
- remote_addr: 来源 IP(用于限流/取证)
|
||
|
||
表结构与设计文档 CHANNEL_INTEGRATION.md §4.3 一致。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sqlite3
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from .signature import sha256_hex
|
||
|
||
# 审计表 DDL(与 channel_sync/webhook_server 共用)
|
||
WEBHOOK_AUDIT_SCHEMA: str = """
|
||
CREATE TABLE IF NOT EXISTS webhook_audit (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
received_at TEXT NOT NULL,
|
||
channel TEXT NOT NULL,
|
||
event_id TEXT,
|
||
decision TEXT NOT NULL,
|
||
reject_reason TEXT,
|
||
order_id TEXT,
|
||
raw_body_hash TEXT,
|
||
remote_addr TEXT
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_webhook_audit_event
|
||
ON webhook_audit(channel, event_id);
|
||
CREATE INDEX IF NOT EXISTS idx_webhook_audit_decision
|
||
ON webhook_audit(channel, decision, received_at);
|
||
"""
|
||
|
||
VALID_DECISIONS: frozenset[str] = frozenset(
|
||
{"accepted", "rejected", "parse_error", "duplicate"}
|
||
)
|
||
|
||
|
||
def utc_now_iso() -> str:
|
||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||
|
||
|
||
@dataclass
|
||
class WebhookAuditEntry:
|
||
"""单条审计记录的入参结构。"""
|
||
|
||
channel: str
|
||
decision: str
|
||
event_id: Optional[str] = None
|
||
reject_reason: Optional[str] = None
|
||
order_id: Optional[str] = None
|
||
raw_body: Optional[bytes] = None
|
||
remote_addr: Optional[str] = None
|
||
received_at: Optional[str] = None
|
||
|
||
def __post_init__(self) -> None:
|
||
if self.decision not in VALID_DECISIONS:
|
||
raise ValueError(
|
||
f"非法 decision: {self.decision!r}; 允许: {sorted(VALID_DECISIONS)}"
|
||
)
|
||
if not self.received_at:
|
||
self.received_at = utc_now_iso()
|
||
|
||
|
||
def apply_audit_schema(conn: sqlite3.Connection) -> None:
|
||
"""幂等地把 webhook_audit 表与索引装上。"""
|
||
conn.executescript(WEBHOOK_AUDIT_SCHEMA)
|
||
conn.commit()
|
||
|
||
|
||
def record(
|
||
conn: sqlite3.Connection,
|
||
entry: WebhookAuditEntry,
|
||
) -> int:
|
||
"""写入一条审计记录,返回 rowid。"""
|
||
raw_hash = sha256_hex(entry.raw_body) if entry.raw_body else None
|
||
cur = conn.execute(
|
||
"""
|
||
INSERT INTO webhook_audit(
|
||
received_at, channel, event_id, decision,
|
||
reject_reason, order_id, raw_body_hash, remote_addr
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
entry.received_at,
|
||
entry.channel,
|
||
entry.event_id,
|
||
entry.decision,
|
||
entry.reject_reason,
|
||
entry.order_id,
|
||
raw_hash,
|
||
entry.remote_addr,
|
||
),
|
||
)
|
||
conn.commit()
|
||
return int(cur.lastrowid or 0)
|
||
|
||
|
||
def count_by_event(conn: sqlite3.Connection, channel: str, event_id: str) -> int:
|
||
"""统计 (channel, event_id) 在审计表里的出现次数。"""
|
||
row = conn.execute(
|
||
"SELECT COUNT(*) FROM webhook_audit "
|
||
"WHERE channel=? AND event_id=? AND decision='accepted'",
|
||
(channel, event_id),
|
||
).fetchone()
|
||
return int(row[0]) if row else 0
|
||
|
||
|
||
def list_recent(
|
||
conn: sqlite3.Connection,
|
||
*,
|
||
channel: Optional[str] = None,
|
||
decision: Optional[str] = None,
|
||
limit: int = 50,
|
||
) -> list[dict]:
|
||
"""列出最近审计记录,按 id 倒序。"""
|
||
where: list[str] = []
|
||
args: list = []
|
||
if channel:
|
||
where.append("channel=?")
|
||
args.append(channel)
|
||
if decision:
|
||
where.append("decision=?")
|
||
args.append(decision)
|
||
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||
args.append(int(limit))
|
||
cur = conn.execute(
|
||
f"SELECT id, received_at, channel, event_id, decision, "
|
||
f"reject_reason, order_id, raw_body_hash, remote_addr "
|
||
f"FROM webhook_audit {where_sql} "
|
||
f"ORDER BY id DESC LIMIT ?",
|
||
args,
|
||
)
|
||
cols = [d[0] for d in cur.description]
|
||
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
||
|
||
|
||
def open_audit_db(db_path: str | Path) -> sqlite3.Connection:
|
||
"""打开(或创建)含 webhook_audit 表的数据库连接。
|
||
|
||
订单表如果存在就一起连进来(同一 SQLite 文件);如果不存在则只装审计表。
|
||
"""
|
||
db_path = Path(db_path)
|
||
if db_path.parent and not db_path.parent.exists():
|
||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||
conn = sqlite3.connect(str(db_path))
|
||
try:
|
||
conn.execute("PRAGMA foreign_keys = ON")
|
||
apply_audit_schema(conn)
|
||
except Exception:
|
||
conn.close()
|
||
raise
|
||
return conn
|