diff --git a/CLAUDE.md b/CLAUDE.md index 17e2270..030d02f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/backend/.env.example b/backend/.env.example index 534800f..eca87ac 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index 661c3ae..5309ad9 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -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"], diff --git a/backend/app/config.py b/backend/app/config.py index dbbe4c8..01c53c0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 = "" diff --git a/backend/app/services/oss.py b/backend/app/services/oss.py index 1eba5a3..a592631 100644 --- a/backend/app/services/oss.py +++ b/backend/app/services/oss.py @@ -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 diff --git a/backend/scripts/backup.sh b/backend/scripts/backup.sh index e53e848..6a9f245 100755 --- a/backend/scripts/backup.sh +++ b/backend/scripts/backup.sh @@ -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 diff --git a/frontend/hooks/useOSSUpload.ts b/frontend/hooks/useOSSUpload.ts index 98ae0e7..fd7073e 100644 --- a/frontend/hooks/useOSSUpload.ts +++ b/frontend/hooks/useOSSUpload.ts @@ -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((resolve, reject) => { diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 8ff7e04..e469768 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -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 { const response = await this.client.post('/upload/policy', {