"""应用级数据维护用例。 本模块拥有保留期、批次循环、进度和部分失败汇总语义。具体数据库表如何删除由 ``CleanupRepository`` 端口提供,调度器只负责触发用例。 """ import json from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any, Callable, ContextManager, Dict, Optional, Protocol from app.application.configuration import get_chain_runtime_config_snapshot from app.runtime.log import logger CleanupProgress = Callable[..., None] @dataclass(frozen=True, slots=True) class CleanupPolicy: """描述一次数据维护运行使用的总开关和各表保留期。""" enabled: bool message_days: int download_history_days: int site_userdata_days: int transfer_history_days: int download_failure_days: int @dataclass(frozen=True, slots=True) class CleanupPlan: """描述单张表的保留期、截止点和批量删除动作。""" name: str retention_days: int cutoff: str delete_batch: Callable[[Any], int] class CleanupRepository(Protocol): """数据维护用例需要的最小持久化端口。""" def session(self) -> ContextManager[Any]: """返回一次维护运行共用的数据库会话上下文。""" ... def unit_of_work(self, db: Any) -> "CleanupUnitOfWork": """返回绑定到当前维护会话的事务边界。""" ... def delete_messages(self, db: Any, cutoff: str, limit: int) -> int: """删除早于截止时间的消息。""" ... def delete_download_history(self, db: Any, cutoff: str, limit: int) -> int: """删除早于截止时间的下载历史。""" ... def delete_download_orphans(self, db: Any, limit: int) -> int: """删除已经失去父下载历史的文件记录。""" ... def delete_site_userdata(self, db: Any, cutoff: str, limit: int) -> int: """删除早于截止日期的站点用户数据快照。""" ... def delete_transfer_history(self, db: Any, cutoff: str, limit: int) -> int: """删除早于截止时间的整理历史。""" ... def delete_download_failures(self, db: Any, cutoff: str, limit: int) -> int: """删除已经过期的下载失败冷却记录。""" ... class CleanupUnitOfWork(Protocol): """数据维护每一批删除所需的最小事务能力。""" def commit(self) -> None: """提交当前批次。""" ... def rollback(self) -> None: """回滚失败批次并恢复会话可用状态。""" ... class DataCleanupService: """按配置执行分批数据清理并生成兼容报告。""" DEFAULT_BATCH_SIZE = 500 def __init__( self, *, repository: CleanupRepository, policy_reader: Callable[[], CleanupPolicy], clock: Callable[[], datetime] = datetime.now, ) -> None: """保存持久化端口、动态配置读取器和可测试时钟。""" self._repository = repository self._policy_reader = policy_reader self._clock = clock def execute( self, batch_size: Optional[int] = None, progress_callback: Optional[CleanupProgress] = None, ) -> Dict[str, Any]: """执行全部清理计划,保持旧调度入口的报告和异常语义。""" started_at = self._clock() normalized_batch_size = batch_size or self.DEFAULT_BATCH_SIZE if normalized_batch_size <= 0: normalized_batch_size = self.DEFAULT_BATCH_SIZE policy = self._policy_reader() report: Dict[str, Any] = { "started_at": started_at.strftime("%Y-%m-%d %H:%M:%S"), "batch_size": normalized_batch_size, "enabled": policy.enabled, "tables": {}, "total_deleted": 0, } if not policy.enabled: report["skipped_reason"] = "disabled" logger.info("数据表清理总开关未开启,跳过执行") return report plans = self._build_plans( policy=policy, started_at=started_at, batch_size=normalized_batch_size, ) if progress_callback: progress_callback(value=0, text="开始清理数据表 ...") errors: list[str] = [] with self._repository.session() as db: for plan_index, plan in enumerate(plans): self._execute_plan( db=db, plan=plan, plan_index=plan_index, total_plans=len(plans), report=report, errors=errors, progress_callback=progress_callback, ) if errors: report["errors"] = errors logger.error( f"数据表清理部分失败:{json.dumps(report, ensure_ascii=False)}" ) raise RuntimeError(";".join(errors)) logger.info(f"数据表清理完成:{json.dumps(report, ensure_ascii=False)}") return report def _execute_plan( self, *, db: Any, plan: CleanupPlan, plan_index: int, total_plans: int, report: Dict[str, Any], errors: list[str], progress_callback: Optional[CleanupProgress], ) -> None: """执行单表计划并把成功、跳过或失败状态写入总报告。""" if plan.retention_days <= 0: report["tables"][plan.name] = { "deleted": 0, "batches": 0, "cutoff": None, "retention_days": plan.retention_days, "skipped": True, "reason": "retention_days<=0", } if progress_callback: progress_callback( value=(plan_index + 1) / total_plans * 100, text=f"数据表 {plan.name} 跳过清理", ) return try: if progress_callback: progress_callback( value=plan_index / total_plans * 100, text=f"正在清理数据表 {plan.name} ...", ) unit_of_work = self._repository.unit_of_work(db) table_report = self._cleanup_in_batches( db=db, table_name=plan.name, delete_batch=plan.delete_batch, unit_of_work=unit_of_work, ) table_report["cutoff"] = plan.cutoff table_report["retention_days"] = plan.retention_days report["tables"][plan.name] = table_report report["total_deleted"] += table_report["deleted"] except Exception as err: self._repository.unit_of_work(db).rollback() errors.append(f"{plan.name}: {str(err)}") logger.error(f"数据表 {plan.name} 清理失败:{str(err)}") report["tables"][plan.name] = { "deleted": 0, "batches": 0, "cutoff": plan.cutoff, "retention_days": plan.retention_days, "error": str(err), } finally: if progress_callback: progress_callback( value=(plan_index + 1) / total_plans * 100, text=f"数据表 {plan.name} 清理处理完成", ) def _build_plans( self, *, policy: CleanupPolicy, started_at: datetime, batch_size: int, ) -> list[CleanupPlan]: """把一次动态配置快照转换为固定顺序的清理计划。""" message_cutoff = self._cutoff(started_at, policy.message_days, "%Y-%m-%d") download_history_cutoff = self._cutoff( started_at, policy.download_history_days, "%Y-%m-%d", ) site_userdata_cutoff = self._cutoff( started_at, policy.site_userdata_days, "%Y-%m-%d", ) transfer_history_cutoff = self._cutoff( started_at, policy.transfer_history_days, "%Y-%m-%d", ) download_failure_cutoff = self._cutoff( started_at, policy.download_failure_days, "%Y-%m-%d %H:%M:%S", ) return [ CleanupPlan( "message", policy.message_days, message_cutoff, lambda db: self._repository.delete_messages( db, message_cutoff, batch_size ), ), CleanupPlan( "downloadhistory", policy.download_history_days, download_history_cutoff, lambda db: self._repository.delete_download_history( db, download_history_cutoff, batch_size ), ), CleanupPlan( "downloadfiles", policy.download_history_days, "follow-parent-history", lambda db: self._repository.delete_download_orphans(db, batch_size), ), CleanupPlan( "siteuserdata", policy.site_userdata_days, site_userdata_cutoff, lambda db: self._repository.delete_site_userdata( db, site_userdata_cutoff, batch_size ), ), CleanupPlan( "transferhistory", policy.transfer_history_days, transfer_history_cutoff, lambda db: self._repository.delete_transfer_history( db, transfer_history_cutoff, batch_size ), ), CleanupPlan( "downloadfailure", policy.download_failure_days, download_failure_cutoff, lambda db: self._repository.delete_download_failures( db, download_failure_cutoff, batch_size ), ), ] def _cleanup_in_batches( self, *, db: Any, table_name: str, delete_batch: Callable[[Any], int], unit_of_work: CleanupUnitOfWork, ) -> Dict[str, int]: """循环执行单表分批删除,直到持久化端口返回零。""" total_deleted = 0 batches = 0 while True: deleted = delete_batch(db) or 0 if deleted <= 0: break unit_of_work.commit() batches += 1 total_deleted += deleted logger.info( f"数据表 {table_name} 清理第 {batches} 批完成,删除 {deleted} 条记录" ) return {"deleted": total_deleted, "batches": batches} @staticmethod def _cutoff(started_at: datetime, retention_days: int, pattern: str) -> str: """按兼容格式计算一个清理截止时间。""" return (started_at - timedelta(days=retention_days)).strftime(pattern) def read_cleanup_policy() -> CleanupPolicy: """读取并规范化当前数据清理配置,单次运行期间保持快照一致。""" config = get_chain_runtime_config_snapshot() return CleanupPolicy( enabled=bool(config.data_cleanup_enable), message_days=_normalize_days(config.data_cleanup_message_days), download_history_days=_normalize_days(config.data_cleanup_download_history_days), site_userdata_days=_normalize_days(config.data_cleanup_site_userdata_days), transfer_history_days=_normalize_days(config.data_cleanup_transfer_history_days), download_failure_days=_normalize_days(config.data_cleanup_download_failure_days), ) def _normalize_days(retention_days: Any) -> int: """把配置保留期规范为非负整数,非法值按关闭单表清理处理。""" try: normalized_days = int(retention_days or 0) except (TypeError, ValueError): return 0 return max(normalized_days, 0)