Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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<BaseResponse<ClubResponse>> 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));
}
Expand All @@ -44,4 +47,14 @@ public ResponseEntity<BaseResponse<ClubResponse>> 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<BaseResponse<ClubImageResponse>> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -23,6 +24,7 @@ public static ClubResponse from(Club club) {
.clubType(club.getClubType())
.maxMembers(club.getMaxMembers())
.leaderNickname(club.getLeader().getNickname())
.clubImageUrl(club.getClubImage())
.build();
}
}
6 changes: 6 additions & 0 deletions src/main/java/com/be/sportizebe/domain/club/entity/Club.java
Original file line number Diff line number Diff line change
Expand Up @@ -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; // 동호회장
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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); // 동호회 사진 수정
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);

// 동호회 멤버 테이블에 방장(동호회 생성자) 추가
Expand All @@ -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);
}
Expand All @@ -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);
}
Comment on lines +87 to +110
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

기존 이미지 삭제를 업로드 성공 이후로 미루세요.

현재 순서는 업로드 실패 시 기존 이미지가 소실됩니다. 새 이미지 업로드 성공 후에 기존 이미지를 삭제하는 방식이 더 안전합니다.

✅ 제안 수정안
-    // 기존 이미지가 있으면 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);
+    String oldImageUrl = club.getClubImage();
+
+    // 새 이미지 S3에 업로드
+    String clubImageUrl = s3Service.uploadFile(PathName.CLUB, image);
+
+    // 동호회 이미지 URL 업데이트
+    club.updateClubImage(clubImageUrl);
+
+    // 기존 이미지가 있으면 S3에서 삭제
+    if (oldImageUrl != null) {
+      s3Service.deleteFile(oldImageUrl);
+    }
+
+    return ClubImageResponse.from(clubImageUrl);
🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java`
around lines 87 - 110, The current updateClubImage deletes the old S3 file
before attempting to upload the new one, risking image loss if upload fails;
change updateClubImage (in ClubServiceImpl) to first call
s3Service.uploadFile(PathName.CLUB, image) and get the new clubImageUrl, then
update the entity (club.updateClubImage(clubImageUrl)), persist, and only after
successful upload/update call s3Service.deleteFile(oldClubImageUrl) to remove
the previous file; also add a guard to avoid deleting when oldClubImageUrl is
null or equals the new clubImageUrl and consider wrapping upload+update in
appropriate try/catch to avoid leaking the newly uploaded file on subsequent DB
failures.

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<BaseResponse<PostResponse>> 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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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); // 게시글 수정

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,38 @@
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
@RequiredArgsConstructor
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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
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
@RequestMapping("/api/users")
@Tag(name = "user", description = "사용자 관련 API")
public class UserController {

private final UserService userService;
private final UserServiceImpl userService;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

인터페이스 대신 구체 클래스 주입 사용

UserServiceImpl 구체 클래스를 직접 주입하고 있습니다. 의존성 역전 원칙(DIP)에 따라 인터페이스 타입(UserService)을 사용하는 것이 권장됩니다. 이는 테스트 용이성과 유연성을 높여줍니다.

♻️ 인터페이스 타입 사용 제안
-    private final UserServiceImpl userService;
+    private final UserService userService;

import도 함께 수정:

-import com.be.sportizebe.domain.user.service.UserServiceImpl;
+import com.be.sportizebe.domain.user.service.UserService;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private final UserServiceImpl userService;
private final UserService userService;
🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/domain/user/controller/UserController.java`
at line 28, The controller is injecting the concrete class UserServiceImpl via
the field userService; change the dependency to the interface type UserService
and update any constructor or field injection points in UserController to accept
UserService instead of UserServiceImpl (and adjust imports) so Spring injects
the implementation while the controller depends on the interface for better DIP,
testability, and flexibility.


@PostMapping("/signup")
@Operation(summary = "회원가입", description = "이메일과 비밀번호로 회원가입")
Expand All @@ -30,4 +34,14 @@ public ResponseEntity<BaseResponse<SignUpResponse>> 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<BaseResponse<ProfileImageResponse>> uploadProfileImage(
@AuthenticationPrincipal User user,
@RequestPart("file") MultipartFile file
) {
ProfileImageResponse response = userService.uploadProfileImage(user.getId(), file);
return ResponseEntity.ok(BaseResponse.success("프로필 사진 업로드 성공", response));
}
}
Loading