⚒️ 重大重构 LoveACE V2
引入了 mongodb 对数据库进行了一定程度的数据加密 性能改善 代码简化 统一错误模型和响应 使用 apifox 作为文档
This commit is contained in:
13
loveace/service/model/service.py
Normal file
13
loveace/service/model/service.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from abc import abstractmethod
|
||||
|
||||
|
||||
class Service:
|
||||
@abstractmethod
|
||||
async def initialize(self):
|
||||
"""初始化服务"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def shutdown(self):
|
||||
"""关闭服务"""
|
||||
pass
|
||||
441
loveace/service/remote/aufe/__init__.py
Normal file
441
loveace/service/remote/aufe/__init__.py
Normal file
@@ -0,0 +1,441 @@
|
||||
import asyncio
|
||||
import binascii
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from asyncio import Task
|
||||
from base64 import b64encode
|
||||
from datetime import datetime
|
||||
from typing import Dict, Type, TypeVar
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import padding as symmetric_padding
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from httpx import AsyncClient, RequestError
|
||||
from httpx._types import HeaderTypes
|
||||
|
||||
from loveace.config.logger import LoggerMixin
|
||||
from loveace.config.manager import config_manager
|
||||
from loveace.service.model.service import Service
|
||||
from loveace.service.remote.aufe.model.status import (
|
||||
ECCheckStatus,
|
||||
ECLoginStatus,
|
||||
UAAPLoginStatus,
|
||||
)
|
||||
|
||||
# 设置 HTTPX 日志级别为 CRITICAL
|
||||
if not config_manager.get_settings().app.debug:
|
||||
logging.getLogger("httpx").setLevel(logging.CRITICAL)
|
||||
|
||||
|
||||
class SubClient:
|
||||
|
||||
async def aclose(self): ...
|
||||
|
||||
|
||||
T_SubClient = TypeVar("T_SubClient", bound=SubClient)
|
||||
|
||||
|
||||
class AUFEConnection:
|
||||
userid: str
|
||||
ec_password: str
|
||||
password: str
|
||||
_client: AsyncClient
|
||||
twf_id: str
|
||||
last_check: datetime
|
||||
ec_logged: bool = False
|
||||
uaap_logged: bool = False
|
||||
trace_id: str
|
||||
timeout: int = 30
|
||||
_sub_clients: Dict[str, SubClient] = {}
|
||||
|
||||
def __init__(self, userid: str, ec_password: str, password: str):
|
||||
self.userid = userid
|
||||
self.ec_password = ec_password
|
||||
self.password = password
|
||||
self.last_check = datetime.now()
|
||||
self.trace_id = str(uuid.uuid4().hex)
|
||||
self.timeout = config_manager.get_settings().aufe.default_timeout
|
||||
self.logger.info(
|
||||
f"创建AUFE连接,用户ID: {self.userid}, Trace ID: {self.trace_id},超时: {self.timeout}s"
|
||||
)
|
||||
|
||||
@property
|
||||
def logger(self) -> LoggerMixin:
|
||||
return LoggerMixin(user_id=self.userid, trace_id=self.trace_id)
|
||||
|
||||
def start_client(self):
|
||||
self._client = AsyncClient()
|
||||
|
||||
def health_checkpoint(self):
|
||||
self.last_check = datetime.now()
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
delta = datetime.now() - self.last_check
|
||||
self.logger.info(
|
||||
f"AUFE连接健康检查,距离上次检查时间: {delta.total_seconds()}秒"
|
||||
)
|
||||
if delta.total_seconds() > 300: # 5分钟未检查则视为不健康
|
||||
self.logger.warning("AUFE连接不健康,已超过5分钟未检查,将自动关闭")
|
||||
return False
|
||||
if self._client.is_closed:
|
||||
self.logger.warning("AUFE连接已关闭")
|
||||
return False
|
||||
check_uaap = await self.check_uaap_login_status()
|
||||
if not check_uaap.logged_in:
|
||||
self.logger.warning("UAAP登录状态无效,可能需要重新登录")
|
||||
return False
|
||||
check_ec = await self.check_ec_login_status()
|
||||
if not check_ec.logged_in:
|
||||
self.logger.warning("EC登录状态无效,可能需要重新登录")
|
||||
return False
|
||||
return True
|
||||
|
||||
def inject_subclient(self, name: str, sub_client: SubClient):
|
||||
"""
|
||||
注入子客户端
|
||||
该方法用于将子客户端的关闭方法绑定到主客户端上
|
||||
以便在关闭主客户端时也能关闭子客户端
|
||||
Args:
|
||||
sub_client (SubClient): 子客户端实例,必须实现 aclose 方法
|
||||
"""
|
||||
self.logger.info(f"注入子客户端 {name},类型: {type(sub_client).__name__}")
|
||||
self._sub_clients[name] = sub_client
|
||||
|
||||
def get_subclient(
|
||||
self, name: str, type_sub_client: Type[T_SubClient]
|
||||
) -> T_SubClient | None:
|
||||
"""
|
||||
获取已注入的子客户端
|
||||
Args:
|
||||
name (str): 子客户端名称
|
||||
type_sub_client (Type[T_SubClient]): 子客户端类型,用于类型检查
|
||||
Returns:
|
||||
T_SubClient: 子客户端实例
|
||||
Raises:
|
||||
ValueError: 如果子客户端不存在或类型不匹配
|
||||
"""
|
||||
if name not in self._sub_clients:
|
||||
return None
|
||||
sub_client = self._sub_clients[name]
|
||||
if not isinstance(sub_client, type_sub_client):
|
||||
return None
|
||||
return sub_client
|
||||
|
||||
async def close_client(self):
|
||||
await self._client.aclose()
|
||||
for sub_client in self._sub_clients.values():
|
||||
self.logger.info(f"正在关闭子客户端 {type(sub_client).__name__}")
|
||||
await sub_client.aclose()
|
||||
self._sub_clients.clear()
|
||||
|
||||
async def ec_login(self) -> ECLoginStatus:
|
||||
"""
|
||||
使用用户名和密码登录AUFE
|
||||
"""
|
||||
try:
|
||||
# 初始请求获取认证参数
|
||||
response = await self._client.get(
|
||||
f"{config_manager.get_settings().aufe.server_url}/por/login_auth.csp?apiversion=1"
|
||||
)
|
||||
if twfid_g := re.search(r"<TwfID>(.*)</TwfID>", response.text):
|
||||
self.twf_id = twfid_g.group(1)
|
||||
else:
|
||||
self.logger.error("错误: 响应中未找到TwfID。")
|
||||
return ECLoginStatus(fail_not_found_twfid=True)
|
||||
self.logger.info(f"Twf Id: {self.twf_id[:5]}******")
|
||||
if rsa_key_g := re.search(
|
||||
r"<RSA_ENCRYPT_KEY>(.*)</RSA_ENCRYPT_KEY>", response.text
|
||||
):
|
||||
rsa_key = rsa_key_g.group(1)
|
||||
else:
|
||||
self.logger.error("错误: 响应中未找到RSA_ENCRYPT_KEY。")
|
||||
return ECLoginStatus(fail_not_found_rsa_key=True)
|
||||
self.logger.info(f"RSA密钥: {rsa_key[:5]}******")
|
||||
if rsa_exp_match := re.search(
|
||||
r"<RSA_ENCRYPT_EXP>(.*)</RSA_ENCRYPT_EXP>", response.text
|
||||
):
|
||||
rsa_exp = rsa_exp_match.group(1)
|
||||
else:
|
||||
self.logger.error("错误: 响应中未找到RSA_ENCRYPT_EXP。")
|
||||
return ECLoginStatus(fail_not_found_rsa_exp=True)
|
||||
self.logger.info(f"RSA指数: {rsa_exp[:5]}******")
|
||||
if csrf_match := re.search(
|
||||
r"<CSRF_RAND_CODE>(.*)</CSRF_RAND_CODE>", response.text
|
||||
):
|
||||
csrf_code = csrf_match.group(1)
|
||||
password_to_encrypt = self.password + "_" + csrf_code
|
||||
else:
|
||||
self.logger.error("错误: 响应中未找到CSRF_RAND_CODE。")
|
||||
return ECLoginStatus(fail_not_found_csrf_code=True)
|
||||
self.logger.info(f"CSRF代码: {csrf_code[:5]}******")
|
||||
# 创建RSA密钥并加密密码
|
||||
rsa_exp_int = int(rsa_exp)
|
||||
rsa_modulus = int(rsa_key, 16)
|
||||
public_numbers = rsa.RSAPublicNumbers(e=rsa_exp_int, n=rsa_modulus)
|
||||
public_key = public_numbers.public_key(default_backend())
|
||||
encrypted_password = public_key.encrypt(
|
||||
password_to_encrypt.encode("utf-8"), padding.PKCS1v15()
|
||||
)
|
||||
encrypted_password_hex = binascii.hexlify(encrypted_password).decode(
|
||||
"ascii"
|
||||
)
|
||||
self.logger.info(f"加密后密码: {encrypted_password_hex[:5]}******")
|
||||
self.logger.info("开始执行登录请求")
|
||||
login_response = await self._client.post(
|
||||
f"{config_manager.get_settings().aufe.server_url}/por/login_psw.csp?anti_replay=1&encrypt=1&type=cs",
|
||||
data={
|
||||
"svpn_rand_code": "",
|
||||
"mitm": "",
|
||||
"svpn_req_randcode": csrf_code,
|
||||
"svpn_name": self.userid,
|
||||
"svpn_password": encrypted_password_hex,
|
||||
},
|
||||
cookies={"TWFID": self.twf_id},
|
||||
timeout=10000,
|
||||
)
|
||||
self.logger.info(f"登录响应: {login_response.text[:10]}******")
|
||||
# 检查登录结果
|
||||
if "<Result>1</Result>" in login_response.text:
|
||||
self.logger.info("登录成功")
|
||||
self._client.cookies.set("TWFID", self.twf_id)
|
||||
self.ec_logged = True
|
||||
return ECLoginStatus(success=True)
|
||||
elif "Invalid username or password!" in login_response.text:
|
||||
self.logger.error("登录失败: 用户名或密码错误")
|
||||
return ECLoginStatus(fail_invalid_credentials=True)
|
||||
elif "[CDATA[maybe attacked]]" in login_response.text or "CAPTCHA required" in login_response.text:
|
||||
self.logger.error("登录失败: 可能受到攻击或需要验证码")
|
||||
return ECLoginStatus(fail_maybe_attacked=True)
|
||||
else:
|
||||
self.logger.error(f"登录失败: {login_response.text}")
|
||||
return ECLoginStatus(fail_unknown_error=True)
|
||||
|
||||
except RequestError as e:
|
||||
self.logger.error(f"登录连接错误: {str(e)}")
|
||||
return ECLoginStatus(fail_network_error=True)
|
||||
except Exception as e:
|
||||
self.logger.error(f"登录失败: {e}")
|
||||
return ECLoginStatus(fail_unknown_error=True)
|
||||
|
||||
async def check_ec_login_status(self) -> ECCheckStatus:
|
||||
"""
|
||||
检查当前登录状态
|
||||
"""
|
||||
if not self.ec_logged:
|
||||
return ECCheckStatus(logged_in=False)
|
||||
try:
|
||||
response = await self._client.get(
|
||||
config_manager.get_settings().aufe.ec_check_url,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
self.logger.info("登录状态有效")
|
||||
return ECCheckStatus(logged_in=True)
|
||||
else:
|
||||
self.logger.warning("登录状态无效,可能需要重新登录")
|
||||
return ECCheckStatus(logged_in=False)
|
||||
except RequestError as e:
|
||||
self.logger.error(f"检查登录状态连接错误: {str(e)}")
|
||||
return ECCheckStatus(fail_network_error=True)
|
||||
except Exception as e:
|
||||
self.logger.error(f"检查登录状态失败: {e}")
|
||||
return ECCheckStatus(fail_unknown_error=True)
|
||||
|
||||
async def uaap_login(self) -> UAAPLoginStatus:
|
||||
"""
|
||||
使用用户名和密码登录UAAP
|
||||
"""
|
||||
try:
|
||||
# 初始请求获取登录页面
|
||||
response = await self._client.get(
|
||||
config_manager.get_settings().aufe.uaap_login_url
|
||||
)
|
||||
if lt_match := re.search(r'name="lt" value="(.*?)"', response.text):
|
||||
lt_value = lt_match.group(1)
|
||||
else:
|
||||
self.logger.error("错误: 登录页面中未找到lt参数。")
|
||||
return UAAPLoginStatus(fail_not_found_lt=True)
|
||||
self.logger.info(f"lt参数: {lt_value[:5]}******")
|
||||
if execution_match := re.search(
|
||||
r'name="execution" value="(.*?)"', response.text
|
||||
):
|
||||
execution_value = execution_match.group(1)
|
||||
else:
|
||||
self.logger.error("错误: 登录页面中未找到execution参数。")
|
||||
return UAAPLoginStatus(fail_not_found_execution=True)
|
||||
self.logger.info(f"execution参数: {execution_value[:5]}******")
|
||||
# 处理密钥 - CryptoJS使用的是8字节密钥
|
||||
key_bytes = lt_value.encode("utf-8")[:8]
|
||||
# 如果密钥不足8字节,则用0填充
|
||||
if len(key_bytes) < 8:
|
||||
key_bytes = key_bytes + b"\0" * (8 - len(key_bytes))
|
||||
|
||||
# 处理明文数据 - 确保是字节类型
|
||||
password_bytes = self.password.encode("utf-8")
|
||||
|
||||
# 使用PKCS7填充
|
||||
padder = symmetric_padding.PKCS7(64).padder()
|
||||
padded_data = padder.update(password_bytes) + padder.finalize()
|
||||
|
||||
# 创建DES加密器 - ECB模式
|
||||
cipher = Cipher(
|
||||
algorithms.TripleDES(key_bytes), modes.ECB(), backend=default_backend()
|
||||
)
|
||||
encryptor = cipher.encryptor()
|
||||
|
||||
# 加密数据
|
||||
encrypted = encryptor.update(padded_data) + encryptor.finalize()
|
||||
|
||||
# 提交登录表单
|
||||
login_response = await self._client.post(
|
||||
config_manager.get_settings().aufe.uaap_login_url,
|
||||
data={
|
||||
"username": self.userid,
|
||||
"password": b64encode(encrypted).decode("utf-8"),
|
||||
"lt": lt_value,
|
||||
"execution": execution_value,
|
||||
"_eventId": "submit",
|
||||
"submit": "LOGIN",
|
||||
},
|
||||
timeout=10000,
|
||||
)
|
||||
# 检查登录结果
|
||||
if (
|
||||
login_response.status_code == 302
|
||||
and "Location" in login_response.headers
|
||||
):
|
||||
redirect_url = login_response.headers["Location"]
|
||||
if redirect_url.startswith(
|
||||
config_manager.get_settings().aufe.uaap_check_url
|
||||
):
|
||||
self.logger.info("UAAP登录成功")
|
||||
self.uaap_logged = True
|
||||
return UAAPLoginStatus(success=True)
|
||||
elif "Invalid username or password" in login_response.text:
|
||||
self.logger.error("UAAP登录失败: 用户名或密码错误")
|
||||
return UAAPLoginStatus(fail_invalid_credentials=True)
|
||||
else:
|
||||
self.logger.error(f"UAAP登录失败: {login_response.text}")
|
||||
return UAAPLoginStatus(fail_unknown_error=True)
|
||||
return UAAPLoginStatus(fail_unknown_error=True)
|
||||
|
||||
except RequestError as e:
|
||||
self.logger.error(f"UAAP登录连接错误: {str(e)}")
|
||||
return UAAPLoginStatus(fail_network_error=True)
|
||||
except Exception as e:
|
||||
self.logger.error(f"UAAP登录失败: {e}")
|
||||
return UAAPLoginStatus(fail_unknown_error=True)
|
||||
|
||||
async def check_uaap_login_status(self) -> ECCheckStatus:
|
||||
"""
|
||||
检查当前UAAP登录状态
|
||||
"""
|
||||
return ECCheckStatus(logged_in=self.uaap_logged)
|
||||
|
||||
@property
|
||||
def client(self) -> AsyncClient:
|
||||
"""
|
||||
获取HTTP客户端实例
|
||||
注意: 此客户端只适用于教务系统,其他系统请查看具体 Service 实现
|
||||
"""
|
||||
self.health_checkpoint()
|
||||
return self._client
|
||||
|
||||
@property
|
||||
def empty_client(self, headers: HeaderTypes | None = None) -> AsyncClient:
|
||||
"""
|
||||
获取一个新的空白HTTP客户端实例,用于子系统构建请求
|
||||
"""
|
||||
self.health_checkpoint()
|
||||
return AsyncClient(headers=headers)
|
||||
|
||||
|
||||
class AUFEService(Service):
|
||||
"""
|
||||
AUFE服务类
|
||||
该类用于管理多个AUFE连接实例,提供获取或创建连接的功能
|
||||
并定期清理不健康的连接
|
||||
"""
|
||||
|
||||
sessions: dict[str, AUFEConnection] = {}
|
||||
logger: LoggerMixin
|
||||
task: Task
|
||||
|
||||
def __init__(self):
|
||||
# AUFEService 的 logger 不需要 trace_id,因为它是服务级别的日志
|
||||
self.logger = LoggerMixin(user_id="AUFEService", trace_id="")
|
||||
|
||||
async def get_or_create_connection(
|
||||
self, userid: str, ec_password: str, password: str
|
||||
) -> AUFEConnection:
|
||||
"""
|
||||
获取或创建AUFE连接
|
||||
该方法会检查现有连接的健康状态,如果不健康则重新创建连接
|
||||
注意,获取实例后请尽快操作登录,否则可能因为连接不健康而需要重新创建
|
||||
Args:
|
||||
userid (str): 用户ID
|
||||
ec_password (str): EC系统密码
|
||||
password (str): UAAP密码
|
||||
Returns:
|
||||
AUFEConnection: AUFE连接实例
|
||||
"""
|
||||
if userid not in self.sessions:
|
||||
self.sessions[userid] = AUFEConnection(
|
||||
userid=userid, ec_password=ec_password, password=password
|
||||
)
|
||||
self.sessions[userid].start_client()
|
||||
return self.sessions[userid]
|
||||
return self.sessions[userid]
|
||||
|
||||
async def _loop_cleanup(self):
|
||||
"""
|
||||
清理不健康的AUFE连接
|
||||
"""
|
||||
to_remove = []
|
||||
for userid, connection in self.sessions.items():
|
||||
if not await connection.health_check():
|
||||
self.logger.info(f"用户 {userid} 的AUFE连接不健康,正在关闭连接")
|
||||
await connection.close_client()
|
||||
self.logger.info(f"用户 {userid} 的AUFE连接已关闭,正在移除连接")
|
||||
to_remove.append(userid)
|
||||
self.logger.info(f"用户 {userid} 的AUFE连接已移除")
|
||||
for userid in to_remove:
|
||||
del self.sessions[userid]
|
||||
|
||||
async def loop_cleanup_task(self):
|
||||
"""
|
||||
定期清理不健康的AUFE连接 ASYNC TASK
|
||||
该任务每5分钟运行一次,检查所有连接的健康状态,并清理不健康的连接
|
||||
该任务应在应用启动时运行,并在应用关闭时取消
|
||||
"""
|
||||
while True:
|
||||
await asyncio.sleep(60) # 每分钟检查一次
|
||||
await self._loop_cleanup()
|
||||
|
||||
async def initialize(self):
|
||||
"""
|
||||
初始化AUFE服务
|
||||
该方法在应用启动时调用,用于启动清理任务
|
||||
"""
|
||||
self.logger.info("初始化AUFE服务")
|
||||
self.task = asyncio.create_task(self.loop_cleanup_task())
|
||||
self.logger.info("AUFE服务初始化完成")
|
||||
|
||||
async def shutdown(self):
|
||||
"""
|
||||
关闭AUFE服务
|
||||
该方法在应用关闭时调用,用于关闭所有连接
|
||||
"""
|
||||
self.logger.info("关闭AUFE服务")
|
||||
for userid, connection in self.sessions.items():
|
||||
self.logger.info(f"正在关闭用户 {userid} 的AUFE连接")
|
||||
await connection.close_client()
|
||||
self.logger.info(f"用户 {userid} 的AUFE连接已关闭")
|
||||
self.sessions.clear()
|
||||
self.task.cancel()
|
||||
try:
|
||||
await self.task
|
||||
except asyncio.CancelledError:
|
||||
self.logger.info("AUFE服务已关闭")
|
||||
pass
|
||||
178
loveace/service/remote/aufe/depends.py
Normal file
178
loveace/service/remote/aufe/depends.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from fastapi import Depends, HTTPException
|
||||
|
||||
from loveace.database.auth.user import ACEUser
|
||||
from loveace.router.dependencies.auth import get_user_by_token
|
||||
from loveace.router.schemas.error import ProtectRouterErrorToCode
|
||||
from loveace.router.schemas.exception import UniResponseHTTPException
|
||||
from loveace.service.remote.aufe import AUFEConnection, AUFEService
|
||||
from loveace.utils.rsa import RSAUtils
|
||||
|
||||
service = AUFEService()
|
||||
rsa = RSAUtils.get_or_create_rsa_utils()
|
||||
|
||||
|
||||
async def get_aufe_service() -> AUFEService:
|
||||
"""获取AUFE服务实例"""
|
||||
return service
|
||||
|
||||
|
||||
async def get_aufe_conn(
|
||||
user: ACEUser = Depends(get_user_by_token),
|
||||
) -> AUFEConnection:
|
||||
"""获取用户的AUFE连接"""
|
||||
service = await get_aufe_service()
|
||||
conn = await service.get_or_create_connection(
|
||||
user.userid,
|
||||
ec_password=rsa.decrypt(user.ec_password),
|
||||
password=rsa.decrypt(user.password),
|
||||
)
|
||||
logger = conn.logger
|
||||
# 同步当前请求的 trace_id 到连接的 logger
|
||||
conn.logger.trace_id = logger.trace_id
|
||||
if conn.ec_logged and conn.uaap_logged:
|
||||
logger.info(f"用户 {user.userid} 的AUFE连接已登录且可用")
|
||||
return conn
|
||||
try:
|
||||
# 测试连接是否可用
|
||||
if (await conn.check_ec_login_status()).logged_in:
|
||||
logger.info(f"用户 {user.userid} 的AUFE连接仍然可用")
|
||||
if (await conn.check_uaap_login_status()).logged_in:
|
||||
logger.info(f"用户 {user.userid} 的UAAP连接仍然可用")
|
||||
return conn
|
||||
else:
|
||||
logger.info(f"用户 {user.userid} 的UAAP连接不可用,尝试重新登录")
|
||||
|
||||
# UAAP登录重试机制 (最多3次)
|
||||
uaap_login_status = None
|
||||
for uaap_retry in range(3):
|
||||
uaap_login_status = await conn.uaap_login()
|
||||
if uaap_login_status.success:
|
||||
break
|
||||
|
||||
# 如果是密码错误,直接退出重试
|
||||
if uaap_login_status.fail_invalid_credentials:
|
||||
logger.error(
|
||||
f"用户 {user.userid} UAAP登录失败 (密码错误),停止重试"
|
||||
)
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"用户 {user.userid} UAAP登录重试第 {uaap_retry + 1} 次"
|
||||
)
|
||||
|
||||
if not uaap_login_status or not uaap_login_status.success:
|
||||
if uaap_login_status and uaap_login_status.fail_invalid_credentials:
|
||||
logger.error(
|
||||
f"用户 {user.userid} 的UAAP连接重新登录失败,可能是密码错误"
|
||||
)
|
||||
raise ProtectRouterErrorToCode().user_need_reset_password.to_http_exception(
|
||||
logger.trace_id
|
||||
)
|
||||
else:
|
||||
logger.error(f"用户 {user.userid} 的UAAP连接重新登录失败")
|
||||
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
|
||||
logger.trace_id
|
||||
)
|
||||
|
||||
if (await conn.check_uaap_login_status()).logged_in:
|
||||
logger.info(f"用户 {user.userid} 的UAAP连接重新登录成功")
|
||||
return conn
|
||||
else:
|
||||
logger.error(f"用户 {user.userid} 的UAAP连接重新登录失败")
|
||||
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
|
||||
logger.trace_id
|
||||
)
|
||||
else:
|
||||
logger.info(f"用户 {user.userid} 的AUFE连接不可用,尝试重新登录")
|
||||
|
||||
# EC登录重试机制 (最多3次)
|
||||
ec_login_status = None
|
||||
for ec_retry in range(3):
|
||||
ec_login_status = await conn.ec_login()
|
||||
if ec_login_status.success:
|
||||
break
|
||||
|
||||
# 如果是攻击防范或密码错误,直接退出重试
|
||||
if (
|
||||
ec_login_status.fail_maybe_attacked
|
||||
or ec_login_status.fail_invalid_credentials
|
||||
):
|
||||
logger.error(
|
||||
f"用户 {user.userid} EC登录失败 (攻击防范或密码错误),停止重试"
|
||||
)
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"用户 {user.userid} EC登录重试第 {ec_retry + 1} 次"
|
||||
)
|
||||
|
||||
if not ec_login_status or not ec_login_status.success:
|
||||
if ec_login_status and ec_login_status.fail_invalid_credentials:
|
||||
logger.error(
|
||||
f"用户 {user.userid} 的AUFE连接重新登录失败,可能是密码错误"
|
||||
)
|
||||
raise ProtectRouterErrorToCode().user_need_reset_password.to_http_exception(
|
||||
logger.trace_id
|
||||
)
|
||||
else:
|
||||
logger.error(f"用户 {user.userid} 的AUFE连接重新登录失败")
|
||||
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
|
||||
logger.trace_id
|
||||
)
|
||||
|
||||
if (await conn.check_ec_login_status()).logged_in:
|
||||
logger.info(f"用户 {user.userid} 的AUFE连接重新登录成功")
|
||||
|
||||
# UAAP登录重试机制 (最多3次)
|
||||
uaap_login_status = None
|
||||
for uaap_retry in range(3):
|
||||
uaap_login_status = await conn.uaap_login()
|
||||
if uaap_login_status.success:
|
||||
break
|
||||
|
||||
# 如果是密码错误,直接退出重试
|
||||
if uaap_login_status.fail_invalid_credentials:
|
||||
logger.error(
|
||||
f"用户 {user.userid} UAAP登录失败 (密码错误),停止重试"
|
||||
)
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"用户 {user.userid} UAAP登录重试第 {uaap_retry + 1} 次"
|
||||
)
|
||||
|
||||
if not uaap_login_status or not uaap_login_status.success:
|
||||
if uaap_login_status and uaap_login_status.fail_invalid_credentials:
|
||||
logger.error(
|
||||
f"用户 {user.userid} 的UAAP连接重新登录失败,可能是密码错误"
|
||||
)
|
||||
raise ProtectRouterErrorToCode().user_need_reset_password.to_http_exception(
|
||||
logger.trace_id
|
||||
)
|
||||
else:
|
||||
logger.error(f"用户 {user.userid} 的UAAP连接重新登录失败")
|
||||
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
|
||||
logger.trace_id
|
||||
)
|
||||
|
||||
if (await conn.check_uaap_login_status()).logged_in:
|
||||
logger.info(f"用户 {user.userid} 的UAAP连接重新登录成功")
|
||||
return conn
|
||||
else:
|
||||
logger.error(f"用户 {user.userid} 的UAAP连接重新登录失败")
|
||||
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
|
||||
logger.trace_id
|
||||
)
|
||||
else:
|
||||
logger.error(f"用户 {user.userid} 的AUFE连接重新登录失败")
|
||||
raise ProtectRouterErrorToCode().invalid_authentication.to_http_exception(
|
||||
logger.trace_id
|
||||
)
|
||||
|
||||
except (HTTPException, UniResponseHTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise ProtectRouterErrorToCode().remote_service_error.to_http_exception(
|
||||
logger.trace_id
|
||||
)
|
||||
28
loveace/service/remote/aufe/model/status.py
Normal file
28
loveace/service/remote/aufe/model/status.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ECLoginStatus(BaseModel):
|
||||
success: bool = False
|
||||
fail_not_found_twfid: bool = False
|
||||
fail_not_found_rsa_key: bool = False
|
||||
fail_not_found_rsa_exp: bool = False
|
||||
fail_not_found_csrf_code: bool = False
|
||||
fail_invalid_credentials: bool = False
|
||||
fail_maybe_attacked: bool = False
|
||||
fail_network_error: bool = False
|
||||
fail_unknown_error: bool = False
|
||||
|
||||
|
||||
class ECCheckStatus(BaseModel):
|
||||
logged_in: bool = False
|
||||
fail_network_error: bool = False
|
||||
fail_unknown_error: bool = False
|
||||
|
||||
|
||||
class UAAPLoginStatus(BaseModel):
|
||||
success: bool = False
|
||||
fail_not_found_lt: bool = False
|
||||
fail_not_found_execution: bool = False
|
||||
fail_invalid_credentials: bool = False
|
||||
fail_network_error: bool = False
|
||||
fail_unknown_error: bool = False
|
||||
367
loveace/service/remote/s3/__init__.py
Normal file
367
loveace/service/remote/s3/__init__.py
Normal file
@@ -0,0 +1,367 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncGenerator, BinaryIO, Dict, Optional
|
||||
|
||||
import aioboto3
|
||||
from botocore.client import Config as BotoCoreConfig
|
||||
from types_aiobotocore_s3 import S3Client
|
||||
|
||||
from loveace.config.logger import logger
|
||||
from loveace.config.manager import config_manager
|
||||
from loveace.service.model.service import Service
|
||||
from loveace.service.remote.s3.model.s3 import (
|
||||
S3CopyResult,
|
||||
S3ListResult,
|
||||
S3Object,
|
||||
S3UploadResult,
|
||||
)
|
||||
|
||||
s3_config = config_manager.get_settings().s3
|
||||
|
||||
# Boto3 很诡异的问题,不把这两个参数设为 when_required 他会把 check 直接塞到 rawfile 里
|
||||
# 阅读了一下应该是国内的一些 S3 兼容服务不能识读 checksum 导致的
|
||||
|
||||
os.environ["AWS_REQUEST_CHECKSUM_CALCULATION"] = "when_required"
|
||||
os.environ["AWS_RESPONSE_CHECKSUM_VALIDATION"] = "when_required"
|
||||
|
||||
# 验证 S3 配置
|
||||
if not all(
|
||||
[
|
||||
s3_config.endpoint_url,
|
||||
s3_config.access_key_id,
|
||||
s3_config.secret_access_key,
|
||||
s3_config.bucket_name,
|
||||
]
|
||||
):
|
||||
logger.warning("S3 配置不完整,S3 功能将不可用")
|
||||
raise ValueError("S3 配置不完整,S3 功能将不可用")
|
||||
|
||||
|
||||
class S3Service(Service):
|
||||
"""类型提示完善的 aioboto3 S3 管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self._session: aioboto3.Session = aioboto3.Session()
|
||||
self._bucket_name = s3_config.bucket_name
|
||||
self._endpoint_url = s3_config.endpoint_url
|
||||
self._client_config = {
|
||||
"aws_access_key_id": s3_config.access_key_id,
|
||||
"aws_secret_access_key": s3_config.secret_access_key,
|
||||
"endpoint_url": s3_config.endpoint_url,
|
||||
"region_name": s3_config.region_name,
|
||||
"use_ssl": s3_config.use_ssl,
|
||||
"config": BotoCoreConfig(
|
||||
s3={
|
||||
"addressing_style": s3_config.addressing_style,
|
||||
"signature_version": s3_config.signature_version,
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_client(self) -> AsyncGenerator[S3Client, None]:
|
||||
"""获取 S3 客户端上下文管理器"""
|
||||
async with self._session.client("s3", **self._client_config) as client: # type: ignore
|
||||
yield client
|
||||
|
||||
def _get_object_url(self, s3_key: str, bucket: Optional[str] = None) -> str:
|
||||
"""
|
||||
生成对象的直链 URL(非预签名)
|
||||
|
||||
Args:
|
||||
s3_key: S3 对象键
|
||||
bucket: 存储桶名称
|
||||
|
||||
Returns:
|
||||
str: 直链 URL
|
||||
"""
|
||||
bucket_name = bucket or self._bucket_name
|
||||
# 根据寻址风格构建 URL
|
||||
if s3_config.addressing_style == "virtual":
|
||||
# 虚拟主机风格:https://bucket-name.endpoint/key
|
||||
return f"https://{bucket_name}.{self._endpoint_url.replace('https://', '').replace('http://', '')}/{s3_key}"
|
||||
else:
|
||||
# 路径风格:https://endpoint/bucket-name/key
|
||||
return f"{self._endpoint_url}/{bucket_name}/{s3_key}"
|
||||
|
||||
async def upload_obj(
|
||||
self,
|
||||
file_obj: BinaryIO,
|
||||
s3_key: str,
|
||||
bucket: Optional[str] = None,
|
||||
extra_args: Optional[Dict[str, Any]] = None,
|
||||
) -> S3UploadResult:
|
||||
"""
|
||||
上传文件对象到 S3
|
||||
|
||||
Args:
|
||||
file_obj: 文件对象
|
||||
s3_key: S3 对象键
|
||||
bucket: 存储桶名称
|
||||
extra_args: 额外参数
|
||||
|
||||
Returns:
|
||||
S3UploadResult: 上传结果,包含成功状态和直链 URL
|
||||
"""
|
||||
bucket_name = bucket or self._bucket_name
|
||||
|
||||
try:
|
||||
async with self.get_client() as s3:
|
||||
logger.info(f"开始上传文件对象到 S3: {s3_key}")
|
||||
await s3.upload_fileobj(
|
||||
file_obj, bucket_name, s3_key, ExtraArgs=extra_args
|
||||
)
|
||||
logger.info(f"文件对象上传成功: {s3_key}")
|
||||
obj_url = self._get_object_url(s3_key, bucket_name)
|
||||
return S3UploadResult(
|
||||
success=True,
|
||||
url=obj_url,
|
||||
key=s3_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"文件对象上传失败 -> {s3_key}: {e}")
|
||||
return S3UploadResult(
|
||||
success=False,
|
||||
key=s3_key,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def delete_object(self, s3_key: str, bucket: Optional[str] = None) -> bool:
|
||||
"""
|
||||
删除单个 S3 对象
|
||||
|
||||
Args:
|
||||
s3_key: S3 对象键
|
||||
bucket: 存储桶名称
|
||||
|
||||
Returns:
|
||||
bool: 删除成功返回 True
|
||||
"""
|
||||
bucket_name = bucket or self._bucket_name
|
||||
|
||||
try:
|
||||
async with self.get_client() as s3:
|
||||
await s3.delete_object(Bucket=bucket_name, Key=s3_key)
|
||||
logger.info(f"对象删除成功: {s3_key}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"对象删除失败 {s3_key}: {e}")
|
||||
return False
|
||||
|
||||
async def list_objects(
|
||||
self,
|
||||
prefix: str = "",
|
||||
bucket: Optional[str] = None,
|
||||
max_keys: int = 1000,
|
||||
continuation_token: Optional[str] = None,
|
||||
) -> S3ListResult:
|
||||
"""
|
||||
列出 S3 对象
|
||||
|
||||
Args:
|
||||
prefix: 对象键前缀
|
||||
bucket: 存储桶名称
|
||||
max_keys: 最大返回数量
|
||||
continuation_token: 继续令牌,用于分页
|
||||
|
||||
Returns:
|
||||
S3ListResult: 对象列表结果
|
||||
"""
|
||||
bucket_name = bucket or self._bucket_name
|
||||
|
||||
try:
|
||||
async with self.get_client() as s3:
|
||||
params: Dict[str, Any] = {
|
||||
"Bucket": bucket_name,
|
||||
"Prefix": prefix,
|
||||
"MaxKeys": max_keys,
|
||||
}
|
||||
|
||||
if continuation_token:
|
||||
params["ContinuationToken"] = continuation_token
|
||||
|
||||
response = await s3.list_objects_v2(**params)
|
||||
|
||||
objects = []
|
||||
if contents := response.get("Contents"):
|
||||
for item in contents:
|
||||
if key := item.get("Key"):
|
||||
size = item.get("Size", 0)
|
||||
last_mod = item.get("LastModified")
|
||||
last_modified_str = last_mod.isoformat() if last_mod else ""
|
||||
objects.append(
|
||||
S3Object(
|
||||
key=key,
|
||||
size=size or 0,
|
||||
last_modified=last_modified_str,
|
||||
)
|
||||
)
|
||||
|
||||
return S3ListResult(
|
||||
success=True,
|
||||
objects=objects,
|
||||
prefix=prefix,
|
||||
is_truncated=response.get("IsTruncated", False),
|
||||
continuation_token=response.get("NextContinuationToken"),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"列出对象失败,前缀: {prefix}: {e}")
|
||||
return S3ListResult(
|
||||
success=False,
|
||||
prefix=prefix,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def generate_presigned_url(
|
||||
self,
|
||||
s3_key: str,
|
||||
bucket: Optional[str] = None,
|
||||
expiration: int = 3600,
|
||||
method: str = "get_object",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
生成预签名 URL
|
||||
|
||||
Args:
|
||||
s3_key: S3 对象键
|
||||
bucket: 存储桶名称
|
||||
expiration: URL 有效期(秒)
|
||||
method: HTTP 方法(get_object, put_object 等)
|
||||
|
||||
Returns:
|
||||
Optional[str]: 预签名 URL,生成失败返回 None
|
||||
"""
|
||||
bucket_name = bucket or self._bucket_name
|
||||
|
||||
try:
|
||||
async with self.get_client() as s3:
|
||||
url = await s3.generate_presigned_url(
|
||||
ClientMethod=method,
|
||||
Params={"Bucket": bucket_name, "Key": s3_key},
|
||||
ExpiresIn=expiration,
|
||||
)
|
||||
logger.info(f"预签名 URL 生成成功: {s3_key}")
|
||||
return url
|
||||
except Exception as e:
|
||||
logger.error(f"生成预签名 URL 失败 {s3_key}: {e}")
|
||||
return None
|
||||
|
||||
async def generate_presigned_url_from_direct_url(
|
||||
self,
|
||||
direct_url: str,
|
||||
expiration: int = 3600,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
从直链 URL 生成预签名 URL
|
||||
|
||||
Args:
|
||||
direct_url: 直链 URL
|
||||
expiration: URL 有效期(秒)
|
||||
|
||||
Returns:
|
||||
Optional[str]: 预签名 URL,生成失败返回 None
|
||||
"""
|
||||
try:
|
||||
# 解析出 bucket 和 key
|
||||
if s3_config.addressing_style == "virtual":
|
||||
# 虚拟主机风格:https://bucket-name.endpoint/key
|
||||
url_without_protocol = direct_url.replace("https://", "").replace(
|
||||
"http://", ""
|
||||
)
|
||||
first_slash = url_without_protocol.find("/")
|
||||
bucket_name = self._bucket_name
|
||||
s3_key = url_without_protocol[first_slash + 1 :]
|
||||
else:
|
||||
# 路径风格:https://endpoint/bucket-name/key
|
||||
url_without_protocol = direct_url.replace("https://", "").replace(
|
||||
"http://", ""
|
||||
)
|
||||
path_parts = url_without_protocol.split("/")
|
||||
bucket_name = self._bucket_name
|
||||
s3_key = "/".join(path_parts[2:])
|
||||
|
||||
return await self.generate_presigned_url(
|
||||
s3_key=s3_key,
|
||||
bucket=bucket_name,
|
||||
expiration=expiration,
|
||||
method="get_object",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"从直链 URL 生成预签名 URL 失败 {direct_url}: {e}")
|
||||
return None
|
||||
|
||||
async def object_exists(self, s3_key: str, bucket: Optional[str] = None) -> bool:
|
||||
"""
|
||||
检查 S3 对象是否存在
|
||||
|
||||
Args:
|
||||
s3_key: S3 对象键
|
||||
bucket: 存储桶名称
|
||||
|
||||
Returns:
|
||||
bool: 存在返回 True
|
||||
"""
|
||||
bucket_name = bucket or self._bucket_name
|
||||
|
||||
try:
|
||||
async with self.get_client() as s3:
|
||||
await s3.head_object(Bucket=bucket_name, Key=s3_key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def copy_object(
|
||||
self,
|
||||
source_key: str,
|
||||
dest_key: str,
|
||||
source_bucket: Optional[str] = None,
|
||||
dest_bucket: Optional[str] = None,
|
||||
) -> S3CopyResult:
|
||||
"""
|
||||
复制 S3 对象
|
||||
|
||||
Args:
|
||||
source_key: 源对象键
|
||||
dest_key: 目标对象键
|
||||
source_bucket: 源存储桶名称
|
||||
dest_bucket: 目标存储桶名称
|
||||
|
||||
Returns:
|
||||
S3CopyResult: 复制结果,包含成功状态和目标直链 URL
|
||||
"""
|
||||
src_bucket_name = source_bucket or self._bucket_name
|
||||
dst_bucket_name = dest_bucket or self._bucket_name
|
||||
|
||||
copy_source = {"Bucket": src_bucket_name, "Key": source_key}
|
||||
|
||||
try:
|
||||
async with self.get_client() as s3:
|
||||
await s3.copy_object(
|
||||
CopySource=copy_source, # type: ignore
|
||||
Bucket=dst_bucket_name,
|
||||
Key=dest_key, # type: ignore
|
||||
)
|
||||
logger.info(f"对象复制成功: {source_key} -> {dest_key}")
|
||||
return S3CopyResult(
|
||||
success=True,
|
||||
source_key=source_key,
|
||||
dest_key=dest_key,
|
||||
dest_url=self._get_object_url(dest_key, dst_bucket_name),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"对象复制失败 {source_key} -> {dest_key}: {e}")
|
||||
return S3CopyResult(
|
||||
success=False,
|
||||
source_key=source_key,
|
||||
dest_key=dest_key,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def initialize(self):
|
||||
"""初始化 S3 服务"""
|
||||
logger.info("S3 服务初始化完成")
|
||||
|
||||
async def shutdown(self):
|
||||
"""关闭 S3 服务"""
|
||||
logger.info("S3 服务已关闭")
|
||||
8
loveace/service/remote/s3/depends.py
Normal file
8
loveace/service/remote/s3/depends.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from loveace.service.remote.s3 import S3Service
|
||||
|
||||
s3 = S3Service()
|
||||
|
||||
|
||||
async def get_s3_service() -> S3Service:
|
||||
"""获取S3服务实例"""
|
||||
return s3
|
||||
59
loveace/service/remote/s3/model/s3.py
Normal file
59
loveace/service/remote/s3/model/s3.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class S3UploadResult(BaseModel):
|
||||
"""S3 上传结果"""
|
||||
|
||||
success: bool
|
||||
"""上传是否成功"""
|
||||
url: Optional[str] = None
|
||||
"""直链 URL,仅在上传成功时返回"""
|
||||
key: Optional[str] = None
|
||||
"""S3 对象键"""
|
||||
error: Optional[str] = None
|
||||
"""错误信息,仅在上传失败时返回"""
|
||||
|
||||
|
||||
class S3CopyResult(BaseModel):
|
||||
"""S3 复制结果"""
|
||||
|
||||
success: bool
|
||||
"""复制是否成功"""
|
||||
source_key: Optional[str] = None
|
||||
"""源 S3 对象键"""
|
||||
dest_key: Optional[str] = None
|
||||
"""目标 S3 对象键"""
|
||||
dest_url: Optional[str] = None
|
||||
"""目标直链 URL,仅在复制成功时返回"""
|
||||
error: Optional[str] = None
|
||||
"""错误信息,仅在复制失败时返回"""
|
||||
|
||||
|
||||
class S3Object(BaseModel):
|
||||
"""S3 对象基本信息"""
|
||||
|
||||
key: str
|
||||
"""对象键"""
|
||||
size: int
|
||||
"""对象大小(字节)"""
|
||||
last_modified: str
|
||||
"""最后修改时间"""
|
||||
|
||||
|
||||
class S3ListResult(BaseModel):
|
||||
"""S3 列表操作结果"""
|
||||
|
||||
success: bool
|
||||
"""操作是否成功"""
|
||||
objects: list[S3Object] = []
|
||||
"""对象列表"""
|
||||
prefix: str = ""
|
||||
"""前缀"""
|
||||
is_truncated: bool = False
|
||||
"""是否存在更多对象"""
|
||||
continuation_token: Optional[str] = None
|
||||
"""继续令牌,用于分页"""
|
||||
error: Optional[str] = None
|
||||
"""错误信息"""
|
||||
Reference in New Issue
Block a user