diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml
index d9b9a7b..04a7908 100644
--- a/docs/admin-openapi.yaml
+++ b/docs/admin-openapi.yaml
@@ -4268,6 +4268,30 @@ components:
description: 总订单数
type: integer
type: object
+ DtoGetBatchDownloadURLsRequest:
+ properties:
+ file_keys:
+ description: 文件路径标识列表,最多 50 个
+ items:
+ type: string
+ minLength: 1
+ nullable: true
+ type: array
+ required:
+ - file_keys
+ type: object
+ DtoGetBatchDownloadURLsResponse:
+ properties:
+ expires_in:
+ description: URL 有效期(秒)
+ type: integer
+ urls:
+ additionalProperties:
+ type: string
+ description: file_key 到预签名下载 URL 的映射
+ nullable: true
+ type: object
+ type: object
DtoGetUploadURLRequest:
properties:
content_type:
@@ -21150,6 +21174,142 @@ paths:
summary: 代理商资金概况
tags:
- 代理商资金管理
+ /api/admin/storage/batch-download-urls:
+ post:
+ 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 生成会失败,整个请求返回错误
+ - 图片/文件内容直接从对象存储加载到浏览器,**不经过后端**,不消耗服务器带宽
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DtoGetBatchDownloadURLsRequest'
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ properties:
+ code:
+ description: 响应码
+ example: 0
+ type: integer
+ data:
+ $ref: '#/components/schemas/DtoGetBatchDownloadURLsResponse'
+ msg:
+ description: 响应消息
+ example: success
+ type: string
+ timestamp:
+ description: 时间戳
+ format: date-time
+ type: string
+ required:
+ - code
+ - msg
+ - data
+ - timestamp
+ type: object
+ description: 成功
+ "400":
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ description: 请求参数错误
+ "401":
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ description: 未认证或认证已过期
+ "403":
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ description: 无权访问
+ "500":
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ description: 服务器内部错误
+ security:
+ - BearerAuth: []
+ summary: 批量获取文件下载预签名 URL
+ tags:
+ - 对象存储
/api/admin/storage/upload-url:
post:
description: |-