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 index 5346a75..0bfcced 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeScoreService.java +++ b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeScoreService.java @@ -3,6 +3,7 @@ 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 com.yau.digitalrmb.training.application.TrainingTaskProgressService; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -24,6 +25,9 @@ public class ExchangeScoreService { @Resource private StuModuleScoreDetailsMapper scoreDetailsMapper; + @Resource + private TrainingTaskProgressService taskProgress; + @Value("${exchange.score.wrong-deduct:0.50}") private BigDecimal wrongDeduct = new BigDecimal("0.50"); @@ -53,6 +57,18 @@ public class ExchangeScoreService { detail.setSchedule(schedule); detail.setScoreProject(score(errors, schedule)); scoreDetailsMapper.updateById(detail); + syncTaskProgress(userId, completedStep); + } + + /** + * 同步 /api/v1/training-tasks 读取的任务步骤进度。 + * 旧业务接口由拦截器写入,动作链则不经过拦截器,统一在步骤完成的这一处写入才不会漏; + * completeThrough 幂等,且与成绩更新在同一事务内。 + */ + private void syncTaskProgress(String userId, int completedStep) { + if (taskProgress == null || completedStep < 1) return; + taskProgress.completeThrough(userId, TrainingTaskProgressService.EXCHANGE, + String.format("%02d", completedStep)); } @Transactional(readOnly = true) diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionTrainingScoreService.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionTrainingScoreService.java index 727856e..cffafe9 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionTrainingScoreService.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionTrainingScoreService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper; +import com.yau.digitalrmb.training.application.TrainingTaskProgressService; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,6 +27,9 @@ public class InstitutionTrainingScoreService { @Resource private StuModuleScoreDetailsMapper scoreDetailsMapper; + @Resource + private TrainingTaskProgressService taskProgress; + @Value("${institution-training.score.wrong-deduct:0.50}") private BigDecimal wrongDeduct = new BigDecimal("0.50"); @@ -59,6 +63,20 @@ public class InstitutionTrainingScoreService { detail.setSchedule(schedule); detail.setScoreProject(score(errorCount, schedule)); scoreDetailsMapper.updateById(detail); + syncTaskProgress(userId, completedStep); + } + + /** + * 同步 /api/v1/training-tasks 读取的任务步骤进度。 + * 旧业务接口由拦截器写入,动作链则不经过拦截器,统一在步骤完成的这一处写入才不会漏; + * completeThrough 幂等,且与成绩更新在同一事务内。 + */ + private void syncTaskProgress(String userId, int completedStep) { + if (taskProgress == null || completedStep < 1) { + return; + } + taskProgress.completeThrough(userId, TrainingTaskProgressService.GENERATION, + String.format("%02d", completedStep)); } @Transactional(readOnly = true) 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 index 2b5db3e..fab2e5e 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceScoreService.java +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceScoreService.java @@ -3,6 +3,7 @@ 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 com.yau.digitalrmb.training.application.TrainingTaskProgressService; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -24,6 +25,9 @@ public class IssuanceScoreService { @Resource private StuModuleScoreDetailsMapper scoreDetailsMapper; + @Resource + private TrainingTaskProgressService taskProgress; + @Value("${issuance.score.wrong-deduct:0.50}") private BigDecimal wrongDeduct = new BigDecimal("0.50"); @@ -53,6 +57,18 @@ public class IssuanceScoreService { detail.setSchedule(schedule); detail.setScoreProject(score(errors, schedule)); scoreDetailsMapper.updateById(detail); + syncTaskProgress(userId, completedStep); + } + + /** + * 同步 /api/v1/training-tasks 读取的任务步骤进度。 + * 旧业务接口由拦截器写入,动作链则不经过拦截器,统一在步骤完成的这一处写入才不会漏; + * completeThrough 幂等,且与成绩更新在同一事务内。 + */ + private void syncTaskProgress(String userId, int completedStep) { + if (taskProgress == null || completedStep < 1) return; + taskProgress.completeThrough(userId, TrainingTaskProgressService.ISSUANCE, + String.format("%02d", completedStep)); } @Transactional(readOnly = true) 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 index ebedf76..fa38a47 100644 --- a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentScoreService.java +++ b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentScoreService.java @@ -3,6 +3,7 @@ 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 com.yau.digitalrmb.training.application.TrainingTaskProgressService; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -24,6 +25,9 @@ public class PaymentScoreService { @Resource private StuModuleScoreDetailsMapper scoreDetailsMapper; + @Resource + private TrainingTaskProgressService taskProgress; + @Value("${payment.score.wrong-deduct:0.50}") private BigDecimal wrongDeduct = new BigDecimal("0.50"); @@ -53,6 +57,18 @@ public class PaymentScoreService { detail.setSchedule(schedule); detail.setScoreProject(score(errors, schedule)); scoreDetailsMapper.updateById(detail); + syncTaskProgress(userId, completedStep); + } + + /** + * 同步 /api/v1/training-tasks 读取的任务步骤进度。 + * 旧业务接口由拦截器写入,动作链则不经过拦截器,统一在步骤完成的这一处写入才不会漏; + * completeThrough 幂等,且与成绩更新在同一事务内。 + */ + private void syncTaskProgress(String userId, int completedStep) { + if (taskProgress == null || completedStep < 1) return; + taskProgress.completeThrough(userId, TrainingTaskProgressService.PAYMENT, + String.format("%02d", completedStep)); } @Transactional(readOnly = true) diff --git a/src/main/java/com/yau/digitalrmb/progress/application/ModuleCompletionService.java b/src/main/java/com/yau/digitalrmb/progress/application/ModuleCompletionService.java index 02ad9e1..56d67d7 100644 --- a/src/main/java/com/yau/digitalrmb/progress/application/ModuleCompletionService.java +++ b/src/main/java/com/yau/digitalrmb/progress/application/ModuleCompletionService.java @@ -53,7 +53,7 @@ public class ModuleCompletionService { schedule = detail != null && detail.getSchedule() != null ? detail.getSchedule() : 0.0; } else { List completedStepCodes = jdbcTemplate.queryForList( - "SELECT step_code FROM training_task_step_progress WHERE user_id = ? AND module_code = ?", + "SELECT step_code FROM training_task_step_progress WHERE user_id = ? AND module_code = ? AND completed_at IS NOT NULL", String.class, userId, config.trainingModuleCode); int completedSteps = completedStepCodes.size(); schedule = config.totalSteps > 0 @@ -133,7 +133,7 @@ public class ModuleCompletionService { private ModuleCompletionResult getFromTrainingTaskTable(String userId, String moduleCode, ModuleConfig config) { List completedStepCodes = jdbcTemplate.queryForList( - "SELECT step_code FROM training_task_step_progress WHERE user_id = ? AND module_code = ?", + "SELECT step_code FROM training_task_step_progress WHERE user_id = ? AND module_code = ? AND completed_at IS NOT NULL", String.class, userId, config.trainingModuleCode); int completedSteps = completedStepCodes.size(); diff --git a/src/main/java/com/yau/digitalrmb/shared/api/ErrorCode.java b/src/main/java/com/yau/digitalrmb/shared/api/ErrorCode.java index 3d3c985..b297785 100644 --- a/src/main/java/com/yau/digitalrmb/shared/api/ErrorCode.java +++ b/src/main/java/com/yau/digitalrmb/shared/api/ErrorCode.java @@ -6,6 +6,8 @@ public enum ErrorCode { UNAUTHORIZED(401), FORBIDDEN(403), RESOURCE_NOT_FOUND(404), + /** 请求与资源当前状态冲突,例如已锁定的答案不允许再被覆盖。 */ + CONFLICT(409), INTERNAL_ERROR(500); private final int code; diff --git a/src/main/java/com/yau/digitalrmb/shared/web/GlobalExceptionHandler.java b/src/main/java/com/yau/digitalrmb/shared/web/GlobalExceptionHandler.java index 518d206..5b39b40 100644 --- a/src/main/java/com/yau/digitalrmb/shared/web/GlobalExceptionHandler.java +++ b/src/main/java/com/yau/digitalrmb/shared/web/GlobalExceptionHandler.java @@ -65,6 +65,8 @@ public class GlobalExceptionHandler { return HttpStatus.FORBIDDEN; case RESOURCE_NOT_FOUND: return HttpStatus.NOT_FOUND; + case CONFLICT: + return HttpStatus.CONFLICT; default: return HttpStatus.INTERNAL_SERVER_ERROR; } diff --git a/src/main/java/com/yau/digitalrmb/training/application/TrainingTaskProgressService.java b/src/main/java/com/yau/digitalrmb/training/application/TrainingTaskProgressService.java index b751890..d051590 100644 --- a/src/main/java/com/yau/digitalrmb/training/application/TrainingTaskProgressService.java +++ b/src/main/java/com/yau/digitalrmb/training/application/TrainingTaskProgressService.java @@ -1,25 +1,57 @@ package com.yau.digitalrmb.training.application; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowCallbackHandler; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.sql.Timestamp; import java.time.Instant; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +/** + * 五个实验模块统一的任务步骤进度。 + * + *

每个步骤有两类信息: + *

+ * 步骤状态:COMPLETED 已完成(已提交)、CURRENT 下一个可操作的步骤、SAVED 有未提交的草稿、 + * LOCKED 前面还有步骤未完成。草稿不计入完成数。 + * + *

防止学员事后改答案:步骤完成之前答案可以随时覆盖保存;步骤完成之后只再接受一次保存 + * (页面在最后一个业务动作完成后会自动存一次最终结果),随后答案被锁定(locked=true), + * 不同内容的保存一律返回 409,原答案保留;重新实训会清空整个模块的进度和锁定状态。 + */ @Service public class TrainingTaskProgressService { - private static final String GENERATION = "PBOC_CURRENCY_GENERATION"; - private static final String ISSUANCE = "PBOC_CURRENCY_ISSUANCE"; - private static final String WALLET = "WALLET_OPENING"; - private static final String EXCHANGE = "CURRENCY_EXCHANGE"; - private static final String PAYMENT = "CURRENCY_PAYMENT"; + // 模块编码:供各模块评分服务在步骤完成时统一写入进度 + public static final String GENERATION = "PBOC_CURRENCY_GENERATION"; + public static final String ISSUANCE = "PBOC_CURRENCY_ISSUANCE"; + public static final String WALLET = "WALLET_OPENING"; + public static final String EXCHANGE = "CURRENCY_EXCHANGE"; + public static final String PAYMENT = "CURRENCY_PAYMENT"; + + /** 单个步骤答案 JSON 的最大字符数(对应 MEDIUMTEXT 列,留足中文按 4 字节计的余量)。 */ + public static final int MAX_STEP_DATA_LENGTH = 100_000; + + // 只用于校验草稿是否为合法 JSON;开启尾部多余内容检查,避免 `{"a":1} x` 这种串被存进去 + private static final ObjectMapper JSON = new ObjectMapper() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); private final JdbcTemplate jdbc; private final Map definitions; @@ -36,24 +68,76 @@ public class TrainingTaskProgressService { } public TrainingTaskView one(String userId, String moduleCode) { - ModuleDefinition module = definitions.get(moduleCode); - if (module == null) throw new IllegalArgumentException("不支持的实训模块:" + moduleCode); - List completed = jdbc.queryForList("SELECT step_code FROM training_task_step_progress WHERE user_id = ? AND module_code = ?", - String.class, userId, moduleCode); - return view(module, completed); + ModuleDefinition module = requireModule(moduleCode); + return view(module, rows(userId, moduleCode)); + } + + /** 单个步骤的详情,含已保存的答案 JSON(没有保存过则 stepData 为空)。 */ + public TrainingStepDetailView stepDetail(String userId, String moduleCode, String stepCode) { + ModuleDefinition module = requireModule(moduleCode); + requireStep(module, stepCode); + TrainingTaskView task = view(module, rows(userId, moduleCode)); + TrainingStepView step = task.getSteps().get(module.indexOf(stepCode)); + List data = jdbc.queryForList( + "SELECT step_data FROM training_task_step_progress WHERE user_id = ? AND module_code = ? AND step_code = ?", + String.class, userId, moduleCode, stepCode); + return new TrainingStepDetailView(moduleCode, step.getStepCode(), step.getStepName(), step.getStatus(), + step.isActionAvailable(), step.isHasData(), step.getSavedAt(), step.isLocked(), + data.isEmpty() ? null : data.get(0)); + } + + /** + * 随时保存某一步的答案,重复保存覆盖上一次。 + * 只写答案,不改变完成状态:已完成的步骤保存后仍是 COMPLETED; + * 允许保存还未解锁的步骤(页面可自由浏览各步),它只显示为 SAVED,不会提前解锁后续步骤。 + * + *

锁定规则:步骤未完成时可反复覆盖;步骤完成后接受一次保存并随即锁定, + * 锁定后再提交不同的内容返回 409(CONFLICT),提交完全相同的内容视为成功(幂等,便于页面重试)。 + */ + @Transactional + public TrainingTaskView saveStep(String userId, String moduleCode, String stepCode, String stepData) { + ModuleDefinition module = requireModule(moduleCode); + requireStep(module, stepCode); + requireValidStepData(stepData); + Timestamp now = Timestamp.from(Instant.now()); + + // 行锁串行化同一步骤的并发保存,避免两个请求同时越过锁定检查 + List> existing = jdbc.queryForList( + "SELECT completed_at, sealed_at, step_data FROM training_task_step_progress " + + "WHERE user_id = ? AND module_code = ? AND step_code = ? FOR UPDATE", + userId, moduleCode, stepCode); + if (existing.isEmpty()) { + jdbc.update("INSERT INTO training_task_step_progress (user_id, module_code, step_code, step_data, saved_at) " + + "VALUES (?, ?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE step_data = VALUES(step_data), saved_at = VALUES(saved_at)", + userId, moduleCode, stepCode, stepData, now); + return one(userId, moduleCode); + } + Map row = existing.get(0); + boolean completed = row.get("completed_at") != null; + if (row.get("sealed_at") != null) { + if (sameJson(stepData, (String) row.get("step_data"))) return one(userId, moduleCode); + throw new BusinessException(ErrorCode.CONFLICT, "该步骤已完成,答案已锁定,不能再修改;如需重新作答请点击「重新实训」"); + } + // 已完成的步骤:这是最后一次允许的保存,写入同时锁定 + jdbc.update("UPDATE training_task_step_progress SET step_data = ?, saved_at = ?, sealed_at = ? " + + "WHERE user_id = ? AND module_code = ? AND step_code = ?", + stepData, now, completed ? now : null, userId, moduleCode, stepCode); + return one(userId, moduleCode); } - /** 真实业务步骤成功后调用;同步补齐此前步骤,避免历史业务数据导致进度断层。 */ + /** 真实业务步骤成功后调用;同步补齐此前步骤,避免历史业务数据导致进度断层。已有草稿的步骤保留答案并变为完成。 */ public void completeThrough(String userId, String moduleCode, String stepCode) { ModuleDefinition module = definitions.get(moduleCode); if (module == null || module.unavailable) return; int index = module.indexOf(stepCode); if (index < 0) return; - Instant now = Instant.now(); + Timestamp now = Timestamp.from(Instant.now()); for (int i = 0; i <= index; i++) { String currentStep = module.steps.get(i).code; + // 已有完成时间的保持原值;只有草稿的行补上完成时间 jdbc.update("INSERT INTO training_task_step_progress (user_id, module_code, step_code, completed_at) VALUES (?, ?, ?, ?) " - + "ON DUPLICATE KEY UPDATE completed_at = completed_at", + + "ON DUPLICATE KEY UPDATE completed_at = COALESCE(completed_at, VALUES(completed_at))", userId, moduleCode, currentStep, now); } } @@ -62,13 +146,37 @@ public class TrainingTaskProgressService { jdbc.update("DELETE FROM training_task_step_progress WHERE user_id = ? AND module_code = ?", userId, moduleCode); } - private TrainingTaskView view(ModuleDefinition module, List completed) { + /** 对外的重置入口:先校验模块编码,非法编码返回 400 而不是静默成功。 */ + @Transactional + public void resetModule(String userId, String moduleCode) { + requireModule(moduleCode); + reset(userId, moduleCode); + } + + private Map rows(String userId, String moduleCode) { + Map result = new HashMap<>(); + jdbc.query("SELECT step_code, completed_at, saved_at, sealed_at, CASE WHEN step_data IS NULL THEN 0 ELSE 1 END AS has_data " + + "FROM training_task_step_progress WHERE user_id = ? AND module_code = ?", + (RowCallbackHandler) rs -> { + Timestamp savedAt = rs.getTimestamp("saved_at"); + result.put(rs.getString("step_code"), new Row(rs.getTimestamp("completed_at") != null, + rs.getInt("has_data") == 1, savedAt == null ? null : savedAt.toInstant(), + rs.getTimestamp("sealed_at") != null)); + }, userId, moduleCode); + return result; + } + + private TrainingTaskView view(ModuleDefinition module, Map rows) { List steps = new ArrayList<>(); boolean prefixCompleted = true; int completedCount = 0; + int savedCount = 0; String currentStepCode = null; for (StepDefinition step : module.steps) { - boolean done = prefixCompleted && completed.contains(step.code); + Row row = rows.get(step.code); + boolean done = prefixCompleted && row != null && row.completed; + // 草稿 = 有答案数据但还没完成;已完成步骤重新保存不会变回草稿 + boolean draft = !done && row != null && !row.completed && row.hasData; String status; boolean actionAvailable = false; if (done) { @@ -78,18 +186,58 @@ public class TrainingTaskProgressService { status = "NOT_STARTED"; prefixCompleted = false; } else if (prefixCompleted) { - status = "CURRENT"; + status = draft ? "SAVED" : "CURRENT"; actionAvailable = true; currentStepCode = step.code; prefixCompleted = false; } else { - status = "LOCKED"; + status = draft ? "SAVED" : "LOCKED"; } - steps.add(new TrainingStepView(step.code, step.name, step.sortNo, status, actionAvailable)); + if ("SAVED".equals(status)) savedCount++; + steps.add(new TrainingStepView(step.code, step.name, step.sortNo, status, actionAvailable, + row != null && row.hasData, row == null ? null : row.savedAt, row != null && row.sealed)); + } + String status = completedCount == module.steps.size() ? "COMPLETED" + : completedCount == 0 && savedCount == 0 ? "NOT_STARTED" : "IN_PROGRESS"; + return new TrainingTaskView(module.code, module.name, status, module.steps.size(), completedCount, savedCount, + currentStepCode, module.unavailable, module.unavailableReason, steps); + } + + private ModuleDefinition requireModule(String moduleCode) { + ModuleDefinition module = definitions.get(moduleCode); + if (module == null) throw validation("不支持的实训模块:" + moduleCode); + return module; + } + + private void requireStep(ModuleDefinition module, String stepCode) { + if (module.indexOf(stepCode) < 0) throw validation("不支持的实训步骤:" + module.code + " 第 " + stepCode + " 步"); + } + + private void requireValidStepData(String stepData) { + if (stepData == null || stepData.trim().isEmpty()) throw validation("答案数据不能为空"); + if (stepData.length() > MAX_STEP_DATA_LENGTH) throw validation("答案数据过大,最多 " + MAX_STEP_DATA_LENGTH + " 个字符"); + JsonNode node; + try { + node = JSON.readTree(stepData); + } catch (JsonProcessingException exception) { + throw validation("答案数据不是合法的 JSON"); + } + if (node == null || !(node.isObject() || node.isArray())) throw validation("答案数据必须是 JSON 对象或数组"); + } + + /** 按 JSON 语义比较两份答案:忽略空白和对象键顺序,数组顺序与数值仍必须一致。 */ + private boolean sameJson(String incoming, String stored) { + if (stored == null) return false; + if (incoming.equals(stored)) return true; + try { + return JSON.readTree(incoming).equals(JSON.readTree(stored)); + } catch (JsonProcessingException exception) { + return false; } - String status = completedCount == module.steps.size() ? "COMPLETED" : completedCount == 0 ? "NOT_STARTED" : "IN_PROGRESS"; - return new TrainingTaskView(module.code, module.name, status, module.steps.size(), completedCount, currentStepCode, - module.unavailable, module.unavailableReason, steps); + } + + private BusinessException validation(String message) { + return new BusinessException(ErrorCode.VALIDATION_ERROR, message); } private Map definitions() { @@ -98,8 +246,9 @@ public class TrainingTaskProgressService { "机构身份信息与密钥准备", "生成数字货币请求", "验证货币生成请求", "生成交易信息标识", "生成额度控制位请求", "验证额度控制位请求", "控制系统签名", "生成额度控制位", "生成标准面额币串")); result.put(ISSUANCE, module(ISSUANCE, "货币发行", false, null, "查看货币需求", "生成发行申请计划", "发行计划数字签名", "发送标准报文", "中央银行验证与业务核查", "准备金通知与ACS扣减", "发行并确权数字货币")); - result.put(WALLET, module(WALLET, "开立数字钱包", true, "钱包开立真实业务接口尚未实现", - "开立数字钱包")); + // 步骤与 ModuleCompletionService / 前端 WalletOpen 的 5 个步骤一致 + result.put(WALLET, module(WALLET, "开通数字货币钱包", false, null, + "商业银行钱包开立", "钱包标识生成", "钱包注册", "智能合约签订", "央行钱包激活")); result.put(EXCHANGE, module(EXCHANGE, "货币兑换", false, null, "确认银行卡、钱包和商业银行库存", "生成取币请求报文", "钱包私钥签名", "商业银行验签并扣款", "央行确认币串权属")); result.put(PAYMENT, module(PAYMENT, "数字货币支付", false, null, @@ -113,15 +262,33 @@ public class TrainingTaskProgressService { return new ModuleDefinition(code, name, unavailable, reason, steps); } + /** 模块进度。status:NOT_STARTED 未开始 / IN_PROGRESS 进行中(有完成或草稿)/ COMPLETED 全部完成。 */ @Getter @AllArgsConstructor public static class TrainingTaskView { private final String moduleCode; private final String moduleName; private final String status; - private final int totalSteps; private final int completedSteps; private final String currentStepCode; + private final int totalSteps; private final int completedSteps; private final int savedSteps; + private final String currentStepCode; private final boolean actionUnavailable; private final String unavailableReason; private final List steps; } + /** 步骤进度。status:COMPLETED 已完成 / CURRENT 可操作 / SAVED 有草稿未完成 / LOCKED 锁定 / NOT_STARTED 模块不可用。 */ @Getter @AllArgsConstructor public static class TrainingStepView { - private final String stepCode; private final String stepName; private final int sortNo; private final String status; private final boolean actionAvailable; + private final String stepCode; private final String stepName; private final int sortNo; private final String status; + private final boolean actionAvailable; private final boolean hasData; private final Instant savedAt; + /** 答案已锁定:步骤完成后的最后一次保存已落库,之后不可再修改。 */ + private final boolean locked; + } + @Getter @AllArgsConstructor + public static class TrainingStepDetailView { + private final String moduleCode; private final String stepCode; private final String stepName; private final String status; + private final boolean actionAvailable; private final boolean hasData; private final Instant savedAt; private final boolean locked; + private final String stepData; + } + private static class Row { + private final boolean completed; private final boolean hasData; private final Instant savedAt; private final boolean sealed; + private Row(boolean completed, boolean hasData, Instant savedAt, boolean sealed) { + this.completed = completed; this.hasData = hasData; this.savedAt = savedAt; this.sealed = sealed; + } } private static class ModuleDefinition { private final String code, name; private final boolean unavailable; private final String unavailableReason; private final List steps; diff --git a/src/main/java/com/yau/digitalrmb/training/infrastructure/TrainingTaskProgressInterceptor.java b/src/main/java/com/yau/digitalrmb/training/infrastructure/TrainingTaskProgressInterceptor.java index 7a73fe1..c057ec6 100644 --- a/src/main/java/com/yau/digitalrmb/training/infrastructure/TrainingTaskProgressInterceptor.java +++ b/src/main/java/com/yau/digitalrmb/training/infrastructure/TrainingTaskProgressInterceptor.java @@ -9,7 +9,11 @@ import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -/** 将已成功返回的真实业务操作映射为对应模块任务步骤;不暴露独立的人工完成接口。 */ +/** + * 将已成功返回的真实业务操作映射为对应模块任务步骤;不暴露独立的人工完成接口。 + * 只认会改变业务数据的写操作:仅仅打开页面的读取(查询上下文、查看库存等)不能算步骤完成, + * 否则学员一进页面步骤就被当成已完成,答案也会被提前锁定。 + */ @Component public class TrainingTaskProgressInterceptor implements HandlerInterceptor { private final TrainingTaskProgressService progressService; @@ -48,7 +52,6 @@ public class TrainingTaskProgressInterceptor implements HandlerInterceptor { if ("POST".equals(method) && "/api/v1/institution-identifiers/quota-control-bits/steps/send".equals(path)) return pair("PBOC_CURRENCY_GENERATION", "08"); if ("POST".equals(method) && "/api/v1/institution-identifiers/standard-currencies/steps/generate".equals(path)) return pair("PBOC_CURRENCY_GENERATION", "09"); - if ("GET".equals(method) && "/api/v1/commercial-banks/issuance/inventory".equals(path)) return pair("PBOC_CURRENCY_ISSUANCE", "01"); if ("POST".equals(method) && path.matches("/api/v1/commercial-banks/issuance/requests/[^/]+/prepare-message")) return pair("PBOC_CURRENCY_ISSUANCE", "02"); if ("POST".equals(method) && path.matches("/api/v1/commercial-banks/issuance/requests/[^/]+/sign")) return pair("PBOC_CURRENCY_ISSUANCE", "03"); if ("POST".equals(method) && path.matches("/api/v1/commercial-banks/issuance/requests/[^/]+/send")) return pair("PBOC_CURRENCY_ISSUANCE", "04"); @@ -56,13 +59,19 @@ public class TrainingTaskProgressInterceptor implements HandlerInterceptor { if ("POST".equals(method) && (path.matches("/api/v1/central-banks/issuance/reserve-deductions/[^/]+/execute") || "/api/v1/central-banks/issuance/reserve-deductions".equals(path))) return pair("PBOC_CURRENCY_ISSUANCE", "06"); if ("POST".equals(method) && path.matches("/api/v1/central-banks/issuance/requests/[^/]+/digital-currency-ownership")) return pair("PBOC_CURRENCY_ISSUANCE", "07"); - if ("GET".equals(method) && "/api/v1/exchanges/context".equals(path)) return pair("CURRENCY_EXCHANGE", "01"); + // 模块三(开通数字货币钱包):每步取前端该步「业务完成判据」对应的最后一个写操作,只认成功的 POST + if ("POST".equals(method) && "/api/v1/wallet-opening/training/reset".equals(path)) return pair("WALLET_OPENING", "RESET"); + if ("POST".equals(method) && "/api/v1/wallet-opening/send".equals(path)) return pair("WALLET_OPENING", "01"); + if ("POST".equals(method) && "/api/v1/commercial-banks/wallet-opening/step2/confirm-collection".equals(path)) return pair("WALLET_OPENING", "02"); + if ("POST".equals(method) && "/api/v1/central-banks/wallet-opening/step3/confirm".equals(path)) return pair("WALLET_OPENING", "03"); + if ("POST".equals(method) && "/api/v1/commercial-banks/wallet-opening/step4/send".equals(path)) return pair("WALLET_OPENING", "04"); + if ("POST".equals(method) && "/api/v1/central-banks/wallet-opening/step5/send".equals(path)) return pair("WALLET_OPENING", "05"); + if ("POST".equals(method) && "/api/v1/exchanges".equals(path)) return pair("CURRENCY_EXCHANGE", "02"); if ("POST".equals(method) && path.matches("/api/v1/exchanges/[^/]+/sign")) return pair("CURRENCY_EXCHANGE", "03"); if ("POST".equals(method) && path.matches("/api/v1/exchanges/[^/]+/bank-process")) return pair("CURRENCY_EXCHANGE", "04"); if ("POST".equals(method) && path.matches("/api/v1/exchanges/[^/]+/confirm-ownership")) return pair("CURRENCY_EXCHANGE", "05"); - if ("GET".equals(method) && "/api/v1/payments/context".equals(path)) return pair("CURRENCY_PAYMENT", "01"); if ("POST".equals(method) && path.matches("/api/v1/payments/[^/]+/sign")) return pair("CURRENCY_PAYMENT", "02"); if ("POST".equals(method) && path.matches("/api/v1/payments/[^/]+/payer-bank-process")) return pair("CURRENCY_PAYMENT", "03"); if ("POST".equals(method) && path.matches("/api/v1/payments/[^/]+/central-bank-settle")) return pair("CURRENCY_PAYMENT", "04"); diff --git a/src/main/java/com/yau/digitalrmb/training/interfaces/rest/TrainingTaskProgressController.java b/src/main/java/com/yau/digitalrmb/training/interfaces/rest/TrainingTaskProgressController.java index 830e74d..cffce03 100644 --- a/src/main/java/com/yau/digitalrmb/training/interfaces/rest/TrainingTaskProgressController.java +++ b/src/main/java/com/yau/digitalrmb/training/interfaces/rest/TrainingTaskProgressController.java @@ -7,16 +7,23 @@ import com.yau.digitalrmb.training.application.TrainingTaskProgressService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.slf4j.MDC; +import com.yau.digitalrmb.training.interfaces.rest.dto.TrainingStepSaveRequest; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import javax.validation.Valid; import java.util.List; @RestController @RequestMapping("/api/v1/training-tasks") -@Tag(name = "实训任务进度", description = "按当前用户分别返回五个实验模块的子步骤完成进度") +@Tag(name = "实训任务进度", + description = "按当前用户返回五个实验模块的步骤进度。步骤状态:COMPLETED 已完成(已提交)、CURRENT 可操作、" + + "SAVED 已保存草稿未完成、LOCKED 前序步骤未完成。草稿不计入完成数。") public class TrainingTaskProgressController { private final TrainingTaskProgressService service; private final CurrentUserService currentUserService; @@ -30,6 +37,33 @@ public class TrainingTaskProgressController { @Operation(summary = "查询当前用户单个实训任务进度") public ApiResponse one(@PathVariable String moduleCode) { return ok(service.one(userId(), moduleCode)); } + @GetMapping("/{moduleCode}/steps/{stepCode}") + @Operation(summary = "查询单个步骤的进度和已保存的答案", + description = "stepData 为最近一次保存的答案 JSON 字符串;没有保存过则为 null。") + public ApiResponse step( + @PathVariable String moduleCode, @PathVariable String stepCode) { + return ok(service.stepDetail(userId(), moduleCode, stepCode)); + } + + @PutMapping("/{moduleCode}/steps/{stepCode}") + @Operation(summary = "保存单个步骤的答案(随时保存)", + description = "重复保存覆盖上一次。只保存草稿,不会把步骤标记为完成;步骤完成仍由真实业务操作成功后自动记录," + + "已完成的步骤重新保存后依然是 COMPLETED。返回保存后该模块的最新进度。") + public ApiResponse saveStep( + @PathVariable String moduleCode, @PathVariable String stepCode, + @Valid @RequestBody TrainingStepSaveRequest request) { + return ok(service.saveStep(userId(), moduleCode, stepCode, request.getStepData())); + } + + @DeleteMapping("/{moduleCode}") + @Operation(summary = "重新实训:清空某个模块的步骤进度", + description = "删除当前用户在该模块下的全部步骤进度,包括完成记录和已保存的答案。幂等,没有数据时同样成功。" + + "只清进度,不会回滚各模块已产生的业务数据。") + public ApiResponse reset(@PathVariable String moduleCode) { + service.resetModule(userId(), moduleCode); + return ok(null); + } + private String userId() { return currentUserService.getCurrentUser().getUserId(); } private ApiResponse ok(T data) { return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); } } diff --git a/src/main/java/com/yau/digitalrmb/training/interfaces/rest/dto/TrainingStepSaveRequest.java b/src/main/java/com/yau/digitalrmb/training/interfaces/rest/dto/TrainingStepSaveRequest.java new file mode 100644 index 0000000..53b98dc --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/interfaces/rest/dto/TrainingStepSaveRequest.java @@ -0,0 +1,19 @@ +package com.yau.digitalrmb.training.interfaces.rest.dto; + +import com.yau.digitalrmb.training.application.TrainingTaskProgressService; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +@Data +@Schema(description = "保存实训步骤答案(草稿)") +public class TrainingStepSaveRequest { + + @NotBlank(message = "答案数据不能为空") + @Size(max = TrainingTaskProgressService.MAX_STEP_DATA_LENGTH, message = "答案数据过大") + @Schema(description = "答案数据,必须是 JSON 对象或数组序列化后的字符串,后端原样存储", + example = "{\"payerWalletId\":\"WALLET_A\",\"amount\":\"1000.00\",\"note\":\"房租\"}") + private String stepData; +} diff --git a/src/main/resources/db/manual/training_task_progress_backfill.sql b/src/main/resources/db/manual/training_task_progress_backfill.sql new file mode 100644 index 0000000..7de9723 --- /dev/null +++ b/src/main/resources/db/manual/training_task_progress_backfill.sql @@ -0,0 +1,72 @@ +-- 补录历史数据中漏记的「步骤已完成」记录(training_task_step_progress.completed_at)。 +-- 背景:模块二、四、五的动作链、模块三的各接口原先不会写这张表,页面与 /api/v1/training-tasks 因此少算已做完的步骤。 +-- 新代码上线后这些步骤会在完成时自动记录;本脚本只补上线前已经做完、但没有记录的部分。 +-- +-- 判定依据(都来自真实业务数据,不凭空标记): +-- 模块二/四/五:某步骤的「最后一个动作」已 COMPLETED,且所属实验未被取消, +-- 该步骤内没有被回滚或失败的动作(如兑换步骤四的扣款回滚)。完成时间取该动作的完成时间。 +-- 模块三:央行钱包激活记录已走完最终确权并发送(与「重置兑换/支付」判断模块三已完成的口径一致), +-- 则 01~05 五个步骤都视为完成,完成时间取该记录的最后更新时间。 +-- 只补缺失:已有完成时间的不改;已有草稿答案的保留;可重复执行。 +-- 模块一的记录一直由接口拦截器写入,无需补录。 +-- 第三部分把「已完成且已有答案」的步骤封存(锁定),让上线前就已经做完的答案也不能再被覆盖。 +-- 请在发布时执行一次:它会让这些步骤放弃「完成后再接受一次保存」的机会, +-- 所以不要在业务高峰期、学员正好做完某一步的瞬间重复执行。 + +-- 一、模块二(发行)、四(兑换)、五(支付):按动作链补录 +INSERT INTO training_task_step_progress (user_id, module_code, step_code, completed_at) +SELECT a.user_id, + CASE a.module_code WHEN 'ISSUANCE' THEN 'PBOC_CURRENCY_ISSUANCE' + WHEN 'EXCHANGE' THEN 'CURRENCY_EXCHANGE' + WHEN 'PAYMENT' THEN 'CURRENCY_PAYMENT' END, + x.step_code, MIN(x.completed_at) +FROM training_experiment_attempt a +JOIN training_experiment_action x ON x.attempt_id = a.id +WHERE a.status IN ('IN_PROGRESS', 'SUBMITTED', 'COMPLETED') + AND x.status = 'COMPLETED' AND x.completed_at IS NOT NULL + AND ((a.module_code = 'ISSUANCE' AND + ((x.step_code = '01' AND x.action_code = 'send-application') + OR (x.step_code = '02' AND x.action_code = 'compare-digest') + OR (x.step_code = '03' AND x.action_code = 'query-application-message') + OR (x.step_code = '04' AND x.action_code = 'generate-reserve-deduction-request') + OR (x.step_code = '05' AND x.action_code = 'send-balance-notice') + OR (x.step_code = '06' AND x.action_code = 'generate-production-digest') + OR (x.step_code = '07' AND x.action_code = 'load-ownership-result'))) + OR (a.module_code = 'EXCHANGE' AND + ((x.step_code = '01' AND x.action_code = 'refresh-institution') + OR (x.step_code = '02' AND x.action_code = 'output-withdrawal-request') + OR (x.step_code = '03' AND x.action_code = 'send-request') + OR (x.step_code = '04' AND x.action_code = 'output-debit-voucher') + OR (x.step_code = '05' AND x.action_code = 'user-confirm'))) + OR (a.module_code = 'PAYMENT' AND + ((x.step_code = '01' AND x.action_code = 'refresh-payee') + OR (x.step_code = '02' AND x.action_code = 'send-payment') + OR (x.step_code = '03' AND x.action_code = 'send-to-central-bank') + OR (x.step_code = '04' AND x.action_code = 'return-settlement') + OR (x.step_code = '05' AND x.action_code = 'execute-credit') + OR (x.step_code = '06' AND x.action_code = 'notify-payee')))) + AND NOT EXISTS (SELECT 1 FROM training_experiment_action bad + WHERE bad.attempt_id = a.id AND bad.step_code = x.step_code + AND bad.status IN ('ROLLED_BACK', 'FAILED')) +GROUP BY a.user_id, a.module_code, x.step_code +ON DUPLICATE KEY UPDATE completed_at = COALESCE(completed_at, VALUES(completed_at)); + +-- 二、模块三(开通钱包):央行已完成最终确权并发送的用户,五个步骤都补为完成 +-- 注意:查询必须包进派生表。INSERT ... SELECT ... JOIN 后面直接接 ON DUPLICATE KEY 时, +-- MySQL 会把 ON 当成连接条件而报语法错误;派生表的时间列也不能叫 completed_at,否则与目标表同名、ON DUPLICATE 里会有歧义。 +INSERT INTO training_task_step_progress (user_id, module_code, step_code, completed_at) +SELECT t.user_id, t.module_code, t.step_code, t.done_at +FROM (SELECT w.user_id AS user_id, 'WALLET_OPENING' AS module_code, s.step_code AS step_code, w.updated_at AS done_at + FROM (SELECT user_id, MAX(updated_at) AS updated_at + FROM central_wallet_activation + WHERE wallet_activated = TRUE AND final_sent = TRUE + AND cb_final_signature IS NOT NULL AND status = 'FINAL_SENT' AND deleted = FALSE + GROUP BY user_id) w + CROSS JOIN (SELECT '01' AS step_code UNION ALL SELECT '02' UNION ALL SELECT '03' + UNION ALL SELECT '04' UNION ALL SELECT '05') s) t +ON DUPLICATE KEY UPDATE completed_at = COALESCE(training_task_step_progress.completed_at, VALUES(completed_at)); + +-- 三、封存历史答案:已完成且已有答案的步骤,锁定时间取答案的保存时间 +UPDATE training_task_step_progress +SET sealed_at = COALESCE(saved_at, completed_at) +WHERE completed_at IS NOT NULL AND step_data IS NOT NULL AND sealed_at IS NULL; diff --git a/src/main/resources/db/manual/training_task_progress_draft_migrate.sql b/src/main/resources/db/manual/training_task_progress_draft_migrate.sql new file mode 100644 index 0000000..afc1798 --- /dev/null +++ b/src/main/resources/db/manual/training_task_progress_draft_migrate.sql @@ -0,0 +1,51 @@ +-- 把旧「步骤进度」表(user_step_progress_new)里已保存的答案迁移到 training_task_step_progress.step_data。 +-- 前置:先执行 training_task_progress_draft_upgrade.sql(新增 step_data / saved_at 列)。 +-- +-- 规则: +-- 1. 只迁移模块 1~5;旧表 moudule_code 的取值与前端约定一致: +-- 中国人民银行生成数字货币 -> PBOC_CURRENCY_GENERATION +-- 发行货币 -> PBOC_CURRENCY_ISSUANCE +-- wallet-open -> WALLET_OPENING +-- currency-exchange -> CURRENCY_EXCHANGE +-- currency-payment -> CURRENCY_PAYMENT +-- 模块六(innovation-scene 等)不在迁移范围,仍使用旧接口。 +-- 2. 只迁移未被逻辑删除(deleted=0)、内容是合法 JSON 对象/数组的记录。 +-- 3. 不覆盖新表里已有的答案。旧表主键只有 id(不是 用户+模块+步骤 唯一),同一步骤可能有多条 +-- 未删除记录,这里只取其中最新的一条(update_time 相同则取 id 大的)。 +-- 4. 只写 step_data / saved_at,不动 completed_at:旧表里「已保存」不代表业务已完成, +-- 完成状态仍以真实业务操作为准。没有完成记录的步骤迁移后显示为「已保存」草稿。 +-- 本脚本可重复执行;请用 utf8mb4 连接执行(模块名含中文,连接字符集不对会匹配不到任何行)。 + +INSERT INTO training_task_step_progress (user_id, module_code, step_code, step_data, saved_at) +SELECT s.user_id, + CASE s.moudule_code + WHEN '中国人民银行生成数字货币' THEN 'PBOC_CURRENCY_GENERATION' + WHEN '发行货币' THEN 'PBOC_CURRENCY_ISSUANCE' + WHEN 'wallet-open' THEN 'WALLET_OPENING' + WHEN 'currency-exchange' THEN 'CURRENCY_EXCHANGE' + WHEN 'currency-payment' THEN 'CURRENCY_PAYMENT' + END, + s.step_code, s.data, s.update_time +FROM user_step_progress_new s +WHERE s.deleted = 0 + AND s.moudule_code IN ('中国人民银行生成数字货币', '发行货币', 'wallet-open', 'currency-exchange', 'currency-payment') + AND s.data IS NOT NULL + AND CHAR_LENGTH(s.data) <= 100000 + AND JSON_VALID(s.data) = 1 + AND LEFT(LTRIM(s.data), 1) IN ('{', '[') + AND s.step_code REGEXP '^[0-9]{2}$' + AND ((s.moudule_code = '中国人民银行生成数字货币' AND s.step_code BETWEEN '01' AND '09') + OR (s.moudule_code = '发行货币' AND s.step_code BETWEEN '01' AND '07') + OR (s.moudule_code = 'wallet-open' AND s.step_code BETWEEN '01' AND '05') + OR (s.moudule_code = 'currency-exchange' AND s.step_code BETWEEN '01' AND '05') + OR (s.moudule_code = 'currency-payment' AND s.step_code BETWEEN '01' AND '06')) + AND NOT EXISTS (SELECT 1 FROM user_step_progress_new n + WHERE n.user_id = s.user_id AND n.moudule_code = s.moudule_code + AND n.step_code = s.step_code AND n.deleted = 0 + AND (n.update_time > s.update_time + OR (n.update_time = s.update_time AND n.id > s.id))) +-- 注意赋值顺序:MySQL 按书写顺序依次赋值,saved_at 必须写在 step_data 前面, +-- 否则判断 step_data IS NULL 时它已经被上一句改成非空,saved_at 就永远写不进去。 +ON DUPLICATE KEY UPDATE + saved_at = IF(step_data IS NULL, VALUES(saved_at), saved_at), + step_data = COALESCE(step_data, VALUES(step_data)); diff --git a/src/main/resources/db/manual/training_task_progress_draft_upgrade.sql b/src/main/resources/db/manual/training_task_progress_draft_upgrade.sql new file mode 100644 index 0000000..51363b3 --- /dev/null +++ b/src/main/resources/db/manual/training_task_progress_draft_upgrade.sql @@ -0,0 +1,32 @@ +-- 实训任务步骤进度支持「随时保存」:除完成记录外,还能保存步骤答案草稿(一段 JSON)。 +-- completed_at 为空 = 仅保存了草稿、该步骤尚未完成 +-- step_data = 前端保存的答案 JSON 字符串 +-- saved_at = 最近一次保存时间 +-- sealed_at = 答案被锁定的时间:步骤完成后只再接受一次保存,之后答案不可再被覆盖(为空表示未锁定) +-- 旧记录不受影响:它们的 completed_at 都有值,step_data / saved_at 为空。 +-- 兼容滚动发布:旧版本后端仍按「只写 completed_at」的方式插入,新列均可为空,不会报错。 +-- 本脚本可重复执行(先查 information_schema,缺什么补什么)。 + +SET @db = DATABASE(); + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'training_task_step_progress' AND COLUMN_NAME = 'step_data') = 0, + 'ALTER TABLE training_task_step_progress ADD COLUMN step_data MEDIUMTEXT NULL AFTER completed_at', + 'SELECT ''step_data 已存在'''); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'training_task_step_progress' AND COLUMN_NAME = 'saved_at') = 0, + 'ALTER TABLE training_task_step_progress ADD COLUMN saved_at TIMESTAMP NULL DEFAULT NULL AFTER step_data', + 'SELECT ''saved_at 已存在'''); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'training_task_step_progress' AND COLUMN_NAME = 'sealed_at') = 0, + 'ALTER TABLE training_task_step_progress ADD COLUMN sealed_at TIMESTAMP NULL DEFAULT NULL AFTER saved_at', + 'SELECT ''sealed_at 已存在'''); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 草稿行没有完成时间,completed_at 需要允许为空(重复执行同样安全) +ALTER TABLE training_task_step_progress + MODIFY COLUMN completed_at TIMESTAMP NULL DEFAULT NULL; diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 028e6ab..927f3c6 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -765,7 +765,10 @@ CREATE TABLE IF NOT EXISTS training_task_step_progress ( user_id VARCHAR(36) NOT NULL, module_code VARCHAR(64) NOT NULL, step_code VARCHAR(32) NOT NULL, - completed_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP NULL DEFAULT NULL, + step_data MEDIUMTEXT NULL, + saved_at TIMESTAMP NULL DEFAULT NULL, + sealed_at TIMESTAMP NULL DEFAULT NULL, PRIMARY KEY (user_id, module_code, step_code), KEY idx_training_task_progress_user_module (user_id, module_code) ); 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 index 3a7bad6..c53c823 100644 --- a/src/test/java/com/yau/digitalrmb/exchange/application/service/ExchangeScoreServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/exchange/application/service/ExchangeScoreServiceTest.java @@ -2,6 +2,7 @@ package com.yau.digitalrmb.exchange.application.service; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper; +import com.yau.digitalrmb.training.application.TrainingTaskProgressService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -18,8 +19,27 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class ExchangeScoreServiceTest { @Mock private StuModuleScoreDetailsMapper mapper; + @Mock private TrainingTaskProgressService taskProgress; @InjectMocks private ExchangeScoreService service; + @Test + void markStepCompletedSyncsTrainingTaskProgressForTheSameStep() { + when(mapper.selectOne(any())).thenReturn(null); + + service.markStepCompleted("user-1", 4); + + verify(taskProgress).completeThrough("user-1", "CURRENCY_EXCHANGE", "04"); + } + + @Test + void markStepCompletedSkipsTrainingTaskProgressWithoutUserOrStep() { + service.markStepCompleted(" ", 2); + when(mapper.selectOne(any())).thenReturn(null); + service.markStepCompleted("user-1", 0); + + verify(taskProgress, never()).completeThrough(any(), any(), any()); + } + @Test void firstErrorCreatesExchangeScoreDetail() { when(mapper.selectOne(any())).thenReturn(null); diff --git a/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java b/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java index 2550d1e..1ebe2a5 100644 --- a/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java @@ -53,6 +53,8 @@ class ExchangeControllerTest { jdbc.update("DELETE FROM training_experiment_action WHERE attempt_id IN " + "(SELECT id FROM training_experiment_attempt WHERE user_id=?)", USER_ID); jdbc.update("DELETE FROM training_experiment_attempt WHERE user_id=?", USER_ID); + jdbc.update("DELETE FROM training_task_step_progress WHERE user_id=? AND module_code='CURRENCY_EXCHANGE'", + USER_ID); jdbc.update("DELETE FROM stu_module_score_details WHERE user_id=? AND serial_number=4", USER_ID); jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,school_name," + "role_id,is_deleted,zy_user_id) VALUES (?,?,?,?,?,?,?,?,4,0,?)", @@ -119,6 +121,8 @@ class ExchangeControllerTest { jdbc.update("DELETE FROM training_experiment_action WHERE attempt_id IN " + "(SELECT id FROM training_experiment_attempt WHERE user_id=?)", USER_ID); jdbc.update("DELETE FROM training_experiment_attempt WHERE user_id=?", USER_ID); + jdbc.update("DELETE FROM training_task_step_progress WHERE user_id=? AND module_code='CURRENCY_EXCHANGE'", + USER_ID); jdbc.update("DELETE FROM exchange_step_log"); jdbc.update("DELETE FROM wallet_balance_ledger"); jdbc.update("DELETE FROM central_bank_currency_ownership"); @@ -338,6 +342,55 @@ class ExchangeControllerTest { Integer.class, exchangeId)).isEqualTo(1); } + @Test + void actionChainAdvancesTrainingTaskProgressAsSoonAsAStepCompletes() throws Exception { + String created = mockMvc.perform(post("/api/v1/exchange/attempts").with(user())) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + String attemptId = JsonPath.read(created, "$.data.attemptId"); + + mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_EXCHANGE").with(user())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("NOT_STARTED")) + .andExpect(jsonPath("$.data.completedSteps").value(0)); + + // 步骤一做了一半:进度不能提前推进。这里刻意不调用 GET /exchanges/context, + // 那是旧接口,拦截器会把它记成步骤一,掩盖动作链没写进度的问题。 + executeAction(attemptId, "01", "refresh-wallet", "COMPLETED"); + executeAction(attemptId, "01", "confirm-binding", "COMPLETED"); + mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_EXCHANGE").with(user())) + .andExpect(jsonPath("$.data.completedSteps").value(0)); + + // 步骤一最后一个动作完成:同一时刻再查必须已更新 + executeAction(attemptId, "01", "refresh-institution", "COMPLETED"); + mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_EXCHANGE").with(user())) + .andExpect(jsonPath("$.data.status").value("IN_PROGRESS")) + .andExpect(jsonPath("$.data.completedSteps").value(1)) + .andExpect(jsonPath("$.data.currentStepCode").value("02")) + .andExpect(jsonPath("$.data.steps[0].status").value("COMPLETED")) + .andExpect(jsonPath("$.data.steps[1].status").value("CURRENT")); + + // 步骤二:取到钱包/账户后只走动作链(POST /exchanges 不会被调用) + String context = mockMvc.perform(get("/api/v1/exchanges/context").with(user())) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + String walletId = JsonPath.read(context, "$.data.walletId"); + String accountId = JsonPath.read(context, "$.data.bankAccountId"); + executeAction(attemptId, "02", "generate-timestamp", "COMPLETED"); + executeAction(attemptId, "02", "concatenate-withdrawal", "COMPLETED", + "{\"walletId\":\"" + walletId + "\",\"bankAccountId\":\"" + accountId + + "\",\"amount\":200.00}"); + mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_EXCHANGE").with(user())) + .andExpect(jsonPath("$.data.completedSteps").value(1)); + executeAction(attemptId, "02", "output-withdrawal-request", "COMPLETED"); + mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_EXCHANGE").with(user())) + .andExpect(jsonPath("$.data.completedSteps").value(2)) + .andExpect(jsonPath("$.data.currentStepCode").value("03")); + + // 重复提交已完成的动作:幂等,不产生重复记录 + executeAction(attemptId, "02", "output-withdrawal-request", "COMPLETED"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM training_task_step_progress " + + "WHERE user_id=? AND module_code='CURRENCY_EXCHANGE'", Integer.class, USER_ID)).isEqualTo(2); + } + @Test void rejectsWrongAlgorithmAndWalletPrivateKeyWithoutAdvancingTheExchangeAction() throws Exception { String context = mockMvc.perform(get("/api/v1/exchanges/context").with(user())) diff --git a/src/test/java/com/yau/digitalrmb/institutionidentity/application/InstitutionTrainingScoreServiceTest.java b/src/test/java/com/yau/digitalrmb/institutionidentity/application/InstitutionTrainingScoreServiceTest.java new file mode 100644 index 0000000..3b21657 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/institutionidentity/application/InstitutionTrainingScoreServiceTest.java @@ -0,0 +1,39 @@ +package com.yau.digitalrmb.institutionidentity.application; + +import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper; +import com.yau.digitalrmb.training.application.TrainingTaskProgressService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.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 InstitutionTrainingScoreServiceTest { + @Mock private StuModuleScoreDetailsMapper mapper; + @Mock private TrainingTaskProgressService taskProgress; + @InjectMocks private InstitutionTrainingScoreService service; + + @Test + void markStepCompletedSyncsTrainingTaskProgressForTheSameStep() { + when(mapper.selectOne(any())).thenReturn(null); + + service.markStepCompleted("user-1", 9); + + verify(taskProgress).completeThrough("user-1", "PBOC_CURRENCY_GENERATION", "09"); + } + + @Test + void markStepCompletedSkipsTrainingTaskProgressWithoutUserOrStep() { + service.markStepCompleted(" ", 2); + when(mapper.selectOne(any())).thenReturn(null); + service.markStepCompleted("user-1", 0); + + verify(taskProgress, never()).completeThrough(any(), any(), 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 index 25d5bc1..f4c067b 100644 --- a/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceScoreServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceScoreServiceTest.java @@ -2,6 +2,7 @@ package com.yau.digitalrmb.issuance.application.service; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper; +import com.yau.digitalrmb.training.application.TrainingTaskProgressService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -20,9 +21,30 @@ class IssuanceScoreServiceTest { @Mock private StuModuleScoreDetailsMapper mapper; + @Mock + private TrainingTaskProgressService taskProgress; + @InjectMocks private IssuanceScoreService service; + @Test + void markStepCompletedSyncsTrainingTaskProgressForTheSameStep() { + when(mapper.selectOne(any())).thenReturn(null); + + service.markStepCompleted("user-1", 7); + + verify(taskProgress).completeThrough("user-1", "PBOC_CURRENCY_ISSUANCE", "07"); + } + + @Test + void markStepCompletedSkipsTrainingTaskProgressWithoutUserOrStep() { + service.markStepCompleted(" ", 2); + when(mapper.selectOne(any())).thenReturn(null); + service.markStepCompleted("user-1", 0); + + verify(taskProgress, never()).completeThrough(any(), any(), any()); + } + @Test void firstErrorCreatesScoreDetailAndDeductsAtCurrentProgress() { when(mapper.selectOne(any())).thenReturn(null); 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 index 15d4760..f729777 100644 --- a/src/test/java/com/yau/digitalrmb/payment/application/service/PaymentScoreServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/payment/application/service/PaymentScoreServiceTest.java @@ -2,6 +2,7 @@ package com.yau.digitalrmb.payment.application.service; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper; +import com.yau.digitalrmb.training.application.TrainingTaskProgressService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -11,14 +12,34 @@ 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 PaymentScoreServiceTest { @Mock private StuModuleScoreDetailsMapper mapper; + @Mock private TrainingTaskProgressService taskProgress; @InjectMocks private PaymentScoreService service; + @Test + void markStepCompletedSyncsTrainingTaskProgressForTheSameStep() { + when(mapper.selectOne(any())).thenReturn(null); + + service.markStepCompleted("user-1", 2); + + verify(taskProgress).completeThrough("user-1", "CURRENCY_PAYMENT", "02"); + } + + @Test + void markStepCompletedSkipsTrainingTaskProgressWithoutUserOrStep() { + service.markStepCompleted(" ", 2); + when(mapper.selectOne(any())).thenReturn(null); + service.markStepCompleted("user-1", 0); + + verify(taskProgress, never()).completeThrough(any(), any(), any()); + } + @Test void firstErrorCreatesPaymentScoreDetail() { when(mapper.selectOne(any())).thenReturn(null); diff --git a/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java index 31e4669..ed26b1b 100644 --- a/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java @@ -186,7 +186,8 @@ class PaymentControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.length()").value(5)) .andExpect(jsonPath("$.data[2].moduleCode").value("WALLET_OPENING")) - .andExpect(jsonPath("$.data[2].actionUnavailable").value(true)); + .andExpect(jsonPath("$.data[2].totalSteps").value(5)) + .andExpect(jsonPath("$.data[2].actionUnavailable").value(false)); assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id=?", BigDecimal.class, PAYER_STORAGE_WALLET)) .isEqualByComparingTo("9800.00"); @@ -307,6 +308,42 @@ class PaymentControllerTest { "WALLET_B_7D8E9F0A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6")); } + @Test + void actionChainAdvancesTrainingTaskProgressAsSoonAsAStepCompletes() throws Exception { + String created = mockMvc.perform(post("/api/v1/payment/attempts").with(payer())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + String attemptId = JsonPath.read(created, "$.data.attemptId"); + + mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_PAYMENT").with(payer())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("NOT_STARTED")) + .andExpect(jsonPath("$.data.completedSteps").value(0)) + .andExpect(jsonPath("$.data.steps[0].status").value("CURRENT")); + + // 步骤一只做了一半:进度不能提前推进 + executeAction(attemptId, "01", "refresh-payer", payer()); + mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_PAYMENT").with(payer())) + .andExpect(jsonPath("$.data.completedSteps").value(0)); + + // 步骤一最后一个动作完成后,同一时刻再查进度必须已经更新 + executeAction(attemptId, "01", "refresh-payee", payer()); + mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_PAYMENT").with(payer())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("IN_PROGRESS")) + .andExpect(jsonPath("$.data.completedSteps").value(1)) + .andExpect(jsonPath("$.data.currentStepCode").value("02")) + .andExpect(jsonPath("$.data.steps[0].status").value("COMPLETED")) + .andExpect(jsonPath("$.data.steps[1].status").value("CURRENT")); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM training_task_step_progress " + + "WHERE user_id=? AND module_code='CURRENCY_PAYMENT'", Integer.class, PAYER_USER)).isEqualTo(1); + + // 重复提交已完成的动作:幂等,不产生重复记录 + executeAction(attemptId, "01", "refresh-payee", payer()); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM training_task_step_progress " + + "WHERE user_id=? AND module_code='CURRENCY_PAYMENT'", Integer.class, PAYER_USER)).isEqualTo(1); + } + @Test void isolatesPrototypeWalletBalancesCoinsAndResetByLearner() throws Exception { mockMvc.perform(get("/api/v1/payments/context").with(payer())) diff --git a/src/test/java/com/yau/digitalrmb/training/application/TrainingTaskProgressServiceTest.java b/src/test/java/com/yau/digitalrmb/training/application/TrainingTaskProgressServiceTest.java new file mode 100644 index 0000000..fb1cc62 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/training/application/TrainingTaskProgressServiceTest.java @@ -0,0 +1,337 @@ +package com.yau.digitalrmb.training.application; + +import com.yau.digitalrmb.progress.application.ModuleCompletionService; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class TrainingTaskProgressServiceTest { + private static final String USER = "progress-user-1"; + private static final String OTHER = "progress-user-2"; + private static final String PAYMENT = "CURRENCY_PAYMENT"; + + @Autowired private TrainingTaskProgressService service; + @Autowired private ModuleCompletionService moduleCompletion; + @Autowired private JdbcTemplate jdbc; + + /** 每步一个字母:D=已完成 C=当前可操作 S=已保存草稿 L=锁定 N=未开始。 */ + private String statuses(TrainingTaskProgressService.TrainingTaskView view) { + return view.getSteps().stream().map(TrainingTaskProgressService.TrainingStepView::getStatus) + .map(s -> { + switch (s) { + case "COMPLETED": return "D"; + case "CURRENT": return "C"; + case "SAVED": return "S"; + case "LOCKED": return "L"; + case "NOT_STARTED": return "N"; + default: return "?"; + } + }).collect(Collectors.joining()); + } + + @Test + void untouchedModuleStartsAtTheFirstStep() { + TrainingTaskProgressService.TrainingTaskView view = service.one(USER, PAYMENT); + + assertThat(view.getStatus()).isEqualTo("NOT_STARTED"); + assertThat(view.getCompletedSteps()).isZero(); + assertThat(view.getSavedSteps()).isZero(); + assertThat(view.getCurrentStepCode()).isEqualTo("01"); + assertThat(statuses(view)).isEqualTo("CLLLLL"); + } + + @Test + void savingADraftMarksTheStepSavedAndTheModuleInProgressWithoutCompletingIt() { + TrainingTaskProgressService.TrainingTaskView view = + service.saveStep(USER, PAYMENT, "01", "{\"payer\":\"A\",\"amount\":12.5}"); + + assertThat(view.getStatus()).isEqualTo("IN_PROGRESS"); + assertThat(view.getCompletedSteps()).isZero(); + assertThat(view.getSavedSteps()).isEqualTo(1); + assertThat(view.getCurrentStepCode()).isEqualTo("01"); + assertThat(view.getSteps().get(0).getStatus()).isEqualTo("SAVED"); + assertThat(view.getSteps().get(0).isActionAvailable()).isTrue(); + assertThat(view.getSteps().get(0).isHasData()).isTrue(); + assertThat(view.getSteps().get(0).getSavedAt()).isNotNull(); + assertThat(view.getSteps().get(1).getStatus()).isEqualTo("LOCKED"); + } + + @Test + void draftDataRoundTripsAndEachSaveReplacesThePreviousOne() { + service.saveStep(USER, PAYMENT, "02", "{\"v\":1}"); + service.saveStep(USER, PAYMENT, "02", "{\"v\":2,\"note\":\"房租\"}"); + + TrainingTaskProgressService.TrainingStepDetailView detail = service.stepDetail(USER, PAYMENT, "02"); + + assertThat(detail.getStepData()).isEqualTo("{\"v\":2,\"note\":\"房租\"}"); + assertThat(detail.isHasData()).isTrue(); + assertThat(detail.getStepName()).isEqualTo("签名支付请求"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM training_task_step_progress WHERE user_id=?", + Integer.class, USER)).isEqualTo(1); + } + + @Test + void stepWithoutADraftReturnsNoData() { + TrainingTaskProgressService.TrainingStepDetailView detail = service.stepDetail(USER, PAYMENT, "01"); + + assertThat(detail.getStepData()).isNull(); + assertThat(detail.isHasData()).isFalse(); + assertThat(detail.getStatus()).isEqualTo("CURRENT"); + assertThat(detail.getSavedAt()).isNull(); + } + + @Test + void draftsCanBeSavedBeyondTheFrontierWithoutUnlockingAnything() { + TrainingTaskProgressService.TrainingTaskView view = + service.saveStep(USER, PAYMENT, "03", "{\"x\":1}"); + + assertThat(statuses(view)).isEqualTo("CLSLLL"); + assertThat(view.getCurrentStepCode()).isEqualTo("01"); + assertThat(view.getSteps().get(2).isActionAvailable()).isFalse(); + assertThat(view.getCompletedSteps()).isZero(); + assertThat(view.getStatus()).isEqualTo("IN_PROGRESS"); + } + + @Test + void completingAStepKeepsItsDraftDataAndCountsItExactlyOnce() { + service.saveStep(USER, PAYMENT, "01", "{\"keep\":true}"); + + service.completeThrough(USER, PAYMENT, "01"); + service.completeThrough(USER, PAYMENT, "01"); + TrainingTaskProgressService.TrainingTaskView view = service.one(USER, PAYMENT); + + assertThat(view.getCompletedSteps()).isEqualTo(1); + assertThat(view.getSavedSteps()).isZero(); + assertThat(view.getCurrentStepCode()).isEqualTo("02"); + assertThat(view.getSteps().get(0).getStatus()).isEqualTo("COMPLETED"); + assertThat(service.stepDetail(USER, PAYMENT, "01").getStepData()).isEqualTo("{\"keep\":true}"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM training_task_step_progress WHERE user_id=?", + Integer.class, USER)).isEqualTo(1); + } + + @Test + void completingALaterStepAlsoCompletesTheEarlierOnesThatOnlyHadDrafts() { + service.saveStep(USER, PAYMENT, "01", "{\"a\":1}"); + service.saveStep(USER, PAYMENT, "02", "{\"b\":2}"); + + service.completeThrough(USER, PAYMENT, "02"); + TrainingTaskProgressService.TrainingTaskView view = service.one(USER, PAYMENT); + + // 前两步已完成,第 3 步是下一个可操作步骤,其余锁定 + assertThat(statuses(view)).isEqualTo("DDCLLL"); + assertThat(view.getCompletedSteps()).isEqualTo(2); + assertThat(view.getCurrentStepCode()).isEqualTo("03"); + } + + @Test + void completedStepAcceptsOnlyOneFinalizingSaveThenLocksTheOriginalAnswer() { + service.saveStep(USER, PAYMENT, "01", "{\"v\":1}"); + service.completeThrough(USER, PAYMENT, "01"); + + // 完成之后紧接着的那一次保存(页面做完最后一个动作会自动存一次)被接受 + TrainingTaskProgressService.TrainingTaskView view = service.saveStep(USER, PAYMENT, "01", "{\"v\":2}"); + assertThat(view.getSteps().get(0).getStatus()).isEqualTo("COMPLETED"); + assertThat(view.getSteps().get(0).isLocked()).isTrue(); + assertThat(view.getCompletedSteps()).isEqualTo(1); + + // 之后任何不同的内容都被拒绝,原答案保持不变 + assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "01", "{\"v\":3,\"cheat\":true}")) + .isInstanceOf(BusinessException.class) + .satisfies(e -> assertThat(((BusinessException) e).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); + TrainingTaskProgressService.TrainingStepDetailView detail = service.stepDetail(USER, PAYMENT, "01"); + assertThat(detail.getStepData()).isEqualTo("{\"v\":2}"); + assertThat(detail.isLocked()).isTrue(); + } + + @Test + void savingTheSameAnswerAgainAfterLockingIsAHarmlessNoOp() { + service.completeThrough(USER, PAYMENT, "01"); + service.saveStep(USER, PAYMENT, "01", "{\"a\":1}"); + + TrainingTaskProgressService.TrainingTaskView view = service.saveStep(USER, PAYMENT, "01", "{\"a\":1}"); + + assertThat(view.getSteps().get(0).isLocked()).isTrue(); + assertThat(service.stepDetail(USER, PAYMENT, "01").getStepData()).isEqualTo("{\"a\":1}"); + } + + @Test + void lockedAnswerComparisonIgnoresWhitespaceAndKeyOrderButNotContent() { + service.completeThrough(USER, PAYMENT, "01"); + service.saveStep(USER, PAYMENT, "01", "{\"a\":1,\"b\":[1,2],\"c\":{\"x\":\"房租\"}}"); + + // 只是排版、键顺序不同:同一份答案,视为幂等成功 + service.saveStep(USER, PAYMENT, "01", "{ \"c\": {\"x\": \"房租\"}, \"b\": [1, 2], \"a\": 1 }"); + + // 内容有任何不同(值、数组顺序、多一个字段)都必须拒绝 + for (String changed : new String[]{ + "{\"a\":2,\"b\":[1,2],\"c\":{\"x\":\"房租\"}}", + "{\"a\":1,\"b\":[2,1],\"c\":{\"x\":\"房租\"}}", + "{\"a\":1,\"b\":[1,2],\"c\":{\"x\":\"房租\"},\"d\":0}"}) { + assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "01", changed)) + .as(changed).isInstanceOf(BusinessException.class); + } + assertThat(service.stepDetail(USER, PAYMENT, "01").getStepData()) + .isEqualTo("{\"a\":1,\"b\":[1,2],\"c\":{\"x\":\"房租\"}}"); + } + + @Test + void draftsStayEditableUntilTheStepIsCompleted() { + service.saveStep(USER, PAYMENT, "02", "{\"v\":1}"); + service.saveStep(USER, PAYMENT, "02", "{\"v\":2}"); + service.saveStep(USER, PAYMENT, "02", "{\"v\":3}"); + + TrainingTaskProgressService.TrainingStepDetailView detail = service.stepDetail(USER, PAYMENT, "02"); + assertThat(detail.getStepData()).isEqualTo("{\"v\":3}"); + assertThat(detail.isLocked()).isFalse(); + } + + @Test + void completingALaterStepGivesEarlierDraftStepsTheirOneFinalizingSave() { + service.saveStep(USER, PAYMENT, "01", "{\"s\":1}"); + service.completeThrough(USER, PAYMENT, "02"); + + service.saveStep(USER, PAYMENT, "01", "{\"s\":2}"); + assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "01", "{\"s\":3}")) + .isInstanceOf(BusinessException.class); + assertThat(service.stepDetail(USER, PAYMENT, "01").getStepData()).isEqualTo("{\"s\":2}"); + } + + @Test + void completedStepWithoutAnyAnswerCanStillBeSavedOnceAndLocksAfterwards() { + service.completeThrough(USER, PAYMENT, "01"); + assertThat(service.stepDetail(USER, PAYMENT, "01").isLocked()).isFalse(); + + service.saveStep(USER, PAYMENT, "01", "{\"first\":1}"); + + assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "01", "{\"second\":2}")) + .isInstanceOf(BusinessException.class); + } + + @Test + void lockingIsPerUserAndResetUnlocksTheModule() { + service.completeThrough(USER, PAYMENT, "01"); + service.saveStep(USER, PAYMENT, "01", "{\"u\":1}"); + + service.saveStep(OTHER, PAYMENT, "01", "{\"o\":1}"); + service.saveStep(OTHER, PAYMENT, "01", "{\"o\":2}"); + assertThat(service.stepDetail(OTHER, PAYMENT, "01").getStepData()).isEqualTo("{\"o\":2}"); + + service.resetModule(USER, PAYMENT); + service.saveStep(USER, PAYMENT, "01", "{\"again\":1}"); + assertThat(service.stepDetail(USER, PAYMENT, "01").getStepData()).isEqualTo("{\"again\":1}"); + assertThat(service.stepDetail(USER, PAYMENT, "01").isLocked()).isFalse(); + } + + @Test + void draftsAreNeverCountedAsCompletedByTheModuleCompletionProgress() { + service.saveStep(USER, PAYMENT, "01", "{\"a\":1}"); + service.saveStep(USER, PAYMENT, "02", "{\"b\":2}"); + + assertThat(moduleCompletion.getModuleCompletion(USER, "5").getSchedule()).isZero(); + + service.completeThrough(USER, PAYMENT, "01"); + + assertThat(moduleCompletion.getModuleCompletion(USER, "5").getSchedule()) + .isEqualTo(16.67); + } + + @Test + void usersNeverSeeEachOthersDrafts() { + service.saveStep(USER, PAYMENT, "01", "{\"secret\":1}"); + + assertThat(service.one(OTHER, PAYMENT).getSavedSteps()).isZero(); + assertThat(service.stepDetail(OTHER, PAYMENT, "01").getStepData()).isNull(); + } + + @Test + void resetRemovesDraftsTogetherWithCompletedSteps() { + service.saveStep(USER, PAYMENT, "01", "{\"a\":1}"); + service.completeThrough(USER, PAYMENT, "01"); + service.saveStep(USER, PAYMENT, "02", "{\"b\":2}"); + + service.reset(USER, PAYMENT); + + TrainingTaskProgressService.TrainingTaskView view = service.one(USER, PAYMENT); + assertThat(view.getStatus()).isEqualTo("NOT_STARTED"); + assertThat(service.stepDetail(USER, PAYMENT, "02").getStepData()).isNull(); + } + + @Test + void resetModuleOnlyClearsTheRequestedModuleOfTheRequestedUser() { + service.saveStep(USER, PAYMENT, "01", "{\"a\":1}"); + service.saveStep(USER, "CURRENCY_EXCHANGE", "01", "{\"b\":1}"); + service.saveStep(OTHER, PAYMENT, "01", "{\"c\":1}"); + + service.resetModule(USER, PAYMENT); + + assertThat(service.one(USER, PAYMENT).getSavedSteps()).isZero(); + assertThat(service.one(USER, "CURRENCY_EXCHANGE").getSavedSteps()).isEqualTo(1); + assertThat(service.one(OTHER, PAYMENT).getSavedSteps()).isEqualTo(1); + // 没有数据时重复重置同样成功 + service.resetModule(USER, PAYMENT); + assertThatThrownBy(() -> service.resetModule(USER, "NOPE")).isInstanceOf(BusinessException.class); + } + + @Test + void everyModuleFromOneToFiveSupportsDrafts() { + String[] modules = {"PBOC_CURRENCY_GENERATION", "PBOC_CURRENCY_ISSUANCE", "WALLET_OPENING", + "CURRENCY_EXCHANGE", "CURRENCY_PAYMENT"}; + int[] steps = {9, 7, 5, 5, 6}; + for (int i = 0; i < modules.length; i++) { + TrainingTaskProgressService.TrainingTaskView view = + service.saveStep(USER, modules[i], "01", "{\"m\":" + i + "}"); + + assertThat(view.getTotalSteps()).as(modules[i]).isEqualTo(steps[i]); + assertThat(view.isActionUnavailable()).as(modules[i]).isFalse(); + assertThat(view.getSteps().get(0).getStatus()).as(modules[i]).isEqualTo("SAVED"); + } + } + + @Test + void rejectsDataThatIsNotAJsonObjectOrArray() { + for (String bad : new String[]{"", " ", "not json", "{\"a\":", "{\"a\":1} x", "123", "\"text\"", "null"}) { + assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "01", bad)) + .as("input=[" + bad + "]") + .isInstanceOf(BusinessException.class); + } + assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "01", null)) + .isInstanceOf(BusinessException.class); + assertThat(service.one(USER, PAYMENT).getSavedSteps()).isZero(); + } + + @Test + void acceptsJsonArraysAndRejectsOversizedPayloads() { + assertThat(service.saveStep(USER, PAYMENT, "01", "[1,2,3]").getSavedSteps()).isEqualTo(1); + + StringBuilder huge = new StringBuilder("{\"d\":\""); + for (int i = 0; i < TrainingTaskProgressService.MAX_STEP_DATA_LENGTH; i++) huge.append('x'); + huge.append("\"}"); + assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "02", huge.toString())) + .isInstanceOf(BusinessException.class); + } + + @Test + void rejectsUnknownModulesAndStepsInsteadOfFailingWithAServerError() { + assertThatThrownBy(() -> service.saveStep(USER, "NOPE", "01", "{}")) + .isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.saveStep(USER, PAYMENT, "99", "{}")) + .isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.stepDetail(USER, PAYMENT, "99")) + .isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.one(USER, "NOPE")) + .isInstanceOf(BusinessException.class); + } +} diff --git a/src/test/java/com/yau/digitalrmb/training/infrastructure/TrainingTaskProgressInterceptorTest.java b/src/test/java/com/yau/digitalrmb/training/infrastructure/TrainingTaskProgressInterceptorTest.java new file mode 100644 index 0000000..eac46ec --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/training/infrastructure/TrainingTaskProgressInterceptorTest.java @@ -0,0 +1,93 @@ +package com.yau.digitalrmb.training.infrastructure; + +import com.yau.digitalrmb.security.application.CurrentUser; +import com.yau.digitalrmb.security.application.CurrentUserService; +import com.yau.digitalrmb.training.application.TrainingTaskProgressService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TrainingTaskProgressInterceptorTest { + private static final String USER = "interceptor-user"; + + private TrainingTaskProgressService progress; + private TrainingTaskProgressInterceptor interceptor; + + @BeforeEach + void setUp() { + progress = mock(TrainingTaskProgressService.class); + CurrentUserService users = mock(CurrentUserService.class); + when(users.getCurrentUser()).thenReturn(new CurrentUser("1001", "延安大学", null, null, null, null, 4L, + USER, "u", "用户", "2001", "测试班", "u")); + interceptor = new TrainingTaskProgressInterceptor(progress, users); + } + + private void call(String method, String path, int status) { + MockHttpServletResponse response = new MockHttpServletResponse(); + response.setStatus(status); + interceptor.postHandle(new MockHttpServletRequest(method, path), response, new Object(), null); + } + + @Test + void everyWalletOpeningStepIsCompletedByItsLastWriteOperation() { + call("POST", "/api/v1/wallet-opening/send", 200); + call("POST", "/api/v1/commercial-banks/wallet-opening/step2/confirm-collection", 200); + call("POST", "/api/v1/central-banks/wallet-opening/step3/confirm", 200); + call("POST", "/api/v1/commercial-banks/wallet-opening/step4/send", 200); + call("POST", "/api/v1/central-banks/wallet-opening/step5/send", 200); + + for (String step : new String[]{"01", "02", "03", "04", "05"}) { + verify(progress).completeThrough(USER, "WALLET_OPENING", step); + } + } + + @Test + void walletOpeningResetClearsTheModuleProgress() { + call("POST", "/api/v1/wallet-opening/training/reset", 200); + + verify(progress).reset(USER, "WALLET_OPENING"); + } + + @Test + void failedWalletOperationsAndPlainReadsDoNotCompleteAnything() { + call("POST", "/api/v1/wallet-opening/send", 400); + call("POST", "/api/v1/central-banks/wallet-opening/step5/send", 500); + // 读操作、步骤中间动作:不是步骤收尾,不能算完成 + call("GET", "/api/v1/wallet-opening/page", 200); + call("GET", "/api/v1/wallet-opening/step5/page", 200); + call("POST", "/api/v1/wallet-opening/step5/receive", 200); + call("POST", "/api/v1/central-banks/wallet-opening/step3/send", 200); + call("POST", "/api/v1/commercial-banks/wallet-opening/step4/output-confirm", 200); + + verify(progress, never()).completeThrough(any(), any(), any()); + verify(progress, never()).reset(any(), any()); + } + + @Test + void existingWriteMappingsStillWork() { + call("POST", "/api/v1/payments/abc/notifications", 200); + call("POST", "/api/v1/central-banks/issuance/requests/r1/digital-currency-ownership", 200); + call("POST", "/api/v1/exchanges/abc/confirm-ownership", 200); + + verify(progress).completeThrough(USER, "CURRENCY_PAYMENT", "06"); + verify(progress).completeThrough(USER, "PBOC_CURRENCY_ISSUANCE", "07"); + verify(progress).completeThrough(USER, "CURRENCY_EXCHANGE", "05"); + } + + @Test + void merelyOpeningAPageNeverMarksAStepCompleted() { + // 打开页面只是读取数据,不能算步骤完成;否则步骤一进入就被当成已完成,答案会被提前锁定 + call("GET", "/api/v1/exchanges/context", 200); + call("GET", "/api/v1/payments/context", 200); + call("GET", "/api/v1/commercial-banks/issuance/inventory", 200); + + verify(progress, never()).completeThrough(any(), any(), any()); + } +} diff --git a/src/test/java/com/yau/digitalrmb/training/interfaces/rest/TrainingTaskProgressControllerTest.java b/src/test/java/com/yau/digitalrmb/training/interfaces/rest/TrainingTaskProgressControllerTest.java new file mode 100644 index 0000000..5ba1234 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/training/interfaces/rest/TrainingTaskProgressControllerTest.java @@ -0,0 +1,192 @@ +package com.yau.digitalrmb.training.interfaces.rest; + +import com.yau.digitalrmb.security.application.CurrentUser; +import com.yau.digitalrmb.security.application.CurrentUserService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class TrainingTaskProgressControllerTest { + private static final String USER_A = "00000000-0000-0000-0000-0000000a0001"; + private static final String USER_B = "00000000-0000-0000-0000-0000000b0002"; + private static final String BASE = "/api/v1/training-tasks/CURRENCY_PAYMENT"; + + @Autowired private org.springframework.test.web.servlet.MockMvc mockMvc; + @Autowired private JdbcTemplate jdbc; + @MockBean private CurrentUserService currentUserService; + + @BeforeEach + void clean() { + jdbc.update("DELETE FROM training_task_step_progress WHERE user_id IN (?, ?)", USER_A, USER_B); + } + + @AfterEach + void cleanAfter() { + clean(); + } + + private void actAs(String userId) { + when(currentUserService.getCurrentUser()).thenReturn(new CurrentUser("1001", "延安大学", null, null, null, + null, 4L, userId, "progress", "进度用户", "2001", "测试班", "progress")); + } + + private RequestPostProcessor token(String userId) { + return jwt().jwt(token -> token.subject(userId).claim("userId", userId)); + } + + @Test + void saveThenReadBackTheSameJsonAndTheUpdatedProgress() throws Exception { + actAs(USER_A); + String body = "{\"stepData\":\"{\\\"amount\\\":\\\"1000.00\\\",\\\"note\\\":\\\"房租\\\"}\"}"; + + mockMvc.perform(put(BASE + "/steps/02").with(token(USER_A)) + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.status").value("IN_PROGRESS")) + .andExpect(jsonPath("$.data.savedSteps").value(1)) + .andExpect(jsonPath("$.data.completedSteps").value(0)) + .andExpect(jsonPath("$.data.steps[1].status").value("SAVED")) + .andExpect(jsonPath("$.data.steps[1].hasData").value(true)) + .andExpect(jsonPath("$.data.steps[1].savedAt").isNotEmpty()); + + mockMvc.perform(get(BASE + "/steps/02").with(token(USER_A))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.stepCode").value("02")) + .andExpect(jsonPath("$.data.status").value("SAVED")) + .andExpect(jsonPath("$.data.stepData").value("{\"amount\":\"1000.00\",\"note\":\"房租\"}")); + + mockMvc.perform(get(BASE).with(token(USER_A))) + .andExpect(jsonPath("$.data.status").value("IN_PROGRESS")) + .andExpect(jsonPath("$.data.steps[1].status").value("SAVED")); + } + + @Test + void anotherUserNeverSeesTheDraft() throws Exception { + actAs(USER_A); + mockMvc.perform(put(BASE + "/steps/01").with(token(USER_A)) + .contentType(MediaType.APPLICATION_JSON).content("{\"stepData\":\"{\\\"a\\\":1}\"}")) + .andExpect(status().isOk()); + + actAs(USER_B); + mockMvc.perform(get(BASE + "/steps/01").with(token(USER_B))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.stepData").doesNotExist()) + .andExpect(jsonPath("$.data.hasData").value(false)); + mockMvc.perform(get(BASE).with(token(USER_B))) + .andExpect(jsonPath("$.data.status").value("NOT_STARTED")) + .andExpect(jsonPath("$.data.savedSteps").value(0)); + } + + @Test + void invalidRequestsAreRejectedWith400NotA500() throws Exception { + actAs(USER_A); + String[] badBodies = { + "{}", + "{\"stepData\":\"\"}", + "{\"stepData\":\"not json\"}", + "{\"stepData\":\"123\"}", + "{\"stepData\":\"{\\\"a\\\":\"}", + "not a body at all", + }; + for (String body : badBodies) { + mockMvc.perform(put(BASE + "/steps/01").with(token(USER_A)) + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + mockMvc.perform(put(BASE + "/steps/99").with(token(USER_A)) + .contentType(MediaType.APPLICATION_JSON).content("{\"stepData\":\"{}\"}")) + .andExpect(status().isBadRequest()); + mockMvc.perform(put("/api/v1/training-tasks/NOPE/steps/01").with(token(USER_A)) + .contentType(MediaType.APPLICATION_JSON).content("{\"stepData\":\"{}\"}")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/api/v1/training-tasks/NOPE").with(token(USER_A))) + .andExpect(status().isBadRequest()); + mockMvc.perform(get(BASE + "/steps/99").with(token(USER_A))) + .andExpect(status().isBadRequest()); + + mockMvc.perform(get(BASE).with(token(USER_A))) + .andExpect(jsonPath("$.data.savedSteps").value(0)); + } + + @Test + void lockedStepRejectsADifferentAnswerWith409AndKeepsTheOriginal() throws Exception { + actAs(USER_A); + jdbc.update("INSERT INTO training_task_step_progress (user_id, module_code, step_code, completed_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)", + USER_A, "CURRENCY_PAYMENT", "01"); + String first = "{\"stepData\":\"{\\\"answer\\\":\\\"original\\\"}\"}"; + String changed = "{\"stepData\":\"{\\\"answer\\\":\\\"changed\\\"}\"}"; + + mockMvc.perform(put(BASE + "/steps/01").with(token(USER_A)) + .contentType(MediaType.APPLICATION_JSON).content(first)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.steps[0].locked").value(true)); + + mockMvc.perform(put(BASE + "/steps/01").with(token(USER_A)) + .contentType(MediaType.APPLICATION_JSON).content(changed)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(409)); + // 重复提交同样的内容不报错 + mockMvc.perform(put(BASE + "/steps/01").with(token(USER_A)) + .contentType(MediaType.APPLICATION_JSON).content(first)) + .andExpect(status().isOk()); + + mockMvc.perform(get(BASE + "/steps/01").with(token(USER_A))) + .andExpect(jsonPath("$.data.stepData").value("{\"answer\":\"original\"}")) + .andExpect(jsonPath("$.data.locked").value(true)); + } + + @Test + void deleteClearsDraftsAndRejectsUnknownModules() throws Exception { + actAs(USER_A); + mockMvc.perform(put(BASE + "/steps/01").with(token(USER_A)) + .contentType(MediaType.APPLICATION_JSON).content("{\"stepData\":\"{\\\"a\\\":1}\"}")) + .andExpect(status().isOk()); + + mockMvc.perform(delete(BASE).with(token(USER_A))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + mockMvc.perform(delete(BASE).with(token(USER_A))).andExpect(status().isOk()); + mockMvc.perform(get(BASE).with(token(USER_A))) + .andExpect(jsonPath("$.data.status").value("NOT_STARTED")) + .andExpect(jsonPath("$.data.savedSteps").value(0)); + + mockMvc.perform(delete("/api/v1/training-tasks/NOPE").with(token(USER_A))) + .andExpect(status().isBadRequest()); + } + + @Test + void listEndpointCoversAllFiveModulesAndNoneIsMarkedUnavailable() throws Exception { + actAs(USER_A); + mockMvc.perform(get("/api/v1/training-tasks").with(token(USER_A))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(5)) + .andExpect(jsonPath("$.data[0].moduleCode").value("PBOC_CURRENCY_GENERATION")) + .andExpect(jsonPath("$.data[1].moduleCode").value("PBOC_CURRENCY_ISSUANCE")) + .andExpect(jsonPath("$.data[2].moduleCode").value("WALLET_OPENING")) + .andExpect(jsonPath("$.data[2].totalSteps").value(5)) + .andExpect(jsonPath("$.data[2].actionUnavailable").value(false)) + .andExpect(jsonPath("$.data[3].moduleCode").value("CURRENCY_EXCHANGE")) + .andExpect(jsonPath("$.data[4].moduleCode").value("CURRENCY_PAYMENT")); + } +} diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index 70b317c..1889a58 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -876,7 +876,10 @@ CREATE TABLE IF NOT EXISTS training_task_step_progress ( user_id VARCHAR(36) NOT NULL, module_code VARCHAR(64) NOT NULL, step_code VARCHAR(32) NOT NULL, - completed_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP NULL DEFAULT NULL, + step_data MEDIUMTEXT NULL, + saved_at TIMESTAMP NULL DEFAULT NULL, + sealed_at TIMESTAMP NULL DEFAULT NULL, PRIMARY KEY (user_id, module_code, step_code) );