12 KiB
对象存储与上传
核心 storage.Store 保持 Put/Get/Delete,不会强迫每个适配器实现云厂商的所有能力。业务可按需依赖可选接口:
| 能力 | 接口 | Local | S3 / R2 | OSS |
|---|---|---|---|---|
| 读写删除 | Store | 支持 | 支持 | 支持 |
| 临时下载 | Presigner | NewSigner | 支持 | 支持 |
| 带选项的预览/下载 | ReadPresigner | NewSigner | 支持 | 支持 |
| 限定大小/类型的直传 | UploadPresigner | NewUploadSigner | 支持 | 支持,需要配置签名 Header |
| 创建、上传、查询、完成、取消分片 | MultipartStore | 当前不提供 | 支持 | 支持 |
| 分片直传签名 | PartPresigner | 当前不提供 | 支持 | 支持,需要配置签名 Header |
没有固定业务字段、目录规则或身份模型。鉴权、对象归属、业务配额由应用定义。SDK 客户端和连接池由应用持有;适配器不会在一次调用后关闭客户端。
签名预览与下载
v0.29.0-preview 新增独立的可选接口 ReadPresigner,不扩充原 Store/Presigner,不要求第三方适配器修改代码。原 PresignGet(ctx, key, ttl) 和既有本地下载链接继续有效、默认 attachment。
// signer 可以是 storage.NewSigner 返回的本地 Handler,或 S3/R2、OSS Store。
link, err := signer.PresignGetWithOptions(ctx, key, 30*time.Minute, storage.ReadOptions{
Disposition: storage.DispositionInline,
FileName: "示例报告.pdf",
ContentType: "application/pdf",
})
if err != nil { return err }
// 把 link.URL 和 link.ExpiresAt 返回给已授权的调用方。
TTL 仍为 1 秒到 1 小时,30 分钟无需改配置。ReadLink 包含 url 和 expiresAt,到期时间取自实际签名,精确到秒(不足一秒的部分舍弃);临时云凭据可能更早失效。无需额外请求 Header,客户端直接打开 URL。可通过 storage.ReadPresigner 做能力判断;不支持时由调用方明确处理,不应静默降级成公开地址。
Disposition 只接受 inline/attachment,空值为 attachment。FileName 是显示名,与存储 key 无关;中文使用标准 filename* 编码,拒绝控制字符(包括 CR/LF)、路径分隔符及超长值。ContentType 为空时沿用对象元数据,可显式指定合法 MIME 类型;它只控制响应,不转换或验证文件内容。PDF 应在上传时就保存 application/pdf 元数据。inline 请求内联显示,能否展示还取决于浏览器的格式支持与用户设置。
本地签名绑定 key、到期时间、版本及全部选项,拒绝重复/未知参数和篡改;先验签、验有效期,再读取对象或处理条件请求。Local 使用受文件内容边界约束的流,GET/HEAD 的长度不包含内部元数据;支持单段/多段 Range、206/416,以及 Last-Modified 条件请求。HEAD 返回完整对象头、无响应体,忽略 Range。自定义 Store 的 Body 实现 io.ReadSeeker 时复用同一行为;仅支持顺序读取时返回完整 200,不宣称支持范围读取,也不把整个对象加载到内存。
云端适配器在 OSS/S3 原生 SDK 签名前传入响应参数,禁止先签名后拼参数或替换域名。Range 由云存储处理;云端 GET 签名不能直接改成 HEAD 使用。新接口请求 Cache-Control: private, no-store,本地成功、错误及条件响应均保持该策略;云端响应头覆盖受平台规则约束,需检查实际 200/206 响应与代理缓存配置。
OSS 默认域名或加速域名可能强制下载。需要自定义域名时,在应用装配 SDK client 时设置已绑定桶的域名和 WithUseCName(true),再传给适配器;SDK 的区域、签名版本、凭据由应用配置。例如在现有 SDK config 上调用 WithEndpoint("https://files.example").WithUseCName(true)。参见 OSS 预览规则与 GetObject 响应参数规则。
签发权限、对象是否不可变、审计和前端链接复用由项目维护。框架不添加分享表、撤销、租户或数据库依赖;重新签发不会撤销旧链接,到期不会收回已下载内容,也不会中断已开始的传输。本地入口使用显式配置的公开 baseURL,不猜测 Host/转发头,不公开磁盘目录。应用与代理日志应隐藏查询参数。
完整离线 预览/下载示例,包含真实 HTTP 检查与一页 PDF。OSS V1/V4、S3/R2 SDK 签名和隔离 MinIO 有回归;OSS/R2 云账号尚未实测,部署方仍需对实际桶、域名及浏览器进行验收。
服务端上传限制
uploader, err := storage.NewUploader(files, storage.UploadPolicy{
MaxBytes: 32 << 20,
ContentTypes: []string{"image/png", "image/jpeg", "application/pdf"},
})
if err != nil { return err }
info, err := uploader.Put(ctx, key, reader, size, contentType)
files 是任意 Store。MaxBytes 必须为正数;ContentTypes 是精确 MIME 白名单,忽略参数,不接受通配符。空白名单允许所有合法声明类型。单次上传检查声明大小、流的实际长度;设置类型白名单时,再以头部最多 512 字节嗅探结果核对声明类型。适配器本身的大小限制仍然有效,建议与策略统一。这里只读取有限前缀,不把完整文件放进内存。
MIME 嗅探用于粗粒度识别,不验证完整文件结构或病毒;CSV/JSON 等类型可能被嗅探成 text/plain。需要复杂格式判断时,在业务校验流程中处理。必须传入可终止、正确返回 EOF 的 reader;ContextReader 不能中断任意用户 Reader 中已经阻塞的 Read。
使用 Local 时先写临时文件,校验失败不会替换旧对象。云端网络中断可能产生结果未知或遗留未完成上传;使用唯一暂存 key,并通过查询/对账确认后再更新业务记录,不要把上传 error 当成绝对未写入。
云端直传
// files 为 S3/R2 或 OSS 的 Store;Uploader 统一执行策略检查。
uploader, err := storage.NewUploader(files, storage.UploadPolicy{
MaxBytes: 32 << 20, ContentTypes: []string{"image/png", "image/jpeg"},
})
if err != nil { return err }
// 业务先授权并生成暂存 key,随后签发。
request, err := uploader.PresignPut(ctx, key, size, "image/png", 5*time.Minute)
返回 method/url/headers/expiresAt,有效期 1 秒到 1 小时。签名绑定 key、方法、确切 Content-Length 和声明 Content-Type,缺少这些签名约束时适配器报错。SDK 凭据提前失效时,链接也可能提前失效。
浏览器请求使用原始 File/Blob 作为 body,不能额外包 FormData;Content-Length 由浏览器按 body 长度生成,禁止手动设置。其余 Headers 按返回值原样发送:
const headers = new Headers();
for (const [name, values] of Object.entries(request.headers)) {
if (name.toLowerCase() !== 'content-length') headers.set(name, values.join(', '));
}
const response = await fetch(request.url, {
method: request.method, headers, body: file,
});
if (!response.ok) throw new Error(`upload failed: ${response.status}`);
桶的 CORS 由部署方配置,允许业务域名、PUT 和返回的请求头;分片直传还需暴露 ETag 响应头。前端不能得到永久凭据。链接在到期前可能重复使用并覆盖同一个 key,不是一次性凭证。用随机暂存 key,业务确认前不要当作已发布文件。
云端直传的类型限制针对声明 Header,无法由 API 服务实时检查文件字节。 内容校验/扫描需要后处理;验证通过前不要公开文件。前端报告“上传完成”不等于文件可信。具体平台仍需实网验收,不能仅凭 S3 兼容声明判断一致。
OSS 使用官方 Go SDK v2:
client := ossSdk.NewClient(ossSdk.LoadDefaultConfig().
WithRegion("cn-hangzhou").
WithSignatureVersion(ossSdk.SignatureVersionV4).
WithAdditionalHeaders([]string{"content-length"}).
WithCredentialsProvider(credentialProvider))
files, err := oss.New(client, bucket, 32<<20)
适配器不修改传入的 client;缺少签名大小约束会返回明确错误。SDK 的 presign 实现与返回 SignedHeaders 都经过检查。
本地签名上传
files, err := local.New("./privateFiles", 32<<20)
if err != nil { return err }
signer, err := storage.NewUploadSigner(files, "https://files.example/upload", secret,
storage.UploadPolicy{MaxBytes: 32<<20, ContentTypes: []string{"image/png"}})
if err != nil { return err }
// signer 实现 http.Handler,可挂到现有的 HTTP 服务;secret 至少 32 字节。
request, err := signer.PresignPut(ctx, key, size, "image/png", time.Minute)
Handler 检查签名、有效期、单值参数、Content-Length/Content-Type,以及实际内容大小和嗅探类型,成功返回 201。本地存储只开放一个受控入口,不把磁盘目录映射成公共目录。原 NewSigner 继续只服务下载。请求超时、CORS、并发限制和速率限制由承载该 Handler 的服务配置。
分片与续传
S3/R2/OSS 均实现 MultipartStore 和 PartPresigner。Uploader 也暴露相同操作;不支持的底层返回 ErrUnsupported。
session, err := uploader.CreateMultipart(ctx, key, "application/pdf")
if err != nil { return err }
// 业务持久保存 session,与已授权调用者关联,不能信任客户端任意传入的 key/uploadId。
part, err := uploader.UploadPart(ctx, session, 1, reader, size)
// 或签发单个分片的 PUT 请求:
request, err := uploader.PresignPart(ctx, session, 1, size, 5*time.Minute)
// 重连后分页读取实际已上传的分片,NextMarker == 0 表示结束。
page, err := uploader.ListParts(ctx, session, 0, 1000)
// 按 Number 升序从 1 连续提交,ETag 使用存储平台的返回值。
info, err := uploader.CompleteMultipart(ctx, session, parts)
// 用户取消/任务过期时,用仍有效的 context 显式清理。
err = uploader.AbortMultipart(ctx, session)
以上为互斥分支的 API 索引,实际调用需逐步处理 error。续传由业务保存 session,查询已有分片后重传缺失部分;框架不维护有身份语义的上传任务表,也不代替业务调度重试。
统一边界:每个对象最多 10,000 片、每片最多 5 GiB,除末片外至少 5 MiB;这是适配层为跨平台复用选取的共同约束。空文件走普通 Put。ListParts 每页最多 1,000 条。平台/桶限制可能更低。默认单对象仍为 32 MiB,需要大文件时显式提高适配器与 UploadPolicy 的限制;OSS 简单 Put 和云端单次签名 PUT 最大 5 GiB,更大使用分片。
Complete 会向平台分页核对全部分片的序号、ETag、真实大小和累计上限,前端填写的 Part.Size 不参与信任判断。完成时停止并发替换分片;Provider 仍会再次校验 ETag。返回 Info 的 Size 为已核对大小,ContentType 留空,完整对象元数据可通过 Get 获取。Multipart 的类型策略检查创建时的声明,实际内容应在暂存对象合并后校验。未完成的分片占用存储空间:应用需要超时取消或配置桶生命周期;URL 过期不会自动清理分片。
验收:Local 实际字节与原子性、S3/OSS SDK HTTP 协议测试、R2 签名配置测试、真实隔离 MinIO 验证。OSS/R2 尚未使用云账号实测。复现 MinIO 验证:先准备镜像,再运行 python3 scripts/verifyUploads.py;报告见 uploadsLocal.json。