diff --git a/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java b/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java index 00e9adf1..b5c173ee 100644 --- a/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java +++ b/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java @@ -1,29 +1,37 @@ package com.assu.server.domain.inquiry.controller; +import com.assu.server.domain.inquiry.dto.profileImage.ProfileImageResponse; import com.assu.server.domain.inquiry.dto.InquiryAnswerRequestDTO; import com.assu.server.domain.inquiry.dto.InquiryCreateRequestDTO; import com.assu.server.domain.inquiry.dto.InquiryResponseDTO; import com.assu.server.domain.inquiry.service.InquiryService; +import com.assu.server.domain.inquiry.service.ProfileImageService; import com.assu.server.global.apiPayload.BaseResponse; import com.assu.server.global.apiPayload.code.status.SuccessStatus; import com.assu.server.global.util.PrincipalDetails; 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.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.Map; -@Tag(name = "Inquiry", description = "문의 API") +@Tag(name = "MyPage", description = "마이페이지 API") @RestController -@RequestMapping("/member/inquiries") +@RequestMapping("/member") @RequiredArgsConstructor public class InquiryController { private final InquiryService inquiryService; + private final ProfileImageService profileImageService; @Operation( summary = "문의 생성 API", @@ -31,7 +39,7 @@ public class InquiryController { "- 문의를 생성하고 해당 문의의 id를 반환합니다.\n"+ " - InquiryCreateRequestDTO: title, content, email\n" ) - @PostMapping + @PostMapping("/inquiries") public BaseResponse create( @AuthenticationPrincipal PrincipalDetails pd, @RequestBody @Valid InquiryCreateRequestDTO req @@ -48,7 +56,7 @@ public BaseResponse create( " - page: Request Param, Integer, 1 이상\n" + " - size: Request Param, Integer, default = 20" ) - @GetMapping + @GetMapping("/inquiries") public BaseResponse> list( @AuthenticationPrincipal PrincipalDetails pd, @RequestParam(defaultValue = "all") String status, // all | waiting | answered @@ -66,7 +74,7 @@ public BaseResponse> list( "- 본인의 단건 문의를 상세 조회합니다.\n"+ " - inquiry-id: Path Variable, Long\n" ) - @GetMapping("/{inquiry-id}") + @GetMapping("/inquiries/{inquiry-id}") public BaseResponse get( @AuthenticationPrincipal PrincipalDetails pd, @PathVariable("inquiry-id") Long inquiryId @@ -82,7 +90,7 @@ public BaseResponse get( "- 문의에 답변을 저장하고 상태를 ANSWERED로 변경합니다.\n"+ " - inquiry-id: Path Variable, Long\n" ) - @PatchMapping("/{inquiry-id}/answer") + @PatchMapping("/inquiries/{inquiry-id}/answer") public BaseResponse answer( @PathVariable("inquiry-id") Long inquiryId, @RequestBody @Valid InquiryAnswerRequestDTO req @@ -90,4 +98,29 @@ public BaseResponse answer( inquiryService.answer(inquiryId, req.getAnswer()); return BaseResponse.onSuccess(SuccessStatus._OK, "The inquiry answered successfully. id=" + inquiryId); } + + @Operation( + summary = "프로필 사진 업로드/교체 API", + description = "# [v1.0 (2025-09-15)](https://clumsy-seeder-416.notion.site/26f1197c19ed8031bc50e3571e8ea18f?source=copy_link)\n" + + "- `multipart/form-data`로 프로필 이미지를 업로드합니다.\n" + + "- 기존 이미지가 있으면 S3에서 삭제 후 새 이미지로 교체합니다.\n" + + "- 성공 시 업로드된 이미지 key를 반환합니다." + ) + @PutMapping(value = "/profile/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public BaseResponse uploadOrReplaceProfileImage( + @AuthenticationPrincipal PrincipalDetails pd, + @RequestPart("image") + @Parameter( + description = "프로필 이미지 파일 (jpg/png/webp 등)", + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE, + schema = @Schema(type = "string", format = "binary") + ) + ) + MultipartFile image + ) { + String key = profileImageService.updateProfileImage(pd.getMemberId(), image); + return BaseResponse.onSuccess(SuccessStatus._OK, new ProfileImageResponse(key)); + } } \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/inquiry/dto/profileImage/ProfileImageResponse.java b/src/main/java/com/assu/server/domain/inquiry/dto/profileImage/ProfileImageResponse.java new file mode 100644 index 00000000..e8867f2e --- /dev/null +++ b/src/main/java/com/assu/server/domain/inquiry/dto/profileImage/ProfileImageResponse.java @@ -0,0 +1,12 @@ +package com.assu.server.domain.inquiry.dto.profileImage; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ProfileImageResponse { + @Schema(description = "업로드된 프로필 이미지 URL") + private String url; +} diff --git a/src/main/java/com/assu/server/domain/inquiry/service/ProfileImageService.java b/src/main/java/com/assu/server/domain/inquiry/service/ProfileImageService.java new file mode 100644 index 00000000..d19a5db7 --- /dev/null +++ b/src/main/java/com/assu/server/domain/inquiry/service/ProfileImageService.java @@ -0,0 +1,7 @@ +package com.assu.server.domain.inquiry.service; + +import org.springframework.web.multipart.MultipartFile; + +public interface ProfileImageService { + String updateProfileImage(Long memberId, MultipartFile image); +} diff --git a/src/main/java/com/assu/server/domain/inquiry/service/ProfileImageServiceImpl.java b/src/main/java/com/assu/server/domain/inquiry/service/ProfileImageServiceImpl.java new file mode 100644 index 00000000..3f797885 --- /dev/null +++ b/src/main/java/com/assu/server/domain/inquiry/service/ProfileImageServiceImpl.java @@ -0,0 +1,55 @@ +package com.assu.server.domain.inquiry.service; + +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.infra.s3.AmazonS3Manager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ProfileImageServiceImpl implements ProfileImageService{ + + private static final long MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB + private static final String[] ALLOWED_EXT = {"jpg", "jpeg", "png", "webp"}; + + private final MemberRepository memberRepository; + private final AmazonS3Manager amazonS3Manager; + + @Override + @Transactional + public String updateProfileImage(Long memberId, MultipartFile image) { + if (image == null || image.isEmpty()) { + throw new CustomAuthException(ErrorStatus.PROFILE_IMAGE_NOT_FOUND); + } + + // 1) 멤버 조회 + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new CustomAuthException(ErrorStatus.NO_SUCH_MEMBER)); + + // 2) 업로드 (generateKeyName + uploadFile 만 사용) + String keyPath = "members/" + member.getId() + "/profile/" + image.getOriginalFilename(); + String keyName = amazonS3Manager.generateKeyName(keyPath); + String uploadedKey = amazonS3Manager.uploadFile(keyName, image); // S3에 올린 후 key 반환 + + // 3) 기존 파일 있으면 삭제 (기존 값이 key 라는 전제) + String oldKey = member.getProfileUrl(); + if (oldKey != null && !oldKey.isBlank()) { + try { amazonS3Manager.deleteFile(oldKey); } + catch (Exception e) { log.warn("이전 프로필 삭제 실패 key={}", oldKey, e); } + } + + // 4) DB 업데이트 (key 저장) + member.setProfileUrl(uploadedKey); + + // 5) 호출자에 key 반환 (FE는 필요 시 presigned URL 생성해 사용) + return uploadedKey; + } +} + diff --git a/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java index f0d331a3..94ecae2f 100644 --- a/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java @@ -98,6 +98,13 @@ public enum ErrorStatus implements BaseErrorCode { // 주소 에러 NO_SUCH_ADDRESS(HttpStatus.NOT_FOUND, "ADDRESS_7001", "주소를 찾을 수 없습니다."), + // 프로필(Profile) 관련 에러 + PROFILE_IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "PROFILE_5001", "프로필 이미지 업로드에 실패했습니다."), + PROFILE_IMAGE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "PROFILE_5002", "프로필 이미지 삭제에 실패했습니다."), + PROFILE_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "PROFILE_4001", "존재하지 않는 프로필 이미지입니다."), + PROFILE_IMAGE_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "PROFILE_4002", "지원하지 않는 이미지 형식입니다."), + PROFILE_IMAGE_TOO_LARGE(HttpStatus.BAD_REQUEST, "PROFILE_4003", "허용된 크기를 초과한 이미지입니다."), + ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/assu/server/infra/s3/AmazonS3Manager.java b/src/main/java/com/assu/server/infra/s3/AmazonS3Manager.java index 1d0d622a..8ec2f5d1 100644 --- a/src/main/java/com/assu/server/infra/s3/AmazonS3Manager.java +++ b/src/main/java/com/assu/server/infra/s3/AmazonS3Manager.java @@ -8,6 +8,7 @@ import software.amazon.awssdk.core.ResponseBytes; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectResponse; import software.amazon.awssdk.services.s3.model.PutObjectRequest; @@ -135,4 +136,18 @@ public String generateKeyName(String path) { return path + '/' + UUID.randomUUID(); } + public void deleteFile(String keyName) { + if (keyName == null || keyName.isBlank()) return; + try { + s3Client.deleteObject(DeleteObjectRequest.builder() + .bucket(amazonConfig.getBucket()) + .key(keyName) + .build()); + log.debug("S3 삭제 완료 key={}", keyName); + } catch (Exception e) { + log.error("S3 파일 삭제 실패. key={}", keyName, e); + throw new RuntimeException("S3 delete failed", e); + } + } + }