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: `## 文件上传流程