feat: support dynamic training task steps

main
chenyuan 4 weeks ago
parent 1ab8541f7e
commit c1f90a5e64

@ -35,6 +35,9 @@ public class StudentTrainingAnswer {
@ApiModelProperty("步骤四答案")
private String step4Answer;
@ApiModelProperty("自定义步骤答案按步骤ID保存")
private String dynamicStepAnswers;
@ApiModelProperty("当前步骤")
private Integer currentStep;
@ -138,6 +141,14 @@ public class StudentTrainingAnswer {
this.step4Answer = step4Answer == null ? null : step4Answer.trim();
}
public String getDynamicStepAnswers() {
return dynamicStepAnswers;
}
public void setDynamicStepAnswers(String dynamicStepAnswers) {
this.dynamicStepAnswers = dynamicStepAnswers == null ? null : dynamicStepAnswers.trim();
}
public Integer getCurrentStep() {
return currentStep;
}

@ -0,0 +1,40 @@
package com.sztzjy.linkCommerce.entity;
public class TrainingTaskStep {
private String id;
private String name;
private String kind;
public TrainingTaskStep() {
}
public TrainingTaskStep(String id, String name, String kind) {
this.id = id;
this.name = name;
this.kind = kind;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getKind() {
return kind;
}
public void setKind(String kind) {
this.kind = kind;
}
}

@ -222,7 +222,8 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
private boolean hasAnyAnswer(StudentTrainingAnswer answer) {
return StringUtils.isNotBlank(answer.getStep1Answer()) || StringUtils.isNotBlank(answer.getStep2Answer())
|| StringUtils.isNotBlank(answer.getStep3Answer()) || StringUtils.isNotBlank(answer.getStep4Answer());
|| StringUtils.isNotBlank(answer.getStep3Answer()) || StringUtils.isNotBlank(answer.getStep4Answer())
|| StringUtils.isNotBlank(answer.getDynamicStepAnswers());
}
private boolean isTerminalOrProcessing(String status) {
@ -243,6 +244,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
node.put("step2Answer", answer.getStep2Answer());
node.put("step3Answer", answer.getStep3Answer());
node.put("step4Answer", answer.getStep4Answer());
node.put("dynamicStepAnswers", answer.getDynamicStepAnswers());
return json(node);
}

@ -1,5 +1,7 @@
package com.sztzjy.linkCommerce.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.sztzjy.linkCommerce.config.exception.handler.ServiceException;
import com.sztzjy.linkCommerce.config.security.JwtUser;
import com.sztzjy.linkCommerce.entity.StudentTrainingAnswer;
@ -163,6 +165,7 @@ public class StudentTrainingAnswerServiceImpl implements StudentTrainingAnswerSe
record.setStep2Answer(existing.getStep2Answer());
record.setStep3Answer(existing.getStep3Answer());
record.setStep4Answer(existing.getStep4Answer());
record.setDynamicStepAnswers(existing.getDynamicStepAnswers());
record.setSubmitTime(existing.getSubmitTime());
}
@ -171,6 +174,7 @@ public class StudentTrainingAnswerServiceImpl implements StudentTrainingAnswerSe
record.setStep2Answer("");
record.setStep3Answer("");
record.setStep4Answer("");
record.setDynamicStepAnswers("");
record.setCurrentStep(1);
record.setProgressStatus(STATUS_NOT_STARTED);
record.setSubmitted(Boolean.FALSE);
@ -190,10 +194,13 @@ public class StudentTrainingAnswerServiceImpl implements StudentTrainingAnswerSe
if (request.getStep4Answer() != null) {
record.setStep4Answer(request.getStep4Answer());
}
if (request.getDynamicStepAnswers() != null) {
record.setDynamicStepAnswers(request.getDynamicStepAnswers());
}
boolean submitted = ACTION_SUBMIT.equals(StringUtils.upperCase(StringUtils.trimToEmpty(request.getSaveAction())));
record.setSubmitted(submitted);
record.setCurrentStep(submitted ? 4 : normalizeCurrentStepValue(request.getCurrentStep()));
record.setCurrentStep(submitted ? taskStepCount(task) : normalizeCurrentStepValue(request.getCurrentStep()));
record.setProgressStatus(submitted ? STATUS_COMPLETED : resolveProgressStatus(record));
if (submitted) {
record.setSubmitTime(new Date());
@ -205,17 +212,28 @@ public class StudentTrainingAnswerServiceImpl implements StudentTrainingAnswerSe
if (currentStep == null || currentStep < 1) {
return 1;
}
if (currentStep > 4) {
return currentStep;
}
private int taskStepCount(TrainingTask task) {
if (task == null || StringUtils.isBlank(task.getSteps())) {
return 4;
}
return currentStep;
try {
JSONArray steps = JSON.parseArray(task.getSteps());
return steps == null || steps.isEmpty() ? 4 : steps.size();
} catch (Exception ignored) {
String[] steps = StringUtils.split(task.getSteps(), "\n,>");
return steps == null || steps.length == 0 ? 4 : steps.length;
}
}
private String resolveProgressStatus(StudentTrainingAnswer answer) {
if (StringUtils.isNotBlank(answer.getStep1Answer())
|| StringUtils.isNotBlank(answer.getStep2Answer())
|| StringUtils.isNotBlank(answer.getStep3Answer())
|| StringUtils.isNotBlank(answer.getStep4Answer())) {
|| StringUtils.isNotBlank(answer.getStep4Answer())
|| StringUtils.isNotBlank(answer.getDynamicStepAnswers())) {
return STATUS_IN_PROGRESS;
}
return STATUS_NOT_STARTED;
@ -248,6 +266,7 @@ public class StudentTrainingAnswerServiceImpl implements StudentTrainingAnswerSe
"step2_answer longtext NULL COMMENT 'step 2 answer json'," +
"step3_answer longtext NULL COMMENT 'step 3 answer json'," +
"step4_answer longtext NULL COMMENT 'step 4 answer json'," +
"dynamic_step_answers longtext NULL COMMENT 'custom step answers json'," +
"current_step int DEFAULT 1 COMMENT 'current step'," +
"progress_status varchar(32) DEFAULT 'NOT_STARTED' COMMENT 'progress status'," +
"submitted bit(1) DEFAULT b'0' COMMENT 'submitted'," +
@ -260,6 +279,7 @@ public class StudentTrainingAnswerServiceImpl implements StudentTrainingAnswerSe
"KEY idx_student_training_answer_student (student_user_id)," +
"KEY idx_student_training_answer_class (teaching_class_id)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='student training answer'");
ensureDynamicStepAnswersColumn();
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS student_training_progress (" +
"id varchar(64) NOT NULL COMMENT 'primary id'," +
"student_user_id varchar(64) NOT NULL COMMENT 'student user id'," +
@ -303,6 +323,25 @@ public class StudentTrainingAnswerServiceImpl implements StudentTrainingAnswerSe
studentTrainingAnswerTableChecked = true;
}
private void ensureDynamicStepAnswersColumn() {
try {
Integer count = jdbcTemplate.queryForObject(
"select count(*) from information_schema.columns where table_schema = database() " +
"and table_name = 'student_training_answer' and column_name = 'dynamic_step_answers'",
Integer.class);
if (count != null && count > 0) {
return;
}
} catch (Exception ignored) {
// The column alteration below is still safe for databases that do not expose metadata here.
}
try {
jdbcTemplate.execute("ALTER TABLE student_training_answer ADD COLUMN dynamic_step_answers longtext NULL COMMENT 'custom step answers json' AFTER step4_answer");
} catch (Exception ignored) {
// Existing deployments may already have been migrated by another application instance.
}
}
private void syncDetailTables(StudentTrainingAnswer answer) {
if (jdbcTemplate == null || answer == null || StringUtils.isBlank(answer.getId())) {
return;

@ -1,6 +1,7 @@
package com.sztzjy.linkCommerce.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.sztzjy.linkCommerce.config.security.JwtUser;
import com.sztzjy.linkCommerce.entity.SchoolClass;
import com.sztzjy.linkCommerce.entity.SchoolClassExample;
@ -9,6 +10,7 @@ import com.sztzjy.linkCommerce.entity.TaskAllocationExample;
import com.sztzjy.linkCommerce.entity.TeachingClassStudent;
import com.sztzjy.linkCommerce.entity.TrainingTask;
import com.sztzjy.linkCommerce.entity.TrainingTaskClassConfig;
import com.sztzjy.linkCommerce.entity.TrainingTaskStep;
import com.sztzjy.linkCommerce.entity.importDto.TrainingTaskImportDTO;
import com.sztzjy.linkCommerce.mapper.SchoolClassMapper;
import com.sztzjy.linkCommerce.mapper.TaskAllocationMapper;
@ -95,7 +97,7 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
normalizeTask(task);
TrainingTask builtInTask = requireBuiltInTask(task.getTaskKey());
task.setProjectName(builtInTask.getProjectName());
task.setSteps(normalizeFixedSteps(task.getSteps(), builtInTask));
task.setSteps(normalizeSteps(task.getSteps(), builtInTask));
task.setId(UUID.randomUUID().toString());
task.setCreatedBy(operatorId);
task.setCreateTime(new Date());
@ -118,7 +120,7 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
task.setProjectName(existing.getProjectName());
task.setSort(existing.getSort());
task.setEnabled(existing.getEnabled());
task.setSteps(normalizeFixedSteps(task.getSteps(), builtInTask));
task.setSteps(normalizeSteps(task.getSteps(), builtInTask));
task.setUpdateTime(new Date());
trainingTaskMapper.updateByPrimaryKeySelective(task);
return trainingTaskMapper.selectByPrimaryKey(id);
@ -243,7 +245,7 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
task.setProjectName(baseTask.getProjectName());
task.setSort(baseTask.getSort());
task.setEnabled(task.getEnabled() == null ? Boolean.TRUE : task.getEnabled());
task.setSteps(normalizeFixedSteps(task.getSteps(), builtInTask));
task.setSteps(normalizeSteps(task.getSteps(), builtInTask));
TrainingTaskClassConfig existing = trainingTaskClassConfigMapper.selectByTeachingClassAndTaskKey(teachingClassId, normalizedTaskKey);
TrainingTaskClassConfig config = toConfig(teachingClassId, task, operatorId);
@ -647,7 +649,7 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
task.setBackground(StringUtils.trimToEmpty(row.getBackground()));
task.setObjectives(toJsonArray(splitList(row.getObjectives())));
task.setRequirements(StringUtils.trimToEmpty(row.getRequirements()));
task.setSteps(normalizeFixedSteps(row.getSteps(), builtInTask));
task.setSteps(normalizeSteps(row.getSteps(), builtInTask));
task.setMaterialName(StringUtils.trimToEmpty(row.getMaterialName()));
task.setMaterialUrl(StringUtils.trimToEmpty(row.getMaterialUrl()));
task.setSort(row.getSort());
@ -711,23 +713,60 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
return null;
}
private String normalizeFixedSteps(String value, TrainingTask builtInTask) {
int expectedCount = getFixedStepCount(builtInTask);
List<String> steps = parseJsonOrDelimitedList(value);
List<String> normalized = steps.stream()
.map(StringUtils::trimToEmpty)
.filter(StringUtils::isNotBlank)
.limit(expectedCount)
.collect(Collectors.toList());
while (normalized.size() < expectedCount) {
normalized.add("Step " + (normalized.size() + 1));
private String normalizeSteps(String value, TrainingTask builtInTask) {
List<TrainingTaskStep> steps = parseSteps(value, builtInTask);
if (steps.isEmpty()) {
throw new IllegalArgumentException("至少保留一个步骤");
}
return toJsonArray(normalized);
return JSON.toJSONString(steps);
}
private int getFixedStepCount(TrainingTask builtInTask) {
List<String> steps = parseJsonOrDelimitedList(builtInTask == null ? null : builtInTask.getSteps());
return steps.isEmpty() ? 4 : steps.size();
private List<TrainingTaskStep> parseSteps(String value, TrainingTask builtInTask) {
List<TrainingTaskStep> result = new ArrayList<>();
String source = StringUtils.isBlank(value) && builtInTask != null ? builtInTask.getSteps() : value;
if (StringUtils.isBlank(source)) {
return result;
}
String trimmed = StringUtils.trimToEmpty(source);
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
try {
List<?> values = JSON.parseArray(trimmed, Object.class);
if (values != null) {
for (Object valueItem : values) {
if (valueItem instanceof JSONObject) {
JSONObject item = (JSONObject) valueItem;
addStep(result, item.getString("id"), item.getString("name"), item.getString("kind"), builtInTask);
} else {
addStep(result, null, String.valueOf(valueItem), null, builtInTask);
}
}
return result;
}
} catch (Exception ignored) {
// Fall through to legacy delimiter parsing.
}
}
for (String name : parseJsonOrDelimitedList(source)) {
addStep(result, null, name, null, builtInTask);
}
return result;
}
private void addStep(List<TrainingTaskStep> steps, String id, String name, String kind, TrainingTask builtInTask) {
String normalizedName = StringUtils.trimToEmpty(name);
if (StringUtils.isBlank(normalizedName)) {
return;
}
int position = steps.size() + 1;
int builtInCount = parseJsonOrDelimitedList(builtInTask == null ? null : builtInTask.getSteps()).size();
boolean builtIn = "builtin".equalsIgnoreCase(StringUtils.trimToEmpty(kind))
|| (StringUtils.isBlank(kind) && position <= builtInCount);
String normalizedKind = builtIn ? "builtin" : "custom";
String normalizedId = StringUtils.trimToEmpty(id);
if (StringUtils.isBlank(normalizedId)) {
normalizedId = (builtIn ? "builtin-" : "custom-") + position;
}
steps.add(new TrainingTaskStep(normalizedId, normalizedName, normalizedKind));
}
private List<String> parseJsonOrDelimitedList(String value) {

@ -12,6 +12,7 @@
<result column="step2_answer" jdbcType="LONGVARCHAR" property="step2Answer" />
<result column="step3_answer" jdbcType="LONGVARCHAR" property="step3Answer" />
<result column="step4_answer" jdbcType="LONGVARCHAR" property="step4Answer" />
<result column="dynamic_step_answers" jdbcType="LONGVARCHAR" property="dynamicStepAnswers" />
<result column="current_step" jdbcType="INTEGER" property="currentStep" />
<result column="progress_status" jdbcType="VARCHAR" property="progressStatus" />
<result column="submitted" jdbcType="BIT" property="submitted" />
@ -23,7 +24,7 @@
<sql id="Base_Column_List">
id, student_user_id, teaching_class_id, task_id, task_key, task_name, step1_answer, step2_answer,
step3_answer, step4_answer, current_step, progress_status, submitted, ai_assessment_score, submit_time, create_time, update_time
step3_answer, step4_answer, dynamic_step_answers, current_step, progress_status, submitted, ai_assessment_score, submit_time, create_time, update_time
</sql>
<select id="selectByPrimaryKey" parameterType="java.lang.String" resultMap="BaseResultMap">
@ -69,6 +70,7 @@
<if test="step2Answer != null">step2_answer,</if>
<if test="step3Answer != null">step3_answer,</if>
<if test="step4Answer != null">step4_answer,</if>
<if test="dynamicStepAnswers != null">dynamic_step_answers,</if>
<if test="currentStep != null">current_step,</if>
<if test="progressStatus != null">progress_status,</if>
<if test="submitted != null">submitted,</if>
@ -88,6 +90,7 @@
<if test="step2Answer != null">#{step2Answer},</if>
<if test="step3Answer != null">#{step3Answer},</if>
<if test="step4Answer != null">#{step4Answer},</if>
<if test="dynamicStepAnswers != null">#{dynamicStepAnswers},</if>
<if test="currentStep != null">#{currentStep,jdbcType=INTEGER},</if>
<if test="progressStatus != null">#{progressStatus,jdbcType=VARCHAR},</if>
<if test="submitted != null">#{submitted,jdbcType=BIT},</if>
@ -110,6 +113,7 @@
<if test="step2Answer != null">step2_answer = #{step2Answer},</if>
<if test="step3Answer != null">step3_answer = #{step3Answer},</if>
<if test="step4Answer != null">step4_answer = #{step4Answer},</if>
<if test="dynamicStepAnswers != null">dynamic_step_answers = #{dynamicStepAnswers},</if>
<if test="currentStep != null">current_step = #{currentStep,jdbcType=INTEGER},</if>
<if test="progressStatus != null">progress_status = #{progressStatus,jdbcType=VARCHAR},</if>
<if test="submitted != null">submitted = #{submitted,jdbcType=BIT},</if>

@ -69,6 +69,26 @@ class StudentTrainingAnswerServiceImplTest {
assertEquals(Boolean.TRUE, record.getValue().getSubmitted());
}
@Test
void saveKeepsDynamicStepAnswersBeyondTheFourthStep() {
StudentTrainingAnswerServiceImpl service = buildService();
prepareTaskAndClass(service);
when(service.studentTrainingAnswerMapper.selectByStudentClassAndTask("stu-1", "class-1", "task-1"))
.thenReturn(null, new StudentTrainingAnswer());
StudentTrainingAnswer request = new StudentTrainingAnswer();
request.setCurrentStep(6);
request.setDynamicStepAnswers("{\"custom-1\":{\"content\":\"Supplemental research\",\"submitted\":false}}");
request.setSaveAction("SAVE");
service.save("new-product-survey", request, student());
ArgumentCaptor<StudentTrainingAnswer> record = ArgumentCaptor.forClass(StudentTrainingAnswer.class);
verify(service.studentTrainingAnswerMapper).insertSelective(record.capture());
assertEquals(6, record.getValue().getCurrentStep());
assertEquals(request.getDynamicStepAnswers(), record.getValue().getDynamicStepAnswers());
}
@Test
void resetClearsEveryStepAndSubmissionState() {
StudentTrainingAnswerServiceImpl service = buildService();

@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
@ -73,7 +74,9 @@ class TrainingTaskServiceImplTest {
assertEquals(0, errors.size());
assertEquals("Teacher Edited Survey", task.getTaskName());
assertEquals("[\"s1\",\"s2\",\"Step 3\",\"Step 4\"]", task.getSteps());
assertTrue(task.getSteps().contains("\"name\":\"s1\""));
assertTrue(task.getSteps().contains("\"name\":\"s2\""));
assertFalse(task.getSteps().contains("Step 3"));
}
@Test
@ -92,7 +95,7 @@ class TrainingTaskServiceImplTest {
}
@Test
void updateTaskPersistsTeacherEditedTaskNameAndFixedStepCount() {
void updateTaskPersistsTeacherEditedTaskNameAndActualStepCount() {
TrainingTaskServiceImpl service = new TrainingTaskServiceImpl();
service.trainingTaskMapper = mock(TrainingTaskMapper.class);
TrainingTask stored = storedTask("task-1", "new-product-survey");
@ -111,7 +114,28 @@ class TrainingTaskServiceImplTest {
assertEquals("new-product-survey", captor.getValue().getTaskKey());
assertEquals("Foundation", captor.getValue().getProjectName());
assertEquals("Teacher Edited Survey", captor.getValue().getTaskName());
assertEquals("[\"Only One\",\"Step 2\",\"Step 3\",\"Step 4\"]", captor.getValue().getSteps());
assertTrue(captor.getValue().getSteps().contains("\"name\":\"Only One\""));
assertFalse(captor.getValue().getSteps().contains("Step 2"));
}
@Test
void updateTaskPreservesStepsBeyondTheBuiltInCount() {
TrainingTaskServiceImpl service = new TrainingTaskServiceImpl();
service.trainingTaskMapper = mock(TrainingTaskMapper.class);
TrainingTask stored = storedTask("task-1", "new-product-survey");
when(service.trainingTaskMapper.selectByPrimaryKey("task-1")).thenReturn(stored);
TrainingTask payload = new TrainingTask();
payload.setProjectName("Foundation");
payload.setTaskKey("new-product-survey");
payload.setTaskName("Teacher Edited Survey");
payload.setSteps("[\"Step 1\",\"Step 2\",\"Step 3\",\"Step 4\",\"Custom Step\"]");
service.update("task-1", payload);
ArgumentCaptor<TrainingTask> captor = ArgumentCaptor.forClass(TrainingTask.class);
verify(service.trainingTaskMapper).updateByPrimaryKeySelective(captor.capture());
assertTrue(captor.getValue().getSteps().contains("Custom Step"));
}
@Test
@ -159,7 +183,8 @@ class TrainingTaskServiceImplTest {
verify(service.trainingTaskMapper).updateByPrimaryKeySelective(captor.capture());
assertEquals("task-1", captor.getValue().getId());
assertEquals("Teacher Edited Survey", captor.getValue().getTaskName());
assertEquals("[\"s1\",\"s2\",\"s3\",\"s4\"]", captor.getValue().getSteps());
assertTrue(captor.getValue().getSteps().contains("\"name\":\"s5\""));
assertTrue(captor.getValue().getSteps().contains("\"id\":\"custom-5\""));
}
@Test

Loading…
Cancel
Save