diff --git a/build.gradle b/build.gradle index f042a27..502f07a 100644 --- a/build.gradle +++ b/build.gradle @@ -50,7 +50,7 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' // AWS S3 - implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + implementation "software.amazon.awssdk:s3:2.41.18" // PostGIS 공간 타입 <-> JTS(Point 등) 매핑 지원 implementation "org.hibernate.orm:hibernate-spatial" diff --git a/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java b/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java index e443795..a7776b5 100644 --- a/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java +++ b/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java @@ -2,9 +2,9 @@ import com.be.sportizebe.domain.club.dto.request.ClubCreateRequest; import com.be.sportizebe.domain.club.dto.request.ClubUpdateRequest; +import com.be.sportizebe.domain.club.dto.response.ClubImageResponse; import com.be.sportizebe.domain.club.dto.response.ClubResponse; import com.be.sportizebe.domain.club.service.ClubServiceImpl; -import com.be.sportizebe.domain.user.entity.SportType; import com.be.sportizebe.domain.user.entity.User; import com.be.sportizebe.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; @@ -13,9 +13,11 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @RequiredArgsConstructor @@ -25,12 +27,13 @@ public class ClubController { private final ClubServiceImpl clubService; - @PostMapping("") - @Operation(summary = "동호회 생성", description = "종목별 동호회를 생성합니다. 생성한 사용자가 동호회장이 됩니다.") + @PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "동호회 생성", description = "종목별 동호회를 생성합니다. 생성한 사용자가 동호회장이 됩니다. (이미지 첨부 가능)") public ResponseEntity> createClub( - @RequestBody @Valid ClubCreateRequest request, + @RequestPart("request") @Valid ClubCreateRequest request, + @RequestPart(value = "image", required = false) MultipartFile image, @AuthenticationPrincipal User user) { - ClubResponse response = clubService.createClub(request, user); + ClubResponse response = clubService.createClub(request, image, user); return ResponseEntity.status(HttpStatus.CREATED) .body(BaseResponse.success("동호회 생성 성공", response)); } @@ -44,4 +47,14 @@ public ResponseEntity> updateClub( ClubResponse response = clubService.updateClub(clubId, request, user); return ResponseEntity.ok(BaseResponse.success("동호회 수정 성공", response)); } + + @PostMapping(value = "/{clubId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "동호회 사진 수정", description = "동호회 사진을 수정합니다. 동호회장만 수정할 수 있습니다.") + public ResponseEntity> updateClubImage( + @Parameter(description = "동호회 ID") @PathVariable Long clubId, + @RequestPart("image") MultipartFile image, + @AuthenticationPrincipal User user) { + ClubImageResponse response = clubService.updateClubImage(clubId, image, user); + return ResponseEntity.ok(BaseResponse.success("동호회 사진 수정 성공", response)); + } } diff --git a/src/main/java/com/be/sportizebe/domain/club/dto/request/ClubCreateRequest.java b/src/main/java/com/be/sportizebe/domain/club/dto/request/ClubCreateRequest.java index 6d486c8..b2d5e38 100644 --- a/src/main/java/com/be/sportizebe/domain/club/dto/request/ClubCreateRequest.java +++ b/src/main/java/com/be/sportizebe/domain/club/dto/request/ClubCreateRequest.java @@ -12,15 +12,14 @@ public record ClubCreateRequest( @Schema(description = "동호회 소개", example = "매주 토요일 축구합니다") String introduce, @Schema(description = "동호회 관련 종목", example = "SOCCER") SportType clubType, @Schema(description = "최대 정원", example = "20") Integer maxMembers) { - // 관련 종목은 파라미터로 받음 - // TODO : S3 세팅 후 imgUrl은 multipartform으로 변경 - public Club toEntity(User user) { + public Club toEntity(User user, String clubImage) { return Club.builder() .name(name) .introduce(introduce) .clubType(clubType) .maxMembers(maxMembers) + .clubImage(clubImage) .leader(user) .build(); } diff --git a/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubImageResponse.java b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubImageResponse.java new file mode 100644 index 0000000..dfe2659 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubImageResponse.java @@ -0,0 +1,13 @@ +package com.be.sportizebe.domain.club.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "동호회 이미지 응답") +public record ClubImageResponse( + @Schema(description = "동호회 이미지 URL", example = "https://bucket.s3.ap-northeast-2.amazonaws.com/club/uuid.jpg") + String clubImageUrl +) { + public static ClubImageResponse from(String clubImageUrl) { + return new ClubImageResponse(clubImageUrl); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubResponse.java b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubResponse.java index 21c9934..1ab75f8 100644 --- a/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubResponse.java +++ b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubResponse.java @@ -13,7 +13,8 @@ public record ClubResponse( @Schema(description = "동호회 소개", example = "매주 토요일 축구합니다") String introduce, @Schema(description = "동호회 관련 종목", example = "SOCCER") SportType clubType, @Schema(description = "최대 정원", example = "20") Integer maxMembers, - @Schema(description = "동호회장 닉네임", example = "닉네임") String leaderNickname) { + @Schema(description = "동호회장 닉네임", example = "닉네임") String leaderNickname, + @Schema(description = "동호회 이미지 URL") String clubImageUrl) { public static ClubResponse from(Club club) { return ClubResponse.builder() @@ -23,6 +24,7 @@ public static ClubResponse from(Club club) { .clubType(club.getClubType()) .maxMembers(club.getMaxMembers()) .leaderNickname(club.getLeader().getNickname()) + .clubImageUrl(club.getClubImage()) .build(); } } diff --git a/src/main/java/com/be/sportizebe/domain/club/entity/Club.java b/src/main/java/com/be/sportizebe/domain/club/entity/Club.java index 0d5ccc3..142c9a4 100644 --- a/src/main/java/com/be/sportizebe/domain/club/entity/Club.java +++ b/src/main/java/com/be/sportizebe/domain/club/entity/Club.java @@ -37,6 +37,8 @@ public class Club extends BaseTimeEntity { @Column(nullable = false) private Integer maxMembers; // 최대 정원 + private String clubImage; // 동호회 사진 URL + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "leader_id", nullable = false) private User leader; // 동호회장 @@ -54,4 +56,8 @@ public void update(String name, String introduce, Integer maxMembers, SportType this.maxMembers = maxMembers; this.clubType = clubType; } + + public void updateClubImage(String clubImage) { + this.clubImage = clubImage; + } } diff --git a/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java b/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java index 89c19f6..4ac4c0e 100644 --- a/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java +++ b/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java @@ -2,12 +2,15 @@ import com.be.sportizebe.domain.club.dto.request.ClubCreateRequest; import com.be.sportizebe.domain.club.dto.request.ClubUpdateRequest; +import com.be.sportizebe.domain.club.dto.response.ClubImageResponse; import com.be.sportizebe.domain.club.dto.response.ClubResponse; -import com.be.sportizebe.domain.user.entity.SportType; import com.be.sportizebe.domain.user.entity.User; +import org.springframework.web.multipart.MultipartFile; public interface ClubService { - ClubResponse createClub(ClubCreateRequest request, User user); // 동호회 생성 + ClubResponse createClub(ClubCreateRequest request, MultipartFile image, User user); // 동호회 생성 ClubResponse updateClub(Long clubId, ClubUpdateRequest request, User user); // 동호회 수정 + + ClubImageResponse updateClubImage(Long clubId, MultipartFile image, User user); // 동호회 사진 수정 } diff --git a/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java b/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java index bf27de5..49de51c 100644 --- a/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java +++ b/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java @@ -3,18 +3,21 @@ import com.be.sportizebe.domain.chat.service.ChatRoomService; import com.be.sportizebe.domain.club.dto.request.ClubCreateRequest; import com.be.sportizebe.domain.club.dto.request.ClubUpdateRequest; +import com.be.sportizebe.domain.club.dto.response.ClubImageResponse; import com.be.sportizebe.domain.club.dto.response.ClubResponse; import com.be.sportizebe.domain.club.entity.Club; import com.be.sportizebe.domain.club.entity.ClubMember; import com.be.sportizebe.domain.club.exception.ClubErrorCode; import com.be.sportizebe.domain.club.repository.ClubMemberRepository; import com.be.sportizebe.domain.club.repository.ClubRepository; -import com.be.sportizebe.domain.user.entity.SportType; import com.be.sportizebe.domain.user.entity.User; import com.be.sportizebe.global.exception.CustomException; +import com.be.sportizebe.global.s3.enums.PathName; +import com.be.sportizebe.global.s3.service.S3Service; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @@ -24,16 +27,23 @@ public class ClubServiceImpl implements ClubService { private final ClubRepository clubRepository; private final ChatRoomService chatRoomService; private final ClubMemberRepository clubMemberRepository; + private final S3Service s3Service; @Override @Transactional - public ClubResponse createClub(ClubCreateRequest request, User user) { + public ClubResponse createClub(ClubCreateRequest request, MultipartFile image, User user) { if (clubRepository.existsByName(request.name())) { throw new CustomException(ClubErrorCode.CLUB_NAME_DUPLICATED); } + // 이미지가 있으면 S3에 업로드 + String clubImageUrl = null; + if (image != null && !image.isEmpty()) { + clubImageUrl = s3Service.uploadFile(PathName.CLUB, image); + } + // 동호회 엔티티 생성 - Club club = request.toEntity(user); + Club club = request.toEntity(user, clubImageUrl); clubRepository.save(club); // 동호회 멤버 테이블에 방장(동호회 생성자) 추가 @@ -56,6 +66,7 @@ public ClubResponse updateClub(Long clubId, ClubUpdateRequest request, User user Club club = clubRepository.findById(clubId) .orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND)); + // 동호회 방장만 수정 가능하도록 검증 if (club.getLeader().getId() != user.getId()) { throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED); } @@ -72,4 +83,29 @@ public ClubResponse updateClub(Long clubId, ClubUpdateRequest request, User user return ClubResponse.from(club); } + + @Override + @Transactional + public ClubImageResponse updateClubImage(Long clubId, MultipartFile image, User user) { + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND)); + + // 동호회 방장만 수정 가능하도록 검증 + if (club.getLeader().getId() != user.getId()) { + throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED); + } + + // 기존 이미지가 있으면 S3에서 삭제 + if (club.getClubImage() != null) { + s3Service.deleteFile(club.getClubImage()); + } + + // 새 이미지 S3에 업로드 + String clubImageUrl = s3Service.uploadFile(PathName.CLUB, image); + + // 동호회 이미지 URL 업데이트 + club.updateClubImage(clubImageUrl); + + return ClubImageResponse.from(clubImageUrl); + } } diff --git a/src/main/java/com/be/sportizebe/domain/post/controller/PostController.java b/src/main/java/com/be/sportizebe/domain/post/controller/PostController.java index 175ae5c..4160c9d 100644 --- a/src/main/java/com/be/sportizebe/domain/post/controller/PostController.java +++ b/src/main/java/com/be/sportizebe/domain/post/controller/PostController.java @@ -17,9 +17,11 @@ import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @RequiredArgsConstructor @@ -29,13 +31,14 @@ public class PostController { private final PostService postService; - @PostMapping("/posts/{property}") - @Operation(summary = "게시글 생성", description = "게시판 종류별 게시글 생성") + @PostMapping(value = "/posts/{property}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "게시글 생성", description = "게시판 종류별 게시글 생성 (이미지 첨부 가능)") public ResponseEntity> createPost( @Parameter(description = "게시판 종류 (SOCCER, BASKETBALL, FREE)") @PathVariable PostProperty property, - @RequestBody @Valid CreatePostRequest request, + @RequestPart("request") @Valid CreatePostRequest request, + @RequestPart(value = "image", required = false) MultipartFile image, // JSON 아님 @AuthenticationPrincipal User user) { - PostResponse response = postService.createPost(property, request, user); + PostResponse response = postService.createPost(property, request, image, user); return ResponseEntity.status(HttpStatus.CREATED) .body(BaseResponse.success("게시글 생성 성공", response)); } diff --git a/src/main/java/com/be/sportizebe/domain/post/dto/request/CreatePostRequest.java b/src/main/java/com/be/sportizebe/domain/post/dto/request/CreatePostRequest.java index ed609fa..e1c1d76 100644 --- a/src/main/java/com/be/sportizebe/domain/post/dto/request/CreatePostRequest.java +++ b/src/main/java/com/be/sportizebe/domain/post/dto/request/CreatePostRequest.java @@ -10,10 +10,9 @@ public record CreatePostRequest( @NotBlank(message = "제목은 필수입니다.") String title, @NotBlank(message = "내용은 필수입니다.") String content, - boolean isAnonymous, - String imgUrl) { -// TODO : S3 세팅 후 imgUrl은 multipartform으로 변경 - public Post toEntity(PostProperty property, User user) { // DTO -> Entity 변환 + boolean isAnonymous) { + + public Post toEntity(PostProperty property, User user, String imgUrl) { return Post.builder() .title(title) .content(content) diff --git a/src/main/java/com/be/sportizebe/domain/post/service/PostService.java b/src/main/java/com/be/sportizebe/domain/post/service/PostService.java index 1490c63..8cde826 100644 --- a/src/main/java/com/be/sportizebe/domain/post/service/PostService.java +++ b/src/main/java/com/be/sportizebe/domain/post/service/PostService.java @@ -7,9 +7,10 @@ import com.be.sportizebe.domain.user.entity.User; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.web.multipart.MultipartFile; public interface PostService { - PostResponse createPost(PostProperty property, CreatePostRequest request, User user); // 게시글 생성 + PostResponse createPost(PostProperty property, CreatePostRequest request, MultipartFile image, User user); // 게시글 생성 PostResponse updatePost(Long postId, UpdatePostRequest request, User user); // 게시글 수정 diff --git a/src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java b/src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java index af5ec47..bc62910 100644 --- a/src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java +++ b/src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java @@ -9,12 +9,15 @@ import com.be.sportizebe.domain.post.repository.PostRepository; import com.be.sportizebe.domain.user.entity.User; import com.be.sportizebe.global.exception.CustomException; +import com.be.sportizebe.global.s3.enums.PathName; +import com.be.sportizebe.global.s3.service.S3Service; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; @Slf4j @Service @@ -22,15 +25,22 @@ public class PostServiceImpl implements PostService { private final PostRepository postRepository; + private final S3Service s3Service; @Override @Transactional - public PostResponse createPost(PostProperty property, CreatePostRequest request, User user) { - Post post = request.toEntity(property, user); // 요청 dto 데이터를 entity로 변환 + public PostResponse createPost(PostProperty property, CreatePostRequest request, MultipartFile image, User user) { + // 이미지가 있으면 S3에 업로드 + String imgUrl = null; + if (image != null && !image.isEmpty()) { + imgUrl = s3Service.uploadFile(PathName.POST, image); + } + + Post post = request.toEntity(property, user, imgUrl); - Post savedPost = postRepository.save(post); // db에 저장 + Post savedPost = postRepository.save(post); - return PostResponse.from(savedPost); // entity를 dto로 변환하여 응답 + return PostResponse.from(savedPost); } @Override diff --git a/src/main/java/com/be/sportizebe/domain/user/controller/UserController.java b/src/main/java/com/be/sportizebe/domain/user/controller/UserController.java index a3729d2..103bf41 100644 --- a/src/main/java/com/be/sportizebe/domain/user/controller/UserController.java +++ b/src/main/java/com/be/sportizebe/domain/user/controller/UserController.java @@ -1,19 +1,23 @@ package com.be.sportizebe.domain.user.controller; +import com.be.sportizebe.domain.auth.exception.AuthErrorCode; import com.be.sportizebe.domain.user.dto.request.SignUpRequest; +import com.be.sportizebe.domain.user.dto.response.ProfileImageResponse; import com.be.sportizebe.domain.user.dto.response.SignUpResponse; -import com.be.sportizebe.domain.user.service.UserService; +import com.be.sportizebe.domain.user.entity.User; +import com.be.sportizebe.domain.user.service.UserServiceImpl; +import com.be.sportizebe.global.exception.CustomException; import com.be.sportizebe.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @RequiredArgsConstructor @@ -21,7 +25,7 @@ @Tag(name = "user", description = "사용자 관련 API") public class UserController { - private final UserService userService; + private final UserServiceImpl userService; @PostMapping("/signup") @Operation(summary = "회원가입", description = "이메일과 비밀번호로 회원가입") @@ -30,4 +34,14 @@ public ResponseEntity> signUp(@RequestBody @Valid S return ResponseEntity.status(HttpStatus.CREATED) .body(BaseResponse.success("회원가입 성공", response)); } + + @PostMapping(value = "/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "프로필 사진 업로드", description = "사용자 프로필 사진을 업로드합니다. (최대 5MB, jpg/jpeg/png/gif/webp 지원)") + public ResponseEntity> uploadProfileImage( + @AuthenticationPrincipal User user, + @RequestPart("file") MultipartFile file + ) { + ProfileImageResponse response = userService.uploadProfileImage(user.getId(), file); + return ResponseEntity.ok(BaseResponse.success("프로필 사진 업로드 성공", response)); + } } \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/user/dto/response/ProfileImageResponse.java b/src/main/java/com/be/sportizebe/domain/user/dto/response/ProfileImageResponse.java new file mode 100644 index 0000000..52becee --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/user/dto/response/ProfileImageResponse.java @@ -0,0 +1,13 @@ +package com.be.sportizebe.domain.user.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "프로필 이미지 업로드 응답") +public record ProfileImageResponse( + @Schema(description = "프로필 이미지 URL", example = "https://bucket.s3.ap-northeast-2.amazonaws.com/profile/uuid.jpg") + String profileImageUrl +) { + public static ProfileImageResponse from(String profileImageUrl) { + return new ProfileImageResponse(profileImageUrl); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/user/entity/User.java b/src/main/java/com/be/sportizebe/domain/user/entity/User.java index fc5ff2c..6cc26a0 100644 --- a/src/main/java/com/be/sportizebe/domain/user/entity/User.java +++ b/src/main/java/com/be/sportizebe/domain/user/entity/User.java @@ -44,6 +44,8 @@ public class User extends BaseTimeEntity { @Enumerated(EnumType.STRING) private List interestType; // 사용자 관심 종목 + private String profileImage; // 프로필 사진 URL + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List posts = new ArrayList<>(); // 작성한 게시글 목록 @@ -51,4 +53,8 @@ public class User extends BaseTimeEntity { public void updateRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } + + public void updateProfileImage(String profileImage) { + this.profileImage = profileImage; + } } diff --git a/src/main/java/com/be/sportizebe/domain/user/service/UserService.java b/src/main/java/com/be/sportizebe/domain/user/service/UserService.java index 8826197..6efa18e 100644 --- a/src/main/java/com/be/sportizebe/domain/user/service/UserService.java +++ b/src/main/java/com/be/sportizebe/domain/user/service/UserService.java @@ -1,51 +1,16 @@ package com.be.sportizebe.domain.user.service; import com.be.sportizebe.domain.user.dto.request.SignUpRequest; +import com.be.sportizebe.domain.user.dto.response.ProfileImageResponse; import com.be.sportizebe.domain.user.dto.response.SignUpResponse; -import com.be.sportizebe.domain.user.entity.Role; -import com.be.sportizebe.domain.user.entity.User; -import com.be.sportizebe.domain.user.exception.UserErrorCode; -import com.be.sportizebe.domain.user.repository.UserRepository; -import com.be.sportizebe.global.exception.CustomException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; -@Service -@Slf4j -@RequiredArgsConstructor -public class UserService { +public interface UserService { - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; + // 회원가입 + SignUpResponse signUp(SignUpRequest request); - @Transactional - public SignUpResponse signUp(SignUpRequest request) { + // 프로필 사진 업로드 + ProfileImageResponse uploadProfileImage(Long userId, MultipartFile file); - // 사용자 아이디 중복 체크 - if (userRepository.existsByUsername(request.username())) { - throw new CustomException(UserErrorCode.DUPLICATE_USERNAME); - } - - // 사용자 닉네임 중복 체크 - if (userRepository.existsByNickname(request.nickName())) { - throw new CustomException(UserErrorCode.DUPLICATE_NICKNAME); - } - - String encodedPassword = passwordEncoder.encode(request.password()); - - User user = User.builder() - .username(request.username()) - .password(encodedPassword) - .nickname(request.nickName()) - .role(Role.USER) - .build(); - - User savedUser = userRepository.save(user); - log.info("새로운 사용자 생성: {}", savedUser.getUsername()); - - return SignUpResponse.from(savedUser); - } -} \ No newline at end of file +} diff --git a/src/main/java/com/be/sportizebe/domain/user/service/UserServiceImpl.java b/src/main/java/com/be/sportizebe/domain/user/service/UserServiceImpl.java new file mode 100644 index 0000000..c25180e --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/user/service/UserServiceImpl.java @@ -0,0 +1,79 @@ +package com.be.sportizebe.domain.user.service; + +import com.be.sportizebe.domain.user.dto.request.SignUpRequest; +import com.be.sportizebe.domain.user.dto.response.ProfileImageResponse; +import com.be.sportizebe.domain.user.dto.response.SignUpResponse; +import com.be.sportizebe.domain.user.entity.Role; +import com.be.sportizebe.domain.user.entity.User; +import com.be.sportizebe.domain.user.exception.UserErrorCode; +import com.be.sportizebe.domain.user.repository.UserRepository; +import com.be.sportizebe.global.exception.CustomException; +import com.be.sportizebe.global.s3.enums.PathName; +import com.be.sportizebe.global.s3.service.S3Service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@Slf4j +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final S3Service s3Service; + + @Override + @Transactional + public SignUpResponse signUp(SignUpRequest request) { + + // 사용자 아이디 중복 체크 + if (userRepository.existsByUsername(request.username())) { + throw new CustomException(UserErrorCode.DUPLICATE_USERNAME); + } + + // 사용자 닉네임 중복 체크 + if (userRepository.existsByNickname(request.nickName())) { + throw new CustomException(UserErrorCode.DUPLICATE_NICKNAME); + } + + String encodedPassword = passwordEncoder.encode(request.password()); + + User user = User.builder() + .username(request.username()) + .password(encodedPassword) + .nickname(request.nickName()) + .role(Role.USER) + .build(); + + User savedUser = userRepository.save(user); + log.info("새로운 사용자 생성: {}", savedUser.getUsername()); + + return SignUpResponse.from(savedUser); + } + + @Override + @Transactional + public ProfileImageResponse uploadProfileImage(Long userId, MultipartFile file) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + // 기존 프로필 이미지가 있으면 S3에서 삭제 + if (user.getProfileImage() != null) { + s3Service.deleteFile(user.getProfileImage()); + } + + // 새 프로필 이미지 S3에 업로드 + String profileImageUrl = s3Service.uploadFile(PathName.PROFILE, file); + + // 사용자 프로필 이미지 URL 업데이트 + user.updateProfileImage(profileImageUrl); + + log.info("사용자 프로필 이미지 업로드 완료: userId={}, url={}", userId, profileImageUrl); + + return ProfileImageResponse.from(profileImageUrl); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/global/config/S3Config.java b/src/main/java/com/be/sportizebe/global/config/S3Config.java new file mode 100644 index 0000000..494438a --- /dev/null +++ b/src/main/java/com/be/sportizebe/global/config/S3Config.java @@ -0,0 +1,45 @@ +package com.be.sportizebe.global.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Getter +@Configuration +public class S3Config { + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucket; // 버킷 명 + + @Value("${spring.cloud.aws.s3.path.profile}") + private String profile; // 프로필 사진 + + @Value("${spring.cloud.aws.s3.path.club}") + private String club; + + @Value("${spring.cloud.aws.s3.path.post}") + private String post; + + @Value("${spring.cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${spring.cloud.aws.credentials.secret-key}") + private String secretKey; + + @Bean + S3Client s3Client() { + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); + + return S3Client.builder() + .region(Region.AP_NORTHEAST_2) + // DefaultCredentialsProvider: 환경 변수나 AWS 프로필 파일에서 자격 증명을 찾음 + // StaticCredentialsProvider: 개발환경에서는 application.yml에서 찾도록 설정 + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build(); + } +} diff --git a/src/main/java/com/be/sportizebe/global/s3/convertor/MultipartJackson2HttpMessageConverter.java b/src/main/java/com/be/sportizebe/global/s3/convertor/MultipartJackson2HttpMessageConverter.java new file mode 100644 index 0000000..02fb6e7 --- /dev/null +++ b/src/main/java/com/be/sportizebe/global/s3/convertor/MultipartJackson2HttpMessageConverter.java @@ -0,0 +1,33 @@ +package com.be.sportizebe.global.s3.convertor; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Type; + +@Component +public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter { + /** + * "Content-Type: multipart/form-data" 헤더를 지원하는 HTTP 요청 변환기 + */ + public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) { + super(objectMapper, MediaType.APPLICATION_OCTET_STREAM); + } + + @Override + public boolean canWrite(Class clazz, MediaType mediaType) { + return false; + } + + @Override + public boolean canWrite(Type type, Class clazz, MediaType mediaType) { + return false; + } + + @Override + protected boolean canWrite(MediaType mediaType) { + return false; + } +} diff --git a/src/main/java/com/be/sportizebe/global/s3/enums/PathName.java b/src/main/java/com/be/sportizebe/global/s3/enums/PathName.java new file mode 100644 index 0000000..eda2675 --- /dev/null +++ b/src/main/java/com/be/sportizebe/global/s3/enums/PathName.java @@ -0,0 +1,16 @@ +package com.be.sportizebe.global.s3.enums; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PathName { + @Schema(description = "프로필 사진") + PROFILE, + @Schema(description = "동호회 사진") + CLUB, + @Schema(description = "게시글 사진") + POST; +} diff --git a/src/main/java/com/be/sportizebe/global/s3/exception/S3ErrorCode.java b/src/main/java/com/be/sportizebe/global/s3/exception/S3ErrorCode.java new file mode 100644 index 0000000..583e0d4 --- /dev/null +++ b/src/main/java/com/be/sportizebe/global/s3/exception/S3ErrorCode.java @@ -0,0 +1,20 @@ +package com.be.sportizebe.global.s3.exception; + +import com.be.sportizebe.global.exception.model.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum S3ErrorCode implements BaseErrorCode { + FILE_NOT_FOUND("IMG4001", "존재하지 않는 이미지입니다.", HttpStatus.NOT_FOUND), + FILE_SIZE_INVALID("IMG4002", "파일 크기는 5MB를 초과할 수 없습니다.", HttpStatus.BAD_REQUEST), + FILE_TYPE_INVALID("IMG4003", "이미지 파일만 업로드 가능합니다.", HttpStatus.BAD_REQUEST), + FILE_URL_INVALID("IMG4004", "유효하지 않은 이미지 URL입니다.", HttpStatus.BAD_REQUEST), + FILE_SERVER_ERROR("IMG5001", "이미지 처리 중 서버 에러, 관리자에게 문의 바랍니다.", HttpStatus.INTERNAL_SERVER_ERROR); + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/com/be/sportizebe/global/s3/service/S3Service.java b/src/main/java/com/be/sportizebe/global/s3/service/S3Service.java new file mode 100644 index 0000000..6453283 --- /dev/null +++ b/src/main/java/com/be/sportizebe/global/s3/service/S3Service.java @@ -0,0 +1,13 @@ +package com.be.sportizebe.global.s3.service; + +import com.be.sportizebe.global.s3.enums.PathName; +import org.springframework.web.multipart.MultipartFile; + +public interface S3Service { + + // 파일 업로드 + String uploadFile(PathName pathName, MultipartFile file); + + // 파일 삭제 + void deleteFile(String fileUrl); +} diff --git a/src/main/java/com/be/sportizebe/global/s3/service/S3ServiceImpl.java b/src/main/java/com/be/sportizebe/global/s3/service/S3ServiceImpl.java new file mode 100644 index 0000000..b240440 --- /dev/null +++ b/src/main/java/com/be/sportizebe/global/s3/service/S3ServiceImpl.java @@ -0,0 +1,118 @@ +package com.be.sportizebe.global.s3.service; + +import com.be.sportizebe.global.config.S3Config; +import com.be.sportizebe.global.exception.CustomException; +import com.be.sportizebe.global.s3.enums.PathName; +import com.be.sportizebe.global.s3.exception.S3ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +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.PutObjectRequest; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3ServiceImpl implements S3Service { + + private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + private static final List ALLOWED_EXTENSIONS = List.of(".jpg", ".jpeg", ".png", ".gif", ".webp"); + + private final S3Client s3Client; + private final S3Config s3Config; + + @Override + public String uploadFile(PathName pathName, MultipartFile file) { + + validateFile(file); + + String fileName = createFileName(pathName, file.getOriginalFilename()); + + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(s3Config.getBucket()) + .key(fileName) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + return getFileUrl(fileName); + } catch (IOException e) { + log.error("S3 파일 업로드 실패: {}", e.getMessage()); + throw new CustomException(S3ErrorCode.FILE_SERVER_ERROR); + } + } + + @Override + public void deleteFile(String fileUrl) { + if (fileUrl == null || !fileUrl.contains(".com/")) { + throw new CustomException(S3ErrorCode.FILE_URL_INVALID); + } + + String fileName = extractFileName(fileUrl); + + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(s3Config.getBucket()) + .key(fileName) + .build(); + + s3Client.deleteObject(deleteObjectRequest); + log.info("S3 파일 삭제 완료: {}", fileName); + } + + // 파일 유효성 검증 + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new CustomException(S3ErrorCode.FILE_NOT_FOUND); + } + + if (file.getSize() > MAX_FILE_SIZE) { + throw new CustomException(S3ErrorCode.FILE_SIZE_INVALID); + } + + String extension = extractExtension(file.getOriginalFilename()).toLowerCase(); + if (!ALLOWED_EXTENSIONS.contains(extension)) { + throw new CustomException(S3ErrorCode.FILE_TYPE_INVALID); + } + } + + // key (경로 + 파일명) 생성 + private String createFileName(PathName pathName, String originalFileName) { + String extension = extractExtension(originalFileName); + + return switch (pathName) { + case PROFILE -> s3Config.getProfile(); + case CLUB -> s3Config.getClub(); + case POST -> s3Config.getPost(); + } + + "/" + UUID.randomUUID() + extension; + } + + // 확장자 추출 + private String extractExtension(String originalFileName) { + if (originalFileName == null || !originalFileName.contains(".")) { + return ""; + } + return originalFileName.substring(originalFileName.lastIndexOf(".")); + } + + // 파일명 추출 + private String extractFileName(String fileUrl) { + // https://bucket.s3.region.amazonaws.com/folder/filename.ext -> folder/filename.ext + return fileUrl.substring(fileUrl.indexOf(".com/") + 5); + } + + // s3 url : https://{버킷명}.s3.ap-northeast-2.amazonaws.com/{파일명} + private String getFileUrl(String fileName) { + return String.format("https://%s.s3.ap-northeast-2.amazonaws.com/%s", + s3Config.getBucket(), fileName); + } +} diff --git a/src/main/java/com/be/sportizebe/global/security/SecurityConfig.java b/src/main/java/com/be/sportizebe/global/security/SecurityConfig.java index d38aae7..6a481ba 100644 --- a/src/main/java/com/be/sportizebe/global/security/SecurityConfig.java +++ b/src/main/java/com/be/sportizebe/global/security/SecurityConfig.java @@ -35,10 +35,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() - .requestMatchers("/api/users/**").permitAll() + .requestMatchers("/api/users/signup").permitAll() + .requestMatchers("/api/users/**").authenticated() .requestMatchers("/ws-stomp/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/**").permitAll() // 조회는 인증 필요 없음 .anyRequest().authenticated() ) .exceptionHandling(exception -> exception diff --git a/src/main/resources b/src/main/resources index 07ba8ff..05d0469 160000 --- a/src/main/resources +++ b/src/main/resources @@ -1 +1 @@ -Subproject commit 07ba8ffcf3fa141b112f56bb2937e24a66eab6c0 +Subproject commit 05d0469d24d5c627fe5a94af9f6a7e797be874e6