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:
parent
f02b3f4098
commit
8ab2d869fc
@ -106,8 +106,9 @@ useEffect(() => { loadData() }, [loadData])
|
|||||||
- 配置项:`AI_PROVIDER`, `AI_API_KEY`, `AI_API_BASE_URL`
|
- 配置项:`AI_PROVIDER`, `AI_API_KEY`, `AI_API_BASE_URL`
|
||||||
|
|
||||||
### 文件上传
|
### 文件上传
|
||||||
- 阿里云 OSS 直传,前端通过 `useOSSUpload` hook 处理
|
- 腾讯云 COS 直传,前端通过 `useOSSUpload` hook 处理
|
||||||
- 流程:`api.getUploadPolicy()` → POST 到 OSS → `api.fileUploaded()` 回调
|
- 流程:`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`
|
- SSE (Server-Sent Events),端点 `/api/v1/sse/events`
|
||||||
|
|||||||
@ -8,12 +8,16 @@
|
|||||||
APP_NAME=秒思智能审核平台
|
APP_NAME=秒思智能审核平台
|
||||||
APP_VERSION=1.0.0
|
APP_VERSION=1.0.0
|
||||||
DEBUG=false
|
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 ---
|
||||||
REDIS_URL=redis://localhost:6379/0
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
# --- JWT ---
|
# --- JWT ---
|
||||||
# 生产环境务必更换为随机密钥: python -c "import secrets; print(secrets.token_urlsafe(64))"
|
# 生产环境务必更换为随机密钥: python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||||
@ -26,12 +30,20 @@ AI_PROVIDER=oneapi
|
|||||||
AI_API_KEY=
|
AI_API_KEY=
|
||||||
AI_API_BASE_URL=
|
AI_API_BASE_URL=
|
||||||
|
|
||||||
# --- 阿里云 OSS ---
|
# --- 腾讯云 COS ---
|
||||||
OSS_ACCESS_KEY_ID=
|
COS_SECRET_ID=
|
||||||
OSS_ACCESS_KEY_SECRET=
|
COS_SECRET_KEY=
|
||||||
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
COS_REGION=ap-guangzhou
|
||||||
OSS_BUCKET_NAME=miaosi-files
|
COS_BUCKET_NAME=miaosi-files-1250000000
|
||||||
OSS_BUCKET_DOMAIN=
|
COS_CDN_DOMAIN=
|
||||||
|
|
||||||
|
# --- 邮件 SMTP ---
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM_NAME=秒思智能审核平台
|
||||||
|
SMTP_USE_SSL=true
|
||||||
|
|
||||||
# --- 加密密钥 ---
|
# --- 加密密钥 ---
|
||||||
# 用于加密存储 API 密钥等敏感数据
|
# 用于加密存储 API 密钥等敏感数据
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
文件上传 API
|
文件上传 API
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.services.oss import generate_upload_policy, get_file_url
|
from app.services.oss import generate_upload_policy, get_file_url
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.models.user import User
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/upload", tags=["文件上传"])
|
router = APIRouter(prefix="/upload", tags=["文件上传"])
|
||||||
|
|
||||||
@ -19,10 +21,12 @@ class UploadPolicyRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UploadPolicyResponse(BaseModel):
|
class UploadPolicyResponse(BaseModel):
|
||||||
"""上传凭证响应"""
|
"""COS 直传凭证响应"""
|
||||||
access_key_id: str
|
q_sign_algorithm: str
|
||||||
|
q_ak: str
|
||||||
|
q_key_time: str
|
||||||
|
q_signature: str
|
||||||
policy: str
|
policy: str
|
||||||
signature: str
|
|
||||||
host: str
|
host: str
|
||||||
dir: str
|
dir: str
|
||||||
expire: int
|
expire: int
|
||||||
@ -49,11 +53,12 @@ class FileUploadedResponse(BaseModel):
|
|||||||
@router.post("/policy", response_model=UploadPolicyResponse)
|
@router.post("/policy", response_model=UploadPolicyResponse)
|
||||||
async def get_upload_policy(
|
async def get_upload_policy(
|
||||||
request: UploadPolicyRequest,
|
request: UploadPolicyRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取 OSS 直传凭证
|
获取 COS 直传凭证
|
||||||
|
|
||||||
前端使用此凭证直接上传文件到阿里云 OSS,无需经过后端。
|
前端使用此凭证直接上传文件到腾讯云 COS,无需经过后端。
|
||||||
|
|
||||||
文件类型说明:
|
文件类型说明:
|
||||||
- script: 脚本文档 (docx, pdf, xlsx, txt, pptx)
|
- script: 脚本文档 (docx, pdf, xlsx, txt, pptx)
|
||||||
@ -87,9 +92,11 @@ async def get_upload_policy(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return UploadPolicyResponse(
|
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"],
|
policy=policy["policy"],
|
||||||
signature=policy["signature"],
|
|
||||||
host=policy["host"],
|
host=policy["host"],
|
||||||
dir=policy["dir"],
|
dir=policy["dir"],
|
||||||
expire=policy["expire"],
|
expire=policy["expire"],
|
||||||
@ -100,6 +107,7 @@ async def get_upload_policy(
|
|||||||
@router.post("/complete", response_model=FileUploadedResponse)
|
@router.post("/complete", response_model=FileUploadedResponse)
|
||||||
async def file_uploaded(
|
async def file_uploaded(
|
||||||
request: FileUploadedRequest,
|
request: FileUploadedRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
文件上传完成回调
|
文件上传完成回调
|
||||||
|
|||||||
@ -32,12 +32,12 @@ class Settings(BaseSettings):
|
|||||||
AI_API_KEY: str = "" # 中转服务商的 API Key
|
AI_API_KEY: str = "" # 中转服务商的 API Key
|
||||||
AI_API_BASE_URL: str = "" # 中转服务商的 Base URL,如 https://api.oneinall.ai/v1
|
AI_API_BASE_URL: str = "" # 中转服务商的 Base URL,如 https://api.oneinall.ai/v1
|
||||||
|
|
||||||
# 阿里云 OSS 配置
|
# 腾讯云 COS 配置
|
||||||
OSS_ACCESS_KEY_ID: str = ""
|
COS_SECRET_ID: str = ""
|
||||||
OSS_ACCESS_KEY_SECRET: str = ""
|
COS_SECRET_KEY: str = ""
|
||||||
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliyuncs.com"
|
COS_REGION: str = "ap-guangzhou"
|
||||||
OSS_BUCKET_NAME: str = "miaosi-files"
|
COS_BUCKET_NAME: str = "miaosi-files-1250000000"
|
||||||
OSS_BUCKET_DOMAIN: str = "" # 公开访问域名,如 https://miaosi-files.oss-cn-hangzhou.aliyuncs.com
|
COS_CDN_DOMAIN: str = "" # CDN 自定义域名,空则用 COS 源站
|
||||||
|
|
||||||
# 邮件 SMTP
|
# 邮件 SMTP
|
||||||
SMTP_HOST: str = ""
|
SMTP_HOST: str = ""
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
阿里云 OSS 服务
|
腾讯云 COS 服务 — 表单直传签名
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
import hmac
|
import hmac
|
||||||
@ -17,99 +17,109 @@ def generate_upload_policy(
|
|||||||
upload_dir: Optional[str] = None,
|
upload_dir: Optional[str] = None,
|
||||||
) -> dict:
|
) -> 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:
|
Returns:
|
||||||
{
|
{
|
||||||
"accessKeyId": "...",
|
"q_sign_algorithm": "sha1",
|
||||||
|
"q_ak": "SecretId",
|
||||||
|
"q_key_time": "{start};{end}",
|
||||||
|
"q_signature": "...",
|
||||||
"policy": "base64 encoded policy",
|
"policy": "base64 encoded policy",
|
||||||
"signature": "...",
|
"host": "https://bucket.cos.region.myqcloud.com",
|
||||||
"host": "https://bucket.oss-cn-hangzhou.aliyuncs.com",
|
|
||||||
"dir": "uploads/2026/02/",
|
"dir": "uploads/2026/02/",
|
||||||
"expire": 1234567890
|
"expire": 1234567890,
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
if not settings.OSS_ACCESS_KEY_ID or not settings.OSS_ACCESS_KEY_SECRET:
|
if not settings.COS_SECRET_ID or not settings.COS_SECRET_KEY:
|
||||||
raise ValueError("OSS 配置未设置")
|
raise ValueError("COS 配置未设置")
|
||||||
|
|
||||||
# 计算过期时间
|
# 计算时间范围
|
||||||
expire_time = int(time.time()) + expire_seconds
|
start_time = int(time.time())
|
||||||
expire_date = datetime.utcfromtimestamp(expire_time).strftime("%Y-%m-%dT%H:%M:%SZ")
|
end_time = start_time + expire_seconds
|
||||||
|
|
||||||
|
# key_time: "{start};{end}"
|
||||||
|
key_time = f"{start_time};{end_time}"
|
||||||
|
|
||||||
# 默认上传目录:uploads/年/月/
|
# 默认上传目录:uploads/年/月/
|
||||||
if upload_dir is None:
|
if upload_dir is None:
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
upload_dir = f"uploads/{now.year}/{now.month:02d}/"
|
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. 构建 Policy(COS 表单上传 Policy 格式)
|
||||||
policy_dict = {
|
policy_dict = {
|
||||||
"expiration": expire_date,
|
"expiration": datetime.utcfromtimestamp(end_time).strftime(
|
||||||
|
"%Y-%m-%dT%H:%M:%S.000Z"
|
||||||
|
),
|
||||||
"conditions": [
|
"conditions": [
|
||||||
{"bucket": settings.OSS_BUCKET_NAME},
|
{"bucket": settings.COS_BUCKET_NAME},
|
||||||
["starts-with", "$key", upload_dir],
|
["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],
|
["content-length-range", 0, max_size_mb * 1024 * 1024],
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Base64 编码 Policy
|
# 3. Base64 编码 Policy
|
||||||
policy_json = json.dumps(policy_dict)
|
policy_json = json.dumps(policy_dict)
|
||||||
policy_base64 = base64.b64encode(policy_json.encode()).decode()
|
policy_base64 = base64.b64encode(policy_json.encode()).decode()
|
||||||
|
|
||||||
# 计算签名
|
# 4. string_to_sign = SHA1(policy_base64)
|
||||||
signature = base64.b64encode(
|
string_to_sign = hashlib.sha1(policy_base64.encode()).hexdigest()
|
||||||
hmac.new(
|
|
||||||
settings.OSS_ACCESS_KEY_SECRET.encode(),
|
# 5. signature = HMAC-SHA1(sign_key, string_to_sign)
|
||||||
policy_base64.encode(),
|
signature = hmac.new(
|
||||||
hashlib.sha1
|
sign_key.encode(),
|
||||||
).digest()
|
string_to_sign.encode(),
|
||||||
).decode()
|
hashlib.sha1,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
# 构建 Host
|
# 构建 Host
|
||||||
host = settings.OSS_BUCKET_DOMAIN
|
host = f"https://{settings.COS_BUCKET_NAME}.cos.{settings.COS_REGION}.myqcloud.com"
|
||||||
if not host:
|
|
||||||
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
|
||||||
|
|
||||||
return {
|
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,
|
"policy": policy_base64,
|
||||||
"signature": signature,
|
|
||||||
"host": host,
|
"host": host,
|
||||||
"dir": upload_dir,
|
"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:
|
def get_file_url(file_key: str) -> str:
|
||||||
"""
|
"""
|
||||||
获取文件的公开访问 URL
|
获取文件的访问 URL
|
||||||
|
|
||||||
|
优先使用 CDN 域名,否则用 COS 源站域名。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_key: 文件在 OSS 中的 key,如 "uploads/2026/02/video.mp4"
|
file_key: 文件在 COS 中的 key,如 "uploads/2026/02/video.mp4"
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
完整的访问 URL
|
完整的访问 URL
|
||||||
"""
|
"""
|
||||||
host = settings.OSS_BUCKET_DOMAIN
|
if settings.COS_CDN_DOMAIN:
|
||||||
if not host:
|
host = settings.COS_CDN_DOMAIN
|
||||||
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
else:
|
||||||
|
host = f"https://{settings.COS_BUCKET_NAME}.cos.{settings.COS_REGION}.myqcloud.com"
|
||||||
|
|
||||||
# 确保 host 以 https:// 开头
|
# 确保 host 以 https:// 开头
|
||||||
if not host.startswith("http"):
|
if not host.startswith("http"):
|
||||||
@ -129,26 +139,22 @@ def parse_file_key_from_url(url: str) -> str:
|
|||||||
从完整 URL 解析出文件 key
|
从完整 URL 解析出文件 key
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: 完整的 OSS URL
|
url: 完整的 COS URL
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
文件 key
|
文件 key
|
||||||
"""
|
"""
|
||||||
host = settings.OSS_BUCKET_DOMAIN
|
# 尝试移除 CDN 域名
|
||||||
if not host:
|
if settings.COS_CDN_DOMAIN:
|
||||||
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
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 前缀
|
# 尝试移除 COS 源站域名
|
||||||
if url.startswith(host):
|
cos_host = f"https://{settings.COS_BUCKET_NAME}.cos.{settings.COS_REGION}.myqcloud.com"
|
||||||
return url[len(host):].lstrip("/")
|
if url.startswith(cos_host):
|
||||||
|
return url[len(cos_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]
|
|
||||||
|
|
||||||
return url
|
return url
|
||||||
|
|||||||
@ -6,13 +6,11 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: miaosi-postgres
|
container_name: miaosi-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
POSTGRES_DB: miaosi
|
POSTGRES_DB: ${POSTGRES_DB:-miaosi}
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@ -23,10 +21,8 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: miaosi-redis
|
container_name: miaosi-redis
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- ./data/redis:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@ -39,21 +35,18 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: miaosi-api
|
container_name: miaosi-api
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
environment:
|
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
|
REDIS_URL: redis://redis:6379/0
|
||||||
DEBUG: "true"
|
env_file:
|
||||||
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/app/app
|
|
||||||
- video_temp:/tmp/videos
|
- video_temp:/tmp/videos
|
||||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
|
||||||
|
|
||||||
# Celery Worker
|
# Celery Worker
|
||||||
celery-worker:
|
celery-worker:
|
||||||
@ -62,15 +55,16 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: miaosi-celery-worker
|
container_name: miaosi-celery-worker
|
||||||
environment:
|
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
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/app/app
|
|
||||||
- video_temp:/tmp/videos
|
- video_temp:/tmp/videos
|
||||||
command: celery -A app.celery_app worker -l info -Q default,review -c 2
|
command: celery -A app.celery_app worker -l info -Q default,review -c 2
|
||||||
|
|
||||||
@ -81,15 +75,42 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: miaosi-celery-beat
|
container_name: miaosi-celery-beat
|
||||||
environment:
|
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
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
- celery-worker
|
redis:
|
||||||
volumes:
|
condition: service_healthy
|
||||||
- ./app:/app/app
|
celery-worker: {}
|
||||||
command: celery -A app.celery_app beat -l info
|
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:
|
volumes:
|
||||||
postgres_data:
|
|
||||||
redis_data:
|
|
||||||
video_temp:
|
video_temp:
|
||||||
|
|||||||
50
backend/nginx/conf.d/default.conf
Normal file
50
backend/nginx/conf.d/default.conf
Normal 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
43
backend/nginx/nginx.conf
Normal 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
49
backend/scripts/backup.sh
Executable 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)] 备份完成"
|
||||||
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
# --- API 地址 ---
|
# --- API 地址 ---
|
||||||
# 后端 API 基础 URL(浏览器端访问)
|
# 后端 API 基础 URL(浏览器端访问)
|
||||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
NEXT_PUBLIC_API_BASE_URL=https://your-domain.com
|
||||||
|
|
||||||
# --- Mock 模式 ---
|
# --- Mock 模式 ---
|
||||||
# 设为 true 使用前端 mock 数据(development 环境下默认开启)
|
# 设为 true 使用前端 mock 数据(development 环境下默认开启)
|
||||||
|
|||||||
@ -57,17 +57,19 @@ export function useOSSUpload(fileType: string = 'general'): UseOSSUploadReturn {
|
|||||||
setProgress(10)
|
setProgress(10)
|
||||||
const policy = await api.getUploadPolicy(fileType)
|
const policy = await api.getUploadPolicy(fileType)
|
||||||
|
|
||||||
// 2. 构建 OSS 直传 FormData
|
// 2. 构建 COS 直传 FormData
|
||||||
const fileKey = `${policy.dir}${Date.now()}_${file.name}`
|
const fileKey = `${policy.dir}${Date.now()}_${file.name}`
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('key', fileKey)
|
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('policy', policy.policy)
|
||||||
formData.append('OSSAccessKeyId', policy.access_key_id)
|
|
||||||
formData.append('signature', policy.signature)
|
|
||||||
formData.append('success_action_status', '200')
|
formData.append('success_action_status', '200')
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|
||||||
// 3. 上传到 OSS
|
// 3. 上传到 COS
|
||||||
setProgress(30)
|
setProgress(30)
|
||||||
const xhr = new XMLHttpRequest()
|
const xhr = new XMLHttpRequest()
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user