diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index dbd01f8..1fd35f3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -170,7 +170,11 @@ public enum ErrorCode { REPORT_REASON_REQUIRED(9607, "report reason is required"), CANNOT_BLOCK_SELF(9608, "cannot block yourself"), REPORT_RATE_LIMITED(9609, "report rate limited"), - BLOCKED_SEND_PROHIBITED(9610, "message cannot be sent"); + BLOCKED_SEND_PROHIBITED(9610, "message cannot be sent"), + + // 入职清单 97xx + ONBOARDING_CHECKLIST_ITEM_NOT_FOUND(9701, "onboarding checklist item not found"), + ONBOARDING_PROGRESS_CONCURRENT_UPDATE(9705, "onboarding progress was modified concurrently"); private final int code; private final String message; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index af6a7c5..5047798 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java @@ -76,7 +76,8 @@ public class GlobalExceptionHandler { || errorCode == ErrorCode.FEEDBACK_NOT_FOUND.getCode() || errorCode == ErrorCode.CONVERSATION_NOT_FOUND.getCode() || errorCode == ErrorCode.APPEAL_NOT_FOUND.getCode() - || errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; + || errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode() + || errorCode == ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; // 冲突 → 409 if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode() || errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode() @@ -163,6 +164,9 @@ public class GlobalExceptionHandler { || errorCode == ErrorCode.REPORT_INVALID_TRANSITION.getCode() || errorCode == ErrorCode.REPORT_IDEMPOTENCY_CONFLICT.getCode()) return HttpStatus.CONFLICT; + // 入职进度并发冲突 → 409 + if (errorCode == ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode()) + return HttpStatus.CONFLICT; // 举报限流 → 429 if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode()) return HttpStatus.TOO_MANY_REQUESTS; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java new file mode 100644 index 0000000..15c5cd2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java @@ -0,0 +1,67 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; +import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; +import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "入职清单", description = "学生读取有效入职清单并更新本人完成状态") +@RestController +@RequestMapping("/onboarding") +@RequiredArgsConstructor +public class OnboardingChecklistController { + + private final OnboardingChecklistService onboardingChecklistService; + + @Operation(summary = "读取有效入职清单", description = "返回通用项与企业活动自定义项的合并结果") + @GetMapping("/companies/{companyId}/checklist") + public ResponseEntity> getEffectiveChecklist( + @PathVariable Long companyId, + HttpServletRequest request) { + Long userId = resolveUserId(); + EffectiveChecklistResponse data = + onboardingChecklistService.getEffectiveChecklist(userId, companyId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "更新清单条目完成状态", description = "使用绝对状态语义设置当前学生的完成状态") + @PutMapping("/companies/{companyId}/checklist/items/{itemId}/completion") + public ResponseEntity> updateCompletion( + @PathVariable Long companyId, + @PathVariable Long itemId, + @Valid @RequestBody ProgressUpdateRequest body, + HttpServletRequest request) { + Long userId = resolveUserId(); + ProgressUpdateResponse data = onboardingChecklistService.updateCompletion( + userId, companyId, itemId, body); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + private Long resolveUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + return Long.valueOf(authentication.getName()); + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java new file mode 100644 index 0000000..71cb6f8 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java @@ -0,0 +1,244 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.onboarding.dto.ChecklistItemView; +import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; +import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; +import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class OnboardingChecklistService { + + private static final String STANDARD_SOURCE = "STANDARD"; + private static final String COMPANY_SOURCE = "COMPANY"; + + private final UserRepository userRepository; + private final CompanyRepository companyRepository; + private final OnboardingChecklistItemRepository checklistItemRepository; + private final StudentOnboardingProgressRepository progressRepository; + + @Transactional(readOnly = true) + public EffectiveChecklistResponse getEffectiveChecklist(Long userId, Long companyId) { + assertStudentRole(userId); + Company company = companyRepository.findById(companyId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + + List items = sortedItems( + checklistItemRepository.findEffectiveChecklistItems(companyId)); + List progressRows = loadProgress(userId, items); + return toEffectiveChecklistResponse(company, items, progressRows); + } + + @Transactional + public ProgressUpdateResponse updateCompletion( + Long userId, + Long companyId, + Long itemId, + ProgressUpdateRequest request) { + assertStudentRole(userId); + if (request == null || request.getCompleted() == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + companyRepository.findById(companyId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + + List items = sortedItems( + checklistItemRepository.findEffectiveChecklistItems(companyId)); + OnboardingChecklistItem item = items.stream() + .filter(candidate -> Objects.equals(candidate.getId(), itemId)) + .findFirst() + .orElseThrow(() -> new BusinessException( + ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND)); + + List progressRows = + new ArrayList<>(loadProgress(userId, items)); + StudentOnboardingProgress current = + progressRepository.findByStudentUserIdAndItemId(userId, itemId).orElse(null); + boolean desired = Boolean.TRUE.equals(request.getCompleted()); + + if (current != null) { + boolean actual = Boolean.TRUE.equals(current.getCompleted()); + if (actual != desired) { + if (!Objects.equals(request.getVersion(), current.getVersion())) { + throw new BusinessException( + ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE); + } + current.setCompleted(desired); + current.setCompletedAt(desired ? LocalDateTime.now() : null); + saveProgress(current); + } + } else if (desired) { + current = StudentOnboardingProgress.builder() + .studentUserId(userId) + .itemId(itemId) + .completed(true) + .completedAt(LocalDateTime.now()) + .build(); + progressRows.add(current); + saveProgress(current); + } + + return toProgressUpdateResponse(item, current, items, progressRows); + } + + private void assertStudentRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + boolean isStudent = user.getRoles() != null + && user.getRoles().stream().anyMatch(role -> role == User.Role.STUDENT); + if (!isStudent) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + private List sortedItems(List items) { + if (items == null || items.isEmpty()) { + return List.of(); + } + List sorted = new ArrayList<>(items); + sorted.sort(Comparator + .comparing( + OnboardingChecklistItem::getSortOrder, + Comparator.nullsLast(Integer::compareTo)) + .thenComparing( + OnboardingChecklistItem::getId, + Comparator.nullsLast(Long::compareTo))); + return sorted; + } + + private List loadProgress( + Long userId, List items) { + if (items.isEmpty()) { + return List.of(); + } + List itemIds = items.stream() + .map(OnboardingChecklistItem::getId) + .filter(Objects::nonNull) + .toList(); + if (itemIds.isEmpty()) { + return List.of(); + } + return progressRepository.findByStudentUserIdAndItemIdIn(userId, itemIds); + } + + private EffectiveChecklistResponse toEffectiveChecklistResponse( + Company company, + List items, + List progressRows) { + Map progressByItemId = progressRows.stream() + .collect(Collectors.toMap( + StudentOnboardingProgress::getItemId, + Function.identity(), + (first, ignored) -> first)); + + List views = items.stream() + .map(item -> toChecklistItemView(item, progressByItemId.get(item.getId()))) + .toList(); + + int totalItems = views.size(); + int completedItems = completedCount(items, progressRows); + return EffectiveChecklistResponse.builder() + .companyId(company.getId()) + .companyName(company.getName()) + .customized(items.stream() + .anyMatch(item -> COMPANY_SOURCE.equals(item.getScopeType()))) + .totalItems(totalItems) + .completedItems(completedItems) + .completionRate(completionRate(totalItems, completedItems)) + .items(views) + .build(); + + } + + private ProgressUpdateResponse toProgressUpdateResponse( + OnboardingChecklistItem item, + StudentOnboardingProgress progress, + List items, + List progressRows) { + int totalItems = items.size(); + int completedItems = completedCount(items, progressRows); + boolean completed = progress != null && Boolean.TRUE.equals(progress.getCompleted()); + return ProgressUpdateResponse.builder() + .itemId(item.getId()) + .completed(completed) + .completedAt(completed && progress != null ? progress.getCompletedAt() : null) + .progressVersion(progress == null || progress.getVersion() == null + ? 0L : progress.getVersion()) + .totalItems(totalItems) + .completedItems(completedItems) + .completionRate(completionRate(totalItems, completedItems)) + .build(); + } + + private ChecklistItemView toChecklistItemView( + OnboardingChecklistItem item, + StudentOnboardingProgress progress) { + boolean completed = progress != null && Boolean.TRUE.equals(progress.getCompleted()); + return ChecklistItemView.builder() + .itemId(item.getId()) + .source(STANDARD_SOURCE.equals(item.getScopeType()) + ? STANDARD_SOURCE : COMPANY_SOURCE) + .itemKey(item.getItemKey()) + .title(item.getTitle()) + .description(item.getDescription()) + .category(item.getCategory()) + .required(item.getRequired()) + .sortOrder(item.getSortOrder()) + .active(item.getActive()) + .completed(completed) + .completedAt(completed && progress != null ? progress.getCompletedAt() : null) + .progressVersion(progress == null || progress.getVersion() == null + ? 0L : progress.getVersion()) + .build(); + } + + private int completedCount( + List items, + List progressRows) { + var effectiveItemIds = items.stream() + .map(OnboardingChecklistItem::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + return (int) progressRows.stream() + .filter(progress -> effectiveItemIds.contains(progress.getItemId())) + .filter(progress -> Boolean.TRUE.equals(progress.getCompleted())) + .count(); + } + + private double completionRate(int totalItems, int completedItems) { + if (totalItems == 0) { + return 0.0; + } + double rawRate = completedItems * 100.0 / totalItems; + return Math.min(100.0, Math.round(rawRate * 10.0) / 10.0); + } + + private void saveProgress(StudentOnboardingProgress progress) { + try { + progressRepository.saveAndFlush(progress); + } catch (ObjectOptimisticLockingFailureException ex) { + throw new BusinessException( + ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE, ex); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java new file mode 100644 index 0000000..a59950c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java @@ -0,0 +1,28 @@ +package com.mydreamjob.onboarding.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChecklistItemView { + + private Long itemId; + private String source; + private String itemKey; + private String title; + private String description; + private String category; + private Boolean required; + private Integer sortOrder; + private Boolean active; + private Boolean completed; + private LocalDateTime completedAt; + private Long progressVersion; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java new file mode 100644 index 0000000..a1d0837 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java @@ -0,0 +1,23 @@ +package com.mydreamjob.onboarding.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EffectiveChecklistResponse { + + private Long companyId; + private String companyName; + private Boolean customized; + private Integer totalItems; + private Integer completedItems; + private Double completionRate; + private List items; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java new file mode 100644 index 0000000..752fb2c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java @@ -0,0 +1,15 @@ +package com.mydreamjob.onboarding.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class ProgressUpdateRequest { + + @NotNull(message = "completed is required") + private Boolean completed; + + @Min(value = 0, message = "version must not be negative") + private Long version; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java new file mode 100644 index 0000000..7dfcd9f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java @@ -0,0 +1,23 @@ +package com.mydreamjob.onboarding.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProgressUpdateResponse { + + private Long itemId; + private Boolean completed; + private LocalDateTime completedAt; + private Long progressVersion; + private Integer totalItems; + private Integer completedItems; + private Double completionRate; +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistControllerTest.java new file mode 100644 index 0000000..d8201bf --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistControllerTest.java @@ -0,0 +1,166 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.onboarding.dto.ChecklistItemView; +import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; +import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; +import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; +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.data.redis.core.RedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +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("integration-test") +class OnboardingChecklistControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @MockBean private OnboardingChecklistService onboardingChecklistService; + @MockBean private RedisTemplate redisTemplate; + + @Test + void getChecklist_shouldReturn200WithStableProgressContract() throws Exception { + when(onboardingChecklistService.getEffectiveChecklist(100L, 7L)) + .thenReturn(effectiveChecklistResponse()); + + mockMvc.perform(get("/onboarding/companies/7/checklist") + .header("Authorization", token()) + .header("X-Request-Id", "req-t3-get")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.requestId").value("req-t3-get")) + .andExpect(jsonPath("$.data.companyId").value(7)) + .andExpect(jsonPath("$.data.companyName").value("示例企业")) + .andExpect(jsonPath("$.data.customized").value(true)) + .andExpect(jsonPath("$.data.totalItems").value(1)) + .andExpect(jsonPath("$.data.completedItems").value(1)) + .andExpect(jsonPath("$.data.completionRate").value(100.0)) + .andExpect(jsonPath("$.data.items[0].itemId").value(11)) + .andExpect(jsonPath("$.data.items[0].source").value("STANDARD")) + .andExpect(jsonPath("$.data.items[0].progressVersion").value(2)); + + verify(onboardingChecklistService).getEffectiveChecklist(100L, 7L); + } + + @Test + void updateCompletion_shouldReturn200WithRequestIdentityAndLatestRate() throws Exception { + when(onboardingChecklistService.updateCompletion( + eq(100L), eq(7L), eq(11L), any(ProgressUpdateRequest.class))) + .thenReturn(ProgressUpdateResponse.builder() + .itemId(11L) + .completed(true) + .completedAt(LocalDateTime.of(2026, 9, 16, 10, 30)) + .progressVersion(0L) + .totalItems(1) + .completedItems(1) + .completionRate(100.0) + .build()); + + mockMvc.perform(put("/onboarding/companies/7/checklist/items/11/completion") + .header("Authorization", token()) + .header("X-Request-Id", "req-t3-put") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"completed\":true,\"version\":0}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.requestId").value("req-t3-put")) + .andExpect(jsonPath("$.data.itemId").value(11)) + .andExpect(jsonPath("$.data.completed").value(true)) + .andExpect(jsonPath("$.data.progressVersion").value(0)) + .andExpect(jsonPath("$.data.totalItems").value(1)) + .andExpect(jsonPath("$.data.completedItems").value(1)) + .andExpect(jsonPath("$.data.completionRate").value(100.0)); + + verify(onboardingChecklistService).updateCompletion( + eq(100L), + eq(7L), + eq(11L), + argThat(request -> Boolean.TRUE.equals(request.getCompleted()) + && Long.valueOf(0L).equals(request.getVersion()))); + } + + @Test + void getChecklist_withoutToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/onboarding/companies/7/checklist")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void getChecklist_nonStudent_shouldReturn403() throws Exception { + when(onboardingChecklistService.getEffectiveChecklist(100L, 7L)) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + + mockMvc.perform(get("/onboarding/companies/7/checklist") + .header("Authorization", token())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + } + + @Test + void updateCompletion_companyMissing_shouldReturn404() throws Exception { + when(onboardingChecklistService.updateCompletion( + eq(100L), eq(7L), eq(11L), any(ProgressUpdateRequest.class))) + .thenThrow(new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + + mockMvc.perform(put("/onboarding/companies/7/checklist/items/11/completion") + .header("Authorization", token()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"completed\":true,\"version\":0}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND.getCode())); + } + + private String token() { + return "Bearer " + jwtTokenProvider.generateAccessToken( + 100L, "1", Map.of("roles", "STUDENT")); + } + + private EffectiveChecklistResponse effectiveChecklistResponse() { + return EffectiveChecklistResponse.builder() + .companyId(7L) + .companyName("示例企业") + .customized(true) + .totalItems(1) + .completedItems(1) + .completionRate(100.0) + .items(List.of(ChecklistItemView.builder() + .itemId(11L) + .source("STANDARD") + .itemKey("standard-onboard") + .title("准备入职材料") + .description("身份证和银行卡") + .category("DOCUMENT") + .required(true) + .sortOrder(10) + .active(true) + .completed(true) + .completedAt(LocalDateTime.of(2026, 9, 16, 10, 30)) + .progressVersion(2L) + .build())) + .build(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistServiceTest.java new file mode 100644 index 0000000..209d121 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistServiceTest.java @@ -0,0 +1,305 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; +import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; +import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OnboardingChecklistServiceTest { + + private static final Long STUDENT_ID = 100L; + private static final Long COMPANY_ID = 7L; + private static final LocalDateTime COMPLETED_AT = LocalDateTime.of(2026, 9, 16, 10, 30); + + @Mock private UserRepository userRepository; + @Mock private CompanyRepository companyRepository; + @Mock private OnboardingChecklistItemRepository checklistItemRepository; + @Mock private StudentOnboardingProgressRepository progressRepository; + @InjectMocks private OnboardingChecklistService service; + + @Test + void getEffectiveChecklist_withoutCompanyItems_shouldReturnStandardFallback() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student())); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + OnboardingChecklistItem standard = item(1L, "STANDARD", null, "standard-onboard", 10); + when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID)) + .thenReturn(List.of(standard)); + when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection())) + .thenReturn(List.of()); + + EffectiveChecklistResponse response = service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID); + + assertThat(response.getCompanyId()).isEqualTo(COMPANY_ID); + assertThat(response.getCompanyName()).isEqualTo("示例企业"); + assertThat(response.getCustomized()).isFalse(); + assertThat(response.getTotalItems()).isEqualTo(1); + assertThat(response.getCompletedItems()).isZero(); + assertThat(response.getCompletionRate()).isEqualTo(0.0); + assertThat(response.getItems()).singleElement().satisfies(view -> { + assertThat(view.getItemId()).isEqualTo(1L); + assertThat(view.getSource()).isEqualTo("STANDARD"); + assertThat(view.getItemKey()).isEqualTo("standard-onboard"); + assertThat(view.getCompleted()).isFalse(); + assertThat(view.getCompletedAt()).isNull(); + assertThat(view.getProgressVersion()).isZero(); + }); + } + + @Test + void getEffectiveChecklist_withCompanyItems_shouldMergeSortAndComputeProgress() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student())); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + OnboardingChecklistItem standardSecond = item(2L, "STANDARD", null, "standard-second", 10); + OnboardingChecklistItem companyFirst = item(3L, "COMPANY", COMPANY_ID, "company-first", 10); + OnboardingChecklistItem standardThird = item(1L, "STANDARD", null, "standard-third", 20); + when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID)) + .thenReturn(List.of(standardThird, companyFirst, standardSecond)); + + StudentOnboardingProgress completed = progress(3L, true, COMPLETED_AT, 4L); + StudentOnboardingProgress orphaned = progress(99L, true, COMPLETED_AT, 8L); + when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection())) + .thenReturn(List.of(completed, orphaned)); + + EffectiveChecklistResponse response = service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID); + + assertThat(response.getCustomized()).isTrue(); + assertThat(response.getTotalItems()).isEqualTo(3); + assertThat(response.getCompletedItems()).isEqualTo(1); + assertThat(response.getCompletionRate()).isEqualTo(33.3); + assertThat(response.getItems()).extracting(view -> view.getItemId()) + .containsExactly(2L, 3L, 1L); + assertThat(response.getItems().get(1).getSource()).isEqualTo("COMPANY"); + assertThat(response.getItems().get(1).getCompleted()).isTrue(); + assertThat(response.getItems().get(1).getCompletedAt()).isEqualTo(COMPLETED_AT); + assertThat(response.getItems().get(1).getProgressVersion()).isEqualTo(4L); + } + + @Test + void getEffectiveChecklist_empty_shouldReturnZeroRate() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student())); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID)).thenReturn(List.of()); + + EffectiveChecklistResponse response = service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID); + + assertThat(response.getItems()).isEmpty(); + assertThat(response.getTotalItems()).isZero(); + assertThat(response.getCompletedItems()).isZero(); + assertThat(response.getCompletionRate()).isEqualTo(0.0); + assertThat(response.getCustomized()).isFalse(); + } + + @Test + void getEffectiveChecklist_companyMissing_shouldThrowCompanyNotFound() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student())); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.COMPANY_NOT_FOUND.getCode()); + verifyNoInteractions(checklistItemRepository, progressRepository); + } + + @Test + void getEffectiveChecklist_nonStudent_shouldThrowForbidden() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of( + User.builder().id(STUDENT_ID).roles(Set.of(User.Role.COMPANY)).build())); + + assertThatThrownBy(() -> service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.FORBIDDEN.getCode()); + verifyNoInteractions(companyRepository, checklistItemRepository, progressRepository); + } + + @Test + void updateCompletion_firstCompletion_shouldCreateProgressAndReturnRate() { + stubAccessAndOneItem(); + when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection())) + .thenReturn(new ArrayList<>()); + when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L)) + .thenReturn(Optional.empty()); + when(progressRepository.saveAndFlush(any(StudentOnboardingProgress.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + ProgressUpdateResponse response = service.updateCompletion( + STUDENT_ID, COMPANY_ID, 11L, request(true, 0L)); + + assertThat(response.getItemId()).isEqualTo(11L); + assertThat(response.getCompleted()).isTrue(); + assertThat(response.getCompletedAt()).isNotNull(); + assertThat(response.getProgressVersion()).isZero(); + assertThat(response.getCompletedItems()).isEqualTo(1); + assertThat(response.getTotalItems()).isEqualTo(1); + assertThat(response.getCompletionRate()).isEqualTo(100.0); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(StudentOnboardingProgress.class); + verify(progressRepository).saveAndFlush(captor.capture()); + assertThat(captor.getValue().getStudentUserId()).isEqualTo(STUDENT_ID); + assertThat(captor.getValue().getItemId()).isEqualTo(11L); + assertThat(captor.getValue().getCompleted()).isTrue(); + assertThat(captor.getValue().getCompletedAt()).isNotNull(); + } + + @Test + void updateCompletion_sameStateWithStaleVersion_shouldBeIdempotent() { + stubAccessAndOneItem(); + StudentOnboardingProgress existing = progress(11L, true, COMPLETED_AT, 5L); + when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection())) + .thenReturn(List.of(existing)); + when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L)) + .thenReturn(Optional.of(existing)); + + ProgressUpdateResponse response = service.updateCompletion( + STUDENT_ID, COMPANY_ID, 11L, request(true, 2L)); + + assertThat(response.getCompleted()).isTrue(); + assertThat(response.getCompletedAt()).isEqualTo(COMPLETED_AT); + assertThat(response.getProgressVersion()).isEqualTo(5L); + assertThat(response.getCompletionRate()).isEqualTo(100.0); + verify(progressRepository, never()).saveAndFlush(any()); + } + + @Test + void updateCompletion_differentStateWithStaleVersion_shouldReturnConcurrentUpdate() { + stubAccessAndOneItem(); + StudentOnboardingProgress existing = progress(11L, true, COMPLETED_AT, 5L); + when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection())) + .thenReturn(List.of(existing)); + when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L)) + .thenReturn(Optional.of(existing)); + + assertThatThrownBy(() -> service.updateCompletion( + STUDENT_ID, COMPANY_ID, 11L, request(false, 4L))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode()); + assertThat(existing.getCompleted()).isTrue(); + assertThat(existing.getCompletedAt()).isEqualTo(COMPLETED_AT); + verify(progressRepository, never()).saveAndFlush(any()); + } + + @Test + void updateCompletion_cancel_shouldClearCompletedAt() { + stubAccessAndOneItem(); + StudentOnboardingProgress existing = progress(11L, true, COMPLETED_AT, 5L); + when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection())) + .thenReturn(List.of(existing)); + when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L)) + .thenReturn(Optional.of(existing)); + when(progressRepository.saveAndFlush(any(StudentOnboardingProgress.class))) + .thenAnswer(invocation -> { + StudentOnboardingProgress saved = invocation.getArgument(0); + saved.setVersion(6L); + return saved; + }); + + ProgressUpdateResponse response = service.updateCompletion( + STUDENT_ID, COMPANY_ID, 11L, request(false, 5L)); + + assertThat(response.getCompleted()).isFalse(); + assertThat(response.getCompletedAt()).isNull(); + assertThat(response.getProgressVersion()).isEqualTo(6L); + assertThat(response.getCompletedItems()).isZero(); + assertThat(response.getCompletionRate()).isEqualTo(0.0); + assertThat(existing.getCompleted()).isFalse(); + assertThat(existing.getCompletedAt()).isNull(); + verify(progressRepository).saveAndFlush(existing); + } + + @Test + void updateCompletion_itemOutsideCompanyEffectiveChecklist_shouldReturnNotFound() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student())); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID)) + .thenReturn(List.of(item(11L, "COMPANY", COMPANY_ID, "company-item", 10))); + + assertThatThrownBy(() -> service.updateCompletion( + STUDENT_ID, COMPANY_ID, 999L, request(true, 0L))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()); + verifyNoInteractions(progressRepository); + } + + private void stubAccessAndOneItem() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student())); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID)) + .thenReturn(List.of(item(11L, "STANDARD", null, "standard-onboard", 10))); + } + + private User student() { + return User.builder().id(STUDENT_ID).roles(Set.of(User.Role.STUDENT)).build(); + } + + private Company company() { + return Company.builder().id(COMPANY_ID).name("示例企业").ownerUserId(900L).build(); + } + + private OnboardingChecklistItem item( + Long id, String scopeType, Long companyId, String itemKey, int sortOrder) { + return OnboardingChecklistItem.builder() + .id(id) + .scopeType(scopeType) + .scopeId(companyId == null ? 0L : companyId) + .companyId(companyId) + .itemKey(itemKey) + .title(itemKey + "-title") + .description(itemKey + "-description") + .category("OTHER") + .sortOrder(sortOrder) + .required(false) + .active(true) + .build(); + } + + private StudentOnboardingProgress progress( + Long itemId, boolean completed, LocalDateTime completedAt, Long version) { + return StudentOnboardingProgress.builder() + .id(itemId * 10) + .studentUserId(STUDENT_ID) + .itemId(itemId) + .completed(completed) + .completedAt(completedAt) + .version(version) + .build(); + } + + private ProgressUpdateRequest request(boolean completed, Long version) { + ProgressUpdateRequest request = new ProgressUpdateRequest(); + request.setCompleted(completed); + request.setVersion(version); + return request; + } +}