diff --git a/bms-back/bms-admin/src/main/resources/application-prod.yml b/bms-back/bms-admin/src/main/resources/application-prod.yml index e74a0a0a..1ea85a50 100644 --- a/bms-back/bms-admin/src/main/resources/application-prod.yml +++ b/bms-back/bms-admin/src/main/resources/application-prod.yml @@ -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: diff --git a/bms-back/bms-admin/src/main/resources/application.yml b/bms-back/bms-admin/src/main/resources/application.yml index 8f1eff08..aa89ddb2 100644 --- a/bms-back/bms-admin/src/main/resources/application.yml +++ b/bms-back/bms-admin/src/main/resources/application.yml @@ -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:} + access-key-secret: ${OSS_ACCESS_KEY_SECRET:} + # 项目相关配置 bms: # 名称 diff --git a/bms-back/bms-admin/src/test/java/com/bms/ar/ArBillExportDownloadTest.java b/bms-back/bms-admin/src/test/java/com/bms/ar/ArBillExportDownloadTest.java new file mode 100644 index 00000000..7809cc9d --- /dev/null +++ b/bms-back/bms-admin/src/test/java/com/bms/ar/ArBillExportDownloadTest.java @@ -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; + } +} diff --git a/bms-back/bms-admin/src/test/java/com/bms/common/utils/file/OssStorageTest.java b/bms-back/bms-admin/src/test/java/com/bms/common/utils/file/OssStorageTest.java new file mode 100644 index 00000000..0dbd3df9 --- /dev/null +++ b/bms-back/bms-admin/src/test/java/com/bms/common/utils/file/OssStorageTest.java @@ -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 ignored = builder(client)) { + assertEquals("oss://test-bucket/bms/test/ar/bill.xlsx", + new OssStorage(properties()).upload(file, "ar/bill.xlsx", "application/test")); + ArgumentCaptor 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 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 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 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 builder(OSS client) { + return mockConstruction(OSSClientBuilder.class, (mock, context) -> + when(mock.build(anyString(), anyString(), anyString(), any(ClientBuilderConfiguration.class))) + .thenReturn(client)); + } +} diff --git a/bms-back/bms-common/pom.xml b/bms-back/bms-common/pom.xml index cab6d475..9859aaef 100644 --- a/bms-back/bms-common/pom.xml +++ b/bms-back/bms-common/pom.xml @@ -17,6 +17,12 @@ + + com.aliyun.oss + aliyun-sdk-oss + 3.18.3 + + org.springframework @@ -120,4 +126,4 @@ - \ No newline at end of file + diff --git a/bms-back/bms-common/src/main/java/com/bms/common/config/OssProperties.java b/bms-back/bms-common/src/main/java/com/bms/common/config/OssProperties.java new file mode 100644 index 00000000..44b4da56 --- /dev/null +++ b/bms-back/bms-common/src/main/java/com/bms/common/config/OssProperties.java @@ -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; } +} diff --git a/bms-back/bms-common/src/main/java/com/bms/common/utils/file/OssStorage.java b/bms-back/bms-common/src/main/java/com/bms/common/utils/file/OssStorage.java new file mode 100644 index 00000000..98293b5e --- /dev/null +++ b/bms-back/bms-common/src/main/java/com/bms/common/utils/file/OssStorage.java @@ -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); + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java index bdf2f3c7..49bb55f9 100644 --- a/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java @@ -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 { - 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)); + 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); + 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()); - FileUtils.writeBytes(BmsConfig.getProfile() + FileUtils.stripPrefix(resource), response.getOutputStream()); + 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(); } diff --git a/bms-back/docs/oss-bill-storage.md b/bms-back/docs/oss-bill-storage.md new file mode 100644 index 00000000..07192284 --- /dev/null +++ b/bms-back/docs/oss-bill-storage.md @@ -0,0 +1,48 @@ +# 应收账单 OSS 存储 + +新生成的应收账单导出 Excel 上传到 `lionqueen` Bucket。 +生产路径为 `bms/prod/ar/bill/yyyy/MM/dd/.xlsx`,本地、测试路径为 +`bms/test/ar/bill/yyyy/MM/dd/.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/)。