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( operationId = "getWordCloud", summary = "查询行业技能词云", description = "返回当前筛选范围内的全部技能,按热度降序排列;前端取前十条作为 TOP10。仅 STUDENT 可访问。" ) @SecurityRequirement(name = "bearerAuth") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", description = "成功返回筛选范围内的全部技能", content = @Content( mediaType = "application/json", examples = @ExampleObject( name = "success", value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"industry\":{\"id\":1,\"code\":\"INTERNET\",\"name\":\"互联网\"},\"occupation\":{\"id\":1,\"code\":\"FRONTEND_DEVELOPER\",\"name\":\"前端开发工程师\"},\"category\":null,\"total\":1,\"items\":[{\"rank\":1,\"skillId\":26,\"skillCode\":\"REACT\",\"nameZh\":\"React\",\"nameEn\":\"React\",\"sourceType\":\"O*NET\",\"sourceCode\":\"15-1254.00|React|2.E.7.h\",\"categoryId\":103,\"categoryName\":\"前端框架\",\"heat\":4.38,\"heatSourceCode\":\"15-1254.00\",\"heatElementId\":\"2.E.7.h\",\"heatProxy\":true}]}}" ) ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", description = "筛选参数缺失、非正整数或节点关系非法", content = @Content(schema = @Schema(implementation = ApiResponse.class)) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "401", description = "未登录", content = @Content(schema = @Schema(implementation = ApiResponse.class)) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "403", description = "已登录但不是 STUDENT 角色", content = @Content(schema = @Schema(implementation = ApiResponse.class)) ) }) @GetMapping("/skills/word-cloud") public ResponseEntity> getWordCloud( @Parameter(description = "行业 ID,必填正整数", required = true, example = "1") @Positive(message = "industryId must be positive") @RequestParam Long industryId, @Parameter(description = "职位 ID,必填正整数", required = true, example = "1") @Positive(message = "occupationId must be positive") @RequestParam Long occupationId, @Parameter(description = "技能分类 ID,可选正整数;省略时返回职位下全部分类", required = false, example = "101") @Positive(message = "categoryId must be positive") @RequestParam(required = false) Long categoryId, HttpServletRequest request) { Long userId = resolveAuthenticatedStudent(); SkillCloudResponse data = skillCloudQueryService.query(industryId, occupationId, categoryId); return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); } @Operation( operationId = "getSkillMatch", summary = "查询个人技能匹配度", description = "使用当前 STUDENT 的个人技能,与筛选结果热度 TOP10 比对并返回命中、缺失和匹配度。仅 STUDENT 可访问。" ) @SecurityRequirement(name = "bearerAuth") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", description = "成功返回个人技能匹配结果", content = @Content( mediaType = "application/json", examples = @ExampleObject( name = "success", value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"matchScore\":50,\"matchedCount\":1,\"totalCount\":2,\"matchedSkills\":[{\"rank\":1,\"skillId\":26,\"skillCode\":\"REACT\",\"nameZh\":\"React\",\"nameEn\":\"React\",\"sourceType\":\"O*NET\",\"sourceCode\":\"15-1254.00|React|2.E.7.h\",\"categoryId\":103,\"categoryName\":\"前端框架\",\"heat\":4.38,\"heatSourceCode\":\"15-1254.00\",\"heatElementId\":\"2.E.7.h\",\"heatProxy\":true}],\"missingSkills\":[{\"rank\":2,\"skillId\":31,\"skillCode\":\"DOCKER\",\"nameZh\":\"Docker\",\"nameEn\":\"Docker\",\"sourceType\":\"O*NET\",\"sourceCode\":\"15-1299.08|Docker|2.E.7.h\",\"categoryId\":105,\"categoryName\":\"工程化工具\",\"heat\":4.12,\"heatSourceCode\":\"15-1299.08\",\"heatElementId\":\"2.E.7.h\",\"heatProxy\":true}]}}" ) ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", description = "筛选参数缺失、非正整数或节点关系非法", content = @Content(schema = @Schema(implementation = ApiResponse.class)) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "401", description = "未登录", content = @Content(schema = @Schema(implementation = ApiResponse.class)) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "403", description = "已登录但不是 STUDENT 角色", content = @Content(schema = @Schema(implementation = ApiResponse.class)) ) }) @GetMapping("/skills/match") public ResponseEntity> getMatch( @Parameter(description = "行业 ID,必填正整数", required = true, example = "1") @Positive(message = "industryId must be positive") @RequestParam Long industryId, @Parameter(description = "职位 ID,必填正整数", required = true, example = "1") @Positive(message = "occupationId must be positive") @RequestParam Long occupationId, @Parameter(description = "技能分类 ID,可选正整数;省略时使用职位下全部技能", required = false, example = "101") @Positive(message = "categoryId must be positive") @RequestParam(required = false) Long categoryId, HttpServletRequest request) { Long userId = resolveAuthenticatedStudent(); SkillMatchResponse data = skillMatchService.match(userId, industryId, occupationId, categoryId); return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); } @Operation( operationId = "getLearningPath", summary = "生成个人学习路径", description = "仅针对个人 TOP10 中未命中的技能,按热度顺序返回优先级和通用学习建议。仅 STUDENT 可访问。" ) @SecurityRequirement(name = "bearerAuth") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", description = "成功返回缺失技能的学习路径;没有缺口时列表为空", content = @Content( mediaType = "application/json", examples = @ExampleObject( name = "success", value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"total\":1,\"items\":[{\"priority\":1,\"skillId\":26,\"skillName\":\"React\",\"categoryId\":103,\"categoryName\":\"前端框架\",\"suggestion\":\"先理解 React 的核心概念与生命周期,再通过一个小型项目练习集成。\"}]}}" ) ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", description = "筛选参数缺失、非正整数或节点关系非法", content = @Content(schema = @Schema(implementation = ApiResponse.class)) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "401", description = "未登录", content = @Content(schema = @Schema(implementation = ApiResponse.class)) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "403", description = "已登录但不是 STUDENT 角色", content = @Content(schema = @Schema(implementation = ApiResponse.class)) ) }) @GetMapping("/skills/learning-path") public ResponseEntity> getLearningPath( @Parameter(description = "行业 ID,必填正整数", required = true, example = "1") @Positive(message = "industryId must be positive") @RequestParam Long industryId, @Parameter(description = "职位 ID,必填正整数", required = true, example = "1") @Positive(message = "occupationId must be positive") @RequestParam Long occupationId, @Parameter(description = "技能分类 ID,可选正整数;省略时使用职位下全部技能", required = false, example = "101") @Positive(message = "categoryId must be positive") @RequestParam(required = false) Long categoryId, HttpServletRequest request) { Long userId = resolveAuthenticatedStudent(); SkillLearningPathResponse data = skillLearningPathService.generate( userId, industryId, occupationId, categoryId); return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); } private Long resolveAuthenticatedStudent() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getName())) { throw new BusinessException(ErrorCode.UNAUTHORIZED); } try { Long userId = Long.valueOf(authentication.getName()); profileService.assertStudentRole(userId); return userId; } catch (NumberFormatException exception) { throw new BusinessException(ErrorCode.UNAUTHORIZED); } } private String getRequestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); } }