refactor: reorganize startup persistence boundaries

This commit is contained in:
jxxghp
2026-08-23 21:24:34 +08:00
parent 7f09927c47
commit e1509c4e0d
96 changed files with 1036 additions and 782 deletions
+1
View File
@@ -0,0 +1 @@
"""实现 Application 持久化端口的 SQLAlchemy 适配器。"""
+152
View File
@@ -0,0 +1,152 @@
"""Chain durable 事件写入端口的 SQLAlchemy 适配器。"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from sqlalchemy.orm import Session
from app.application.chain.durable_events import (
ChainDurableEventWriter,
TransferHistoryRef,
download_added_event_key,
snapshot_download_added,
snapshot_transfer_result,
transfer_result_event_key,
)
from app.application.history import TransferHistoryRecord, TransferHistoryWriter
from app.application.outbox import DurableEventCommand, OutboxIntent
from app.db.adapters.outbox import SqlAlchemyOutboxRepository
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.uow import SqlAlchemyUnitOfWork
class _StagingTransferHistoryWriter:
"""让既有历史字段映射复用无提交的 replace 适配器。"""
def __init__(self, repository: TransferHistoryOper) -> None:
"""保存绑定调用方 Session 的整理历史仓储。"""
self._repository = repository
def get_by_src(
self,
src: str,
storage: str | None = None,
) -> TransferHistoryRecord | None:
"""转发按源路径读取。"""
return self._repository.get_by_src(src, storage)
def get_success_by_src(
self,
src: str,
storage: str | None = None,
) -> TransferHistoryRecord | None:
"""转发按源路径读取成功记录。"""
return self._repository.get_success_by_src(src, storage)
def add_force(self, **payload: Any) -> TransferHistoryRecord:
"""保持应用层旧端口名,但只暂存替换而不自行提交。"""
return self._repository.stage_replace_by_src(**payload)
class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
"""为每次 Chain 结果事件创建独占同步 Session 和 UoW。"""
def __init__(self, session_factory: Callable[[], Session]) -> None:
"""注入惰性同步 Session 工厂。"""
self._session_factory = session_factory
def download_added(
self,
*,
history_payload: dict[str, Any],
file_payloads: list[dict[str, Any]],
event_payload: dict[str, Any],
after_commit: Callable[[], None],
publish: Callable[[dict[str, Any]], None],
) -> None:
"""原子写下载历史、文件清单和 DownloadAdded intent。"""
session = self._session_factory()
try:
repository = DownloadHistoryOper(session)
outbox = SqlAlchemyOutboxRepository(session)
command = DurableEventCommand(
unit_of_work=SqlAlchemyUnitOfWork(session),
outbox=outbox,
)
event_key = download_added_event_key(event_payload)
event_payload["idempotency_key"] = event_key
def stage_business() -> None:
"""在同一事务暂存下载历史和可选文件清单。"""
repository.stage_add(history_payload)
if file_payloads:
repository.stage_add_files(file_payloads)
command.execute(
intent=OutboxIntent(
event_key=event_key,
topic="download.added",
payload=snapshot_download_added(event_payload),
),
stage_business=stage_business,
after_commit=after_commit,
publish=lambda: publish(event_payload),
)
finally:
session.close()
def transfer_result(
self,
*,
topic: str,
stage_history: Callable[[TransferHistoryWriter], TransferHistoryRecord | None],
event_payload: dict[str, Any],
publish: Callable[[dict[str, Any]], None],
) -> TransferHistoryRecord | None:
"""原子写整理历史与结果 intent,并返回脱离 Session 的最小投影。"""
session = self._session_factory()
try:
staging = _StagingTransferHistoryWriter(TransferHistoryOper(session))
command = DurableEventCommand(
unit_of_work=SqlAlchemyUnitOfWork(session),
outbox=SqlAlchemyOutboxRepository(session),
)
def stage_business() -> TransferHistoryRef | None:
"""复用历史字段映射,并在 flush 后冻结安全投影。"""
history = stage_history(staging)
if history is None:
return None
return TransferHistoryRef(
id=history.id,
status=bool(history.status),
src=history.src,
src_storage=history.src_storage,
src_fileitem=history.src_fileitem,
)
def build_intent(
history: TransferHistoryRef | None,
) -> OutboxIntent:
"""历史 ID 确定后构造事件键与可恢复快照。"""
if history is None:
raise RuntimeError("整理历史暂存失败,无法登记 durable 结果事件")
event_key = transfer_result_event_key(topic, history.id)
event_payload["transfer_history_id"] = history.id
event_payload["idempotency_key"] = event_key
return OutboxIntent(
event_key=event_key,
topic=topic,
payload=snapshot_transfer_result(event_payload),
)
return command.execute(
intent=build_intent,
stage_business=stage_business,
publish=lambda: publish(event_payload),
)
finally:
session.close()
+55
View File
@@ -0,0 +1,55 @@
"""下载失败冷却切片的显式会话与事务适配器。"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, cast
from app.db.oper.downloadfailure import DownloadFailureOper
from app.db.uow import SqlAlchemyUnitOfWork
class TransactionalDownloadFailureRepository:
"""为 Chain 下载失败读写创建短生命周期会话并显式收口事务。"""
def __init__(self, session_factory: Callable[[], Any]) -> None:
"""保存由启动组合根提供的同步会话工厂。"""
self._session_factory = session_factory
def get_active_by_fingerprints(
self,
fingerprints: list[str],
now_time: str,
) -> dict[str, Any]:
"""在独立只读会话中查询仍处于冷却期的失败记录。"""
with self._session_factory() as session:
return cast(
dict[str, Any],
DownloadFailureOper(db=session).get_active_by_fingerprints(
fingerprints=fingerprints,
now_time=now_time,
),
)
def record_failure(
self,
fingerprint: str,
now_time: str,
next_retry_at: str,
**kwargs: object,
) -> Any:
"""在一个显式 UoW 中新增或更新下载失败记录。"""
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
failure = DownloadFailureOper(db=session).record_failure(
fingerprint=fingerprint,
now_time=now_time,
next_retry_at=next_retry_at,
**kwargs,
)
transaction.commit()
return failure
except Exception:
transaction.rollback()
raise
+168
View File
@@ -0,0 +1,168 @@
"""Application outbox 端口的 SQLAlchemy 持久化适配器。"""
from datetime import datetime
from sqlalchemy import or_, select, update
from sqlalchemy.orm import Session
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.outbox import ClaimedOutboxMessage, OutboxIntent
from app.db.base import execute_dml
from app.db.models.outbox import OutboxMessage
def _iso(value: datetime) -> str:
"""将带时区时间统一序列化为可排序 ISO 字符串。"""
return value.isoformat()
class SqlAlchemyOutboxRepository:
"""使用调用方 Session 原子暂存并条件认领 outbox。"""
def __init__(self, session: Session) -> None:
"""保存由调用方拥有的 SQLAlchemy Session。"""
self._session = session
def stage(self, intent: OutboxIntent, now: datetime) -> None:
"""加入当前事务并 flush,使唯一键冲突在业务 commit 前暴露。"""
self._session.add(
OutboxMessage(
event_key=intent.event_key,
topic=intent.topic,
payload_version=intent.payload_version,
payload=intent.payload,
status="pending",
attempt=0,
next_retry_at=_iso(now),
created_at=_iso(now),
)
)
self._session.flush()
def claim(
self,
now: datetime,
lease_until: datetime,
) -> ClaimedOutboxMessage | None:
"""条件更新候选行;并发丢失竞争时返回 None。"""
now_text = _iso(now)
candidate = self._session.execute(
select(OutboxMessage)
.where(
OutboxMessage.status.in_(("pending", "processing")),
OutboxMessage.next_retry_at <= now_text,
or_(
OutboxMessage.lease_until.is_(None),
OutboxMessage.lease_until <= now_text,
),
)
.order_by(OutboxMessage.id)
.limit(1)
).scalars().first()
if candidate is None:
return None
next_attempt = candidate.attempt + 1
claimed = execute_dml(
self._session,
update(OutboxMessage)
.where(
OutboxMessage.id == candidate.id,
OutboxMessage.attempt == candidate.attempt,
or_(
OutboxMessage.lease_until.is_(None),
OutboxMessage.lease_until <= now_text,
),
)
.values(
status="processing",
attempt=next_attempt,
lease_until=_iso(lease_until),
),
)
self._session.commit()
if not claimed:
return None
return ClaimedOutboxMessage(
message_id=candidate.id,
event_key=candidate.event_key,
topic=candidate.topic,
payload=dict(candidate.payload),
payload_version=candidate.payload_version,
attempt=next_attempt,
)
def complete(self, message_id: int, completed_at: datetime) -> None:
"""持久化完成终态并释放 lease。"""
self._session.execute(
update(OutboxMessage)
.where(OutboxMessage.id == message_id)
.values(status="completed", completed_at=_iso(completed_at), lease_until=None)
)
self._session.commit()
def complete_by_event_key(self, event_key: str, completed_at: datetime) -> None:
"""即时 post-commit 全部成功时按幂等键收口对应 intent。"""
self._session.execute(
update(OutboxMessage)
.where(OutboxMessage.event_key == event_key)
.values(status="completed", completed_at=_iso(completed_at), lease_until=None)
)
self._session.commit()
def retry(
self,
message_id: int,
*,
next_retry_at: datetime,
last_error: str,
dead: bool,
) -> None:
"""持久化下一次退避或不可自动重试的 dead 终态。"""
self._session.execute(
update(OutboxMessage)
.where(OutboxMessage.id == message_id)
.values(
status="dead" if dead else "pending",
next_retry_at=_iso(next_retry_at),
lease_until=None,
last_error=last_error,
)
)
self._session.commit()
class SqlAlchemyAsyncOutboxStager:
"""只负责把 outbox 意图加入调用方异步事务。"""
def __init__(self, session: AsyncSession) -> None:
"""保存由异步订阅命令拥有的 Session。"""
self._session = session
async def stage(self, intent: OutboxIntent, now: datetime) -> None:
"""暂存并 flush,确保业务行与意图由同一次 commit 决定。"""
self._session.add(
OutboxMessage(
event_key=intent.event_key,
topic=intent.topic,
payload_version=intent.payload_version,
payload=intent.payload,
status="pending",
attempt=0,
next_retry_at=_iso(now),
created_at=_iso(now),
)
)
await self._session.flush()
async def complete_by_event_key(
self,
event_key: str,
completed_at: datetime,
) -> None:
"""异步 post-commit 全部成功时按幂等键收口 intent。"""
await self._session.execute(
update(OutboxMessage)
.where(OutboxMessage.event_key == event_key)
.values(status="completed", completed_at=_iso(completed_at), lease_until=None)
)
await self._session.commit()
+225
View File
@@ -0,0 +1,225 @@
"""站点 Chain 端口的显式会话与事务适配器。"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from contextlib import AbstractAsyncContextManager
from typing import Any, TypeVar
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db.oper.site import SiteOper
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
T = TypeVar("T")
class TransactionalSiteRepository:
"""为同步 Chain 站点端口和异步健康统计提供短生命周期会话。"""
def __init__(
self,
*,
sync_session: Callable[[], Session],
async_session: Callable[[], AbstractAsyncContextManager[AsyncSession]],
) -> None:
"""保存同步会话工厂和异步会话上下文工厂。"""
self._sync_session = sync_session
self._async_session = async_session
def _read(self, operation: Callable[[SiteOper], T]) -> T:
"""在独立同步会话中执行只读站点操作。"""
with self._sync_session() as session:
return operation(SiteOper(db=session))
def _write(self, operation: Callable[[SiteOper], T]) -> T:
"""在独立同步 UoW 中执行站点写操作。"""
with self._sync_session() as session:
session.expire_on_commit = False
unit_of_work = SqlAlchemyUnitOfWork(session)
try:
result = operation(SiteOper(db=session))
unit_of_work.commit()
return result
except Exception:
unit_of_work.rollback()
raise
async def _async_write(
self,
operation: Callable[[SiteOper], Awaitable[T]],
) -> T:
"""在独立异步 UoW 中执行站点写操作。"""
async with self._async_session() as session:
session.sync_session.expire_on_commit = False
unit_of_work = SqlAlchemyAsyncUnitOfWork(session)
try:
result = await operation(SiteOper(db=session))
await unit_of_work.commit()
return result
except Exception:
await unit_of_work.rollback()
raise
async def _async_read(self, operation: Callable[[SiteOper], Awaitable[T]]) -> T:
"""在独立异步会话中执行只读站点操作。"""
async with self._async_session() as session:
return await operation(SiteOper(db=session))
def add(self, **kwargs: Any) -> tuple[bool, str]:
"""新增站点并提交事务。"""
return self._write(lambda repository: repository.add(**kwargs))
def get(self, site_id: int) -> Any:
"""按 ID 查询站点。"""
return self._read(lambda repository: repository.get(site_id))
def get_by_domain(self, domain: str) -> Any:
"""按域名查询站点。"""
return self._read(lambda repository: repository.get_by_domain(domain))
def get_domains_by_ids(self, ids: list[int]) -> list[str | None]:
"""查询一组站点 ID 对应的域名。"""
return self._read(lambda repository: repository.get_domains_by_ids(ids))
def list(self) -> list[Any]:
"""查询全部站点。"""
return self._read(lambda repository: repository.list())
def list_order_by_pri(self) -> list[Any]:
"""同步按优先级查询站点。"""
return self._read(lambda repository: repository.list_order_by_pri())
def get_userdata_latest(self) -> list[Any]:
"""同步查询各站点最新用户数据。"""
return self._read(lambda repository: repository.get_userdata_latest())
async def async_get(self, site_id: int) -> Any:
"""异步按 ID 查询站点。"""
return await self._async_read(lambda repository: repository.async_get(site_id))
async def async_get_by_domain(self, domain: str) -> Any:
"""异步按域名查询站点。"""
return await self._async_read(
lambda repository: repository.async_get_by_domain(domain)
)
async def async_get_by_name(self, name: str) -> Any:
"""异步按名称查询站点。"""
return await self._async_read(
lambda repository: repository.async_get_by_name(name)
)
async def async_list(self) -> list[Any]:
"""异步查询全部站点。"""
return await self._async_read(lambda repository: repository.async_list())
async def async_list_order_by_pri(self) -> list[Any]:
"""异步按优先级查询站点。"""
return await self._async_read(
lambda repository: repository.async_list_order_by_pri()
)
async def async_update(self, site_id: int, payload: dict[str, Any]) -> Any:
"""异步更新站点并提交事务。"""
return await self._async_write(
lambda repository: repository.async_update(site_id, payload)
)
async def async_get_userdata_by_domain(
self,
domain: str,
workdate: str | None = None,
) -> list[Any]:
"""异步查询站点用户数据。"""
return await self._async_read(
lambda repository: repository.async_get_userdata_by_domain(domain, workdate)
)
async def async_get_userdata_latest(self) -> list[Any]:
"""异步查询各站点最新用户数据。"""
return await self._async_read(
lambda repository: repository.async_get_userdata_latest()
)
async def async_get_icon_by_domain(self, domain: str) -> Any:
"""异步按域名查询站点图标。"""
return await self._async_read(
lambda repository: repository.async_get_icon_by_domain(domain)
)
async def async_get_statistic_by_domain(self, domain: str) -> Any:
"""异步按域名查询站点统计。"""
return await self._async_read(
lambda repository: repository.async_get_statistic_by_domain(domain)
)
async def async_list_statistics(self) -> list[Any]:
"""异步查询全部站点统计。"""
return await self._async_read(
lambda repository: repository.async_list_statistics()
)
def update(self, site_id: int, payload: dict[str, Any]) -> Any:
"""更新站点并提交事务。"""
return self._write(lambda repository: repository.update(site_id, payload))
def update_cookie(self, domain: str, cookies: str) -> tuple[bool, str]:
"""更新站点 Cookie 并提交事务。"""
return self._write(
lambda repository: repository.update_cookie(domain, cookies)
)
def update_rss(self, domain: str, rss: str) -> tuple[bool, str]:
"""更新站点 RSS 地址并提交事务。"""
return self._write(lambda repository: repository.update_rss(domain, rss))
def update_userdata(
self,
domain: str,
name: str,
payload: dict[str, Any],
) -> tuple[bool, str]:
"""更新站点用户数据并提交事务。"""
return self._write(
lambda repository: repository.update_userdata(domain, name, payload)
)
def update_icon(
self,
name: str,
domain: str,
icon_url: str,
icon_base64: str,
) -> bool:
"""更新站点图标并提交事务。"""
return self._write(
lambda repository: repository.update_icon(
name,
domain,
icon_url,
icon_base64,
)
)
def success(self, domain: str, seconds: int | None = None) -> Any:
"""记录站点访问成功并提交事务。"""
return self._write(lambda repository: repository.success(domain, seconds))
def fail(self, domain: str) -> Any:
"""记录站点访问失败并提交事务。"""
return self._write(lambda repository: repository.fail(domain))
async def async_success(self, domain: str, seconds: int | None = None) -> Any:
"""异步记录站点访问成功并提交事务。"""
return await self._async_write(
lambda repository: repository.async_success(domain, seconds)
)
async def async_fail(self, domain: str) -> Any:
"""异步记录站点访问失败并提交事务。"""
return await self._async_write(
lambda repository: repository.async_fail(domain)
)
+129
View File
@@ -0,0 +1,129 @@
"""订阅写入端口的 SQLAlchemy 事务适配器。"""
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.application.subscription.write import (
AfterCommitEffect,
AsyncAfterCommitEffect,
AsyncCreateSubscriptionCommand,
CreateSubscriptionCommand,
subscription_added_event_key,
subscription_added_notification_key,
subscription_added_report_key,
)
from app.db.adapters.outbox import (
SqlAlchemyAsyncOutboxStager,
SqlAlchemyOutboxRepository,
)
from app.db.oper.subscribe import SubscribeOper
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
class TransactionalSubscribeWriter:
"""为每次订阅新增创建独占会话,并把提交权交给 Application Command。"""
def __init__(
self,
sync_session: Callable[[], Session],
async_session: Callable[
[],
AbstractAsyncContextManager[AsyncSession],
],
) -> None:
"""注入同步会话工厂和异步会话作用域。"""
self._sync_session = sync_session
self._async_session = async_session
def add(
self,
identity: dict,
payload: dict,
username: str | None = None,
after_commit: AfterCommitEffect | None = None,
notification: dict[str, object] | None = None,
) -> tuple[int, str]:
"""在独占同步会话内执行一次完整订阅新增事务。"""
session = self._sync_session()
try:
outbox = SqlAlchemyOutboxRepository(session)
command = CreateSubscriptionCommand(
repository=SubscribeOper(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
outbox=outbox,
)
def delivered(subscribe_id: int) -> None:
"""执行旧 post-commit 编排,全部成功后收口 durable intent。"""
if after_commit:
after_commit(subscribe_id)
outbox.complete_by_event_key(
subscription_added_event_key(subscribe_id, payload),
datetime.now(timezone.utc),
)
if notification:
outbox.complete_by_event_key(
subscription_added_notification_key(subscribe_id, payload),
datetime.now(timezone.utc),
)
outbox.complete_by_event_key(
subscription_added_report_key(subscribe_id, payload),
datetime.now(timezone.utc),
)
return command.execute(
identity,
payload,
username,
delivered,
notification,
)
finally:
session.close()
async def async_add(
self,
identity: dict,
payload: dict,
username: str | None = None,
after_commit: AsyncAfterCommitEffect | None = None,
notification: dict[str, object] | None = None,
) -> tuple[int, str]:
"""在独占异步会话作用域内执行一次完整订阅新增事务。"""
async with self._async_session() as session:
outbox = SqlAlchemyAsyncOutboxStager(session)
command = AsyncCreateSubscriptionCommand(
repository=SubscribeOper(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
outbox=outbox,
)
async def delivered(subscribe_id: int) -> None:
"""异步执行旧编排,全部成功后收口 durable intent。"""
if after_commit:
await after_commit(subscribe_id)
await outbox.complete_by_event_key(
subscription_added_event_key(subscribe_id, payload),
datetime.now(timezone.utc),
)
if notification:
await outbox.complete_by_event_key(
subscription_added_notification_key(subscribe_id, payload),
datetime.now(timezone.utc),
)
await outbox.complete_by_event_key(
subscription_added_report_key(subscribe_id, payload),
datetime.now(timezone.utc),
)
return await command.execute(
identity,
payload,
username,
delivered,
notification,
)
+61
View File
@@ -0,0 +1,61 @@
"""旧 Oper 写入口的 SQLAlchemy 事务执行适配器。"""
from collections.abc import Awaitable, Callable
from contextlib import AbstractAsyncContextManager
from typing import TypeVar
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
T = TypeVar("T")
class TransactionalWriteRunner:
"""为兼容写入口创建独占会话,并用 UoW 明确提交或回滚。"""
def __init__(
self,
*,
sync_session: Callable[[], Session],
async_session: Callable[[], AbstractAsyncContextManager[AsyncSession]],
) -> None:
"""保存同步会话工厂和异步会话上下文工厂。"""
self._sync_session = sync_session
self._async_session = async_session
def sync(self, operation: Callable[[Session], T]) -> T:
"""在独占同步 Session 中执行操作并统一收口事务。"""
session = self._sync_session()
# 兼容 Oper 历史上会返回刚写入的 ORM 对象;提交后若过期,Session 关闭后连主键
# 都无法读取。独占短会话没有后续一致性读取需求,因此保留已 flush 的字段快照。
session.expire_on_commit = False
unit_of_work = SqlAlchemyUnitOfWork(session)
try:
result = operation(session)
unit_of_work.commit()
return result
except Exception:
unit_of_work.rollback()
raise
finally:
session.close()
async def async_(
self,
operation: Callable[[AsyncSession], Awaitable[T]],
) -> T:
"""在独占 AsyncSession 中执行操作并统一收口事务。"""
async with self._async_session() as session:
# 与同步兼容入口保持相同的返回对象生命周期。
session.sync_session.expire_on_commit = False
unit_of_work = SqlAlchemyAsyncUnitOfWork(session)
try:
result = await operation(session)
await unit_of_work.commit()
return result
except Exception:
await unit_of_work.rollback()
raise
+71
View File
@@ -0,0 +1,71 @@
"""工作流执行状态事务适配器。"""
from collections.abc import Callable
from typing import Any, TypeVar
from sqlalchemy.orm import Session
from app.application.workflow import WorkflowExecutionCommand
from app.db.oper.workflow import WorkflowOper
from app.db.uow import SqlAlchemyUnitOfWork
_Result = TypeVar("_Result")
class TransactionalWorkflowExecutionService:
"""为每次工作流执行状态写入创建独立短会话和 UnitOfWork。"""
def __init__(self, session_factory: Callable[[], Session]) -> None:
"""保存由启动组合根提供的同步 Session 工厂。"""
self._session_factory = session_factory
def start(self, workflow_id: int) -> bool:
"""以独立事务提交运行中状态。"""
return self._run(lambda command: command.start(workflow_id))
def success(self, workflow_id: int, result: str | None = None) -> bool:
"""以独立事务提交成功状态。"""
return self._run(lambda command: command.success(workflow_id, result))
def fail(self, workflow_id: int, result: str) -> bool:
"""以独立事务提交失败状态。"""
return self._run(lambda command: command.fail(workflow_id, result))
def step(
self,
workflow_id: int,
action_id: str,
context: dict[str, Any],
execution_state: dict[str, Any] | None = None,
) -> bool:
"""以独立事务提交动作进度。"""
return self._run(
lambda command: command.step(
workflow_id,
action_id,
context,
execution_state,
)
)
def reset(self, workflow_id: int, reset_count: bool = False) -> bool:
"""以独立事务提交执行状态重置。"""
return self._run(
lambda command: command.reset(workflow_id, reset_count)
)
def _run(
self,
operation: Callable[[WorkflowExecutionCommand], _Result],
) -> _Result:
"""创建短会话并把提交/回滚交给 Application command。"""
session = self._session_factory()
try:
command = WorkflowExecutionCommand(
repository=WorkflowOper(db=session),
unit_of_work=SqlAlchemyUnitOfWork(session),
)
return operation(command)
finally:
session.close()
+36 -15
View File
@@ -1,7 +1,7 @@
"""
ORM 基类与数据访问基类。
Base 提供声明式基类与通用的行为(字典转换、增删改查便利方法);
Base 提供声明式基类与兼容行为(字典转换、增删改查便利方法);
DbOper 是各业务 Oper 的基类,持有一个可注入的会话。
"""
from collections.abc import Awaitable, Callable
@@ -12,9 +12,14 @@ from sqlalchemy import (CursorResult, Executable, Identity, Integer, Sequence,
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, declared_attr, mapped_column
from app.runtime.config import settings
from app.db.decorators import async_db_query, async_db_update, db_query, db_update
from app.db.decorators import (
legacy_async_db_query,
legacy_async_db_update,
legacy_db_query,
legacy_db_update,
)
from app.db.uow import run_async_transaction, run_sync_transaction
from app.runtime.config import settings
T = TypeVar("T")
@@ -64,88 +69,104 @@ class Base(DeclarativeBase): # type: ignore[misc] # SQLAlchemy 无 py.typed
继承本类的模型一律使用 mapped_column() + Mapped[] 注解;确需非映射的类级属性时
用 ClassVar 显式声明,而不是把这个标志加回来。
create/get/update/delete/list/truncate 及其异步版本仅保留旧插件 ABI。宿主新代码应由
Application Command 定义事务边界,经显式 Session 调用 Oper,不得新增对这些方法的依赖。
"""
# 由 get_id_column() 在各模型中提供实际的列定义,这里只声明类型供 IDE 使用
id: Mapped[int]
@db_update
@legacy_db_update
def create(self, db: Session) -> None:
"""兼容旧插件调用:新增当前模型并提交。"""
db.add(self)
@async_db_update
@legacy_async_db_update
async def async_create(self, db: AsyncSession) -> Self:
"""兼容旧插件调用:异步新增当前模型、刷新主键并提交。"""
db.add(self)
await db.flush()
return self
@classmethod
@db_query
@legacy_db_query
def get(cls, db: Session, rid: int) -> Optional[Self]:
"""兼容旧插件调用:按主键查询当前模型。"""
return cast(
Optional[Self],
db.execute(select(cls).where(and_(cls.id == rid))).scalars().first(),
)
@classmethod
@async_db_query
@legacy_async_db_query
async def async_get(cls, db: AsyncSession, rid: int) -> Optional[Self]:
"""兼容旧插件调用:异步按主键查询当前模型。"""
result = await db.execute(select(cls).where(and_(cls.id == rid)))
return cast(Optional[Self], result.scalars().first())
@db_update
@legacy_db_update
def update(self, db: Session, payload: dict[str, Any]) -> None:
"""兼容旧插件调用:更新当前模型字段并提交。"""
for key, value in payload.items():
setattr(self, key, value)
if inspect(self).detached:
db.add(self)
@async_db_update
@legacy_async_db_update
async def async_update(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> None:
"""兼容旧插件调用:异步更新当前模型字段并提交。"""
for key, value in payload.items():
setattr(self, key, value)
if inspect(self).detached:
db.add(self)
@classmethod
@db_update
@legacy_db_update
def delete(cls, db: Session, rid: Any) -> None:
"""兼容旧插件调用:按主键删除当前模型并提交。"""
db.execute(delete(cls).where(and_(cls.id == rid)))
@classmethod
@async_db_update
@legacy_async_db_update
async def async_delete(cls, db: AsyncSession, rid: Any) -> None:
"""兼容旧插件调用:异步按主键删除当前模型并提交。"""
result = await db.execute(select(cls).where(and_(cls.id == rid)))
user = result.scalars().first()
if user:
await db.delete(user)
@classmethod
@db_update
@legacy_db_update
def truncate(cls, db: Session) -> None:
"""兼容旧插件调用:清空当前模型表并提交。"""
db.execute(delete(cls))
@classmethod
@async_db_update
@legacy_async_db_update
async def async_truncate(cls, db: AsyncSession) -> None:
"""兼容旧插件调用:异步清空当前模型表并提交。"""
await db.execute(delete(cls))
@classmethod
@db_query
@legacy_db_query
def list(cls, db: Session) -> List[Self]:
"""兼容旧插件调用:查询当前模型的全部记录。"""
return list(db.execute(select(cls)).scalars().all())
@classmethod
@async_db_query
@legacy_async_db_query
async def async_list(cls, db: AsyncSession) -> List[Self]:
"""兼容旧插件调用:异步查询当前模型的全部记录。"""
result = await db.execute(select(cls))
return list(result.scalars().all())
def to_dict(self) -> dict[str, Any]:
"""把当前模型的映射列转换为字典。"""
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns} # noqa
@declared_attr.directive # type: ignore[misc] # SQLAlchemy decorator 缺少类型信息
+74 -22
View File
@@ -5,8 +5,8 @@
未显式传入会话时自动创建,并在结束时归还——异步路径经 async_session_scope 收口,
连接池与配额都在那里生效。
收尾故障(rollback / close / __aexit__ 自身抛异常)一律只记日志、不上抛,四个装饰器
的处理一致。理由与代价都要写明,别当成漏写的 raise:
收尾故障(rollback / close / __aexit__ 自身抛异常)一律只记日志、不上抛,正式装饰器
和 legacy 兼容壳的处理一致。理由与代价都要写明,别当成漏写的 raise:
- 连接断开、事务已失效这类故障恰恰最容易发生在「出错之后」的收尾阶段。裸写收尾语句时
它一抛错就顶替掉原始异常,调用方看到的只剩「connection reset」,业务异常连类型都被
@@ -28,31 +28,12 @@ from app.runtime.log import logger
_R = TypeVar("_R")
# 四个装饰器会重写实参列表:未传会话时自行创建一个并塞回 db 位置。因此包装后的可调用
# 正式装饰器会重写实参列表:未传会话时自行创建一个并塞回 db 位置。因此包装后的可调用
# 对象接受的实参与被包装函数的签名并不一致——用 Callable[..., _R] 如实表达「参数由装饰器
# 接管、返回值原样透传」。否则调用方传 None 或传异步会话都会被判成类型不符,而这恰恰是
# 装饰器存在的理由(各 Oper 的 self._db 常态就是 None)。
def run_legacy_sync_query(operation: Callable[[Session], _R]) -> _R:
"""为已移除查询装饰器的旧 Model ABI 提供一次性同步会话。"""
db = ScopedSession()
try:
return operation(db)
finally:
try:
db.close()
except Exception as close_err: # noqa: BLE001 兼容查询释放失败不改变返回语义
logger.error(f"释放数据库会话失败:{close_err}")
async def run_legacy_async_query(
operation: Callable[[AsyncSession], Awaitable[_R]],
) -> _R:
"""为移除异步查询装饰器的旧 Model ABI 提供一次性异步会话。"""
async with async_session_scope() as db:
return await operation(db)
def _get_args_db(
args: tuple[Any, ...],
kwargs: dict[str, Any],
@@ -345,6 +326,77 @@ def legacy_async_db_query(
return wrapper
def legacy_db_update(func: Callable[..., _R]) -> Callable[..., _R]:
"""保留旧 Model 同步写 ABI,并维持历史自动提交语义。
该装饰器只供已经公开的 Model/Base 方法兼容仓外插件。宿主新写路径必须
通过 Application Command、显式 Session 和 UnitOfWork 完成事务收口。
"""
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> _R:
db = _get_args_db(args, kwargs)
owns_session = db is None
if db is None:
db = ScopedSession()
args, kwargs = _inject_legacy_db(func, args, kwargs, db)
try:
result = func(*args, **kwargs)
db.commit()
return result
except Exception:
try:
db.rollback()
except Exception as rollback_err: # noqa: BLE001 回滚失败不能掩盖原始异常
logger.error(f"事务回滚失败,原始异常将原样上抛:{rollback_err}")
raise
finally:
if owns_session:
try:
db.close()
except Exception as close_err: # noqa: BLE001 释放故障不得改变旧 ABI 结果
logger.error(f"释放数据库会话失败:{close_err}")
return wrapper
def legacy_async_db_update(
func: Callable[..., Awaitable[_R]],
) -> Callable[..., Awaitable[_R]]:
"""保留旧 Model 异步写 ABI,并维持历史自动提交语义。
该装饰器只承接既有兼容面;新宿主代码不得用它创建隐式事务。
"""
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> _R:
db = _get_args_async_db(args, kwargs)
owns_session = db is None
scope = None
if db is None:
scope = async_session_scope()
db = await scope.__aenter__()
args, kwargs = _inject_legacy_db(func, args, kwargs, db)
try:
result = await func(*args, **kwargs)
await db.commit()
return result
except Exception:
try:
await db.rollback()
except Exception as rollback_err: # noqa: BLE001 回滚失败不能掩盖原始异常
logger.error(f"事务回滚失败,原始异常将原样上抛:{rollback_err}")
raise
finally:
if owns_session and scope is not None:
try:
await scope.__aexit__(None, None, None)
except Exception as close_err: # noqa: BLE001 释放故障不得改变旧 ABI 结果
logger.error(f"释放数据库会话失败:{close_err}")
return wrapper
def _inject_legacy_db(
func: Callable[..., _R],
args: tuple[Any, ...],
+5 -7
View File
@@ -4,7 +4,7 @@ from sqlalchemy import Boolean, Index, Integer, String, Text, select, update
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, execute_dml, get_id_column
from app.db.decorators import run_legacy_sync_query
from app.db.decorators import legacy_db_query
def _get_for_user_statement(
@@ -85,6 +85,7 @@ class AgentTask(Base):
return task.id
@classmethod
@legacy_db_query
def get_for_user(
cls,
db: Session | int | None = None,
@@ -105,11 +106,10 @@ class AgentTask(Base):
_get_for_user_statement(cls, task_id=task_id, user_id=user_id)
).scalars().first()
if isinstance(db, Session):
return query(db)
return run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_db_query
def list_for_user(
cls,
db: Session | None = None,
@@ -125,9 +125,7 @@ class AgentTask(Base):
_list_for_user_statement(cls, user_id=user_id, enabled=enabled)
).scalars().all())
if isinstance(db, Session):
return query(db)
return run_legacy_sync_query(query)
return query(db)
@classmethod
def update_task(
+9 -5
View File
@@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, execute_dml, get_id_column
from app.db.decorators import run_legacy_async_query, run_legacy_sync_query
from app.db.decorators import legacy_async_db_query, legacy_db_query
class Message(Base):
@@ -49,6 +49,7 @@ class Message(Base):
return self.to_dict()
@classmethod
@legacy_db_query
def list_by_page(
cls,
db: Session | None = None,
@@ -67,9 +68,10 @@ class Message(Base):
.limit(count)
).scalars().all())
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_db_query
def exists_by_source(
cls,
db: Session | str | None = None,
@@ -93,9 +95,10 @@ class Message(Base):
select(cls.id).where(cls.source == source).limit(1)
).scalars().first() is not None
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
async def async_list_by_page(
cls, db: AsyncSession | None = None, page: int = 1, count: int = 30
) -> List["Message"]:
@@ -112,9 +115,10 @@ class Message(Base):
)
return list(result.scalars().all())
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
@classmethod
@legacy_async_db_query
async def async_list_sent_by_page(
cls,
db: AsyncSession | None = None,
@@ -155,7 +159,7 @@ class Message(Base):
)
return list(result.scalars().all())
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
@classmethod
def delete_before(
+4 -7
View File
@@ -8,7 +8,6 @@ from app.db.base import Base, get_id_column
from app.db.decorators import (
legacy_async_db_query,
legacy_db_query,
run_legacy_sync_query,
)
@@ -55,6 +54,7 @@ class PassKey(Base):
transports: Mapped[Optional[str]] = mapped_column(String, nullable=True)
@classmethod
@legacy_db_query
def get_by_user_id(
cls,
db: Session | int | None = None,
@@ -72,9 +72,7 @@ class PassKey(Base):
_get_by_user_id_statement(cls, user_id)
).scalars().all())
if isinstance(db, Session):
return query(db)
return run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
@@ -86,6 +84,7 @@ class PassKey(Base):
return list(result.scalars().all())
@classmethod
@legacy_db_query
def get_by_credential_id(
cls,
db: Session | str | None = None,
@@ -103,9 +102,7 @@ class PassKey(Base):
_get_by_credential_id_statement(cls, credential_id)
).scalars().first()
if isinstance(db, Session):
return query(db)
return run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
+7 -23
View File
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import get_id_column, Base
from app.db.decorators import run_legacy_async_query, run_legacy_sync_query
from app.db.decorators import legacy_async_db_query, legacy_db_query
class PluginData(Base):
@@ -21,54 +21,44 @@ class PluginData(Base):
)
@classmethod
@legacy_db_query
def get_plugin_data(cls, db: Session | None = None, plugin_id: str | None = None):
"""在调用方 Session 中读取插件全部数据,并兼容旧无会话入口。"""
if plugin_id is None:
raise TypeError("plugin_id is required")
if not isinstance(db, Session):
return run_legacy_sync_query(lambda session: cls.get_plugin_data(session, plugin_id))
return list(db.execute(select(cls).where(cls.plugin_id == plugin_id)).scalars().all())
@classmethod
@legacy_async_db_query
async def async_get_plugin_data(
cls, db: AsyncSession | None = None, plugin_id: str | None = None
):
"""在调用方 AsyncSession 中读取插件全部数据,并兼容旧无会话入口。"""
if plugin_id is None:
raise TypeError("plugin_id is required")
if not isinstance(db, AsyncSession):
return await run_legacy_async_query(
lambda session: cls.async_get_plugin_data(session, plugin_id)
)
result = await db.execute(select(cls).where(cls.plugin_id == plugin_id))
return list(result.scalars().all())
@classmethod
@legacy_db_query
def get_plugin_data_by_key(
cls, db: Session | None = None, plugin_id: str | None = None, key: str | None = None
):
"""在调用方 Session 中按键读取插件数据,并兼容旧无会话入口。"""
if plugin_id is None or key is None:
raise TypeError("plugin_id and key are required")
if not isinstance(db, Session):
return run_legacy_sync_query(
lambda session: cls.get_plugin_data_by_key(session, plugin_id, key)
)
return db.execute(
select(cls).where(cls.plugin_id == plugin_id, cls.key == key)
).scalars().first()
@classmethod
@legacy_async_db_query
async def async_get_plugin_data_by_key(
cls, db: AsyncSession | None = None, plugin_id: str | None = None, key: str | None = None
):
"""在调用方 AsyncSession 中按键读取插件数据,并兼容旧无会话入口。"""
if plugin_id is None or key is None:
raise TypeError("plugin_id and key are required")
if not isinstance(db, AsyncSession):
return await run_legacy_async_query(
lambda session: cls.async_get_plugin_data_by_key(session, plugin_id, key)
)
result = await db.execute(
select(cls).where(cls.plugin_id == plugin_id, cls.key == key)
)
@@ -85,28 +75,22 @@ class PluginData(Base):
db.execute(delete(cls).where(cls.plugin_id == plugin_id))
@classmethod
@legacy_db_query
def get_plugin_data_by_plugin_id(
cls, db: Session | None = None, plugin_id: str | None = None
):
"""在调用方 Session 中按插件 ID 读取数据,并兼容旧无会话入口。"""
if plugin_id is None:
raise TypeError("plugin_id is required")
if not isinstance(db, Session):
return run_legacy_sync_query(
lambda session: cls.get_plugin_data_by_plugin_id(session, plugin_id)
)
return list(db.execute(select(cls).where(cls.plugin_id == plugin_id)).scalars().all())
@classmethod
@legacy_async_db_query
async def async_get_plugin_data_by_plugin_id(
cls, db: AsyncSession | None = None, plugin_id: str | None = None
):
"""在调用方 AsyncSession 中按插件 ID 读取数据,并兼容旧无会话入口。"""
if plugin_id is None:
raise TypeError("plugin_id is required")
if not isinstance(db, AsyncSession):
return await run_legacy_async_query(
lambda session: cls.async_get_plugin_data_by_plugin_id(session, plugin_id)
)
result = await db.execute(select(cls).where(cls.plugin_id == plugin_id))
return list(result.scalars().all())
+17 -9
View File
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, get_id_column
from app.db.decorators import run_legacy_async_query, run_legacy_sync_query
from app.db.decorators import legacy_async_db_query, legacy_db_query
class Site(Base):
@@ -58,6 +58,7 @@ class Site(Base):
downloader: Mapped[Optional[str]] = mapped_column(String)
@classmethod
@legacy_db_query
def get_by_domain(cls, db: Session | str | None = None, domain: str | None = None):
"""按域名查询站点,兼容显式会话和旧插件无会话调用。"""
if domain is None and isinstance(db, str):
@@ -69,9 +70,10 @@ class Site(Base):
"""在给定同步会话中执行域名查询。"""
return session.execute(select(cls).where(cls.domain == domain)).scalars().first()
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
async def async_get_by_domain(
cls,
db: AsyncSession | str | None = None,
@@ -88,9 +90,10 @@ class Site(Base):
result = await session.execute(select(cls).where(cls.domain == domain))
return result.scalar_one_or_none()
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
@classmethod
@legacy_async_db_query
async def async_get_by_name(
cls,
db: AsyncSession | str | None = None,
@@ -107,18 +110,20 @@ class Site(Base):
result = await session.execute(select(cls).where(cls.name == name))
return result.scalar_one_or_none()
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
@classmethod
@legacy_db_query
def get_actives(cls, db: Session | None = None):
"""查询启用站点,兼容显式会话和旧插件无会话调用。"""
def query(session: Session):
"""在给定同步会话中执行启用站点查询。"""
return list(session.execute(select(cls).where(cls.is_active.is_(True))).scalars().all())
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
async def async_get_actives(cls, db: AsyncSession | None = None):
"""异步查询启用站点,兼容显式会话和旧插件无会话调用。"""
async def query(session: AsyncSession):
@@ -126,18 +131,20 @@ class Site(Base):
result = await session.execute(select(cls).where(cls.is_active.is_(True)))
return list(result.scalars().all())
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
@classmethod
@legacy_db_query
def list_order_by_pri(cls, db: Session | None = None):
"""按优先级升序查询站点,兼容显式会话和旧插件无会话调用。"""
def query(session: Session):
"""在给定同步会话中执行优先级查询。"""
return list(session.execute(select(cls).order_by(cls.pri)).scalars().all())
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
async def async_list_order_by_pri(cls, db: AsyncSession | None = None):
"""异步按优先级升序查询站点,兼容显式会话和旧插件无会话调用。"""
async def query(session: AsyncSession):
@@ -145,9 +152,10 @@ class Site(Base):
result = await session.execute(select(cls).order_by(cls.pri))
return list(result.scalars().all())
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
@classmethod
@legacy_db_query
def get_domains_by_ids(
cls,
db: Session | list[int] | None = None,
@@ -165,7 +173,7 @@ class Site(Base):
"""在给定同步会话中执行域名投影查询。"""
return list(session.execute(select(cls.domain).where(cls.id.in_(ids))).scalars().all())
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
def reset(cls, db: Session):
+3 -4
View File
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, get_id_column
from app.db.decorators import run_legacy_async_query
from app.db.decorators import legacy_async_db_query
class SiteIcon(Base):
@@ -27,6 +27,7 @@ class SiteIcon(Base):
return db.execute(select(cls).where(cls.domain == domain)).scalars().first()
@classmethod
@legacy_async_db_query
async def async_get_by_domain(
cls,
db: AsyncSession | None = None,
@@ -41,6 +42,4 @@ class SiteIcon(Base):
result = await session.execute(select(cls).where(cls.domain == domain))
return result.scalar_one_or_none()
if isinstance(db, AsyncSession):
return await query(db)
return await run_legacy_async_query(query)
return await query(db)
+3 -4
View File
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import get_id_column, Base
from app.db.decorators import run_legacy_async_query
from app.db.decorators import legacy_async_db_query
class SiteStatistic(Base):
@@ -35,6 +35,7 @@ class SiteStatistic(Base):
return db.execute(select(cls).where(cls.domain == domain)).scalars().first()
@classmethod
@legacy_async_db_query
async def async_get_by_domain(
cls,
db: AsyncSession | None = None,
@@ -49,9 +50,7 @@ class SiteStatistic(Base):
result = await session.execute(select(cls).where(cls.domain == domain))
return result.scalar_one_or_none()
if isinstance(db, AsyncSession):
return await query(db)
return await run_legacy_async_query(query)
return await query(db)
@classmethod
def reset(cls, db: Session):
+35 -18
View File
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import get_id_column, Base
from app.db.decorators import run_legacy_async_query, run_legacy_sync_query
from app.db.decorators import legacy_async_db_query, legacy_db_query
from app.db.models._constraints import media_identity_constraint
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
@@ -140,6 +140,7 @@ class Subscribe(Base):
return condition
@classmethod
@legacy_db_query
def exists(
cls, db: Session | MediaSource | None = None,
media_source: MediaSource | str | None = None,
@@ -164,9 +165,10 @@ class Subscribe(Base):
return session.execute(
statement.where(cls.episode_group == episode_group)
).scalars().first()
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
async def async_exists(
cls, db: AsyncSession | MediaSource | None = None,
media_source: MediaSource | str | None = None,
@@ -192,9 +194,10 @@ class Subscribe(Base):
statement.where(cls.episode_group == episode_group)
)
return result.scalars().first()
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
@classmethod
@legacy_db_query
def exists_by_username(
cls, db: Session | str | None = None,
username: str | MediaSource | None = None,
@@ -224,9 +227,10 @@ class Subscribe(Base):
return session.execute(
statement.where(cls.episode_group == episode_group)
).scalars().first()
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
async def async_exists_by_username(
cls, db: AsyncSession | str | None = None,
username: str | MediaSource | None = None,
@@ -256,9 +260,10 @@ class Subscribe(Base):
statement.where(cls.episode_group == episode_group)
)
return result.scalars().first()
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
@classmethod
@legacy_db_query
def get_by_state(cls, db: Session | str | None = None, state: str | None = None):
"""按状态列表查询订阅,兼容显式会话和旧插件无会话调用。"""
if not isinstance(db, Session):
@@ -269,9 +274,10 @@ class Subscribe(Base):
if state:
statement = statement.where(cls.state.in_(state.split(',')))
return list(session.execute(statement).scalars().all())
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
async def async_get_by_state(
cls, db: AsyncSession | str | None = None, state: str | None = None
):
@@ -285,9 +291,10 @@ class Subscribe(Base):
statement = statement.where(cls.state.in_(state.split(',')))
result = await session.execute(statement)
return list(result.scalars().all())
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
@classmethod
@legacy_db_query
def get_by_title(
cls, db: Session | str | None = None, title: str | None = None,
season: Optional[int] = None,
@@ -301,9 +308,10 @@ class Subscribe(Base):
if season is not None:
statement = statement.where(cls.season == season)
return session.execute(statement).scalars().first()
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
async def async_get_by_title(
cls, db: AsyncSession | str | None = None, title: str | None = None,
season: Optional[int] = None,
@@ -318,9 +326,10 @@ class Subscribe(Base):
statement = statement.where(cls.season == season)
result = await session.execute(statement)
return result.scalars().first()
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
@classmethod
@legacy_async_db_query
async def async_list_by_title(
cls, db: AsyncSession | str | None = None, title: str | None = None,
season: Optional[int] = None,
@@ -335,9 +344,10 @@ class Subscribe(Base):
statement = statement.where(cls.season == season)
result = await session.execute(statement)
return list(result.scalars().all())
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
@classmethod
@legacy_db_query
def list_by_media_identity(
cls, db: Session | MediaSource | None = None,
media_source: MediaSource | str | None = None,
@@ -357,9 +367,10 @@ class Subscribe(Base):
def query(session: Session):
"""在给定会话中执行媒体身份列表查询。"""
return list(session.execute(select(cls).where(condition)).scalars().all())
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
async def async_list_by_media_identity(
cls, db: AsyncSession | MediaSource | None = None,
media_source: MediaSource | str | None = None,
@@ -380,9 +391,10 @@ class Subscribe(Base):
"""在给定异步会话中执行媒体身份列表查询。"""
result = await session.execute(select(cls).where(condition))
return list(result.scalars().all())
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
@classmethod
@legacy_db_query
def get_by(
cls, db: Session | str | None = None,
type: str | MediaSource | None = None,
@@ -407,9 +419,10 @@ class Subscribe(Base):
def query(session: Session):
"""在给定会话中执行类型媒体查询。"""
return session.execute(statement).scalars().first()
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
async def async_get_by(
cls, db: AsyncSession | str | None = None,
type: str | MediaSource | None = None,
@@ -435,9 +448,10 @@ class Subscribe(Base):
"""在给定异步会话中执行类型媒体查询。"""
result = await session.execute(query)
return result.scalars().first()
return await execute_query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(execute_query)
return await execute_query(db)
@classmethod
@legacy_db_query
def list_by_username(cls, db: Session | str | None = None, username: str | None = None,
state: Optional[str] = None, mtype: Optional[str] = None):
"""按用户筛选订阅,兼容显式会话和旧插件无会话调用。"""
@@ -451,9 +465,10 @@ class Subscribe(Base):
if mtype:
statement = statement.where(cls.type == mtype)
return list(session.execute(statement).scalars().all())
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
async def async_list_by_username(cls, db: AsyncSession | str | None = None,
username: str | None = None, state: Optional[str] = None,
mtype: Optional[str] = None):
@@ -469,9 +484,10 @@ class Subscribe(Base):
statement = statement.where(cls.type == mtype)
result = await session.execute(statement)
return list(result.scalars().all())
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
@classmethod
@legacy_db_query
def list_by_type(cls, db: Session | str | None = None, mtype: str | None = None, days: int = 7):
"""按类型查询最近时间窗内的订阅,兼容显式会话和旧插件无会话调用。"""
if not isinstance(db, Session):
@@ -483,9 +499,10 @@ class Subscribe(Base):
cls.date >= time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - 86400 * int(days)))
)).scalars().all())
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
async def async_list_by_type(cls, db: AsyncSession | str | None = None,
mtype: str | None = None, days: int = 7):
"""异步按类型查询最近时间窗内的订阅,兼容显式会话和旧插件无会话调用。"""
@@ -499,4 +516,4 @@ class Subscribe(Base):
time.localtime(time.time() - 86400 * int(days)))
))
return list(result.scalars().all())
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
+8 -5
View File
@@ -11,7 +11,6 @@ from app.db.base import Base, execute_dml, get_id_column
from app.db.decorators import (
legacy_async_db_query,
legacy_db_query,
run_legacy_sync_query,
)
from app.db.models._constraints import media_identity_constraint
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType
@@ -189,6 +188,7 @@ class TransferHistory(Base):
return list(result.scalars().all())
@classmethod
@legacy_db_query
def get_by_hash(
cls,
db: Session | str | None = None,
@@ -206,9 +206,10 @@ class TransferHistory(Base):
select(cls).where(cls.download_hash == download_hash)
).scalars().first()
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_db_query
def get_by_src(
cls, db: Session | str | None = None, src: str | None = None,
storage: Optional[str] = None
@@ -235,9 +236,10 @@ class TransferHistory(Base):
statement.order_by(cls.id.desc())
).scalars().first()
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_db_query
def get_success_by_src(
cls, db: Session | str | None = None, src: str | None = None,
storage: Optional[str] = None
@@ -266,9 +268,10 @@ class TransferHistory(Base):
statement.order_by(cls.id.desc())
).scalars().first()
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_db_query
def get_by_dest(
cls, db: Session | str | None = None, dest: str | None = None,
storage: Optional[str] = None
@@ -295,7 +298,7 @@ class TransferHistory(Base):
statement.order_by(cls.id.desc())
).scalars().first()
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_db_query
+9 -12
View File
@@ -4,10 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, get_id_column
from app.db.decorators import (
run_legacy_async_query,
run_legacy_sync_query,
)
from app.db.decorators import legacy_async_db_query, legacy_db_query
class User(Base):
@@ -38,6 +35,7 @@ class User(Base):
settings: Mapped[Optional[Any]] = mapped_column(JSON, default=dict)
@classmethod
@legacy_db_query
def get_by_name(
cls,
db: Session | str | None = None,
@@ -53,11 +51,10 @@ class User(Base):
"""在给定会话中执行用户名查询。"""
return session.execute(select(cls).where(cls.name == name)).scalars().first()
if isinstance(db, Session):
return query(db)
return run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
async def async_get_by_name(
cls,
db: AsyncSession | str | None = None,
@@ -74,9 +71,10 @@ class User(Base):
result = await session.execute(select(cls).filter(cls.name == name))
return result.scalars().first()
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
@classmethod
@legacy_db_query
def get_by_id(cls, db: Session | int | None = None, user_id: int | None = None):
"""按用户 ID 查询用户,兼容显式会话和旧插件无会话调用。"""
if user_id is None and isinstance(db, int):
@@ -88,11 +86,10 @@ class User(Base):
"""在给定会话中执行用户 ID 查询。"""
return session.execute(select(cls).where(cls.id == user_id)).scalars().first()
if isinstance(db, Session):
return query(db)
return run_legacy_sync_query(query)
return query(db)
@classmethod
@legacy_async_db_query
async def async_get_by_id(
cls,
db: AsyncSession | int | None = None,
@@ -109,7 +106,7 @@ class User(Base):
result = await session.execute(select(cls).filter(cls.id == user_id))
return result.scalars().first()
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
return await query(db)
def delete_by_name(self, db: Session, name: str):
user = self.get_by_name(db, name)