From 2413c2fe14bae2b86a6c9458a782d1d427c06dfa Mon Sep 17 00:00:00 2001 From: huang Date: Sat, 18 Apr 2026 10:08:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E6=96=87=E4=BB=B6=E4=B8=8B=E8=BD=BD=E9=A2=84?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=20URL=20=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 POST /api/admin/storage/batch-download-urls,支持一次传入最多 50 个 file_key,返回对应预签名下载 URL 映射。解决列表页批量展示图片需多次请求的问题。 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- internal/handler/admin/storage.go | 36 ++++++++++++++ internal/model/dto/storage_dto.go | 11 +++++ internal/routes/storage.go | 80 +++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/internal/handler/admin/storage.go b/internal/handler/admin/storage.go index 40650a1..85a614c 100644 --- a/internal/handler/admin/storage.go +++ b/internal/handler/admin/storage.go @@ -19,6 +19,42 @@ func NewStorageHandler(service *storage.Service) *StorageHandler { return &StorageHandler{service: service} } +// GetBatchDownloadURLs 批量获取文件下载预签名 URL +// POST /api/admin/storage/batch-download-urls +func (h *StorageHandler) GetBatchDownloadURLs(c *fiber.Ctx) error { + if h.service == nil { + return errors.New(errors.CodeInternalError, "对象存储服务未配置") + } + + var req dto.GetBatchDownloadURLsRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + urls := make(map[string]string, len(req.FileKeys)) + expiresIn := 0 + + for _, fileKey := range req.FileKeys { + result, err := h.service.GetDownloadURL(c.UserContext(), fileKey) + if err != nil { + logger.GetAppLogger().Error("获取下载URL失败", + zap.String("file_key", fileKey), + zap.Error(err), + ) + return errors.New(errors.CodeInternalError, "获取下载URL失败") + } + urls[fileKey] = result.URL + expiresIn = result.ExpiresIn + } + + return response.Success(c, dto.GetBatchDownloadURLsResponse{ + URLs: urls, + ExpiresIn: expiresIn, + }) +} + +// GetUploadURL 获取文件上传预签名 URL +// POST /api/admin/storage/upload-url func (h *StorageHandler) GetUploadURL(c *fiber.Ctx) error { if h.service == nil { return errors.New(errors.CodeInternalError, "对象存储服务未配置") diff --git a/internal/model/dto/storage_dto.go b/internal/model/dto/storage_dto.go index ed9e401..aacf1b7 100644 --- a/internal/model/dto/storage_dto.go +++ b/internal/model/dto/storage_dto.go @@ -11,3 +11,14 @@ type GetUploadURLResponse struct { FileKey string `json:"file_key" description:"文件路径标识,上传成功后用于调用业务接口"` ExpiresIn int `json:"expires_in" description:"URL 有效期(秒)"` } + +// GetBatchDownloadURLsRequest 批量获取文件下载预签名 URL 请求 +type GetBatchDownloadURLsRequest struct { + FileKeys []string `json:"file_keys" validate:"required,min=1,max=50,dive,required" required:"true" minLength:"1" description:"文件路径标识列表,最多 50 个"` +} + +// GetBatchDownloadURLsResponse 批量获取文件下载预签名 URL 响应 +type GetBatchDownloadURLsResponse struct { + URLs map[string]string `json:"urls" description:"file_key 到预签名下载 URL 的映射"` + ExpiresIn int `json:"expires_in" description:"URL 有效期(秒)"` +} diff --git a/internal/routes/storage.go b/internal/routes/storage.go index bfb83fc..f4212fb 100644 --- a/internal/routes/storage.go +++ b/internal/routes/storage.go @@ -12,6 +12,86 @@ func registerStorageRoutes(router fiber.Router, handler *admin.StorageHandler, d storage := router.Group("/storage") groupPath := basePath + "/storage" + Register(storage, doc, groupPath, "POST", "/batch-download-urls", handler.GetBatchDownloadURLs, RouteSpec{ + Summary: "批量获取文件下载预签名 URL", + Description: `## 文件展示流程 + +本接口用于批量获取对象存储文件的预签名下载 URL。由于存储桶为私有访问,文件不能直接通过路径访问,必须通过预签名 URL 才能展示。 + +### 为什么需要这个接口 + +对象存储使用私有 Bucket,文件路径(file_key)本身无法直接访问。前端持有的 file_key 只是文件标识,需要通过本接口换取带签名的临时访问 URL,才能在 ` + "`" + `` + "`" + ` 或下载链接中使用。 + +### 完整使用流程 + +1. **业务接口返回 file_key**(存储在数据库中的文件路径标识) +2. **调用本接口** 传入所有需要展示的 file_key 列表 +3. **使用返回的 URL** 直接赋值给 ` + "`" + `` + "`" + ` 或下载链接,图片/文件内容直接从对象存储加载,不经过后端 + +### 列表页批量展示示例 + +` + "```" + `javascript +// 假设列表数据中每条记录有 avatar_key 字段 +const listData = await api.get('/customers'); + +// 收集所有 file_key(过滤掉空值) +const fileKeys = listData.map(item => item.avatar_key).filter(Boolean); + +// 一次性换取所有下载 URL +const { data } = await api.post('/storage/batch-download-urls', { + file_keys: fileKeys +}); + +// 渲染时通过 map 取对应 URL +listData.forEach(item => { + item.avatarUrl = data.urls[item.avatar_key] ?? ''; +}); +` + "```" + ` + +### 单个文件展示示例 + +` + "```" + `javascript +// 详情页只有一个文件时,也走批量接口(保持一致) +const { data } = await api.post('/storage/batch-download-urls', { + file_keys: [record.license_key] +}); + +const licenseUrl = data.urls[record.license_key]; +` + "```" + ` + +### 前端缓存建议 + +预签名 URL 有效期 **24 小时**,建议在内存中缓存,避免同一页面重复请求: + +` + "```" + `javascript +const urlCache = new Map(); // key: file_key, value: { url, expireAt } + +async function getFileUrl(fileKey) { + const cached = urlCache.get(fileKey); + if (cached && cached.expireAt > Date.now()) { + return cached.url; + } + const { data } = await api.post('/storage/batch-download-urls', { + file_keys: [fileKey] + }); + const url = data.urls[fileKey]; + urlCache.set(fileKey, { url, expireAt: Date.now() + data.expires_in * 1000 * 0.9 }); + return url; +} +` + "```" + ` + +### 注意事项 + +- 单次请求最多 **50 个** file_key +- 预签名 URL 有效期 **24 小时**,过期后需重新获取 +- file_key 不存在时对应的 URL 生成会失败,整个请求返回错误 +- 图片/文件内容直接从对象存储加载到浏览器,**不经过后端**,不消耗服务器带宽`, + Tags: []string{"对象存储"}, + Input: new(dto.GetBatchDownloadURLsRequest), + Output: new(dto.GetBatchDownloadURLsResponse), + Auth: true, + }) + Register(storage, doc, groupPath, "POST", "/upload-url", handler.GetUploadURL, RouteSpec{ Summary: "获取文件上传预签名 URL", Description: `## 文件上传流程