✨ 支持应收账单导出通过 OSS 签名链接下载
This commit is contained in:
+8
-1
@@ -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);
|
||||||
|
|||||||
+34
-8
@@ -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();
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user