文件存储
VEF 提供一套与 provider 无关的存储抽象、三种内置 provider、分片上传协议、用于 CRUD 流程同步模型与后端文件引用的类型化生命周期门面,以及用于下游清理的事务性 outbox。
类型化生命周期门面是
Files/FilesFor[T]——没有Promoter[T]。上传协议是带显式 claim / queue 生命周期的分片 multipart,principal 授权贯穿全流程。本页描述当前公开 API surface。
支持的 Provider
| 配置值 | 后端 |
|---|---|
memory | 进程内 map;测试和短期演示 |
filesystem | 本地文件系统 |
minio | MinIO / S3 兼容对象存储 |
storage.provider 选择后端。未配置时默认 memory 并输出 warning;对象会在进程重启后丢失。
如果希望 storage 模块在启动时创建所需表,设置
vef.storage.auto_migrate = true。迁移是幂等的,会检查
sys_storage_upload_claim、sys_storage_upload_part、
sys_storage_pending_delete 和 sys_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:PutObjectOptions、GetObjectOptions、DeleteObjectOptions、DeleteObjectsOptions、CopyObjectOptions、StatObjectOptions。框架刻意不支持位置参数 ——这样后续追加字段始终是可叠加变更。
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连续区间。- 调用
CompleteMultipart或AbortMultipart后会话关闭,后续操作返回ErrUploadSessionNotFound。AbortMultipart幂等。
sys/storage.list_partsRPC action 用于让客户端恢复上传中断,它由框架的 part-store 表服务,不是storage.Multipart上的ListParts方法 —— 后端接口本身只暴露上面 6 个方法。
内置资源:sys/storage
存储模块注册了一个 RPC 资源,包含分片上传动作:
| Action | 访问方式 | 入参 | 出参 | 作用 |
|---|---|---|---|---|
init_upload | Bearer 认证(引擎默认) | InitUploadParams | InitUploadResult | 创建 pending claim、开启 multipart session,并返回不透明 claimId 与协商出的 partSize |
upload_part | Bearer 认证(引擎默认) | UploadPartParams(multipart 表单) | UploadPartResult | 上传一片 |
list_parts | Bearer 认证(引擎默认) | ListPartsParams | ListPartsResult | 列出已上传的 parts |
complete_upload | Bearer 认证(引擎默认) | CompleteUploadParams | CompleteUploadResult | 完成 session;服务端从已记录的 parts 组装最终清单 |
abort_upload | Bearer 认证(引擎默认) | AbortUploadParams | 成功,无 data 载荷 | 中止并释放 session |
下载通过下方代理中间件提供。
文件注册表资源:sys/storage/file
一个独立的只读资源(sys/storage/file),暴露一个 action:
| Action | 访问方式 | 入参 | 出参 | 作用 |
|---|---|---|---|---|
file/resolve | Bearer 认证(引擎默认) | ResolveParams | ResolveResult | 批量解析 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/html、application/javascript
这类同源不安全类型会被扩展名探测结果或 application/octet-stream 替换。
init_upload
InitUploadParams:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
filename | string | 是 | 原始文件名,最长 255 字符(validate:"required,max=255")。当扩展名为纯字母数字(.pdf、.tar,模式 ^\.[a-zA-Z0-9]+$)时会复用到 object key,否则回退为 .bin。持久化在 claim 行上,并作为 originalFilename 原样回显。 |
size | int64 | 是 | 对象的精确总字节数,至少为 1(validate:"required,min=1")。会按 vef.storage.max_upload_size(默认 1 GiB)校验 —— 超限返回 ErrCodeUploadSizeExceedsLimit——并用于计算 part 计划。complete_upload 之后还会核对实际上传总量与此声明一致。 |
contentType | string | 否 | 客户端建议的 MIME 类型,最长 127 字符(validate:"max=127")。服务端会清洗:image/*、audio/*、video/*、font/* 前缀以及 application/pdf、application/zip、application/gzip、application/x-tar、application/octet-stream 这些精确类型原样接受;其余值由扩展名探测替换,最终回退 application/octet-stream。 |
public | bool | 否 | true 时 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:
| 字段 | 类型 | 说明 |
|---|---|---|
key | string | 位于 priv/ 或 pub/ 下的最终 object key,init 时即确定。 |
claimId | string | 后续所有 action 使用的不透明 session 句柄。这是唯一的客户端可见标识 —— 后端 multipart UploadID 永远留在服务端。 |
originalFilename | string | 客户端传入的 filename,持久化在 claim 行上(不在后端 metadata 里),因此与存储后端无关、始终可用。 |
partSize | int64 | 后端权威的切片字节数(MinIO 16 MiB、filesystem 4 MiB、memory 64 KiB)。除最后一片外每片必须恰好是这个大小。客户端不要假设固定值 —— 始终使用返回值。 |
partCount | int | 需要上传的 part 数量,即 ceil(size / partSize)。小文件也会得到 partCount = 1。 |
expiresAt | 时间戳(RFC 3339) | claim(连同上传 session)失效的时间。 |
upload_part
upload_part 拒绝 JSON body(ErrCodeUploadRequiresMultipart)。请发送
multipart/form-data:resource、action、version 是普通表单字段,
params 是 JSON 字符串,part 原始字节放在名为 file 的表单分片里(缺失时
返回 ErrCodeUploadRequiresFile)。
UploadPartParams:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
file | 表单文件分片 | 是 | 该 part 的原始字节。大于 partSize 返回 ErrCodeUploadPartTooLarge;非末片小于 partSize 返回 ErrCodeUploadPartTooSmall(只有最后一片可以承载余量)。 |
claimId | string | 是 | init_upload 返回的句柄(validate:"required")。claim 必须归属调用方、处于 pending 且未过期。 |
partNumber | int | 是 | 1 起的 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:
| 字段 | 类型 | 说明 |
|---|---|---|
partNumber | int | 已接受的 part 序号,原样回显。 |
size | int64 | 后端为该 part 记录的字节数。 |
list_parts
ListPartsParams:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
claimId | string | 是 | 要查询的进行中 session(validate:"required")。claim 必须归属调用方、处于 pending 且未过期 —— 已完成的 claim 返回 ErrCodeClaimNotPending。 |
ListPartsResult:
| 字段 | 类型 | 说明 |
|---|---|---|
parts | ListedPart[] | 已接受的 parts,按 partNumber 升序排列。 |
ListedPart:
| 字段 | 类型 | 说明 |
|---|---|---|
partNumber | int | 1 起的 part 序号。 |
size | int64 | 已记录的字节数。 |
该列表由框架的 part-store 表提供,而不是后端原生列举,并且与
complete_upload 组装用的是同一张表 —— 这里列出的每个 part 都会被原样采
信。part 的 ETag 刻意不暴露。
complete_upload
CompleteUploadParams:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
claimId | string | 是 | 要完成的 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 加上框架跟踪的原始文件名:
| 字段 | 类型 | 说明 |
|---|---|---|
bucket | string | 后端 bucket。MinIO 报告真实 bucket;无 bucket 概念的后端使用哨兵值 filesystem 与 memory。 |
key | string | 最终 object key(与 init_upload 返回的一致)。 |
eTag | string | 组装后对象的 ETag —— 不是 part ETag,也永远不是此 action 的入参。 |
size | int64 | 最终对象字节数。 |
contentType | string | 对象保存的清洗后 content type。 |
lastModified | 时间戳(RFC 3339) | 后端 last-modified 时间。 |
metadata | object(string→string) | 后端用户 metadata,key 已 canonical 化;为空时省略。上传 RPC 从不接受客户端 metadata,所以经此资源上传的对象不携带任何 metadata。 |
originalFilename | string | init_upload 时记录的原始文件名。 |
abort_upload
AbortUploadParams:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
claimId | string | 是 | 要取消的 session(validate:"required")。 |
行为 —— abort 是可安全重试的清理路径:
- 未知
claimId返回成功(唯一不用 access-denied 回答缺失 claim 的 action);归属其他 principal 的 claim 仍会被result.ErrAccessDenied拒绝。 - 只有
pendingclaim 会被中止。对uploadedclaim 调用是 no-op 成功 —— abort 绝不会删除已完成的对象。 - pending claim 在一个事务里被释放:part 行与 claim 行被删除,并插入一条
pending_delete记录以便后端清理。删除工作器随后异步中止后端 session 并 删除已发布字节,带重试/退避和死信队列。这使得 abort 具有崩溃安全性, 且不会因后端临时不可用而失败。 - 成功响应没有载荷:
data为null。
客户端演练:分片上传
以下是客户端对 POST /api 实现的完整线上(wire)序列,示例为在 MinIO 后端上传一个 40 MiB 的 report.pdf(partSize 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-data:resource、action、version 是普通表单字段,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.PublicPrefix | pub/ | 世界可读;默认 ACL 直接放行 |
storage.PrivatePrefix | priv/ | 由业务侧 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 |
| 缓存 header | pub/* 返回 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 字段:
| 字段 | 类型 | 说明 |
|---|---|---|
key | string | 存储 object key,也是自然的关联键 |
originalFilename | string | 上传时客户端提供的文件名 |
contentType | string | 清洗后的 MIME 类型 |
size | int64 | 对象字节数 |
public | bool | 该对象是否落在公开前缀下 |
status | string | 生命周期状态:uploaded → claimed → deleted |
startedAt | 时间戳 | 上传 session 的开启时间 |
claimedAt | 时间戳 | 业务事务采纳时间;未被引用时为 nil |
deletedAt | 时间戳 | delete worker 删除时间;对象仍存在时为 nil |
deleteReason | string | 删除原因;删除前为空 |
uploadedBy | string | 上传该对象的 principal ID;由嵌入的审计列 createdBy 映射而来 |
FileRecord 还嵌入了标准身份/审计列(id、createdAt、createdBy);
createdBy 即上传者,resolve 将其作为 uploadedBy 返回。
FileStatus 取值:
| 常量 | 值 | 含义 |
|---|---|---|
FileStatusUploaded | uploaded | 对象已在后端最终化,尚未被业务引用 |
FileStatusClaimed | claimed | 业务事务已采纳此上传 |
FileStatusDeleted | deleted | delete 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 会写入一个属于上传调用方、状态为 pending 的
upload_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.claimed、
vef.storage.file.deleted 和 vef.storage.delete.dead_letter 是否路由到
事务性 event transport;否则直接启动失败。实践中应启用 outbox transport,
并把 vef.storage.* 路由到 outbox,或者把默认 event transport 设为
outbox。
生命周期工作器配置(均在 vef.storage 下):
| 配置键 | 默认值 | 作用 |
|---|---|---|
sweep_interval | 5m | claim sweeper 运行间隔 |
sweep_batch_size | 200 | 每次扫描的 claim 数 |
orphan_retention | 0(禁用) | 回收完成但未被引用超过该时长的上传;默认禁用,因为会删除用户数据 |
delete_worker_interval | 5m | delete worker 轮询间隔 |
delete_batch_size | 100 | 每轮消费的 pending-delete 行数 |
delete_concurrency | 8 | 并发后端删除操作数 |
delete_max_attempts | 12 | 死信前重试次数 |
delete_lease_window | 5m | pending-delete 行租赁时长 |
Files 与 FilesFor[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,整条记录被删时用DeleteReasonDeleted。DeleteReasonClaimExpired是框架内部专用,不要传。
如果你发现自己在
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_file | string / *string / []string / map[string]string | 字段值就是 file key — 对 map 而言取的是 value,map 的 key 只是自定义标签 |
rich_text | string | 扫描 HTML 中嵌入的资源 URL,再通过 URLKeyMapper 翻译 |
markdown | string | 扫描 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 配合使用。
存储事件
| 类型常量 / topic | Payload / 构造器 | JSON payload | 触发 |
|---|---|---|---|
EventTypeFileClaimed / vef.storage.file.claimed | FileClaimedEvent; NewFileClaimedEvent(key) | fileKey | 某次业务事务采纳了一个之前 pending 的 claim(Files.OnCreate 或 update 新侧) |
EventTypeFileDeleted / vef.storage.file.deleted | FileDeletedEvent; NewFileDeletedEvent(key, reason) | fileKey, reason | delete worker 成功把对象从后端删除 |
EventTypeDeleteDeadLetter / vef.storage.delete.dead_letter | DeleteDeadLetterEvent; NewDeleteDeadLetterEvent(id, key, reason, attempts, lastErr) | pendingDeleteId, fileKey, reason, attempts,可选 lastError | delete worker 重试用尽;该队列行会在事件发布后被删除 |
三者都通过 outbox transport 搭配 event.WithTx(...) 发布。FileClaimedEvent 共享调用方业务事务;FileDeletedEvent 与 DeleteDeadLetterEvent 共享 delete worker 的记账事务。订阅者应挂在下游 sink transport 上,使用 event.WithGroup("..."),并依赖 Inbox 中间件去重。
事件携带的 DeleteReason:
| Reason | Wire 值 | 含义 |
|---|---|---|
DeleteReasonReplaced | replaced | uploaded_file 字段被新值覆盖 |
DeleteReasonDeleted | deleted | 拥有该 key 的业务行被删 |
DeleteReasonClaimExpired | claim_expired | pending claim 超时(仅框架内 sweeper 使用) |
DeleteReasonAborted | aborted | 上传方取消了进行中的上传(仅框架内 abort_upload 使用) |
DeleteReasonOrphaned | orphaned | 上传已完成但未被业务事务采纳(仅框架内 claim sweeper 使用) |
dead-letter 事件携带清洗后的 lastError 分类,而不是原始后端错误。
当前取值是 access_denied、bucket_not_found、session_not_found 和
transient。
公开 supporting API:
| API 组 | 公开 surface |
|---|---|
| 事件构造器 | EventTypeFileClaimed, EventTypeFileDeleted, EventTypeDeleteDeadLetter, NewFileClaimedEvent, NewFileDeletedEvent, NewDeleteDeadLetterEvent |
| 门面构造器 | NewFiles, NewFilesFor, MultipartFor |
| 生命周期服务 | ClaimConsumer, DeleteEnqueuer, Files, FilesFor[T], FileRegistry |
| 存储接口 | Service, Multipart, FileACL, URLKeyMapper |
| URL mapper | DefaultFileACL, IdentityURLKeyMapper, ProxyURLKeyMapper, DefaultProxyPrefix |
| metadata helper | CanonicalizeMetadataKeys |
| 注册表类型 | FileRecord, FileStatus, FileStatusUploaded, FileStatusClaimed, FileStatusDeleted |
| option struct | PutObjectOptions, 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.ErrUploadSessionNotFound | multipart session 已关闭或不存在 |
storage.ErrPartTooSmall | 非最后一片小于 PartSize() |
storage.ErrPartETagMismatch | completion 时记录的 part ETag 与后端状态不匹配 |
storage.ErrPartNumberOutOfRange | parts 没有覆盖 1..N 连续区间 |
storage.ErrClaimNotFound | Consume 引用的 claim 不存在或归属其他 principal |
storage.ErrAccessDenied | 生命周期方法接收到 nil / 匿名 principal |
storage.ErrBucketNotFound / ErrObjectNotFound / ErrInvalidBucketName | provider 层查找失败 |
result.Err 业务错误经 API envelope 承载,ErrCode* 常量编号范围
2200-2299;响应保持 HTTP 200,失败由 body 的 code 表达。每个
ErrCode* 常量对应一个同名的 result.Err 值
(storage.ErrCodeUploadSizeMismatch ↔ storage.ErrUploadSizeMismatch):
| Code | 错误 | i18n key | 触发条件 |
|---|---|---|---|
2200 | storage.ErrInvalidFileKey | storage_invalid_file_key | 下载代理(/storage/files/<key>)收到不合法的 object key |
2201 | storage.ErrFileNotFound | storage_file_not_found | 代理下载:后端找不到对象 |
2202 | storage.ErrFailedToGetFile | storage_failed_to_get_file | 代理下载:后端读取或 FileACL 评估失败 |
2203 | storage.ErrClaimNotPending | storage_claim_not_pending | upload_part / list_parts 操作了不再处于 pending 状态的 claim(例如已完成) |
2204 | storage.ErrClaimExpired | storage_claim_expired | claim 的 expiresAt(TTL 为 vef.storage.claim_ttl,默认 24h)已过 |
2205 | storage.ErrUploadSizeExceedsLimit | storage_upload_size_exceeds_limit | 声明的 size 超过 vef.storage.max_upload_size;在 init_upload 检查,并在 upload_part / complete_upload 复查 |
2206 | storage.ErrMultipartNotSupported | storage_multipart_not_supported | 配置的后端未实现 storage.Multipart |
2207 | storage.ErrPublicUploadsNotAllowed | storage_public_uploads_not_allowed | public = true 但 vef.storage.allow_public_uploads 为 false |
2208 | storage.ErrUploadTooManyParts | storage_upload_too_many_parts | part 计划超过后端的 MaxPartCount() |
2209 | storage.ErrTooManyPendingUploads | storage_too_many_pending_uploads | principal 已持有 vef.storage.max_pending_claims 个 pending claim |
2210 | storage.ErrUploadRequiresMultipart | storage_upload_requires_multipart | upload_part 收到 JSON body 而不是 multipart/form-data |
2211 | storage.ErrUploadRequiresFile | storage_upload_requires_file | upload_part 缺少名为 file 的表单分片 |
2212 | storage.ErrClaimNotMultipart | storage_claim_not_multipart | claim 行没有绑定后端 session(防御性检查;只可能由被中断的 init_upload 产生) |
2213 | storage.ErrUploadPartNumberOutOfRange | storage_part_number_out_of_range | partNumber 超过 partCount(小于 1 的值在参数校验阶段就已失败) |
2214 | storage.ErrUploadPartTooLarge | storage_upload_part_too_large | 某个 part 大于 partSize |
2215 | storage.ErrUploadPartTooSmall | storage_upload_part_too_small | 非末片小于 partSize |
2216 | storage.ErrUploadPartsIncomplete | storage_upload_parts_incomplete | complete_upload 时已记录的 parts 少于 partCount |
2217 | storage.ErrUploadObjectNotFound | storage_object_not_found | complete_upload 幂等重试:后端 session 已关闭且对象不存在 |
2218 | storage.ErrUploadSizeMismatch | storage_upload_size_mismatch | 组装后的对象大小与声明的 size 不符;返回错误前会先删除该对象 |
2220 | storage.ErrInvalidFilename | storage_invalid_filename | init_upload 收到了无效的文件名 |
Storage API 错误码常量
| 常量 | 码 | 触发条件 |
|---|---|---|
storage.ErrCodeInvalidFileKey | 2200 | 下载代理(/storage/files/<key>)收到不合法的 object key |
storage.ErrCodeFileNotFound | 2201 | 后端找不到对象 |
storage.ErrCodeFailedToGetFile | 2202 | 后端读取或 FileACL 评估失败 |
storage.ErrCodeClaimNotMultipart | 2212 | claim 行没有绑定后端 session |
storage.ErrCodeInvalidFilename | 2220 | init_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.Service和storage.Multipart,不依赖具体 provider 类型。 - 所有
Files/FilesFor[T]调用都放进业务事务里 —— 这就是这个门面的意义。 - 把未确认的对象当作隔离状态:claim sweeper 会最终回收,绕开 claim 直接调
PutObject等于绕开生命周期。 - 一旦开始存私有文件,请实现一个真正的
FileACL,默认实现拒绝所有priv/*读。 - 把
vef.storage.delete.dead_letter接入 ops 看板 —— 队列行已经退役,事件里带着排查所需的信息。 - 模块使用的 extension group 名称是
vef:api:resources和vef:app:middlewares;需要替换 URL 映射时使用vef.SupplyURLKeyMapper(...)。
下一步
参考 自定义 Handler 把直接调用 storage.Service 与业务流程结合,或读 事件总线 了解生命周期事件背后的 outbox transport。