feat: 腾讯云 COS 迁移至火山引擎 TOS 对象存储
签名算法从 COS HMAC-SHA1 改为 TOS V4 HMAC-SHA256, 更新前后端上传凭证字段、配置项、备份脚本和文档。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
eba9ce8e60
commit
3a444864ac
@ -106,9 +106,9 @@ useEffect(() => { loadData() }, [loadData])
|
||||
- 配置项:`AI_PROVIDER`, `AI_API_KEY`, `AI_API_BASE_URL`
|
||||
|
||||
### 文件上传
|
||||
- 腾讯云 COS 直传,前端通过 `useOSSUpload` hook 处理
|
||||
- 流程:`api.getUploadPolicy()` → POST 到 COS → `api.fileUploaded()` 回调
|
||||
- COS 签名:HMAC-SHA1,字段包括 `q-sign-algorithm`、`q-ak`、`q-key-time`、`q-signature`、`policy`
|
||||
- 火山引擎 TOS 直传,前端通过 `useOSSUpload` hook 处理
|
||||
- 流程:`api.getUploadPolicy()` → POST 到 TOS → `api.fileUploaded()` 回调
|
||||
- TOS V4 签名:HMAC-SHA256,字段包括 `x-tos-algorithm`、`x-tos-credential`、`x-tos-date`、`x-tos-signature`、`policy`
|
||||
|
||||
### 实时推送
|
||||
- SSE (Server-Sent Events),端点 `/api/v1/sse/events`
|
||||
|
||||
@ -30,12 +30,13 @@ AI_PROVIDER=oneapi
|
||||
AI_API_KEY=
|
||||
AI_API_BASE_URL=
|
||||
|
||||
# --- 腾讯云 COS ---
|
||||
COS_SECRET_ID=
|
||||
COS_SECRET_KEY=
|
||||
COS_REGION=ap-guangzhou
|
||||
COS_BUCKET_NAME=miaosi-files-1250000000
|
||||
COS_CDN_DOMAIN=
|
||||
# --- 火山引擎 TOS ---
|
||||
TOS_ACCESS_KEY_ID=
|
||||
TOS_SECRET_ACCESS_KEY=
|
||||
TOS_REGION=cn-beijing
|
||||
TOS_BUCKET_NAME=miaosi-files
|
||||
TOS_ENDPOINT=
|
||||
TOS_CDN_DOMAIN=
|
||||
|
||||
# --- 邮件 SMTP ---
|
||||
SMTP_HOST=
|
||||
|
||||
@ -21,11 +21,11 @@ class UploadPolicyRequest(BaseModel):
|
||||
|
||||
|
||||
class UploadPolicyResponse(BaseModel):
|
||||
"""COS 直传凭证响应"""
|
||||
q_sign_algorithm: str
|
||||
q_ak: str
|
||||
q_key_time: str
|
||||
q_signature: str
|
||||
"""TOS 直传凭证响应"""
|
||||
x_tos_algorithm: str
|
||||
x_tos_credential: str
|
||||
x_tos_date: str
|
||||
x_tos_signature: str
|
||||
policy: str
|
||||
host: str
|
||||
dir: str
|
||||
@ -56,9 +56,9 @@ async def get_upload_policy(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取 COS 直传凭证
|
||||
获取 TOS 直传凭证
|
||||
|
||||
前端使用此凭证直接上传文件到腾讯云 COS,无需经过后端。
|
||||
前端使用此凭证直接上传文件到火山引擎 TOS,无需经过后端。
|
||||
|
||||
文件类型说明:
|
||||
- script: 脚本文档 (docx, pdf, xlsx, txt, pptx)
|
||||
@ -92,10 +92,10 @@ async def get_upload_policy(
|
||||
)
|
||||
|
||||
return UploadPolicyResponse(
|
||||
q_sign_algorithm=policy["q_sign_algorithm"],
|
||||
q_ak=policy["q_ak"],
|
||||
q_key_time=policy["q_key_time"],
|
||||
q_signature=policy["q_signature"],
|
||||
x_tos_algorithm=policy["x_tos_algorithm"],
|
||||
x_tos_credential=policy["x_tos_credential"],
|
||||
x_tos_date=policy["x_tos_date"],
|
||||
x_tos_signature=policy["x_tos_signature"],
|
||||
policy=policy["policy"],
|
||||
host=policy["host"],
|
||||
dir=policy["dir"],
|
||||
|
||||
@ -32,12 +32,13 @@ class Settings(BaseSettings):
|
||||
AI_API_KEY: str = "" # 中转服务商的 API Key
|
||||
AI_API_BASE_URL: str = "" # 中转服务商的 Base URL,如 https://api.oneinall.ai/v1
|
||||
|
||||
# 腾讯云 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 源站
|
||||
# 火山引擎 TOS 配置
|
||||
TOS_ACCESS_KEY_ID: str = ""
|
||||
TOS_SECRET_ACCESS_KEY: str = ""
|
||||
TOS_REGION: str = "cn-beijing"
|
||||
TOS_BUCKET_NAME: str = "miaosi-files"
|
||||
TOS_ENDPOINT: str = "" # 自定义 Endpoint,空则用默认 tos-cn-{region}.volces.com
|
||||
TOS_CDN_DOMAIN: str = "" # CDN 自定义域名,空则用 TOS 源站
|
||||
|
||||
# 邮件 SMTP
|
||||
SMTP_HOST: str = ""
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"""
|
||||
腾讯云 COS 服务 — 表单直传签名
|
||||
火山引擎 TOS (Volcengine Object Storage) 服务 — 表单直传签名 (V4)
|
||||
"""
|
||||
import time
|
||||
import hmac
|
||||
@ -7,7 +7,7 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@ -17,90 +17,94 @@ def generate_upload_policy(
|
||||
upload_dir: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
生成前端直传 COS 所需的 Policy 和签名
|
||||
生成前端直传 TOS 所需的 Policy 和签名 (V4 HMAC-SHA256)
|
||||
|
||||
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)
|
||||
TOS 表单直传签名流程 (PostObject):
|
||||
1. 构建 policy JSON → Base64 编码
|
||||
2. 派生签名密钥: kDate → kRegion → kService → kSigning
|
||||
3. signature = HMAC-SHA256(kSigning, policy_base64)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"q_sign_algorithm": "sha1",
|
||||
"q_ak": "SecretId",
|
||||
"q_key_time": "{start};{end}",
|
||||
"q_signature": "...",
|
||||
"x_tos_algorithm": "TOS4-HMAC-SHA256",
|
||||
"x_tos_credential": "AKIDxxxx/20260210/cn-beijing/tos/request",
|
||||
"x_tos_date": "20260210T120000Z",
|
||||
"x_tos_signature": "...",
|
||||
"policy": "base64 encoded policy",
|
||||
"host": "https://bucket.cos.region.myqcloud.com",
|
||||
"host": "https://bucket.tos-cn-beijing.volces.com",
|
||||
"dir": "uploads/2026/02/",
|
||||
"expire": 1234567890,
|
||||
}
|
||||
"""
|
||||
if not settings.COS_SECRET_ID or not settings.COS_SECRET_KEY:
|
||||
raise ValueError("COS 配置未设置")
|
||||
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
|
||||
raise ValueError("TOS 配置未设置")
|
||||
|
||||
# 计算时间范围
|
||||
start_time = int(time.time())
|
||||
end_time = start_time + expire_seconds
|
||||
# 计算时间
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
date_stamp = now_utc.strftime("%Y%m%d") # 20260210
|
||||
tos_date = now_utc.strftime("%Y%m%dT%H%M%SZ") # 20260210T120000Z
|
||||
expire_time = int(time.time()) + expire_seconds
|
||||
expiration = datetime.fromtimestamp(expire_time, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.000Z"
|
||||
)
|
||||
|
||||
# key_time: "{start};{end}"
|
||||
key_time = f"{start_time};{end_time}"
|
||||
# Credential scope
|
||||
region = settings.TOS_REGION
|
||||
credential = f"{settings.TOS_ACCESS_KEY_ID}/{date_stamp}/{region}/tos/request"
|
||||
|
||||
# 默认上传目录:uploads/年/月/
|
||||
if upload_dir is None:
|
||||
now = datetime.now()
|
||||
upload_dir = f"uploads/{now.year}/{now.month:02d}/"
|
||||
|
||||
# 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 格式)
|
||||
# 1. 构建 Policy
|
||||
policy_dict = {
|
||||
"expiration": datetime.utcfromtimestamp(end_time).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.000Z"
|
||||
),
|
||||
"expiration": expiration,
|
||||
"conditions": [
|
||||
{"bucket": settings.COS_BUCKET_NAME},
|
||||
{"bucket": settings.TOS_BUCKET_NAME},
|
||||
["starts-with", "$key", upload_dir],
|
||||
{"q-sign-algorithm": "sha1"},
|
||||
{"q-ak": settings.COS_SECRET_ID},
|
||||
{"q-sign-time": key_time},
|
||||
{"x-tos-algorithm": "TOS4-HMAC-SHA256"},
|
||||
{"x-tos-credential": credential},
|
||||
{"x-tos-date": tos_date},
|
||||
["content-length-range", 0, max_size_mb * 1024 * 1024],
|
||||
],
|
||||
}
|
||||
|
||||
# 3. Base64 编码 Policy
|
||||
# 2. Base64 编码 Policy
|
||||
policy_json = json.dumps(policy_dict)
|
||||
policy_base64 = base64.b64encode(policy_json.encode()).decode()
|
||||
|
||||
# 4. string_to_sign = SHA1(policy_base64)
|
||||
string_to_sign = hashlib.sha1(policy_base64.encode()).hexdigest()
|
||||
# 3. 派生签名密钥 (V4 Signing Key)
|
||||
k_date = hmac.new(
|
||||
f"TOS4{settings.TOS_SECRET_ACCESS_KEY}".encode(),
|
||||
date_stamp.encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
# 5. signature = HMAC-SHA1(sign_key, string_to_sign)
|
||||
k_region = hmac.new(k_date, region.encode(), hashlib.sha256).digest()
|
||||
k_service = hmac.new(k_region, b"tos", hashlib.sha256).digest()
|
||||
k_signing = hmac.new(k_service, b"request", hashlib.sha256).digest()
|
||||
|
||||
# 4. signature = HMAC-SHA256(kSigning, policy_base64)
|
||||
signature = hmac.new(
|
||||
sign_key.encode(),
|
||||
string_to_sign.encode(),
|
||||
hashlib.sha1,
|
||||
k_signing,
|
||||
policy_base64.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
# 构建 Host
|
||||
host = f"https://{settings.COS_BUCKET_NAME}.cos.{settings.COS_REGION}.myqcloud.com"
|
||||
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
|
||||
host = f"https://{settings.TOS_BUCKET_NAME}.{endpoint}"
|
||||
|
||||
return {
|
||||
"q_sign_algorithm": "sha1",
|
||||
"q_ak": settings.COS_SECRET_ID,
|
||||
"q_key_time": key_time,
|
||||
"q_signature": signature,
|
||||
"x_tos_algorithm": "TOS4-HMAC-SHA256",
|
||||
"x_tos_credential": credential,
|
||||
"x_tos_date": tos_date,
|
||||
"x_tos_signature": signature,
|
||||
"policy": policy_base64,
|
||||
"host": host,
|
||||
"dir": upload_dir,
|
||||
"expire": end_time,
|
||||
"expire": expire_time,
|
||||
}
|
||||
|
||||
|
||||
@ -108,18 +112,19 @@ def get_file_url(file_key: str) -> str:
|
||||
"""
|
||||
获取文件的访问 URL
|
||||
|
||||
优先使用 CDN 域名,否则用 COS 源站域名。
|
||||
优先使用 CDN 域名,否则用 TOS 源站域名。
|
||||
|
||||
Args:
|
||||
file_key: 文件在 COS 中的 key,如 "uploads/2026/02/video.mp4"
|
||||
file_key: 文件在 TOS 中的 key,如 "uploads/2026/02/video.mp4"
|
||||
|
||||
Returns:
|
||||
完整的访问 URL
|
||||
"""
|
||||
if settings.COS_CDN_DOMAIN:
|
||||
host = settings.COS_CDN_DOMAIN
|
||||
if settings.TOS_CDN_DOMAIN:
|
||||
host = settings.TOS_CDN_DOMAIN
|
||||
else:
|
||||
host = f"https://{settings.COS_BUCKET_NAME}.cos.{settings.COS_REGION}.myqcloud.com"
|
||||
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{settings.TOS_REGION}.volces.com"
|
||||
host = f"https://{settings.TOS_BUCKET_NAME}.{endpoint}"
|
||||
|
||||
# 确保 host 以 https:// 开头
|
||||
if not host.startswith("http"):
|
||||
@ -139,22 +144,23 @@ def parse_file_key_from_url(url: str) -> str:
|
||||
从完整 URL 解析出文件 key
|
||||
|
||||
Args:
|
||||
url: 完整的 COS URL
|
||||
url: 完整的 TOS URL
|
||||
|
||||
Returns:
|
||||
文件 key
|
||||
"""
|
||||
# 尝试移除 CDN 域名
|
||||
if settings.COS_CDN_DOMAIN:
|
||||
cdn = settings.COS_CDN_DOMAIN.rstrip("/")
|
||||
if settings.TOS_CDN_DOMAIN:
|
||||
cdn = settings.TOS_CDN_DOMAIN.rstrip("/")
|
||||
if not cdn.startswith("http"):
|
||||
cdn = f"https://{cdn}"
|
||||
if url.startswith(cdn):
|
||||
return url[len(cdn):].lstrip("/")
|
||||
|
||||
# 尝试移除 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("/")
|
||||
# 尝试移除 TOS 源站域名
|
||||
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{settings.TOS_REGION}.volces.com"
|
||||
tos_host = f"https://{settings.TOS_BUCKET_NAME}.{endpoint}"
|
||||
if url.startswith(tos_host):
|
||||
return url[len(tos_host):].lstrip("/")
|
||||
|
||||
return url
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
# ===========================
|
||||
# PostgreSQL 每日备份脚本
|
||||
# 备份到本地 + 上传到腾讯云 COS
|
||||
# 备份到本地 + 上传到火山引擎 TOS
|
||||
# ===========================
|
||||
# 配合 crontab 使用:
|
||||
# 0 3 * * * /path/to/backup.sh >> /var/log/miaosi-backup.log 2>&1
|
||||
@ -15,9 +15,9 @@ 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}"
|
||||
# TOS 备份桶(需要先安装 tosutil 并配置好凭证)
|
||||
TOS_BACKUP_BUCKET="${TOS_BACKUP_BUCKET:-}"
|
||||
TOS_BACKUP_PREFIX="${TOS_BACKUP_PREFIX:-backups/postgres}"
|
||||
|
||||
# ---- 执行 ----
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
@ -32,13 +32,13 @@ docker exec "$POSTGRES_CONTAINER" pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" | g
|
||||
|
||||
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}"
|
||||
# 2. 上传到 TOS(如果配置了备份桶)
|
||||
if [ -n "$TOS_BACKUP_BUCKET" ]; then
|
||||
if command -v tosutil &> /dev/null; then
|
||||
tosutil cp "${BACKUP_DIR}/${FILENAME}" "tos://${TOS_BACKUP_BUCKET}/${TOS_BACKUP_PREFIX}/${FILENAME}"
|
||||
echo "[$(date)] 已上传到 TOS: ${TOS_BACKUP_BUCKET}/${TOS_BACKUP_PREFIX}/${FILENAME}"
|
||||
else
|
||||
echo "[$(date)] 警告: coscli 未安装,跳过 COS 上传"
|
||||
echo "[$(date)] 警告: tosutil 未安装,跳过 TOS 上传"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@ -57,19 +57,19 @@ export function useOSSUpload(fileType: string = 'general'): UseOSSUploadReturn {
|
||||
setProgress(10)
|
||||
const policy = await api.getUploadPolicy(fileType)
|
||||
|
||||
// 2. 构建 COS 直传 FormData
|
||||
// 2. 构建 TOS 直传 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('x-tos-algorithm', policy.x_tos_algorithm)
|
||||
formData.append('x-tos-credential', policy.x_tos_credential)
|
||||
formData.append('x-tos-date', policy.x_tos_date)
|
||||
formData.append('x-tos-signature', policy.x_tos_signature)
|
||||
formData.append('policy', policy.policy)
|
||||
formData.append('success_action_status', '200')
|
||||
formData.append('file', file)
|
||||
|
||||
// 3. 上传到 COS
|
||||
// 3. 上传到 TOS
|
||||
setProgress(30)
|
||||
const xhr = new XMLHttpRequest()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
@ -136,10 +136,10 @@ export interface RefreshTokenResponse {
|
||||
}
|
||||
|
||||
export interface UploadPolicyResponse {
|
||||
q_sign_algorithm: string
|
||||
q_ak: string
|
||||
q_key_time: string
|
||||
q_signature: string
|
||||
x_tos_algorithm: string
|
||||
x_tos_credential: string
|
||||
x_tos_date: string
|
||||
x_tos_signature: string
|
||||
policy: string
|
||||
host: string
|
||||
dir: string
|
||||
@ -426,7 +426,7 @@ class ApiClient {
|
||||
// ==================== 文件上传 ====================
|
||||
|
||||
/**
|
||||
* 获取 COS 上传凭证
|
||||
* 获取 TOS 上传凭证
|
||||
*/
|
||||
async getUploadPolicy(fileType: string = 'general'): Promise<UploadPolicyResponse> {
|
||||
const response = await this.client.post<UploadPolicyResponse>('/upload/policy', {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user