Merge branch 'master' of https://git.lionquexpress.com/wuweihua/BMS
This commit is contained in:
+54
-13
@@ -1,5 +1,7 @@
|
||||
package com.bms.web.controller.report;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -23,6 +25,9 @@ import com.bms.common.core.page.TableDataInfo;
|
||||
import com.bms.common.enums.BusinessType;
|
||||
import com.bms.common.exception.ServiceException;
|
||||
import com.bms.common.utils.file.FileUtils;
|
||||
import com.bms.common.utils.file.OssStorage;
|
||||
import com.bms.common.utils.DateUtils;
|
||||
import com.bms.common.utils.uuid.IdUtils;
|
||||
import com.bms.common.utils.poi.ExcelUtil;
|
||||
// 提成功能暂停使用:import com.bms.price.domain.PrcProfitCommissionRate;
|
||||
import com.bms.price.domain.PrcProfitReportExport;
|
||||
@@ -40,6 +45,9 @@ public class PrcProfitReportController extends BaseController
|
||||
@Autowired
|
||||
private IPrcProfitReportService profitReportService;
|
||||
|
||||
@Autowired
|
||||
private OssStorage ossStorage;
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('report:profit:list')")
|
||||
@GetMapping("/summary")
|
||||
public AjaxResult summary(PrcProfitReportQuery query)
|
||||
@@ -100,9 +108,7 @@ public class PrcProfitReportController extends BaseController
|
||||
try
|
||||
{
|
||||
List<PrcProfitReportOrderRow> rows = profitReportService.selectOrderRows(query);
|
||||
AjaxResult file = new ExcelUtil<PrcProfitReportOrderRow>(PrcProfitReportOrderRow.class)
|
||||
.exportExcel(rows, "订单毛利报表");
|
||||
return finishExport(export, rows.size(), file);
|
||||
return finishExport(export, rows, PrcProfitReportOrderRow.class, "订单毛利报表");
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
@@ -120,9 +126,7 @@ public class PrcProfitReportController extends BaseController
|
||||
try
|
||||
{
|
||||
List<PrcProfitReportProductRow> rows = profitReportService.selectProductRows(query);
|
||||
AjaxResult file = new ExcelUtil<PrcProfitReportProductRow>(PrcProfitReportProductRow.class)
|
||||
.exportExcel(rows, "产品毛利报表");
|
||||
return finishExport(export, rows.size(), file);
|
||||
return finishExport(export, rows, PrcProfitReportProductRow.class, "产品毛利报表");
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
@@ -145,11 +149,22 @@ public class PrcProfitReportController extends BaseController
|
||||
{
|
||||
PrcProfitReportExport export = profitReportService.selectExport(exportId, getUsername());
|
||||
if (export == null || !"SUCCESS".equals(export.getExportStatus())
|
||||
|| export.getStorageKey() == null || !FileUtils.checkAllowDownload(export.getStorageKey()))
|
||||
|| export.getStorageKey() == null || export.getStorageKey().isBlank())
|
||||
{
|
||||
throw new ServiceException("导出文件不存在或尚未生成成功");
|
||||
}
|
||||
|
||||
if (export.getStorageKey().startsWith("oss://"))
|
||||
{
|
||||
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
FileUtils.setAttachmentResponseHeader(response, export.getFileName());
|
||||
ossStorage.download(export.getStorageKey(), response.getOutputStream());
|
||||
return;
|
||||
}
|
||||
if (!FileUtils.checkAllowDownload(export.getStorageKey()))
|
||||
{
|
||||
throw new ServiceException("导出文件路径非法");
|
||||
}
|
||||
Path downloadRoot = Paths.get(BmsConfig.getDownloadPath()).toAbsolutePath().normalize();
|
||||
Path filePath = downloadRoot.resolve(export.getStorageKey()).normalize();
|
||||
if (!filePath.startsWith(downloadRoot) || !Files.isRegularFile(filePath))
|
||||
@@ -175,22 +190,48 @@ public class PrcProfitReportController extends BaseController
|
||||
return export;
|
||||
}
|
||||
|
||||
private AjaxResult finishExport(PrcProfitReportExport export, int rowCount, AjaxResult file)
|
||||
private <T> AjaxResult finishExport(PrcProfitReportExport export, List<T> rows, Class<T> rowType, String sheetName)
|
||||
{
|
||||
Object storageKey = file.get("msg");
|
||||
if (storageKey == null || String.valueOf(storageKey).isBlank())
|
||||
Path temp = null;
|
||||
try
|
||||
{
|
||||
throw new ServiceException("导出文件生成失败");
|
||||
temp = Files.createTempFile("profit-report-", ".xlsx");
|
||||
try (OutputStream output = Files.newOutputStream(temp))
|
||||
{
|
||||
new ExcelUtil<T>(rowType).exportExcelToStream(rows, sheetName, output);
|
||||
}
|
||||
export.setStorageKey(String.valueOf(storageKey));
|
||||
export.setRowCount(rowCount);
|
||||
String objectKey = "report/profit/" + DateUtils.datePath() + "/" + IdUtils.fastSimpleUUID() + ".xlsx";
|
||||
export.setStorageKey(ossStorage.upload(temp, objectKey,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
|
||||
export.setRowCount(rows.size());
|
||||
export.setExportStatus("SUCCESS");
|
||||
if (profitReportService.completeExport(export) != 1)
|
||||
{
|
||||
ossStorage.delete(export.getStorageKey());
|
||||
throw new ServiceException("保存导出日志失败");
|
||||
}
|
||||
return success(export);
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
logger.error("毛利报表生成或上传失败,exportId={}", export.getExportId(), e);
|
||||
throw new ServiceException("毛利报表生成或上传失败,请重新导出");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (temp != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
Files.deleteIfExists(temp);
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
logger.warn("毛利报表临时文件清理失败,exportId={}", export.getExportId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void failExport(PrcProfitReportExport export, RuntimeException e)
|
||||
{
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
# Elasticsearch:172.19.64.13:9200
|
||||
|
||||
# 生产环境文件存储根目录
|
||||
# 上传、导入、头像及账单下载文件都会基于此目录保存
|
||||
# 上传、导入、头像及历史本地账单文件基于此目录保存;新账单文件上传 OSS
|
||||
oss:
|
||||
path-prefix: ${OSS_PATH_PREFIX:bms/prod}
|
||||
|
||||
bms:
|
||||
profile: /www/server/bms/data
|
||||
elasticsearch:
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
# OSS 自定义域名必须已绑定到下方 Bucket,且支持 HTTPS。
|
||||
oss:
|
||||
endpoint: ${OSS_ENDPOINT:https://oss.lionquexpress.com}
|
||||
bucket: ${OSS_BUCKET:lionqueen}
|
||||
# 本地、测试环境默认使用测试目录;prod 配置覆盖为 bms/prod。
|
||||
path-prefix: ${OSS_PATH_PREFIX:bms/test}
|
||||
cname: ${OSS_CNAME:true}
|
||||
access-key-id: ${OSS_ACCESS_KEY_ID:LTAI5t8dPStP73tpzNzRaTSB}
|
||||
access-key-secret: ${OSS_ACCESS_KEY_SECRET:wYCm3dji3bU0l0QoXHAQ6gJDKs7SbZ}
|
||||
|
||||
# 项目相关配置
|
||||
bms:
|
||||
# 名称
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.bms.ar;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import com.bms.ar.domain.ArBillExport;
|
||||
import com.bms.ar.mapper.ArBillExportMapper;
|
||||
import com.bms.ar.service.impl.ArBillServiceImpl;
|
||||
import com.bms.common.config.BmsConfig;
|
||||
import com.bms.common.utils.file.OssStorage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ArBillExportDownloadTest {
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void routesOssDownloadThroughStorage() throws Exception {
|
||||
OssStorage storage = mock(OssStorage.class);
|
||||
ArBillServiceImpl service = service("oss://lionqueen/bms/ar/bill/test.xlsx", storage);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
service.downloadExport(1L, response);
|
||||
verify(storage).download(eq("oss://lionqueen/bms/ar/bill/test.xlsx"), same(response.getOutputStream()));
|
||||
assertTrue(response.getHeader("Content-Disposition").contains("bill.xlsx"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void historicalLocalBillStillDownloadsWithoutOss() throws Exception {
|
||||
String previousProfile = BmsConfig.getProfile();
|
||||
try {
|
||||
new BmsConfig().setProfile(tempDir.toString());
|
||||
Path file = tempDir.resolve("download/ar/test.xlsx");
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.write(file, new byte[] {4, 5, 6});
|
||||
OssStorage storage = mock(OssStorage.class);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
service("/profile/download/ar/test.xlsx", storage).downloadExport(1L, response);
|
||||
assertArrayEquals(new byte[] {4, 5, 6}, response.getContentAsByteArray());
|
||||
verifyNoInteractions(storage);
|
||||
} finally {
|
||||
new BmsConfig().setProfile(previousProfile);
|
||||
}
|
||||
}
|
||||
|
||||
private ArBillServiceImpl service(String storageKey, OssStorage storage) {
|
||||
ArBillExport export = new ArBillExport();
|
||||
export.setExportId(1L);
|
||||
export.setExportStatus("SUCCESS");
|
||||
export.setStorageKey(storageKey);
|
||||
export.setFileName("bill.xlsx");
|
||||
ArBillExportMapper mapper = mock(ArBillExportMapper.class);
|
||||
when(mapper.selectById(1L)).thenReturn(export);
|
||||
ArBillServiceImpl service = new ArBillServiceImpl();
|
||||
ReflectionTestUtils.setField(service, "exportMapper", mapper);
|
||||
ReflectionTestUtils.setField(service, "ossStorage", storage);
|
||||
return service;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.bms.common.utils.file;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import com.aliyun.oss.ClientBuilderConfiguration;
|
||||
import com.aliyun.oss.OSS;
|
||||
import com.aliyun.oss.OSSClientBuilder;
|
||||
import com.aliyun.oss.model.OSSObject;
|
||||
import com.aliyun.oss.model.PutObjectRequest;
|
||||
import com.bms.common.config.OssProperties;
|
||||
import com.bms.common.exception.ServiceException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedConstruction;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OssStorageTest {
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private OssProperties properties() {
|
||||
OssProperties properties = new OssProperties();
|
||||
properties.setEndpoint("https://oss.example.test");
|
||||
properties.setBucket("test-bucket");
|
||||
properties.setAccessKeyId("test-id");
|
||||
properties.setAccessKeySecret("test-secret");
|
||||
return properties;
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadsPrivateObjectAndClosesClient() throws Exception {
|
||||
OSS client = mock(OSS.class);
|
||||
Path file = Files.writeString(tempDir.resolve("bill.xlsx"), "bill");
|
||||
try (MockedConstruction<OSSClientBuilder> ignored = builder(client)) {
|
||||
assertEquals("oss://test-bucket/bms/test/ar/bill.xlsx",
|
||||
new OssStorage(properties()).upload(file, "ar/bill.xlsx", "application/test"));
|
||||
ArgumentCaptor<PutObjectRequest> request = ArgumentCaptor.forClass(PutObjectRequest.class);
|
||||
verify(client).putObject(request.capture());
|
||||
assertEquals("test-bucket", request.getValue().getBucketName());
|
||||
assertEquals("bms/test/ar/bill.xlsx", request.getValue().getKey());
|
||||
assertEquals(4, request.getValue().getMetadata().getContentLength());
|
||||
assertEquals("private", request.getValue().getMetadata().getRawMetadata().get("x-oss-object-acl"));
|
||||
verify(client).shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void productionUploadUsesConfiguredPrefix() throws Exception {
|
||||
OSS client = mock(OSS.class);
|
||||
Path file = Files.writeString(tempDir.resolve("bill.xlsx"), "bill");
|
||||
OssProperties properties = properties();
|
||||
properties.setPathPrefix("bms/prod");
|
||||
try (MockedConstruction<OSSClientBuilder> ignored = builder(client)) {
|
||||
assertEquals("oss://test-bucket/bms/prod/ar/bill.xlsx",
|
||||
new OssStorage(properties).upload(file, "ar/bill.xlsx", "application/test"));
|
||||
verify(client).putObject(argThat((PutObjectRequest request) ->
|
||||
"bms/prod/ar/bill.xlsx".equals(request.getKey())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadFailureIsPropagatedAndClientClosed() throws Exception {
|
||||
OSS client = mock(OSS.class);
|
||||
when(client.putObject(any(PutObjectRequest.class))).thenThrow(new IllegalStateException("upload failed"));
|
||||
Path file = Files.writeString(tempDir.resolve("bill.xlsx"), "bill");
|
||||
try (MockedConstruction<OSSClientBuilder> ignored = builder(client)) {
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> new OssStorage(properties()).upload(file, "ar/bill.xlsx", "application/test"));
|
||||
verify(client).shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadsStoredObjectThroughStream() throws Exception {
|
||||
OSS client = mock(OSS.class);
|
||||
OSSObject object = new OSSObject();
|
||||
object.setObjectContent(new ByteArrayInputStream(new byte[] {1, 2, 3}));
|
||||
when(client.getObject("test-bucket", "ar/bill.xlsx")).thenReturn(object);
|
||||
try (MockedConstruction<OSSClientBuilder> ignored = builder(client)) {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
new OssStorage(properties()).download("oss://test-bucket/ar/bill.xlsx", output);
|
||||
assertArrayEquals(new byte[] {1, 2, 3}, output.toByteArray());
|
||||
verify(client).shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingCredentialsAndForeignBucket() {
|
||||
OssStorage storage = new OssStorage(new OssProperties());
|
||||
assertThrows(ServiceException.class, () -> storage.upload(tempDir.resolve("bill.xlsx"), "ar/bill.xlsx", "application/test"));
|
||||
assertThrows(ServiceException.class,
|
||||
() -> new OssStorage(properties()).download("oss://other-bucket/ar/bill.xlsx", new ByteArrayOutputStream()));
|
||||
}
|
||||
|
||||
private MockedConstruction<OSSClientBuilder> builder(OSS client) {
|
||||
return mockConstruction(OSSClientBuilder.class, (mock, context) ->
|
||||
when(mock.build(anyString(), anyString(), anyString(), any(ClientBuilderConfiguration.class)))
|
||||
.thenReturn(client));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.bms.report;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import com.bms.common.config.BmsConfig;
|
||||
import com.bms.common.core.domain.AjaxResult;
|
||||
import com.bms.common.exception.ServiceException;
|
||||
import com.bms.common.utils.file.OssStorage;
|
||||
import com.bms.price.domain.PrcProfitReportExport;
|
||||
import com.bms.price.domain.vo.PrcProfitReportQuery;
|
||||
import com.bms.price.service.IPrcProfitReportService;
|
||||
import com.bms.web.controller.report.PrcProfitReportController;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ProfitReportOssTest {
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private final IPrcProfitReportService service = mock(IPrcProfitReportService.class);
|
||||
private final OssStorage storage = mock(OssStorage.class);
|
||||
|
||||
private PrcProfitReportController controller() {
|
||||
PrcProfitReportController controller = new PrcProfitReportController() {
|
||||
@Override
|
||||
public String getUsername() { return "tester"; }
|
||||
};
|
||||
ReflectionTestUtils.setField(controller, "profitReportService", service);
|
||||
ReflectionTestUtils.setField(controller, "ossStorage", storage);
|
||||
when(service.createExport(any())).thenAnswer(invocation -> {
|
||||
invocation.<PrcProfitReportExport>getArgument(0).setExportId(1L);
|
||||
return 1;
|
||||
});
|
||||
when(service.selectOrderRows(any())).thenReturn(Collections.emptyList());
|
||||
when(service.selectProductRows(any())).thenReturn(Collections.emptyList());
|
||||
return controller;
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
void bothDimensionsUploadValidExcelAndCleanTempFile(boolean product) throws Exception {
|
||||
PrcProfitReportController controller = controller();
|
||||
AtomicReference<Path> generated = new AtomicReference<>();
|
||||
when(storage.upload(any(), startsWith("report/profit/"), anyString())).thenAnswer(invocation -> {
|
||||
Path file = invocation.getArgument(0);
|
||||
generated.set(file);
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(file.toFile())) {
|
||||
assertTrue(workbook.getSheetAt(0).getRow(0).getPhysicalNumberOfCells() > 0);
|
||||
}
|
||||
verify(service, never()).completeExport(any());
|
||||
return "oss://lionqueen/bms/test/report/profit/test.xlsx";
|
||||
});
|
||||
when(service.completeExport(any())).thenReturn(1);
|
||||
AjaxResult result = product ? controller.exportProducts(new PrcProfitReportQuery())
|
||||
: controller.exportOrders(new PrcProfitReportQuery());
|
||||
PrcProfitReportExport export = (PrcProfitReportExport) result.get("data");
|
||||
assertEquals("SUCCESS", export.getExportStatus());
|
||||
assertTrue(export.getStorageKey().startsWith("oss://"));
|
||||
assertFalse(Files.exists(generated.get()));
|
||||
verify(service).completeExport(export);
|
||||
verify(service, never()).failExport(anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadFailureMarksFailedAndCleansTemp() throws Exception {
|
||||
PrcProfitReportController controller = controller();
|
||||
AtomicReference<Path> generated = new AtomicReference<>();
|
||||
when(storage.upload(any(), anyString(), anyString())).thenAnswer(invocation -> {
|
||||
generated.set(invocation.getArgument(0));
|
||||
throw new IOException("upload unavailable");
|
||||
});
|
||||
assertThrows(ServiceException.class, () -> controller.exportProducts(new PrcProfitReportQuery()));
|
||||
assertFalse(Files.exists(generated.get()));
|
||||
verify(service, never()).completeExport(any());
|
||||
verify(service).failExport(eq(1L), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsuccessfulStatusUpdateDeletesUploadedObject() throws Exception {
|
||||
PrcProfitReportController controller = controller();
|
||||
when(storage.upload(any(), anyString(), anyString())).thenReturn("oss://lionqueen/bms/test/report/profit/test.xlsx");
|
||||
assertThrows(ServiceException.class, () -> controller.exportOrders(new PrcProfitReportQuery()));
|
||||
verify(storage).delete("oss://lionqueen/bms/test/report/profit/test.xlsx");
|
||||
verify(service).failExport(eq(1L), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ossDownloadUsesCurrentUserAndStorage() throws Exception {
|
||||
PrcProfitReportController controller = controller();
|
||||
when(service.selectExport(1L, "tester")).thenReturn(export("oss://lionqueen/bms/test/report/profit/test.xlsx"));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
controller.downloadExport(1L, response);
|
||||
verify(storage).download(eq("oss://lionqueen/bms/test/report/profit/test.xlsx"), same(response.getOutputStream()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void historicalLocalFileStillDownloads() throws Exception {
|
||||
String previousProfile = BmsConfig.getProfile();
|
||||
try {
|
||||
new BmsConfig().setProfile(tempDir.toString());
|
||||
Path file = tempDir.resolve("download/old.xlsx");
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.write(file, new byte[] {1, 2, 3});
|
||||
PrcProfitReportController controller = controller();
|
||||
when(service.selectExport(1L, "tester")).thenReturn(export("old.xlsx"));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
controller.downloadExport(1L, response);
|
||||
assertArrayEquals(new byte[] {1, 2, 3}, response.getContentAsByteArray());
|
||||
verifyNoInteractions(storage);
|
||||
} finally {
|
||||
new BmsConfig().setProfile(previousProfile);
|
||||
}
|
||||
}
|
||||
|
||||
private PrcProfitReportExport export(String key) {
|
||||
PrcProfitReportExport export = new PrcProfitReportExport();
|
||||
export.setStorageKey(key);
|
||||
export.setExportStatus("SUCCESS");
|
||||
export.setFileName("profit.xlsx");
|
||||
return export;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,12 @@
|
||||
|
||||
<dependencies>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.aliyun.oss</groupId>
|
||||
<artifactId>aliyun-sdk-oss</artifactId>
|
||||
<version>3.18.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring框架基本的核心工具 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.bms.common.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** OSS 连接配置;密钥由环境变量或外部配置提供。 */
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "oss")
|
||||
public class OssProperties {
|
||||
private String endpoint;
|
||||
private String bucket;
|
||||
private String accessKeyId;
|
||||
private String accessKeySecret;
|
||||
private boolean cname = true;
|
||||
/** 仅用于新上传对象;历史文件按数据库中的完整存储标识读取。 */
|
||||
private String pathPrefix = "bms/test";
|
||||
|
||||
public String getEndpoint() { return endpoint; }
|
||||
public void setEndpoint(String endpoint) { this.endpoint = endpoint; }
|
||||
public String getBucket() { return bucket; }
|
||||
public void setBucket(String bucket) { this.bucket = bucket; }
|
||||
public String getAccessKeyId() { return accessKeyId; }
|
||||
public void setAccessKeyId(String accessKeyId) { this.accessKeyId = accessKeyId; }
|
||||
public String getAccessKeySecret() { return accessKeySecret; }
|
||||
public void setAccessKeySecret(String accessKeySecret) { this.accessKeySecret = accessKeySecret; }
|
||||
public boolean isCname() { return cname; }
|
||||
public void setCname(boolean cname) { this.cname = cname; }
|
||||
public String getPathPrefix() { return pathPrefix; }
|
||||
public void setPathPrefix(String pathPrefix) { this.pathPrefix = pathPrefix; }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.bms.common.utils.file;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import com.aliyun.oss.ClientBuilderConfiguration;
|
||||
import com.aliyun.oss.OSS;
|
||||
import com.aliyun.oss.OSSClientBuilder;
|
||||
import com.aliyun.oss.model.CannedAccessControlList;
|
||||
import com.aliyun.oss.model.OSSObject;
|
||||
import com.aliyun.oss.model.ObjectMetadata;
|
||||
import com.aliyun.oss.model.PutObjectRequest;
|
||||
import com.bms.common.config.OssProperties;
|
||||
import com.bms.common.exception.ServiceException;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** 通用 OSS 文件工具。数据库保存 oss://bucket/key,下载通过后端鉴权接口转发。 */
|
||||
@Component
|
||||
public class OssStorage {
|
||||
private final OssProperties properties;
|
||||
|
||||
public OssStorage(OssProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/** 使用文件流上传,避免把整个账单读入内存。返回可持久化的存储标识。 */
|
||||
public String upload(Path file, String objectKey, String contentType) throws IOException {
|
||||
validateObjectKey(objectKey);
|
||||
String prefix = properties.getPathPrefix();
|
||||
validateObjectKey(prefix);
|
||||
if (prefix.endsWith("/")) {
|
||||
throw new ServiceException("OSS 存储前缀不能以 / 结尾");
|
||||
}
|
||||
objectKey = prefix + "/" + objectKey;
|
||||
OSS client = createClient();
|
||||
try (InputStream input = Files.newInputStream(file)) {
|
||||
ObjectMetadata metadata = new ObjectMetadata();
|
||||
metadata.setContentLength(Files.size(file));
|
||||
metadata.setContentType(contentType);
|
||||
metadata.setObjectAcl(CannedAccessControlList.Private);
|
||||
client.putObject(new PutObjectRequest(properties.getBucket(), objectKey, input, metadata));
|
||||
return "oss://" + properties.getBucket() + "/" + objectKey;
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/** 不关闭调用方的响应输出流。 */
|
||||
public void download(String storageKey, OutputStream output) throws IOException {
|
||||
String objectKey = resolveObjectKey(storageKey);
|
||||
OSS client = createClient();
|
||||
try (OSSObject object = client.getObject(properties.getBucket(), objectKey)) {
|
||||
object.getObjectContent().transferTo(output);
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
public void delete(String storageKey) {
|
||||
String objectKey = resolveObjectKey(storageKey);
|
||||
OSS client = createClient();
|
||||
try {
|
||||
client.deleteObject(properties.getBucket(), objectKey);
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveObjectKey(String storageKey) {
|
||||
String prefix = "oss://" + properties.getBucket() + "/";
|
||||
if (storageKey == null || !storageKey.startsWith(prefix)) {
|
||||
throw new ServiceException("OSS 文件存储标识非法或 Bucket 不匹配");
|
||||
}
|
||||
String objectKey = storageKey.substring(prefix.length());
|
||||
validateObjectKey(objectKey);
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
private void validateObjectKey(String objectKey) {
|
||||
if (!StringUtils.hasText(objectKey) || objectKey.startsWith("/")
|
||||
|| objectKey.contains("\\") || objectKey.contains("..")) {
|
||||
throw new ServiceException("OSS 对象路径非法");
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟检查密钥,未配置 OSS 时不影响历史本地文件下载及其他业务启动。
|
||||
private OSS createClient() {
|
||||
if (!StringUtils.hasText(properties.getEndpoint()) || !StringUtils.hasText(properties.getBucket())
|
||||
|| !StringUtils.hasText(properties.getAccessKeyId())
|
||||
|| !StringUtils.hasText(properties.getAccessKeySecret())) {
|
||||
throw new ServiceException("请配置 OSS endpoint、bucket、access-key-id 和 access-key-secret");
|
||||
}
|
||||
ClientBuilderConfiguration config = new ClientBuilderConfiguration();
|
||||
config.setSupportCname(properties.isCname());
|
||||
config.setConnectionTimeout(10000);
|
||||
config.setConnectionRequestTimeout(10000);
|
||||
config.setSocketTimeout(60000);
|
||||
return new OSSClientBuilder().build(properties.getEndpoint(), properties.getAccessKeyId(),
|
||||
properties.getAccessKeySecret(), config);
|
||||
}
|
||||
}
|
||||
@@ -768,11 +768,22 @@ public class ExcelUtil<T>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对list数据源将其里面的数据导入到excel表单
|
||||
*
|
||||
* @return 结果
|
||||
*/
|
||||
/** 写入调用方提供的流,不生成公共下载目录文件;异常交给调用方处理。 */
|
||||
public void exportExcelToStream(List<T> list, String sheetName, OutputStream output) throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
this.init(list, sheetName, StringUtils.EMPTY, Type.EXPORT);
|
||||
writeSheet();
|
||||
wb.write(output);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IOUtils.closeQuietly(wb);
|
||||
}
|
||||
}
|
||||
|
||||
/** 对 list 数据源生成本地 Excel 文件,返回文件名。 */
|
||||
public AjaxResult exportExcel()
|
||||
{
|
||||
OutputStream out = null;
|
||||
|
||||
@@ -5,10 +5,8 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.text.SimpleDateFormat;
|
||||
@@ -85,7 +83,7 @@ import com.bms.common.exception.ServiceException;
|
||||
import com.bms.common.utils.DateUtils;
|
||||
import com.bms.common.utils.StringUtils;
|
||||
import com.bms.common.utils.file.FileUtils;
|
||||
import com.bms.common.utils.file.FileUploadUtils;
|
||||
import com.bms.common.utils.file.OssStorage;
|
||||
import com.bms.common.utils.uuid.IdUtils;
|
||||
import com.bms.price.domain.PrcIncomeBill;
|
||||
import com.bms.price.domain.PrcIncomeBillFee;
|
||||
@@ -103,6 +101,9 @@ import com.bms.price.mapper.PrcOrderBillMapper;
|
||||
*/
|
||||
@Service
|
||||
public class ArBillServiceImpl implements IArBillService {
|
||||
@Autowired
|
||||
private OssStorage ossStorage;
|
||||
|
||||
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/ar/ar_bill_template.xlsx";
|
||||
@@ -1038,7 +1039,7 @@ public class ArBillServiceImpl implements IArBillService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行异步导出任务:读取模板、流式拼接明细并原子移动临时文件。
|
||||
* 执行异步导出任务:流式生成临时 Excel,上传 OSS 后才标记成功。
|
||||
*/
|
||||
@Override
|
||||
public void processExportTask(Long exportId) {
|
||||
@@ -1052,6 +1053,7 @@ public class ArBillServiceImpl implements IArBillService {
|
||||
long startedAt = System.nanoTime();
|
||||
log.info("开始执行应收账单导出任务,exportId={}, arBillId={}", exportId, export.getArBillId());
|
||||
ArBill bill = null;
|
||||
Path temp = null;
|
||||
try {
|
||||
bill = billMapper.selectArBillById(export.getArBillId());
|
||||
if (bill == null) {
|
||||
@@ -1060,28 +1062,17 @@ public class ArBillServiceImpl implements IArBillService {
|
||||
if (ArBillStatus.is(bill.getStatus(), ArBillStatus.DRAFT) || ArBillStatus.is(bill.getStatus(), ArBillStatus.VOID)) {
|
||||
throw new ServiceException("草稿或作废账单不可导出");
|
||||
}
|
||||
String relative = "ar/" + DateUtils.datePath() + "/" + IdUtils.fastSimpleUUID() + ".xlsx";
|
||||
String downloadDir = BmsConfig.getProfile() + File.separator + "download";
|
||||
File file = FileUploadUtils.getAbsoluteFile(downloadDir, relative);
|
||||
Path target = file.toPath();
|
||||
Path temp = Files.createTempFile(target.getParent(), ".ar-bill-export-", ".xlsx.tmp");
|
||||
boolean moved = false;
|
||||
try {
|
||||
String objectKey = "ar/bill/" + DateUtils.datePath() + "/" + IdUtils.fastSimpleUUID() + ".xlsx";
|
||||
temp = Files.createTempFile("ar-bill-export-", ".xlsx");
|
||||
ArExportTemplate template = resolveExportTemplate(export, bill);
|
||||
writeOoxmlWorkbook(bill, template, temp);
|
||||
moveToTarget(temp, target);
|
||||
moved = true;
|
||||
} finally {
|
||||
if (!moved) {
|
||||
Files.deleteIfExists(temp);
|
||||
}
|
||||
}
|
||||
export.setStorageKey(FileUploadUtils.getPathFileName(downloadDir, relative));
|
||||
export.setFileSize(Files.size(target));
|
||||
export.setFileHash(sha256(target));
|
||||
export.setFileSize(Files.size(temp));
|
||||
export.setFileHash(sha256(temp));
|
||||
export.setStorageKey(ossStorage.upload(temp, objectKey,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
|
||||
int updated = exportMapper.markSuccess(export);
|
||||
if (updated == 0) {
|
||||
Files.deleteIfExists(target);
|
||||
ossStorage.delete(export.getStorageKey());
|
||||
log.warn("应收账单导出文件已生成但任务状态未更新,exportId={}, arBillId={}",
|
||||
exportId, export.getArBillId());
|
||||
} else {
|
||||
@@ -1097,6 +1088,14 @@ public class ArBillServiceImpl implements IArBillService {
|
||||
logService.recordFailure(export.getArBillId(), ArLogObjectType.EXPORT, String.valueOf(exportId),
|
||||
ArLogOperationType.EXPORT, bill == null ? null : bill.getStatus(), message, export.getExportBy(), null);
|
||||
}
|
||||
} finally {
|
||||
if (temp != null) {
|
||||
try {
|
||||
Files.deleteIfExists(temp);
|
||||
} catch (IOException e) {
|
||||
log.warn("应收账单导出临时文件清理失败,exportId={}", exportId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1127,12 +1126,17 @@ public class ArBillServiceImpl implements IArBillService {
|
||||
throw new ServiceException("导出文件不存在或尚未生成成功");
|
||||
}
|
||||
String resource = export.getStorageKey();
|
||||
if (!FileUtils.checkAllowDownload(resource)) {
|
||||
boolean ossFile = resource.startsWith("oss://");
|
||||
if (!ossFile && !FileUtils.checkAllowDownload(resource)) {
|
||||
throw new ServiceException("导出文件路径非法");
|
||||
}
|
||||
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
FileUtils.setAttachmentResponseHeader(response, export.getFileName());
|
||||
if (ossFile) {
|
||||
ossStorage.download(resource, response.getOutputStream());
|
||||
} else {
|
||||
FileUtils.writeBytes(BmsConfig.getProfile() + FileUtils.stripPrefix(resource), response.getOutputStream());
|
||||
}
|
||||
log.debug("应收账单导出文件下载完成,exportId={}, fileSize={}, costMs={}",
|
||||
exportId, export.getFileSize(), elapsedMillis(startedAt));
|
||||
}
|
||||
@@ -1800,14 +1804,6 @@ public class ArBillServiceImpl implements IArBillService {
|
||||
}
|
||||
}
|
||||
|
||||
private void moveToTarget(Path source, Path target) throws IOException {
|
||||
try {
|
||||
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
private String exportRemark(ArBillDetail detail) {
|
||||
return StringUtils.isEmpty(detail.getRemark()) ? detail.getGoodsDescription() : detail.getRemark();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# 应收账单 OSS 存储
|
||||
|
||||
新生成的应收账单导出 Excel 上传到 `lionqueen` Bucket。
|
||||
生产路径为 `bms/prod/ar/bill/yyyy/MM/dd/<uuid>.xlsx`,本地、测试路径为
|
||||
`bms/test/ar/bill/yyyy/MM/dd/<uuid>.xlsx`。数据库 `ar_bill_export.storage_key`
|
||||
保存包含完整对象路径的 `oss://lionqueen/...`,不需要数据库变更。
|
||||
生产环境启动需启用 `prod` Profile,可通过 `OSS_PATH_PREFIX` 覆盖目录前缀
|
||||
(不以 `/` 开头或结尾)。同 Bucket 下的目录划分用于区分文件,权限隔离仍需配置 RAM 策略。
|
||||
|
||||
## 部署配置
|
||||
|
||||
在后端进程环境变量中填写完整密钥,然后重启后端:
|
||||
|
||||
```text
|
||||
OSS_ACCESS_KEY_ID=<完整 AccessKey ID>
|
||||
OSS_ACCESS_KEY_SECRET=<完整 AccessKey Secret>
|
||||
```
|
||||
|
||||
默认配置位于 `bms-admin/src/main/resources/application.yml`:
|
||||
|
||||
```yaml
|
||||
oss:
|
||||
endpoint: ${OSS_ENDPOINT:https://oss.lionquexpress.com}
|
||||
bucket: ${OSS_BUCKET:lionqueen}
|
||||
path-prefix: ${OSS_PATH_PREFIX:bms/test}
|
||||
cname: ${OSS_CNAME:true}
|
||||
access-key-id: ${OSS_ACCESS_KEY_ID:}
|
||||
access-key-secret: ${OSS_ACCESS_KEY_SECRET:}
|
||||
```
|
||||
|
||||
也可以通过外部 Spring 配置文件填写这些配置。
|
||||
`oss.lionquexpress.com` 按 OSS 自定义域名使用,需在 OSS 控制台已绑定到
|
||||
`lionqueen`,且 HTTPS 证书可用。若这个域名仅用于 CDN 分发,请设置
|
||||
`OSS_ENDPOINT` 为 Bucket 所在地域的 OSS Endpoint,并设置 `OSS_CNAME=false`。
|
||||
密钥需要目标 Bucket 下对应对象的上传、读取、删除权限。
|
||||
|
||||
## 生成和下载
|
||||
|
||||
- 流式生成系统临时文件,计算大小和 SHA-256,上传成功后才更新导出状态为 SUCCESS。
|
||||
- 上传时显式设置对象 ACL 为 private,下载继续使用原有账单下载接口及权限校验,后端流式读取 OSS。
|
||||
- 生成或上传失败走原有 FAILED 状态处理;正常和异常路径都会尝试清理临时文件。
|
||||
- 任务已超时或被其他流程结束、成功状态更新返回 0 时,删除该次上传的 OSS 对象。
|
||||
- OSS 上传与数据库状态更新不属于同一事务;数据库更新异常可能留下未关联的 OSS 对象,需运维核对处理。
|
||||
- 历史 `/profile/...` 账单仍从原本地目录下载,本次不迁移历史文件。
|
||||
- 已有 `oss://lionqueen/bms/ar/bill/...` 文件仍按数据库中记录的路径读取,下载和删除不重新添加环境前缀。
|
||||
- 普通附件、头像、导入文件及导出模板维持现有存储方式。
|
||||
|
||||
SDK 自定义域名与客户端配置参考:[阿里云 OSS Java SDK 文档](https://www.alibabacloud.com/help/en/oss/developer-reference/oss-java-sdk/)。
|
||||
|
||||
## 毛利分析报表
|
||||
|
||||
产品、订单两个维度的导出也使用上述 OSS 配置。生产文件路径为
|
||||
`bms/prod/report/profit/yyyy/MM/dd/<uuid>.xlsx`,本地和测试为
|
||||
`bms/test/report/profit/yyyy/MM/dd/<uuid>.xlsx`。
|
||||
导出保持同步:临时 Excel 生成并上传成功后更新导出日志,再由前端发起下载。
|
||||
下载继续校验当前用户及原接口权限;历史本地文件名仍从原 download 目录读取。
|
||||
上传失败标记导出失败并清理临时文件,状态更新返回 0 时删除此次上传的 OSS 对象。
|
||||
Reference in New Issue
Block a user