feat: expose resumable issuance training actions
parent
1e111da5bb
commit
4cacb89813
@ -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<ReserveDeductionNotificationView> 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()));
|
||||
}
|
||||
}
|
||||
@ -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<String> 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<Object> work = () -> {
|
||||
ActionOutcome<Object> 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<Object> 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<Object> 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<DenominationItem> denominations(List<DenominationItemRequest> values) {
|
||||
if (values == null || values.isEmpty()) throw validation("面额明细不能为空");
|
||||
List<DenominationItem> result = new ArrayList<DenominationItem>();
|
||||
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<String, Object> values = new LinkedHashMap<String, Object>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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<DenominationItemRequest> 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<DenominationItemRequest> getDenominations() { return denominations; }
|
||||
public void setDenominations(List<DenominationItemRequest> denominations) { this.denominations = denominations; }
|
||||
}
|
||||
@ -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<ExperimentAttemptView> create() {
|
||||
return ok(attemptService.create(ExperimentModule.ISSUANCE, subject(currentUserService.getCurrentUser())));
|
||||
}
|
||||
|
||||
@GetMapping("/current")
|
||||
@Operation(summary = "恢复当前发行实验")
|
||||
public ApiResponse<ExperimentAttemptView> current() {
|
||||
return ok(attemptService.current(ExperimentModule.ISSUANCE, subject(currentUserService.getCurrentUser())));
|
||||
}
|
||||
|
||||
@GetMapping("/{attemptId}")
|
||||
@Operation(summary = "查询发行实验详情")
|
||||
public ApiResponse<ExperimentAttemptView> detail(@PathVariable UUID attemptId) {
|
||||
return ok(attemptService.detail(attemptId, subject(currentUserService.getCurrentUser())));
|
||||
}
|
||||
|
||||
@PostMapping("/{attemptId}/cancel")
|
||||
@Operation(summary = "取消发行实验")
|
||||
public ApiResponse<ExperimentAttemptView> cancel(@PathVariable UUID attemptId) {
|
||||
return ok(attemptService.cancel(attemptId, subject(currentUserService.getCurrentUser())));
|
||||
}
|
||||
|
||||
@PostMapping("/{attemptId}/steps/{stepCode}/actions/{actionCode}")
|
||||
@Operation(summary = "执行一个发行实验按钮动作")
|
||||
public ApiResponse<ExperimentActionView<?>> 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 <T> ApiResponse<T> ok(T data) {
|
||||
return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<Object>(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<ActionWork<Object>> 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<Object> 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<com.yau.digitalrmb.issuance.domain.model.DenominationItem> completeDomainDenominations() {
|
||||
String[] values = {"100", "50", "20", "10", "5", "1", "0.5", "0.2", "0.1", "0.05", "0.01"};
|
||||
java.util.List<com.yau.digitalrmb.issuance.domain.model.DenominationItem> 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());
|
||||
}
|
||||
}
|
||||
@ -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<Object>(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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue