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