259 lines
8.4 KiB
Python
259 lines
8.4 KiB
Python
"""统一 S3 存储模块 — 合并本地局域网 S3 和公网 MinIO"""
|
|||
|
|
|
||
|
|
from pathlib import Path
|
||
|
|
from time import strftime, localtime
|
||
|
|
|
||
|
|
import boto3
|
||
|
|
from botocore.config import Config
|
||
|
|
from nonebot import logger
|
||
|
|
|
||
|
|
from hexi.web_config import get_effective_value
|
||
|
|
|
||
|
|
# 该插件模块名,用于读取统一配置值库(Web 修改后生效)
|
||
|
|
_PLUGIN_ID = "hexi.plugins.nonebot_plugin_video_analysis"
|
||
|
|
|
||
|
|
|
||
|
|
def _ev(name: str, default):
|
||
|
|
"""读取统一配置生效值(用户值库 > getter > 默认),来源无关。"""
|
||
|
|
return get_effective_value(_PLUGIN_ID, name, default)
|
||
|
|
|
||
|
|
|
||
|
|
# ============================= 本地局域网 PLANA_S3 配置 =============================
|
||
|
|
LOCAL_S3_PLANA_ENDPOINT = "http://192.168.2.15:5246"
|
||
|
|
LOCAL_S3_PLANA_ACCESS_KEY = "vS8Lf7UoiS5cxL+mz+CH"
|
||
|
|
LOCAL_S3_PLANA_SECRET_KEY = "Fi3phPW10CsZWuiQCerK4gxaIIJp41w5SQNlnDRb"
|
||
|
|
LOCAL_S3_PLANA_BUCKET = "PLANA"
|
||
|
|
|
||
|
|
# ============================= 本地局域网 PLANB_S3 配置 =============================
|
||
|
|
LOCAL_S3_PLANB_ENDPOINT = "http://192.168.2.15:5246"
|
||
|
|
LOCAL_S3_PLANB_ACCESS_KEY = "vS8Lf7UoiS5cxL+mz+CH"
|
||
|
|
LOCAL_S3_PLANB_SECRET_KEY = "Fi3phPW10CsZWuiQCerK4gxaIIJp41w5SQNlnDRb"
|
||
|
|
LOCAL_S3_PLANB_BUCKET = "PLANB"
|
||
|
|
|
||
|
|
# ============================= 本地局域网 S3 配置 =============================
|
||
|
|
LOCAL_S3_PLANC_ENDPOINT = "http://192.168.2.15:5246"
|
||
|
|
LOCAL_S3_PLANC_ACCESS_KEY = "vS8Lf7UoiS5cxL+mz+CH"
|
||
|
|
LOCAL_S3_PLANC_SECRET_KEY = "Fi3phPW10CsZWuiQCerK4gxaIIJp41w5SQNlnDRb"
|
||
|
|
LOCAL_S3_PLANC_BUCKET = "PLANC"
|
||
|
|
|
||
|
|
# ============================= 公网 MinIO 配置 =============================
|
||
|
|
PUBLIC_S3_ENDPOINT = "s3.sansenhoshi.top"
|
||
|
|
PUBLIC_S3_ACCESS_KEY = "JDMynACSjPaN8JRwriwS"
|
||
|
|
PUBLIC_S3_SECRET_KEY = "JRl4bIGxeiqwqvfeBTlAWQbUKMpNNHSZPm6ne93j"
|
||
|
|
PUBLIC_S3_BUCKET = "s-file-trans"
|
||
|
|
PUBLIC_S3_REGION = "bot"
|
||
|
|
PUBLIC_S3_SECURE = True
|
||
|
|
PUBLIC_S3_DOMAIN = f"https://{PUBLIC_S3_ENDPOINT}/{PUBLIC_S3_BUCKET}"
|
||
|
|
|
||
|
|
# ============================= 客户端实例(懒加载) =============================
|
||
|
|
_local_s3_plana = None
|
||
|
|
_local_s3_planb = None
|
||
|
|
_local_s3_planc = None
|
||
|
|
_public_s3 = None
|
||
|
|
|
||
|
|
|
||
|
|
class S3Client:
|
||
|
|
"""通用 S3 客户端"""
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
endpoint_url: str,
|
||
|
|
access_key: str,
|
||
|
|
secret_key: str,
|
||
|
|
bucket: str,
|
||
|
|
region: str = "us-east-1",
|
||
|
|
secure: bool = False,
|
||
|
|
):
|
||
|
|
self.bucket = bucket
|
||
|
|
self.client = boto3.client(
|
||
|
|
"s3",
|
||
|
|
endpoint_url=endpoint_url,
|
||
|
|
aws_access_key_id=access_key,
|
||
|
|
aws_secret_access_key=secret_key,
|
||
|
|
region_name=region,
|
||
|
|
verify=secure,
|
||
|
|
config=Config(
|
||
|
|
signature_version="s3v4",
|
||
|
|
s3={"addressing_style": "path"},
|
||
|
|
retries={"max_attempts": 3},
|
||
|
|
connect_timeout=10,
|
||
|
|
read_timeout=60,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
def upload_and_get_url(
|
||
|
|
self, local_path: str, key: str, expire: int = 3600
|
||
|
|
) -> str:
|
||
|
|
self.client.upload_file(local_path, self.bucket, key)
|
||
|
|
url = self.client.generate_presigned_url(
|
||
|
|
"get_object",
|
||
|
|
Params={"Bucket": self.bucket, "Key": key},
|
||
|
|
ExpiresIn=expire,
|
||
|
|
)
|
||
|
|
return url
|
||
|
|
|
||
|
|
def upload_public(self, local_path: str, key: str) -> str:
|
||
|
|
"""上传并设为公开读"""
|
||
|
|
self.client.upload_file(
|
||
|
|
local_path,
|
||
|
|
self.bucket,
|
||
|
|
key,
|
||
|
|
ExtraArgs={"ACL": "public-read"},
|
||
|
|
)
|
||
|
|
logger.info(f"文件 {key} 已成功上传至云端S3")
|
||
|
|
return f"{_ev('PUBLIC_S3_DOMAIN', PUBLIC_S3_DOMAIN)}/{key}"
|
||
|
|
|
||
|
|
def delete(self, key: str) -> bool:
|
||
|
|
try:
|
||
|
|
self.client.delete_object(Bucket=self.bucket, Key=key)
|
||
|
|
logger.info(f"成功删除 S3 文件: {key}")
|
||
|
|
return True
|
||
|
|
except Exception:
|
||
|
|
logger.exception(f"删除 S3 文件失败: {key}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def _get_local_s3_plana() -> S3Client:
|
||
|
|
global _local_s3_plana
|
||
|
|
if _local_s3_plana is None:
|
||
|
|
_local_s3_plana = S3Client(
|
||
|
|
_ev("LOCAL_S3_PLANA_ENDPOINT", LOCAL_S3_PLANA_ENDPOINT),
|
||
|
|
_ev("LOCAL_S3_PLANA_ACCESS_KEY", LOCAL_S3_PLANA_ACCESS_KEY),
|
||
|
|
_ev("LOCAL_S3_PLANA_SECRET_KEY", LOCAL_S3_PLANA_SECRET_KEY),
|
||
|
|
_ev("LOCAL_S3_PLANA_BUCKET", LOCAL_S3_PLANA_BUCKET),
|
||
|
|
)
|
||
|
|
return _local_s3_plana
|
||
|
|
|
||
|
|
|
||
|
|
def _get_local_s3_planb() -> S3Client:
|
||
|
|
global _local_s3_planb
|
||
|
|
if _local_s3_planb is None:
|
||
|
|
_local_s3_planb = S3Client(
|
||
|
|
_ev("LOCAL_S3_PLANB_ENDPOINT", LOCAL_S3_PLANB_ENDPOINT),
|
||
|
|
_ev("LOCAL_S3_PLANB_ACCESS_KEY", LOCAL_S3_PLANB_ACCESS_KEY),
|
||
|
|
_ev("LOCAL_S3_PLANB_SECRET_KEY", LOCAL_S3_PLANB_SECRET_KEY),
|
||
|
|
_ev("LOCAL_S3_PLANB_BUCKET", LOCAL_S3_PLANB_BUCKET),
|
||
|
|
)
|
||
|
|
return _local_s3_planb
|
||
|
|
|
||
|
|
|
||
|
|
def _get_local_s3_planc() -> S3Client:
|
||
|
|
global _local_s3_planc
|
||
|
|
if _local_s3_planc is None:
|
||
|
|
_local_s3_planc = S3Client(
|
||
|
|
_ev("LOCAL_S3_PLANC_ENDPOINT", LOCAL_S3_PLANC_ENDPOINT),
|
||
|
|
_ev("LOCAL_S3_PLANC_ACCESS_KEY", LOCAL_S3_PLANC_ACCESS_KEY),
|
||
|
|
_ev("LOCAL_S3_PLANC_SECRET_KEY", LOCAL_S3_PLANC_SECRET_KEY),
|
||
|
|
_ev("LOCAL_S3_PLANC_BUCKET", LOCAL_S3_PLANC_BUCKET),
|
||
|
|
)
|
||
|
|
return _local_s3_planc
|
||
|
|
|
||
|
|
|
||
|
|
def _get_public_s3() -> S3Client:
|
||
|
|
global _public_s3
|
||
|
|
if _public_s3 is None:
|
||
|
|
secure = _ev("PUBLIC_S3_SECURE", PUBLIC_S3_SECURE)
|
||
|
|
endpoint = _ev("PUBLIC_S3_ENDPOINT", PUBLIC_S3_ENDPOINT)
|
||
|
|
_public_s3 = S3Client(
|
||
|
|
f"{'https' if secure else 'http'}://{endpoint}",
|
||
|
|
_ev("PUBLIC_S3_ACCESS_KEY", PUBLIC_S3_ACCESS_KEY),
|
||
|
|
_ev("PUBLIC_S3_SECRET_KEY", PUBLIC_S3_SECRET_KEY),
|
||
|
|
_ev("PUBLIC_S3_BUCKET", PUBLIC_S3_BUCKET),
|
||
|
|
region=_ev("PUBLIC_S3_REGION", PUBLIC_S3_REGION),
|
||
|
|
secure=secure,
|
||
|
|
)
|
||
|
|
return _public_s3
|
||
|
|
|
||
|
|
|
||
|
|
# ============================= 对外接口 =============================
|
||
|
|
|
||
|
|
def upload_to_public_s3(file_path: str | Path) -> tuple[str, str]:
|
||
|
|
"""
|
||
|
|
上传到公网 MinIO,返回 (公网URL, file_key)
|
||
|
|
|
||
|
|
私聊场景使用,生成可公网访问的链接
|
||
|
|
"""
|
||
|
|
file_path = Path(file_path)
|
||
|
|
current_day = strftime("%Y-%m-%d", localtime())
|
||
|
|
file_key = f"{current_day}/{file_path.name}"
|
||
|
|
try:
|
||
|
|
url = _get_public_s3().upload_public(str(file_path), file_key)
|
||
|
|
return url, file_key
|
||
|
|
except Exception:
|
||
|
|
logger.exception("上传到公网 MinIO 失败")
|
||
|
|
return "", ""
|
||
|
|
|
||
|
|
|
||
|
|
def upload_to_local_s3(
|
||
|
|
title: str, image_post: bool, file_path: str | Path, plan: str | None = None
|
||
|
|
) -> str:
|
||
|
|
"""
|
||
|
|
上传到局域网 S3,返回预签名 URL
|
||
|
|
|
||
|
|
plan="A" → PLANA 桶
|
||
|
|
plan="B" → PLANB 桶
|
||
|
|
None → PLANC 桶(默认)
|
||
|
|
"""
|
||
|
|
file_path = Path(file_path)
|
||
|
|
current_day = strftime("%Y-%m-%d", localtime())
|
||
|
|
if image_post:
|
||
|
|
file_key = f"{current_day}/{title}/{file_path.name}"
|
||
|
|
else:
|
||
|
|
file_key = f"{current_day}/{file_path.name}"
|
||
|
|
|
||
|
|
if plan == "A":
|
||
|
|
client = _get_local_s3_plana()
|
||
|
|
elif plan == "B":
|
||
|
|
client = _get_local_s3_planb()
|
||
|
|
else:
|
||
|
|
client = _get_local_s3_planc()
|
||
|
|
|
||
|
|
try:
|
||
|
|
logger.info(f"文件 {file_key} 已成功上传至本地S3")
|
||
|
|
return client.upload_and_get_url(str(file_path), file_key)
|
||
|
|
except Exception:
|
||
|
|
logger.exception("上传到本地 S3 失败")
|
||
|
|
return ""
|
||
|
|
|
||
|
|
|
||
|
|
def delete_from_public_s3(file_key: str) -> bool:
|
||
|
|
"""从公网 MinIO 删除指定 key 的对象"""
|
||
|
|
return _get_public_s3().delete(file_key)
|
||
|
|
|
||
|
|
|
||
|
|
def upload_with_plan(
|
||
|
|
file_path: str | Path,
|
||
|
|
*,
|
||
|
|
plan: str | None = None,
|
||
|
|
is_private: bool = False,
|
||
|
|
title: str = "",
|
||
|
|
image_post: bool = False,
|
||
|
|
) -> tuple[str, str | None]:
|
||
|
|
"""
|
||
|
|
统一上传入口:按 plan 路由到对应的本地桶,并按需上传公网
|
||
|
|
|
||
|
|
plan="A" → PLANA(仅本地)
|
||
|
|
plan="B" → PLANB + 公网
|
||
|
|
私聊 → PLANB + 公网
|
||
|
|
默认 → PLANC(仅本地)
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
(local_url, public_url_or_none)
|
||
|
|
"""
|
||
|
|
# 本地上传
|
||
|
|
if plan == "A":
|
||
|
|
local_url = upload_to_local_s3(title, image_post, file_path, plan="A")
|
||
|
|
elif plan == "B":
|
||
|
|
local_url = upload_to_local_s3(title, image_post, file_path, plan="B")
|
||
|
|
elif is_private:
|
||
|
|
local_url = upload_to_local_s3(title, image_post, file_path, plan="B")
|
||
|
|
else:
|
||
|
|
local_url = upload_to_local_s3(title, image_post, file_path) # PLANC
|
||
|
|
|
||
|
|
# 公网上传(仅 PLANB 或私聊)
|
||
|
|
public_url = None
|
||
|
|
if plan == "B" or is_private:
|
||
|
|
public_url, _ = upload_to_public_s3(file_path)
|
||
|
|
|
||
|
|
return local_url, public_url
|