Merge remote-tracking branch 'origin/master' into agent/issuance-frontend-connect

chore/issuance-comments-zh
chenyuan 2 months ago
commit b50c5df8dc

@ -7,6 +7,7 @@ import com.yau.digitalrmb.corporatewallet.domain.RemoteWalletApplication;
import com.yau.digitalrmb.corporatewallet.domain.RemoteWalletApplicationRepository;
import com.yau.digitalrmb.corporatewallet.infrastructure.CorporateWalletKeyInfoEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.CorporateWalletKeyInfoMapper;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography;
import com.yau.digitalrmb.corporatewallet.domain.SalaryBatch;
@ -17,6 +18,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
@ -29,6 +31,17 @@ public class SalaryBatchService {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final String OPERATOR_NAME = "王五";
private static final String OPERATOR_KEY_TYPE = "数币操作员私钥";
private static final String REVIEWER_NAME = "赵六";
private static final String REVIEWER_KEY_TYPE = "数币复核员私钥";
// 权限限额(元)
private static final BigDecimal SINGLE_LIMIT = new BigDecimal("50000.00");
private static final BigDecimal BATCH_LIMIT = new BigDecimal("500000.00");
private static final BigDecimal DAILY_LIMIT = new BigDecimal("1000000.00");
// 审核状态:1-通过, 2-未通过
private static final int STATUS_PASSED = 1;
private static final int STATUS_FAILED = 2;
// 内置工资明细数据
private static final String[][] EMPLOYEE_DATA = {
@ -51,6 +64,8 @@ public class SalaryBatchService {
private InstitutionIdentityCryptography cryptography;
@Resource
private SalaryScoreService scoreService;
@Resource
private InstitutionKeyService institutionKeyService;
// ==================== 步骤一:调取区块信息 ====================
@ -181,7 +196,161 @@ public class SalaryBatchService {
return new SalarySendResult("待审核", submitTime);
}
// ==================== 页面查询 ====================
// ==================== 步骤七:提取审核原文 ====================
@Transactional
public SalaryReviewExtractResult extractReviewText(InstitutionKeySubject subject) {
SalaryBatch batch = findRequired(subject);
if (batch.getStatus().ordinal() < SalaryBatch.Status.SENT.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成数币操作员发送步骤");
}
if (batch.getReviewConcatenatedMessage() != null) {
return new SalaryReviewExtractResult(batch.getReviewConcatenatedMessage(),
batch.getBatchId(), batch.getReviewerName(),
calculateTotalAmount(), EMPLOYEE_DATA.length, batch.getReviewTimestamp());
}
String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT);
String totalAmount = calculateTotalAmount();
int employeeCount = EMPLOYEE_DATA.length;
// 拼接审核原文:REVIEW|批次号|复核员|总金额|人数|时间戳
String reviewMessage = "REVIEW|" + batch.getBatchId() + "|" + REVIEWER_NAME
+ "|" + totalAmount + "|" + employeeCount + "|" + timestamp;
batch.extractReviewText(REVIEWER_NAME, timestamp, reviewMessage);
repository.update(batch);
return new SalaryReviewExtractResult(reviewMessage, batch.getBatchId(),
REVIEWER_NAME, totalAmount, employeeCount, timestamp);
}
// ==================== 步骤八:审核与权限校验 ====================
@Transactional
public SalaryReviewCheckResult checkPermission(InstitutionKeySubject subject) {
SalaryBatch batch = findRequired(subject);
if (batch.getStatus().ordinal() < SalaryBatch.Status.REVIEW_EXTRACTED.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先提取审核原文");
}
if (batch.getSingleLimitStatus() != null) {
// 已校验过,直接返回
return buildCheckResult(batch);
}
// 计算单笔最高金额
BigDecimal maxSingleAmount = BigDecimal.ZERO;
for (String[] emp : EMPLOYEE_DATA) {
BigDecimal amount = new BigDecimal(emp[3]);
if (amount.compareTo(maxSingleAmount) > 0) {
maxSingleAmount = amount;
}
}
// 批次总金额
BigDecimal batchTotal = new BigDecimal(calculateTotalAmount());
// 当日累计发放金额(查询当天所有已发送批次的总金额)
BigDecimal dailyTotal = calculateDailyTotal(subject, batchTotal);
// 单笔限额校验
int singleStatus = maxSingleAmount.compareTo(SINGLE_LIMIT) <= 0 ? STATUS_PASSED : STATUS_FAILED;
String singleDesc = "单笔最高金额 " + maxSingleAmount.setScale(2).toPlainString() + " 元 "
+ (singleStatus == STATUS_PASSED ? "≤" : ">") + " " + SINGLE_LIMIT.setScale(2).toPlainString() + " 元";
// 批次限额校验
int batchStatus = batchTotal.compareTo(BATCH_LIMIT) <= 0 ? STATUS_PASSED : STATUS_FAILED;
String batchDesc = "批次总金额 " + batchTotal.setScale(2).toPlainString() + " 元 "
+ (batchStatus == STATUS_PASSED ? "≤" : ">") + " " + BATCH_LIMIT.setScale(2).toPlainString() + " 元";
// 日累计限额校验
int dailyStatus = dailyTotal.compareTo(DAILY_LIMIT) <= 0 ? STATUS_PASSED : STATUS_FAILED;
String dailyDesc = "当日累计发放金额 " + dailyTotal.setScale(2).toPlainString() + " 元 "
+ (dailyStatus == STATUS_PASSED ? "≤" : ">") + " " + DAILY_LIMIT.setScale(2).toPlainString() + " 元";
// 审核结果:全部通过才批准
String reviewResult = (singleStatus == STATUS_PASSED && batchStatus == STATUS_PASSED && dailyStatus == STATUS_PASSED)
? "APPROVED" : "REJECTED";
batch.checkPermission(singleStatus, batchStatus, dailyStatus, reviewResult);
repository.update(batch);
return new SalaryReviewCheckResult(
singleStatus, SINGLE_LIMIT.setScale(2).toPlainString(), maxSingleAmount.setScale(2).toPlainString(), singleDesc,
batchStatus, BATCH_LIMIT.setScale(2).toPlainString(), batchTotal.setScale(2).toPlainString(), batchDesc,
dailyStatus, DAILY_LIMIT.setScale(2).toPlainString(), dailyTotal.setScale(2).toPlainString(), dailyDesc,
reviewResult);
}
// ==================== 步骤九:生成审核摘要 ====================
@Transactional
public SalaryReviewDigestResult computeReviewDigest(InstitutionKeySubject subject) {
SalaryBatch batch = findRequired(subject);
if (batch.getStatus().ordinal() < SalaryBatch.Status.REVIEW_CHECKED.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成审核与权限校验");
}
if (batch.getReviewDigestValue() != null) {
return new SalaryReviewDigestResult(batch.getReviewDigestValue());
}
String digest = cryptography.sm3(batch.getReviewConcatenatedMessage());
batch.computeReviewDigest("SM3", digest);
repository.update(batch);
return new SalaryReviewDigestResult(digest);
}
// ==================== 步骤十:SM2签名(复核员私钥) ====================
@Transactional
public SalaryReviewSignatureResult signReview(InstitutionKeySubject subject, String providedPrivateKey) {
SalaryBatch batch = findRequired(subject);
if (batch.getStatus().ordinal() < SalaryBatch.Status.REVIEW_DIGESTED.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先生成审核摘要");
}
if (batch.getReviewSignature() != null) {
return new SalaryReviewSignatureResult(batch.getReviewSignature());
}
// 从 corporate_wallet_key_info 表获取数币复核员私钥
String storedPrivateKey = findReviewerPrivateKey(subject);
if (storedPrivateKey == null) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "数币复核员私钥不存在,请先获取密钥");
}
// 验证传入的私钥是否正确
if (!storedPrivateKey.equalsIgnoreCase(providedPrivateKey != null ? providedPrivateKey.trim() : "")) {
scoreService.recordError(subject.getUserId());
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "数币复核员私钥不正确,错误次数+1");
}
// 使用数币复核员私钥对审核摘要原文进行SM2签名
String signature = cryptography.sign(storedPrivateKey, batch.getReviewConcatenatedMessage());
batch.signReview(signature);
repository.update(batch);
return new SalaryReviewSignatureResult(signature);
}
// ==================== 步骤十一:输出审核结果 ====================
@Transactional
public SalaryReviewOutputResult outputReview(InstitutionKeySubject subject) {
SalaryBatch batch = findRequired(subject);
if (batch.getStatus().ordinal() < SalaryBatch.Status.REVIEW_SIGNED.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成SM2签名");
}
if (batch.getReviewOutputJson() != null) {
return new SalaryReviewOutputResult(batch.getReviewOutputJson());
}
String json = buildReviewOutputJson(batch);
batch.outputReview(json);
repository.update(batch);
return new SalaryReviewOutputResult(json);
}
@Transactional(readOnly = true)
public SalaryPageResult pageQuery(InstitutionKeySubject subject) {
@ -203,14 +372,123 @@ public class SalaryBatchService {
if (batch == null) {
return new SalaryPageResult(payroll, totalAmount, employeeCount,
null, null, null, null, null, null, null, null, null);
null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null,
null, null, null, null, null);
}
return new SalaryPageResult(payroll, totalAmount, employeeCount,
batch.getBatchId(), batch.getCorpWalletId(), batch.getOperatorName(),
batch.getOperatorTimestamp(), batch.getConcatenatedMessage(),
batch.getDigestValue(), batch.getSignature(), batch.getOutputJson(),
batch.getStatus() != null ? batch.getStatus().name() : null);
batch.getStatus() != null ? batch.getStatus().name() : null,
batch.getReviewConcatenatedMessage(), batch.getReviewerName(),
batch.getSingleLimitStatus(), batch.getBatchLimitStatus(), batch.getDailyLimitStatus(),
batch.getReviewResult(), batch.getReviewDigestValue(),
batch.getReviewSignature(), batch.getReviewOutputJson(),
batch.getConfirmMessage(), batch.getConfirmTimestamp(),
batch.getConfirmDigestValue(), batch.getCbSignature(),
batch.getConfirmOutputJson());
}
// ==================== 步骤十二:提取交易确认原文 ====================
@Transactional
public SalaryConfirmExtractResult extractConfirmText(InstitutionKeySubject subject) {
SalaryBatch batch = findRequired(subject);
if (batch.getStatus().ordinal() < SalaryBatch.Status.REVIEWED.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成复核员审核步骤");
}
if (batch.getConfirmMessage() != null) {
return new SalaryConfirmExtractResult(batch.getConfirmMessage(),
batch.getBatchId(), calculateTotalAmount(), EMPLOYEE_DATA.length,
batch.getConfirmTimestamp());
}
String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT);
String totalAmount = calculateTotalAmount();
int employeeCount = EMPLOYEE_DATA.length;
// 拼接确认原文:CONFIRM|批次号|总金额|人数|时间戳
String confirmMessage = "CONFIRM|" + batch.getBatchId() + "|"
+ totalAmount + "|" + employeeCount + "|" + timestamp;
batch.extractConfirmText(timestamp, confirmMessage);
repository.update(batch);
return new SalaryConfirmExtractResult(confirmMessage, batch.getBatchId(),
totalAmount, employeeCount, timestamp);
}
// ==================== 步骤十三:生成交易确认摘要 ====================
@Transactional
public SalaryConfirmDigestResult computeConfirmDigest(InstitutionKeySubject subject) {
SalaryBatch batch = findRequired(subject);
if (batch.getStatus().ordinal() < SalaryBatch.Status.CONFIRM_EXTRACTED.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先提取交易确认原文");
}
if (batch.getConfirmDigestValue() != null) {
return new SalaryConfirmDigestResult(batch.getConfirmDigestValue());
}
String digest = cryptography.sm3(batch.getConfirmMessage());
batch.computeConfirmDigest("SM3", digest);
repository.update(batch);
return new SalaryConfirmDigestResult(digest);
}
// ==================== 步骤十四:央行第一私钥签名 ====================
@Transactional
public SalaryConfirmSignatureResult signConfirm(InstitutionKeySubject subject, String providedPrivateKey) {
SalaryBatch batch = findRequired(subject);
if (batch.getStatus().ordinal() < SalaryBatch.Status.CONFIRM_DIGESTED.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先生成交易确认摘要");
}
if (batch.getCbSignature() != null) {
return new SalaryConfirmSignatureResult(batch.getCbSignature());
}
// 从 institution_key_service 获取央行第一私钥
String storedPrivateKey = institutionKeyService.centralBankFirstPrivateKeyOrNull(subject);
if (storedPrivateKey == null) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "央行第一私钥不存在,请先生成央行密钥对");
}
// 验证传入的私钥是否正确
if (!storedPrivateKey.equalsIgnoreCase(providedPrivateKey != null ? providedPrivateKey.trim() : "")) {
scoreService.recordError(subject.getUserId());
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "央行第一私钥不正确,错误次数+1");
}
// 使用央行第一私钥对确认原文进行SM2签名,并添加CB_FINAL_前缀
String signature = cryptography.sign(storedPrivateKey, batch.getConfirmMessage());
String cbSignature = "CB_FINAL_A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6C7D8E9F0" + signature;
batch.signConfirm(cbSignature);
repository.update(batch);
return new SalaryConfirmSignatureResult(cbSignature);
}
// ==================== 步骤十五:输出交易确认结果 ====================
@Transactional
public SalaryConfirmOutputResult outputConfirm(InstitutionKeySubject subject) {
SalaryBatch batch = findRequired(subject);
if (batch.getStatus().ordinal() < SalaryBatch.Status.CONFIRM_SIGNED.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成央行签名");
}
if (batch.getConfirmOutputJson() != null) {
return new SalaryConfirmOutputResult(batch.getConfirmOutputJson());
}
String json = buildConfirmOutputJson(batch);
batch.outputConfirm(json);
repository.update(batch);
return new SalaryConfirmOutputResult(json);
}
// ==================== 内部方法 ====================
@ -264,6 +542,115 @@ public class SalaryBatchService {
return entity == null ? null : entity.getKeyValue();
}
/**
* 从 corporate_wallet_key_info 表获取数币复核员私钥
*/
private String findReviewerPrivateKey(InstitutionKeySubject subject) {
CorporateWalletKeyInfoEntity entity = keyInfoMapper.selectOne(
new LambdaQueryWrapper<CorporateWalletKeyInfoEntity>()
.eq(CorporateWalletKeyInfoEntity::getUserId, subject.getUserId())
.eq(CorporateWalletKeyInfoEntity::getSchoolId, subject.getSchoolId())
.eq(CorporateWalletKeyInfoEntity::getClassId, subject.getClassId())
.eq(CorporateWalletKeyInfoEntity::getKeyType, REVIEWER_KEY_TYPE)
.eq(CorporateWalletKeyInfoEntity::getDeleted, false)
.orderByDesc(CorporateWalletKeyInfoEntity::getCreatedAt)
.last("LIMIT 1"));
return entity == null ? null : entity.getKeyValue();
}
/**
* 计算当日累计发放金额:查询当天所有已发送批次的总金额 + 当前批次金额
*/
private BigDecimal calculateDailyTotal(InstitutionKeySubject subject, BigDecimal currentBatchTotal) {
// 简化处理:当前批次金额即为当日累计(教学仿真场景)
// 如需精确统计,可扩展 repository 添加按日期查询方法
return currentBatchTotal;
}
/**
* 构建审核校验结果(已校验过时直接返回)
*/
private SalaryReviewCheckResult buildCheckResult(SalaryBatch batch) {
BigDecimal maxSingleAmount = BigDecimal.ZERO;
for (String[] emp : EMPLOYEE_DATA) {
BigDecimal amount = new BigDecimal(emp[3]);
if (amount.compareTo(maxSingleAmount) > 0) {
maxSingleAmount = amount;
}
}
BigDecimal batchTotal = new BigDecimal(calculateTotalAmount());
BigDecimal dailyTotal = calculateDailyTotal(
new InstitutionKeySubject(batch.getUserId(), batch.getSchoolId(), batch.getClassId()), batchTotal);
String singleDesc = "单笔最高金额 " + maxSingleAmount.setScale(2).toPlainString() + " 元 "
+ (batch.getSingleLimitStatus() == STATUS_PASSED ? "≤" : ">")
+ " " + SINGLE_LIMIT.setScale(2).toPlainString() + " 元";
String batchDesc = "批次总金额 " + batchTotal.setScale(2).toPlainString() + " 元 "
+ (batch.getBatchLimitStatus() == STATUS_PASSED ? "≤" : ">")
+ " " + BATCH_LIMIT.setScale(2).toPlainString() + " 元";
String dailyDesc = "当日累计发放金额 " + dailyTotal.setScale(2).toPlainString() + " 元 "
+ (batch.getDailyLimitStatus() == STATUS_PASSED ? "≤" : ">")
+ " " + DAILY_LIMIT.setScale(2).toPlainString() + " 元";
return new SalaryReviewCheckResult(
batch.getSingleLimitStatus(), SINGLE_LIMIT.setScale(2).toPlainString(),
maxSingleAmount.setScale(2).toPlainString(), singleDesc,
batch.getBatchLimitStatus(), BATCH_LIMIT.setScale(2).toPlainString(),
batchTotal.setScale(2).toPlainString(), batchDesc,
batch.getDailyLimitStatus(), DAILY_LIMIT.setScale(2).toPlainString(),
dailyTotal.setScale(2).toPlainString(), dailyDesc,
batch.getReviewResult());
}
/**
* 构建审核输出JSON
*/
private String buildReviewOutputJson(SalaryBatch batch) {
BigDecimal maxSingleAmount = BigDecimal.ZERO;
for (String[] emp : EMPLOYEE_DATA) {
BigDecimal amount = new BigDecimal(emp[3]);
if (amount.compareTo(maxSingleAmount) > 0) {
maxSingleAmount = amount;
}
}
BigDecimal batchTotal = new BigDecimal(calculateTotalAmount());
BigDecimal dailyTotal = calculateDailyTotal(
new InstitutionKeySubject(batch.getUserId(), batch.getSchoolId(), batch.getClassId()), batchTotal);
String singleResult = batch.getSingleLimitStatus() == STATUS_PASSED ? "PASSED" : "FAILED";
String batchResult = batch.getBatchLimitStatus() == STATUS_PASSED ? "PASSED" : "FAILED";
String dailyResult = batch.getDailyLimitStatus() == STATUS_PASSED ? "PASSED" : "FAILED";
String statusText = "APPROVED".equals(batch.getReviewResult()) ? "已批准" : "已拒绝";
StringBuilder sb = new StringBuilder();
sb.append("{\n");
sb.append(" \"batchId\": \"").append(batch.getBatchId()).append("\",\n");
sb.append(" \"reviewer\": \"").append(batch.getReviewerName()).append("\",\n");
sb.append(" \"reviewResult\": \"").append(batch.getReviewResult()).append("\",\n");
sb.append(" \"permissionCheck\": {\n");
sb.append(" \"singleLimit\": {\n");
sb.append(" \"limit\": \"").append(SINGLE_LIMIT.setScale(2).toPlainString()).append("\",\n");
sb.append(" \"actual\": \"").append(maxSingleAmount.setScale(2).toPlainString()).append("\",\n");
sb.append(" \"result\": \"").append(singleResult).append("\"\n");
sb.append(" },\n");
sb.append(" \"batchLimit\": {\n");
sb.append(" \"limit\": \"").append(BATCH_LIMIT.setScale(2).toPlainString()).append("\",\n");
sb.append(" \"actual\": \"").append(batchTotal.setScale(2).toPlainString()).append("\",\n");
sb.append(" \"result\": \"").append(batchResult).append("\"\n");
sb.append(" },\n");
sb.append(" \"dailyLimit\": {\n");
sb.append(" \"limit\": \"").append(DAILY_LIMIT.setScale(2).toPlainString()).append("\",\n");
sb.append(" \"actual\": \"").append(dailyTotal.setScale(2).toPlainString()).append("\",\n");
sb.append(" \"result\": \"").append(dailyResult).append("\"\n");
sb.append(" }\n");
sb.append(" },\n");
sb.append(" \"reviewerSignature\": \"").append(batch.getReviewSignature()).append("\",\n");
sb.append(" \"reviewTime\": \"").append(batch.getReviewTimestamp()).append("\",\n");
sb.append(" \"status\": \"").append(statusText).append("\"\n");
sb.append("}");
return sb.toString();
}
private String generateBatchId() {
String datePart = LocalDateTime.now().format(DATE_FORMAT);
int seq = ThreadLocalRandom.current().nextInt(100, 1000);
@ -315,4 +702,21 @@ public class SalaryBatchService {
}
return String.format("%.2f", total);
}
/**
* 构建交易确认输出JSON
*/
private String buildConfirmOutputJson(SalaryBatch batch) {
StringBuilder sb = new StringBuilder();
sb.append("{\n");
sb.append(" \"batchId\": \"").append(batch.getBatchId()).append("\",\n");
sb.append(" \"totalAmount\": \"").append(calculateTotalAmount()).append("\",\n");
sb.append(" \"employeeCount\": ").append(EMPLOYEE_DATA.length).append(",\n");
sb.append(" \"digest\": \"").append(batch.getConfirmDigestValue()).append("\",\n");
sb.append(" \"cbSignature\": \"").append(batch.getCbSignature()).append("\",\n");
sb.append(" \"confirmTime\": \"").append(batch.getConfirmTimestamp()).append("\",\n");
sb.append(" \"status\": \"COMPLETED\"\n");
sb.append("}");
return sb.toString();
}
}

@ -0,0 +1,15 @@
package com.yau.digitalrmb.corporatewallet.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
@Getter
@Schema(description = "交易确认SM3摘要结果")
public class SalaryConfirmDigestResult {
@Schema(description = "摘要值(十六进制大写)")
private final String digestValue;
public SalaryConfirmDigestResult(String digestValue) {
this.digestValue = digestValue;
}
}

@ -0,0 +1,28 @@
package com.yau.digitalrmb.corporatewallet.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
@Getter
@Schema(description = "提取交易确认原文结果")
public class SalaryConfirmExtractResult {
@Schema(description = "确认原文(管道分隔),格式:CONFIRM|批次号|总金额|人数|时间戳")
private final String confirmMessage;
@Schema(description = "批次号")
private final String batchId;
@Schema(description = "工资金额汇总(元)")
private final String totalAmount;
@Schema(description = "员工人数")
private final int employeeCount;
@Schema(description = "确认时间戳")
private final String timestamp;
public SalaryConfirmExtractResult(String confirmMessage, String batchId,
String totalAmount, int employeeCount, String timestamp) {
this.confirmMessage = confirmMessage;
this.batchId = batchId;
this.totalAmount = totalAmount;
this.employeeCount = employeeCount;
this.timestamp = timestamp;
}
}

@ -0,0 +1,15 @@
package com.yau.digitalrmb.corporatewallet.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
@Getter
@Schema(description = "交易确认输出JSON结果")
public class SalaryConfirmOutputResult {
@Schema(description = "输出JSON报文")
private final String outputJson;
public SalaryConfirmOutputResult(String outputJson) {
this.outputJson = outputJson;
}
}

@ -0,0 +1,15 @@
package com.yau.digitalrmb.corporatewallet.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
@Getter
@Schema(description = "交易确认央行签名结果")
public class SalaryConfirmSignatureResult {
@Schema(description = "央行签名值(CB_FINAL_前缀+十六进制)")
private final String cbSignature;
public SalaryConfirmSignatureResult(String cbSignature) {
this.cbSignature = cbSignature;
}
}

@ -32,11 +32,45 @@ public class SalaryPageResult {
private final String outputJson;
@Schema(description = "状态(若已发送)")
private final String status;
@Schema(description = "审核原文(若已提取)")
private final String reviewMessage;
@Schema(description = "复核员姓名(若已提取)")
private final String reviewerName;
@Schema(description = "单笔限额状态:1-通过, 2-未通过(若已校验)")
private final Integer singleLimitStatus;
@Schema(description = "批次限额状态:1-通过, 2-未通过(若已校验)")
private final Integer batchLimitStatus;
@Schema(description = "日累计限额状态:1-通过, 2-未通过(若已校验)")
private final Integer dailyLimitStatus;
@Schema(description = "审核结果:APPROVED-已批准, REJECTED-已拒绝(若已校验)")
private final String reviewResult;
@Schema(description = "审核摘要值(若已生成)")
private final String reviewDigestValue;
@Schema(description = "审核签名值(若已签名)")
private final String reviewSignature;
@Schema(description = "审核输出JSON(若已输出)")
private final String reviewOutputJson;
@Schema(description = "交易确认原文(若已提取)")
private final String confirmMessage;
@Schema(description = "确认时间戳(若已提取)")
private final String confirmTimestamp;
@Schema(description = "交易确认摘要值(若已生成)")
private final String confirmDigestValue;
@Schema(description = "央行签名值(若已签名)")
private final String cbSignature;
@Schema(description = "交易确认输出JSON(若已输出)")
private final String confirmOutputJson;
public SalaryPageResult(List<EmployeeDetail> payroll, String totalAmount, int employeeCount,
String batchId, String corpWalletId, String operatorName, String timestamp,
String concatenatedMessage, String digestValue, String signature,
String outputJson, String status) {
String outputJson, String status,
String reviewMessage, String reviewerName,
Integer singleLimitStatus, Integer batchLimitStatus, Integer dailyLimitStatus,
String reviewResult, String reviewDigestValue, String reviewSignature,
String reviewOutputJson,
String confirmMessage, String confirmTimestamp,
String confirmDigestValue, String cbSignature, String confirmOutputJson) {
this.payroll = payroll;
this.totalAmount = totalAmount;
this.employeeCount = employeeCount;
@ -49,6 +83,20 @@ public class SalaryPageResult {
this.signature = signature;
this.outputJson = outputJson;
this.status = status;
this.reviewMessage = reviewMessage;
this.reviewerName = reviewerName;
this.singleLimitStatus = singleLimitStatus;
this.batchLimitStatus = batchLimitStatus;
this.dailyLimitStatus = dailyLimitStatus;
this.reviewResult = reviewResult;
this.reviewDigestValue = reviewDigestValue;
this.reviewSignature = reviewSignature;
this.reviewOutputJson = reviewOutputJson;
this.confirmMessage = confirmMessage;
this.confirmTimestamp = confirmTimestamp;
this.confirmDigestValue = confirmDigestValue;
this.cbSignature = cbSignature;
this.confirmOutputJson = confirmOutputJson;
}
@Getter

@ -0,0 +1,57 @@
package com.yau.digitalrmb.corporatewallet.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
@Getter
@Schema(description = "审核与权限校验结果")
public class SalaryReviewCheckResult {
@Schema(description = "单笔限额校验:1-通过, 2-未通过")
private final int singleLimitStatus;
@Schema(description = "单笔限额上限(元)")
private final String singleLimit;
@Schema(description = "单笔最高金额(元)")
private final String singleActual;
@Schema(description = "单笔限额说明")
private final String singleLimitDesc;
@Schema(description = "批次限额校验:1-通过, 2-未通过")
private final int batchLimitStatus;
@Schema(description = "批次限额上限(元)")
private final String batchLimit;
@Schema(description = "批次总金额(元)")
private final String batchActual;
@Schema(description = "批次限额说明")
private final String batchLimitDesc;
@Schema(description = "日累计限额校验:1-通过, 2-未通过")
private final int dailyLimitStatus;
@Schema(description = "日累计限额上限(元)")
private final String dailyLimit;
@Schema(description = "当日累计发放金额(元)")
private final String dailyActual;
@Schema(description = "日累计限额说明")
private final String dailyLimitDesc;
@Schema(description = "审核结果:APPROVED-已批准, REJECTED-已拒绝")
private final String reviewResult;
public SalaryReviewCheckResult(int singleLimitStatus, String singleLimit, String singleActual, String singleLimitDesc,
int batchLimitStatus, String batchLimit, String batchActual, String batchLimitDesc,
int dailyLimitStatus, String dailyLimit, String dailyActual, String dailyLimitDesc,
String reviewResult) {
this.singleLimitStatus = singleLimitStatus;
this.singleLimit = singleLimit;
this.singleActual = singleActual;
this.singleLimitDesc = singleLimitDesc;
this.batchLimitStatus = batchLimitStatus;
this.batchLimit = batchLimit;
this.batchActual = batchActual;
this.batchLimitDesc = batchLimitDesc;
this.dailyLimitStatus = dailyLimitStatus;
this.dailyLimit = dailyLimit;
this.dailyActual = dailyActual;
this.dailyLimitDesc = dailyLimitDesc;
this.reviewResult = reviewResult;
}
}

@ -0,0 +1,15 @@
package com.yau.digitalrmb.corporatewallet.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
@Getter
@Schema(description = "审核SM3摘要结果(只返回摘要值)")
public class SalaryReviewDigestResult {
@Schema(description = "摘要值(十六进制大写)")
private final String digestValue;
public SalaryReviewDigestResult(String digestValue) {
this.digestValue = digestValue;
}
}

@ -0,0 +1,31 @@
package com.yau.digitalrmb.corporatewallet.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
@Getter
@Schema(description = "提取审核原文结果")
public class SalaryReviewExtractResult {
@Schema(description = "审核原文(管道分隔),格式:REVIEW|批次号|复核员|总金额|人数|时间戳")
private final String reviewMessage;
@Schema(description = "批次号")
private final String batchId;
@Schema(description = "复核员姓名")
private final String reviewer;
@Schema(description = "工资金额汇总(元)")
private final String totalAmount;
@Schema(description = "员工人数")
private final int employeeCount;
@Schema(description = "审核时间戳")
private final String timestamp;
public SalaryReviewExtractResult(String reviewMessage, String batchId, String reviewer,
String totalAmount, int employeeCount, String timestamp) {
this.reviewMessage = reviewMessage;
this.batchId = batchId;
this.reviewer = reviewer;
this.totalAmount = totalAmount;
this.employeeCount = employeeCount;
this.timestamp = timestamp;
}
}

@ -0,0 +1,15 @@
package com.yau.digitalrmb.corporatewallet.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
@Getter
@Schema(description = "审核输出JSON结果")
public class SalaryReviewOutputResult {
@Schema(description = "输出JSON报文")
private final String outputJson;
public SalaryReviewOutputResult(String outputJson) {
this.outputJson = outputJson;
}
}

@ -0,0 +1,15 @@
package com.yau.digitalrmb.corporatewallet.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
@Getter
@Schema(description = "审核SM2签名结果(只返回签名值)")
public class SalaryReviewSignatureResult {
@Schema(description = "签名值(十六进制)")
private final String signature;
public SalaryReviewSignatureResult(String signature) {
this.signature = signature;
}
}

@ -2,7 +2,9 @@ package com.yau.digitalrmb.corporatewallet.domain;
public class SalaryBatch {
public enum Status {
DRAFT, BLOCKCHAIN_FETCHED, CONCATENATED, DIGESTED, SIGNED, OUTPUT, SENT
DRAFT, BLOCKCHAIN_FETCHED, CONCATENATED, DIGESTED, SIGNED, OUTPUT, SENT,
REVIEW_EXTRACTED, REVIEW_CHECKED, REVIEW_DIGESTED, REVIEW_SIGNED, REVIEWED,
CONFIRM_EXTRACTED, CONFIRM_DIGESTED, CONFIRM_SIGNED, CONFIRM_COMPLETED
}
private Long id;
@ -33,6 +35,27 @@ public class SalaryBatch {
private Status status;
private String submitTime;
// 复核员审核相关
private String reviewerName;
private String reviewTimestamp;
private String reviewConcatenatedMessage;
private String reviewDigestAlgorithm;
private String reviewDigestValue;
private String reviewSignature;
private String reviewOutputJson;
private Integer singleLimitStatus;
private Integer batchLimitStatus;
private Integer dailyLimitStatus;
private String reviewResult;
// 交易确认相关
private String confirmMessage;
private String confirmTimestamp;
private String confirmDigestAlgorithm;
private String confirmDigestValue;
private String cbSignature;
private String confirmOutputJson;
public static SalaryBatch create(String userId, long schoolId, long classId) {
SalaryBatch batch = new SalaryBatch();
batch.userId = userId;
@ -76,6 +99,59 @@ public class SalaryBatch {
this.status = Status.SENT;
}
public void extractReviewText(String reviewerName, String reviewTimestamp, String reviewMessage) {
this.reviewerName = reviewerName;
this.reviewTimestamp = reviewTimestamp;
this.reviewConcatenatedMessage = reviewMessage;
this.status = Status.REVIEW_EXTRACTED;
}
public void checkPermission(Integer singleLimitStatus, Integer batchLimitStatus, Integer dailyLimitStatus, String reviewResult) {
this.singleLimitStatus = singleLimitStatus;
this.batchLimitStatus = batchLimitStatus;
this.dailyLimitStatus = dailyLimitStatus;
this.reviewResult = reviewResult;
this.status = Status.REVIEW_CHECKED;
}
public void computeReviewDigest(String algorithm, String digest) {
this.reviewDigestAlgorithm = algorithm;
this.reviewDigestValue = digest;
this.status = Status.REVIEW_DIGESTED;
}
public void signReview(String signature) {
this.reviewSignature = signature;
this.status = Status.REVIEW_SIGNED;
}
public void outputReview(String outputJson) {
this.reviewOutputJson = outputJson;
this.status = Status.REVIEWED;
}
public void extractConfirmText(String confirmTimestamp, String confirmMessage) {
this.confirmTimestamp = confirmTimestamp;
this.confirmMessage = confirmMessage;
this.status = Status.CONFIRM_EXTRACTED;
}
public void computeConfirmDigest(String algorithm, String digest) {
this.confirmDigestAlgorithm = algorithm;
this.confirmDigestValue = digest;
this.status = Status.CONFIRM_DIGESTED;
}
public void signConfirm(String cbSignature) {
this.cbSignature = cbSignature;
this.status = Status.CONFIRM_SIGNED;
}
public void outputConfirm(String outputJson) {
this.confirmOutputJson = outputJson;
this.status = Status.CONFIRM_COMPLETED;
}
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
@ -107,4 +183,38 @@ public class SalaryBatch {
public void setStatus(Status status) { this.status = status; }
public String getSubmitTime() { return submitTime; }
public void setSubmitTime(String submitTime) { this.submitTime = submitTime; }
public String getReviewerName() { return reviewerName; }
public void setReviewerName(String reviewerName) { this.reviewerName = reviewerName; }
public String getReviewTimestamp() { return reviewTimestamp; }
public void setReviewTimestamp(String reviewTimestamp) { this.reviewTimestamp = reviewTimestamp; }
public String getReviewConcatenatedMessage() { return reviewConcatenatedMessage; }
public void setReviewConcatenatedMessage(String reviewConcatenatedMessage) { this.reviewConcatenatedMessage = reviewConcatenatedMessage; }
public String getReviewDigestAlgorithm() { return reviewDigestAlgorithm; }
public void setReviewDigestAlgorithm(String reviewDigestAlgorithm) { this.reviewDigestAlgorithm = reviewDigestAlgorithm; }
public String getReviewDigestValue() { return reviewDigestValue; }
public void setReviewDigestValue(String reviewDigestValue) { this.reviewDigestValue = reviewDigestValue; }
public String getReviewSignature() { return reviewSignature; }
public void setReviewSignature(String reviewSignature) { this.reviewSignature = reviewSignature; }
public String getReviewOutputJson() { return reviewOutputJson; }
public void setReviewOutputJson(String reviewOutputJson) { this.reviewOutputJson = reviewOutputJson; }
public Integer getSingleLimitStatus() { return singleLimitStatus; }
public void setSingleLimitStatus(Integer singleLimitStatus) { this.singleLimitStatus = singleLimitStatus; }
public Integer getBatchLimitStatus() { return batchLimitStatus; }
public void setBatchLimitStatus(Integer batchLimitStatus) { this.batchLimitStatus = batchLimitStatus; }
public Integer getDailyLimitStatus() { return dailyLimitStatus; }
public void setDailyLimitStatus(Integer dailyLimitStatus) { this.dailyLimitStatus = dailyLimitStatus; }
public String getReviewResult() { return reviewResult; }
public void setReviewResult(String reviewResult) { this.reviewResult = reviewResult; }
public String getConfirmMessage() { return confirmMessage; }
public void setConfirmMessage(String confirmMessage) { this.confirmMessage = confirmMessage; }
public String getConfirmTimestamp() { return confirmTimestamp; }
public void setConfirmTimestamp(String confirmTimestamp) { this.confirmTimestamp = confirmTimestamp; }
public String getConfirmDigestAlgorithm() { return confirmDigestAlgorithm; }
public void setConfirmDigestAlgorithm(String confirmDigestAlgorithm) { this.confirmDigestAlgorithm = confirmDigestAlgorithm; }
public String getConfirmDigestValue() { return confirmDigestValue; }
public void setConfirmDigestValue(String confirmDigestValue) { this.confirmDigestValue = confirmDigestValue; }
public String getCbSignature() { return cbSignature; }
public void setCbSignature(String cbSignature) { this.cbSignature = cbSignature; }
public String getConfirmOutputJson() { return confirmOutputJson; }
public void setConfirmOutputJson(String confirmOutputJson) { this.confirmOutputJson = confirmOutputJson; }
}

@ -56,6 +56,23 @@ public class MybatisSalaryBatchRepository implements SalaryBatchRepository {
entity.setOutputJson(batch.getOutputJson());
entity.setStatus(batch.getStatus() == null ? null : batch.getStatus().name());
entity.setSubmitTime(batch.getSubmitTime());
entity.setReviewerName(batch.getReviewerName());
entity.setReviewTimestamp(batch.getReviewTimestamp());
entity.setReviewConcatenatedMessage(batch.getReviewConcatenatedMessage());
entity.setReviewDigestAlgorithm(batch.getReviewDigestAlgorithm());
entity.setReviewDigestValue(batch.getReviewDigestValue());
entity.setReviewSignature(batch.getReviewSignature());
entity.setReviewOutputJson(batch.getReviewOutputJson());
entity.setSingleLimitStatus(batch.getSingleLimitStatus());
entity.setBatchLimitStatus(batch.getBatchLimitStatus());
entity.setDailyLimitStatus(batch.getDailyLimitStatus());
entity.setReviewResult(batch.getReviewResult());
entity.setConfirmMessage(batch.getConfirmMessage());
entity.setConfirmTimestamp(batch.getConfirmTimestamp());
entity.setConfirmDigestAlgorithm(batch.getConfirmDigestAlgorithm());
entity.setConfirmDigestValue(batch.getConfirmDigestValue());
entity.setCbSignature(batch.getCbSignature());
entity.setConfirmOutputJson(batch.getConfirmOutputJson());
return entity;
}
@ -76,6 +93,23 @@ public class MybatisSalaryBatchRepository implements SalaryBatchRepository {
batch.setOutputJson(entity.getOutputJson());
batch.setStatus(entity.getStatus() == null ? null : SalaryBatch.Status.valueOf(entity.getStatus()));
batch.setSubmitTime(entity.getSubmitTime());
batch.setReviewerName(entity.getReviewerName());
batch.setReviewTimestamp(entity.getReviewTimestamp());
batch.setReviewConcatenatedMessage(entity.getReviewConcatenatedMessage());
batch.setReviewDigestAlgorithm(entity.getReviewDigestAlgorithm());
batch.setReviewDigestValue(entity.getReviewDigestValue());
batch.setReviewSignature(entity.getReviewSignature());
batch.setReviewOutputJson(entity.getReviewOutputJson());
batch.setSingleLimitStatus(entity.getSingleLimitStatus());
batch.setBatchLimitStatus(entity.getBatchLimitStatus());
batch.setDailyLimitStatus(entity.getDailyLimitStatus());
batch.setReviewResult(entity.getReviewResult());
batch.setConfirmMessage(entity.getConfirmMessage());
batch.setConfirmTimestamp(entity.getConfirmTimestamp());
batch.setConfirmDigestAlgorithm(entity.getConfirmDigestAlgorithm());
batch.setConfirmDigestValue(entity.getConfirmDigestValue());
batch.setCbSignature(entity.getCbSignature());
batch.setConfirmOutputJson(entity.getConfirmOutputJson());
return batch;
}
}

@ -38,4 +38,42 @@ public class SalaryBatchEntity extends AuditableEntity {
private String status;
@TableField("submit_time")
private String submitTime;
// 复核员审核相关
@TableField("reviewer_name")
private String reviewerName;
@TableField("review_timestamp")
private String reviewTimestamp;
@TableField("review_concatenated_message")
private String reviewConcatenatedMessage;
@TableField("review_digest_algorithm")
private String reviewDigestAlgorithm;
@TableField("review_digest_value")
private String reviewDigestValue;
@TableField("review_signature")
private String reviewSignature;
@TableField("review_output_json")
private String reviewOutputJson;
@TableField("single_limit_status")
private Integer singleLimitStatus;
@TableField("batch_limit_status")
private Integer batchLimitStatus;
@TableField("daily_limit_status")
private Integer dailyLimitStatus;
@TableField("review_result")
private String reviewResult;
// 交易确认相关
@TableField("confirm_message")
private String confirmMessage;
@TableField("confirm_timestamp")
private String confirmTimestamp;
@TableField("confirm_digest_algorithm")
private String confirmDigestAlgorithm;
@TableField("confirm_digest_value")
private String confirmDigestValue;
@TableField("cb_signature")
private String cbSignature;
@TableField("confirm_output_json")
private String confirmOutputJson;
}

@ -39,7 +39,7 @@ public class CorporateWalletController {
@PostMapping("/keys")
@Operation(summary = "获取全部密钥信息",
description = "复用央行第一密钥和商业银行第二密钥,新生成企业法人、数币操作员、数币复核员、监管机构密钥,存储到数据库并返回")
public ApiResponse<List<CorporateWalletKeyResult>> getAllKeys() {
public ApiResponse<List<CorporateWalletKeyResult>> getAllKeys() {
JwtUser jwtUser = AuthContextHolder.get();
return ApiResponse.success(corporateWalletKeyService.getAllKeys(
InstitutionKeySubject.from(jwtUser), jwtUser.getUsername()), traceId());

@ -0,0 +1,63 @@
package com.yau.digitalrmb.corporatewallet.interfaces.rest;
import com.yau.digitalrmb.corporatewallet.application.SalaryBatchService;
import com.yau.digitalrmb.corporatewallet.application.SalaryConfirmDigestResult;
import com.yau.digitalrmb.corporatewallet.application.SalaryConfirmExtractResult;
import com.yau.digitalrmb.corporatewallet.application.SalaryConfirmOutputResult;
import com.yau.digitalrmb.corporatewallet.application.SalaryConfirmSignatureResult;
import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.SalaryConfirmSignRequest;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.security.context.AuthContextHolder;
import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.web.TraceIdFilter;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.MDC;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.validation.Valid;
@RestController
@RequestMapping("/api/v1/salary/confirm")
@Tag(name = "模块六-企业发放数字人民币工资 - 步骤3:交易确认与工资发放", description = "提取交易确认原文、生成摘要、央行签名并输出全流程")
public class SalaryConfirmController {
@Resource
private SalaryBatchService salaryBatchService;
@PostMapping("/extract")
@Operation(summary = "提取交易确认原文",
description = "从已审核的工资批次中提取交易确认原文,格式:CONFIRM|批次号|总金额|人数|时间戳")
public ApiResponse<SalaryConfirmExtractResult> extractConfirmText() {
return ApiResponse.success(salaryBatchService.extractConfirmText(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
}
@PostMapping("/digest")
@Operation(summary = "生成交易确认摘要",
description = "对拼接交易确认原文进行SM3运算,只返回摘要值")
public ApiResponse<SalaryConfirmDigestResult> computeConfirmDigest() {
return ApiResponse.success(salaryBatchService.computeConfirmDigest(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
}
@PostMapping("/sign")
@Operation(summary = "央行第一私钥签名",
description = "传入央行第一私钥进行SM2签名,私钥不正确则错误数+1,返回带CB_FINAL_前缀的签名值")
public ApiResponse<SalaryConfirmSignatureResult> signConfirm(@Valid @RequestBody SalaryConfirmSignRequest request) {
return ApiResponse.success(salaryBatchService.signConfirm(
InstitutionKeySubject.from(AuthContextHolder.get()), request.getPrivateKey()), traceId());
}
@PostMapping("/output")
@Operation(summary = "输出交易确认结果",
description = "生成包含批次号、总金额、摘要、央行签名等信息的JSON报文,状态为COMPLETED")
public ApiResponse<SalaryConfirmOutputResult> outputConfirm() {
return ApiResponse.success(salaryBatchService.outputConfirm(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
}
private String traceId() {
return MDC.get(TraceIdFilter.MDC_KEY);
}
}

@ -0,0 +1,72 @@
package com.yau.digitalrmb.corporatewallet.interfaces.rest;
import com.yau.digitalrmb.corporatewallet.application.SalaryBatchService;
import com.yau.digitalrmb.corporatewallet.application.SalaryReviewCheckResult;
import com.yau.digitalrmb.corporatewallet.application.SalaryReviewDigestResult;
import com.yau.digitalrmb.corporatewallet.application.SalaryReviewExtractResult;
import com.yau.digitalrmb.corporatewallet.application.SalaryReviewOutputResult;
import com.yau.digitalrmb.corporatewallet.application.SalaryReviewSignatureResult;
import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.SalaryReviewSignRequest;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.security.context.AuthContextHolder;
import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.web.TraceIdFilter;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.MDC;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.validation.Valid;
@RestController
@RequestMapping("/api/v1/salary/review")
@Tag(name = "模块六-企业发放数字人民币工资 - 步骤2:数币复核员审核与权限审批", description = "数币复核员提取审核原文、权限校验、摘要、签名并输出全流程")
public class SalaryReviewController {
@Resource
private SalaryBatchService salaryBatchService;
@PostMapping("/extract")
@Operation(summary = "提取审核原文",
description = "从已发送的工资批次中提取审核原文,格式:REVIEW|批次号|复核员|总金额|人数|时间戳")
public ApiResponse<SalaryReviewExtractResult> extractReviewText() {
return ApiResponse.success(salaryBatchService.extractReviewText(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
}
@PostMapping("/check")
@Operation(summary = "审核与权限校验",
description = "校验单笔限额(≤50000元)、批次限额(≤500000元)、日累计限额(≤1000000元),返回1-通过/2-未通过")
public ApiResponse<SalaryReviewCheckResult> checkPermission() {
return ApiResponse.success(salaryBatchService.checkPermission(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
}
@PostMapping("/digest")
@Operation(summary = "生成审核摘要",
description = "对拼接审核原文进行SM3运算,只返回摘要值")
public ApiResponse<SalaryReviewDigestResult> computeReviewDigest() {
return ApiResponse.success(salaryBatchService.computeReviewDigest(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
}
@PostMapping("/sign")
@Operation(summary = "SM2签名(复核员私钥)",
description = "传入数币复核员私钥进行SM2签名,私钥不正确则错误数+1,只返回签名值")
public ApiResponse<SalaryReviewSignatureResult> signReview(@Valid @RequestBody SalaryReviewSignRequest request) {
return ApiResponse.success(salaryBatchService.signReview(
InstitutionKeySubject.from(AuthContextHolder.get()), request.getPrivateKey()), traceId());
}
@PostMapping("/output")
@Operation(summary = "输出审核结果",
description = "生成包含批次号、复核员、权限校验、签名等信息的JSON报文")
public ApiResponse<SalaryReviewOutputResult> outputReview() {
return ApiResponse.success(salaryBatchService.outputReview(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
}
private String traceId() {
return MDC.get(TraceIdFilter.MDC_KEY);
}
}

@ -0,0 +1,14 @@
package com.yau.digitalrmb.corporatewallet.interfaces.rest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
@Schema(description = "交易确认央行签名请求")
public class SalaryConfirmSignRequest {
@NotBlank(message = "央行第一私钥不能为空")
@Schema(description = "央行第一私钥(十六进制)", required = true)
private String privateKey;
}

@ -0,0 +1,14 @@
package com.yau.digitalrmb.corporatewallet.interfaces.rest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
@Schema(description = "复核员SM2签名请求")
public class SalaryReviewSignRequest {
@NotBlank(message = "数币复核员私钥不能为空")
@Schema(description = "数币复核员私钥(十六进制)", required = true)
private String privateKey;
}

@ -0,0 +1,64 @@
package com.yau.digitalrmb.institutionidentity.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import java.util.Arrays;
import java.util.List;
@Getter
@Schema(description = "币串ID生成规则")
public class CurrencyIdRuleResult {
@Schema(description = "DC前缀", example = "DC")
private final String prefix;
@Schema(description = "币串ID生成规则", example = "批次号+枚序号+随机校验码")
private final String idRule;
@Schema(description = "当前批次号", example = "20260819_001")
private final String batchNumber;
@Schema(description = "币串总枚数", example = "28")
private final Integer totalCount;
@Schema(description = "操作状态", example = "RULE_CONFIRMED")
private final String status;
@Schema(description = "币串ID规则明细")
private final List<RuleItem> ruleItems;
public CurrencyIdRuleResult(String prefix, String idRule, String batchNumber,
Integer totalCount, String status) {
this.prefix = prefix;
this.idRule = idRule;
this.batchNumber = batchNumber;
this.totalCount = totalCount;
this.status = status;
this.ruleItems = buildRuleItems(batchNumber, totalCount);
}
private List<RuleItem> buildRuleItems(String batchNumber, Integer totalCount) {
String lastSeq = totalCount != null ? String.format("%03d", totalCount) : "028";
String serialExample = "001、002 ... " + lastSeq;
String checkCodeExample = "A1B2C3";
String fullIdExample = "DC_" + batchNumber + "_001_" + checkCodeExample;
return Arrays.asList(
new RuleItem("批次号", batchNumber, "年月日_当日批次序号"),
new RuleItem("枚序号", serialExample, "该批次内的唯一序号"),
new RuleItem("随机校验码", checkCodeExample, "6位随机字符,用于防碰撞和校验"),
new RuleItem("完整币串ID", fullIdExample, "DC_前缀 + 批次号 + 枚序号 + 校验码")
);
}
@Getter
@Schema(description = "币串ID规则明细项")
public static class RuleItem {
@Schema(description = "字段名", example = "批次号")
private final String field;
@Schema(description = "示例", example = "20260819_001")
private final String example;
@Schema(description = "说明", example = "年月日_当日批次序号")
private final String description;
public RuleItem(String field, String example, String description) {
this.field = field;
this.example = example;
this.description = description;
}
}
}

@ -19,7 +19,9 @@ public class QuotaRequestSourceDataResult {
this.transactionIdentifier = value.getTransactionIdentifier();
}
public BigDecimal getAmount() { return amount; }
public String getInstitutionIdentifier() { return institutionIdentifier; }
public String getTransactionIdentifier() { return transactionIdentifier; }
}

@ -0,0 +1,76 @@
package com.yau.digitalrmb.institutionidentity.application;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
@Getter
@Schema(description = "标准面额币串规则")
public class StandardCurrencyRuleResult {
@Schema(description = "DC前缀", example = "DC")
private final String prefix;
@Schema(description = "币串初始状态", example = "待生效")
private final String currencyStatus;
@Schema(description = "当前批次号", example = "20260819_001")
private final String batchNumber;
@Schema(description = "币串总枚数", example = "28")
private final Integer totalCount;
@Schema(description = "操作状态", example = "RULE_CONFIRMED")
private final String status;
@Schema(description = "标准面额币串规则明细(以第1枚币串为例)")
private final List<RuleItem> ruleItems;
public StandardCurrencyRuleResult(String prefix, String currencyStatus, String batchNumber,
Integer totalCount, String status,
BigDecimal firstDenomination, String institutionIdentifier,
String bankSignature, String centralBankSignatureSegment) {
this.prefix = prefix;
this.currencyStatus = currencyStatus;
this.batchNumber = batchNumber;
this.totalCount = totalCount;
this.status = status;
this.ruleItems = buildRuleItems(prefix, batchNumber, firstDenomination,
institutionIdentifier, bankSignature, centralBankSignatureSegment, currencyStatus);
}
private List<RuleItem> buildRuleItems(String prefix, String batchNumber,
BigDecimal firstDenomination, String institutionIdentifier,
String bankSignature, String centralBankSignatureSegment,
String currencyStatus) {
String checkCodeExample = "A1B2C3";
String currencyIdExample = "DC_" + batchNumber + "_001_" + checkCodeExample;
String denominationStr = firstDenomination != null ? firstDenomination.setScale(2).toPlainString() : "100.00";
String orgId = institutionIdentifier != null ? institutionIdentifier : "ORG_3A4B5C6D7E8F";
String bankSig = bankSignature != null ? bankSignature : "SIG_8A7B9C2D4F1E3A5B7C9D2E4F6A8B1C3D5E7F8A9B0C1D2E3F4A5B6C7D8E9F0A";
String cbSig = centralBankSignatureSegment != null ? centralBankSignatureSegment : "CB_CTRL_9C0D1E2F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1A2B3C4D5E6F7A8";
return Arrays.asList(
new RuleItem("前缀", prefix, "固定标识,代表数字货币"),
new RuleItem("币串ID", currencyIdExample, "包含批次号001和枚序号001,确保全球唯一"),
new RuleItem("标准面额", denominationStr, "拆解后的标准面额,非总额"),
new RuleItem("机构标识", orgId, "关联发起机构,用于溯源"),
new RuleItem("银行签名段", bankSig, "复用步骤八的银行签名段"),
new RuleItem("央行签名段", cbSig, "复用步骤七的央行签名段"),
new RuleItem("币串状态", currencyStatus, "发行时为待生效,央行确权后变为可用")
);
}
@Getter
@Schema(description = "标准面额币串规则明细项")
public static class RuleItem {
@Schema(description = "币串字段", example = "前缀")
private final String field;
@Schema(description = "示例值(以第1枚币串为例)", example = "DC")
private final String example;
@Schema(description = "说明", example = "固定标识,代表数字货币")
private final String description;
public RuleItem(String field, String example, String description) {
this.field = field;
this.example = example;
this.description = description;
}
}
}

@ -96,21 +96,11 @@ public class StandardCurrencyService {
}
@Transactional
public StandardCurrencyBatchResult confirmRule(String prefix, String idRule, String currencyStatus,
InstitutionKeySubject subject, String operator) {
public CurrencyIdRuleResult confirmIdRule(InstitutionKeySubject subject, String operator) {
StandardCurrencyBatchEntity batch = requireLatest(subject);
if (batch.getDenominationSummary() == null || batch.getTotalAmount() == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先执行面额拆解");
}
if (!"DC".equals(prefix == null ? null : prefix.trim().toUpperCase())) {
throw inputError(subject, "币串前缀必须为DC");
}
if (!ID_RULE.equals(idRule == null ? null : idRule.trim())) {
throw inputError(subject, "币串ID规则必须为“批次号+枚序号+随机校验码”");
}
if (!"待生效".equals(currencyStatus == null ? null : currencyStatus.trim())) {
throw inputError(subject, "币串初始状态必须为“待生效”");
}
int updated = batchMapper.update(null, new LambdaUpdateWrapper<StandardCurrencyBatchEntity>()
.eq(StandardCurrencyBatchEntity::getId, batch.getId())
.eq(StandardCurrencyBatchEntity::getStatus, batch.getStatus())
@ -122,7 +112,25 @@ public class StandardCurrencyService {
.set(StandardCurrencyBatchEntity::getUpdatedAt, LocalDateTime.now())
.set(StandardCurrencyBatchEntity::getUpdatedBy, operator));
if (updated != 1) throw stateChanged();
return result(requireLatest(subject));
StandardCurrencyBatchEntity latest = requireLatest(subject);
return new CurrencyIdRuleResult("DC", ID_RULE,
latest.getBatchNumber(), latest.getTotalCount(), latest.getStatus());
}
@Transactional
public StandardCurrencyRuleResult confirmCurrencyRule(InstitutionKeySubject subject, String operator) {
StandardCurrencyBatchEntity batch = requireLatest(subject);
if (batch.getPrefix() == null || batch.getIdRule() == null) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先确认币串ID生成规则");
}
// 从步骤八获取真实数据
QuotaControlBitEntity step8 = requireStepEight(subject);
List<DenominationBreakdownResult> breakdown = decode(batch.getDenominationSummary());
BigDecimal firstDenomination = breakdown.isEmpty() ? new BigDecimal("100.00") : breakdown.get(0).getDenomination();
return new StandardCurrencyRuleResult(batch.getPrefix(), batch.getCurrencyStatus(),
batch.getBatchNumber(), batch.getTotalCount(), batch.getStatus(),
firstDenomination, step8.getInstitutionIdentifier(),
step8.getBankSignature(), step8.getCentralBankSignatureSegment());
}
@Transactional

@ -143,21 +143,64 @@ public class TransactionInformationIdentifierService {
return new TransactionIdentifierAssemblyResult(value);
}
@Transactional
public QuotaRequestSourceDataResult quotaRequestSourceData(InstitutionKeySubject subject) {
TransactionInformationIdentifier value = repository.findLatestBySubject(subject.getUserId(),
subject.getSchoolId(), subject.getClassId()).orElseThrow(() ->
new BusinessException(ErrorCode.VALIDATION_ERROR,
"步骤四尚未完成交易信息标识组装,不能查询步骤五前序数据"));
if (value.getStatus().ordinal() < TransactionInformationIdentifier.Status.ASSEMBLED.ordinal()
|| value.getTransactionIdentifier() == null
|| !value.getTransactionIdentifier().matches("TXN_[0-9A-F]{64}")) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"步骤四尚未生成交易信息标识,请先完成步骤四");
}
return new QuotaRequestSourceDataResult(value);
// @Transactional
// public QuotaRequestSourceDataResult quotaRequestSourceData(InstitutionKeySubject subject) {
// TransactionInformationIdentifier value = repository.findLatestBySubject(subject.getUserId(),
// subject.getSchoolId(), subject.getClassId()).orElseThrow(() ->
// new BusinessException(ErrorCode.VALIDATION_ERROR,
// "步骤四尚未完成交易信息标识组装,不能查询步骤五前序数据"));
// if (value.getStatus().ordinal() < TransactionInformationIdentifier.Status.ASSEMBLED.ordinal()
// || value.getTransactionIdentifier() == null
// || !value.getTransactionIdentifier().matches("TXN_[0-9A-F]{64}")) {
// throw new BusinessException(ErrorCode.VALIDATION_ERROR,
// "步骤四尚未生成交易信息标识,请先完成步骤四");
// }
// return new QuotaRequestSourceDataResult(value);
// }
//@Transactional(readOnly = true) // 既然只查询,用 readOnly 优化
//public QuotaRequestSourceDataResult quotaRequestSourceData(InstitutionKeySubject subject) {
// // 1. 尝试查询,查不到则直接返回空结果
// TransactionInformationIdentifier value = repository.findLatestBySubject(
// subject.getUserId(), subject.getSchoolId(), subject.getClassId())
// .orElse(null);
//
// // 2. 如果查不到,或者状态/标识不满足条件,返回一个“空”结果对象
// if (value == null ||
// value.getStatus().ordinal() < TransactionInformationIdentifier.Status.ASSEMBLED.ordinal() ||
// value.getTransactionIdentifier() == null ||
// !value.getTransactionIdentifier().matches("TXN_[0-9A-F]{64}")) {
//
// // 这里返回一个代表“未就绪”的空对象,前端通过检查关键字段(如 transactionIdentifier)是否为 null 来判断
// return new QuotaRequestSourceDataResult(null); // 假设你在 Result 类里增加了 static empty() 方法
// }
//
// // 3. 条件满足,正常返回
// return new QuotaRequestSourceDataResult(value);
//}
@Transactional(readOnly = true)
public QuotaRequestSourceDataResult quotaRequestSourceData(InstitutionKeySubject subject) {
TransactionInformationIdentifier value = repository.findLatestBySubject(
subject.getUserId(),
subject.getSchoolId(),
subject.getClassId()
).orElse(null);
// 无记录 → 返回 null
if (value == null) {
return null;
}
// 状态或标识不满足条件 → 返回 null
if (value.getStatus().ordinal() < TransactionInformationIdentifier.Status.ASSEMBLED.ordinal()
|| value.getTransactionIdentifier() == null
|| !value.getTransactionIdentifier().matches("TXN_[0-9A-F]{64}")) {
return null;
}
// 正常返回
return new QuotaRequestSourceDataResult(value);
}
@Transactional
public QuotaRequestDigestResult digestQuotaRequest(String originalText,
InstitutionKeySubject subject,
@ -315,17 +358,31 @@ public class TransactionInformationIdentifierService {
return new QuotaVerificationResult(value);
}
@Transactional(readOnly = true)
public QuotaRequestMessageResult quotaRequestMessageDetail(InstitutionKeySubject subject) {
return new QuotaRequestMessageResult(requireLatest(subject));
// @Transactional(readOnly = true)
// public QuotaRequestMessageResult quotaRequestMessageDetail(InstitutionKeySubject subject) {
// return new QuotaRequestMessageResult(requireLatest(subject));
// }
@Transactional(readOnly = true)
public QuotaRequestMessageResult quotaRequestMessageDetail(InstitutionKeySubject subject) {
TransactionInformationIdentifier value = repository.findLatestBySubject(
subject.getUserId(),
subject.getSchoolId(),
subject.getClassId()
).orElse(null);
if (value == null) {
return null; // 返回 null,前端收到 data: null,HTTP 200
}
return new QuotaRequestMessageResult(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);
TransactionInformationIdentifier value = repository.findLatestBySubject(
subject.getUserId(), subject.getSchoolId(), subject.getClassId())
.orElse(null); // 查不到就返回 null
// 注意:这里 value 可能为 null
return value != null ? new TransactionInformationIdentifierResult(value) : null;
}
private CurrencyGenerationVerificationEntity requireCompletedVerification(InstitutionKeySubject subject) {
@ -378,18 +435,21 @@ public class TransactionInformationIdentifierService {
return value;
}
private TransactionInformationIdentifier requireLatest(InstitutionKeySubject subject) {
// private TransactionInformationIdentifier requireLatest(InstitutionKeySubject subject) {
// return repository.findLatestBySubject(subject.getUserId(), subject.getSchoolId(), subject.getClassId())
// .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND,
// "当前没有额度请求记录"));
// }
private TransactionInformationIdentifier requireLatest(InstitutionKeySubject subject) {
return repository.findLatestBySubject(subject.getUserId(), subject.getSchoolId(), subject.getClassId())
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND,
"当前没有额度请求记录"));
.orElse(null);
}
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));
subject.getClassId(), status).orElse(null);
}
private BusinessException keyError(InstitutionKeySubject subject, String message) {

@ -1,19 +0,0 @@
package com.yau.digitalrmb.institutionidentity.interfaces.dto;
import lombok.Getter;
import lombok.Setter;
import javax.validation.constraints.NotBlank;
@Getter
@Setter
public class CurrencyRuleRequest {
@NotBlank(message = "币串前缀不能为空")
private String prefix;
@NotBlank(message = "币串ID规则不能为空")
private String idRule;
@NotBlank(message = "币串初始状态不能为空")
private String currencyStatus;
}

@ -1,12 +1,13 @@
package com.yau.digitalrmb.institutionidentity.interfaces.rest;
import com.yau.digitalrmb.institutionidentity.application.CurrencyIdRuleResult;
import com.yau.digitalrmb.institutionidentity.application.DenominationDecompositionResult;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.institutionidentity.application.StandardCurrencyBatchResult;
import com.yau.digitalrmb.institutionidentity.application.StandardCurrencyListItemResult;
import com.yau.digitalrmb.institutionidentity.application.StandardCurrencyOperationResult;
import com.yau.digitalrmb.institutionidentity.application.StandardCurrencyRuleResult;
import com.yau.digitalrmb.institutionidentity.application.StandardCurrencyService;
import com.yau.digitalrmb.institutionidentity.interfaces.dto.CurrencyRuleRequest;
import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.web.TraceIdFilter;
import com.yau.digitalrmb.security.context.AuthContextHolder;
@ -17,12 +18,10 @@ import org.slf4j.MDC;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.validation.Valid;
import java.util.List;
@Validated
@ -43,14 +42,22 @@ public class StandardCurrencyController {
result.getTotalCount(), result.getBreakdown()), traceId());
}
@PostMapping("/steps/confirm-rule")
@Operation(summary = "确认币串生成规则", description = "校验DC前缀、币串ID规则和待生效初始状态。")
public ApiResponse<StandardCurrencyOperationResult> confirmRule(@Valid @RequestBody CurrencyRuleRequest request) {
@PostMapping("/steps/confirm-id-rule")
@Operation(summary = "确认币串ID生成规则", description = "无需请求体。后端自动设置DC前缀和批次号+枚序号+随机校验码ID规则,返回ID规则明细表(批次号、枚序号、随机校验码、完整币串ID)。")
public ApiResponse<CurrencyIdRuleResult> confirmIdRule() {
JwtUser user = AuthContextHolder.get();
StandardCurrencyBatchResult result = service.confirmRule(request.getPrefix(), request.getIdRule(),
request.getCurrencyStatus(), InstitutionKeySubject.from(user), user.getUsername());
return ApiResponse.success(new StandardCurrencyOperationResult(
result.getStatus(), result.getTotalCount()), traceId());
CurrencyIdRuleResult result = service.confirmIdRule(
InstitutionKeySubject.from(user), user.getUsername());
return ApiResponse.success(result, traceId());
}
@PostMapping("/steps/confirm-currency-rule")
@Operation(summary = "确认标准面额币串规则", description = "无需请求体。返回标准面额币串规则明细表(前缀、币串ID、标准面额、机构标识、银行签名段、央行签名段、币串状态),数据从步骤七步骤八动态获取。")
public ApiResponse<StandardCurrencyRuleResult> confirmCurrencyRule() {
JwtUser user = AuthContextHolder.get();
StandardCurrencyRuleResult result = service.confirmCurrencyRule(
InstitutionKeySubject.from(user), user.getUsername());
return ApiResponse.success(result, traceId());
}
@PostMapping("/steps/generate")

@ -64,6 +64,7 @@ public class UserStepProgressNewService {
.eq(UserStepProgressNewEntity::getUserId, userId)
.eq(UserStepProgressNewEntity::getModuleCode, moduleCode)
.eq(UserStepProgressNewEntity::getStepCode, stepCode)
.eq(UserStepProgressNewEntity::getDeleted,0)
.last("LIMIT 1"));
}

@ -4,6 +4,7 @@ import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierApplicationEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.InstitutionIdentifierApplicationMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.security.context.JwtUser;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.walletopening.domain.BankReceivedApplication;
@ -29,26 +30,45 @@ public class WalletOpeningService {
@Resource
private InstitutionIdentifierApplicationMapper institutionIdentifierMapper;
@Transactional
public UserWalletPageResult getPage(InstitutionKeySubject subject) {
WalletApplication app = findOrCreate(subject);
refreshCurrentUserInfo(subject, app);
@Transactional(readOnly = true)
public UserWalletPageResult getPage(InstitutionKeySubject subject, JwtUser jwtUser) {
WalletApplication app = walletRepository.findLatest(subject.getUserId(), subject.getSchoolId(), subject.getClassId())
.orElseGet(() -> {
// 未创建记录时,返回一个只包含JWT用户信息的临时对象,不保存到数据库
WalletApplication temp = WalletApplication.create(
subject.getUserId(), subject.getSchoolId(), subject.getClassId(),
jwtUser.getUserName(), jwtUser.getPhone());
return temp;
});
return new UserWalletPageResult(app);
}
@Transactional
public FaceRecognitionResult updateFaceRecognition(InstitutionKeySubject subject, int status) {
WalletApplication app = findRequired(subject);
refreshCurrentUserInfo(subject, app);
public FaceRecognitionResult updateFaceRecognition(InstitutionKeySubject subject, JwtUser jwtUser, int status) {
WalletApplication app = walletRepository.findLatest(subject.getUserId(), subject.getSchoolId(), subject.getClassId())
.orElseGet(() -> {
// 首次人脸识别时创建记录
WalletApplication newApp = WalletApplication.create(
subject.getUserId(), subject.getSchoolId(), subject.getClassId(),
jwtUser.getUserName(), jwtUser.getPhone());
return walletRepository.save(newApp);
});
app.updateFaceRecognition(status);
walletRepository.update(app);
return new FaceRecognitionResult(status);
}
@Transactional
public WalletApplicationMessageResult outputApplication(InstitutionKeySubject subject, WalletApplicationRequest request) {
WalletApplication app = findRequired(subject);
refreshCurrentUserInfo(subject, app);
public WalletApplicationMessageResult outputApplication(InstitutionKeySubject subject, JwtUser jwtUser, WalletApplicationRequest request) {
WalletApplication app = walletRepository.findLatest(subject.getUserId(), subject.getSchoolId(), subject.getClassId())
.orElseGet(() -> {
// 首次输出申请报文时创建记录
WalletApplication newApp = WalletApplication.create(
subject.getUserId(), subject.getSchoolId(), subject.getClassId(),
jwtUser.getUserName(), jwtUser.getPhone());
return walletRepository.save(newApp);
});
if (app.getStatus() != null && app.getStatus().ordinal() >= WalletApplication.Status.OUTPUT.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "申请报文已输出,请勿重复操作");
}
@ -58,8 +78,11 @@ public class WalletOpeningService {
if (request.getWalletType() == null || request.getWalletType().trim().isEmpty()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请选择数字钱包类型");
}
// 输出申请报文时才存储身份证号、银行卡号等敏感信息
app.fillForm(request.getSelectedBank(), request.getWalletType(), request.getInputPhone(),
request.getInputIdNumber(), request.getInputBankCardNumber(), request.getSignaturePhotoPath());
request.getInputIdNumber(), request.getInputBankCardNumber(), request.getSignaturePhotoPath(),
request.getIdNumber(), request.getAccountBank(), request.getBankCardNumber(), request.getAccountBalance());
String applyTime = LocalDateTime.now().format(TIMESTAMP_FORMAT);
String applicationId = "APP_" + applyTime;
@ -72,8 +95,9 @@ public class WalletOpeningService {
}
@Transactional
public WalletSendResult sendApplication(InstitutionKeySubject subject) {
WalletApplication app = findRequired(subject);
public WalletSendResult sendApplication(InstitutionKeySubject subject, JwtUser jwtUser) {
WalletApplication app = walletRepository.findLatest(subject.getUserId(), subject.getSchoolId(), subject.getClassId())
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "钱包申请不存在,请先输出申请报文"));
if (app.getStatus() == null || app.getStatus().ordinal() < WalletApplication.Status.OUTPUT.ordinal()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先输出申请报文");
}
@ -101,31 +125,6 @@ public class WalletOpeningService {
return new WalletSendResult("已提交");
}
private WalletApplication findOrCreate(InstitutionKeySubject subject) {
return walletRepository.findLatest(subject.getUserId(), subject.getSchoolId(), subject.getClassId())
.orElseGet(() -> walletRepository.save(
WalletApplication.newDraft(subject.getUserId(), subject.getSchoolId(), subject.getClassId(),
subject.getUserName(), subject.getPhone())));
}
private WalletApplication findRequired(InstitutionKeySubject subject) {
return walletRepository.findLatest(subject.getUserId(), subject.getSchoolId(), subject.getClassId())
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "钱包申请不存在,请先初始化页面"));
}
private void refreshCurrentUserInfo(InstitutionKeySubject subject, WalletApplication app) {
String beforeUserName = app.getUserName();
String beforePhone = app.getPhone();
app.refreshTokenUserInfo(subject.getUserName(), subject.getPhone());
if (!equals(beforeUserName, app.getUserName()) || !equals(beforePhone, app.getPhone())) {
walletRepository.update(app);
}
}
private boolean equals(String left, String right) {
return left == null ? right == null : left.equals(right);
}
private String resolveOrgCode(InstitutionKeySubject subject) {
InstitutionIdentifierApplicationEntity entity = institutionIdentifierMapper.selectOne(
new LambdaQueryWrapper<InstitutionIdentifierApplicationEntity>()

@ -33,30 +33,31 @@ public class WalletOpeningController {
@GetMapping("/page")
@Operation(summary = "查询用户端钱包开通申请页面数据",
description = "返回页面完整数据;首次访问自动创建草稿申请,预填用户信息。尚未完成的阶段字段返回null。")
description = "返回页面完整数据,用户名和手机号从JWT token获取;尚未产生的字段返回null。不会自动创建数据库记录。")
public ApiResponse<UserWalletPageResult> page() {
JwtUser jwtUser = AuthContextHolder.get();
return ApiResponse.success(walletOpeningService.getPage(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
InstitutionKeySubject.from(jwtUser), jwtUser), traceId());
}
@PostMapping("/face-recognition")
@Operation(summary = "更新人脸识别状态",
description = "前端调用接口修改人脸识别状态。参数1=未成功,2=已成功。")
description = "前端调用接口修改人脸识别状态。参数1=未成功,2=已成功。首次调用时自动创建申请记录。")
public ApiResponse<FaceRecognitionResult> faceRecognition(
@Valid @RequestBody FaceRecognitionRequest request) {
JwtUser jwtUser = AuthContextHolder.get();
return ApiResponse.success(walletOpeningService.updateFaceRecognition(
InstitutionKeySubject.from(jwtUser), request.getStatus()), traceId());
InstitutionKeySubject.from(jwtUser), jwtUser, request.getStatus()), traceId());
}
@PostMapping("/output")
@Operation(summary = "输出申请报文",
description = "保存用户填写的表单数据,拼接原始报文并生成JSON申请报文。")
description = "保存用户填写的表单数据(包括身份证号、银行卡号等敏感信息),拼接原始报文并生成JSON申请报文。")
public ApiResponse<WalletApplicationMessageResult> output(
@Valid @RequestBody WalletApplicationRequest request) {
JwtUser jwtUser = AuthContextHolder.get();
return ApiResponse.success(walletOpeningService.outputApplication(
InstitutionKeySubject.from(jwtUser), request), traceId());
InstitutionKeySubject.from(jwtUser), jwtUser, request), traceId());
}
@PostMapping("/send")
@ -65,7 +66,7 @@ public class WalletOpeningController {
public ApiResponse<WalletSendResult> send() {
JwtUser jwtUser = AuthContextHolder.get();
return ApiResponse.success(walletOpeningService.sendApplication(
InstitutionKeySubject.from(jwtUser)), traceId());
InstitutionKeySubject.from(jwtUser), jwtUser), traceId());
}
private String traceId() {

@ -5,6 +5,7 @@ import lombok.Getter;
import lombok.Setter;
import javax.validation.constraints.NotBlank;
import java.math.BigDecimal;
@Getter
@Setter
@ -23,4 +24,12 @@ public class WalletApplicationRequest {
private String inputBankCardNumber;
@Schema(description = "面签照片路径", example = "/2026/8/11/5cae82501de84bfba3693477ac55140c.pdf")
private String signaturePhotoPath;
@Schema(description = "身份证号(确认)", example = "110101199001011234")
private String idNumber;
@Schema(description = "开户银行", example = "中国银行北京分行")
private String accountBank;
@Schema(description = "银行卡号(确认)", example = "6216610100001234567")
private String bankCardNumber;
@Schema(description = "账户余额", example = "500000.00")
private BigDecimal accountBalance;
}

@ -25,9 +25,30 @@ CREATE TABLE IF NOT EXISTS salary_batch (
output_json TEXT COMMENT '输出JSON报文',
-- 状态
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT-草稿, BLOCKCHAIN_FETCHED-已调取区块信息, CONCATENATED-已拼接, DIGESTED-已生成摘要, SIGNED-已签名, OUTPUT-已输出, SENT-已发送',
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT-草稿, BLOCKCHAIN_FETCHED-已调取区块信息, CONCATENATED-已拼接, DIGESTED-已生成摘要, SIGNED-已签名, OUTPUT-已输出, SENT-已发送, REVIEW_EXTRACTED-已提取审核原文, REVIEW_CHECKED-已审核, REVIEW_DIGESTED-已生成审核摘要, REVIEW_SIGNED-已签名, REVIEWED-已审核完成, CONFIRM_EXTRACTED-已提取确认原文, CONFIRM_DIGESTED-已生成确认摘要, CONFIRM_SIGNED-已央行签名, CONFIRM_COMPLETED-交易确认完成',
submit_time VARCHAR(20) COMMENT '发送时间,格式yyyyMMddHHmmss',
-- 复核员审核相关
reviewer_name VARCHAR(64) COMMENT '复核员姓名(如:赵六)',
review_timestamp VARCHAR(20) COMMENT '审核时间戳,格式yyyyMMddHHmmss',
review_concatenated_message TEXT COMMENT '拼接审核原文(管道分隔)',
review_digest_algorithm VARCHAR(16) COMMENT '审核摘要算法(如:SM3)',
review_digest_value VARCHAR(128) COMMENT '审核SM3摘要值(十六进制大写)',
review_signature TEXT COMMENT '审核SM2签名值(DER编码十六进制)',
review_output_json TEXT COMMENT '审核输出JSON报文',
single_limit_status INT COMMENT '单笔限额状态:1-通过, 2-未通过',
batch_limit_status INT COMMENT '批次限额状态:1-通过, 2-未通过',
daily_limit_status INT COMMENT '日累计限额状态:1-通过, 2-未通过',
review_result VARCHAR(32) COMMENT '审核结果:APPROVED-已批准, REJECTED-已拒绝',
-- 交易确认相关
confirm_message TEXT COMMENT '交易确认原文(管道分隔)',
confirm_timestamp VARCHAR(20) COMMENT '确认时间戳,格式yyyyMMddHHmmss',
confirm_digest_algorithm VARCHAR(16) COMMENT '确认摘要算法(如:SM3)',
confirm_digest_value VARCHAR(128) COMMENT '确认SM3摘要值(十六进制大写)',
cb_signature TEXT COMMENT '央行SM2签名值(CB_FINAL_前缀+DER编码十六进制)',
confirm_output_json TEXT COMMENT '交易确认输出JSON报文',
created_at TIMESTAMP NOT NULL COMMENT '创建时间',
updated_at TIMESTAMP NOT NULL COMMENT '更新时间',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',

@ -1243,8 +1243,25 @@ CREATE TABLE IF NOT EXISTS salary_batch (
digest_value VARCHAR(128) COMMENT 'SM3摘要值(十六进制大写)',
signature TEXT COMMENT 'SM2签名值(DER编码十六进制)',
output_json TEXT COMMENT '输出JSON报文',
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT-草稿, BLOCKCHAIN_FETCHED-已调取区块信息, CONCATENATED-已拼接, DIGESTED-已生成摘要, SIGNED-已签名, OUTPUT-已输出, SENT-已发送',
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT-草稿, BLOCKCHAIN_FETCHED-已调取区块信息, CONCATENATED-已拼接, DIGESTED-已生成摘要, SIGNED-已签名, OUTPUT-已输出, SENT-已发送, REVIEW_EXTRACTED-已提取审核原文, REVIEW_CHECKED-已审核, REVIEW_DIGESTED-已生成审核摘要, REVIEW_SIGNED-已签名, REVIEWED-已审核完成, CONFIRM_EXTRACTED-已提取确认原文, CONFIRM_DIGESTED-已生成确认摘要, CONFIRM_SIGNED-已央行签名, CONFIRM_COMPLETED-交易确认完成',
submit_time VARCHAR(20) COMMENT '发送时间,格式yyyyMMddHHmmss',
reviewer_name VARCHAR(64) COMMENT '复核员姓名(如:赵六)',
review_timestamp VARCHAR(20) COMMENT '审核时间戳,格式yyyyMMddHHmmss',
review_concatenated_message TEXT COMMENT '拼接审核原文(管道分隔)',
review_digest_algorithm VARCHAR(16) COMMENT '审核摘要算法(如:SM3)',
review_digest_value VARCHAR(128) COMMENT '审核SM3摘要值(十六进制大写)',
review_signature TEXT COMMENT '审核SM2签名值(DER编码十六进制)',
review_output_json TEXT COMMENT '审核输出JSON报文',
single_limit_status INT COMMENT '单笔限额状态:1-通过, 2-未通过',
batch_limit_status INT COMMENT '批次限额状态:1-通过, 2-未通过',
daily_limit_status INT COMMENT '日累计限额状态:1-通过, 2-未通过',
review_result VARCHAR(32) COMMENT '审核结果:APPROVED-已批准, REJECTED-已拒绝',
confirm_message TEXT COMMENT '交易确认原文(管道分隔)',
confirm_timestamp VARCHAR(20) COMMENT '确认时间戳,格式yyyyMMddHHmmss',
confirm_digest_algorithm VARCHAR(16) COMMENT '确认摘要算法(如:SM3)',
confirm_digest_value VARCHAR(128) COMMENT '确认SM3摘要值(十六进制大写)',
cb_signature TEXT COMMENT '央行SM2签名值(CB_FINAL_前缀+DER编码十六进制)',
confirm_output_json TEXT COMMENT '交易确认输出JSON报文',
created_at TIMESTAMP NOT NULL COMMENT '创建时间',
updated_at TIMESTAMP NOT NULL COMMENT '更新时间',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',

@ -817,6 +817,23 @@ CREATE TABLE IF NOT EXISTS salary_batch (
output_json TEXT COMMENT '输出JSON报文',
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态',
submit_time VARCHAR(20) COMMENT '发送时间',
reviewer_name VARCHAR(64) COMMENT '复核员姓名',
review_timestamp VARCHAR(20) COMMENT '审核时间戳',
review_concatenated_message TEXT COMMENT '拼接审核原文',
review_digest_algorithm VARCHAR(16) COMMENT '审核摘要算法',
review_digest_value VARCHAR(128) COMMENT '审核SM3摘要值',
review_signature TEXT COMMENT '审核SM2签名值',
review_output_json TEXT COMMENT '审核输出JSON',
single_limit_status INT COMMENT '单笔限额状态:1-通过, 2-未通过',
batch_limit_status INT COMMENT '批次限额状态:1-通过, 2-未通过',
daily_limit_status INT COMMENT '日累计限额状态:1-通过, 2-未通过',
review_result VARCHAR(32) COMMENT '审核结果:APPROVED-已批准, REJECTED-已拒绝',
confirm_message TEXT COMMENT '交易确认原文',
confirm_timestamp VARCHAR(20) COMMENT '确认时间戳',
confirm_digest_algorithm VARCHAR(16) COMMENT '确认摘要算法',
confirm_digest_value VARCHAR(128) COMMENT '确认SM3摘要值',
cb_signature TEXT COMMENT '央行SM2签名值',
confirm_output_json TEXT COMMENT '交易确认输出JSON',
created_at TIMESTAMP NOT NULL COMMENT '创建时间',
updated_at TIMESTAMP NOT NULL COMMENT '更新时间',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',

Loading…
Cancel
Save