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
parent
e7c54403d7
commit
79ecc277e2
@ -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,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());
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue