From 6e64f42ddd26d52c2e3684fec90b77ba679b833b Mon Sep 17 00:00:00 2001 From: dradnats1012 Date: Sat, 20 Jul 2024 21:29:38 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=8B=9D=EB=8B=A8=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94,=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/dining/controller/DiningApi.java | 15 +++++++ .../dining/controller/DiningController.java | 19 +++++++++ .../domain/dining/dto/DiningLikeRequest.java | 19 +++++++++ .../domain/dining/dto/DiningResponse.java | 8 +++- .../exception/DuplicateLikeException.java | 19 +++++++++ .../exception/LikeNotFoundException.java | 19 +++++++++ .../koin/domain/dining/model/Dining.java | 15 ++++++- .../koin/domain/dining/model/DiningLikes.java | 39 ++++++++++++++++++ .../repository/DiningLikesRepository.java | 14 +++++++ .../domain/dining/service/DiningService.java | 40 +++++++++++++++++++ .../migration/V33_add_dining_like_table.sql | 7 ++++ 11 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/dining/dto/DiningLikeRequest.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/exception/DuplicateLikeException.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/exception/LikeNotFoundException.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningLikes.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/repository/DiningLikesRepository.java create mode 100644 src/main/resources/db/migration/V33_add_dining_like_table.sql diff --git a/src/main/java/in/koreatech/koin/domain/dining/controller/DiningApi.java b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningApi.java index f4294071b..4fd7dadfd 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/controller/DiningApi.java +++ b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningApi.java @@ -6,8 +6,11 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import in.koreatech.koin.domain.dining.dto.DiningLikeRequest; import in.koreatech.koin.domain.dining.dto.DiningResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -32,4 +35,16 @@ ResponseEntity> getDinings( @DateTimeFormat(pattern = "yyMMdd") @Parameter(description = "조회 날짜(yyMMdd)") @RequestParam(required = false) LocalDate date ); + + @Operation(summary = "식단 좋아요") + @PatchMapping("/dining/like") + ResponseEntity likeDining( + @RequestBody DiningLikeRequest diningLikeRequest + ); + + @Operation(summary = "식단 좋아요 취소") + @PatchMapping("/dining/like/cancel") + ResponseEntity likeDiningCancel( + @RequestBody DiningLikeRequest diningLikeRequest + ); } diff --git a/src/main/java/in/koreatech/koin/domain/dining/controller/DiningController.java b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningController.java index 12448c6e1..57c50a67d 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/controller/DiningController.java +++ b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningController.java @@ -6,9 +6,12 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import in.koreatech.koin.domain.dining.dto.DiningLikeRequest; import in.koreatech.koin.domain.dining.dto.DiningResponse; import in.koreatech.koin.domain.dining.service.DiningService; import lombok.RequiredArgsConstructor; @@ -27,4 +30,20 @@ public ResponseEntity> getDinings( List responses = diningService.getDinings(date); return ResponseEntity.ok(responses); } + + @PatchMapping("/dining/like") + public ResponseEntity likeDining( + @RequestBody DiningLikeRequest diningLikeRequest + ) { + diningService.likeDining(diningLikeRequest); + return ResponseEntity.ok().build(); + } + + @PatchMapping("/dining/like/cancel") + public ResponseEntity likeDiningCancel( + @RequestBody DiningLikeRequest diningLikeRequest + ) { + diningService.likeDiningCancel(diningLikeRequest); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningLikeRequest.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningLikeRequest.java new file mode 100644 index 000000000..094c8722d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningLikeRequest.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.domain.dining.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record DiningLikeRequest( + @Schema(description = "메뉴 고유 ID", example = "1", requiredMode = REQUIRED) + Integer diningId, + + @Schema(description = "사용자 ID", example = "1", requiredMode = REQUIRED) + Integer userId +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java index 7174503bf..aa5b5570a 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningResponse.java @@ -61,7 +61,10 @@ public record DiningResponse( @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @Schema(description = "메뉴 변경 시각", example = "2024-04-04 23:01:52", requiredMode = NOT_REQUIRED) - LocalDateTime changedAt + LocalDateTime changedAt, + + @Schema(description = "식단 좋아요 수", example = "1", requiredMode = REQUIRED) + Integer likes ) { public static DiningResponse from(Dining dining) { @@ -78,7 +81,8 @@ public static DiningResponse from(Dining dining) { dining.getCreatedAt(), dining.getUpdatedAt(), dining.getSoldOut(), - dining.getIsChanged() + dining.getIsChanged(), + dining.getLikes() ); } } diff --git a/src/main/java/in/koreatech/koin/domain/dining/exception/DuplicateLikeException.java b/src/main/java/in/koreatech/koin/domain/dining/exception/DuplicateLikeException.java new file mode 100644 index 000000000..36dec38e9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/exception/DuplicateLikeException.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.domain.dining.exception; + +import in.koreatech.koin.global.exception.DuplicationException; + +public class DuplicateLikeException extends DuplicationException { + private static final String DEFAULT_MESSAGE = "이미 좋아요를 누른 식단입니다!"; + + public DuplicateLikeException(String message) { + super(message); + } + + public DuplicateLikeException(String message, String detail) { + super(message, detail); + } + + public static DuplicateLikeException withDetail(Integer diningId, Integer userId) { + return new DuplicateLikeException(DEFAULT_MESSAGE, "diningId: '" + diningId + "'" + "userId: " + userId); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/exception/LikeNotFoundException.java b/src/main/java/in/koreatech/koin/domain/dining/exception/LikeNotFoundException.java new file mode 100644 index 000000000..3dd22c78a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/exception/LikeNotFoundException.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.domain.dining.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class LikeNotFoundException extends DataNotFoundException { + private static final String DEFAULT_MESSAGE = "좋아요를 누른적이 없는 식단입니다!"; + + public LikeNotFoundException(String message) { + super(message); + } + + public LikeNotFoundException(String message, String detail) { + super(message, detail); + } + + public static LikeNotFoundException withDetail(Integer diningId, Integer userId) { + return new LikeNotFoundException(DEFAULT_MESSAGE, "diningId: '" + diningId + "'" + "userId: " + userId); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/Dining.java b/src/main/java/in/koreatech/koin/domain/dining/model/Dining.java index 2833d71c9..e1fd86032 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/model/Dining.java +++ b/src/main/java/in/koreatech/koin/domain/dining/model/Dining.java @@ -75,6 +75,9 @@ public class Dining extends BaseEntity { @Column(name = "is_changed", columnDefinition = "DATETIME") private LocalDateTime isChanged; + @Column(name = "likes") + private Integer likes = 0; + @Builder private Dining( LocalDate date, @@ -86,7 +89,8 @@ private Dining( String menu, String imageUrl, LocalDateTime soldOut, - LocalDateTime isChanged + LocalDateTime isChanged, + Integer likes ) { this.date = date; this.type = type; @@ -98,6 +102,7 @@ private Dining( this.imageUrl = imageUrl; this.soldOut = soldOut; this.isChanged = isChanged; + this.likes = likes; } public void setImageUrl(String imageUrl) { @@ -112,6 +117,14 @@ public void cancelSoldOut() { this.soldOut = null; } + public void likesDining() { + this.likes++; + } + + public void likesDiningCancel() { + this.likes--; + } + /** * DB에 "[메뉴, 메뉴, ...]" 형태로 저장되어 List로 파싱하여 반환 */ diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/DiningLikes.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningLikes.java new file mode 100644 index 000000000..7fd97a9ac --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningLikes.java @@ -0,0 +1,39 @@ +package in.koreatech.koin.domain.dining.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "dining_likes") +@NoArgsConstructor(access = PROTECTED) +public class DiningLikes { + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false) + private Integer id; + + @Column(name = "dining_id", nullable = false) + private Integer diningId; + + @Column(name = "user_id", nullable = false) + private Integer userId; + + @Builder + private DiningLikes( + Integer diningId, + Integer userId + ) { + this.diningId = diningId; + this.userId = userId; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningLikesRepository.java b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningLikesRepository.java new file mode 100644 index 000000000..8dbdb5ca3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningLikesRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.dining.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.dining.model.DiningLikes; + +public interface DiningLikesRepository extends Repository { + + Boolean existsByDiningIdAndUserId(Integer diningId, Integer userId); + + DiningLikes save(DiningLikes diningLikes); + + void deleteByDiningIdAndUserId(Integer diningId, Integer userId); +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/service/DiningService.java b/src/main/java/in/koreatech/koin/domain/dining/service/DiningService.java index 916bb8207..bf31a076f 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/service/DiningService.java +++ b/src/main/java/in/koreatech/koin/domain/dining/service/DiningService.java @@ -7,7 +7,13 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import in.koreatech.koin.domain.dining.dto.DiningLikeRequest; import in.koreatech.koin.domain.dining.dto.DiningResponse; +import in.koreatech.koin.domain.dining.exception.DuplicateLikeException; +import in.koreatech.koin.domain.dining.exception.LikeNotFoundException; +import in.koreatech.koin.domain.dining.model.Dining; +import in.koreatech.koin.domain.dining.model.DiningLikes; +import in.koreatech.koin.domain.dining.repository.DiningLikesRepository; import in.koreatech.koin.domain.dining.repository.DiningRepository; import lombok.RequiredArgsConstructor; @@ -17,6 +23,7 @@ public class DiningService { private final DiningRepository diningRepository; + private final DiningLikesRepository diningLikesRepository; private final Clock clock; @@ -29,4 +36,37 @@ public List getDinings(LocalDate date) { .map(DiningResponse::from) .toList(); } + + @Transactional(readOnly = false) + public void likeDining(DiningLikeRequest diningLikeRequest) { + if (diningLikesRepository.existsByDiningIdAndUserId(diningLikeRequest.diningId(), diningLikeRequest.userId())) { + throw DuplicateLikeException.withDetail(diningLikeRequest.diningId(), diningLikeRequest.userId()); + } + + Dining dining = diningRepository.getById(diningLikeRequest.diningId()); + dining.likesDining(); + diningRepository.save(dining); + + diningLikesRepository.save(DiningLikes.builder() + .diningId(diningLikeRequest.diningId()) + .userId(diningLikeRequest.userId()) + .build()); + } + + @Transactional(readOnly = false) + public void likeDiningCancel(DiningLikeRequest diningLikeRequest) { + if (!diningLikesRepository.existsByDiningIdAndUserId(diningLikeRequest.diningId(), + diningLikeRequest.userId())) { + throw LikeNotFoundException.withDetail(diningLikeRequest.diningId(), diningLikeRequest.userId()); + } + + Dining dining = diningRepository.getById(diningLikeRequest.diningId()); + dining.likesDiningCancel(); + diningRepository.save(dining); + + diningLikesRepository.deleteByDiningIdAndUserId( + diningLikeRequest.diningId(), + diningLikeRequest.userId() + ); + } } diff --git a/src/main/resources/db/migration/V33_add_dining_like_table.sql b/src/main/resources/db/migration/V33_add_dining_like_table.sql new file mode 100644 index 000000000..ccd7485a8 --- /dev/null +++ b/src/main/resources/db/migration/V33_add_dining_like_table.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS dining_likes ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + dining_id INT NOT NULL, + user_id INT NOT NULL +); + +ALTER TABLE `dining_menus` ADD COLUMN `likes` INT DEFAULT 0; \ No newline at end of file From 48fbd6734e853bd513ca8bd466acf7acd5857f36 Mon Sep 17 00:00:00 2001 From: dradnats1012 Date: Sat, 20 Jul 2024 21:51:52 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=8B=9D=EB=8B=A8=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EA=B8=B0=EB=8A=A5=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koin/acceptance/DiningApiTest.java | 59 ++++++++++++++++++- .../koreatech/koin/fixture/DiningFixture.java | 5 ++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java b/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java index a0d3c7935..65af813c3 100644 --- a/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/DiningApiTest.java @@ -87,7 +87,8 @@ void findDinings() { "created_at": "2024-01-15 12:00:00", "updated_at": "2024-01-15 12:00:00", "soldout_at": null, - "changed_at": null + "changed_at": null, + "likes": 0 } ] """); @@ -135,7 +136,8 @@ void nullDate() { "created_at": "2024-01-15 12:00:00", "updated_at": "2024-01-15 12:00:00", "soldout_at": null, - "changed_at": null + "changed_at": null, + "likes": 0 } ] """); @@ -292,4 +294,57 @@ void checkSoldOutNotificationResend() { verify(coopEventListener, never()).onDiningSoldOutRequest(any()); } + + @Test + @DisplayName("특정 식단의 좋아요를 누른다") + void likeDining() { + RestAssured.given() + .contentType(ContentType.JSON) + .body(String.format(""" + { + "dining_id": "%s", + "user_id": %s + } + """, A코너_점심.getId(), coop_준기.getId()) + ) + .when() + .patch("/dining/like") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + } + + @Test + @DisplayName("특정 식단의 좋아요 중복해서 누르면 에러") + void likeDiningDuplicate() { + RestAssured.given() + .contentType(ContentType.JSON) + .body(String.format(""" + { + "dining_id": "%s", + "user_id": %s + } + """, A코너_점심.getId(), coop_준기.getId()) + ) + .when() + .patch("/dining/like") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + RestAssured.given() + .contentType(ContentType.JSON) + .body(String.format(""" + { + "dining_id": "%s", + "user_id": %s + } + """, A코너_점심.getId(), coop_준기.getId()) + ) + .when() + .patch("/dining/like") + .then() + .statusCode(HttpStatus.CONFLICT.value()) + .extract(); + } } diff --git a/src/test/java/in/koreatech/koin/fixture/DiningFixture.java b/src/test/java/in/koreatech/koin/fixture/DiningFixture.java index e14583fc3..a6f80d134 100644 --- a/src/test/java/in/koreatech/koin/fixture/DiningFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/DiningFixture.java @@ -31,6 +31,7 @@ public DiningFixture(DiningRepository diningRepository) { .kcal(300) .menu(""" ["참치김치볶음밥", "유부된장국", "땡초부추전", "누룽지탕"]""") + .likes(0) .build() ); } @@ -46,6 +47,7 @@ public DiningFixture(DiningRepository diningRepository) { .kcal(881) .menu(""" ["혼합잡곡밥", "가쓰오장국", "땡초부추전", "누룽지탕"]""") + .likes(0) .build() ); } @@ -61,6 +63,7 @@ public DiningFixture(DiningRepository diningRepository) { .kcal(881) .menu(""" ["병아리콩밥", "(탕)소고기육개장", "땡초부추전", "누룽지탕"]""") + .likes(0) .build() ); } @@ -76,6 +79,7 @@ public DiningFixture(DiningRepository diningRepository) { .kcal(881) .menu(""" ["병아리콩밥", "(탕)소고기육개장", "땡초부추전", "누룽지탕"]""") + .likes(0) .build() ); } @@ -91,6 +95,7 @@ public DiningFixture(DiningRepository diningRepository) { .kcal(881) .menu(""" ["병아리", "소고기", "땡초", "탕"]""") + .likes(0) .build() ); } From 727fee51c59fe6aabaf9b37ccccc0020810691d1 Mon Sep 17 00:00:00 2001 From: dradnats1012 Date: Sat, 20 Jul 2024 22:35:41 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20flyway=20=EC=96=B8=EB=8D=94?= =?UTF-8?q?=EB=B0=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...3_add_dining_like_table.sql => V33__add_dining_like_table.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V33_add_dining_like_table.sql => V33__add_dining_like_table.sql} (100%) diff --git a/src/main/resources/db/migration/V33_add_dining_like_table.sql b/src/main/resources/db/migration/V33__add_dining_like_table.sql similarity index 100% rename from src/main/resources/db/migration/V33_add_dining_like_table.sql rename to src/main/resources/db/migration/V33__add_dining_like_table.sql