FileCodeBox Go 重写版 v2.5.6(安全审计修复版)

Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务:

- 安全审计全部修复(docs/security-audit-2026-09-05.md):
  bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、
  全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、
  管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、
  会话默认 7 天、限流缓存故障降级、robots.txt 端点等
- 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固
- Redis 支持库号(FCB_REDIS_DB / redis://…/db URL)
- 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、
  提码 5 位起、chunk 32MiB 上限、admin 审计动作等)

验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
This commit is contained in:
2026-09-05 04:22:41 +08:00
commit 9686fe887a
173 changed files with 32455 additions and 0 deletions
+650
View File
@@ -0,0 +1,650 @@
package storage
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
)
// S3Storage 基于 aws-sdk-go-v2 的 S3 兼容对象存储引擎(AWS / MinIO / R2 / OSS 等)。
//
// 相比参考实现(S3FileStorageaioboto3)的改进:
// - 单例客户端 + 自定义连接池 Transport(参考实现每次操作新建 session);
// - SaveFile 走 manager.Uploader 分片并发上传(未知长度也可流式,内存占用 ≤ partSize);
// - SaveChunk 落本地临时文件获取精确 Content-Length(参考实现整块读入内存);
// - MergeChunks 用 S3 原生 multipart 流式合并,边读边校验哈希(不落盘、不整块进内存);
// - 5xx/网络错误由 SDK 内置指数退避重试器处理(可配次数)。
type S3Storage struct {
client *s3.Client
presigner *s3.PresignClient
uploader *manager.Uploader
bucket string
}
// NewS3Storage 构造 S3 引擎。
func NewS3Storage(opts S3Options) (*S3Storage, error) {
if strings.TrimSpace(opts.Bucket) == "" {
return nil, fmt.Errorf("storage/s3: 缺少 bucket 配置(s3_bucket_name")
}
region := strings.TrimSpace(opts.Region)
if region == "" {
region = "us-east-1"
}
loadOpts := []func(*config.LoadOptions) error{
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
opts.AccessKeyID, opts.SecretAccessKey, opts.SessionToken,
)),
// SDK 内置重试器:标准模式,指数退避 + 抖动,覆盖 5xx 与网络错误。
config.WithRetryMaxAttempts(3),
// 兼容性:仅协议要求时才计算校验和。默认的 trailing CRC32 需要可重放流
// 或 TLSMinIO/R2 等自建端点通常不需要,关闭后 MergeChunks 的
// GET→UploadPart 纯流式转发才能工作。
config.WithRequestChecksumCalculation(aws.RequestChecksumCalculationWhenRequired),
config.WithResponseChecksumValidation(aws.ResponseChecksumValidationWhenRequired),
}
if ep := strings.TrimSpace(opts.Endpoint); ep != "" {
loadOpts = append(loadOpts, config.WithBaseEndpoint(ep))
}
awsCfg, err := config.LoadDefaultConfig(context.Background(), loadOpts...)
if err != nil {
return nil, fmt.Errorf("storage/s3: 初始化 SDK 配置失败: %w", err)
}
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
// 寻址风格:path 显式启用;auto 时自定义端点(自建 MinIO 等)默认 path-style。
switch strings.ToLower(strings.TrimSpace(opts.AddressingStyle)) {
case "path":
o.UsePathStyle = true
case "virtual":
o.UsePathStyle = false
default: // auto
o.UsePathStyle = strings.TrimSpace(opts.Endpoint) != ""
}
// 连接复用:自定义 Transport 连接池。
o.HTTPClient = newPooledHTTPClient()
})
st := &S3Storage{
client: client,
presigner: s3.NewPresignClient(client),
bucket: opts.Bucket,
}
st.uploader = manager.NewUploader(client, func(u *manager.Uploader) {
u.PartSize = 5 * 1024 * 1024 // 5MBS3 multipart 最小分片
u.Concurrency = 4
u.LeavePartsOnError = false
})
return st, nil
}
func init() {
RegisterEngine("s3", func(ctx context.Context) (Storage, error) {
return NewS3Storage(engineOptions.S3)
})
}
// newPooledHTTPClient 供 SDK 使用的连接池化 HTTP 客户端。
func newPooledHTTPClient() *http.Client {
return &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 16,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: time.Second,
ResponseHeaderTimeout: 60 * time.Second,
},
}
}
// key 校验并规范化对象键(拒绝穿越,统一斜杠)。
func (s *S3Storage) key(savePath string) (string, error) {
cleaned, ok := SanitizePath(savePath)
if !ok {
return "", fmt.Errorf("%w: %q", ErrInvalidPath, savePath)
}
return cleaned, nil
}
// SaveFile 流式保存:manager.Uploader 按需分片并发上传,内存占用恒定。
func (s *S3Storage) SaveFile(ctx context.Context, r io.Reader, savePath string) (int64, error) {
key, err := s.key(savePath)
if err != nil {
return 0, err
}
src := &countingReader{r: r}
_, err = s.uploader.Upload(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: src,
ContentType: aws.String("application/octet-stream"),
})
if err != nil {
return src.count(), mapS3Error(err, "PutObject")
}
return src.count(), nil
}
// DeleteFile 删除对象;S3 对不存在的键也返回成功。
func (s *S3Storage) DeleteFile(ctx context.Context, savePath string) error {
key, err := s.key(savePath)
if err != nil {
return err
}
_, err = s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
return mapS3Error(err, "DeleteObject")
}
// Open 获取下载流:Range 直接透传为 GetObject Range 头(流式,不落盘)。
func (s *S3Storage) Open(ctx context.Context, savePath string, rng *Range) (*Download, error) {
key, err := s.key(savePath)
if err != nil {
return nil, err
}
input := &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}
if rng != nil {
input.Range = aws.String(rangeHeaderValue(rng))
}
out, err := s.client.GetObject(ctx, input)
if err != nil {
return nil, mapS3Error(err, "GetObject")
}
size := aws.ToInt64(out.ContentLength)
start, end := int64(0), size-1
if cr := aws.ToString(out.ContentRange); cr != "" { // 服务端按 206 返回了区间
if sr, e, total, ok := parseContentRange(cr); ok {
start, end = sr, e
if total >= 0 {
size = total
}
}
}
return &Download{
ReadCloser: out.Body,
Start: start,
End: end,
Total: size,
Meta: FileMeta{
Size: size,
ContentType: aws.ToString(out.ContentType),
AcceptRanges: true,
},
}, nil
}
// rangeHeaderValue 将 Range 结构转为 HTTP Range 头值。
func rangeHeaderValue(rng *Range) string {
if rng.End < 0 {
return fmt.Sprintf("bytes=%d-", rng.Start)
}
return fmt.Sprintf("bytes=%d-%d", rng.Start, rng.End)
}
// parseContentRange 解析 "bytes 0-99/1000"total 可能为 "*")。
func parseContentRange(v string) (start, end, total int64, ok bool) {
v = strings.TrimSpace(v)
if !strings.HasPrefix(v, "bytes ") {
return 0, 0, -1, false
}
parts := strings.SplitN(strings.TrimPrefix(v, "bytes "), "/", 2)
if len(parts) != 2 {
return 0, 0, -1, false
}
total = -1
if parts[1] != "*" {
t, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, 0, -1, false
}
total = t
}
se := strings.SplitN(parts[0], "-", 2)
if len(se) != 2 {
return 0, 0, -1, false
}
s0, err1 := strconv.ParseInt(se[0], 10, 64)
e0, err2 := strconv.ParseInt(se[1], 10, 64)
if err1 != nil || err2 != nil {
return 0, 0, -1, false
}
return s0, e0, total, true
}
// Stat 获取对象元信息(HeadObject)。
func (s *S3Storage) Stat(ctx context.Context, savePath string) (*FileMeta, error) {
key, err := s.key(savePath)
if err != nil {
return nil, err
}
out, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, mapS3Error(err, "HeadObject")
}
return &FileMeta{
Size: aws.ToInt64(out.ContentLength),
ContentType: aws.ToString(out.ContentType),
AcceptRanges: true,
}, nil
}
// SaveChunk 保存分片对象:落临时文件获取精确长度后 PutObject(可重试)。
func (s *S3Storage) SaveChunk(ctx context.Context, uploadID string, chunkIndex int, r io.Reader, savePath string) (int64, error) {
if _, err := s.key(savePath); err != nil {
return 0, err
}
key, err := s.key(ChunkPartPath(savePath, uploadID, chunkIndex))
if err != nil {
return 0, err
}
// 落临时文件:获得精确 Content-Length 与可重放 Body(网络失败可安全重试)。
tmp, err := os.CreateTemp("", "fcb-s3-chunk-*")
if err != nil {
return 0, fmt.Errorf("storage/s3: 创建临时文件失败: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = tmp.Close(); _ = os.Remove(tmpName) }()
size, err := io.Copy(tmp, r)
if err != nil {
return 0, fmt.Errorf("storage/s3: 缓存分片失败: %w", err)
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
return 0, fmt.Errorf("storage/s3: 回卷分片失败: %w", err)
}
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: tmp,
ContentLength: aws.Int64(size),
ContentType: aws.String("application/octet-stream"),
})
if err != nil {
return size, mapS3Error(err, "PutObject(分片)")
}
return size, nil
}
// S3 multipart 最小分片限制:除最后一片外每片 ≥5MB,否则 Complete 返回 EntityTooSmall。
// 分片上传的分块大小由服务端配置保证(建议 ≥5MB)。
const s3MinPartSize = 5 * 1024 * 1024
// MergeChunks 用 S3 原生 multipart 流式合并:
// 逐分片 GET → 边流边算哈希 → UploadPart(带精确 Content-Length)→ Complete。
// 任一步失败即 Abort 并返回错误;成功后清理分片对象。
func (s *S3Storage) MergeChunks(ctx context.Context, uploadID string, total int, verifyHash func(index int) (string, error), savePath string) (int64, string, error) {
if total <= 0 {
return 0, "", fmt.Errorf("storage/s3: 非法分片总数 %d", total)
}
key, err := s.key(savePath)
if err != nil {
return 0, "", err
}
chunkPrefix, err := s.key(chunkDirOf(savePath, uploadID))
if err != nil {
return 0, "", err
}
// 单分片快速路径:直接流式 PutObject,绕过 multipart 的 5MB 限制。
if total == 1 {
return s.mergeSingle(ctx, chunkPrefix+"/0.part", key, verifyHash, 0)
}
mpu, err := s.client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
ContentType: aws.String("application/octet-stream"),
})
if err != nil {
return 0, "", mapS3Error(err, "CreateMultipartUpload")
}
_ = aws.ToString(mpu.UploadId) // S3 侧 multipart 会话 IDAbort 时复用 mpu.UploadId
size := int64(0)
totalHash := sha256.New()
parts := make([]types.CompletedPart, 0, total)
defer func() {
// 出错时取消 multipart(避免残留分片产生存储费用)。
if len(parts) < total {
_, _ = s.client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
UploadId: mpu.UploadId,
})
}
}()
for i := 0; i < total; i++ {
if err := ctx.Err(); err != nil {
return 0, "", err
}
var expected string
if verifyHash != nil {
expected, err = verifyHash(i)
if err != nil {
return 0, "", err
}
}
getOut, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(fmt.Sprintf("%s/%d.part", chunkPrefix, i)),
})
if err != nil {
return 0, "", mapS3Error(err, fmt.Sprintf("GetObject(分片 %d)", i))
}
// 分片先流式落临时文件:计算哈希 + 获得可回卷 body(SDK 签名哈希需要 seekable 流,
// 同时为 UploadPart 失败重试保留数据)。
chunkHash := sha256.New()
tmp, err := os.CreateTemp("", "fcb-s3-part-*")
if err != nil {
_ = getOut.Body.Close()
return 0, "", fmt.Errorf("storage/s3: 创建分片临时文件失败: %w", err)
}
partLen, err := io.Copy(io.MultiWriter(tmp, totalHash, chunkHash), getOut.Body)
_ = getOut.Body.Close()
if err != nil {
_ = tmp.Close()
_ = os.Remove(tmp.Name())
return 0, "", fmt.Errorf("storage/s3: 读取分片 %d 失败: %w", i, err)
}
if expected != "" && expected != hex.EncodeToString(chunkHash.Sum(nil)) {
_ = tmp.Close()
_ = os.Remove(tmp.Name())
return 0, "", fmt.Errorf("%w: 分片 %d 期望 %s", ErrHashMismatch, i, expected)
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
_ = tmp.Close()
_ = os.Remove(tmp.Name())
return 0, "", fmt.Errorf("storage/s3: 回卷分片 %d 失败: %w", i, err)
}
up, err := s.client.UploadPart(ctx, &s3.UploadPartInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
UploadId: mpu.UploadId,
PartNumber: aws.Int32(int32(i + 1)),
Body: tmp,
ContentLength: aws.Int64(partLen),
})
_ = tmp.Close()
_ = os.Remove(tmp.Name())
if err != nil {
return 0, "", mapS3Error(err, fmt.Sprintf("UploadPart(分片 %d)", i))
}
parts = append(parts, types.CompletedPart{
PartNumber: aws.Int32(int32(i + 1)),
ETag: up.ETag,
})
size += partLen
}
if _, err := s.client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
UploadId: mpu.UploadId,
MultipartUpload: &types.CompletedMultipartUpload{Parts: parts},
}); err != nil {
return 0, "", mapS3Error(err, "CompleteMultipartUpload")
}
// 合并成功后清理分片对象(静默容错)。
_ = s.CleanChunks(ctx, uploadID, savePath)
return size, hex.EncodeToString(totalHash.Sum(nil)), nil
}
// mergeSingle 单分片合并快速路径:GET 分片 → 落临时文件校验 → PutObject 正式键。
func (s *S3Storage) mergeSingle(ctx context.Context, chunkKey, dstKey string, verifyHash func(index int) (string, error), index int) (int64, string, error) {
getOut, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(chunkKey),
})
if err != nil {
return 0, "", mapS3Error(err, "GetObject(分片)")
}
defer func() { _ = getOut.Body.Close() }()
tmp, err := os.CreateTemp("", "fcb-s3-merge-*")
if err != nil {
return 0, "", fmt.Errorf("storage/s3: 创建临时文件失败: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = tmp.Close(); _ = os.Remove(tmpName) }()
fileHash := sha256.New()
size, err := io.Copy(io.MultiWriter(tmp, fileHash), getOut.Body)
if err != nil {
return 0, "", fmt.Errorf("storage/s3: 读取分片失败: %w", err)
}
if verifyHash != nil {
expected, err := verifyHash(index)
if err != nil {
return 0, "", err
}
if expected != "" && expected != hex.EncodeToString(fileHash.Sum(nil)) {
return 0, "", fmt.Errorf("%w: 分片 %d 期望 %s", ErrHashMismatch, index, expected)
}
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
return 0, "", fmt.Errorf("storage/s3: 回卷临时文件失败: %w", err)
}
if _, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(dstKey),
Body: tmp,
ContentLength: aws.Int64(size),
ContentType: aws.String("application/octet-stream"),
}); err != nil {
return 0, "", mapS3Error(err, "PutObject(合并)")
}
return size, hex.EncodeToString(fileHash.Sum(nil)), nil
}
// CleanChunks 列举并批量删除分片对象;前缀不存在时静默成功。
func (s *S3Storage) CleanChunks(ctx context.Context, uploadID string, savePath string) error {
prefix, err := s.key(chunkDirOf(savePath, uploadID))
if err != nil {
return err
}
prefix += "/"
paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucket),
Prefix: aws.String(prefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
return mapS3Error(err, "ListObjectsV2(分片)")
}
if len(page.Contents) == 0 {
return nil
}
objs := make([]types.ObjectIdentifier, 0, len(page.Contents))
for _, obj := range page.Contents {
objs = append(objs, types.ObjectIdentifier{Key: obj.Key})
}
if _, err := s.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: aws.String(s.bucket),
Delete: &types.Delete{Objects: objs, Quiet: aws.Bool(true)},
}); err != nil {
return mapS3Error(err, "DeleteObjects(分片)")
}
}
return nil
}
// FileExists HeadObject 探测存在性。
func (s *S3Storage) FileExists(ctx context.Context, savePath string) (bool, error) {
key, err := s.key(savePath)
if err != nil {
return false, err
}
_, err = s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
if isS3NotFound(err) {
return false, nil
}
return false, mapS3Error(err, "HeadObject")
}
return true, nil
}
// PresignGetURL 生成限时下载直链。
func (s *S3Storage) PresignGetURL(ctx context.Context, savePath string, expires int64) (string, error) {
key, err := s.key(savePath)
if err != nil {
return "", err
}
if expires <= 0 {
expires = 3600
}
out, err := s.presigner.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}, s3.WithPresignExpires(time.Duration(expires)*time.Second))
if err != nil {
return "", mapS3Error(err, "PresignGetObject")
}
return out.URL, nil
}
// PresignPutURL 生成限时直传 URL。
func (s *S3Storage) PresignPutURL(ctx context.Context, savePath string, expires int64) (string, error) {
key, err := s.key(savePath)
if err != nil {
return "", err
}
if expires <= 0 {
expires = 900
}
out, err := s.presigner.PresignPutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}, s3.WithPresignExpires(time.Duration(expires)*time.Second))
if err != nil {
return "", mapS3Error(err, "PresignPutObject")
}
return out.URL, nil
}
// HealthCheck 健康检查:列举 bucketMaxKeys=1),同时校验连通性、凭据与 bucket 存在。
func (s *S3Storage) HealthCheck(ctx context.Context) error {
maxKeys := int32(1)
_, err := s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucket),
MaxKeys: aws.Int32(maxKeys),
Prefix: aws.String(""),
})
if err != nil {
return fmt.Errorf("%w: S3 健康检查失败: %v", ErrUnavailable, err)
}
return nil
}
// ReadHead 读取对象前 n 字节(保留的便捷封装:HeadMeta 的仅头部形态)。
func (s *S3Storage) ReadHead(ctx context.Context, savePath string, n int64) ([]byte, error) {
_, head, err := s.HeadMeta(ctx, savePath, n)
return head, err
}
// HeadMeta 读取对象元信息与头部字节(S3 引擎实现:HeadObject + Range GET)。
func (s *S3Storage) HeadMeta(ctx context.Context, savePath string, headBytes int64) (*FileMeta, []byte, error) {
key, err := s.key(savePath)
if err != nil {
return nil, nil, err
}
head, err := s.headBytes(ctx, s.bucket, key, headBytes)
if err != nil {
return nil, nil, err
}
meta, err := s.Stat(ctx, savePath)
if err != nil {
return nil, nil, err
}
return meta, head, nil
}
// headBytes 通过 Range GET 读取对象前 n 字节。
func (s *S3Storage) headBytes(ctx context.Context, bucket, key string, n int64) ([]byte, error) {
if n <= 0 {
return nil, nil
}
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Range: aws.String(fmt.Sprintf("bytes=0-%d", n-1)),
})
if err != nil {
return nil, mapS3Error(err, "GetObject(head)")
}
defer func() { _ = out.Body.Close() }()
return io.ReadAll(io.LimitReader(out.Body, n))
}
// isS3NotFound 判断错误是否为对象不存在。
func isS3NotFound(err error) bool {
var nf *types.NotFound
if errors.As(err, &nf) {
return true
}
var ae smithy.APIError
if errors.As(err, &ae) {
switch ae.ErrorCode() {
case "NotFound", "NoSuchKey":
return true
}
}
var re *awshttp.ResponseError
if errors.As(err, &re) && re.HTTPStatusCode() == http.StatusNotFound {
return true
}
return false
}
// mapS3Error 将 SDK 错误映射为包内哨兵错误。
func mapS3Error(err error, op string) error {
if err == nil {
return nil
}
if isS3NotFound(err) {
return fmt.Errorf("%w%s", ErrNotFound, op)
}
var re *awshttp.ResponseError
if errors.As(err, &re) && re.HTTPStatusCode() == http.StatusRequestedRangeNotSatisfiable {
return fmt.Errorf("%w%s", ErrRangeNotSatisfiable, op)
}
var ae smithy.APIError
if errors.As(err, &ae) && ae.ErrorCode() == "InvalidRange" {
return fmt.Errorf("%w%s", ErrRangeNotSatisfiable, op)
}
return fmt.Errorf("storage/s3: %s 失败: %w", op, err)
}
// 接口编译期断言。
var _ Storage = (*S3Storage)(nil)