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
|