轻松聘AI开发
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

158 lines
9.2 KiB

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<ApiResponse<StudentProfileResponse>> 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<ApiResponse<Map<String, Object>>> updateMyProfile(
@Valid @RequestBody StudentProfileUpdateRequest req,
HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId();
Map<String, Object> 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<ApiResponse<Map<String, Object>>> uploadResume(
@RequestParam("file") MultipartFile file,
HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId();
Map<String, Object> view = profileService.uploadResume(userId, file);
return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request)));
}
@Operation(summary = "下载简历", description = "下载当前学生上传的简历文件,仅 STUDENT 角色")
@GetMapping("/me/resume")
public ResponseEntity<byte[]> 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<ApiResponse<Map<String, Object>>> 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;
}
}
}