Merge remote-tracking branch 'origin/master'

chore/issuance-comments-zh
赵佳正 3 weeks ago
commit 655d96ff26

@ -0,0 +1,64 @@
# 模块 2、4、5 用户输入错误评分实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**目标:** 为发行、兑换、支付三个可恢复实训模块补齐与模块一一致的用户输入错误记录和评分扣分。
**架构:** 每个模块新增独立成绩服务,复用 `stu_module_score_details` 表;动作服务只在用户输入校验路径捕获并以 `REQUIRES_NEW` 记录错误,顺序门禁和系统异常继续原样返回。前端不新增本地评分逻辑。
**技术栈:** Java 8、Spring Boot 2.7、MyBatis-Plus、JUnit 5、Mockito;前端 Vue 3/Vite 仅做构建回归验证。
---
### 任务 1:先写三个模块成绩服务的失败测试
**文件:**
- 新建:`src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceScoreServiceTest.java`
- 新建:`src/test/java/com/yau/digitalrmb/exchange/application/service/ExchangeScoreServiceTest.java`
- 新建:`src/test/java/com/yau/digitalrmb/payment/application/service/PaymentScoreServiceTest.java`
- [ ] **步骤 1:** 用 Mockito 构造 mapper 和成绩实体,分别断言首次错误创建明细、重复错误累加、完成步骤后得分按进度折算、空用户不写入。
- [ ] **步骤 2:** 运行三个测试,确认在成绩服务不存在时按预期失败。
### 任务 2:实现三个模块成绩服务
**文件:**
- 新建:`src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceScoreService.java`
- 新建:`src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeScoreService.java`
- 新建:`src/main/java/com/yau/digitalrmb/payment/application/service/PaymentScoreService.java`
- [ ] **步骤 1:** 按模块名称、步骤总数和模块序号创建成绩明细,默认每次错误扣 0.50 分。
- [ ] **步骤 2:** 为 `recordError` 标注 `REQUIRES_NEW`,实现错误数、进度和得分更新。
- [ ] **步骤 3:** 实现 `markStepCompleted` 和 `detail`,保持错误数不被进度更新覆盖。
- [ ] **步骤 4:** 运行任务 1 测试,确认全部通过。
### 任务 3:接入发行动作的输入错误计分
**文件:**
- 修改:`src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java`
- 新建:`src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionServiceScoringTest.java`
- [ ] **步骤 1:** 测试发行申请金额/面额校验失败调用 `recordError`,越级动作不调用。
- [ ] **步骤 2:** 在动作工作函数中捕获仅属于用户输入的 `BusinessException`,记录错误序号后保留原业务消息。
- [ ] **步骤 3:** 运行发行动作测试和既有发行测试。
### 任务 4:接入兑换和支付动作的输入错误计分
**文件:**
- 修改:`src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeTrainingActionService.java`
- 修改:`src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java`
- 新建:对应动作服务评分测试文件。
- [ ] **步骤 1:** 分别测试钱包/银行卡/金额和付款钱包/收款钱包/金额/备注错误会计分。
- [ ] **步骤 2:** 确认顺序门禁和缺少业务上下文不会计分。
- [ ] **步骤 3:** 运行后端完整测试。
### 任务 5:回归前端错误展示和构建
**文件:**
- 检查:`ecny-lab-web/src/views/training/steps/IssueCurrency/**`
- 检查:`ecny-lab-web/src/views/training/steps/CurrencyExchange/**`
- 检查:`ecny-lab-web/src/views/training/steps/CurrencyPayment/**`
- [ ] **步骤 1:** 确认三个模块按钮继续调用动作链并展示后端业务错误。
- [ ] **步骤 2:** 运行 `yarn build:prod`,确认无编译错误。

@ -0,0 +1,58 @@
# 模块 2、4、5 用户输入错误记录与评分设计
## 目标
让发行货币(模块 2)、数字货币兑换(模块 4)和数字货币支付(模块 5)沿用模块一的评分口径:用户提交的业务输入不正确时,后端持久化一次错误并扣分;越级操作、重复点击、缺少前置数据和系统异常不计入错误。
## 依据与现状
- 模块一通过错误记录器在事务外记录错误次数,并同步成绩明细的完成进度、错误数和得分。
- 模块 2、4、5 使用可恢复实验动作链。动作失败时动作事务会回滚,因此错误计分必须使用 `REQUIRES_NEW`,不能依赖动作主事务。
- 模块 2 的用户输入集中在发行申请金额、币种、机构标识、面额明细和签名密钥;模块 4 集中在钱包、银行卡和兑换金额;模块 5 集中在付款/收款钱包、支付金额和备注。
## 设计
### 评分服务
在后端为三个模块各提供独立的成绩服务,复用统一成绩明细表 `stu_module_score_details`:
- 模块 2:模块名“发行数字货币”,序号 2,7 个步骤。
- 模块 4:模块名“数字货币兑换”,序号 4,5 个步骤。
- 模块 5:模块名“用户支付数字货币”,序号 5,6 个步骤。
每个服务提供 `recordError(userId)`、`markStepCompleted(userId, step)` 和 `detail(userId)`。错误扣分规则与模块一一致,默认每次错误扣 0.50 分,并以当前完成进度折算项目得分。
### 错误分类
动作服务只在下列用户输入校验路径调用 `recordError`:
- 模块 2:发行申请字段/面额明细非法、机构标识或银行代码不匹配、签名密钥不正确。
- 模块 4:兑换钱包或银行账户不属于当前用户、兑换金额非法、用户签名或摘要输入校验失败。
- 模块 5:付款钱包不属于当前用户、收款钱包无效、支付金额非法、备注超过限制。
顺序门禁(“请先完成动作”)、重复动作、动作状态冲突、找不到实验、业务前置数据缺失和内部异常不计分。
### 事务边界
动作服务在识别为“用户输入错误”的 `BusinessException` 时调用对应成绩服务的 `recordError`。成绩服务使用新事务,确保即使动作主事务回滚,错误记录和成绩更新仍然保留。错误响应继续返回原有业务提示,并追加错误序号,便于用户确认本次错误已计分。
### 前端行为
模块 2、4、5 不在前端本地重复计算分数。按钮请求沿用后端动作链,错误提示直接展示后端返回的业务消息;步骤回显继续读取动作历史和本地步骤记录。这样可避免前后端对“哪些错误计分”的判断不一致。
## 测试策略
后端为三个评分服务补单元测试,覆盖:
1. 首次错误创建成绩明细、错误数为 1、得分按进度折算。
2. 重复错误累计错误数并继续扣分。
3. 完成步骤后再记录错误,完成进度保留且得分下降。
4. 空用户标识不写入成绩。
为三个动作服务补行为测试,分别验证一个用户输入错误会增加一次错误,而顺序门禁异常不会增加错误。已有动作成功流程和前端构建测试保持通过。
## 不在本次范围
- 不修改模块一既有评分口径。
- 不把所有 HTTP 400 或所有 `VALIDATION_ERROR` 自动视为计分错误。
- 不新增前端独立成绩缓存或视觉评分组件。

@ -0,0 +1,103 @@
package com.yau.digitalrmb.exchange.application.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.UUID;
/** 数字货币兑换模块的错误次数、进度和成绩统计。 */
@Service
public class ExchangeScoreService {
public static final String MODULE_NAME = "数字货币兑换";
private static final int MODULE_SERIAL_NUMBER = 4;
private static final int TOTAL_STEPS = 5;
private static final double TOTAL_SCORE = 100.0;
@Resource
private StuModuleScoreDetailsMapper scoreDetailsMapper;
@Value("${exchange.score.wrong-deduct:0.50}")
private BigDecimal wrongDeduct = new BigDecimal("0.50");
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int recordError(String userId) {
if (blank(userId)) return 0;
StuModuleScoreDetailsEntity detail = getOrCreate(userId);
int errors = errorCount(detail.getCompletionStatus()) + 1;
detail.setCompletionStatus(String.valueOf(errors));
detail.setScoreProject(score(errors, progress(detail.getSchedule())));
scoreDetailsMapper.updateById(detail);
return errors;
}
@Transactional
public void markStepCompleted(String userId, int completedStep) {
if (blank(userId)) return;
StuModuleScoreDetailsEntity detail = getOrCreate(userId);
int errors = errorCount(detail.getCompletionStatus());
int normalized = Math.max(0, Math.min(TOTAL_STEPS, completedStep));
double completedSchedule = BigDecimal.valueOf(normalized)
.multiply(BigDecimal.valueOf(TOTAL_SCORE))
.divide(BigDecimal.valueOf(TOTAL_STEPS), 2, RoundingMode.HALF_UP)
.doubleValue();
double schedule = Math.max(progress(detail.getSchedule()), completedSchedule);
detail.setCompletionStatus(String.valueOf(errors));
detail.setSchedule(schedule);
detail.setScoreProject(score(errors, schedule));
scoreDetailsMapper.updateById(detail);
}
@Transactional(readOnly = true)
public StuModuleScoreDetailsEntity detail(String userId) {
if (blank(userId)) return null;
return scoreDetailsMapper.selectOne(new LambdaQueryWrapper<StuModuleScoreDetailsEntity>()
.eq(StuModuleScoreDetailsEntity::getUserId, userId)
.eq(StuModuleScoreDetailsEntity::getMoudule, MODULE_NAME)
.eq(StuModuleScoreDetailsEntity::getSerialNumber, MODULE_SERIAL_NUMBER)
.last("LIMIT 1"));
}
private StuModuleScoreDetailsEntity getOrCreate(String userId) {
StuModuleScoreDetailsEntity existing = detail(userId);
if (existing != null) return existing;
StuModuleScoreDetailsEntity created = new StuModuleScoreDetailsEntity();
created.setId(UUID.randomUUID().toString());
created.setMoudule(MODULE_NAME);
created.setLearningProjects("实验实训");
created.setAssessmentItems("兑换流程错误次数");
created.setScoringCriteria(wrongDeduct.stripTrailingZeros().toPlainString());
created.setCompletionStatus("0");
created.setScoreProject(0.0);
created.setUserId(userId);
created.setTotalScore(TOTAL_SCORE);
created.setSerialNumber(MODULE_SERIAL_NUMBER);
created.setSchedule(0.0);
scoreDetailsMapper.insert(created);
return created;
}
private double score(int errors, double schedule) {
BigDecimal base = BigDecimal.valueOf(TOTAL_SCORE)
.subtract(wrongDeduct.multiply(BigDecimal.valueOf(Math.max(0, errors))))
.max(BigDecimal.ZERO);
return base.multiply(BigDecimal.valueOf(schedule))
.divide(BigDecimal.valueOf(TOTAL_SCORE), 2, RoundingMode.HALF_UP)
.doubleValue();
}
private double progress(Double schedule) { return schedule == null ? 0.0 : schedule; }
private int errorCount(String value) {
if (blank(value)) return 0;
try { return Math.max(0, Integer.parseInt(value)); }
catch (NumberFormatException ignored) { return 0; }
}
private boolean blank(String value) { return value == null || value.trim().isEmpty(); }
}

@ -11,6 +11,7 @@ import com.yau.digitalrmb.exchange.interfaces.dto.ExchangeActionRequest;
import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography;
import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException; import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.shared.exception.TrainingInputException;
import com.yau.digitalrmb.training.attempt.application.ActionOutcome; import com.yau.digitalrmb.training.attempt.application.ActionOutcome;
import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService; import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService;
import com.yau.digitalrmb.training.attempt.domain.ActionStatus; import com.yau.digitalrmb.training.attempt.domain.ActionStatus;
@ -18,6 +19,7 @@ import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView; import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView; import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@ -56,15 +58,26 @@ public class ExchangeTrainingActionService {
private final ExchangeApplicationService exchanges; private final ExchangeApplicationService exchanges;
private final InstitutionIdentityCryptography cryptography; private final InstitutionIdentityCryptography cryptography;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ExchangeScoreService scoreService;
@Autowired
public ExchangeTrainingActionService(ExperimentAttemptService attempts, public ExchangeTrainingActionService(ExperimentAttemptService attempts,
ExchangeApplicationService exchanges, ExchangeApplicationService exchanges,
InstitutionIdentityCryptography cryptography, InstitutionIdentityCryptography cryptography,
ObjectMapper objectMapper) { ObjectMapper objectMapper,
ExchangeScoreService scoreService) {
this.attempts = attempts; this.attempts = attempts;
this.exchanges = exchanges; this.exchanges = exchanges;
this.cryptography = cryptography; this.cryptography = cryptography;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.scoreService = scoreService;
}
public ExchangeTrainingActionService(ExperimentAttemptService attempts,
ExchangeApplicationService exchanges,
InstitutionIdentityCryptography cryptography,
ObjectMapper objectMapper) {
this(attempts, exchanges, cryptography, objectMapper, null);
} }
public ExperimentActionView<?> execute(UUID attemptId, String stepCode, String actionCode, public ExperimentActionView<?> execute(UUID attemptId, String stepCode, String actionCode,
@ -82,9 +95,19 @@ public class ExchangeTrainingActionService {
} }
String[] next = ACTIONS.get(Math.min(index + 1, ACTIONS.size() - 1)).split(":", 2); String[] next = ACTIONS.get(Math.min(index + 1, ACTIONS.size() - 1)).split(":", 2);
final UUID boundExchangeId = exchangeId; final UUID boundExchangeId = exchangeId;
return attempts.execute(attemptId, subject, stepCode, actionCode, ExperimentActionView<?> result = attempts.execute(attemptId, subject, stepCode, actionCode,
fingerprint(key, input, subject), Object.class, fingerprint(key, input, subject), Object.class,
() -> run(key, input, attempt, boundExchangeId, actor), next[0], next[1]); () -> {
try {
return run(key, input, attempt, boundExchangeId, actor);
} catch (TrainingInputException exception) {
if (scoreService == null) throw exception;
int errors = scoreService.recordError(subject.getUserId());
throw new TrainingInputException(exception.getMessage() + ",本次实训第" + errors + "次错误");
}
}, next[0], next[1]);
if (scoreService != null) scoreService.markStepCompleted(subject.getUserId(), stepNumber(stepCode));
return result;
} }
private ActionOutcome<Object> run(String key, ExchangeActionRequest request, private ActionOutcome<Object> run(String key, ExchangeActionRequest request,
@ -197,10 +220,10 @@ public class ExchangeTrainingActionService {
ExchangeActor actor) { ExchangeActor actor) {
ExchangeContext context = exchanges.context(actor); ExchangeContext context = exchanges.context(actor);
if (request.getWalletId() != null && !context.getWalletId().equals(request.getWalletId())) { if (request.getWalletId() != null && !context.getWalletId().equals(request.getWalletId())) {
throw validation("钱包不属于当前用户"); throw inputValidation("钱包不属于当前用户");
} }
if (request.getBankAccountId() != null && !context.getBankAccountId().equals(request.getBankAccountId())) { if (request.getBankAccountId() != null && !context.getBankAccountId().equals(request.getBankAccountId())) {
throw validation("银行账户不属于当前用户"); throw inputValidation("银行账户不属于当前用户");
} }
BigDecimal amount = money(request.getAmount()); BigDecimal amount = money(request.getAmount());
String timestamp = text(prior(attempt, "02", "generate-timestamp"), "timestamp"); String timestamp = text(prior(attempt, "02", "generate-timestamp"), "timestamp");
@ -265,7 +288,7 @@ public class ExchangeTrainingActionService {
} }
private BigDecimal money(BigDecimal value) { private BigDecimal money(BigDecimal value) {
if (value == null || value.signum() <= 0 || value.scale() > 2) throw validation("兑换金额必须大于零且最多两位小数"); if (value == null || value.signum() <= 0 || value.scale() > 2) throw inputValidation("兑换金额必须大于零且最多两位小数");
return value.setScale(2); return value.setScale(2);
} }
@ -290,4 +313,13 @@ public class ExchangeTrainingActionService {
private BusinessException validation(String message) { private BusinessException validation(String message) {
return new BusinessException(ErrorCode.VALIDATION_ERROR, message); return new BusinessException(ErrorCode.VALIDATION_ERROR, message);
} }
private TrainingInputException inputValidation(String message) {
return new TrainingInputException(message);
}
private int stepNumber(String stepCode) {
try { return Integer.parseInt(stepCode); }
catch (NumberFormatException ignored) { return 0; }
}
} }

@ -0,0 +1,105 @@
package com.yau.digitalrmb.issuance.application.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.UUID;
/** 发行数字货币模块的错误次数、进度和成绩统计。 */
@Service
public class IssuanceScoreService {
public static final String MODULE_NAME = "发行数字货币";
private static final int MODULE_SERIAL_NUMBER = 2;
private static final int TOTAL_STEPS = 7;
private static final double TOTAL_SCORE = 100.0;
@Resource
private StuModuleScoreDetailsMapper scoreDetailsMapper;
@Value("${issuance.score.wrong-deduct:0.50}")
private BigDecimal wrongDeduct = new BigDecimal("0.50");
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int recordError(String userId) {
if (blank(userId)) return 0;
StuModuleScoreDetailsEntity detail = getOrCreate(userId);
int errors = errorCount(detail.getCompletionStatus()) + 1;
detail.setCompletionStatus(String.valueOf(errors));
detail.setScoreProject(score(errors, progress(detail.getSchedule())));
scoreDetailsMapper.updateById(detail);
return errors;
}
@Transactional
public void markStepCompleted(String userId, int completedStep) {
if (blank(userId)) return;
StuModuleScoreDetailsEntity detail = getOrCreate(userId);
int errors = errorCount(detail.getCompletionStatus());
int normalized = Math.max(0, Math.min(TOTAL_STEPS, completedStep));
double completedSchedule = BigDecimal.valueOf(normalized)
.multiply(BigDecimal.valueOf(TOTAL_SCORE))
.divide(BigDecimal.valueOf(TOTAL_STEPS), 2, RoundingMode.HALF_UP)
.doubleValue();
double schedule = Math.max(progress(detail.getSchedule()), completedSchedule);
detail.setCompletionStatus(String.valueOf(errors));
detail.setSchedule(schedule);
detail.setScoreProject(score(errors, schedule));
scoreDetailsMapper.updateById(detail);
}
@Transactional(readOnly = true)
public StuModuleScoreDetailsEntity detail(String userId) {
if (blank(userId)) return null;
return scoreDetailsMapper.selectOne(new LambdaQueryWrapper<StuModuleScoreDetailsEntity>()
.eq(StuModuleScoreDetailsEntity::getUserId, userId)
.eq(StuModuleScoreDetailsEntity::getMoudule, MODULE_NAME)
.eq(StuModuleScoreDetailsEntity::getSerialNumber, MODULE_SERIAL_NUMBER)
.last("LIMIT 1"));
}
private StuModuleScoreDetailsEntity getOrCreate(String userId) {
StuModuleScoreDetailsEntity existing = detail(userId);
if (existing != null) return existing;
StuModuleScoreDetailsEntity created = new StuModuleScoreDetailsEntity();
created.setId(UUID.randomUUID().toString());
created.setMoudule(MODULE_NAME);
created.setLearningProjects("实验实训");
created.setAssessmentItems("发行流程错误次数");
created.setScoringCriteria(wrongDeduct.stripTrailingZeros().toPlainString());
created.setCompletionStatus("0");
created.setScoreProject(0.0);
created.setUserId(userId);
created.setTotalScore(TOTAL_SCORE);
created.setSerialNumber(MODULE_SERIAL_NUMBER);
created.setSchedule(0.0);
scoreDetailsMapper.insert(created);
return created;
}
private double score(int errors, double schedule) {
BigDecimal base = BigDecimal.valueOf(TOTAL_SCORE)
.subtract(wrongDeduct.multiply(BigDecimal.valueOf(Math.max(0, errors))))
.max(BigDecimal.ZERO);
return base.multiply(BigDecimal.valueOf(schedule))
.divide(BigDecimal.valueOf(TOTAL_SCORE), 2, RoundingMode.HALF_UP)
.doubleValue();
}
private double progress(Double schedule) { return schedule == null ? 0.0 : schedule; }
private int errorCount(String value) {
if (blank(value)) return 0;
try { return Math.max(0, Integer.parseInt(value)); }
catch (NumberFormatException ignored) { return 0; }
}
private boolean blank(String value) { return value == null || value.trim().isEmpty(); }
}

@ -12,6 +12,7 @@ import com.yau.digitalrmb.issuance.interfaces.dto.IssuanceActionRequest;
import com.yau.digitalrmb.issuance.interfaces.dto.DenominationItemRequest; import com.yau.digitalrmb.issuance.interfaces.dto.DenominationItemRequest;
import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException; import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.shared.exception.TrainingInputException;
import com.yau.digitalrmb.training.attempt.application.ActionOutcome; import com.yau.digitalrmb.training.attempt.application.ActionOutcome;
import com.yau.digitalrmb.training.attempt.application.ActionWork; import com.yau.digitalrmb.training.attempt.application.ActionWork;
import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService; import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService;
@ -19,6 +20,7 @@ import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView; import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView; import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@ -56,7 +58,9 @@ public class IssuanceTrainingActionService {
private final ReserveDeductionExecutionService executionService; private final ReserveDeductionExecutionService executionService;
private final DigitalCurrencyDraftProductionService productionService; private final DigitalCurrencyDraftProductionService productionService;
private final DigitalCurrencyOwnershipConfirmationService ownershipService; private final DigitalCurrencyOwnershipConfirmationService ownershipService;
private final IssuanceScoreService scoreService;
@Autowired
public IssuanceTrainingActionService(ExperimentAttemptService attempts, ObjectMapper objectMapper, public IssuanceTrainingActionService(ExperimentAttemptService attempts, ObjectMapper objectMapper,
CommercialBankIssuanceApplicationService commercialService, CommercialBankIssuanceApplicationService commercialService,
CentralBankIssuanceQueryService centralBankService, CentralBankIssuanceQueryService centralBankService,
@ -64,7 +68,8 @@ public class IssuanceTrainingActionService {
ReserveDeductionNotificationService notificationService, ReserveDeductionNotificationService notificationService,
ReserveDeductionExecutionService executionService, ReserveDeductionExecutionService executionService,
DigitalCurrencyDraftProductionService productionService, DigitalCurrencyDraftProductionService productionService,
DigitalCurrencyOwnershipConfirmationService ownershipService) { DigitalCurrencyOwnershipConfirmationService ownershipService,
IssuanceScoreService scoreService) {
this.attempts = attempts; this.attempts = attempts;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.commercialService = commercialService; this.commercialService = commercialService;
@ -74,6 +79,20 @@ public class IssuanceTrainingActionService {
this.executionService = executionService; this.executionService = executionService;
this.productionService = productionService; this.productionService = productionService;
this.ownershipService = ownershipService; this.ownershipService = ownershipService;
this.scoreService = scoreService;
}
/** 保留旧测试/集成调用方的构造方式;生产环境由 Spring 注入成绩服务。 */
public IssuanceTrainingActionService(ExperimentAttemptService attempts, ObjectMapper objectMapper,
CommercialBankIssuanceApplicationService commercialService,
CentralBankIssuanceQueryService centralBankService,
CentralBankIssuanceBusinessReviewService reviewService,
ReserveDeductionNotificationService notificationService,
ReserveDeductionExecutionService executionService,
DigitalCurrencyDraftProductionService productionService,
DigitalCurrencyOwnershipConfirmationService ownershipService) {
this(attempts, objectMapper, commercialService, centralBankService, reviewService, notificationService,
executionService, productionService, ownershipService, null);
} }
public ExperimentActionView<?> execute(UUID attemptId, String stepCode, String actionCode, public ExperimentActionView<?> execute(UUID attemptId, String stepCode, String actionCode,
@ -94,12 +113,24 @@ public class IssuanceTrainingActionService {
final String actionKey = key; final String actionKey = key;
final boolean bindSuppliedRequest = attempt.getBusinessId() == null && businessRequestId != null; final boolean bindSuppliedRequest = attempt.getBusinessId() == null && businessRequestId != null;
ActionWork<Object> work = () -> { ActionWork<Object> work = () -> {
ActionOutcome<Object> outcome = run(actionKey, input, businessRequestId, actor, keySubject); try {
return bindSuppliedRequest ActionOutcome<Object> outcome = run(actionKey, input, businessRequestId, actor, keySubject);
? ActionOutcome.completed(outcome.getOutput(), businessRequestId.toString()) : outcome; return bindSuppliedRequest
? ActionOutcome.completed(outcome.getOutput(), businessRequestId.toString()) : outcome;
} catch (TrainingInputException exception) {
throw scoredInputError(exception, subject);
} catch (BusinessException exception) {
if ("01:refresh-custom-application".equals(actionKey)
&& exception.getErrorCode() == ErrorCode.VALIDATION_ERROR) {
throw scoredInputError(exception, subject);
}
throw exception;
}
}; };
return attempts.execute(attemptId, subject, stepCode, actionCode, ExperimentActionView<?> result = attempts.execute(attemptId, subject, stepCode, actionCode,
fingerprint(key, input, requestId, subject), Object.class, work, next[0], next[1]); fingerprint(key, input, requestId, subject), Object.class, work, next[0], next[1]);
if (scoreService != null) scoreService.markStepCompleted(subject.getUserId(), stepNumber(stepCode));
return result;
} }
private ActionOutcome<Object> run(String key, IssuanceActionRequest request, UUID requestId, private ActionOutcome<Object> run(String key, IssuanceActionRequest request, UUID requestId,
@ -215,16 +246,16 @@ public class IssuanceTrainingActionService {
} }
private List<DenominationItem> denominations(List<DenominationItemRequest> values) { private List<DenominationItem> denominations(List<DenominationItemRequest> values) {
if (values == null || values.isEmpty()) throw validation("面额明细不能为空"); if (values == null || values.isEmpty()) throw inputValidation("面额明细不能为空");
List<DenominationItem> result = new ArrayList<DenominationItem>(); List<DenominationItem> result = new ArrayList<DenominationItem>();
for (DenominationItemRequest value : values) { for (DenominationItemRequest value : values) {
if (value == null || value.getDenomination() == null || value.getQuantity() == null) { if (value == null || value.getDenomination() == null || value.getQuantity() == null) {
throw validation("面额和数量不能为空"); throw inputValidation("面额和数量不能为空");
} }
try { try {
result.add(new DenominationItem(value.getDenomination(), value.getQuantity())); result.add(new DenominationItem(value.getDenomination(), value.getQuantity()));
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
throw validation(exception.getMessage()); throw inputValidation(exception.getMessage());
} }
} }
return result; return result;
@ -236,12 +267,12 @@ public class IssuanceTrainingActionService {
} }
private BigDecimal positive(BigDecimal value, String name) { private BigDecimal positive(BigDecimal value, String name) {
if (value == null || value.signum() <= 0) throw validation(name + "必须大于零"); if (value == null || value.signum() <= 0) throw inputValidation(name + "必须大于零");
return value; return value;
} }
private String required(String value, String name) { private String required(String value, String name) {
if (value == null || value.trim().isEmpty()) throw validation(name + "不能为空"); if (value == null || value.trim().isEmpty()) throw inputValidation(name + "不能为空");
return value.trim(); return value.trim();
} }
@ -266,4 +297,19 @@ public class IssuanceTrainingActionService {
private BusinessException validation(String message) { private BusinessException validation(String message) {
return new BusinessException(ErrorCode.VALIDATION_ERROR, message); return new BusinessException(ErrorCode.VALIDATION_ERROR, message);
} }
private TrainingInputException inputValidation(String message) {
return new TrainingInputException(message);
}
private TrainingInputException scoredInputError(BusinessException exception, ExperimentSubject subject) {
if (scoreService == null) return new TrainingInputException(exception.getMessage());
int errors = scoreService.recordError(subject.getUserId());
return new TrainingInputException(exception.getMessage() + ",本次实训第" + errors + "次错误");
}
private int stepNumber(String stepCode) {
try { return Integer.parseInt(stepCode); }
catch (NumberFormatException ignored) { return 0; }
}
} }

@ -0,0 +1,105 @@
package com.yau.digitalrmb.payment.application.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.UUID;
/** 用户支付数字货币模块的错误次数、进度和成绩统计。 */
@Service
public class PaymentScoreService {
public static final String MODULE_NAME = "用户支付数字货币";
private static final int MODULE_SERIAL_NUMBER = 5;
private static final int TOTAL_STEPS = 6;
private static final double TOTAL_SCORE = 100.0;
@Resource
private StuModuleScoreDetailsMapper scoreDetailsMapper;
@Value("${payment.score.wrong-deduct:0.50}")
private BigDecimal wrongDeduct = new BigDecimal("0.50");
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int recordError(String userId) {
if (blank(userId)) return 0;
StuModuleScoreDetailsEntity detail = getOrCreate(userId);
int errors = errorCount(detail.getCompletionStatus()) + 1;
detail.setCompletionStatus(String.valueOf(errors));
detail.setScoreProject(score(errors, progress(detail.getSchedule())));
scoreDetailsMapper.updateById(detail);
return errors;
}
@Transactional
public void markStepCompleted(String userId, int completedStep) {
if (blank(userId)) return;
StuModuleScoreDetailsEntity detail = getOrCreate(userId);
int errors = errorCount(detail.getCompletionStatus());
int normalized = Math.max(0, Math.min(TOTAL_STEPS, completedStep));
double completedSchedule = BigDecimal.valueOf(normalized)
.multiply(BigDecimal.valueOf(TOTAL_SCORE))
.divide(BigDecimal.valueOf(TOTAL_STEPS), 2, RoundingMode.HALF_UP)
.doubleValue();
double schedule = Math.max(progress(detail.getSchedule()), completedSchedule);
detail.setCompletionStatus(String.valueOf(errors));
detail.setSchedule(schedule);
detail.setScoreProject(score(errors, schedule));
scoreDetailsMapper.updateById(detail);
}
@Transactional(readOnly = true)
public StuModuleScoreDetailsEntity detail(String userId) {
if (blank(userId)) return null;
return scoreDetailsMapper.selectOne(new LambdaQueryWrapper<StuModuleScoreDetailsEntity>()
.eq(StuModuleScoreDetailsEntity::getUserId, userId)
.eq(StuModuleScoreDetailsEntity::getMoudule, MODULE_NAME)
.eq(StuModuleScoreDetailsEntity::getSerialNumber, MODULE_SERIAL_NUMBER)
.last("LIMIT 1"));
}
private StuModuleScoreDetailsEntity getOrCreate(String userId) {
StuModuleScoreDetailsEntity existing = detail(userId);
if (existing != null) return existing;
StuModuleScoreDetailsEntity created = new StuModuleScoreDetailsEntity();
created.setId(UUID.randomUUID().toString());
created.setMoudule(MODULE_NAME);
created.setLearningProjects("实验实训");
created.setAssessmentItems("支付流程错误次数");
created.setScoringCriteria(wrongDeduct.stripTrailingZeros().toPlainString());
created.setCompletionStatus("0");
created.setScoreProject(0.0);
created.setUserId(userId);
created.setTotalScore(TOTAL_SCORE);
created.setSerialNumber(MODULE_SERIAL_NUMBER);
created.setSchedule(0.0);
scoreDetailsMapper.insert(created);
return created;
}
private double score(int errors, double schedule) {
BigDecimal base = BigDecimal.valueOf(TOTAL_SCORE)
.subtract(wrongDeduct.multiply(BigDecimal.valueOf(Math.max(0, errors))))
.max(BigDecimal.ZERO);
return base.multiply(BigDecimal.valueOf(schedule))
.divide(BigDecimal.valueOf(TOTAL_SCORE), 2, RoundingMode.HALF_UP)
.doubleValue();
}
private double progress(Double schedule) { return schedule == null ? 0.0 : schedule; }
private int errorCount(String value) {
if (blank(value)) return 0;
try { return Math.max(0, Integer.parseInt(value)); }
catch (NumberFormatException ignored) { return 0; }
}
private boolean blank(String value) { return value == null || value.trim().isEmpty(); }
}

@ -9,6 +9,7 @@ import com.yau.digitalrmb.payment.domain.model.PaymentContext;
import com.yau.digitalrmb.payment.interfaces.dto.PaymentActionRequest; import com.yau.digitalrmb.payment.interfaces.dto.PaymentActionRequest;
import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException; import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.shared.exception.TrainingInputException;
import com.yau.digitalrmb.training.attempt.application.ActionOutcome; import com.yau.digitalrmb.training.attempt.application.ActionOutcome;
import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService; import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService;
import com.yau.digitalrmb.training.attempt.domain.ActionStatus; import com.yau.digitalrmb.training.attempt.domain.ActionStatus;
@ -16,6 +17,7 @@ import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView; import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView; import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
@ -51,13 +53,23 @@ public class PaymentTrainingActionService {
private final ExperimentAttemptService attempts; private final ExperimentAttemptService attempts;
private final PaymentApplicationService payments; private final PaymentApplicationService payments;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final PaymentScoreService scoreService;
@Autowired
public PaymentTrainingActionService(ExperimentAttemptService attempts, public PaymentTrainingActionService(ExperimentAttemptService attempts,
PaymentApplicationService payments, PaymentApplicationService payments,
ObjectMapper objectMapper) { ObjectMapper objectMapper,
PaymentScoreService scoreService) {
this.attempts = attempts; this.attempts = attempts;
this.payments = payments; this.payments = payments;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.scoreService = scoreService;
}
public PaymentTrainingActionService(ExperimentAttemptService attempts,
PaymentApplicationService payments,
ObjectMapper objectMapper) {
this(attempts, payments, objectMapper, null);
} }
public ExperimentActionView<?> execute(UUID attemptId, String stepCode, String actionCode, public ExperimentActionView<?> execute(UUID attemptId, String stepCode, String actionCode,
@ -75,9 +87,19 @@ public class PaymentTrainingActionService {
} }
String[] next = ACTIONS.get(Math.min(index + 1, ACTIONS.size() - 1)).split(":", 2); String[] next = ACTIONS.get(Math.min(index + 1, ACTIONS.size() - 1)).split(":", 2);
final UUID boundPaymentId = paymentId; final UUID boundPaymentId = paymentId;
return attempts.execute(attemptId, subject, stepCode, actionCode, ExperimentActionView<?> result = attempts.execute(attemptId, subject, stepCode, actionCode,
fingerprint(key, input, subject), Object.class, fingerprint(key, input, subject), Object.class,
() -> run(key, input, attempt, boundPaymentId, actor), next[0], next[1]); () -> {
try {
return run(key, input, attempt, boundPaymentId, actor);
} catch (TrainingInputException exception) {
if (scoreService == null) throw exception;
int errors = scoreService.recordError(subject.getUserId());
throw new TrainingInputException(exception.getMessage() + ",本次实训第" + errors + "次错误");
}
}, next[0], next[1]);
if (scoreService != null) scoreService.markStepCompleted(subject.getUserId(), stepNumber(stepCode));
return result;
} }
private ActionOutcome<Object> run(String key, PaymentActionRequest request, private ActionOutcome<Object> run(String key, PaymentActionRequest request,
@ -154,15 +176,24 @@ public class PaymentTrainingActionService {
ExperimentAttemptView attempt, ExperimentAttemptView attempt,
PaymentActor actor) { PaymentActor actor) {
String payeeWalletId = request.getPayeeWalletId(); String payeeWalletId = request.getPayeeWalletId();
PaymentContext context = payments.context(payeeWalletId == null || payeeWalletId.trim().isEmpty() PaymentContext context;
? null : payeeWalletId.trim(), actor); try {
context = payments.context(payeeWalletId == null || payeeWalletId.trim().isEmpty()
? null : payeeWalletId.trim(), actor);
} catch (BusinessException exception) {
if (exception.getErrorCode() == ErrorCode.VALIDATION_ERROR
|| exception.getErrorCode() == ErrorCode.RESOURCE_NOT_FOUND) {
throw inputValidation(exception.getMessage());
}
throw exception;
}
if (request.getPayerWalletId() != null if (request.getPayerWalletId() != null
&& !context.getPayer().getWalletId().equals(request.getPayerWalletId().trim())) { && !context.getPayer().getWalletId().equals(request.getPayerWalletId().trim())) {
throw validation("付款钱包不属于当前用户"); throw inputValidation("付款钱包不属于当前用户");
} }
BigDecimal amount = money(request.getAmount()); BigDecimal amount = money(request.getAmount());
String note = request.getNote() == null ? "" : request.getNote().trim(); String note = request.getNote() == null ? "" : request.getNote().trim();
if (note.length() > 256) throw validation("支付备注最多256个字符"); if (note.length() > 256) throw inputValidation("支付备注最多256个字符");
String timestamp = text(prior(attempt, "02", "generate-timestamp"), "timestamp"); String timestamp = text(prior(attempt, "02", "generate-timestamp"), "timestamp");
Map<String, Object> output = new LinkedHashMap<String, Object>(); Map<String, Object> output = new LinkedHashMap<String, Object>();
output.put("payerWalletId", context.getPayer().getWalletId()); output.put("payerWalletId", context.getPayer().getWalletId());
@ -232,13 +263,13 @@ public class PaymentTrainingActionService {
} }
private BigDecimal money(BigDecimal value) { private BigDecimal money(BigDecimal value) {
if (value == null) throw validation("支付金额不能为空"); if (value == null) throw inputValidation("支付金额不能为空");
try { try {
BigDecimal normalized = value.setScale(2, RoundingMode.UNNECESSARY); BigDecimal normalized = value.setScale(2, RoundingMode.UNNECESSARY);
if (normalized.signum() <= 0) throw validation("支付金额必须大于0"); if (normalized.signum() <= 0) throw inputValidation("支付金额必须大于0");
return normalized; return normalized;
} catch (ArithmeticException exception) { } catch (ArithmeticException exception) {
throw validation("支付金额最多保留两位小数"); throw inputValidation("支付金额最多保留两位小数");
} }
} }
@ -264,4 +295,13 @@ public class PaymentTrainingActionService {
private BusinessException validation(String message) { private BusinessException validation(String message) {
return new BusinessException(ErrorCode.VALIDATION_ERROR, message); return new BusinessException(ErrorCode.VALIDATION_ERROR, message);
} }
private TrainingInputException inputValidation(String message) {
return new TrainingInputException(message);
}
private int stepNumber(String stepCode) {
try { return Integer.parseInt(stepCode); }
catch (NumberFormatException ignored) { return 0; }
}
} }

@ -0,0 +1,10 @@
package com.yau.digitalrmb.shared.exception;
import com.yau.digitalrmb.shared.api.ErrorCode;
/** 用户在实训动作中提交的业务输入不合法;此类错误需要计入模块成绩。 */
public class TrainingInputException extends BusinessException {
public TrainingInputException(String message) {
super(ErrorCode.VALIDATION_ERROR, message);
}
}

@ -0,0 +1,55 @@
package com.yau.digitalrmb.exchange.application.service;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ExchangeScoreServiceTest {
@Mock private StuModuleScoreDetailsMapper mapper;
@InjectMocks private ExchangeScoreService service;
@Test
void firstErrorCreatesExchangeScoreDetail() {
when(mapper.selectOne(any())).thenReturn(null);
assertEquals(1, service.recordError("user-1"));
ArgumentCaptor<StuModuleScoreDetailsEntity> captor =
ArgumentCaptor.forClass(StuModuleScoreDetailsEntity.class);
verify(mapper).insert(captor.capture());
assertEquals("数字货币兑换", captor.getValue().getMoudule());
assertEquals("1", captor.getValue().getCompletionStatus());
assertEquals(4, captor.getValue().getSerialNumber());
}
@Test
void errorKeepsProgressWhenReducingScore() {
StuModuleScoreDetailsEntity detail = new StuModuleScoreDetailsEntity();
detail.setCompletionStatus("0");
detail.setSchedule(100.0);
when(mapper.selectOne(any())).thenReturn(detail);
assertEquals(1, service.recordError("user-1"));
assertEquals(99.5, detail.getScoreProject());
verify(mapper).updateById(detail);
}
@Test
void emptyUserDoesNotWriteScore() {
assertEquals(0, service.recordError(null));
verify(mapper, never()).selectOne(any());
}
}

@ -0,0 +1,64 @@
package com.yau.digitalrmb.issuance.application.service;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IssuanceScoreServiceTest {
@Mock
private StuModuleScoreDetailsMapper mapper;
@InjectMocks
private IssuanceScoreService service;
@Test
void firstErrorCreatesScoreDetailAndDeductsAtCurrentProgress() {
when(mapper.selectOne(any())).thenReturn(null);
assertEquals(1, service.recordError("user-1"));
ArgumentCaptor<StuModuleScoreDetailsEntity> captor =
ArgumentCaptor.forClass(StuModuleScoreDetailsEntity.class);
verify(mapper).insert(captor.capture());
StuModuleScoreDetailsEntity detail = captor.getValue();
assertEquals("发行数字货币", detail.getMoudule());
assertEquals("1", detail.getCompletionStatus());
assertEquals(2, detail.getSerialNumber());
assertEquals(0.0, detail.getScoreProject());
}
@Test
void repeatedErrorKeepsProgressAndReducesScore() {
StuModuleScoreDetailsEntity detail = new StuModuleScoreDetailsEntity();
detail.setCompletionStatus("1");
detail.setSchedule(50.0);
when(mapper.selectOne(any())).thenReturn(detail);
assertEquals(2, service.recordError("user-1"));
assertEquals("2", detail.getCompletionStatus());
assertEquals(49.5, detail.getScoreProject());
verify(mapper).updateById(detail);
}
@Test
void emptyUserDoesNotWriteScore() {
assertEquals(0, service.recordError(" "));
verify(mapper, never()).selectOne(any());
verify(mapper, never()).insert(any(StuModuleScoreDetailsEntity.class));
verify(mapper, never()).updateById(any(StuModuleScoreDetailsEntity.class));
}
}

@ -0,0 +1,49 @@
package com.yau.digitalrmb.payment.application.service;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PaymentScoreServiceTest {
@Mock private StuModuleScoreDetailsMapper mapper;
@InjectMocks private PaymentScoreService service;
@Test
void firstErrorCreatesPaymentScoreDetail() {
when(mapper.selectOne(any())).thenReturn(null);
assertEquals(1, service.recordError("user-1"));
ArgumentCaptor<StuModuleScoreDetailsEntity> captor =
ArgumentCaptor.forClass(StuModuleScoreDetailsEntity.class);
verify(mapper).insert(captor.capture());
assertEquals("用户支付数字货币", captor.getValue().getMoudule());
assertEquals("1", captor.getValue().getCompletionStatus());
assertEquals(5, captor.getValue().getSerialNumber());
}
@Test
void completedProgressIsPreservedWhenErrorIsRecorded() {
StuModuleScoreDetailsEntity detail = new StuModuleScoreDetailsEntity();
detail.setCompletionStatus("2");
detail.setSchedule(50.0);
when(mapper.selectOne(any())).thenReturn(detail);
service.recordError("user-1");
assertEquals("3", detail.getCompletionStatus());
assertEquals(48.5, detail.getScoreProject());
verify(mapper).updateById(detail);
}
}
Loading…
Cancel
Save