2026-08-23 23:52:58 +08:00
|
|
|
|
# -*- coding: utf-8 -*-
|
2026-08-28 17:17:38 +08:00
|
|
|
|
"""基础设施层 · OSS 对象存储统一封装(aioboto3,兼容阿里云 OSS / MinIO / S3)。
|
2026-08-23 23:52:58 +08:00
|
|
|
|
|
2026-08-28 17:17:38 +08:00
|
|
|
|
全项目所有文件上传/下载的唯一入口,供各端口(培训/园区/运营端/平台)调用:
|
|
|
|
|
|
- upload() 服务端中转上传(表单 multipart 走后端)
|
|
|
|
|
|
- presigned_put() 生成临时上传链接(前端直传,绕过后端带宽)
|
|
|
|
|
|
- presigned_get() 生成临时下载链接
|
|
|
|
|
|
- object_url() 对象的稳定相对路径 /oss/<key>(入库用;经下载路由跳转预签名 URL)
|
|
|
|
|
|
|
|
|
|
|
|
未配置 OSS_ENDPOINT 时降级本地 serverdata/uploads,保证开发环境无 OSS 也可用。
|
2026-08-23 23:52:58 +08:00
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
from .. import config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OSS:
|
2026-08-28 17:17:38 +08:00
|
|
|
|
"""对象存储:上传 / 预签名上下行 URL。配置了 OSS_ENDPOINT 才启用,否则落本地上传目录。"""
|
2026-08-23 23:52:58 +08:00
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
|
self._client: Optional[object] = None
|
|
|
|
|
|
self.enabled = bool(config.OSS_ENDPOINT)
|
|
|
|
|
|
self.bucket = config.OSS_BUCKET
|
2026-08-28 17:17:38 +08:00
|
|
|
|
self.local_dir = config.SERVERDATA_DIR / "uploads"
|
2026-08-23 23:52:58 +08:00
|
|
|
|
self.local_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
async def _get_client(self):
|
|
|
|
|
|
if self._client is None:
|
|
|
|
|
|
import aioboto3
|
2026-08-28 17:17:38 +08:00
|
|
|
|
from botocore.config import Config
|
2026-08-23 23:52:58 +08:00
|
|
|
|
|
|
|
|
|
|
session = aioboto3.Session(
|
|
|
|
|
|
aws_access_key_id=config.OSS_ACCESS_KEY,
|
|
|
|
|
|
aws_secret_access_key=config.OSS_SECRET_KEY,
|
|
|
|
|
|
)
|
2026-08-28 17:17:38 +08:00
|
|
|
|
# aioboto3 的 client 是异步上下文管理器,需 enter 后长期复用;
|
|
|
|
|
|
# 阿里云 OSS 要求 virtual-hosted style;不支持 PutObject CRC32 trailer(botocore 新版默认)
|
|
|
|
|
|
self._client = await session.client(
|
2026-08-23 23:52:58 +08:00
|
|
|
|
"s3",
|
|
|
|
|
|
endpoint_url=config.OSS_ENDPOINT,
|
2026-08-28 17:17:38 +08:00
|
|
|
|
region_name=config.OSS_REGION or "cn-heyuan",
|
|
|
|
|
|
config=Config(
|
|
|
|
|
|
s3={
|
|
|
|
|
|
"addressing_style": "virtual",
|
|
|
|
|
|
"payload_signing_enabled": True,
|
|
|
|
|
|
},
|
|
|
|
|
|
signature_version="s3v4",
|
|
|
|
|
|
request_checksum_calculation="when_required",
|
|
|
|
|
|
response_checksum_validation="when_required",
|
|
|
|
|
|
),
|
|
|
|
|
|
).__aenter__()
|
2026-08-23 23:52:58 +08:00
|
|
|
|
return self._client
|
|
|
|
|
|
|
2026-08-28 17:17:38 +08:00
|
|
|
|
def clean_key(self, key: str) -> str:
|
|
|
|
|
|
"""规整对象 key:去首尾斜杠、拒目录穿越。"""
|
|
|
|
|
|
key = (key or "").strip().strip("/")
|
|
|
|
|
|
if not key or ".." in key.split("/"):
|
|
|
|
|
|
raise ValueError(f"非法对象 key:{key!r}")
|
|
|
|
|
|
return key
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 上传 ----
|
|
|
|
|
|
|
2026-08-23 23:52:58 +08:00
|
|
|
|
async def upload(self, key: str, data: bytes, content_type: str = "application/octet-stream") -> str:
|
2026-08-28 17:17:38 +08:00
|
|
|
|
"""服务端上传对象,成功返回稳定相对路径 /oss/<key>(入库/回传客户端统一用这个)。"""
|
|
|
|
|
|
key = self.clean_key(key)
|
2026-08-23 23:52:58 +08:00
|
|
|
|
if self.enabled:
|
|
|
|
|
|
client = await self._get_client()
|
|
|
|
|
|
await client.put_object(Bucket=self.bucket, Key=key, Body=data, ContentType=content_type)
|
|
|
|
|
|
return f"/oss/{key}"
|
|
|
|
|
|
dest = self.local_dir / key
|
|
|
|
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
dest.write_bytes(data)
|
|
|
|
|
|
return f"/uploads/{key}"
|
|
|
|
|
|
|
2026-08-28 17:17:38 +08:00
|
|
|
|
# ---- 下载 / 访问 ----
|
|
|
|
|
|
|
|
|
|
|
|
def object_url(self, key: str) -> str:
|
|
|
|
|
|
"""对象的稳定相对路径(下载经 GET /oss/<key> 跳转预签名 URL,桶私有也可读)。"""
|
|
|
|
|
|
return f"/oss/{self.clean_key(key)}"
|
|
|
|
|
|
|
|
|
|
|
|
async def presigned_get(self, key: str, expires: int = 3600) -> str:
|
|
|
|
|
|
"""临时下载链接(预签名 GET,走 OSS 源站)。"""
|
|
|
|
|
|
key = self.clean_key(key)
|
|
|
|
|
|
if not self.enabled:
|
|
|
|
|
|
return f"/uploads/{key}"
|
|
|
|
|
|
client = await self._get_client()
|
|
|
|
|
|
return await client.generate_presigned_url(
|
|
|
|
|
|
"get_object", Params={"Bucket": self.bucket, "Key": key}, ExpiresIn=expires
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def download_url(self, key: str, expires: int = 3600) -> str:
|
|
|
|
|
|
"""统一下载链接:配置了 CDN 加速域名则走 CDN(带鉴权),否则退回预签名直链。"""
|
|
|
|
|
|
if config.OSS_CDN_BASE_URL:
|
|
|
|
|
|
return self.cdn_url(key)
|
|
|
|
|
|
return await self.presigned_get(key, expires)
|
|
|
|
|
|
|
2026-09-04 23:51:47 +08:00
|
|
|
|
async def download(self, key: str) -> bytes | None:
|
|
|
|
|
|
"""直接读取对象内容(字节)。OSS 模式走 get_object,本地模式读文件。"""
|
|
|
|
|
|
key = self.clean_key(key)
|
|
|
|
|
|
if self.enabled:
|
|
|
|
|
|
client = await self._get_client()
|
|
|
|
|
|
resp = await client.get_object(Bucket=self.bucket, Key=key)
|
|
|
|
|
|
async with resp["Body"] as body:
|
|
|
|
|
|
return await body.read()
|
|
|
|
|
|
dest = self.local_dir / key
|
|
|
|
|
|
if dest.exists():
|
|
|
|
|
|
return dest.read_bytes()
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-08-28 17:17:38 +08:00
|
|
|
|
# ---- CDN URL 鉴权(阿里云「鉴权方式A」)----
|
|
|
|
|
|
|
|
|
|
|
|
# 「永久有效」实现:鉴权时间戳取 32 位最大值(2038-01),叠加控制台有效期内长期可访问
|
|
|
|
|
|
_CDN_TS_MAX = "7FFFFFFF" # 方式A 要求大写 hex
|
|
|
|
|
|
|
|
|
|
|
|
def _cdn_sign(self, key: str, ts_hex: str) -> str:
|
|
|
|
|
|
"""鉴权方式A:md5(密钥 + URI + timestamp),URI 以 / 开头,时间戳大写 hex。"""
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
|
|
|
|
|
|
uri = f"/{key}"
|
|
|
|
|
|
return hashlib.md5(f"{config.OSS_CDN_AUTH_KEY}{uri}{ts_hex}".encode()).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
def cdn_url(self, key: str) -> str:
|
|
|
|
|
|
"""CDN 鉴权直链(方式A):{CDN}/{md5签名}/{时间戳}/{key};未配密钥则裸链接。"""
|
|
|
|
|
|
key = self.clean_key(key)
|
|
|
|
|
|
base = config.OSS_CDN_BASE_URL.rstrip("/")
|
|
|
|
|
|
if not config.OSS_CDN_AUTH_KEY:
|
|
|
|
|
|
return f"{base}/{key}"
|
|
|
|
|
|
ts_hex = self._CDN_TS_MAX
|
|
|
|
|
|
return f"{base}/{self._cdn_sign(key, ts_hex)}/{ts_hex}/{key}"
|
|
|
|
|
|
|
|
|
|
|
|
def direct_url(self, key: str) -> str:
|
|
|
|
|
|
"""直链(不经服务端代理,直接返回给前端):
|
|
|
|
|
|
CDN 加速域名(带鉴权)> OSS 公网直链 > 本地 /uploads。"""
|
|
|
|
|
|
key = self.clean_key(key)
|
|
|
|
|
|
if config.OSS_CDN_BASE_URL:
|
|
|
|
|
|
return self.cdn_url(key)
|
2026-08-23 23:52:58 +08:00
|
|
|
|
if self.enabled:
|
2026-08-28 17:17:38 +08:00
|
|
|
|
return self.public_url(key)
|
2026-08-23 23:52:58 +08:00
|
|
|
|
return f"/uploads/{key}"
|
|
|
|
|
|
|
2026-08-28 17:17:38 +08:00
|
|
|
|
async def presigned_put(self, key: str, expires: int = 600, content_type: str = "application/octet-stream") -> str:
|
|
|
|
|
|
"""临时上传链接(预签名 PUT,供前端直传)。"""
|
|
|
|
|
|
key = self.clean_key(key)
|
|
|
|
|
|
if not self.enabled:
|
|
|
|
|
|
raise RuntimeError("OSS 未配置,无法生成直传链接")
|
|
|
|
|
|
client = await self._get_client()
|
|
|
|
|
|
return await client.generate_presigned_url(
|
|
|
|
|
|
"put_object",
|
|
|
|
|
|
Params={"Bucket": self.bucket, "Key": key, "ContentType": content_type},
|
|
|
|
|
|
ExpiresIn=expires,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def public_url(self, key: str) -> str:
|
|
|
|
|
|
"""对象的公网直链(仅公共读桶可直接访问;私有桶 403)。"""
|
|
|
|
|
|
endpoint = (config.OSS_ENDPOINT or "").rstrip("/")
|
|
|
|
|
|
return f"{endpoint}/{self.bucket}/{self.clean_key(key)}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 模块级单例:全项目统一通过 `from ..infrastructure.oss import oss` 使用
|
|
|
|
|
|
oss = OSS()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_url(u: str | None) -> str:
|
|
|
|
|
|
"""资源 URL 统一出口(读取时把对象路径生成为可访问 URL):
|
|
|
|
|
|
1) 对象路径(/oss/<key>、/uploads/<f>)补全为站点公开绝对 URL;
|
|
|
|
|
|
2) 本站 CDN 裸链(历史入库的无鉴权链接)自动重签为鉴权直链;
|
|
|
|
|
|
3) 其余完整 URL 原样返回。"""
|
|
|
|
|
|
import os as _os
|
|
|
|
|
|
|
|
|
|
|
|
if not u:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
u = str(u).strip()
|
|
|
|
|
|
base = _os.environ.get("PUBLIC_BASE", "https://opc.pinesound.cn").rstrip("/")
|
|
|
|
|
|
if not (u.startswith("http://") or u.startswith("https://")):
|
|
|
|
|
|
if u.startswith("/oss/"):
|
|
|
|
|
|
# OSS 对象路径 → 直接生成 CDN 鉴权直链(未启用 OSS 时回退站点路径)
|
|
|
|
|
|
if oss.enabled and config.OSS_CDN_BASE_URL:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return oss.cdn_url(u[len("/oss/"):])
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return u
|
|
|
|
|
|
return base + u
|
|
|
|
|
|
return base + ("" if u.startswith("/") else "/") + u
|
|
|
|
|
|
# 历史 CDN 裸链(无鉴权段)→ 提取 key 重签(鉴权上线前的存量数据)
|
|
|
|
|
|
cdn = config.OSS_CDN_BASE_URL
|
|
|
|
|
|
if cdn and config.OSS_CDN_AUTH_KEY and u.startswith(f"{cdn}/") and "/7FFFFFFF/" not in u:
|
|
|
|
|
|
key = u[len(cdn) + 1:].split("?")[0]
|
|
|
|
|
|
try:
|
|
|
|
|
|
return oss.cdn_url(key)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return u
|
|
|
|
|
|
return u
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 本站资源 URL 前缀(用于入库归一化:完整 URL → 对象路径)
|
|
|
|
|
|
_OSS_PUBLIC_PREFIXES = (
|
|
|
|
|
|
lambda: (config.OSS_CDN_BASE_URL or "") + "/",
|
|
|
|
|
|
lambda: (config.OSS_ENDPOINT or "").rstrip("/") + "/" + config.OSS_BUCKET + "/",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def to_object_path(u: str | None) -> str:
|
|
|
|
|
|
"""入库归一化(写入规范):本站资源完整 URL → 对象相对路径(/oss/<key>、/uploads/<f>)。
|
|
|
|
|
|
|
|
|
|
|
|
换 OSS/CDN 供应商时只改本层配置,数据库无需迁移。
|
|
|
|
|
|
外部 URL(如微信头像)与本站已有相对路径原样保留。
|
|
|
|
|
|
"""
|
|
|
|
|
|
import os as _os
|
|
|
|
|
|
|
|
|
|
|
|
if not u:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
u = str(u).strip()
|
|
|
|
|
|
if not (u.startswith("http://") or u.startswith("https://")):
|
|
|
|
|
|
return u # 已是路径/外链原样
|
|
|
|
|
|
for prefix_fn in _OSS_PUBLIC_PREFIXES:
|
|
|
|
|
|
prefix = prefix_fn()
|
|
|
|
|
|
if prefix and u.startswith(prefix):
|
|
|
|
|
|
rest = u[len(prefix):].split("?")[0].strip("/")
|
|
|
|
|
|
parts = rest.split("/")
|
|
|
|
|
|
# CDN 鉴权方式A:{sign}/{timestamp}/{key}——剥掉前两段
|
|
|
|
|
|
if len(parts) >= 3 and parts[1].upper() == "7FFFFFFF":
|
|
|
|
|
|
rest = "/".join(parts[2:])
|
|
|
|
|
|
if ".." in rest.split("/"):
|
|
|
|
|
|
return u
|
|
|
|
|
|
return f"/oss/{rest}"
|
|
|
|
|
|
# 站点自身域名下的 /oss、/uploads 路径 → 还原为相对路径
|
|
|
|
|
|
base = _os.environ.get("PUBLIC_BASE", "https://opc.pinesound.cn").rstrip("/")
|
|
|
|
|
|
if u.startswith(base + "/"):
|
|
|
|
|
|
rest = u[len(base) + 1:].split("?")[0]
|
|
|
|
|
|
if rest.startswith(("oss/", "uploads/")):
|
|
|
|
|
|
return "/" + rest
|
|
|
|
|
|
return u # 外部 URL 原样(如微信头像)
|