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:
Your Name 2026-02-10 11:02:15 +08:00
parent eba9ce8e60
commit 3a444864ac
8 changed files with 116 additions and 108 deletions

View File

@ -106,9 +106,9 @@ useEffect(() => { loadData() }, [loadData])
- 配置项:`AI_PROVIDER`, `AI_API_KEY`, `AI_API_BASE_URL` - 配置项:`AI_PROVIDER`, `AI_API_KEY`, `AI_API_BASE_URL`
### 文件上传 ### 文件上传
- 腾讯云 COS 直传,前端通过 `useOSSUpload` hook 处理 - 火山引擎 TOS 直传,前端通过 `useOSSUpload` hook 处理
- 流程:`api.getUploadPolicy()` → POST 到 COS → `api.fileUploaded()` 回调 - 流程:`api.getUploadPolicy()` → POST 到 TOS → `api.fileUploaded()` 回调
- COS 签名HMAC-SHA1字段包括 `q-sign-algorithm``q-ak``q-key-time``q-signature`、`policy` - 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` - SSE (Server-Sent Events),端点 `/api/v1/sse/events`

View File

@ -30,12 +30,13 @@ AI_PROVIDER=oneapi
AI_API_KEY= AI_API_KEY=
AI_API_BASE_URL= AI_API_BASE_URL=
# --- 腾讯云 COS --- # --- 火山引擎 TOS ---
COS_SECRET_ID= TOS_ACCESS_KEY_ID=
COS_SECRET_KEY= TOS_SECRET_ACCESS_KEY=
COS_REGION=ap-guangzhou TOS_REGION=cn-beijing
COS_BUCKET_NAME=miaosi-files-1250000000 TOS_BUCKET_NAME=miaosi-files
COS_CDN_DOMAIN= TOS_ENDPOINT=
TOS_CDN_DOMAIN=
# --- 邮件 SMTP --- # --- 邮件 SMTP ---
SMTP_HOST= SMTP_HOST=

View File

@ -21,11 +21,11 @@ class UploadPolicyRequest(BaseModel):
class UploadPolicyResponse(BaseModel): class UploadPolicyResponse(BaseModel):
"""COS 直传凭证响应""" """TOS 直传凭证响应"""
q_sign_algorithm: str x_tos_algorithm: str
q_ak: str x_tos_credential: str
q_key_time: str x_tos_date: str
q_signature: str x_tos_signature: str
policy: str policy: str
host: str host: str
dir: str dir: str
@ -56,9 +56,9 @@ async def get_upload_policy(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
获取 COS 直传凭证 获取 TOS 直传凭证
前端使用此凭证直接上传文件到腾讯云 COS无需经过后端 前端使用此凭证直接上传文件到火山引擎 TOS无需经过后端
文件类型说明 文件类型说明
- script: 脚本文档 (docx, pdf, xlsx, txt, pptx) - script: 脚本文档 (docx, pdf, xlsx, txt, pptx)
@ -92,10 +92,10 @@ async def get_upload_policy(
) )
return UploadPolicyResponse( return UploadPolicyResponse(
q_sign_algorithm=policy["q_sign_algorithm"], x_tos_algorithm=policy["x_tos_algorithm"],
q_ak=policy["q_ak"], x_tos_credential=policy["x_tos_credential"],
q_key_time=policy["q_key_time"], x_tos_date=policy["x_tos_date"],
q_signature=policy["q_signature"], x_tos_signature=policy["x_tos_signature"],
policy=policy["policy"], policy=policy["policy"],
host=policy["host"], host=policy["host"],
dir=policy["dir"], dir=policy["dir"],

View File

@ -32,12 +32,13 @@ 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
# 腾讯云 COS 配置 # 火山引擎 TOS 配置
COS_SECRET_ID: str = "" TOS_ACCESS_KEY_ID: str = ""
COS_SECRET_KEY: str = "" TOS_SECRET_ACCESS_KEY: str = ""
COS_REGION: str = "ap-guangzhou" TOS_REGION: str = "cn-beijing"
COS_BUCKET_NAME: str = "miaosi-files-1250000000" TOS_BUCKET_NAME: str = "miaosi-files"
COS_CDN_DOMAIN: str = "" # CDN 自定义域名,空则用 COS 源站 TOS_ENDPOINT: str = "" # 自定义 Endpoint空则用默认 tos-cn-{region}.volces.com
TOS_CDN_DOMAIN: str = "" # CDN 自定义域名,空则用 TOS 源站
# 邮件 SMTP # 邮件 SMTP
SMTP_HOST: str = "" SMTP_HOST: str = ""

View File

@ -1,5 +1,5 @@
""" """
腾讯云 COS 服务 表单直传签名 火山引擎 TOS (Volcengine Object Storage) 服务 表单直传签名 (V4)
""" """
import time import time
import hmac import hmac
@ -7,7 +7,7 @@ import base64
import hashlib import hashlib
import json import json
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime, timezone
from app.config import settings from app.config import settings
@ -17,90 +17,94 @@ def generate_upload_policy(
upload_dir: Optional[str] = None, upload_dir: Optional[str] = None,
) -> dict: ) -> dict:
""" """
生成前端直传 COS 所需的 Policy 和签名 生成前端直传 TOS 所需的 Policy 和签名 (V4 HMAC-SHA256)
COS 表单直传签名流程: TOS 表单直传签名流程 (PostObject):
1. key_time = "{start_time};{end_time}" 1. 构建 policy JSON Base64 编码
2. sign_key = HMAC-SHA1(secret_key, key_time) 2. 派生签名密钥: kDate kRegion kService kSigning
3. policy JSON Base64 编码 3. signature = HMAC-SHA256(kSigning, policy_base64)
4. string_to_sign = SHA1(policy_base64)
5. signature = HMAC-SHA1(sign_key, string_to_sign)
Returns: Returns:
{ {
"q_sign_algorithm": "sha1", "x_tos_algorithm": "TOS4-HMAC-SHA256",
"q_ak": "SecretId", "x_tos_credential": "AKIDxxxx/20260210/cn-beijing/tos/request",
"q_key_time": "{start};{end}", "x_tos_date": "20260210T120000Z",
"q_signature": "...", "x_tos_signature": "...",
"policy": "base64 encoded policy", "policy": "base64 encoded policy",
"host": "https://bucket.cos.region.myqcloud.com", "host": "https://bucket.tos-cn-beijing.volces.com",
"dir": "uploads/2026/02/", "dir": "uploads/2026/02/",
"expire": 1234567890, "expire": 1234567890,
} }
""" """
if not settings.COS_SECRET_ID or not settings.COS_SECRET_KEY: if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
raise ValueError("COS 配置未设置") raise ValueError("TOS 配置未设置")
# 计算时间范围 # 计算时间
start_time = int(time.time()) now_utc = datetime.now(timezone.utc)
end_time = start_time + expire_seconds 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}" # Credential scope
key_time = f"{start_time};{end_time}" region = settings.TOS_REGION
credential = f"{settings.TOS_ACCESS_KEY_ID}/{date_stamp}/{region}/tos/request"
# 默认上传目录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}/"
# 1. sign_key = HMAC-SHA1(secret_key, key_time) # 1. 构建 Policy
sign_key = hmac.new(
settings.COS_SECRET_KEY.encode(),
key_time.encode(),
hashlib.sha1,
).hexdigest()
# 2. 构建 PolicyCOS 表单上传 Policy 格式)
policy_dict = { policy_dict = {
"expiration": datetime.utcfromtimestamp(end_time).strftime( "expiration": expiration,
"%Y-%m-%dT%H:%M:%S.000Z"
),
"conditions": [ "conditions": [
{"bucket": settings.COS_BUCKET_NAME}, {"bucket": settings.TOS_BUCKET_NAME},
["starts-with", "$key", upload_dir], ["starts-with", "$key", upload_dir],
{"q-sign-algorithm": "sha1"}, {"x-tos-algorithm": "TOS4-HMAC-SHA256"},
{"q-ak": settings.COS_SECRET_ID}, {"x-tos-credential": credential},
{"q-sign-time": key_time}, {"x-tos-date": tos_date},
["content-length-range", 0, max_size_mb * 1024 * 1024], ["content-length-range", 0, max_size_mb * 1024 * 1024],
], ],
} }
# 3. Base64 编码 Policy # 2. 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) # 3. 派生签名密钥 (V4 Signing Key)
string_to_sign = hashlib.sha1(policy_base64.encode()).hexdigest() 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( signature = hmac.new(
sign_key.encode(), k_signing,
string_to_sign.encode(), policy_base64.encode(),
hashlib.sha1, hashlib.sha256,
).hexdigest() ).hexdigest()
# 构建 Host # 构建 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 { return {
"q_sign_algorithm": "sha1", "x_tos_algorithm": "TOS4-HMAC-SHA256",
"q_ak": settings.COS_SECRET_ID, "x_tos_credential": credential,
"q_key_time": key_time, "x_tos_date": tos_date,
"q_signature": signature, "x_tos_signature": signature,
"policy": policy_base64, "policy": policy_base64,
"host": host, "host": host,
"dir": upload_dir, "dir": upload_dir,
"expire": end_time, "expire": expire_time,
} }
@ -108,18 +112,19 @@ def get_file_url(file_key: str) -> str:
""" """
获取文件的访问 URL 获取文件的访问 URL
优先使用 CDN 域名否则用 COS 源站域名 优先使用 CDN 域名否则用 TOS 源站域名
Args: Args:
file_key: 文件在 COS 中的 key "uploads/2026/02/video.mp4" file_key: 文件在 TOS 中的 key "uploads/2026/02/video.mp4"
Returns: Returns:
完整的访问 URL 完整的访问 URL
""" """
if settings.COS_CDN_DOMAIN: if settings.TOS_CDN_DOMAIN:
host = settings.COS_CDN_DOMAIN host = settings.TOS_CDN_DOMAIN
else: 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:// 开头 # 确保 host 以 https:// 开头
if not host.startswith("http"): if not host.startswith("http"):
@ -139,22 +144,23 @@ def parse_file_key_from_url(url: str) -> str:
从完整 URL 解析出文件 key 从完整 URL 解析出文件 key
Args: Args:
url: 完整的 COS URL url: 完整的 TOS URL
Returns: Returns:
文件 key 文件 key
""" """
# 尝试移除 CDN 域名 # 尝试移除 CDN 域名
if settings.COS_CDN_DOMAIN: if settings.TOS_CDN_DOMAIN:
cdn = settings.COS_CDN_DOMAIN.rstrip("/") cdn = settings.TOS_CDN_DOMAIN.rstrip("/")
if not cdn.startswith("http"): if not cdn.startswith("http"):
cdn = f"https://{cdn}" cdn = f"https://{cdn}"
if url.startswith(cdn): if url.startswith(cdn):
return url[len(cdn):].lstrip("/") return url[len(cdn):].lstrip("/")
# 尝试移除 COS 源站域名 # 尝试移除 TOS 源站域名
cos_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"
if url.startswith(cos_host): tos_host = f"https://{settings.TOS_BUCKET_NAME}.{endpoint}"
return url[len(cos_host):].lstrip("/") if url.startswith(tos_host):
return url[len(tos_host):].lstrip("/")
return url return url

View File

@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# =========================== # ===========================
# PostgreSQL 每日备份脚本 # PostgreSQL 每日备份脚本
# 备份到本地 + 上传到腾讯云 COS # 备份到本地 + 上传到火山引擎 TOS
# =========================== # ===========================
# 配合 crontab 使用: # 配合 crontab 使用:
# 0 3 * * * /path/to/backup.sh >> /var/log/miaosi-backup.log 2>&1 # 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}" POSTGRES_DB="${POSTGRES_DB:-miaosi}"
RETAIN_DAYS="${RETAIN_DAYS:-7}" RETAIN_DAYS="${RETAIN_DAYS:-7}"
# COS 备份桶(需要先安装 coscli 并配置好凭证) # TOS 备份桶(需要先安装 tosutil 并配置好凭证)
COS_BACKUP_BUCKET="${COS_BACKUP_BUCKET:-}" TOS_BACKUP_BUCKET="${TOS_BACKUP_BUCKET:-}"
COS_BACKUP_PREFIX="${COS_BACKUP_PREFIX:-backups/postgres}" TOS_BACKUP_PREFIX="${TOS_BACKUP_PREFIX:-backups/postgres}"
# ---- 执行 ---- # ---- 执行 ----
DATE=$(date +%Y%m%d_%H%M%S) 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}" echo "[$(date)] 本地备份完成: ${BACKUP_DIR}/${FILENAME}"
# 2. 上传到 COS如果配置了备份桶 # 2. 上传到 TOS如果配置了备份桶
if [ -n "$COS_BACKUP_BUCKET" ]; then if [ -n "$TOS_BACKUP_BUCKET" ]; then
if command -v coscli &> /dev/null; then if command -v tosutil &> /dev/null; then
coscli cp "${BACKUP_DIR}/${FILENAME}" "cos://${COS_BACKUP_BUCKET}/${COS_BACKUP_PREFIX}/${FILENAME}" tosutil cp "${BACKUP_DIR}/${FILENAME}" "tos://${TOS_BACKUP_BUCKET}/${TOS_BACKUP_PREFIX}/${FILENAME}"
echo "[$(date)] 已上传到 COS: ${COS_BACKUP_BUCKET}/${COS_BACKUP_PREFIX}/${FILENAME}" echo "[$(date)] 已上传到 TOS: ${TOS_BACKUP_BUCKET}/${TOS_BACKUP_PREFIX}/${FILENAME}"
else else
echo "[$(date)] 警告: coscli 未安装,跳过 COS 上传" echo "[$(date)] 警告: tosutil 未安装,跳过 TOS 上传"
fi fi
fi fi

View File

@ -57,19 +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. 构建 COS 直传 FormData // 2. 构建 TOS 直传 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('x-tos-algorithm', policy.x_tos_algorithm)
formData.append('q-ak', policy.q_ak) formData.append('x-tos-credential', policy.x_tos_credential)
formData.append('q-key-time', policy.q_key_time) formData.append('x-tos-date', policy.x_tos_date)
formData.append('q-signature', policy.q_signature) formData.append('x-tos-signature', policy.x_tos_signature)
formData.append('policy', policy.policy) formData.append('policy', policy.policy)
formData.append('success_action_status', '200') formData.append('success_action_status', '200')
formData.append('file', file) formData.append('file', file)
// 3. 上传到 COS // 3. 上传到 TOS
setProgress(30) setProgress(30)
const xhr = new XMLHttpRequest() const xhr = new XMLHttpRequest()
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {

View File

@ -136,10 +136,10 @@ export interface RefreshTokenResponse {
} }
export interface UploadPolicyResponse { export interface UploadPolicyResponse {
q_sign_algorithm: string x_tos_algorithm: string
q_ak: string x_tos_credential: string
q_key_time: string x_tos_date: string
q_signature: string x_tos_signature: string
policy: string policy: string
host: string host: string
dir: string dir: string
@ -426,7 +426,7 @@ class ApiClient {
// ==================== 文件上传 ==================== // ==================== 文件上传 ====================
/** /**
* COS * TOS
*/ */
async getUploadPolicy(fileType: string = 'general'): Promise<UploadPolicyResponse> { async getUploadPolicy(fileType: string = 'general'): Promise<UploadPolicyResponse> {
const response = await this.client.post<UploadPolicyResponse>('/upload/policy', { const response = await this.client.post<UploadPolicyResponse>('/upload/policy', {