mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
refactor: 推进后端分层架构治理
This commit is contained in:
+54
-78
@@ -1,89 +1,65 @@
|
||||
"""数据库包的惰性兼容导出入口。
|
||||
|
||||
具体实现位于 ``base``、``decorators``、``engine`` 与 ``session``。包入口只维护
|
||||
公开符号到所有者模块的映射,避免数据库子模块为了导入同包实现而回流到一个会主动
|
||||
导入全部实现的根模块。旧的 ``from app.db import X`` 路径继续可用。
|
||||
"""
|
||||
数据库包入口。
|
||||
|
||||
本模块只做符号再导出,不承载实现——具体职责分布在:
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
- diagnostics 驱动错误的统一分类与日志
|
||||
- engine 引擎构建、连接额度核算
|
||||
- session 会话获取、异步连接池与配额
|
||||
- decorators 同步/异步事务装饰器
|
||||
- base ORM 基类与数据访问基类
|
||||
- models 表结构声明,一实体一文件
|
||||
- oper 数据访问实现,与 models 同名文件一一对应
|
||||
|
||||
历史上这些代码全部堆在本文件里(782 行),既让包入口承担了实现职责、
|
||||
使依赖图难以理清,也让「import 即建立数据库连接」这一副作用被固化下来。
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.db.base import Base, DbOper, execute_dml, get_id_column
|
||||
from app.db.decorators import async_db_query, async_db_update, db_query, db_update
|
||||
from app.db.engine import (
|
||||
check_connection_budget,
|
||||
connection_budget,
|
||||
get_engine,
|
||||
get_global_async_engine,
|
||||
)
|
||||
from app.db.session import (
|
||||
AsyncSessionFactory,
|
||||
ScopedSession,
|
||||
SessionFactory,
|
||||
async_session_scope,
|
||||
close_database,
|
||||
get_async_db,
|
||||
get_async_engine,
|
||||
get_async_session_factory,
|
||||
get_db,
|
||||
get_scoped_session,
|
||||
get_session_factory,
|
||||
)
|
||||
|
||||
# ==================== 对外契约的分层 ====================
|
||||
# 下方 __all__ 是本包**对外承诺**的那一层,仓库外的插件只应依赖其中的名字:
|
||||
#
|
||||
# - 数据访问:继承 DbOper 子类(插件基类已备好 self.plugindata / self.systemconfig),
|
||||
# 或给自己的函数套 db_query / db_update / async_db_query / async_db_update 装饰器。
|
||||
# 会话的获取、提交、回滚、释放全部由装饰器收口。
|
||||
# - 引擎:Engine / AsyncEngine 保留在契约内。建表、Alembic 迁移、连接诊断这些用途
|
||||
# 确实需要引擎对象本身,装饰器覆盖不到,仓库外拿它是正当的。
|
||||
#
|
||||
# SessionFactory / AsyncSessionFactory / ScopedSession 三个名字**不在**契约内,已从
|
||||
# __all__ 移除,降级为内部实现细节。它们建出来的是绕过上述装饰器的裸会话——没有提交、
|
||||
# 没有回滚、没有释放,谁建谁自己兜底,本身就是误用的形状。仓库内确有几处直接
|
||||
# `from app.db import SessionFactory`(scheduler、postgresql 模块、Alembic 迁移脚本),
|
||||
# 那是包内部的既有用法,直接导入不受 __all__ 约束,照常可用。
|
||||
# 若确实需要真正的工厂对象(而非 `X()` 取一个会话),用 get_session_factory() /
|
||||
# get_scoped_session() / get_async_session_factory()——转发函数上没有 sessionmaker
|
||||
# 与 scoped_session 的实例接口(.remove() / .configure() / .begin() 等)。
|
||||
#
|
||||
# 实现上,三个工厂名字本身就是转发函数(见 session 模块),直接再导出即可——导入它们
|
||||
# 不会碰引擎。Engine / AsyncEngine 则不同:调用方拿到的必须是引擎**对象**而非函数,
|
||||
# 所以只能靠模块级 __getattr__ 在取属性时才创建。
|
||||
#
|
||||
# 注意这意味着 `from app.db import Engine` 仍会在 import 期把引擎建出来——那是调用方
|
||||
# 自己选的时机。本包自身及仓库内代码一律用 get_engine(),所以 `import app.db` 不连库。
|
||||
if TYPE_CHECKING:
|
||||
# 只为静态检查声明这两个名字:运行期由下方 __getattr__ 解析,模块 __dict__ 里并不存在,
|
||||
# 类型检查器无从知道它们属于本模块(__all__ 里的它们会被报成 reportUnsupportedDunderAll)。
|
||||
# 这里同时把类型钉准,比 __getattr__ 的 Any 更有用:调用方拿到的确实是这两类引擎。
|
||||
from sqlalchemy.engine import Engine as _SyncEngine
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine as _SaAsyncEngine
|
||||
|
||||
Engine: _SyncEngine
|
||||
AsyncEngine: _SaAsyncEngine
|
||||
_EXPORTS = {
|
||||
"AsyncSessionFactory": ("app.db.session", "AsyncSessionFactory"),
|
||||
"Base": ("app.db.base", "Base"),
|
||||
"DbOper": ("app.db.base", "DbOper"),
|
||||
"ScopedSession": ("app.db.session", "ScopedSession"),
|
||||
"SessionFactory": ("app.db.session", "SessionFactory"),
|
||||
"async_db_query": ("app.db.decorators", "async_db_query"),
|
||||
"async_db_update": ("app.db.decorators", "async_db_update"),
|
||||
"async_session_scope": ("app.db.session", "async_session_scope"),
|
||||
"check_connection_budget": ("app.db.engine", "check_connection_budget"),
|
||||
"close_database": ("app.db.session", "close_database"),
|
||||
"connection_budget": ("app.db.engine", "connection_budget"),
|
||||
"db_query": ("app.db.decorators", "db_query"),
|
||||
"db_update": ("app.db.decorators", "db_update"),
|
||||
"execute_dml": ("app.db.base", "execute_dml"),
|
||||
"get_async_db": ("app.db.session", "get_async_db"),
|
||||
"get_async_engine": ("app.db.session", "get_async_engine"),
|
||||
"get_async_session_factory": (
|
||||
"app.db.session",
|
||||
"get_async_session_factory",
|
||||
),
|
||||
"get_db": ("app.db.session", "get_db"),
|
||||
"get_engine": ("app.db.engine", "get_engine"),
|
||||
"get_global_async_engine": ("app.db.engine", "get_global_async_engine"),
|
||||
"get_id_column": ("app.db.base", "get_id_column"),
|
||||
"get_scoped_session": ("app.db.session", "get_scoped_session"),
|
||||
"get_session_factory": ("app.db.session", "get_session_factory"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""
|
||||
惰性解析 Engine / AsyncEngine 两个旧名字,保持仓库外插件的导入路径可用。
|
||||
:param name: 属性名
|
||||
:return: 对应的引擎
|
||||
"""
|
||||
"""按需解析旧数据库导出,并缓存到包命名空间。"""
|
||||
if name == "Engine":
|
||||
return get_engine()
|
||||
if name == "AsyncEngine":
|
||||
return get_global_async_engine()
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
return getattr(import_module("app.db.engine"), "get_engine")()
|
||||
elif name == "AsyncEngine":
|
||||
return getattr(
|
||||
import_module("app.db.engine"),
|
||||
"get_global_async_engine",
|
||||
)()
|
||||
elif name in _EXPORTS:
|
||||
module_name, symbol_name = _EXPORTS[name]
|
||||
value = getattr(import_module(module_name), symbol_name)
|
||||
else:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
"""向交互式工具暴露兼容符号,同时保持实现模块惰性。"""
|
||||
return sorted({*globals(), *_EXPORTS, "Engine", "AsyncEngine"})
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""数据维护用例的 SQLAlchemy 适配器。"""
|
||||
|
||||
from typing import Any, Callable, ContextManager
|
||||
|
||||
from app.db.models.downloadfailure import DownloadFailure
|
||||
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
|
||||
|
||||
|
||||
class DatabaseCleanupRepository:
|
||||
"""把应用层清理端口映射到现有模型批量删除方法。"""
|
||||
|
||||
def __init__(self, *, session_factory: Callable[[], ContextManager[Any]]) -> None:
|
||||
"""保存会话工厂,使测试和不同数据库后端可以显式注入。"""
|
||||
self._session_factory = session_factory
|
||||
|
||||
def session(self) -> ContextManager[Any]:
|
||||
"""创建一次维护运行共用的数据库会话。"""
|
||||
return self._session_factory()
|
||||
|
||||
@staticmethod
|
||||
def delete_messages(db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除早于截止时间的消息。"""
|
||||
return Message.delete_before(db=db, before_time=cutoff, limit=limit)
|
||||
|
||||
@staticmethod
|
||||
def delete_download_history(db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除早于截止时间的下载历史。"""
|
||||
return DownloadHistory.delete_before(
|
||||
db=db,
|
||||
before_time=cutoff,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_download_orphans(db: Any, limit: int) -> int:
|
||||
"""删除已经失去父下载历史的文件记录。"""
|
||||
return DownloadFiles.delete_orphans(db=db, limit=limit)
|
||||
|
||||
@staticmethod
|
||||
def delete_site_userdata(db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除早于截止日期的站点用户数据快照。"""
|
||||
return SiteUserData.delete_before(db=db, before_day=cutoff, limit=limit)
|
||||
|
||||
@staticmethod
|
||||
def delete_transfer_history(db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除早于截止时间的整理历史。"""
|
||||
return TransferHistory.delete_before(
|
||||
db=db,
|
||||
before_time=cutoff,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_download_failures(db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除已经过期的下载失败冷却记录。"""
|
||||
return DownloadFailure.delete_expired(
|
||||
db=db,
|
||||
before_time=cutoff,
|
||||
limit=limit,
|
||||
)
|
||||
+59
-27
@@ -1,28 +1,60 @@
|
||||
"""
|
||||
ORM 模型。
|
||||
"""ORM 模型的惰性兼容导出与显式注册入口。"""
|
||||
|
||||
_identity 必须在此处导入:它在 import 期把媒体身份归一挂到 mapper 事件上,是六张带
|
||||
身份列的表的写入不变量。导入任一模型都会先初始化本包,因此这一行让强制点无处可绕。
|
||||
"""
|
||||
from . import _identity # noqa: F401 仅为注册 mapper 事件,不导出符号
|
||||
from .agentchat import AgentChat
|
||||
from .agenttask import AgentTask
|
||||
from .agenttaskrun import AgentTaskRun
|
||||
from .downloadfailure import DownloadFailure
|
||||
from .downloadhistory import DownloadHistory, DownloadFiles
|
||||
from .mediaserver import MediaServerItem
|
||||
from .message import Message
|
||||
from .passkey import PassKey
|
||||
from .plugindata import PluginData
|
||||
from .site import Site
|
||||
from .siteicon import SiteIcon
|
||||
from .sitestatistic import SiteStatistic
|
||||
from .siteuserdata import SiteUserData
|
||||
from .subscribe import Subscribe
|
||||
from .subscribehistory import SubscribeHistory
|
||||
from .systemconfig import SystemConfig
|
||||
from .transferhistory import TransferHistory
|
||||
from .transferpending import TransferPending
|
||||
from .user import User
|
||||
from .userconfig import UserConfig
|
||||
from .workflow import Workflow
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from . import _identity # noqa: F401 注册全局媒体身份写入不变量
|
||||
|
||||
|
||||
_MODEL_EXPORTS = {
|
||||
"AgentChat": ("app.db.models.agentchat", "AgentChat"),
|
||||
"AgentTask": ("app.db.models.agenttask", "AgentTask"),
|
||||
"AgentTaskRun": ("app.db.models.agenttaskrun", "AgentTaskRun"),
|
||||
"DownloadFailure": ("app.db.models.downloadfailure", "DownloadFailure"),
|
||||
"DownloadFiles": ("app.db.models.downloadhistory", "DownloadFiles"),
|
||||
"DownloadHistory": ("app.db.models.downloadhistory", "DownloadHistory"),
|
||||
"MediaServerItem": ("app.db.models.mediaserver", "MediaServerItem"),
|
||||
"Message": ("app.db.models.message", "Message"),
|
||||
"PassKey": ("app.db.models.passkey", "PassKey"),
|
||||
"PluginData": ("app.db.models.plugindata", "PluginData"),
|
||||
"Site": ("app.db.models.site", "Site"),
|
||||
"SiteIcon": ("app.db.models.siteicon", "SiteIcon"),
|
||||
"SiteStatistic": ("app.db.models.sitestatistic", "SiteStatistic"),
|
||||
"SiteUserData": ("app.db.models.siteuserdata", "SiteUserData"),
|
||||
"Subscribe": ("app.db.models.subscribe", "Subscribe"),
|
||||
"SubscribeHistory": (
|
||||
"app.db.models.subscribehistory",
|
||||
"SubscribeHistory",
|
||||
),
|
||||
"SystemConfig": ("app.db.models.systemconfig", "SystemConfig"),
|
||||
"TransferHistory": ("app.db.models.transferhistory", "TransferHistory"),
|
||||
"TransferPending": ("app.db.models.transferpending", "TransferPending"),
|
||||
"User": ("app.db.models.user", "User"),
|
||||
"UserConfig": ("app.db.models.userconfig", "UserConfig"),
|
||||
"Workflow": ("app.db.models.workflow", "Workflow"),
|
||||
}
|
||||
|
||||
|
||||
def load_all_models() -> None:
|
||||
"""显式导入全部 ORM 模型,供建表和 Alembic 元数据收集使用。"""
|
||||
for module_name, _ in dict.fromkeys(_MODEL_EXPORTS.values()):
|
||||
import_module(module_name)
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""按需解析旧模型包级导出,并缓存模型类。"""
|
||||
contract = _MODEL_EXPORTS.get(name)
|
||||
if contract is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module_name, symbol_name = contract
|
||||
value = getattr(import_module(module_name), symbol_name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
"""返回模型包的兼容公开面。"""
|
||||
return sorted({*globals(), *_MODEL_EXPORTS, "load_all_models"})
|
||||
|
||||
|
||||
__all__ = [*_MODEL_EXPORTS, "load_all_models"]
|
||||
|
||||
@@ -4,7 +4,8 @@ from sqlalchemy import Integer, String, JSON, Index, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import Base, async_db_query, db_query, get_id_column
|
||||
from app.db.base import Base, get_id_column
|
||||
from app.db.decorators import async_db_query, db_query
|
||||
|
||||
|
||||
class AgentChat(Base):
|
||||
|
||||
@@ -3,7 +3,8 @@ from typing import Optional
|
||||
from sqlalchemy import Boolean, Index, Integer, String, Text, select, update
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import Base, db_query, db_update, execute_dml, get_id_column
|
||||
from app.db.base import Base, execute_dml, get_id_column
|
||||
from app.db.decorators import db_query, db_update
|
||||
|
||||
|
||||
class AgentTask(Base):
|
||||
|
||||
@@ -3,7 +3,8 @@ from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy import Index, Integer, String, Text, delete, select, update
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import Base, db_query, db_update, execute_dml, get_id_column
|
||||
from app.db.base import Base, execute_dml, get_id_column
|
||||
from app.db.decorators import db_query, db_update
|
||||
from app.db.models.agenttask import AgentTask
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ from typing import List, Optional
|
||||
from sqlalchemy import Float, Index, Integer, String, delete, select
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import Base, db_query, db_update, execute_dml, get_id_column
|
||||
from app.db.base import Base, execute_dml, get_id_column
|
||||
from app.db.decorators import db_query, db_update
|
||||
from app.db.models._constraints import media_identity_constraint
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ from sqlalchemy import Integer, String, JSON, Index, delete, select, func, updat
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import Base, async_db_query, db_query, db_update, execute_dml, get_id_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.models._constraints import media_identity_constraint
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from sqlalchemy import Integer, String, JSON, Index, delete, or_
|
||||
from sqlalchemy import String, JSON, Index, delete, or_
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import Base, async_db_query, db_query, db_update, execute_dml, get_id_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.models._constraints import media_identity_constraint
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ from sqlalchemy import Integer, String, JSON, Index, and_, delete, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import Base, async_db_query, db_query, db_update, execute_dml, get_id_column
|
||||
from app.db.base import Base, execute_dml, get_id_column
|
||||
from app.db.decorators import async_db_query, db_query, db_update
|
||||
|
||||
|
||||
class Message(Base):
|
||||
|
||||
@@ -4,7 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
from datetime import datetime
|
||||
|
||||
from app.db import Base, db_query, db_update, async_db_query, async_db_update, get_id_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
|
||||
|
||||
|
||||
class PassKey(Base):
|
||||
|
||||
@@ -3,13 +3,8 @@ from sqlalchemy import String, JSON, Index, delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import (
|
||||
db_query,
|
||||
db_update,
|
||||
async_db_query,
|
||||
get_id_column,
|
||||
Base,
|
||||
)
|
||||
from app.db.base import get_id_column, Base
|
||||
from app.db.decorators import db_query, db_update, async_db_query
|
||||
|
||||
|
||||
class PluginData(Base):
|
||||
|
||||
@@ -5,7 +5,8 @@ from sqlalchemy import Boolean, Integer, String, JSON, select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import db_query, db_update, Base, async_db_query, async_db_update, get_id_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
|
||||
|
||||
|
||||
class Site(Base):
|
||||
|
||||
@@ -3,7 +3,8 @@ from sqlalchemy import String, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import db_query, Base, get_id_column, async_db_query
|
||||
from app.db.base import Base, get_id_column
|
||||
from app.db.decorators import db_query, async_db_query
|
||||
|
||||
|
||||
class SiteIcon(Base):
|
||||
|
||||
@@ -5,7 +5,8 @@ from sqlalchemy import Integer, String, JSON, delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import db_query, db_update, get_id_column, Base, async_db_query
|
||||
from app.db.base import get_id_column, Base
|
||||
from app.db.decorators import db_query, db_update, async_db_query
|
||||
|
||||
|
||||
class SiteStatistic(Base):
|
||||
|
||||
@@ -5,7 +5,8 @@ from sqlalchemy import Integer, String, Float, JSON, Index, delete, func, or_, s
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import Base, async_db_query, db_query, db_update, execute_dml, get_id_column
|
||||
from app.db.base import Base, execute_dml, get_id_column
|
||||
from app.db.decorators import async_db_query, db_query, db_update
|
||||
|
||||
|
||||
class SiteUserData(Base):
|
||||
|
||||
@@ -5,7 +5,8 @@ from sqlalchemy import Integer, String, Float, JSON, Index, delete, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import db_query, db_update, get_id_column, Base, async_db_query, async_db_update
|
||||
from app.db.base import get_id_column, Base
|
||||
from app.db.decorators import db_query, db_update, async_db_query, async_db_update
|
||||
from app.db.models._constraints import media_identity_constraint
|
||||
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ from sqlalchemy import Integer, String, Float, JSON, Index, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import db_query, Base, get_id_column, async_db_query
|
||||
from app.db.base import Base, get_id_column
|
||||
from app.db.decorators import db_query, async_db_query
|
||||
from app.db.models._constraints import media_identity_constraint
|
||||
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ from sqlalchemy import String, JSON, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import db_query, db_update, Base, async_db_query, get_id_column
|
||||
from app.db.base import Base, get_id_column
|
||||
from app.db.decorators import db_query, db_update, async_db_query
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
|
||||
@@ -7,7 +7,8 @@ from sqlalchemy import Boolean, Index, Integer, JSON, String, delete, func, or_,
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import Base, async_db_query, db_query, db_update, execute_dml, get_id_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.models._constraints import media_identity_constraint
|
||||
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ from typing import List, Optional
|
||||
from sqlalchemy import Index, String, delete, select
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import Base, db_query, db_update, execute_dml, get_id_column
|
||||
from app.db.base import Base, execute_dml, get_id_column
|
||||
from app.db.decorators import db_query, db_update
|
||||
|
||||
|
||||
class TransferPending(Base):
|
||||
|
||||
@@ -3,7 +3,8 @@ from sqlalchemy import Boolean, JSON, String, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import Base, db_query, db_update, async_db_query, async_db_update, get_id_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
|
||||
|
||||
|
||||
class User(Base):
|
||||
|
||||
@@ -2,7 +2,8 @@ from typing import Any, Optional
|
||||
from sqlalchemy import String, UniqueConstraint, JSON, select
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db import db_query, db_update, get_id_column, Base
|
||||
from app.db.base import get_id_column, Base
|
||||
from app.db.decorators import db_query, db_update
|
||||
|
||||
|
||||
class UserConfig(Base):
|
||||
|
||||
@@ -6,7 +6,8 @@ from sqlalchemy import Integer, JSON, String, Index, and_, or_, select, update
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import Base, db_query, get_id_column, db_update, async_db_query, async_db_update
|
||||
from app.db.base import Base, get_id_column
|
||||
from app.db.decorators import db_query, db_update, async_db_query, async_db_update
|
||||
|
||||
|
||||
class Workflow(Base):
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any, Optional, Union
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.agentchat import AgentChat
|
||||
from app.schemas.types import NotificationChannel
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.agenttask import AgentTask
|
||||
from app.db.models.agenttaskrun import AgentTaskRun
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.downloadfailure import DownloadFailure
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from typing import Dict, List, Optional, cast
|
||||
|
||||
from app.db import DbOper
|
||||
from sqlalchemy import delete as sqlalchemy_delete, update as sqlalchemy_update
|
||||
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
@@ -110,6 +112,17 @@ class DownloadHistoryOper(DbOper):
|
||||
"""
|
||||
DownloadFiles.delete_by_fullpath(self._db, fullpath)
|
||||
|
||||
def stage_delete_file_by_fullpath(self, fullpath: str) -> None:
|
||||
"""暂存指定完整路径的下载文件记录删除。"""
|
||||
self._db.execute(
|
||||
sqlalchemy_update(DownloadFiles)
|
||||
.where(
|
||||
DownloadFiles.fullpath == fullpath,
|
||||
DownloadFiles.state == 1,
|
||||
)
|
||||
.values(state=0)
|
||||
)
|
||||
|
||||
def get_hash_by_fullpath(self, fullpath: str) -> Optional[str]:
|
||||
"""
|
||||
按fullpath查询下载文件记录hash
|
||||
@@ -192,6 +205,14 @@ class DownloadHistoryOper(DbOper):
|
||||
"""
|
||||
DownloadHistory.delete(self._db, historyid)
|
||||
|
||||
def stage_delete_history(self, historyid: int) -> None:
|
||||
"""暂存下载记录删除,不由模型装饰器提交事务。"""
|
||||
self._db.execute(
|
||||
sqlalchemy_delete(DownloadHistory).where(
|
||||
DownloadHistory.id == historyid
|
||||
)
|
||||
)
|
||||
|
||||
def delete_downloadfile(self, downloadfileid):
|
||||
"""
|
||||
删除下载文件记录
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.mediaserver import MediaServerItem
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ from typing import Optional, Union
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.message import Message
|
||||
from app.schemas import NotificationChannel, MessageType
|
||||
from app.schemas.notification import NotificationChannel
|
||||
from app.schemas.message import MessageType
|
||||
|
||||
|
||||
class MessageOper(DbOper):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.plugindata import PluginData
|
||||
|
||||
|
||||
|
||||
+47
-3
@@ -1,9 +1,11 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Tuple, Optional
|
||||
from typing import Any, List, Mapping, Tuple, Optional
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.models import SiteIcon
|
||||
from sqlalchemy import delete as sqlalchemy_delete
|
||||
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.site import Site
|
||||
from app.db.models.siteicon import SiteIcon
|
||||
from app.db.models.sitestatistic import SiteStatistic
|
||||
from app.db.models.siteuserdata import SiteUserData
|
||||
|
||||
@@ -35,6 +37,48 @@ class SiteOper(DbOper):
|
||||
"""
|
||||
return await Site.async_get(self._db, sid)
|
||||
|
||||
async def get_by_id(self, site_id: int) -> Optional[Site]:
|
||||
"""读取站点写用例需要的目标站点。"""
|
||||
return await self.async_get(site_id)
|
||||
|
||||
async def get_by_domain(self, domain: str) -> Optional[Site]:
|
||||
"""按域名读取站点写用例的重复目标。"""
|
||||
return await Site.async_get_by_domain(self._db, domain)
|
||||
|
||||
async def stage_create(self, payload: Mapping[str, Any]) -> None:
|
||||
"""暂存新增站点,不由仓储自行提交。"""
|
||||
values = dict(payload)
|
||||
values.pop("id", None)
|
||||
self._db.add(Site(**values))
|
||||
|
||||
async def stage_update(
|
||||
self,
|
||||
site_id: int,
|
||||
payload: Mapping[str, Any],
|
||||
) -> bool:
|
||||
"""暂存站点字段更新,不由模型装饰器提前提交。"""
|
||||
site = await self.async_get(site_id)
|
||||
if not site:
|
||||
return False
|
||||
for key, value in payload.items():
|
||||
if key != "id":
|
||||
setattr(site, key, value)
|
||||
return True
|
||||
|
||||
async def stage_delete(self, site_id: int) -> None:
|
||||
"""暂存站点删除,由请求级 UnitOfWork 统一提交。"""
|
||||
await self._db.execute(
|
||||
sqlalchemy_delete(Site).where(Site.id == site_id)
|
||||
)
|
||||
|
||||
async def stage_priorities(self, priorities: list[dict]) -> None:
|
||||
"""暂存批量优先级更新,避免逐行独立提交。"""
|
||||
for priority in priorities:
|
||||
site_id = priority.get("id")
|
||||
site = await self.async_get(site_id) if site_id else None
|
||||
if site:
|
||||
site.pri = priority.get("pri")
|
||||
|
||||
def list(self) -> List[Site]:
|
||||
"""
|
||||
获取站点列表
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
import time
|
||||
from typing import Any, Tuple, List, Optional
|
||||
|
||||
from app.db import DbOper
|
||||
from sqlalchemy import delete as sqlalchemy_delete
|
||||
|
||||
from app.application.subscription.delete import SubscribeDeletionCandidate
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.models.subscribehistory import SubscribeHistory
|
||||
from app.schemas.types import MediaSource
|
||||
@@ -154,6 +157,75 @@ class SubscribeOper(DbOper):
|
||||
"""
|
||||
return await Subscribe.async_get(self._db, rid=sid)
|
||||
|
||||
async def get_candidate(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
) -> Optional[SubscribeDeletionCandidate]:
|
||||
"""读取订阅删除用例需要的权限字段与完整事件快照。"""
|
||||
subscribe = await self.async_get(subscribe_id)
|
||||
if not subscribe:
|
||||
return None
|
||||
values = subscribe.__dict__
|
||||
event_payload = {
|
||||
column.name: values.get(column.name)
|
||||
for column in subscribe.__table__.columns
|
||||
}
|
||||
return SubscribeDeletionCandidate(
|
||||
subscribe_id=subscribe_id,
|
||||
username=subscribe.username,
|
||||
event_payload=event_payload,
|
||||
)
|
||||
|
||||
async def list_candidates_by_identity(
|
||||
self,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
season: Optional[int],
|
||||
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,
|
||||
)
|
||||
candidates = []
|
||||
seen_ids = set()
|
||||
for subscribe in subscribes or []:
|
||||
subscribe_music_type = getattr(subscribe, "music_type", None)
|
||||
if music_type and not (
|
||||
subscribe_music_type == music_type
|
||||
or (music_type == "recording" and subscribe_music_type is None)
|
||||
):
|
||||
continue
|
||||
if season is not None and subscribe.season != season:
|
||||
continue
|
||||
if not subscribe.id or subscribe.id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(subscribe.id)
|
||||
values = subscribe.__dict__
|
||||
candidates.append(
|
||||
SubscribeDeletionCandidate(
|
||||
subscribe_id=subscribe.id,
|
||||
username=subscribe.username,
|
||||
event_payload={
|
||||
column.name: values.get(column.name)
|
||||
for column in subscribe.__table__.columns
|
||||
},
|
||||
)
|
||||
)
|
||||
return candidates
|
||||
|
||||
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,
|
||||
)
|
||||
return [subscribe.id for subscribe in subscribes if subscribe.id]
|
||||
|
||||
def get_by(
|
||||
self, type: str, media_source: MediaSource, media_id: str,
|
||||
season: Optional[str] = None,
|
||||
@@ -206,6 +278,12 @@ class SubscribeOper(DbOper):
|
||||
"""
|
||||
await Subscribe.async_delete(self._db, rid=sid)
|
||||
|
||||
async def stage_delete(self, sid: int) -> None:
|
||||
"""登记订阅删除但不提交,由 Application UnitOfWork 控制事务边界。"""
|
||||
await self._db.execute(
|
||||
sqlalchemy_delete(Subscribe).where(Subscribe.id == sid)
|
||||
)
|
||||
|
||||
async def async_update(self, sid: int, payload: dict) -> Optional[Subscribe]:
|
||||
"""
|
||||
异步更新订阅。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.subscribehistory import SubscribeHistory
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import copy
|
||||
import threading
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.systemconfig import SystemConfig
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.foundation.singleton import Singleton
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import time
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.db import DbOper
|
||||
from sqlalchemy import delete as sqlalchemy_delete
|
||||
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
@@ -207,6 +209,18 @@ class TransferHistoryOper(DbOper):
|
||||
"""
|
||||
TransferHistory.delete(self._db, historyid)
|
||||
|
||||
def stage_delete(self, historyid: int) -> None:
|
||||
"""暂存整理记录删除,不由模型装饰器提交事务。"""
|
||||
self._db.execute(
|
||||
sqlalchemy_delete(TransferHistory).where(
|
||||
TransferHistory.id == historyid
|
||||
)
|
||||
)
|
||||
|
||||
def stage_truncate(self) -> None:
|
||||
"""暂存全部整理记录删除,由请求级事务统一提交。"""
|
||||
self._db.execute(sqlalchemy_delete(TransferHistory))
|
||||
|
||||
async def async_delete(self, historyid):
|
||||
"""
|
||||
异步删除转移记录。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.transferpending import TransferPending
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ runtime 兼容映射指向 SDK 薄门面;canonical 数据访问模块仍只依
|
||||
"""
|
||||
from typing import List, Optional
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.user import User
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Any, Union, Dict, Optional
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.userconfig import UserConfig
|
||||
from app.schemas.types import UserConfigKey
|
||||
from app.foundation.singleton import Singleton
|
||||
|
||||
+57
-2
@@ -1,6 +1,8 @@
|
||||
from typing import List, Tuple, Optional, Any, Coroutine, Sequence
|
||||
from typing import List, Mapping, Tuple, Optional, Any
|
||||
|
||||
from app.db import DbOper
|
||||
from sqlalchemy import delete as sqlalchemy_delete
|
||||
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.workflow import Workflow
|
||||
|
||||
|
||||
@@ -25,6 +27,34 @@ class WorkflowOper(DbOper):
|
||||
"""
|
||||
return Workflow.get(self._db, wid)
|
||||
|
||||
def stage_state(self, workflow_id: int, state: str) -> bool:
|
||||
"""暂存工作流状态变更,不由模型方法自行提交。"""
|
||||
workflow = self.get(workflow_id)
|
||||
if not workflow:
|
||||
return False
|
||||
workflow.state = state
|
||||
return True
|
||||
|
||||
def stage_update(
|
||||
self,
|
||||
workflow_id: int,
|
||||
payload: Mapping[str, Any],
|
||||
) -> Optional[Workflow]:
|
||||
"""暂存工作流字段更新并返回同一会话中的对象。"""
|
||||
workflow = self.get(workflow_id)
|
||||
if not workflow:
|
||||
return None
|
||||
for key, value in payload.items():
|
||||
if key != "id":
|
||||
setattr(workflow, key, value)
|
||||
return workflow
|
||||
|
||||
def stage_delete(self, workflow_id: int) -> None:
|
||||
"""暂存工作流删除,由请求级 UnitOfWork 统一提交。"""
|
||||
self._db.execute(
|
||||
sqlalchemy_delete(Workflow).where(Workflow.id == workflow_id)
|
||||
)
|
||||
|
||||
async def async_get(self, wid: int) -> Optional[Workflow]:
|
||||
"""
|
||||
异步查询单个工作流
|
||||
@@ -73,6 +103,31 @@ class WorkflowOper(DbOper):
|
||||
"""
|
||||
return await Workflow.async_get_by_name(self._db, name)
|
||||
|
||||
async def stage_create(self, payload: Mapping[str, Any]) -> Workflow:
|
||||
"""暂存新工作流,不在操作器内提交事务。"""
|
||||
workflow = Workflow(**dict(payload))
|
||||
self._db.add(workflow)
|
||||
await self._db.flush()
|
||||
return workflow
|
||||
|
||||
async def stage_reset(
|
||||
self,
|
||||
workflow_id: int,
|
||||
reset_count: bool = False,
|
||||
) -> Optional[Workflow]:
|
||||
"""暂存工作流重置字段,不触发模型装饰器的隐式提交。"""
|
||||
workflow = await self.async_get(workflow_id)
|
||||
if not workflow:
|
||||
return None
|
||||
workflow.state = "W"
|
||||
workflow.result = None
|
||||
workflow.current_action = None
|
||||
workflow.context = {}
|
||||
workflow.execution_state = {}
|
||||
if reset_count:
|
||||
workflow.run_count = 0
|
||||
return workflow
|
||||
|
||||
def start(self, wid: int) -> bool:
|
||||
"""
|
||||
启动
|
||||
|
||||
+2
-2
@@ -19,10 +19,10 @@ from sqlalchemy.ext.asyncio import AsyncEngine as SaAsyncEngine
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
|
||||
from app.runtime.config import global_vars, settings
|
||||
from app.db import engine as engine_module
|
||||
import app.db.engine as engine_module
|
||||
from app.db.engine import (_async_pool_enabled, _get_database_engine,
|
||||
get_engine, get_global_async_engine)
|
||||
from app.runtime.config import global_vars, settings
|
||||
from app.runtime.log import logger
|
||||
|
||||
# 会话工厂同样惰性:sessionmaker 在构造时就要绑定引擎,模块级构造等于把引擎的
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""SQLAlchemy 请求级事务适配器。"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
class SqlAlchemyUnitOfWork:
|
||||
"""把同步 Session 的提交与回滚能力适配为应用层事务端口。"""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
"""保存由请求依赖提供的同步数据库会话。"""
|
||||
self._session = session
|
||||
|
||||
def commit(self) -> None:
|
||||
"""提交请求级事务。"""
|
||||
self._session.commit()
|
||||
|
||||
def rollback(self) -> None:
|
||||
"""回滚请求级事务。"""
|
||||
self._session.rollback()
|
||||
|
||||
|
||||
class SqlAlchemyAsyncUnitOfWork:
|
||||
"""把 AsyncSession 的提交与回滚能力适配为应用层事务端口。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
"""保存由请求依赖提供的数据库会话。"""
|
||||
self._session = session
|
||||
|
||||
async def commit(self) -> None:
|
||||
"""提交请求级事务。"""
|
||||
await self._session.commit()
|
||||
|
||||
async def rollback(self) -> None:
|
||||
"""回滚请求级事务。"""
|
||||
await self._session.rollback()
|
||||
Reference in New Issue
Block a user