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`
### 文件上传
- 腾讯云 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`

View File

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

View File

@ -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"],

View File

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

View File

@ -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. 构建 PolicyCOS 表单上传 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

View File

@ -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

View File

@ -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) => {

View File

@ -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', {