✨ 支持应收账单导出通过 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')") @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") @GetMapping("/export/{exportId}/download")
public void download(@PathVariable Long exportId, HttpServletResponse response) throws Exception { public void download(@PathVariable Long exportId, HttpServletResponse response) throws Exception {
billService.downloadExport(exportId, response); billService.downloadExport(exportId, response);
@@ -1,18 +1,25 @@
package com.bms.common.utils.file; package com.bms.common.utils.file;
import java.net.URL;
import java.net.URLEncoder;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Date;
import com.aliyun.oss.ClientBuilderConfiguration; import com.aliyun.oss.ClientBuilderConfiguration;
import com.aliyun.oss.HttpMethod;
import com.aliyun.oss.OSS; import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder; import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.CannedAccessControlList; import com.aliyun.oss.model.CannedAccessControlList;
import com.aliyun.oss.model.GeneratePresignedUrlRequest;
import com.aliyun.oss.model.OSSObject; import com.aliyun.oss.model.OSSObject;
import com.aliyun.oss.model.ObjectMetadata; import com.aliyun.oss.model.ObjectMetadata;
import com.aliyun.oss.model.PutObjectRequest; import com.aliyun.oss.model.PutObjectRequest;
import com.aliyun.oss.model.ResponseHeaderOverrides;
import com.bms.common.config.OssProperties; import com.bms.common.config.OssProperties;
import com.bms.common.exception.ServiceException; import com.bms.common.exception.ServiceException;
import org.springframework.stereotype.Component; 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) { public void delete(String storageKey) {
String objectKey = resolveObjectKey(storageKey); String objectKey = resolveObjectKey(storageKey);
OSS client = createClient(); OSS client = createClient();
@@ -3,6 +3,7 @@ package com.bms.receivable.service;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map;
import com.bms.receivable.domain.ArBill; import com.bms.receivable.domain.ArBill;
import com.bms.receivable.domain.ArBankAccount; import com.bms.receivable.domain.ArBankAccount;
import com.bms.receivable.domain.ArBillDetail; import com.bms.receivable.domain.ArBillDetail;
@@ -85,6 +86,8 @@ public interface IArBillService
List<ArBillExport> selectExports(Long arBillId); List<ArBillExport> selectExports(Long arBillId);
Map<String, Object> getExportDownloadInfo(Long exportId);
void downloadExport(Long exportId, jakarta.servlet.http.HttpServletResponse response) throws Exception; void downloadExport(Long exportId, jakarta.servlet.http.HttpServletResponse response) throws Exception;
List<ArBillLog> selectLogList(ArBillLog query); 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 Logger log = LoggerFactory.getLogger(ArBillServiceImpl.class);
private static final String EXPORT_TEMPLATE_BUSINESS_TYPE = "AR_BILL"; 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 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 行开始写入。 * Excel 行号从 1 开始;模板第 4 行是表头,明细从第 5 行开始写入。
*/ */
@@ -1245,17 +1246,29 @@ public class ArBillServiceImpl implements IArBillService {
return exports; 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 @Override
public void downloadExport(Long exportId, HttpServletResponse response) throws Exception { public void downloadExport(Long exportId, HttpServletResponse response) throws Exception {
long startedAt = System.nanoTime(); long startedAt = System.nanoTime();
ArBillExport export = exportMapper.selectById(exportId); ArBillExport export = requireDownloadableExport(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("导出文件不存在或尚未生成成功");
}
String resource = export.getStorageKey(); String resource = export.getStorageKey();
boolean ossFile = resource.startsWith("oss://"); boolean ossFile = resource.startsWith("oss://");
if (!ossFile && !FileUtils.checkAllowDownload(resource)) { if (!ossFile && !FileUtils.checkAllowDownload(resource)) {
@@ -1272,6 +1285,19 @@ public class ArBillServiceImpl implements IArBillService {
exportId, export.getFileSize(), elapsedMillis(startedAt)); 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 @Override
public List<ArBillLog> selectLogList(ArBillLog query) { public List<ArBillLog> selectLogList(ArBillLog query) {
long startedAt = System.nanoTime(); long startedAt = System.nanoTime();
+4
View File
@@ -91,6 +91,10 @@ export function downloadArBillExport(exportId) {
return request({ url: '/ar/bill/export/' + exportId + '/download', method: 'get', responseType: 'blob' }) return request({ url: '/ar/bill/export/' + exportId + '/download', method: 'get', responseType: 'blob' })
} }
export function getArBillExportDownloadInfo(exportId) {
return request({ url: '/ar/bill/export/' + exportId + '/download-url', method: 'get' })
}
export function listArBillLogs(query) { export function listArBillLogs(query) {
return request({ url: '/ar/bill/log/list', method: 'get', params: query }) return request({ url: '/ar/bill/log/list', method: 'get', params: query })
} }
+16 -2
View File
@@ -69,7 +69,7 @@
<script setup name="ArBill"> <script setup name="ArBill">
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { listArBillCustomerOptions, listArBill, getArBill, confirmArBill, voidArBill, exportArBill, getArBillExport, downloadArBillExport, getArBillGenerateTask, listActiveArBillGenerateTasks, cancelArBillGenerateTask, retryArBillGenerateTask } from '@/api/ar/bill' import { listArBillCustomerOptions, listArBill, getArBill, confirmArBill, voidArBill, exportArBill, getArBillExport, downloadArBillExport, getArBillExportDownloadInfo, getArBillGenerateTask, listActiveArBillGenerateTasks, cancelArBillGenerateTask, retryArBillGenerateTask } from '@/api/ar/bill'
import BillDetail from './components/BillDetail.vue' import BillDetail from './components/BillDetail.vue'
import GenerateBillDialog from './components/GenerateBillDialog.vue' import GenerateBillDialog from './components/GenerateBillDialog.vue'
import { AR_BILL_STATUS, AR_BILL_STATUS_OPTIONS, AR_BILL_STATUS_TYPES } from '@/constants/ar/bill' import { AR_BILL_STATUS, AR_BILL_STATUS_OPTIONS, AR_BILL_STATUS_TYPES } from '@/constants/ar/bill'
@@ -382,7 +382,21 @@ function openExportTask(task) {
} }
// 仅下载 SUCCESS 状态的导出任务,文件名优先使用后端保存的名称。 // 仅下载 SUCCESS 状态的导出任务,文件名优先使用后端保存的名称。
function downloadExport(row) { downloadArBillExport(row.exportId).then(blob => saveAs(blob, row.fileName || `账单_${row.exportId}.xlsx`)) } function downloadExport(row) {
getArBillExportDownloadInfo(row.exportId).then(response => {
const info = response.data || {}
if (info.direct && info.downloadUrl) {
const link = document.createElement('a')
link.href = info.downloadUrl
link.style.display = 'none'
document.body.appendChild(link)
link.click()
link.remove()
return
}
return downloadArBillExport(row.exportId).then(blob => saveAs(blob, row.fileName || `账单_${row.exportId}.xlsx`))
})
}
function clearCustomerFilter() { function clearCustomerFilter() {
const query = { ...route.query } const query = { ...route.query }
delete query.customerId delete query.customerId