refactor: finish transactional runtime migration

This commit is contained in:
jxxghp
2026-08-22 15:18:16 +08:00
parent fe2e6809f7
commit be18cace1f
59 changed files with 1006 additions and 568 deletions
+16 -1
View File
@@ -1,6 +1,6 @@
"""从 FastAPI AppState 读取类型化宿主能力。"""
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Generator
from typing import cast
from fastapi import Depends, Request
@@ -39,6 +39,21 @@ def get_api_runtime_config(
return runtime.configuration.api()
def get_sync_session(
runtime: HostRuntime = Depends(get_host_runtime),
) -> Generator[object, None, None]:
"""从 HostRuntime 生成请求独占的同步数据库会话。"""
yield from runtime.persistence.sync_session()
async def get_async_session(
runtime: HostRuntime = Depends(get_host_runtime),
) -> AsyncGenerator[object, None]:
"""从 HostRuntime 生成请求独占的异步数据库会话。"""
async for session in runtime.persistence.async_session():
yield session
def resolve_api_runtime_config(value: object) -> ApiRuntimeConfig:
"""兼容直接调用 endpoint 的旧入口,并统一返回真实配置快照。"""
if isinstance(value, ApiRuntimeConfig):
+10 -5
View File
@@ -3,15 +3,19 @@
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.context import get_agent_chat_repository, get_agent_chat_transaction
from app.api.data import get_async_db
from app.api.dependencies.data import repository
from app.api.context import (
get_agent_chat_repository,
get_agent_chat_transaction,
get_async_session,
get_host_runtime,
)
from app.application.messaging.chat import (
AgentChatService,
AsyncAgentChatRepository,
AsyncUnitOfWork,
)
from app.application.messaging.message import MessageQueryService
from app.startup.context import HostRuntime
def get_agent_chat_service(
@@ -23,7 +27,8 @@ def get_agent_chat_service(
def get_message_query_service(
db: AsyncSession = Depends(get_async_db),
db: AsyncSession = Depends(get_async_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> MessageQueryService:
"""组装消息历史异步查询服务。"""
return MessageQueryService(repository=repository("message", db))
return MessageQueryService(repository=runtime.messaging.repository(db))
+53 -22
View File
@@ -1,58 +1,89 @@
"""用户身份、授权与认证服务依赖。"""
from typing import Any
from typing import Any, cast
from fastapi import Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.adapters.web.security.access import verify_token
from app.api.data import get_async_db, get_db
from app.api.dependencies.data import repository, standalone_repository
from app.application.security.auth import AuthService
from app.application.security.passkeys import PasskeyService
from app.application.security.user import UserService
from app.api.context import get_async_session, get_host_runtime, get_sync_session
from app.application.security.auth import (
AuthConfigRepository,
AuthPasskeyRepository,
AuthService,
AuthUserRepository,
)
from app.application.security.passkeys import PasskeyRepository, PasskeyService
from app.application.security.user import (
AsyncUnitOfWork,
UserRepository,
UserService,
)
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.startup.context import HostRuntime
def get_user_service(
db: AsyncSession = Depends(get_async_db),
db: AsyncSession = Depends(get_async_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> UserService:
"""组装用户管理应用服务。"""
return UserService(repository=repository("user", db))
def get_auth_service() -> AuthService:
"""组装同步认证应用服务。"""
return AuthService(
users=standalone_repository("user"),
config=standalone_repository("system_config"),
passkeys=standalone_repository("passkey"),
return UserService(
repository=cast(
UserRepository, runtime.authentication.user_repository(db)
),
unit_of_work=cast(
AsyncUnitOfWork, runtime.persistence.async_transaction(db)
),
)
def get_passkey_service() -> PasskeyService:
def get_auth_service(
runtime: HostRuntime = Depends(get_host_runtime),
) -> AuthService:
"""组装同步认证应用服务。"""
return AuthService(
users=cast(AuthUserRepository, runtime.authentication.standalone_user()),
config=cast(AuthConfigRepository, runtime.authentication.system_config()),
passkeys=cast(AuthPasskeyRepository, runtime.authentication.passkey()),
)
def get_passkey_service(
runtime: HostRuntime = Depends(get_host_runtime),
) -> PasskeyService:
"""组装 PassKey 应用服务。"""
return PasskeyService(repository=standalone_repository("passkey"))
return PasskeyService(repository=cast(
PasskeyRepository, runtime.authentication.passkey()
))
def get_current_user(
db: Session = Depends(get_db),
db: Session = Depends(get_sync_session),
token_data: _SchemaTokenPayload = Depends(verify_token),
runtime: HostRuntime = Depends(get_host_runtime),
) -> Any:
"""读取令牌对应用户,不存在时返回 403。"""
user = repository("user", db).get_by_id(token_data.sub)
user_repository = cast(
AuthUserRepository, runtime.authentication.user_repository(db)
)
user = user_repository.get_by_id(token_data.sub)
if not user:
raise HTTPException(status_code=403, detail="用户不存在")
return user
async def get_current_user_async(
db: AsyncSession = Depends(get_async_db),
db: AsyncSession = Depends(get_async_session),
token_data: _SchemaTokenPayload = Depends(verify_token),
runtime: HostRuntime = Depends(get_host_runtime),
) -> Any:
"""异步读取令牌对应用户,不存在时返回 403。"""
user = await repository("user", db).async_get_by_id(token_data.sub)
user_repository = cast(
UserRepository, runtime.authentication.user_repository(db)
)
user = await user_repository.async_get_by_id(token_data.sub)
if not user:
raise HTTPException(status_code=403, detail="用户不存在")
return user
+28 -18
View File
@@ -4,8 +4,7 @@ from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.api.data import get_async_db, get_db
from app.api.dependencies.data import repository, transaction
from app.api.context import get_async_session, get_host_runtime, get_sync_session
from app.application.dashboard import DashboardQueryService
from app.application.history import (
DownloadHistoryMutationCommand,
@@ -19,63 +18,74 @@ from app.chain.storage import StorageChain
from app.runtime.events import eventmanager
from app.schemas.types import EventType
from app.schemas.workflow import FileItem as _SchemaFileItem
from app.startup.context import HostRuntime
def get_mediaserver_query_service(
db: AsyncSession = Depends(get_async_db),
db: AsyncSession = Depends(get_async_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> MediaServerQueryService:
"""组装媒体服务器本地条目异步查询服务。"""
return MediaServerQueryService(repository=repository("media_server", db))
return MediaServerQueryService(
repository=runtime.history.media_server_repository(db)
)
def get_dashboard_query_service(
db: Session = Depends(get_db),
db: Session = Depends(get_sync_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> DashboardQueryService:
"""组装 Dashboard 媒体与整理历史统计查询服务。"""
from app.chain.dashboard import DashboardChain
return DashboardQueryService(
repository=repository("transfer_history", db),
repository=runtime.history.transfer_repository(db),
media_statistics=DashboardChain().media_statistic,
)
def get_download_history_mutation_command(
db: Session = Depends(get_db),
db: Session = Depends(get_sync_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> DownloadHistoryMutationCommand:
"""组装下载历史删除用例及其请求级事务。"""
return DownloadHistoryMutationCommand(
repository=repository("download_history", db),
unit_of_work=transaction("sync", db),
repository=runtime.history.download_repository(db),
unit_of_work=runtime.persistence.sync_transaction(db),
)
def get_history_query_service(
db: AsyncSession = Depends(get_async_db),
db: AsyncSession = Depends(get_async_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> HistoryQueryService:
"""组装历史列表和详情异步查询服务。"""
return HistoryQueryService(
download_repository=repository("download_history", db),
transfer_repository=repository("transfer_history", db),
download_repository=runtime.history.download_repository(db),
transfer_repository=runtime.history.transfer_repository(db),
)
def get_transfer_history_lookup_service(
db: Session = Depends(get_db),
db: Session = Depends(get_sync_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> TransferHistoryLookupService:
"""组装手动整理使用的同步历史投影服务。"""
return TransferHistoryLookupService(repository("transfer_history", db))
return TransferHistoryLookupService(
runtime.history.transfer_repository(db)
)
def get_transfer_history_mutation_command(
db: Session = Depends(get_db),
db: Session = Depends(get_sync_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> TransferHistoryMutationCommand:
"""组装整理历史删除、文件处理和事件发布用例。"""
storage_chain = StorageChain()
return TransferHistoryMutationCommand(
repository=repository("transfer_history", db),
download_repository=repository("download_history", db),
unit_of_work=transaction("sync", db),
repository=runtime.history.transfer_repository(db),
download_repository=runtime.history.download_repository(db),
unit_of_work=runtime.persistence.sync_transaction(db),
file_item_factory=lambda payload: _SchemaFileItem(**payload),
delete_media_file=storage_chain.delete_media_file,
publish_download_file_deleted=lambda payload: eventmanager.send_event(
+16 -11
View File
@@ -1,11 +1,12 @@
"""站点领域的请求级 command/query 依赖。"""
from typing import Any
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.api.data import get_async_db, get_db
from app.api.dependencies.data import repository, transaction
from app.api.context import get_async_session, get_host_runtime, get_sync_session
from app.application.site.mutation import SiteMutationCommand
from app.application.site.query import SiteQueryService
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
@@ -13,20 +14,22 @@ from app.domain import site as site_rules
from app.foundation import url as url_tools
from app.runtime.events import eventmanager
from app.schemas.types import EventType
from app.startup.context import HostRuntime
async def _publish_site_updated(payload: dict) -> None:
async def _publish_site_updated(payload: dict[str, Any]) -> None:
"""发布已提交的站点更新事件。"""
await eventmanager.async_send_event(EventType.SiteUpdated, payload)
async def _publish_site_deleted(payload: dict) -> None:
async def _publish_site_deleted(payload: dict[str, Any]) -> None:
"""发布已提交的站点删除事件。"""
await eventmanager.async_send_event(EventType.SiteDeleted, payload)
def get_site_mutation_command(
db: AsyncSession = Depends(get_async_db),
db: AsyncSession = Depends(get_async_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> SiteMutationCommand:
"""组装请求级站点写用例及其事务和外部目录依赖。"""
sites_helper = SitesHelper()
@@ -37,8 +40,8 @@ def get_site_mutation_command(
return f"{scheme}://{netloc}/"
return SiteMutationCommand(
repository=repository("site", db),
unit_of_work=transaction("async", db),
repository=runtime.site.repository(db),
unit_of_work=runtime.persistence.async_transaction(db),
auth_level_provider=lambda: sites_helper.auth_level,
indexer_loader=sites_helper.async_get_indexer,
domain_extractor=site_rules.extract_domain,
@@ -49,14 +52,16 @@ def get_site_mutation_command(
def get_site_query_service(
db: AsyncSession = Depends(get_async_db),
db: AsyncSession = Depends(get_async_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> SiteQueryService:
"""组装站点异步查询服务。"""
return SiteQueryService(repository=repository("site", db))
return SiteQueryService(repository=runtime.site.repository(db))
def get_site_sync_query_service(
db: Session = Depends(get_db),
db: Session = Depends(get_sync_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> SiteQueryService:
"""组装站点同步查询服务,用于同步 Chain 路由。"""
return SiteQueryService(repository=repository("site", db))
return SiteQueryService(repository=runtime.site.repository(db))
+25 -14
View File
@@ -8,13 +8,14 @@ from sqlalchemy.orm import Session
from app.adapters.external.server import MoviePilotServerHelper
from app.api.context import (
get_async_session,
get_host_runtime,
get_subscription_history_repository,
get_subscription_outbox,
get_subscription_repository,
get_subscription_transaction,
get_sync_session,
)
from app.api.data import get_async_db, get_db
from app.api.dependencies.data import repository
from app.application.outbox import AsyncOutboxTransaction
from app.application.scheduling import start_scheduler_job
from app.application.servarr import ServarrSubscriptionService
@@ -36,6 +37,7 @@ from app.application.subscription.search import SearchSubscriptionsCommand
from app.runtime.events import eventmanager
from app.runtime.log import logger
from app.schemas.types import EventType
from app.startup.context import HostRuntime
async def _publish_subscribe_deleted(
@@ -93,7 +95,8 @@ def get_delete_subscriptions_by_identity_command(
def get_search_subscriptions_command(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_async_db),
db: AsyncSession = Depends(get_async_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> SearchSubscriptionsCommand:
"""组装手工订阅搜索用例,并把调度延迟到响应后的后台任务。"""
def schedule_search(subscribe_id: int | None, state: str | None) -> None:
@@ -107,19 +110,20 @@ def get_search_subscriptions_command(
)
return SearchSubscriptionsCommand(
repository=repository("subscribe", db),
repository=runtime.subscription.repository(db),
schedule_search=schedule_search,
)
def get_subscription_query_service(
db: AsyncSession = Depends(get_async_db),
db: AsyncSession = Depends(get_async_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> SubscriptionQueryService:
"""组装订阅和订阅历史异步查询服务。"""
return SubscriptionQueryService(
repository=repository("subscribe", db),
async_repository=repository("subscribe", db),
history_repository=repository("subscribe_history", db),
repository=runtime.subscription.repository(db),
async_repository=runtime.subscription.repository(db),
history_repository=runtime.subscription.history_repository(db),
)
@@ -142,18 +146,25 @@ def get_subscription_mutation_service(
def get_subscription_sync_mutation_service(
db: Session = Depends(get_db),
db: Session = Depends(get_sync_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> SubscriptionMutationService:
"""组装同步订阅查询服务,供文件信息接口使用。"""
return SubscriptionMutationService(repository=repository("subscribe", db))
return SubscriptionMutationService(
repository=cast(
SubscriptionMutationRepository,
runtime.subscription.repository(db),
)
)
def get_servarr_subscription_service(
async_db: AsyncSession = Depends(get_async_db),
db: Session = Depends(get_db),
async_db: AsyncSession = Depends(get_async_session),
db: Session = Depends(get_sync_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> ServarrSubscriptionService:
"""组装 Servarr 兼容路由的请求级订阅数据用例。"""
return ServarrSubscriptionService(
async_repository=repository("subscribe", async_db),
sync_repository=repository("subscribe", db),
async_repository=runtime.subscription.repository(async_db),
sync_repository=runtime.subscription.repository(db),
)
+19 -14
View File
@@ -1,12 +1,13 @@
"""工作流领域的请求级 command/query 依赖。"""
from typing import Any, cast
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.adapters.external.server import MoviePilotServerHelper
from app.api.data import get_async_db, get_db
from app.api.dependencies.data import repository, standalone_repository, transaction
from app.api.context import get_async_session, get_host_runtime, get_sync_session
from app.application.scheduling import Scheduler
from app.application.workflow import (
WorkflowDefinitionCommand,
@@ -15,46 +16,50 @@ from app.application.workflow import (
)
from app.runtime.config import global_vars
from app.workflow import WorkFlowManager
from app.startup.context import HostRuntime
def get_workflow_mutation_command(
db: Session = Depends(get_db),
db: Session = Depends(get_sync_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> WorkflowMutationCommand:
"""组装请求级工作流写用例和提交后的调度副作用。"""
scheduler = Scheduler()
workflow_manager = WorkFlowManager()
return WorkflowMutationCommand(
repository=repository("workflow", db),
unit_of_work=transaction("sync", db),
repository=runtime.workflow.repository(db),
unit_of_work=runtime.persistence.sync_transaction(db),
add_timer=scheduler.update_workflow_job,
remove_timer=scheduler.remove_workflow_job,
load_event=workflow_manager.load_workflow_events,
remove_event=workflow_manager.remove_workflow_event,
refresh_event=workflow_manager.update_workflow_event,
stop_running=global_vars.stop_workflow,
delete_cache=lambda workflow_id: standalone_repository(
"system_config"
delete_cache=lambda workflow_id: cast(
Any, runtime.workflow.system_config()
).delete(f"WorkflowCache-{workflow_id}"),
)
def get_workflow_definition_command(
db: AsyncSession = Depends(get_async_db),
db: AsyncSession = Depends(get_async_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> WorkflowDefinitionCommand:
"""组装工作流创建、复用和重置的异步写用例。"""
return WorkflowDefinitionCommand(
repository=repository("workflow", db),
unit_of_work=transaction("async", db),
repository=runtime.workflow.repository(db),
unit_of_work=runtime.persistence.async_transaction(db),
stop_running=global_vars.stop_workflow,
delete_cache=lambda workflow_id: standalone_repository(
"system_config"
delete_cache=lambda workflow_id: cast(
Any, runtime.workflow.system_config()
).delete(f"WorkflowCache-{workflow_id}"),
report_fork=MoviePilotServerHelper.async_workflow_fork_by_id,
)
def get_workflow_query_service(
db: AsyncSession = Depends(get_async_db),
db: AsyncSession = Depends(get_async_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> WorkflowQueryService:
"""组装工作流只读查询用例,避免端点直接持有数据库操作器。"""
return WorkflowQueryService(repository=repository("workflow", db))
return WorkflowQueryService(repository=runtime.workflow.repository(db))
+22 -1
View File
@@ -45,6 +45,10 @@ class CleanupRepository(Protocol):
"""返回一次维护运行共用的数据库会话上下文。"""
...
def unit_of_work(self, db: Any) -> "CleanupUnitOfWork":
"""返回绑定到当前维护会话的事务边界。"""
...
def delete_messages(self, db: Any, cutoff: str, limit: int) -> int:
"""删除早于截止时间的消息。"""
...
@@ -70,6 +74,18 @@ class CleanupRepository(Protocol):
...
class CleanupUnitOfWork(Protocol):
"""数据维护每一批删除所需的最小事务能力。"""
def commit(self) -> None:
"""提交当前批次。"""
...
def rollback(self) -> None:
"""回滚失败批次并恢复会话可用状态。"""
...
class DataCleanupService:
"""按配置执行分批数据清理并生成兼容报告。"""
@@ -175,16 +191,19 @@ class DataCleanupService:
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] = {
@@ -279,12 +298,13 @@ class DataCleanupService:
),
]
@staticmethod
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
@@ -293,6 +313,7 @@ class DataCleanupService:
deleted = delete_batch(db) or 0
if deleted <= 0:
break
unit_of_work.commit()
batches += 1
total_deleted += deleted
logger.info(
+4 -1
View File
@@ -146,14 +146,16 @@ class OutboxDispatcher:
lease_seconds: int = 60,
clock: Callable[[], datetime] | None = None,
close: Callable[[], None] | None = None,
failure_observer: Callable[[bool], None] | None = None,
) -> None:
"""注入持久端口、topic handler有界重试策略。"""
"""注入持久端口、topic handler有界重试策略与失败观测端口"""
self._repository = repository
self._handlers = handlers
self._max_attempts = max_attempts
self._lease_seconds = lease_seconds
self._clock = clock or (lambda: datetime.now(timezone.utc))
self._close = close or (lambda: None)
self._failure_observer = failure_observer or (lambda _dead: None)
def dispatch_one(self) -> bool:
"""处理一条到期消息;无消息返回 False,handler 失败留待重试。"""
@@ -176,6 +178,7 @@ class OutboxDispatcher:
last_error=str(error)[:4000],
dead=dead,
)
self._failure_observer(dead)
return True
self._repository.complete(message.message_id, now)
return True
+38 -7
View File
@@ -4,7 +4,7 @@
避免 API 层同时承担 HTTP 编排和 ORM 适配职责。
"""
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from typing import Any, Protocol
@@ -33,12 +33,27 @@ class UserRepository(Protocol):
"""更新用户 OTP 状态。"""
class AsyncUnitOfWork(Protocol):
"""用户写用例所需的异步事务边界。"""
async def commit(self) -> None:
"""提交用户写入。"""
async def rollback(self) -> None:
"""回滚失败的用户写入。"""
class UserService:
"""用户管理应用服务。"""
def __init__(self, repository: UserRepository) -> None:
"""创建用户服务。"""
def __init__(
self,
repository: UserRepository,
unit_of_work: AsyncUnitOfWork | None = None,
) -> None:
"""创建用户服务;旧独立仓储可暂不提供请求级 UoW。"""
self._repository = repository
self._unit_of_work = unit_of_work
async def list(self) -> list[Any]:
"""返回用户列表。"""
@@ -54,19 +69,35 @@ class UserService:
async def create(self, payload: dict[str, Any]) -> Any | None:
"""创建用户。"""
return await self._repository.async_create(payload)
return await self._write(lambda: self._repository.async_create(payload))
async def update(self, user_id: int, payload: dict[str, Any]) -> Any | None:
"""更新用户。"""
return await self._repository.async_update(user_id, payload)
return await self._write(
lambda: self._repository.async_update(user_id, payload)
)
async def delete(self, user_id: int) -> None:
"""删除用户。"""
await self._repository.async_delete(user_id)
await self._write(lambda: self._repository.async_delete(user_id))
async def update_otp(self, name: str, otp: bool, secret: str) -> None:
"""更新用户 OTP 状态。"""
await self._repository.async_update_otp_by_name(name, otp, secret)
await self._write(
lambda: self._repository.async_update_otp_by_name(name, otp, secret)
)
async def _write(self, operation: Callable[[], Awaitable[Any]]) -> Any:
"""执行用户写入,并在正式请求路径统一提交或回滚。"""
try:
result = await operation()
if self._unit_of_work is not None:
await self._unit_of_work.commit()
return result
except Exception:
if self._unit_of_work is not None:
await self._unit_of_work.rollback()
raise
_configured_user_id_lookup: Callable[[int], Any | None] | None = None
+26 -1
View File
@@ -4,7 +4,8 @@ ORM 基类与数据访问基类。
Base 提供声明式基类与通用的行为(字典转换、增删改查便利方法);
DbOper 是各业务 Oper 的基类,持有一个可注入的会话。
"""
from typing import Any, List, Optional, Self, Union, cast
from collections.abc import Awaitable, Callable
from typing import Any, List, Optional, Self, TypeVar, Union, cast
from sqlalchemy import (CursorResult, Executable, Identity, Integer, Sequence,
and_, delete, inspect, select)
@@ -13,6 +14,10 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, Session, declared_attr, mapp
from app.runtime.config import settings
from app.db.decorators import async_db_query, async_db_update, db_query, db_update
from app.db.uow import run_async_transaction, run_sync_transaction
T = TypeVar("T")
def execute_dml(db: Session, statement: Executable,
@@ -147,4 +152,24 @@ class DbOper:
"""
def __init__(self, db: Optional[Union[Session, AsyncSession]] = None):
"""保存调用方会话;无会话写入由组合根兼容事务执行器承接。"""
self._db = db
def _execute_sync_write(self, operation: Callable[[Session], T]) -> T:
"""在当前同步会话暂存,或委托组合根创建兼容事务。"""
if self._db is None:
return run_sync_transaction(operation)
if not isinstance(self._db, Session):
raise TypeError("同步写操作不能使用 AsyncSession")
return operation(self._db)
async def _execute_async_write(
self,
operation: Callable[[AsyncSession], Awaitable[T]],
) -> T:
"""在当前异步会话暂存,或委托组合根创建兼容事务。"""
if self._db is None:
return await run_async_transaction(operation)
if not isinstance(self._db, AsyncSession):
raise TypeError("异步写操作不能使用同步 Session")
return await operation(self._db)
+6
View File
@@ -7,6 +7,7 @@ from app.db.models.downloadhistory import DownloadFiles, DownloadHistory
from app.db.models.message import Message
from app.db.models.siteuserdata import SiteUserData
from app.db.models.transferhistory import TransferHistory
from app.db.uow import SqlAlchemyUnitOfWork
class DatabaseCleanupRepository:
@@ -20,6 +21,11 @@ class DatabaseCleanupRepository:
"""创建一次维护运行共用的数据库会话。"""
return self._session_factory()
@staticmethod
def unit_of_work(db: Any) -> SqlAlchemyUnitOfWork:
"""把当前维护 Session 适配成显式批次事务边界。"""
return SqlAlchemyUnitOfWork(db)
@staticmethod
def delete_messages(db: Any, cutoff: str, limit: int) -> int:
"""删除早于截止时间的消息。"""
+1 -3
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 db_query, db_update
from app.db.decorators import db_query
class AgentTask(Base):
@@ -49,7 +49,6 @@ class AgentTask(Base):
)
@classmethod
@db_update
def add_task(cls, db: Session, **kwargs: object) -> int:
"""
新增 Agent 定时任务并返回任务 ID。
@@ -96,7 +95,6 @@ class AgentTask(Base):
).scalars().all())
@classmethod
@db_update
def update_task(
cls,
db: Session,
+1 -5
View File
@@ -4,7 +4,7 @@ from sqlalchemy import Index, Integer, String, Text, delete, 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 db_query, db_update
from app.db.decorators import db_query
from app.db.models.agenttask import AgentTask
@@ -41,7 +41,6 @@ class AgentTaskRun(Base):
)
@classmethod
@db_update
def begin_run(
cls,
db: Session,
@@ -107,7 +106,6 @@ class AgentTaskRun(Base):
return run_id
@classmethod
@db_update
def finish_run(
cls,
db: Session,
@@ -174,7 +172,6 @@ class AgentTaskRun(Base):
return True
@classmethod
@db_update
def interrupt_task(
cls,
db: Session,
@@ -226,7 +223,6 @@ class AgentTaskRun(Base):
))
@classmethod
@db_update
def delete_task_and_runs(
cls,
db: Session,
-2
View File
@@ -4,7 +4,6 @@ from sqlalchemy import Float, Index, Integer, String, delete, select
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, execute_dml, get_id_column
from app.db.decorators import db_update
from app.db.models._constraints import media_identity_constraint
@@ -115,7 +114,6 @@ class DownloadFailure(Base):
return failure
@classmethod
@db_update
def delete_expired(
cls,
db: Session,
+1 -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 Base, execute_dml, get_id_column
from app.db.decorators import async_db_query, db_query, db_update
from app.db.decorators import async_db_query, db_query
from app.db.models._constraints import media_identity_constraint
from app.schemas.types import MediaSource
@@ -295,7 +295,6 @@ class DownloadHistory(Base):
).scalars().all())
@classmethod
@db_update
def delete_before(
cls,
db: Session,
@@ -367,14 +366,12 @@ class DownloadFiles(Base):
return list(db.execute(select(cls).where(cls.savepath == savepath)).scalars().all())
@classmethod
@db_update
def delete_by_fullpath(cls, db: Session, fullpath: str):
db.execute(
update(cls).where(cls.fullpath == fullpath, cls.state == 1).values(state=0)
)
@classmethod
@db_update
def delete_orphans(
cls,
db: Session,
+4 -4
View File
@@ -7,7 +7,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 async_db_query, db_query, db_update
from app.db.decorators import async_db_query, db_query
from app.db.models._constraints import media_identity_constraint
from app.schemas.types import MediaSource
@@ -65,16 +65,16 @@ class MediaServerItem(Base):
).scalars().first()
@classmethod
@db_update
def empty(cls, db: Session, server: Optional[str] = None):
"""在调用方事务中暂存媒体服务器条目清空操作。"""
statement = delete(cls)
if server is not None:
statement = statement.where(cls.server == server)
db.execute(statement, execution_options={"synchronize_session": False})
@classmethod
@db_update
def delete_stale(cls, db: Session, server: str, sync_time: str):
"""在调用方事务中删除本轮同步未更新的条目。"""
return execute_dml(
db,
delete(cls).where(
@@ -85,8 +85,8 @@ class MediaServerItem(Base):
)
@classmethod
@db_update
def delete_excluded_servers(cls, db: Session, servers: List[str]):
"""在调用方事务中删除不属于启用服务器的条目。"""
statement = delete(cls)
if servers:
statement = statement.where(
+1 -3
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 async_db_query, db_query, db_update
from app.db.decorators import async_db_query, db_query
class Message(Base):
@@ -40,7 +40,6 @@ class Message(Base):
Index('ix_message_reg_time_id', 'reg_time', 'id'),
)
@db_update
def create_and_to_dict(self, db: Session) -> dict:
"""
创建消息记录并返回写入后的字段字典。
@@ -134,7 +133,6 @@ class Message(Base):
return list(result.scalars().all())
@classmethod
@db_update
def delete_before(
cls,
db: Session,
+12 -16
View File
@@ -1,11 +1,11 @@
from typing import Optional
from sqlalchemy import Integer, String, Boolean, DateTime, Text, select, ForeignKey
from sqlalchemy import Integer, String, Boolean, DateTime, Text, select, ForeignKey, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column
from datetime import datetime
from app.db.base import Base, get_id_column
from app.db.decorators import db_query, db_update, async_db_query, async_db_update
from app.db.decorators import db_query, async_db_query
class PassKey(Base):
@@ -85,19 +85,17 @@ class PassKey(Base):
return result.scalars().first()
@classmethod
@db_update
def delete_by_id(cls, db: Session, passkey_id: int, user_id: int):
"""删除指定用户的PassKey"""
passkey = db.execute(
select(cls).where(cls.id == passkey_id, cls.user_id == user_id)
).scalars().first()
if passkey:
passkey.delete(db, passkey.id)
db.delete(passkey)
return True
return False
@classmethod
@async_db_update
async def async_delete_by_id(cls, db: AsyncSession, passkey_id: int, user_id: int):
"""异步删除指定用户的PassKey"""
result = await db.execute(
@@ -108,24 +106,22 @@ class PassKey(Base):
)
passkey = result.scalars().first()
if passkey:
await passkey.async_delete(db, passkey.id)
await db.delete(passkey)
return True
return False
@db_update
def update_last_used(self, db: Session, sign_count: int):
"""更新最后使用时间和签名计数"""
self.update(db, {
'last_used_at': datetime.now(),
'sign_count': sign_count
})
db.execute(update(type(self)).where(type(self).id == self.id).values(
last_used_at=datetime.now(),
sign_count=sign_count,
))
return True
@async_db_update
async def async_update_last_used(self, db: AsyncSession, sign_count: int):
"""异步更新最后使用时间和签名计数"""
await self.async_update(db, {
'last_used_at': datetime.now(),
'sign_count': sign_count
})
await db.execute(update(type(self)).where(type(self).id == self.id).values(
last_used_at=datetime.now(),
sign_count=sign_count,
))
return True
+3 -3
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 db_query, db_update, async_db_query
from app.db.decorators import db_query, async_db_query
class PluginData(Base):
@@ -49,13 +49,13 @@ class PluginData(Base):
return result.scalar_one_or_none()
@classmethod
@db_update
def del_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str):
"""在调用方事务中暂存单个插件键删除。"""
db.execute(delete(cls).where(cls.plugin_id == plugin_id, cls.key == key))
@classmethod
@db_update
def del_plugin_data(cls, db: Session, plugin_id: str):
"""在调用方事务中暂存插件全部数据删除。"""
db.execute(delete(cls).where(cls.plugin_id == plugin_id))
@classmethod
+3 -3
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 db_query, db_update, async_db_query, async_db_update
from app.db.decorators import db_query, async_db_query
class Site(Base):
@@ -102,11 +102,11 @@ class Site(Base):
return list(db.execute(select(cls.domain).where(cls.id.in_(ids))).scalars().all())
@classmethod
@db_update
def reset(cls, db: Session):
"""在调用方持有的同步事务中暂存清空操作。"""
db.execute(delete(cls))
@classmethod
@async_db_update
async def async_reset(cls, db: AsyncSession):
"""在调用方持有的异步事务中暂存清空操作。"""
await db.execute(delete(cls))
+2 -2
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 db_query, db_update, async_db_query
from app.db.decorators import db_query, async_db_query
class SiteStatistic(Base):
@@ -41,6 +41,6 @@ class SiteStatistic(Base):
return result.scalar_one_or_none()
@classmethod
@db_update
def reset(cls, db: Session):
"""在调用方持有的事务中暂存统计表清空操作。"""
db.execute(delete(cls))
+1 -2
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, execute_dml, get_id_column
from app.db.decorators import async_db_query, db_query, db_update
from app.db.decorators import async_db_query, db_query
class SiteUserData(Base):
@@ -138,7 +138,6 @@ class SiteUserData(Base):
return list(result.scalars().all())
@classmethod
@db_update
def delete_before(
cls,
db: Session,
+3 -3
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 db_query, db_update, async_db_query
from app.db.decorators import db_query, async_db_query
class SystemConfig(Base):
@@ -28,9 +28,9 @@ class SystemConfig(Base):
result = await db.execute(select(cls).where(cls.key == key))
return result.scalar_one_or_none()
@db_update
def delete_by_key(self, db: Session, key: str):
"""在调用方持有的事务中暂存指定配置删除。"""
systemconfig = self.get_by_key(db, key)
if systemconfig:
systemconfig.delete(db, systemconfig.id)
db.delete(systemconfig)
return True
+2 -4
View File
@@ -8,7 +8,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 async_db_query, db_query, db_update
from app.db.decorators import async_db_query, db_query
from app.db.models._constraints import media_identity_constraint
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType
@@ -555,14 +555,13 @@ class TransferHistory(Base):
)).scalars().first()
@classmethod
@db_update
def update_download_hash(cls, db: Session, historyid: Optional[int] = None, download_hash: Optional[str] = None):
"""在调用方事务中暂存下载任务哈希更新。"""
db.execute(
update(cls).where(cls.id == historyid).values(download_hash=download_hash)
)
@classmethod
@db_update
def replace_by_src(cls, db: Session, **kwargs) -> "TransferHistory":
"""
用同源存储的新记录原子替换旧整理历史。
@@ -600,7 +599,6 @@ class TransferHistory(Base):
).scalars().all())
@classmethod
@db_update
def delete_before(
cls,
db: Session,
+1 -4
View File
@@ -4,7 +4,7 @@ from sqlalchemy import Index, String, delete, select
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, execute_dml, get_id_column
from app.db.decorators import db_query, db_update
from app.db.decorators import db_query
class TransferPending(Base):
@@ -35,7 +35,6 @@ class TransferPending(Base):
)
@classmethod
@db_update
def register(cls, db: Session, storage: str, src_path: str,
now_time: str) -> Optional["TransferPending"]:
"""
@@ -58,7 +57,6 @@ class TransferPending(Base):
return pending
@classmethod
@db_update
def discard(cls, db: Session, storage: str, src_path: str) -> int:
"""
注销一个待整理文件登记,整理到达终态(成功或失败)时调用。
@@ -93,7 +91,6 @@ class TransferPending(Base):
).scalars().all())
@classmethod
@db_update
def clear(cls, db: Session) -> int:
"""
清空全部待整理登记。
+9 -19
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 db_query, db_update, async_db_query, async_db_update
from app.db.decorators import db_query, async_db_query
class User(Base):
@@ -60,56 +60,46 @@ class User(Base):
)
return result.scalars().first()
@db_update
def delete_by_name(self, db: Session, name: str):
user = self.get_by_name(db, name)
if user:
user.delete(db, user.id)
db.delete(user)
return True
@async_db_update
async def async_delete_by_name(self, db: AsyncSession, name: str):
user = await self.async_get_by_name(db, name)
if user:
await user.async_delete(db, user.id)
await db.delete(user)
return True
@db_update
def delete_by_id(self, db: Session, user_id: int):
user = self.get_by_id(db, user_id)
if user:
user.delete(db, user.id)
db.delete(user)
return True
@classmethod
@async_db_update
async def async_delete_by_id(cls, db: AsyncSession, user_id: int):
"""异步按用户 ID 删除用户,供 UserOper 通过类方法调用。"""
user = await cls.async_get_by_id(db, user_id)
if user:
await user.async_delete(db, user.id)
await db.delete(user)
return True
@db_update
def update_otp_by_name(self, db: Session, name: str, otp: bool, secret: str):
user = self.get_by_name(db, name)
if user:
user.update(db, {
'is_otp': otp,
'otp_secret': secret
})
user.is_otp = otp
user.otp_secret = secret
return True
return False
@classmethod
@async_db_update
async def async_update_otp_by_name(cls, db: AsyncSession, name: str, otp: bool, secret: str):
"""异步按用户名更新 OTP 状态,供 UserOper 通过类方法调用。"""
user = await cls.async_get_by_name(db, name)
if user:
await user.async_update(db, {
'is_otp': otp,
'otp_secret': secret
})
user.is_otp = otp
user.otp_secret = secret
return True
return False
+3 -3
View File
@@ -3,7 +3,7 @@ from sqlalchemy import String, UniqueConstraint, JSON, select
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import get_id_column, Base
from app.db.decorators import db_query, db_update
from app.db.decorators import db_query
class UserConfig(Base):
@@ -30,9 +30,9 @@ class UserConfig(Base):
select(cls).where(cls.username == username, cls.key == key)
).scalars().first()
@db_update
def delete_by_key(self, db: Session, username: str, key: str):
"""在调用方持有的事务中暂存指定用户配置删除。"""
userconfig = self.get_by_key(db=db, username=username, key=key)
if userconfig:
userconfig.delete(db=db, rid=userconfig.id)
db.delete(userconfig)
return True
+7 -7
View File
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import Base, get_id_column
from app.db.decorators import db_query, async_db_query, async_db_update
from app.db.decorators import db_query, async_db_query
class Workflow(Base):
@@ -135,8 +135,8 @@ class Workflow(Base):
return True
@classmethod
@async_db_update
async def async_update_state(cls, db: AsyncSession, wid: int, state: str):
"""在调用方持有的异步事务中暂存工作流状态。"""
await db.execute(update(cls).where(cls.id == wid).values(state=state))
return True
@@ -146,8 +146,8 @@ class Workflow(Base):
return True
@classmethod
@async_db_update
async def async_start(cls, db: AsyncSession, wid: int):
"""在调用方持有的异步事务中暂存运行中状态。"""
await db.execute(update(cls).where(cls.id == wid).values(state='R'))
return True
@@ -163,8 +163,8 @@ class Workflow(Base):
return True
@classmethod
@async_db_update
async def async_fail(cls, db: AsyncSession, wid: int, result: str):
"""在调用方持有的异步事务中暂存失败结果。"""
await db.execute(update(cls).where(
and_(cls.id == wid, cls.state != "P")
).values(
@@ -187,8 +187,8 @@ class Workflow(Base):
return True
@classmethod
@async_db_update
async def async_success(cls, db: AsyncSession, wid: int, result: Optional[str] = None):
"""在调用方持有的异步事务中暂存成功结果。"""
await db.execute(update(cls).where(
and_(cls.id == wid, cls.state != "P")
).values(
@@ -212,8 +212,8 @@ class Workflow(Base):
return True
@classmethod
@async_db_update
async def async_reset(cls, db: AsyncSession, wid: int, reset_count: Optional[bool] = False):
"""在调用方持有的异步事务中暂存执行状态重置。"""
await db.execute(update(cls).where(cls.id == wid).values(
state='W',
result=None,
@@ -243,9 +243,9 @@ class Workflow(Base):
return True
@classmethod
@async_db_update
async def async_update_current_action(cls, db: AsyncSession, wid: int, action_id: str, context: dict,
execution_state: Optional[dict] = None):
"""在调用方持有的异步事务中暂存动作进度。"""
# 先获取当前current_action
result = await db.execute(select(cls.current_action).where(cls.id == wid))
current_action = result.scalar()
+58 -36
View File
@@ -24,14 +24,16 @@ class AgentTaskOper(DbOper):
新增 Agent 定时任务。
"""
now = self._now()
task_id = AgentTask.add_task(
self._db,
**kwargs,
enabled=True,
last_status="waiting",
run_count=0,
created_at=now,
updated_at=now,
task_id = self._execute_sync_write(
lambda session: AgentTask.add_task(
session,
**kwargs,
enabled=True,
last_status="waiting",
run_count=0,
created_at=now,
updated_at=now,
)
)
return self.get(task_id)
@@ -81,38 +83,50 @@ class AgentTaskOper(DbOper):
if not normalized_payload:
return False
normalized_payload["updated_at"] = self._now()
return AgentTask.update_task(
self._db,
task_id=task_id,
payload=normalized_payload,
user_id=user_id,
return self._execute_sync_write(
lambda session: AgentTask.update_task(
session,
task_id=task_id,
payload=normalized_payload,
user_id=user_id,
)
)
def delete(self, task_id: int, user_id: Optional[str] = None) -> bool:
"""
删除非运行中的 Agent 定时任务及其运行历史。
"""
return AgentTaskRun.delete_task_and_runs(
self._db,
task_id=task_id,
user_id=user_id,
return self._execute_sync_write(
lambda session: AgentTaskRun.delete_task_and_runs(
session,
task_id=task_id,
user_id=user_id,
)
)
def begin_run(
self,
task_id: int,
trigger_source: str = "scheduled",
*,
run_id: Optional[str] = None,
started_at: Optional[str] = None,
) -> Optional[AgentTaskRun]:
"""
原子创建一次运行并返回其任务快照。
可选运行 ID 和开始时间用于恢复/幂等验证;正常调度入口由本方法生成。
"""
run_id = uuid4().hex
created_run_id = AgentTaskRun.begin_run(
self._db,
task_id=task_id,
run_id=run_id,
trigger_source=trigger_source,
started_at=self._now(),
resolved_run_id = run_id or uuid4().hex
resolved_started_at = started_at or self._now()
created_run_id = self._execute_sync_write(
lambda session: AgentTaskRun.begin_run(
session,
task_id=task_id,
run_id=resolved_run_id,
trigger_source=trigger_source,
started_at=resolved_started_at,
)
)
return self.get_run(created_run_id) if created_run_id else None
@@ -124,11 +138,15 @@ class AgentTaskOper(DbOper):
"""
将遗留的运行中任务标记为中断且结果未知。
"""
return AgentTaskRun.interrupt_task(
self._db,
task_id=task_id,
result=(result or "")[:20000],
finished_at=self._now(),
finished_at = self._now()
normalized_result = (result or "")[:20000]
return self._execute_sync_write(
lambda session: AgentTaskRun.interrupt_task(
session,
task_id=task_id,
result=normalized_result,
finished_at=finished_at,
)
)
def get_run(self, run_id: str) -> Optional[AgentTaskRun]:
@@ -157,13 +175,17 @@ class AgentTaskOper(DbOper):
disable_date_task: bool = False,
) -> bool:
"""收口精确运行并更新仍匹配的任务投影。"""
return AgentTaskRun.finish_run(
self._db,
run_id=run_id,
success=success,
result=(result or "")[:20000],
finished_at=self._now(),
disable_date_task=disable_date_task,
finished_at = self._now()
normalized_result = (result or "")[:20000]
return self._execute_sync_write(
lambda session: AgentTaskRun.finish_run(
session,
run_id=run_id,
success=success,
result=normalized_result,
finished_at=finished_at,
disable_date_task=disable_date_task,
)
)
def finish(
+6 -4
View File
@@ -54,8 +54,10 @@ class DownloadFailureOper(DbOper):
"""
删除已过期较久的失败记录
"""
return DownloadFailure.delete_expired(
self._db,
before_time=before_time,
limit=limit,
return self._execute_sync_write(
lambda session: DownloadFailure.delete_expired(
session,
before_time=before_time,
limit=limit,
)
)
+3 -1
View File
@@ -127,7 +127,9 @@ class DownloadHistoryOper(DbOper):
按fullpath删除下载文件记录
:param fullpath: 数据key
"""
DownloadFiles.delete_by_fullpath(self._db, fullpath)
self._execute_sync_write(
lambda session: DownloadFiles.delete_by_fullpath(session, fullpath)
)
def stage_delete_file_by_fullpath(self, fullpath: str) -> None:
"""暂存指定完整路径的下载文件记录删除。"""
+16 -3
View File
@@ -61,19 +61,32 @@ class MediaServerOper(DbOper):
"""
清空媒体服务器数据
"""
MediaServerItem.empty(self._db, server)
self._execute_sync_write(
lambda session: MediaServerItem.empty(session, server)
)
def delete_stale(self, server: str, sync_time: str) -> int:
"""
删除本轮同步未更新的旧数据
"""
return MediaServerItem.delete_stale(self._db, server, sync_time)
return self._execute_sync_write(
lambda session: MediaServerItem.delete_stale(
session,
server,
sync_time,
)
)
def delete_excluded_servers(self, servers: list[str]) -> int:
"""
删除未启用或已移除媒体服务器的数据
"""
return MediaServerItem.delete_excluded_servers(self._db, servers)
return self._execute_sync_write(
lambda session: MediaServerItem.delete_excluded_servers(
session,
servers,
)
)
def exists(self, **kwargs) -> Optional[MediaServerItem]:
"""
+2 -1
View File
@@ -62,7 +62,8 @@ class MessageOper(DbOper):
if k not in Message.__table__.columns.keys(): # noqa
kwargs.pop(k)
return Message(**kwargs).create_and_to_dict(self._db)
message = Message(**kwargs)
return self._execute_sync_write(message.create_and_to_dict)
async def async_add(self,
channel: Optional[NotificationChannel] = None,
+21 -3
View File
@@ -24,13 +24,31 @@ class PassKeyOper(DbOper):
def create(self, payload: dict[str, Any]) -> PassKey:
"""创建 PassKey 凭证。"""
passkey = PassKey(**payload)
passkey.create(self._db)
self._execute_sync_write(lambda session: self._stage_create(session, passkey))
return passkey
@staticmethod
def _stage_create(session: Any, passkey: PassKey) -> None:
"""在调用方事务中暂存凭证并分配主键。"""
session.add(passkey)
session.flush()
def update_last_used(self, passkey: PassKey, sign_count: int) -> bool:
"""更新凭证最后使用时间和签名计数。"""
return bool(passkey.update_last_used(self._db, sign_count))
return bool(self._execute_sync_write(
lambda session: passkey.update_last_used(session, sign_count)
))
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
"""删除指定用户的凭证。"""
return bool(PassKey.delete_by_id(self._db, passkey_id, user_id))
return bool(self._execute_sync_write(
lambda session: PassKey.delete_by_id(session, passkey_id, user_id)
))
async def async_delete_by_id(self, passkey_id: int, user_id: int) -> bool:
"""在独立异步事务中删除指定用户的凭证。"""
return bool(await self._execute_async_write(
lambda session: PassKey.async_delete_by_id(
session, passkey_id, user_id
)
))
+8 -4
View File
@@ -80,10 +80,14 @@ class PluginDataOper(DbOper):
:param plugin_id: 插件id
:param key: 数据key
"""
if key:
PluginData.del_plugin_data_by_key(self._db, plugin_id, key)
else:
PluginData.del_plugin_data(self._db, plugin_id)
def stage(session: Session) -> None:
"""把兼容删除入口映射到调用方或组合根持有的事务。"""
if key:
PluginData.del_plugin_data_by_key(session, plugin_id, key)
else:
PluginData.del_plugin_data(session, plugin_id)
self._execute_sync_write(stage)
def stage_delete(self, plugin_id: str) -> None:
"""暂存目标插件全部数据删除并 flush,不提交调用方事务。"""
+2 -2
View File
@@ -116,8 +116,8 @@ class SiteOper(DbOper):
Site.delete(self._db, sid)
def reset(self) -> None:
"""清空站点表,保留站点模型细节在数据库适配层"""
Site.reset(self._db)
"""清空站点表;兼容入口的事务由组合根统一持有"""
self._execute_sync_write(Site.reset)
async def stage_reset(self) -> None:
"""暂存清空站点表,由应用事务统一提交。"""
+17 -7
View File
@@ -264,14 +264,18 @@ class TransferHistoryOper(DbOper):
kwargs.update({
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
})
TransferHistory.replace_by_src(self._db, **kwargs)
def stage(session: Session) -> Optional[TransferHistory]:
"""在同一事务替换记录并返回兼容查询投影。"""
TransferHistory.replace_by_src(session, **kwargs)
return TransferHistory.get_by_src(
session,
kwargs.get("src"),
kwargs["src_storage"],
)
# 保持 add_force 的既有返回契约:返回可被调用方安全读取字段的查询结果,
# 而非事务提交后可能已脱离会话的新建实例。
return TransferHistory.get_by_src(
self._db,
kwargs.get("src"),
kwargs["src_storage"],
)
return self._execute_sync_write(stage)
def stage_replace_by_src(self, **kwargs) -> TransferHistory:
"""在调用方事务内按源路径替换整理历史并返回已分配 ID 的新记录。"""
@@ -295,7 +299,13 @@ class TransferHistoryOper(DbOper):
"""
补充转移记录download_hash
"""
TransferHistory.update_download_hash(self._db, historyid, download_hash)
self._execute_sync_write(
lambda session: TransferHistory.update_download_hash(
session,
historyid,
download_hash,
)
)
def list_by_date(self, date: str) -> List[TransferHistory]:
"""
+16 -7
View File
@@ -20,11 +20,14 @@ class TransferPendingOper(DbOper):
:param src_path: 源文件路径
:return: 登记记录
"""
return TransferPending.register(
self._db,
storage=storage,
src_path=src_path,
now_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
now_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return self._execute_sync_write(
lambda session: TransferPending.register(
session,
storage=storage,
src_path=src_path,
now_time=now_time,
)
)
def discard(self, storage: str, src_path: str) -> int:
@@ -34,7 +37,13 @@ class TransferPendingOper(DbOper):
:param src_path: 源文件路径
:return: 删除的记录数
"""
return TransferPending.discard(self._db, storage=storage, src_path=src_path)
return self._execute_sync_write(
lambda session: TransferPending.discard(
session,
storage=storage,
src_path=src_path,
)
)
def list_all(self, limit: Optional[int] = 5000) -> List[Tuple[str, str]]:
"""
@@ -56,4 +65,4 @@ class TransferPendingOper(DbOper):
清空全部待整理登记
:return: 删除的记录数
"""
return TransferPending.clear(self._db)
return self._execute_sync_write(TransferPending.clear)
+34 -6
View File
@@ -11,6 +11,8 @@ runtime 兼容映射指向 SDK 薄门面;canonical 数据访问模块仍只依
"""
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import DbOper
from app.db.models.user import User
@@ -49,27 +51,53 @@ class UserOper(DbOper):
async def async_create(self, payload: dict) -> Optional[User]:
"""异步创建用户。"""
return await User(**payload).async_create(self._db)
user = User(**payload)
async def stage(session: AsyncSession) -> User:
"""在当前异步事务中暂存用户并分配主键。"""
session.add(user)
await session.flush()
return user
return await self._execute_async_write(stage)
async def async_update(self, user_id: int, payload: dict) -> Optional[User]:
"""异步更新用户。"""
user = await self.async_get_by_id(user_id)
if user:
await user.async_update(self._db, payload)
async def stage(session: AsyncSession) -> User:
"""在当前事务中更新用户字段,必要时重新附加游离对象。"""
for key, value in payload.items():
setattr(user, key, value)
return await session.merge(user)
await self._execute_async_write(stage)
return user
async def async_delete(self, user_id: int) -> None:
async def async_delete(self, user_id: int) -> bool:
"""异步删除用户。"""
await User.async_delete_by_id(self._db, user_id)
return bool(await self._execute_async_write(
lambda session: User.async_delete_by_id(session, user_id)
))
async def async_delete_by_name(self, name: str) -> bool:
"""在独立异步事务中按用户名删除用户。"""
return bool(await self._execute_async_write(
lambda session: User().async_delete_by_name(session, name)
))
async def async_update_otp_by_name(
self,
name: str,
otp: bool,
secret: str,
) -> None:
) -> bool:
"""异步更新用户 OTP 状态。"""
await User.async_update_otp_by_name(self._db, name, otp, secret)
return bool(await self._execute_async_write(
lambda session: User.async_update_otp_by_name(
session, name, otp, secret
)
))
async def async_get_by_name(self, name: str) -> Optional[User]:
"""
+57 -1
View File
@@ -1,9 +1,65 @@
"""SQLAlchemy 请求级事务适配器。"""
"""SQLAlchemy 请求级事务适配器与旧 Oper 事务执行端口"""
from collections.abc import Awaitable, Callable
from typing import Protocol, TypeVar
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
T = TypeVar("T")
class SyncTransactionRunner(Protocol):
"""为无显式 Session 的兼容写入口提供独占同步事务。"""
def __call__(self, operation: Callable[[Session], T]) -> T:
"""在一个独占会话中执行并提交操作。"""
...
class AsyncTransactionRunner(Protocol):
"""为无显式 Session 的兼容写入口提供独占异步事务。"""
def __call__(
self,
operation: Callable[[AsyncSession], Awaitable[T]],
) -> Awaitable[T]:
"""在一个独占异步会话中执行并提交操作。"""
...
_sync_transaction_runner: SyncTransactionRunner | None = None
_async_transaction_runner: AsyncTransactionRunner | None = None
def configure_transaction_runners(
*,
sync: SyncTransactionRunner,
async_: AsyncTransactionRunner,
) -> None:
"""由组合根登记旧 Oper 兼容入口使用的显式事务执行器。"""
global _sync_transaction_runner, _async_transaction_runner
_sync_transaction_runner = sync
_async_transaction_runner = async_
def run_sync_transaction(operation: Callable[[Session], T]) -> T:
"""委托组合根在独占同步事务中执行兼容写操作。"""
if _sync_transaction_runner is None:
raise RuntimeError("同步事务执行器尚未配置")
return _sync_transaction_runner(operation)
async def run_async_transaction(
operation: Callable[[AsyncSession], Awaitable[T]],
) -> T:
"""委托组合根在独占异步事务中执行兼容写操作。"""
if _async_transaction_runner is None:
raise RuntimeError("异步事务执行器尚未配置")
return await _async_transaction_runner(operation)
class SqlAlchemyUnitOfWork:
"""把同步 Session 的提交与回滚能力适配为应用层事务端口。"""
+73 -12
View File
@@ -81,22 +81,27 @@ class SyncSessionProvider(Protocol):
...
class CompatibilityApiData(Protocol):
"""未迁移 API 领域继续使用的结构化兼容 Facade"""
class RepositoryFactory(Protocol):
"""由请求 Session 构造某一明确领域仓储的通用工厂"""
sync_session: SyncSessionProvider
async_session: AsyncSessionProvider
def repository(self, name: str, session: object) -> object:
"""按旧能力名构造请求级仓储。"""
def __call__(self, session: object) -> object:
"""绑定请求会话并返回领域仓储。"""
...
def standalone_repository(self, name: str) -> object:
"""按旧能力名构造独立仓储。"""
class StandaloneRepositoryFactory(Protocol):
"""构造自持有兼容事务边界的领域仓储。"""
def __call__(self) -> object:
"""返回无需请求 Session 的领域仓储。"""
...
def transaction(self, name: str, session: object) -> object:
"""按旧能力名构造事务端口。"""
class SyncUnitOfWorkFactory(Protocol):
"""由同步请求 Session 构造事务端口的工厂。"""
def __call__(self, session: object) -> object:
"""绑定请求会话并返回同步事务端口。"""
...
@@ -109,6 +114,57 @@ class AgentChatRuntime:
transaction: AsyncUnitOfWorkFactory
@dataclass(frozen=True, slots=True)
class PersistenceRuntime:
"""全部 HTTP 业务领域共享的请求会话与事务工厂。"""
sync_session: SyncSessionProvider
async_session: AsyncSessionProvider
sync_transaction: SyncUnitOfWorkFactory
async_transaction: AsyncUnitOfWorkFactory
@dataclass(frozen=True, slots=True)
class AuthenticationRuntime:
"""认证、用户管理与 PassKey API 的显式数据工厂。"""
user_repository: RepositoryFactory
standalone_user: StandaloneRepositoryFactory
system_config: StandaloneRepositoryFactory
passkey: StandaloneRepositoryFactory
@dataclass(frozen=True, slots=True)
class MessagingRuntime:
"""消息历史 API 的显式仓储工厂。"""
repository: RepositoryFactory
@dataclass(frozen=True, slots=True)
class HistoryRuntime:
"""下载、整理、媒体服务器与 Dashboard 领域的数据工厂。"""
download_repository: RepositoryFactory
transfer_repository: RepositoryFactory
media_server_repository: RepositoryFactory
@dataclass(frozen=True, slots=True)
class SiteRuntime:
"""站点读写领域的显式仓储工厂。"""
repository: RepositoryFactory
@dataclass(frozen=True, slots=True)
class WorkflowRuntime:
"""工作流定义、状态与缓存操作所需的数据工厂。"""
repository: RepositoryFactory
system_config: StandaloneRepositoryFactory
@dataclass(frozen=True, slots=True)
class SubscriptionRuntime:
"""订阅 API 可见的请求级写事务运行时。"""
@@ -125,6 +181,11 @@ class HostRuntime:
"""宿主组合根构建且在一个 FastAPI lifespan 内共享的运行时对象。"""
agent_chat: AgentChatRuntime
persistence: PersistenceRuntime
authentication: AuthenticationRuntime
messaging: MessagingRuntime
history: HistoryRuntime
site: SiteRuntime
subscription: SubscriptionRuntime
workflow: WorkflowRuntime
configuration: RuntimeConfiguration
compatibility_api_data: CompatibilityApiData
+56 -4
View File
@@ -23,6 +23,7 @@ from app.runtime.extensions.module_manager import ModuleManager
from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher
from app.runtime.extensions.plugin_manager import PluginManager
from app.runtime.events import EventHandlerBinding, EventManager
from app.runtime.observability import record_metric
from app.runtime.state import SystemHelper
from app.runtime.thread import ThreadHelper
from app.adapters.network.doh import DohHelper
@@ -80,7 +81,11 @@ from app.db.session import (
get_async_db,
get_db,
)
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
from app.db.uow import (
SqlAlchemyAsyncUnitOfWork,
SqlAlchemyUnitOfWork,
configure_transaction_runners,
)
from app.db.oper.subscribe import SubscribeOper
from app.db.oper.agentchat import AgentChatOper
from app.db.oper.agenttask import AgentTaskOper
@@ -114,7 +119,18 @@ from app.startup.subscription import (
from app.startup.chain_events import TransactionalChainDurableEventWriter
from app.startup.download_failure import TransactionalDownloadFailureRepository
from app.startup.workflow import TransactionalWorkflowExecutionService
from app.startup.context import AgentChatRuntime, HostRuntime, SubscriptionRuntime
from app.startup.transaction import TransactionalWriteRunner
from app.startup.context import (
AgentChatRuntime,
AuthenticationRuntime,
HistoryRuntime,
HostRuntime,
MessagingRuntime,
PersistenceRuntime,
SiteRuntime,
SubscriptionRuntime,
WorkflowRuntime,
)
from app.adapters.web.security.access import set_superuser_token_payload_provider
from app.application.security.auth import build_superuser_token_payload
from app.application.image import configure_wallpaper_providers
@@ -320,6 +336,10 @@ def _build_outbox_dispatcher() -> OutboxDispatcher:
),
},
close=session.close,
failure_observer=lambda dead: record_metric(
"scheduler.job.dead_letter" if dead else "scheduler.job.retry",
owner="outbox",
),
)
@@ -535,6 +555,15 @@ async def init_modules() -> HostRuntime:
"""
启动模块并返回本次 lifespan 唯一的类型化 HostRuntime
"""
# 兼容 Oper 的无 Session 写入口仍由组合根持有事务,避免模型恢复自动提交。
transaction_runner = TransactionalWriteRunner(
sync_session=SessionFactory,
async_session=async_session_scope,
)
configure_transaction_runners(
sync=transaction_runner.sync,
async_=transaction_runner.async_,
)
# 数据访问能力统一在启动组合根注入,Runtime 和 Adapter 不再直接依赖 Oper。
api_data = ApiDataPorts(
sync_session=get_db,
@@ -572,6 +601,25 @@ async def init_modules() -> HostRuntime:
repository=AgentChatOper,
transaction=SqlAlchemyAsyncUnitOfWork,
),
persistence=PersistenceRuntime(
sync_session=get_db,
async_session=get_async_db,
sync_transaction=SqlAlchemyUnitOfWork,
async_transaction=SqlAlchemyAsyncUnitOfWork,
),
authentication=AuthenticationRuntime(
user_repository=UserOper,
standalone_user=UserOper,
system_config=SystemConfigOper,
passkey=PassKeyOper,
),
messaging=MessagingRuntime(repository=MessageOper),
history=HistoryRuntime(
download_repository=DownloadHistoryOper,
transfer_repository=TransferHistoryOper,
media_server_repository=MediaServerOper,
),
site=SiteRuntime(repository=SiteOper),
subscription=SubscriptionRuntime(
async_session=get_async_db,
repository=SubscribeOper,
@@ -579,11 +627,15 @@ async def init_modules() -> HostRuntime:
transaction=SqlAlchemyAsyncUnitOfWork,
outbox=SqlAlchemyAsyncOutboxStager,
),
workflow=WorkflowRuntime(
repository=WorkflowOper,
system_config=SystemConfigOper,
),
configuration=runtime_configuration,
compatibility_api_data=api_data,
)
configure_runtime_configuration(host_runtime.configuration)
configure_api_data_runtime(host_runtime.compatibility_api_data)
# 旧 app.api.data 导入只保留 ABI 转发,正式 API 依赖全部读取 HostRuntime。
configure_api_data_runtime(api_data)
configure_runtime_data_providers()
workflow_execution = TransactionalWorkflowExecutionService(SessionFactory)
configure_workflow_legacy_writer(workflow_execution)
+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