From 77d47de0b491998c993c3c7d35440e0d0f0a3ec9 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Thu, 27 Mar 2025 16:02:56 +0900 Subject: [PATCH 01/11] =?UTF-8?q?:hammer:=20chore=20:=20running=5Frecords?= =?UTF-8?q?=20ddl=20=EC=88=98=EC=A0=95=20:=20=EB=B3=B4=EC=83=81=20?= =?UTF-8?q?=EC=A7=80=EA=B8=89=20=EC=97=AC=EB=B6=80=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/sql/schema.sql | 1 + src/test/resources/sql/schema.sql | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql index 1fa8b843..2206bc52 100644 --- a/src/main/resources/sql/schema.sql +++ b/src/main/resources/sql/schema.sql @@ -60,6 +60,7 @@ CREATE TABLE `running_records` `end_at` timestamp, `total_distance` integer, `pace_in_milli_seconds` integer, + `is_rewarded` boolean, `created_at` timestamp, `updated_at` timestamp, `deleted_at` TIMESTAMP diff --git a/src/test/resources/sql/schema.sql b/src/test/resources/sql/schema.sql index 1fa8b843..2206bc52 100644 --- a/src/test/resources/sql/schema.sql +++ b/src/test/resources/sql/schema.sql @@ -60,6 +60,7 @@ CREATE TABLE `running_records` `end_at` timestamp, `total_distance` integer, `pace_in_milli_seconds` integer, + `is_rewarded` boolean, `created_at` timestamp, `updated_at` timestamp, `deleted_at` TIMESTAMP From c4f600e30396ca7168666dff8c5429040fd50646 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Thu, 27 Mar 2025 16:05:13 +0900 Subject: [PATCH 02/11] =?UTF-8?q?:triangular=5Fflag=5Fon=5Fpost:=20feat=20?= =?UTF-8?q?:=20RunningRecord=20JPA=EC=97=94=ED=8B=B0=ED=8B=B0=EC=97=90=20`?= =?UTF-8?q?isRewarded`=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runimo/records/domain/RunningRecord.java | 14 +++++++++++++- .../records/service/RecordCommandService.java | 5 +++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/runimo/runimo/records/domain/RunningRecord.java b/src/main/java/org/runimo/runimo/records/domain/RunningRecord.java index e0c13e5a..3bcae9a7 100644 --- a/src/main/java/org/runimo/runimo/records/domain/RunningRecord.java +++ b/src/main/java/org/runimo/runimo/records/domain/RunningRecord.java @@ -29,14 +29,17 @@ public class RunningRecord extends BaseEntity { private Distance totalDistance; @Embedded private Pace averagePace; + @Column(name = "is_rewarded", nullable = false) + private Boolean isRewarded; @Builder - public RunningRecord(Long userId, String title, LocalDateTime startedAt, LocalDateTime endAt, Distance totalDistance, Pace averagePace) { + public RunningRecord(Long userId, String title, LocalDateTime startedAt, LocalDateTime endAt, Distance totalDistance, Pace averagePace, Boolean isRewarded) { this.userId = userId; this.title = title; this.recordPublicId = UUID.randomUUID().toString(); this.startedAt = startedAt; this.endAt = endAt; + this.isRewarded = isRewarded; this.totalDistance = totalDistance; this.averagePace = averagePace; } @@ -59,8 +62,17 @@ public void update(RunningRecord updatedEntity) { this.endAt = updatedEntity.getEndAt(); this.averagePace = updatedEntity.averagePace; this.recordPublicId = updatedEntity.recordPublicId; + this.isRewarded = updatedEntity.isRewarded; } + public void reward(Long editorId) { + validateEditor(editorId); + this.isRewarded = true; + } + + public boolean isRecordAlreadyRewarded() { + return this.isRewarded; + } private void validateEditor(Long editorId) { if (editorId == null || !Objects.equals(this.userId, editorId)) { diff --git a/src/main/java/org/runimo/runimo/records/service/RecordCommandService.java b/src/main/java/org/runimo/runimo/records/service/RecordCommandService.java index 6f3a81bb..0e39813a 100644 --- a/src/main/java/org/runimo/runimo/records/service/RecordCommandService.java +++ b/src/main/java/org/runimo/runimo/records/service/RecordCommandService.java @@ -21,7 +21,7 @@ public class RecordCommandService { @Transactional public RecordSaveResponse saveRecord(Long userId, RecordCreateCommand command) { - RunningRecord runningRecord = mapToRunningRecord(userId, command); + RunningRecord runningRecord = mapToCreatedRunningRecord(userId, command); recordRepository.save(runningRecord); return new RecordSaveResponse(runningRecord.getId()); } @@ -45,13 +45,14 @@ private RunningRecord mapToUpdateRecord(RecordUpdateCommand command) { ); } - private RunningRecord mapToRunningRecord(Long id, RecordCreateCommand command) { + private RunningRecord mapToCreatedRunningRecord(Long id, RecordCreateCommand command) { return RunningRecord.builder() .userId(id) .startedAt(command.startedAt()) .endAt(command.endAt()) .averagePace(command.averagePace()) .totalDistance(command.totalDistance()) + .isRewarded(false) .build(); } } From 51ee36e1d3303e730b46cb05945ba7db40fc1b31 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Thu, 27 Mar 2025 16:10:42 +0900 Subject: [PATCH 03/11] =?UTF-8?q?:recycle:=20refactor=20:=20`@UserId`?= =?UTF-8?q?=EB=A5=BC=20=ED=99=9C=EC=9A=A9=ED=95=98=EC=97=AC,=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=EB=90=9C=20user=20PK=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runimo/records/controller/RecordController.java | 12 ++++++++---- .../controller/requests/RecordSaveRequest.java | 2 -- .../service/usecases/RecordCreateUsecaseImpl.java | 2 +- .../service/usecases/dtos/RecordCreateCommand.java | 6 +++--- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/runimo/runimo/records/controller/RecordController.java b/src/main/java/org/runimo/runimo/records/controller/RecordController.java index 20b5fbdb..7c593f64 100644 --- a/src/main/java/org/runimo/runimo/records/controller/RecordController.java +++ b/src/main/java/org/runimo/runimo/records/controller/RecordController.java @@ -7,8 +7,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.runimo.runimo.common.response.SuccessResponse; import org.runimo.runimo.records.controller.requests.RecordSaveRequest; import org.runimo.runimo.records.controller.requests.RecordUpdateRequest; +import org.runimo.runimo.records.enums.RecordHttpResponse; import org.runimo.runimo.records.service.usecases.RecordCreateUsecase; import org.runimo.runimo.records.service.usecases.RecordQueryUsecase; import org.runimo.runimo.records.service.usecases.RecordUpdateUsecase; @@ -39,11 +41,13 @@ public class RecordController { @ApiResponse(responseCode = "401", description = "인증 실패") }) @PostMapping - public ResponseEntity saveRecord( - @RequestBody RecordSaveRequest request + public ResponseEntity> saveRecord( + @RequestBody RecordSaveRequest request, + @UserId Long userId ) { - RecordSaveResponse response = recordCreateUsecase.execute(RecordCreateCommand.from(request)); - return ResponseEntity.created(URI.create("/api/v1/records/" + response.savedId())).body(response); + RecordSaveResponse response = recordCreateUsecase.execute(RecordCreateCommand.from(request, userId)); + return ResponseEntity.created(URI.create("/api/v1/records/" + response.savedId())) + .body(SuccessResponse.of(RecordHttpResponse.RECORD_SAVED, response)); } @Operation(summary = "기록 조회", description = "기록을 조회합니다.") diff --git a/src/main/java/org/runimo/runimo/records/controller/requests/RecordSaveRequest.java b/src/main/java/org/runimo/runimo/records/controller/requests/RecordSaveRequest.java index f9ea60bd..62a0ad58 100644 --- a/src/main/java/org/runimo/runimo/records/controller/requests/RecordSaveRequest.java +++ b/src/main/java/org/runimo/runimo/records/controller/requests/RecordSaveRequest.java @@ -6,8 +6,6 @@ @Schema(description = "사용자 달리기 기록 저장 요청 DTO") public record RecordSaveRequest( - @Schema(description = "사용자 고유 식별자", example = "c7b3b3b3-7b3b-4b3b-8b3b-3b3b3b3b3b3b") - String userPublicId, @Schema(description = "달리기 제목", example = "오늘의 달리기") LocalDateTime startedAt, @Schema(description = "달리기 시작 시각", example = "2021-10-10T10:10:10") diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/RecordCreateUsecaseImpl.java b/src/main/java/org/runimo/runimo/records/service/usecases/RecordCreateUsecaseImpl.java index 7b16f01b..3e978b07 100644 --- a/src/main/java/org/runimo/runimo/records/service/usecases/RecordCreateUsecaseImpl.java +++ b/src/main/java/org/runimo/runimo/records/service/usecases/RecordCreateUsecaseImpl.java @@ -23,7 +23,7 @@ public class RecordCreateUsecaseImpl implements RecordCreateUsecase { @Override @Transactional public RecordSaveResponse execute(RecordCreateCommand command) { - User user = userFinder.findUserByPublicId(command.userPublicId()) + User user = userFinder.findUserById(command.userId()) .orElseThrow(NoSuchElementException::new); userStatService.updateUserStats(user, command); return commandService.saveRecord(user.getId(), command); diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/dtos/RecordCreateCommand.java b/src/main/java/org/runimo/runimo/records/service/usecases/dtos/RecordCreateCommand.java index c359ccd1..5bff2c0b 100644 --- a/src/main/java/org/runimo/runimo/records/service/usecases/dtos/RecordCreateCommand.java +++ b/src/main/java/org/runimo/runimo/records/service/usecases/dtos/RecordCreateCommand.java @@ -8,16 +8,16 @@ import java.time.temporal.ChronoUnit; public record RecordCreateCommand( - String userPublicId, + Long userId, LocalDateTime startedAt, LocalDateTime endAt, Pace averagePace, Distance totalDistance ) { - public static RecordCreateCommand from(RecordSaveRequest request) { + public static RecordCreateCommand from(final RecordSaveRequest request, final Long userId) { return new RecordCreateCommand( - request.userPublicId(), + userId, request.startedAt(), request.endAt(), new Pace(request.averagePaceInMilliSeconds()), From 19d842a63dff5ffbcc24d1e0902f6420b837eaf8 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Thu, 27 Mar 2025 16:12:23 +0900 Subject: [PATCH 04/11] =?UTF-8?q?:sparkles:=20feat=20:=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EC=95=8C=EC=A7=80=EA=B8=89=20validate=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../records/repository/RecordRepository.java | 16 ++++++++ .../runimo/records/service/RecordFinder.java | 11 +++++ .../runimo/rewards/service/RewardService.java | 41 ++++++++++++++++--- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java b/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java index e47d67fb..d3acc66e 100644 --- a/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java +++ b/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java @@ -1,12 +1,28 @@ package org.runimo.runimo.records.repository; import org.runimo.runimo.records.domain.RunningRecord; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.Optional; @Repository public interface RecordRepository extends JpaRepository { Optional findByRecordPublicId(String id); + + @Query("SELECT r FROM RunningRecord r " + + "WHERE r.userId = :userId " + + "AND r.startedAt BETWEEN :startOfWeek AND :now " + + "ORDER BY r.startedAt ASC") + Slice findFirstRunOfWeek( + @Param("userId") Long userId, + @Param("startOfWeek") LocalDateTime startOfWeek, + @Param("now") LocalDateTime now, + Pageable pageable + ); } diff --git a/src/main/java/org/runimo/runimo/records/service/RecordFinder.java b/src/main/java/org/runimo/runimo/records/service/RecordFinder.java index a6ff216d..14348342 100644 --- a/src/main/java/org/runimo/runimo/records/service/RecordFinder.java +++ b/src/main/java/org/runimo/runimo/records/service/RecordFinder.java @@ -3,9 +3,12 @@ import lombok.RequiredArgsConstructor; import org.runimo.runimo.records.domain.RunningRecord; import org.runimo.runimo.records.repository.RecordRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.Optional; @Component @@ -23,4 +26,12 @@ public Optional findById(Long id) { public Optional findByPublicId(String id) { return recordRepository.findByRecordPublicId(id); } + + @Transactional(readOnly = true) + public Optional findFirstRunOfCurrentWeek(Long userId) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime startOfWeek = now.minusDays(now.getDayOfWeek().getValue() - 1L).withHour(0).withMinute(0).withSecond(0); + PageRequest pageRequest = PageRequest.of(0, 1, Sort.by("createdAt").ascending()); + return recordRepository.findFirstRunOfWeek(userId, startOfWeek, now, pageRequest).stream().findFirst(); + } } diff --git a/src/main/java/org/runimo/runimo/rewards/service/RewardService.java b/src/main/java/org/runimo/runimo/rewards/service/RewardService.java index 71bb0b61..a4e4f84d 100644 --- a/src/main/java/org/runimo/runimo/rewards/service/RewardService.java +++ b/src/main/java/org/runimo/runimo/rewards/service/RewardService.java @@ -1,7 +1,9 @@ package org.runimo.runimo.rewards.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.runimo.runimo.item.domain.Egg; +import org.runimo.runimo.records.domain.RunningRecord; import org.runimo.runimo.records.service.RecordFinder; import org.runimo.runimo.rewards.service.eggs.EggGrantService; import org.runimo.runimo.rewards.service.dtos.RewardClaimCommand; @@ -9,9 +11,12 @@ import org.runimo.runimo.user.domain.User; import org.runimo.runimo.user.service.UserFinder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.NoSuchElementException; +import java.util.Optional; +@Slf4j @Service @RequiredArgsConstructor public class RewardService { @@ -20,14 +25,40 @@ public class RewardService { private final UserFinder userFinder; private final EggGrantService eggGrantService; + @Transactional public RewardResponse claimReward(RewardClaimCommand command) { - User user = userFinder.findUserById(command.userId()) + RunningRecord runningRecord = recordFinder.findById(command.recordId()) .orElseThrow(NoSuchElementException::new); - recordFinder.findById(command.recordId()) - .orElseThrow(NoSuchElementException::new); - // grant egg - Egg grantedEgg = eggGrantService.grantRandomEggToUser(user); + validateRecord(runningRecord); + Egg grantedEgg = rewardEgg(command); + runningRecord.reward(command.userId()); //TODO: 애정 보상 return new RewardResponse(grantedEgg.getItemCode(), grantedEgg.getEggType()); } + + private Egg rewardEgg(RewardClaimCommand command) { + User user = userFinder.findUserById(command.userId()) + .orElseThrow(NoSuchElementException::new); + if(validateRecordIsFirstRecordOfWeek(command)) { + return eggGrantService.grantRandomEggToUser(user); + } + return Egg.EMPTY; + } + + private void validateRecord(RunningRecord runningRecord) { + if(runningRecord.isRecordAlreadyRewarded()) throw new IllegalStateException("이미 보상이 지급되었습니다."); + } + + private boolean validateRecordIsFirstRecordOfWeek(RewardClaimCommand command) { + Optional firstRecordOfWeek = recordFinder.findFirstRunOfCurrentWeek(command.userId()); + if(firstRecordOfWeek.isEmpty()) { + log.info("유저 {}의 첫번째 달리기 기록이 없습니다.", command.userId()); + return false; + } + if(!command.recordId().equals(firstRecordOfWeek.get().getId())){ + log.info("유저 {}의 첫번째 달리기 기록이 아닙니다.", command.userId()); + return false; + } + return true; + } } From 9867404cf7e735450eabb33ec915915cf16f12a6 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Thu, 27 Mar 2025 16:12:39 +0900 Subject: [PATCH 05/11] =?UTF-8?q?:sparkles:=20feat=20:=20Record=20HTTP?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20enum=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../records/enums/RecordHttpResponse.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/main/java/org/runimo/runimo/records/enums/RecordHttpResponse.java diff --git a/src/main/java/org/runimo/runimo/records/enums/RecordHttpResponse.java b/src/main/java/org/runimo/runimo/records/enums/RecordHttpResponse.java new file mode 100644 index 00000000..3c6aa550 --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/enums/RecordHttpResponse.java @@ -0,0 +1,40 @@ +package org.runimo.runimo.records.enums; +import org.runimo.runimo.exceptions.code.CustomResponseCode; +import org.springframework.http.HttpStatus; + +public enum RecordHttpResponse implements CustomResponseCode { + RECORD_SAVED("RSH2001", "달리기 기록 저장 성공", "달리기 기록 저장 성공"), + ; + + private final String code; + private final String clientMessage; + private final String logMessage; + + RecordHttpResponse(String code, String clientMessage, String logMessage) { + this.code = code; + this.clientMessage = clientMessage; + this.logMessage = logMessage; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getClientMessage() { + return this.clientMessage; + } + + @Override + public String getLogMessage() { + return this.logMessage; + } + + @Override + public HttpStatus getHttpStatusCode() { + return null; + } +} + + From b5d4dc6862e2e6331ae2a434a0a3ad369c447165 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Thu, 27 Mar 2025 16:14:18 +0900 Subject: [PATCH 06/11] =?UTF-8?q?:sparkles:=20feat=20:=20Egg=20EMPTY=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/runimo/runimo/item/domain/Egg.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/org/runimo/runimo/item/domain/Egg.java b/src/main/java/org/runimo/runimo/item/domain/Egg.java index 372def4f..6f0125cf 100644 --- a/src/main/java/org/runimo/runimo/item/domain/Egg.java +++ b/src/main/java/org/runimo/runimo/item/domain/Egg.java @@ -9,10 +9,13 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import static org.runimo.runimo.common.GlobalConsts.EMPTYFIELD; + @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Egg extends Item { + public static final Egg EMPTY = new Egg(EMPTYFIELD, EMPTYFIELD, EMPTYFIELD, EMPTYFIELD, null, 0L); @Column(name = "egg_type") @Enumerated(EnumType.STRING) private EggType eggType; From 19c11d4d40b398d84c0b151b107acd0f8f33ee37 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Thu, 27 Mar 2025 16:14:41 +0900 Subject: [PATCH 07/11] =?UTF-8?q?:white=5Fcheck=5Fmark:=20test=20:=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/runimo/runimo/CleanUpUtil.java | 3 +- .../org/runimo/runimo/rewards/RewardTest.java | 2 +- .../rewards/api/RewardAcceptanceTest.java | 135 ++++++++++++++++++ .../rewards/service/RewardServiceTest.java | 94 ++++++++++++ .../org/runimo/runimo/user/UserFixtures.java | 12 ++ 5 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 src/test/java/org/runimo/runimo/rewards/api/RewardAcceptanceTest.java create mode 100644 src/test/java/org/runimo/runimo/rewards/service/RewardServiceTest.java diff --git a/src/test/java/org/runimo/runimo/CleanUpUtil.java b/src/test/java/org/runimo/runimo/CleanUpUtil.java index 460aa398..ada387e7 100644 --- a/src/test/java/org/runimo/runimo/CleanUpUtil.java +++ b/src/test/java/org/runimo/runimo/CleanUpUtil.java @@ -10,7 +10,8 @@ public class CleanUpUtil { private static final String[] USER_TABLES = { "user_item", "oauth_accounts", - "users" + "users", + "running_records", }; @Autowired diff --git a/src/test/java/org/runimo/runimo/rewards/RewardTest.java b/src/test/java/org/runimo/runimo/rewards/RewardTest.java index 06a98286..7449a8bc 100644 --- a/src/test/java/org/runimo/runimo/rewards/RewardTest.java +++ b/src/test/java/org/runimo/runimo/rewards/RewardTest.java @@ -60,7 +60,7 @@ void tearDown() { @Test void 보상_요청_테스트() { RecordCreateCommand recordCreateCommand = new RecordCreateCommand( - savedUser.getPublicId(), + savedUser.getId(), LocalDateTime.now(), LocalDateTime.now().plusHours(1), new Pace(1909L), diff --git a/src/test/java/org/runimo/runimo/rewards/api/RewardAcceptanceTest.java b/src/test/java/org/runimo/runimo/rewards/api/RewardAcceptanceTest.java new file mode 100644 index 00000000..4aa97e2a --- /dev/null +++ b/src/test/java/org/runimo/runimo/rewards/api/RewardAcceptanceTest.java @@ -0,0 +1,135 @@ +package org.runimo.runimo.rewards.api; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.ValidatableResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.runimo.runimo.CleanUpUtil; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.records.controller.requests.RecordSaveRequest; +import org.runimo.runimo.rewards.controller.requests.RewardClaimRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; + +import java.time.LocalDateTime; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.notNullValue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class RewardAcceptanceTest { + + @LocalServerPort + private int port; + @Autowired + private JwtTokenFactory jwtTokenFactory; + + @Autowired + private CleanUpUtil cleanUpUtil; + @Autowired + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @AfterEach() + void tearDown() { + cleanUpUtil.cleanUpUserInfos(); + } + + + @Test + @Sql(scripts = "/sql/user_item_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 달리기_기록_저장_후_주간_첫번째_달리기보상_수령() throws JsonProcessingException { + String header = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + RecordSaveRequest request = new RecordSaveRequest( + LocalDateTime.now(), + LocalDateTime.now().plusMinutes(20), + 1000L, + 1000L); + ValidatableResponse res = given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(request)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/records") + .then() + .log().ifValidationFails() + .statusCode(HttpStatus.CREATED.value()) + .body("payload", notNullValue()) + .body("payload.saved_id", notNullValue()); + + Integer recordId = res.extract().path("payload.saved_id"); + + RewardClaimRequest rewardClaimRequest = new RewardClaimRequest(Long.valueOf(recordId)); + + given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(rewardClaimRequest)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/rewards/runnings") + .then() + .log().all() + .body("payload", notNullValue()); + } + + @Test + @Sql(scripts = "/sql/user_item_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 달리기_보상_수령_후_재시도_시_예외() throws JsonProcessingException { + String header = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + RecordSaveRequest request = new RecordSaveRequest( + LocalDateTime.now(), + LocalDateTime.now().plusMinutes(20), + 1000L, + 1000L); + ValidatableResponse res = given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(request)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/records") + .then() + .log().ifValidationFails() + .statusCode(HttpStatus.CREATED.value()) + .body("payload", notNullValue()) + .body("payload.saved_id", notNullValue()); + + Integer recordId = res.extract().path("payload.saved_id"); + + RewardClaimRequest rewardClaimRequest = new RewardClaimRequest(Long.valueOf(recordId)); + + given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(rewardClaimRequest)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/rewards/runnings") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .body("payload", notNullValue()); + + given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(rewardClaimRequest)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/rewards/runnings") + .then() + .log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + +} diff --git a/src/test/java/org/runimo/runimo/rewards/service/RewardServiceTest.java b/src/test/java/org/runimo/runimo/rewards/service/RewardServiceTest.java new file mode 100644 index 00000000..a28f2770 --- /dev/null +++ b/src/test/java/org/runimo/runimo/rewards/service/RewardServiceTest.java @@ -0,0 +1,94 @@ +package org.runimo.runimo.rewards.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.runimo.runimo.common.scale.Distance; +import org.runimo.runimo.item.domain.Egg; +import org.runimo.runimo.item.domain.EggType; +import org.runimo.runimo.records.domain.RunningRecord; +import org.runimo.runimo.records.service.RecordFinder; +import org.runimo.runimo.rewards.service.dtos.RewardClaimCommand; +import org.runimo.runimo.rewards.service.dtos.RewardResponse; +import org.runimo.runimo.rewards.service.eggs.EggGrantService; +import org.runimo.runimo.user.UserFixtures; +import org.runimo.runimo.user.service.UserFinder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.mockito.MockitoAnnotations.openMocks; + +class RewardServiceTest { + + private RewardService rewardService; + @Mock + private RecordFinder recordFinder; + @Mock + private UserFinder userFinder; + @Mock + private EggGrantService eggGrantService; + + private static RunningRecord getRunningRecordWithIds(Long userId, Long recordId, boolean isRewarded) { + RunningRecord runningRecord = RunningRecord.builder() + .userId(userId) + .isRewarded(isRewarded) + .totalDistance(new Distance(1000L)) + .startedAt(LocalDateTime.now()) + .build(); + ReflectionTestUtils.setField(runningRecord, "id", recordId); + return runningRecord; + } + + @BeforeEach + void setUp() { + openMocks(this); + rewardService = new RewardService(recordFinder, userFinder, eggGrantService); + when(userFinder.findUserById(any())).thenReturn(Optional.of(UserFixtures.getUserWithId(1L))); + } + + @Test + void 보상을_받지_않은_이번주_첫_기록이라면_알을_지급한다() { + RunningRecord unRewardedRecord = getRunningRecordWithIds(1L, 1L, false); + RewardClaimCommand command = new RewardClaimCommand(1L, 1L); + + when(recordFinder.findById(any())).thenReturn(java.util.Optional.of(unRewardedRecord)); + when(recordFinder.findFirstRunOfCurrentWeek(any())).thenReturn(java.util.Optional.of(unRewardedRecord)); + when(eggGrantService.grantRandomEggToUser(any())).thenReturn(Egg.builder().eggType(EggType.MADANG).build()); + + RewardResponse res = rewardService.claimReward(command); + assertNotNull(res); + assertEquals(EggType.MADANG, res.eggType()); + } + + @Test + void 이미_보상을_받은_기록이면_예외를_던진다() { + RunningRecord alreadyRewardedRecord = getRunningRecordWithIds(1L, 1L, true); + RewardClaimCommand command = new RewardClaimCommand(1L, 1L); + + when(recordFinder.findById(any())).thenReturn(java.util.Optional.of(alreadyRewardedRecord)); + when(recordFinder.findFirstRunOfCurrentWeek(any())).thenReturn(java.util.Optional.of(alreadyRewardedRecord)); + when(eggGrantService.grantRandomEggToUser(any())).thenReturn(Egg.builder().eggType(EggType.MADANG).build()); + + assertThrows(IllegalStateException.class, () -> rewardService.claimReward(command)); + } + + @Test + void 이번주_첫_기록이_아니면_알을_지급하지_않는다() { + RunningRecord unRewardedRecord = getRunningRecordWithIds(1L, 1L, false); + RunningRecord anotherRecord = getRunningRecordWithIds(1L, 2L, false); + RewardClaimCommand command = new RewardClaimCommand(1L, 1L); + + when(recordFinder.findById(any())).thenReturn(java.util.Optional.of(unRewardedRecord)); + when(recordFinder.findFirstRunOfCurrentWeek(any())).thenReturn(Optional.of(anotherRecord)); + + RewardResponse res = rewardService.claimReward(command); + verify(eggGrantService, never()).grantRandomEggToUser(any()); + assertEquals(Egg.EMPTY.getItemCode(), res.eggCode()); + assertNull(res.eggType()); + } +} \ No newline at end of file diff --git a/src/test/java/org/runimo/runimo/user/UserFixtures.java b/src/test/java/org/runimo/runimo/user/UserFixtures.java index 4800024a..d7e6469a 100644 --- a/src/test/java/org/runimo/runimo/user/UserFixtures.java +++ b/src/test/java/org/runimo/runimo/user/UserFixtures.java @@ -1,6 +1,7 @@ package org.runimo.runimo.user; import org.runimo.runimo.user.domain.User; +import org.springframework.test.util.ReflectionTestUtils; public final class UserFixtures { @@ -12,4 +13,15 @@ public static User getDefaultUser() { .totalTimeInSeconds(0L) .build(); } + + public static User getUserWithId(Long id) { + User user = User.builder() + .nickname("test") + .imgUrl("test") + .totalDistanceInMeters(0L) + .totalTimeInSeconds(0L) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } } From 0b41442b9b6bbbef6f892434246b8cdf2cd9b316 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Thu, 27 Mar 2025 16:15:13 +0900 Subject: [PATCH 08/11] =?UTF-8?q?:sparkles:=20=20feat=20:=20=EB=B9=88=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EB=A5=BC=20=EC=9D=98=EB=AF=B8=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=83=81=EC=88=98=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/runimo/runimo/common/GlobalConsts.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/runimo/runimo/common/GlobalConsts.java b/src/main/java/org/runimo/runimo/common/GlobalConsts.java index 9ba476a4..ae828406 100644 --- a/src/main/java/org/runimo/runimo/common/GlobalConsts.java +++ b/src/main/java/org/runimo/runimo/common/GlobalConsts.java @@ -16,4 +16,6 @@ public final class GlobalConsts { "/v3/api-docs" ); + public static final String EMPTYFIELD = "EMPTY"; + } From 5a9a02697647dbdf75dc57099588bbac7630c268 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Thu, 27 Mar 2025 16:15:44 +0900 Subject: [PATCH 09/11] =?UTF-8?q?:memo:=20docs=20:=20Reward=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20README.md=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/runimo/runimo/rewards/README.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/main/java/org/runimo/runimo/rewards/README.md diff --git a/src/main/java/org/runimo/runimo/rewards/README.md b/src/main/java/org/runimo/runimo/rewards/README.md new file mode 100644 index 00000000..302edc3e --- /dev/null +++ b/src/main/java/org/runimo/runimo/rewards/README.md @@ -0,0 +1,34 @@ +# 보상 지급관련 패키지 + +## 요구사항 + +### 1. 주간 첫번째 달리기를 마치면 알을 지급한다. + +- 사용자는 주마다 첫번째 달리기를 마치면 `알`을 보상으로 받는다. + - ex) 1주일동안 3번 달렸다면, 1번째 달린 날에 `알`을 받는다. + + +- `알`은 사용자의 누적 달리기 거리에 따라 출현 확률이 결정된다. + - ex) 사용자가 30km를 누적으로 달렸다면 2가지 알이 해금되었고, 1/2의 확률로 각각의 알이 출현한다. + +#### 제한 사항: +- 주간은 월요일 ~ 일요일 단위로 계산한다. +- 주간 첫번째 달리기는 월요일 00:00:00 ~ 일요일 23:59:59 사이에 달려야 한다. +- 사용자는 주간 첫번째 달리기를 마치면 `알`을 받는다. +- 이미 달린 경우 알을 받을 수 없다. +- **알 지급에 대한 기록을 남겨야한다.** + + +### 2. 매 달리기마다 애정을 지급한다. + +- 사용자는 매 달리기마다 `애정`을 보상으로 받는다. + + +- `애정`은 1km에 1개씩 지급된다. + + +- 달린거리를 `내림` 하여 `애정`을 지급한다. + - ex) 3.5km를 달렸다면 3개의 `애정`을 받는다. + - ex) 3.1km를 달렸다면 3개의 `애정`을 받는다. + - ex) 2.9km를 달렸다면 2개의 `애정`을 받는다. + From d6c0b80e660421d07b8055c207a6b3cfcc5b4332 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Thu, 27 Mar 2025 16:32:34 +0900 Subject: [PATCH 10/11] =?UTF-8?q?:bug:=20fix=20:=20=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=8B=AC=EB=A6=AC=EA=B8=B0=20=EC=8B=9C=EA=B0=84=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/runimo/runimo/records/repository/RecordRepository.java | 3 +-- .../java/org/runimo/runimo/records/service/RecordFinder.java | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java b/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java index d3acc66e..58274226 100644 --- a/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java +++ b/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java @@ -17,8 +17,7 @@ public interface RecordRepository extends JpaRepository { @Query("SELECT r FROM RunningRecord r " + "WHERE r.userId = :userId " + - "AND r.startedAt BETWEEN :startOfWeek AND :now " + - "ORDER BY r.startedAt ASC") + "AND r.startedAt BETWEEN :startOfWeek AND :now") Slice findFirstRunOfWeek( @Param("userId") Long userId, @Param("startOfWeek") LocalDateTime startOfWeek, diff --git a/src/main/java/org/runimo/runimo/records/service/RecordFinder.java b/src/main/java/org/runimo/runimo/records/service/RecordFinder.java index 14348342..d0427814 100644 --- a/src/main/java/org/runimo/runimo/records/service/RecordFinder.java +++ b/src/main/java/org/runimo/runimo/records/service/RecordFinder.java @@ -31,7 +31,7 @@ public Optional findByPublicId(String id) { public Optional findFirstRunOfCurrentWeek(Long userId) { LocalDateTime now = LocalDateTime.now(); LocalDateTime startOfWeek = now.minusDays(now.getDayOfWeek().getValue() - 1L).withHour(0).withMinute(0).withSecond(0); - PageRequest pageRequest = PageRequest.of(0, 1, Sort.by("createdAt").ascending()); + PageRequest pageRequest = PageRequest.of(0, 1, Sort.by("startedAt").ascending()); return recordRepository.findFirstRunOfWeek(userId, startOfWeek, now, pageRequest).stream().findFirst(); } } From 938ce83795052bab0e97ba3861e0e512353baf54 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Thu, 27 Mar 2025 16:33:13 +0900 Subject: [PATCH 11/11] =?UTF-8?q?:white=5Fcheck=5Fmark:=20test=20:=20?= =?UTF-8?q?=EC=B2=AB=EB=B2=88=EC=A7=B8=20=EB=8B=AC=EB=A6=AC=EA=B8=B0?= =?UTF-8?q?=EA=B0=80=20=EC=95=84=EB=8B=8C=EA=B2=BD=EC=9A=B0=20=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=EA=B8=89=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rewards/api/RewardAcceptanceTest.java | 73 +++++++++++++++++-- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/src/test/java/org/runimo/runimo/rewards/api/RewardAcceptanceTest.java b/src/test/java/org/runimo/runimo/rewards/api/RewardAcceptanceTest.java index 4aa97e2a..6528d4fc 100644 --- a/src/test/java/org/runimo/runimo/rewards/api/RewardAcceptanceTest.java +++ b/src/test/java/org/runimo/runimo/rewards/api/RewardAcceptanceTest.java @@ -22,7 +22,7 @@ import java.time.LocalDateTime; import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.*; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") @@ -38,6 +38,8 @@ class RewardAcceptanceTest { @Autowired private ObjectMapper objectMapper; + private static final LocalDateTime pivotTime = LocalDateTime.of(2023, 10, 1, 10, 0); + @BeforeEach void setUp() { RestAssured.port = port; @@ -54,8 +56,8 @@ void tearDown() { void 달리기_기록_저장_후_주간_첫번째_달리기보상_수령() throws JsonProcessingException { String header = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); RecordSaveRequest request = new RecordSaveRequest( - LocalDateTime.now(), - LocalDateTime.now().plusMinutes(20), + pivotTime, + pivotTime.plusMinutes(20), 1000L, 1000L); ValidatableResponse res = given() @@ -90,8 +92,8 @@ void tearDown() { void 달리기_보상_수령_후_재시도_시_예외() throws JsonProcessingException { String header = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); RecordSaveRequest request = new RecordSaveRequest( - LocalDateTime.now(), - LocalDateTime.now().plusMinutes(20), + pivotTime, + pivotTime.plusMinutes(20), 1000L, 1000L); ValidatableResponse res = given() @@ -132,4 +134,65 @@ void tearDown() { .statusCode(HttpStatus.BAD_REQUEST.value()); } + @Test + @Sql(scripts = "/sql/user_item_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 첫번째_기록이_이번주_첫번째_달리기가_아니라서_알_미지급() throws JsonProcessingException { + String header = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + + // 첫번째 기록 저장 + RecordSaveRequest firstRequest = new RecordSaveRequest( + pivotTime, + pivotTime.plusMinutes(20), + 1000L, + 1000L); + ValidatableResponse firstRes = given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(firstRequest)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/records") + .then() + .log().ifValidationFails() + .statusCode(HttpStatus.CREATED.value()) + .body("payload", notNullValue()) + .body("payload.saved_id", notNullValue()); + + Integer firstRecordId = firstRes.extract().path("payload.saved_id"); + + // 두번째 기록 저장 (startedAt이 더 빠르게) + RecordSaveRequest secondRequest = new RecordSaveRequest( + pivotTime.minusDays(1), + pivotTime.minusDays(1).plusMinutes(20), + 1000L, + 1000L); + given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(secondRequest)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/records") + .then() + .log().ifValidationFails() + .statusCode(HttpStatus.CREATED.value()) + .body("payload", notNullValue()) + .body("payload.saved_id", notNullValue()); + + // 첫번째 기록의 id로 보상 요청 + RewardClaimRequest rewardClaimRequest = new RewardClaimRequest(Long.valueOf(firstRecordId)); + + given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(rewardClaimRequest)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/rewards/runnings") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .body("payload.egg_type", nullValue()) + .body("payload.egg_code", equalTo("EMPTY")); + } + + + }