feat:文件上传的功能开发

chore/issuance-comments-zh
赵佳正 4 weeks ago
commit dfe131edf1

@ -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"));
}
}

@ -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 = "数字人民币创新应用场景步骤数据已逻辑删除,可从模块六第一步重新开始";
}
}

@ -0,0 +1,100 @@
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.DonationEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationMapper;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationReceiptEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationReceiptMapper;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationSupervisionEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationSupervisionMapper;
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 DonationMapper donationMapper;
@Resource private DonationReceiptMapper donationReceiptMapper;
@Resource private DonationSupervisionMapper donationSupervisionMapper;
@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 += logicalDeleteDonationRecords(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<CorporateWalletApplicationEntity>()
.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<RemoteWalletApplicationEntity>()
.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<SalaryBatchEntity>()
.eq(SalaryBatchEntity::getUserId, subject.getUserId())
.eq(SalaryBatchEntity::getSchoolId, subject.getSchoolId())
.eq(SalaryBatchEntity::getClassId, subject.getClassId())
.set(SalaryBatchEntity::getDeleted, true));
}
private int logicalDeleteDonationRecords(InstitutionKeySubject subject) {
int count = 0;
count += donationMapper.update(null, new LambdaUpdateWrapper<DonationEntity>()
.eq(DonationEntity::getUserId, subject.getUserId())
.eq(DonationEntity::getSchoolId, subject.getSchoolId())
.eq(DonationEntity::getClassId, subject.getClassId())
.set(DonationEntity::getDeleted, true));
count += donationReceiptMapper.update(null, new LambdaUpdateWrapper<DonationReceiptEntity>()
.eq(DonationReceiptEntity::getUserId, subject.getUserId())
.eq(DonationReceiptEntity::getSchoolId, subject.getSchoolId())
.eq(DonationReceiptEntity::getClassId, subject.getClassId())
.set(DonationReceiptEntity::getDeleted, true));
count += donationSupervisionMapper.update(null, new LambdaUpdateWrapper<DonationSupervisionEntity>()
.eq(DonationSupervisionEntity::getUserId, subject.getUserId())
.eq(DonationSupervisionEntity::getSchoolId, subject.getSchoolId())
.eq(DonationSupervisionEntity::getClassId, subject.getClassId())
.set(DonationSupervisionEntity::getDeleted, true));
return count;
}
private int logicalDeleteScore(String userId) {
return scoreDetailsMapper.update(null, new LambdaUpdateWrapper<StuModuleScoreDetailsEntity>()
.eq(StuModuleScoreDetailsEntity::getUserId, userId)
.eq(StuModuleScoreDetailsEntity::getMoudule, SalaryScoreService.MODULE_NAME)
.set(StuModuleScoreDetailsEntity::getDeleted, true));
}
}

@ -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;
}

@ -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,

@ -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

@ -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;
}

@ -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<DonationRecordEntity> {
}

@ -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));
}

@ -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<CorporateInnovationTrainingResetResult> reset() {
return ApiResponse.success(resetService.reset(
InstitutionKeySubject.from(AuthContextHolder.get())), traceId());
}
private String traceId() {
return MDC.get(TraceIdFilter.MDC_KEY);
}
}

@ -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;
}

@ -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<String, Object> 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<String, Object> values = new LinkedHashMap<String, Object>();
values.put("action", key);

@ -52,14 +52,17 @@ 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));
if (teachingPayeeWhitelist.isTeachingWallet(payeeWalletId)) {
return loadParticipant(teachingPayeeWhitelist.resolve(payeeWalletId));
}
return loadParticipant(walletProjection.ensureForWallet(payeeWalletId));
}
@Override

@ -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<WalletRow> 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<WalletRow> 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;

@ -192,16 +192,24 @@ public class WalletPrerequisiteProjectionService {
private void ensureWallet(SourceWallet source) {
List<Wallet> 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);
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 " +
"(wallet_id,user_id,wallet_type,status,balance,frozen_amount,central_bank_confirmation_signature," +
@ -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<WalletCertificate> 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;
}
}

@ -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")

@ -14,6 +14,9 @@ spring:
init:
mode: always
encoding: UTF-8
logging:
file:
path: ./logs
server:
port: 8789

@ -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='模块六-公益捐赠记录表';

@ -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),
@ -1269,6 +1269,7 @@ CREATE TABLE IF NOT EXISTS salary_batch (
deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-工资发放批次表';
<<<<<<< HEAD
-- 模块六:捐赠企业发起捐赠表 - 捐赠企业向受赠机构发起捐赠全流程
CREATE TABLE IF NOT EXISTS donation (
id BIGINT PRIMARY KEY COMMENT '主键ID(雪花算法生成)',
@ -1346,6 +1347,51 @@ CREATE TABLE IF NOT EXISTS donation_supervision (
updated_by VARCHAR(64) NOT NULL COMMENT '更新人',
deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-第三方监管监督与审核表';
=======
-- 模块六:企业使用数字人民币进行公益捐赠 - 发起、公示、监管审核全流程
CREATE TABLE IF NOT EXISTS 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='模块六-公益捐赠记录表';
>>>>>>> 8af6f539bf42ee657bec4cc824118ff56d043811
-- 步骤四商业银行端:生成合约信息与开通确认
CREATE TABLE IF NOT EXISTS smart_contract_generation (

@ -0,0 +1,67 @@
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;
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 DonationRecordMapper donationRecordMapper;
@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, DonationRecordEntity.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(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(15);
verify(stepProgressService).reset("reset-user", "innovation-scene");
}
}

@ -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<DonationRecordEntity> 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);
}
}

@ -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<SalaryBatch> 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");
}
}

@ -204,11 +204,71 @@ 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
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
@ -400,13 +460,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() {

@ -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();

@ -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),
@ -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,

Loading…
Cancel
Save