Files
gaokao-volunteer-system/data/orders/retention_cleanup.py

215 lines
6.5 KiB
Python

from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from data.orders.dao import OrdersDAO
from data.orders.deletion_service import OrderDeletionService
@dataclass
class RetentionCleanupResult:
cutoff_iso: str
dry_run: bool
scanned: int = 0
candidates: int = 0
anonymized: int = 0
deletion_logs_pruned: int = 0
share_events_pruned: int = 0
_TERMINAL_STATUSES = {"completed", "refunded"}
def _parse_iso(ts: str | None) -> datetime | None:
if not ts:
return None
return datetime.fromisoformat(ts)
def _iter_candidates(db_path: str, cutoff_iso: str) -> tuple[int, list[str]]:
cutoff = datetime.fromisoformat(cutoff_iso)
scanned = 0
candidates: list[str] = []
offset = 0
page_size = 1000
while True:
with OrdersDAO.connect(db_path) as dao:
batch = dao.list(limit=page_size, offset=offset)
if not batch:
break
scanned += len(batch)
for order in batch:
if order.status not in _TERMINAL_STATUSES:
continue
anchor = _parse_iso(
order.completed_at or order.status_updated_at or order.created_at
)
if anchor is None:
continue
if anchor <= cutoff:
candidates.append(order.id)
if len(batch) < page_size:
break
offset += page_size
return scanned, candidates
def _prune_deletion_request_log(log_path: str, candidate_ids: list[str]) -> int:
path = Path(log_path)
if not path.exists() or not candidate_ids:
return 0
keep: list[str] = []
pruned = 0
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
item = json.loads(line)
except json.JSONDecodeError:
keep.append(line)
continue
if str(item.get("order_id") or "") in candidate_ids:
pruned += 1
continue
keep.append(json.dumps(item, ensure_ascii=False))
path.write_text("\n".join(keep) + ("\n" if keep else ""), encoding="utf-8")
return pruned
def _prune_share_access_events(share_db_path: str, candidate_ids: list[str]) -> int:
if not candidate_ids:
return 0
from data.share.short_link import ShortLinkService
service = ShortLinkService(db_path=share_db_path)
with service._connect() as conn:
rows = conn.execute(
"SELECT code FROM share_links WHERE report_id IN ({})".format(
",".join("?" for _ in candidate_ids)
),
tuple(candidate_ids),
).fetchall()
codes = [str(row[0]) for row in rows]
if not codes:
return 0
conn.execute(
"DELETE FROM share_link_access_events WHERE code IN ({})".format(
",".join("?" for _ in codes)
),
tuple(codes),
)
conn.commit()
return int(conn.total_changes)
def resolve_cutoff_iso(
*, cutoff_iso: str | None = None, retention_days: int | None = None
) -> str:
if cutoff_iso is not None:
datetime.fromisoformat(cutoff_iso)
return cutoff_iso
if retention_days is None or retention_days <= 0:
raise ValueError("retention_days must be > 0 when cutoff is not provided")
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
return cutoff.replace(microsecond=0).isoformat()
def run_cleanup(
db_path: str,
*,
cutoff_iso: str,
apply: bool,
deletion_request_log_path: str | None = None,
share_db_path: str | None = None,
) -> RetentionCleanupResult:
if deletion_request_log_path is None or share_db_path is None:
try:
from admin.config import load_settings
settings = load_settings()
except Exception:
settings = None
if deletion_request_log_path is None and settings is not None:
deletion_request_log_path = settings.deletion_request_log_path
if share_db_path is None and settings is not None:
share_db_path = settings.share_db_path
scanned, candidate_ids = _iter_candidates(db_path, cutoff_iso)
result = RetentionCleanupResult(
cutoff_iso=cutoff_iso,
dry_run=not apply,
scanned=scanned,
candidates=len(candidate_ids),
)
if not apply:
return result
service = OrderDeletionService.for_db(db_path)
try:
for order_id in candidate_ids:
service.anonymize_order(
order_id, actor="retention_cleanup", reason="retention_expired"
)
result.anonymized += 1
finally:
service.close()
if deletion_request_log_path:
result.deletion_logs_pruned = _prune_deletion_request_log(
deletion_request_log_path, candidate_ids
)
if share_db_path:
result.share_events_pruned = _prune_share_access_events(
share_db_path, candidate_ids
)
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Run retention cleanup for old completed/refunded orders"
)
cutoff_group = parser.add_mutually_exclusive_group(required=True)
cutoff_group.add_argument(
"--cutoff",
help="ISO8601 cutoff; orders on or before this anchor are candidates",
)
cutoff_group.add_argument(
"--retention-days",
type=int,
help="Retention window in days; cutoff is computed as now - retention_days",
)
parser.add_argument("--dry-run", action="store_true")
return parser
def main(argv: list[str] | None = None, *, db_path: str | None = None) -> int:
from admin.config import load_settings
settings = load_settings()
if db_path is None:
db_path = settings.orders_db_path
parser = build_parser()
args = parser.parse_args(argv)
try:
cutoff_iso = resolve_cutoff_iso(
cutoff_iso=args.cutoff,
retention_days=args.retention_days,
)
except ValueError as exc:
parser.error(str(exc))
result = run_cleanup(
db_path,
cutoff_iso=cutoff_iso,
apply=not args.dry_run,
deletion_request_log_path=getattr(settings, "deletion_request_log_path", None),
share_db_path=getattr(settings, "share_db_path", None),
)
print(json.dumps(result.__dict__, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())