From f19428560a5591096f8db3b419c85710cb5a34f6 Mon Sep 17 00:00:00 2001 From: "jiazheng.zhao" <2624533894@qq.com> Date: Tue, 18 Aug 2026 17:20:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E7=9A=84=E5=8A=9F=E8=83=BD=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CorporateWalletKeyService.java | 12 +- .../application/CorporateWalletResult.java | 8 + .../application/CorporateWalletService.java | 24 +- .../application/RemoteWalletResult.java | 9 + .../application/RemoteWalletService.java | 27 +- .../application/SalaryBatchService.java | 318 ++++++++++++++++++ .../application/SalaryBlockchainResult.java | 24 ++ .../application/SalaryConcatenateResult.java | 15 + .../application/SalaryDigestResult.java | 15 + .../application/SalaryOutputResult.java | 15 + .../application/SalaryPageResult.java | 76 +++++ .../application/SalaryScoreService.java | 94 ++++++ .../application/SalarySendResult.java | 18 + .../application/SalarySignatureResult.java | 15 + .../CorporateWalletApplicationRepository.java | 1 + .../RemoteWalletApplicationRepository.java | 1 + .../corporatewallet/domain/SalaryBatch.java | 110 ++++++ .../domain/SalaryBatchRepository.java | 9 + ...sCorporateWalletApplicationRepository.java | 16 + ...atisRemoteWalletApplicationRepository.java | 16 + .../MybatisSalaryBatchRepository.java | 81 +++++ .../infrastructure/SalaryBatchEntity.java | 41 +++ .../infrastructure/SalaryBatchMapper.java | 8 + .../rest/SalaryBatchController.java | 92 +++++ .../rest/dto/SalarySignRequest.java | 14 + .../corporate_wallet_key_info_upgrade.sql | 18 + .../db/manual/salary_batch_upgrade.sql | 36 ++ src/main/resources/schema.sql | 24 ++ src/test/resources/schema.sql | 24 ++ 29 files changed, 1151 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchService.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBlockchainResult.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryConcatenateResult.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryDigestResult.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryOutputResult.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryPageResult.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryScoreService.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/application/SalarySendResult.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/application/SalarySignatureResult.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/domain/SalaryBatch.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/domain/SalaryBatchRepository.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisSalaryBatchRepository.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/SalaryBatchEntity.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/SalaryBatchMapper.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryBatchController.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalarySignRequest.java create mode 100644 src/main/resources/db/manual/corporate_wallet_key_info_upgrade.sql create mode 100644 src/main/resources/db/manual/salary_batch_upgrade.sql diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletKeyService.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletKeyService.java index 755b29f..7f29d5c 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletKeyService.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletKeyService.java @@ -58,15 +58,15 @@ public class CorporateWalletKeyService { // 4. 数币操作员密钥(新增) String[] operatorPair = findOrCreateNewKeyPair(subject, "数币操作员私钥", "数币操作员公钥", - "李四", operator); - results.add(buildResult("数币操作员私钥", "李四", operatorPair[0], true, "NEW")); - results.add(buildResult("数币操作员公钥", "李四", operatorPair[1], false, "NEW")); + "王五", operator); + results.add(buildResult("数币操作员私钥", "王五", operatorPair[0], true, "NEW")); + results.add(buildResult("数币操作员公钥", "王五", operatorPair[1], false, "NEW")); // 5. 数币复核员密钥(新增) String[] reviewerPair = findOrCreateNewKeyPair(subject, "数币复核员私钥", "数币复核员公钥", - "王五", operator); - results.add(buildResult("数币复核员私钥", "王五", reviewerPair[0], true, "NEW")); - results.add(buildResult("数币复核员公钥", "王五", reviewerPair[1], false, "NEW")); + "赵六", operator); + results.add(buildResult("数币复核员私钥", "赵六", reviewerPair[0], true, "NEW")); + results.add(buildResult("数币复核员公钥", "赵六", reviewerPair[1], false, "NEW")); // 6. 监管机构密钥(新增) String[] regulatorPair = findOrCreateNewKeyPair(subject, "监管机构私钥", "监管机构公钥", diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletResult.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletResult.java index d5cf092..5be77b9 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletResult.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletResult.java @@ -18,14 +18,22 @@ public class CorporateWalletResult { private final String status; @Schema(description = "激活时间", example = "20260801101000") private final String activateTime; + @Schema(description = "开立渠道:临柜开立 / 远程开立(复用时标注原渠道)") + private final String channel; public CorporateWalletResult(String walletId, String corpName, String creditCode, String walletType, String status, String activateTime) { + this(walletId, corpName, creditCode, walletType, status, activateTime, "临柜开立"); + } + + public CorporateWalletResult(String walletId, String corpName, String creditCode, + String walletType, String status, String activateTime, String channel) { this.walletId = walletId; this.corpName = corpName; this.creditCode = creditCode; this.walletType = walletType; this.status = status; this.activateTime = activateTime; + this.channel = channel; } } diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletService.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletService.java index d1b4033..6827156 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletService.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateWalletService.java @@ -2,6 +2,8 @@ package com.yau.digitalrmb.corporatewallet.application; import com.yau.digitalrmb.corporatewallet.domain.CorporateWalletApplication; import com.yau.digitalrmb.corporatewallet.domain.CorporateWalletApplicationRepository; +import com.yau.digitalrmb.corporatewallet.domain.RemoteWalletApplication; +import com.yau.digitalrmb.corporatewallet.domain.RemoteWalletApplicationRepository; import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.CorporateWalletApplicationRequest; import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; import com.yau.digitalrmb.institutionidentity.infrastructure.SmInstitutionIdentityCryptography; @@ -22,6 +24,8 @@ public class CorporateWalletService { @Resource private CorporateWalletApplicationRepository repository; @Resource + private RemoteWalletApplicationRepository remoteRepository; + @Resource private SmInstitutionIdentityCryptography cryptography; /** @@ -145,6 +149,8 @@ public class CorporateWalletService { /** * 生成对公钱包输出 + * 业务规则:以统一社会信用代码为准,一个企业只能有一个激活状态的对公钱包。 + * 若远程开立已激活同信用代码的钱包,则复用该钱包ID,不再生成新钱包。 */ @Transactional public CorporateWalletResult outputWallet(InstitutionKeySubject subject) { @@ -155,7 +161,21 @@ public class CorporateWalletService { if (app.getWalletId() != null) { return new CorporateWalletResult( app.getWalletId(), app.getCorpName(), app.getCreditCode(), - app.getWalletType(), "ACTIVATED", app.getActivateTime()); + app.getWalletType(), "ACTIVATED", app.getActivateTime(), "临柜开立"); + } + + // 跨渠道检查:远程开立是否已激活同信用代码的钱包 + RemoteWalletApplication remoteActivated = remoteRepository + .findActivatedByCreditCode(app.getCreditCode(), subject.getUserId(), + subject.getSchoolId(), subject.getClassId()) + .orElse(null); + if (remoteActivated != null) { + // 复用远程开立的钱包ID,不再生成新钱包 + app.activateWallet(remoteActivated.getWalletId(), remoteActivated.getActivateTime()); + repository.update(app); + return new CorporateWalletResult( + remoteActivated.getWalletId(), app.getCorpName(), app.getCreditCode(), + app.getWalletType(), "ACTIVATED", remoteActivated.getActivateTime(), "远程开立"); } String walletId = "CORP_" + app.getDigestValue(); @@ -167,7 +187,7 @@ public class CorporateWalletService { return new CorporateWalletResult( walletId, app.getCorpName(), app.getCreditCode(), - app.getWalletType(), "ACTIVATED", activateTime); + app.getWalletType(), "ACTIVATED", activateTime, "临柜开立"); } private CorporateWalletApplication findOrCreate(InstitutionKeySubject subject) { diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/RemoteWalletResult.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/RemoteWalletResult.java index 878775f..2bb7f04 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/application/RemoteWalletResult.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/RemoteWalletResult.java @@ -20,10 +20,18 @@ public class RemoteWalletResult { private final String activateTime; @Schema(description = "人脸识别记录", example = "已验证") private final String faceRecognitionLog; + @Schema(description = "开立渠道:远程开立 / 临柜开立(复用时标注原渠道)") + private final String channel; public RemoteWalletResult(String walletId, String corpName, String creditCode, String walletType, String status, String activateTime, String faceRecognitionLog) { + this(walletId, corpName, creditCode, walletType, status, activateTime, faceRecognitionLog, "远程开立"); + } + + public RemoteWalletResult(String walletId, String corpName, String creditCode, + String walletType, String status, String activateTime, + String faceRecognitionLog, String channel) { this.walletId = walletId; this.corpName = corpName; this.creditCode = creditCode; @@ -31,5 +39,6 @@ public class RemoteWalletResult { this.status = status; this.activateTime = activateTime; this.faceRecognitionLog = faceRecognitionLog; + this.channel = channel; } } diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/RemoteWalletService.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/RemoteWalletService.java index 1ba090a..34b034e 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/application/RemoteWalletService.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/RemoteWalletService.java @@ -1,5 +1,7 @@ package com.yau.digitalrmb.corporatewallet.application; +import com.yau.digitalrmb.corporatewallet.domain.CorporateWalletApplication; +import com.yau.digitalrmb.corporatewallet.domain.CorporateWalletApplicationRepository; import com.yau.digitalrmb.corporatewallet.domain.RemoteWalletApplication; import com.yau.digitalrmb.corporatewallet.domain.RemoteWalletApplicationRepository; import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.RemoteWalletApplicationRequest; @@ -23,6 +25,8 @@ public class RemoteWalletService { @Resource private RemoteWalletApplicationRepository repository; @Resource + private CorporateWalletApplicationRepository corporateRepository; + @Resource private SmInstitutionIdentityCryptography cryptography; // ==================== 步骤一:企业法人在线填写信息 ==================== @@ -194,6 +198,11 @@ public class RemoteWalletService { return new RemoteDigestResult("SM3", digest); } + /** + * 生成对公钱包输出 + * 业务规则:以统一社会信用代码为准,一个企业只能有一个激活状态的对公钱包。 + * 若临柜开立已激活同信用代码的钱包,则复用该钱包ID,不再生成新钱包。 + */ @Transactional public RemoteWalletResult outputWallet(InstitutionKeySubject subject) { RemoteWalletApplication app = findRequired(subject); @@ -203,7 +212,21 @@ public class RemoteWalletService { if (app.getWalletId() != null) { return new RemoteWalletResult( app.getWalletId(), app.getCorpName(), app.getCreditCode(), - app.getWalletType(), "ACTIVATED", app.getActivateTime(), "已验证"); + app.getWalletType(), "ACTIVATED", app.getActivateTime(), "已验证", "远程开立"); + } + + // 跨渠道检查:临柜开立是否已激活同信用代码的钱包 + CorporateWalletApplication corpActivated = corporateRepository + .findActivatedByCreditCode(app.getCreditCode(), subject.getUserId(), + subject.getSchoolId(), subject.getClassId()) + .orElse(null); + if (corpActivated != null) { + // 复用临柜开立的钱包ID,不再生成新钱包 + app.activateWallet(corpActivated.getWalletId(), corpActivated.getActivateTime()); + repository.update(app); + return new RemoteWalletResult( + corpActivated.getWalletId(), app.getCorpName(), app.getCreditCode(), + app.getWalletType(), "ACTIVATED", corpActivated.getActivateTime(), "已验证", "临柜开立"); } String walletId = "CORP_" + app.getWalletDigestValue(); @@ -215,7 +238,7 @@ public class RemoteWalletService { return new RemoteWalletResult( walletId, app.getCorpName(), app.getCreditCode(), - app.getWalletType(), "ACTIVATED", activateTime, "已验证"); + app.getWalletType(), "ACTIVATED", activateTime, "已验证", "远程开立"); } // ==================== 内部方法 ==================== diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchService.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchService.java new file mode 100644 index 0000000..58f0458 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchService.java @@ -0,0 +1,318 @@ +package com.yau.digitalrmb.corporatewallet.application; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.yau.digitalrmb.corporatewallet.domain.CorporateWalletApplication; +import com.yau.digitalrmb.corporatewallet.domain.CorporateWalletApplicationRepository; +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.InstitutionKeySubject; +import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; +import com.yau.digitalrmb.corporatewallet.domain.SalaryBatch; +import com.yau.digitalrmb.corporatewallet.domain.SalaryBatchRepository; +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.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +@Service +public class SalaryBatchService { + private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + 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[][] EMPLOYEE_DATA = { + {"张明", "WALLET_EMP_001", "技术部", "8500.00"}, + {"李二", "WALLET_EMP_002", "市场部", "7200.00"}, + {"王小", "WALLET_EMP_003", "财务部", "6800.00"}, + {"赵六", "WALLET_EMP_004", "人事部", "5600.00"}, + {"钱七", "WALLET_EMP_005", "技术部", "9200.00"} + }; + + @Resource + private SalaryBatchRepository repository; + @Resource + private CorporateWalletApplicationRepository corporateWalletRepository; + @Resource + private RemoteWalletApplicationRepository remoteWalletRepository; + @Resource + private CorporateWalletKeyInfoMapper keyInfoMapper; + @Resource + private InstitutionIdentityCryptography cryptography; + @Resource + private SalaryScoreService scoreService; + + // ==================== 步骤一:调取区块信息 ==================== + + @Transactional + public SalaryBlockchainResult fetchBlockchain(InstitutionKeySubject subject, String operator) { + SalaryBatch batch = findOrCreate(subject); + + // 付款钱包从步骤一(对公钱包开立)获取 + String corpWalletId = findCorporateWalletId(subject); + if (corpWalletId == null) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成对公钱包开立(步骤一)"); + } + + // 批次号随机生成 + String batchId = generateBatchId(); + // 时间戳 + String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT); + + batch.fetchBlockchain(batchId, corpWalletId, OPERATOR_NAME, timestamp); + repository.update(batch); + + return new SalaryBlockchainResult(corpWalletId, batchId, OPERATOR_NAME, timestamp); + } + + // ==================== 步骤二:拼接工资发放申请原文 ==================== + + @Transactional + public SalaryConcatenateResult concatenate(InstitutionKeySubject subject) { + SalaryBatch batch = findRequired(subject); + if (batch.getStatus().ordinal() < SalaryBatch.Status.BLOCKCHAIN_FETCHED.ordinal()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先调取区块信息"); + } + + String message = buildConcatenatedMessage(batch); + batch.concatenate(message); + repository.update(batch); + + return new SalaryConcatenateResult(message); + } + + // ==================== 步骤三:生成摘要 ==================== + + @Transactional + public SalaryDigestResult computeDigest(InstitutionKeySubject subject) { + SalaryBatch batch = findRequired(subject); + if (batch.getStatus().ordinal() < SalaryBatch.Status.CONCATENATED.ordinal()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先拼接工资发放申请原文"); + } + if (batch.getDigestValue() != null) { + return new SalaryDigestResult(batch.getDigestValue()); + } + + String digest = cryptography.sm3(batch.getConcatenatedMessage()); + batch.computeDigest("SM3", digest); + repository.update(batch); + + return new SalaryDigestResult(digest); + } + + // ==================== 步骤四:SM2签名 ==================== + + @Transactional + public SalarySignatureResult sign(InstitutionKeySubject subject, String providedPrivateKey) { + SalaryBatch batch = findRequired(subject); + if (batch.getStatus().ordinal() < SalaryBatch.Status.DIGESTED.ordinal()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先生成摘要"); + } + if (batch.getSignature() != null) { + return new SalarySignatureResult(batch.getSignature()); + } + + // 从 corporate_wallet_key_info 表获取数币操作员私钥 + String storedPrivateKey = findOperatorPrivateKey(subject); + if (storedPrivateKey == null) { + throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "数币操作员私钥不存在,请先获取密钥"); + } + + // 验证传入的私钥是否正确 + if (!storedPrivateKey.equalsIgnoreCase(providedPrivateKey != null ? providedPrivateKey.trim() : "")) { + // 错误数加1 + scoreService.recordError(subject.getUserId()); + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "数币操作员私钥不正确,错误次数+1"); + } + + // 使用数币操作员私钥对摘要进行SM2签名 + String signature = cryptography.sign(storedPrivateKey, batch.getConcatenatedMessage()); + batch.sign(signature); + repository.update(batch); + + return new SalarySignatureResult(signature); + } + + // ==================== 步骤五:输出 ==================== + + @Transactional + public SalaryOutputResult output(InstitutionKeySubject subject) { + SalaryBatch batch = findRequired(subject); + if (batch.getStatus().ordinal() < SalaryBatch.Status.SIGNED.ordinal()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成SM2签名"); + } + if (batch.getOutputJson() != null) { + return new SalaryOutputResult(batch.getOutputJson()); + } + + String json = buildOutputJson(batch); + batch.output(json); + repository.update(batch); + + return new SalaryOutputResult(json); + } + + // ==================== 步骤六:发送 ==================== + + @Transactional + public SalarySendResult send(InstitutionKeySubject subject) { + SalaryBatch batch = findRequired(subject); + if (batch.getStatus().ordinal() < SalaryBatch.Status.OUTPUT.ordinal()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成输出"); + } + if (batch.getStatus() == SalaryBatch.Status.SENT) { + return new SalarySendResult("待审核", batch.getSubmitTime()); + } + + String submitTime = LocalDateTime.now().format(TIMESTAMP_FORMAT); + batch.send(submitTime); + repository.update(batch); + + return new SalarySendResult("待审核", submitTime); + } + + // ==================== 页面查询 ==================== + + @Transactional(readOnly = true) + public SalaryPageResult pageQuery(InstitutionKeySubject subject) { + // 始终返回内置工资明细 + List payroll = new ArrayList<>(); + for (int i = 0; i < EMPLOYEE_DATA.length; i++) { + payroll.add(new SalaryPageResult.EmployeeDetail( + i + 1, EMPLOYEE_DATA[i][0], EMPLOYEE_DATA[i][1], + EMPLOYEE_DATA[i][2], EMPLOYEE_DATA[i][3])); + } + + String totalAmount = calculateTotalAmount(); + int employeeCount = EMPLOYEE_DATA.length; + + // 查询已有批次数据,若不存在则返回空字段(不报错) + SalaryBatch batch = repository + .findLatest(subject.getUserId(), subject.getSchoolId(), subject.getClassId()) + .orElse(null); + + if (batch == null) { + return new SalaryPageResult(payroll, totalAmount, employeeCount, + 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); + } + + // ==================== 内部方法 ==================== + + private SalaryBatch findOrCreate(InstitutionKeySubject subject) { + return repository.findLatest(subject.getUserId(), subject.getSchoolId(), subject.getClassId()) + .orElseGet(() -> repository.save( + SalaryBatch.create(subject.getUserId(), subject.getSchoolId(), subject.getClassId()))); + } + + private SalaryBatch findRequired(InstitutionKeySubject subject) { + return repository.findLatest(subject.getUserId(), subject.getSchoolId(), subject.getClassId()) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, + "工资批次不存在,请先调取区块信息")); + } + + /** + * 从对公钱包开立(临柜/远程)获取付款钱包ID + */ + private String findCorporateWalletId(InstitutionKeySubject subject) { + // 先查临柜开立 + CorporateWalletApplication corpApp = corporateWalletRepository + .findLatest(subject.getUserId(), subject.getSchoolId(), subject.getClassId()) + .orElse(null); + if (corpApp != null && corpApp.getWalletId() != null && !corpApp.getWalletId().isEmpty()) { + return corpApp.getWalletId(); + } + // 再查远程开立 + RemoteWalletApplication remoteApp = remoteWalletRepository + .findLatest(subject.getUserId(), subject.getSchoolId(), subject.getClassId()) + .orElse(null); + if (remoteApp != null && remoteApp.getWalletId() != null && !remoteApp.getWalletId().isEmpty()) { + return remoteApp.getWalletId(); + } + return null; + } + + /** + * 从 corporate_wallet_key_info 表获取数币操作员私钥 + */ + private String findOperatorPrivateKey(InstitutionKeySubject subject) { + CorporateWalletKeyInfoEntity entity = keyInfoMapper.selectOne( + new LambdaQueryWrapper() + .eq(CorporateWalletKeyInfoEntity::getUserId, subject.getUserId()) + .eq(CorporateWalletKeyInfoEntity::getSchoolId, subject.getSchoolId()) + .eq(CorporateWalletKeyInfoEntity::getClassId, subject.getClassId()) + .eq(CorporateWalletKeyInfoEntity::getKeyType, OPERATOR_KEY_TYPE) + .eq(CorporateWalletKeyInfoEntity::getDeleted, false) + .orderByDesc(CorporateWalletKeyInfoEntity::getCreatedAt) + .last("LIMIT 1")); + return entity == null ? null : entity.getKeyValue(); + } + + private String generateBatchId() { + String datePart = LocalDateTime.now().format(DATE_FORMAT); + int seq = ThreadLocalRandom.current().nextInt(100, 1000); + return "BATCH_" + datePart + "_" + String.format("%03d", seq); + } + + private String buildConcatenatedMessage(SalaryBatch batch) { + StringBuilder sb = new StringBuilder(); + sb.append(batch.getBatchId()); + sb.append("|").append(batch.getCorpWalletId()); + sb.append("|").append(batch.getOperatorName()); + for (String[] emp : EMPLOYEE_DATA) { + sb.append("|").append(emp[0]); // 姓名 + sb.append("|").append(emp[1]); // 钱包ID + sb.append("|").append(emp[3]); // 金额 + } + sb.append("|").append(batch.getOperatorTimestamp()); + return sb.toString(); + } + + private String buildOutputJson(SalaryBatch batch) { + StringBuilder sb = new StringBuilder(); + sb.append("{"); + sb.append("\"batchId\":\"").append(batch.getBatchId()).append("\","); + sb.append("\"corpWalletId\":\"").append(batch.getCorpWalletId()).append("\","); + sb.append("\"operator\":\"").append(batch.getOperatorName()).append("\","); + sb.append("\"totalAmount\":\"").append(calculateTotalAmount()).append("\","); + sb.append("\"employeeCount\":").append(EMPLOYEE_DATA.length).append(","); + sb.append("\"payroll\":["); + for (int i = 0; i < EMPLOYEE_DATA.length; i++) { + if (i > 0) sb.append(","); + sb.append("{\"name\":\"").append(EMPLOYEE_DATA[i][0]).append("\","); + sb.append("\"walletId\":\"").append(EMPLOYEE_DATA[i][1]).append("\","); + sb.append("\"amount\":\"").append(EMPLOYEE_DATA[i][3]).append("\"}"); + } + sb.append("],"); + sb.append("\"digest\":\"").append(batch.getDigestValue()).append("\","); + sb.append("\"operatorSignature\":\"").append(batch.getSignature()).append("\","); + sb.append("\"status\":\"待审核\","); + sb.append("\"submitTime\":\"").append(batch.getOperatorTimestamp()).append("\""); + sb.append("}"); + return sb.toString(); + } + + private String calculateTotalAmount() { + double total = 0; + for (String[] emp : EMPLOYEE_DATA) { + total += Double.parseDouble(emp[3]); + } + return String.format("%.2f", total); + } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBlockchainResult.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBlockchainResult.java new file mode 100644 index 0000000..2b41d8a --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBlockchainResult.java @@ -0,0 +1,24 @@ +package com.yau.digitalrmb.corporatewallet.application; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +@Schema(description = "调取区块信息结果") +public class SalaryBlockchainResult { + @Schema(description = "付款钱包ID(从步骤一对公钱包开立获取)") + private final String corpWalletId; + @Schema(description = "批次号(随机生成)") + private final String batchId; + @Schema(description = "操作员(数币操作员)") + private final String operatorName; + @Schema(description = "时间戳") + private final String timestamp; + + public SalaryBlockchainResult(String corpWalletId, String batchId, String operatorName, String timestamp) { + this.corpWalletId = corpWalletId; + this.batchId = batchId; + this.operatorName = operatorName; + this.timestamp = timestamp; + } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryConcatenateResult.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryConcatenateResult.java new file mode 100644 index 0000000..0a96ac5 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryConcatenateResult.java @@ -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 SalaryConcatenateResult { + @Schema(description = "拼接原文(管道分隔)") + private final String concatenatedMessage; + + public SalaryConcatenateResult(String concatenatedMessage) { + this.concatenatedMessage = concatenatedMessage; + } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryDigestResult.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryDigestResult.java new file mode 100644 index 0000000..7a836ab --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryDigestResult.java @@ -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 SalaryDigestResult { + @Schema(description = "摘要值") + private final String digestValue; + + public SalaryDigestResult(String digestValue) { + this.digestValue = digestValue; + } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryOutputResult.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryOutputResult.java new file mode 100644 index 0000000..2ab01b2 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryOutputResult.java @@ -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 SalaryOutputResult { + @Schema(description = "输出JSON报文") + private final String outputJson; + + public SalaryOutputResult(String outputJson) { + this.outputJson = outputJson; + } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryPageResult.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryPageResult.java new file mode 100644 index 0000000..643f0cb --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryPageResult.java @@ -0,0 +1,76 @@ +package com.yau.digitalrmb.corporatewallet.application; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.util.List; + +@Getter +@Schema(description = "工资明细页面查询结果") +public class SalaryPageResult { + @Schema(description = "工资明细列表") + private final List payroll; + @Schema(description = "合计金额") + private final String totalAmount; + @Schema(description = "员工数量") + private final int employeeCount; + @Schema(description = "批次号(若已调取区块信息)") + private final String batchId; + @Schema(description = "付款钱包ID(若已调取区块信息)") + private final String corpWalletId; + @Schema(description = "操作员(若已调取区块信息)") + private final String operatorName; + @Schema(description = "时间戳(若已调取区块信息)") + private final String timestamp; + @Schema(description = "拼接原文(若已拼接)") + private final String concatenatedMessage; + @Schema(description = "摘要值(若已生成摘要)") + private final String digestValue; + @Schema(description = "签名值(若已签名)") + private final String signature; + @Schema(description = "输出JSON(若已输出)") + private final String outputJson; + @Schema(description = "状态(若已发送)") + private final String status; + + public SalaryPageResult(List payroll, String totalAmount, int employeeCount, + String batchId, String corpWalletId, String operatorName, String timestamp, + String concatenatedMessage, String digestValue, String signature, + String outputJson, String status) { + this.payroll = payroll; + this.totalAmount = totalAmount; + this.employeeCount = employeeCount; + this.batchId = batchId; + this.corpWalletId = corpWalletId; + this.operatorName = operatorName; + this.timestamp = timestamp; + this.concatenatedMessage = concatenatedMessage; + this.digestValue = digestValue; + this.signature = signature; + this.outputJson = outputJson; + this.status = status; + } + + @Getter + @Schema(description = "员工工资明细") + public static class EmployeeDetail { + @Schema(description = "序号") + private final int seqNo; + @Schema(description = "姓名") + private final String name; + @Schema(description = "钱包ID") + private final String walletId; + @Schema(description = "部门") + private final String department; + @Schema(description = "工资金额(元)") + private final String amount; + + public EmployeeDetail(int seqNo, String name, String walletId, String department, String amount) { + this.seqNo = seqNo; + this.name = name; + this.walletId = walletId; + this.department = department; + this.amount = amount; + } + } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryScoreService.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryScoreService.java new file mode 100644 index 0000000..502c97e --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryScoreService.java @@ -0,0 +1,94 @@ +package com.yau.digitalrmb.corporatewallet.application; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.Propagation; +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 SalaryScoreService { + public static final String MODULE_NAME = "企业发放数字人民币工资"; + private static final int MODULE_SERIAL_NUMBER = 6; + private static final double TOTAL_SCORE = 100.0; + + @Resource + private StuModuleScoreDetailsMapper scoreDetailsMapper; + + @Value("${salary.score.wrong-deduct:0.50}") + private BigDecimal wrongDeduct = new BigDecimal("0.50"); + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int recordError(String userId) { + if (userId == null || userId.trim().isEmpty()) { + return 0; + } + StuModuleScoreDetailsEntity detail = getOrCreate(userId); + int accumulatedErrorCount = errorCount(detail.getCompletionStatus()) + 1; + detail.setCompletionStatus(String.valueOf(accumulatedErrorCount)); + detail.setScoreProject(score(accumulatedErrorCount, progress(detail.getSchedule()))); + scoreDetailsMapper.updateById(detail); + return accumulatedErrorCount; + } + + private StuModuleScoreDetailsEntity getOrCreate(String userId) { + StuModuleScoreDetailsEntity existing = scoreDetailsMapper.selectOne( + new LambdaQueryWrapper() + .eq(StuModuleScoreDetailsEntity::getUserId, userId) + .eq(StuModuleScoreDetailsEntity::getMoudule, MODULE_NAME) + .eq(StuModuleScoreDetailsEntity::getSerialNumber, MODULE_SERIAL_NUMBER) + .last("LIMIT 1")); + 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(MODULE_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; + } + } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalarySendResult.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalarySendResult.java new file mode 100644 index 0000000..9161904 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalarySendResult.java @@ -0,0 +1,18 @@ +package com.yau.digitalrmb.corporatewallet.application; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +@Schema(description = "发送结果") +public class SalarySendResult { + @Schema(description = "状态:待审核") + private final String status; + @Schema(description = "发送时间") + private final String submitTime; + + public SalarySendResult(String status, String submitTime) { + this.status = status; + this.submitTime = submitTime; + } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalarySignatureResult.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalarySignatureResult.java new file mode 100644 index 0000000..f6d5329 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalarySignatureResult.java @@ -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 SalarySignatureResult { + @Schema(description = "签名值") + private final String signature; + + public SalarySignatureResult(String signature) { + this.signature = signature; + } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/domain/CorporateWalletApplicationRepository.java b/src/main/java/com/yau/digitalrmb/corporatewallet/domain/CorporateWalletApplicationRepository.java index fe0e627..530debb 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/domain/CorporateWalletApplicationRepository.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/domain/CorporateWalletApplicationRepository.java @@ -6,5 +6,6 @@ public interface CorporateWalletApplicationRepository { CorporateWalletApplication save(CorporateWalletApplication application); Optional findLatest(String userId, long schoolId, long classId); Optional findByApplicationId(String applicationId); + Optional findActivatedByCreditCode(String creditCode, String userId, long schoolId, long classId); void update(CorporateWalletApplication application); } diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/domain/RemoteWalletApplicationRepository.java b/src/main/java/com/yau/digitalrmb/corporatewallet/domain/RemoteWalletApplicationRepository.java index b272fdb..c0e1cdd 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/domain/RemoteWalletApplicationRepository.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/domain/RemoteWalletApplicationRepository.java @@ -5,5 +5,6 @@ import java.util.Optional; public interface RemoteWalletApplicationRepository { RemoteWalletApplication save(RemoteWalletApplication application); Optional findLatest(String userId, long schoolId, long classId); + Optional findActivatedByCreditCode(String creditCode, String userId, long schoolId, long classId); void update(RemoteWalletApplication application); } diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/domain/SalaryBatch.java b/src/main/java/com/yau/digitalrmb/corporatewallet/domain/SalaryBatch.java new file mode 100644 index 0000000..539b9d2 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/domain/SalaryBatch.java @@ -0,0 +1,110 @@ +package com.yau.digitalrmb.corporatewallet.domain; + +public class SalaryBatch { + public enum Status { + DRAFT, BLOCKCHAIN_FETCHED, CONCATENATED, DIGESTED, SIGNED, OUTPUT, SENT + } + + private Long id; + private String userId; + private long schoolId; + private long classId; + + // 调取区块信息 + private String batchId; + private String corpWalletId; + private String operatorName; + private String operatorTimestamp; + + // 拼接原文 + private String concatenatedMessage; + + // SM3摘要 + private String digestAlgorithm; + private String digestValue; + + // SM2签名 + private String signature; + + // 输出JSON + private String outputJson; + + // 状态 + private Status status; + private String submitTime; + + public static SalaryBatch create(String userId, long schoolId, long classId) { + SalaryBatch batch = new SalaryBatch(); + batch.userId = userId; + batch.schoolId = schoolId; + batch.classId = classId; + batch.status = Status.DRAFT; + return batch; + } + + public void fetchBlockchain(String batchId, String corpWalletId, String operatorName, String timestamp) { + this.batchId = batchId; + this.corpWalletId = corpWalletId; + this.operatorName = operatorName; + this.operatorTimestamp = timestamp; + this.status = Status.BLOCKCHAIN_FETCHED; + } + + public void concatenate(String message) { + this.concatenatedMessage = message; + this.status = Status.CONCATENATED; + } + + public void computeDigest(String algorithm, String digest) { + this.digestAlgorithm = algorithm; + this.digestValue = digest; + this.status = Status.DIGESTED; + } + + public void sign(String signature) { + this.signature = signature; + this.status = Status.SIGNED; + } + + public void output(String outputJson) { + this.outputJson = outputJson; + this.status = Status.OUTPUT; + } + + public void send(String submitTime) { + this.submitTime = submitTime; + this.status = Status.SENT; + } + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + public long getSchoolId() { return schoolId; } + public void setSchoolId(long schoolId) { this.schoolId = schoolId; } + public long getClassId() { return classId; } + public void setClassId(long classId) { this.classId = classId; } + public String getBatchId() { return batchId; } + public void setBatchId(String batchId) { this.batchId = batchId; } + public String getCorpWalletId() { return corpWalletId; } + public void setCorpWalletId(String corpWalletId) { this.corpWalletId = corpWalletId; } + public String getOperatorName() { return operatorName; } + public void setOperatorName(String operatorName) { this.operatorName = operatorName; } + public String getOperatorTimestamp() { return operatorTimestamp; } + public void setOperatorTimestamp(String operatorTimestamp) { this.operatorTimestamp = operatorTimestamp; } + public String getConcatenatedMessage() { return concatenatedMessage; } + public void setConcatenatedMessage(String concatenatedMessage) { this.concatenatedMessage = concatenatedMessage; } + public String getDigestAlgorithm() { return digestAlgorithm; } + public void setDigestAlgorithm(String digestAlgorithm) { this.digestAlgorithm = digestAlgorithm; } + public String getDigestValue() { return digestValue; } + public void setDigestValue(String digestValue) { this.digestValue = digestValue; } + public String getSignature() { return signature; } + public void setSignature(String signature) { this.signature = signature; } + public String getOutputJson() { return outputJson; } + public void setOutputJson(String outputJson) { this.outputJson = outputJson; } + public Status getStatus() { return status; } + public void setStatus(Status status) { this.status = status; } + public String getSubmitTime() { return submitTime; } + public void setSubmitTime(String submitTime) { this.submitTime = submitTime; } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/domain/SalaryBatchRepository.java b/src/main/java/com/yau/digitalrmb/corporatewallet/domain/SalaryBatchRepository.java new file mode 100644 index 0000000..1f01744 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/domain/SalaryBatchRepository.java @@ -0,0 +1,9 @@ +package com.yau.digitalrmb.corporatewallet.domain; + +import java.util.Optional; + +public interface SalaryBatchRepository { + SalaryBatch save(SalaryBatch batch); + Optional findLatest(String userId, long schoolId, long classId); + void update(SalaryBatch batch); +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisCorporateWalletApplicationRepository.java b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisCorporateWalletApplicationRepository.java index a251179..d5c33f8 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisCorporateWalletApplicationRepository.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisCorporateWalletApplicationRepository.java @@ -44,6 +44,22 @@ public class MybatisCorporateWalletApplicationRepository implements CorporateWal return entity == null ? Optional.empty() : Optional.of(toDomain(entity)); } + @Override + public Optional findActivatedByCreditCode(String creditCode, String userId, long schoolId, long classId) { + CorporateWalletApplicationEntity entity = mapper.selectOne( + new LambdaQueryWrapper() + .eq(CorporateWalletApplicationEntity::getCreditCode, creditCode) + .eq(CorporateWalletApplicationEntity::getUserId, userId) + .eq(CorporateWalletApplicationEntity::getSchoolId, schoolId) + .eq(CorporateWalletApplicationEntity::getClassId, classId) + .eq(CorporateWalletApplicationEntity::getDeleted, false) + .isNotNull(CorporateWalletApplicationEntity::getWalletId) + .ne(CorporateWalletApplicationEntity::getWalletId, "") + .orderByDesc(CorporateWalletApplicationEntity::getCreatedAt) + .last("LIMIT 1")); + return entity == null ? Optional.empty() : Optional.of(toDomain(entity)); + } + @Override public void update(CorporateWalletApplication application) { mapper.updateById(toEntity(application)); diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisRemoteWalletApplicationRepository.java b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisRemoteWalletApplicationRepository.java index 0fd1668..6aa4779 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisRemoteWalletApplicationRepository.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisRemoteWalletApplicationRepository.java @@ -34,6 +34,22 @@ public class MybatisRemoteWalletApplicationRepository implements RemoteWalletApp return entity == null ? Optional.empty() : Optional.of(toDomain(entity)); } + @Override + public Optional findActivatedByCreditCode(String creditCode, String userId, long schoolId, long classId) { + RemoteWalletApplicationEntity entity = mapper.selectOne( + new LambdaQueryWrapper() + .eq(RemoteWalletApplicationEntity::getCreditCode, creditCode) + .eq(RemoteWalletApplicationEntity::getUserId, userId) + .eq(RemoteWalletApplicationEntity::getSchoolId, schoolId) + .eq(RemoteWalletApplicationEntity::getClassId, classId) + .eq(RemoteWalletApplicationEntity::getDeleted, false) + .isNotNull(RemoteWalletApplicationEntity::getWalletId) + .ne(RemoteWalletApplicationEntity::getWalletId, "") + .orderByDesc(RemoteWalletApplicationEntity::getCreatedAt) + .last("LIMIT 1")); + return entity == null ? Optional.empty() : Optional.of(toDomain(entity)); + } + @Override public void update(RemoteWalletApplication application) { mapper.updateById(toEntity(application)); diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisSalaryBatchRepository.java b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisSalaryBatchRepository.java new file mode 100644 index 0000000..b170e97 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisSalaryBatchRepository.java @@ -0,0 +1,81 @@ +package com.yau.digitalrmb.corporatewallet.infrastructure; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.yau.digitalrmb.corporatewallet.domain.SalaryBatch; +import com.yau.digitalrmb.corporatewallet.domain.SalaryBatchRepository; +import org.springframework.stereotype.Repository; + +import javax.annotation.Resource; +import java.util.Optional; + +@Repository +public class MybatisSalaryBatchRepository implements SalaryBatchRepository { + @Resource + private SalaryBatchMapper mapper; + + @Override + public SalaryBatch save(SalaryBatch batch) { + SalaryBatchEntity entity = toEntity(batch); + mapper.insert(entity); + batch.setId(entity.getId()); + return batch; + } + + @Override + public Optional findLatest(String userId, long schoolId, long classId) { + SalaryBatchEntity entity = mapper.selectOne( + new LambdaQueryWrapper() + .eq(SalaryBatchEntity::getUserId, userId) + .eq(SalaryBatchEntity::getSchoolId, schoolId) + .eq(SalaryBatchEntity::getClassId, classId) + .eq(SalaryBatchEntity::getDeleted, false) + .orderByDesc(SalaryBatchEntity::getCreatedAt) + .last("LIMIT 1")); + return entity == null ? Optional.empty() : Optional.of(toDomain(entity)); + } + + @Override + public void update(SalaryBatch batch) { + mapper.updateById(toEntity(batch)); + } + + private SalaryBatchEntity toEntity(SalaryBatch batch) { + SalaryBatchEntity entity = new SalaryBatchEntity(); + entity.setId(batch.getId()); + entity.setUserId(batch.getUserId()); + entity.setSchoolId(batch.getSchoolId()); + entity.setClassId(batch.getClassId()); + entity.setBatchId(batch.getBatchId()); + entity.setCorpWalletId(batch.getCorpWalletId()); + entity.setOperatorName(batch.getOperatorName()); + entity.setOperatorTimestamp(batch.getOperatorTimestamp()); + entity.setConcatenatedMessage(batch.getConcatenatedMessage()); + entity.setDigestAlgorithm(batch.getDigestAlgorithm()); + entity.setDigestValue(batch.getDigestValue()); + entity.setSignature(batch.getSignature()); + entity.setOutputJson(batch.getOutputJson()); + entity.setStatus(batch.getStatus() == null ? null : batch.getStatus().name()); + entity.setSubmitTime(batch.getSubmitTime()); + return entity; + } + + private SalaryBatch toDomain(SalaryBatchEntity entity) { + SalaryBatch batch = new SalaryBatch(); + batch.setId(entity.getId()); + batch.setUserId(entity.getUserId()); + batch.setSchoolId(entity.getSchoolId()); + batch.setClassId(entity.getClassId()); + batch.setBatchId(entity.getBatchId()); + batch.setCorpWalletId(entity.getCorpWalletId()); + batch.setOperatorName(entity.getOperatorName()); + batch.setOperatorTimestamp(entity.getOperatorTimestamp()); + batch.setConcatenatedMessage(entity.getConcatenatedMessage()); + batch.setDigestAlgorithm(entity.getDigestAlgorithm()); + batch.setDigestValue(entity.getDigestValue()); + batch.setSignature(entity.getSignature()); + batch.setOutputJson(entity.getOutputJson()); + batch.setStatus(entity.getStatus() == null ? null : SalaryBatch.Status.valueOf(entity.getStatus())); + batch.setSubmitTime(entity.getSubmitTime()); + return batch; + } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/SalaryBatchEntity.java b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/SalaryBatchEntity.java new file mode 100644 index 0000000..a842b57 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/SalaryBatchEntity.java @@ -0,0 +1,41 @@ +package com.yau.digitalrmb.corporatewallet.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; + +@Getter +@Setter +@TableName("salary_batch") +public class SalaryBatchEntity extends AuditableEntity { + @TableField("user_id") + private String userId; + @TableField("school_id") + private Long schoolId; + @TableField("class_id") + private Long classId; + @TableField("batch_id") + private String batchId; + @TableField("corp_wallet_id") + private String corpWalletId; + @TableField("operator_name") + private String operatorName; + @TableField("operator_timestamp") + private String operatorTimestamp; + @TableField("concatenated_message") + private String concatenatedMessage; + @TableField("digest_algorithm") + private String digestAlgorithm; + @TableField("digest_value") + private String digestValue; + @TableField("signature") + private String signature; + @TableField("output_json") + private String outputJson; + @TableField("status") + private String status; + @TableField("submit_time") + private String submitTime; +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/SalaryBatchMapper.java b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/SalaryBatchMapper.java new file mode 100644 index 0000000..5912a8b --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/SalaryBatchMapper.java @@ -0,0 +1,8 @@ +package com.yau.digitalrmb.corporatewallet.infrastructure; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SalaryBatchMapper extends BaseMapper { +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryBatchController.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryBatchController.java new file mode 100644 index 0000000..5e1831f --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/SalaryBatchController.java @@ -0,0 +1,92 @@ +package com.yau.digitalrmb.corporatewallet.interfaces.rest; + +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.corporatewallet.application.SalaryBatchService; +import com.yau.digitalrmb.corporatewallet.application.SalaryBlockchainResult; +import com.yau.digitalrmb.corporatewallet.application.SalaryConcatenateResult; +import com.yau.digitalrmb.corporatewallet.application.SalaryDigestResult; +import com.yau.digitalrmb.corporatewallet.application.SalaryOutputResult; +import com.yau.digitalrmb.corporatewallet.application.SalaryPageResult; +import com.yau.digitalrmb.corporatewallet.application.SalarySendResult; +import com.yau.digitalrmb.corporatewallet.application.SalarySignatureResult; +import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.SalarySignRequest; +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.*; + +import javax.annotation.Resource; +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/v1/salary") +@Tag(name = "模块六-企业发放数字人民币工资 - 步骤1:数币操作员导入工资明细", description = "数币操作员导入工资明细、签名并发送全流程") +public class SalaryBatchController { + @Resource + private SalaryBatchService salaryBatchService; + + @PostMapping("/blockchain") + @Operation(summary = "调取区块信息", + description = "从步骤一对公钱包开立获取付款钱包ID,随机生成批次号,操作员为王五,生成时间戳") + public ApiResponse fetchBlockchain() { + JwtUser jwtUser = AuthContextHolder.get(); + return ApiResponse.success(salaryBatchService.fetchBlockchain( + InstitutionKeySubject.from(jwtUser), jwtUser.getUsername()), traceId()); + } + + @PostMapping("/concatenate") + @Operation(summary = "拼接工资发放申请原文", + description = "拼接批次号|付款钱包|操作员|员工姓名|钱包ID|金额|...|时间戳") + public ApiResponse concatenate() { + return ApiResponse.success(salaryBatchService.concatenate( + InstitutionKeySubject.from(AuthContextHolder.get())), traceId()); + } + + @PostMapping("/digest") + @Operation(summary = "生成摘要", + description = "对拼接原文进行SM3运算,只返回摘要值") + public ApiResponse computeDigest() { + return ApiResponse.success(salaryBatchService.computeDigest( + InstitutionKeySubject.from(AuthContextHolder.get())), traceId()); + } + + @PostMapping("/sign") + @Operation(summary = "SM2签名", + description = "传入数币操作员私钥进行SM2签名,私钥不正确则错误数+1,只返回签名值") + public ApiResponse sign(@Valid @RequestBody SalarySignRequest request) { + return ApiResponse.success(salaryBatchService.sign( + InstitutionKeySubject.from(AuthContextHolder.get()), request.getPrivateKey()), traceId()); + } + + @PostMapping("/output") + @Operation(summary = "输出", + description = "生成包含批次号、钱包、工资明细、摘要、签名等信息的JSON报文") + public ApiResponse output() { + return ApiResponse.success(salaryBatchService.output( + InstitutionKeySubject.from(AuthContextHolder.get())), traceId()); + } + + @PostMapping("/send") + @Operation(summary = "发送", + description = "发送工资发放申请到数币复核员审核,状态变为待审核") + public ApiResponse send() { + return ApiResponse.success(salaryBatchService.send( + InstitutionKeySubject.from(AuthContextHolder.get())), traceId()); + } + + @GetMapping("/page") + @Operation(summary = "页面查询", + description = "查询工资明细和当前步骤状态,前面步骤未完成时返回工资明细但其他字段为空(不报错)") + public ApiResponse pageQuery() { + return ApiResponse.success(salaryBatchService.pageQuery( + InstitutionKeySubject.from(AuthContextHolder.get())), traceId()); + } + + private String traceId() { + return MDC.get(TraceIdFilter.MDC_KEY); + } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalarySignRequest.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalarySignRequest.java new file mode 100644 index 0000000..db43d25 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/SalarySignRequest.java @@ -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 SalarySignRequest { + @NotBlank(message = "数币操作员私钥不能为空") + @Schema(description = "数币操作员私钥(十六进制)", required = true) + private String privateKey; +} diff --git a/src/main/resources/db/manual/corporate_wallet_key_info_upgrade.sql b/src/main/resources/db/manual/corporate_wallet_key_info_upgrade.sql new file mode 100644 index 0000000..4e27765 --- /dev/null +++ b/src/main/resources/db/manual/corporate_wallet_key_info_upgrade.sql @@ -0,0 +1,18 @@ +-- 模块六:密钥信息表 - 存储央行、商业银行、企业法人、数币操作员、复核员、监管机构的SM2密钥对 +CREATE TABLE IF NOT EXISTS corporate_wallet_key_info ( + id BIGINT PRIMARY KEY COMMENT '主键ID(雪花算法生成)', + user_id VARCHAR(36) NOT NULL COMMENT '用户ID(关联登录用户)', + school_id BIGINT NOT NULL COMMENT '学校ID', + class_id BIGINT NOT NULL COMMENT '班级ID', + key_type VARCHAR(64) NOT NULL COMMENT '密钥类型(如:央行第一私钥、央行第一公钥、商业银行第二私钥、企业法人私钥、数币操作员私钥、数币复核员私钥、监管机构私钥等)', + holder VARCHAR(128) NOT NULL COMMENT '持有方(如:中国人民银行、中国银行、张三、李四、王五、赵六、民政部公益监管中心)', + key_algorithm VARCHAR(16) NOT NULL DEFAULT 'SM2' COMMENT '密码算法(如:SM2)', + key_value TEXT NOT NULL COMMENT '完整值(十六进制),私钥或公钥的HEX字符串', + is_private_key BOOLEAN NOT NULL COMMENT '是否为私钥:true-私钥, false-公钥', + source VARCHAR(32) NOT NULL DEFAULT 'NEW' COMMENT '密钥来源:REUSED-复用已有(央行/商业银行), NEW-新生成, SIGNED-签约生成(企业法人)', + created_at TIMESTAMP NOT NULL COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL COMMENT '更新时间', + created_by VARCHAR(64) NOT NULL COMMENT '创建人', + updated_by VARCHAR(64) NOT NULL COMMENT '更新人', + deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-密钥信息表'; diff --git a/src/main/resources/db/manual/salary_batch_upgrade.sql b/src/main/resources/db/manual/salary_batch_upgrade.sql new file mode 100644 index 0000000..6f7d94d --- /dev/null +++ b/src/main/resources/db/manual/salary_batch_upgrade.sql @@ -0,0 +1,36 @@ +-- 模块六:工资发放批次表 - 数币操作员导入工资明细、签名并发送 +CREATE TABLE IF NOT EXISTS salary_batch ( + id BIGINT PRIMARY KEY COMMENT '主键ID(雪花算法生成)', + user_id VARCHAR(36) NOT NULL COMMENT '用户ID(关联登录用户)', + school_id BIGINT NOT NULL COMMENT '学校ID', + class_id BIGINT NOT NULL COMMENT '班级ID', + + -- 调取区块信息 + batch_id VARCHAR(64) COMMENT '批次号(如:BATCH_20260801_001)', + corp_wallet_id VARCHAR(128) COMMENT '付款钱包ID(从步骤一对公钱包开立获取,如:CORP_3A4B...)', + operator_name VARCHAR(64) COMMENT '操作员姓名(如:王五)', + operator_timestamp VARCHAR(20) COMMENT '操作时间戳,格式yyyyMMddHHmmss(如:20260801120000)', + + -- 拼接原文 + concatenated_message TEXT COMMENT '拼接工资发放申请原文(管道分隔)', + + -- SM3摘要 + digest_algorithm VARCHAR(16) COMMENT '摘要算法(如:SM3)', + digest_value VARCHAR(128) COMMENT 'SM3摘要值(十六进制大写)', + + -- SM2签名 + signature TEXT COMMENT 'SM2签名值(DER编码十六进制)', + + -- 输出JSON + output_json TEXT COMMENT '输出JSON报文', + + -- 状态 + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT-草稿, BLOCKCHAIN_FETCHED-已调取区块信息, CONCATENATED-已拼接, DIGESTED-已生成摘要, SIGNED-已签名, OUTPUT-已输出, SENT-已发送', + submit_time VARCHAR(20) COMMENT '发送时间,格式yyyyMMddHHmmss', + + created_at TIMESTAMP NOT NULL COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL COMMENT '更新时间', + created_by VARCHAR(64) NOT NULL COMMENT '创建人', + updated_by VARCHAR(64) NOT NULL COMMENT '更新人', + deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-工资发放批次表'; diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 687418b..ba37d7e 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1022,6 +1022,30 @@ CREATE TABLE IF NOT EXISTS corporate_wallet_key_info ( deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-密钥信息表'; +-- 模块六:工资发放批次表 - 数币操作员导入工资明细、签名并发送 +CREATE TABLE IF NOT EXISTS salary_batch ( + id BIGINT PRIMARY KEY COMMENT '主键ID(雪花算法生成)', + user_id VARCHAR(36) NOT NULL COMMENT '用户ID(关联登录用户)', + school_id BIGINT NOT NULL COMMENT '学校ID', + class_id BIGINT NOT NULL COMMENT '班级ID', + batch_id VARCHAR(64) COMMENT '批次号(如:BATCH_20260801_001)', + corp_wallet_id VARCHAR(128) COMMENT '付款钱包ID(从步骤一对公钱包开立获取)', + operator_name VARCHAR(64) COMMENT '操作员姓名(如:王五)', + operator_timestamp VARCHAR(20) COMMENT '操作时间戳,格式yyyyMMddHHmmss', + concatenated_message TEXT COMMENT '拼接工资发放申请原文(管道分隔)', + digest_algorithm VARCHAR(16) COMMENT '摘要算法(如:SM3)', + 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-已发送', + submit_time VARCHAR(20) COMMENT '发送时间,格式yyyyMMddHHmmss', + created_at TIMESTAMP NOT NULL COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL COMMENT '更新时间', + created_by VARCHAR(64) NOT NULL COMMENT '创建人', + updated_by VARCHAR(64) NOT NULL COMMENT '更新人', + deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-工资发放批次表'; + -- 步骤四商业银行端:生成合约信息与开通确认 CREATE TABLE IF NOT EXISTS smart_contract_generation ( id BIGINT PRIMARY KEY, diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index 7d290c2..05e53eb 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -671,3 +671,27 @@ CREATE TABLE IF NOT EXISTS corporate_wallet_key_info ( updated_by VARCHAR(64) NOT NULL COMMENT '更新人', deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-密钥信息表'; + +-- 模块六:工资发放批次表 +CREATE TABLE IF NOT EXISTS salary_batch ( + id BIGINT PRIMARY KEY COMMENT '主键ID', + user_id VARCHAR(36) NOT NULL COMMENT '用户ID', + school_id BIGINT NOT NULL COMMENT '学校ID', + class_id BIGINT NOT NULL COMMENT '班级ID', + batch_id VARCHAR(64) COMMENT '批次号', + corp_wallet_id VARCHAR(128) COMMENT '付款钱包ID', + operator_name VARCHAR(64) COMMENT '操作员姓名', + operator_timestamp VARCHAR(20) COMMENT '操作时间戳', + concatenated_message TEXT COMMENT '拼接原文', + digest_algorithm VARCHAR(16) COMMENT '摘要算法', + digest_value VARCHAR(128) COMMENT 'SM3摘要值', + signature TEXT COMMENT 'SM2签名值', + output_json TEXT COMMENT '输出JSON报文', + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态', + submit_time VARCHAR(20) COMMENT '发送时间', + created_at TIMESTAMP NOT NULL COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL COMMENT '更新时间', + created_by VARCHAR(64) NOT NULL COMMENT '创建人', + updated_by VARCHAR(64) NOT NULL COMMENT '更新人', + deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-工资发放批次表';