740 lines
18 KiB
Markdown
740 lines
18 KiB
Markdown
# 七牛云对象存储 (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)
|