Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file modified exam-system-backend/run.bat
100644 → 100755
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ public interface StudentRepository extends JpaRepository<Student, Long> {
*/
boolean existsBySessionId(String sessionId);

/**
* 根據測驗 ID、Email 和姓名查詢學員
* 用於學員重新加入時找回原有 Session
*
* @param examId 測驗 ID
* @param email Email
* @param name 姓名
* @return 學員實體(Optional)
*/
Optional<Student> findByExamIdAndEmailAndName(Long examId, String email, String name);

/**
* 根據測驗 ID 統計各分數的學員數量
* 用於生成累積分數分布圖
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -87,35 +88,61 @@ public StudentDTO joinExam(StudentDTO studentDTO) {
}
}

// 生成唯一的 sessionId
String sessionId = UUID.randomUUID().toString();

// 建立學員實體
Student student = Student.builder()
.exam(exam)
.sessionId(sessionId)
.name(studentDTO.getName())
.email(studentDTO.getEmail())
.occupation(studentDTO.getOccupation())
.surveyData(studentDTO.getSurveyData())
.avatarIcon(studentDTO.getAvatarIcon())
.totalScore(0)
.build();

student = studentRepository.save(student);

log.info("Student joined successfully: {} (sessionId: {})", student.getName(), sessionId);

// 透過 WebSocket 通知講師有新學員加入
Map<String, Object> studentData = new HashMap<>();
studentData.put("id", student.getId());
studentData.put("name", student.getName());
studentData.put("avatarIcon", student.getAvatarIcon());
studentData.put("totalScore", student.getTotalScore());
studentData.put("correctAnswersCount", 0); // 新加入的學員答對題數為 0
studentData.put("totalStudents", studentRepository.countByExamId(exam.getId()));

webSocketService.broadcastStudentJoined(exam.getId(), WebSocketMessage.studentJoined(studentData));
// 檢查學員是否已存在(根據 Email 和姓名)
// 如果存在,則返回現有學員 Session,實現重新登入功能
Optional<Student> existingStudent = studentRepository.findByExamIdAndEmailAndName(
exam.getId(), studentDTO.getEmail(), studentDTO.getName());

Student student;
if (existingStudent.isPresent()) {
student = existingStudent.get();
log.info("Existing student rejoined: {} (sessionId: {})", student.getName(), student.getSessionId());

// 更新學員資訊(如頭像或調查資料可能變更)
boolean updated = false;
if (!student.getAvatarIcon().equals(studentDTO.getAvatarIcon())) {
student.setAvatarIcon(studentDTO.getAvatarIcon());
updated = true;
}
if (studentDTO.getSurveyData() != null && !studentDTO.getSurveyData().equals(student.getSurveyData())) {
student.setSurveyData(studentDTO.getSurveyData());
updated = true;
}

if (updated) {
student = studentRepository.save(student);
}
} else {
// 生成唯一的 sessionId
String sessionId = UUID.randomUUID().toString();

// 建立學員實體
student = Student.builder()
.exam(exam)
.sessionId(sessionId)
.name(studentDTO.getName())
.email(studentDTO.getEmail())
.occupation(studentDTO.getOccupation())
.surveyData(studentDTO.getSurveyData())
.avatarIcon(studentDTO.getAvatarIcon())
.totalScore(0)
.build();

student = studentRepository.save(student);

log.info("Student joined successfully: {} (sessionId: {})", student.getName(), sessionId);

// 透過 WebSocket 通知講師有新學員加入
Map<String, Object> studentData = new HashMap<>();
studentData.put("id", student.getId());
studentData.put("name", student.getName());
studentData.put("avatarIcon", student.getAvatarIcon());
studentData.put("totalScore", student.getTotalScore());
studentData.put("correctAnswersCount", 0); // 新加入的學員答對題數為 0
studentData.put("totalStudents", studentRepository.countByExamId(exam.getId()));

webSocketService.broadcastStudentJoined(exam.getId(), WebSocketMessage.studentJoined(studentData));
}

// 如果當前有正在進行的題目,推送給新加入的學員
if (exam.getCurrentQuestionStartedAt() != null && exam.getCurrentQuestionIndex() < exam.getQuestions().size()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.exam.system.exception.BusinessException;
import com.exam.system.exception.ResourceNotFoundException;
import com.exam.system.repository.ExamRepository;
import com.exam.system.repository.ExamSurveyFieldConfigRepository;
import com.exam.system.repository.StudentRepository;
import com.exam.system.websocket.WebSocketService;
import org.junit.jupiter.api.BeforeEach;
Expand All @@ -18,6 +19,7 @@
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Collections;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
Expand All @@ -42,6 +44,9 @@ class StudentServiceTest {
@Mock
private WebSocketService webSocketService;

@Mock
private ExamSurveyFieldConfigRepository examSurveyFieldConfigRepository;

@InjectMocks
private StudentService studentService;

Expand All @@ -62,6 +67,10 @@ void setUp() {
void testJoinExam_Success() {
// Given
when(examRepository.findByAccessCode("TEST01")).thenReturn(Optional.of(testExam));
when(examSurveyFieldConfigRepository.findByExamIdOrderByDisplayOrderAsc(anyLong())).thenReturn(Collections.emptyList());

when(studentRepository.findByExamIdAndEmailAndName(anyLong(), anyString(), anyString())).thenReturn(Optional.empty());

when(studentRepository.save(any(Student.class))).thenAnswer(invocation -> {
Student s = invocation.getArgument(0);
s.setId(1L);
Expand All @@ -86,6 +95,42 @@ void testJoinExam_Success() {
verify(webSocketService).broadcastStudentJoined(eq(1L), any());
}

@Test
@DisplayName("測試學員重新加入測驗 (Reconnect)")
void testJoinExam_Reconnect() {
// Given
when(examRepository.findByAccessCode("TEST01")).thenReturn(Optional.of(testExam));
when(examSurveyFieldConfigRepository.findByExamIdOrderByDisplayOrderAsc(anyLong())).thenReturn(Collections.emptyList());

// 模擬已存在的學員
Student existingStudent = TestDataBuilder.createStudent(testExam);
existingStudent.setId(1L);
existingStudent.setSessionId("existing-session-id");
existingStudent.setEmail(testStudentDTO.getEmail());
existingStudent.setName(testStudentDTO.getName());
existingStudent.setAvatarIcon("old-avatar");

testStudentDTO.setAvatarIcon("new-avatar"); // 模擬頭像變更

when(studentRepository.findByExamIdAndEmailAndName(eq(1L), eq(testStudentDTO.getEmail()), eq(testStudentDTO.getName())))
.thenReturn(Optional.of(existingStudent));

when(studentRepository.save(any(Student.class))).thenAnswer(invocation -> invocation.getArgument(0));

// When
StudentDTO result = studentService.joinExam(testStudentDTO);

// Then
assertThat(result).isNotNull();
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getSessionId()).isEqualTo("existing-session-id");
assertThat(result.getAvatarIcon()).isEqualTo("new-avatar"); // 確認頭像已更新

verify(studentRepository, times(1)).save(any(Student.class)); // 應該被調用一次來更新頭像
// 不應該廣播 join 訊息(因為是 reconnect)- 實際上現在的邏輯只在 else 區塊廣播,所以這裡應該 verify never
verify(webSocketService, never()).broadcastStudentJoined(anyLong(), any());
}

@Test
@DisplayName("測試學員加入測驗 - 無效的 accessCode")
void testJoinExam_InvalidAccessCode() {
Expand Down
17 changes: 16 additions & 1 deletion exam-system-frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions exam-system-frontend/src/pages/StudentExam.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,30 @@ export const StudentExam: React.FC = () => {
handleQuestionStarted
);

// 當 WebSocket 連線恢復時,重新獲取最新的學員狀態(包含當前題目)
// 這解決了斷線重連後可能錯過 WebSocket 推送的問題
console.log('[StudentExam] WebSocket 連線恢復,重新同步狀態');
studentApi.getStudent(sessionId)
.then(student => {
if (student.currentQuestion) {
console.log('[StudentExam] 重新同步獲取到當前題目:', student.currentQuestion);
setCurrentQuestion({
questionId: student.currentQuestion.questionId,
questionIndex: student.currentQuestion.questionIndex,
questionText: student.currentQuestion.questionText,
options: student.currentQuestion.options,
expiresAt: student.currentQuestion.expiresAt,
});
setExamStatus('STARTED');
} else {
// 如果後端沒回傳 currentQuestion,代表現在可能沒題目或已過期
// 但我們只在明確收到 null 時才清除,避免閃爍
}
})
.catch(err => {
console.error('[StudentExam] 重新同步狀態失敗:', err);
});

// 清理函式
return () => {
console.log('[StudentExam] 取消訂閱個人題目主題');
Expand Down