From 4b315995f38c4b0f7cbe57dc51a5cf7bcaeacbf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=B2=85?= <907037276@qq.com> Date: Sat, 5 Sep 2026 19:50:10 +0800 Subject: [PATCH 1/8] fix: align payment teaching payee with prototype --- .../service/PaymentTrainingActionService.java | 18 +++--- .../JdbcPaymentResourceRepository.java | 3 + .../TeachingPaymentPayeeWhitelist.java | 55 +++++++++++++++---- .../rest/PaymentControllerTest.java | 31 ++++++++++- 4 files changed, 86 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java index 490738d..7e96443 100644 --- a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java +++ b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java @@ -86,8 +86,12 @@ public class PaymentTrainingActionService { switch (key) { case "01:refresh-payer": return completed(payments.payer(actor)); - case "01:refresh-payee": - return completed(payments.payee(required(request.getPayeeWalletId(), "收款钱包标识"))); + case "01:refresh-payee": { + String payeeWalletId = request.getPayeeWalletId(); + return completed(payeeWalletId == null || payeeWalletId.trim().isEmpty() + ? payments.context(null, actor).getPayee() + : payments.payee(payeeWalletId.trim())); + } case "02:generate-timestamp": return completed(value("timestamp", TIMESTAMP.format(Instant.now()))); case "02:concatenate-payment": @@ -149,8 +153,9 @@ public class PaymentTrainingActionService { private Map paymentSource(PaymentActionRequest request, ExperimentAttemptView attempt, PaymentActor actor) { - String payeeWalletId = required(request.getPayeeWalletId(), "收款钱包标识"); - PaymentContext context = payments.context(payeeWalletId, actor); + String payeeWalletId = request.getPayeeWalletId(); + PaymentContext context = payments.context(payeeWalletId == null || payeeWalletId.trim().isEmpty() + ? null : payeeWalletId.trim(), actor); if (request.getPayerWalletId() != null && !context.getPayer().getWalletId().equals(request.getPayerWalletId().trim())) { throw validation("付款钱包不属于当前用户"); @@ -237,11 +242,6 @@ public class PaymentTrainingActionService { } } - private String required(String value, String name) { - if (value == null || value.trim().isEmpty()) throw validation(name + "不能为空"); - return value.trim(); - } - private String fingerprint(String key, PaymentActionRequest request, ExperimentSubject subject) { Map values = new LinkedHashMap(); values.put("action", key); diff --git a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java index c0997c1..b21899c 100644 --- a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java +++ b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java @@ -59,6 +59,9 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository @Override public PaymentParticipant loadPayee(String payeeWalletId) { + if (teachingPayeeWhitelist.isTeachingWallet(payeeWalletId)) { + return loadParticipant(teachingPayeeWhitelist.resolve(payeeWalletId)); + } return loadParticipant(walletProjection.referenceForWallet(payeeWalletId)); } diff --git a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/TeachingPaymentPayeeWhitelist.java b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/TeachingPaymentPayeeWhitelist.java index 5124cc5..1f9dd02 100644 --- a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/TeachingPaymentPayeeWhitelist.java +++ b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/TeachingPaymentPayeeWhitelist.java @@ -22,9 +22,16 @@ public class TeachingPaymentPayeeWhitelist { private static final String BANK_A = "BKCHCNBJ00001"; private static final String BANK_B = "BKCHCNBJ00002"; private static final String PAYEE_A_USER = "00000000-0000-0000-0000-000000000951"; - private static final String PAYEE_B_USER = "00000000-0000-0000-0000-000000000952"; + private static final String PAYEE_B_USER = "00000000-0000-0000-0000-000000000953"; private static final String PAYEE_A_WALLET = "WALLET_TRAINING_PAYEE_A"; - private static final String PAYEE_B_WALLET = "WALLET_TRAINING_PAYEE_B"; + private static final String PAYEE_B_WALLET = + "WALLET_B_7D8E9F0A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6"; + private static final PayeeDefinition PAYEE_A = new PayeeDefinition( + PAYEE_A_USER, PAYEE_A_WALLET, "TYPE_II", "CERT_" + PAYEE_A_WALLET, + BANK_A, "教学商业银行A", "ORG_TRAINING_PAYEE_A"); + private static final PayeeDefinition PAYEE_B = new PayeeDefinition( + PAYEE_B_USER, PAYEE_B_WALLET, "一类钱包", "00C3D4E5F6A7B890", + BANK_B, "建设银行(BKCHCNBJ00002)", "ORG_5F6G7B8E9G0W"); private final JdbcTemplate jdbc; private final InstitutionIdentityCryptography cryptography; @@ -39,9 +46,21 @@ public class TeachingPaymentPayeeWhitelist { @Transactional public WalletPrerequisiteReference resolveForDifferentBank(String payerBankCode) { - PayeeDefinition payee = BANK_B.equals(payerBankCode) - ? new PayeeDefinition(PAYEE_A_USER, PAYEE_A_WALLET, BANK_A, "教学商业银行A", "ORG_TRAINING_PAYEE_A") - : new PayeeDefinition(PAYEE_B_USER, PAYEE_B_WALLET, BANK_B, "教学商业银行B", "ORG_TRAINING_PAYEE_B"); + return resolve(BANK_B.equals(payerBankCode) ? PAYEE_A : PAYEE_B); + } + + public boolean isTeachingWallet(String walletId) { + return definitionFor(walletId) != null; + } + + @Transactional + public WalletPrerequisiteReference resolve(String walletId) { + PayeeDefinition payee = definitionFor(walletId); + if (payee == null) throw conflict("教学收款方钱包不存在"); + return resolve(payee); + } + + private WalletPrerequisiteReference resolve(PayeeDefinition payee) { ensureWallet(payee); ensureCertificate(payee); ensureBankAccount(payee); @@ -50,6 +69,12 @@ public class TeachingPaymentPayeeWhitelist { payee.bankCode, payee.organizationId); } + private PayeeDefinition definitionFor(String walletId) { + if (PAYEE_A_WALLET.equals(walletId)) return PAYEE_A; + if (PAYEE_B_WALLET.equals(walletId)) return PAYEE_B; + return null; + } + private void ensureWallet(PayeeDefinition payee) { List wallets = jdbc.query("SELECT user_id,wallet_type,status FROM digital_wallet WHERE wallet_id=?", (rs, row) -> new WalletRow(rs.getString("user_id"), rs.getString("wallet_type"), @@ -58,15 +83,20 @@ public class TeachingPaymentPayeeWhitelist { try { jdbc.update("INSERT INTO digital_wallet (wallet_id,user_id,wallet_type,status,balance,frozen_amount," + "central_bank_confirmation_signature,opened_at,created_at,updated_at) " + - "VALUES (?,?, 'TYPE_II','ACTIVE',0,0,'TEACHING_PAYEE_CONFIRMED',CURRENT_TIMESTAMP," + - "CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", payee.walletId, payee.userId); + "VALUES (?,?, ?,'ACTIVE',0,0,'TEACHING_PAYEE_CONFIRMED',CURRENT_TIMESTAMP," + + "CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", payee.walletId, payee.userId, payee.walletType); } catch (DuplicateKeyException exception) { + List afterConflict = jdbc.query( + "SELECT user_id,wallet_type,status FROM digital_wallet WHERE wallet_id=?", + (rs, row) -> new WalletRow(rs.getString("user_id"), rs.getString("wallet_type"), + rs.getString("status")), payee.walletId); + if (afterConflict.isEmpty()) throw conflict("教学收款方的钱包标识或用户标识已被占用"); ensureWallet(payee); } return; } WalletRow wallet = wallets.get(0); - if (!payee.userId.equals(wallet.userId) || !"TYPE_II".equals(wallet.walletType) + if (!payee.userId.equals(wallet.userId) || !payee.walletType.equals(wallet.walletType) || !"ACTIVE".equals(wallet.status)) { throw conflict("教学收款方钱包状态或归属异常"); } @@ -81,7 +111,7 @@ public class TeachingPaymentPayeeWhitelist { jdbc.update("INSERT INTO wallet_certificate (certificate_serial,wallet_id,public_key,encrypted_private_key," + "filing_status,central_bank_root_signature,status,issued_at) " + "VALUES (?,?,?,?, 'REGISTERED','TEACHING_PAYEE_ROOT','VALID',CURRENT_TIMESTAMP)", - "CERT_" + payee.walletId, payee.walletId, keyPair.getPublicKey(), + payee.certificateSerial, payee.walletId, keyPair.getPublicKey(), walletCipher.encrypt(keyPair.getPrivateKey())); } catch (DuplicateKeyException exception) { ensureCertificate(payee); @@ -137,14 +167,19 @@ public class TeachingPaymentPayeeWhitelist { private static final class PayeeDefinition { private final String userId; private final String walletId; + private final String walletType; + private final String certificateSerial; private final String bankCode; private final String bankName; private final String organizationId; - private PayeeDefinition(String userId, String walletId, String bankCode, String bankName, + private PayeeDefinition(String userId, String walletId, String walletType, String certificateSerial, + String bankCode, String bankName, String organizationId) { this.userId = userId; this.walletId = walletId; + this.walletType = walletType; + this.certificateSerial = certificateSerial; this.bankCode = bankCode; this.bankName = bankName; this.organizationId = organizationId; diff --git a/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java index 96e5a8c..f71e843 100644 --- a/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java @@ -204,11 +204,38 @@ class PaymentControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.payer.walletId").value(PAYER_WALLET)) - .andExpect(jsonPath("$.data.payee.walletId").value("WALLET_TRAINING_PAYEE_B")) + .andExpect(jsonPath("$.data.payee.walletId").value( + "WALLET_B_7D8E9F0A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6")) .andExpect(jsonPath("$.data.payee.bankCode").value("BKCHCNBJ00002")); assertThat(jdbc.queryForObject("SELECT status FROM digital_wallet WHERE wallet_id=?", String.class, - "WALLET_TRAINING_PAYEE_B")).isEqualTo("ACTIVE"); + "WALLET_B_7D8E9F0A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6")).isEqualTo("ACTIVE"); + } + + @Test + void actionChainUsesTeachingPayeeWithoutASecondWallet() throws Exception { + String created = mockMvc.perform(post("/api/v1/payment/attempts").with(payer())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + String attemptId = JsonPath.read(created, "$.data.attemptId"); + + executeAction(attemptId, "01", "refresh-payer", payer()); + mockMvc.perform(post("/api/v1/payment/attempts/{id}/steps/01/actions/refresh-payee", attemptId) + .with(payer()).contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.output.walletId").value( + "WALLET_B_7D8E9F0A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6")) + .andExpect(jsonPath("$.data.output.walletType").value("一类钱包")) + .andExpect(jsonPath("$.data.output.certificateSerial").value("00C3D4E5F6A7B890")) + .andExpect(jsonPath("$.data.output.bankName").value("建设银行(BKCHCNBJ00002)")) + .andExpect(jsonPath("$.data.output.organizationId").value("ORG_5F6G7B8E9G0W")); + executeAction(attemptId, "02", "generate-timestamp", payer()); + mockMvc.perform(post("/api/v1/payment/attempts/{id}/steps/02/actions/concatenate-payment", attemptId) + .with(payer()).contentType(MediaType.APPLICATION_JSON) + .content("{\"payerWalletId\":\"" + PAYER_WALLET + "\",\"payeeWalletId\":\"WALLET_B_7D8E9F0A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6\",\"amount\":50,\"note\":\"教学支付\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.output.payeeWalletId").value( + "WALLET_B_7D8E9F0A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6")); } @Test From 792888ab049cd988eb4fb26a7a1961c2c161e73e Mon Sep 17 00:00:00 2001 From: whb <17803890193@163.com> Date: Mon, 7 Sep 2026 14:10:22 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/yau/digitalrmb/DigitalRmbApplication.java | 7 ++++++- src/main/resources/application.yml | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/yau/digitalrmb/DigitalRmbApplication.java b/src/main/java/com/yau/digitalrmb/DigitalRmbApplication.java index 93d0329..300323c 100644 --- a/src/main/java/com/yau/digitalrmb/DigitalRmbApplication.java +++ b/src/main/java/com/yau/digitalrmb/DigitalRmbApplication.java @@ -1,14 +1,19 @@ package com.yau.digitalrmb; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.scheduling.annotation.EnableScheduling; +@Slf4j @SpringBootApplication @EnableScheduling public class DigitalRmbApplication { public static void main(String[] args) { - SpringApplication.run(DigitalRmbApplication.class, args); + ConfigurableApplicationContext context = SpringApplication.run(DigitalRmbApplication.class, args); + log.info("数字人民币教学仿真后端启动完成,端口:{}", + context.getEnvironment().getProperty("server.port")); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 38e3194..e5a1977 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,6 +14,9 @@ spring: init: mode: always encoding: UTF-8 +logging: + file: + path: ./logs server: port: 8787 From 5e66219d2602c80ffb001277b25142cdb754f049 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Wed, 16 Sep 2026 08:45:23 +0800 Subject: [PATCH 3/8] fix(payment): project wallet prerequisites on refresh --- .../JdbcPaymentResourceRepository.java | 4 +- .../rest/PaymentControllerTest.java | 41 ++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java index 1b7e8e9..ba21ab5 100644 --- a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java +++ b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java @@ -49,14 +49,14 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository @Override public PaymentParticipant loadPayer(PaymentActor actor) { - WalletPrerequisiteReference reference = walletProjection.referenceForSubject( + WalletPrerequisiteReference reference = walletProjection.ensureForSubject( new InstitutionKeySubject(actor.getUserId(), actor.getSchoolId(), actor.getClassId())); return loadParticipant(reference); } @Override public PaymentParticipant loadPayee(String payeeWalletId) { - return loadParticipant(walletProjection.referenceForWallet(payeeWalletId)); + return loadParticipant(walletProjection.ensureForWallet(payeeWalletId)); } @Override diff --git a/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java index e4a5a02..10cbafb 100644 --- a/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java @@ -198,6 +198,39 @@ class PaymentControllerTest { String.class)).isEqualTo("TRANSFERRED"); } + @Test + void refreshActionsProjectCompletedModuleThreeWalletsWhenSharedRowsAreMissing() throws Exception { + clearSharedWalletProjection(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM digital_wallet WHERE wallet_id IN (?,?)", + Integer.class, PAYER_WALLET, PAYEE_WALLET)).isZero(); + + String created = mockMvc.perform(post("/api/v1/payment/attempts").with(payer())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + String attemptId = JsonPath.read(created, "$.data.attemptId"); + + mockMvc.perform(post("/api/v1/payment/attempts/{id}/steps/01/actions/refresh-payer", attemptId) + .with(payer()).contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.actionStatus").value("COMPLETED")) + .andExpect(jsonPath("$.data.output.walletId").value(PAYER_WALLET)); + mockMvc.perform(post("/api/v1/payment/attempts/{id}/steps/01/actions/refresh-payee", attemptId) + .with(payer()).contentType(MediaType.APPLICATION_JSON) + .content("{\"payeeWalletId\":\"" + PAYEE_WALLET + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.actionStatus").value("COMPLETED")) + .andExpect(jsonPath("$.data.output.walletId").value(PAYEE_WALLET)); + + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM digital_wallet WHERE wallet_id IN (?,?)", + Integer.class, PAYER_WALLET, PAYEE_WALLET)).isEqualTo(2); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM wallet_certificate WHERE wallet_id IN (?,?)", + Integer.class, PAYER_WALLET, PAYEE_WALLET)).isEqualTo(2); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM wallet_contract WHERE wallet_id IN (?,?)", + Integer.class, PAYER_WALLET, PAYEE_WALLET)).isEqualTo(2); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM wallet_bank_binding WHERE wallet_id IN (?,?)", + Integer.class, PAYER_WALLET, PAYEE_WALLET)).isEqualTo(2); + } + @Test void rejectsPaymentWhenPayerHasNoWalletOwnedCoins() throws Exception { String created = mockMvc.perform(post("/api/v1/payments").with(payer()).contentType(MediaType.APPLICATION_JSON) @@ -387,13 +420,17 @@ class PaymentControllerTest { } private void cleanWalletState() { + clearSharedWalletProjection(); + WalletOpeningTestData.deleteWalletFacts(jdbc, PAYER_USER); + WalletOpeningTestData.deleteWalletFacts(jdbc, PAYEE_USER); + } + + private void clearSharedWalletProjection() { jdbc.update("DELETE FROM wallet_bank_binding WHERE wallet_id IN (?,?)", PAYER_WALLET, PAYEE_WALLET); jdbc.update("DELETE FROM wallet_contract WHERE wallet_id IN (?,?)", PAYER_WALLET, PAYEE_WALLET); jdbc.update("DELETE FROM wallet_certificate WHERE wallet_id IN (?,?)", PAYER_WALLET, PAYEE_WALLET); jdbc.update("DELETE FROM simulated_bank_account WHERE user_id IN (?,?)", PAYER_USER, PAYEE_USER); jdbc.update("DELETE FROM digital_wallet WHERE wallet_id IN (?,?)", PAYER_WALLET, PAYEE_WALLET); - WalletOpeningTestData.deleteWalletFacts(jdbc, PAYER_USER); - WalletOpeningTestData.deleteWalletFacts(jdbc, PAYEE_USER); } private void cleanPaymentData() { From c243884e8862be07c180be64978c5dea06f50323 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Wed, 16 Sep 2026 09:40:48 +0800 Subject: [PATCH 4/8] fix(salary): isolate batches and sign digest value --- .../application/SalaryBatchService.java | 16 ++-- .../MybatisSalaryBatchRepository.java | 1 + .../application/SalaryBatchServiceTest.java | 90 +++++++++++++++++++ 3 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 src/test/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchServiceTest.java diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchService.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchService.java index 3bd15c2..d3fe05d 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchService.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchService.java @@ -73,14 +73,16 @@ public class SalaryBatchService { @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, "请先完成对公钱包开立(步骤一)"); } + // 每次调取区块信息都代表一个新的工资批次,避免复用上一批次的摘要、签名和状态。 + SalaryBatch batch = repository.save( + SalaryBatch.create(subject.getUserId(), subject.getSchoolId(), subject.getClassId())); + // 批次号随机生成 String batchId = generateBatchId(); // 时间戳 @@ -161,8 +163,8 @@ public class SalaryBatchService { throw new BusinessException(ErrorCode.VALIDATION_ERROR, "数币操作员私钥不正确,错误次数+1"); } - // 使用数币操作员私钥对摘要进行SM2签名 - String signature = cryptography.sign(storedPrivateKey, batch.getConcatenatedMessage()); + // 按原型要求,使用数币操作员私钥对SM3摘要值进行SM2签名。 + String signature = cryptography.sign(storedPrivateKey, batch.getDigestValue()); batch.sign(signature); repository.update(batch); @@ -516,12 +518,6 @@ public class SalaryBatchService { // ==================== 内部方法 ==================== - 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, diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisSalaryBatchRepository.java b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisSalaryBatchRepository.java index eb38960..8c5d2ff 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisSalaryBatchRepository.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/MybatisSalaryBatchRepository.java @@ -30,6 +30,7 @@ public class MybatisSalaryBatchRepository implements SalaryBatchRepository { .eq(SalaryBatchEntity::getClassId, classId) .eq(SalaryBatchEntity::getDeleted, false) .orderByDesc(SalaryBatchEntity::getCreatedAt) + .orderByDesc(SalaryBatchEntity::getId) .last("LIMIT 1")); return entity == null ? Optional.empty() : Optional.of(toDomain(entity)); } diff --git a/src/test/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchServiceTest.java b/src/test/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchServiceTest.java new file mode 100644 index 0000000..6080d7c --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchServiceTest.java @@ -0,0 +1,90 @@ +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.SalaryBatch; +import com.yau.digitalrmb.corporatewallet.domain.SalaryBatchRepository; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SalaryBatchServiceTest { + private static final InstitutionKeySubject SUBJECT = + new InstitutionKeySubject("salary-user", 1001L, 2001L); + private static final String PRIVATE_KEY = "OPERATOR_PRIVATE_KEY"; + + @Mock private SalaryBatchRepository repository; + @Mock private CorporateWalletApplicationRepository corporateWalletRepository; + @Mock private InstitutionIdentityCryptography cryptography; + @Mock private SalaryScoreService scoreService; + @Mock private CorporateWalletKeyService keyService; + @InjectMocks private SalaryBatchService service; + + @Test + void startsFreshBatchAndSignsCurrentDigestWhenWorkflowRunsAgain() { + AtomicReference latest = new AtomicReference<>(); + AtomicLong ids = new AtomicLong(10L); + when(repository.save(any(SalaryBatch.class))).thenAnswer(invocation -> { + SalaryBatch batch = invocation.getArgument(0); + batch.setId(ids.incrementAndGet()); + latest.set(batch); + return batch; + }); + when(repository.findLatest(SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId())) + .thenAnswer(invocation -> Optional.ofNullable(latest.get())); + + CorporateWalletApplication wallet = CorporateWalletApplication.create( + SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId()); + wallet.setWalletId("CORP_WALLET_001"); + when(corporateWalletRepository.findLatest( + SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId())) + .thenReturn(Optional.of(wallet)); + when(cryptography.sm3(any(String.class))).thenReturn("DIGEST_ONE", "DIGEST_TWO"); + when(keyService.getOrCreatePrivateKey( + any(InstitutionKeySubject.class), eq("数币操作员私钥"), eq("王五"))) + .thenReturn(PRIVATE_KEY); + when(cryptography.sign(PRIVATE_KEY, "DIGEST_ONE")).thenReturn("SIGNATURE_ONE"); + when(cryptography.sign(PRIVATE_KEY, "DIGEST_TWO")).thenReturn("SIGNATURE_TWO"); + + service.fetchBlockchain(SUBJECT, "tester"); + service.concatenate(SUBJECT); + SalaryDigestResult firstDigest = service.computeDigest(SUBJECT); + SalaryBatch firstBatch = latest.get(); + assertThat(firstBatch.getStatus()).isEqualTo(SalaryBatch.Status.DIGESTED); + SalarySignatureResult firstSignature = service.sign(SUBJECT, PRIVATE_KEY); + + service.fetchBlockchain(SUBJECT, "tester"); + service.concatenate(SUBJECT); + SalaryDigestResult secondDigest = service.computeDigest(SUBJECT); + SalaryBatch secondBatch = latest.get(); + assertThat(secondBatch.getStatus()).isEqualTo(SalaryBatch.Status.DIGESTED); + SalarySignatureResult secondSignature = service.sign(SUBJECT, PRIVATE_KEY); + + assertThat(secondBatch).isNotSameAs(firstBatch); + assertThat(secondBatch.getId()).isNotEqualTo(firstBatch.getId()); + assertThat(firstDigest.getDigestValue()).isEqualTo("DIGEST_ONE"); + assertThat(secondDigest.getDigestValue()).isEqualTo("DIGEST_TWO"); + assertThat(firstSignature.getSignature()).isEqualTo("SIGNATURE_ONE"); + assertThat(secondSignature.getSignature()).isEqualTo("SIGNATURE_TWO"); + + verify(repository, times(2)).save(any(SalaryBatch.class)); + verify(cryptography).sign(PRIVATE_KEY, "DIGEST_ONE"); + verify(cryptography).sign(PRIVATE_KEY, "DIGEST_TWO"); + } +} From 2dd9c817be0f5f9aa210a7346d9b72f016b5956e Mon Sep 17 00:00:00 2001 From: chenyuan Date: Wed, 16 Sep 2026 10:26:23 +0800 Subject: [PATCH 5/8] feat(module6): add innovation training reset --- ...orporateInnovationTrainingResetResult.java | 21 ++++++ ...rporateInnovationTrainingResetService.java | 70 +++++++++++++++++++ ...rateInnovationTrainingResetController.java | 35 ++++++++++ ...ateInnovationTrainingResetServiceTest.java | 62 ++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetResult.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetService.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/CorporateInnovationTrainingResetController.java create mode 100644 src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetResult.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetResult.java new file mode 100644 index 0000000..45ca4bd --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetResult.java @@ -0,0 +1,21 @@ +package com.yau.digitalrmb.corporatewallet.application; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +@Schema(description = "模块六创新应用场景重新实训结果") +public class CorporateInnovationTrainingResetResult { + @Schema(description = "是否重置成功") + private final boolean reset; + @Schema(description = "逻辑删除的业务记录总数") + private final int deletedRecordCount; + @Schema(description = "处理说明") + private final String message; + + public CorporateInnovationTrainingResetResult(int deletedRecordCount) { + this.reset = true; + this.deletedRecordCount = deletedRecordCount; + this.message = "数字人民币创新应用场景步骤数据已逻辑删除,可从模块六第一步重新开始"; + } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetService.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetService.java new file mode 100644 index 0000000..10e5bfa --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetService.java @@ -0,0 +1,70 @@ +package com.yau.digitalrmb.corporatewallet.application; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.yau.digitalrmb.corporatewallet.infrastructure.CorporateWalletApplicationEntity; +import com.yau.digitalrmb.corporatewallet.infrastructure.CorporateWalletApplicationMapper; +import com.yau.digitalrmb.corporatewallet.infrastructure.RemoteWalletApplicationEntity; +import com.yau.digitalrmb.corporatewallet.infrastructure.RemoteWalletApplicationMapper; +import com.yau.digitalrmb.corporatewallet.infrastructure.SalaryBatchEntity; +import com.yau.digitalrmb.corporatewallet.infrastructure.SalaryBatchMapper; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity; +import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper; +import com.yau.digitalrmb.progress.application.UserStepProgressNewService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Resource; + +@Service +public class CorporateInnovationTrainingResetService { + private static final String TRAINING_ID = "innovation-scene"; + + @Resource private CorporateWalletApplicationMapper corporateWalletMapper; + @Resource private RemoteWalletApplicationMapper remoteWalletMapper; + @Resource private SalaryBatchMapper salaryBatchMapper; + @Resource private StuModuleScoreDetailsMapper scoreDetailsMapper; + @Resource private UserStepProgressNewService stepProgressService; + + @Transactional + public CorporateInnovationTrainingResetResult reset(InstitutionKeySubject subject) { + int count = 0; + count += logicalDeleteCorporateWalletApplications(subject); + count += logicalDeleteRemoteWalletApplications(subject); + count += logicalDeleteSalaryBatches(subject); + count += logicalDeleteScore(subject.getUserId()); + stepProgressService.reset(subject.getUserId(), TRAINING_ID); + return new CorporateInnovationTrainingResetResult(count); + } + + private int logicalDeleteCorporateWalletApplications(InstitutionKeySubject subject) { + return corporateWalletMapper.update(null, new LambdaUpdateWrapper() + .eq(CorporateWalletApplicationEntity::getUserId, subject.getUserId()) + .eq(CorporateWalletApplicationEntity::getSchoolId, subject.getSchoolId()) + .eq(CorporateWalletApplicationEntity::getClassId, subject.getClassId()) + .set(CorporateWalletApplicationEntity::getDeleted, true)); + } + + private int logicalDeleteRemoteWalletApplications(InstitutionKeySubject subject) { + return remoteWalletMapper.update(null, new LambdaUpdateWrapper() + .eq(RemoteWalletApplicationEntity::getUserId, subject.getUserId()) + .eq(RemoteWalletApplicationEntity::getSchoolId, subject.getSchoolId()) + .eq(RemoteWalletApplicationEntity::getClassId, subject.getClassId()) + .set(RemoteWalletApplicationEntity::getDeleted, true)); + } + + private int logicalDeleteSalaryBatches(InstitutionKeySubject subject) { + return salaryBatchMapper.update(null, new LambdaUpdateWrapper() + .eq(SalaryBatchEntity::getUserId, subject.getUserId()) + .eq(SalaryBatchEntity::getSchoolId, subject.getSchoolId()) + .eq(SalaryBatchEntity::getClassId, subject.getClassId()) + .set(SalaryBatchEntity::getDeleted, true)); + } + + private int logicalDeleteScore(String userId) { + return scoreDetailsMapper.update(null, new LambdaUpdateWrapper() + .eq(StuModuleScoreDetailsEntity::getUserId, userId) + .eq(StuModuleScoreDetailsEntity::getMoudule, SalaryScoreService.MODULE_NAME) + .set(StuModuleScoreDetailsEntity::getDeleted, true)); + } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/CorporateInnovationTrainingResetController.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/CorporateInnovationTrainingResetController.java new file mode 100644 index 0000000..d7c0eaa --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/CorporateInnovationTrainingResetController.java @@ -0,0 +1,35 @@ +package com.yau.digitalrmb.corporatewallet.interfaces.rest; + +import com.yau.digitalrmb.corporatewallet.application.CorporateInnovationTrainingResetResult; +import com.yau.digitalrmb.corporatewallet.application.CorporateInnovationTrainingResetService; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; + +@RestController +@RequestMapping("/api/v1/corporate-wallet/training") +@Tag(name = "模块六:数字人民币创新应用场景实验管理") +public class CorporateInnovationTrainingResetController { + @Resource private CorporateInnovationTrainingResetService resetService; + + @PostMapping("/reset") + @Operation(summary = "重新开始数字人民币创新应用场景实验", + description = "逻辑删除当前主体的对公钱包申请、工资批次、模块六成绩和步骤进度;保留模块六密钥,可从模块六第一步重新开始。") + public ApiResponse reset() { + return ApiResponse.success(resetService.reset( + InstitutionKeySubject.from(AuthContextHolder.get())), traceId()); + } + + private String traceId() { + return MDC.get(TraceIdFilter.MDC_KEY); + } +} diff --git a/src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java b/src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java new file mode 100644 index 0000000..029dbda --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java @@ -0,0 +1,62 @@ +package com.yau.digitalrmb.corporatewallet.application; + +import com.yau.digitalrmb.corporatewallet.infrastructure.CorporateWalletApplicationMapper; +import com.yau.digitalrmb.corporatewallet.infrastructure.CorporateWalletApplicationEntity; +import com.yau.digitalrmb.corporatewallet.infrastructure.RemoteWalletApplicationEntity; +import com.yau.digitalrmb.corporatewallet.infrastructure.RemoteWalletApplicationMapper; +import com.yau.digitalrmb.corporatewallet.infrastructure.SalaryBatchEntity; +import com.yau.digitalrmb.corporatewallet.infrastructure.SalaryBatchMapper; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper; +import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity; +import com.yau.digitalrmb.progress.application.UserStepProgressNewService; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CorporateInnovationTrainingResetServiceTest { + private static final InstitutionKeySubject SUBJECT = + new InstitutionKeySubject("reset-user", 1001L, 2001L); + + @Mock private CorporateWalletApplicationMapper corporateWalletMapper; + @Mock private RemoteWalletApplicationMapper remoteWalletMapper; + @Mock private SalaryBatchMapper salaryBatchMapper; + @Mock private StuModuleScoreDetailsMapper scoreDetailsMapper; + @Mock private UserStepProgressNewService stepProgressService; + @InjectMocks private CorporateInnovationTrainingResetService service; + + @BeforeAll + static void initializeMybatisLambdaMetadata() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, CorporateWalletApplicationEntity.class); + TableInfoHelper.initTableInfo(assistant, RemoteWalletApplicationEntity.class); + TableInfoHelper.initTableInfo(assistant, SalaryBatchEntity.class); + TableInfoHelper.initTableInfo(assistant, StuModuleScoreDetailsEntity.class); + } + + @Test + void resetsModuleSixBusinessDataScoreAndNavigationProgress() { + when(corporateWalletMapper.update(any(), any())).thenReturn(2); + when(remoteWalletMapper.update(any(), any())).thenReturn(3); + when(salaryBatchMapper.update(any(), any())).thenReturn(4); + when(scoreDetailsMapper.update(any(), any())).thenReturn(1); + + CorporateInnovationTrainingResetResult result = service.reset(SUBJECT); + + assertThat(result.isReset()).isTrue(); + assertThat(result.getDeletedRecordCount()).isEqualTo(10); + verify(stepProgressService).reset("reset-user", "innovation-scene"); + } +} From 847a761cbb6ef851ac7de13a8a2b2c6e3459e0b6 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Wed, 16 Sep 2026 11:07:46 +0800 Subject: [PATCH 6/8] feat(module6): implement charity donation workflow --- ...rporateInnovationTrainingResetService.java | 12 + .../application/DonationPageResult.java | 44 ++ .../application/DonationService.java | 418 ++++++++++++++++++ .../application/SalaryScoreService.java | 8 +- .../infrastructure/DonationRecordEntity.java | 48 ++ .../infrastructure/DonationRecordMapper.java | 8 + .../interfaces/rest/DonationController.java | 103 +++++ .../rest/dto/DonationCreateRequest.java | 25 ++ .../rest/dto/DonationSignRequest.java | 13 + .../db/manual/module_6_charity_upgrade.sql | 44 ++ src/main/resources/schema.sql | 44 ++ ...ateInnovationTrainingResetServiceTest.java | 7 +- .../application/DonationServiceTest.java | 119 +++++ src/test/resources/schema.sql | 18 + 14 files changed, 906 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/application/DonationPageResult.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/application/DonationService.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/DonationRecordEntity.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/DonationRecordMapper.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/DonationController.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/DonationCreateRequest.java create mode 100644 src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/DonationSignRequest.java create mode 100644 src/main/resources/db/manual/module_6_charity_upgrade.sql create mode 100644 src/test/java/com/yau/digitalrmb/corporatewallet/application/DonationServiceTest.java diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetService.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetService.java index 10e5bfa..2976a7b 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetService.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetService.java @@ -3,6 +3,8 @@ package com.yau.digitalrmb.corporatewallet.application; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.yau.digitalrmb.corporatewallet.infrastructure.CorporateWalletApplicationEntity; import com.yau.digitalrmb.corporatewallet.infrastructure.CorporateWalletApplicationMapper; +import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordEntity; +import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordMapper; import com.yau.digitalrmb.corporatewallet.infrastructure.RemoteWalletApplicationEntity; import com.yau.digitalrmb.corporatewallet.infrastructure.RemoteWalletApplicationMapper; import com.yau.digitalrmb.corporatewallet.infrastructure.SalaryBatchEntity; @@ -23,6 +25,7 @@ public class CorporateInnovationTrainingResetService { @Resource private CorporateWalletApplicationMapper corporateWalletMapper; @Resource private RemoteWalletApplicationMapper remoteWalletMapper; @Resource private SalaryBatchMapper salaryBatchMapper; + @Resource private DonationRecordMapper donationRecordMapper; @Resource private StuModuleScoreDetailsMapper scoreDetailsMapper; @Resource private UserStepProgressNewService stepProgressService; @@ -32,6 +35,7 @@ public class CorporateInnovationTrainingResetService { count += logicalDeleteCorporateWalletApplications(subject); count += logicalDeleteRemoteWalletApplications(subject); count += logicalDeleteSalaryBatches(subject); + count += logicalDeleteDonationRecords(subject); count += logicalDeleteScore(subject.getUserId()); stepProgressService.reset(subject.getUserId(), TRAINING_ID); return new CorporateInnovationTrainingResetResult(count); @@ -61,6 +65,14 @@ public class CorporateInnovationTrainingResetService { .set(SalaryBatchEntity::getDeleted, true)); } + private int logicalDeleteDonationRecords(InstitutionKeySubject subject) { + return donationRecordMapper.update(null, new LambdaUpdateWrapper() + .eq(DonationRecordEntity::getUserId, subject.getUserId()) + .eq(DonationRecordEntity::getSchoolId, subject.getSchoolId()) + .eq(DonationRecordEntity::getClassId, subject.getClassId()) + .set(DonationRecordEntity::getDeleted, true)); + } + private int logicalDeleteScore(String userId) { return scoreDetailsMapper.update(null, new LambdaUpdateWrapper() .eq(StuModuleScoreDetailsEntity::getUserId, userId) diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/DonationPageResult.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/DonationPageResult.java new file mode 100644 index 0000000..9b44269 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/DonationPageResult.java @@ -0,0 +1,44 @@ +package com.yau.digitalrmb.corporatewallet.application; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(description = "公益捐赠三步流程页面数据") +public class DonationPageResult { + private final String donorName; + private final String donorWalletId; + private final String recipientName; + private final String recipientWalletId; + private final String amount; + private final String projectName; + private final String memo; + private final String donationTimestamp; + private final String donationId; + private final String donationMessage; + private final String donationDigest; + private final String donorPrivateKey; + private final String donorSignature; + private final String donationOutputJson; + private final String receiptStatus; + private final String publicationTimestamp; + private final String publicationMessage; + private final String publicationDigest; + private final String publicationId; + private final String publicationTitle; + private final String publicationContent; + private final String publicationUrl; + private final String publicationOutputJson; + private final String supervisionTimestamp; + private final String supervisionMessage; + private final String supervisionDigest; + private final String regulatorPrivateKey; + private final String regulatorSignature; + private final String approvalId; + private final String approvalTime; + private final String recordNumber; + private final String supervisionOutputJson; + private final String status; +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/DonationService.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/DonationService.java new file mode 100644 index 0000000..363edcf --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/DonationService.java @@ -0,0 +1,418 @@ +package com.yau.digitalrmb.corporatewallet.application; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +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.DonationRecordEntity; +import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordMapper; +import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.DonationCreateRequest; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; +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.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +public class DonationService { + private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + private static final String DONOR_KEY_TYPE = "企业法人私钥"; + private static final String REGULATOR_KEY_TYPE = "监管机构私钥"; + private static final String REGULATOR_NAME = "民政部公益监管中心"; + + private enum Status { + DRAFT, CONCATENATED, DIGESTED, SIGNED, SUBMITTED, RECEIVED, + PUBLICATION_EXTRACTED, PUBLICATION_DIGESTED, PUBLICATION_READY, PUBLISHED, + SUPERVISION_EXTRACTED, SUPERVISION_DIGESTED, SUPERVISION_SIGNED, ARCHIVED + } + + @Resource private DonationRecordMapper mapper; + @Resource private CorporateWalletApplicationRepository corporateWalletRepository; + @Resource private RemoteWalletApplicationRepository remoteWalletRepository; + @Resource private CorporateWalletKeyService keyService; + @Resource private InstitutionIdentityCryptography cryptography; + @Resource private SalaryScoreService scoreService; + @Resource private ObjectMapper objectMapper; + + @Transactional + public DonationPageResult page(InstitutionKeySubject subject, String operator) { + DonationRecordEntity record = findLatest(subject); + CorporateWalletApplication donor = donorWallet(subject); + RemoteWalletApplication recipient = recipientWallet(subject); + return result(subject, record, donor, recipient, operator); + } + + @Transactional + public DonationPageResult concatenate(InstitutionKeySubject subject, String operator, + DonationCreateRequest request) { + CorporateWalletApplication donor = requiredDonorWallet(subject); + RemoteWalletApplication recipient = requiredRecipientWallet(subject); + validateWalletInputs(donor, recipient, request); + + DonationRecordEntity record = findLatest(subject); + if (record == null || status(record).ordinal() >= Status.SUBMITTED.ordinal()) { + record = new DonationRecordEntity(); + record.setUserId(subject.getUserId()); + record.setSchoolId(subject.getSchoolId()); + record.setClassId(subject.getClassId()); + record.setDeleted(false); + record.setStatus(Status.DRAFT.name()); + } + + String amount = money(request.getAmount()); + String message = String.join("|", "DONATE", request.getDonorWalletId(), + request.getRecipientWalletId(), amount, request.getProjectName().trim(), + request.getMemo().trim(), request.getTimestamp()); + record.setDonationId("DONATE_" + request.getTimestamp().substring(0, 8) + "_001"); + record.setDonorName(request.getDonorName().trim()); + record.setDonorWalletId(request.getDonorWalletId().trim()); + record.setRecipientName(request.getRecipientName().trim()); + record.setRecipientWalletId(request.getRecipientWalletId().trim()); + record.setAmount(request.getAmount().setScale(2)); + record.setProjectName(request.getProjectName().trim()); + record.setMemo(request.getMemo().trim()); + record.setDonationTimestamp(request.getTimestamp()); + record.setDonationMessage(message); + record.setDonationDigest(null); + record.setDonorSignature(null); + record.setDonationOutputJson(null); + record.setStatus(Status.CONCATENATED.name()); + save(record); + return result(subject, record, donor, recipient, operator); + } + + @Transactional + public DonationPageResult digest(InstitutionKeySubject subject) { + DonationRecordEntity record = required(subject, Status.CONCATENATED, "请先拼接捐赠原文"); + if (record.getDonationDigest() == null) { + record.setDonationDigest(cryptography.sm3(record.getDonationMessage())); + record.setStatus(Status.DIGESTED.name()); + mapper.updateById(record); + } + return result(subject, record, donorWallet(subject), recipientWallet(subject), record.getDonorName()); + } + + @Transactional + public DonationPageResult signDonation(InstitutionKeySubject subject, String providedPrivateKey) { + DonationRecordEntity record = required(subject, Status.DIGESTED, "请先生成捐赠摘要"); + String privateKey = keyService.getOrCreatePrivateKey(subject, DONOR_KEY_TYPE, record.getDonorName()); + validatePrivateKey(subject, privateKey, providedPrivateKey, "企业法人私钥不正确,错误次数+1"); + if (record.getDonorSignature() == null) { + record.setDonorSignature(cryptography.sign(privateKey, record.getDonationDigest())); + record.setStatus(Status.SIGNED.name()); + mapper.updateById(record); + } + return result(subject, record, donorWallet(subject), recipientWallet(subject), record.getDonorName()); + } + + @Transactional + public DonationPageResult outputDonation(InstitutionKeySubject subject) { + DonationRecordEntity record = required(subject, Status.SIGNED, "请先完成企业法人SM2签名"); + if (record.getDonationOutputJson() == null) { + Map output = new LinkedHashMap<>(); + output.put("donationId", record.getDonationId()); + output.put("corpWalletId", record.getDonorWalletId()); + output.put("orgWalletId", record.getRecipientWalletId()); + output.put("amount", money(record.getAmount())); + output.put("project", record.getProjectName()); + output.put("memo", record.getMemo()); + output.put("digest", record.getDonationDigest()); + output.put("corpSignature", record.getDonorSignature()); + output.put("timestamp", record.getDonationTimestamp()); + output.put("status", "已提交"); + record.setDonationOutputJson(json(output)); + record.setStatus(Status.SUBMITTED.name()); + mapper.updateById(record); + scoreService.markStepCompleted(subject.getUserId(), 6); + } + return result(subject, record, donorWallet(subject), recipientWallet(subject), record.getDonorName()); + } + + @Transactional + public DonationPageResult receiptDetails(InstitutionKeySubject subject) { + DonationRecordEntity record = required(subject, Status.SUBMITTED, "请先由捐赠企业提交捐赠报文"); + if (status(record).ordinal() < Status.RECEIVED.ordinal()) { + record.setStatus(Status.RECEIVED.name()); + mapper.updateById(record); + } + return result(subject, record, donorWallet(subject), recipientWallet(subject), record.getRecipientName()); + } + + @Transactional + public DonationPageResult extractPublication(InstitutionKeySubject subject) { + DonationRecordEntity record = required(subject, Status.RECEIVED, "请先查看捐款明细"); + if (record.getPublicationMessage() == null) { + String timestamp = now(); + String title = publicationTitle(record, timestamp); + record.setPublicationTimestamp(timestamp); + record.setPublicationTitle(title); + record.setPublicationMessage(String.join("|", "PUBLIC", record.getRecipientWalletId(), + title, record.getDonorName(), money(record.getAmount()), + record.getProjectName(), timestamp)); + record.setStatus(Status.PUBLICATION_EXTRACTED.name()); + mapper.updateById(record); + } + return result(subject, record, donorWallet(subject), recipientWallet(subject), record.getRecipientName()); + } + + @Transactional + public DonationPageResult digestPublication(InstitutionKeySubject subject) { + DonationRecordEntity record = required(subject, Status.PUBLICATION_EXTRACTED, "请先提取公示拼接原文"); + if (record.getPublicationDigest() == null) { + record.setPublicationDigest(cryptography.sm3(record.getPublicationMessage())); + record.setStatus(Status.PUBLICATION_DIGESTED.name()); + mapper.updateById(record); + } + return result(subject, record, donorWallet(subject), recipientWallet(subject), record.getRecipientName()); + } + + @Transactional + public DonationPageResult publish(InstitutionKeySubject subject) { + DonationRecordEntity record = required(subject, Status.PUBLICATION_DIGESTED, "请先生成资金公示摘要"); + if (record.getPublicationId() == null) { + String month = record.getPublicationTimestamp().substring(0, 6); + record.setPublicationId("PUBLIC_" + record.getPublicationTimestamp().substring(0, 8) + "_001"); + record.setPublicationContent("本月收到" + record.getDonorName() + "捐赠" + moneyWithGrouping(record.getAmount()) + + "元,用于" + record.getProjectName()); + record.setPublicationUrl("https://www.sunshine.org/donate/" + month); + record.setStatus(Status.PUBLICATION_READY.name()); + mapper.updateById(record); + } + return result(subject, record, donorWallet(subject), recipientWallet(subject), record.getRecipientName()); + } + + @Transactional + public DonationPageResult outputPublication(InstitutionKeySubject subject) { + DonationRecordEntity record = required(subject, Status.PUBLICATION_READY, "请先发布资金公示"); + if (record.getPublicationOutputJson() == null) { + Map output = new LinkedHashMap<>(); + output.put("publicationId", record.getPublicationId()); + output.put("orgId", record.getRecipientWalletId()); + output.put("title", record.getPublicationTitle()); + output.put("content", record.getPublicationContent()); + output.put("url", record.getPublicationUrl()); + output.put("digest", record.getPublicationDigest()); + output.put("publishTime", record.getPublicationTimestamp()); + output.put("status", "PUBLISHED"); + record.setPublicationOutputJson(json(output)); + record.setStatus(Status.PUBLISHED.name()); + mapper.updateById(record); + scoreService.markStepCompleted(subject.getUserId(), 7); + } + return result(subject, record, donorWallet(subject), recipientWallet(subject), record.getRecipientName()); + } + + @Transactional + public DonationPageResult extractSupervision(InstitutionKeySubject subject) { + DonationRecordEntity record = required(subject, Status.PUBLISHED, "请先完成受赠机构资金公示"); + if (record.getSupervisionMessage() == null) { + String timestamp = now(); + record.setSupervisionTimestamp(timestamp); + record.setSupervisionMessage(String.join("|", "SUPERVISE", record.getDonationId(), + REGULATOR_NAME, money(record.getAmount()), record.getProjectName(), timestamp)); + record.setStatus(Status.SUPERVISION_EXTRACTED.name()); + mapper.updateById(record); + } + return result(subject, record, donorWallet(subject), recipientWallet(subject), REGULATOR_NAME); + } + + @Transactional + public DonationPageResult digestSupervision(InstitutionKeySubject subject) { + DonationRecordEntity record = required(subject, Status.SUPERVISION_EXTRACTED, "请先提取监管审核原文"); + if (record.getSupervisionDigest() == null) { + record.setSupervisionDigest(cryptography.sm3(record.getSupervisionMessage())); + record.setStatus(Status.SUPERVISION_DIGESTED.name()); + mapper.updateById(record); + } + return result(subject, record, donorWallet(subject), recipientWallet(subject), REGULATOR_NAME); + } + + @Transactional + public DonationPageResult signSupervision(InstitutionKeySubject subject, String providedPrivateKey) { + DonationRecordEntity record = required(subject, Status.SUPERVISION_DIGESTED, "请先生成监管审核摘要"); + String privateKey = keyService.getOrCreatePrivateKey(subject, REGULATOR_KEY_TYPE, REGULATOR_NAME); + validatePrivateKey(subject, privateKey, providedPrivateKey, "监管机构私钥不正确,错误次数+1"); + if (record.getRegulatorSignature() == null) { + record.setRegulatorSignature(cryptography.sign(privateKey, record.getSupervisionDigest())); + record.setStatus(Status.SUPERVISION_SIGNED.name()); + mapper.updateById(record); + } + return result(subject, record, donorWallet(subject), recipientWallet(subject), REGULATOR_NAME); + } + + @Transactional + public DonationPageResult outputSupervision(InstitutionKeySubject subject) { + DonationRecordEntity record = required(subject, Status.SUPERVISION_SIGNED, "请先完成监管机构SM2签名"); + if (record.getSupervisionOutputJson() == null) { + String date = record.getSupervisionTimestamp().substring(0, 8); + record.setApprovalId("APPROVE_" + date + "_001"); + record.setApprovalTime(record.getSupervisionTimestamp()); + record.setRecordNumber("RECORD_" + date + "_001"); + Map output = new LinkedHashMap<>(); + output.put("approvalId", record.getApprovalId()); + output.put("donationId", record.getDonationId()); + output.put("supervisor", REGULATOR_NAME); + output.put("approvalResult", "APPROVED"); + output.put("supervisorSignature", record.getRegulatorSignature()); + output.put("approvalTime", record.getApprovalTime()); + output.put("recordNumber", record.getRecordNumber()); + output.put("status", "已备案"); + record.setSupervisionOutputJson(json(output)); + record.setStatus(Status.ARCHIVED.name()); + mapper.updateById(record); + scoreService.markStepCompleted(subject.getUserId(), 8); + } + return result(subject, record, donorWallet(subject), recipientWallet(subject), REGULATOR_NAME); + } + + private DonationPageResult result(InstitutionKeySubject subject, DonationRecordEntity record, + CorporateWalletApplication donor, RemoteWalletApplication recipient, + String operator) { + String donorName = record != null ? record.getDonorName() : donor == null ? null : donor.getCorpName(); + String recipientName = record != null ? record.getRecipientName() : recipient == null ? null : recipient.getCorpName(); + String effectiveOperator = operator == null || operator.trim().isEmpty() ? "企业法人" : operator; + String donorKey = keyService.getOrCreatePrivateKey(subject, DONOR_KEY_TYPE, effectiveOperator); + String regulatorKey = keyService.getOrCreatePrivateKey(subject, REGULATOR_KEY_TYPE, REGULATOR_NAME); + return DonationPageResult.builder() + .donorName(donorName) + .donorWalletId(record != null ? record.getDonorWalletId() : donor == null ? null : donor.getWalletId()) + .recipientName(recipientName) + .recipientWalletId(record != null ? record.getRecipientWalletId() : recipient == null ? null : recipient.getWalletId()) + .amount(record == null || record.getAmount() == null ? "100000.00" : money(record.getAmount())) + .projectName(record == null ? "乡村教育支持计划" : record.getProjectName()) + .memo(record == null ? "乡村教育支持计划捐款" : record.getMemo()) + .donationTimestamp(record == null ? null : record.getDonationTimestamp()) + .donationId(record == null ? null : record.getDonationId()) + .donationMessage(record == null ? null : record.getDonationMessage()) + .donationDigest(record == null ? null : record.getDonationDigest()) + .donorPrivateKey(donorKey) + .donorSignature(record == null ? null : record.getDonorSignature()) + .donationOutputJson(record == null ? null : record.getDonationOutputJson()) + .receiptStatus(record != null && status(record).ordinal() >= Status.RECEIVED.ordinal() ? "已到账" : null) + .publicationTimestamp(record == null ? null : record.getPublicationTimestamp()) + .publicationMessage(record == null ? null : record.getPublicationMessage()) + .publicationDigest(record == null ? null : record.getPublicationDigest()) + .publicationId(record == null ? null : record.getPublicationId()) + .publicationTitle(record == null ? null : record.getPublicationTitle()) + .publicationContent(record == null ? null : record.getPublicationContent()) + .publicationUrl(record == null ? null : record.getPublicationUrl()) + .publicationOutputJson(record == null ? null : record.getPublicationOutputJson()) + .supervisionTimestamp(record == null ? null : record.getSupervisionTimestamp()) + .supervisionMessage(record == null ? null : record.getSupervisionMessage()) + .supervisionDigest(record == null ? null : record.getSupervisionDigest()) + .regulatorPrivateKey(regulatorKey) + .regulatorSignature(record == null ? null : record.getRegulatorSignature()) + .approvalId(record == null ? null : record.getApprovalId()) + .approvalTime(record == null ? null : record.getApprovalTime()) + .recordNumber(record == null ? null : record.getRecordNumber()) + .supervisionOutputJson(record == null ? null : record.getSupervisionOutputJson()) + .status(record == null ? null : record.getStatus()) + .build(); + } + + private DonationRecordEntity required(InstitutionKeySubject subject, Status minimum, String message) { + DonationRecordEntity record = findLatest(subject); + if (record == null || status(record).ordinal() < minimum.ordinal()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, message); + } + return record; + } + + private DonationRecordEntity findLatest(InstitutionKeySubject subject) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(DonationRecordEntity::getUserId, subject.getUserId()) + .eq(DonationRecordEntity::getSchoolId, subject.getSchoolId()) + .eq(DonationRecordEntity::getClassId, subject.getClassId()) + .eq(DonationRecordEntity::getDeleted, false) + .orderByDesc(DonationRecordEntity::getCreatedAt) + .orderByDesc(DonationRecordEntity::getId) + .last("LIMIT 1")); + } + + private CorporateWalletApplication donorWallet(InstitutionKeySubject subject) { + return corporateWalletRepository.findLatest(subject.getUserId(), subject.getSchoolId(), subject.getClassId()) + .orElse(null); + } + + private RemoteWalletApplication recipientWallet(InstitutionKeySubject subject) { + return remoteWalletRepository.findLatest(subject.getUserId(), subject.getSchoolId(), subject.getClassId()) + .orElse(null); + } + + private CorporateWalletApplication requiredDonorWallet(InstitutionKeySubject subject) { + CorporateWalletApplication wallet = donorWallet(subject); + if (wallet == null || wallet.getWalletId() == null || wallet.getStatus() != CorporateWalletApplication.Status.ACTIVATED) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成临柜开立对公钱包"); + } + return wallet; + } + + private RemoteWalletApplication requiredRecipientWallet(InstitutionKeySubject subject) { + RemoteWalletApplication wallet = recipientWallet(subject); + if (wallet == null || wallet.getWalletId() == null || wallet.getStatus() != RemoteWalletApplication.Status.ACTIVATED) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成远程开立受赠机构对公钱包"); + } + return wallet; + } + + private void validateWalletInputs(CorporateWalletApplication donor, RemoteWalletApplication recipient, + DonationCreateRequest request) { + if (!same(donor.getCorpName(), request.getDonorName()) + || !same(donor.getWalletId(), request.getDonorWalletId())) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "捐赠企业或付款钱包与对公钱包开立结果不一致"); + } + if (!same(recipient.getCorpName(), request.getRecipientName()) + || !same(recipient.getWalletId(), request.getRecipientWalletId())) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "受赠机构或收款钱包与远程开立结果不一致"); + } + } + + private void validatePrivateKey(InstitutionKeySubject subject, String expected, String actual, String message) { + if (!same(expected, actual)) { + scoreService.recordError(subject.getUserId()); + throw new BusinessException(ErrorCode.VALIDATION_ERROR, message); + } + } + + private void save(DonationRecordEntity record) { + if (record.getId() == null) mapper.insert(record); else mapper.updateById(record); + } + + private String publicationTitle(DonationRecordEntity record, String timestamp) { + int month = Integer.parseInt(timestamp.substring(4, 6)); + return record.getRecipientName() + timestamp.substring(0, 4) + "年" + month + "月捐赠收入公示"; + } + + private String json(Map value) { + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("公益捐赠报文序列化失败", exception); + } + } + + private Status status(DonationRecordEntity record) { + return record.getStatus() == null ? Status.DRAFT : Status.valueOf(record.getStatus()); + } + + private String now() { return LocalDateTime.now().format(TIMESTAMP_FORMAT); } + + private String money(BigDecimal amount) { return amount.setScale(2).toPlainString(); } + + private String moneyWithGrouping(BigDecimal amount) { return String.format("%,.2f", amount); } + + private boolean same(String left, String right) { + return left != null && right != null && left.trim().equalsIgnoreCase(right.trim()); + } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryScoreService.java b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryScoreService.java index 83a897e..eaf82b8 100644 --- a/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryScoreService.java +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/application/SalaryScoreService.java @@ -45,7 +45,7 @@ public class SalaryScoreService { /** * 标记步骤完成,更新进度和成绩 * @param userId 用户ID - * @param completedStep 完成的步骤编号(1-7) + * @param completedStep 完成的步骤编号(1-8) */ @Transactional(propagation = Propagation.REQUIRES_NEW) public void markStepCompleted(String userId, int completedStep) { @@ -55,10 +55,10 @@ public class SalaryScoreService { StuModuleScoreDetailsEntity detail = getOrCreate(userId); int errorCount = errorCount(detail.getCompletionStatus()); - // 模块六共7个步骤: + // 模块六共8个步骤: // 1-临柜开立, 2-远程开立, 3-数币操作员导入, 4-数币复核员审核, - // 5-交易确认与工资发放, 6-捐赠企业发起, 7-受赠机构接收 - int totalSteps = 7; + // 5-交易确认与工资发放, 6-捐赠企业发起, 7-受赠机构接收, 8-第三方监管审核 + int totalSteps = 8; int normalizedStep = Math.max(0, Math.min(totalSteps, completedStep)); // 计算进度百分比:completedStep / totalSteps * 100 diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/DonationRecordEntity.java b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/DonationRecordEntity.java new file mode 100644 index 0000000..21688b2 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/DonationRecordEntity.java @@ -0,0 +1,48 @@ +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; + +import java.math.BigDecimal; + +@Getter +@Setter +@TableName("donation_record") +public class DonationRecordEntity extends AuditableEntity { + @TableField("user_id") private String userId; + @TableField("school_id") private Long schoolId; + @TableField("class_id") private Long classId; + @TableField("donation_id") private String donationId; + @TableField("donor_name") private String donorName; + @TableField("donor_wallet_id") private String donorWalletId; + @TableField("recipient_name") private String recipientName; + @TableField("recipient_wallet_id") private String recipientWalletId; + private BigDecimal amount; + @TableField("project_name") private String projectName; + private String memo; + @TableField("donation_timestamp") private String donationTimestamp; + @TableField("donation_message") private String donationMessage; + @TableField("donation_digest") private String donationDigest; + @TableField("donor_signature") private String donorSignature; + @TableField("donation_output_json") private String donationOutputJson; + @TableField("publication_timestamp") private String publicationTimestamp; + @TableField("publication_message") private String publicationMessage; + @TableField("publication_digest") private String publicationDigest; + @TableField("publication_id") private String publicationId; + @TableField("publication_title") private String publicationTitle; + @TableField("publication_content") private String publicationContent; + @TableField("publication_url") private String publicationUrl; + @TableField("publication_output_json") private String publicationOutputJson; + @TableField("supervision_timestamp") private String supervisionTimestamp; + @TableField("supervision_message") private String supervisionMessage; + @TableField("supervision_digest") private String supervisionDigest; + @TableField("regulator_signature") private String regulatorSignature; + @TableField("approval_id") private String approvalId; + @TableField("approval_time") private String approvalTime; + @TableField("record_number") private String recordNumber; + @TableField("supervision_output_json") private String supervisionOutputJson; + private String status; +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/DonationRecordMapper.java b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/DonationRecordMapper.java new file mode 100644 index 0000000..7968516 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/infrastructure/DonationRecordMapper.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 DonationRecordMapper extends BaseMapper { +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/DonationController.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/DonationController.java new file mode 100644 index 0000000..f9fb14e --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/DonationController.java @@ -0,0 +1,103 @@ +package com.yau.digitalrmb.corporatewallet.interfaces.rest; + +import com.yau.digitalrmb.corporatewallet.application.DonationPageResult; +import com.yau.digitalrmb.corporatewallet.application.DonationService; +import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.DonationCreateRequest; +import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.DonationSignRequest; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.security.context.AuthContextHolder; +import com.yau.digitalrmb.security.context.JwtUser; +import com.yau.digitalrmb.shared.api.ApiResponse; +import com.yau.digitalrmb.shared.web.TraceIdFilter; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.slf4j.MDC; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/v1/donations") +@Tag(name = "模块六-3-企业公益捐赠", description = "捐赠企业发起、受赠机构公示、第三方监管审核三步流程") +public class DonationController { + @Resource private DonationService donationService; + + @GetMapping("/page") + @Operation(summary = "查询公益捐赠三步流程页面数据") + public ApiResponse page() { + JwtUser user = AuthContextHolder.get(); + return success(donationService.page(InstitutionKeySubject.from(user), user.getUsername())); + } + + @PostMapping("/concatenate") + @Operation(summary = "拼接捐赠原文") + public ApiResponse concatenate(@Valid @RequestBody DonationCreateRequest request) { + JwtUser user = AuthContextHolder.get(); + return success(donationService.concatenate(InstitutionKeySubject.from(user), user.getUsername(), request)); + } + + @PostMapping("/digest") + @Operation(summary = "生成捐赠原文SM3摘要") + public ApiResponse digest() { return success(donationService.digest(subject())); } + + @PostMapping("/sign") + @Operation(summary = "使用企业法人私钥对捐赠摘要进行SM2签名") + public ApiResponse sign(@Valid @RequestBody DonationSignRequest request) { + return success(donationService.signDonation(subject(), request.getPrivateKey())); + } + + @PostMapping("/output") + @Operation(summary = "输出并提交捐赠报文") + public ApiResponse output() { return success(donationService.outputDonation(subject())); } + + @PostMapping("/receipt/details") + @Operation(summary = "受赠机构查看到账捐赠明细") + public ApiResponse receiptDetails() { return success(donationService.receiptDetails(subject())); } + + @PostMapping("/publication/extract") + @Operation(summary = "提取资金公示拼接原文") + public ApiResponse extractPublication() { return success(donationService.extractPublication(subject())); } + + @PostMapping("/publication/digest") + @Operation(summary = "生成资金公示原文SM3摘要") + public ApiResponse digestPublication() { return success(donationService.digestPublication(subject())); } + + @PostMapping("/publication/publish") + @Operation(summary = "发布资金公示") + public ApiResponse publish() { return success(donationService.publish(subject())); } + + @PostMapping("/publication/output") + @Operation(summary = "输出资金公示报文") + public ApiResponse outputPublication() { return success(donationService.outputPublication(subject())); } + + @PostMapping("/supervision/extract") + @Operation(summary = "提取监管审核捐赠原文") + public ApiResponse extractSupervision() { return success(donationService.extractSupervision(subject())); } + + @PostMapping("/supervision/digest") + @Operation(summary = "生成监管审核原文SM3摘要") + public ApiResponse digestSupervision() { return success(donationService.digestSupervision(subject())); } + + @PostMapping("/supervision/sign") + @Operation(summary = "使用监管机构私钥对审核摘要进行SM2签名") + public ApiResponse signSupervision(@Valid @RequestBody DonationSignRequest request) { + return success(donationService.signSupervision(subject(), request.getPrivateKey())); + } + + @PostMapping("/supervision/output") + @Operation(summary = "输出监管审核备案报文") + public ApiResponse outputSupervision() { return success(donationService.outputSupervision(subject())); } + + private InstitutionKeySubject subject() { + return InstitutionKeySubject.from(AuthContextHolder.get()); + } + + private ApiResponse success(DonationPageResult result) { + return ApiResponse.success(result, MDC.get(TraceIdFilter.MDC_KEY)); + } +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/DonationCreateRequest.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/DonationCreateRequest.java new file mode 100644 index 0000000..2ddb238 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/DonationCreateRequest.java @@ -0,0 +1,25 @@ +package com.yau.digitalrmb.corporatewallet.interfaces.rest.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.Digits; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; +import java.math.BigDecimal; + +@Data +@Schema(description = "拼接捐赠原文请求") +public class DonationCreateRequest { + @NotBlank @Size(max = 200) private String donorName; + @NotBlank @Size(max = 128) private String donorWalletId; + @NotBlank @Size(max = 200) private String recipientName; + @NotBlank @Size(max = 128) private String recipientWalletId; + @NotNull @DecimalMin(value = "0.01") @Digits(integer = 18, fraction = 2) private BigDecimal amount; + @NotBlank @Size(max = 200) private String projectName; + @NotBlank @Size(max = 500) private String memo; + @NotBlank @Pattern(regexp = "\\d{14}", message = "时间戳格式应为yyyyMMddHHmmss") private String timestamp; +} diff --git a/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/DonationSignRequest.java b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/DonationSignRequest.java new file mode 100644 index 0000000..58a12be --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/corporatewallet/interfaces/rest/dto/DonationSignRequest.java @@ -0,0 +1,13 @@ +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 DonationSignRequest { + @NotBlank(message = "私钥不能为空") + private String privateKey; +} diff --git a/src/main/resources/db/manual/module_6_charity_upgrade.sql b/src/main/resources/db/manual/module_6_charity_upgrade.sql new file mode 100644 index 0000000..b96dadc --- /dev/null +++ b/src/main/resources/db/manual/module_6_charity_upgrade.sql @@ -0,0 +1,44 @@ +-- 模块六公益捐赠三步流程增量建表脚本。 +-- 已有环境升级时执行;全新环境由 schema.sql 自动创建。 +CREATE TABLE IF NOT EXISTS donation_record ( + id BIGINT PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + school_id BIGINT NOT NULL, + class_id BIGINT NOT NULL, + donation_id VARCHAR(64), + donor_name VARCHAR(200), + donor_wallet_id VARCHAR(128), + recipient_name VARCHAR(200), + recipient_wallet_id VARCHAR(128), + amount DECIMAL(20, 2), + project_name VARCHAR(200), + memo VARCHAR(500), + donation_timestamp VARCHAR(20), + donation_message TEXT, + donation_digest VARCHAR(128), + donor_signature TEXT, + donation_output_json TEXT, + publication_timestamp VARCHAR(20), + publication_message TEXT, + publication_digest VARCHAR(128), + publication_id VARCHAR(64), + publication_title VARCHAR(300), + publication_content VARCHAR(1000), + publication_url VARCHAR(500), + publication_output_json TEXT, + supervision_timestamp VARCHAR(20), + supervision_message TEXT, + supervision_digest VARCHAR(128), + regulator_signature TEXT, + approval_id VARCHAR(64), + approval_time VARCHAR(20), + record_number VARCHAR(64), + supervision_output_json TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + updated_by VARCHAR(64) NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + INDEX idx_donation_subject (user_id, school_id, class_id, deleted, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-公益捐赠记录表'; diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 0e0d2d9..a02f362 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1269,6 +1269,50 @@ CREATE TABLE IF NOT EXISTS salary_batch ( deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-工资发放批次表'; +-- 模块六:企业使用数字人民币进行公益捐赠 - 发起、公示、监管审核全流程 +CREATE TABLE IF NOT EXISTS donation_record ( + 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', + donation_id VARCHAR(64) COMMENT '捐赠编号', + donor_name VARCHAR(200) COMMENT '捐赠企业', + donor_wallet_id VARCHAR(128) COMMENT '付款钱包ID', + recipient_name VARCHAR(200) COMMENT '受赠机构', + recipient_wallet_id VARCHAR(128) COMMENT '收款钱包ID', + amount DECIMAL(20, 2) COMMENT '捐赠金额', + project_name VARCHAR(200) COMMENT '项目名称', + memo VARCHAR(500) COMMENT '捐赠备注', + donation_timestamp VARCHAR(20) COMMENT '捐赠时间戳', + donation_message TEXT COMMENT '捐赠拼接原文', + donation_digest VARCHAR(128) COMMENT '捐赠SM3摘要', + donor_signature TEXT COMMENT '企业法人SM2签名', + donation_output_json TEXT COMMENT '捐赠提交报文', + publication_timestamp VARCHAR(20) COMMENT '公示时间戳', + publication_message TEXT COMMENT '公示拼接原文', + publication_digest VARCHAR(128) COMMENT '公示SM3摘要', + publication_id VARCHAR(64) COMMENT '公示编号', + publication_title VARCHAR(300) COMMENT '公示标题', + publication_content VARCHAR(1000) COMMENT '公示内容', + publication_url VARCHAR(500) COMMENT '公示网址', + publication_output_json TEXT COMMENT '资金公示报文', + supervision_timestamp VARCHAR(20) COMMENT '监管审核时间戳', + supervision_message TEXT COMMENT '监管审核原文', + supervision_digest VARCHAR(128) COMMENT '监管审核SM3摘要', + regulator_signature TEXT COMMENT '监管机构SM2签名', + approval_id VARCHAR(64) COMMENT '审批编号', + approval_time VARCHAR(20) COMMENT '审批时间', + record_number VARCHAR(64) COMMENT '备案编号', + supervision_output_json TEXT COMMENT '监管审核备案报文', + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '流程状态', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + updated_by VARCHAR(64) NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + INDEX idx_donation_subject (user_id, school_id, class_id, deleted, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-公益捐赠记录表'; + -- 步骤四商业银行端:生成合约信息与开通确认 CREATE TABLE IF NOT EXISTS smart_contract_generation ( id BIGINT PRIMARY KEY, diff --git a/src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java b/src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java index 029dbda..e601cbc 100644 --- a/src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java @@ -1,6 +1,8 @@ package com.yau.digitalrmb.corporatewallet.application; import com.yau.digitalrmb.corporatewallet.infrastructure.CorporateWalletApplicationMapper; +import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordEntity; +import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordMapper; import com.yau.digitalrmb.corporatewallet.infrastructure.CorporateWalletApplicationEntity; import com.yau.digitalrmb.corporatewallet.infrastructure.RemoteWalletApplicationEntity; import com.yau.digitalrmb.corporatewallet.infrastructure.RemoteWalletApplicationMapper; @@ -33,6 +35,7 @@ class CorporateInnovationTrainingResetServiceTest { @Mock private CorporateWalletApplicationMapper corporateWalletMapper; @Mock private RemoteWalletApplicationMapper remoteWalletMapper; @Mock private SalaryBatchMapper salaryBatchMapper; + @Mock private DonationRecordMapper donationRecordMapper; @Mock private StuModuleScoreDetailsMapper scoreDetailsMapper; @Mock private UserStepProgressNewService stepProgressService; @InjectMocks private CorporateInnovationTrainingResetService service; @@ -43,6 +46,7 @@ class CorporateInnovationTrainingResetServiceTest { TableInfoHelper.initTableInfo(assistant, CorporateWalletApplicationEntity.class); TableInfoHelper.initTableInfo(assistant, RemoteWalletApplicationEntity.class); TableInfoHelper.initTableInfo(assistant, SalaryBatchEntity.class); + TableInfoHelper.initTableInfo(assistant, DonationRecordEntity.class); TableInfoHelper.initTableInfo(assistant, StuModuleScoreDetailsEntity.class); } @@ -51,12 +55,13 @@ class CorporateInnovationTrainingResetServiceTest { when(corporateWalletMapper.update(any(), any())).thenReturn(2); when(remoteWalletMapper.update(any(), any())).thenReturn(3); when(salaryBatchMapper.update(any(), any())).thenReturn(4); + when(donationRecordMapper.update(any(), any())).thenReturn(5); when(scoreDetailsMapper.update(any(), any())).thenReturn(1); CorporateInnovationTrainingResetResult result = service.reset(SUBJECT); assertThat(result.isReset()).isTrue(); - assertThat(result.getDeletedRecordCount()).isEqualTo(10); + assertThat(result.getDeletedRecordCount()).isEqualTo(15); verify(stepProgressService).reset("reset-user", "innovation-scene"); } } diff --git a/src/test/java/com/yau/digitalrmb/corporatewallet/application/DonationServiceTest.java b/src/test/java/com/yau/digitalrmb/corporatewallet/application/DonationServiceTest.java new file mode 100644 index 0000000..f486282 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/corporatewallet/application/DonationServiceTest.java @@ -0,0 +1,119 @@ +package com.yau.digitalrmb.corporatewallet.application; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +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.DonationRecordEntity; +import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordMapper; +import com.yau.digitalrmb.corporatewallet.interfaces.rest.dto.DonationCreateRequest; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DonationServiceTest { + private static final InstitutionKeySubject SUBJECT = new InstitutionKeySubject("u-1", 1L, 2L); + + @Mock private DonationRecordMapper mapper; + @Mock private CorporateWalletApplicationRepository corporateWalletRepository; + @Mock private RemoteWalletApplicationRepository remoteWalletRepository; + @Mock private CorporateWalletKeyService keyService; + @Mock private InstitutionIdentityCryptography cryptography; + @Mock private SalaryScoreService scoreService; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); + @InjectMocks private DonationService service; + + private final AtomicReference latest = new AtomicReference<>(); + + @BeforeAll + static void initializeMybatisLambdaMetadata() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + DonationRecordEntity.class); + } + + @BeforeEach + void setUp() { + CorporateWalletApplication donor = CorporateWalletApplication.create("u-1", 1L, 2L); + donor.fillForm("创新科技公司", "CREDIT-1", "张三", "10000000.00", "技术服务", "20260916090000"); + donor.activateWallet("CORP_DONOR", "20260916091000"); + RemoteWalletApplication recipient = RemoteWalletApplication.create("u-1", 1L, 2L); + recipient.fillForm("阳光公益基金", "CREDIT-2", "李四", "13900139000", "20260916092000"); + recipient.activateWallet("CORP_RECIPIENT", "20260916093000"); + + when(corporateWalletRepository.findLatest("u-1", 1L, 2L)).thenReturn(Optional.of(donor)); + when(remoteWalletRepository.findLatest("u-1", 1L, 2L)).thenReturn(Optional.of(recipient)); + when(mapper.selectOne(any())).thenAnswer(invocation -> latest.get()); + when(mapper.insert(any(DonationRecordEntity.class))).thenAnswer(invocation -> { + DonationRecordEntity record = invocation.getArgument(0); + record.setId(100L); + latest.set(record); + return 1; + }); + when(mapper.updateById(any(DonationRecordEntity.class))).thenAnswer(invocation -> { + latest.set(invocation.getArgument(0)); + return 1; + }); + when(keyService.getOrCreatePrivateKey(any(), anyString(), anyString())).thenAnswer(invocation -> + "监管机构私钥".equals(invocation.getArgument(1)) ? "REGULATOR_KEY" : "DONOR_KEY"); + when(cryptography.sm3(anyString())).thenAnswer(invocation -> "HASH_" + invocation.getArgument(0)); + when(cryptography.sign(anyString(), anyString())).thenAnswer(invocation -> + "SIGN_" + invocation.getArgument(1)); + } + + @Test + void completesPrototypeDonationPublicationAndSupervisionFlow() { + DonationCreateRequest request = new DonationCreateRequest(); + request.setDonorName("创新科技公司"); + request.setDonorWalletId("CORP_DONOR"); + request.setRecipientName("阳光公益基金"); + request.setRecipientWalletId("CORP_RECIPIENT"); + request.setAmount(new BigDecimal("100000.00")); + request.setProjectName("乡村教育支持计划"); + request.setMemo("乡村教育支持计划捐款"); + request.setTimestamp("20260916100000"); + + service.concatenate(SUBJECT, "张三", request); + service.digest(SUBJECT); + service.signDonation(SUBJECT, "DONOR_KEY"); + service.outputDonation(SUBJECT); + service.receiptDetails(SUBJECT); + service.extractPublication(SUBJECT); + service.digestPublication(SUBJECT); + service.publish(SUBJECT); + service.outputPublication(SUBJECT); + service.extractSupervision(SUBJECT); + service.digestSupervision(SUBJECT); + service.signSupervision(SUBJECT, "REGULATOR_KEY"); + DonationPageResult result = service.outputSupervision(SUBJECT); + + assertThat(result.getStatus()).isEqualTo("ARCHIVED"); + assertThat(result.getDonationOutputJson()).contains("DONATE_20260916_001", "已提交"); + assertThat(result.getPublicationOutputJson()).contains("PUBLISHED", "阳光公益基金"); + assertThat(result.getSupervisionOutputJson()).contains("APPROVED", "已备案"); + verify(scoreService).markStepCompleted("u-1", 6); + verify(scoreService).markStepCompleted("u-1", 7); + verify(scoreService).markStepCompleted("u-1", 8); + } +} diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index c16904b..4996196 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -883,6 +883,24 @@ CREATE TABLE IF NOT EXISTS payment_order ( FOREIGN KEY (payee_wallet_id) REFERENCES digital_wallet(wallet_id) ); +CREATE TABLE IF NOT EXISTS donation_record ( + id BIGINT PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, school_id BIGINT NOT NULL, class_id BIGINT NOT NULL, + donation_id VARCHAR(64), donor_name VARCHAR(200), donor_wallet_id VARCHAR(128), + recipient_name VARCHAR(200), recipient_wallet_id VARCHAR(128), amount DECIMAL(20,2), + project_name VARCHAR(200), memo VARCHAR(500), donation_timestamp VARCHAR(20), + donation_message CLOB, donation_digest VARCHAR(128), donor_signature CLOB, donation_output_json CLOB, + publication_timestamp VARCHAR(20), publication_message CLOB, publication_digest VARCHAR(128), + publication_id VARCHAR(64), publication_title VARCHAR(300), publication_content VARCHAR(1000), + publication_url VARCHAR(500), publication_output_json CLOB, + supervision_timestamp VARCHAR(20), supervision_message CLOB, supervision_digest VARCHAR(128), + regulator_signature CLOB, approval_id VARCHAR(64), approval_time VARCHAR(20), + record_number VARCHAR(64), supervision_output_json CLOB, status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, updated_by VARCHAR(64) NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + CREATE TABLE IF NOT EXISTS training_task_step_progress ( user_id VARCHAR(36) NOT NULL, module_code VARCHAR(64) NOT NULL, From 93bed6a2a4a3c36ea06d48e529d92ff0be02016b Mon Sep 17 00:00:00 2001 From: chenyuan Date: Thu, 17 Sep 2026 17:22:54 +0800 Subject: [PATCH 7/8] fix(wallet): retire unused stale wallet after module 3 retraining MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module 3 retraining projects a new wallet_id/signature via loadCompletedSource(), but ensureWallet() only ever validated the existing digital_wallet row against it and threw a conflict when the stale row (from before retraining) didn't match. If that stale wallet was never actually used downstream (zero balance/frozen amount, no exchange/payment/ledger history), retire it and its dependent certificate/contract/binding rows so the fresh module 3 result can be projected instead of surfacing '共享钱包与模块三最终结果不一致'. Wallets that were actually used still correctly conflict. --- .../WalletPrerequisiteProjectionService.java | 41 ++++++++++++++-- ...lletPrerequisiteProjectionServiceTest.java | 49 +++++++++++++++++++ 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java index f48a933..706867a 100644 --- a/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java +++ b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java @@ -192,15 +192,23 @@ public class WalletPrerequisiteProjectionService { private void ensureWallet(SourceWallet source) { List existing = jdbc.query( - "SELECT wallet_id,user_id,wallet_type,central_bank_confirmation_signature " + + "SELECT wallet_id,user_id,wallet_type,central_bank_confirmation_signature,balance,frozen_amount " + "FROM digital_wallet WHERE wallet_id=? OR user_id=?", (rs, row) -> new Wallet(rs.getString("wallet_id"), rs.getString("user_id"), - rs.getString("wallet_type"), rs.getString("central_bank_confirmation_signature")), + rs.getString("wallet_type"), rs.getString("central_bank_confirmation_signature"), + rs.getBigDecimal("balance"), rs.getBigDecimal("frozen_amount")), source.walletId, source.userId); if (!existing.isEmpty()) { if (existing.size() != 1) throw conflict("共享钱包标识或用户归属冲突"); - validateWallet(existing.get(0), source); - return; + Wallet value = existing.get(0); + if (!isRetrainedUnusedWallet(value, source)) { + validateWallet(value, source); + return; + } + // Module 3 was redone after this wallet was first projected here, and the stale wallet + // was never actually used downstream, so retire it and let the fresh module 3 result + // through instead of surfacing a spurious "共享钱包与模块三最终结果不一致" conflict. + retireUnusedWalletChain(value.walletId); } try { jdbc.update("INSERT INTO digital_wallet " + @@ -213,6 +221,25 @@ public class WalletPrerequisiteProjectionService { } } + private boolean isRetrainedUnusedWallet(Wallet value, SourceWallet source) { + if (!source.userId.equals(value.userId) || source.walletId.equals(value.walletId)) return false; + if (value.balance.signum() != 0 || value.frozenAmount.signum() != 0) return false; + Integer usageCount = jdbc.queryForObject( + "SELECT (SELECT COUNT(*) FROM currency_exchange_order WHERE wallet_id=?) + " + + "(SELECT COUNT(*) FROM payment_order WHERE payer_wallet_id=? OR payee_wallet_id=?) + " + + "(SELECT COUNT(*) FROM wallet_balance_ledger WHERE wallet_id=?) + " + + "(SELECT COUNT(*) FROM payment_wallet_ledger WHERE wallet_id=?)", + Integer.class, value.walletId, value.walletId, value.walletId, value.walletId, value.walletId); + return usageCount != null && usageCount == 0; + } + + private void retireUnusedWalletChain(String staleWalletId) { + jdbc.update("DELETE FROM wallet_bank_binding WHERE wallet_id=?", staleWalletId); + jdbc.update("DELETE FROM wallet_contract WHERE wallet_id=?", staleWalletId); + jdbc.update("DELETE FROM wallet_certificate WHERE wallet_id=?", staleWalletId); + jdbc.update("DELETE FROM digital_wallet WHERE wallet_id=?", staleWalletId); + } + private void ensureCertificate(SourceWallet source) { List existing = jdbc.query( "SELECT certificate_serial,wallet_id,public_key,central_bank_root_signature " + @@ -470,9 +497,13 @@ public class WalletPrerequisiteProjectionService { private final String userId; private final String walletType; private final String finalSignature; - private Wallet(String walletId, String userId, String walletType, String finalSignature) { + private final BigDecimal balance; + private final BigDecimal frozenAmount; + private Wallet(String walletId, String userId, String walletType, String finalSignature, + BigDecimal balance, BigDecimal frozenAmount) { this.walletId = walletId; this.userId = userId; this.walletType = walletType; this.finalSignature = finalSignature; + this.balance = balance; this.frozenAmount = frozenAmount; } } diff --git a/src/test/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionServiceTest.java b/src/test/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionServiceTest.java index e56d428..7fdf339 100644 --- a/src/test/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionServiceTest.java @@ -250,6 +250,55 @@ class WalletPrerequisiteProjectionServiceTest { return jdbc.queryForObject("SELECT account_id FROM simulated_bank_account WHERE user_id=?", String.class, SUBJECT_USER); } + @Test + void retrainedModuleThreeReplacesUnusedStaleWalletInsteadOfConflicting() { + seedCompleted(); + service.ensureForSubject(subject()); + + String newWalletId = "WALLET_PROJECTION_TEST_RETRAIN"; + WalletOpeningTestData.deleteWalletFacts(jdbc, SUBJECT_USER); + WalletOpeningTestData.insertCompletedWallet(jdbc, 980100L, SUBJECT_USER, SCHOOL_ID, CLASS_ID, + newWalletId, keyPair.getPrivateKey(), keyPair.getPublicKey(), "测试银行", + "6216610100001234567", new BigDecimal("50000.00")); + + WalletPrerequisiteReference reference = service.ensureForSubject(subject()); + + assertThat(reference.getWalletId()).isEqualTo(newWalletId); + assertThat(count("digital_wallet", "wallet_id", WALLET_ID)).isZero(); + assertThat(count("digital_wallet", "wallet_id", newWalletId)).isEqualTo(1); + assertThat(count("wallet_certificate", "wallet_id", newWalletId)).isEqualTo(1); + assertThat(count("wallet_contract", "wallet_id", newWalletId)).isEqualTo(1); + assertThat(count("wallet_bank_binding", "wallet_id", newWalletId)).isEqualTo(1); + jdbc.update("DELETE FROM wallet_bank_binding WHERE wallet_id=?", newWalletId); + jdbc.update("DELETE FROM wallet_contract WHERE wallet_id=?", newWalletId); + jdbc.update("DELETE FROM wallet_certificate WHERE wallet_id=?", newWalletId); + jdbc.update("DELETE FROM digital_wallet WHERE wallet_id=?", newWalletId); + } + + @Test + void retrainedModuleThreeStillConflictsWhenStaleWalletWasActuallyUsed() { + seedCompleted(); + service.ensureForSubject(subject()); + String accountId = jdbc.queryForObject( + "SELECT account_id FROM simulated_bank_account WHERE user_id=?", String.class, SUBJECT_USER); + jdbc.update("INSERT INTO currency_exchange_order (id,request_no,user_id,wallet_id,bank_account_id,bank_code," + + "organization_id,amount,currency,request_timestamp,message_text,signing_original_text,digest," + + "status,created_at,updated_at,created_by,updated_by) VALUES " + + "('00000000-0000-0000-0000-000000000880','PROJECTION_USED',?,?,?,?," + + "'ORG_EXPECTED',1,'CNY','20260904090000','TEST','TEST','TEST','CANCELLED'," + + "CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'test','test')", + SUBJECT_USER, WALLET_ID, accountId, BANK_CODE); + + String newWalletId = "WALLET_PROJECTION_TEST_RETRAIN_USED"; + WalletOpeningTestData.deleteWalletFacts(jdbc, SUBJECT_USER); + WalletOpeningTestData.insertCompletedWallet(jdbc, 980200L, SUBJECT_USER, SCHOOL_ID, CLASS_ID, + newWalletId, keyPair.getPrivateKey(), keyPair.getPublicKey(), "测试银行", + "6216610100001234567", new BigDecimal("50000.00")); + + assertThatThrownBy(() -> service.ensureForSubject(subject())) + .isInstanceOf(BusinessException.class).hasMessageContaining("共享钱包与模块三最终结果不一致"); + } + @Test void sameBankCodeFromAnotherScopeCannotSupplyOrganizationId() { seedCompleted(); From 8af6f539bf42ee657bec4cc824118ff56d043811 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Thu, 17 Sep 2026 19:28:00 +0800 Subject: [PATCH 8/8] fix(wallet): widen bank_card_number columns and cap input length Module 3 step 1 (output application) had no length limit on the bank card number fields in either the frontend input or the backend DTO. A long test value overflowed VARCHAR(30) and the MySQL update failed with 'Data truncation: Data too long for column bank_card_number', returning a 500 and blocking the whole module 3 flow (and therefore module 4/5 downstream). - widen wallet_application.bank_card_number/input_bank_card_number, bank_received_wallet_application.bank_card_number, wallet_registration_request.bank_card_number, and central_wallet_registration.bank_card_number from VARCHAR(30) to VARCHAR(64) in schema.sql (already applied directly to the running dev database via ALTER TABLE) - add @Size(max = 64) to WalletApplicationRequest's card number fields - add maxlength=64 to the bank card input on the wallet open step --- .../interfaces/rest/dto/WalletApplicationRequest.java | 3 +++ src/main/resources/schema.sql | 10 +++++----- src/test/resources/schema.sql | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/yau/digitalrmb/walletopening/interfaces/rest/dto/WalletApplicationRequest.java b/src/main/java/com/yau/digitalrmb/walletopening/interfaces/rest/dto/WalletApplicationRequest.java index d6e43a6..73fd882 100644 --- a/src/main/java/com/yau/digitalrmb/walletopening/interfaces/rest/dto/WalletApplicationRequest.java +++ b/src/main/java/com/yau/digitalrmb/walletopening/interfaces/rest/dto/WalletApplicationRequest.java @@ -5,6 +5,7 @@ import lombok.Getter; import lombok.Setter; import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; import java.math.BigDecimal; @Getter @@ -20,6 +21,7 @@ public class WalletApplicationRequest { private String inputPhone; @Schema(description = "身份证号", example = "110101199001011234") private String inputIdNumber; + @Size(max = 64, message = "银行卡号长度不能超过64位") @Schema(description = "银行卡号", example = "6216610100001234567") private String inputBankCardNumber; @Schema(description = "面签照片路径", example = "/2026/8/11/5cae82501de84bfba3693477ac55140c.pdf") @@ -28,6 +30,7 @@ public class WalletApplicationRequest { private String idNumber; @Schema(description = "开户银行", example = "中国银行北京分行") private String accountBank; + @Size(max = 64, message = "银行卡号长度不能超过64位") @Schema(description = "银行卡号(确认)", example = "6216610100001234567") private String bankCardNumber; @Schema(description = "账户余额", example = "500000.00") diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index a02f362..d810c8b 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -930,13 +930,13 @@ CREATE TABLE IF NOT EXISTS wallet_application ( id_number VARCHAR(20), phone VARCHAR(20), account_bank VARCHAR(100), - bank_card_number VARCHAR(30), + bank_card_number VARCHAR(64), account_balance DECIMAL(20, 2), selected_bank VARCHAR(100), wallet_type VARCHAR(20), input_phone VARCHAR(20), input_id_number VARCHAR(20), - input_bank_card_number VARCHAR(30), + input_bank_card_number VARCHAR(64), signature_photo_path VARCHAR(255), face_recognition_status INTEGER, application_id VARCHAR(64), @@ -964,7 +964,7 @@ CREATE TABLE IF NOT EXISTS bank_received_wallet_application ( user_name VARCHAR(100), id_number VARCHAR(20), phone VARCHAR(20), - bank_card_number VARCHAR(30), + bank_card_number VARCHAR(64), wallet_type VARCHAR(20), apply_time VARCHAR(20), created_at TIMESTAMP NOT NULL, @@ -1083,7 +1083,7 @@ CREATE TABLE IF NOT EXISTS wallet_registration_request ( wallet_id VARCHAR(128), user_name VARCHAR(100), id_number VARCHAR(20), - bank_card_number VARCHAR(30), + bank_card_number VARCHAR(64), wallet_type VARCHAR(20), org_code VARCHAR(20), cert_serial VARCHAR(64), @@ -1113,7 +1113,7 @@ CREATE TABLE IF NOT EXISTS central_wallet_registration ( wallet_id VARCHAR(128), user_name VARCHAR(100), id_number VARCHAR(20), - bank_card_number VARCHAR(30), + bank_card_number VARCHAR(64), wallet_type VARCHAR(20), org_code VARCHAR(20), cert_serial VARCHAR(64), diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index 4996196..fb43be5 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -536,9 +536,9 @@ CREATE TABLE IF NOT EXISTS wallet_application ( phone VARCHAR(20), input_phone VARCHAR(20), input_id_number VARCHAR(20), - input_bank_card_number VARCHAR(30), + input_bank_card_number VARCHAR(64), account_bank VARCHAR(100), - bank_card_number VARCHAR(30), + bank_card_number VARCHAR(64), account_balance DECIMAL(20,2), selected_bank VARCHAR(100), wallet_type VARCHAR(20),