Browse Source

feat: expose student skill cloud endpoints

backup/master-local-20261012
pwklppg 1 month ago
parent
commit
3e353bd755
  1. 2
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  2. 204
      mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java
  3. 212
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudAuthorizationIntegrationTest.java
  4. 389
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java

2
mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java

@ -74,6 +74,8 @@ public class SecurityConfig {
.requestMatchers("/auth/register").permitAll() .requestMatchers("/auth/register").permitAll()
// 当前用户 // 当前用户
.requestMatchers("/auth/me").authenticated() .requestMatchers("/auth/me").authenticated()
// 行业技能词云(需登录,仅 STUDENT,Controller 层校验角色)
.requestMatchers(HttpMethod.GET, "/skills/**").authenticated()
// 企业资料(需登录) // 企业资料(需登录)
.requestMatchers("/companies/me/**").authenticated() .requestMatchers("/companies/me/**").authenticated()
// 企业公开岗位列表(匿名 GET) // 企业公开岗位列表(匿名 GET)

204
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<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");
}
}

212
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<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
);
}
}

389
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<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…
Cancel
Save