refactor: govern background tasks and query ownership

This commit is contained in:
jxxghp
2026-08-23 13:24:04 +08:00
parent 43c173a0e7
commit f1e542bef0
37 changed files with 1570 additions and 510 deletions
+15
View File
@@ -22,6 +22,7 @@ from app.startup.context import (
HostRuntime,
SubscriptionRuntime,
)
from app.runtime.tasks import TaskRegistry, get_task_registry
def get_host_runtime(request: Request) -> HostRuntime:
@@ -32,6 +33,20 @@ def get_host_runtime(request: Request) -> HostRuntime:
return runtime
def get_background_task_registry(
runtime: HostRuntime = Depends(get_host_runtime),
) -> TaskRegistry:
"""返回当前 lifespan 统一管理的后台任务登记器。"""
return runtime.tasks
def resolve_background_task_registry(value: object) -> TaskRegistry:
"""兼容直接调用 endpoint 的旧入口,并优先使用注入的任务登记器。"""
if isinstance(value, TaskRegistry):
return value
return get_task_registry()
def get_api_runtime_config(
runtime: HostRuntime = Depends(get_host_runtime),
) -> ApiRuntimeConfig:
+5 -3
View File
@@ -2,7 +2,7 @@
from typing import Any, cast
from fastapi import BackgroundTasks, Depends
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
@@ -38,6 +38,8 @@ from app.runtime.events import eventmanager
from app.runtime.log import logger
from app.schemas.types import EventType
from app.startup.context import HostRuntime
from app.api.context import get_background_task_registry, resolve_background_task_registry
from app.runtime.tasks import TaskRegistry
async def _publish_subscribe_deleted(
@@ -94,14 +96,14 @@ def get_delete_subscriptions_by_identity_command(
def get_search_subscriptions_command(
background_tasks: BackgroundTasks,
task_registry: TaskRegistry = Depends(get_background_task_registry),
db: AsyncSession = Depends(get_async_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> SearchSubscriptionsCommand:
"""组装手工订阅搜索用例,并把调度延迟到响应后的后台任务。"""
def schedule_search(subscribe_id: int | None, state: str | None) -> None:
"""按历史参数提交订阅搜索调度任务。"""
background_tasks.add_task(
resolve_background_task_registry(task_registry).create_sync(
start_scheduler_job,
job_id="subscribe_search",
sid=subscribe_id,
+7 -3
View File
@@ -4,7 +4,7 @@ import json
import time
from typing import Protocol, Union, Any, List, Optional
from fastapi import BackgroundTasks, Depends, Request
from fastapi import Depends, Request
from starlette.responses import PlainTextResponse
from app.schemas.message import MessageClearBefore as _SchemaMessageClearBefore
@@ -32,6 +32,8 @@ from app.runtime.extensions.service_config import ServiceConfigHelper
from app.runtime.log import logger
from app.adapters.external.wechat_crypt import WXBizMsgCrypt
from app.schemas.types import NotificationChannel, SystemConfigKey
from app.api.context import get_background_task_registry, resolve_background_task_registry
from app.runtime.tasks import TaskRegistry
router = ResponseAPIRouter()
@@ -116,7 +118,7 @@ def start_message_chain(body: Any, form: Any, args: Any):
@router.post("/", summary="接收用户消息", response_model=_SchemaResponse[None])
async def user_message(
background_tasks: BackgroundTasks,
task_registry: Annotated[TaskRegistry, Depends(get_background_task_registry)],
request: Request,
_: _SchemaTokenPayload = Depends(verify_apitoken),
):
@@ -150,7 +152,9 @@ async def user_message(
list(form.keys()) if form else [],
image_markers,
)
background_tasks.add_task(start_message_chain, body, form, args)
resolve_background_task_registry(task_registry).create_sync(
start_message_chain, body, form, args, owner="api.message.user"
)
return _SchemaResponse(success=True)
+16 -3
View File
@@ -60,6 +60,8 @@ from app.adapters.system.plugin.package import PluginPackageManager
from app.application.database import DatabaseWorkerOverloadedError
from app.runtime.log import logger
from app.schemas.types import SystemConfigKey
from app.api.context import get_background_task_registry, resolve_background_task_registry
from app.runtime.tasks import TaskRegistry
router = ResponseAPIRouter()
_plugin_release_refresh_tasks: set[asyncio.Task] = set()
@@ -112,11 +114,17 @@ async def _refresh_plugin_release_versions(plugin_id: str, repo_url: str) -> Non
logger.warning(f"后台刷新插件 {plugin_id} Release 列表失败:{e}")
def _schedule_plugin_release_refresh(plugin_id: str, repo_url: str) -> None:
def _schedule_plugin_release_refresh(
plugin_id: str, repo_url: str, task_registry: TaskRegistry | None = None
) -> None:
"""
保留后台任务引用,避免任务被回收,同时让 helper 负责同仓库强刷合并。
"""
task = asyncio.create_task(_refresh_plugin_release_versions(plugin_id, repo_url))
registry = resolve_background_task_registry(task_registry)
task = registry.create(
_refresh_plugin_release_versions(plugin_id, repo_url),
owner="api.plugin.release_refresh",
)
_plugin_release_refresh_tasks.add(task)
def _discard_task(completed_task: asyncio.Task) -> None:
@@ -368,6 +376,7 @@ async def plugin_releases(
_: ApiPrincipal = Depends(get_current_active_superuser_async),
repo_url: Optional[str] = "",
force: bool = False,
task_registry: TaskRegistry = Depends(get_background_task_registry),
) -> dict:
"""
查询指定插件可直接安装的 GitHub Release 版本。
@@ -404,7 +413,11 @@ async def plugin_releases(
)
release_items = await plugin_helper.async_get_plugin_release_versions(plugin_id, repo_url)
if force and has_release_cache:
_schedule_plugin_release_refresh(plugin_id, repo_url)
_schedule_plugin_release_refresh(
plugin_id,
repo_url,
resolve_background_task_registry(task_registry),
)
items = []
for item in release_items:
version = item.get("version")
+7 -3
View File
@@ -1,7 +1,7 @@
from typing import List, Any, Dict, Optional
from fastapi import Depends, HTTPException
from starlette.background import BackgroundTasks
from typing import Annotated
from app.schemas.common import JsonObject as _SchemaJsonObject
from app.schemas.response import Response as _SchemaResponse
@@ -42,6 +42,8 @@ from app.runtime.log import logger
from app.application.scheduling import Scheduler
from app.schemas.types import SystemConfigKey, MediaType
from app.domain import site as site_rules
from app.api.context import get_background_task_registry, resolve_background_task_registry
from app.runtime.tasks import TaskRegistry
router = ResponseAPIRouter()
@@ -169,13 +171,15 @@ async def update_site(
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=_SchemaResponse[None])
async def cookie_cloud_sync(
background_tasks: BackgroundTasks,
task_registry: Annotated[TaskRegistry, Depends(get_background_task_registry)],
_: ApiPrincipal = Depends(get_current_active_superuser_async),
) -> Any:
"""
运行CookieCloud同步站点信息
"""
background_tasks.add_task(Scheduler().start, job_id="cookiecloud")
resolve_background_task_registry(task_registry).create_sync(
Scheduler().start, job_id="cookiecloud", owner="api.site.cookiecloud_sync"
)
return _SchemaResponse(success=True, message="CookieCloud同步任务已启动!")
+11 -4
View File
@@ -1,7 +1,7 @@
from typing import List, Any, Annotated, Optional
import cn2an
from fastapi import Request, BackgroundTasks, Depends, HTTPException, Header
from fastapi import Request, Depends, HTTPException, Header
from app.schemas.common import IdData as _SchemaIdData
from app.schemas.response import Response as _SchemaResponse
@@ -12,6 +12,10 @@ from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
from app.schemas.workflow import Subscribe as _SchemaSubscribe
from app.api.response import ResponseAPIRouter
from app.api.context import (
get_background_task_registry,
resolve_background_task_registry,
)
from app.chain.subscribe import SubscribeChain
from app.runtime.events import eventmanager
from app.domain.context import MediaInfo
@@ -52,6 +56,7 @@ from app.api.dependencies.subscription import (
)
from app.adapters.external.server import MoviePilotServerHelper
from app.application.scheduling import Scheduler
from app.runtime.tasks import TaskRegistry
from app.schemas.event import SubscribeModifiedEventData
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
@@ -489,7 +494,7 @@ async def delete_subscribe_by_media_identity(
)
async def seerr_subscribe(
request: Request,
background_tasks: BackgroundTasks,
task_registry: Annotated[TaskRegistry, Depends(get_background_task_registry)],
authorization: Annotated[str | None, Header()] = None,
) -> Any:
"""
@@ -521,7 +526,7 @@ async def seerr_subscribe(
user_name = req_json.get("request", {}).get("requestedBy_username")
# 添加订阅
if media_type == MediaType.MOVIE:
background_tasks.add_task(
resolve_background_task_registry(task_registry).create_sync(
start_subscribe_add,
mtype=media_type,
media_source=MediaSource.TMDB,
@@ -531,6 +536,7 @@ async def seerr_subscribe(
# 电影不传季号,避免被误判为剧集(S00)并污染通知标题
season=None,
username=user_name,
owner="api.subscribe.seerr",
)
else:
seasons = []
@@ -543,7 +549,7 @@ async def seerr_subscribe(
]
break
for season in seasons:
background_tasks.add_task(
resolve_background_task_registry(task_registry).create_sync(
start_subscribe_add,
mtype=media_type,
media_source=MediaSource.TMDB,
@@ -552,6 +558,7 @@ async def seerr_subscribe(
year="",
season=season,
username=user_name,
owner="api.subscribe.seerr",
)
return _SchemaResponse(success=True)
+11 -5
View File
@@ -1,11 +1,13 @@
from typing import Any, Annotated
from fastapi import BackgroundTasks, Request, Depends
from fastapi import Depends, Request
from app.schemas.response import Response as _SchemaResponse
from app.api.response import ResponseAPIRouter
from app.chain.webhook import WebhookChain
from app.adapters.web.security.access import verify_apitoken
from app.api.context import get_background_task_registry, resolve_background_task_registry
from app.runtime.tasks import TaskRegistry
router = ResponseAPIRouter()
@@ -19,7 +21,7 @@ def start_webhook_chain(body: Any, form: Any, args: Any):
@router.post("/", summary="Webhook消息响应", response_model=_SchemaResponse[None])
async def webhook_message(
background_tasks: BackgroundTasks,
task_registry: Annotated[TaskRegistry, Depends(get_background_task_registry)],
request: Request,
_: Annotated[str, Depends(verify_apitoken)],
) -> Any:
@@ -29,13 +31,15 @@ async def webhook_message(
body = await request.body()
form = await request.form()
args = request.query_params
background_tasks.add_task(start_webhook_chain, body, form, args)
resolve_background_task_registry(task_registry).create_sync(
start_webhook_chain, body, form, args, owner="api.webhook.message"
)
return _SchemaResponse(success=True)
@router.get("/", summary="Webhook消息响应", response_model=_SchemaResponse[None])
async def webhook_message_get(
background_tasks: BackgroundTasks,
task_registry: Annotated[TaskRegistry, Depends(get_background_task_registry)],
request: Request,
_: Annotated[str, Depends(verify_apitoken)],
) -> Any:
@@ -43,5 +47,7 @@ async def webhook_message_get(
Webhook响应,配置请求中需要添加参数:token=API_TOKEN&source=媒体服务器名
"""
args = request.query_params
background_tasks.add_task(start_webhook_chain, None, None, args)
resolve_background_task_registry(task_registry).create_sync(
start_webhook_chain, None, None, args, owner="api.webhook.message"
)
return _SchemaResponse(success=True)
+5 -1
View File
@@ -7,6 +7,7 @@ from threading import Lock
from typing import Awaitable, Callable, Dict, Iterable, List, Optional, Tuple, Union
from app.schemas.types import NotificationChannel
from app.runtime.tasks import get_task_registry
# Agent 选择按钮回调前缀(新旧两种格式都必须继续兼容)
AGENT_CHOICE_PREFIX = "agent_interaction:choice:"
@@ -185,7 +186,10 @@ def create_web_agent_background_task(
coroutine: Awaitable[object],
) -> asyncio.Task[object]:
"""登记 Web Agent 后台任务,使应用关闭时可以统一收口。"""
task = asyncio.create_task(coroutine)
task = get_task_registry().create(
coroutine,
owner="api.agent.web_execution",
)
_WEB_AGENT_BACKGROUND_TASKS.add(task)
task.add_done_callback(_WEB_AGENT_BACKGROUND_TASKS.discard)
return task
+15
View File
@@ -173,6 +173,12 @@ class DbOper:
return run_sync_transaction(operation)
return operation(self._db)
def _execute_sync_query(self, operation: Callable[[Session], T]) -> T:
"""在当前同步会话查询,或委托组合根创建一次性兼容会话。"""
if self._db is None or isinstance(self._db, AsyncSession):
return run_sync_transaction(operation)
return operation(self._db)
async def _execute_async_write(
self,
operation: Callable[[AsyncSession], Awaitable[T]],
@@ -184,6 +190,15 @@ class DbOper:
return await run_async_transaction(operation)
return await operation(self._db)
async def _execute_async_query(
self,
operation: Callable[[AsyncSession], Awaitable[T]],
) -> T:
"""在当前异步会话查询,或委托组合根创建一次性兼容会话。"""
if self._db is None or isinstance(self._db, Session):
return await run_async_transaction(operation)
return await operation(self._db)
def _stage_create(self, model: TModel) -> TModel:
"""在显式同步事务中暂存新模型,不触发 Base 的兼容提交装饰器。"""
def stage(session: Session) -> TModel:
+73 -48
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
from app.db.decorators import run_legacy_async_query, run_legacy_sync_query
class Message(Base):
@@ -49,21 +49,32 @@ class Message(Base):
return self.to_dict()
@classmethod
@db_query
def list_by_page(cls, db: Session, page: int = 1, count: int = 30) -> List["Message"]:
def list_by_page(
cls,
db: Session | None = None,
page: int = 1,
count: int = 30,
) -> List["Message"]:
"""
分页获取消息记录。
分页获取消息记录,兼容显式会话和旧插件无会话调用
"""
return list(db.execute(
select(cls)
.order_by(cls.reg_time.desc(), cls.id.desc())
.offset((page - 1) * count)
.limit(count)
).scalars().all())
def query(session: Session) -> List["Message"]:
"""在给定同步会话中执行消息分页查询。"""
return list(session.execute(
select(cls)
.order_by(cls.reg_time.desc(), cls.id.desc())
.offset((page - 1) * count)
.limit(count)
).scalars().all())
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
@classmethod
@db_query
def exists_by_source(cls, db: Session, source: str) -> bool:
def exists_by_source(
cls,
db: Session | str | None = None,
source: str | None = None,
) -> bool:
"""
判断指定来源标识的消息记录是否存在。
@@ -71,31 +82,42 @@ class Message(Base):
:param source: 消息来源唯一标识
:return: 是否存在匹配记录
"""
return db.execute(
select(cls.id).where(cls.source == source).limit(1)
).scalars().first() is not None
if source is None and isinstance(db, str):
source, db = db, None
if source is None:
raise TypeError("source is required")
def query(session: Session) -> bool:
"""在给定同步会话中执行来源存在性查询。"""
return session.execute(
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)
@classmethod
@async_db_query
async def async_list_by_page(
cls, db: AsyncSession, page: int = 1, count: int = 30
cls, db: AsyncSession | None = None, page: int = 1, count: int = 30
) -> List["Message"]:
"""
异步分页获取消息记录。
"""
result = await db.execute(
select(cls)
.order_by(cls.reg_time.desc(), cls.id.desc())
.offset((page - 1) * count)
.limit(count)
)
return list(result.scalars().all())
async def query(session: AsyncSession) -> List["Message"]:
"""在给定异步会话中执行消息分页查询。"""
result = await session.execute(
select(cls)
.order_by(cls.reg_time.desc(), cls.id.desc())
.offset((page - 1) * count)
.limit(count)
)
return list(result.scalars().all())
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
@classmethod
@async_db_query
async def async_list_sent_by_page(
cls,
db: AsyncSession,
db: AsyncSession | None = None,
page: int = 1,
count: int = 30,
all_clear_before: Optional[str] = None,
@@ -105,32 +127,35 @@ class Message(Base):
"""
分页获取系统发送的通知消息。
"""
statement = select(cls).where(cls.action == 1)
if all_clear_before:
statement = statement.where(cls.reg_time > all_clear_before)
if system_clear_before:
statement = statement.where(
or_(
and_(cls.image.isnot(None), cls.image != ""),
cls.reg_time > system_clear_before,
async def query(session: AsyncSession) -> List["Message"]:
"""在给定异步会话中执行通知消息分页查询。"""
statement = select(cls).where(cls.action == 1)
if all_clear_before:
statement = statement.where(cls.reg_time > all_clear_before)
if system_clear_before:
statement = statement.where(
or_(
and_(cls.image.isnot(None), cls.image != ""),
cls.reg_time > system_clear_before,
)
)
)
if media_clear_before:
statement = statement.where(
or_(
cls.image.is_(None),
cls.image == "",
cls.reg_time > media_clear_before,
if media_clear_before:
statement = statement.where(
or_(
cls.image.is_(None),
cls.image == "",
cls.reg_time > media_clear_before,
)
)
result = await session.execute(
statement
.order_by(cls.reg_time.desc(), cls.id.desc())
.offset((page - 1) * count)
.limit(count)
)
return list(result.scalars().all())
result = await db.execute(
statement
.order_by(cls.reg_time.desc(), cls.id.desc())
.offset((page - 1) * count)
.limit(count)
)
return list(result.scalars().all())
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
@classmethod
def delete_before(
+95 -29
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, async_db_query
from app.db.decorators import run_legacy_async_query, run_legacy_sync_query
class Site(Base):
@@ -58,48 +58,114 @@ class Site(Base):
downloader: Mapped[Optional[str]] = mapped_column(String)
@classmethod
@db_query
def get_by_domain(cls, db: Session, domain: str):
return db.execute(select(cls).where(cls.domain == domain)).scalars().first()
def get_by_domain(cls, db: Session | str | None = None, domain: str | None = None):
"""按域名查询站点,兼容显式会话和旧插件无会话调用。"""
if domain is None and isinstance(db, str):
domain, db = db, None
if domain is None:
raise TypeError("domain is required")
def query(session: Session):
"""在给定同步会话中执行域名查询。"""
return session.execute(select(cls).where(cls.domain == domain)).scalars().first()
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
@classmethod
@async_db_query
async def async_get_by_domain(cls, db: AsyncSession, domain: str):
result = await db.execute(select(cls).where(cls.domain == domain))
return result.scalar_one_or_none()
async def async_get_by_domain(
cls,
db: AsyncSession | str | None = None,
domain: str | None = None,
):
"""异步按域名查询站点,兼容显式会话和旧插件无会话调用。"""
if domain is None and isinstance(db, str):
domain, db = db, None
if domain is None:
raise TypeError("domain is required")
async def query(session: AsyncSession):
"""在给定异步会话中执行域名查询。"""
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)
@classmethod
@async_db_query
async def async_get_by_name(cls, db: AsyncSession, name: str):
result = await db.execute(select(cls).where(cls.name == name))
return result.scalar_one_or_none()
async def async_get_by_name(
cls,
db: AsyncSession | str | None = None,
name: str | None = None,
):
"""异步按站点名称查询,兼容显式会话和旧插件无会话调用。"""
if name is None and isinstance(db, str):
name, db = db, None
if name is None:
raise TypeError("name is required")
async def query(session: AsyncSession):
"""在给定异步会话中执行名称查询。"""
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)
@classmethod
@db_query
def get_actives(cls, db: Session):
return list(db.execute(select(cls).where(cls.is_active.is_(True))).scalars().all())
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)
@classmethod
@async_db_query
async def async_get_actives(cls, db: AsyncSession):
result = await db.execute(select(cls).where(cls.is_active.is_(True)))
return list(result.scalars().all())
async def async_get_actives(cls, db: AsyncSession | None = None):
"""异步查询启用站点,兼容显式会话和旧插件无会话调用。"""
async def query(session: AsyncSession):
"""在给定异步会话中执行启用站点查询。"""
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)
@classmethod
@db_query
def list_order_by_pri(cls, db: Session):
return list(db.execute(select(cls).order_by(cls.pri)).scalars().all())
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)
@classmethod
@async_db_query
async def async_list_order_by_pri(cls, db: AsyncSession):
result = await db.execute(select(cls).order_by(cls.pri))
return list(result.scalars().all())
async def async_list_order_by_pri(cls, db: AsyncSession | None = None):
"""异步按优先级升序查询站点,兼容显式会话和旧插件无会话调用。"""
async def query(session: AsyncSession):
"""在给定异步会话中执行优先级查询。"""
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)
@classmethod
@db_query
def get_domains_by_ids(cls, db: Session, ids: list):
return list(db.execute(select(cls.domain).where(cls.id.in_(ids))).scalars().all())
def get_domains_by_ids(
cls,
db: Session | list[int] | None = None,
ids: list[int] | None = None,
):
"""按 ID 查询域名,兼容显式会话和旧插件无会话调用。"""
if ids is None and isinstance(db, list):
ids, db = db, None
if ids is None:
raise TypeError("ids is required")
if not ids:
return []
def query(session: Session):
"""在给定同步会话中执行域名投影查询。"""
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)
@classmethod
def reset(cls, db: Session):
+221 -140
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 async_db_query, db_query
from app.db.decorators import run_legacy_async_query, run_legacy_sync_query
from app.db.models._constraints import media_identity_constraint
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
@@ -140,50 +140,66 @@ class Subscribe(Base):
return condition
@classmethod
@db_query
def exists(
cls, db: Session, media_source: MediaSource, media_id: str,
cls, db: Session | MediaSource | None = None,
media_source: MediaSource | str | None = None,
media_id: str | None = None,
season: Optional[int] = None,
episode_group: Optional[str] = None,
music_type: Optional[str] = None,
):
"""按媒体身份、季号与剧集组查询已有订阅。"""
if db is not None and not isinstance(db, Session):
media_source, media_id, db = db, media_source, None
condition = cls._identity_condition(
media_source, media_id, music_type
)
if condition is None:
return None
statement = select(cls).where(condition)
if season is not None:
statement = statement.where(cls.season == season)
statement = statement.where(cls.episode_group == episode_group)
return db.execute(statement).scalars().first()
def query(session: Session):
"""在给定会话中执行订阅身份查询。"""
statement = select(cls).where(condition)
if season is not None:
statement = statement.where(cls.season == season)
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)
@classmethod
@async_db_query
async def async_exists(
cls, db: AsyncSession, media_source: MediaSource, media_id: str,
cls, db: AsyncSession | MediaSource | None = None,
media_source: MediaSource | str | None = None,
media_id: str | None = None,
season: Optional[int] = None,
episode_group: Optional[str] = None,
music_type: Optional[str] = None,
):
"""异步按媒体身份、季号与剧集组查询已有订阅。"""
if db is not None and not isinstance(db, AsyncSession):
media_source, media_id, db = db, media_source, None
condition = cls._identity_condition(
media_source, media_id, music_type
)
if condition is None:
return None
query = select(cls).filter(condition)
if season is not None:
query = query.filter(cls.season == season)
query = query.filter(cls.episode_group == episode_group)
result = await db.execute(query)
return result.scalars().first()
async def query(session: AsyncSession):
"""在给定异步会话中执行订阅身份查询。"""
statement = select(cls).where(condition)
if season is not None:
statement = statement.where(cls.season == season)
result = await session.execute(
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)
@classmethod
@db_query
def exists_by_username(
cls, db: Session, username: str, media_source: MediaSource, media_id: str,
cls, db: Session | str | None = None,
username: str | MediaSource | None = None,
media_source: MediaSource | str | None = None,
media_id: str | None = None,
season: Optional[int] = None,
episode_group: Optional[str] = None,
music_type: Optional[str] = None,
@@ -191,6 +207,8 @@ class Subscribe(Base):
"""
按订阅 owner、媒体身份、季号与剧集组查询订阅行。
"""
if db is not None and not isinstance(db, Session):
username, media_source, media_id, db = db, username, media_source, None
if not username:
return None
condition = cls._identity_condition(
@@ -198,23 +216,30 @@ class Subscribe(Base):
)
if condition is None:
return None
statement = select(cls).where(cls.username == username, condition)
if season is not None:
statement = statement.where(cls.season == season)
statement = statement.where(cls.episode_group == episode_group)
return db.execute(statement).scalars().first()
def query(session: Session):
"""在给定会话中执行订阅 owner 查询。"""
statement = select(cls).where(cls.username == username, condition)
if season is not None:
statement = statement.where(cls.season == season)
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)
@classmethod
@async_db_query
async def async_exists_by_username(
cls, db: AsyncSession, username: str, media_source: MediaSource,
media_id: str, season: Optional[int] = None,
cls, db: AsyncSession | str | None = None,
username: str | MediaSource | None = None,
media_source: MediaSource | str | None = None,
media_id: str | None = None, season: Optional[int] = None,
episode_group: Optional[str] = None,
music_type: Optional[str] = None,
):
"""
异步按订阅 owner、媒体身份、季号与剧集组查询订阅行。
"""
if db is not None and not isinstance(db, AsyncSession):
username, media_source, media_id, db = db, username, media_source, None
if not username:
return None
condition = cls._identity_condition(
@@ -222,80 +247,106 @@ class Subscribe(Base):
)
if condition is None:
return None
query = select(cls).filter(cls.username == username, condition)
if season is not None:
query = query.filter(cls.season == season)
query = query.filter(cls.episode_group == episode_group)
result = await db.execute(query)
return result.scalars().first()
@classmethod
@db_query
def get_by_state(cls, db: Session, state: str):
# 如果 state 为空或 None,返回所有订阅
statement = select(cls)
if state:
# 如果传入的状态不为空,拆分成多个状态
statement = statement.where(cls.state.in_(state.split(',')))
return list(db.execute(statement).scalars().all())
@classmethod
@async_db_query
async def async_get_by_state(cls, db: AsyncSession, state: str):
# 如果 state 为空或 None,返回所有订阅
if not state:
result = await db.execute(select(cls))
else:
# 如果传入的状态不为空,拆分成多个状态
result = await db.execute(
select(cls).filter(cls.state.in_(state.split(',')))
async def query(session: AsyncSession):
"""在给定异步会话中执行订阅 owner 查询。"""
statement = select(cls).where(cls.username == username, condition)
if season is not None:
statement = statement.where(cls.season == season)
result = await session.execute(
statement.where(cls.episode_group == episode_group)
)
return list(result.scalars().all())
return result.scalars().first()
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
@classmethod
@db_query
def get_by_title(cls, db: Session, title: str, season: Optional[int] = None):
statement = select(cls).where(cls.name == title)
if season is not None:
statement = statement.where(cls.season == season)
return db.execute(statement).scalars().first()
def get_by_state(cls, db: Session | str | None = None, state: str | None = None):
"""按状态列表查询订阅,兼容显式会话和旧插件无会话调用。"""
if not isinstance(db, Session):
state, db = db if state is None else state, None
def query(session: Session):
"""在给定会话中执行状态查询。"""
statement = select(cls)
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)
@classmethod
@async_db_query
async def async_get_by_title(cls, db: AsyncSession, title: str, season: Optional[int] = None):
if season is not None:
result = await db.execute(
select(cls).filter(cls.name == title, cls.season == season)
)
else:
result = await db.execute(
select(cls).filter(cls.name == title)
)
return result.scalars().first()
async def async_get_by_state(
cls, db: AsyncSession | str | None = None, state: str | None = None
):
"""异步按状态列表查询订阅,兼容显式会话和旧插件无会话调用。"""
if not isinstance(db, AsyncSession):
state, db = db if state is None else state, None
async def query(session: AsyncSession):
"""在给定异步会话中执行状态查询。"""
statement = select(cls)
if state:
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)
@classmethod
@async_db_query
async def async_list_by_title(cls, db: AsyncSession, title: str, season: Optional[int] = None):
"""
异步按标题查询候选订阅列表。
"""
if season is not None:
result = await db.execute(
select(cls).filter(cls.name == title, cls.season == season)
)
else:
result = await db.execute(
select(cls).filter(cls.name == title)
)
return list(result.scalars().all())
def get_by_title(
cls, db: Session | str | None = None, title: str | None = None,
season: Optional[int] = None,
):
"""按标题查询订阅,兼容显式会话和旧插件无会话调用。"""
if not isinstance(db, Session):
title, db = db if title is None else title, None
def query(session: Session):
"""在给定会话中执行标题查询。"""
statement = select(cls).where(cls.name == title)
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)
@classmethod
async def async_get_by_title(
cls, db: AsyncSession | str | None = None, title: str | None = None,
season: Optional[int] = None,
):
"""异步按标题查询订阅,兼容显式会话和旧插件无会话调用。"""
if not isinstance(db, AsyncSession):
title, db = db if title is None else title, None
async def query(session: AsyncSession):
"""在给定异步会话中执行标题查询。"""
statement = select(cls).where(cls.name == title)
if season is not None:
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)
@classmethod
async def async_list_by_title(
cls, db: AsyncSession | str | None = None, title: str | None = None,
season: Optional[int] = None,
):
"""异步按标题查询候选订阅列表,兼容显式会话和旧插件无会话调用。"""
if not isinstance(db, AsyncSession):
title, db = db if title is None else title, None
async def query(session: AsyncSession):
"""在给定异步会话中执行标题列表查询。"""
statement = select(cls).where(cls.name == title)
if season is not None:
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)
@classmethod
@db_query
def list_by_media_identity(
cls, db: Session, media_source: MediaSource, media_id: str,
cls, db: Session | MediaSource | None = None,
media_source: MediaSource | str | None = None,
media_id: str | None = None,
music_type: Optional[str] = None,
):
"""同步按统一媒体身份查询候选订阅列表。"""
if db is not None and not isinstance(db, Session):
media_source, media_id, db = db, media_source, None
condition = cls._identity_condition(
media_source=media_source,
media_id=media_id,
@@ -303,15 +354,21 @@ class Subscribe(Base):
)
if condition is None:
return []
return list(db.execute(select(cls).where(condition)).scalars().all())
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)
@classmethod
@async_db_query
async def async_list_by_media_identity(
cls, db: AsyncSession, media_source: MediaSource, media_id: str,
cls, db: AsyncSession | MediaSource | None = None,
media_source: MediaSource | str | None = None,
media_id: str | None = None,
music_type: Optional[str] = None,
):
"""异步按统一媒体身份查询候选订阅列表。"""
if db is not None and not isinstance(db, AsyncSession):
media_source, media_id, db = db, media_source, None
condition = cls._identity_condition(
media_source=media_source,
media_id=media_id,
@@ -319,19 +376,26 @@ class Subscribe(Base):
)
if condition is None:
return []
result = await db.execute(select(cls).filter(condition))
return list(result.scalars().all())
async def query(session: AsyncSession):
"""在给定异步会话中执行媒体身份列表查询。"""
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)
@classmethod
@db_query
def get_by(
cls, db: Session, type: str, media_source: MediaSource, media_id: str,
cls, db: Session | str | None = None,
type: str | MediaSource | None = None,
media_source: MediaSource | str | None = None,
media_id: str | None = None,
season: Optional[str] = None,
music_type: Optional[str] = None,
):
"""
根据条件查询订阅
"""
if db is not None and not isinstance(db, Session):
type, media_source, media_id, db = db, type, media_source, None
condition = cls._identity_condition(
media_source, media_id, music_type
)
@@ -340,18 +404,25 @@ class Subscribe(Base):
statement = select(cls).where(condition, cls.type == type)
if season is not None:
statement = statement.where(cls.season == season)
return db.execute(statement).scalars().first()
def query(session: Session):
"""在给定会话中执行类型媒体查询。"""
return session.execute(statement).scalars().first()
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
@classmethod
@async_db_query
async def async_get_by(
cls, db: AsyncSession, type: str, media_source: MediaSource, media_id: str,
cls, db: AsyncSession | str | None = None,
type: str | MediaSource | None = None,
media_source: MediaSource | str | None = None,
media_id: str | None = None,
season: Optional[str] = None,
music_type: Optional[str] = None,
):
"""
根据条件查询订阅
"""
if db is not None and not isinstance(db, AsyncSession):
type, media_source, media_id, db = db, type, media_source, None
condition = cls._identity_condition(
media_source, media_id, music_type
)
@@ -360,62 +431,72 @@ class Subscribe(Base):
query = select(cls).filter(condition, cls.type == type)
if season is not None:
query = query.filter(cls.season == season)
result = await db.execute(query)
return result.scalars().first()
async def execute_query(session: AsyncSession):
"""在给定异步会话中执行类型媒体查询。"""
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)
@classmethod
@db_query
def list_by_username(cls, db: Session, username: str, state: Optional[str] = None, mtype: Optional[str] = None):
statement = select(cls).where(cls.username == username)
if state:
statement = statement.where(cls.state == state)
if mtype:
statement = statement.where(cls.type == mtype)
return list(db.execute(statement).scalars().all())
def list_by_username(cls, db: Session | str | None = None, username: str | None = None,
state: Optional[str] = None, mtype: Optional[str] = None):
"""按用户筛选订阅,兼容显式会话和旧插件无会话调用。"""
if not isinstance(db, Session):
username, db = db if username is None else username, None
def query(session: Session):
"""在给定会话中执行用户筛选查询。"""
statement = select(cls).where(cls.username == username)
if state:
statement = statement.where(cls.state == state)
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)
@classmethod
@async_db_query
async def async_list_by_username(cls, db: AsyncSession, username: str, state: Optional[str] = None,
async def async_list_by_username(cls, db: AsyncSession | str | None = None,
username: str | None = None, state: Optional[str] = None,
mtype: Optional[str] = None):
if mtype:
"""异步按用户筛选订阅,兼容显式会话和旧插件无会话调用。"""
if not isinstance(db, AsyncSession):
username, db = db if username is None else username, None
async def query(session: AsyncSession):
"""在给定异步会话中执行用户筛选查询。"""
statement = select(cls).where(cls.username == username)
if state:
result = await db.execute(
select(cls).filter(cls.state == state, cls.username == username, cls.type == mtype)
)
else:
result = await db.execute(
select(cls).filter(cls.username == username, cls.type == mtype)
)
else:
if state:
result = await db.execute(
select(cls).filter(cls.state == state, cls.username == username)
)
else:
result = await db.execute(
select(cls).filter(cls.username == username)
)
return list(result.scalars().all())
statement = statement.where(cls.state == state)
if mtype:
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)
@classmethod
@db_query
def list_by_type(cls, db: Session, mtype: str, days: int):
return list(db.execute(
select(cls).where(
def list_by_type(cls, db: Session | str | None = None, mtype: str | None = None, days: int = 7):
"""按类型查询最近时间窗内的订阅,兼容显式会话和旧插件无会话调用。"""
if not isinstance(db, Session):
mtype, db = db if mtype is None else mtype, None
def query(session: Session):
"""在给定会话中执行时间窗订阅查询。"""
return list(session.execute(select(cls).where(
cls.type == mtype,
cls.date >= time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - 86400 * int(days)))
)
).scalars().all())
)).scalars().all())
return query(db) if isinstance(db, Session) else run_legacy_sync_query(query)
@classmethod
@async_db_query
async def async_list_by_type(cls, db: AsyncSession, mtype: str, days: int):
result = await db.execute(
select(cls).filter(
async def async_list_by_type(cls, db: AsyncSession | str | None = None,
mtype: str | None = None, days: int = 7):
"""异步按类型查询最近时间窗内的订阅,兼容显式会话和旧插件无会话调用。"""
if not isinstance(db, AsyncSession):
mtype, db = db if mtype is None else mtype, None
async def query(session: AsyncSession):
"""在给定异步会话中执行时间窗订阅查询。"""
result = await session.execute(select(cls).where(
cls.type == mtype,
cls.date >= time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - 86400 * int(days)))
)
)
return list(result.scalars().all())
))
return list(result.scalars().all())
return await query(db) if isinstance(db, AsyncSession) else await run_legacy_async_query(query)
+38 -13
View File
@@ -4,7 +4,10 @@ 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 async_db_query, run_legacy_sync_query
from app.db.decorators import (
run_legacy_async_query,
run_legacy_sync_query,
)
class User(Base):
@@ -55,12 +58,23 @@ class User(Base):
return run_legacy_sync_query(query)
@classmethod
@async_db_query
async def async_get_by_name(cls, db: AsyncSession, name: str):
result = await db.execute(
select(cls).filter(cls.name == name)
)
return result.scalars().first()
async def async_get_by_name(
cls,
db: AsyncSession | str | None = None,
name: str | None = None,
):
"""异步按用户名查询,兼容显式会话和旧插件无会话调用。"""
if name is None and isinstance(db, str):
name, db = db, None
if name is None:
raise TypeError("name is required")
async def query(session: AsyncSession):
"""在给定异步会话中执行用户名查询。"""
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)
@classmethod
def get_by_id(cls, db: Session | int | None = None, user_id: int | None = None):
@@ -79,12 +93,23 @@ class User(Base):
return run_legacy_sync_query(query)
@classmethod
@async_db_query
async def async_get_by_id(cls, db: AsyncSession, user_id: int):
result = await db.execute(
select(cls).filter(cls.id == user_id)
)
return result.scalars().first()
async def async_get_by_id(
cls,
db: AsyncSession | int | None = None,
user_id: int | None = None,
):
"""异步按用户 ID 查询,兼容显式会话和旧插件无会话调用。"""
if user_id is None and isinstance(db, int):
user_id, db = db, None
if user_id is None:
raise TypeError("user_id is required")
async def query(session: AsyncSession):
"""在给定异步会话中执行用户 ID 查询。"""
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)
def delete_by_name(self, db: Session, name: str):
user = self.get_by_name(db, name)
+50 -11
View File
@@ -3,6 +3,7 @@ from typing import Optional, Union
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, select
from app.db.base import DbOper
from app.db.models.message import Message
@@ -105,7 +106,14 @@ class MessageOper(DbOper):
"""
分页获取消息记录
"""
return Message.list_by_page(self._db, page, count)
return self._execute_sync_query(
lambda session: list(session.execute(
select(Message)
.order_by(Message.reg_time.desc(), Message.id.desc())
.offset((page - 1) * count)
.limit(count)
).scalars().all())
)
def exists_by_source(self, source: str) -> bool:
"""
@@ -114,7 +122,11 @@ class MessageOper(DbOper):
:param source: 消息来源唯一标识
:return: 是否存在匹配记录
"""
return Message.exists_by_source(self._db, source)
return self._execute_sync_query(
lambda session: session.execute(
select(Message.id).where(Message.source == source).limit(1)
).scalars().first() is not None
)
async def async_list_by_page(
self, page: int = 1, count: int = 30
@@ -122,7 +134,17 @@ class MessageOper(DbOper):
"""
分页获取消息记录
"""
return await Message.async_list_by_page(self._db, page, count)
async def query(session: AsyncSession) -> list[Message]:
"""在调用方异步会话中执行消息分页查询。"""
result = await session.execute(
select(Message)
.order_by(Message.reg_time.desc(), Message.id.desc())
.offset((page - 1) * count)
.limit(count)
)
return list(result.scalars().all())
return await self._execute_async_query(query)
async def async_list_sent_by_page(
self,
@@ -135,11 +157,28 @@ class MessageOper(DbOper):
"""
分页获取系统发送的通知消息
"""
return await Message.async_list_sent_by_page(
self._db,
page,
count,
all_clear_before=all_clear_before,
system_clear_before=system_clear_before,
media_clear_before=media_clear_before,
)
async def query(session: AsyncSession) -> list[Message]:
"""在调用方异步会话中执行通知消息分页查询。"""
statement = select(Message).where(Message.action == 1)
if all_clear_before:
statement = statement.where(Message.reg_time > all_clear_before)
if system_clear_before:
statement = statement.where(or_(
and_(Message.image.isnot(None), Message.image != ""),
Message.reg_time > system_clear_before,
))
if media_clear_before:
statement = statement.where(or_(
Message.image.is_(None),
Message.image == "",
Message.reg_time > media_clear_before,
))
result = await session.execute(
statement
.order_by(Message.reg_time.desc(), Message.id.desc())
.offset((page - 1) * count)
.limit(count)
)
return list(result.scalars().all())
return await self._execute_async_query(query)
+86 -18
View File
@@ -1,7 +1,8 @@
from datetime import datetime
from typing import Any, List, Mapping, Tuple, Optional
from sqlalchemy import delete as sqlalchemy_delete
from sqlalchemy import delete as sqlalchemy_delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db.base import DbOper
@@ -11,6 +12,18 @@ from app.db.models.sitestatistic import SiteStatistic
from app.db.models.siteuserdata import SiteUserData
async def _async_first(session: AsyncSession, statement: Any) -> Optional[Site]:
"""执行异步站点查询并返回首条记录。"""
result = await session.execute(statement)
return result.scalars().first()
async def _async_all(session: AsyncSession, statement: Any) -> list[Site]:
"""执行异步站点查询并返回稳定列表。"""
result = await session.execute(statement)
return list(result.scalars().all())
class SiteOper(DbOper):
"""
站点管理
@@ -21,7 +34,7 @@ class SiteOper(DbOper):
新增站点
"""
site = Site(**kwargs)
if not site.get_by_domain(self._db, kwargs.get("domain")):
if not self.get_by_domain(kwargs.get("domain")):
self._stage_create(site)
return True, "新增站点成功"
return False, "站点已存在"
@@ -30,13 +43,22 @@ class SiteOper(DbOper):
"""
查询单个站点
"""
return Site.get(self._db, sid)
return self._execute_sync_query(
lambda session: session.execute(
select(Site).where(Site.id == sid)
).scalars().first()
)
async def async_get(self, sid: int) -> Optional[Site]:
"""
异步查询单个站点
"""
return await Site.async_get(self._db, sid)
return await self._execute_async_query(
lambda session: _async_first(
session,
select(Site).where(Site.id == sid),
)
)
async def get_by_id(self, site_id: int) -> Optional[Site]:
"""读取站点写用例需要的目标站点。"""
@@ -80,35 +102,59 @@ class SiteOper(DbOper):
"""
获取站点列表
"""
return Site.list(self._db)
return self._execute_sync_query(
lambda session: list(session.execute(select(Site)).scalars().all())
)
async def async_list(self) -> List[Site]:
"""
异步获取站点列表
"""
return await Site.async_list(self._db)
return await self._execute_async_query(
lambda session: _async_all(session, select(Site))
)
async def async_list_order_by_pri(self) -> List[Site]:
"""异步按优先级获取站点,供站点查询应用服务使用。"""
return await Site.async_list_order_by_pri(self._db)
return await self._execute_async_query(
lambda session: _async_all(
session,
select(Site).order_by(Site.pri),
)
)
def list_order_by_pri(self) -> List[Site]:
"""
获取站点列表
"""
return Site.list_order_by_pri(self._db)
return self._execute_sync_query(
lambda session: list(
session.execute(select(Site).order_by(Site.pri)).scalars().all()
)
)
def list_active(self) -> List[Site]:
"""
按状态获取站点列表
"""
return Site.get_actives(self._db)
return self._execute_sync_query(
lambda session: list(
session.execute(
select(Site).where(Site.is_active.is_(True))
).scalars().all()
)
)
async def async_list_active(self) -> List[Site]:
"""
异步按状态获取站点列表
"""
return await Site.async_get_actives(self._db)
return await self._execute_async_query(
lambda session: _async_all(
session,
select(Site).where(Site.is_active.is_(True)),
)
)
def delete(self, sid: int):
"""
@@ -128,7 +174,7 @@ class SiteOper(DbOper):
"""
更新站点
"""
site = Site.get(self._db, sid)
site = self.get(sid)
if not site:
return None
self._stage_update(site, payload)
@@ -147,37 +193,59 @@ class SiteOper(DbOper):
"""
按域名获取站点
"""
return Site.get_by_domain(self._db, domain)
return self._execute_sync_query(
lambda session: session.execute(
select(Site).where(Site.domain == domain)
).scalars().first()
)
async def async_get_by_domain(self, domain: str) -> Optional[Site]:
"""
异步按域名获取站点
"""
return await Site.async_get_by_domain(self._db, domain)
return await self._execute_async_query(
lambda session: _async_first(
session,
select(Site).where(Site.domain == domain),
)
)
async def async_get_by_name(self, name: str) -> Optional[Site]:
"""
异步按名称获取站点
"""
return await Site.async_get_by_name(self._db, name)
return await self._execute_async_query(
lambda session: _async_first(
session,
select(Site).where(Site.name == name),
)
)
def get_domains_by_ids(self, ids: List[int]) -> List[Optional[str]]:
"""
按ID获取站点域名
"""
return Site.get_domains_by_ids(self._db, ids)
if not ids:
return []
return self._execute_sync_query(
lambda session: list(
session.execute(
select(Site.domain).where(Site.id.in_(ids))
).scalars().all()
)
)
def exists(self, domain: str) -> bool:
"""
判断站点是否存在
"""
return Site.get_by_domain(self._db, domain) is not None
return self.get_by_domain(domain) is not None
def update_cookie(self, domain: str, cookies: str) -> Tuple[bool, str]:
"""
更新站点Cookie
"""
site = Site.get_by_domain(self._db, domain)
site = self.get_by_domain(domain)
if not site:
return False, "站点不存在"
self._stage_update(site, {
@@ -189,7 +257,7 @@ class SiteOper(DbOper):
"""
更新站点rss
"""
site = Site.get_by_domain(self._db, domain)
site = self.get_by_domain(domain)
if not site:
return False, "站点不存在"
self._stage_update(site, {
+127 -47
View File
@@ -297,13 +297,24 @@ class SubscribeOper(DbOper):
"""
获取订阅
"""
return Subscribe.get(self._db, rid=sid)
return self._execute_sync_query(
lambda session: session.execute(
select(Subscribe).where(Subscribe.id == sid)
).scalars().first()
)
async def async_get(self, sid: int) -> Optional[Subscribe]:
"""
获取订阅
"""
return await Subscribe.async_get(self._db, rid=sid)
if self._db is not None and not isinstance(self._db, (Session, AsyncSession)):
# 保留旧测试替身与插件注入对象对 Model ABI 的兼容入口。
return await Subscribe.async_get(self._db, rid=sid)
async def query(session: AsyncSession) -> Optional[Subscribe]:
"""在调用方异步会话中执行订阅主键查询。"""
result = await session.execute(select(Subscribe).where(Subscribe.id == sid))
return result.scalars().first()
return await self._execute_async_query(query)
async def async_list_by_media_identity(
self,
@@ -312,12 +323,18 @@ class SubscribeOper(DbOper):
music_type: Optional[str] = None,
) -> List[Subscribe]:
"""异步按规范媒体身份读取订阅。"""
return await Subscribe.async_list_by_media_identity(
self._db,
media_source=media_source,
media_id=media_id,
music_type=music_type,
)
async def query(session: AsyncSession) -> List[Subscribe]:
"""在调用方异步会话中执行媒体身份列表查询。"""
condition = Subscribe._identity_condition( # pylint: disable=protected-access
media_source, media_id, music_type
)
if condition is None:
return []
result = await session.execute(select(Subscribe).where(condition))
return list(result.scalars().all())
if isinstance(self._db, AsyncSession):
return await query(self._db)
return await self._execute_async_query(query)
def list_by_media_identity(
self,
@@ -326,12 +343,15 @@ class SubscribeOper(DbOper):
music_type: Optional[str] = None,
) -> List[Subscribe]:
"""同步按规范媒体身份读取订阅。"""
return Subscribe.list_by_media_identity(
self._db,
media_source=media_source,
media_id=media_id,
music_type=music_type,
)
def query(session: Session) -> List[Subscribe]:
"""在调用方同步会话中执行媒体身份列表查询。"""
condition = Subscribe._identity_condition( # pylint: disable=protected-access
media_source, media_id, music_type
)
if condition is None:
return []
return list(session.execute(select(Subscribe).where(condition)).scalars().all())
return self._execute_sync_query(query)
async def get_candidate(
self,
@@ -360,11 +380,8 @@ class SubscribeOper(DbOper):
music_type: Optional[str],
) -> List[SubscribeDeletionCandidate]:
"""按媒体身份读取去重后的订阅删除快照。"""
subscribes = await Subscribe.async_list_by_media_identity(
self._db,
media_source=media_source,
media_id=media_id,
music_type=music_type,
subscribes = await self.async_list_by_media_identity(
media_source, media_id, music_type
)
candidates = []
seen_ids = set()
@@ -395,11 +412,7 @@ class SubscribeOper(DbOper):
async def list_search_ids(self, username: str, state: str) -> List[int]:
"""返回用户指定状态的订阅编号,不向应用用例暴露 ORM 列表。"""
subscribes = await Subscribe.async_list_by_username(
self._db,
username,
state=state,
)
subscribes = await self.async_list_by_username(username, state=state)
return [subscribe.id for subscribe in subscribes if subscribe.id]
def get_by(
@@ -410,9 +423,18 @@ class SubscribeOper(DbOper):
"""
根据条件查询订阅
"""
return Subscribe.get_by(
self._db, type, media_source, media_id, season, music_type,
)
def query(session: Session) -> Optional[Subscribe]:
"""在调用方同步会话中执行类型媒体查询。"""
condition = Subscribe._identity_condition( # pylint: disable=protected-access
media_source, media_id, music_type
)
if condition is None:
return None
statement = select(Subscribe).where(condition, Subscribe.type == type)
if season is not None:
statement = statement.where(Subscribe.season == season)
return session.execute(statement).scalars().first()
return self._execute_sync_query(query)
async def async_get_by(
self, type: str, media_source: MediaSource, media_id: str,
@@ -422,25 +444,55 @@ class SubscribeOper(DbOper):
"""
根据条件查询订阅
"""
return await Subscribe.async_get_by(
self._db, type, media_source, media_id, season, music_type,
)
async def query(session: AsyncSession) -> Optional[Subscribe]:
"""在调用方异步会话中执行类型媒体查询。"""
condition = Subscribe._identity_condition( # pylint: disable=protected-access
media_source, media_id, music_type
)
if condition is None:
return None
statement = select(Subscribe).where(condition, Subscribe.type == type)
if season is not None:
statement = statement.where(Subscribe.season == season)
result = await session.execute(statement)
return result.scalars().first()
return await self._execute_async_query(query)
def list(self, state: Optional[str] = None) -> List[Subscribe]:
"""
获取订阅列表
"""
if state:
return Subscribe.get_by_state(self._db, state)
return Subscribe.list(self._db)
return self._execute_sync_query(
lambda session: list(session.execute(
select(Subscribe).where(Subscribe.state.in_(state.split(',')))
).scalars().all())
)
return self._execute_sync_query(
lambda session: list(session.execute(select(Subscribe)).scalars().all())
)
async def async_list(self, state: Optional[str] = None) -> List[Subscribe]:
"""
异步获取订阅列表
"""
if self._db is not None and not isinstance(self._db, (Session, AsyncSession)):
if state:
return await Subscribe.async_get_by_state(self._db, state)
return await Subscribe.async_list(self._db)
if state:
return await Subscribe.async_get_by_state(self._db, state)
return await Subscribe.async_list(self._db)
async def query(session: AsyncSession) -> List[Subscribe]:
"""在调用方异步会话中执行状态列表查询。"""
result = await session.execute(
select(Subscribe).where(Subscribe.state.in_(state.split(',')))
)
return list(result.scalars().all())
return await self._execute_async_query(query)
async def query_all(session: AsyncSession) -> List[Subscribe]:
"""在调用方异步会话中执行全量订阅查询。"""
result = await session.execute(select(Subscribe))
return list(result.scalars().all())
return await self._execute_async_query(query_all)
async def async_list_by_username(
self,
@@ -449,12 +501,20 @@ class SubscribeOper(DbOper):
mtype: Optional[str] = None,
) -> List[Subscribe]:
"""异步按用户获取订阅。"""
return await Subscribe.async_list_by_username(
self._db,
username=username,
state=state,
mtype=mtype,
)
if self._db is not None and not isinstance(self._db, (Session, AsyncSession)):
return await Subscribe.async_list_by_username(
self._db, username=username, state=state, mtype=mtype
)
async def query(session: AsyncSession) -> List[Subscribe]:
"""在调用方异步会话中执行用户筛选查询。"""
statement = select(Subscribe).where(Subscribe.username == username)
if state:
statement = statement.where(Subscribe.state == state)
if mtype:
statement = statement.where(Subscribe.type == mtype)
result = await session.execute(statement)
return list(result.scalars().all())
return await self._execute_async_query(query)
async def async_list_by_title(
self,
@@ -462,11 +522,14 @@ class SubscribeOper(DbOper):
season: Optional[int] = None,
) -> List[Subscribe]:
"""异步按标题获取订阅,供旧查询测试和迁移调用兼容。"""
return await Subscribe.async_list_by_title(
self._db,
title=title,
season=season,
)
async def query(session: AsyncSession) -> List[Subscribe]:
"""在调用方异步会话中执行标题列表查询。"""
statement = select(Subscribe).where(Subscribe.name == title)
if season is not None:
statement = statement.where(Subscribe.season == season)
result = await session.execute(statement)
return list(result.scalars().all())
return await self._execute_async_query(query)
def delete(self, sid: int):
"""
@@ -535,13 +598,30 @@ class SubscribeOper(DbOper):
"""
获取指定用户的订阅
"""
return Subscribe.list_by_username(self._db, username=username, state=state, mtype=mtype)
def query(session: Session) -> List[Subscribe]:
"""在调用方同步会话中执行用户筛选查询。"""
statement = select(Subscribe).where(Subscribe.username == username)
if state:
statement = statement.where(Subscribe.state == state)
if mtype:
statement = statement.where(Subscribe.type == mtype)
return list(session.execute(statement).scalars().all())
return self._execute_sync_query(query)
def list_by_type(self, mtype: str, days: int = 7) -> List[Subscribe]:
"""
获取指定类型的订阅
"""
return Subscribe.list_by_type(self._db, mtype=mtype, days=days)
def query(session: Session) -> List[Subscribe]:
"""在调用方同步会话中执行时间窗订阅查询。"""
cutoff = time.strftime(
"%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - 86400 * int(days)),
)
return list(session.execute(select(Subscribe).where(
Subscribe.type == mtype, Subscribe.date >= cutoff
)).scalars().all())
return self._execute_sync_query(query)
def add_history(self, **kwargs):
"""
+13 -2
View File
@@ -12,6 +12,7 @@ runtime 兼容映射指向 SDK 薄门面;canonical 数据访问模块仍只依
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.base import DbOper
from app.db.models.user import User
@@ -103,13 +104,23 @@ class UserOper(DbOper):
"""
异步根据用户名获取用户
"""
return await User.async_get_by_name(self._db, name)
async def query(session: AsyncSession) -> Optional[User]:
"""在调用方异步会话中执行用户名查询。"""
result = await session.execute(select(User).where(User.name == name))
return result.scalars().first()
return await self._execute_async_query(query)
async def async_get_by_id(self, user_id: int) -> Optional[User]:
"""
异步根据用户 ID 获取用户
"""
return await User.async_get_by_id(self._db, user_id)
async def query(session: AsyncSession) -> Optional[User]:
"""在调用方异步会话中执行用户 ID 查询。"""
result = await session.execute(select(User).where(User.id == user_id))
return result.scalars().first()
return await self._execute_async_query(query)
def get_permissions(self, name: str) -> dict:
"""
+29 -2
View File
@@ -17,6 +17,16 @@ class ModuleResultAggregation(StrEnum):
ORDERED_LIST_MERGE = "ordered_list_merge"
class ModuleResultShape(StrEnum):
"""描述模块 provider 返回值的基础 Python 形状。"""
ANY = "any"
LIST = "list"
STRING = "string"
MAPPING = "mapping"
BOOLEAN = "boolean"
class ModuleExecutionMode(StrEnum):
"""描述 provider 可以采用的执行形态。"""
@@ -45,6 +55,7 @@ class ModuleMethodContract:
version: int = 1
input_contract: str = "legacy_args"
result_contract: str = "Any"
result_shape: ModuleResultShape = ModuleResultShape.ANY
required_parameters: tuple[str, ...] = ()
execution: ModuleExecutionMode = ModuleExecutionMode.SYNC_OR_ASYNC
timeout_policy: str = "caller_budget"
@@ -74,11 +85,11 @@ _METHOD_CONTRACTS = {
"media_category": ModuleMethodContract(family="media-recognition", input_contract="MediaCategoryRequest", result_contract="CategoryConfig | None"),
"mediaserver_items": ModuleMethodContract(family="media-server", input_contract="MediaServerItemsRequest", result_contract="list[MediaServerItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("server", "library_id", "start_index", "limit")),
"mediaserver_iteminfo": ModuleMethodContract(family="media-server", input_contract="MediaServerItemRequest", result_contract="MediaServerItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "item_id")),
"mediaserver_play_url": ModuleMethodContract(family="media-server", input_contract="MediaServerPlayRequest", result_contract="str | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "item_id")),
"mediaserver_play_url": ModuleMethodContract(family="media-server", input_contract="MediaServerPlayRequest", result_contract="str | None", result_shape=ModuleResultShape.STRING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "item_id")),
"mediaserver_tv_episodes": ModuleMethodContract(family="media-server", input_contract="MediaServerEpisodesRequest", result_contract="list[MediaServerPlayItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("server", "item_id")),
"download_file": ModuleMethodContract(family="storage", input_contract="StorageDownloadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "path")),
"upload_file": ModuleMethodContract(family="storage", input_contract="StorageUploadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "path", "new_name")),
"list_files": ModuleMethodContract(family="storage", input_contract="StorageListRequest", result_contract="list[FileItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("fileitem", "recursion")),
"list_files": ModuleMethodContract(family="storage", input_contract="StorageListRequest", result_contract="list[FileItem]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("fileitem", "recursion")),
"get_file_item": ModuleMethodContract(family="storage", input_contract="StorageItemRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "path")),
"get_folder": ModuleMethodContract(family="storage", input_contract="StorageFolderRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "path")),
"get_parent_item": ModuleMethodContract(family="storage", input_contract="StorageParentRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem",)),
@@ -455,6 +466,22 @@ def diagnose_module_callable(method: str, callback: Callable[..., Any]) -> tuple
return tuple(f"missing-parameter:{name}" for name in missing)
def diagnose_module_result(method: str, result: Any) -> tuple[str, ...]:
"""诊断显式模块结果的基础形状,兼容阶段只告警而不改写返回值。"""
shape = get_module_method_contract(method).result_shape
if shape is ModuleResultShape.ANY or result is None:
return ()
matches = {
ModuleResultShape.LIST: isinstance(result, list),
ModuleResultShape.STRING: isinstance(result, str),
ModuleResultShape.MAPPING: isinstance(result, dict),
ModuleResultShape.BOOLEAN: isinstance(result, bool),
}
if matches.get(shape, True):
return ()
return (f"unexpected-result:{shape.value}:{type(result).__name__}",)
def list_explicit_module_contracts() -> dict[str, ModuleMethodContract]:
"""返回显式方法清单的副本,供架构基线和 SDK 文档使用。"""
return dict(_METHOD_CONTRACTS)
@@ -12,6 +12,7 @@ from app.runtime.log import logger
from app.runtime.observability import observe_duration, record_metric
from app.runtime.extensions.module.contracts import (
diagnose_module_callable,
diagnose_module_result,
get_module_method_contract,
is_explicit_module_method,
)
@@ -134,8 +135,10 @@ class ModuleInvocationDispatcher:
logger.info("请求插件 %s 执行:%s ...", plugin_name, method)
if self.is_valid_empty(result):
result = func(*args, **kwargs)
self._diagnose_result(method, result, "plugin")
elif isinstance(result, list):
temp = func(*args, **kwargs)
self._diagnose_result(method, temp, "plugin")
if isinstance(temp, list):
result.extend(temp)
else:
@@ -187,8 +190,10 @@ class ModuleInvocationDispatcher:
logger.info("请求插件 %s 执行:%s ...", plugin_name, method)
if self.is_valid_empty(result):
result = await self._async_call(func, *args, **kwargs)
self._diagnose_result(method, result, "plugin")
elif isinstance(result, list):
temp = await self._async_call(func, *args, **kwargs)
self._diagnose_result(method, temp, "plugin")
if isinstance(temp, list):
result.extend(temp)
else:
@@ -238,10 +243,13 @@ class ModuleInvocationDispatcher:
self._diagnose_callable(method, func, f"宿主模块 {module_id}")
if self.is_valid_empty(result):
result = func(*args, **kwargs)
self._diagnose_result(method, result, "system")
elif ObjectUtils.check_signature(func, result):
result = func(result)
self._diagnose_result(method, result, "system")
elif isinstance(result, list):
temp = func(*args, **kwargs)
self._diagnose_result(method, temp, "system")
if isinstance(temp, list):
result.extend(temp)
else:
@@ -291,10 +299,13 @@ class ModuleInvocationDispatcher:
self._diagnose_callable(method, func, f"宿主模块 {module_id}")
if self.is_valid_empty(result):
result = await self._async_call(func, *args, **kwargs)
self._diagnose_result(method, result, "system")
elif ObjectUtils.check_signature(func, result):
result = await self._async_call(func, result)
self._diagnose_result(method, result, "system")
elif isinstance(result, list):
temp = await self._async_call(func, *args, **kwargs)
self._diagnose_result(method, temp, "system")
if isinstance(temp, list):
result.extend(temp)
else:
@@ -360,6 +371,24 @@ class ModuleInvocationDispatcher:
", ".join(problems),
)
@staticmethod
def _diagnose_result(method: str, result: Any, provider_type: str) -> None:
"""记录 provider 结果形状偏差,保持旧插件返回值原样继续执行。"""
problems = diagnose_module_result(method, result)
if problems:
record_metric(
"module.contract.result_mismatch",
method=method,
provider_type=provider_type,
problem=problems[0],
)
logger.warning(
"模块方法 %s%s provider 返回值与契约不一致:%s;当前仅诊断",
method,
provider_type,
", ".join(problems),
)
async def _async_call(
self,
func: Callable[..., Any],
+132
View File
@@ -0,0 +1,132 @@
"""进程内后台任务登记与生命周期收口。"""
from __future__ import annotations
import asyncio
from collections.abc import Coroutine
from dataclasses import dataclass
from functools import partial
from typing import Any, Callable
@dataclass(frozen=True, slots=True)
class TaskRecord:
"""记录一个后台任务的所有者,便于关停阶段按责任域收口。"""
owner: str
task: asyncio.Task[Any]
cancel_on_shutdown: bool
class TaskRegistry:
"""管理由宿主创建的进程内后台任务,并提供统一取消与等待入口。"""
def __init__(self) -> None:
"""初始化空任务登记表。"""
self._records: dict[asyncio.Task[Any], TaskRecord] = {}
self._accepting = True
@property
def records(self) -> tuple[TaskRecord, ...]:
"""返回当前仍未完成的任务快照。"""
return tuple(
record for record in self._records.values() if not record.task.done()
)
def create(
self,
coroutine: Coroutine[Any, Any, Any],
*,
owner: str,
cancel_on_shutdown: bool = True,
) -> asyncio.Task[Any]:
"""创建并登记后台任务,任务完成后自动从登记表移除。"""
if not self._accepting:
coroutine.close()
raise RuntimeError("后台任务登记器正在关闭,不能再创建新任务")
task = asyncio.create_task(coroutine, name=owner)
self.register(
task,
owner=owner,
cancel_on_shutdown=cancel_on_shutdown,
)
return task
def create_sync(
self,
function: Callable[..., Any],
*args: Any,
owner: str,
**kwargs: Any,
) -> asyncio.Task[Any]:
"""在线程池执行同步后台函数并登记其异步生命周期。"""
return self.create(
asyncio.to_thread(partial(function, *args, **kwargs)),
owner=owner,
cancel_on_shutdown=False,
)
def register(
self,
task: asyncio.Task[Any],
*,
owner: str,
cancel_on_shutdown: bool = True,
) -> asyncio.Task[Any]:
"""登记已有任务并绑定责任域。"""
if not self._accepting:
task.cancel()
raise RuntimeError("后台任务登记器正在关闭,不能再登记新任务")
task.set_name(owner)
self._records[task] = TaskRecord(
owner=owner,
task=task,
cancel_on_shutdown=cancel_on_shutdown,
)
task.add_done_callback(self._discard)
return task
def _discard(self, task: asyncio.Task[Any]) -> None:
"""移除已结束任务,并把未处理异常交给事件循环统一报告。"""
record = self._records.pop(task, None)
if task.cancelled():
return
exception = task.exception()
if exception is not None:
task.get_loop().call_exception_handler(
{
"message": "MoviePilot 后台任务执行失败",
"exception": exception,
"task": task,
"owner": record.owner if record else task.get_name(),
}
)
async def shutdown(self, *, timeout_seconds: float = 10.0) -> None:
"""取消并等待全部登记任务,超时后放弃等待但不影响其他关闭步骤。"""
self._accepting = False
records = self.records
tasks = [record.task for record in records]
for record in records:
if record.cancel_on_shutdown:
record.task.cancel()
if tasks:
_, pending = await asyncio.wait(tasks, timeout=timeout_seconds)
for task in pending:
task.cancel()
self._records.clear()
_default_registry = TaskRegistry()
_runtime_registry: TaskRegistry | None = None
def configure_task_registry(registry: TaskRegistry | None) -> None:
"""由启动组合根发布当前 lifespan 的任务登记器。"""
global _runtime_registry
_runtime_registry = registry
def get_task_registry() -> TaskRegistry:
"""返回当前宿主任务登记器,未启动完整 lifespan 时保留测试兼容回退。"""
return _runtime_registry or _default_registry
+4 -1
View File
@@ -1,9 +1,11 @@
"""宿主启动阶段构建的类型化运行时上下文。"""
from collections.abc import AsyncGenerator, Callable, Generator
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Protocol
from app.runtime.tasks import TaskRegistry
from app.application.messaging.chat import (
AsyncAgentChatRepository,
AgentChatPersistenceService,
@@ -193,3 +195,4 @@ class HostRuntime:
workflow: WorkflowRuntime
configuration: RuntimeConfiguration
settings: RuntimeSettingsService
tasks: TaskRegistry = field(default_factory=TaskRegistry)
+34
View File
@@ -32,6 +32,7 @@ from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat()
from app.runtime.health import get_application_health
from app.runtime.topology import validate_process_topology
from app.runtime.tasks import TaskRegistry, configure_task_registry
from app.adapters.external.server import MoviePilotServerHelper
from app.runtime.state import SystemHelper
from app.runtime.log import logger, LoggerManager
@@ -165,6 +166,24 @@ async def initialize_modules_component(app: FastAPI) -> None:
app.state.host_runtime = runtime
def initialize_task_registry(app: FastAPI) -> None:
"""创建当前 lifespan 独占的后台任务登记器。"""
task_registry = TaskRegistry()
app.state.task_registry = task_registry
configure_task_registry(task_registry)
async def stop_task_registry(app: FastAPI) -> None:
"""停止接收新后台任务,并取消、等待当前 lifespan 的存量任务。"""
task_registry = getattr(app.state, "task_registry", None)
try:
if isinstance(task_registry, TaskRegistry):
await task_registry.shutdown(timeout_seconds=30.0)
finally:
configure_task_registry(None)
app.state.task_registry = None
def prepare_plugin_restore() -> None:
"""先装配插件外部系统服务,再恢复插件及其依赖。"""
configure_plugin_services()
@@ -188,6 +207,15 @@ def prepare_database_component(app: FastAPI) -> None:
def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
"""按现有顺序构建应用组件清单,回调在每次 lifespan 启动时重新绑定。"""
return (
LifecycleComponent(
name="后台任务登记器",
start=lambda: initialize_task_registry(app),
stop=lambda: stop_task_registry(app),
start_order=5,
stop_order=5,
start_timeout_seconds=30,
stop_timeout_seconds=60,
),
LifecycleComponent(
name="数据库准备",
start=lambda: prepare_database_component(app),
@@ -371,10 +399,16 @@ async def lifespan(app: FastAPI):
sync_plugins_task = asyncio.create_task(
run_startup_step("插件同步与启动收尾", init_extra)
)
task_registry = app.state.task_registry
task_registry.register(sync_plugins_task, owner="startup.plugin_settlement")
health.mark_ready()
except BaseException:
# Uvicorn 在 lifespan 抛错时不会开始接流量;状态仍需供嵌入式入口和测试诊断。
health.mark_failed()
try:
await stop_task_registry(app)
except Exception as cleanup_error:
logger.error(f"启动失败后的后台任务清理失败:{cleanup_error}")
raise
try:
# 在此处 yield,表示应用已经启动,控制权交回 FastAPI 主事件循环
+2
View File
@@ -171,6 +171,7 @@ from app.runtime.extensions.service_config import (
ServiceConfigHelper,
configure_service_config_reader,
)
from app.runtime.tasks import get_task_registry
_database_worker: DatabaseWorker | None = None
@@ -719,6 +720,7 @@ async def init_modules() -> HostRuntime:
),
configuration=runtime_configuration,
settings=runtime_settings,
tasks=get_task_registry(),
)
configure_runtime_configuration(host_runtime.configuration)
configure_runtime_settings(host_runtime.settings)