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] 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); + } +}