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
parent f02b3f4098
commit 8ab2d869fc
11 changed files with 313 additions and 121 deletions

View File

@ -106,8 +106,9 @@ useEffect(() => { loadData() }, [loadData])
- 配置项:`AI_PROVIDER`, `AI_API_KEY`, `AI_API_BASE_URL`
### 文件上传
- 阿里云 OSS 直传,前端通过 `useOSSUpload` hook 处理
- 流程:`api.getUploadPolicy()` → POST 到 OSS → `api.fileUploaded()` 回调
- 腾讯云 COS 直传,前端通过 `useOSSUpload` hook 处理
- 流程:`api.getUploadPolicy()` → POST 到 COS → `api.fileUploaded()` 回调
- COS 签名HMAC-SHA1字段包括 `q-sign-algorithm``q-ak``q-key-time``q-signature``policy`
### 实时推送
- SSE (Server-Sent Events),端点 `/api/v1/sse/events`

View File

@ -8,12 +8,16 @@
APP_NAME=秒思智能审核平台
APP_VERSION=1.0.0
DEBUG=false
ENVIRONMENT=production
# --- 数据库 ---
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/miaosi
POSTGRES_USER=miaosi
POSTGRES_PASSWORD=change-me-in-production
POSTGRES_DB=miaosi
DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
# --- Redis ---
REDIS_URL=redis://localhost:6379/0
REDIS_URL=redis://redis:6379/0
# --- JWT ---
# 生产环境务必更换为随机密钥: python -c "import secrets; print(secrets.token_urlsafe(64))"
@ -26,12 +30,20 @@ AI_PROVIDER=oneapi
AI_API_KEY=
AI_API_BASE_URL=
# --- 阿里云 OSS ---
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
OSS_BUCKET_NAME=miaosi-files
OSS_BUCKET_DOMAIN=
# --- 腾讯云 COS ---
COS_SECRET_ID=
COS_SECRET_KEY=
COS_REGION=ap-guangzhou
COS_BUCKET_NAME=miaosi-files-1250000000
COS_CDN_DOMAIN=
# --- 邮件 SMTP ---
SMTP_HOST=
SMTP_PORT=465
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM_NAME=秒思智能审核平台
SMTP_USE_SSL=true
# --- 加密密钥 ---
# 用于加密存储 API 密钥等敏感数据

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),
):
"""
文件上传完成回调

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 = ""

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

View File

@ -6,13 +6,11 @@ services:
image: postgres:16-alpine
container_name: miaosi-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: miaosi
ports:
- "5432:5432"
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-miaosi}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
@ -23,10 +21,8 @@ services:
redis:
image: redis:7-alpine
container_name: miaosi-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
- ./data/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
@ -39,21 +35,18 @@ services:
context: .
dockerfile: Dockerfile
container_name: miaosi-api
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/miaosi
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-miaosi}
REDIS_URL: redis://redis:6379/0
DEBUG: "true"
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./app:/app/app
- video_temp:/tmp/videos
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
# Celery Worker
celery-worker:
@ -62,15 +55,16 @@ services:
dockerfile: Dockerfile
container_name: miaosi-celery-worker
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/miaosi
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-miaosi}
REDIS_URL: redis://redis:6379/0
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./app:/app/app
- video_temp:/tmp/videos
command: celery -A app.celery_app worker -l info -Q default,review -c 2
@ -81,15 +75,42 @@ services:
dockerfile: Dockerfile
container_name: miaosi-celery-beat
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/miaosi
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-miaosi}
REDIS_URL: redis://redis:6379/0
env_file:
- .env
depends_on:
- celery-worker
volumes:
- ./app:/app/app
redis:
condition: service_healthy
celery-worker: {}
command: celery -A app.celery_app beat -l info
# Next.js 前端
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-https://your-domain.com}
NEXT_PUBLIC_USE_MOCK: "false"
container_name: miaosi-frontend
depends_on:
- api
# Nginx 反向代理
nginx:
image: nginx:alpine
container_name: miaosi-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on:
- api
- frontend
volumes:
postgres_data:
redis_data:
video_temp:

View File

@ -0,0 +1,50 @@
server {
listen 80;
server_name your-domain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# SSE 代理(长连接,必须在 /api/ 之前匹配)
location /api/v1/sse/ {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# API 代理
location /api/ {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 前端代理
location / {
proxy_pass http://frontend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

43
backend/nginx/nginx.conf Normal file
View File

@ -0,0 +1,43 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1000;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# 上传大小限制(与后端 MAX_FILE_SIZE_MB 对齐)
client_max_body_size 500m;
include /etc/nginx/conf.d/*.conf;
}

49
backend/scripts/backup.sh Executable file
View File

@ -0,0 +1,49 @@
#!/bin/bash
# ===========================
# PostgreSQL 每日备份脚本
# 备份到本地 + 上传到腾讯云 COS
# ===========================
# 配合 crontab 使用:
# 0 3 * * * /path/to/backup.sh >> /var/log/miaosi-backup.log 2>&1
set -euo pipefail
# ---- 配置 ----
BACKUP_DIR="${BACKUP_DIR:-/var/backups/miaosi}"
POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-miaosi-postgres}"
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_DB="${POSTGRES_DB:-miaosi}"
RETAIN_DAYS="${RETAIN_DAYS:-7}"
# COS 备份桶(需要先安装 coscli 并配置好凭证)
COS_BACKUP_BUCKET="${COS_BACKUP_BUCKET:-}"
COS_BACKUP_PREFIX="${COS_BACKUP_PREFIX:-backups/postgres}"
# ---- 执行 ----
DATE=$(date +%Y%m%d_%H%M%S)
FILENAME="miaosi_${DATE}.sql.gz"
mkdir -p "$BACKUP_DIR"
echo "[$(date)] 开始备份数据库 ${POSTGRES_DB}..."
# 1. pg_dump 导出并压缩
docker exec "$POSTGRES_CONTAINER" pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" | gzip > "${BACKUP_DIR}/${FILENAME}"
echo "[$(date)] 本地备份完成: ${BACKUP_DIR}/${FILENAME}"
# 2. 上传到 COS如果配置了备份桶
if [ -n "$COS_BACKUP_BUCKET" ]; then
if command -v coscli &> /dev/null; then
coscli cp "${BACKUP_DIR}/${FILENAME}" "cos://${COS_BACKUP_BUCKET}/${COS_BACKUP_PREFIX}/${FILENAME}"
echo "[$(date)] 已上传到 COS: ${COS_BACKUP_BUCKET}/${COS_BACKUP_PREFIX}/${FILENAME}"
else
echo "[$(date)] 警告: coscli 未安装,跳过 COS 上传"
fi
fi
# 3. 清理过期的本地备份
find "$BACKUP_DIR" -name "miaosi_*.sql.gz" -mtime +"$RETAIN_DAYS" -delete
echo "[$(date)] 已清理 ${RETAIN_DAYS} 天前的本地备份"
echo "[$(date)] 备份完成"

View File

@ -6,7 +6,7 @@
# --- API 地址 ---
# 后端 API 基础 URL浏览器端访问
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
NEXT_PUBLIC_API_BASE_URL=https://your-domain.com
# --- Mock 模式 ---
# 设为 true 使用前端 mock 数据development 环境下默认开启)

View File

@ -57,17 +57,19 @@ export function useOSSUpload(fileType: string = 'general'): UseOSSUploadReturn {
setProgress(10)
const policy = await api.getUploadPolicy(fileType)
// 2. 构建 OSS 直传 FormData
// 2. 构建 COS 直传 FormData
const fileKey = `${policy.dir}${Date.now()}_${file.name}`
const formData = new FormData()
formData.append('key', fileKey)
formData.append('q-sign-algorithm', policy.q_sign_algorithm)
formData.append('q-ak', policy.q_ak)
formData.append('q-key-time', policy.q_key_time)
formData.append('q-signature', policy.q_signature)
formData.append('policy', policy.policy)
formData.append('OSSAccessKeyId', policy.access_key_id)
formData.append('signature', policy.signature)
formData.append('success_action_status', '200')
formData.append('file', file)
// 3. 上传到 OSS
// 3. 上传到 COS
setProgress(30)
const xhr = new XMLHttpRequest()
await new Promise<void>((resolve, reject) => {