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
+16 -8
View File
@@ -1,13 +1,15 @@
"""
文件上传 API
"""
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from app.services.oss import generate_upload_policy, get_file_url
from app.config import settings
from app.models.user import User
from app.api.deps import get_current_user
router = APIRouter(prefix="/upload", tags=["文件上传"])
@@ -19,10 +21,12 @@ class UploadPolicyRequest(BaseModel):
class UploadPolicyResponse(BaseModel):
"""传凭证响应"""
access_key_id: str
"""COS 直传凭证响应"""
q_sign_algorithm: str
q_ak: str
q_key_time: str
q_signature: str
policy: str
signature: str
host: str
dir: str
expire: int
@@ -49,11 +53,12 @@ class FileUploadedResponse(BaseModel):
@router.post("/policy", response_model=UploadPolicyResponse)
async def get_upload_policy(
request: UploadPolicyRequest,
current_user: User = Depends(get_current_user),
):
"""
获取 OSS 直传凭证
获取 COS 直传凭证
前端使用此凭证直接上传文件到阿里云 OSS,无需经过后端。
前端使用此凭证直接上传文件到腾讯云 COS,无需经过后端。
文件类型说明:
- script: 脚本文档 (docx, pdf, xlsx, txt, pptx)
@@ -87,9 +92,11 @@ async def get_upload_policy(
)
return UploadPolicyResponse(
access_key_id=policy["accessKeyId"],
q_sign_algorithm=policy["q_sign_algorithm"],
q_ak=policy["q_ak"],
q_key_time=policy["q_key_time"],
q_signature=policy["q_signature"],
policy=policy["policy"],
signature=policy["signature"],
host=policy["host"],
dir=policy["dir"],
expire=policy["expire"],
@@ -100,6 +107,7 @@ async def get_upload_policy(
@router.post("/complete", response_model=FileUploadedResponse)
async def file_uploaded(
request: FileUploadedRequest,
current_user: User = Depends(get_current_user),
):
"""
文件上传完成回调
+6 -6
View File
@@ -32,12 +32,12 @@ class Settings(BaseSettings):
AI_API_KEY: str = "" # 中转服务商的 API Key
AI_API_BASE_URL: str = "" # 中转服务商的 Base URL,如 https://api.oneinall.ai/v1
# 阿里云 OSS 配置
OSS_ACCESS_KEY_ID: str = ""
OSS_ACCESS_KEY_SECRET: str = ""
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliyuncs.com"
OSS_BUCKET_NAME: str = "miaosi-files"
OSS_BUCKET_DOMAIN: str = "" # 公开访问域名,如 https://miaosi-files.oss-cn-hangzhou.aliyuncs.com
# 腾讯云 COS 配置
COS_SECRET_ID: str = ""
COS_SECRET_KEY: str = ""
COS_REGION: str = "ap-guangzhou"
COS_BUCKET_NAME: str = "miaosi-files-1250000000"
COS_CDN_DOMAIN: str = "" # CDN 自定义域名,空则用 COS 源站
# 邮件 SMTP
SMTP_HOST: str = ""
+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