From 4e74d32882191fdb50432b937cf062890ae6255e Mon Sep 17 00:00:00 2001 From: CHANTXU64 Date: Mon, 2 Feb 2026 10:28:22 +0800 Subject: [PATCH 01/13] =?UTF-8?q?Fix:=20TMDB=20=E5=89=A7=E9=9B=86=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5=E4=B8=8D=E6=98=BE=E7=A4=BA=E7=AC=AC=200=20?= =?UTF-8?q?=E5=AD=A3=EF=BC=88=E7=89=B9=E5=88=AB=E7=AF=87=EF=BC=89=20#5444?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/mediaserver.py | 2 +- app/core/context.py | 2 +- app/modules/themoviedb/__init__.py | 4 ++-- app/modules/themoviedb/tmdbapi.py | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/api/endpoints/mediaserver.py b/app/api/endpoints/mediaserver.py index 3224f88f..51123ec3 100644 --- a/app/api/endpoints/mediaserver.py +++ b/app/api/endpoints/mediaserver.py @@ -101,7 +101,7 @@ def not_exists(media_in: schemas.MediaInfo, mtype = MediaType(media_in.type) if media_in.type else None if mtype: meta.type = mtype - if media_in.season: + if media_in.season is not None: meta.begin_season = media_in.season meta.type = MediaType.TV if media_in.year: diff --git a/app/core/context.py b/app/core/context.py index dd52af63..75f0e055 100644 --- a/app/core/context.py +++ b/app/core/context.py @@ -465,7 +465,7 @@ class MediaInfo: for seainfo in info.get('seasons'): # 季 season = seainfo.get("season_number") - if not season: + if season is None: continue # 集 episode_count = seainfo.get("episode_count") diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 4cfd494d..4ee52c2e 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -798,7 +798,7 @@ class TheMovieDbModule(_ModuleBase): if not tmdb_info: return [] return [schemas.TmdbSeason(**sea) - for sea in tmdb_info.get("seasons", []) if sea.get("season_number")] + for sea in tmdb_info.get("seasons", []) if sea.get("season_number") is not None] def tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]: """ @@ -1168,7 +1168,7 @@ class TheMovieDbModule(_ModuleBase): if not tmdb_info: return [] return [schemas.TmdbSeason(**sea) - for sea in tmdb_info.get("seasons", []) if sea.get("season_number")] + for sea in tmdb_info.get("seasons", []) if sea.get("season_number") is not None] async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]: """ diff --git a/app/modules/themoviedb/tmdbapi.py b/app/modules/themoviedb/tmdbapi.py index 417a5931..2a02d777 100644 --- a/app/modules/themoviedb/tmdbapi.py +++ b/app/modules/themoviedb/tmdbapi.py @@ -167,7 +167,7 @@ class TmdbApi: """ 记录匹配调试日志 """ - if season_number and season_year: + if season_number is not None and season_year: logger.debug(f"正在识别{mtype.value}:{name}, 季集={season_number}, 季集年份={season_year} ...") else: logger.debug(f"正在识别{mtype.value}:{name}, 年份={year} ...") @@ -473,7 +473,7 @@ class TmdbApi: info = self._set_media_type(info, MediaType.MOVIE) else: # 有当前季和当前季集年份,使用精确匹配 - if season_year and season_number: + if season_year and season_number is not None: self._log_match_debug(mtype, name, season_year, season_number, season_year) info = self.__search_tv_by_season(name, season_year, @@ -697,7 +697,7 @@ class TmdbApi: return {} ret_seasons = {} for season_info in tv_info.get("seasons") or []: - if not season_info.get("season_number"): + if season_info.get("season_number") is None: continue ret_seasons[season_info.get("season_number")] = season_info return ret_seasons @@ -2028,7 +2028,7 @@ class TmdbApi: info = self._set_media_type(info, MediaType.MOVIE) else: # 有当前季和当前季集年份,使用精确匹配 - if season_year and season_number: + if season_year and season_number is not None: self._log_match_debug(mtype, name, season_year, season_number, season_year) info = await self.__async_search_tv_by_season(name, season_year, From f28be2e7de70f7a9a84d58785cb4efa031e4f58e Mon Sep 17 00:00:00 2001 From: 0honus0 <63273720+0honus0@users.noreply.github.com> Date: Mon, 2 Feb 2026 06:52:48 +0000 Subject: [PATCH 02/13] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=8C=89=E9=92=AExpath=E6=94=AF=E6=8C=81nicept=E7=BD=91?= =?UTF-8?q?=E7=AB=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/helper/cookie.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/helper/cookie.py b/app/helper/cookie.py index 6afe8f6e..5a0247bf 100644 --- a/app/helper/cookie.py +++ b/app/helper/cookie.py @@ -45,7 +45,8 @@ class CookieHelper: '//button[@type="submit"]', '//button[@lay-filter="login"]', '//button[@lay-filter="formLogin"]', - '//input[@type="button"][@value="登录"]' + '//input[@type="button"][@value="登录"]', + '//input[@id="submit-btn"]' ], "error": [ "//table[@class='main']//td[@class='text']/text()" From d622d1474d2012d49b5b4ebbdf57199a17dcc0ac Mon Sep 17 00:00:00 2001 From: 0honus0 <63273720+0honus0@users.noreply.github.com> Date: Mon, 2 Feb 2026 07:00:57 +0000 Subject: [PATCH 03/13] =?UTF-8?q?=E6=A0=B9=E6=8D=AE=E6=84=8F=E8=A7=81?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=B0=BE=E9=83=A8=E9=80=97=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/helper/cookie.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/helper/cookie.py b/app/helper/cookie.py index 5a0247bf..396d6ccf 100644 --- a/app/helper/cookie.py +++ b/app/helper/cookie.py @@ -19,26 +19,26 @@ class CookieHelper: "username": [ '//input[@name="username"]', '//input[@id="form_item_username"]', - '//input[@id="username"]' + '//input[@id="username"]', ], "password": [ '//input[@name="password"]', '//input[@id="form_item_password"]', '//input[@id="password"]', - '//input[@type="password"]' + '//input[@type="password"]', ], "captcha": [ '//input[@name="imagestring"]', '//input[@name="captcha"]', '//input[@id="form_item_captcha"]', - '//input[@placeholder="驗證碼"]' + '//input[@placeholder="驗證碼"]', ], "captcha_img": [ '//img[@alt="captcha"]/@src', '//img[@alt="CAPTCHA"]/@src', '//img[@alt="SECURITY CODE"]/@src', '//img[@id="LAY-user-get-vercode"]/@src', - '//img[contains(@src,"/api/getCaptcha")]/@src' + '//img[contains(@src,"/api/getCaptcha")]/@src', ], "submit": [ '//input[@type="submit"]', @@ -46,15 +46,15 @@ class CookieHelper: '//button[@lay-filter="login"]', '//button[@lay-filter="formLogin"]', '//input[@type="button"][@value="登录"]', - '//input[@id="submit-btn"]' + '//input[@id="submit-btn"]', ], "error": [ - "//table[@class='main']//td[@class='text']/text()" + "//table[@class='main']//td[@class='text']/text()", ], "twostep": [ '//input[@name="two_step_code"]', '//input[@name="2fa_secret"]', - '//input[@name="otp"]' + '//input[@name="otp"]', ] } From 1751caef62b92336524cd87ec7a2854876722719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=AF=E5=A4=A7=E4=BE=A0?= Date: Sat, 31 Jan 2026 15:22:07 +0800 Subject: [PATCH 04/13] =?UTF-8?q?fix:=20=E8=A1=A5=E5=85=85=E5=87=A0?= =?UTF-8?q?=E5=A4=84season=E7=9A=84=E5=88=A4=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agent/tools/impl/search_media.py | 2 +- app/agent/tools/impl/search_torrents.py | 2 +- app/api/endpoints/media.py | 6 +++--- app/api/endpoints/subscribe.py | 2 +- app/chain/media.py | 4 ++-- app/modules/douban/scraper.py | 4 ++-- app/modules/themoviedb/__init__.py | 4 ++-- app/modules/themoviedb/tmdbapi.py | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/agent/tools/impl/search_media.py b/app/agent/tools/impl/search_media.py index 63ee3243..b1abf57a 100644 --- a/app/agent/tools/impl/search_media.py +++ b/app/agent/tools/impl/search_media.py @@ -63,7 +63,7 @@ class SearchMediaTool(MoviePilotTool): if media_type: if result.type != MediaType(media_type): continue - if season and result.season != season: + if season is not None and result.season != season: continue filtered_results.append(result) diff --git a/app/agent/tools/impl/search_torrents.py b/app/agent/tools/impl/search_torrents.py index bedfdb2a..15a8957e 100644 --- a/app/agent/tools/impl/search_torrents.py +++ b/app/agent/tools/impl/search_torrents.py @@ -80,7 +80,7 @@ class SearchTorrentsTool(MoviePilotTool): if media_type and torrent.media_info: if torrent.media_info.type != MediaType(media_type): continue - if season and torrent.meta_info and torrent.meta_info.begin_season != season: + if season is not None and torrent.meta_info and torrent.meta_info.begin_season != season: continue # 使用正则表达式过滤标题(分辨率、质量等关键字) if regex_pattern and torrent.torrent_info and torrent.torrent_info.title: diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index eddef82b..69fbc1bb 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -195,7 +195,7 @@ async def seasons(mediaid: Optional[str] = None, tmdbid = int(mediaid[5:]) seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid) if seasons_info: - if season: + if season is not None: return [sea for sea in seasons_info if sea.season_number == season] return seasons_info if title: @@ -207,11 +207,11 @@ async def seasons(mediaid: Optional[str] = None, if settings.RECOGNIZE_SOURCE == "themoviedb": seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=mediainfo.tmdb_id) if seasons_info: - if season: + if season is not None: return [sea for sea in seasons_info if sea.season_number == season] return seasons_info else: - sea = season or 1 + sea = season if season is not None else 1 return [schemas.MediaSeason( season_number=sea, poster_path=mediainfo.poster_path, diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 7d31491c..4a331f9a 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -199,7 +199,7 @@ async def subscribe_mediaid( # 使用名称检查订阅 if title_check and title: meta = MetaInfo(title) - if season: + if season is not None: meta.begin_season = season result = await Subscribe.async_get_by_title(db, title=meta.name, season=meta.begin_season) diff --git a/app/chain/media.py b/app/chain/media.py index 220e7161..8eae13c5 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -958,10 +958,10 @@ class MediaChain(ChainBase): year = None if tmdbinfo.get('release_date'): year = tmdbinfo['release_date'][:4] - elif tmdbinfo.get('seasons') and season: + elif tmdbinfo.get('seasons') and season is not None: for seainfo in tmdbinfo['seasons']: season_number = seainfo.get("season_number") - if not season_number: + if season_number is None: continue air_date = seainfo.get("air_date") if air_date and season_number == season: diff --git a/app/modules/douban/scraper.py b/app/modules/douban/scraper.py index 9b2ad984..5fc4afab 100644 --- a/app/modules/douban/scraper.py +++ b/app/modules/douban/scraper.py @@ -21,7 +21,7 @@ class DoubanScraper: # 电影元数据文件 doc = self.__gen_movie_nfo_file(mediainfo=mediainfo) else: - if season: + if season is not None: # 季元数据文件 doc = self.__gen_tv_season_nfo_file(mediainfo=mediainfo, season=season) else: @@ -41,7 +41,7 @@ class DoubanScraper: :param episode: 集号 """ ret_dict = {} - if season: + if season is not None: # 豆瓣无季图片 return {} if episode: diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 4cfd494d..4ee52c2e 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -798,7 +798,7 @@ class TheMovieDbModule(_ModuleBase): if not tmdb_info: return [] return [schemas.TmdbSeason(**sea) - for sea in tmdb_info.get("seasons", []) if sea.get("season_number")] + for sea in tmdb_info.get("seasons", []) if sea.get("season_number") is not None] def tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]: """ @@ -1168,7 +1168,7 @@ class TheMovieDbModule(_ModuleBase): if not tmdb_info: return [] return [schemas.TmdbSeason(**sea) - for sea in tmdb_info.get("seasons", []) if sea.get("season_number")] + for sea in tmdb_info.get("seasons", []) if sea.get("season_number") is not None] async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]: """ diff --git a/app/modules/themoviedb/tmdbapi.py b/app/modules/themoviedb/tmdbapi.py index 417a5931..f0519d95 100644 --- a/app/modules/themoviedb/tmdbapi.py +++ b/app/modules/themoviedb/tmdbapi.py @@ -697,7 +697,7 @@ class TmdbApi: return {} ret_seasons = {} for season_info in tv_info.get("seasons") or []: - if not season_info.get("season_number"): + if season_info.get("season_number") is None: continue ret_seasons[season_info.get("season_number")] = season_info return ret_seasons From 832383448376b2fc82cb3bfd21d8b44630c1cedf Mon Sep 17 00:00:00 2001 From: CHANTXU64 Date: Mon, 2 Feb 2026 16:52:04 +0800 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96RSS=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E5=92=8C=E7=BD=91=E9=A1=B5=E6=8A=93=E5=8F=96=E4=B8=AD?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E6=97=A5=E6=9C=9F(PubDate)=E7=9A=84=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E5=85=BC=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/helper/rss.py: 优化RSS解析,支持带命名空间的日期标签(如 pubDate/published/updated)。 - app/modules/indexer/spider/__init__.py: 优化网页抓取,增加日期格式校验并对非标准格式进行自动归一化。 --- app/helper/rss.py | 5 ++++- app/modules/indexer/spider/__init__.py | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/helper/rss.py b/app/helper/rss.py index 5cd309d8..5257ff0c 100644 --- a/app/helper/rss.py +++ b/app/helper/rss.py @@ -382,7 +382,10 @@ class RssHelper: size = int(size_attr) # 发布日期 - pubdate_nodes = item.xpath('.//pubDate | .//published | .//updated') + pubdate_nodes = item.xpath('./pubDate | ./published | ./updated') + if not pubdate_nodes: + pubdate_nodes = item.xpath('.//*[local-name()="pubDate"] | .//*[local-name()="published"] | .//*[local-name()="updated"]') + pubdate = "" if pubdate_nodes and pubdate_nodes[0].text: pubdate = StringUtils.get_time(pubdate_nodes[0].text) diff --git a/app/modules/indexer/spider/__init__.py b/app/modules/indexer/spider/__init__.py index 1ced2441..ef2bba9d 100644 --- a/app/modules/indexer/spider/__init__.py +++ b/app/modules/indexer/spider/__init__.py @@ -428,6 +428,12 @@ class SiteSpider: if pubdate_str: pubdate_str = pubdate_str.replace('\n', ' ').strip() self.torrents_info['pubdate'] = self.__filter_text(pubdate_str, selector.get('filters')) + if self.torrents_info.get('pubdate'): + try: + if not isinstance(self.torrents_info['pubdate'], datetime.datetime): + datetime.datetime.strptime(str(self.torrents_info['pubdate']), '%Y-%m-%d %H:%M:%S') + except (ValueError, TypeError): + self.torrents_info['pubdate'] = StringUtils.unify_datetime_str(str(self.torrents_info['pubdate'])) def __get_date_elapsed(self, torrent: Any): # torrent date elapsed text From 8ce78eabca120152f9c53a49f03420d1783cf989 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 2 Feb 2026 18:44:30 +0800 Subject: [PATCH 06/13] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20version.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- version.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.py b/version.py index ca47d8cc..73b0d8ac 100644 --- a/version.py +++ b/version.py @@ -1,2 +1,2 @@ -APP_VERSION = 'v2.9.8' -FRONTEND_VERSION = 'v2.9.8' +APP_VERSION = 'v2.9.9' +FRONTEND_VERSION = 'v2.9.9' From f6a541f2b944cb695699fae901524f0bf5a43fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=AF=E5=A4=A7=E4=BE=A0?= Date: Mon, 2 Feb 2026 21:50:35 +0800 Subject: [PATCH 07/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E6=95=B4=E7=90=86=E5=A4=B1=E8=B4=A5=E6=97=B6=E8=AF=AF=E6=8A=A5?= =?UTF-8?q?=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/modules/filemanager/transhandler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/modules/filemanager/transhandler.py b/app/modules/filemanager/transhandler.py index db67bf71..13d2afa0 100644 --- a/app/modules/filemanager/transhandler.py +++ b/app/modules/filemanager/transhandler.py @@ -295,6 +295,7 @@ class TransHandler: elif overwrite_mode == 'never': # 存在不覆盖 self.__update_result(result=result, + success=False, message=f"媒体库存在同名文件,当前覆盖模式为不覆盖", fileitem=fileitem, target_item=target_item, From 498f1fec74afe9eb7e2211337503bc204f100c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=AF=E5=A4=A7=E4=BE=A0?= Date: Mon, 2 Feb 2026 23:12:42 +0800 Subject: [PATCH 08/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E6=95=B4=E7=90=86?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E5=8F=AF=E8=83=BD=E5=AF=BC=E8=87=B4=E8=AF=AF?= =?UTF-8?q?=E5=88=A0=E5=AD=97=E5=B9=95=E5=8F=8A=E9=9F=B3=E8=BD=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/modules/filemanager/transhandler.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/modules/filemanager/transhandler.py b/app/modules/filemanager/transhandler.py index db67bf71..202a7001 100644 --- a/app/modules/filemanager/transhandler.py +++ b/app/modules/filemanager/transhandler.py @@ -313,6 +313,9 @@ class TransHandler: logger.info( f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ...") self.__delete_version_files(target_oper, new_file) + else: + # 附加文件 总是需要覆盖 + overflag = True # 整理文件 new_item, err_msg = self.__transfer_file(fileitem=fileitem, @@ -797,8 +800,8 @@ class TransHandler: continue if media_file.type != "file": continue - media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT - if f".{media_file.extension.lower()}" not in media_exts: + # 当前只有视频文件需要保留最新版本,其余格式无需处理,以避免误删 (issue 5449) + if f".{media_file.extension.lower()}" not in settings.RMT_MEDIAEXT: continue # 识别文件中的季集信息 filemeta = MetaInfoPath(media_path) From 30488418e58658c7aedd96c9932618c4f4621f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E8=99=BE?= <802181+cddjr@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:38:05 +0800 Subject: [PATCH 09/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E6=95=B4=E7=90=86?= =?UTF-8?q?=E6=97=B6download=5Fhash=E5=8F=82=E6=95=B0=E8=A2=AB=E8=A6=86?= =?UTF-8?q?=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导致后续文件均识别成同一个媒体信息 --- app/chain/transfer.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/chain/transfer.py b/app/chain/transfer.py index a9930d7c..0a42bfc1 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -1380,8 +1380,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): # 获取下载Hash if download_history and (not downloader or not download_hash): - downloader = download_history.downloader - download_hash = download_history.download_hash + _downloader = download_history.downloader + _download_hash = download_history.download_hash + else: + _downloader = downloader + _download_hash = download_hash # 后台整理 transfer_task = TransferTask( @@ -1395,8 +1398,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): scrape=scrape, library_type_folder=library_type_folder, library_category_folder=library_category_folder, - downloader=downloader, - download_hash=download_hash, + downloader=_downloader, + download_hash=_download_hash, download_history=download_history, manual=manual, background=background From 72365d00b49ed39ce3054eebef027f3ced150746 Mon Sep 17 00:00:00 2001 From: ChanningHe Date: Wed, 4 Feb 2026 12:54:17 +0900 Subject: [PATCH 10/13] enhance: discord debug information --- app/modules/discord/__init__.py | 23 +++++++++++-- app/modules/discord/discord.py | 61 +++++++++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/app/modules/discord/__init__.py b/app/modules/discord/__init__.py index 82fc8664..15ed851a 100644 --- a/app/modules/discord/__init__.py +++ b/app/modules/discord/__init__.py @@ -139,9 +139,23 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): 发送通知消息 :param message: 消息通知对象 """ - for conf in self.get_configs().values(): + # DEBUG: Log entry and configs + configs = self.get_configs() + logger.debug(f"[Discord] post_message 被调用,message.source={message.source}, " + f"message.userid={message.userid}, message.channel={message.channel}") + logger.debug(f"[Discord] 当前配置数量: {len(configs)}, 配置名称: {list(configs.keys())}") + logger.debug(f"[Discord] 当前实例数量: {len(self.get_instances())}, 实例名称: {list(self.get_instances().keys())}") + + if not configs: + logger.warning("[Discord] get_configs() 返回空,没有可用的 Discord 配置") + return + + for conf in configs.values(): + logger.debug(f"[Discord] 检查配置: name={conf.name}, type={conf.type}, enabled={conf.enabled}") if not self.check_message(message, conf.name): + logger.debug(f"[Discord] check_message 返回 False,跳过配置: {conf.name}") continue + logger.debug(f"[Discord] check_message 通过,准备发送到: {conf.name}") targets = message.targets userid = message.userid if not userid and targets is not None: @@ -150,13 +164,18 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): logger.warn("用户没有指定 Discord 用户ID,消息无法发送") return client: Discord = self.get_instance(conf.name) + logger.debug(f"[Discord] get_instance('{conf.name}') 返回: {client is not None}") if client: - client.send_msg(title=message.title, text=message.text, + logger.debug(f"[Discord] 调用 client.send_msg, userid={userid}, title={message.title[:50] if message.title else None}...") + result = client.send_msg(title=message.title, text=message.text, image=message.image, userid=userid, link=message.link, buttons=message.buttons, original_message_id=message.original_message_id, original_chat_id=message.original_chat_id, mtype=message.mtype) + logger.debug(f"[Discord] send_msg 返回结果: {result}") + else: + logger.warning(f"[Discord] 未找到配置 '{conf.name}' 对应的 Discord 客户端实例") def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None: """ diff --git a/app/modules/discord/discord.py b/app/modules/discord/discord.py index 6108d2da..25159f1b 100644 --- a/app/modules/discord/discord.py +++ b/app/modules/discord/discord.py @@ -33,6 +33,9 @@ class Discord: DISCORD_GUILD_ID: Optional[Union[str, int]] = None, DISCORD_CHANNEL_ID: Optional[Union[str, int]] = None, **kwargs): + logger.debug(f"[Discord] 初始化 Discord 实例: name={kwargs.get('name')}, " + f"GUILD_ID={DISCORD_GUILD_ID}, CHANNEL_ID={DISCORD_CHANNEL_ID}, " + f"TOKEN={'已配置' if DISCORD_BOT_TOKEN else '未配置'}") if not DISCORD_BOT_TOKEN: logger.error("Discord Bot Token 未配置!") return @@ -40,10 +43,12 @@ class Discord: self._token = DISCORD_BOT_TOKEN self._guild_id = self._to_int(DISCORD_GUILD_ID) self._channel_id = self._to_int(DISCORD_CHANNEL_ID) + logger.debug(f"[Discord] 解析后的 ID: _guild_id={self._guild_id}, _channel_id={self._channel_id}") base_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message/" self._ds_url = f"{base_ds_url}?token={settings.API_TOKEN}" if kwargs.get("name"): self._ds_url = f"{self._ds_url}&source={kwargs.get('name')}" + logger.debug(f"[Discord] 消息回调 URL: {self._ds_url}") intents = discord.Intents.default() intents.message_content = True @@ -168,13 +173,19 @@ class Discord: original_message_id: Optional[Union[int, str]] = None, original_chat_id: Optional[str] = None, mtype: Optional['NotificationType'] = None) -> Optional[bool]: + logger.debug(f"[Discord] send_msg 被调用: userid={userid}, title={title[:50] if title else None}...") + logger.debug(f"[Discord] get_state() = {self.get_state()}, " + f"_ready_event.is_set() = {self._ready_event.is_set()}, " + f"_client = {self._client is not None}") if not self.get_state(): + logger.warning("[Discord] get_state() 返回 False,Bot 未就绪,无法发送消息") return False if not title and not text: logger.warn("标题和内容不能同时为空") return False try: + logger.debug(f"[Discord] 准备异步发送消息...") future = asyncio.run_coroutine_threadsafe( self._send_message(title=title, text=text, image=image, userid=userid, link=link, buttons=buttons, @@ -182,7 +193,9 @@ class Discord: original_chat_id=original_chat_id, mtype=mtype), self._loop) - return future.result(timeout=30) + result = future.result(timeout=30) + logger.debug(f"[Discord] 异步发送完成,结果: {result}") + return result except Exception as err: logger.error(f"发送 Discord 消息失败:{err}") return False @@ -254,7 +267,9 @@ class Discord: original_message_id: Optional[Union[int, str]], original_chat_id: Optional[str], mtype: Optional['NotificationType'] = None) -> bool: + logger.debug(f"[Discord] _send_message: userid={userid}, original_chat_id={original_chat_id}") channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id) + logger.debug(f"[Discord] _resolve_channel 返回: {channel}, type={type(channel)}") if not channel: logger.error("未找到可用的 Discord 频道或私聊") return False @@ -264,11 +279,18 @@ class Discord: content = None if original_message_id and original_chat_id: + logger.debug(f"[Discord] 编辑现有消息: message_id={original_message_id}") return await self._edit_message(chat_id=original_chat_id, message_id=original_message_id, content=content, embed=embed, view=view) - await channel.send(content=content, embed=embed, view=view) - return True + logger.debug(f"[Discord] 发送新消息到频道: {channel}") + try: + await channel.send(content=content, embed=embed, view=view) + logger.debug("[Discord] 消息发送成功") + return True + except Exception as e: + logger.error(f"[Discord] 发送消息到频道失败: {e}") + return False async def _send_list_message(self, embeds: List[discord.Embed], userid: Optional[str], @@ -515,26 +537,38 @@ class Discord: return view async def _resolve_channel(self, userid: Optional[str] = None, chat_id: Optional[str] = None): + logger.debug(f"[Discord] _resolve_channel: userid={userid}, chat_id={chat_id}, " + f"_channel_id={self._channel_id}, _guild_id={self._guild_id}") # 优先使用明确的聊天 ID if chat_id: + logger.debug(f"[Discord] 尝试通过 chat_id={chat_id} 获取频道") channel = self._client.get_channel(int(chat_id)) if channel: + logger.debug(f"[Discord] 通过 get_channel 找到频道: {channel}") return channel try: - return await self._client.fetch_channel(int(chat_id)) + channel = await self._client.fetch_channel(int(chat_id)) + logger.debug(f"[Discord] 通过 fetch_channel 找到频道: {channel}") + return channel except Exception as err: logger.warn(f"通过 chat_id 获取 Discord 频道失败:{err}") # 私聊 if userid: + logger.debug(f"[Discord] 尝试通过 userid={userid} 获取私聊频道") dm = await self._get_dm_channel(str(userid)) if dm: + logger.debug(f"[Discord] 获取到私聊频道: {dm}") return dm + else: + logger.debug(f"[Discord] 无法获取用户 {userid} 的私聊频道") # 配置的广播频道 if self._broadcast_channel: + logger.debug(f"[Discord] 使用缓存的广播频道: {self._broadcast_channel}") return self._broadcast_channel if self._channel_id: + logger.debug(f"[Discord] 尝试通过配置的 _channel_id={self._channel_id} 获取频道") channel = self._client.get_channel(self._channel_id) if not channel: try: @@ -544,9 +578,11 @@ class Discord: channel = None self._broadcast_channel = channel if channel: + logger.debug(f"[Discord] 通过配置的频道ID找到频道: {channel}") return channel # 按 Guild 寻找一个可用文本频道 + logger.debug(f"[Discord] 尝试在 Guild 中寻找可用频道") target_guilds = [] if self._guild_id: guild = self._client.get_guild(self._guild_id) @@ -554,6 +590,7 @@ class Discord: target_guilds.append(guild) else: target_guilds = list(self._client.guilds) + logger.debug(f"[Discord] 目标 Guilds 数量: {len(target_guilds)}") for guild in target_guilds: for channel in guild.text_channels: @@ -563,13 +600,25 @@ class Discord: return None async def _get_dm_channel(self, userid: str) -> Optional[discord.DMChannel]: + logger.debug(f"[Discord] _get_dm_channel: userid={userid}") if userid in self._user_dm_cache: + logger.debug(f"[Discord] 从缓存获取私聊频道: {self._user_dm_cache.get(userid)}") return self._user_dm_cache.get(userid) try: - user_obj = self._client.get_user(int(userid)) or await self._client.fetch_user(int(userid)) + logger.debug(f"[Discord] 尝试获取/创建用户 {userid} 的私聊频道") + user_obj = self._client.get_user(int(userid)) + logger.debug(f"[Discord] get_user 结果: {user_obj}") if not user_obj: + user_obj = await self._client.fetch_user(int(userid)) + logger.debug(f"[Discord] fetch_user 结果: {user_obj}") + if not user_obj: + logger.debug(f"[Discord] 无法找到用户 {userid}") return None - dm = user_obj.dm_channel or await user_obj.create_dm() + dm = user_obj.dm_channel + logger.debug(f"[Discord] 用户现有 dm_channel: {dm}") + if not dm: + dm = await user_obj.create_dm() + logger.debug(f"[Discord] 创建新的 dm_channel: {dm}") if dm: self._user_dm_cache[userid] = dm return dm From 636f338ed79f15cf91c5579bf2be4045f4424798 Mon Sep 17 00:00:00 2001 From: ChanningHe Date: Wed, 4 Feb 2026 13:42:33 +0900 Subject: [PATCH 11/13] enhance: [discord] add _user_chat_mapping to chat in channel --- app/modules/discord/discord.py | 78 ++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 12 deletions(-) diff --git a/app/modules/discord/discord.py b/app/modules/discord/discord.py index 25159f1b..8477c638 100644 --- a/app/modules/discord/discord.py +++ b/app/modules/discord/discord.py @@ -64,6 +64,7 @@ class Discord: self._thread: Optional[threading.Thread] = None self._ready_event = threading.Event() self._user_dm_cache: Dict[str, discord.DMChannel] = {} + self._user_chat_mapping: Dict[str, str] = {} # userid -> chat_id mapping for reply targeting self._broadcast_channel = None self._bot_user_id: Optional[int] = None @@ -91,6 +92,9 @@ class Discord: if not self._should_process_message(message): return + # Update user-chat mapping for reply targeting + self._update_user_chat_mapping(str(message.author.id), str(message.channel.id)) + cleaned_text = self._clean_bot_mention(message.content or "") username = message.author.display_name or message.author.global_name or message.author.name payload = { @@ -117,6 +121,10 @@ class Discord: except Exception as e: logger.error(f"处理 Discord 交互响应失败:{e}") + # Update user-chat mapping for reply targeting + if interaction.user and interaction.channel: + self._update_user_chat_mapping(str(interaction.user.id), str(interaction.channel.id)) + username = (interaction.user.display_name or interaction.user.global_name or interaction.user.name) \ if interaction.user else None payload = { @@ -537,11 +545,20 @@ class Discord: return view async def _resolve_channel(self, userid: Optional[str] = None, chat_id: Optional[str] = None): + """ + Resolve the channel to send messages to. + Priority order: + 1. chat_id (original channel where user sent the message) - for contextual replies + 2. userid (DM) - for private conversations + 3. Configured _channel_id (broadcast channel) - for system notifications + 4. Any available text channel in configured guild - fallback + """ logger.debug(f"[Discord] _resolve_channel: userid={userid}, chat_id={chat_id}, " f"_channel_id={self._channel_id}, _guild_id={self._guild_id}") - # 优先使用明确的聊天 ID + + # Priority 1: Use explicit chat_id (reply to the same channel where user sent message) if chat_id: - logger.debug(f"[Discord] 尝试通过 chat_id={chat_id} 获取频道") + logger.debug(f"[Discord] 尝试通过 chat_id={chat_id} 获取原始频道") channel = self._client.get_channel(int(chat_id)) if channel: logger.debug(f"[Discord] 通过 get_channel 找到频道: {channel}") @@ -553,17 +570,23 @@ class Discord: except Exception as err: logger.warn(f"通过 chat_id 获取 Discord 频道失败:{err}") - # 私聊 + # Priority 2: Use user-chat mapping (reply to where the user last sent a message) if userid: - logger.debug(f"[Discord] 尝试通过 userid={userid} 获取私聊频道") - dm = await self._get_dm_channel(str(userid)) - if dm: - logger.debug(f"[Discord] 获取到私聊频道: {dm}") - return dm - else: - logger.debug(f"[Discord] 无法获取用户 {userid} 的私聊频道") + mapped_chat_id = self._get_user_chat_id(str(userid)) + if mapped_chat_id: + logger.debug(f"[Discord] 从用户映射获取 chat_id={mapped_chat_id}") + channel = self._client.get_channel(int(mapped_chat_id)) + if channel: + logger.debug(f"[Discord] 通过映射找到频道: {channel}") + return channel + try: + channel = await self._client.fetch_channel(int(mapped_chat_id)) + logger.debug(f"[Discord] 通过 fetch_channel 找到映射频道: {channel}") + return channel + except Exception as err: + logger.warn(f"通过映射的 chat_id 获取 Discord 频道失败:{err}") - # 配置的广播频道 + # Priority 3: Use configured broadcast channel (for system notifications) if self._broadcast_channel: logger.debug(f"[Discord] 使用缓存的广播频道: {self._broadcast_channel}") return self._broadcast_channel @@ -581,7 +604,7 @@ class Discord: logger.debug(f"[Discord] 通过配置的频道ID找到频道: {channel}") return channel - # 按 Guild 寻找一个可用文本频道 + # Priority 4: Find any available text channel in guild (fallback) logger.debug(f"[Discord] 尝试在 Guild 中寻找可用频道") target_guilds = [] if self._guild_id: @@ -595,8 +618,20 @@ class Discord: for guild in target_guilds: for channel in guild.text_channels: if guild.me and channel.permissions_for(guild.me).send_messages: + logger.debug(f"[Discord] 在 Guild 中找到可用频道: {channel}") self._broadcast_channel = channel return channel + + # Priority 5: Fallback to DM (only if no channel available) + if userid: + logger.debug(f"[Discord] 回退到私聊: userid={userid}") + dm = await self._get_dm_channel(str(userid)) + if dm: + logger.debug(f"[Discord] 获取到私聊频道: {dm}") + return dm + else: + logger.debug(f"[Discord] 无法获取用户 {userid} 的私聊频道") + return None async def _get_dm_channel(self, userid: str) -> Optional[discord.DMChannel]: @@ -626,6 +661,25 @@ class Discord: logger.error(f"获取 Discord 私聊失败:{err}") return None + def _update_user_chat_mapping(self, userid: str, chat_id: str) -> None: + """ + Update user-chat mapping for reply targeting. + This ensures replies go to the same channel where the user sent the message. + :param userid: User ID + :param chat_id: Channel/Chat ID where the user sent the message + """ + if userid and chat_id: + self._user_chat_mapping[userid] = chat_id + logger.debug(f"[Discord] 更新用户频道映射: userid={userid} -> chat_id={chat_id}") + + def _get_user_chat_id(self, userid: str) -> Optional[str]: + """ + Get the chat ID where the user last sent a message. + :param userid: User ID + :return: Chat ID or None if not found + """ + return self._user_chat_mapping.get(userid) + def _should_process_message(self, message: discord.Message) -> bool: if isinstance(message.channel, discord.DMChannel): return True From 1147930f3fbeb84e39ec1f80fb126bd6b26af956 Mon Sep 17 00:00:00 2001 From: ChanningHe Date: Wed, 4 Feb 2026 14:09:40 +0900 Subject: [PATCH 12/13] fix: [slack&discord&telegram] handle special characters in config names --- app/modules/discord/discord.py | 12 ++++++++---- app/modules/slack/slack.py | 5 ++++- app/modules/telegram/telegram.py | 6 ++++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/modules/discord/discord.py b/app/modules/discord/discord.py index 8477c638..f1997c5c 100644 --- a/app/modules/discord/discord.py +++ b/app/modules/discord/discord.py @@ -2,6 +2,7 @@ import asyncio import re import threading from typing import Optional, List, Dict, Any, Tuple, Union +from urllib.parse import quote import discord from discord import app_commands @@ -47,7 +48,9 @@ class Discord: base_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message/" self._ds_url = f"{base_ds_url}?token={settings.API_TOKEN}" if kwargs.get("name"): - self._ds_url = f"{self._ds_url}&source={kwargs.get('name')}" + # URL encode the source name to handle special characters in config names + encoded_name = quote(kwargs.get('name'), safe='') + self._ds_url = f"{self._ds_url}&source={encoded_name}" logger.debug(f"[Discord] 消息回调 URL: {self._ds_url}") intents = discord.Intents.default() @@ -548,10 +551,11 @@ class Discord: """ Resolve the channel to send messages to. Priority order: - 1. chat_id (original channel where user sent the message) - for contextual replies - 2. userid (DM) - for private conversations - 3. Configured _channel_id (broadcast channel) - for system notifications + 1. `chat_id` (original channel where user sent the message) - for contextual replies + 2. `userid` mapping (channel where user last sent a message) - for contextual replies + 3. Configured `_channel_id` (broadcast channel) - for system notifications 4. Any available text channel in configured guild - fallback + 5. `userid` (DM) - for private conversations as a final fallback """ logger.debug(f"[Discord] _resolve_channel: userid={userid}, chat_id={chat_id}, " f"_channel_id={self._channel_id}, _guild_id={self._guild_id}") diff --git a/app/modules/slack/slack.py b/app/modules/slack/slack.py index 3931ecee..16f890c0 100644 --- a/app/modules/slack/slack.py +++ b/app/modules/slack/slack.py @@ -1,6 +1,7 @@ import re from threading import Lock from typing import List, Optional +from urllib.parse import quote import requests from slack_bolt import App @@ -42,7 +43,9 @@ class Slack: # 标记消息来源 if kwargs.get("name"): - self._ds_url = f"{self._ds_url}&source={kwargs.get('name')}" + # URL encode the source name to handle special characters + encoded_name = quote(kwargs.get('name'), safe='') + self._ds_url = f"{self._ds_url}&source={encoded_name}" # 注册消息响应 @slack_app.event("message") diff --git a/app/modules/telegram/telegram.py b/app/modules/telegram/telegram.py index c7588e95..86817b8a 100644 --- a/app/modules/telegram/telegram.py +++ b/app/modules/telegram/telegram.py @@ -2,7 +2,7 @@ import asyncio import re import threading from typing import Optional, List, Dict, Callable -from urllib.parse import urljoin +from urllib.parse import urljoin, quote from telebot import TeleBot, apihelper from telebot.types import BotCommand, InlineKeyboardMarkup, InlineKeyboardButton, InputMediaPhoto @@ -65,7 +65,9 @@ class Telegram: # 标记渠道来源 if kwargs.get("name"): - self._ds_url = f"{self._ds_url}&source={kwargs.get('name')}" + # URL encode the source name to handle special characters + encoded_name = quote(kwargs.get('name'), safe='') + self._ds_url = f"{self._ds_url}&source={encoded_name}" @_bot.message_handler(commands=['start', 'help']) def send_welcome(message): From a1829fe59071da78e84f31e0c0ac62123097bdc9 Mon Sep 17 00:00:00 2001 From: DDSRem <1448139087@qq.com> Date: Wed, 4 Feb 2026 23:24:14 +0800 Subject: [PATCH 13/13] feat: u115 global rate limiting strategy --- app/modules/filemanager/storages/u115.py | 110 +++++++++++++------- app/utils/limit.py | 123 ++++++++++++++++++++--- 2 files changed, 181 insertions(+), 52 deletions(-) diff --git a/app/modules/filemanager/storages/u115.py b/app/modules/filemanager/storages/u115.py index 47a8799e..cff7b2a8 100644 --- a/app/modules/filemanager/storages/u115.py +++ b/app/modules/filemanager/storages/u115.py @@ -3,7 +3,7 @@ import secrets import time from pathlib import Path from threading import Lock -from typing import List, Optional, Tuple, Union, Dict +from typing import List, Optional, Tuple, Union from hashlib import sha256 import oss2 @@ -20,7 +20,7 @@ from app.modules.filemanager.storages import transfer_process from app.schemas.types import StorageSchema from app.utils.singleton import WeakSingleton from app.utils.string import StringUtils -from app.utils.limit import QpsRateLimiter +from app.utils.limit import QpsRateLimiter, RateStats lock = Lock() @@ -46,22 +46,23 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): # 文件块大小,默认10MB chunk_size = 10 * 1024 * 1024 - # 流控重试间隔时间 - retry_delay = 70 + # 下载接口单独限流 + download_endpoint = "/open/ufile/downurl" + # 风控触发后休眠时间(秒) + limit_sleep_seconds = 3600 def __init__(self): super().__init__() self._auth_state = {} self.session = httpx.Client(follow_redirects=True, timeout=20.0) self._init_session() - self.qps_limiter: Dict[str, QpsRateLimiter] = { - "/open/ufile/files": QpsRateLimiter(4), - "/open/folder/get_info": QpsRateLimiter(3), - "/open/ufile/move": QpsRateLimiter(2), - "/open/ufile/copy": QpsRateLimiter(2), - "/open/ufile/update": QpsRateLimiter(2), - "/open/ufile/delete": QpsRateLimiter(2), - } + # 接口限流 + self._download_limiter = QpsRateLimiter(1) + self._api_limiter = QpsRateLimiter(3) + self._limit_until = 0.0 + self._limit_lock = Lock() + # 总体 QPS/QPM/QPH 统计 + self._rate_stats = RateStats(source="115") def _init_session(self): """ @@ -209,8 +210,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): try: resp = self.session.get( - f"{settings.U115_AUTH_SERVER}/u115/token", - params={"state": state} + f"{settings.U115_AUTH_SERVER}/u115/token", params={"state": state} ) if resp is None: return {}, "无法连接到授权服务器" @@ -221,12 +221,14 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): if status == "completed": data = result.get("data", {}) if data: - self.set_config({ - "refresh_time": int(time.time()), - "access_token": data.get("access_token"), - "refresh_token": data.get("refresh_token"), - "expires_in": data.get("expires_in"), - }) + self.set_config( + { + "refresh_time": int(time.time()), + "access_token": data.get("access_token"), + "refresh_token": data.get("refresh_token"), + "expires_in": data.get("expires_in"), + } + ) self._auth_state = {} return {"status": 2, "tip": "授权成功"}, "" return {}, "授权服务器返回数据不完整" @@ -292,11 +294,24 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): # 错误日志标志 no_error_log = kwargs.pop("no_error_log", False) # 重试次数 - retry_times = kwargs.pop("retry_limit", 5) + retry_times = kwargs.pop("retry_limit", 3) - # qps 速率限制 - if endpoint in self.qps_limiter: - self.qps_limiter[endpoint].acquire() + # 按接口类型限流 + if endpoint == self.download_endpoint: + self._download_limiter.acquire() + else: + self._api_limiter.acquire() + self._rate_stats.record() + + # 风控冷却期间阻止所有接口调用,统一等待 + with self._limit_lock: + wait_until = self._limit_until + if wait_until > time.time(): + wait_secs = wait_until - time.time() + logger.info( + f"【115】风控冷却中,本请求等待 {wait_secs:.0f} 秒后再调用接口..." + ) + time.sleep(wait_secs) try: resp = self.session.request(method, f"{self.base_url}{endpoint}", **kwargs) @@ -310,13 +325,24 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): kwargs["retry_limit"] = retry_times - # 处理速率限制 if resp.status_code == 429: - reset_time = 5 + int(resp.headers.get("X-RateLimit-Reset", 60)) - logger.debug( - f"【115】{method} 请求 {endpoint} 限流,等待{reset_time}秒后重试" + self._rate_stats.log_stats("warning") + if retry_times <= 0: + logger.error( + f"【115】{method} 请求 {endpoint} 触发限流(429),重试次数用尽!" + ) + return None + with self._limit_lock: + self._limit_until = max( + self._limit_until, + time.time() + self.limit_sleep_seconds, + ) + logger.warning( + f"【115】触发限流(429),全体接口进入风控冷却 {self.limit_sleep_seconds} 秒,随后重试..." ) - time.sleep(reset_time) + time.sleep(self.limit_sleep_seconds) + kwargs["retry_limit"] = retry_times - 1 + kwargs["no_error_log"] = no_error_log return self._request_api(method, endpoint, result_key, **kwargs) # 处理请求错误 @@ -329,6 +355,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): ) return None kwargs["retry_limit"] = retry_times - 1 + kwargs["no_error_log"] = no_error_log sleep_duration = 2 ** (5 - retry_times + 1) logger.info( f"【115】{method} 请求 {endpoint} 错误 {e},等待 {sleep_duration} 秒后重试..." @@ -339,20 +366,27 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): # 返回数据 ret_data = resp.json() if ret_data.get("code") not in (0, 20004): - error_msg = ret_data.get("message") + error_msg = ret_data.get("message", "") if not no_error_log: logger.warn(f"【115】{method} 请求 {endpoint} 出错:{error_msg}") if "已达到当前访问上限" in error_msg: + self._rate_stats.log_stats("warning") if retry_times <= 0: logger.error( - f"【115】{method} 请求 {endpoint} 达到访问上限,重试次数用尽!" + f"【115】{method} 请求 {endpoint} 触发风控(访问上限),重试次数用尽!" ) return None - kwargs["retry_limit"] = retry_times - 1 - logger.info( - f"【115】{method} 请求 {endpoint} 达到访问上限,等待 {self.retry_delay} 秒后重试..." + with self._limit_lock: + self._limit_until = max( + self._limit_until, + time.time() + self.limit_sleep_seconds, + ) + logger.warning( + f"【115】触发风控(访问上限),全体接口进入风控冷却 {self.limit_sleep_seconds} 秒,随后重试..." ) - time.sleep(self.retry_delay) + time.sleep(self.limit_sleep_seconds) + kwargs["retry_limit"] = retry_times - 1 + kwargs["no_error_log"] = no_error_log return self._request_api(method, endpoint, result_key, **kwargs) return None @@ -879,7 +913,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ - 企业级复制实现(支持目录递归复制) + 复制 """ if fileitem.fileid is None: fileitem = self.get_item(Path(fileitem.path)) @@ -912,7 +946,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ - 原子性移动操作实现 + 移动 """ if fileitem.fileid is None: fileitem = self.get_item(Path(fileitem.path)) @@ -950,7 +984,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton): def usage(self) -> Optional[schemas.StorageUsage]: """ - 获取带有企业级配额信息的存储使用情况 + 存储使用情况 """ try: resp = self._request_api("GET", "/open/user/info", "data") diff --git a/app/utils/limit.py b/app/utils/limit.py index e9a90acd..a3e48d74 100644 --- a/app/utils/limit.py +++ b/app/utils/limit.py @@ -98,8 +98,14 @@ class ExponentialBackoffRateLimiter(BaseRateLimiter): 每次触发限流时,等待时间会成倍增加,直到达到最大等待时间 """ - def __init__(self, base_wait: float = 60.0, max_wait: float = 600.0, backoff_factor: float = 2.0, - source: str = "", enable_logging: bool = True): + def __init__( + self, + base_wait: float = 60.0, + max_wait: float = 600.0, + backoff_factor: float = 2.0, + source: str = "", + enable_logging: bool = True, + ): """ 初始化 ExponentialBackoffRateLimiter 实例 :param base_wait: 基础等待时间(秒),默认值为 60 秒(1 分钟) @@ -156,7 +162,9 @@ class ExponentialBackoffRateLimiter(BaseRateLimiter): current_time = time.time() with self.lock: self.next_allowed_time = current_time + self.current_wait - self.current_wait = min(self.current_wait * self.backoff_factor, self.max_wait) + self.current_wait = min( + self.current_wait * self.backoff_factor, self.max_wait + ) wait_time = self.next_allowed_time - current_time self.log_warning(f"触发限流,将在 {wait_time:.2f} 秒后允许继续调用") @@ -168,8 +176,13 @@ class WindowRateLimiter(BaseRateLimiter): 如果超过允许的最大调用次数,则限流直到窗口期结束 """ - def __init__(self, max_calls: int, window_seconds: float, - source: str = "", enable_logging: bool = True): + def __init__( + self, + max_calls: int, + window_seconds: float, + source: str = "", + enable_logging: bool = True, + ): """ 初始化 WindowRateLimiter 实例 :param max_calls: 在时间窗口内允许的最大调用次数 @@ -190,7 +203,10 @@ class WindowRateLimiter(BaseRateLimiter): current_time = time.time() with self.lock: # 清理超出时间窗口的调用记录 - while self.call_times and current_time - self.call_times[0] > self.window_seconds: + while ( + self.call_times + and current_time - self.call_times[0] > self.window_seconds + ): self.call_times.popleft() if len(self.call_times) < self.max_calls: @@ -225,8 +241,12 @@ class CompositeRateLimiter(BaseRateLimiter): 当任意一个限流策略触发限流时,都会阻止调用 """ - def __init__(self, limiters: List[BaseRateLimiter], source: str = "", enable_logging: bool = True): - + def __init__( + self, + limiters: List[BaseRateLimiter], + source: str = "", + enable_logging: bool = True, + ): """ 初始化 CompositeRateLimiter 实例 :param limiters: 要组合的限流器列表 @@ -263,7 +283,9 @@ class CompositeRateLimiter(BaseRateLimiter): # 通用装饰器:自定义限流器实例 -def rate_limit_handler(limiter: BaseRateLimiter, raise_on_limit: bool = False) -> Callable: +def rate_limit_handler( + limiter: BaseRateLimiter, raise_on_limit: bool = False +) -> Callable: """ 通用装饰器,允许用户传递自定义的限流器实例,用于处理限流逻辑 该装饰器可灵活支持任意继承自 BaseRateLimiter 的限流器 @@ -344,8 +366,14 @@ def rate_limit_handler(limiter: BaseRateLimiter, raise_on_limit: bool = False) - # 装饰器:指数退避限流 -def rate_limit_exponential(base_wait: float = 60.0, max_wait: float = 600.0, backoff_factor: float = 2.0, - raise_on_limit: bool = False, source: str = "", enable_logging: bool = True) -> Callable: +def rate_limit_exponential( + base_wait: float = 60.0, + max_wait: float = 600.0, + backoff_factor: float = 2.0, + raise_on_limit: bool = False, + source: str = "", + enable_logging: bool = True, +) -> Callable: """ 装饰器,用于应用指数退避限流策略 通过逐渐增加调用等待时间控制调用频率。每次触发限流时,等待时间会成倍增加,直到达到最大等待时间 @@ -359,14 +387,21 @@ def rate_limit_exponential(base_wait: float = 60.0, max_wait: float = 600.0, bac :return: 装饰器函数 """ # 实例化 ExponentialBackoffRateLimiter,并传入相关参数 - limiter = ExponentialBackoffRateLimiter(base_wait, max_wait, backoff_factor, source, enable_logging) + limiter = ExponentialBackoffRateLimiter( + base_wait, max_wait, backoff_factor, source, enable_logging + ) # 使用通用装饰器逻辑包装该限流器 return rate_limit_handler(limiter, raise_on_limit) # 装饰器:时间窗口限流 -def rate_limit_window(max_calls: int, window_seconds: float, - raise_on_limit: bool = False, source: str = "", enable_logging: bool = True) -> Callable: +def rate_limit_window( + max_calls: int, + window_seconds: float, + raise_on_limit: bool = False, + source: str = "", + enable_logging: bool = True, +) -> Callable: """ 装饰器,用于应用时间窗口限流策略 在固定的时间窗口内限制调用次数,当调用次数超过最大值时,触发限流,直到时间窗口结束 @@ -407,3 +442,63 @@ class QpsRateLimiter: self.next_call_time = max(now, self.next_call_time) + self.interval if sleep_duration > 0: time.sleep(sleep_duration) + + +class RateStats: + """ + 请求速率统计:记录时间戳,计算 QPS / QPM / QPH + """ + + def __init__(self, window_seconds: float = 7200, source: str = ""): + """ + :param window_seconds: 统计窗口(秒),默认 2 小时,用于计算 QPH + :param source: 日志来源标识 + """ + self._window = window_seconds + self._source = source + self._lock = threading.Lock() + self._timestamps: deque = deque() + + def record(self) -> None: + """ + 记录一次请求 + """ + t = time.time() + with self._lock: + self._timestamps.append(t) + while self._timestamps and t - self._timestamps[0] > self._window: + self._timestamps.popleft() + + def _count_since(self, seconds: float) -> int: + t = time.time() + with self._lock: + return sum(1 for ts in self._timestamps if t - ts <= seconds) + + def get_qps(self) -> float: + """ + 最近 1 秒内请求数 + """ + return self._count_since(1.0) + + def get_qpm(self) -> float: + """ + 最近 1 分钟内请求数 + """ + return self._count_since(60.0) + + def get_qph(self) -> float: + """ + 最近 1 小时内请求数 + """ + return self._count_since(3600.0) + + def log_stats(self, level: str = "info") -> None: + """ + 输出当前 QPS/QPM/QPH + """ + qps, qpm, qph = self.get_qps(), self.get_qpm(), self.get_qph() + msg = f"QPS={qps} QPM={qpm} QPH={qph}" + if self._source: + msg = f"[{self._source}] {msg}" + log_fn = getattr(logger, level, logger.info) + log_fn(msg)