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