跳到主要内容

文件存储

VEF 提供一套与 provider 无关的存储抽象、三种内置 provider、分片上传协议、用于 CRUD 流程同步模型与后端文件引用的类型化生命周期门面,以及用于下游清理的事务性 outbox。

类型化生命周期门面是 Files / FilesFor[T]——没有 Promoter[T]。上传协议是带显式 claim / queue 生命周期的分片 multipart,principal 授权贯穿全流程。本页描述当前公开 API surface。

支持的 Provider

配置值后端
memory进程内 map;测试和短期演示
filesystem本地文件系统
minioMinIO / S3 兼容对象存储

storage.provider 选择后端。未配置时默认 memory 并输出 warning;对象会在进程重启后丢失。

如果希望 storage 模块在启动时创建所需表,设置 vef.storage.auto_migrate = true。迁移是幂等的,会检查 sys_storage_upload_claimsys_storage_upload_partsys_storage_pending_deletesys_storage_file

storage.Service 接口

应用代码依赖 storage.Service,不依赖任何 provider 特定类型:

type Service interface {
PutObject(ctx, opts PutObjectOptions) (*ObjectInfo, error)
GetObject(ctx, opts GetObjectOptions) (io.ReadCloser, *ObjectInfo, error)
DeleteObject(ctx, opts DeleteObjectOptions) error
DeleteObjects(ctx, opts DeleteObjectsOptions) error
CopyObject(ctx, opts CopyObjectOptions) (*ObjectInfo, error)
StatObject(ctx, opts StatObjectOptions) (*ObjectInfo, error)
}

每个方法都使用对应的 option struct:PutObjectOptionsGetObjectOptionsDeleteObjectOptionsDeleteObjectsOptionsCopyObjectOptionsStatObjectOptions。框架刻意不支持位置参数 ——这样后续追加字段始终是可叠加变更。

GetObject 会返回 body reader 和 best-effort ObjectInfo。调用方必须关闭 reader,并对 ObjectInfo 做 nil-check。

分片上传

框架的上传协议只接受分片 multipart——没有单次 PUT 上传。每个后端都实现 storage.Multipart

type Multipart interface {
PartSize() int64
MaxPartCount() int
InitMultipart(ctx, opts InitMultipartOptions) (*MultipartSession, error)
PutPart(ctx, opts PutPartOptions) (*PartInfo, error)
CompleteMultipart(ctx, opts CompleteMultipartOptions) (*ObjectInfo, error)
AbortMultipart(ctx, opts AbortMultipartOptions) error
}

storage.MultipartFor(svc) 拿到类型化句柄(后端不支持分片上传时返回 nil)。契约保证:

  • 不同 part number 允许并发上传;相同 part number 的并发调用是 last-writer-wins。
  • 除最后一片外,每片必须不小于 PartSize() 字节。
  • CompleteMultipart 会校验每个已记录的 (PartNumber, ETag) 以及 parts 是否覆盖 1..N 连续区间。
  • 调用 CompleteMultipartAbortMultipart 后会话关闭,后续操作返回 ErrUploadSessionNotFoundAbortMultipart 幂等。

sys/storage.list_parts RPC action 用于让客户端恢复上传中断,它由框架的 part-store 表服务,storage.Multipart 上的 ListParts 方法 —— 后端接口本身只暴露上面 6 个方法。

内置资源:sys/storage

存储模块注册了一个 RPC 资源,包含分片上传动作:

Action访问方式入参出参作用
init_uploadBearer 认证(引擎默认)InitUploadParamsInitUploadResult创建 pending claim、开启 multipart session,并返回不透明 claimId 与协商出的 partSize
upload_partBearer 认证(引擎默认)UploadPartParams(multipart 表单)UploadPartResult上传一片
list_partsBearer 认证(引擎默认)ListPartsParamsListPartsResult列出已上传的 parts
complete_uploadBearer 认证(引擎默认)CompleteUploadParamsCompleteUploadResult完成 session;服务端从已记录的 parts 组装最终清单
abort_uploadBearer 认证(引擎默认)AbortUploadParams成功,无 data 载荷中止并释放 session

下载通过下方代理中间件提供。

文件注册表资源:sys/storage/file

一个独立的只读资源(sys/storage/file),暴露一个 action:

Action访问方式入参出参作用
file/resolveBearer 认证(引擎默认)ResolveParamsResolveResult批量解析 object key(≤ 200)为文件元数据——key、原始文件名、内容类型、大小、状态、上传时间、上传者(uploadedBy)。调用方不可见的 key 会被静默省略。

该资源故意与分片上传协议分离:它是持久文件注册表上的查询接口,而非分片上传状态机的一步。授权逻辑与下载代理一致——pub/ 下的 key 公开可读,其余 key 经过 storage.FileACL.CanRead

这些 action 均未声明 per-action 权限、public 标志、自定义限流或审计日志, 因此全部继承 API 引擎默认值:Bearer 认证加默认限流(每 5 分钟滑动窗口 100 次请求,除非 vef.api.rate_limit 覆盖)。在认证之上,每个接收 claimId 的 action 都会强制按 claim 校验归属 —— 只有创建该 claim 的 principal 才能 操作它。不存在的 claim ID 与归属他人的 claim 得到完全相同的回答 (result.ErrAccessDenied,code 1100,HTTP 403):API 刻意不泄露某个 claim ID 是否存在。唯一例外是 abort_upload,它把未知 claim 视为已中止并 返回成功。

所有 HTTP 上传都使用同一套协议:init_upload -> upload_part -> complete_upload。小文件也会返回 partCount = 1;不存在单次 PUT HTTP action。public 标志默认按私有处理,只有 vef.storage.allow_public_uploads 为 true 时,客户端才可以请求 pub/ key。

客户端传入的 contentType 会被清洗。安全的二进制、图片、音频、视频、 字体、压缩包和 PDF 类型会被接受;text/htmlapplication/javascript 这类同源不安全类型会被扩展名探测结果或 application/octet-stream 替换。

init_upload

InitUploadParams

字段类型必填说明
filenamestring原始文件名,最长 255 字符(validate:"required,max=255")。当扩展名为纯字母数字(.pdf.tar,模式 ^\.[a-zA-Z0-9]+$)时会复用到 object key,否则回退为 .bin。持久化在 claim 行上,并作为 originalFilename 原样回显。
sizeint64对象的精确总字节数,至少为 1(validate:"required,min=1")。会按 vef.storage.max_upload_size(默认 1 GiB)校验 —— 超限返回 ErrCodeUploadSizeExceedsLimit——并用于计算 part 计划。complete_upload 之后还会核对实际上传总量与此声明一致。
contentTypestring客户端建议的 MIME 类型,最长 127 字符(validate:"max=127")。服务端会清洗:image/*audio/*video/*font/* 前缀以及 application/pdfapplication/zipapplication/gzipapplication/x-tarapplication/octet-stream 这些精确类型原样接受;其余值由扩展名探测替换,最终回退 application/octet-stream
publicbooltrue 时 key 落在 pub/ 而不是 priv/ 下。除非 vef.storage.allow_public_uploads = true,否则返回 ErrCodePublicUploadsNotAllowed

行为:

  • 后端未实现 storage.Multipart 时返回 ErrCodeMultipartNotSupported;part 计划(ceil(size / partSize))超过后端 MaxPartCount()(MinIO 为 10000;filesystem 与 memory 不设上限)时返回 ErrCodeUploadTooManyParts;调用方已持有 vef.storage.max_pending_claims(默认 100,best-effort 计数)个 pending claim 时返回 ErrCodeTooManyPendingUploads
  • 生成的 key 按日期分区:<pub/|priv/>YYYY/MM/DD/<uuid><ext>
  • pending claim 在 vef.storage.claim_ttl(默认 24h)后过期;未完成上传的 parts 在此之前一直有效。

InitUploadResult

字段类型说明
keystring位于 priv/pub/ 下的最终 object key,init 时即确定。
claimIdstring后续所有 action 使用的不透明 session 句柄。这是唯一的客户端可见标识 —— 后端 multipart UploadID 永远留在服务端。
originalFilenamestring客户端传入的 filename,持久化在 claim 行上(不在后端 metadata 里),因此与存储后端无关、始终可用。
partSizeint64后端权威的切片字节数(MinIO 16 MiB、filesystem 4 MiB、memory 64 KiB)。除最后一片外每片必须恰好是这个大小。客户端不要假设固定值 —— 始终使用返回值。
partCountint需要上传的 part 数量,即 ceil(size / partSize)。小文件也会得到 partCount = 1
expiresAt时间戳(RFC 3339)claim(连同上传 session)失效的时间。

upload_part

upload_part 拒绝 JSON body(ErrCodeUploadRequiresMultipart)。请发送 multipart/form-dataresourceactionversion 是普通表单字段, params 是 JSON 字符串,part 原始字节放在名为 file 的表单分片里(缺失时 返回 ErrCodeUploadRequiresFile)。

UploadPartParams

字段类型必填说明
file表单文件分片该 part 的原始字节。大于 partSize 返回 ErrCodeUploadPartTooLarge;非末片小于 partSize 返回 ErrCodeUploadPartTooSmall(只有最后一片可以承载余量)。
claimIdstringinit_upload 返回的句柄(validate:"required")。claim 必须归属调用方、处于 pending 且未过期。
partNumberint1 起的 part 序号(validate:"required,min=1")。超过 partCount 返回 ErrCodeUploadPartNumberOutOfRange

行为:

  • 不同 part 序号可以并发上传。重发同一序号会覆盖之前的字节 —— part 行按 upsert 处理,最新的后端 ETag 胜出,与后端的 last-writer-wins 语义一致。
  • 每次上传 part 时都会把 claim 声明的 size当前 vef.storage.max_upload_size 重新校验,运行时收紧上限也会拦住进行中的 上传。
  • 后端 ETag 记录在框架的 part 表里并刻意返回:complete_upload 在 服务端组装清单,客户端永远不需要回传 ETag。

UploadPartResult

字段类型说明
partNumberint已接受的 part 序号,原样回显。
sizeint64后端为该 part 记录的字节数。

list_parts

ListPartsParams

字段类型必填说明
claimIdstring要查询的进行中 session(validate:"required")。claim 必须归属调用方、处于 pending 且未过期 —— 已完成的 claim 返回 ErrCodeClaimNotPending

ListPartsResult

字段类型说明
partsListedPart[]已接受的 parts,按 partNumber 升序排列。

ListedPart

字段类型说明
partNumberint1 起的 part 序号。
sizeint64已记录的字节数。

该列表由框架的 part-store 表提供,而不是后端原生列举,并且与 complete_upload 组装用的是同一张表 —— 这里列出的每个 part 都会被原样采 信。part 的 ETag 刻意不暴露。

complete_upload

CompleteUploadParams

字段类型必填说明
claimIdstring要完成的 session(validate:"required")。服务端从自己的 part-store 行重建 parts 清单 —— 永远不接受客户端传入的 ETag。

行为:

  • 已记录的 parts 少于 partCount 时返回 ErrCodeUploadPartsIncomplete; claim TTL 已过时返回 ErrCodeClaimExpired。声明的 size 会按当前 vef.storage.max_upload_size 上限重新校验。
  • 组装完成后服务端把对象大小与声明的 size 比对;不一致会立即删除已组装 的对象并返回 ErrCodeUploadSizeMismatch
  • 成功时在同一事务里把 claim 标记为 uploaded 并清掉它的 part 行。对象此 后等待业务采纳(见下方 Files)。
  • 幂等:对已 uploaded 的 claim 再次调用 complete_upload 会重新 stat 对 象并返回相同形状的响应。后端 session 已关闭、但记账尚未提交时到达的重试 会重新 stat 对象并提交同一事务;session 与对象都不存在时返回 ErrCodeUploadObjectNotFound

CompleteUploadResult —— storage.ObjectInfo 加上框架跟踪的原始文件名:

字段类型说明
bucketstring后端 bucket。MinIO 报告真实 bucket;无 bucket 概念的后端使用哨兵值 filesystemmemory
keystring最终 object key(与 init_upload 返回的一致)。
eTagstring组装后对象的 ETag —— 不是 part ETag,也永远不是此 action 的入参。
sizeint64最终对象字节数。
contentTypestring对象保存的清洗后 content type。
lastModified时间戳(RFC 3339)后端 last-modified 时间。
metadataobject(string→string)后端用户 metadata,key 已 canonical 化;为空时省略。上传 RPC 从不接受客户端 metadata,所以经此资源上传的对象不携带任何 metadata。
originalFilenamestringinit_upload 时记录的原始文件名。

abort_upload

AbortUploadParams

字段类型必填说明
claimIdstring要取消的 session(validate:"required")。

行为 —— abort 是可安全重试的清理路径:

  • 未知 claimId 返回成功(唯一不用 access-denied 回答缺失 claim 的 action);归属其他 principal 的 claim 仍会被 result.ErrAccessDenied 拒绝。
  • 只有 pending claim 会被中止。对 uploaded claim 调用是 no-op 成功 —— abort 绝不会删除已完成的对象。
  • pending claim 在一个事务里被释放:part 行与 claim 行被删除,并插入一条 pending_delete 记录以便后端清理。删除工作器随后异步中止后端 session 并 删除已发布字节,带重试/退避和死信队列。这使得 abort 具有崩溃安全性, 且不会因后端临时不可用而失败。
  • 成功响应没有载荷:datanull

客户端演练:分片上传

以下是客户端对 POST /api 实现的完整线上(wire)序列,示例为在 MinIO 后端上传一个 40 MiB 的 report.pdfpartSize 16 MiB,共 3 片)。全部六个 action 都要求认证(默认 Bearer),后续每个调用都通过 init_upload 返回的 claimId 路由 —— 后端的 multipart UploadID 永远不会离开服务端。成功响应使用标准信封(message 文案随语言变化);失败复用同一信封,code 取存储错误码区间(2200–2299,见下方错误表)。

1. init_upload

curl http://localhost:8080/api \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"resource": "sys/storage",
"action": "init_upload",
"version": "v1",
"params": {
"filename": "report.pdf",
"size": 41943040,
"contentType": "application/pdf",
"public": false
}
}'
{
"code": 0,
"message": "Success",
"data": {
"key": "priv/2026/07/09/6c9e6f0e-8d5a-4d5e-9a3b-2f4a1c7e9b21.pdf",
"claimId": "b3a2c1d0-4e5f-47a9-8bcd-ef0123456789",
"originalFilename": "report.pdf",
"partSize": 16777216,
"partCount": 3,
"expiresAt": "2026-07-10T12:04:05Z"
}
}

size 必须是精确的字节数:如果实际上传总量与声明不符,complete_upload 会删除已组装的对象并返回 size mismatch 错误。partSize 是后端权威的切片大小 —— 除最后一片外每片必须恰好是 partSize 字节,最后一片承载余量。

2. upload_part(× partCount

upload_part 拒绝 JSON body。请发送 multipart/form-dataresourceactionversion 是普通表单字段,params 是 JSON 字符串,文件字节放在名为 file 的表单分片里:

split -b 16777216 report.pdf part- # part-aa, part-ab, part-ac

curl http://localhost:8080/api \
-H 'Authorization: Bearer <token>' \
-F 'resource=sys/storage' \
-F 'action=upload_part' \
-F 'version=v1' \
-F 'params={"claimId":"b3a2c1d0-4e5f-47a9-8bcd-ef0123456789","partNumber":1}' \
-F 'file=@part-aa'
{
"code": 0,
"message": "Success",
"data": {
"partNumber": 1,
"size": 16777216
}
}

partNumber 2 和 3 重复此调用(part-ac 是 8388608 字节的余量)。不同 part number 可以并发上传;重发同一个 part number 会覆盖之前的字节(last-writer-wins)。后端 ETag 由服务端记录且刻意不返回 —— 客户端永远不需要回传 ETag。

3. complete_upload

curl http://localhost:8080/api \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"resource": "sys/storage",
"action": "complete_upload",
"version": "v1",
"params": { "claimId": "b3a2c1d0-4e5f-47a9-8bcd-ef0123456789" }
}'
{
"code": 0,
"message": "Success",
"data": {
"bucket": "app-files",
"key": "priv/2026/07/09/6c9e6f0e-8d5a-4d5e-9a3b-2f4a1c7e9b21.pdf",
"eTag": "9b2cf535f27731c974343645a3985328-3",
"size": 41943040,
"contentType": "application/pdf",
"lastModified": "2026-07-09T12:08:15Z",
"originalFilename": "report.pdf"
}
}

服务端从自己的表组装 parts 清单;已记录的 parts 少于 partCount 时调用失败,返回 ErrCodeUploadPartsIncomplete。重试是幂等的 —— 后端 session 已关闭之后到达的重试会重新 stat 对象并返回相同形状的响应。此时 claim 处于 uploaded 状态,等待业务采纳(见下方 Files)。

恢复中断的上传:list_parts

只要 claim 仍处于 pending 且未过期(expiresAt),已被接受的 parts 在客户端重启后依然有效。先问服务端已经持有哪些 parts,跳过它们,只上传剩余部分:

curl http://localhost:8080/api \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"resource": "sys/storage",
"action": "list_parts",
"version": "v1",
"params": { "claimId": "b3a2c1d0-4e5f-47a9-8bcd-ef0123456789" }
}'
{
"code": 0,
"message": "Success",
"data": {
"parts": [
{ "partNumber": 1, "size": 16777216 },
{ "partNumber": 2, "size": 16777216 }
]
}
}

这里缺第 3 片:把它传上去,然后调用 complete_upload。列表按 partNumber 升序排列,每个列出的 part 都已连同 ETag 记录在服务端,complete_upload 会原样采信。

取消上传:abort_upload

同样的 JSON 信封,"action": "abort_upload"params 里带 claimId

{ "code": 0, "message": "Success", "data": null }

Abort 是幂等的:未知或已中止的 claimId 仍返回 code: 0。只有 pending 状态的 claim 会被中止 —— 对 uploaded claim 调用是 no-op,绝不会删除已完成的对象。

通过代理下载

下载是对代理路由的普通 HTTP GET,不是 RPC action:

curl -O http://localhost:8080/storage/files/pub/2026/07/09/6c9e6f0e-8d5a-4d5e-9a3b-2f4a1c7e9b21.pdf

pub/* key 匿名可读。其他 key 由代理从 Authorization: Bearer(或浏览 器场景下的 ?__accessToken=)解析请求 principal,再调用 FileACL.CanRead。无凭证的请求会把 nil principal 传给 ACL(ACL 自身是授 权机构,可以放行匿名读);凭证存在但无效的请求会被拒绝。

可见性前缀

object key 通过前缀表达可见意图:

常量含义
storage.PublicPrefixpub/世界可读;默认 ACL 直接放行
storage.PrivatePrefixpriv/由业务侧 FileACL 控制

存储资源会根据上传时的 public 标志,把 key 落在 pub/priv/ 下。代理下载会匿名放行 pub/*,其他 key 才调用 FileACL;存储后端本身不做这种判断。

FileACL

storage.FileACL 决定 principal 能否读取一个私有 key。

type FileACL interface {
CanRead(ctx context.Context, principal *security.Principal, key string) (bool, error)
}

默认实现 storage.DefaultFileACL 只允许 pub/ 下的读。代理会在调用 ACL 前直接放行 pub/*,让公共文件无需 auth token 也可访问;业务代码通过 vef.SupplyFileACL(...) 注入自己的私有文件授权逻辑。

存储代理中间件

模块在 app 层挂了一个下载路由:

GET /storage/files/<key>

行为:

表面行为
路由名为 storage_proxy、顺序为 900 的 app 中间件;不是 RPC action,也不由 API engine 分发
key 校验<key> 做一次 URL 解码;拒绝空 key、绝对路径、.. 片段、反斜杠、NUL 字节、重复斜杠和结尾斜杠
访问控制pub/* 匿名可读;其他 key 都会带着请求 principal 调用 FileACL.CanRead。代理仅对非 pub/ key 从 Authorization: Bearer?__accessToken= 解析 principal —— 凭证存在但无效时会被拒绝,无凭证的请求把 nil 传给 ACL。
Content-Type优先使用后端 metadata 或扩展名探测结果,再把不安全类型清洗为 application/octet-stream;总是发送 X-Content-Type-Options: nosniff
缓存 headerpub/* 返回 Cache-Control: public, max-age=3600, immutable,如果 stat 数据有 ETag 也会返回 ETag;非公开 key 返回 Cache-Control: private, no-store 且不返回 ETag

文件注册表

每个上传的文件都会被记录在持久化的注册表(sys_storage_file,公开模型 storage.FileRecord)中。注册表的意义在于:当短暂的 upload claim 行被业务 采纳删除后,只有注册表能回答"这个 key 对应什么文件"。 Lookup 也会返回已删除对象的记录——FileRecord.IsDeleted()(和状态 deleted)可以区分它们,因此仍然引用已删除 key 的业务行也能渲染文件名。

type FileRegistry interface {
Lookup(ctx context.Context, keys []string) (map[string]FileRecord, error)
}

FileRecord 字段:

字段类型说明
keystring存储 object key,也是自然的关联键
originalFilenamestring上传时客户端提供的文件名
contentTypestring清洗后的 MIME 类型
sizeint64对象字节数
publicbool该对象是否落在公开前缀下
statusstring生命周期状态:uploadedclaimeddeleted
startedAt时间戳上传 session 的开启时间
claimedAt时间戳业务事务采纳时间;未被引用时为 nil
deletedAt时间戳delete worker 删除时间;对象仍存在时为 nil
deleteReasonstring删除原因;删除前为空
uploadedBystring上传该对象的 principal ID;由嵌入的审计列 createdBy 映射而来

FileRecord 还嵌入了标准身份/审计列(idcreatedAtcreatedBy); createdBy 即上传者,resolve 将其作为 uploadedBy 返回。

FileStatus 取值:

常量含义
FileStatusUploadeduploaded对象已在后端最终化,尚未被业务引用
FileStatusClaimedclaimed业务事务已采纳此上传
FileStatusDeleteddeleteddelete worker 已从后端删除对象

注册表记录是 upload claim 的投影——每个字段都从 claim 行复制而来。下载代理 通过注册表解析每个 key,以返回 RFC 6266 Content-Disposition 和原始文件名。

FileRegistry 刻意设计为只读。记录上传是框架自身的完整性不变量,在最终化 上传的事务中完成。业务代码通过 DI 注入 FileRegistry,按存储 key 渲染文件 名。

sys/storage/file.resolve RPC action 也使用注册表——客户端批量查询,按 key 经 ACL 鉴权,与代理行为一致。

上传 Claim 与 Pending Delete(生命周期)

init_upload 会写入一个属于上传调用方、状态为 pendingupload_claim 行。complete_upload 会把同一条 claim 标记为 uploaded。 在业务模型真正引用(通过 Files.OnCreate / OnUpdate)这个 key 之前, 对象处于隔离状态——周期性 sweeper 会把已过期但对象已经完成的 multipart claim 恢复为 uploaded,或者把废弃对象入队为异步删除 (DeleteReasonClaimExpired)。

业务写入因此分裂为两组事务性表面:

  • Claim consumer:在业务 insert 同一事务中删除 upload_claim 行。
  • Delete enqueuer:把被替换的字段值、被删除的业务行对应的对象,写入 pending_delete 行做异步回收。

后台 DeleteWorker 会按重试/退避策略处理 pending_delete 行。删除成功会发出 vef.storage.file.deleted;用尽重试预算的行会在发出 vef.storage.delete.dead_letter 后从队列移除,事件本身就是供人工排查的持久信号。

Storage 会在启动时检查 vef.storage.file.claimedvef.storage.file.deletedvef.storage.delete.dead_letter 是否路由到 事务性 event transport;否则直接启动失败。实践中应启用 outbox transport, 并把 vef.storage.* 路由到 outbox,或者把默认 event transport 设为 outbox

生命周期工作器配置(均在 vef.storage 下):

配置键默认值作用
sweep_interval5mclaim sweeper 运行间隔
sweep_batch_size200每次扫描的 claim 数
orphan_retention0(禁用)回收完成但未被引用超过该时长的上传;默认禁用,因为会删除用户数据
delete_worker_interval5mdelete worker 轮询间隔
delete_batch_size100每轮消费的 pending-delete 行数
delete_concurrency8并发后端删除操作数
delete_max_attempts12死信前重试次数
delete_lease_window5mpending-delete 行租赁时长

FilesFilesFor[T]

CRUD 生命周期门面——已经取代了旧的 Promoter[T]

type Files interface {
OnCreate(ctx, tx orm.DB, principal *security.Principal, model any) error
OnUpdate(ctx, tx orm.DB, principal *security.Principal, oldModel, newModel any) error
OnDelete(ctx, tx orm.DB, model any) error
}

关键语义:

  • 三个方法都必须在业务事务里调用orm.DB.RunInTx)。传入的 tx 就是业务事务 DB 实例,所以 claim 消费和 pending-delete 记账会和业务写入一起提交或回滚。
  • OnCreate / OnUpdate 接受 *security.Principal——只有归属该 principal 的 claim 才会被采纳。nil / 匿名 principal 直接 ErrAccessDenied。背景任务如果合法地"代表系统"操作,需要显式传入一个合成的系统 principal。
  • OnDelete 不消费 claim,所以不需要 principal;调用前请先在 CRUD 层验证行所有权。
  • FileClaimedEvent 通过outbox transport 在调用方事务里发布event.WithTx)——订阅者只有在业务事务提交后才能看到事件。

类型化版本

storage.FilesFor[T] 把 meta 解析提前到构造时完成,调用时不再做反射查找:

files := storage.NewFilesFor[User](filesFacade)
err := files.OnCreate(ctx, tx, principal, &user)

CRUD 的生命周期 hook 构建在 FilesFor[T] 之上,自定义 hook 也应该照此写法。

认领文件的两种方式

用户上传的文件,在你的业务代码"认领"之前一直处于待定状态。认领有两种方式,按你手里有什么选:

  • 手上有模型 struct? 把它交给 Files / FilesFor[T],框架会自己识别其中的文件字段。
  • 手上只有一个文件 key(或一组 key)? 直接调用 ClaimConsumer.Consume(...)

两种方式最终做的事情一样——后者只是前者的手动版。哪个更符合调用现场就用哪个。

方式一:传结构体(推荐写法)

给文件字段加上 meta:"uploaded_file" 标签,把整个 struct 交给 FilesFor[T],就完了。

type Article struct {
orm.FullAuditedModel
CoverImage string `json:"coverImage" bun:"cover_image" meta:"uploaded_file"`
Gallery []string `json:"gallery" bun:"gallery,array" meta:"uploaded_file"`
Body string `json:"body" bun:"body" meta:"rich_text"`
}

files := storage.NewFilesFor[Article](filesFacade)

err := db.RunInTx(ctx, func(ctx context.Context, tx orm.DB) error {
if _, err := tx.NewInsert().Model(article).Exec(ctx); err != nil {
return err
}
// 一行就认领 article 引用的所有文件
return files.OnCreate(ctx, tx, principal, article)
})

更新时把新旧两份模型都传进去——框架会认领新文件、把被替换掉的旧文件入队删除:

err := files.OnUpdate(ctx, tx, principal, oldArticle, newArticle)

删除时把模型传进去,里面所有文件都会入队删除:

err := files.OnDelete(ctx, tx, article)

普通 CRUD 默认就走这条路径。只要结构体合适,优先选它。

方式二:传文件 key(没有结构体时)

有时候你手上并没有模型——可能是后台任务、自定义上传流程,或者只想认领某个具体 key。注入 storage.ClaimConsumer,把 key 切片传给 Consume

err := db.RunInTx(ctx, func(ctx context.Context, tx orm.DB) error {
if _, err := tx.NewInsert().Model(report).Exec(ctx); err != nil {
return err
}
// 直接按 key 认领文件
return claims.Consume(ctx, tx, principal, []string{report.FileKey})
})

如果还需要删除文件(比如上一版的附件),用 storage.DeleteEnqueuer

err := deletes.Enqueue(ctx, tx,
[]string{oldKey},
storage.DeleteReasonReplaced, // 或 DeleteReasonDeleted
)

几条要记的:

  • 一定要放在 RunInTx 里调用,并且传入的 tx 必须是同一个 —— 这样认领和业务写入才会一起提交。
  • Consume 只能认领当前 principal 自己上传的文件。试图认领别人的文件会返回 storage.ErrClaimNotFound
  • nil / 匿名 principal 直接返回 storage.ErrAccessDenied;后台任务需要先构造一个真正的系统 principal。
  • 传空 / nil 切片没问题,就是什么都不做。
  • 字段被覆盖时用 DeleteReasonReplaced,整条记录被删时用 DeleteReasonDeletedDeleteReasonClaimExpired 是框架内部专用,不要传。

如果你发现自己在 Consume / Enqueue 之上手写反射扫结构体,就停下来 —— FilesFor[T] 做的就是这件事,切回方式一。

Meta 标签字段

字段通过 meta tag 加入生命周期管理:

type User struct {
orm.FullAuditedModel

Avatar string `json:"avatar" bun:"avatar" meta:"uploaded_file"`
Gallery []string `json:"gallery" bun:"gallery,array" meta:"uploaded_file,category:gallery"`
Profiles map[string]string `json:"profiles" bun:"profiles" meta:"uploaded_file"`
Bio string `json:"bio" bun:"bio" meta:"rich_text"`
Notes string `json:"notes" bun:"notes" meta:"markdown"`
}
meta 取值字段形态抽取策略
uploaded_filestring / *string / []string / map[string]string字段值就是 file key — 对 map 而言取的是 value,map 的 key 只是自定义标签
rich_textstring扫描 HTML 中嵌入的资源 URL,再通过 URLKeyMapper 翻译
markdownstring扫描 Markdown 中嵌入的资源 URL,再通过 URLKeyMapper 翻译

如果文件引用位于嵌套 struct 里,可以在外层字段上使用 meta:"dive";扫描器会递归进入这个嵌套值,并识别其中自己的 meta:"uploaded_file"meta:"rich_text"meta:"markdown" 字段。不支持的字段形态会被忽略,不会生成文件引用。

URLKeyMapper 用来把富文本/Markdown 中的 URL 翻译成存储 key。框架 DI 图默认提供 storage.ProxyURLKeyMapper,因此内容里嵌入 /storage/files/<key> 时无需额外配置即可对账。如果你直接调用 storage.NewFiles(...),nil mapper 会被规整为 IdentityURLKeyMapper;只有业务内容直接嵌入 bare key 时才传 &storage.IdentityURLKeyMapper{}

这个 mapper 的方向是显式的:URLToKey 在对账时消费内容里的 URL, KeyToURL 在代码需要把已存储 key 渲染回 URL 时使用。

当内容中嵌入的是框架代理 URL 形态(/storage/files/<key>)时,可以使用 storage.ProxyURLKeyMapper{Prefix: storage.DefaultProxyPrefix}。公开 helper ReplaceHtmlURLs(content, replacements)ReplaceMarkdownURLs(content, replacements) 用于重写已渲染内容里的嵌入 URL,通常和 URLKeyMapper.KeyToURL 配合使用。

存储事件

类型常量 / topicPayload / 构造器JSON payload触发
EventTypeFileClaimed / vef.storage.file.claimedFileClaimedEvent; NewFileClaimedEvent(key)fileKey某次业务事务采纳了一个之前 pending 的 claim(Files.OnCreate 或 update 新侧)
EventTypeFileDeleted / vef.storage.file.deletedFileDeletedEvent; NewFileDeletedEvent(key, reason)fileKey, reasondelete worker 成功把对象从后端删除
EventTypeDeleteDeadLetter / vef.storage.delete.dead_letterDeleteDeadLetterEvent; NewDeleteDeadLetterEvent(id, key, reason, attempts, lastErr)pendingDeleteId, fileKey, reason, attempts,可选 lastErrordelete worker 重试用尽;该队列行会在事件发布后被删除

三者都通过 outbox transport 搭配 event.WithTx(...) 发布。FileClaimedEvent 共享调用方业务事务;FileDeletedEventDeleteDeadLetterEvent 共享 delete worker 的记账事务。订阅者应挂在下游 sink transport 上,使用 event.WithGroup("..."),并依赖 Inbox 中间件去重。

事件携带的 DeleteReason

ReasonWire 值含义
DeleteReasonReplacedreplaceduploaded_file 字段被新值覆盖
DeleteReasonDeleteddeleted拥有该 key 的业务行被删
DeleteReasonClaimExpiredclaim_expiredpending claim 超时(仅框架内 sweeper 使用)
DeleteReasonAbortedaborted上传方取消了进行中的上传(仅框架内 abort_upload 使用)
DeleteReasonOrphanedorphaned上传已完成但未被业务事务采纳(仅框架内 claim sweeper 使用)

dead-letter 事件携带清洗后的 lastError 分类,而不是原始后端错误。 当前取值是 access_deniedbucket_not_foundsession_not_foundtransient

公开 supporting API:

API 组公开 surface
事件构造器EventTypeFileClaimed, EventTypeFileDeleted, EventTypeDeleteDeadLetter, NewFileClaimedEvent, NewFileDeletedEvent, NewDeleteDeadLetterEvent
门面构造器NewFiles, NewFilesFor, MultipartFor
生命周期服务ClaimConsumer, DeleteEnqueuer, Files, FilesFor[T], FileRegistry
存储接口Service, Multipart, FileACL, URLKeyMapper
URL mapperDefaultFileACL, IdentityURLKeyMapper, ProxyURLKeyMapper, DefaultProxyPrefix
metadata helperCanonicalizeMetadataKeys
注册表类型FileRecord, FileStatus, FileStatusUploaded, FileStatusClaimed, FileStatusDeleted
option structPutObjectOptions, GetObjectOptions, DeleteObjectOptions, DeleteObjectsOptions, CopyObjectOptions, StatObjectOptions, InitMultipartOptions, PutPartOptions, CompleteMultipartOptions, AbortMultipartOptions
结果结构ObjectInfo, MultipartSession, PartInfo, CompletedPart, FileRef
meta 常量MetaType, MetaTypeUploadedFile, MetaTypeRichText, MetaTypeMarkdown

storage.CanonicalizeMetadataKeys(m) 会返回一个新 metadata map,key 使用 S3/HTTP-header canonical form,例如 author 会变成 Author;nil 或空输入返回 nil。每个 backend 都在 store boundary 应用这个 helper,因此 metadata 会以统一的 provider-neutral 形状 round-trip。

错误

storage 包暴露两类错误值;两类都可以用 errors.Is 匹配,但只有第一类是 纯 Go sentinel。

纯 Go sentinel(errors.New,自身不携带 API code 或 HTTP status):

错误触发
storage.ErrUploadSessionNotFoundmultipart session 已关闭或不存在
storage.ErrPartTooSmall非最后一片小于 PartSize()
storage.ErrPartETagMismatchcompletion 时记录的 part ETag 与后端状态不匹配
storage.ErrPartNumberOutOfRangeparts 没有覆盖 1..N 连续区间
storage.ErrClaimNotFoundConsume 引用的 claim 不存在或归属其他 principal
storage.ErrAccessDenied生命周期方法接收到 nil / 匿名 principal
storage.ErrBucketNotFound / ErrObjectNotFound / ErrInvalidBucketNameprovider 层查找失败

result.Err 业务错误经 API envelope 承载,ErrCode* 常量编号范围 2200-2299;响应保持 HTTP 200,失败由 body 的 code 表达。每个 ErrCode* 常量对应一个同名的 result.Err 值 (storage.ErrCodeUploadSizeMismatchstorage.ErrUploadSizeMismatch):

Code错误i18n key触发条件
2200storage.ErrInvalidFileKeystorage_invalid_file_key下载代理(/storage/files/<key>)收到不合法的 object key
2201storage.ErrFileNotFoundstorage_file_not_found代理下载:后端找不到对象
2202storage.ErrFailedToGetFilestorage_failed_to_get_file代理下载:后端读取或 FileACL 评估失败
2203storage.ErrClaimNotPendingstorage_claim_not_pendingupload_part / list_parts 操作了不再处于 pending 状态的 claim(例如已完成)
2204storage.ErrClaimExpiredstorage_claim_expiredclaim 的 expiresAt(TTL 为 vef.storage.claim_ttl,默认 24h)已过
2205storage.ErrUploadSizeExceedsLimitstorage_upload_size_exceeds_limit声明的 size 超过 vef.storage.max_upload_size;在 init_upload 检查,并在 upload_part / complete_upload 复查
2206storage.ErrMultipartNotSupportedstorage_multipart_not_supported配置的后端未实现 storage.Multipart
2207storage.ErrPublicUploadsNotAllowedstorage_public_uploads_not_allowedpublic = truevef.storage.allow_public_uploads 为 false
2208storage.ErrUploadTooManyPartsstorage_upload_too_many_partspart 计划超过后端的 MaxPartCount()
2209storage.ErrTooManyPendingUploadsstorage_too_many_pending_uploadsprincipal 已持有 vef.storage.max_pending_claims 个 pending claim
2210storage.ErrUploadRequiresMultipartstorage_upload_requires_multipartupload_part 收到 JSON body 而不是 multipart/form-data
2211storage.ErrUploadRequiresFilestorage_upload_requires_fileupload_part 缺少名为 file 的表单分片
2212storage.ErrClaimNotMultipartstorage_claim_not_multipartclaim 行没有绑定后端 session(防御性检查;只可能由被中断的 init_upload 产生)
2213storage.ErrUploadPartNumberOutOfRangestorage_part_number_out_of_rangepartNumber 超过 partCount(小于 1 的值在参数校验阶段就已失败)
2214storage.ErrUploadPartTooLargestorage_upload_part_too_large某个 part 大于 partSize
2215storage.ErrUploadPartTooSmallstorage_upload_part_too_small非末片小于 partSize
2216storage.ErrUploadPartsIncompletestorage_upload_parts_incompletecomplete_upload 时已记录的 parts 少于 partCount
2217storage.ErrUploadObjectNotFoundstorage_object_not_foundcomplete_upload 幂等重试:后端 session 已关闭且对象不存在
2218storage.ErrUploadSizeMismatchstorage_upload_size_mismatch组装后的对象大小与声明的 size 不符;返回错误前会先删除该对象
2220storage.ErrInvalidFilenamestorage_invalid_filenameinit_upload 收到了无效的文件名

Storage API 错误码常量

常量触发条件
storage.ErrCodeInvalidFileKey2200下载代理(/storage/files/<key>)收到不合法的 object key
storage.ErrCodeFileNotFound2201后端找不到对象
storage.ErrCodeFailedToGetFile2202后端读取或 FileACL 评估失败
storage.ErrCodeClaimNotMultipart2212claim 行没有绑定后端 session
storage.ErrCodeInvalidFilename2220init_upload 收到了无效的文件名

归属越权与未知 claim ID 不在这个编号区间:它们统一用框架通用的 result.ErrAccessDenied(code 1100,HTTP 403)回答,使 API 不泄露某个 claim ID 是否存在(abort_upload 除外 —— 见上文)。

最小服务示例

package avatars

import (
"context"
"strings"

"github.com/coldsmirk/vef-framework-go/storage"
)

func SaveAvatar(ctx context.Context, svc storage.Service) error {
_, err := svc.PutObject(ctx, storage.PutObjectOptions{
Key: "pub/avatars/user-1001.txt",
Reader: strings.NewReader("demo"),
Size: int64(len("demo")),
ContentType: "text/plain",
})

return err
}

CRUD 集成模式

对于带有 meta 文件字段的模型,建议用类型化 hook 集成 FilesFor[T]

filesUser := storage.NewFilesFor[User](filesFacade)

create := crud.NewCreate[User, UserParams]().
AfterTx(func(ctx context.Context, tx orm.DB, principal *security.Principal, model *User) error {
return filesUser.OnCreate(ctx, tx, principal, model)
})

泛型 CRUD 默认写路径已经接好了 FilesFor[T](见 Hooks),自定义写路径照此写就行。

实践建议

  • 依赖 storage.Servicestorage.Multipart,不依赖具体 provider 类型。
  • 所有 Files / FilesFor[T] 调用都放进业务事务里 —— 这就是这个门面的意义。
  • 把未确认的对象当作隔离状态:claim sweeper 会最终回收,绕开 claim 直接调 PutObject 等于绕开生命周期。
  • 一旦开始存私有文件,请实现一个真正的 FileACL,默认实现拒绝所有 priv/* 读。
  • vef.storage.delete.dead_letter 接入 ops 看板 —— 队列行已经退役,事件里带着排查所需的信息。
  • 模块使用的 extension group 名称是 vef:api:resourcesvef:app:middlewares;需要替换 URL 映射时使用 vef.SupplyURLKeyMapper(...)

下一步

参考 自定义 Handler 把直接调用 storage.Service 与业务流程结合,或读 事件总线 了解生命周期事件背后的 outbox transport。