refactor(db): 修复异步连接池无界增长,并完成 SQLAlchemy 2.0 迁移与分层归位 (#6320)

This commit is contained in:
Aqr-K
2026-08-15 06:58:38 +08:00
committed by GitHub
parent e28de9cfe1
commit 8a11214a43
252 changed files with 11405 additions and 2889 deletions
+422
View File
@@ -0,0 +1,422 @@
"""
订阅的写入路径:app/application/subscribe.py 的 add_subscribe / async_add_subscribe。
这两个函数是订阅表的唯一写入口,把 MediaInfo / MusicInfo 翻译成一行订阅:
标题、年份、类型、海报背景、评分简介、剧集组、音乐实体与曲目数,再叠上
持久化类型强转(布尔开关转整型、年份转字符串)。字段映射错了不会报错,
只会让订阅静静地记错——而搜索、洗版、完成判定、去重全都读这张表。
因此这里断言的是「落库后每个字段的实际值」,不是「调用了什么」:
同目录的 test_subscribe_oper.py 用替身钉的是查重语义(谁被查、查几次),
证明不了写进去的到底是什么。两者互补,缺一不可。
唯一的例外是**持久化类型强转**(年份转字符串、布尔开关转整型)。这两步是
为 PostgreSQL 的严格类型检查而存在的,而测试库是 SQLite——SQLite 的类型
亲和会在写入时把 ``2003`` 悄悄转成 ``'2003'``、把 ``True`` 转成 ``1``
落库后的值对「写入路径自己有没有转」完全无感(已用变异验证确认:删掉
``_normalize_year`` 后按落库值断言的用例全部照过)。所以这两类契约必须在
建模那一刻、即 ``Subscribe(**kwargs)`` 的入参上断言,见 ``payloads`` 夹具。
强转跟着订阅表的列走,因此仍留在 ``app/db/oper/subscribe.py``,夹具也就
仍然钉在那个模块的 ``Subscribe`` 上。
同步与异步两条链路是两份逐字复制的实现,任何一条改了另一条没跟上都属于
真实缺陷,故每个字段契约都在两条链路上各断言一次。
"""
import asyncio
import pytest
from app.application.subscribe import add_subscribe, async_add_subscribe
from app.db.models.subscribe import Subscribe
from app.db.oper.subscribe import SubscribeOper
from app.domain.context import MediaInfo, MusicInfo
from app.schemas.types import MediaSource, MediaType
@pytest.fixture(autouse=True)
def _track(db):
"""把订阅表纳入用例级回收。"""
db.watermark(Subscribe)
def _media_id(tag: str) -> str:
"""给每个用例分配独立媒体 ID,避免共用测试库时互相去重。"""
return f"wp-{tag}"
def _mediainfo(media_id: str, title: str = "识别标题",
mtype: MediaType = MediaType.TV, year: str = "2026",
episode_group: str = None) -> MediaInfo:
"""构造带完整展示字段的识别结果。"""
media = MediaInfo()
media.type = mtype
media.title = title
media.year = year
media.media_source = MediaSource.TMDB
media.media_id = media_id
media.episode_group = episode_group
media.vote_average = 8.5
media.overview = "测试简介"
media.poster_path = "https://image.tmdb.org/t/p/original/poster.jpg"
media.backdrop_path = "https://image.tmdb.org/t/p/original/backdrop.jpg"
return media
def _musicinfo(media_id: str, music_type: str, **kwargs) -> MusicInfo:
"""构造音乐订阅所需的标准音乐信息。"""
return MusicInfo(media_source=MediaSource.MusicBrainz, media_id=media_id,
music_type=music_type, **kwargs)
def _add(oper: SubscribeOper, is_async: bool, **kwargs):
"""按链路分派到同步或异步新增,让同一份字段契约跑两遍。"""
if is_async:
return asyncio.run(async_add_subscribe(subscribe_oper=oper, **kwargs))
return add_subscribe(subscribe_oper=oper, **kwargs)
def _row(db, subscribe_id: int) -> Subscribe:
"""按主键读回落库的订阅行。"""
db.session.expire_all()
return Subscribe.get(db.session, subscribe_id)
class _SubscribeSpy:
"""记录建模入参并转交真实模型,保持写入路径仍然真的落库。"""
def __init__(self, recorded: list):
self._recorded = recorded
def __call__(self, **kwargs):
"""截获 ``Subscribe(**kwargs)`` 的入参后构造真实模型实例。"""
self._recorded.append(dict(kwargs))
return Subscribe(**kwargs)
def __getattr__(self, name):
"""查重用的类方法(exists / async_exists 等)原样透传给真实模型。"""
return getattr(Subscribe, name)
@pytest.fixture
def payloads(monkeypatch):
"""
捕获写入路径建模时的原始 kwargs,即落库**前**的值与类型。
只用于持久化类型强转这一类契约:SQLite 的类型亲和会在写入时替写入路径
把类型「修好」,落库后的值证明不了转换真的发生过;而这两步转换恰恰是为
PostgreSQL 而写的,漏了只会在生产库上炸。
"""
recorded: list = []
monkeypatch.setattr("app.db.oper.subscribe.Subscribe", _SubscribeSpy(recorded))
return recorded
# 每个字段契约都在同步与异步两条链路上跑一遍:两份实现是逐字复制的,
# 只测一条等于放任另一条漂移
_BOTH_PATHS = pytest.mark.parametrize(
"is_async", [pytest.param(False, id="sync"), pytest.param(True, id="async")]
)
# --------------------------------------------------------------------------- #
# 展示字段的翻译
# --------------------------------------------------------------------------- #
@_BOTH_PATHS
def test_add_maps_every_display_field_onto_the_row(db, is_async):
"""
识别结果的展示字段必须完整落库。
标题、年份、类型、评分、简介都是订阅列表和通知的唯一数据来源,
错一项用户就看到一条张冠李戴的订阅。
"""
oper = SubscribeOper()
media_id = _media_id(f"display-{is_async}")
sid, message = _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1)
assert message == "新增订阅成功"
row = _row(db, sid)
assert row.name == "识别标题"
assert row.year == "2026"
assert row.type == MediaType.TV.value
assert row.media_source == MediaSource.TMDB.value
assert row.media_id == media_id
assert row.season == 1
assert row.vote == 8.5
assert row.description == "测试简介"
@_BOTH_PATHS
def test_add_persists_poster_and_backdrop_from_media(db, is_async):
"""
海报与背景取自识别结果的图片接口,而不是原始路径字段。
接口会把 original 尺寸换成 w500,直接存 poster_path 会让列表页
每张卡片都去拉原图。
"""
oper = SubscribeOper()
media_id = _media_id(f"image-{is_async}")
sid, _ = _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1)
row = _row(db, sid)
assert row.poster == "https://image.tmdb.org/t/p/w500/poster.jpg"
assert row.backdrop == "https://image.tmdb.org/t/p/w500/backdrop.jpg"
@_BOTH_PATHS
def test_add_persists_episode_group(db, is_async):
"""
剧集组必须来自识别结果并落库。
订阅按剧集组去重、搜索也按剧集组匹配集数,丢了它主季与自定义组会互相顶替。
"""
oper = SubscribeOper()
media_id = _media_id(f"eg-{is_async}")
sid, _ = _add(oper, is_async,
mediainfo=_mediainfo(media_id, episode_group="eg-1"), season=1)
assert _row(db, sid).episode_group == "eg-1"
@_BOTH_PATHS
def test_add_stamps_creation_date(db, is_async):
"""
新增时间由写入路径盖戳,调用方传入的值不作数。
订阅列表默认按 date 排序、过期清理也读它,留空会让这条订阅永远排在最后。
"""
oper = SubscribeOper()
media_id = _media_id(f"date-{is_async}")
sid, _ = _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1,
date="1970-01-01 00:00:00")
date = _row(db, sid).date
assert date is not None
assert date != "1970-01-01 00:00:00"
# 形如 2026-08-14 12:34:56
assert len(date) == 19 and date[4] == "-" and date[13] == ":"
# --------------------------------------------------------------------------- #
# 持久化类型强转
# --------------------------------------------------------------------------- #
@_BOTH_PATHS
def test_add_converts_boolean_flags_to_integers(db, payloads, is_async):
"""
历史兼容的布尔开关建模前必须转成整型。
PostgreSQL 的整型列拒收布尔值,不转会让新增订阅在 PG 上直接抛类型错误。
断言落在建模入参上而非落库值:SQLite 会替我们把 True 存成 1
按落库值断言的话删掉转换也照样通过。
"""
oper = SubscribeOper()
media_id = _media_id(f"flags-{is_async}")
_add(oper, is_async, mediainfo=_mediainfo(media_id), season=1,
best_version=True, best_version_full=False, manual_total_episode=True)
payload = payloads[-1]
for field, expected in (("best_version", 1), ("best_version_full", 0),
("manual_total_episode", 1)):
assert payload[field] == expected
assert type(payload[field]) is int, f"{field} 仍是 {type(payload[field])}"
@_BOTH_PATHS
@pytest.mark.parametrize(
"supplied, expected",
[
pytest.param(True, 1, id=""),
pytest.param(False, 0, id=""),
pytest.param(None, 0, id="缺省"),
],
)
def test_add_normalizes_search_imdbid_to_zero_or_one(db, payloads, is_async,
supplied, expected):
"""
search_imdbid 无论传什么都归一到整型 0/1。
这一列参与搜索分支判定,存进 None 或 True 会让「是否用 imdbid 搜」
在不同订阅上表现不一致,在 PG 上还会直接拒写。
"""
oper = SubscribeOper()
media_id = _media_id(f"imdb-{is_async}-{supplied}")
_add(oper, is_async, mediainfo=_mediainfo(media_id), season=1,
search_imdbid=supplied)
payload = payloads[-1]
assert payload["search_imdbid"] == expected
assert type(payload["search_imdbid"]) is int
@_BOTH_PATHS
def test_add_converts_numeric_year_to_string(db, payloads, is_async):
"""
音乐链路的年份是数字,而 year 列是字符串,建模前必须转换。
不转在 PostgreSQL 上直接抛类型错误。同样只能在建模入参上验证:
SQLite 的 TEXT 亲和会把整数 2003 自动存成 '2003',读回来看不出差别。
"""
oper = SubscribeOper()
media_id = _media_id(f"year-{is_async}")
sid, _ = _add(oper, is_async,
mediainfo=_musicinfo(media_id, "album", title="叶惠美", year=2003))
assert payloads[-1]["year"] == "2003"
assert type(payloads[-1]["year"]) is str
# 落库值也要对得上,转换不能只发生在建模而在写入时被改回去
assert _row(db, sid).year == "2003"
@_BOTH_PATHS
def test_add_keeps_missing_year_as_null(db, payloads, is_async):
"""
年份缺失时留空,不能变成字符串 "None"
转字符串离无脑 str() 只有一步之遥,写成 "None" 后年份筛选会命中一个
不存在的年份,而这条订阅从此在按年份筛选的界面里凭空消失。
"""
oper = SubscribeOper()
media_id = _media_id(f"noyear-{is_async}")
sid, _ = _add(oper, is_async,
mediainfo=_musicinfo(media_id, "album", title="无年份专辑"))
assert payloads[-1]["year"] is None
assert _row(db, sid).year is None
# --------------------------------------------------------------------------- #
# 音乐字段
# --------------------------------------------------------------------------- #
@_BOTH_PATHS
def test_add_persists_album_entity_and_track_count(db, is_async):
"""
专辑订阅要落实体类型和总曲目数。
整专完成判定拿 total_tracks 当分母,缺了这条订阅永远判不到完成。
"""
oper = SubscribeOper()
media_id = _media_id(f"album-{is_async}")
sid, _ = _add(oper, is_async,
mediainfo=_musicinfo(media_id, "album", title="叶惠美", total_tracks=11))
row = _row(db, sid)
assert row.type == MediaType.MUSIC.value
assert row.music_type == "album"
assert row.total_tracks == 11
@_BOTH_PATHS
def test_add_drops_track_count_for_single_recording(db, is_async):
"""
单曲订阅只留实体类型,专辑曲目数必须丢弃。
单曲带着专辑的 total_tracks 会让完成判定把一首歌当整专等,永远不完成。
"""
oper = SubscribeOper()
media_id = _media_id(f"recording-{is_async}")
sid, _ = _add(oper, is_async,
mediainfo=_musicinfo(media_id, "recording", title="晴天", total_tracks=11))
row = _row(db, sid)
assert row.music_type == "recording"
assert row.total_tracks is None
@_BOTH_PATHS
def test_add_clears_music_fields_for_non_music_media(db, is_async):
"""
非音乐媒体的音乐字段一律置空,调用方传进来的也要被覆盖。
影视订阅带上 music_type 会被音乐去重逻辑当成音乐实体,造成串号。
"""
oper = SubscribeOper()
media_id = _media_id(f"nonmusic-{is_async}")
sid, _ = _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1,
music_type="album", total_tracks=99)
row = _row(db, sid)
assert row.music_type is None
assert row.total_tracks is None
@_BOTH_PATHS
def test_add_persists_music_cover_as_poster_and_backdrop(db, is_async):
"""
音乐订阅的海报与背景都用发行封面。
音乐没有独立背景图,留空会让订阅卡片在列表里显示成一块空白。
"""
oper = SubscribeOper()
media_id = _media_id(f"cover-{is_async}")
cover = "https://coverartarchive.org/release-group/example/front-500"
sid, _ = _add(oper, is_async,
mediainfo=_musicinfo(media_id, "album", title="封面专辑", cover_url=cover))
row = _row(db, sid)
assert row.poster == cover
assert row.backdrop == cover
# --------------------------------------------------------------------------- #
# 调用方字段的保留
# --------------------------------------------------------------------------- #
@_BOTH_PATHS
def test_add_keeps_caller_supplied_subscription_settings(db, is_async):
"""
调用方传入的订阅设置要原样保留,不被媒体翻译覆盖。
写入路径只负责翻译媒体身份与展示字段;把用户填的保存路径、过滤词、
总集数一并覆盖掉,等于用户每次新增订阅的设置都白填。
"""
oper = SubscribeOper()
media_id = _media_id(f"settings-{is_async}")
sid, _ = _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1,
username="alice", save_path="/media/tv", keyword="关键字",
include="内嵌", exclude="预告", total_episode=12,
start_episode=3, downloader="qbittorrent", state="R")
row = _row(db, sid)
assert row.username == "alice"
assert row.save_path == "/media/tv"
assert row.keyword == "关键字"
assert (row.include, row.exclude) == ("内嵌", "预告")
assert (row.total_episode, row.start_episode) == (12, 3)
assert row.downloader == "qbittorrent"
assert row.state == "R"
@_BOTH_PATHS
def test_add_overrides_caller_supplied_media_fields(db, is_async):
"""
媒体身份与展示字段以识别结果为准,调用方传的同名值要被覆盖。
否则上游一个陈旧的 name/year 就能让订阅记成另一部剧,而去重按身份走、
发现不了这种错位。
"""
oper = SubscribeOper()
media_id = _media_id(f"override-{is_async}")
sid, _ = _add(oper, is_async,
mediainfo=_mediainfo(media_id, title="正确标题", year="2026"),
season=1, name="错误标题", year="1999", type="电影")
row = _row(db, sid)
assert row.name == "正确标题"
assert row.year == "2026"
assert row.type == MediaType.TV.value