From 7aedfb9d08fb69ba42308d0b86d27d332d1efd40 Mon Sep 17 00:00:00 2001 From: zizi Date: Sat, 30 May 2026 00:48:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(p1r):=20=E5=AE=9E=E7=8E=B0=20Account=20?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E4=B8=8B=E8=BD=BD=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/enums/ErrorCodeConstants.java | 1 + ...untFileServiceFacadeAutoConfiguration.java | 21 + .../account/AccountExportService.java | 23 + .../account/AccountExportServiceImpl.java | 439 ++++++++++++++++++ .../facade/AccountFileServiceFacade.java | 75 +++ .../UnavailableAccountFileServiceFacade.java | 9 + .../account/AppAccountExportController.java | 99 ++++ .../vo/AccountExportTaskCreateReqVO.java | 5 + .../AccountDownloadCredentialMapper.java | 18 + .../account/AccountExportTaskMapper.java | 5 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../account/AccountExportServiceTest.java | 400 ++++++++++++++++ ...untFileServiceFacadeConfigurationTest.java | 71 +++ .../AppAccountExportControllerTest.java | 205 ++++++++ 14 files changed, 1372 insertions(+) create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/autoconfigure/member/account/AccountFileServiceFacadeAutoConfiguration.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountExportService.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountExportServiceImpl.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/facade/AccountFileServiceFacade.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/facade/UnavailableAccountFileServiceFacade.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountExportController.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountExportServiceTest.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/facade/AccountFileServiceFacadeConfigurationTest.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountExportControllerTest.java diff --git a/muse-cloud/muse-module-member/muse-module-member-api/src/main/java/cn/iocoder/muse/module/member/enums/ErrorCodeConstants.java b/muse-cloud/muse-module-member/muse-module-member-api/src/main/java/cn/iocoder/muse/module/member/enums/ErrorCodeConstants.java index b72a664e..5387eb46 100644 --- a/muse-cloud/muse-module-member/muse-module-member-api/src/main/java/cn/iocoder/muse/module/member/enums/ErrorCodeConstants.java +++ b/muse-cloud/muse-module-member/muse-module-member-api/src/main/java/cn/iocoder/muse/module/member/enums/ErrorCodeConstants.java @@ -70,5 +70,6 @@ public interface ErrorCodeConstants { ErrorCode ACCOUNT_EXPORT_SOURCE_BLOCKED = new ErrorCode(1_004_020_011, "导出来源已被阻断"); ErrorCode ACCOUNT_REQUEST_HASH_REQUIRED = new ErrorCode(1_004_020_012, "requestHash 不能为空"); ErrorCode ACCOUNT_MARKET_PROJECTION_UNAVAILABLE = new ErrorCode(1_004_020_013, "Market Account 投影暂不可用"); + ErrorCode ACCOUNT_FILE_SERVICE_UNAVAILABLE = new ErrorCode(1_004_020_014, "Account 导出文件服务暂不可用"); } diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/autoconfigure/member/account/AccountFileServiceFacadeAutoConfiguration.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/autoconfigure/member/account/AccountFileServiceFacadeAutoConfiguration.java new file mode 100644 index 00000000..028e1d1c --- /dev/null +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/autoconfigure/member/account/AccountFileServiceFacadeAutoConfiguration.java @@ -0,0 +1,21 @@ +package cn.iocoder.muse.autoconfigure.member.account; + +import cn.iocoder.muse.module.member.application.account.facade.AccountFileServiceFacade; +import cn.iocoder.muse.module.member.application.account.facade.UnavailableAccountFileServiceFacade; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +/** + * Account FileService Facade 自动配置。 + */ +@AutoConfiguration +public class AccountFileServiceFacadeAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(AccountFileServiceFacade.class) + public AccountFileServiceFacade accountFileServiceFacade() { + // 默认实现只表达 FileService 未接入;真实 FileService owner 提供 Facade 后,本 Bean 自动让位。 + return new UnavailableAccountFileServiceFacade(); + } +} diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountExportService.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountExportService.java new file mode 100644 index 00000000..f53927cf --- /dev/null +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountExportService.java @@ -0,0 +1,23 @@ +package cn.iocoder.muse.module.member.application.account; + +import cn.iocoder.muse.module.member.controller.app.account.vo.AccountExportTaskCreateReqVO; +import cn.iocoder.muse.module.member.controller.app.account.vo.AccountExportTaskDetailRespVO; +import cn.iocoder.muse.module.member.controller.app.account.vo.AccountExportTaskResultRespVO; + +/** + * Account 导出任务应用服务。 + */ +public interface AccountExportService { + + AccountExportTaskResultRespVO appCreateExportTask(Long accountUserId, AccountExportTaskCreateReqVO reqVO); + + AccountExportTaskDetailRespVO appGetExportTask(Long accountUserId, String taskId); + + DownloadPackage appDownloadExport(Long accountUserId, String credentialId); + + /** + * 下载成功时返回给 Controller 的二进制包;Controller 必须直接写文件流,不能封装 CommonResult。 + */ + record DownloadPackage(String filename, String contentType, byte[] bytes) { + } +} 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 new file mode 100644 index 00000000..447b5819 --- /dev/null +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountExportServiceImpl.java @@ -0,0 +1,439 @@ +package cn.iocoder.muse.module.member.application.account; + +import cn.iocoder.muse.framework.common.exception.ServiceException; +import cn.iocoder.muse.framework.common.util.json.JsonUtils; +import cn.iocoder.muse.module.member.application.account.facade.AccountFileServiceFacade; +import cn.iocoder.muse.module.member.controller.app.account.vo.AccountExportTaskCreateReqVO; +import cn.iocoder.muse.module.member.controller.app.account.vo.AccountExportTaskDetailRespVO; +import cn.iocoder.muse.module.member.controller.app.account.vo.AccountExportTaskResultRespVO; +import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCommandDO; +import cn.iocoder.muse.module.member.dal.dataobject.account.AccountDownloadCredentialDO; +import cn.iocoder.muse.module.member.dal.dataobject.account.AccountExportTaskDO; +import cn.iocoder.muse.module.member.dal.dataobject.account.MemberSecurityEventDO; +import cn.iocoder.muse.module.member.dal.dataobject.user.MemberUserDO; +import cn.iocoder.muse.module.member.dal.mysql.account.AccountDownloadCredentialMapper; +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 cn.iocoder.muse.module.member.domain.account.AccountDownloadGuard; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.NOT_FOUND; +import static cn.iocoder.muse.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; +import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID; +import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_EXPORT_SOURCE_BLOCKED; +import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_FILE_SERVICE_UNAVAILABLE; +import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_USER_NOT_EXISTS; + +/** + * Account 导出任务应用服务实现。 + */ +@Service +public class AccountExportServiceImpl implements AccountExportService { + + private static final String OPERATION_CREATE_EXPORT_TASK = "appCreateExportTask"; + private static final String OPERATION_DOWNLOAD_EXPORT = "appDownloadExport"; + private static final String TARGET_TYPE_EXPORT_TASK = "exportTask"; + private static final String STATUS_QUEUED = "queued"; + private static final String STATUS_PROCESSING = "processing"; + private static final String STATUS_COMPLETED = "completed"; + private static final String STATUS_FAILED = "failed"; + private static final String STATUS_IDEMPOTENT_HIT = "idempotent_hit"; + private static final int SAFE_DATE_RANGE_DAYS = 31; + private static final Set EXPORT_TYPES = Set.of("profile", "usage", "security_events", "purchases", "licenses"); + private static final Set DATE_RANGE_REQUIRED_TYPES = Set.of("usage", "security_events"); + private static final Set SENSITIVE_EXPORT_TYPES = Set.of("security_events", "usage"); + private static final Set DESENSITIZATION_RULES = Set.of("standard", "strict"); + + @Resource + private MemberUserMapper memberUserMapper; + @Resource + private AccountExportTaskMapper exportTaskMapper; + @Resource + private AccountDownloadCredentialMapper downloadCredentialMapper; + @Resource + private MemberSecurityEventMapper securityEventMapper; + @Resource + private AccountCommandService commandService; + @Resource + private AccountAuditService auditService; + @Resource + private AccountFileServiceFacade fileServiceFacade; + + @Override + @Transactional(rollbackFor = Exception.class) + public AccountExportTaskResultRespVO appCreateExportTask(Long accountUserId, AccountExportTaskCreateReqVO reqVO) { + requireUser(accountUserId); + ExportRequest exportRequest = normalizeRequest(reqVO); + String requestHash = commandService.buildRequestHash(Map.of( + "exportType", exportRequest.exportType(), + "dateRange", exportRequest.dateRangeSnapshot(), + "desensitizationRule", exportRequest.desensitizationRule())); + AccountCommandService.CommandEnvelope envelope = AccountCommandService.CommandEnvelope.builder() + .commandId(reqVO.getCommandId()) + .operationId(OPERATION_CREATE_EXPORT_TASK) + .actorUserId(accountUserId) + .ownerUserId(accountUserId) + .targetType(TARGET_TYPE_EXPORT_TASK) + .targetKey("exportTask:" + accountUserId) + .requestHash(requestHash) + .build(); + AccountCommandDO replayCommand = commandService.reserveCommand(envelope); + if (replayCommand != null) { + AccountExportTaskResultRespVO replay = JsonUtils.parseObject(replayCommand.getResultSnapshot(), + AccountExportTaskResultRespVO.class); + replay.setStatus(STATUS_IDEMPOTENT_HIT); + return replay; + } + + String taskId = newTaskId(); + String sourceSnapshot = sourceSnapshot(exportRequest); + AccountFileServiceFacade.ExportTaskCreateResult fileResult = fileServiceFacade.createExportTask( + AccountFileServiceFacade.ExportTaskCreateCommand.builder() + .accountUserId(accountUserId) + .taskId(taskId) + .commandId(reqVO.getCommandId()) + .exportType(exportRequest.exportType()) + .desensitizationRule(exportRequest.desensitizationRule()) + .sourceSnapshot(sourceSnapshot) + .build()); + AccountExportTaskDO task = buildTask(accountUserId, reqVO.getCommandId(), requestHash, taskId, + exportRequest, sourceSnapshot, fileResult); + exportTaskMapper.insert(task); + insertDownloadCredentialIfPresent(accountUserId, task, fileResult); + AccountExportTaskResultRespVO result = toCreateResult(task); + commandService.recordSucceeded(envelope, JsonUtils.toJsonString(result)); + auditService.record(AccountAuditService.AuditCreateReq.builder() + .operationId(OPERATION_CREATE_EXPORT_TASK) + .actorUserId(accountUserId) + .accountUserId(accountUserId) + .side("app") + .targetType(TARGET_TYPE_EXPORT_TASK) + .targetId(task.getId()) + .commandId(reqVO.getCommandId()) + .requestHash(requestHash) + .afterSnapshot(JsonUtils.toJsonString(result)) + .riskSummary(sensitiveExportType(exportRequest.exportType()) ? "sensitive_export" : null) + .status("succeeded") + .build()); + if (sensitiveExportType(exportRequest.exportType())) { + insertSensitiveExportEvent(accountUserId, exportRequest, taskId); + } + return result; + } + + @Override + public AccountExportTaskDetailRespVO appGetExportTask(Long accountUserId, String taskId) { + requireUser(accountUserId); + AccountExportTaskDO task = exportTaskMapper.selectByTaskIdAndAccountUserId(taskId, accountUserId); + if (task == null) { + throw notFound("导出任务不存在"); + } + AccountDownloadCredentialDO credential = STATUS_COMPLETED.equals(task.getStatus()) + ? downloadCredentialMapper.selectLatestByTaskIdAndAccountUserId(task.getTaskId(), accountUserId) + : null; + return toDetail(task, credential); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public DownloadPackage appDownloadExport(Long accountUserId, String credentialId) { + String credentialHash = hashCredential(credentialId); + AccountDownloadCredentialDO credential = downloadCredentialMapper.selectByCredentialHash(credentialHash); + if (credential == null) { + throw new ServiceException(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID); + } + AccountDownloadGuard.requireUsableCredential(accountUserId, credential.getAccountUserId(), + credential.getExpiresAt(), credential.getConsumedAt(), credential.getRevokedAt(), + Boolean.TRUE.equals(credential.getSourceBlocked()), LocalDateTime.now()); + AccountExportTaskDO task = exportTaskMapper.selectByTaskIdAndAccountUserId(credential.getTaskId(), accountUserId); + requireDownloadableTask(task); + AccountFileServiceFacade.DownloadPackageResult result = + fileServiceFacade.downloadExportPackage(accountUserId, task.getTaskId(), task.getFileRef()); + 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) + .accountUserId(accountUserId) + .side("app") + .targetType(TARGET_TYPE_EXPORT_TASK) + .targetId(task.getId()) + .requestHash(commandService.buildRequestHash(Map.of( + "credentialHash", credentialHash, + "taskId", task.getTaskId(), + "fileRef", task.getFileRef()))) + .afterSnapshot(JsonUtils.toJsonString(Map.of("taskId", task.getTaskId(), "bytes", result.bytes().length))) + .status("succeeded") + .build()); + return new DownloadPackage(defaultFilename(result.filename(), task), "application/octet-stream", result.bytes()); + } + + private MemberUserDO requireUser(Long accountUserId) { + MemberUserDO user = accountUserId == null ? null : memberUserMapper.selectById(accountUserId); + if (user == null) { + throw new ServiceException(ACCOUNT_USER_NOT_EXISTS); + } + return user; + } + + private ExportRequest normalizeRequest(AccountExportTaskCreateReqVO reqVO) { + if (reqVO == null) { + throw invalidParamException("导出任务请求不能为空"); + } + String exportType = normalizeExportType(reqVO.getExportType()); + String desensitizationRule = normalizeDesensitizationRule(reqVO.getDesensitizationRule()); + LocalDate startDate = reqVO.getDateRange() == null ? null : reqVO.getDateRange().getStartDate(); + LocalDate endDate = reqVO.getDateRange() == null ? null : reqVO.getDateRange().getEndDate(); + if (DATE_RANGE_REQUIRED_TYPES.contains(exportType) && (startDate == null || endDate == null)) { + throw invalidParamException("{} 导出必须提供 dateRange.startDate/endDate", exportType); + } + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { + throw invalidParamException("dateRange.startDate 不能晚于 endDate"); + } + long rangeDays = startDate == null || endDate == null ? 0 : endDate.toEpochDay() - startDate.toEpochDay() + 1; + if (sensitiveExportType(exportType) && rangeDays > SAFE_DATE_RANGE_DAYS) { + // OpenAPI 当前没有 step-up 证明字段,大范围高敏导出必须 fail-closed,避免静默放开敏感数据导出。 + throw invalidParamException("高敏导出超过 {} 天需要 step-up,当前请求缺少 step-up 证明", SAFE_DATE_RANGE_DAYS); + } + return new ExportRequest(exportType, startDate, endDate, desensitizationRule); + } + + private String normalizeExportType(String exportType) { + if (!StringUtils.hasText(exportType) || !EXPORT_TYPES.contains(exportType.trim())) { + throw invalidParamException("exportType 参数不支持: {}", exportType); + } + return exportType.trim(); + } + + private String normalizeDesensitizationRule(String desensitizationRule) { + if (!StringUtils.hasText(desensitizationRule)) { + return "standard"; + } + String normalized = desensitizationRule.trim(); + if (!DESENSITIZATION_RULES.contains(normalized)) { + throw invalidParamException("desensitizationRule 参数不支持: {}", desensitizationRule); + } + return normalized; + } + + private AccountExportTaskDO buildTask(Long accountUserId, String commandId, String requestHash, String taskId, + ExportRequest exportRequest, String sourceSnapshot, + AccountFileServiceFacade.ExportTaskCreateResult fileResult) { + String status = normalizeCreateStatus(fileResult); + String fileRef = fileResult == null || !fileResult.available() ? null : fileResult.fileRef(); + AccountExportTaskDO task = AccountExportTaskDO.builder() + .taskId(taskId) + .accountUserId(accountUserId) + .commandId(commandId) + .requestHash(requestHash) + .exportType(exportRequest.exportType()) + .status(status) + .sourceSnapshot(sourceSnapshot) + .fileRef(fileRef) + .sourceBlocked(false) + .errorCode(STATUS_FAILED.equals(status) ? ACCOUNT_FILE_SERVICE_UNAVAILABLE.getCode().toString() : null) + .errorMessage(STATUS_FAILED.equals(status) ? "FileService 返回 completed 但缺少可下载文件信息" : null) + .completedAt(STATUS_COMPLETED.equals(status) + ? firstNonNull(fileResult == null ? null : fileResult.completedAt(), LocalDateTime.now()) + : null) + .build(); + return task; + } + + 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 可派生的下载凭证;缺任一项都按失败关闭。 + return STATUS_FAILED; + } + return STATUS_COMPLETED; + } + if (STATUS_PROCESSING.equals(status)) { + return STATUS_PROCESSING; + } + // FileService 内部 pending/unavailable 统一收敛为 queued,避免返回 OpenAPI 未声明状态。 + return STATUS_QUEUED; + } + + private AccountExportTaskResultRespVO toCreateResult(AccountExportTaskDO task) { + AccountExportTaskResultRespVO result = new AccountExportTaskResultRespVO(); + result.setTaskId(task.getTaskId()); + // 创建响应枚举没有 completed;同步完成时对外仍表达为 processing,详情接口再返回 completed 和凭证过期时间。 + result.setStatus((STATUS_PROCESSING.equals(task.getStatus()) || STATUS_COMPLETED.equals(task.getStatus())) + ? STATUS_PROCESSING : STATUS_QUEUED); + result.setEstimatedCompletionAt(LocalDateTime.now().plusMinutes(5)); + return result; + } + + private void insertDownloadCredentialIfPresent(Long accountUserId, AccountExportTaskDO task, + AccountFileServiceFacade.ExportTaskCreateResult fileResult) { + if (fileResult == null || !fileResult.available() || !STATUS_COMPLETED.equals(task.getStatus()) + || !StringUtils.hasText(task.getFileRef()) || fileResult.downloadExpiresAt() == null) { + return; + } + String accountCredentialId = buildAccountCredentialId(task); + AccountDownloadCredentialDO credential = AccountDownloadCredentialDO.builder() + .credentialHash(hashCredential(accountCredentialId)) + .accountUserId(accountUserId) + .taskId(task.getTaskId()) + .expiresAt(fileResult.downloadExpiresAt()) + .sourceBlocked(false) + .build(); + downloadCredentialMapper.insert(credential); + } + + private AccountExportTaskDetailRespVO toDetail(AccountExportTaskDO task, AccountDownloadCredentialDO credential) { + AccountExportTaskDetailRespVO detail = new AccountExportTaskDetailRespVO(); + detail.setTaskId(task.getTaskId()); + detail.setExportType(task.getExportType()); + detail.setStatus(normalizeDetailStatus(task.getStatus())); + detail.setFailedReason(task.getErrorMessage()); + detail.setCreatedAt(task.getCreateTime()); + detail.setCompletedAt(task.getCompletedAt()); + if (STATUS_COMPLETED.equals(detail.getStatus()) && isCredentialVisible(credential)) { + detail.setDownloadExpiresAt(credential.getExpiresAt()); + String accountCredentialId = buildAccountCredentialId(task); + if (credential.getCredentialHash().equals(hashCredential(accountCredentialId))) { + detail.setDownloadCredentialId(accountCredentialId); + } + } + return detail; + } + + private boolean isCredentialVisible(AccountDownloadCredentialDO credential) { + // 详情接口只能暴露仍可用的 Account 下载凭证;过期、撤销、已消费或源阻断时不回传 credentialId。 + return credential != null + && credential.getExpiresAt() != null + && credential.getExpiresAt().isAfter(LocalDateTime.now()) + && credential.getConsumedAt() == null + && credential.getRevokedAt() == null + && !Boolean.TRUE.equals(credential.getSourceBlocked()); + } + + private String normalizeDetailStatus(String status) { + if (STATUS_PROCESSING.equals(status) || STATUS_COMPLETED.equals(status) || STATUS_FAILED.equals(status)) { + return status; + } + return STATUS_QUEUED; + } + + private void requireDownloadableTask(AccountExportTaskDO task) { + if (task == null || !STATUS_COMPLETED.equals(task.getStatus()) || !StringUtils.hasText(task.getFileRef())) { + throw new ServiceException(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID); + } + if (Boolean.TRUE.equals(task.getSourceBlocked())) { + throw new ServiceException(ACCOUNT_EXPORT_SOURCE_BLOCKED); + } + } + + private void insertSensitiveExportEvent(Long accountUserId, ExportRequest exportRequest, String taskId) { + MemberSecurityEventDO event = MemberSecurityEventDO.builder() + .accountUserId(accountUserId) + .eventType("sensitive_export") + .severity("warning") + .deviceInfo(JsonUtils.toJsonString(Map.of( + "description", "Account sensitive export requested", + "exportType", exportRequest.exportType(), + "dateRange", exportRequest.dateRangeSnapshot(), + "taskId", taskId))) + .acknowledged(false) + .build(); + event.setCreateTime(LocalDateTime.now()); + securityEventMapper.insert(event); + } + + private String sourceSnapshot(ExportRequest exportRequest) { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("source", "account"); + snapshot.put("exportType", exportRequest.exportType()); + snapshot.put("dateRange", exportRequest.dateRangeSnapshot()); + snapshot.put("desensitizationRule", exportRequest.desensitizationRule()); + return JsonUtils.toJsonString(snapshot); + } + + private String hashCredential(String credentialId) { + if (!StringUtils.hasText(credentialId)) { + throw new ServiceException(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID); + } + try { + byte[] bytes = MessageDigest.getInstance("SHA-256") + .digest(credentialId.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(bytes); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("JDK 缺少 SHA-256 摘要算法", ex); + } + } + + private String newTaskId() { + return "acct-exp-" + UUID.randomUUID(); + } + + private boolean sensitiveExportType(String exportType) { + return SENSITIVE_EXPORT_TYPES.contains(exportType); + } + + private String defaultFilename(String filename, AccountExportTaskDO task) { + return StringUtils.hasText(filename) ? filename : "account-" + task.getExportType() + "-" + task.getTaskId() + ".bin"; + } + + private String buildAccountCredentialId(AccountExportTaskDO task) { + // Account 下载凭证 ID 由 taskId/requestHash 可重算生成;数据库只保存 hash,不持久化也不回传 FileService 原始 token。 + return "acct-dl-" + sha256(task.getTaskId() + ":" + task.getRequestHash()).substring(0, 32); + } + + private String sha256(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); + } + } + + private T firstNonNull(T first, T second) { + return first != null ? first : second; + } + + private ServiceException notFound(String message) { + return new ServiceException(NOT_FOUND.getCode(), message); + } + + private record ExportRequest(String exportType, LocalDate startDate, LocalDate endDate, + String desensitizationRule) { + + Map dateRangeSnapshot() { + Map dateRange = new LinkedHashMap<>(); + if (startDate != null) { + dateRange.put("startDate", startDate); + } + if (endDate != null) { + dateRange.put("endDate", endDate); + } + return dateRange; + } + } +} diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/facade/AccountFileServiceFacade.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/facade/AccountFileServiceFacade.java new file mode 100644 index 00000000..3ecbbdc0 --- /dev/null +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/facade/AccountFileServiceFacade.java @@ -0,0 +1,75 @@ +package cn.iocoder.muse.module.member.application.account.facade; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Account 导出包的 FileService 边界。 + * + *

P1R-3 只在 Account 侧定义任务、凭证和下载校验,不接管真实文件生成/存储。 + * 后续 FileService 接入时必须替换该 Facade,并返回真实 fileRef 与文件字节。

+ */ +public interface AccountFileServiceFacade { + + /** + * 请求 FileService 创建 Account 导出包。 + * + *

默认 unavailable,调用方只能创建合同允许的 queued 任务,不能伪造 completed、凭证或 fileRef。

+ */ + default ExportTaskCreateResult createExportTask(ExportTaskCreateCommand command) { + return ExportTaskCreateResult.unavailable(); + } + + /** + * 根据 Account 已校验通过的任务读取导出包字节。 + */ + default DownloadPackageResult downloadExportPackage(Long accountUserId, String taskId, String fileRef) { + return DownloadPackageResult.unavailable(); + } + + /** + * Account 请求 FileService 创建导出任务的最小命令。 + */ + @Data + @Builder + class ExportTaskCreateCommand { + private Long accountUserId; + private String taskId; + private String commandId; + private String exportType; + private String desensitizationRule; + private String sourceSnapshot; + } + + /** + * FileService 导出任务创建投影。 + */ + record ExportTaskCreateResult(boolean available, String status, String credentialId, String fileRef, + LocalDateTime downloadExpiresAt, LocalDateTime completedAt) { + + public static ExportTaskCreateResult unavailable() { + return new ExportTaskCreateResult(false, null, null, null, null, null); + } + + public static ExportTaskCreateResult available(String status, String credentialId, String fileRef, + LocalDateTime downloadExpiresAt, LocalDateTime completedAt) { + return new ExportTaskCreateResult(true, status, credentialId, fileRef, downloadExpiresAt, completedAt); + } + } + + /** + * FileService 下载包字节响应。 + */ + record DownloadPackageResult(boolean available, String filename, String contentType, byte[] bytes) { + + public static DownloadPackageResult unavailable() { + return new DownloadPackageResult(false, null, null, null); + } + + public static DownloadPackageResult available(String filename, String contentType, byte[] bytes) { + return new DownloadPackageResult(true, filename, contentType, bytes); + } + } +} diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/facade/UnavailableAccountFileServiceFacade.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/facade/UnavailableAccountFileServiceFacade.java new file mode 100644 index 00000000..2b5970d5 --- /dev/null +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/facade/UnavailableAccountFileServiceFacade.java @@ -0,0 +1,9 @@ +package cn.iocoder.muse.module.member.application.account.facade; + +/** + * FileService 未接入时的 Account 默认 Facade。 + * + *

该类保持普通类形态,由 AutoConfiguration 条件注册,避免组件扫描阶段抢占真实实现。

+ */ +public class UnavailableAccountFileServiceFacade implements AccountFileServiceFacade { +} diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountExportController.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountExportController.java new file mode 100644 index 00000000..a7ad349a --- /dev/null +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountExportController.java @@ -0,0 +1,99 @@ +package cn.iocoder.muse.module.member.controller.app.account; + +import cn.iocoder.muse.framework.common.pojo.CommonResult; +import cn.iocoder.muse.module.member.application.account.AccountExportService; +import cn.iocoder.muse.module.member.controller.app.account.vo.AccountExportTaskCreateReqVO; +import cn.iocoder.muse.module.member.controller.app.account.vo.AccountExportTaskDetailRespVO; +import cn.iocoder.muse.module.member.controller.app.account.vo.AccountExportTaskResultRespVO; +import cn.iocoder.muse.module.member.domain.account.AccountApiVersionGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; + +import static cn.iocoder.muse.framework.common.pojo.CommonResult.success; +import static cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +/** + * 用户 APP - Muse Account 导出任务 API。 + */ +@Tag(name = "用户 APP - Muse Account 导出") +@RestController +@RequestMapping("/muse") +@Validated +public class AppAccountExportController { + + @Resource + private AccountExportService exportService; + + @PostMapping(value = "/account/export-tasks", headers = "X-API-Version") + @Operation(summary = "创建个人中心导出任务") + public CommonResult createExportTask( + @RequestHeader(value = "X-API-Version", required = false) String apiVersion, + @RequestBody @Valid AccountExportTaskCreateReqVO reqVO) { + AccountApiVersionGuard.requireVersion(apiVersion); + return success(exportService.appCreateExportTask(getLoginUserId(), reqVO)); + } + + @PostMapping(value = "/account/export-tasks", headers = "!X-API-Version") + @Operation(summary = "创建个人中心导出任务") + public CommonResult createExportTaskWithoutVersion( + @RequestBody @Valid AccountExportTaskCreateReqVO reqVO) { + AccountApiVersionGuard.requireVersion(null); + return success(null); + } + + @GetMapping(value = "/account/export-tasks/{taskId}", headers = "X-API-Version") + @Operation(summary = "查询个人中心导出任务") + public CommonResult getExportTask( + @RequestHeader(value = "X-API-Version", required = false) String apiVersion, + @PathVariable String taskId) { + AccountApiVersionGuard.requireVersion(apiVersion); + return success(exportService.appGetExportTask(getLoginUserId(), taskId)); + } + + @GetMapping(value = "/account/export-tasks/{taskId}", headers = "!X-API-Version") + @Operation(summary = "查询个人中心导出任务") + public CommonResult getExportTaskWithoutVersion(@PathVariable String taskId) { + AccountApiVersionGuard.requireVersion(null); + return success(null); + } + + @GetMapping(value = "/account/downloads/{credentialId}", headers = "X-API-Version", + produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + @Operation(summary = "下载个人中心导出包") + public ResponseEntity downloadExport( + @RequestHeader(value = "X-API-Version", required = false) String apiVersion, + @PathVariable String credentialId) { + AccountApiVersionGuard.requireVersion(apiVersion); + AccountExportService.DownloadPackage download = exportService.appDownloadExport(getLoginUserId(), credentialId); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(download.filename() == null ? "account-export.bin" : download.filename(), + StandardCharsets.UTF_8) + .build().toString()) + .body(download.bytes()); + } + + @GetMapping(value = "/account/downloads/{credentialId}", headers = "!X-API-Version") + @Operation(summary = "下载个人中心导出包") + public CommonResult downloadExportWithoutVersion(@PathVariable String credentialId) { + AccountApiVersionGuard.requireVersion(null); + return success(null); + } +} diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/app/account/vo/AccountExportTaskCreateReqVO.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/app/account/vo/AccountExportTaskCreateReqVO.java index 08103bd7..8a09d65e 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/app/account/vo/AccountExportTaskCreateReqVO.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/app/account/vo/AccountExportTaskCreateReqVO.java @@ -1,6 +1,8 @@ package cn.iocoder.muse.module.member.controller.app.account.vo; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import lombok.Data; import java.time.LocalDate; @@ -10,10 +12,13 @@ import java.time.LocalDate; public class AccountExportTaskCreateReqVO { @Schema(description = "幂等键") + @NotBlank(message = "commandId 不能为空") private String commandId; @Schema(description = "导出类型") + @NotBlank(message = "exportType 不能为空") private String exportType; @Schema(description = "时间范围") + @Valid private DateRangeVO dateRange; @Schema(description = "脱敏规则") private String desensitizationRule; 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 8eaa596e..ab664ae6 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 @@ -1,9 +1,12 @@ package cn.iocoder.muse.module.member.dal.mysql.account; import cn.iocoder.muse.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.muse.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.muse.module.member.dal.dataobject.account.AccountDownloadCredentialDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import org.apache.ibatis.annotations.Mapper; +import java.time.LocalDateTime; import java.util.List; /** @@ -20,4 +23,19 @@ public interface AccountDownloadCredentialMapper extends BaseMapperX() + .eq(AccountDownloadCredentialDO::getTaskId, taskId) + .eq(AccountDownloadCredentialDO::getAccountUserId, accountUserId) + .orderByDesc(AccountDownloadCredentialDO::getExpiresAt) + .last("LIMIT 1")); + } + + default int markConsumed(String credentialHash, LocalDateTime consumedAt) { + return update(null, new LambdaUpdateWrapper() + .set(AccountDownloadCredentialDO::getConsumedAt, consumedAt) + .eq(AccountDownloadCredentialDO::getCredentialHash, credentialHash) + .isNull(AccountDownloadCredentialDO::getConsumedAt)); + } + } diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountExportTaskMapper.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountExportTaskMapper.java index f67613d3..6269693a 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountExportTaskMapper.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountExportTaskMapper.java @@ -14,6 +14,11 @@ public interface AccountExportTaskMapper extends BaseMapperX { + 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()); + assertNotNull(result.getTaskId()); + assertNotEquals("pending", result.getStatus()); + verify(exportTaskMapper).insert(argThat((AccountExportTaskDO task) -> + Long.valueOf(1001L).equals(task.getAccountUserId()) + && "security_events".equals(task.getExportType()) + && "queued".equals(task.getStatus()) + && task.getFileRef() == null + && Boolean.FALSE.equals(task.getSourceBlocked()))); + verify(downloadCredentialMapper, never()).insert(any(AccountDownloadCredentialDO.class)); + verify(securityEventMapper).insert(argThat((MemberSecurityEventDO event) -> + Long.valueOf(1001L).equals(event.getAccountUserId()) + && "sensitive_export".equals(event.getEventType()) + && "warning".equals(event.getSeverity()) + && event.getDeviceInfo() != null + && !event.getDeviceInfo().contains("credential"))); + verify(commandService).recordSucceeded(any(), argThat(snapshot -> snapshot.contains("\"status\":\"queued\""))); + verify(auditService).record(argThat(req -> + "appCreateExportTask".equals(req.getOperationId()) + && Long.valueOf(1001L).equals(req.getAccountUserId()) + && "succeeded".equals(req.getStatus()))); + } + + @Test + void should_returnIdempotentHit_whenCreateCommandReplayed() { + 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(AccountCommandDO.builder() + .resultSnapshot(""" + {"taskId":"task-701","status":"queued","estimatedCompletionAt":"2026-05-28T10:05:00"} + """) + .build()); + + AccountExportTaskResultRespVO result = exportService.appCreateExportTask(1001L, reqVO); + + assertEquals("task-701", result.getTaskId()); + assertEquals("idempotent_hit", result.getStatus()); + verifyNoInteractions(fileServiceFacade, exportTaskMapper, downloadCredentialMapper, securityEventMapper); + } + + @Test + void should_failClosed_whenSecurityExportRangeRequiresStepUpButNoStepUpFieldExists() { + AccountExportTaskCreateReqVO reqVO = exportReq("security_events", "cmd-export-1"); + reqVO.getDateRange().setStartDate(LocalDate.of(2026, 1, 1)); + reqVO.getDateRange().setEndDate(LocalDate.of(2026, 5, 28)); + when(memberUserMapper.selectById(1001L)).thenReturn(user(1001L)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> exportService.appCreateExportTask(1001L, reqVO)); + + assertEquals(400, exception.getCode()); + assertTrue(exception.getMessage().contains("step-up")); + verifyNoInteractions(commandService, fileServiceFacade, exportTaskMapper); + } + + @Test + void should_neverExposePendingStatus_whenFacadeReturnsInternalPending() { + 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("pending", + null, null, null, null)); + 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()))); + verify(commandService).recordSucceeded(any(), argThat(snapshot -> !snapshot.contains("pending"))); + } + + @Test + void should_storeHashedCredentialOnly_whenFileFacadeCompletesExport() { + AccountExportTaskCreateReqVO reqVO = exportReq("profile", "cmd-export-1"); + LocalDateTime expiresAt = LocalDateTime.of(2026, 5, 28, 11, 0); + 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", expiresAt, + 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("processing", result.getStatus()); + verify(exportTaskMapper).insert(argThat((AccountExportTaskDO task) -> + "completed".equals(task.getStatus()) && "file-ref-1".equals(task.getFileRef()))); + verify(downloadCredentialMapper).insert(argThat((AccountDownloadCredentialDO credential) -> + !hash("plain-credential-token").equals(credential.getCredentialHash()) + && !"plain-credential-token".equals(credential.getCredentialHash()) + && Long.valueOf(1001L).equals(credential.getAccountUserId()) + && expiresAt.equals(credential.getExpiresAt()))); + } + + @Test + void should_failClosedInternally_whenFacadeCompletedWithoutDownloadableFileInfo() { + 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", null, 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("FileService"))); + verify(downloadCredentialMapper, never()).insert(any(AccountDownloadCredentialDO.class)); + } + + @Test + void should_getExportTaskForOwnerOnly_withoutReturningHashedCredential() { + AccountExportTaskDO task = exportTask("task-701", 1001L, "completed", "file-ref-1", false); + 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("task-701", detail.getTaskId()); + assertEquals("completed", detail.getStatus()); + assertEquals(accountCredentialId, detail.getDownloadCredentialId()); + assertFalse(detail.getDownloadCredentialId().contains("hash")); + assertNotNull(detail.getDownloadExpiresAt()); + verify(exportTaskMapper).selectByTaskIdAndAccountUserId("task-701", 1001L); + verify(exportTaskMapper, never()).selectByTaskId("task-701"); + } + + @Test + void should_hideDownloadCredentialInDetail_whenCredentialIsExpired() { + AccountExportTaskDO task = exportTask("task-701", 1001L, "completed", "file-ref-1", false); + 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().minusMinutes(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); + when(downloadCredentialMapper.selectByCredentialHash(hash("cred-1"))) + .thenReturn(credential("cred-1", 1001L, "task-701", expiresAt, 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(1); + when(commandService.buildRequestHash(any())).thenReturn("hash-download"); + + AccountExportService.DownloadPackage result = exportService.appDownloadExport(1001L, "cred-1"); + + 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)); + verify(auditService).record(argThat(req -> + "appDownloadExport".equals(req.getOperationId()) + && "succeeded".equals(req.getStatus()) + && Long.valueOf(1001L).equals(req.getAccountUserId()))); + } + + @Test + void should_rejectDownload_whenCredentialWasConsumedConcurrently() { + 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(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); + + ServiceException exception = assertThrows(ServiceException.class, + () -> exportService.appDownloadExport(1001L, "cred-1")); + + assertEquals(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID.getCode(), exception.getCode()); + verifyNoInteractions(auditService); + } + + @Test + void should_rejectDownload_whenCredentialExpired() { + when(downloadCredentialMapper.selectByCredentialHash(hash("cred-1"))) + .thenReturn(credential("cred-1", 1001L, "task-701", LocalDateTime.now().minusMinutes(1), false)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> exportService.appDownloadExport(1001L, "cred-1")); + + assertEquals(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID.getCode(), exception.getCode()); + verifyNoInteractions(fileServiceFacade); + } + + @Test + void should_rejectDownload_whenCredentialBelongsToAnotherUser() { + when(downloadCredentialMapper.selectByCredentialHash(hash("cred-1"))) + .thenReturn(credential("cred-1", 2002L, "task-701", LocalDateTime.now().plusHours(1), false)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> exportService.appDownloadExport(1001L, "cred-1")); + + assertEquals(ACCOUNT_RESOURCE_FORBIDDEN.getCode(), exception.getCode()); + verifyNoInteractions(fileServiceFacade); + } + + @Test + void should_rejectDownload_whenCredentialSourceBlocked() { + when(downloadCredentialMapper.selectByCredentialHash(hash("cred-1"))) + .thenReturn(credential("cred-1", 1001L, "task-701", LocalDateTime.now().plusHours(1), true)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> exportService.appDownloadExport(1001L, "cred-1")); + + assertEquals(ACCOUNT_EXPORT_SOURCE_BLOCKED.getCode(), exception.getCode()); + verifyNoInteractions(fileServiceFacade); + } + + @Test + void should_rejectDownload_whenFileServiceUnavailableInsteadOfReturningFakeStream() { + 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(fileServiceFacade.downloadExportPackage(1001L, "task-701", "file-ref-1")) + .thenReturn(AccountFileServiceFacade.DownloadPackageResult.unavailable()); + + ServiceException exception = assertThrows(ServiceException.class, + () -> exportService.appDownloadExport(1001L, "cred-1")); + + assertEquals(ACCOUNT_FILE_SERVICE_UNAVAILABLE.getCode(), exception.getCode()); + verify(downloadCredentialMapper, never()).markConsumed(anyString(), any()); + } + + private AccountExportTaskCreateReqVO exportReq(String exportType, String commandId) { + AccountExportTaskCreateReqVO reqVO = new AccountExportTaskCreateReqVO(); + reqVO.setCommandId(commandId); + reqVO.setExportType(exportType); + reqVO.setDesensitizationRule("standard"); + AccountExportTaskCreateReqVO.DateRangeVO dateRange = new AccountExportTaskCreateReqVO.DateRangeVO(); + dateRange.setStartDate(LocalDate.of(2026, 5, 1)); + dateRange.setEndDate(LocalDate.of(2026, 5, 28)); + reqVO.setDateRange(dateRange); + return reqVO; + } + + private MemberUserDO user(Long id) { + return MemberUserDO.builder().id(id).nickname("Muse").build(); + } + + private AccountExportTaskDO exportTask(String taskId, Long accountUserId, String status, + String fileRef, boolean sourceBlocked) { + AccountExportTaskDO task = AccountExportTaskDO.builder() + .taskId(taskId) + .accountUserId(accountUserId) + .commandId("cmd-export-1") + .requestHash("hash-export") + .exportType("security_events") + .status(status) + .fileRef(fileRef) + .sourceBlocked(sourceBlocked) + .completedAt("completed".equals(status) ? LocalDateTime.of(2026, 5, 28, 10, 0) : null) + .build(); + task.setId(701L); + task.setCreateTime(LocalDateTime.of(2026, 5, 28, 9, 55)); + return task; + } + + private AccountDownloadCredentialDO credential(String credentialId, Long accountUserId, String taskId, + LocalDateTime expiresAt, boolean sourceBlocked) { + return AccountDownloadCredentialDO.builder() + .credentialHash(hash(credentialId)) + .accountUserId(accountUserId) + .taskId(taskId) + .expiresAt(expiresAt) + .sourceBlocked(sourceBlocked) + .build(); + } + + private String hash(String credentialId) { + try { + byte[] bytes = MessageDigest.getInstance("SHA-256") + .digest(credentialId.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(bytes); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + private String accountCredentialId(AccountExportTaskDO task) { + return "acct-dl-" + hash(task.getTaskId() + ":" + task.getRequestHash()).substring(0, 32); + } +} diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/facade/AccountFileServiceFacadeConfigurationTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/facade/AccountFileServiceFacadeConfigurationTest.java new file mode 100644 index 00000000..37527135 --- /dev/null +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/facade/AccountFileServiceFacadeConfigurationTest.java @@ -0,0 +1,71 @@ +package cn.iocoder.muse.module.member.application.account.facade; + +import cn.iocoder.muse.autoconfigure.member.account.AccountFileServiceFacadeAutoConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.annotation.ImportCandidates; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Account FileService fallback Bean 注册测试。 + */ +class AccountFileServiceFacadeConfigurationTest { + + private static final AccountFileServiceFacade REAL_FACADE = new AccountFileServiceFacade() { + }; + + private ApplicationContextRunner contextRunner; + + @BeforeEach + void setUp() { + contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(AccountFileServiceFacadeAutoConfiguration.class)); + } + + @Test + void should_registerUnavailableFallbackWhenRealFacadeMissing() { + contextRunner.run(context -> { + Map beans = context.getBeansOfType(AccountFileServiceFacade.class); + assertEquals(1, beans.size()); + AccountFileServiceFacade facade = context.getBean(AccountFileServiceFacade.class); + assertInstanceOf(UnavailableAccountFileServiceFacade.class, facade); + assertFalse(facade.createExportTask(null).available()); + assertFalse(facade.downloadExportPackage(1001L, "task-1", "file-ref-1").available()); + }); + } + + @Test + void should_useRealFacadeAndSkipUnavailableFallbackWhenRealFacadeExists() { + contextRunner.withUserConfiguration(RealAccountFileServiceFacadeConfiguration.class).run(context -> { + Map beans = context.getBeansOfType(AccountFileServiceFacade.class); + assertEquals(1, beans.size()); + assertSame(REAL_FACADE, context.getBean(AccountFileServiceFacade.class)); + assertEquals(0, context.getBeansOfType(UnavailableAccountFileServiceFacade.class).size()); + }); + } + + @Test + void should_publishAccountFileServiceFacadeAutoConfigurationImport() { + ImportCandidates candidates = ImportCandidates.load(AutoConfiguration.class, getClass().getClassLoader()); + + assertTrue(candidates.getCandidates() + .contains(AccountFileServiceFacadeAutoConfiguration.class.getName())); + } + + @Configuration(proxyBeanMethods = false) + static class RealAccountFileServiceFacadeConfiguration { + + @Bean + AccountFileServiceFacade realAccountFileServiceFacade() { + return REAL_FACADE; + } + } +} diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountExportControllerTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountExportControllerTest.java new file mode 100644 index 00000000..81e999d0 --- /dev/null +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountExportControllerTest.java @@ -0,0 +1,205 @@ +package cn.iocoder.muse.module.member.controller.app.account; + +import cn.iocoder.muse.framework.common.enums.UserTypeEnum; +import cn.iocoder.muse.framework.common.exception.ServiceException; +import cn.iocoder.muse.framework.common.pojo.CommonResult; +import cn.iocoder.muse.framework.common.util.json.JsonUtils; +import cn.iocoder.muse.framework.security.core.LoginUser; +import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.muse.module.member.application.account.AccountExportService; +import cn.iocoder.muse.module.member.controller.app.AppMuseAccountContractController; +import cn.iocoder.muse.module.member.controller.app.account.vo.AccountExportTaskCreateReqVO; +import cn.iocoder.muse.module.member.controller.app.account.vo.AccountExportTaskDetailRespVO; +import cn.iocoder.muse.module.member.controller.app.account.vo.AccountExportTaskResultRespVO; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_API_VERSION_UNSUPPORTED; +import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * AppAccountExportController 导出任务和下载 API 合同测试。 + */ +class AppAccountExportControllerTest extends BaseMockitoUnitTest { + + @InjectMocks + private AppAccountExportController controller; + @Mock + private AccountExportService exportService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new TestExceptionAdvice()) + .build(); + SecurityFrameworkUtils.setLoginUser(loginUser(1001L), new MockHttpServletRequest()); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void should_rejectMissingApiVersion_beforeCatchAll_when_exportRoutesExist() throws Exception { + MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller, new AppMuseAccountContractController()) + .setControllerAdvice(new TestExceptionAdvice()) + .build(); + + routeMockMvc.perform(post("/muse/account/export-tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtils.toJsonString(exportReq()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode())) + .andExpect(jsonPath("$").value(hasKey("data"))); + + verifyNoInteractions(exportService); + } + + @Test + void should_createExportTaskForLoginUser_withCommonResult() throws Exception { + AccountExportTaskResultRespVO result = new AccountExportTaskResultRespVO(); + result.setTaskId("task-701"); + result.setStatus("queued"); + result.setEstimatedCompletionAt(LocalDateTime.of(2026, 5, 28, 10, 5)); + when(exportService.appCreateExportTask(eq(1001L), any())).thenReturn(result); + + mockMvc.perform(post("/muse/account/export-tasks") + .header("X-API-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtils.toJsonString(exportReq()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.taskId").value("task-701")) + .andExpect(jsonPath("$.data.status").value("queued")) + .andExpect(jsonPath("$.data.status").value(not("pending"))); + + verify(exportService).appCreateExportTask(eq(1001L), argThat(req -> + "cmd-export-1".equals(req.getCommandId()) + && "security_events".equals(req.getExportType()))); + verify(exportService, never()).appCreateExportTask(eq(2002L), any()); + } + + @Test + void should_rejectMissingCommandId_when_createExportTask() throws Exception { + mockMvc.perform(post("/muse/account/export-tasks") + .header("X-API-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"exportType\":\"profile\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(400)); + + verifyNoInteractions(exportService); + } + + @Test + void should_getExportTaskForLoginUser_withCommonResult() throws Exception { + AccountExportTaskDetailRespVO detail = new AccountExportTaskDetailRespVO(); + detail.setTaskId("task-701"); + detail.setExportType("profile"); + detail.setStatus("queued"); + detail.setCreatedAt(LocalDateTime.of(2026, 5, 28, 10, 0)); + when(exportService.appGetExportTask(1001L, "task-701")).thenReturn(detail); + + mockMvc.perform(get("/muse/account/export-tasks/task-701") + .header("X-API-Version", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.taskId").value("task-701")) + .andExpect(jsonPath("$.data.status").value("queued")); + + verify(exportService).appGetExportTask(1001L, "task-701"); + } + + @Test + void should_downloadBinaryPackage_withoutCommonResultWrapper() throws Exception { + when(exportService.appDownloadExport(1001L, "cred-1")) + .thenReturn(new AccountExportService.DownloadPackage("account-export.zip", + "application/octet-stream", "zip-bytes".getBytes())); + + mockMvc.perform(get("/muse/account/downloads/cred-1") + .header("X-API-Version", "1")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Type", "application/octet-stream")) + .andExpect(header().exists("Content-Disposition")) + .andExpect(content().bytes("zip-bytes".getBytes())); + + verify(exportService).appDownloadExport(1001L, "cred-1"); + } + + @Test + void should_returnCommonResultError_whenDownloadCredentialInvalid() throws Exception { + doThrow(new ServiceException(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID)) + .when(exportService).appDownloadExport(1001L, "cred-1"); + + mockMvc.perform(get("/muse/account/downloads/cred-1") + .header("X-API-Version", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID.getCode())) + .andExpect(jsonPath("$").value(hasKey("data"))) + .andExpect(jsonPath("$").value(not(hasKey("message")))); + } + + private AccountExportTaskCreateReqVO exportReq() { + AccountExportTaskCreateReqVO reqVO = new AccountExportTaskCreateReqVO(); + reqVO.setCommandId("cmd-export-1"); + reqVO.setExportType("security_events"); + reqVO.setDesensitizationRule("standard"); + AccountExportTaskCreateReqVO.DateRangeVO dateRange = new AccountExportTaskCreateReqVO.DateRangeVO(); + dateRange.setStartDate(LocalDate.of(2026, 5, 1)); + dateRange.setEndDate(LocalDate.of(2026, 5, 28)); + reqVO.setDateRange(dateRange); + return reqVO; + } + + private LoginUser loginUser(Long id) { + LoginUser loginUser = new LoginUser(); + loginUser.setId(id); + loginUser.setUserType(UserTypeEnum.MEMBER.getValue()); + return loginUser; + } + + @RestControllerAdvice + static class TestExceptionAdvice { + + @ExceptionHandler(ServiceException.class) + CommonResult serviceException(ServiceException exception) { + return CommonResult.error(exception.getCode(), exception.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + CommonResult methodArgumentNotValidException(MethodArgumentNotValidException exception) { + return CommonResult.error(400, exception.getMessage()); + } + + @ExceptionHandler(BindException.class) + CommonResult bindException(BindException exception) { + return CommonResult.error(400, exception.getMessage()); + } + } +}