ForcePilot/backend/package/yuxi/repositories/scheduled/base.py
Kris 6498af03e3 feat(scheduler): 完整实现定时任务调度限界上下文基础架构
本次提交完成了定时任务调度限界上下文的全量基础架构搭建,包括:
1.  基于六边形架构的完整分层(core/use_cases/framework/adapters/infrastructure)
2.  任务调度核心领域模型、端口契约与校验工具
3.  持久化适配器层与SQLAlchemy仓储实现
4.  调度运行时核心组件(handler注册表、执行引擎)
5.  内置维护型任务handler(日志清理、幂等记录清理)
6.  全局异常处理器与PostgreSQL表结构适配
7.  ARQ worker调度任务集成与启动装配逻辑
2026-06-22 20:07:20 +08:00

203 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""定时任务调度域仓库基类,提供共享的分页、转义与软删除工具。
本模块是调度域(``scheduled``)专属的 ``BaseRepository`` 独立副本,与
``external_systems.base.BaseRepository`` 在设计上保持一致但**独立维护**
不 import ``yuxi.repositories.external_systems`` 包的任何符号,避免跨限界
上下文耦合。后续调度域仓储的演化由本子包自行控制。
所有 ``scheduled`` 下的仓库均继承 ``BaseRepository``,复用:
- ``_escape_like_pattern`` —— LIKE 关键词转义
- ``_resolve_pagination`` —— 分页参数解析page/page_size 优先,其次 limit/offset
- ``_not_deleted`` —— 软删除过滤条件 ``is_deleted == 0``
- ``delete_by_id`` —— 按主键执行软删除(``is_deleted=1, deleted_at=now``
- ``get_deleted_by_id`` —— 按主键查询软删除记录(恢复前精确查询,避免 list_deleted 误匹配)
- ``restore_by_id`` —— 按主键恢复软删除记录(``is_deleted=0, deleted_at=None``
- ``list_deleted`` —— 查询软删除记录列表(按 ``deleted_at`` 降序,支持时间范围与分页)
- ``count_deleted`` —— 统计软删除记录数
- ``hard_delete_by_id`` —— 按主键物理删除记录(仅删除 ``is_deleted=1`` 的记录)
- ``hard_delete_before`` —— 物理删除 ``deleted_at`` 早于截止时间的软删除记录
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import delete as sa_delete, func, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.utils.datetime_utils import utc_now_naive
class BaseRepository:
"""定时任务调度域仓库基类。
子类需设置 ``model`` 类属性为对应的 SQLAlchemy 模型,即可复用软删除
过滤、分页解析与 LIKE 转义等通用逻辑。
"""
model: type = None # 子类覆盖为具体模型
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ------------------------------------------------------------------
# 通用工具
# ------------------------------------------------------------------
@staticmethod
def _escape_like_pattern(value: str) -> str:
"""转义 SQL LIKE 特殊字符,避免关键词注入错误匹配。"""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
@staticmethod
def _resolve_pagination(
page: int | None,
page_size: int | None,
limit: int | None,
offset: int | None,
) -> tuple[int | None, int]:
"""解析分页参数,优先使用 page/page_size其次 limit/offset。"""
if page is not None and page_size is not None:
return page_size, max(page - 1, 0) * page_size
if limit is not None:
return limit, offset or 0
return None, 0
def _not_deleted(self):
"""返回当前模型的软删除过滤条件is_deleted == 0"""
return self.model.is_deleted == 0
async def delete_by_id(
self,
record_id: int,
*,
updated_by: str | None = None,
commit: bool = True,
) -> int:
"""按主键执行软删除(``is_deleted=1, deleted_at=now``)。返回受影响行数。
仅标记 ``is_deleted=0`` 的记录,避免重复软删除。当 ``commit=False``
时仅执行 ``flush()`` 使变更进入会话但不提交,便于上层在事务中组合
多个写操作。
"""
now = utc_now_naive()
stmt = (
sa_update(self.model)
.where(self.model.id == record_id)
.where(self.model.is_deleted == 0)
.values(is_deleted=1, deleted_at=now, updated_at=now, updated_by=updated_by)
)
result = await self.db.execute(stmt)
if commit:
await self.db.commit()
else:
await self.db.flush()
return result.rowcount
# ------------------------------------------------------------------
# 回收站(软删除记录查询 / 恢复 / 物理删除)
# ------------------------------------------------------------------
async def get_deleted_by_id(self, record_id: int):
"""按主键查询软删除记录is_deleted=1。返回 ORM 实例或 None。
用于恢复前的记录查询与唯一约束预检,避免 list_deleted(limit=1) 的误匹配。
"""
stmt = (
select(self.model)
.where(self.model.id == record_id)
.where(self.model.is_deleted == 1)
)
result = await self.db.execute(stmt)
return result.scalars().first()
async def restore_by_id(
self,
record_id: int,
*,
updated_by: str | None = None,
commit: bool = True,
) -> int:
"""按主键恢复软删除记录is_deleted=0, deleted_at=None。返回受影响行数。
仅恢复 is_deleted=1 的记录,避免误恢复未删除资源。
"""
now = utc_now_naive()
stmt = (
sa_update(self.model)
.where(self.model.id == record_id)
.where(self.model.is_deleted == 1)
.values(is_deleted=0, deleted_at=None, updated_at=now, updated_by=updated_by)
)
result = await self.db.execute(stmt)
if commit:
await self.db.commit()
else:
await self.db.flush()
return result.rowcount
async def list_deleted(
self,
*,
limit: int | None = None,
offset: int = 0,
start_time: datetime | None = None,
end_time: datetime | None = None,
) -> list:
"""查询软删除记录is_deleted=1按 deleted_at 降序排序。"""
stmt = select(self.model).where(self.model.is_deleted == 1)
if start_time is not None:
stmt = stmt.where(self.model.deleted_at >= start_time)
if end_time is not None:
stmt = stmt.where(self.model.deleted_at <= end_time)
stmt = stmt.order_by(self.model.deleted_at.desc())
if limit is not None:
stmt = stmt.limit(limit).offset(offset)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def count_deleted(self) -> int:
"""统计软删除记录数。"""
stmt = select(func.count()).select_from(self.model).where(self.model.is_deleted == 1)
result = await self.db.execute(stmt)
return int(result.scalar() or 0)
async def hard_delete_by_id(
self,
record_id: int,
*,
commit: bool = True,
) -> int:
"""按主键物理删除记录(仅删除 is_deleted=1 的记录)。返回受影响行数。"""
stmt = (
sa_delete(self.model)
.where(self.model.id == record_id)
.where(self.model.is_deleted == 1)
)
result = await self.db.execute(stmt)
if commit:
await self.db.commit()
else:
await self.db.flush()
return result.rowcount
async def hard_delete_before(
self,
cutoff_at: datetime,
*,
commit: bool = True,
) -> int:
"""物理删除 deleted_at 早于 cutoff_at 的软删除记录。返回受影响行数。"""
stmt = (
sa_delete(self.model)
.where(self.model.is_deleted == 1)
.where(self.model.deleted_at < cutoff_at)
)
result = await self.db.execute(stmt)
if commit:
await self.db.commit()
else:
await self.db.flush()
return result.rowcount