forked from baskbuddy/zhangshun
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
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;
|
|
}
|
|
}
|
|
}
|
|
|