feat: score input errors in modules 2 4 5

chore/issuance-comments-zh
陈沅 3 weeks ago
parent 7a3c52a07d
commit 0fd1bbafa2

@ -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 分,并以当前完成进度折算项目得分。

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

@ -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.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<Object> work = () -> {
ActionOutcome<Object> outcome = run(actionKey, input, businessRequestId, actor, keySubject);
return bindSuppliedRequest
? ActionOutcome.completed(outcome.getOutput(), businessRequestId.toString()) : outcome;
try {
ActionOutcome<Object> 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<Object> run(String key, IssuanceActionRequest request, UUID requestId,
@ -215,16 +246,16 @@ public class IssuanceTrainingActionService {
}
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>();
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; }
}
}

@ -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.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<Object> 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<String, Object> output = new LinkedHashMap<String, Object>();
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; }
}
}

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