From a37dc806094bec4e3fdc0ce56cb7d3757cc216b6 Mon Sep 17 00:00:00 2001 From: zizi Date: Sat, 30 May 2026 01:23:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(p1r):=20=E5=8A=A0=E5=9B=BA=20Account=20?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=87=AD=E8=AF=81=E6=B6=88=E8=B4=B9=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../account/AccountExportServiceImpl.java | 82 ++++++++--- .../AccountDownloadCredentialMapper.java | 11 +- .../account/AccountExportServiceTest.java | 132 ++++++++++++++++-- 3 files changed, 192 insertions(+), 33 deletions(-) diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountExportServiceImpl.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountExportServiceImpl.java index a28f7532..7a815377 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountExportServiceImpl.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountExportServiceImpl.java @@ -17,10 +17,13 @@ import cn.iocoder.muse.module.member.dal.mysql.account.MemberSecurityEventMapper import cn.iocoder.muse.module.member.dal.mysql.user.MemberUserMapper; import cn.iocoder.muse.module.member.domain.account.AccountDownloadGuard; import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -72,6 +75,8 @@ public class AccountExportServiceImpl implements AccountExportService { private AccountAuditService auditService; @Resource private AccountFileServiceFacade fileServiceFacade; + @Value("${muse.account.download-credential-secret:}") + private String downloadCredentialSecret; @Override @Transactional(rollbackFor = Exception.class) @@ -101,7 +106,7 @@ public class AccountExportServiceImpl implements AccountExportService { String taskId = newTaskId(); String sourceSnapshot = sourceSnapshot(exportRequest); - AccountFileServiceFacade.ExportTaskCreateResult fileResult = fileServiceFacade.createExportTask( + AccountFileServiceFacade.ExportTaskCreateResult fileResult = createExportTaskSafely( AccountFileServiceFacade.ExportTaskCreateCommand.builder() .accountUserId(accountUserId) .taskId(taskId) @@ -156,22 +161,21 @@ public class AccountExportServiceImpl implements AccountExportService { if (credential == null) { throw new ServiceException(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID); } + LocalDateTime now = LocalDateTime.now(); AccountDownloadGuard.requireUsableCredential(accountUserId, credential.getAccountUserId(), credential.getExpiresAt(), credential.getConsumedAt(), credential.getRevokedAt(), - Boolean.TRUE.equals(credential.getSourceBlocked()), LocalDateTime.now()); + Boolean.TRUE.equals(credential.getSourceBlocked()), now); AccountExportTaskDO task = exportTaskMapper.selectByTaskIdAndAccountUserId(credential.getTaskId(), accountUserId); requireDownloadableTask(task); - AccountFileServiceFacade.DownloadPackageResult result = - fileServiceFacade.downloadExportPackage(accountUserId, task.getTaskId(), task.getFileRef()); + int consumedRows = downloadCredentialMapper.markConsumedIfUsable(credentialHash, accountUserId, now, now); + if (consumedRows != 1) { + // 凭证消费是一次性原子门禁;校验后被撤销、过期、阻断或并发消费时,必须在读文件前失败关闭。 + throw new ServiceException(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID); + } + AccountFileServiceFacade.DownloadPackageResult result = downloadExportPackageSafely(accountUserId, task); if (result == null || !result.available() || result.bytes() == null || result.bytes().length == 0) { throw new ServiceException(ACCOUNT_FILE_SERVICE_UNAVAILABLE); } - LocalDateTime consumedAt = LocalDateTime.now(); - int consumedRows = downloadCredentialMapper.markConsumed(credentialHash, consumedAt); - if (consumedRows != 1) { - // 凭证消费是一次性门禁;并发下载在读取后抢先消费时,后续请求必须失败关闭。 - throw new ServiceException(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID); - } auditService.record(AccountAuditService.AuditCreateReq.builder() .operationId(OPERATION_DOWNLOAD_EXPORT) .actorUserId(accountUserId) @@ -189,6 +193,25 @@ public class AccountExportServiceImpl implements AccountExportService { return new DownloadPackage(defaultFilename(result.filename(), task), "application/octet-stream", result.bytes()); } + private AccountFileServiceFacade.ExportTaskCreateResult createExportTaskSafely( + AccountFileServiceFacade.ExportTaskCreateCommand command) { + try { + return fileServiceFacade.createExportTask(command); + } catch (RuntimeException ex) { + // FileService 还不是 Account owner;异常只能收敛为 unavailable,不能泄露为 500 或伪造文件结果。 + return AccountFileServiceFacade.ExportTaskCreateResult.unavailable(); + } + } + + private AccountFileServiceFacade.DownloadPackageResult downloadExportPackageSafely(Long accountUserId, + AccountExportTaskDO task) { + try { + return fileServiceFacade.downloadExportPackage(accountUserId, task.getTaskId(), task.getFileRef()); + } catch (RuntimeException ex) { + throw new ServiceException(ACCOUNT_FILE_SERVICE_UNAVAILABLE); + } + } + private MemberUserDO requireUser(Long accountUserId) { MemberUserDO user = accountUserId == null ? null : memberUserMapper.selectById(accountUserId); if (user == null) { @@ -240,7 +263,8 @@ public class AccountExportServiceImpl implements AccountExportService { ExportRequest exportRequest, String sourceSnapshot, AccountFileServiceFacade.ExportTaskCreateResult fileResult) { String status = normalizeCreateStatus(fileResult); - String fileRef = fileResult == null || !fileResult.available() ? null : fileResult.fileRef(); + String fileRef = fileResult == null || !fileResult.available() || STATUS_FAILED.equals(status) + ? null : fileResult.fileRef(); AccountExportTaskDO task = AccountExportTaskDO.builder() .taskId(taskId) .accountUserId(accountUserId) @@ -252,7 +276,7 @@ public class AccountExportServiceImpl implements AccountExportService { .fileRef(fileRef) .sourceBlocked(false) .errorCode(STATUS_FAILED.equals(status) ? ACCOUNT_FILE_SERVICE_UNAVAILABLE.getCode().toString() : null) - .errorMessage(STATUS_FAILED.equals(status) ? "FileService 返回 completed 但缺少可下载文件信息" : null) + .errorMessage(STATUS_FAILED.equals(status) ? "FileService 返回 completed 但缺少可下载文件信息或下载凭证 secret" : null) .completedAt(STATUS_COMPLETED.equals(status) ? firstNonNull(fileResult == null ? null : fileResult.completedAt(), LocalDateTime.now()) : null) @@ -263,8 +287,9 @@ public class AccountExportServiceImpl implements AccountExportService { private String normalizeCreateStatus(AccountFileServiceFacade.ExportTaskCreateResult fileResult) { String status = fileResult == null ? null : fileResult.status(); if (STATUS_COMPLETED.equals(status)) { - if (!fileResult.available() || !StringUtils.hasText(fileResult.fileRef()) || fileResult.downloadExpiresAt() == null) { - // completed 必须同时具备真实 fileRef 和 Account 可派生的下载凭证;缺任一项都按失败关闭。 + if (!fileResult.available() || !StringUtils.hasText(fileResult.fileRef()) + || fileResult.downloadExpiresAt() == null || !hasDownloadCredentialSecret()) { + // completed 必须同时具备真实 fileRef、过期时间和 Account HMAC secret;缺任一项都按失败关闭。 return STATUS_FAILED; } return STATUS_COMPLETED; @@ -311,7 +336,10 @@ public class AccountExportServiceImpl implements AccountExportService { detail.setFailedReason(task.getErrorMessage()); detail.setCreatedAt(task.getCreateTime()); detail.setCompletedAt(task.getCompletedAt()); - if (STATUS_COMPLETED.equals(detail.getStatus()) && isCredentialVisible(credential)) { + if (STATUS_COMPLETED.equals(detail.getStatus()) + && !Boolean.TRUE.equals(task.getSourceBlocked()) + && hasDownloadCredentialSecret() + && isCredentialVisible(credential)) { detail.setDownloadExpiresAt(credential.getExpiresAt()); String accountCredentialId = buildAccountCredentialId(task); if (credential.getCredentialHash().equals(hashCredential(accountCredentialId))) { @@ -398,17 +426,25 @@ public class AccountExportServiceImpl implements AccountExportService { } private String buildAccountCredentialId(AccountExportTaskDO task) { - // Account 下载凭证 ID 由 taskId/requestHash 可重算生成;数据库只保存 hash,不持久化也不回传 FileService 原始 token。 - return "acct-dl-" + sha256(task.getTaskId() + ":" + task.getRequestHash()).substring(0, 32); + // Account 下载凭证 ID 使用服务端 secret 做 HMAC 派生;只读数据库不能反推可用 credentialId。 + if (!hasDownloadCredentialSecret()) { + throw new IllegalStateException("Account 下载凭证 secret 未配置"); + } + return "acct-dl-" + hmacSha256(downloadCredentialSecret, task.getTaskId() + ":" + task.getRequestHash()) + .substring(0, 32); } - private String sha256(String payload) { + private boolean hasDownloadCredentialSecret() { + return StringUtils.hasText(downloadCredentialSecret); + } + + private String hmacSha256(String secret, String payload) { try { - byte[] bytes = MessageDigest.getInstance("SHA-256") - .digest(payload.getBytes(StandardCharsets.UTF_8)); - return HexFormat.of().formatHex(bytes); - } catch (NoSuchAlgorithmException ex) { - throw new IllegalStateException("JDK 缺少 SHA-256 摘要算法", ex); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + } catch (Exception ex) { + throw new IllegalStateException("JDK 缺少 HmacSHA256 摘要算法或密钥不可用", ex); } } diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountDownloadCredentialMapper.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountDownloadCredentialMapper.java index ab664ae6..10e8d01d 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountDownloadCredentialMapper.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountDownloadCredentialMapper.java @@ -31,11 +31,18 @@ public interface AccountDownloadCredentialMapper extends BaseMapperX() .set(AccountDownloadCredentialDO::getConsumedAt, consumedAt) .eq(AccountDownloadCredentialDO::getCredentialHash, credentialHash) - .isNull(AccountDownloadCredentialDO::getConsumedAt)); + .eq(AccountDownloadCredentialDO::getAccountUserId, accountUserId) + .gt(AccountDownloadCredentialDO::getExpiresAt, now) + .isNull(AccountDownloadCredentialDO::getRevokedAt) + .isNull(AccountDownloadCredentialDO::getConsumedAt) + .and(wrapper -> wrapper.isNull(AccountDownloadCredentialDO::getSourceBlocked) + .or() + .eq(AccountDownloadCredentialDO::getSourceBlocked, false))); } } diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountExportServiceTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountExportServiceTest.java index 11c0f0c1..4db7b69c 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountExportServiceTest.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountExportServiceTest.java @@ -14,10 +14,15 @@ import cn.iocoder.muse.module.member.dal.mysql.account.AccountDownloadCredential import cn.iocoder.muse.module.member.dal.mysql.account.AccountExportTaskMapper; import cn.iocoder.muse.module.member.dal.mysql.account.MemberSecurityEventMapper; import cn.iocoder.muse.module.member.dal.mysql.user.MemberUserMapper; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; +import org.mockito.InOrder; import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.LocalDate; @@ -37,6 +42,8 @@ import static org.mockito.Mockito.*; */ class AccountExportServiceTest extends BaseMockitoUnitTest { + private static final String TEST_CREDENTIAL_SECRET = "unit-test-download-secret"; + @InjectMocks private AccountExportServiceImpl exportService; @@ -55,6 +62,11 @@ class AccountExportServiceTest extends BaseMockitoUnitTest { @Mock private AccountFileServiceFacade fileServiceFacade; + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(exportService, "downloadCredentialSecret", TEST_CREDENTIAL_SECRET); + } + @Test void should_createQueuedProfileExport_whenFileServiceUnavailable() { AccountExportTaskCreateReqVO reqVO = exportReq("profile", "cmd-export-1"); @@ -213,6 +225,56 @@ class AccountExportServiceTest extends BaseMockitoUnitTest { verify(downloadCredentialMapper, never()).insert(any(AccountDownloadCredentialDO.class)); } + @Test + void should_failClosedInternally_whenCredentialSecretMissing() { + ReflectionTestUtils.setField(exportService, "downloadCredentialSecret", ""); + AccountExportTaskCreateReqVO reqVO = exportReq("profile", "cmd-export-1"); + when(memberUserMapper.selectById(1001L)).thenReturn(user(1001L)); + when(commandService.buildRequestHash(any())).thenReturn("hash-export"); + when(commandService.reserveCommand(any())).thenReturn(null); + when(fileServiceFacade.createExportTask(any())) + .thenReturn(AccountFileServiceFacade.ExportTaskCreateResult.available("completed", + "plain-credential-token", "file-ref-1", LocalDateTime.of(2026, 5, 28, 11, 0), + LocalDateTime.of(2026, 5, 28, 10, 0))); + when(exportTaskMapper.insert(any(AccountExportTaskDO.class))).thenAnswer(invocation -> { + AccountExportTaskDO task = invocation.getArgument(0); + task.setId(701L); + task.setCreateTime(LocalDateTime.of(2026, 5, 28, 10, 0)); + return 1; + }); + + AccountExportTaskResultRespVO result = exportService.appCreateExportTask(1001L, reqVO); + + assertEquals("queued", result.getStatus()); + verify(exportTaskMapper).insert(argThat((AccountExportTaskDO task) -> + "failed".equals(task.getStatus()) + && task.getFileRef() == null + && task.getErrorMessage().contains("secret"))); + verify(downloadCredentialMapper, never()).insert(any(AccountDownloadCredentialDO.class)); + } + + @Test + void should_createQueuedTask_whenCreateFacadeThrowsRuntimeException() { + AccountExportTaskCreateReqVO reqVO = exportReq("profile", "cmd-export-1"); + when(memberUserMapper.selectById(1001L)).thenReturn(user(1001L)); + when(commandService.buildRequestHash(any())).thenReturn("hash-export"); + when(commandService.reserveCommand(any())).thenReturn(null); + when(fileServiceFacade.createExportTask(any())).thenThrow(new RuntimeException("file service down")); + when(exportTaskMapper.insert(any(AccountExportTaskDO.class))).thenAnswer(invocation -> { + AccountExportTaskDO task = invocation.getArgument(0); + task.setId(701L); + task.setCreateTime(LocalDateTime.of(2026, 5, 28, 10, 0)); + return 1; + }); + + AccountExportTaskResultRespVO result = exportService.appCreateExportTask(1001L, reqVO); + + assertEquals("queued", result.getStatus()); + verify(exportTaskMapper).insert(argThat((AccountExportTaskDO task) -> + "queued".equals(task.getStatus()) && task.getFileRef() == null)); + verify(downloadCredentialMapper, never()).insert(any(AccountDownloadCredentialDO.class)); + } + @Test void should_getExportTaskForOwnerOnly_withoutReturningHashedCredential() { AccountExportTaskDO task = exportTask("task-701", 1001L, "completed", "file-ref-1", false); @@ -227,6 +289,8 @@ class AccountExportServiceTest extends BaseMockitoUnitTest { assertEquals("task-701", detail.getTaskId()); assertEquals("completed", detail.getStatus()); assertEquals(accountCredentialId, detail.getDownloadCredentialId()); + assertNotEquals("acct-dl-" + hash(task.getTaskId() + ":" + task.getRequestHash()).substring(0, 32), + detail.getDownloadCredentialId()); assertFalse(detail.getDownloadCredentialId().contains("hash")); assertNotNull(detail.getDownloadExpiresAt()); verify(exportTaskMapper).selectByTaskIdAndAccountUserId("task-701", 1001L); @@ -249,6 +313,22 @@ class AccountExportServiceTest extends BaseMockitoUnitTest { assertNull(detail.getDownloadExpiresAt()); } + @Test + void should_hideDownloadCredentialInDetail_whenTaskSourceBlocked() { + AccountExportTaskDO task = exportTask("task-701", 1001L, "completed", "file-ref-1", true); + String accountCredentialId = accountCredentialId(task); + when(memberUserMapper.selectById(1001L)).thenReturn(user(1001L)); + when(exportTaskMapper.selectByTaskIdAndAccountUserId("task-701", 1001L)).thenReturn(task); + when(downloadCredentialMapper.selectLatestByTaskIdAndAccountUserId("task-701", 1001L)) + .thenReturn(credential(accountCredentialId, 1001L, "task-701", LocalDateTime.now().plusHours(1), false)); + + AccountExportTaskDetailRespVO detail = exportService.appGetExportTask(1001L, "task-701"); + + assertEquals("completed", detail.getStatus()); + assertNull(detail.getDownloadCredentialId()); + assertNull(detail.getDownloadExpiresAt()); + } + @Test void should_downloadPackage_whenCredentialHashMatchesAndTaskCompleted() { LocalDateTime expiresAt = LocalDateTime.now().plusHours(1); @@ -259,7 +339,8 @@ class AccountExportServiceTest extends BaseMockitoUnitTest { when(fileServiceFacade.downloadExportPackage(1001L, "task-701", "file-ref-1")) .thenReturn(AccountFileServiceFacade.DownloadPackageResult.available("account-export.zip", "application/octet-stream", "zip-bytes".getBytes(StandardCharsets.UTF_8))); - when(downloadCredentialMapper.markConsumed(eq(hash("cred-1")), any(LocalDateTime.class))).thenReturn(1); + when(downloadCredentialMapper.markConsumedIfUsable(eq(hash("cred-1")), eq(1001L), + any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(1); when(commandService.buildRequestHash(any())).thenReturn("hash-download"); AccountExportService.DownloadPackage result = exportService.appDownloadExport(1001L, "cred-1"); @@ -267,7 +348,12 @@ class AccountExportServiceTest extends BaseMockitoUnitTest { assertEquals("account-export.zip", result.filename()); assertArrayEquals("zip-bytes".getBytes(StandardCharsets.UTF_8), result.bytes()); verify(downloadCredentialMapper).selectByCredentialHash(hash("cred-1")); - verify(downloadCredentialMapper).markConsumed(eq(hash("cred-1")), any(LocalDateTime.class)); + InOrder inOrder = inOrder(downloadCredentialMapper, exportTaskMapper, fileServiceFacade); + inOrder.verify(downloadCredentialMapper).selectByCredentialHash(hash("cred-1")); + inOrder.verify(exportTaskMapper).selectByTaskIdAndAccountUserId("task-701", 1001L); + inOrder.verify(downloadCredentialMapper).markConsumedIfUsable(eq(hash("cred-1")), eq(1001L), + any(LocalDateTime.class), any(LocalDateTime.class)); + inOrder.verify(fileServiceFacade).downloadExportPackage(1001L, "task-701", "file-ref-1"); verify(auditService).record(argThat(req -> "appDownloadExport".equals(req.getOperationId()) && "succeeded".equals(req.getStatus()) @@ -280,15 +366,14 @@ class AccountExportServiceTest extends BaseMockitoUnitTest { .thenReturn(credential("cred-1", 1001L, "task-701", LocalDateTime.now().plusHours(1), false)); when(exportTaskMapper.selectByTaskIdAndAccountUserId("task-701", 1001L)) .thenReturn(exportTask("task-701", 1001L, "completed", "file-ref-1", false)); - when(fileServiceFacade.downloadExportPackage(1001L, "task-701", "file-ref-1")) - .thenReturn(AccountFileServiceFacade.DownloadPackageResult.available("account-export.zip", - "application/octet-stream", "zip-bytes".getBytes(StandardCharsets.UTF_8))); - when(downloadCredentialMapper.markConsumed(eq(hash("cred-1")), any(LocalDateTime.class))).thenReturn(0); + when(downloadCredentialMapper.markConsumedIfUsable(eq(hash("cred-1")), eq(1001L), + any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(0); ServiceException exception = assertThrows(ServiceException.class, () -> exportService.appDownloadExport(1001L, "cred-1")); assertEquals(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID.getCode(), exception.getCode()); + verifyNoInteractions(fileServiceFacade); verifyNoInteractions(auditService); } @@ -336,12 +421,33 @@ class AccountExportServiceTest extends BaseMockitoUnitTest { .thenReturn(exportTask("task-701", 1001L, "completed", "file-ref-1", false)); when(fileServiceFacade.downloadExportPackage(1001L, "task-701", "file-ref-1")) .thenReturn(AccountFileServiceFacade.DownloadPackageResult.unavailable()); + when(downloadCredentialMapper.markConsumedIfUsable(eq(hash("cred-1")), eq(1001L), + any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(1); ServiceException exception = assertThrows(ServiceException.class, () -> exportService.appDownloadExport(1001L, "cred-1")); assertEquals(ACCOUNT_FILE_SERVICE_UNAVAILABLE.getCode(), exception.getCode()); - verify(downloadCredentialMapper, never()).markConsumed(anyString(), any()); + verify(downloadCredentialMapper).markConsumedIfUsable(eq(hash("cred-1")), eq(1001L), + any(LocalDateTime.class), any(LocalDateTime.class)); + } + + @Test + void should_rejectDownloadAsFileServiceUnavailable_whenDownloadFacadeThrowsRuntimeException() { + when(downloadCredentialMapper.selectByCredentialHash(hash("cred-1"))) + .thenReturn(credential("cred-1", 1001L, "task-701", LocalDateTime.now().plusHours(1), false)); + when(exportTaskMapper.selectByTaskIdAndAccountUserId("task-701", 1001L)) + .thenReturn(exportTask("task-701", 1001L, "completed", "file-ref-1", false)); + when(downloadCredentialMapper.markConsumedIfUsable(eq(hash("cred-1")), eq(1001L), + any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(1); + when(fileServiceFacade.downloadExportPackage(1001L, "task-701", "file-ref-1")) + .thenThrow(new RuntimeException("file service down")); + + ServiceException exception = assertThrows(ServiceException.class, + () -> exportService.appDownloadExport(1001L, "cred-1")); + + assertEquals(ACCOUNT_FILE_SERVICE_UNAVAILABLE.getCode(), exception.getCode()); + verifyNoInteractions(auditService); } private AccountExportTaskCreateReqVO exportReq(String exportType, String commandId) { @@ -400,6 +506,16 @@ class AccountExportServiceTest extends BaseMockitoUnitTest { } private String accountCredentialId(AccountExportTaskDO task) { - return "acct-dl-" + hash(task.getTaskId() + ":" + task.getRequestHash()).substring(0, 32); + return "acct-dl-" + hmac(TEST_CREDENTIAL_SECRET, task.getTaskId() + ":" + task.getRequestHash()).substring(0, 32); + } + + private String hmac(String secret, String payload) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } } }