Files
cs-note/hzh/DEV/OSS.md
T
2026-05-24 11:42:38 +08:00

24 KiB
Raw Blame History

tags, create time
tags create time
后端
Go
对象存储
S3
文件上传
2026-05-07 19:12

OSS 对象存储

概述

OSS(Object Storage Service)即对象存储,是一种将非结构化数据作为对象存储在云端的方案。阿里云的 OSS、AWS 的 S3 以及兼容 S3 协议的 MinIO 本质上是同一种技术——通过 HTTP API 对海量二进制数据进行增删改查。理解其核心概念和工作模式,是构建现代 Web 应用基础设施的关键一环。

思考题:和传统的关系型数据库相比,为什么图片、视频、日志文件这类数据不适合存在 MySQL 里?

正文

1. 核心概念

对象存储有三个基本概念:

概念 说明 类比关系型数据库
Bucket(存储桶) 对象的容器,类似"数据库" Database / Table
Object(对象) 实际存储的数据,包含 Key + Body + Metadata,类似"行记录" Row
Key(键) 对象的全局唯一标识,类似文件系统路径 File Path

关键点:对象存储是一个扁平结构——没有真正的目录层级,所谓的 folder/ 只是 Key 中的一部分字符(如 photos/2026/vacation.jpg)。但管理面板会按 / 做可视化分组。

flowchart LR
    subgraph OSS_BUCKET["Bucket: my-app-data"]
        direction TB
        O1["Key: uploads/avatar/001.png<br/>Body: (PNG binary data)"]
        O2["Key: docs/report.pdf<br/>Body: (PDF binary data)"]
        O3["Key: backups/db-dump.sql.gz<br/>Body: (SQL compressed)"]
    end

    classDef bucketStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
    class OSS_BUCKET bucketStyle

Region 和 Endpoint:

每个 Bucket 必须属于一个地域(Region),Region 决定了数据的物理存储位置。访问时需要使用对应的 Endpoint:

地域 内网 Endpoint 外网 Endpoint
杭州 (oss-cn-hangzhou) oss-cn-hangzhou-internal.aliyuncs.com oss-cn-hangzhou.aliyuncs.com
上海 (oss-cn-shanghai) oss-cn-shanghai-internal.aliyuncs.com oss-cn-shanghai.aliyuncs.com
北京 (oss-cn-beijing) oss-cn-beijing-internal.aliyuncs.com oss-cn-beijing.aliyuncs.com

最佳实践:当你的 Go 服务和 OSS Bucket 在同一 Region 时,务必使用内网 Endpoint。内网流量免费且速度比外网快 5~10 倍。

2. Go SDK 选型

目前 Go 生态中有两套主流 OSS/S3 SDK:

graph TD
    A["Go 对象存储 SDK"] --> B["Aliyun OSS SDK<br/>alibabacloud-oss-go-sdk-v2"]
    A --> C["Minio Client SDK<br/>minio/go-minio"]
    A --> D["AWS SDK for Go v2<br/>aws/aws-sdk-go-v2/s3"]

    B --> B1["适用于阿里云 OSS"]
    B --> B2["API 完全覆盖 OSS 功能"]
    B --> B3["绑定阿里云鉴权体系"]

    C --> C1["兼容 S3/OSS/COS/GCS 等"]
    C --> C2["API 接近标准库 io.Reader"]
    C --> C3["推荐用于多云/混合云场景"]

    D --> D1["AWS 官方 SDK"]
    D --> D2["功能最全面"]
    D --> D3["体积大, 编译慢"]

    style C1 fill:#e8f5e9,stroke:#2e7d32
    style B1 fill:#fff3e0,stroke:#f57c00
    style D1 fill:#e3f2fd,stroke:#1565c0
对比项 Aliyun OSS SDK Minio Client AWS S3 SDK v2
适配服务 仅阿里云 OSS S3/OSS/COS/GCS等 AWS S3 / Cloudflare R2
安装大小 ~5MB ~2MB ~150MB
API 风格 RESTful 包装 io.Reader 流式 深度异步链式
预签名 URL ✅ 原生支持 ✅ PresignedGetObject ✅ PresignObjectAPI
推荐度 只用阿里云 多云通用首选 AWS 重度用户

结论:如果你只做阿里云,用官方 SDK;如果需要同时对接 S3、MinIO 或其他兼容 S3 的服务,选择 Minio Client SDK。

3. 初始化客户端

方式一:阿里云官方 SDK(v2)

import (
    oss "github.com/alibabacloud-go/oss-20190517/v2/client"
)

// 从环境变量读取凭证,避免硬编码
client := oss.NewClient(&oss.Config{
    Endpoint:        oss.String("oss-cn-hangzhou.aliyuncs.com"),
    AccessKeyId:     oss.String(os.Getenv("OSS_ACCESS_KEY_ID")),
    AccessKeySecret: oss.String(os.Getenv("OSS_ACCESS_KEY_SECRET")),
})

方式二:Minio Client(更简洁,跨云兼容)

import "github.com/minio/minio-go/v7"

client, err := minio.New("oss-cn-hangzhou.aliyuncs.com", &minio.Options{
    Creds:  credentials.NewStaticV4(accessKey, secretKey, ""),
    Secure: true, // HTTPS
})
if err != nil {
    log.Fatal(err)
}

安全提醒:永远不要将 AccessKey 写在代码里。生产环境应使用环境变量、密钥管理服务(KMS / Vault / 云厂商 IAM Role)注入。

4. Bucket 操作

创建与查询

// 创建 Bucket(Minio 写法)
bucketName := "my-app-uploads"
location := "cn-hangzhou"

err := client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{
    Region: location,
})
// 如果已存在则 ErrBucketExists 忽略即可

// 检查 Bucket 是否存在
exists, _ := client.BucketExists(ctx, bucketName)

权限模型

ACL 说明 适用场景
Private(默认) 只有 Owner 可读写 用户上传的文件、备份
PublicRead Owner 读写 + 所有人可读 CDN 加速的图片资源
PublicReadWrite 所有人可读写 极不推荐 — 可能被恶意刷费

5. 文件上传:三种架构对比

前端要上传图片/文件到 OSS,主要有以下三种架构方案。选择合适的方案直接影响系统的可扩展性、安全性和开发成本。

方案 A:服务端中转上传(Server Relay)

sequenceDiagram
    participant FE as 浏览器 / 客户端
    participant GO as Gin Server
    participant OSS as OSS 服务

    FE->>GO: POST 文件 (multipart/form-data)
    Note over GO: 解析请求体 (io.Reader 流式)
    GO->>OSS: PUT 上传 (Stream 转发)
    OSS-->>GO: 200 OK (ETag, Location)
    GO-->>FE: 200 OK (返回 OSS 访问地址)

工作流程:

  1. 客户端将文件 POST 到 Go 服务端(multipart/form-data)
  2. Go 服务端接收后,通过 SDK 将文件写入 OSS
  3. 返回文件的 OSS 访问地址给客户端
func uploadWithRelay(c *gin.Context) {
    // 限制文件大小(防内存溢出 + 防恶意大文件)
    c.Request.ParseMultipartForm(32 << 20) // 最大 32MB

    file, header, err := c.Request.FormFile("file")
    if err != nil {
        c.JSON(400, gin.H{"error": "invalid file"})
        return
    }
    defer file.Close()

    // 校验文件类型
    buf := make([]byte, 512)
    file.Read(buf)
    contentType := http.DetectContentType(buf)
    allowed := map[string]bool{
        "image/jpeg": true, "image/png": true, "image/webp": true,
    }
    if !allowed[contentType] {
        c.JSON(400, gin.H{"error": "unsupported file type"})
        return
    }
    // rewind for reading again
    file.Seek(0, io.SeekStart)

    // 重命名防止冲突(UUID_原文件名)
    fileName := uuid.New().String() + "_" + header.Filename

    _, err = client.PutObject(c.Request.Context(), bucketName, fileName, file, -1, minio.PutObjectOptions{
        ContentType: contentType,
    })
    if err != nil {
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }

    // 记录元信息到数据库
    db.Create(&FileRecord{Key: fileName, Size: header.Size, Type: contentType})

    c.JSON(200, gin.H{
        "url":  fmt.Sprintf("https://%s.%s/%s", bucketName, endpoint, fileName),
        "size": header.Size,
    })
}

方案 B:预签名 URL 直传(Pre-Signed URL)

sequenceDiagram
    participant FE as 浏览器 / 客户端
    participant GO as Gin Server
    participant OSS as OSS 服务

    FE->>GO: GET /upload/url?filename=photo.jpg
    Note over GO: 验证登录态和权限
    GO->>OSS: PresignedPutObject(photo.jpg, 15min)
    OSS-->>GO: (预签名URL)含签名和过期参数
    GO-->>FE: (返回URL和类型信息)

    FE->>OSS: PUT 直接上传<br/>Content-Type: image/jpeg<br/>文件二进制数据
    OSS-->>FE: 200 OK

    opt 通知服务端完成
        FE->>GO: POST /upload/callback<br/>文件名和元信息
        Note over GO: 验证对象存在于 OSS 中
        GO-->>FE: 200 OK (记录元信息到 DB)
    end

工作流程:

  1. 客户端请求服务端获取预签名 URL
  2. 服务端调用 OSS 的 PresignedPutObject 生成有时效性的上传链接
  3. 客户端拿到 URL 后直接 PUT 到 OSS,不经服务端
  4. 上传完成后回调服务端,记录元信息到数据库
// --- 服务端:生成预签名 URL ---
func getPresignedUploadURL(c *gin.Context) {
    filename := c.Query("filename")
    contentType := c.DefaultQuery("contentType", "application/octet-stream")

    url, err := client.PresignedPutObject(
        context.Background(),
        bucketName,
        filepath.Join("uploads", filename),
        15*time.Minute,
    )
    if err != nil {
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }

    c.JSON(200, gin.H{
        "uploadURL":   url.String(),
        "fileName":    filepath.Join("uploads", filename),
        "contentType": contentType,
        "expiresIn":   900, // 秒
    })
}

// --- 客户端:用返回的 URL 直接 PUT 到 OSS ---
/*
const resp = await fetch('/api/upload/url?filename=avatar.jpg');
const { uploadURL, contentType } = await resp.json();

await fetch(uploadURL, {
    method: 'PUT',
    headers: { 'Content-Type': contentType },
    body: formData.get('file'),
});

// 上传完成后通知服务端
await fetch('/api/upload/callback', {
    method: 'POST',
    body: JSON.stringify({ fileName: 'avatar.jpg', fileSize: 102400 }),
});
*/

详细说明:预签名 URL 的完整流程和安全要点请参考第 9 节。

方案 C:STS 临时凭证直传(STS Credentials)

sequenceDiagram
    participant FE as 浏览器 / 客户端
    participant GO as Gin Server
    participant STS as STS 服务
    participant OSS as OSS 服务

    FE->>GO: 请求上传权限
    Note over GO: 验证用户身份
    GO->>STS: AssumeRole (角色Arn + SessionName)
    STS-->>GO: AccessKeyId + AccessKeySecret + SecurityToken<br/>有效期15分钟
    GO-->>FE: (返回临时凭证)

    FE->>OSS: 用临时凭证直接上传<br/>PostObject API
    OSS-->>FE: 200 OK

工作流程:

  1. 客户端请求服务端获取上传权限
  2. 服务端调用 STS(Security Token Service)AssumeRole 获取临时凭据
  3. 将临时凭据(AccessKeyId + Secret + Token)发给客户端
  4. 客户端直接使用这些凭据通过 OSS PostObject API 上传
import "github.com/aliyun/aliyun-sts-go-sdk"

func getSTSCredentials(c *gin.Context) {
    stsClient := stssdk.NewClientWithAccessKey("cn-hangzhou", masterAK, masterSK)
    request := stssdk.CreateAssumeRoleRequest()
    request.RoleArn = "acs:ram::<YOUR_ACCOUNT_ID>:role/oss-upload-role"
    request.RoleSessionName = "web-session-" + userID
    request.DurationSeconds = 900  // 15 分钟
    request.Policy = `{
        "Statement": [{
            "Action": ["oss:PutObject", "oss:PostObject"],
            "Effect": "Allow",
            "Resource": ["acs:oss:*:*:my-bucket/uploads/*"]
        }]
    }`

    response, err := stsClient.AssumeRole(request)
    if err != nil {
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }

    c.JSON(200, gin.H{
        "accessKeyId":     response.Credentials.AccessKeyId,
        "accessKeySecret": response.Credentials.AccessKeySecret,
        "securityToken":   response.Credentials.SecurityToken,
        "expiration":      response.Credentials.Expiration,
    })
}

关键区别:预签名 URL 是一次性的、针对特定文件的上传链接;STS 临时凭证是一组通用的上传凭据,可以在有效期内用于上传任意文件(受 Policy 约束)。

三种方案对比

维度 方案 A:服务端中转 方案 B:预签名 URL 直传 方案 C:STS 临时凭证
流量走向 客户端 → 服务端 → OSS 客户端 → OSS 客户端 → OSS
服务端带宽消耗 N × 文件大小 ⚠️ 零带宽 ✅ 零带宽 ✅
水平扩展性 差(服务端成为瓶颈) 优秀 优秀
延迟 往返两次(双 RTT) 一次直传(单 RTT) 一次直传(单 RTT)
控制粒度 粗(整个文件过服务端) 中(URL 级控制) 细(Policy 级可控)
实现复杂度 低 ✅ 中 ⚡ 高 ❌
安全性 高(服务端全拦截) 中高(签名+过期) 中(需配置好 Policy)
适合场景 小文件 (<1MB)、需要服务端二次处理 普通上传(头像、截图等) 大文件、批量上传、企业级应用

如何选型?

flowchart TD
    A["用户上传文件"] --> B{"文件大小?"}
    B -- "<= 1MB" --> C{"是否需要服务端<br/>内容分析/转码?"}
    B -- "> 1MB 或不确定" --> D{"预期并发量?"}

    C -- "否" --> E["✅ 方案 B: 预签名 URL<br/>最简单+零带宽开销"]
    C -- "是" --> F["✅ 方案 A: 服务端中转<br/>可控但限并发"]

    D -- "QPS < 100" --> G["✅ 方案 B: 预签名 URL"]
    D -- "QPS >= 100 或<br/>大批量/大文件" --> H["✅ 方案 C: STS 临时凭证<br/>灵活且可扩展"]

经验法则:大多数 Web 应用选**方案 B(预签名 URL)**就足够了。只有在需要对文件内容进行服务端分析(如病毒扫描、图像压缩、OCR),或者并发量极大时才考虑方案 A 或 C。


本节的代码示例即为各方案的完整实现;更多细节和上下文请参考:服务端中转 → 第 6 节(对象基本操作)、预签名 URL → 第 7 节、STS 临时凭证 → 第 9 节。

6. 对象基本操作

上传(服务端直传示例)

// 小文件:< 100MB 时直接用 FPutObject
err := client.FPutObject(ctx, bucketName, "uploads/photo.jpg", "./photo.jpg", minio.PutObjectOptions{
    ContentType: "image/jpeg",
})

// 大文件:自动分片上传(Minio SDK 内部处理)
file, _ := os.Open("large-video.mp4")
_, err = client.PutObject(ctx, bucketName, "videos/large.mp4", file, -1, minio.PutObjectOptions{
    PartSize: 10 << 20, // 每片 10MB
    ContentType: "video/mp4",
})

分片阈值:Minio SDK 默认当文件超过 5MB 时自动启动分片上传,最大支持单文件 50TB。无需手动实现。

下载

// 完整下载 → io.Reader
obj, err := client.GetObject(ctx, bucketName, "uploads/photo.jpg", minio.GetObjectOptions{})
defer obj.Close()

data, _ := io.ReadAll(obj)
_ = data // 写入数据库或直接返回给前端

// 范围下载(只读文件的某个区间)
opts := minio.GetObjectOptions{}
opts.Set("Range", "bytes=0-1023") // 只读前 1KB

删除

// 删除单个对象
client.RemoveObject(ctx, bucketName, "uploads/old-photo.jpg", minio.RemoveObjectOptions{})

// 批量删除
objsCh := make(chan minio.ObjectInfo, 100)
for info := range objsCh {
    client.RemoveObject(ctx, bucketName, info.Key, minio.RemoveObjectOptions{})
}
close(objsCh)
client.RemoveObjects(ctx, bucketName, objsCh, minio.RemoveObjectsOptions{})

列出对象

// 列出以 "uploads/" 开头的对象(模拟目录效果)
objects := client.ListObjects(ctx, bucketName, minio.ListObjectsOptions{
    Prefix:    "uploads/",    // 前缀过滤
    Recursive: true,          // false 则只在虚拟"目录"层面
})

for obj := range objects {
    fmt.Printf("%-40s %8d bytes  %s\n", obj.Key, obj.Size, obj.LastModified)
}

7. 预签名 URL — 免服务端中转

这是对象存储最常用的高级模式之一。

核心问题:用户上传图片时,如果不走对象存储直传,而是先 POST 到 Go 服务端再由服务端转发到 OSS——当 1000 个用户同时上传 5MB 图片时,Go 进程需要消耗多少带宽?

答案很简单:5MB × 1000 = 5GB 内存 + 带宽压力。这就是为什么应该让客户端直接跟 OSS 对话。

预签名 URL 流程:

sequenceDiagram
    participant FE as 浏览器 / 客户端
    participant GO as Gin Server
    participant OSS as OSS 服务

    FE->>GO: GET /upload/url<br/>?filename=photo.jpg
    Note over GO: 验证用户登录态和权限
    GO->>OSS: PresignedGetObject(photo.jpg, 15min)
    OSS-->>GO: (预签名URL)<br/>含签名和过期参数
    GO-->>FE: (返回URL和类型信息)

    FE->>OSS: PUT直接上传<br/>Content-Type: image/jpeg<br/>文件二进制数据
    OSS-->>FE: 200 OK

    opt 通知服务端完成
        FE->>GO: POST /upload/callback<br/>文件名和元信息
        GO-->>FE: 200 OK
    end

安全要点:预签名 URL 有时间限制(通常 5~15 分钟),过期后无效。即使 URL 泄露也无法长期滥用。

代码示例:

func getPresignedUploadURL(c *gin.Context) {
    filename := c.Query("filename")
    contentType := c.DefaultQuery("contentType", "application/octet-stream")

    // 生成一个有效期 15 分钟的 PUT 预签名 URL
    // 注意:PresignedPutObject 不允许设置 Content-Type 约束
    // 如需严格校验,应在 Callback 中二次确认
    url, err := client.PresignedPutObject(
        context.Background(),
        bucketName,
        filepath.Join("uploads", filename),
        15*time.Minute,
    )
    if err != nil {
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }

    c.JSON(200, gin.H{
        "uploadURL":   url.String(),
        "fileName":    filepath.Join("uploads", filename),
        "contentType": contentType,
        "expiresIn":   900, // 秒
    })
}

POST 端回调验证(防越权上传):

func uploadCallback(c *gin.Context) {
    var req struct {
        FileName string `json:"fileName" binding:"required"`
        FileSize int64  `json:"fileSize" binding:"required"`
    }
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": "invalid params"})
        return
    }

    key := filepath.Join("uploads", req.FileName)

    // 检查对象是否真的存在于 OSS 中
    _, err := client.StatObject(context.Background(), bucketName, key, minio.StatObjectOptions{})
    if err != nil {
        c.JSON(404, gin.H{"error": "file not found in OSS"})
        return
    }

    // 保存元信息到数据库
    db.Create(&FileRecord{Key: key, Size: req.FileSize})
    c.JSON(200, gin.H{"status": "recorded"})
}

8. 生命周期管理(Lifecycle)

频繁删除和上传会产生大量碎片化的旧版本,合理配置 Lifecycle 可以节省成本并避免磁盘爆炸:

import "github.com/minio/minio-go/v7/pkg/lifecycle"

err := client.SetBucketLifecycle(ctx, bucketName, &lifecycle.Configuration{
    Rules: []lifecycle.Rule{
        {
            ID:      "expire-old-uploads",
            Status:  lifecycle.RuleEnabled,
            Prefix:  "uploads/",
            Expiration: lifecycle.Expiration{Days: 90}, // 90天后自动删除
        },
        {
            ID:      "transition-rare-access",
            Status:  lifecycle.RuleEnabled,
            Prefix:  "backups/",
            Transition: lifecycle.Transition{
                Days: 30,
                StorageClass: "GLACIER", // 转归档存储(S3 兼容写法)
            },
        },
    },
})

存储类型与成本对比(阿里云为例):

存储类型 单价 (元/GB/月) 取回费用 最低存储时长 适用场景
标准存储(Standard) ~0.12 无 无 活跃数据、频繁访问
低频访问(IA) ~0.084 有 30天 很少读取但需即时访问
归档存储 ~0.042 有 + 恢复时间 60天 合规存档、历史备份
冷归档 ~0.021 有 + 数小时恢复 180天 极低频、合规留存

策略建议:新创建的 Bucket 使用标准存储即可。在业务逻辑中标记哪些文件属于"冷数据",然后通过 Lifecycle 规则自动降级。不要人为在每个上传接口中判断冷热。

9. 并发与安全注意事项

常见陷阱和解决方案:

flowchart TD
    subgraph TRAPS["常见陷阱"]
    T1["AccessKey泄露<br/>导致账单爆炸"]
    T2["未限流<br/>OSS请求被拒绝"]
    T3["命名冲突<br/>同名文件覆盖"]
    T4["内存溢出<br/>全量读入内存"]
    T5["内网跨地域<br/>用了公网Endpoint"]
    end

    subgraph FIXES["解决方案"]
        F1["使用RAM子账号+STS<br/>临时凭证(15min过期)"]
        F2["客户端侧限速+重试退避<br/>指数退避算法"]
        F3["使用UUID或NanoID重命名<br/>fileUUID_扩展名"]
        F4["始终用io.Reader流式处理<br/>避免ReadAll读大文件"]
        F5["确保ECS与Bucket同Region<br/>使用内网internal endpoint"]
    end

    T1 --> F1
    T2 --> F2
    T3 --> F3
    T4 --> F4
    T5 --> F5

    style T1 fill:#ffebee,stroke:#c62828
    style T2 fill:#ffebee,stroke:#c62828
    style T3 fill:#fff3e0,stroke:#f57c00
    style T4 fill:#ffebee,stroke:#c62828
    style T5 fill:#fff3e0,stroke:#f57c00

    style F1 fill:#e8f5e9,stroke:#2e7d32
    style F2 fill:#e8f5e9,stroke:#2e7d32
    style F3 fill:#e8f5e9,stroke:#2e7d32
    style F4 fill:#e8f5e9,stroke:#2e7d32
    style F5 fill:#e8f5e9,stroke:#2e7d32

正确使用流式下载避免 OOM:

// ❌ 错误做法:大文件会把整个内容加载到内存
obj, _ := client.GetObject(ctx, bucketName, "large-backup.sql.gz", minio.GetObjectOptions{})
data, _ := io.ReadAll(obj) // 10GB 文件 → 10GB 内存 = 💥

// ✅ 正确做法:边读边写
obj, _ := client.GetObject(ctx, bucketName, "large-backup.sql.gz", minio.GetObjectOptions{})
defer obj.Close()

dst, _ := os.Create("./backup-latest.sql.gz")
defer dst.Close()

_, err := io.Copy(dst, obj) // 零拷贝,内存占用恒定

STS 临时凭证(RAM Role):

import "github.com/aliyun/aliyun-sts-go-sdk"

func getTemporaryCredentials() (*sts.Credentials, error) {
    client := stssdk.NewClientWithAccessKey("cn-hangzhou", ak, sk)
    request := stssdk.CreateAssumeRoleRequest()
    request.RoleArn = "acs:ram::<YOUR_ACCOUNT_ID>:role/oss-upload-role"
    request.RoleSessionName = "web-session"
    request.DurationSeconds = 900 // 15 分钟

    response, err := client.AssumeRole(request)
    if err != nil {
        return nil, err
    }
    // 前端用这些临时凭据直传 OSS,服务端不暴露 AK/SK
    return &response.Credentials, nil
}

教学提示:STS 的核心思路是"最小权限原则"——前端只获得一个有时效性的短期令牌,即使被拦截也只能在 15 分钟内使用,且只能用于指定的 Bucket 和路径。

附录:快速上手清单

部署新的 OSS 集成时,逐项检查:

  • 创建独立 RAM 子账号(不要直接用主账号 AK)
  • 最小权限授权:只允许目标 Bucket 的操作
  • 服务端和内网都使用 -internal Endpoint
  • 文件大小限制前置到中间件层(参考 MaxMultipartMemory)
  • 文件名使用 UUID 重命名,防止冲突和路径遍历
  • 生成预签名 URL 时设置合理的过期时间
  • 上传成功后通过 callback 或事件通知更新数据库元信息
  • 配置 Lifecycle 自动清理过期文件
  • 大文件传输加进度条和断点续传支持(客户端侧)

关联笔记