4 changed files with 807 additions and 0 deletions
@ -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<ApiResponse<SkillCloudResponse>> 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<ApiResponse<SkillMatchResponse>> 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<ApiResponse<SkillLearningPathResponse>> 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"); |
|||
} |
|||
} |
|||
@ -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<String> 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<String, Object> redisTemplate; |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> 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<User.Role> 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 |
|||
); |
|||
} |
|||
} |
|||
@ -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<String, Object> redisTemplate; |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> 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<String> expectedFields) { |
|||
Set<String> 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 |
|||
); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue