✨ 支持应收账单导出通过 OSS 签名链接下载

This commit is contained in:
caojia
2026-11-12 09:27:10 +08:00
parent 27715b3e13
commit 146d3469b6
6 changed files with 94 additions and 11 deletions
@@ -325,9 +325,16 @@ public class ArBillController extends BaseController {
}
/**
* 下载已成功生成的导出文件;文件内容由服务层从临时文件或配置存储读取。
* 获取已成功生成导出文件的下载方式;OSS 文件返回短时签名链接,本地历史文件走原下载接口。
*/
@PreAuthorize("@ss.hasPermi('ar:bill:download')")
@GetMapping("/export/{exportId}/download-url")
public AjaxResult exportDownloadInfo(@PathVariable Long exportId) {
return success(billService.getExportDownloadInfo(exportId));
}
/** 下载本地历史导出文件;OSS 文件由前端通过签名链接直接下载。 */
@PreAuthorize("@ss.hasPermi('ar:bill:download')")
@GetMapping("/export/{exportId}/download")
public void download(@PathVariable Long exportId, HttpServletResponse response) throws Exception {
billService.downloadExport(exportId, response);
@@ -1,18 +1,25 @@
package com.bms.common.utils.file;
import java.net.URL;
import java.net.URLEncoder;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Date;
import com.aliyun.oss.ClientBuilderConfiguration;
import com.aliyun.oss.HttpMethod;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.CannedAccessControlList;
import com.aliyun.oss.model.GeneratePresignedUrlRequest;
import com.aliyun.oss.model.OSSObject;
import com.aliyun.oss.model.ObjectMetadata;
import com.aliyun.oss.model.PutObjectRequest;
import com.aliyun.oss.model.ResponseHeaderOverrides;
import com.bms.common.config.OssProperties;
import com.bms.common.exception.ServiceException;
import org.springframework.stereotype.Component;
@@ -67,6 +74,28 @@ public class OssStorage {
}
}
/** 为私有 OSS 对象生成带附件文件名的限时下载链接。 */
public String generatePresignedDownloadUrl(String storageKey, String fileName, Date expiresAt) {
String objectKey = resolveObjectKey(storageKey);
OSS client = createClient();
try {
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(
properties.getBucket(), objectKey, HttpMethod.GET);
request.setExpiration(expiresAt);
String downloadName = StringUtils.hasText(fileName) ? fileName : "账单导出.xlsx";
String encodedFileName = URLEncoder.encode(downloadName, StandardCharsets.UTF_8).replace("+", "%20");
ResponseHeaderOverrides responseHeaders = new ResponseHeaderOverrides();
responseHeaders.setContentDisposition("attachment; filename=" + encodedFileName
+ ";filename*=utf-8''" + encodedFileName);
request.setResponseHeaders(responseHeaders);
URL signedUrl = client.generatePresignedUrl(request);
return signedUrl.toExternalForm();
} finally {
client.shutdown();
}
}
public void delete(String storageKey) {
String objectKey = resolveObjectKey(storageKey);
OSS client = createClient();
@@ -3,6 +3,7 @@ package com.bms.receivable.service;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import java.util.Map;
import com.bms.receivable.domain.ArBill;
import com.bms.receivable.domain.ArBankAccount;
import com.bms.receivable.domain.ArBillDetail;
@@ -85,6 +86,8 @@ public interface IArBillService
List<ArBillExport> selectExports(Long arBillId);
Map<String, Object> getExportDownloadInfo(Long exportId);
void downloadExport(Long exportId, jakarta.servlet.http.HttpServletResponse response) throws Exception;
List<ArBillLog> selectLogList(ArBillLog query);
@@ -119,6 +119,7 @@ public class ArBillServiceImpl implements IArBillService {
private static final Logger log = LoggerFactory.getLogger(ArBillServiceImpl.class);
private static final String EXPORT_TEMPLATE_BUSINESS_TYPE = "AR_BILL";
private static final String EXPORT_TEMPLATE_RESOURCE = "templates/receivable/ar_bill_template.xlsx";
private static final long EXPORT_DOWNLOAD_URL_TTL_MILLIS = 10 * 60 * 1000L;
/**
* Excel 行号从 1 开始;模板第 4 行是表头,明细从第 5 行开始写入。
*/
@@ -1245,17 +1246,29 @@ public class ArBillServiceImpl implements IArBillService {
return exports;
}
@Override
public Map<String, Object> getExportDownloadInfo(Long exportId) {
ArBillExport export = requireDownloadableExport(exportId);
String resource = export.getStorageKey();
Map<String, Object> info = new HashMap<>(3);
if (resource.startsWith("oss://")) {
Date expiresAt = new Date(System.currentTimeMillis() + EXPORT_DOWNLOAD_URL_TTL_MILLIS);
info.put("direct", true);
info.put("downloadUrl", ossStorage.generatePresignedDownloadUrl(resource, export.getFileName(), expiresAt));
info.put("expiresAt", expiresAt.getTime());
} else {
if (!FileUtils.checkAllowDownload(resource)) {
throw new ServiceException("导出文件路径非法");
}
info.put("direct", false);
}
return info;
}
@Override
public void downloadExport(Long exportId, HttpServletResponse response) throws Exception {
long startedAt = System.nanoTime();
ArBillExport export = exportMapper.selectById(exportId);
if (export == null) {
throw new ServiceException("账单不存在或无权访问");
}
billScope.requireBillAccessible(export.getArBillId());
if (!ArExportStatus.SUCCESS.getCode().equals(export.getExportStatus()) || StringUtils.isEmpty(export.getStorageKey())) {
throw new ServiceException("导出文件不存在或尚未生成成功");
}
ArBillExport export = requireDownloadableExport(exportId);
String resource = export.getStorageKey();
boolean ossFile = resource.startsWith("oss://");
if (!ossFile && !FileUtils.checkAllowDownload(resource)) {
@@ -1272,6 +1285,19 @@ public class ArBillServiceImpl implements IArBillService {
exportId, export.getFileSize(), elapsedMillis(startedAt));
}
private ArBillExport requireDownloadableExport(Long exportId) {
ArBillExport export = exportMapper.selectById(exportId);
if (export == null) {
throw new ServiceException("账单不存在或无权访问");
}
billScope.requireBillAccessible(export.getArBillId());
if (!ArExportStatus.SUCCESS.getCode().equals(export.getExportStatus())
|| StringUtils.isEmpty(export.getStorageKey())) {
throw new ServiceException("导出文件不存在或尚未生成成功");
}
return export;
}
@Override
public List<ArBillLog> selectLogList(ArBillLog query) {
long startedAt = System.nanoTime();