diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index fc11d84..0394c90 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -74,6 +74,8 @@ public class SecurityConfig { .requestMatchers("/auth/register").permitAll() // 当前用户 .requestMatchers("/auth/me").authenticated() + // 行业技能词云(需登录,仅 STUDENT,Controller 层校验角色) + .requestMatchers(HttpMethod.GET, "/skills/**").authenticated() // 企业资料(需登录) .requestMatchers("/companies/me/**").authenticated() // 企业公开岗位列表(匿名 GET) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java new file mode 100644 index 0000000..9f5ad94 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java @@ -0,0 +1,204 @@ +package com.mydreamjob.skill; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.profile.ProfileService; +import com.mydreamjob.skill.dto.SkillCloudResponse; +import com.mydreamjob.skill.dto.SkillLearningPathResponse; +import com.mydreamjob.skill.dto.SkillMatchResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 行业技能词云、个人匹配度与学习路径接口。 + */ +@Tag(name = "行业技能词云", description = "按行业、职位和技能分类查询热门技能、个人匹配度与学习路径") +@RestController +@Validated +@RequiredArgsConstructor +public class SkillCloudController { + + private final SkillCloudQueryService skillCloudQueryService; + private final SkillMatchService skillMatchService; + private final SkillLearningPathService skillLearningPathService; + private final ProfileService profileService; + + @Operation( + summary = "查询行业技能词云", + description = "返回当前筛选范围内的全部技能,按热度降序排列;前端取前十条作为 TOP10。仅 STUDENT 可访问。" + ) + @SecurityRequirement(name = "bearerAuth") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "成功返回筛选范围内的全部技能", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "success", + value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"industry\":{\"id\":1,\"code\":\"INTERNET\",\"name\":\"互联网\"},\"occupation\":{\"id\":1,\"code\":\"FRONTEND_DEVELOPER\",\"name\":\"前端开发工程师\"},\"category\":null,\"total\":1,\"items\":[{\"rank\":1,\"skillId\":26,\"skillCode\":\"REACT\",\"nameZh\":\"React\",\"nameEn\":\"React\",\"sourceType\":\"O*NET\",\"sourceCode\":\"15-1254.00|React|2.E.7.h\",\"categoryId\":103,\"categoryName\":\"前端框架\",\"heat\":4.38,\"heatSourceCode\":\"15-1254.00\",\"heatElementId\":\"2.E.7.h\",\"heatProxy\":true}]}}" + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", description = "筛选参数缺失、非正整数或节点关系非法", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", description = "未登录", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", description = "已登录但不是 STUDENT 角色", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + @GetMapping("/skills/word-cloud") + public ResponseEntity> getWordCloud( + @Parameter(description = "行业 ID,必填正整数", required = true, example = "1") + @Positive(message = "industryId must be positive") + @RequestParam Long industryId, + @Parameter(description = "职位 ID,必填正整数", required = true, example = "1") + @Positive(message = "occupationId must be positive") + @RequestParam Long occupationId, + @Parameter(description = "技能分类 ID,可选正整数;省略时返回职位下全部分类", required = false, example = "101") + @Positive(message = "categoryId must be positive") + @RequestParam(required = false) Long categoryId, + HttpServletRequest request) { + Long userId = resolveAuthenticatedStudent(); + SkillCloudResponse data = skillCloudQueryService.query(industryId, occupationId, categoryId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation( + summary = "查询个人技能匹配度", + description = "使用当前 STUDENT 的个人技能,与筛选结果热度 TOP10 比对并返回命中、缺失和匹配度。仅 STUDENT 可访问。" + ) + @SecurityRequirement(name = "bearerAuth") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "成功返回个人技能匹配结果", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "success", + value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"matchScore\":50,\"matchedCount\":1,\"totalCount\":2,\"matchedSkills\":[{\"rank\":1,\"skillId\":26,\"skillCode\":\"REACT\",\"nameZh\":\"React\",\"nameEn\":\"React\",\"sourceType\":\"O*NET\",\"sourceCode\":\"15-1254.00|React|2.E.7.h\",\"categoryId\":103,\"categoryName\":\"前端框架\",\"heat\":4.38,\"heatSourceCode\":\"15-1254.00\",\"heatElementId\":\"2.E.7.h\",\"heatProxy\":true}],\"missingSkills\":[{\"rank\":2,\"skillId\":31,\"skillCode\":\"DOCKER\",\"nameZh\":\"Docker\",\"nameEn\":\"Docker\",\"sourceType\":\"O*NET\",\"sourceCode\":\"15-1299.08|Docker|2.E.7.h\",\"categoryId\":105,\"categoryName\":\"工程化工具\",\"heat\":4.12,\"heatSourceCode\":\"15-1299.08\",\"heatElementId\":\"2.E.7.h\",\"heatProxy\":true}]}}" + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", description = "筛选参数缺失、非正整数或节点关系非法", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", description = "未登录", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", description = "已登录但不是 STUDENT 角色", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + @GetMapping("/skills/match") + public ResponseEntity> getMatch( + @Parameter(description = "行业 ID,必填正整数", required = true, example = "1") + @Positive(message = "industryId must be positive") + @RequestParam Long industryId, + @Parameter(description = "职位 ID,必填正整数", required = true, example = "1") + @Positive(message = "occupationId must be positive") + @RequestParam Long occupationId, + @Parameter(description = "技能分类 ID,可选正整数;省略时使用职位下全部技能", required = false, example = "101") + @Positive(message = "categoryId must be positive") + @RequestParam(required = false) Long categoryId, + HttpServletRequest request) { + Long userId = resolveAuthenticatedStudent(); + SkillMatchResponse data = skillMatchService.match(userId, industryId, occupationId, categoryId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation( + summary = "生成个人学习路径", + description = "仅针对个人 TOP10 中未命中的技能,按热度顺序返回优先级和通用学习建议。仅 STUDENT 可访问。" + ) + @SecurityRequirement(name = "bearerAuth") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "成功返回缺失技能的学习路径;没有缺口时列表为空", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "success", + value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"total\":1,\"items\":[{\"priority\":1,\"skillId\":26,\"skillName\":\"React\",\"categoryId\":103,\"categoryName\":\"前端框架\",\"suggestion\":\"先理解 React 的核心概念与生命周期,再通过一个小型项目练习集成。\"}]}}" + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", description = "筛选参数缺失、非正整数或节点关系非法", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", description = "未登录", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", description = "已登录但不是 STUDENT 角色", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + @GetMapping("/skills/learning-path") + public ResponseEntity> getLearningPath( + @Parameter(description = "行业 ID,必填正整数", required = true, example = "1") + @Positive(message = "industryId must be positive") + @RequestParam Long industryId, + @Parameter(description = "职位 ID,必填正整数", required = true, example = "1") + @Positive(message = "occupationId must be positive") + @RequestParam Long occupationId, + @Parameter(description = "技能分类 ID,可选正整数;省略时使用职位下全部技能", required = false, example = "101") + @Positive(message = "categoryId must be positive") + @RequestParam(required = false) Long categoryId, + HttpServletRequest request) { + Long userId = resolveAuthenticatedStudent(); + SkillLearningPathResponse data = skillLearningPathService.generate( + userId, industryId, occupationId, categoryId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + private Long resolveAuthenticatedStudent() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null + || !authentication.isAuthenticated() + || "anonymousUser".equals(authentication.getName())) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + try { + Long userId = Long.valueOf(authentication.getName()); + profileService.assertStudentRole(userId); + return userId; + } catch (NumberFormatException exception) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudAuthorizationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudAuthorizationIntegrationTest.java new file mode 100644 index 0000000..d9ef3a4 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudAuthorizationIntegrationTest.java @@ -0,0 +1,212 @@ +package com.mydreamjob.skill; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.skill.dto.SkillCloudItem; +import com.mydreamjob.skill.dto.SkillCloudNode; +import com.mydreamjob.skill.dto.SkillCloudResponse; +import com.mydreamjob.skill.dto.SkillLearningPathItem; +import com.mydreamjob.skill.dto.SkillLearningPathResponse; +import com.mydreamjob.skill.dto.SkillMatchResponse; +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.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +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.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:skill_controller_auth;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class SkillCloudAuthorizationIntegrationTest { + + private static final String WORD_CLOUD_PATH = "/skills/word-cloud"; + private static final String MATCH_PATH = "/skills/match"; + private static final String LEARNING_PATH = "/skills/learning-path"; + private static final List PATHS = List.of(WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH); + private static final AtomicLong PHONE = new AtomicLong(920_000_000L); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Autowired + private UserRepository userRepository; + + @MockBean + private SkillCloudQueryService skillCloudQueryService; + + @MockBean + private SkillMatchService skillMatchService; + + @MockBean + private SkillLearningPathService skillLearningPathService; + + @MockBean + private RedisTemplate redisTemplate; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations operations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(operations); + when(redisTemplate.hasKey(any())).thenReturn(false); + + when(skillCloudQueryService.query(1L, 1L, null)).thenReturn(cloudResponse()); + when(skillMatchService.match(anyLong(), eq(1L), eq(1L), isNull())).thenReturn(matchResponse()); + when(skillLearningPathService.generate(anyLong(), eq(1L), eq(1L), isNull())) + .thenReturn(learningPathResponse()); + } + + @AfterEach + void tearDown() { + userRepository.deleteAll(); + } + + @Test + void databaseRoleSetsControlAllSkillEndpoints() throws Exception { + Long student = createUser(Set.of(User.Role.STUDENT)); + Long company = createUser(Set.of(User.Role.COMPANY)); + Long admin = createUser(Set.of(User.Role.ADMIN)); + Long studentCompany = createUser(Set.of(User.Role.STUDENT, User.Role.COMPANY)); + Long companyAdmin = createUser(Set.of(User.Role.COMPANY, User.Role.ADMIN)); + + for (String path : PATHS) { + assertStatus(path, student, 200); + assertStatus(path, company, 403); + assertStatus(path, admin, 403); + assertStatus(path, studentCompany, 200); + assertStatus(path, companyAdmin, 403); + } + } + + @Test + void identityQueryParametersCannotOverrideAuthenticatedSubject() throws Exception { + Long student = createUser(Set.of(User.Role.STUDENT)); + Long company = createUser(Set.of(User.Role.COMPANY)); + + for (String path : PATHS) { + mockMvc.perform(get(path) + .param("industryId", "1") + .param("occupationId", "1") + .param("userId", company.toString()) + .param("studentId", company.toString()) + .header("Authorization", bearerToken(student))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + verify(skillCloudQueryService).query(1L, 1L, null); + verify(skillMatchService).match(student, 1L, 1L, null); + verify(skillLearningPathService).generate(student, 1L, 1L, null); + } + + private void assertStatus(String path, Long userId, int expectedStatus) throws Exception { + var request = get(path) + .param("industryId", "1") + .param("occupationId", "1") + .header("Authorization", bearerToken(userId)); + if (expectedStatus == 200) { + mockMvc.perform(request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } else { + mockMvc.perform(request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + } + + private Long createUser(Set roles) { + User user = User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .role(roles.iterator().next()) + .roles(Set.copyOf(roles)) + .active(true) + .verified(true) + .build(); + return userRepository.save(user).getId(); + } + + private String bearerToken(Long userId) { + return "Bearer " + jwtTokenProvider.generateAccessToken( + userId, + String.valueOf(userId), + Map.of("roles", "STUDENT") + ); + } + + private SkillCloudResponse cloudResponse() { + return new SkillCloudResponse( + new SkillCloudNode(1L, "INTERNET", "互联网"), + new SkillCloudNode(1L, "FRONTEND_DEVELOPER", "前端开发工程师"), + null, + 1, + List.of(skill()) + ); + } + + private SkillMatchResponse matchResponse() { + return new SkillMatchResponse(100, 1, 1, List.of(skill()), List.of()); + } + + private SkillLearningPathResponse learningPathResponse() { + return new SkillLearningPathResponse( + 1, + List.of(new SkillLearningPathItem( + 1, + 3L, + "JavaScript", + 101L, + "编程语言", + "先掌握 JavaScript 的基础语法和常见数据结构,再完成至少一个编码练习。" + )) + ); + } + + private SkillCloudItem skill() { + return new SkillCloudItem( + 1, + 3L, + "JAVASCRIPT", + "JavaScript", + "JavaScript", + "ESCO", + "http://data.europa.eu/esco/skill/javascript", + 101L, + "编程语言", + new BigDecimal("4.38"), + "15-1254.00", + "2.B.3.e", + false + ); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java new file mode 100644 index 0000000..bd67449 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java @@ -0,0 +1,389 @@ +package com.mydreamjob.skill; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.skill.dto.SkillCloudItem; +import com.mydreamjob.skill.dto.SkillCloudNode; +import com.mydreamjob.skill.dto.SkillCloudResponse; +import com.mydreamjob.skill.dto.SkillLearningPathItem; +import com.mydreamjob.skill.dto.SkillLearningPathResponse; +import com.mydreamjob.skill.dto.SkillMatchResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +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.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +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.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:skill_controller_api;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class SkillCloudControllerTest { + + private static final String WORD_CLOUD_PATH = "/skills/word-cloud"; + private static final String MATCH_PATH = "/skills/match"; + private static final String LEARNING_PATH = "/skills/learning-path"; + private static final AtomicLong PHONE = new AtomicLong(910_000_000L); + + private Long studentId; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + + @Autowired + private UserRepository userRepository; + + @MockBean + private SkillCloudQueryService skillCloudQueryService; + + @MockBean + private SkillMatchService skillMatchService; + + @MockBean + private SkillLearningPathService skillLearningPathService; + + @MockBean + private RedisTemplate redisTemplate; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations operations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(operations); + when(redisTemplate.hasKey(any())).thenReturn(false); + + studentId = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + + when(skillCloudQueryService.query(1L, 1L, null)).thenReturn(cloudResponse()); + when(skillMatchService.match(studentId, 1L, 1L, null)).thenReturn(matchResponse()); + when(skillLearningPathService.generate(studentId, 1L, 1L, null)).thenReturn(learningPathResponse()); + } + + @AfterEach + void tearDown() { + userRepository.deleteAll(); + } + + @Test + void wordCloudStudentRequestReturnsServicePayload() throws Exception { + mockMvc.perform(get(WORD_CLOUD_PATH) + .param("industryId", "1") + .param("occupationId", "1") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.industry.code").value("INTERNET")) + .andExpect(jsonPath("$.data.occupation.code").value("FRONTEND_DEVELOPER")) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.items[0].skillId").value(3)) + .andExpect(jsonPath("$.data.items[0].nameZh").value("JavaScript")) + .andExpect(jsonPath("$.data.items[0].sourceType").value("ESCO")); + } + + @Test + void matchStudentRequestReturnsServicePayload() throws Exception { + mockMvc.perform(get(MATCH_PATH) + .param("industryId", "1") + .param("occupationId", "1") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.matchScore").value(100)) + .andExpect(jsonPath("$.data.matchedCount").value(1)) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.matchedSkills[0].skillId").value(3)) + .andExpect(jsonPath("$.data.missingSkills").isEmpty()); + } + + @Test + void learningPathStudentRequestReturnsServicePayload() throws Exception { + mockMvc.perform(get(LEARNING_PATH) + .param("industryId", "1") + .param("occupationId", "1") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.items[0].priority").value(1)) + .andExpect(jsonPath("$.data.items[0].skillId").value(3)) + .andExpect(jsonPath("$.data.items[0].suggestion") + .value("先掌握 JavaScript 的基础语法和常见数据结构,再完成至少一个编码练习。")); + } + + @ParameterizedTest + @ValueSource(strings = {WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH}) + void anonymousRequestIsUnauthorized(String path) throws Exception { + mockMvc.perform(get(path) + .param("industryId", "1") + .param("occupationId", "1")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + + @Test + void missingRequiredParametersReturnBadRequest() throws Exception { + mockMvc.perform(get(WORD_CLOUD_PATH) + .param("occupationId", "1") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + mockMvc.perform(get(WORD_CLOUD_PATH) + .param("industryId", "1") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + @Test + void nonPositiveParametersReturnBadRequest() throws Exception { + mockMvc.perform(get(WORD_CLOUD_PATH) + .param("industryId", "0") + .param("occupationId", "1") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + mockMvc.perform(get(MATCH_PATH) + .param("industryId", "1") + .param("occupationId", "-1") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + mockMvc.perform(get(LEARNING_PATH) + .param("industryId", "1") + .param("occupationId", "1") + .param("categoryId", "0") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + @Test + void identityComesOnlyFromAuthenticatedPrincipal() throws Exception { + mockMvc.perform(get(MATCH_PATH) + .param("industryId", "1") + .param("occupationId", "1") + .param("userId", "999") + .param("studentId", "888") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.matchScore").value(100)); + + verify(skillMatchService).match(eq(studentId), eq(1L), eq(1L), isNull()); + } + + @ParameterizedTest + @ValueSource(strings = {WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH}) + void successfulResponsesDoNotExposePrivateProfileFields(String path) throws Exception { + String body = mockMvc.perform(get(path) + .param("industryId", "1") + .param("occupationId", "1") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(body) + .doesNotContain("skills_json") + .doesNotContain("phone") + .doesNotContain("email") + .doesNotContain("resume"); + } + + @Test + void openApiDocumentsStudentProtectedSkillEndpoints() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + JsonNode root = objectMapper.readTree(json); + JsonNode paths = root.path("paths"); + + for (String path : List.of(WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH)) { + JsonNode get = paths.path(path).path("get"); + assertThat(get.isMissingNode()).isFalse(); + assertThat(get.path("security").toString()).contains("bearerAuth"); + assertThat(get.path("responses").has("200")).isTrue(); + assertThat(get.path("responses").has("400")).isTrue(); + assertThat(get.path("responses").has("401")).isTrue(); + assertThat(get.path("responses").has("403")).isTrue(); + + JsonNode parameters = get.path("parameters"); + assertThat(parameter(parameters, "industryId").path("required").asBoolean()).isTrue(); + assertThat(parameter(parameters, "occupationId").path("required").asBoolean()).isTrue(); + assertThat(parameter(parameters, "categoryId").path("required").asBoolean()).isFalse(); + } + } + + @Test + void openApiSuccessExamplesMatchDtoContract() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + JsonNode paths = objectMapper.readTree(json).path("paths"); + + JsonNode wordCloud = successExample(paths.path(WORD_CLOUD_PATH).path("get")); + JsonNode wordCloudData = wordCloud.path("data"); + assertFields(wordCloudData, Set.of("industry", "occupation", "category", "total", "items")); + assertThat(wordCloudData.path("total").asInt()).isEqualTo(wordCloudData.path("items").size()); + assertSkillItemContract(wordCloudData.path("items").get(0)); + + JsonNode match = successExample(paths.path(MATCH_PATH).path("get")); + JsonNode matchData = match.path("data"); + assertFields(matchData, Set.of("matchScore", "matchedCount", "totalCount", "matchedSkills", "missingSkills")); + int matchedCount = matchData.path("matchedCount").asInt(); + int totalCount = matchData.path("totalCount").asInt(); + assertThat(matchData.path("matchedSkills").size()).isEqualTo(matchedCount); + assertThat(matchData.path("missingSkills").size()).isEqualTo(totalCount - matchedCount); + int expectedScore = totalCount == 0 ? 0 : (int) Math.round(matchedCount * 100.0 / totalCount); + assertThat(matchData.path("matchScore").asInt()).isEqualTo(expectedScore); + assertSkillItemContract(matchData.path("matchedSkills").get(0)); + assertSkillItemContract(matchData.path("missingSkills").get(0)); + + JsonNode learningPath = successExample(paths.path(LEARNING_PATH).path("get")); + JsonNode learningData = learningPath.path("data"); + assertFields(learningData, Set.of("total", "items")); + assertThat(learningData.path("total").asInt()).isEqualTo(learningData.path("items").size()); + assertFields(learningData.path("items").get(0), + Set.of("priority", "skillId", "skillName", "categoryId", "categoryName", "suggestion")); + } + + private JsonNode successExample(JsonNode get) throws Exception { + JsonNode value = get.path("responses").path("200").path("content") + .path("application/json").path("examples").path("success").path("value"); + assertThat(value.isMissingNode()).isFalse(); + return value.isTextual() ? objectMapper.readTree(value.asText()) : value; + } + + private void assertSkillItemContract(JsonNode item) { + assertThat(item.isMissingNode()).isFalse(); + assertFields(item, Set.of( + "rank", "skillId", "skillCode", "nameZh", "nameEn", "sourceType", "sourceCode", + "categoryId", "categoryName", "heat", "heatSourceCode", "heatElementId", "heatProxy")); + } + + private void assertFields(JsonNode object, Set expectedFields) { + Set actualFields = new HashSet<>(); + object.fieldNames().forEachRemaining(actualFields::add); + assertThat(actualFields).containsExactlyInAnyOrderElementsOf(expectedFields); + } + private JsonNode parameter(JsonNode parameters, String name) { + for (JsonNode parameter : parameters) { + if (name.equals(parameter.path("name").asText())) { + return parameter; + } + } + return objectMapper.missingNode(); + } + + + private String studentToken() { + return token(studentId, "STUDENT"); + } + + private String token(long userId, String role) { + return "Bearer " + jwtTokenProvider.generateAccessToken( + userId, + "1380013" + String.format("%04d", userId), + Map.of("roles", role) + ); + } + + private SkillCloudResponse cloudResponse() { + SkillCloudItem skill = skill(); + return new SkillCloudResponse( + new SkillCloudNode(1L, "INTERNET", "互联网"), + new SkillCloudNode(1L, "FRONTEND_DEVELOPER", "前端开发工程师"), + null, + 1, + List.of(skill) + ); + } + + private SkillMatchResponse matchResponse() { + return new SkillMatchResponse(100, 1, 1, List.of(skill()), List.of()); + } + + private SkillLearningPathResponse learningPathResponse() { + return new SkillLearningPathResponse( + 1, + List.of(new SkillLearningPathItem( + 1, + 3L, + "JavaScript", + 101L, + "编程语言", + "先掌握 JavaScript 的基础语法和常见数据结构,再完成至少一个编码练习。" + )) + ); + } + + private SkillCloudItem skill() { + return new SkillCloudItem( + 1, + 3L, + "JAVASCRIPT", + "JavaScript", + "JavaScript", + "ESCO", + "http://data.europa.eu/esco/skill/javascript", + 101L, + "编程语言", + new BigDecimal("4.38"), + "15-1254.00", + "2.B.3.e", + false + ); + } +}