diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/controller/JoinPostController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/controller/JoinPostController.java new file mode 100644 index 00000000..4ad0bda8 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/controller/JoinPostController.java @@ -0,0 +1,76 @@ +package com.back.web7_9_codecrete_be.domain.community.post.controller; + +import com.back.web7_9_codecrete_be.domain.community.post.dto.request.JoinPostCreateRequest; +import com.back.web7_9_codecrete_be.domain.community.post.dto.request.JoinPostUpdateRequest; +import com.back.web7_9_codecrete_be.domain.community.post.dto.response.JoinPostResponse; +import com.back.web7_9_codecrete_be.domain.community.post.service.JoinPostService; +import com.back.web7_9_codecrete_be.domain.users.entity.User; +import com.back.web7_9_codecrete_be.global.rq.Rq; +import com.back.web7_9_codecrete_be.global.rsData.RsData; +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.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/join") +@RequiredArgsConstructor +@Tag(name = "Community - Join", description = "구인글 API") +public class JoinPostController { + + private final JoinPostService joinPostService; + private final Rq rq; + + @Operation(summary = "구인글 작성") + @PostMapping + public RsData create( + @Valid @RequestBody JoinPostCreateRequest req + ) { + User user = rq.getUser(); + Long postId = joinPostService.create(req, user); + return RsData.success("구인글이 작성되었습니다.", postId); + } + + @Operation(summary = "구인글 상세 조회") + @GetMapping("/{postId}") + public RsData get( + @PathVariable Long postId + ) { + return RsData.success( + "구인글 조회 성공", + joinPostService.get(postId) + ); + } + + @Operation(summary = "구인글 수정") + @PutMapping("/{postId}") + public RsData update( + @PathVariable Long postId, + @Valid @RequestBody JoinPostUpdateRequest req + ) { + User user = rq.getUser(); + joinPostService.update(postId, req, user.getId()); + return RsData.success("구인글이 수정되었습니다."); + } + + @Operation(summary = "구인글 삭제") + @DeleteMapping("/{postId}") + public RsData delete( + @PathVariable Long postId + ) { + User user = rq.getUser(); + joinPostService.delete(postId, user.getId()); + return RsData.success("구인글이 삭제되었습니다."); + } + + @Operation(summary = "구인글 마감") + @PatchMapping("/{postId}/close") + public RsData close( + @PathVariable Long postId + ) { + User user = rq.getUser(); + joinPostService.close(postId, user.getId()); + return RsData.success("구인글이 마감되었습니다."); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/controller/ReviewPostController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/controller/ReviewPostController.java new file mode 100644 index 00000000..017fed09 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/controller/ReviewPostController.java @@ -0,0 +1,71 @@ +package com.back.web7_9_codecrete_be.domain.community.post.controller; + + +import com.back.web7_9_codecrete_be.domain.community.post.dto.request.ReviewPostMultipartRequest; +import com.back.web7_9_codecrete_be.domain.community.post.dto.request.ReviewPostUpdateMultipartRequest; +import com.back.web7_9_codecrete_be.domain.community.post.dto.response.ReviewPostResponse; +import com.back.web7_9_codecrete_be.domain.community.post.service.ReviewPostService; +import com.back.web7_9_codecrete_be.domain.users.entity.User; +import com.back.web7_9_codecrete_be.global.rq.Rq; +import com.back.web7_9_codecrete_be.global.rsData.RsData; +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.MediaType; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/reviews") +@RequiredArgsConstructor +@Tag(name = "Community - Review", description = "후기 게시글 API") +public class ReviewPostController { + + private final ReviewPostService reviewPostService; + private final Rq rq; + + @Operation(summary = "후기 게시글 작성") + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public RsData createReview( + @ModelAttribute @Valid ReviewPostMultipartRequest req + ) { + User user = rq.getUser(); + Long postId = reviewPostService.create(req, user); + return RsData.success("후기 게시글이 작성되었습니다.", postId); + } + + @Operation(summary = "후기 게시글 상세 조회") + @GetMapping("/{postId}") + public RsData getReview( + @PathVariable Long postId + ) { + return RsData.success( + "후기 게시글 조회 성공", + reviewPostService.getReview(postId) + ); + } + + @Operation(summary = "후기 게시글 수정") + @PutMapping( + value = "/{postId}", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) + public RsData updateReview( + @PathVariable Long postId, + @ModelAttribute @Valid ReviewPostUpdateMultipartRequest req + ) { + User user = rq.getUser(); + reviewPostService.update(postId, req, user.getId()); + return RsData.success("후기 게시글이 수정되었습니다."); + } + + @Operation(summary = "후기 게시글 삭제") + @DeleteMapping("/{postId}") + public RsData deleteReview( + @PathVariable Long postId + ) { + User user = rq.getUser(); + reviewPostService.delete(postId, user.getId()); + return RsData.success("후기 게시글이 삭제되었습니다."); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/request/JoinPostCreateRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/request/JoinPostCreateRequest.java new file mode 100644 index 00000000..3c2a5d25 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/request/JoinPostCreateRequest.java @@ -0,0 +1,58 @@ +package com.back.web7_9_codecrete_be.domain.community.post.dto.request; + +import com.back.web7_9_codecrete_be.domain.community.post.entity.GenderPreference; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Schema(description = "구인글 작성 요청") +public class JoinPostCreateRequest { + + @NotNull + @Schema(description = "콘서트 ID", example = "1") + private Long concertId; + + @NotBlank + @Schema(description = "구인글 제목", example = "아이유 콘서트 같이 가실 분!") + private String title; + + @NotBlank + @Schema(description = "구인글 내용", example = "혼자 가기 아쉬워서 같이 가실 분 구해요.") + private String content; + + @Min(2) + @Schema(description = "모집 인원", example = "4") + private Integer maxParticipants; + + @Schema( + description = "성별 선호", + allowableValues = {"MALE", "FEMALE", "ANY"}, + example = "ANY" + ) + private GenderPreference genderPreference; + + @Schema(description = "연령대 최소", example = "20") + private Integer ageRangeMin; + + @Schema(description = "연령대 최대", example = "35") + private Integer ageRangeMax; + + @Schema(description = "만날 시간", example = "2025-01-05T18:30:00") + private LocalDateTime meetingAt; + + @Schema(description = "만날 장소", example = "잠실역 3번 출구") + private String meetingPlace; + + @Schema( + description = "활동 태그", + example = "[\"Dinner before\", \"Photo taking\"]" + ) + private List activityTags; +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/request/JoinPostUpdateRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/request/JoinPostUpdateRequest.java new file mode 100644 index 00000000..a39f4f2e --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/request/JoinPostUpdateRequest.java @@ -0,0 +1,60 @@ +package com.back.web7_9_codecrete_be.domain.community.post.dto.request; + +import com.back.web7_9_codecrete_be.domain.community.post.entity.GenderPreference; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Schema(description = "구인글 수정 요청") +public class JoinPostUpdateRequest { + + @NotBlank(message = "제목은 필수입니다.") + @Schema( + description = "구인글 제목", + example = "아이유 콘서트 같이 가실 분 구해요!" + ) + private String title; + + @NotBlank(message = "내용은 필수입니다.") + @Schema( + description = "구인글 내용", + example = "혼자 가기 아쉬워서 같이 즐기실 분 찾습니다." + ) + private String content; + + @NotNull(message = "모집 인원은 필수입니다.") + @Min(value = 1, message = "모집 인원은 최소 1명 이상이어야 합니다.") + @Schema(description = "모집 인원", example = "4") + private Integer maxParticipants; + + @Schema( + description = "성별 선호", + allowableValues = {"MALE", "FEMALE", "ANY"}, + example = "ANY" + ) + private GenderPreference genderPreference; + + @Schema(description = "연령대 최소", example = "20") + private Integer ageRangeMin; + + @Schema(description = "연령대 최대", example = "35") + private Integer ageRangeMax; + + @Schema(description = "만날 시간", example = "2025-01-05T18:30:00") + private LocalDateTime meetingAt; + + @Schema(description = "만날 장소", example = "잠실역 3번 출구") + private String meetingPlace; + + @Schema( + description = "활동 태그", + example = "[\"Dinner before\", \"Photo taking\"]" + ) + private List activityTags; +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/request/ReviewPostMultipartRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/request/ReviewPostMultipartRequest.java new file mode 100644 index 00000000..18e08248 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/request/ReviewPostMultipartRequest.java @@ -0,0 +1,63 @@ +package com.back.web7_9_codecrete_be.domain.community.post.dto.request; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Getter +@Setter +@Schema(description = "후기 게시글 작성 요청 (multipart/form-data)") +public class ReviewPostMultipartRequest { + + @NotNull(message = "콘서트 ID는 필수입니다.") + @Schema( + description = "후기를 작성할 콘서트 ID", + example = "1" + ) + private Long concertId; + + @NotBlank(message = "제목은 필수입니다.") + @Schema( + description = "후기 게시글 제목", + example = "아이유 콘서트 후기" + ) + private String title; + + @NotBlank(message = "내용은 필수입니다.") + @Schema( + description = "후기 게시글 내용", + example = "라이브가 정말 미쳤습니다. 음향도 최고였어요." + ) + private String content; + + @NotNull(message = "평점은 필수입니다.") + @Min(value = 0, message = "평점은 0 이상이어야 합니다.") + @Max(value = 5, message = "평점은 5 이하여야 합니다.") + @Schema( + description = "콘서트 평점 (0~5)", + example = "5" + ) + private Integer rating; + + @Parameter( + description = "후기 이미지 파일 (다중 업로드 가능)", + content = @Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + array = @ArraySchema( + schema = @Schema(type = "string", format = "binary") + ) + ) + ) + private List images; +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/request/ReviewPostUpdateMultipartRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/request/ReviewPostUpdateMultipartRequest.java new file mode 100644 index 00000000..f800d1fd --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/request/ReviewPostUpdateMultipartRequest.java @@ -0,0 +1,66 @@ +package com.back.web7_9_codecrete_be.domain.community.post.dto.request; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Getter +@Setter +@Schema(description = "후기 게시글 수정 요청 (multipart/form-data)") +public class ReviewPostUpdateMultipartRequest { + + @NotBlank(message = "제목은 필수입니다.") + @Schema( + description = "수정할 후기 게시글 제목", + example = "아이유 콘서트 후기 (수정)" + ) + private String title; + + @NotBlank(message = "내용은 필수입니다.") + @Schema( + description = "수정할 후기 게시글 내용", + example = "2층 좌석이었지만 시야도 괜찮고 전반적으로 만족스러웠어요." + ) + private String content; + + @NotNull(message = "평점은 필수입니다.") + @Min(value = 0, message = "평점은 0 이상이어야 합니다.") + @Max(value = 5, message = "평점은 5 이하여야 합니다.") + @Schema( + description = "수정할 콘서트 평점 (0~5)", + example = "4" + ) + private Integer rating; + + @Schema( + description = """ + 수정 후에도 유지할 기존 이미지 URL 목록 + - 프론트에서 기존 이미지 중 '삭제하지 않은 이미지'만 전달 + - 전달되지 않은 기존 이미지는 삭제 처리됨 + """, + example = "[\"https://s3.amazonaws.com/reviews/images/abc.jpg\"]" + ) + private List remainImageUrls; + + @Parameter( + description = "새로 추가할 후기 이미지 파일 목록 (다중 업로드 가능)", + content = @Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + array = @ArraySchema( + schema = @Schema(type = "string", format = "binary") + ) + ) + ) + private List images; +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/response/JoinPostResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/response/JoinPostResponse.java new file mode 100644 index 00000000..f405659d --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/response/JoinPostResponse.java @@ -0,0 +1,83 @@ +package com.back.web7_9_codecrete_be.domain.community.post.dto.response; + +import com.back.web7_9_codecrete_be.domain.community.post.entity.GenderPreference; +import com.back.web7_9_codecrete_be.domain.community.post.entity.JoinPost; +import com.back.web7_9_codecrete_be.domain.community.post.entity.JoinStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@Schema(description = "구인글 응답 DTO") +public class JoinPostResponse { + + @Schema(description = "공통 게시글 정보") + private PostResponse post; + + @Schema(description = "콘서트 ID", example = "1") + private Long concertId; + + @Schema(description = "모집 인원", example = "4") + private Integer maxParticipants; + + @Schema(description = "현재 참여 인원", example = "2") + private Integer currentParticipants; + + @Schema( + description = "성별 선호", + allowableValues = {"MALE", "FEMALE", "ANY"}, + example = "ANY" + ) + private GenderPreference genderPreference; + + @Schema(description = "연령대 최소", example = "20") + private Integer ageRangeMin; + + @Schema(description = "연령대 최대", example = "35") + private Integer ageRangeMax; + + @Schema( + description = "만날 시간", + example = "2025-01-05T18:30:00" + ) + private LocalDateTime meetingAt; + + @Schema( + description = "만날 장소", + example = "잠실역 3번 출구" + ) + private String meetingPlace; + + @Schema( + description = "활동 태그", + example = "[\"Dinner before\", \"Photo taking\"]" + ) + private List activityTags; + + @Schema( + description = "모집 상태", + allowableValues = {"OPEN", "CLOSED"}, + example = "OPEN" + ) + private JoinStatus status; + + public static JoinPostResponse from(JoinPost joinPost) { + return JoinPostResponse.builder() + .post(PostResponse.from(joinPost.getPost())) + .concertId(joinPost.getConcertId()) + .maxParticipants(joinPost.getMaxParticipants()) + .currentParticipants(joinPost.getCurrentParticipants()) + .genderPreference(joinPost.getGenderPreference()) + .ageRangeMin(joinPost.getAgeRangeMin()) + .ageRangeMax(joinPost.getAgeRangeMax()) + .meetingAt(joinPost.getMeetingAt()) + .meetingPlace(joinPost.getMeetingPlace()) + .activityTags(joinPost.getActivityTags()) + .status(joinPost.getStatus()) + .build(); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/response/ReviewPostResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/response/ReviewPostResponse.java new file mode 100644 index 00000000..cff8cb4f --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/dto/response/ReviewPostResponse.java @@ -0,0 +1,38 @@ +package com.back.web7_9_codecrete_be.domain.community.post.dto.response; + +import com.back.web7_9_codecrete_be.domain.community.post.entity.ReviewPost; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +@Schema(description = "후기 게시글 응답 DTO") +public class ReviewPostResponse { + + @Schema(description = "공통 게시글 정보") + private PostResponse post; + + @Schema(description = "콘서트 ID", example = "100") + private Long concertId; + + @Schema(description = "평점 (0~5)", example = "4") + private Integer rating; + + @Schema(description = "후기 이미지 URL 목록") + private List imageUrls; + + public static ReviewPostResponse from( + ReviewPost reviewPost, + List imageUrls + ) { + return ReviewPostResponse.builder() + .post(PostResponse.from(reviewPost.getPost())) + .concertId(reviewPost.getConcertId()) + .rating(reviewPost.getRating()) + .imageUrls(imageUrls) + .build(); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/GenderPreference.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/GenderPreference.java new file mode 100644 index 00000000..11ddb9b9 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/GenderPreference.java @@ -0,0 +1,8 @@ +package com.back.web7_9_codecrete_be.domain.community.post.entity; + +public enum GenderPreference { + MALE, + FEMALE, + ANY +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/JoinPost.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/JoinPost.java new file mode 100644 index 00000000..de285a44 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/JoinPost.java @@ -0,0 +1,88 @@ +package com.back.web7_9_codecrete_be.domain.community.post.entity; + +import com.back.web7_9_codecrete_be.domain.community.post.dto.request.JoinPostCreateRequest; +import com.back.web7_9_codecrete_be.domain.community.post.dto.request.JoinPostUpdateRequest; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "join_post") +public class JoinPost { + + @Id + private Long postId; + + @Column(nullable = false) + private Long concertId; + + @Column(nullable = false) + private Integer maxParticipants; + + @Column(nullable = false) + private Integer currentParticipants = 1; + + @Enumerated(EnumType.STRING) + private GenderPreference genderPreference; + + private Integer ageRangeMin; + private Integer ageRangeMax; + + private LocalDateTime meetingAt; + + @Column(length = 100) + private String meetingPlace; + + @Enumerated(EnumType.STRING) + private JoinStatus status; + + @ElementCollection + @CollectionTable( + name = "join_activity_tag", + joinColumns = @JoinColumn(name = "join_post_id") + ) + @Column(name = "tag") + private List activityTags = new ArrayList<>(); + + @OneToOne(fetch = FetchType.LAZY) + @MapsId + @JoinColumn(name = "post_id") + private Post post; + + public static JoinPost create(Post post, JoinPostCreateRequest req) { + JoinPost joinPost = new JoinPost(); + joinPost.post = post; + joinPost.postId = post.getPostId(); + joinPost.concertId = req.getConcertId(); + joinPost.maxParticipants = req.getMaxParticipants(); + joinPost.genderPreference = req.getGenderPreference(); + joinPost.ageRangeMin = req.getAgeRangeMin(); + joinPost.ageRangeMax = req.getAgeRangeMax(); + joinPost.meetingAt = req.getMeetingAt(); + joinPost.meetingPlace = req.getMeetingPlace(); + joinPost.activityTags = req.getActivityTags(); + joinPost.status = JoinStatus.OPEN; + return joinPost; + } + + public void update(JoinPostUpdateRequest req) { + this.maxParticipants = req.getMaxParticipants(); + this.genderPreference = req.getGenderPreference(); + this.ageRangeMin = req.getAgeRangeMin(); + this.ageRangeMax = req.getAgeRangeMax(); + this.meetingAt = req.getMeetingAt(); + this.meetingPlace = req.getMeetingPlace(); + this.activityTags = req.getActivityTags(); + } + + public void close() { + this.status = JoinStatus.CLOSED; + } +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/JoinStatus.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/JoinStatus.java new file mode 100644 index 00000000..e9bfcaba --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/JoinStatus.java @@ -0,0 +1,6 @@ +package com.back.web7_9_codecrete_be.domain.community.post.entity; + +public enum JoinStatus { + OPEN, + CLOSED +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/Post.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/Post.java index 75dc3cb2..2977edbe 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/Post.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/Post.java @@ -51,6 +51,20 @@ public class Post { @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) private List comments = new ArrayList<>(); + @OneToOne(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private ReviewPost reviewPost; + + public void addReviewPost(ReviewPost reviewPost) { + this.reviewPost = reviewPost; + } + + @OneToOne(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private JoinPost joinPost; + + public void addJoinPost(JoinPost joinPost) { + this.joinPost = joinPost; + } + @Builder private Post(Long userId, String nickname, String title, String content, PostCategory category) { this.userId = userId; diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/ReviewImage.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/ReviewImage.java new file mode 100644 index 00000000..46796adf --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/ReviewImage.java @@ -0,0 +1,30 @@ +package com.back.web7_9_codecrete_be.domain.community.post.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor +@Table(name = "review_image") +public class ReviewImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String imageUrl; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private ReviewPost reviewPost; + + public static ReviewImage create(ReviewPost reviewPost, String imageUrl) { + ReviewImage image = new ReviewImage(); + image.reviewPost = reviewPost; + image.imageUrl = imageUrl; + return image; + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/ReviewPost.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/ReviewPost.java new file mode 100644 index 00000000..ceed8c17 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/entity/ReviewPost.java @@ -0,0 +1,61 @@ +package com.back.web7_9_codecrete_be.domain.community.post.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Entity +@NoArgsConstructor +@Table(name = "review_post") +public class ReviewPost { + + @Id + private Long postId; + + @Column(name = "concert_id", nullable = false) + private Long concertId; + + @Column(nullable = false) + private Integer rating; // 0~5 + + @OneToOne(fetch = FetchType.LAZY) + @MapsId + @JoinColumn(name = "post_id") + private Post post; + + @OneToMany( + mappedBy = "reviewPost", + cascade = CascadeType.ALL, + orphanRemoval = true + ) + private List images = new ArrayList<>(); + + public static ReviewPost create(Post post, Long concertId, Integer rating) { + ReviewPost review = new ReviewPost(); + review.post = post; + review.postId = post.getPostId(); + review.concertId = concertId; + review.rating = rating; + return review; + } + + public void updateRating(Integer rating) { + this.rating = rating; + } + + public void addImage(ReviewImage image) { + images.add(image); + } + + public void clearImages() { + images.clear(); + } + + public void removeImage(ReviewImage image) { + images.remove(image); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/repository/JoinPostRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/repository/JoinPostRepository.java new file mode 100644 index 00000000..dbac2c28 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/repository/JoinPostRepository.java @@ -0,0 +1,10 @@ +package com.back.web7_9_codecrete_be.domain.community.post.repository; + +import com.back.web7_9_codecrete_be.domain.community.post.entity.JoinPost; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface JoinPostRepository extends JpaRepository { + List findByConcertId(Long concertId); +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/repository/ReviewImageRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/repository/ReviewImageRepository.java new file mode 100644 index 00000000..63425aeb --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/repository/ReviewImageRepository.java @@ -0,0 +1,13 @@ +package com.back.web7_9_codecrete_be.domain.community.post.repository; + +import com.back.web7_9_codecrete_be.domain.community.post.entity.ReviewImage; +import com.back.web7_9_codecrete_be.domain.community.post.entity.ReviewPost; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ReviewImageRepository extends JpaRepository { + List findByReviewPost(ReviewPost reviewPost); + + void deleteByReviewPost(ReviewPost reviewPost); +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/repository/ReviewPostRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/repository/ReviewPostRepository.java new file mode 100644 index 00000000..0936adf3 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/repository/ReviewPostRepository.java @@ -0,0 +1,7 @@ +package com.back.web7_9_codecrete_be.domain.community.post.repository; + +import com.back.web7_9_codecrete_be.domain.community.post.entity.ReviewPost; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewPostRepository extends JpaRepository { +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/service/JoinPostService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/service/JoinPostService.java new file mode 100644 index 00000000..db416a0b --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/service/JoinPostService.java @@ -0,0 +1,114 @@ +package com.back.web7_9_codecrete_be.domain.community.post.service; + +import com.back.web7_9_codecrete_be.domain.community.post.dto.request.JoinPostCreateRequest; +import com.back.web7_9_codecrete_be.domain.community.post.dto.request.JoinPostUpdateRequest; +import com.back.web7_9_codecrete_be.domain.community.post.dto.response.JoinPostResponse; +import com.back.web7_9_codecrete_be.domain.community.post.entity.JoinPost; +import com.back.web7_9_codecrete_be.domain.community.post.entity.JoinStatus; +import com.back.web7_9_codecrete_be.domain.community.post.entity.Post; +import com.back.web7_9_codecrete_be.domain.community.post.entity.PostCategory; +import com.back.web7_9_codecrete_be.domain.community.post.repository.JoinPostRepository; +import com.back.web7_9_codecrete_be.domain.community.post.repository.PostRepository; +import com.back.web7_9_codecrete_be.domain.users.entity.User; +import com.back.web7_9_codecrete_be.global.error.code.PostErrorCode; +import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class JoinPostService { + + private final PostRepository postRepository; + private final JoinPostRepository joinPostRepository; + + // 구인글 작성 + @Transactional + public Long create(JoinPostCreateRequest req, User user) { + + Post post = Post.create( + user.getId(), + user.getNickname(), + req.getTitle(), + req.getContent(), + PostCategory.JOIN + ); + + JoinPost joinPost = JoinPost.create(post, req); + post.addJoinPost(joinPost); + + postRepository.save(post); + return post.getPostId(); + } + + // 구인글 단건 조회 + @Transactional(readOnly = true) + public JoinPostResponse get(Long postId) { + JoinPost joinPost = joinPostRepository.findById(postId) + .orElseThrow(() -> + new BusinessException(PostErrorCode.POST_NOT_FOUND) + ); + + return JoinPostResponse.from(joinPost); + } + + // 구인글 수정 + @Transactional + public void update( + Long postId, + JoinPostUpdateRequest req, + Long userId + ) { + JoinPost joinPost = joinPostRepository.findById(postId) + .orElseThrow(() -> + new BusinessException(PostErrorCode.POST_NOT_FOUND) + ); + + Post post = validateOwner(joinPost, userId); + + post.update(req.getTitle(), req.getContent(), PostCategory.JOIN); + + joinPost.update(req); + } + + // 구인글 삭제 + @Transactional + public void delete(Long postId, Long userId) { + JoinPost joinPost = joinPostRepository.findById(postId) + .orElseThrow(() -> + new BusinessException(PostErrorCode.POST_NOT_FOUND) + ); + + Post post = validateOwner(joinPost, userId); + postRepository.delete(post); + } + + // 구인글 작성자 검증 + private Post validateOwner(JoinPost joinPost, Long userId) { + Post post = joinPost.getPost(); + + if (!post.getUserId().equals(userId)) { + throw new BusinessException(PostErrorCode.NO_POST_PERMISSION); + } + return post; + } + + @Transactional + public void close(Long postId, Long userId) { + JoinPost joinPost = joinPostRepository.findById(postId) + .orElseThrow(() -> new BusinessException(PostErrorCode.POST_NOT_FOUND)); + + Post post = joinPost.getPost(); + + if (!post.getUserId().equals(userId)) { + throw new BusinessException(PostErrorCode.NO_POST_PERMISSION); + } + + if (joinPost.getStatus() == JoinStatus.CLOSED) { + throw new BusinessException(PostErrorCode.JOIN_ALREADY_CLOSED); + } + + joinPost.close(); // status = CLOSED + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/service/ReviewPostService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/service/ReviewPostService.java new file mode 100644 index 00000000..ae2d2359 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/community/post/service/ReviewPostService.java @@ -0,0 +1,188 @@ +package com.back.web7_9_codecrete_be.domain.community.post.service; + +import com.back.web7_9_codecrete_be.domain.community.post.dto.request.ReviewPostMultipartRequest; +import com.back.web7_9_codecrete_be.domain.community.post.dto.request.ReviewPostUpdateMultipartRequest; +import com.back.web7_9_codecrete_be.domain.community.post.dto.response.ReviewPostResponse; +import com.back.web7_9_codecrete_be.domain.community.post.entity.Post; +import com.back.web7_9_codecrete_be.domain.community.post.entity.PostCategory; +import com.back.web7_9_codecrete_be.domain.community.post.entity.ReviewImage; +import com.back.web7_9_codecrete_be.domain.community.post.entity.ReviewPost; +import com.back.web7_9_codecrete_be.domain.community.post.repository.PostRepository; +import com.back.web7_9_codecrete_be.domain.community.post.repository.ReviewPostRepository; +import com.back.web7_9_codecrete_be.domain.users.entity.User; +import com.back.web7_9_codecrete_be.global.error.code.PostErrorCode; +import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; +import com.back.web7_9_codecrete_be.global.storage.FileStorageService; +import com.back.web7_9_codecrete_be.global.storage.ImageFileValidator; +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; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ReviewPostService { + + private final PostRepository postRepository; + private final ReviewPostRepository reviewPostRepository; + private final FileStorageService fileStorageService; + private final ImageFileValidator imageFileValidator; + + // 후기 작성 + @Transactional + public Long create(ReviewPostMultipartRequest req, User user) { + + Post post = Post.create( + user.getId(), + user.getNickname(), + req.getTitle(), + req.getContent(), + PostCategory.REVIEW + ); + + ReviewPost reviewPost = ReviewPost.create( + post, + req.getConcertId(), + req.getRating() + ); + + post.addReviewPost(reviewPost); + postRepository.save(post); + + // 이미지 업로드 + if (req.getImages() != null) { + for (MultipartFile file : req.getImages()) { + imageFileValidator.validateImageFile(file); + + String url = + fileStorageService.upload(file, "reviews/images"); + + reviewPost.addImage( + ReviewImage.create(reviewPost, url) + ); + } + } + + return post.getPostId(); + } + + // 후기 단건 조회 + @Transactional(readOnly = true) + public ReviewPostResponse getReview(Long postId) { + + ReviewPost reviewPost = reviewPostRepository.findById(postId) + .orElseThrow(() -> + new BusinessException(PostErrorCode.POST_NOT_FOUND) + ); + + List imageUrls = reviewPost.getImages() + .stream() + .map(ReviewImage::getImageUrl) + .toList(); + + return ReviewPostResponse.from(reviewPost, imageUrls); + } + + // 후기 수정 + @Transactional + public void update( + Long postId, + ReviewPostUpdateMultipartRequest req, + Long userId + ) { + ReviewPost reviewPost = reviewPostRepository.findById(postId) + .orElseThrow(() -> + new BusinessException(PostErrorCode.POST_NOT_FOUND)); + + Post post = validateOwner(reviewPost, userId); + + post.update(req.getTitle(), req.getContent(), PostCategory.REVIEW); + reviewPost.updateRating(req.getRating()); + + updateImages( + reviewPost, + req.getRemainImageUrls(), + req.getImages() + ); + } + + // 후기 삭제 + @Transactional + public void delete(Long postId, Long userId) { + + ReviewPost reviewPost = reviewPostRepository.findById(postId) + .orElseThrow(() -> + new BusinessException(PostErrorCode.POST_NOT_FOUND)); + + Post post = validateOwner(reviewPost, userId); + + List urls = reviewPost.getImages() + .stream() + .map(ReviewImage::getImageUrl) + .toList(); + + postRepository.delete(post); + + urls.forEach(this::safeDeleteImage); + } + + // 이미지 업데이트 + private void updateImages( + ReviewPost reviewPost, + List remainImageUrls, + List newImages + ) { + List remainUrls = + remainImageUrls == null ? List.of() : remainImageUrls; + + // 기존 이미지 중 삭제 대상 제거 + List oldImages = + List.copyOf(reviewPost.getImages()); + + for (ReviewImage image : oldImages) { + if (!remainUrls.contains(image.getImageUrl())) { + reviewPost.removeImage(image); + safeDeleteImage(image.getImageUrl()); + } + } + + // 새 이미지 업로드 + if (newImages != null) { + for (MultipartFile file : newImages) { + imageFileValidator.validateImageFile(file); + + String url = + fileStorageService.upload(file, "reviews/images"); + + reviewPost.addImage( + ReviewImage.create(reviewPost, url) + ); + } + } + } + + // 작성자 검증 + private Post validateOwner(ReviewPost reviewPost, Long userId) { + Post post = reviewPost.getPost(); + + if (!post.getUserId().equals(userId)) { + throw new BusinessException(PostErrorCode.NO_POST_PERMISSION); + } + + return post; + } + + // 이미지 안전 삭제 + private void safeDeleteImage(String url) { + try { + fileStorageService.delete(url); + } catch (Exception e) { + log.warn("후기 이미지 삭제 실패 url={}", url, e); + } + } +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PostErrorCode.java b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PostErrorCode.java index f636fc1a..2bf5869e 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PostErrorCode.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PostErrorCode.java @@ -22,6 +22,12 @@ public enum PostErrorCode implements ErrorCode { "유효하지 않은 게시글 카테고리입니다." ), + JOIN_ALREADY_CLOSED( + HttpStatus.BAD_REQUEST, + "P-111", + "이미 마감된 모집 게시글입니다." + ), + // 권한 관련 NO_POST_PERMISSION( HttpStatus.FORBIDDEN, diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java b/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java index 25d2170d..23fd0ee6 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java @@ -1,7 +1,7 @@ package com.back.web7_9_codecrete_be.global.security; -import java.util.List; - +import com.back.web7_9_codecrete_be.domain.auth.service.TokenService; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; @@ -14,91 +14,93 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import com.back.web7_9_codecrete_be.domain.auth.service.TokenService; - -import lombok.RequiredArgsConstructor; +import java.util.List; @Configuration @RequiredArgsConstructor public class SecurityConfig { - private final JwtTokenProvider jwtTokenProvider; - private final JwtProperties jwtProperties; - private final CustomUserDetailService customUserDetailService; - private final TokenService tokenService; - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - - http - .csrf(csrf -> csrf.disable()) - .cors(Customizer.withDefaults()) - - // 기본 로그인 폼 비활성화 - .formLogin(form -> form.disable()) - - // HTTP Basic 인증 비활성화 - .httpBasic(basic -> basic.disable()) - - // H2 Console 설정 - .headers(headers -> headers.frameOptions(frame -> frame.disable())) - - // 세션 관리 설정 - Stateless - .sessionManagement((session) -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - - // Authorization 설정 - .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/ws-chat/**", - "/actuator/**", - "/api/v1/auth/**", // 로그인/회원가입은 허용 - "/v3/api-docs/**", // Swagger - "/swagger-ui/**", // Swagger UI - "/h2-console/**", // H2 Console - "/api/v1/location/**", //location 정보 조회 도메인(임시) - "/api/v1/concerts/**", // concert 정보 조회 도메인 - "/api/v1/artists/**", // artist 정보 저장 도메인(임시) - "/api/v1/users/**", - "/api/v1/chats/**" - ).permitAll() - - // ADMIN 전용 - .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") - - .anyRequest().authenticated() - ) - - .addFilterBefore( - new JwtAuthenticationFilter(jwtTokenProvider, jwtProperties, tokenService), - UsernamePasswordAuthenticationFilter.class - ); - - return http.build(); - } - - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { - return configuration.getAuthenticationManager(); - } - - // CORS 설정(로컬 프론트 통신 허용) - @Bean - public UrlBasedCorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - - configuration.setAllowedOrigins(List.of("http://localhost:3000", "https://www.naeconcertbutakhae.shop")); - configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); - - configuration.setAllowedHeaders(List.of("*")); + private final JwtTokenProvider jwtTokenProvider; + private final JwtProperties jwtProperties; + private final CustomUserDetailService customUserDetailService; + private final TokenService tokenService; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + http + .csrf(csrf -> csrf.disable()) + .cors(Customizer.withDefaults()) + + // 기본 로그인 폼 비활성화 + .formLogin(form -> form.disable()) + + // HTTP Basic 인증 비활성화 + .httpBasic(basic -> basic.disable()) + + // H2 Console 설정 + .headers(headers -> headers.frameOptions(frame -> frame.disable())) + + // 세션 관리 설정 - Stateless + .sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // Authorization 설정 + + .authorizeHttpRequests(auth -> auth + .requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers( + "/ws-chat/**", + "/actuator/**", + "/api/v1/auth/**", // 로그인/회원가입은 허용 + "/v3/api-docs/**", // Swagger + "/swagger-ui/**", // Swagger UI + "/h2-console/**", // H2 Console + "/api/v1/location/**", //location 정보 조회 도메인(임시) + "/api/v1/concerts/**", // concert 정보 조회 도메인 + "/api/v1/artists/**", // artist 정보 저장 도메인(임시) + "/api/v1/users/**", + "/api/v1/chats/**", + "/api/v1/reviews/**", + "api/v1/join/**" + ).permitAll() + + // ADMIN 전용 + .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") + + .anyRequest().authenticated() + ) + + .addFilterBefore( + new JwtAuthenticationFilter(jwtTokenProvider, jwtProperties, tokenService), + UsernamePasswordAuthenticationFilter.class + ); + + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } + + // CORS 설정(로컬 프론트 통신 허용) + @Bean + public UrlBasedCorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins(List.of("http://localhost:3000", "http://localhost:8080", "https://www.naeconcertbutakhae.shop")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + + configuration.setAllowedHeaders(List.of("*")); configuration.setExposedHeaders(List.of("Set-Cookie")); - //쿠키 자동으로 넘어가게 설정 - configuration.setAllowCredentials(true); + //쿠키 자동으로 넘어가게 설정 + configuration.setAllowCredentials(true); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); - return source; - } + return source; + } }