diff --git a/src/main/java/com/example/chatserver/domain/member/controller/MemberInfoController.java b/src/main/java/com/example/chatserver/domain/member/controller/MemberInfoController.java new file mode 100644 index 0000000..e8d1271 --- /dev/null +++ b/src/main/java/com/example/chatserver/domain/member/controller/MemberInfoController.java @@ -0,0 +1,54 @@ +package com.example.chatserver.domain.member.controller; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.example.chatserver.domain.member.service.MemberService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/api/info") +@RequiredArgsConstructor +@Tag(name = "InfoChange", description = "회원 정보 수정") +public class MemberInfoController { + private final MemberService memberService; // 정보 수정 비즈니스 로직 담당 + + @Operation(summary = "프로필 이미지 조회", description = "로그인한 유저의 프로필 이미지 URL을 조회합니다.") + @GetMapping("/profile-image") + public ResponseEntity getProfileImage( + @AuthenticationPrincipal UserDetails userDetails) { + String imageUrl = memberService.getProfileImage(userDetails.getUsername()); + return ResponseEntity.ok(imageUrl); + } + + @Operation(summary = "프로필 이미지 변경", description = "로그인한 유저의 프로필 이미지를 S3에 업로드하고 변경합니다.") + @PostMapping(value = "/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity updateProfileImage( + @AuthenticationPrincipal UserDetails userDetails, + @RequestParam("file") MultipartFile file) { + + // 1. 파일이 비어있는지 기본 체크 + if (file == null || file.isEmpty()) { + return ResponseEntity.badRequest().body("파일이 존재하지 않습니다."); + } + + // 2. userDetails.getUsername() (일반적으로 이메일이나 PK)를 넘겨서 식별 + String newImageUrl = memberService.updateProfileImage(userDetails.getUsername(), file); + + return ResponseEntity.ok(newImageUrl); + } +} + diff --git a/src/main/java/com/example/chatserver/domain/member/entity/Member.java b/src/main/java/com/example/chatserver/domain/member/entity/Member.java index 5381632..3db1df4 100644 --- a/src/main/java/com/example/chatserver/domain/member/entity/Member.java +++ b/src/main/java/com/example/chatserver/domain/member/entity/Member.java @@ -1,5 +1,6 @@ package com.example.chatserver.domain.member.entity; +import com.example.chatserver.domain.profile.ProfileImage; import com.example.chatserver.global.common.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; @@ -27,15 +28,20 @@ public class Member extends BaseTimeEntity { @Column(nullable = false, length = 100) private String password; - @Column(nullable = false, unique = true, updatable = false) private String publicId; + @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + private ProfileImage profileImage; + @Enumerated(EnumType.STRING) @Builder.Default private Role role = Role.USER; + public void updateProfileImage(ProfileImage profileImage) { + this.profileImage = profileImage; + } } diff --git a/src/main/java/com/example/chatserver/domain/member/service/MemberService.java b/src/main/java/com/example/chatserver/domain/member/service/MemberService.java index 2a5e105..2fc88c9 100644 --- a/src/main/java/com/example/chatserver/domain/member/service/MemberService.java +++ b/src/main/java/com/example/chatserver/domain/member/service/MemberService.java @@ -9,10 +9,14 @@ import com.example.chatserver.domain.member.exception.AlreadyExistEmailException; import com.example.chatserver.domain.member.exception.PasswordIncorrectException; import com.example.chatserver.domain.member.repository.MemberRepository; +import com.example.chatserver.domain.profile.ProfileImage; +import com.example.chatserver.domain.profile.repository.ProfileImageRepository; +import com.example.chatserver.domain.profile.service.ImageStorage; import com.example.chatserver.global.common.error.BaseException; import com.example.chatserver.global.common.error.ErrorCode; import com.example.chatserver.global.security.jwt.JwtUtil; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; @@ -21,11 +25,13 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @Slf4j @Service +@RequiredArgsConstructor @Transactional(readOnly = true) public class MemberService { @@ -33,14 +39,9 @@ public class MemberService { private final JwtUtil jwtUtil; private final StringRedisTemplate redisTemplate; private final PasswordEncoder passwordEncoder; + private final ImageStorage imageStorage; + private final ProfileImageRepository profileImageRepository; - public MemberService(MemberRepository memberRepository, JwtUtil jwtUtil, StringRedisTemplate redisTemplate, - PasswordEncoder passwordEncoder) { - this.memberRepository = memberRepository; - this.jwtUtil = jwtUtil; - this.redisTemplate = redisTemplate; - this.passwordEncoder = passwordEncoder; - } @Transactional public ResponseEntity signup(SignupRequest signupRequest) { @@ -153,4 +154,57 @@ public ResponseEntity reissueToken(String refreshToken) { ); } + @Transactional + public String updateProfileImage(String publicId, MultipartFile file) { + // 1. 회원 조회 + Member member = memberRepository.findByPublicId(publicId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + + // 2. 파일 저장 (예: AWS S3, 로컬 파일 시스템 등) + ProfileImage existingImage = member.getProfileImage(); + if (existingImage != null) { + // S3 삭제 로직은 ImageStorage 인터페이스에 delete 메서드를 추가로 구현 예정 + // imageStorage.delete("profiles/" + publicId + "/" + existingImage.getStoreFileName()); + } + + // 3. S3에 새 이미지 업로드 (폴더 경로: profiles/{publicId}) + String directory = "profiles/" + member.getPublicId(); + String imageUrl = imageStorage.upload(file, directory); + + // 4. 엔티티 생성 또는 업데이트 + String storeFileName = extractFileName(imageUrl); + + if (existingImage == null) { + // 처음 프로필 이미지를 등록하는 경우 + ProfileImage newProfileImage = ProfileImage.builder() + .imageUrl(imageUrl) + .storeFileName(storeFileName) + .uploadFileName(file.getOriginalFilename()) + .member(member) + .build(); + member.updateProfileImage(newProfileImage); + profileImageRepository.save(newProfileImage); + } else { + // 기존 이미지가 있는 경우 (Dirty Checking으로 업데이트) + existingImage.updateImageInfo(imageUrl, storeFileName, file.getOriginalFilename()); + } + return imageUrl; + } + + // 프로필 이미지 조회 + public String getProfileImage(String publicId) { + Member member = memberRepository.findByPublicId(publicId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + + ProfileImage profileImage = member.getProfileImage(); + if (profileImage == null) { + throw new IllegalArgumentException("프로필 이미지가 등록되지 않았습니다."); + } + return profileImage.getImageUrl(); + } + + // URL에서 파일명만 추출하는 편의 메서드 + private String extractFileName(String url) { + return url.substring(url.lastIndexOf("/") + 1); + } } diff --git a/src/main/java/com/example/chatserver/domain/profile/ProfileImage.java b/src/main/java/com/example/chatserver/domain/profile/ProfileImage.java index 5a7f47d..cb2a9c1 100644 --- a/src/main/java/com/example/chatserver/domain/profile/ProfileImage.java +++ b/src/main/java/com/example/chatserver/domain/profile/ProfileImage.java @@ -31,6 +31,13 @@ public class ProfileImage extends BaseTimeEntity { @JoinColumn(name = "member_id") private Member member; + + public void updateImageInfo(String imageUrl, String storeFileName, String uploadFileName) { + this.imageUrl = imageUrl; + this.storeFileName = storeFileName; + this.uploadFileName = uploadFileName; + } + @Builder public ProfileImage(String storeFileName, String uploadFileName, String imageUrl, Member member) { this.storeFileName = storeFileName; diff --git a/src/main/java/com/example/chatserver/domain/profile/repository/ProfileImageRepository.java b/src/main/java/com/example/chatserver/domain/profile/repository/ProfileImageRepository.java new file mode 100644 index 0000000..26c7f6a --- /dev/null +++ b/src/main/java/com/example/chatserver/domain/profile/repository/ProfileImageRepository.java @@ -0,0 +1,11 @@ +package com.example.chatserver.domain.profile.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.example.chatserver.domain.profile.ProfileImage; + +@Repository +public interface ProfileImageRepository extends JpaRepository { + +}