feat: 实训任务进度支持随时保存答案,并统一覆盖模块一至五

/api/v1/training-tasks 原先只表示"哪些步骤完成了",且模块二、四、五的动作链、
模块三的各接口都不会写入,导致步骤做完了进度不更新。同时新需求要求学员操作过程中
可以随时保存答案。

接口
- PUT    /training-tasks/{moduleCode}/steps/{stepCode}  保存答案(JSON),覆盖上一次
- GET    /training-tasks/{moduleCode}/steps/{stepCode}  读取答案和步骤状态
- DELETE /training-tasks/{moduleCode}                   重新实训,清空模块进度
- 步骤新增状态 SAVED(已保存草稿未完成);模块/步骤新增 savedSteps、hasData、savedAt、locked
- 非法输入(空、非 JSON、非对象/数组、超 10 万字符、未知模块/步骤)一律 400

防止学员改答案
- 步骤完成之前答案可反复覆盖;完成之后只再接受一次保存(页面做完最后一个动作会自动存一次),
  随即锁定。锁定后提交不同内容返回 409(新增 ErrorCode.CONFLICT),原答案保留;
  相同内容(忽略空白和键顺序)视为成功。重新实训清空整个模块,包括锁定状态
- 保存时对该步骤行加锁,避免并发请求绕过锁定检查

进度写入
- 模块一、二、四、五的评分服务在步骤完成时统一同步进度;模块三在拦截器里按
  前端各步完成判据对应到最后一个写接口,并恢复为 5 步可用
- 去掉拦截器里"仅打开页面的读接口也记步骤完成"的三处映射(兑换/支付 context、发行库存),
  否则步骤一进入就被当成已完成,答案会被提前锁定
- 草稿不计入完成数:module-completion 只统计 completed_at 不为空的记录

数据库(必须先于后端部署,按顺序执行,均可重复执行)
1. db/manual/training_task_progress_draft_upgrade.sql  新增 step_data/saved_at/sealed_at,completed_at 改为可空
2. db/manual/training_task_progress_draft_migrate.sql  把旧 user_step_progress_new 的答案迁入
3. db/manual/training_task_progress_backfill.sql       补录历史漏记的完成记录,并封存已完成且有答案的步骤
   第 3 个脚本会锁定存量答案,建议在没有学员正在操作时执行

测试
- 新增服务层 22、接口层 6、拦截器 5、评分服务若干用例;锁定与进度同步的用例已验证
  在去掉实现后会失败
- 完整套件的 29 个失败(institutionidentity 三个类和 WalletRuntimeResetTest)在改动前的
  master 上同样存在,与本次无关
master
chenyuan 4 days ago
parent e7c54403d7
commit 79ecc277e2

@ -3,6 +3,7 @@ package com.yau.digitalrmb.exchange.application.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Propagation;
@ -24,6 +25,9 @@ public class ExchangeScoreService {
@Resource @Resource
private StuModuleScoreDetailsMapper scoreDetailsMapper; private StuModuleScoreDetailsMapper scoreDetailsMapper;
@Resource
private TrainingTaskProgressService taskProgress;
@Value("${exchange.score.wrong-deduct:0.50}") @Value("${exchange.score.wrong-deduct:0.50}")
private BigDecimal wrongDeduct = new BigDecimal("0.50"); private BigDecimal wrongDeduct = new BigDecimal("0.50");
@ -53,6 +57,18 @@ public class ExchangeScoreService {
detail.setSchedule(schedule); detail.setSchedule(schedule);
detail.setScoreProject(score(errors, schedule)); detail.setScoreProject(score(errors, schedule));
scoreDetailsMapper.updateById(detail); 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) @Transactional(readOnly = true)

@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -26,6 +27,9 @@ public class InstitutionTrainingScoreService {
@Resource @Resource
private StuModuleScoreDetailsMapper scoreDetailsMapper; private StuModuleScoreDetailsMapper scoreDetailsMapper;
@Resource
private TrainingTaskProgressService taskProgress;
@Value("${institution-training.score.wrong-deduct:0.50}") @Value("${institution-training.score.wrong-deduct:0.50}")
private BigDecimal wrongDeduct = new BigDecimal("0.50"); private BigDecimal wrongDeduct = new BigDecimal("0.50");
@ -59,6 +63,20 @@ public class InstitutionTrainingScoreService {
detail.setSchedule(schedule); detail.setSchedule(schedule);
detail.setScoreProject(score(errorCount, schedule)); detail.setScoreProject(score(errorCount, schedule));
scoreDetailsMapper.updateById(detail); 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) @Transactional(readOnly = true)

@ -3,6 +3,7 @@ package com.yau.digitalrmb.issuance.application.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Propagation;
@ -24,6 +25,9 @@ public class IssuanceScoreService {
@Resource @Resource
private StuModuleScoreDetailsMapper scoreDetailsMapper; private StuModuleScoreDetailsMapper scoreDetailsMapper;
@Resource
private TrainingTaskProgressService taskProgress;
@Value("${issuance.score.wrong-deduct:0.50}") @Value("${issuance.score.wrong-deduct:0.50}")
private BigDecimal wrongDeduct = new BigDecimal("0.50"); private BigDecimal wrongDeduct = new BigDecimal("0.50");
@ -53,6 +57,18 @@ public class IssuanceScoreService {
detail.setSchedule(schedule); detail.setSchedule(schedule);
detail.setScoreProject(score(errors, schedule)); detail.setScoreProject(score(errors, schedule));
scoreDetailsMapper.updateById(detail); 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) @Transactional(readOnly = true)

@ -3,6 +3,7 @@ package com.yau.digitalrmb.payment.application.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Propagation;
@ -24,6 +25,9 @@ public class PaymentScoreService {
@Resource @Resource
private StuModuleScoreDetailsMapper scoreDetailsMapper; private StuModuleScoreDetailsMapper scoreDetailsMapper;
@Resource
private TrainingTaskProgressService taskProgress;
@Value("${payment.score.wrong-deduct:0.50}") @Value("${payment.score.wrong-deduct:0.50}")
private BigDecimal wrongDeduct = new BigDecimal("0.50"); private BigDecimal wrongDeduct = new BigDecimal("0.50");
@ -53,6 +57,18 @@ public class PaymentScoreService {
detail.setSchedule(schedule); detail.setSchedule(schedule);
detail.setScoreProject(score(errors, schedule)); detail.setScoreProject(score(errors, schedule));
scoreDetailsMapper.updateById(detail); 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) @Transactional(readOnly = true)

@ -53,7 +53,7 @@ public class ModuleCompletionService {
schedule = detail != null && detail.getSchedule() != null ? detail.getSchedule() : 0.0; schedule = detail != null && detail.getSchedule() != null ? detail.getSchedule() : 0.0;
} else { } else {
List<String> completedStepCodes = jdbcTemplate.queryForList( List<String> 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); String.class, userId, config.trainingModuleCode);
int completedSteps = completedStepCodes.size(); int completedSteps = completedStepCodes.size();
schedule = config.totalSteps > 0 schedule = config.totalSteps > 0
@ -133,7 +133,7 @@ public class ModuleCompletionService {
private ModuleCompletionResult getFromTrainingTaskTable(String userId, String moduleCode, ModuleConfig config) { private ModuleCompletionResult getFromTrainingTaskTable(String userId, String moduleCode, ModuleConfig config) {
List<String> completedStepCodes = jdbcTemplate.queryForList( List<String> 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); String.class, userId, config.trainingModuleCode);
int completedSteps = completedStepCodes.size(); int completedSteps = completedStepCodes.size();

@ -6,6 +6,8 @@ public enum ErrorCode {
UNAUTHORIZED(401), UNAUTHORIZED(401),
FORBIDDEN(403), FORBIDDEN(403),
RESOURCE_NOT_FOUND(404), RESOURCE_NOT_FOUND(404),
/** 请求与资源当前状态冲突,例如已锁定的答案不允许再被覆盖。 */
CONFLICT(409),
INTERNAL_ERROR(500); INTERNAL_ERROR(500);
private final int code; private final int code;

@ -65,6 +65,8 @@ public class GlobalExceptionHandler {
return HttpStatus.FORBIDDEN; return HttpStatus.FORBIDDEN;
case RESOURCE_NOT_FOUND: case RESOURCE_NOT_FOUND:
return HttpStatus.NOT_FOUND; return HttpStatus.NOT_FOUND;
case CONFLICT:
return HttpStatus.CONFLICT;
default: default:
return HttpStatus.INTERNAL_SERVER_ERROR; return HttpStatus.INTERNAL_SERVER_ERROR;
} }

@ -1,25 +1,57 @@
package com.yau.digitalrmb.training.application; 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.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowCallbackHandler;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/**
* 五个实验模块统一的任务步骤进度。
*
* <p>每个步骤有两类信息:
* <ul>
* <li>完成记录(completed_at):真实业务动作成功后由各模块写入,决定 COMPLETED;</li>
* <li>答案草稿(step_data + saved_at):学员操作过程中随时保存的一段 JSON,决定 SAVED。</li>
* </ul>
* 步骤状态:COMPLETED 已完成(已提交)、CURRENT 下一个可操作的步骤、SAVED 有未提交的草稿、
* LOCKED 前面还有步骤未完成。草稿不计入完成数。
*
* <p>防止学员事后改答案:步骤完成之前答案可以随时覆盖保存;步骤完成之后只再接受<b>一次</b>保存
* (页面在最后一个业务动作完成后会自动存一次最终结果),随后答案被锁定(locked=true),
* 不同内容的保存一律返回 409,原答案保留;重新实训会清空整个模块的进度和锁定状态。
*/
@Service @Service
public class TrainingTaskProgressService { public class TrainingTaskProgressService {
private static final String GENERATION = "PBOC_CURRENCY_GENERATION"; // 模块编码:供各模块评分服务在步骤完成时统一写入进度
private static final String ISSUANCE = "PBOC_CURRENCY_ISSUANCE"; public static final String GENERATION = "PBOC_CURRENCY_GENERATION";
private static final String WALLET = "WALLET_OPENING"; public static final String ISSUANCE = "PBOC_CURRENCY_ISSUANCE";
private static final String EXCHANGE = "CURRENCY_EXCHANGE"; public static final String WALLET = "WALLET_OPENING";
private static final String PAYMENT = "CURRENCY_PAYMENT"; 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 JdbcTemplate jdbc;
private final Map<String, ModuleDefinition> definitions; private final Map<String, ModuleDefinition> definitions;
@ -36,24 +68,76 @@ public class TrainingTaskProgressService {
} }
public TrainingTaskView one(String userId, String moduleCode) { public TrainingTaskView one(String userId, String moduleCode) {
ModuleDefinition module = definitions.get(moduleCode); ModuleDefinition module = requireModule(moduleCode);
if (module == null) throw new IllegalArgumentException("不支持的实训模块:" + moduleCode); return view(module, rows(userId, moduleCode));
List<String> 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); /** 单个步骤的详情,含已保存的答案 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<String> 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,不会提前解锁后续步骤。
*
* <p>锁定规则:步骤未完成时可反复覆盖;步骤完成后接受一次保存并随即锁定,
* 锁定后再提交<b>不同</b>的内容返回 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<Map<String, Object>> 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<String, Object> 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) { public void completeThrough(String userId, String moduleCode, String stepCode) {
ModuleDefinition module = definitions.get(moduleCode); ModuleDefinition module = definitions.get(moduleCode);
if (module == null || module.unavailable) return; if (module == null || module.unavailable) return;
int index = module.indexOf(stepCode); int index = module.indexOf(stepCode);
if (index < 0) return; if (index < 0) return;
Instant now = Instant.now(); Timestamp now = Timestamp.from(Instant.now());
for (int i = 0; i <= index; i++) { for (int i = 0; i <= index; i++) {
String currentStep = module.steps.get(i).code; String currentStep = module.steps.get(i).code;
// 已有完成时间的保持原值;只有草稿的行补上完成时间
jdbc.update("INSERT INTO training_task_step_progress (user_id, module_code, step_code, completed_at) VALUES (?, ?, ?, ?) " 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); 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); jdbc.update("DELETE FROM training_task_step_progress WHERE user_id = ? AND module_code = ?", userId, moduleCode);
} }
private TrainingTaskView view(ModuleDefinition module, List<String> completed) { /** 对外的重置入口:先校验模块编码,非法编码返回 400 而不是静默成功。 */
@Transactional
public void resetModule(String userId, String moduleCode) {
requireModule(moduleCode);
reset(userId, moduleCode);
}
private Map<String, Row> rows(String userId, String moduleCode) {
Map<String, Row> 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<String, Row> rows) {
List<TrainingStepView> steps = new ArrayList<>(); List<TrainingStepView> steps = new ArrayList<>();
boolean prefixCompleted = true; boolean prefixCompleted = true;
int completedCount = 0; int completedCount = 0;
int savedCount = 0;
String currentStepCode = null; String currentStepCode = null;
for (StepDefinition step : module.steps) { 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; String status;
boolean actionAvailable = false; boolean actionAvailable = false;
if (done) { if (done) {
@ -78,18 +186,58 @@ public class TrainingTaskProgressService {
status = "NOT_STARTED"; status = "NOT_STARTED";
prefixCompleted = false; prefixCompleted = false;
} else if (prefixCompleted) { } else if (prefixCompleted) {
status = "CURRENT"; status = draft ? "SAVED" : "CURRENT";
actionAvailable = true; actionAvailable = true;
currentStepCode = step.code; currentStepCode = step.code;
prefixCompleted = false; prefixCompleted = false;
} else { } 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<String, ModuleDefinition> definitions() { private Map<String, ModuleDefinition> definitions() {
@ -98,8 +246,9 @@ public class TrainingTaskProgressService {
"机构身份信息与密钥准备", "生成数字货币请求", "验证货币生成请求", "生成交易信息标识", "生成额度控制位请求", "验证额度控制位请求", "控制系统签名", "生成额度控制位", "生成标准面额币串")); "机构身份信息与密钥准备", "生成数字货币请求", "验证货币生成请求", "生成交易信息标识", "生成额度控制位请求", "验证额度控制位请求", "控制系统签名", "生成额度控制位", "生成标准面额币串"));
result.put(ISSUANCE, module(ISSUANCE, "货币发行", false, null, result.put(ISSUANCE, module(ISSUANCE, "货币发行", false, null,
"查看货币需求", "生成发行申请计划", "发行计划数字签名", "发送标准报文", "中央银行验证与业务核查", "准备金通知与ACS扣减", "发行并确权数字货币")); "查看货币需求", "生成发行申请计划", "发行计划数字签名", "发送标准报文", "中央银行验证与业务核查", "准备金通知与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(EXCHANGE, module(EXCHANGE, "货币兑换", false, null,
"确认银行卡、钱包和商业银行库存", "生成取币请求报文", "钱包私钥签名", "商业银行验签并扣款", "央行确认币串权属")); "确认银行卡、钱包和商业银行库存", "生成取币请求报文", "钱包私钥签名", "商业银行验签并扣款", "央行确认币串权属"));
result.put(PAYMENT, module(PAYMENT, "数字货币支付", false, null, result.put(PAYMENT, module(PAYMENT, "数字货币支付", false, null,
@ -113,15 +262,33 @@ public class TrainingTaskProgressService {
return new ModuleDefinition(code, name, unavailable, reason, steps); return new ModuleDefinition(code, name, unavailable, reason, steps);
} }
/** 模块进度。status:NOT_STARTED 未开始 / IN_PROGRESS 进行中(有完成或草稿)/ COMPLETED 全部完成。 */
@Getter @AllArgsConstructor @Getter @AllArgsConstructor
public static class TrainingTaskView { public static class TrainingTaskView {
private final String moduleCode; private final String moduleName; private final String status; 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<TrainingStepView> steps; private final boolean actionUnavailable; private final String unavailableReason; private final List<TrainingStepView> steps;
} }
/** 步骤进度。status:COMPLETED 已完成 / CURRENT 可操作 / SAVED 有草稿未完成 / LOCKED 锁定 / NOT_STARTED 模块不可用。 */
@Getter @AllArgsConstructor @Getter @AllArgsConstructor
public static class TrainingStepView { 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 static class ModuleDefinition {
private final String code, name; private final boolean unavailable; private final String unavailableReason; private final List<StepDefinition> steps; private final String code, name; private final boolean unavailable; private final String unavailableReason; private final List<StepDefinition> steps;

@ -9,7 +9,11 @@ import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
/** 将已成功返回的真实业务操作映射为对应模块任务步骤;不暴露独立的人工完成接口。 */ /**
* 将已成功返回的真实业务操作映射为对应模块任务步骤;不暴露独立的人工完成接口。
* 只认会改变业务数据的写操作:仅仅打开页面的读取(查询上下文、查看库存等)不能算步骤完成,
* 否则学员一进页面步骤就被当成已完成,答案也会被提前锁定。
*/
@Component @Component
public class TrainingTaskProgressInterceptor implements HandlerInterceptor { public class TrainingTaskProgressInterceptor implements HandlerInterceptor {
private final TrainingTaskProgressService progressService; 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/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 ("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/[^/]+/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/[^/]+/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"); 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/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 ("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) && "/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/[^/]+/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/[^/]+/bank-process")) return pair("CURRENCY_EXCHANGE", "04");
if ("POST".equals(method) && path.matches("/api/v1/exchanges/[^/]+/confirm-ownership")) return pair("CURRENCY_EXCHANGE", "05"); 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/[^/]+/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/[^/]+/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"); if ("POST".equals(method) && path.matches("/api/v1/payments/[^/]+/central-bank-settle")) return pair("CURRENCY_PAYMENT", "04");

@ -7,16 +7,23 @@ import com.yau.digitalrmb.training.application.TrainingTaskProgressService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.MDC; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; 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.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.List; import java.util.List;
@RestController @RestController
@RequestMapping("/api/v1/training-tasks") @RequestMapping("/api/v1/training-tasks")
@Tag(name = "实训任务进度", description = "按当前用户分别返回五个实验模块的子步骤完成进度") @Tag(name = "实训任务进度",
description = "按当前用户返回五个实验模块的步骤进度。步骤状态:COMPLETED 已完成(已提交)、CURRENT 可操作、"
+ "SAVED 已保存草稿未完成、LOCKED 前序步骤未完成。草稿不计入完成数。")
public class TrainingTaskProgressController { public class TrainingTaskProgressController {
private final TrainingTaskProgressService service; private final TrainingTaskProgressService service;
private final CurrentUserService currentUserService; private final CurrentUserService currentUserService;
@ -30,6 +37,33 @@ public class TrainingTaskProgressController {
@Operation(summary = "查询当前用户单个实训任务进度") @Operation(summary = "查询当前用户单个实训任务进度")
public ApiResponse<TrainingTaskProgressService.TrainingTaskView> one(@PathVariable String moduleCode) { return ok(service.one(userId(), moduleCode)); } public ApiResponse<TrainingTaskProgressService.TrainingTaskView> one(@PathVariable String moduleCode) { return ok(service.one(userId(), moduleCode)); }
@GetMapping("/{moduleCode}/steps/{stepCode}")
@Operation(summary = "查询单个步骤的进度和已保存的答案",
description = "stepData 为最近一次保存的答案 JSON 字符串;没有保存过则为 null。")
public ApiResponse<TrainingTaskProgressService.TrainingStepDetailView> step(
@PathVariable String moduleCode, @PathVariable String stepCode) {
return ok(service.stepDetail(userId(), moduleCode, stepCode));
}
@PutMapping("/{moduleCode}/steps/{stepCode}")
@Operation(summary = "保存单个步骤的答案(随时保存)",
description = "重复保存覆盖上一次。只保存草稿,不会把步骤标记为完成;步骤完成仍由真实业务操作成功后自动记录,"
+ "已完成的步骤重新保存后依然是 COMPLETED。返回保存后该模块的最新进度。")
public ApiResponse<TrainingTaskProgressService.TrainingTaskView> 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<Void> reset(@PathVariable String moduleCode) {
service.resetModule(userId(), moduleCode);
return ok(null);
}
private String userId() { return currentUserService.getCurrentUser().getUserId(); } private String userId() { return currentUserService.getCurrentUser().getUserId(); }
private <T> ApiResponse<T> ok(T data) { return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); } private <T> ApiResponse<T> ok(T data) { return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); }
} }

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

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

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

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

@ -765,7 +765,10 @@ CREATE TABLE IF NOT EXISTS training_task_step_progress (
user_id VARCHAR(36) NOT NULL, user_id VARCHAR(36) NOT NULL,
module_code VARCHAR(64) NOT NULL, module_code VARCHAR(64) NOT NULL,
step_code VARCHAR(32) 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), PRIMARY KEY (user_id, module_code, step_code),
KEY idx_training_task_progress_user_module (user_id, module_code) KEY idx_training_task_progress_user_module (user_id, module_code)
); );

@ -2,6 +2,7 @@ package com.yau.digitalrmb.exchange.application.service;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper; 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.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
@ -18,8 +19,27 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class ExchangeScoreServiceTest { class ExchangeScoreServiceTest {
@Mock private StuModuleScoreDetailsMapper mapper; @Mock private StuModuleScoreDetailsMapper mapper;
@Mock private TrainingTaskProgressService taskProgress;
@InjectMocks private ExchangeScoreService service; @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 @Test
void firstErrorCreatesExchangeScoreDetail() { void firstErrorCreatesExchangeScoreDetail() {
when(mapper.selectOne(any())).thenReturn(null); when(mapper.selectOne(any())).thenReturn(null);

@ -53,6 +53,8 @@ class ExchangeControllerTest {
jdbc.update("DELETE FROM training_experiment_action WHERE attempt_id IN " + jdbc.update("DELETE FROM training_experiment_action WHERE attempt_id IN " +
"(SELECT id FROM training_experiment_attempt WHERE user_id=?)", USER_ID); "(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_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("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," 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,?)", + "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 " + jdbc.update("DELETE FROM training_experiment_action WHERE attempt_id IN " +
"(SELECT id FROM training_experiment_attempt WHERE user_id=?)", USER_ID); "(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_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 exchange_step_log");
jdbc.update("DELETE FROM wallet_balance_ledger"); jdbc.update("DELETE FROM wallet_balance_ledger");
jdbc.update("DELETE FROM central_bank_currency_ownership"); jdbc.update("DELETE FROM central_bank_currency_ownership");
@ -338,6 +342,55 @@ class ExchangeControllerTest {
Integer.class, exchangeId)).isEqualTo(1); 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 @Test
void rejectsWrongAlgorithmAndWalletPrivateKeyWithoutAdvancingTheExchangeAction() throws Exception { void rejectsWrongAlgorithmAndWalletPrivateKeyWithoutAdvancingTheExchangeAction() throws Exception {
String context = mockMvc.perform(get("/api/v1/exchanges/context").with(user())) String context = mockMvc.perform(get("/api/v1/exchanges/context").with(user()))

@ -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());
}
}

@ -2,6 +2,7 @@ package com.yau.digitalrmb.issuance.application.service;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper; 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.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
@ -20,9 +21,30 @@ class IssuanceScoreServiceTest {
@Mock @Mock
private StuModuleScoreDetailsMapper mapper; private StuModuleScoreDetailsMapper mapper;
@Mock
private TrainingTaskProgressService taskProgress;
@InjectMocks @InjectMocks
private IssuanceScoreService service; 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 @Test
void firstErrorCreatesScoreDetailAndDeductsAtCurrentProgress() { void firstErrorCreatesScoreDetailAndDeductsAtCurrentProgress() {
when(mapper.selectOne(any())).thenReturn(null); when(mapper.selectOne(any())).thenReturn(null);

@ -2,6 +2,7 @@ package com.yau.digitalrmb.payment.application.service;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity; import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper; 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.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor; 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.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class PaymentScoreServiceTest { class PaymentScoreServiceTest {
@Mock private StuModuleScoreDetailsMapper mapper; @Mock private StuModuleScoreDetailsMapper mapper;
@Mock private TrainingTaskProgressService taskProgress;
@InjectMocks private PaymentScoreService service; @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 @Test
void firstErrorCreatesPaymentScoreDetail() { void firstErrorCreatesPaymentScoreDetail() {
when(mapper.selectOne(any())).thenReturn(null); when(mapper.selectOne(any())).thenReturn(null);

@ -186,7 +186,8 @@ class PaymentControllerTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(5)) .andExpect(jsonPath("$.data.length()").value(5))
.andExpect(jsonPath("$.data[2].moduleCode").value("WALLET_OPENING")) .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)) assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id=?", BigDecimal.class, PAYER_STORAGE_WALLET))
.isEqualByComparingTo("9800.00"); .isEqualByComparingTo("9800.00");
@ -307,6 +308,42 @@ class PaymentControllerTest {
"WALLET_B_7D8E9F0A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6")); "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 @Test
void isolatesPrototypeWalletBalancesCoinsAndResetByLearner() throws Exception { void isolatesPrototypeWalletBalancesCoinsAndResetByLearner() throws Exception {
mockMvc.perform(get("/api/v1/payments/context").with(payer())) mockMvc.perform(get("/api/v1/payments/context").with(payer()))

@ -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);
}
}

@ -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());
}
}

@ -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"));
}
}

@ -876,7 +876,10 @@ CREATE TABLE IF NOT EXISTS training_task_step_progress (
user_id VARCHAR(36) NOT NULL, user_id VARCHAR(36) NOT NULL,
module_code VARCHAR(64) NOT NULL, module_code VARCHAR(64) NOT NULL,
step_code VARCHAR(32) 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) PRIMARY KEY (user_id, module_code, step_code)
); );

Loading…
Cancel
Save