|
|
from __future__ import annotations
|
|
|
|
|
|
import os
|
|
|
from io import BytesIO
|
|
|
from typing import BinaryIO, Dict, Optional
|
|
|
|
|
|
from .interface import StorageInterface
|
|
|
|
|
|
|
|
|
class QiniuKodoStorage(StorageInterface):
|
|
|
"""七牛云Kodo存储适配器"""
|
|
|
|
|
|
storage_type = "qiniu_kodo"
|
|
|
|
|
|
def __init__(self, config: dict):
|
|
|
try:
|
|
|
from qiniu import Auth, BucketManager, put_data
|
|
|
except ImportError:
|
|
|
raise ImportError("请先安装 qiniu: pip install qiniu")
|
|
|
|
|
|
self.access_key = config.get("access_key")
|
|
|
self.secret_key = config.get("secret_key")
|
|
|
self.bucket_name = config.get("bucket")
|
|
|
self.domain = config.get("domain") # CDN域名
|
|
|
|
|
|
if not all([self.access_key, self.secret_key, self.bucket_name]):
|
|
|
raise ValueError("七牛云Kodo配置不完整,需要: access_key, secret_key, bucket")
|
|
|
|
|
|
self.auth = Auth(self.access_key, self.secret_key)
|
|
|
self.bucket_manager = BucketManager(self.auth)
|
|
|
self.put_data = put_data
|
|
|
|
|
|
def upload(self, file_stream: BinaryIO, key: str, mime_type: Optional[str] = None) -> Dict:
|
|
|
"""上传文件到七牛云Kodo"""
|
|
|
token = self.auth.upload_token(self.bucket_name, key)
|
|
|
|
|
|
data = file_stream.read()
|
|
|
ret, info = self.put_data(token, key, data, mime_type=mime_type)
|
|
|
|
|
|
if info.status_code != 200:
|
|
|
raise Exception(f"七牛云上传失败: {info}")
|
|
|
|
|
|
return {
|
|
|
"hash": ret.get("hash"),
|
|
|
"key": ret.get("key"),
|
|
|
}
|
|
|
|
|
|
def append_chunk(self, key: str, chunk_stream: BinaryIO, offset: int) -> None:
|
|
|
"""
|
|
|
追加写入数据块
|
|
|
注意:七牛云不支持原生append,采用读-改-写策略
|
|
|
"""
|
|
|
chunk_data = chunk_stream.read()
|
|
|
|
|
|
# 如果文件已存在,先下载
|
|
|
existing_data = b""
|
|
|
if self.exists(key):
|
|
|
try:
|
|
|
file_stream = self.download(key)
|
|
|
existing_data = file_stream.read()
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
# 合并数据
|
|
|
if offset == 0:
|
|
|
new_data = chunk_data
|
|
|
else:
|
|
|
new_data = existing_data[:offset] + chunk_data
|
|
|
|
|
|
# 重新上传
|
|
|
token = self.auth.upload_token(self.bucket_name, key)
|
|
|
self.put_data(token, key, new_data)
|
|
|
|
|
|
def download(self, key: str) -> BinaryIO:
|
|
|
"""从七牛云Kodo下载文件"""
|
|
|
# 生成私有下载链接
|
|
|
base_url = f"http://{self.domain}/{key}" if self.domain else f"http://{self.bucket_name}.kodo.com/{key}"
|
|
|
private_url = self.auth.private_download_url(base_url, expires=3600)
|
|
|
|
|
|
# 下载文件
|
|
|
import requests
|
|
|
response = requests.get(private_url)
|
|
|
if response.status_code != 200:
|
|
|
raise FileNotFoundError(f"文件不存在: {key}")
|
|
|
|
|
|
return BytesIO(response.content)
|
|
|
|
|
|
def delete(self, key: str) -> None:
|
|
|
"""删除Kodo文件"""
|
|
|
ret, info = self.bucket_manager.delete(self.bucket_name, key)
|
|
|
if info.status_code not in [200, 612]: # 612表示文件不存在
|
|
|
raise Exception(f"删除文件失败: {info}")
|
|
|
|
|
|
def exists(self, key: str) -> bool:
|
|
|
"""检查Kodo文件是否存在"""
|
|
|
ret, info = self.bucket_manager.stat(self.bucket_name, key)
|
|
|
return info.status_code == 200
|
|
|
|
|
|
def get_url(self, key: str, expires: int = 3600) -> str:
|
|
|
"""获取Kodo文件访问URL"""
|
|
|
base_url = f"http://{self.domain}/{key}" if self.domain else f"http://{self.bucket_name}.kodo.com/{key}"
|
|
|
|
|
|
if expires == 0:
|
|
|
# 永久URL(适用于公共读bucket)
|
|
|
return base_url
|
|
|
|
|
|
# 生成带签名的临时URL
|
|
|
return self.auth.private_download_url(base_url, expires=expires)
|
|
|
|