feat: expose resumable issuance training actions

agent/payment-training-progress
chenyuan 2 weeks ago
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()));
}
}

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

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

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

@ -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<IssuanceRequestView> 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<IssuanceRequestView> 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<IssuanceRequestView> 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<IssuanceRequestView> 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<IssuanceRequestView> 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<IssuanceRequestView> 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<IssuanceRequestView> 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<IssuanceRequestView> 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<ReserveDeductionNotificationView> 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<ReserveDeductionNotificationView> 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<DenominationItemRequest> 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<DenominationItem> items(List<DenominationItemRequest> input) {

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

@ -7,24 +7,32 @@ public final class ActionOutcome<T> {
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 <T> ActionOutcome<T> completed(T output) {
return new ActionOutcome<T>(output, ActionStatus.COMPLETED, null, null);
return new ActionOutcome<T>(output, ActionStatus.COMPLETED, null, null, null);
}
public static <T> ActionOutcome<T> completed(T output, String businessId) {
return new ActionOutcome<T>(output, ActionStatus.COMPLETED, null, null, businessId);
}
public static <T> ActionOutcome<T> rolledBack(T output, String nextStepCode, String nextActionCode) {
return new ActionOutcome<T>(output, ActionStatus.ROLLED_BACK, nextStepCode, nextActionCode);
return new ActionOutcome<T>(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; }
}

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

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

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

Loading…
Cancel
Save