Files
gaokao-volunteer-system/data/notifications/email_service.py
Hermes Agent 246f21c7f2
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
feat(review): land unified remediation and rules evidence closure
2026-06-18 10:21:38 +08:00

315 lines
10 KiB
Python

from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from data.orders.models import utc_now_iso
from data.orders.schema import apply_schema
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS delivery_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL,
event_type TEXT NOT NULL,
channel TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ready',
attempt_count INTEGER NOT NULL DEFAULT 1,
last_attempt_at TEXT,
failure_reason TEXT,
created_at TEXT NOT NULL,
UNIQUE(order_id, event_type, channel),
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
);
"""
DELIVERY_EVENT_STATUSES = ("ready", "validated", "delivered", "failed")
@dataclass
class DeliveryNotificationEvent:
order_id: str
event_type: str
channel: str
payload_json: str
status: str
attempt_count: int
last_attempt_at: str | None
failure_reason: str | None
created_at: str
class DuplicateNotificationEvent(ValueError):
"""同一订单/事件/渠道的通知已存在。"""
class DeliveryNotificationService:
def __init__(
self, conn: sqlite3.Connection, *, owns_connection: bool = True
) -> None:
self._conn = conn
self._owns_connection = owns_connection
@classmethod
def for_db(cls, db_path: str | Path) -> "DeliveryNotificationService":
conn = apply_schema(db_path)
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA_SQL)
cls._ensure_columns(conn)
conn.commit()
return cls(conn)
@classmethod
def from_connection(cls, conn: sqlite3.Connection) -> "DeliveryNotificationService":
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA_SQL)
cls._ensure_columns(conn)
return cls(conn, owns_connection=False)
@staticmethod
def _ensure_columns(conn: sqlite3.Connection) -> None:
columns = {
row[1]
for row in conn.execute(
"PRAGMA table_info(delivery_notifications)"
).fetchall()
}
if "status" not in columns:
conn.execute(
"ALTER TABLE delivery_notifications ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'"
)
if "attempt_count" not in columns:
conn.execute(
"ALTER TABLE delivery_notifications ADD COLUMN attempt_count INTEGER NOT NULL DEFAULT 1"
)
if "last_attempt_at" not in columns:
conn.execute(
"ALTER TABLE delivery_notifications ADD COLUMN last_attempt_at TEXT"
)
if "failure_reason" not in columns:
conn.execute(
"ALTER TABLE delivery_notifications ADD COLUMN failure_reason TEXT"
)
_ensure_unique_constraint(conn)
def close(self) -> None:
if self._owns_connection:
self._conn.close()
def notify_report_ready(
self, order_id: str, payload_json: str, channel: str = "station"
) -> None:
self.notify_event(
order_id,
event_type="report_ready",
channel=channel,
payload_json=payload_json,
)
def notify_event(
self,
order_id: str,
*,
event_type: str,
channel: str,
payload_json: str,
) -> None:
try:
self._conn.execute(
"INSERT INTO delivery_notifications(order_id, event_type, channel, payload_json, status, attempt_count, last_attempt_at, failure_reason, created_at) VALUES (?, ?, ?, ?, 'ready', 1, ?, NULL, ?)",
(
order_id,
event_type,
channel,
payload_json,
utc_now_iso(),
utc_now_iso(),
),
)
self._conn.commit()
except sqlite3.IntegrityError:
self._conn.rollback()
raise DuplicateNotificationEvent(
f"delivery notification already exists: order_id={order_id} event_type={event_type} channel={channel}"
)
def mark_validated(
self,
order_id: str,
event_type: str = "report_ready",
*,
channel: str | None = None,
payload_json: str | None = None,
validated_at: str | None = None,
) -> None:
if validated_at is None:
validated_at = utc_now_iso()
sql = "UPDATE delivery_notifications SET status='validated', last_attempt_at=?"
params: list[object] = [validated_at]
if payload_json is None:
pass
else:
sql = (
"UPDATE delivery_notifications SET status='validated', payload_json=?, last_attempt_at=?"
)
params = [payload_json, validated_at]
sql += " WHERE order_id=? AND event_type=?"
params.extend([order_id, event_type])
if channel is not None:
sql += " AND channel=?"
params.append(channel)
self._conn.execute(sql, tuple(params))
self._conn.commit()
def mark_delivered(
self,
order_id: str,
event_type: str = "report_ready",
*,
channel: str | None = None,
payload_json: str | None = None,
sent_at: str | None = None,
) -> None:
if sent_at is None:
sent_at = utc_now_iso()
sql = "UPDATE delivery_notifications SET status='delivered', last_attempt_at=?, failure_reason=NULL"
params: list[object] = [sent_at]
if payload_json is None:
pass
else:
sql = (
"UPDATE delivery_notifications SET status='delivered', payload_json=?, last_attempt_at=?, failure_reason=NULL"
)
params = [payload_json, sent_at]
sql += " WHERE order_id=? AND event_type=?"
params.extend([order_id, event_type])
if channel is not None:
sql += " AND channel=?"
params.append(channel)
self._conn.execute(sql, tuple(params))
self._conn.commit()
def mark_failed(
self,
order_id: str,
failure_reason: str,
*,
event_type: str = "report_ready",
channel: str | None = None,
) -> None:
sql = (
"UPDATE delivery_notifications SET status='failed', attempt_count=attempt_count+1, last_attempt_at=?, failure_reason=? WHERE order_id=? AND event_type=?"
)
params: list[object] = [utc_now_iso(), failure_reason, order_id, event_type]
if channel is not None:
sql += " AND channel=?"
params.append(channel)
self._conn.execute(sql, tuple(params))
self._conn.commit()
def list_events(
self,
order_id: str,
*,
status: str | None = None,
channel: str | None = None,
) -> list[DeliveryNotificationEvent]:
sql = "SELECT order_id, event_type, channel, payload_json, status, attempt_count, last_attempt_at, failure_reason, created_at FROM delivery_notifications WHERE order_id=?"
params: list[object] = [order_id]
if status is not None:
sql += " AND status=?"
params.append(status)
if channel is not None:
sql += " AND channel=?"
params.append(channel)
sql += " ORDER BY id ASC"
rows = self._conn.execute(sql, tuple(params)).fetchall()
return [DeliveryNotificationEvent(**dict(row)) for row in rows]
def list_pending_events(
self,
*,
channel: str | None = None,
statuses: tuple[str, ...] = ("ready", "validated"),
limit: int = 100,
) -> list[DeliveryNotificationEvent]:
placeholders = ",".join("?" for _ in statuses)
sql = (
"SELECT order_id, event_type, channel, payload_json, status, attempt_count, last_attempt_at, failure_reason, created_at FROM delivery_notifications WHERE status IN ("
+ placeholders
+ ")"
)
params: list[object] = list(statuses)
if channel is not None:
sql += " AND channel=?"
params.append(channel)
sql += " ORDER BY id ASC LIMIT ?"
params.append(limit)
rows = self._conn.execute(sql, tuple(params)).fetchall()
return [DeliveryNotificationEvent(**dict(row)) for row in rows]
def _has_unique_index(
conn: sqlite3.Connection,
*,
columns: tuple[str, ...],
) -> bool:
for _, index_name, is_unique, *_ in conn.execute(
"PRAGMA index_list(delivery_notifications)"
).fetchall():
if not is_unique:
continue
index_columns = tuple(
row[2] for row in conn.execute(f"PRAGMA index_info({index_name!r})").fetchall()
)
if index_columns == columns:
return True
return False
def _ensure_unique_constraint(conn: sqlite3.Connection) -> None:
expected = ("order_id", "event_type", "channel")
legacy = ("order_id", "event_type")
if _has_unique_index(conn, columns=expected):
return
if not _has_unique_index(conn, columns=legacy):
return
conn.execute(
"""
CREATE TABLE delivery_notifications_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL,
event_type TEXT NOT NULL,
channel TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ready',
attempt_count INTEGER NOT NULL DEFAULT 1,
last_attempt_at TEXT,
failure_reason TEXT,
created_at TEXT NOT NULL,
UNIQUE(order_id, event_type, channel),
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
)
"""
)
conn.execute(
"""
INSERT INTO delivery_notifications_new(
id, order_id, event_type, channel, payload_json, status,
attempt_count, last_attempt_at, failure_reason, created_at
)
SELECT
id, order_id, event_type, channel, payload_json, status,
attempt_count, last_attempt_at, failure_reason, created_at
FROM delivery_notifications
"""
)
conn.execute("DROP TABLE delivery_notifications")
conn.execute(
"ALTER TABLE delivery_notifications_new RENAME TO delivery_notifications"
)