feat(p1r): 实现 Account 导出下载 API

This commit is contained in:
zizi 2026-05-30 00:48:00 +08:00
parent 293d6821d6
commit 7aedfb9d08
14 changed files with 1372 additions and 0 deletions

View File

@ -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 导出文件服务暂不可用");
}

View File

@ -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();
}
}

View File

@ -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) {
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,9 @@
package cn.iocoder.muse.module.member.application.account.facade;
/**
* FileService 未接入时的 Account 默认 Facade
*
* <p>该类保持普通类形态 AutoConfiguration 条件注册避免组件扫描阶段抢占真实实现</p>
*/
public class UnavailableAccountFileServiceFacade implements AccountFileServiceFacade {
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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));
}
}

View File

@ -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);
}

View File

@ -1 +1,2 @@
cn.iocoder.muse.autoconfigure.member.account.MarketAccountProjectionFacadeAutoConfiguration
cn.iocoder.muse.autoconfigure.member.account.AccountFileServiceFacadeAutoConfiguration

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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());
}
}
}