diff --git a/BackEnd_Config b/BackEnd_Config index 2bd0417..2f057c9 160000 --- a/BackEnd_Config +++ b/BackEnd_Config @@ -1 +1 @@ -Subproject commit 2bd04179937fb46fb915217c421631e73c257f95 +Subproject commit 2f057c92ce09d49761ac73d8e9f9e6c471f4378c diff --git a/build.gradle b/build.gradle index c174a87..148fc94 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,9 @@ dependencies { implementation group:'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' implementation group:'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' implementation group:'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + // S3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' } tasks.named('compileJava') { diff --git a/src/main/java/com/likelion/trendithon/domain/card/controller/CardController.java b/src/main/java/com/likelion/trendithon/domain/card/controller/CardController.java index 8cc5258..948ac56 100644 --- a/src/main/java/com/likelion/trendithon/domain/card/controller/CardController.java +++ b/src/main/java/com/likelion/trendithon/domain/card/controller/CardController.java @@ -53,7 +53,7 @@ public ResponseEntity getAllCard() { return cardService.getAllCards(); } - @Operation(summary = "[ 토큰 O | 카드 삭제 ]", description = "ID를 통해 특정 카드 삭제") + @Operation(summary = "[ 토큰 X | 카드 삭제 | 테스트용 ]", description = "ID를 통해 특정 카드 삭제") @DeleteMapping("/{id}") public ResponseEntity deleteCard(@PathVariable Long id) { return cardService.deleteCard(id); diff --git a/src/main/java/com/likelion/trendithon/domain/card/controller/ExperienceController.java b/src/main/java/com/likelion/trendithon/domain/card/controller/ExperienceController.java index 780d696..195abcd 100644 --- a/src/main/java/com/likelion/trendithon/domain/card/controller/ExperienceController.java +++ b/src/main/java/com/likelion/trendithon/domain/card/controller/ExperienceController.java @@ -21,30 +21,29 @@ @RestController @AllArgsConstructor -@RequestMapping("/api/cards") +@RequestMapping("/api/cards/experience") @Tag(name = "Card", description = "Card 관리 API") public class ExperienceController { private ExperienceService experienceService; - @Operation(summary = "[ 토큰 O | 카드 경험 ]", description = "다른 사용자가 생성한 카드 경험 등록") - @PostMapping("/experience") + @Operation(summary = "[ 토큰 O | 경험 등록 ]", description = "다른 사용자가 생성한 카드 경험 등록") + @PostMapping() public ResponseEntity createExperience( - @Parameter(description = "경험할 카드 ID") Long cardId, @Parameter(description = "경험 내용") @RequestBody CreateExperienceRequest createExperienceRequest, HttpServletRequest httpServletRequest) { - return experienceService.createExperience(cardId, createExperienceRequest, httpServletRequest); + return experienceService.createExperience(createExperienceRequest, httpServletRequest); } - @Operation(summary = "[ 토큰 O | 사용자 경험 조회 ]", description = "사용자가 현재 도전 중인 경험 조회") - @GetMapping("/experience") + @Operation(summary = "[ 토큰 O | 경험 조회 ]", description = "사용자가 현재 도전 중인 경험 조회") + @GetMapping() public ResponseEntity getEnableExperience(HttpServletRequest httpServletRequest) { return experienceService.getEnableExperience(httpServletRequest); } - @Operation(summary = "[ 토큰 O | 사용자 경험 수정 ]", description = "사용자가 현재 도전 중인 경험 종료일 변경") - @PutMapping("/experience") + @Operation(summary = "[ 토큰 O | 경험 수정 ]", description = "사용자가 현재 도전 중인 경험 종료일 변경") + @PutMapping() public ResponseEntity updateExperience( @Parameter(description = "경험 종료일") @RequestBody UpdateExperienceRequest updateExperienceRequest, @@ -52,8 +51,8 @@ public ResponseEntity updateExperience( return experienceService.updateExperience(updateExperienceRequest, httpServletRequest); } - @Operation(summary = "[ 토큰 O | 사용자 경험 포기 ]", description = "사용자가 현재 도전 중인 경험 포기") - @PutMapping("/experience/quit") + @Operation(summary = "[ 토큰 O | 경험 포기 ]", description = "사용자가 현재 도전 중인 경험 포기") + @PutMapping("/quit") public ResponseEntity quitExperience(HttpServletRequest httpServletRequest) { return experienceService.quitExperience(httpServletRequest); } diff --git a/src/main/java/com/likelion/trendithon/domain/card/controller/ReviewController.java b/src/main/java/com/likelion/trendithon/domain/card/controller/ReviewController.java new file mode 100644 index 0000000..7d433b1 --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/card/controller/ReviewController.java @@ -0,0 +1,45 @@ +package com.likelion.trendithon.domain.card.controller; + +import java.util.List; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.likelion.trendithon.domain.card.dto.request.CreateReviewRequest; +import com.likelion.trendithon.domain.card.service.ReviewService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; + +@RestController +@AllArgsConstructor +@RequestMapping("/api/reviews") +@Tag(name = "Review", description = "Review 관리 API") +public class ReviewController { + + private final ReviewService reviewService; + + @Operation(summary = "[ 토큰 O | 경험 리뷰 등록 ]", description = "경험을 완료한 카드 리뷰 등록") + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createReview( + @Parameter( + description = "리뷰 내용", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @RequestPart + CreateReviewRequest createReviewRequest, + @Parameter( + description = "리뷰 이미지 리스트", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) + @RequestPart(value = "images") + List images) { + return reviewService.createReview(createReviewRequest, images); + } +} diff --git a/src/main/java/com/likelion/trendithon/domain/card/dto/request/CreateExperienceRequest.java b/src/main/java/com/likelion/trendithon/domain/card/dto/request/CreateExperienceRequest.java index 77a4a8c..2ad354b 100644 --- a/src/main/java/com/likelion/trendithon/domain/card/dto/request/CreateExperienceRequest.java +++ b/src/main/java/com/likelion/trendithon/domain/card/dto/request/CreateExperienceRequest.java @@ -8,12 +8,15 @@ @Getter public class CreateExperienceRequest { + @Schema(description = "카드 아이디", example = "1") + private Long cardId; + @Schema(description = "카드 표지", example = "#000000") private String cover; - @Schema(description = "시작 날짜", example = "2025.01.01") + @Schema(description = "시작 날짜", example = "2025-01-01") private LocalDate startDate; - @Schema(description = "종료 날짜", example = "2025.01.01") + @Schema(description = "종료 날짜", example = "2025-01-01") private LocalDate endDate; } diff --git a/src/main/java/com/likelion/trendithon/domain/card/dto/request/CreateReviewRequest.java b/src/main/java/com/likelion/trendithon/domain/card/dto/request/CreateReviewRequest.java new file mode 100644 index 0000000..cfadb2a --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/card/dto/request/CreateReviewRequest.java @@ -0,0 +1,22 @@ +package com.likelion.trendithon.domain.card.dto.request; + +import java.time.LocalDate; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +public class CreateReviewRequest { + + @Schema(description = "리뷰할 경험 ID", example = "1") + private Long experienceId; + + @Schema(description = "경험 점수", example = "3.5") + private Double score; + + @Schema(description = "실제 종료 날짜", example = "2025-01-01") + private LocalDate endDate; + + @Schema(description = "느낀 점", example = "좋았다.") + private String content; +} diff --git a/src/main/java/com/likelion/trendithon/domain/card/dto/response/CardResponse.java b/src/main/java/com/likelion/trendithon/domain/card/dto/response/CardResponse.java index e0ab60e..6a7e806 100644 --- a/src/main/java/com/likelion/trendithon/domain/card/dto/response/CardResponse.java +++ b/src/main/java/com/likelion/trendithon/domain/card/dto/response/CardResponse.java @@ -1,7 +1,5 @@ package com.likelion.trendithon.domain.card.dto.response; -import com.likelion.trendithon.domain.card.entity.Card; - import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @@ -16,6 +14,15 @@ public class CardResponse { @Schema(description = "응답 메세지", example = "카드 조회에 성공하였습니다.") private String message; - @Schema(description = "조회한 카드 ID", example = "1") - private Long cardId; + @Schema(description = "조회한 카드 이모지", example = "모바일 키보드 이모지") + private String emoji; + + @Schema(description = "조회한 카드 제목", example = "멋쟁이사자 되기") + private String title; + + @Schema(description = "조회한 카드 내용", example = "나는 오늘 멋쟁이 사자가 되었다.") + private String content; + + @Schema(description = "조회한 카드 표지", example = "#000000") + private String cover; } diff --git a/src/main/java/com/likelion/trendithon/domain/card/dto/response/CreateExperienceResponse.java b/src/main/java/com/likelion/trendithon/domain/card/dto/response/CreateExperienceResponse.java index 67fa50a..c25cec0 100644 --- a/src/main/java/com/likelion/trendithon/domain/card/dto/response/CreateExperienceResponse.java +++ b/src/main/java/com/likelion/trendithon/domain/card/dto/response/CreateExperienceResponse.java @@ -11,7 +11,7 @@ public class CreateExperienceResponse { @Schema(description = "카드 생성 결과", example = "true") private boolean success; - @Schema(description = "응답 메세지", example = "카드 생성에 성공하였습니다.") + @Schema(description = "응답 메세지", example = "경험 생성에 성공하였습니다.") private String message; @Schema(description = "카드 ID", example = "1") diff --git a/src/main/java/com/likelion/trendithon/domain/card/dto/response/CreateReviewResponse.java b/src/main/java/com/likelion/trendithon/domain/card/dto/response/CreateReviewResponse.java new file mode 100644 index 0000000..70fadcb --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/card/dto/response/CreateReviewResponse.java @@ -0,0 +1,19 @@ +package com.likelion.trendithon.domain.card.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class CreateReviewResponse { + + @Schema(description = "리뷰 생성 결과", example = "true") + private boolean success; + + @Schema(description = "응답 메세지", example = "리뷰 생성에 성공하였습니다.") + private String message; + + @Schema(description = "생성된 리뷰 ID", example = "1") + private Long reviewId; +} diff --git a/src/main/java/com/likelion/trendithon/domain/card/dto/response/ExperienceResponse.java b/src/main/java/com/likelion/trendithon/domain/card/dto/response/ExperienceResponse.java index f1f8cc3..5c94aa9 100644 --- a/src/main/java/com/likelion/trendithon/domain/card/dto/response/ExperienceResponse.java +++ b/src/main/java/com/likelion/trendithon/domain/card/dto/response/ExperienceResponse.java @@ -1,5 +1,7 @@ package com.likelion.trendithon.domain.card.dto.response; +import java.time.LocalDate; + import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @@ -14,6 +16,18 @@ public class ExperienceResponse { @Schema(description = "응답 메세지", example = "경험 조회에 성공하였습니다.") private String message; - @Schema(description = "조회한 경험 ID", example = "1") - private Long experienceId; + @Schema(description = "경험 제목", example = "멋쟁이사자 되기") + private String title; + + @Schema(description = "경험 상태", example = "true") + private boolean state; + + @Schema(description = "경험 표지", example = "#000000") + private String cover; + + @Schema(description = "시작 날짜", example = "2025-01-01") + private LocalDate startDate; + + @Schema(description = "종료 날짜", example = "2025-01-01") + private LocalDate endDate; } diff --git a/src/main/java/com/likelion/trendithon/domain/card/entity/Experience.java b/src/main/java/com/likelion/trendithon/domain/card/entity/Experience.java index 481c966..40b9ed8 100644 --- a/src/main/java/com/likelion/trendithon/domain/card/entity/Experience.java +++ b/src/main/java/com/likelion/trendithon/domain/card/entity/Experience.java @@ -44,6 +44,9 @@ public class Experience { @OnDelete(action = OnDeleteAction.CASCADE) private Card card; + @Column(name = "title", nullable = false) + private String title; + @Column(name = "state", nullable = false) private boolean state; diff --git a/src/main/java/com/likelion/trendithon/domain/card/entity/Review.java b/src/main/java/com/likelion/trendithon/domain/card/entity/Review.java new file mode 100644 index 0000000..d2b1042 --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/card/entity/Review.java @@ -0,0 +1,51 @@ +package com.likelion.trendithon.domain.card.entity; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Review { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long reviewId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "experience_id", nullable = false) + private Experience experience; + + @Column(name = "score", nullable = false) + private Double score; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + @Column(name = "content", nullable = false) + private String content; + + @OneToMany(mappedBy = "review", cascade = CascadeType.ALL, orphanRemoval = true) + private List reviewImageList = new ArrayList<>(); +} diff --git a/src/main/java/com/likelion/trendithon/domain/card/entity/ReviewImage.java b/src/main/java/com/likelion/trendithon/domain/card/entity/ReviewImage.java new file mode 100644 index 0000000..e033cae --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/card/entity/ReviewImage.java @@ -0,0 +1,40 @@ +package com.likelion.trendithon.domain.card.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReviewImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long reviewImageId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private Review review; + + @Column(name = "image_url", nullable = false) + private String imageUrl; +} diff --git a/src/main/java/com/likelion/trendithon/domain/card/repository/ReviewImageRepository.java b/src/main/java/com/likelion/trendithon/domain/card/repository/ReviewImageRepository.java new file mode 100644 index 0000000..7dd49f9 --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/card/repository/ReviewImageRepository.java @@ -0,0 +1,7 @@ +package com.likelion.trendithon.domain.card.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.likelion.trendithon.domain.card.entity.ReviewImage; + +public interface ReviewImageRepository extends JpaRepository {} diff --git a/src/main/java/com/likelion/trendithon/domain/card/repository/ReviewRepository.java b/src/main/java/com/likelion/trendithon/domain/card/repository/ReviewRepository.java new file mode 100644 index 0000000..1ad2f60 --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/card/repository/ReviewRepository.java @@ -0,0 +1,7 @@ +package com.likelion.trendithon.domain.card.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.likelion.trendithon.domain.card.entity.Review; + +public interface ReviewRepository extends JpaRepository {} diff --git a/src/main/java/com/likelion/trendithon/domain/card/service/CardService.java b/src/main/java/com/likelion/trendithon/domain/card/service/CardService.java index a5f5938..523ebb7 100644 --- a/src/main/java/com/likelion/trendithon/domain/card/service/CardService.java +++ b/src/main/java/com/likelion/trendithon/domain/card/service/CardService.java @@ -93,7 +93,10 @@ public ResponseEntity getCardById(Long id) { CardResponse.builder() .success(true) .message("카드 조회에 성공하였습니다.") - .cardId(card.getCardId()) + .emoji(card.getEmoji()) + .title(card.getTitle()) + .content(card.getContent()) + .cover(card.getCover()) .build()); } catch (IllegalArgumentException e) { log.error("[GET /api/cards/{}] 특정 카드 조회 실패", id); @@ -142,7 +145,7 @@ public ResponseEntity getRandomCards() { .build()); } } - + @Transactional public ResponseEntity getAllCards() { try { @@ -171,6 +174,7 @@ public ResponseEntity deleteCard(Long id) { cardRepository .findById(id) .orElseThrow(() -> new IllegalArgumentException("카드를 찾을 수 없습니다.")); + cardRepository.delete(card); log.info("[DELETE /api/cards/{}] 특정 카드 삭제 성공 - 삭제한 카드 ID: {}", id, id); return ResponseEntity.ok( DeleteCardResponse.builder().success(true).message("카드 삭제에 성공하였습니다.").cardId(id).build()); diff --git a/src/main/java/com/likelion/trendithon/domain/card/service/ExperienceService.java b/src/main/java/com/likelion/trendithon/domain/card/service/ExperienceService.java index 8fd2a2b..fc7054f 100644 --- a/src/main/java/com/likelion/trendithon/domain/card/service/ExperienceService.java +++ b/src/main/java/com/likelion/trendithon/domain/card/service/ExperienceService.java @@ -34,9 +34,7 @@ public class ExperienceService { // 경험 생성 @Transactional public ResponseEntity createExperience( - Long cardId, - CreateExperienceRequest createExperienceRequest, - HttpServletRequest httpServletRequest) { + CreateExperienceRequest createExperienceRequest, HttpServletRequest httpServletRequest) { try { String loginId = @@ -46,15 +44,25 @@ public ResponseEntity createExperience( .findByLoginId(loginId) .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + if (user.getState()) { + log.error("[POST /api/cards/create] 경험 생성 실패 - 도전 중인 경험이 이미 존재합니다."); + return ResponseEntity.ok( + CreateExperienceResponse.builder() + .success(false) + .message("해당 사용자가 도전 중인 경험이 존재합니다.") + .build()); + } + Card card = cardRepository - .findById(cardId) + .findById(createExperienceRequest.getCardId()) .orElseThrow(() -> new IllegalArgumentException("카드를 찾을 수 없습니다.")); Experience experience = Experience.builder() .user(user) .card(card) + .title(card.getTitle()) .state(true) .cover(createExperienceRequest.getCover()) .startDate(createExperienceRequest.getStartDate()) @@ -67,7 +75,7 @@ public ResponseEntity createExperience( userRepository.save(user); log.info( - "[POST /api/cards/] 경험 생성 성공 - 생성한 사용자 ID: {}, 카드 ID: {}, 경험 ID: {}", + "[POST /api/cards/experience] 경험 생성 성공 - 생성한 사용자 ID: {}, 카드 ID: {}, 경험 ID: {}", user.getLoginId(), card.getCardId(), experience.getExperienceId()); @@ -79,7 +87,7 @@ public ResponseEntity createExperience( .experienceId(experience.getExperienceId()) .build()); } catch (Exception e) { - log.error("[POST /api/cards/create] 경험 생성 실패 - 에러: {}", e.getMessage()); + log.error("[POST /api/cards/experience] 경험 생성 실패 - 에러: {}", e.getMessage()); return ResponseEntity.ok( CreateExperienceResponse.builder().success(false).message("경험 생성에 실패하였습니다.").build()); } @@ -106,7 +114,11 @@ public ResponseEntity getEnableExperience( ExperienceResponse.builder() .success(true) .message("경험 조회에 성공하였습니다.") - .experienceId(experience.getExperienceId()) + .title(experience.getTitle()) + .state(experience.isState()) + .cover(experience.getCover()) + .startDate(experience.getStartDate()) + .endDate(experience.getEndDate()) .build()); } catch (IllegalArgumentException e) { log.error("[GET /api/cards/experience] 특정 경험 조회 실패"); @@ -143,7 +155,11 @@ public ResponseEntity updateExperience( ExperienceResponse.builder() .success(true) .message("경험 수정에 성공하였습니다.") - .experienceId(experience.getExperienceId()) + .title(experience.getTitle()) + .state(experience.isState()) + .cover(experience.getCover()) + .startDate(experience.getStartDate()) + .endDate(experience.getEndDate()) .build()); } catch (IllegalArgumentException e) { log.error("[PUT /api/cards/experience] 특정 경험 수정 실패"); @@ -176,17 +192,13 @@ public ResponseEntity quitExperience(HttpServletRequest http experienceRepository.save(experience); return ResponseEntity.ok( - ExperienceResponse.builder() - .success(true) - .message("경험 수정에 성공하였습니다.") - .experienceId(experience.getExperienceId()) - .build()); + ExperienceResponse.builder().success(true).message("경험 포기에 성공하였습니다.").build()); } catch (IllegalArgumentException e) { - log.error("[PUT /api/cards/experience] 특정 경험 수정 실패"); + log.error("[PUT /api/cards/experience/quit] 특정 경험 포기 실패"); return ResponseEntity.ok( ExperienceResponse.builder().success(false).message(e.getMessage()).build()); } catch (Exception e) { - log.error("[PUT /api/cards/experience] 특정 경험 수정 실패 - 에러: {}", e.getMessage()); + log.error("[PUT /api/cards/experience/quit] 특정 경험 포기 실패 - 에러: {}", e.getMessage()); return ResponseEntity.ok( ExperienceResponse.builder().success(false).message("경험 수정 중 오류가 발생하였습니다.").build()); } diff --git a/src/main/java/com/likelion/trendithon/domain/card/service/ReviewService.java b/src/main/java/com/likelion/trendithon/domain/card/service/ReviewService.java new file mode 100644 index 0000000..52f0d6e --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/card/service/ReviewService.java @@ -0,0 +1,87 @@ +package com.likelion.trendithon.domain.card.service; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.likelion.trendithon.domain.card.dto.request.CreateReviewRequest; +import com.likelion.trendithon.domain.card.dto.response.CreateReviewResponse; +import com.likelion.trendithon.domain.card.entity.Experience; +import com.likelion.trendithon.domain.card.entity.Review; +import com.likelion.trendithon.domain.card.entity.ReviewImage; +import com.likelion.trendithon.domain.card.repository.CardRepository; +import com.likelion.trendithon.domain.card.repository.ExperienceRepository; +import com.likelion.trendithon.domain.card.repository.ReviewRepository; +import com.likelion.trendithon.domain.user.repository.UserRepository; +import com.likelion.trendithon.global.auth.JwtUtil; +import com.likelion.trendithon.global.s3.service.S3Service; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@AllArgsConstructor +@Slf4j +public class ReviewService { + + private final ExperienceRepository experienceRepository; + private final CardRepository cardRepository; + private final UserRepository userRepository; + private final JwtUtil jwtUtil; + private final S3Service s3Service; + private final ReviewRepository reviewRepository; + + // 경험 생성 + @Transactional + public ResponseEntity createReview( + CreateReviewRequest createReviewRequest, List images) { + + try { + + Experience experience = + experienceRepository + .findById(createReviewRequest.getExperienceId()) + .orElseThrow(() -> new IllegalArgumentException("경험을 찾을 수 없습니다.")); + + Review review = + Review.builder() + .experience(experience) + .score(createReviewRequest.getScore()) + .endDate(createReviewRequest.getEndDate()) + .content(createReviewRequest.getContent()) + .build(); + + List reviewImageList = new ArrayList<>(); + + for (MultipartFile image : images) { + ReviewImage reviewImage = + ReviewImage.builder().review(review).imageUrl(s3Service.uploadFile(image)).build(); + + reviewImageList.add(reviewImage); + } + + review.setReviewImageList(reviewImageList); + reviewRepository.save(review); + + log.info( + "[POST /api/reviews] 리뷰 생성 성공 - 생성한 사용자 ID: {}, 경험 ID: {}, 리뷰 ID: {}", + experience.getUser().getLoginId(), + experience.getExperienceId(), + review.getReviewId()); + return ResponseEntity.ok( + CreateReviewResponse.builder() + .success(true) + .message("리뷰 생성에 성공하였습니다.") + .reviewId(review.getReviewId()) + .build()); + } catch (Exception e) { + log.error("[POST /api/reviews] 리뷰 생성 실패 - 에러: {}", e.getMessage()); + return ResponseEntity.ok( + CreateReviewResponse.builder().success(false).message("리뷰 생성에 실패하였습니다.").build()); + } + } +} diff --git a/src/main/java/com/likelion/trendithon/domain/temp/controller/TempController.java b/src/main/java/com/likelion/trendithon/domain/temp/controller/TempController.java deleted file mode 100644 index 92985ba..0000000 --- a/src/main/java/com/likelion/trendithon/domain/temp/controller/TempController.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.likelion.trendithon.domain.temp.controller; - -public class TempController {} diff --git a/src/main/java/com/likelion/trendithon/domain/temp/dto/TempResponse.java b/src/main/java/com/likelion/trendithon/domain/temp/dto/TempResponse.java deleted file mode 100644 index aa343c5..0000000 --- a/src/main/java/com/likelion/trendithon/domain/temp/dto/TempResponse.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.likelion.trendithon.domain.temp.dto; - -public class TempResponse {} diff --git a/src/main/java/com/likelion/trendithon/domain/temp/entity/Temp.java b/src/main/java/com/likelion/trendithon/domain/temp/entity/Temp.java deleted file mode 100644 index 52dbc72..0000000 --- a/src/main/java/com/likelion/trendithon/domain/temp/entity/Temp.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.likelion.trendithon.domain.temp.entity; - -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public class Temp {} diff --git a/src/main/java/com/likelion/trendithon/domain/temp/repository/TempRepository.java b/src/main/java/com/likelion/trendithon/domain/temp/repository/TempRepository.java deleted file mode 100644 index 7f6867e..0000000 --- a/src/main/java/com/likelion/trendithon/domain/temp/repository/TempRepository.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.likelion.trendithon.domain.temp.repository; - -public class TempRepository {} diff --git a/src/main/java/com/likelion/trendithon/domain/temp/service/TempService.java b/src/main/java/com/likelion/trendithon/domain/temp/service/TempService.java deleted file mode 100644 index f562f52..0000000 --- a/src/main/java/com/likelion/trendithon/domain/temp/service/TempService.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.likelion.trendithon.domain.temp.service; - -public class TempService {} diff --git a/src/main/java/com/likelion/trendithon/domain/user/controller/UserController.java b/src/main/java/com/likelion/trendithon/domain/user/controller/UserController.java index 76a593b..79db57d 100644 --- a/src/main/java/com/likelion/trendithon/domain/user/controller/UserController.java +++ b/src/main/java/com/likelion/trendithon/domain/user/controller/UserController.java @@ -1,6 +1,9 @@ package com.likelion.trendithon.domain.user.controller; +import jakarta.servlet.http.HttpServletRequest; + import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -30,9 +33,7 @@ public class UserController { @PostMapping("/register") public ResponseEntity register( @Parameter(description = "회원가입 정보") @RequestBody SignUpRequest signUpRequest) { - String nickname = NicknameGenerator.generateNickname(); - - return userService.register(signUpRequest, nickname); + return userService.register(signUpRequest); } @Operation(summary = "[ 토큰 X | 랜덤 닉네임 생성 ]", description = "랜덤 닉네임 생성") @@ -54,4 +55,12 @@ public ResponseEntity checkLoginIdDuplicate( @Parameter(description = "중복 검사할 아이디") @RequestBody DuplicateCheckRequest request) { return userService.checkLoginIdDuplicate(request); } + + @Operation( + summary = "[ 토큰 O | 사용자 상태 조회 ]", + description = "사용자가 경험을 등록했는지, 경험 중인지, 아무것도 안 했는지 조회") + @GetMapping("/state") + public ResponseEntity getCardById(HttpServletRequest httpServletRequest) { + return userService.getUserState(httpServletRequest); + } } diff --git a/src/main/java/com/likelion/trendithon/domain/user/dto/response/UserStateResponse.java b/src/main/java/com/likelion/trendithon/domain/user/dto/response/UserStateResponse.java new file mode 100644 index 0000000..3dbfa05 --- /dev/null +++ b/src/main/java/com/likelion/trendithon/domain/user/dto/response/UserStateResponse.java @@ -0,0 +1,18 @@ +package com.likelion.trendithon.domain.user.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserStateResponse { + @Schema(description = "사용자 상태 조회 결과", example = "true") + private boolean success; + + @Schema(description = "응답 메세지", example = "사용자 상태 조회에 성공하였습니다.") + private String message; + + @Schema(description = "사용자 상태", example = "true") + private Boolean state; +} diff --git a/src/main/java/com/likelion/trendithon/domain/user/entity/User.java b/src/main/java/com/likelion/trendithon/domain/user/entity/User.java index 5246639..6ddaff2 100644 --- a/src/main/java/com/likelion/trendithon/domain/user/entity/User.java +++ b/src/main/java/com/likelion/trendithon/domain/user/entity/User.java @@ -34,10 +34,10 @@ public class User extends BaseTimeEntity { @Column(name = "password", nullable = false) private String password; - @Column(name = "nickname", nullable = false, unique = true) + @Column(name = "nickname", nullable = false) private String nickname; - @Column(name = "state", nullable = false) + @Column(name = "state") private Boolean state; @Column(name = "refresh_token") diff --git a/src/main/java/com/likelion/trendithon/domain/user/service/UserService.java b/src/main/java/com/likelion/trendithon/domain/user/service/UserService.java index 600c6cc..e52b0cb 100644 --- a/src/main/java/com/likelion/trendithon/domain/user/service/UserService.java +++ b/src/main/java/com/likelion/trendithon/domain/user/service/UserService.java @@ -1,5 +1,7 @@ package com.likelion.trendithon.domain.user.service; +import jakarta.servlet.http.HttpServletRequest; + import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -11,6 +13,7 @@ import com.likelion.trendithon.domain.user.dto.response.DuplicateCheckResponse; import com.likelion.trendithon.domain.user.dto.response.LoginResponse; import com.likelion.trendithon.domain.user.dto.response.SignUpResponse; +import com.likelion.trendithon.domain.user.dto.response.UserStateResponse; import com.likelion.trendithon.domain.user.entity.User; import com.likelion.trendithon.domain.user.repository.UserRepository; import com.likelion.trendithon.global.auth.JwtUtil; @@ -27,7 +30,7 @@ public class UserService { private final JwtUtil jwtUtil; @Transactional - public ResponseEntity register(SignUpRequest request, String nickname) { + public ResponseEntity register(SignUpRequest request) { try { // 중복 회원 검사 if (userRepository.findByLoginId(request.getLoginId()).isPresent()) { @@ -43,7 +46,8 @@ public ResponseEntity register(SignUpRequest request, String nic User.builder() .loginId(request.getLoginId()) .password(encodedPassword) - .nickname(nickname) + .nickname(request.getNickname()) + .state(null) .userRole("USER") .build(); userRepository.save(user); @@ -53,7 +57,11 @@ public ResponseEntity register(SignUpRequest request, String nic user.getLoginId(), user.getNickname()); return ResponseEntity.ok( - SignUpResponse.builder().success(true).message("회원가입이 완료되었습니다.").build()); + SignUpResponse.builder() + .success(true) + .nickname(request.getNickname()) + .message("회원가입이 완료되었습니다.") + .build()); } catch (Exception e) { log.error("[POST /api/users/register] 회원가입 실패 - ID: {}", request.getLoginId()); return ResponseEntity.ok( @@ -127,4 +135,34 @@ public ResponseEntity checkLoginIdDuplicate( return ResponseEntity.ok(response); } + + // 사용자 상태 조회 + @Transactional + public ResponseEntity getUserState(HttpServletRequest httpServletRequest) { + + try { + String loginId = + jwtUtil.extractLoginId(httpServletRequest.getHeader("Authorization").substring(7)); + User user = + userRepository + .findByLoginId(loginId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + + log.info("[GET /api/users/state] 사용자 상태 조회 성공 - 조회한 사용자 ID: {}", user.getLoginId()); + return ResponseEntity.ok( + UserStateResponse.builder() + .success(true) + .message("사용자 상태 조회에 성공하였습니다.") + .state(user.getState()) + .build()); + } catch (IllegalArgumentException e) { + log.error("[GET /api/users/state] 사용자 상태 조회 실패"); + return ResponseEntity.ok( + UserStateResponse.builder().success(false).message(e.getMessage()).build()); + } catch (Exception e) { + log.error("[GET /api/users/state] 사용자 상태 조회 실패 - 에러: {}", e.getMessage()); + return ResponseEntity.ok( + UserStateResponse.builder().success(false).message("카드 조회 중 오류가 발생하였습니다.").build()); + } + } } diff --git a/src/main/java/com/likelion/trendithon/global/config/S3Config.java b/src/main/java/com/likelion/trendithon/global/config/S3Config.java new file mode 100644 index 0000000..a6692bc --- /dev/null +++ b/src/main/java/com/likelion/trendithon/global/config/S3Config.java @@ -0,0 +1,54 @@ +package com.likelion.trendithon.global.config; + +import jakarta.annotation.PostConstruct; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSCredentialsProvider; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; + +import lombok.Getter; + +@Getter +@Configuration +public class S3Config { + + private AWSCredentials awsCredentials; + + @Value("${aws.credentials.access-key}") + private String accessKey; + + @Value("${aws.credentials.secret-key}") + private String secretKey; + + @Value("${aws.region}") + private String region; + + @Value("${aws.s3.bucket}") + private String bucket; + + @PostConstruct + public void init() { + this.awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + } + + @Bean + public AmazonS3 amazonS3() { + AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } + + @Bean + public AWSCredentialsProvider awsCredentialsProvider() { + return new AWSStaticCredentialsProvider(awsCredentials); + } +} diff --git a/src/main/java/com/likelion/trendithon/global/config/SecurityConfig.java b/src/main/java/com/likelion/trendithon/global/config/SecurityConfig.java index 7e8c939..ac3655c 100644 --- a/src/main/java/com/likelion/trendithon/global/config/SecurityConfig.java +++ b/src/main/java/com/likelion/trendithon/global/config/SecurityConfig.java @@ -65,6 +65,7 @@ public CorsConfigurationSource corsConfigurationSource() { // 모든 출처 허용 configuration.addAllowedOrigin("http://localhost:5173"); // 개발 서버 configuration.addAllowedOrigin("https://localhost:5173"); // 배포 서버 + configuration.addAllowedOrigin("https://front-end-navy-two.vercel.app"); // 배포 서버 // 모든 HTTP 메서드 허용 configuration.addAllowedMethod("*"); // 모든 헤더 허용 diff --git a/src/main/java/com/likelion/trendithon/global/s3/service/S3Service.java b/src/main/java/com/likelion/trendithon/global/s3/service/S3Service.java new file mode 100644 index 0000000..1ee3aaf --- /dev/null +++ b/src/main/java/com/likelion/trendithon/global/s3/service/S3Service.java @@ -0,0 +1,115 @@ +package com.likelion.trendithon.global.s3.service; + +import java.io.ByteArrayInputStream; +import java.util.Base64; +import java.util.UUID; + +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.DeleteObjectRequest; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.likelion.trendithon.global.config.S3Config; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class S3Service { + + private final AmazonS3 amazonS3; + private final S3Config s3Config; + + public String uploadFile(MultipartFile file) { + + validateFile(file); + + String keyName = createKeyName(); + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + try { + amazonS3.putObject( + new PutObjectRequest(s3Config.getBucket(), "review", file.getInputStream(), metadata)); + return amazonS3.getUrl(s3Config.getBucket(), "review").toString(); + } catch (Exception e) { + throw new IllegalArgumentException(e.getMessage()); + } + } + + public String base64UploadFile(String base64Url) { + + String base64Data = base64Url; + String contentType = "image/png"; + + if (base64Url.contains(",")) { + String[] parts = base64Url.split(","); + if (parts[0].contains("data:") && parts[0].contains(";base64")) { + contentType = parts[0].substring(5, parts[0].indexOf(";")); + } + base64Data = parts[1]; + } + + byte[] decodedBytes = Base64.getDecoder().decode(base64Data); + String keyName = createKeyName(); + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(decodedBytes.length); + metadata.setContentType(contentType); + + try (ByteArrayInputStream inputStream = new ByteArrayInputStream(decodedBytes)) { + amazonS3.putObject( + new PutObjectRequest(s3Config.getBucket(), keyName, inputStream, metadata)); + return amazonS3.getUrl(s3Config.getBucket(), keyName).toString(); + } catch (Exception e) { + throw new IllegalArgumentException(e.getMessage()); + } + } + + public String createKeyName() { + + return "review/" + UUID.randomUUID().toString(); + } + + public String getFileUrl(String keyName) { + existFile(keyName); + + try { + String fileUrl = amazonS3.getUrl(s3Config.getBucket(), keyName).toString(); + return fileUrl; + } catch (Exception e) { + throw new IllegalArgumentException(e.getMessage()); + } + } + + public void deleteFile(String keyName) { + existFile(keyName); + + try { + amazonS3.deleteObject(new DeleteObjectRequest(s3Config.getBucket(), keyName)); + } catch (Exception e) { + throw new IllegalArgumentException(e.getMessage()); + } + } + + private void existFile(String keyName) { + if (!amazonS3.doesObjectExist(s3Config.getBucket(), keyName)) { + throw new IllegalArgumentException(); + } + } + + private void validateFile(MultipartFile file) { + if (file.getSize() > 5 * 1024 * 1024) { + throw new IllegalArgumentException(); + } + + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new IllegalArgumentException(); + } + } +}