From 38a5242af066244b1035b9ce941588ea1e9fd945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=B2=85?= <907037276@qq.com> Date: Wed, 23 Sep 2026 18:00:07 +0800 Subject: [PATCH 1/3] docs: define input error scoring for modules 2 4 5 --- ...module-2-4-5-input-error-scoring-design.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-23-module-2-4-5-input-error-scoring-design.md diff --git a/docs/superpowers/specs/2026-09-23-module-2-4-5-input-error-scoring-design.md b/docs/superpowers/specs/2026-09-23-module-2-4-5-input-error-scoring-design.md new file mode 100644 index 0000000..00c62a5 --- /dev/null +++ b/docs/superpowers/specs/2026-09-23-module-2-4-5-input-error-scoring-design.md @@ -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:模块名“数字货币兑换”,序号 5,5 个步骤。 +- 模块 5:模块名“用户支付数字货币”,序号 6,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` 自动视为计分错误。 +- 不新增前端独立成绩缓存或视觉评分组件。 From 7a3c52a07d4039340f4072344296463d2b5fae68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=B2=85?= <907037276@qq.com> Date: Wed, 23 Sep 2026 18:22:32 +0800 Subject: [PATCH 2/3] docs: plan module input error scoring --- ...-4-5-input-error-scoring-implementation.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-23-module-2-4-5-input-error-scoring-implementation.md diff --git a/docs/superpowers/plans/2026-09-23-module-2-4-5-input-error-scoring-implementation.md b/docs/superpowers/plans/2026-09-23-module-2-4-5-input-error-scoring-implementation.md new file mode 100644 index 0000000..5364598 --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-module-2-4-5-input-error-scoring-implementation.md @@ -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`,确认无编译错误。 From 0fd1bbafa2102e05c4696c2b884b75b45d42e726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=B2=85?= <907037276@qq.com> Date: Wed, 23 Sep 2026 18:46:54 +0800 Subject: [PATCH 3/3] feat: score input errors in modules 2 4 5 --- ...module-2-4-5-input-error-scoring-design.md | 4 +- .../service/ExchangeScoreService.java | 103 +++++++++++++++++ .../ExchangeTrainingActionService.java | 44 +++++++- .../service/IssuanceScoreService.java | 105 ++++++++++++++++++ .../IssuanceTrainingActionService.java | 66 +++++++++-- .../service/PaymentScoreService.java | 105 ++++++++++++++++++ .../service/PaymentTrainingActionService.java | 60 ++++++++-- .../exception/TrainingInputException.java | 10 ++ .../service/ExchangeScoreServiceTest.java | 55 +++++++++ .../service/IssuanceScoreServiceTest.java | 64 +++++++++++ .../service/PaymentScoreServiceTest.java | 49 ++++++++ 11 files changed, 637 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeScoreService.java create mode 100644 src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceScoreService.java create mode 100644 src/main/java/com/yau/digitalrmb/payment/application/service/PaymentScoreService.java create mode 100644 src/main/java/com/yau/digitalrmb/shared/exception/TrainingInputException.java create mode 100644 src/test/java/com/yau/digitalrmb/exchange/application/service/ExchangeScoreServiceTest.java create mode 100644 src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceScoreServiceTest.java create mode 100644 src/test/java/com/yau/digitalrmb/payment/application/service/PaymentScoreServiceTest.java diff --git a/docs/superpowers/specs/2026-09-23-module-2-4-5-input-error-scoring-design.md b/docs/superpowers/specs/2026-09-23-module-2-4-5-input-error-scoring-design.md index 00c62a5..0b45690 100644 --- a/docs/superpowers/specs/2026-09-23-module-2-4-5-input-error-scoring-design.md +++ b/docs/superpowers/specs/2026-09-23-module-2-4-5-input-error-scoring-design.md @@ -17,8 +17,8 @@ 在后端为三个模块各提供独立的成绩服务,复用统一成绩明细表 `stu_module_score_details`: - 模块 2:模块名“发行数字货币”,序号 2,7 个步骤。 -- 模块 4:模块名“数字货币兑换”,序号 5,5 个步骤。 -- 模块 5:模块名“用户支付数字货币”,序号 6,6 个步骤。 +- 模块 4:模块名“数字货币兑换”,序号 4,5 个步骤。 +- 模块 5:模块名“用户支付数字货币”,序号 5,6 个步骤。 每个服务提供 `recordError(userId)`、`markStepCompleted(userId, step)` 和 `detail(userId)`。错误扣分规则与模块一一致,默认每次错误扣 0.50 分,并以当前完成进度折算项目得分。 diff --git a/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeScoreService.java b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeScoreService.java new file mode 100644 index 0000000..5346a75 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeScoreService.java @@ -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() + .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(); } +} diff --git a/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeTrainingActionService.java b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeTrainingActionService.java index a37fa02..2a17b00 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeTrainingActionService.java +++ b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeTrainingActionService.java @@ -11,6 +11,7 @@ import com.yau.digitalrmb.exchange.interfaces.dto.ExchangeActionRequest; import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; import com.yau.digitalrmb.shared.api.ErrorCode; 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.ExperimentAttemptService; 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.ExperimentAttemptView; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; @@ -56,15 +58,26 @@ public class ExchangeTrainingActionService { private final ExchangeApplicationService exchanges; private final InstitutionIdentityCryptography cryptography; private final ObjectMapper objectMapper; + private final ExchangeScoreService scoreService; + @Autowired public ExchangeTrainingActionService(ExperimentAttemptService attempts, ExchangeApplicationService exchanges, InstitutionIdentityCryptography cryptography, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + ExchangeScoreService scoreService) { this.attempts = attempts; this.exchanges = exchanges; this.cryptography = cryptography; 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, @@ -82,9 +95,19 @@ public class ExchangeTrainingActionService { } String[] next = ACTIONS.get(Math.min(index + 1, ACTIONS.size() - 1)).split(":", 2); 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, - () -> 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 run(String key, ExchangeActionRequest request, @@ -197,10 +220,10 @@ public class ExchangeTrainingActionService { ExchangeActor actor) { ExchangeContext context = exchanges.context(actor); if (request.getWalletId() != null && !context.getWalletId().equals(request.getWalletId())) { - throw validation("钱包不属于当前用户"); + throw inputValidation("钱包不属于当前用户"); } if (request.getBankAccountId() != null && !context.getBankAccountId().equals(request.getBankAccountId())) { - throw validation("银行账户不属于当前用户"); + throw inputValidation("银行账户不属于当前用户"); } BigDecimal amount = money(request.getAmount()); String timestamp = text(prior(attempt, "02", "generate-timestamp"), "timestamp"); @@ -265,7 +288,7 @@ public class ExchangeTrainingActionService { } 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); } @@ -290,4 +313,13 @@ public class ExchangeTrainingActionService { private BusinessException validation(String 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; } + } } diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceScoreService.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceScoreService.java new file mode 100644 index 0000000..2b5db3e --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceScoreService.java @@ -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() + .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(); } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java index a4842ba..495e305 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java @@ -12,6 +12,7 @@ import com.yau.digitalrmb.issuance.interfaces.dto.IssuanceActionRequest; import com.yau.digitalrmb.issuance.interfaces.dto.DenominationItemRequest; import com.yau.digitalrmb.shared.api.ErrorCode; 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.ActionWork; 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.ExperimentAttemptView; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; @@ -56,7 +58,9 @@ public class IssuanceTrainingActionService { private final ReserveDeductionExecutionService executionService; private final DigitalCurrencyDraftProductionService productionService; private final DigitalCurrencyOwnershipConfirmationService ownershipService; + private final IssuanceScoreService scoreService; + @Autowired public IssuanceTrainingActionService(ExperimentAttemptService attempts, ObjectMapper objectMapper, CommercialBankIssuanceApplicationService commercialService, CentralBankIssuanceQueryService centralBankService, @@ -64,7 +68,8 @@ public class IssuanceTrainingActionService { ReserveDeductionNotificationService notificationService, ReserveDeductionExecutionService executionService, DigitalCurrencyDraftProductionService productionService, - DigitalCurrencyOwnershipConfirmationService ownershipService) { + DigitalCurrencyOwnershipConfirmationService ownershipService, + IssuanceScoreService scoreService) { this.attempts = attempts; this.objectMapper = objectMapper; this.commercialService = commercialService; @@ -74,6 +79,20 @@ public class IssuanceTrainingActionService { this.executionService = executionService; this.productionService = productionService; 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, @@ -94,12 +113,24 @@ public class IssuanceTrainingActionService { final String actionKey = key; final boolean bindSuppliedRequest = attempt.getBusinessId() == null && businessRequestId != null; ActionWork work = () -> { - ActionOutcome outcome = run(actionKey, input, businessRequestId, actor, keySubject); - return bindSuppliedRequest - ? ActionOutcome.completed(outcome.getOutput(), businessRequestId.toString()) : outcome; + try { + ActionOutcome outcome = run(actionKey, input, businessRequestId, actor, keySubject); + 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]); + if (scoreService != null) scoreService.markStepCompleted(subject.getUserId(), stepNumber(stepCode)); + return result; } private ActionOutcome run(String key, IssuanceActionRequest request, UUID requestId, @@ -215,16 +246,16 @@ public class IssuanceTrainingActionService { } private List denominations(List values) { - if (values == null || values.isEmpty()) throw validation("面额明细不能为空"); + if (values == null || values.isEmpty()) throw inputValidation("面额明细不能为空"); List result = new ArrayList(); for (DenominationItemRequest value : values) { if (value == null || value.getDenomination() == null || value.getQuantity() == null) { - throw validation("面额和数量不能为空"); + throw inputValidation("面额和数量不能为空"); } try { result.add(new DenominationItem(value.getDenomination(), value.getQuantity())); } catch (RuntimeException exception) { - throw validation(exception.getMessage()); + throw inputValidation(exception.getMessage()); } } return result; @@ -236,12 +267,12 @@ public class IssuanceTrainingActionService { } 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; } 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(); } @@ -266,4 +297,19 @@ public class IssuanceTrainingActionService { private BusinessException validation(String 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; } + } } diff --git a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentScoreService.java b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentScoreService.java new file mode 100644 index 0000000..ebedf76 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentScoreService.java @@ -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() + .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(); } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java index 7e96443..4318179 100644 --- a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java +++ b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java @@ -9,6 +9,7 @@ import com.yau.digitalrmb.payment.domain.model.PaymentContext; import com.yau.digitalrmb.payment.interfaces.dto.PaymentActionRequest; import com.yau.digitalrmb.shared.api.ErrorCode; 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.ExperimentAttemptService; 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.ExperimentAttemptView; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; import java.math.BigDecimal; import java.math.RoundingMode; @@ -51,13 +53,23 @@ public class PaymentTrainingActionService { private final ExperimentAttemptService attempts; private final PaymentApplicationService payments; private final ObjectMapper objectMapper; + private final PaymentScoreService scoreService; + @Autowired public PaymentTrainingActionService(ExperimentAttemptService attempts, PaymentApplicationService payments, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + PaymentScoreService scoreService) { this.attempts = attempts; this.payments = payments; 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, @@ -75,9 +87,19 @@ public class PaymentTrainingActionService { } String[] next = ACTIONS.get(Math.min(index + 1, ACTIONS.size() - 1)).split(":", 2); 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, - () -> 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 run(String key, PaymentActionRequest request, @@ -154,15 +176,24 @@ public class PaymentTrainingActionService { ExperimentAttemptView attempt, PaymentActor actor) { String payeeWalletId = request.getPayeeWalletId(); - PaymentContext context = payments.context(payeeWalletId == null || payeeWalletId.trim().isEmpty() - ? null : payeeWalletId.trim(), actor); + PaymentContext context; + 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 && !context.getPayer().getWalletId().equals(request.getPayerWalletId().trim())) { - throw validation("付款钱包不属于当前用户"); + throw inputValidation("付款钱包不属于当前用户"); } BigDecimal amount = money(request.getAmount()); 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"); Map output = new LinkedHashMap(); output.put("payerWalletId", context.getPayer().getWalletId()); @@ -232,13 +263,13 @@ public class PaymentTrainingActionService { } private BigDecimal money(BigDecimal value) { - if (value == null) throw validation("支付金额不能为空"); + if (value == null) throw inputValidation("支付金额不能为空"); try { BigDecimal normalized = value.setScale(2, RoundingMode.UNNECESSARY); - if (normalized.signum() <= 0) throw validation("支付金额必须大于0"); + if (normalized.signum() <= 0) throw inputValidation("支付金额必须大于0"); return normalized; } catch (ArithmeticException exception) { - throw validation("支付金额最多保留两位小数"); + throw inputValidation("支付金额最多保留两位小数"); } } @@ -264,4 +295,13 @@ public class PaymentTrainingActionService { private BusinessException validation(String 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; } + } } diff --git a/src/main/java/com/yau/digitalrmb/shared/exception/TrainingInputException.java b/src/main/java/com/yau/digitalrmb/shared/exception/TrainingInputException.java new file mode 100644 index 0000000..19f0214 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/shared/exception/TrainingInputException.java @@ -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); + } +} diff --git a/src/test/java/com/yau/digitalrmb/exchange/application/service/ExchangeScoreServiceTest.java b/src/test/java/com/yau/digitalrmb/exchange/application/service/ExchangeScoreServiceTest.java new file mode 100644 index 0000000..3a7bad6 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/exchange/application/service/ExchangeScoreServiceTest.java @@ -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 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()); + } +} diff --git a/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceScoreServiceTest.java b/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceScoreServiceTest.java new file mode 100644 index 0000000..25d5bc1 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceScoreServiceTest.java @@ -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 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)); + } +} diff --git a/src/test/java/com/yau/digitalrmb/payment/application/service/PaymentScoreServiceTest.java b/src/test/java/com/yau/digitalrmb/payment/application/service/PaymentScoreServiceTest.java new file mode 100644 index 0000000..31aa73d --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/payment/application/service/PaymentScoreServiceTest.java @@ -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 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); + } +}