diff --git a/app/core/config.py b/app/core/config.py index d2d6faf7..35c02be7 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -9,10 +9,10 @@ import sys import threading from asyncio import AbstractEventLoop from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple, Type +from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_origin, get_args from urllib.parse import quote, urlencode, urlparse -from dotenv import set_key +from dotenv import set_key, unset_key from pydantic import BaseModel, Field, ConfigDict, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -690,6 +690,18 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): if isinstance(value, str): value = value.strip() + # 处理 Optional 类型:当值为空字符串且类型允许 None 时,转为 None + # 兼容 typing.Union (Python 3.9) 与 types.UnionType (Python 3.10+ PEP 604) + origin = get_origin(expected_type) + is_union = origin is Union or getattr(origin, "__name__", None) == "UnionType" + if ( + is_union + and type(None) in get_args(expected_type) + and isinstance(value, str) + and not value + ): + return default, str(default) != str(original_value) + try: if expected_type is bool: if isinstance(value, bool): @@ -812,13 +824,19 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): logger.warning(message) return False, message else: + # 当值为 None 时,从 env 文件中删除该键,恢复为默认值 + if converted_value is None: + unset_key( + dotenv_path=SystemUtils.get_env_path(), + key_to_unset=field_name, + ) + logger.info(f"配置项 '{field_name}' 已清空,从 'app.env' 中移除") + return True, message # 如果是列表、字典或集合类型,将其转换为JSON字符串 if isinstance(converted_value, (list, dict, set)): value_to_write = json.dumps(converted_value) else: - value_to_write = ( - str(converted_value) if converted_value is not None else "" - ) + value_to_write = str(converted_value) set_key( dotenv_path=SystemUtils.get_env_path(), @@ -967,7 +985,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): @property def PROXY(self): - if self.PROXY_HOST: + if self.PROXY_HOST and self.PROXY_HOST.strip(): return { "http": self.PROXY_HOST, "https": self.PROXY_HOST, @@ -1009,7 +1027,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): @property def PROXY_SERVER(self): - if self.PROXY_HOST: + if self.PROXY_HOST and self.PROXY_HOST.strip(): try: parsed = urlparse(self.PROXY_HOST) if not parsed.scheme: diff --git a/app/utils/http.py b/app/utils/http.py index f9a9a14a..3556c99a 100644 --- a/app/utils/http.py +++ b/app/utils/http.py @@ -92,6 +92,9 @@ def _get_shared_async_transport( 会话级状态由调用方在外层 AsyncClient(transport=...) 实例化时单独配置, 每次调用用完即销毁,因此天然无 jar 累积串扰。 """ + # 规范化代理:拒绝空字符串等非法值,防止 httpx 抛出 Unknown scheme for proxy URL + if proxy is not None and (not proxy or not proxy.strip()): + proxy = None try: loop = asyncio.get_running_loop() except RuntimeError: @@ -899,12 +902,17 @@ class AsyncRequestUtils: # 如果已经是字符串格式,直接返回 if isinstance(proxies, str): - return proxies + return proxies.strip() or None # 如果是字典格式,提取http或https代理 if isinstance(proxies, dict): # 优先使用https代理,如果没有则使用http代理 - proxy_url = proxies.get("https") or proxies.get("http") + # 先各自 strip,避免空白字符串阻断裂合取或回退到 http 代理 + https_proxy = proxies.get("https") + http_proxy = proxies.get("http") + https_proxy = https_proxy.strip() if isinstance(https_proxy, str) else None + http_proxy = http_proxy.strip() if isinstance(http_proxy, str) else None + proxy_url = https_proxy or http_proxy if proxy_url: return proxy_url