diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceAttemptCancellationHandler.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceAttemptCancellationHandler.java new file mode 100644 index 0000000..d8adca1 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceAttemptCancellationHandler.java @@ -0,0 +1,52 @@ +package com.yau.digitalrmb.issuance.application.service; + +import com.yau.digitalrmb.issuance.application.query.ReserveDeductionNotificationView; +import com.yau.digitalrmb.issuance.domain.model.IssuanceAuditActor; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import com.yau.digitalrmb.training.attempt.application.AttemptCancellationHandler; +import com.yau.digitalrmb.training.attempt.domain.ExperimentAttempt; +import com.yau.digitalrmb.training.attempt.domain.ExperimentModule; +import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.UUID; + +@Component +public class IssuanceAttemptCancellationHandler implements AttemptCancellationHandler { + private final ReserveDeductionNotificationService notificationService; + private final ReserveDeductionExecutionService executionService; + private final DigitalCurrencyGenerationModuleGateway generationGateway; + + public IssuanceAttemptCancellationHandler(ReserveDeductionNotificationService notificationService, + ReserveDeductionExecutionService executionService, + DigitalCurrencyGenerationModuleGateway generationGateway) { + this.notificationService = notificationService; + this.executionService = executionService; + this.generationGateway = generationGateway; + } + + @Override + public ExperimentModule module() { + return ExperimentModule.ISSUANCE; + } + + @Override + public void cancel(ExperimentAttempt attempt, ExperimentSubject subject) { + if (attempt.getBusinessId() == null) return; + UUID requestId; + try { + requestId = UUID.fromString(attempt.getBusinessId()); + } catch (IllegalArgumentException exception) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR, "发行实验绑定的业务标识无效"); + } + Optional notification = notificationService.find(requestId); + if (notification.isPresent() + && executionService.find(notification.get().getTransactionId()).isPresent()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "准备金已扣减,不能取消发行实验"); + } + generationGateway.releaseGeneratedCurrencies(requestId, + new IssuanceAuditActor(subject.getUserId(), subject.getUserId())); + } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceRequestAccessGuard.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceRequestAccessGuard.java index 3804e8c..31026ec 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceRequestAccessGuard.java +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceRequestAccessGuard.java @@ -38,12 +38,17 @@ public class IssuanceRequestAccessGuard { } public void requireTransactionOwner(String transactionId, IssuanceAuditActor actor) { + requestIdForTransaction(transactionId, actor); + } + + public UUID requestIdForTransaction(String transactionId, IssuanceAuditActor actor) { if (blank(transactionId)) { throw new BusinessException(ErrorCode.VALIDATION_ERROR, "准备金扣减交易标识不能为空"); } ReserveDeductionNotification notification = notificationRepository.findByTransactionId(transactionId) .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "准备金扣减通知不存在")); requireRequestOwner(notification.getRequestId(), actor); + return notification.getRequestId(); } private boolean blank(String value) { diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java new file mode 100644 index 0000000..768b92a --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java @@ -0,0 +1,258 @@ +package com.yau.digitalrmb.issuance.application.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.issuance.application.command.CreateIssuanceRequestCommand; +import com.yau.digitalrmb.issuance.application.command.UpdateIssuanceRequestCommand; +import com.yau.digitalrmb.issuance.application.query.ReserveDeductionNotificationView; +import com.yau.digitalrmb.issuance.domain.model.DenominationItem; +import com.yau.digitalrmb.issuance.domain.model.IssuanceAuditActor; +import com.yau.digitalrmb.issuance.interfaces.dto.IssuanceActionRequest; +import com.yau.digitalrmb.issuance.interfaces.dto.DenominationItemRequest; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import com.yau.digitalrmb.training.attempt.application.ActionOutcome; +import com.yau.digitalrmb.training.attempt.application.ActionWork; +import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService; +import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject; +import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView; +import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@Service +public class IssuanceTrainingActionService { + private static final List ACTIONS = Arrays.asList( + "01:refresh-quota", "01:refresh-custom-application", "01:generate-timestamp", + "01:concatenate-application", "01:generate-digest", "01:sign-application", + "01:package-application", "01:send-application", "02:verify-signature", + "02:recalculate-digest", "02:compare-digest", "03:query-institution-input", + "03:query-quota-input", "03:query-application-message", "04:auto-confirm-plan", + "04:generate-reserve-deduction-request", "05:confirm-receipt", "05:query-account", + "05:verify-deduction", "05:execute-deduction", "05:generate-balance-notice", + "05:send-balance-notice", "06:confirm-production-receipt", + "06:generate-production-digest", "07:load-ownership-result"); + + private final ExperimentAttemptService attempts; + private final ObjectMapper objectMapper; + private final CommercialBankIssuanceApplicationService commercialService; + private final CentralBankIssuanceQueryService centralBankService; + private final CentralBankIssuanceBusinessReviewService reviewService; + private final ReserveDeductionNotificationService notificationService; + private final ReserveDeductionExecutionService executionService; + private final DigitalCurrencyDraftProductionService productionService; + private final DigitalCurrencyOwnershipConfirmationService ownershipService; + + public IssuanceTrainingActionService(ExperimentAttemptService attempts, ObjectMapper objectMapper, + CommercialBankIssuanceApplicationService commercialService, + CentralBankIssuanceQueryService centralBankService, + CentralBankIssuanceBusinessReviewService reviewService, + ReserveDeductionNotificationService notificationService, + ReserveDeductionExecutionService executionService, + DigitalCurrencyDraftProductionService productionService, + DigitalCurrencyOwnershipConfirmationService ownershipService) { + this.attempts = attempts; + this.objectMapper = objectMapper; + this.commercialService = commercialService; + this.centralBankService = centralBankService; + this.reviewService = reviewService; + this.notificationService = notificationService; + this.executionService = executionService; + this.productionService = productionService; + this.ownershipService = ownershipService; + } + + public ExperimentActionView execute(UUID attemptId, String stepCode, String actionCode, + IssuanceActionRequest request, ExperimentSubject subject, + IssuanceAuditActor actor, InstitutionKeySubject keySubject) { + String key = stepCode + ":" + actionCode; + int actionIndex = ACTIONS.indexOf(key); + if (actionIndex < 0) throw validation("不支持的发行实验动作:" + key); + IssuanceActionRequest input = request == null ? new IssuanceActionRequest() : request; + ExperimentAttemptView attempt = attempts.detail(attemptId, subject); + UUID requestId = resolveRequestId(attempt.getBusinessId(), input.getRequestId()); + if (!"01:refresh-quota".equals(key) && !"01:refresh-custom-application".equals(key) + && requestId == null) { + throw validation("请先生成或关联发行申请"); + } + String[] next = nextAction(actionIndex); + final UUID businessRequestId = requestId; + final String actionKey = key; + final boolean bindSuppliedRequest = attempt.getBusinessId() == null && businessRequestId != null; + ActionWork work = () -> { + ActionOutcome outcome = run(actionKey, input, businessRequestId, actor, keySubject); + return bindSuppliedRequest + ? ActionOutcome.completed(outcome.getOutput(), businessRequestId.toString()) : outcome; + }; + return attempts.execute(attemptId, subject, stepCode, actionCode, + fingerprint(key, input, requestId, subject), Object.class, work, next[0], next[1]); + } + + private ActionOutcome run(String key, IssuanceActionRequest request, UUID requestId, + IssuanceAuditActor actor, InstitutionKeySubject keySubject) { + switch (key) { + case "01:refresh-quota": + return completed(commercialService.getInventory()); + case "01:refresh-custom-application": + if (requestId != null) { + Object existing = hasDraftValues(request) + ? commercialService.update(requestId, new UpdateIssuanceRequestCommand( + positive(request.getTotalAmount(), "发行总金额"), + required(request.getCurrency(), "币种"), denominations(request.getDenominations())), actor) + : commercialService.getCommercialBankView(requestId, actor); + return ActionOutcome.completed(existing, + requestId.toString()); + } + com.yau.digitalrmb.issuance.application.query.IssuanceRequestView created = commercialService.create( + new CreateIssuanceRequestCommand(required(request.getBankCode(), "商业银行机构代码"), + required(request.getOrganizationId(), "机构标识"), + positive(request.getTotalAmount(), "发行总金额"), required(request.getCurrency(), "币种"), + denominations(request.getDenominations())), actor); + return ActionOutcome.completed(created, created.getId().toString()); + case "01:generate-timestamp": + return completed(commercialService.captureTimestamp(requestId, actor)); + case "01:concatenate-application": + return completed(commercialService.prepareMessage(requestId, actor)); + case "01:generate-digest": + return completed(commercialService.digest(requestId, actor)); + case "01:sign-application": + return completed(commercialService.sign(requestId, actor, keySubject)); + case "01:package-application": + return completed(commercialService.packagePayload(requestId, actor)); + case "01:send-application": + return completed(commercialService.send(requestId, actor)); + case "02:verify-signature": + return completed(centralBankService.verify(requestId, actor, keySubject)); + case "02:recalculate-digest": + case "02:compare-digest": + return completed(centralBankService.verification(requestId) + .orElseThrow(() -> validation("请先验证发行申请签名"))); + case "03:query-institution-input": + return completed(commercialService.getCommercialBankView(requestId, actor)); + case "03:query-quota-input": + return completed(commercialService.getInventory()); + case "03:query-application-message": + return completed(centralBankService.getCentralBankView(requestId)); + case "04:auto-confirm-plan": + return completed(reviewService.review(requestId, actor)); + case "04:generate-reserve-deduction-request": + return completed(notificationService.send(requestId, actor)); + case "05:confirm-receipt": + return completed(notificationService.receive(requestId, actor)); + case "05:query-account": + return completed(requireNotification(requestId)); + case "05:verify-deduction": + return completed(notificationService.submit(requestId, actor)); + case "05:execute-deduction": + return completed(executionService.execute(requireNotification(requestId).getTransactionId(), actor)); + case "05:generate-balance-notice": + case "05:send-balance-notice": + ReserveDeductionNotificationView notification = requireNotification(requestId); + return completed(executionService.find(notification.getTransactionId()) + .orElseThrow(() -> validation("准备金尚未扣减,不能生成余额通知"))); + case "06:confirm-production-receipt": + return completed(productionService.produce(requestId, actor, keySubject)); + case "06:generate-production-digest": + return completed(productionService.find(requestId) + .orElseThrow(() -> validation("请先生成数字货币生产批次"))); + case "07:load-ownership-result": + return completed(ownershipService.confirm(requestId, actor, keySubject)); + default: + throw validation("不支持的发行实验动作:" + key); + } + } + + private ReserveDeductionNotificationView requireNotification(UUID requestId) { + return notificationService.find(requestId) + .orElseThrow(() -> validation("准备金扣减通知不存在")); + } + + private ActionOutcome completed(Object output) { + return ActionOutcome.completed(output); + } + + private UUID resolveRequestId(String boundBusinessId, UUID supplied) { + UUID bound = null; + if (boundBusinessId != null && !boundBusinessId.trim().isEmpty()) { + try { + bound = UUID.fromString(boundBusinessId); + } catch (IllegalArgumentException exception) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR, "发行实验绑定的业务标识无效"); + } + } + if (bound != null && supplied != null && !bound.equals(supplied)) { + throw validation("动作请求与当前实验绑定的发行申请不一致"); + } + return bound == null ? supplied : bound; + } + + private String[] nextAction(int currentIndex) { + String next = ACTIONS.get(Math.min(currentIndex + 1, ACTIONS.size() - 1)); + return next.split(":", 2); + } + + private List denominations(List values) { + if (values == null || values.isEmpty()) throw validation("面额明细不能为空"); + List result = new ArrayList(); + for (DenominationItemRequest value : values) { + if (value == null || value.getDenomination() == null || value.getQuantity() == null) { + throw validation("面额和数量不能为空"); + } + try { + result.add(new DenominationItem(value.getDenomination(), value.getQuantity())); + } catch (RuntimeException exception) { + throw validation(exception.getMessage()); + } + } + return result; + } + + private boolean hasDraftValues(IssuanceActionRequest request) { + return request.getTotalAmount() != null || request.getCurrency() != null + || request.getDenominations() != null; + } + + private BigDecimal positive(BigDecimal value, String name) { + if (value == null || value.signum() <= 0) throw validation(name + "必须大于零"); + return value; + } + + private String required(String value, String name) { + if (value == null || value.trim().isEmpty()) throw validation(name + "不能为空"); + return value.trim(); + } + + private String fingerprint(String key, IssuanceActionRequest request, UUID requestId, + ExperimentSubject subject) { + Map values = new LinkedHashMap(); + values.put("action", key); + values.put("businessRequestId", requestId); + values.put("request", request); + values.put("userId", subject.getUserId()); + try { + byte[] json = objectMapper.writeValueAsString(values).getBytes(StandardCharsets.UTF_8); + byte[] digest = MessageDigest.getInstance("SHA-256").digest(json); + StringBuilder result = new StringBuilder(); + for (byte value : digest) result.append(String.format("%02x", value & 0xff)); + return result.toString(); + } catch (JsonProcessingException | NoSuchAlgorithmException exception) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR, "无法生成动作幂等指纹"); + } + } + + private BusinessException validation(String message) { + return new BusinessException(ErrorCode.VALIDATION_ERROR, message); + } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/interfaces/dto/IssuanceActionRequest.java b/src/main/java/com/yau/digitalrmb/issuance/interfaces/dto/IssuanceActionRequest.java new file mode 100644 index 0000000..7ad2244 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/interfaces/dto/IssuanceActionRequest.java @@ -0,0 +1,30 @@ +package com.yau.digitalrmb.issuance.interfaces.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +@Schema(description = "发行实验单步动作输入;不同动作只读取其需要的字段") +public class IssuanceActionRequest { + private UUID requestId; + private String bankCode; + private String organizationId; + private BigDecimal totalAmount; + private String currency; + private List denominations; + + public UUID getRequestId() { return requestId; } + public void setRequestId(UUID requestId) { this.requestId = requestId; } + public String getBankCode() { return bankCode; } + public void setBankCode(String bankCode) { this.bankCode = bankCode; } + public String getOrganizationId() { return organizationId; } + public void setOrganizationId(String organizationId) { this.organizationId = organizationId; } + public BigDecimal getTotalAmount() { return totalAmount; } + public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; } + public String getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + public List getDenominations() { return denominations; } + public void setDenominations(List denominations) { this.denominations = denominations; } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/interfaces/rest/CentralBankIssuanceController.java b/src/main/java/com/yau/digitalrmb/issuance/interfaces/rest/CentralBankIssuanceController.java index 88d6463..485bb5e 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/interfaces/rest/CentralBankIssuanceController.java +++ b/src/main/java/com/yau/digitalrmb/issuance/interfaces/rest/CentralBankIssuanceController.java @@ -13,17 +13,23 @@ import com.yau.digitalrmb.issuance.application.service.CentralBankIssuanceQueryS import com.yau.digitalrmb.issuance.application.service.DigitalCurrencyDraftProductionService; import com.yau.digitalrmb.issuance.application.service.DigitalCurrencyOwnershipConfirmationService; import com.yau.digitalrmb.issuance.application.service.IssuanceRequestAccessGuard; +import com.yau.digitalrmb.issuance.application.service.IssuanceTrainingActionService; import com.yau.digitalrmb.issuance.application.service.ReserveDeductionExecutionService; import com.yau.digitalrmb.issuance.application.service.ReserveDeductionNotificationService; import com.yau.digitalrmb.issuance.domain.model.IssuanceAuditActor; import com.yau.digitalrmb.issuance.interfaces.dto.CentralBankIssuanceRequestResponse; import com.yau.digitalrmb.issuance.interfaces.dto.ReserveDeductionExecutionRequest; +import com.yau.digitalrmb.issuance.interfaces.dto.IssuanceActionRequest; import com.yau.digitalrmb.security.application.CurrentUser; import com.yau.digitalrmb.security.application.CurrentUserService; import com.yau.digitalrmb.shared.api.ApiResponse; import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.exception.BusinessException; import com.yau.digitalrmb.shared.web.TraceIdFilter; +import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService; +import com.yau.digitalrmb.training.attempt.domain.ExperimentModule; +import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject; +import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.slf4j.MDC; @@ -50,6 +56,8 @@ public class CentralBankIssuanceController { private final IssuanceRequestAccessGuard accessGuard; private final CurrentUserService currentUserService; private final ObjectMapper mapper; + private final ExperimentAttemptService attemptService; + private final IssuanceTrainingActionService trainingActionService; public CentralBankIssuanceController(CentralBankIssuanceQueryService service, CentralBankIssuanceBusinessReviewService businessReviewService, @@ -59,7 +67,9 @@ public class CentralBankIssuanceController { DigitalCurrencyOwnershipConfirmationService digitalCurrencyOwnershipConfirmationService, IssuanceRequestAccessGuard accessGuard, CurrentUserService currentUserService, - ObjectMapper mapper) { + ObjectMapper mapper, + ExperimentAttemptService attemptService, + IssuanceTrainingActionService trainingActionService) { this.service = service; this.businessReviewService = businessReviewService; this.reserveDeductionNotificationService = reserveDeductionNotificationService; @@ -69,6 +79,8 @@ public class CentralBankIssuanceController { this.accessGuard = accessGuard; this.currentUserService = currentUserService; this.mapper = mapper; + this.attemptService = attemptService; + this.trainingActionService = trainingActionService; } @GetMapping("/requests/{id}") @@ -88,42 +100,57 @@ public class CentralBankIssuanceController { CurrentUser currentUser = currentUserService.getCurrentUser(); IssuanceAuditActor actor = actor(currentUser); accessGuard.requireRequestOwner(id, actor); - return ok(service.verify(id, actor, InstitutionKeySubject.from(currentUser))); + runAction(id, "02", "verify-signature", currentUser); + return ok(service.verification(id).orElseThrow(() -> + new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "发行请求验证结果不存在"))); } @PostMapping("/requests/{id}/business-review") @Operation(summary = "对数字货币发行请求进行业务核查", description = "在接收验证通过后,核查申请机构准备金账户、央行数字货币库存及申请金额和面额明细的一致性") public ApiResponse businessReview(@PathVariable UUID id) { - IssuanceAuditActor actor = actor(); + CurrentUser user = currentUserService.getCurrentUser(); + IssuanceAuditActor actor = actor(user); accessGuard.requireRequestOwner(id, actor); - return ok(businessReviewService.review(id, actor)); + runAction(id, "04", "auto-confirm-plan", user); + return ok(businessReviewService.find(id).orElseThrow(() -> + new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "发行计划核查结果不存在"))); } @PostMapping("/requests/{id}/reserve-deduction-notification") @Operation(summary = "发送准备金扣减通知", description = "仅在发行请求验签和业务核查通过后发送通知,不执行实际准备金扣款") public ApiResponse sendReserveDeductionNotification(@PathVariable UUID id) { - IssuanceAuditActor actor = actor(); + CurrentUser user = currentUserService.getCurrentUser(); + IssuanceAuditActor actor = actor(user); accessGuard.requireRequestOwner(id, actor); - return ok(reserveDeductionNotificationService.send(id, actor)); + runAction(id, "04", "generate-reserve-deduction-request", user); + return ok(reserveDeductionNotificationService.find(id).orElseThrow(() -> + new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "准备金扣减通知不存在"))); } @PostMapping("/reserve-deductions") @Operation(summary = "提交准备金扣款申请", description = "ACS 模拟系统核对扣减通知后,实际扣减商业银行存款准备金") public ApiResponse executeReserveDeduction( @Valid @RequestBody ReserveDeductionExecutionRequest request) { - IssuanceAuditActor actor = actor(); + CurrentUser user = currentUserService.getCurrentUser(); + IssuanceAuditActor actor = actor(user); accessGuard.requireTransactionOwner(request.getTransactionId(), actor); - return ok(reserveDeductionExecutionService.execute(request.getTransactionId(), request.getReserveAccountNo(), - request.getReserveAccountName(), request.getDeductionAmount(), actor)); + UUID requestId = requestIdForTransaction(request.getTransactionId()); + runAction(requestId, "05", "execute-deduction", user); + return ok(reserveDeductionExecutionService.find(request.getTransactionId()).orElseThrow(() -> + new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "准备金扣减结果不存在"))); } @PostMapping("/reserve-deductions/{transactionId}/execute") @Operation(summary = "ACS 执行准备金扣减", description = "央行 ACS 受理商业银行已提交的扣减申请,并按通知快照执行扣款") public ApiResponse executeSubmittedReserveDeduction( @PathVariable String transactionId) { - IssuanceAuditActor actor = actor(); + CurrentUser user = currentUserService.getCurrentUser(); + IssuanceAuditActor actor = actor(user); accessGuard.requireTransactionOwner(transactionId, actor); - return ok(reserveDeductionExecutionService.execute(transactionId, actor)); + UUID requestId = requestIdForTransaction(transactionId); + runAction(requestId, "05", "execute-deduction", user); + return ok(reserveDeductionExecutionService.find(transactionId).orElseThrow(() -> + new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "准备金扣减结果不存在"))); } @PostMapping("/requests/{id}/digital-currency-production") @@ -132,7 +159,9 @@ public class CentralBankIssuanceController { CurrentUser currentUser = currentUserService.getCurrentUser(); IssuanceAuditActor actor = actor(currentUser); accessGuard.requireRequestOwner(id, actor); - return ok(digitalCurrencyDraftProductionService.produce(id, actor, InstitutionKeySubject.from(currentUser))); + runAction(id, "06", "confirm-production-receipt", currentUser); + return ok(digitalCurrencyDraftProductionService.find(id).orElseThrow(() -> + new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "数字货币生产批次不存在"))); } @GetMapping("/requests/{id}/digital-currency-production") @@ -149,8 +178,9 @@ public class CentralBankIssuanceController { CurrentUser currentUser = currentUserService.getCurrentUser(); IssuanceAuditActor actor = actor(currentUser); accessGuard.requireRequestOwner(id, actor); - return ok(digitalCurrencyOwnershipConfirmationService.confirm(id, actor, - InstitutionKeySubject.from(currentUser))); + runAction(id, "07", "load-ownership-result", currentUser); + return ok(digitalCurrencyOwnershipConfirmationService.findProduction(id).orElseThrow(() -> + new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "数字货币确权结果不存在"))); } @GetMapping("/requests/{id}/digital-currency-ownership") @@ -161,6 +191,43 @@ public class CentralBankIssuanceController { new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "数字货币确权记录不存在"))); } + private void runAction(UUID requestId, String stepCode, String actionCode, CurrentUser user) { + ExperimentSubject subject = subject(user); + ExperimentAttemptView attempt; + try { + attempt = attemptService.current(ExperimentModule.ISSUANCE, subject); + } catch (BusinessException exception) { + if (exception.getErrorCode() != ErrorCode.RESOURCE_NOT_FOUND) throw exception; + attempt = attemptService.create(ExperimentModule.ISSUANCE, subject); + } + if (attempt.getBusinessId() != null && !requestId.toString().equals(attempt.getBusinessId())) { + attempt = attemptService.create(ExperimentModule.ISSUANCE, subject); + } + IssuanceActionRequest request = new IssuanceActionRequest(); + request.setRequestId(requestId); + trainingActionService.execute(attempt.getAttemptId(), stepCode, actionCode, request, subject, actor(user), + InstitutionKeySubject.from(user)); + } + + private UUID requestIdForTransaction(String transactionId) { + return accessGuard.requestIdForTransaction(transactionId, actor()); + } + + private ExperimentSubject subject(CurrentUser user) { + return new ExperimentSubject(user.getUserId(), numeric(user.getSchoolId(), "学校 ID"), + numeric(user.getClassId(), "班级 ID"), ""); + } + + private long numeric(String value, String name) { + try { + long result = Long.parseLong(value); + if (result <= 0) throw new NumberFormatException(); + return result; + } catch (RuntimeException exception) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "登录凭据中的" + name + "无效"); + } + } + private IssuanceAuditActor actor() { return actor(currentUserService.getCurrentUser()); } diff --git a/src/main/java/com/yau/digitalrmb/issuance/interfaces/rest/CommercialBankIssuanceController.java b/src/main/java/com/yau/digitalrmb/issuance/interfaces/rest/CommercialBankIssuanceController.java index 1c8a15b..e788972 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/interfaces/rest/CommercialBankIssuanceController.java +++ b/src/main/java/com/yau/digitalrmb/issuance/interfaces/rest/CommercialBankIssuanceController.java @@ -10,6 +10,7 @@ import com.yau.digitalrmb.issuance.application.query.ReserveDeductionNotificatio import com.yau.digitalrmb.issuance.application.service.CommercialBankIssuanceApplicationService; import com.yau.digitalrmb.issuance.application.service.DenominationPlanService; import com.yau.digitalrmb.issuance.application.service.IssuanceRequestAccessGuard; +import com.yau.digitalrmb.issuance.application.service.IssuanceTrainingActionService; import com.yau.digitalrmb.issuance.application.service.ReserveDeductionNotificationService; import com.yau.digitalrmb.issuance.domain.model.DenominationItem; import com.yau.digitalrmb.issuance.domain.model.IssuanceAuditActor; @@ -17,12 +18,17 @@ import com.yau.digitalrmb.issuance.interfaces.dto.CreateIssuanceRequest; import com.yau.digitalrmb.issuance.interfaces.dto.DenominationItemRequest; import com.yau.digitalrmb.issuance.interfaces.dto.DenominationPlanRequest; import com.yau.digitalrmb.issuance.interfaces.dto.UpdateIssuanceRequest; +import com.yau.digitalrmb.issuance.interfaces.dto.IssuanceActionRequest; import com.yau.digitalrmb.security.application.CurrentUser; import com.yau.digitalrmb.security.application.CurrentUserService; import com.yau.digitalrmb.shared.api.ApiResponse; import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.exception.BusinessException; import com.yau.digitalrmb.shared.web.TraceIdFilter; +import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService; +import com.yau.digitalrmb.training.attempt.domain.ExperimentModule; +import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject; +import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.slf4j.MDC; @@ -48,17 +54,23 @@ public class CommercialBankIssuanceController { private final ReserveDeductionNotificationService notificationService; private final IssuanceRequestAccessGuard accessGuard; private final CurrentUserService currentUserService; + private final ExperimentAttemptService attemptService; + private final IssuanceTrainingActionService trainingActionService; public CommercialBankIssuanceController(CommercialBankIssuanceApplicationService service, DenominationPlanService denominationPlanService, ReserveDeductionNotificationService notificationService, IssuanceRequestAccessGuard accessGuard, - CurrentUserService currentUserService) { + CurrentUserService currentUserService, + ExperimentAttemptService attemptService, + IssuanceTrainingActionService trainingActionService) { this.service = service; this.denominationPlanService = denominationPlanService; this.notificationService = notificationService; this.accessGuard = accessGuard; this.currentUserService = currentUserService; + this.attemptService = attemptService; + this.trainingActionService = trainingActionService; } @GetMapping("/inventory") @@ -83,16 +95,25 @@ public class CommercialBankIssuanceController { @PostMapping("/requests") @Operation(summary = "创建发行申请", description = "创建数字货币发行申请草稿") public ApiResponse create(@Valid @RequestBody CreateIssuanceRequest request) { - return ok(service.create(new CreateIssuanceRequestCommand(request.getBankCode(), request.getOrganizationId(), - request.getTotalAmount(), request.getCurrency(), items(request.getDenominations())), actor())); + CurrentUser user = currentUserService.getCurrentUser(); + IssuanceActionRequest actionRequest = actionRequest(null, request.getBankCode(), request.getOrganizationId(), + request.getTotalAmount(), request.getCurrency(), request.getDenominations()); + ExperimentSubject subject = subject(user); + ExperimentAttemptView attempt = attemptService.create(ExperimentModule.ISSUANCE, subject); + trainingActionService.execute(attempt.getAttemptId(), "01", "refresh-custom-application", actionRequest, + subject, actor(user), InstitutionKeySubject.from(user)); + attempt = attemptService.detail(attempt.getAttemptId(), subject); + return ok(service.getCommercialBankView(UUID.fromString(attempt.getBusinessId()), actor(user))); } @PutMapping("/requests/{id}") @Operation(summary = "更新发行申请", description = "仅可更新草稿状态的发行申请") public ApiResponse update(@PathVariable UUID id, @Valid @RequestBody UpdateIssuanceRequest request) { - return ok(service.update(id, new UpdateIssuanceRequestCommand(request.getTotalAmount(), request.getCurrency(), - items(request.getDenominations())), actor())); + CurrentUser user = currentUserService.getCurrentUser(); + runAction("01", "refresh-custom-application", actionRequest(id, null, null, request.getTotalAmount(), + request.getCurrency(), request.getDenominations()), user); + return ok(service.getCommercialBankView(id, actor(user))); } @GetMapping("/requests/{id}") @@ -104,54 +125,118 @@ public class CommercialBankIssuanceController { @PostMapping("/requests/{id}/timestamp") @Operation(summary = "获取时间戳", description = "冻结用于发行请求原文的时间戳") public ApiResponse timestamp(@PathVariable UUID id) { - return ok(service.captureTimestamp(id, actor())); + CurrentUser user = currentUserService.getCurrentUser(); + runAction("01", "generate-timestamp", actionRequest(id), user); + return ok(service.getCommercialBankView(id, actor(user))); } @PostMapping("/requests/{id}/prepare-message") @Operation(summary = "生成待签名原文", description = "使用已获取时间戳生成发行请求待签名原文") public ApiResponse prepare(@PathVariable UUID id) { - return ok(service.prepareMessage(id, actor())); + CurrentUser user = currentUserService.getCurrentUser(); + runAction("01", "concatenate-application", actionRequest(id), user); + return ok(service.getCommercialBankView(id, actor(user))); } @PostMapping("/requests/{id}/digest") @Operation(summary = "生成 SM3 摘要", description = "对待签名原文生成 SM3 摘要") public ApiResponse digest(@PathVariable UUID id) { - return ok(service.digest(id, actor())); + CurrentUser user = currentUserService.getCurrentUser(); + runAction("01", "generate-digest", actionRequest(id), user); + return ok(service.getCommercialBankView(id, actor(user))); } @PostMapping("/requests/{id}/sign") @Operation(summary = "生成 SM2 签名", description = "使用生成数字货币实验模块保存的商业银行第二私钥签名") public ApiResponse sign(@PathVariable UUID id) { CurrentUser currentUser = currentUserService.getCurrentUser(); - return ok(service.sign(id, actor(currentUser), InstitutionKeySubject.from(currentUser))); + runAction("01", "sign-application", actionRequest(id), currentUser); + return ok(service.getCommercialBankView(id, actor(currentUser))); } @PostMapping("/requests/{id}/package") @Operation(summary = "封装发行请求报文", description = "封装包含摘要与签名的 JSON 发行请求报文") public ApiResponse pack(@PathVariable UUID id) { - return ok(service.packagePayload(id, actor())); + CurrentUser user = currentUserService.getCurrentUser(); + runAction("01", "package-application", actionRequest(id), user); + return ok(service.getCommercialBankView(id, actor(user))); } @PostMapping("/requests/{id}/send") @Operation(summary = "发送发行请求", description = "发送至中央银行端并更新接收状态") public ApiResponse send(@PathVariable UUID id) { - return ok(service.send(id, actor())); + CurrentUser user = currentUserService.getCurrentUser(); + runAction("01", "send-application", actionRequest(id), user); + return ok(service.getCommercialBankView(id, actor(user))); } @PostMapping("/requests/{id}/reserve-deduction-notification/receive") @Operation(summary = "确认接收准备金扣减通知", description = "商业银行确认收到中央银行发送的准备金扣减通知") public ApiResponse receiveReserveDeductionNotification(@PathVariable UUID id) { - IssuanceAuditActor actor = actor(); - accessGuard.requireRequestOwner(id, actor); - return ok(notificationService.receive(id, actor)); + CurrentUser user = currentUserService.getCurrentUser(); + runAction("05", "confirm-receipt", actionRequest(id), user); + return ok(notificationService.find(id).orElseThrow(() -> + new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "准备金扣减通知不存在"))); } @PostMapping("/requests/{id}/reserve-deduction-application") @Operation(summary = "提交准备金扣减申请", description = "商业银行根据已接收通知向央行 ACS 提交准备金扣减申请") public ApiResponse submitReserveDeductionApplication(@PathVariable UUID id) { - IssuanceAuditActor actor = actor(); - accessGuard.requireRequestOwner(id, actor); - return ok(notificationService.submit(id, actor)); + CurrentUser user = currentUserService.getCurrentUser(); + runAction("05", "verify-deduction", actionRequest(id), user); + return ok(notificationService.find(id).orElseThrow(() -> + new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "准备金扣减通知不存在"))); + } + + private ExperimentAttemptView runAction(String stepCode, String actionCode, IssuanceActionRequest request, + CurrentUser user) { + ExperimentSubject subject = subject(user); + ExperimentAttemptView attempt; + try { + attempt = attemptService.current(ExperimentModule.ISSUANCE, subject); + } catch (BusinessException exception) { + if (exception.getErrorCode() != ErrorCode.RESOURCE_NOT_FOUND) throw exception; + attempt = attemptService.create(ExperimentModule.ISSUANCE, subject); + } + if (request.getRequestId() != null && attempt.getBusinessId() != null + && !request.getRequestId().toString().equals(attempt.getBusinessId())) { + attempt = attemptService.create(ExperimentModule.ISSUANCE, subject); + } + trainingActionService.execute(attempt.getAttemptId(), stepCode, actionCode, request, subject, + actor(user), InstitutionKeySubject.from(user)); + return attemptService.detail(attempt.getAttemptId(), subject); + } + + private ExperimentSubject subject(CurrentUser user) { + return new ExperimentSubject(user.getUserId(), numeric(user.getSchoolId(), "学校 ID"), + numeric(user.getClassId(), "班级 ID"), ""); + } + + private long numeric(String value, String name) { + try { + long result = Long.parseLong(value); + if (result <= 0) throw new NumberFormatException(); + return result; + } catch (RuntimeException exception) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "登录凭据中的" + name + "无效"); + } + } + + private IssuanceActionRequest actionRequest(UUID requestId) { + return actionRequest(requestId, null, null, null, null, null); + } + + private IssuanceActionRequest actionRequest(UUID requestId, String bankCode, String organizationId, + java.math.BigDecimal totalAmount, String currency, + List denominations) { + IssuanceActionRequest result = new IssuanceActionRequest(); + result.setRequestId(requestId); + result.setBankCode(bankCode); + result.setOrganizationId(organizationId); + result.setTotalAmount(totalAmount); + result.setCurrency(currency); + result.setDenominations(denominations); + return result; } private List items(List input) { diff --git a/src/main/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceTrainingAttemptController.java b/src/main/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceTrainingAttemptController.java new file mode 100644 index 0000000..11b34cc --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceTrainingAttemptController.java @@ -0,0 +1,111 @@ +package com.yau.digitalrmb.issuance.interfaces.rest; + +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.issuance.application.service.IssuanceTrainingActionService; +import com.yau.digitalrmb.issuance.domain.model.IssuanceAuditActor; +import com.yau.digitalrmb.issuance.interfaces.dto.IssuanceActionRequest; +import com.yau.digitalrmb.security.application.CurrentUser; +import com.yau.digitalrmb.security.application.CurrentUserService; +import com.yau.digitalrmb.shared.api.ApiResponse; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import com.yau.digitalrmb.shared.web.TraceIdFilter; +import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService; +import com.yau.digitalrmb.training.attempt.domain.ExperimentModule; +import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject; +import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView; +import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.slf4j.MDC; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/issuance/attempts") +@Tag(name = "数字货币发行实验动作", description = "模块二可恢复实验及独立按钮动作") +public class IssuanceTrainingAttemptController { + private final ExperimentAttemptService attemptService; + private final IssuanceTrainingActionService actionService; + private final CurrentUserService currentUserService; + + public IssuanceTrainingAttemptController(ExperimentAttemptService attemptService, + IssuanceTrainingActionService actionService, + CurrentUserService currentUserService) { + this.attemptService = attemptService; + this.actionService = actionService; + this.currentUserService = currentUserService; + } + + @PostMapping + @Operation(summary = "开始新的发行实验") + public ApiResponse create() { + return ok(attemptService.create(ExperimentModule.ISSUANCE, subject(currentUserService.getCurrentUser()))); + } + + @GetMapping("/current") + @Operation(summary = "恢复当前发行实验") + public ApiResponse current() { + return ok(attemptService.current(ExperimentModule.ISSUANCE, subject(currentUserService.getCurrentUser()))); + } + + @GetMapping("/{attemptId}") + @Operation(summary = "查询发行实验详情") + public ApiResponse detail(@PathVariable UUID attemptId) { + return ok(attemptService.detail(attemptId, subject(currentUserService.getCurrentUser()))); + } + + @PostMapping("/{attemptId}/cancel") + @Operation(summary = "取消发行实验") + public ApiResponse cancel(@PathVariable UUID attemptId) { + return ok(attemptService.cancel(attemptId, subject(currentUserService.getCurrentUser()))); + } + + @PostMapping("/{attemptId}/steps/{stepCode}/actions/{actionCode}") + @Operation(summary = "执行一个发行实验按钮动作") + public ApiResponse> execute(@PathVariable UUID attemptId, + @PathVariable String stepCode, + @PathVariable String actionCode, + @RequestBody(required = false) IssuanceActionRequest request) { + CurrentUser user = currentUserService.getCurrentUser(); + IssuanceActionRequest input = request == null ? new IssuanceActionRequest() : request; + return ok(actionService.execute(attemptId, stepCode, actionCode, input, subject(user), actor(user), + InstitutionKeySubject.from(user))); + } + + private ExperimentSubject subject(CurrentUser user) { + return new ExperimentSubject(required(user.getUserId(), "用户 ID"), numeric(user.getSchoolId(), "学校 ID"), + numeric(user.getClassId(), "班级 ID"), ""); + } + + private IssuanceAuditActor actor(CurrentUser user) { + return new IssuanceAuditActor(required(user.getUserId(), "用户 ID"), required(user.getName(), "用户姓名")); + } + + private long numeric(String value, String name) { + try { + long result = Long.parseLong(required(value, name)); + if (result <= 0) throw new NumberFormatException(name); + return result; + } catch (NumberFormatException exception) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "登录凭据中的" + name + "无效"); + } + } + + private String required(String value, String name) { + if (value == null || value.trim().isEmpty()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "登录凭据缺少" + name); + } + return value.trim(); + } + + private ApiResponse ok(T data) { + return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); + } +} diff --git a/src/main/java/com/yau/digitalrmb/training/attempt/application/ActionOutcome.java b/src/main/java/com/yau/digitalrmb/training/attempt/application/ActionOutcome.java index bdf8a3e..3df2f8e 100644 --- a/src/main/java/com/yau/digitalrmb/training/attempt/application/ActionOutcome.java +++ b/src/main/java/com/yau/digitalrmb/training/attempt/application/ActionOutcome.java @@ -7,24 +7,32 @@ public final class ActionOutcome { private final ActionStatus status; private final String nextStepCode; private final String nextActionCode; + private final String businessId; - private ActionOutcome(T output, ActionStatus status, String nextStepCode, String nextActionCode) { + private ActionOutcome(T output, ActionStatus status, String nextStepCode, String nextActionCode, + String businessId) { this.output = output; this.status = status; this.nextStepCode = nextStepCode; this.nextActionCode = nextActionCode; + this.businessId = businessId; } public static ActionOutcome completed(T output) { - return new ActionOutcome(output, ActionStatus.COMPLETED, null, null); + return new ActionOutcome(output, ActionStatus.COMPLETED, null, null, null); + } + + public static ActionOutcome completed(T output, String businessId) { + return new ActionOutcome(output, ActionStatus.COMPLETED, null, null, businessId); } public static ActionOutcome rolledBack(T output, String nextStepCode, String nextActionCode) { - return new ActionOutcome(output, ActionStatus.ROLLED_BACK, nextStepCode, nextActionCode); + return new ActionOutcome(output, ActionStatus.ROLLED_BACK, nextStepCode, nextActionCode, null); } public T getOutput() { return output; } public ActionStatus getStatus() { return status; } public String getNextStepCode() { return nextStepCode; } public String getNextActionCode() { return nextActionCode; } + public String getBusinessId() { return businessId; } } diff --git a/src/main/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptService.java b/src/main/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptService.java index b383b8f..75ef037 100644 --- a/src/main/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptService.java +++ b/src/main/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptService.java @@ -104,6 +104,9 @@ public class ExperimentAttemptService { Instant completedAt = Instant.now(); action.complete(outcome.getStatus(), writeOutput(outcome.getOutput()), completedAt); + if (outcome.getBusinessId() != null) { + attempt.bindBusinessId(outcome.getBusinessId(), completedAt); + } if (outcome.getStatus() == ActionStatus.ROLLED_BACK) { attempt.moveTo(outcome.getNextStepCode(), outcome.getNextActionCode(), completedAt); } else { diff --git a/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceAttemptCancellationHandlerTest.java b/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceAttemptCancellationHandlerTest.java new file mode 100644 index 0000000..939ba02 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceAttemptCancellationHandlerTest.java @@ -0,0 +1,59 @@ +package com.yau.digitalrmb.issuance.application.service; + +import com.yau.digitalrmb.issuance.application.query.ReserveDeductionExecutionView; +import com.yau.digitalrmb.issuance.application.query.ReserveDeductionNotificationView; +import com.yau.digitalrmb.shared.exception.BusinessException; +import com.yau.digitalrmb.training.attempt.domain.ExperimentAttempt; +import com.yau.digitalrmb.training.attempt.domain.ExperimentModule; +import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class IssuanceAttemptCancellationHandlerTest { + private final ReserveDeductionNotificationService notifications = mock(ReserveDeductionNotificationService.class); + private final ReserveDeductionExecutionService executions = mock(ReserveDeductionExecutionService.class); + private final DigitalCurrencyGenerationModuleGateway gateway = mock(DigitalCurrencyGenerationModuleGateway.class); + private final IssuanceAttemptCancellationHandler handler = + new IssuanceAttemptCancellationHandler(notifications, executions, gateway); + private final ExperimentSubject subject = new ExperimentSubject("user-1", 10L, 20L, ""); + + @Test + void releasesOnlyModuleTwoReservationsBeforeTheIrreversibleDeduction() { + UUID requestId = UUID.fromString("00000000-0000-0000-0000-000000000401"); + ExperimentAttempt attempt = boundAttempt(requestId); + when(notifications.find(requestId)).thenReturn(Optional.empty()); + + handler.cancel(attempt, subject); + + verify(gateway).releaseGeneratedCurrencies(any(UUID.class), any()); + } + + @Test + void rejectsCancellationAfterReserveDeduction() { + UUID requestId = UUID.fromString("00000000-0000-0000-0000-000000000402"); + ExperimentAttempt attempt = boundAttempt(requestId); + ReserveDeductionNotificationView notification = mock(ReserveDeductionNotificationView.class); + when(notification.getTransactionId()).thenReturn("RESERVE-402"); + when(notifications.find(requestId)).thenReturn(Optional.of(notification)); + when(executions.find("RESERVE-402")).thenReturn(Optional.of(mock(ReserveDeductionExecutionView.class))); + + assertThatThrownBy(() -> handler.cancel(attempt, subject)).isInstanceOf(BusinessException.class); + verify(gateway, never()).releaseGeneratedCurrencies(any(UUID.class), any()); + } + + private ExperimentAttempt boundAttempt(UUID requestId) { + ExperimentAttempt attempt = ExperimentAttempt.start(ExperimentModule.ISSUANCE, subject, 1, Instant.now()); + attempt.bindBusinessId(requestId.toString(), Instant.now()); + return attempt; + } +} diff --git a/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionServiceTest.java b/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionServiceTest.java new file mode 100644 index 0000000..028c2d0 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionServiceTest.java @@ -0,0 +1,133 @@ +package com.yau.digitalrmb.issuance.application.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.issuance.application.query.IssuanceRequestView; +import com.yau.digitalrmb.issuance.domain.model.IssuanceAuditActor; +import com.yau.digitalrmb.issuance.interfaces.dto.IssuanceActionRequest; +import com.yau.digitalrmb.shared.exception.BusinessException; +import com.yau.digitalrmb.training.attempt.application.ActionOutcome; +import com.yau.digitalrmb.training.attempt.application.ActionWork; +import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService; +import com.yau.digitalrmb.training.attempt.domain.ExperimentModule; +import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject; +import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView; +import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Instant; +import java.util.Collections; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class IssuanceTrainingActionServiceTest { + private ExperimentAttemptService attempts; + private CommercialBankIssuanceApplicationService commercial; + private IssuanceTrainingActionService service; + private final UUID attemptId = UUID.fromString("00000000-0000-0000-0000-000000000201"); + private final ExperimentSubject subject = new ExperimentSubject("user-1", 10L, 20L, ""); + private final IssuanceAuditActor actor = new IssuanceAuditActor("user-1", "测试用户"); + private final InstitutionKeySubject keySubject = new InstitutionKeySubject("user-1", 10L, 20L); + + @BeforeEach + void setUp() { + attempts = mock(ExperimentAttemptService.class); + commercial = mock(CommercialBankIssuanceApplicationService.class); + service = new IssuanceTrainingActionService(attempts, new ObjectMapper(), commercial, + mock(CentralBankIssuanceQueryService.class), mock(CentralBankIssuanceBusinessReviewService.class), + mock(ReserveDeductionNotificationService.class), mock(ReserveDeductionExecutionService.class), + mock(DigitalCurrencyDraftProductionService.class), mock(DigitalCurrencyOwnershipConfirmationService.class)); + when(attempts.detail(attemptId, subject)).thenReturn(attemptView( + "00000000-0000-0000-0000-000000000298")); + when(attempts.execute(any(UUID.class), any(ExperimentSubject.class), anyString(), anyString(), anyString(), + any(Class.class), any(ActionWork.class), anyString(), anyString())).thenReturn( + new ExperimentActionView(attemptId, ExperimentModule.ISSUANCE, "01", "test", + com.yau.digitalrmb.training.attempt.domain.ActionStatus.COMPLETED, + com.yau.digitalrmb.training.attempt.domain.AttemptStatus.IN_PROGRESS, + "01", "test", Instant.now(), Collections.emptyMap())); + } + + @Test + void registersExactlyTheApprovedIssuanceActions() { + String[] keys = { + "01:refresh-quota", "01:refresh-custom-application", "01:generate-timestamp", + "01:concatenate-application", "01:generate-digest", "01:sign-application", + "01:package-application", "01:send-application", "02:verify-signature", + "02:recalculate-digest", "02:compare-digest", "03:query-institution-input", + "03:query-quota-input", "03:query-application-message", "04:auto-confirm-plan", + "04:generate-reserve-deduction-request", "05:confirm-receipt", "05:query-account", + "05:verify-deduction", "05:execute-deduction", "05:generate-balance-notice", + "05:send-balance-notice", "06:confirm-production-receipt", + "06:generate-production-digest", "07:load-ownership-result" + }; + for (String key : keys) { + String[] parts = key.split(":"); + service.execute(attemptId, parts[0], parts[1], new IssuanceActionRequest(), subject, actor, keySubject); + } + + assertThatThrownBy(() -> service.execute(attemptId, "01", "invented-action", + new IssuanceActionRequest(), subject, actor, keySubject)).isInstanceOf(BusinessException.class); + } + + @Test + @SuppressWarnings("unchecked") + void bindsTheCreatedIssuanceRequestWithoutExposingAnyPrivateKey() { + UUID requestId = UUID.fromString("00000000-0000-0000-0000-000000000299"); + IssuanceActionRequest request = validCreateRequest(); + when(attempts.detail(attemptId, subject)).thenReturn(attemptView(null)); + IssuanceRequestView view = IssuanceRequestView.from( + com.yau.digitalrmb.issuance.domain.model.IssuanceRequest.create( + new com.yau.digitalrmb.issuance.domain.model.IssuanceApplicationId(requestId), "REQ-299", + request.getBankCode(), request.getOrganizationId(), request.getTotalAmount(), + request.getCurrency(), completeDomainDenominations(), actor.getUserId())); + when(commercial.create(any(), any(IssuanceAuditActor.class))).thenReturn(view); + ArgumentCaptor> work = ArgumentCaptor.forClass(ActionWork.class); + + service.execute(attemptId, "01", "refresh-custom-application", request, subject, actor, keySubject); + verify(attempts).execute(any(UUID.class), any(ExperimentSubject.class), anyString(), anyString(), anyString(), + any(Class.class), work.capture(), anyString(), anyString()); + ActionOutcome outcome = work.getValue().run(); + + assertThat(outcome.getBusinessId()).isEqualTo(requestId.toString()); + assertThat(new ObjectMapper().valueToTree(outcome.getOutput()).toString()).doesNotContain("privateKey"); + } + + private IssuanceActionRequest validCreateRequest() { + IssuanceActionRequest request = new IssuanceActionRequest(); + request.setBankCode("BKCHCNBJ00001"); + request.setOrganizationId("ORG_001"); + request.setTotalAmount(new java.math.BigDecimal("100.00")); + request.setCurrency("DC"); + com.yau.digitalrmb.issuance.interfaces.dto.DenominationItemRequest item = + new com.yau.digitalrmb.issuance.interfaces.dto.DenominationItemRequest(); + item.setDenomination(new java.math.BigDecimal("100.00")); + item.setQuantity(1); + request.setDenominations(Collections.singletonList(item)); + return request; + } + + private java.util.List completeDomainDenominations() { + String[] values = {"100", "50", "20", "10", "5", "1", "0.5", "0.2", "0.1", "0.05", "0.01"}; + java.util.List result = new java.util.ArrayList<>(); + for (String value : values) { + result.add(new com.yau.digitalrmb.issuance.domain.model.DenominationItem( + new java.math.BigDecimal(value), "100".equals(value) ? 1 : 0)); + } + return result; + } + + private ExperimentAttemptView attemptView(String businessId) { + return new ExperimentAttemptView(attemptId, ExperimentModule.ISSUANCE, 1, + com.yau.digitalrmb.training.attempt.domain.AttemptStatus.IN_PROGRESS, "01", null, + businessId, Instant.now(), Instant.now(), null, Collections.emptyList()); + } +} diff --git a/src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceTrainingAttemptControllerTest.java b/src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceTrainingAttemptControllerTest.java new file mode 100644 index 0000000..e2f33c1 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceTrainingAttemptControllerTest.java @@ -0,0 +1,99 @@ +package com.yau.digitalrmb.issuance.interfaces.rest; + +import com.jayway.jsonpath.JsonPath; +import com.yau.digitalrmb.issuance.application.service.IssuanceTrainingActionService; +import com.yau.digitalrmb.security.application.CurrentUser; +import com.yau.digitalrmb.security.application.CurrentUserService; +import com.yau.digitalrmb.training.attempt.domain.ActionStatus; +import com.yau.digitalrmb.training.attempt.domain.AttemptStatus; +import com.yau.digitalrmb.training.attempt.domain.ExperimentModule; +import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class IssuanceTrainingAttemptControllerTest { + private static final String USER_ID = "00000000-0000-0000-0000-000000000487"; + + @Autowired private MockMvc mockMvc; + @MockBean private IssuanceTrainingActionService actionService; + @MockBean private CurrentUserService currentUserService; + + @Test + void exposesLifecycleAndEveryApprovedIssuanceAction() throws Exception { + when(currentUserService.getCurrentUser()).thenReturn(user(USER_ID)); + String created = mockMvc.perform(post("/api/v1/issuance/attempts").with(jwtFor(USER_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.moduleCode").value("ISSUANCE")) + .andReturn().getResponse().getContentAsString(); + UUID attemptId = UUID.fromString(JsonPath.read(created, "$.data.attemptId")); + + when(actionService.execute(any(UUID.class), anyString(), anyString(), any(), any(), any(), any())) + .thenAnswer(invocation -> new ExperimentActionView(invocation.getArgument(0), + ExperimentModule.ISSUANCE, invocation.getArgument(1), invocation.getArgument(2), + ActionStatus.COMPLETED, AttemptStatus.IN_PROGRESS, invocation.getArgument(1), + invocation.getArgument(2), java.time.Instant.now(), + java.util.Collections.singletonMap("safe", true))); + + String[][] actions = { + {"01", "refresh-quota"}, {"01", "refresh-custom-application"}, + {"01", "generate-timestamp"}, {"01", "concatenate-application"}, + {"01", "generate-digest"}, {"01", "sign-application"}, + {"01", "package-application"}, {"01", "send-application"}, + {"02", "verify-signature"}, {"02", "recalculate-digest"}, {"02", "compare-digest"}, + {"03", "query-institution-input"}, {"03", "query-quota-input"}, + {"03", "query-application-message"}, {"04", "auto-confirm-plan"}, + {"04", "generate-reserve-deduction-request"}, {"05", "confirm-receipt"}, + {"05", "query-account"}, {"05", "verify-deduction"}, {"05", "execute-deduction"}, + {"05", "generate-balance-notice"}, {"05", "send-balance-notice"}, + {"06", "confirm-production-receipt"}, {"06", "generate-production-digest"}, + {"07", "load-ownership-result"} + }; + for (String[] action : actions) { + mockMvc.perform(post("/api/v1/issuance/attempts/{id}/steps/{step}/actions/{action}", + attemptId, action[0], action[1]).with(jwtFor(USER_ID)) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.stepCode").value(action[0])) + .andExpect(jsonPath("$.data.actionCode").value(action[1])) + .andExpect(jsonPath("$.data.output.safe").value(true)); + } + + mockMvc.perform(get("/api/v1/issuance/attempts/current").with(jwtFor(USER_ID))) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.attemptId").value(attemptId.toString())); + mockMvc.perform(get("/api/v1/issuance/attempts/{id}", attemptId).with(jwtFor(USER_ID))) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.attemptId").value(attemptId.toString())); + mockMvc.perform(post("/api/v1/issuance/attempts/{id}/cancel", attemptId).with(jwtFor(USER_ID))) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.attemptStatus").value("CANCELLED")); + } + + private CurrentUser user(String userId) { + return new CurrentUser("1001", "延安大学", null, null, null, null, 4L, + userId, "tzs001", "测试用户", "2001", "测试班", "tzs001"); + } + + private org.springframework.test.web.servlet.request.RequestPostProcessor jwtFor(String userId) { + return jwt().jwt(token -> token.subject(userId).claim("userId", userId)); + } +} diff --git a/src/test/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptServiceTest.java b/src/test/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptServiceTest.java index b55dd6b..c6088d7 100644 --- a/src/test/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptServiceTest.java @@ -92,6 +92,17 @@ class ExperimentAttemptServiceTest { assertThrows(BusinessException.class, () -> service.current(ExperimentModule.ISSUANCE, subject)); } + @Test + void bindsTheBusinessRecordCreatedByAnActionToItsAttempt() { + ExperimentAttemptView attempt = service.create(ExperimentModule.ISSUANCE, subject); + + service.execute(attempt.getAttemptId(), subject, "01", "refresh-custom-application", "fingerprint-a", + TestOutput.class, () -> ActionOutcome.completed(new TestOutput("created"), "business-101"), + "01", "generate-timestamp"); + + assertEquals("business-101", service.detail(attempt.getAttemptId(), subject).getBusinessId()); + } + public static final class TestOutput { private String value;