mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 03:38:47 +00:00
115 lines
4.8 KiB
Python
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
|