gouploader

package module
v1.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 30, 2026 License: MIT Imports: 16 Imported by: 0

README

gouploader

生产级文件上传工具包,适用于 Gin 框架。

📖 完整架构设计与生产级维度说明请看 docs/ARCHITECTURE.md

功能特性

  • 普通上传 — 单文件上传,流式 I/O,内存恒定
  • 分片上传 — 大文件拆分为分片,并行上传 + 断点续传
  • 秒传 — 基于整文件哈希索引,命中即零流量上传(需注入 HashStore
  • 云原生 Multipart 直传(方案 A) — 服务端只签名,客户端 PUT 直连云端,大文件不过服务器带宽
  • STS 前端直传(方案 B) — 客户端用云厂商 JS SDK 直连云端
  • 可插拔存储 — 本地磁盘 / S3 兼容(MinIO、AWS)/ 三家云独立子 module(按需引入,主包零污染)
  • 自定义存储 — 实现 Storage / MultipartStorage 接口对接任意后端
  • Feature Flag 灰度 — Multipart 路径按请求动态切换
  • 悬挂 Multipart 清理 — 后台 worker 自动扫描 + Abort,防止云厂商费用泄漏
  • 完整性校验 — MD5 / SHA256 校验;Complete 前服务端二次校验云端 ETag
  • 安全防护 — 目录穿越防护、预签名 URL 参数锁死、表单字段大小限制
  • 并发与内存安全 — 会话级互斥锁、原子文件写入、幂等上传、全链路流式 I/O
  • 跨域支持 / 回调机制 — 内置 CORS 中间件、上传完成钩子

安装

# 主包(所有场景必装)
go get github.com/gtkit/gouploader

# 按需引入云厂商 adapter(三选一或多选,主包不会被污染)
go get github.com/gtkit/gouploader/aliyun   # 阿里云 OSS
go get github.com/gtkit/gouploader/tencent  # 腾讯云 COS
go get github.com/gtkit/gouploader/huawei   # 华为云 OBS

三条上传路径

路径 数据流 前端依赖 使用场景
代理上传 Client → Server → Storage 纯 HTTP 小文件、本地磁盘 / MinIO
Multipart 直传(方案 A) Client → Cloud(Server 只签名) 纯 HTTP(PUT 预签名 URL) 大文件、阿里 / 腾讯 / 华为云
STS 直传(方案 B) Client → Cloud(Server 只签 STS) 云厂商 JS SDK 浏览器、前端可引入云 SDK

三条路径共享同一套协议(/chunk/init/chunk/resume/chunk/complete/chunk/abort),前端无需感知当前走哪条——由 /chunk/init 响应中的 upload_path 字段指示。

快速开始

package main

import (
    "log"

    "github.com/gin-gonic/gin"
    "github.com/gtkit/gouploader"
)

func main() {
    r := gin.Default()

    uploader, err := gouploader.New(
        gouploader.WithLocalStorage("./uploads"),
    )
    if err != nil {
        log.Fatal(err)
    }
    defer uploader.Close()

    uploader.RegisterRoutes(r.Group("/api/v1/upload"))
    r.Run(":8080")
}

生产级配置示例(方案 A:Multipart 直传 + 秒传 + 灰度)

import (
    "time"
    "github.com/gtkit/gouploader"
    "github.com/gtkit/gouploader/aliyun"  // 按需引入,主包不会拉这个依赖
)

// 1. 创建云原生 Storage
ossStorage, _ := aliyun.NewStorage(aliyun.Config{
    Endpoint:        "oss-cn-hangzhou.aliyuncs.com",
    AccessKeyID:     os.Getenv("OSS_AK"),
    AccessKeySecret: os.Getenv("OSS_SK"),
    Bucket:          "my-bucket",
    Region:          "cn-hangzhou",
})

// 2. 实现 HashStore(秒传索引)- 业务方按需用 DB/Redis 实现
type dbHashStore struct{ db *gorm.DB }
func (s *dbHashStore) LookupByHash(ctx context.Context, algo, hash string) (*gouploader.HashedFile, error) { ... }
func (s *dbHashStore) Register(ctx context.Context, entry *gouploader.HashedFile) error { ... }

// 3. 组装
uploader, _ := gouploader.New(
    gouploader.WithStorage(ossStorage),               // 云原生 Storage,自动启用 Multipart 直传
    gouploader.WithHashStore(&dbHashStore{db: db}),   // 秒传

    // Multipart 直传配置
    gouploader.WithMultipartThreshold(100 << 20),     // 大于 100MB 走直传;小于等于走代理
    gouploader.WithPartSignTTL(15 * time.Minute),     // 单 URL 有效期

    // 灰度 Feature Flag
    gouploader.WithMultipartFeatureFlag(func(ctx, ginCtx, req) bool {
        uid := ginCtx.GetString("user_id")
        return hashMod(uid, 100) < 10   // 10% 灰度
    }),

    // 用户隔离
    gouploader.WithUserIDFn(func(c *gin.Context) string {
        return c.GetString("user_id")
    }),
    gouploader.WithMultipartKeyPrefix(func(c *gin.Context, userID string) string {
        return fmt.Sprintf("user-%s/%s/", userID, time.Now().Format("2006-01-02"))
    }),

    // 悬挂 Multipart 清理 worker
    gouploader.WithOrphanCleanup(24*time.Hour, 1*time.Hour),

    // 业务回调
    gouploader.WithOnUploadComplete(func(r *gouploader.UploadResult) {
        db.SaveFile(r.FileID, r.StorageKey, r.Checksum)
    }),
)
defer uploader.Close()

生产建议:无论是否启用 WithOrphanCleanup,都强烈建议在 Bucket 层面配置生命周期规则(AbortIncompleteMultipartUpload, 建议 7 天),做应用级 + 云级双保险。

配置选项

所有选项通过函数式选项传入 gouploader.New()

uploader, err := gouploader.New(
    // 存储后端(三选一)
    gouploader.WithLocalStorage("./uploads"),
    // 或
    gouploader.WithS3Storage(gouploader.S3Config{...}),
    // 或
    gouploader.WithStorage(myCustomStorage),

    // 上传限制
    gouploader.WithMaxFileSize(10 << 30),                        // 10GB,默认 10GB
    gouploader.WithAllowedTypes(".jpg", ".png", ".mp4", ".pdf"), // 空=允许所有类型
    gouploader.WithHashAlgorithm("sha256"),                      // "md5" 或 "sha256",默认 "sha256"

    // 分片设置
    gouploader.WithChunkTempDir("./tmp/chunks"),                 // 默认 "./tmp/chunks"
    gouploader.WithChunkSize(5<<20, 1<<20, 20<<20),             // 默认/最小/最大,默认 5MB/1MB/20MB
    gouploader.WithChunkSessionTTL(24 * time.Hour),              // 默认 24 小时
    gouploader.WithChunkCleanupInterval(1 * time.Hour),          // 默认 1 小时

    // STS 直传(可选,配置后启用前端直传云存储)
    gouploader.WithAliyunSTS(gouploader.AliyunSTSConfig{
        AccessKeyID:     "LTAI...",
        AccessKeySecret: "xxx",
        RoleArn:         "acs:ram::123456:role/oss-upload-role",
        OSSEndpoint:     "oss-cn-hangzhou.aliyuncs.com",
        Bucket:          "my-bucket",
        Region:          "cn-hangzhou",
    }),
    gouploader.WithOnDirectUploadComplete(func(result *gouploader.DirectUploadResult) {
        db.SaveDirectFile(result.Key, result.Size)
    }),

    // 回调
    gouploader.WithOnUploadComplete(func(result *gouploader.UploadResult) {
        // 每次上传成功后触发(普通上传和分片合并均触发)
        // 用途:存数据库、发通知、触发后续处理等
        db.SaveFile(result.FileID, result.FileName, result.StorageKey, result.Checksum)
    }),
)
选项参考

基础选项

选项 默认值 说明
WithLocalStorage(path) "./uploads" 本地磁盘存储
WithS3Storage(S3Config) - S3 兼容对象存储(MinIO / AWS)
WithStorage(Storage) - 自定义 Storage 实现(含子 module 的云 adapter)
WithMaxFileSize(bytes) 10GB 允许的最大文件大小
WithAllowedTypes(exts...) 全部 允许的文件扩展名
WithHashAlgorithm(algo) "sha256" 校验和算法
WithChunkTempDir(dir) "./tmp/chunks" 分片临时目录(代理路径用)
WithChunkSize(默认, 最小, 最大) 5MB/1MB/20MB 分片大小限制
WithChunkSessionTTL(duration) 24h 上传会话过期时间
WithChunkCleanupInterval(duration) 1h 过期会话清理间隔
WithOnUploadComplete(fn) - 上传完成回调(成功路径)
WithOnUploadInit(fn) - 会话初始化成功钩子(不含秒传命中)
WithOnUploadError(fn) - 任一阶段失败钩子(Stage 区分失败环节)
WithOnUploadAbort(fn) - 会话被中止钩子
WithOnInstantHit(fn) - 秒传命中钩子
WithPublicURLBuilder(fn) - 注入"对外可见 URL"拼装策略;所有上传完成路径回显到 result.URL(详见下文「上传完成 URL 回显」)

Multipart 直传 & 秒传选项

选项 默认值 说明
WithHashStore(HashStore) - 秒传索引存储(DB/Redis/自定义)。未注入时秒传关闭。⚠️ 多租户场景必须实现 UserScopedHashStore(v1.4.0+)
WithHashStoreRegisterTimeout(d) 30s HashStore.Register 异步注册超时;HashStore 实现卡死时防止后台 goroutine 累积
WithStrictPartsVerify(bool) true Multipart Complete 前云端 ListParts 校验严格度(默认严格拒绝)。仅在云 API 抖动率高时显式 false 降级
WithRequireUserID() - 强制要求 UserIDFn 返回非空字符串,否则禁用秒传查询(多租户场景必备)
WithKeyPrefixSigner([]byte) - 启用 STS 直传 KeyPrefix HMAC 签名,防止伪造(多租户场景必备)
WithMultipartThreshold(bytes) 100MB 走 Multipart 直传的最小文件大小
WithMultipartFeatureFlag(fn) - 按请求动态决定是否走 Multipart 路径(灰度用)
WithMultipartDisabled() - 强制关闭 Multipart 直传(降级/回滚用)
WithPartSignTTL(duration) 15min 单个 part 预签名 URL 有效期
WithUserIDFn(fn) →"anon" 从 Gin 上下文提取用户标识;配置后自动启用会话所有权校验
WithMultipartKeyPrefix(fn) 按日期 生成对象键前缀(建议加用户 ID 隔离)
WithOrphanCleanup(ttl, interval) - 启用悬挂 Multipart 清理 worker

S3 兼容存储

一套实现覆盖所有 S3 兼容服务,只需修改 Endpoint

// MinIO
gouploader.WithS3Storage(gouploader.S3Config{
    Endpoint:  "127.0.0.1:9000",
    AccessKey: "minioadmin",
    SecretKey: "minioadmin",
    Bucket:    "uploads",
    UseSSL:    false,
})

// 阿里云 OSS
gouploader.WithS3Storage(gouploader.S3Config{
    Endpoint:  "oss-cn-hangzhou.aliyuncs.com",
    AccessKey: "your-access-key",
    SecretKey: "your-secret-key",
    Bucket:    "my-bucket",
    Region:    "oss-cn-hangzhou",
    UseSSL:    true,
})

// 腾讯云 COS
gouploader.WithS3Storage(gouploader.S3Config{
    Endpoint:  "cos.ap-guangzhou.myqcloud.com",
    AccessKey: "your-access-key",
    SecretKey: "your-secret-key",
    Bucket:    "my-bucket-1250000000",
    Region:    "ap-guangzhou",
    UseSSL:    true,
})

// 华为云 OBS
gouploader.WithS3Storage(gouploader.S3Config{
    Endpoint:  "obs.cn-north-4.myhuaweicloud.com",
    AccessKey: "your-access-key",
    SecretKey: "your-secret-key",
    Bucket:    "my-bucket",
    Region:    "cn-north-4",
    UseSSL:    true,
})

// AWS S3
gouploader.WithS3Storage(gouploader.S3Config{
    Endpoint:  "s3.us-east-1.amazonaws.com",
    AccessKey: "AKIAIOSFODNN7EXAMPLE",
    SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
    Bucket:    "my-bucket",
    Region:    "us-east-1",
    UseSSL:    true,
})

自定义存储

实现 Storage 接口即可对接任意后端:

type Storage interface {
    Save(ctx context.Context, key string, reader io.Reader, size int64) error
    Get(ctx context.Context, key string) (io.ReadCloser, error)
    Delete(ctx context.Context, key string) error
    Exists(ctx context.Context, key string) (bool, error)
}
uploader, _ := gouploader.New(
    gouploader.WithStorage(myFTPStorage),
)

跨域配置

内置 CORS 中间件,支持前端跨域请求:

group := r.Group("/api/v1/upload")
group.Use(gouploader.CORS(gouploader.CORSConfig{
    AllowOrigins:     []string{"https://your-frontend.com"},
    AllowCredentials: true,
}))
uploader.RegisterRoutes(group)

路由注册

自动注册(推荐)
uploader.RegisterRoutes(r.Group("/api/v1/upload"))

注册以下路由:

方法 路径 使用路径 说明
POST /api/v1/upload - 普通单文件上传
POST /api/v1/upload/chunk/init 全部 初始化分片上传(响应含 upload_path 指示路径)
POST /api/v1/upload/chunk/resume 全部 秒传 + 断点续传查询(按 file_hash 查找)
POST /api/v1/upload/chunk proxy 上传单个分片(multipart/form-data)
GET /api/v1/upload/chunk/status/:upload_id proxy 查询上传进度(断点续传)
POST /api/v1/upload/chunk/merge/:upload_id proxy 合并所有分片
POST /api/v1/upload/chunk/parts/sign multipart 按需签发一组 part 的预签名 URL
POST /api/v1/upload/chunk/parts/report multipart 客户端上报单片 PUT 成功后的 ETag
POST /api/v1/upload/chunk/complete multipart 合并分片(携带全部 parts + ETag)
DELETE /api/v1/upload/chunk/:upload_id 全部 中止(Multipart 路径同时 Abort 云端)

如果配置了 STS 直传,还会注册:

方法 路径 说明
POST /api/v1/upload/direct/token 签发 STS 临时凭证
POST /api/v1/upload/direct/complete 前端确认上传完成
POST /api/v1/upload/direct/callback 云存储回调(仅当 Provider 实现了 CallbackVerifier 时注册,详见下文「云存储回调验签」)
手动注册(搭配自定义中间件)
auth := myAuthMiddleware()

r.POST("/upload",                        auth, uploader.HandleUpload())
r.POST("/upload/chunk/init",             auth, uploader.HandleChunkInit())
r.POST("/upload/chunk",                  auth, uploader.HandleChunkUpload())
r.GET("/upload/chunk/status/:upload_id", auth, uploader.HandleChunkStatus())
r.POST("/upload/chunk/merge/:upload_id", auth, uploader.HandleChunkMerge())
r.DELETE("/upload/chunk/:upload_id",     auth, uploader.HandleChunkAbort())

接口文档

普通上传

POST /api/v1/uploadmultipart/form-data

字段 类型 必填 说明
file file 要上传的文件
checksum string 期望的文件哈希值
algorithm string 哈希算法:md5sha256

响应:

{
  "code": 0,
  "message": "ok",
  "data": {
    "file_id": "550e8400-e29b-41d4-a716-446655440000",
    "file_name": "photo.jpg",
    "file_size": 1048576,
    "storage_key": "2026/04/09/550e8400-e29b-41d4-a716-446655440000.jpg",
    "checksum": "a1b2c3d4...",
    "url": "https://static.example.com/2026/04/09/550e8400-e29b-41d4-a716-446655440000.jpg"
  }
}

url 字段在配置 WithPublicURLBuilder 后填充,未配置时不输出(适用于所有上传完成路径)。

分片上传 — 初始化

POST /api/v1/upload/chunk/initapplication/json

{
  "file_name": "video.mp4",
  "file_size": 1073741824,
  "chunk_size": 5242880,
  "checksum": "整个文件的sha256",
  "algorithm": "sha256"
}

响应(按命中路径不同):

// 普通代理 / Multipart 直传:基础字段
{
  "code": 0,
  "data": {
    "upload_id": "550e8400-...",
    "chunk_size": 5242880,
    "total_chunks": 205,
    "upload_path": "proxy"          // 或 "multipart"
  }
}

// 秒传命中:instant=true,前端无需上传
{
  "code": 0,
  "data": {
    "instant": true,
    "upload_path": "instant",
    "storage_key": "2026/04/09/abc.jpg",
    "file_id": "...",
    "url": "https://static.example.com/2026/04/09/abc.jpg"  // 配置 WithPublicURLBuilder 后填充
  }
}

// Multipart 直传额外字段(upload_path=multipart 时):
//   "part_urls": { "1": "<presigned-url>", "2": "...", ... }
//   "part_sign_ttl": 900
//   "recommend_concurrency": 6
分片上传 — 上传分片

POST /api/v1/upload/chunkmultipart/form-data

重要: upload_idchunk_index 字段必须在 chunk 字段之前。

字段 类型 必填 说明
upload_id string 初始化返回的会话 ID
chunk_index int 分片索引(从 0 开始)
chunk_checksum string 该分片的哈希值
chunk file 分片二进制数据

响应:

{
  "code": 0,
  "data": {
    "upload_id": "550e8400-...",
    "chunk_index": 3,
    "uploaded_count": 4,
    "total_chunks": 205
  }
}
分片上传 — 查询状态(断点续传)

GET /api/v1/upload/chunk/status/:upload_id

响应:

{
  "code": 0,
  "data": {
    "upload_id": "550e8400-...",
    "total_chunks": 205,
    "uploaded_chunks": [0, 1, 2, 3, 7, 8],
    "missing_chunks": [4, 5, 6, 9, 10, 11]
  }
}
分片上传 — 合并

POST /api/v1/upload/chunk/merge/:upload_id(代理路径) POST /api/v1/upload/chunk/complete(Multipart 直传路径,body 携带 parts + ETag)

响应(两条路径字段一致):

{
  "code": 0,
  "data": {
    "file_id": "f17c99c7-...",
    "file_name": "video.mp4",
    "file_size": 1073741824,
    "storage_key": "2026/04/09/f17c99c7-....mp4",
    "checksum": "a1b2c3d4...",
    "url": "https://static.example.com/2026/04/09/f17c99c7-....mp4"
  }
}
分片上传 — 中止

DELETE /api/v1/upload/chunk/:upload_id

响应:

{
  "code": 0,
  "message": "ok"
}
分片上传 — 秒传 / 断点续传查询

POST /api/v1/upload/chunk/resumeapplication/json

{
  "file_hash": "整个文件的sha256",
  "algorithm": "sha256",
  "file_size": 1073741824
}

响应分三种:

// 秒传命中
{
  "code": 0,
  "data": {
    "instant": true,
    "storage_key": "2026/04/09/abc.mp4",
    "file_id": "...",
    "url": "https://static.example.com/2026/04/09/abc.mp4"  // 配置 WithPublicURLBuilder 后填充
  }
}

// 找到未完成 session(断点续传)
{
  "code": 0,
  "data": {
    "upload_id": "550e8400-...",
    "upload_path": "proxy",
    "total_chunks": 205,
    "uploaded_parts": [0, 1, 2, ...],
    "missing_parts": [50, 51, 52, ...]
  }
}

// 都未命中(前端走正常 /chunk/init)
{ "code": 0, "data": {} }
前端直传 — 获取 STS 凭证

POST /api/v1/upload/direct/tokenapplication/json

{
  "file_name": "photo.jpg",
  "file_size": 1048576
}

响应:

{
  "code": 0,
  "data": {
    "access_key_id": "STS.xxx",
    "access_key_secret": "xxx",
    "security_token": "xxx",
    "expiration": "2026-04-09T02:00:00Z",
    "region": "cn-hangzhou",
    "bucket": "my-bucket",
    "endpoint": "oss-cn-hangzhou.aliyuncs.com",
    "key_prefix": "direct/2026/04/09/",
    "key": "direct/2026/04/09/550e8400-....jpg"
  }
}
前端直传 — 确认完成

POST /api/v1/upload/direct/completeapplication/json

{
  "key": "direct/2026/04/09/550e8400-....jpg",
  "key_prefix": "direct/2026/04/09/",
  "etag": "\"d41d8cd98f00b204e9800998ecf8427e\""
}

v1.4.0 起 key_prefix 是可选字段

  • 强烈建议前端把 IssueToken 响应的 key_prefix 原样回传——彻底修复跨天上传问题(23:59 拿 token,00:01 调 Complete 也能成功)
  • 不传也兼容 v1.3.0 行为:服务端 fallback 到 keyPrefixFn(ginCtx) 重新计算 prefix(仍存在跨天问题,但不被拒绝),同时打 Warn 日志提示升级
  • 启用 WithKeyPrefixSigner 时还需带上 key_prefix_sig(值取自 IssueToken 响应的 key_prefix_sig

响应:

{
  "code": 0,
  "data": {
    "bucket": "my-bucket",
    "key": "direct/2026/04/09/550e8400-....jpg",
    "size": 1048576,
    "etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
    "url": "https://static.example.com/direct/2026/04/09/550e8400-....jpg"
  }
}
前端直传 — 云存储回调

POST /api/v1/upload/direct/callback — 由云厂商上报;只在 STSProvider 实现了 CallbackVerifier 时注册。

由云存储直接调用,不是前端调用。验签算法因厂商而异(OSS RSA / OBS HMAC-SHA256 / COS Token),详见 云存储回调验签

响应字段与 /direct/complete 一致,url 字段在配置 WithPublicURLBuilder 后填充。

错误响应

所有错误遵循统一格式:

{
  "code": 10001,
  "message": "文件超过大小限制",
  "details": "最大允许: 10737418240 字节"
}
错误码 HTTP 状态码 说明
10001 413 文件超过大小限制
10002 400 文件类型不允许
10003 400 校验和不匹配
10005 400 参数无效
10006 400 multipart 解析错误
20001 404 上传会话不存在
20002 410 上传会话已过期
20003 400 分片索引越界
20005 400 分片校验和不匹配
20006 409 分片未全部上传
20007 500 合并后文件校验失败
30001 500 存储写入失败
30002 500 存储读取失败
40001 503 STS 直传未配置
40002 500 STS 凭证签发失败
40003 403 回调验签失败
40004 404 直传文件不存在
50001 501 当前存储不支持 Multipart 直传
50002 500 云端 Multipart 初始化失败
50003 500 预签名 URL 生成失败
50004 500 云端 Multipart 合并失败
50005 500 云端 Multipart 中止失败
50006 400 服务端二次校验 parts 失败
50007 400 客户端上报 ETag 与云端不一致
50008 403 无权访问该会话(身份 ≠ session.UserID)

前端对接

普通上传(JavaScript)
async function uploadFile(file) {
  const formData = new FormData();
  formData.append('file', file);

  const response = await fetch('/api/v1/upload', {
    method: 'POST',
    body: formData,
  });
  return response.json();
}
分片断点续传(JavaScript)
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB

async function uploadLargeFile(file) {
  // 第一步:计算文件哈希(可选,用于完整性校验)
  const checksum = await calculateSHA256(file);

  // 第二步:初始化上传会话
  const initRes = await fetch('/api/v1/upload/chunk/init', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      file_name: file.name,
      file_size: file.size,
      chunk_size: CHUNK_SIZE,
      checksum: checksum,
      algorithm: 'sha256',
    }),
  }).then(r => r.json());

  const { upload_id, total_chunks } = initRes.data;

  // 第三步:查询已上传的分片(用于断点续传)
  const statusRes = await fetch(`/api/v1/upload/chunk/status/${upload_id}`)
    .then(r => r.json());
  const missingChunks = statusRes.data.missing_chunks;

  // 第四步:并发上传缺失的分片
  const CONCURRENCY = 3;
  const queue = [...missingChunks];

  async function uploadWorker() {
    while (queue.length > 0) {
      const index = queue.shift();
      const start = index * CHUNK_SIZE;
      const end = Math.min(start + CHUNK_SIZE, file.size);
      const blob = file.slice(start, end);

      const formData = new FormData();
      formData.append('upload_id', upload_id);
      formData.append('chunk_index', String(index));
      formData.append('chunk', blob);

      const res = await fetch('/api/v1/upload/chunk', {
        method: 'POST',
        body: formData,
      }).then(r => r.json());

      console.log(`分片 ${index}/${total_chunks} 完成,共 ${res.data.uploaded_count} 片`);
    }
  }

  await Promise.all(Array.from({ length: CONCURRENCY }, () => uploadWorker()));

  // 第五步:合并
  const mergeRes = await fetch(`/api/v1/upload/chunk/merge/${upload_id}`, {
    method: 'POST',
  }).then(r => r.json());

  console.log('上传完成:', mergeRes.data);
  return mergeRes.data;
}
Multipart 直传(方案 A,JavaScript,统一协议)

前端通用逻辑——根据 /chunk/init 返回的 upload_path 自动分流,支持三种情形:秒传 / 代理 / 直传。前端零耦合云厂商

async function smartUpload(file) {
  // 1. 计算整文件哈希(支持秒传和断点续传)
  const fileHash = await sha256(file);

  // 2. 查询秒传 + 断点续传
  const resume = await fetch('/api/v1/upload/chunk/resume', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ file_hash: fileHash, file_name: file.name, file_size: file.size }),
  }).then(r => r.json()).then(r => r.data);

  if (resume.instant) {
    console.log('秒传命中!', resume.storage_key);
    return resume;
  }

  let upload_id = resume.upload_id;
  let upload_path = resume.upload_path;
  let total_chunks = resume.total_chunks;
  let part_urls = null;
  let uploaded = resume.uploaded_parts || [];

  // 3. 未命中 resume 则走 init
  if (!upload_id) {
    const init = await fetch('/api/v1/upload/chunk/init', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        file_name: file.name,
        file_size: file.size,
        file_hash: fileHash,
        algorithm: 'sha256',
      }),
    }).then(r => r.json()).then(r => r.data);

    if (init.instant) return init;  // init 也会触发秒传

    upload_id = init.upload_id;
    upload_path = init.upload_path;
    total_chunks = init.total_chunks;
    part_urls = init.part_urls;
  }

  // 4. 分流:proxy 走 POST,multipart 走 PUT
  if (upload_path === 'multipart') {
    return uploadViaMultipart(file, upload_id, total_chunks, part_urls, uploaded);
  }
  return uploadViaProxy(file, upload_id, total_chunks, uploaded);
}

// ——————— Multipart 直传实现 ———————
async function uploadViaMultipart(file, upload_id, total_chunks, part_urls, uploadedSet) {
  const chunkSize = Math.ceil(file.size / total_chunks);
  const partEtags = [];
  const uploadedSetObj = new Set(uploadedSet);

  // 按需签名:首批 URL 由 init 下发,后续批次调 /chunk/parts/sign
  async function urlFor(partNum) {
    if (part_urls && part_urls[partNum]) return part_urls[partNum];
    const batch = [];
    for (let i = partNum; i < Math.min(partNum + 10, total_chunks + 1); i++) batch.push(i);
    const res = await fetch('/api/v1/upload/chunk/parts/sign', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ upload_id, part_numbers: batch }),
    }).then(r => r.json()).then(r => r.data);
    part_urls = { ...part_urls, ...res.urls };
    return part_urls[partNum];
  }

  // 并发 PUT(前端自控并发度)
  const CONCURRENCY = 6;
  const tasks = [];
  for (let n = 1; n <= total_chunks; n++) {
    if (uploadedSetObj.has(n - 1)) continue;  // 断点续传:跳过已传
    tasks.push(n);
  }
  const results = {};
  await Promise.all(
    Array.from({ length: CONCURRENCY }, async () => {
      while (tasks.length) {
        const n = tasks.shift();
        const url = await urlFor(n);
        const start = (n - 1) * chunkSize;
        const end = Math.min(start + chunkSize, file.size);
        const blob = file.slice(start, end);
        const resp = await fetch(url, { method: 'PUT', body: blob });
        const etag = resp.headers.get('ETag');
        results[n] = etag;
        // 上报 ETag(可选——Complete 时也会带)
        fetch('/api/v1/upload/chunk/parts/report', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ upload_id, part_num: n, etag }),
        });
      }
    })
  );

  // Complete 合并
  const parts = Object.keys(results).map(n => ({ part_num: +n, etag: results[n] })).sort((a, b) => a.part_num - b.part_num);
  return fetch('/api/v1/upload/chunk/complete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ upload_id, parts }),
  }).then(r => r.json()).then(r => r.data);
}

// ——————— 代理路径(沿用旧协议) ———————
async function uploadViaProxy(file, upload_id, total_chunks, uploadedSet) {
  // ... 参见 "分片断点续传(JavaScript)" 节
}
前端直传云存储(JavaScript)
async function directUpload(file) {
  // 第一步:从后端获取 STS 临时凭证
  const tokenRes = await fetch('/api/v1/upload/direct/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      file_name: file.name,
      file_size: file.size,
    }),
  }).then(r => r.json());

  const { access_key_id, access_key_secret, security_token, endpoint, bucket, key } = tokenRes.data;

  // 第二步:直传到云存储(以阿里云 OSS 为例)
  const ossUrl = `https://${bucket}.${endpoint}/${key}`;
  await fetch(ossUrl, {
    method: 'PUT',
    headers: {
      'Authorization': '...', // 使用 STS 凭证签名(建议使用阿里云 OSS JS SDK)
      'x-oss-security-token': security_token,
    },
    body: file,
  });

  // 第三步:通知后端上传完成(v1.4.0 起 key_prefix 必填,值取自 token 响应)
  const completeRes = await fetch('/api/v1/upload/direct/complete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ key, key_prefix: tokenData.key_prefix }),
  }).then(r => r.json());

  console.log('直传完成:', completeRes.data);
  return completeRes.data;
}

分片上传流程

前端                                  后端
 |                                     |
 |-- POST /chunk/init --------------->|  创建会话,返回 upload_id
 |<-- { upload_id, total_chunks } ----|
 |                                     |
 |-- POST /chunk(并发 x N)---------->|  流式写入每个分片到磁盘
 |   { upload_id, chunk_index, data } |  校验分片哈希
 |<-- { uploaded_count } -------------|  标记分片已上传(幂等)
 |                                     |
 |   (网络中断、应用重启)              |
 |                                     |
 |-- GET /chunk/status/:id ---------->|  返回已上传和缺失的分片列表
 |<-- { missing_chunks: [4,5,6] } ----|
 |                                     |
 |-- POST /chunk(仅缺失分片)-------->|  只补传断开的部分
 |                                     |
 |-- POST /chunk/merge/:id ---------->|  流式合并所有分片
 |                                     |  校验最终文件哈希
 |<-- { storage_key, checksum } ------|  清理临时数据

前端直传流程

前端                                后端                        云存储
 |                                   |                            |
 |-- POST /direct/token ----------->|                            |
 |   { file_name, file_size }       | 生成 keyPrefix + key       |
 |                                   | 调用 STSProvider 签发凭证  |
 |<-- { credential, key } ----------|                            |
 |                                   |                            |
 |-- PUT 直传到云存储 ------------------------------------------>|
 |   使用 STS 临时凭证签名            |                            |
 |<-- 200 OK ---------------------------------------------------|
 |                                   |                            |
 |-- POST /direct/complete -------->|                            |
 |   { key }                         | 验证文件存在               |
 |                                   | 触发 onDirectUploadComplete|
 |<-- { key, etag } ----------------|                            |

项目结构

gouploader/                       主 module (零云 SDK 依赖)
├── gouploader.go                 # 入口:Uploader + New() + RegisterRoutes()
├── options.go                  # 函数式选项:WithXxx()
├── storage.go                  # Storage 接口(公共类型)
├── model.go                    # 请求/响应类型(公共类型)
├── errors.go                   # 错误码(公共类型)
├── cors.go                     # CORS 中间件
├── docs/
│   └── ARCHITECTURE.md         # 架构设计 + 生产级维度
├── internal/
│   ├── types/
│   │   ├── storage.go          # Storage 接口
│   │   ├── multipart.go        # MultipartStorage 扩展接口
│   │   ├── hashstore.go        # HashStore 接口(秒传)
│   │   ├── errors.go           # 错误类型 + 错误码
│   │   ├── model.go            # 数据传输对象
│   │   ├── sts.go              # STS 凭证 & Provider
│   │   └── sanitize.go         # 目录穿越防护
│   ├── hash/                   # 流式 MD5/SHA256 计算
│   ├── response/               # 统一 JSON 响应
│   ├── chunk/
│   │   ├── store.go            # ChunkStore 接口 + HashIndex 扩展接口
│   │   └── filestore.go        # 文件系统实现
│   ├── store/
│   │   ├── local.go            # 本地磁盘存储
│   │   └── s3.go               # S3 兼容存储(MinIO / AWS)
│   ├── service/
│   │   ├── upload.go           # 普通上传业务逻辑
│   │   ├── chunk_upload.go     # 分片上传(自动分流 proxy / multipart)
│   │   ├── direct_upload.go    # STS 直传业务逻辑
│   │   └── orphan_cleaner.go   # 悬挂 Multipart 清理 worker
│   ├── handler/
│   │   ├── upload.go           # 普通上传 Gin 处理器
│   │   ├── chunk_upload.go     # 分片上传 Gin 处理器
│   │   └── direct_upload.go    # STS 直传 Gin 处理器
│   └── sts/                    # 各家 STS Provider 实现
│       ├── aliyun.go / tencent.go / huawei.go / aws.go
│
# —— 以下为独立子 module,按需引入 ——
├── aliyun/                       独立 module: github.com/gtkit/gouploader/aliyun
│   ├── go.mod                  # 依赖 aliyun-oss-go-sdk(用户开通后再拉)
│   └── aliyun.go               # Storage + MultipartStorage 实现
├── tencent/                      独立 module: github.com/gtkit/gouploader/tencent
└── huawei/                       独立 module: github.com/gtkit/gouploader/huawei

依赖隔离:只用阿里云时,go.mod 只会拉 aliyun-oss-go-sdk,其他云 SDK 不进依赖图。

设计决策

内存安全
  • 所有 Handler 使用 multipart.NewReader() 而非 c.FormFile(),避免 Gin 默认的 32MB 内存缓冲
  • io.TeeReader 在流式传输的同时计算哈希——无需二次读取
  • io.LimitReader 在 I/O 层面强制文件/分片大小限制
  • io.MultiReader 合并分片时不将其加载到内存
  • 表单字段读取限制为 1KB,防止内存耗尽攻击
并发安全
  • 每个 Sessionsync.Mutex 保护元数据更新(亚毫秒级持锁)
  • sync.Map 缓存活跃会话,避免每次分片上传都读磁盘
  • 分片数据文件按索引命名——不同分片的并行写入互不冲突
  • 唯一临时文件名(通过 atomic.Int64)防止同一分片的覆盖竞态
  • 分片上传幂等——重复上传已存在的分片直接返回成功
可靠性
  • 所有文件写入使用 写临时文件 + fsync + rename 实现原子性
  • 合并操作幂等——失败重试返回相同结果
  • 合并失败时保留会话,分片不会丢失
  • 过期会话由后台协程自动清理

生命周期钩子

基于"库只暴露事件、业务自己决定怎么用"的原则,提供 5 个钩子覆盖全部关键事件。你可以基于这些钩子自行接入 Prometheus / 审计库 / 告警通道,库本身不依赖任何监控框架

钩子触发表
钩子 触发时机 Payload
OnUploadComplete 普通上传 / 代理合并 / Multipart 合并 成功后 *UploadResult
OnUploadInit 会话初始化成功后(proxy 或 multipart);秒传命中时不触发 *UploadInitEvent
OnUploadError 任一阶段失败(init / part / merge / complete / abort) *UploadErrorEvent(含 Stage
OnUploadAbort 客户端 DELETE /chunk/:id 清理完 session 后 *UploadAbortEvent
OnInstantHit HashStore 命中、秒传成功 *InstantHitEvent
Stage 常量(用于 UploadErrorEvent.Stage
常量 对应阶段
StageInit 会话初始化(参数校验 / 云端 InitMultipart)
StagePart 分片上传(代理 POST 或直传 Report ETag)
StageMerge 代理合并(本地分片 → 最终对象)
StageComplete 直传 Complete(云端 CompleteMultipart)
StageAbort 中止阶段(云端 Abort 失败)
使用示例
uploader, _ := gouploader.New(
    gouploader.WithStorage(storage),

    // 会话初始化 → 审计埋点
    gouploader.WithOnUploadInit(func(ctx context.Context, ev *gouploader.UploadInitEvent) {
        audit.Record(ctx, "upload_init", ev.UserID, ev.UploadID, ev.FileSize)
    }),

    // 失败 → Prometheus counter
    gouploader.WithOnUploadError(func(ctx context.Context, ev *gouploader.UploadErrorEvent) {
        metrics.UploadErrors.
            WithLabelValues(ev.Stage, ev.UserID).
            Inc()
    }),

    // 中止 → 回收业务配额
    gouploader.WithOnUploadAbort(func(ctx context.Context, ev *gouploader.UploadAbortEvent) {
        quota.Refund(ctx, ev.UserID, ev.UploadID)
    }),

    // 秒传命中 → 流量节省统计
    gouploader.WithOnInstantHit(func(ctx context.Context, ev *gouploader.InstantHitEvent) {
        metrics.BytesSavedByInstant.Add(float64(ev.FileSize))
    }),

    // 成功 → 存文件元数据
    gouploader.WithOnUploadComplete(func(r *gouploader.UploadResult) {
        db.SaveFile(r)
    }),
)
关键约束
  • 同步调用:钩子返回后主流程才继续。要异步处理请在钩子内 go func(){...}()
  • 要轻量:钩子里做阻塞 I/O 会拖慢上传 RT
  • Panic 会被隔离:钩子内 panic 被 recover + 打 Error 日志,不影响上传。但别依赖这个兜底——发现 钩子 panic 已被隔离 日志要马上修
  • 可选:不配置任何钩子时行为等价于未引入钩子机制前的版本

上传完成 URL 回显

业务方注入 WithPublicURLBuilder 后,所有上传完成路径返回的 result(包括钩子 payload 与 HTTP JSON 响应)的 URL 字段会被自动填充。

覆盖路径:

HTTP 说明 字段
POST / 普通上传 data.url
POST /chunk/merge/:upload_id 代理分片合并 data.url
POST /chunk/complete Multipart 直传合并 data.url
POST /chunk/init 秒传命中(Instant=true 时) data.url
POST /chunk/resume 秒传命中 data.url
POST /direct/complete STS 直传完成 data.url
POST /direct/callback 云存储回调 data.url
gouploader.New(
    gouploader.WithStorage(storage),
    // 简单 CDN 前缀拼接
    gouploader.WithPublicURLBuilder(func(key string) string {
        return "https://static.example.com/" + key
    }),
)

私有 bucket 临时签 URL:库实现了 SignedURLStorage 可选扩展接口,三家云 adapter + S3Storage 都已实现。LocalStorage 不实现(本地文件无"签名 URL"语义)。

gouploader.WithPublicURLBuilder(func(key string) string {
    if su, ok := storage.(gouploader.SignedURLStorage); ok {
        url, err := su.GetSignedGetURL(context.Background(), key, 15*time.Minute)
        if err == nil {
            return url
        }
    }
    return ""  // 降级:不回显 URL
})

接口签名:

type SignedURLStorage interface {
    Storage
    GetSignedGetURL(ctx context.Context, key string, ttl time.Duration) (string, error)
}

实现细节:

  • 三家云 + S3 都使用本地 HMAC 计算(无网络调用),性能适合按请求生成
  • 返回 URL 含敏感签名参数,不应持久化存储到 DB——每次需要时重新签名
  • 配置 CDNHost 时签名 URL 会自动用 CDN 域名替换原始云端域名
  • TTL 上限因厂商而异(普遍 7 天,建议 ≤ 1 小时)
  • 阿里云 OSS Go SDK V1 限制:ctx 取消不真正生效(后续 SDK V2 升级解决)
  • 入参校验:ttl <= 0key == "" / 含 \x00 \r \n 直接拒绝,不发起云 API 调用

⚠️ CDN 签名一致性:签名 URL 是基于原始 endpoint host 计算的。若 CDNHost 配的 CDN 域名未在云厂商控制台绑定为该 bucket 的加速域名,浏览器请求 CDN 会因签名 host 不匹配返回 403 SignatureMismatch。配置 CDN 前务必在云控制台完成域名绑定。

不配置时所有 result.URL 字段为空字符串,JSON 因 omitempty 不输出,行为等价于未引入此特性的旧版本。

builder 内 panic 被 recover 隔离,不影响主流程;但 URL 字段会为空,并打 Error 日志 PublicURLBuilder panic 已隔离

生产部署须知

上线前请逐项确认:

🔴 会话所有权授权

本包通过 WithUserIDFn 自动启用会话所有权校验。启用后,以下端点在请求身份 ≠ session 创建者时返回 HTTP 403 + CodeUnauthorized (50008)

  • POST /chunk · GET /chunk/status/:id · POST /chunk/merge/:id
  • POST /chunk/complete · POST /chunk/parts/sign · POST /chunk/parts/report
  • DELETE /chunk/:id
gouploader.WithUserIDFn(func(c *gin.Context) string {
    return c.GetString("user_id")  // 由业务中间件注入
})

不配置 WithUserIDFn 等于关闭授权保护。对外服务务必配置。

🔴 云存储回调验签

POST /direct/callback 端点接收云厂商上报的"上传完成"回调。未启用验签时,攻击者可伪造回调写入任意元数据——必须按以下方式启用:

厂商 算法 启用方式
阿里云 OSS RSA + MD5 PKCS1v15 调用 WithAliyunSTS(cfg) 即自动启用,公钥 URL 严格校验白名单
华为云 OBS HMAC-SHA256(密钥 SecretAccessKey 调用 WithHuaweiSTS(cfg) 即自动启用
腾讯云 COS Token + IP 白名单(COS 无 RSA 链路) WithTencentSTS(cfg) 时必须配置 cfg.CallbackToken(≥32 字节随机)

腾讯云配置示例:

gouploader.WithTencentSTS(gouploader.TencentSTSConfig{
    SecretId:           "AKID...",
    SecretKey:          "...",
    Region:             "ap-guangzhou",
    Bucket:             "my-bucket-1250000000",
    AppId:              "1250000000",
    // 必须:用足够熵的随机串作为回调共享密钥
    CallbackToken:      "your-32+-byte-random-token",
    // 可选:进一步限制回调来源 IP / CIDR
    CallbackAllowedIPs: []string{"43.128.0.0/16"},
})

通过控制台配置 COS 回调时,把 CallbackToken 透传到回调 body 模板的 callback_token 字段(或通过 X-Callback-Token 请求头),服务端 constant-time 比对。

回调消息体统一使用以下 JSON 格式(在云厂商控制台的回调模板里配置):

{"bucket":"${bucket}","object":"${object}","size":${size},"etag":${etag}}

objectkey 字段任一非空即可。

🔴 单实例部署限制

默认 FileStore 基于本地文件系统,不支持多实例水平扩展:分片 session 存在本地磁盘,节点间不共享。

多实例场景(两种方案):

  1. 粘性路由(最简单):负载均衡按 upload_id 一致性哈希,同一 session 始终路由到同一节点
  2. 注入分布式 Store(更弹性):实现 chunk.Store 接口对接 Redis / etcd,通过 WithStorage 相关机制注入

单实例环境(如 K8s 单 Pod、单机部署)可直接用默认 FileStore。

🔴 AWS S3 的 chunkSize 要求

AWS S3 Multipart 规则:最小分片 5 MiB(最后一片除外)。本包默认 ChunkMinSize=1MB 对 AWS S3 不够,用户选 AWS 时必须显式配置:

gouploader.WithChunkSize(5<<20, 5<<20, 100<<20) // 默认/最小/最大 = 5/5/100 MiB

阿里 OSS(100KB)/腾讯 COS(1MB)/华为 OBS(100KB)默认配置可用,但建议至少 5MB 以减少分片数。

🟡 钩子的 Context 生命周期

钩子回调收到的 ctx 是 HTTP 请求的 ctx请求结束后立即 cancel。钩子内若需异步处理(DB 写入、发 MQ):

gouploader.WithOnUploadComplete(func(r *gouploader.UploadResult) {
    go func() {
        // ❌ 错误:主请求已结束,ctx 已 cancel
        // db.Save(origCtx, r)

        // ✅ 正确:新建独立 ctx
        bgCtx := context.Background()
        db.Save(bgCtx, r)
    }()
})

Go 1.21+ 还可以用 context.WithoutCancel(origCtx) 保留 trace_id 等 value 但移除 cancel 信号。

🔴 HashStore 多租户隔离

v1.4.0 安全修复:v1.3.0 之前的 HashStore.LookupByHash(ctx, algo, hash) 接口不含 userID 维度——攻击者凭知道的文件 hash 可命中其他用户的私有文件 storage_key(绕过会话所有权校验)。

多租户私有 bucket 场景必须实现 UserScopedHashStore 接口

type UserScopedHashStore interface {
    HashStore  // 继承
    LookupByHashForUser(ctx context.Context, userID, algorithm, hash string) (*HashedFile, error)
    RegisterForUser(ctx context.Context, userID string, entry *HashedFile) error
}

// 完整 GORM 实现示例:
type dbHashStore struct{ db *gorm.DB }

func (s *dbHashStore) LookupByHashForUser(ctx context.Context, userID, algo, hash string) (*types.HashedFile, error) {
    var row hashIndexRow
    err := s.db.WithContext(ctx).
        Where("user_id = ? AND algorithm = ? AND hash = ?", userID, algo, hash).
        First(&row).Error
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }
    return &types.HashedFile{Algorithm: row.Algo, Hash: row.Hash, StorageKey: row.Key, FileSize: row.Size}, nil
}

func (s *dbHashStore) RegisterForUser(ctx context.Context, userID string, e *types.HashedFile) error {
    return s.db.WithContext(ctx).Create(&hashIndexRow{
        UserID: userID, Algo: e.Algorithm, Hash: e.Hash, Key: e.StorageKey, Size: e.FileSize,
    }).Error
}

// 旧接口仍保留以满足 type assertion;通常代理到 ForUser 版本(userID="" 视为全局)
func (s *dbHashStore) LookupByHash(ctx context.Context, algo, hash string) (*types.HashedFile, error) {
    return s.LookupByHashForUser(ctx, "", algo, hash)
}
func (s *dbHashStore) Register(ctx context.Context, e *types.HashedFile) error {
    return s.RegisterForUser(ctx, "", e)
}

service 层会通过 type assertion 自动选择:实现 UserScopedHashStore 就走用户隔离查询,否则回退旧接口(伴随 Warn 日志)。

多租户场景完整配置(v1.4.0 推荐组合,缺一不可):

secret := []byte(os.Getenv("UPLOAD_KEY_PREFIX_SECRET")) // ≥ 32 字节随机
uploader, _ := gouploader.New(
    gouploader.WithStorage(storage),
    gouploader.WithHashStore(myUserScopedHashStore), // 实现 UserScopedHashStore
    gouploader.WithUserIDFn(func(c *gin.Context) string {
        return c.GetString("user_id") // 业务中间件注入
    }),
    gouploader.WithRequireUserID(),              // 拒绝匿名秒传查询
    gouploader.WithMultipartKeyPrefix(func(c *gin.Context, userID string) string {
        return fmt.Sprintf("user-%s/%s/", userID, time.Now().Format("2006-01-02"))
    }),                                          // KeyPrefix 编入 userID 双重隔离
    gouploader.WithKeyPrefixSigner(secret),      // KeyPrefix HMAC 签名防伪造
    gouploader.WithAliyunSTS(...),
)

四道防线协同工作:

防线 阻断的攻击
WithUserIDFn + UserScopedHashStore B 凭 hash 命中 A 的私有文件秒传索引
WithRequireUserID 匿名用户共享 "anon" 虚拟身份的秒传索引
keyPrefix 编入 userID B 拿不到 A 的 key_prefix 路径前缀
WithKeyPrefixSigner B 凭猜测构造他人 key_prefix 调 Complete

任何一项缺失都会让攻击面回归到 v1.3.0 的越权场景。

通用约束

  • 返回准确的 FileSize — 不能返回 0 或估算值(库内置 fileSize 一致性校验防 hash 冲突攻击)
  • 对象删除后及时同步删除索引条目 — 防止返回已被删除的 StorageKey
🟢 悬挂 Multipart 清理

应用内已有 WithOrphanCleanup但仍建议在云 Bucket 层面配置生命周期规则兜底(应用挂了时兜底)。参见各子 module 的 README。

优雅关闭

使用完毕后必须调用 Close() 以停止后台清理协程(包括悬挂 Multipart 清理 worker):

uploader, _ := gouploader.New(...)
defer uploader.Close()

生产级落地清单

本包在设计上覆盖以下六大维度的生产级考量(🔴 必做 / 🟡 应做 / 🟢 可选)。完整说明见 docs/ARCHITECTURE.md

可靠性
  • 🔴 秒传:整文件哈希索引(WithHashStore
  • 🔴 断点续传/chunk/resume 按 hash 找回未完成会话
  • 🔴 悬挂 Multipart 清理WithOrphanCleanup + Bucket 生命周期规则双保险
  • 🟡 Complete 二次校验:调云端 ListParts 比对客户端上报 ETag,防伪造
  • 🟡 失败可恢复:合并失败不删 session,支持重试
安全
  • 🔴 预签名 URL 参数锁死:签名时固定 partNum + Content-Length(各 adapter 实现)
  • 🔴 按需签名/chunk/parts/sign 滑动窗口签发,降低 URL 泄漏影响
  • 🔴 keyPrefix 强隔离WithUserIDFn + WithMultipartKeyPrefix 按用户隔离对象键
  • 🟡 MIME sniffing:Complete 后可在业务回调中跑 http.DetectContentType
  • 🟡 病毒扫描 hookOnUploadComplete 中异步触发扫描
性能
  • 🟡 客户端自适应 partSize/chunk/init 返回 recommend_concurrency,前端测速后调整
  • 🟡 并发窗口:前端 PUT 限流(默认 6)
  • 🟢 单 part 失败重试:前端重签 URL + 指数退避
可观测性
  • 🔴 Prometheus metrics 接入点:service/chunk_upload.go 关键路径都有结构化日志
  • 🔴 结构化日志:zap with upload_id / cloud_upload_id / key
  • 🟡 审计日志:通过 WithOnUploadComplete 钩子落独立 audit 表
  • 🟡 慢请求告警:单 part > 10s 打告警
成本
  • 🔴 悬挂清理双保险:应用 worker + Bucket 生命周期规则
  • 🟡 秒传命中率监控:HashStore.LookupByHash 返回值可埋点
UX & 运维
  • 🔴 Feature Flag 灰度WithMultipartFeatureFlag 按请求分流
  • 🟡 接口版本化预留:主包 Version 常量;路由可在 group prefix 加 /v1/
  • 🟢 WebSocket 进度推送:OnUploadComplete 中主动推

版本与迭代

当前版本已完成 M1(最小可用生产版)的全部核心工作:

  • ✅ 主 module:接口/协议/调度/灰度/秒传/清理 worker 全部就绪
  • aliyun/:完整对接 aliyun-oss-go-sdk,Storage + MultipartStorage + 单元测试 + E2E
  • tencent/:完整对接 cos-go-sdk-v5,Storage + MultipartStorage + 单元测试 + E2E
  • huawei/:完整对接 huaweicloud-sdk-go-obs,Storage + MultipartStorage + 单元测试 + E2E

三家 adapter 使用 SDK 自家签名(非 minio-go 的 S3 V4 通用层),解决了 OBS 部分 region 签名不兼容、OSS Callback 丢失、COS 元数据 header 被改写等已知兼容性问题。

下一步 M2(可选增强):Prometheus metrics 接入、审计日志落库、MIME sniffing、跨厂商迁移工具等。详细路线图见 docs/ARCHITECTURE.md 第 6 节

各 adapter 的 E2E 验证
# 阿里云
OSS_ENDPOINT=... OSS_BUCKET=... OSS_AK=... OSS_SK=... \
    go test -v -run TestIntegration ./aliyun/...

# 腾讯云
COS_BUCKET_URL=... COS_SECRET_ID=... COS_SECRET_KEY=... \
    go test -v -run TestIntegration ./tencent/...

# 华为云
OBS_ENDPOINT=... OBS_BUCKET=... OBS_AK=... OBS_SK=... \
    go test -v -run TestIntegration ./huawei/...

许可证

MIT

Documentation

Overview

Package gouploader 提供生产级文件上传工具包,适用于 Gin 框架。

功能特性:

  • 普通单文件上传(流式传输,内存恒定)
  • 分片上传,支持断点续传
  • 秒传(基于文件哈希索引,需注入 HashStore)
  • 云原生 Multipart 直传(需使用 aliyun/tencent/huawei 子 module 的 Storage 实现)
  • STS 前端直传(客户端使用云厂商 JS SDK 直连云端)
  • 可插拔存储:本地磁盘、S3 兼容(MinIO/AWS S3)、各云厂商(独立子 module)
  • 文件完整性校验(MD5/SHA256)、并发安全、流式 I/O

快速开始(代理路径,最简):

uploader, err := gouploader.New(
    gouploader.WithLocalStorage("./uploads"),
)
uploader.RegisterRoutes(r.Group("/api/v1/upload"))

生产场景(Multipart 直传 + 秒传 + 灰度):

import "github.com/gtkit/gouploader/aliyun"

ossStorage, _ := aliyun.NewStorage(aliyun.Config{...})
uploader, _ := gouploader.New(
    gouploader.WithStorage(ossStorage),
    gouploader.WithHashStore(myHashStore),
    gouploader.WithMultipartThreshold(100 << 20),
    gouploader.WithMultipartFeatureFlag(gradualRollout),
    gouploader.WithOrphanCleanup(24*time.Hour, 1*time.Hour),
)
Example (Basic)

Example_basic demonstrates the simplest usage with local storage.

package main

import (
	"fmt"
	"log"

	"github.com/gin-gonic/gin"
	"github.com/gtkit/gouploader"
)

func main() {
	r := gin.Default()

	uploader, err := gouploader.New(
		gouploader.WithLocalStorage("./uploads"),
	)
	if err != nil {
		log.Fatal(err)
	}

	uploader.RegisterRoutes(r.Group("/api/v1/upload"))
	defer uploader.Close()
	// r.Run(":8080")
	fmt.Println("routes registered")
}
Output:
routes registered
Example (CustomRoutes)

Example_customRoutes demonstrates registering routes individually with custom middleware.

package main

import (
	"fmt"
	"log"

	"github.com/gin-gonic/gin"
	"github.com/gtkit/gouploader"
)

func main() {
	r := gin.Default()

	uploader, err := gouploader.New(
		gouploader.WithLocalStorage("./uploads"),
	)
	if err != nil {
		log.Fatal(err)
	}

	// Register routes individually with custom middleware.
	auth := func(c *gin.Context) { c.Next() } // your auth middleware

	v1 := r.Group("/api/v1", auth)
	{
		v1.POST("/upload", uploader.HandleUpload())
		v1.POST("/upload/chunk/init", uploader.HandleChunkInit())
		v1.POST("/upload/chunk", uploader.HandleChunkUpload())
		v1.GET("/upload/chunk/status/:upload_id", uploader.HandleChunkStatus())
		v1.POST("/upload/chunk/merge/:upload_id", uploader.HandleChunkMerge())
		v1.DELETE("/upload/chunk/:upload_id", uploader.HandleChunkAbort())
	}

	fmt.Println("custom routes registered")
}
Output:
custom routes registered
Example (S3)

Example_s3 demonstrates using S3-compatible storage (MinIO/阿里云/腾讯云/华为云).

package main

import (
	"fmt"
	"log"

	"github.com/gin-gonic/gin"
	"github.com/gtkit/gouploader"
)

func main() {
	r := gin.Default()

	uploader, err := gouploader.New(
		gouploader.WithS3Storage(gouploader.S3Config{
			// MinIO:
			//   Endpoint: "127.0.0.1:9000",
			// 阿里云 OSS:
			//   Endpoint: "oss-cn-hangzhou.aliyuncs.com",
			// 腾讯云 COS:
			//   Endpoint: "cos.ap-guangzhou.myqcloud.com",
			// 华为云 OBS:
			//   Endpoint: "obs.cn-north-4.myhuaweicloud.com",
			Endpoint:  "127.0.0.1:9000",
			AccessKey: "minioadmin",
			SecretKey: "minioadmin",
			Bucket:    "uploads",
			Region:    "us-east-1",
			UseSSL:    false,
		}),
		gouploader.WithMaxFileSize(10<<30), // 10GB
		gouploader.WithChunkSize(
			10<<20, // default: 10MB
			1<<20,  // min: 1MB
			50<<20, // max: 50MB
		),
	)
	if err != nil {
		log.Fatal(err)
	}

	uploader.RegisterRoutes(r.Group("/api/v1/upload"))
	fmt.Println("s3 routes registered")
}
Example (WithCallback)

Example_withCallback demonstrates how to handle post-upload business logic.

package main

import (
	"fmt"
	"log"

	"github.com/gin-gonic/gin"
	"github.com/gtkit/gouploader"
)

func main() {
	r := gin.Default()

	uploader, err := gouploader.New(
		gouploader.WithLocalStorage("./uploads"),
		gouploader.WithMaxFileSize(2<<30), // 2GB
		gouploader.WithAllowedTypes(".jpg", ".png", ".mp4", ".pdf"),
		gouploader.WithHashAlgorithm("sha256"),
		gouploader.WithOnUploadComplete(func(result *gouploader.UploadResult) {
			// This callback fires after every successful upload.
			// Use it to save metadata to your database, send notifications, etc.
			log.Printf("file uploaded: %s -> %s (checksum: %s)",
				result.FileName, result.StorageKey, result.Checksum)
		}),
	)
	if err != nil {
		log.Fatal(err)
	}

	uploader.RegisterRoutes(r.Group("/api/v1/upload"))
	defer uploader.Close()
	// r.Run(":8080")
	fmt.Println("routes registered")
}
Output:
routes registered

Index

Examples

Constants

View Source
const (
	CodeFileTooLarge     = types.CodeFileTooLarge     // 文件超过大小限制
	CodeFileTypeNotAllow = types.CodeFileTypeNotAllow // 文件类型不允许
	CodeChecksumMismatch = types.CodeChecksumMismatch // 校验和不匹配
	CodeFileReadError    = types.CodeFileReadError    // 文件读取错误
	CodeInvalidParam     = types.CodeInvalidParam     // 参数无效
	CodeMultipartError   = types.CodeMultipartError   // multipart 解析错误

	CodeSessionNotFound    = types.CodeSessionNotFound    // 上传会话不存在
	CodeSessionExpired     = types.CodeSessionExpired     // 上传会话已过期
	CodeChunkIndexOutRange = types.CodeChunkIndexOutRange // 分片索引越界
	CodeChunkDuplicate     = types.CodeChunkDuplicate     // 分片重复上传
	CodeChunkHashMismatch  = types.CodeChunkHashMismatch  // 分片校验和不匹配
	CodeMergeIncomplete    = types.CodeMergeIncomplete    // 分片未全部上传
	CodeMergeHashFailed    = types.CodeMergeHashFailed    // 合并后校验失败

	CodeStorageWriteFail  = types.CodeStorageWriteFail  // 存储写入失败
	CodeStorageReadFail   = types.CodeStorageReadFail   // 存储读取失败
	CodeStorageDeleteFail = types.CodeStorageDeleteFail // 存储删除失败

	CodeSTSNotConfigured   = types.CodeSTSNotConfigured   // STS 未配置
	CodeSTSIssueFailed     = types.CodeSTSIssueFailed     // STS 凭证签发失败
	CodeCallbackVerifyFail = types.CodeCallbackVerifyFail // 回调验签失败
	CodeDirectFileNotFound = types.CodeDirectFileNotFound // 直传文件不存在

	CodeMultipartNotSupported = types.CodeMultipartNotSupported // 当前存储后端不支持 Multipart 直传
	CodeMultipartInitFailed   = types.CodeMultipartInitFailed   // 云端 Multipart 初始化失败
	CodeMultipartPresignFail  = types.CodeMultipartPresignFail  // 预签名 URL 生成失败
	CodeMultipartCompleteFail = types.CodeMultipartCompleteFail // 云端 Multipart 合并失败
	CodeMultipartAbortFail    = types.CodeMultipartAbortFail    // 云端 Multipart 中止失败
	CodePartVerifyFail        = types.CodePartVerifyFail        // 服务端二次校验 parts 失败
	CodePartETagMismatch      = types.CodePartETagMismatch      // 客户端上报 ETag 与云端不一致
	CodeUnauthorized          = types.CodeUnauthorized          // 无权访问该会话
)

错误码常量。

View Source
const (
	StageInit     = types.StageInit     // 会话初始化阶段
	StagePart     = types.StagePart     // 分片上传阶段
	StageMerge    = types.StageMerge    // 代理路径合并阶段
	StageComplete = types.StageComplete // Multipart 直传 Complete 阶段
	StageAbort    = types.StageAbort    // 中止阶段
)

Stage 常量:UploadErrorEvent.Stage 字段的合法取值。 这些字符串值可直接作为 Prometheus label 使用。

View Source
const (
	AbortReasonClientRequest = types.AbortReasonClientRequest
)

中止原因常量:UploadAbortEvent.Reason 字段的取值。

View Source
const Version = "v1.4.0"

Variables

View Source
var (
	ErrFileTooLarge     = types.ErrFileTooLarge
	ErrFileTypeNotAllow = types.ErrFileTypeNotAllow
	ErrChecksumMismatch = types.ErrChecksumMismatch
	ErrInvalidParam     = types.ErrInvalidParam
	ErrMultipartError   = types.ErrMultipartError

	ErrSessionNotFound    = types.ErrSessionNotFound
	ErrSessionExpired     = types.ErrSessionExpired
	ErrChunkIndexOutRange = types.ErrChunkIndexOutRange
	ErrChunkHashMismatch  = types.ErrChunkHashMismatch
	ErrMergeIncomplete    = types.ErrMergeIncomplete
	ErrMergeHashFailed    = types.ErrMergeHashFailed

	ErrStorageWrite  = types.ErrStorageWrite
	ErrStorageRead   = types.ErrStorageRead
	ErrStorageDelete = types.ErrStorageDelete

	ErrSTSNotConfigured   = types.ErrSTSNotConfigured
	ErrSTSIssueFailed     = types.ErrSTSIssueFailed
	ErrCallbackVerifyFail = types.ErrCallbackVerifyFail
	ErrDirectFileNotFound = types.ErrDirectFileNotFound

	ErrMultipartNotSupported = types.ErrMultipartNotSupported
	ErrMultipartInitFailed   = types.ErrMultipartInitFailed
	ErrMultipartPresignFail  = types.ErrMultipartPresignFail
	ErrMultipartCompleteFail = types.ErrMultipartCompleteFail
	ErrMultipartAbortFail    = types.ErrMultipartAbortFail
	ErrPartVerifyFail        = types.ErrPartVerifyFail
	ErrPartETagMismatch      = types.ErrPartETagMismatch
	ErrUnauthorized          = types.ErrUnauthorized
)

预定义错误。

Functions

func CORS

func CORS(cfg CORSConfig) gin.HandlerFunc

CORS 返回处理跨域请求的 Gin 中间件。

用法:

group := r.Group("/api/v1/upload")
group.Use(gouploader.CORS(gouploader.CORSConfig{
    AllowOrigins: []string{"https://example.com"},
}))
uploader.RegisterRoutes(group)

Types

type AWSSTSConfig

type AWSSTSConfig = sts.AWSConfig

AWSSTSConfig AWS S3 STS 配置。

type AliyunSTSConfig

type AliyunSTSConfig = sts.AliyunConfig

AliyunSTSConfig 阿里云 OSS STS 配置。

type CORSConfig

type CORSConfig struct {
	// AllowOrigins 允许访问的源列表。
	// 使用 "*" 允许所有源(不建议生产环境使用)。
	AllowOrigins []string

	// AllowHeaders 客户端可以发送的额外请求头。
	// 默认请求头(Content-Type, X-Request-ID)始终包含。
	AllowHeaders []string

	// ExposeHeaders 客户端可以读取的额外响应头。
	ExposeHeaders []string

	// AllowCredentials 是否允许携带凭据(Cookie 等)。
	AllowCredentials bool

	// MaxAge 预检请求结果的缓存时间(秒)。默认 86400(24小时)。
	MaxAge int
}

CORSConfig 跨域资源共享配置。

type ChunkCompleteRequest added in v1.2.0

type ChunkCompleteRequest = types.ChunkCompleteRequest

ChunkCompleteRequest Multipart 直传路径的合并请求体。

type ChunkInitRequest

type ChunkInitRequest = types.ChunkInitRequest

ChunkInitRequest 初始化分片上传的 JSON 请求体。

type ChunkInitResponse

type ChunkInitResponse = types.ChunkInitResponse

ChunkInitResponse 分片上传会话创建后的响应。

type ChunkStatusResponse

type ChunkStatusResponse = types.ChunkStatusResponse

ChunkStatusResponse 查询分片上传状态的响应。

type ChunkUploadResponse

type ChunkUploadResponse = types.ChunkUploadResponse

ChunkUploadResponse 单个分片上传后的响应。

type CompleteResult added in v1.2.0

type CompleteResult = types.CompleteResult

CompleteResult Multipart 合并结果。

type DirectCompleteRequest

type DirectCompleteRequest = types.DirectCompleteRequest

DirectCompleteRequest 前端确认上传完成的请求体。

type DirectTokenRequest

type DirectTokenRequest = types.DirectTokenRequest

DirectTokenRequest 请求 STS 凭证的请求体。

type DirectTokenResponse

type DirectTokenResponse = types.DirectTokenResponse

DirectTokenResponse STS 凭证响应。

type DirectUploadResult

type DirectUploadResult = types.DirectUploadResult

DirectUploadResult 前端直传完成后的结果。

type Error

type Error = types.Error

Error 统一错误类型。

type HashStore added in v1.2.0

type HashStore = types.HashStore

HashStore 是秒传索引接口,由业务方实现(MySQL/Redis/Mongo 等)。

⚠️ 多租户私有 bucket 场景必须实现 UserScopedHashStore(v1.4.0+), 否则用户 B 凭知道的 hash 可命中 A 的私有文件构成越权访问。

type HashedFile added in v1.2.0

type HashedFile = types.HashedFile

HashedFile 秒传索引条目。

type HuaweiSTSConfig

type HuaweiSTSConfig = sts.HuaweiConfig

HuaweiSTSConfig 华为云 OBS STS 配置。

type InstantHitEvent added in v1.2.0

type InstantHitEvent = types.InstantHitEvent

InstantHitEvent 秒传命中时触发的生命周期钩子 payload。

type MultipartMeta added in v1.2.0

type MultipartMeta = types.MultipartMeta

MultipartMeta 创建 Multipart 时可设置的对象元数据。

type MultipartStorage added in v1.2.0

type MultipartStorage = types.MultipartStorage

MultipartStorage 是支持云原生 Multipart 直传的存储扩展接口。 由各云厂商 adapter 子 module 实现(aliyun / tencent / huawei)。

type Option

type Option func(*config)

Option 用于配置 Uploader。

func WithAWSSTS

func WithAWSSTS(cfg AWSSTSConfig) Option

WithAWSSTS 配置 AWS S3 STS 直传。

func WithAliyunSTS

func WithAliyunSTS(cfg AliyunSTSConfig) Option

WithAliyunSTS 配置阿里云 OSS STS 直传。

func WithAllowedTypes

func WithAllowedTypes(exts ...string) Option

WithAllowedTypes 设置允许的文件扩展名(如 ".jpg", ".png", ".mp4")。 空列表表示允许所有类型。

func WithChunkCleanupInterval

func WithChunkCleanupInterval(interval time.Duration) Option

WithChunkCleanupInterval 设置过期会话的清理间隔。

interval 必须为正数;非法值会让 New() 返回错误(v1.4.0 之前会 panic)。

func WithChunkSessionTTL

func WithChunkSessionTTL(ttl time.Duration) Option

WithChunkSessionTTL 设置分片上传会话的过期时间。

func WithChunkSize

func WithChunkSize(defaultSize, minSize, maxSize int64) Option

WithChunkSize 设置默认、最小和最大分片大小。

func WithChunkTempDir

func WithChunkTempDir(dir string) Option

WithChunkTempDir 设置分片数据的临时目录。

func WithHashAlgorithm

func WithHashAlgorithm(algo string) Option

WithHashAlgorithm 设置校验和算法("md5" 或 "sha256")。

func WithHashStore added in v1.2.0

func WithHashStore(hs HashStore) Option

WithHashStore 注入秒传索引存储。nil 时秒传功能关闭。

典型用法:

type dbHashStore struct{ db *gorm.DB }
func (s *dbHashStore) LookupByHash(ctx, algo, hash) (*HashedFile, error) { ... }
func (s *dbHashStore) Register(ctx, entry *HashedFile) error { ... }

gouploader.WithHashStore(&dbHashStore{db: db})

func WithHashStoreRegisterTimeout added in v1.3.0

func WithHashStoreRegisterTimeout(d time.Duration) Option

WithHashStoreRegisterTimeout 设置 HashStore.Register 异步注册的最大等待时长。 默认 30 秒。HashStore 实现卡死时,此超时防止后台 goroutine 无限累积。 业务方根据下游存储 SLA 自行调整(如 Redis 一般 100ms,MySQL 写入一般 1-2s)。

func WithHuaweiSTS

func WithHuaweiSTS(cfg HuaweiSTSConfig) Option

WithHuaweiSTS 配置华为云 OBS STS 直传。

func WithKeyPrefixSigner added in v1.4.0

func WithKeyPrefixSigner(secret []byte) Option

WithKeyPrefixSigner 启用 STS 直传 KeyPrefix HMAC 签名(v1.4.0+,强烈建议配置)。

配置后:

  • IssueToken 返回的 STSCredential 增加 KeyPrefixSig 字段(HMAC-SHA256 base64)
  • Complete 请求必须传 KeyPrefixSig,服务端验签通过才能 Complete

防御场景:v1.3.0 / v1.4.0-rc1 中,攻击者凭知道的 KeyPrefix(如默认日期格式公开可见) 可调 Complete 越权"确认"他人文件,触发 onComplete 钩子被记账到攻击者名下, 或拿到他人 storage_key + URL(PublicURLBuilder 拼出)。HMAC 签名让 KeyPrefix 必须由本服务进程签发才能通过 Complete。

secret 长度建议 ≥ 32 字节随机值;多实例部署时所有实例必须使用相同 secret (否则跨实例 IssueToken/Complete 会失败)。secret 不可对外泄漏。

推荐用法:

secret := []byte(os.Getenv("UPLOAD_KEY_PREFIX_SECRET")) // ≥ 32 字节
uploader, _ := gouploader.New(
    gouploader.WithKeyPrefixSigner(secret),
    gouploader.WithAliyunSTS(...),
)

不配置时退化到不签名行为(向后兼容),但 README 红色警告"多租户场景必须启用"。

func WithLocalStorage

func WithLocalStorage(basePath string) Option

WithLocalStorage 配置本地磁盘存储。

func WithMaxFileSize

func WithMaxFileSize(size int64) Option

WithMaxFileSize 设置允许的最大文件大小(字节)。

func WithMultipartDisabled added in v1.2.0

func WithMultipartDisabled() Option

WithMultipartDisabled 强制关闭 Multipart 直传路径,所有大文件都走代理合并。 用于灰度回滚或调试。默认启用(前提是 Storage 实现了 MultipartStorage 接口)。

func WithMultipartFeatureFlag added in v1.2.0

func WithMultipartFeatureFlag(fn func(ctx context.Context, ginCtx *gin.Context, req *ChunkInitRequest) bool) Option

WithMultipartFeatureFlag 注入 Feature Flag 钩子,按请求动态决定是否走 Multipart 路径。 典型用法:灰度发布 / 按用户 ID hash / 按 bucket 分流。

gouploader.WithMultipartFeatureFlag(func(ctx, ginCtx, req) bool {
    uid := ginCtx.GetString("user_id")
    return hashMod(uid) < 10  // 10% 灰度
})

func WithMultipartKeyPrefix added in v1.2.0

func WithMultipartKeyPrefix(fn func(c *gin.Context, userID string) string) Option

WithMultipartKeyPrefix 生成 Multipart 路径下的对象键前缀。 默认按日期生成 "{year}/{month}/{day}/"。

典型用法(按用户隔离):

gouploader.WithMultipartKeyPrefix(func(c, userID) string {
    return fmt.Sprintf("user-%s/%s/", userID, time.Now().Format("2006-01-02"))
})

func WithMultipartThreshold added in v1.2.0

func WithMultipartThreshold(bytes int64) Option

WithMultipartThreshold 设置走 Multipart 直传的最小文件大小(字节)。 小于此阈值的文件强制走代理路径——省掉签名开销。默认 100MB。

func WithOnDirectUploadComplete

func WithOnDirectUploadComplete(fn func(result *DirectUploadResult)) Option

WithOnDirectUploadComplete 注册直传完成后的回调。 在 Complete 或 Callback 确认文件上传成功后触发。

func WithOnInstantHit added in v1.2.0

func WithOnInstantHit(fn func(ctx context.Context, ev *InstantHitEvent)) Option

WithOnInstantHit 注册秒传命中钩子。 用于统计命中率、节省流量等业务指标。 仅在 HashStore 配置且 LookupByHash 返回非 nil 时触发。

func WithOnUploadAbort added in v1.2.0

func WithOnUploadAbort(fn func(ctx context.Context, ev *UploadAbortEvent)) Option

WithOnUploadAbort 注册上传中止钩子。 触发时机:客户端 DELETE /chunk/:id 清理完 session 后(云端 Multipart 也已 Abort)。

func WithOnUploadComplete

func WithOnUploadComplete(fn func(result *UploadResult)) Option

WithOnUploadComplete 注册上传完成后的回调函数。 每次上传成功后触发(包括普通上传和分片合并)。 可用于保存数据库、发送通知等业务逻辑。

func WithOnUploadError added in v1.2.0

func WithOnUploadError(fn func(ctx context.Context, ev *UploadErrorEvent)) Option

WithOnUploadError 注册上传阶段失败钩子。 触发阶段见 Stage 常量:StageInit / StagePart / StageMerge / StageComplete / StageAbort。

func WithOnUploadInit added in v1.2.0

func WithOnUploadInit(fn func(ctx context.Context, ev *UploadInitEvent)) Option

WithOnUploadInit 注册上传会话初始化成功钩子。 触发时机:InitiateMultipart(云端)或本地 session 创建成功后。 秒传命中时不触发此钩子,改为触发 WithOnInstantHit。

func WithOrphanCleanup added in v1.2.0

func WithOrphanCleanup(ttl, interval time.Duration) Option

WithOrphanCleanup 启用悬挂 Multipart 清理 worker。 ttl: 超过此时长未完成的 Multipart 视为悬挂,会被 Abort。默认 24h。 interval: 清理 worker 扫描间隔。默认 1h。

注意:此功能需要 Storage 实现 MultipartStorage 接口(包含 ListOrphans / AbortMultipart)。 建议同时在 bucket 层面配置 AbortIncompleteMultipartUpload 生命周期规则做双保险。

func WithPartSignTTL added in v1.2.0

func WithPartSignTTL(ttl time.Duration) Option

WithPartSignTTL 设置单个 part 预签名 URL 的有效期。默认 15 分钟。 太短会导致网络慢的客户端 URL 过期,太长会扩大 URL 泄漏的爆炸半径。

func WithPublicURLBuilder added in v1.3.0

func WithPublicURLBuilder(fn func(storageKey string) string) Option

WithPublicURLBuilder 注入"对外可见 URL"拼装策略。

配置后,所有上传完成路径返回的 result(UploadResult / DirectUploadResult)的 URL 字段会被填充为 builder(storageKey) 的输出。覆盖路径:

  • 普通上传 POST /
  • 代理分片合并 POST /chunk/merge/:upload_id
  • Multipart 直传合并 POST /chunk/complete
  • 秒传命中 POST /chunk/init
  • STS 直传 POST /direct/complete
  • STS 直传回调 POST /direct/callback

builder 应保持轻量(纯字符串拼接),避免阻塞主上传流程。 builder 内 panic 被 recover 隔离,不影响上传;但 URL 字段会为空字符串。

典型用法(CDN 前缀拼接):

gouploader.WithPublicURLBuilder(func(key string) string {
    return "https://static.example.com/" + key
})

私有 bucket 临时访问 URL 示例(业务方自行调云 SDK 签名):

gouploader.WithPublicURLBuilder(func(key string) string {
    url, _ := storage.GetSignedGetURL(context.Background(), key, 15*time.Minute)
    return url
})

不配置 / 设为 nil 时,所有 result.URL 字段为空,JSON 因 omitempty 不输出, 行为等价于未引入此特性的旧版本。

func WithRequireUserID added in v1.4.0

func WithRequireUserID() Option

WithRequireUserID 强制要求 UserIDFn 返回非空字符串,否则禁用秒传查询。

默认行为(不启用):UserIDFn 返回空字符串时退化为 "anon",匿名用户共享秒传索引。 多租户私有 bucket 场景应启用此 Option,避免匿名场景下用户 B 凭 hash 命中 用户 A 的私有文件 storage_key(即使 HashStore 实现了 UserScopedHashStore, "anon" 用户仍是一个共享的虚拟身份)。

启用后,对于 UserIDFn 返回空字符串的请求:

  • InitUpload 秒传查询路径直接跳过(返回未命中,走正常上传流程)
  • Resume 秒传查询路径同上

推荐多租户业务方一律启用:

gouploader.WithUserIDFn(func(c *gin.Context) string {
    if user, ok := c.Get("user_id"); ok { return user.(string) }
    return ""  // 未登录返回空
}),
gouploader.WithRequireUserID(),

func WithS3Storage

func WithS3Storage(s3 S3Config) Option

WithS3Storage 配置 S3 兼容对象存储。

func WithSTSKeyPrefix

func WithSTSKeyPrefix(fn func(c *gin.Context) string) Option

WithSTSKeyPrefix 设置生成 keyPrefix 的函数。 默认按日期生成,如 "direct/2026/04/09/"。 可用于按用户 ID 隔离:func(c *gin.Context) string { return "user-" + getUserID(c) + "/" }.

func WithSTSProvider

func WithSTSProvider(p STSProvider) Option

WithSTSProvider 设置自定义 STS Provider 实现。

func WithStorage

func WithStorage(s Storage) Option

WithStorage 设置自定义 Storage 实现。 会覆盖 WithLocalStorage 和 WithS3Storage。

func WithStrictPartsVerify added in v1.4.0

func WithStrictPartsVerify(strict bool) Option

WithStrictPartsVerify 设置 Multipart 直传 Complete 前的服务端二次校验严格度。

strict=true(默认):调用云端 ListParts 失败时拒绝 Complete,返回

ErrPartVerifyFail.WithDetails("云端校验不可用")

这是默认行为,安全优先——避免客户端伪造 ETag 在云 API 抖动时通过校验。

strict=false:ListParts 失败时放行 Complete,但日志级别从 Warn 升级为 Error 便于业务方监控降级。仅在云 API 抖动率高且业务可接受弱校验时启用。

v1.4.0 之前的行为等价于 strict=false(但日志为 Warn),新版默认 strict=true。

func WithTencentSTS

func WithTencentSTS(cfg TencentSTSConfig) Option

WithTencentSTS 配置腾讯云 COS STS 直传。

func WithUserIDFn added in v1.2.0

func WithUserIDFn(fn func(c *gin.Context) string) Option

WithUserIDFn 从 Gin 上下文提取用户标识,用于 keyPrefix 隔离和 hash 索引二级键。 未配置时默认返回 "anon"。

type OrphanMultipart added in v1.2.0

type OrphanMultipart = types.OrphanMultipart

OrphanMultipart 悬挂 Multipart 元信息。

type PartETag added in v1.2.0

type PartETag = types.PartETag

PartETag 分片号 + ETag 的键值对,用于 Multipart 合并。

type PartInfo added in v1.2.0

type PartInfo = types.PartInfo

PartInfo 云端分片元信息。

type PartSignRequest added in v1.2.0

type PartSignRequest = types.PartSignRequest

PartSignRequest 按需签名请求体。

type PartSignResponse added in v1.2.0

type PartSignResponse = types.PartSignResponse

PartSignResponse 按需签名响应。

type PresignPartRequest added in v1.2.0

type PresignPartRequest = types.PresignPartRequest

PresignPartRequest 预签名单片请求。

type ResumeRequest added in v1.2.0

type ResumeRequest = types.ResumeRequest

ResumeRequest 秒传 + 断点续传查询请求体。

type ResumeResponse added in v1.2.0

type ResumeResponse = types.ResumeResponse

ResumeResponse 秒传 + 断点续传查询响应。

type S3Config

type S3Config struct {
	Endpoint  string
	AccessKey string
	SecretKey string
	Bucket    string
	Region    string
	UseSSL    bool

	// CDNHost 配置了 CDN 加速域名时,GetSignedGetURL 返回的预签名 URL 会用此域名替换原始 endpoint。
	//
	// 接受三种输入格式(自动 normalize):纯 host / 带 scheme / 带 scheme + path。
	// 注意:CDN 必须在云厂商控制台绑定为对应 bucket 加速域名,否则签名校验失败 403。
	CDNHost string
}

S3Config 包含 S3 兼容对象存储的连接参数。

Endpoint 示例:

MinIO:      "127.0.0.1:9000"
阿里云 OSS:  "oss-cn-hangzhou.aliyuncs.com"
腾讯云 COS:  "cos.ap-guangzhou.myqcloud.com"
华为云 OBS:  "obs.cn-north-4.myhuaweicloud.com"
AWS S3:     "s3.us-east-1.amazonaws.com"

type STSCredential

type STSCredential = types.STSCredential

STSCredential STS 临时凭证。

type STSProvider

type STSProvider = types.STSProvider

STSProvider STS 凭证签发接口。

type SignedURLStorage added in v1.4.0

type SignedURLStorage = types.SignedURLStorage

SignedURLStorage 是 Storage 的可选扩展接口,提供"生成预签名 GET URL"能力。 业务方通过 type assertion 检测;详见 types.SignedURLStorage 文档。

内置实现:aliyun / tencent / huawei adapter + S3Storage。LocalStorage 不实现。

type Storage

type Storage = types.Storage

Storage 抽象文件持久化层。 所有方法均使用 io.Reader 流式传输,不会将完整文件缓冲到内存。

内置实现:本地磁盘、S3 兼容(MinIO/阿里云/腾讯云/华为云/AWS)。 也可通过 WithStorage 选项提供自定义实现。

type TencentSTSConfig

type TencentSTSConfig = sts.TencentConfig

TencentSTSConfig 腾讯云 COS STS 配置。

type UploadAbortEvent added in v1.2.0

type UploadAbortEvent = types.UploadAbortEvent

UploadAbortEvent 会话被中止时触发的生命周期钩子 payload。

type UploadErrorEvent added in v1.2.0

type UploadErrorEvent = types.UploadErrorEvent

UploadErrorEvent 任一上传阶段失败时触发的生命周期钩子 payload。

type UploadInitEvent added in v1.2.0

type UploadInitEvent = types.UploadInitEvent

UploadInitEvent 会话初始化成功时触发的生命周期钩子 payload。

type UploadResult

type UploadResult = types.UploadResult

UploadResult 文件上传完成后返回的结果(普通上传和分片合并)。

type Uploader

type Uploader struct {
	// contains filtered or unexported fields
}

Uploader 是 gouploader 包的主入口。

func New

func New(opts ...Option) (*Uploader, error)

New 使用给定的选项创建 Uploader 实例。

v1.4.0 起,Option 函数的非法配置(如负值 chunk cleanup interval)不再 panic, 改为聚合到错误返回值。业务方必须检查返回值。

func (*Uploader) Close

func (u *Uploader) Close()

Close 释放 Uploader 持有的资源(停止后台清理协程)。 在 Uploader 不再使用或应用关闭时必须调用。

func (*Uploader) HandleChunkAbort

func (u *Uploader) HandleChunkAbort() gin.HandlerFunc

HandleChunkAbort 返回中止分片上传的 Gin 处理函数。

func (*Uploader) HandleChunkComplete added in v1.2.0

func (u *Uploader) HandleChunkComplete() gin.HandlerFunc

HandleChunkComplete 返回 Multipart 路径合并的 Gin 处理函数。

func (*Uploader) HandleChunkInit

func (u *Uploader) HandleChunkInit() gin.HandlerFunc

HandleChunkInit 返回分片上传初始化的 Gin 处理函数。

func (*Uploader) HandleChunkMerge

func (u *Uploader) HandleChunkMerge() gin.HandlerFunc

HandleChunkMerge 返回合并分片的 Gin 处理函数。

func (*Uploader) HandleChunkReportPartETag added in v1.2.0

func (u *Uploader) HandleChunkReportPartETag() gin.HandlerFunc

HandleChunkReportPartETag 返回 Multipart 路径下客户端上报 ETag 的 Gin 处理函数。

func (*Uploader) HandleChunkResume added in v1.2.0

func (u *Uploader) HandleChunkResume() gin.HandlerFunc

HandleChunkResume 返回秒传 + 断点续传查询的 Gin 处理函数。

func (*Uploader) HandleChunkSignParts added in v1.2.0

func (u *Uploader) HandleChunkSignParts() gin.HandlerFunc

HandleChunkSignParts 返回按需签发分片预签名 URL 的 Gin 处理函数。

func (*Uploader) HandleChunkStatus

func (u *Uploader) HandleChunkStatus() gin.HandlerFunc

HandleChunkStatus 返回查询分片上传状态的 Gin 处理函数。

func (*Uploader) HandleChunkUpload

func (u *Uploader) HandleChunkUpload() gin.HandlerFunc

HandleChunkUpload 返回分片上传的 Gin 处理函数。

func (*Uploader) HandleDirectCallback

func (u *Uploader) HandleDirectCallback() gin.HandlerFunc

HandleDirectCallback 返回云存储回调的 Gin 处理函数。

func (*Uploader) HandleDirectComplete

func (u *Uploader) HandleDirectComplete() gin.HandlerFunc

HandleDirectComplete 返回前端确认上传完成的 Gin 处理函数。

func (*Uploader) HandleDirectToken

func (u *Uploader) HandleDirectToken() gin.HandlerFunc

HandleDirectToken 返回签发 STS 凭证的 Gin 处理函数。

func (*Uploader) HandleUpload

func (u *Uploader) HandleUpload() gin.HandlerFunc

HandleUpload 返回普通上传的 Gin 处理函数。 用于手动注册路由(搭配自定义中间件)。

func (*Uploader) RegisterRoutes

func (u *Uploader) RegisterRoutes(group *gin.RouterGroup)

RegisterRoutes 在给定的 Gin 路由组上注册所有上传接口。

基础路由(始终注册):

POST   {group}/                         - 普通单文件上传
POST   {group}/chunk/init               - 初始化分片上传(自动探测 proxy / multipart / instant)
POST   {group}/chunk                    - [proxy] 上传单个分片
GET    {group}/chunk/status/:upload_id  - 查询上传进度(断点续传)
POST   {group}/chunk/merge/:upload_id   - [proxy] 合并所有分片
DELETE {group}/chunk/:upload_id         - 中止上传(同时 Abort 云端 Multipart)
POST   {group}/chunk/resume             - 秒传 + 断点续传查询
POST   {group}/chunk/parts/sign         - [multipart] 按需签发分片预签名 URL
POST   {group}/chunk/parts/report       - [multipart] 客户端上报单片 ETag
POST   {group}/chunk/complete           - [multipart] 合并(携带 parts + ETag 列表)

STS 直传路由(仅当配置了 STS Provider 时注册):

POST   {group}/direct/token     - 签发 STS 临时凭证
POST   {group}/direct/callback  - 云存储上传完成回调
POST   {group}/direct/complete  - 前端确认上传完成

type UserScopedHashStore added in v1.4.0

type UserScopedHashStore = types.UserScopedHashStore

UserScopedHashStore 是 HashStore 的可选扩展接口,提供按用户隔离的秒传索引。 多租户场景必须使用。详见 types.UserScopedHashStore 文档。

Directories

Path Synopsis
aliyun module
aws module
huawei module
internal
sts
minio module
tencent module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL