315 lines
10 KiB
Python
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"
|
|
)
|