# 七牛云对象存储 (Kodo) Python SDK 开发规范

## 概述

本文档规范美家卡智影项目中使用七牛云对象存储 (Kodo) Python SDK 的开发标准，涵盖文件上传、下载、管理和 CDN 操作等核心功能。

**SDK 版本**: v5.0.0+  
**Python 版本**: 3.8+ (兼容 2.7 和 3.3+)  
**官方文档**: https://developer.qiniu.com/kodo/1242/python

---

## 1. 安装与初始化

### 1.1 安装 SDK

```bash
pip install qiniu
```

### 1.2 初始化配置

```python
from qiniu import Auth

# 从环境变量读取密钥（推荐）
import os
access_key = os.getenv('QINIU_ACCESS_KEY')
secret_key = os.getenv('QINIU_SECRET_KEY')

# 构建鉴权对象
q = Auth(access_key, secret_key)
```

**环境变量配置** (`.env` 文件):
```bash
QINIU_ACCESS_KEY=your-access-key
QINIU_SECRET_KEY=your-secret-key
QINIU_BUCKET_NAME=your-bucket-name
QINIU_BUCKET_DOMAIN=your-domain.com
```

---

## 2. 文件上传

### 2.1 上传方式选择

| 场景 | 推荐方式 | 说明 |
|------|----------|------|
| 小文件 (< 100MB) | 表单上传 (put_file) | 简单快速，一次请求完成 |
| 大文件 (> 100MB) | 分片上传 v2 (put_file_v2) | 支持断点续传，适应弱网环境 |
| 网络不稳定 | 分片上传 v2 | 自动重试，更可靠 |

### 2.2 服务端生成上传 Token

```python
from qiniu import Auth

def generate_upload_token(
    bucket_name: str,
    key: str = None,
    expires: int = 3600,
    policy: dict = None
) -> str:
    """
    生成上传凭证
    
    Args:
        bucket_name: 存储空间名称
        key: 指定文件名（可选）
        expires: Token 有效期（秒），默认 3600
        policy: 上传策略配置（可选）
    
    Returns:
        上传 Token 字符串
    """
    q = Auth(access_key, secret_key)
    
    # 自定义上传策略（可选）
    if policy is None:
        policy = {}
    
    token = q.upload_token(bucket_name, key, expires, policy)
    return token
```

### 2.3 客户端直传（推荐）

**服务端生成 Token，客户端直传到七牛云**:

```python
# 服务端 API
from fastapi import APIRouter
from pydantic import BaseModel

router = APIRouter(prefix="/qiniu", tags=["Qiniu"])

class UploadTokenRequest(BaseModel):
    key: str           # 文件名
    expires: int = 3600  # Token 有效期

class UploadTokenResponse(BaseModel):
    token: str
    key: str
    upload_url: str = "https://upload.qiniup.com"

@router.post("/upload-token", response_model=UploadTokenResponse)
async def get_upload_token(request: UploadTokenRequest):
    """获取上传凭证，客户端直传"""
    token = generate_upload_token(
        bucket_name=os.getenv('QINIU_BUCKET_NAME'),
        key=request.key,
        expires=request.expires
    )
    return UploadTokenResponse(token=token, key=request.key)
```

### 2.4 服务端上传文件（保留场景）

```python
from qiniu import Auth, put_file_v2, etag
import qiniu.config

def upload_file(
    local_file_path: str,
    key: str,
    bucket_name: str = None
) -> dict:
    """
    服务端上传文件到七牛云
    
    Args:
        local_file_path: 本地文件路径
        key: 存储的文件名（如 "audios/voice.mp3"）
        bucket_name: 存储空间名称
    
    Returns:
        {"key": str, "hash": str, "url": str}
    """
    bucket_name = bucket_name or os.getenv('QINIU_BUCKET_NAME')
    
    # 生成上传 Token
    token = q.upload_token(bucket_name, key, 3600)
    
    # 使用分片上传 v2（推荐）
    ret, info = put_file_v2(
        up_token=token,
        key=key,
        file_path=local_file_path,
        version='v2'  # 指定分片上传 v2 版本
    )
    
    if ret is None:
        raise Exception(f"上传失败: {info}")
    
    # 验证文件完整性
    assert ret['key'] == key
    assert ret['hash'] == etag(local_file_path)
    
    # 构建访问 URL
    domain = os.getenv('QINIU_BUCKET_DOMAIN')
    url = f"https://{domain}/{key}"
    
    return {
        "key": ret['key'],
        "hash": ret['hash'],
        "url": url
    }
```

### 2.5 上传策略 (PutPolicy)

常用策略配置：

```python
# 1. 限制文件大小 (10MB ~ 100MB)
policy = {
    "fsizeMin": 1024 * 1024 * 10,       # 最小 10MB
    "fsizeLimit": 1024 * 1024 * 100,    # 最大 100MB
    "mimeLimit": "audio/*;video/*"       # 限制文件类型
}

# 2. 上传后回调业务服务器
policy = {
    "callbackUrl": "https://your-api.com/callback",
    "callbackBody": "key=$(key)&hash=$(etag)&fname=$(fname)&fsize=$(fsize)",
    "callbackBodyType": "application/x-www-form-urlencoded"
}

# 3. 上传后转码（持久化处理）
import base64
fops = 'avthumb/mp4/s/640x360/vb/1.25m'
saveas_key = base64.urlsafe_b64encode(f'{bucket_name}:output.mp4'.encode()).decode()

policy = {
    "persistentOps": f"{fops}|saveas/{saveas_key}",
    "persistentPipeline": "transcoding",  # 队列名称
    "persistentNotifyUrl": "https://your-api.com/pfop/callback"
}
```

---

## 3. 文件下载

### 3.1 公有空间下载

公有空间文件可直接访问：

```python
def get_public_url(key: str, domain: str = None) -> str:
    """获取公有空间文件 URL"""
    domain = domain or os.getenv('QINIU_BUCKET_DOMAIN')
    return f"https://{domain}/{key}"
```

### 3.2 私有空间下载（临时 URL）

```python
import requests

def get_private_url(key: str, expires: int = 3600) -> str:
    """
    生成私有空间文件的临时下载 URL
    
    Args:
        key: 文件 Key
        expires: 链接有效期（秒）
    
    Returns:
        带签名的临时 URL
    """
    domain = os.getenv('QINIU_BUCKET_DOMAIN')
    base_url = f"https://{domain}/{key}"
    
    # 生成私有下载链接
    private_url = q.private_download_url(base_url, expires=expires)
    return private_url

# 使用示例
def download_file(key: str, local_path: str):
    """下载私有空间文件到本地"""
    private_url = get_private_url(key, expires=3600)
    
    response = requests.get(private_url)
    if response.status_code == 200:
        with open(local_path, 'wb') as f:
            f.write(response.content)
        return True
    return False
```

---

## 4. 文件管理 (BucketManager)

### 4.1 初始化管理器

```python
from qiniu import Auth, BucketManager

q = Auth(access_key, secret_key)
bucket = BucketManager(q)
```

### 4.2 获取文件信息

```python
def get_file_info(bucket_name: str, key: str) -> dict:
    """
    获取文件元信息
    
    Returns:
        {
            "fsize": 文件大小(字节),
            "hash": 文件哈希,
            "mimeType": MIME类型,
            "putTime": 上传时间(100纳秒时间戳),
            "type": 存储类型(0=标准,1=低频,2=归档,3=深度归档)
        }
    """
    ret, info = bucket.stat(bucket_name, key)
    if ret is None:
        raise Exception(f"获取文件信息失败: {info}")
    return ret
```

### 4.3 列举文件列表

```python
from typing import List, Optional

def list_files(
    bucket_name: str,
    prefix: str = None,      # 前缀筛选
    limit: int = 100,        # 每页数量
    marker: str = None       # 分页标记
) -> dict:
    """
    列举空间文件列表
    
    Returns:
        {
            "items": [{"key": ..., "fsize": ..., ...}],
            "marker": "分页标记",
            "commonPrefixes": ["公共前缀列表"]
        }
    """
    ret, eof, info = bucket.list(
        bucket_name,
        prefix=prefix,
        marker=marker,
        limit=limit,
        delimiter=None  # 不指定分隔符
    )
    
    return {
        "items": ret.get('items', []),
        "marker": ret.get('marker'),
        "eof": eof  # 是否已列举完
    }

# 遍历所有文件
def list_all_files(bucket_name: str, prefix: str = None) -> List[dict]:
    """遍历获取所有文件"""
    files = []
    marker = None
    
    while True:
        result = list_files(bucket_name, prefix, limit=1000, marker=marker)
        files.extend(result['items'])
        
        if result['eof'] or not result['marker']:
            break
        marker = result['marker']
    
    return files
```

### 4.4 删除文件

```python
def delete_file(bucket_name: str, key: str) -> bool:
    """删除单个文件"""
    ret, info = bucket.delete(bucket_name, key)
    return ret == {}

def delete_files_batch(bucket_name: str, keys: List[str]) -> dict:
    """批量删除文件"""
    from qiniu import build_batch_delete
    
    ops = build_batch_delete(bucket_name, keys)
    ret, info = bucket.batch(ops)
    return ret
```

### 4.5 复制和移动文件

```python
def copy_file(
    src_bucket: str,
    src_key: str,
    dest_bucket: str,
    dest_key: str,
    force: bool = True
) -> bool:
    """复制文件"""
    ret, info = bucket.copy(
        src_bucket, src_key,
        dest_bucket, dest_key,
        force=force  # 强制覆盖
    )
    return ret is not None

def move_file(
    src_bucket: str,
    src_key: str,
    dest_bucket: str,
    dest_key: str,
    force: bool = True
) -> bool:
    """移动/重命名文件"""
    ret, info = bucket.move(
        src_bucket, src_key,
        dest_bucket, dest_key,
        force=force
    )
    return ret is not None
```

### 4.6 修改文件元信息

```python
def change_mime(bucket_name: str, key: str, mime_type: str):
    """修改文件 MIME 类型"""
    ret, info = bucket.change_mime(bucket_name, key, mime_type)
    return ret is not None

def change_type(bucket_name: str, key: str, file_type: int):
    """
    修改文件存储类型
    
    file_type:
        0 = 标准存储
        1 = 低频存储
        2 = 归档存储
        3 = 深度归档存储
    """
    ret, info = bucket.change_type(bucket_name, key, file_type)
    return ret is not None
```

### 4.7 批量操作

```python
from qiniu import (
    build_batch_stat,
    build_batch_copy,
    build_batch_move,
    build_batch_rename,
    build_batch_delete
)

def batch_stat(bucket_name: str, keys: List[str]) -> List[dict]:
    """批量查询文件信息"""
    ops = build_batch_stat(bucket_name, keys)
    ret, info = bucket.batch(ops)
    return ret

def batch_rename(
    bucket_name: str,
    key_map: dict,  # {"old_key": "new_key", ...}
    force: bool = True
):
    """批量重命名"""
    ops = build_batch_rename(bucket_name, key_map, force=force)
    ret, info = bucket.batch(ops)
    return ret

def batch_copy(
    src_bucket: str,
    key_map: dict,  # {"src_key": "dest_key", ...}
    dest_bucket: str = None,
    force: bool = True
):
    """批量复制"""
    dest_bucket = dest_bucket or src_bucket
    ops = build_batch_copy(src_bucket, key_map, dest_bucket, force=force)
    ret, info = bucket.batch(ops)
    return ret
```

### 4.8 抓取网络资源

```python
def fetch_remote_file(
    remote_url: str,
    key: str,
    bucket_name: str = None
) -> dict:
    """
    抓取远程文件到七牛云
    
    Args:
        remote_url: 远程文件 URL
        key: 保存的文件名
        bucket_name: 目标空间
    
    Returns:
        {"key": ..., "hash": ..., "fsize": ...}
    """
    bucket_name = bucket_name or os.getenv('QINIU_BUCKET_NAME')
    ret, info = bucket.fetch(remote_url, bucket_name, key)
    return ret
```

---

## 5. CDN 操作

### 5.1 初始化 CDN Manager

```python
from qiniu import CdnManager

cdn_manager = CdnManager(q)
```

### 5.2 刷新 CDN 缓存

```python
def refresh_urls(urls: List[str]) -> dict:
    """刷新指定 URL 的 CDN 缓存"""
    ret, info = cdn_manager.refresh_urls(urls)
    return ret

def refresh_dirs(dirs: List[str]) -> dict:
    """刷新整个目录的 CDN 缓存"""
    ret, info = cdn_manager.refresh_dirs(dirs)
    return ret
```

### 5.3 预取资源

```python
def prefetch_urls(urls: List[str]) -> dict:
    """预取资源到 CDN 节点"""
    ret, info = cdn_manager.prefetch_urls(urls)
    return ret
```

### 5.4 获取 CDN 日志

```python
def get_cdn_log_list(domains: List[str], log_date: str) -> List[dict]:
    """
    获取 CDN 日志下载链接
    
    Args:
        domains: 域名列表
        log_date: 日期 (YYYY-MM-DD)
    
    Returns:
        [{"name": ..., "url": ..., "size": ..., "mtime": ...}]
    """
    ret, info = cdn_manager.get_log_list_data(domains, log_date)
    return ret.get('data', [])
```

---

## 6. 项目集成方案

### 6.1 服务端封装模块

```python
# app/services/qiniu_service.py
"""
七牛云对象存储服务封装
"""

import os
from typing import List, Optional
from qiniu import Auth, BucketManager, CdnManager, put_file_v2, etag

class QiniuService:
    """七牛云服务封装"""
    
    def __init__(self):
        access_key = os.getenv('QINIU_ACCESS_KEY')
        secret_key = os.getenv('QINIU_SECRET_KEY')
        self.bucket_name = os.getenv('QINIU_BUCKET_NAME')
        self.domain = os.getenv('QINIU_BUCKET_DOMAIN')
        
        self.auth = Auth(access_key, secret_key)
        self.bucket = BucketManager(self.auth)
        self.cdn = CdnManager(self.auth)
    
    def get_upload_token(self, key: str, expires: int = 3600, policy: dict = None) -> str:
        """生成上传 Token"""
        return self.auth.upload_token(self.bucket_name, key, expires, policy)
    
    def get_file_url(self, key: str, private: bool = False, expires: int = 3600) -> str:
        """获取文件访问 URL"""
        base_url = f"https://{self.domain}/{key}"
        if private:
            return self.auth.private_download_url(base_url, expires)
        return base_url
    
    def upload_file(self, local_path: str, key: str) -> dict:
        """服务端上传文件"""
        token = self.get_upload_token(key)
        ret, info = put_file_v2(token, key, local_path, version='v2')
        
        if ret is None:
            raise Exception(f"上传失败: {info}")
        
        return {
            "key": ret['key'],
            "hash": ret['hash'],
            "url": self.get_file_url(key)
        }
    
    def delete_file(self, key: str) -> bool:
        """删除文件"""
        ret, info = self.bucket.delete(self.bucket_name, key)
        return ret == {}
    
    def refresh_cdn(self, keys: List[str]) -> dict:
        """刷新 CDN 缓存"""
        urls = [self.get_file_url(key) for key in keys]
        return self.cdn.refresh_urls(urls)

# 全局单例
_qiniu_service: Optional[QiniuService] = None

def get_qiniu_service() -> QiniuService:
    global _qiniu_service
    if _qiniu_service is None:
        _qiniu_service = QiniuService()
    return _qiniu_service
```

### 6.2 FastAPI 路由集成

```python
# app/api/v1/qiniu.py

from fastapi import APIRouter, UploadFile, File
from app.services.qiniu_service import get_qiniu_service

router = APIRouter(prefix="/qiniu", tags=["Qiniu"])

@router.post("/upload-token")
async def get_upload_token(key: str, expires: int = 3600):
    """获取客户端直传 Token"""
    service = get_qiniu_service()
    token = service.get_upload_token(key, expires)
    return {"token": token, "key": key}

@router.post("/upload")
async def upload_file(file: UploadFile = File(...), key: str = None):
    """服务端上传文件（小文件场景）"""
    import tempfile
    import shutil
    
    service = get_qiniu_service()
    
    # 生成唯一文件名
    if key is None:
        import uuid
        ext = file.filename.split('.')[-1] if '.' in file.filename else ''
        key = f"uploads/{uuid.uuid4()}.{ext}" if ext else f"uploads/{uuid.uuid4()}"
    
    # 保存临时文件
    with tempfile.NamedTemporaryFile(delete=False) as tmp:
        shutil.copyfileobj(file.file, tmp)
        tmp_path = tmp.name
    
    try:
        result = service.upload_file(tmp_path, key)
        return result
    finally:
        os.unlink(tmp_path)

@router.delete("/files/{key:path}")
async def delete_file(key: str):
    """删除文件"""
    service = get_qiniu_service()
    success = service.delete_file(key)
    return {"success": success}
```

---

## 7. 最佳实践

### 7.1 文件名规范

```python
def generate_key(file_type: str, user_id: str, filename: str) -> str:
    """
    生成规范的文件存储路径
    
    格式: {type}/{user_id}/{date}/{uuid}.{ext}
    """
    import uuid
    from datetime import datetime
    
    ext = filename.split('.')[-1] if '.' in filename else 'bin'
    date = datetime.now().strftime('%Y%m')
    unique_id = str(uuid.uuid4())[:8]
    
    return f"{file_type}/{user_id}/{date}/{unique_id}.{ext}"

# 使用示例
key = generate_key("voices", "user_123", "my-voice.mp3")
# 结果: voices/user_123/202501/a1b2c3d4.mp3
```

### 7.2 错误处理

```python
from qiniu import AuthError, HTTPError

def handle_qiniu_error(func):
    """七牛云操作错误处理装饰器"""
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except AuthError as e:
            raise Exception(f"认证失败: {e}")
        except HTTPError as e:
            raise Exception(f"请求失败: {e}")
        except Exception as e:
            raise Exception(f"操作失败: {e}")
    return wrapper
```

### 7.3 安全配置

1. **密钥管理**: 使用环境变量，禁止硬编码
2. **Token 有效期**: 上传 Token 建议 1 小时，下载 Token 根据场景设置
3. **上传策略**: 限制文件大小和 MIME 类型
4. **私有空间**: 敏感文件使用私有空间 + 临时 URL

---

## 8. 常见问题

### Q1: 上传失败，返回 401 错误？

**A**: 检查 AccessKey 和 SecretKey 是否正确，以及 Token 是否过期。

### Q2: 如何支持大文件上传？

**A**: 使用分片上传 v2 (`put_file_v2`)，SDK 会自动处理分片和断点续传。

### Q3: 文件上传后如何获取访问 URL？

**A**: 公有空间直接拼接 `https://{domain}/{key}`，私有空间使用 `auth.private_download_url()` 生成临时 URL。

### Q4: 如何刷新 CDN 缓存？

**A**: 使用 `CdnManager.refresh_urls()` 或 `refresh_dirs()`，注意目录刷新有每日限额。

### Q5: 上传回调不生效？

**A**: 确保 callbackUrl 是公网可访问的 HTTPS 地址，且返回 Content-Type: application/json。

---

## 9. 参考资料

- [七牛云 Python SDK 官方文档](https://developer.qiniu.com/kodo/1242/python)
- [上传策略文档](https://developer.qiniu.com/kodo/1206/put-policy)
- [表单上传 API](https://developer.qiniu.com/kodo/1272/api-overview)
- [Python SDK GitHub](https://github.com/qiniu/python-sdk)
