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) # 对于大文件,使用分块读取来减少内存压力 # 七牛 SDK 的 put_data 需要完整数据,所以我们分块读取后组合 BUFFER_SIZE = 8 * 1024 * 1024 # 8MB chunks = [] total_size = 0 while True: chunk = file_stream.read(BUFFER_SIZE) if not chunk: break chunks.append(chunk) total_size += len(chunk) data = b''.join(chunks) 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)