Files
2026-09-02 00:05:36 +00:00

115 lines
4.8 KiB
Python

"""Спул отчётов и записи идемпотентности.
Отчёт кладётся на диск ДО попытки доставки: доставка может не удаться, а
обращение пользователя терять нельзя — оно и есть предмет задачи. Ретеншн
описан в README и исполняется отдельным таймером, а не этим процессом.
"""
from __future__ import annotations
import json
import os
import shutil
import threading
import time
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path
from . import config
def new_report_id() -> str:
"""Короткий непрозрачный идентификатор: показывается пользователю целиком."""
return "hpr-" + os.urandom(5).hex()
@dataclass(frozen=True)
class StoredReport:
report_id: str
directory: Path
class Store:
def __init__(self, spool: Path) -> None:
self.spool = spool
self.reports = spool / "reports"
self.idem = spool / "idem"
for path in (self.reports, self.idem):
path.mkdir(parents=True, exist_ok=True)
spool.chmod(0o700)
self._lock = threading.Lock()
# --- идемпотентность -------------------------------------------------
def _idem_path(self, key: str) -> Path:
return self.idem / (sha256(key.encode("utf-8")).hexdigest()[:32] + ".json")
def lookup(self, key: str, now: float | None = None) -> str | None:
moment = time.time() if now is None else now
path = self._idem_path(key)
if not path.exists():
return None
try:
record = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if moment - float(record.get("created", 0)) >= config.IDEMPOTENCY_TTL_SECONDS:
path.unlink(missing_ok=True)
return None
value = record.get("report_id")
return value if isinstance(value, str) else None
def remember(self, key: str, report_id: str, now: float | None = None) -> None:
moment = time.time() if now is None else now
path = self._idem_path(key)
tmp = path.with_suffix(".tmp")
# Запись содержит только идентификатор и время: ни сообщения, ни адреса.
tmp.write_text(json.dumps({"report_id": report_id, "created": moment}), encoding="utf-8")
tmp.chmod(0o600)
tmp.replace(path)
# --- отчёты ----------------------------------------------------------
def save(self, report_id: str, meta: dict, attachment: bytes | None) -> StoredReport:
directory = self.reports / time.strftime("%Y-%m", time.gmtime()) / report_id
directory.mkdir(parents=True, exist_ok=True)
directory.chmod(0o700)
meta_path = directory / "report.json"
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
meta_path.chmod(0o600)
if attachment is not None:
blob = directory / f"houseplan-support-{report_id}.json"
blob.write_bytes(attachment)
blob.chmod(0o600)
return StoredReport(report_id=report_id, directory=directory)
def mark_delivery(self, stored: StoredReport, status: str, detail: str = "") -> None:
path = stored.directory / "delivery.json"
path.write_text(
json.dumps({"status": status, "detail": detail, "at": time.time()}, ensure_ascii=False),
encoding="utf-8",
)
path.chmod(0o600)
# --- ретеншн ---------------------------------------------------------
def purge(self, retention_days: int, now: float | None = None) -> int:
"""Удаляет отчёты старше срока хранения. Возвращает число удалённых."""
moment = time.time() if now is None else now
deadline = moment - retention_days * 86400
removed = 0
with self._lock:
for directory in sorted(self.reports.glob("*/*")):
if not directory.is_dir():
continue
if directory.stat().st_mtime < deadline:
shutil.rmtree(directory, ignore_errors=True)
removed += 1
for path in self.idem.glob("*.json"):
try:
record = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
record = {}
if moment - float(record.get("created", 0)) >= config.IDEMPOTENCY_TTL_SECONDS:
path.unlink(missing_ok=True)
removed += 1
return removed