Files
go-omni/storage/README.md

146 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 对象存储与上传
核心 `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。
```go
// 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 预览规则](https://help.aliyun.com/zh/oss/how-to-ensure-an-object-is-previewed-when-you-access-the-object)与 [GetObject 响应参数规则](https://www.alibabacloud.com/help/en/oss/developer-reference/getobject)。
签发权限、对象是否不可变、审计和前端链接复用由项目维护。框架不添加分享表、撤销、租户或数据库依赖;重新签发不会撤销旧链接,到期不会收回已下载内容,也不会中断已开始的传输。本地入口使用显式配置的公开 baseURL,不猜测 Host/转发头,不公开磁盘目录。应用与代理日志应隐藏查询参数。
完整离线 [预览/下载示例](../examples/signedRead/README.md),包含真实 HTTP 检查与一页 PDF。OSS V1/V4、S3/R2 SDK 签名和隔离 MinIO 有回归;OSS/R2 云账号尚未实测,部署方仍需对实际桶、域名及浏览器进行验收。
## 服务端上传限制
```go
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 当成绝对未写入。
## 云端直传
```go
// 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 按返回值原样发送:
```js
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:
```go
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 都经过检查。
## 本地签名上传
```go
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`。
```go
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](../performance/uploadsLocal.json)。