feat:发行货币的功能开发

master
jiazheng.zhao 4 weeks ago
parent c6f2a24293
commit f92cb83566

@ -5,7 +5,7 @@
## 运行环境
- JDK 8已按 Temurin 8 验证)
- Spring Boot 2.7.18、Spring Security 5.7、MyBatis-Plus 3.5.17、Springdoc 1.8.0
- Spring Boot 2.7.18、Spring Security 5.7、MyBatis-Plus 3.5.17、Knife4j 4.4.0
- MySQL 8
## 登录方式
@ -47,7 +47,7 @@ Token 单点登录中的平台 `userId` 保存为 `sys_user.zy_user_id`;本地
mvn spring-boot:run -Dspring-boot.run.profiles=local -Dspring-boot.run.arguments=--server.port=8081
```
本地 Swagger<http://localhost:8081/swagger-ui/index.html>
本地 Swagger<http://localhost:8081/doc.html>
测试环境会执行 `schema.sql`,创建或更新测试账号 `tzs001`。`dev` 配置的
`spring.sql.init.mode``never`,不会自动修改真实 MySQL开发库必须已有

@ -22,7 +22,7 @@
<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>
<springdoc.version>1.8.0</springdoc.version>
<knife4j.version>4.4.0</knife4j.version>
<bouncycastle.version>1.70</bouncycastle.version>
</properties>
@ -63,9 +63,9 @@
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>${springdoc.version}</version>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-spring-boot-starter</artifactId>
<version>${knife4j.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>

@ -2,13 +2,10 @@ package com.yau.digitalrmb;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
@SpringBootApplication
@EnableScheduling
@EnableConfigurationProperties(PlatformIntegrationProperties.class)
public class DigitalRmbApplication {
public static void main(String[] args) {

@ -0,0 +1,29 @@
package com.yau.digitalrmb.institutionidentity.application;
import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentifierApplication;
public class CommercialBankInstitutionIdentifierResult {
private final long applicationId;
private final String applicationStatus;
private final int status;
private final String statusText;
private final String institutionIdentifier;
private final String fullInstitutionIdentifier;
public CommercialBankInstitutionIdentifierResult(InstitutionIdentifierApplication application) {
boolean feedbacked = application.getStatus() == InstitutionIdentifierApplication.Status.FEEDBACKED;
this.applicationId = application.getId();
this.applicationStatus = application.getStatus().name();
this.status = feedbacked ? 2 : 1;
this.statusText = feedbacked ? "已确认" : "待确认";
this.institutionIdentifier = feedbacked ? application.getInstitutionIdentifier() : null;
this.fullInstitutionIdentifier = feedbacked ? application.getDigest() : null;
}
public long getApplicationId() { return applicationId; }
public String getApplicationStatus() { return applicationStatus; }
public int getStatus() { return status; }
public String getStatusText() { return statusText; }
public String getInstitutionIdentifier() { return institutionIdentifier; }
public String getFullInstitutionIdentifier() { return fullInstitutionIdentifier; }
}

@ -31,6 +31,8 @@ public class CurrencyGenerationRequestService {
private InstitutionIdentityCryptography cryptography;
@Resource
private InstitutionKeyService keyService;
@Resource
private InstitutionTrainingScoreService scoreService;
private final Clock clock = Clock.systemUTC();
@ -108,6 +110,7 @@ public class CurrencyGenerationRequestService {
expected = request.getStatus();
invokeDomain(request::receive);
repository.update(request, expected, "RECEIVE", "央行成功接收货币生成请求", operator);
scoreService.markStepCompleted(subject.getUserId(), 2);
return new CurrencyRequestResult(request);
}

@ -25,6 +25,7 @@ public class CurrencyGenerationVerificationService {
@Resource private CurrencyGenerationRequestService requestService;
@Resource private InstitutionIdentityCryptography cryptography;
@Resource private InstitutionKeyService keyService;
@Resource private InstitutionTrainingScoreService scoreService;
@Transactional
public CurrencyGenerationVerificationResult receive(long requestId, InstitutionKeySubject subject, String operator) {
@ -95,6 +96,7 @@ public class CurrencyGenerationVerificationService {
CurrencyGenerationVerification.Status expected = value.getStatus();
invoke(value::returnResponse);
repository.update(value, expected, "RETURN_RESPONSE", "向商业银行返回确认报文", operator);
scoreService.markStepCompleted(subject.getUserId(), 3);
return new CurrencyGenerationVerificationResult(value);
}

@ -25,15 +25,15 @@ public class InstitutionApplicationVerificationService {
private InstitutionTrainingErrorRecorder errorRecorder;
@Transactional
public InstitutionApplicationVerificationResult verify(String verificationKeyId,
InstitutionKeySubject subject, String operator) {
public InstitutionApplicationVerificationResult verify(String keyId, InstitutionKeySubject subject,
String operator) {
InstitutionIdentifierApplication application = requireCurrent(subject,
InstitutionIdentifierApplication.Status.PACKAGED, "当前没有待验证的本轮机构标识数据");
long applicationId = application.getId();
if (!InstitutionKeyService.BANK_SECOND_KEY.equals(verificationKeyId)) {
recordAndThrow(application, "WRONG_KEY", "VERIFY", verificationKeyId,
"验证必须选择商业银行第二密钥的公钥", operator);
if (!InstitutionKeyService.BANK_SECOND_KEY.equals(keyId)) {
recordAndThrow(application, "WRONG_PUBLIC_KEY", "DECRYPT_VERIFY", keyId,
"解密验证必须选择商业银行第二公钥", operator);
}
long applicationId = application.getId();
requireStatus(application, InstitutionIdentifierApplication.Status.PACKAGED,
"当前步骤不是央行验证,不能操作", operator);
InstitutionIdentifierApplication.Status expectedStatus = application.getStatus();
@ -42,36 +42,42 @@ public class InstitutionApplicationVerificationService {
boolean signatureValid = keyService.verifyCommercialBank(subject, application.getDigest(),
application.getBankSignature());
boolean digestMatches = recalculatedDigest.equalsIgnoreCase(application.getDigest());
invokeDomain(() -> application.recordVerification(verificationKeyId, signatureValid, digestMatches));
repository.update(application, expectedStatus, "VERIFY",
signatureValid && digestMatches ? "SM2身份验证和SM3完整性验证通过" : "申请验证失败",
invokeDomain(() -> application.recordVerification(
InstitutionKeyService.BANK_SECOND_KEY, signatureValid, digestMatches));
repository.update(application, expectedStatus, "DECRYPT_VERIFY",
signatureValid && digestMatches
? "使用商业银行第二公钥完成SM2解密验证身份真实性和数据完整性验证通过"
: "央行解密验证失败",
operator);
return new InstitutionApplicationVerificationResult(applicationId, application.getStatus().name(),
"SM2_WITH_SM3_VERIFY_AND_SM3_RECALCULATE", verificationKeyId, originalText,
"SM2_WITH_SM3_VERIFY_AND_SM3_RECALCULATE", InstitutionKeyService.BANK_SECOND_KEY, originalText,
application.getDigest(), recalculatedDigest, signatureValid, digestMatches);
}
@Transactional
public InstitutionIdentifierConfirmationResult confirm(String signingKeyId,
public InstitutionIdentifierConfirmationResult confirm(String providedPrivateKey,
InstitutionKeySubject subject, String operator) {
InstitutionIdentifierApplication application = requireCurrent(subject,
InstitutionIdentifierApplication.Status.VERIFIED, "当前没有待确权的本轮机构标识数据");
long applicationId = application.getId();
if (!InstitutionKeyService.CENTRAL_FIRST_KEY.equals(signingKeyId)) {
recordAndThrow(application, "WRONG_KEY", "CONFIRM", signingKeyId,
"签名确权必须选择中央银行第一密钥", operator);
}
requireStatus(application, InstitutionIdentifierApplication.Status.VERIFIED,
"申请尚未验证通过,不能进行央行签名确权", operator);
InstitutionIdentifierApplication.Status expectedStatus = application.getStatus();
String digest = application.getDigest();
String identifier = "ORG_" + digest.substring(0, 12);
String centralBankSignature = keyService.signCentralBank(subject, digest);
invokeDomain(() -> application.issue(signingKeyId, identifier, centralBankSignature));
String centralBankSignature = providedPrivateKey == null
? keyService.signCentralBank(subject, digest)
: keyService.signCentralBank(subject, providedPrivateKey, digest);
if (centralBankSignature == null) {
recordAndThrow(application, "WRONG_PRIVATE_KEY", "CONFIRM", null,
"签名确权必须使用中央银行第一私钥", operator);
}
invokeDomain(() -> application.issue(
InstitutionKeyService.CENTRAL_FIRST_KEY, identifier, centralBankSignature));
repository.update(application, expectedStatus, "CONFIRM",
"中央银行第一私钥完成SM2签名确权并签发机构标识", operator);
return new InstitutionIdentifierConfirmationResult(applicationId, application.getStatus().name(),
signingKeyId, centralBankSignature, identifier, digest);
InstitutionKeyService.CENTRAL_FIRST_KEY, centralBankSignature, identifier, digest);
}
private InstitutionIdentifierApplication requireCurrent(InstitutionKeySubject subject,

@ -0,0 +1,78 @@
package com.yau.digitalrmb.institutionidentity.application;
import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentifierApplication;
import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentifierRepository;
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 javax.annotation.Resource;
@Service
public class InstitutionIdentifierFeedbackService {
@Resource
private InstitutionIdentifierRepository repository;
@Resource
private InstitutionTrainingScoreService scoreService;
@Transactional(readOnly = true)
public InstitutionIdentifierFinalResult finalIdentifier(InstitutionKeySubject subject) {
InstitutionIdentifierApplication application = requireLatest(subject);
if (application.getStatus() != InstitutionIdentifierApplication.Status.ISSUED
&& application.getStatus() != InstitutionIdentifierApplication.Status.FEEDBACKED) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"央行尚未完成签名确权,不能查看最终机构标识,当前状态:"
+ application.getStatus().name());
}
return new InstitutionIdentifierFinalResult(application);
}
@Transactional
public InstitutionIdentifierFinalResult feedback(InstitutionKeySubject subject, String operator) {
InstitutionIdentifierApplication application = requireLatest(subject);
if (application.getStatus() != InstitutionIdentifierApplication.Status.ISSUED) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"当前没有待反馈的最终机构标识,当前状态:" + application.getStatus().name());
}
InstitutionIdentifierApplication.Status expectedStatus = application.getStatus();
invokeDomain(application::feedback);
repository.update(application, expectedStatus, "FEEDBACK",
"央行将最终机构标识同步反馈到商业银行端", operator);
scoreService.markStepCompleted(subject.getUserId(), 1);
return new InstitutionIdentifierFinalResult(application);
}
@Transactional(readOnly = true)
public CommercialBankInstitutionIdentifierResult commercialBankResult(InstitutionKeySubject subject) {
InstitutionIdentifierApplication application = requireLatest(subject);
if (!isVisibleToCommercialBank(application.getStatus())) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND,
"商业银行第二私钥签名成功后才会出现待确认数据");
}
return new CommercialBankInstitutionIdentifierResult(application);
}
private InstitutionIdentifierApplication requireLatest(InstitutionKeySubject subject) {
return repository.findLatestBySubject(subject.getUserId(), subject.getSchoolId(), subject.getClassId())
.orElseThrow(() -> new BusinessException(
ErrorCode.RESOURCE_NOT_FOUND, "当前没有机构标识申请数据"));
}
private static boolean isVisibleToCommercialBank(InstitutionIdentifierApplication.Status status) {
return status == InstitutionIdentifierApplication.Status.SIGNED
|| status == InstitutionIdentifierApplication.Status.PACKAGED
|| status == InstitutionIdentifierApplication.Status.VERIFIED
|| status == InstitutionIdentifierApplication.Status.ISSUED
|| status == InstitutionIdentifierApplication.Status.FEEDBACKED;
}
private static void invokeDomain(Runnable operation) {
try {
operation.run();
} catch (IllegalArgumentException | IllegalStateException exception) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, exception.getMessage());
}
}
}

@ -0,0 +1,32 @@
package com.yau.digitalrmb.institutionidentity.application;
import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentifierApplication;
public class InstitutionIdentifierFinalResult {
private final long applicationId;
private final String applicationStatus;
private final int feedbackStatus;
private final String feedbackStatusText;
private final String institutionIdentifier;
private final String fullInstitutionIdentifier;
private final String centralBankSignature;
public InstitutionIdentifierFinalResult(InstitutionIdentifierApplication application) {
boolean feedbacked = application.getStatus() == InstitutionIdentifierApplication.Status.FEEDBACKED;
this.applicationId = application.getId();
this.applicationStatus = application.getStatus().name();
this.feedbackStatus = feedbacked ? 2 : 1;
this.feedbackStatusText = feedbacked ? "已反馈" : "待反馈";
this.institutionIdentifier = application.getInstitutionIdentifier();
this.fullInstitutionIdentifier = application.getDigest();
this.centralBankSignature = application.getCentralBankSignature();
}
public long getApplicationId() { return applicationId; }
public String getApplicationStatus() { return applicationStatus; }
public int getFeedbackStatus() { return feedbackStatus; }
public String getFeedbackStatusText() { return feedbackStatusText; }
public String getInstitutionIdentifier() { return institutionIdentifier; }
public String getFullInstitutionIdentifier() { return fullInstitutionIdentifier; }
public String getCentralBankSignature() { return centralBankSignature; }
}

@ -59,22 +59,22 @@ public class InstitutionIdentifierStepService {
}
@Transactional
public InstitutionSignatureResult sign(String keyId, InstitutionKeySubject subject, String operator) {
public InstitutionSignatureResult sign(String privateKey, InstitutionKeySubject subject, String operator) {
InstitutionIdentifierApplication application = requireCurrent(subject,
InstitutionIdentifierApplication.Status.DIGESTED, "当前没有待签名的本轮机构标识数据");
long applicationId = application.getId();
if (!InstitutionKeyService.BANK_SECOND_KEY.equals(keyId)) {
recordAndThrow(application, "WRONG_KEY", "SIGN", keyId,
"签名必须选择商业银行第二密钥", operator);
}
requireStatus(application, InstitutionIdentifierApplication.Status.DIGESTED,
"当前步骤不是商业银行签名,不能操作", operator);
InstitutionIdentifierApplication.Status expectedStatus = application.getStatus();
String signature = keyService.signCommercialBank(subject, application.getDigest());
String signature = keyService.signCommercialBank(subject, privateKey, application.getDigest());
if (signature == null) {
recordAndThrow(application, "WRONG_PRIVATE_KEY", "SIGN", null,
"签名必须选择商业银行第二私钥", operator);
}
invokeDomain(() -> application.recordBankSignature(signature));
repository.update(application, expectedStatus, "SIGN", "使用商业银行第二私钥完成SM2签名", operator);
return new InstitutionSignatureResult(applicationId, application.getStatus().name(), "SM2", keyId,
"COMMERCIAL_BANK_SECOND_PRIVATE_KEY", signature);
return new InstitutionSignatureResult(applicationId, application.getStatus().name(), "SM2",
InstitutionKeyService.BANK_SECOND_KEY, "COMMERCIAL_BANK_SECOND_PRIVATE_KEY", signature);
}
@Transactional

@ -56,6 +56,15 @@ public class InstitutionKeyService {
return cryptography.sign(privateKey(subject, BANK_SECOND_KEY), message);
}
@Transactional(readOnly = true)
public String signCommercialBank(InstitutionKeySubject subject, String providedPrivateKey, String message) {
String storedPrivateKey = privateKey(subject, BANK_SECOND_KEY);
if (!storedPrivateKey.equalsIgnoreCase(providedPrivateKey)) {
return null;
}
return cryptography.sign(storedPrivateKey, message);
}
@Transactional(readOnly = true)
public boolean verifyCommercialBank(InstitutionKeySubject subject, String message, String signature) {
return cryptography.verify(required(subject, BANK_SECOND_KEY).getPublicKey(), message, signature);
@ -66,6 +75,15 @@ public class InstitutionKeyService {
return cryptography.sign(privateKey(subject, CENTRAL_FIRST_KEY), message);
}
@Transactional(readOnly = true)
public String signCentralBank(InstitutionKeySubject subject, String providedPrivateKey, String message) {
String storedPrivateKey = privateKey(subject, CENTRAL_FIRST_KEY);
if (!storedPrivateKey.equalsIgnoreCase(providedPrivateKey)) {
return null;
}
return cryptography.sign(storedPrivateKey, message);
}
@Transactional
public void logicalDelete(InstitutionKeySubject subject, String keyId, String operator) {
InstitutionSm2KeyEntity key = required(subject, keyId);

@ -22,6 +22,9 @@ public class InstitutionTrainingErrorRecorder {
@Resource
private InstitutionIdentifierTrainingErrorMapper errorMapper;
@Resource
private InstitutionTrainingScoreService scoreService;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int record(long applicationId, String errorType, String operationStep, String providedKeyId,
String errorMessage, String operator) {
@ -56,6 +59,7 @@ public class InstitutionTrainingErrorRecorder {
error.setCreatedAt(LocalDateTime.now());
error.setCreatedBy(limit(operator, 64));
errorMapper.insert(error);
scoreService.recordError(application.getUserId(), errorSequence);
return errorSequence;
}

@ -0,0 +1,117 @@
package com.yau.digitalrmb.institutionidentity.application;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.UUID;
/**
*
*/
@Service
public class InstitutionTrainingScoreService {
public static final String MODULE_NAME = "中国人民银行生成数字货币";
private static final int TRAINING_SERIAL_NUMBER = 3;
private static final double TOTAL_SCORE = 100.0;
private static final int TOTAL_TRAINING_MODULES = 9;
@Resource
private StuModuleScoreDetailsMapper scoreDetailsMapper;
@Value("${institution-training.score.wrong-deduct:0.50}")
private BigDecimal wrongDeduct = new BigDecimal("0.50");
@Transactional
public void recordError(String userId, int errorCount) {
if (userId == null || userId.trim().isEmpty()) {
return;
}
StuModuleScoreDetailsEntity detail = getOrCreate(userId);
detail.setCompletionStatus(String.valueOf(errorCount));
detail.setScoreProject(score(errorCount, progress(detail.getSchedule())));
scoreDetailsMapper.updateById(detail);
}
@Transactional
public void markStepCompleted(String userId, int completedStep) {
if (userId == null || userId.trim().isEmpty()) {
return;
}
StuModuleScoreDetailsEntity detail = getOrCreate(userId);
int errorCount = errorCount(detail.getCompletionStatus());
int normalizedStep = Math.max(0, Math.min(TOTAL_TRAINING_MODULES, completedStep));
double schedule = BigDecimal.valueOf(normalizedStep)
.multiply(BigDecimal.valueOf(TOTAL_SCORE))
.divide(BigDecimal.valueOf(TOTAL_TRAINING_MODULES), 2, RoundingMode.HALF_UP)
.doubleValue();
detail.setCompletionStatus(String.valueOf(errorCount));
detail.setSchedule(schedule);
detail.setScoreProject(score(errorCount, schedule));
scoreDetailsMapper.updateById(detail);
}
@Transactional(readOnly = true)
public StuModuleScoreDetailsEntity detail(String userId) {
if (userId == null || userId.trim().isEmpty()) {
return null;
}
return scoreDetailsMapper.selectOne(new LambdaQueryWrapper<StuModuleScoreDetailsEntity>()
.eq(StuModuleScoreDetailsEntity::getUserId, userId)
.eq(StuModuleScoreDetailsEntity::getMoudule, MODULE_NAME)
.eq(StuModuleScoreDetailsEntity::getSerialNumber, TRAINING_SERIAL_NUMBER)
.last("LIMIT 1"));
}
private StuModuleScoreDetailsEntity getOrCreate(String userId) {
StuModuleScoreDetailsEntity existing = detail(userId);
if (existing != null) {
return existing;
}
StuModuleScoreDetailsEntity created = new StuModuleScoreDetailsEntity();
created.setId(UUID.randomUUID().toString());
created.setMoudule(MODULE_NAME);
created.setLearningProjects("实验实训");
created.setAssessmentItems("三步流程错误次数");
created.setScoringCriteria(wrongDeduct.stripTrailingZeros().toPlainString());
created.setCompletionStatus("0");
created.setScoreProject(0.0);
created.setUserId(userId);
created.setTotalScore(TOTAL_SCORE);
created.setSerialNumber(TRAINING_SERIAL_NUMBER);
created.setSchedule(0.0);
scoreDetailsMapper.insert(created);
return created;
}
private double score(int errorCount, double schedule) {
BigDecimal base = BigDecimal.valueOf(TOTAL_SCORE)
.subtract(wrongDeduct.multiply(BigDecimal.valueOf(Math.max(0, errorCount))))
.max(BigDecimal.ZERO);
return base.multiply(BigDecimal.valueOf(schedule))
.divide(BigDecimal.valueOf(TOTAL_SCORE), 2, RoundingMode.HALF_UP)
.doubleValue();
}
private double progress(Double schedule) {
return schedule == null ? 0.0 : schedule;
}
private int errorCount(String completionStatus) {
if (completionStatus == null || completionStatus.trim().isEmpty()) {
return 0;
}
try {
return Math.max(0, Integer.parseInt(completionStatus));
} catch (NumberFormatException exception) {
return 0;
}
}
}

@ -16,11 +16,13 @@ public class IssuedInstitutionIdentifierQueryService {
@Transactional(readOnly = true)
public IssuedInstitutionIdentifierResult requireIssued(long applicationId, InstitutionKeySubject subject) {
InstitutionIdentifierApplication application = repository.findById(applicationId).orElseThrow(() ->
new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "机构标识申请不存在"));
if (application.getStatus() != InstitutionIdentifierApplication.Status.ISSUED) {
InstitutionIdentifierApplication application = repository.findByIdAndSubject(applicationId,
subject.getUserId(), subject.getSchoolId(), subject.getClassId()).orElseThrow(() ->
new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "当前实训主体下的机构标识申请不存在"));
if (application.getStatus() != InstitutionIdentifierApplication.Status.FEEDBACKED) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"任务一尚未签发机构标识,不能发起货币生成请求,当前状态:" + application.getStatus().name());
"机构标识尚未反馈到商业银行端,不能发起货币生成请求,当前状态:"
+ application.getStatus().name());
}
return new IssuedInstitutionIdentifierResult(applicationId, application.getInstitutionIdentifier(),
application.getDigest());

@ -0,0 +1,48 @@
package com.yau.digitalrmb.institutionidentity.application;
import com.yau.digitalrmb.institutionidentity.domain.TransactionInformationIdentifier;
import java.math.BigDecimal;
public class TransactionInformationIdentifierResult {
private final long id;
private final long verificationId;
private final long requestId;
private final long identifierApplicationId;
private final BigDecimal amount;
private final String institutionIdentifier;
private final String transactionTimestamp;
private final String originalText;
private final String digestAlgorithm;
private final String digest;
private final String transactionIdentifier;
private final String status;
public TransactionInformationIdentifierResult(TransactionInformationIdentifier value) {
this.id = value.getId();
this.verificationId = value.getVerificationId();
this.requestId = value.getRequestId();
this.identifierApplicationId = value.getIdentifierApplicationId();
this.amount = value.getAmount();
this.institutionIdentifier = value.getInstitutionIdentifier();
this.transactionTimestamp = value.getTransactionTimestamp();
this.originalText = value.getOriginalText();
this.digestAlgorithm = value.getDigest() == null ? null : "SM3";
this.digest = value.getDigest();
this.transactionIdentifier = value.getTransactionIdentifier();
this.status = value.getStatus().name();
}
public long getId() { return id; }
public long getVerificationId() { return verificationId; }
public long getRequestId() { return requestId; }
public long getIdentifierApplicationId() { return identifierApplicationId; }
public BigDecimal getAmount() { return amount; }
public String getInstitutionIdentifier() { return institutionIdentifier; }
public String getTransactionTimestamp() { return transactionTimestamp; }
public String getOriginalText() { return originalText; }
public String getDigestAlgorithm() { return digestAlgorithm; }
public String getDigest() { return digest; }
public String getTransactionIdentifier() { return transactionIdentifier; }
public String getStatus() { return status; }
}

@ -0,0 +1,175 @@
package com.yau.digitalrmb.institutionidentity.application;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography;
import com.yau.digitalrmb.institutionidentity.domain.TransactionInformationIdentifier;
import com.yau.digitalrmb.institutionidentity.domain.TransactionInformationIdentifierRepository;
import com.yau.digitalrmb.institutionidentity.infrastructure.CurrencyGenerationRequestEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.CurrencyGenerationRequestMapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.CurrencyGenerationVerificationEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.CurrencyGenerationVerificationMapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierApplicationEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierApplicationMapper;
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 javax.annotation.Resource;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@Service
public class TransactionInformationIdentifierService {
private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
@Resource
private CurrencyGenerationVerificationMapper verificationMapper;
@Resource
private CurrencyGenerationRequestMapper requestMapper;
@Resource
private InstitutionIdentifierApplicationMapper applicationMapper;
@Resource
private TransactionInformationIdentifierRepository repository;
@Resource
private InstitutionIdentityCryptography cryptography;
@Resource
private InstitutionTrainingScoreService scoreService;
private final Clock clock = Clock.systemUTC();
@Transactional
public TransactionInformationIdentifierResult prepare(InstitutionKeySubject subject, String operator) {
CurrencyGenerationVerificationEntity verification = requireCompletedVerification(subject);
CurrencyGenerationRequestEntity request = requireCompletedRequest(verification.getRequestId(), subject);
InstitutionIdentifierApplicationEntity application = requireCompletedIdentifier(
request.getIdentifierApplicationId(), subject);
TransactionInformationIdentifier existing = repository.findLatestByVerificationAndSubject(
verification.getId(), subject.getUserId(), subject.getSchoolId(), subject.getClassId()).orElse(null);
if (existing != null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"当前步骤三结果已生成模块四记录,不能重复准备");
}
String timestamp = LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC).format(TIMESTAMP);
TransactionInformationIdentifier value = invokeResult(() -> TransactionInformationIdentifier.prepare(
verification.getId(), request.getId(), application.getId(), request.getAmount(),
application.getInstitutionIdentifier(), timestamp));
long id = repository.save(value, subject.getUserId(), subject.getSchoolId(), subject.getClassId(), operator);
return new TransactionInformationIdentifierResult(TransactionInformationIdentifier.restore(id,
value.getVerificationId(), value.getRequestId(), value.getIdentifierApplicationId(),
value.getAmount(), value.getInstitutionIdentifier(), value.getTransactionTimestamp(),
value.getOriginalText(), null, null, value.getStatus()));
}
@Transactional
public TransactionInformationIdentifierResult digest(InstitutionKeySubject subject, String operator) {
TransactionInformationIdentifier value = requireStatus(subject,
TransactionInformationIdentifier.Status.PREPARED, "请先获取前序数据并准备交易信息标识");
TransactionInformationIdentifier.Status expected = value.getStatus();
String digest = cryptography.sm3(value.getOriginalText());
invoke(() -> value.recordDigest(digest));
repository.update(value, expected, "DIGEST", "使用SM3计算交易信息标识摘要", operator);
return new TransactionInformationIdentifierResult(value);
}
@Transactional
public TransactionInformationIdentifierResult assemble(InstitutionKeySubject subject, String operator) {
TransactionInformationIdentifier value = requireStatus(subject,
TransactionInformationIdentifier.Status.DIGESTED, "请先使用SM3生成摘要");
TransactionInformationIdentifier.Status expected = value.getStatus();
invoke(value::assemble);
repository.update(value, expected, "ASSEMBLE", "将固定前缀TXN_与SM3摘要组装为交易信息标识", operator);
scoreService.markStepCompleted(subject.getUserId(), 4);
return new TransactionInformationIdentifierResult(value);
}
@Transactional(readOnly = true)
public TransactionInformationIdentifierResult detail(InstitutionKeySubject subject) {
TransactionInformationIdentifier value = repository.findLatestBySubject(subject.getUserId(),
subject.getSchoolId(), subject.getClassId()).orElseThrow(() ->
new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "当前没有交易信息标识记录"));
return new TransactionInformationIdentifierResult(value);
}
private CurrencyGenerationVerificationEntity requireCompletedVerification(InstitutionKeySubject subject) {
CurrencyGenerationVerificationEntity value = verificationMapper.selectOne(
new LambdaQueryWrapper<CurrencyGenerationVerificationEntity>()
.eq(CurrencyGenerationVerificationEntity::getUserId, subject.getUserId())
.eq(CurrencyGenerationVerificationEntity::getSchoolId, subject.getSchoolId())
.eq(CurrencyGenerationVerificationEntity::getClassId, subject.getClassId())
.eq(CurrencyGenerationVerificationEntity::getStatus, "RESPONSE_RETURNED")
.eq(CurrencyGenerationVerificationEntity::getDeleted, false)
.orderByDesc(CurrencyGenerationVerificationEntity::getCreatedAt)
.last("LIMIT 1"));
if (value == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"步骤三尚未完成确认报文返回,不能进入模块四");
}
return value;
}
private CurrencyGenerationRequestEntity requireCompletedRequest(long requestId, InstitutionKeySubject subject) {
CurrencyGenerationRequestEntity value = requestMapper.selectOne(
new LambdaQueryWrapper<CurrencyGenerationRequestEntity>()
.eq(CurrencyGenerationRequestEntity::getId, requestId)
.eq(CurrencyGenerationRequestEntity::getUserId, subject.getUserId())
.eq(CurrencyGenerationRequestEntity::getSchoolId, subject.getSchoolId())
.eq(CurrencyGenerationRequestEntity::getClassId, subject.getClassId())
.eq(CurrencyGenerationRequestEntity::getStatus, "RECEIVED")
.eq(CurrencyGenerationRequestEntity::getDeleted, false));
if (value == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"步骤二尚未完成央行接收,不能进入模块四");
}
return value;
}
private InstitutionIdentifierApplicationEntity requireCompletedIdentifier(long applicationId,
InstitutionKeySubject subject) {
InstitutionIdentifierApplicationEntity value = applicationMapper.selectOne(
new LambdaQueryWrapper<InstitutionIdentifierApplicationEntity>()
.eq(InstitutionIdentifierApplicationEntity::getId, applicationId)
.eq(InstitutionIdentifierApplicationEntity::getUserId, subject.getUserId())
.eq(InstitutionIdentifierApplicationEntity::getSchoolId, subject.getSchoolId())
.eq(InstitutionIdentifierApplicationEntity::getClassId, subject.getClassId())
.eq(InstitutionIdentifierApplicationEntity::getStatus, "FEEDBACKED")
.eq(InstitutionIdentifierApplicationEntity::getDeleted, false));
if (value == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"步骤一尚未完成机构标识反馈,不能进入模块四");
}
return value;
}
private TransactionInformationIdentifier requireStatus(InstitutionKeySubject subject,
TransactionInformationIdentifier.Status status,
String message) {
return repository.findLatestBySubjectAndStatus(subject.getUserId(), subject.getSchoolId(),
subject.getClassId(), status).orElseThrow(() ->
new BusinessException(ErrorCode.VALIDATION_ERROR, message));
}
private static void invoke(Runnable operation) {
try {
operation.run();
} catch (IllegalArgumentException | IllegalStateException exception) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, exception.getMessage());
}
}
private static <T> T invokeResult(Supplier<T> supplier) {
try {
return supplier.get();
} catch (IllegalArgumentException | IllegalStateException exception) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, exception.getMessage());
}
}
private interface Supplier<T> {
T get();
}
}

@ -13,6 +13,7 @@ public class InstitutionIdentifierApplication {
VERIFIED,
VERIFICATION_FAILED,
ISSUED,
FEEDBACKED,
REJECTED
}
@ -103,8 +104,13 @@ public class InstitutionIdentifierApplication {
this.status = Status.ISSUED;
}
public void feedback() {
requireStatus(Status.ISSUED, "请先完成央行签名确权");
this.status = Status.FEEDBACKED;
}
public void reject() {
if (status == Status.ISSUED || status == Status.REJECTED) {
if (status == Status.ISSUED || status == Status.FEEDBACKED || status == Status.REJECTED) {
throw new IllegalStateException("申请状态不允许拒绝");
}
status = Status.REJECTED;

@ -13,6 +13,10 @@ public interface InstitutionIdentifierRepository {
long schoolId,
long classId);
Optional<InstitutionIdentifierApplication> findLatestBySubject(String userId,
long schoolId,
long classId);
Optional<InstitutionIdentifierApplication> findLatestBySubjectAndStatus(String userId,
long schoolId,
long classId,

@ -0,0 +1,109 @@
package com.yau.digitalrmb.institutionidentity.domain;
import java.math.BigDecimal;
/**
*
*/
public class TransactionInformationIdentifier {
public enum Status {
PREPARED, DIGESTED, ASSEMBLED
}
private final Long id;
private final long verificationId;
private final long requestId;
private final long identifierApplicationId;
private final BigDecimal amount;
private final String institutionIdentifier;
private final String transactionTimestamp;
private final String originalText;
private String digest;
private String transactionIdentifier;
private Status status;
private TransactionInformationIdentifier(Long id, long verificationId, long requestId,
long identifierApplicationId, BigDecimal amount,
String institutionIdentifier, String transactionTimestamp,
String originalText, String digest,
String transactionIdentifier, Status status) {
this.id = id;
this.verificationId = verificationId;
this.requestId = requestId;
this.identifierApplicationId = identifierApplicationId;
this.amount = amount;
this.institutionIdentifier = institutionIdentifier;
this.transactionTimestamp = transactionTimestamp;
this.originalText = originalText;
this.digest = digest;
this.transactionIdentifier = transactionIdentifier;
this.status = status;
}
public static TransactionInformationIdentifier prepare(long verificationId, long requestId,
long identifierApplicationId, BigDecimal amount,
String institutionIdentifier,
String transactionTimestamp) {
if (verificationId <= 0 || requestId <= 0 || identifierApplicationId <= 0) {
throw new IllegalArgumentException("前序流程数据不完整,不能生成交易信息标识");
}
if (amount == null || amount.signum() <= 0) {
throw new IllegalArgumentException("前序流程中的货币金额无效");
}
if (institutionIdentifier == null || !institutionIdentifier.matches("ORG_[0-9A-F]{12}")) {
throw new IllegalArgumentException("前序流程中的投放机构标识无效");
}
if (transactionTimestamp == null || !transactionTimestamp.matches("\\d{14}")) {
throw new IllegalArgumentException("交易信息标识时间戳格式不正确");
}
String originalText = amount.setScale(2).toPlainString() + "|" + institutionIdentifier
+ "|" + transactionTimestamp;
return new TransactionInformationIdentifier(null, verificationId, requestId,
identifierApplicationId, amount.setScale(2), institutionIdentifier,
transactionTimestamp, originalText, null, null, Status.PREPARED);
}
public static TransactionInformationIdentifier restore(Long id, long verificationId, long requestId,
long identifierApplicationId, BigDecimal amount,
String institutionIdentifier,
String transactionTimestamp, String originalText,
String digest, String transactionIdentifier,
Status status) {
return new TransactionInformationIdentifier(id, verificationId, requestId,
identifierApplicationId, amount, institutionIdentifier, transactionTimestamp,
originalText, digest, transactionIdentifier, status);
}
public void recordDigest(String digest) {
requireStatus(Status.PREPARED, "请先获取前序流程数据并拼接请求原文");
if (digest == null || !digest.matches("[0-9A-F]{64}")) {
throw new IllegalArgumentException("SM3摘要格式不正确");
}
this.digest = digest;
this.status = Status.DIGESTED;
}
public void assemble() {
requireStatus(Status.DIGESTED, "请先使用SM3生成摘要");
this.transactionIdentifier = "TXN_" + digest;
this.status = Status.ASSEMBLED;
}
private void requireStatus(Status expected, String message) {
if (status != expected) {
throw new IllegalStateException(message + ",当前状态:" + status.name());
}
}
public Long getId() { return id; }
public long getVerificationId() { return verificationId; }
public long getRequestId() { return requestId; }
public long getIdentifierApplicationId() { return identifierApplicationId; }
public BigDecimal getAmount() { return amount; }
public String getInstitutionIdentifier() { return institutionIdentifier; }
public String getTransactionTimestamp() { return transactionTimestamp; }
public String getOriginalText() { return originalText; }
public String getDigest() { return digest; }
public String getTransactionIdentifier() { return transactionIdentifier; }
public Status getStatus() { return status; }
}

@ -0,0 +1,24 @@
package com.yau.digitalrmb.institutionidentity.domain;
import java.util.Optional;
public interface TransactionInformationIdentifierRepository {
long save(TransactionInformationIdentifier value, String userId, long schoolId, long classId, String operator);
Optional<TransactionInformationIdentifier> findByIdAndSubject(long id, String userId,
long schoolId, long classId);
Optional<TransactionInformationIdentifier> findLatestByVerificationAndSubject(long verificationId,
String userId,
long schoolId,
long classId);
Optional<TransactionInformationIdentifier> findLatestBySubject(String userId, long schoolId, long classId);
Optional<TransactionInformationIdentifier> findLatestBySubjectAndStatus(
String userId, long schoolId, long classId, TransactionInformationIdentifier.Status status);
void update(TransactionInformationIdentifier value,
TransactionInformationIdentifier.Status expectedStatus,
String operation, String detail, String operator);
}

@ -3,6 +3,7 @@ package com.yau.digitalrmb.institutionidentity.infrastructure;
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;
@ -21,4 +22,5 @@ public class CurrencyGenerationRequestOperationLogEntity {
@TableField("operation_detail") private String operationDetail;
@TableField("created_at") private LocalDateTime createdAt;
@TableField("created_by") private String createdBy;
@TableLogic private Boolean deleted = false;
}

@ -3,6 +3,7 @@ package com.yau.digitalrmb.institutionidentity.infrastructure;
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;
@ -21,4 +22,5 @@ public class CurrencyGenerationVerificationOperationLogEntity {
@TableField("operation_detail") private String operationDetail;
@TableField("created_at") private LocalDateTime createdAt;
@TableField("created_by") private String createdBy;
@TableLogic private Boolean deleted = false;
}

@ -3,6 +3,7 @@ package com.yau.digitalrmb.institutionidentity.infrastructure;
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;
@ -28,4 +29,6 @@ public class InstitutionIdentifierOperationLogEntity {
private LocalDateTime createdAt;
@TableField("created_by")
private String createdBy;
@TableLogic
private Boolean deleted = false;
}

@ -3,6 +3,7 @@ package com.yau.digitalrmb.institutionidentity.infrastructure;
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;
@ -35,4 +36,6 @@ public class InstitutionIdentifierTrainingErrorEntity {
private LocalDateTime createdAt;
@TableField("created_by")
private String createdBy;
@TableLogic
private Boolean deleted = false;
}

@ -3,6 +3,7 @@ package com.yau.digitalrmb.institutionidentity.infrastructure;
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;
@ -32,4 +33,6 @@ public class InstitutionSm2KeyAuditEntity {
private LocalDateTime createdAt;
@TableField("created_by")
private String createdBy;
@TableLogic
private Boolean deleted = false;
}

@ -65,6 +65,21 @@ public class MybatisInstitutionIdentifierRepository implements InstitutionIdenti
return Optional.ofNullable(entity).map(this::toDomain);
}
@Override
public Optional<InstitutionIdentifierApplication> findLatestBySubject(
String userId, long schoolId, long classId) {
InstitutionIdentifierApplicationEntity entity = mapper.selectOne(
new LambdaQueryWrapper<InstitutionIdentifierApplicationEntity>()
.eq(InstitutionIdentifierApplicationEntity::getUserId, userId)
.eq(InstitutionIdentifierApplicationEntity::getSchoolId, schoolId)
.eq(InstitutionIdentifierApplicationEntity::getClassId, classId)
.eq(InstitutionIdentifierApplicationEntity::getDeleted, false)
.orderByDesc(InstitutionIdentifierApplicationEntity::getTrainingRound)
.orderByDesc(InstitutionIdentifierApplicationEntity::getCreatedAt)
.last("LIMIT 1"));
return Optional.ofNullable(entity).map(this::toDomain);
}
@Override
public Optional<InstitutionIdentifierApplication> findLatestBySubjectAndStatus(
String userId, long schoolId, long classId, InstitutionIdentifierApplication.Status status) {

@ -0,0 +1,152 @@
package com.yau.digitalrmb.institutionidentity.infrastructure;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.yau.digitalrmb.institutionidentity.domain.TransactionInformationIdentifier;
import com.yau.digitalrmb.institutionidentity.domain.TransactionInformationIdentifierRepository;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import org.springframework.stereotype.Repository;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.Optional;
@Repository
public class MybatisTransactionInformationIdentifierRepository
implements TransactionInformationIdentifierRepository {
@Resource
private TransactionInformationIdentifierMapper mapper;
@Resource
private TransactionInformationIdentifierOperationLogMapper logMapper;
@Override
public long save(TransactionInformationIdentifier value, String userId,
long schoolId, long classId, String operator) {
TransactionInformationIdentifierEntity entity = toEntity(value);
entity.setUserId(userId);
entity.setSchoolId(schoolId);
entity.setClassId(classId);
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
entity.setCreatedBy(operator);
entity.setUpdatedBy(operator);
entity.setDeleted(false);
mapper.insert(entity);
insertLog(entity.getId(), "PREPARE", null, value.getStatus().name(),
"从已完成的步骤一至步骤三复制金额和机构标识并生成交易时间戳", operator);
return entity.getId();
}
@Override
public Optional<TransactionInformationIdentifier> findByIdAndSubject(long id, String userId,
long schoolId, long classId) {
return Optional.ofNullable(mapper.selectOne(new LambdaQueryWrapper<TransactionInformationIdentifierEntity>()
.eq(TransactionInformationIdentifierEntity::getId, id)
.eq(TransactionInformationIdentifierEntity::getUserId, userId)
.eq(TransactionInformationIdentifierEntity::getSchoolId, schoolId)
.eq(TransactionInformationIdentifierEntity::getClassId, classId)
.eq(TransactionInformationIdentifierEntity::getDeleted, false)))
.map(this::toDomain);
}
@Override
public Optional<TransactionInformationIdentifier> findLatestByVerificationAndSubject(
long verificationId, String userId, long schoolId, long classId) {
return Optional.ofNullable(mapper.selectOne(new LambdaQueryWrapper<TransactionInformationIdentifierEntity>()
.eq(TransactionInformationIdentifierEntity::getVerificationId, verificationId)
.eq(TransactionInformationIdentifierEntity::getUserId, userId)
.eq(TransactionInformationIdentifierEntity::getSchoolId, schoolId)
.eq(TransactionInformationIdentifierEntity::getClassId, classId)
.eq(TransactionInformationIdentifierEntity::getDeleted, false)
.orderByDesc(TransactionInformationIdentifierEntity::getCreatedAt)
.last("LIMIT 1")))
.map(this::toDomain);
}
@Override
public Optional<TransactionInformationIdentifier> findLatestBySubject(String userId, long schoolId, long classId) {
return Optional.ofNullable(mapper.selectOne(new LambdaQueryWrapper<TransactionInformationIdentifierEntity>()
.eq(TransactionInformationIdentifierEntity::getUserId, userId)
.eq(TransactionInformationIdentifierEntity::getSchoolId, schoolId)
.eq(TransactionInformationIdentifierEntity::getClassId, classId)
.eq(TransactionInformationIdentifierEntity::getDeleted, false)
.orderByDesc(TransactionInformationIdentifierEntity::getCreatedAt)
.last("LIMIT 1")))
.map(this::toDomain);
}
@Override
public Optional<TransactionInformationIdentifier> findLatestBySubjectAndStatus(
String userId, long schoolId, long classId, TransactionInformationIdentifier.Status status) {
return Optional.ofNullable(mapper.selectOne(new LambdaQueryWrapper<TransactionInformationIdentifierEntity>()
.eq(TransactionInformationIdentifierEntity::getUserId, userId)
.eq(TransactionInformationIdentifierEntity::getSchoolId, schoolId)
.eq(TransactionInformationIdentifierEntity::getClassId, classId)
.eq(TransactionInformationIdentifierEntity::getStatus, status.name())
.eq(TransactionInformationIdentifierEntity::getDeleted, false)
.orderByDesc(TransactionInformationIdentifierEntity::getCreatedAt)
.last("LIMIT 1")))
.map(this::toDomain);
}
@Override
public void update(TransactionInformationIdentifier value,
TransactionInformationIdentifier.Status expectedStatus,
String operation, String detail, String operator) {
LambdaUpdateWrapper<TransactionInformationIdentifierEntity> update =
new LambdaUpdateWrapper<TransactionInformationIdentifierEntity>()
.eq(TransactionInformationIdentifierEntity::getId, value.getId())
.eq(TransactionInformationIdentifierEntity::getStatus, expectedStatus.name())
.eq(TransactionInformationIdentifierEntity::getDeleted, false)
.set(TransactionInformationIdentifierEntity::getDigest, value.getDigest())
.set(TransactionInformationIdentifierEntity::getTransactionIdentifier,
value.getTransactionIdentifier())
.set(TransactionInformationIdentifierEntity::getStatus, value.getStatus().name())
.set(TransactionInformationIdentifierEntity::getUpdatedAt, LocalDateTime.now())
.set(TransactionInformationIdentifierEntity::getUpdatedBy, operator);
if (mapper.update(null, update) != 1) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"交易信息标识状态已变化,不能重复或越级操作");
}
insertLog(value.getId(), operation, expectedStatus.name(), value.getStatus().name(), detail, operator);
}
private TransactionInformationIdentifierEntity toEntity(TransactionInformationIdentifier value) {
TransactionInformationIdentifierEntity entity = new TransactionInformationIdentifierEntity();
entity.setId(value.getId());
entity.setVerificationId(value.getVerificationId());
entity.setRequestId(value.getRequestId());
entity.setIdentifierApplicationId(value.getIdentifierApplicationId());
entity.setAmount(value.getAmount());
entity.setInstitutionIdentifier(value.getInstitutionIdentifier());
entity.setTransactionTimestamp(value.getTransactionTimestamp());
entity.setOriginalText(value.getOriginalText());
entity.setDigest(value.getDigest());
entity.setTransactionIdentifier(value.getTransactionIdentifier());
entity.setStatus(value.getStatus().name());
return entity;
}
private TransactionInformationIdentifier toDomain(TransactionInformationIdentifierEntity entity) {
return TransactionInformationIdentifier.restore(entity.getId(), entity.getVerificationId(),
entity.getRequestId(), entity.getIdentifierApplicationId(), entity.getAmount(),
entity.getInstitutionIdentifier(), entity.getTransactionTimestamp(), entity.getOriginalText(),
entity.getDigest(), entity.getTransactionIdentifier(),
TransactionInformationIdentifier.Status.valueOf(entity.getStatus()));
}
private void insertLog(Long id, String operation, String fromStatus, String toStatus,
String detail, String operator) {
TransactionInformationIdentifierOperationLogEntity log =
new TransactionInformationIdentifierOperationLogEntity();
log.setTransactionIdentifierId(id);
log.setOperation(operation);
log.setFromStatus(fromStatus);
log.setToStatus(toStatus);
log.setOperationDetail(detail);
log.setCreatedAt(LocalDateTime.now());
log.setCreatedBy(operator);
logMapper.insert(log);
}
}

@ -0,0 +1,44 @@
package com.yau.digitalrmb.institutionidentity.infrastructure;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@TableName("stu_module_score_details")
public class StuModuleScoreDetailsEntity {
@TableId(type = IdType.ASSIGN_UUID)
private String id;
private String moudule;
@TableField("learning_projects")
private String learningProjects;
@TableField("assessment_items")
private String assessmentItems;
@TableField("scoring_criteria")
private String scoringCriteria;
@TableField("completion_status")
private String completionStatus;
@TableField("score_project")
private Double scoreProject;
@TableField("user_id")
private String userId;
@TableField("total_score")
private Double totalScore;
@TableField("serial_number")
private Integer serialNumber;
private Double schedule;
}

@ -0,0 +1,8 @@
package com.yau.digitalrmb.institutionidentity.infrastructure;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface StuModuleScoreDetailsMapper extends BaseMapper<StuModuleScoreDetailsEntity> {
}

@ -0,0 +1,28 @@
package com.yau.digitalrmb.institutionidentity.infrastructure;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.yau.digitalrmb.shared.infrastructure.persistence.AuditableEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@TableName("transaction_information_identifier")
public class TransactionInformationIdentifierEntity extends AuditableEntity {
@TableField("verification_id") private Long verificationId;
@TableField("request_id") private Long requestId;
@TableField("identifier_application_id") private Long identifierApplicationId;
private BigDecimal amount;
@TableField("institution_identifier") private String institutionIdentifier;
@TableField("transaction_timestamp") private String transactionTimestamp;
@TableField("original_text") private String originalText;
private String digest;
@TableField("transaction_identifier") private String transactionIdentifier;
private String status;
@TableField("user_id") private String userId;
@TableField("school_id") private Long schoolId;
@TableField("class_id") private Long classId;
}

@ -0,0 +1,8 @@
package com.yau.digitalrmb.institutionidentity.infrastructure;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface TransactionInformationIdentifierMapper extends BaseMapper<TransactionInformationIdentifierEntity> {
}

@ -0,0 +1,28 @@
package com.yau.digitalrmb.institutionidentity.infrastructure;
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.time.LocalDateTime;
@Getter
@Setter
@TableName("transaction_information_identifier_operation_log")
public class TransactionInformationIdentifierOperationLogEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@TableField("transaction_identifier_id") private Long transactionIdentifierId;
private String operation;
@TableField("from_status") private String fromStatus;
@TableField("to_status") private String toStatus;
@TableField("operation_detail") private String operationDetail;
@TableField("created_at") private LocalDateTime createdAt;
@TableField("created_by") private String createdBy;
@TableLogic
private Boolean deleted = false;
}

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

@ -1,11 +1,8 @@
package com.yau.digitalrmb.institutionidentity.interfaces.dto;
import javax.validation.constraints.NotBlank;
public class ConfirmInstitutionIdentifierRequest {
@NotBlank(message = "签名密钥不能为空")
private String signingKeyId;
private String privateKey;
public String getSigningKeyId() { return signingKeyId; }
public void setSigningKeyId(String signingKeyId) { this.signingKeyId = signingKeyId; }
public String getPrivateKey() { return privateKey; }
public void setPrivateKey(String privateKey) { this.privateKey = privateKey; }
}

@ -1,11 +1,8 @@
package com.yau.digitalrmb.institutionidentity.interfaces.dto;
import javax.validation.constraints.Size;
public class InstitutionIdentifierStepRequest {
@Size(max = 64, message = "密钥标识长度不能超过64个字符")
private String keyId;
private String privateKey;
public String getKeyId() { return keyId; }
public void setKeyId(String keyId) { this.keyId = keyId; }
public String getPrivateKey() { return privateKey; }
public void setPrivateKey(String privateKey) { this.privateKey = privateKey; }
}

@ -3,9 +3,9 @@ package com.yau.digitalrmb.institutionidentity.interfaces.dto;
import javax.validation.constraints.NotBlank;
public class VerifyInstitutionApplicationRequest {
@NotBlank(message = "验证密钥不能为空")
private String verificationKeyId;
@NotBlank(message = "验证公钥编号不能为空")
private String keyId;
public String getVerificationKeyId() { return verificationKeyId; }
public void setVerificationKeyId(String verificationKeyId) { this.verificationKeyId = verificationKeyId; }
public String getKeyId() { return keyId; }
public void setKeyId(String keyId) { this.keyId = keyId; }
}

@ -31,7 +31,7 @@ public class CurrencyGenerationRequestController {
@PostMapping("/steps/prepare")
@Operation(summary = "准备请求信息",
description = "任务一必须已ISSUED;后端自动复制简洁和完整机构标识,前端不能覆盖。")
description = "任务一必须已完成信息反馈;后端自动复制简洁和完整机构标识,前端不能覆盖。")
public ApiResponse<CurrencyRequestResult> prepare(
@Valid @RequestBody PrepareCurrencyRequest request) {
JwtUser jwtUser = AuthContextHolder.get();

@ -5,6 +5,9 @@ import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService;
import com.yau.digitalrmb.institutionidentity.application.InstitutionApplicationVerificationResult;
import com.yau.digitalrmb.institutionidentity.application.InstitutionApplicationVerificationService;
import com.yau.digitalrmb.institutionidentity.application.InstitutionIdentifierConfirmationResult;
import com.yau.digitalrmb.institutionidentity.application.InstitutionIdentifierFeedbackService;
import com.yau.digitalrmb.institutionidentity.application.InstitutionIdentifierFinalResult;
import com.yau.digitalrmb.institutionidentity.application.CommercialBankInstitutionIdentifierResult;
import com.yau.digitalrmb.institutionidentity.application.InstitutionInformationPreparationResult;
import com.yau.digitalrmb.institutionidentity.application.InstitutionDigestResult;
import com.yau.digitalrmb.institutionidentity.application.InstitutionSignatureResult;
@ -13,8 +16,8 @@ import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyPairResu
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.institutionidentity.interfaces.dto.GenerateInstitutionIdentifierRequest;
import com.yau.digitalrmb.institutionidentity.interfaces.dto.InstitutionIdentifierStepRequest;
import com.yau.digitalrmb.institutionidentity.interfaces.dto.VerifyInstitutionApplicationRequest;
import com.yau.digitalrmb.institutionidentity.interfaces.dto.ConfirmInstitutionIdentifierRequest;
import com.yau.digitalrmb.institutionidentity.interfaces.dto.VerifyInstitutionApplicationRequest;
import com.yau.digitalrmb.security.context.AuthContextHolder;
import com.yau.digitalrmb.security.context.JwtUser;
import com.yau.digitalrmb.shared.api.ApiResponse;
@ -44,6 +47,9 @@ public class InstitutionIdentifierController {
@Resource
private InstitutionApplicationVerificationService verificationService;
@Resource
private InstitutionIdentifierFeedbackService feedbackService;
@PostMapping("/keys/commercial-bank")
@Operation(summary = "获取商业银行SM2密钥对",
description = "按Token中的userId、schoolId、classId生成或读取商业银行密钥对私钥加密保存。")
@ -97,7 +103,7 @@ public class InstitutionIdentifierController {
public ApiResponse<InstitutionSignatureResult> sign(
@Valid @RequestBody InstitutionIdentifierStepRequest request) {
JwtUser jwtUser = AuthContextHolder.get();
return ApiResponse.success(stepService.sign(request.getKeyId(),
return ApiResponse.success(stepService.sign(request.getPrivateKey(),
InstitutionKeySubject.from(jwtUser), jwtUser.getUsername()), traceId());
}
@ -109,23 +115,50 @@ public class InstitutionIdentifierController {
jwtUser.getUsername()), traceId());
}
@PostMapping("/applications/verification")
@Operation(summary = "查看并保存机构标识申请验证过程", description = "仅PACKAGED状态可操作保存SM2验签和SM3完整性验证结果。")
@PostMapping({"/applications/decryption-verification", "/applications/verification"})
@Operation(summary = "央行解密验证机构标识申请",
description = "仅PACKAGED状态可操作必须选择商业银行第二公钥执行SM2验签并重算SM3摘要验证身份真实性和数据完整性。旧verification路径保留兼容。")
public ApiResponse<InstitutionApplicationVerificationResult> verifyApplication(
@Valid @RequestBody VerifyInstitutionApplicationRequest request) {
JwtUser jwtUser = AuthContextHolder.get();
return ApiResponse.success(verificationService.verify(request.getVerificationKeyId(),
return ApiResponse.success(verificationService.verify(request.getKeyId(),
InstitutionKeySubject.from(jwtUser), jwtUser.getUsername()), traceId());
}
@PostMapping("/applications/confirmation")
@Operation(summary = "央行签名确权并组装机构标识", description = "仅VERIFIED状态可操作保存央行签名和机构标识并进入ISSUED状态。")
@Operation(summary = "央行签名确权并组装机构标识",
description = "仅VERIFIED状态可操作privateKey可选传入时校验央行第一私钥不传时直接完成确权。")
public ApiResponse<InstitutionIdentifierConfirmationResult> confirmIdentifier(
@Valid @RequestBody ConfirmInstitutionIdentifierRequest request) {
@Valid @RequestBody(required = false) ConfirmInstitutionIdentifierRequest request) {
JwtUser jwtUser = AuthContextHolder.get();
return ApiResponse.success(verificationService.confirm(request == null ? null : request.getPrivateKey(),
InstitutionKeySubject.from(jwtUser), jwtUser.getUsername()), traceId());
}
@GetMapping("/applications/final-identifier")
@Operation(summary = "央行查看最终机构标识",
description = "央行签名确权后即可查看是否已反馈通过feedbackStatus区分。")
public ApiResponse<InstitutionIdentifierFinalResult> finalIdentifier() {
return ApiResponse.success(feedbackService.finalIdentifier(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
}
@PostMapping("/applications/feedback")
@Operation(summary = "央行信息反馈",
description = "将已确权的最终机构标识同步到商业银行端商业银行状态由1变为2。")
public ApiResponse<InstitutionIdentifierFinalResult> feedback() {
JwtUser jwtUser = AuthContextHolder.get();
return ApiResponse.success(verificationService.confirm(request.getSigningKeyId(),
return ApiResponse.success(feedbackService.feedback(
InstitutionKeySubject.from(jwtUser), jwtUser.getUsername()), traceId());
}
@GetMapping("/applications/commercial-bank")
@Operation(summary = "商业银行查看机构标识反馈状态",
description = "商业银行第二私钥签名成功后返回状态1待确认央行信息反馈后返回状态2及机构标识数据。")
public ApiResponse<CommercialBankInstitutionIdentifierResult> commercialBankResult() {
return ApiResponse.success(feedbackService.commercialBankResult(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
}
private String traceId() { return MDC.get(TraceIdFilter.MDC_KEY); }
}

@ -0,0 +1,59 @@
package com.yau.digitalrmb.institutionidentity.interfaces.rest;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.institutionidentity.application.TransactionInformationIdentifierResult;
import com.yau.digitalrmb.institutionidentity.application.TransactionInformationIdentifierService;
import com.yau.digitalrmb.security.context.AuthContextHolder;
import com.yau.digitalrmb.security.context.JwtUser;
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.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
@RestController
@RequestMapping("/api/v1/institution-identifiers/transaction-information-identifiers")
@Tag(name = "模块四:生成交易信息标识")
public class TransactionInformationIdentifierController {
@Resource
private TransactionInformationIdentifierService service;
@PostMapping("/steps/prepare")
@Operation(summary = "获取前序数据并拼接交易信息原文",
description = "步骤一必须FEEDBACKED、步骤二必须RECEIVED、步骤三必须RESPONSE_RETURNED金额和机构标识由后端自动流转。")
public ApiResponse<TransactionInformationIdentifierResult> prepare() {
JwtUser user = AuthContextHolder.get();
return ok(service.prepare(InstitutionKeySubject.from(user), user.getUsername()));
}
@PostMapping("/steps/digest")
@Operation(summary = "使用SM3生成交易信息摘要")
public ApiResponse<TransactionInformationIdentifierResult> digest() {
JwtUser user = AuthContextHolder.get();
return ok(service.digest(InstitutionKeySubject.from(user), user.getUsername()));
}
@PostMapping("/steps/assemble")
@Operation(summary = "组装最终交易信息标识",
description = "将固定前缀TXN_与已保存的SM3摘要拼接结果写入数据库供后续模块读取。")
public ApiResponse<TransactionInformationIdentifierResult> assemble() {
JwtUser user = AuthContextHolder.get();
return ok(service.assemble(InstitutionKeySubject.from(user), user.getUsername()));
}
@GetMapping
@Operation(summary = "查询当前用户最新交易信息标识记录")
public ApiResponse<TransactionInformationIdentifierResult> detail() {
return ok(service.detail(InstitutionKeySubject.from(AuthContextHolder.get())));
}
private ApiResponse<TransactionInformationIdentifierResult> ok(TransactionInformationIdentifierResult result) {
return ApiResponse.success(result, MDC.get(TraceIdFilter.MDC_KEY));
}
}

@ -0,0 +1,11 @@
package com.yau.digitalrmb.platformintegration.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConditionalOnProperty(prefix = "platform-integration", name = "enabled", havingValue = "true")
@EnableConfigurationProperties(PlatformIntegrationProperties.class)
public class PlatformIntegrationConfig {
}

@ -52,7 +52,8 @@ public class SecurityConfig {
.authorizeRequests(authorize -> authorize
.antMatchers("/actuator/health", "/api/v1/auth/login", "/api/v1/auth/sso",
"/api/user/login",
"/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
"/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html",
"/doc.html", "/webjars/**", "/swagger-resources/**")
.permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(jwt -> { }))

@ -2,15 +2,22 @@ package com.yau.digitalrmb.shared.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
private static final String AUTHORIZATION = "Authorization";
@Bean
OpenAPI openAPI() {
return new OpenAPI().info(new Info().title("数字人民币教学仿真后端").version("0.1.0"))
.schemaRequirement("bearerAuth", new SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT"));
.addSecurityItem(new SecurityRequirement().addList(AUTHORIZATION))
.schemaRequirement(AUTHORIZATION, new SecurityScheme()
.type(SecurityScheme.Type.APIKEY)
.in(SecurityScheme.In.HEADER)
.name(AUTHORIZATION));
}
}

@ -18,13 +18,13 @@ public class SwaggerStartupLogger {
@EventListener(ApplicationReadyEvent.class)
public void printSwaggerAddresses() {
if (!environment.getProperty("springdoc.swagger-ui.enabled", Boolean.class, false)) {
if (!environment.getProperty("knife4j.enable", Boolean.class, false)) {
return;
}
String port = environment.getProperty("local.server.port", environment.getProperty("server.port", "8080"));
String contextPath = environment.getProperty("server.servlet.context-path", "");
String baseUrl = "http://localhost:" + port + contextPath;
LOGGER.info("Swagger UI: {}/swagger-ui/index.html", baseUrl);
LOGGER.info("Knife4j UI: {}/doc.html", baseUrl);
LOGGER.info("OpenAPI JSON: {}/v3/api-docs", baseUrl);
}
}

@ -10,6 +10,8 @@ spring:
springdoc:
swagger-ui:
enabled: true
knife4j:
enable: true
platform-integration:
enabled: true
token:

@ -25,10 +25,15 @@ management:
springdoc:
swagger-ui:
enabled: false
knife4j:
enable: false
platform-integration:
token:
link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY}
frontend:
callback-url: ${DIGITAL_RMB_FRONTEND_CALLBACK_URL}
callback-url: ${DIGITAL_RMB_FRONTEND_CALLBACK_URL:http://localhost:5173/sso-callback}
institution-training:
score:
wrong-deduct: ${INSTITUTION_TRAINING_WRONG_DEDUCT:0.50}
server:
port: 8787
port: 2318

@ -39,6 +39,7 @@ CREATE TABLE IF NOT EXISTS institution_identifier_operation_log (
operation_detail VARCHAR(1024) NOT NULL,
created_at TIMESTAMP NOT NULL,
created_by VARCHAR(64) NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_identifier_operation_application
FOREIGN KEY (application_id) REFERENCES institution_identifier_application(id)
);
@ -58,6 +59,7 @@ CREATE TABLE IF NOT EXISTS institution_identifier_training_error (
error_message VARCHAR(512) NOT NULL,
created_at TIMESTAMP NOT NULL,
created_by VARCHAR(64) NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT uk_identifier_training_error UNIQUE (application_id, error_sequence),
CONSTRAINT fk_identifier_error_application
FOREIGN KEY (application_id) REFERENCES institution_identifier_application(id)
@ -98,6 +100,7 @@ CREATE TABLE IF NOT EXISTS institution_sm2_key_audit (
operation_detail VARCHAR(512) NOT NULL,
created_at TIMESTAMP NOT NULL,
created_by VARCHAR(64) NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_institution_key_audit_key
FOREIGN KEY (key_record_id) REFERENCES institution_sm2_key(id)
);
@ -146,6 +149,7 @@ CREATE TABLE IF NOT EXISTS currency_generation_request_operation_log (
operation_detail VARCHAR(1024) NOT NULL,
created_at TIMESTAMP NOT NULL,
created_by VARCHAR(64) NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_currency_request_operation_request
FOREIGN KEY (request_id) REFERENCES currency_generation_request(id)
);
@ -198,9 +202,79 @@ CREATE TABLE IF NOT EXISTS currency_generation_verification_operation_log (
operation_detail VARCHAR(1024) NOT NULL,
created_at TIMESTAMP NOT NULL,
created_by VARCHAR(64) NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_currency_verification_operation
FOREIGN KEY (verification_id) REFERENCES currency_generation_verification(id)
);
CREATE INDEX IF NOT EXISTS idx_currency_verification_operation
ON currency_generation_verification_operation_log(verification_id, created_at);
CREATE TABLE IF NOT EXISTS stu_module_score_details (
id VARCHAR(64) NOT NULL,
moudule VARCHAR(100) NOT NULL,
learning_projects VARCHAR(100),
assessment_items VARCHAR(100),
scoring_criteria VARCHAR(100),
completion_status VARCHAR(100),
score_project DOUBLE,
user_id VARCHAR(64) NOT NULL,
total_score DECIMAL(10, 2) NOT NULL DEFAULT 100.00,
serial_number INT,
schedule DOUBLE,
PRIMARY KEY (id),
CONSTRAINT uk_user_moudule_serial UNIQUE (user_id, moudule, serial_number)
);
CREATE INDEX IF NOT EXISTS idx_user_moudule
ON stu_module_score_details(user_id, moudule);
CREATE TABLE IF NOT EXISTS transaction_information_identifier (
id BIGINT PRIMARY KEY,
verification_id BIGINT NOT NULL,
request_id BIGINT NOT NULL,
identifier_application_id BIGINT NOT NULL,
amount DECIMAL(18, 2) NOT NULL,
institution_identifier VARCHAR(32) NOT NULL,
transaction_timestamp CHAR(14) NOT NULL,
original_text VARCHAR(256) NOT NULL,
digest CHAR(64),
transaction_identifier VARCHAR(80),
status VARCHAR(32) NOT NULL,
user_id VARCHAR(36) NOT NULL,
school_id BIGINT NOT NULL,
class_id BIGINT NOT 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,
CONSTRAINT uk_transaction_identifier_verification UNIQUE
(verification_id, user_id, school_id, class_id),
CONSTRAINT fk_transaction_identifier_verification FOREIGN KEY (verification_id)
REFERENCES currency_generation_verification(id),
CONSTRAINT fk_transaction_identifier_request FOREIGN KEY (request_id)
REFERENCES currency_generation_request(id),
CONSTRAINT fk_transaction_identifier_application FOREIGN KEY (identifier_application_id)
REFERENCES institution_identifier_application(id)
);
CREATE INDEX IF NOT EXISTS idx_transaction_identifier_subject
ON transaction_information_identifier(user_id, school_id, class_id, status, deleted);
CREATE TABLE IF NOT EXISTS transaction_information_identifier_operation_log (
id BIGINT PRIMARY KEY,
transaction_identifier_id BIGINT NOT NULL,
operation VARCHAR(32) NOT NULL,
from_status VARCHAR(32),
to_status VARCHAR(32) NOT NULL,
operation_detail VARCHAR(1024) NOT NULL,
created_at TIMESTAMP NOT NULL,
created_by VARCHAR(64) NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_transaction_identifier_operation FOREIGN KEY (transaction_identifier_id)
REFERENCES transaction_information_identifier(id)
);
CREATE INDEX IF NOT EXISTS idx_transaction_identifier_operation
ON transaction_information_identifier_operation_log(transaction_identifier_id, created_at);

@ -0,0 +1,23 @@
-- 为中国人民银行生成数字货币实验的过程记录表补齐逻辑删除字段。
-- 已存在的历史记录默认视为未删除。
-- 本脚本为一次性升级脚本,请勿重复执行。
ALTER TABLE institution_identifier_operation_log
ADD COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0,
ADD INDEX idx_identifier_operation_application_deleted (application_id, deleted);
ALTER TABLE institution_identifier_training_error
ADD COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0,
ADD INDEX idx_identifier_error_application_deleted (application_id, deleted);
ALTER TABLE institution_sm2_key_audit
ADD COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0,
ADD INDEX idx_institution_key_audit_record_deleted (key_record_id, deleted);
ALTER TABLE currency_generation_request_operation_log
ADD COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0,
ADD INDEX idx_currency_request_operation_deleted (request_id, deleted);
ALTER TABLE currency_generation_verification_operation_log
ADD COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0,
ADD INDEX idx_currency_verification_operation_deleted (verification_id, deleted);

@ -0,0 +1,49 @@
-- 模块四“生成交易信息标识”一次性 MySQL 升级脚本。
-- 适用于已经存在步骤一至步骤三业务表的数据库;执行前请先备份。
CREATE TABLE transaction_information_identifier (
id BIGINT PRIMARY KEY,
verification_id BIGINT NOT NULL,
request_id BIGINT NOT NULL,
identifier_application_id BIGINT NOT NULL,
amount DECIMAL(18, 2) NOT NULL,
institution_identifier VARCHAR(32) NOT NULL,
transaction_timestamp CHAR(14) NOT NULL,
original_text VARCHAR(256) NOT NULL,
digest CHAR(64),
transaction_identifier VARCHAR(80),
status VARCHAR(32) NOT NULL,
user_id VARCHAR(36) NOT NULL,
school_id BIGINT NOT NULL,
class_id BIGINT NOT 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,
CONSTRAINT uk_transaction_identifier_verification UNIQUE
(verification_id, user_id, school_id, class_id),
CONSTRAINT fk_transaction_identifier_verification FOREIGN KEY (verification_id)
REFERENCES currency_generation_verification(id),
CONSTRAINT fk_transaction_identifier_request FOREIGN KEY (request_id)
REFERENCES currency_generation_request(id),
CONSTRAINT fk_transaction_identifier_application FOREIGN KEY (identifier_application_id)
REFERENCES institution_identifier_application(id),
INDEX idx_transaction_identifier_subject
(user_id, school_id, class_id, status, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块四交易信息标识';
CREATE TABLE transaction_information_identifier_operation_log (
id BIGINT PRIMARY KEY,
transaction_identifier_id BIGINT NOT NULL,
operation VARCHAR(32) NOT NULL,
from_status VARCHAR(32),
to_status VARCHAR(32) NOT NULL,
operation_detail VARCHAR(1024) NOT NULL,
created_at TIMESTAMP NOT NULL,
created_by VARCHAR(64) NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_transaction_identifier_operation FOREIGN KEY (transaction_identifier_id)
REFERENCES transaction_information_identifier(id),
INDEX idx_transaction_identifier_operation (transaction_identifier_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块四交易信息标识操作日志';

@ -50,6 +50,22 @@ CREATE TABLE IF NOT EXISTS auth_refresh_token (
expires_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP NULL
);
--
-- CREATE TABLE IF NOT EXISTS stu_module_score_details (
-- id VARCHAR(64) NOT NULL,
-- moudule VARCHAR(100) NOT NULL,
-- learning_projects VARCHAR(100),
-- assessment_items VARCHAR(100),
-- scoring_criteria VARCHAR(100),
-- completion_status VARCHAR(100),
-- score_project DOUBLE,
-- user_id VARCHAR(64) NOT NULL,
-- total_score DECIMAL(10, 2) NOT NULL DEFAULT 100.00,
-- serial_number INT,
-- schedule DOUBLE,
-- PRIMARY KEY (id),
-- CONSTRAINT uk_user_moudule_serial UNIQUE (user_id, moudule, serial_number)
-- );
CREATE TABLE IF NOT EXISTS institution_sm2_key (
id BIGINT PRIMARY KEY,
@ -80,6 +96,7 @@ CREATE TABLE IF NOT EXISTS institution_sm2_key_audit (
operation_detail VARCHAR(512) NOT NULL,
created_at TIMESTAMP NOT NULL,
created_by VARCHAR(64) NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_institution_key_audit_key
FOREIGN KEY (key_record_id) REFERENCES institution_sm2_key(id)
);

@ -12,6 +12,8 @@ import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifi
import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionPrivateKeyCipher;
import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionSm2KeyEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionSm2KeyMapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import com.yau.digitalrmb.security.application.JwtTokenService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -64,6 +66,9 @@ class InstitutionIdentifierControllerTest {
@Autowired
private InstitutionKeyService keyService;
@Autowired
private StuModuleScoreDetailsMapper scoreDetailsMapper;
@Autowired
private JdbcTemplate jdbcTemplate;
@ -73,11 +78,16 @@ class InstitutionIdentifierControllerTest {
@BeforeEach
void setUp() {
trainingErrorMapper.delete(null);
operationLogMapper.delete(null);
applicationMapper.delete(null);
jdbcTemplate.update("DELETE FROM currency_generation_verification_operation_log");
jdbcTemplate.update("DELETE FROM currency_generation_verification");
jdbcTemplate.update("DELETE FROM currency_generation_request_operation_log");
jdbcTemplate.update("DELETE FROM currency_generation_request");
jdbcTemplate.update("DELETE FROM institution_identifier_training_error");
jdbcTemplate.update("DELETE FROM institution_identifier_operation_log");
jdbcTemplate.update("DELETE FROM institution_identifier_application");
jdbcTemplate.update("DELETE FROM institution_sm2_key_audit");
jdbcTemplate.update("DELETE FROM institution_sm2_key");
jdbcTemplate.update("DELETE FROM stu_module_score_details");
keyAuthorization = "Bearer " + jwtTokenService.issueFor(USER_ID, 1001L, 2001L,
"institution-key-tester", Collections.singleton("STUDENT")).accessToken();
authorization = keyAuthorization;
@ -119,6 +129,109 @@ class InstitutionIdentifierControllerTest {
assertThat(privateKeyCipher.decrypt(bankKey.getEncryptedPrivateKey())).isEqualTo(returnedPrivateKey);
}
@Test
void allInstitutionTrainingTablesHaveLogicalDeleteAndMapperDeleteHidesRows() throws Exception {
String[] tables = {
"institution_identifier_application",
"institution_identifier_operation_log",
"institution_identifier_training_error",
"institution_sm2_key",
"institution_sm2_key_audit",
"currency_generation_request",
"currency_generation_request_operation_log",
"currency_generation_verification",
"currency_generation_verification_operation_log"
};
for (String table : tables) {
Integer columnCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
+ "WHERE LOWER(TABLE_NAME) = ? AND LOWER(COLUMN_NAME) = 'deleted'",
Integer.class, table);
assertThat(columnCount).as(table + " must have deleted column").isEqualTo(1);
}
mvc.perform(post(BASE_PATH + "/steps/digest")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"bankCode\":\"BKCHCNBJ20100\"}"))
.andExpect(status().isOk());
mvc.perform(post(BASE_PATH + "/steps/sign")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"privateKey\":\"WRONG\"}"))
.andExpect(status().isBadRequest());
InstitutionIdentifierTrainingErrorEntity error = trainingErrorMapper.selectList(null).get(0);
assertThat(trainingErrorMapper.deleteById(error.getId())).isEqualTo(1);
assertThat(trainingErrorMapper.selectById(error.getId())).isNull();
Boolean deleted = jdbcTemplate.queryForObject(
"SELECT deleted FROM institution_identifier_training_error WHERE id = ?",
Boolean.class, error.getId());
assertThat(deleted).isTrue();
}
@Test
void signAcceptsCommercialBankSecondPrivateKeyReturnedByKeyEndpoint() throws Exception {
mvc.perform(post(BASE_PATH + "/steps/digest")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"bankCode\":\"BKCHCNBJ20101\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("DIGESTED"));
InstitutionSm2KeyEntity bankKey = keyMapper.selectList(null).stream()
.filter(key -> InstitutionKeyService.BANK_SECOND_KEY.equals(key.getKeyId()))
.findFirst().orElseThrow(AssertionError::new);
String privateKey = privateKeyCipher.decrypt(bankKey.getEncryptedPrivateKey());
mvc.perform(post(BASE_PATH + "/steps/sign")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"privateKey\":\"" + privateKey + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("SIGNED"))
.andExpect(jsonPath("$.data.keyId").value(InstitutionKeyService.BANK_SECOND_KEY))
.andExpect(jsonPath("$.data.signature").isNotEmpty());
}
@Test
void signRejectsWrongPrivateKeyWithoutPersistingPrivateKeyValue() throws Exception {
mvc.perform(post(BASE_PATH + "/steps/digest")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"bankCode\":\"BKCHCNBJ20102\"}"))
.andExpect(status().isOk());
String malformedPrivateKey = "1173B8C5C558BAE5C2C9EB89FD5C30BCB7DA8B478B";
mvc.perform(post(BASE_PATH + "/steps/sign")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"privateKey\":\"" + malformedPrivateKey + "\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(
"签名必须选择商业银行第二私钥")))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第1次错误")));
String wrongPrivateKey = "0000000000000000000000000000000000000000000000000000000000000001";
mvc.perform(post(BASE_PATH + "/steps/sign")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"privateKey\":\"" + wrongPrivateKey + "\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第2次错误")));
InstitutionIdentifierApplicationEntity application = applicationMapper.selectOne(null);
assertThat(application.getScoringCriteria()).isEqualTo(2);
List<InstitutionIdentifierTrainingErrorEntity> errors = trainingErrorMapper.selectList(null);
assertThat(errors).hasSize(2);
assertThat(errors).extracting(InstitutionIdentifierTrainingErrorEntity::getErrorType,
InstitutionIdentifierTrainingErrorEntity::getProvidedKeyId)
.containsOnly(
org.assertj.core.groups.Tuple.tuple("WRONG_PRIVATE_KEY", null),
org.assertj.core.groups.Tuple.tuple("WRONG_PRIVATE_KEY", null));
}
@Test
void logicalDeleteIsScopedToTokenSubjectAndAllowsRegeneration() throws Exception {
String firstResponse = mvc.perform(post(BASE_PATH + "/keys/commercial-bank")
@ -127,10 +240,8 @@ class InstitutionIdentifierControllerTest {
.andReturn().getResponse().getContentAsString();
Number firstRecordId = JsonPath.read(firstResponse, "$.data.recordId");
mvc.perform(delete(BASE_PATH + "/keys/commercial-bank")
.header("Authorization", keyAuthorization))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").doesNotExist());
keyService.logicalDelete(new InstitutionKeySubject(USER_ID, 1001L, 2001L),
InstitutionKeyService.BANK_SECOND_KEY, "institution-key-tester");
Integer deleted = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM institution_sm2_key WHERE id = ? AND deleted = TRUE",
@ -156,7 +267,7 @@ class InstitutionIdentifierControllerTest {
@Test
void protectedStepRejectsMissingJwt() throws Exception {
mvc.perform(post(BASE_PATH + "/steps/prepare")
mvc.perform(post(BASE_PATH + "/steps/digest")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"bankCode\":\"BKCHCNBJ20001\"}"))
.andExpect(status().isUnauthorized())
@ -164,56 +275,36 @@ class InstitutionIdentifierControllerTest {
}
@Test
void skippedStepReturnsBadRequestAndDoesNotChangePersistedState() throws Exception {
long applicationId = prepare("BKCHCNBJ20002");
void signWithoutCurrentDigestedRoundReturnsResourceNotFoundAndDoesNotCreateErrorRecord() throws Exception {
mvc.perform(post(BASE_PATH + "/steps/sign")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content(stepRequest(applicationId, InstitutionKeyService.BANK_SECOND_KEY)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.message")
.value(org.hamcrest.Matchers.containsString("当前步骤不是商业银行签名,不能操作")));
InstitutionIdentifierApplicationEntity application = applicationMapper.selectById(applicationId);
assertThat(application.getStatus()).isEqualTo("PREPARED");
assertThat(application.getDigest()).isNull();
assertThat(application.getBankSignature()).isNull();
assertThat(application.getScoringCriteria()).isEqualTo(1);
List<InstitutionIdentifierTrainingErrorEntity> errors = trainingErrorMapper.selectList(null);
assertThat(errors).filteredOn(error -> error.getApplicationId().equals(applicationId))
.extracting(InstitutionIdentifierTrainingErrorEntity::getErrorSequence,
InstitutionIdentifierTrainingErrorEntity::getErrorType)
.containsExactly(org.assertj.core.groups.Tuple.tuple(1, "INVALID_STEP"));
List<InstitutionIdentifierOperationLogEntity> logs = operationLogMapper.selectList(null);
assertThat(logs).filteredOn(log -> log.getApplicationId().equals(applicationId))
.extracting(InstitutionIdentifierOperationLogEntity::getOperation)
.containsExactly("PREPARE");
.content("{\"privateKey\":\"WRONG\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(
"当前没有待签名的本轮机构标识数据")));
assertThat(applicationMapper.selectList(null)).isEmpty();
assertThat(trainingErrorMapper.selectList(null)).isEmpty();
assertThat(operationLogMapper.selectList(null)).isEmpty();
}
@Test
void wrongKeysIncreaseScoringCriteriaAndRecordErrorSequence() throws Exception {
long applicationId = prepare("BKCHCNBJ20004");
mvc.perform(post(BASE_PATH + "/steps/digest")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content(stepRequest(applicationId, null)))
.andExpect(status().isOk());
long applicationId = digest("BKCHCNBJ20004");
mvc.perform(post(BASE_PATH + "/steps/sign")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content(stepRequest(applicationId, "WRONG_PRIVATE_KEY")))
.content("{\"privateKey\":\"WRONG_PRIVATE_KEY\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第1次错误")));
mvc.perform(post(BASE_PATH + "/steps/sign")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content(stepRequest(applicationId, "ANOTHER_WRONG_KEY")))
.content("{\"privateKey\":\"ANOTHER_WRONG_KEY\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第2次错误")));
@ -225,14 +316,23 @@ class InstitutionIdentifierControllerTest {
.extracting(InstitutionIdentifierTrainingErrorEntity::getErrorSequence,
InstitutionIdentifierTrainingErrorEntity::getProvidedKeyId)
.containsExactly(
org.assertj.core.groups.Tuple.tuple(1, "WRONG_PRIVATE_KEY"),
org.assertj.core.groups.Tuple.tuple(2, "ANOTHER_WRONG_KEY"));
org.assertj.core.groups.Tuple.tuple(1, null),
org.assertj.core.groups.Tuple.tuple(2, null));
StuModuleScoreDetailsEntity score = onlyScoreDetail();
assertThat(score.getMoudule()).isEqualTo("中国人民银行生成数字货币");
assertThat(score.getLearningProjects()).isEqualTo("实验实训");
assertThat(score.getSerialNumber()).isEqualTo(3);
assertThat(score.getCompletionStatus()).isEqualTo("2");
assertThat(score.getScoringCriteria()).isEqualTo("0.5");
assertThat(score.getSchedule()).isZero();
assertThat(score.getScoreProject()).isZero();
}
@Test
void prepareSameBankAgainCreatesNewTrainingRoundWithoutOverwritingHistory() throws Exception {
long firstApplicationId = prepare("BKCHCNBJ20005");
long secondApplicationId = prepare("BKCHCNBJ20005");
long firstApplicationId = digest("BKCHCNBJ20005");
long secondApplicationId = digest("BKCHCNBJ20005");
assertThat(secondApplicationId).isNotEqualTo(firstApplicationId);
InstitutionIdentifierApplicationEntity first = applicationMapper.selectById(firstApplicationId);
@ -244,88 +344,254 @@ class InstitutionIdentifierControllerTest {
}
@Test
void completeApiWorkflowUsesApplicationIdAndPersistsIssuedResult() throws Exception {
long applicationId = prepare("BKCHCNBJ20003");
void completeApiWorkflowUsesCurrentTrainingRoundAndPersistsIssuedResult() throws Exception {
long applicationId = digest("BKCHCNBJ20003");
mvc.perform(post(BASE_PATH + "/steps/digest")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content(stepRequest(applicationId, null)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.applicationId").value(applicationId))
.andExpect(jsonPath("$.data.status").value("DIGESTED"))
.andExpect(jsonPath("$.data.algorithm").value("SM3"))
.andExpect(jsonPath("$.data.digest").isNotEmpty());
mvc.perform(get(BASE_PATH + "/applications/commercial-bank")
.header("Authorization", authorization))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(
"商业银行第二私钥签名成功后才会出现待确认数据")));
InstitutionSm2KeyEntity bankKey = keyMapper.selectList(null).stream()
.filter(key -> InstitutionKeyService.BANK_SECOND_KEY.equals(key.getKeyId()))
.findFirst().orElseThrow(AssertionError::new);
String privateKey = privateKeyCipher.decrypt(bankKey.getEncryptedPrivateKey());
mvc.perform(post(BASE_PATH + "/steps/sign")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content(stepRequest(applicationId, InstitutionKeyService.BANK_SECOND_KEY)))
.content("{\"privateKey\":\"" + privateKey + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("SIGNED"))
.andExpect(jsonPath("$.data.signatureAlgorithm").value("SM2_WITH_SM3"))
.andExpect(jsonPath("$.data.signatureHashAlgorithm").value("SM3"));
mvc.perform(get(BASE_PATH + "/applications/commercial-bank")
.header("Authorization", authorization))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value(1))
.andExpect(jsonPath("$.data.statusText").value("待确认"))
.andExpect(jsonPath("$.data.applicationStatus").value("SIGNED"))
.andExpect(jsonPath("$.data.institutionIdentifier")
.value(org.hamcrest.Matchers.nullValue()));
mvc.perform(post(BASE_PATH + "/steps/package")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content(stepRequest(applicationId, null)))
.header("Authorization", authorization))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("PACKAGED"));
mvc.perform(post(BASE_PATH + "/applications/verification")
mvc.perform(post(BASE_PATH + "/applications/decryption-verification")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"applicationId\":" + applicationId + ",\"verificationKeyId\":\""
+ InstitutionKeyService.BANK_SECOND_KEY + "\"}"))
.content("{\"keyId\":\"" + InstitutionKeyService.BANK_SECOND_KEY + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("VERIFIED"))
.andExpect(jsonPath("$.data.verificationKeyId")
.value(InstitutionKeyService.BANK_SECOND_KEY))
.andExpect(jsonPath("$.data.signatureValid").value(true))
.andExpect(jsonPath("$.data.digestMatches").value(true));
mvc.perform(post(BASE_PATH + "/applications/confirmation")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"applicationId\":" + applicationId + ",\"signingKeyId\":\""
+ InstitutionKeyService.CENTRAL_FIRST_KEY + "\"}"))
.header("Authorization", authorization))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ISSUED"))
.andExpect(jsonPath("$.data.signingKeyId")
.value(InstitutionKeyService.CENTRAL_FIRST_KEY))
.andExpect(jsonPath("$.data.signatureAlgorithm").value("SM2_WITH_SM3"))
.andExpect(jsonPath("$.data.institutionIdentifier").value(org.hamcrest.Matchers.startsWith("ORG_")));
InstitutionIdentifierApplicationEntity issued = applicationMapper.selectById(applicationId);
assertThat(issued.getStatus()).isEqualTo("ISSUED");
assertThat(issued.getDigest()).matches("[0-9A-F]{64}");
assertThat(issued.getBankSignature()).isNotBlank();
assertThat(issued.getCentralBankSignature()).isNotBlank();
assertThat(issued.getInstitutionIdentifier()).startsWith("ORG_");
mvc.perform(get(BASE_PATH + "/applications/final-identifier")
.header("Authorization", authorization))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.applicationStatus").value("ISSUED"))
.andExpect(jsonPath("$.data.feedbackStatus").value(1))
.andExpect(jsonPath("$.data.feedbackStatusText").value("待反馈"))
.andExpect(jsonPath("$.data.institutionIdentifier")
.value(org.hamcrest.Matchers.startsWith("ORG_")))
.andExpect(jsonPath("$.data.fullInstitutionIdentifier")
.value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}")));
mvc.perform(get(BASE_PATH + "/applications/commercial-bank")
.header("Authorization", authorization))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value(1))
.andExpect(jsonPath("$.data.statusText").value("待确认"))
.andExpect(jsonPath("$.data.applicationStatus").value("ISSUED"))
.andExpect(jsonPath("$.data.institutionIdentifier")
.value(org.hamcrest.Matchers.nullValue()));
mvc.perform(post(BASE_PATH + "/currency-requests/steps/prepare")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"identifierApplicationId\":" + applicationId
+ ",\"amount\":1000.00,\"deliveryNodeCode\":\"SYS_DC_001\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(
"机构标识尚未反馈到商业银行端")));
mvc.perform(post(BASE_PATH + "/applications/feedback")
.header("Authorization", authorization))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.applicationStatus").value("FEEDBACKED"))
.andExpect(jsonPath("$.data.feedbackStatus").value(2))
.andExpect(jsonPath("$.data.feedbackStatusText").value("已反馈"));
mvc.perform(get(BASE_PATH + "/applications/commercial-bank")
.header("Authorization", authorization))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value(2))
.andExpect(jsonPath("$.data.statusText").value("已确认"))
.andExpect(jsonPath("$.data.applicationStatus").value("FEEDBACKED"))
.andExpect(jsonPath("$.data.institutionIdentifier")
.value(org.hamcrest.Matchers.startsWith("ORG_")))
.andExpect(jsonPath("$.data.fullInstitutionIdentifier")
.value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}")));
mvc.perform(post(BASE_PATH + "/currency-requests/steps/prepare")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"identifierApplicationId\":" + applicationId
+ ",\"amount\":1000.00,\"deliveryNodeCode\":\"SYS_DC_001\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("PREPARED"))
.andExpect(jsonPath("$.data.identifierApplicationId").value(applicationId))
.andExpect(jsonPath("$.data.institutionIdentifier")
.value(org.hamcrest.Matchers.startsWith("ORG_")))
.andExpect(jsonPath("$.data.fullInstitutionIdentifier")
.value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}")));
InstitutionIdentifierApplicationEntity feedbacked = applicationMapper.selectById(applicationId);
assertThat(feedbacked.getStatus()).isEqualTo("FEEDBACKED");
assertThat(feedbacked.getDigest()).matches("[0-9A-F]{64}");
assertThat(feedbacked.getBankSignature()).isNotBlank();
assertThat(feedbacked.getCentralBankSignature()).isNotBlank();
assertThat(feedbacked.getInstitutionIdentifier()).startsWith("ORG_");
StuModuleScoreDetailsEntity score = onlyScoreDetail();
assertThat(score.getCompletionStatus()).isEqualTo("0");
assertThat(score.getSchedule()).isEqualTo(11.11);
assertThat(score.getScoreProject()).isEqualTo(11.11);
List<InstitutionIdentifierOperationLogEntity> logs = operationLogMapper.selectList(null);
assertThat(logs).filteredOn(log -> log.getApplicationId().equals(applicationId))
.extracting(InstitutionIdentifierOperationLogEntity::getOperation)
.containsExactly("PREPARE", "DIGEST", "SIGN", "PACKAGE", "VERIFY", "CONFIRM");
.containsExactly("PREPARE", "DIGEST", "SIGN", "PACKAGE", "DECRYPT_VERIFY", "CONFIRM", "FEEDBACK");
}
private long prepare(String bankCode) throws Exception {
String response = mvc.perform(post(BASE_PATH + "/steps/prepare")
@Test
void centralBankDecryptionVerificationRejectsWrongPublicKeyAndKeepsPackagedStatus() throws Exception {
long applicationId = digest("BKCHCNBJ20104");
InstitutionSm2KeyEntity bankKey = keyMapper.selectList(null).stream()
.filter(key -> InstitutionKeyService.BANK_SECOND_KEY.equals(key.getKeyId()))
.findFirst().orElseThrow(AssertionError::new);
String privateKey = privateKeyCipher.decrypt(bankKey.getEncryptedPrivateKey());
mvc.perform(post(BASE_PATH + "/steps/sign")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"privateKey\":\"" + privateKey + "\"}"))
.andExpect(status().isOk());
mvc.perform(post(BASE_PATH + "/steps/package")
.header("Authorization", authorization))
.andExpect(status().isOk());
mvc.perform(post(BASE_PATH + "/applications/decryption-verification")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keyId\":\"" + InstitutionKeyService.CENTRAL_FIRST_KEY + "\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(
"解密验证必须选择商业银行第二公钥")))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第1次错误")));
InstitutionIdentifierApplicationEntity packaged = applicationMapper.selectById(applicationId);
assertThat(packaged.getStatus()).isEqualTo("PACKAGED");
InstitutionIdentifierTrainingErrorEntity error = trainingErrorMapper.selectList(null).get(0);
assertThat(error.getErrorType()).isEqualTo("WRONG_PUBLIC_KEY");
assertThat(error.getOperationStep()).isEqualTo("DECRYPT_VERIFY");
assertThat(error.getProvidedKeyId()).isEqualTo(InstitutionKeyService.CENTRAL_FIRST_KEY);
}
@Test
void confirmationValidatesCentralBankPrivateKeyOnlyWhenProvided() throws Exception {
long applicationId = advanceToVerified("BKCHCNBJ20103");
mvc.perform(post(BASE_PATH + "/applications/confirmation")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"privateKey\":\"WRONG\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(
"签名确权必须使用中央银行第一私钥")))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第1次错误")));
InstitutionIdentifierApplicationEntity verified = applicationMapper.selectById(applicationId);
assertThat(verified.getStatus()).isEqualTo("VERIFIED");
assertThat(verified.getScoringCriteria()).isEqualTo(1);
InstitutionIdentifierTrainingErrorEntity error = trainingErrorMapper.selectList(null).get(0);
assertThat(error.getErrorType()).isEqualTo("WRONG_PRIVATE_KEY");
assertThat(error.getProvidedKeyId()).isNull();
InstitutionSm2KeyEntity centralBankKey = keyMapper.selectList(null).stream()
.filter(key -> InstitutionKeyService.CENTRAL_FIRST_KEY.equals(key.getKeyId()))
.findFirst().orElseThrow(AssertionError::new);
String privateKey = privateKeyCipher.decrypt(centralBankKey.getEncryptedPrivateKey());
mvc.perform(post(BASE_PATH + "/applications/confirmation")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"privateKey\":\"" + privateKey + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ISSUED"))
.andExpect(jsonPath("$.data.signingKeyId")
.value(InstitutionKeyService.CENTRAL_FIRST_KEY));
}
private long advanceToVerified(String bankCode) throws Exception {
long applicationId = digest(bankCode);
InstitutionSm2KeyEntity bankKey = keyMapper.selectList(null).stream()
.filter(key -> InstitutionKeyService.BANK_SECOND_KEY.equals(key.getKeyId()))
.findFirst().orElseThrow(AssertionError::new);
String privateKey = privateKeyCipher.decrypt(bankKey.getEncryptedPrivateKey());
mvc.perform(post(BASE_PATH + "/steps/sign")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"privateKey\":\"" + privateKey + "\"}"))
.andExpect(status().isOk());
mvc.perform(post(BASE_PATH + "/steps/package")
.header("Authorization", authorization))
.andExpect(status().isOk());
mvc.perform(post(BASE_PATH + "/applications/decryption-verification")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keyId\":\"" + InstitutionKeyService.BANK_SECOND_KEY + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("VERIFIED"));
return applicationId;
}
private StuModuleScoreDetailsEntity onlyScoreDetail() {
List<StuModuleScoreDetailsEntity> details = scoreDetailsMapper.selectList(null);
assertThat(details).hasSize(1);
return details.get(0);
}
private long digest(String bankCode) throws Exception {
String response = mvc.perform(post(BASE_PATH + "/steps/digest")
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"bankCode\":\"" + bankCode + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.applicationId").isNumber())
.andExpect(jsonPath("$.data.status").value("PREPARED"))
.andExpect(jsonPath("$.data.bankCode").value(bankCode))
.andExpect(jsonPath("$.data.trainingRound").isNumber())
.andExpect(jsonPath("$.data.scoringCriteria").value(0))
.andExpect(jsonPath("$.data.status").value("DIGESTED"))
.andExpect(jsonPath("$.data.algorithm").value("SM3"))
.andExpect(jsonPath("$.data.digest").isNotEmpty())
.andReturn().getResponse().getContentAsString();
Number applicationId = JsonPath.read(response, "$.data.applicationId");
return applicationId.longValue();
}
private String stepRequest(long applicationId, String keyId) {
if (keyId == null) {
return "{\"applicationId\":" + applicationId + "}";
}
return "{\"applicationId\":" + applicationId + ",\"keyId\":\"" + keyId + "\"}";
}
}

@ -0,0 +1,266 @@
package com.yau.digitalrmb.institutionidentity;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.institutionidentity.domain.CurrencyGenerationRequest;
import com.yau.digitalrmb.institutionidentity.domain.CurrencyGenerationVerification;
import com.yau.digitalrmb.institutionidentity.infrastructure.CurrencyGenerationRequestEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.CurrencyGenerationRequestMapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.CurrencyGenerationVerificationEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.CurrencyGenerationVerificationMapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierApplicationEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierApplicationMapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.TransactionInformationIdentifierEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.TransactionInformationIdentifierMapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.TransactionInformationIdentifierOperationLogEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.TransactionInformationIdentifierOperationLogMapper;
import com.yau.digitalrmb.security.application.JwtTokenService;
import org.junit.jupiter.api.BeforeEach;
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.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class TransactionInformationIdentifierControllerTest {
private static final String BASE_PATH =
"/api/v1/institution-identifiers/transaction-information-identifiers";
private static final String USER_ID = "00000000-0000-0000-0000-000000009004";
private static final long SCHOOL_ID = 1001L;
private static final long CLASS_ID = 2001L;
@Autowired private MockMvc mvc;
@Autowired private JwtTokenService jwtTokenService;
@Autowired private JdbcTemplate jdbcTemplate;
@Autowired private InstitutionIdentifierApplicationMapper applicationMapper;
@Autowired private CurrencyGenerationRequestMapper requestMapper;
@Autowired private CurrencyGenerationVerificationMapper verificationMapper;
@Autowired private TransactionInformationIdentifierMapper transactionMapper;
@Autowired private TransactionInformationIdentifierOperationLogMapper transactionLogMapper;
@Autowired private StuModuleScoreDetailsMapper scoreMapper;
private String authorization;
@BeforeEach
void setUp() {
jdbcTemplate.update("DELETE FROM transaction_information_identifier_operation_log");
jdbcTemplate.update("DELETE FROM transaction_information_identifier");
jdbcTemplate.update("DELETE FROM currency_generation_verification_operation_log");
jdbcTemplate.update("DELETE FROM currency_generation_verification");
jdbcTemplate.update("DELETE FROM currency_generation_request_operation_log");
jdbcTemplate.update("DELETE FROM currency_generation_request");
jdbcTemplate.update("DELETE FROM institution_identifier_training_error");
jdbcTemplate.update("DELETE FROM institution_identifier_operation_log");
jdbcTemplate.update("DELETE FROM institution_identifier_application");
jdbcTemplate.update("DELETE FROM institution_sm2_key_audit");
jdbcTemplate.update("DELETE FROM institution_sm2_key");
jdbcTemplate.update("DELETE FROM stu_module_score_details");
authorization = "Bearer " + jwtTokenService.issueFor(USER_ID, SCHOOL_ID, CLASS_ID,
"module-four-tester", Collections.singleton("STUDENT")).accessToken();
}
@Test
void rejectsModuleFourWhenStepThreeHasNotReturnedResponse() throws Exception {
mvc.perform(post(BASE_PATH + "/steps/prepare").header("Authorization", authorization))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(
"步骤三尚未完成确认报文返回")));
assertThat(transactionMapper.selectList(null)).isEmpty();
}
@Test
void flowsCompletedPreviousDataAndPersistsEveryModuleFourStep() throws Exception {
PrecedingData preceding = insertCompletedPrecedingData();
mvc.perform(post(BASE_PATH + "/steps/prepare").header("Authorization", authorization))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("PREPARED"))
.andExpect(jsonPath("$.data.verificationId").value(preceding.verificationId))
.andExpect(jsonPath("$.data.requestId").value(preceding.requestId))
.andExpect(jsonPath("$.data.identifierApplicationId").value(preceding.applicationId))
.andExpect(jsonPath("$.data.amount").value(1589.85))
.andExpect(jsonPath("$.data.institutionIdentifier").value("ORG_3A4B5C6D7E8F"))
.andExpect(jsonPath("$.data.transactionTimestamp")
.value(org.hamcrest.Matchers.matchesPattern("\\d{14}")))
.andExpect(jsonPath("$.data.originalText")
.value(org.hamcrest.Matchers.matchesPattern(
"1589\\.85\\|ORG_3A4B5C6D7E8F\\|\\d{14}")))
.andExpect(jsonPath("$.data.digest").value(org.hamcrest.Matchers.nullValue()))
.andExpect(jsonPath("$.data.transactionIdentifier")
.value(org.hamcrest.Matchers.nullValue()));
mvc.perform(post(BASE_PATH + "/steps/assemble").header("Authorization", authorization))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(
"请先使用SM3生成摘要")));
mvc.perform(post(BASE_PATH + "/steps/digest").header("Authorization", authorization))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("DIGESTED"))
.andExpect(jsonPath("$.data.digestAlgorithm").value("SM3"))
.andExpect(jsonPath("$.data.digest")
.value(org.hamcrest.Matchers.matchesPattern("[0-9A-F]{64}")));
mvc.perform(post(BASE_PATH + "/steps/assemble").header("Authorization", authorization))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ASSEMBLED"))
.andExpect(jsonPath("$.data.transactionIdentifier")
.value(org.hamcrest.Matchers.matchesPattern("TXN_[0-9A-F]{64}")));
mvc.perform(get(BASE_PATH).header("Authorization", authorization))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ASSEMBLED"))
.andExpect(jsonPath("$.data.transactionIdentifier")
.value(org.hamcrest.Matchers.matchesPattern("TXN_[0-9A-F]{64}")));
List<TransactionInformationIdentifierEntity> records = transactionMapper.selectList(null);
assertThat(records).hasSize(1);
TransactionInformationIdentifierEntity persisted = records.get(0);
assertThat(persisted.getVerificationId()).isEqualTo(preceding.verificationId);
assertThat(persisted.getRequestId()).isEqualTo(preceding.requestId);
assertThat(persisted.getIdentifierApplicationId()).isEqualTo(preceding.applicationId);
assertThat(persisted.getAmount()).isEqualByComparingTo("1589.85");
assertThat(persisted.getInstitutionIdentifier()).isEqualTo("ORG_3A4B5C6D7E8F");
assertThat(persisted.getOriginalText()).isEqualTo("1589.85|ORG_3A4B5C6D7E8F|"
+ persisted.getTransactionTimestamp());
assertThat(persisted.getDigest()).matches("[0-9A-F]{64}");
assertThat(persisted.getTransactionIdentifier()).isEqualTo("TXN_" + persisted.getDigest());
assertThat(persisted.getStatus()).isEqualTo("ASSEMBLED");
assertThat(transactionLogMapper.selectList(null))
.extracting(TransactionInformationIdentifierOperationLogEntity::getOperation)
.containsExactly("PREPARE", "DIGEST", "ASSEMBLE");
StuModuleScoreDetailsEntity score = scoreMapper.selectOne(
new LambdaQueryWrapper<StuModuleScoreDetailsEntity>()
.eq(StuModuleScoreDetailsEntity::getUserId, USER_ID)
.eq(StuModuleScoreDetailsEntity::getSerialNumber, 3));
assertThat(score).isNotNull();
assertThat(score.getSchedule()).isEqualTo(44.44);
assertThat(score.getScoreProject()).isEqualTo(44.44);
}
@Test
void preventsDuplicatePreparationForSameCompletedVerification() throws Exception {
insertCompletedPrecedingData();
mvc.perform(post(BASE_PATH + "/steps/prepare").header("Authorization", authorization))
.andExpect(status().isOk());
mvc.perform(post(BASE_PATH + "/steps/prepare").header("Authorization", authorization))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(
"不能重复准备")));
assertThat(transactionMapper.selectList(null)).hasSize(1);
}
private PrecedingData insertCompletedPrecedingData() {
LocalDateTime now = LocalDateTime.now();
InstitutionIdentifierApplicationEntity application = new InstitutionIdentifierApplicationEntity();
application.setBankCode("BKCHCNBJ40001");
application.setUserId(USER_ID);
application.setSchoolId(SCHOOL_ID);
application.setClassId(CLASS_ID);
application.setRequestTimestamp("20260805180000");
application.setDigest("3A4B5C6D7E8F0000000000000000000000000000000000000000000000000000");
application.setBankSignature("3044AABB");
application.setStatus("FEEDBACKED");
application.setVerificationKeyId(InstitutionKeyService.BANK_SECOND_KEY);
application.setSignatureValid(true);
application.setDigestMatches(true);
application.setSigningKeyId(InstitutionKeyService.CENTRAL_FIRST_KEY);
application.setInstitutionIdentifier("ORG_3A4B5C6D7E8F");
application.setCentralBankSignature("3044CCDD");
application.setTrainingRound(1);
application.setScoringCriteria(0);
application.setCreatedAt(now);
application.setUpdatedAt(now);
application.setCreatedBy("module-four-tester");
application.setUpdatedBy("module-four-tester");
application.setDeleted(false);
applicationMapper.insert(application);
CurrencyGenerationRequestEntity request = new CurrencyGenerationRequestEntity();
request.setIdentifierApplicationId(application.getId());
request.setInstitutionIdentifier(application.getInstitutionIdentifier());
request.setFullInstitutionIdentifier(application.getDigest());
request.setAmount(new BigDecimal("1589.85"));
request.setDeliveryNodeCode("SYS_DC_001");
request.setRequestTimestamp("20260805180100");
request.setRequestOriginalText("ORG_3A4B5C6D7E8F|1589.85|SYS_DC_001|20260805180100");
request.setRequestDigest("7A8B9C0D1E2F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1A2B3C4D5E6F");
request.setBankSignature("3044AABB");
request.setSigningKeyId(InstitutionKeyService.BANK_SECOND_KEY);
request.setRequestMessage("{request:true}");
request.setStatus(CurrencyGenerationRequest.Status.RECEIVED.name());
request.setUserId(USER_ID);
request.setSchoolId(SCHOOL_ID);
request.setClassId(CLASS_ID);
request.setCreatedAt(now);
request.setUpdatedAt(now);
request.setCreatedBy("module-four-tester");
request.setUpdatedBy("module-four-tester");
request.setDeleted(false);
requestMapper.insert(request);
CurrencyGenerationVerificationEntity verification = new CurrencyGenerationVerificationEntity();
verification.setRequestId(request.getId());
verification.setRequestMessage(request.getRequestMessage());
verification.setRequestOriginalText(request.getRequestOriginalText());
verification.setRequestDigest(request.getRequestDigest());
verification.setBankSignature(request.getBankSignature());
verification.setBankKeyId(request.getSigningKeyId());
verification.setAmount(request.getAmount());
verification.setSignatureValid(true);
verification.setRecomputedDigest(request.getRequestDigest());
verification.setDigestMatches(true);
verification.setTotalQuota(new BigDecimal("500000000.00"));
verification.setUsedQuota(new BigDecimal("495000000.00"));
verification.setRemainingQuota(new BigDecimal("5000000.00"));
verification.setQuotaSufficient(true);
verification.setCentralBankKeyId(InstitutionKeyService.CENTRAL_FIRST_KEY);
verification.setCentralBankSignature("3044CCDD");
verification.setConfirmationTime("20260805180200");
verification.setConfirmationMessage("{confirmed:true}");
verification.setStatus(CurrencyGenerationVerification.Status.RESPONSE_RETURNED.name());
verification.setUserId(USER_ID);
verification.setSchoolId(SCHOOL_ID);
verification.setClassId(CLASS_ID);
verification.setCreatedAt(now);
verification.setUpdatedAt(now);
verification.setCreatedBy("module-four-tester");
verification.setUpdatedBy("module-four-tester");
verification.setDeleted(false);
verificationMapper.insert(verification);
return new PrecedingData(application.getId(), request.getId(), verification.getId());
}
private static class PrecedingData {
private final long applicationId;
private final long requestId;
private final long verificationId;
private PrecedingData(long applicationId, long requestId, long verificationId) {
this.applicationId = applicationId;
this.requestId = requestId;
this.verificationId = verificationId;
}
}
}

@ -0,0 +1,31 @@
package com.yau.digitalrmb.platformintegration.config;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformIntegrationConfigTest {
private final ApplicationContextRunner contextRunner =
new ApplicationContextRunner().withUserConfiguration(PlatformIntegrationConfig.class);
@Test
void doesNotBindOrValidatePropertiesWhenIntegrationIsDisabled() {
contextRunner
.withPropertyValues("platform-integration.enabled=false")
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).doesNotHaveBean(PlatformIntegrationProperties.class);
});
}
@Test
void validatesRequiredPropertiesWhenIntegrationIsEnabled() {
contextRunner
.withPropertyValues("platform-integration.enabled=true")
.run(context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure()).hasMessageContaining("platform-integration");
});
}
}

@ -0,0 +1,43 @@
package com.yau.digitalrmb.shared;
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.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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")
@TestPropertySource(properties = {
"springdoc.swagger-ui.enabled=true",
"knife4j.enable=true"
})
class SwaggerDocumentationTest {
@Autowired
private MockMvc mvc;
@Test
void docHtmlAndOpenApiJsonArePubliclyAvailable() throws Exception {
mvc.perform(get("/doc.html"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith("text/html"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("knife4j")));
mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith("application/json"))
.andExpect(jsonPath("$.info.title").value("数字人民币教学仿真后端"))
.andExpect(jsonPath("$.components.securitySchemes.Authorization.type").value("apiKey"))
.andExpect(jsonPath("$.components.securitySchemes.Authorization.in").value("header"))
.andExpect(jsonPath("$.components.securitySchemes.Authorization.name").value("Authorization"))
.andExpect(jsonPath("$.security[0].Authorization").isArray());
}
}

@ -48,6 +48,22 @@ CREATE TABLE IF NOT EXISTS auth_refresh_token (
revoked_at TIMESTAMP NULL
);
CREATE TABLE IF NOT EXISTS stu_module_score_details (
id VARCHAR(64) NOT NULL,
moudule VARCHAR(100) NOT NULL,
learning_projects VARCHAR(100),
assessment_items VARCHAR(100),
scoring_criteria VARCHAR(100),
completion_status VARCHAR(100),
score_project DOUBLE,
user_id VARCHAR(64) NOT NULL,
total_score DECIMAL(10, 2) NOT NULL DEFAULT 100.00,
serial_number INT,
schedule DOUBLE,
PRIMARY KEY (id),
CONSTRAINT uk_user_moudule_serial UNIQUE (user_id, moudule, serial_number)
);
CREATE TABLE IF NOT EXISTS institution_sm2_key (
id BIGINT PRIMARY KEY,
key_id VARCHAR(64) NOT NULL,
@ -77,6 +93,7 @@ CREATE TABLE IF NOT EXISTS institution_sm2_key_audit (
operation_detail VARCHAR(512) NOT NULL,
created_at TIMESTAMP NOT NULL,
created_by VARCHAR(64) NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_institution_key_audit_key
FOREIGN KEY (key_record_id) REFERENCES institution_sm2_key(id)
);

Loading…
Cancel
Save