Merge branch 'feat/issuance-request-module'

master
chenyuan 4 weeks ago
commit 5b58192330

@ -0,0 +1,43 @@
# Task 4 Report: Issuance Application Workflow
## Scope
Implemented Task 4 application commands, query views, commercial-bank orchestration, and the read-only central-bank query. No REST controller or interface DTO was added.
## Changes
- Added create/update commands and commercial-bank inventory/request views under `issuance/application`.
- Added transactional commercial-bank operations for create, update, message preparation, SM3 digest recording, SM2 signing with `sm2-key-02`, JSON packaging, and idempotent sending.
- Generated request numbers as `ISSUE_REQ_` followed by an upper-case UUID suffix.
- Added request/inventory error mapping to `RESOURCE_NOT_FOUND` and domain argument/state error mapping to `VALIDATION_ERROR`.
- Added a central-bank read-only query that exposes persisted request receipt state, receipt time, and payload data.
- Registered the message composer and SM3/SM2 signing service as Spring beans.
- Added focused application tests for end-to-end send visibility, repeated sends, invalid post-prepare update, central-bank payload/receipt lookup, and missing request mapping.
## Test evidence
- RED: `mvn '-Dtest=CommercialBankIssuanceApplicationServiceTest,CentralBankIssuanceQueryServiceTest' test -B` failed at test compilation before implementation because the application command, query, and service classes did not exist.
- GREEN: the same focused command passed with 5 tests and 0 failures/errors.
- JDK 8: `mvn test -B` using Temurin `1.8.0_502` passed with 45 tests and 0 failures/errors.
- During full-suite verification, Spring reported an ambiguous service constructor. The application-context failure reproduced the problem; annotating the intended three-dependency constructor with `@Autowired` restored application-context startup. The focused and full JDK 8 suites were then rerun successfully.
- `git diff --check` completed without whitespace errors.
## Commit
`feat: add issuance application workflow`
## Review and concerns
- The repository's existing MyBatis audit handler derives `created_by` and `updated_by` from the authenticated Spring Security account; application operation signatures retain the authenticated account name expected by the next REST layer.
- JSON payload includes the required request identity, bank/organization fields, denomination rows, currency, timestamp, digest, and signature. `orgCode` and `bankCode` both carry the aggregate's commercial-bank code to support either naming convention at the integration boundary.
- No known Task 4 scope issues remain.
## Fix Round 1: Explicit Audit Actor
- Root cause: every commercial-bank write accepted `authenticatedAccountName` but discarded it before repository persistence, allowing the MyBatis audit handler to use its `SYSTEM` fallback whenever no Spring Security context was present.
- Added an actor-aware repository save boundary. The commercial-bank service now passes its supplied authenticated account for create, draft update, message preparation, digest, signature, packaging, and send. The MyBatis repository writes that actor to both audit columns on insert and only `updated_by` on later writes, preserving the original creator.
- Added a persistence regression that exercises all of those operations through the Spring application service and reads the actual `issuance_request` row after each operation.
- RED: `mvn '-Dtest=MybatisIssuanceRequestRepositoryTest' test -B` failed as expected with `expected: "creator" but was: "SYSTEM"`.
- GREEN: `mvn '-Dtest=CommercialBankIssuanceApplicationServiceTest,CentralBankIssuanceQueryServiceTest,MybatisIssuanceRequestRepositoryTest' test -B` passed with 10 tests and 0 failures/errors.
- JDK 8: `mvn test -B` using Temurin `1.8.0_502` passed with 46 tests and 0 failures/errors.
- `git diff --check` completed without whitespace errors before commit.

@ -364,7 +364,7 @@ git commit -m "feat: add issuance application workflow"
- Consumes: application service methods and current authenticated JWT subject.
- Produces: the ten REST paths specified in the approved design, wrapped in `ApiResponse<T>`.
- [ ] **Step 1: Write failing MVC and OpenAPI documentation tests**
- [ ] **Step 1: Write failing MVC, end-to-end workflow, and OpenAPI documentation tests**
```java
@Test
@ -384,13 +384,23 @@ void apiDocsContainChineseCommercialAndCentralBankTags() throws Exception {
.andExpect(content().string(containsString("数字货币发行模块 - 商业银行端")))
.andExpect(content().string(containsString("数字货币发行模块 - 中央银行端")));
}
@Test
void completeTeachingWorkflowReturnsReceivedPayloadToCentralBankEnd() throws Exception {
UUID requestId = createPrepareDigestSignPackageAndSendThroughCommercialBankApi();
mockMvc.perform(get("/api/v1/central-banks/issuance/requests/{id}", requestId)
.with(jwt().jwt(jwt -> jwt.subject("tzs001"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.receiveStatus").value("RECEIVED"))
.andExpect(jsonPath("$.data.payload.requestId").exists());
}
```
- [ ] **Step 2: Run controller tests to verify they fail**
Run: `mvn -Dtest=CommercialBankIssuanceControllerTest,CentralBankIssuanceControllerTest test -B`
Expected: compilation fails because the controllers and DTOs do not exist.
Expected: compilation fails because the controllers, DTOs, and commercial-bank-to-central-bank HTTP workflow do not exist.
- [ ] **Step 3: Implement controllers, DTO validation, and Chinese Swagger metadata**
@ -419,7 +429,7 @@ git add src/main/java/com/yau/digitalrmb/issuance/interfaces src/main/java/com/y
git commit -m "feat: expose commercial and central issuance APIs"
```
## Task 6: Verify the Full Workflow on the Dev Database
## Task 6: Verify the Completed Workflow on the Dev Database
**Files:**
- Modify: `README.md`
@ -429,31 +439,11 @@ git commit -m "feat: expose commercial and central issuance APIs"
- Consumes: completed application JAR and `dev` profile.
- Produces: documented startup and verified commercial-bank-to-central-bank workflow.
- [ ] **Step 1: Write a failing integration workflow test**
```java
@Test
void completeTeachingWorkflowReturnsReceivedPayloadToCentralBankEnd() throws Exception {
UUID requestId = createPrepareDigestSignPackageAndSendThroughCommercialBankApi();
mockMvc.perform(get("/api/v1/central-banks/issuance/requests/{id}", requestId)
.with(jwt().jwt(jwt -> jwt.subject("tzs001"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.receiveStatus").value("RECEIVED"))
.andExpect(jsonPath("$.data.payload.requestId").exists());
}
```
- [ ] **Step 2: Run the full workflow test to verify it fails before the missing integration behavior is completed**
Run: `mvn -Dtest=IssuanceWorkflowIntegrationTest test -B`
Expected: FAIL until all commercial-bank action endpoints and the central-bank projection are connected.
- [ ] **Step 3: Add only the missing integration wiring and document the dev workflow**
- [ ] **Step 1: Add the dev workflow documentation**
Add a README section with the `dev` profile startup command, the Chinese Swagger URL, the commercial-bank inventory endpoint, and the central-bank request query endpoint. Do not document or expose any database password or private key.
- [ ] **Step 4: Run all automated verification**
- [ ] **Step 2: Run all automated verification**
Run:
@ -466,7 +456,7 @@ mvn package -DskipTests -B
Expected: Maven exits 0 with all tests passing and the JAR built for Java 8.
- [ ] **Step 5: Run the dev-profile smoke test against the 118 test database**
- [ ] **Step 3: Run the dev-profile smoke test against the 118 test database**
Run:
@ -479,7 +469,7 @@ Invoke-WebRequest http://localhost:8081/v3/api-docs -UseBasicParsing
Expected: health status is `UP`, Swagger returns HTTP 200, and OpenAPI JSON contains both readable Chinese tag names without replacement characters.
- [ ] **Step 6: Commit verification and documentation**
- [ ] **Step 4: Commit verification and documentation**
```bash
git add README.md src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceWorkflowIntegrationTest.java

@ -19,6 +19,8 @@
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<mybatis-plus.version>3.5.17</mybatis-plus.version>
@ -66,6 +68,11 @@
<artifactId>springdoc-openapi-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.84</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>

@ -0,0 +1,20 @@
package com.yau.digitalrmb.issuance.application;
import com.yau.digitalrmb.issuance.domain.service.IssuanceMessageComposer;
import com.yau.digitalrmb.issuance.domain.service.IssuanceSignatureService;
import com.yau.digitalrmb.issuance.infrastructure.crypto.BouncyCastleIssuanceSignatureService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class IssuanceApplicationConfiguration {
@Bean
public IssuanceMessageComposer issuanceMessageComposer() {
return new IssuanceMessageComposer();
}
@Bean
public IssuanceSignatureService issuanceSignatureService() {
return new BouncyCastleIssuanceSignatureService();
}
}

@ -0,0 +1,32 @@
package com.yau.digitalrmb.issuance.application.command;
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class CreateIssuanceRequestCommand {
private final String bankCode;
private final String organizationId;
private final BigDecimal totalAmount;
private final String currency;
private final List<DenominationItem> denominations;
public CreateIssuanceRequestCommand(String bankCode, String organizationId, BigDecimal totalAmount, String currency,
List<DenominationItem> denominations) {
this.bankCode = bankCode;
this.organizationId = organizationId;
this.totalAmount = totalAmount;
this.currency = currency;
this.denominations = denominations == null ? null
: Collections.unmodifiableList(new ArrayList<DenominationItem>(denominations));
}
public String getBankCode() { return bankCode; }
public String getOrganizationId() { return organizationId; }
public BigDecimal getTotalAmount() { return totalAmount; }
public String getCurrency() { return currency; }
public List<DenominationItem> getDenominations() { return denominations; }
}

@ -0,0 +1,25 @@
package com.yau.digitalrmb.issuance.application.command;
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class UpdateIssuanceRequestCommand {
private final BigDecimal totalAmount;
private final String currency;
private final List<DenominationItem> denominations;
public UpdateIssuanceRequestCommand(BigDecimal totalAmount, String currency, List<DenominationItem> denominations) {
this.totalAmount = totalAmount;
this.currency = currency;
this.denominations = denominations == null ? null
: Collections.unmodifiableList(new ArrayList<DenominationItem>(denominations));
}
public BigDecimal getTotalAmount() { return totalAmount; }
public String getCurrency() { return currency; }
public List<DenominationItem> getDenominations() { return denominations; }
}

@ -0,0 +1,23 @@
package com.yau.digitalrmb.issuance.application.query;
import java.math.BigDecimal;
public final class CommercialBankInventoryView {
private final String bankCode;
private final BigDecimal currentBalance;
private final BigDecimal warningThreshold;
private final BigDecimal suggestedSupplementAmount;
public CommercialBankInventoryView(String bankCode, BigDecimal currentBalance, BigDecimal warningThreshold,
BigDecimal suggestedSupplementAmount) {
this.bankCode = bankCode;
this.currentBalance = currentBalance;
this.warningThreshold = warningThreshold;
this.suggestedSupplementAmount = suggestedSupplementAmount;
}
public String getBankCode() { return bankCode; }
public BigDecimal getCurrentBalance() { return currentBalance; }
public BigDecimal getWarningThreshold() { return warningThreshold; }
public BigDecimal getSuggestedSupplementAmount() { return suggestedSupplementAmount; }
}

@ -0,0 +1,68 @@
package com.yau.digitalrmb.issuance.application.query;
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
import com.yau.digitalrmb.issuance.domain.model.IssuanceRequest;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
public final class IssuanceRequestView {
private final UUID id;
private final String requestNo;
private final String bankCode;
private final String organizationId;
private final BigDecimal totalAmount;
private final String currency;
private final List<DenominationItem> denominations;
private final String requestTimestamp;
private final String messageText;
private final String digest;
private final String signature;
private final String signingKeyRef;
private final String payloadJson;
private final String status;
private final String centralBankReceiveStatus;
private final Instant centralBankReceivedAt;
private IssuanceRequestView(IssuanceRequest request) {
this.id = request.getId().value();
this.requestNo = request.getRequestNo();
this.bankCode = request.getBankCode();
this.organizationId = request.getOrganizationId();
this.totalAmount = request.getTotalAmount();
this.currency = request.getCurrency();
this.denominations = Collections.unmodifiableList(new ArrayList<DenominationItem>(request.getDenominations()));
this.requestTimestamp = request.getRequestTimestamp();
this.messageText = request.getMessageText();
this.digest = request.getDigest();
this.signature = request.getSignature();
this.signingKeyRef = request.getSigningKeyRef();
this.payloadJson = request.getPayloadJson();
this.status = request.getStatus().name();
this.centralBankReceiveStatus = request.getCentralBankReceiveStatus().name();
this.centralBankReceivedAt = request.getCentralBankReceivedAt();
}
public static IssuanceRequestView from(IssuanceRequest request) { return new IssuanceRequestView(request); }
public UUID getId() { return id; }
public String getRequestNo() { return requestNo; }
public String getBankCode() { return bankCode; }
public String getOrganizationId() { return organizationId; }
public BigDecimal getTotalAmount() { return totalAmount; }
public String getCurrency() { return currency; }
public List<DenominationItem> getDenominations() { return denominations; }
public String getRequestTimestamp() { return requestTimestamp; }
public String getMessageText() { return messageText; }
public String getDigest() { return digest; }
public String getSignature() { return signature; }
public String getSigningKeyRef() { return signingKeyRef; }
public String getPayloadJson() { return payloadJson; }
public String getStatus() { return status; }
public String getCentralBankReceiveStatus() { return centralBankReceiveStatus; }
public Instant getCentralBankReceivedAt() { return centralBankReceivedAt; }
}

@ -0,0 +1,34 @@
package com.yau.digitalrmb.issuance.application.service;
import com.yau.digitalrmb.issuance.application.query.IssuanceRequestView;
import com.yau.digitalrmb.issuance.domain.model.IssuanceApplicationId;
import com.yau.digitalrmb.issuance.domain.model.IssuanceRequest;
import com.yau.digitalrmb.issuance.domain.repository.IssuanceRequestRepository;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
@Service
public class CentralBankIssuanceQueryService {
private final IssuanceRequestRepository repository;
public CentralBankIssuanceQueryService(IssuanceRequestRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public IssuanceRequestView getCentralBankView(UUID id) {
return IssuanceRequestView.from(requireRequest(id));
}
private IssuanceRequest requireRequest(UUID id) {
if (id == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "request id must not be null");
}
return repository.findById(new IssuanceApplicationId(id)).orElseThrow(() ->
new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "issuance request was not found: " + id));
}
}

@ -0,0 +1,220 @@
package com.yau.digitalrmb.issuance.application.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yau.digitalrmb.issuance.application.command.CreateIssuanceRequestCommand;
import com.yau.digitalrmb.issuance.application.command.UpdateIssuanceRequestCommand;
import com.yau.digitalrmb.issuance.application.query.CommercialBankInventoryView;
import com.yau.digitalrmb.issuance.application.query.IssuanceRequestView;
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
import com.yau.digitalrmb.issuance.domain.model.IssuanceApplicationId;
import com.yau.digitalrmb.issuance.domain.model.IssuanceBankInventory;
import com.yau.digitalrmb.issuance.domain.model.IssuanceRequest;
import com.yau.digitalrmb.issuance.domain.repository.IssuanceRequestRepository;
import com.yau.digitalrmb.issuance.domain.service.IssuanceMessageComposer;
import com.yau.digitalrmb.issuance.domain.service.IssuanceSignatureService;
import com.yau.digitalrmb.issuance.domain.service.SignedIssuancePayload;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
@Service
public class CommercialBankIssuanceApplicationService {
private static final String SIGNING_KEY_REF = "sm2-key-02";
private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private final IssuanceRequestRepository repository;
private final IssuanceMessageComposer messageComposer;
private final IssuanceSignatureService signatureService;
private final ObjectMapper objectMapper;
@Autowired
public CommercialBankIssuanceApplicationService(IssuanceRequestRepository repository,
IssuanceMessageComposer messageComposer,
IssuanceSignatureService signatureService) {
this(repository, messageComposer, signatureService, new ObjectMapper());
}
CommercialBankIssuanceApplicationService(IssuanceRequestRepository repository,
IssuanceMessageComposer messageComposer,
IssuanceSignatureService signatureService, ObjectMapper objectMapper) {
this.repository = repository;
this.messageComposer = messageComposer;
this.signatureService = signatureService;
this.objectMapper = objectMapper;
}
@Transactional
public IssuanceRequestView create(CreateIssuanceRequestCommand command, String authenticatedAccountName) {
try {
UUID id = UUID.randomUUID();
IssuanceRequest request = IssuanceRequest.create(new IssuanceApplicationId(id), requestNo(id),
command.getBankCode(), command.getOrganizationId(), command.getTotalAmount(), command.getCurrency(),
command.getDenominations());
repository.save(request, auditActor(authenticatedAccountName));
return IssuanceRequestView.from(request);
} catch (IllegalArgumentException | IllegalStateException | NullPointerException exception) {
throw validationError(exception);
}
}
@Transactional
public IssuanceRequestView update(UUID id, UpdateIssuanceRequestCommand command, String authenticatedAccountName) {
try {
IssuanceRequest request = requireRequest(id);
request.updateDraft(command.getTotalAmount(), command.getCurrency(), command.getDenominations());
repository.save(request, auditActor(authenticatedAccountName));
return IssuanceRequestView.from(request);
} catch (IllegalArgumentException | IllegalStateException | NullPointerException exception) {
throw validationError(exception);
}
}
@Transactional
public IssuanceRequestView prepareMessage(UUID id, String authenticatedAccountName) {
try {
IssuanceRequest request = requireRequest(id);
String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT);
String message = messageComposer.compose(request.getBankCode(), request.getOrganizationId(),
request.getTotalAmount(), request.getDenominations(), request.getCurrency(), timestamp);
request.prepareMessage(timestamp, message);
repository.save(request, auditActor(authenticatedAccountName));
return IssuanceRequestView.from(request);
} catch (IllegalArgumentException | IllegalStateException | NullPointerException exception) {
throw validationError(exception);
}
}
@Transactional
public IssuanceRequestView digest(UUID id, String authenticatedAccountName) {
try {
IssuanceRequest request = requireRequest(id);
SignedIssuancePayload signedPayload = signatureService.sign(SIGNING_KEY_REF, request.getMessageText());
request.recordDigest(signedPayload.getDigest());
repository.save(request, auditActor(authenticatedAccountName));
return IssuanceRequestView.from(request);
} catch (IllegalArgumentException | IllegalStateException | NullPointerException exception) {
throw validationError(exception);
}
}
@Transactional
public IssuanceRequestView sign(UUID id, String authenticatedAccountName) {
try {
IssuanceRequest request = requireRequest(id);
SignedIssuancePayload signedPayload = signatureService.sign(SIGNING_KEY_REF, request.getMessageText());
request.recordSignature(signedPayload.getSigningKeyRef(), signedPayload.getSignature());
repository.save(request, auditActor(authenticatedAccountName));
return IssuanceRequestView.from(request);
} catch (IllegalArgumentException | IllegalStateException | NullPointerException exception) {
throw validationError(exception);
}
}
@Transactional
public IssuanceRequestView packagePayload(UUID id, String authenticatedAccountName) {
try {
IssuanceRequest request = requireRequest(id);
request.packagePayload(payloadFor(request));
repository.save(request, auditActor(authenticatedAccountName));
return IssuanceRequestView.from(request);
} catch (IllegalArgumentException | IllegalStateException | NullPointerException exception) {
throw validationError(exception);
}
}
@Transactional
public IssuanceRequestView send(UUID id, String authenticatedAccountName) {
try {
IssuanceRequest request = requireRequest(id);
request.sendToCentralBank(Instant.now());
repository.save(request, auditActor(authenticatedAccountName));
return IssuanceRequestView.from(request);
} catch (IllegalArgumentException | IllegalStateException | NullPointerException exception) {
throw validationError(exception);
}
}
@Transactional(readOnly = true)
public IssuanceRequestView getCommercialBankView(UUID id) {
return IssuanceRequestView.from(requireRequest(id));
}
@Transactional(readOnly = true)
public CommercialBankInventoryView getInventory(String bankCode) {
if (bankCode == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "bank code must not be null");
}
IssuanceBankInventory inventory = repository.findInventoryByBankCode(bankCode).orElseThrow(() ->
new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "issuance bank inventory was not found: " + bankCode));
return new CommercialBankInventoryView(inventory.getBankCode(), inventory.getCurrentBalance(),
inventory.getWarningThreshold(), inventory.getSuggestedSupplementAmount());
}
private IssuanceRequest requireRequest(UUID id) {
if (id == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "request id must not be null");
}
return repository.findById(new IssuanceApplicationId(id)).orElseThrow(() ->
new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "issuance request was not found: " + id));
}
private String payloadFor(IssuanceRequest request) {
Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("requestId", request.getId().value().toString());
payload.put("requestNo", request.getRequestNo());
payload.put("orgCode", request.getBankCode());
payload.put("bankCode", request.getBankCode());
payload.put("orgId", request.getOrganizationId());
payload.put("totalAmount", request.getTotalAmount());
payload.put("denominations", denominationPayload(request.getDenominations()));
payload.put("currency", request.getCurrency());
payload.put("timestamp", request.getRequestTimestamp());
payload.put("digest", request.getDigest());
payload.put("signature", request.getSignature());
try {
return objectMapper.writeValueAsString(payload);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("unable to package issuance payload", exception);
}
}
private List<Map<String, Integer>> denominationPayload(List<DenominationItem> denominations) {
List<Map<String, Integer>> payload = new ArrayList<Map<String, Integer>>();
for (DenominationItem denomination : denominations) {
Map<String, Integer> item = new LinkedHashMap<String, Integer>();
item.put("denomination", denomination.getDenomination());
item.put("quantity", denomination.getQuantity());
payload.add(item);
}
return payload;
}
private static String requestNo(UUID id) {
return "ISSUE_REQ_" + id.toString().replace("-", "").toUpperCase(Locale.ROOT);
}
private static String auditActor(String authenticatedAccountName) {
if (authenticatedAccountName == null || authenticatedAccountName.trim().isEmpty()) {
throw new IllegalArgumentException("authenticated account name must not be blank");
}
return authenticatedAccountName;
}
private static BusinessException validationError(RuntimeException exception) {
return new BusinessException(ErrorCode.VALIDATION_ERROR, exception.getMessage());
}
}

@ -0,0 +1,6 @@
package com.yau.digitalrmb.issuance.domain.model;
public enum CentralBankReceiveStatus {
NOT_RECEIVED,
RECEIVED
}

@ -0,0 +1,45 @@
package com.yau.digitalrmb.issuance.domain.model;
import java.util.Objects;
public final class DenominationItem {
private final int denomination;
private final int quantity;
public DenominationItem(int denomination, int quantity) {
if (denomination <= 0) {
throw new IllegalArgumentException("denomination must be positive");
}
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
this.denomination = denomination;
this.quantity = quantity;
}
public int getDenomination() {
return denomination;
}
public int getQuantity() {
return quantity;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof DenominationItem)) {
return false;
}
DenominationItem that = (DenominationItem) other;
return denomination == that.denomination && quantity == that.quantity;
}
@Override
public int hashCode() {
return Objects.hash(denomination, quantity);
}
}

@ -0,0 +1,36 @@
package com.yau.digitalrmb.issuance.domain.model;
import java.math.BigDecimal;
import java.util.Objects;
public final class IssuanceBankInventory {
private final String bankCode;
private final BigDecimal currentBalance;
private final BigDecimal warningThreshold;
private final BigDecimal suggestedSupplementAmount;
public IssuanceBankInventory(String bankCode, BigDecimal currentBalance, BigDecimal warningThreshold,
BigDecimal suggestedSupplementAmount) {
this.bankCode = Objects.requireNonNull(bankCode, "bankCode must not be null");
this.currentBalance = Objects.requireNonNull(currentBalance, "currentBalance must not be null");
this.warningThreshold = Objects.requireNonNull(warningThreshold, "warningThreshold must not be null");
this.suggestedSupplementAmount = Objects.requireNonNull(suggestedSupplementAmount,
"suggestedSupplementAmount must not be null");
}
public String getBankCode() {
return bankCode;
}
public BigDecimal getCurrentBalance() {
return currentBalance;
}
public BigDecimal getWarningThreshold() {
return warningThreshold;
}
public BigDecimal getSuggestedSupplementAmount() {
return suggestedSupplementAmount;
}
}

@ -0,0 +1,235 @@
package com.yau.digitalrmb.issuance.domain.model;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public class IssuanceRequest {
private final IssuanceApplicationId id;
private final String requestNo;
private final String bankCode;
private final String organizationId;
private BigDecimal totalAmount;
private String currency;
private List<DenominationItem> denominations;
private String requestTimestamp;
private String messageText;
private String digest;
private String signature;
private String signingKeyRef;
private String payloadJson;
private IssuanceRequestStatus status;
private CentralBankReceiveStatus centralBankReceiveStatus;
private Instant centralBankReceivedAt;
private IssuanceRequest(IssuanceApplicationId id, String requestNo, String bankCode, String organizationId,
BigDecimal totalAmount, String currency, List<DenominationItem> denominations) {
this.id = Objects.requireNonNull(id, "id must not be null");
this.requestNo = Objects.requireNonNull(requestNo, "requestNo must not be null");
this.bankCode = Objects.requireNonNull(bankCode, "bankCode must not be null");
this.organizationId = Objects.requireNonNull(organizationId, "organizationId must not be null");
applyDraftValues(totalAmount, currency, denominations);
this.status = IssuanceRequestStatus.DRAFT;
this.centralBankReceiveStatus = CentralBankReceiveStatus.NOT_RECEIVED;
}
public static IssuanceRequest create(IssuanceApplicationId id, String requestNo, String bankCode, String organizationId,
BigDecimal totalAmount, String currency,
List<DenominationItem> denominations) {
return new IssuanceRequest(id, requestNo, bankCode, organizationId, totalAmount, currency, denominations);
}
public static IssuanceRequest rehydrate(IssuanceApplicationId id, String requestNo, String bankCode, String organizationId,
BigDecimal totalAmount, String currency,
List<DenominationItem> denominations, String requestTimestamp,
String messageText, String digest, String signature, String signingKeyRef,
String payloadJson, IssuanceRequestStatus status,
CentralBankReceiveStatus centralBankReceiveStatus,
Instant centralBankReceivedAt) {
IssuanceRequest request = new IssuanceRequest(id, requestNo, bankCode, organizationId, totalAmount, currency,
denominations);
request.requestTimestamp = requestTimestamp;
request.messageText = messageText;
request.digest = digest;
request.signature = signature;
request.signingKeyRef = signingKeyRef;
request.payloadJson = payloadJson;
request.status = Objects.requireNonNull(status, "status must not be null");
request.centralBankReceiveStatus = Objects.requireNonNull(centralBankReceiveStatus,
"centralBankReceiveStatus must not be null");
request.centralBankReceivedAt = centralBankReceivedAt;
return request;
}
public void updateDraft(BigDecimal totalAmount, String currency, List<DenominationItem> denominations) {
requireStatus(IssuanceRequestStatus.DRAFT);
applyDraftValues(totalAmount, currency, denominations);
}
public void prepareMessage(String requestTimestamp, String messageText) {
requireStatus(IssuanceRequestStatus.DRAFT);
String validatedRequestTimestamp = Objects.requireNonNull(requestTimestamp, "requestTimestamp must not be null");
String validatedMessageText = Objects.requireNonNull(messageText, "messageText must not be null");
this.requestTimestamp = validatedRequestTimestamp;
this.messageText = validatedMessageText;
this.status = IssuanceRequestStatus.MESSAGE_PREPARED;
}
public void recordDigest(String digest) {
requireStatus(IssuanceRequestStatus.MESSAGE_PREPARED);
String validatedDigest = Objects.requireNonNull(digest, "digest must not be null");
this.digest = validatedDigest;
this.status = IssuanceRequestStatus.DIGESTED;
}
public void recordSignature(String signingKeyRef, String signature) {
requireStatus(IssuanceRequestStatus.DIGESTED);
String validatedSigningKeyRef = Objects.requireNonNull(signingKeyRef, "signingKeyRef must not be null");
String validatedSignature = Objects.requireNonNull(signature, "signature must not be null");
this.signingKeyRef = validatedSigningKeyRef;
this.signature = validatedSignature;
this.status = IssuanceRequestStatus.SIGNED;
}
public void packagePayload(String payloadJson) {
requireStatus(IssuanceRequestStatus.SIGNED);
String validatedPayloadJson = Objects.requireNonNull(payloadJson, "payloadJson must not be null");
this.payloadJson = validatedPayloadJson;
this.status = IssuanceRequestStatus.PACKAGED;
}
public void sendToCentralBank(Instant receivedAt) {
if (status == IssuanceRequestStatus.SENT) {
return;
}
requireStatus(IssuanceRequestStatus.PACKAGED);
Instant validatedReceivedAt = Objects.requireNonNull(receivedAt, "receivedAt must not be null");
this.status = IssuanceRequestStatus.SENT;
this.centralBankReceiveStatus = CentralBankReceiveStatus.RECEIVED;
this.centralBankReceivedAt = validatedReceivedAt;
}
public IssuanceApplicationId getId() {
return id;
}
public String getRequestNo() {
return requestNo;
}
public String getBankCode() {
return bankCode;
}
public String getOrganizationId() {
return organizationId;
}
public BigDecimal getTotalAmount() {
return totalAmount;
}
public String getCurrency() {
return currency;
}
public List<DenominationItem> getDenominations() {
return denominations;
}
public String getRequestTimestamp() {
return requestTimestamp;
}
public String getMessageText() {
return messageText;
}
public String getDigest() {
return digest;
}
public String getSignature() {
return signature;
}
public String getSigningKeyRef() {
return signingKeyRef;
}
public String getPayloadJson() {
return payloadJson;
}
public IssuanceRequestStatus getStatus() {
return status;
}
public CentralBankReceiveStatus getCentralBankReceiveStatus() {
return centralBankReceiveStatus;
}
public Instant getCentralBankReceivedAt() {
return centralBankReceivedAt;
}
private void applyDraftValues(BigDecimal totalAmount, String currency, List<DenominationItem> denominations) {
BigDecimal normalizedAmount = normalizeAmount(totalAmount);
List<DenominationItem> copiedDenominations = copyDenominations(denominations);
BigDecimal denominationTotal = calculateDenominationTotal(copiedDenominations);
if (denominationTotal.compareTo(normalizedAmount) != 0) {
throw new IllegalArgumentException("denomination total must equal totalAmount");
}
String validatedCurrency = Objects.requireNonNull(currency, "currency must not be null");
this.totalAmount = normalizedAmount;
this.currency = validatedCurrency;
this.denominations = Collections.unmodifiableList(copiedDenominations);
}
private BigDecimal normalizeAmount(BigDecimal totalAmount) {
BigDecimal amount = Objects.requireNonNull(totalAmount, "totalAmount must not be null")
.setScale(2, RoundingMode.UNNECESSARY);
if (amount.signum() <= 0) {
throw new IllegalArgumentException("totalAmount must be positive");
}
return amount;
}
private List<DenominationItem> copyDenominations(List<DenominationItem> denominations) {
List<DenominationItem> copiedDenominations = new ArrayList<DenominationItem>(
Objects.requireNonNull(denominations, "denominations must not be null"));
if (copiedDenominations.isEmpty()) {
throw new IllegalArgumentException("denominations must not be empty");
}
Set<Integer> faceValues = new HashSet<Integer>();
for (DenominationItem denomination : copiedDenominations) {
Objects.requireNonNull(denomination, "denomination item must not be null");
if (!faceValues.add(denomination.getDenomination())) {
throw new IllegalArgumentException("denomination values must be unique");
}
}
return copiedDenominations;
}
private BigDecimal calculateDenominationTotal(List<DenominationItem> denominations) {
BigDecimal total = BigDecimal.ZERO;
for (DenominationItem denomination : denominations) {
total = total.add(BigDecimal.valueOf(denomination.getDenomination())
.multiply(BigDecimal.valueOf(denomination.getQuantity())));
}
return total.setScale(2);
}
private void requireStatus(IssuanceRequestStatus expectedStatus) {
if (status != expectedStatus) {
throw new IllegalStateException("expected status " + expectedStatus + " but was " + status);
}
}
}

@ -0,0 +1,10 @@
package com.yau.digitalrmb.issuance.domain.model;
public enum IssuanceRequestStatus {
DRAFT,
MESSAGE_PREPARED,
DIGESTED,
SIGNED,
PACKAGED,
SENT
}

@ -0,0 +1,19 @@
package com.yau.digitalrmb.issuance.domain.repository;
import com.yau.digitalrmb.issuance.domain.model.IssuanceApplicationId;
import com.yau.digitalrmb.issuance.domain.model.IssuanceBankInventory;
import com.yau.digitalrmb.issuance.domain.model.IssuanceRequest;
import java.util.Optional;
public interface IssuanceRequestRepository {
void save(IssuanceRequest request);
default void save(IssuanceRequest request, String auditActor) {
save(request);
}
Optional<IssuanceRequest> findById(IssuanceApplicationId id);
Optional<IssuanceBankInventory> findInventoryByBankCode(String bankCode);
}

@ -0,0 +1,56 @@
package com.yau.digitalrmb.issuance.domain.service;
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
public final class IssuanceMessageComposer {
public String compose(String bankCode, String organizationId, BigDecimal totalAmount,
List<DenominationItem> denominations, String currency, String timestamp) {
requireNonNull(bankCode, "bankCode");
requireNonNull(organizationId, "organizationId");
requireNonNull(totalAmount, "totalAmount");
requireNonNull(denominations, "denominations");
requireNonNull(currency, "currency");
requireNonNull(timestamp, "timestamp");
List<DenominationItem> orderedDenominations = new ArrayList<DenominationItem>(denominations);
for (DenominationItem denomination : orderedDenominations) {
requireNonNull(denomination, "denomination item");
}
Collections.sort(orderedDenominations, new Comparator<DenominationItem>() {
@Override
public int compare(DenominationItem left, DenominationItem right) {
return Integer.compare(right.getDenomination(), left.getDenomination());
}
});
StringBuilder denominationText = new StringBuilder();
for (DenominationItem denomination : orderedDenominations) {
if (denominationText.length() > 0) {
denominationText.append(',');
}
denominationText.append(denomination.getDenomination())
.append(':')
.append(denomination.getQuantity());
}
return "ISSUE|" + bankCode
+ "|" + organizationId
+ "|" + totalAmount.setScale(2, RoundingMode.UNNECESSARY).toPlainString()
+ "|" + denominationText
+ "|" + currency
+ "|" + timestamp;
}
private static void requireNonNull(Object value, String name) {
Objects.requireNonNull(value, name + " must not be null");
}
}

@ -0,0 +1,8 @@
package com.yau.digitalrmb.issuance.domain.service;
public interface IssuanceSignatureService {
SignedIssuancePayload sign(String keyRef, String plainText);
boolean verify(String keyRef, String plainText, SignedIssuancePayload payload);
}

@ -0,0 +1,28 @@
package com.yau.digitalrmb.issuance.domain.service;
import java.util.Objects;
public final class SignedIssuancePayload {
private final String digest;
private final String signature;
private final String signingKeyRef;
public SignedIssuancePayload(String digest, String signature, String signingKeyRef) {
this.digest = Objects.requireNonNull(digest, "digest must not be null");
this.signature = Objects.requireNonNull(signature, "signature must not be null");
this.signingKeyRef = Objects.requireNonNull(signingKeyRef, "signingKeyRef must not be null");
}
public String getDigest() {
return digest;
}
public String getSignature() {
return signature;
}
public String getSigningKeyRef() {
return signingKeyRef;
}
}

@ -0,0 +1,79 @@
package com.yau.digitalrmb.issuance.infrastructure.crypto;
import com.yau.digitalrmb.issuance.domain.service.IssuanceSignatureService;
import com.yau.digitalrmb.issuance.domain.service.SignedIssuancePayload;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.PublicKey;
import java.security.Signature;
import java.util.Base64;
import java.util.Objects;
public final class BouncyCastleIssuanceSignatureService implements IssuanceSignatureService {
private static final char[] HEX = "0123456789ABCDEF".toCharArray();
private final InMemorySm2SigningKeyProvider keyProvider;
public BouncyCastleIssuanceSignatureService() {
this(new InMemorySm2SigningKeyProvider());
}
public BouncyCastleIssuanceSignatureService(InMemorySm2SigningKeyProvider keyProvider) {
this.keyProvider = Objects.requireNonNull(keyProvider, "keyProvider must not be null");
InMemorySm2SigningKeyProvider.ensureBouncyCastleProvider();
}
@Override
public SignedIssuancePayload sign(String keyRef, String plainText) {
requireNonNull(keyRef, "keyRef");
requireNonNull(plainText, "plainText");
byte[] textBytes = plainText.getBytes(StandardCharsets.UTF_8);
String digest = digest(textBytes);
String signature = Base64.getEncoder().encodeToString(keyProvider.sign(keyRef, textBytes));
return new SignedIssuancePayload(digest, signature, keyRef);
}
@Override
public boolean verify(String keyRef, String plainText, SignedIssuancePayload payload) {
requireNonNull(keyRef, "keyRef");
requireNonNull(plainText, "plainText");
requireNonNull(payload, "payload");
PublicKey publicKey = keyProvider.publicKeyFor(keyRef);
if (!keyRef.equals(payload.getSigningKeyRef())) {
return false;
}
try {
Signature signature = Signature.getInstance("SM3withSM2", "BC");
signature.initVerify(publicKey);
signature.update(plainText.getBytes(StandardCharsets.UTF_8));
return signature.verify(Base64.getDecoder().decode(payload.getSignature()));
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("Unable to verify the SM2 signature", exception);
}
}
private static String digest(byte[] textBytes) {
try {
MessageDigest messageDigest = MessageDigest.getInstance("SM3", "BC");
return toUpperCaseHex(messageDigest.digest(textBytes));
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("Unable to calculate the SM3 digest", exception);
}
}
private static String toUpperCaseHex(byte[] bytes) {
char[] characters = new char[bytes.length * 2];
for (int index = 0; index < bytes.length; index++) {
int value = bytes[index] & 0xFF;
characters[index * 2] = HEX[value >>> 4];
characters[index * 2 + 1] = HEX[value & 0x0F];
}
return new String(characters);
}
private static void requireNonNull(Object value, String name) {
Objects.requireNonNull(value, name + " must not be null");
}
}

@ -0,0 +1,71 @@
package com.yau.digitalrmb.issuance.infrastructure.crypto;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.Security;
import java.security.Signature;
import java.security.spec.ECGenParameterSpec;
import java.util.Objects;
public final class InMemorySm2SigningKeyProvider {
public static final String TEACHING_KEY_REF = "sm2-key-02";
private static final String PROVIDER_NAME = BouncyCastleProvider.PROVIDER_NAME;
private final KeyPair keyPair;
public InMemorySm2SigningKeyProvider() {
ensureBouncyCastleProvider();
this.keyPair = generateKeyPair();
}
public byte[] sign(String keyRef, byte[] plainText) {
requireKnownKey(keyRef);
Objects.requireNonNull(plainText, "plainText must not be null");
try {
Signature signature = Signature.getInstance("SM3withSM2", PROVIDER_NAME);
signature.initSign(keyPair.getPrivate());
signature.update(plainText);
return signature.sign();
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("Unable to sign with the in-memory SM2 key", exception);
}
}
public PublicKey publicKeyFor(String keyRef) {
requireKnownKey(keyRef);
return keyPair.getPublic();
}
static void ensureBouncyCastleProvider() {
if (Security.getProvider(PROVIDER_NAME) != null) {
return;
}
synchronized (InMemorySm2SigningKeyProvider.class) {
if (Security.getProvider(PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
}
private KeyPair generateKeyPair() {
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC", PROVIDER_NAME);
keyPairGenerator.initialize(new ECGenParameterSpec("sm2p256v1"), new SecureRandom());
return keyPairGenerator.generateKeyPair();
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("Unable to generate the in-memory SM2 key", exception);
}
}
private static void requireKnownKey(String keyRef) {
Objects.requireNonNull(keyRef, "keyRef must not be null");
if (!TEACHING_KEY_REF.equals(keyRef)) {
throw new IllegalArgumentException("Unknown SM2 key reference: " + keyRef);
}
}
}

@ -0,0 +1,24 @@
package com.yau.digitalrmb.issuance.infrastructure.persistence.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("issuance_bank_inventory")
public class IssuanceBankInventoryEntity {
@TableId(value = "bank_code", type = IdType.INPUT)
private String bankCode;
private BigDecimal currentBalance;
private BigDecimal warningThreshold;
private LocalDateTime updatedAt;
}

@ -0,0 +1,20 @@
package com.yau.digitalrmb.issuance.infrastructure.persistence.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@TableName("issuance_request_denomination")
public class IssuanceRequestDenominationEntity {
@TableId(value = "request_id", type = IdType.INPUT)
private String requestId;
private Integer denomination;
private Integer quantity;
}

@ -0,0 +1,51 @@
package com.yau.digitalrmb.issuance.infrastructure.persistence.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("issuance_request")
public class IssuanceRequestEntity {
@TableId(value = "id", type = IdType.INPUT)
private String id;
private String requestNo;
private String bankCode;
private String organizationId;
private BigDecimal totalAmount;
private String currency;
private String requestTimestamp;
private String messageText;
private String digest;
private String signature;
private String signingKeyRef;
private String payloadJson;
private String status;
private String centralReceiveStatus;
private LocalDateTime centralReceivedAt;
@TableField(value = "created_at", fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(value = "updated_at", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
@TableField(value = "created_by", fill = FieldFill.INSERT)
private String createdBy;
@TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE)
private String updatedBy;
@TableLogic
private Boolean deleted;
}

@ -0,0 +1,9 @@
package com.yau.digitalrmb.issuance.infrastructure.persistence.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yau.digitalrmb.issuance.infrastructure.persistence.entity.IssuanceBankInventoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface IssuanceBankInventoryMapper extends BaseMapper<IssuanceBankInventoryEntity> {
}

@ -0,0 +1,9 @@
package com.yau.digitalrmb.issuance.infrastructure.persistence.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yau.digitalrmb.issuance.infrastructure.persistence.entity.IssuanceRequestDenominationEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface IssuanceRequestDenominationMapper extends BaseMapper<IssuanceRequestDenominationEntity> {
}

@ -0,0 +1,9 @@
package com.yau.digitalrmb.issuance.infrastructure.persistence.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yau.digitalrmb.issuance.infrastructure.persistence.entity.IssuanceRequestEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface IssuanceRequestMapper extends BaseMapper<IssuanceRequestEntity> {
}

@ -0,0 +1,157 @@
package com.yau.digitalrmb.issuance.infrastructure.persistence.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.issuance.domain.model.CentralBankReceiveStatus;
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
import com.yau.digitalrmb.issuance.domain.model.IssuanceApplicationId;
import com.yau.digitalrmb.issuance.domain.model.IssuanceBankInventory;
import com.yau.digitalrmb.issuance.domain.model.IssuanceRequest;
import com.yau.digitalrmb.issuance.domain.model.IssuanceRequestStatus;
import com.yau.digitalrmb.issuance.domain.repository.IssuanceRequestRepository;
import com.yau.digitalrmb.issuance.infrastructure.persistence.entity.IssuanceBankInventoryEntity;
import com.yau.digitalrmb.issuance.infrastructure.persistence.entity.IssuanceRequestDenominationEntity;
import com.yau.digitalrmb.issuance.infrastructure.persistence.entity.IssuanceRequestEntity;
import com.yau.digitalrmb.issuance.infrastructure.persistence.mapper.IssuanceBankInventoryMapper;
import com.yau.digitalrmb.issuance.infrastructure.persistence.mapper.IssuanceRequestDenominationMapper;
import com.yau.digitalrmb.issuance.infrastructure.persistence.mapper.IssuanceRequestMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Objects;
import java.util.UUID;
@Repository
public class MybatisIssuanceRequestRepository implements IssuanceRequestRepository {
private final IssuanceRequestMapper requestMapper;
private final IssuanceRequestDenominationMapper denominationMapper;
private final IssuanceBankInventoryMapper inventoryMapper;
public MybatisIssuanceRequestRepository(IssuanceRequestMapper requestMapper,
IssuanceRequestDenominationMapper denominationMapper,
IssuanceBankInventoryMapper inventoryMapper) {
this.requestMapper = requestMapper;
this.denominationMapper = denominationMapper;
this.inventoryMapper = inventoryMapper;
}
@Override
@Transactional
public void save(IssuanceRequest request) {
saveInternal(request, null);
}
@Override
@Transactional
public void save(IssuanceRequest request, String auditActor) {
saveInternal(request, Objects.requireNonNull(auditActor, "auditActor must not be null"));
}
private void saveInternal(IssuanceRequest request, String auditActor) {
String requestId = request.getId().value().toString();
IssuanceRequestEntity entity = toEntity(request);
IssuanceRequestEntity existing = requestMapper.selectById(requestId);
if (existing == null) {
assignAuditActorForInsert(entity, auditActor);
requestMapper.insert(entity);
} else {
assignAuditActorForUpdate(entity, auditActor);
requestMapper.updateById(entity);
}
denominationMapper.delete(new LambdaQueryWrapper<IssuanceRequestDenominationEntity>()
.eq(IssuanceRequestDenominationEntity::getRequestId, requestId));
for (DenominationItem denomination : request.getDenominations()) {
denominationMapper.insert(toDenominationEntity(requestId, denomination));
}
}
private void assignAuditActorForInsert(IssuanceRequestEntity entity, String auditActor) {
if (auditActor != null) {
entity.setCreatedBy(auditActor);
entity.setUpdatedBy(auditActor);
}
}
private void assignAuditActorForUpdate(IssuanceRequestEntity entity, String auditActor) {
if (auditActor != null) {
entity.setUpdatedBy(auditActor);
}
}
@Override
public Optional<IssuanceRequest> findById(IssuanceApplicationId id) {
String requestId = id.value().toString();
IssuanceRequestEntity requestEntity = requestMapper.selectById(requestId);
if (requestEntity == null) {
return Optional.empty();
}
List<IssuanceRequestDenominationEntity> denominationEntities = denominationMapper.selectList(
new LambdaQueryWrapper<IssuanceRequestDenominationEntity>()
.eq(IssuanceRequestDenominationEntity::getRequestId, requestId)
.orderByDesc(IssuanceRequestDenominationEntity::getDenomination));
return Optional.of(toAggregate(requestEntity, denominationEntities));
}
@Override
public Optional<IssuanceBankInventory> findInventoryByBankCode(String bankCode) {
IssuanceBankInventoryEntity inventory = inventoryMapper.selectById(bankCode);
if (inventory == null) {
return Optional.empty();
}
BigDecimal suggested = inventory.getWarningThreshold().subtract(inventory.getCurrentBalance());
return Optional.of(new IssuanceBankInventory(inventory.getBankCode(), inventory.getCurrentBalance(),
inventory.getWarningThreshold(), suggested.max(BigDecimal.ZERO).setScale(2)));
}
private IssuanceRequestEntity toEntity(IssuanceRequest request) {
IssuanceRequestEntity entity = new IssuanceRequestEntity();
entity.setId(request.getId().value().toString());
entity.setRequestNo(request.getRequestNo());
entity.setBankCode(request.getBankCode());
entity.setOrganizationId(request.getOrganizationId());
entity.setTotalAmount(request.getTotalAmount());
entity.setCurrency(request.getCurrency());
entity.setRequestTimestamp(request.getRequestTimestamp());
entity.setMessageText(request.getMessageText());
entity.setDigest(request.getDigest());
entity.setSignature(request.getSignature());
entity.setSigningKeyRef(request.getSigningKeyRef());
entity.setPayloadJson(request.getPayloadJson());
entity.setStatus(request.getStatus().name());
entity.setCentralReceiveStatus(request.getCentralBankReceiveStatus().name());
if (request.getCentralBankReceivedAt() != null) {
entity.setCentralReceivedAt(request.getCentralBankReceivedAt().atOffset(ZoneOffset.UTC).toLocalDateTime());
}
return entity;
}
private IssuanceRequestDenominationEntity toDenominationEntity(String requestId, DenominationItem denomination) {
IssuanceRequestDenominationEntity entity = new IssuanceRequestDenominationEntity();
entity.setRequestId(requestId);
entity.setDenomination(denomination.getDenomination());
entity.setQuantity(denomination.getQuantity());
return entity;
}
private IssuanceRequest toAggregate(IssuanceRequestEntity requestEntity,
List<IssuanceRequestDenominationEntity> denominationEntities) {
List<DenominationItem> denominations = new ArrayList<DenominationItem>();
for (IssuanceRequestDenominationEntity denomination : denominationEntities) {
denominations.add(new DenominationItem(denomination.getDenomination(), denomination.getQuantity()));
}
return IssuanceRequest.rehydrate(new IssuanceApplicationId(UUID.fromString(requestEntity.getId())),
requestEntity.getRequestNo(), requestEntity.getBankCode(), requestEntity.getOrganizationId(),
requestEntity.getTotalAmount(), requestEntity.getCurrency(), denominations,
requestEntity.getRequestTimestamp(), requestEntity.getMessageText(), requestEntity.getDigest(),
requestEntity.getSignature(), requestEntity.getSigningKeyRef(), requestEntity.getPayloadJson(),
IssuanceRequestStatus.valueOf(requestEntity.getStatus()),
CentralBankReceiveStatus.valueOf(requestEntity.getCentralReceiveStatus()),
requestEntity.getCentralReceivedAt() == null ? null
: requestEntity.getCentralReceivedAt().toInstant(ZoneOffset.UTC));
}
}

@ -0,0 +1,20 @@
package com.yau.digitalrmb.issuance.interfaces.dto;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yau.digitalrmb.issuance.application.query.IssuanceRequestView;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.UUID;
@Schema(description = "中央银行端发行请求接收结果")
public class CentralBankIssuanceRequestResponse {
@Schema(description = "发行申请标识") private final UUID id;
@Schema(description = "发行请求编号") private final String requestNo;
@Schema(description = "接收状态", example = "RECEIVED") private final String receiveStatus;
@Schema(description = "接收时间") private final Instant receivedAt;
@Schema(description = "数字货币发行请求报文") private final JsonNode payload;
private CentralBankIssuanceRequestResponse(IssuanceRequestView view, JsonNode payload) { id=view.getId(); requestNo=view.getRequestNo(); receiveStatus=view.getCentralBankReceiveStatus(); receivedAt=view.getCentralBankReceivedAt(); this.payload=payload; }
public static CentralBankIssuanceRequestResponse from(IssuanceRequestView view, ObjectMapper mapper) { try { return new CentralBankIssuanceRequestResponse(view, view.getPayloadJson()==null?null:mapper.readTree(view.getPayloadJson())); } catch(Exception e) { throw new IllegalStateException("无法解析发行请求报文",e); } }
public UUID getId(){return id;} public String getRequestNo(){return requestNo;} public String getReceiveStatus(){return receiveStatus;} public Instant getReceivedAt(){return receivedAt;} public JsonNode getPayload(){return payload;}
}

@ -0,0 +1,24 @@
package com.yau.digitalrmb.issuance.interfaces.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Positive;
import java.math.BigDecimal;
import java.util.List;
@Schema(description = "创建数字货币发行申请")
public class CreateIssuanceRequest {
@NotBlank @Schema(description = "商业银行机构代码", example = "BKCHCNBJ00001") private String bankCode;
@NotBlank @Schema(description = "机构标识", example = "ORG_3A4B5C6D7E8F") private String organizationId;
@NotNull @Positive @Schema(description = "发行总金额", example = "50000.00") private BigDecimal totalAmount;
@NotBlank @Schema(description = "币种", example = "DC") private String currency;
@Valid @NotEmpty @Schema(description = "面额明细") private List<DenominationItemRequest> denominations;
public String getBankCode(){return bankCode;} public void setBankCode(String v){bankCode=v;}
public String getOrganizationId(){return organizationId;} public void setOrganizationId(String v){organizationId=v;}
public BigDecimal getTotalAmount(){return totalAmount;} public void setTotalAmount(BigDecimal v){totalAmount=v;}
public String getCurrency(){return currency;} public void setCurrency(String v){currency=v;}
public List<DenominationItemRequest> getDenominations(){return denominations;} public void setDenominations(List<DenominationItemRequest> v){denominations=v;}
}

@ -0,0 +1,15 @@
package com.yau.digitalrmb.issuance.interfaces.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "数字货币面额明细")
public class DenominationItemRequest {
@Schema(description = "面额", example = "100")
private Integer denomination;
@Schema(description = "数量", example = "400")
private Integer quantity;
public Integer getDenomination() { return denomination; }
public void setDenomination(Integer denomination) { this.denomination = denomination; }
public Integer getQuantity() { return quantity; }
public void setQuantity(Integer quantity) { this.quantity = quantity; }
}

@ -0,0 +1,2 @@
package com.yau.digitalrmb.issuance.interfaces.dto;
public class UpdateIssuanceRequest extends CreateIssuanceRequest { }

@ -0,0 +1,22 @@
package com.yau.digitalrmb.issuance.interfaces.rest;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yau.digitalrmb.issuance.application.service.CentralBankIssuanceQueryService;
import com.yau.digitalrmb.issuance.interfaces.dto.CentralBankIssuanceRequestResponse;
import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.web.TraceIdFilter;
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.*;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/central-banks/issuance")
@Tag(name="数字货币发行模块 - 中央银行端", description="中央银行查看数字货币发行请求接收结果")
public class CentralBankIssuanceController {
private final CentralBankIssuanceQueryService service; private final ObjectMapper mapper;
public CentralBankIssuanceController(CentralBankIssuanceQueryService service,ObjectMapper mapper){this.service=service;this.mapper=mapper;}
@GetMapping("/requests/{id}") @Operation(summary="查询发行请求接收结果",description="中央银行端查询接收状态、接收时间和完整请求报文")
public ApiResponse<CentralBankIssuanceRequestResponse> get(@PathVariable UUID id){return ApiResponse.success(CentralBankIssuanceRequestResponse.from(service.getCentralBankView(id),mapper),MDC.get(TraceIdFilter.MDC_KEY));}
}

@ -0,0 +1,45 @@
package com.yau.digitalrmb.issuance.interfaces.rest;
import com.yau.digitalrmb.issuance.application.command.CreateIssuanceRequestCommand;
import com.yau.digitalrmb.issuance.application.command.UpdateIssuanceRequestCommand;
import com.yau.digitalrmb.issuance.application.query.CommercialBankInventoryView;
import com.yau.digitalrmb.issuance.application.query.IssuanceRequestView;
import com.yau.digitalrmb.issuance.application.service.CommercialBankIssuanceApplicationService;
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
import com.yau.digitalrmb.issuance.interfaces.dto.CreateIssuanceRequest;
import com.yau.digitalrmb.issuance.interfaces.dto.DenominationItemRequest;
import com.yau.digitalrmb.issuance.interfaces.dto.UpdateIssuanceRequest;
import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.web.TraceIdFilter;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.MDC;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.*;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/v1/commercial-banks/issuance")
@Tag(name="数字货币发行模块 - 商业银行端", description="商业银行发起和处理数字货币发行申请")
public class CommercialBankIssuanceController {
private final CommercialBankIssuanceApplicationService service;
public CommercialBankIssuanceController(CommercialBankIssuanceApplicationService service){this.service=service;}
@GetMapping("/inventory") @Operation(summary="查询货币需求信息", description="商业银行端查询库存余额、预警阈值和建议补充金额")
public ApiResponse<CommercialBankInventoryView> inventory(@RequestParam String bankCode){return ok(service.getInventory(bankCode));}
@PostMapping("/requests") @Operation(summary="创建发行申请", description="商业银行端创建数字货币发行申请草稿")
public ApiResponse<IssuanceRequestView> create(@Valid @RequestBody CreateIssuanceRequest request,@AuthenticationPrincipal Jwt jwt){return ok(service.create(new CreateIssuanceRequestCommand(request.getBankCode(),request.getOrganizationId(),request.getTotalAmount(),request.getCurrency(),items(request.getDenominations())),actor(jwt)));}
@PutMapping("/requests/{id}") @Operation(summary="更新发行申请", description="商业银行端仅可更新草稿状态的发行申请")
public ApiResponse<IssuanceRequestView> update(@PathVariable UUID id,@Valid @RequestBody UpdateIssuanceRequest request,@AuthenticationPrincipal Jwt jwt){return ok(service.update(id,new UpdateIssuanceRequestCommand(request.getTotalAmount(),request.getCurrency(),items(request.getDenominations())),actor(jwt)));}
@GetMapping("/requests/{id}") @Operation(summary="查询发行申请", description="商业银行端查询发行申请及其处理产物") public ApiResponse<IssuanceRequestView> get(@PathVariable UUID id){return ok(service.getCommercialBankView(id));}
@PostMapping("/requests/{id}/prepare-message") @Operation(summary="生成待签名原文",description="冻结时间戳并生成发行请求待签名原文") public ApiResponse<IssuanceRequestView> prepare(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){return ok(service.prepareMessage(id,actor(jwt)));}
@PostMapping("/requests/{id}/digest") @Operation(summary="生成SM3摘要",description="对待签名原文生成SM3摘要") public ApiResponse<IssuanceRequestView> digest(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){return ok(service.digest(id,actor(jwt)));}
@PostMapping("/requests/{id}/sign") @Operation(summary="生成SM2签名",description="使用实验密钥sm2-key-02生成SM2签名") public ApiResponse<IssuanceRequestView> sign(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){return ok(service.sign(id,actor(jwt)));}
@PostMapping("/requests/{id}/package") @Operation(summary="封装发行请求报文",description="封装包含摘要与签名的JSON发行请求报文") public ApiResponse<IssuanceRequestView> pack(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){return ok(service.packagePayload(id,actor(jwt)));}
@PostMapping("/requests/{id}/send") @Operation(summary="发送发行请求",description="发送至中央银行端并更新中央银行接收状态") public ApiResponse<IssuanceRequestView> send(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){return ok(service.send(id,actor(jwt)));}
private List<DenominationItem> items(List<DenominationItemRequest> input){return input.stream().map(i->new DenominationItem(i.getDenomination(),i.getQuantity())).collect(Collectors.toList());}
private String actor(Jwt jwt){String account=jwt.getClaimAsString("preferred_username"); return account==null||account.trim().isEmpty()?jwt.getSubject():account;}
private <T> ApiResponse<T> ok(T data){return ApiResponse.success(data,MDC.get(TraceIdFilter.MDC_KEY));}
}

@ -77,3 +77,46 @@ ON DUPLICATE KEY UPDATE account = VALUES(account), display_name = VALUES(display
source_updated_at = CURRENT_TIMESTAMP, synced_at = CURRENT_TIMESTAMP;
INSERT IGNORE INTO sys_user_role (user_id, role_id) VALUES (487, 1002);
CREATE TABLE IF NOT EXISTS issuance_bank_inventory (
bank_code VARCHAR(32) PRIMARY KEY,
current_balance DECIMAL(20, 2) NOT NULL,
warning_threshold DECIMAL(20, 2) NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS issuance_request (
id CHAR(36) PRIMARY KEY,
request_no VARCHAR(64) NOT NULL UNIQUE,
bank_code VARCHAR(32) NOT NULL,
organization_id VARCHAR(64) NOT NULL,
total_amount DECIMAL(20, 2) NOT NULL,
currency VARCHAR(16) NOT NULL,
request_timestamp VARCHAR(32),
message_text TEXT,
digest CHAR(64),
signature TEXT,
signing_key_ref VARCHAR(64),
payload_json TEXT,
status VARCHAR(32) NOT NULL,
central_receive_status VARCHAR(32) NOT NULL,
central_received_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
created_by VARCHAR(64) NOT NULL,
updated_by VARCHAR(64) NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS issuance_request_denomination (
request_id CHAR(36) NOT NULL,
denomination INT NOT NULL,
quantity INT NOT NULL,
PRIMARY KEY (request_id, denomination)
);
INSERT INTO issuance_bank_inventory (bank_code, current_balance, warning_threshold, updated_at)
VALUES ('BKCHCNBJ00001', 49950000.00, 50000000.00, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE current_balance = VALUES(current_balance),
warning_threshold = VALUES(warning_threshold),
updated_at = CURRENT_TIMESTAMP;

@ -0,0 +1,75 @@
package com.yau.digitalrmb.issuance.application.service;
import com.yau.digitalrmb.issuance.application.query.IssuanceRequestView;
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
import com.yau.digitalrmb.issuance.domain.model.IssuanceApplicationId;
import com.yau.digitalrmb.issuance.domain.model.IssuanceBankInventory;
import com.yau.digitalrmb.issuance.domain.model.IssuanceRequest;
import com.yau.digitalrmb.issuance.domain.repository.IssuanceRequestRepository;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Arrays;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class CentralBankIssuanceQueryServiceTest {
@Test
void exposesReceiveStatusTimeAndPayloadForSentRequest() {
UUID requestId = UUID.randomUUID();
IssuanceRequest request = IssuanceRequest.create(new IssuanceApplicationId(requestId), "ISSUE_REQ_ABCDEF",
"BKCHCNBJ00001", "ORG_3A4B5C6D7E8F", new BigDecimal("100.00"), "DC",
Arrays.asList(new DenominationItem(100, 1)));
request.prepareMessage("20260803120000", "ISSUE|...");
request.recordDigest("ABC123");
request.recordSignature("sm2-key-02", "signature");
request.packagePayload("{\"requestId\":\"" + requestId + "\"}");
Instant receivedAt = Instant.parse("2026-08-03T12:00:00Z");
request.sendToCentralBank(receivedAt);
CentralBankIssuanceQueryService service = new CentralBankIssuanceQueryService(new SingleRequestRepository(request));
IssuanceRequestView view = service.getCentralBankView(requestId);
assertThat(view.getCentralBankReceiveStatus()).isEqualTo("RECEIVED");
assertThat(view.getCentralBankReceivedAt()).isEqualTo(receivedAt);
assertThat(view.getPayloadJson()).contains(requestId.toString());
}
@Test
void mapsUnknownRequestToResourceNotFound() {
CentralBankIssuanceQueryService service = new CentralBankIssuanceQueryService(new SingleRequestRepository(null));
assertThatThrownBy(() -> service.getCentralBankView(UUID.randomUUID()))
.isInstanceOf(BusinessException.class)
.extracting(exception -> ((BusinessException) exception).getErrorCode())
.isEqualTo(ErrorCode.RESOURCE_NOT_FOUND);
}
private static final class SingleRequestRepository implements IssuanceRequestRepository {
private final IssuanceRequest request;
private SingleRequestRepository(IssuanceRequest request) {
this.request = request;
}
@Override
public void save(IssuanceRequest request) { }
@Override
public Optional<IssuanceRequest> findById(IssuanceApplicationId id) {
return request == null || !request.getId().equals(id) ? Optional.<IssuanceRequest>empty() : Optional.of(request);
}
@Override
public Optional<IssuanceBankInventory> findInventoryByBankCode(String bankCode) {
return Optional.empty();
}
}
}

@ -0,0 +1,108 @@
package com.yau.digitalrmb.issuance.application.service;
import com.yau.digitalrmb.issuance.application.command.CreateIssuanceRequestCommand;
import com.yau.digitalrmb.issuance.application.command.UpdateIssuanceRequestCommand;
import com.yau.digitalrmb.issuance.application.query.IssuanceRequestView;
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
import com.yau.digitalrmb.issuance.domain.model.IssuanceApplicationId;
import com.yau.digitalrmb.issuance.domain.model.IssuanceBankInventory;
import com.yau.digitalrmb.issuance.domain.model.IssuanceRequest;
import com.yau.digitalrmb.issuance.domain.repository.IssuanceRequestRepository;
import com.yau.digitalrmb.issuance.domain.service.IssuanceMessageComposer;
import com.yau.digitalrmb.issuance.infrastructure.crypto.BouncyCastleIssuanceSignatureService;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class CommercialBankIssuanceApplicationServiceTest {
private final InMemoryIssuanceRequestRepository repository = new InMemoryIssuanceRequestRepository();
private final CommercialBankIssuanceApplicationService service = new CommercialBankIssuanceApplicationService(
repository, new IssuanceMessageComposer(), new BouncyCastleIssuanceSignatureService());
@Test
void sendsCommercialBankRequestAndMakesCentralBankViewReceived() {
UUID requestId = service.create(createCommand(), "tzs001").getId();
service.prepareMessage(requestId, "tzs001");
service.digest(requestId, "tzs001");
service.sign(requestId, "tzs001");
service.packagePayload(requestId, "tzs001");
IssuanceRequestView sent = service.send(requestId, "tzs001");
IssuanceRequestView centralBankView = new CentralBankIssuanceQueryService(repository)
.getCentralBankView(requestId);
assertThat(sent.getStatus()).isEqualTo("SENT");
assertThat(sent.getCentralBankReceiveStatus()).isEqualTo("RECEIVED");
assertThat(centralBankView.getCentralBankReceiveStatus()).isEqualTo("RECEIVED");
assertThat(sent.getRequestNo()).matches("ISSUE_REQ_[0-9A-F]{32}");
assertThat(sent.getRequestTimestamp()).matches("\\d{14}");
assertThat(sent.getPayloadJson()).contains("\"requestId\":\"" + requestId + "\"");
assertThat(sent.getPayloadJson()).contains("\"orgCode\":\"BKCHCNBJ00001\"")
.contains("\"orgId\":\"ORG_3A4B5C6D7E8F\"")
.contains("\"denominations\"").contains("\"digest\"").contains("\"signature\"");
}
@Test
void retainsReceivedRequestWhenSendIsRepeated() {
UUID requestId = service.create(createCommand(), "tzs001").getId();
service.prepareMessage(requestId, "tzs001");
service.digest(requestId, "tzs001");
service.sign(requestId, "tzs001");
service.packagePayload(requestId, "tzs001");
IssuanceRequestView firstSend = service.send(requestId, "tzs001");
IssuanceRequestView repeatedSend = service.send(requestId, "tzs001");
assertThat(repeatedSend.getStatus()).isEqualTo("SENT");
assertThat(repeatedSend.getCentralBankReceiveStatus()).isEqualTo("RECEIVED");
assertThat(repeatedSend.getCentralBankReceivedAt()).isEqualTo(firstSend.getCentralBankReceivedAt());
}
@Test
void rejectsUpdateAfterMessagePreparation() {
UUID requestId = service.create(createCommand(), "tzs001").getId();
service.prepareMessage(requestId, "tzs001");
assertThatThrownBy(() -> service.update(requestId, new UpdateIssuanceRequestCommand(
new BigDecimal("200.00"), "DC", Arrays.asList(new DenominationItem(100, 2))), "tzs001"))
.isInstanceOf(BusinessException.class)
.extracting(exception -> ((BusinessException) exception).getErrorCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR);
}
private CreateIssuanceRequestCommand createCommand() {
return new CreateIssuanceRequestCommand("BKCHCNBJ00001", "ORG_3A4B5C6D7E8F", new BigDecimal("50000.00"),
"DC", Arrays.asList(new DenominationItem(100, 400), new DenominationItem(50, 200)));
}
private static final class InMemoryIssuanceRequestRepository implements IssuanceRequestRepository {
private final Map<UUID, IssuanceRequest> requests = new HashMap<UUID, IssuanceRequest>();
@Override
public void save(IssuanceRequest request) {
requests.put(request.getId().value(), request);
}
@Override
public Optional<IssuanceRequest> findById(IssuanceApplicationId id) {
return Optional.ofNullable(requests.get(id.value()));
}
@Override
public Optional<IssuanceBankInventory> findInventoryByBankCode(String bankCode) {
return Optional.empty();
}
}
}

@ -0,0 +1,233 @@
package com.yau.digitalrmb.issuance.domain.model;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class IssuanceRequestTest {
private static final IssuanceApplicationId REQUEST_ID =
new IssuanceApplicationId(UUID.fromString("b7f572a0-1e8d-4e3c-8e42-44b5994f0429"));
private static final String REQUEST_NO = "ISSUE_REQ_B7F572A01E8D4E3C8E4244B5994F0429";
@Test
void rejectsDenominationsWhoseTotalDoesNotMatchRequestAmount() {
assertThatThrownBy(() -> IssuanceRequest.create(
REQUEST_ID,
REQUEST_NO,
"BKCHCNBJ00001",
"ORG_3A4B5C6D7E8F",
new BigDecimal("50000.00"),
"DC",
Arrays.asList(new DenominationItem(100, 400))))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void rejectsNonPositiveDenominationOrQuantity() {
assertThatThrownBy(() -> new DenominationItem(0, 1))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new DenominationItem(100, 0))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void rejectsDuplicateDenominationsThatCannotBeStoredAsDistinctRows() {
assertThatThrownBy(() -> IssuanceRequest.create(
REQUEST_ID,
REQUEST_NO,
"BKCHCNBJ00001",
"ORG_3A4B5C6D7E8F",
new BigDecimal("500.00"),
"DC",
Arrays.asList(new DenominationItem(100, 2), new DenominationItem(100, 3))))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void sendsOnlyPackagedRequestAndMarksCentralBankAsReceived() {
IssuanceRequest request = preparedSignedAndPackagedRequest();
Instant receivedAt = Instant.parse("2026-08-03T12:00:00Z");
request.sendToCentralBank(receivedAt);
assertThat(request.getStatus()).isEqualTo(IssuanceRequestStatus.SENT);
assertThat(request.getCentralBankReceiveStatus()).isEqualTo(CentralBankReceiveStatus.RECEIVED);
assertThat(request.getCentralBankReceivedAt()).isEqualTo(receivedAt);
}
@Test
void rejectsTransitionsThatDoNotFollowTheWorkflow() {
IssuanceRequest request = draftRequest();
assertThatThrownBy(() -> request.recordDigest("A1B2"))
.isInstanceOf(IllegalStateException.class);
assertThatThrownBy(() -> request.sendToCentralBank(Instant.parse("2026-08-03T12:00:00Z")))
.isInstanceOf(IllegalStateException.class);
}
@Test
void repeatedSendRetainsOriginalCentralBankReceipt() {
IssuanceRequest request = preparedSignedAndPackagedRequest();
Instant firstReceivedAt = Instant.parse("2026-08-03T12:00:00Z");
request.sendToCentralBank(firstReceivedAt);
request.sendToCentralBank(Instant.parse("2026-08-03T12:01:00Z"));
assertThat(request.getStatus()).isEqualTo(IssuanceRequestStatus.SENT);
assertThat(request.getCentralBankReceiveStatus()).isEqualTo(CentralBankReceiveStatus.RECEIVED);
assertThat(request.getCentralBankReceivedAt()).isEqualTo(firstReceivedAt);
}
@Test
void rejectsMissingReceiveTimeWithoutChangingThePackagedRequest() {
IssuanceRequest request = preparedSignedAndPackagedRequest();
assertThatThrownBy(() -> request.sendToCentralBank(null))
.isInstanceOf(NullPointerException.class);
assertThat(request.getStatus()).isEqualTo(IssuanceRequestStatus.PACKAGED);
assertThat(request.getCentralBankReceiveStatus()).isEqualTo(CentralBankReceiveStatus.NOT_RECEIVED);
assertThat(request.getCentralBankReceivedAt()).isNull();
}
@Test
void updatesOnlyDraftAndNormalizesTheRequestAmount() {
IssuanceRequest request = draftRequest();
request.updateDraft(new BigDecimal("200.0"), "DC", Arrays.asList(new DenominationItem(100, 2)));
assertThat(request.getTotalAmount()).isEqualByComparingTo("200.00");
assertThat(request.getTotalAmount().scale()).isEqualTo(2);
request.prepareMessage("20260803120000", "ISSUE|...");
assertThatThrownBy(() -> request.updateDraft(
new BigDecimal("200.00"), "DC", Arrays.asList(new DenominationItem(100, 2))))
.isInstanceOf(IllegalStateException.class);
}
@Test
void rejectedDraftUpdateLeavesEveryObservableFieldUnchanged() {
IssuanceRequest request = draftRequest();
RequestSnapshot original = RequestSnapshot.from(request);
assertThatThrownBy(() -> request.updateDraft(
new BigDecimal("200.00"), null, Arrays.asList(new DenominationItem(100, 2))))
.isInstanceOf(NullPointerException.class);
original.assertMatches(request);
}
@Test
void rejectedMessagePreparationLeavesEveryObservableFieldUnchanged() {
IssuanceRequest request = draftRequest();
RequestSnapshot original = RequestSnapshot.from(request);
assertThatThrownBy(() -> request.prepareMessage("20260803120000", null))
.isInstanceOf(NullPointerException.class);
original.assertMatches(request);
}
@Test
void rejectedSignatureRecordingLeavesEveryObservableFieldUnchanged() {
IssuanceRequest request = draftRequest();
request.prepareMessage("20260803120000", "ISSUE|...");
request.recordDigest("0123456789ABCDEF");
RequestSnapshot original = RequestSnapshot.from(request);
assertThatThrownBy(() -> request.recordSignature("sm2-key-02", null))
.isInstanceOf(NullPointerException.class);
original.assertMatches(request);
}
private IssuanceRequest preparedSignedAndPackagedRequest() {
IssuanceRequest request = draftRequest();
request.prepareMessage("20260803120000", "ISSUE|BKCHCNBJ00001|ORG_3A4B5C6D7E8F|50000.00|100:400,50:200|DC|20260803120000");
assertThat(request.getStatus()).isEqualTo(IssuanceRequestStatus.MESSAGE_PREPARED);
request.recordDigest("0123456789ABCDEF");
assertThat(request.getStatus()).isEqualTo(IssuanceRequestStatus.DIGESTED);
request.recordSignature("sm2-key-02", "signature");
assertThat(request.getStatus()).isEqualTo(IssuanceRequestStatus.SIGNED);
request.packagePayload("{\"requestId\":\"b7f572a0-1e8d-4e3c-8e42-44b5994f0429\"}");
assertThat(request.getStatus()).isEqualTo(IssuanceRequestStatus.PACKAGED);
return request;
}
private IssuanceRequest draftRequest() {
return IssuanceRequest.create(
REQUEST_ID,
REQUEST_NO,
"BKCHCNBJ00001",
"ORG_3A4B5C6D7E8F",
new BigDecimal("50000.00"),
"DC",
Arrays.asList(new DenominationItem(100, 400), new DenominationItem(50, 200)));
}
private static final class RequestSnapshot {
private final IssuanceApplicationId id;
private final String bankCode;
private final String organizationId;
private final BigDecimal totalAmount;
private final String currency;
private final List<DenominationItem> denominations;
private final String requestTimestamp;
private final String messageText;
private final String digest;
private final String signature;
private final String signingKeyRef;
private final String payloadJson;
private final IssuanceRequestStatus status;
private final CentralBankReceiveStatus centralBankReceiveStatus;
private final Instant centralBankReceivedAt;
private RequestSnapshot(IssuanceRequest request) {
this.id = request.getId();
this.bankCode = request.getBankCode();
this.organizationId = request.getOrganizationId();
this.totalAmount = request.getTotalAmount();
this.currency = request.getCurrency();
this.denominations = new ArrayList<DenominationItem>(request.getDenominations());
this.requestTimestamp = request.getRequestTimestamp();
this.messageText = request.getMessageText();
this.digest = request.getDigest();
this.signature = request.getSignature();
this.signingKeyRef = request.getSigningKeyRef();
this.payloadJson = request.getPayloadJson();
this.status = request.getStatus();
this.centralBankReceiveStatus = request.getCentralBankReceiveStatus();
this.centralBankReceivedAt = request.getCentralBankReceivedAt();
}
static RequestSnapshot from(IssuanceRequest request) {
return new RequestSnapshot(request);
}
void assertMatches(IssuanceRequest request) {
assertThat(request.getId()).isEqualTo(id);
assertThat(request.getBankCode()).isEqualTo(bankCode);
assertThat(request.getOrganizationId()).isEqualTo(organizationId);
assertThat(request.getTotalAmount()).isEqualByComparingTo(totalAmount);
assertThat(request.getCurrency()).isEqualTo(currency);
assertThat(request.getDenominations()).containsExactlyElementsOf(denominations);
assertThat(request.getRequestTimestamp()).isEqualTo(requestTimestamp);
assertThat(request.getMessageText()).isEqualTo(messageText);
assertThat(request.getDigest()).isEqualTo(digest);
assertThat(request.getSignature()).isEqualTo(signature);
assertThat(request.getSigningKeyRef()).isEqualTo(signingKeyRef);
assertThat(request.getPayloadJson()).isEqualTo(payloadJson);
assertThat(request.getStatus()).isEqualTo(status);
assertThat(request.getCentralBankReceiveStatus()).isEqualTo(centralBankReceiveStatus);
assertThat(request.getCentralBankReceivedAt()).isEqualTo(centralBankReceivedAt);
}
}
}

@ -0,0 +1,62 @@
package com.yau.digitalrmb.issuance.infrastructure.crypto;
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
import com.yau.digitalrmb.issuance.domain.service.IssuanceMessageComposer;
import com.yau.digitalrmb.issuance.domain.service.SignedIssuancePayload;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class BouncyCastleIssuanceSignatureServiceTest {
private static final String KEY_REF = "sm2-key-02";
private static final String PLAIN_TEXT =
"ISSUE|BKCHCNBJ00001|ORG_3A4B5C6D7E8F|50000.00|100:400,50:100|DC|20260801103218";
private final IssuanceMessageComposer composer = new IssuanceMessageComposer();
private final BouncyCastleIssuanceSignatureService service =
new BouncyCastleIssuanceSignatureService(new InMemorySm2SigningKeyProvider());
@Test
void composesPrototypeCompatiblePlainTextInDescendingDenominationOrder() {
assertThat(composer.compose(
"BKCHCNBJ00001",
"ORG_3A4B5C6D7E8F",
new BigDecimal("50000.00"),
Arrays.asList(new DenominationItem(50, 100), new DenominationItem(100, 400)),
"DC",
"20260801103218"))
.isEqualTo(PLAIN_TEXT);
}
@Test
void signsWithSm3AndSm2UsingTheTeachingKey() {
SignedIssuancePayload payload = service.sign(KEY_REF, PLAIN_TEXT);
assertThat(payload.getDigest()).matches("[0-9A-F]{64}");
assertThat(payload.getSignature()).isNotBlank();
assertThat(payload.getSigningKeyRef()).isEqualTo(KEY_REF);
assertThat(service.verify(KEY_REF, PLAIN_TEXT, payload)).isTrue();
assertThat(service.verify(KEY_REF, PLAIN_TEXT + "0", payload)).isFalse();
}
@Test
void rejectsUnknownKeyReferences() {
assertThatThrownBy(() -> service.sign("sm2-key-unknown", PLAIN_TEXT))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Unknown SM2 key reference");
}
@Test
void rejectsUnknownKeyReferencesDuringVerification() {
SignedIssuancePayload payload = service.sign(KEY_REF, PLAIN_TEXT);
assertThatThrownBy(() -> service.verify("sm2-key-unknown", PLAIN_TEXT, payload))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Unknown SM2 key reference");
}
}

@ -0,0 +1,166 @@
package com.yau.digitalrmb.issuance.infrastructure.persistence;
import com.yau.digitalrmb.issuance.application.command.CreateIssuanceRequestCommand;
import com.yau.digitalrmb.issuance.application.command.UpdateIssuanceRequestCommand;
import com.yau.digitalrmb.issuance.application.service.CommercialBankIssuanceApplicationService;
import com.yau.digitalrmb.issuance.domain.model.CentralBankReceiveStatus;
import com.yau.digitalrmb.issuance.domain.model.DenominationItem;
import com.yau.digitalrmb.issuance.domain.model.IssuanceBankInventory;
import com.yau.digitalrmb.issuance.domain.model.IssuanceApplicationId;
import com.yau.digitalrmb.issuance.domain.model.IssuanceRequest;
import com.yau.digitalrmb.issuance.domain.model.IssuanceRequestStatus;
import com.yau.digitalrmb.issuance.domain.repository.IssuanceRequestRepository;
import com.yau.digitalrmb.issuance.infrastructure.persistence.entity.IssuanceBankInventoryEntity;
import com.yau.digitalrmb.issuance.infrastructure.persistence.mapper.IssuanceBankInventoryMapper;
import com.yau.digitalrmb.issuance.infrastructure.persistence.mapper.IssuanceRequestMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Arrays;
import java.util.Locale;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("test")
class MybatisIssuanceRequestRepositoryTest {
@Autowired
private IssuanceRequestRepository repository;
@Autowired
private IssuanceBankInventoryMapper inventoryMapper;
@Autowired
private IssuanceRequestMapper requestMapper;
@Autowired
private CommercialBankIssuanceApplicationService commercialBankService;
@Test
void savesAndRestoresTheRequestWithItsDenominationsAndCentralBankStatus() {
IssuanceRequest sentRequest = sentRequest();
repository.save(sentRequest);
IssuanceRequest restored = repository.findById(sentRequest.getId())
.orElseThrow(() -> new AssertionError("saved request was not restored"));
assertThat(restored.getRequestNo()).isEqualTo("ISSUE_REQ_123456781234123412341234567890AB");
assertThat(restored.getBankCode()).isEqualTo("BKCHCNBJ00001");
assertThat(restored.getOrganizationId()).isEqualTo("ORG_3A4B5C6D7E8F");
assertThat(restored.getTotalAmount()).isEqualByComparingTo("50000.00");
assertThat(restored.getCurrency()).isEqualTo("DC");
assertThat(restored.getDenominations())
.containsExactly(new DenominationItem(100, 400), new DenominationItem(50, 200));
assertThat(restored.getRequestTimestamp()).isEqualTo("20260801103218");
assertThat(restored.getMessageText()).isEqualTo("ISSUE|BKCHCNBJ00001|ORG_3A4B5C6D7E8F|50000.00");
assertThat(restored.getDigest()).isEqualTo("0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF");
assertThat(restored.getSignature()).isEqualTo("base64-signature");
assertThat(restored.getSigningKeyRef()).isEqualTo("sm2-key-02");
assertThat(restored.getPayloadJson()).isEqualTo("{\"requestId\":\"example\"}");
assertThat(restored.getStatus()).isEqualTo(IssuanceRequestStatus.SENT);
assertThat(restored.getCentralBankReceiveStatus()).isEqualTo(CentralBankReceiveStatus.RECEIVED);
assertThat(restored.getCentralBankReceivedAt()).isEqualTo(Instant.parse("2026-08-03T12:00:00Z"));
}
@Test
void readsSuggestedAmountAsThresholdMinusCurrentBalance() {
IssuanceBankInventory inventory = repository.findInventoryByBankCode("BKCHCNBJ00001")
.orElseThrow(() -> new AssertionError("seeded bank inventory was not found"));
assertThat(inventory.getCurrentBalance()).isEqualByComparingTo("49950000.00");
assertThat(inventory.getWarningThreshold()).isEqualByComparingTo("50000000.00");
assertThat(inventory.getSuggestedSupplementAmount()).isEqualByComparingTo("50000.00");
}
@Test
void clampsSuggestedAmountToZeroWhenBalanceExceedsThreshold() {
IssuanceBankInventoryEntity inventory = new IssuanceBankInventoryEntity();
inventory.setBankCode("BKCLAMP00001");
inventory.setCurrentBalance(new BigDecimal("60000000.00"));
inventory.setWarningThreshold(new BigDecimal("50000000.00"));
inventory.setUpdatedAt(java.time.LocalDateTime.now());
inventoryMapper.insert(inventory);
IssuanceBankInventory restored = repository.findInventoryByBankCode("BKCLAMP00001")
.orElseThrow(() -> new AssertionError("inventory was not restored"));
assertThat(restored.getSuggestedSupplementAmount()).isEqualByComparingTo("0.00");
}
@Test
void savingChangedDraftReplacesRatherThanAccumulatesDenominations() {
UUID id = UUID.randomUUID();
IssuanceRequest request = IssuanceRequest.create(new IssuanceApplicationId(id), requestNo(id),
"BKCHCNBJ00001", "ORG_3A4B5C6D7E8F", new BigDecimal("50000.00"), "DC",
Arrays.asList(new DenominationItem(100, 400), new DenominationItem(50, 200)));
repository.save(request);
request.updateDraft(new BigDecimal("50000.00"), "DC", Arrays.asList(new DenominationItem(100, 500)));
repository.save(request);
IssuanceRequest restored = repository.findById(request.getId())
.orElseThrow(() -> new AssertionError("updated request was not restored"));
assertThat(restored.getDenominations()).containsExactly(new DenominationItem(100, 500));
}
@Test
void commercialBankWritesPersistTheSuppliedActorForCreateUpdateAndTransitions() {
UUID requestId = commercialBankService.create(createCommand(), "creator").getId();
assertAuditActor(requestId, "creator", "creator");
commercialBankService.update(requestId, updateCommand(), "updater");
assertAuditActor(requestId, "creator", "updater");
commercialBankService.prepareMessage(requestId, "preparer");
assertAuditActor(requestId, "creator", "preparer");
commercialBankService.digest(requestId, "digester");
assertAuditActor(requestId, "creator", "digester");
commercialBankService.sign(requestId, "signer");
assertAuditActor(requestId, "creator", "signer");
commercialBankService.packagePayload(requestId, "packager");
assertAuditActor(requestId, "creator", "packager");
commercialBankService.send(requestId, "sender");
assertAuditActor(requestId, "creator", "sender");
}
private CreateIssuanceRequestCommand createCommand() {
return new CreateIssuanceRequestCommand("BKCHCNBJ00001", "ORG_3A4B5C6D7E8F", new BigDecimal("50000.00"),
"DC", Arrays.asList(new DenominationItem(100, 400), new DenominationItem(50, 200)));
}
private UpdateIssuanceRequestCommand updateCommand() {
return new UpdateIssuanceRequestCommand(new BigDecimal("50000.00"), "DC",
Arrays.asList(new DenominationItem(100, 500)));
}
private void assertAuditActor(UUID requestId, String expectedCreatedBy, String expectedUpdatedBy) {
com.yau.digitalrmb.issuance.infrastructure.persistence.entity.IssuanceRequestEntity entity =
requestMapper.selectById(requestId.toString());
assertThat(entity.getCreatedBy()).isEqualTo(expectedCreatedBy);
assertThat(entity.getUpdatedBy()).isEqualTo(expectedUpdatedBy);
}
private IssuanceRequest sentRequest() {
UUID id = UUID.fromString("12345678-1234-1234-1234-1234567890ab");
IssuanceRequest request = IssuanceRequest.create(new IssuanceApplicationId(id),
"ISSUE_REQ_123456781234123412341234567890AB",
"BKCHCNBJ00001", "ORG_3A4B5C6D7E8F", new BigDecimal("50000.00"), "DC",
Arrays.asList(new DenominationItem(100, 400), new DenominationItem(50, 200)));
request.prepareMessage("20260801103218", "ISSUE|BKCHCNBJ00001|ORG_3A4B5C6D7E8F|50000.00");
request.recordDigest("0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF");
request.recordSignature("sm2-key-02", "base64-signature");
request.packagePayload("{\"requestId\":\"example\"}");
request.sendToCentralBank(Instant.parse("2026-08-03T12:00:00Z"));
return request;
}
private String requestNo(UUID id) {
return "ISSUE_REQ_" + id.toString().replace("-", "").toUpperCase(Locale.ROOT);
}
}

@ -0,0 +1,62 @@
package com.yau.digitalrmb.issuance.interfaces.rest;
import com.jayway.jsonpath.JsonPath;
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.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
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.content;
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 IssuanceControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void commercialBankWorkflowIsVisibleToCentralBankEnd() throws Exception {
String requestBody = "{\"bankCode\":\"BKCHCNBJ00001\",\"organizationId\":\"ORG_3A4B5C6D7E8F\",\"totalAmount\":50000.00,\"currency\":\"DC\",\"denominations\":[{\"denomination\":100,\"quantity\":400},{\"denomination\":50,\"quantity\":200}]}";
String createResponse = mockMvc.perform(post("/api/v1/commercial-banks/issuance/requests")
.with(jwt().jwt(jwt -> jwt.subject("487").claim("preferred_username", "tzs001")))
.contentType(MediaType.APPLICATION_JSON).content(requestBody))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("DRAFT"))
.andReturn().getResponse().getContentAsString();
String requestId = JsonPath.read(createResponse, "$.data.id");
String base = "/api/v1/commercial-banks/issuance/requests/" + requestId;
mockMvc.perform(post(base + "/prepare-message").with(jwt().jwt(jwt -> jwt.subject("487").claim("preferred_username", "tzs001"))))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("MESSAGE_PREPARED"));
mockMvc.perform(post(base + "/digest").with(jwt().jwt(jwt -> jwt.subject("487").claim("preferred_username", "tzs001"))))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("DIGESTED"));
mockMvc.perform(post(base + "/sign").with(jwt().jwt(jwt -> jwt.subject("487").claim("preferred_username", "tzs001"))))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("SIGNED"));
mockMvc.perform(post(base + "/package").with(jwt().jwt(jwt -> jwt.subject("487").claim("preferred_username", "tzs001"))))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("PACKAGED"));
mockMvc.perform(post(base + "/send").with(jwt().jwt(jwt -> jwt.subject("487").claim("preferred_username", "tzs001"))))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("SENT"));
mockMvc.perform(get("/api/v1/central-banks/issuance/requests/{id}", requestId)
.with(jwt().jwt(jwt -> jwt.subject("487").claim("preferred_username", "tzs001"))))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.receiveStatus").value("RECEIVED"));
}
@Test
void swaggerDocumentsBothChineseIssuanceEnds() throws Exception {
String apiDocs = new String(mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray(), StandardCharsets.UTF_8);
assertThat(apiDocs).contains("数字货币发行模块 - 商业银行端")
.contains("数字货币发行模块 - 中央银行端");
}
}
Loading…
Cancel
Save