package com.mydreamjob.profile; import com.mydreamjob.profile.dto.StudentProfileResponse; import com.mydreamjob.profile.dto.StudentProfileUpdateRequest; import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; 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.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.nio.charset.StandardCharsets; import java.util.Map; @Tag(name = "个人资料", description = "学生个人资料查询、更新与简历管理") @RestController @RequestMapping("/profile") @RequiredArgsConstructor public class ProfileController { private final ProfileService profileService; private final CurrentUserAccessor currentUserAccessor; private final RequestIdAccessor requestIdAccessor; // ── 资料 CRUD ──────────────────────────────────────────── @Operation( summary = "查询当前学生资料", description = "查询当前登录学生(仅 STUDENT 角色)自己的学生资料。" + "身份仅从 JWT/SecurityContext 获取,不接受 userId、studentUserId 等请求参数;" + "本接口无 Path、Query、Body 参数。" + "若尚未创建个人资料:HTTP 200,data 为空对象 {}(非 null),且不会自动创建数据库记录。" + "当前未设置接口级限流。", security = @SecurityRequirement(name = "bearerAuth"), parameters = {}) @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", description = "成功;有资料返回资料对象,无资料返回空对象 {};message=ok", content = @Content( mediaType = "application/json", schema = @Schema(ref = "#/components/schemas/ApiResponseStudentProfileResponse"), examples = @ExampleObject( name = "success-with-data", summary = "有资料", value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"id\":1,\"userId\":2," + "\"realName\":\"张三\",\"education\":\"本科\",\"school\":\"某某大学\"," + "\"graduationYear\":2026,\"resumeUrl\":\"/uploads/resumes/2/resume.pdf\"," + "\"skills\":\"[Java, Spring Boot]\",\"createdAt\":\"2026-08-24T10:00:00\"}," + "\"timestamp\":\"2026-08-24T10:00:00Z\"}"))), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "401", description = "未登录、Token 缺失/非法/过期;运行时实际返回 code=401、message=not authenticated", content = @Content( mediaType = "application/json", examples = @ExampleObject( name = "unauthorized", value = "{\"code\":401,\"message\":\"not authenticated\"," + "\"timestamp\":\"2026-08-24T10:00:00Z\"}"))), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "403", description = "已登录但不是 STUDENT 角色(COMPANY/ADMIN);code=2002、message=forbidden", content = @Content( mediaType = "application/json", examples = @ExampleObject( name = "forbidden", value = "{\"code\":2002,\"message\":\"forbidden\",\"data\":null," + "\"timestamp\":\"2026-08-24T10:00:00Z\"}"))) }) @GetMapping("/me") public ResponseEntity> getMyProfile(HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); profileService.assertStudentRole(userId); StudentProfileResponse response = profileService.toResponse(profileService.findProfileByUserId(userId)); return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request))); } @Operation( summary = "更新学生资料", description = "仅更新传入的非 null 字段;不存在则自动创建。" + "已有在线简历时必须携带最近 GET /profile/me/online-resume 的 revision," + "缺少或过期返回 409/6002;成功写入推进在线简历 revision,随后重新 GET 获取最新版本。" + "未建立在线简历时兼容不传 revision,显式 revision 只能为 0。" + "角色:仅 STUDENT(COMPANY/ADMIN 返回 403)。" + "安全约束:请求体中的 resumeUrl 为只读字段,客户端不得设置——" + "缺省或显式 null 表示不修改现有简历引用;" + "传入任意非 null 值(含空字符串)整次请求返回 400,且不产生任何部分更新。" + "简历引用只能由上传简历、简历版本等受控接口生成与绑定。") @PutMapping("/me") public ResponseEntity>> updateMyProfile( @Valid @RequestBody StudentProfileUpdateRequest req, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); Map view = profileService.toView(profileService.updateProfile(userId, req)); return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } // ── 简历管理 ────────────────────────────────────────────── @Operation(summary = "上传简历", description = "仅 STUDENT 角色;PDF/DOC/DOCX,≤10MB;上传新简历时替换旧文件") @PostMapping(value = "/me/resume", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity>> uploadResume( @RequestParam("file") MultipartFile file, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); Map view = profileService.uploadResume(userId, file); return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } @Operation(summary = "下载简历", description = "下载当前学生上传的简历文件,仅 STUDENT 角色") @GetMapping("/me/resume") public ResponseEntity downloadResume(HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); ProfileService.ResumeFile rf = profileService.getResume(userId); MediaType mediaType = resolveContentType(rf.getContentType()); ContentDisposition cd = ContentDisposition.inline() .filename(ResumeFilenameSanitizer.forDownload(rf.getFilename(), "resume"), StandardCharsets.UTF_8) .build(); return ResponseEntity.ok() .contentType(mediaType) .header(HttpHeaders.CONTENT_DISPOSITION, cd.toString()) .body(rf.getContent()); } @Operation(summary = "删除简历", description = "删除当前学生的简历文件并清空 resumeUrl,仅 STUDENT 角色") @DeleteMapping("/me/resume") public ResponseEntity>> deleteResume(HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); profileService.deleteResume(userId); return ResponseEntity.ok(ApiResponse.ok( Map.of("message", "resume deleted"), requestIdAccessor.get(request))); } // ── 辅助 ────────────────────────────────────────────────── private MediaType resolveContentType(String contentType) { if (contentType == null) return MediaType.APPLICATION_OCTET_STREAM; try { return MediaType.valueOf(contentType); } catch (Exception e) { return MediaType.APPLICATION_OCTET_STREAM; } } }