feat: 阿里云 OSS 迁移至腾讯云 COS + 完善部署配置

COS 迁移:
- 后端签名服务改为 COS HMAC-SHA1 表单直传签名
- config.py: OSS_* 配置项替换为 COS_SECRET_ID/KEY/REGION/BUCKET_NAME/CDN_DOMAIN
- upload.py: UploadPolicyResponse 改为 COS 字段
- 前端 useOSSUpload hook: FormData 字段改为 COS 格式
- 前端 api.ts: UploadPolicyResponse 类型对齐

部署配置:
- docker-compose.yml: 新增 Nginx + 前端容器,数据卷宿主机持久化
- Nginx: HTTPS + HTTP/2 + SSE 长连接 + API/前端反向代理
- backup.sh: PostgreSQL 每日备份 → 本地 + COS
- .env.example: 更新为 COS 配置模板

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-10 10:28:13 +08:00
co-authored by Claude Opus 4.6
parent f02b3f4098
commit 8ab2d869fc
11 changed files with 313 additions and 121 deletions
+75 -69
View File
@@ -1,5 +1,5 @@
"""
阿里云 OSS 服务
腾讯云 COS 服务 — 表单直传签名
"""
import time
import hmac
@@ -17,99 +17,109 @@ def generate_upload_policy(
upload_dir: Optional[str] = None,
) -> dict:
"""
生成前端直传 OSS 所需的 Policy 和签名
生成前端直传 COS 所需的 Policy 和签名
COS 表单直传签名流程:
1. key_time = "{start_time};{end_time}"
2. sign_key = HMAC-SHA1(secret_key, key_time)
3. policy JSON → Base64 编码
4. string_to_sign = SHA1(policy_base64)
5. signature = HMAC-SHA1(sign_key, string_to_sign)
Returns:
{
"accessKeyId": "...",
"q_sign_algorithm": "sha1",
"q_ak": "SecretId",
"q_key_time": "{start};{end}",
"q_signature": "...",
"policy": "base64 encoded policy",
"signature": "...",
"host": "https://bucket.oss-cn-hangzhou.aliyuncs.com",
"host": "https://bucket.cos.region.myqcloud.com",
"dir": "uploads/2026/02/",
"expire": 1234567890
"expire": 1234567890,
}
"""
if not settings.OSS_ACCESS_KEY_ID or not settings.OSS_ACCESS_KEY_SECRET:
raise ValueError("OSS 配置未设置")
if not settings.COS_SECRET_ID or not settings.COS_SECRET_KEY:
raise ValueError("COS 配置未设置")
# 计算过期时间
expire_time = int(time.time()) + expire_seconds
expire_date = datetime.utcfromtimestamp(expire_time).strftime("%Y-%m-%dT%H:%M:%SZ")
# 计算时间范围
start_time = int(time.time())
end_time = start_time + expire_seconds
# key_time: "{start};{end}"
key_time = f"{start_time};{end_time}"
# 默认上传目录:uploads/年/月/
if upload_dir is None:
now = datetime.now()
upload_dir = f"uploads/{now.year}/{now.month:02d}/"
# 构建 Policy
# 1. sign_key = HMAC-SHA1(secret_key, key_time)
sign_key = hmac.new(
settings.COS_SECRET_KEY.encode(),
key_time.encode(),
hashlib.sha1,
).hexdigest()
# 2. 构建 PolicyCOS 表单上传 Policy 格式)
policy_dict = {
"expiration": expire_date,
"expiration": datetime.utcfromtimestamp(end_time).strftime(
"%Y-%m-%dT%H:%M:%S.000Z"
),
"conditions": [
{"bucket": settings.OSS_BUCKET_NAME},
{"bucket": settings.COS_BUCKET_NAME},
["starts-with", "$key", upload_dir],
{"q-sign-algorithm": "sha1"},
{"q-ak": settings.COS_SECRET_ID},
{"q-sign-time": key_time},
["content-length-range", 0, max_size_mb * 1024 * 1024],
]
],
}
# Base64 编码 Policy
# 3. Base64 编码 Policy
policy_json = json.dumps(policy_dict)
policy_base64 = base64.b64encode(policy_json.encode()).decode()
# 计算签名
signature = base64.b64encode(
hmac.new(
settings.OSS_ACCESS_KEY_SECRET.encode(),
policy_base64.encode(),
hashlib.sha1
).digest()
).decode()
# 4. string_to_sign = SHA1(policy_base64)
string_to_sign = hashlib.sha1(policy_base64.encode()).hexdigest()
# 5. signature = HMAC-SHA1(sign_key, string_to_sign)
signature = hmac.new(
sign_key.encode(),
string_to_sign.encode(),
hashlib.sha1,
).hexdigest()
# 构建 Host
host = settings.OSS_BUCKET_DOMAIN
if not host:
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
host = f"https://{settings.COS_BUCKET_NAME}.cos.{settings.COS_REGION}.myqcloud.com"
return {
"accessKeyId": settings.OSS_ACCESS_KEY_ID,
"q_sign_algorithm": "sha1",
"q_ak": settings.COS_SECRET_ID,
"q_key_time": key_time,
"q_signature": signature,
"policy": policy_base64,
"signature": signature,
"host": host,
"dir": upload_dir,
"expire": expire_time,
"expire": end_time,
}
def generate_sts_token(
role_arn: str,
session_name: str = "miaosi-upload",
duration_seconds: int = 3600,
) -> dict:
"""
生成 STS 临时凭证(需要配置 RAM 角色)
当前使用 Policy 签名方式,STS 方式为可选增强。
如需启用 STS,请安装 aliyun-python-sdk-sts 并配置 RAM 角色。
"""
# 回退到 Policy 签名方式
return generate_upload_policy(
max_size_mb=settings.MAX_FILE_SIZE_MB,
expire_seconds=duration_seconds,
)
def get_file_url(file_key: str) -> str:
"""
获取文件的公开访问 URL
获取文件的访问 URL
优先使用 CDN 域名,否则用 COS 源站域名。
Args:
file_key: 文件在 OSS 中的 key,如 "uploads/2026/02/video.mp4"
file_key: 文件在 COS 中的 key,如 "uploads/2026/02/video.mp4"
Returns:
完整的访问 URL
"""
host = settings.OSS_BUCKET_DOMAIN
if not host:
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
if settings.COS_CDN_DOMAIN:
host = settings.COS_CDN_DOMAIN
else:
host = f"https://{settings.COS_BUCKET_NAME}.cos.{settings.COS_REGION}.myqcloud.com"
# 确保 host 以 https:// 开头
if not host.startswith("http"):
@@ -129,26 +139,22 @@ def parse_file_key_from_url(url: str) -> str:
从完整 URL 解析出文件 key
Args:
url: 完整的 OSS URL
url: 完整的 COS URL
Returns:
文件 key
"""
host = settings.OSS_BUCKET_DOMAIN
if not host:
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
# 尝试移除 CDN 域名
if settings.COS_CDN_DOMAIN:
cdn = settings.COS_CDN_DOMAIN.rstrip("/")
if not cdn.startswith("http"):
cdn = f"https://{cdn}"
if url.startswith(cdn):
return url[len(cdn):].lstrip("/")
# 移除 host 前缀
if url.startswith(host):
return url[len(host):].lstrip("/")
# 尝试其他格式
if settings.OSS_BUCKET_NAME in url:
# 格式: https://bucket.endpoint/key
parts = url.split(settings.OSS_BUCKET_NAME + ".")
if len(parts) > 1:
key_part = parts[1].split("/", 1)
if len(key_part) > 1:
return key_part[1]
# 尝试移除 COS 源站域名
cos_host = f"https://{settings.COS_BUCKET_NAME}.cos.{settings.COS_REGION}.myqcloud.com"
if url.startswith(cos_host):
return url[len(cos_host):].lstrip("/")
return url