Skip to content

Commit 5ba591d

Browse files
authored
Merge pull request #37 from Sportize/feat/s3
✨ Feat: S3 관련 설정, 파일 업로드 관련 기능 구현
2 parents 62ddfef + dbb2890 commit 5ba591d

File tree

25 files changed

+488
-80
lines changed

25 files changed

+488
-80
lines changed

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ dependencies {
5050
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0'
5151

5252
// AWS S3
53-
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
53+
implementation "software.amazon.awssdk:s3:2.41.18"
5454

5555
// PostGIS 공간 타입 <-> JTS(Point 등) 매핑 지원
5656
implementation "org.hibernate.orm:hibernate-spatial"

src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
import com.be.sportizebe.domain.club.dto.request.ClubCreateRequest;
44
import com.be.sportizebe.domain.club.dto.request.ClubUpdateRequest;
5+
import com.be.sportizebe.domain.club.dto.response.ClubImageResponse;
56
import com.be.sportizebe.domain.club.dto.response.ClubResponse;
67
import com.be.sportizebe.domain.club.service.ClubServiceImpl;
7-
import com.be.sportizebe.domain.user.entity.SportType;
88
import com.be.sportizebe.domain.user.entity.User;
99
import com.be.sportizebe.global.response.BaseResponse;
1010
import io.swagger.v3.oas.annotations.Operation;
@@ -13,9 +13,11 @@
1313
import jakarta.validation.Valid;
1414
import lombok.RequiredArgsConstructor;
1515
import org.springframework.http.HttpStatus;
16+
import org.springframework.http.MediaType;
1617
import org.springframework.http.ResponseEntity;
1718
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1819
import org.springframework.web.bind.annotation.*;
20+
import org.springframework.web.multipart.MultipartFile;
1921

2022
@RestController
2123
@RequiredArgsConstructor
@@ -25,12 +27,13 @@ public class ClubController {
2527

2628
private final ClubServiceImpl clubService;
2729

28-
@PostMapping("")
29-
@Operation(summary = "동호회 생성", description = "종목별 동호회를 생성합니다. 생성한 사용자가 동호회장이 됩니다.")
30+
@PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
31+
@Operation(summary = "동호회 생성", description = "종목별 동호회를 생성합니다. 생성한 사용자가 동호회장이 됩니다. (이미지 첨부 가능)")
3032
public ResponseEntity<BaseResponse<ClubResponse>> createClub(
31-
@RequestBody @Valid ClubCreateRequest request,
33+
@RequestPart("request") @Valid ClubCreateRequest request,
34+
@RequestPart(value = "image", required = false) MultipartFile image,
3235
@AuthenticationPrincipal User user) {
33-
ClubResponse response = clubService.createClub(request, user);
36+
ClubResponse response = clubService.createClub(request, image, user);
3437
return ResponseEntity.status(HttpStatus.CREATED)
3538
.body(BaseResponse.success("동호회 생성 성공", response));
3639
}
@@ -44,4 +47,14 @@ public ResponseEntity<BaseResponse<ClubResponse>> updateClub(
4447
ClubResponse response = clubService.updateClub(clubId, request, user);
4548
return ResponseEntity.ok(BaseResponse.success("동호회 수정 성공", response));
4649
}
50+
51+
@PostMapping(value = "/{clubId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
52+
@Operation(summary = "동호회 사진 수정", description = "동호회 사진을 수정합니다. 동호회장만 수정할 수 있습니다.")
53+
public ResponseEntity<BaseResponse<ClubImageResponse>> updateClubImage(
54+
@Parameter(description = "동호회 ID") @PathVariable Long clubId,
55+
@RequestPart("image") MultipartFile image,
56+
@AuthenticationPrincipal User user) {
57+
ClubImageResponse response = clubService.updateClubImage(clubId, image, user);
58+
return ResponseEntity.ok(BaseResponse.success("동호회 사진 수정 성공", response));
59+
}
4760
}

src/main/java/com/be/sportizebe/domain/club/dto/request/ClubCreateRequest.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,14 @@ public record ClubCreateRequest(
1212
@Schema(description = "동호회 소개", example = "매주 토요일 축구합니다") String introduce,
1313
@Schema(description = "동호회 관련 종목", example = "SOCCER") SportType clubType,
1414
@Schema(description = "최대 정원", example = "20") Integer maxMembers) {
15-
// 관련 종목은 파라미터로 받음
16-
// TODO : S3 세팅 후 imgUrl은 multipartform으로 변경
1715

18-
public Club toEntity(User user) {
16+
public Club toEntity(User user, String clubImage) {
1917
return Club.builder()
2018
.name(name)
2119
.introduce(introduce)
2220
.clubType(clubType)
2321
.maxMembers(maxMembers)
22+
.clubImage(clubImage)
2423
.leader(user)
2524
.build();
2625
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.be.sportizebe.domain.club.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
5+
@Schema(description = "동호회 이미지 응답")
6+
public record ClubImageResponse(
7+
@Schema(description = "동호회 이미지 URL", example = "https://bucket.s3.ap-northeast-2.amazonaws.com/club/uuid.jpg")
8+
String clubImageUrl
9+
) {
10+
public static ClubImageResponse from(String clubImageUrl) {
11+
return new ClubImageResponse(clubImageUrl);
12+
}
13+
}

src/main/java/com/be/sportizebe/domain/club/dto/response/ClubResponse.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ public record ClubResponse(
1313
@Schema(description = "동호회 소개", example = "매주 토요일 축구합니다") String introduce,
1414
@Schema(description = "동호회 관련 종목", example = "SOCCER") SportType clubType,
1515
@Schema(description = "최대 정원", example = "20") Integer maxMembers,
16-
@Schema(description = "동호회장 닉네임", example = "닉네임") String leaderNickname) {
16+
@Schema(description = "동호회장 닉네임", example = "닉네임") String leaderNickname,
17+
@Schema(description = "동호회 이미지 URL") String clubImageUrl) {
1718

1819
public static ClubResponse from(Club club) {
1920
return ClubResponse.builder()
@@ -23,6 +24,7 @@ public static ClubResponse from(Club club) {
2324
.clubType(club.getClubType())
2425
.maxMembers(club.getMaxMembers())
2526
.leaderNickname(club.getLeader().getNickname())
27+
.clubImageUrl(club.getClubImage())
2628
.build();
2729
}
2830
}

src/main/java/com/be/sportizebe/domain/club/entity/Club.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ public class Club extends BaseTimeEntity {
3737
@Column(nullable = false)
3838
private Integer maxMembers; // 최대 정원
3939

40+
private String clubImage; // 동호회 사진 URL
41+
4042
@ManyToOne(fetch = FetchType.LAZY)
4143
@JoinColumn(name = "leader_id", nullable = false)
4244
private User leader; // 동호회장
@@ -54,4 +56,8 @@ public void update(String name, String introduce, Integer maxMembers, SportType
5456
this.maxMembers = maxMembers;
5557
this.clubType = clubType;
5658
}
59+
60+
public void updateClubImage(String clubImage) {
61+
this.clubImage = clubImage;
62+
}
5763
}

src/main/java/com/be/sportizebe/domain/club/service/ClubService.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
import com.be.sportizebe.domain.club.dto.request.ClubCreateRequest;
44
import com.be.sportizebe.domain.club.dto.request.ClubUpdateRequest;
5+
import com.be.sportizebe.domain.club.dto.response.ClubImageResponse;
56
import com.be.sportizebe.domain.club.dto.response.ClubResponse;
6-
import com.be.sportizebe.domain.user.entity.SportType;
77
import com.be.sportizebe.domain.user.entity.User;
8+
import org.springframework.web.multipart.MultipartFile;
89

910
public interface ClubService {
10-
ClubResponse createClub(ClubCreateRequest request, User user); // 동호회 생성
11+
ClubResponse createClub(ClubCreateRequest request, MultipartFile image, User user); // 동호회 생성
1112

1213
ClubResponse updateClub(Long clubId, ClubUpdateRequest request, User user); // 동호회 수정
14+
15+
ClubImageResponse updateClubImage(Long clubId, MultipartFile image, User user); // 동호회 사진 수정
1316
}

src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@
33
import com.be.sportizebe.domain.chat.service.ChatRoomService;
44
import com.be.sportizebe.domain.club.dto.request.ClubCreateRequest;
55
import com.be.sportizebe.domain.club.dto.request.ClubUpdateRequest;
6+
import com.be.sportizebe.domain.club.dto.response.ClubImageResponse;
67
import com.be.sportizebe.domain.club.dto.response.ClubResponse;
78
import com.be.sportizebe.domain.club.entity.Club;
89
import com.be.sportizebe.domain.club.entity.ClubMember;
910
import com.be.sportizebe.domain.club.exception.ClubErrorCode;
1011
import com.be.sportizebe.domain.club.repository.ClubMemberRepository;
1112
import com.be.sportizebe.domain.club.repository.ClubRepository;
12-
import com.be.sportizebe.domain.user.entity.SportType;
1313
import com.be.sportizebe.domain.user.entity.User;
1414
import com.be.sportizebe.global.exception.CustomException;
15+
import com.be.sportizebe.global.s3.enums.PathName;
16+
import com.be.sportizebe.global.s3.service.S3Service;
1517
import lombok.RequiredArgsConstructor;
1618
import org.springframework.stereotype.Service;
1719
import org.springframework.transaction.annotation.Transactional;
20+
import org.springframework.web.multipart.MultipartFile;
1821

1922
@Service
2023
@RequiredArgsConstructor
@@ -24,16 +27,23 @@ public class ClubServiceImpl implements ClubService {
2427
private final ClubRepository clubRepository;
2528
private final ChatRoomService chatRoomService;
2629
private final ClubMemberRepository clubMemberRepository;
30+
private final S3Service s3Service;
2731

2832
@Override
2933
@Transactional
30-
public ClubResponse createClub(ClubCreateRequest request, User user) {
34+
public ClubResponse createClub(ClubCreateRequest request, MultipartFile image, User user) {
3135
if (clubRepository.existsByName(request.name())) {
3236
throw new CustomException(ClubErrorCode.CLUB_NAME_DUPLICATED);
3337
}
3438

39+
// 이미지가 있으면 S3에 업로드
40+
String clubImageUrl = null;
41+
if (image != null && !image.isEmpty()) {
42+
clubImageUrl = s3Service.uploadFile(PathName.CLUB, image);
43+
}
44+
3545
// 동호회 엔티티 생성
36-
Club club = request.toEntity(user);
46+
Club club = request.toEntity(user, clubImageUrl);
3747
clubRepository.save(club);
3848

3949
// 동호회 멤버 테이블에 방장(동호회 생성자) 추가
@@ -56,6 +66,7 @@ public ClubResponse updateClub(Long clubId, ClubUpdateRequest request, User user
5666
Club club = clubRepository.findById(clubId)
5767
.orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND));
5868

69+
// 동호회 방장만 수정 가능하도록 검증
5970
if (club.getLeader().getId() != user.getId()) {
6071
throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED);
6172
}
@@ -72,4 +83,29 @@ public ClubResponse updateClub(Long clubId, ClubUpdateRequest request, User user
7283

7384
return ClubResponse.from(club);
7485
}
86+
87+
@Override
88+
@Transactional
89+
public ClubImageResponse updateClubImage(Long clubId, MultipartFile image, User user) {
90+
Club club = clubRepository.findById(clubId)
91+
.orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND));
92+
93+
// 동호회 방장만 수정 가능하도록 검증
94+
if (club.getLeader().getId() != user.getId()) {
95+
throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED);
96+
}
97+
98+
// 기존 이미지가 있으면 S3에서 삭제
99+
if (club.getClubImage() != null) {
100+
s3Service.deleteFile(club.getClubImage());
101+
}
102+
103+
// 새 이미지 S3에 업로드
104+
String clubImageUrl = s3Service.uploadFile(PathName.CLUB, image);
105+
106+
// 동호회 이미지 URL 업데이트
107+
club.updateClubImage(clubImageUrl);
108+
109+
return ClubImageResponse.from(clubImageUrl);
110+
}
75111
}

src/main/java/com/be/sportizebe/domain/post/controller/PostController.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
import org.springframework.data.domain.Sort;
1818
import org.springframework.data.web.PageableDefault;
1919
import org.springframework.http.HttpStatus;
20+
import org.springframework.http.MediaType;
2021
import org.springframework.http.ResponseEntity;
2122
import org.springframework.security.core.annotation.AuthenticationPrincipal;
2223
import org.springframework.web.bind.annotation.*;
24+
import org.springframework.web.multipart.MultipartFile;
2325

2426
@RestController
2527
@RequiredArgsConstructor
@@ -29,13 +31,14 @@ public class PostController {
2931

3032
private final PostService postService;
3133

32-
@PostMapping("/posts/{property}")
33-
@Operation(summary = "게시글 생성", description = "게시판 종류별 게시글 생성")
34+
@PostMapping(value = "/posts/{property}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
35+
@Operation(summary = "게시글 생성", description = "게시판 종류별 게시글 생성 (이미지 첨부 가능)")
3436
public ResponseEntity<BaseResponse<PostResponse>> createPost(
3537
@Parameter(description = "게시판 종류 (SOCCER, BASKETBALL, FREE)") @PathVariable PostProperty property,
36-
@RequestBody @Valid CreatePostRequest request,
38+
@RequestPart("request") @Valid CreatePostRequest request,
39+
@RequestPart(value = "image", required = false) MultipartFile image, // JSON 아님
3740
@AuthenticationPrincipal User user) {
38-
PostResponse response = postService.createPost(property, request, user);
41+
PostResponse response = postService.createPost(property, request, image, user);
3942
return ResponseEntity.status(HttpStatus.CREATED)
4043
.body(BaseResponse.success("게시글 생성 성공", response));
4144
}

src/main/java/com/be/sportizebe/domain/post/dto/request/CreatePostRequest.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,9 @@
1010
public record CreatePostRequest(
1111
@NotBlank(message = "제목은 필수입니다.") String title,
1212
@NotBlank(message = "내용은 필수입니다.") String content,
13-
boolean isAnonymous,
14-
String imgUrl) {
15-
// TODO : S3 세팅 후 imgUrl은 multipartform으로 변경
16-
public Post toEntity(PostProperty property, User user) { // DTO -> Entity 변환
13+
boolean isAnonymous) {
14+
15+
public Post toEntity(PostProperty property, User user, String imgUrl) {
1716
return Post.builder()
1817
.title(title)
1918
.content(content)

0 commit comments

Comments
 (0)