refactor: 推进后端分层架构治理

This commit is contained in:
jxxghp
2026-08-18 00:29:14 +08:00
parent 5d0bacabd5
commit 5128ae9e1e
363 changed files with 34179 additions and 6019 deletions
+54 -78
View File
@@ -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__ = [
+63
View File
@@ -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
View File
@@ -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"]
+2 -1
View File
@@ -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):
+2 -1
View File
@@ -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):
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+3 -2
View File
@@ -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
+2 -1
View File
@@ -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):
+2 -1
View File
@@ -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):
+2 -7
View File
@@ -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):
+2 -1
View File
@@ -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):
+2 -1
View File
@@ -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):
+2 -1
View File
@@ -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):
+2 -1
View File
@@ -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):
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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):
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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):
+2 -1
View File
@@ -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 -1
View File
@@ -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):
+2 -1
View File
@@ -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):
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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
+22 -1
View File
@@ -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):
"""
删除下载文件记录
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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]:
"""
获取站点列表
+79 -1
View File
@@ -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]:
"""
异步更新订阅。
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+15 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 在构造时就要绑定引擎,模块级构造等于把引擎的
+36
View File
@@ -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()