feat(p1r): 实现 Account 导出下载 API
This commit is contained in:
parent
293d6821d6
commit
7aedfb9d08
@ -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 导出文件服务暂不可用");
|
||||
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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<String> EXPORT_TYPES = Set.of("profile", "usage", "security_events", "purchases", "licenses");
|
||||
private static final Set<String> DATE_RANGE_REQUIRED_TYPES = Set.of("usage", "security_events");
|
||||
private static final Set<String> SENSITIVE_EXPORT_TYPES = Set.of("security_events", "usage");
|
||||
private static final Set<String> 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<String, Object> 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> 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<String, Object> dateRangeSnapshot() {
|
||||
Map<String, Object> dateRange = new LinkedHashMap<>();
|
||||
if (startDate != null) {
|
||||
dateRange.put("startDate", startDate);
|
||||
}
|
||||
if (endDate != null) {
|
||||
dateRange.put("endDate", endDate);
|
||||
}
|
||||
return dateRange;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 边界。
|
||||
*
|
||||
* <p>P1R-3 只在 Account 侧定义任务、凭证和下载校验,不接管真实文件生成/存储。
|
||||
* 后续 FileService 接入时必须替换该 Facade,并返回真实 fileRef 与文件字节。</p>
|
||||
*/
|
||||
public interface AccountFileServiceFacade {
|
||||
|
||||
/**
|
||||
* 请求 FileService 创建 Account 导出包。
|
||||
*
|
||||
* <p>默认 unavailable,调用方只能创建合同允许的 queued 任务,不能伪造 completed、凭证或 fileRef。</p>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package cn.iocoder.muse.module.member.application.account.facade;
|
||||
|
||||
/**
|
||||
* FileService 未接入时的 Account 默认 Facade。
|
||||
*
|
||||
* <p>该类保持普通类形态,由 AutoConfiguration 条件注册,避免组件扫描阶段抢占真实实现。</p>
|
||||
*/
|
||||
public class UnavailableAccountFileServiceFacade implements AccountFileServiceFacade {
|
||||
}
|
||||
@ -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<AccountExportTaskResultRespVO> 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<AccountExportTaskResultRespVO> 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<AccountExportTaskDetailRespVO> 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<AccountExportTaskDetailRespVO> 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<byte[]> 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<Void> downloadExportWithoutVersion(@PathVariable String credentialId) {
|
||||
AccountApiVersionGuard.requireVersion(null);
|
||||
return success(null);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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<AccountDown
|
||||
return selectList(AccountDownloadCredentialDO::getTaskId, taskId);
|
||||
}
|
||||
|
||||
default AccountDownloadCredentialDO selectLatestByTaskIdAndAccountUserId(String taskId, Long accountUserId) {
|
||||
return selectOne(new LambdaQueryWrapperX<AccountDownloadCredentialDO>()
|
||||
.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<AccountDownloadCredentialDO>()
|
||||
.set(AccountDownloadCredentialDO::getConsumedAt, consumedAt)
|
||||
.eq(AccountDownloadCredentialDO::getCredentialHash, credentialHash)
|
||||
.isNull(AccountDownloadCredentialDO::getConsumedAt));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -14,6 +14,11 @@ public interface AccountExportTaskMapper extends BaseMapperX<AccountExportTaskDO
|
||||
return selectOne(AccountExportTaskDO::getTaskId, taskId);
|
||||
}
|
||||
|
||||
default AccountExportTaskDO selectByTaskIdAndAccountUserId(String taskId, Long accountUserId) {
|
||||
return selectOne(AccountExportTaskDO::getTaskId, taskId,
|
||||
AccountExportTaskDO::getAccountUserId, accountUserId);
|
||||
}
|
||||
|
||||
default AccountExportTaskDO selectByCommandId(String commandId) {
|
||||
return selectOne(AccountExportTaskDO::getCommandId, commandId);
|
||||
}
|
||||
|
||||
@ -1 +1,2 @@
|
||||
cn.iocoder.muse.autoconfigure.member.account.MarketAccountProjectionFacadeAutoConfiguration
|
||||
cn.iocoder.muse.autoconfigure.member.account.AccountFileServiceFacadeAutoConfiguration
|
||||
|
||||
@ -0,0 +1,400 @@
|
||||
package cn.iocoder.muse.module.member.application.account;
|
||||
|
||||
import cn.iocoder.muse.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
|
||||
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_RESOURCE_FORBIDDEN;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* AccountExportService 导出任务和下载凭证测试。
|
||||
*/
|
||||
class AccountExportServiceTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private AccountExportServiceImpl exportService;
|
||||
|
||||
@Mock
|
||||
private MemberUserMapper memberUserMapper;
|
||||
@Mock
|
||||
private AccountExportTaskMapper exportTaskMapper;
|
||||
@Mock
|
||||
private AccountDownloadCredentialMapper downloadCredentialMapper;
|
||||
@Mock
|
||||
private MemberSecurityEventMapper securityEventMapper;
|
||||
@Mock
|
||||
private AccountCommandService commandService;
|
||||
@Mock
|
||||
private AccountAuditService auditService;
|
||||
@Mock
|
||||
private AccountFileServiceFacade fileServiceFacade;
|
||||
|
||||
@Test
|
||||
void should_createQueuedSecurityExport_andWriteSensitiveSecurityEvent_whenFileServiceUnavailable() {
|
||||
AccountExportTaskCreateReqVO reqVO = exportReq("security_events", "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.unavailable());
|
||||
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());
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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<String, AccountFileServiceFacade> 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<String, AccountFileServiceFacade> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user