⚒️ 重大重构 LoveACE V2

引入了 mongodb
对数据库进行了一定程度的数据加密
性能改善
代码简化
统一错误模型和响应
使用 apifox 作为文档
This commit is contained in:
2025-11-20 20:44:25 +08:00
parent 6b90c6d7bb
commit bbc86b8330
168 changed files with 14264 additions and 19152 deletions

View File

@@ -0,0 +1,13 @@
from abc import abstractmethod
class Service:
@abstractmethod
async def initialize(self):
"""初始化服务"""
pass
@abstractmethod
async def shutdown(self):
"""关闭服务"""
pass

View 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

View 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
)

View 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

View 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 服务已关闭")

View File

@@ -0,0 +1,8 @@
from loveace.service.remote.s3 import S3Service
s3 = S3Service()
async def get_s3_service() -> S3Service:
"""获取S3服务实例"""
return s3

View 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
"""错误信息"""